ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从REA建模到事件驱动架构:业务系统设计的资源、事件与代理人实践

从REA建模到事件驱动架构:业务系统设计的资源、事件与代理人实践

手头拿到一个立项名,只写了“rea”三个字母,没有需求文档、没有原型图、没有验收标准。按理说这种项目可以直接打回让业务方补材料,但真正做过业务系统重构的人都明白,很多问题不是需求没写全,而是压根没想清楚。我判断“rea”大概率对应业界那套 REA 建模思路——核心是Resources(资源)、Events(事件)、Agents(代理人)三个词的首字母。这篇文章把我在实际项目里怎么从这个模糊标题出发,一路拆到数据库表和接口设计的过程完整讲一遍,包括踩过的坑和最后沉淀下来的检查清单,适合刚接触业务建模的后端开发、架构师,以及长期被“需求变来变去”折磨的产品经理参考。

1. 项目首析:标题只有 rea 三个字母,怎么把需求做成可执行的方案

1.1 三个字母的公开解读与私人心得

先聊一个很多人会问的问题:rea 到底是不是这三个词?其实业界对 REA 的解读非常统一,它最早出现在会计信息系统领域,最初目的解决的是账务数据和业务事实脱节的问题。后来不少 ERP、供应链系统的设计者开始用它做企业级流程建模,因为它提供了一个极度稳定的业务骨架:任何一笔业务都能拆成“谁在什么时间做了什么动作、影响了哪些资源”,这个骨架天然适合计算机落地。

但如果只把 REA 当成三个名词的缩写,它就变成了一句正确的废话。我个人的理解是,REA 真正值钱的地方在于“强制你从事件出发,而不是从状态出发”。普通设计习惯是以单据为核心,POS 单、入库单、库存表、应收表一堆东西摆在那里,系统要处理的是“把单据 A 转换成单据 B”的复杂流转。而 REA 要求你先放下单据,问一句:业务里到底发生过什么?这个动作叫什么?谁参与?动到了哪笔资源?

这句话初看轻飘飘,实际上是两种完全不同的开发世界观。以单据为核心,意味着将来每一次需求变更都可能加字段、加状态、加接口;以事件为核心,意味着业务变动大概率是“新增一种事件类型”,核心表和核心逻辑可以长期稳定。回到 rea 这个项目,我当天就做了一个关键决策:不急着出原型,先带着 REA 视角重新梳理业务流程,把所有参与方、业务动作、资源对象列成一张大表,再回来谈技术方案。事实证明这一步省了后面至少两周的返工。

1.2 为什么事件视角比数据视角更适合业务系统

用生活化的类比来解释:传统的表结构像“记流水账”,每个业务对象保存自己的最新状态,比如库存表里直接写“当前数量=500”。而事件视角像“装监控摄像头”,系统里只记录精确到明细的事实,比如“张三在 9:45 卖了一台设备给李四,单价 1000”。库存表里那 500 是怎么来的?靠回放这些事件算出来,或者定期汇总事件结果生成快照。

这两者有什么区别?假设某天财务说库存数字对了,要查是哪笔记录把库存搞错了。传统结构里你只能看到当前数量,至于哪些单据发生过、先后顺序如何,很难还原。事件结构里,直接把所有库存节点上的交易事件拉出来排序,哪个环节算错一目了然。这个特性在涉及多人、多部门、多系统交互的场景下特别值钱,因为它给了系统一种“可追溯真实”的能力。

另一个好处是系统之间的集成变简单了。很多老系统联调困难,是因为 A 系统把结果直接写死给 B 系统,B 系统只能被动接受结果,一旦数据中间有偏差两边根本说不清。REA 则鼓励把事件本身作为上游产出物,大家共享同一份事实流,下游系统各自用自己的规则去解读。总线、消息队列这类基础设施本质上干的就是这件事。所以 rea 项目里我坚定地选择“事件先落地、状态按需算”的架构取向,这也是后面所有设计的原动力。

2. 核心原理拆解:资源、事件、代理人三角怎么用在架构设计上

