“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 参与 EventEvent 增减 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 前期会多花一点建模时间但它省掉的是后期无数个通宵排查数据差异的夜晚。这个取舍值。