cswork / 系统设计

第 7 章 / 共 57 章 · 约 9 分钟

案例 5:设计网约车派单系统(类 Uber)

本页目录
  1. 1. 需求澄清
  2. 2. 容量估算
  3. 3. 核心实体与接口
  4. 4. 高层架构
  5. 5. 深挖
  6. 6. 订单状态机
  7. 7. 面试中常见的坑
  8. 8. 回答分级
  9. 9. 不同级别的期望
  10. 章节测验

先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。

先写你的思路

只存在这台设备上;登录后会存进你的账号。

网约车这道题的重点不在“下单”,而在两件事:海量司机位置的高频写入和就近查询,以及派单时绝不能把一个司机同时派给两个乘客。前者考地理索引,后者考并发控制。

1. 需求澄清

功能需求:

  • 乘客输入起终点,拿到预估价格和接驾时间
  • 乘客确认叫车,系统匹配附近司机
  • 司机在限定时间内接单或拒单;拒单或超时就派给下一位
  • 行程中双方实时看到对方位置
  • 订单状态流转:匹配中、已派单、司机已到达、行程中、已完成、已取消

不做:支付结算、评价、拼车、动态调价算法(只讲它对架构的影响)。

非功能需求:

  • 匹配要快:几秒内向司机发出邀约,一分钟内要么匹配成功,要么明确告诉乘客没车
  • 派单强一致:一个司机同一时刻只能收到一个邀约,一个订单只能被一个司机接走
  • 位置写入吞吐高:这是整个系统写流量的大头
  • 按地域隔离:一个城市的高峰不能拖垮其他城市

2. 容量估算

指标估算
峰值在线司机200 万
位置上报频率每 4 秒一次
位置写入 QPS200 万 / 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

一次派单的过程:

  1. 乘客下单,Ride Service 写入 Ride(status=MATCHING),把派单任务放进持久化队列。
  2. Matching Service 从 Geo Index 查上车点半径 R 内的空闲司机。
  3. 按路网 ETA 排序。直线距离会被河流、高架、单行道误导。
  4. 给排第一的司机加锁:SET driver:{id}:lock {ride_id} NX PX 15000。加锁失败说明他正被别的订单邀约,换下一位。
  5. 加锁成功后推送邀约。司机接单时,Ride Service 用条件更新把订单改为 DRIVER_ASSIGNED,同时把司机状态设为行程中。
  6. 拒单或 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+)讨论贪心匹配与批量匹配的取舍、锁和数据库谁是真相、按城市隔离的故障域和热点治理,并说明哪里刻意不追求全局最优

章节测验

6 道选择题
  1. 司机位置每 4 秒更新一次。下面哪种索引最适合用来查附近司机?
  2. 用 geohash 查附近司机时,乘客恰好站在格子边缘。应该怎么做?
  3. Matching Service 用 SET driver:{id}:lock ride_id NX PX 15000 给司机加锁。为什么 TTL 必不可少?
  4. 两个司机几乎同时对同一张订单点了“接单”。在数据库层怎样保证只有一人成功?
  5. 为什么下单接口只接收 estimate_id,而不接收客户端传来的价格?
  6. 面对每秒 50 万次的位置写入,下面哪个做法不合适?

章节讨论

正在加载讨论…