☰
REA业务建模:用事件溯源重构订单库存与对账系统
2026/10/21 4:53:31 网站建设 项目流程

如果把“rea”这三个字母丢给一个长期做业务系统的工程师,他大概率会愣一下:是某个内部项目的缩写?还是某个框架的版本号?但如果我说,这是“Resources–Events–Agents”的简称,是会计学里“双重记账”思想在软件建模中的一次迁移,很多做过电商、供应链、财务系统的人就会一拍大腿:原来是这个。

这篇文章就把“rea”当作一个业务建模框架来聊。它不是某个中间件,也不是某种编程语言,而是一种看业务的方式:什么东西在变化、什么动作触发了变化、谁有资格触发变化。把这套思路用到系统设计里,订单超卖、库存对不上、对账靠人肉这种老问题,会有完全不同的解法。适合正在做订单、库存、账户、积分、优惠券这类“状态敏感型”系统的开发同学,也适合产品经理和数据工程师读——只要你的业务里有“余额”“数量”“状态”这种词,REA就能帮上忙。

1. “rea”三个字母,看成一套业务建模思路

1.1 从记账逻辑说起:Resources–Events–Agents

REA最早不是软件行业的东西,它来自会计领域。会计记账有个基本原则:每一笔账都要有来龙去脉,不能只记一个结果。今天库存少了5件,为什么少了?因为卖了一单。卖给了谁?某个客户。收了多少现金?款项进到了哪个账户?这三个问题,恰好对应REA的三大核心要素:

  • Resource(资源):有价值、可识别、可计量的东西。货物、现金、库存、积分、优惠券额度,都是资源。
  • Event(事件):让资源发生转移、消耗、生产的事实。下单、支付、发货、退款、入库,都是事件。
  • Agent(代理):发起事件或参与事件的个人、组织、系统。买家、卖家、仓库、支付渠道,都是代理。

用一句话概括:代理通过事件改变资源的归属或状态。卖出一件商品,就是“买家”和“卖家”两个代理,通过“销售”这个事件,把“商品”这件资源从卖家名下转移到买家名下。这个动作如果只记录“库存减1”,那就丢了“谁”“何时”“因为什么”这三个关键维度。REA要求你把“变化发生的那一刻”完整留下来,而不是只留下变化后的结果。

这里有个很容易混淆的点:REA里的“资源”不是数据库里一张叫资源表的记录,而是业务上真正有价值的东西。一次下单动作本身不是资源,但下单后产生的“待支付订单”才是资源;一次点击不是资源,但点击后消耗的“抽奖次数”才是资源。判断标准就一条:它是否可以被消耗、被转移、被生产。能,就是资源。

1.2 为什么传统CRUD设计在复杂业务里越改越累

传统业务系统最常用的建模方式是CRUD——为每一个业务概念建一张表,状态变了就直接UPDATE。库存表有一列quantity,下单就UPDATE stock SET quantity = quantity - 1。这个方案在小规模业务里没问题,但业务复杂起来以后,坑会一个接一个地出现。

第一个坑是“不知道当前状态是怎么来的”。库存变成0了,是卖完了、被锁定、还是被异常扣减?如果没有事件记录,只能靠日志猜。第二个坑是“并发更新会丢数据”。两个人同时下单,两个事务同时读到库存5,各减1,各自写回4——库存明明是5,卖了两单应该剩3,结果变成4,这就是超卖。加锁能解决一部分问题,但锁的范围、粒度、超时都要设计,越往后越复杂。第三个坑是“状态字段越来越多”。订单表里搞一个status还嫌不够,又加payment_status、refund_status、shipment_status,每个状态之间什么顺序、什么约束,全靠代码里if-else硬扛。

REA换了一个思路:不存“当前状态”,只存“发生过的事实”。当前库存是100还是200,不重要;重要的是过去发生了哪些入库、哪些出库、哪些锁定。当前状态随时可以从事件流里重新算出来。这带来一个质变:状态变成了推导结果,而不是存储内容。于是并发更新从“改一个数字”变成了“追加一条记录”,追加天然有先后顺序,追加不会覆盖历史,自然就不会丢更新。

