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,因此当前这一秒内入队的申请要等到下一轮。这是有意的取舍,但需要在文档中写明。