场景设计:美团/饿了么如何找出附近 1km 的商家
本文讨论通用外卖 LBS 场景,不代表美团或饿了么真实内部实现。
1. 面试官真正想考什么
“找出附近 1km 的商家”看起来只是一个距离计算题,实际会连续考察以下能力:
- 是否能定义清楚“附近”和“1km”;
- 是否理解经纬度不能直接当平面坐标计算;
- 是否知道空间索引的作用,而不是全表扫描后逐个算距离;
- 是否能把地理候选集、营业状态、配送范围、库存、排序拆成不同阶段;
- 是否能处理热点区域、高并发、分页和数据实时性;
- 是否知道直线距离不等于道路距离,更不等于配送时间。
一个成熟回答不能停留在一句:
把商家经纬度放进 Redis GEO,然后使用 GEOSEARCH 查询。
Redis GEO 只是候选召回组件,完整系统还包括数据更新、精确过滤、业务排序、缓存和降级。
2. 先明确需求边界
2.1 “1km”到底是哪一种距离
常见距离口径有三种:
| 距离口径 | 含义 | 适用场景 |
|---|---|---|
| 球面直线距离 | 地球表面两点之间的大圆距离 | 首轮附近候选召回 |
| 道路距离 | 沿道路网络实际行驶的距离 | 配送费、可达性判断 |
| 预计配送时间 | 路径、路况、运力、楼宇等综合估算 | 最终排序和履约承诺 |
面试题中的“附近 1km”一般先按球面直线距离理解,但生产系统往往采用两阶段甚至三阶段模型:
1 | |
如果一开始就对所有商家调用地图路径规划服务,成本和延迟都会失控。
2.2 查询对象是商家还是门店
同一个品牌可能有数百家门店。真正参与地理检索的通常不是品牌,而是可履约的门店实体:
1 | |
因此空间索引的 member 应使用 store_id,而不是品牌 ID。
2.3 不能只看圆形半径
即使门店距离用户只有 800 米,也可能出现:
- 门店未营业;
- 超出门店自定义配送多边形;
- 隔着河流、高速或封闭园区;
- 门店爆单暂停接单;
- 用户地址不在该门店服务城市;
- 店铺被风控、下线或商品不可售。
所以“1km”只是空间候选条件,不是最终可下单条件。
3. 总体架构
flowchart LR
U[用户 App] --> GW[API Gateway]
GW --> LBS[LBS 查询服务]
LBS --> LOC[位置标准化服务]
LOC --> GEO[(空间索引<br/>Redis GEO / ES / PostGIS / H3)]
LBS --> CACHE[(门店摘要缓存)]
LBS --> STORE[门店服务]
LBS --> DELIVERY[配送范围与 ETA 服务]
LBS --> RANK[排序服务]
STORE --> DB[(门店主库)]
DELIVERY --> MAP[地图与路网服务]
LBS --> RESULT[分页结果]
RESULT --> U
CDC[门店变更 CDC / MQ] --> INDEXER[空间索引更新器]
INDEXER --> GEO
CDC --> CACHE
一次查询可以拆成五步:
- 校验并标准化用户坐标;
- 从空间索引召回 1km 内的候选门店 ID;
- 批量获取门店营业、类目、评分等摘要;
- 执行业务过滤和可配送校验;
- 按综合策略排序并游标分页。
4. 经纬度为什么不能直接相减
经纬度是球面坐标,不是简单的二维平面坐标。纬度差一度和经度差一度代表的实际距离不同,而且经度对应的距离会随纬度变化。
4.1 Haversine 公式
对于两点:
1 | |
可使用 Haversine 公式估算球面距离:
1 | |
其中 R 为地球平均半径,约 6371km。
问题在于:即使单次计算很快,对数百万门店逐条计算仍然不可接受。真正的关键不是距离公式,而是如何先利用索引缩小候选集。
5. 方案一:Redis GEO
5.1 数据写入
Redis GEO 底层使用有序集合保存地理位置编码,适合快速查询附近点位。
1 | |
建议按城市或区域拆 Key:
1 | |
不要把全国所有门店塞进一个 Key。城市拆分可以降低单 Key 规模,也方便数据治理和故障隔离。
5.2 半径查询
新系统应优先使用 GEOSEARCH,而不是已被 Redis 标记为旧式接口的 GEORADIUS:
1 | |
返回结果通常只包含:
- 门店 ID;
- 距离;
- 可选经纬度。
门店名称、营业状态、评分等业务字段不应全部塞进 GEO Key,而应通过批量缓存查询获取。
5.3 Redis GEO 的优缺点
优点:
- 延迟低,适合在线查询;
- 接入简单;
- 支持按距离排序和限制数量;
- 适合门店坐标相对稳定的场景。
局限:
- 复杂过滤能力较弱;
- 不适合直接表达复杂配送多边形;
- 数据以内存为主,规模和成本需要评估;
- 空间索引与门店数据库之间存在最终一致性问题。
所以 Redis GEO 更适合做“地理候选召回层”,不是完整门店检索引擎。
6. 方案二:PostGIS
如果系统使用 PostgreSQL,可以使用 PostGIS 的 geography 类型和 GiST 索引。
6.1 表结构
1 | |
6.2 1km 查询
1 | |
关键点是使用 ST_DWithin 先走空间索引缩小候选集,再使用 ST_Distance 排序。直接写:
1 | |
可能更难有效利用空间索引。
6.3 适用场景
PostGIS 更适合:
- 数据量中等,但过滤逻辑复杂;
- 需要多边形、行政区、配送围栏等空间运算;
- 希望空间数据与业务事务保持更强一致性;
- 不想维护独立搜索集群。
7. 方案三:Elasticsearch Geo 查询
如果门店搜索还需要同时支持:
- 菜品和店名全文检索;
- 类目过滤;
- 品牌、评分、销量筛选;
- 地理距离排序;
- 多维相关性评分;
那么 Elasticsearch 更自然。
示例查询:
1 | |
缺点是索引存在刷新延迟,不能把它当作订单接单状态的绝对事实来源。搜索结果返回后,仍应由门店/交易域做关键状态校验。
8. 方案四:Geohash 或 H3 网格
当业务规模扩展到城市级调度、热力分析、骑手匹配和区域聚合时,可以把经纬度映射为网格 ID。
8.1 基本思路
1 | |
flowchart TB
C0[用户所在中心网格]
C0 --- C1[邻格 1]
C0 --- C2[邻格 2]
C0 --- C3[邻格 3]
C0 --- C4[邻格 4]
C0 --- C5[邻格 5]
C0 --- C6[邻格 6]
C0 --> F[合并网格内门店]
C1 --> F
C2 --> F
C3 --> F
C4 --> F
C5 --> F
C6 --> F
F --> D[精确距离过滤]
不能只查用户所在的一个网格,因为用户可能位于网格边缘,距离很近的门店却位于相邻网格。
8.2 网格方案的价值
- 分片键天然明确;
- 易做热力统计和区域聚合;
- 可用于骑手、订单、门店的统一空间表达;
- 支持提前计算邻接网格;
- 对海量动态对象更容易水平扩展。
但网格只能做近似召回,最终仍要进行真实距离或配送范围判断。
9. 推荐的生产级查询流程
sequenceDiagram
participant App as 用户 App
participant API as LBS API
participant Geo as 空间索引
participant Cache as 门店摘要缓存
participant Store as 门店服务
participant ETA as 配送/ETA 服务
participant Rank as 排序服务
App->>API: 查询附近 1km 门店(lat, lon, filters, cursor)
API->>API: 坐标校验、城市识别、参数归一化
API->>Geo: 召回 Top 200~500 候选门店
Geo-->>API: storeId + straightDistance
API->>Cache: MGET 门店摘要
Cache-->>API: 状态、类目、评分、活动等
API->>API: 营业/风控/类目等快速过滤
API->>Store: 批量回源缺失或关键状态
Store-->>API: 最新门店状态
API->>ETA: 对 Top N 计算配送可达性/ETA
ETA-->>API: 可配送、道路距离、预计时间
API->>Rank: 综合排序
Rank-->>API: 排序结果
API-->>App: 数据 + nextCursor
9.1 为什么先召回 200~500 个,而不是直接返回 20 个
因为空间索引只知道距离,不知道后续有多少门店会因以下原因被过滤:
- 休息中;
- 超配送范围;
- 风控下线;
- 类目不匹配;
- 没有可售商品;
- 不满足会员或活动条件。
如果只召回 20 个,过滤后可能只剩 3 个。候选集应预留一定过召回比例,但也不能无限放大。
10. 配送范围不能只用圆
很多门店的真实配送范围是多边形,甚至包含多个不连续区域。
flowchart LR
P[用户坐标] --> B{是否落入门店配送多边形}
B -- 否 --> X[过滤门店]
B -- 是 --> R{道路是否可达}
R -- 否 --> X
R -- 是 --> E[计算 ETA 与配送费]
可采用:
- PostGIS
ST_Contains/ST_Intersects; - 地图供应商的围栏能力;
- 将配送多边形预切分为 H3 网格集合;
- 先用包围盒过滤,再做精确点在多边形内判断。
11. 排序设计
用户说“附近”,不一定意味着只按距离升序。
一个简化综合分可以写成:
1 | |
面试时应强调两点:
- 过滤和排序必须分开,不能让商业排序突破“不可配送”等硬约束;
- 首页可以综合排序,但用户选择“距离最近”时必须遵守明确的排序语义。
12. 游标分页,而不是深分页
如果排序字段是:
1 | |
游标应携带上一条记录的完整排序键:
1 | |
下一页通过 search_after 或等价条件继续查。不要依赖大 OFFSET,因为:
- 深分页成本高;
- 门店状态动态变化时容易重复或遗漏;
- 排序分数实时变化,页码语义不稳定。
如果要求一次滚动期间结果稳定,可以生成短时有效的 query_snapshot_id,冻结一部分候选或排序版本。
13. 数据更新与一致性
13.1 哪些数据进入空间索引
适合进入地理索引的字段:
store_id;- 经纬度;
- 城市或分片信息。
营业状态、评分、库存等高频字段可以进入门店摘要缓存或搜索索引,但要避免把所有业务状态耦合进一个超大 Redis GEO Key。
13.2 更新链路
flowchart LR
Admin[商家后台修改门店] --> DB[(门店数据库)]
DB --> OUTBOX[(Outbox / CDC)]
OUTBOX --> MQ[消息队列]
MQ --> GEO[更新空间索引]
MQ --> CACHE[失效门店缓存]
MQ --> SEARCH[更新搜索索引]
MQ --> AUDIT[审计与重放日志]
推荐数据库为事实源,空间索引为派生数据。通过 CDC 或 Outbox 事件更新索引,并提供:
- 消费幂等;
- 失败重试;
- 死信队列;
- 全量重建;
- 数据对账。
对于门店关停这类关键状态,查询结果返回前还可做一次批量状态校验。
14. 热点与高并发优化
14.1 热点区域
商圈、车站、办公园区会形成热点坐标。可采用:
- 按城市/区域拆分空间 Key;
- 对近似坐标做网格级短缓存;
- 只缓存候选门店 ID,不缓存强个性化最终结果;
- 热点 Key 本地缓存 + Redis 二级缓存;
- 异步预热热门商圈。
14.2 坐标归一化缓存
把经纬度映射到一个较小网格,例如 100~200 米网格:
1 | |
同一网格中的用户可复用初始候选集,再根据用户精确坐标重算距离和排序。
注意:网格缓存不能直接返回精确距离,否则会出现用户移动几十米但距离不变的问题。
14.3 批量查询
避免对每个门店调用一次 RPC:
1 | |
15. 容量估算示例
假设:
- 1000 万日活;
- 每个用户每天触发 20 次附近查询;
- 峰值系数为平均值的 10 倍;
平均 QPS:
1 | |
峰值约:
1 | |
如果每次召回 300 个候选,门店摘要查询和排序链路必须批量化,且需要控制 ETA 服务只处理最终 Top N,否则下游调用量会被候选数放大。
16. 常见错误回答
错误一:直接用 SQL 比较经纬度差
1 | |
它只能得到近似矩形,而且不同纬度误差不同,不能代表精确 1km。
错误二:全表执行距离公式
距离公式本身没有问题,问题是没有空间索引和候选集裁剪。
错误三:只按直线距离判断可配送
直线距离适合召回,不适合直接承诺配送。
错误四:把 Redis 当唯一事实源
空间索引丢失或延迟时,应能从数据库/搜索索引重建。关键交易状态不能只依赖派生缓存。
错误五:忽略分页稳定性
动态排序下,普通页码很容易出现重复和漏项,应使用稳定排序键和游标。
17. 面试时可以这样总结
我会先明确 1km 是球面直线距离,实际配送还需要道路和配送范围校验。查询链路采用空间索引粗召回,再批量获取门店摘要,执行营业、风控、配送围栏等硬过滤,最后计算 ETA 并综合排序。中小规模可以用 PostGIS,低延迟高并发候选召回可用 Redis GEO,搜索过滤复杂时可用 Elasticsearch,超大规模区域计算可引入 H3。数据库是事实源,空间索引通过 CDC 或 Outbox 最终一致更新,并提供重建和对账能力。分页使用复合排序游标,不使用深 OFFSET。
18. 延伸追问
- 如果用户定位漂移 500 米,如何避免结果频繁跳动?
- 如果门店坐标批量修正,如何无停机重建索引?
- 如何设计跨城市边界的附近查询?
- 如何处理一个门店多个取餐点?
- 如果 Redis GEO 故障,降级到什么程度?
- 如何证明“附近结果为空”不是索引漏数据?
- 地理位置属于敏感信息,日志和埋点如何脱敏?
参考资料
- Redis Geospatial: https://redis.io/docs/latest/develop/data-types/geospatial/
- Redis GEOSEARCH: https://redis.io/docs/latest/commands/geosearch/
- PostGIS ST_DWithin: https://postgis.net/docs/ST_DWithin.html
- Elasticsearch Geo Queries: https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html
- H3 Documentation: https://h3geo.org/docs/