news 2026/10/20 3:42:19

基于REA模型的事件溯源实践:构建可追溯可回放的业务系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于REA模型的事件溯源实践:构建可追溯可回放的业务系统

两周前,内部物流系统提了一个让我一开始不太想接的需求:所有的库存出入库动作,需要变成可追溯、可对账、能按任意时间点回放的数据。我翻了几个重量级流程引擎,最后还是决定自己动手写一个小工具,名字就叫rea。这个重新落地的过程,让我对业务建模和事件溯源都有了更深的理解,所以把完整的思路和踩坑经历整理出来,给正在做订单、库存、账户流水这类系统的同学参考。

rea对应的核心思路来自经典业务建模里的 REA 模型,即 Resource(资源)、Event(事件)、Agent(参与者)。简单说,它解决的问题是:别再用一堆可变的字段去模拟业务事实,而是把每一个业务动作当成一条不可变的事件记录下来,你想要的“当前状态”只是这些事件的投影结果。这套思路尤其适合库存台账、积分流水、订单履约、财务对账这些需要审计和回放的场景。如果你对事件溯源、领域驱动设计或者只是想给自己的 CRUD 系统找一个更可靠的记账方式,这篇应该都能给你一些启发。

1. 为什么我会写 rea:当业务说“记一笔账”,技术别急着建表

很多人拿到一个业务需求,第一反应都是“建几张表、加几个状态字段”。我也这么干过,而且在这条路上摔得够惨。

举个例子,最常见的订单表,一开始无非是id, user_id, sku_id, qty, status。后面有了支付,加paid_at, pay_amount;有了发货,加shipped_at, express_no;有了售后,加refund_status, refund_reason。再往后,运营要“30天内退款原因分布”,你就得在十几个字段里捞数据,还要靠猜:这个状态到底是用户取消,还是系统超时取消?这个金额到底是实付、应付还是含税原价?字段一旦更新,旧值就被覆盖了,想查一个订单在某个时间点长什么样,基本没戏。

rea想做的第一件事,就是强迫你把注意力从“表结构”移到“发生了什么”。它把模型压成三个概念:谁(Agent)、什么东西(Resource)、发生了什么(Event)。

1.1 REA 模型的一句话版

REA 模型最早是会计信息系统里提出来的一套业务建模方式,核心语句是:事件连接参与者和资源,业务状态由事件产生,而不是由字段直接存储。

打个比方,用户用 100 积分兑换一张 5 元优惠券,传统做法是在积分表里直接扣掉 100,同时生成一条兑换记录。但“积分余额 = 800”这个数字只是一个结果,真正的事实是“用户在某个时刻用 100 积分发起了一次兑换”。REA 模型会把这个事实拆成下面三个东西:

  • Agent(参与者):用户、系统、运营、供应商
  • Resource(资源):积分、优惠券、库存、资金
  • Event(事件):兑换、发放、核销、回补

Event 负责把 Agent 和 Resource 连起来,例如“用户(Agent)兑换(Event)了 100 积分(Resource)”。所有“当前状态”都可以由事件流重新计算出来:当前积分余额 = 所有积分发放事件之和 - 所有积分消耗事件之和。

这听起来像是换了个说法,但关键区别在“不可变”和“可回放”上。传统表里的余额是裸数据,可以被人为 UPDATE;事件流里的余额是派生数据,任何人想改余额,都必须通过追加一条“调整事件”来实现,改不了历史。

1.2 传统余额模型的别扭之处

我做一个库存系统时,早期用的就是最朴素的方案:一张库存表,字段是available, reserved, outbound,每次下单就在事务里UPDATE inventory SET available = available - ? WHERE sku_id = ?。当时觉得天经地义,现在回头看,至少有三个问题很别扭。

第一个问题是状态覆盖。一次出库操作,会同时改available和reserved,如果一个并发请求同时在改同一行,没有锁就会丢更新。加锁倒是能解决,但锁的粒度和范围很难控制。

第二个问题是语义丢失。“现在库存为 0” 这个结果,到底是因为没采购、还是采购了但被预留、还是出库了但没记账?只看字段值完全不知道。每次排查问题都要翻业务日志,对不上账的情况特别多。

第三个问题是审计不可能。财务审计会问:这个月入库了多少、出库了多少、退回了多少?在传统表结构下,你只能查到“现在还剩多少”,至于中间过程,如果之前没有设计流水表,就彻底丢了。

我整理了一张对照表,说明了为什么从事件流视角重新看问题时,很多场景会简单很多:

