对账发现:账户余额和流水对不上 3 分钱
做交易系统最怕"状态对不上"。我们有个积分账户服务,某天对账脚本报:用户 A 的余额是 1200,但流水累加只有 1199.97。差 3 分钱,谁也说不清是哪笔操作漏了。传统的"改余额 + 插流水"两条写,因为中途异常出现过不一致。痛定思痛,我把核心域改成了事件溯源(Event Sourcing)。
事件建模:状态是事件的累加,不是字段
事件溯源的核心理念:不存当前状态,只存发生了什么事件。余额这种"状态"不是一张表里的一行数字,而是所有"积分变更事件"按顺序重放的累计结果。这样 3 分钱的差错,本质是"少了一条事件"或"有一条事件没被重放",可追、可查、可重建。
先定义事件,用 JDK 17 的 sealed 约束事件家族:
public sealed interface PointEvent
permits PointEarned, PointSpent, PointAdjusted {
Long userId();
long amount();
Instant occurredAt();
}
public record PointEarned(Long userId, long amount, Instant occurredAt)
implements PointEvent {}
public record PointSpent(Long userId, long amount, Instant occurredAt)
implements PointEvent {}
仓储实现:只追加,不更新
事件溯源的仓储只有追加(append),没有 update。当前状态通过重放事件得到,或者缓存一份快照加速:
public class PointEventStore {
// 只追加,幂等靠 eventId 去重
public void append(PointEvent e, String eventId) {
if (dedup.contains(eventId)) return;
jdbc.insert("INSERT INTO point_event(user_id,type,amount,ts,event_id) "
+ "VALUES(?,?,?,?,?)",
e.userId(), e.getClass().getSimpleName(),
e.amount(), e.occurredAt(), eventId);
dedup.add(eventId);
}
// 当前余额 = 事件重放
public long balance(Long userId) {
return eventRepo.findByUserId(userId).stream()
.mapToLong(ev -> switch (ev) {
case PointEarned e -> e.amount();
case PointSpent e -> -e.amount();
case PointAdjusted e -> e.amount();
}).sum();
}
}
之前的"3 分钱"问题就此消失:要么流水里真少一条事件(能查到),要么重放逻辑有 bug(改一处即可对所有历史生效)。状态永远可由事件重建,不存在"状态与流水两张皮"。
与 CQRS 结合:读写各走各的
事件溯源天然适合配 CQRS(命令查询职责分离)。写侧只管产生和持久化事件;一个独立的投影(Projection)进程订阅事件流,把事件转换成适合查询的"读模型"——比如一张 point_balance 快照表、一份 ES 里的用户积分文档。
// 投影:监听事件,更新读模型
@KafkaListener(topics = "point-events")
public void on(PointEvent e) {
switch (e) {
case PointEarned ev -> balanceView.add(ev.userId(), ev.amount());
case PointSpent ev -> balanceView.add(ev.userId(), -ev.amount());
case PointAdjusted ev -> balanceView.set(ev.userId(), ev.amount());
}
}
这样写链路由事件保证一致,读链路通过投影异步更新。读模型甚至可以多种并存:余额快照给接口用,ES 文档给运营检索用,互不影响。
付出的代价
- 事件schema 演进:事件一旦写入不能改结构,只能新增事件类型,老事件靠 upcaster 兼容。我们建了事件版本号。
- 重放成本:用户有 5 年事件时,全量重放很慢,必须靠周期快照(比如每天存一份余额快照,重放只从最近快照往后)。
- 心智负担:团队要习惯"没有当前状态,只有历史"的思维方式,新人上手要培训。
下篇预告
这篇先把《领域事件与事件溯源实践》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。