从命令到事件:不变的是什么
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_EXIST | LoanCreated |
| ACTIVE | LoanRenewed / LoanReturned / LoanOverdue |
| RENEWED | LoanRenewed / LoanReturned / LoanOverdue |
| OVERDUE | LoanReturned |
| 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 性能调优
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。