REA模型实战:用资源-事件-主体重构库存、采购与对账系统
做业务系统的时间久了回头看那些最让人头疼的需求十有八九都跟账有关。客户要一个库存台账要一张销售报表要一笔对账单底层其实都在问同一个问题钱和货是怎么流动的今天的数据是从哪一笔业务事实里长出来的。传统做法里我们习惯围绕凭证或单据建模一张销售出库单、一张采购入库单字段以单据头、单据尾、明细行的形式堆起来。刚开始够用业务一复杂就出问题销售要撤销、库存要冲销、跨店调拨要同时动两边各种业务单据互相引用最后对不平账排查起来像在一堆线性记账的纸片上找失踪的几块钱。后来我接触到一种完全不同的建模视角叫REA也就是Resource(资源)-Event(事件)-Agent(参与者)模型。它不把单子当核心而是把业务发生时的事实当核心。谁在什么时候、把什么资源、转给了谁这些事实一旦落库报表、台账、对账都是按需算出来的结果而不是不断维护的一堆快照。这篇内容我想把这套模型的原理、建模方法和落地中的坑都讲透适合正在设计进销存、订单中心、财务核算系统的开发者也适合被两本账问题折磨的产品和技术同学参考。1. 传统建模的记账思维困境1.1 以单据为中心的模型为什么让程序员头疼大多数后台系统起步时都会做单据采购单、销售单、付款单。每张单据是一个实体单据头存往来单位、日期、业务员明细行存商品、数量、单价。表面上很直观但它把业务事实和业务管理混在了一起。举个实际场景。某天销售做了一张出库单后来又因为退货做了红字出库单再过几天又做了一张销售退款单。这三个东西其实是同一笔经济事件的不同侧面但在单据模型里它们是三个独立的实体靠内部单号、关联ID手工串起来。代码里不得不写大量单据类型、状态机、关联关系来控制流程。一旦出现跨月结账、库存按先进先出核算、部分退货、多仓调拨之类的问题这套模型的脆弱性就暴露了。我见过某仓库系统出库单和入库单各有一套流水号中间经过几次数据清洗和迁移两张表对不上最后靠人工拿Excel逐行核对才把库存调平。根子就在于我们保存的是业务单据的视图而不是业务发生的事实。还有一个隐性成本需求变更。业务部门说我们要支持先开票后发货或者同一张单可以分多次出库传统单据模型要加字段、加状态、加子表而REA模型里这些只是不同事件之间依赖关系的不同排列。谁在什么时候做了什么事实不变展示层怎么组都可以。1.2 我踩过的一个对账失败的坑说一个我自己做电商订单中心的经历。当时要支持订单-支付-发货三步流程我设计了三张主表订单表、支付表、发货表再配一个记录每步状态变更的事件日志表。看起来还算正常可上线没多久财务对账时就出现了已支付但订单显示未支付的情况。查了半天原因是支付回调重复推送的时候幂等逻辑写在应用层判断顺序是先查订单状态、再写支付记录高并发下两个请求同时通过了查重各写了一条支付记录。而订单表的支付状态只在合理的调用链路上更新一旦某条调用链路在更新状态前失败状态就永远不一致。后来我用REA的思路重新审视这个问题订单、支付、发货本质上都是事件事件发生就应该记录下来围绕一个业务事件聚合多个经济事件打完点再确定性地下推到订单状态聚合上。所有状态都通过事件累计推导而不是依赖单据互相改状态。那次重构之后对账逻辑反而简单了把订单下的所有收款事件累加和订单金额比一比就行不需要维护那么多状态同步。2. REA模型的三块基石资源、事件、主体2.1 三个概念的精确定义REA是会计信息系统领域很早提出来的一套本体论框架它用三个基本构造块描述一切企业业务活动。资源Resource被业务活动处理、买卖、消耗的对象必须能带来经济价值。库存商品是资源现金是资源仓库的货架空间也是资源甚至一段时间内的服务能力也算资源。判断标准就一句它是否参与了经济价值的流入或流出。事件Event对资源产生影响的具体业务行为。注意REA里的事件不是UI上的按钮点击而是资源的流入/流出发生了。销售发货是一个事件采购到货是一个事件报废也是一个事件。主体Agent参与事件的个人、部门、组织或者外部角色。客户、供应商、仓库保管员、配送司机都算主体。有时一个事件会涉及多个主体比如销售出库涉及内部仓库员和外部客户两个主体。一个完整的REA模型不是把实体分成三类就结束而是描述三类实体之间的三层关系资源-事件关系哪个资源被哪个事件处理了、事件-事件关系哪个事件触发或补偿了哪个事件、主体-事件关系哪个主体参与了哪个事件。业务规则基本都藏在这三类关系里。2.2 用便利店买水的例子把三个概念串一遍想象你在便利店买一瓶水。经典单据模型是这样生成本地单商品名矿泉水数量1单价2元合计2元付款方式微信支付人工收款日期时间。数据落库之后这张单记录了发生了一笔销售。用REA来看同样一件事拆成几个实体资源一瓶水库存资源以及2元人民币现金资源事件商品交付事件资源流出水从老板手里给了你现金收款事件资源流入2元进了收银台主体你客户主体老板销售主体更有意思的是事件之间的关系。商品交付和现金收款并不是孤立发生的它们是一对经济补偿关系一方的资源流出由另一方的资源流入来补偿。老板把水给你是因为你付了钱你把钱给老板是因为他给了你水。这个关系在REA模型里被明确建模而不是靠备注挂在单据上。再看采购入库同样存在一对补偿关系——供应商发货我们收货是资源流入对应的我们应付账最终付款是资源流出。这两件事之间允许有时间差正好天然对应财务上的应付账款期间。只要把事件和补偿关系落库账期、对账、付款计划都能从关系里查出来不必单独设计一沓应收/应付余额表。2.3 为什么说REA是先记事实、后出报表传统系统的本质是先出报表再修改报表。每做一单业务都要同时更新库存台账、应收/应付台账、进销存汇总。这相当于每次业务发生你同时手工改了好几本账任何一本账漏改系统就出现账实不符。REA的思路反过来了只把事实记录在案也就是资源流动事件的发生记录。库存余额怎么算把商品入库事件的数量减去商品出库事件的数量。收款欠款怎么算把销售事件金额减去对应收现事件金额。所有台账都是查询时计算出来的而不是写入时维护的。这套做法有很强的容错性。哪怕某天发现某条事件录错了直接修正事件本身所有基于事件的统计都会跟着变不用担心改漏一本账。我把这种思路比作记账本和流水账的差别流水账记录了每一笔发生的事而记账本是某个时点的快照。快照容易过期只要反应慢了就是错流水账永远诚实任何时候需要快照都能从流水里重建。3. 用REA建模一个真实业务域库存、采购与销售3.1 双生产事件与双消费事件的结构前面说的事件-事件关系只有补偿还不够生产场景里还有转化关系。比如把面粉加工成面包面粉被消耗消费事件面包被产出生产事件。这两个事件不是交换补偿而是同一生产过程的两个侧面。REA把这类结构叫经济转化(conversion)。库存领料、生产报工、装配下线、质量报废全部可以纳入这个框架。拿最常见的组装场景来说资源原材料A、原材料B、包装箱C消费事件领料出库消耗A、B、C生产事件产成品入库产出商品D主体生产人员、仓库管理员在建模时这两个事件之间是因果依赖关系——消费事件必须先发生或者至少和产出事件同批发生。设计数据库时可以在生产事件里带上一个source_event_id或者通过生产批次把多个资源和产出挂在一起。这样设计最大的好处是成本核算可以直接从事件出发把A、B、C在当天或当批的消耗成本加总得到D的生产成本再多维度分摊到人工和制费。不需要额外维护一张成本结转表。3.2 事件之间的因果依赖与两个经典周期REA模型里最经典的两个周期是采购-付款周期和销售-收款周期。它们互为镜像采购周期采购事件资源流入商品付款事件资源流出现金两者通过补偿关系相连中间的时间差就是应付账期销售周期销售事件资源流出商品收款事件资源流入现金同样通过补偿关系相连中间的时间差就是应收账期这个概念的价值在于报表上的应付账款余额根本不需要单独建表跑一次查询就能算出来所有采购事件金额减去所有对应付款事件金额。只要补偿关系完整这个数永远能和对方核对上。而传统单据系统里应付账款余额通常是一个被不断update的冗余字段多一次更新就多一次出错的机会。我曾在一个模拟的供应链项目里把供应商对账从导出-比对-冲销改成了按事件对账直接查出一个月内所有采购事件和付款事件由系统自动匹配补偿关系剩下未匹配的才是真正的差异。原来对账要花两个小时改成一分钟而且准确率显著提高。3.3 一个可直接参考的数据模型设计下面给出一版简化但可落地的表结构设计围绕销售-收款周期CREATE TABLE resource ( id BIGINT PRIMARY KEY, resource_type VARCHAR(64), -- INVENTORY_ITEM, CASH, SERVICE code VARCHAR(64), name VARCHAR(255), unit VARCHAR(32) ); CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(64), -- INTERNAL, CUSTOMER, SUPPLIER name VARCHAR(255), attributes JSON -- 联系人、税号等扩展字段 ); CREATE TABLE economic_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(64), -- SELL, PURCHASE, RECEIVE_CASH, PAY_CASH, CONVERT occurred_at TIMESTAMP, agent_id BIGINT, -- 主体客户/供应商 reference_no VARCHAR(64) -- 外部单号保留追溯能力 ); CREATE TABLE event_resource_line ( id BIGINT PRIMARY KEY, event_id BIGINT, resource_id BIGINT, quantity DECIMAL(18,4), unit_price DECIMAL(18,4), direction VARCHAR(8), -- IN / OUT flow_type VARCHAR(16) -- INCREASE / DECREASE ); CREATE TABLE event_link ( id BIGINT PRIMARY KEY, source_event_id BIGINT, -- 例如销售事件 target_event_id BIGINT, -- 例如收款事件 link_type VARCHAR(32) -- COMPENSATION / CONVERSION );这里的event_resource_line是核心它把资源和事件的多对多关系落库quantity和direction共同决定了资源的流入流出。event_link记录了事件之间的依赖对账和成本核算都靠它。我实际使用时还会给economic_event加一个唯一业务键比如event_type occurred_at agent_id reference_no防止重复事件进入系统。4. REA与REST API、DDD聚合的联合作战4.1 聚合根该选资源还是事件很多学了DDD的人会把聚合根设计成订单、商品。引入REA之后聚合根的边界最好落在事件上而不是落在单据上。为什么以销售订单为例。在REA里一笔销售订单其实是客户承诺购买这个事件的记录它只代表意向和条款真正的商品资源流动发生在出库事件里。如果把订单当聚合根订单、明细、赠品、分摊规则全都塞在一个聚合里并发操作时锁粒度会非常大一直锁到整个订单。把聚合根换成出库事件每个出库事件就是一个聚合里面包含出库的资源明细线和事件本身。多个出库事件可以共同引用同一个订单号秩序但彼此不需要互相锁。这样既保留了订单视角又把写并发分摊到了更细的粒度上。我实践下来并发和扩展性明显更好。4.2 幂等设计的天然优势REA模型里的事件是不可变的一个事件的资源流入流出一旦发生就不该被修改只能被另一个反向事件冲销。这种不可变性天然适合做幂等设计。举个支付回调和发货的协作例子。第三方支付回调到达时系统需要记录收款事件。如果这个事件已经存在根据业务键查到重复就直接返回成功不重复写。发货逻辑类似当系统收到发货指令先查重再写出库事件。两件事互不依赖对方的状态只依赖事实是否已经记录。相比传统做法里订单状态从已支付改成已发货的强状态流REA事件流的并发安全窗口小得多重复提交带来的风险低很多。实际阶梯中我会把唯一业务键放在表级约束里而不是只在应用层判断。一旦出现唯一键冲突应用层捕获后统一返回已处理这样即便接口被并发调十几次数据仍然干净。4.3 我发现的一个可用性bug事件重复提交当然REA方案也不是没有新问题。一次重构里我给一个模拟的销售系统接入了事件模型结果上线第二周就出现同一笔订单的收款事件被重复写入的情况。排查后定位到支付回调服务重试机制本身是幂等的但消息队列的消费者把事件记录和后续处理用了同一个事务。消息确认失败导致消息重新投递整个事务被重复执行了而表级唯一键在事件记录表上按理说第二次执行时应该冲突回滚并返回已处理。问题在于我写的处理器把唯一键冲突异常也当作普通异常记到了日志里随后抛给了消息队列重试形成了一个死循环。修复方式很简单单独建一张event_dedup表把业务键存进去用INSERT IGNORE的方式做幂等主流程不管是否已存在都返回成功只是根据插入结果决定是否执行后续动作。这个坑让我意识到事件模型的幂等不仅是数据库层的问题消费端的处理策略也必须设计成对重复完全无感。5. 落地REA模型必须想清楚的四个坑5.1 库存账和财务账分离之后的两本账问题很多团队上线事件模型后发现自己这里有两套账一套是业务系统的实时资源流一套是财务系统的科目余额。业务系统按可用库存算财务系统按会计科目记账两边对不上最后不得不定期做差异调整表。这不是模型的问题而是落地时没有统一资源和会计科目的映射。我的建议是在event_resource_line里增加一个account_code字段这样每一个事件行在落库时同时写好它影响的会计科目。这样每当业务事件发生财务凭证就是事件的派生视图不再需要手工补录。要保证两本账一致关键是让资源维度和科目维度在同一行数据里对齐而不是一个事件落两张表。5.2 事件表的膨胀与归档事件模型最直观的问题数据量大。既然不维护余额所有历史事件都要存下来运行三年后event_resource_line表轻松上亿行。很多人因此放弃REA重新改回余额快照方案。我的做法是分桶归档加冗余余额。线上保留最近12个月的实时事件区更早的按月自动归档到冷表或数据仓库。日常报表查询的是期初余额快照 当月事件明细而不是全量事件。这个余额快照可以每天生成一次存在一张resource_balance_log表里。这样既保留了事件的追溯能力又避免了每次查询都全量扫历史事件。5.3 并发与乐观锁的边界虽然是事件流模型但某些场景仍然存在写冲突。比如同一个库存商品同时被两个出库事件扣减两者都基于相同期初余额计算就可能出现负库存。事件模型不能解决并发控制必须配合行级锁或乐观锁。我的做法是在resource表里加一个version字段扣减库存时用UPDATE ... WHERE version当前版本 来校验更新成功才算事件成立否则单独抛出可重试错误。这种事件幂等 资源行版本号的组合已经能覆盖我遇到的大部分高并发场景不需要引入复杂的分布式锁。5.4 权限与审计的最小化设计REA模型天然具备审计优势因为事件记录的就是事实但审计并不等于简单地把事件表开放给所有人查。企业里客户和供应商可能同属一个资源池员工和外部角色都挂在agent表上权限如果不区分很容易出现越权。我的最小化建议是agent表里严格区分agent_type查询接口按角色做行过滤涉及金额修改的事件只允许通过追加反向事件实现不允许物理删除任何事件。加上event_source字段记录每次事件的来源IP、操作端审计排查时基本一两步就能定位到问题来源。6. 总结与个人实践感受作为一个被对账、单据流转、状态同步坑过很多次的人我现在的倾向很明确凡是涉及资源流动、资金往来、库存变动的核心业务链优先用REA思路去建模和实现。它不是银弹建模前期的抽象难度比建状态机要高团队如果没有领域建模经验初期会有点吃力。但一旦把资源、事件、主体的边界理清楚后续的报表、对账、审计、并发扩展都会比传统单据模型省心得多。我在实际项目里体会最深的一点是REA逼着你去思考业务的本质事实是什么而不是用户想在这个页面上看到什么状态。前者更稳定后者经常变。只要把事实层做稳上面的任何视图都可以随时重新组装这也是这套模型最让我觉得踏实的地方。最后再分享一个小经验如果你是第一次尝试REA不要急着把所有场景都套进去。挑一个高频、稳定、规则清晰的业务链路比如销售-收款或者采购-付款先用它练手写一个最简单的原型跑通对账和库存计算再扩展。REA适合逐步落地一次推翻所有旧系统往往会把自己先推坑里。希望这篇内容能给你一些可复用的设计思路少走一些我们绕过的弯路。