1. 问题与目标

我们维护一个”配置即代码”的自助变更系统。用户提交一次变更申请后,系统需要依次完成:

  1. 向外部审批系统发起审批,等待审批结果回调;
  2. 以机器人账号在 Git 仓库创建分支、提交文件、发起 Merge Request(下称 MR);
  3. 等待 MR 的检查流水线通过;
  4. 以审批机器人身份批准 MR 并合并,合并失败时 rebase 后重试;
  5. 等待部署流水线把配置下发,并通过回调告知系统”已生效”。

一次变更跨越审批系统、Git 托管平台(GitLab)和 CI 三个外部系统,耗时从几分钟到几小时不等。如果把它当作一次普通的同步请求来处理,会面临几个问题:HTTP 连接无法等待这么久;任何一步失败都可能让请求停在一个无人知晓的中间态;外部系统的回调可能重复、迟到或乱序;进程重启会丢失正在执行的步骤。

我们为这条流程设定了四个目标:

  • 可观察:任何时刻,一个请求都处在一个可解释的状态,并且能看出它停在哪一步。
  • 失败可见:失败必须落成一个明确的失败状态,不能表现为永远”处理中”。
  • 对事件鲁棒:重复、迟到、属于旧申请的事件不得改变当前申请的状态。
  • 可演进:调整流程顺序、适配上游接口变化时,不中断在途请求。

2. 约束与考量

外部调用不在本地事务内。 在 GitLab 上创建分支、提交和 MR 之后,本地写库仍可能失败;反过来,本地状态已经写入,远端调用也可能失败。两边之间不存在可以依赖的原子性。

事件来源多,且投递语义弱。 驱动状态变化的来源包括:用户提交、审批系统回调、系统自身推进、部署流水线回调、定时对账、运维人员手动恢复。外部回调按”至少一次”投递,可能迟到,也可能早于我们自己的状态写入到达。

上游响应存在歧义。 GitLab 对”同一账号重复批准”返回 HTTP 401,与”该账号无权批准”是同一个状态码。只看响应无法区分两者。

部署形态。 服务多实例部署。我们不希望为这一条流程引入工作流引擎或分布式锁等新组件,现有的共享协调点只有关系数据库。

流程需要演进。 流程顺序要从”先建 MR 再审批”调整为”先审批再建 MR”,切换时已有审批在途。上游平台还计划在审批接口中新增一个必填的结构化字段,并给出了兼容期。

3. 方案

3.1 两层状态:步骤状态与对外状态

我们用两张表分别记录两个粒度的状态:

  • operation 表记录步骤状态,细到”流水线超时”“rebase 冲突”“机器人批准失败”。状态码按阶段划分号段(创建阶段 10001 起、处理阶段 10101 起、关闭阶段 10201 起),按号段前缀即可聚合统计。
  • binding 表记录对外状态,只表达用户关心的阶段:审批中、MR 创建中、合并中、已合并、已生效、失败、已拒绝。该表以 (subject_id, target_key) 为唯一键,每个”主体—目标”关系只有一行。
enum StepStatus {            // 细粒度:服务于排障和指标
    CREATE_STARTED(10001), CREATE_SUCCEEDED(10002), CREATE_PIPELINE_FAILED(10004),
    HANDLE_STARTED(10101), HANDLE_APPROVED(10102), HANDLE_REBASE_CONFLICT(10105),
    HANDLE_PIPELINE_PENDING(10501), CALLED_BACK(10301), /* ... */;
}

enum BindingStatus {         // 粗粒度:服务于展示和"能否重新申请"
    APPROVAL_SENDING, APPROVAL_SENT, MR_CREATING, MR_CREATED, MERGING, MERGED, VALIDATED,
    CREATE_FAILED, HANDLE_FAILED, REJECTED, CLOSED;

    /** 唯一的事实来源:数据层和接口层都引用它判断能否重新申请。 */
    static final Set<BindingStatus> REAPPLIABLE = Set.of(REJECTED, CLOSED);
}

之所以拆成两层,是因为两类读者的需求不同。排障需要知道请求停在哪个具体步骤;用户和”能否重新申请”的判断只关心所处的大阶段。如果只用一个状态字段,每次细化内部步骤都会牵动前端展示和重新申请规则。

每次状态转移都在同一个本地事务里同时写两张表:

void transition(OperationBuilder op, StepStatus step, BindingStatus status) {
    tx.execute(List.of(
        () -> operationRepo.update(op.status(step)),          // 以刚读到的状态做乐观并发校验
        () -> bindingRepo.updateStatus(op.id(), status)
    ));
}

