ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于状态机的网约车司机状态管理设计与实现

基于状态机的网约车司机状态管理设计与实现 在实际网约车平台项目中Uber、Lyft 这类产品留给开发者最直接的经验往往不是订单算法有多复杂而是司机端每天要处理大量状态变更登录、出车、听单、接单、行程中、收工、休息。这些操作看似简单一旦并发上来、多实例部署、网络抖动状态就会错乱。本文围绕“司机状态管理”这条主线从模型设计、接口实现到并发控制带你把一个司机端核心模块从零跑通。如果你正在做网约车、代驾、跑腿、货运调度这类业务或者只是想把状态机设计落到 Spring Boot 项目里这篇文章会比较合适。文章会包含数据库表结构、Java 状态机设计、Redis 缓存方案、并发控制、接口验证和排错方法。读完以后你可以直接在自己的项目里复现这套流程并根据业务情况扩展成订单、计费、派单系统。1. 司机端最核心的问题不是接口数量而是状态一致性1.1 司机端业务为什么容易写乱很多人看到司机端第一反应是“登录后调一个接口接单就行了”。实际远没有这么简单。一次接单动作背后至少涉及几个问题司机当前是否处于可接单状态。车辆是否处于空闲状态。订单是否已经被其他司机抢走。状态从“听单中”到“已接单”再到“行程中”“已完成”是否允许跳转。如果司机同时扫了多个设备是否会出现一个司机被派两单。这些问题本质上都是状态一致性问题。用一堆if (status 1)写业务代码短期能跑但订单状态多了以后判断逻辑会越来越散排查问题只能靠日志猜。1.2 用状态机管理状态的三个好处状态机可以理解成一张“允许做什么”的规则表。它规定了每个状态允许迁移到哪些状态不允许迁移到哪些状态。这样做有三个直接好处非法流转在入口处就被拦截而不是业务执行到一半才发现数据不对。后续加新状态时只需要在规则表里增加一行不需要改几十处判断。状态变更路径清晰测试用例可以直接覆盖状态机规则。一个常见的司机状态流转关系如下休息中 - 出车 - 听单中听单中 - 收车 - 休息中听单中 - 接单 - 服务中服务中 - 完成订单 - 听单中服务中 - 取消订单 - 听单中听单中 - 暂停接单 - 暂停中暂停中 - 恢复接单 - 听单中这只是一个基础版本。实际业务里还会有“交班”“充电”“申诉中”等状态。无论状态多少设计思路一致。1.3 状态机与订单状态、车辆状态的关系司机端不能只看司机表里的状态因为司机是否可接单还受车辆和订单影响。比如司机状态是听单中但车辆状态是维修中不能派单。司机状态是听单中但已经有未完成订单不能继续派单。所以我建议把“司机状态”“车辆状态”“订单状态”拆开维护在派单查询时再组合判断。不要把一个总状态字段到处使用否则一个字段要表达太多含义后续很难维护。注意状态机的核心价值是约束非法迁移而不是替代业务校验。订单超时、信用分不足等业务规则仍然要在服务层处理。2. 环境准备与项目骨架2.1 技术选型和版本要求为了把完整案例跑通我选择以下技术栈。这是目前中小型项目里比较常见的组合组件版本建议用途JDK17运行 Spring Boot 3Spring Boot3.2.xWeb 应用框架MyBatis-Plus3.5.5数据访问层MySQL8.0主数据库Redis7.x在线司机缓存与并发控制Maven3.9.x依赖管理如果你的项目仍是 Spring Boot 2.x代码主体可以复用只需要调整包名和少量配置。2.2 创建 Maven 项目与目录结构这里不演示 IDE 创建项目的过程直接给出目录结构driver-center/ ├── pom.xml ├── src/main/java/com/example/driver/ │ ├── DriverCenterApplication.java │ ├── common/ │ │ ├── Result.java │ │ └── BusinessException.java │ ├── config/ │ │ └── RedisConfig.java │ ├── controller/ │ │ └── DriverController.java │ ├── enums/ │ │ └── DriverStatusEnum.java │ ├── mapper/ │ │ └── DriverMapper.java │ ├── model/ │ │ ├── Driver.java │ │ └── DriverStatusLog.java │ ├── service/ │ │ ├── DriverService.java │ │ └── DriverStatusMachine.java │ └── state/ │ └── StateTransition.java └── src/main/resources/ ├── application.yml └── mapper/ └── DriverMapper.xml这里没有把 Redis 配置单独拆到独立文件学习阶段写在application.yml即可。2.3 引入依赖pom.xml中需要包含 Web、MyBatis-Plus、MySQL、Redis、Lombok 等依赖。关键部分如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency这里要说明MyBatis-Plus 的版本要和 Spring Boot 3 匹配。不要直接复制一个 3.4.x 版本否则启动时会出现Failed to introspect Class这类兼容性错误。2.4 基础配置文件application.yml配置数据源、Redis、日志等server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/driver_center?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 database: 0 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true配置map-underscore-to-camel-case是为了让driver_status自动映射为driverStatus避免写一堆ResultMap。注意生产环境不要把数据库密码写在配置文件里建议使用环境变量或配置中心。3. 司机状态模型与状态机规则设计3.1 为什么用“事件”而不是“改状态字段”很多初学项目会在 Service 里直接写driver.setStatus(2); driverMapper.updateById(driver);这样写的缺点是谁都可以改状态未来新增状态时很难发现遗漏。更好的做法是“通过事件触发状态迁移”例如调用driverService.startWork(driverId)内部由状态机决定是否允许从当前状态迁移到目标状态。事件和状态是两回事。状态是结果事件是原因。以“出车”为例当前状态休息中触发事件出车目标状态听单中3.2 定义司机状态枚举public enum DriverStatusEnum { OFF_DUTY(0, 休息中), LISTENING(1, 听单中), SERVING(2, 服务中), PAUSED(3, 暂停接单); private final int code; private final String desc; DriverStatusEnum(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static DriverStatusEnum of(int code) { for (DriverStatusEnum value : values()) { if (value.code code) { return value; } } throw new IllegalArgumentException(未知司机状态: code); } }枚举的价值在于把魔法数字变为可读类型业务代码里不再出现if (status 1 event 2)。3.3 用规则表维护状态迁移状态机规则可以定义在StateTransition类中用 Map 保存状态到可触发事件的映射public class StateTransition { private final MapDriverStatusEnum, SetDriverStatusEnum transitionMap new HashMap(); public StateTransition() { transitionMap.put(DriverStatusEnum.OFF_DUTY, Set.of(DriverStatusEnum.LISTENING)); transitionMap.put(DriverStatusEnum.LISTENING, Set.of( DriverStatusEnum.OFF_DUTY, DriverStatusEnum.SERVING, DriverStatusEnum.PAUSED )); transitionMap.put(DriverStatusEnum.SERVING, Set.of( DriverStatusEnum.LISTENING, DriverStatusEnum.OFF_DUTY )); transitionMap.put(DriverStatusEnum.PAUSED, Set.of( DriverStatusEnum.LISTENING, DriverStatusEnum.OFF_DUTY )); } public boolean canTransition(DriverStatusEnum current, DriverStatusEnum target) { SetDriverStatusEnum targets transitionMap.get(current); return targets ! null targets.contains(target); } }这里只是用Set表达的简单规则。如果业务复杂可以改为在数据库中配置状态迁移表运维可以直接改规则但学习阶段用枚举 Map 更直观。3.4 状态机服务封装DriverStatusMachine负责校验和迁移Service public class DriverStatusMachine { private final StateTransition transition new StateTransition(); public void transition(Driver driver, DriverStatusEnum target) { DriverStatusEnum current DriverStatusEnum.of(driver.getDriverStatus()); if (!transition.canTransition(current, target)) { throw new BusinessException(非法状态迁移: current - target); } driver.setDriverStatus(target.getCode()); } }这里把“是否允许迁移”和“如何改状态”放到了同一个类里便于复用。3.5 数据库表设计司机主表CREATE TABLE t_driver ( id BIGINT PRIMARY KEY AUTO_INCREMENT, driver_name VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL, driver_status TINYINT NOT NULL DEFAULT 0 COMMENT 0休息中 1听单中 2服务中 3暂停接单, vehicle_id BIGINT DEFAULT NULL, current_order_id BIGINT DEFAULT NULL, version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) );状态变更日志表CREATE TABLE t_driver_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, driver_id BIGINT NOT NULL, from_status TINYINT NOT NULL, to_status TINYINT NOT NULL, event_desc VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_driver_id (driver_id) );日志表的作用是审计。一旦线上出现司机状态不对可以通过日志表还原每个司机经历了哪些状态迁移定位是哪个环节跳错了。4. 核心接口实现与并发控制4.1 司机实体与 Mapper使用 MyBatis-Plus 可以简化大量 CRUD 代码Data TableName(t_driver) public class Driver { TableId(type IdType.AUTO) private Long id; private String driverName; private String phone; private Integer driverStatus; private Long vehicleId; private Long currentOrderId; Version private Integer version; private LocalDateTime createTime; private LocalDateTime updateTime; }Version是 MyBatis-Plus 提供的乐观锁版本号后续并发更新时会用到。Mapper 只需要继承BaseMapperMapper public interface DriverMapper extends BaseMapperDriver { }4.2 出车/收车接口设计出车接口的核心逻辑是查询司机最新状态。校验状态机是否允许从当前状态迁移到“听单中”。写入司机状态。记录状态变更日志。如果使用 Redis 维护在线司机集合则同步写入 Redis。Controller 层提供 HTTP 接口RestController RequestMapping(/api/driver) public class DriverController { private final DriverService driverService; public DriverController(DriverService driverService) { this.driverService driverService; } PostMapping(/{id}/start-work) public ResultVoid startWork(PathVariable Long id) { driverService.startWork(id); return Result.ok(); } PostMapping(/{id}/stop-work) public ResultVoid stopWork(PathVariable Long id) { driverService.stopWork(id); return Result.ok(); } }Service 实现Service public class DriverService { private final DriverMapper driverMapper; private final DriverStatusLogMapper logMapper; private final DriverStatusMachine statusMachine; private final StringRedisTemplate redisTemplate; private static final String ONLINE_DRIVER_KEY driver:online; public DriverService(DriverMapper driverMapper, DriverStatusLogMapper logMapper, DriverStatusMachine statusMachine, StringRedisTemplate redisTemplate) { this.driverMapper driverMapper; this.logMapper logMapper; this.statusMachine statusMachine; this.redisTemplate redisTemplate; } Transactional public void startWork(Long driverId) { Driver driver driverMapper.selectById(driverId); if (driver null) { throw new BusinessException(司机不存在); } DriverStatusEnum target DriverStatusEnum.LISTENING; statusMachine.transition(driver, target); int rows driverMapper.updateById(driver); if (rows 0) { throw new BusinessException(更新失败请重试); } saveStatusLog(driver.getId(), DriverStatusEnum.OF(driver.getDriverStatus()), target, 出车); redisTemplate.opsForSet().add(ONLINE_DRIVER_KEY, String.valueOf(driverId)); } Transactional public void stopWork(Long driverId) { Driver driver driverMapper.selectById(driverId); if (driver null) { throw new BusinessException(司机不存在); } DriverStatusEnum target DriverStatusEnum.OFF_DUTY; statusMachine.transition(driver, target); driverMapper.updateById(driver); saveStatusLog(driver.getId(), DriverStatusEnum.of(driver.getDriverStatus()), target, 收车); redisTemplate.opsForSet().remove(ONLINE_DRIVER_KEY, String.valueOf(driverId)); } private void saveStatusLog(Long driverId, DriverStatusEnum from, DriverStatusEnum to, String desc) { // 插入日志表 } }这里有一个关键问题startWork和stopWork里先selectById再updateById不是原子操作。如果两个请求同时到达A 读到“休息中”B 也读到“休息中”两个都执行出车最终状态没问题但日志会记录两次。更严重的是如果 A 读到“服务中”但实际已经收车数据就会不一致。所以要加入数据库层面的并发控制。4.3 用乐观锁控制状态变更并发MyBatis-Plus 的Version可以帮我们实现乐观锁。但需要让updateById在版本不一致时返回 0Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }这样updateById会自动带上version条件。并发下只有第一个请求能更新成功第二个请求返回 0。同时要保证状态机校验和更新在同一个“读最新数据”的时机上。推荐加一层分布式锁public void startWorkWithLock(Long driverId) { String lockKey driver:lock: driverId; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (!Boolean.TRUE.equals(locked)) { throw new BusinessException(操作太频繁请稍后重试); } try { startWork(driverId); } finally { redisTemplate.delete(lockKey); } }这里锁的作用是防止同一个司机短时间内重复提交出车或收车请求。也可以用数据库悲观锁SELECT ... FOR UPDATE但 Redis 锁更轻量适合学习场景。4.4 订单分配时如何筛选在线司机当用户下单后派单服务需要从大量在线司机中挑出候选司机。实现方式很多简单模块可以这样做从 Redis 的在线司机集合中随机取一批司机 ID。批量查询这些司机的实时状态和车辆位置。过滤掉服务中、暂停中、车辆维修的司机。按距离排序返回候选列表。这一步不直接在司机服务里做长事务而是通过缓存加快读取。public ListDriver listOnlineDrivers(int limit) { SetString memberIds redisTemplate.opsForSet().distinctRandomMembers(ONLINE_DRIVER_KEY, limit); if (memberIds null || memberIds.isEmpty()) { return Collections.emptyList(); } ListLong ids memberIds.stream().map(Long::valueOf).toList(); return driverMapper.selectBatchIds(ids); }distinctRandomMembers是 Redis 集合的随机弹出候选适合派单。这个接口不保证司机一定可接单最终接单前还需要二次确认。4.5 状态变更日志与最终一致性日志在互联网业务里容易被忽略但状态类系统没有日志很难排查。推荐在状态变更的同一个本地事务里写入日志表。如果后续要统计司机出车时长、在线时长这张日志表就是原始数据来源。Transactional public void startWork(Long driverId) { // ...状态校验与更新 logMapper.insert(new DriverStatusLog(driverId, fromCode, toCode, 出车)); }日志表数据量增长很快需要注意归档。一般按周或按月分区。5. 运行验证与接口测试5.1 建表并写入测试数据启动前先执行建表语句然后插入一个司机INSERT INTO t_driver (driver_name, phone, driver_status, version) VALUES (张三, 13800001111, 0, 0);数据库记录driver_status 0表示休息中。5.2 启动服务在项目根目录执行mvn spring-boot:run启动后确认日志中没有异常。如果 Redis 或数据库连接失败会直接影响到接口调用。5.3 用 curl 验证出车流程调用出车接口curl -X POST http://localhost:8080/api/driver/1/start-work正常返回{ code: 0, message: success, data: null }然后查询数据库SELECT id, driver_name, driver_status, version FROM t_driver WHERE id 1;预期结果id1, driver_status1, version1再查看日志表SELECT driver_id, from_status, to_status, event_desc FROM t_driver_status_log;预期结果driver_id1, from_status0, to_status1, event_desc出车5.4 验证重复出车会被拦截再次调用出车接口此时司机状态已经是LISTENING。状态机判断LISTENING - LISTENING不允许接口应返回{ code: 500, message: 非法状态迁移: LISTENING - LISTENING, data: null }这个验证很关键。它说明状态机规则在接口入口生效了。5.5 验证收车与 Redis 同步调用收车接口curl -X POST http://localhost:8080/api/driver/1/stop-work收车后 Redis 中在线司机集合应移除该司机。可以在 Redis 命令行确认SMEMBERS driver:online如果返回空集合说明收车逻辑正确。5.6 接口测试结果汇总操作当前状态请求结果数据库状态原因第一次出车休息中成功听单中合法迁移第二次出车听单中失败听单中同一状态迁移非法收车听单中成功休息中合法迁移收车后再收车休息中失败休息中非法迁移这个结果就是状态机的核心收益业务代码不需要再写if (status 0) { ... }来处理所有分支。6. 常见问题排查6.1 更新数据库时返回 rows0但没有业务异常现象启动成功出车接口返回“更新失败”但数据库状态看起来没变。常见原因乐观锁版本号不存在或丢失。Version字段被 Lombok 的Data覆盖后没有正确赋值。更新之前重新查了一次Driver对象覆盖了原来的 version 值。检查方式在 Service 中打印driver.getVersion()。查看 MyBatis-Plus 的 Log 输出确认UPDATE语句是否带version ?条件。解决方案确保实体类字段名称为version与数据库一致。不要在 update 前对Driver对象执行setVersion(0)这类赋值。6.2 司机显示在线但收不到订单现象Redis 在线集合里有司机 ID但派单服务查不到该司机或过滤掉他。常见原因司机状态在 MySQL 中是“服务中”但 Redis 集合没有移除。派单查询时只查 Redis没有和数据库状态做二次校验。检查方式SMEMBERS driver:onlineSELECT id, driver_status, current_order_id FROM t_driver WHERE id 1;解决方案收车、接单、完成订单时都必须维护 Redis 集合。派单前先调用数据库批量查询过滤driver_status 1的司机避免缓存和数据库不一致。预防建议不要在多个地方直接操作 Redis最好封装一个DriverOnlineManager所有在线状态变更都走同一个类。6.3 多实例部署时 Redis 锁失效现象同一个司机同时向两台服务器发出车请求两个实例都获取到了锁。常见原因使用了简单的setIfAbsent锁但锁没有设置唯一 valueA 实例删除了 B 实例的锁。Redis 锁超时时间设置过短长事务导致锁提前释放。没有使用 Redisson 之类的分布式锁实现。检查方式查看 Redis 中锁的 value 是否每次请求都不同。检查请求日志中的耗时是否超过锁过期时间。解决方案锁 value 使用 UUID删除锁时先比较 value再删除。使用 Redisson 的RLock替代手写 Redis 锁它在看门狗机制下可以自动续期。RLock lock redissonClient.getLock(driver:lock: driverId); boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); try { if (!locked) { throw new BusinessException(操作太频繁); } startWork(driverId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }6.4 状态迁移日志比状态表少一条现象状态表更新成功但日志表没有记录。常见原因Transactional只加在 Service 方法上但saveStatusLog内部捕获了异常。日志插入的 Mapper 没有参与当前事务。检查方式查看日志文件里是否有DriverStatusLog.insert的异常。确认saveStatusLog方法确实调用了logMapper.insert。解决方案不要捕获日志插入异常后直接吞掉。如果日志写入失败可以记录到独立日志表或消息队列后续补偿。7. 生产环境落地时需要额外考虑的能力7.1 状态机规则配置化当前版本把迁移规则写在StateTransition类里适合小型项目。生产环境状态数量多、业务变化快建议把规则表放到数据库CREATE TABLE t_state_transition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, current_status TINYINT NOT NULL, event_code VARCHAR(32) NOT NULL, target_status TINYINT NOT NULL, is_enabled TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_status_event (current_status, event_code) );业务代码通过事件编码查询目标状态不再硬编码 Map。这样产品要增加一条“司机申诉中 - 听单中”的迁移时不需要发版只需修改数据库配置。7.2 不可只依赖 Redis 维护在线状态Redis 适合做派单时的快速筛选但它是缓存而不是数据源。服务重启、Redis 重启、Key 过期都可能导致数据丢失。生产环境要定期做对账每隔一段时间扫描 MySQL 中状态为听单中的司机。检查这些司机是否存在于 Redis。以 MySQL 为准重建 Redis 在线集合。SELECT id FROM t_driver WHERE driver_status 1;然后执行DEL driver:online SADD driver:online 1 2 3 ...对账任务可以用定时任务也可以在派单服务启动时执行。7.3 状态变更操作要幂等出车这个动作本身是幂等的。如果司机已经是听单中再次出车不应该报错而应该直接返回成功。但前面我们用状态机拦截了LISTENING - LISTENING。这两种设计怎么取舍答案是看业务语义。如果你认为“重复出车”是用户误触应该返回“当前已听单”前端做提示。如果你认为“重复出车”和“出车成功”对用户没有区别可以在状态机前加一层幂等判断if (driver.getDriverStatus() target.getCode()) { return; }幂等判断要谨慎只有明确不会产生副作用的状态才能这样处理。“接单”就不应该幂等因为一个订单只能被一个司机接。7.4 日志、监控和告警状态类系统最怕“悄无声息地错”。建议在关键状态变更点埋点记录接口耗时、异常率。监控状态变更失败次数。如果某个司机状态在短时间内频繁变化触发告警。派单服务发现司机在线但实际订单异常时记录状态漂移日志。这些能力不一定要一开始就做但至少要在日志里留好 traceId、driverId、fromStatus、toStatus否则后续排障成本会很高。7.5 从司机端扩展到完整业务司机状态管理只是网约车平台的一个基础模块。后续可以按相同思路扩展订单状态机待支付、已支付、已派单、行程中、已完成、已取消。计费模块按里程、时长、动态调价计算订单金额。派单算法基于司机位置、方向、评分、历史接单率做更精细的候选排序。消息推送出车、收车、派单结果通过 WebSocket 或推送网关触达司机端。如果要做派单建议先设计好“司机位置”的存储。真实项目通常用 Redis GEO 存经纬度而不是每次查数据库计算距离。8. 最佳实践清单项目落地前可以按下面这份清单逐项检查是否用枚举或配置表统一管理状态而不是散落的魔法数字。是否已定义状态迁移规则并禁止未授权的跳转。是否在状态变更入口做了并发控制乐观锁或分布式锁二选一。是否记录状态变更日志日志是否包含 from、to、operator、time。是否区分司机状态、车辆状态、订单状态不混用同一个字段。是否在 Redis 和 MySQL 之间做了定时对账。是否考虑多实例部署下的锁竞争和缓存同步。是否对重复请求做了幂等处理。是否能在日志中看到完整的状态迁移链路。是否预留在状态机中扩展新状态的机制。以上只是状态机管理的一个最小闭环。实际项目里Uber、Lyft 这类平台的司机端还有更复杂的规则比如不同城市的资质校验、车辆类型限制、高峰时段的在线激励等。但无论业务多复杂底层都是先保证状态一致再保证业务规则可扩展。先把这一层做扎实后面的订单、计费、派单系统会顺很多。
返回列表