1.3 REA与事件溯源的关系:它们不是一件事,但天然合拍

很多人把REA和Event Sourcing(事件溯源)混在一起。严格说,REA是分析框架,事件溯源是落地技术。REA告诉你“什么值得记”,事件溯源告诉你“怎么记”。两者可以分开用——你完全可以用REA分析业务,但仍然用普通表结构存储结果;也可以用事件溯源,但事件类型设计得很混乱,根本没梳理清楚资源和代理。我的经验是,REA适合做事件溯源的“前置分析”。先想清楚系统里有哪几类资源和事件,再设计事件表、写命令处理器,思路会顺很多,事件类型也不会设计得五花八门。

2. 拆解核心元素:资源、事件、代理怎么落地

2.1 三步识别你系统里的资源、事件、代理

面对一个已有系统或者一个全新需求,怎么快速把REA三要素识别出来?我一般带着团队在白板上走三个问题:

  1. 什么在被消耗、被生产、被转移?——找出所有“会变少、会变多、会换归属”的东西。库存、余额、配额、积分、优惠券、订单金额,都是候选资源。
  2. 什么动作导致了这个变化?——找出所有“业务上不可拆分”的动作。注意,是业务上的不可拆分,不是代码上的不可拆分。支付成功是一个动作,支付中不是;收货确认是一个动作,物流轨迹更新是流程节点,不一定都是事件。
  3. 谁有资格发起这个动作?——找出个人、组织、系统角色。买家和卖家是代理,仓库和客服也是代理。有些事件没有显式的个人参与,比如“定时任务自动释放未支付订单”,这里的代理就是系统本身。

这三个问题的答案不一定一次就完整。我常用的做法是先列资源,再列事件,最后补代理。因为资源最直观——打开数据库,看看哪些表里存的是“数量”“金额”“状态”,基本就是资源了。事件次之,需要跟业务方盘流程。代理相对好找,因为系统里一般都有用户、角色、组织这类概念。

2.2 事件的粒度:一次下单是几个事件?

这是REA落地时争论最多的点。一次“用户下单”,应该记成一个事件,还是拆成“OrderCreated”“StockLocked”“CouponApplied”多个事件?我的建议是:一个业务事实一个事件,不要贪多,也不要漏关键改动。

一个业务事实,是指“业务上必须原子成立、并且能独立说明一个变化的动作”。用户下单这个动作,业务上确实是一个原子操作:订单创建、库存锁定、优惠券核销同时发生,任何一个失败都不应该让其他两个生效。但在事件设计上,我仍然会把它们拆成不同事件,只是放在同一个事务里追加。为什么拆?因为它们的资源不同:OrderCreated改的是“订单”资源的状态,StockLocked改的是“库存”资源,CouponApplied改的是“优惠券”资源。如果混成一个OrderPlaced大事件,后续做库存分析、优惠券核销分析时,就得反复解析同一个事件的payload,查询和投影都很别扭。

反过来,有些动作看起来能拆,但拆了会破坏业务语义。比如“支付成功”,它至少涉及支付单、订单、账户余额三个资源。但业务上,“支付成功”是一个不可否认的事实,你不可能先记“支付单状态变更”,再等一会儿记“订单已支付”。所以我会以“支付成功”为统一事件,payload里带上涉及的订单号、支付单号、金额、支付渠道,再由投影逻辑去更新不同资源的读模型。

2.3 代理的边界与权限:谁有资格发起事件

代理这块经常被忽略,但它恰恰是“事件可信度”的来源。一个“库存增加”事件,如果可以被任意代理发起,那这个系统就没什么可靠性可言。所以设计事件时,每个事件必须明确它的合法发起方——是买家、卖家、仓库管理员、支付回调、还是定时任务。这不是数据库权限的问题,而是业务规则的问题。

