设计一个 LBS 附近的人系统(Geohash 与空间索引)

本文系统设计一个 LBS「附近的人」系统:需求澄清与量级估算、Geohash/H3/S2 空间索引对比、地理位置存储与查询(Redis GEO/PostGIS)、九宫格与多级邻居搜索、位置更新与过期淘汰、隐私与模糊化处理,并给出编码伪代码、查询流程与空间索引权衡。

「附近的人」「附近的门店」「附近的车」这类 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/离线)  │  │
   │  └──────────────┘  └──────────────┘  └────────────────┘  │
   └───────────────────────────────────────────────────────────┘

四层职责:

  1. 接入网关:鉴权、限流、位置脱敏(模糊到网格),把读写分流。
  2. 写入服务:编码位置、批量写入 Redis,设置 TTL 自动淘汰离线用户。
  3. 查询服务:把「圆心+半径」转成若干网格,合并候选集后精确算距离过滤。
  4. 存储层:热数据用 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 长度 → 网格大小):

长度单元宽 × 高典型用途
54.9km × 4.9km城市级粗筛
61.2km × 0.6km街区级
7153m × 153m附近的人
94.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 GEOonline_users ZSet在线用户实时位置
Redis GEOpoi:{city}城市内静态 POI(可选)
PostGISpoi(id, name, geom, tags)静态门店,GiST 索引
MySQLuser_geo_pref(uid, hidden, precision)隐私设置
Kafkalocation_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 前 45 位(城市级)把用户分到不同 Redis 分片,查询只打 12 个分片。
  • 候选集上限:COUNT 限制候选数量,避免热点区域(如市中心)扫描百万点。
  • 热点区域:市中心人多,可对同一格内按活跃度抽样(只索引活跃用户),控制格子基数。
  • 读多写少优化:查询结果可短暂缓存(如 10 秒),但 LBS 场景缓存价值有限(位置变化快)。
  • 批量 GEOADD:管道(pipeline)批量写,吞吐提升数倍。

容量与热点

  • 300 万在线 × 每点约 50 字节 ≈ 150MB,单 Redis 分片轻松容纳;分片是为了分散查询压力而非容量。
  • 热点格(商圈、景区)用「子格拆分」把大格拆成多个小格,避免单 key 过大。

八、权衡与备选

决策点本文选型备选权衡说明
空间索引Geohash + 九宫格H3 / S2Geohash 简单、Redis 原生;H3/S2 更均匀但库重
在线存储Redis GEOMySQL + 空间索引Redis 快、支持 TTL;MySQL 持久但慢、不适合高频写
离线存储PostGISElasticsearchPostGIS 空间查询强;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 淘汰挂在嘴边,而不是堆组件。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个视频会议系统(WebRTC SFU)
  2. 设计一个 A/B 测试与实验平台
  3. 设计一个分布式锁服务