2.1 资源:一切要管的“东西”

资源在 REA 里是个很宽泛的概念,可以是实物资产(库存商品、生产设备),也可以是非实物的(现金、应收账款、某段时长的服务能力),核心特征是“可以被某个业务动作消耗或产出”。比如“支付现金”这个动作,消耗的是现金资源;“收到订单”这个动作承诺了未来交付,本质上是产生了一份“应收账款”资源。

很多团队设计资源表时,习惯把“库存现有量”作为字段存在资源配置上,这种做法在 REA 里属于结果而非资源本身。资源对象本身只表达“有什么东西存在”,它的数量与状态应该由事件推导出来。例如你要在系统里表示“一批苹果”,不要建一张表专门记苹果库存数量,而是建立“苹果存货”这个资源,库存数量通过把所有“入库事件”数量求和,再减去“出库事件”数量得出。这个思路一开始会觉得绕,但真遇到并发、审计、库存月结时就显出威力了,因为汇总计算的过程本身就能被复核。

在我做的模拟项目 X 里,资源还被细分为“可分割资源”和“不可分割资源”。苹果是可分割的,1 吨苹果可以被拆成 100 份出库;一台设备是不可分割的,要么整体交付要么不交付。这一区分直接决定事件表里的数量字段是整数还是小数、有没有批次号,甚至影响后续盘点的粒度。

2.2 事件:让状态产生变化的唯一起点

事件是 REA 模型里的中心组件,也是我对 rea 项目最看重的部分。什么样的事情够格称为一个事件?我给的判断标准很简单:必须发生在某个时间点、由某个代理人执行、且直接导致至少一份资源的增减或承诺变化。不满足这三个条件的信息,优先考虑挂在资源或代理人上面,不要硬塞进事件表。

举个例子,“用户提交了售后申请”是一个事件;“用户抱怨了一句物流慢”就不是。因为前者至少改变了售后工单资源的状态,后者只是一个文本信息,不影响任何资源。落库的时候我们建了一张 event 总表,字段包括事件类型、发生时间、提交人、涉及资源 ID、数量、方向(增加或减少)、关联的上一事件 ID。这张表承担了非常重的职责,但好处是所有业务流程的“事实”都沉淀在一个地方,排查问题的时候直接对着事件流看,很少有信息黑洞。

事件与事件之间往往有先后依赖。比如“采购下单”和“收货入库”是两件事,中间隔着一个时间差,按 REA 设计应该分成两个事件。很多新手会顺应直觉把“下单后直接入库”写成一个事件,节省环节。我强烈不建议这么做,因为一旦出现部分到货、分批验收、货损少件这类混乱场景,拆开除单据之外,没有第二个能准确对账的出路。

2.3 代理人:谁参与、谁负责、谁获益

代理人可以是个人也可以是组织,包括客户、供应商、内部员工、仓库管理员、承运方。REA 特别强调一个业务事件至少涉及两个集成的代理人:一方发起,另一方接收。这不是官僚主义,而是为了保证每条事实都有明确的责任边界。

比如“收货”这个事件,发起方是供应商驾驶员,接收方是我方仓库保管员。如果事后发现货物数量不对,可以直接定位当时双方各自确认了什么;如果只有一条“已收货”的日志,没有明确谁参与,最后追责时又得从操作日志里面猜。代理人字段和权限体系高度相关,它不仅是用来展示“操作人是谁”,更是业务规则的核心输入:某个角色能否发起某类事件,是否能绕过某条校验链。

在一个跨组织协作的场景中,代理人的重要性会进一步放大。订单系统、仓储系统、财务系统各有一个“用户”概念,如果设计时只把用户 ID 当作普通外键插入事件表,跨系统识别同一个人会非常痛苦。更合理的做法是引入“代理人员主数据”的概念,无论哪个系统调用,先映射到统一的内部员工或往来单位标识,再关联权限及组织层级信息。这个设计为后续做多租户、多分公司扩展省下大量集成成本。

3. 实战落地:把采购入库到销售结账的全链路揉进 REA 模型

