上周我在梳理一个老项目的数据模型时,又翻出了一段关于库存与订单之间对不上的历史债务。那个系统当年是标准的“字段打天下”,order 表、product 表、order_item 表一应俱全,看似结构清晰,但每到月底对账、做审计追溯、或者想把业务流程往前推一步的时候,整张表就像一盘散沙。后来接触了 REA(Resource-Event-Agent,资源-事件-主体)这套建模思路,才逐渐意识到问题的根源不在于缺一张表,而在于我们一开始就把“业务状态”存成了静态字段,而不是把“业务发生”当作第一公民。
如果你也正在被这类问题困扰,或者只是听到“REA”这个缩写觉得眼熟但没真正深入研究过,那么这篇内容值得你花十分钟看完。我不打算讲晦涩的学术推导,而是从一个实际项目的重构经验出发,把 REA 的核心理念拆开,给出可以照着做的建模步骤、代码层面的落地思路,以及我踩过的几个坑。适合正在做领域建模、事件溯源重构,或者单纯想把业务模型做得更结实的开发者参考。
1. 为什么字段越加越多,业务却越来越难扩展
任何有一定年头的信息系统,都会经历从“能跑”到“能改”再到“能扩展”这三个阶段。大多数系统卡在第二阶段,表象就是:每次业务提出新需求,第一反应都是“在表上加一个字段”。加字段本身没错,但加了几年之后,你会发现问题开始变得微妙。
1.1 传统表结构的惯性陷阱
拿订单例子来说,这是几乎所有业务系统里最核心的对象。传统的设计方式是维护一张订单主表和一张订单明细表,主表上有订单状态、订单金额、客户编号、下单时间等列。订单状态通常是一个枚举值,比如待支付、已支付、已发货、已完成。
这个模型在初期非常好用,SQL 查询简单直接:
SELECT order_id, status, total_amount FROM orders WHERE customer_id = 1234;但业务方很快就发现,仅靠一个“状态”字段无法解释“这个订单为什么是待支付”。到底是用户还没打开支付页,还是支付接口回调超时,还是优惠券扣减异常导致支付被拦截?这些过程信息都被压缩进了一个状态值里。于是你开始增加查询日志表、操作记录表、状态流转表。
紧接着第二个问题浮现:金额本来就是订单明细里商品价格乘以数量算出来的结果,却因为下游财务系统需要,被冗余存储在了订单主表上。这个冗余的动作引入了“不一致”的可能——订单明细变了,主表金额忘记更新;优惠活动改了,快照金额没有同步。
1.2 状态与结果的本质问题
第三个问题最隐蔽,也最关键:“状态”是一个静态快照,它没法回答“这单从创建到支付经历了哪些环节、每个环节由谁触发、涉及哪些资源变动”。而这恰恰是现代业务审计和流程优化最需要的数据。
究其本质,传统模型把“业务事实”和“业务解读”混为一谈。订单状态是解读,支付成功事件是事实;订单金额是解读,商品定价与成交数量是事实。当你把解读当作主要存储对象时,事实本身的完整性就被牺牲了。
REA 的出发点是反过来的:先把业务世界里不变的事实找出来,再把所有变化记录成事件,最后随时可以从事件流里推导出任意时刻的状态和结果。这个思路虽然最初诞生于会计信息系统领域,但今天放到订单、库存、账务等几乎所有核心业务场景里,依然不过时。
2. REA 三要素:资源、事件、主体
REA 的全称拆开是 Resource、Event、Agent,分别对应资源、事件、主体。这套模型最早在会计信息系统研究中被提出,出发点是为了让业务建模能够直接反映真实世界的经济交换,而不是为了迎合某张数据库表的结构。
我第一次接触这个理论时,只把它当成一种“画 E-R 图的新姿势”。真正用了之后才意识到,它的价值不在于图怎么画,而在于它逼着你在建表之前先回答三个问题:业务里到底哪些东西是资源,哪些动作是事件,谁在参与这个事件。
2.1 资源:能够被交换或消耗的实体
资源在 REA 中指的是那些具有经济价值、可以在事件中被交换、转化或消耗的实体。库存商品是资源,现金是资源,固定资产是资源,甚至一段时间的服务能力也可以视为一种资源。
在技术建模上,识别资源有两个标志。第一,它是事件作用的对象,而不是事件本身。“存储商品”不是资源,商品本身才是;第二,它需要支撑数量、金额或可用性的度量逻辑。一个纯粹的字典数据,比如商品分类,就不算资源,因为它本身不参与业务事件的价值交换。
一个常见的误区是,资源系统上线之初容易把所有名词都建模成资源,导致模型里充满“并发查询用户是否同时存在”这种没有业务意义的关系。真正的资源一定会在某个事件里被增量或减量,如果它的数量自始至终不变,那它就只是配置数据。
2.2 事件:业务发生的真实记录
事件是 REA 的核心。它不是系统的操作日志,而是业务域内实际发生的、具有经济后果的动作。“用户点击了按钮”不是事件,“支付成功,资金从客户账户流入公司账户”才是事件。
区分事件和日志的一个好办法是:看这个动作是否会导致资源的增减或权力的转移。如果是,那它就是一个 REA 事件;如果不是,它只是技术埋点。订单创建、支付到账、货物出库、退货入库,这些是事件;页面停留时长、接口调用耗时,这些不是。
在落地时,我倾向于把事件理解为一种不可变的业务事实。它应该包含时间、参与者、影响到的资源和资源变动量。换句话说,事件就是“谁在什么时间,对什么资源做了什么”。把这些维度固定下来,后面无论做审计、分析还是状态推导,都有据可依。
2.3 主体:谁参与了业务动作
主体是事件的参与者,可以是个人,也可以是组织、系统角色。它回答的是“谁发起了这个事件”和“谁接收了这个事件的结果”。在订单场景里,客户是下单事件的主体,运营人员是订单取消事件的主体,仓库操作员是发货事件的主体。
主体建模的关键是区分“具体的人”和“角色”。具体的人会变动,比如一个客户换了个手机号,他还是他;角色是稳定的,比如“审核员”这个角色不会因为操作者的离职而消失。事件流里应该存储主体的唯一标识,并且尽量同时保存角色信息,一旦业务需要追溯“这一类角色处理过多少单”,可以直接查询而不需要回看流程上下文。
把拆解三个要素串起来,一个典型的业务事实可以表达为:某主体在某个时点作用于某项资源,引起资源数量或状态变化的事件。这样一个表达,天然支持从“财务视角”和“运营视角”同时解读同一份数据。
3. 使用三要素重构一个订单系统
理论讲完了,重点看怎么落地。我拿一个常见的“客户下单-支付-发货”流程来演示 REA 建模的完整过程。为了方便描述,假设业务比较简单,但足够说明问题。
3.1 现有模型梳理与症结
重构前的表结构大致如下:orders、order_items、products、inventory_logs。orders 表有 status 和 total_amount,inventory_logs 记录库存变动,但只写“增加”“减少”和“数量”,没有和订单、商品在模型层面强关联。最开始看起来各部分都能用,真正需要整合的时候就乱了。
比如产品团队想做一个“库存周转效率”报表,需要知道一批货从入库到出库经历了多久、中间有多少库存被订单预占、又有多少被退货回补。这套查询在旧模型里几乎无法实现,因为没有统一的事件框架,只有离散的“库存流水”。
3.2 定义资源清单
第一步,列出这个业务里真正的资源。商品是资源,因为它是被买卖的对象;可售库存额度也是资源,因为它反映了当前可以承诺给客户的数量;资金是资源,因为支付发生后它从客户向商家转移。
这里有个经验:尽量不要把“订单”本身建模成资源。订单更像是多个事件之间的纽带,是业务为了追踪一次交换而引入的聚合标识。如果把订单当作资源,你会忍不住去维护订单数量,可订单既不会被消耗也不会被生产,它不是资源。
商品库存的建模方式也比较灵活。可以直接用商品作为资源然后挂数量,也可以把每个库存批次作为单独的资源实例。区别在于粒度:前者适合对同质商品做总量管理,后者适合对不同批次做先进先出和保质期管理。在我重构的那个项目中,因为涉及批次效期,所以把“库存批次”作为资源,商品作为它的分类维度。
3.3 识别核心业务事件
第二步,沿着业务流把关键动作列出来。下单确认、支付成功、仓库发货、客户签收、退货入库,这五个动作都会引起资源变动。
- 下单确认:商品从可用状态变成已预占状态,相当于可售库存额度减少
- 支付成功:资金从客户转移到商家账户
- 仓库发货:库存批次从在库变成在途
- 客户签收:在途变成已售出
- 退货入库:已售出商品退回,可售库存额度增加
这五个事件之间不是孤立的,它们通过“同一个订单号”关联,形成一条完整的事件链。但事件链本身并不是预先定义好的流程表,而是由聚合标识自然连接起来的。这意味着我们不需要维护“订单目前处于第几步”,只需要知道这条链上已经发生了哪些事件,就足以推导出当前状态。
3.4 明确参与主体并开始建模
第三步,给每个事件配上主体。下单确认的主体是客户,支付成功的主体是客户和支付渠道,发货的主体是仓库操作员,签收的主体是客户,退货入库的主体也是客户但触发角色是售后审核人员。
到这里,模型的全貌已经出来了。可以用如下结构来落地每一次事件的存储(习惯用 JSON 描述事件载荷,但底层表结构类似):
{ "event_id": "evt_20241110001", "event_type": "order.confirmed", "occurred_at": "2024-11-10T14:23:05+08:00", "agents": { "customer_id": "c_84291", "operator_id": null }, "resources": { "sku": "P100023", "batch_no": "B20241101", "delta": -1 }, "link": { "order_id": "SO20241110001" } }资源变动统一写成 delta 正负值,正数表示资源增加,负数表示资源减少。库存预占实际上不改变库存总量,只改变“可用数量”的语义,所以我会把“可售库存额度”单独建模为一种资源,并用事件来转换它。
事件存储完成后,订单的状态就可以通过查询事件来推导。但这个“推导”在初期实现时不要做成在线计算,因为性能不够。实践中的做法是维护一张“投影表”,每次事件发生后异步更新投影。投影表只用于查询,真正的业务事实在事件表里。
4. 从 REA 模型到可运行系统的关键配合
模型画得再漂亮,也要落到实际代码和存储里才能产生价值。这一节我讲三个直接影响可运行性的关键技术决策,以及我做出的选择。
4.1 事件存储的设计策略
事件表我会设计成一张带有“聚合标识”的追加日志。每一行就是一个事件,不允许修改和删除,只允许新增。字段上除了事件类型、发生时间、主体和资源变动,还会有一列 version,用于标记这个事件在同一个聚合里的顺序。
版本号在并发场景下非常重要。以“客户取消订单”和“仓库开始发货”这两个事件为例,如果两个操作几乎同时提交,系统必须保证它们不会互相覆盖。实现方式是在更新投影前先校验当前版本:
预期版本号不存在,拒绝写入事件实际操作里,简单的做法是在数据库行上加乐观锁,读取出当前最大版本号,写入时带上这个版本号,更新条件里比较版本号一致,这样一次写入就是原子的。事件表只增不改,所以并发写冲突的用例比较少,但校验逻辑不能省。
4.2 用投影表支撑查询
事件流的直接查询代价很高,尤其是做列表页、报表页这种面向终态的查询。投影表的职责,就是把事件流实时聚合出一份“面向读取”的数据视图。投影表不存储事实,只存储结论。
以订单投影为例,它看起来和传统订单表几乎没有区别,照样有订单状态、支付金额、发货时间这些字段。但它的资料来源完全不同,不是由业务操作写入,而是由事件处理器通过消费事件流构建出来的:
def on_order_confirmed(event): with db.transaction(): projection = get_projection(event.order_id) projection.status = "已确认" projection.reserved_at = event.occurred_at projection.save()投影表的好处是,下游系统可以继续沿用 SQL 查询习惯,不用推翻重来。同时因为事件流是唯一的权威事实源,投影表即使损坏或者漏数据,也能通过重放事件流完整重建。这个能力在传统模型里是做不到的,传统模型一旦数据被误改,往往只能靠备份恢复。
4.3 状态推导的一致性处理
在实际项目中,最终一致的窗口期会带来一些业务困惑。比如客户支付成功后,页面请求打到尚未完成投影更新的节点上,仍然看到“待支付”。这个问题不能完全靠技术消除,但可以把策略分为两种。
强一致要求出现在“资金的收付”这类场景,此时一个聚合内的所有更新必须同步完成,可以用数据库事务保证。弱一致场景出现在“订单列表展示”这类读多写少的场景,允许短暂延迟。重点是让业务方明确知道:事件的写入是强一致的,投影的读取是最终一致的,两者之间天然存在一个小窗口。
我在实践中还会给投影表增加一个 updated_at 字段,并让前端能拿到这个字段。当用户看到“该数据更新于 3 秒前”时,即使状态暂时滞后,也不会引发明显的体验问题。
5. 老系统改造的路径演进
把现有系统完全切换成 REA 事件模型,是一件工作量不小的事。多数团队做不到推倒重来,所以如何平滑演进,比如何做完美设计更值得研究。我分两个场景来讲。
5.1 从现有表逐步生成事件
如果项目已经有订单表和库存日志表,但历史上没有完整事件流,第一步要做的是用存量数据反推事件。这个过程可以叫回填或者历史事件重建。方法不复杂,把订单表里的每一条记录按业务顺序拆成事件,再按订单号聚合写入事件表。
回填的时候有个很大的坑:存量数据的精度不足。比如库存日志里只记录了“数量变更”,却没有记录对应的“事件发生时间”,也没有记录操作人员。这种情况下,我建议先在事件表里给缺失字段打上“未知”标记,然后优先保证事件链完整。不要因为个别字段缺失就暂停回填,因为哪怕字段不完整,事件流已经能支持后续的业务分析,缺口可以在未来追溯中逐步补齐。
回填完成后,把老的写路径改造成“业务操作先生成事件,再由事件驱动更新投影”的模式。这一步会有阵痛,因为操作不再直接写订单表,而是先写事件,再由监听器写投影。调试复杂度和链路长度都会增加,但这是值得的。
5.2 新功能直接建立在事件模型上
老功能平滑过渡的期间,新功能可以直接采用 REA 模型。比如项目中新做的退换货模块,就直接采用事件流加投影的架构。由于没有历史包袱,事件定义可以做得更干净。
退换货核心事件可以定义四个:退货申请、退货审核通过、仓库收货确认、退款完成。每个事件都会修改“退货单投影”的当前状态,同时触发库存投影和资金投影的更新。因为事件是独立的、追加的,后续要增加换货流程时,只需要增加“换货发出”和“换货签收”事件,对已有事件的兼容性压力很小。
这个演进策略总结起来就是八个字:新老并存、逐步替换。千万不要尝试一次性把所有模块全部重写成事件溯源,那会让项目进度失控,而且排查问题半径太大,出了问题很难定位到具体环节。
6. 常见问题与排查技巧实录
这节我按真实经历来整理几个最常遇到的问题,以及对应的定位思路。
6.1 事件表增长过快如何治理
引入了事件流后,事件表的数据量增长远高于传统订单表。一个每天 10 万订单的系统,平均每个订单产生 5 个事件,一天就是 50 万行事件记录。一年下来将近两亿行。
治理办法分两层。第一层是聚合归档,超过 90 天且已完成的事件,迁移到冷存储表,投影表仍然保留终态数据。第二层是压缩事件,有些中间过程事件对长期审计没有价值,可以在重建投影时跳过,仅保留影响资源变动的事件。
这里有个容易忽略的点:如果事件源数据迁移到冷存储,投影重建时就不能直接读取热事件表,需要把冷存储也纳入重放范围。所以在设计归档策略时,一定要保证冷热数据可以按时间顺序无缝拼接,否则会导致重建后的投影与最终真实状态不一致。
6.2 投影与事件不一致的排查思路
投影和事件不一致,最常见的现象是“订单显示已签收,但支付事件还停留在待支付”。排查思路是先定位差异发生在哪条链路,而不是盲目重算。
第一步,查事件表里的订单事件链,确认事件之间的先后顺序以及是否缺少关键事件。第二步,查投影表的更新时间,看它是否晚于事件发生时间。第三步,查看消费事件的日志,确认是否有异常导致事件没有进入处理器。多数情况下问题不在事件本身,而在投影更新的处理程序。
如果是历史数据不一致,可以写一个对账脚本,按订单号批量比较事件推导出的状态和投影状态,把不一致的记录输出到文件,然后再决定是手动修正投影还是触发重放。这个脚本建议做成定期任务,每周跑一次,数据准确度会明显提升。
6.3 事件定义粒度怎么把握
事件定义得太粗,比如把“下单确认”和“支付成功”合并成一个事件,会导致很难回答“从下单到支付平均耗时是多少”这类问题。定义得太细,比如把“支付接口开始返回”也定义成事件,会导致模型里充斥着没有业务含义的技术噪音。
我的经验是:事件的粒度应该对齐“资源发生实际变化”的边界,而不是对齐“系统接口调用”的边界。资源变了,才产生业务事件;接口调用只是产生事件的技术前置条件。这个标准在大多数业务场景里都是可用的。
7. 一次完整改造的复盘清单
整篇内容偏“思路向”的总结已经不少,最后我以一次实际改造为例,把可以复用的步骤整理成清单,方便你直接对照执行。
7.1 分阶段的执行步骤
改造一套系统的关键节点,我按照动作顺序排列如下,每一步都对应明确的产出物。建议按顺序走,不要跳步:
- 理清资源清单,输出一份资源字典,每个资源要标明定义、度量单位、参与哪些业务事件。
- 以资源字典为输入,梳理业务事件清单,给每个事件编号,标明参与主体和资源变动量。
- 设计事件载荷格式,统一时间格式、编号规则、版本号策略。这里的重点是让所有事件能够被程序稳定解析。
- 建立事件存储模型,任何业务写操作先落事件,再通过事件处理器更新投影。旧代码这期间保留老表逻辑,只做双写。
- 开发存量回填程序,把历史订单与历史库存转化为事件,逐批校验事件链完整性。
- 启用对账脚本,比对事件与投影的一致性。对账脚本要覆盖每日新增数据和全量历史数据两个维度。
- 切换查询层到投影表,确认下游报表系统可以直接读取投影数据。
- 灰度切换写路径,先让百分之十的流量走事件流,验证无误后逐步放量。
这套流程走完,差不多需要三到四周时间,具体取决于团队对事件溯源和相关模式的熟悉度。我自己第一次走这个过程时,在校验历史数据上花的时间比预期多了一倍,因为老系统里很多数据和业务实际是脱节的,需要不断和产品确认后才能补全。
7.2 成本收益的实际感受
改造期间最直观的代价是开发复杂度会上升。原来一段更新订单状态加更新库存的代码,前后不过几十行,现在要在事件写入、事件处理、投影更新之间来回调试,链路确实长了不少。
但改造完成后的收益也让人印象深刻。首先是新增业务功能的速度加快了。以前加一个“预售转普通库存”的业务动作,需要改订单表、库存表、报表查询逻辑;现在只需要新增一个事件类型,再写一个投影处理逻辑,独立发布即可,改动边界非常清晰。其次是排障能力强了很多。以前遇到数据问题,要翻多个日志系统猜测原因,现在直接查事件流就能还原现场。
如果非要给一个建议,那就是不要在没有明确业务痛点的情况下为了用而用。如果你当前的系统只是内部几十人使用的后台,业务变动不频繁,REA 带来的成本可能大于收益。但如果你正在构建的是多方交易、长期审计、流程易变的中大型业务系统,那么尽早迁移到事件驱动加 REA 的思路,是一个非常值得投入的方向。