说实话我第一次认真琢磨 REA 这组字母是在一个乱成一锅粥的项目里。当时的业务方反复改口一会儿说下单要锁名额一会儿说到期名额要释放一会儿又说取消单要把钱原路退回。开发同学被带着在订单表里加列加来加去一张表膨胀到二十多个字段状态枚举从3个涨到9个可业务口径还是对不上。后来有一位架构师提了一句别老想着“订单”试试看成“谁对什么资源做了什么”。这句话直接把方向带偏着救回来了——他说的就是 REA 模型Resource资源- Event事件- Agent代理。如果你也家常便饭地被“订单状态乱飞”“对账对不上”“新需求一来就要改表结构”这类问题折磨那这篇内容应该能帮到你。我会从 REA 的核心抽象讲起用一个运动场馆预约系统从头到尾跑一遍建模流程最后再把我踩过的坑、排查过的线上问题整理成清单。这套思路适合后端开发、业务架构师也很适合做产品设计的同学拿来和研发对齐语言。1. 为什么我劝你别再死磕“订单表”很多业务系统从第一天起就把“订单”当成万能模型。用户在电商平台下单是订单预约线下服务是订单买会员卡还是订单。似乎只要有一张 order 表再配上 status 字段就能吃遍所有业务。但项目跑一两年之后这张表就会变成全公司最危险的“公共厕所”。1.1 传统订单表在膨胀什么订单表通常经历三个阶段的变化。第一阶段只有基础下单和支付表结构很干净商品、数量、金额、收货信息、支付状态。第二阶段业务增加退款、优惠券分摊、售后研发开始往里加 refund_status、coupon_amount、after_sale_type。第三阶段业务要支持预约、改签、超时释放研发又把 appointment_time、expired_at、lock_status 塞进去。表面上看是字段多了实质上问题更严重表结构在替“流程”和“规则”背锅。每一条新规则都被翻译成一对新的列或一个更长的状态枚举最后导致任何一张报表都要靠一大串状态排列组合来推断业务事实。1.2 状态机不是银弹有人会说我们把状态机梳理清楚不就行了这话对了一半。状态机的确能让代码可控但状态机本身解决的是“当前状态是什么”并没有回答“这个状态是怎么来的”。举个例子一个订单显示“已取消”这到底是用户主动取消的、系统超时取消的还是客服后台强制取消的三种路径可能都会落到同一个“已取消”状态。于是问责和稽核的时候大家只能靠操作日志去猜。日志一多链路一断事情就说不清楚了。这背后的真实矛盾是业务关心“发生了什么事”而传统表结构只存“当前长啥样”。REA 直接把这个问题翻转过来把“事件”本身当成存续的核心对象状态只是事件流的结果随时可以被重新算出来。1.3 REA 的核心主张REA 模型最早出现在会计信息系统领域当时是为了解决一个很基础的问题怎么把企业的经济业务记清楚。它的主张非常朴素业务中真正重要的不是“单据”而是单据背后“有价值的东西在变”。每一次资源变动都由一个明确的事件触发。任何事件都绕不开两个 Agent谁发起的、对谁发生的。所以它天然适合用来拆解那些容易纠缠不清的业务语义库存、名额、余额、积分、优惠券本质上都是 Resource预约、扣减、释放、核销、充值本质上都是 Event用户、客服、运营、系统本身都是 Agent。一旦大家用这套语言沟通订单表退居其次变成事件投影出来的“读模型”很多争论就不攻自破。2. Resource - Event - Agent 到底各管什么如果你想在团队里推广 REA不要一上来就贴学术概念。我习惯先用一个“打比方”的方式讲清楚把业务系统当成一套记账系统。2.1 Resource业务里真正“有价值”的东西凡是会被业务影响、能被计量、能在某个时刻增加或减少的东西都可以建模成 Resource。在一个场馆预约系统里Resource 可能是“一个可预约的时段”“一张月卡里的剩余次数”“账户里的余额”。在一个电商系统里Resource 就是库存、钱、优惠券。Resource 有一个重要特征它自带数量与归属。例如时段资源总量是固定的被预约一个就少一个账户余额归属到某个用户充值才增加消费和退款才减少。把资源建模清楚了天然就能把库存和金额两件事统一起来看而不是在订单表和库存流水表里两头找补。2.2 Event系统里最该被完整记录的“发生了什么”事件是 REA 的灵魂。常见的设计错误是只管结果不管过程。比如用户支付成功了我们会在订单表写一句“已支付”但如果没有 payment_event后面做退款、对账、索赔时就只能望着金额发呆。事件的描述应当完整回答四件事什么时间、谁参与、涉及哪些资源、引起资源如何变化。同一个业务来回的多次资源变动应该拆成多个事件而不是在一个事件里全部包办。甚至同一个语义在不同规则下也可以落成不同事件比如“锁定名额”和“永久核销名额”虽然都让可用名额减少但前者是可逆的后者是不可逆的建模时就要分开。2.3 Agent谁是业务动作的责任人Agent 指的是“谁发生了这件事”。这里最常见的错误是Agent 只被理解成“用户”。但真实系统里有大量动作并非用户直接触发运营在后台给新用户送优惠券客服手动对异常订单做补偿系统定时任务批量释放逾期名额。这些动作都由不同的 Agent 发出如果不记清楚后面审计和权限排查就无从谈起。所以我通常会把 Agent 分成内部 Agent 和外部 Agent 两类。外部 Agent 包括 C 端用户、供应商、商户内部 Agent 包括客服、运营、系统任务。在设计表结构时不单要记 agent_id还要记 agent_type这样同一套模型才能容纳人工操作和自动任务。2.4 三者关系把三者拼起来业务语义就完整了。一个用户Agent发起支付事件Event导致账户余额Resource减少、订单待结算余额Resource增加一个系统任务Agent执行释放事件Event让被锁定的时段名额Resource重新变回可售。关系清楚了技术方案自然就清晰。你要做的不是设计一个无所不包的状态机而是把 Resource 的变动、Event 的产生、Agent 的责任分别放进独立且可追踪的存储结构里。3. 用一个场馆预约系统完整跑一遍 REA 建模讲完概念我直接用一个具体项目做演示。假设我们要做一个室内网球馆的小程序核心业务是用户在线预约场地。预约分几个阶段查询可约时段、锁定名额、支付、到场核销、超时释放。3.1 先识别资源、事件与代理我习惯先拉一张白板把名词挑出来再用 REA 逐个归类。这个项目里Resource 包括场地时段venue_slot、账户余额wallet、优惠券coupon、核销凭证voucher。Event 包括预约锁定allocation_lock、支付扣款payment_debit、支付成功回调payment_success、核销完成redemption、超时释放allocation_release。Agent 包括用户user、系统定时任务system_job、场馆管理员staff。识别完之后可以先不动技术把主流程口述一遍用户请求预约系统先对时段资源执行锁定锁定成功后用户发起支付支付事件扣减余额支付成功事件把凭证资源置为可用用户到场后管理员执行核销事件凭证失效如果用户没能在支付前完成动作超时任务执行释放事件时段重新可约。3.2 核心表怎么设计在这个模型下我不建议把所有事实都塞进一张订单表。更稳妥的做法是围绕“事件”建表再单独维护资源快照。我给出一套简化但完整可落地的表结构。表名类型核心字段说明resource_venue_slotResource 表id、slot_name、start_time、end_time、total_qty、available_qty时段资源available_qty 属于冗余快照resource_walletResource 表id、user_id、balance、currency用户余额资源agent_userAgent 表id、user_name、phone外部代理用户event_booking_lockEvent 表id、agent_type、agent_id、slot_id、lock_qty、status预约锁定事件status 标记是否被释放event_paymentEvent 表id、agent_type、agent_id、wallet_id、amount、biz_ticket_id支付事件event_releaseEvent 表id、agent_type、agent_id、lock_event_id、slot_id、release_qty释放事件与锁定事件形成配对event_redemptionEvent 表id、agent_type、agent_id、voucher_id、slot_id核销事件这里有一点要特别重视resource_venue_slot 的 available_qty 是“从事件推算出来的当前值”我把它物化到了资源表上理由是查询可用时段时不能实时扫描所有事件否则数据库压力扛不住。但它不是不可推翻的事实每次锁定时都要用条件更新来保证安全。3.3 核心流程落地细节锁定名额这一步是并发压力最集中的地方。两个用户同时抢最后一个时段绝不能两个都成功。我推荐这样处理在锁定事件写入前执行一条带条件的 UPDATE。UPDATE resource_venue_slot SET available_qty available_qty - 1 WHERE id #{slotId} AND available_qty 1;如果该 UPDATE 影响行数为 0直接返回“名额不足”不需要再插入事件。只有更新成功才往 event_booking_lock 写一条锁定事件。这一招能把资源校验和扣减合并成一个原子操作大幅减少分布式锁的依赖。支付扣款也遵循同样的逻辑。先查余额再条件更新余额表UPDATE resource_wallet SET balance balance - #{paymentAmount} WHERE user_id #{userId} AND balance #{paymentAmount};更新成功再写 event_payment。这样每一个资源变动都对应一个事件事件和资源之间通过事务边界保持一致。这里要注意如果支付成功但插入事件失败会导致钱扣了、订单还挂在未支付状态。所以条件更新和事件写入必须在同一个本地事务里完成或者用消息表配合事务消息模式兜底。3.4 取消、超时、补单怎么建模很多团队遇到“取消”就头疼如果你把取消当成一个状态那永远只能在 status 枚举里加值。但在 REA 里取消是一个新的事件这个事件和原来的锁定、支付事件形成“因果链条”。用户取消预约时系统产生两个事件一个是对锁定事件的终止另一个是对已扣款的退回。如果按事件模型来做就是先写一条 event_release把锁定的名额归还资源表再写一条支付退回事件让余额增加。系统超时释放同理只是发起 Agent 变成 system_job。这样做最大的好处是所有可逆操作都能追溯。你需要知道“这个名额现在是可用还是已用”可以查资源快照但如果你想知道“这个名额经历了哪些波折”顺着事件链往回找就行。4. 从简单建模到流程可视化事件链和状态机怎么结合有个现实问题当业务到处都是事件人们又开始慌了——我怎么快速判断一张单当前到哪一步了有人说 REA 只适合做审计不适合做业务查询。这话不完全对只要把“事件流”和“当前状态”结合好既能保证可追溯又能保证查询效率。4.1 用事件链表达完整业务过程我先画一下场馆预约的主链路事件序列预约锁定事件 - 支付事件 - 支付成功回调事件 - 核销事件超时释放则是一条分支预约锁定事件 - 释放事件每一条异常路径都由对应的事件拼接而成。代码实现上我给事件表加两个公共字段parent_event_id 和 event_status。parent_event_id 用来把所有关联事件串成一条链event_status 则标记该事件自身是否“终止”。一个锁定事件一旦关联到释放事件它的 event_status 会被更新为“已释放”从而避免被同一逻辑重复处理。4.2 聚合根读模型事件是源、状态是投影为了兼顾查询体验我会推荐 CQRS 风格的做法事件表作为写模型单独再维护一张读模型表用来保存“当前状态快照”。以预约业务为例可以设计 reservation_view字段说明booking_id对内业务编号等于一次完整预约生命周期user_id用户slot_id时段pay_status未支付/已支付/已退款redeem_status未核销/已核销lock_status锁定中/已释放last_event_id该快照最近消费到的事件 ID写入事件时出库消息或定时任务把最新事件回放到读模型。这样业务要查询时直接查 reservation_view不用把一堆 event 表 join 一遍。开发过程中要注意幂等性消费事件时用 last_event_id 去重重复消息不要重复回放。4.3 状态机叠加在事件之上事件模型并不意味着完全抛弃状态机。移到维度后状态机的职责更纯粹它只负责约束“下一个合法事件是什么”。比如锁定事件可以发生的前提是时段存在、可约、用户未发起过重复锁定。支付事件可以发生的前提是锁定状态有效、余额充足。释放事件可以发生的前提是锁定状态有效且未支付、未核销。核销事件可以发生的前提是支付事件已成功。每类事件内部写一个小型校验器用校验规则来驱动状态流转而不是在一个全局 switch 里判断所有状态。这样扩展新事件时不影响既有事件只需要加新的校验器代码模块边界非常清楚。这样做以后你觉得头疼的“新需求”往往变化成增加一种新事件、增加一对资源字段、增加一个新读模型而不是把原来的表结构推翻重来。5. REA 落地时踩过坑和排查经验理论和模型讲得再好落到真实项目还是会踩坑。这一节我把实践中频繁遇到的问题列成清单每个都给出我的排查思路照着查能少走不少弯路。5.1 易错点一把“事件”做成流水账刚开始用 REA 时很容易一高兴就把所有动作都记成事件造成事件类型爆炸比如“用户点了按钮”“页面停留超过十秒”也记录。事件如果太细碎不仅增加存储成本读取时还容易产生大量无效数据。我后来总结了一个原则只有会导致 Resource 增减或者 Agent 权责发生变化的行为才需要落成事件。页面点击、参数修改这类过程数据记在日志系统不该混入业务事件流。5.2 易错点二直接用硬删除或覆盖修改旧事件业务方说“这个订单金额错了改成便宜点”如果你直接 UPDATE event_payment 里的金额字段短期看是改对了但长期会破坏审计线索为什么事件记录和当时的支付渠道对不上正确做法是写一条新事件调价事件adjustment并让它指向原支付事件同时更新资源快照。这种方式叫“追加式修正”所有对历史的修正最终都变成新事件。这样财务对账时每一分钱的来路都对得上。5.3 易错点三把 Agent 只当用户我会在每张事件表里同时保存 agent_type 和 agent_id。agent_type 可以是 user、staff、system_job 等。这种事情做完以后排查线上问题时差异非常大。有一次用户反馈“我没操作优惠券怎么被核销了”我直接按时间查 event_release 和 event_redemption发现记录里的 agent_type 是 system_job一看代码才发现是定时任务把 pending 状态误判成了成功问题一下就定位了。5.4 常见问题排查速查如果实际项目中遇到异常我一般按下面表格来定位症状排查步骤常见根因可用库存显示为负数先看最近的 lock 与 release 事件是否配对释放事件缺失或计数条件写错用户余额扣多了检查 payment 事件是否有重复写入再看钱包更新是否幂等事务消息重放导致重复扣款订单状态与支付结果不一致对比 event_payment 和读模型里的支付状态读模型消费偏移或丢消息一个时间段可以重复预约检查资源表的条件更新是否在同一事务中执行事务边界没有包住查询与更新客服人工改单后无法追踪查看动作事件是否记录了 agent_type 和 agent_id未建模内部 Agent 类型报表数字与业务系统不一致按天或按小时回放事件重建读模型读模型在某个中间版本存在不一致逻辑5.5 性能与扩展的平衡方案REA 模型最被诟病的是查询路径长如果每一次查数据都要从事件开始聚合数据库很快被打爆。所以我早在第三部分就处理了这个矛盾再在这里集中讲策略写路径只写事件表顺带更新 Resource 表更新 Resources 时用条件更新保证原子性。读路径读模型表只保存当前投影不直接扫事件。归档路径超过一定时间的历史事件归档到独立表或者数据仓库不参与日常事务查询。对账路径每日跑一次事件回放脚本用事件流重建资源快照和读模型进行比对发现不一致自动告警。用这个方案跑了接近一年最直观的感受是写路径相对稳定读路径简单快速而对账脚本成了整个系统安全感的来源。一旦资源快照和事件流之间有差异对账最先发现根本不需要等到业务方来投诉。最后说几句真实体会我接触 REA 之前一直把建表当成一件“差不多就行”的事。后来被几个项目反复教育才意识到一张表的结构背后藏着我们对整个业务世界如何运转的基本假设。REA 让我学会了一件事先别急着定义状态先问问这件事的发起者是谁它动了哪份资源产生了哪条可追溯记录。把这个想清楚了表结构就是水到渠成的事。还有一个很实用的扩展思路当你习惯用 REA 思考以后会发现它不仅适合做业务系统的领域模型也适合用来设计数据分析的数仓分层。你可以把资源表当作事实维度把事件表当作业务事实明细把 Agent 表当作对应的维表。在数仓里做数据治理时这套逻辑一样成立。如果你刚开始接触这套模型我的建议是不要全盘推翻现有系统挑一个最让人头疼的模块先试点。比如先处理“库存、余额”这类资源语义最清晰的领域把事件表和资源表跑通再逐步把优惠券、积分、售后这些旁支业务迁过来。这种渐进方式团队压力小也能最快看到效果。