Skip to content

设计一个打车系统(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. 司机位置上报

python
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. 订单池与匹配引擎

python
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. 司机位置批量落盘

python
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 更可靠,避免锁提前过期。

java
// 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 项目中两个容易踩的坑:

  1. @Scheduled 默认单线程执行,如果匹配逻辑耗时超过 1 秒,下一次会延迟触发 —— 必须 @Async 或配置 ThreadPoolTaskScheduler 的 poolSize
  2. Redis Geo 的 GEORADIUS 在主从模式下有延迟问题:司机上报位置写 master,但读 slave 可能读到 1-2 秒前的旧位置,高峰期会导致司机位置漂移。解决方案:读本地节点(读写分离时用 @RedisRoute 强制读 master)

总结

打车系统设计的核心在于实时 LBS 搜索并发匹配的原子性控制

  • Redis Geo 处理附近司机查找,GeoHash 编码保证精度与性能的平衡
  • 订单池(Redis ZSet)做批量匹配,避免逐个匹配的竞争开销
  • 派单用 SETNX 锁保证原子性,锁超时 5 秒,防止死锁
  • 司机位置 3 秒上报 Redis Geo,10 秒批量 flush 到 MySQL 做轨迹回放

高阶设计点:

  1. ETA 预估:不简单用直线距离,而是结合路况(地图 API)+ 历史轨迹用 ML 模型预估,ETA 准确度直接影响用户留存
  2. 派单公平性:用多目标优化(SimHash 或 EGT 博弈论),平衡司机收入、乘客等待时间、平台抽成
  3. 订单池超时处理:订单在池中等待超过 30 秒未匹配,触发加价策略(溢价系数上调),吸引更多司机
  4. 司机地理位置实时性:Redis Geo 更新是毫秒级,但百万级 QPS 下需要分片(按城市/区域拆 Key),避免单 Key 热点
  5. 防刷单与风控:司机和乘客的 GPS 轨迹需要交叉验证,防止虚假行程

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。