Spring Boot调度系统实战:缓存与并发导致的数据一致性问题解析
1. 从一个“灵异事件”说起列车长怎么变成了“鬼”这段时间在梳理一套“至冬公共列车调度管理系统”时遇到了一个很有意思的线上问题调度后台明明给某个车次分配了列车长系统也提示“分配成功”可过了几分钟再刷新刚才的列车长信息竟然凭空消失了。如果只是单次偶发还能安慰自己是操作手误。但这个问题持续出现并且有明确的复现规律在早晚高峰时段多个人同时操作调度台时列车长字段时有时无甚至出现过两位调度员看到同一次列车却挂着不同列车长的情况。群里有人开玩笑说“至冬公共列车的列车长是鬼”——人确实是分配的可系统里查不到就像幽灵一样。排查到最后这根本不是玄学而是典型的数据一致性问题缓存与数据库不一致、并发写入互相覆盖、事务边界不正确。本文就把这套列车调度系统的设计与实现拆开讲清楚包含表结构、后端代码、运行示例以及“列车长消失”这类幽灵问题的复现与修复思路。文章内容不依赖特定业务背景只要是做订单、排班、调度、任务分配这类系统的开发者都能从中看到自己项目里常见的问题影子。适合读者想学习 Spring Boot 项目完整实现流程的后端新手遇到“数据更新后查询不到”“多用户并发写入互相覆盖”的开发者需要在项目中引入缓存、分布式锁、并发控制但还没形成体系思路的朋友。2. 列车调度系统到底是什么核心概念先理清楚2.1 调度系统的业务定位公共列车调度系统简单来说是管理“一辆列车什么时候出发、由哪个乘务组值乘、谁是列车长、走哪条线路”的中台系统。它不直接控制列车运行而是负责任务编排与人员分配。从调度员视角核心操作只有几个创建车次计划比如 D112 次列车每天 18:00 从 A 站出发为某个日期的车次分配乘务组指定列车长和主要岗位人员发布调度计划相关岗位人员能看到自己的值乘任务遇到临时调整时重新分配或取消任务。这套系统在工作原理上和酒店排房、医院排班、外卖订单调度没有本质区别。核心都是在一张任务表上通过并发操作修改状态同时保证最终数据正确。2.2 “列车长是鬼”在技术上代表什么从数据库层面看“列车长信息消失”只有几种可能现象可能原因查询走缓存缓存里是旧数据分配成功后没有清缓存或者缓存过期时间设置不合理数据库记录被覆盖并发多线程同时更新同一条任务记录后提交的旧数据覆盖了新数据事务没有提交分配逻辑所在的事务方法异常回滚但接口返回了成功查询条件不匹配通过车次号查询时没有带上日期维度查到的是“模板”而非“当日实例”本文后文的实战案例会先把这套系统设计成正确可运行的版本再人为制造一个并发场景还原“列车长消失”的过程最后给出修复方案。2.3 功能模块拆分这次实战包含以下模块车次管理维护基础线路和车次信息乘务组管理维护乘务组成员并指定谁是列车长调度任务把某一天的车次和乘务组进行绑定生成可执行的调度记录查询接口给外部系统或前端提供“某车次当日列车长是谁”的查询能力。代码结构和技术选型采用 Spring Boot Spring Data JPA MySQL Redis这是目前后端业务系统比较常见的组合。3. 环境准备与项目结构3.1 版本环境说明本文代码采用以下环境JDK 1.8 及以上推荐 8 或 11Spring Boot 2.7.xMySQL 5.7 及以上Redis 5.0 及以上用于缓存热点数据Maven 3.6 及以上IDE 推荐 IntelliJ IDEA。版本号不需要与本例完全一致按你本机的实际环境调整即可。核心配置思路是一致的。如果你的项目使用 MyBatis-Plus 或 MyBatis替换数据访问层即可Service 层的业务逻辑仍然可复用。3.2 项目目录结构建议创建 Maven 项目train-dispatch-demo结构如下train-dispatch-demo ├── pom.xml ├── src │ └── main │ ├── java │ │ └── com/example/traindispatch │ │ ├── TrainDispatchApplication.java │ │ ├── controller │ │ │ └── DispatchController.java │ │ ├── entity │ │ │ ├── TrainInfo.java │ │ │ ├── CrewGroup.java │ │ │ └── ScheduleTask.java │ │ ├── repository │ │ │ ├── TrainInfoRepository.java │ │ │ ├── CrewGroupRepository.java │ │ │ └── ScheduleTaskRepository.java │ │ ├── service │ │ │ ├── DispatchService.java │ │ │ └── ScheduleQueryService.java │ │ └── config │ │ └── RedisConfig.java │ └── resources │ ├── application.yml │ └── schema.sql4. 数据库表设计先别写代码把数据模型定义好调度类系统最怕业务模型不清晰。这里使用三张核心表车次表、乘务组表、调度任务表。4.1 车次表 train_info保存车次的基础信息。注意这里保存的是“车次模板”并非某一天的具体班次。CREATE TABLE train_info ( id bigint NOT NULL AUTO_INCREMENT, train_no varchar(32) NOT NULL COMMENT 车次号如 D112, start_station varchar(64) NOT NULL COMMENT 始发站, end_station varchar(64) NOT NULL COMMENT 终点站, departure_time varchar(16) NOT NULL COMMENT 计划发车时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_train_no (train_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT车次信息表;车次表是基础资料很少被并发修改。4.2 乘务组表 crew_group乘务组与列车长的关系可以设计成一张表。这里把“列车长”作为一个字段挂在乘务组上为了演示简化了角色模型。如果你要支持多角色建议再拆一张乘务成员表。CREATE TABLE crew_group ( id bigint NOT NULL AUTO_INCREMENT, group_no varchar(32) NOT NULL COMMENT 乘务组编号, group_name varchar(64) NOT NULL COMMENT 乘务组名称, captain_name varchar(32) NOT NULL COMMENT 列车长姓名, captain_phone varchar(20) DEFAULT NULL COMMENT 列车长联系电话, status tinyint NOT NULL DEFAULT 1 COMMENT 1可用 0停用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_group_no (group_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT乘务组信息表;4.3 调度任务表 schedule_task这张表是核心。它把某一天的具体车次和乘务组绑定在一起。CREATE TABLE schedule_task ( id bigint NOT NULL AUTO_INCREMENT, task_no varchar(64) NOT NULL COMMENT 调度任务编号, train_id bigint NOT NULL COMMENT 车次ID, train_no varchar(32) NOT NULL COMMENT 冗余车次号, run_date varchar(16) NOT NULL COMMENT 运行日期如 2025-06-01, crew_group_id bigint NOT NULL COMMENT 乘务组ID, crew_group_no varchar(32) NOT NULL COMMENT 冗余乘务组编号, captain_name varchar(32) NOT NULL COMMENT 冗余列车长姓名, status tinyint NOT NULL DEFAULT 0 COMMENT 0待发布 1已发布 2已取消 3已完成, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_task (train_id, run_date), KEY idx_run_date (run_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT调度任务表;这里有两个设计要注意使用uk_task唯一索引约束同一个车次在同一天只有一条调度任务避免重复创建冗余了train_no、crew_group_no、captain_name字段查询时避免频繁关联两张基础表减少查询成本。version字段是乐观锁版本号后面修复并发问题时会用到。5. Spring Boot 工程搭建与核心代码实现5.1 引入 Maven 依赖在pom.xml中引入 Spring Boot Web、JPA、MySQL、Redis 相关依赖?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdtrain-dispatch-demo/artifactId version1.0.0-SNAPSHOT/version properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project注意Spring Boot 2.7.x 中spring-boot-starter-data-redis默认使用 Lettuce 客户端不需要额外引入客户端依赖。如果你已经用到低版本 Spring Boot需要更换对应的依赖坐标。5.2 application.yml 配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/train_dispatch?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none show-sql: true properties: hibernate: format_sql: true redis: host: localhost port: 6379 database: 0 timeout: 3000ms cache: type: redisddl-auto设置为none因为这里通过schema.sql手动建表。实际生产环境也建议由 DBA 统一管理表结构变更而不是让 JPA 自动建表。5.3 实体类三个实体类分别对应三张表。这里只展示最复杂的ScheduleTaskpackage com.example.traindispatch.entity; import javax.persistence.*; import java.time.LocalDateTime; Entity Table(name schedule_task) public class ScheduleTask { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name task_no, nullable false, length 64) private String taskNo; Column(name train_id, nullable false) private Long trainId; Column(name train_no, nullable false, length 32) private String trainNo; Column(name run_date, nullable false, length 16) private String runDate; Column(name crew_group_id, nullable false) private Long crewGroupId; Column(name crew_group_no, nullable false, length 32) private String crewGroupNo; Column(name captain_name, nullable false, length 32) private String captainName; Column(name status, nullable false) private Integer status; Version Column(name version, nullable false) private Integer version; Column(name create_time, nullable false) private LocalDateTime createTime; Column(name update_time, nullable false) private LocalDateTime updateTime; // 省略 getter/setter }这里使用了 JPA 的Version注解在update时 JPA 会自动生成update ... where version 当前版本号的 SQL。当版本号不匹配时会抛出OptimisticLockException这是后面修复并发覆盖的基础。需要注意的是Version注解的字段类型必须是int、long、Integer、Long或Timestamp不能使用基本类型之外的复杂类型。5.4 Repository 数据访问层package com.example.traindispatch.repository; import com.example.traindispatch.entity.ScheduleTask; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; Repository public interface ScheduleTaskRepository extends JpaRepositoryScheduleTask, Long { OptionalScheduleTask findByTrainIdAndRunDate(Long trainId, String runDate); OptionalScheduleTask findByTrainNoAndRunDate(String trainNo, String runDate); }这里利用方法名自动生成查询findByTrainIdAndRunDate会查train_id和run_date两个条件。对应业务规则同一列车同一天只能有一条调度任务。5.5 调度服务核心业务逻辑调度服务负责创建任务、发布任务、变更列车长。这是整篇文章的核心。package com.example.traindispatch.service; import com.example.traindispatch.entity.CrewGroup; import com.example.traindispatch.entity.ScheduleTask; import com.example.traindispatch.entity.TrainInfo; import com.example.traindispatch.repository.CrewGroupRepository; import com.example.traindispatch.repository.ScheduleTaskRepository; import com.example.traindispatch.repository.TrainInfoRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; Service public class DispatchService { Autowired private TrainInfoRepository trainInfoRepository; Autowired private CrewGroupRepository crewGroupRepository; Autowired private ScheduleTaskRepository scheduleTaskRepository; /** * 创建调度任务 */ Transactional(rollbackFor Exception.class) public ScheduleTask createScheduleTask(Long trainId, String runDate, Long crewGroupId) { // 1. 校验车次是否存在 TrainInfo trainInfo trainInfoRepository.findById(trainId) .orElseThrow(() - new RuntimeException(车次不存在: trainId)); // 2. 校验乘务组是否存在 CrewGroup crewGroup crewGroupRepository.findById(crewGroupId) .orElseThrow(() - new RuntimeException(乘务组不存在: crewGroupId)); // 3. 校验同一天是否已经存在该车次的调度任务 OptionalScheduleTask existTask scheduleTaskRepository.findByTrainIdAndRunDate(trainId, runDate); if (existTask.isPresent()) { throw new RuntimeException(该车次在 runDate 已经存在调度任务); } // 4. 组装调度任务 ScheduleTask task new ScheduleTask(); task.setTaskNo(TASK- UUID.randomUUID().toString().replace(-, ).substring(0, 16).toUpperCase()); task.setTrainId(trainInfo.getId()); task.setTrainNo(trainInfo.getTrainNo()); task.setRunDate(runDate); task.setCrewGroupId(crewGroup.getId()); task.setCrewGroupNo(crewGroup.getGroupNo()); task.setCaptainName(crewGroup.getCaptainName()); task.setStatus(0); task.setVersion(0); task.setCreateTime(LocalDateTime.now()); task.setUpdateTime(LocalDateTime.now()); return scheduleTaskRepository.save(task); } /** * 发布调度任务 */ Transactional(rollbackFor Exception.class) public void publishScheduleTask(Long taskId) { ScheduleTask task scheduleTaskRepository.findById(taskId) .orElseThrow(() - new RuntimeException(调度任务不存在: taskId)); if (task.getStatus() ! 0) { throw new RuntimeException(只有待发布状态的任务才能发布当前状态: task.getStatus()); } task.setStatus(1); task.setUpdateTime(LocalDateTime.now()); scheduleTaskRepository.save(task); } /** * 变更列车长 */ Transactional(rollbackFor Exception.class) public void changeCaptain(Long taskId, Long newCrewGroupId) { ScheduleTask task scheduleTaskRepository.findById(taskId) .orElseThrow(() - new RuntimeException(调度任务不存在: taskId)); // 这里只允许已发布任务变更 if (task.getStatus() ! 1) { throw new RuntimeException(只有已发布状态的任务才能变更列车长当前状态: task.getStatus()); } CrewGroup newCrewGroup crewGroupRepository.findById(newCrewGroupId) .orElseThrow(() - new RuntimeException(乘务组不存在: newCrewGroupId)); task.setCrewGroupId(newCrewGroup.getId()); task.setCrewGroupNo(newCrewGroup.getGroupNo()); task.setCaptainName(newCrewGroup.getCaptainName()); task.setUpdateTime(LocalDateTime.now()); scheduleTaskRepository.save(task); } }这段代码有三个注意点Transactional(rollbackFor Exception.class)确保任何异常抛出时事务回滚每次操作前都做过状态校验changeCaptain方法中先查后改如果在高并发场景下两个请求同时查到同一个taskId就可能存在后提交覆盖前提交的风险。5.6 查询服务为什么“列车长会消失”查询服务需要从 Redis 缓存读取数据这也是最容易出问题的地方。package com.example.traindispatch.service; import com.example.traindispatch.entity.ScheduleTask; import com.example.traindispatch.repository.ScheduleTaskRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.Optional; Service public class ScheduleQueryService { private static final String TASK_CACHE_KEY_PREFIX dispatch:task:; Autowired private ScheduleTaskRepository scheduleTaskRepository; Autowired private StringRedisTemplate stringRedisTemplate; Autowired private ObjectMapper objectMapper; /** * 查询当日某车次的列车长信息 */ public ScheduleTask queryTaskWithCache(String trainNo, String runDate) { String cacheKey TASK_CACHE_KEY_PREFIX trainNo : runDate; // 1. 先查缓存 String cacheValue stringRedisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { try { return objectMapper.readValue(cacheValue, ScheduleTask.class); } catch (Exception e) { // JSON 解析失败回源数据库同时删除缓存 stringRedisTemplate.delete(cacheKey); } } // 2. 缓存未命中查数据库 OptionalScheduleTask taskOpt scheduleTaskRepository.findByTrainNoAndRunDate(trainNo, runDate); if (!taskOpt.isPresent()) { return null; } // 3. 写入缓存设置 10 分钟过期 try { String json objectMapper.writeValueAsString(taskOpt.get()); stringRedisTemplate.opsForValue().set(cacheKey, json, Duration.ofMinutes(10)); } catch (Exception e) { // 序列化失败不影响主流程 } return taskOpt.get(); } /** * 清理缓存分配任务、变更列车长后调用 */ public void evictTaskCache(String trainNo, String runDate) { String cacheKey TASK_CACHE_KEY_PREFIX trainNo : runDate; stringRedisTemplate.delete(cacheKey); } }这里如果忽视了“更新后清缓存”的步骤就会出现很典型的幽灵问题数据库里列车长已经是新人但 Redis 缓存里还是旧人。查询接口因为缓存优先返回的是旧数据。看起来就是“之前的列车长阴魂不散”很像闹鬼。6. 完整运行初始化数据并启动项目6.1 准备初始化数据在数据库中执行以下初始化数据。这里需要先执行第 4 节的建表 SQL再插入测试数据。INSERT INTO train_info (id, train_no, start_station, end_station, departure_time) VALUES (1, D112, 至冬城, 北港, 18:00); INSERT INTO crew_group (id, group_no, group_name, captain_name, captain_phone, status) VALUES (1, G001, 一组, 张三, 13800000001, 1); INSERT INTO crew_group (id, group_no, group_name, captain_name, captain_phone, status) VALUES (2, G002, 二组, 李四, 13800000002, 1);6.2 启动主类package com.example.traindispatch; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; SpringBootApplication EnableCaching public class TrainDispatchApplication { public static void main(String[] args) { SpringApplication.run(TrainDispatchApplication.class, args); } }6.3 调用接口验证启动项目后使用 curl 或者 Postman 调用接口。创建调度任务curl -X POST http://localhost:8080/dispatch/create?trainId1runDate2025-06-01crewGroupId1预期返回 JSON 中captainName为“张三”status为 0。发布调度任务curl -X POST http://localhost:8080/dispatch/publish?taskId1再次查询curl http://localhost:8080/dispatch/query?trainNoD112runDate2025-06-01可以看到返回的列车长信息。此时你已经完成了一套最小可运行的调度系统。7. 复现“列车长是鬼”问题到底出在哪7.1 场景一更新数据库但没清缓存在changeCaptain方法中我们如果忘记了调用evictTaskCache那么会发生什么调度员 A 查询任务返回张三此时 Redis 缓存了张三调度员 B 变更列车长为李四数据库更新成功调度员 A 再次查询任务因为缓存还没过期Redis 中仍然是张三调度员 A 看到的结果是“列车长还是张三”但数据库已经不是了如果变更后有人手动清了 Redis数据恢复为李四。这种问题反直觉因为它不是“数据丢了”而是“查询结果不可见”。被覆盖的是缓存丢失的是“实时一致性”。真实系统里还会衍生出更严重的版本缓存被写入旧值后数据库已经更新但缓存过期前所有查询都读到旧值形成短暂的“幽灵窗口”。7.2 场景二并发双写覆盖假设两个调度员同时在两个终端上处理同一个任务。调度员 A 想把列车长换成张三调度员 B 想换成李四。由于changeCaptain方法内部是先findById查出旧数据再修改字段然后save。在事务隔离级别为默认的REPEATABLE READ或READ COMMITTED下两个事务可能会都读到同一条旧数据。时间线如下时间调度员 A调度员 B10:00:00查询任务列车长为张三10:00:01查询任务列车长为张三10:00:02修改为李四提交成功10:00:03修改为王五提交成功如果数据库没有锁、没有版本控制B 提交时会把 A 的改动覆盖。最终结果是“张三 → 王五”李四丢了。如果 B 没有重新查询而是基于旧数据覆盖甚至会出现“调度员以为自己在改列车长实际上把别人的变更覆盖掉了”。这种情况下可以理解为列车长不是鬼而是被后来者的数据覆盖了。7.3 场景三事务边界错误还有一种隐蔽问题如果createScheduleTask不加事务scheduleTaskRepository.save(task)之后可能在方法返回前出现异常导致数据回滚但接口没有正确返回失败。此时前端可能提示“创建成功”实际数据库中没有记录。查询自然查不到看起来像“幽灵任务”。实际项目中最常见的是Transactional注解失效常见原因包括方法被定义在同一个类内部调用Spring AOP 代理不生效方法不是 public异常被 catch 住了没有触发事务回滚数据库引擎不是 InnoDB比如 MyISAM 不支持事务。8. 修复方案让“幽灵列车长”彻底消失8.1 修复缓存不一致核心原则先更新数据库再清理缓存。修改changeCaptain方法在保存成功后调用缓存清理逻辑Transactional(rollbackFor Exception.class) public void changeCaptain(Long taskId, Long newCrewGroupId) { ScheduleTask task scheduleTaskRepository.findById(taskId) .orElseThrow(() - new RuntimeException(调度任务不存在: taskId)); if (task.getStatus() ! 1) { throw new RuntimeException(只有已发布状态的任务才能变更列车长当前状态: task.getStatus()); } CrewGroup newCrewGroup crewGroupRepository.findById(newCrewGroupId) .orElseThrow(() - new RuntimeException(乘务组不存在: newCrewGroupId)); task.setCrewGroupId(newCrewGroup.getId()); task.setCrewGroupNo(newCrewGroup.getGroupNo()); task.setCaptainName(newCrewGroup.getCaptainName()); task.setUpdateTime(LocalDateTime.now()); scheduleTaskRepository.save(task); // 清理缓存 scheduleQueryService.evictTaskCache(task.getTrainNo(), task.getRunDate()); }还有一个细节调用evictTaskCache必须在save之后。如果把清理缓存放在save之前存在窗口期内其他服务会读到旧缓存。8.2 乐观锁解决并发覆盖使用Version注解之后JPA 在更新时自动带上版本号条件。如果两条事务同时基于版本号 0 更新只有一条会成功另一条会抛出ObjectOptimisticLockingFailureException。在 Controller 层可以捕获这个异常并提示用户PostMapping(/dispatch/changeCaptain) public String changeCaptain(RequestParam Long taskId, RequestParam Long newCrewGroupId) { try { dispatchService.changeCaptain(taskId, newCrewGroupId); return 变更成功; } catch (ObjectOptimisticLockingFailureException e) { return 操作失败任务已被其他调度员修改请刷新后重试; } catch (Exception e) { return 操作失败 e.getMessage(); } }在真实业务中更好的做法是让前端在点击“变更”前把当前数据的版本号一起提交上来后端在 update 时校验版本号。这样用户可以明确感知到冲突。8.3 查询接口的缓存穿透处理如果某个车次在某个日期没有调度任务查询接口每次都会查数据库。高并发下这个查询流量会直接打到数据库产生缓存穿透。可以加一个空值缓存if (!taskOpt.isPresent()) { // 使用短过期时间缓存空值防止缓存穿透 stringRedisTemplate.opsForValue().set(cacheKey, NULL, Duration.ofMinutes(1)); return null; }注意空值缓存的过期时间要短避免车次任务真正创建后仍然读到“空缓存”。9. 完整 Controller 代码与接口汇总把上述逻辑串起来完整的 Controller 如下package com.example.traindispatch.controller; import com.example.traindispatch.entity.ScheduleTask; import com.example.traindispatch.service.DispatchService; import com.example.traindispatch.service.ScheduleQueryService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/dispatch) public class DispatchController { Autowired private DispatchService dispatchService; Autowired private ScheduleQueryService scheduleQueryService; PostMapping(/create) public String create(RequestParam Long trainId, RequestParam String runDate, RequestParam Long crewGroupId) { try { ScheduleTask task dispatchService.createScheduleTask(trainId, runDate, crewGroupId); return 创建成功任务编号 task.getTaskNo(); } catch (Exception e) { return 创建失败 e.getMessage(); } } PostMapping(/publish) public String publish(RequestParam Long taskId) { try { dispatchService.publishScheduleTask(taskId); return 发布成功; } catch (Exception e) { return 发布失败 e.getMessage(); } } PostMapping(/changeCaptain) public String changeCaptain(RequestParam Long taskId, RequestParam Long newCrewGroupId) { try { dispatchService.changeCaptain(taskId, newCrewGroupId); return 变更成功; } catch (ObjectOptimisticLockingFailureException e) { return 操作失败任务已被其他调度员修改请刷新后重试; } catch (Exception e) { return 操作失败 e.getMessage(); } } GetMapping(/query) public ScheduleTask query(RequestParam String trainNo, RequestParam String runDate) { return scheduleQueryService.queryTaskWithCache(trainNo, runDate); } }到这里一个具备创建、发布、变更、查询的基础调度系统已经完整可运行。10. 常见问题与排查思路问题现象常见原因解决思路查询出旧列车长Redis 缓存未失效更新后删除缓存或将缓存过期时间缩短加版本号入缓存 key两个调度员互相覆盖数据并发更新没有乐观锁加Version字段前端带版本号提交接口提示创建成功但数据库没有记录事务回滚但未正确抛错检查方法是否 public是否同类内调用数据库引擎是否为 InnoDB查询大量请求打到数据库缓存穿透缓存空值使用布隆过滤器加接口限流任务无法创建提示已存在唯一索引冲突前端按车次 日期先查询给出友好提示Redis 连接超时配置或网络问题调整timeout检查 Redis 服务状态排查建议先看数据库当前的实际数据确认是“没有写入”还是“写入了但查不到”再看 Redis 中缓存 key 对应的值确定是否读到旧缓存打开 SQL 日志观察实际执行的 update 语句对并发问题用 jmeter 或并发脚本压测复现后看异常日志。11. 最佳实践与工程建议经过这次的“幽灵列车长”事件我总结了一些对调度类系统非常实用的开发建议。11.1 业务模型层不要把基础模板数据和当日实例数据混在一张表里。车次模板与调度任务分离是避免语义混乱的前提冗余字段要克制。只在高频查询、低频更新的场景做冗余状态字段尽量使用数值枚举并在代码里定义常量或枚举类不要散落魔法数字。11.2 缓存层缓存 key 要包含业务维度比如dispatch:task:{trainNo}:{runDate}更新数据库成功后必须清理缓存而且顺序不能反缓存过期时间不能太长也不能太短。推荐业务上取 5~15 分钟再根据实际查询量调整缓存序列化对象要留意版本兼容。如果实体新增字段旧缓存反序列化可能失败。11.3 并发控制层涉及数据变更的服务推荐使用乐观锁写冲突频繁时再用分布式锁分布式锁的实现优先选择 Redisson避免自己实现 SETNX 锁时出现释放错乱问题不要在锁内做远程调用和耗时操作锁的生命周期越短越好。11.4 事务层事务注解要加rollbackFor Exception.class因为 Spring 默认只回滚 RuntimeException不能在事务里捕获所有异常不抛出否则事务不会回滚事务方法不要同类内调用否则代理失效。11.5 安全与审计调度操作建议记录操作日志至少包含操作人、操作时间、操作前后值变更列车长这类敏感操作应该保留变更历史表方便追溯涉及生产数据变更时先在小流量环境验证脚本确保影响范围可控。12. 下一步学习方向本文实现的调度系统虽然可以运行但离生产环境还有一段距离。如果你要继续完善可以从这几个方向入手用 Redisson 实现更可靠的分布式锁保证极端并发下也不出现双写覆盖引入消息队列比如 RocketMQ 或 RabbitMQ把“调度任务创建后通知乘务组”做成异步流程给调度任务增加更完整的状态机比如待发布、已发布、已接单、值乘中、已完成、已取消增加操作审计日志记录每一步变更的操作人和变更内容把缓存和数据库的一致性方案升级为 Canal MQ 异步清缓存降低业务代码侵入。这次“至冬公共列车列车长是鬼”的谜团本质上不是灵异问题而是分布式系统里最常见的缓存一致性和并发写覆盖问题。如果你在自己的项目里也遇到过类似的现象可以按照本文的流程先确认数据库数据再查缓存再看并发时间线问题通常很快就能定位。学会主动给系统设计乐观锁、版本号、缓存清理策略才能真正避免这种“幽灵数据”反复出现。