ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

REA资源事件代理模式实战:重构库存、对账与数据一致性架构

REA资源事件代理模式实战:重构库存、对账与数据一致性架构

1. 从“rea”到我们系统里的资源-事件-代理

接到一个库存模块重构需求时,对方丢给我三个字母:rea。我一开始以为是某个内部工具代号,后来才反应过来,他们想要的是 REA 模式,也就是 Resource-Event-Agent,资源-事件-代理。这套思路最早不是技术圈发明的,而是从会计信息系统领域长出来的建模方法,用来描述一个企业到底“有什么、发生了什么、是谁参与的”。我花了一周把现有订单流程重新理了一遍,最终用 REA 重构了库存、账务和审计链路,效果比我预想的好不少。这篇文章就把当时的设计和踩坑记录整理出来,给准备入坑的人一个参考。

先说它能解决什么问题。传统业务系统里,大家习惯用状态字段和余额表,但状态字段只告诉你当前对象长什么样,回答不了“为什么会变成这样”;余额表只给你一个最终数字,无法还原每一步经济动作。REA 的思路恰好反过来:状态和余额都是临时推导结果,事件才是不变的事实。每次资源增减都有对应事件,每个事件都有参与代理,数据一致性会更可靠,审计也能直接回溯。

适合谁来读?后端工程师、架构师、数据开发,尤其是正在做订单、库存、支付、积分、财务对账这类系统的朋友。如果你正被“状态越加越多”“对账老对不上”“历史数据改一个后面全乱”这三个问题折磨,REA 值得认真研究一遍。

1.1 一次库存核对引发的重构

当时我在一个电商项目里,日单量不小,库存一直用一张 stock 表,字段就叫 quantity,下单扣、取消加。看起来很简单,可到了晚上对账就出问题:订单系统显示卖了 100 件,仓库的 WMS 显示出库 99 件,财务的 ERP 又显示成交 101 件。三方数据不一致,谁都不愿改自己的逻辑,最后只能人工拉 Excel 一笔一笔核,每次大促之后都要加班两三天。

我后来想明白一个问题:问题不出在一张库存表上,而在于我们没有定义“库存到底是怎么产生的”。库存余额不是基础事实,基础事实应该是每一次入库、每一笔出库、每一次盘点调整。只有把事件完整留下来,库存才能在任何时间被重新算出来。REA 正是围绕这个思路设计的。

1.2 三个核心词:资源、事件、代理

REA 模型只有三个核心概念,理解成本极低。

  • 资源(Resource):被管理、被转移的对象。库存里的 SKU、账户里的余额、用户手里的积分、仓库里的库位,都属于资源。
  • 事件(Event):导致资源增减或转移的原始动作。采购入库、销售出库、退款、发放积分、盘点调整,都是事件。
  • 代理(Agent):参与事件的人或系统。客户、供应商、库管员,甚至自动补货脚本,都算代理。

绝大多数业务场景都能套进一个句式:谁,在什么时间,对什么资源,做了什么事件,产生了什么增减。把这个句式落成一张张表,REA 的主体就完成了。

1.3 这套设计适合解决什么问题

我总结过,REA 主要适合三类场景。

第一类是强追溯场景,比如审计、财务、合规,要求每个数字都能从原始凭证还原。第二类是状态流转复杂的场景,比如订单、库存、支付,单一状态字段根本存不下历史。第三类是跨系统集成场景,比如业务库、仓库 WMS、财务 ERP 三方对账,REA 作为统一事实层,可以收敛口径差异。

如果只是一个小工具站、一个博客、一个简单后台,那确实用不上 REA。技术选型没有银弹,它解决的是“业务事实如何被持久化和推导”的问题,不是万能架构。但只要你需要回答“某年某月这笔数是怎么来的”,REA 就会变得很有价值。

2. 为什么我会选这个架构而不是继续更新状态字段

2.1 传统状态机的问题在哪

