乘客点下“叫车”,就期待几秒钟内有车。在这一下点击背后,系统必须知道数十万名移动中的司机此刻在哪里,挑出合适的一位,确保没有其他乘客分到同一位司机,然后追踪一段以付款结束的行程。它是系统设计面试中的热门题目,因为它把高流量的实时数据流和一个小而严格的事务核心结合在一起。
本文逐步设计这个后端:需求与容量估算、位置接入、使用 geohash、S2 和 H3 的地理空间索引、匹配与派单、行程状态机,以及需要预先规划的故障,并配有一张可以照搬的架构图。
需求
功能需求:
- 司机上线并持续上报位置。
- 乘客发起叫车,并看到附近的车辆和预计接驾时间。
- 系统为乘客匹配一位司机,司机可以接受或拒绝。
- 双方应用都实时追踪行程;行程以车费和付款结束。
非功能需求:
- 几秒钟内完成匹配。
- 司机位置的延迟不超过几秒。
- 绝不把一位司机同时分配给两位乘客。
- 一个城市的问题不能拖垮其他城市。
容量:决定设计的数字
假设在大规模部署中,高峰期有一百万名司机在线,每人每 4 秒上报一次位置,每分钟有 20,000 个叫车请求:
| 指标 | 计算 | 结果 |
|---|---|---|
| 位置更新 | 1,000,000 / 4 s | 每秒 250,000 次 |
| 接入带宽 | 250,000 × 约 100 字节 | 约 25 MB/s |
| 高峰速率下的原始历史数据 | 25 MB/s × 86,400 s | 每天约 2 TB |
| 叫车请求 | 20,000 / 60 s | 每秒约 333 个 |
这里出现了两种截然不同的负载。位置更新的数量极其庞大,但每一条都很小、可丢弃,4 秒后就会被新的更新取代。叫车请求要少三个数量级,但每一个都必须恰好处理一次,并且和钱挂钩。架构把两者分开。
位置接入:只保留当前状态
司机应用通过 WebSocket 这类持久连接,或者小而定期的 HTTPS 请求发送位置。位置网关对司机进行身份验证,丢弃不可能的点(4 秒内跳了 50 km),再把每条更新写入按城市分区的数据流,例如 Kafka。
有三个消费者读取这个数据流:
- 实时地理空间索引只保留每位空闲司机的最新位置,并设置较短的过期时间,这样手机失联的司机会在比如 30 秒后消失。
- 行程追踪把已分配司机的位置转发到乘客的应用。
- 冷存储把完整的历史保存在对象存储中,用于分析、ETA 模型和纠纷处理。
关键决策在于:热索引每位司机一行,而不是每次更新一行。一百万名司机、每人大约一百字节,总共约 100 MB,小到可以按城市放在内存中。
地理空间索引
匹配要回答的问题是“这个点附近有哪些空闲司机?”在纬度和经度上建普通的数据库索引很难回答好这个问题,所以系统会把坐标映射到单元格。
| 索引 | 单元格形状 | 优势 | 注意事项 |
|---|---|---|---|
| Geohash | 矩形,编码为字符串前缀 | 简单;前缀可以嵌套;Redis GEO 使用它 | 单元格形状不一;相邻单元格的前缀可能不同 |
| S2 | 投影立方体上的四边形 | 分层结构,Google 在用 | 理解起来更复杂 |
| H3 | 六边形 | 每个相邻单元格距离相同;16 级分辨率 | 六边形不能完美嵌套 |
Uber 在 2018 年开源的 H3 让半径搜索变得均匀:取乘客所在的单元格及其周围一圈单元格,再收集其中的司机即可。分辨率很重要。根据 H3 分辨率表:
| H3 分辨率 | 平均单元格面积 | 平均边长 |
|---|---|---|
| 7 | 5.16 km² | 1.41 km |
| 8 | 0.74 km² | 0.53 km |
| 9 | 0.11 km² | 0.20 km |
分辨率 8 或 9 适合人口密集的城市;乡村地区需要更大的环或更粗的单元格。
如果想从简单的方案开始,Redis 内置了地理空间命令:
GEOADD drivers:sf -122.4194 37.7749 driver:8812
GEOSEARCH drivers:sf FROMLONLAT -122.4100 37.7800 BYRADIUS 2 km ASC COUNT 20 WITHDIST
GEOSEARCH 从 Redis 6.2 起可用,它返回 2 km 范围内最近的 20 位司机,并按距离排序。先按城市这一天然边界分片,再把机场、体育场这类特别繁忙的地方拆分成更细的单元格。
匹配与派单
直线距离并不能很好地反映接驾时间:河对岸 500 m 外的司机,可能要 15 分钟才能到。因此匹配分两个阶段进行:
- 生成候选。 在地理索引中查询上车点附近的空闲司机,并按车型和其他约束进行筛选。
- 排序。 向 ETA 服务查询排名靠前的候选司机基于路网的接驾时间,按 ETA 排序,并加入司机行驶方向等因素。
每来一个请求就立即匹配最近的司机,是一种贪心策略。把一两秒内的请求攒起来,作为一个分配问题一起求解,可以让所有乘客的总等待时间最小。代价是稍有延迟,换来高峰时段更好的匹配。
然后,派单每次只向一位司机发出邀约,超时时间约为 15 秒。司机拒绝或超时后,邀约转给下一位候选司机。
绝不重复分配司机
两个匹配 worker 可能在同一秒内为两位乘客选中同一位司机。要在司机的状态上防止这种情况,而不是在匹配逻辑里:
- 为每位司机存储状态(
available、offered、on_trip)和一个版本号。 - 邀约是一次条件更新:只有当状态在你读取的版本下仍为
available时,才设置为offered。 - 恰好只有一个 worker 胜出;另一个转向它的下一位候选司机。
- 过期的邀约同样以条件更新的方式,把司机改回
available。
同样的比较并设置(compare-and-set)思路也保护乘客一侧:每位乘客只能有一个进行中的请求。
行程状态机
一段行程会依次经过 requested、matching、driver_assigned、driver_arrived、in_progress、completed 和 paid,并且可以从多个状态取消。行程服务把每段行程存储在 Postgres 这样的关系型数据库中,只在当前状态允许时才执行状态转换,并把每次变化作为事件发布,供计费、通知、收据和分析使用。
让每次状态转换都具备幂等性,以行程 ID 和状态版本为键。移动网络会重试,“司机已到达”送达两次也不能导致任何操作执行两次。状态图示例展示了如何单独画出这个生命周期。
实时更新、ETA 与定价
行程进行中,乘客的应用通过推送连接接收司机的位置,更新频率限制为每隔几秒一次,并在客户端做平滑处理。同一个位置数据流还供给需要实时路况的 ETA 服务,以及动态定价:后者在滑动窗口内按单元格比较请求数和空闲司机数。行程结束后通过支付服务商完成付款;这部分见支付网关集成图。
需要预先规划的故障
- 司机断网。 应用在重新连接时只发送最新的点,索引会让失联的司机过期。
- 匹配 worker 在邀约途中崩溃。 邀约带有过期时间;邀约失效后,司机回到
available。 - 某个城市的集群故障。 由于一切都按城市分区,故障只局限在本地。进行中的行程在持久化的行程存储中保有状态。
- 伪造 GPS。 在网关上对速度和路线做合理性检查,可以拦住粗糙的作弊。
网约车系统图
图表内容
- City partition: everything inside is per city
- Driver app
- Rider app
- Location gateway
- locations topic
- Live geo index (latest, 30 s TTL)
- Location history
- Matching + dispatch
- ETA service
- Trip service
- Trips (Postgres)
- GPS every 4 s
- 250k/s
- latest position
- every point
- POST /rides
- nearby available drivers
- rank by pickup ETA
- driver assigned (CAS)
- state transitions
- ride offer, 15 s to accept
这张图的读法如下:
- 框架代表一个城市,也是其中所有组件的分区边界。
- 位置路径走在上方:司机应用、位置网关、Kafka,再从那里流向 Redis 地理索引和用于历史数据的对象存储库。
- 请求路径走在下方:乘客应用调用匹配服务,匹配服务从地理索引读取候选司机,向 ETA 服务询问接驾时间,并把邀约发给司机。
- 行程服务掌管状态机,并把它保存在 Postgres 中。
- Markdown 块展示了容量估算。
绘制实时系统的系统设计图
- 把热路径和事务路径分开。 这里,位置数据流和行程生命周期的流量、保证和故障模式都不同;把它们画成独立的泳道。
- 把分区键写在图上。 “按城市分区”短短几个字就解释了扩展方式和故障隔离。
- 标明哪些是当前数据,哪些是历史数据。 最新位置索引和历史存储回答的是不同的问题。
- 在连线上标注速率:一个箭头上写着每秒 250,000 次更新,评审者就知道负载在哪里。
关于这类图的通用方法,请参阅系统设计图示例。推送连接见聊天应用架构图,数据流本身见 Kafka 架构图。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加一个基础节点包中的 框架,以城市命名。
- 为司机应用和乘客应用添加两个网络节点包中的 设备 节点。
- 为位置网关添加一个云节点包中的 API 网关,为数据流添加一个数据节点包中的 Kafka 节点,为地理索引添加一个 Redis 节点,并为历史数据添加一个 对象存储库。
- 为匹配、ETA 和行程服务添加三个云节点包中的 计算实例 节点,并为行程添加一个数据节点包中的 Postgres 节点。
- 在上方连接位置路径,在其下方连接请求路径,并在连线上标注它们传输的内容和频率。
- 添加一个基础节点包中的 Markdown 块,写上容量数字。
这个示例共用 12 个图表节点,在 Free 套餐的限制之内。
常见问题
司机应该多久上报一次位置? 每隔几秒是常见的范围。更新越频繁,位置越新鲜,但接入量也会成倍增加:把间隔从 4 秒减半到 2 秒,流量就会翻倍。
我应该用 geohash、S2 还是 H3? 哪一种都可以。geohash 最简单,而且内置于 Redis;H3 的相邻单元格距离一致,让半径搜索和按单元格定价更加简洁。选定一种并始终如一地使用。
如何避免把一位司机分配给两位乘客? 把邀约做成对司机状态的条件更新,只有当司机在匹配器读取的版本下仍然空闲时才会成功。只有一个匹配器能胜出。
为什么不立即把每位乘客匹配给最近的司机? 贪心匹配可能让后来的乘客等待很久。把一两秒内的请求合并起来一起求解,能以短暂的延迟为代价降低总等待时间。
用什么数据库存储行程? 关系型数据库很合适:行程是事务性的,需要严格的状态转换,并且要供给计费。高流量的位置数据则放在别处,在内存和对象存储中。