半年前接了一个采购系统的重构活,项目本身不算复杂,就是采购下单、入库、付款、对账这些事。真正让我头疼的不是代码,而是业务方总在“库存为什么又对不上”“这个供应商挂靠在哪个订单下”这些问题上和开发来回扯皮。后来我换了个角度去建模,不再一上来就画实体关系图,而是先让团队把每个业务动作列出来,再把动作对应的资源、参与动作的人,以及动作之间的先后关系梳理清楚。这套思路在资料里通常叫REA模式,也就是Resource-Event-Agent(资源、事件、主体)。真按这个思路重画之后,很多纠缠不清的老问题反而自动消失了。
这篇文章不适合没接触过业务建模的人拿去做面试速成,更适合那些已经做过库存、订单、财务模块,却发现系统越改越僵、库里一堆状态字段互相打架的朋友。我会把REA模式的原理、落地步骤、和关系型数据库/微服务怎么配合,以及我自己踩过的几个坑,一次性讲明白。
1. 实体和交易为什么会越改越乱
1.1 ERD先入为主之后,很多动作被当成“属性”
大多数后台系统都是从“表”开始的:用户表、订单表、库存表、流水表。这套东西用得好能解决八成问题,但一旦涉及业务规则的变动,比如“每个订单允许部分收货”“退货后再重新入库”“供应商预付款要冲抵多笔订单”,就会开始出现一堆奇怪的空字段和状态值。
我见过一个典型的库存表,里面有in_stock、on_way、locked_stock、frozen_stock四个字段,字段之间还有规则:下单最后要锁死、支付后要减掉、退款后要加回。每次改需求都要在所有状态机衔接处加判断,改到后面没人敢动。这里的本质问题,是把“订、付、发、收、退”这些动作,强行塞进了“一张库存表的状态”里。状态是动作的结果,不是动作本身。REA模式恰恰是反过来,先承认动作是独立且不可删改的,再让库存、订单去“跟随”动作。
1.2 一个库存系统为什么库存总是对不上
再举一个库存对不上的例子。某公司把“入库单”和“出库单”当成两个核心实体,入库单写明细,出库单写明细,两边各自维护一个字段叫“剩余数量”。看起来没问题,但业务执行时经常出现采购先入库、质检不合格后拒收、销售又提前锁定库存等情况。于是出库单要引用入库单,锁库又要引用出库单,中间还有各种临时的人工调整单。
最后查账的时候,根本没法从数据库里还原“为什么库存变成了这个数”,只能靠一堆操作日志去手工拼。REA模式的做法不一样:把“验收入库”和“发货出库”都当成离散事件,每个事件至少连接一个资源流入或流出一个库存池。库存永远是某个时间点把所有事件按时间顺序累加的结果。只要事件不漏、不乱改,库存就一定能对平。
2. 资源、事件、主体:REA的三个原型
2.1 三个原型分别管什么
REA模式最早是给会计信息系统总结的一套本体论。名字里的三个字母刚好对应最基本的三类元素:
- Resource(资源):对业务有价值的资产,比如商品、现金、仓库容量、服务工时。它不一定是你自己拥有的,也可能只是被借用、被消耗。
- Event(事件):让资源发生流入、流出、变化的动作,比如采购下单、验收入库、销售发货、支付货款、退货退款。
- Agent(主体):参与事件的人和单位,宽泛来说既包括内部的人和部门,也包括外部客户、供应商、甚至合作方。
这套名字听着抽象,但核心就一句话:业务世界可以被拆成“谁在什么时间动用了什么资源”。普通ERD擅长描述“这个资源有哪些字段”,REA强调“这个资源是怎么变化的”。两者不是互斥的,REA更像是在ERD之上加了一层时间维度和责任维度。
2.2 用采购到付款的例子完整串一遍
我们用一个最简单的采购场景来串:A公司向B供应商买10台设备,每台1000元,货到了之后付掉一半货款,另一半下个月结清。
传统建模会设计一张“采购单”,表里存下单日期、供应商ID、总金额、付款状态、收货状态、开票状态,状态字段后期越来越多。REA建模会把这件事拆成三个事件:
- 采购订单事件:资源是“待交付的设备”,主体是A公司和B供应商。这个事件本身不一定改变实际库存,只是立下一个约定。
- 验收入库事件:资源变为“设备库存”,数量加10,主体还是双方,但多了仓库操作人员。
- 支付货款事件:资源是“现金/应付款”,金额减少5000,主体是A公司的财务部门和B供应商。
你会发现,这里的“事件”不只记录结果,还记录因果关系。付款事件必须关联到对应的采购事件,否则我凭什么付这笔钱?采购事件又关联到验收入库事件,因为没入库的采购不应该触发后续付款。事件链串起来之后,“未付款金额是多少”不再需要状态字段,从事件关系里直接能算出来。
2.3 资源与事件的连线,才是完整的业务闭环
很多刚接触REA的人会犯一个错:把事件当流水,只记录“发生了什么事”,记录完就完了。这其实漏掉了最重要的部分——事件必须和资源建立“流入/流出”的连线,才会形成闭环。
还是上面的例子,验收入库事件让“设备库存”这个资源池增加10,支付货款事件让“现金”这个资源池减少5000。如果哪天发现设备少了一台,我需要逆着“验收入库”找到它的上一笔事件是采购,再找到下单的主体是谁,而不是去一张孤零零的入库明细表里翻。所以REA设计的关键,是每个事件至少有一个资源关联、至少有一个主体关联,不能存在“无主事件”和“无资源事件”。
3. 把订单系统按REA重画一遍的实操过程
3.1 从用例描述开始,而不是直接画表
以前我们做设计,习惯打开数据库设计工具直接建表。用REA建模之后,我要求团队先写十几个“业务小故事”:客户下单、仓库锁定库存、销售发货、财务开票、客户退货、仓库重新入库。每个故事只要写明“谁、做了什么动作、影响了哪个资源、产生了什么单据”即可。
这个步骤看着费时间,实际能提前暴露很多歧义。比如仓库说“发货”的时候,财务其实关心的是“物权转移”,销售关心的却是“货离开仓库”。同一个动作,在不同部门眼里的资源变化方向不一样。不先在用例描述里统一口径,后面表怎么建都会有人叫苦。
3.2 定义事件对象与事件之间的时序关系
用例整理完之后,把它们变成事件清单,并为事件定义前后依赖。比如“验收入库”依赖“采购订单生效”,“结算应付”依赖“验收入库”,“退货退款”依赖“客户收货若干天之后发起”等等。
这个依赖关系相当于业务系统里的状态机,但它比传统状态机更直观。因为状态机描述的是“订单处于什么状态”,REA描述的是“上一件事实已经发生、并且已被证实,下一件事实才能发生”。一个没付款的订单想跳过验收入库?从事件依赖上就卡住了,不需要额外写一堆if判断。
3.3 设计物理表:事件表与资源明细表怎么落地
概念理清之后,落地其实很朴素。我不会去追求教科书式的纯净REA,而是做一个折中设计,这也是我在实际项目中用的方案:
资源侧建两张表:product_stock和product_stock_event。前者保存当前库存快照,后者记录每一次流入流出。事件侧建一张business_event表,统一保存业务事件的公共字段,再针对不同类型事件扩展几张明细表。
-- 业务事件主表 CREATE TABLE business_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(50) NOT NULL, -- 下单、入库、出库、付款、退款 event_time DATETIME NOT NULL, occurred_by BIGINT NOT NULL, -- 主体ID,可能是客户/供应商/内部员工 reference_type VARCHAR(30), -- 关联单据类型,比如采购单 reference_id BIGINT, -- 关联单据ID UNIQUE KEY uk_event (event_type, reference_type, reference_id) ); -- 资源变化明细表 CREATE TABLE resource_flow ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, -- 指向商品/现金等资源 flow_direction TINYINT NOT NULL, -- 1=流入,0=流出 quantity DECIMAL(18,4) NOT NULL, unit_cost DECIMAL(18,4) NULL, created_at DATETIME NOT NULL, KEY idx_event (event_id), KEY idx_resource (resource_id, created_at) );这个结构粗看和流水账差不多,但它背后有约束:一件业务事件只有在所有关联资源流动都落库后,才算是真正完成。所以后面新增库存、核对账单,都直接查这两张表,比在十几张业务表里跳来跳去省心得多。
4. 关系型数据库里的REA:需要处理的“事件账”和“双写”
4.1 事件记录只增不改,才是账本的根
REA落地到关系型数据库,最大的变化是从“update 状态字段”改成“insert 事件记录”。很多团队推不动REA,就是卡在这:以前一行记录标识订单已完成,改个状态就行;现在要新增一条事件,还要回算库存快照,感觉变复杂了。
这个复杂度其实换来的是可靠性和可审计性。状态字段随时可能被人为改掉、被并发覆盖、被迁移脚本弄丢;新增的事件记录只要做到只增不改,就天然带时间线属性。商品库存、账户余额这些当前值,都是从事件推到快照得出的结果,不是被反复更新的唯一真相。
4.2 当前库存还是要靠汇总表/物化视图,不能每次都全表扫
说完只增不改,再来说性能。如果每次查询库存都去把resource_flow累计一遍,单子多了肯定撑不住。我的做法是维护一个“当前快照表”,比如product_stock,每次事件发生时和resource_flow放在一个数据库事务里更新。两次更新一起提交,要么都成功,要么都失败。
这就是业内人士口中常说的“双写”。双写会带来一个额外好处:快照表和事件账可以互相对账。如果某天发现快照和从事件重算出来的结果不一致,说明有bug或人为操作,能直接定位。如果不做双写,只留事件表,那检查成本太高;只留快照表,又没有溯源能力。
4.3 物化视图在外部分析场景很实用
如果数据量很大,需要出库存月度报告、供应商结算汇总,我会额外构建物化视图或者定时任务,把事件按天、按资源维度聚合。事件表是基线,聚合表是分析缓存。这样做避免分析查询拖着事务库的选型走。
我在做电商库存中心时,每天零点重算“库存流水日汇总”,几十万条事件几分钟就跑完,完全不影响在线业务。关键是这个重算可以随意跑,因为事件表是只增文档,不像传统状态表重算时会把正在更新的数据搅乱。
5. 我踩过的坑:REA不是银弹,五种情况慎用
5.1 把“主体”窄化成“用户”
第一个坑是我一开始最容易犯的,以为主体就是登录用户。实际上,采购审批中参与的主体可能是部门、供应商、法人主体,也可能是一个内部角色。如果你把主体直接等同于用户表,后续做权限和审计就会缺维度。
举个例子:某个采购事件由采购员A录入,但审批主体是采购主管B,再往上还有财务审核角色C。如果只记录“操作人A”,出了问题没法回追到审批责任链。正确做法是把主体拆成“经办主体”和“授权主体”,或者至少再建立一张责任链表。
5.2 用REA强行取代领域事件
REA事件和微服务架构里的领域事件,名字相似,但作用不同。REA事件是业务事实的持久化记录,领域事件是用来触发系统间协作的即时消息。把这两个混为一谈会出笑话:
订单创建后你想发消息给仓储系统,结果你直接往REA事件表里塞一条,然后让仓储系统去轮询这张表,低效还容易延迟。正确姿势是:业务端先持久化REA事件,确认成功后,再通过消息队列发对应领域事件给下游系统。两套机制各司其职,不要试图让一个模型同时搞定记账和通信。
5.3 事件切得太碎,查询会爆炸
理想化的REA喜欢把每个微小动作都拆成独立事件,比如“修改收货地址”也当成事件。如果粒度太小,业务端开发会烦死,每次都去找合适的事件类型。我后来建议的粒度参考标准是:这个动作是否改变了资源流入流出,或是否改变了主体之间的责任关系。符合这个标准才拆,否则放到自己的业务表里去。
比如“修改收货地址”不影响资源流入流出,也不影响谁欠谁钱,那就作为普通字段维护,不放进business_event。“验收入库”影响库存资源,必须拆;“发起采购审批”不影响真实库存,但影响责任归属,可以考虑拆。这样不会过度建模。
5.4 传统ER、事件溯源和REA不是对立关系
很多人把REA和事件溯源(Event Sourcing)完全等同,或者把REA和传统关系建模完全对立。我自己的体会是:REA提供的是概念框架,事件溯源是技术实现,ER图是物化模型。你可以用ER图去表达REA概念,也可以用事件溯源框架去实现REA,二者没有必然冲突。
实际项目里我不会让团队把整张ERD扔掉,只是把原先那张“大状态表”拆成“事实事件表+状态快照表”。工具还是传统数据库工具,思想换成了REA,这才是最落地的路径。
5.5 没有精确语义约束时,REA会退化成流水账
最后一个坑,也是最容易忽略的:REA必须配上事件间的“依赖语义”才有意义。只建事件表但不定义“验收入库必须依赖采购订单”“付款必须依赖验收入库”,那它的价值和普通操作日志没有区别。我在设计文档里固定用一组动词组合来表达约束:consist_of、exchange_of、fulfillment_of。事件之间的关系一旦写清楚,后续做合规校验、对账平台就顺手了。
6. 落地上手时需要准备好的几个辅助手段
6.1 为每个事件配上“事件ID谱系”
我在实践中发现,事件表里单靠自增ID不够用。比如要查“这笔付款冲抵了哪些采购单”,只靠事件ID无法直接关联,需要事件表里带reference_type和reference_id。但更进一步,付款可能跨多张采购单,还需要一张“事件关联表”。
事件关联表也很有用:它记录依赖事件之间的多对多关系,比如一笔付款关联三张入库验收事件,一张入库验收关联两笔采购订单。有了这个关联表,对账的时候就不用写令人头秃的递归查询,直接两表join就能把链路拉出来。
6.2 补上“反向事件”和“取消事件”的约定
业务里永远有撤销。REA处理撤销的方式不是把原事件删掉或改成“已作废”,而是新增一个反向事件去抵消它。比如“验收入库”之后发现多收了2台,就新增一个“退货出库”,数量-2,并关联原入库事件ID。这样原事件能否被证实、能否对账,都不会被破坏。
我在做实施时还会加一个状态字段叫void_flag,用来标记事件是否被后续调整单冲销。这个字段不是把事实改了,只是给审计人看的“该事件已经失去效力”的标签。账本数据保留原样,逻辑有效性和事实有效性分得清清楚楚。
6.3 小步试点:先找一个模块跑通,不要全系统推翻
当你准备在现有系统里引入REA,最忌讳一上来就重构老库。我个人经验是:挑一个痛点最明显、边界最清楚的模块先试,比如“库存出入库”或者“对账单结算”。把这个模块的现有表替换成事件+快照模式,同时保留旧表只读作为备份。跑一两个月,确认对账、溯源、性能都稳定了,再逐步扩展到采购、销售、财务模块。
很多项目引入REA失败,不是因为方法不行,而是影响面太大、团队惯性太大,项目中途被叫停。小步快跑,产出可量化的改进比例,后面推广才有说服力。
7. 最后分享一条踩坑后的经验
如果只让我给出一条实操心得,我会说:REA模式最值钱的不是那张事件表,而是让业务和开发重新统一了对“到底是什么事实”的认知。以前开发问“库存为什么是负的”,回答往往模棱两可;现在只要反问“哪一条入库事件漏了,或者哪一条出库事件重复了”,问题立刻会聚焦到一个具体的时间点和操作人身上。
项目组里有一句话成了口头禅:“先把事实写下来,再谈怎么显示。”REA模式下,事实是一次性写入且不被篡改的事件,显示层完全可以根据不同角色生成不同视图。开发不再疲于维护几十个互相拉扯的状态字段,业务方也终于能拿到一张说得清来龙去脉的账。
我给自己定了个规矩:以后凡是涉及钱、货、权属流转的功能,都先用REA把这三种元素理出来,再决定具体用什么技术实现。时间会证明,这个前置动作,能省掉后面几轮返工的力气。