📍 设计一个地理位置附近的人功能:雷达的魔法!

41 阅读8分钟

📖 开场:雷达探测

想象你是军舰上的雷达员 🚢:

没有雷达(盲人摸象)

你:想找附近的船
    ↓
方法:开船到处找 🚢
    ↓
找了3天,一无所获 ❌

结果:
- 效率低 ❌
- 浪费时间 ❌

有雷达(一目了然)

你:打开雷达 📡
    ↓
雷达:显示附近10公里内的所有船
    ↓
屏幕上清晰显示:
- 东北方向5公里:货船
- 西南方向3公里:渔船
- 正北方向8公里:油轮
    ↓
一目了然 ✅

结果:
- 高效 ✅
- 精准 ✅

这就是LBS(基于位置的服务):附近的人/附近的店!


🤔 核心挑战

挑战1:如何快速查询附近的人? 🤔

问题:
1亿用户
    ↓
查询我附近5公里的人
    ↓
暴力方法:遍历1亿用户,计算距离 💀
    ↓
太慢了!❌

解决:
GeoHash + Redis GEO ✅

挑战2:如何计算距离? 📏

地球是圆的,不是平面 🌍
    ↓
不能用勾股定理
    ↓
需要用:球面距离公式

🎯 核心技术

技术1:GeoHash算法 ⭐⭐⭐

GeoHash原理

GeoHash:
将地球划分成网格,每个网格一个编码

例子:
北京天安门:
经度:116.404
纬度:39.915
    ↓
GeoHash编码:wx4g0e

编码特点:
- 编码越长,精度越高
- 编码相同,位置越近
- 前缀相同,位置相近 ✅

GeoHash精度

| 长度 | 纬度误差 | 经度误差 | 大小 |
|-----|---------|---------|------|
| 1   | ±23km   | ±23km   | 5000km |
| 2   | ±2.8km  | ±5.6km  | 630km  |
| 3   | ±0.7km  | ±0.7km  | 78km   |
| 4   | ±0.087km| ±0.18km | 20km   |
| 5   | ±22m    | ±22m    | 2.4km  |
| 6   | ±2.7m   | ±5.5m   | 610m   |
| 7   | ±0.67m  | ±0.67m  | 76m    |

编码过程

以北京天安门为例:
经度:116.404(范围:-180 ~ 180
纬度:39.915(范围:-90 ~ 90

1. 经度二分:
   -180 ~ 180,中间值0
   116.404 > 0,取右半,编码1
   
   0 ~ 180,中间值90
   116.404 > 90,取右半,编码1
   
   90 ~ 180,中间值135
   116.404 < 135,取左半,编码0
   
   ... 重复多次
   最终:11010010110...

2. 纬度二分:
   -90 ~ 90,中间值0
   39.915 > 0,取右半,编码1
   
   0 ~ 90,中间值45
   39.915 < 45,取左半,编码0
   
   ... 重复多次
   最终:10111000110...

3. 交叉合并:
   经度:1 1 0 1 0 0 1 0 1 1 0...
   纬度:  1 0 1 1 1 0 0 0 1 1...
   合并:1110 0111 1000 1010 1010...
   
4. Base32编码:
   1110 0111 1000 1010 1010  wx4g0e

技术2:Redis GEO ⭐⭐⭐

Redis GEO命令

# ⭐ 添加位置
GEOADD users 116.404 39.915 user1
GEOADD users 116.405 39.916 user2
GEOADD users 116.406 39.917 user3

# ⭐ 查询附近的人(5公里内)
GEORADIUS users 116.404 39.915 5 km WITHDIST WITHCOORD

# 返回:
1) user1, 距离0.03km, 116.404, 39.915
2) user2, 距离0.15km, 116.405, 39.916
3) user3, 距离0.31km, 116.406, 39.917

# ⭐ 计算两点距离
GEODIST users user1 user2 km

# 返回:0.15km

Java代码实现

