☰
REA模型实战:用事件溯源重构积分商城与库存对账
2026/10/21 15:14:43 网站建设 项目流程

最近在重构一个内部积分商城系统。需求方丢过来一句话:员工积分、商品库存、月底给财务的对账单,这三块你们自己捋清楚。听起来不复杂,真正动手接旧代码的时候才发现,积分一张表、库存一张表、订单一张表,每张表都挂着一串状态位,状态位之间还在互相打架。光“取消订单之后积分退回来、库存怎么办”这一个问题,就在三个服务里改了两天。后来我把一整团乱麻拽回白板,画到最底层,发现核心只剩三个字母:rea。别误会,这三个字母不是网络上那些流行梗,是我很早之前就该认真用的 REA 模型——资源、事件、代理。这篇文章就把我这次用 REA 重构业务系统的整个过程、踩过的坑、以及最后沉淀下来的表结构和代码思路,完完整整写出来。

1. REA 模型到底是什么,为什么它值得你重新认识

1.1 一句话说清 REA:所有业务最终都是资源在代理之间流转

REA 不是新鲜事物,它是上世纪八十年代提出的业务过程建模方法,全称 Resources-Events-Agents,翻译过来就是资源、事件、代理。它想解决的命题很朴素:商业世界里每天都在发生什么?答案永远是价值从一个地方跑到另一个地方。

拿买咖啡举例。你从店员手里拿到咖啡,你把钱交给收银台。咖啡是资源,钱也是资源;付钱和交付咖啡是事件;你和店员是代理。整个交易用 REA 的语言描述就是:代理(你、店员)通过事件(支付、交付),驱动资源(金钱、咖啡)完成了从一方到另一方的流转。听上去像废话,但现代软件系统里大量问题的根源,恰恰是很多人跳过了这一步,直接去建订单表、库存表、流水表。

旧系统就是典型:把订单当成一张主表,堆了一堆字段:已下单、已支付、已发货、已取消。REA 视角下,订单根本不是一张主表,它只是“支付事件 + 扣库存事件 + 发货事件”聚在一起之后,展示给用户的一个视图。事件才是真正值得被记录的东西。

1.2 四十年前的模型为什么放到今天反而显得新

很多人一听“八十年代”就觉得老古董,但 REA 的核心坚持恰好踩中了当今主流架构思路——事件溯源。传统业务表记录的是“当前状态”,比如订单表里 state=已发货;REA 记录的是“发生了什么”,比如一条不可变流水:2025年6月18日10点23分,代理A向代理B交付了2件商品。

当前状态只是把所有事件汇总后算出来的一个结果。只要事件完整,你随时能重算出任何历史时点的状态;如果只保存当前状态,历史一变动,新状态怎么来的、为什么变成这样,全都说不清。财务对账要的是“整个月的每一笔变化”,运营申诉要的是“这个订单为什么被取消”,审计要的是“谁在什么时候动过价格”。这些问题的答案全都藏在事件流里,而不在最新状态表里。

所以我的理解里,REA 与其说是一套数据库设计模板,不如说是一种“用不可变日志记录业务”的思维方式。现在很多系统设计主动把核心流水表设计成 Append-Only,用消息队列把变更事件串起来,本质上都是向 REA 的初衷靠拢。

2. 拆开资源、事件、代理,附最容易理解错的三处细节

2.1 资源:先问一句它是价值载体,还是状态容器

资源(Resource)的定义特别容易偏。REA 里的资源是“可计量的价值载体”,比如商品、钱、积分、原材料、工时。判断标准只有一个:它能不能被计数或计量,并且在代理之间流转?能,才配叫资源。

第一版设计时,我差点把订单建成了资源类型,后来发现没法回答一个问题:订单的数量单位是什么?一件订单?那这个“一件”背后到底是什么物理量?根本没有。订单没有单位,不可能在代理之间流转,它只是若干事件组合出来的业务视图。

真正需要在系统里识别出来的资源,一般是:库存商品、积分余额、现金账户、应收账款、原材料、优惠券余额、可用的服务次数。把这些识别出来,中心表就清晰了。还有一些边角案例,比如一次咨询服务的“小时数”、一桶水可以洗车的“次数”,只要可计量,都可以算资源。

