
想聊一个特别容易被忽略的老概念。前几年做中后台系统每次业务方提需求我的第一反应都是去改订单表、加状态字段。改到后来一张订单表里十几个状态谁都不敢动。后来接触到一个叫REA的建模思路才开始理解为什么系统会越改越乱也才知道原来有一种方式可以在一开始就把业务语义理干净。REA这个缩写在不同语境下有不少含义。如果在技术资料里往上追溯你会发现它更多指向 Resource-Event-Agent一个从会计信息系统领域走出来、深刻影响了许多企业建模方法的本体框架。简单说它把企业里所有业务都抽象成三件事什么东西在流动Resource发生了什么流动Event谁参与了这次流动Agent。听起来简单但用起来会发现它对业务模型的穿透力比传统ER建模强得多。真正适合REA的是那些每天和订单、库存、资金、对账打交道的后台系统。如果你正在为业务模型设计发愁或者正在做交易、财务、供应链相关的数据建模这篇内容值得看完。我会从原理讲到落地再用一个完整场景带你走一遍建模全过程最后聊聊我踩过的那些坑。1. 为什么业务模型会“越改越乱”先看传统建模的痛点1.1 状态字段堆出来的“定时炸弹”以我做过的一个电商后台为例最初建模是大家最熟悉的方式订单表、用户表、商品表、支付表、物流表外加一堆状态字段。订单状态、支付状态、发货状态、退款状态……每个状态从一开始的两三个随着业务发展膨胀成十几个。表面上结构清晰实际上问题一箩筐。最头疼的是状态字段之间的联动。一笔订单从待支付到已支付到已发货每次状态变化都要在业务代码里写一段if-else判断。业务规则一多状态机就变成一团乱麻。线上出问题的时候排障只能靠翻日志还原现场效率极低。更麻烦的是订单、库存、财务三个系统各算各的账。订单系统说卖出去了库存系统说没扣掉财务系统说没收到钱。三个系统坐下来对台账谁也说不清问题出在哪个环节。这种场景在大大小小的公司里几乎每天都在发生。1.2 REA 当初解决的是什么问题REA 最早是会计领域的研究者提出来的。传统会计关注借贷平衡记录的是账户的增减这种模型用来出外部报表很方便但很难回答“库存为什么变了”“这笔钱是谁收的、为什么收”这类业务问题。REA 的提出者想换一个视角把会计要记录的每一笔经济事项拆成“资源发生了什么变化”“是谁发起的”“是什么动作导致的”用事件本身作为记账的原子单位。后来这个思路被扩展到企业建模领域成了描述整个企业业务逻辑的语义框架。我后来理解 REA 时最有共鸣的一点就是传统 ER 建模是结果导向的它把系统需要展示的当前状态当成实体来建模。可现实业务是过程导向的真正有价值的信息藏在每一次业务动作里。REA 不问你“现在库存是多少”它问你“发生了什么事件让库存变成了现在这样”。这个转变直接决定了后续所有表结构和代码逻辑的走向。2. REA 三要素拆解Resource、Event、Agent 到底怎么用2.1 资源不只是钱和货还包括服务和额度很多初学者一听“资源”就先想到钱、货其实远不止这些。REA 里的资源包括任何企业拥有或控制、可以进行经济交换的对象。实物商品、货币资金、应收账款、版权专利、服务工时甚至设备的使用权都算资源。定义资源的判断标准就一句话它是否在业务事件中被交换、被转化、被使用如果答案是肯定的它就是资源。这里有个很容易犯的错就是把“订单”这种单据也当成资源。订单本身不是资源订单承载的商品、金额才是资源。单据是信息载体资源和事件的关联才构成业务真相。资源的粒度也要认真设计。同样一个商品卖给不同渠道客户“商品”和“渠道可售商品”可能是两种资源因为它们的库存池不同、定价逻辑不同、被使用的场景不同。建模时别急着建资源表先罗列出业务里到底有哪些“可被事件操作的对象”再决定粒度。2.2 事件只有改变资源状态的行为才配叫事件事件是 REA 的核心。判断一个业务行为是不是事件标准非常清晰它是否改变了至少一个资源的状态。比如“下单”本身如果只是记录了一个购买意图没有改变任何资源状态那它就不是事件。但“扣减库存”“产生应收账款”“收到货款”“发出货物”这些行为都改变了资源状态都是货真价实的事件。事件大体分三类。第一是交换事件一个资源的流出伴随另一个资源的流入比如销售付出商品、获得收款权利采购收到货物、承担付款义务。第二是转化事件资源被消耗或投入产出另一种资源比如生产领料、加工、报废。第三是使用事件资源的用途被临时占用比如借出、租用、分配人员工时。区分事件类型的作用在于建模关联时需要在事件之间画出不同的关系。交换事件之间有配对关系转化事件之间有消耗产出关系使用事件往往还需要关联一个释放动作。分类分清楚了后面画模型才不会糊。2.3 代理每个事件都要追得到“谁干的、谁授权的”代理就是人或者组织。REA 把代理分成内部代理和外部代理。内部代理是组织里参与业务的人或部门比如销售员、仓管员、财务。外部代理是客户、供应商、银行等。这里有个比较苛刻的要求每个事件都必须至少关联两个代理一个负责执行一个负责授权或承担对向责任。销售出库事件内部代理是仓管员外部代理是客户采购入库事件内部代理是仓管员外部代理是供应商。我当时觉得这个约束很麻烦但后来才明白它恰恰是 REA 能成为“审计友好模型”的原因。每个数据变更都能回答“谁干的、谁授权的”出了问题才追得到源头。很多系统出事之后查不到责任人就是因为当初建模时根本没想这件事。3. 完整建模实操一个销售与采购场景的全过程3.1 场景定义先把业务边界圈清楚用一个足够典型的场景走一遍完整建模过程。某公司主营业务是标准件销售从供应商采购在自己的仓库里存放然后卖给分销商。业务流程是这样的销售人员接到分销商订单后确认可售库存生成销售单。仓库按销售单出库并发货。财务根据销售单开具发票分销商在账期到期后支付货款。与此同时采购人员根据库存水位向供应商下采购单供应商交货入库财务按采购单付款。这个场景能覆盖采购、销售、库存、资金四条主链路足够拿来练手。3.2 圈出事件、资源、代理建模的第一遍草稿先圈事件。业务描述里哪些行为改变了资源状态确认订单并锁定库存销售出库开具发票收到货款下采购单并锁定采购预算供应商交货入库支付供应商货款。这就是核心事件列表。再圈资源。被交换、转化、使用的对象有哪些商品库存、应收账款、现金、应付账款、采购预算额度、库存可售额度。这里要特别注意两个容易被忽略的资源销售单确认时锁定的库存额度采购单确认时锁定的预算额度。它们属于“配额”性质的资源在锁定事件中被使用在出库、入库或取消事件中被释放。不把它们建模成资源锁定库存、锁定预算的动作就没法用事件来表达。最后圈代理。内部代理有销售员、仓管员、财务、采购员。外部代理有分销商、供应商、银行。每个事件至少关联两方比如销售出库事件内部代理是仓管员外部代理是分销商采购入库事件内部代理是仓管员外部代理是供应商。3.3 画出关联关系双效配对与存量增减事件和资源的关系本质是数量关系。出库减少库存入库增加库存收款增加现金付款减少现金。事件和代理的关系是控制关系谁执行、谁授权、谁承担义务。事件之间还有两类重要的关系。一类是交换事件的双效关系比如销售出库和确认收款一增一减成对出现。另一类是转化关系的配对关系比如采购入库对应采购单生产领料对应生产指令。这里特别想谈一谈双效关系。传统会计里每一笔分录都有借和贷REA 的双效关系就是为这事准备的。但 REA 不要求把借贷放在同一个表里而是用两个事件之间的语义关联来表达。这对系统设计的影响很大你不再需要“会计凭证”这样一个缝合概念业务事件本身就是账本。下面用一个简表总结这个场景的核心事件与资源关系方便对照事件名称涉及的资源数量方向内部代理外部代理确认订单并锁定库存库存可售额度减少销售员分销商销售出库商品库存减少仓管员分销商开具发票应收账款增加财务分销商收到货款现金增加财务银行下采购单并锁定预算采购预算额度减少采购员供应商供应商交货入库商品库存增加仓管员供应商支付供应商货款现金减少财务银行3.4 转成表结构从概念模型到关系模型的直接映射概念模型理清之后落到关系型数据库里可以这样设计。资源表记录资源定义和当前可用余额事件表记录每一次业务动作代理表记录参与方事件代理关联表记录事件和代理的多对多关系。给出一个可直接参考的建表 SQL-- 资源表 CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL UNIQUE, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, total_quantity DECIMAL(20,4) NOT NULL DEFAULT 0, available_quantity DECIMAL(20,4) NOT NULL DEFAULT 0, unit VARCHAR(16) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 代理表 CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_code VARCHAR(64) NOT NULL UNIQUE, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL, contact_info VARCHAR(255) ); -- 事件表 CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_time TIMESTAMP NOT NULL, biz_date DATE NOT NULL, event_type VARCHAR(64) NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(20,4) NOT NULL, unit_price DECIMAL(20,4), amount DECIMAL(20,4), source_bill_no VARCHAR(64), created_by VARCHAR(64), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 事件代理关联表 CREATE TABLE event_agent ( event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, agent_role VARCHAR(32) NOT NULL, PRIMARY KEY (event_id, agent_id, agent_role) );注意一个细节我给事件表加了 biz_date 字段按业务日期归档数据。对账时按业务日期分组比按 created_at 分组可靠得多因为系统补录、回填数据时created_at 会晚于真实业务发生时间。4. REA 模型落地从概念到系统的映射与工程细节4.1 事件表就是流水表别把状态和流水混在一起概念模型到系统落地最实用的一种方式就是照着上面的表结构直接建表。事件表是核心流水表资源表是主数据加余额。要查“现在还有多少库存”直接查资源表的 available_quantity要分析“库存为什么变成这样”就查这个资源相关的所有事件。这种设计把高频查询看余额和低频分析查流水分开兼顾了性能和可追溯性。线上运行一段时间后你会发现所有业务问题几乎都能通过“查这个资源的事件流”来回答。这比传统模型里 join 七八张表、靠状态字段猜来猜去要可靠得多。另外一个常见做法是把事件表设计成不可变的事件流。所有事件只能插入不能修改、不能删除。业务要修正就插入一个与原事件关联的冲正事件。冲正事件的类型可以叫“库存调整-冲正”或者“收款红冲”。这样账可追溯坏处是查询时得多做一层“过滤已冲正”的逻辑。我建议关键资源的事件一定要走不可变设计非关键资源可以宽松一些。4.2 事件溯源架构下的 REA领域事件再也不靠拍脑袋如果你在做事件溯源架构REA 几乎是现成的需求分析工具。事件溯源里最难的问题就是领域事件应该怎么定义。我见过不少团队拍脑袋定事件最后事件表里既有“库存扣减”又有“订单创建”把命令和事件混在一起。用 REA 的判别标准梳理一遍问题清楚多了只有改变资源状态的行为才是事件命令只是发起事件的意图本身不落库。这个标准能让团队在事件定义上少很多内耗。比如创建订单这个动作你说它是事件吗如果创建订单只是锁定了库存额度那锁定额度这个资源变化才是事件订单本身是业务单据。再比如用户点击了“提交退款申请”这只是命令产生“退款受理并冻结应付”的资源变化才是事件。把命令和事件分开之后领域模型和事件存储都会干净很多。4.3 工程实现里的几个硬指标金额、事务、并发、幂等几个工程细节值得单独拿出来说。金额字段一律用“分”存储用整数类型避免浮点误差。数量字段用 decimal 类型精确到小数点后四位避免单价乘数量出现尾差。所有资源变更必须和事件在同一个数据库事务里写。先插入事件再更新资源余额这个顺序不能反。为什么先更新余额再插入事件如果事务中途失败回滚你会得到一个余额没变但事件已经插入的脏状态先插入事件再更新余额失败回滚后两个操作都不生效状态一致。并发控制上更新余额时用乐观锁条件UPDATE resource SET available_quantity available_quantity - 10 WHERE resource_id 1 AND available_quantity 10。影响行数为 0 就抛异常重试或者直接提示用户库存不足。幂等性上给每个事件一个全局唯一业务编号比如“订单号 事件序列号”。插入时做唯一约束重复插入直接失败消息队列重试就不会产生重复库存变更。5. 常见问题与排查技巧实录5.1 事件和状态混为一谈是最常见的建模错误新手最容易犯的错就是把状态当成事件落库。“订单已发货”不是一个事件它是“销售出库”这个事件推导出来的结果。如果你把状态也当成事件写进事件表后面排查问题会非常痛苦状态改了两次的、状态回滚的、状态穿越的各种脏数据很难收拾干净。我的建议是永远不要在事件表里存状态字段。状态要么通过实时计算事件流得出要么在资源表里维护一个冗余的“当前状态”字段。后者查询效率更高但更新时必须在同一个事务里和事件一起写。5.2 一个事件涉及多个资源增减时怎么记录最好一个事件同时涉及多个资源增减比如销售出库既减少商品库存又增加应收账款再减少库存可售额度这时我推荐“主事件 事件明细”的存储结构。主事件记录时间、类型、代理、关联单据号。事件明细记录每个资源增减的数量和金额。这样既保留了事件的整体语义又方便按资源维度做汇总查询。查询“某商品在 3 月一共出库多少”直接按 resource_id 汇总时间范围的事件明细就行。-- 主事件表 CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_time TIMESTAMP NOT NULL, biz_date DATE NOT NULL, event_type VARCHAR(64) NOT NULL, source_bill_no VARCHAR(64), created_by VARCHAR(64) ); -- 事件明细表 CREATE TABLE event_line ( line_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(20,4) NOT NULL, unit_price DECIMAL(20,4), amount DECIMAL(20,4), direction VARCHAR(16) NOT NULL );主事件表不再冗余资源信息所有资源变更都在明细里。这个结构的扩展性非常好以后新增资源类型、新增事件类型都只需要加数据不需要改表结构。5.3 跨系统对账时REA 系模型的优势会彻底体现做过对账的人都知道最痛苦的不是数据对不上而是连对账的锚点都找不到。两个系统导出来的表结构不一样字段含义不一样口径也不一样对账基本靠人工逐条核对。用 REA 系模型之后两边系统天然都有一张事件表。事件表记录了什么时间、谁、操作了什么资源、数量金额多少双方系统只要按业务单据号交换事件明细拉出来逐条核对就行。曾经有个项目两边系统对账本来预计要花一天因为双方都有完整的事件流水半小时就定位到了异常单据。有个小技巧分享对账之前先对事件类型字典。两边对同一件事的命名可能不一样比如这边叫“销售出库”那边叫“订单发货”先做一次事件类型映射再拉流水比对能少很多沟通成本。5.4 什么时候不该用 REA别拿牛刀杀鸡REA 不是银弹。如果业务非常简单一张表就能搞定硬上 REA 等于给自己找麻烦。事件表、资源表、代理表三件套会给简单系统增加不必要的复杂度和查询成本。判断标准我一直用一句话你的系统是不是需要回答“钱货从哪里来、到哪里去、谁经手的”这类问题。需要REA 有价值不需要传统 CRUD 更直接。比如一个内容发布后台文章、标签、分类的管理完全不需要事件模型。再比如一个纯展示型官网数据和业务动作都极其有限REA 只会让简单的事情变复杂。我见过一个小团队把企业内部报销流程也用 REA 重写结果就是过度设计维护成本远大于收益。规模小、流程短、无对账需求老老实实用状态机就够了。6. 最后再分享一点实际的心里话我用了很多年“状态驱动”的建模方式转成 REA 看问题之后最大的变化不是表怎么建而是面对需求时的第一反应。以前业务方说“加个状态”我马上想加字段。现在我会先问一句“这个状态是哪个事件的结果”带着这个问题去设计系统往往会简单很多。有一回业务方提了一个需求要把“已锁定库存”和“已出库库存”分开统计。放在以前我可能直接加两个状态字段再写一堆定时任务去同步。用 REA 的思路想了一下发现这本来就是两个不同事件的结果一个“锁定库存”事件一个“销售出库”事件。查询时按事件类型分组汇总根本不需要额外加字段。那一次经历让我彻底认同了 REA 的建模哲学。如果你正在做交易、库存、财务类的系统建议找一个最小的模块试着用 REA 完整建模一次。不需要一次性推翻现有系统拿一个新的小功能练手即可。实践过的人很难再退回原来的建模方式。毕竟真正的好模型不是让你多写代码而是让你少写那些为了维护状态一致性而生的补丁代码。