做业务系统开发的朋友,多半都经历过这样的时刻:一开始库存就一张表,加上一张进出的流水表,开发速度飞快;等业务慢慢起来,店铺多了、促销多了、退款多了,那张流水表开始变得不堪重负。你查一个“当前库存”要绕好几个弯,对一笔账要翻半天历史记录,明明只是简单的一进一出,怎么就越用越别扭?
我最近重构的一套订单库存系统,最初也踩在这种“用流水表硬撑业务复杂度”的坑里。后来把整套模型推倒重来,代码代号就叫REA,全称是Resource、Event、Agent,也就是“资源、事件、代理”。这套思路不是我发明的,在会计信息系统和企业建模领域已经沉淀了很多年,但国内讲得很少,实际落地的人更少。这篇文章就把我这次重做的完整过程、表结构设计、事件流处理、迁移步骤、踩过的坑都拆开聊一聊,希望能给同样折腾业务建模的人一点参考。
1. 我为什么会在项目里引入 REA 这套思路
1.1 传统台账模型到底卡在哪里
先说结论:传统“库存表 + 进出流水表”不是不能用,而是它的表达力不够。库存表只告诉你“现在有多少”,如果你想回答“这个数是怎么来的”“每个环节谁负责的”“为什么两个库房之间调拨完之后账平了但货没平”,就非常吃力。
我在这次项目里遇到的实际场景是这样的:一个零售业务,有 A、B 两个分店,总仓在中间,每天有大量跨店调拨、顾客退货、供应商补货,还有线上订单和线下订单混在一起结算。问题集中爆发在三个地方:
- 一件商品从总仓调到 A 店,再从 A 店退货回总仓,中间如果价格变了、包装破损了,旧模型只能靠“备注”字段填充,没有结构性表达。
- 两个店铺之间调拨,A 店减了库存,B 店加了库存,库存总量是对的,但如果在途阶段发生了破损或者丢件,总账立刻对不上。
- 对账时要逐条核对“某一天每个库房的入库、出库、退货、调拨”,基本上靠写复杂 SQL 硬算,性能差且逻辑难维护。
这几个问题本质上是同一个:我只有“结果记录”,没有“业务事实”。流水表的每一行只描述了一个方向性的数量变化,却没有回答“这个变化对应的完整业务事件是什么、参与了哪些角色、涉及了哪些资源对象”。
1.2 REA 模型的核心视角:把业务拆成三种基本元素
REA 模型的出发点特别简单:任何经济活动,都可以拆成资源(Resource)、事件(Event)、代理(Agent)三个要素。
- 资源:被交换、被消耗、被生产的东西。在订单库存系统里,就是商品、库存、钱、积分这类有经济价值的对象。
- 事件:真正发生的业务动作,比如入库、出库、调拨确认、付款、退款。事件会引发资源的流入或流出。
- 代理:参与事件的个人或组织,比如顾客、供应商、仓管员、门店。
有人看到这里会觉得:这不就是把流水表拆成了三张表吗?对,这就是关键转折点。传统流水表把“资源发生了变动”当作核心事实,REA 把“事件发生了”当作核心事实。资源变动只是一个结果,而结果是可以追溯的,事件则是不可更改的事实。一个事件里,可能有多个资源进出、多个代理参与、多个关联单据,把这些信息拍平到一张流水表里,信息密度和扩展性都远不如单独的“事件事实”。
1.3 为什么这种建模方式适合业务快速变化的系统
我在决定采用这套思路之前,特意对比过事件溯源(Event Sourcing)和账本式设计。事件溯源的核心是不保存当前状态,只保存事件流,通过回放事件得到最终状态。REA 更温和一点,它可以保留“当前资源状态”这类物化视图,但底层的事实来源从“流水账”变成了“业务事件”。
我选择 REA 的真正原因是它给系统留出了足够的解释空间。传统模型里加一个业务动作类型,可能要改表结构、改查询逻辑、改报表;REA 里加一个业务动作,本质上是加一个新的“事件类型”,资源表和代理表不用动,查询逻辑也只是多一种类型分支。多店铺、多币种、组合支付、部分退款,这类复杂场景用 REA 表达都只是“事件有多种类型”而已。
2. 落地表结构设计:把业务翻译成资源、事件、代理
2.1 最小可用模型:三张核心表 + 两张关联表
我落地时的第一版表结构,不是冲着一上来就把模型做完整,而是先保证核心闭环能跑通。下面这三张表是骨架。
-- 资源:可以是商品实例、库存批次、资金账户、优惠券 CREATE TABLE rea_resource ( id BIGSERIAL PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, -- product / lot / account / coupon resource_code VARCHAR(64) NOT NULL, name VARCHAR(255) NOT NULL, unit VARCHAR(32) NOT NULL, attributes JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (resource_type, resource_code) ); -- 代理:能够参与事件的人或组织 CREATE TABLE rea_agent ( id BIGSERIAL PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- customer / supplier / store / employee agent_code VARCHAR(64) NOT NULL, name VARCHAR(255) NOT NULL, attributes JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (agent_type, agent_code) ); -- 事件:业务事实,只追加,不改写 CREATE TABLE rea_event ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- restock / issue / transfer_issue / transfer_receipt / sale / refund ... event_no VARCHAR(64) NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, agent_id BIGINT NOT NULL REFERENCES rea_agent(id), description TEXT NOT NULL DEFAULT '', attributes JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (event_no) );光有这三张表还不够,资源和事件之间是多对多的关系。一个“跨店调拨事件”,至少涉及两个库存批次资源:调出店的资源、调入店的资源。一个“销售事件”,涉及商品资源和资金资源。所以中间必须有一张关联表记录“每个事件中每种资源的增减方向”。
-- 事件资源关系:方向 +1 代表流入,-1 代表流出 CREATE TABLE rea_event_resource ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES rea_event(id), resource_id BIGINT NOT NULL REFERENCES rea_resource(id), direction SMALLINT NOT NULL DEFAULT 1, -- 1 / -1 quantity NUMERIC(18, 4) NOT NULL, unit_amount NUMERIC(18, 4) NOT NULL DEFAULT 0, total_offset NUMERIC(18, 4) NOT NULL DEFAULT 0, -- 优惠/损耗等调整 created_at TIMESTAMPTZ NOT NULL DEFAULT now() );这套设计的好处是:事件表里只有事实,资源表里只有对象的静态描述,方向、数量、金额全部放在关系表里。你查“库存余额”时,本质上就是在按资源分组聚合关系表里的方向与数量。我第二版加上了快照表和各种物化视图,但核心事实来源永远在这套动静分离的结构里。
2.2 用“跨店调拨”完整过一遍设计思路
拿“A 店调拨 100 件商品到 B 店”举例,在 REA 里这个业务不是一个“减库存”动作加一个“加库存”动作,而是一个事件对:
- 调拨出库事件:代理是 A 店仓管;资源是 A 店的 100 件商品,方向为 -1。
- 调拨入库事件:代理是 B 店仓管;资源是 B 店的 100 件商品,方向为 +1。
两条事件通过event_no上的调拨单号关联,或者通过attributes里记录对端事件 ID 关联。中间的在途库存天然可以被表达为“调拨出库事件已发生、调拨入库事件未发生”的状态。这就是传统流水表做不到的地方——它只能看到两行“库存变动”,看不到这两个变动之间的业务关联。
当时我要给管理端做一个“在途库存”页面,用传统模型我得额外维护一张在途表,然后祈祷它和流水表不会出现数据不一致。换成 REA 之后,在途库存只是一个派生查询:查所有调拨出库事件,减去已关联的调拨入库事件,剩下就是还在路上的货。这个查询不需要额外的写入操作,自然就没有一致性负担。
2.3 中间还加了一些延伸设计
核心模型跑通之后,我补了三样东西,属于“非必需但非常顺手”的设计。
第一是事件与单据的映射表。因为业务上难免有原始凭证,比如采购单、销售单、调拨指令,我加了一张rea_document表,用它和rea_event关联。这样任何一个业务事实都能找到它的源头单据,做审计时会特别舒服。
第二是资源实体拆成“资源定义”和“资源实例”。商品定义是一层,某个具体批次库存又是一层。比如同一种商品,A 店进价和 B 店进价不同,会计口径也不同,那就必须拆成两个资源实例,否则成本核算根本没法做。
第三是对代理的扩展。代理不仅仅是人,也可以是某个逻辑组织。比如“平台补贴”这类没有真实操作者但参与了资金流动的角色,我也给它建一个资源账户,并配上固定的代理记录。这样报表里数据不会因为“没人认领”而悬空。
3. 核心代码实现:事件提交、余额推导与对账逻辑
3.1 事件提交要保证事务边界
REA 模型在代码实现层面有一个很重要的原则:事件写入与资源状态更新必须处于同一个事务。事件模型容易给人一个误区,以为把所有东西都做成异步事件,最后通过回放来算状态。实际上事务边界如果切得太散,在途数据、重复事件、资源余额短暂不一致都会成为常态。
我这边采用的提交函数,在事务里完成三件事:
- 校验事件参数,比如调拨出库方是否有足够库存。
- 写入
rea_event和rea_event_resource关系记录。 - 更新资源当前余额表,也就是物化后的库存表。
顺手把伪代码贴在这儿,大家感受一下边界。
def apply_event(event_type, event_no, occurred_at, agent_id, resource_lines): with transaction(): event = Event.create( event_type=event_type, event_no=event_no, occurred_at=occurred_at, agent_id=agent_id, ) for line in resource_lines: EventResource.create( event_id=event.id, resource_id=line.resource_id, direction=line.direction, quantity=line.quantity, unit_amount=line.unit_amount, ) balance = ResourceBalance.fetch(resource_id=line.resource_id) balance.quantity += line.direction * line.quantity balance.save()这里一定要先写事件表,再更新余额。因为一旦后续发生问题,余额可以靠事件表重算,而事件表本身永远是我们信得过的根源。事件表只追加、不更新、不删除。任何需要修改业务事实的情况,都通过新增“冲正事件”实现,而不是去编辑原事件行。刚开始团队觉得这种方式麻烦,后来线上出过一次数据错乱,复盘的时候大家一致认为“只追加”这个约束是救命稻草。
3.2 余额查询与历史溯源用 SQL 直观表达
一个高频需求是“实时库存余额”。我建议直接建一个物化余额表,事件提交时同步更新,查询时直接读这张表。但如果出现一次意外,需要核对余额,怎么把事件流重新摊开?
下面的 SQL 可以按资源查出时间点之前的净变化量:
SELECT er.resource_id, SUM(CASE WHEN er.direction = 1 THEN er.quantity ELSE -er.quantity END) AS quantity_delta, SUM(CASE WHEN er.direction = 1 THEN er.unit_amount * er.quantity ELSE -er.unit_amount * er.quantity END) AS amount_delta FROM rea_event_resource er JOIN rea_event e ON e.id = er.event_id WHERE e.occurred_at <= %(as_of)s GROUP BY er.resource_id;这段 SQL 看着简单,实际上覆盖了很大一部分对账需求。把“截至某个时间点”的条件去掉,你甚至能拉出所有资源的全量事件明细。库存余额永远不是“读一张表”,而是“从一个可复算的公式推导出来”。
这个设计在业务上带来的直接收益是:运营同学问“为什么库存是 85 而不是 100”,我不再需要对着备注猜,直接查这 15 件差异由哪些事件造成。如果 85 这个数字错了,还能够在几分钟内定位到具体事件、具体代理、具体单据。
3.3 幂等控制与事件重放
事件模型最担心的一个问题就是“同一笔业务被重复提交”。比如用户支付成功后,支付回调被消息队列投递了两次;或者出库单被仓库系统重复推送。若没有幂等控制,库存就会被扣两次。
我在rea_event上加了event_no的唯一索引,并把“业务方单号”直接作为事件号。假设一个订单的渔获有多个动作组成,我会生成事件号时拼接业务主体的单号和动作类型,比如SALE-ORDER-20250101001-PAY就天然保障同一订单的同一动作只能存在一次。如果同一事件号写入两次,第二次会被数据库唯一索引挡住。
重放则适用于已经出过一次线上事故、需要恢复状态的场景。因为所有已发生事件都躺在rea_event里,我可以把事务内的“更新余额”改为“从零开始重新计算”。恢复动作本质上不是改余额,而是基于事件流重建快照。这种方法比手动修改数据安全得多,也是事件驱动模型最让人有底气的地方。
4. 从旧模型迁移到 REA 的实操策略
4.1 先别重构,先用“双写”观察差异
我见过很多团队改造核心模块时,一上来就是迁移大戏:停服、建表、导数据、比对、上线。这个做法不能说完全不行,但一旦出问题,定位故障的时间成本极高。我更推荐的方式是存量并行、增量切换。
具体操作分三步:
- 存量快照:在某个业务低峰期,把现有所有资源的账面余额做一次快照,作为 REA 模型的初始余额。注意,这个初始余额必须由系统负责人签字确认,因为它将成为后续所有对账的基线。
- 增量双写:新旧两套逻辑同时运行,新模型只写
rea_event和rea_event_resource,不参与业务决策。每天跑一次对账任务,比较旧模型余额和 REA 推导余额是否一致。 - 灰度切换:双写观察一段时间后,如果差异率为零或差异均可被解释,再逐步把库存查询、销售扣减、调拨逻辑切到新模型上。
我当时还写了一个对账任务,定时从旧表读取库存,与 REA 物化余额比较,差异超过阈值直接告警到群。整个过程双写跑了将近三周,中间发现过 4 类差异,全是旧模型的历史脏数据,比如重复导入的调拨单、手动修改库存没有留痕等。等这些问题一个个处理完,迁移才稳住。
4.2 历史流水转换为事件时要注意什么
历史数据转换是迁移里最容易被低估的工作。旧流水表里有一堆“不明原因”的调整记录,这些记录在 REA 模型里是不能直接原样搬过去的,因为 REA 的事件必须有业务含义、有代理、有资源。如果一条流水只有“库存 +5”没有对应的业务说明,它在 REA 里只能是一个“盘点调整事件”,代理必须是盘点员或者系统管理员,而且必须补充备注。
转换过程中我建议遵循三个规则:
- 每一种旧流水类型,必须映射到一个明确的 REA 事件类型,不存在“无法归类”的中间态。
- 每一张旧流水,必须找到对应的代理,找不到就指定一个系统默认代理并打上标签。
- 所有转换脚本必须是幂等的,能够重复执行,方便调整映射规则后重新生成事件数据。
当时我的历史数据里有一种“库房内移动”,旧模型把它记为一正一负两条流水。转换到 REA 时我犹豫过,是把它当成两个独立事件还是一个调拨事件对。最后决定统一为调拨事件对,并且同一个调拨事件对共享一个event_no。因为这样后续查询“同一批次物理移动到哪儿去了”时,只需要按事件号聚合,而不用靠时间戳去猜对应关系。
4.3 哪些模块不适合一上来就切到 REA
REA 不是万能药。如果你现有系统的主要诉求是极致的并发写吞吐,比如每秒几千次库存扣减,REA 的“事件 + 关联表”结构确实会比单行库存更新的开销更大。这种场景我建议保留物化余额表作为写入通道,REA 事件流作为异步审计通道,而不是让每次扣减都走全链路事件。
另外,如果你们目前的报表逻辑高度依赖“月末结账”这种强流程,REA 的事件骨架也要和结账逻辑配合,不能只依赖实时推导。月末结账本质上是对一段事件流做“冻结确认”,我在系统里加了rea_period表记录账期,每个事件关联所属账期,结账后禁止冲正,只能开新账期。没有这个概念的话,事件会越积越多,月初和月末的对账口径就会模糊。
5. 常见问题与排查技巧实录
5.1 我踩过的五个典型问题
随手整理一下这次改造中真正遇到过的坑,按出现频率排一下。
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 对账时同一笔调拨在调出店显示库存少,在调入店显示库存多,差额长期挂在在途 | 旧系统在途库存没有结构化表达,无法区分“在途”和“丢失” | 用事件对表达调拨,在途等于调拨出库减去调拨入库 |
| 重复扣库存 | 支付回调或出库单据重复推送,业务方没有幂等键 | event_no唯一索引,事件号为业务单号加动作类型 |
| 库存余额莫名多出一部分 | 旧系统有人手工执行 SQL 修改库存,没留审计痕迹 | 关闭数据库手工写权限,所有变更经 APPLY 函数完成 |
| 报表取数慢 | 直接用rea_event_resource全表聚合,数据量变大后越来越慢 | 建立物化余额表和每日快照表,聚合只跑增量 |
| 跨账期退款导致上个月报表错误 | 退款事件落在新账期,但业务逻辑上要匹配原销售账期 | 在事件上增加“业务归属账期”与“事件发生账期”两个字段 |
第 5 个问题非常值得展开讲。电商业务里经常出现 1 月份的订单在 2 月份被退款,旧模型会把退款记在 2 月份的流水里,导致 1 月份利润虚高、2 月份利润虚低。REA 模型里我在rea_event上单独加了settle_period字段,和occurred_at完全独立。事件发生时间用来排序和推导状态,结算账期用来做财务报表。两个字段独立之后,月结逻辑一下子清晰了很多。
5.2 实用性很强的排查技巧
如果你也是从传统的“两张表对账”切到事件模型,我分享几个个人建议。
千万要保留event_no的可读性。不要把事件号生成得过于抽象,尽量让人能从事件号里看出业务类型和原单号。否则出问题时,你看着一堆随机字符串会对不上业务。
每次事件提交时,在attributesJSONB 里记录“当前操作者、操作来源、操作前资源量”等上下文信息。这些信息不会用于核心逻辑,但在排查“为什么当时会允许这笔出库”时价值巨大。我统计过,线上 50% 的疑难杂症最终都是靠这类上下文信息定位的,而不是靠主表字段。
尽量给每个资源类型建立独立的余额物化表。最初我把所有资源类型的余额都放在一张表里,靠着类型字段筛选,后来发现不同类型资源的更新频率差异极大,锁竞争互相干扰。拆开以后,库存类资源和资金类资源的写冲突基本消失。
5.3 事件流日志需要额外做点“人工友好”加工
最后一点经验,和代码无关,但我觉得比代码还重要。事件模型在技术层面清楚,业务同事看不懂。他们看到rea_event_resource里的 direction 字段是 1 和 -1,完全没有感觉。为了协同顺畅,我加了一张事件视图,把 direction 翻译成“入库/出库/调拨出/调拨入/销售/退款”,并把代理名称与资源名称 JOIN 好。这就是业务方唯一需要看的数据出口。
不要把原始表直接暴露给业务方。他们一旦面对太抽象的模型,就会觉得系统不可信,这是实践中一个很容易被忽略的沟通问题。
最后聊两句我在这个项目里的真实体会
这次把项目代号定为“REA”,说实话一开始我自己心里也没底,因为它不是常见的、有大量现成实例可以参考的技术方案。真正做下来之后,感觉最大的收获不是表结构设计得多漂亮,而是我建立了一种“用事实描述业务”的习惯。传统模型里我们总是追求一个字段能直接回答当前的数值,REA 让我接受了一个更本质的思路:数值只是一个结果,事实才值得信任。
后来遇到任何新需求,我会先问一句:这件事是什么事件?涉及什么资源?谁发起的?这样一问,建模的基本框架就出来了。很多时候,需求方描述一堆“我要加个字段”,其实底层的业务事件早就已经支持了,只是查询视角没有暴露出来。
如果你也在设计库存、订单、资金或任何有“资源进出”属性的系统,不妨从小范围开始试试 REA 的思路。不用一上来就迁移全部,找一个最难受的模块先用事件表达,跑两周看看对账效果。等到你习惯了不靠“备注”来解释业务数据的时候,大概率就不会再想回到旧模型了。