场景设计:美团/饿了么如何找出附近 1km 的商家

本文讨论通用外卖 LBS 场景,不代表美团或饿了么真实内部实现。

1. 面试官真正想考什么

“找出附近 1km 的商家”看起来只是一个距离计算题,实际会连续考察以下能力:

  1. 是否能定义清楚“附近”和“1km”;
  2. 是否理解经纬度不能直接当平面坐标计算;
  3. 是否知道空间索引的作用,而不是全表扫描后逐个算距离;
  4. 是否能把地理候选集、营业状态、配送范围、库存、排序拆成不同阶段;
  5. 是否能处理热点区域、高并发、分页和数据实时性;
  6. 是否知道直线距离不等于道路距离,更不等于配送时间。

一个成熟回答不能停留在一句:

把商家经纬度放进 Redis GEO,然后使用 GEOSEARCH 查询。

Redis GEO 只是候选召回组件,完整系统还包括数据更新、精确过滤、业务排序、缓存和降级。

2. 先明确需求边界

2.1 “1km”到底是哪一种距离

常见距离口径有三种:

距离口径 含义 适用场景
球面直线距离 地球表面两点之间的大圆距离 首轮附近候选召回
道路距离 沿道路网络实际行驶的距离 配送费、可达性判断
预计配送时间 路径、路况、运力、楼宇等综合估算 最终排序和履约承诺

面试题中的“附近 1km”一般先按球面直线距离理解,但生产系统往往采用两阶段甚至三阶段模型:

1
空间索引粗召回 -> 球面距离精确过滤 -> 路网/ETA 二次评估

如果一开始就对所有商家调用地图路径规划服务,成本和延迟都会失控。

2.2 查询对象是商家还是门店

同一个品牌可能有数百家门店。真正参与地理检索的通常不是品牌,而是可履约的门店实体:

1
brand -> merchant -> store -> delivery zone

因此空间索引的 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

一次查询可以拆成五步:

  1. 校验并标准化用户坐标;
  2. 从空间索引召回 1km 内的候选门店 ID;
  3. 批量获取门店营业、类目、评分等摘要;
  4. 执行业务过滤和可配送校验;
  5. 按综合策略排序并游标分页。

4. 经纬度为什么不能直接相减

经纬度是球面坐标,不是简单的二维平面坐标。纬度差一度和经度差一度代表的实际距离不同,而且经度对应的距离会随纬度变化。

4.1 Haversine 公式

对于两点:

1
2
P1(lat1, lon1)
P2(lat2, lon2)

可使用 Haversine 公式估算球面距离:

1
2
3
4
5
6
a = sin²(Δlat / 2)
+ cos(lat1) × cos(lat2) × sin²(Δlon / 2)

c = 2 × atan2(√a, √(1-a))

d = R × c

其中 R 为地球平均半径,约 6371km。

问题在于:即使单次计算很快,对数百万门店逐条计算仍然不可接受。真正的关键不是距离公式,而是如何先利用索引缩小候选集。

5. 方案一:Redis GEO

5.1 数据写入

Redis GEO 底层使用有序集合保存地理位置编码,适合快速查询附近点位。

1
2
GEOADD geo:store:shanghai 121.4737 31.2304 100001
GEOADD geo:store:shanghai 121.4800 31.2250 100002

建议按城市或区域拆 Key:

1
geo:store:{city_id}

不要把全国所有门店塞进一个 Key。城市拆分可以降低单 Key 规模,也方便数据治理和故障隔离。

5.2 半径查询

新系统应优先使用 GEOSEARCH,而不是已被 Redis 标记为旧式接口的 GEORADIUS

1
2
3
4
5
6
GEOSEARCH geo:store:shanghai
FROMLONLAT 121.4750 31.2280
BYRADIUS 1 km
ASC
WITHDIST
COUNT 200

返回结果通常只包含:

  • 门店 ID;
  • 距离;
  • 可选经纬度。

门店名称、营业状态、评分等业务字段不应全部塞进 GEO Key,而应通过批量缓存查询获取。

5.3 Redis GEO 的优缺点

优点:

  • 延迟低,适合在线查询;
  • 接入简单;
  • 支持按距离排序和限制数量;
  • 适合门店坐标相对稳定的场景。

局限:

  • 复杂过滤能力较弱;
  • 不适合直接表达复杂配送多边形;
  • 数据以内存为主,规模和成本需要评估;
  • 空间索引与门店数据库之间存在最终一致性问题。

所以 Redis GEO 更适合做“地理候选召回层”,不是完整门店检索引擎。

6. 方案二:PostGIS

如果系统使用 PostgreSQL,可以使用 PostGIS 的 geography 类型和 GiST 索引。

6.1 表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE store_location (
store_id BIGINT PRIMARY KEY,
city_id BIGINT NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL,
status SMALLINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_store_location_gist
ON store_location USING GIST (location);

CREATE INDEX idx_store_city_status
ON store_location (city_id, status);

6.2 1km 查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT
store_id,
ST_Distance(
location,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
) AS distance_m
FROM store_location
WHERE city_id = :cityId
AND status = 1
AND ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
1000
)
ORDER BY distance_m, store_id
LIMIT 100;

关键点是使用 ST_DWithin 先走空间索引缩小候选集,再使用 ST_Distance 排序。直接写:

