Redis GEO地理位置应用

Redis GEO地理位置应用

Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。

一、GEO基本原理

#

1.1 底层实现

Redis GEO基于Sorted Set(ZSet)实现:

地理位置编码:
- 将经纬度(二维)通过GeoHash算法编码为52位整数
- 经度、纬度各编码26位,交错合并
- 编码值作为ZSet的score

存储结构:
ZSet key: "locations"
member: "shop:1001", score: GeoHash编码值
member: "shop:1002", score: GeoHash编码值

#

1.2 GeoHash算法

GeoHash将地球表面递归划分为网格:

第1次划分:经度2份,纬度2份 → 4个区域
第2次划分:每个区域再分4份 → 16个区域
...递归n次

编码后的值相邻表示地理位置相近
(但不绝对,存在边界问题)

示例:
北京天安门:wx4g0b7x
北京王府井:wx4g0c8v
上海外滩:wtw3sjq6

二、GEO命令

#

2.1 添加位置

# GEOADD key longitude latitude member [longitude latitude member ...]
GEOADD shops 116.4074 39.9042 "shop:1001" # 北京
GEOADD shops 121.4737 31.2304 "shop:1002" # 上海
GEOADD shops 113.2644 23.1291 "shop:1003" # 广州
GEOADD shops 114.0579 22.5431 "shop:1004" # 深圳

# 查看编码值(底层就是ZSet)
ZRANGE shops 0 -1 WITHSCORES

#

2.2 获取位置

# GEOPOS key member [member ...]
GEOPOS shops shop:1001
# 1) 1) "116.40740543603897" # 经度
# 2) "39.904211364871584" # 纬度

GEOPOS shops shop:1001 shop:1002

#

2.3 计算距离

# GEODIST key member1 member2 [M|KM|FT|MI]
GEODIST shops shop:1001 shop:1002 KM
# "1067.3788" # 北京到上海约1067公里

GEODIST shops shop:1003 shop:1004 KM
# "119.0316" # 广州到深圳约119公里

GEODIST shops shop:1001 shop:1001 M
# "0" # 同一点距离为0

#

2.4 范围查询

# GEORADIUS key longitude latitude radius M|KM|FT|MI [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]

# 查询北京(116.40, 39.90)附近500公里内的店铺
GEORADIUS shops 116.40 39.90 500 KM WITHDIST
# 1) 1) "shop:1001"
# 2) "0.0981" # 距离约0.1公里(北京)

# 查询北京附近1000公里内的店铺,按距离排序
GEORADIUS shops 116.40 39.90 1000 KM WITHDIST ASC COUNT 5

# 查询北京附近2000公里内的店铺,返回坐标
GEORADIUS shops 116.40 39.90 2000 KM WITHCOORD WITHDIST
# 1) 1) "shop:1001"
# 2) "0.0981"
# 3) 1) "116.40740543603897"
# 2) "39.904211364871584"
# 2) 1) "shop:1003"
# 2) "1888.4633"
# 3) 1) "113.26439946889877"
# 2) "23.129099598793785"

#

2.5 基于成员的范围查询

# GEORADIUSBYMEMBER key member radius M|KM|FT|MI [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]

# 查询shop:1001(北京)附近1500公里内的店铺
GEORADIUSBYMEMBER shops shop:1001 1500 KM WITHDIST

#

2.6 获取GeoHash

# GEOHASH key member [member ...]
GEOHASH shops shop:1001 shop:1002
# 1) "wx4g0b7x"
# 2) "wtw3sjq6"

三、Java中使用Redis GEO

#

3.1 Spring Data Redis