3.1 先圈流程边界:先定义关键事件,再反推资源与角色

拿到 rea 项目后我没有直接开写代码,而是用一个下午把所有主要业务场景的动画片拉出来,圈定三个“边界问题”:系统到底管哪一段?哪些事情必须留痕?哪些事情只作为输入参数?

以模拟项目 X 的零售购销流程为例,我先列出核心事件链:

  • 供应商报价:供应商 A 提出可按某价格供货。
  • 采购下单:采购员向供应商发出采购订单。
  • 收货入库:仓管收到实物,录入实收数量。
  • 上架陈列:商品进入可售状态。
  • 顾客下单:顾客承诺购买某些商品。
  • 收款确认:顾客支付货款。
  • 出库交付:商品交给顾客。

这个阶段不需要考虑数据库表,重点是让所有业务方对“哪些是事件”达成一致。当时业务方一直想把“库存”也列为一个事件,被我拉回来了。“库存发生变化”是结果,不是事件,它一定由更具体的事件驱动;如果你把结果也伪装成事件,后面所有追溯链条都会断掉。

边界圈定以后,再反推资源。资源清单通常比事件清单少很多,因为资源相对稳定。这个项目里资源主要有商品存货、库存现金、应收账款、应付账款、顾客承诺、供应商承诺。你能看到资源不一定都是实打实的东西,“承诺类资源”特别容易被忽略,但它在信用销售、订货合同场景里非常重要。没有承诺资源,就没有办法表达“顾客已经订了货但还没付款提货”这个中间状态。

3.2 字段表设计的替代方案:用事件记录而非可变状态

传统表设计碰到“确认收货”这类流程,入门做法是给 orders 表加一个 status 字段,0 表示待发货,1 表示已发货,2 表示已签收。REA 的做法完全不同:订单创建、发货、签收各是一条事件,事件类型不同、发生时间不同、操作人不同;订单当前处于什么状态,通过查询事件流最新记录得到。

下面我用一段简化的 schema 示意一下事件表的形态:

CREATE TABLE business_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(50) NOT NULL, -- 例如 purchase_order, stock_in, customer_payment event_time TIMESTAMP NOT NULL, agent_from BIGINT NOT NULL, -- 发起方代理人 agent_to BIGINT NOT NULL, -- 接收方代理人 resource_id BIGINT NOT NULL, -- 涉及资源 quantity DECIMAL(18,4) NOT NULL, -- 数量 unit VARCHAR(20), direction SMALLINT NOT NULL, -- 1 为资源增加,-1 为资源减少 linked_event_id BIGINT, -- 关联上一环节事件,例如“收款”关联“顾客下单” biz_no VARCHAR(64), -- 业务单号,供前端查询与打印使用 source_system VARCHAR(32), -- 来源系统标记 UNIQUE KEY uk_events (event_type, biz_no, resource_id) );

这张表设计的时候有几个细节值得说明。第一,linked_event_id 不是强外键,业务上不一定能保证每个事件都有严格的一对一上游,所以只做记录,不做级联约束。第二,biz_no 的存在是为了兼容老系统的“单号”概念,很多报表和分析依然依赖业务单号,保留它是为了平滑迁移。第三,direction 字段允许我们通过统一公式“当前库存=SUM(增加事件数量)-SUM(减少事件数量)”算总额,避免为不同类型的业务各建一套汇总逻辑。

除此之外还需要一个 resource_snapshot 表做性能缓存。它存的是每个资源在某时刻的数量快照,比如每日库存快照。快照的意义在于减少全量事件求和带来的性能压力,但快照一定是异步生成且可重新计算的,绝不能作为唯一数据源。简单说:“事件表是真相,快照表是缓存”。

3.3 一个可运行的查询示例和接口边界说明

纯 schema 不够直观,我实际跑过的查询大概是这样。要算“某个商品当前的可用库存”,只需要一条 SQL 聚集事件:

SELECT resource_id, SUM(CASE WHEN direction = 1 THEN quantity ELSE -quantity END) AS current_stock FROM business_event WHERE resource_id = 10086 GROUP BY resource_id;

