一、GEO数据结构原理
1. Redis GEO 数据结构
Redis GEO 底层使用的是 Sorted Set(有序集合),使用 GeoHash 算法将二维的经纬度转换为一维的字符串。
GeoHash 算法原理:
-
将地球划分为网格
-
每个网格用二进制编码表示
-
将二进制编码转换为 base32 字符串
-
字符串越长,精度越高
实际存储格式:
java
key = "shop:geo:1" # 类型1的店铺
member = "店铺ID"
score = GeoHash编码后的整数值
二、代码逐行解析
1. 查询店铺信息
java
List<Shop> list = shopService.list();
// 假设Shop实体类包含:
// - id: Long 店铺ID
// - typeId: Long 店铺类型ID(如1:餐厅,2:酒店)
// - x: Double 经度
// - y: Double 纬度
2. 按照类型分组
java
Map<Long, List<Shop>> map = list.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
分组结果示例:
java
{
1: [Shop{id=1001, typeId=1, x=116.404, y=39.915}, Shop{id=1002, typeId=1, x=116.408, y=39.918}],
2: [Shop{id=2001, typeId=2, x=116.406, y=39.912}, Shop{id=2002, typeId=2, x=116.410, y=39.916}],
3: [Shop{id=3001, typeId=3, x=116.402, y=39.920}]
}
3. 关键:GeoLocation 详解
java
// GeoLocation的结构
public class GeoLocation<T> {
private final T name; // 成员标识(这里用店铺ID)
private final Point point; // 经纬度坐标
}
两种写入方式对比:
方式一:单条写入(注释掉的代码)
java
// 每次循环调用一次Redis,性能较差
stringRedisTemplate.opsForGeo().add(
key, // Redis键
new Point(shop.getX(), shop.getY()), // 经纬度
shop.getId().toString() // 成员名称(店铺ID)
);
方式二:批量写入(代码使用的)
java
// 1. 创建GeoLocation列表
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
// 2. 为每个店铺创建GeoLocation对象
for(Shop shop : value) {
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(), // member:店铺ID作为标识
new Point(shop.getX(), shop.getY()) // point:经纬度坐标
));
}
// 3. 批量写入Redis(一次网络请求)
stringRedisTemplate.opsForGeo().add(key, locations);
批量写入的优势:
-
减少网络IO次数
-
提高性能
-
原子性操作
三、实际数据示例
假设店铺数据:
| 店铺ID | 类型ID | 名称 | 经度(x) | 纬度(y) |
|---|---|---|---|---|
| 1001 | 1 | 星巴克 | 116.404 | 39.915 |
| 1002 | 1 | 麦当劳 | 116.408 | 39.918 |
| 2001 | 2 | 如家酒店 | 116.406 | 39.912 |
Redis 存储结果:
bash
# 类型1的店铺集合
> GEOADD shop:geo:1 116.404 39.915 "1001"
> GEOADD shop:geo:1 116.408 39.918 "1002"
# 类型2的店铺集合
> GEOADD shop:geo:2 116.406 39.912 "2001"
# 查看存储的数据结构
> TYPE shop:geo:1
zset # 实际是有序集合
> ZRANGE shop:geo:1 0 -1 WITHSCORES
1) "1001"
2) "4069885365071587" # GeoHash编码后的分数
3) "1002"
4) "4069885365805148"
四、如何使用存储的数据
1. 查询附近店铺
java
// 查询经纬度(116.405, 39.916)附近10公里内的类型1店铺
Circle circle = new Circle(
new Point(116.405, 39.916), // 中心点
new Distance(10, Metrics.KILOMETERS) // 半径10公里
);
// 按距离升序返回
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs
.newGeoRadiusArgs()
.includeDistance() // 包含距离
.includeCoordinates() // 包含坐标
.limit(10) // 限制返回数量
.sortAscending(); // 按距离升序
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
stringRedisTemplate.opsForGeo()
.radius("shop:geo:1", circle, args);
2. 获取两个店铺的距离
java
// 计算店铺1001和1002的距离
Distance distance = stringRedisTemplate.opsForGeo()
.distance("shop:geo:1", "1001", "1002", Metrics.KILOMETERS);
System.out.println(distance.getValue()); // 输出:2.356 km
3. 获取店铺的经纬度
java
// 获取店铺1001的坐标
List<Point> points = stringRedisTemplate.opsForGeo()
.position("shop:geo:1", "1001");
Point point = points.get(0);
System.out.println("经度:" + point.getX()); // 116.404
System.out.println("纬度:" + point.getY()); // 39.915
五、完整应用场景示例
场景:附近餐厅推荐
java
@Service
public class ShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 查找附近的餐厅
*/
public List<ShopVO> findNearbyRestaurants(
Double userLng, // 用户经度
Double userLat, // 用户纬度
Double radiusKm, // 搜索半径(公里)
Integer limit // 返回数量
) {
String key = "shop:geo:1"; // 类型1=餐厅
// 1. 创建搜索范围
Circle circle = new Circle(
new Point(userLng, userLat),
new Distance(radiusKm, Metrics.KILOMETERS)
);
// 2. 设置查询参数
RedisGeoCommands.GeoRadiusCommandArgs args =
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance()
.includeCoordinates()
.limit(limit)
.sortAscending();
// 3. 执行GEO查询
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults =
stringRedisTemplate.opsForGeo().radius(key, circle, args);
// 4. 解析结果
List<ShopVO> result = new ArrayList<>();
for (GeoResult<RedisGeoCommands.GeoLocation<String>> geoResult : geoResults) {
// 店铺ID
String shopId = geoResult.getContent().getName();
// 距离
Distance distance = geoResult.getDistance();
// 坐标
Point point = geoResult.getContent().getPoint();
// 创建返回对象
ShopVO shopVO = new ShopVO();
shopVO.setId(Long.parseLong(shopId));
shopVO.setDistance(distance.getValue());
shopVO.setLongitude(point.getX());
shopVO.setLatitude(point.getY());
result.add(shopVO);
}
return result;
}
/**
* 添加新店铺
*/
public void addShop(Shop shop) {
// 添加到数据库
shopService.save(shop);
// 同步到Redis GEO
String key = "shop:geo:" + shop.getTypeId();
stringRedisTemplate.opsForGeo().add(
key,
new Point(shop.getX(), shop.getY()),
shop.getId().toString()
);
}
}
六、性能优化建议
1. 数据预热
java
@Component
public class GeoDataLoader {
@PostConstruct // 项目启动时执行
public void loadGeoData() {
// 加载GEO数据到Redis
// ... 同测试代码
}
}
2. 分片策略(店铺数量多时)
java
// 按城市分片,避免单个key过大
String key = "shop:geo:" + typeId + ":" + cityCode;
3. 缓存更新策略
java
// 监听店铺信息变更
@EventListener
public void handleShopUpdate(ShopUpdateEvent event) {
Shop shop = event.getShop();
String key = "shop:geo:" + shop.getTypeId();
// 更新GEO位置
stringRedisTemplate.opsForGeo().add(
key,
new Point(shop.getX(), shop.getY()),
shop.getId().toString()
);
}
七、注意事项
1. 精度问题
bash
// Redis GEO 精度大约为 1-2 米,适合大多数应用
// 如果需要更高精度,可以考虑其他方案
2. 数据一致性
java
// 建议使用事务或分布式锁保证数据库和Redis的一致性
@Transactional
public void updateShopLocation(Long shopId, Double lng, Double lat) {
// 1. 更新数据库
shopMapper.updateLocation(shopId, lng, lat);
// 2. 更新Redis
Shop shop = shopMapper.selectById(shopId);
String key = "shop:geo:" + shop.getTypeId();
stringRedisTemplate.opsForGeo().add(
key,
new Point(lng, lat),
shopId.toString()
);
}
3. 数据清理
java
// 店铺删除时同步清理GEO数据
public void deleteShop(Long shopId) {
Shop shop = getById(shopId);
// 1. 删除数据库记录
removeById(shopId);
// 2. 删除Redis GEO数据
String key = "shop:geo:" + shop.getTypeId();
stringRedisTemplate.opsForGeo().remove(key, shopId.toString());
}
通过这样的设计,你可以实现高效的附近店铺搜索功能,查询性能远高于直接在数据库中使用距离计算函数(如MySQL的ST_Distance)。