2.2 事件:所有“改状态”的冲动,都应该改成“加事件”

事件是 REA 的核心。资源的任何数量变化、归属变化,都必须由一条事件记录来解释。不是说想更新库存就把库存表 UPDATE 一下,而是先往事件表里插入一条“出库2件”的记录,再根据所有事件算出“当前库存剩余X件”。这就是事件溯源做法。

实际设计里我一般把事件分成两类:经济事件和经济增量/减量事件。前者是真实业务动作,比如“销售商品”“采购原材”“支付货款”;后者描述这个动作对资源的影响是增还是减。工程上最简单的做法,是在事件明细表里直接记带符号的数量,正数表示流入或增加,负数表示流出或减少。

事件有两个硬性约束:原子、不可变。原子是指一条事件必须完整发生,不能拆一半;不可变是指历史事件不允许 UPDATE。团队刚开始很不习惯,一个 UPDATE 就能把钱改回来,为什么非要再插一条反冲事件?后来发现,凡是允许 UPDATE 的事故现场,永远会留下“这个数被谁改的、为什么改”的谜团;永远追加事件,至少账是算得清的。

2.3 代理:谁干的、谁负责、谁有权

代理(Agent)就是资源的发送方和接收方,通常是人或组织。REA 里每个事件都应该有来源代理和目标代理。例如“员工兑换商品”事件,员工是积分付出的一方,公司结算户是积分接收的一方;同时还有一个“库存减少”事件,它的代理可能是仓库系统或者系统本身。

代理有两个实际用处。第一是权限模型:用户能不能执行某个动作,本质上就是他能不能作为代理创建某类事件。只要在事件创建入口校验“当前登录人是否具备兑换下单这类事件的创建权”,权限控制就变得很直接。第二是审计追溯:事件带上了人/组织维度,出了问题可以直接定位到“谁在什么时间把什么东西从谁那里移到了谁那里”,比普通操作日志强太多。

实际建模时还要区分内部代理和外部代理。员工是内部代理,供应商是外部代理,公司结算户是内部代理。这个区分对服务边界很有帮助,跨代理往往意味着跨组织,消息、单据、合同都围绕这个边界展开。

3. 实操:用 REA 把内部积分商城重新建模成一套干净表结构

3.1 场景梳理:一个不超过几千人的内部积分商城

先说业务背景。某公司内部有个积分商城,员工每月有积分入账,积分可兑换实体商品,商品有库存,兑换时扣积分、扣库存,月底财务要根据所有流水和供应商对账。旧系统实现了订单、积分流水、库存明细、退款单四组表,互相用状态机硬接,肉眼可见的脆弱。

按 REA 重来,先别急着写建表语句。我在白板上花了一个下午,把三组元素列清楚:

资源(Resource):

  • 商品 SKU,可计数
  • 员工积分,按点数计量
  • 结算资金,按元计量,财务侧使用

事件(Event):

  • 积分发放(公司给员工)
  • 积分冻结与解冻(兑换下单和取消订单时使用)
  • 商品出库(仓库把商品发给员工)
  • 供应商送货入库(商品从供应商进入公司库)
  • 月底资金结转(积分对应资金转给供应商)

代理(Agent):

  • 员工(内部代理,积分持有方)
  • 公司/结算中心(内部代理)
  • 供应商(外部代理)
  • 系统(自动代理,承担自动发货这类无人值守事件)

这一步做完,整个系统的核心骨架已经出来了。剩下的事情都是围绕这三类元素加细节。

3.2 核心表结构:四张主表加一张明细表

没完全照搬教科书的多表关联,我做了一些工程化简化。最终核心落成四张表:资源表、代理表、事件表、事件明细表。

CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_name VARCHAR(64) NOT NULL, unit VARCHAR(16) NOT NULL, status TINYINT DEFAULT 1 ); CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(16), -- employee/supplier/system agent_name VARCHAR(64) ); CREATE TABLE event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32), -- point_grant/order_redeem/stock_out/... event_time DATETIME NOT NULL, from_agent_id BIGINT, to_agent_id BIGINT, biz_no VARCHAR(64), -- 外部业务单号,用于幂等 UNIQUE KEY uk_biz (event_type, biz_no) ); CREATE TABLE event_item ( item_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, quantity DECIMAL(18,4) NOT NULL, -- 正数表示流入,负数表示流出 unit_price DECIMAL(18,4), KEY idx_event (event_id), KEY idx_resource (resource_id) );

