MyBatisPlus 乐观锁机制防止 lora-scripts 任务重复提交
在 AI 模型训练日益自动化的今天,像lora-scripts这样的 LoRA 微调工具已经成为许多团队快速适配 Stable Diffusion 或大语言模型的首选。它封装了从数据准备到权重导出的完整流程,极大降低了使用门槛。然而,当多个用户或调度实例同时操作时,一个看似简单却极具破坏性的问题浮现出来:训练任务被重复提交。
你有没有遇到过这种情况?前端点击“开始训练”后没反应,于是再点一次——结果后台启动了两个完全相同的训练进程;或者因为网络抖动,客户端重试请求,导致同一配置的任务并发运行。这不仅浪费 GPU 资源,还可能导致输出混乱、日志错乱,甚至污染模型版本管理。
这类问题本质上是并发控制缺失引发的数据一致性挑战。而我们不需要引入复杂的分布式锁或消息队列就能解决它。答案就藏在一个轻量但强大的机制里:MyBatisPlus 的乐观锁。
设想这样一个场景:系统中有两个调度节点(Node A 和 Node B),它们定时轮询数据库查找状态为PENDING的任务并尝试执行。如果没有任何并发保护,两者可能同时查到同一个待处理任务,并几乎同时发起更新操作将其置为RUNNING。最终,这个任务会被执行两次。
传统做法可能会用悲观锁,比如在查询时加FOR UPDATE,但这会阻塞其他事务读取,影响整体吞吐。尤其在读多写少的场景下,这种“以防万一”的加锁策略显得过于沉重。
而乐观锁则换了一种思路:我不提前锁定资源,而是假设冲突很少发生;只在真正修改时检查是否有人抢先一步。这种“先操作,后验证”的方式非常适合lora-scripts中任务状态变更频率低但需强一致性的特点。
它的核心实现非常简洁——通过一个名为version的字段来追踪记录的修改次数。每次更新数据时,SQL 语句都会附加一个条件:WHERE version = 当前值,并在成功后将version + 1。由于数据库的UPDATE是原子操作,因此只要有任何一个事务先完成了更新,后续基于旧版本号的请求就会失败,影响行数为 0,从而天然避免了并发修改。
MyBatisPlus 将这一机制封装得极为友好。你只需要做三件事:
- 在表中添加
version字段; - 在实体类对应字段上加上
@Version注解; - 注册
OptimisticLockerInnerInterceptor插件。
之后所有通过 MyBatisPlus 执行的更新操作都会自动带上版本校验逻辑,无需手动拼接 WHERE 条件。
来看个实际例子。假设我们的任务表结构如下:
CREATE TABLE lora_training_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_name VARCHAR(255) NOT NULL, config_path VARCHAR(512), status ENUM('PENDING', 'RUNNING', 'SUCCESS', 'FAILED') DEFAULT 'PENDING', version INT DEFAULT 1 COMMENT '乐观锁版本号', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );对应的 Java 实体类只需标注@Version:
@TableName("lora_training_task") public class LoraTrainingTask { @TableId(type = IdType.AUTO) private Long id; private String taskName; private String configPath; private String status; @Version @TableField("version") private Integer version; // 时间字段自动填充 @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; // getter/setter ... }然后在配置类中启用插件:
@Configuration public class MyBatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }一切就绪后,当我们尝试启动一个任务时,可以这样写服务逻辑:
@Service @Transactional(rollbackFor = Exception.class) public class TrainingTaskService { @Autowired private LoraTrainingTaskMapper taskMapper; public boolean startTask(Long taskId) { // 查询当前任务 LoraTrainingTask task = taskMapper.selectById(taskId); if (!"PENDING".equals(task.getStatus())) { throw new IllegalStateException("任务不可启动,当前状态:" + task.getStatus()); } // 构造更新对象(只设置要变的字段) LoraTrainingTask update = new LoraTrainingTask(); update.setId(taskId); update.setStatus("RUNNING"); // 使用 UpdateWrapper 添加额外条件 int rows = taskMapper.update(update, new UpdateWrapper<LoraTrainingTask>() .eq("id", taskId) .eq("status", "PENDING") // 状态前置校验 ); if (rows == 0) { throw new RuntimeException("任务启动失败,可能已被其他节点抢占"); } // 此处触发外部脚本执行,如调用 Python train.py invokeTrainingScript(task.getConfigPath()); return true; } }注意这里的update()方法生成的实际 SQL 类似于:
UPDATE lora_training_task SET status = 'RUNNING', version = version + 1 WHERE id = ? AND status = 'PENDING' AND version = ?即使两个节点同时执行这段代码,也只有一个能真正修改成功。另一个会收到rows == 0的结果,进而抛出异常或进入重试流程。这就是乐观锁在分布式环境下实现“抢占式任务分发”的精髓所在。
当然,防重复不只是靠乐观锁单打独斗。我们在任务提交阶段也可以做一层前置拦截。例如,在创建新任务前先检查是否存在同名且未完成的任务:
public boolean submitTask(String taskName, String configPath) { LoraTrainingTask exist = taskMapper.selectOne( new QueryWrapper<LoraTrainingTask>() .eq("task_name", taskName) .in("status", "PENDING", "RUNNING") ); if (exist != null) { throw new IllegalArgumentException("任务已存在:" + taskName); } LoraTrainingTask newTask = new LoraTrainingTask(); newTask.setTaskName(taskName); newTask.setConfigPath(configPath); newTask.setStatus("PENDING"); newTask.setVersion(1); return taskMapper.insert(newTask) > 0; }这样一来,无论是人为误操作还是接口重试,都能被有效拦截。
不过也要注意几个工程实践中的关键点:
- 命名规范很重要:建议任务名包含用户 ID、时间戳或配置哈希值,确保业务上的唯一性;
- 状态机要严谨:明确状态流转路径(如不允许从 FAILED 回到 PENDING),避免非法跳转;
- 配合有限重试:对于乐观锁更新失败的情况,可设计最多 2~3 次指数退避重试,应对瞬时竞争;
- 日志监控不可少:记录乐观锁冲突事件,作为系统压力和调度效率的观测指标;
- 可与 Redis 结合使用:对于高频幂等校验,可用 Redis 做第一层过滤,减轻数据库负担。
更进一步地,在企业级部署中,这套机制还能与其他能力融合。比如结合事件总线发布“任务状态变更”事件,供审计系统或通知服务消费;或是定期快照任务上下文,支持故障回滚与调试复现。
这种基于版本号的轻量级并发控制方案,看似简单,却精准命中了自动化训练系统的痛点。它没有引入复杂依赖,也不牺牲性能,仅靠数据库一行字段和一个注解,就在多节点、高并发环境中构筑起一道可靠防线。
更重要的是,它体现了一种设计哲学:在正确的地方用最合适的工具解决问题。不必为了防重就上分布式锁,也不必为了安全就牺牲可用性。MyBatisPlus 的乐观锁正是这样一个“恰到好处”的选择——简单、高效、可靠。
随着lora-scripts向更复杂的协同训练平台演进,类似的机制还将延伸至参数版本管理、资源抢占调度等领域。而这一次次微小的技术选型,终将汇聚成支撑大规模 AI 工程化的坚实底座。