如果只把一个“REA”的标题丢给我,我会先说一句:这不是一个能靠搜索引擎直接找到标准答案的东西。项目标题只有三个字母,可它可能是房地产、可能是某种能源项目、也可能是某个系统的缩写。这次我碰到的 REA,落到业务系统里其实对应的是 Resource-Event-Agent,也就是“资源-事件-代理”模型。它解决的不是“怎么把页面做漂亮”,而是业务系统里库存、订单、财务来回扯皮时,怎么把数据关系理成一套能追溯、能重算、能对账的结构。
这套模型没有想象中的高门槛,但它很挑落地方式。你要是把它当成一个简单的“流水表设计”来做,大概率会做成一张只有增删改查的表;你要是当成一个正式建模框架来做,它又能帮你把很多业务问题消解在方案阶段。这篇我就围绕“REA”在内部试点项目里的真实推进过程,说一说为什么选它、怎么拆、怎么建表写代码,以及中间踩过的若干个坑。
1. 设计前的判断:REA 这个标题到底指什么,我们要做的又是什么
1.1 给“REA”找到一个不跑偏的落点
拿到项目标题“REA”之后,我第一反应是把常见释义都过一遍,免得从一开始就跑偏。在不同的上下文里,这个缩写相差非常大:房地产领域可能代表地产代理或评估系统,能源领域可能代表可再生能源评估,在软件领域又可能被理解成 Reactive Event Analysis,还有人会说是 Responsive Execution Agent。网络热词的数据大多数时候只会指向前面这两类,因为它们的搜索体量很大。
但回到我手上的真实场景,业务背景非常明确:一个做仓配和订单管理的内部系统,每个月都要对库存、应收、应付数据做盘点,但现有数据库里没有一份能支撑追溯的完整记录。商品数量被直接 update,仓库人员修改单据不像改,财务说要“原始凭证”,开发说“表里已经没有以前的数据了”。这种情况下,REA 作为“资源-事件-代理”模型就成了最合适的落点。
REA 模型在 1982 年就被提出,早期主要是为了改进会计信息系统,后来被用在数据库建模、ERP 研发和业务领域设计中。它的核心思想是:不要直接记录“余额”和“存量”,而要记录“资源被什么事件改变了多少”,同时记录谁参与了这个事件。这样任何时点的库存、余额都能由事件流重新计算出来。
所以我在项目启动会上就给团队明确了一句定义:这次 REA 不是房地产,不是能源,而是一套“以事件流水为核心”的业务建模方式。后面所有表结构、代码规则、字典口径都围绕这一定义展开。
1.2 为什么资源-事件-代理能站得住脚
刚开始提 REA,团队里最直接的反应是:我们已经有库存表和订单表了,为什么还要引入一套新概念?这确实是很多业务系统团队会问的问题。传统做法里,库存表保存一个“当前数量”,订单表保存“订单状态”,需要月报时直接查当前值。看似简单,但有一个致命缺陷:当前值一旦被覆盖,历史就消失了。
REA 的处理方式完全反过来。它不信任“当前值”,它信任“事件序列”。就好比你银行账户里显示的余额其实不重要,重要的是每一笔存取款记录;只要记录连续、完整、不被篡改,任意一天余额都能推出来。REA 的三条支柱正好对应这个逻辑:
- 资源必须是可识别的、可计量的对象,比如商品、现金、应收款、服务工时;
- 事件必须是资源的变化,系统里任何一次业务动作都对应一个“事件”,事件只新增、不修改、不删除;
- 代理必须是事件的参与者,包括内部员工、外部客户、供应商、系统机器人,每个事件都要能追溯到具体代理。
这套规则听起来像是约束,其实是在给系统上保险。业务方总是希望“改一下就行”,开发方也总想贪图方便只维护一个状态位,但月末对不上账的时候,任何人都会后悔没有留下事件。我后来跟团队打过一个比方:REA 就像把仓库做成了“快递轨迹”,而不是一张“车辆当前在哪个城市”的卡片。轨迹虽然多几条记录,但每一步都能复查。
2. 关键设计:资源-事件-代理三要素怎么拆
2.1 资源:先定义哪些算“可交易”的东西
在 REA 设计里,第一步不是画页面,而是把所有业务对象里符合“资源”定义的东西找出来。资源不是“数据库表”,而是能够被购买、出售、消耗、转换或者持有的经济对象。一个仓配项目里通常会有这几类:
| 资源类型 | 举例 | 产生变化的事件方向 |
|---|---|---|
| 商品库存 | SKU、批次、箱号 | 入库 +1,出库 -1 |
| 备品耗材 | 包材、辅料 | 领用 +1,消耗 -1 |
| 现金/银行科目 | 收款单、付款单 | 收款 +1,付款 -1 |
| 应收/应付 | 客户欠款、供应商应付款 | 发货形成应收,付款核销应收 |
| 服务/产能 | 人工小时、仓储面积 | 投入 -1,产出 +1 |
这一步容易出两个问题。第一是把系统自身的数据对象也当成资源,比如“订单状态”不是资源,它只是事件发生后的结果,不应该作为资源单独建模;第二是漏掉细粒度资源,比如同一个 SKU 可能存在不同批次、不同有效期,如果不拆成资源实体,后面按批次追溯时就会抓瞎。
我给试点设定的规则是:凡是需要在对账、审计、业务分析中被单独追踪计量的对象,就有资格成为资源;凡是一个对象的“状态”只是别的资源变化引起的投影,就不单独建资源。这样既不会太散,也不会太大而化之。资源字段也尽量精简,一般只需要资源编码、资源类型、计量单位、状态,其他属性可以放到扩展表。
2.2 事件:把业务动作变成一条不可变的事件流
事件是我在这个项目里投入精力最多的地方。很多人以为“事件”就是操作日志,其实不是。REA 里的“事件”强调的是资源在经济上的转变:一次采购入库,一批销售出库,一台设备报废,一笔现金收款。它们都改变了资源的可用量或者归属。
设计事件时,我会先问自己一个问题:用户这一个操作,到底改变了什么资源?如果答案是“什么都没有”,那它很可能不是 REA 事件,只是普通系统日志。举例来说,“用户登录”不改变任何资源,所以不建事件;“仓库把 100 件商品从 A 仓调到 B 仓”改变了两个仓库的库存,所以是一个转移事件,它需要拆成两条明细,一条 -100,一条 +100,共用同一个事件号。
每个 REA 事件还有几个必备属性:事件编号、事件类型、发生时间、操作代理、来源单据号。事件类型一般做成字典,包括采购入库、销售出库、生产完工、报废、盘点调整、退货入库等。不要用中文名称直接当编码,最好用稳定的英文编码,比如 PURCHASE_IN、SALE_OUT、STOCK_TAKE,因为代码里和接口对接都更稳。
最重要的规则是“不可变”。事件一旦写入,不允许 UPDATE,不允许 DELETE。如果真的录错了,就再新增一条更正事件,把错误的影响抵消掉。这个规则一开始很反直觉,连内部仓管都会问“我为什么不能直接改数量?”我的回答是:你可以改,但改了以后,你解释不清当时为什么错,也无法让系统自动证明月末数据是对的。不如保留原事件,再补一条调整记录,历史就清清楚楚。
2.3 代理:人和系统的责任边界
代理是 REA 里最容易做得随意、也最容易被忽略的部分。很多业务表只记录一个“操作员”字段,丢给数据时就变成了一个字符串,根本分不清是谁、属于哪个角色、对内还是对外。REA 要求把代理建模成实体,并明确每个事件里的代理职责。
我分了两个层次:内部代理和外部代理。内部代理包括仓管员、采购员、客服、财务、还有系统自动任务;外部代理包括客户、供应商、承运商。对应地在 agent 表里用一个 agent_type 字段区分。这样设计最大的好处是,月末对账时不仅能知道“库存为什么少了”,还能定位到“是哪个代理的哪个环节造成少货”。
另一个细节是区分“操作人”和“责任人”。比如一次销售出库,操作人是仓库扫码员,责任人是审核该单据的客服或销售专员;这两个人可能不一样,一旦发生售后纠纷,必须都能查出来。我在事件表里留了 operator_id 和 responsible_agent_id 两个字段,前者是实际点击按钮的人,后者是业务上应该对这个动作负责的人。很多时候责任清晰,问题排查就能少吵很多架。
3. 落地过程:从模型到表结构与关键代码
3.1 核心表结构:四张基础表外加一张明细
REA 的表结构听上去很“学术”,实际落地并不复杂。第一个试点我不建议引入十几个表,通常会从四张核心表起步:资源表、代理表、事件表、事件明细表。事件明细表绑定资源、数量和方向,是整个模型里真正产生数据量的表。
下面这份 DDL 是我在试点中使用的最小可用结构,没有加太多索引,但已经能支撑库存重算和对账:
CREATE TABLE resource ( resource_id bigint PRIMARY KEY, resource_code varchar(64) UNIQUE NOT NULL, resource_type varchar(32) NOT NULL, unit varchar(16) NOT NULL, status varchar(16) NOT NULL DEFAULT 'ACTIVE', create_time timestamptz NOT NULL DEFAULT now() ); CREATE TABLE agent ( agent_id bigint PRIMARY KEY, agent_no varchar(64) UNIQUE NOT NULL, agent_type varchar(16) NOT NULL, agent_name varchar(128) NOT NULL ); CREATE TABLE event ( event_id bigint PRIMARY KEY, event_no varchar(64) UNIQUE NOT NULL, event_type varchar(32) NOT NULL, occurred_at timestamptz NOT NULL, operator_id bigint REFERENCES agent(agent_id), responsible_id bigint REFERENCES agent(agent_id), outer_ref varchar(128) NOT NULL, UNIQUE (event_type, outer_ref) ); CREATE TABLE resource_event_line ( line_id bigint PRIMARY KEY, event_id bigint NOT NULL REFERENCES event(event_id), resource_id bigint NOT NULL REFERENCES resource(resource_id), direction smallint NOT NULL CHECK (direction IN (1, -1)), quantity numeric NOT NULL, unit_cost numeric, amount numeric, agent_id bigint REFERENCES agent(agent_id) );这里面的关键点不是字段多少,而是两个唯一约束。event_no 必须唯一,保证事件不会重复;event_type 加 outer_ref 联合唯一,保证同一个业务单据不会被重复转换。outer_ref 我们一般填业务单号,比如“采购单 PO202306001”,这个值必须由上游业务系统稳定提供,不能每次生成不同的流水号。
3.2 事件写入:唯一键和事务边界是命门
代码层面,REA 的一步核心逻辑是“应用事件”。所谓“应用”,就是把一个业务动作转换成 event 主记录和 resource_event_line 明细记录,并顺带更新一份库存快照。这里最怕两件事:重复提交和半成功状态。
重复提交在微服务和异步消息场景里特别常见。消息队列偶发重试,上游系统因为超时重发,都可能导致同一个事件被插入两次。如果没有唯一键,库存就直接翻倍。所以我在写入前先做一次事件号查重,再尝试插入,插入成功才继续写明细和快照。伪代码如下:
function apply_event(req): # 1. 根据 event_type + outer_ref 查重 if event_exists(event_type, req.outer_ref): return duplicate_event # 2. 开启数据库事务 begin transaction # 3. 插入 event 主记录 insert into event(event_no, event_type, occurred_at, operator_id, responsible_id, outer_ref) values(req.event_no, req.event_type, req.occurred_at, req.operator_id, req.responsible_id, req.outer_ref) # 4. 插入明细,至少一行,可以多行 for line in req.lines: insert into resource_event_line(event_id, resource_id, direction, quantity, unit_cost, amount) values(当前event_id, line.resource_id, line.direction, line.quantity, line.unit_cost, line.amount) # 5. 更新库存物化表 for line in req.lines: upsert stock_snapshot(resource_id, quantity = quantity + line.direction * line.quantity) commit transaction有三个细节值得注意。第一,事件主记录和明细、快照必须在同一个事务里,否则会出现“事件写进去了但库存没变”的情况。第二,先插入 event 主记录再获得 event_id,不要在代码里预先手工拼 event_id,避免并发冲突。第三,库存物化表不是“真值”,它只是加速查询的缓存;碰到任何对不上的情况,都可以重跑“事件流聚合”来修复。
3.3 从事件流算出库存:快照加增量,别每次都全量扫描
有了事件流以后,很多人会问:那我要看当前库存,难道每次都要把所有历史事件扫一遍吗?确实不能这么干。绝大多数系统不需要每次都重算全量,我采用“起始快照加增量事件”的经典模式。
平时维护一张库存快照表,Daily 或每次事件后更新。到了月末、季度末或者需要审计时,从一个已经确认正确的快照时间点出发,重放这个时间点之后的所有事件增量。这样既能保证实时性能,又能保证最终一致。
查询语句可以简化成下面这样:
SELECT r.resource_code, COALESCE(sn.quantity, 0) + COALESCE(delta.qty, 0) AS current_qty FROM resource r LEFT JOIN stock_snapshot sn ON sn.resource_id = r.resource_id LEFT JOIN ( SELECT l.resource_id, SUM(l.direction * l.quantity) AS qty FROM resource_event_line l JOIN event e ON e.event_id = l.event_id WHERE e.occurred_at > COALESCE(:snapshot_time, '1970-01-01') GROUP BY l.resource_id ) delta ON delta.resource_id = r.resource_id;实际项目里还会给 Snapshot 加一个“已结算时间戳”,每次结算后把快照时间前移。这样明细表只需要保留最近一段时间,历史更早的数据可以归档。但归档之前一定得确认最后一次快照是正确的,否则旧数据删了就真的找不回来了。
4. 常见问题与排查:REA 落地最容易栽的跟头
4.1 库存不准,先查事件唯一性
试点上线后第一个不稳定现象是,某天库存显示比实际多了一倍。第一反应是查有没有重复事件。我们当时排查发现,上游订单推送接口因为网络超时,框架自动重试了两次,而第一次实际已经写库成功,导致两条完全一样的采购入库事件。这个问题不解决,后续所有对账都会偏。
我给的修复方案是加一个唯一索引,不是靠代码里的 if 判断。代码判断只能防“认识到的重复”,唯一索引能在数据库层面兜底,即使多条请求同时进来也不会双写成功。同时要求上游必须在 outer_ref 里携带幂等键,不能拿时间戳这种重复率高的值当唯一键。事件唯一性查询优先级最高,因为事件是根,根坏了,后面长出来的都歪。
4.2 对账不一致,检查数字类型和舍入规则
另一个坑来自金额计算。当时有人图省事,把数量和金额字段设计成 double 或 float,短时间内看不出问题,一旦遇到小数位多、涉及多行汇总时,对账单就会出现一分钱或几厘钱的差异。浮点数的二进制表示本身就存在误差,这不是代码 Bug,是数据类型没用在点子上。
从此项目统一规定,所有库存数量、订单金额、税额字段全部使用 numeric,并且明确“展示精度”和“计算精度”。库存数量按最小可发单位,比如件、箱、克,可以精确到 4 位小数,但单据打印时只显示 2 位;金额参与计算时保留至少 4 位小数,最后合计再四舍五入到分。别小看这个细节,月底对不上账时,差距基本都在这里。
4.3 代理维度混乱,导致责任说不清
系统里出现了一个很典型的问题:同一个客服既是售前导购,又负责售后赔付,结果“操作代理”和“责任代理”被混在同一个字段里。有一次发生了错发货,售后想给客户补发,但系统把补发事件的责任人记成了仓库扫码员,财务那边对口径时完全对不上。
REA 对代理的处理要始终和“角色”绑定,而不是和“工号”绑定。我的调整是增加一个 agent_role 字典,并允许一个代理拥有多个角色。事件表不直接存“人”,而是存“某人在某个角色下参与了事件”。这个看起来多了一步,但让所有跨部门问题都能答出“谁在什么岗位、按什么权限、做了哪个动作”。
4.4 录错事件不要硬改,要“新增更正事件”
有一次仓库在录入销售出库时多发了 5 件货,业务方希望直接把这笔单据的明细数字从 100 改成 95。按 REA 规则,这种做法是被禁止的。我们采取的方案是保留原事件“销售出库 100 件”,新增一条更正的“销售退货 5 件”事件,并在备注里关联原事件号。
这么做的好处是,历史事件链完整保留了“当时确实出库 100 件”的事实,同时最终库存通过流计算回到 95 件。财务结账、审计追溯、业务复盘需要的都是事实和修正痕迹,而不是一份被改得干干净净的数据。这个规则我直接写进了开发规范,并在数据字典里附了例图,阻止后续任何人直接 update。
5. 推进与运维配套:别让 REA 变成一纸文档
5.1 先小范围试点,再用“资源-事件-代理”对话对齐
很多团队拿到 REA 后容易激动,想把客户、订单、合同、仓储、财务一次全部建好,结果模型越画越大,迟迟不敢动工。我这次故意收缩范围,试点期只做了三件事:采购入库、销售出库、库存盘点调整。其余应收应付、生产领料先不动,因为事件类型越多,事务边界和幂等问题就越复杂。
试点期过了以后,团队养成了一种很有效的对话方式:开会讨论任何需求时,不再说“这个表加个字段”,而是先画一个小表格,三列分别是资源、事件、代理。一次需求讨论会,如果这三列都能填满,说明业务理解到位;如果哪一列空着,那就说明需求还不完整。这个习惯比任何文档模板都管用。
5.2 每季度做一次“模型体检”
模型不可能一次设计完美,所以我保留了一个“模型体检”动作。每个季度把线上所有事件类型和资源类型拉出来,逐项检查是否还符合 REA 原则。检查清单通常包括四个问题:
| 检查问题 | 如果答“否”意味着什么 |
|---|---|
| 每个事件是否都能对应至少一个资源的变化? | 存在无效事件,可能只是日志或中间状态 |
| 每个资源变化是否都有代理记录? | 存在无法追责的数据变化,必须补审计 |
| 事件是否只增不改? | 存在破坏历史的业务,需要排查哪里开了 update 口子 |
| 对外提供的库存/余额是否可由事件流重算? | 存在隐藏双写,一旦不一致会很麻烦 |
这个体检不需要每次都动代码,很多时候只是查数据字典和开发规范就能发现问题。有一次体检发现,某个外部系统直接把“已发货”当作事件写入,实际上资源并没有任何变化,因为“已发货”只是订单状态。这个口子虽然静默运行了很久,但只要能被体检识别出来并改掉,就不会在关键时刻爆雷。
5.3 几条关于缩写型项目的个人经验
最后说一点我做这类项目的个人体会。如果你拿到的项目标题也是一个含混的三字母缩写,不要急着写代码,先逼自己用三句话把范围讲清楚。这次 REA 的三句话就是:资源是商品和钱,事件是入库出库和销售,代理是仓库和客户。这三句话讲清楚以后,表结构、接口命名、字段归属全都有了方向。
第二个经验是要把“重放能力”当成系统可信度的底线。REA 项目里最有价值的不是那张实时库存表,而是“随时能把任何时点库存重算出来”的能力。即使平时性能优化到不需要重算,这个能力也必须存在。它就像消防通道,平时没人走,但一旦着火,你知道它能用,心里就不慌。
第三个经验是模型落地要有分寸。不要把每个字段都强行定义成资源或者事件,业务系统还会包含大量配置、权限、元数据,这些数据不参与业务流转,硬套 REA 只会增加维护成本。REA 要管的是“会被交易和管理、会影响对账”的数据,不是所有数据。
我之前接过不少缩写型项目,真正让人翻车的往往不是技术,而是命名背后的语义模糊。REA 这次给我的教训很简单:下次再看到这种短标题,就先写三行字——资源是什么、事件是什么、代理是谁。三行字写明白了,项目就通了一半。