《Java 消息中间件·事件驱动架构》13.3 libraryd 事件流

libraryd 借阅系统事件流实战:定义 LoanCreated/Renewed/Returned/Overdue 四种领域事件,用内存追加日志模拟 Kafka 分区保序语义,实现投影状态重建、幂等重放验证、按借阅单过滤追踪与事件因果链可视化。

从命令到事件:不变的是什么

13.1 节的领域模型和 13.2 节的命令处理器共同产出了事件,但事件一旦离开聚合根,它的生命周期就进入了完全不同的轨道。事件是过去时的事实——不能被拒绝、不能被修改、只能被投递和消费。这一节建一个最小的事件流系统,验证四条核心属性:保序、可追踪、可重建、幂等。

四种事件类型

libraryd 借阅系统需要四种领域事件,覆盖一笔借阅的完整生命周期:

class LoanCreated {
    final String eventId;
    final String loanId;
    final String bookIsbn;
    final String memberId;
    final Instant borrowedAt;
    final Instant dueAt;
    final String sourceCommandId; // 溯源键
}

class LoanRenewed {
    final String eventId;
    final String loanId;
    final Instant newDueAt;
    final int renewalCount;
    final String sourceCommandId;
}

class LoanReturned {
    final String eventId;
    final String loanId;
    final String bookIsbn;
    final Instant returnedAt;
    final String sourceCommandId;
}

class LoanOverdue {
    final String eventId;
    final String loanId;
    final Instant overdueAt;
    final int daysLate;
}

四种事件没有共享基类。如果硬加一个 DomainEvent 父类,问题是每个事件特有的字段必须降级成 Map<String, Object> 或单独的子字段,反而增加消费端的解析负担。在消息系统中,事件类型靠主题 + schema 注册来识别,不是靠 Java 继承。

每个事件都有 eventId,这是事件自身的去重键,不是 commandId。同一命令被重投两次会生成两个不同 eventId 的事件,消费端必须按 eventId 去重,而不是按 sourceCommandId。后者只用于审计和调试,不用于幂等。

事件流:模拟 Kafka 分区

Kafka 的核心保证是同一分区内的消息保序。下面用内存 ArrayList 模拟一个分区的事件流:

class EventStream {
    private final String topic;
    private final String streamId; // 模拟 partition id
    private final List<Object> events = new ArrayList<>();
    private int offsetSeq = 0;

    int append(Object event) {
        events.add(event);
        return ++offsetSeq;
    }

    List<Object> findByLoanId(String loanId) {
        List<Object> result = new ArrayList<>();
        for (Object e : events) {
            // 按 loanId 过滤
            if (matchesLoanId(e, loanId)) result.add(e);
        }
        return result;
    }

    Map<String, Integer> typeSummary() {
        Map<String, Integer> counts = new HashMap<>();
        for (Object e : events) {
            String type = eventType(e);
            counts.put(type, counts.getOrDefault(type, 0) + 1);
        }
        return counts;
    }
}

EventStream 不是 Kafka Producer 的封装,它不关心序列化、不关心网络、不关心 broker。它只负责回答两个问题:这个 loanId 发生过什么?每种事件各来了多少次?这两个问题在排查业务问题时比 Kafka metrics 更直接。

投影重建:从事件流还原状态

事件源的核心信条是存储事件而不是状态。但用户要查「L-100 现在逾期了吗」时,总不能每次重放全部事件。投影是把事件流折叠成当前状态的过程:

class LoanProjection {
    private final String loanId;
    private String status = "NOT_EXIST";
    private int renewalCount;
    private Instant dueAt;
    private final List<String> appliedEventIds = new ArrayList<>();

    void apply(Object event) {
        if (event instanceof LoanCreated) {
            LoanCreated e = (LoanCreated) event;
            if (!e.loanId.equals(loanId)) return;
            this.status = "ACTIVE";
            this.dueAt = e.dueAt;
            appliedEventIds.add(e.eventId);
        } else if (event instanceof LoanRenewed) {
            LoanRenewed e = (LoanRenewed) event;
            if (!e.loanId.equals(loanId)) return;
            this.dueAt = e.newDueAt;
            this.renewalCount = e.renewalCount;
            this.status = "RENEWED";
            appliedEventIds.add(e.eventId);
        } else if (event instanceof LoanReturned) {
            LoanReturned e = (LoanReturned) event;
            if (!e.loanId.equals(loanId)) return;
            this.status = "RETURNED";
            appliedEventIds.add(e.eventId);
        } else if (event instanceof LoanOverdue) {
            LoanOverdue e = (LoanOverdue) event;
            if (!e.loanId.equals(loanId)) return;
            this.status = "OVERDUE";
            appliedEventIds.add(e.eventId);
        }
    }
}

