两周前,内部物流系统提了一个让我一开始不太想接的需求:所有的库存出入库动作,需要变成可追溯、可对账、能按任意时间点回放的数据。我翻了几个重量级流程引擎,最后还是决定自己动手写一个小工具,名字就叫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的方法很简单:先在纸上把所有可能的业务事件画成一条时间线,然后问自己三个问题:
- 这个业务是否需要回答“过去某个时刻状态是什么”?
- 这个业务是否经常因为数据对不上账去翻日志?
- 这个业务的状态变化是否由多方、多个操作共同决定?
如果答案都是肯定的,那事件流方案大概率值得用;如果答案全是“不需要”,那老老实实用一张表加事务锁可能更省心。
最后再分享一点实际经验:不要一开始就把事件类型设计得特别完整,先定义两三个核心事件,把链路跑通,再通过事件版本化慢慢演进。我最初设计库存模型时列了八种事件,后来发现真正高频的只有三种,事件类型写得越多,后期为兼容旧数据付的成本就越高。
rea本身只是个小工具,真正重要的还是你愿不愿意把“业务事实”和“业务状态”分开来看。分开了,很多纠缠不清的问题会自然变得清晰。