@Service
public class LocationService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String GEO_KEY = "user:location";
    
    /**
     * ⭐ 更新用户位置
     */
    public void updateLocation(Long userId, double longitude, double latitude) {
        redisTemplate.opsForGeo().add(
            GEO_KEY,
            new Point(longitude, latitude),
            String.valueOf(userId)
        );
    }
    
    /**
     * ⭐ 查询附近的人(5公里内)
     */
    public List<NearbyUser> getNearbyUsers(double longitude, double latitude, 
                                           double radius) {
        // 构造查询条件
        Circle circle = new Circle(new Point(longitude, latitude), 
                                  new Distance(radius, Metrics.KILOMETERS));
        
        // 查询附近的人
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = 
            redisTemplate.opsForGeo().radius(GEO_KEY, circle,
                RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                    .includeDistance()  // 包含距离
                    .includeCoordinates()  // 包含坐标
                    .sortAscending()  // 按距离排序
                    .limit(20)  // 最多20个
            );
        
        // 转换结果
        List<NearbyUser> nearbyUsers = new ArrayList<>();
        
        if (results != null) {
            for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
                Long userId = Long.valueOf(result.getContent().getName());
                double distance = result.getDistance().getValue();
                Point point = result.getContent().getPoint();
                
                // 查询用户信息
                User user = userService.getById(userId);
                
                NearbyUser nearbyUser = new NearbyUser();
                nearbyUser.setUserId(userId);
                nearbyUser.setNickname(user.getNickname());
                nearbyUser.setAvatar(user.getAvatar());
                nearbyUser.setDistance(distance);
                nearbyUser.setLongitude(point.getX());
                nearbyUser.setLatitude(point.getY());
                
                nearbyUsers.add(nearbyUser);
            }
        }
        
        return nearbyUsers;
    }
    
    /**
     * ⭐ 计算两用户之间的距离
     */
    public double getDistance(Long userId1, Long userId2) {
        Distance distance = redisTemplate.opsForGeo().distance(
            GEO_KEY,
            String.valueOf(userId1),
            String.valueOf(userId2),
            Metrics.KILOMETERS
        );
        
        return distance != null ? distance.getValue() : 0;
    }
    
    /**
     * 获取用户位置
     */
    public Point getLocation(Long userId) {
        List<Point> positions = redisTemplate.opsForGeo().position(
            GEO_KEY,
            String.valueOf(userId)
        );
        
        return positions != null && !positions.isEmpty() ? positions.get(0) : null;
    }
}

技术3:距离计算(Haversine公式)

Haversine公式

计算地球表面两点的距离

公式:
a = sin²(Δφ/2) + cos1) × cos2) × sin²(Δλ/2)
c = 2 × atan2(√a, √(1−a))
d = R × c

其中:
φ:纬度
λ:经度
R:地球半径(6371km)
d:距离

Java实现

public class GeoUtils {
    
    private static final double EARTH_RADIUS = 6371.0;  // 地球半径(千米)
    
    /**
     * ⭐ 计算两点之间的距离(Haversine公式)
     */
    public static double calculateDistance(double lon1, double lat1, 
                                          double lon2, double lat2) {
        // 将角度转换为弧度
        double lat1Rad = Math.toRadians(lat1);
        double lat2Rad = Math.toRadians(lat2);
        double deltaLat = Math.toRadians(lat2 - lat1);
        double deltaLon = Math.toRadians(lon2 - lon1);
        
        // 计算a
        double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
                  Math.cos(lat1Rad) * Math.cos(lat2Rad) *
                  Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
        
        // 计算c
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        
        // 计算距离
        return EARTH_RADIUS * c;
    }
}

技术4:附近的人接口实现

@RestController
@RequestMapping("/nearby")
public class NearbyController {
    
    @Autowired
    private LocationService locationService;
    
    /**
     * ⭐ 更新我的位置
     */
    @PostMapping("/location")
    public Result<Void> updateLocation(@RequestParam Long userId,
                                      @RequestParam double longitude,
                                      @RequestParam double latitude) {
        locationService.updateLocation(userId, longitude, latitude);
        return Result.success();
    }
    
    /**
     * ⭐ 查询附近的人
     */
    @GetMapping("/users")
    public Result<List<NearbyUser>> getNearbyUsers(
            @RequestParam Long userId,
            @RequestParam(defaultValue = "5.0") double radius) {
        
        // 1. 获取我的位置
        Point myLocation = locationService.getLocation(userId);
        
        if (myLocation == null) {
            return Result.fail("请先更新位置");
        }
        
        // 2. 查询附近的人
        List<NearbyUser> nearbyUsers = locationService.getNearbyUsers(
            myLocation.getX(),  // 经度
            myLocation.getY(),  // 纬度
            radius
        );
        
        // 3. 过滤掉自己
        nearbyUsers = nearbyUsers.stream()
            .filter(user -> !user.getUserId().equals(userId))
            .collect(Collectors.toList());
        
        return Result.success(nearbyUsers);
    }
    
    /**
     * 计算与某人的距离
     */
    @GetMapping("/distance")
    public Result<Double> getDistance(@RequestParam Long userId1,
                                     @RequestParam Long userId2) {
        double distance = locationService.getDistance(userId1, userId2);
        return Result.success(distance);
    }
}

🎓 面试题速答

Q1: GeoHash是什么?

A: 空间索引算法

将地球划分成网格,每个网格一个编码

特点:
1. 编码越长,精度越高
2. 编码相同,位置越近
3. 前缀相同,位置相近 ✅

例子:
wx4g0e(北京天安门)
wx4g0s(北京故宫)
前缀wx4g相同,距离很近 ✅

