REA业务建模:以事件驱动理清库存、订单与对账混乱
半年前接了一个采购系统的重构活项目本身不算复杂就是采购下单、入库、付款、对账这些事。真正让我头疼的不是代码而是业务方总在“库存为什么又对不上”“这个供应商挂靠在哪个订单下”这些问题上和开发来回扯皮。后来我换了个角度去建模不再一上来就画实体关系图而是先让团队把每个业务动作列出来再把动作对应的资源、参与动作的人以及动作之间的先后关系梳理清楚。这套思路在资料里通常叫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把这三种元素理出来再决定具体用什么技术实现。时间会证明这个前置动作能省掉后面几轮返工的力气。