能力传统余额表/状态表事件流模型(rea 的思路)
当前状态直接读字段,快投影后读,需要维护投影表
历史状态几乎不可查,字段被覆盖按事件流回放,任意时点可算
审计追踪依赖操作日志,日志与数据分离事件本身是事实,天然可审计
并发安全依赖行锁和事务,容易死锁追加式写入,冲突面小
业务语义状态字段含义模糊事件类型表达业务动作,一目了然
错误恢复只能人工修数据定位出错事件后重建投影即可

这张表并不是说传统方案一无是处,但要处理“可追溯、可对账”这类需求时,事件流的优势是压倒性的。rea的价值就在于帮你把事件流这套机制封装好,让你不用重复造轮子。

2. rea 的核心抽象:事件就是唯一事实

做好了心理建设,接下来看rea内部是怎么组织的。只有把抽象层看明白了,后面加业务逻辑的时候才不会糊。

2.1 事件流与投影的关系

rea里的第一等公民是「事件流」,而不是「实体」。每个业务对象(比如一个订单、一个 SKU)都有自己的一条流,所有发生在这个对象上的动作,按照发生顺序追加到流的末尾。

事件的结构我设计成下面这样:

interface Event { eventId: string; // 全局唯一,调用方生成,用于幂等 type: string; // 事件类型,如 'stock.received' agentId: string; // 谁发起的动作 occurredAt: string; // 业务发生时间 payload: Record<string, any>; // 事实数据,只放业务事实 schemaVersion: number; // 负载版本,用于兼容迁移 }

流本身是 append-only 的,已经写入的事件不允许修改和删除。每个流内部有一个递增的seq,写入时通过乐观锁保证顺序:WHERE stream_id = ? AND last_seq = ?,只有当前流的最新序号等于预期值时,新事件才能写入。

投影(Projection)是另一个核心概念。你可以把事件流理解成流水账,投影就是基于流水账计算出来的“余额表”。rea允许你注册一个投影器,内部维护一张物化表,每次新事件追加后,投影器把事件作用到表上,更新当前状态。比如:

