我第一次见到REA这个缩写,是在和一个做财务系统的朋友聊天时。他提到核心表全部按“资源-事件-参与者”来建模,我当时第一反应是:这不就是把ER图画细一点吗?直到后来,自己被一个销售对账需求逼到墙角,被一堆改来改去的状态字段折腾得焦头烂额,才回过味来——REA不是一种画图风格,而是一种描述业务事实的纪律。简单说,REA是Resource(资源)、Event(事件)、Agent(参与者)三个词的缩写,它把每一次有经济后果的业务动作都拆成一笔“可回放”的事件,而不是一条条会被覆盖的记录。
这篇文章想聊清楚的,就是这套建模思想到底是什么、怎么落地、以及我在实践里踩过的坑。适合正在做订单、库存、账务相关系统的开发者和数据建模的同学。如果你只想应付一个简单表单系统,REA确实有点重;但只要你见过“一张订单表往里面堆了十几个状态位”的场面,就值得花十分钟把下面的内容看完。
1. 为什么用REA——先想清楚业务怎么“记账”,再谈表结构
1.1 库存表暴露出的问题:余额是“结果”不是“事实”
大多数业务系统在起步时,Model都是这么设计的:一张商品表带一个stock_qty字段,一张订单表带一个status字段,一张用户表带一个balance字段。这套设计在业务简单时没什么问题,可一旦出现退款、取消、补单、对账、审计,麻烦就来了。
举个最常见的场景:商品A的库存字段写着58,但你需要回答“这个58是怎么来的”。你只能翻订单流水,看哪些单子加了、哪些单子减了。如果中间有人直接改过库存字段,或者某个订单状态被回滚,这个58就已经失真了。问题的根源在于:你把一个应该被推导出来的“结果”当成了“事实”来存储。余额、库存、状态这些都是某个时刻的状态快照,它们会变,而且一变就丢掉了历史。而真正不会变的,是那些曾经发生过的事件:某人在某天买了三件商品,某人在某次退货中退回一件。
1.2 REA的三元组:从“表结构”回归“经济事实”
REA最初是会计领域提出的建模思想,核心就三个词:
- 资源(Resource):有价值的东西,比如商品、现金、积分。
- 事件(Event):让资源发生增减的业务动作,比如销售、采购、付款。
- 参与者(Agent):参与事件的个人或组织,比如顾客、供应商、销售人员。
用一句话概括:**谁,在什么时候,因为什么事件,让什么资源发生了怎样的变化。**这三个词组合起来,能描述绝大多数业务场景。
比如“书店卖出三本书”:资源是书和现金,事件是销售和收款,参与者是顾客与书店。这个描述里没有“库存数量”这种会被覆盖的数字,有的只是可追溯的事件。你可以随时问系统“当时发生了什么”,而不是“现在还剩多少”。
REA最让我佩服的一点是它跳过了中间状态。传统建模关心“订单现在处于什么状态”,REA只记录“发生过什么事件”。状态是事件发生后自然推导出来的东西,而不是需要小心翼翼维护的易变字段。
1.3 REA与事件溯源的区别
很多人会把REA和事件溯源(Event Sourcing)混为一谈,这里说一下我的理解。事件溯源是一种存储与实现手段,核心是“不更新事实表,只追加事件日志”;REA则更像一种业务分析框架,它告诉你哪些东西该被称为“资源”、哪些动作才值得记成“事件”。
两者天然契合,但不是一回事。我实际项目中用过两种结合:底层按事件流存储,对外暴露按REA语义组织的聚合接口。如果团队规模小,也可以先只用REA做数据库表结构设计,服务层不搞复杂的事件总线,照样能解决很多对账问题。REA给你的是业务视角的清晰度,事件溯源给你的是存储层面的不可变性。
2. 拿销售场景手把手建模:从业务事件到ER图
2.1 场景定义与实体清单
先说一个我练手用的虚构场景,就叫“模拟项目X”吧,一个跨平台的销售系统。业务流程本身不复杂:顾客下单购买商品,系统从仓库发货,顾客付款,平台收到货款。看起来就是一个普通电商闭环,但要支持售后、退款、对账,传统表结构已经开始吃力。
按照REA的思路,第一步不是画ER图,而是把业务里“有经济后果的动作”全部列出来。我当时的动作清单是这样的:
- 顾客下单(承诺购买)
- 系统发货(商品离开仓库)
- 顾客付款(现金进入平台账户)
- 商品采购入库(商品进入仓库)
- 支付供应商货款(现金流出)
注意:像“用户浏览商品”“加入购物车”这类动作不算经济事件,它们不改变任何有价值的资源,所以不需要建模成Event。
2.2 三类核心实体怎么识别
实体识别有个很实用的判断标准:**问自己,这个数据如果被删除,会不会让历史账目对不上?**如果会,它大概率是事件或资源;如果不会,它可能是辅助信息。
在上述场景里,资源有两类:商品类资源(库存商品)和资金类资源(应收款、现金)。参与者至少三个:顾客、销售平台、供应商。事件则有销售、发货、收款、采购、付款,共五个。
还有一种判断方式:资源是名词,事件是动词。名词如“商品”“现金”“积分”是资源;动词如“销售”“付款”“采购”是事件。参与者则是动作的执行主体。这套识别规则几乎可以直接套用。
2.3 关系如何建立:资源流、转换流、职责流
识别出实体之后,最关键的步骤是建立关系。REA建模里关系主要分三类:
第一类是“事件-资源”关系,也叫资源流。销售事件消耗了商品资源,同时创造了应收款资源;付款事件消耗了应收款资源,同时增加了现金资源。用通俗话说,事件像管道,资源像管道里流动的水。
第二类是“事件-事件”关系,也叫转换流。销售和付款之间是配对关系,前者代表“应给”,后者代表“实际给”;采购和付款之间同样是配对关系。这种配对可以让你发现某个销售事件发生了,但对应的付款事件迟迟没到——这就是未结应收。
第三类是“事件-参与者”关系,也叫职责流。每个事件至少要关联两个参与者:执行方和接收方。比如销售事件,执行方是平台,接收方是顾客。某个事件如果不关联参与者,说明业务描述不完整。
这三类关系理清之后,ER图基本就出来了。我一直认为REA最值钱的地方就在这一步,它强迫你想清楚“这笔业务动作到底交换了什么”,而不是一上来直接按页面原型建表。
2.4 通过流程连接看懂整条业务链
实体和关系都有了,怎么把它们串成一条完整的业务链?关键在资源。
继续用书店的例子:采购事件把现金资源转换为库存商品资源,销售事件把库存商品资源转换为应收款资源,收款事件把应收款资源转换为现金资源。这里现金资源同时出现在两个事件的两端,它就是连接采购、销售、付款这几个事件的“铰链”。
我在实际建模时,会专门检查每一类资源是否至少出现在一进一出两个事件里。如果某类资源只有进的没有出的,或者只有出的没有进的,那很可能漏掉了某个关键业务动作。比如只建模了“发货”却没有“收货”,库存商品资源的链条就断了。
这种从“资源流动”角度审查业务的方式,比单纯画ER图更容易暴露业务盲区。它让你站在账本的高度看系统,而不只是站在接口的角度看功能。
3. 从模型到表:建表、编码与查询落地方案
3.1 事件表、资源表、参与者表怎么设计
模型阶段再怎么清晰,最终也要落到数据库表上。下面这组SQL是从我实际项目里简化出来的,数据库用PostgreSQL,但换成MySQL只要改一下自增语法就行。
资源表:
create table resource ( id bigint generated always as identity primary key, code varchar(64) not null unique, name varchar(128) not null, res_type varchar(32) not null, currency varchar(8) default 'CNY' );参与者表:
create table agent ( id bigint generated always as identity primary key, agent_type varchar(32) not null, name varchar(128) not null );事件表:
create table event ( id bigint generated always as identity primary key, event_no varchar(64) not null unique, event_type varchar(32) not null, occurred_at timestamptz not null, buyer_id bigint references agent(id), seller_id bigint references agent(id), created_at timestamptz not null default now() );事件资源关联表,用来记录每个事件影响了哪些资源、增减数量是多少:
create table event_resource ( id bigint generated always as identity primary key, event_id bigint not null references event(id), resource_id bigint not null references resource(id), quantity numeric(18, 4) not null, unit_amount numeric(18, 4) not null );这里有几个细节值得注意。一是金额数值统一用numeric,不要用float,对账场景下浮点误差会让你生不如死。二是event_no加上唯一约束,这是幂等的基础,后面会展开说。三是occurred_at和created_at分开存,前者是业务实际发生时间,后者是记录写入时间,两者在补单、延迟同步时会出现明显差异。
3.2 事件幂等与不可变设计
REA模型落地时最容易踩的坑,就是把事件表当成“订单状态表”来用——有人下单了insert一条event,后面订单取消又把这条event删了或者改了状态。这么一搞,历史就被破坏了。
我的强制规范是:事件表只有insert,没有update和delete。业务发生变化时,不在原事件上修改,而是新增一个反向事件。比如订单取消,新增一个负数量的销售事件,跟原事件配对,账目依然能对平。这样做还有一个额外好处:并发场景下天然支持重试。客户端重复提交,由于event_no唯一约束的存在,后插入的同号事件会直接报错,不会造成双倍记账。
幂等这块我多说一句。消息队列场景里,消费者重复投递事件是常态。如果你的事件表没有唯一约束,对账报表会莫名多出一倍数字,排查起来非常痛苦。我后来在event_no生成规则里加入了业务线前缀加订单号,比如sale_order_20230402_001,既方便定位,又天然保证全局唯一。
3.3 派生状态的SQL实现
库存余额可以随时从事件表中聚合出来,核心思路是把每个事件对资源的影响方向约定好:采购为正、销售为负、退货为正。
下面是一个简化的库存视图SQL:
select r.id, r.name, sum( case when e.event_type = 'PURCHASE' then er.quantity when e.event_type = 'SALE' then -er.quantity else 0 end ) as current_stock from resource r left join event_resource er on er.resource_id = r.id left join event e on e.id = er.event_id where r.res_type = 'INVENTORY' group by r.id, r.name;这种查询写起来很规整,而且不依赖任何“当前库存”字段。如果发现库存对不上,直接从源头事件排查,不用去猜哪次update改错了。
3.4 工程妥协:投影表与物化视图
但这里有个现实问题:事件粒度很细,存量数据大了之后,每次库存汇总都实时聚合,数据库会撑不住。我项目里最后的方案是增加投影表(projection)。
投影表本质上就是“余额表”,比如inventory_balance,它的来源是事件表,但会通过一个订阅者或定时任务增量更新。当新事件写入时,投影表同步累加或累减。也就是说,我允许存在一份“结果表”,但它永远只是事件的派生品,而不是事实来源。修正数据时,必须改事件,再重放投影。
这套架构让我同时拿到了两个好处:短期查询性能上去了,长期可追溯性也没有丢。代价是写完事件后要维护投影更新逻辑,多了一点复杂度。我的取舍标准是:如果这个查询会被大量用户高频触发,建投影;如果只是月底跑一次报表,直接实时聚合。
4. 这些坑我替你踩过了:REA实战问题清单
4.1 事件表被当成“订单状态表”来更新
很多第一次用REA的团队,建模做得很漂亮,一到写代码就原形毕露。订单状态一变,直接update event set event_type = 'CANCELED'。事后对账时,系统里根本看不到“曾经有过一次销售然后被取消”的记录,只有一条孤零零的取消事件。
排查这个问题的唯一抓手是审计日志或者数据库binlog,极其痛苦。我后来在代码评审标准里加了一条硬性规则:**任何对event表执行update的代码都不许合入。**业务逻辑里要表达“取消”,就生成一个新的取消事件,两个事件拼起来才是完整的历史。
4.2 退货和退款被建模成“修改原事件”
还有一次,某团队把退货设计成了直接改销售事件的数量:原来卖3件,退1件,就把销售事件的数量改成2。表面看库存对了,实际应收款、订单快照、财务入账全部对不上。
正确做法,是在销售事件之外新建一个退货事件,数量为负,关联同一个商品资源。这样“卖了3件,退了1件,净卖2件”是查询层算出来的结果,而事实层永远保存着两条原始记录。退款也是一样,不要改原付款事件,新增一笔负向付款事件来充抵。
4.3 事件粒度失控
另一个典型错误是把所有用户操作都建模成事件。“用户加入购物车”“用户点击了结算”“用户修改了收货地址”,如果这些也算事件,事件表会膨胀到无法维护,而且九成是没用的事件。
我的判断标准是:**只有让资源价值发生变化的行为,才值得记事件。**加购物车没有改变库存、现金、应收款,不值得记;结算产生订单并锁定库存,改变了库存资源,值得记。把这条标准写进建模文档,能让团队少吵很多架。
4.4 什么时候不该用REA
说句公道话,REA不是银弹。如果业务只是一个内部工具,比如员工名册管理、会议室预订,强行上REA只会增加开发量。这类项目根本没有对账和审计需求,一张表搞定的东西没必要拆成事件和资源。
判断要不要用REA,我一般问三个问题:第一,这个业务是否有经济后果,是否涉及金额、库存、积分等敏感资源;第二,你需不需要回答“过去某时刻系统里是什么状态”;第三,是否有多方协作导致的纠葛场景,比如顾客、平台、供应商之间对账。三个问题只要有两个以上是肯定的,REA就值得引入。
5. 从一张表到一套账:REA改变的不只是数据库设计
5.1 编码层面更容易测试与扩展
用REA建模之后,业务扩展带来的数据结构变动会少很多。新增一个业务动作,往往只是往事件表里增加一种event_type,配套关系表复用现成的。总不可能每个新需求都新造一张资源表吧,做审计时却能轻松按事件维度拉全量流水。
这一点在接口设计上特别明显。传统状态机风格的服务,每加一个状态就要改一堆校验逻辑和接口文档;REA风格的服务,对外暴露的核心接口是recordEvent,业务动作进来就落事件,派生查询通过投影完成。新增业务时,服务骨架不用动。
5.2 对账、审计、报表变得理所当然
我印象最深的是一次月结对账,财务要求平台、仓储、支付渠道三方数字一致。如果用传统表结构,光是把订单表、退款表、优惠券表、支付流水表join一遍就够喝一壶。REA体系下,所有资源增减都沉淀在事件表里,按资源和时间窗口做一次聚合,账目天然平齐。
这背后的原理是REA本身继承自会计的复式记账思想。每个事件至少影响两个资源,比如“发货”同时减少库存资源、增加应收款资源,一减一增,资源总量守恒。有这个约束兜底,报表对不上时基本可以断定是漏记或重复记事件,而不是逻辑错乱。
5.3 从小模块试点开始
最后给想尝试REA的同学一个落地建议:不要一上来就把老系统全部重写,找一个业务边界清晰、对账需求强烈的模块试点,比如积分账户或者库存流水。用REA建模这两个模块,再把老系统并行跑一段时间,对比新旧账目。
我当时的做法是,先做一个只读的“事件查询后台”,把订单、售后、支付流水按事件结构展示给运营和财务看。他们一旦习惯了“所有数字都能追溯”的查询方式,就再也回不到只能看当前状态的老系统了。
这个内容后续还可以这样扩展:给事件表加上责任链、审批流,让敏感操作需要授权后才会写入。根据我个人经验,REA最大的价值不是帮你设计出一套高明的表结构,而是逼着你换一套语言去描述业务。先写清楚“发生了什么”,再考虑“怎么存”,一半以上的建模争论会自然消失。愿你少走我走过的弯路。