REA模型实战:用资源、事件、主体三要素重构业务系统,终结对账难题

发布时间:2026/10/21 15:35:46
REA模型实战:用资源、事件、主体三要素重构业务系统,终结对账难题
一提 rea 这三个字母大部分后端同事的第一反应是你打错字了想写 React 吧或者是让我读点什么。但我今天聊的 rea是一套把我从月底对账地狱里捞出来的业务建模方案——Resource、Event、Agent资源、事件、主体。这套思路最早是从会计信息系统研究里走出来的常被人叫 REA 模型。我的诉求很简单别再让订单、库存、资金三套数一到月底就互相打架。这篇不写理论综述直接讲我怎么理解这三个字母怎么把它拆成表结构和服务代码以及上线之后踩过的那些坑。如果你正在做一个订单结算、积分账本、库存台账之类的系统而且已经受够了财务说差 0.01 元、研发查一整天的日子这篇应该能给你一点不一样的建模视角。我的核心主张一句话就能说完业务系统里应该先记录事实再生成账本账本是结论结论不该被当成原始数据来维护。1. 先搞明白业务系统的账是怎么一步步对不上的1.1 传统复式记账在业务系统里的水土不服复式记账本身没有问题银行、账房这类业务本质就是钱进钱出天然适合有借必有贷、借贷必相等。问题是现在的业务系统早就不是纯资金流了。一个电商订单从下单到结算完成要经过下单、支付、发货、签收、退款、售后一堆动作里面还掺着优惠券、积分、多商户分账。你如果要求每个动作都直接翻译成一张带借贷分录的记账凭证系统很快会被自己逼疯。我见过最典型的做法为了把订单翻译成借贷开发同学在科目表里加了一堆过渡科目比如待结算款在途存货虚拟预收。这些科目不是业务真实存在的东西纯粹是为了让账能平。到了月底订单系统的数字和财务系统的余额对不上两边各自拿报表解释解释到最后往往变成一笔糊涂账。问题的根源在于我们一开始就从账出发建模而不是从事实出发。借贷分录是人对经济业务的一种表达方式它是视图是结果。你把它当原始数据存一旦业务规则变化、订单状态回退、做错了要冲正整个账本都得跟着改改来改去一定会漏。1.2 Resource、Event、Agent三个字母各管一摊如果你把业务数据里会变的东西抽象一下其实绕不开这三类东西、动作、人/组织。Resource资源是有价值且能计量的东西。现金、库存商品、积分、优惠券、服务时长都算。你不用管它是不是资产或者负债在业务侧它就是一个可以被计量、被转移、被消耗的存量。生活里类比一下就是水库里的水。Event事件是改变资源状态的那个事实瞬间。支付完成、出库发货、退款到账都是事件。事件是 REA 的核心它回答的问题是发生了什么、什么时候发生的、涉及多少量。还用水库打比方事件就是进出水的那一笔笔水流记录——流向、流量、时间全记下来。Agent主体是资源的控制方和参与方。顾客、商户、仓库管理员、平台都在这一类。它回答的是谁把资源给了谁。REA 最核心的命题是一笔经济业务本质上是某个 Agent 控制的一部分 Resource通过一个 Event转移给了另一个 Agent。这个表述听起来像绕口令但它把所有业务抽象成了同一套结构。你不需要为支付单独建一套模型为发货再建一套模型它们都是资源在主体之间流动的事件只是资源类型和参与主体不同罢了。2. REA 建模的核心细节三类实体怎么落地2.1 三类实体三类关系概念只有三个落地的时候关系也就三类多了反而乱。第一类关系叫 stockflow资源流。一个事件从某个资源里流出流进另一个资源或者在同一资源上增减。它保证的是总量守恒A 的现金少了 150必然对应 B 的资金账户多了 150。你在这条关系上做校验就天然有了复式记账借贷平衡的味道只不过平衡的不是科目而是资源。第二类关系叫 participation主体参与。事件的两端至少要挂两个主体表示控制权从谁转移到谁。支付事件里参与主体是付款的客户和收款的商户出库事件里参与主体是仓库和收货人。有了这一层以后要查谁欠谁就非常直接。第三类关系叫 fulfillment履约关联。事件要和前面的契约挂钩比如支付事件满足订单的付款义务发货事件满足订单的交货义务。在实际工程里这层通常表现为事件表里挂一个订单号、合同号。订单是否履约完毕查关联事件就知道了不需要专门设计一套状态机去猜。用一个表把这三类关系摆出来建模时心里就有数了关系类型解决的问题在表结构里的体现stockflow资源增减与守恒事件明细行的资源增减字段participation控制权转移的双方事件明细行的来源方、去向方fulfillment事件与契约/订单匹配事件主表里的单据号2.2 业务单据和经济事件这是最容易混的一对很多同学建模的时候把订单、支付单、发货单直接当成了事件。这是一个很隐蔽的坑。订单本质是契约描述的是打算做什么事件描述的是已经发生了什么。一个东西是意愿一个东西是事实。举个例子用户下单成功订单状态变成待支付这只是一个契约状态。真正的事件是支付回调到达那一刻——资金控制权从客户转移到了商户/平台账上这才叫发生了经济事件。同理仓管打了一张出库单不代表货已经出库出库单只是凭证货权真正转移的那个瞬间才构成事件。工程上这个区分会带来一个非常硬的规则单据可以改状态事件不能改。订单可以从待支付改成已支付但一笔已经落库的资金转移事件绝不允许直接 UPDATE 金额。要撤销一笔错误事件正确做法是新增一笔方向相反的事件去冲正。前者是改草稿后者是发一条不可撤回的消息之后再发一条反方向的消息抵消。这套只追加、不修改的思路也是后面所有对账和审计能力的基础。一旦你允许改历史事件审计追溯就名存实亡了。2.3 一笔订单从支付到退货的完整事件流讲抽象概念不如看一路完整例子。客户 A 在商户 B 那里买了 3 件商品单价 50合计 150 元。我们用 REA 模型把这条链路记录下来大致会产生下面这样的事件流序号事件类型发生时间来源主体去向主体资源变化说明1PAYMENT15:00A客户B商户 平台资金池A 现金余额 -150平台待结算资金 150支付完成2DELIVERY16:00仓库库存A在途权益库存商品 -3 件A 待收货权益 3 件发货出库3CONFIRM_RECEIPT第 5 天A待收货A已收货待收货权益 -3 件已收货资产 3 件确认收货4REFUND第 7 天B结算款A现金平台应结算款 -50A 现金余额 50部分退货退款你会注意到每一步都是什么时间、谁给谁、什么东西变了多少。没有一列是科目余额也没有任何一个字段是改出来的。等到要对账的时候把这张事件流按资源维度和主体维度汇总余额自然就出来了。这也是 REA 和传统记账最大的区别账本不用预先写死它是事件流的投影。你随时可以问任何一个资源现在的余额是由哪几笔事件组成的每一笔都能追回原始单据。这个能力传统一张凭证一张凭证维护的账本是给不了的。3. 从模型到代码落地一个最小可跑的 REA 记账引擎3.1 表结构五张表别多也别少概念讲得再漂亮最后得落到表上。我落地的时候只用了四张核心表加一张可选字典表。这里给出一个最小可跑的建表方案你可以直接拿去改。CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- CUSTOMER / MERCHANT / PLATFORM / WAREHOUSE agent_name VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, -- CASH / INVENTORY / POINT / COUPON / IN_TRANSIT owner_agent_id BIGINT NOT NULL, current_value DECIMAL(18,4) NOT NULL DEFAULT 0, unit VARCHAR(16) NOT NULL, version INT NOT NULL DEFAULT 0 ); CREATE TABLE economic_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- PAYMENT / DELIVERY / REFUND / ADJUST event_time DATETIME NOT NULL, -- 实际发生时间 recognize_time DATETIME, -- 财务入账确认时间 source_doc_type VARCHAR(32) NOT NULL, source_doc_id VARCHAR(64) NOT NULL, remark VARCHAR(255), UNIQUE KEY uk_doc (source_doc_type, source_doc_id) ); CREATE TABLE event_line ( line_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, direction TINYINT NOT NULL, -- 1 表示资源流入(增加), -1 表示资源流出(减少) resource_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, from_agent_id BIGINT, to_agent_id BIGINT, KEY idx_event (event_id), KEY idx_resource (resource_id, direction) );为什么事件明细要单独拆一张 event_line而不是在 economic_event 上直接加资源增减两个字段因为一笔事件极可能同时影响多个资源。比如一笔支付除了扣客户现金还要同时登记平台资金池、核销优惠券、发放积分。如果把这些都塞进主表字段会爆炸而且没法表达一个事件有多条资源流这种真实情况。resource 表里的 current_value 我特意标注了它是缓存。真正的余额理论上可以从 event_line 聚合出来SELECT resource_id, SUM(CASE WHEN direction 1 THEN quantity ELSE -quantity END) AS balance FROM event_line WHERE resource_id 1001 GROUP BY resource_id;保留 current_value 只是为了让高频查询不用每次扫全量事件。它可以是缓存可以重建但绝不能成为唯一数据源。3.2 两条硬规则守恒和幂等REA 模型落地成代码之后真正要守住的就是两条规则。第一双效守恒。每个事件至少有一条流入行和一条流出行且流入流出的口径要能互相印证。资金类事件金额守恒A 减 150 就必须有另一方加 150库存类事件数量守恒出库 3 件在途权益就增加 3 件。比较特殊的是跨类型转换比如 1000 积分换一张 50 元券积分的个和券的元量纲不一样不能直接相减。这种情况需要一张换算规则表在事件落库之前做换算校验保证转出资源的折算价值等于转入资源的折算价值。第二幂等。事件是对外部世界的记录外部系统很可能重复通知你。支付回调到达两次事件只能记一次。这里最有效的手段是 source_doc_type 加 source_doc_id 的唯一索引同时在业务代码里先查一次。两道防线都上实测下来才不会漏。另外要强调一下 event_time 和 recognize_time 分开。event_time 是真实发生时间recognize_time 是财务确认入账的时间。很多业务里有日切的概念一笔支付发生在 23:59:59财务要求记到第二天因为结算周期按自然日切。没有这两个时间字段这种规则根本没法解释清楚月底对账必然吵架。3.3 核心服务记一笔事件服务层的核心逻辑用一个函数就能说清楚。我写一段接近伪代码的 Python重点是思路def record_event(event_type, event_time, source_doc, lines): # 第一道防线幂等检查 if event_exists(source_doc): return get_existing_event(source_doc) with transaction(): # 第二道防线锁定并校验资源余额 for line in lines: res lock_resource(line.resource_id) if line.direction -1 and res.current_value line.quantity: raise BusinessError(f余额不足: resource_id{res.resource_id}) # 落事件主表 event insert_economic_event(...) # 落事件明细并更新资源缓存余额 for line in lines: insert_event_line(event, line) update_resource_balance(line.resource_id, line.direction * line.quantity) # 写入幂等占位记录 mark_doc_processed(source_doc)lock_resource 在关系型数据库里就是用 SELECT ... FOR UPDATE 锁住资源行防止两个并发请求同时扣减同一笔余额。version 字段则是乐观锁的备选方案两者选一个就行我实际项目里用行锁更多。这里有个容易被忽略的点事件落库和资源余额更新必须在同一个数据库事务里。很多项目支付服务和订单服务分库分应用回调来了先落事件、再发消息让订单系统改状态中间一旦消息丢失就会出现钱扣了、订单没支付的事故。我的处理办法是事件落库和订单关键状态更新放在同一事务事务提交后再发消息做后续的异步动作宁可消息重复不能事务拆开。3.4 从事件流生成借贷报表映射规则怎么定REA 模型本身不产借贷但财务就是要借贷。关键是这层映射放在最后而不是一开始就让业务代码手写借贷分录。我在项目里维护了一张入账规则表本质上就是事件类型到科目分录的翻译。拿前面那笔订单举例简化版映射大概是这样的事件类型借方科目示例贷方科目示例入账时点PAYMENT银行存款/平台资金池应付商户结算款支付确认时DELIVERY发出商品库存商品出库时CONFIRM_RECEIPT应付商户结算款主营业务收入确认收货时REFUND主营业务收入红冲银行存款/平台资金池退款确认时科目只是示例不同公司的会计准则和业务模式不一样但这张表的形态是通用的。因为事件是唯一的真实来源入账规则哪怕后期调整也只需从某个时间点开始对新事件应用新规则历史事件不受影响。你不需要像传统账本那样回去改历史凭证。这套结构带来的排查体验是质变的。以前对不上账先猜是订单漏了、还是支付回调丢了再翻日志。现在只要顺着 event_line 查某个资源余额对应的每一笔事件哪笔多记了、哪笔漏记了、哪笔方向错了一眼就能定位。追问到原始单据之后要么补一笔事件要么冲正一笔事件永远不需要去 UPDATE 已经存在的记录。4. 实际落地中的坑常见问题与排查技巧实录4.1 高频问题速查表把我在项目里遇到的典型问题整理成一张表按症状—根因—解法来看排查效率会高很多症状根因解法支付回调重复请求钱记了两遍没做幂等source_doc 唯一索引 事务内幂等检查支付成功但订单状态没更新事件落库和业务状态更新不在同一事务同一事务内更新事务提交后再发异步消息财务余额和业务库存对不上两套系统各自写各自的数都以 event_line 为唯一数据源只保留各自缓存误记一笔账直接改了老事件的金额把事件当可变记录维护禁止 UPDATE/DELETE新增反向事件冲正月末报表扫描几百万事件太慢没有预聚合每日余额快照 物化汇总表并发扣减导致余额变负没有锁或乐观锁资源行 SELECT FOR UPDATE 或 version 校验4.2 几个我自己踩过的坑第一个坑是把事件表当普通业务表用了。早期有一次退款金额录错我图省事直接 UPDATE 了那条事件记录。结果当天晚上的对账任务全部异常因为关联的快照和事件流对不上了。那次之后我立了铁规矩事件一旦落库只有 INSERT没有 UPDATE 和 DELETE。想纠正就新写一笔 ADJUST 或者 REFUND 事件去冲。虽然多写两行代码但审计可靠性和对账准确性完全不一样。第二个坑是一开始就把契约Commitment也建模进去了。REA 完整本体里确实有契约用来表达未来的事件承诺比如订单还没支付前它已经在违约边缘了。但第一版就把契约表加进来系统复杂度直接翻倍。后来我把契约层砍掉订单状态只在应用层表达主流程只用资源、事件、主体三张核心表跑通了之后才逐步考虑把待支付订单未履约合同这类契约语义补回来。建议你也从最小模型开始别一步到位。第三个坑是 time 字段不分。我项目里有一笔支付发生在 23:59:59财务坚持要记到第二天因为结算周期按自然日切割。当时事件表里只有 event_time财务让改研发说没记错双方僵了很久。后来加了 recognize_time所有入账对账都看这个字段这个问题再没出现过。任何涉及钱和时间边界的系统这个双时间设计真的不能省。第四个坑比较细是方向字段的语义。我最早把 event_line.direction 设计成 IN / OUT 两个字符串结果下游报表同事写 SUM 的时候经常忘记把 OUT 折算成负数导致余额翻倍。我后来统一改成 1 和 -1聚合查询全部用统一公式从根上消掉了正负号理解偏差。细节看起来不起眼但对账系统里正负号的歧义最容易造成事故。做完这套之后我个人最大的体会是财务对账从猜谜变成了查证。任何数字都能顺着事件追到一笔具体的事实再追到原始单据不再需要靠经验猜是哪里漏了。开发成本确实比直接建一张借贷流水表要高一点但对账成本和对账周期降了一个量级。如果你正在为订单、库存、资金三套数对不上头疼不妨试试从 rea 这三个字母开始重新建模。