「附近的人」「附近的门店」「附近的车」这类 LBS(Location-Based Service)系统,本质是同一道题:给定一个坐标和一个半径,快速找出范围内的其他实体,并按距离排序。难点在于——位置是高频变化的一维时间序列,而查询是二维空间范围,普通 B 树索引对「经纬度双维度」无能为力。本文按照系统设计面试的标准答题结构,设计一个支持千万级日活、位置秒级更新、查询毫秒返回的「附近的人」系统。
一句话:LBS 的核心是把二维空间查询降维成「前缀/编码匹配」——用 Geohash 把经纬度压成一个字符串,让范围查询变成字符串前缀扫描或有序集合范围扫描。
一、需求澄清与量级估算
1.1 需求澄清
- 查询类型:只要「附近的人」,还是「附近的门店/POI」这类静态实体?
- 半径与数量:固定半径(如 5km)还是用户可调?返回 Top N 还是全部?
- 排序依据:纯距离,还是「距离 + 活跃度/热度」混合排序?
- 更新频率:位置多久上报一次?秒级还是分钟级?
- 隐私要求:是否需要模糊化(如只显示 100m 精度)、隐身模式、黑名单?
明确假设(面向面试的合理假设):
| 需求项 | 假设 |
|---|---|
| 查询 | 半径 1~10km,返回最近 100 人 |
| 实体 | 移动用户(高频更新)+ 静态门店(低频) |
| 更新 | 在线用户每 5~30 秒上报一次位置 |
| 排序 | 距离优先,同距离按活跃时间 |
| 隐私 | 位置模糊到 100m 网格,支持隐身 |
1.2 量级估算
| 指标 | 估算值 | 推导 |
|---|---|---|
| 日活用户 | 3000 万 | 假设月活 1 亿,日活 30% |
| 在线用户 | ~300 万 | 日活 10%,峰值集中 |
| 位置更新 QPS | ~60 万 | 300 万在线 / 5 秒 = 60 万写入/秒 |
| 查询 QPS | ~10 万 | 日活 5% 每分钟查一次 / 60 |
| 单次查询扫描 | 数百~数千点 | 取决于半径与密度 |
| 位置存储 | ~GB 级 | 300 万在线 × 每点几十字节 |
一句话:LBS 的写入量(60 万 QPS)远大于查询量,位置数据是易失的临时状态——存 Redis 内存、过期即删,而不是堆进 MySQL。
二、高层架构设计
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 移动端 A │ │ 移动端 B │ │ Web/门店 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 位置上报 / 附近查询 │ │
┌──────▼───────────────────▼───────────────────▼──────┐
│ LBS 接入网关 (无状态) │
│ 鉴权 / 限流 / 位置脱敏 / 查询参数规整 │
└──────┬───────────────────────────────────┬────────────┘
│ 写: 位置上报 │ 读: 附近查询
┌──────▼──────────────┐ ┌────────▼──────────────┐
│ 位置写入服务 │ │ 附近查询服务 │
│ 网格编码 + 批量落盘 │ │ 九宫格展开 + 距离过滤 │
└──────┬──────────────┘ └────────┬──────────────┘
│ │
┌──────▼────────────────────────────────────▼──────────────┐
│ 空间索引存储层 │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Redis GEO │ │ Redis ZSet │ │ PostGIS/ES │ │
│ │ (热数据/在线) │ │ (Geohash 前缀)│ │ (静态POI/离线) │ │
│ └──────────────┘ └──────────────┘ └────────────────┘ │
└───────────────────────────────────────────────────────────┘
四层职责:
- 接入网关:鉴权、限流、位置脱敏(模糊到网格),把读写分流。
- 写入服务:编码位置、批量写入 Redis,设置 TTL 自动淘汰离线用户。
- 查询服务:把「圆心+半径」转成若干网格,合并候选集后精确算距离过滤。
- 存储层:热数据用 Redis GEO/ZSet,静态 POI 用 PostGIS 或 ES。
2.1 为什么在线用 Redis、离线用 PostGIS
- 在线用户:位置秒级变化、量大、可丢失(丢了重新上报),适合 Redis 内存 + TTL。
- 静态 POI:门店/地标变化极少、需要复杂空间查询(多边形、相交),适合 PostGIS 地理空间 这类带 GiST 索引的关系库。
- 全文 + 空间混合:要「附近 + 关键词」时用 Elasticsearch 地理搜索 。
一句话:按「数据是否易失」分存储——易失的在线位置进内存,持久的 POI 进关系库/搜索引擎,别用一套存储硬扛两种访问模式。
三、核心组件设计
3.1 空间索引方案对比
| 方案 | 编码/结构 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| Geohash | 经纬度交替二分 → Base32 字符串 | 简单、前缀即邻近、易分片 | 边界问题、精度不均 | 通用、Redis 友好 |
| H3 | 六边形分层网格 | 邻域规则、面积均匀 | 库较重、字符串较长 | 蜂窝优化、覆盖分析 |
| S2 | 球面四叉树 + Hilbert 曲线 | 精度灵活、覆盖精确 | 概念复杂 | 高精度、大厂常用 |
| R-Tree/GiST | 树形空间索引 | 支持任意形状 | 需数据库支持 | PostGIS/空间库 |
Geohash 编码原理(交替二分经度/纬度,Base32 输出):
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
def geohash(lat, lon, precision=9):
lat_rng, lon_rng = (-90.0, 90.0), (-180.0, 180.0)
bits, bit, ch, out = 0, 0, 0, []
even = True # 偶数位编经度,奇数位编纬度
while len(out) < precision:
if even:
mid = (lon_rng[0] + lon_rng[1]) / 2
if lon > mid: ch = (ch << 1) | 1; lon_rng = (mid, lon_rng[1])
else: ch = ch << 1; lon_rng = (lon_rng[0], mid)
else:
mid = (lat_rng[0] + lat_rng[1]) / 2
if lat > mid: ch = (ch << 1) | 1; lat_rng = (mid, lat_rng[1])
else: ch = ch << 1; lat_rng = (lat_rng[0], mid)
even = not even
if (bits := bits + 1) == 5:
out.append(BASE32[ch]); bits, ch = 0, 0
return "".join(out)
精度对照(Geohash 长度 → 网格大小):
| 长度 | 单元宽 × 高 | 典型用途 |
|---|---|---|
| 5 | 4.9km × 4.9km | 城市级粗筛 |
| 6 | 1.2km × 0.6km | 街区级 |
| 7 | 153m × 153m | 附近的人 |
| 9 | 4.8m × 4.8m | 精确点位 |
3.2 九宫格查询
Geohash 的致命问题是边界:圆心附近的人可能落在相邻格子里。解决办法是查「中心格 + 周围 8 格」共 9 格:
def nearby(lat, lon, radius_m):
# 1. 选精度:让网格略大于半径,减少格子数
precision = choose_precision(radius_m) # 5km -> 5, 1km -> 6
center = geohash(lat, lon, precision)
# 2. 展开九宫格(相邻格)
cells = [center] + neighbors(center)
# 3. 从每格拉候选(Redis ZSet 或 GEO 命令)
candidates = []
for c in cells:
candidates += zrangebylex(f"geo:{c}", ...)
# 4. 精确算距离并过滤 + 排序
result = []
for uid, ulat, ulon in candidates:
d = haversine(lat, lon, ulat, ulon)
if d <= radius_m:
result.append((uid, d))
return sorted(result, key=lambda x: x[1])[:100]
半径大时要递归扩层:1km 用九宫格,10km 可能要中心 + 两层邻居(25 格)。精度选择的原则是「网格边长 ≥ 半径」,避免候选集过大。
3.3 Redis GEO 的用法
Redis 从 3.2 起内置 GEO 命令,底层是 ZSet + Geohash 52bit 整数,非常适合在线位置:
# 写入位置
GEOADD online_users 116.397 39.908 "user:42"
# 查询半径 5km 内,按距离排序,取最近 100,带坐标和距离
GEOSEARCH online_users FROMLONLAT 116.397 39.908 BYRADIUS 5 km ASC COUNT 100 WITHDIST WITHCOORD
# 查看两点距离
GEODIST online_users "user:42" "user:99" km
GEOSEARCH 内部就是把范围转成若干 Geohash 格子扫描 ZSet,再算距离过滤——九宫格逻辑 Redis 已帮你封装,生产上直接用即可。自研时再手写九宫格。
3.4 位置更新与过期淘汰
- 上报频率限制:客户端每 5~30 秒上报一次,服务端对同一用户限流(如最快 3 秒一次),防止刷。
- TTL 淘汰:每个用户的位置 key 设 TTL(如 60 秒),超时未上报即视为离线自动清除——用过期代替主动下线,大幅简化逻辑。
- 批量写入:网关聚合 100ms 内的上报,用
GEOADD批量写,减少 RTT。 - 降级:Redis 压力大时,位置可只写本地缓存 + 定期批量同步,牺牲一点实时性。
3.5 隐私与模糊化
- 坐标模糊:上报时把坐标量化到 100m 网格(
round(lat/0.001)*0.001)再存储,避免暴露精确位置。 - 隐身模式:用户级开关,隐身用户不写入在线位置索引(或写入隔离空间,查询时排除)。
- 黑名单:被拉黑的人不出现在彼此的「附近」结果里,查询后过滤。
- 结果偏移:展示时对坐标加随机抖动,防止通过多点定位反推真实位置。
四、数据模型
| 存储 | 结构 | 用途 |
|---|---|---|
| Redis GEO | online_users ZSet | 在线用户实时位置 |
| Redis GEO | poi:{city} | 城市内静态 POI(可选) |
| PostGIS | poi(id, name, geom, tags) | 静态门店,GiST 索引 |
| MySQL | user_geo_pref(uid, hidden, precision) | 隐私设置 |
| Kafka | location_stream | 位置流,供轨迹/热力分析 |
PostGIS 表定义:
CREATE TABLE poi (
id BIGSERIAL PRIMARY KEY,
name TEXT,
geom GEOGRAPHY(POINT, 4326), -- WGS84 经纬度
tags JSONB
);
CREATE INDEX idx_poi_geom ON poi USING GIST (geom);
-- 附近 5km 门店,按距离排序
SELECT id, name,
ST_Distance(geom, ST_MakePoint(116.397, 39.908)::geography) AS dist
FROM poi
WHERE ST_DWithin(geom, ST_MakePoint(116.397, 39.908)::geography, 5000)
ORDER BY dist LIMIT 50;
五、关键流程
5.1 位置上报(写路径)
移动端 → 网关(脱敏+限流) → 写入服务(聚合批量)
→ GEOADD online_users lon lat uid (TTL 60s)
→ 异步投递 Kafka location_stream (轨迹分析)
要点:写路径只做索引写入,不做任何重计算;轨迹分析等重活全异步。
5.2 附近查询(读路径)
移动端 → 网关 → 查询服务
1. 取自己坐标(缓存)
2. GEOSEARCH 半径 R,ASC,COUNT 200(多取一些做过滤)
3. 过滤:隐身 / 黑名单 / 自己
4. 排序:距离优先,同距离按活跃时间
5. 截断 Top 100 返回(坐标已模糊化)
5.3 大半径与结果为空
- 半径 10km 结果太少 → 自动扩半径(10 → 20 → 50km),并提示「附近的人较少」。
- 半径 10km 结果太多 → 按距离截断 + 分页(用游标而非 offset)。
六、可靠性与一致性
6.1 位置数据可丢失
位置是可重建的临时状态:丢了下次上报即可恢复。因此不需要强一致,允许 Redis 主从异步复制下的短暂不一致。这一点和资金、订单完全不同——别给易失数据上重一致性的枷锁。
6.2 查询的边界正确性
- 九宫格只覆盖一层时会漏掉对角较远的点:扩层半径要覆盖「半径 + 网格对角线」。
- 用 Haversine 精确距离过滤,不能只靠格子命中(格子内也可能超半径)。
- 跨城市/跨分区:查询按坐标定位到城市分区,避免全库扫描。
6.3 高可用
- Redis 用集群模式按 Geohash 前缀/城市分片,单分片故障只影响局部区域。
- 查询服务无状态,可随意扩缩容;写入服务幂等(同一用户覆盖写)。
- 降级预案:Redis 不可用时,退回 PostGIS 查询(慢但可用)。
一句话:LBS 的高可用思路是「数据可丢、服务可降级」——在线位置丢了能重报,Redis 挂了能退到关系库,别为临时数据搭昂贵的一致性设施。
七、性能与扩展
- Geohash 前缀分片:按 Geohash 前 4
5 位(城市级)把用户分到不同 Redis 分片,查询只打 12 个分片。 - 候选集上限:
COUNT限制候选数量,避免热点区域(如市中心)扫描百万点。 - 热点区域:市中心人多,可对同一格内按活跃度抽样(只索引活跃用户),控制格子基数。
- 读多写少优化:查询结果可短暂缓存(如 10 秒),但 LBS 场景缓存价值有限(位置变化快)。
- 批量 GEOADD:管道(pipeline)批量写,吞吐提升数倍。
容量与热点
- 300 万在线 × 每点约 50 字节 ≈ 150MB,单 Redis 分片轻松容纳;分片是为了分散查询压力而非容量。
- 热点格(商圈、景区)用「子格拆分」把大格拆成多个小格,避免单 key 过大。
八、权衡与备选
| 决策点 | 本文选型 | 备选 | 权衡说明 |
|---|---|---|---|
| 空间索引 | Geohash + 九宫格 | H3 / S2 | Geohash 简单、Redis 原生;H3/S2 更均匀但库重 |
| 在线存储 | Redis GEO | MySQL + 空间索引 | Redis 快、支持 TTL;MySQL 持久但慢、不适合高频写 |
| 离线存储 | PostGIS | Elasticsearch | PostGIS 空间查询强;ES 适合全文+空间混合 |
| 过期策略 | TTL 自动淘汰 | 主动下线 | TTL 简单可靠;主动下线实时但需额外协调 |
| 隐私 | 网格模糊 | 精确坐标 | 模糊保隐私、损精度;精确体验好但有风险 |
关键取舍
- 精度 vs 隐私:模糊到 100m 兼顾可用与隐私,极端场景(约会/社交)需更强模糊。
- 实时 vs 成本:秒级更新体验最好但写入量巨大,可对非活跃用户降频上报。
- 内存 vs 磁盘:Redis 内存贵但快,只存在线用户;历史轨迹落 Kafka/数仓。
九、扩展场景与面试追问
9.1 扩展到「附近的车/门店」
- 静态实体(门店)变化少,放 PostGIS,用 GiST 索引 +
ST_DWithin。 - 动态实体(车)同「附近的人」,但要求更高实时性与匹配,可参考 网约车调度系统设计 的派单匹配。
- 推荐排序时,「附近」只是召回,最终排序可接 推荐系统 的排序模型。
9.2 轨迹与地理围栏
- 轨迹:位置流落 Kafka,批量写时序库/数仓,支持轨迹回放与热力图。
- 地理围栏(Geofencing):用户进出某个多边形区域时触发通知,用「网格 + 多边形相交」判定。
9.3 面试常见追问
| 追问 | 关键回答 |
|---|---|
| 为什么不用 MySQL 存位置? | 60 万 QPS 高频写 + TTL 淘汰,MySQL 扛不住;位置又是易失数据 |
| Geohash 边界问题怎么办? | 查九宫格(中心 + 8 邻格),扩层半径覆盖对角线 |
| 怎么处理市中心热点? | 限制候选数 + 只索引活跃用户 + 子格拆分 |
| 位置数据会丢吗? | 会,但可重建(下次上报),所以不需要强一致 |
| 怎么保护隐私? | 坐标量化到网格 + 隐身开关 + 结果抖动 + 黑名单 |
| 半径 10km 没人怎么办? | 自动扩半径并提示,或放宽到城市级推荐 |
十、总结
| 模块 | 关键设计 | 一句话记忆 |
|---|---|---|
| 空间索引 | Geohash 降维 | 二维查询变字符串前缀 |
| 查询 | 九宫格 + 精确距离 | 格子粗筛、Haversine 精算 |
| 在线存储 | Redis GEO + TTL | 易失数据进内存、过期即删 |
| 离线存储 | PostGIS GiST | 静态 POI 用空间数据库 |
| 隐私 | 网格模糊 + 隐身 | 精度换安全 |
| 可靠 | 可丢可降级 | 临时数据不上重一致性 |
一句话:LBS 的面试核心是讲清楚「如何用 Geohash 把二维空间查询降维、九宫格如何解决边界、在线用 Redis GEO 离线用 PostGIS、以及位置作为易失数据为何不需要强一致」,把 60 万 QPS 写入与 TTL 淘汰挂在嘴边,而不是堆组件。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。