拿到项目名时,第一眼只有三个字母:rea。这个模块在需求清单里往返了两轮,才确认它指的是 Resource-Event-Agent,也就是资源、事件、代理。最初以为只是内部系统的某个代号,等把需求文档完整读完才意识到,这是对原来那套“库存表里直接放余额、月底靠人工对账搅不清”的老账本模型的一次彻底重写。这篇文章的核心是把这次数据模型重构的全过程拆开讲清楚。如果你正在做进销存、费用报销、订单履约,或者任何带审批流和数据流的业务系统,可以直接用这套思路作为表结构设计和避坑清单的参考。
1. 为什么我把“rea”理解成事件驱动的数据模型
1.1 表面是缩写,内核是一种建模视角
平时做业务系统,大家的习惯是从“有哪些对象”出发,把用户、订单、商品、库存这些实体画出来,再连上关系。这种方式不是不行,但业务一复杂,实体关系就开始失控:同一张订单既要有下单状态,又要有发货状态,还要有售后状态,每个角色都在给这张表加字段。
REA 的思路刚好反过来:它问的不是“有哪些东西”,而是“发生了什么”。核心记录对象是事件。资源只是被事件影响的对象,代理只是事件的参与者。库存数量不是一条记录里的快照,而是所有历史事件累计出来的结果。
我打个比方。老式手工记账,老板每天关心的不是账本上还剩多少钱,而是流水单上每一笔收入、支出的记录。余额不重要,因为它是可以随时从流水里算出来的。REA 模型也是这样:事件表就是流水账,库存表里的“当前数量”只是流水账跑出来的一个视图。传统建模把余额当成事实,REA 把事件当成事实,余额只是给你查询用的结果。
这套思路最早来源于会计信息系统领域,目的是解决财务系统中的审计追溯问题,但后来被大量应用到 ERP、进销存、工作流系统里。它最大的好处是:业务规则怎么变,报表口径怎么换,底层的数据骨架都不需要伤筋动骨。因为事实永远不会改,只是你看它的角度变了。
1.2 和传统借贷记账法相比,它赢在哪
做过财务系统的同学都知道复式记账法,凭证里必须有借方和贷方,有借必有贷,借贷必相等。这套规则对会计来讲非常严谨,但对程序建模来说,并不是所有业务都能很好地映射成借贷方向。
比如一个总仓调拨到分仓的场景:在传统借贷模型里,需要写“借:分仓库存,贷:总仓库存”,语义上勉强能对上。但如果业务变成“领料单被审批人退回”,借贷方向怎么记?到底是冲减哪一笔?这时设计人员很容易开始纠结科目设计,而不是关注业务本身发生了什么。
REA 模型不强制要求借贷方向。它只记录三个维度的内容:资源发生了什么变化、是谁参与了这个变化、这个变化发生在什么时间。数量增减用正负表示,方向由资源流本身决定,而不是人为去套借贷。等需要出财务报表时,再从事件表里按规则生成凭证,底层数据是干净的。
这种设计也天然支持审计追踪。哪一步出了问题,可以直接翻事件流,而不需要从余额反推到底哪笔记账错了。对依赖审计和多级审批的业务系统来说,这个特性比什么都重要。
2. 资源、事件、代理:三个最容易被搞混的角色
2.1 资源:不是所有东西都叫 Resource
在 REA 模型里,资源必须满足一个条件:能被业务活动消耗、获取或改变,并且有经济价值。最典型的资源是库存物料、现金、固定资产。这意味着你需要在写代码之前先分辨:客户算资源吗?不算。客户是代理,是事件参与者。仓库算资源吗?也不算,仓库更多是位置维度。合同算资源吗?通常不算,合同是约束条件,影响资源如何被交换,但本身不是被消耗的对象。
很多团队第一次建模时都会把“订单”当资源,这是常见误区。订单描述了一次交易意图,而真正发生的事件是“货物出库”和“收到货款”。库存里消失的那批货才是资源,订单只是连接这些事件的业务单据。
资源表建议至少要涵盖三个语义维度:物品、存储位置、批次。同一款物料在不同批次、不同仓位下,价格可能不同,库存归属也不同。所以 resource 表的主键不应该只是物料编码,往往是“物料 + 仓位 + 批次”的组合维度。很多计费系统后面出问题,根因就是最初没有预留这层粒度。
2.2 事件:整套模型里唯一的“事实源”
事件是整个 REA 模型的心脏。最核心的两类是增量事件和减量事件。采购收货入库是增量事件,销售出库是减量事件,调拨则可以拆成“出库事件 + 入库事件”两个动作。为什么要这样拆?因为拆开之后,每个仓库的出入库流水都是完整的,不会出现“总仓减了但分仓没加”这种强一致性问题。
事件带上正负数量还不够,必须带上业务发生时间。注意,这里说的“业务发生时间”不是系统写入时间,而是业务真实发生的那个时刻。比如用户在晚上九点提交了一张领料单,仓库次日早上八点才审核通过,那么产生库存影响的事件时间应该记审核通过时间,而不是提交时间。很多系统最终对不上账,就是因为时间语义混用了。
事件表还有一个铁律:只追加,不修改。哪怕数据录错了,也不要直接改原事件,而是新增一条反向事件来冲红。为什么?因为事件是事实,事实不应该被覆盖。修改历史事件的任何一个字段,都会导致从那个时间点之后的查询结果整体发生变化。把事件表设计成只能 insert,不能 update 和 delete,你会省下巨量的对账痛苦。
2.3 代理:不只是“操作人”那么简单
代理是执行事件的个人或组织。内部代理是公司自己的员工、部门;外部代理是客户、供应商、物流方。REA 模型强调代理并不是简单地记录“谁操作了”,而是要记录代理在一个事件中扮演的角色。
拿领料场景举例:一张领料单,有申请人、审批人、发料人三个角色。在 REA 里,这三者都是代理,但他们参与的是同一个事件。不要把这个关系简单拼成“领料单用户 ID”,而要用一张关联表把事件、代理、角色三者绑起来,类似事件代理关系表。这样后续做绩效分析、权限追溯、操作审计都非常顺手。
代理本身也可能有层级关系。部门下面有子部门,公司下面有分公司。如果业务要看“某个部门所有的领料”,建议在代理表里维护一层父子关系,或者冗余一个部门层级路径。不要试图在事件查询时递归去查部门树,性能和逻辑都会让你怀疑人生。
3. 实操:从零套出领料系统的 REA 数据层
3.1 业务背景与建模目标
就拿这个再普通不过的场景来说:车间领料、仓库入库、生产退料、月底盘点。原来的系统是标准的状态机设计——领料单有“草稿、审批中、已通过、已领料”几个状态,每流转一次状态就更新一次后端字段。库存表里存着一个当前数量,每次领料就用“当前数量减领料数”直接扣减。
听起来简单,实际跑起来全是问题。并发领料时两个请求同时读到旧余额,库存变成负数;盘点差异直接改库存表,连个痕迹都留不下;月底对账时,财务问“这批物料月初的时候到底有多少”,系统根本答不上来,因为没有“历史某一时刻的库存”这个概念。
用 REA 重做之后,目标非常明确:所有库存变动全部落到事件表,库存余额不再作为事实存在。任何时间点,只要把事件表按时间过滤再聚合,就能算出当时的库存。账单、审计、对账都不需要靠人工修补数据。
3.2 表结构与字段设计
我实际采用的方案是四张表加一张关联表。资源表存物品本身的信息,代理表存人员和组织,业务事件表存每一次变动,事件代理关联表存参与者及其角色。
-- 资源表:物料 + 仓位 + 批次 的维度 CREATE TABLE resource ( id BIGINT PRIMARY KEY, sku VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, warehouse VARCHAR(32) NOT NULL, batch_no VARCHAR(64), unit VARCHAR(16) NOT NULL, created_at TIMESTAMP NOT NULL ); -- 代理表 CREATE TABLE agent ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, agent_type VARCHAR(16) NOT NULL, -- INTERNAL / EXTERNAL parent_id BIGINT, created_at TIMESTAMP NOT NULL ); -- 业务事件表 CREATE TABLE business_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_no VARCHAR(64) NOT NULL, resource_id BIGINT NOT NULL, flow_quantity DECIMAL(18, 4) NOT NULL, -- 正数入库,负数出库 event_type VARCHAR(32) NOT NULL, -- RECEIPT / ISSUE / TRANSFER_IN / TRANSFER_OUT / ADJUST occurred_at TIMESTAMP NOT NULL, -- 业务发生时间 created_at TIMESTAMP NOT NULL, -- 系统写入时间 source_order_no VARCHAR(64), remark VARCHAR(255), UNIQUE KEY uk_event_no_type (event_no, event_type) );这里最重要的字段是flow_quantity,它用有符号数表示资源流向。正数代表入库、负代表出库。这样设计的好处是,聚合查询只要一句SUM(flow_quantity)就能算出库存净变化,不需要区分哪个事件类型是入哪个是出。
事件表里加了联合唯一约束uk_event_no_type,是为了防止同一个业务单号被重复导入两次。很多上游系统会因为网络重试把同一张领料单推两次,这是实际场景中最常见的脏数据来源。
3.3 关键 SQL:插入事件与重建余额
当车间审核通过一张领料单,真正需要落库的代码其实只有一条 INSERT:
INSERT INTO business_event ( event_no, resource_id, flow_quantity, event_type, occurred_at, created_at, source_order_no ) VALUES ( 'REQ-20240307-001', 101, -8, 'ISSUE', '2024-03-07 10:30:00', NOW(), 'REQ-20240307-001' );查某天之前的库存数量,则是一句简单的聚合:
SELECT r.id, r.sku, r.name, COALESCE(SUM(e.flow_quantity), 0) AS stock_balance FROM resource r LEFT JOIN business_event e ON e.resource_id = r.id AND e.occurred_at < '2024-03-01 00:00:00' GROUP BY r.id, r.sku, r.name HAVING COALESCE(SUM(e.flow_quantity), 0) > 0 ORDER BY r.id;我第一次跑这个查询的时候,觉得这 SQL 简单得有点不太真实。但确实就是这么简单。REA 的核心价值不在 SQL 技巧,而在设计层面把复杂度挪到了事件写入阶段,查询阶段反而变得无比简单。
你可能会担心:如果事件表有百万条数据,每次查库存都要全表聚合怎么办?这个在后面的扩展部分会说,最简单的方案是引入按日汇总表,查询性能完全能压下来。
3.4 盘点与冲红:不许改,只许补
盘点差异是库存系统里最难处理的场景。仓库盘完点,发现账上 100 件,实际只有 92 件,差的 8 件到底怎么记?传统做法是直接把库存表改成 92,然后糊里糊涂地留一条备注。
用 REA 模型,正确做法是新增一条“盘点调整”事件,flow_quantity记 -8,备注写“月度盘点盘亏”。这样业务含义清清楚楚:系统账没被篡改,后续审计能明确看到调整原因。
如果事件本身录错了,同样不要改原事件,而是新增一条数量相反的红冲事件,把错误抵消掉。例如多发了 8 件物料,就新增一条flow_quantity为 +8 的冲正事件,备注里写明原单号和原因。这样留下两条记录,虽然看起来多一步,但历史事实保持完整。
注意:如果哪一天你发现有人直接 UPDATE 了 business_event 表里的 flow_quantity,那么这个系统的数据可信度就已经崩了一半。事件表在数据库层面就应该限制 UPDATE 和 DELETE 权限,只允许 INSERT。这一条怎么强调都不过分。
4. 落地时容易踩的坑,以及我的排查心得
4.1 把“状态”混进了资源表
我第一次做 REA 重构时,面对的最大阻力来自同事习惯的状态机思维。他们坚持在 resource 表上保留一个status字段,来记录“在用、停用、冻结”。这听上去合理,但这些状态本质上不是资源属性,而是资源在某个时间点被某个事件作用后产生的结果。
更麻烦的是“冻结”状态。如果你在资源表里直接存了frozen=1,那么当你想知道“3 月 1 日哪些物料是冻结的”,你根本没有办法回答,因为你覆盖了状态变化的历史。
我的建议是:状态变化也必须变成事件。启用是一个事件,停用是一个事件,冻结和解冻各是一个事件。资源表只保留不变的静态属性字段。这样任何状态都是可追溯的,前端需要的状态值完全可以用最后一条状态事件推导出来。
4.2 忽视了时间字段的双轨制
前文提到要区分occurred_at和created_at,这里具体说下后果。如果只保留一个写入时间created_at,那么在数据补录的场景里就会出错。仓库可能是三天后才把一张领料单补录进系统,如果用created_at作为库存查询的时间边界,那三天的库存数据就会整体偏移,导致月末盘点永远对不平。
正确做法是双轨制:occurred_at表示业务实际发生的时间,是报表和对账的唯一依据;created_at只作为数据写入审计时间。在汇总表里,也一律用occurred_at做日期分组。遇到跨时区业务时,建议统一存储为数据库 UTC 时间,展示层再转换到业务本地时区。
4.3 事件重复与数据漂移
事件重复是另一个高频问题。上游系统因为网络超时重发同一张领料单,如果你的下游没有幂等控制,事件表会莫名其妙多出一条负数流水。三个月后库存对不上,排查起来非常痛苦,因为两条事件几乎一模一样。
解决方式就是加业务唯一键。我在设计时用了(event_no, event_type)联合唯一索引。接收方写入前先查询该组合是否已存在,存在就直接跳过。这个做法成本极低,但能避免一大类重复导入问题。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 月底库存变成负数 | 事件丢失,或者发生了未记录出库 | 按时间倒序检查事件流水,找出缺口时间段 |
| 同日事件对账不平 | 时间使用了 created_at 而非 occurred_at | 检查事件写入逻辑,统一业务时间口径 |
| 收到重复单号,出现双倍扣减 | 上游网络重试,无幂等约束 | 增加唯一联合索引,重复插入直接忽略 |
| 历史数量被人改过 | 事件表 UPDATE 权限未限制 | 禁用 UPDATE/DELETE,恢复数据用红冲事件 |
| 查询越来越慢 | 事件表过大,无汇总层 | 建按日汇总表,每日定时重算 |
这套排查表是我自己多次踩坑后整理的。最让我记忆深刻的是第一个问题——库存负数,查了半天发现不是代码逻辑 bug,而是某个同事手动改过数据库,把一条出库事件的flow_quantity从 -8 误改成了 -80。所以事件表权限限制,真的是血泪教训。
5. 从数据层走向业务事件中心
5.1 用汇总表解决查询性能
事件表天然只增不减,量大了以后全表聚合会很慢。实际上产环境我不可能每次打开库存页面都扫一遍百万级流水。做法是引入日汇总表:
CREATE TABLE resource_daily_summary ( summary_date DATE NOT NULL, resource_id BIGINT NOT NULL, in_quantity DECIMAL(18, 4) DEFAULT 0, out_quantity DECIMAL(18, 4) DEFAULT 0, PRIMARY KEY (summary_date, resource_id) );每天凌晨跑一个定时任务,把前一天所有事件按资源聚合,写入汇总表。查询某天的库存结余时,用“该日期之前所有日汇总之和 + 当日的流水明细”得到结果。这样既保留了事件表的全部细节,又让日常查询秒级返回。
提示:汇总表也要支持重算。比如发现某天有红冲事件补录,你需要删除该日汇总并重新聚合,而不是直接加一笔,否则会重复统计。
5.2 让事件流成为消息中枢
REA 模型另一个很自然的发展方向是事件总线。既然所有业务事实都集中在事件表,那事件本身就是最好的消息源。
每次插入一条 business_event,同时向消息队列发布一条事件消息,下游服务可以订阅它来触发单据通知、财务记账、库存预警。由于事件表有唯一键约束,下游消费时也天然可以依赖事件号做幂等,避免消息重复投递导致的数据错误。
这种做法的价值是,把核心数据流和外围业务解耦。库存模块不关心上游审批流长什么样,也不关心下游财务要不要做凭证,它只负责忠实地记录每一个事件源。
5.3 多账套、多组织的扩展思路
如果你的业务以后要支持多组织、多账套,REA 模型也能平滑扩展。最常用的方案是在 business_event 表增加租户或者组织维度字段,资源表则把仓库维度扩展为组织加仓库。查询时带上组织过滤条件即可。
多币种场景稍微复杂一点。资源的价格是一个事件维度,而不是资源属性。同一种物料在不同采购批次中价格不同,这属于事件事实。建议把金额、币种字段直接放到事件表里,而不是塞进资源表。这样库存数量用flow_quantity,库存金额则用flow_quantity * unit_price结合币种字段计算,账实两边都能对上。
这个扩展方向本质上和原来的 REA 骨架不冲突。无论业务以后怎么变,最底层的事件表结构都会保持稳定。
把库存、订单、财务这些业务沉淀成事件流之后,整个系统的可追溯性会上一个数量级。这次项目做完,我最大的体会不是 SQL 写得有多巧妙,而是“事件是事实、余额是视图”这个原则有多值得坚持。只要你牢牢守住“事件表不修改”这条底线,后面无论业务怎么扩展,你都能站在一个可靠的事实源上做任何维度的分析。而我见过的大部分烂账系统,几乎都是在某个深夜被人直接改了历史数据,从那以后就开始永无宁日的对账循环。少动那张表,多记新事件,你会回来感谢这条规矩的。