《Java 消息中间件·可靠消息与消费治理》8.1 领域事件建模

事件是已经发生的事实,不是请求也不是状态快照。本篇先划清事件体该放什么、不该放什么,再给出 libraryd 六个事件的 record 定义,逐字段说明它为什么存在、缺了会怎样;最后用一次本机实测说明事件 ID 与增量字段如何决定重放是否可重复。

事件是过去时

命名先暴露建模水平。BookBorrow 是命令,BookBorrowed 是事件。前者说「请借书」,接收方可以拒绝;后者说「书已经借出」,接收方只能接受或补偿。把命令当事件发出去,消费方会不自觉地回写「是否允许」,事件就退化成了 RPC。

libraryd 的六个事件统一用过去时:

事件主题key语义
BookBorrowedlibrary.loan.createdbookId借阅已经创建
BookReturnedlibrary.loan.returnedbookId书已经归还
ReservationCreatedlibrary.hold.placedbookId预约已经登记
InventoryAdjustedlibrary.inventory.adjustedbookId库存已经调整
BookRenewedlibrary.loan.renewedbookId借阅已经续期
FineChargedlibrary.fine.chargedmemberId罚款已经记账

前四个主题沿用前文已经出现的名字,后两个在本章首次定义。主题名一旦发布就不要再改:消费者组、ACL、监控、下游订阅都挂在它上面,改名等于换了一条链路。

key 的选择不是随手填的。借阅、归还、续期、库存都按 bookId 分区,保证同一本书的事件在分区内有序;罚款按 memberId 分区,因为读者关心的是「我这个月累计被扣了多少」,同一会员的罚款必须有序才能折叠出余额。

事件体里放什么

一个领域事件只需要三类信息:

  1. 身份:这条事件是谁,因谁而起。
  2. 时间:业务事实在什么时候发生。
  3. 事实本身:发生了什么,涉及哪些聚合根,结果是什么。

按这个标准,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,理由有三条:

  1. 版本是传输契约,不是业务事实。业务方读事件时不应该关心它是第几版。
  2. 同一份 payload 可能被不同版本的信封包装,版本放在信封上更自然。
  3. 消费端可以先读头决定用哪个 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:重建、扩分区、跨集群复制都会让位移变化,幂等记录失效。

本节判据

  1. 事件和命令的区别是什么? 答:事件是已经发生的事实,过去时,接收方只能接受或补偿;命令是请求,接收方可以拒绝。
  2. 为什么事件里不放 stockAfter? 答:它是派生状态,重复投递时旧快照会覆盖新状态;只放 delta,配合 eventId 去重才能正确重放。
  3. eventId 为什么不能用 Kafka 位移? 答:分区扩容、主题重建、跨集群复制都会改变位移,历史幂等记录会失效。
  4. causationId 和 correlationId 有什么区别? 答:前者指向直接导致本条事件的上游事件,后者标识一次端到端请求;前者是因果链,后者是调用链。
  5. previousDueAt 为什么必须进续期事件? 答:它是并发控制的比较条件,没有它两个并发续期都会成功,到期日被推后两次。

阅读导航:上一节 7.3 事件溯源入门 | 下一节 8.2 schema 演进与兼容性

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收