REA数据模型:用事件溯源思想构建订单库存资金一体化架构 1. 项目概述与为什么要做“REA”数据模型1.1 这个项目代号到底指什么最早接到这个需求时我磁盘里只有一行标题rea。没有需求文档没有原型图。我当时第一个反应是这该不会是某个开源框架名字的缩写吧翻了一圈之后才意识到这其实是一个数据建模项目团队要用一套通用结构把公司的订单、库存、资金、往来单位全部串起来。而rea在这里代表的正是REA模型——Resources-Events-Agents也就是资源-事件-参与者模型。它最早从会计信息系统领域提出来用来描述业务过程最底层的那套骨架后来被大量借鉴到业务系统数据建模里。说简单点REA模型就是回答三个问题发生了什么事、这件事牵涉到哪些资源、谁参与了这件事。传统企业开发里我们习惯把业务拆成“订单表、付款表、出库表”每加一种业务就加一张表每加一个状态就加一个字段。REA模型的思路正好反过来它把所有业务都抽象成统一的事件流资源只是事件发生前后被转换或交换的东西参与者负责记录责任主体。这样一来不管未来业务怎么膨胀核心表结构可以保持稳定新增业务只是新增一种事件类型而不是新增一堆互不兼容的表。后来我把这套思路讲给组里的后端同事听他第一反应是“这不就是把流水表好好设计一下吗”我当时没急着反驳。等我们把订单、库存、资金全面接进REA结构后他不得不承认这个模型解决的不只是流水记录问题而是把业务从“状态快照思维”彻底拉到了“事件溯源思维”。1.2 REA模型到底解决了哪些痛点在落到REA模型之前我们系统里的订单和库存是两套完全割裂的数据结构。订单模块有自己的订单表订单状态字段从待支付一直枚举到已完成每次状态变更只是update一行记录历史版本全靠一张操作日志表勉强撑着。库存模块则是一张商品表加一个库存数字出货时直接减库存出错了只能靠手工盘点对账。这种设计在业务量小的时候没什么问题但一旦需要回答“这个订单为什么被取消”“这批货具体是哪天下架的”“某某客户名下所有资金变动是怎么发生的”整个人就会陷入疯狂联表加各种状态判断。REA模型把这些问题全部归纳进一套关系里Event表记录“发生了什么”Resource表记录“什么东西被影响”Agent表记录“谁发起或参与了这件事”再通过Event与Resource之间的存量-流量关系、Event与Event之间的触发关系、Event与Agent之间的参与关系把整条业务链串起来。它的价值不在于某个表设计得多精巧而在于所有业务事实都被统一成了同一种表达方式。一旦你有了一致的表达审计回溯、报表统计、异常排查就只是查询方式问题不再需要到处拼表。很多人觉得REA模型只适合财务系统我觉得这个理解太窄了。它适合一切有资源流动、有责任主体、需要追溯过程的业务系统。电商订单、库存流转、资金清结算、客户积分、甚至会议室预约系统都能从REA模型里找到对应的资源、事件和参与者。唯一不太适合的是那些纯KV缓存、临时计算类场景那些用状态表更直接。1.3 适合谁看、需要什么基础这篇文章主要是写给两类人看。一类是做后端开发或者数据架构的同学你们可能正被复杂的业务表设计搞得头疼想知道有没有一套更耐用的建模方法另一类是业务分析师或者产品经理你们虽然不直接写SQL但平时需要跟开发讨论业务逻辑REA模型能帮你们把“业务语言”和“数据语言”对齐。需要的基础不多能看懂基本SQL、知道表连接是什么概念就够。不需要学过会计REA模型虽然从会计领域来但它讲的不是借贷分录而是更底层的业务事实。会计里的借和贷只是表达方式REA关心的是资源从哪来、到哪去、谁经手。我会尽量用大白话讲并且给出可以直接跑的表结构你看完可以照着改一版套到自己的业务里。2. REA模型的核心概念与原理拆解2.1 三个基本元素Resource、Event、AgentREA模型里只有三个核心实体每一笔业务都围绕它们展开。Resource资源是企业或组织能够控制、有价值、可以参与交换的东西。钱是资源商品是资源库房里的原材料是资源甚至“客户预付的款项”这种抽象权益也可以是资源。资源不一定要有物理形态只要能被某个事件改变其数量或归属就可以建模成资源。我习惯在资源表里加一个resource_type字段区分现金、商品、积分、应收款等等避免把不同种类的资源混在一个表里又不想为每种资源建一套独立表。Event事件是业务发生的瞬间比如“客户下订单”“仓库发货”“收到一笔付款”。事件是REA模型里最核心的实体也最容易被搞混。很多团队刚开始建模时会把状态值当成事件比如把订单状态从“待发货”更新为“已发货”当作事件。实际上“已发货”只是状态快照“发出这个包裹”才是事件。状态可以覆盖事件不可以覆盖。这也是REA模型能实现追溯的根本原因——事件一旦写入就不修改只追加。Agent参与者是事件的执行人或责任人。客户、供应商、仓库管理员、审核人员、甚至第三方支付系统都能作为Agent。有些系统会简单地把Agent做成一个字段挂在事件表上但那样做会丢信息。一个事件往往有多个参与者比如“销售出库”这件事至少涉及顾客、销售员和仓库管理员三个人。所以Agent与Event之间必须建模成一张参与关系表通过role字段区分角色。这样未来问你“某个顾客名下所有订单”“某个仓库管理员经手的全部出库”就能直接查。2.2 三类关系存量-流量、触发、参与有了实体还不够REA模型的关键在于实体之间的关系怎么定义。第一类关系是存量-流量关系发生在Resource和Event之间。它描述一个事件导致某种资源增加还是减少对应关系上的direction字段。比如“销售出库”这个事件让仓库里商品这种资源减少就让resource_id指向商品direction设为out。与之对应“采购入库”让商品资源增加direction设为in。这个关系里通常还会带数量、单价、金额因为资源流动必须有度量信息。设计时要注意同一个事件可以关联多条存量-流量记录。比如一张销售订单同时涵盖商品A和商品B那么事件表只记录“销售”这个动作stock_flow表里就写两行一行对应A一笔一行对应B一笔。第二类关系是触发关系发生在Event和Event之间。业务事件从来不是孤立的付款是因为订单触发发货是因为付款成功触发退货是因为对原发货不满意触发。REA模型允许事件之间建立自关联用一个triggered_by_event字段挂在事件表上指向触发当前事件的那个源头事件。这样做的好处是你可以从一笔最终收款一直往上追回最初那个动作也能从一条订单往下把所有相关后续事件全部拉出来。第三类关系是参与关系发生在Agent和Event之间。每一个事件至少有一个参与者但通常会有多个。比如“确认订单”这个事件客户是发起人客服人员是处理人再比如“付款”事件客户是付款方支付系统是执行方商家是收款方。参与关系表里除了event_id和agent_id还要有role字段用来表示这个参与者在这个事件里扮演什么角色。一条事件记录如果没有参与者那么这个事件就缺乏责任主体也就不满足REA模型的基本要求。2.3 与复式记账的映射关系REA模型经常被拿来跟复式记账法做对比实际上它们不是对立关系REA是比复式记账更底层的一层。复式记账里每一笔分录都要有借方和贷方资产等于负债加权益而REA模型记录的只是资源流和事件流借贷关系可以从REA数据推导出来。举个例子一笔销售收款业务。传统记账会写借银行存款贷主营业务收入。REA模型里则建模为两条存量-流量记录一条是现金资源流入公司directionin一条是商品资源流出公司directionout。至于为什么是“借现金”而不是“贷现金”那是会计准则加在上面的解释REA模型不管这个。它只需要保证资源的流入流出是成对出现的业务事实完整最终总能在报表层推算出借贷平衡。我在实际项目里发现会计同事和开发同事讨论模型时经常出现鸡同鸭讲因为会计关心的是账务科目开发关心的是数据结构。REA模型正好提供了一个中间层它对开发是统一的数据模型对会计是可追溯的业务事实。科目只是对资源的一种分类而REA关心的“这笔钱从哪来”远比“这笔钱记哪个科目”更接近业务本质。这也解释了为什么REA模型能够支持审计追踪因为它记录的是不可变事实而不是可变状态。3. 实操用一个订单系统把REA模型落成数据库3.1 场景定义与业务规则理论讲完接下来用一个小型B2C电商场景完整走一遍落地过程。假设业务规则比较简单顾客在前台下订单订单创建后需要完成支付支付成功后仓库发货库存同步减少。订单一旦创建系统要能追踪到这笔订单关联的支付事件、发货事件并且要能随时查出某个商品的出入库历史、某个顾客的所有资金变动。为了避免把案例复杂化这里先不讨论退款、部分发货、优惠券这些分支。核心是让大家看明白REA模型的骨架怎么搭后续扩展分支时只需要增加事件类型和关系行数不需要改核心结构。在正式建表之前我得先强制自己把REA思维摆到台面上先把所有“名词”和“动作”列出来名词基本对应Resource和Agent动作对应Event。这个阶段不要想字段不要想主键只做业务抽象。列完之后我们再决定哪些信息需要作为独立实体哪些只是属性字段。这个案例里资源有商品、库存、现金账户和应收账款参与者有顾客、销售人员、仓库管理员和支付系统事件有订单创建、支付成功和商品发货。这里需要特别说一句订单创建在REA模型里是一个承诺类事件它本身并不直接让商品资源流出而是触发后续的支付和发货事件。把“订单创建”和“商品出库”分开建模是REA模型特别重要的一点也是传统订单表最难理解的差异。3.2 实体设计与表结构基于上面的分析我设计了四张核心表外加两张关系表。资源表、参与者表、事件表、存量-流量关系表和参与关系表事件表内部用triggered_by_event字段表达事件之间的触发关系。资源表比较简单只存资源本身的属性比如名称、类型、单位、状态。不建议把数量放在资源表里因为“当前库存数量”是一个可推导的聚合值应该由事件流汇总得到而不是直接保存一个会被覆盖的快照。我在很多项目里看到过把库存数字直接写在商品表上的设计那个数字一旦被并发更新或者手工修改整个库存历史就废了。REA模型里库存当前值通过查询所有“商品入库”和“商品出库”事件的差额算出来虽然查询变重了但准确性和可追溯性提升了几个量级。数量不是不能缓存而是缓存区只能作为读性能优化不能作为业务依据。事件表是整张结构的核心虽然字段不多但每一行都代表一个不可变的业务事实。event_type用字符串描述事件类型occurred_at记录事件发生时间external_no保存外部单号方便跟第三方系统对账。triggered_by_event指向触发当前事件的源头事件形成一张事件链。存量-流量表负责把资源和事件连接起来每次资源流动就在这张表里插入一条记录。direction只有in和out两种取值数量必须是正数方向语义由direction表达不允许用负数表示方向因为负数会增加查询和理解难度还容易写出方向相反的脏数据。同时这张表里还保存单价和金额用于后续财务报表。参与关系表把事件和参与者连接起来通过role区分角色。比如支付成功事件里顾客是payer角色支付系统是processor角色实际业务中可能还有收款员角色那就再加一行。下面是最简化的建表SQL-- 资源表 CREATE TABLE rea_resource ( id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, unit VARCHAR(32), status VARCHAR(16) NOT NULL ); -- 参与者表 CREATE TABLE rea_agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL ); -- 事件表 CREATE TABLE rea_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMP NOT NULL, external_no VARCHAR(64), description TEXT, triggered_by_event BIGINT REFERENCES rea_event(id) ); -- 存量-流量关系表 CREATE TABLE rea_stock_flow ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES rea_event(id), resource_id BIGINT NOT NULL REFERENCES rea_resource(id), direction VARCHAR(4) NOT NULL CHECK (direction IN (in,out)), quantity NUMERIC(18,4) NOT NULL, unit_price NUMERIC(18,4), amount NUMERIC(18,4) ); -- 参与关系表 CREATE TABLE rea_participation ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES rea_event(id), agent_id BIGINT NOT NULL REFERENCES rea_agent(id), role VARCHAR(32) NOT NULL );这套表结构看起来很简单但它足够承载完整的订单-支付-发货链路。实际项目里可以再加租户ID、币种、汇率、税种这些字段但骨架不需要变。3.3 关键字段与约束说明有几个字段需要特别解释一下踩坑基本都踩在这些地方。事件表的triggered_by_event不是必填字段因为链路最源头的事件没有前置事件。比如“创建订单”事件就是一条链路起点它不触发自其他事件。在查询一条完整业务链时通常用递归CTE或临时表逐层向下展开在MySQL 8.0以下的老版本里做递归会比较难受建议要么升级数据库要么在应用层预先维护一张事件链路表。stock_flow表的quantity必须大于0这是一个很重要的约束。direction已经说明增减了所以数量没有负数的合法场景。如果出现负数意味着模型被人为破坏了要么是程序bug要么是数据清洗脚本写了反逻辑。amount字段我建议直接保存而不是依赖unit_price乘以quantity推导。因为现实中单价可能因为折扣、赠送等原因不匹配金额在事件发生时点保存业务结果可以避免后续规则变化导致历史数据计算错误。这也是事件溯源思想的体现——存事实不存推导逻辑。还有一种字段容易被忽略就是参与关系表里的role。role不是简单的中文备注它是业务规则的一部分。同一个Agent可能在同一事件里充当多个角色比如一个人既是审批人又是执行人如果业务上要求区分清楚就要在participation表插入两行。如果只存一个user_id字段这种多角色情况就直接丢失了。4. 实操过程中踩过的坑与排查经验4.1 把Event当成了Resource是最高频的错误我刚开始设计REA模型时第一个版本就把事件和资源混在一起了。当时想的是每一次出库影响库存那“出库记录”不也是一种资源吗结果把出库记录做成资源表再用另一张表记录“资源的资源”整个模型画出来像一团毛线。正确的思路是区分“实体”和“关系”。Event和Resource是实体存量-流量是关系。出库事件本身不是资源它影响的是“商品”这个资源。资源可以是被库存事件影响的对象但事件自身不能被当成资源再去关联。如果非要说事件也是一种数据资产那只是在数据仓库层面的讨论不是业务建模层面的建模对象。这个坑最典型的表现是建表时把order_event_cash、order_event_product拆成好几张表然后每张表都依赖一个订单ID。这实际上回到了传统订单表的设计REA的通用性就被破坏了。所以我的建议是画实体关系图时先问自己三个问题这个东西能被事件改变数量或所有权吗能就是Resource。这个东西是一个发生在某个时间点的动作吗能就是Event。这个东西能发起或负责一个动作吗能就是Agent。这样分完思路会立刻清晰。4.2 事件粒度太粗导致业务事实丢失另一个常见问题是事件粒度。比如把“支付成功”和“发货完成”合并成同一个事件“订单完成”节省了一张表却丢失了支付和发货之间的时间差、责任人差、异常分支。一旦支付成功但发货失败你这个合并事件就不知道该怎么记录状态了。我后来给自己定了一条原则事件粒度不能大于“一个业务原子动作”。所谓原子动作就是在这个动作完成之后业务状态发生了本质变化并且这个变化无法再被拆成多个独立发生的动作。支付成功和发货完成是两个不同的本质变化必须拆成两个事件。但是“支付成功”本身不需要再拆成“支付请求发起”“支付回调处理”“支付结果通知”因为业务上只有“成功”这一个事实对资源流动有影响。如果拿不准粒度可以问业务方一句话“这个动作如果单独失败后面需要重新触发另一个动作吗”需要单独失败处理就必须拆开。不需要就可以合并。这个判断方法在电商、供应链场景里都很好用。4.3 查询性能和可追溯性的权衡REA模型最大的缺点就是查询路径长。传统订单表一条记录就能查完订单所有信息REA模型需要从event表出发再join stock_flow和participation才能拼出完整视图。在我实际项目里订单详情页对性能要求很高如果每次请求都实时走REA查询数据库压力会非常大。我的应对方案分两层。第一层在业务写入端严格保持REA模型作为唯一事实来源所有事件流、资源流、参与人都以REA表为准。第二层在读取端建立面向查询的投影表比如订单视图表把事件链聚合后的结果冗余存储。这样既保持了REA模型的历史溯源能力又让日常查询体验和传统订单表一样快。投影表可以异步刷新即使投影偶尔落后也不影响业务正确性因为原始事实还在。很多人一听到“冗余投影”就觉得是坏味道但在这个场景里投影表不是主数据只是缓存。主数据永远是REA事件表投影表可以被随时重建。只要数据库能支持重算你就不需要担心冗余带来的数据不一致问题。4.4 与老系统的兼容问题如果项目是从老系统迁移过来老系统里的订单表、库存表已经积累了多年数据迁移到REA模型时不可能是空的。我们当时做了一个一次性回填脚本把老订单表里的每个状态变更变成一条Event记录再把商品库存变动记录解析成stock_flow。这个过程非常耗时而且老数据的质量参差不齐很多历史订单缺了责任人、缺了时间只能硬编码一个“系统迁移”参与者。回填过程中我最大的感受是模型再完美也补不上历史操作日志缺失的坑。所以如果是全新系统直接从REA模型开始比迁移容易得多。如果是老系统迁移建议先选一块核心业务做试点比如只迁移库存相关事件跑一段时间稳定后再迁资金事件。不要指望一次性把所有数据都搬进REA模型那样只会把迁移工作量放大到不可控。我还建议在做回填时给每一条迁移来的Event加一个数据来源标记比如migration_batch_id字段方便出问题时排查是哪一批脚本写坏了。我们当时就是靠这个字段定位到一个老订单状态转换时数值翻倍的问题排查效率提升了不止一倍。5. 从REA模型继续扩展以及我的实际体会5.1 支持多资源、多参与者、复杂事件链REA模型并不死板它对复杂场景的支撑能力其实很强。一个事件可以同时关联多个资源流动比如“以旧换新”业务里旧商品作为资源流入新商品和现金差额作为资源流出stock_flow表里可以插入三条记录方向分别为in、out、out。这样真实业务就不会被强迫拆成多个伪事件。多参与者也很容易支持还是在participation表里加行。比如“退货审核”事件里顾客是returner客服是reviewer仓库是inspector三条记录一插整个责任链就清楚了。查询某个员工参与了哪些事件只需要在participation表按agent_id过滤不需要去几十张业务表里搜索他的名字。事件链可以无限延伸。一个订单事件触发支付和发货支付事件触发账务事件账务事件触发对账事件对账事件又衍生出差异处理事件。这一长串用triggered_by_event串起来称为事件链。做全链路追踪时只要拿到入口事件ID就能一层层展开所有下游事件。这种能力在传统状态机设计里很难做到因为状态机只关心单个状态流转不关心跨业务的因果网络。5.2 我实际使用后的感受与建议这个REA项目从启动到上线用了大概两个月。前两周全在讨论概念和画模型真正写代码的时间并不多。最大的成本不是开发而是让团队成员从“状态思维”切换到“事件思维”。一旦切换成功后续加需求就变得很舒服了。比如后来接入优惠券不需要改订单表结构只要新增一个优惠券抵扣事件并关联到原订单和现金资源上优惠券系统就完整地融入了主链路。如果你也想尝试REA模型我的建议是从一个足够小的模块开始比如只做库存出入库。先画清楚资源、事件、参与者建好三张主表和两张关系表跑通一条最简单的业务流。等大家都接受这种方式后再逐步把订单、资金接进来。不要让REA模型一开始就承担所有业务那样讨论成本会很快淹没你的信心。最后分享一个小技巧我给每个事件都加了一个外部业务号比如订单号、付款流水号或者物流单号。这些外部号在老系统里是分散在各张表里的但放到事件表后整个链路统一了。以后客服查一个订单为什么没发货直接拿订单号搜事件表所有关联事件按时间排出来一眼就能看出卡在哪一步。简单但真的非常管用。我个人在这些年做业务系统时有一个体会绝大多数数据问题不是服务不够多而是建模时没有把“变更”当成一等公民。状态是临时的事件才是永恒的。REA模型恰好逼着你把这件事做对所以我后来每接到一个业务系统需求第一版ER图都会先想一想这套表如果三年后被要求追溯每一笔历史能不能撑住。能撑住才敢放开业务往前跑。