“REA”这三个字母,我第一次接触到,是在接手一套遗留的进销存系统改造时。数据库里摆着几十张表,从order_header、order_detail到inventory_balance、account_receivable,字段多到让人头皮发麻,数据却对不上:库存台账和出入库明细对不齐,财务的应收余额和销售那边的发货记录隔三差五就差出几单。折腾了几天,某位前辈只丢过来一句话:“别急着加表,先想清楚你的领域模型是不是健康的。”后来我才知道,他说的模型,就是 REA——Resource(资源)、Event(事件)、Agent(参与者)。
这篇文章,是我把 REA 模型用在一个内部业务系统重构项目中的完整复盘。我会讲清楚 REA 到底是什么、为什么能解决“账对不上”这类顽疾,也会给出从概念到数据库表结构的落地方案,附上实际用过的建表语句和踩坑记录。如果你正在做交易系统、订单库存、账务类的数据模型设计,或者被统计口径不一致折磨过,这篇内容应该能帮你找到一条更稳的路。
1. 为什么业务系统总是“账实不符”——REA模型的立身之处
1.1 传统表结构最容易埋的雷:把“余额”当“第一公民”
大多数业务系统在设计表结构时,会本能地优先设计“结果表”:库存余量表、应收余额表、账户余额表。看起来没毛病,报表直接查这些表就行,速度快、逻辑简单。但问题恰恰出在这里——“余额”是一个结果,不是事实。事实是已经发生的“事件”,而余额只是事件累计之后的一个投影。
举个例子,传统设计里会有一张inventory_balance表,记录当前库存数量。每次出库、入库都要去更新它。听起来简单,但在多线程、多门店、多仓库并发操作下,更新这张表的逻辑就变得极其微妙:是先扣库存再记账,还是先记账再扣库存?发送货物了但发票没开,库存要不要减?仓库退货了采购单还没冲销,库存加不加?每一条规则都会派生成一堆 if-else,最终把系统的核心逻辑变成一团意大利面。
REA 的思路是反过来的:不要直接维护“余额”,只记录“事件”。库存余额、应收余额、采购欠款,都从事件表里实时计算出来,或者通过物化视图/定时任务投影出来。余额不是存储的,而是推导的。这一下就把设计问题的复杂度从“并发算力”转移到“建模正确性”——而建模正确性,恰恰是可以用人力反复验证的。
1.2 REA的三段论:发生了什么、涉及谁、影响哪个对象
REA 最早是会计学领域的一种本体建模方法,后来被企业应用架构设计广泛引用。它把业务活动归结为三件事:
- Resource(资源):企业拥有或控制的、能够带来价值的对象,比如商品、现金、库存、服务工时。
- Event(事件):业务活动真实发生的瞬间或记录,比如一次销售、一次收货、一次支付。
- Agent(参与者):参与事件的个人或组织,比如客户、供应商、内部员工、系统机器人。
这三者之间靠关系连起来:Agent 参与 Event,Event 增减 Resource。用一句话概括就是:每个业务事实都是“谁(Agent),在什么时候,参与了什么动作(Event),影响了什么资源(Resource)”。整个系统只要把这三类对象和它们的关系表建清楚,所有业务查询都可以基于这些表推导出来。
我喜欢用咖啡店的例子来理解它:顾客(Agent)支付(Event)了现金(Resource流入),店员(Agent)制作(Event)了一杯咖啡(Resource本身的转化)。拿走咖啡是“出库事件”,收钱是“收款事件”。咖啡豆消耗是“领料事件”,营业额是以上事件的聚合结果。
1.3 用REA倒逼你用“事件流”重看业务
REA 对业务分析者最大的价值,不是提供一套表结构模板,而是逼你先回答“有哪些事件”,再回答“有哪些余额”。事件是不可再拆的原子事实:顾客付了钱、仓库发了货、供应商送了货、财务确认了发票。每个事件都不需要“依赖前一个事件的结果”,它自己本身就是完整的事实。
一旦用这个视角梳理业务,你会发现很多模糊地带会自动暴露出来:比如“客户申请退款”严格来说不是事件,因为退款还没发生;“客户收到退款”才是事件。“仓库锁定库存”也不是事件,它只是一个临时的状态,不增减资源;“仓库实际拣货出库”才是事件。建模时把这些表达完全不同的东西混在一起,后面的所有查询都会扭曲。
2. 资源、事件、参与者逐项拆解——建模前必须想清楚的三个问题
2.1 Resource(资源):别把“条目”和“资源”混为一谈
在设计 Resource 时,最容易犯的错是把“库存明细行”或“资产卡片”直接当成资源。资源应该是“可被事件独立增减且具备计量单位”的实体。比如一个 SKU 是资源,但“某个仓库里某个批次的 SKU 库存”不是资源,它是资源在某个时空位置的投影。如果你非要把“某仓某批次”建为资源表,那资源的数量就会爆炸,因为每次移库、转批次都要新增一个资源对象,从 REA 角度看这是多余的。
比较好的做法是:核心资源表保持精简,比如products、cash_accounts、material_categories;而把“仓-批次”这类维度拆成资源属性或独立的维度表,通过事件关联去推导。资源表里长期稳定的是“标识性信息”:名称、编号、计量单位,以及最关键的“类型”(是实物商品、虚拟服务,还是资金类资源)。类型会直接影响后面事件表里的增减语义:实物商品可以“入库/出库/盘点调整”,资金只做“流入/流出/内部调拨”,不要指望同一套事件模板生搬硬套。
2.2 Event(事件):先有事实,后有余额
Event 表是整个 REA 落地的核心。它应该像流水账一样,只记录业务“发生了”,不关心“变成了什么”。比如销售发货事件,就是“某订单、某商品、某数量、某仓库、某承运商、某时间”。至于发货之后库存余额变成多少,那是查询时再算的事。
在真正建 Event 表时,请把下面这些字段当成底线:事件编号、事件类型、发生时间、关联的 Resource、Resource 数量或金额方向、关联的订单/来源单据、操作 Agent、幂等键。其中“发生时间”要比“创建时间”更敏感——业务发生时间可能在深夜由上游系统补单,创建时间则是本系统落库的时间,两者必须分开存,否则统计日报必然受时区和补单影响。
Event 记录还有一个特性:它天然是可以追加的。出现了红冲、更正,不要直接改原事件,而是新增一条冲正事件。这样审计和追溯就会变得极其轻松:每一个余额变动都有据可查,且有完整的事件链。我从实际项目中得到的经验是,哪怕初期觉得“冲正事件”操作麻烦,后期排查问题时你会百万分庆幸当时忍住了“直接改原记录”的冲动。
2.3 Agent(参与者):人与角色要分两张表
Agent 不只是“用户表”。REA 里的 Agent 应该分两层:自然人或组织是个体实体(比如客户、供应商、员工),但他们在事件里承担的“角色”是另一回事。同一个员工,在“采购收货”事件里是仓管员,在“付款审批”事件里是财务审批人,职责完全不同。如果只在 Agent 表里塞一个role字段,事件追溯时就会出现失真——因为事件记录的是“他当时以什么身份操作”,而不是“他用户表里现在写的是什么身份”。
所以我建议至少建三张表:agents(自然人/组织的主数据)、agent_roles(角色定义)、agent_role_assignments(主体与角色的关联,带有效期)。这样一来,“操作人”在事件表里存的是一个“角色分配记录ID”,而不是用户ID。哪怕这名员工一个月后转岗了,历史事件里的审批人角色依然指向当时的正确分工,不会被追溯成后来的新岗位。
这类设计在一开始看起来有点繁琐,但当你被审计问“当时谁有这个权限、谁实际操作了”时,会明白这种审计追溯性是普通台账式设计给不了的。
3. 从REA到数据库表:一个可以直接拿去改的落地模板
3.1 核心表设计:资源表、代理表、事件表、关联表
我拿最常见的“采购-销售-库存”业务来举例。基于 REA,最核心的表大概是这样:
res_products:资源表,存商品主数据res_money_accounts:资金资源,一般指虚拟账户或现金账户agt_partners:参与者主表,包含客户、供应商、员工,用类型字段区分agt_role_assignments:参与者-角色关联表evt_inventory_changes:库存变动事件表(入库、出库、盘点、调拨都能放一起)evt_sales:销售事件表(客户下单、确认收款、发货等,按需拆分事件子类型)evt_purchases:采购事件表(到货、入库、收到发票、付款)rel_event_resources:事件与资源的关系表(多对多)
我不建议把每个事件类型都单独拆一张表拆得太细,因为那样会在统计跨类型事件时做大量 union。更好的办法是“同类事件一张表,用event_type字段区分”。比如库存相关的入库、出库、盘点、调拨,全部放进evt_inventory_changes,只是数量符号不同、类型枚举不同。这样查询一段时间的库房流水,只用扫一张表,逻辑简单很多。
3.2 库存事件表的建表示例(PostgreSQL风格)
下面是我实际用过的库存事件表生产方式,稍微脱敏,去掉业务专用字段:
CREATE TABLE evt_inventory_changes ( id BIGSERIAL PRIMARY KEY, event_no VARCHAR(32) NOT NULL UNIQUE, -- 事件编号,业务侧生成 event_type SMALLINT NOT NULL, -- 10=入库 20=出库 30=盘点调整 40=调拨出 50=调拨入 product_id BIGINT NOT NULL REFERENCES res_products(id), warehouse_id BIGINT NOT NULL, -- 仓库维度,属于资源属性,不是独立资源 batch_no VARCHAR(64), -- 批次,可为空 qty NUMERIC(18,4) NOT NULL, -- 本事件影响的库存数量(正数为增加,负数为减少) unit_cost NUMERIC(18,4), -- 事件发生时的成本单价,可空 source_order_no VARCHAR(64), -- 来源单号,比如采购单/销售单 happened_at TIMESTAMPTZ NOT NULL, -- 业务发生时间 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 落库时间 agent_role_assignment_id BIGINT REFERENCES agt_role_assignments(id), idempotent_key VARCHAR(128) UNIQUE, -- 幂等键,防止重复入账 remark TEXT ); CREATE INDEX idx_inv_event_time ON evt_inventory_changes (happened_at); CREATE INDEX idx_inv_event_product ON evt_inventory_changes (product_id, warehouse_id, happened_at DESC);别小看idempotent_key这个字段。实际系统里最怕的不是“漏记账”,而是“重复记账”。上游系统重推一条消息、人工重审一张单据、定时任务重复扫表,都会导致同一条事件被插入两次。只要建表时就给这个字段唯一约束,在业务层做“先查幂等键、存在则跳过”的处理,就能从根上防住重复。我见过太多系统上线半年后发现库存虚高,最后定位到某条扫码记录被消费了三次,非常痛苦。
3.3 事件表怎么支撑“实时库存”查询
有了事件表,实时库存就是一次聚合查询:
SELECT product_id, warehouse_id, SUM(qty) AS current_stock FROM evt_inventory_changes WHERE happened_at <= now() GROUP BY product_id, warehouse_id;如果你的数据量在百万级以上,或者前端报表要求秒级响应,再建一个物化视图定期刷新就行了。不要因为“实时查询慢”而回到“维护余额表”的老路,那会重新引入并发一致性问题。物化视图的刷新策略可以选每分钟一次或每分钟多次,业务上绝大多数交易系统对“库存延迟一分钟”是能接受的。
我做的系统里,事件表在三个月内涨到了超过 800 万行,物化视图每五分钟刷新一次,查询依然能在百毫秒内完成。之后真遇到更强的实时性需求,还可以用窗口聚合、列存引擎处理,完全不需要改模型。
3.4 销售和收款事件的组合设计
销售业务往往涉及多个事件:客户下单、财务收款、仓库发货。按 REA 的思路,下单、收款、发货应该分属不同的事件类型,但它们共享同一个业务单据号(销售单号)。建表时我会建议:
evt_sales只放“销售达成”的核心事实:销售单号、客户、销售金额、销售时间。evt_receipts放收款事件:收到多少钱、收的哪个账户、对应销售单号。evt_deliveries放发货事件:发了什么商品、数量、出哪个仓库、对应销售单号。
三者通过source_sales_no关联,而不是强行合成一个大宽表。这样做的好处,是能够轻松回答“已下单未收款”“已收款未发货”“部分发货”这类过程性问题。每一类状态都不是一个字段,而是一个查询结果。比如“待发货”就是销售事件存在、但对应的发货事件还没生成。
4. 实战复盘:某跨平台销售系统的订单与库存重构
4.1 项目背景:旧模型为什么被推翻
当时我所在的团队维护一个模拟的跨平台销售系统,支持小程序、电商店铺、线下门店三个渠道。旧模型是这样设计的:一张orders表存订单,一张order_items存商品明细,一张stock表存每个门店每种商品的库存余量。销售时先扣stock,再写orders。听起来架构不复杂,可一旦出现“下单后客户取消、平台退款、门店却已发货”之类的混合场景,stock和账务数据就乱了。
最典型的一个故障:某商品在两个门店之间调拨,调拨单生成后,门店 A 的扣减和门店 B 的增加分两条任务执行,第一条执行成功、第二条因网络超时失败,结果商品凭空少了一件。对账时只能手工去 diff 库存流水,但旧系统根本没有完整流水表。当时的处境就是:不是缺一个库存修复工具,而是缺一个能记录所有事件的模型。
4.2 重构过程:把“存量”逻辑全部推倒成“流量”逻辑
重构的第一步是梳理事件清单。我拉上产品和仓库负责人,关在会议室外加了一个白板,逐个环节问“这里到底发生了什么”,最终整理了如下的核心事件:
- 入库:采购到货、加工完成、退货回库
- 出库:销售发货、调拨出库、盘亏出库
- 调整:盘点差异、系统修正
- 收款:客户付款、退款
- 采购付款:向供应商付款、供应商退款
接着把资源清单列出来:商品、资金账户、客户/供应商往来。参与者也很清晰:客户、门店、员工、供应商。然后按 REA 的关系开始设计表,基本就是前面章节里的模板。
这里有个很值得说的设计决定:我们没有为“订单状态”建字段。旧系统的orders.status字段有十几个枚举值,每个值背后都藏着一段业务规则。重构后,订单的状态完全由相关事件推导:有销售事件、没有收款事件 = “待收款”;有销售事件、有收款事件、没有发货事件 = “待发货”;全部都有 = “已完成”。这个设计刚开始受到一些同事的质疑:觉得多查两张表太麻烦。实际上线后,产品经理再也不会因为“状态没及时更新”来投诉开发了,因为状态根本不需要更新,只要事件进来了,状态自然成立。
4.3 关键实现:调拨事件与幂等去重
调拨是库存里最容易出错的场景,我把这部分单独拿出来写。调拨的本质是同一个资源在不同仓库之间移动,整个过程分为两个事件:调拨出库(库存从 A 仓库减少)和调拨入库(库存到 B 仓库增加)。这两个事件在技术上不一定同时发生,实物在途时库存既不在 A 也不在 B,账面上就形成了“在途库存”。
实现上我们要保证两个事件的成对性,我提供一个实用方案:同一条调拨单生成两行事件记录,共享同一个transfer_no,但event_type不同;两行记录通过同一个事务写入数据库。如果 B 仓库迟迟没有点击“收货”,系统就不会生成调拨入库事件,查询在途库存时直接找transfer_no对应只有调拨出库、没有调拨入库的事件即可。
配合幂等键,调拨单的每一行操作对应一个唯一的idempotent_key,比如transfer_{单号}_{明细行}_{出/入}。这样就算门店员工手滑点了两次“确认出库”,第二次插入也会被唯一约束拦住。我们还给前端提示了“操作成功但已存在相同事件”的提示,让员工知道这次重复点击没有造成重复入账。
4.4 对账核对:一套 SQL 把旧系统的差异暴露出来
重构完成后的一个重要验证步骤,是拿旧系统的账面库存和新模型做一次全量对账。用一段简单的聚合 SQL 就能完成:
SELECT product_id, warehouse_id, SUM(qty) AS system_stock FROM evt_inventory_changes GROUP BY product_id, warehouse_id HAVING SUM(qty) <> 0;理论上每个仓库每种商品的库存余额都不可能为负数(除了极少数的预售场景),所以任何负库存都是模型或数据有问题。再加上一句按日统计事件发生量的查询,我们迅速定位到旧系统错在哪几笔:有两笔销售出库事件漏记了,有一笔采购入库被记成了负数。要是没有事件流模型,这几乎没法查。
旧库存表里的数据我们提前导出,拆成“期初库存事件”作为每个商品的初始余额,从重构生效日开始累积新事件。这样迁移后库存余额连续,不会出现断点。这也是 REA 落地的常规经验:不要试图把历史余额一笔笔还原,直接把期初数当成最开始的初始化事件写进去,既干净又省力。
5. 项目中最常见的坑与排查方法——用REA也会翻车的几个地方
5.1 事件表和时间戳的坑
第一个坑是“业务时间”和“系统时间”混用。如果某次跨时区补单发生在 23:50,系统落库时间是次日 00:05,日报就会把本来属于昨天的销售记到明天。排查日报数据对不上,95% 是这类时间语义问题。修法也很简单:所有统计一律使用happened_at,只在审计追溯时看created_at。
第二个坑是“事件时间早于上一个事件时间”。例如补录一张上周的入库单,插入后会出现时间倒挂。如果物化视图是按时间游标增量刷新的,而它只处理“大于上次扫描时间”的新事件,就会漏掉补录数据。我建议物化视图的扫描策略不要只依赖单一行的时间字段,可以加一个自增序列作为水位线,每次刷新时取id > last_id的行,同时也允许重新计算某天的数据。
5.2 类型枚举和负数的约定
REA 模型里事件的方向非常关键。同一个字段qty,入库为正、出库为负,这种符号约定必须写进开发规范里。我吃过一次亏:某次报表团队为了“简洁”,在查询里写了个ABS(qty),直接把所有出库负数的语义抹掉了,结果一段时间内的“出库量”变成了“出入库绝对值之和”。排查半天才发现问题是查询层对符号的二次解释。
另外,事件类型的枚举值要尽量避免“含义重叠”。比如出库里有“销售出库”和“其他出库”,如果后来业务加了一个“赠品出库”,设计者看到“其他出库”能塞就塞,会直接毁掉统计的可解释性。事件类型的划分粒度宁可细一点,后期合并查询容易,拆分再迁移才是真正的大手术。
5.3 状态机逸出:REA不是银弹
REA 模型擅长解决的,是“事实记录与余额推导”的问题。但它本身不解决复杂的审批流状态机:订单的待审核、已拒绝、退款中,这些属于“要素并未真实发生、流程存在中间状态”的情况。我建议这类流程控制仍然用独立的状态流转表,REA 事件表只记录“流程最终生效”的事实。不要在事件表里塞status字段,更不要用事件类型去模拟“审批”这种非业务事实。
当初我有同事试图把所有状态全部“事件化”,结果是每走一步就生成一条“某某审核通过”事件,事件表里全是过程动作,真正的业务价值反而被淹没了。后来我们定了个原则:事件表只记录“引起了资源增减或者权利义务变化”的动作,纯流程记录留在一张独立的process_trail表里。
5.4 高频场景的性能取舍
如果某个商品短时间内的库存事件量极大(比如秒杀场景),单表聚合查询会成为瓶颈。这时有两种很实用的处理路径,不用推翻 REA:
- 对核心商品做双层缓存:事件表落库后异步更新 Redis 里的“热点商品库存”,查询时热点商品直接走缓存。
- 把事件表按粒度拆成“热数据表”和“归档表”:三十天内的热数据实时聚合,旧数据定时归档到聚合表或数仓。
这两个方案都能保住事件模型的完整追溯能力,同时又规避了高并发查询的压力。我在处理十万级 QPS 的库存扣减场景时,用的是“Redis 预热库存 + 异步落事件表 + 每日对账”的组合,效果很稳。
6. 什么样的情况适合引入REA,什么样的场景别硬上
REA 不是银弹,没必要所有系统都套上这套方法论。如果一个业务本身就是简单的一次性交易,比如一个表单、一条记录、一个状态字段,那直接建表就够了,用 REA 反而增加复杂度。我判断是否值得引入 REA,一般看三条标准:
第一,业务里是否存在“多事件共同影响一个余额”的情况。比如库存被订单、退货、盘点、调拨四个事件共同影响,这就是典型适合 REA 的场景。
第二,是否存在“对账难”的痛点。如果财务或运营定期要导出 Excel 手工核对账面和实际数据,那说明系统缺事件级流水,引入 REA 是值得的。
第三,是否需要审计追溯。如果你的业务受到合规约束,或者用户投诉时需要查“当时到底发生了什么”,REA 的设计会节约大量排查时间。
反过来,一个纯粹的 CMS 内容管理、一个简单问卷系统,就不要强行上事件表了。强行把所有业务都抽象成资源-事件-参与者,只会让项目失去可读性,团队协作成本飙升。方法终究是为业务服务的,不是反过来。
从一个老开发的角度,我对 REA 最深的体会是:它逼着你在动手建表之前,把业务里“到底哪些是事实、哪些是计算出来的结果”想清楚。很多项目的数据库之所以越来越臃肿,就是因为大家都在结果表上加字段,没人愿意回到事件流的源头去理清因果。虽然 REA 前期会多花一点建模时间,但它省掉的是后期无数个通宵排查数据差异的夜晚。这个取舍,值。