下图是对外状态的主路径(省略了各类失败分支):

stateDiagram-v2
    [*] --> APPROVAL_SENT: 提交申请
    APPROVAL_SENT --> REJECTED: 审批拒绝或撤回
    APPROVAL_SENT --> MR_CREATING: 审批通过
    MR_CREATING --> MR_CREATED: 检查流水线通过
    MR_CREATING --> CREATE_FAILED
    MR_CREATED --> MERGING
    MERGING --> MERGED: 合并成功
    MERGING --> HANDLE_FAILED
    MERGED --> VALIDATED: 部署回调或定时对账
    REJECTED --> [*]
    VALIDATED --> [*]

3.2 入口异步化,失败一律落库

提交接口只做同步校验(参数、幂等、是否已有进行中的申请),然后把长流程提交到有界线程池,立即返回 HTTP 202 和 operationId。客户端按 operationId 轮询。

private void fireAsync(String opId, Task task, StepStatus failStep, BindingStatus failStatus) {
    try {
        executor.execute(() -> {
            try {
                task.run();
            } catch (Exception e) {
                log.error("async step failed", e);        // 失败状态已由切面写入,见下文
            } catch (Throwable t) {
                log.error("[CRITICAL] JVM-level error", t); // JVM 可能已处于异常状态,只记录不写库
            }
        });
    } catch (RejectedExecutionException e) {
        // 线程池满或正在关闭:把失败写进库,轮询方看到"失败"而不是"处理中"
        tx.execute(List.of(
            () -> operationRepo.updateStatus(opId, failStep),
            () -> bindingRepo.updateStatus(opId, failStatus)));
    }
}

线程池的核心线程数等于最大线程数,队列有界,拒绝策略选择 AbortPolicy。没有选另外两种常见做法,原因如下:

  • CallerRunsPolicy 会让 Web 线程执行一个耗时数分钟的任务,过载会被传导到所有接口。
  • 无界队列会掩盖过载,任务可能在队列里等很久,期间用户看到的始终是”处理中”。

AbortPolicy 加上”拒绝即落库”,把过载转化成一个用户可见、可以统计的失败状态。

每个步骤方法通过注解声明自己的失败状态,由切面统一兜底:

@WorkflowStep(onErrorStep = StepStatus.HANDLE_MERGE_FAILED,
              onErrorStatus = BindingStatus.HANDLE_FAILED)
public Result handleMergeRequest(String opId, long mrIid) { ... }

方法内部已经识别的失败(如流水线失败、rebase 冲突)由业务代码写入更具体的状态;未预料的异常由切面写入注解声明的状态。两者合起来,保证任何一条退出路径都会留下终态。

3.3 回调守卫:只接受”当前所有者”且”正在等待”的事件

审批回调的处理分两层。第一层按回调自身携带的请求 ID 做幂等,挡住重复投递。第二层判断这个回调是否仍然有效:

Binding row = bindingRepo.findByOperationId(callback.operationId());
if (row == null || !AWAITING_APPROVAL.contains(row.status())) {
    return accepted("IGNORED_STALE");   // 返回 2xx,避免上游把它当作失败反复重试
}

这里有两个设计点。

  • 按 operationId 判断所有权。 用户重新申请时,binding 行会被原地重置并换上新的 operationId(见数据库篇)。旧申请的迟到回调因此查不到行,被直接忽略,不会把新申请推进到错误的状态。
  • 等待集合包含”发送中”。 AWAITING_APPROVAL = {APPROVAL_SENDING, APPROVAL_SENT}。我们在调用审批接口之前写入 SENDING,调用成功后写入 SENT。审批系统的回调可能比 SENT 的写入更早到达,如果等待集合只包含 SENT,这类回调会被误判为过期。

3.4 以远端状态为准的幂等

本地状态与远端状态之间没有原子性,所以每个会改动远端的步骤在执行前先读远端状态:

  • 处理 MR 之前,如果 MR 已经是 merged,直接按”已合并”收尾,不再批准。否则重新批准会被 GitLab 拒绝,记录下来的会是”批准失败”,把排查方向引向权限问题。
  • 关闭 MR 之前,如果它已经是 closed 或 merged,跳过关闭调用。

机器人批准这一步需要额外处理 401 的歧义:

void approveAsReviewer(Api reviewer, long mrIid) throws ApiException, InterruptedException {
    int maxRetries = Math.max(0, config.getInt("approval.maxRetries", 3)); // 负值按 0 处理
    ApiException last = null;
    for (int attempt = 1; attempt <= maxRetries + 1; attempt++) {
        if (hasApprovalFrom(reviewer, mrIid)) {
            return;                                   // 已经批准过:跳过调用
        }
        try {
            approver.approve(reviewer, mrIid);
            return;
        } catch (ApiException e) {
            last = e;
            if (attempt > maxRetries) break;
            Thread.sleep(backoffBaseMs << (attempt - 1)); // 指数退避
        }
    }
    // 重复批准与无权批准都返回 401。最后再读一次,确认批准是否其实已经存在。
    if (hasApprovalFrom(reviewer, mrIid)) {
        return;
    }
    throw last;
}
  • 每次尝试前读一次批准人列表。 如果上一次批准请求已到达 GitLab 但响应丢失,下一次尝试会看到批准已存在,而不是再收到一个 401。
  • 最终失败后再读一次。 最后一次尝试之后没有下一次尝试来纠正,这次读取覆盖了这个情况。
  • 读取是 fail-open 的。 读不到批准人列表时,按”尚未批准”处理,继续尝试批准。这保持了引入该检查之前的行为,检查本身不会成为新的失败来源。
  • 两个审批账号分别重试。 两次批准不是一个原子操作,如果把它们作为一对整体重试,会对第一个账号重复提交批准。

这些检查让”从中断处重新执行”变得安全,运维恢复工具可以直接重新触发某个步骤。

3.5 等待外部状态:阶梯式轮询,区分”超时”与”失败”

rebase 结果和流水线状态都需要轮询。我们把轮询抽象成一个通用组件:前 N 次用短间隔,之后改用长间隔,达到上限后返回超时。轮询结果有三种:通过、失败、超时。

  • 流水线失败是确定性的,用同一个 MR 重试仍会失败,因此进入失败状态。
  • 流水线超时表示还不知道结果,对外状态保持在”合并中”,步骤状态记为”流水线待定”,交由人工确认。

接口层据此向用户给出不同的提示:确定性失败提示”需要联系维护团队”,结果未知的情况提示”可以继续等待”(详见 API 篇)。

3.6 在线调整流程顺序,无需排空

把流程从”先建 MR 再审批”改为”先审批再建 MR”时,我们没有设置停机或排空窗口,而是让两种顺序在一段时间内并存:

  1. 新流程由配置开关控制,默认关闭,可以随时切回旧流程。
  2. 回调按载荷形态区分流程。 旧流程在发起审批时 MR 已经存在,会把 MR 编号放进审批单的扩展字段;新流程发起审批时还没有 MR。回调处理据此判断:

    boolean legacyFlow = StringUtils.isNotEmpty(callback.ext().get("mrId"));
    if (approved) {
        if (legacyFlow) workflow.fireHandle(opId, mrId);      // MR 已存在:批准并合并
        else            workflow.fireCreateAfterApproval(opId); // 先创建 MR
    }
    

    切换开关前发出、切换后才回调的审批,仍按旧流程处理。

  3. 创建步骤通过查库判断”插入还是推进”,而不是依赖调用方传入的标志:

    if (operationRepo.exists(opId)) {
        transition(op, CREATE_STARTED, MR_CREATING);   // 新流程:行在发起审批时已创建
    } else {
        insertOperationAndBinding(op);                 // 旧流程:首次创建
    }
    

    这让同一个步骤对两种顺序都成立,对运维重新触发也保持幂等。

3.7 丢失回调的定时对账

部署流水线的回调可能丢失,相应的请求会停在”已合并”。我们用一个定时任务收敛这类记录。

滑动时间窗口。 每次只扫描最后变更时间位于 (now - maxAge, now - minAge) 内的”已合并”记录。早于 minAge 的记录可能只是回调还在路上,跳过;晚于 maxAge 的记录移出窗口,以此限制单次工作量。

用水位线代替逐行查询。 回调表只记录 MR 编号,不含业务字段。对账任务只做两次查询:一次 IN 查询批量取出 operationId → mrIid,一次查询取回调表中最大的 MR 编号。之后对每一行做一次比较:

mrIid <= maxCallbackIid  → 推进为"已生效"
mrIid >  maxCallbackIid  → 保持"已合并",等待下一轮

mrIid == max 表示这个 MR 自己的回调已经到达;mrIid < max 表示编号更大的 MR 已有回调,而 MR 编号在项目内单调递增,由此推断较早的这条回调已经丢失。这条推断成立的前提是:部署流水线按合并顺序执行,且后一次执行不会遗漏之前已合并的变更。minAge 窗口用来防止把”迟到”误判为”丢失”。