1
WHERE ST_Distance(location, point) <= 1000

可能更难有效利用空间索引。

6.3 适用场景

PostGIS 更适合:

  • 数据量中等,但过滤逻辑复杂;
  • 需要多边形、行政区、配送围栏等空间运算;
  • 希望空间数据与业务事务保持更强一致性;
  • 不想维护独立搜索集群。

7. 方案三:Elasticsearch Geo 查询

如果门店搜索还需要同时支持:

  • 菜品和店名全文检索;
  • 类目过滤;
  • 品牌、评分、销量筛选;
  • 地理距离排序;
  • 多维相关性评分;

那么 Elasticsearch 更自然。

示例查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"query": {
"bool": {
"filter": [
{
"geo_distance": {
"distance": "1km",
"location": {
"lat": 31.228,
"lon": 121.475
}
}
},
{ "term": { "city_id": 310100 } },
{ "term": { "open": true } }
]
}
},
"sort": [
{
"_geo_distance": {
"location": [121.475, 31.228],
"order": "asc",
"unit": "m"
}
},
{ "store_id": "asc" }
],
"size": 100
}

缺点是索引存在刷新延迟,不能把它当作订单接单状态的绝对事实来源。搜索结果返回后,仍应由门店/交易域做关键状态校验。

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
2
3
4
5
6
7
8
score = w1 × 距离衰减
+ w2 × 预计送达速度
+ w3 × 评分
+ w4 × 销量与转化率
+ w5 × 个性化偏好
+ w6 × 活动与商业策略
- w7 × 爆单惩罚
- w8 × 取消风险

面试时应强调两点:

  1. 过滤和排序必须分开,不能让商业排序突破“不可配送”等硬约束;
  2. 首页可以综合排序,但用户选择“距离最近”时必须遵守明确的排序语义。

12. 游标分页,而不是深分页

如果排序字段是:

1
rank_score DESC, distance ASC, store_id ASC

游标应携带上一条记录的完整排序键:

1
2
3
4
5
{
"rankScore": 0.8732,
"distance": 642.8,
"storeId": 100023
}

下一页通过 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
nearby:{cityId}:{gridId}:{filterHash}

同一网格中的用户可复用初始候选集,再根据用户精确坐标重算距离和排序。

注意:网格缓存不能直接返回精确距离,否则会出现用户移动几十米但距离不变的问题。

14.3 批量查询

避免对每个门店调用一次 RPC:

1
2
错误:N 个候选 -> N 次门店 RPC -> N 次活动 RPC
正确:N 个候选 -> 批量门店摘要 -> 批量活动 -> 批量 ETA

15. 容量估算示例

假设:

  • 1000 万日活;
  • 每个用户每天触发 20 次附近查询;
  • 峰值系数为平均值的 10 倍;

平均 QPS:

1
10,000,000 × 20 / 86,400 ≈ 2,315 QPS

峰值约:

1
2,315 × 10 ≈ 23,150 QPS

如果每次召回 300 个候选,门店摘要查询和排序链路必须批量化,且需要控制 ETA 服务只处理最终 Top N,否则下游调用量会被候选数放大。

16. 常见错误回答

错误一:直接用 SQL 比较经纬度差

1
2
WHERE ABS(lat - :lat) < 0.01
AND ABS(lon - :lon) < 0.01

它只能得到近似矩形,而且不同纬度误差不同,不能代表精确 1km。

错误二:全表执行距离公式

距离公式本身没有问题,问题是没有空间索引和候选集裁剪。

错误三:只按直线距离判断可配送

直线距离适合召回,不适合直接承诺配送。

错误四:把 Redis 当唯一事实源

空间索引丢失或延迟时,应能从数据库/搜索索引重建。关键交易状态不能只依赖派生缓存。

错误五:忽略分页稳定性

动态排序下,普通页码很容易出现重复和漏项,应使用稳定排序键和游标。

17. 面试时可以这样总结

我会先明确 1km 是球面直线距离,实际配送还需要道路和配送范围校验。查询链路采用空间索引粗召回,再批量获取门店摘要,执行营业、风控、配送围栏等硬过滤,最后计算 ETA 并综合排序。中小规模可以用 PostGIS,低延迟高并发候选召回可用 Redis GEO,搜索过滤复杂时可用 Elasticsearch,超大规模区域计算可引入 H3。数据库是事实源,空间索引通过 CDC 或 Outbox 最终一致更新,并提供重建和对账能力。分页使用复合排序游标,不使用深 OFFSET。

18. 延伸追问

  1. 如果用户定位漂移 500 米,如何避免结果频繁跳动?
  2. 如果门店坐标批量修正,如何无停机重建索引?
  3. 如何设计跨城市边界的附近查询?
  4. 如何处理一个门店多个取餐点?
  5. 如果 Redis GEO 故障,降级到什么程度?
  6. 如何证明“附近结果为空”不是索引漏数据?
  7. 地理位置属于敏感信息,日志和埋点如何脱敏?

参考资料


场景设计:美团/饿了么如何找出附近 1km 的商家
https://allendericdalexander.github.io/2026/07/29/archtect/examples/01-nearby-merchants-within-1km/
作者
AtLuoFu
发布于
2026年7月29日
许可协议