网约车是典型的「实时双边匹配」系统:一边是不断移动、状态随时变化的司机,一边是位置固定、等待时间敏感的乘客,系统要在几秒内把两者撮合起来,并在整个行程中持续追踪位置、更新状态、计算费用。它和电商、社交系统的最大区别在于——所有核心数据都带空间属性且高频变化,司机位置每秒都在变,匹配半径每分钟都在抖。本文按照系统设计面试的标准答题结构,设计一个生产级的网约车调度系统。
一句话:网约车的技术主线是「用地理索引把空间搜索降成集合运算,用状态机把一次行程的十几个阶段管死」——索引决定匹配快不快,状态机决定账算得对不对。
一、需求澄清与量级估算
1.1 需求澄清
面试官给出题目「设计一个网约车调度系统」后,先通过提问明确边界:
- 业务范围:只做快车/专车的即时单,还是包含预约单、拼车、顺风车?预约单会引入「未来时刻的匹配」,复杂度陡增,先假设以即时单为主、预约单作为扩展。
- 匹配模式:抢单(司机端看到订单自己抢)还是派单(系统指定)?现实中主流是「系统派单 + 少量抢单」,本文按派单设计。
- 计价规则:是否包含动态调价(高峰期溢价)、里程费 + 时长费 + 起步价、优惠券抵扣?计价必须可回溯、可审计。
- 实时性要求:乘客发起叫车到收到司机,目标 P99 多少?行业标准是 5 秒内响应、30 秒内成单。
- 一致性要求:一个订单绝不能同时派给两个司机(超卖),一个司机也不能同时接两单(占用冲突)。
- 合规与安全:行程录音、紧急联系人、行程分享、司机资质审核是否在范围内。
1.2 量级估算
假设一个覆盖 20 个城市的中型平台:
日订单量: 2000 万单/天
日活司机: 80 万
日活乘客: 800 万
在线司机峰值: 30 万(同时上报位置)
位置上报频率: 每 4 秒一次(行车中)/ 每 30 秒一次(空闲)
位置写入 QPS: 30 万 / 4 ≈ 7.5 万 QPS(峰值 ×3 ≈ 22 万 QPS)
叫车 QPS: 2000 万 / 86400 ≈ 230 QPS(峰值集中在早晚高峰,×10 ≈ 2300 QPS)
派单尝试: 平均每单 1.8 次(含司机拒单/超时)→ 峰值约 4000 次/秒
存储:
位置快照: 30 万司机 × 每秒 0.25 条 → 只保留最新 + 滑动窗口轨迹
轨迹明细: 2000 万单 × 平均 20 分钟 × 每 4 秒一条 ≈ 60 亿条/天
订单主表: 2000 万行/天,热数据保留 90 天
关键结论:位置写入是整个系统最大的写压力来源,订单和匹配反而是小头。因此架构的第一原则是——位置数据走内存型存储 + 只保留最新值,轨迹明细走追加型时序存储,两者彻底分离。
二、高层架构设计
┌──────────────┐
司机 App ──位置上报──▶│ 接入网关 │
(WebSocket) │ (鉴权/限流) │
└──────┬───────┘
│
┌───────────┴────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 位置服务 │ │ 订单服务 │
│ (内存地理索引) │ │ (状态机/事务) │
│ Redis GEO / H3 │ │ MySQL + 分库分表 │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ 候选司机集合 │ 订单事件
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 派单引擎 │◀───▶│ 消息队列 │
│ (匹配算法/评分) │ │ (订单/位置/计费) │
└────────┬─────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ ETA 预测服务 │ │ 计价与账单服务 │
│ (路网/历史/实时) │ │ (幂等/对账) │
└──────────────────┘ └──────────────────┘
▲
┌────────┴─────────┐
│ 轨迹存储 (时序) │
│ HBase/Cassandra │
└──────────────────┘
数据流分三条:
- 位置流:司机 App 每 4 秒上报一次坐标,网关做鉴权与限流后写入位置服务,位置服务同时更新内存索引(供匹配用)和时序存储(供轨迹回放与计费核算用)。
- 订单流:乘客叫车 → 订单服务创建订单(状态
PENDING_MATCH)→ 派单引擎从位置服务取候选司机 → 评分排序 → 下发派单请求 → 司机接受后订单进入ACCEPTED,全程通过消息队列解耦。 - 计费流:行程开始后位置流持续产生轨迹点,行程结束时计价服务按轨迹与规则计算费用,走幂等接口生成账单。
三、核心组件设计
3.1 司机与乘客的地理索引
匹配的第一性问题是「给定乘客坐标,找出附近 3 公里内的空闲司机」。朴素做法是遍历全部司机算距离,30 万司机 × 每秒数千次查询完全不可行。必须建立空间索引。
方案一:Redis GEO(Geohash 编码 + 有序集合)
Redis 的 GEOADD/GEOSEARCH 底层用 52 位 Geohash 整数作为 zset 的 score,按矩形框查询再按距离过滤:
# 更新司机位置(GEOADD 会覆盖同 member 的旧位置)
GEOADD driver:geo:city_shanghai 121.4737 31.2304 "driver:8821"
# 查乘客 3 公里内的司机,按距离升序返回 20 个
GEOSEARCH driver:geo:city_shanghai FROMMEMBER passenger:99001 \
BYRADIUS 3 km ASC COUNT 20 WITHDIST WITHCOORD
# 司机下线/接单后从索引移除
ZREM driver:geo:city_shanghai "driver:8821"
优点是实现简单、原子更新、天然支持按城市分片(不同 key)。缺点是 Geohash 矩形框在极点附近变形,且 COUNT 截断会漏掉「框内但排序靠后」的司机,需要用「先粗筛网格、再精算球面距离」的两段式。
方案二:H3/S2 六边形/球面网格
H3 把地球切成六边形层级(分辨率 0-15),每个司机归属一个 cell,乘客查询时取「乘客所在 cell + 一圈邻居 cell」:
H3 分辨率 8:平均边长约 531 米,六边形面积约 0.737 km²
查询半径 3 km ≈ 需要 2 圈邻居(k=2 → 19 个 cell)
索引结构:Redis Set driver:cell:<h3_index> → {driver_id, ...}
H3 的优势是六边形到中心距离均匀(不会像矩形那样出现「角落距离是边长 √2 倍」的问题),邻居计算规整。代价是要自己维护「cell → 司机集合」的映射,并处理跨 cell 边界的司机(一个司机可同时存在于相邻 cell 的候选集,需要去重)。
方案三:PostGIS 空间索引
如果位置数据落在 PostgreSQL 里,用 GiST 索引可以直接做半径查询:
CREATE INDEX idx_driver_geo ON driver_position USING GIST (geog);
SELECT driver_id, ST_Distance(geog, ST_MakePoint(121.4737, 31.2304)::geography) AS dist
FROM driver_position
WHERE driver_status = 'IDLE'
AND ST_DWithin(geog, ST_MakePoint(121.4737, 31.2304)::geography, 3000)
ORDER BY dist
LIMIT 20;
PostGIS 适合低频、需要复杂空间谓词的场景(如围栏判断、区域统计),但不适合每秒二十万次的高频位置写入——磁盘型数据库扛不住。生产做法是:热路径用 Redis/H3 内存索引,冷路径(对账、围栏、报表)用 PostGIS,详细的空间索引原理可参考 PostGIS 地理空间实践 。
司机状态过滤:空间索引只解决「谁在附近」,还要叠加「谁可用」。空闲司机集合用一个独立的内存结构维护,接单时原子地「从空闲集移除 + 从地理索引移除」,避免同一司机被重复派单:
-- Redis Lua:原子地检查并占用司机
-- KEYS[1]=idle:set KEYS[2]=driver:geo:city ARGV[1]=driver_id
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 0 then
return 0 -- 司机已被占用或不在线
end
redis.call('SREM', KEYS[1], ARGV[1])
redis.call('ZREM', KEYS[2], ARGV[1])
return 1 -- 占用成功
3.2 派单与匹配算法
拿到候选司机后,如何选出「最该派给谁」?这里有三种典型策略,复杂度与效果递增。
策略一:最近优先(Greedy Nearest)
按距离升序依次询问司机,接受即成单,超时(如 10 秒)换下一个。
for driver in candidates_sorted_by_distance:
if not try_lock(driver): continue # 已被占用,跳过
result = send_dispatch(driver, timeout=10s)
if result == ACCEPT: confirm_order(driver); return SUCCESS
if result == REJECT or TIMEOUT:
release_lock(driver); continue
return NO_DRIVER
优点是实现简单、单次匹配快。缺点是局部最优导致全局低效:一个乘客可能在高峰期连拒 8 个司机,每个司机白等 10 秒,成单时间被拉长到几十秒。
策略二:批量匹配(Batch Matching)
把 2~3 秒内到达的叫车请求攒成一个批次,用二分图最大权匹配(KM 算法或匈牙利算法)一次性求出「整体最优」的乘客-司机配对:
每个批次周期(2 秒):
1. 收集本周期内所有 PENDING 订单 → 乘客集合 P
2. 对每个乘客取候选司机,合并去重 → 司机集合 D
3. 构造二分图边权 w(p, d) = f(距离, 司机评分, 乘客偏好, ETA)
4. 用 KM 算法求最大权匹配,对匹配上的 (p, d) 并发下发派单,未匹配的进入下一批次
边权函数是核心,通常加权组合:
w(p,d) = α · (1 - normalize(dist)) # 距离越近越好
+ β · (1 - normalize(eta)) # 接驾时间越短越好
+ γ · driver_rating_score # 司机评分
+ δ · acceptance_probability # 司机历史接单率(减少拒单)
- ε · penalty_if_reject_history # 曾拒过该乘客的惩罚
批量匹配的收益在高峰期非常明显:整体接驾距离下降 10%~20%,拒单率下降。代价是引入了 2 秒的批次延迟,且 KM 算法是 O(n³),批次规模必须控制在几百个订单以内,超了要拆批。
策略三:全局最优 + 预测
在批量匹配基础上引入「未来供需预测」:如果系统知道 3 分钟后某区域会涌入 50 个订单,就不应该把该区域仅剩的 10 个司机全部派给当前的近距离订单,而要「留一手」。这属于运筹优化范畴,工程上通常简化为区域级配额:每个 H3 cell 维护一个「可派司机下限」,低于下限时提高派单门槛(只派给 ETA 最短的乘客)。
派单下发与拒单处理:无论哪种策略,下发都要处理三种结果:
- 接受:订单状态
PENDING_MATCH → ACCEPTED,写司机占用记录,通知乘客。 - 拒单:释放司机锁,记录拒单原因(距离远/方向不对/评分低),把该司机在本单的候选权重调低,重新匹配。
- 超时:与拒单同处理,但额外统计「无响应率」,异常高的司机降权或下线。
3.3 行程状态机与计价
一次行程的状态流转必须严格定义,否则会出现「司机已到达但乘客取消」「行程中订单被重复计费」这类脏数据。
PENDING_MATCH ──匹配成功──▶ ACCEPTED ──司机到达上车点──▶ ARRIVED
│ │ │
│乘客取消 │司机/乘客取消 │乘客上车
▼ ▼ ▼
CANCELLED CANCELLED IN_TRIP
▲ │
│ 到达终点 │
│ ▼
└──────────────── 取消/异常 ──────────────────── COMPLETED
│
▼
SETTLED(已结算)
状态机实现要点:
- 转移必须带前置校验,禁止非法跳转(如
PENDING_MATCH直接到IN_TRIP):
ALLOWED = {
"PENDING_MATCH": {"ACCEPTED", "CANCELLED"},
"ACCEPTED": {"ARRIVED", "CANCELLED"},
"ARRIVED": {"IN_TRIP", "CANCELLED"},
"IN_TRIP": {"COMPLETED"},
"COMPLETED": {"SETTLED"},
"SETTLED": set(),
"CANCELLED": set(),
}
def transition(order, to_state, actor):
cur = order.state
if to_state not in ALLOWED[cur]:
raise IllegalTransition(f"{cur} -> {to_state}")
# 带版本号的乐观锁,防止并发重复转移
rows = db.execute(
"UPDATE orders SET state=%s, version=version+1 "
"WHERE order_id=%s AND state=%s AND version=%s",
(to_state, order.id, cur, order.version))
if rows == 0:
raise ConcurrentModification(order.id)
publish_event(OrderStateChanged(order.id, cur, to_state, actor))
每次转移写一条事件(事件溯源思路),账单、风控、客服都从事件流重放,避免「改状态顺手改余额」的耦合。
超时自动转移:用延迟队列或定时扫表兜底。例如
ACCEPTED超过 20 分钟未ARRIVED,自动触发「司机未到达」告警并允许乘客无责取消。延迟投递的具体实现可参考 分布式任务调度系统设计 。
计价模型:费用由多个分量组成,且必须可解释、可回溯。
总费用 = 起步价
+ 里程费(按计价规则分段,如 0-3km 2.5元/km,3km+ 3.2元/km)+ 时长费(每分钟,低速等待时启用等待费)
+ 动态调价系数 × 基础费用(高峰期)
+ 附加费(高速费/停车费,司机录入需审核)
- 优惠券抵扣 - 余额抵扣
计价输入:行程轨迹(真实里程)、行程起止时间、城市计价规则版本、调价系数快照
关键设计:计价规则版本化。订单创建时快照当前规则版本号,结算时按该版本计算,避免「乘客下单时是旧价,结算时规则已改」的争议。里程不能简单用起终点直线距离,要用轨迹点累加(并对 GPS 漂移做滤波,剔除跳跃点)。
CREATE TABLE trip_settlement (
order_id BIGINT PRIMARY KEY,
driver_id BIGINT NOT NULL,
rule_version INT NOT NULL, -- 计价规则版本快照
distance_m INT NOT NULL, -- 轨迹累加里程
duration_s INT NOT NULL,
surge_factor DECIMAL(4,2) NOT NULL, -- 调价系数快照
base_fare DECIMAL(10,2) NOT NULL,
total_fare DECIMAL(10,2) NOT NULL,
coupon_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
pay_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING/PAID/REFUNDED
idempotent_key VARCHAR(64) UNIQUE, -- 结算幂等键
created_at DATETIME NOT NULL,
INDEX idx_driver_time (driver_id, created_at)
);
idempotent_key 用 order_id + settle_attempt 生成,保证「结算接口被重复调用时只扣一次钱」。
3.4 ETA 预测与轨迹上报
ETA(Estimated Time of Arrival)贯穿全流程:派单时用来排序候选司机,乘客端用来显示「预计 3 分钟到达」,行程中用来预估剩余时间。它是网约车体验的核心指标,误差每减少 1 分钟,成单率就有可观测提升。
ETA 的三种实现层次:
- 直线距离 / 平均速度:
eta = haversine_distance / 25km/h。最粗糙,误差常在 50% 以上,只在冷启动或无路网数据时用。 - 路网最短路径:把路网建成带权图(边权 = 路段通行时间),用 Dijkstra/A* 求最短通行时间。需要维护一份动态更新的路网图,边权随实时路况变化。
- 机器学习预测:用历史行程 + 实时路况特征训练模型,输入包括「起终点、出发时刻、路段历史速度、天气、是否高峰、实时路况流」,输出分钟级 ETA。工业界常用 GBDT 或深度序列模型。
特征工程示意:
base_route_time 路网最短路径基准时间
historical_speed 该时段同路段的 P50 速度
realtime_speed 近 5 分钟该路段实测平均速度
segment_count 途经红绿灯/路口数量
rain_intensity 降水强度(雨天平均慢 15%)
is_peak 是否高峰时段
driver_skill 司机历史该区域平均速度偏差
→ 模型输出 eta_seconds,并给出 P50/P90 置信区间
轨迹上报的工程挑战:
- 上报频率与流量:行车中每 3
5 秒一个点,一个司机一小时 7001200 个点。要按状态动态调整频率(空闲时 30 秒,行车时 4 秒,急加减速时临时提频)。 - 弱网与补传:隧道、地下车库会丢点。App 端要本地缓存轨迹,出隧道后批量补传,并带客户端时间戳与服务端接收时间戳双时间轴,便于排序与去重。
- 漂移过滤:GPS 在楼宇密集区漂移可达几百米。用「速度合理性检验」过滤:若相邻两点推算速度 > 200km/h 则判定为漂移点丢弃;再用卡尔曼滤波或地图匹配(Map Matching,把轨迹吸附到路网上)平滑。
- 写放大:60 亿条/天不能直接进关系库。轨迹明细写时序/宽表存储(HBase、Cassandra、ClickHouse),按
order_id或driver_id + 时间分区,设置 TTL(如 90 天),供计费核对与纠纷取证;实时消费端只保留滑动窗口(最近 10 分钟)在内存里做地图展示。
轨迹写入的分层:
实时层(内存,最近 10 分钟) → 供司机位置展示、匹配、ETA 实时特征
明细层(时序库,90 天 TTL) → 供计费里程核算、纠纷回放、地图匹配训练
汇总层(数仓,永久) → 供运力分析、区域热力、司机画像
轨迹与订单事件都通过消息队列流转,位置流的背压处理与 消息队列系统设计
里讲的分区、消费组、削峰策略完全同构——位置按 city_id 或 driver_id 分区,保证同一司机的点序不乱。
四、深入权衡
1. 抢单 vs 派单:抢单把匹配成本转嫁给司机(司机自己判断值不值),系统简单但容易出现「好单被抢、烂单没人接」;派单由系统统筹,全局效率高但要处理拒单与司机不满。成熟平台用「派单为主 + 特定场景抢单(预约单、长途单)」的混合模式。
2. 内存索引 vs 数据库索引:内存索引(Redis GEO/H3)撑住高频写与毫秒查询,但重启会丢、多副本间有短暂不一致;数据库索引(PostGIS)强一致、可复杂查询,但扛不住高频写。生产必须双写 + 冷热分工,并接受「内存索引有秒级延迟」这个事实。
3. 派单半径的取舍:半径太小,高峰期找不到司机,成单率低;半径太大,司机接驾距离长,乘客等待久、司机空驶油耗高。实践中用「动态半径」:先试 1 公里,无候选则逐级扩到 3、5 公里,并给远处的司机加补贴权重。
4. 一致性与可用性:匹配时最怕「同一司机被两个订单同时锁定」。用 Redis 单线程 Lua 保证占用的原子性,配合订单侧的乐观锁(state + version 条件更新),宁可偶发「匹配失败重试」也不能出现「一车两单」。
5. 计价的公平与风控:动态调价是收益与口碑的双刃剑。工程上必须做调价上限(如最高 3 倍)、明细可查、申诉通道,并防范刷单(同一设备反复下单取消骗补贴)——风控规则与 优惠券营销系统设计 里的防刷体系同源。
五、总结
网约车调度系统的核心可以浓缩成三句话:
- 空间索引决定匹配速度——用 Geohash/H3 把「附近的人」变成 O(1) 的集合查询,用 Redis 内存索引扛住每秒二十万次的位置写入。
- 状态机决定账目正确——把行程的十几个阶段用严格的状态转移表管死,每次转移写事件、带乐观锁,计价规则版本化快照。
- ETA 决定用户体验——从直线距离到路网到机器学习,ETA 精度每提升一档,成单率就有可观测改善。
延伸阅读:地理索引在服务端落地的完整实践见 PostGIS 地理空间实践 与 Elasticsearch 地理搜索 ;行程内的司乘沟通与位置共享可以复用 即时通讯系统设计 的长连接与多端同步方案;派单的超时兜底与定时补偿依赖 分布式任务调度系统设计 的延迟队列能力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。