一个容易踩坑的地方是“系统代用户发起事件”。比如用户点击“确认收货”,前端调接口时传了用户ID,但实际执行动作的是后端服务。在REA里,这个事件的代理应该记成“买家”,因为业务责任的归属在买家;但技术上要区分“操作人”和“执行人”。我的做法是在事件表的metadata里同时记两个字段:agent_id(业务代理,比如买家)和operator_id(实际操作者,比如后台管理员或者系统任务)。这样既不破坏业务语义,又能追溯技术责任。代理信息一旦漏了,后面做权限审计、责任界定、数据分析都会很痛苦。

2.4 建模中的四个常见误区

第一个误区:把状态当资源。订单状态“已支付”“已发货”不是资源,因为状态本身没有价值,不会转让也不会消耗。真正参与REA循环的是“订单”“支付单”“发货单”这些实打实的业务对象,状态只是它们在不同阶段的表现。

第二个误区:把查询当事件。用户浏览了商品详情页,这不是事件,因为浏览没有改变任何资源的状态。如果要统计流量,那是另外一套日志系统的事,不应该混进业务事件流里。判断标准还是那句话:这个动作会让某个资源变多、变少、换归属吗?不会,就不算。

第三个误区:把外部回调结果直接当事件。支付回调说“支付成功”,这本身是一个事实,可以记事件;但回调里还可能带一些奇怪的附加信息,比如“用户使用了红包”“用户所在地区是华东”。这些是上下文信息,不是事件本身。我习惯把它们放在metadata里,而不是payload里,防止后续解析核心事件时被噪音干扰。

第四个误区:忽视资源的主权归属。同一个资源只能由一个聚合根负责更新。比如库存,不能由订单服务和库存服务同时各写各的。REA要求每个资源的事件流是单一的,所有对它的修改都走同一个入口。一旦资源的事件流被分散到多个服务,就很难保证顺序,很难做幂等,对账自然也就乱了。

3. 实操:一个订单场景的REA化改造全过程

3.1 改造前的问题与目标

我这里用一个模拟项目来演示,背景是某跨平台订单系统的改造。这个系统的典型问题是:库存表直接UPDATE导致频繁超卖;订单状态字段散落在多个服务里,各服务状态同步靠异步消息,经常不一致;月底对账要靠导出Excel人肉比对。

改造目标很明确:把“库存”“订单余额”“优惠券核销”这几个资源的可变状态,全部改成事件流追加,再通过投影生成读模型。核心原则是——所有写操作只追加事件,不原地改状态;状态一律通过事件流重新计算或快照读取。

3.2 表结构与核心接口设计

事件存储是整套方案的底座。我用一张统一的事件表,所有资源共享,而不是每个资源一张事件表。原因很简单:对账和审计时需要按时间线跨资源查询,统一表可以一条SQL拉出“某用户在某段时间内的全部业务动作”。

CREATE TABLE event_store ( aggregate_id VARCHAR(64) NOT NULL, aggregate_type VARCHAR(32) NOT NULL, event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, seq BIGINT NOT NULL, payload JSONB NOT NULL, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMP NOT NULL DEFAULT now(), PRIMARY KEY (aggregate_id, seq), UNIQUE (event_id) ); CREATE INDEX idx_event_store_time ON event_store (created_at, aggregate_type);

几个字段的设计意图值得说清楚:

  • aggregate_id:资源实例的ID,比如订单号、SKU编号。同一资源的所有事件,aggregate_id相同,seq连续递增。
  • seq:版本号,用于并发控制。追加事件时,业务方必须携带“当前已读到的最大seq”,数据库层通过主键约束保证同一aggregate_id下不会产生重复seq。
  • event_id:业务生成的全局唯一ID,用于幂等。重复提交的事件,靠这个字段直接丢弃。
  • payload:事件本身的业务数据,比如“OrderCreated”事件里存订单明细。
  • metadata:环境信息,比如代理ID、操作人、来源IP。这些数据会影响追责,但不参与核心业务计算,单独放可以让payload保持纯净。

追加事件的核心接口长这样:

async function appendEvent( conn: DBConnection, agg: { id: string; type: string }, expectedSeq: number, event: { id: string; type: string; payload: object; metadata: object }, ) { await conn.query( `INSERT INTO event_store (aggregate_id, aggregate_type, event_id, event_type, seq, payload, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [agg.id, agg.type, event.id, event.type, expectedSeq + 1, event.payload, event.metadata], ); }

注意,这里没有在代码里显式加锁,并发安全完全靠数据库主键约束兜底。如果两个请求同时尝试追加seq=5,第二个INSERT会触发唯一键冲突,上层捕获冲突后重读最新事件、重新做业务校验即可。这个思路比SELECT FOR UPDATE性能更好,也更贴合事件溯源的风格。

3.3 命令处理器与事件追加:核心代码示例

有了通用的事件追加接口,业务逻辑就围绕“命令处理器”展开。命令处理器做的事情很固定:读当前事件流,校验业务规则,追加新事件。以创建订单为例:

async function handleCreateOrder(conn: DBConnection, cmd: CreateOrderCommand) { const orderId = generateOrderId(cmd.userId); const orderAgg = { id: orderId, type: 'ORDER' }; // 1. 读取当前订单聚合的最大 seq const currentSeq = await getCurrentSeq(conn, orderAgg); // 2. 校验库存:从库存读模型查可用数量 for (const item of cmd.items) { const available = await getAvailableStock(conn, item.skuId); if (available < item.quantity) { throw new BusinessError('STOCK_NOT_ENOUGH', `SKU ${item.skuId} 库存不足`); } } // 3. 在同一个事务里追加多个事件 await withTransaction(conn, async (tx) => { await appendEvent(tx, orderAgg, currentSeq, { id: uuid(), type: 'OrderCreated', payload: { userId: cmd.userId, items: cmd.items, orderAmount: cmd.amount }, metadata: { agentId: cmd.userId, operatorId: cmd.operatorId }, }); for (const item of cmd.items) { const stockAgg = { id: item.skuId, type: 'SKU' }; const stockSeq = await getCurrentSeq(tx, stockAgg); await appendEvent(tx, stockAgg, stockSeq, { id: uuid(), type: 'StockLocked', payload: { orderId, skuId: item.skuId, quantity: item.quantity }, metadata: { agentId: cmd.userId, operatorId: cmd.operatorId }, }); } }); }

这里有个细节:订单事件和库存事件在同一个数据库事务里追加,保证“订单创建”和“库存锁定”要么都成功,要么都不成功。如果为了性能把订单和库存拆到两个服务里,那就必须引入分布式事务或者Saga补偿,复杂度会上一个台阶。我的建议是,在没有强性能瓶颈的情况下,先把它们放在同一个事务里,系统演进到必须拆分时再想补偿方案。

支付成功的处理器类似,但追加的事件类型不同:

async function handlePaymentSucceeded( conn: DBConnection, cmd: PaymentSucceededCommand, ) { const orderAgg = { id: cmd.orderId, type: 'ORDER' }; await withTransaction(conn, async (tx) => { const orderSeq = await getCurrentSeq(tx, orderAgg); // 校验订单状态:必须处于待支付状态 const order = await buildOrderView(tx, cmd.orderId); if (order.status !== 'PENDING_PAYMENT') { throw new BusinessError('ORDER_STATUS_INVALID', '订单不在待支付状态'); } await appendEvent(tx, orderAgg, orderSeq, { id: uuid(), type: 'OrderPaid', payload: { orderId: cmd.orderId, amount: cmd.amount, paymentId: cmd.paymentId }, metadata: { agentId: cmd.userId, operatorId: cmd.system }, }); const accountAgg = { id: cmd.merchantAccountId, type: 'ACCOUNT' }; const accountSeq = await getCurrentSeq(tx, accountAgg); await appendEvent(tx, accountAgg, accountSeq, { id: uuid(), type: 'AccountCredited', payload: { amount: cmd.amount, orderId: cmd.orderId, paymentId: cmd.paymentId }, metadata: { agentId: cmd.merchantId, operatorId: cmd.system }, }); }); }

这个例子里的两个事件——OrderPaid和AccountCredited——一个是订单资源的“完成支付”,一个是账户资源的“入账”。它们在同一事务里,从业务语义上是同一个“支付成功”事实的投影。

3.4 读模型与快照:怎样得到“当前状态”

事件流只存事实,不存状态。那查询“当前库存是多少”怎么办?两个办法:实时扫描事件流计算,或者维护一个读模型。

实时计算在事件量少的时候可行,事件量大了以后性能撑不住。所以实际项目中几乎都会维护读模型。读模型的更新逻辑是通过订阅事件流做的,每来一个事件就更新对应的投影表。以库存为例:

function applyStockEvent(currentStock: number, event: StockEvent): number { switch (event.eventType) { case 'StockIn': return currentStock + event.payload.quantity; case 'StockLocked': return currentStock - event.payload.quantity; case 'StockReleased': return currentStock + event.payload.quantity; case 'StockOut': return currentStock; // 出库时库存已在锁定时扣减,这里不重复扣 default: return currentStock; } }

读模型的存储很简单,一张SKU表,一个available_quantity字段。订单一创建,库存锁定事件被投影,available_quantity同步减掉。库存表本身不做UPDATE扣减,所以超卖问题从机制上被消灭了。查询当前库存,走的是读模型表,速度很快,不需要扫描事件表。

投影还有一个重要好处:它可以“重建”。如果投影代码出了bug,把读模型的数据搞坏了,不需要修复原数据,只需要从事件流重新执行一遍投影逻辑,就能生成一份完整的新读模型。这个能力在传统CRUD系统里很难实现——数据一旦被错误更新,只能靠备份恢复或者手工改数。

3.5 对账逻辑:事件流如何替代月末人工核对

对账是传统业务系统最痛苦的事情之一。订单系统说已经发了货,库存系统说还没扣,财务系统说钱没到账,三个系统各执一词,最后人肉核对Excel。REA加事件溯源之后,对账变成了一件很自然的事:所有系统共享同一个底层事实流,只是从不同角度投影。

举个例子,财务对账只需要这样扫描事件:

async function reconcile(conn: DBConnection, day: string) { const events = await conn.query( `SELECT * FROM event_store WHERE event_type = 'AccountCredited' AND created_at >= $1 AND created_at < $2 ORDER BY seq`, [day + ' 00:00:00', day + ' 23:59:59'], ); let totalAmount = 0; for (const event of events.rows) { totalAmount += Number(event.payload.amount); } // 与支付渠道结算单核对 const settlement = await getChannelSettlement(day); if (totalAmount !== settlement.amount) { logReconcileDiff(day, totalAmount, settlement.amount); } }

这里的优势在于:AccountCredited事件是业务发生的原始事实,不是某个系统处理后的结果。只要事件在入库时经过校验和幂等处理,对账依据就是可靠且不可篡改的。对不上的地方,大概率是外部渠道的数据差异,不再是内部系统之间的互相甩锅。

4. 常见问题与排查技巧实录

4.1 事件流越积越长,怎么处理?

事件溯源最常被质疑的问题:事件表无限增长怎么办?订单三年积累了几亿条事件,查询和投影会越来越慢。我的方案是快照加归档。

快照的思路是:每隔一段时间(比如每100条事件),把当前状态计算出来存一张快照表。此后重建状态时,只需要从最近一个快照开始向后重放,不需要从头扫描。快照表本身也很简单:

CREATE TABLE aggregate_snapshot ( aggregate_id VARCHAR(64) NOT NULL, aggregate_type VARCHAR(32) NOT NULL, seq BIGINT NOT NULL, state JSONB NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now(), PRIMARY KEY (aggregate_id, seq) );

恢复状态时,先查最新的快照,再从快照的seq之后读取事件。归档则是把超过一定时限的冷事件迁移到冷存储,比如迁移到归档表或者对象存储。事件流里的数据永远不能物理删除,因为它是审计和追溯的依据。

4.2 顺序错乱、重复提交、错误事件怎么兜底?

事件乱序最常见的场景是异步消息重试。支付回调可能因为网络超时被发送两次,两个请求都到达后端,先后顺序不确定。兜底方案分三层:

第一层是幂等。事件表里event_id有唯一约束,同一事件重复插入直接被数据库拒绝。所以命令处理器在生成事件ID时,要用确定性的ID。比如支付成功事件用pay_加支付单号做事件ID,多次回调生成的事件ID相同,天然幂等。

第二层是版本控制。同一个聚合根的seq必须连续,冲突时说明有并发修改,需要重试整个命令处理器。上层捕获唯一键冲突后,重新读取事件流、重新校验业务规则、再次尝试追加。

第三层是补偿事件。如果错误事件已经写进去了怎么办?千万不要直接修改或删除历史事件。正确做法是追加一个反向事件,比如错误的StockLocked后面追加StockReleased,语义是“我锁错了,现在释放”。事件溯源的核心精神是:历史不能被改写,只能被补充。

4.3 团队接受度低:怎么把一个抽象模型讲成“流水账”

REA刚提出来时,团队里不少人觉得太抽象,不如CRUD直观。我后来发现一个很管用的类比:这就像记流水账。你不会在账本上涂改“余额”两个字,而是一笔一笔往下记,到月底用收支汇总算余额。REA就是把业务系统当成一本流水账,事件是每一笔入账出账,资源是账本里的科目,代理是记账凭证上的经手人。

用这个类比跟业务方讲,理解成本低很多。大家都有记账的生活经验,没有人会在自己的账本上反复涂改一个余额数字,而是在Excel里记录每一笔明细,用公式汇总。REA不是发明了一套新东西,只是把大家早就认同的生活常识搬进了系统设计。

4.4 排查速查表

症状可能原因处理办法
库存显示负数并发追加事件时未做版本冲突检测检查seq唯一约束和冲突重试逻辑
同一事件重复执行两次命令处理器没有生成确定性事件ID用业务唯一标识生成event_id,数据库幂等拦截
订单状态与支付记录不一致投影逻辑漏处理某个事件类型对照事件类型枚举,逐个检查投影分支
对账金额对不上事件payload里金额精度丢失金额统一用整数分存储,不做浮点运算
事件流重放结果与线上不符早期事件payload结构不兼容在投影代码里做兼容旧数据的解析分支
某个聚合根事件每次查询都全量扫描缺少快照定期生成快照,查询从最近快照开始重放

4.5 几条避坑心得

第一,不要在顺手的时候阉割metadata。初期图省事,只往事件里塞业务字段,不记录代理和操作人。等真正需要追责、做数据分析的时候,就会发现历史事件里全是残缺信息,回天乏术。每写一个事件,就想想:未来审计的人看到这条记录,能还原当时发生了什么吗?能知道是谁干的吗?

第二,投射要幂等,读模型要能重建。投影逻辑如果幂等性不好,消息重投就会导致读模型重复更新。我习惯把投影函数写成纯函数:输入当前读模型状态和一条事件,输出新状态,不产生任何副作用。这样无论是重放还是补投,结果都一样。

第三,不确定要不要拆的事件,先合并再拆。一个业务动作如果实在拿不准是记成一个大事件还是拆成多个小事件,先用大事件记,后期再在投影层拆解。反过来,如果一开始拆得太细,重建业务语义时需要把多条事件拼起来,逻辑会很难看。事件粒度宁粗勿细,是实战中比较稳妥的方向。

第四,业务校验必须在命令处理器里完成,不能依赖投影。事件追加前,该查的库存、该验的状态,一个都不能少。投影只是被动把事实反映到读模型上,它不负责业务正确性。如果校验放在投影阶段,错误的命令可能已经被持久化,虽然可以补偿,但终究是多了一次事故。

我自己的习惯是,接手任何一个新系统,先不急着看表结构,而是在白板上随手写三栏:资源、事件、代理。能把这个三栏填得满满当当、而且每个事件都能说清楚“它改变了哪个资源、由谁发起”,说明我对这个业务已经有了初步把控。如果某个模块填不满,那多半是需求还没聊透,或者系统本身设计得有问题。这套方法我用了很多年,几乎每次都管用。这就是“rea”这三个字母对我最大的价值——它不是一堆理论的缩写,而是一把梳理业务的钥匙。有了它,再看那些复杂的订单、库存、账户系统,就会觉得它们不过是一本记得整整齐齐的流水账。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询