投影是纯函数:同样的输入事件序列永远产生同样的输出状态。这个纯函数性质是事件溯源的测试基础——可以随机抽取一段历史事件,运行投影,和数据库里的当前状态比对,不一致就说明投影逻辑有 bug 或者事件丢失。

注意 apply 方法只认 eventId 去重是不够的(在本实现里靠 appliedEventIds 防止重复消费),但上面代码没有提前返回的逻辑。真实做法是:收到事件时先查 appliedEventIds.contains(e.eventId),如果包含就跳过。这个检查应该在消费端做,不是在投影里做。

因果链追踪

事件流不是静态的快照,是有时间方向的图。每笔借阅都是图中一条路径:

LoanCreated  --[续借]--> LoanRenewed --[归还]--> LoanReturned
      |
      +--[逾期]--> LoanOverdue

状态机的合法迁移只有单向:

当前状态允许下一个事件
NOT_EXISTLoanCreated
ACTIVELoanRenewed / LoanReturned / LoanOverdue
RENEWEDLoanRenewed / LoanReturned / LoanOverdue
OVERDUELoanReturned
RETURNED(终态)

LoanOverdue 比较特殊:它不是由命令直接生成的,是由定时任务扫描到期日触发的。所以在事件流里,LoanOverdue 的 sourceCommandId 为 null,它的来源是调度器而不是用户命令。这种「系统生成事件」在事件溯源系统里很常见,消费端必须处理没有 commandId 的情况。

编译运行

本节约简版代码在 LibrarydEventStream.java,JDK 8 验证通过:

javac LibrarydEventStream.java
java LibrarydEventStream

输出:

=== libraryd 事件流追踪演示 ===

1. 生成完整事件流
   --- Event Stream: library.loans / partition-0 ---
   [1] C L-100 | LoanCreated[id=E-1, loan=L-100, book=9780134685991, due=2026-10-15T08:00:00Z]
   [2] R L-100 | LoanRenewed[id=E-2, loan=L-100, due=2026-10-22T08:00:00Z, count=1]
   [3] RT L-100 | LoanReturned[id=E-3, loan=L-100, returnedAt=2026-10-11T08:00:00Z]
   [4] C L-101 | LoanCreated[id=E-4, loan=L-101, book=9780134685092, due=2026-10-09T08:00:00Z]
   [5] O L-101 | LoanOverdue[id=E-5, loan=L-101, daysLate=2, at=2026-10-11T08:00:00Z]
   [6] C L-102 | LoanCreated[id=E-6, loan=L-102, book=9780134685991, due=2026-10-17T08:00:00Z]
   [7] R L-102 | LoanRenewed[id=E-7, loan=L-102, due=2026-10-31T08:00:00Z, count=1]
   [8] R L-102 | LoanRenewed[id=E-8, loan=L-102, due=2026-11-14T08:00:00Z, count=2]
   [9] R L-102 | LoanRenewed[id=E-9, loan=L-102, due=2026-11-28T08:00:00Z, count=3]
   [10] C L-103 | LoanCreated[id=E-10, loan=L-103, book=9780134685991, due=2026-10-18T08:00:00Z]
   [11] RT L-103 | LoanReturned[id=E-11, loan=L-103, returnedAt=2026-10-06T08:00:00Z]
   --- end (11 events) ---

2. 按借阅单追踪事件序列
   L-100 事件数: 3
   L-101 事件数: 2
   L-102 事件数: 4
   L-103 事件数: 2

3. 投影重建:从事件流还原状态
   LoanProj[loan=L-100, status=RETURNED, renewals=1, due=..., events=3]
   LoanProj[loan=L-101, status=OVERDUE, renewals=0, due=..., events=2]
   LoanProj[loan=L-102, status=RENEWED, renewals=3, due=..., events=4]
   LoanProj[loan=L-103, status=RETURNED, renewals=0, due=..., events=2]

4. 事件类型统计
   LoanOverdue: 1
   LoanCreated: 4
   LoanRenewed: 4
   LoanReturned: 2

5. 事件序列因果链(以 L-100 为例)
   C: NOT_EXIST -> ACTIVE @2026-10-01T08:00:00Z
   R: ACTIVE -> RENEWED @2026-10-22T08:00:00Z
   RT: RENEWED -> RETURNED @2026-10-11T08:00:00Z

6. 幂等性验证:重复应用同一事件
   单次应用状态: RETURNED
   重复应用状态: RETURNED
   幂等: true

