第 7 章 / 共 57 章 · 约 9 分钟
案例 5:设计网约车派单系统(类 Uber)
先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。
先写你的思路
只存在这台设备上;登录后会存进你的账号。
网约车这道题的重点不在“下单”,而在两件事:海量司机位置的高频写入和就近查询,以及派单时绝不能把一个司机同时派给两个乘客。前者考地理索引,后者考并发控制。
1. 需求澄清
功能需求:
- 乘客输入起终点,拿到预估价格和接驾时间
- 乘客确认叫车,系统匹配附近司机
- 司机在限定时间内接单或拒单;拒单或超时就派给下一位
- 行程中双方实时看到对方位置
- 订单状态流转:匹配中、已派单、司机已到达、行程中、已完成、已取消
不做:支付结算、评价、拼车、动态调价算法(只讲它对架构的影响)。
非功能需求:
- 匹配要快:几秒内向司机发出邀约,一分钟内要么匹配成功,要么明确告诉乘客没车
- 派单强一致:一个司机同一时刻只能收到一个邀约,一个订单只能被一个司机接走
- 位置写入吞吐高:这是整个系统写流量的大头
- 按地域隔离:一个城市的高峰不能拖垮其他城市
2. 容量估算
| 指标 | 估算 |
|---|---|
| 峰值在线司机 | 200 万 |
| 位置上报频率 | 每 4 秒一次 |
| 位置写入 QPS | 200 万 / 4 = 50 万/秒 |
| 日订单量 | 2000 万 |
| 下单 QPS | 平均约 230/秒,峰值按 10 倍约 2300/秒 |
结论很明确:位置写入比下单高两到三个数量级(对平均下单量是三个数量级以上,对峰值是两个)。而且派单只关心司机此刻在哪,几秒前的位置已经没用了。所以位置数据应该放内存、只保留最新值,历史轨迹异步落盘。
3. 核心实体与接口
实体:Rider、Driver(状态:离线 / 空闲 / 邀约中 / 行程中)、Ride、FareEstimate、DriverLocation。
POST /fare-estimates {pickup, dropoff} → {estimate_id, price, eta}
POST /rides {estimate_id} → {ride_id, status}
POST /drivers/location {lat, lng, ts} // 高频,也可以走长连接
POST /rides/{id}/offers/{oid} {accept: true | false} // 司机响应邀约
PATCH /rides/{id} {status: arrived | in_trip | completed | cancelled}
有两个细节值得主动说:
- 下单只传
estimate_id,价格存在服务端。客户端传来的价格一律不信。 - 用户身份从鉴权 token 里取,不放在请求体中。
新邀约、司机位置、状态变化这类服务器主动推的消息走 WebSocket;App 在后台时退回系统推送。
4. 高层架构
查看图表源码
flowchart LR
accTitle: 网约车派单的高层架构
rider["Rider App"] --> gwy["API Gateway"]
driver["Driver App"] --> gwy
gwy --> ride["Ride Service"] --> ridedb[("Ride DB(按城市分片)")]
ride -->|"下单事件"| mq["Matching Queue"] --> match["Matching Service"]
gwy --> match
driver -->|"位置上报"| loc["Location Service"] --> geo[("Geo Index(内存)")]
geo --> match
match --> lock[("Driver Lock(Redis,带 TTL)")]
match --> eta["ETA Service(路网估时)"]
loc --> kafka["Kafka"] --> trail["轨迹归档(计价、风控、分析)"]
push["Realtime Push(WebSocket / APNs / FCM)"] -->|"邀约、位置、状态"| rider
push --> driver
一次派单的过程:
- 乘客下单,Ride Service 写入
Ride(status=MATCHING),把派单任务放进持久化队列。 - Matching Service 从 Geo Index 查上车点半径 R 内的空闲司机。
- 按路网 ETA 排序。直线距离会被河流、高架、单行道误导。
- 给排第一的司机加锁:
SET driver:{id}:lock {ride_id} NX PX 15000。加锁失败说明他正被别的订单邀约,换下一位。 - 加锁成功后推送邀约。司机接单时,Ride Service 用条件更新把订单改为
DRIVER_ASSIGNED,同时把司机状态设为行程中。 - 拒单或 15 秒超时:释放锁,换下一个候选;候选用完就扩大半径重查,到上限仍没有就通知乘客。
5. 深挖
5.1 地理索引怎么选
| 方案 | 思路 | 优点 | 问题 |
|---|---|---|---|
| SQL 经纬度范围查询 | lat BETWEEN … AND lng BETWEEN … | 简单 | B 树不擅长二维范围查询,也扛不住 50 万/秒写入 |
| Geohash | 经纬度编码成字符串,前缀相同代表位置相近 | 更新只是改一个值;Redis GEO 内部就是这个原理 | 格子边缘两侧的点前缀可能完全不同,要连同周围 8 个格子一起查 |
| 四叉树 | 按点的密度递归切分区域 | 密集区格子小,稀疏区格子大 | 点一直在动,节点频繁拆分合并,维护成本高 |
| H3 六边形网格 | 把地球表面切成多层六边形 | 六个邻居到中心距离相等,按环扩展查询很自然 | 要引入专门的库,概念上稍复杂 |
对一直在移动的司机,固定网格(geohash 或 H3)比四叉树合适:更新位置只是从一个格子挪到另一个格子。四叉树更适合静态数据,比如餐厅和地标。
实现上可以直接用 Redis:GEOADD drivers:{city} lng lat driver_id 写入,GEOSEARCH drivers:{city} FROMLONLAT lng lat BYRADIUS 3 km 查询。按城市拆 key,天然就是分片。
5.2 扛住 50 万/秒的位置写入
- 只存最新位置,放内存。丢一条无所谓,4 秒后下一条就来了。
- 按城市或网格分片,多个 Redis 实例并行写。
- 客户端自适应上报:司机停着不动时降频,行程中升频。
- 按上报时间戳做“后写入者胜”,丢掉乱序到达的旧位置。
- 完整轨迹写 Kafka 再归档,用于事后计费和风控,不挡在主路径上。
5.3 不重复派单
要在两个层面分别守住:
- 司机层面,同一时刻只能有一个邀约:用带 TTL 的分布式锁。TTL 不能省。Matching Service 加完锁就崩溃的话,没有 TTL 这个司机会被永远锁住,再也收不到单。
- 订单层面,只能被接一次:司机点接单时执行条件更新。
UPDATE rides
SET status = 'DRIVER_ASSIGNED', driver_id = :d, version = version + 1
WHERE ride_id = :r AND status = 'MATCHING' AND version = :v;
影响行数为 0,说明订单已经被取消或被别人接走,直接返回失败。
释放锁也有讲究。拒单或超时后要删锁,但不能直接 DEL:如果这把锁已经因 TTL 过期、又被另一个订单重新加上,直接删就会误删别人的锁。要用 Lua 脚本先比较 value 是不是自己的 ride_id,是才删除(compare-and-delete)。
还要说清楚以谁为准。Redis 锁只表示“这个司机正在看一个邀约”,是短期的协调状态;司机有没有接单、是否在行程中,以 Ride DB 为准。接单时在同一个事务里条件更新订单(MATCHING → DRIVER_ASSIGNED)和司机状态(AVAILABLE → ON_TRIP),这样即使 Redis 锁因主从切换丢失,一个司机也接不下两单。事务提交后,再把司机从 Geo Index 的空闲集合里移除。
还有一个容易漏掉的:乘客连点两次下单。POST /rides 要带幂等键,否则会生成两张订单。
5.4 派单服务自己挂了怎么办
派单任务放在持久化队列里(Kafka 或 SQS),Worker 处理完才确认。Worker 崩溃后任务会被重新投递;订单状态机在数据库里,新 Worker 读到 MATCHING 就继续派。邀约超时这类“N 秒后再做某事”的逻辑,用延迟队列或 Temporal 这类工作流引擎实现,不要用进程内的 sleep,进程一重启就全没了。
5.5 进阶:批量匹配
上面是贪心策略:来一单就派最近的司机。高峰期这样做全局并不最优,前一单可能抢走了离后一单更近的司机。改进办法是把 1 到 2 秒内的订单攒在一起,当作二分图匹配求总 ETA 最小。代价是每单多等一两秒。能讲出这个取舍,就是加分项。
5.6 热点
演唱会散场,同一片区域几千人同时叫车:
- 城市之间服务独立分片,一个城市的高峰不影响别处
- 订单进队列排队,不直接同步压到数据库
- 动态调价从供需两端调节:抑制一部分需求,同时把附近司机吸引过来
5.7 行程中的实时位置
司机位置照常上报给 Location Service,同时发布到 ride:{id} 频道。乘客所在的网关订阅这个频道并推给乘客。每 4 秒一个点,客户端做插值动画,看起来就是连续移动。
6. 订单状态机
查看图表源码
flowchart LR
accTitle: 订单状态机
matching["MATCHING"] --> assigned["DRIVER_ASSIGNED"] --> arrived["DRIVER_ARRIVED"] --> trip["IN_TRIP"] --> done["COMPLETED"]
matching --> nodriver["NO_DRIVER_FOUND"]
matching --> cancelled["CANCELLED"]
assigned --> cancelled
arrived --> cancelled
每次状态迁移都用条件更新,不允许跳步;每次迁移发出一个事件,计价、通知、分析系统各自订阅。
7. 面试中常见的坑
- 用 SQL 存实时位置再做范围查询
- 只按直线距离派单
- 加锁不设 TTL
- 相信客户端传来的价格
- 用进程内定时器处理邀约超时
8. 回答分级
地理索引与位置写入
- Bad:把实时位置存进 SQL 做经纬度范围查询,每次上报都写数据库。
- Good:只在内存里保留最新位置,用 geohash 或 Redis GEO 按城市分片;完整轨迹异步写入 Kafka 归档。
- Great:说明不停移动的点更适合固定网格(geohash、H3)而不是四叉树,geohash 在格子边界要连同周围 8 个格子一起查;按上报时间戳丢弃乱序的旧点,停车时降低上报频率。
不重复派单
- Bad:不加锁,或者加锁不设 TTL;用普通 DEL 释放锁。
- Good:司机级用带 TTL 的分布式锁,保证同一时间只有一个邀约;行程级用条件更新,保证只被接受一次。
- Great:明确 Redis 锁只是协调提示、真相在 Ride DB:接单时在同一个事务里更新行程和司机状态;用比较后删除的 Lua 脚本释放锁;下单接口带幂等键,防止乘客连点生成两个行程。
匹配质量与故障
- Bad:只按直线距离派单;邀约超时用进程内的 sleep。
- Good:按路网 ETA 排序;派单任务放进持久化队列,处理完才确认;超时交给延迟队列或工作流引擎。
- Great:提出批量匹配:攒 1~2 秒的请求做二分图匹配,降低总 ETA,并说明多等一两秒的代价;城市之间互相隔离,热点区域结合动态调价从供需两端调节。
9. 不同级别的期望
| 级别 | 期望 |
|---|---|
| Mid(L4) | 在提示下给出位置上报、地理索引和派单流程,知道实时位置要放在内存里、派单要防止重复 |
| Senior(L5) | 自己估算出每秒 50 万次位置写入并据此选型,讲清司机锁和行程条件更新两层保护,能处理派单服务崩溃和邀约超时 |
| Staff(L6+) | 讨论贪心匹配与批量匹配的取舍、锁和数据库谁是真相、按城市隔离的故障域和热点治理,并说明哪里刻意不追求全局最优 |