Redis Geo:用 Spring Boot 找最近的位置
1. 概述
在大多数外卖、打车或连锁咖啡地图应用中都能找到’找离你最近的店铺’这样的功能。你可以自己写 Haversine 公式,然后扫描整个位置表,但在数据量大时这种方法很慢。
Redis 有专门的 Geo 命令组可以解决这个问题,延迟只有几毫秒。本文将指导你如何将坐标存储到 Redis,使用 GEOSEARCH 找到最近的位置,计算两点之间的距离,并在 Spring Boot 3.x 中编写测试。
2. Redis Geo 如何工作
Redis 没有专门的 Geo 数据类型。每个坐标被编码为一个 52 位 geohash,然后作为 score 存储在 sorted set 中。地理位置接近的点通常有相近的 geohash。因此 Redis 只需扫描几个 score 范围,而不是整个数据集。
你可以用 redis-cli 快速尝试:
GEOADD coffee:hanoi 105.8547 21.0334 cafe-giang
GEOADD coffee:hanoi 105.8541 21.0251 cong-trang-tien
GEOADD coffee:hanoi 105.8128 21.0318 highlands-lotte
GEOSEARCH coffee:hanoi FROMLONLAT 105.8522 21.0287 BYRADIUS 2 km ASC WITHDIST
GEOADD 命令先接受经度,然后是纬度。GEOSEARCH 在半径 2 km 内找出离还剑湖周围的 member,按距离从近到远排序(ASC),并返回距离(WITHDIST):
1) 1) "cong-trang-tien"
2) "0.4465"
2) 1) "cafe-giang"
2) "0.5845"
highlands-lotte 距离约 4 km,所以不会出现在结果中。你的结果可能在小数位最后有所不同,因为 Redis 使用 geohash 和球体地球模型。
因为本质上是 sorted set,你仍然可以在同一个 key 上使用 ZRANGE、ZCARD、ZREM。
注意: 从 Redis 6.2 起,
GEORADIUS和GEORADIUSBYMEMBER已被标记为已弃用。对于新代码,你应该使用GEOSEARCH。
3. 安装 Spring Data Redis
将 Redis starter 添加到 pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
在 application.properties 中配置连接:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot 会自动创建 StringRedisTemplate bean。这对 Geo 来说是合适的选择,因为 member 通常是字符串形式的 ID。你通过 opsForGeo() 访问 Geo 命令组,返回 GeoOperations<String, String>。
4. 存储位置
创建一个 service 来聚集所有 Geo 操作:
@Service
public class StoreLocatorService {
static final String KEY = "coffee:hanoi";
private final GeoOperations<String, String> geoOps;
public StoreLocatorService(StringRedisTemplate redisTemplate) {
this.geoOps = redisTemplate.opsForGeo();
}
public void addStore(String storeId, double longitude, double latitude) {
geoOps.add(KEY, new Point(longitude, latitude), storeId);
}
public void removeStore(String storeId) {
geoOps.remove(KEY, storeId);
}
}
说明:
Point属于org.springframework.data.geo包。构造函数接受(x, y),即 (经度, 纬度)。geoOps.add(...)等同于GEOADD。如果storeId已存在,旧坐标会被覆盖。你不会得到重复的记录。geoOps.remove(...)实际上调用ZREM,因为 Geo 是 sorted set。
当需要一次性加载多个点时,你可以传递一个 Map<String, Point> 在单个命令中发送所有数据:
public void addStores(Map<String, Point> stores) {
geoOps.add(KEY, stores);
}
这种方式比在循环中调用 addStore 可以节省网络往返。
5. 找最近的位置
5.1. 根据用户坐标查找
最常见的情况是应用程序发送用户的 GPS 位置。你需要在给定半径内返回 N 个最近的店铺。首先,为结果声明一个 record:
public record NearbyStore(String storeId, double distanceKm, double longitude, double latitude) {
}
然后将查找方法添加到 StoreLocatorService:
public List<NearbyStore> findNearest(double longitude, double latitude,
double radiusKm, int limit) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.includeCoordinates()
.sortAscending()
.limit(limit);
GeoResults<RedisGeoCommands.GeoLocation<String>> results = geoOps.search(
KEY,
GeoReference.fromCoordinate(longitude, latitude),
new Distance(radiusKm, Metrics.KILOMETERS),
args);
if (results == null) {
return List.of();
}
return results.getContent().stream()
.map(r -> new NearbyStore(
r.getContent().getName(),
r.getDistance().getValue(),
r.getContent().getPoint().getX(),
r.getContent().getPoint().getY()))
.toList();
}
各部分的作用:
GeoReference.fromCoordinate(...)对应FROMLONLAT,即测量距离的参考点。new Distance(radiusKm, Metrics.KILOMETERS)对应BYRADIUS 2 km。你在这里选择的单位也是返回距离的单位。includeDistance()和includeCoordinates()添加WITHDIST和WITHCOORD。如果缺少includeCoordinates(),getPoint()将返回null。sortAscending()按距离从近到远排序。limit(limit)添加COUNT。
用还剑湖的位置尝试调用:
storeLocatorService.findNearest(105.8522, 21.0287, 2, 5)
.forEach(s -> System.out.printf("%s: %.2f km%n", s.storeId(), s.distanceKm()));
cong-trang-tien: 0.45 km
cafe-giang: 0.58 km
5.2. 在已有的位置周围查找
有时你想推荐’这个店铺附近的其他店铺’。你不需要坐标,只需要用 member 本身作为中心:
public List<String> findStoresNear(String storeId, double radiusKm) {
var results = geoOps.search(
KEY,
GeoReference.fromMember(storeId),
new Distance(radiusKm, Metrics.KILOMETERS),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().sortAscending());
if (results == null) {
return List.of();
}
return results.getContent().stream()
.map(r -> r.getContent().getName())
.filter(name -> !name.equals(storeId))
.toList();
}
GeoReference.fromMember 对应 FROMMEMBER。结果总是包含原始 member 且距离为 0,所以你需要用 filter 一行将其过滤出去。
5.3. 在矩形框内查找
当用户拖动地图时,你通常需要显示框内的所有点,而不是圆形范围内的点。此时你使用 BoundingBox(对应 BYBOX):
public List<String> findInBox(double centerLon, double centerLat,
double widthKm, double heightKm) {
var box = new BoundingBox(
new Distance(widthKm, Metrics.KILOMETERS),
new Distance(heightKm, Metrics.KILOMETERS));
var results = geoOps.search(KEY,
GeoReference.fromCoordinate(centerLon, centerLat),
box,
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs());
return results == null ? List.of()
: results.getContent().stream().map(r -> r.getContent().getName()).toList();
}
BoundingBox 根据宽度和高度定义框,中心是参考点。
6. 计算距离和获取坐标
除了搜索,GeoOperations 还支持两个其他有用的操作:
public Distance distanceBetween(String storeA, String storeB) {
return geoOps.distance(KEY, storeA, storeB, Metrics.KILOMETERS);
}
public Point locationOf(String storeId) {
List<Point> points = geoOps.position(KEY, storeId);
return (points == null || points.isEmpty()) ? null : points.get(0);
}
distance对应GEODIST。如果其中一个 member 不存在,该方法返回null。position对应GEOPOS。返回的列表对于不存在的 member 会有null元素。
重新读取的坐标会与你写入的值略有不同,例如 105.85470169782638 而不是 105.8547。这是 52 位 geohash 的误差,大约一米以内。你不应该用 equals 与原始值进行比较。
7. 使用 Testcontainers 编写测试
为了在真实 Redis 上运行测试而不依赖开发机器,你可以使用 Testcontainers。添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
为主要行为编写测试:
@SpringBootTest
@Testcontainers
class StoreLocatorServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
StoreLocatorService service;
@Autowired
StringRedisTemplate redisTemplate;
@BeforeEach
void setUp() {
redisTemplate.delete(StoreLocatorService.KEY);
service.addStore("cafe-giang", 105.8547, 21.0334);
service.addStore("cong-trang-tien", 105.8541, 21.0251);
service.addStore("highlands-lotte", 105.8128, 21.0318);
}
@Test
void shouldReturnStoresWithinRadiusSortedByDistance() {
List<NearbyStore> result = service.findNearest(105.8522, 21.0287, 2, 10);
assertThat(result)
.extracting(NearbyStore::storeId)
.containsExactly("cong-trang-tien", "cafe-giang");
assertThat(result.get(0).distanceKm()).isCloseTo(0.45, within(0.02));
}
@Test
void shouldRespectLimit() {
assertThat(service.findNearest(105.8522, 21.0287, 10, 1))
.extracting(NearbyStore::storeId)
.containsExactly("cong-trang-tien");
}
@Test
void shouldNotFindRemovedStore() {
service.removeStore("cafe-giang");
assertThat(service.findNearest(105.8522, 21.0287, 2, 10))
.extracting(NearbyStore::storeId)
.doesNotContain("cafe-giang");
}
}
@ServiceConnection(name = "redis") 告诉 Spring Boot 从容器自动配置 host 和 port。你不需要 @DynamicPropertySource。距离测试使用 isCloseTo(..., within(...)) 而不是绝对比较,因为结果有小的误差,如第 6 节所述。
8. 常见错误和何时不应使用
交换经度和纬度。 这是第一大错误。谷歌地图显示“21.0287, 105.8522”(lat, lon),而 Redis 和 Point 需要(lon, lat)。如果交换,Redis 可能会报错 invalid longitude,latitude pair,或更糟的是成功存储了错误位置的点。
注意: Redis 只接受 ±85.05112878 度范围内的纬度,这是 Web Mercator 投影的限制。你无法存储靠近两极的点。
将详细数据存储在 member 中。 Member 应该是简短的 ID,如 store:1024。不要将 JSON、名称、地址、营业时间塞入 member。应该将详细信息存储在 Redis Hash 或数据库中。从 GEOSEARCH 获得 ID 列表后,你可以通过一次批量调用获取详细信息。
Key 过大。 包含数千万个点的 sorted set 是一个大 key。删除或迁移此 key 会阻塞 Redis。在 Redis Cluster 中,一个 key 的所有点都位于同一个分片上。你应该按城市或地区拆分 key,例如 coffee:hanoi、coffee:hcm。
没有按 member 的 TTL。 对于不断更新的驾驶员位置,“死”的 member 会永远留下来。你需要单独的清理作业,例如一个辅助 sorted set 存储最后更新时间戳,然后定期删除过期 member。
没有 limit 的大半径。 在人烟稠密的城市中搜索 50 km 而不设限制可能返回数万个元素。你应该总是设置合理的 limit。
何时不应使用 Redis Geo:
- 你需要按多边形(区边界、交付区域)、几何交集或寻路进行查询。使用 PostGIS 或 Elasticsearch geo。
- 你需要根据多个条件组合过滤,如’最近、营业中、有停车位’。Redis Geo 只能按距离过滤。其余过滤必须在应用层完成,或使用 Redis Query Engine。
- Redis 是唯一的存储位置且没有持久化。Geo 应该是加速索引,而原始数据源应该在数据库中。
9. 结论
Redis Geo 将’找最近的位置’问题转化为 sorted set 上的几个简单命令。你已经学会了如何用 GEOADD 存储坐标、用 GEOSEARCH 按半径或矩形框查找、用 GEODIST 计算距离。你也已经将所有这些封装到一个带 Testcontainers 测试的 Spring Boot service 中。
在实际应用中,你需要记住三点:总是先传经度,保持 member 为简短 ID,以及当数据量大时按区域拆分 key。对于比距离更复杂的空间查询,你应该将 Redis 与专业的地理数据系统结合使用。