地理位置服务几乎是所有社交、外卖、打车类应用的标配功能。以“附近的人”为例,系统需要实时记录每个用户的经纬度坐标,当用户发起查询时,快速找出半径若干公里内的其他用户并按距离排序。如果直接用MySQL存储经纬度,再通过SQL计算两点间距离,数据量一上来就会引发全表扫描,性能完全无法接受。Redis提供的GEO数据类型就是为这类场景而生的,它把地理位置的存取、距离计算、范围检索都封装成了原子命令,配合Redis本身的高性能,可以非常优雅地解决这个问题。

一、GEO的底层原理:Geohash与ZSet的结合
很多开发者只停留在会用GEO命令的层面,遇到线上问题就束手无策,主要原因是不了解它的底层结构。Redis的GEO并不是一个全新的数据结构,它是基于有序集合(ZSet)实现的。当你执行GEOADD命令写入一个坐标时,Redis会把二维的经纬度编码成一维的52位整数,然后把这个整数作为ZSet的score存储,成员名就是用户ID。
这个编码算法就是Geohash。它的思路是将经度范围(-180到180)和纬度范围(-85到85)不断二分,每次二分产生一个二进制位,经度和纬度的二进制位交错排列,最终得到一个比特串。比特串越长,表示的区域越小,定位越精确。两个位置编码后的整数越接近,说明它们在地理上越相近,这样就把二维的平面距离问题转化成了一维的排序问题,这正是ZSet最擅长的事情。
查询附近的人时,Redis会先把目标经纬度编码成Geohash整数,然后根据查询半径反推出需要扫描的Geohash区间,在ZSet中做范围检索,最后再对候选点逐一计算精确距离并过滤排序。这种“粗筛加精算”的两段式策略,保证了查询效率,这也是GEO能支撑高并发查询的关键。
二、GEO核心命令详解与代码示例
GEO相关的命令不多,掌握下面这几个就足以应付绝大多数场景。先看写入命令GEOADD,语法为GEOADD key longitude latitude member,经度在前纬度在后,顺序千万不能搞反,这是新手最容易犯的错误之一。
# 写入用户坐标:key为 nearby:users GEOADD nearby:users 116.404 39.915 user:1001 GEOADD nearby:users 116.410 39.920 user:1002 GEOADD nearby:users 116.398 39.909 user:1003 GEOADD nearby:users 121.473 31.230 user:2001
查询附近的人主要依赖GEOSEARCH命令(Redis 6.2之后推荐,替代了旧版的GEORADIUS)。它可以指定中心点坐标或中心成员,配合半径、单位、排序方向、返回条数等参数,一次拿到完整结果。
# 查询 116.404,39.915 附近5公里内的用户,按距离由近到远,最多返回50个,并返回距离和坐标 GEOSEARCH nearby:users FROMLONLAT 116.404 39.915 BYRADIUS 5 km ASC COUNT 50 WITHDIST WITHCOORD
如果需要计算两个用户之间的精确距离,使用GEODIST命令;需要取回某个用户的最新坐标,使用GEOPOS命令。示例如下:
# 计算两个用户的直线距离,单位为千米 GEODIST nearby:users user:1001 user:1002 km # 获取指定用户的当前坐标 GEOPOS nearby:users user:1001
下面用Java的Jedis客户端演示一个完整的附近的人查询流程,包括坐标上报和分页查询两个核心方法,实际项目中可以直接套用:
public class NearbyService {
private static final String GEO_KEY = "nearby:users";
private final Jedis jedis;
public NearbyService(Jedis jedis) {
this.jedis = jedis;
}
// 用户上报坐标,Redis会自动覆盖旧坐标
public void reportLocation(long userId, double lng, double lat) {
jedis.geoadd(GEO_KEY, lng, lat, "user:" + userId);
}
// 查询附近的人,radius单位为公里
public List<GeoRadiusResponse> findNearby(double lng, double lat, double radius, int count) {
GeoSearchParam param = new GeoSearchParam()
.fromLonLat(lng, lat)
.byRadius(radius, GeoUnit.KM)
.withCoord()
.withDist()
.ascending()
.count(count);
return jedis.geosearch(GEO_KEY, param);
}
}
三、实战方案设计与常见坑点
真实业务中,仅靠几条命令还不够,需要一套完整的方案设计。推荐的做法是:客户端每隔一段时间(比如10秒)上报一次GPS坐标,服务端收到后直接GEOADD更新,GEOADD对已存在的成员执行的是覆盖操作,天然支持坐标的动态变化。查询时先通过GEOSEARCH拿到附近用户ID列表和距离,再从用户信息表(可以用MySQL或另一个Redis Hash)批量补全昵称、头像等展示数据,最后返回给客户端。
第一个常见的坑是数据膨胀问题。如果用户下线后坐标一直留在GEO集合里,随着注册用户增多,这个ZSet会越来越大,影响内存和查询效率。解决办法是给在线状态加一层过滤:只把活跃用户写入GEO,用户退出或长时间未上报时,用ZREM命令将其移除。也可以按城市拆分多个GEO key,比如nearby:users:beijing、nearby:users:shanghai,查询时只访问用户当前城市对应的key,既控制了单个集合的大小,也避免了跨城市无效计算。
第二个坑是大半径查询。有人直接把半径设成几十公里,COUNT不限制,结果一次查询返回几万个成员,网络和序列化开销剧增,Redis主线程被长时间占用。务必给COUNT设置合理上限,比如50或100,并在客户端做好分页。此外,Geohash编码存在边界问题,两个物理上很近的点可能落在相邻的不同格子,不过Redis在实现时已经通过扫描相邻格子做了修正,使用者不需要额外处理,但如果你自己基于Geohash字符串做查询,就必须考虑九宫格补偿。
第三个坑是距离精度。GEO计算的是球面距离,精度已经足够日常使用,但要注意GEODIST支持m、km、mi、ft四种单位,返回结果如果和预期差了三个数量级,多半是单位传错了。另外,极地附近(纬度接近正负90度)的Geohash误差会明显增大,好在绝大多数业务场景不会涉及,了解即可。
总结一下,Redis GEO凭借Geohash编码和ZSet的高效范围检索能力,把附近的人这类LBS功能实现成本降到极低。核心思路就是坐标上报用GEOADD、范围查询用GEOSEARCH、精确距离用GEODIST,再配合按城市分key、限制返回条数、及时清理离线用户这三个工程手段,就能构建出一个稳定支撑高并发的附近的人服务。