Q2: Redis GEO如何使用?

A: GEOADD + GEORADIUS

# 添加位置
GEOADD users 116.404 39.915 user1

# 查询附近5公里的人
GEORADIUS users 116.404 39.915 5 km WITHDIST

Java代码

// 添加位置
redisTemplate.opsForGeo().add(GEO_KEY, 
    new Point(longitude, latitude), userId);

// 查询附近的人
Circle circle = new Circle(new Point(lon, lat), 
    new Distance(5, Metrics.KILOMETERS));
redisTemplate.opsForGeo().radius(GEO_KEY, circle);

Q3: 如何计算两点距离?

A: Haversine公式

public static double calculateDistance(
    double lon1, double lat1, double lon2, double lat2) {
    
    double lat1Rad = Math.toRadians(lat1);
    double lat2Rad = Math.toRadians(lat2);
    double deltaLat = Math.toRadians(lat2 - lat1);
    double deltaLon = Math.toRadians(lon2 - lon1);
    
    double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
              Math.cos(lat1Rad) * Math.cos(lat2Rad) *
              Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
    
    double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    
    return 6371 * c;  // 6371是地球半径
}

Q4: Redis GEO底层原理?

A: ZSet + GeoHash

Redis GEO底层使用ZSet实现:
- member:用户ID
- score:GeoHash编码(52位整数)

原理:
1. 将经纬度转换为GeoHash
2. 存储到ZSet
3. 查询附近的人:查询相近的GeoHash

Q5: 如何优化性能?

A: 三层优化

  1. 限制结果数量
.limit(20)  // 最多返回20个
  1. 缓存用户信息
// 用户信息缓存到Redis
User user = redisTemplate.opsForValue().get("user:" + userId);
  1. 异步更新位置
// 异步更新位置到Redis
@Async
public void updateLocation(Long userId, Point point) {
    redisTemplate.opsForGeo().add(GEO_KEY, point, userId);
}

Q6: 如何处理跨国场景?

A: 分区存储

// 按国家分区
String geoKey = "user:location:" + countryCode;

// 中国用户
redisTemplate.opsForGeo().add("user:location:CN", point, userId);

// 美国用户
redisTemplate.opsForGeo().add("user:location:US", point, userId);

好处

  • 减少数据量
  • 提高查询速度

🎬 总结

       附近的人功能核心

┌────────────────────────────────────┐
│ 1. GeoHash算法 ⭐                   │
│    - 空间索引                      │
│    - 编码相近 = 位置相近           │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. Redis GEO ⭐⭐⭐                  │
│    - GEOADD添加位置                │
│    - GEORADIUS查询附近             │
│    - 底层ZSet实现                  │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. Haversine公式                   │
│    - 计算球面距离                  │
│    - 考虑地球曲率                  │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. 性能优化                        │
│    - 限制结果数量                  │
│    - 缓存用户信息                  │
│    - 异步更新位置                  │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了附近的人功能的设计!🎊

核心要点

  1. GeoHash:空间索引算法
  2. Redis GEO:GEOADD + GEORADIUS
  3. Haversine公式:计算球面距离
  4. 性能优化:限制结果、缓存、异步

下次面试,这样回答

"附近的人功能使用Redis GEO实现。Redis GEO基于GeoHash算法和ZSet数据结构。用户更新位置时,调用GEOADD命令将经纬度和用户ID存储到Redis,Redis会将经纬度编码成52位整数的GeoHash作为ZSet的score,用户ID作为member。

查询附近的人使用GEORADIUS命令。提供当前经纬度和搜索半径(如5公里),Redis会计算目标位置的GeoHash,然后在ZSet中查找score相近的member。返回结果包括用户ID、距离和坐标。Java代码使用RedisTemplate的opsForGeo方法操作,Circle对象定义查询圆形区域。

距离计算使用Haversine公式。该公式考虑了地球是球体而非平面,能准确计算两点间的球面距离。公式将经纬度转换为弧度,通过三角函数计算球面角度,最后乘以地球半径6371千米得到距离。Redis GEODIST命令内部也是使用这个公式。

性能优化方面,查询时限制返回20个用户避免数据量过大。用户基本信息缓存到Redis,避免频繁查询数据库。位置更新采用异步处理,不阻塞主线程。跨国场景按国家分区存储,如'user:location:CN'和'user:location:US',减少单个key的数据量,提高查询速度。"

面试官:👍 "很好!你对LBS功能的设计理解很深刻!"


本文完 🎬

上一篇: 221-设计一个电商库存系统.md
下一篇: 223-设计一个分布式Session管理方案.md

作者注:写完这篇,我都想去开发探探了!📍
如果这篇文章对你有帮助,请给我一个Star⭐!