网约车应用设计:寻找最近司机与订单匹配

1. 概述

网约车应用有两个核心问题。哪个司机目前离乘客最近?怎样确保一个订单只分配给一个司机,即使几十个司机同时点击“接单”?

本文将按照系统设计的思路全面讲解这个问题:需求、规模估计、数据模型、API和处理流程。随后重点讨论难点:并发冲突、数据一致性和突发负载。读完本文后,你将能够用Spring Boot 3、Redis、Kafka和PostgreSQL构建“寻找司机和匹配订单”的核心功能。

2. 需求

2.1. 功能需求

  • 司机在线时定期发送GPS位置。
  • 乘客指定接送地点进行下单。
  • 系统找到最近的空闲司机,依次向他们发送邀请。
  • 司机接受或拒绝邀请。如果司机在15秒内未响应,系统将邀请发送给下一个司机。
  • 乘客可以追踪订单状态:REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED(或CANCELLED)。

2.2. 规模和约束

假设系统运行在一个中等规模的国家:

  • 高峰期有30万名司机在线,每个司机每4秒发送一次位置。因此大约每秒7.5万次位置写入。
  • 大约每秒1500个下单请求。下雨或下班时段,这个数字可能增加5倍。
  • 司机匹配必须在200毫秒内完成(p99)。
  • 一个订单恰好对应一个司机。一个司机最多只能有一个进行中的订单。这是不可违反的约束。

从这些数字可以得出一个重要的观察:司机位置是写入频繁、生命周期短、可以容忍丢失的数据——丢失一个GPS点位没关系。相反,订单数据写入不频繁,但必须保证绝对的一致性。因此这两类数据应该存储在不同的系统中。

3. 数据模型

司机位置存储在Redis中。订单存储在PostgreSQL中:

CREATE TABLE rides (
    id           BIGSERIAL PRIMARY KEY,
    customer_id  VARCHAR(64)  NOT NULL,
    driver_id    VARCHAR(64),
    pickup_lat   DOUBLE PRECISION NOT NULL,
    pickup_lng   DOUBLE PRECISION NOT NULL,
    status       VARCHAR(20)  NOT NULL DEFAULT 'REQUESTED',
    created_at   TIMESTAMP    NOT NULL DEFAULT now(),
    accepted_at  TIMESTAMP
);

-- 每个司机最多只能有一个进行中的订单
CREATE UNIQUE INDEX ux_rides_driver_active
    ON rides (driver_id)
    WHERE status IN ('ACCEPTED', 'IN_PROGRESS');

最后的部分唯一索引是最后的“安全网”。假设应用层有bug,试图将两个订单分配给同一司机。此时数据库会拒绝第二条写入。

Redis端使用三种key:

drivers:available            GEO集合:driverId -> (lng, lat),仅包含空闲司机
driver:heartbeat:{driverId}  String,TTL 30秒,标记司机仍在线
driver:lock:{driverId}       String,TTL 15秒,值为正在邀请的订单ID

4. API

POST /api/drivers/{driverId}/location   { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides                         { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept         { "driverId": "d-7" }
GET  /api/rides/{rideId}

位置更新端点的负载非常大。在实际应用中,这部分通常通过WebSocket或gRPC流实现,避免每4秒打开一个HTTP连接。为了保持示例简洁,本文仍使用REST。

5. 使用Redis GEO存储和查找司机位置

添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5.1. 更新位置

@Service
public class DriverLocationService {

    static final String AVAILABLE_KEY = "drivers:available";
    private final StringRedisTemplate redis;

    public DriverLocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void updateLocation(String driverId, double lng, double lat) {
        redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
        redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
    }

    public void markBusy(String driverId) {
        redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
    }
}

GEOADD以geohash形式将坐标存储在有序集合中。每条命令的时间复杂度为O(log N),因此单个Redis节点每秒可以处理数万条命令而不会出现问题。

heartbeat部分存在的原因是GEO集合中的成员没有单独的TTL。如果司机失去信号或关闭应用,旧坐标仍会留在集合中。heartbeat键在30秒后过期,这样可以过滤掉这些“幽灵”司机。

注意: Redis GEO按照**(经度,纬度)**的顺序接收参数。反序是常见错误。参数反序时,胡志明市的司机会“移动”到南极。

5.2. 寻找最近的司机

public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
    var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance()
            .sortAscending()
            .limit(limit * 2L); // 取多一些来补偿被过滤的司机

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
            AVAILABLE_KEY,
            GeoReference.fromCoordinate(lng, lat),
            new Distance(radiusKm, Metrics.KILOMETERS),
            args);

    List<String> candidates = results.getContent().stream()
            .map(r -> r.getContent().getName())
            .toList();

    List<String> alive = redis.opsForValue().multiGet(
            candidates.stream().map(id -> "driver:heartbeat:" + id).toList());

    List<String> online = new ArrayList<>();
    for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
        if (alive.get(i) != null) {
            online.add(candidates.get(i));
        }
    }
    return online;
}

