4. 事件设计原则

在银行交易事件溯源系统中,事件不是普通的日志,而是唯一可信的事实来源。账户余额、交易流水、风控状态,全部由事件回放推导而来。因此,事件设计一旦失当,后续的聚合重建、审计追溯、跨系统对账都会付出高昂代价。本节围绕五条核心原则展开:命名、结构、版本、不可变性与元数据。

核心判断标准:一个事件设计得好不好,只需问一句——三年后另一个团队能否仅凭这个事件,准确还原当时发生了什么,且不产生歧义?

一、事件命名:用业务事实说话

事件命名应遵循“过去时 + 业务名词 + 限定语”的公式,表达已经发生的事实,而不是命令或意图。命令是“请扣款”,事件是“款项已扣除”。

  • 推荐:AccountDebited、TransferCompleted、CardFrozen、LoanRepaymentReceived
  • 避免:DebitAccount(命令)、UpdateBalance(技术动作)、DataChanged(语义空洞)
  • 命名空间建议带限界上下文前缀,如 payment.TransferCompleted、card.CardFrozen,避免跨域重名。

命名还要避免“状态型”词汇。事件描述的是状态迁移的动作,而非状态本身。例如用 AccountOpened 而不是 AccountActive,因为后者无法说明是开户、解冻还是激活。

二、事件结构:自包含与最小充分

事件结构应满足“自包含”:消费方无需回查数据库即可理解事件含义。同时遵循“最小充分”原则,只携带必要字段,避免把整个账户实体塞进事件。

{
  "eventId": "evt-9f2c8a1b",
  "eventType": "payment.TransferCompleted",
  "aggregateId": "acct-6222-001",
  "version": 17,
  "occurredAt": "2025-03-11T09:24:31.482+08:00",
  "payload": {
    "fromAccount": "6222****001",
    "toAccount": "6217****889",
    "amount": { "currency": "CNY", "value": 12500.00 },
    "channel": "MOBILE",
    "referenceNo": "TR20250311092431"
  },
  "metadata": {
    "traceId": "trace-7d1e",
    "operator": "customer:U88321",
    "schemaVersion": 2
  }
}

结构上建议区分三层:信封层(eventId、eventType、occurredAt)、聚合层(aggregateId、version)、载荷层(payload)。金额等关键数值必须携带币种,避免多币种场景下的隐式假设。

三、事件版本:兼容演进而非原地修改

事件一旦写入事件存储,就永远不能被改写。业务演进只能通过新增版本实现。常见策略有三种:

策略做法适用场景
弱模式兼容新增可选字段,旧消费方忽略字段追加、非破坏性变更
显式版本号eventType 带 v2,或 metadata.schemaVersion 递增字段语义变化、结构重组
事件升级器读取时用 Upcaster 将 v1 转为 v2需要统一消费视图
版本升级优先选择“向后兼容的追加”,把破坏性变更留给显式版本号。不要试图删除或重命名字段,那会让历史事件无法回放。

四、事件不可变性:只追加,不修改

不可变性是事件溯源的基石。事件存储只允许 append,禁止 update 与 delete。若发现错误,应追加一个补偿事件,例如 TransferReversed,而不是修改原事件。

-- 正确:追加补偿事件
INSERT INTO event_store (aggregate_id, version, event_type, payload)
VALUES ('acct-6222-001', 18, 'payment.TransferReversed',
        '{"originalEventId":"evt-9f2c8a1b","reason":"CUSTOMER_DISPUTE"}');

-- 禁止:原地修改历史事件
-- UPDATE event_store SET payload = ... WHERE event_id = 'evt-9f2c8a1b';

不可变性带来的收益是审计可信、回放可重复、并发可控(基于 version 的乐观锁)。代价是存储增长,需要通过快照与归档策略平衡。

五、事件元数据:让事实可追溯

元数据描述“事件发生的上下文”,而非业务事实本身。银行场景中,元数据至少应覆盖:

  • 追踪:traceId、spanId,用于跨服务链路定位。
  • 主体:operator、channel、deviceId,区分柜面、手机银行、批量任务。
  • 合规:schemaVersion、sourceSystem、regulatoryTag。
  • 时间:occurredAt(业务发生时间)与 recordedAt(写入时间)分离。
常见误区:把业务字段塞进元数据,或把 traceId 写进 payload。前者导致消费方难以区分事实与上下文,后者让业务载荷被技术信息污染,回放时产生噪声。

六、案例复盘:一次转账事件的设计演进

某银行最初设计 TransferDone,payload 仅含金额与双方账号。上线后发现三个问题:无法区分币种、无法追溯操作渠道、新增手续费字段时旧消费方解析失败。改进后:

  1. 重命名为 payment.TransferCompleted,明确上下文与完成语义。
  2. 金额改为对象结构,强制携带 currency。
  3. channel、operator 移入 metadata。
  4. 新增 fee 字段时保持可选,schemaVersion 从 1 升到 2,旧事件由 Upcaster 补默认值。

复盘结论:事件设计的成本在前期极低,在后期极高。命名、结构、版本、不可变性与元数据这五条原则,本质是在为未来的回放、审计与跨团队协作预留空间。

事件设计五原则与结构分层 命名 过去时·业务事实 结构 自包含·最小充分 版本 兼容演进·Upcaster 不可变性 只追加·补偿事件 元数据 追踪·合规·主体 事件结构三层 信封层 eventId · eventType · occurredAt · recordedAt 聚合层 aggregateId · version(乐观锁) 载荷层 payload(业务事实) + metadata(上下文)