REA模型实战:用资源-事件-主体建模,从源头解决账实不符
发布时间:2026/10/11 10:15:49 锦皓数字建站

如果你和我一样拿到业务需求的第一反应是“先建表”——客户表、订单表、商品表、借阅记录表把字段撸完再写接口那这篇关于 REA 模型 的文章可能值得你花十几分钟读完。我最近重构一个社区资料馆的借阅系统时发现所有对不上账的诡异bug根源几乎都是同一个把状态当成字段存了把余额当成字段存了把未来承诺也当成字段存了。而一套上世纪八十年代就提出的老框架恰好把这些问题从根上理清了。这篇文章不会讲太多理论重点是用一个完整的案例带你从“传统ER建模”切换到“REA建模”的思考方式再把概念模型映射到数据库表和代码最后聊聊我踩过的坑和适用的边界。核心就一句话资源Resource、事件Event、主体Agent三者分清楚你的业务模型就有了审计底账很多“账实不符”的锅根本不用背。1. 从一场“对不上账”的库存排查说起1.1 传统ER建模为什么越改越乱先说那个资料馆项目。第一版需求特别简单有图书资源有会员会员可以借书、还书。当时的建模方案几乎所有人都能想到——建三张表books、members、borrow_records。books里存库存数量borrow_records里存一个status字段下拉值有borrowed、returned、overdue三种。表面看没什么问题但业务一跑起来就开始失控。第一轮加需求图书可以捐赠入库要记捐赠人。第二轮加需求要支持预约借阅。第三轮加需求采购要记账顺便跟踪给供应商付了多少钱。第四轮加需求图书报废注销。每一轮我都在原表上打补丁books表里塞了current_member_id、arrival_date、source_typeborrow_records里塞了is_overdue、renew_count、operator_id状态字段之间开始互相打架。最经典的一幕发生在某次月底盘点数据库里写“库存总量100本”书架上实际只有97本。查了半天发现有个会员还书时系统没写borrow_records只更新了books表的库存数字另一个接口又把status改了。谁能想到呢当时所有人都在想是不是并发问题但真正的问题很简单——我们根本没有一个可靠的“事实来源”。1.2 冗余状态字段是账实不符的元凶我一直怀疑“status字段”是很多系统的万恶之源。不是说不能用枚举而是你一旦把“当前状态”和“历史事实”混在同一个字段里这个字段就会变成谁都能改、改了没人知道的泥潭。比如borrow_records.status returned但returned_at是空比如books.stock 97但没有任何一条记录解释为什么从100变成97。传统ER建模鼓励你把当前结果物化出来比如库存数量、当前借阅人、是否逾期这些字段确实查询方便可它们本质上是“计算结果”不是“业务事实”。真实世界里账是行为和时间的累积不是当前快照。你今天看到的库存、余额、在借数量都应该能由一组行为推导出来并且能解释每一步。这就是我转向REA模型的直接动机不想再维护一堆互相打架的冗余状态想让数据自己“说清楚账”。1.3 REA模型的出发点像记账一样建模REA是Resource、Event、Agent三个单词的首字母中文通常翻译成“资源-事件-主体”最早是会计信息系统领域的研究者提出的企业业务建模框架。它的出发点特别朴素任何企业的业务活动都可以拆成“谁Agent参与了什么事件Event事件改变了什么资源Resource”。与传统的ER建模相比REA最颠覆的一点是事件才是核心。资源状态的变化必须由事件驱动库存、余额这些数字都不应该直接存而应该由事件流实时或定期推导出来。就像记账一样流水是唯一的真相分类账只是流水的结果。这个思路在金融、电商、进销存领域尤其好用因为它天然带审计性——每个数字变化都能找到“是谁、在什么时候、因为什么事件”造成的。我当时看到这个框架的第一反应是这不就是事件溯源吗后来才明白REA比单纯的事件溯源多了一套严谨的语义规则它告诉你怎么把“事件”分类、怎么表达资源增减、怎么关联主体责任关系而不是让你拍脑袋定义一堆EventType。2. 资源、事件、主体三个概念怎么判2.1 三个概念的一句话判别法很多初学者拿到REA最容易卡在“这个东西到底算资源还是事件”上。我总结了一个特别实用的三步判别法问三个问题它是否被业务主体消耗、转化、交换、借用如果是它是资源Resource。比如书、现金、借阅额度、积分。它是一个已经发生的动作或事实吗有明确的时间点如果是它是事件Event。比如借出、归还、采购付款。它是参与或发起行为的人、组织、角色吗如果是它是主体Agent。比如会员、管理员、供应商。“借阅记录”这个词最容易让人误会。如果你建模时把“借阅记录”当成一张表放着要看清楚它本质上是“借出事件”和“归还事件”的集合不是独立资源。同理“借阅次数”也不是资源连事件都不是它是事件的聚合计算结果。真正需要存储的只有某天某会员借走了一本书借出事件某天某会员还回来一本书归还事件。2.2 存量-流动、参与、事件关联三类关系REA把实体之间的关系分成了三类这也是它和传统ER图的本质区别第一类是“存量-流动”关系Stock-Flow连接事件和资源表达资源在事件中流入或流出。比如借出事件连接“图书”资源数量是-1归还事件连接同一资源数量是1。这个关系是库存余额能推导出来的根本。第二类是“参与”关系Participation连接事件和主体表达谁参与了这件事。一个事件可以有多个参与主体比如借出事件既有借书的会员也有办理借阅的馆员甚至还有审批线上预约的管理员。第三类是“事件-事件”关系Duality或Commitment表达事件之间的业务因果。借出事件对应归还事件采购事件对应付款事件预约承诺对应实际借出事件。这一关系让系统的数据不只是“流水账”而是一张能追溯业务链条的网。这三类关系我建议在建模阶段先画在纸上不要急着进数据库。等你想清楚每类关系再设计表结构后面会顺利很多。2.3 和普通ER图、类图的区别多了一个审计视角传统ER图解决的是“有哪些实体、实体有什么属性、实体之间怎么关联”核心是名词结构。REA解决的是“业务怎么运转、事件如何改变资源、谁能负责”核心是行为链加审计视角。举个例子传统ER图里books表会有“当前借阅人member_id”字段这个字段是状态。REA里根本不会有这个字段因为“当前借阅人”是从“借出事件未归还”里推导出来的。books表只保留这本书的ISBN、书名、存放位置等固有属性谁借走了、什么时候还全是事件的功劳。REA不是要替代ER或类图它更像是一套约束规则告诉你哪些字段不允许出现在实体表里、哪些数据必须由事件推导。很多团队做了REA概念模型之后落数据库还是可以用ER那套工具但表结构的设计理念完全变了。3. 用社区资料馆的借还业务走一遍REA拆解3.1 按业务生命周期画出事件链理论讲再多不如直接过一遍案例。假设我们的社区资料馆有以下真实业务馆员采购新书供应商供货馆里付钱给供应商热心会员捐赠旧书资料馆接受捐赠会员借出图书管理员登记会员归还图书管理员验收图书破损报废管理员注销超期未还收取罚款。如果你按传统思路第一反应是画“图书表、会员表、采购单表、借阅表、罚款表”。如果按REA思路第一步不是画表而是先画业务生命周期的事件链。你会发现整个业务流程就是一条条事件链采购事件带来图书入库、付款事件带来现金流出借出事件带来图书出库、归还事件带来图书入库收货与付款配对借出与归配对注销与借出无关但同样是图书出库事件。这些事件链串起来之后资源只有一个核心——图书外加一个现金资源。画完事件链我心里一下子就踏实了。因为所有业务动作都是事件未来无论加“续借”“转借”“预借”还是“赔偿”本质上都是往事件链里增加事件类型而不是在图书表上继续堆字段。3.2 采购、捐赠、借出、归还这些事件怎么落地成模型下面是我在这个项目里整理出来的核心事件类型每一行代表一类事件并标明它的资源方向和参与主体事件类型业务语义资源增减参与主体PURCHASE_BOOK_IN采购入库图书 1供应商、馆员DONATION_BOOK_IN捐赠入库图书 1捐赠者、馆员BORROW_BOOK_OUT借出图书 -1会员、馆员RETURN_BOOK_IN归还图书 1会员、馆员WRITEOFF_BOOK_OUT报废注销图书 -1馆员CASH_PAY_OUT采购付款现金 -1供应商、馆员PENALTY_CASH_IN罚款收取现金 1会员、馆员注意看采购这个场景一笔采购会产生两个事件一个是PURCHASE_BOOK_IN图书流入一个是CASH_PAY_OUT现金流出两个事件之间通过“采购-付款”的配对关系关联。这就是REA里最特别的“事件-事件”关系它保证了业务不是单点记录而是完整闭环。归还也一样一笔归还同时还清了借出事件对应的“未还承诺”在数据库中我会用link_event_id把归还与借出关联起来。哪个会员借的书有没有还一目了然。3.3 资源的流入流出与余额推导有了事件流所有账目都可以动态计算不再需要“库存数字”这个字段。具体的推导逻辑是这样的馆藏总库存 所有图书1事件的总和 - 所有图书-1事件的总和在借数量 所有借出事件数量 - 所有归还事件数量现金结余 所有现金1事件 - 所有现金-1事件某本书当前借阅人 与这本书相关的借出事件中还未找到对应归还事件的那条事件的会员主体。这套推导在SQL里也特别直白核心就是按资源聚合事件增减量SELECT resource_id, SUM(quantity_delta) AS current_balance FROM event_entry WHERE resource_id ? GROUP BY resource_id;你可能会说每次都SUM累加性能是不是很差这个问题我后面会讲快照方案但核心思想不变快照只是性能缓存权威数据永远是事件流。一旦某个数字对不上你不需要去猜直接打开事件流从头到尾捋一遍一定能找到问题事件。3.4 主体边界谁发起、谁经手、谁负责REA对主体的建模也比较讲究不建议所有角色都塞进一个“user_id”了事。一个事件至少要有“发起者”和“经手者”有些场景还要有“审批者”“责任人”。在社区资料馆的模型里我区分了两类主体内部主体馆员、管理员拥有操作权限外部主体会员、捐赠者、供应商是业务交互的另一端。每个事件记录里都包含source_agent_id和target_agent_id。比如借出事件source_agent_id是会员借阅者target_agent_id是馆员承办人。这个设计最大的收益在审计排查出了问题能精准定位到“谁在什么时间做了这件事”而不是面对一张只有created_by的订单表发呆。我强烈建议别在资源表上直接挂“当前归属人”。比如books表不要放current_member_id字段因为这个字段本质上是派生状态一旦和事件流不一致你根本不知道哪个才是真的。归属人的唯一正确来源永远是“最近一条未归还的借出事件”。4. 从概念模型到数据库表与代码的落地映射4.1 表结构事件明细表与资源台账的拆法概念模型理清后落地数据库就顺理成章了。我最终用的是六张核心表而不是传统的“一张业务表加一堆字段”。第一组是基础数据表resource表存资源的固有属性比如图书的ISBN、书名、位置不存库存数量agent表存主体信息区分会员、馆员、供应商。第二组是事件表event_header表存事件头包含事件ID、事件类型、发生时间、参与主体、外部单号event_entry表存事件明细包含资源ID、数量增减、单价。事件头加明细的结构是为了支持一个事件里同时关联多个资源的情况比如采购一批书同时包含多本不同的书或者一笔收款同时核销多本逾期图书的罚款。我用SQL大致表示一下核心结构CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, name VARCHAR(255) NOT NULL, location VARCHAR(64), unique_attrs JSON ); CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- MEMBER / STAFF / SUPPLIER / DONOR display_name VARCHAR(255) NOT NULL ); CREATE TABLE event_header ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMP NOT NULL, source_agent_id BIGINT NOT NULL, target_agent_id BIGINT, link_event_id BIGINT, -- 配对事件如借出关联归还 reversed_event_id BIGINT, -- 冲销事件 ref_no VARCHAR(64) ); CREATE TABLE event_entry ( entry_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity_delta DECIMAL(18,2) NOT NULL, -- 正数为流入负数为流出 unit_price DECIMAL(18,2) );这个结构最大的好处是新增一种业务动作通常只要新增一个event_type常量再写对应的写入逻辑不需要改表结构。我在重构期间加“续借”功能只花了一个多小时这在以前的字段堆叠式设计里完全不敢想。4.2 聚合根的划定事件为核心还是资源为核心从概念模型落到领域模型时很多人会纠结聚合根到底选什么。我的建议是以事件为核心的聚合而不是以资源为核心。为什么因为“库存扣减”的一致性边界就在事件上。一次借出必须同时完成“检查可借数量”和“写入借出事件”两个操作否则就会出现超借。如果聚合根是resource那么每次借出都要锁图书资源并发高一点就会出问题。我在工程实现里把借出事件作为一个独立聚合根内部包含一条图书资源的出库明细。事务边界就是创建借出事件 校验并确认图书在馆。归还同理。校验逻辑可以依赖数据库的约束比如在event_entry里对同一资源、同一事件类型做唯一约束例如同一本书不能同时有两个未归还的借出事件从根上避免超借。4.3 余额查询的性能问题与快照方案前面说了余额靠SUM推导但有读者肯定要问线上首页要显示馆藏数量、在借数量每次都SUM累加数据量大以后怎么办我的做法是“事件流为真相快照表做缓存”。具体分三步每次事件写入后同步影响一张“资源日结快照表”字段包含resource_id、统计日期、期初数量、流入量、流出量、结存数量查询余额时优先读快照快照没有覆盖到最新事件时再用事件流补算当天的增量快照只允许追加和更新底层依据永远是事件流任何修正都通过补一个修正事件来实现不直接改快照。实测下来这个方案兼顾了查询性能和数据的可追溯性。即使快照表被误改了也能重算因为没有丢掉事件流。这比在books表里存一个随时会失真的stock字段强太多了。4.4 事件不可变性的工程约束REA落地最大的工程约束就是事件不可修改、不可删除。任何写错数据的情况都通过新增“冲销事件”来纠正而不是UPDATE原事件。这句话说起来容易做起来要配合几个硬性手段。第一数据库层面禁止对event_header和event_entry执行UPDATE或DELETE可以靠数据库触发器或者只给应用账号INSERT和SELECT权限。第二业务接口层不提供“修改借出记录”“删除归还记录”这样的操作只提供“冲销”操作冲销事件会在event_header里记录对应的reversed_event_id。我见过不少团队在REA实践到一半因为“业务方要求改数据”就偷偷把事件UPDATE了结果整个审计链条断裂账又对不上了。如果你决定走REA一定要提前和业务方对齐这个规则数据错了可以冲销重录但历史足迹必须留下。5. 关于REA的常见翻车现场与我的使用边界5.1 坑一把状态和承诺都塞进事件表最容易出问题的是把“还没发生的承诺”当成事件写进去。比如用户预约了一本书如果直接把预约记录写成“借出事件”那么库存余额会提前-1图书实际还在馆里账就虚了。预约本质上是一种承诺Commitment不是事件。独立放在reservation表里用传统状态机管理待生效、已生效、已过期只有预约真正转化成借出事实时才创建BORROW_BOOK_OUT事件。过分追求“所有业务都进事件表”反而会让事件表变成垃圾场。我在实际项目里就把“借出承诺”和“借出事件”分开建模。预约表管流程状态事件表管库存账目两边通过预约单号关联互不污染。这个边界想清楚之后很多看似无解的对账问题自动消失了。5.2 坑二价格、积分、促销这类业务规则放错了层REA建模很容易让人钻牛角尖价格算资源吗积分算事件吗促销规则算主体吗我的结论是价格、积分、促销这些属于“业务规则和值对象”不要硬塞进REA三层主结构里。价格应该作为事件明细的unit_price字段保存。比如借书的押金、罚款金额、采购单价都是事件发生时点的快照存下来即可不要为价格单独建模成资源。积分如果只是“累计奖励积分”这种汇总指标可以由事件流聚合计算但也不必把每次积分变动都做成资源流转。只有当积分真实可以兑换商品时才把积分建模成资源。促销规则属于承诺层或规则引擎层在事件发生时通过规则计算最终成交价但模型主体不需要变化。5.3 坑三硬套REA把简单场景复杂化REA很好用但不代表所有系统都该用。我踩过一次坑是一个纯粹的内容审核后台待审核、审核中、已通过、已驳回没有任何库存、余额、资源流转。当时为了统一技术栈硬套REA结果审核状态硬要用事件推导还写了好几个冲销接口最后维护成本翻倍。判断一个业务适不适合REA我一般看三个信号一业务里有没有需要“盘平”的资源数量或金额二有没有监管或审计需求需要回答“谁在何时做了什么”三有没有多主体协同产生责任追踪问题。这三个信号只要有一个明显REA就是加分项。如果全都没有直接用状态机反而更清爽。5.4 什么情况下我依旧会用回传统状态机最后的实践总结是REA不是银弹我更倾向于把它用在“账务核心域”把“流程管理域”留给传统状态机。在社区资料馆项目里预约单、审批流、上架审核这种面向流程状态的功能我用的是普通状态机表格图书库存、现金账、罚款账这种需要账实相符的领域用的是REA事件流。两者通过event_type和ref_no做关联井水不犯河水。这种混合方案让我越用越舒服。因为不见得全系统都需要REA核心域管好账流程域管好状态各自发挥各自优势。如果你正在重构一个账目总对不平的老系统我的建议是别想着一次性推翻重来可以先挑库存和交易两个模块改用REA跑通之后再逐步扩大数据会自己用账目清爽程度告诉你这个方向对不对。在我第一次完整跑通那套事件流推导看到“应还数量”“实还数量”“在库数量”三项数字完全自洽的那一刻说实话还挺有成就感的。REA模型不新但它值得你在任何一个涉及资源流动的系统里重新尝试一遍。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。