看到rea这个标题,我第一反应不是“读”,也不是某个地区缩写,而是很多年前那个库存项目——REA:Resource-Event-Agent。先跟你交个底,这篇文章我按“资源-事件-参与者”建模来写。如果你手里正捏着订单、库存、应收应付这类系统,或者被借贷对账、数据改来改去搞到头大,这篇文章能给你另一套建模思路:把业务当成一条事件链,把余额当成事件链的投影。前半部分讲清楚REA到底在解决什么问题,后半部分直接给出表结构、SQL、踩坑经验,你可以照着抄。
1. 为什么无头无尾的“rea”会被我解释成业务建模
1.1 一次库存对账事故,让我重新审视“记账”
第一次接触REA,是因为一次库存模块重构。上下游系统各自维护了一份“库存量”,每天都跑对账任务。看起来原理很简单:库存等于入库减出库,但实际对账时经常差几千件。查到最后发现,仓库有个流程是“先发货后补单”,补单时单据时间填的是前几天;财务系统按入账时间记账,订单系统按业务发生时间记账。两个时间不一致,同一笔业务落到不同天里,余额自然对不上。
传统做法是对账日纠错:哪边不平,手动调一分录。这种办法治标不治本,因为系统里根本没有一个“这盒物料在几点几分被哪个仓库发出去了”的独立事实。当时我们最终得出的结论是:与其在余额上反复找平衡,不如把每一次资源变动都记录成不可变的事件,谁要余额谁自己去重放。这个思路,就是REA的雏形。
1.2 REA是什么:三个单词,一条价值链
REA等于Resource(资源)、Event(事件)、Agent(参与者)。这套模型把企业经济活动浓缩成三个问题:什么东西在变?发生了什么动作?谁参与了这个动作?
举一个很普通的例子:一杯咖啡从豆子到顾客手里。咖啡豆是资源;咖啡豆入库、烘焙、制作、售出、收款都是事件;采购员、咖啡师、顾客都是参与者。库存余额不是又一个实体,而是“入库事件总量减去出库事件总量”的结果。这个直觉,比一堆“借库存贷应付”的科目表直接不少。
很多年前我第一次看到这三个词,心里也犯嘀咕:这不是流水账吗?等我真正把传统借贷账目拆开,才发现事情没那么简单。
1.3 它解决的核心问题:把事实与结果分离
REA最关键的一点,是区分“事实”与“派生结果”。在传统系统里,库存余额、应收余额被当成事实;在REA里,它们只是视图。真实的事实是事件流。谁把余额表删了都不怕,事件还在就能重建;谁把事件删了,那才叫事故。
这个思想不是我的发明,很早就有学者在企业建模领域讨论过。后来我在技术评审里反复复用它的核心价值:一是可解释性,任何余额都能从一个事件链推导出来;二是可追溯性,从发货单能一路追溯到供应商、采购单、付款;三是可扩展性,新增业务类型时不用改一堆寄存的逻辑,只是新增一种事件类型。
但很多人听到这里会追问:那借贷记账法到底哪里不行?下面我就认真聊聊这个。
2. 传统借贷账目体系的卡脖子点:记录的是结论,不是事实
2.1 借贷分录的隐含假设
借贷记账法本身是个非常聪明的设计,它把每笔经济业务拆成“资产来源”和“资产去向”两个方向,用“有借必有贷,借贷必相等”保证账目平衡。问题不在于这个设计本身,而在于系统运转久了之后,大家把“科目余额”当成了唯一事实源。
在企业系统里,总账科目余额本质上是聚合结果。聚合结果可以被重建,但很多系统设计把它当成不可替代的事实,于是每次修数据都要做冲销、补记、调账。调来调去,余额看齐了,但业务事实被搅成一锅粥。
REA模型并不否定记账功能,它只是把借贷背后的经济事件、资源、参与者重新显式建模。打个比方:借贷体系相当于直接放映电影截图,REA则把拍摄素材、剪辑脚本、演员分工都存了下来。截图可以随时从素材里再生成,前提是素材不能被随意覆盖。
2.2 多币种、多仓、多租户下的复杂度爆炸
当业务只有一台收银机时,借贷表也够用。但业务一复杂,整个链条就开始卡脖子。
拿多仓库举例:A仓发货到B仓,数量只是从一个仓库挪到另一个,借贷里却要先记“A仓库存减少”,再记“B仓库存增加”。商品价值不一样时,还要补一笔“库存差异调整”。业务逻辑每多一层,这种调整分录就多一类。
多币种更痛苦。一个订单用美元成交、用人民币对账、中间还要评估汇兑损益,借贷系统里的科目组合成倍增加。其实每种币种对应的现金是一个资源,汇兑损益不过是一个特殊事件导致的资源价值重估。如果用REA,事件表加一个币种维度,再定义一次重估事件即可。
多租户场景同样如此。传统建模往往把客户编号挂在一个大维表里,再用过滤条件把所有查询包一层。REA天然以Agent区分主体,授权关系本身就是模型的一部分,不需要额外的租户魔改。
2.3 用事件对偶替代借贷符号
再看深一层,REA里有个概念叫对偶关系。我理解它就是在回答“为什么会有借贷相等”:在现实经济行为中,一个资源的流出,往往伴随另一个资源的流入。卖出咖啡,是咖啡豆库存流出,对应现金流入;采购生豆,是供应商那边的资源流出,对应我们这边现金流出。两个事件必须配对,这个配对关系被显式建模成事件与事件之间的链接。
一旦把对偶关系建模出来,系统就比传统借贷表多了一个能力:可以在不依赖会计科目的情况下,推导出任意两个资源之间的因果联系。看账本时看到的不是孤立的金额,而是资金怎么从A跑到B再到C的路径。
这也是我之前觉得“这不就是流水账”不准确的原因:它确实是流水账,但是一张把流水中每一滴水的来源、去向、责任人都标注清楚的流水账。下一节,我们把它的边界真正落到表结构上。
3. 资源、事件、参与者:三个构件的边界怎么拿捏
3.1 Resource:不是产品目录,而是价值载体
Resource在REA里定义得很宽:库存商品、现金、应收账款、固定资产、知识产权,甚至外包团队的时间盒都可以是资源。判断标准很简单:第一,它有价值;第二,它能被企业计划和控制;第三,它能因为一个事件增加或减少。
对实施者来说,最常见的误区是把资源表当成产品目录。产品目录里那些没有库存核算价值的元数据属性,应该放在单独的产品维表里,资源表只管“算得清增减”的账。比如同款咖啡豆,有“在库”和“在途”两种状态,与其建模成两个资源类型,不如建模成同一资源类型下两个独立资源实例,或者让事件的时间戳自然表达在途与否。状态永远是视图,不要让它轻易渗透到资源主数据里。
我在实际项目里养成了一个习惯:每新建一个Resource之前先问自己一句,未来会不会为它单独创建Event?如果不会,它可能就是另一个资源上的属性,而不是独立资源。这句话帮我砍掉过不少设计冲动。
3.2 Event:不可再分的经济动作,不是操作日志
Event是最容易被高估也最容易做错的一层。它要求记录的是“不可再分的经济动作”,比如一次收货、一次发货、一次收款、一次转账。判断标准可以拆成几条:是否能让某个Resource的数量或价值发生变化?是不是在真实世界里已经客观发生?拆成更细的子事件后是否还有业务意义?
举个例子,“点击下单”这个动作本身不是经济事件,它只是表达购买意图;“创建订单”也不是经济事件,订单属于契约;真正的事件是“仓库发出5箱货”。很多团队设计表时把订单创建、订单审核、订单配送、订单完成全部塞进一张大日志表,美其名曰事件驱动。这会让后续做余量计算时非常痛苦,因为一半记录根本不该影响任何资源。
事件表里最需要注意的是方向与事件类型的分开存储。方向(INFLOW/OUTFLOW)是计算余额必须的信号,类型是业务语义。同一种类型在不同场景下可以有不同的方向,比如“销售发货”对库存是OUTFLOW,对销售费用可能是INFLOW。把两者塞进同一个字段,后期会带来数不清的隐患。
3.3 Agent:责任归属与授权边界
Agent解决的是三个问题:谁发起了事件?谁承担了结果?谁授权了这件事?落地时最常用的做法,是把参与者区分为内部参与者与外部参与者。内部参与者就是本系统内的员工、角色;外部参与者就是客户、供应商、承运方。
权限控制层面,Authorization表专门在参与者和事件类型之间建立授权关系。比如仓库主管可以授权发货事件,采购员可以发起采购合同。这个设计比单纯塞进现成权限框架里更贴合业务事实,因为业务授权不只是“能不能点按钮”,而是“能不能对该资源执行该经济事件”。
经验之谈:Agent表不要企图记录所有组织架构。部门层级、汇报线是组织信息,不是经济参与者。把部门硬塞进Agent里,短期看只是多存几个字段,长期看部门调整时你根本不知道要不要新建一条资源变更事件。
4. 一个销售库存业务的Schema:从事件反推账本
4.1 先建Event表,而不是先建余额表
REA落地时,我推荐从Event表起步。这跟传统系统正好相反:传统系统先定库存余额表,然后为了追溯才去加流水表,结果流水表永远滞后,永远对不上。如果先定义事件,所有报表都是从它派生出来的投影表,加字段本质上是加一种新事件类型,改动可控。
下面这组DDL非常精简,适合销售库存场景起步。实际生产环境还需要补充租户ID、数据版本号等字段,这里先保留最核心的部分。
CREATE TABLE resource ( resource_id BINARY(16) PRIMARY KEY, resource_name VARCHAR(64) NOT NULL, resource_type ENUM('ITEM', 'CASH', 'ACCOUNT', 'SERVICE') NOT NULL, unit VARCHAR(16) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE agent ( agent_id BINARY(16) PRIMARY KEY, agent_name VARCHAR(64) NOT NULL, agent_type ENUM('INTERNAL', 'EXTERNAL') NOT NULL, is_customer BOOLEAN NOT NULL DEFAULT FALSE, is_supplier BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE event ( event_id BINARY(16) PRIMARY KEY, event_no VARCHAR(32) NOT NULL UNIQUE, event_type VARCHAR(32) NOT NULL, direction ENUM('INFLOW', 'OUTFLOW') NOT NULL, resource_id BINARY(16) NOT NULL REFERENCES resource(resource_id), quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4), occurred_at DATETIME NOT NULL, recorded_at DATETIME NOT NULL, internal_agent_id BINARY(16) NOT NULL REFERENCES agent(agent_id), external_agent_id BINARY(16) REFERENCES agent(agent_id), UNIQUE KEY uk_dup_guard (event_no, resource_id) );注意event_no这个唯一键,生产环境里对应的是上游单据唯一号。两笔相同的业务如果被重复录入,数据库层会直接拒绝,这是幂等的第一道防线。第5章我会细聊这条设计为什么重要。
4.2 当前库存怎么算:一条SQL直接求和
需要当前库存时,不需要去读任何余额表,直接把Event表按Resource和方向求和即可。
SELECT r.resource_name, SUM(CASE WHEN e.direction = 'INFLOW' THEN e.quantity ELSE -e.quantity END) AS current_stock FROM event e JOIN resource r ON e.resource_id = r.resource_id WHERE e.event_type IN ('PURCHASE_ARRIVAL', 'SALES_SHIPMENT', 'STOCK_ADJUSTMENT') GROUP BY r.resource_id, r.resource_name;这里我把查询限定在影响库存的事件类型里,而不是把Event表里所有记录都拉进来。很多新手会把所有事件全加一遍,结果把收款、账务调整之类的INFLOW也加进了库存。所以在设计事件类型时,应该给每类事件定义“它影响哪类资源、方向是什么”,形成一张事件类型注册表,后续所有查询都依赖这张表。
4.3 应收应付自动派生:发票和收款本身就是一进一出
REA还有一个非常好用的红利:应收账款余额不是单独维护的,而是从发票事件和收款事件里推导出来。每张发票是一个INFLOW事件,经济资源是“应收账款”;每次收款是一个OUTFLOW事件,把应收账款减掉。合在一起就是当前客户欠款:
SELECT a.agent_name AS customer, SUM(CASE WHEN e.event_type = 'INVOICE' THEN e.quantity * e.unit_price ELSE 0 END) - SUM(CASE WHEN e.event_type = 'PAYMENT' THEN e.quantity * e.unit_price ELSE 0 END) AS outstanding FROM event e JOIN agent a ON e.external_agent_id = a.agent_id JOIN resource res ON e.resource_id = res.resource_id WHERE res.resource_type = 'ACCOUNT' AND e.event_type IN ('INVOICE', 'PAYMENT') GROUP BY a.agent_id, a.agent_name;这种设计下,退款就是一笔新的负数量事件,而不是去修改原事件。历史库存增量没有被破坏,审计链路也清晰得多。
5. 报表、审计与事件溯源:从REA得到的三个红利
5.1 报表不再是“查余额”,而是“重放行为”
传统报表是查一张快照表,REA是重放一段行为流。我们把余额理解成暂停帧:我要看昨天的库存,不依赖定时快照,只需要加一个时间过滤条件,重放到昨天那一刻。
这带来一个额外能力:任何业务问题都能回到原始行为去排查。比如财务问“这批货物价值怎么波动这么大”,传统系统只能看到调账后的金额;REA里可以把入库、出库、报损、移仓事件全部摊开,每一步写的是什么原因一目了然。它天然适合做审计追踪,因为事件流本身就是一笔完整的行为账。
5.2 不可变事件、幂等键、业务时间戳,三个红线必须一起立
想享受REA的好处,前提是把底层规则立住。我在项目中始终坚持三条红线。
第一,事件表只做INSERT,禁止UPDATE和DELETE。哪怕录错了,也只能再补一条调整事件,把错误影响抵消掉。听起来繁琐,但换来的是一劳永逸的简单:任何时刻的事件流都是不可篡改的历史。
第二,幂等键必须在数据库层兜底。最稳妥的方式是给业务事件号加唯一索引,再配合外部消息或批处理任务里的去重逻辑。没有唯一索引,再好的应用层去重也可能在并发下漏掉。
第三,业务时间戳与系统入账时间戳分开存。occurred_at记录真实业务发生时间,recorded_at记录系统落库时间。报表按业务时间聚合,问题排查按入账时间查询,两者互不干扰。跨时区、跨系统的场景下,这个双时间戳设计能避免很多扯皮。
5.3 和事件溯源、微服务架构的配合
REA模型与事件溯源架构天然亲和。微服务拆分时,服务之间传递的往往就是业务事件,比如库存扣减、订单付款成功、退款发起。REA正好给这些事件定义了标准范式:有资源、有参与者、有方向、有时间。
但这里我有一个明确的提醒:不要把REA事件和消息队列里的消息画等号。REA事件是已经发生的经济事实,持久化在主库里;MQ消息是传输载体,可能丢失、乱序、重复。正确做法是把REA事件作为唯一事实源,消息只是它的一个副本或者投影。下游服务收到消息后,可以异步更新自己的投影表,投影过期了就从REA事件重放一次。
6. 用REA建模时最容易翻车的三个坑
6.1 把Event表写成操作日志
第一次落地REA时,我见过最典型的翻车现场,就是Event表被当成万能的“操作日志”。用户登录、按钮点击、页面浏览也往里插。结果查询余额时全表扫描越来越慢,而且业务查询根本不知道该过滤哪些事件类型。
判断标准我再重复一遍:只有会引起Resource增加或减少的动作,才配称为经济事件。行为日志请单独放到日志系统,两套数据的生命周期、存储策略、访问模式完全不同,混在一起只会两头难受。
6.2 过度建模:把流程节点全部事件化
另一种相反的极端是把所有东西都建模成Event。订单创建、订单审核、订单配货、订单发货、订单完成,全部分解成一串事件。表面上挺RER,实际上业务查询需要跨十几个事件类型才能拼出一个订单状态,开发效率急剧下降。
原因在于订单本身是契约,不是经济事件。契约表达的是双方约定,事件表达的是资源实际增减。下单时不产生资源增减,货物出库才产生。契约表和事件表分开建模,一个管承诺,一个管事实,这样订单状态自然有了归属。
6.3 忽视投影层的性能设计
不能只建一个Event表就上线。事件表会无限增长,业务报表不可能每次都全表重放。合理的做法是在同一事务里写事件和更新投影表,或者用异步消费者更新投影表。库存余额、应收余额、销量统计这些高频查询走投影表,历史分析走事件表。
我曾经为了省事,报表直接对Event表做全量聚合,数据量到千万级以后查询直接卡死。后来加了一张库存投影表,每次写入事件时同时更新投影,报表查询从秒级变成毫秒级。所以建议把Event表当作唯一事实源,但不要让它成为唯一的查询源。
6.4 把REA套用到根本不需要的地方
REA也有明确的适用边界。纯文档管理、简单CMS、个人账本这类业务,资源流动极少,事件类型单一,引入一套完整的事件模型只会增加表和逻辑的复杂度。倒不如用普通业务表更直接。
什么时候应该用REA?我的判断是:业务里有多种资源频繁流动,存在多个参与者和多步审批授权,同时你还特别需要追溯历史行为和生成弹性报表。只有这类系统,REA的投资才值得。
7. 我的落地最小集:十个表起步
7.1 表清单:一个保守但完整的REA骨架
如果你准备在一个真实业务里尝试REA,我建议从下面这组表开始。不追求一次覆盖全部理论,先保证核心链路跑得通。
| 序号 | 表名 | 作用 | 备注 |
|---|---|---|---|
| 1 | resource | 资源主数据 | 库存、现金、应收账户都在这里 |
| 2 | agent | 参与者主数据 | 内部角色与外部客户/供应商 |
| 3 | authorization | 参与者对事件类型的授权 | 控制谁可以发起哪些行为 |
| 4 | event | 经济事件明细 | 唯一的原始事实来源 |
| 5 | event_duality | 事件对偶关系 | 发货事件对应收款事件 |
| 6 | contract | 契约头 | 订单、采购单的承诺信息 |
| 7 | contract_item | 契约行 | 订单行、商品、数量、价格 |
| 8 | policy | 业务规则 | 折扣、信用额度、发货约束 |
| 9 | projection_inventory | 库存投影表 | 高性能当前库存 |
| 10 | projection_receivable | 应收投影表 | 高性能客户欠款 |
这十张表并不复杂,但已经覆盖了一个普通销售库存业务从承诺到履行再到结算的完整闭环。
7.2 老系统迁移的三步走
老系统里没有事件,只有一堆余额和账单,怎么迁移?我实践下来比较稳的顺序是三步。
第一步,把历史余额做成初始事件。先把当天库存、应收、应付的存量确认下来,作为时间起点,生成一条“期初事件”。这是整条事件链的地基。
第二步,按单据往回补近期事件。从最近三个月起,把采购入库单、销售发货单、收款单、付款单都反写成Event表。过程中会遇到单据编号缺失、上游系统时间不准等问题,尽量在转化期就把脏数据清掉。
第三步,双写并行并做日级对账。老系统继续跑一段时间,新系统同时接收新业务事件,每天比对库存与应收余额。只有连续两到三周误差为零,才敢切流量。
7.3 什么情况下我劝你“别用REA”
最后说句掏心窝的话:千万别因为这篇文章觉得REA是银弹。如果你的团队只有两三个人,业务一个月改不了一次,余额表直接一把梭反而更快。
我个人的体会是,REA最大的价值出现在业务进入高速变化期的时候。那时候你改一个库存规则,传统系统要动七八个模块,REA系统可能只加一种事件类型。但这是用前期的建模成本换来的——你需要愿意接受“事件先于视图”的设计思想,并且有足够的纪律性维护事件不可变、幂等和时间戳这三条底线。
最后再分享一个小技巧:技术评审上遇到“REA是什么”这种问题,别只背Resource、Event、Agent三个单词。把一杯咖啡的完整生命周期讲一遍——从采购豆子、烘焙、制作、售出到收款,每个环节谁是资源、谁是事件、谁是参与者,链路天然就是一套REA。把故事讲清楚,比背概念有用得多。