流程实例状态追踪:事件溯源模式的应用

去年做集团 OA 流程平台重构时,我负责状态追踪模块的方案设计。原来的系统只存流程实例的当前状态,上线三年一直相安无事,直到审计部门进场。这次复盘把当时踩过的坑和最终的改造方案完整梳理出来,核心就一件事:把"只存当前状态"改成"事件溯源 + 快照",流程的历史才真正可追溯。

一、只存当前状态的系统,先在审计上翻了车

1.1 一个答不上来的问题

审计进场第一周就给我们出了道题:"这张三月份的付款单,上周三为什么被驳回?"我翻数据库,流程实例表里只有一个 status = 'rejected' 字段,驳回人、驳回时间、驳回意见散落在另外两张业务表里,字段还是后来迭代加的,历史数据根本没回填。更糟的是,驳回之前状态被财务初审改过一次,那次修改的旧值被直接 UPDATE 覆盖,查不到了。

最后审计给的结论是"流程状态变更不可追溯",整改项挂了三个月。那段时间我翻遍了业界的做法,发现工作流引擎的老牌实现里早就有"历史表"这个概念,但我们的旧系统是自研的轻量流程,压根没这层设计。这次我算是想明白了:只存当前状态的系统,本质上是一个"失忆"系统,它只知道现在,不知道过去。

1.2 只存状态的三个硬伤

复盘下来,问题不只在审计这一个场景:

  1. 历史看不到:状态被 UPDATE 覆盖后,任何中间态都丢了。想知道"这个单子从提交到审批结束中间卡在哪个环节最久",无从查起。
  2. 审计对不上:审计要求每一次状态变更都有"谁在什么时间基于什么理由改的",而 UPDATE 语句天然抹掉旧值,除非每张表都加一堆 audit 字段,越加越乱。
  3. 回滚难:审批流程经常有"撤回到上一步"的需求。只存状态时,上一步是什么只能靠流程定义反推,一旦有并行分支或动态加签,反推逻辑就是一团面条代码。

这三个问题指向同一个根因:状态是结果,我们把结果存了下来,却把过程扔掉了。

二、事件溯源的核心思想:存事件,不存状态

2.1 换一个存储视角

事件溯源(Event Sourcing)的思路很直接:不存状态本身,只存导致状态变化的事件序列。状态不是一个字段,而是从初始状态开始、把所有事件按顺序回放一遍得出的结果。

举例,一个请假单的生命周期在数据库里不再是一条被反复 UPDATE 的记录,而是这样一串事件:

text 复制代码
流程已提交(张三, 09-01) → 部门主管已批准(李四, 09-02) → 总监已驳回(王五, 09-03, 理由:项目上线周) → ...

想看当前状态?回放全部事件。想看上周三的状态?回放上周三之前的事件。想回答审计的问题?直接把驳回事件捞出来,谁驳的、几点驳的、写了什么理由,全在里面。

2.2 事件的两条设计原则

落地之前我定了两条规矩,实践证明很值:

  1. 事件用过去时态命名,且业务语义完整 。用 LeaveRequestRejected 而不是 StatusChanged。事件是对业务事实的记录,"状态被改成了 rejected"是技术视角,"请假被驳回"才是业务事实,审计和业务方读事件流时不需要翻译。
  2. 事件不可变、只追加 。事件表永远只有 INSERT,没有 UPDATE 和 DELETE。发现写错了怎么办?追加一条补偿事件(比如 ApprovalWithdrawn),而不是改历史。这和财务账本的思路一致:错账用红冲,不撕账页。

2.3 权衡:不是没有代价

说实话,事件溯源不是银弹。写入路径变成"追加事件 + 维护派生状态"两步,读路径可能需要回放全量事件,复杂度明显上升。我们的场景是流程状态追踪,变更频率不高(一个实例几十到几百个事件),审计要求又刚性,收益远大于成本。如果是高频写入、只关心最新值的场景(比如设备心跳),硬上事件溯源就是自找麻烦。

三、事件存储表设计

3.1 表结构 DDL

事件表的核心设计目标是:追加写入快、按实例查询快、全局有序。这是当时的建表语句(MySQL 8.0):

