📖 开场:雷达探测
想象你是军舰上的雷达员 🚢:
没有雷达(盲人摸象):
你:想找附近的船
↓
方法:开船到处找 🚢
↓
找了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) + cos(φ1) × cos(φ2) × 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: 三层优化:
- 限制结果数量:
.limit(20) // 最多返回20个
- 缓存用户信息:
// 用户信息缓存到Redis
User user = redisTemplate.opsForValue().get("user:" + userId);
- 异步更新位置:
// 异步更新位置到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. 性能优化 │
│ - 限制结果数量 │
│ - 缓存用户信息 │
│ - 异步更新位置 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了附近的人功能的设计!🎊
核心要点:
- GeoHash:空间索引算法
- Redis GEO:GEOADD + GEORADIUS
- Haversine公式:计算球面距离
- 性能优化:限制结果、缓存、异步
下次面试,这样回答:
"附近的人功能使用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⭐!