1. "REA" 这个词,我前后摸索了好几天才真正接上话我在某次内部架构评审会上第一次听到"REA"这个词,当时差点没接住话头。同事说"下单模块要继续加状态的话,后面退款和风控都很难做,不如按 REA 重建",我下意识以为是某个工具的名字,搜了一下发现搜索结果五花八门,有房产资讯、有环境评价机构,甚至还有语言学相关内容,唯独没有我要找的那个解释。如果你也是因为类似场景来搜"rea",这篇就是把这块拼图帮你补上。先说三个最常撞车的含义,方便你判断自己找的是哪个:场景"REA" 通常指什么房产/地产房产中介、代理机构或相关服务品牌的缩写环境与监管区域环境评估、可再生能源评估类职能机构的缩写会计信息系统/架构设计Resource-Events-Agents 模型,即"资源—事件—参与者"业务建模方法这篇要聊的是第三种,也是在我看来最值得花时间搞懂的一种。它不解决"怎么把代码写得漂亮"的问题,而是解决"业务事实该怎么记录才经得起对账、审计和纠纷追溯"的问题。适合后端开发、架构师、正在改造订单/账务系统的团队,以及任何一个被"应付款怎么调"、"历史数据被 update 覆盖了"这种问题折磨过的人。2. REA 骨架拆解:Resource、Event、Agent 谁才是真正的主角很多人第一眼看到 REA 模型都会被名字骗了,以为三样东西平起平坐。实际上,Event(事件)才是整个模型的灵魂,Resource 和 Agent 更像是事件的两个维度,一个说明"什么东西发生变化",一个说明"谁对谁发生这个变化"。2.1 Resource:它可以是一杯咖啡,也可以是一笔积分余额Resource 对应的是"经济资源",定义上很宽泛:现金、商品、积分、虚拟币、服务产能、甚至一段可以被授权使用的数据,只要它满足两个条件——能被某一方持有或支配,并且持有或支配会产生经济后果。我在设计时习惯把 Resource 继续分两类:一类是"存量型资源",比如账户余额、库存数量,它在任何时候都有明确的数量;另一类是"记录型资源",比如一笔订单、一次服务记录,它没有严格意义上的"余额",但有"是否发生过"和"归属谁是"的判断。分类不是必须的,但能让你在想"正负号怎么算"的时候少挠头。2.2 Event:事实入库,状态靠计算Event 记录的是"已经发生的经济事实",比如支付、发货、收货、退款。REA 的核心纪律是:事实一旦入库,原则上不允许修改或删除。后续任何变化都通过追加新事件来表达。这个纪律和普通 CRUD 的思维差异非常大。常规订单表里一个status字段,从pending改成paid再改成refunded,过程全被覆盖,只能靠操作日志猜。而事件流方式下,每次状态变更都是一条新记录,账户余额、库存数量都可以直接从事件重新计算出来,谁也别想"偷改账"。2.3 Agent:决定"谁对谁负责"Agent 就是"参与者",分为内部参与者和外部参与者。内部参与者是你的员工、部门、平台自身;外部参与者是客户、供应商、银行渠道等等。两方和一个资源往往构成一个最小经济交换单元。比如你在咖啡店买一杯咖啡,REA 视角下至少包含两组经济交换:一组是现金交换——你的现金资源减少,店家现金资源增加;一组是商品交换——店家咖啡库存减少,你获得咖啡。所以别急着把"下单付款"当一条操作,它其实是两条事件链的组合。明确 Agent 有什么好处?对账时你能精确回答"这笔钱到底从谁账户到了谁账户",而不是笼统地 "订单支付成功"。到了退款和差错处理环节,这个答案就是决策依据。2.4 Policy:REA 中容易被忽视的"第四位成员"原始 REA 模型严格说只有 R、E、A,但落地时基本都要补一个 Policy,用来定义"何时允许发生哪个事件"。比如"必须先付款后发货""单笔退款不得超过原金额""同一订单只允许一次全额退款"。Policy 本身不产生事件,但它是事件入账的校验规则。我自己的经验是:先有 Policy,再写recordEvent的执行逻辑。把业务规则从代码的 if-else 里抽出来,变成配置化或至少标注化的规则清单,后面审计和合规沟通会顺畅很多。3. 为什么我决定用 REA 重写订单模块:三个传统模型的硬伤有人会问,市面上大部分系统就是"订单表 + 状态机 + 流水表",不也跑得好好的吗?我在决定重构之前也这么想,直到一次真实的财务纠纷让我看到问题:用户退款了,订单状态改成了"已退款",但财务流水里有一笔原始支付记录没有配套冲正记录,对账时两边对不上,最后只能人工翻日志手工补单。这类问题的根源,可以归结为传统模型的三点硬伤:硬伤传统订单+状态机REA 事件模型历史被覆盖update 状态,覆水难收追加事件,事实永久保留余额与流水脱节余额是独立字段,错了难定位余额由事件重算,可追溯退款造"负订单"逆向逻辑各不相同,口径混乱反向事件天然对称3.1 状态枚举的"三宗罪"订单状态枚举会越来越复杂:待支付、已支付、待发货、已发货、已收货、退款中、已退款、部分退款……每次新增业务都要在状态机里加节点,还免不了"非法迁移"的坑。更麻烦的是,状态字段本质上是"结论",而不是"原因"。当你看到一条订单处于"已退款",你并不知道它经历了怎样的事件序列,这对风控和客诉处理都不够友好。REA 不是彻底消灭状态,而是把状态降级为"由事件推导出来的展示层字段"。展示层想看到一个订单流转历史?直接按时间拉事件即可,不需要在订单表里维护数组字段。3.2 从"改字段"到"算字段":余额问题的本质解法传统账户表里的balance字段,最大的问题是它和流水可能不一致。扣款时先改余额,再插流水,如果第二步失败,余额和流水就过掉了。REA 的思路反过来:先管好事件,余额是事件计算出的投影。实际操作时,我不会真的每次都全表聚合算余额,而是维护一份"余额投影表",每当新事件落库,就在同一事务里更新投影。这相当于做了物化视图,既保留事件溯源的可重算性,又保住查询性能。3.3 它和借贷记账法不是一回事,而是更上游不少财务背景的朋友一看到 REA 就说"这不就是借贷记账吗",我起初也有这个错觉,后来才理清关系。借贷记账法(借方/贷方)属于记账层,规定的是"分录怎么写、借贷怎么平衡";REA 属于业务本体层,回答的是"经济事实的语义是什么"。用 REA 事件可以投影出任何你需要的会计口径:凡是"资源从 A 流到 B",你总能拆成 A 的贷方和 B 的借方。换句话说,REA 是更上游的事实层,借贷分录只是下游的报表投影方式。这个理解一旦打通,你就不会被"REA 会不会和现有财务系统冲突"吓到——它不冲突,它只是把源头理顺了。4. 落地实战:用 REA 思想重做一个二手交易结算模块光讲概念容易飘,我直接用一个真实做过的"模拟项目X"来说明。某二手交易社区原先把"抢单、托管、确认收货、退款"全放在一张order表里,字段越加越多,团队决定重写结算模块,我们按 REA 思路做了完整的落地。4.1 领域建模:先画资源,再画事件动手写代码前,先别急着建表,按两步做领域建模:列出所有 Resource:用户平台余额(积分)、商品、平台托管资金池、提现用的现金池。列出所有 Agent:买家、卖家、平台自身、外部支付渠道。然后画出事件链。这里我建议用一张简单的流程表来理清每个业务动作对应哪些事件:业务动作涉及资源事件方向充值现金池,平台余额渠道→平台入现金;平台→用户增余额下单托管平台余额买家余额减,平台托管池增确认收货商品,托管池卖家交付商品给买家;平台托管池给卖家增余额退款平台余额平台托管池→买家余额回补关键原则是"一增一减成对出现"。托管时买家余额减,托管池就要增;收货时托管池减,卖家余额就要增。每一对事件都保证守恒,这就是后续对账能自动平衡的原因。4.2 四张核心表和建表 SQL我们最终落地了四张核心表:resource、agent、economic_event、commitment。前两张是基础主数据,后两张是业务事实。-- 资源:平台内可被交易和追踪的价值载体 CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, resource_type VARCHAR(20) NOT NULL, -- PLATFORM_CREDIT / ITEM / CASH_POOL code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, unit VARCHAR(20) NOT NULL DEFAULT '个' ); -- 参与者:内部与外部 CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_type VARCHAR(20) NOT NULL, -- USER / PLATFORM / CHANNEL external_uid VARCHAR(64), name VARCHAR(128) NOT NULL ); -- 经济事件:事实流水 CREATE TABLE economic_event ( id BIGSERIAL PRIMARY KEY, event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), event_type VARCHAR(20) NOT NULL, -- RECEIVE / PROVIDE resource_id BIGINT NOT NULL REFERENCES resource(id), quantity NUMERIC(18, 4) NOT NULL, -- 正数表示数量,方向由 from/to 表达 from_agent_id BIGINT REFERENCES agent(id), to_agent_id BIGINT REFERENCES agent(id), ref_no VARCHAR(64) NOT NULL ); CREATE UNIQUE INDEX uk_economic_event_ref_no ON economic_event(ref_no); -- 承诺:预排但尚未发生 CREATE TABLE commitment ( id BIGSERIAL PRIMARY KEY, planned_time TIMESTAMPTZ, resource_id BIGINT NOT NULL REFERENCES resource(id), quantity NUMERIC(18, 4) NOT NULL, from_agent_id BIGINT REFERENCES agent(id), to_agent_id BIGINT REFERENCES agent(id), status VARCHAR(20) NOT NULL DEFAULT 'OPEN' );commitment表一开始很容易被忽略。它记录的是"承诺但尚未发生"的业务,比如买家下单后商家还没发货,此刻商品和钱都没真正流动。承诺不该影响余额计算,但它是业务状态的重要组成部分。没有这张表,你就只能靠脑补或者状态字段。4.3 业务代码里如何落一个事件领域层我通常只暴露一个recordEvent方法,所有业务动作最后都收敛到这里。它要保证两件事:幂等、事务一致。type EconomicEvent = { eventType: "RECEIVE" | "PROVIDE"; resourceId: string; quantity: number; // 恒为正数 fromAgentId?: string; toAgentId?: string; refNo: string; // 业务关联号,用于幂等 }; async function recordEvent(event: EconomicEvent, tx: Transaction): Promise<void> { // 1. 校验 Policy:比如 fromAgent 余额是否足够 await assertPolicy(event, tx); // 2. 幂等检查:ref_no 重复则直接返回,防止网络重试造成双花 const existing = await tx.query( "SELECT id FROM economic_event WHERE ref_no = $1", [event.refNo] ); if (existing.rows.length > 0) return; // 3. 落事件,同一事务里更新余额投影 await tx.query( `INSERT INTO economic_event (event_type, resource_id, quantity, from_agent_id, to_agent_id, ref_no) VALUES ($1, $2, $3, $4, $5, $6)`, [event.eventType, event.resourceId, event.quantity, event.fromAgentId, event.toAgentId, event.refNo] ); await updateBalanceProjection(event, tx); }ref_no是防重试的命脉。我们遇到过支付渠道重复回调、前端双击下单、队列重投三种情况,靠它全部挡下了。给它的建议是:全局唯一,且每个业务场景有自己的前缀规则,比如PAY_订单号_时间戳、REFUND_订单号_序号,排查问题的时候一眼就能定位链路。4.4 托管、收货、退款:三条路径的完整事件推演我用一个具体数字走一遍。买家 A 下单购买了卖家 B 的 100 元商品,场景如下:步骤事件资源变动1. 下单托管A → 平台托管池,PROVIDE 100A 余额 -100,托管池 +1002. 卖家发货B → A,商品 PROVIDE 1商品从 B 转移到 A3. 确认收货平台托管池 → B,PROVIDE 100托管池 -100,B 余额 +1004. 部分退款平台托管池 → A,PROVIDE 20托管池 -20,A 余额 +20每一步都可以独立审计。最后对账时只要执行同样的余额推算 SQL,任何一环出错都会被立刻揪出来。-- 计算某用户当前某资源的余额 SELECT e.resource_id, SUM(CASE WHEN e.to_agent_id = :agentId THEN e.quantity WHEN e.from_agent_id = :agentId THEN -e.quantity ELSE 0 END) AS balance FROM economic_event e WHERE e.from_agent_id = :agentId OR e.to_agent_id = :agentId GROUP BY e.resource_id;这段 SQL 的重点是"方向由 from/to 决定"而不是由正负号决定。quantity永远是正数,表达语义时更不容易出错。如果你在传统系统里习惯"正数入、负数出",这套模型初期可能需要适应一下,但适应之后你会发现代码里几乎没有"负数到底代表扣还是退"的判断谜题了。5. 这五个坑我全都踩过,写出来帮你绕开落地 REA 的过程远没有 UML 图那么美好。下面五个问题,是我在不同项目里真实遇到的,每一条都让团队付出过加班代价。5.1 误区一:把 REA 当成 Event Sourcing 来宣传Event Sourcing 是技术层面的持久化策略,REA 是业务语义模型,两者能配合,但不能画等号。如果团队没理解 REA 的"资源—事件—参与者"语义,只看见"追加事件"这个表象,很容易搞出一堆技术上叫事件、业务上说不清来源的流水。我们的教训是:引入 REA 时,先给团队讲清楚"哪些动作算经济事件、哪些是承诺、哪些只是展示层状态",而不是上来就讨论数据库架构。5.2 误区二:所有资源都用同一套正负号逻辑资源和资源之间差异很大。现金、余额这类资源适合做"存量余额推算";但商品资源如果只做数量推算,就无法回答"是哪个批次、哪个 SKU、什么成色"这种明细问题。所以我在实现时给resource_type分了类,不同类型用不同的查询器。建议提前定义好资源细粒度。比如把"商品"建模到 SKU 级别,而不是笼统的"货品"级别,否则事件流越滚越大,后来再想拆分成本极高。5.3 误区三:余额投影和事件流不在同一事务里更新这是掉过最多坑的地方。如果先插事件成功、更新投影失败,余额和事实立刻不一致;反过来先改投影再插事件,查流水时会发现缺了凭证。必须在同一个数据库事务里完成两步。分布式场景下,如果多个服务都写事件,需要引入独立的事件服务或至少统一的事件表入口,别让业务服务各自往表里乱插。5.4 误区四:给前端直接查事件表事件表适合写入和审计,但不适合直接给前端列表、报表做展示。买家看到的是"订单号、金额、状态、时间",不是一堆 fromAgent/toAgent 的技术流水。因此必须做查询投影,把事件流聚合出订单视图、交易视图、余额视图等。我的建议是让每个查询投影对应一个明确的业务问题,比如"我的订单列表""我的余额""我的结算明细"。投影数据可以由事件流重建,也可以定期校正。5.5 误区五:小项目也套全套 REAREA 的完整模型,对大部分小工具来说有点过度设计。如果你只需要一个简单的记账本、一个原型 Demo、或者一个状态不会反复横跳的内部小系统,状态机反而更直接。我个人的判断标准很简单:当"冲正"、"对账"、"多方交易"、"审计追溯"出现两个以上,才值得引入 REA。它在项目早期看起来像绕远路,但一旦业务规模上来,你会发现它是唯一能让多方信任同一套事实基础的模型。所以如果你现在还在犹豫要不要用,我建议从小模块试点开始——比如把"余额变动"单独抽成事件流,其余部分继续沿用状态机。等团队真正理解了 Resource、Event、Agent 这套语义,再逐步扩大范围。最后再分享一个我坚持了很久的习惯:每次事件落库时,把当时的业务上下文(订单号、操作人、操作终端、IP)作为事件元数据一并存下来,这些看似冗余的信息,在日后客诉和合规审计时,比任何一张表都更有说服力。
1. "REA" 这个词,我前后摸索了好几天才真正接上话我在某次内部架构评审会上第一次听到"REA"这个词,当时差点没接住话头。同事说"下单模块要继续加状态的话,后面退款和风控都很难做,不如按 REA 重建",我下意识以为是某个工具的名字,搜了一下发现搜索结果五花八门,有房产资讯、有环境评价机构,甚至还有语言学相关内容,唯独没有我要找的那个解释。如果你也是因为类似场景来搜"rea",这篇就是把这块拼图帮你补上。先说三个最常撞车的含义,方便你判断自己找的是哪个:场景"REA" 通常指什么房产/地产房产中介、代理机构或相关服务品牌的缩写环境与监管区域环境评估、可再生能源评估类职能机构的缩写会计信息系统/架构设计Resource-Events-Agents 模型,即"资源—事件—参与者"业务建模方法这篇要聊的是第三种,也是在我看来最值得花时间搞懂的一种。它不解决"怎么把代码写得漂亮"的问题,而是解决"业务事实该怎么记录才经得起对账、审计和纠纷追溯"的问题。适合后端开发、架构师、正在改造订单/账务系统的团队,以及任何一个被"应付款怎么调"、"历史数据被 update 覆盖了"这种问题折磨过的人。2. REA 骨架拆解:Resource、Event、Agent 谁才是真正的主角很多人第一眼看到 REA 模型都会被名字骗了,以为三样东西平起平坐。实际上,Event(事件)才是整个模型的灵魂,Resource 和 Agent 更像是事件的两个维度,一个说明"什么东西发生变化",一个说明"谁对谁发生这个变化"。2.1 Resource:它可以是一杯咖啡,也可以是一笔积分余额Resource 对应的是"经济资源",定义上很宽泛:现金、商品、积分、虚拟币、服务产能、甚至一段可以被授权使用的数据,只要它满足两个条件——能被某一方持有或支配,并且持有或支配会产生经济后果。我在设计时习惯把 Resource 继续分两类:一类是"存量型资源",比如账户余额、库存数量,它在任何时候都有明确的数量;另一类是"记录型资源",比如一笔订单、一次服务记录,它没有严格意义上的"余额",但有"是否发生过"和"归属谁是"的判断。分类不是必须的,但能让你在想"正负号怎么算"的时候少挠头。2.2 Event:事实入库,状态靠计算Event 记录的是"已经发生的经济事实",比如支付、发货、收货、退款。REA 的核心纪律是:事实一旦入库,原则上不允许修改或删除。后续任何变化都通过追加新事件来表达。这个纪律和普通 CRUD 的思维差异非常大。常规订单表里一个status字段,从pending改成paid再改成refunded,过程全被覆盖,只能靠操作日志猜。而事件流方式下,每次状态变更都是一条新记录,账户余额、库存数量都可以直接从事件重新计算出来,谁也别想"偷改账"。2.3 Agent:决定"谁对谁负责"Agent 就是"参与者",分为内部参与者和外部参与者。内部参与者是你的员工、部门、平台自身;外部参与者是客户、供应商、银行渠道等等。两方和一个资源往往构成一个最小经济交换单元。比如你在咖啡店买一杯咖啡,REA 视角下至少包含两组经济交换:一组是现金交换——你的现金资源减少,店家现金资源增加;一组是商品交换——店家咖啡库存减少,你获得咖啡。所以别急着把"下单付款"当一条操作,它其实是两条事件链的组合。明确 Agent 有什么好处?对账时你能精确回答"这笔钱到底从谁账户到了谁账户",而不是笼统地 "订单支付成功"。到了退款和差错处理环节,这个答案就是决策依据。2.4 Policy:REA 中容易被忽视的"第四位成员"原始 REA 模型严格说只有 R、E、A,但落地时基本都要补一个 Policy,用来定义"何时允许发生哪个事件"。比如"必须先付款后发货""单笔退款不得超过原金额""同一订单只允许一次全额退款"。Policy 本身不产生事件,但它是事件入账的校验规则。我自己的经验是:先有 Policy,再写recordEvent的执行逻辑。把业务规则从代码的 if-else 里抽出来,变成配置化或至少标注化的规则清单,后面审计和合规沟通会顺畅很多。3. 为什么我决定用 REA 重写订单模块:三个传统模型的硬伤有人会问,市面上大部分系统就是"订单表 + 状态机 + 流水表",不也跑得好好的吗?我在决定重构之前也这么想,直到一次真实的财务纠纷让我看到问题:用户退款了,订单状态改成了"已退款",但财务流水里有一笔原始支付记录没有配套冲正记录,对账时两边对不上,最后只能人工翻日志手工补单。这类问题的根源,可以归结为传统模型的三点硬伤:硬伤传统订单+状态机REA 事件模型历史被覆盖update 状态,覆水难收追加事件,事实永久保留余额与流水脱节余额是独立字段,错了难定位余额由事件重算,可追溯退款造"负订单"逆向逻辑各不相同,口径混乱反向事件天然对称3.1 状态枚举的"三宗罪"订单状态枚举会越来越复杂:待支付、已支付、待发货、已发货、已收货、退款中、已退款、部分退款……每次新增业务都要在状态机里加节点,还免不了"非法迁移"的坑。更麻烦的是,状态字段本质上是"结论",而不是"原因"。当你看到一条订单处于"已退款",你并不知道它经历了怎样的事件序列,这对风控和客诉处理都不够友好。REA 不是彻底消灭状态,而是把状态降级为"由事件推导出来的展示层字段"。展示层想看到一个订单流转历史?直接按时间拉事件即可,不需要在订单表里维护数组字段。3.2 从"改字段"到"算字段":余额问题的本质解法传统账户表里的balance字段,最大的问题是它和流水可能不一致。扣款时先改余额,再插流水,如果第二步失败,余额和流水就过掉了。REA 的思路反过来:先管好事件,余额是事件计算出的投影。实际操作时,我不会真的每次都全表聚合算余额,而是维护一份"余额投影表",每当新事件落库,就在同一事务里更新投影。这相当于做了物化视图,既保留事件溯源的可重算性,又保住查询性能。3.3 它和借贷记账法不是一回事,而是更上游不少财务背景的朋友一看到 REA 就说"这不就是借贷记账吗",我起初也有这个错觉,后来才理清关系。借贷记账法(借方/贷方)属于记账层,规定的是"分录怎么写、借贷怎么平衡";REA 属于业务本体层,回答的是"经济事实的语义是什么"。用 REA 事件可以投影出任何你需要的会计口径:凡是"资源从 A 流到 B",你总能拆成 A 的贷方和 B 的借方。换句话说,REA 是更上游的事实层,借贷分录只是下游的报表投影方式。这个理解一旦打通,你就不会被"REA 会不会和现有财务系统冲突"吓到——它不冲突,它只是把源头理顺了。4. 落地实战:用 REA 思想重做一个二手交易结算模块光讲概念容易飘,我直接用一个真实做过的"模拟项目X"来说明。某二手交易社区原先把"抢单、托管、确认收货、退款"全放在一张order表里,字段越加越多,团队决定重写结算模块,我们按 REA 思路做了完整的落地。4.1 领域建模:先画资源,再画事件动手写代码前,先别急着建表,按两步做领域建模:列出所有 Resource:用户平台余额(积分)、商品、平台托管资金池、提现用的现金池。列出所有 Agent:买家、卖家、平台自身、外部支付渠道。然后画出事件链。这里我建议用一张简单的流程表来理清每个业务动作对应哪些事件:业务动作涉及资源事件方向充值现金池,平台余额渠道→平台入现金;平台→用户增余额下单托管平台余额买家余额减,平台托管池增确认收货商品,托管池卖家交付商品给买家;平台托管池给卖家增余额退款平台余额平台托管池→买家余额回补关键原则是"一增一减成对出现"。托管时买家余额减,托管池就要增;收货时托管池减,卖家余额就要增。每一对事件都保证守恒,这就是后续对账能自动平衡的原因。4.2 四张核心表和建表 SQL我们最终落地了四张核心表:resource、agent、economic_event、commitment。前两张是基础主数据,后两张是业务事实。-- 资源:平台内可被交易和追踪的价值载体 CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, resource_type VARCHAR(20) NOT NULL, -- PLATFORM_CREDIT / ITEM / CASH_POOL code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, unit VARCHAR(20) NOT NULL DEFAULT '个' ); -- 参与者:内部与外部 CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_type VARCHAR(20) NOT NULL, -- USER / PLATFORM / CHANNEL external_uid VARCHAR(64), name VARCHAR(128) NOT NULL ); -- 经济事件:事实流水 CREATE TABLE economic_event ( id BIGSERIAL PRIMARY KEY, event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), event_type VARCHAR(20) NOT NULL, -- RECEIVE / PROVIDE resource_id BIGINT NOT NULL REFERENCES resource(id), quantity NUMERIC(18, 4) NOT NULL, -- 正数表示数量,方向由 from/to 表达 from_agent_id BIGINT REFERENCES agent(id), to_agent_id BIGINT REFERENCES agent(id), ref_no VARCHAR(64) NOT NULL ); CREATE UNIQUE INDEX uk_economic_event_ref_no ON economic_event(ref_no); -- 承诺:预排但尚未发生 CREATE TABLE commitment ( id BIGSERIAL PRIMARY KEY, planned_time TIMESTAMPTZ, resource_id BIGINT NOT NULL REFERENCES resource(id), quantity NUMERIC(18, 4) NOT NULL, from_agent_id BIGINT REFERENCES agent(id), to_agent_id BIGINT REFERENCES agent(id), status VARCHAR(20) NOT NULL DEFAULT 'OPEN' );commitment表一开始很容易被忽略。它记录的是"承诺但尚未发生"的业务,比如买家下单后商家还没发货,此刻商品和钱都没真正流动。承诺不该影响余额计算,但它是业务状态的重要组成部分。没有这张表,你就只能靠脑补或者状态字段。4.3 业务代码里如何落一个事件领域层我通常只暴露一个recordEvent方法,所有业务动作最后都收敛到这里。它要保证两件事:幂等、事务一致。type EconomicEvent = { eventType: "RECEIVE" | "PROVIDE"; resourceId: string; quantity: number; // 恒为正数 fromAgentId?: string; toAgentId?: string; refNo: string; // 业务关联号,用于幂等 }; async function recordEvent(event: EconomicEvent, tx: Transaction): Promise<void> { // 1. 校验 Policy:比如 fromAgent 余额是否足够 await assertPolicy(event, tx); // 2. 幂等检查:ref_no 重复则直接返回,防止网络重试造成双花 const existing = await tx.query( "SELECT id FROM economic_event WHERE ref_no = $1", [event.refNo] ); if (existing.rows.length > 0) return; // 3. 落事件,同一事务里更新余额投影 await tx.query( `INSERT INTO economic_event (event_type, resource_id, quantity, from_agent_id, to_agent_id, ref_no) VALUES ($1, $2, $3, $4, $5, $6)`, [event.eventType, event.resourceId, event.quantity, event.fromAgentId, event.toAgentId, event.refNo] ); await updateBalanceProjection(event, tx); }ref_no是防重试的命脉。我们遇到过支付渠道重复回调、前端双击下单、队列重投三种情况,靠它全部挡下了。给它的建议是:全局唯一,且每个业务场景有自己的前缀规则,比如PAY_订单号_时间戳、REFUND_订单号_序号,排查问题的时候一眼就能定位链路。4.4 托管、收货、退款:三条路径的完整事件推演我用一个具体数字走一遍。买家 A 下单购买了卖家 B 的 100 元商品,场景如下:步骤事件资源变动1. 下单托管A → 平台托管池,PROVIDE 100A 余额 -100,托管池 +1002. 卖家发货B → A,商品 PROVIDE 1商品从 B 转移到 A3. 确认收货平台托管池 → B,PROVIDE 100托管池 -100,B 余额 +1004. 部分退款平台托管池 → A,PROVIDE 20托管池 -20,A 余额 +20每一步都可以独立审计。最后对账时只要执行同样的余额推算 SQL,任何一环出错都会被立刻揪出来。-- 计算某用户当前某资源的余额 SELECT e.resource_id, SUM(CASE WHEN e.to_agent_id = :agentId THEN e.quantity WHEN e.from_agent_id = :agentId THEN -e.quantity ELSE 0 END) AS balance FROM economic_event e WHERE e.from_agent_id = :agentId OR e.to_agent_id = :agentId GROUP BY e.resource_id;这段 SQL 的重点是"方向由 from/to 决定"而不是由正负号决定。quantity永远是正数,表达语义时更不容易出错。如果你在传统系统里习惯"正数入、负数出",这套模型初期可能需要适应一下,但适应之后你会发现代码里几乎没有"负数到底代表扣还是退"的判断谜题了。5. 这五个坑我全都踩过,写出来帮你绕开落地 REA 的过程远没有 UML 图那么美好。下面五个问题,是我在不同项目里真实遇到的,每一条都让团队付出过加班代价。5.1 误区一:把 REA 当成 Event Sourcing 来宣传Event Sourcing 是技术层面的持久化策略,REA 是业务语义模型,两者能配合,但不能画等号。如果团队没理解 REA 的"资源—事件—参与者"语义,只看见"追加事件"这个表象,很容易搞出一堆技术上叫事件、业务上说不清来源的流水。我们的教训是:引入 REA 时,先给团队讲清楚"哪些动作算经济事件、哪些是承诺、哪些只是展示层状态",而不是上来就讨论数据库架构。5.2 误区二:所有资源都用同一套正负号逻辑资源和资源之间差异很大。现金、余额这类资源适合做"存量余额推算";但商品资源如果只做数量推算,就无法回答"是哪个批次、哪个 SKU、什么成色"这种明细问题。所以我在实现时给resource_type分了类,不同类型用不同的查询器。建议提前定义好资源细粒度。比如把"商品"建模到 SKU 级别,而不是笼统的"货品"级别,否则事件流越滚越大,后来再想拆分成本极高。5.3 误区三:余额投影和事件流不在同一事务里更新这是掉过最多坑的地方。如果先插事件成功、更新投影失败,余额和事实立刻不一致;反过来先改投影再插事件,查流水时会发现缺了凭证。必须在同一个数据库事务里完成两步。分布式场景下,如果多个服务都写事件,需要引入独立的事件服务或至少统一的事件表入口,别让业务服务各自往表里乱插。5.4 误区四:给前端直接查事件表事件表适合写入和审计,但不适合直接给前端列表、报表做展示。买家看到的是"订单号、金额、状态、时间",不是一堆 fromAgent/toAgent 的技术流水。因此必须做查询投影,把事件流聚合出订单视图、交易视图、余额视图等。我的建议是让每个查询投影对应一个明确的业务问题,比如"我的订单列表""我的余额""我的结算明细"。投影数据可以由事件流重建,也可以定期校正。5.5 误区五:小项目也套全套 REAREA 的完整模型,对大部分小工具来说有点过度设计。如果你只需要一个简单的记账本、一个原型 Demo、或者一个状态不会反复横跳的内部小系统,状态机反而更直接。我个人的判断标准很简单:当"冲正"、"对账"、"多方交易"、"审计追溯"出现两个以上,才值得引入 REA。它在项目早期看起来像绕远路,但一旦业务规模上来,你会发现它是唯一能让多方信任同一套事实基础的模型。所以如果你现在还在犹豫要不要用,我建议从小模块试点开始——比如把"余额变动"单独抽成事件流,其余部分继续沿用状态机。等团队真正理解了 Resource、Event、Agent 这套语义,再逐步扩大范围。最后再分享一个我坚持了很久的习惯:每次事件落库时,把当时的业务上下文(订单号、操作人、操作终端、IP)作为事件元数据一并存下来,这些看似冗余的信息,在日后客诉和合规审计时,比任何一张表都更有说服力。