老系统里到处都是状态机:订单表有 status,支付表有 pay_status,发货表有 ship_status,后来加了售后,又多一张 after_sale_status。表面上看状态划分很清楚,实际有两个大坑。

一个是状态只能记录当前值,追不了历史。想知道一个订单十分钟前是什么状态,要么靠操作日志,要么靠 binlog 解析,运气不好日志还被定期清理了。另一个是状态之间互相影响,订单状态和支付状态一旦不同步,展示给用户的视图就是错的,而且没人知道是哪个服务先改的。

我踩过最狠的一次:运营后台有个“手工修正库存”按钮,实现就是一个 UPDATE stock 语句。第二天核对时发现库存比仓库实物多了一百多件,问谁改的没人承认,最后只能靠数据库 binlog 回放才找到入口。不是说不能用后台工具,而是这类工具应该去写一条“盘点调整事件”,通过补偿事件修正,而不是直接改总量。

2.2 REA 的核心思想:余额不是事实,事件才是事实

REA 最核心的一句话是:可推导出来的数字,永远不要作为唯一的真值源。

打个比方,银行不会为了省事只存一个账户余额,而是把余额和流水都存下来。余额是流水的投影,流水才是真实发生过的事实。账户出了问题,银行翻的是流水,而不是改余额。库存也是一个道理,库存余额应该是所有出入库事件的汇总结果。

所以,在这种架构里,数据表被分成两类:事实表和投影表。事实表只追加、不修改、不删除;投影表从事实推导而来,可以随时重建。业务查询走投影表,审计追溯走事实表。两者对不上时,默认投影表是脏的,重建一次就好。光这一点,就让人在处理历史数据问题时踏实很多。

2.3 和事件溯源、DDD 到底是什么关系

方案评审时有人问:这不就是 Event Sourcing(事件溯源)吗,为什么还要单独叫 REA?

我的理解是,事件溯源是工程手段,REA 是建模语义。事件溯源只规定“状态变更要以事件序列保存”,但事件怎么分类、资源如何增减、代理怎么关联,这些业务语义它不管。REA 恰好补的是这一层。无论你叫 REA 还是叫“事件驱动建模”,关键是把经济活动拆成可推导的原子事实。

和 DDD 也是互补关系。你可以把资源、事件、代理看作一组领域对象,DDD 负责服务内部的领域规则,REA 负责跨服务边界的持久化语义,事件溯源负责底层存储。三者各干各的,不冲突。我现在做设计,一般会用 DDD 梳理逻辑,用 REA 定义数据库事实层,再用事件表存储事件。

3. 落地数据库设计:核心表结构与字段解析

下面给出我在一个真实项目里用过的表结构,已经脱敏,字段可以直接参考。当时用的 MySQL 8,换成 PostgreSQL 也差不多,只需把 DATETIME 换成 TIMESTAMP,把自增主键换成序列。

3.1 资源表:先定义“被管理的东西”

资源表不存余额,只负责描述“系统里有哪些资源”。

CREATE TABLE resource ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, resource_code VARCHAR(64) NOT NULL, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- SKU / ACCOUNT / POINT / BALANCE unit VARCHAR(16) NOT NULL, -- 件 / 元 / 积分 status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_resource (tenant_id, resource_code) );

SKU、账户、积分类型都在这张表里建。如果要做批次管理,可以拆一张 resource_batch 表,带 batch_no、生产日期、过期日期,再用 resource_id 关联。如果要做库位管理,再拆 resource_location。资源粒度最好提前想清楚,我见过不少项目把 SKU 和批次混在同一张表里,后面做批次过期预警时非常痛苦。

金额类资源要注意币种,人民币余额、美元余额、金币、积分,建议用不同 resource_code 区分,或者加一个 currency 字段。同一种商品要不要拆成“可用库存”和“锁定库存”两个资源?我的建议是不要拆,总库存是一行资源,锁定只是资源的一种临时约束条件,用附加表去表达,而不是硬拆成两张资源表。