GEOSEARCH返回给定半径内的司机,按距离从近到远排序。然后单一的MGET命令检查所有候选司机的heartbeat,只需增加一次往返。对于位于第1区且半径为3公里的乘客,结果可能如下所示:

[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]

这里的距离是直线距离。在生产环境中,应该使用地图服务的实际ETA来重新排序前N个候选司机。原因是距离500米但在河对岸的司机可能比距离1公里的司机到达更晚。

6. 订单匹配和并发冲突处理

有两类并发冲突需要防止:

  1. 两个订单邀请同一司机。 两个附近的乘客同时下单,匹配逻辑都选中了d-7。
  2. 两个司机接受同一订单。 当你同时向多个司机发送邀请以减少等待时间时会出现这种情况。

6.1. 邀请期间用Redis锁定司机

@Service
public class DriverLockService {

    private static final RedisScript<Long> RELEASE = RedisScript.of("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

    public DriverLockService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryLock(String driverId, long rideId) {
        return Boolean.TRUE.equals(redis.opsForValue()
                .setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
    }

    public void release(String driverId, long rideId) {
        redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
    }
}

SET NX EX是原子操作。只有一个匹配线程为每个司机获得锁,锁在15秒后自动过期,恰好等于司机响应时间。得益于这个TTL,当司机不响应时系统会自动“转移”给下一个人,无需任何清理任务。

Lua脚本用于释放锁,只在值与rideId匹配时删除键。如果使用纯DEL,订单A的过期锁可能会意外删除订单B刚获得的锁。

6.2. 用条件更新确定订单

Redis锁只是一种优化机制来避免重复邀请。数据库才是事实来源:

@Repository
public class RideRepository {

    private final JdbcTemplate jdbc;

    public RideRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean assignDriver(long rideId, String driverId) {
        int updated = jdbc.update("""
                UPDATE rides
                SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
                WHERE id = ? AND status = 'REQUESTED'
                """, driverId, rideId);
        return updated == 1;
    }
}

条件status = 'REQUESTED'将UPDATE语句变成一个比较并设置操作。当两个司机同时点击接受时,数据库将两条写命令序列化到同一行。第一条命令返回1。第二条命令看到status已是ACCEPTED,因此返回0。这种方法不需要SELECT ... FOR UPDATE,也不需要单独的version列。

处理接受订单的Service将这些步骤结合起来:

@Transactional
public AcceptResult accept(long rideId, String driverId) {
    if (!rideRepository.assignDriver(rideId, driverId)) {
        return AcceptResult.ALREADY_TAKEN;
    }
    locationService.markBusy(driverId);
    lockService.release(driverId, rideId);
    return AcceptResult.ACCEPTED;
}

这里的顺序是有意设计的:先写数据库,再更新Redis。如果markBusy步骤失败,司机仍会在空闲列表中停留几秒。最坏的后果是司机收到额外邀请,此时部分唯一索引会阻止第二个订单分配。反过来做风险更大:先从Redis删除司机,然后数据库写入失败会导致司机从系统中“消失”。

7. 使用Kafka的异步处理流程

查找司机、发送邀请、等待15秒是一个长流程。不应该在整个过程中保持客户的HTTP请求。APIPOST /api/rides只保存订单然后立即返回202 Accepted。订单匹配在单独的consumer中运行。

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}

@Component
public class MatchingConsumer {

    private final DriverLocationService locations;
    private final DriverLockService locks;
    private final OfferSender offerSender;
    private final KafkaTemplate<String, RideRequested> kafka;

    // 为简洁起见,省略constructor