与正常回调路径并发时只生效一次。 回调处理和对账任务都会调用同一个状态推进方法:

@Transactional
public String markValidated(String opId) {
    Operation op = operationRepo.selectForUpdate(opId);  // 固定加锁顺序:先 operation
    Binding   b  = bindingRepo.selectForUpdate(opId);    // 再 binding
    if (b == null || (b.status() != MERGED && b.status() != VALIDATED)) {
        return null;                                     // 不符合条件:幂等空操作
    }
    if (op.status() != CALLED_BACK) {
        operationRepo.updateStatus(opId, CALLED_BACK);   // 修复可能滞后的步骤状态
    }
    if (b.status() == MERGED) {
        requireOneRow(bindingRepo.update(
            "SET status = VALIDATED WHERE operation_id = ? AND status = MERGED", opId));
        return b.subjectId();                            // 只有真正翻转时才返回非空
    }
    return null;
}

两条路径并发时,行锁保证只有一方完成翻转,另一方读到 VALIDATED 后返回 null。调用方只在返回非空时计入”生效成功”指标,并且等外层事务提交之后再统一发出,所以事务回滚不会留下多计的指标,并发也不会导致重复计数。

3.8 适配上游接口变更

上游平台计划在批准接口中新增一个必填字段:一组需要逐项确认的检查项。我们使用的 GitLab 客户端库无法表达这个字段。处理方式如下:

  • 把批准动作收敛到一个独立组件。 组件内有两种实现:沿用客户端库的旧实现,以及直接发 HTTP 请求、携带检查项的新实现。每次调用时读取配置开关来选择,切换不需要重启。
  • 检查项不写死。 从上游的配置接口读取检查项列表,并按项目缓存。
  • 利用 422 响应自我修复。 项目的检查项配置变化后,按旧缓存构造的请求会收到 422,而 422 的响应体中带有当前生效的列表。组件直接用响应体里的列表重建请求重试一次,省去一次额外的读取往返。
  • 保持异常语义不变。 两种实现都以客户端库原有的异常类型报告失败,并携带真实的 HTTP 状态码。调用方的重试循环、401 歧义处理和日志都无需改动。

3.9 为什么没有选择工作流引擎

成熟的工作流引擎能提供持久化执行、自动重试和崩溃恢复,这些正是本方案中手工实现的部分。我们没有采用的原因是:这条流程的步骤数量有限;团队已有的共享基础设施只有关系数据库;而真正困难的部分(外部响应的歧义、回调的过期判断、以远端状态为准的幂等)是业务语义,引擎并不能替我们解决。代价是部分持久化执行能力需要自己补齐,见第 5 节。

4. 成果

  • 状态可查询。 每个请求都能通过 operationId 查到所处阶段和停止的具体步骤,排障不需要翻日志重建过程。
  • 失败可见。 线程池过载、步骤异常、流水线失败都会落成明确的失败状态,不会表现为永远”处理中”。
  • 对事件鲁棒。 重复投递、迟到回调、属于旧申请的回调不会改变当前申请的状态。
  • 重入安全。 运维从中断处重新触发步骤时,已合并的请求直接收尾,已批准的账号跳过批准,不会制造新的失败。
  • 流程可演进。 流程顺序切换通过配置完成,发布期间的在途审批无需排空;上游接口变更通过开关切换,调用方无需修改,切换无需重启。
  • 丢失回调可自动收敛。 定时对账把滞留记录推进到终态,并保证成功指标只计一次。

5. 已知限制与后续计划

  • 合法转移没有集中定义。 乐观并发校验比较的是”刚读到的状态”,只能防止丢失更新,不能防止非法的 from→to 转移。合法性目前分散在入口守卫、可重新申请集合和推进方法的前置条件中。后续计划引入集中的转移表,所有写入都经过它校验。
  • 执行状态不持久。 长流程运行在进程内线程池中,等待通过线程休眠实现。进程在步骤中途崩溃时,记录会停在”进行中”。批量路径已有超时扫描,单请求路径目前依赖运维工具恢复。后续计划为各类”进行中”状态增加超时扫描,或把等待改为由调度器驱动。
  • 对账依赖推断。 水位线方法依赖 MR 编号单调和部署流水线的执行语义,且回调表不含业务维度,比较是全局的。如果回调能携带业务标识,可以改为精确匹配。
  • 流程代码集中在一个大类中。 这增加了整体审计状态机的成本,后续计划按阶段拆分。