如果要算“这个月供应商 A 累计供货金额”,就要先找到采购到货事件中 agent_from 为供应商 A 的所有记录,再关联到当时的约定单价。单价我建议单独放在一个价格配置表,由对应事件记录 price_version 做关联,不要直接冗余到事件表里。如果不做版本关联,供应商改价后回看历史流水会出现金额“被追溯性”的错误。这个问题我在项目里踩过一整个来回,后来统一加上 price_version 字段才算安心。

接口边界上,我倾向把所有事件统一收敛到一个写接口,类似 eventing service 入口,所有外部系统不允许直接改库存表。控制器只负责接收“谁来、对哪个资源、做什么类型的动作”,服务层负责补齐事件上下文(如当前时间、来源系统)、做业务校验、落库事件,最后触发下游读模型更新。这套链路看起来多了一层转发,但把事件写入权限收口,是后续做好审计、重放、幂等处理的基础。如果让每个业务模块直接 insert 自己的事件表,不同模块对同一资源的状态判断很容易出现分叉。

4. 常见错误与排查实录:一次真实系统跑偏后的对照复盘

4.1 事件与状态混用,导致账目对不上

项目做到中期,测试反馈库存账和实物盘亏了很多。打开数据一看,一大半原因是代码里非常自然地写了类似“库存表 num = num - 1”的语句,只更新了库存表,没有写入任何事件。库存数字变了,但查不到哪笔操作引起的。这就是典型的“事件与状态混用”——没有建立“状态必须来自事件回放”的纪律,导致事实链断裂。

纠正方法并不复杂:先让所有增量写在事件表,再允许库存表基于事件异步刷新;同一次请求里如果既有增删事件又有库存更新,必须放在一个事务里保证一致性。最初会觉得性能上有额外开销,但实际上库存表的刷新可以通过批量汇总完成,并不会导致系统变慢。更关键的是,后来再遇到对账差异,我可以基于事件流逐项排错,不再靠猜。

4.2 重复事件没有幂等设计,导致金额翻倍

有一次联调测试,发现应收账款的总额比实际业务多了一倍。检查后发现是支付回调被消息中间件重复投递,支付处理器没有判断同一笔支付事件是否已经处理过,重复写入了一条相同的收款事件。这个坑在普通表模型里同样存在,但在事件模型里特别致命,因为事件写完后不可篡改,重复一条就直接污染整个汇总结果。

解决方案是在事件表上加业务唯一键,这个唯一键不应该是数据库自增 ID,而应是业务流水号。比如支付事件用“支付渠道 + 支付渠道单号”做唯一索引,重复插入时数据库直接抛约束错误;代码层再捕获这个异常,用查询已有事件返回原结果替代再次处理。这就是幂等设计,也是事件驱动系统的基本功。哪怕你的项目不是高并发场景,我也建议从第一天就做好这个约束,因为事后补数据的成本远高于事前加索引的成本。

4.3 过度抽象,把模型做成了中看不中用的“万能库”

还有一种失败方式,是把所有东西都抽象成事件后,业务规则不知道该在哪一层执行。比如“新客户下单必须经过信用审核”、“库存不足不能发货”这类业务规则,如果建模时只盯着“一切皆事件”,就可能出现:事件无脑落库,校验规则全丢到前端,后端完全裸奔。

REA 模型对应的事件不是无序集合,事件之间是有流程约束的。实现上需要为每种事件类型定义一个“前置条件校验器”,在这个事件被写入之前检查资源和代理人的状态是否满足要求。这个做法理解起来很简单:事件表负责记录事实,校验器负责保证事实符合业务预期。它既保留了事件记录的灵活性,也避免了系统变成一本没有任何规则约束的流水账。每新增一种事件类型,第一件事是写出它允许发生的前置条件和不允许发生的情形,这条经验真的能避免项目后期大量的“脏事件”。

4.4 实例排查实录:一张表支持多业务时,代理人和资源的维度要统一