@Service
public class GeoService {

@Autowired
private StringRedisTemplate redis;

/**
* 添加地理位置
*/
public Long addLocation(String key, String member, double longitude, double latitude) {
return redis.opsForGeo().add(key, new Point(longitude, latitude), member);
}

/**
* 批量添加位置
*/
public Long addLocations(String key, Map<String, Point> memberLocations) {
Map<Object, Point> map = new HashMap<>(memberLocations);
return redis.opsForGeo().add(key, map);
}

/**
* 查询附近的位置
*/
public List<GeoResult<RedisGeoCommands.GeoLocation<String>>> findNearby(
String key, double longitude, double latitude, double radiusKm, int limit) {

Circle circle = new Circle(new Point(longitude, latitude),
new Distance(radiusKm, Metrics.KILOMETERS));

RedisGeoCommands.GeoRadiusCommandArgs args =
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance()
.includeCoordinates()
.sortAscending()
.limit(limit);

GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redis.opsForGeo().radius(key, circle, args);

return results.getContent();
}

/**
* 查询指定成员附近的位置
*/
public List<GeoResult<RedisGeoCommands.GeoLocation<String>>> findNearbyByMember(
String key, String member, double radiusKm, int limit) {

Distance distance = new Distance(radiusKm, Metrics.KILOMETERS);

RedisGeoCommands.GeoRadiusCommandArgs args =
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance()
.sortAscending()
.limit(limit);

GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redis.opsForGeo().radius(key, member, distance, args);

return results.getContent();
}

/**
* 计算两个位置的距离
*/
public Distance calculateDistance(String key, String member1, String member2) {
return redis.opsForGeo().distance(key, member1, member2, Metrics.KILOMETERS);
}

/**
* 获取位置的经纬度
*/
public List<Point> getPositions(String key, String... members) {
return redis.opsForGeo().position(key, members);
}

/**
* 删除位置
*/
public Long removeLocation(String key, String... members) {
return redis.opsForGeo().remove(key, (Object[]) members);
}
}

#

3.2 实际应用:附近的人

@Service
public class NearbyService {

@Autowired
private GeoService geoService;
@Autowired
private UserMapper userMapper;

private static final String USER_LOCATIONS = "user:locations";

/**
* 更新用户位置
*/
public void updateLocation(Long userId, double longitude, double latitude) {
geoService.addLocation(USER_LOCATIONS, "user:" + userId, longitude, latitude);

// 同时更新数据库(持久化)
userMapper.updateLocation(userId, longitude, latitude);
}

/**
* 查找附近的人
*/
public List<NearbyUser> findNearbyUsers(Long userId, double radiusKm, int limit) {
// 获取自己的位置
List<Point> positions = geoService.getPositions(USER_LOCATIONS, "user:" + userId);
if (positions.isEmpty() || positions.get(0) == null) {
return Collections.emptyList();
}

Point myLocation = positions.get(0);

// 查询附近的人
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results =
geoService.findNearby(USER_LOCATIONS, myLocation.getX(), myLocation.getY(), radiusKm, limit + 1);

List<NearbyUser> nearbyUsers = new ArrayList<>();
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
String member = result.getContent().getName();
if (member.equals("user:" + userId)) {
continue; // 排除自己
}

Long nearbyUserId = Long.parseLong(member.replace("user:", ""));
double distance = result.getDistance().getValue();

nearbyUsers.add(new NearbyUser(nearbyUserId, distance));
}

return nearbyUsers;
}

/**
* 查找附近的商家
*/
public List<NearbyShop> findNearbyShops(double longitude, double latitude,
double radiusKm, int limit) {
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results =
geoService.findNearby("shops", longitude, latitude, radiusKm, limit);

List<NearbyShop> shops = new ArrayList<>();
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : results) {
String member = result.getContent().getName();
Long shopId = Long.parseLong(member.replace("shop:", ""));
double distance = result.getDistance().getValue();

shops.add(new NearbyShop(shopId, distance));
}

return shops;
}
}

#

3.3 实际应用:配送范围

@Service
public class DeliveryService {

@Autowired
private GeoService geoService;

private static final String DELIVERY_POINTS = "delivery:points";

/**
* 检查地址是否在配送范围内
*/
public boolean isInDeliveryRange(double longitude, double latitude, double maxDistanceKm) {
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results =
geoService.findNearby(DELIVERY_POINTS, longitude, latitude, maxDistanceKm, 1);

return !results.isEmpty();
}

/**
* 查找最近的配送点
*/
public DeliveryPoint findNearestPoint(double longitude, double latitude) {
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results =
geoService.findNearby(DELIVERY_POINTS, longitude, latitude, 100, 1);

if (results.isEmpty()) {
return null;
}

String member = results.get(0).getContent().getName();
double distance = results.get(0).getDistance().getValue();

return new DeliveryPoint(member, distance);
}
}

四、性能优化

#

4.1 数据分片

