☰
Spring Boot调度系统实战:缓存与并发导致的数据一致性问题解析
2026/10/21 17:23:31 网站建设 项目流程

1. 从一个“灵异事件”说起:列车长怎么变成了“鬼”

这段时间在梳理一套“至冬公共列车调度管理系统”时,遇到了一个很有意思的线上问题:调度后台明明给某个车次分配了列车长,系统也提示“分配成功”,可过了几分钟再刷新,刚才的列车长信息竟然凭空消失了。

如果只是单次偶发,还能安慰自己是操作手误。但这个问题持续出现,并且有明确的复现规律:在早晚高峰时段,多个人同时操作调度台时,列车长字段时有时无,甚至出现过两位调度员看到同一次列车却挂着不同列车长的情况。群里有人开玩笑说:“至冬公共列车的列车长是鬼?”——人确实是分配的,可系统里查不到,就像幽灵一样。

排查到最后,这根本不是玄学,而是典型的数据一致性问题:缓存与数据库不一致、并发写入互相覆盖、事务边界不正确。本文就把这套列车调度系统的设计与实现拆开讲清楚,包含表结构、后端代码、运行示例,以及“列车长消失”这类幽灵问题的复现与修复思路。

文章内容不依赖特定业务背景,只要是做订单、排班、调度、任务分配这类系统的开发者,都能从中看到自己项目里常见的问题影子。

适合读者:

  • 想学习 Spring Boot 项目完整实现流程的后端新手;
  • 遇到“数据更新后查询不到”“多用户并发写入互相覆盖”的开发者;
  • 需要在项目中引入缓存、分布式锁、并发控制,但还没形成体系思路的朋友。

2. 列车调度系统到底是什么?核心概念先理清楚

2.1 调度系统的业务定位

公共列车调度系统,简单来说,是管理“一辆列车什么时候出发、由哪个乘务组值乘、谁是列车长、走哪条线路”的中台系统。它不直接控制列车运行,而是负责任务编排与人员分配。

从调度员视角,核心操作只有几个:

  1. 创建车次计划(比如 D112 次列车,每天 18:00 从 A 站出发);
  2. 为某个日期的车次分配乘务组;
  3. 指定列车长和主要岗位人员;
  4. 发布调度计划,相关岗位人员能看到自己的值乘任务;
  5. 遇到临时调整时,重新分配或取消任务。

这套系统在工作原理上,和酒店排房、医院排班、外卖订单调度没有本质区别。核心都是:在一张任务表上,通过并发操作修改状态,同时保证最终数据正确。

2.2 “列车长是鬼”在技术上代表什么?

从数据库层面看,“列车长信息消失”只有几种可能:

现象可能原因
查询走缓存,缓存里是旧数据分配成功后没有清缓存,或者缓存过期时间设置不合理
数据库记录被覆盖并发多线程同时更新同一条任务记录,后提交的旧数据覆盖了新数据
事务没有提交分配逻辑所在的事务方法异常回滚,但接口返回了成功
查询条件不匹配通过车次号查询时,没有带上日期维度,查到的是“模板”而非“当日实例”

本文后文的实战案例,会先把这套系统设计成正确可运行的版本,再人为制造一个并发场景,还原“列车长消失”的过程,最后给出修复方案。

2.3 功能模块拆分

这次实战包含以下模块:

  • 车次管理:维护基础线路和车次信息;
  • 乘务组管理:维护乘务组成员,并指定谁是列车长;
  • 调度任务:把某一天的车次和乘务组进行绑定,生成可执行的调度记录;
  • 查询接口:给外部系统或前端提供“某车次当日列车长是谁”的查询能力。

代码结构和技术选型采用 Spring Boot + Spring Data JPA + MySQL + Redis,这是目前后端业务系统比较常见的组合。

3. 环境准备与项目结构

3.1 版本环境说明

本文代码采用以下环境:

  • JDK 1.8 及以上(推荐 8 或 11);
  • Spring Boot 2.7.x;
  • MySQL 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.sql

4. 数据库表设计:先别写代码,把数据模型定义好

调度类系统最怕业务模型不清晰。这里使用三张核心表:车次表、乘务组表、调度任务表。

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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='调度任务表';

