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(写入时间)分离。
六、案例复盘:一次转账事件的设计演进
某银行最初设计 TransferDone,payload 仅含金额与双方账号。上线后发现三个问题:无法区分币种、无法追溯操作渠道、新增手续费字段时旧消费方解析失败。改进后:
- 重命名为
payment.TransferCompleted,明确上下文与完成语义。 - 金额改为对象结构,强制携带 currency。
- channel、operator 移入 metadata。
- 新增 fee 字段时保持可选,schemaVersion 从 1 升到 2,旧事件由 Upcaster 补默认值。
复盘结论:事件设计的成本在前期极低,在后期极高。命名、结构、版本、不可变性与元数据这五条原则,本质是在为未来的回放、审计与跨团队协作预留空间。