《Java 消息中间件·事件驱动架构》1.1 事件、命令与查询

从 libraryd 的借阅动作出发,把事件、命令、查询当成三种不同的句子:过去时的事实、祈使的请求、只读的提问。用三个 Java record 和一组可运行的进程内总线实验,展示多播与单接收的差别,并演示命令被广播、事件被点对点投递之后各自坏在哪里。

一个动作,三种说法

读者在 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 是投影参数,决定返回的视图宽度。查询天然是同步的:调用方要的是返回值,不是「已受理」。

语义差异不是命名差异

把三类消息放在一起对照,差异集中在七个维度:

维度事件命令查询
时态过去时:已发生祈使:请执行疑问:是什么
语气陈述事实提出请求请求读取
接收者零到多个,写代码时未知恰好一个逻辑接收者一个调用方
能否拒绝不能,事实已经成立能,校验失败就拒绝能,找不到就返回空
重放可以,消费者需幂等不可以,除非有幂等键无副作用,可重复
命名LoanCreatedReturnBookFindLoan
跨进程形态主题、多消费组点对点队列或单消费组同步 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 里用四条规则把三类消息隔开:

  1. 分包:com.plume.mq.events、com.plume.mq.commands、com.plume.mq.queries 三个包,禁止跨包继承或共用基类。事件和命令没有共同的父类型,编译器就不会让它们互相赋值。
  2. 不同的信封:事件信封有 eventId / occurredAt / schemaVersion / producer;命令信封有 commandId / expectedVersion / deadline;查询不进消息系统,只在 API 层出现。
  3. 主题命名:事件用过去时(library.loan.created);命令如果必须跨进程,用祈使名并只允许一个消费组;查询不允许建主题。
  4. 注册约束:事件处理器注册 N 个是正常的;命令处理器注册第二个应当在启动时失败,而不是运行时才发现重复执行。

代码评审时可以用五个问题快速识别混用:

  • 这个消息的名字是过去时还是祈使句?和它的实际语义一致吗?
  • 如果再加一个消费者,生产者需要改代码吗?需要改,说明它可能还是 RPC。
  • 这个消息能被拒绝吗?能,它就不该被当成事件。
  • 消费者失败后,生产者会知道吗?会,说明生产者在等回包。
  • 这条消息重放一次,会发生什么?有副作用且不幂等,说明它不该被重放。

本节边界

这一节的实验全部在单进程内完成:EventBus 和 CommandBus 是内存里的 HashMap,没有网络、没有序列化、没有 Kafka。这样做是为了把「语义差异」从「传输机制」里剥出来——如果一上来就起 broker,读者很容易把「命令只能一个消费者收到」误解成 Kafka 的消费组特性,而不是消息本身的语义要求。

真实的 Kafka 映射、主题设计、消费组配置分别在 1.2 节和第二章展开。本节提到的 libraryd-mq 代码位于卷三的实验工程,完整源码见配套实验工程的 exp1/InteractionDemo.java。

小结

  • 事件是过去时的事实,命令是祈使的请求,查询是只读的提问;三者的接收者数量、拒绝语义、重放语义都不同。
  • record 适合做这三类消息的载体,但它只保证引用不可变;集合字段要防御性拷贝,结构相等不能替代事件去重。
  • 事件天然多播,命令天然单接收(一个逻辑所有者,可以多个实例),查询不该进消息系统。
  • 命令被广播会重复执行,事件被点对点投递会静默丢失消费者;两类错误都不一定报错,但都会在业务上留下痕迹。
  • 用分包、不同信封、主题命名和注册约束把语义固化到结构里,比靠命名约定可靠。

阅读导航:上一节 目录与学习路径 | 下一节 1.2 事件驱动 vs 请求驱动

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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