前阵子给一个做图书管理的老系统做重构方案改了三轮最后卡在一个很基础的问题上账务模块说借书押金是负债业务模块说押金就是一笔预收的钱两边各说各话谁都讲不清楚这笔钱到底该怎么走。为了不再陷进这种口径之争我给自己定了一个小课题代号就叫 rea——把 Resource-Event-Agent 这套建模方法完整梳理一遍验证它能不能把业务和账务装进同一张模型里。简单说REA 是一种以资源、事件、参与者为核心视角的业务建模方式最早出现在会计信息系统领域后来被很多企业建模、数据中台设计的人拿来做底层分析框架。它跟传统复式记账模型最大的区别是复式记账关心钱记在哪个科目REA 关心这笔业务实际发生了什么。这套思路对我这种常年跟业务系统、账务系统打交道的人来说属于越用越顺手的工具但第一次接触时确实需要转换一下脑子。这篇文章我不会讲太深的理论推导重点是把我在实际项目里怎么用 REA 拆解业务、怎么落到数据库表、怎么处理跟复式记账的衔接以及踩过的几个坑都摊开来讲。适合正在做业务建模、系统重构或者被业务-财务口径不一致折磨过的同学参考。哪怕你现在只是写一个小工具这个视角也能帮你把表结构设计得清楚不少。1. 从一张记账凭证说起REA想解决的根本问题1.1 凭证能告诉你结果但回答不了发生了什么传统记账系统里一切业务的终点都是一张记账凭证。比如卖了一件商品财务会录入借应收账款贷主营业务收入。这条分录非常标准金额、科目、借贷方向全都对任何会计看了都挑不出毛病。但如果你退一步问这笔收入是哪个客户带来的是哪位销售签的单货物已经出库了吗要是客户退货流程该怎么走凭证上完全看不出来。这不是会计偷懒而是复式记账的定位就是记录经济结果不是记录业务过程。单靠凭证你能算出利润、资产、负债却没办法还原出钱从哪来、货往哪去、经手人是谁。一旦业务想追溯、审计想看过程你就得回头翻订单表、库存表、物流表这时候三个系统之间的口径经常会打架。我们在重构老系统时最头疼的恰好就是这个追溯断层。1.2 REA的三张底牌资源、事件、参与者REA 的核心非常简单就三个词Resource、Event、Agent。资源Resource企业控制或拥有、能带来价值的对象。库存、现金、图书、工时、客户账户里的积分都算资源。事件Event资源控制权发生变化的行为。销售、收款、采购、付款、领料、退货、借出、归还全部是事件。参与者Agent参与事件的人或部门。内部参与者如仓库管理员、销售员、图书管理员外部参与者如客户、供应商、读者。这三类对象的关系构成了整个 REA 模型的基本骨架。事件是核心它一边连接资源描述资源怎么流动一边连接参与者描述谁对这件事负责。用自己的话说REA 把一笔业务拆成了谁、在什么时间、对什么资源、做了什么事。业务过程的关键信息全保留而不是只留一个借贷数字。1.3 为什么给这个梳理起代号rea这个代号其实是我偷懒。当时想给这个课题起个正经名字结果想了半天没想出来索性直接用 REA 的缩写当项目代号。后来梳理完才觉得这个缩写挺划算三个字母对应三类对象跟模型本身一样简洁。我当时给自己定的验证目标是能不能用 REA 模型把一笔押金业务从头到尾讲清楚并且能自然推导出会计分录。如果这个能走通那业务-财务口径不一致的问题就有希望从建模层面解决而不是靠人肉对账。现在回头看这个目标基本达到了而且过程中还额外解决了几个别的模块的建模问题。下面逐步拆解我走过的路径。2. 和复式记账赛跑REA视角下的建模差异2.1 复式记账是压缩饼干REA是原料厨房为了讲清楚差异我列了个对比表。平时做系统分析时我经常把这个表贴在白板旁边提醒自己不要掉进凭证思维。维度复式记账模型REA模型记录单位借贷分录事件 资源流 参与者核心问题这笔账记在哪个科目这笔业务实际发生了什么业务事实大部分被压缩掉全量保留过程还原难需要翻多个系统顺着事件链回溯即可典型使用者财务、审计业务分析、系统设计、数据建模你看复式记账像压缩饼干——体积小、营养密度高但你看不出原材料是什么。REA 更像原料厨房每样食材都摆在那里最后怎么加工、做成什么菜可以由你按需处理。这个差异直接决定了两种模型的适用场景。如果你只是要一份利润表复式记账又快又准但如果你还要回答某批货物为什么晚到了三天这个客户的退款为什么拖了两周那必须有事件级别的记录。2.2 一个销售例子两边分别怎么建模拿最常见的销售业务来看。传统方式分录一行就写完了借应收账款 100贷主营业务收入 100。换成 REA 建模同样是这笔销售会拆成这样事件1客户下单外部参与者客户内部参与者销售员事件2仓库发货资源流库存减少在途库存增加事件3财务收款资源流现金增加应收账款减少事件4客户退货反向资源流库存增加现金减少这样一看业务过程全都在。订单是谁下的、谁审核的、货什么时候发的、钱什么时候到账的、有没有退货全部可以用事件链表达。我实际测算过一个场景传统模式下销售总监问上个月哪些订单发货了但没收款得从订单表和收款表里来回比对REA 模型里直接筛发货事件已发生但对应收款事件还没发生的事件对一条查询就搞定。2.3 反直觉的地方REA 模型里没有账户也没有余额初次接触 REA 的人最不适应的一点是这个模型里竟然没有账户、没有余额。在复式记账里应收账款是一个账户余额多少直接看它就行。在 REA 里你记录的是销售事件和收款事件所谓应收账款余额是从这些事件里推出来的应收账款余额 所有销售事件金额 - 所有收款事件金额同样的库存余额 所有入库事件数量 - 所有出库事件数量押金余额 所有收取押金事件 - 所有退还押金事件。这个不存余额只存事实的设计初看很别扭因为你没法一眼看到一个数字。但好处是极大的任何时候都可以回放历史余额永远是从事实推导出来的不存在表里数据和实际对不上的脏数据问题。用我们项目里的一句话总结余额不是存出来的是算出来的。3. 图书馆借书场景把REA从概念落地成数据库表3.1 先识别三类对象光讲概念没用我拿当时重构的图书管理场景完整走一遍流程。某高校图书馆的借书业务看起来非常简单读者借书、还书偶尔有丢书赔款。按照 REA 的流程第一步不是画表而是先找资源、事件、参与者。资源图书按可借副本数计算读者账户里的可用借阅额度这也是一种资源属于可消耗的权益。事件借出事件、归还事件、丢失登记事件、赔款支付事件。参与者读者外部参与者、图书管理员内部参与者。这个环节最容易犯的错是把资源粒度搞错。后面我会专门讲这个坑这里先记住一个原则建模时想清楚你管理的是某本书还是某一本具体的书。3.2 画出事件-资源-参与者之间的连接识别完三类对象下一步就是连线。借出事件是这个领域最核心的事件它要连接两类资源、两个参与者连接资源图书实例表示某本具体的书从在馆变为借出连接资源读者借阅额度表示读者可借数量减少连接参与者读者表示谁借的连接参与者图书管理员表示谁经手办理的。归还事件反过来连接图书实例在馆、连接借阅额度恢复同时连接参与者和经手人。关键来了借出事件和归还事件必须成对出现。这是 REA 里很重要的一个概念叫事件对偶。一个借出事件在逻辑上总有一个归还事件跟它对应否则这个资源流转就没有闭环。丢失登记事件处理的是书回不来的情况它也是借出事件的另一种对应事件。丢失后赔款支付事件再和丢失登记事件配对形成借出→丢失→赔款的完整链路。我在纸上画图时会让成对事件贴在一起画这样一眼就能看到资源流是否闭合。3.3 落到数据库表结构、外键和一条常用查询模型理清楚了数据库表基本就是照着实体落。下面是简化版结构去掉一些索引和审计字段保留核心逻辑。-- 图书副本表一本书可能有多本所以副本是独立资源 CREATE TABLE book_copy ( id BIGINT PRIMARY KEY, book_id BIGINT NOT NULL, copy_code VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL -- 在馆、借出、丢失等状态辅助字段 ); -- 读者表 CREATE TABLE reader ( id BIGINT PRIMARY KEY, reader_no VARCHAR(32) NOT NULL, name VARCHAR(64) NOT NULL, max_borrow_count INT NOT NULL DEFAULT 5 ); -- 借出事件表 CREATE TABLE lending_event ( id BIGINT PRIMARY KEY, book_copy_id BIGINT NOT NULL REFERENCES book_copy(id), reader_id BIGINT NOT NULL REFERENCES reader(id), operator_id BIGINT NOT NULL, -- 图书管理员内部参与者 event_time TIMESTAMP NOT NULL, due_time TIMESTAMP NOT NULL, return_event_id BIGINT NULL REFERENCES return_event(id) ); -- 归还事件表 CREATE TABLE return_event ( id BIGINT PRIMARY KEY, lending_event_id BIGINT NOT NULL REFERENCES lending_event(id), operator_id BIGINT NOT NULL, event_time TIMESTAMP NOT NULL );注意看lending_event里有一个return_event_id就是用来处理事件对偶的。return_event表里又存了一份lending_event_id两边都指向对方。实际落地时保留一边即可但我习惯两边都榜查询超期列表时方便。要查哪些书在谁手上超期未还一条 SQL 就行SELECT r.name AS reader_name, bc.copy_code, le.event_time AS borrow_time, le.due_time FROM lending_event le JOIN book_copy bc ON le.book_copy_id bc.id JOIN reader r ON le.reader_id r.id WHERE le.return_event_id IS NULL AND le.due_time CURRENT_TIMESTAMP;这条查询里没有任何是否超期的字段。超期不是状态是一个推导结果借出事件有归还事件还没有且当前时间超过应还时间。这种写法一开始会让人不适应但好处是特别防呆。不会出现状态字段忘记更新导致读者明明还了书还显示超期的问题。3.4 这么建模到底换来什么有人会问不就一个借书功能吗传统方式加一个是否已还字段不就行了为什么要搞这么麻烦我当时重构时靠 REA 模型解决了一个实打实的问题读者的资源额度校验。图书馆允许读者借 5 本书同时支持提前预约、续借。传统状态字段做法里已借 5 本要么是冗余计数要么每次查都 COUNT。冗余计数会在退书、续借、预约转借等场景下不断出 bugCOUNT 查询本身没错但如果以后要支持借阅额度按时间动态恢复传统状态字段就完全不够用了。REA 模型天然把额度变动建模成借出事件和归还事件两条资源流任何时刻的可用额度 初始额度 - 未归还的借出事件数。规则变了只需要调整推导逻辑不需要改表结构。4. 从纸上谈兵到真实系统两条落地路线和我的选型思路4.1 路线一REA 作为领域模型用事件溯源方式落地如果你做的系统本身合规要求高、审计追溯要求强比如金融、政务、司法相关系统我推荐直接把 REA 模型当头号领域模型落地。具体做法是所有事件表只做插入不做更新、不做删除。业务行为发生一次就写一条事件记录。余额、库存、额度这些派生数据通过定期计算生成物化视图或快照表查询的时候先查快照需要追溯时再回放事件。这跟事件溯源Event Sourcing的思路几乎完全一致。REA 模型天然适合事件溯源因为它的核心就是记录事实而不是记录状态。我在图书项目里没有全面走这条路因为存量系统改造不现实。但如果是从零开始做一个对账敏感的新系统我会优先考虑这种方案。4.2 路线二REA 模型和复式记账共存从模型推导凭证大多数企业系统没法彻底放弃复式记账因为财务、税务、审计链条都建立在凭证之上。那 REA 的价值在哪在于把凭证从输入变成输出。我们在图书管理系统里就是这么做的。业务操作照常走 REA 事件模型事件记录完成后系统按规则自动生成会计分录。例如没收押金时产生一个押金收取事件对应的会计凭证是借库存现金贷其他应付款——押金读者丢书赔款时产生赔款支付事件对应凭证是借库存现金贷营业外收入。关键原则是不是每个 REA 事件都要记账。只有资源控制权真正转移、或者产生经济义务时才需要生成凭证。单纯内部的状态变化比如图书从在馆变为借出资源控制权没有离开系统就不需要记账。这种设计把业务人员和财务人员的矛盾化解在建模层业务系统记录事实财务系统按需取数。不再是两套系统各做各的然后月底人工对账。4.3 什么时候不该硬上 REA说完两条路线我得泼一点冷水。REA 不是万能的有些场景硬上会非常痛苦。业务规则本身不稳定、每天都在变的场景。REA 建模需要你对业务有一定的抽象能力业务还在一团浆糊的时候建出来的模型大概率也是歪的。纯 CRUD 内部系统。比如就是一个简单的配置管理后台资源、事件、参与者的三元结构会显得过度设计直接上普通表结构效率更高。团队没有建模习惯。如果团队习惯了字段加一加页面改一改的开发方式REA 的推导式查询会让他们觉得又慢又绕。第一个试点项目建议选一个小模块让团队先体会一下模型带来的好处。我自己做选型时会问一个问题这个系统五年后最怕的是字段不够用还是历史说不清前者用普通建模就行后者值得上 REA。5. 真实项目里最容易踩的四个坑5.1 坑一事件没有成对出现这是我在项目评审时见到的最高频问题。很多人画 REA 模型画到订单事件就停了后面没有对应的收款事件也没有发货事件。没有成对事件资源流就断了。你记录了一个销售事件但看不到对应的经济流入这个系统的账从模型层面就平不了。修正方法很简单画完一个事件后强迫自己追问一句这件事对应的反向事件是什么借出对应归还销售对应收款采购对应付款。找不到对应关系的事件要么是业务边界没理清要么是你漏了一半模型。5.2 坑二资源粒度定错图书场景里有一个经典问题把某本书当成资源还是把某本具体的书当成资源如果你建模的是某本书那同一本书有 3 个副本、其中 1 本被借走你怎么处理书 ID 只有一份你又得额外搞一个副本数量字段来辅助判断库存。试几次就会发现资源粒度选的不是哪本模型后面全是补丁。更微妙的是库存的粒度。有些系统只按全局库存算但实际业务有多个库位不同批次价格还不一样。如果资源粒度选得太粗后面做批次追溯时只能推倒重来。我的经验是资源粒度要跟控制权变更的粒度保持一致。谁的控制权发生变化谁就是独立资源。每一本具体的书都可以被借出、归还那它就是一个独立资源图书种类不能被借出它不是资源。5.3 坑三用状态字段替代事件很多团队习惯在设计表时加一个status字段比如图书状态在馆/借出/丢失。我说实话这个字段作为查询辅助没问题但如果你把事件的职责也塞进去麻烦就来了。举个例子你把归还实现成UPDATE book_copy SET status在馆 WHERE id...。归还这个业务动作的时间、操作人、对应借出事件全部丢掉了。后面想统计平均借阅时长你根本算不出来因为事件记录不存在。经验法则是凡是涉及资源控制权变化的行为单独建事件用事件记录来表达。状态字段只允许作为冗余缓存存在而且要由事件推导更新不能让业务代码各改各的。5.4 坑四承诺事件和执行事件混在一起REA 还有一个概念叫承诺Commitment和执行Execution。订单本身是一种承诺它表达了打算卖发货和收款才是执行表达实际发生。很多系统只建了一张订单表把订单状态从待付款一路改成已发货已完成。状态字段本身承担了承诺和执行两重职责。这么做的直接后果是你很难回答这个月承诺了多少销售额实际成交了多少。两者之差就是订单取消、部分违约的金额。如果你把承诺和执行分开建模这个问题就透明得多承诺事件记录订单签订执行事件记录发货收款两者按业务编号关联。我自己在设计订单模块时会刻意保留两套事件订单事件和履约事件。一开始觉得多了一张表后面做经营分析时省了无数力气。最后说点实在的我用 rea 这套方法做图书管理重构前后花了大半个月。最大的体会不是模型本身有多高级而是它逼着我在动手写 SQL 之前先把业务到底发生了什么这件事想清楚。很多项目里的代码混乱、系统割裂根子不在编码在于建模的人没有把事实和结果分清楚。如果你也想试我建议先别急着画大而全的企业级模型。找一个你熟悉的小业务比如借书、点外卖、报销单用便签纸写下你识别出的资源、事件、参与者然后试着把成对事件连起来。这张纸上的东西可能比你之前画过的任何 ER 图都更接近业务的本质。最后分享一个小经验给每对事件编个对偶编号比如借出-归还编成 LOAN-001销售-收款编成 SALE-001。有了这个编号排查资源流断裂、做审计追溯会省很多时间。这个小习惯是我在做了几轮模型评审之后才总结出来的现在每次建模都会用上。