看到“rea”这个代号的第一反应,我以为是哪个懒人把项目名打错了。直到把三个字母补全为 Resource、Event、Agent,我才意识到这其实是一套业务建模思想。它不是什么开源框架,也不是最新的网络热词,而是一套在会计与业务系统设计中被反复验证的语义模型。最近我在重构一套进销存系统时,就用它理清了那些纠缠不清的订单、库存和流水,今天把这段实践和思考完整拆出来。
如果你正在做订单系统、库存台账、财务对账或者一块后台管理模块,多半会遇到同一类痛点:表结构越加越多、业务口径每个人说得都不一样、月底对账总是差几块钱还找不到原因。REA 模型恰好能帮你在设计源头把“业务事实”和“统计口径”分开,让系统在很长一段时间内改需求不伤筋动骨。下面这篇文章,适合后端开发者、系统架构师,也适合跟研发沟通的产品经理。
1. 拿到“rea”这个代号,我第一反应是查字典
1.1 三个字母背后的完整定义
REA 拆开是 Resource、Event、Agent,中文通常翻译成资源、事件、参与者。它最早是会计信息系统领域用来描述经济业务的一套语义模型,核心思想很简单:商业活动本质上就是一群参与者(Agent)通过经济事件(Event)去交换或转换资源(Resource)。
这句话看起来像废话,但真往业务里一放,很多传统设计就会变形。举个例子,一张销售订单,传统表设计会把客户、商品、数量、金额、业务员、备注全部塞进一张订单表里。当时觉得挺顺,后来发现同一客户可能在不同区域下订单,同一商品在不同活动里价格不同,订单上还要挂各种审批状态、物流状态、开票状态。表越拆越多,但业务逻辑还是绕成一团。
用 REA 的思路看,订单并不是核心。核心是“发货”这个事件:谁把什么资源发给了谁;以及“收款”这个事件:谁从谁那里收到了现金。订单只是这两个事件之间的一个承诺,不应该过度承担建模职责。把目光从“表单”挪到“事件”上,设计思路立刻就不一样了。
1.2 为什么偏偏选它做业务系统
我之前在某公司负责一套跨平台进销存系统,最头疼的不是功能做不出来,而是对账对不上。采购部看库存,财务看应付,销售看毛利,都是同一笔业务,三个部门能产出三个数字。后来回看底层表,问题很清晰:库存表存了当前数量,订单表存了订单状态,收款表存了到账记录,这些表各自维护“余额”,只要一个环节漏更,对账就崩。
REA 模型给了一个很极端的约束:余额和汇总一律不落表,只记录事件本身。库存数量不是字段,而是所有“入库”事件减去所有“出库”事件之后计算出来的结果;应收账款不是一张表里的某个数字,而是“发货”事件和“收款”事件对比出来的差额。听起来会增加查询负担,但在现代数据库和缓存面前,这点计算代价远小于长期维护不一致账本的代价。
我当时决定把旧系统重构,核心就是引入 REA 模型。现在回想,这个决定是在跟业务方确认了“到底什么是一次完整业务”之后才做的。先有业务共识,再有模型设计,REA 只是把共识固化下来。
2. 核心概念拆解:资源、事件、参与者怎么搭配
2.1 资源:有价值的、可追踪的东西
资源是“有价值并且需要追踪去向”的对象,可以是实体的商品、现金,也可以是虚拟的应收账款、积分、服务工时。每个资源都应该有一个可识别的主体,比如具体到某一批次的商品,或者某一张面额的抵用券。
判断一个东西是不是资源,有个很实用的标准:看它会不会在业务中被“消耗”或“增加”。库存商品会被卖出而减少,现金会因收款而增加,这些是资源。而“销售订单号”或“审批记录”虽然也很重要,但它们描述的是业务的凭证和流程,本身不会被增减,所以不应作为资源建模。
我做模型时习惯给资源加一个“资源规格”的维度。比如同一款手机,有不同颜色和存储容量,它们在业务上可能是同一种商品,但库存追踪时必须区分规格。于是拆成资源主表和资源规格表,事件与规格关联。这样既不会在商品维度上含糊,也不会把型号参数重复塞进每张事件表。
2.2 事件:让资源发生流动的动作
事件是 REA 模型里最核心的部分,也是我建议大家最先确定的元素。事件记录的是“发生了什么”,不记录“还剩多少”。采购入库、销售出库、收付款、领用、盘点调整,这些都是事件。
事件天然分成两个方向:一类让资源流入组织,比如采购入库、预收款;另一类让资源流出组织,比如销售发货、现金付款。一个完整业务循环,通常由一对相反方向的事件组成。卖货这个业务,就是商品流出事件和现金流入事件共同完成;采购则是商品流入事件和现金流出事件共同完成。
事件需要记录的关键信息不是余额,而是“时点”和“量”。什么时候发生,涉及多少资源数量,按什么单价成交。单价和总额可以记录,但它们是事件发生时的业务事实,不是动态计算出来的汇总。事件表一旦写入,原则上就不应该更新或删除,只能追加一条反向冲正事件,这一点对后续对账和审计特别重要。
刚开始建模的人经常会问:那“订单”应该放在哪里?我的处理方式是把订单看作一个“承诺”或“契约”,它单独有一张契约表,用来关联后续发生的多个事件。订单本身不是事件,不是资源,但它连接了未来的发货事件和收款事件。表结构上,契约与事件是弱关联,一个发货事件可能对应一张订单,也可能对应多张订单合并发货。
2.3 参与者:谁参与了这件事
参与者是“执行或参与事件的人或组织”,包括内部参与者和外部参与者。内部参与者可以是销售员、仓管员、采购员;外部参与者可以是客户、供应商、承运商。同一个事件可能涉及多个参与者:一张销售发货单,仓管员负责出库,货车司机负责运输,客户负责签收。
在传统表设计里,参与者经常被当成冗余字段写在单据表上,比如“业务员”字段、”客户名称”字段。一旦一个事件有多角色参与,字段就塞不下了。REA 的处理是单独建一张参与者关联表,每个事件通过关联表连接多个参与者,并且在关联表上标明角色。
我建议参与者表用“组织+人员”两层。客户、供应商这类组织级参与者,与内部员工这类人员级参与者分开。事件与参与者的关联,统一记录“参与者ID、事件ID、角色类型”,不要嫌多一张表麻烦。后续做业绩统计、客户分析、物流追溯时,这张关联表会派上大用场。
3. 建模实操:从模糊需求到 REA 实战落地
3.1 第一步:先画业务循环,不急着建表
我重构进销存系统时,第一步不是打开数据库画 ER 图,而是拿白板列出业务循环。以销售模块为例,完整循环大概是:
- 顾客询价、下订单
- 仓库按订单发货
- 财务向客户收款
- 客户收货后可能退货
- 财务对账、结转成本
这个循环里,真正让资源发生流动的动作是发货、收款、退货、付款。订单在这里只是一个前奏,不应该被当成主事件来处理。
紧接着要识别每个事件里有哪些资源在流动。发货事件流动的是库存商品,收款事件流动的是现金,退货事件流动的是退回商品,付款事件流动的是现金或银行存款。最后再给每个事件补上参与者:发货事件的内部参与者是仓管员,外部参与者是承运商和客户;收款事件的外部参与者是客户,内部参与者是财务人员。
这一步我不建议直接画成数据库表,而是用文字清单把“谁、在什么时间、通过什么动作、改变了什么资源”描述出来。描述得越清楚,后面建表越顺利。很多时候业务对账对不上,不是缺字段,而是这一步没想透。
3.2 第二步:把模型落成可执行的库表
画完业务循环,再转成表结构就顺理成章了。下面是我在项目里实际用过的核心表结构简化版,命名上保留了 REA 的语义痕迹。
-- 资源表:记录资源名称、类型、计量单位 CREATE TABLE resource_specs ( id BIGINT PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50), unit VARCHAR(20) ); -- 资源实例表:可追踪的具体资源批次 CREATE TABLE resources ( id BIGINT PRIMARY KEY, spec_id BIGINT NOT NULL REFERENCES resource_specs(id), batch_no VARCHAR(50), initial_qty NUMERIC(18,3) NOT NULL DEFAULT 0 ); -- 事件主表:记录业务事件本身 CREATE TABLE events ( id BIGINT PRIMARY KEY, event_type VARCHAR(50) NOT NULL, occurred_at TIMESTAMP NOT NULL, recorded_at TIMESTAMP NOT NULL, ref_doc_no VARCHAR(50), status VARCHAR(20) NOT NULL DEFAULT 'VALID' ); -- 事件资源明细:事件涉及的具体资源及数量 CREATE TABLE event_items ( event_id BIGINT NOT NULL REFERENCES events(id), resource_id BIGINT NOT NULL REFERENCES resources(id), direction VARCHAR(10) NOT NULL CHECK (direction IN ('IN', 'OUT')), quantity NUMERIC(18,3) NOT NULL, unit_price NUMERIC(18,6), amount NUMERIC(18,2) ); -- 参与者表 CREATE TABLE agents ( id BIGINT PRIMARY KEY, agent_type VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL ); -- 事件参与者关联表 CREATE TABLE event_agents ( event_id BIGINT NOT NULL REFERENCES events(id), agent_id BIGINT NOT NULL REFERENCES agents(id), role_type VARCHAR(30) NOT NULL );event_items 表里的 direction 字段非常重要,它标明这笔明细是流入还是流出。查询某一时刻的库存,只需要把资源的所有事件明细按方向累加即可。这也是 REA 模型最舒服的地方:没有冗余的“当前库存”字段,逻辑上不会出现库存表和流水表不一致的问题。
amount 字段刻意保留在明细层而不是事件主表,因为同一个事件可能同时涉及多种资源,比如一张发货单里包含手机和耳机,不能只算一个总金额。unit_price 和 amount 是业务发生时的快照,后续价格调整不应该修改这里的值,只能通过冲正事件修正。
3.3 第三步:查询口径与 API 设计
建表之后,第一个要写的查询就是“某时间段内的销售明细和毛利”。销售毛利不再依赖单独的报表表,而是通过事件明细去计算:发货事件中的商品数量乘以成本价,得到销售成本;收款事件中的实收金额,得到销售收入。两者都来源于事件,统计口径天然一致。
查询某个客户的应收账款,也不需要建应收账款表,直接把这个客户参与的事件拿出来比较:发货事件发生的应收,减去收款事件发生的实收,差额就是当前欠款。需要注意,查询时要用 event_agents 关联表去限定参与者,而不是在事件表里放一个“客户ID”字段。这样既支持一笔订单多个参与方,也不会因为参与者扩展而改表结构。
API 设计上,对外提供的写入接口应该围绕“事件”展开,比如 submitShipment,submitPayment,submitReturn。而查询接口围绕“资源余额”和“事件列表”展开,比如 getInventory,getReceivable。写入端绝不接受前端传一个“最新库存数量”直接更新,库存数量只可能是后端根据事件计算出来的结果。这个约束在前后端接口文档里要写清楚,得让前端同学明白:提交的永远是业务事件,不是业务状态。
4. 我在业务系统里踩过的四个经典坑
4.1 坑一:把订单当成了资源
我第一版设计时,为了图省事,把订单也放进了资源表管理。当时想的理由很充分:订单有价值,也需要追踪状态。结果没几天就出问题了,销售统计时把订单金额和收款金额混在一起算,客户付了钱之后订单变成历史,数据不断叠加,对账越对越乱。
后来才意识到,订单的价值是“未来可能发生的交易”,它是契约,不是已经被消耗或获取的资源。订单可以和“应收款”这样的资源建立关系,但订单本身不应该进入资源表。如果你在建表时发现一个对象既不是库存也不是现金,却总想在资源表里给它留一行,先停下来问一句:它真的会被“增加”或“减少”吗?如果只是状态流转,它就应该放在契约或凭证层。
4.2 坑二:金额和数量的归属搞不清楚
传统单据表喜欢把整单合计金额直接写在主表上,这在 REA 模型里特别别扭。一笔销售发货单如果包含三种商品,明细金额分别不同,还有一个整单折扣,那么“整单金额”到底属于哪个资源?如果硬放在事件主表,统计时很容易重复计算。
我的解决办法是:事件主表只保留事件本身的时点、类型、凭证号;明细表记录每种资源对应的数量、单价、金额;涉及整单折扣时,再单独建一张“折扣”资源或折扣明细,把它视为一种负向资源变动。这样每一种价值的增减都有对应的资源,统计毛利时能拆开看商品收入、折扣影响、退款影响,业务方再问差异时也查得清楚。
4.3 坑三:退货和取消直接删数据
旧系统里遇到退货,第一选择是把原订单明细删除,或者给状态字段改成“已作废”。这在 REA 模型里是绝对的禁区。事件是历史事实,不能因为后来退货就否认当初发生过发货。如果删除事件,后续库存计算和审计日志都会出现不可解释的缺口。
正确做法是新增一条反向事件,记录退货数量、退款金额、退货参与者,再通过一个冲正事件组字段把原发货事件和退货事件关联起来。汇总时原事件照常保留,但计算净库存和净应收时会同时考虑正向事件与冲正事件。这个过程很像财务上的红字冲销,虽然初期觉得多写了代码,但后续只要业务方问“为什么这笔单发生过又退了”,你随时可以拿出完整事件链。
4.4 坑四:参与者表关联后统计翻倍
引入 event_agents 表后,有一个很典型的 SQL 问题。比如统计销售发货总金额,原本直接 from events 就行,后来为了把参与人带出来,join 了 event_agents,结果因为有多个参与者,发货金额被重复计数。
排查后意识到,当一张关联表的一侧记录数是 M,另一侧是 N,join 后就会产生 M 乘 N 的重复。解决办法是统计时先聚合 event_items,再单独查询 event_agents 作为维度表,或者对关联表先按角色去重。一个事件可能有业务员、仓管员、司机、客户四个人参与,但金额只能按事件明细算一次,不能让参与者数量放大金额。这个坑很隐蔽,建议在写报表 SQL 时养成“金额先聚合、参与人后关联”的习惯。
5. 让 REA 和常见开发方式共存
5.1 与领域驱动设计的配合
很多人看到事件、资源、参与者的结构,第一反应是它和事件溯源、DDD 很像。确实如此。REA 可以看成是业务领域模型的一种骨架,事件表对应领域事件,资源对应聚合根里的实体,参与者对应领域内的角色。
用 DDD 落地 REA 时,我会把每个完整业务循环看作一个聚合边界。比如销售域聚合里,发货事件和收款事件是核心,订单契约是外围。写入流程先创建契约,再发生资源流出事件,再发生资源流入事件,事件的不可变性正好满足事件溯源的要求。这样设计的好处是,聚合边界清晰,不会因为一个客户把销售和收款强行放成一个巨型聚合。
5.2 与复式记账的衔接
团队里有财务背景的人会问,REA 和借贷记账法是什么关系?我的理解是:复式记账是一种展示规则,REA 是更底层的业务事实模型。一笔销售发货,在借贷表里是“借应收账款,贷库存商品”,在 REA 里就是两个事件:库存流出事件和应收增加事件。
实际落地时,不必把传统凭证表丢掉。可以保留凭证表,但凭证的来源应该是事件,而不是手工填写。事件发生后,由规则引擎自动生成会计凭证,确保账务和业务流水一一对应。这样既符合财务审查习惯,也保留 REA 的扩展能力。
5.3 最小落地模块建议
如果要从零开始引入 REA,不建议一口气把所有业务都重构。我建议先选一个边界清晰、对账问题最严重的模块试点,比如库存或应收账款。最小模块清单只需要五张表:
- 资源规格表
- 资源实例表
- 事件主表
- 事件资源明细表
- 事件参与者关联表
不需要一开始就设计复杂的契约表、关联规则表。先把“事件不可修改”这个原则守住,把余额查询改成实时计算,就跑通了一个最小闭环。等业务认可这套逻辑,再逐步把订单、合同、契约加入模型,扩展起来会非常自然。
6. 我的一点实践心得
折腾这套 REA 设计之后,我对业务系统的看法改变了不少。过去拿到需求,第一反应是“要建几张表、分几个模块”,现在变成“谁在什么时候对什么资源做了什么”以及“这件事的参与者和方向是什么”。表结构反而成了最后一步。
如果真想在自己的项目里尝试 REA,我建议从一张“事件清单”开始,把各个岗位口中的业务动作全列出来,然后逐个问:这个动作有没有让某个资源增加或减少?如果答案是否定的,那它大概率不是核心事件,只是一个附属流程。把所有核心事件理清楚,资源表、参与者表自然会浮出水面。
还有一点很重要:不要在高并发场景里无脑实时计算所有余额。如果查询量上来了,可以建立汇总表或快照表,但更新汇总表的逻辑一定要由事件驱动,而不是业务代码里随手更新。汇总表只是缓存,事件表才是唯一的事实来源。只要守住这个底线,REA 模型给系统带来的长期收益,远大于初期那点建模成本。