先说清楚——标题里的“rea”不是随手打的乱码它是我最近在给某订货系统的数据库做重构时反复琢磨的一个缩写REA也就是 Resources-Events-Agents资源-事件-代理人语义建模模型。如果你和我一样在业务系统里被“对不上账”“查不清历史”“状态字段满天飞”折磨过这篇博文应该能帮上忙。我会用一个真实改造过的业务场景把这套模型怎么思考、怎么建模、怎么落成表结构甚至连踩过的坑和折衷方案一起聊透。适合正在设计订单、库存、财务相关数据模型的后端开发也适合想搞懂业务建模的转行新人——不需要你提前懂任何会计概念我会一步步拆开讲。1. 为什么一个缩写值得我重写整层数据结构1.1 传统建表方式的通病先回放一下很多团队都走过的老路接到订货系统需求后大家第一反应是抽“实体”——商品表、客户表、订单表、库存表、收款表。订单表里放订单状态待支付、已支付、已发货、已签收。库存表里放一个 current_stock 字段每次销售、入库、退货、盘点都在这个数字上改来改去。看起来挺合理对不对我这次接手的某订货平台就是这个画风。刚开始功能跑得飞快但只要业务量一上来问题就全暴露了。第一个星期财务就发现应收账款对不上订单表里的订单金额和一个老旧的收款记录表怎么都核不平。第二个星期仓库发现库存变成负数——明明系统里显示还有3件货但盘点时货架已经空了。业务方开始互相扯皮销售说仓库多扣了货仓库说系统自动减的仓库管理员说我只负责扫码。追根溯源问题出在一个地方我们一直在给“状态”建模而不是给“事实”建模。订单表里的“已发货”只是某个时刻的状态快照库存表里的 current_stock 只是持续叠加的结果数字。一旦上游有人漏记、误记、重复记所有下游状态瞬间全错而且你根本找不到是哪一环错的。1.2 REA 模型的一句话直觉REA 模型最初是会计信息系统领域提出的企业建模方法核心思想是不要急着记“现在值多少”先把“发生了什么”记录下来。资源Resource是业务中有价值的事物比如货物、现金事件Event是让资源发生变化的动作比如卖掉一件货、收到一笔钱代理人Agent是参与这些动作的人或部门比如客户、仓库、财务。把这套框架套到数据模型上业务就变成一条清晰的故事线代理人参与事件事件让资源流动。库存不对了去查事件流是哪一次发货、哪一次入库、哪一次退货导致的。应收对不上去查销售发货事件和收款事件是否成对关联。你不再是追着状态跑而是顺着事件链条一查到底。这套思想和现在后端圈里常聊的“事件溯源”本质上是一家人但它比事件溯源出现早得多而且天然适合业务系统的数据建模。这个思考角度在当时立刻打动了我。第二周我就拿一个最头疼的应收模块做试点重写跑通之后才推动了整个系统的逐步改造。下面我把整个建模过程按实操顺序展开尽量把当初我怎么想、怎么选、怎么落地的路径还原出来。2. 把业务拆成资源、事件、代理人2.1 资源的识别比想象中难很多人觉得资源就是“有形的货物”其实资源的核心特征是“有价值且能参与业务流转”。在我改造的订货系统里一开始只识别出了商品货物和现金但真正动手后发现遗漏严重客户额度算不算资源积分算不算退货后产生的“待退货库存”算不算如果把这些问题一股脑抛给业务方他们只会回你一句“你先告诉我你要干嘛”。我得到的经验是先识别那些“会被事件增加或减少”的东西。比如库存会因入库增加、因发货减少应收款会因发货增加、因收款减少。积分会因消费增加、因兑换减少所以它也是资源。而商品名称、商品类目这些只描述属性、不参与增减的可以单独放“主数据”表不放到 REA 的资源池里避免模型过度膨胀。当时我最终收敛出的资源池很简单只有三类资源类型例子参与什么事件货物类资源各 SKU 库存采购入库、销售发货、退货、盘盈盘亏货币类资源客户应收款、供应商应付款、现金账户销售发货、收款、采购入库、付款权益类资源客户额度、积分类余额额度授予、额度消费、积分累计、积分兑换注意一个细节资源表里的字段要极简通常只需要 id、编码、名称、单位、资源类型。不要往这张表里塞颜色、尺寸、规格等业务属性那会让“资源”退化成“商品主数据”REA 的灵活性就打折扣了。商品属性可以放到独立的扩展属性表里按 resource_id 关联。2.2 事件是模型里最值得花时间的地方事件的定义很简单业务实际发生的、改变资源状态的一次动作。但划分粒度却是个拿不准的活。拿“销售”来说你既可以建“销售事件”一个大类也可以细分成“下单”“发货”“收款”三个事件。我发现判断粒度有个简单办法看它是否独立改变某种资源。下单本身不改变货物库存也不改变现金只是承诺未来会发生一笔交易所以它应该单独作为一种“承诺类事件”存在用来追踪订单履约进度。发货才真正让货物减少、应收款增加这是“执行类事件”。收款让应收款减少、现金增加这又是另一个执行类事件。三条事件各自独立、时间不同、操作人不同强行合在一张表里只会让状态字段无限膨胀。最终梳理后的事件类型控制在 12 个以内承诺类客户下单、采购申请执行类销售发货、采购入库、收款、付款调整类退货、换货、库存盘点、额度调整、积分结转作废类订单取消、发货退回、作废更正这个清单看起来朴素但每个事件类型背后都对应了一张“事件流”中的事实记录——时间、说明、操作人、涉及资源和数量。后来排查历史数据问题时这些事件记录起了极大的作用很多历史问题只需要顺着时间和事件类型过滤就能定位。2.3 代理人别只想到人在业务系统里代理人既可以是自然人也可以是组织甚至可以是系统本身。客户、供应商、销售员、仓库管理员、财务人员都很好理解但容易漏掉的是“操作终端”“对接系统”这类参与者。某台机器自动触发的盘点某个上游 ERP 导入的采购单你得知道是谁干了这件事部分机器触发可以直接给个“系统”代理。落地时我设计了独立的 agent 表并通过一张关联表和事件多对多绑定绑定关系上还要带一个“角色”字段。比如销售发货事件里同一个发货动作同时牵扯到客户收货方、销售员经办方、仓库管理员执行方三者在事件里的角色不同查询口径也不同。后续做“某销售员的业绩统计”“某仓库的发货量排名”只靠一张操作人字段根本不够用必须靠角色化关联才能拉出干净的数据。2.4 给三要素之间划线资源、事件、代理人画完之后真正出设计价值的是“关系”。REA 模型里最核心的关系有两种第一种叫“交换”关系也叫经济二元性。业务里最常见的模式是“一手交钱、一手交货”销售发货事件对应着未来的收款事件采购入库事件对应着未来的付款事件。把这两个事件人工关联起来就能回答“这笔货款到底收回来没有”。当时我在事件之间建了一张关联表保存“发货事件ID”和“收款事件ID”的对应关系财务后来最喜欢查的就是这张关系表。第二种叫“参与”关系也就是事件和代理人之间的归属关系。除了记住“谁干的”还要记住“为了谁、由谁审批、关联了哪个客户”。这种多角色参与在传统表里很容易被简化成“操作人”一个字段但所有业务报表只要稍作变体就会出现“查不到”“重复统计”的问题。我建议从一开始就在事件代理人关联表上做文章细分角色字段否则后面改模型非常痛苦。3. 从概念模型到建表语句的完整落地过程3.1 先说清楚表的分工在把模型翻译成物理表时我遵守了一个原则主数据表和事件表彻底分离。资源主数据、代理人主数据是相对静态的而事件流水是持续增长的。两者混在一个 schema 里不是不行但会严重影响查询性能和数据归档。我当时的核心表设计如下每一张都有明确的职责边界表名职责核心字段resource资源主数据id, code, name, unit, resource_typeagent代理人主数据id, name, agent_type, contact_infoevt_header事件头表evt_id, evt_no, evt_type, occurred_at, descriptionevt_line事件明细行evt_id, line_no, resource_id, qty, direction, unit_priceevt_agent事件-代理关联evt_id, agent_id, role_codeevt_rel事件间关联left_evt_id, right_evt_id, relation_type这里有一个值得展开的选型细节为什么要把事件拆成“头表 明细行”两张表因为一个事件可能同时涉及多种资源或多次明细。比如一批退货客户退了三件货品每件数量和价格都不一样事件头表保存“什么时候退、为什么退”这类公共信息事件明细行保存“退了哪个 SKU、退了多少、按什么价格退”的逐行数据。这个设计从第 1 天就避免了到处塞“退货原因”“备注”这类重复字段的坏味道。3.2 最关键的字段设计方向字段direction是我强烈建议所有团队都加的。它用1表示资源流入入库、收款、-1表示资源流出发货、付款后续计算库存、余额只需要SUM(qty * direction)一条 SQL 就能搞定。很多人会把收货和发货各建两套表结构其实没必要同一张明细表配合方向字段就能表达双向流动。事件唯一编号evt_no也需要单独说。数据库自增主键适合内部关联但对业务人员来说他们需要的是“一笔一眼能看懂的单号”。我采用“事件类型缩写 年月日 当天流水号”的格式比如SO-20250108-0032。这样做的好处是业务方在错误排查时可以直接报单号沟通而不是报一串无规律的数据库数字。时间字段我统一用occurred_at代表业务实际发生时间而不是数据落库时间。传统表里经常只存created_at导致所有统计都以录入时间为准月初月底对账时经常乱成一锅粥。实际业务发生后延迟录入、补偿录入、补单情况太常见时间口径不统一报表永远算不准。CREATE TABLE evt_header ( evt_id BIGINT PRIMARY KEY, evt_no VARCHAR(32) NOT NULL UNIQUE, evt_type VARCHAR(32) NOT NULL, occurred_at TIMESTAMP NOT NULL, description TEXT, created_by BIGINT NOT NULL ); CREATE INDEX idx_evt_header_time ON evt_header (occurred_at);3.3 一次标准的销售发货事件怎么落库拿一条销售发货来说完整落库过程需要操作这五张表。我会把这个过程拆成每一步的具体 SQL方便你照着调整。第 1 步插入事件头表记录这是一笔“销售发货”INSERT INTO evt_header (evt_id, evt_no, evt_type, occurred_at, description, created_by) VALUES (1001, SO-20250108-0032, SALE_ISSUE, 2025-01-08 10:30:00, 客户A 批量采购订单发货, 2001);第 2 步插入事件明细。这个例子里有三个 SKU 的货物出库方向全部是-1同时生成一条应收账款资源方向是1INSERT INTO evt_line (evt_id, line_no, resource_id, qty, direction, unit_price) VALUES (1001, 1, 50101, 50, -1, 19.90), (1001, 2, 50102, 20, -1, 39.00), (1001, 3, 50103, 10, -1, 99.00), (1001, 4, 60101, 3244.00, 1, 1.00);注意第 4 行里的资源是“客户应收款余额”方向为流入意思是这笔销售动作业务上增加了对客户 A 的应收账款。很多系统到现在还要靠订单表和收款表勾稽核算而 REA 模型直接通过这种“资源流动”方式把应收款的增减建模进事件流。每条明细都带unit_price是必须的因为同一 SKU 可能在不同订单里价格不同价格要跟着事件走而不是去查商品主数据里的当前价。第 3 步关联代理人。客户、销售员、仓库管理员分别记录角色INSERT INTO evt_agent (evt_id, agent_id, role_code) VALUES (1001, 30101, CUSTOMER), (1001, 2001, SALESPERSON), (1001, 2007, WAREHOUSE_KEEPER);第 4 步如果是发货类事件还要在事件关联表里记录它对应的承诺事件订单。这一步是之后做“订单履约率”“发货及时率”的基础INSERT INTO evt_rel (left_evt_id, right_evt_id, relation_type) VALUES (1001, 9005, FULFILLS_ORDER);这样一套组合动作下来数据的意义就从“记录一条销售单”变成了“完整记录一次业务故事”。后来我在复盘这套数据模型时能通过事件流还原任意一笔业务的完整生命周期而传统状态表只能看到断断续续的碎片。3.4 实时库存为什么能直接算出来改造前的库存表是一个数字字段每次出库入库都去 UPDATE。改造后库存完全可以通过事件明细聚合得到SELECT resource_id, SUM(qty * direction) AS current_stock FROM evt_line WHERE resource_id 50101 GROUP BY resource_id;这条 SQL 跑出来的结果一定是对的因为每一行流入流出都是一条不可否认的事件事实。但纯实时聚合有一个绕不开的问题性能。我在改造到 3 个月后单表事件行到了 500 万行库存查询变得缓慢。解决办法不是改回状态表而是引入“库存快照”作为性能辅助下面第 5 部分会专门讲。4. 实操心得订单状态和金额到底怎么建模4.1 不要把状态字段当成事件流的替代品订单表是否还需要“订单状态”字段我的答案是需要但它的角色变了。状态字段从“事实记录者”降级成了“查询优化字段”它只是承诺事件和关联执行事件之间进度的一个缓存。真正的状态判定应该由事件组合计算出来而不是手工维护。从计算角度看一个订单的完整履约为订单刚创建没有任何关联发货事件状态为“待发货”关联了部分发货事件状态为“部分发货”发货数量合计等于订单数量状态为“已发货”又发生了收款事件且金额已覆盖应收状态为“已完成”当时我在重建过程中实现了几个纯 SQL 的视图来读取这些状态。一个订单的剩余未发数量用下面这种思路算先查订单商品明细总量再减去所有关联发货事件中对同一资源的发货量剩下的就是未发数量。这个逻辑天然支持“拆单”——一次订单可以分三次发货每次发货后剩余数量自动变化。传统状态字段根本做不到这么细的进度拆解因为它的取值范围是预先枚举死的没考虑拆单场景。4.2 金额核算应收、实收、未收对不上账的死结旧系统里应收账款怎么算传统做法是sum(订单金额) - sum(收款金额)。听起来没毛病但一旦订单发生折扣、手续费、按比例收款、部分退款这个公式就乱了。REA 的算法是某客户应收余额 SUM(销售发货事件对应应收资源流入) - SUM(收款事件对应应收资源流出)关键是每一笔应收流入都有对应事件每一笔收款流出也都有对应事件两边都是精确的账务流水。想查“这个客户从 2024 年至今累计采购额”只需要对销售发货事件聚合想查“这个客户还欠多少”用上面的差额公式。每个数字都能通过事件日志自证来源。我当时还建了一张“账龄分析”视图按应收流入事件的日期分组统计每个时间段的未收金额。因为每笔应收都有时间戳账龄分析完全不需要依赖“订单创建时间”这类间接数据报表准确度和生成速度都上了一个台阶。4.3 承诺事件和执行事件脱钩的代价这里说一个实操中的真实教训。最初我把“订单”和“发货”做了强关联假设一个订单只发一次货。但真实业务很快就用行动告诉我完全不是这样。同一订单分批发货、部分退货、换货补发都是再正常不过的操作。如果模型一开始不把承诺事件和执行事件分开后续就得不断在订单表上堆各种“已发货数量”“部分发货标识”“是否拆单”字段查询逻辑越来越绕。而采用事件关联表后一个承诺事件可以关联 N 个执行事件一个执行事件也可以关联多个承诺事件比如客户把多笔小额订单合并成一单发货模型依然干净。这也是 REA 模型最有气质的地方逻辑复杂性和表结构清晰度是分离的。5. 避坑实录改造成 REA 之后我栽过的几个跟头5.1 过度抽象事件类型膨胀到失控第一个坑发生在重构后的第二个月。某个开发组为了保持“模型纯净”把不同场景的事件都拆成独立类型普通销售、赠品发放、样品出库、内购处理、废品销毁……事件类型表从最初 12 个慢慢膨胀到 60 多个。查询接口的入参写了一大堆枚举联调成本飙升。我的经验是事件类型先收敛差异放到描述字段或扩展属性里。事件类型越少统计函数越稳定。如果一个事件类型只有 1% 的数据量且业务操作过程本质相同就不要单开类型。等到业务规则真的出现本质区别再拆分类也不迟。5.2 事件不能物理删除那录错了怎么办事件流的铁律是不可变、不删除。但实际业务里错误一定会发生仓库录错了数量财务登记了错误的收款金额。如果你把事件 DELETE 掉后续所有基于事件流的计算都会留下历史断裂审计时根本没法交代。正确做法是把“作废”也建模成事件。假设一条错误的发货事件编号为 1600你新建一条类型为“作废更正”的事件 1601明细是原发货事件的完全反向并在描述里写明“作废 evt_noSO-20250115-0087”。两笔合并后的净效果等于错误未发生但每一笔操作痕迹都在。一开始会觉得这太繁琐但之后每次做审计对账都省心的多。5.3 纯事件流查询慢库存快照和物化视图大约在改造 3 个月后事件表行数到 500 万量级实时库存查询从几毫秒飙升到几百毫秒有些聚合统计甚至需要数秒。业务方反馈“明明就是查个库存量怎么这么慢”。这确实是纯事件溯源的通病。我采用的方案是在主事件流之外建了一条“定期快照链路”每天业务结束后建一张库存快照表记录每个资源当日的库存总量。查询库存时优先读快照。如果需要精确结果或核对再走完整事件聚合。平时的页面展示用快照快照背后的数据由定时任务从事件流计算得出完全可信。CREATE TABLE stock_snapshot ( resource_id BIGINT NOT NULL, snapshot_date DATE NOT NULL, qty DECIMAL(18,4) NOT NULL, PRIMARY KEY (resource_id, snapshot_date) );这里想补一句架构上不要有“一口吃成胖子”的洁癖。纯 REA 事件流虽然理论最干净但工程上需要读模型、快照、临时表等折衷。真正的生产系统是“事件流负责事实快照负责性能”两者配合而不是二选一。5.4 历史数据迁移做“期初结转事件”而不是硬拆旧系统运行了三年历史数据不可能全部重写成事件流。一开始我也想从旧订单、旧库存表里把每一笔业务倒推成事件但很快发现旧系统的日志既不完整字段含义也模糊硬拆只会引入更多脏数据。我的折衷方案是“期初结转”为每个有余额的资源建一条特殊事件比如“库存期初”“应收期初”将旧系统截止改造日前一天的存量作为事件的初始数据。事件明细里写清“期初结转自旧系统”描述字段记录参考依据。这样新系统从改造日当天开始运行新事件流历史余额都挂在期初事件上财务和业务都能看懂统计也不受干扰。此后每个月对账盯一次期初值确认没有偏差即可。5.5 业务方看不懂“事件流”怎么办最后这个坑是团队协作层面的。你试图和业务方解释“我们以后不直接改库存了通过事件明细来算”他们会礼貌点头第二天依旧找你要“库存的最终数字是多少”。业务人员的心智模型停留在“状态必须是个能一眼看到的数”事件流抽象思维需要时间适应。我的处理办法是不强迫业务方改变语言把复杂的事件计算封装成固定视图并直接在报表页面展示“库存余额”“应收余额”“订单状态”这些他们熟悉的字段。视图下层是事件流上层是业务口径。只要最终结果准确业务方不会关心你是用状态表还是事件表算出来的。交付界面上依然保持低调内部架构可以自由发挥。6. REA 模型适用的边界和我的选型建议6.1 什么业务适合 REA我建议优先考虑 REA 的业务域库存和供应链、订单与履约、财务与应收应付、资产管理和审计追溯。这些业务共同的特点是资源流转明确、事件频发、需要跨多个角色多阶段推进、查错和审计需求高。事件越丰富REA 的回报越大。反过来说如果业务只是简单的内容展示、用户信息管理、文章收藏资源抽象很弱事件类型也少硬套 REA 只会增加开发和维护成本完全没有必要。判断标准很简单你系统里有没有“增减余额”类的资源有没有跨越多个环节的复杂流程有没有“出了错却查不到是谁干的”的痛感三个都满足REA 值得用一个都不满足别给自己找麻烦。6.2 跟事件溯源Event Sourcing的关系REA 不是事件溯源它是业务建模方法论事件溯源是一种技术实现方式两者可以完美配合。我当时落地的是“REA 思想指导模型设计 关系型数据库存储事件流”没有完整引入事件溯源框架。如果你的团队已经上了事件溯源框架REA 仍然可以作为事件类型设计的上层业务指南。很多团队在做事件溯源时会卡在“事件类型怎么定义”直接拍脑袋想。这时候把 REA 三要素拿过来过一遍事件类型的边界一下子就清晰了凡是改变资源状态的都是事件剩下的都是辅助信息。我在重构和团队技术评审时就是拿这套方法论帮大家统一了事件粒度的语言。6.3 从哪个模块开始试点想做改造的人先别急着把整个系统都掀翻。我最推荐的方式是选一个“金额对账最痛苦”的模块先试点。一方面这类模块业务复杂度够能充分验证 REA 模型是否真的适合你另一方面对账痛点的改善效果特别显著团队内部容易形成正反馈推进后续改造时阻力会小很多。我当时选的第一个试点就是“应收账款 订单发货”这个小闭环。两周时间完成建模和重构财务月底第一次轻松完成了对账团队对这块的信心一下就上来了。之后从销售域逐步扩展到采购、库存、退货域每次扩展都沿用同一套建模语言协作效率反而比旧系统时期更高。7. 写在最后的个人体会数据建模这件事很多年后你会发现比拼的不是建表技巧而是你脑子里有没有一套稳定的业务理解框架。REA 模型并不比传统表结构“高级”多少它只是逼你先回答“这个业务里究竟什么是真实发生的”再去想“系统里应该存什么”。我曾经用传统状态表做过很多次项目每次上线后都在无穷无尽地补状态字段和修数据直到改用事件流视角去重建核心模块后才真正摆脱了那种永远在救火的疲惫感。如果你也想试我建议别贪多挑一个最让你头疼、对账最难、逻辑最容易绕晕的模块用三要素重新画一遍模型。画完之后先不急着建表把模型讲给一个不懂技术的业务朋友听他能听懂说明你的建模清楚他听不懂多半是抽得太狠或者事件粒度没切好。调整到讲得顺了再落库。这套方法不一定适用所有系统但对那些正在被“对不上账”折磨的业务系统来说很可能是一次值得的翻盘机会。