一个动作,三种说法
读者在 libraryd 里借走一本书。这个动作可以被三种方式说出来:
- 「借阅已创建,借的是 B-1,读者是 M-1。」这是事件:一件已经发生的事。
- 「请为 M-1 办理 B-1 的借阅。」这是命令:一个希望被执行的请求。
- 「L-1 这笔借阅现在是什么状态?」这是查询:一次不改变世界的提问。
日常说话时三者可以混着来,写成代码就不行。因为它们的接收者数量、失败语义、能否重放、命名时态都不一样。把命令写成事件,接收方会把它当成既成事实,失去拒绝的机会;把事件写成命令,点对点投递只会送给一个消费者,其余消费者永远收不到。
这一节先把三者的语义差异讲清楚,再用三个 Java record 和一组可运行的进程内总线,把「多播」和「单接收」变成能看见的输出。实验只用 JDK 25 标准库,不连 Kafka——这一节要隔离的是语义,不是传输。
三个 record 的字段为什么不一样
先看 libraryd 的三个类型定义。它们都是 record,因为这三类消息都应该是不可变的值对象:
public record LoanCreated(
String loanId,
String bookId,
String memberId,
Instant borrowedAt,
Instant dueAt,
int schemaVersion) {}
public record ReturnBook(
String commandId,
String loanId,
String memberId,
Instant returnedAt,
long expectedVersion) {}
public record FindLoan(
String loanId,
boolean includeHistory) {}
public record BadEvent(List<String> tags) {}
字段的差异不是随手定的,每多一个字段背后都有一个语义理由。
LoanCreated 是事件。 它要回答「发生了什么」,所以带齐了事实的坐标:哪笔借阅(loanId)、哪本书(bookId)、哪位读者(memberId)、什么时候借的(borrowedAt)、什么时候到期(dueAt)。schemaVersion 不是业务字段,是契约字段——消费者需要知道怎么解释这份载荷。事件里没有「请谁处理」的字段,因为事件没有收件人。
为了聚焦三类消息的差异,这个示例省掉了 eventId 和独立的 occurredAt,用 borrowedAt 兼作发生时间。真实的事件契约会补上这两个字段,2.3 节的事件目录会给出完整版本。
ReturnBook 是命令。 它要回答「请做什么」,所以有明确的目标(loanId)和发起人(memberId)。commandId 是幂等键:命令可能因为超时被重发,接收方靠它去重。expectedVersion 是乐观并发的条件——只有当这笔借阅当前版本等于 3 时才允许归还,否则拒绝。命令可以带条件,因为命令可以被拒绝。
FindLoan 是查询。 它只带查询参数,没有发起人,没有版本条件,也不产生任何状态变化。includeHistory 是投影参数,决定返回的视图宽度。查询天然是同步的:调用方要的是返回值,不是「已受理」。
语义差异不是命名差异
把三类消息放在一起对照,差异集中在七个维度:
| 维度 | 事件 | 命令 | 查询 |
|---|---|---|---|
| 时态 | 过去时:已发生 | 祈使:请执行 | 疑问:是什么 |
| 语气 | 陈述事实 | 提出请求 | 请求读取 |
| 接收者 | 零到多个,写代码时未知 | 恰好一个逻辑接收者 | 一个调用方 |
| 能否拒绝 | 不能,事实已经成立 | 能,校验失败就拒绝 | 能,找不到就返回空 |
| 重放 | 可以,消费者需幂等 | 不可以,除非有幂等键 | 无副作用,可重复 |
| 命名 | LoanCreated | ReturnBook | FindLoan |
| 跨进程形态 | 主题、多消费组 | 点对点队列或单消费组 | 同步 API,通常不进消息系统 |
「命令应单接收」这句话需要拆开。它指的是一个逻辑所有者,不是「一个进程」。一个命令主题可以有 6 个消费者实例,只要它们属于同一个消费组,分区分配保证每条命令只被其中一个实例执行一次。反过来说,如果两个不同的消费组都订阅了命令主题,命令就会被执行两次——这不是 Kafka 的错,是把命令当成了事件。
「事件不能被拒绝」也需要拆开。消费者当然可以处理失败、重试、进死信,但这些都是消费者自己的事。事件的生产者拿不到「业务上不批准」的回复,因为事件描述的是已经提交的状态变化。如果业务需要「可能被拒绝」,那它一开始就是命令,不是事件。
跑一遍:record 给了什么、没给什么
先验证 record 本身。下面这个方法用反射打印一个类型的组件,并检查字段的 final 修饰(片段,省略 import):
static void describe(Class<?> type) {
System.out.println("== " + type.getSimpleName() + " ==");
for (RecordComponent rc : type.getRecordComponents()) {
System.out.printf(" component %-14s %s%n", rc.getName(), rc.getType().getSimpleName());
}
for (Field f : type.getDeclaredFields()) {
System.out.printf(" field %-14s final=%s%n", f.getName(),
Modifier.isFinal(f.getModifiers()));
}
}
本机实测(Temurin 25.0.5+8 / macOS / 2026-10-29),javac --release 25 InteractionDemo.java && java InteractionDemo 的输出:
== LoanCreated ==
component loanId String
component bookId String
component memberId String
component borrowedAt Instant
component dueAt Instant
component schemaVersion int
field loanId final=true
field bookId final=true
field memberId final=true
field borrowedAt final=true
field dueAt final=true
field schemaVersion final=true
== ReturnBook ==
component commandId String
component loanId String
component memberId String
component returnedAt Instant
component expectedVersion long
field commandId final=true
field loanId final=true
field memberId final=true
field returnedAt final=true
field expectedVersion final=true
== FindLoan ==
component loanId String
component includeHistory boolean
field loanId final=true
field includeHistory final=true
这组输出说明三件事。
第一,record 的组件是声明式的:字段名、类型、访问器都由组件列表生成,序列化框架和契约校验可以直接读这份元数据。
第二,字段确实是 final,但这只保证引用不可变,不保证内容不可变。同一个程序里构造了一个 BadEvent(List<String> tags),构造后修改原始列表,记录里的内容跟着变了:
shallow before=[new]
shallow after =[new, mutated] sameRef=true
事件载荷里如果放 List、Map 或数组,必须在构造时做防御性拷贝,否则「不可变事件」只是表面文章。这是事件契约里最常见的一个坑,比字段命名错误更难发现。
第三,record 自动生成的 equals 是结构相等。两笔内容完全相同的 LoanCreated 会判等:
structural equals=true hash=true
toString=LoanCreated[loanId=L-1, bookId=B-1, memberId=M-1, borrowedAt=2026-10-29T00:00:00Z, dueAt=2026-11-12T00:00:00Z, schemaVersion=1]
结构相等不能替代事件去重。两次真实发生的借阅可能字段完全相同,但它们是两个事件;反过来,同一条事件被重投两次,字段也完全相同。去重必须靠 eventId 或业务幂等键,不能靠 equals。
多播与单接收:进程内的最小验证
语义差异落到代码上,最直接的表现是「注册几个处理器」。下面的 EventBus 允许同一类型注册任意多个处理器,CommandBus 在第二次注册时直接抛异常:
static final class EventBus {
private final Map<Class<?>, List<Consumer<Object>>> handlers = new HashMap<>();
<E> void subscribe(Class<E> type, Consumer<E> handler) {
handlers.computeIfAbsent(type, k -> new ArrayList<>())
.add(o -> handler.accept(type.cast(o)));
}
void publish(Object event) {
List<Consumer<Object>> list = handlers.getOrDefault(event.getClass(), List.of());
System.out.println(" publish " + event.getClass().getSimpleName()
+ " -> " + list.size() + " handler(s)");
for (Consumer<Object> h : list) {
h.accept(event);
}
}
}
static final class CommandBus {
private final Map<Class<?>, Consumer<Object>> handlers = new HashMap<>();
<C> void register(Class<C> type, Consumer<C> handler) {
Consumer<Object> existing = handlers.putIfAbsent(type, o -> handler.accept(type.cast(o)));
if (existing != null) {
throw new IllegalStateException("duplicate command handler: " + type.getSimpleName());
}
}
void send(Object command) {
Consumer<Object> h = handlers.get(command.getClass());
if (h == null) {
throw new IllegalStateException("no handler for " + command.getClass().getSimpleName());
}
h.accept(command);
}
}
三个消费者订阅 LoanCreated,发布一次,三个都收到;ReturnBook 的第二个处理器被拒绝:
publish LoanCreated -> 3 handler(s)
[inventory] L-1
[notice] M-1
[analytics] B-1
rejected second handler: duplicate command handler: ReturnBook
[loan] charged L-1
这段输出是「多播 vs 单接收」的最小模型。真实的 Kafka 里,事件的多播靠多个消费组实现,命令的单接收靠单个消费组加分区分配实现;机制不同,约束是同一个。本书卷一讲过消费组与分区分配,这里不重复。
混用之后,坏在哪
同一个程序故意把两类消息放错位置,得到两组输出。
把命令当事件广播。 ReturnBook 被投到事件总线上,两个处理器都执行了:
misuse A: command broadcast as an event
publish ReturnBook -> 2 handler(s)
[fine] executed, loan=L-1
[inventory] executed, loan=L-1
在 libraryd 的合成场景里(这是本书构造的场景),罚金计算和库存归还会各执行一次。如果这两个处理器都写了库,同一笔归还会被记两次账。更隐蔽的问题是:命令本来可以拒绝——比如这本书已经被预约、或者归还日期早于借出日期——但被当成事件广播后,没有「拒绝」这个动作,每个消费者只能各自决定要不要信。
把事件当命令点对点发送。 LoanCreated 走命令总线,只有注册的那一个处理器收到:
misuse B: event sent point-to-point as a command
[inventory] got L-1
[notice] never invoked (no handler registered)
库存更新了,通知没发。这个错误不会报错、不会重试、不会进死信,它只是静默地少了一个消费者。等有人发现「借书成功但读者没收到短信」,往往已经过了几天。
两类错误的根因是同一个:消息的语义和它的传输拓扑必须匹配。事件天然是多播的,命令天然是单播的,查询根本不该出现在消息系统里。把语义写进类型名只是第一步,传输层还得跟上。
| 混用方式 | 直接症状 | 根因 |
|---|---|---|
| 命令广播到多个消费组 | 同一动作被执行多次 | 接收者数量与语义不符 |
| 事件点对点投递 | 部分消费者静默收不到 | 扇出被拓扑截断 |
| 查询包成事件 | 没有返回值,还放大了流量 | 查询需要同步响应 |
| 命令用过去时命名 | 消费者把请求当事实 | 名称在说谎 |
事件带 replyTo | 退化成 RPC,生产者等回包 | 违反了「发完就走」 |
在工程里把三者隔开
语义靠自觉守不住,要靠结构。libraryd-mq 里用四条规则把三类消息隔开:
- 分包:
com.plume.mq.events、com.plume.mq.commands、com.plume.mq.queries三个包,禁止跨包继承或共用基类。事件和命令没有共同的父类型,编译器就不会让它们互相赋值。 - 不同的信封:事件信封有
eventId / occurredAt / schemaVersion / producer;命令信封有commandId / expectedVersion / deadline;查询不进消息系统,只在 API 层出现。 - 主题命名:事件用过去时(
library.loan.created);命令如果必须跨进程,用祈使名并只允许一个消费组;查询不允许建主题。 - 注册约束:事件处理器注册 N 个是正常的;命令处理器注册第二个应当在启动时失败,而不是运行时才发现重复执行。
代码评审时可以用五个问题快速识别混用:
- 这个消息的名字是过去时还是祈使句?和它的实际语义一致吗?
- 如果再加一个消费者,生产者需要改代码吗?需要改,说明它可能还是 RPC。
- 这个消息能被拒绝吗?能,它就不该被当成事件。
- 消费者失败后,生产者会知道吗?会,说明生产者在等回包。
- 这条消息重放一次,会发生什么?有副作用且不幂等,说明它不该被重放。
本节边界
这一节的实验全部在单进程内完成:EventBus 和 CommandBus 是内存里的 HashMap,没有网络、没有序列化、没有 Kafka。这样做是为了把「语义差异」从「传输机制」里剥出来——如果一上来就起 broker,读者很容易把「命令只能一个消费者收到」误解成 Kafka 的消费组特性,而不是消息本身的语义要求。
真实的 Kafka 映射、主题设计、消费组配置分别在 1.2 节和第二章展开。本节提到的 libraryd-mq 代码位于卷三的实验工程,完整源码见配套实验工程的 exp1/InteractionDemo.java。
小结
- 事件是过去时的事实,命令是祈使的请求,查询是只读的提问;三者的接收者数量、拒绝语义、重放语义都不同。
- record 适合做这三类消息的载体,但它只保证引用不可变;集合字段要防御性拷贝,结构相等不能替代事件去重。
- 事件天然多播,命令天然单接收(一个逻辑所有者,可以多个实例),查询不该进消息系统。
- 命令被广播会重复执行,事件被点对点投递会静默丢失消费者;两类错误都不一定报错,但都会在业务上留下痕迹。
- 用分包、不同信封、主题命名和注册约束把语义固化到结构里,比靠命名约定可靠。
阅读导航:上一节 目录与学习路径 | 下一节 1.2 事件驱动 vs 请求驱动
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。