3.2 事件表:只记事实,不做加减法

事件表是整个 REA 模型最容易设计出错的地方。我常用的结构是事件主表、事件行表、事件代理关联表三张,先看主表。

CREATE TABLE business_event ( event_id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, event_type VARCHAR(64) NOT NULL, -- PURCHASE_INBOUND / SALE_OUTBOUND event_no VARCHAR(64) NOT NULL, -- 业务事件编号,展示用 biz_id VARCHAR(128) NOT NULL, -- 外部业务唯一ID,用于幂等 occurred_at DATETIME NOT NULL, -- 业务发生时间 recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 写入时间 source_system VARCHAR(32) NOT NULL, -- ORDER / WMS / ERP status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_biz (tenant_id, biz_id) );

biz_id 是重点。它必须能唯一定位到上游系统的一次业务动作,比如订单号加操作序号,或者采购单号加收货行号。这个字段是幂等的地基。如果上游给不出来,就让上游改造,或者在接入层用业务内容做哈希。

事件行表记录资源增减:

CREATE TABLE business_event_line ( line_id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(18, 4) NOT NULL, -- 带符号,正数表示资源流入 unit VARCHAR(16) NOT NULL, remark VARCHAR(255), KEY idx_event (event_id), KEY idx_resource (resource_id) );

quantity 我统一用带符号数,正数代表资源流入,负数代表资源流出。这样 SUM(quantity) 就是当前资源存量,语义最简单。不要把流出设计成“quantity 存正数 + direction 字段”,那样所有查询都要加一层 direction 判断,非常容易写错。

3.3 代理表:谁参与了这次的资源流

代理表描述“系统里有哪些参与方”。用户、客户、供应商、仓库、配送员,统一抽象为 agent。

CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, agent_type VARCHAR(32) NOT NULL, -- CUSTOMER / SUPPLIER / INTERNAL / SYSTEM agent_code VARCHAR(64) NOT NULL, agent_name VARCHAR(128) NOT NULL, contact_info VARCHAR(255), UNIQUE KEY uk_agent (tenant_id, agent_type, agent_code) );

事件与代理之间再建关联表:

CREATE TABLE business_event_agent ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, agent_role VARCHAR(32) NOT NULL, -- SELLER / BUYER / AUDITOR KEY idx_event (event_id), KEY idx_agent (agent_id) );

有人会问:代理直接在事件行里加 buyer_id、supplier_id 两个字段不就行了?

短期可以,后期会很痛。你会碰到越来越多的角色:发货员、审核员、承运商、自动脚本。每加一个角色就要加一个字段,表结构会变脆。独立关联表可以无限扩展,也能支持一个事件多个同类型代理,比如一次出库有两个仓管员签字,这在审计场景很常见。

3.4 外键、时间与幂等字段的设计细节

我没有在表之间强加物理外键,原因很现实:大表物理外键会让插入和删除变得非常重。REA 表属于典型的高写入表,所以只建普通索引,一致性交给应用层保证。

关于时间,occurred_at 和 recorded_at 必须分开。业务发生时间可能是 23:59,写入时间可能是第二天 00:01,如果混用一个字段,按天对账就会出现跨天误差。这个坑我踩过两次。再补一个 closed_at 字段,标记事实的最终确认时间,因为有些事件要等下游异步回执,确认完成才算闭合。

幂等不只做在接口层,数据库层也要加约束。假设表结构里已对 (tenant_id, biz_id) 建唯一索引,那么上游重发消息时,INSERT 会直接报主键冲突,这时候捕获异常正常返回即可。如果事件存在中间态,可以先写 PENDING,再由确认事件把它改成 CLOSED,业务上也更稳。

4. 用一个真实场景走通全流程

4.1 电商库存场景的事件定义

假设库存系统里有六类核心业务动作:采购收货、销售出库、退货入库、盘点调整、库位转移、库存锁定。