几个关键点:

第一,biz_no 加唯一约束,用来做幂等。这一点帮了很大忙,后面详细说。第二,事件表不直接引用“订单表”,只记录事件本身。多个事件要聚合成订单视图时,可以额外加一个 group_id 字段把它们绑在一起。第三,event_item 表里的 quantity 带正负号,这样对账和查询只需要做 SUM,不需要再做复杂的 case when。

3.3 核心查询:余额不是字段,是事件汇总的结果

员工可用积分怎么查?不再有“积分余额表”,而是把所有涉及该员工的事件明细汇总起来。我建立了一个视图,把每个事件拆成两个方向:流向目标代理的量记为正,从来源代理流出的量记为负。

CREATE VIEW v_agent_flow AS SELECT e.event_id, e.to_agent_id AS agent_id, ei.resource_id, ei.quantity FROM event e JOIN event_item ei ON ei.event_id = e.event_id UNION ALL SELECT e.event_id, e.from_agent_id AS agent_id, ei.resource_id, -ei.quantity FROM event e JOIN event_item ei ON ei.event_id = e.event_id;

查询某员工当前可用积分就是一条简单聚合:

SELECT COALESCE(SUM(quantity), 0) AS balance FROM v_agent_flow WHERE agent_id = 888 AND resource_id = 1001;

月底对账同样简单:按资源维度汇总某个时间段内所有事件明细,得到“本月积分发放多少”“本月商品出库多少”,再和供应商账单、财务科目余额对比。两侧不一致时,差额一定会落到某条缺失或重复的事件上,定位非常快。旧系统那种“余额表直接对余额表”的做法,永远不知道差异是哪一笔交易造成的。

3.4 兑换下单这个动作,要怎么落成一组事件

这是最能体现 REA 优势的步骤。旧流程是“更新订单状态 + 更新积分余额 + 更新库存”,三个 UPDATE 放在一个事务里,一旦中途失败就要到处查补偿。REA 的做法是,把“兑换下单”拆成多个真实语义的事件:

下单时:

  • 插入一条“积分冻结”事件,明细为积分资源、数量为负(员工积分流出到冻结池)
  • 在调度表写一条待出库记录

取消时:

  • 插入一条“积分解冻”事件,明细为积分资源、数量为正(冻结池回给员工)
  • 更新调度表状态为已取消

发货时:

  • 插入一条“商品出库”事件,明细为库存资源、数量为负
  • 同时插入一条“商品送达员工”事件,明细为库存资源、数量为正(也可以合并成一条对库存的流出记录,取决于建模粒度)
  • 更新调度表状态为已发货

如果严格按照主流 REA 学术定义,“冻结”和“解冻”更像是内部状态流转,而不是经济事件。工程实践上,我会把冻结积分建模成一种“冻结资源”的增减,问题不大,核心思路没有变:每个动作都追加一条事件,绝不直接改余额。

有一次我试过把“扣积分 + 扣库存”塞进同一个事件,结果退货时根本不知道要回补哪个资源。后来拆成独立事件,用 group_id 聚类,一切就顺了。事件模型要保持“单一动作”的语义,别贪方便。

4. 落地中的五个典型坑,每个我都踩过一遍

4.1 坑一:把“订单”这种单据当成资源建表

第一版设计里我差点建了 resource_type=ORDER 的资源,清醒过来是因为没法回答“订单的数量单位是什么”。单据是事件的展示聚合,不是资源。正确做法是不建订单主表,或者只建一张轻量的聚合表保存 group_id 和展示字段,真正的业务主数据还是在事件明细里。订单状态也绝不能靠状态字段硬维护,而是实时由事件聚合算出来。

4.2 坑二:一个事件里同时增和减多种资源,导致没法对冲