四条借阅路径全部覆盖:正常借-续-还、借-逾期、借-续-续-续上限、借-直接还。投影重建的四种终态(RETURNED / OVERDUE / RENEWED)与预期一致。幂等验证通过,说明 apply 方法对同一事件序列重入不敏感。

事件设计常见错误

错误症状修正
事件之间没有 eventId重投后重复消费每条事件必须有全局唯一 eventId
用 commandId 做事件去重同一命令生成的事件被误报重复用 eventId 去重,commandId 只溯源
事件携带可变集合消费者拿到后修改,影响后续投影集合字段用不可变结构,或在序列化时保护
投影逻辑不纯重放后状态和线上不一致投影必须是纯函数,禁止读数据库
系统事件漏 sourceCommandId审计链断裂用 null 显式标记来源是调度器/定时任务

从内存学到 Kafka

这一节的 EventStream 是单分区的内存结构,但它已经包含了 Kafka 分区模型的本质:

  • 保序:append 只往末尾追加,offset 单调递增。
  • 分区键:虽然没有显式分配分区,但每一笔借阅的 ID 天然就是 Kafka 的 key——同一 loanId 的事件应该进同一分区,这样才能保证消费者按顺序看到所有相关事件。
  • 重放:findByLoanId 就是重放微观视角,typeSummary 就是聚合视图。

真实的 Kafka 映射在第 7 章讲过:事件写入 library.loans 主题,同一 loanId 为 key 的事件进入同一分区。消费端用 Kafka Streams 做实时投影,用事件存储做历史重放。这里的内存版只保留了语义,去掉了网络和序列化。

事件时间语义:occurredAt、recordedAt 与 processedAt

事件的时间戳不是只有一个。在真实的事件溯源系统中,一个事件至少携带三个时间属性:

  • occurredAt:业务发生时间,由领域层决定。LoanCreated 的 borrowedAt 就是这个语义。
  • recordedAt:事件被写入事件流的时间,由基础设施层决定。它和 occurredAt 之间可能存在延迟——比如网络抖动、broker 故障恢复。
  • processedAt:事件被消费者处理的时间,由消费端决定。它帮助排查消费延迟。

这三个时间的差距就是系统_LATENCY 的来源。如果 recordedAt - occurredAt > 5 秒,说明写事件有延迟;如果 processedAt - recordedAt > 10 秒,说明消费端处理不过来或发生了重平衡。本节示例只用了 occurredAt,但生产系统里三个时间都要记录,并且分别上报不同的监控指标。

特别要注意的是 LoanOverdue 的时间语义。它不是由用户命令触发的,所以没有 sourceCommandId,但它的 overdueAt 仍然是业务语义时间——表示「这个借阅在什么时候被判定为逾期」。判定时机和记录时机可能差几秒到几分钟,取决于定时任务的调度间隔。如果消费端根据 overdueAt 计算罚金,而判定是在 T+1 的凌晨才做的,那罚金计算必须回溯到 overdueAt 的值,而不是 recordedAt。这也是事件溯源的优势:历史状态永远可以从事件中重建,不会因为判定延迟而丢失。

本节边界与延伸

三篇文章从领域模型(13.1)、命令设计(13.2)到事件流(13.3),完成了 libraryd 的最小闭环。下一章(第 14 章)会从性能角度扩展:高并发下的事件吞吐、投影的延迟优化、以及事件存储的归档策略。本章没有做 Kafka 集成,因为"先跑通领域,再上 broker"是事件驱动系统最低成本的验证路径。

时间线视图和投影重建的代码可以用两种方式扩展:

  • 加一个 LoanAuditLog 投影,记录每笔借阅的每时每刻状态,用于客服查询。
  • 加一个 OverdueNotifier 消费者,订阅 LoanOverdue 事件发送提醒邮件。这个消费者必须处理「逾期后已经归还」的竞态——LoanReturned 可能晚于 LoanOverdue,邮件不能发给已经还书的人。

小结

  • 四种事件覆盖借阅生命周期:LoanCreated、LoanRenewed、LoanReturned、LoanOverdue。
  • 事件用 eventId 去重,用 sourceCommandId 溯源;是两个不同的概念。
  • 内存事件流模拟了 Kafka 分区的核心属性:保序、追加、按 key 过滤、重放重建。
  • 投影是纯函数,同样的输入永远产生同样的输出,这是事件溯源可测试的基础。
  • 状态迁移有方向:NOT_EXIST -> ACTIVE -> [RENEWED -> RETURNED | OVERDUE -> RETURNED],不能逆向。
  • 系统事件(如 LoanOverdue)的 sourceCommandId 为 null,消费端需显式处理。

阅读导航:上一节 13.2 libraryd 命令设计 | 下一节 14.1 性能调优

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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