看到标题里孤零零的rea,可能有人会以为我打错了。其实这是我最近在重整一个订单库存系统时反复用到的一套建模思想:REA,取 Resource、Event、Agent 三个单词的首字母。这套模型最早从会计信息系统领域被总结出来,核心一句话就是“不要只存结果状态,要把发生过的每件经济事件记录清楚”。过去我习惯把订单、库存、资金各自建状态表,定期刷余额、对账,结果总是这里差一点、那里少一笔。换到 REA 之后,出库、入库、收款、付款全都变成事件流,库存余额和资金余额都可以从事件流重算,数据口径终于能闭环了。这篇文章把整个思考、落地过程、踩过的坑以及我整理的一套检查清单都写出来,适合做交易系统、领域建模,或者正在被状态一致性折磨的朋友参考。
1. 先从一次“库存账对不上”的项目说起
1.1 传统ERP把库存当成一张表,问题出现在哪
某分销系统刚接手时,数据结构非常典型:商品表、库存表、订单表、支付流水表,库存表里维护一个quantity字段。入库单审核通过,就在代码里执行quantity += 数量;出库单审核通过,就执行quantity -= 数量。看起来顺理成章,可运行几个月后,问题开始排队出现。
第一类是并发问题。两台应用实例同时处理同一个商品的出库单,都读到quantity=10,各自扣掉 1,最后写回 9,实际是应该扣 2 却只扣了 1。加锁能缓解,但锁一多,仓库同事在高峰期就会抱怨列表页转圈。第二类是人为修正问题。因为后面又发现账不平,值班同事直接跑了一条update inventory set quantity = quantity - 5的 SQL,既没有关联单据,也没有备注说明。三个月后,没人能说清楚这个5是从哪来的。第三类是流水断裂。有人偶尔会直接在后台把出库单删除,关联的库存扣减记录跟着消失,数据看起来恢复了“正常”,实际上已经埋下了更大的坑。
等到月底对账,财务拿支付流水说“客户付款 200 万”,仓库拿库存台账说“出货只有 180 万”,两边口径根本对不上。我意识到问题的根源不是某个程序 bug,而是建模方式就不适合这类核心业务。库存余额不应该被当成一个可持久化、可原地修改的状态字段,应该被当成“一系列事件累加后的结果”。
1.2 REA的三件套:Resource、Event、Agent
REA 模型把每个业务动作拆成三个核心组成部分:
| 要素 | 含义 | 业务例子 |
|---|---|---|
| Resource(资源) | 有经济价值、可以被交换或消耗的事物 | 商品、现金、应收账款、积分 |
| Event(事件) | 在某个时点发生的、让资源数量变更的动作 | 入库、出库、收款、付款 |
| Agent(参与者) | 参与事件、承担角色的人或组织 | 客户、供应商、仓管员、财务系统 |
拿一次普通买卖来体会:客户买走一件商品,商品是资源,销售出库是事件,客户和商家是参与者。客户随后付款,现金又变成资源,收款是另一个事件,客户和商家还是参与者。两个事件一前一后,完成了“货出去、钱进来”的交换闭环。REA 特别强调这种经济交换(duality)关系:一笔业务通常不会只有一个孤立事件,它往往由“转出资源”的事件和“转入资源”的事件配对出现。
传统建模里,我们习惯给订单加状态字段:待支付、已支付、待发货、已发货,再通过状态机转移来表达业务变化。REA 的做法则完全不同:状态字段本质上只是一个“视图”,真正的业务事实是那条事件记录。出库事件发生了,库存自然减少;收款事件发生了,订单的支付状态自然应该变为已支付。我们不直接去改订单状态,而是让状态从事件里“长出来”。
1.3 为什么必须以事件为中心,而不是以单据为中心
单据在现实世界里很重要,它有编号、有责任人、有审批流程。但单据只是事件的载体,同一个入库单可能对应多件不同商品、多次分批到货,如果直接把字段都挂在单据上,数据非常容易变成“一张大而全的状态表”。
REA 建议把事件当作唯一的事实源,重新审视“状态”。库存余额、应收余额、订单履约进度,都只是事件流的聚合结果。这个思路和事件溯源的思想很接近:库存表像一个拍好的照片,事件流像连续拍摄的底片。照片可能会被误改、会过期,但底片只要完整,任何时刻都可以重新洗出一张正确的照片。生产环境里,我们不一定需要把所有状态都实时重算,可以保留物化视图或缓存,但必须保证“状态可以被事件流重建”。
2. 用REA重构一个订单系统的完整建模过程
2.1 第一步:把业务描述改写为事件流
想落地 REA,第一步不是画数据库表,而是先把业务描述改写成“事件清单”。还是用那个分销系统举例,核心流程是:
采购备货 → 仓管入库 → 客户下单 → 客户付款 → 仓库发货 → 客户收货 → 售后退货
改成 REA 事件流之后会长成这样:
- 采购单审核通过:供应商确认了将会供应商品,产生一个“采购承诺事件”,它还没有实际改变资源数量,但已经在资源侧预留了仓位。
- 仓管入库:商品实际进入仓库,产生“采购入库事件”,增加“商品数量”资源。
- 供应商开票、系统付款:产生“付款事件”,减少“银行存款”资源。
- 客户下单:产生“销售订单事件”,在商品资源上做“已承诺出库”的占用记录,防止超卖。
- 客户付款:产生“收款事件”,增加“银行存款”或“现金”资源。
- 仓库发货:产生“销售出库事件”,减少“商品数量”资源。
- 客户退货:产生“退货入库事件”,把商品数量加回来,同时伴随“退款事件”,减少银行存款。
这里有个很关键的区别:“采购入库”和“付款”是两个不同事件,一个影响商品资源,一个影响资金资源,但它们通过业务单据关联在一起。REA 不会把它们强行塞进一张“采购总表”,而是让它们各自表达资源变化,再用同一笔source_ref指回原始单据。
2.2 第二步:把资源、参与者、事件映射成表结构
有了事件清单,表结构就非常直接。我建的是一套相对精简但不失核心的表达方式,主要包括五张表:resource资源表、agent参与者表、event事件表,以及两张关联表,分别表达事件实打实让资源变化了多少、每个事件都有哪些参与者参与。
下面是一份简化的 PostgreSQL 建表脚本:
-- 资源表:商品、现金、积分等一切有价值的事物 CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- goods, money, points unit VARCHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 参与者表:人员、部门、外部客户、系统账户等 CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL -- person, organization, system ); -- 事件表:业务发生的事实 CREATE TABLE event ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- purchase_receipt, sale_dispatch, cash_receipt occurred_at TIMESTAMPTZ NOT NULL, description TEXT, source_ref VARCHAR(128) NOT NULL, -- 原始单据号或第三方流水号 source_type VARCHAR(32) NOT NULL, -- order_no, payment_no event_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', reversal_of_event_id BIGINT REFERENCES event(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (source_type, source_ref) ); -- 事件对资源造成的变化:正数表示流入,负数表示流出 CREATE TABLE event_resource_flow ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES event(id), resource_id BIGINT NOT NULL REFERENCES resource(id), quantity_change NUMERIC(20,4) NOT NULL, unit_price NUMERIC(20,4) ); -- 事件与参与者的关系:同一个事件可以有多个参与者 CREATE TABLE event_agent_relation ( event_id BIGINT NOT NULL REFERENCES event(id), agent_id BIGINT NOT NULL REFERENCES agent(id), role_code VARCHAR(32) NOT NULL, -- customer, supplier, warehouse_keeper PRIMARY KEY (event_id, agent_id, role_code) );这张设计的要点是:event_resource_flow用quantity_change正负号表达资源流入流出,而不是单独建一张“库存变动表”再区分加还是减。这种表达最接近会计里的借方贷方思想,SUM 一下就能算出余额,而且天然支持“冲正”。event_agent_relation则把参与者角色独立出来,同一个仓库主管既可以录入入库,也可以复核出库,不需要重新建一条 agent。
2.3 第三步:用 REA 查询库存数量
重构成这套表以后,“查询某个商品库存余额”就变成了对事件流的聚合,而不是读一个可变字段:
WITH balance AS ( SELECT erc.resource_id, SUM(erc.quantity_change) AS current_balance FROM event_resource_flow erc JOIN event e ON erc.event_id = e.id WHERE e.event_status = 'ACTIVE' GROUP BY erc.resource_id ) SELECT r.code, r.name, COALESCE(b.current_balance, 0) AS balance FROM resource r LEFT JOIN balance b ON r.id = b.resource_id WHERE r.resource_type = 'goods';如果想知道某一天发生了什么,直接按event.occurred_at过滤事件;想知道某个商品的历史进出,就查event_resource_flow关联该资源 ID;想知道具体是哪个仓管员入库的,就通过event_agent_relation过滤。查询路径非常一致,任何统计口径都能回到同一条事件链上。
线上系统如果担心SUM太慢,可以按资源 ID 做增量物化视图,或者维护一张“余额投影表”,在每次新事件落库后异步更新。但永远要保留事件表这个最终真相,投影表只是缓存,随时可以被重建。
2.4 重构后的实际效果
重构完成之后,最明显的变化是对账方式变了。原来对账是两边各拉一张“现状表”再做减法,对不上的时候只能靠吵架;现在对账是双方都从同一套事件流重新聚合成余额,如果两边余额不一致,差异一定来自事件流本身,可以一路追到源头。过去需要手工修库存数据的地方,现在只允许通过新增事件来修正。整个系统的可观测性高了不止一个档次,新同事接手时也不再需要对着几十个“神奇的状态字段”猜业务逻辑。
3. 落地时踩过的三个坑和排查链路
3.1 事件记录不能 update,只能新增“冲正”事件
第一次实战时,我曾在一张event_resource_flow表上翻了车。当时仓管员把实际入库 500 件录成了 5000 件,发现错误后,我的第一反应是执行 update,把那行quantity_change从5000改成500。改完之后,当前库存余额看起来对了,可下游统计模块和报表却乱了。因为统计模块在事件发生那一刻已经汇总过一次5000,你把它原地改掉,所有基于历史事件算出来的数据全部错位。
正确的做法是:保留错误事件,新增一条“冲正事件”,数量是-5000,然后再新增一条正确事件500。这样整条事件流依然完整,历史的统计报表虽然曾经看到过一个错误值,但后续可以通过冲正事件把影响抵消。承担审计职责的人一眼就能看到哪里出了问题、是谁改的、后续怎么修正。项目里我给event表增加了reversal_of_event_id字段,冲正事件通过这个字段指回原事件,形成一条可追溯的撤销链。
3.2 参与者的“角色”和“人”必须分开建模
第二个比较隐蔽的坑来自event_agent_relation。最初我图省事,直接把agent_id指向用户表里的用户,角色字段只写了一个备注,没有独立建模。后来报表要统计“每个采购员的采购金额”和“每个仓管员的入库量”,发现问题来了:同一个人早上做采购审核,下午做仓管复核,他既可以是采购员也可以是仓管员,单纯按人统计导致部门报表张冠李戴。
正确的做法是把“参与者身份”和“参与者角色”拆开。agent表只存这个人是谁,event_agent_relation里的role_code存他在这个事件里扮演什么角色。同一个 agent 在同一类单据中可以出现多次,对应不同角色;同一个角色也可以由不同 agent 承担。这样权限、报表、审计都变得清楚,也方便将来接入外部系统时把“系统参与方”建模为 agent。
3.3 历史数据迁移:不要试图把旧流水揉成新事件
重构系统时最头痛的是历史数据。老系统里没有完整的事件流,只有一张库存余额快照和一些破碎的流水。一开始我天真地想逆向推导,把所有旧流水翻译成标准 REA 事件,结果几百条没有关联单据的记录根本无法还原。
后来我换了思路:做一个“可信起点迁移”。确认一个迁移时点,把老系统当天的库存余额写成一条“期初建账事件”,一次性把余额作为资源流入记入事件流;时点之后的新增业务,再严格按标准事件落表。这样做的结果不是 100% 重放历史,而是建立一个可信的起点,从起点之后的所有数据都是干净的。迁移完成后,再用 SQL 做校验:期初余额 + 期间所有流入事件 - 期间所有流出事件 = 老系统期末余额。如果等式不成立,优先怀疑移植时少录了事件,而不是去修改期初快照。这个校验公式后来被我写成了上线检查脚本,每次迁移都要跑一遍。
4. 用REA反推微服务边界和业务校验规则
4.1 事件驱动架构里的服务边界:按资源变化闭环划分
REA 建模给我的另一个启发是微服务边界怎么划。很多团队都在纠结“订单服务”“库存服务”“支付服务”该不该拆分,其实不妨回到资源维度来看:每个服务负责一组资源的生命周期,其他服务通过事件来消费资源变化。
比如“商品库存”和“银行存款”是两个完全不同类型的资源,分别归库存服务和资金服务负责。订单服务只负责产生“销售订单事件”和“收款指令”,库存服务收到出库事件再扣减库存,资金服务收到收款事件再更新余额。服务之间不共享库表,只共享事件的语义。这样好处是每个服务都能独立演进,坏处是分布式一致性更难处理。我通常配合“事务发件箱”模式,先把事件写入本地数据库的 outbox 表,再通过消息中间件发布,保证“业务操作”和“事件发布”至少不丢。
4.2 业务规则从“表现在状态”改为“事件在约束下成立”
传统校验库存是否充足,代码大概长这样:
if inventory.quantity < order.quantity: raise OutOfStockError()换成 REA 表达之后,判断逻辑变成“在没被冲正的事件流里,入库总量减去出库总量是否还能覆盖本次承诺”:
SELECT COALESCE(SUM(quantity_change), 0) AS available FROM event_resource_flow erc JOIN event e ON erc.event_id = e.id WHERE erc.resource_id = :goods_id AND e.event_status = 'ACTIVE';如果available不够,就拒绝这次出库或要求先补货。这个约束天然把“历史错误冲正”的事件也算进去,不会出现“那个商品库存看着是负数,却还能发货”的怪现象。更重要的是,业务规则变得可回溯:任何校验失败,都可以指出是基于哪些事件得出来的结论,而不是基于一个不知何时被谁改过的数字。
4.3 一份用于评审业务模型的检查清单
最后分享我沉淀下来的一份检查清单,现在每次做业务建模评审都会用它过一遍。不需要每条都机械执行,但任何一条答不上来,都说明场景里的业务语义还没想透。
| 检查项 | 说明 |
|---|---|
| 每个业务动作是否都能命名成一个事件? | 如果某个动作没有事件、也不改变任何资源,它可能是查询或外部系统动作,不应该塞进核心链路。 |
| 事件是否包含“发生时点”而不是“入账时点”? | 用occurred_at记录真实业务时间,不能拿数据库当前时间糊弄。 |
| 资源余额能否被事件流完整重建? | 如果删掉余额表就查不出余额,说明建模还不完整。 |
| 事件是否不可变,冲正是否通过新事件完成? | 任何 update 核心事件字段的操作都应该被禁止或至少被审计。 |
| 参与者是否区分了角色和组织实体? | 不要把角色直接写死在用户表上。 |
| 是否有幂等机制防止重复事件? | 通过source_ref唯一约束或消息去重,保证重放安全。 |
| 业务规则是否基于事件约束而非状态字段? | “库存够不够”应该由事件流算出来,而不是读一个状态列。 |
这套模型并不是银弹。业务特别复杂、强流程审批特别多的场景,完全套 REA 会让建模成本增加,而且团队成员需要时间适应那种“不直接改状态”的思考方式。我的做法是先用这套清单做评审,再把 REA 落实到最关键的资源域,比如库存、资金、往来账,而不是一上来就把所有表全部推翻。现在只要启动新的交易类项目,我都会在第一天画一张 REA 草图,团队成员问“这个状态是从哪来的”,我就能指着那条该死的事件流说:都在里面。