之前为了省事,一个订单事件里直接带上积分 -100、库存 -1、应收 +100,方向堆在一起。等到退货要冲销时,不知道要冲哪一部分。后来立了规矩:一个事件只描述一个业务动作,如果这个动作对多种资源有影响,就拆成多个事件并通过 group_id 绑定。虽然表行数变多,但逻辑清楚,冲销时只需要“反向一个事件”就行。

4.3 坑三:忘记幂等,消息重试把积分扣了两遍

系统后期把事件写入改成通过消息队列消费,重试随之而来。同一个“发货完成”的消息被重复消费时,事件表里插入了两条相同的“商品出库”事件,库存多扣一次。排查了两天才找到,最后靠 event 表中的 biz_no 加唯一索引解决。任何事件接入上游消息,都必须带上能识别业务本身的幂等键,否则再漂亮的建模也会被重复数据搞脏。

4.4 坑四:权限只做了页面按钮控制,事件层完全裸奔

旧系统里权限是“谁能打开积分调整页面”,但事件创建接口完全没有校验调用者身份。REA 的思维纠正这个问题很直接:权限判断必须发生在“谁(agent)创建哪类事件”的位置。事件表里的 from_agent_id 就填当前登录用户,创建之前检查这个 agent + event_type 的组合是否有权限。把校验下沉到事件服务里,页面上的按钮只是开关,不是安全边界。

4.5 坑五:余额查询全表聚合导致性能崩掉

事件明细表写多之后,每次查员工余额都全表 SUM,接口慢到不可接受。处理办法是引入汇总表:日常查询走累计余额缓存,事件流依然是唯一真相,异步把最新事件汇总刷新到汇总表。事务边界内同步维护累计余额字段,或者异步补偿,取决于并发量。这个不算违背 REA,而是 REA 落地必要的工程补充。

问题典型现象排查思路/解法
单据被当资源订单表一堆状态字段不建单据主表,用事件聚合
一个事件塞多种变化冲销时不知道对冲谁拆事件 + group_id 聚合
重复事件库存/积分被多扣biz_no + event_type 唯一索引
事件接口无权限任何代理都能创建事件权限下沉到事件创建服务
余额查询慢全表 SUM 性能差物化汇总表 + 异步刷新

5. REA 的适用边界,以及我把这套模型推广给业务方的经验

5.1 什么时候不要上 REA

REA 不是银弹。如果只是一个纯展示的内部后台,比如配置项表加操作日志表的 CRUD,直接用普通表结构更快,没必要上事件溯源。业务没有多代理、多资源流转,也没有对账和审计需求时,追加式事件存储反而显得笨重,查询还要聚合,徒增复杂度。我的判断线很明确:读多写少、状态几乎不变、没有多方对账的系统,别上 REA;写多、状态多变、有审计和对账刚需的系统,才能把 REA 优势发挥出来。

5.2 好记性不如烂账本,推给业务方时可以这样讲

把 REA 讲给运营和财务听,不能直接说“资源-事件-代理”,他们听不懂。我的办法是把事件流水页面直接放到他们面前:每一行就是一笔不可抵赖的账,积分是怎么来的、怎么没的、谁操作的,全部可见。财务要月报,就拉一段事件视图;运营要状态,就聚合一个订单视图。“唯一的真相就是事件”这句话说出来,业务方很难反驳,因为他们天天在对账。

5.3 我实际推了一次之后最深的体会

这次重构让我最深的体会是:旧系统真正的敌人不是某张表设计得差,而是没有把“当前状态”和“历史事件”分开。REA 模型给了我一个原则——状态只是事件汇总的影子。从那以后,我再遇到需求里加“新状态”,第一反应不是给订单表加字段,而是想:这个状态是不是某条新事件的投影。只要事件层不出错,上层的视图怎么变都行。

最后分享一个小技巧:新系统上线初期,我把事件表做成了一个对内部同事只读的查询入口,任何人都能按月、按代理、按资源查流水。这让大家慢慢养成了“看流水、不猜状态”的习惯。虽然刚开始增加了一点沟通成本,但这套以事件为唯一真相的框架,后来撑住了大量需求变更和月底对账。如果你也正在被各种状态字段追着跑,不妨把业务倒回三个字母:资源、事件、代理,重新画一遍。

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

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

立即咨询