  • 库存台账投影:维护available, reserved, outbound三个字段
  • 订单状态投影:维护current_status, paid_at, shipped_at等字段
  • 用户积分投影:维护balance字段

投影之所以重要,是因为查询端总不能每次从头把几千条事件算一遍。通过维护投影表,读性能可以做到和普通表一样快,同时保证“数据来自事件推导”这个特性。

2.2 为什么用“事件类型”而不是“状态字段”来表达业务节点

我用一个库存场景来说清楚设计动机。

物流业务里,“库存预留”和“库存扣减”是两件完全不一样的事。用户下单时,系统只是把库存锁定起来,防止超卖;真正出库时,库存才被消耗掉。如果中间取消了订单,预留的库存在取消那一刻被释放,但从来没有离开过仓库。

假如用传统字段表达,你可能只看到一个inventory.reserved从 1 变成 2 又变回 1,很难判断这个变化出自哪个业务环节。用rea的事件类型,就会变成这样:

// 用户下单,系统预留 10 件 { type: 'stock.reserved', agentId: 'user:1001', payload: { sku: 'SKU-A', qty: 10 } } // 订单支付,仓库打包并出库 { type: 'stock.outbound', agentId: 'system:warehouse', payload: { sku: 'SKU-A', qty: 10 } } // 用户取消订单,预留释放 { type: 'stock.released', agentId: 'user:1001', payload: { sku: 'SKU-A', qty: 10 } }

这三个事件表达的语义,比一个不断变化的reserved字段清楚得多。“为什么预留涨了?”答案是:因为存在stock.reserved事件;“为什么没出库却少了 10 件?”答案是:因为发生了stock.released,先预留后释放,净效果为零。审计人员要的就是这种确定性。

2.3 事件写入 API 与乐观锁

rea的对外 API 我控制在五个主要方法内,避免设计得太重:

import { createRea, sqliteStore } from 'rea'; const store = sqliteStore({ file: './rea.db' }); const rea = createRea({ store, eventTypes: [ 'stock.received', 'stock.reserved', 'stock.outbound', 'stock.released', ], }); // 追加事件 const streamId = 'inventory:SKU-A'; await rea.append(streamId, { eventId: 'evt_0001', type: 'stock.received', agentId: 'supplier:SH-01', occurredAt: '2024-01-10T10:00:00Z', payload: { qty: 500, lot: 'L24-A1' }, }); // 查询当前库存投影 const now = await rea.project('inventory', { skuId: 'SKU-A' }); console.log(now);

append内部会做两件关键事情:一是用eventId做唯一约束,重复的事件直接丢弃并返回已存在的事件信息;二是对当前流做乐观锁校验,seq必须连续。这两点直接解决了我在实际落地中遇到的最大两个坑,后文会详细展开。

3. 落地案例:库存预留、出库与回补

理论讲太多容易飘,我拿自己做的库存模块改造举个例子,把整个过程走一遍。

3.1 建模:从状态字段改成事件流

这是我在实际中做的第一个改造。原来的库存表是:

inventory(id, sku_id, available, reserved, outbound, created_at, updated_at)

每次出入库都UPDATE inventory SET available = available + ?。改造后,我重新定义了事件模型,并且不再把库存数字当“主数据”,而是当“投影数据”。

我把一个 SKU 的完整生命周期建模成下面几条事件:

事件类型触发方对库存的影响业务含义
stock.received供应商/采购单available + qty采购入库
stock.reserved订单服务available - qty, reserved + qty下单锁定库存
stock.outbound仓储服务reserved - qty, outbound + qty实际出库
stock.released订单服务available + qty, reserved - qty取消订单释放预留
stock.adjusted盘点员available ± qty盘点调整

注意,stock.outbound发生时,available不动,因为available在预留阶段已经扣过了;reserved减少,outbound增加。这样每个数字的来龙去脉都能从事件流里找到。

3.2 投影代码实现

下面这个投影器是rea里最典型的一段逻辑,我把核心部分贴出来:

import { defineProjection } from 'rea'; export const inventoryProjection = defineProjection({ // 投影表唯一标识 name: 'inventory', // 投影初始化状态 init: () => ({ available: 0, reserved: 0, outbound: 0, updatedAt: null, }), // 每个事件如何影响投影状态 handle: { 'stock.received': ({ state, event }) => { state.available += event.payload.qty; state.updatedAt = event.occurredAt; }, 'stock.reserved': ({ state, event }) => { state.available -= event.payload.qty; state.reserved += event.payload.qty; state.updatedAt = event.occurredAt; }, 'stock.outbound': ({ state, event }) => { state.reserved -= event.payload.qty; state.outbound += event.payload.qty; state.updatedAt = event.occurredAt; }, 'stock.released': ({ state, event }) => { state.available += event.payload.qty; state.reserved -= event.payload.qty; state.updatedAt = event.occurredAt; }, 'stock.adjusted': ({ state, event }) => { state.available += event.payload.qty; state.updatedAt = event.occurredAt; }, }, });

这里有个值得注意的设计:handle里的每个函数必须是纯函数,只根据当前state和event计算新状态,不能发网络请求、不能读数据库。这样rea才能安全地从零重放所有事件来重建投影。

如果你需要按仓库维度统计,投影的init可以做成一个 Map,按仓库编码分桶。只要事件里有warehouseId,投影器就能自由聚合。

3.3 验证:用事件流做一致性检查

改造后最让我受益的,是能从上到下验证每个事件序列是否合法。我加了一个校验逻辑:每次处理stock.reserved和stock.outbound事件时,都断言当前投影中available和reserved不会变负。

handle: { 'stock.reserved': ({ state, event }) => { if (state.available < event.payload.qty) { throw new ProjectionError(`库存不足:SKU ${event.payload.sku} 可售 ${state.available},尝试预留 ${event.payload.qty}`); } state.available -= event.payload.qty; state.reserved += event.payload.qty; }, }

调整的逻辑也很简单:把真实库存数据全部清零,然后重新把所有历史事件灌进投影器。只要事件源没丢,投影结果就必然与业务当时的真实状态一致;出现异常时,rea会停在第一个报错事件上,直接告诉你哪一笔业务出了问题,而不是让后台跑了一个小时才发现账对不上。

这种“重放即对账”的能力,是传统表结构很难做到的。原来我为了验证库存数据,要写脚本对比库存流水表和库存余额表;现在直接把事件流重算一遍,两侧一比较,差异立刻就出来了。

4. 踩过的四个坑与修复方案

任何工具都要在真实场景里被锤一遍才能可靠。以下是rea从原型到小规模上线过程中,我踩过并且已经修复的四个坑,每一个都值得后来者看一下。

4.1 重复事件导致重复入账

第一次压测后,我发现一个大 bug:一条“出库”事件被重复处理,导致库存多扣了 10 件。

起因是调用方用了消息队列做削峰,客户端收到超时响应后自动重试,但第一次请求其实已经成功提交了。消息队列把同一事件投递了两次,投影器也相应地执行了两次累加。

问题的根源不是投影器写错,而是缺少幂等保护。修复方式是我在rea.append里对eventId加了唯一索引:

CREATE UNIQUE INDEX idx_event_unique ON events (stream_id, event_id);

append时如果遇到eventId重复,直接返回已存在的事件,而不是抛异常或重新写入。这样调用方无论重试多少次,业务结果都只有一次。

注意:eventId应该在业务调用方生成,而不是在服务端生成。如果等到服务端处理时才生成,客户端重试会生成多个不同 ID,幂等就失效了。

4.2 事件顺序错乱引发前向依赖

另一个让我头疼的问题是:库存的outbound事件先于reserved事件到达,投影结果中出现负库存。我一开始以为是并发控制没做好,排查了半天才发现问题是自己设计的。

当时为了追求解耦,我把“业务系统产生事件”和“rea 写入事件流”分成两个环节,中间隔了一个消息队列。由于队列的消费顺序不保证,同一个 stream 的不同事件可能被不同消费者拿到,然后再按自己的节奏写库,顺序就乱了。

这里有个关键认知:事件流的顺序约束是强一致需求,不是最终一致需求。如果在业务系统里已经完成了事务处理,再把事件交给异步队列去落库,那事件流的顺序就失控了。竞态条件下,很可能出现 later event 先写入、earlier event 后写入的情况。

我的修复方案很“笨”但也最可靠:放弃跨系统的异步事件,改回单库事务内同步写入。业务操作和事件追加在同一个本地事务里完成,这样stream_id和seq的顺序天然一致。同时保留乐观锁:

INSERT INTO events (stream_id, seq, event_id, type, agent_id, payload) VALUES (?, ?, ?, ?, ?, ?) WHERE (SELECT max_seq FROM streams WHERE stream_id = ?) = ?;

如果seq不连续,事务直接失败,由上层决定重试还是告警。这个改动确实牺牲了一点吞吐,但对于库存这类核心链路,顺序一致性比吞吐重要得多。

4.3 投影表重建太慢

随着事件量增长,我遇到了第三个问题:上线后想改一下投影规则,结果重建投影时发现几十万条事件要花好几分钟,完全不能接受。

这里我用了经典的snapshot 快照机制。每处理 5000 条事件,就把当前的投影状态持久化成一个快照,并记录对应的seq:

inventory_snapshot(sku_id, snapshot_seq, available, reserved, outbound, created_at)

重建时,先加载距离目标时间点最近的一份快照,然后只回放快照之后的增量事件:

const snapshot = await store.loadLatestSnapshot(streamId); const startSeq = snapshot ? snapshot.seq : 0; const es = await store.loadEventsFrom(streamId, startSeq + 1); let state = snapshot ? snapshot.state : projection.init(); for (const e of es) { projection.handle({ state, event: e }); }

加了快照后,重建一个大型 SKU 集合的时间从“全量重放”变成“增量回放”,性能提升了一个数量级。

注意:快照里只放投影推导出来的状态,不要放任何“用于推导的事实”。因为事实已经在事件流里了,快照要做的只是缩短回放距离。如果快照和事件之间出现了不一致,最终对账时要以事件流为准。

4.4 事件结构膨胀与 schema 版本化

最后一个坑属于“长期不治理的代价”。上线三个月后,产品为了做一个新报表,在stock.received的 payload 里塞了十几个字段,包括一些方便统计的冗余字段。刚开始还没问题,后来另一条业务链路需要读取这些字段,却发现有的旧事件没有这些值,处理逻辑变得非常混乱。

我最终的解决方法是给事件类型加上版本号,并写一个 upcast 迁移函数:

type EventWithVersion = Event & { schemaVersion: number }; const upcasters = { 'stock.received': { from: 1, to: 2, upcast: (payload: any) => ({ qty: payload.qty, lot: payload.lot, supplierCode: payload.supplierCode ?? 'UNKNOWN', }), }, };

读取事件时,先根据schemaVersion判断是否需要 upcast,把旧版本的事件转换成当前版本能识别的结构。这样新增字段不会破坏旧数据,旧事件也能被新逻辑正确处理。

我更深的体会是:冗余字段别进事件。事件只存“业务事实”,任何可以从事实推导出来的统计字段(比如本月累计、按仓库分类的小计)都应该放在投影层,而不是写进事件里。否则一旦推导逻辑变了,历史事件里的冗余数据还会造成新的不一致。

5. rea 的边界:它适合哪些场景

写了这么久,我也得泼点冷水,rea不是什么场景都能用。

我做下来觉得最合适的是这四类:

  • 资金和积分流水:余额最终一致,但每笔变动必须可追溯
  • 订单履约与库存台账:多状态变化、跨部门对账频繁
  • 租赁计费、订阅服务:要按时间点计算中间状态
  • 内部审计与合规链路:不允许“悄悄修改”历史事实

这些场景的共同点是:业务对事件的可追溯性有刚性要求,而且状态变化不是简单的一次性赋值。传统表结构也能做,但往往要额外维护一套权限、审计和补偿机制,非常累。

反过来,下面这些场景我不建议用rea:

  • 纯实时大屏统计,比如“当前在线人数”,直接存 Redis 计数器更合适
  • 海量埋点日志,比如点击流,写入速度优先,审计语义没那么重要
  • 极简单的小工具,比如一个只有注册和注销逻辑的业务,加事件流反而增加复杂度

我自己判断是否引入rea的方法很简单:先在纸上把所有可能的业务事件画成一条时间线,然后问自己三个问题:

  1. 这个业务是否需要回答“过去某个时刻状态是什么”?
  2. 这个业务是否经常因为数据对不上账去翻日志?
  3. 这个业务的状态变化是否由多方、多个操作共同决定?

如果答案都是肯定的,那事件流方案大概率值得用;如果答案全是“不需要”,那老老实实用一张表加事务锁可能更省心。

最后再分享一点实际经验:不要一开始就把事件类型设计得特别完整,先定义两三个核心事件,把链路跑通,再通过事件版本化慢慢演进。我最初设计库存模型时列了八种事件,后来发现真正高频的只有三种,事件类型写得越多,后期为兼容旧数据付的成本就越高。

rea本身只是个小工具,真正重要的还是你愿不愿意把“业务事实”和“业务状态”分开来看。分开了,很多纠缠不清的问题会自然变得清晰。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:42:10

Spring Boot通用后台管理系统实战:从拆包到多模块改造

简介&#xff1a;这份资源是面向Java后端初学者与进阶开发者的SpringBoot通用后台管理系统完整源码&#xff0c;适合需要快速搭建管理平台、学习企业级项目分层与权限设计的开发者。压缩包共152个文件&#xff0c;以129个Java源码为核心&#xff0c;辅以5个XML配置、4个propert…

作者头像 李华
网站建设 2026/10/20 3:41:03

SQL注入三大类型全解析:联合查询、布尔盲注、时间盲注实战指南

很多人第一次接触SQL注入的时候&#xff0c;都会产生一个疑惑&#xff1a;同样是注入了&#xff0c;为什么有的页面直接把你想要的数据明晃晃地列出来&#xff0c;有的页面只是"有反应/没反应"两种状态切换&#xff0c;还有的页面看起来什么都没发生&#xff0c;但数…

作者头像 李华
网站建设 2026/10/20 3:40:08

TensorFlow入门实战:从环境配置到模型部署的完整指南

刚开始接触深度学习的时候&#xff0c;我印象最深的一件事就是在某个技术交流群里看到有人问“学习深度学习从哪里开始”&#xff0c;底下答案五花八门&#xff0c;但出现频率最高的一个词就是TensorFlow。等我自己真正把一个完整的项目跑通之后&#xff0c;才明白为什么大家都…

作者头像 李华
网站建设 2026/10/20 3:39:58

AgenticOps实战:从告警到自愈,Agent如何自主排查故障

1. 从“人找工具”到“Agent 找工具”&#xff1a;AgenticOps 到底在解决什么运维和研发这个行当&#xff0c;过去十几年一直在做同一件事&#xff1a;把重复劳动自动化。从最早的 Shell 脚本&#xff0c;到后来的 Ansible、Terraform&#xff0c;再到 CI/CD 流水线&#xff0c…

作者头像 李华
网站建设 2026/10/20 3:39:55

STM32 RS485总线Modbus RTU从站协议栈实现与CLion工程实践

简介&#xff1a;面向 STM32 嵌入式开发者的 RS485 通信与 Modbus RTU 协议实践项目&#xff0c;基于 HAL 库完成串口初始化、报文解析与收发控制&#xff0c;以中菱轮毂电机 ZLAC8015D 为控制对象&#xff0c;并采用 CLion 与 CMake 构建管理&#xff0c;适合有一定单片机基础…

作者头像 李华
网站建设 2026/10/20 3:39:25

VLAN基本原理详解:从广播域隔离到跨交换机配置

很久没碰交换机的朋友问我一个问题&#xff1a;公司两台交换机&#xff0c;接了几十台电脑&#xff0c;最近网络卡得离谱&#xff0c;一开视频会议&#xff0c;全楼都掉线&#xff0c;问我怎么救。我看了一下拓扑&#xff0c;二十多台设备挤在同一个二层广播域里&#xff0c;AR…

作者头像 李华