Administrator
发布于 2022-07-14 / 3047 阅读
47

领域事件与事件溯源实践

对账发现:账户余额和流水对不上 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 年事件时,全量重放很慢,必须靠周期快照(比如每天存一份余额快照,重放只从最近快照往后)。
  • 心智负担:团队要习惯"没有当前状态,只有历史"的思维方式,新人上手要培训。

下篇预告

这篇先把《领域事件与事件溯源实践》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考