事件类型资源方向涉及资源典型代理备注
采购收货资源流入SKU供应商、采购员采购单确认收货
销售出库资源流出SKU客户、仓管员发货完成
退货入库资源流入SKU客户、质检员良品次品需区分
盘点调整流入或流出SKU盘点员必须关联盘点单
库位转移流入加流出SKU 与库位仓管员涉及两个资源行
库存锁定流入加流出总库存与锁定库存系统不建议拆资源

关于库存锁定,我的建议仍然是不拆资源,而是在事件行表上扩展一个 event_line_attr 表,存 lock_until、order_id、锁定原因等属性。总库存始终是同一行资源,只是同一时间存在“锁定中”和“可售”两种视图,两个视图都由事件行派生出来。

4.2 上游系统接入事件:幂等与去重

订单系统支付完成后会发消息。这段是核心接入逻辑,我用 Java 风格的伪代码写:

public void handleSaleOutbound(OrderPaidMessage msg) { Long eventId = tryCreateEvent( "SALE_OUTBOUND", bizId = msg.getOrderId() + ":" + msg.getSeq(), occurredAt = msg.getPaidTime() ); if (eventId == null) { return; // 重复消息,唯一索引挡住 } createEventLine(eventId, msg.getSkuId(), -msg.getQty()); createEventAgent(eventId, msg.getCustomerId(), "CUSTOMER", "BUYER"); }

tryCreateEvent 先尝试 INSERT,如果唯一索引冲突返回 null,否则补事件行和代理关联。三个写入放同一个本地事务,异常时整体回滚。更严格的方案是事务型消息,先本地写库再确认消息;也可以用定时任务拉取并幂等写入,具体取决于团队现有中间件能力。

有一点必须强调:事件写入的时机很关键。“正在下单”不是事件,“已下单付款”才是;“订单发起出库”不是事件,“仓库实际完成出库”才是。REA 和普通操作日志最大的区别,就是事件必须是已经发生的事实,不能把意图或草稿放进来。

4.3 余额查询:从事件流到当前库存

事件表长时间累积后,每次都实时 SUM 会慢。实际项目里我采用的是“快照加增量”方案,先建一张资源快照表:

CREATE TABLE resource_snapshot ( snapshot_id BIGINT PRIMARY KEY AUTO_INCREMENT, resource_id BIGINT NOT NULL, base_time DATETIME NOT NULL, base_quantity DECIMAL(18, 4) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );

定时任务每隔几分钟生成一次快照,生成逻辑是把截至某个时间点的所有事件聚合起来。然后查询某个资源当前库存时,SQL 长这样:

SELECT s.base_quantity + COALESCE(SUM(l.quantity), 0) AS current_qty FROM resource_snapshot s LEFT JOIN business_event_line l ON l.resource_id = s.resource_id AND l.occurred_at > s.base_time AND l.status = 1 WHERE s.resource_id = 123 AND s.base_time = ( SELECT MAX(base_time) FROM resource_snapshot WHERE resource_id = 123 AND base_time <= NOW() ) GROUP BY s.snapshot_id;

这个方案的优点在于,增量部分只用扫描最近几分钟的事件,查询快;快照本身可以随时重建,即使某次快照生成出错,重刷一次就能恢复。

4.4 对账与异常发现的核心 SQL

对账的核心思路,是把上游系统的凭证明细和事件表做外连接,找出只在一边存在的数据。

SELECT upstream.biz_id, upstream.amount, line.sum_qty FROM upstream_sale_outbound upstream LEFT JOIN ( SELECT e.biz_id, SUM(l.quantity) AS sum_qty FROM business_event e JOIN business_event_line l ON e.event_id = l.event_id WHERE e.event_type = 'SALE_OUTBOUND' AND e.status = 1 GROUP BY e.biz_id ) line ON upstream.biz_id = line.biz_id WHERE line.sum_qty IS NULL OR ABS(line.sum_qty - (-upstream.amount)) > 0.0001;

查询结果就是“上游有记账、事件表没有”,或者“两边数量差超过容差”的异常订单。把这些订单筛出来人工复核,比全量对账高效得多。我用了这个 SQL 之后,每天对账时间从两小时降到了十分钟,剩下的都是真正需要关注的问题点。

5. 遇到的坑和排查方法

5.1 把“单据”当“事件”的代价

我第一次落地 REA 时,把订单创建也当成事件写进去了。后来发现订单创建并不产生任何资源增减,它只是一个业务意图,支付完成才算有事件。意图类数据写进事件表后,表里被大量无效数据塞满,真正要追溯的净流向反而被污染。

后来我把事件分成两类:核心经济事件和辅助业务事件。核心经济事件必须有事件行,辅助业务事件可以没有资源行,但必须打上标记,默认查询时过滤掉。草稿、作废状态、用户点击日志,这些不要写进事件表,停留在业务库自己的单据表里就好。

5.2 资源增减方向为什么会搞反

退款事件是个典型。对 SKU 来说,退款是资源流入;但对资金账户来说,退款是资金流出。同一张事件行表里,一个事件会让 SKU 库存增加,却让现金余额减少,两个 resource_id 对应的 quantity 一正一负。

如果团队没有统一约定,迟早有人写出一条“退款 = 资金 + 100”的记录,然后对账崩掉。所以设计之初就应该固定规则:quantity 永远代表该资源自身的增减,流入为正、流出为负。退款场景下,针对 SKU 是正数,针对资金账户是负数,两者不矛盾。这个正负约定要写进接口文档,并且在做代码评审时重点看。

5.3 事件流积压与查询性能优化

事件表数据量涨得快,这是 REA 必然要付的存储成本,但可以通过分区、快照、归档来缓解。我一般按月份做 MySQL 分区,查询条件里强制带 resource_id 和 occurred_at 范围,避免全表扫描。超过一年的事件转移到冷存储,线上只留热数据,冷数据只读归档。

快照频率也不用太激进,五分钟一次足够。对库存、积分这类查询,偶尔一两分钟的补偿延迟完全可接受,重点是要保证事件一定不丢不重。如果审计要求保留每一笔细节,那就热库七个月加冷库永久,查询时自动路由。

5.4 问题速查表

症状可能原因排查方向
当前库存和 WMS 对不上事件重复或漏写按 biz_id 查重复,按 event_type 分组核对条数
某资源长期为负数事件正负方向定义反了查该资源最近 100 条事件行,人工核对正负
事件表数量暴涨把意图型或日志型数据写进去了检查写事件的入口,是否只有状态变更才触发
金额分毫不差但总差一分钱小数精度不一致检查 unit 与 DECIMAL 精度是否统一
快照重建后对不上存在没有事件行的事件主表用主表 LEFT JOIN 行表,找出无行事件

这张表不仅开发能用,给数据分析同事也有用。复盘时大家按同一张表检查,沟通效率会高很多。

6. 落地时我最想强调的几条经验

做这件事之前,我一直以为“数据一致性”只能靠锁、事务、分布式协议去解决。REA 让我意识到,架构层面只要把事实和状态分离,很多问题在执行层会自然消失。这不是说不用事务和锁,而是把强一致的边界缩小到“一条事件”的写库环节,剩下查询天然可对账、可重建。

最后再分享一个落地技巧。接手旧系统时不一定推翻所有表,可以先建事件表和资源表,把老系统的存量余额生成一条“期初快照事件”,之后增量事件同步补齐,新旧双跑一段时间。等对账稳定通过后再切换,回归风险会小很多。

我在实际项目里最大的教训是,事件命名一定要统一。采购、销售、售后、财务四个团队如果各自叫法不同,事件表就形同虚设。强制使用“大写动词_下划线_名词”的规范,比如 PURCHASE_INBOUND、SALE_OUTBOUND、RETURN_INBOUND,并在接入层做枚举校验。这些看起来不起眼的细节,比选什么中间件、什么框架更影响成败。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进