@Service
public class ShardedGeoService {

private static final int SHARD_COUNT = 10;

/**
* 根据位置计算分片
*/
private String getShardKey(double longitude, double latitude) {
// 使用GeoHash前缀作为分片依据
// 或使用城市编码
int shard = (int) ((longitude + 180) / 360 * SHARD_COUNT);
return "locations:shard:" + shard;
}

public void addLocation(String member, double longitude, double latitude) {
String shardKey = getShardKey(longitude, latitude);
geoService.addLocation(shardKey, member, longitude, latitude);
}

public List<GeoResult<RedisGeoCommands.GeoLocation<String>>> findNearby(
double longitude, double latitude, double radiusKm, int limit) {
// 查询相邻的几个分片
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> allResults = new ArrayList<>();

for (int i = -1; i <= 1; i++) {
int shard = (int) ((longitude + 180) / 360 * SHARD_COUNT) + i;
if (shard >= 0 && shard < SHARD_COUNT) {
String shardKey = "locations:shard:" + shard;
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results =
geoService.findNearby(shardKey, longitude, latitude, radiusKm, limit);
allResults.addAll(results);
}
}

// 按距离排序并取前N个
allResults.sort(Comparator.comparingDouble(r -> r.getDistance().getValue()));
return allResults.stream().limit(limit).collect(Collectors.toList());
}
}

#

4.2 定期清理过期位置

@Component
public class GeoCleanupService {

@Autowired
private StringRedisTemplate redis;

/**
* 清理超过30分钟未更新的位置
*/
@Scheduled(fixedRate = 600000) // 每10分钟
public void cleanupStaleLocations() {
// GEO不支持按score范围删除
// 方案1:使用辅助ZSet记录更新时间
// 方案2:定期全量重建
}
}

#

4.3 使用辅助数据结构

@Service
public class OptimizedGeoService {

@Autowired
private StringRedisTemplate redis;

/**
* 添加位置时同时更新辅助索引
*/
public void addLocationWithIndex(String key, String member,
double longitude, double latitude, long timestamp) {
// 添加GEO位置
redis.opsForGeo().add(key, new Point(longitude, latitude), member);

// 辅助ZSet记录更新时间
redis.opsForZSet().add(key + ":lastUpdate", member, timestamp);

// 辅助Hash存储详细信息
Map<String, String> info = new HashMap<>();
info.put("longitude", String.valueOf(longitude));
info.put("latitude", String.valueOf(latitude));
info.put("updateTime", String.valueOf(timestamp));
redis.opsForHash().putAll(key + ":info:" + member, info);
}

/**
* 清理过期位置
*/
public void cleanupExpired(String key, long expireBefore) {
// 使用辅助ZSet找出过期的成员
Set<String> expired = redis.opsForZSet().rangeByScore(
key + ":lastUpdate", 0, expireBefore);

if (expired != null && !expired.isEmpty()) {
// 从GEO中删除
redis.opsForGeo().remove(key, expired.toArray());

// 从辅助ZSet中删除
redis.opsForZSet().remove(key + ":lastUpdate", expired.toArray());

// 删除辅助Hash
for (String member : expired) {
redis.delete(key + ":info:" + member);
}
}
}
}

五、注意事项

#

5.1 边界问题

GeoHash的边界问题:
- 两个位置GeoHash编码不同,但可能非常近(跨网格边界)
- GEORADIUS命令会处理这个问题(查询相邻网格)
- 但极端情况下仍可能有遗漏

建议:
- 查询半径适当放大(如实际500米,查询550米)
- 对结果进行二次精确计算

#

5.2 距离精度

# Redis GEO使用球面模型计算距离
# 默认使用WGS84地球半径(6372797.56米)
# 对于短距离(<100km)精度足够
# 长距离误差约0.5%

# 如果需要更高精度,可以:
# 1. 使用Redis结果做初步筛选
# 2. 应用层使用Haversine公式精确计算

#

5.3 大数量限制

单key存储的GEO位置数量:
- 理论无限制(受内存限制)
- 建议单key不超过100万个位置
- 超过时考虑按城市/区域分片

六、总结

命令 功能 时间复杂度
GEOADD 添加位置 O(log n)
GEOPOS 获取位置 O(log n)
GEODIST 计算距离 O(log n)
GEORADIUS 范围查询 O(n+log m)
GEORADIUSBYMEMBER 基于成员范围查询 O(log n + m)
GEOHASH 获取GeoHash O(log n)

GEO使用建议:

  1. 适合场景:附近查询、距离计算、范围判断
  2. 不适合场景:精确路径规划、复杂几何计算
  3. 分片策略:大数据量按区域分片
  4. 辅助结构:结合ZSet/Hash管理元数据
  5. 精度处理:短距离足够,长距离可二次精确

核心要点

  1. String:简单的键值对,适合缓存、计数器

  2. Hash:存储对象属性,适合用户信息、配置

  3. List:有序列表,适合消息队列、最新列表

  4. Set:无序去重,适合共同好友、抽奖

  5. ZSet:有序集合,适合排行榜、积分系统

总结

选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。


   转载规则


《Redis GEO地理位置应用》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录