
做后台业务系统或者数据建模做久了手头难免积压一堆“看着差不多但谁都不敢动”的表销售订单、出库单、销售发票、收款单、库存流水字段越加越多关联越绕越深最后连写一条“这个月到底赚了多少”的SQL都要折腾半天。我最早接触“rea”这个词是在一次库存系统改造的评审会上。当时团队争的不是表结构而是“什么才算一次真实发生的业务事实”。这个问题的答案直接决定了我们后续是继续在单据模型里打补丁还是切换到一种更底层的建模方式。rea 在这里指的是 REA 模式全称是 Resource-Event-Agent资源-事件-代理人模型。它最早在会计信息系统领域被系统化提出核心思想非常简单业务系统应该记录“发生了什么”而不是“单据长什么样”。这篇文章我想把 REA 模式从概念到落地完整聊一遍包括它到底解决什么问题、核心概念怎么理解、我在实际项目里怎么设计表和写查询以及那些常规文档里不会讲的坑。适合正在做业务系统设计、数据架构或者被财务和业务口径不一致折磨过的同学参考。1. REA到底解决什么问题从一张被状态字段压垮的表说起1.1 传统“单据驱动”建模的四个典型痛点很多业务系统的数据模型是从“单据”出发设计的。所谓单据就是销售订单、采购入库单、出库单、发票这些带编号、带状态、带审批流的表。这种模型贴近用户操作界面但作为底层数据模型它有几个长期积累的隐患。第一个痛点是状态字段膨胀。一张订单表通常会有 order_status、pay_status、deliver_status、refund_status 等一串状态字段。每次业务流程加一步就得加一个字段或者改一套状态机。等到第N个状态出现时前端下拉框已经长得离谱后端每个接口都要 switch-case 一遍排查问题变成猜谜。第二个痛点是业务事实被单据包裹得太厚。单据是“业务的某个视角”不是“业务本身”。比如一笔销售实际发生的事情是商品离开仓库、客户承诺付款、库存减少、应收增加。但传统模型里这些事实分散在订单、出库单、发票和收款单里彼此靠单号人工关联。审计时要追溯“这批货从采购到销售完整经历”得跨四五张表来回join。第三个痛点是资源语义被割裂。同一批库存在仓库模块叫“可用库存”在财务模块叫“存货”在销售模块叫“可售库存”。各自维护一套表数据口径对不上月底对账全靠 Excel。第四个痛点是业务流程调整成本高。今天业务说“我们不做预收直接现结”你还能硬改订单状态明天说“我们要支持以货抵债”整个单据模型就得重新设计因为经济资源之间的交换关系已经超出了原有表结构的表达范围。1.2 REA的立场业务事实第一单据只是投影REA 模型的立场和单据模型完全不同。它不把“销售订单”当作核心表而是把“销售事件”当作核心表。订单、出库单、发票、收款单统统降级为事件的某种投影或者出力视图。打个比方银行的账务处理最核心的记录是流水而不是对账单。流水记录的是每一笔资金变动的事实谁、什么时候、多少钱、从哪个账户到哪个账户。对账单只是把某段时间的流水按某种规则聚合出来的一个视角。REA 就是要把业务系统建在“流水”这一层而不是建在“对账单”这一层。这样设计的好处很直接状态字段消失了因为事件本身就是事实不需要“待审核”“已审核”“已作废”这种过渡态审计路径变短了因为所有变动都能从事件表直接还原跨业务线共享资源也变容易了因为不同业务只是往同一个资源池里投递事件而不是各自维护一套冗余副本。当然REA 不是说完全不要单据。单据仍然需要只是单据的位置发生了变化它从“真相的来源”变成了“真相的观众”。这一点在后续落地部分我会具体演示。2. REA核心概念与建模原理详解2.1 资源Resource)被业务影响的对象REA 里的资源指的是经济资源也就是那些会被交换、被消耗、被生产、被使用的有价值对象。它不一定是仓库里的实物也可以是现金、应收账款、服务工时、软件授权、知识产权等。建模时要注意一个边界资源是“可以被事件影响”的对象而不是“物品基础档案”。比如商品主数据里的名称、规格、条码这些属于资源档案但不是资源本身。资源在 REA 里的身份是“经历了增和减的载体”。同一件商品入库让它变多销售让它变少盘点调整也能让它变多或变少这些都是独立事件都作用在同一份资源上。在表结构上resource 表通常只放静态属性和当前状态。比如 resource_id、resource_code、resource_name、resource_type、base_uom基础单位以及一些必要的状态。资源与资源之间的关系比如“BOM物料清单里一个成品需要哪些原材料”不属于资源表本身通常会单独建资源结构表来维护。2.2 事件Event)最小颗粒度的业务事实事件是 REA 模型最核心的概念。一个经济事件必须是发生在某个时间点、能够被明确记录、并且对资源产生了可度量影响的事实。事件通常被分成两类。一类是交换型事件涉及两个不同的代理人阵营比如销售、采购、收款、付款。交换型事件一定带来资源的双向流动卖货给你货物从我这里流出去钱从你那里流进来。另一类是转换型事件只涉及一个代理人比如生产领料、完工入库、内部调拨、盘点盈亏。转换型事件同样是合法事件它改变的是资源在同一个组织内部的形态或位置。事件的粒度要控制好。我的经验法则只有一条如果一个操作记录完之后你回答不了“哪个资源变多了、哪个资源变少了、是谁做的、什么时候做的”那么它就不是一个合格的事件。比如“点击审核按钮”不是事件“审核通过后库存增加”才是事件。事件一旦发生不建议做物理删除最多做冲正事件这样才能保证日后的审计追溯完整性。2.3 代理人Agent)权责的承担者代理人就是参与事件的人或组织。内部代理人通常是员工、部门、仓库外部代理人通常是客户、供应商、承运商。注意不要把代理人和“客户表”划等号。客户只是一个业务角色而代理人在 REA 里承担的是权责归属这笔销售是哪个销售员做的货发给了哪个客户款由谁承诺支付责任压在哪一方。实际建模时agent 表通常有一个 agent_type 字段区分内外。另外需要单独维护角色关系因为同一个代理人可以承担多个角色比如同一家公司既可以是你的客户也可以是你某个项目的供应商。角色和代理人分开设计后续应变能力会强很多。还有一个容易被忽略的点代理人之间也存在关系。客户内部的采购员、收货员、财务审批人都是独立代理人都可能在事件链条上出现。传统模型里为了省事经常把这些角色揉进一个“联系人”字段事后追溯时非常痛苦。2.4 经济增量与经济减量REA 最关键的一对关联如果只看资源、事件、代理人三张表REA 还不算完整。真正让模型具备表达力的是事件与资源之间的两条关联也就是经济增量Economic Increment和经济减量Economic Decrement。经济增量表示某个事件导致某份资源增加经济减量表示某个事件导致某份资源减少。以最简单的现金销售为例销售事件一方面引起“库存商品”减少这是一个经济减量另一方面引起“现金”增加这是一个经济增量。一次事件同时挂一个减量和一个增量完成资源交换的表达。这两条关联在物理表上通常拆成两张表来存因为一次事件可能同时影响多种资源。比如“以旧换新”的销售事件现金增量、旧货库存减量、新货库存减量三种资源变动挂在同一事件下如果只在事件表里放两个金额字段根本装不下。这个设计是 REA 能从容应对复杂业务的关键。3. 从概念到落地一个库存-销售系统的REA建模实操3.1 需求场景定义为了把前面的概念串起来我模拟一个实际项目场景。某公司同时经营线下门店和线上商城主营多品类商品需要支持采购入库、销售出库、门店间调拨、盘点盈亏这几类核心业务。老板的需求一句话随时要看清楚每一类商品还剩多少、这个月毛利是多少、应收应付是多少。如果按传统单据模型这张架构图会非常长销售订单、销售出库单、采购订单、采购入库单、调拨单、盘点单、收款单、付款单……每张单都有自己的主表和明细表再加各种状态。即便做出来后续加一种业务类型仍然是大改造。按 REA 建模核心只有五类对象resource资源、agent代理人、event事件、economic_increment经济增量、economic_decrement经济减量。所有业务场景都是往这五个对象里注入新的实例。3.2 表结构设计下面是我在模拟项目中实际使用的表结构去掉了环境相关的字段保留了最关键的骨架。resource 表CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL UNIQUE, resource_name VARCHAR(255) NOT NULL, resource_type VARCHAR(32) NOT NULL DEFAULT GOODS, base_uom VARCHAR(16) NOT NULL, current_status VARCHAR(16) NOT NULL DEFAULT ACTIVE, created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );agent 表CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_code VARCHAR(64) NOT NULL UNIQUE, agent_name VARCHAR(255) NOT NULL, agent_type VARCHAR(32) NOT NULL COMMENT INTERNAL/EXTERNAL, created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );event 表是整张模型的心脏。我建议无论如何都保留 event_type、occurred_at、agent_id、counterparty_id 这几个字段它们回答了最核心的问题发生了什么、什么时候、谁做的、和谁做的。CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_code VARCHAR(64) NOT NULL UNIQUE, event_type VARCHAR(32) NOT NULL COMMENT SALE/PURCHASE/TRANSFER/INVENTORY_ADJ, occurred_at TIMESTAMP NOT NULL, agent_id BIGINT NOT NULL COMMENT 内部代理人责任人, counterparty_id BIGINT COMMENT 外部代理人客户或供应商, location_id BIGINT, remark VARCHAR(512), created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_event_occurred (occurred_at), KEY idx_event_type (event_type) );增量、减量表CREATE TABLE economic_increment ( inc_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, uom VARCHAR(16) NOT NULL, unit_amount DECIMAL(18,4), total_amount DECIMAL(18,4), base_qty DECIMAL(18,4) COMMENT 折算为基础单位数量, created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inc_resource (resource_id), KEY idx_inc_event (event_id) ); CREATE TABLE economic_decrement ( dec_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, uom VARCHAR(16) NOT NULL, unit_amount DECIMAL(18,4), total_amount DECIMAL(18,4), base_qty DECIMAL(18,4), created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_dec_resource (resource_id), KEY idx_dec_event (event_id) );3.3 关键设计说明为什么要拆增量减量表而不是在 event 表里加 increase_qty 和 decrease_qty 两个字段原因前面提过一个事件可能同时影响多个资源。比如销售事件现金增量对应一个 agent库存减量对应另一个 resource如果把两条变动塞进 event 表就会被迫用冗余行或 JSON 字段后续查询聚合非常难受。拆成两张子表之后每行只表达一条原子变动join 和 group by 都很干净。数量和金额都必须用 DECIMAL不要用 FLOAT。浮点数在累计和比较时会出现不可控误差财务场景绝对不能接受。我习惯统一用 DECIMAL(18,4)涉及金额时再根据货币精度做四舍五入。uom 字段必须保留。因为不同资源的计量单位可能不同同一资源也可能有“库存用箱、销售用件”的情况。base_qty 字段用于折算到基础单位方便汇总统计。折算逻辑通常放在应用层完成数据库只负责存储折算后的结果。3.4 核心查询怎么写库存余额是 REA 模型最典型的查询场景。当前库存等于该资源历史增量总和减去历史减量总和SELECT r.resource_id, r.resource_name, COALESCE(inc.qty, 0) - COALESCE(dec.qty, 0) AS current_stock FROM resource r LEFT JOIN ( SELECT resource_id, SUM(base_qty) AS qty FROM economic_increment GROUP BY resource_id ) inc ON r.resource_id inc.resource_id LEFT JOIN ( SELECT resource_id, SUM(base_qty) AS qty FROM economic_decrement GROUP BY resource_id ) dec ON r.resource_id dec.resource_id WHERE r.current_status ACTIVE;在事件量非常小的场景这么写没问题。但事件量一旦超过百万级每次实时汇总肯定撑不住。后面我会讲性能优化的思路。销售毛利查询的思路是找到所有销售事件关联的收入增量然后减去对应的成本减量。这里有个业务约定销售毛利通常只在事件发生时计算一次不随后续采购价格波动而变化。所以我在增量减量表里设计了 unit_amount 和 total_amount在事件发生时就把当时的成本价或售价快照进来。SELECT DATE_FORMAT(e.occurred_at, %Y-%m) AS stat_month, SUM(inc.total_amount) AS total_revenue, SUM(dec.total_amount) AS total_cost, SUM(inc.total_amount) - SUM(dec.total_amount) AS gross_profit FROM event e JOIN economic_increment inc ON e.event_id inc.event_id JOIN economic_decrement dec ON e.event_id dec.event_id WHERE e.event_type SALE GROUP BY DATE_FORMAT(e.occurred_at, %Y-%m);这个查询的业务含义要解释一下对于一笔销售经济增量侧是现金或应收账款增加金额是售价经济减量侧是库存商品减少金额是当时的出库成本。两者相减得到毛利而不是靠外部报表在月底倒挤这才是 REA 的记账思路。3.5 一个销售事务的完整实现实际写入数据时必须用数据库事务。一次销售事件要同时插入一条 event 记录、至少一条 economic_increment、至少一条 economic_decrement。下面这段伪代码展示核心流程def create_sale_event(sale_data): with db.transaction(): sale_event db.insert(event, { event_code: generate_code(SALE), event_type: SALE, occurred_at: sale_data[occurred_at], agent_id: sale_data[salesperson_id], counterparty_id: sale_data[customer_id], location_id: sale_data[shop_id] }) for item in sale_data[items]: db.insert(economic_increment, { event_id: sale_event.id, resource_id: CASH_RESOURCE_ID, agent_id: sale_data[customer_id], quantity: item[sale_qty], total_amount: item[sale_amount], base_qty: item[sale_base_qty] }) db.insert(economic_decrement, { event_id: sale_event.id, resource_id: item[goods_id], agent_id: sale_data[salesperson_id], quantity: item[sale_qty], total_amount: item[cost_amount], base_qty: item[sale_base_qty] })看清楚了吗这里的 economic_increment 指的不是库存增量而是现金资源增量。卖东西这件事在 REA 的视角里是“现金进来货物出去”所以增量表的资源和减量表的资源不同这是新手最容易搞反的地方。4. 实际应用中的关键经验与避坑指南4.1 什么时候值得用 REAREA 不是银弹它适合的场景有明确边界。如果你面对的是供应链、多组织库存、审计要求高、业务口径经常变、多个系统共享同一批资源REA 能显著降低长期维护成本。它尤其擅长回答两类问题一类是“某个资源现在还剩多少”另一类是“某个资源在过去这段时间经历了什么”。如果你的场景只是内容管理、简单 CRUD、或者一个非常稳定的单一销售流程那传统单据模型可能更快、更符合团队既有认知。REA 在前期建模成本上确实更高因为你需要把业务抽象到事实层而不是界面层。团队没有足够共识时硬上容易把项目拖死。我个人的判断标准很简单如果业务经常问“为什么库存系统、订单系统、财务系统口径不一致”或者一句话需求背后涉及三张以上状态表联动就该认真考虑 REA。如果只是“给我一个后台管理页面”先别上 REA。4.2 建模过程中最常遇到的5个坑第一个坑是把“操作”当事件。“新增库存”不是事件“采购入库”才是事件“调整金额”不是事件“对账差异确认”才是事件。判断方法只有一个有没有资源随之增加或减少。如果没有资源影响那它只是流程动作不是经济事件。第二个坑是忽略事件发生的“真实时间”。业务单据经常有录入时间和业务时间之分比如月底补录月初的销售单。REA 模型里必须以 occurred_at业务发生时间为准录入时间只做审计参考。如果搞混了月底统计会乱套。第三个坑是资源没有生命周期概念。库存不只是“有无多少”还有在途、锁定、待检、冻结等状态。REA 通过事件可以推导出这些状态比如“锁定”可以建模为一个独立的转换事件把资源从可用状态转为锁定状态。如果资源表只维护一个当前状态就会回到传统模型的老路。第四个坑是成本计价方式没想清楚。REA 事件模型可以记录每次变动的成本但移动加权、先进先出、个别计价这些方法需要额外的估值逻辑。我见过有团队试图在事件表里同时维护所有计价方法结果每次新增一批货都要重算历史事件性能灾难。正确的做法是事件表只记录当时确认的成本计价方式通过独立估值视图在特定时点计算。第五个坑是代理人和角色的关系没分开。前面说过同一家公司既是客户又是供应商一个人既是仓库管理员又是调拨申请人。如果把 agent_id 直接挂在事件上不考虑角色语境后续查询“某客户在所有仓库的库存”时很难梳理权责。建议在事件表之外增加参与角色表或字段把“谁对哪个资源流负责”表达清楚。4.3 与现有ERP和财务系统对接的思路REA 模型上线最难的不是新系统内部逻辑而是和旧系统、外部系统对接。很多 ERP 系统只输出单据接口比如销售出库单、采购发票不会给你事件级数据。对接思路只能分两步走。第一步把外部单据转换成事件流入 REA 模型。比如“销售出库单”在 ERP 里是一张有头有明细的单据转换时把它拆成一个销售事件、多条经济减量对应商品出库、几条经济增量对应应收或现金。这个转换层的代码要稳定单据类型和事件类型的映射关系要单独维护不要散落在业务代码里。第二步定义对账标识字段。每笔流入的事件都要记录来源系统、来源单号、来源行号这样才能在月底和外部系统逐笔对平。即使 REA 模型内部逻辑再严谨和外部系统之间依然需要对账闭环。对账不是靠“相信对方系统正确”而是靠双向可追踪。5. 常见问题与排查技巧实录5.1 存量数据迁移老系统只有单据没有事件这是一个非常现实的问题。切换到 REA 模型时老系统只有一堆历史出库单和入库单没有事件概念。全部重新录入不现实手工造事件也不可能。我的处理办法是从单据反向生成事件但必须接受信息损失。比如老系统的“销售出库单”我们能确定的是某天某仓库发出了某商品若干对应某客户。这些信息足够生成一个销售事件和对应减量。但增量侧如果只能看到应收金额看不到现金到账流水就需要额外生成一个应收增量的过渡事件。对于无法明确对应资源流向的历史数据最好的方案是生成“期初调整事件”挂在最上级资源上保证总账平衡。迁移时还要注意一个细节历史事件的时间字段用单据审批通过时间还是业务发生时间必须和业务部门确认并留下文档。一旦确定后续所有统计都以它为准中途不能再改口径。5.2 事件时序与并发带来的余额计算错误多用户同时卖货时库存余额很容易被算成负数。问题出在我前面给出的余额查询是“先求和后相减”这个在并发写入时存在竞态条件两次销售事务同时读到同一份库存各自判断库存充足然后都完成扣减。解决思路有两个。第一个是使用数据库行锁在扣减前先对 resource 当前状态行加锁保证同一商品同一时间只有一个事务在扣减。第二个是引入库存预留事件在用户锁定商品时先创建一个“预占事件”锁定成功后正式销售事件落库同时解除预占。第二种方案更贴近真实业务但实现复杂度也更高。我给个务实建议如果库存准确性是硬指标优先用行锁方案代码简单且不容易出逻辑漏洞。预留机制后面再加。5.3 性能优化大事件量下的库存余额查询事件表数据量增长很快每次实时聚合全部历史事件肯定不可持续。我常用的手段有三种。第一种是构建库存快照表。每个自然日结束时把每个资源库存余额快照一份查询时只需要拿到“最近快照”加上“快照之后的净变动”就能快速得到当前余额。快照粒度可以按日也可以按小时看业务对实时性的要求。第二种是物化增量视图。数据库如果支持物化视图可以直接把资源层级的汇总结果物化定期刷新。缺点是刷新窗口内有短暂数据延迟适合报表查询不适合前台实时展示。第三种是在事件表上做时间分片。比如按年分表或者按月分区。查询时业务要求往往带时间范围分片能把全表扫描变成分区扫描效果非常明显。5.4 多单位换算导致的对账差异库存采购用“吨”销售用“件”两边的报表数字永远对不上。这是多计量单位场景下的老问题。REA 里我建议把单位换算放在资源档案层维护资源表加 base_uom基础单位和单位换算因子表。每种资源统一换算到基础单位存储 base_qty所有的余额统计、毛利计算都用 base_qty。增量减量表里保留两个数量一个是业务原始数量和单位一个是折算后的基础单位数量。查询页面如果需要按原始单位展示从 uom 和 quantity 取数如果需要做跨资源合计从 base_qty 取数。这样对账差异基本能消失。如果换算因子本身会变比如不同批次密度不同就必须把换算因子快照到事件行里不能只依赖当前资源档案。结尾一点个人的实际操作体会做了几次这样的建模项目之后我最大的体会是REA 真正难的不是表结构而是团队能不能统一对“事实”的定义。我踩过最深的坑就是一开始把所有界面上的按钮点击都当成了事件结果事件表膨胀到完全没法用增量减量对账对不上。后来我们给自己立了一条纪律每个事件必须回答三个问题谁、在什么时间、让什么资源变多了或变少了。回答不清楚的先不建模。这个纪律虽然简单但帮我挡掉了很多“伪事件”。最后再分享一个小技巧。REA 模型上线后不要急着删掉旧系统也不要想着一次性切换。新模型和旧系统可以并行运行一段时间每天对账。对账差异从大到小收敛到零的过程其实就是业务口径被统一的过程。等到连续两周零差异再考虑下线旧系统。这个内容后续其实还可以继续扩展比如把 REA 事件模型和事件驱动架构结合起来每个业务事件直接触发下游流程让“记账”和“执行”共用同一份数据源。感兴趣的读者可以从这个方向继续深入。