    @KafkaListener(topics = "ride-requested", groupId = "matching")
    public void onRideRequested(RideRequested event) {
        for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
            if (locks.tryLock(driverId, event.rideId())) {
                offerSender.send(driverId, event.rideId()); // push通知 / WebSocket
                return;
            }
        }
        if (event.attempt() < 5) {
            // 还没有可用司机:重新发送以稍后重试
            kafka.send("ride-requested-retry", event.cityId(),
                    new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
        }
    }
}

Consumer从近到远遍历候选司机。它锁定第一个可用的司机并向其发送邀请。如果没有可用司机,事件被推送到retry主题,受重试次数限制。

消息的key是cityId。这样同一城市的所有订单落入同一个分区组,consumer的本地司机缓存(如果有)的命中率会更高。

还有一个小问题:将订单保存到PostgreSQL然后再发送Kafka是两个独立操作。如果应用在中途崩溃,订单会永久“卡在”REQUESTED状态。标准的解决方案是事务性发件箱(transactional outbox)。将事件写入outbox表,与rides表在同一事务中,然后让单独的进程(或Debezium)读取outbox表并推送到Kafka。

注意: Kafka保证at-least-once,因此consumer可能收到同一事件两次。匹配逻辑必须是幂等的。发送邀请前,检查订单是否仍处于REQUESTED状态。第6.2节的条件更新本身已经能安全处理重复情况。

8. 应对突发负载

当下午5点突然下雨时,下单量和在线司机数都会急剧增加。有四种主要应对方式。

  • 按地区对位置数据进行分片。 不要为整个国家使用单一的drivers:available键,而是按城市或geohash网格进行划分,例如drivers:available:hcm。每个键位于Redis Cluster的一个分片上,这样写入负载就能均匀分布。当乘客站在两个网格的边界附近时,可以查询相邻网格。
  • 降低位置发送频率。 静止或正在载客的司机不需要每4秒发送位置。只需在移动超过50米或已过15秒时发送。仅此一项优化可以削减40-60%的写入量。
  • 回压而非崩溃。 Kafka充当缓冲区。当下单量增加5倍时,匹配延迟会增加几秒钟,但没有订单会丢失。根据consumer lag缩放consumer数量,上限是partition数。
  • 动态定价(surge pricing)。 这是产品层面的解决方案,而非技术层面。提高价格会降低需求并吸引更多司机上线,帮助系统恢复平衡。

此外,应该按customerId对下单API进行速率限制。当应用显示“正在找车”持续过长时间时,乘客往往会反复点击下单,自己造成负载加倍。

9. 接受订单时的冲突测试

最重要的测试行为是:多个司机同时接受同一订单时,只有一个人会成功。下面的测试使用H2内存数据库通过@JdbcTest。文件src/test/resources/schema.sql包含第3节中的rides表,去掉partial index因为H2不支持。

@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {

    @Autowired
    RideRepository rideRepository;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
        jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
                + "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");

        List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = drivers.stream()
                    .map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
                    .toList();

            long winners = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) winners++;
            }
            assertThat(winners).isEqualTo(1);
        }

        String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
        assertThat(status).isEqualTo("ACCEPTED");
    }
}

五个虚拟线程并发调用assignDriver来处理同一订单。无论执行顺序如何,只有一条UPDATE命令会满足status = 'REQUESTED'条件。

你可能注意到测试已排除了@JdbcTest的默认@Transactional。原因是:新线程不与test方法共享transaction,且INSERT对它们可见,因为数据已根据auto-commit配置提交。如果你得到winners = 0的结果,请在测试类上添加@Transactional(propagation = Propagation.NOT_SUPPORTED)。

对于GEO逻辑,应该使用Testcontainers运行真实Redis而非mock。距离计算和排序顺序是mock无法验证的。

10. 总结

本文已概述了网约车应用的核心部分。关键点是根据特性分离数据:

  • 司机位置写入频繁且可容忍丢失,应存储在Redis GEO中,配合heartbeat来过滤离线司机。
  • 订单需要一致性,应存储在PostgreSQL中。条件更新充当compare-and-set操作,部分唯一索引是最后的安全网。
  • 带TTL的Redis锁可防止司机重复邀请并自动处理超时。但锁只是优化机制,不是事实来源。
  • Kafka将订单匹配与客户请求分离,吸收突发负载并支持重试。Outbox pattern确保不丢失事件。

进一步扩展时,可以考虑以下方向:使用ETA而非直线距离对候选司机重新排序,每几秒进行批量匹配(batch matching)以优化全局,以及拼车(pooling)。所有这些都可以建立在本文呈现的基础之上。

system-designrediskafkaspring-bootcase-study