1. 项目背景
1.1 业务场景
电商平台开始铺设线下自提门店,App 需要上线三个 LBS(Location Based Service)功能:
- 附近门店:用户在 App 首页看到"附近支持自提的门店",按距离从近到远排序,还要支持按门店类型(自提、体验、仓储)过滤。
- 附近骑手:外卖业务需要实时展示用户附近的骑手位置,用户下单后能看到骑手在地图上移动。
- 附近的人:社交模块想做一个"附近的人"功能,展示周围 1 公里内同时在使用 App 的用户。
第一版实现把门店经纬度存在 MySQL 的 store 表里,查询时用 Haversine 公式计算距离。这个方案在小规模下可以跑,但当门店数量增长到几千家、日均附近查询 QPS 达到 5000 时,数据库开始吃力——每次查询都要扫描几千条记录、计算距离、排序、分页,高峰期 CPU 使用率飙升。
更麻烦的是骑手位置——每秒都有位置上报,MySQL 根本不适合这种高频写入 + 实时查询的场景。如果引入专业 GIS 引擎(如 PostGIS、Elasticsearch 的地理索引),又需要额外的运维和学习成本。
Redis GEO 为这种"轻量地理位置查询"提供了一个刚刚好的方案——它基于 ZSet,通过 geohash 将经纬度编码为一个可排序的数值,实现了 O(log N) 的半径搜索。
1.2 痛点
| 需求 | MySQL 方案的痛点 | Redis GEO 的优势 |
|---|---|---|
| 附近门店搜索 | SELECT *, haversine(lat, lng, ?, ?) AS dist FROM store HAVING dist < 3000 ORDER BY dist → 全表扫描+排序 |
GEOSEARCH ... BYRADIUS 3 km ASC → O(log N) |
| 骑手实时位置 | 每秒 UPDATE 经纬度 → 行锁热点,慢查询 | GEOADD 轻量写入,ms 级更新 |
| 距离计算 | 每次计算都要带 Haversine 公式 | GEODIST 内置,返回米/公里/英里 |
| 门店属性过滤 | SQL 加 WHERE 过滤 → 查询进一步复杂化 | GEO 召回 + 业务层过滤(分离关注点) |
1.3 本章目标
掌握 Redis GEO 的四种实战模式:
- 附近门店:
GEOADD+GEOSEARCH半径搜索 + 属性过滤 + 分页。 - 距离计算:
GEODIST计算两点距离。 - 移动对象跟踪:骑手/用户位置高频更新 + 心跳过期清理。
- 城市边界补偿:按城市拆分 GEO key 时的跨城市查漏策略。
同时理解三个关键边界:
- GEO 底层是 ZSet——删除 member 用
ZREM,没有成员级 TTL。 - 经纬度参数顺序是
经度 纬度(不是纬度在前!)——这是一个高频错误。 - GEO 适合轻量空间查询,不是专业 GIS——不做路线规划、行政区判断、复杂多边形。
2. 项目设计
2.1 第一幕:门店搜索就一个方圆搜索
小胖:"附近门店不就是'以我为中心画一个圈,3 公里内的都列出来'吗?这和地图 App 搜奶茶店一模一样。"
小白:"但门店数量可能很多。全国可能有几万家门店,如果都放一个 key 里,方圆搜索要扫描整个集合——虽然 Redis 的 GEO 用 geohash 做了空间索引优化,但集合太大仍然影响性能。"
大师:"所以第一个设计决策是分区粒度——你按什么维度拆分 GEO key?常见做法是按城市拆:"
按城市拆分 GEO key:
geo:store:city:bj → 北京的门店
geo:store:city:sh → 上海的门店
geo:store:city:gz → 广州的门店
...
好处:
1. 缩小每次查询的集合大小
2. 冷热数据隔离(一线城市门店多、查询多)
3. 方便按城市管理门店的增删
代价:
1. 城市边界附近的用户可能漏掉隔壁城市的门店
(比如在燕郊的用户,离北京门店 2 公里,但按廊坊查就漏了)
2. 需要知道用户当前在哪个城市
小胖:"那城市边界漏召的问题怎么解决?"
大师:"几个方案:第一,如果用户定位距离相邻城市边界 < N 公里(比如 5 公里),同时查本城市和相邻城市的 GEO key,然后合并结果。第二,对于超大连锁(如全国门店都支持自提),维护一个全国门店的 GEO key 作为兜底——但只存关键字段,避免集合过大。第三,业务上允许少量边界漏召——毕竟用户在城市边界是少数场景。"
技术映射:GEO key 的拆分维度取决于业务的地理范围和数据量。国内城市级拆分是平衡性能和准确性的常见方案。
2.2 第二幕:GEO 底层就是 ZSet
小胖:"为什么 GEO 的删除命令是 ZREM 而不是 GEODEL?"
大师:"因为 GEO 没有自己的存储结构——它完全复用 ZSet。GEOADD 本质就是 ZADD,只是把经纬度编码成 geohash 再存为 score。你来验证一下:"
# GEOADD 添加门店
GEOADD geo:store:city:bj 116.397128 39.916527 store:1001
# 用 ZSet 命令查看——你会发现 member 是 store:1001,score 是一个很大的数
ZRANGE geo:store:city:bj 0 -1 WITHSCORES
# → "store:1001" "4069885363682650"
# 那个巨大的 score 就是 geohash 编码的结果
# geohash 把二维的经纬度编码成一维的可排序数值
# 地理上靠近的点,geohash 值也靠近 → ZSet 的范围查询就能做附近搜索
小白:"那这意味着 GEO 有 ZSet 的所有限制——比如没有成员级的 TTL。移动对象(骑手)如果离线了,需要额外机制来清理过期位置。"
大师:"对。所以移动对象通常需要配合一个心跳 key。比如骑手上报位置时同时 ZADD geo:rider:online 一个心跳时间戳,后台定时任务扫描心跳超时的骑手,用 ZREM 把他们从 GEO key 中删除。"
技术映射:GEO = ZSet + geohash 编码。理解了这一点,就能理解 GEO 的能力和限制——能排序、能范围查询、没有独立过期、不能存复杂属性。
2.3 第三幕:附近的人和门店有什么不同
小胖:"附近的人和附近门店不就是换了个 member 名字吗?一个是 store:1001,一个是 user:1001。"
大师:"技术结构上一样,但产品、隐私和运维维度完全不同。我列一个对比:"
| 维度 | 附近门店 | 附近的人 |
|---|---|---|
| 位置变化频率 | 门店位置几乎不变 | 用户移动,可能需要秒级更新 |
| 数据量级 | 全国几万到几十万 | 同时在线可达千万 |
| 隐私敏感度 | 低(门店是公开地址) | 极高(个人实时位置) |
| 过期清理 | 不需要(门店不离开) | 必须(用户退出/离线/关闭定位) |
| 显示精度 | 精确(显示到门牌号) | 模糊(只显示距离,不显示精确坐标) |
| 授权要求 | 不需要 | 需要用户授权定位 |
| 防刷/安全 | 不需要 | 需要(防止被人肉/跟踪/刷位置) |
大师:"所以虽然命令一样,但'附近门店'是静态数据管理,'附近的人'是动态实时系统+隐私保护。两者的实现细节和运维策略差异很大。"
小白:"那'附近的骑手'呢?"
大师:"介于二者之间。位置高频变化(和'附近的人'一样),但骑手的位置展示是业务需求,有明确的授权和业务场景(骑手在配送订单期间才需要被看到)。相比'附近的人',骑手位置的安全敏感度稍低(因为骑手是在工作状态),但仍有隐私问题——不能随便让任何人看到骑手位置。"
2.4 第四幕:GEO 能做什么、不能做什么
小胖:"那 GEO 能做路线规划吗?比如从我家到最近的门店怎么走?"
大师:"不能。GEO 只做直线距离计算,不做道路距离、不规划路线。如果需要路线规划,你要调用地图 API(高德、百度、Google)或者使用专业的 GIS 系统。"
GEO 能做的:
✓ 以某点为中心,搜 N 公里内的对象
✓ 计算两点之间的直线距离(非道路距离)
✓ 按距离排序返回
✓ 矩形范围搜索(BYBOX,适合地图视窗)
GEO 不能做的:
✗ 路线规划(驾车/步行/公交路线)
✗ 多边形区域判断(如"是否在北京市朝阳区")
✗ 复杂属性过滤(GEO 只负责位置,不负责类型/状态/评分)
✗ 成员级 TTL(需要额外心跳机制)
✗ 道路距离计算
3. 项目实战
3.1 环境准备
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:写入门店位置
步骤 1:GEOADD 添加门店
# 语法: GEOADD key 经度 纬度 member
# 注意:经度在前,纬度在后!(这个错踩的人太多了)
# 北京部分门店坐标(模拟数据)
127.0.0.1:6379> GEOADD geo:store:city:bj 116.397128 39.916527 store:1001
(integer) 1
127.0.0.1:6379> GEOADD geo:store:city:bj 116.407526 39.904030 store:1002
(integer) 1
127.0.0.1:6379> GEOADD geo:store:city:bj 116.384799 39.949402 store:1003
(integer) 1
127.0.0.1:6379> GEOADD geo:store:city:bj 116.455100 39.941000 store:1004
(integer) 1
127.0.0.1:6379> GEOADD geo:store:city:bj 116.301200 39.985400 store:1005
(integer) 1
步骤 2:查看门店坐标
# GEOPOS: 返回一个或多个 member 的经纬度
127.0.0.1:6379> GEOPOS geo:store:city:bj store:1001 store:1002
1) 1) "116.39712995290756226"
2) "39.91652672395293673"
2) 1) "116.40752702951431274"
2) "39.90402913513891092"
# 验证 Geo 底层是 ZSet
127.0.0.1:6379> ZRANGE geo:store:city:bj 0 -1 WITHSCORES
1) "store:1005"
2) "4069547399189602"
3) "store:1003"
4) "4069684790339283"
5) "store:1001"
6) "4069885363682650"
7) "store:1002"
8) "4069893824615669"
9) "store:1004"
10) "4069996189982015"
关键提醒:GEOADD 的参数顺序是 经度 纬度 member。经度(longitude)在前,纬度(latitude)在后。如果你写反了(纬度, 经度),门店就跑到了地球另一边——不会报错。
3.3 实战二:附近门店搜索
步骤 1:基本半径搜索
# 以用户当前位置为中心,搜 3 公里内门店
# 用户位置: 天安门附近 (116.397500, 39.908700)
127.0.0.1:6379> GEOSEARCH geo:store:city:bj FROMLONLAT 116.397500 39.908700 BYRADIUS 3 km ASC WITHDIST COUNT 10
1) 1) "store:1002"
2) "0.5193" # 约 519 米
2) 1) "store:1001"
2) "0.8724" # 约 872 米
# 参数说明:
# FROMLONLAT: 以给定经纬度为中心
# BYRADIUS 3 km: 半径 3 公里
# ASC: 从近到远排序
# WITHDIST: 返回距离
# COUNT 10: 最多返回 10 个
步骤 2:不同排序和单位
# 从远到近排序(DESC)
127.0.0.1:6379> GEOSEARCH geo:store:city:bj FROMLONLAT 116.397500 39.908700 BYRADIUS 5 km DESC WITHDIST
1) 1) "store:1005"
2) "11.7711"
2) 1) "store:1003"
2) "4.5187"
...
# 使用英里(mi)或英尺(ft)
127.0.0.1:6379> GEOSEARCH geo:store:city:bj FROMLONLAT 116.397500 39.908700 BYRADIUS 3 mi ASC WITHDIST
# 同时返回坐标和距离(WITHCOORD WITHDIST)
127.0.0.1:6379> GEOSEARCH geo:store:city:bj FROMLONLAT 116.397500 39.908700 BYRADIUS 3 km ASC WITHDIST WITHCOORD
步骤 3:矩形范围搜索(地图视窗)
# BYBOX: 矩形搜索(宽 5 公里,高 4 公里)
127.0.0.1:6379> GEOSEARCH geo:store:city:bj FROMLONLAT 116.397500 39.908700 BYBOX 5 4 km ASC WITHDIST COUNT 20
3.4 实战三:距离计算
# 计算两个门店之间的直线距离
127.0.0.1:6379> GEODIST geo:store:city:bj store:1001 store:1002 km
"1.5845" # 约 1.58 公里
# 以米为单位
127.0.0.1:6379> GEODIST geo:store:city:bj store:1001 store:1002 m
"1584.5234"
# member 不存在
127.0.0.1:6379> GEODIST geo:store:city:bj store:1001 store:9999 km
(nil)
3.5 实战四:门店属性过滤
GEO 不存复杂属性——需要用 Hash 或数据库补全:
# GEO 存位置
127.0.0.1:6379> GEOADD geo:store:city:bj 116.397128 39.916527 store:1001
127.0.0.1:6379> GEOADD geo:store:city:bj 116.407526 39.904030 store:1002
127.0.0.1:6379> GEOADD geo:store:city:bj 116.384799 39.949402 store:1003
# Hash 存门店属性
127.0.0.1:6379> HSET store:info:1001 name "王府井自提店" type "pickup" open 1
(integer) 3
127.0.0.1:6379> HSET store:info:1002 name "前门体验店" type "experience" open 1
(integer) 3
127.0.0.1:6379> HSET store:info:1003 name "西城仓店" type "warehouse" open 0
(integer) 3
Java 实现:GEO 召回 + 属性过滤:
public class NearbyStoreService {
// 搜索附近门店(带属性过滤)
public List<StoreResult> searchNearby(double lng, double lat, String city,
double radiusKm, String filterType, int limit) {
// 1. GEO 召回:取 limit * 3 个候选(留余量给过滤)
String geoKey = "geo:store:city:" + city;
List<GeoResult> geoResults = redis.geoSearch(geoKey,
new GeoReference.FromLonLat(lng, lat),
new Distance(radiusKm, Metrics.KILOMETERS),
GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.sortAscending()
.limit(limit * 3));
if (geoResults.isEmpty()) return Collections.emptyList();
// 2. 批量获取门店属性
List<String> storeIds = geoResults.stream()
.map(r -> r.getContent().getName())
.collect(Collectors.toList());
List<StoreInfo> storeInfos = batchGetStoreInfo(storeIds);
// 3. 属性和距离结合过滤排序
Map<String, StoreInfo> infoMap = storeInfos.stream()
.collect(Collectors.toMap(StoreInfo::getId, Function.identity()));
List<StoreResult> results = new ArrayList<>();
for (GeoResult gr : geoResults) {
String storeId = gr.getContent().getName();
StoreInfo info = infoMap.get(storeId);
if (info == null) continue;
// 属性过滤
if (filterType != null && !filterType.equals(info.getType())) continue;
if (!info.isOpen()) continue;
results.add(StoreResult.builder()
.storeId(storeId)
.name(info.getName())
.type(info.getType())
.distanceKm(gr.getDistance().getValue())
.build());
}
// 4. 截取 limit 条返回
return results.subList(0, Math.min(limit, results.size()));
}
// 通过辅助 Set 快速过滤(高频属性提前建好交集索引)
public List<String> getNearbyPickupStores(double lng, double lat, String city,
double radiusKm, int limit) {
// 先获取附近候选
List<GeoResult> geoResults = redis.geoSearch("geo:store:city:" + city,
new GeoReference.FromLonLat(lng, lat),
new Distance(radiusKm, Metrics.KILOMETERS),
GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance().sortAscending().limit(limit * 2));
// 批量检查是否在 pickup + open 集合中
List<String> storeIds = geoResults.stream()
.map(r -> r.getContent().getName())
.collect(Collectors.toList());
// Pipeline 批量 SISMEMBER 检查类型和营业状态
// ...
return filtered;
}
}
3.6 实战五:移动对象位置跟踪(骑手)
步骤 1:骑手上报位置
# 骑手上报当前位置
127.0.0.1:6379> GEOADD geo:rider:online 116.401000 39.910000 rider:2001
(integer) 1
127.0.0.1:6379> GEOADD geo:rider:online 116.402000 39.912000 rider:2002
(integer) 1
# 同时更新心跳时间(用作过期判断)
127.0.0.1:6379> ZADD geo:rider:heartbeat 1714470000 rider:2001
(integer) 1
127.0.0.1:6379> ZADD geo:rider:heartbeat 1714470000 rider:2002
(integer) 1
步骤 2:查询附近骑手
# 用户附近的在线骑手
127.0.0.1:6379> GEOSEARCH geo:rider:online FROMLONLAT 116.400000 39.910000 BYRADIUS 1 km ASC WITHDIST COUNT 20
1) 1) "rider:2001"
2) "0.0857"
2) 1) "rider:2002"
2) "0.2802"
步骤 3:清理离线骑手
# 定时任务:清理超过 120 秒没心跳的骑手
# Step 1: 找出心跳超时的骑手(score=最后心跳时间戳)
127.0.0.1:6379> ZRANGEBYSCORE geo:rider:heartbeat -inf 1714469880
1) "rider:2003" # 心跳超时 120 秒
# Step 2: 从 GEO key 和心跳 key 中移除
127.0.0.1:6379> ZREM geo:rider:online rider:2003
(integer) 1
127.0.0.1:6379> ZREM geo:rider:heartbeat rider:2003
(integer) 1
Java 骑手位置服务:
public class RiderLocationService {
private static final int HEARTBEAT_TIMEOUT_SEC = 120;
private static final String GEO_ONLINE = "geo:rider:online";
private static final String GEO_HEARTBEAT = "geo:rider:heartbeat";
// 骑手上报位置
public void reportLocation(String riderId, double lng, double lat) {
long now = System.currentTimeMillis() / 1000;
// 更新位置
redis.geoAdd(GEO_ONLINE, new Point(lng, lat), riderId);
// 更新心跳
redis.zAdd(GEO_HEARTBEAT, now, riderId);
}
// 搜索附近在线骑手
public List<RiderNearby> searchNearbyRiders(double lng, double lat, double radiusKm) {
List<GeoResult> results = redis.geoSearch(GEO_ONLINE,
new GeoReference.FromLonLat(lng, lat),
new Distance(radiusKm, Metrics.KILOMETERS),
GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance().sortAscending().limit(20));
return results.stream()
.map(r -> new RiderNearby(
r.getContent().getName(),
r.getDistance().getValue()))
.collect(Collectors.toList());
}
// 定时清理离线骑手(每 30 秒执行一次)
@Scheduled(fixedDelay = 30_000)
public void cleanOfflineRiders() {
long threshold = System.currentTimeMillis() / 1000 - HEARTBEAT_TIMEOUT_SEC;
// 找出心跳超时的骑手
Set<String> offlineRiders = redis.zRangeByScore(GEO_HEARTBEAT, 0, threshold);
if (!offlineRiders.isEmpty()) {
// 批量移除
redis.zRem(GEO_ONLINE, offlineRiders.toArray(new String[0]));
redis.zRem(GEO_HEARTBEAT, offlineRiders.toArray(new String[0]));
log.info("清楚离线骑手: {} 人", offlineRiders.size());
}
}
}
3.7 实战六:城市边界漏召补偿
public class CrossCityStoreSearch {
// 城市边界邻接表(配置或数据库)
private static final Map<String, List<String>> ADJACENT_CITIES = Map.of(
"bj", List.of("tj", "lf", "bd"), // 北京邻近天津、廊坊、保定
"sh", List.of("sz", "jx", "nt") // 上海邻近苏州、嘉兴、南通
);
public List<StoreResult> searchWithBoundaryCompensation(
double lng, double lat, String city,
double radiusKm, String filterType, int limit) {
// 1. 主搜索:本城市
List<StoreResult> results = new ArrayList<>(
nearbyStoreService.searchNearby(lng, lat, city, radiusKm, filterType, limit));
// 2. 如果本城市结果很少,检查是否在边界——补偿搜索相邻城市
if (results.size() < limit) {
List<String> adjacentCities = ADJACENT_CITIES.getOrDefault(city, Collections.emptyList());
for (String adjCity : adjacentCities) {
if (results.size() >= limit) break;
List<StoreResult> adjResults =
nearbyStoreService.searchNearby(lng, lat, adjCity, radiusKm, filterType, limit - results.size());
// 去重(同一个门店可能在本城和邻城搜索中都出现?一般不会,但安全起见)
Set<String> existingIds = results.stream()
.map(StoreResult::getStoreId).collect(Collectors.toSet());
for (StoreResult r : adjResults) {
if (!existingIds.contains(r.getStoreId())) {
results.add(r);
}
}
}
}
// 3. 最终按距离排序
results.sort(Comparator.comparingDouble(StoreResult::getDistanceKm));
return results.subList(0, Math.min(limit, results.size()));
}
}
3.8 常见坑
坑1:经纬度顺序写反
# 错误!纬度在前,经度在后 → 门店飞到奇怪的地方
127.0.0.1:6379> GEOADD geo:store:test 39.916527 116.397128 store:w001
# 不会报错!Redis 不知道经纬度是否合法
# 正确写法:经度在前,纬度在后
127.0.0.1:6379> GEOADD geo:store:test 116.397128 39.916527 store:r001
# 如何自查:
127.0.0.1:6379> GEOPOS geo:store:test store:w001
# 检查返回的经纬度是否在合理范围(中国经度约 73-135,纬度约 18-54)
坑2:GEO 没有独立删除命令
# 正确:用 ZREM 删除 member
127.0.0.1:6379> ZREM geo:store:city:bj store:1001
(integer) 1
# 错误:没有 GEODEL 命令
# GEODEL geo:store:city:bj store:1001 ← 这个命令不存在!
坑3:移动对象隐私泄露
# 危险做法:返回所有附近用户的精确坐标
# GEOSEARCH ... WITHCOORD → 任何人都能拿到精确位置
# 安全做法:
# 1. 只返回距离,不返回精确坐标
# 2. 距离模糊化(如 0.85km → "约 1 公里")
# 3. 限制搜索半径上限(如最多 5 公里)
4. 项目总结
4.1 核心命令速查
| 命令 | 用途 | 复杂度 | 注意事项 |
|---|---|---|---|
GEOADD key lng lat member |
添加坐标 | O(log N) | 经度在前!纬度在后! |
GEOPOS key member... |
获取坐标 | O(N) | N 为查询的 member 数 |
GEODIST key m1 m2 [unit] |
计算两点距离 | O(log N) | unit: m/km/mi/ft |
GEOSEARCH key FROMLONLAT lng lat BYRADIUS r unit [ASC\|DESC] [WITHDIST] [WITHCOORD] [COUNT n] |
半径搜索 | O(N+log N) | N 为指定半径内的元素数 |
GEOSEARCH key FROMMEMBER m BYRADIUS r unit ... |
以成员为中心搜索 | O(N+log N) | 同上 |
GEOSEARCH key ... BYBOX w h unit ... |
矩形范围搜索 | O(N+log N) | 适合地图视窗 |
ZREM key member |
删除成员(GEO 无独立删除命令) | O(log N) | 因为 GEO 底层是 ZSet |
4.2 优点与缺点
| 维度 | 详细说明 |
|---|---|
| 优点1:命令简洁 | 5 个核心命令覆盖 80% 的 LBS 需求 |
| 优点2:搜索快 | 基于 geohash + ZSet 的跳表,O(log N) 索引 |
| 优点3:轻量级 | 不需要额外 GIS 中间件,维护成本低 |
| 优点4:与 Redis 生态融合 | GEO key 可以用 ZSet 命令操作,与 Set/Hash 配合做属性过滤 |
| 缺点1:直线距离 | 不是道路距离,与用户感知有差异 |
| 缺点2:无成员 TTL | 移动对象需要额外的心跳+清理机制 |
| 缺点3:无复杂空间运算 | 不做路线规划、多边形判断、地形匹配 |
| 缺点4:key 粒度需权衡 | 全国一个 key 太大,按城市拆有边界漏召 |
4.3 适用与不适用场景
适用场景:
- 附近门店搜索——门店位置相对固定,按城市拆分,GEO 召回+属性过滤。
- 骑手/车辆实时位置——高频更新,配合心跳清理离线对象。
- 附近的人——需注意隐私保护(距离模糊化、坐标不返回、授权检查)。
- 充电桩/ATM/厕所等 POI 搜索——位置固定,查询频率适中。
- 地图视窗搜索(BYBOX)——用户拖拽地图时检索可视范围内的标记点。
- 配送距离计算——粗略直线距离判断是否在配送范围内。
不适用场景:
- 路线规划——驾车/步行/公交路线需要调地图 API。
- 行政区域判断——"是否在朝阳区"需要 PostGIS 或高德行政区域 API。
- 道路距离计算——需要地图 API。
- 复杂多边形查询——如"派送区域是一个不规则多边形"。
- 大规模实时移动对象(如千万级)——心跳清理和位置更新压力较大。
4.4 常见踩坑经验
| 案例 | 表象 | 根因 | 解法 |
|---|---|---|---|
| 经纬度反了 | 查询附近门店总是返回空,或返回距离几千公里的门店 | GEOADD 参数写成 纬度 经度 |
建立团队代码规范:所有经纬度参数强制使用 Point(lng, lat) 封装,禁止裸传 |
| 存储大量移动对象 OOM | Redis 内存持续增长,GEO key 的 ZCARD 上百万 | 骑手/用户下线后没从 GEO key 移除 | 必须配合心跳 ZSet,定时清理离线 member |
| 边界漏召回 | 用户在燕郊搜不到 2 公里外的北京门店 | 只查了本城市(廊坊)的 GEO key | 用户在城市边界时同时查相邻城市;边界判断:用户距城市中心的距离是否接近城市半径 |
| 深翻页性能差 | 附近门店"加载更多"到第 5 页后越来越慢 | 每次翻页调大 COUNT 拉取更多结果,但 GEOSEARCH 本身要扫描更大的空间 | 限制最大翻页数(如 3 页);加载更多时自动扩大半径或缩小地图 |
| 返回精确坐标导致隐私泄露 | 用户收到骚扰电话,怀疑是"附近的人"泄露坐标 | GEOSEARCH ... WITHCOORD 直接返回了用户的精确经纬度 |
移除 WITHCOORD;距离做模糊化展示("约 500m"而非"521m") |
4.5 思考题
-
设计题:食品外卖 App 需要实现"附近可配送的餐厅"功能。每个餐厅有配送范围(圆形,半径 3~5 公里不等),用户查看 3 公里内的可配送餐厅。请设计 Redis 数据结构方案——用 GEO 存储餐厅位置,如何高效查出"配送范围覆盖用户位置"的餐厅?
-
安全性题:"附近的人"功能中,如何防止恶意用户通过三角定位法(从三个不同位置搜索同一个人)推断出目标用户的精确坐标?请至少提出两种防护手段。
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第9章):
第1题(HLL 交集统计):不能用 HLL 直接做交集统计。HLL 只有
PFADD/PFCOUNT/PFMERGE,没有交集运算。交集需要知道"具体每个元素是否在两个集合中",而 HLL 不存元素,无法判断。解法:用 Bitmap 存精确的交集(BITOP AND),或用 Set(SINTERSTORE),或用数据库日志表做精确交集统计。如果运营只想要一个大概数值,可以用"并集 - HLL 合并后"减去单个值来估算,但偏差很大。第2题(50 万创作者 UV 排行榜):不能 50 万次 PFCOUNT。解法:① 只对 Top N 做精确计算(比如前 1000 名,其余不展示);② 用 Redis ZSet 维护创作者的"UV 分数"——每次新增 UV 时,如果用户该作者未曝光,
ZINCRBY rank:creator:uv 1 creatorId,但这个方案也需要额外维护"用户对该作者的曝光记录";③ 异步算——离线跑 Spark/Flink 消费日志,算出结果后写入 Redis ZSet。
延伸阅读与资源
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号