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并在接入层做枚举校验。这些看起来不起眼的细节比选什么中间件、什么框架更影响成败。