如果你在某次技术讨论里听到“REA”,又在另一位的会议备忘里看到随手缩写的rea,别急着划过那一行。这个词展开是Resources-Events-Agents,也就是资源-事件-代理,一套从1980年代起就被系统化研究的业务建模思想,这几年持续进入订单、库存、财务一体化系统的设计语言。我最近用REA把一个中等规模的进销存系统核心链路重新建模,完整走完评审、开发、上线和对账,收获很大,也踩了不少坑。这篇不是学术论文,是一份从数据库结构、业务事件、对账报表三个角度重写过的实战笔记,适合后端开发者、数据架构师,以及所有正在为企业里“对不上账”而头疼的产品同学。
1. 为什么我放着熟悉的科目表不用,先去梳理“业务事实”
很多业务系统刚起步时,团队习惯用最快的速度把几个核心表搭出来:订单表、支付表、库存表、结算表,然后各自维护状态字段。一开始看着很清晰,等到订单量上来、业务方开始要求“从下单到回款全程追溯”的时候,问题就暴露得又快又难看。
1.1 传统“流水+状态+科目”三个抽屉的通病
我先说一个几乎都会遇到的现场。订单系统里有个订单状态,从“待支付”一路走到“已完成”;财务系统里有个结算状态,从“待确认”走到“已清账”;仓储系统里又有发货状态,从“待出库”走到“已签收”。三个系统各管一段,各自存着一份状态,状态之间却没有一条天然的、可追溯的连线。于是每次月底对账,就需要拉一堆临时SQL、Excel,靠人肉核对“哪些订单已经发了货,但财务还没收款”“哪些货已经签收,但库存还没扣减”。
问题的根源不是某个人写错了代码,而是初始建模时就没有把“订单创建”“付款成功”“商品出库”这些动作当成彼此咬合的经济事件。它们散落在不同的技术模块里,有的存状态机,有的存时间戳,有的干脆用备注字段记一笔。REA的做法完全不同,它先问一个问题:这个业务里真实发生的“事件”是什么?这些事件让哪些“资源”发生了流入、流出?是哪些“代理”发起的?
这种提问方式听起来简单,真正落库时你会发现,它逼着你把业务对象和业务流程的关系全部梳理干净,而不是用一组状态字段掩盖流程的模糊地带。
1.2 同一件事在REA里被拆成两问:发生了什么,谁参与了
拿一个最普通的零售场景举例:客户下单付款。你可以在订单表里记录订单编号、金额、状态,也可以在REA模型里这样描述:
- 发生了一个“销售”事件;
- 这个事件使一项“库存商品”资源流出;
- 这个事件使一项“应收账款”或“现金”资源流入;
- 内部代理是“销售员”或“系统自动分配”,外部代理是“客户”。
这里的关键动作是把“记账”和“记录事实”分开。传统方式倾向于把业务结果压实成一行行的借贷分录,REA则倾向于先把所有经济事实落成事件,再在任何需要的时候派生出账本、余额、科目汇总。好比拍了一卷底片,你可以随时冲洗出黑白照片、彩色照片,甚至可以只打印某个时间段的片段,而不会影响底片本身。
理解这点之后,我对“账”这件事的认知就被重新刷新了。账不是源,它是被加工出来的视图。事件的完整性、资源流动的顺序、代理的参与关系,才是系统真正需要固化的地基。
1.3 什么时候值得动REA
我也要劝退一部分人。如果你的系统只是简单的记录工具,比如一个笔记收藏应用、一个轻量CMS,REA带来的建模成本会明显高于收益。
值得动REA的典型信号是以下几类:
- 业务由多领域协作构成:订单、库存、财务、物流多条链路,彼此之间有强依赖。
- 需要回应审计与追溯:每一个历史操作都必须回答“谁、在什么时间、动了什么、影响哪些资源”。
- 状态容易漂移:不同模块各自维护状态,经常出现两边状态不一致,需要靠对账来纠正。
- 业务扩展频繁:新业务上线时,如果总是要改表字段,说明底层抽象没有抓住共同的业务本质。
REA对这些场景的价值,是提供一种稳定的业务语义层。表结构可以迭代,甚至微服务可以重构,但“资源-事件-代理”这套描述可以被业务方和技术方共用,讨论问题时不再各说各话。
2. 资源、事件、代理这套语言,到底比“表”多出来什么
刚开始接触REA,最容易被三个名词困住。它们不复杂,但和平时习惯里的“表”“字段”“实体”不是一套对应关系。我尽量用大白话把这三个概念说透,再给出一张可以直接对照的关系表。
2.1 资源:不只商品和钱,一切可被经济转换的东西都算
通常情况下,你会把商品、库存、现金、应收款这些当成资源。REA里资源定义得更宽,像积分、优惠券、服务工时、电子合同这类东西,只要它们能被经济事件消耗、转化、交换,就都可以作为资源建模。
资源的一个核心属性是“可计数或可计量”。无论是重量、件数、金额还是积分分值,你总要能在某条事件上表达清楚它流入了多少、流出了多少。很多系统不稳定,就是因为资源建模不干净。比如优惠券系统里,一张券究竟是“资产”还是“负债”语义模糊,导致后续统计口径反复变。REA里没有这个问题,因为每一条事件都要明确资源的一侧方向,而且“流入”与“流出”必须配平。
2.2 事件:有经济后果的动作,而不是“状态机里的一次跳转”
事件是REA的核心。一个事件至少要满足两个条件:第一,它真实发生,并且有可观察的时间;第二,它会导致至少一项资源流出或流入。
继续拿零售系统举例,“下单”这个动作在传统模型里常被当成状态变化,但从REA角度去看,如果“下单”本身还没有触发资源的实际流出或流入,严格来说它还不是一个经济事件。通常要到“支付成功”或“发货确认”的时候,资源才真正开始移动。
把“事件”和“状态变化”彻底分离,是我获得的最大经验。以前设计系统,我总是想给订单对象加一个“已发货”状态,然后通过定时任务扫描更新。REA让我改成一种“事件流”思维:不是先有状态才能证明动作发生了,而是先有动作发生,状态只是动作累积之后的结果。
2.3 代理:内部代理与外部代理,责任链在这里成形
代理可以是用户、客户、员工、供应商,也可以是部门、仓储中心、合作方。REA把代理分成内部和外部,是为了清晰表达责任关系。
一个“销售”事件,外部代理是客户,内部代理是签约或推送订单的销售员。“收款”事件,外部代理是客户的支付账户或银行,内部代理是财务专员或自动收款规则。内部代理与外部代理同时出现在事件上下游时,审计线索就变得非常自然。你不需要专门建一张“操作日志表”去记录谁做了什么,因为每个事件本身就已经带好了参与者信息。
2.4 三件套关系汇总:一张表格看清核心结构
| 关系 | 一方 | 另一方 | 业务含义 |
|---|---|---|---|
| 参与 | 事件 | 内部代理 | 谁负责执行内部动作 |
| 参与 | 事件 | 外部代理 | 谁从外部促成或接收 |
| 流出 | 事件 | 资源 | 什么被消耗、减少、转出 |
| 流入 | 事件 | 资源 | 什么被获得、增加、转入 |
| 双互 | 事件 | 事件 | 流出资源的事件与被换取资源的事件配对 |
这些关系不需要在数据库里单独建一堆布尔字段。你可以在事件表里加agent_id、resource_id、direction这些维度,也可以拆出参与明细表、资源流明细表,然后让事件表、资源属性表和代理主数据表通过引用关联起来。关键是逻辑上让每一笔事件的“出”都有对应的“入”,哪怕是时间上有先后的两个事件。
3. 把订单履约链路按REA重新建模:完整操作实例
理论说再多,不如直接动手。我拿一个通用订单履约链路举例,范围包括“下单到回款再到退货”这条主流程,完整展示从业务清单到建表的操作过程。
3.1 第一步:列出经济事件,先不急着画表
不要一上手就画ER图,建议先在白板上用业务语言写一遍这段时间内发生了哪些经济事件。按照我的订单履约链路,通常可以得到下面这些:
- 客户提交订单
- 客户支付货款
- 仓库发货,商品出库
- 物流转移,客户或系统确认收货
- 发生退货时,商品重新入库
- 退款事件发生,资金返还给客户
这一步的核心原则是“面向事实,面向过去”。你列出来的事情应该是“已经发生过的动作”,而不是“系统处于什么状态”。很多人第一次做这一步时会不自觉地写成“订单是待支付状态”,我建议把这种话全部翻译成“客户提交订单”“支付成功”这类动作,翻译不过来的地方,往往就是还没有想清楚业务究竟发生了什么。
3.2 第二步:为每个事件匹配资源与代理
这个环节只需要一张二维表,把所有事件与对应的资源流、参与代理放一起检查。
| 经济事件 | 流出资源 | 流入资源 | 内部代理 | 外部代理 |
|---|---|---|---|---|
| 客户提交并支付订单 | 应收账款 | 库存商品 | 销售系统 | 客户 |
| 仓库发货 | 库存商品 | 物流在途库存 | 仓库员工 | 物流承运方 |
| 客户确认收货 | 物流在途库存 | 客户已收商品 | 仓库员工 | 客户 |
| 客户退货入库 | 客户已收商品 | 可售库存 | 质检员 | 客户 |
| 退款 | 现金 | 应付退款 | 财务专员 | 客户 |
这张表的好处是所有人一眼就能看到业务链路的骨架。就连产品经理和财务同事也能看懂,任何一个环节上的“资源流入流出”没有闭环,就意味着业务流转有问题。
3.3 第三步:建立事件之间的“双互”关系
REA里一个重要的思想是“一进一出要配对”。销售事件中,商品流出,对应的流入可能是现金,也可能是应收账款。这个配对关系叫双互关系。把它显式建模,可以在业务上天然实现“对账”和“平衡”。
我建议在主外键层面别搞太复杂,核心是给事件表加一个字段,比如paired_event_id,专门记录“这个事件和哪个事件共同组成一次经济交换”。比如:
- 销售出库事件 paired with 收款确认事件;
- 发货事件 paired with 收货确认事件;
- 退货入库事件 paired with 退款事件。
这样,任何一笔货物或钱的流动都能快速找到另一端。到了月底,财务说“有一笔退款没付”,你直接查退货事件里的pair字段,就能定位到应退未退的挂账。
3.4 第四步:落库表结构与核心约束
我按REA思路设计出来的表不会只有三张。通常会有资源主数据表、事件主表、代理主数据表,以及连接事件与资源的资源流明细表、连接事件与代理的参与明细表。下面是一个简化版本的PostgreSQL建表示例,只保留最关键字段,便于理解骨架:
CREATE TABLE resource ( resource_id bigserial PRIMARY KEY, resource_type text NOT NULL, name text NOT NULL, unit text ); CREATE TABLE agent ( agent_id bigserial PRIMARY KEY, agent_type text NOT NULL, -- internal / external name text NOT NULL ); CREATE TABLE event_record ( event_id bigserial PRIMARY KEY, event_type text NOT NULL, occurred_at timestamptz NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), paired_event_id bigint REFERENCES event_record(event_id), created_by bigint REFERENCES agent(agent_id) ); CREATE TABLE resource_flow ( flow_id bigserial PRIMARY KEY, event_id bigint REFERENCES event_record(event_id), resource_id bigint REFERENCES resource(resource_id), direction text NOT NULL, -- inflow / outflow quantity numeric NOT NULL, amount numeric NOT NULL ); CREATE TABLE participation ( event_id bigint REFERENCES event_record(event_id), agent_id bigint REFERENCES agent(agent_id), role text NOT NULL, PRIMARY KEY (event_id, agent_id, role) );这里有几个值得注意的字段设计,我踩过教训:
occurred_at表示业务实际发生时间,received_at表示系统记录时间,两列一定要分开。因为很多历史数据补录时,业务发生时间可能远早于录入时间。如果只存一个字段,审计时就会彻底失真。event_record与resource_flow分开,是因为一个事件可能同时涉及多项资源流动。比如退货事件,可能同时让“商品库存”流入,让“应付退款”流出,一个事件要对应多条资源流。participation里的role字段很重要,比如同一个代理可能既是“审批人”又是“操作人”,角色不记录,后面查“谁批准了这次异常退款”就无从下手。
3.5 第五步:写一个可对账、可回放的基础查询
完成建表之后,我通常会写一个“资余额重算”视图,作为对账工具。基本思路是:不直接存余额,而是把所有资源流动汇总后,按条件过滤出来。
SELECT r.resource_id, r.name, COALESCE(SUM(CASE WHEN rf.direction = 'inflow' THEN rf.quantity ELSE -rf.quantity END), 0) AS current_balance FROM resource r LEFT JOIN resource_flow rf ON rf.resource_id = r.resource_id LEFT JOIN event_record e ON e.event_id = rf.event_id WHERE e.occurred_at < :check_time GROUP BY r.resource_id, r.name;这段SQL可以回答“在某个时间点,某类资源存量是多少”。如果业务需要限制“不能负库存”,就针对这个视图或者底层明细加约束,让事件落地前先校验资源是否充分。这比在业务代码各个角落塞状态判断要集中、干净得多。
4. 真正上线前,最容易掉进去的五个坑
REA最让人兴奋的部分,是建模时把业务事实理得特别清晰。但它不是银弹,落地过程中有一堆暗坑,我把它对应成五个具体场景,每一个都是在实践中交过学费的。
4.1 事件时间与系统接收时间没有分开
你可能觉得这是小事,直到有一天你遇到换库、补单、跨时区同步的应用场景。一次批量导入去年历史订单时,如果只有created_at,系统时间会覆盖掉业务真实发生时间。之后所有报表都不准,所有对账也没法做。
我现在的习惯是任何事件表都必须有两个时间字段:occurred_at和received_at。前者由上游业务提供,后者由当前系统自动填充。补录数据时,只更新occurred_at,严禁动received_at。这样同时保留了“过去发生事实”和“系统知道该事实的时间”两层真实信息,在审计场景中非常重要。
4.2 只有事件还不够,资源存量与业务规则仍需要物化
真正实现REA时会有一个错觉:既然资源余额可以通过事件流重算,那就不需要维护任何存量。但生产环境里,库存扣减、库存预占、信用额度校验这些都是高频操作,每次下单都全表扫描过去事件来做实时计算,性能和复杂度都不可接受。
我的建议是:保留一个物化的存量表或缓存,实时维护余额,但它只是“派生数据”。我们不能把它当作事实来源,必须让事件明细表作为它的还原底稿。如果存量与事件重算结果不一致,以事件重算为准,并追溯物化逻辑的bug。简而言之,要有事实层和派生层两层结构,别混为一谈。
4.3 事件本身不可变,业务纠错必须用补偿事件
传统系统做错了一笔账,最简单的方式是定位到那行记录,直接改掉。REA模型里这个操作是禁区。事件一旦形成,就是历史事实,应该保持只读。
如果业务需要纠错,正确姿势是新增一个“反向事件”,比如原事件是“发货出库”,纠错事件就是“退货入库”;原事件是“收款”,纠错事件就是“退款”。这样,所有历史链路都能完整解释“本来发生了什么”“后来为什么被推翻”。查账的时候,人们看到的是真实纠错流程,而不是被抹去的痕迹。
这一点不仅适用于财务合规,对一个团队的技术文化也有很大影响。当你意识到所有修改都能被审计时,乱改数据的手就会下意识收敛很多。
4.4 代理主数据不一致,责任链会把问题扩大
早期建模时,我只关注了资源流,代理参与关系做得很粗糙。后来要做“哪个销售渠道贡献了多少毛利”,才发现同一个客户在两个系统里分别叫“A科技”和“A科技有限公司”,导致代理关联不上,报表金额互相打架。
解决方法是把内部代理和外部代理都落到独立的主数据层,至少用统一编码、名称、类型三个核心字段收敛。代理主数据不要存在订单表里,而是通过代理表引用。只要代理表语义统一,无论以后的团队怎么拆分系统,至少知道“人”与“组织”这一层是稳定的。
4.5 存量数据迁移不要一蹴而就,要按事件可还原性逐段映射
如果系统不是从零开始,而是从老系统中改造,不可避免地会遇到历史数据问题。千万不要设计好新表后,指望一夜之间把所有老数据全迁移进去。
我做过几次类似迁移后的结论是:先划定一个焦点时间,从当前时间点向前迁移有限窗口,比如三个月,三个月之前的只保留汇总数据,不做事件级回放。因为历史数据往往来源很杂,有些订单甚至找不到完整的时间、代理、资源流记录。强行把它们硬塞进REA,反而制造出一堆质量很差的假事件,后续追查时比没有还麻烦。
迁移过程中,建议每次只迁移一条业务子链,例如先迁移“订单-发货-收货”,验证对账通过后,再迁移“收款-退款-清账”。迁移前后各拿一个月的财务汇总数据做对拍,差异落在可解释范围内再放量。
5. 把REA放进事件驱动架构和微服务里
很多团队一听到“事件”,就自然联想到消息队列、事件驱动架构。REA确实很容易和这种架构结合,但它和消息中间件里的“事件”不是同一个层面的东西。前者是业务账本的结构思想,后者是系统集成和通信机制。我用一段完整经历来说明怎么把它们配合好。
5.1 别把业务事件和消息事件混为一谈
在微服务架构里,经常看到订单服务发出一个order.created消息,支付服务发出一个payment.success消息。如果你只是把消息转发到不同消费者,消息本身就被当成事件了。
REA的做法更加苛刻:每一个需要落账的业务事实,不止要被发送,还要被持久化进本地事件表。也就是说,业务事件表才是账本的事实来源,消息只是把账本事实传播给其他模块的载体。否则你只消费了消息却没留底账,等到消息过期或者重放顺序错乱,整个系统就会失去一致性的证据。
5.2 先写事件,再发消息,顺序必须固定
我早期犯过一个错:先调第三方支付接口,再把结果发消息,最后才写本地事件表。结果支付接口回调成功,消息也发出去了,但本地数据库连接超时,事件表没写成。下游库存和财务都按消息执行了动作,本地却缺少这笔依据,对账怎么都对不平。
后来统一成固定顺序:在同一个本地事务里先完成业务校验,写入事件表和资源流明细,然后提交事务,最后才向消息队列发布消息。既然事件表落地成功了,即使消息发送失败,也可以通过本地事件补偿任务重新推送,保证下游最终看到同一事实。
5.3 报表读模型从事件重算,而不是从业务数据库到处拉数据
把REA落到微服务后,我最喜欢的一点是报表模型可以独立演进。底层事件事实稳定之后,上层的财务科目汇总、经营分析报表,可以从事件表派生不同的读模型,支持不同口径的查询。
比如应收账款账龄分析,不需要在业务事务表里做复杂聚合,可以直接基于“销售事件+收款事件”的配对关系,计算出哪些销售事件尚未配对到收款。这种读模型可以随时删掉重建,不会破坏事实层。算错了就修读模型,而不是去改账目源头。
5.4 用一套业务词汇表统一团队语言
微服务团队之间最大的痛点是术语不一致。订单团队说的“开票”,在财务团队那里可能是“确认收入”;库存团队说的“出库”,在物流团队眼里是“发货完成”。哪怕大家都是写代码的,业务语义的裂缝照样会造成数据裂缝。
REA给团队提供了一套中立的核心词汇:资源、事件、代理、参与、双互、流出、流入。所有团队在描述业务时,都尽量翻译成这套词汇,讨论“这个事件流出什么资源,流入什么资源,由哪些代理参与”就不会各讲各话。这并不是要禁锢团队语言,而是给跨团队沟通提供一个公共锚点。
6. 如果再做一遍,我会问自己这五个问题
叙事到这儿,该回到一个更朴素的问题:你做的系统,到底是真的在用REA,还是只是套了个壳?
这里给出我经常拿来检验自己的五个问题,每个都不需要看架构文档,直接问业务和技术团队即可。
6.1 能从事件表完整回放一件事的前因后果吗
随便选一笔历史订单,能不能回答这些问题:谁下的单?当时用了哪项资源?货是何时发的?钱是何时收的?中间是否发生过退货、退款、撤销?
如果答案是可以,那说明事件层已经足够完整。如果还需要到处翻操作日志、甚至翻Excel,基本可以断定REA落得不够彻底。
6.2 增加一种新业务类型,需要改表还是改事件
新业务上线,比如新增“会员积分抵扣”或“赠品换购”,你需要改哪一层?
理想情况是:资源主数据和事件类型都天然支持,只需新增具体事件类型和相应的双互关系,不需要改动表结构。如果你发现自己需要频繁加字段来支撑新业务,往往说明抽象层还没拉高到“资源流”的级别。
6.3 余额是存储出来的,还是能被事件重算出来
看系统里的库存余额、应收余额、积分余额,它们是只有一条存储记录,还是可以完全从资源流明细里重算出来?
如果你能接受删除余额缓存后,通过事件重算恢复正确值,说明事实层没有被派生层绑架。如果你做不到,说明系统实际上还是以可变状态为中心,REA只是表面文章。
6.4 发现错误时,是修改历史事件,还是新增冲销事件
这一点最能检验团队的工程文化。如果还有人习惯直接UPDATE历史事件,哪怕你画了再好看的架构图,都不能说应用了REA。
正确做法是新增一个冲销事件,让它与旧事件清晰配对。这条准则看起来简单,真正落地时,需要开发规范、Code Review、数据权限、报表口径等一系列机制共同保证。
6.5 团队里的每个人都能解释什么是资源、事件、代理吗
最后这个问题最苛刻,但不是要求人人都背概念,而是要看他们讨论问题时是否在用这套语言。新人进了团队,会议上听到“这笔销售事件配的是哪个流入资源”,能不能很快理解?
如果大家仍然只在说“这个表那个表”,架构师要警惕:团队可能还没从“表驱动思维”切换到“事实驱动思维”。
我个人在实际项目里的最大体会是,REA不会让系统上线第一周就变得很炫酷,它真正改变的是三个月后、半年后、一年后系统面对变化时的从容度。第一次尝试,千万不要贪大求全,只选一条“订单-发货-回款”这样的核心链路做模型改造,推演给业务、后端、财务一起对一遍流程。等大家熟悉了这种业务语言之后,你会发现原本需要反复开会对齐的规则,慢慢变成了一种无需特别强调的默认前提。