有段时间我在做一套进销存系统的重构,业务方给的脑图里写满了“下单、出库、收款、退货”,但每次需求评审,我们都会在同一个问题上吵起来:这笔业务到底该怎么记录?是先记流水,还是先改库存?销售凭证和收款记录,谁才是“真相”?后来我试着用 REA 模型把整个业务流程重新梳理了一遍,发现那些争论天然就消失了。
REA 是 Resources-Events-Agents(资源-事件-参与者)的缩写,最早出现在会计信息系统设计领域,用来解决一个很朴素的问题:如何用一个统一的方式描述所有商业活动。早年它被视为一种学术化的会计模型,但这些年随着企业架构、数据中台、微服务事件建模、甚至区块链智能合约设计的发展,REA 的频率明显高了起来。原因很简单:它提供了一套足够抽象、又足够落地的业务建模语言。这篇文章我会把 REA 从原理到实操完整讲清楚,包括它为什么好用、怎么从零建模一家小店的进销存系统,以及我踩过的一些坑。
1. 先搞清楚 REA 到底是什么:三个字母背后的建模哲学
1.1 从奶茶店的一天说起
想象一家奶茶店。早上店里进了 10 袋珍珠、5 箱牛奶,花了 800 元;下午卖出 120 杯奶茶,收了 1200 元;晚上盘点,发现珍珠少了半袋,损耗记了一笔。这些事听起来再普通不过,但如果让你设计一套系统把这些事记录下来,你会发现传统的“库存表 + 流水账”很难让人满意。
传统做法是维护一张当前库存表,每次进货就在库存数字上加,每次售出就减,同时再记一张流水。问题在于:流水只是操作记录,库存只是数字快照,二者之间没有强约束,月底对账时只能依赖盘点强行修正。业务上一旦出现退货、赠送、调拨、报损,这张库存表就开始变得混乱——到底哪些操作被记进了流水?流水和库存是否一致?业务发生的时间顺序如何追溯?
REA 的思路完全不同。它不关心“怎么记流水”,它先问三个问题:这件事涉及什么资源?发生了什么事件?是谁参与的?奶茶店的一天在 REA 视角下就是一组事件:采购事件把现金转换为珍珠和牛奶;销售事件把珍珠制品转换为现金;盘点事件把损耗记录下来。每一笔经济事实都被放进一个统一的结构里,既不会丢,也不会对不上。
1.2 资源、事件、参与者的准确定义
REA 的三个核心元素,如果只是背定义很容易糊弄过去,但真正用的时候必须吃透:
资源(Resource)是能带来价值的、可计量的对象。现金、库存商品、原材料、会员积分、应收账款、设备使用权,都算资源。资源必须有两个属性:数量和计量单位,否则无法建模。“良好的服务态度”不算资源,因为它不可计量;“本月剩余工时”则算。资源强调的是存量状态,而不是流转动作本身。
事件(Event)是发生在某个时间点上的经济事实。采购、销售、收款、付款、退货、损耗、调拨,都属于事件。事件是 REA 模型的核心枢纽,因为只有事件才能把资源和参与者连接起来。事件一定会改变至少一个资源的数量或者状态。
参与者(Agent)是参与事件的个人或组织。供应商、顾客、员工、店铺、仓库,都是参与者。REA 特别强调一个事件通常关联两类参与者:一个内部参与者(比如店员)和一个外部参与者(比如顾客)。这层对称关系是很多人在建模时忽略的。
1.3 为什么事件优先于单据:相册快照和监控录像的区别
理解 REA 最关键的一步,是接受一个观点:单据不是业务事实本身,只是业务事实的某种视图。传统系统喜欢把“采购入库单”“销售小票”当成底层数据,因为单据离业务人员近,好录入好理解。但单据本质上是人对经济事件的一层翻译——它可能是手写的、可能是打印的、可能是补录的,甚至可能漏录。
REA 把事件当作底层事实,把单据当作上层展示。你可以说“采购事件”产生了“一张采购单”,而不是“录了一张采购单”就等于“发生了一笔采购”。这个转化的意义在于:事件是有唯一次序的,单据可能会被修改、删除、作废;事件是不可回溯篡改的业务事实,修改只能靠另一个补偿事件来冲销。
我经常用的一个类比是:传统记账像是在给业务拍照片,月底对账时把一堆照片翻出来比对;REA 像是在给业务录视频,任何时刻想得到某件商品的库存,把视频倒回到此刻算一遍就行。照片可以修图,视频很难伪造过程。
2. 核心设计思路拆解:REA 模型怎么帮你把业务理干净
2.1 资源:先画存量地图,再谈交易
我在实际建模时第一个动作不是画事件,而是先画“资源存量地图”。把业务里的人财物盘一遍,问自己:到底哪些东西有价值、可计量、值得被追踪?对奶茶店来说,至少包括:库存的珍珠、牛奶、杯子、现金收入、应收账款(比如月结的大客户)、积分资产。如果做连锁店,可能还要把“门店经营权”或者“固定资产设备”纳入资源。
资源地图的作用是帮你确定边界。很多项目做不下去,不是因为事件没列全,而是因为资源没厘清——把“订单金额”当资源、把“会员等级”当资源,这都属于概念错位。资源一定是有“存量”含义的东西,订单金额只是事件产生的数值,不是可以累积消耗的资源。
另外要注意区分“资源”和“资源类型”。数据库里应该有一个资源类型表(如珍珠、牛奶),再有一个资源实例表(如某批次珍珠)。小业务可以不做实例化,但库存和供应链相关场景强烈建议做,否则批次追溯、保质期管理都无从谈起。
2.2 事件:经济事实的最小记录单元
REA 里的事件不是随便什么动作,它必须改变资源存量。顾客进店逛了一圈,没有产生任何价值流动,这不是经济事件;顾客付款买走奶茶,现金增加、奶茶减少,这才是经济事实。
更细节地说,事件可以分成“承诺”和“履行”两个阶段。订单是承诺,发货和收款是履行;合同是承诺,验收和付款是履行。很多系统把订单直接当成核心事件来设计,结果满库都是“待处理订单状态”,一旦状态机复杂起来就失控。REA 的建议是把承诺和履行分开建模,承诺负责业务过程追踪,履行产生经济事实,两个层面的数据结构清晰分工。
在设计事件表时,我倾向于用一个统一的“业务事件”基表,再按需扩展子类型。每个事件必须带时间戳,最好带上不可变的业务唯一键(比如事件号或者追溯 ID),这样可以防止重复入账,也为后续审计留了退路。
2.3 参与者:每一个事件都有“双边关系”
很多人在建模时把参与者当成普通的外键字段:“这笔订单是张三的”,然后就结束了。REA 提醒我们,经济事件天然有双边性:有人给出资源,就有人接收资源。销售事件里,顾客是外部参与者,店员是内部参与者;采购事件里,供应商是外部参与者,仓库管理员是内部参与者。
为什么要强强调双边?因为只有把内部和外部参与者都建模,才能支撑“谁对谁负责”的责任分析。比如月底要看某个店员经手的销售额、某个供应商的供货合格率、某类客户的贡献度,这些分析如果一开始就把参与者塞进字符串备注,后面做数据汇总就要靠文本匹配,极其痛苦。
参与者的对称关系在模型层面很便宜,不过就是在事件表上放两个外键,但在业务分析层面的回报非常大。建模初期多花十分钟补全参与者,等于给未来所有围绕“人”和“组织”的报表留好了地基。
2.4 互惠规则:贷方不消失,库存不蒸发
REA 模型有三条经典的完整性规则,实操中应该有意识地去校验。
第一,每个经济事件必须至少涉及一个资源的流入和一个资源的流出。销售奶茶,现金流入、库存流出;采购珍珠,库存流入、现金流出。如果一个事件只改了一边,比如“发货”只减库存、不记录应收或者收款,那业务事实就是不完整的。
第二,每个事件必须关联一个内部参与者和一个外部参与者。这条规则保证了所有资源流动都有人负责,避免出现“无主库存”或者“幽灵交易”。
第三,资源的存量可以被推导。任意时刻的资源数量,等于该资源所有流入事件的总量减去所有流出事件的总量。这是一个很好的调试工具:如果某天某个商品的推导库存和业务方记忆中的库存对不上,那一定是事件被漏录了或者录重复了,而不是系统算错了。
这三条规则,本质上就是把“复式记账”的思想搬到了更一般化的建模层面。每次我重构业务系统时,都会先拿这三条规则去套,立刻就能查出数据模型里潜藏的洞。
3. 手把手实操:把一家奶茶店的进销存系统改造成 REA 模型
3.1 第一步:圈定业务边界,列出全部用例
为了讲得可落地,我拿一家虚构的奶茶连锁店“某奶茶品牌”作为例子。它目前的业务很简单:门店进货、门店销售、退货、盘点、积分兑换,外加每个月和 A 公司做一次批发月结。第一步是画用例清单,先不要碰表结构。
- 门店向供应商采购原料(珍珠、牛奶、杯子)
- 顾客到店购买饮品,可以现金或扫码支付
- 顾客拿小票退回有质量问题的饮品
- 门店每日盘点,处理损耗和盘盈
- 顾客用积分兑换一杯饮品
- 门店从中转仓调拨原料到另一家店
用例不用一次列完美,但要保证边界清楚。如果连业务方自己都说不清有哪些用例,后面建模一定反复返工。
3.2 第二步:先列资源清单,再画事件流
用例列好后,把资源清单单独拎出来。这份清单我会当成模型的“宪法”,之后所有事件都围绕这些资源展开:
| 资源名称 | 计量单位 | 说明 |
|---|---|---|
| 库存原料(珍珠/牛奶/杯子) | 袋/箱/个 | 可继续细分为原料类型 |
| 门店现金池 | 元 | 每个门店一个现金账户概念 |
| 应收账款 | 元 | 面向月结客户 |
| 顾客积分 | 分 | 另一种有价值的资产 |
然后逐个用例画事件流。采购事件:现金池减少 X 元、库存原料增加 Y 袋;销售事件:库存原料减少、现金池增加;退货事件:库存原料增加、现金池减少。积分兑换本质上也是一个事件对,涉及“积分”资源和“饮品”资源的互换。
事件流画完后,你会发现一套业务的骨架其实非常对称:有出去必有进来,有增加必有减少。这就是 REA 模型的“经济互惠”关系的直观体现。
3.3 第三步:补全参与者,处理特殊场景(退货、调拨、盘点)
基础事件流确定后,把参与者填上。内部参与者包括店员、仓库管理员、门店店长;外部参与者包括供应商、顾客、月结客户。每类事件必须能对应到具体的内部人角色和外部对象。
特殊场景往往在建模后期才暴露问题。退货不是把销售事件删掉,而是生成一个反向的新事件:库存回到店里、现金退给顾客;调拨事件跨门店发生时,是两个事件配对,一个门店库存减少,另一个门店库存增加;盘点损耗是一个特殊事件,它没有明显的经济互惠动作,但会减少库存资源,同时触发“损耗”这种非正常流出。这些场景都需要在设计时预留。
3.4 第四步:落成表结构和关键 SQL
把 REA 模型转成关系型表结构,可以保持下面这样清晰的形态。
CREATE TABLE agent ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, agent_type VARCHAR(16) NOT NULL, -- INTERNAL / EXTERNAL related_org VARCHAR(64) ); CREATE TABLE resource ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, unit VARCHAR(16) NOT NULL, category VARCHAR(32), is_active TINYINT DEFAULT 1 ); CREATE TABLE business_event ( id BIGINT PRIMARY KEY, event_code VARCHAR(32) NOT NULL, -- PURCHASE / SALE / RETURN / STOCK_ADJUST occurred_at DATETIME NOT NULL, resource_out_id BIGINT NOT NULL, resource_out_qty DECIMAL(14, 4) NOT NULL, resource_in_id BIGINT NOT NULL, resource_in_qty DECIMAL(14, 4) NOT NULL, internal_agent_id BIGINT NOT NULL, external_agent_id BIGINT NOT NULL, source_ref VARCHAR(64), -- 来源单据号 UNIQUE KEY uk_event (event_code, occurred_at, source_ref) );关键设计点是每个事件都有出和入两个资源侧,而不是用正负号去模拟。正负号看着简单,但无法同时表达两个不同资源的互换,比如“用积分换奶茶”就必然失败。
查询当前库存时就非常简单了:
SELECT resource_id, SUM(in_qty) - SUM(out_qty) AS stock_balance FROM ( SELECT resource_in_id AS resource_id, resource_in_qty AS in_qty, 0 AS out_qty FROM business_event UNION ALL SELECT resource_out_id, 0, resource_in_qty FROM business_event ) t GROUP BY resource_id;所有事件表里没有 UPDATE 库存数字的操作,只有 INSERT。这一点对并发和数据一致性非常友好,因为库存永远是一个可推导的派生值,而不是一个可以互相覆盖的易变字段。
3.5 第五步:用三条校验规则给模型体检
模型落地前,我会把之前提到的三条规则变成检查项逐条过一遍。
- 规则一检查:有没有事件只涉及一个资源侧?若有,要么是建模不完整,要么需要业务确认是否真的存在“单向”事实。
- 规则二检查:每个事件的外部和内部参与者是否都非空?空着的多半是漏了。
- 规则三检查:抽三到五个资源,把期初数量加上事件推导结果和实际盘点或者业务台账对一遍。
这套体检成本不高,但能拦住绝大多数低级设计缺陷。我见过太多系统上线后才开始补事件流,靠手工 SQL 来回调账,非常折磨人。建模时多花这半小时,后面能省好几天。
4. 常见问题与排查技巧:建模时最容易翻车的 5 个点
4.1 把“订单”当事件,其实它是承诺
最常见的一个认知偏差,是把订单当成经济事件。严格来说,订单只是买卖双方的约定,钱和货都还没有实质性流动。如果以订单为核心表去建模,就会出现“已下单但一直未履约”的脏数据,而且订单的取消和修改会让事件流变得极不稳定。
我的处理方式是订单单独建表,状态只管业务过程的推进;订单完成后生成对应的事件记录(发货事件、收款事件)。订单和事件通过业务编号关联,但两者在数据模型上是分开的。这样既不影响业务方“看订单”的习惯,也不污染经济事实层。
4.2 只建库存表不建事件表,月底对账全靠运气
很多进销存系统设计者图省事,直接在产品表上挂一个 quantity 字段,每次出入库就 UPDATE 这个数字。这在单机小工具里能跑,但一旦出现退货、调拨、并发下单,这个字段会变成不可信任的黑盒。
REA 给的建议其实非常朴素:库存永远不要直接存,从事件推导。如果你觉得查询性能有问题,可以保留一份冗余汇总表,但事件表必须是唯一的真相源。汇总表可以重建,事件表不可以丢失。
4.3 事件只记单边流动,互惠关系被折断
“发完货就完事了,等财务后续再补收款”,我在不少项目里都听过这种话。事件从这个口子就断了:发货事件只记录了库存减少,没有记录应该收到多少钱。一个月后财务要平账,发现少了应收记录,又得补。
如果坚持互惠关系,发货事件应该同时生成一笔“销售出库”资源流出和一笔“应收账款”资源流入,或者预先生成两个事件、通过关联组合成完整交易。业务推进可以分阶段,但经济事实必须在事件层一次性闭环,最多用状态标记“应收已收”。
4.4 参与者塞进一个字符串字段,分析时欲哭无泪
有人建模时会在事件表上放一个 supplier_name VARCHAR(64),觉得这样简单直观。等到了做供应商对账、店员业绩、顾客消费行为分析的时候,同一个供应商可能有“xx奶茶原料公司”“xx原料公司(供货)”“XX公司”三种写法,一切分析都被文本匹配噩梦拖延。
从一开始就应该在 agent 表里管理参与者的规范名称和属性,事件表只存 agent_id。分析时通过外键去取统一信息,就不会出现数据质量分崩离析的状况。
4.5 单位与精度模糊,模型上线后返工
资源单位不统一是另一个高频坑。珍珠进货按“袋”,制作按“克”,销售按“杯”;月底一算,小数位对不上,到底中间换算错了几步都查不出来。模型初期应该把每种资源定死一个最小计量单位(比如“克”),所有事件都按最小单位入账,展示层再做单位换算。现金资源则要注意货币精度,分和元的选择要一致。
如果事件量很大,精度问题会在报表层被放大到不可收拾。宁可一开始多建一层单位换算表,也不要上线后靠脚本清洗。
4.6 十分钟自查清单
| 检查点 | 典型问题 | 建议处置 |
|---|---|---|
| 事件完整性 | 存在只有单边资源变化的事件 | 补全对应的资源流入/流出 |
| 参与者完整性 | 内部参与者或外部参与者为空 | 重新梳理业务责任人 |
| 资源可计量性 | 把订单号、备注当资源 | 移出资源清单,单独建实体 |
| 承诺与履行 | 订单事件与经济事件混表 | 拆分承诺表和履行事件表 |
| 库存推导 | 当前库存可被直接 UPDATE | 改为从事件流推导或单向累加流水 |
| 单位一致性 | 同种资源出现多个单位 | 统一最小计量单位 |
5. 从进销存到更大的舞台:REA 的延伸玩法
5.1 数据中台:把 REA 当作事实层的设计基底
数据中台建设最头疼的问题之一,是不同业务线对“订单”“成交”“用户”的定义不一致。REA 提了一个思路:与其纠结名词,不如把精力放在底层可验证的经济事实上。销售就是“某种资源从我们流向客户,某种资源从客户流向我们”,这个定义放到电商、门店、渠道批发里都成立。
所以我在搭数据中台的分层时,会建议把事件表作为 DWD(明细数据层)的公共事实模板,各业务线用统一的资源映射表和参与者映射表,再做一层自己的扩展字段。这样跨业务的经营分析,比如“全渠道的现金流入”“所有库存资源的消耗趋势”,就有了一个可拼装的共同底座。
5.2 微服务与事件溯源:REA 就是天然的事件契约
微服务架构里流行事件驱动设计,但很多团队在设计事件时很随意:有人发一条“user_created”,有人发一条“order_paid”,字段和语义五花八门。如果用 REA 约束事件设计,每条落到消息总线上的业务事件都必须回答三个问题:动了哪个资源?产生了什么方向?哪些人在场?这恰好就是一份高质量事件契约。
更进一步说,REA 和事件溯源(Event Sourcing)几乎是天生一对。事件溯源要求所有状态变化都被持久化事件表达,REA 给出了这些事件的骨架结构。想自己实现一套轻量事件溯源引擎的同学,完全可以拿 REA 作为领域模型的基础。
5.3 钱包、积分、供应链金融:所有资产流转都可以建模
表面上 REA 总是出现在进销存和会计领域,但把视角放大一点,凡是涉及资产流转的场景,它都能用。个人记账应用里,每月工资入账是事件,消费支出是事件,信用卡还款是事件,余额和负债都是推导出来的资源。区块链场景下的智能合约也一样:代币转移就是资源流入流出的两个事件,钱包地址就是参与者。
我最近在做一个小项目时,甚至用 REA 给家里的家庭预算做了套脚本:收入的资源、支出的资源、家庭成员的外部参与者视角,写起来非常顺手。这套模型的表达能力很强,关键在于你是否愿意在早期多花一点时间把“资源和事件”想清楚。
我个人在实际操作中最深的体会是:REA 给我带来的最大收益不是数据库表怎么建,而是和业务方沟通时终于有了一套统一语言。业务方说“我要加一个退货流程”,我可以立刻问“退的是哪个资源的流入、哪个资源的流出、内外部参与者是谁”。有了这套语言,需求评审从互相猜谜变成了对着模型补细节,效率完全不在一个量级。
如果你最近也在重构一套业务系统,建议先别急着写代码,拿一张纸画一个事件表草稿,把进出资源、参与者列出来试试。这张纸画顺了,后面所有表结构和接口设计都会顺很多。