那次排查让我印象很深。上线一版系统后,运营发现同一个仓库却出了两份“实时库存”,仓储系统和销售系统显示数据不一样。两个系统各自实现了一套库存计算逻辑,仓储系统以“入库单”为准,销售系统以“销售出库单”为准,两个单据在时间口径和资源粒度上根本没对齐。仓储按“批次”记录,销售按“SKU”记录,中间没做映射,汇总结果自然不一致。

最后我们坐下来,把所有业务事件统一到同一张事件消费视图,按 SKU 维度重新梳理资源编码规则,并为批号、仓位增加维度字段。同时约定所有系统读取同一份库存快照事件流,不允许各自从自己的业务表算“库存”。改完之后两边数据终于对齐,也让大家意识到 REA 模型的真正价值不是省两张表,而是把全链路的事实口径统一起来。那件事以后,我在所有项目里都会强调“资源字典”是第一优先级:先统一资源和代理人的编码,再谈事件流转。

5. 给团队落地 REA 的实用工具与验收清单

5.1 建模会议应该问的五个关键问题

现在团队内部推 REA,基本不会一开始就去讨论表结构。我每次引导建模会议时会用一组固定问题,既能拉齐认知,也能快速暴露出业务矛盾:

问题目的
这件事发生的最原始动作是什么?确认事件,而不是把“状态改变”误当事件
这个动作发生前必须满足什么条件?找出前置校验逻辑,沉淀业务规则
有哪些人或组织参与了这个动作?识别代理人,划定责任边界
这个动作增减了哪项资源?数量怎么算?建立资源清单和数量口径
这个动作在现实世界能追溯的单据或凭据是什么?映射老系统单号,保证可迁移追溯

会议时间一般控制在两小时以内。超过这个时间还没把事件链理清楚,通常意味着业务方自己也没想清楚,硬建模只会给后续挖坑。

5.2 建模到开发落地的验收清单

开发阶段我一般会带一份验收清单,写完代码逐项打勾:

  • 每个业务动作都有唯一事件类型编码,且事件表和事件类型表正确关联。
  • 所有事件都有明确的发起方和接收方,不允许出现空代理人的事件。
  • 所有资源增减都通过事件流转,代码里搜不到“直接 update 资源表数量字段”的语句。
  • 关键业务流水号有数据库唯一约束,确保消息重复时不会产生重复事件。
  • 事件表设计时间字段与业务时间字段分离,避免把服务器时间当成业务时间导致排错混乱。
  • 快照表可以全量重建且与事件表对账一致,重建过程不需要停服务。
  • 事件检索接口支持按时间、资源、代理人、事件类型过滤,这是后续排查问题的基础设施。

这些检查项看起来简单,但漏掉任何一条,以后出问题都会让你多熬很多个夜晚。我自己排产时把这些项写进迭代的完成定义里,而不是留给测试阶段才发现。

5.3 实操心得与扩展思路

从一个只有“rea”三个字母的项目开始,走到现在这一整套稳定的事件驱动业务骨架,我最核心的体会是:模糊标题反而是好事,因为它逼着你先把业务本质想清楚。很多人觉得 REA 是会计概念,离开发很远,其实它更像一种“业务事实优先”的设计习惯。你不需要把整个系统推翻重来才能用上它,选一条最容易出乱的链路,比如库存、对账、订单状态,先按事件流重写一遍,体感马上不一样。

这套思路后续还可以继续扩展,比如把事件流接入链路追踪体系,给每个事件生成全链路 ID;或者基于事件数据做业务监控看板,统计某类事件发生频次、耗时分布,预警异常增长。我后面又在另一个项目里把相同的事件模型扩展到工单系统,连“客服接手-响应-解决-回访”这种非实物业务也能顺畅建模,可见它的适用范围比想象中要大得多。实际动手的时候,我建议你不要追求一步到位的大改造,挑一个最让团队头疼的对账场景,做一个小实验,对比改造前后的排查时间与数据准确性。尝到甜头之后,推进阻力就会小很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进