事件是过去时
命名先暴露建模水平。BookBorrow 是命令,BookBorrowed 是事件。前者说「请借书」,接收方可以拒绝;后者说「书已经借出」,接收方只能接受或补偿。把命令当事件发出去,消费方会不自觉地回写「是否允许」,事件就退化成了 RPC。
libraryd 的六个事件统一用过去时:
| 事件 | 主题 | key | 语义 |
|---|---|---|---|
BookBorrowed | library.loan.created | bookId | 借阅已经创建 |
BookReturned | library.loan.returned | bookId | 书已经归还 |
ReservationCreated | library.hold.placed | bookId | 预约已经登记 |
InventoryAdjusted | library.inventory.adjusted | bookId | 库存已经调整 |
BookRenewed | library.loan.renewed | bookId | 借阅已经续期 |
FineCharged | library.fine.charged | memberId | 罚款已经记账 |
前四个主题沿用前文已经出现的名字,后两个在本章首次定义。主题名一旦发布就不要再改:消费者组、ACL、监控、下游订阅都挂在它上面,改名等于换了一条链路。
key 的选择不是随手填的。借阅、归还、续期、库存都按 bookId 分区,保证同一本书的事件在分区内有序;罚款按 memberId 分区,因为读者关心的是「我这个月累计被扣了多少」,同一会员的罚款必须有序才能折叠出余额。
事件体里放什么
一个领域事件只需要三类信息:
- 身份:这条事件是谁,因谁而起。
- 时间:业务事实在什么时候发生。
- 事实本身:发生了什么,涉及哪些聚合根,结果是什么。
按这个标准,BookBorrowed 有八个字段就够,不需要书名、作者、会员姓名、当前库存。后面这些都属于「查询时才知道」或「会变化」的信息。
不放实体快照
事件里塞整个 Book 或 Member 有两个问题。第一,快照会过期:三年前的事件里存着当时的书名,今天书名改了,重放会造出一个不存在的旧世界。第二,快照会放大消息体积,一本书的元数据几十个字段,乘以每天几十万次借阅,成本落在存储和网络上。
正确做法是只放 ID,需要展示时回查。回查拿到的是「现在」的值,这通常正是 UI 想要的;审计要的是「当时」的值,那就应该由事件本身携带当时的关键字段,而不是携带整个实体。
不放派生状态
InventoryAdjusted 只放 delta,不放 stockAfter。这不是洁癖,而是重放正确性的要求。下面的实验会看到:一旦事件携带 stockAfter,一条重试产生的重复事件就能把库存带回旧值。
同理,FineCharged 不放「会员当前欠款总额」,BookBorrowed 不放「这本书当前可借数量」。这些都能由事件流折叠出来,放进去反而制造了第二个事实来源。
不放展示字段和 PII
书名、封面 URL、会员手机号都不进事件。展示字段会变,手机号属于个人信息,扩散到每个下游主题后很难回收。下游要发通知,用 memberId 回查通知服务,让通知服务自己决定用哪个渠道、哪个模板。
不把 null 当业务含义
causationId 为 null 表示「外部触发,没有上游事件」,这是唯一允许的 null 语义。除此之外,字段缺失应该用显式的枚举或布尔字段表达,不要用 null 兼表「未知」「不适用」「已删除」三种意思。消费端很难区分这三种情况。
事件 ID 与因果 ID
eventId:幂等键,不是数据库主键
eventId 在生产者发送前生成,重试和重放时保持不变。它是消费端去重的第一选择:去重表以 eventId 为唯一键,重复投递直接丢弃。
三个常见错误:
- 用 Kafka 位移当事件 ID。分区扩容、主题重建、跨集群复制都会让位移变化,历史幂等记录全部失效。
- 用业务主键当事件 ID。一次借阅可能产生「创建」「续期」「逾期」多条事件,业务主键相同但事件不同,会被误判为重复。
- 消费端收到消息才生成 ID。这样每次投递都是新 ID,去重永远不命中。
JDK 25 的 UUID.randomUUID() 生成 v4 UUID,随机分布,做主键时索引局部性差。UUIDv7 按时间有序,但 JDK 标准库没有内置生成器;本书不引入第三方库,所以用 v4,并在需要有序 ID 的场景用「时间戳前缀 + 随机后缀」的字符串。这是取舍,不是最优解。
causationId:把事件串成因果链
causationId 指向「直接导致这条事件的那条事件」。预约履约产生借阅时,BookBorrowed.causationId 就是 ReservationCreated.eventId;归还产生罚款时,FineCharged.causationId 就是 BookReturned.eventId。
它解决两个问题:
- 排障:一条罚款记错了,可以沿
causationId反查到是哪次归还触发的,而不是在几个主题里按时间猜。 - 重放边界:按因果链重放一个业务过程,比按时间窗口截取更准确。
外部触发的第一条事件(比如馆员在后台手工建借阅)没有上游,causationId 为 null。这个 null 是有意义的,不要用 eventId 自己指向自己来「补空」。
correlationId 是另一回事,它标识一次端到端请求,通常放在消息头里,由接入层生成。把 correlationId 当 causationId 用,会让因果链变成一条横跨多个业务过程的直线,失去「直接导致」的含义。
六个事件的 record 定义
package com.plume.mq.core.events;
import java.time.Instant;
import java.util.UUID;
public final class Events {
private Events() {}
public enum ReturnCondition { GOOD, DAMAGED, LOST }
public enum AdjustmentReason { BORROW, RETURN, DAMAGE, STOCKTAKE, CORRECTION }
public enum FineReason { OVERDUE, DAMAGE, LOST }
public record BookBorrowed(
UUID eventId,
UUID causationId,
Instant occurredAt,
String loanId,
String bookId,
String memberId,
Instant borrowedAt,
Instant dueAt) {}
public record BookReturned(
UUID eventId,
UUID causationId,
Instant occurredAt,
String loanId,
String bookId,
String memberId,
Instant returnedAt,
ReturnCondition condition) {}
public record ReservationCreated(
UUID eventId,
UUID causationId,
Instant occurredAt,
String reservationId,
String bookId,
String memberId,
Instant requestedAt,
Instant expiresAt) {}
public record InventoryAdjusted(
UUID eventId,
UUID causationId,
Instant occurredAt,
String bookId,
int delta,
AdjustmentReason reason,
String referenceId,
String actorId) {}
public record BookRenewed(
UUID eventId,
UUID causationId,
Instant occurredAt,
String loanId,
String bookId,
String memberId,
Instant previousDueAt,
Instant newDueAt,
int renewalCount) {}
public record FineCharged(
UUID eventId,
UUID causationId,
Instant occurredAt,
String fineId,
String loanId,
String memberId,
long amountCents,
FineReason reason,
Instant paymentDueAt) {}
}
逐字段说明
BookBorrowed
| 字段 | 为什么存在 | 缺了会怎样 |
|---|---|---|
eventId | 消费端幂等键,重试不变 | 无法判重,库存可能扣两次 |
causationId | 指向触发它的预约履约事件 | 因果链断在借阅处,排障只能看时间 |
occurredAt | 事件被记录的时间 | 无法区分补录与实时发生 |
loanId | 借阅聚合根,归还/续期/罚款都引用它 | 同一借阅的后续事件串不起来 |
bookId | 库存聚合键,同时是分区键 | 同一本书的事件可能乱序 |
memberId | 配额、权限、通知对象 | 下游必须回查,且回查的是「现在」的会员状态 |
borrowedAt | 借阅条款的生效时间 | 与 occurredAt 混淆,期限算错 |
dueAt | 到期时间,逾期判定的基准 | 只能按当时策略反推,策略一改历史全变 |
occurredAt 和 borrowedAt 看着重复,其实不同。前者是「这条事件什么时候被系统接受」,后者是「借阅从什么时候开始生效」。离线补录时,两者可能差几天:occurredAt 是导入时间,borrowedAt 是真实的借书时间。逾期计算必须用 borrowedAt/dueAt,不能用 occurredAt。
BookReturned
| 字段 | 为什么存在 | 缺了会怎样 |
|---|---|---|
eventId | 归还的幂等键,重复归还会虚增库存 | 同一本书库存加两次 |
causationId | 通常为 null,或指向续期/催还事件 | 无法判断这次归还是否由系统催还触发 |
occurredAt | 归还事实的记录时间 | 与 returnedAt 混用导致统计偏差 |
loanId | 关闭借阅,校验「还的是哪一笔」 | 无法判断是否逾期、是否重复归还 |
bookId | 库存聚合键与分区键 | 库存无法回补 |
memberId | 更新会员借阅额度 | 额度只能靠借阅表反查 |
returnedAt | 实际归还时间,逾期天数按它算 | 逾期罚款算错 |
condition | 完好/损坏/丢失,决定是否罚款和库存状态 | 损坏只能事后人工补一条调整,来源丢失 |
condition 用枚举而不是字符串或布尔。字符串会出现 "good"、"GOOD"、"正常" 三种写法;布尔只能表达「是否损坏」,丢失这个状态没地方放。
ReservationCreated
| 字段 | 为什么存在 | 缺了会怎样 |
|---|---|---|
eventId | 预约的幂等键 | 重复预约占多个队列位 |
causationId | 通常为 null;由借阅失败触发时指向上游 | 无法解释这条预约从哪来 |
occurredAt | 预约登记时间 | 排队顺序只能靠位移,重建后丢失 |
reservationId | 预约聚合根 | 后续履约/取消无法定位 |
bookId | 分区键,保证同一本书的预约有序 | 队列顺序错乱 |
memberId | 谁在排队 | 无法通知,也无法校验重复预约 |
requestedAt | 读者发起预约的时间 | 与 occurredAt 混用,补录场景排序错 |
expiresAt | 预约失效时间,过期任务按它扫 | 过期只能靠定时全表扫,且策略改动影响历史 |
预约的「当前排第几位」是派生状态,不放。它随前面的人取消而变,放进去就要不断发更新事件,事件流会变成状态同步流。
InventoryAdjusted
| 字段 | 为什么存在 | 缺了会怎样 |
|---|---|---|
eventId | 调整的幂等键 | 重复调整直接造成资损 |
causationId | 指向触发调整的借阅/归还/盘点事件 | 库存对不上时无法定位来源 |
occurredAt | 调整时间 | 无法按时间窗口对账 |
bookId | 库存聚合键与分区键 | 同一本书的调整无序 |
delta | 有符号增量,正数入库、负数出库 | 只能存绝对值,无法表达方向 |
reason | 借出/归还/损坏/盘点/修正 | 对账时无法分类,异常调整混在正常流水里 |
referenceId | 关联的业务单据号(loanId、盘点单号) | 只能靠时间猜这笔调整对应哪次操作 |
actorId | 操作人;系统自动调整时为 null | 人工修正无法追责 |
delta 用 int 而不是 long:单本书的库存变化不会超过 21 亿,用 int 省空间也更贴近业务。金额则相反,FineCharged.amountCents 用 long,因为累计罚款和分单位计算需要更大范围。
BookRenewed
| 字段 | 为什么存在 | 缺了会怎样 |
|---|---|---|
eventId | 续期的幂等键 | 重复续期把到期日推后两次 |
causationId | 通常为 null;自动续期时指向触发规则的事件 | 无法区分读者手动续期与系统自动续期 |
occurredAt | 续期操作时间 | 无法审计续期时点 |
loanId | 被续期的借阅 | 无法定位原借阅 |
bookId | 分区键,保证同一本书的借阅事件有序 | 续期与归还可能乱序 |
memberId | 校验会员续期次数与资格 | 只能回查,回查结果可能已变 |
previousDueAt | 续期前的到期日,用于并发冲突检测 | 两个并发续期都成功,到期日被推后两次 |
newDueAt | 续期后的到期日 | 下游无法更新提醒计划 |
renewalCount | 第几次续期,执行「最多续两次」策略 | 次数只能数事件,重放成本高且易错 |
previousDueAt 是乐观锁的等价物。消费端更新借阅时带条件 WHERE due_at = :previousDueAt,更新不到就说明有人先改了,这条续期应拒绝或重试。没有这个字段,两个并发请求都能把 dueAt 往后推。
FineCharged
| 字段 | 为什么存在 | 缺了会怎样 |
|---|---|---|
eventId | 罚款的幂等键 | 重复扣款,直接资损 |
causationId | 指向逾期的归还事件或损坏记录 | 罚款来源无法追溯 |
occurredAt | 记账时间 | 无法按账期汇总 |
fineId | 罚款聚合根,退款/减免引用它 | 后续冲正无法定位 |
loanId | 关联借阅,校验罚款依据 | 无法证明这笔罚款对应哪次借阅 |
memberId | 分区键,同一会员的罚款有序 | 会员欠款余额折叠可能乱序 |
amountCents | 金额,单位分,long | 用 double 会有精度损失 |
reason | 逾期/损坏/丢失 | 无法分类统计,也无法按类型做减免策略 |
paymentDueAt | 罚款缴纳截止时间 | 滞纳金规则没有基准 |
金额一律用整数分。double 的 0.1 + 0.2 不等于 0.3,财务对账时会出现一分钱的差额;用 BigDecimal 可以避免精度问题,但 JSON 序列化后仍然是字符串或数字,跨语言传递容易变形。整数分是跨语言最稳的表达。
schema 版本不放进事件体
六个 record 都没有 schemaVersion 字段。版本号放在消息头 x-schema-version,理由有三条:
- 版本是传输契约,不是业务事实。业务方读事件时不应该关心它是第几版。
- 同一份 payload 可能被不同版本的信封包装,版本放在信封上更自然。
- 消费端可以先读头决定用哪个 schema 解析,不必先解析 payload。
风险也要说清楚:消息头不是所有桥接都保留。把 Kafka 数据导出到文件、数仓或另一个消息系统时,header 经常被丢掉。所以导出格式必须把版本写进导出信封,或者退化成「每个主题一个固定版本」的落地表。这是 8.3 节自管 schema 要处理的问题。
本机实测:编译与不变量检查
环境:Temurin 25.0.5+8,javac --release 25 -Xlint:all,无第三方依赖。
javac --release 25 -Xlint:all -d out $(find src -name '*.java')
java -cp out com.plume.mq.core.events.EventCheck
输出(原样):
== 六个事件的 record 组件 ==
BookBorrowed 8 个字段 eventId:UUID, causationId:UUID, occurredAt:Instant, loanId:String, bookId:String, memberId:String, borrowedAt:Instant, dueAt:Instant
BookReturned 8 个字段 eventId:UUID, causationId:UUID, occurredAt:Instant, loanId:String, bookId:String, memberId:String, returnedAt:Instant, condition:ReturnCondition
ReservationCreated 8 个字段 eventId:UUID, causationId:UUID, occurredAt:Instant, reservationId:String, bookId:String, memberId:String, requestedAt:Instant, expiresAt:Instant
InventoryAdjusted 8 个字段 eventId:UUID, causationId:UUID, occurredAt:Instant, bookId:String, delta:int, reason:AdjustmentReason, referenceId:String, actorId:String
BookRenewed 9 个字段 eventId:UUID, causationId:UUID, occurredAt:Instant, loanId:String, bookId:String, memberId:String, previousDueAt:Instant, newDueAt:Instant, renewalCount:int
FineCharged 9 个字段 eventId:UUID, causationId:UUID, occurredAt:Instant, fineId:String, loanId:String, memberId:String, amountCents:long, reason:FineReason, paymentDueAt:Instant
== 不变量检查 ==
事件数=6 唯一 eventId=6 违规=0
检查覆盖:eventId 非空且互不相同、causationId 不等于自身、occurredAt 不在未来、dueAt > borrowedAt、delta != 0、newDueAt > previousDueAt、amountCents > 0、expiresAt > requestedAt。六条事件全部通过。
这不是「跑通编译」的仪式。字段一多,最容易出的错是语义重复和可空性混乱;把不变量写成可执行检查,改 record 时能立刻发现哪条约束被破坏。
反例实测:快照字段如何被重复事件打坏
假设 InventoryAdjusted 偷懒,除了 delta 还带一个 stockAfter。初始库存 5,两次借出后是 3,其中第一条借出因为生产者重试被投递了两次。按两种方式折叠:
== 状态快照 vs 增量:重复事件下的折叠结果 ==
初始库存=5 两次借出 + 一条重试重复事件
按事件内快照折叠 = 4(错误:重复事件把库存带回旧值)
按 delta + eventId 去重折叠 = 3(正确)
按快照折叠时,重复的那条事件带着旧的 stockAfter=4 最后到达,把库存从 3 覆盖回 4;按 delta 折叠并用 eventId 去重,重复事件被丢弃,结果正确。
这个实验是本书构造的合成场景,不是线上事故。它说明的是:事件里的派生状态会把「幂等」从「丢弃重复」变成「必须保证顺序且不能重放」,而这两条在分布式系统里都很难长期保证。
常见建模错误
- 事件名用名词或命令:
BookBorrow、CreateLoan都是命令。事件必须是过去时,接收方无权拒绝。 - 事件携带全量实体:快照会过期、会膨胀、会制造第二个事实来源。只带 ID 和当时必须固定的关键字段。
- 用 null 表达多种业务含义:未知、不适用、已删除要分开表达;唯一允许的 null 是「没有上游因果」。
- 金额用 double:跨语言序列化和累加都会出问题,统一整数分。
- 时间用字符串或本地时间:
Instant+ UTC,展示层再转时区;本地时间在夏令时和跨时区场景会重复或跳过。 - 拿位移当事件 ID:重建、扩分区、跨集群复制都会让位移变化,幂等记录失效。
本节判据
- 事件和命令的区别是什么? 答:事件是已经发生的事实,过去时,接收方只能接受或补偿;命令是请求,接收方可以拒绝。
- 为什么事件里不放
stockAfter? 答:它是派生状态,重复投递时旧快照会覆盖新状态;只放delta,配合eventId去重才能正确重放。 eventId为什么不能用 Kafka 位移? 答:分区扩容、主题重建、跨集群复制都会改变位移,历史幂等记录会失效。causationId和correlationId有什么区别? 答:前者指向直接导致本条事件的上游事件,后者标识一次端到端请求;前者是因果链,后者是调用链。previousDueAt为什么必须进续期事件? 答:它是并发控制的比较条件,没有它两个并发续期都会成功,到期日被推后两次。
阅读导航:上一节 7.3 事件溯源入门 | 下一节 8.2 schema 演进与兼容性
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。