如果现在让团队把一个业务系统压缩成一张数据模型,很多人第一反应就是拉表建字段。可你有没有遇到过这种场景:订单表加了一个状态字段,运营说还要拆成待支付、已支付、已发货、已完成,于是状态枚举从两个变成八个;过几天财务说要核对资金流,产品同事又提出要看售后率、看退款原因,于是继续加标志位。最后这张表越改越厚,报表口径隔三差五对不上,开发改一个状态流转逻辑,下游导出数据就乱一片。
我真正把rea这个词用起来,是在某次被订单状态折磨到崩溃之后。它不是新语言,也不是某个开源工具,而是一套把业务过程还原成“谁在哪个时间让什么资源发生了怎样的变化”的建模思路。这篇文章就把这套思路讲透:先从资源、事件、代理三个原语拆开讲,再带你用这套方法重新画一张能让业务说真话的ER图。产品、后端、数据岗位的读者都适用,尤其是正在做订单、库存、账务这类强状态系统的同学。
1. "rea"到底是什么:一个让业务过程说真话的建模框架
1.1 为什么"状态字段"总在出错
做业务系统久了你会发现,绝大多数纠缠不清的问题,根源都在于我们用“对象当前状态”代替了“过程本身”。
拿订单来举例。一张订单表里放一个status字段,初值PENDING,支付后改成PAID,仓库发货后改成SHIPPED,用户确认收货后改成COMPLETED。看起来很顺手,但一旦业务复杂起来,这个字段就开始四处漏风。
第一,状态是多个行为被压缩后的结果,它丢掉了过程。你看到一个订单现在是RETURNING,但你不知道它是怎么走到这一步的,是用户申请了退货,还是客服主动介入,中间有没有审批、有没有部分退款。第二个问题是不同角色对“状态”的理解天然不一致。运营说的已完成是“用户确认收货”,财务说的已完成可能是“款项已经结清”,仓库说的已完成是“包裹已经出库”。同一个字段,三套语义,最后报表当然吵架。第三,状态字段在并发和回滚场景下很难维持正确性。用户一边支付一边申请取消,两个动作同时打到订单上,最后status到底该写PAID还是CANCELLED,得靠锁靠更新时间戳靠各种额外手段去兜。
这类问题我见过太多次。某次排查历史数据时,发现一张订单在流水记录里明明已经退款成功,但订单表上的状态还停在CANCELLED,因为退款接口只更新了资金侧状态,订单聚合根里的标志位没有联动。出问题后所有人都去查“怎么改这一步状态”,却没有人去问“状态这个字段本身,是不是就不该承担真相源的职责”。
REA要回答的正是这个问题:你能不能别只记录“现在是什么”,而是把“曾经发生了什么”也完整记下来。
1.2 REA三原语:Resource、Event、Agent
REA这个缩写来自三个词:Resource(资源)、Event(事件)、Agent(代理)。它最初是上世纪八十年代在信息系统和会计模型领域提出的一套业务建模思想,用来回答一个非常朴素的问题:一个组织到底靠什么运转?靠的是一群代理,在某个时间点,通过一系列事件,让资源发生流入、流出或转换。
我用自己的话翻译一遍:
- 资源(Resource):有价值、可计量、会被生产或消耗的东西。库存商品、现金、积分、额度、在途货物、服务工时都算。
- 事件(Event):让资源发生变化的动作。它有明确时间点,必须回答“哪个代理在什么时候做了什么”。
- 代理(Agent):参与事件并且承担责任的主体。可以是客户、供应商、员工、部门,也可以是系统机器人。
把这三个要素连起来就是一句话:业务 = 一组代理通过事件交换或转换资源。
用买咖啡举例。你口袋里的现金减少(资源减少),你拿到一杯拿铁(资源增加),做这两件事之间的动作叫“支付”(事件),参与事件的人是你和咖啡师(代理)。整个过程里没有一个叫“订单状态”的东西,状态只是这些事件发生完之后,你脑子里自然形成的印象。
这里有个关键转变。传统建表方式是给每个业务对象配一堆状态字段,订单有订单状态,支付单有支付单状态,售后单有售后单状态;REA的思路则是把业务拆成一条条“事实流”,状态从事实里推导出来,而不是单独维护一组可能互相打架的枚举值。
用表格对比更直观:
| 视角 | 传统表模型 | REA事实模型 |
|---|---|---|
| 记录重点 | 对象当前状态 | 随时间发生的资源变化 |
| 典型字段 | status、flag、amount | 事件类型、时间、资源增减、代理 |
| 历史追溯 | 难知道为什么变成这样 | 完整链路可回放 |
| 统计口径 | 依赖枚举值判断 | 按事件类型聚合即可 |
| 并发安全 | 需要锁和版本号保护 | 每条事件天然是个不可变事实 |
1.3 用REA不等于扔掉状态字段
有一点必须提前说清楚,免得有人走极端。REA不是让你把所有状态字段全部删除,而是在数据架构里区分“事实”和“投影”。事件表是事实,订单当前状态是投影。投影可以保留,甚至应该保留,因为查询订单列表不可能每次都把几十条事件翻出来重新聚合。
关键原则是:投影只能从事实推导而来,不能反过来让事实迁就投影。你把状态字段当作一个可随时重建的缓存去看待,它的维护成本和沟通成本都会低很多。
2. 资源(Resource):先搞清楚哪些东西值得被建模
2.1 资源的判断标准
很多团队在建模时,第一反应是“先建客户表、商品表、订单表”。这其实是面向“对象”的思考方式,不是面向“资源”的思考方式。要落地REA,第一步是回答一个问题:在这个业务域里,哪些东西是有价值、可计量、并且会被事件改变数量和状态的?
资源通常有三个特征:
- 有价值,至少能被业务语境里的人认为“值钱”。
- 可计量,有数量、金额、时长、积分这类可以加减的度量单位。
- 会被事件改变,要么被生产出来,要么被消耗掉,要么在不同代理之间转移。
实际业务里的资源往往比想象中广。库存商品是资源,现金是资源,客户账户里的余额是资源,优惠券是资源,里程积分是资源,仓库里外包的托盘位是资源,甚至“某台设备的剩余维修工时”也可以是资源。只要它满足上面三条,就值得以资源的方式建模。
常见误区是给“客户”“商品档案”这类主数据也贴上资源的标签。严格说,客户不是资源而是代理,商品档案更像是配置项。客户不会因为一单交易而“数量减少”,商品档案也不会因为库存售罄而消失。真正会变的是客户账户里的可消费余额、库存里的可售数量。这个区分很重要:把主数据和资源混在一起,会导致事件表里去记录“修改商品名”“修改客户地址”这种不涉及价值流动的动作,整个模型就变味了。
2.2 资源状态与资源账本
资源本身也有生命周期。以库存为例,一批货刚入库是AVAILABLE,被订单承诺占用后是RESERVED,发出途中是IN_TRANSIT,到了客户手里变成CONSUMED。如果只用一张inventory表反复更新库存总量和状态,一旦发生并发冲突,最后很难说清楚这个数字是哪些行为叠加的结果。
我在实际项目里的习惯,是为资源拆两层:一层是资源主数据,记录静态属性的resource_item;一层是资源账本,记录每个资源实例当前存量。营造一下创建resource_item表的常见形态:
CREATE TABLE resource_item ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(64) NOT NULL, -- inventory / cash / point / quota item_no VARCHAR(64) NOT NULL, -- 对应商品SKU或资金科目 batch_no VARCHAR(64), -- 可选,批次管理 location VARCHAR(64), -- 可选,仓库/门店/通路 total_amount NUMERIC NOT NULL, -- 总存量 available NUMERIC NOT NULL, -- 可用量 unit VARCHAR(16) NOT NULL, state VARCHAR(32) NOT NULL -- available / frozen / in_transit / consumed );这张表本身仍然是投影,是“当前资源状态”的缓存。真正驱动它变化的,应该是后续要讲的事件表。你不能在代码里随手UPDATE inventory SET total_amount = total_amount - 5,而是要记录一条“库存流出事件”,再由事件去更新投影。这样做的价值在于:当盘点差异出现时,你能回溯每一笔变动是从哪里来的,而不是对着一个神秘减少的数字干瞪眼。
2.3 资源盘点要能解释每一个数字
如果要用一句话总结做资源建模的长期体会:任何存量数字必须能被解释。财务对账、库存盘点、余额核对,本质都是在做这件事。你看到一个系统显示“库存余额是387件”,你必须能说清楚这387是由哪些入库、出库、退货事件累计出来的。如果数据库里只有一张不断被改写的汇总表,你就只能回答“它就是显示387”,然后等着业务部门找你吵架。
所以,资源建模的产出不应该只是一张字段更多的表,而应该是一份资源字典,写清楚每种资源用什么单位计量、什么属性区分、什么状态流转、哪些事件会影响它。这份字典比任何一张表都值钱。
3. 事件(Event):把时间凝固成"事实"
3.1 什么是业务事件
事件是REA里最核心的一块,因为资源不会自己变化,一定是某个代理做了某个动作才变化的。事件要记录的东西有三个:动作类型、发生时刻、涉及多少个代理和资源。
业务里典型的事件包括:payment.received(收到付款)、inventory.outbound(库存出库)、order.committed(订单承诺建立)、refund.issued(退款发出)、coupon.used(优惠券核销)。判断一个动作是不是值得建模成事件,可以问自己:这个动作发生后,会不会导致某个资源数值变化,或者某个代理的责任关系变化?如果会,它就应该是一条独立事件。
我见过不少团队把事件理解得太窄,以为只有支付流水、操作日志才叫事件。其实下单本身也是一条事件,虽然它没有立刻改变库存,但它让“我方对客户承担了履约义务”,也常常让可售库存被预占。这属于代理责任关系的变化,同样值得记录。
3.2 状态是事件的投影
“投影”这个词听起来有点数学味道,其实非常好理解。想象你在看一部连续剧,第1集是“下单”,第2集是“支付”,第3集是“发货”,第4集是“签收”。观众的观感是“这单完成了”,但这个观感并不是独立存在的东西,它是四集剧情播完后自然形成的结论。订单状态属性就是这种结论。
如果把状态当作独立字段去维护,你其实是在和“剧情推导结果”赛跑:用户支付触发状态改PAID,仓库操作触发状态改SHIPPED,稍有不协调,结果就和事实对不上。反过来,如果让状态由事件序列推导,至少数据不会自相矛盾。事件缺失了就补事件,投影错了就重算投影,永远有得查。
事件还能顺带解决几个业务上的老问题。审计追踪直接看事件表历史,不用翻操作日志;统计口径按事件类型聚合,不用在多个状态字段里做组合判断;回放和故障恢复可以把系统状态重建出来,因为事实都在。
3.3 事件的表结构应该怎么设计
为了让读者能直接落地,我给出一个通用的核心事件表结构。这里的原则是:把事件拆成“事件头 + 资源明细 + 代理明细”,因为真实业务里一个事件经常同时涉及多个资源和多个代理。
先看事件头:
CREATE TABLE business_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- 使用动词短语,例如 payment.received occurred_at TIMESTAMP NOT NULL, -- 业务实际发生时间 recorded_at TIMESTAMP NOT NULL, -- 系统记录时间 operator VARCHAR(128), -- 触发者,可能是代理ID或系统标识 business_no VARCHAR(128) NOT NULL, -- 外部业务单号,如订单号/单据号 unique_key VARCHAR(128) NOT NULL, -- 幂等键,防止重复入账 payload JSONB -- 其他上下文,尽量少用 );再看资源明细:
CREATE TABLE event_resource ( event_id BIGINT REFERENCES business_event(event_id), resource_id BIGINT NOT NULL, change_amount NUMERIC NOT NULL, direction SMALLINT NOT NULL -- 1=增加, -1=减少 );以及代理明细:
CREATE TABLE event_agent ( event_id BIGINT REFERENCES business_event(event_id), agent_id BIGINT NOT NULL, role VARCHAR(64) NOT NULL -- 发起方/接收方/审批方/承运方 );unique_key字段是重中之重。业务系统几乎都会遇到重复回调、重试提交、消息重复消费,没有幂等键,一条支付事件被插两遍,资金侧数字就翻倍了。用unique_key做唯一索引,重复请求直接幂等丢弃,这个坑不用非得自己踩一遍。
3.4 事件粒度怎么把握
事件粒度太粗、太细都会出问题。粒度过粗,一个“售后流程”被压成一条after_sale.closed事件,过程里谁审批了、商品退没退回来、退款什么时候到账,全丢在外面的临时表里;粒度过细,登录用户点了哪个按钮都记一条事件,存储和开发成本立刻起飞,业务却不会因此多得到任何价值。
我的判断标准是:只有当这个动作能单独作为责任单元、能单独改变资源或代理关系时,才建事件。例如“支付成功”是一个责任单元,因为资金到账了;“用户把鼠标移到按钮上”不是,因为它什么都没改变。先按这条线切分,再在评审会上逐步核对,粒度就不会偏得太离谱。
4. 代理(Agent):谁在系统里,谁的画像只是外挂
4.1 内部代理与外部代理
代理是事件的发起点,同时也是责任的承担者。REA里代理分两类:内部代理和外部代理。内部代理指组织内部的参与者,比如销售员、仓管员、客服、审批主管;外部代理指组织外部的参与者,比如客户、供应商、承运商。
这个区分不是拿来画流程图的,它直接决定权限边界和对账路径。客户的退款申请事件,代理列表里必须有“客户”和“客服处理人”,二者在退款这件事上的角色完全不同:客户是发起方,客服是处理方。如果代理信息没建模,你后续既要查“哪个客户退款最多”,又要查“哪个客服处理退款最多”,只能重写报表逻辑。
4.2 代理-事件-角色的三元关系
代理建模的一个关键点,是事件可以引入新代理,而不是只能写在事件的某个操作人字段里。一张payment.received事件,从客户视角看,客户是发起方;从我们系统看,收款确认人或者自动渠道是执行方。同一个事件里,两张代理记录分别对应不同角色。
这个结构的好处是权限和审批流能自然融入数据。某个代理能不能触发某类事件,可以设计成“角色+资源范围”的判断规则。比如只有区域经理角色的代理,才能触发超过五万的退款事件;超过的部分必须生成一条新的approval.passed事件,再由这条事件去推动退款事件执行。开发上可能要多跳一步,但每一步都有据可查,出了问题能精确定位到“谁在什么时间以什么身份做了什么”。
4.3 小心“系统”被当成万能代理
实际项目里最容易偷懒的做法,是把所有自动化动作都写成“系统自动执行”,代理字段随便填一个固定值。这样做短期没问题,长期一定吃教训。定时任务扣减了用户余额,数据异常时你要解释这笔扣减是怎么发生的,结果只能查代码日志,业务上完全没有上下文。
我建议把自动化程序本身也建模成内部代理,同时保留触发来源。例如事件表里的operator字段,可以存agent:auto_daily_job,但事件的payload里还要保存触发任务ID、入参快照、执行版本。这样数据正常时审计字段很干净,出了异常也能完整还原这次自动行为的前因后果。
5. 用"REA"重画一张订单系统ER图:从订单表到履行链
5.1 传统订单模型为什么一改就乱
很多系统的订单模块长这样:一张order表存客户、商品、金额、状态,一张order_item表存商品明细,再加上支付流水表、物流表,全部靠订单号关联。看起来正常,但改需求时非常痛苦。
运营要一个“取消率”,产品要一个“未支付放弃率”,财务要一个“实收金额”,三个人对订单表提出三种统计需求,最后都会落到status字段上,而这个字段根本无法同时承载那么多语义。我见过最极端的情况,是订单表里存了order_status、pay_status、refund_status、fulfillment_status四个状态字段,每次状态变更要同时维护四个字段的合法性,改一处漏三处。
5.2 订单域的资源、事件、代理怎么识别
用REA重画订单模型时,不要先想字段,先做三张清单。
订单域的资源清单大概是这样的:
| 资源 | 说明 | 对应事件 |
|---|---|---|
| 库存商品 | 可售数量,随承诺和发货变化 | 下单承诺、发货、退货入库 |
| 应收货款 | 客户应付的订单金额 | 下单承诺增加、支付减少 |
| 实收资金 | 我方实际到账资金 | 收款增加、退款减少 |
| 物流服务 | 承运方需要承担的送达义务 | 发货委托产生、签收完结 |
事件清单可以是:order.committed(下单承诺)、payment.received(收款)、inventory.outbound(出库)、shipment.declared(委托承运)、shipment.signed(客户签收)、refund.issued(退款)。代理清单则包括客户、销售员、仓库操作员、承运商、客服。
这里真正发生变化的是:订单不再是一个不断被改状态的怪物,而是一组事件围绕同一个业务上下文形成的聚合。订单号仍然要保留,它相当于一个事件分组标识;订单当前状态仍然要保留,但它变成投影表里的一个字段,由事件推导。
5.3 落地到表结构:事件带来的查询逻辑变化
按上面的清单,落地后的核心关系是:事件头表 + 事件资源明细表 + 事件代理明细表,再加上资源投影表和代理档案表。这和传统订单表最大的区别在于,订单金额、客户余额、库存数量都不再直接散落在实体表里,而是通过事件聚合出来。
举个例子,要统计“今天实际收到的资金”,传统做法可能是查支付流水再过滤状态;REA做法是直接筛选event_type = 'payment.received'且occurred_at在今天的event_resource记录,求和change_amount。不管这个订单后续是取消还是退款,支付事件始终保留,口径不会漂移。
当然,完全从事件聚合会让列表页和详情页变得非常重。所以实际工程里要保留投影物化表:order_projection,里面存order_no、current_state、total_amount、latest_occurred_at等字段,事件发生后异步更新。注意这里的current_state已经是结论,不是事实来源。
5.4 存量系统怎么渐进迁移
不建议一上来就重构订单库,风险太大。我倾向于三步走。
先从最痛、也最容易验收的资源开始建事件表,比如支付流水和库存流水。这两类数据本来就有流水性质,转成事件模型几乎不增加成本。第二步,把现有status降级为投影缓存,允许项目里保留这个字段,但所有写操作在更新投影之前,必须先插入一条对应事件;这里要补存量数据的历史事件,尽可能从已有操作日志、流水表去回放。第三步,把报表、对账、审计查询逐步改走事件聚合,每完成一个查询场景,就对一次账,双方一致后再把旧字段废弃。
这套渐进式迁移我试过多次,最关键的注意事项是:不要为了省事跳过存量事件回放。没有历史事件的投影就是无源之水,一旦新旧逻辑混跑,出问题很难定位。宁可多花两天做回放脚本,也不要上线后用几周去弥补口径差异。
6. 落地踩坑与经验:REA最难的不是概念,而是"边界"
6.1 误区一:把REA当成一张大而全的类图
第一次接触REA的人很容易兴奋,到处给业务对象贴标签,把资源、事件、代理当成一种万能分类法。于是把“创建用户”也写成user.created事件,把“修改配置”也写成事件,最后事件表里塞满了各种不涉及资源流动的操作记录。
我后来总结了一个校准方法:建模一个动作前先回答三个问题,一有没有资源发生增减,二有没有明确的时间点和平账价值,三有没有代理需要承担责任。只要有一个回答是不确定,就先不要硬建模。很多动作只需要保留操作日志,不需要进入REA的事件体系。
6.2 误区二:状态彻底不要了,查询全去扫事件
另一个极端是全盘取消状态表,每个接口都现算事件。这在数据量小的时候看起来没问题,数据量一大就是灾难。订单列表页要扫几个月的事件明细才能拼出订单状态,性能必然崩盘。
正确的姿势是保留投影表,并把它当成可再生的缓存。事件表负责正确性,投影表负责查询性能。日常开发里把这两张表的同步时机设计清楚,比如事件写入后通过消息队列异步刷新投影,比把状态字段和事件表混在一张表里改来改去干净得多。
6.3 误区三:把订单本身当成事件
这是最常见的概念边界问题。订单是一个业务实体,它描述一次交易的上下文,本身不是事件。支付、发货、收货才是事件。如果建模时强行把order.completed当成事件,订单的“完成”会被定义成瞬间发生的单一动作,然后整个系统又会退化回状态字段的那套思维,只是换了个名字。
事件命名的习惯也值得强调。我见过不少团队用orderStatusChanged这种命名,这就是典型的状态思维。事件应当是动作的完成,所以我会建议团队的事件字典统一采用“领域.动作完成”的动宾结构,例如inventory.received、payment.refunded、shipment.delivered,一看就知道发生过什么,而不是只提示状态变化。
6.4 团队落地的时间点
任何方法论要落地,都需要团队形成共同语言。REA建模不是说培训几小时就完事,更靠持续的评审。我的习惯是每次业务评审会上,针对新增需求都过一遍事件字典和资源字典:这个需求涉及哪些资源,会产生哪些事件,由哪些代理参与,哪些是责任单元、哪些只是界面行为。只要这个几个问题能快速回答,项目就能从根源上避开一堆语义不清的坑。
如果团队里新人多,还得注意别把事件表变成“所有问题都往里扔的垃圾场”。每周可以和开发过一遍本周新增的事件类型,凡是发现没有资源变化、没有责任代理、也没有审计价值的事件,直接清理掉。核心理念是宁可少建,不要乱建。
7. 从"REA"到事件驱动架构:下一步还能做什么
7.1 REA与事件溯源、事件驱动的关系
做技术服务时间长了会发现,REA和现在流行的事件溯源、事件驱动架构在底层哲学上是互通的:都认为真相是事件流,状态只是推导结果。事件溯源强调“用事件流重建一切状态”,REA则进一步指导你“事件里到底要记录哪些业务语义”,两者天然契合。
实际开发时,可以把business_event表当作系统的核心事件流,事件落库后发到消息队列,下游消费者各自更新自己的投影。库存模块收到库存事件后更新库存投影,资金模块收到支付事件后更新余额投影,报表模块直接订阅事件流做聚合指标。这样一套架构下来,各模块之间的数据一致性就不再靠反复定时对账,而是靠同一条事件源驱动。
7.2 统一指标口径和数据中台
数据团队最头疼的往往不是取数难,而是口径不一致。同一个“成交额”,订单域用下单订单金额,财务域用实收金额,增长域用支付成功金额,三张报表各有各的数字。
如果每个业务域都采用REA的事件建模,指标定义就能建立在统一事件类型上。“成交额”到底用哪个事件聚合,只要定一次payment.received,所有团队对同一件事的语义就对齐了。就算后续增加了新的统计维度,也只需要从事件明细里再聚合一层,不用回到各系统的状态表里猜业务含义。
7.3 什么时候不要用REA
最后也得泼点冷水。REA不是所有系统的银弹。一套只做增删改查的管理后台,一张几十行的小配置表,一个内部工具页面,硬上REA只会制造开发成本,没有任何业务收益。不要在简单问题里重复发明架构,真正的架构判断力不在于掌握多少概念,而在于知道什么时候不应该用某个概念。
做个大概的判断标准:如果你的业务域需要频繁管理状态变化,需要审计、对账、回放、处理并发冲突,那REA值得投;如果你的业务只是一次性把纸质流程搬成在线表单,那老老实实写CRUD就好。
最后再分享一点个人体会。这套东西真正给我省下的时间,不在建模那张图,而在日常沟通。团队养成了一个习惯:每次聊到某个字段含义含糊时,就有人问一句“这到底是事实还是看法?事实就记事件,看法只是缓存”。这句话听起来轻飘飘,版本迭代时却是最硬核的定心丸。REA的终点不是一张ER图,而是一种让业务和数据都变得更诚实的工作方式。