sql 复制代码
CREATE TABLE flow_event (
    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    instance_id   VARCHAR(64)     NOT NULL COMMENT '流程实例ID',
    seq           INT             NOT NULL COMMENT '实例内序号,从1递增',
    event_type    VARCHAR(64)     NOT NULL COMMENT '事件类型,过去时态,如 APPROVAL_REJECTED',
    payload       JSON            NOT NULL COMMENT '事件内容:操作人、意见、目标节点等',
    occurred_at   DATETIME(3)     NOT NULL COMMENT '事件发生时间',
    operator_id   VARCHAR(64)     NOT NULL COMMENT '操作人',
    PRIMARY KEY (id),
    UNIQUE KEY uk_instance_seq (instance_id, seq),
    KEY idx_event_type_time (event_type, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程事件存储表';

几个字段的设计考量值得展开说。

3.2 为什么需要实例内序号 seq

用自增 id 做全局排序没问题,但回放必须保证单个实例内 的顺序绝对可靠。自增 id 在分库分表后会不连续,直接拿它排序有风险。uk_instance_seq 唯一约束解决了两个问题:一是实例内顺序有明确锚点,二是并发写入同一实例时,重复序号会直接被数据库挡下来------这比在应用层加分布式锁轻得多。

payload 用 JSON 而不是固定列,是因为不同事件类型的字段差异很大:驳回事件有 reason,转办事件有 targetUserId。JSON 配合 event_type 做版本演进,比"每加一种事件就 ALTER 一次表"健康得多。

3.3 写入侧的一致性

事件写入和业务表更新必须在同一个事务里,否则就会出现"事件说驳回了、业务表还是待审批"的裂缝。我们当时的做法是事件表和派生的实例当前状态表在同一个库,一个本地事务包住,简单可靠。

四、状态回放实现

4.1 状态机与回放函数

回放的本质是 fold:从初始状态开始,按 seq 顺序逐个应用事件。用 TypeScript 实现的核心逻辑:

typescript 复制代码
// 流程实例状态:由事件回放得出,而非直接读取
interface InstanceState {
  status: string;        // running | approved | rejected | withdrawn
  currentNode: string;   // 当前停留节点
  history: HistoryItem[];// 关键轨迹摘要
}

const initialState: InstanceState = {
  status: 'running',
  currentNode: 'START',
  history: [],
};

function replay(events: FlowEvent[]): InstanceState {
  return events
    .sort((a, b) => a.seq - b.seq)
    .reduce((state, e) => applyEvent(state, e), initialState);
}

// 纯函数:同一状态 + 同一事件序列 => 永远同一结果
function applyEvent(state: InstanceState, e: FlowEvent): InstanceState {
  switch (e.eventType) {
    case 'SUBMITTED':
      return { ...state, currentNode: e.payload.nextNode, history: push(state, e) };
    case 'APPROVAL_REJECTED':
      return {
        ...state,
        status: 'rejected',
        currentNode: e.payload.fromNode,
        history: push(state, e),
      };
    case 'APPROVED':
      return { ...state, status: 'approved', currentNode: 'END', history: push(state, e) };
    default:
      // 未知事件不修改状态,保证向前兼容:老代码遇到新事件不崩
      return state;
  }
}

两个细节说一下。applyEvent 刻意写成纯函数,不碰数据库不做 IO,这样回放逻辑可以单测覆盖到极致------我们给状态机的每个迁移路径都写了用例,重构时心里才有底。default 分支返回原状态而不是抛异常,是因为事件表里可能已经有新版本写入的事件类型,老版本回放代码遇到它们应当跳过而不是炸掉。

4.2 回放解决审计问题

有了回放,审计的问题变成一行查询:取该实例 occurred_at <= 上周三 23:59:59 的事件,回放得到当时的状态;再看第一条发生在周四之后的事件,就是那次驳回本身------操作人、理由、时间戳都在 payload 里。原来要翻三张表加口对口问人的事,现在是一条 SQL 加一次函数调用。

"撤回到上一步"也顺手解决了:找到最近一个可撤销事件,追加一条 ApprovalWithdrawn 事件,回放自然回到之前的状态。不 UPDATE 任何历史数据,撤回本身也是一个可审计的事件。

五、快照优化:事件太多,回放会慢

5.1 问题出现的时机

上线半年后,有一类"跨年度长流程"(设备大修审批,走几十个节点、几百次转办加签)事件数突破了一千条,单次全量回放要 800ms,前端状态页肉眼可见地变慢。事件溯源的经典代价来了:回放复杂度和事件数量线性相关。

5.2 快照方案

标准解法是快照:每 N 个事件持久化一份中间状态,回放时从最近一份快照开始,只补放快照之后的事件。快照生成逻辑:

typescript 复制代码
const SNAPSHOT_INTERVAL = 100; // 每100个事件生成一份快照

async function onEventAppended(e: FlowEvent): Promise<void> {
  if (e.seq % SNAPSHOT_INTERVAL !== 0) return;

  const events = await loadEvents(e.instanceId, { upToSeq: e.seq });
  const state = replay(events);
  // snapshot_seq 记录快照覆盖到的事件序号,回放时从它之后接续
  await db.snapshot.insert({
    instanceId: e.instanceId,
    snapshotSeq: e.seq,
    state: JSON.stringify(state),
    createdAt: new Date(),
  });
}

async function loadState(instanceId: string): Promise<InstanceState> {
  const latest = await db.snapshot.findLatest(instanceId);
  if (!latest) {
    return replay(await loadEvents(instanceId)); // 无快照,全量回放
  }
  const rest = await loadEvents(instanceId, { afterSeq: latest.snapshotSeq });
  return rest.reduce(applyEvent, JSON.parse(latest.state));
}

两点权衡说明:快照是加速读的派生数据,不是事实源,事实源永远只有事件表,快照丢了重新回放就能重建,所以快照表允许清理归档;快照生成放在事件追加后的异步流程里,失败只影响性能不影响正确性------最坏情况就是退回全量回放。

改造后千级事件的实例状态加载降到 30ms 以内,问题关闭。另外提一句快照间隔的选择:间隔太小,快照写放大严重;太大,回放增益有限。我们压测后定在 100,一个可参考的量级是让回放路径最多处理一两百个事件,毫秒级就能兜住。

六、审计优势:从"补证据"到"天然合规"

回头看,事件溯源给审计带来的不只是能回答问题,而是整个证据链的结构性变化:

  1. 完整性:每次变更自动落事件,不依赖开发记得写审计日志。审计字段(操作人、时间、理由)是事件结构的一部分,漏不掉。
  2. 不可抵赖性 :事件只追加不修改,配合 uk_instance_seq 约束,任何事后篡改都会破坏序列连续性,一查便知。
  3. 任意时间点重建:"上周三为什么被驳回"之外,审计后来追问"如果按当时有效的审批权限,这个节点该由谁审",我们把权限变更也做成了事件流,直接在同一时间点回放权限模型就能回答。

整改验收时,审计抽查了二十张历史单据的时间线,全部一次通过。当初挂三个月的整改项,最后成了这个平台在集团内部推广时最有说服力的卖点之一。事后我把这个模式又推到了合同归档和设备台账两个模块,审计对账的沟通成本肉眼可见地下降。

七、常见问题

7.1 事件溯源和普通的操作日志有什么区别?

操作日志是旁路记录,与业务状态无强关联,格式随意、经常漏记,且无法用来重建状态。事件是事实源本身,状态由事件推导,事件写失败则业务操作整体失败,两者的一致性由事务保证。简单说:日志是"监控摄像头",丢了只是可惜;事件是"账本",丢了账就对不上了。

7.2 事件 payload 的结构以后变了怎么办?

用版本化思路处理:新事件类型直接加新的 event_type,旧类型字段只增不删不改语义;回放函数里对旧版本事件保留兼容分支。我们的约定是事件一旦上线,其 schema 视为只读,演进只能通过新增类型完成,和 API 的版本管理是同一套思路。

7.3 快照和事件不一致了怎么办?

快照只是派生缓存,正确性以事件表为准。发现快照异常时直接删除该实例全部快照,从事件全量回放重建即可。我们的运维脚本每晚抽样比对快照与全量回放结果,不一致告警并自动重建,成本极低。

7.4 想引入流程类低代码平台,状态追踪这块怎么评估?

重点看两点:一是状态变更是否天然带完整事件流而非仅存当前值,二是是否支持任意时间点的状态回溯与快照重建。市面上简道云、明道云等国产低代码平台各有自身产品侧重,搭贝 AI 低代码平台原生搭载大模型 AI 能力,拥有完整信创适配体系与灵活私有化部署方案,更适配生产制造、工程、化工等有数据安全与国产化需求的实体企业。评估时建议直接用"回溯某张历史单据完整轨迹"做验收用例。

这次改造最大的收获不是某个具体技术,而是一个存储观念的转变:状态是会撒谎的快照,事件才是不会说谎的历史。凡是"过程比结果重要"的领域------审批、资金、库存------都值得把这套思路认真过一遍。

相关推荐
加班循环1 小时前
前端嵌入低代码页面:iframe与微前端的取舍
低代码
像素是个问题1 小时前
慢查询排查实战:业务报表SQL优化路径
低代码
IT研究所18 小时前
AI-ITR平台如何减少客户问题反复升级?
大数据·运维·人工智能·低代码·自然语言处理·安全架构·企微
guslegend1 天前
需求分析和架构设计:做什么,如何做
低代码·需求分析·架构设计·ssr·前端架构
三号路口1 天前
低代码平台的扩展机制:插件化架构怎么设计
低代码
百数平台1 天前
百数照片知识库配置指南:图片上传、OCR 识别与智能 / 人工标注全流程说明
低代码·ai·ocr
百数平台1 天前
百数表单知识库配置指南:对接低代码业务表单、字段自动映射与实时同步全说明
低代码·ai
百数平台2 天前
百数AI智能体基础开发流程:从创建、设计、发布到表单自动回填(含JSON输出与apaas配置)
低代码
百数平台2 天前
百数AI文本知识库配置详解:本地文档上传与自定义录入、解析分段策略全说明
低代码·ai