这里有两个设计要注意:

  1. 使用uk_task唯一索引约束同一个车次在同一天只有一条调度任务,避免重复创建;
  2. 冗余了train_no、crew_group_no、captain_name字段,查询时避免频繁关联两张基础表,减少查询成本。

version字段是乐观锁版本号,后面修复并发问题时会用到。

5. Spring Boot 工程搭建与核心代码实现

5.1 引入 Maven 依赖

在pom.xml中引入 Spring Boot Web、JPA、MySQL、Redis 相关依赖:

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>train-dispatch-demo</artifactId> <version>1.0.0-SNAPSHOT</version> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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: redis

ddl-auto设置为none,因为这里通过schema.sql手动建表。实际生产环境也建议由 DBA 统一管理表结构变更,而不是让 JPA 自动建表。

5.3 实体类

三个实体类分别对应三张表。这里只展示最复杂的ScheduleTask:

package 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 JpaRepository<ScheduleTask, Long> { Optional<ScheduleTask> findByTrainIdAndRunDate(Long trainId, String runDate); Optional<ScheduleTask> 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. 校验同一天是否已经存在该车次的调度任务 Optional<ScheduleTask> 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); } }

这段代码有三个注意点:

  1. @Transactional(rollbackFor = Exception.class),确保任何异常抛出时事务回滚;
  2. 每次操作前都做过状态校验;
  3. 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. 缓存未命中,查数据库 Optional<ScheduleTask> 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?trainId=1&runDate=2025-06-01&crewGroupId=1"

预期返回 JSON 中captainName为“张三”,status为 0。

发布调度任务:

curl -X POST "http://localhost:8080/dispatch/publish?taskId=1"

再次查询:

curl "http://localhost:8080/dispatch/query?trainNo=D112&runDate=2025-06-01"

可以看到返回的列车长信息。此时你已经完成了一套最小可运行的调度系统。

7. 复现“列车长是鬼”:问题到底出在哪

7.1 场景一:更新数据库但没清缓存

在changeCaptain方法中,我们如果忘记了调用evictTaskCache,那么会发生什么?

  1. 调度员 A 查询任务,返回张三,此时 Redis 缓存了张三;
  2. 调度员 B 变更列车长为李四,数据库更新成功;
  3. 调度员 A 再次查询任务,因为缓存还没过期,Redis 中仍然是张三;
  4. 调度员 A 看到的结果是“列车长还是张三”,但数据库已经不是了;
  5. 如果变更后有人手动清了 Redis,数据恢复为李四。

这种问题反直觉,因为它不是“数据丢了”,而是“查询结果不可见”。被覆盖的是缓存,丢失的是“实时一致性”。

真实系统里,还会衍生出更严重的版本:缓存被写入旧值后,数据库已经更新,但缓存过期前所有查询都读到旧值,形成短暂的“幽灵窗口”。

7.2 场景二:并发双写覆盖

假设两个调度员同时在两个终端上处理同一个任务。调度员 A 想把列车长换成张三,调度员 B 想换成李四。

由于changeCaptain方法内部是先findById查出旧数据,再修改字段,然后save。在事务隔离级别为默认的REPEATABLE READ或READ COMMITTED下,两个事务可能会都读到同一条旧数据。

时间线如下:

时间调度员 A调度员 B
10: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 服务状态

排查建议:

  1. 先看数据库当前的实际数据,确认是“没有写入”还是“写入了但查不到”;
  2. 再看 Redis 中缓存 key 对应的值,确定是否读到旧缓存;
  3. 打开 SQL 日志,观察实际执行的 update 语句;
  4. 对并发问题用 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 异步清缓存,降低业务代码侵入。

这次“至冬公共列车列车长是鬼”的谜团,本质上不是灵异问题,而是分布式系统里最常见的缓存一致性和并发写覆盖问题。如果你在自己的项目里也遇到过类似的现象,可以按照本文的流程,先确认数据库数据,再查缓存,再看并发时间线,问题通常很快就能定位。学会主动给系统设计乐观锁、版本号、缓存清理策略,才能真正避免这种“幽灵数据”反复出现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询