资讯动态

REA业务建模:用事件溯源重构订单库存与对账系统

发布时间:2026/10/21 4:53:33 来源:尧图企业网站定制
如果把“rea”这三个字母丢给一个长期做业务系统的工程师他大概率会愣一下是某个内部项目的缩写还是某个框架的版本号但如果我说这是“Resources–Events–Agents”的简称是会计学里“双重记账”思想在软件建模中的一次迁移很多做过电商、供应链、财务系统的人就会一拍大腿原来是这个。这篇文章就把“rea”当作一个业务建模框架来聊。它不是某个中间件也不是某种编程语言而是一种看业务的方式什么东西在变化、什么动作触发了变化、谁有资格触发变化。把这套思路用到系统设计里订单超卖、库存对不上、对账靠人肉这种老问题会有完全不同的解法。适合正在做订单、库存、账户、积分、优惠券这类“状态敏感型”系统的开发同学也适合产品经理和数据工程师读——只要你的业务里有“余额”“数量”“状态”这种词REA就能帮上忙。1. “rea”三个字母看成一套业务建模思路1.1 从记账逻辑说起Resources–Events–AgentsREA最早不是软件行业的东西它来自会计领域。会计记账有个基本原则每一笔账都要有来龙去脉不能只记一个结果。今天库存少了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三要素识别出来我一般带着团队在白板上走三个问题什么在被消耗、被生产、被转移——找出所有“会变少、会变多、会换归属”的东西。库存、余额、配额、积分、优惠券、订单金额都是候选资源。什么动作导致了这个变化——找出所有“业务上不可拆分”的动作。注意是业务上的不可拆分不是代码上的不可拆分。支付成功是一个动作支付中不是收货确认是一个动作物流轨迹更新是流程节点不一定都是事件。谁有资格发起这个动作——找出个人、组织、系统角色。买家和卖家是代理仓库和客服也是代理。有些事件没有显式的个人参与比如“定时任务自动释放未支付订单”这里的代理就是系统本身。这三个问题的答案不一定一次就完整。我常用的做法是先列资源再列事件最后补代理。因为资源最直观——打开数据库看看哪些表里存的是“数量”“金额”“状态”基本就是资源了。事件次之需要跟业务方盘流程。代理相对好找因为系统里一般都有用户、角色、组织这类概念。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], ); }注意这里没有在代码里显式加锁并发安全完全靠数据库主键约束兜底。如果两个请求同时尝试追加seq5第二个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”这三个字母对我最大的价值——它不是一堆理论的缩写而是一把梳理业务的钥匙。有了它再看那些复杂的订单、库存、账户系统就会觉得它们不过是一本记得整整齐齐的流水账。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