设计一个打车系统(Uber/滴滴)
问题
乘客发单到司机接单的全链路涉及哪些环节?LBS 附近司机怎么实时查找?匹配引擎用抢单还是派单?订单池如何设计以避免并发冲突?
分析
打车系统的核心链路:乘客下单 → 匹配司机 → 派单 → 接单 → 行程中 → 支付。其中实时 LBS 搜索和匹配引擎的并发控制是两大难点。
附近司机查找:GeoHash + Redis Geo
乘客发单时,系统需要快速找出乘客附近 3km 内的在线司机。最简单的方案是遍历所有司机算距离,但百万级司机下显然不可行。
GeoHash 算法将经纬度编码为字符串,前缀匹配即表示矩形区域。编码越长,精度越高:
| GeoHash 位数 | 矩形边长 | 适用场景 |
|---|---|---|
| 1 位 | ≈ 5000km | 城市级筛选 |
| 5 位 | ≈ 5km | 打车范围搜索 |
| 6 位 | ≈ 1.2km | 精细匹配 |
| 7 位 | ≈ 150m | 精确到街道 |
实际生产中的选择:一线城市用 6 位(司机密度高,1.2km 内就有几十个司机),三四线城市用 5 位(司机密度低,范围要扩大)。选错位数会出问题:2020 年某网约车公司在二线城市新开城时用了 6 位 GeoHash,结果高峰期 30% 的订单匹配不到司机,因为 1.2km 矩形内只有 2-3 个空闲司机,扩到 5 位后匹配率回升到 95%。
司机位置每 3 秒上报一次,GeoHash 编码后作为索引。乘客发单时用 GeoHash 前缀匹配查找附近司机。
Redis Geo 数据结构(GEOADD/GEORADIUS)封装了 GeoHash 的逻辑,直接支持 GEORADIUS key longitude latitude radius m 查询附近成员,返回距离排序结果,毫秒级响应。
匹配引擎:抢单 vs 派单
抢单模式:乘客发单,附近所有司机收到推送,先到先得。适合供给充足、订单密度高的场景(如一线城市高峰时段)。但存在司机挑单(只接高价值单)的问题。
派单模式:系统根据距离、司机评分、历史接单率、忙碌度等权重分配最优司机。用户体验更好,但算法复杂度高。
主流方案:派单为主 + 抢单兜底。系统先计算 Top 3 候选司机,按权重选最优司机派单,司机有 15 秒确认时间,超时则自动分配给下一个候选。
派单超时与降级流程(时序描述)
乘客端 服务端 司机端
| | |
|--- 下单请求 ---------->| |
| |--- 1. 入订单池 ------->|
| |--- 2. Redis Geo 查附近-|
| |--- 3. 评分 Top 3 ----->|
| | |
| |--- 4. 派单(最优司机) ->|
| | (15s 倒计时)
| |<--- 5a. 接单确认 ------|
| |--- 或 5b. 超时未确认 --|
| | |
|--- 接单成功通知 <------|--- 6. 超时则派下一候选|
| |--- 7. 全部超时则回池 --|如果 Top 3 全部超时,订单回到订单池,等待下一轮匹配(加上溢价系数)。
订单池:避免并发冲突
乘客发单后先进入订单池(Redis ZSet),score = 等待时间,匹配引擎每秒从池中取一批订单做批量匹配,而不是逐一匹配,减少 Redis 锁竞争。
每个订单的匹配结果用 SETNX 锁键 order_id:driver_id,存活时间 5 秒,保证同一个订单不会被多个司机同时抢到。
为什么锁超时设 5 秒? 因为匹配引擎的 batch 轮询周期是 1 秒,锁超时覆盖 5 个轮询周期,足够处理派单 - 确认 - 回滚的完整流程。如果设太短(如 1 秒),网络抖动时派单还在处理中锁就过期了,另一个引擎线程会重复派单。设太长(30 秒)则订单故障时锁一直占着,其他引擎无法接手。
订单池超时与溢价策略
等待时间(秒) 溢价系数 触发动作
0-15 1.0x 正常匹配
15-30 1.2x 小幅度溢价,推送给更多司机
30-60 1.5x 中幅度溢价,跨区域调度
>60 2.0x 最高溢价,调价+人工介入这个策略在大促压测中验证过:某网约车公司 2023 年双十一期间,订单峰值 12 万单/分钟,无溢价时段匹配成功率 82%,通过 1.2x-1.5x 动态溢价后提升到 93%。
代码示例
1. 司机位置上报
import redis
import time
import hashlib
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
DRIVER_LOCATION_KEY = "drivers:locations"
def report_location(driver_id: str, lat: float, lng: float):
"""司机每 3 秒上报一次位置"""
r.geoadd(DRIVER_LOCATION_KEY, (lng, lat, driver_id))
def get_nearby_drivers(lat: float, lng: float, radius_m: int = 3000) -> list:
"""查找附近 3km 内在线司机,返回按距离排序的列表"""
return r.georadius(
DRIVER_LOCATION_KEY,
lng, lat, radius_m, 'm',
withdist=True, # 附带距离
sort='ASC' # 按距离升序
)2. 订单池与匹配引擎
import time
import uuid
from typing import List, Tuple
ORDER_POOL_KEY = "order:pool"
ORDER_LOCK_PREFIX = "order:lock:"
def create_order(passenger_id: str, pickup_lat: float, pickup_lng: float) -> str:
"""乘客下单,进入订单池"""
order_id = f"order:{uuid.uuid4().hex[:12]}"
# 订单信息存 Hash
r.hset(order_id, mapping={
"passenger_id": passenger_id,
"pickup_lat": pickup_lat,
"pickup_lng": pickup_lng,
"status": "pending",
"created_at": int(time.time() * 1000)
})
r.expire(order_id, 300) # 5 分钟过期
# 入订单池,score = 创建时间戳
r.zadd(ORDER_POOL_KEY, {order_id: int(time.time() * 1000)})
return order_id
def match_engine(batch_size: int = 10):
"""匹配引擎:每秒取一批订单做批量匹配"""
# 1. 从订单池取最早的一批订单
orders = r.zrange(ORDER_POOL_KEY, 0, batch_size - 1, withscores=True)
if not orders:
return
for order_id, created_at in orders:
# 2. 获取乘客位置
order_info = r.hgetall(order_id)
if not order_info or order_info.get("status") != b"pending":
# 订单已取消或已匹配,移除
r.zrem(ORDER_POOL_KEY, order_id)
continue
pickup_lat = float(order_info["pickup_lat"])
pickup_lng = float(order_info["pickup_lng"])
# 3. 查附近司机
candidates = get_nearby_drivers(pickup_lat, pickup_lng, 3000)
if not candidates:
# 附近无司机,等待下一轮
continue
# 4. 按权重评分选最优司机
best_driver = score_candidates(candidates, order_id)
if not best_driver:
continue
driver_id = best_driver[0]
# 5. 尝试派单(SETNX 保证原子性)
lock_key = f"{ORDER_LOCK_PREFIX}{order_id}"
locked = r.setnx(lock_key, driver_id)
if locked:
r.expire(lock_key, 5) # 5 秒锁过期
# 派单通知
dispatch_order(order_id, driver_id)
# 从订单池移除
r.zrem(ORDER_POOL_KEY, order_id)
r.hset(order_id, "status", "dispatched")
def score_candidates(candidates: List[Tuple], order_id: str) -> List:
"""对候选司机评分,返回最优司机"""
scored = []
for driver_id, distance in candidates:
# 获取司机评分、接单率等
driver_info = r.hgetall(f"driver:{driver_id}")
if not driver_info:
continue
rating = float(driver_info.get("rating", 5.0))
accept_rate = float(driver_info.get("accept_rate", 1.0))
is_busy = int(driver_info.get("busy", 0))
if is_busy:
continue
# 综合评分:距离越近分数越高,评分越高,接单率越高
score = (1.0 / (distance + 100)) * 1.0 + rating * 0.5 + accept_rate * 0.3
scored.append((driver_id, score))
if not scored:
return None
scored.sort(key=lambda x: x[1], reverse=True)
return scored[0]
def dispatch_order(order_id: str, driver_id: str):
"""派单通知(通过 MQ 或推送)"""
# 实际项目走 MQ 或 WebSocket 推送给司机端
print(f"Dispatch: order={order_id} -> driver={driver_id}")
r.hset(order_id, "driver_id", driver_id)
r.hset(order_id, "status", "dispatched")
r.hset(f"driver:{driver_id}", "busy", 1)3. 司机位置批量落盘
def flush_driver_locations():
"""每 10 秒将司机位置批量写入 MySQL(轨迹回放用)"""
# 从 Redis Geo 读出所有司机位置
drivers = r.geopos(DRIVER_LOCATION_KEY, *r.zrange(DRIVER_LOCATION_KEY, 0, -1))
if not drivers:
return
# 批量写入 MySQL(伪代码,实际用 INSERT BATCH 或 MQ 消费)
batch = []
for driver_id, (lng, lat) in drivers.items():
batch.append({
"driver_id": driver_id,
"lat": lat,
"lng": lng,
"recorded_at": int(time.time())
})
# 异步写入 MySQL
# insert_driver_location_batch(batch)4. 从 Java 8 年后端视角看:单机匹配 vs 分布式匹配
如果这个打车系统作为一个 Java 后端项目,用 Spring Boot 实现时,核心匹配引擎需要关注:
单机版(小规模):用 @Scheduled 注解,fixedRate = 1000 每秒触发一次匹配,匹配逻辑里用 RedisTemplate 操作 ZSet 和 Geo。简单,但一个 JVM 的上下文切换和 GC 停顿会影响匹配时延。实测 200 万订单/天以下够用。
分布式版(大规模):匹配引擎需要部署多实例,用 Redis 分布式锁(Redisson)控制只有一个实例执行匹配。Redisson 的看门狗机制(watchdog)自动续期锁,比 SETNX + 手动 expire 更可靠,避免锁提前过期。
// Spring Boot 分布式匹配引擎
@Scheduled(fixedRate = 1000)
public void matchOrders() {
RLock lock = redissonClient.getLock("match:lock");
if (lock.tryLock()) {
try {
// 从订单池取最早一批订单
Set<ZSetOperations.TypedTuple<String>> orders =
redisTemplate.opsForZSet().rangeWithScores(ORDER_POOL_KEY, 0, 9);
// ... 匹配逻辑
} finally {
lock.unlock();
}
}
}Spring Boot 项目中两个容易踩的坑:
@Scheduled默认单线程执行,如果匹配逻辑耗时超过 1 秒,下一次会延迟触发 —— 必须@Async或配置ThreadPoolTaskScheduler的 poolSize- Redis Geo 的
GEORADIUS在主从模式下有延迟问题:司机上报位置写 master,但读 slave 可能读到 1-2 秒前的旧位置,高峰期会导致司机位置漂移。解决方案:读本地节点(读写分离时用@RedisRoute强制读 master)
总结
打车系统设计的核心在于实时 LBS 搜索和并发匹配的原子性控制:
- Redis Geo 处理附近司机查找,GeoHash 编码保证精度与性能的平衡
- 订单池(Redis ZSet)做批量匹配,避免逐个匹配的竞争开销
- 派单用
SETNX锁保证原子性,锁超时 5 秒,防止死锁 - 司机位置 3 秒上报 Redis Geo,10 秒批量 flush 到 MySQL 做轨迹回放
高阶设计点:
- ETA 预估:不简单用直线距离,而是结合路况(地图 API)+ 历史轨迹用 ML 模型预估,ETA 准确度直接影响用户留存
- 派单公平性:用多目标优化(SimHash 或 EGT 博弈论),平衡司机收入、乘客等待时间、平台抽成
- 订单池超时处理:订单在池中等待超过 30 秒未匹配,触发加价策略(溢价系数上调),吸引更多司机
- 司机地理位置实时性:Redis Geo 更新是毫秒级,但百万级 QPS 下需要分片(按城市/区域拆 Key),避免单 Key 热点
- 防刷单与风控:司机和乘客的 GPS 轨迹需要交叉验证,防止虚假行程