ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

REA模型实战:用资源事件代理建模积分结算系统

REA模型实战:用资源事件代理建模积分结算系统 做后端和架构的同学应该都对这种场景不陌生。业务方丢过来一句话“我们要上一个积分系统买东西得积分积分能抵现。”你第一反应是建表用户表、订单表、积分表、流水表。等上线三个月财务说要对账你发现积分流水里只有加减没有“这笔积分来自哪笔订单、对应哪次消费、被谁消耗”问题一下子全出来了。今天聊的REA模型就是专门用来解决这类建模问题的。REA全称是Resource-Event-Agent资源-事件-代理是一套面向经营活动的领域建模框架不绑定任何框架和语言。这篇文章我会用一个积分结算系统的完整建模过程讲清楚REA是什么、怎么建模、怎么落到存储和查询最后把实际踩过的坑一次说清。适合正在做DDD落地、事件溯源以及账户、库存、积分类系统的开发者参考。1. REA模型认知为什么业务建模绕不开它1.1 三个要素和一个中心关系REA模型的三个核心词汇展开来说并不复杂。Resource资源凡是“有价值、可以衡量、能在业务中流动”的东西都可以归入这个范畴。现金、库存商品、积分、优惠券、产能、维修工单中的工时都是资源。判断标准很直接这个东西在业务里会不会被增加、减少、转移如果会它就是Resource而不是什么普通实体或字典表。很多同学建模时把积分当成“用户的属性”直接给用户表加一个point字段这恰恰是问题的起点。积分本质上是一种可流转、可消耗、有归属的资产应该被当成Resource来建模。Event事件是发生在资源上的经济活动。用户支付、商品出库、积分发放、优惠券核销都属于Event。它记录的是“发生了什么”而不是“现在是什么状态”。平时做CRUD的时候我们习惯把账号状态从待支付改成已支付这是状态的覆盖而在REA里我们记录的是“谁在什么时间做了什么动作”这个动作不会因为业务状态变化而被抹掉。Agent代理是参与经济活动的人、系统角色或组织。用户、供应商、平台、渠道商甚至一个负责积分过期任务的定时程序都可以是Agent。在REA模型里Agent不是简单的标签而是资源流向的归属方。每一笔资源变动都必须能回答“这个资源从哪个Agent流出流入哪个Agent”。三者的关系是一个Event至少连接两个Agent同时连接流入和流出两类Resource。为什么必须有双方因为任何一笔经济活动要成立必然存在一方资源减少、另一方资源增加。如果模型中只看到一个Agent那说明你记录的很可能不是事实而是某个操作的结果快照。1.2 用一杯咖啡看懂REA用一个最生活化的例子来拆解比如“你花20元在咖啡店买一杯咖啡”。传统建模会怎么写订单表记录商品、金额、订单状态支付表记录支付结果库存表扣减库存。看起来逻辑完整但对账时就会遇到一个经典麻烦订单显示已支付财务的银行流水里却没有这笔钱或者支付成功但订单状态没被更新你说不清是哪一步发生的、责任在哪一方。用REA模型这次交易要被拆成两个独立的经济Event。第一个Event是顾客付款。此时支付钱包这类Resource从顾客名下流出20元流入咖啡店的收款账户涉及到的Agent是顾客和咖啡店。第二个Event是咖啡交付。咖啡豆或已制作好的咖啡成品从咖啡店的库存Resource中流出1杯转入顾客名下涉及到的Agent同样是顾客和咖啡店。这里一定会有人问一笔交易为什么要拆成两个Event因为付款和交付本来就是两个独立事实它们可能不是同一时刻发生。顾客观看菜单后可能先付款再取货也可能先取走咖啡后补付钱更可能付款后商家倒闭跑路根本拿不到咖啡。如果把两者揉在一个订单Event里后续处理退款、掉单对账、库存损耗时就会非常痛苦。拆开之后“顾客付了钱但没拿到咖啡”可以定位到第一个Event已完成、第二个Event未发生“拿到咖啡但没扣库存”也可以从Event里直接查出来。这背后正是复式记账的思路每一笔资源的流入必然对应另一笔资源的流出两边都要有据可查。REA把这种会计思想搬到了软件建模层面很多看似复杂的业务按这个逻辑梳理后核心关系会非常干净。等等有人会问“我开发的是一个管理后台不是财务软件有必要这么较真吗”确实不是所有系统都必要但凡是涉及资金、库存、积分、兑换码这类“资产型”数据的模块如果没有这一层事实记录将来对账、审计、排查数据异常时代价会成倍增加。我见过太多系统因为早期省了这一步后期专门开发“审计模块”“对账系统”结果还是对不齐最后只能硬编码各种补丁。1.3 传统建模与REA建模的对照把传统CRUD式的建模和REA建模放在一起对比区别会更直观。对比维度传统建模REA建模核心数据状态字段比如订单状态不可变的经济事实流审计能力依赖操作日志经常缺失环节事件天然全链路可回溯当前余额/库存直接查字段值通过投影计算可从事件推导业务扩展需要改表结构、加字段新增事件类型旧事件不动幂等保障依赖状态校验和锁依赖事件的业务唯一性财务对账另行开发对账逻辑事件本身就是账本传统表结构像给系统拍了一张又一张快照我们能看到的永远是当前状态REA更像一段完整录像任何时间点的业务都可以被还原。数据库字段里那个“订单状态已完成”更像一张抓拍照片而REA事件流里的“下单、支付、出仓、签收”是连续的事实序列。对需要追溯责任的业务来说录像比照片可靠得多。还不止如此。传统建模中当业务需求增加“积分可以跨订单合并使用”的时候你要么给积分表加一大堆关联字段要么再写一套合并逻辑。但在REA里积分合并本质上是一笔资源重组Event把两个积分账户的资源结余转移到一个新账户事件不改变旧积分账户的事实只是做一个新事件。这种扩展方式对新老逻辑都比较温和。1.4 REA与DDD、事件溯源的真实关系DDD领域驱动设计这么多年都很火但很多团队落地时卡在同一个问题上通用语言怎么定义聚合根怎么划分领域事件到底应该记录哪些内容REA恰好给了一套可落地的词法规范。DDD的“领域事件”在REA里对应的是真正的经济Event而不是系统的每一步操作。比如“用户点击了积分明细页”这种操作不算Event因为它不涉及任何Resource的流入流出“用户用100积分兑换了一张5元优惠券”才是Event因为它涉及两个Agent用户和平台、两类Resource积分和优惠券。如果你的团队在做事件风暴时总是吵不清楚哪些是领域事件引入REA的“双Agent、双Resource”校验一下子就能过滤掉大量噪音。事件溯源Event Sourcing落地时最常见的问题也是事件库乱。有些团队把“状态变更”都当成事件存进去结果事件流里鱼龙混杂重放时根本还原不出业务。REA的约束正好解决了这个问题事件必须关联资源、必须关联双向代理、必须描述经济活动。不符合这三条的就不应该进事件库。所以REA不是DDD的替代品也不是事件溯源框架的前置依赖而是它们之间的粘合剂。我个人的习惯是先用REA梳理领域词表再做DDD的聚合设计最后用事件溯源来落地。顺序对了后面会顺很多。2. 实操建模用REA给积分结算系统搭骨架2.1 从一个不过分的模拟项目开始为了把REA落地过程讲得具体我假设自己正在为某社区团购平台设计一套结算模块业务规则一共有四条用户可以用微信充值获得平台余额用户在商城下单时用余额支付同时按支付金额的一定比例获得积分用户可以用积分兑换优惠券优惠券在下一次购买时核销。需要注意的是充值渠道和商品提供方是外部角色平台本身也需要作为Agent进入模型。拿到这个需求第一步不是写代码也不是画架构图而是列事件清单。我把所有可能发生的经济活动写下来用户发起充值、充值成功到账、用户下单、支付成功、订单完成出库、积分发放、积分兑换优惠券、优惠券核销。事件清单不需要一次就完美后续随业务梳理不断补充但第一版必须包含业务方最关心的几件事尤其是钱和商品的流转。第二步对每个事件确认它涉及的Resource和Agent。这里直接给出我梳理后的关系表这个表本质上会成为后面领域模型和数据库设计的基础事件流入Resource流出Resource涉及Agent充值成功余额用户外部资金平台用户、平台支付成功下单商品货权用户余额用户用户、平台订单完成出库商品库存平台商品在库平台平台、供应商积分发放积分用户积分池平台用户、平台积分兑换优惠券优惠券用户积分用户用户、平台优惠券核销优惠券核销记录平台优惠券用户用户、平台注意这里“商品货权用户”与“商品在库平台”是两个不同的Resource。REA模型鼓励把货权和库存分开看待因为用户下单后货权虽然已经转移给用户但商品仍然在平台仓管里直到出库才减少库存。很多库存对不上的问题正是因为把货权转移和库存减少混为一谈。这个点在实际做电商系统时非常重要。第三步确定聚合边界。我的方案是把“经济事件”作为独立的不可变聚合根每个Agent的每个Resource类型对应一个资源账户聚合比如UserAccount聚合里包含用户的余额账户和积分账户平台的资产品类账户单独再建一个聚合。事件聚合负责产生事实账户聚合负责校验和投影两者职责分离不混在一起。2.2 领域模型代码骨架事件与账户分离按照上面的设计核心代码可以拆成三层。Resource层比较简单它包含一个ResourceType和一个数量字段。Event层承担事实记录必须不可变。Agent层负责资源账户的承载。我用接近Java的伪代码把它写出来public class ResourceAccount { private AgentId agentId; private ResourceType resourceType; private long amount; // 以最小单位存储比如“分” private int version; public void apply(EconomicEvent event) { // 先校验后变更 } } public class EconomicEvent { private EventId eventId; private EventType eventType; private Instant occurredAt; private ListEntry entries; public static class Entry { private AgentId agentId; private ResourceType resourceType; private long amount; // 流入记正数流出记负数 private EntryDirection direction; } } public class Agent { private AgentId agentId; private AgentType agentType; private String name; }这里最核心的是EconomicEvent中的Entry列表。一个事件至少要有两条Entry一条表示某Agent的某个Resource增加另一条表示另一个Agent的某个Resource减少。两条Entry之间满足金额守恒。比如充值成功事件一条Entry是用户余额10000分另一条Entry是平台外部资金-10000分。两条金额加起来为0这个事件的完整性就初步得到保证。金额守恒看起来像会计学概念但它对系统质量的作用非常直接。在传统模型里一个不经意的小Bug可能导致用户余额凭空多出1分钱而在REA模型里事件本身自检就能发现不平衡测试用例也更容易写。我经常说REA等于把会计的“借贷平衡”内置到了业务系统的核心数据结构里。当然代码只是骨架真正能不能落地还取决于底层存储和并发控制这部分放到第3章细说。2.3 为什么资源单位必须是最小单位这个坑我踩过必须单独拿出来讲。早期做一个优惠券计算功能时我用浮点数表示折扣比如0.88折结果计算过程中出现了0.30000000000004这类连自己都看不懂的数字后面所有对账全部错位。这不是REA特有的问题而是所有涉及金额、积分、数量类业务都容易犯的错。在REA建模中所有Resource的数量都必须选择整数类型以最小不可分割单位存储。金额以“分”存储积分以1个为单位库存以“件”为单位。打折、汇率换算这些计算放到入账之前处理一旦进入Resource账户全部是整数。这个设计同时解决了两个问题第一浮点数误差从源头消失第二审计时看到的每一笔事件都是完整的业务事实不会出现三条小数记账记录加起来不等于一条原始记录的情况。另外Resource的流出方向我建议用direction字段而不是简单的正负号。正负号只有两个方向但业务里还有“冻结”“解冻”“冲正”等状态只用正负号表达很容易把数量与状态混淆。库存不足时如果系统错误地产生一个负库存光看数据根本判断不出异常但如果direction明确标记为“冻结失败”异常就能被精准定位。2.4 事件不可变与冲正机制很多同学第一次接触REA时会问如果事件写错了怎么办用户的余额被我用一个错误事件扣掉了难道不能改掉那条事件吗答案是原始事件永远不能改只能新增一个“冲正事件”来纠正。比如充值事件本来是100元结果系统录入成了1000元你不能去修改那条充值事件而是要生成一条“充值冲正”事件把多出的900元从用户余额里扣回来并关联到被冲正的原事件ID。这个规则看上去死板却保住了整个系统的底线。一旦事件可修改之前所有基于事件的审计、重放、对账逻辑都会失去信任基础。金融系统里为什么要求“记账凭证不可涂改”因为凭证一旦可改账本就没有意义了。REA在这个层面就是数字化时代的记账凭证。事件不可变的另一个好处是可以在多系统之间可靠地同步数据。下游系统只需要按事件ID增量拉取不需要关心上游是否修改过数据即使下游处理失败重新消费幂等性也可以通过事件唯一ID保证。这是事件驱动架构中一个很实用的基础设计。3. 从模型到落地事件存储、快照与查询投影3.1 事件存储表结构怎么设计模型最终要落到存储。在数据量不大时完全可以用普通关系型数据库来实现事件存储而不必一上来就引入专门的事件溯源中间件。我用PostgreSQL风格的表结构来说明但换成MySQL也成立。核心就两张表一张保存事件流元数据一张保存每个具体事件的数据。CREATE TABLE event_streams ( id BIGSERIAL PRIMARY KEY, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, version INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (aggregate_type, aggregate_id, version) ); CREATE TABLE events ( id BIGSERIAL PRIMARY KEY, stream_id BIGINT NOT NULL REFERENCES event_streams(id), version INT NOT NULL, event_type VARCHAR(128) NOT NULL, event_id VARCHAR(64) NOT NULL UNIQUE, payload JSONB NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() );这里的event_streams表代表一个聚合的版本序列。version字段是递增的整数每次追加新事件时必须指定基于哪个version写入。如果两个并发请求都用version3去追加下一个事件数据库的唯一约束就会让其中一个失败冲突在源头被拦下来。events表的event_id是这个事件的业务唯一ID不是自增主键。比如支付成功事件的事件ID就是支付流水号充值冲正事件的事件ID就是冲正单号。加上唯一约束后即使消费者重复投递消息数据库也会把重复事件挡在外面这是幂等性的最后一道保险。我见过不少团队把事件和业务数据混在一起事件表里既存原始事实又存当时算出来的余额结果重放时数据结构混乱。记得原则Event只存事实数据比如数量、单价、关联订单号所有可推导的余额、库存、状态全部放投影不要放事件。3.2 快照策略与应用恢复事件存储解决了事实保存但性能上不能无限依赖“从头重放所有事件”。假设一个用户账户有10万条积分流水每次加载账户状态都要重放10万次系统必然扛不住。这时候需要快照。快照就是聚合在某个版本时刻的完整存档。恢复时只从最近的快照开始重放后续事件而不是从零开始。比如用户账户已经发生10万条事件系统每100条生成一个快照那么恢复时最多重放100条性能得到了数量级提升。快照的生成策略要兼顾实时性和成本。我常用的方案是在追加事件的事务里判断当前版本号是否达到“上次快照版本100”如果达到了就在同一事务里把聚合状态序列化后写入快照表。快照也可以配合定时任务做但要注意版本对齐避免快照版本与事件版本不一致导致重放重复或缺失。CREATE TABLE snapshots ( stream_id BIGINT NOT NULL REFERENCES event_streams(id), version INT NOT NULL, state JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (stream_id, version) );读取聚合状态时先取版本号最大的快照再加载该版本之后的所有事件顺序重放得到最新状态。快照作为优化手段不能取代事件本身如果快照损坏最多重建一个空聚合重放全量事件不影响数据正确性。3.3 查询投影别拿事件库当查询库事件库和普通业务库的查询特性完全不一样。事件表的写入是追加式没有任何update拿它来做“查询用户当前余额”这类高频业务请求非常不适合。正确做法是建立查询投影用一份独立的读模型来服务查询。还是说积分系统的例子。我可以建一张账户余额读模型表专门用来支撑业务查询CREATE TABLE account_balance_read_model ( user_id VARCHAR(64) PRIMARY KEY, balance_amount BIGINT NOT NULL, total_points BIGINT NOT NULL, coupon_count INT NOT NULL, updated_at TIMESTAMPTZ NOT NULL );这张表的数据不直接写入业务逻辑而是由事件订阅者异步更新。充值成功、支付成功、积分兑换等事件发布后订阅者收到消息重新计算该用户的最新余额和积分然后更新表中的记录。查询接口只读这张表响应速度很快。这个写模型与读模型分离的思路本质上就是CQRS。REA和CQRS天然合拍REA负责把写侧变成一串不可变的事实CQRS负责把读侧变成面向业务场景的投影。在写侧我们强调一致性、完整性和审计能力在读侧我们强调查询性能和响应速度。两者互不拖累。需要注意的是投影更新存在“最终一致”窗口事件写入后到读模型更新完成前用户可能看到旧数据。对大多数积分、余额业务来说这个时间差可以接受如果业务要求强一致可以在同一个事务里同时更新事件表和投影表代价是写入延迟升高。我刚落地REA时只顾着保证事件一致性忘了设计读模型结果上线后接口响应慢得没法看后来补了投影表才解决问题。3.4 幂等、并发控制与重试事件驱动系统里的幂等性远比单体CRUD里需要考虑得多。同一个支付成功事件可能因为消息中间件重投、下游服务重启等原因被处理两次。如果没有幂等保护用户的300元可能被入账两次积分翻倍优惠券重复发放。幂等的第一道防线在事件唯一键上。我用支付回调里的平台流水号作为eventId数据库对eventId加唯一索引重复插入必然失败。第二道防线在账户聚合的版本号上。每个账户记录当前version更新时必须携带期望version如果version对不上说明有其他事件已经先入账当前请求需要重新加载最新状态再合并计算。这里给出一个典型的写入流程伪代码public void processPayment(PaymentReceivedEvent event) { for (int retry 0; retry 3; retry) { AgentAccount account accountRepository.load(userId); if (eventStore.exists(event.getEventId())) { return; // 已经处理过直接忽略 } try { account.apply(event); accountRepository.save(account, account.getVersion()); eventStore.append(event); return; } catch (VersionConflictException ex) { // 说明有并发事件先写入重新加载重试 } } throw new RecoverableException(concurrent payment processing); }这个写法里事件唯一键保证了重复消息不会入账版本冲突保证了并发更新不会互相覆盖。我实际测试过即使两个线程同时收到同一事件最终也只有一个线程能成功写入另一个会在下一轮重试时发现事件已存在并退出。这套机制简单可靠比分布式锁轻量很多。4. 复盘避坑REA落地时最常踩的6个坑4.1 坑一把派生数据混进事件里这是REA建模中最典型的错误几乎每个初学的人都会犯。有人设计“用户支付”事件时除了记录支付金额、订单号还会加一个字段叫“支付后余额”目的是查询方便。结果事件溯源重放到最后一笔时发现余额和真实余额差了10元。为什么因为早期的某条事件里的“支付后余额”算错了而这个错误在之后的N条事件里被当成基础数据继续叠加。正确做法是事件只记录业务事实支付了多少钱、来自哪个订单、发生于何时。用户支付后的余额是多少由投影计算得出不能写进事件。把这个原则记住你的事件流就能永远保持纯净和可重放。4.2 坑二漏掉系统自动任务的Agent身份有一类特殊Agent特别容易被遗漏——系统本身。比如平台规定“90天未使用的积分自动过期”这个定时任务每天扫描积分账户并执行过期操作。很多建模者觉得它不是人就不给它Agent身份。结果积分过期后业务审计想查“谁的积分被清了、为什么被清”时事件流里只有一个资源减少记录没有任何Agent承担责任。正确做法是把“积分过期服务”或者“系统管理Agent”纳入模型给它分配一个AgentId。事件里的双向Agent就变成了“用户”和“系统管理Agent”。这样审计时才能回答这笔积分是用户主动兑换掉还是被规则自动清理的规则归属哪个系统服务。4.3 坑三滥用浮点数处理金额和积分这个坑前面已经提过但值得放进避坑清单里再次强调。任何Resource的入账和出账都使用以最小单位为基准的整数。打折、汇率、税率等计算全部放在事件入账之前计算结果必须四舍五入到最小单位后再进入事件。如果某天计算结果出现0.1个积分业务上要么向下取整要么单独生成一个“零头积分”的Resource绝不能让一个小数出现在账户流水里。4.4 坑四事件表膨胀查询性能失控事件存储的量级和普通业务表完全不一样。一个用户一年可能产生上千条积分流水一个平台一年就是上亿条。如果不做任何优化任何一次加载聚合状态都要扫描几十万条数据系统会卡到没法用。解决方案有分层第一层是快照把上千条事件压缩到最多回放100条第二层是归档把超过3个月的旧事件归档到冷存储业务查询走投影表只在审计、对账、纠纷处理时查冷数据第三层投影表本身也要定期清理和压缩避免重复投影数据越来越多。记住一条事件库是“真相”投影模型才是“读接口”不要让用户请求直接亲吻事件库。4.5 坑五团队不认可新模型方案被喷成过度设计REA落地最大的阻力往往不是技术而是团队协作。业务方会觉得你绕了一大圈明明一张订单表就能解决的问题你非要拆成几个事件和账户开发同事会觉得这套模型复杂要学新的概念还要写一套以前没写过的代码。我的建议是不要试图一次性全面铺开。先挑一个最痛的点比如财务月结算对不上账、积分明细查不清用REA做一个影子模块和旧系统并行跑一段时间用报表比对新旧模型的结果。当数据证明旧模型有缺失、新模型能精准还原后团队自然会接受。技术方案存在的最终目的是解决问题不是证明谁更高级。如果想靠“这个模型很经典很权威”来说服团队阻力只会更大。4.6 坑六把REA当成万能银弹最后一条坑是反过来走向极端什么系统都往REA上套。一个简单的用户配置管理系统只有查询和修改既不涉及资源流转也不需要审计对账硬套REA只会增加无意义的复杂度。判断是否该用REA的标准很简单业务里是否存在多方资源流转、是否存在审计追溯诉求、当前状态是否依赖历史事实推导如果三个都否普通CRUD可能就够用了。我自己的经验是REA最适合的场景是“用户持有资产型数据”的业务钱包、积分、优惠券、库存、配额。这类业务中的数据不仅是一个字段更是一种有生命周期、有归属方、可流转的资产。判断清楚再上模型会给你带来巨大回报判断不清楚硬上就会变成过度设计。最后把六个坑整理成速查表方便排查时对照坑位问题表现解决思路派生入事件重放结果与真实余额不一致事件只记录原始事实漏掉系统Agent自动过期等操作无责任方补建系统Agent浮点数量计算误差导致对账失败全部使用整数最小单位查询撞事件库响应越来越慢用快照投影归档团队阻力方案被质疑影子运行用数据说话过度设计简单CRUD也套REA先评估业务是否匹配写在最后一个实用体会。我最早接触REA时也觉得一个订单要拆成好几个事件实在太绕。直到后来一次对账财务要我说清楚“某用户1月5日20点03分用余额买了一箱牛奶之后余额去哪了、积分发了多少、是否存在资金漏洞”传统模型里的订单状态和流水表根本理不清是REA帮助我从事件流里逐条还原把资金链路完整讲了出来。从那以后凡是涉及钱、库存、积分这类模块我都会先问自己一句我建的是状态还是事实如果只能给一条建议那就是在建模第一周先用Excel把所有事件、资源流入流出、Agent列成一张大表把关系彻底捋顺了再写代码。这个笨办法比画100张架构图都有效。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表