让 MySQL 守住业务不变量:生成列单活槽位、守卫式 UPDATE 与"重复键即答案"
1. 问题与目标
我们的系统通过向 Git 仓库发起 Merge Request(MR)来落地用户的变更申请。如果每个申请各自发起一个 MR,多个 MR 会同时修改同一个仓库,彼此导致 rebase,并在流水线上排队。为此我们引入了批量聚合:申请审批通过后先进入等待集合,每一轮把等待中的申请合并成一个 MR 统一处理。
这个设计要求以下不变量始终成立:
- I1 单活批次:任意时刻最多只有一个活跃批次(运行中或阻塞中)。
- I2 不重复认领:一个申请最多被一个批次认领。
- I3 入队不阻塞:批次运行期间,新申请可以继续进入等待集合。
- I4 入队幂等:同一个申请重复提交,只入队一次。
- I5 一关系一行:每个”主体—目标”关系只有一行当前状态,重新申请复用这一行。
- I6 过期写入失败:状态转移基于预期的当前状态,状态已被别处改动时,写入必须失败。
目标是让数据库直接保证这些不变量,而不是依赖”应用代码恰好没有写错”。
2. 约束与考量
- 数据库是 MySQL InnoDB。 MySQL 不支持 PostgreSQL 那样的部分唯一索引(
CREATE UNIQUE INDEX ... WHERE status = 'RUNNING')。 - 服务多实例部署,不引入新组件。 没有 Redis 或 ZooKeeper 之类的分布式锁可用,也不打算为此引入。
- 触发源会重叠。 调度平台保证定时任务单实例执行,但运维人员可以手动触发同一轮次,手动触发与定时触发可能同时发生。
- 批次持续时间长。 一个批次从创建 MR 到合并需要数分钟,不能在这段时间里持有数据库锁。
- 进程可能在任意一步崩溃。 互斥机制不能因为持有者崩溃而永久泄漏。
3. 方案
3.1 生成列加唯一索引:单活槽位(I1)
CREATE TABLE merge_batch (
id BIGINT NOT NULL AUTO_INCREMENT,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=RUNNING 2=SUCCEEDED 3=FAILED 4=BLOCKED',
active_slot TINYINT GENERATED ALWAYS AS
(CASE WHEN status IN (1, 4) THEN 1 ELSE NULL END) STORED,
mr_id VARCHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_active_slot (active_slot),
KEY idx_status_created (status, created_at, id)
) ENGINE = InnoDB;
这个设计利用了 MySQL 唯一索引的一条语义:唯一索引允许多个 NULL。
- 活跃批次(RUNNING、BLOCKED)的
active_slot为 1,唯一索引保证全表最多一行取值为 1。 - 终态批次(SUCCEEDED、FAILED)的
active_slot为 NULL,任意多行可以共存。 - 批次进入终态的那条
UPDATE status同时让active_slot变成 NULL,槽位随之释放,不需要额外的释放步骤。
这在效果上等价于部分唯一索引 UNIQUE (status) WHERE status IN (RUNNING, BLOCKED)。
我们也比较了其他互斥方案:
| 方案 | 主要问题 |
|---|---|
锁行加 SELECT ... FOR UPDATE |
锁要么在整个批次期间持有(数分钟的长事务),要么另外维护一个租约字段 |
GET_LOCK() 会话级锁 |
锁绑定在数据库连接上,与连接池的连接复用不易配合 |
| Redis 等外部分布式锁 | 需要新组件;租约过期时间与任务实际耗时难以对齐 |
| 生成列加唯一索引 | 锁与状态是同一列,不存在租约,也不会出现”状态已终结而锁未释放” |
最后一行是我们选择它的原因:互斥状态就是业务状态本身。持有者崩溃时,批次停在 RUNNING,槽位保持占用,但这是一个可以被查询、被处理的显式状态,而不是一个看不见的泄漏锁。超时扫描或运维工具把它推进到终态,槽位即释放。阻塞态(BLOCKED)同样占用槽位,后续批次会停下来,直到这个批次被处理。
3.2 认领事务:不加锁的读取与守卫式 UPDATE(I2、I3)
CREATE TABLE merge_batch_request (
id BIGINT NOT NULL AUTO_INCREMENT,
operation_id VARCHAR(36) NOT NULL,
batch_id BIGINT NULL COMMENT 'NULL 表示仍在等待',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_operation_id (operation_id),
KEY idx_batch_created (batch_id, created_at, id)
) ENGINE = InnoDB;
每一轮的认领在一个事务中完成:
@Transactional
public Claim claim(int maxRequests) {
// 1. 占用槽位
long batchId;
try {
batchId = insertReturningKey("INSERT INTO merge_batch (status) VALUES (1)");
} catch (DuplicateKeyException e) {
return Claim.slotOccupied(); // 本事务尚未写入任何数据,正常返回等价于回滚
}
// 2. 读取等待中的申请:普通读,不加 FOR UPDATE
List<Req> pending = query(
"SELECT id, operation_id FROM merge_batch_request " +
"WHERE batch_id IS NULL AND created_at < CURRENT_TIMESTAMP " +
"ORDER BY created_at, id LIMIT ?", maxRequests);
if (pending.isEmpty()) {
throw new NothingToClaimException(); // 回滚,释放刚占用的槽位
}
// 3. 守卫式认领:只认领仍未被认领的行,且影响行数必须与读到的行数一致
int updated = update(
"UPDATE merge_batch_request SET batch_id = ? " +
"WHERE batch_id IS NULL AND id IN (" + placeholders(pending) + ")",
batchId, ids(pending));
if (updated != pending.size()) {
throw new ClaimPreemptedException(); // 有行被别处改动,整体回滚
}
// 4. 写入只追加的成员历史
insertHistory(batchId, pending);
return Claim.claimed(batchId, pending);
}
为什么第 2 步不用 FOR UPDATE。 在 REPEATABLE READ 隔离级别下,带范围条件的锁定读会对扫描到的索引区间加 next-key lock。这里扫描的是 idx_batch_created 上 batch_id IS NULL 的区间,而新入队的申请恰好插入这个区间,于是会被阻塞到认领事务结束,违反 I3。去掉锁定读并不影响正确性:
- 其他认领者被第 1 步的槽位挡在外面;
- 即使出现意外的并发修改,第 3 步的
WHERE batch_id IS NULL和影响行数校验也会发现,并让整个事务回滚。
为什么读到空集要抛异常。 如果正常提交,就会留下一个没有任何成员、却占着槽位的 RUNNING 批次。抛异常让槽位随事务一起回滚。
事务外的无锁预检。 进入认领事务之前,先执行一次 SELECT ... WHERE batch_id IS NULL ... LIMIT 1。等待集合为空时直接结束本轮,不会出现”占用槽位后又回滚”的空转。
统一使用数据库时钟。 判断条件写成 created_at < CURRENT_TIMESTAMP,超时扫描写成 created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND)。写入时间和比较时间来自同一个时钟,应用服务器之间的时钟偏差不会影响判断。
实现时需要确认的一点。 第 1 步在事务内捕获了唯一键冲突后正常返回。在 MySQL 中,单条语句失败只回滚这条语句,事务本身仍然可用。但部分数据访问框架会在异常被翻译时把事务标记为 rollback-only,导致提交时报错。采用这一写法前,需要确认所用框架的行为。
3.3 状态即版本:守卫式状态转移(I6)
所有状态写入都带上预期的当前状态,并检查影响行数:
-- 批次终态转移:只有仍处于 RUNNING 的批次才能转移
UPDATE merge_batch SET status = 2 WHERE id = ? AND status = 1; -- 影响行数必须为 1
-- 普通行的乐观并发控制:以刚读到的状态作为版本
UPDATE operation SET status = ?, ... WHERE operation_id = ? AND status = ?;
影响行数为 0 时,表示状态已被别处改动,写入方抛出”被抢占”异常。
这类守卫写入在组合事务中的位置也很重要。批次失败时的结算事务包含多步:把批次标记为 FAILED,再删除或重置该批次下每个申请的记录。我们把批次的守卫写入放在第一步:
tx.execute(List.of(
() -> batchRepo.markFailed(batchId, code, msg), // WHERE status = RUNNING,影响行数须为 1
() -> resetOperations(batchId) // 后续步骤
));
如果这个批次已经被另一条路径结算过(例如超时扫描与运行中的任务同时到达),第一步就会失败,整个事务回滚,后续的重置不会执行第二遍。
3.4 把重复键当作答案,而不是错误(I4)
唯一约束冲突在不同场景下有明确的业务含义,我们分别处理:
// 入队:重复表示"已经入队过",返回 false,不报错
public boolean admit(String opId) {
try {
insert("INSERT INTO merge_batch_request (operation_id) VALUES (?)", opId);
return true;
} catch (DuplicateKeyException e) {
return false;
}
}
// 审计记录:重复表示"已经记录过",不覆盖已有行(它可能已经是终态)
public void insertAudit(Audit a) {
try { insert(a); } catch (DuplicateKeyException e) { /* 预期内的去重结果 */ }
}
// 请求幂等:先查;查不到则插入;插入冲突说明并发请求先插入了,重读并返回胜出方的结果
public Result checkOrInsert(String requestId, String opId) {
Record r = findById(requestId);
if (r != null) return Result.duplicate(r.operationId());
try {
insert(new Record(requestId, opId));
return Result.fresh();
} catch (DuplicateKeyException e) {
Record winner = findById(requestId);
return winner != null ? Result.duplicate(winner.operationId())
: Result.retryLater();
}
}
这三种写法都没有显式加锁,并发下的正确性来自唯一约束本身。
3.5 一关系一行:原地重置与拒绝覆盖(I5)
binding 表以 (subject_id, target_key) 为唯一键,每个关系只有一行。但每次申请会生成一个新的 operation_id,于是”每次操作一行”的写入模型与”每个关系一行”的唯一键之间需要协调:
Binding existing = findBySubjectAndTarget(subject, target);
if (existing == null) {
insert(newRow); // 首次申请
} else if (REAPPLIABLE.contains(existing.status())) {
int n = update(
"UPDATE binding SET status = ?, operation_id = ?, expires_at = ? " +
"WHERE subject_id = ? AND target_key = ? AND status = ?", // 锁定读到的确切旧状态
newRow.status(), newRow.operationId(), newRow.expiresAt(),
subject, target, existing.status());
if (n == 0) throw new PreemptedException(); // 并发的重新申请或状态变化
} else {
throw new ConstraintViolationException(); // 拒绝覆盖活跃或待恢复的行
}
- 只有”已拒绝”“已关闭”等可重新申请的终态才允许原地重置。 重置会替换
operation_id,这正是上一篇里过期回调被识别出来的依据。 - 其他状态一律拒绝覆盖。 如果覆盖一个仍待人工恢复的失败行,它的
operation_id会被替换,挂在旧operation_id上的恢复流程将找不到目标。 - WHERE 条件锁定读到的确切旧状态,而不是”任一可重新申请状态”,从而在多个状态都可重新申请时,仍能发现读写之间发生的变化。
- 每次申请的历史保存在独立的历史表中,主表只保存当前状态。
3.6 跨表转移:固定加锁顺序,只有真正翻转时才返回结果
需要同时修改 operation 和 binding 两张表的转移,统一先锁 operation、再锁 binding,不同路径使用相同的加锁顺序,避免死锁。推进方法只在本次调用真正改变了状态时返回非空结果,调用方据此决定是否产生副作用(发送指标、通知)。这样并发的两条路径中,只有一条会产生副作用(细节见上一篇 3.7 节)。
3.7 批次失败结算:先关闭再重读,以已合并为准
批次失败时 MR 可能已经存在,并且可能恰好在失败判定之后合并成功。结算流程是:如果 MR 仍处于打开状态,先关闭它,然后重新读取 MR 的最终状态。
- 读到 merged:按成功处理,把所有成员对齐到”已合并”,批次标记为 SUCCEEDED。已合并的 MR 无法再被关闭,所以”合并”与”关闭”竞争时,合并的结果不会丢失。
- 读到 closed 或不存在 MR:批次标记为 FAILED,在同一个事务中删除各成员的处理记录;只追加的成员历史和审计记录保留。
4. 成果
- 六条不变量都由数据库约束或守卫式写入保证。 并发认领、重复入队、重复请求、过期的状态写入,都会在数据库层面被拒绝或被识别,不依赖调用时序。
- 没有引入新组件。 单活互斥没有锁表、租约或外部锁服务,互斥状态与业务状态是同一列,不存在两者不一致的情况。
- 入队不被批次阻塞。 认领事务不对等待区间加锁,批次运行期间新申请照常写入。
- 崩溃后状态显式。 崩溃的批次停在可查询的 RUNNING 状态,由超时扫描或运维工具结算,不存在看不见的泄漏锁。
- 定时触发与手动触发可以安全重叠。 两者执行同一段代码,重叠时后到的一方得到”槽位被占用”,不需要额外协调。
5. 已知限制与后续计划
- 状态即版本存在 ABA 问题。 一行在读写之间离开某个状态又回到该状态,守卫条件仍会通过。表中预留了
version列,目前尚未启用。对于可能往返的状态,后续计划改用单调递增的版本号。 - 全局只有一个槽位。 当前设计不支持按维度并行。如果需要按分区并行,可以把生成列改为
CASE WHEN status IN (1, 4) THEN partition_key END,唯一索引就变成”每个分区最多一个活跃批次”。 - 批量路径存在逐行访问。 移交阶段按申请逐条回查关系行(N+1);成员历史逐行插入;
IN列表最多可达上千个占位符,且未分片。后续计划改为批量查询、批量插入,并对IN列表分片。 - 部分查询缺少合适的索引。 对账查询按
status加时间范围过滤,但只有单列时间索引;取最大 MR 编号使用ORDER BY CAST(mr_id AS UNSIGNED),表达式排序无法利用索引。后续计划补充(status, updated_at)复合索引,并把 MR 编号改存为数值列。 - 幂等记录的过期时间未生效。 记录带有过期时间字段,但没有清理任务,也没有对应的索引。
- 时间精度为秒。
created_at精度为秒,认领条件是created_at < CURRENT_TIMESTAMP,因此当前这一秒内入队的申请要等到下一轮。这是有意的取舍,但需要在文档中写明。