ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

REA建模实战:资源、事件与参与者如何解决对账和审计难题

REA建模实战:资源、事件与参与者如何解决对账和审计难题 我第一次拿到“rea”这个项目代号时第一反应是命名的人可能键盘没按明白。三个字母没有文档、没有原型文件夹里只有一个建到一半的模块骨架。团队里有人猜是“real”的缩写也有人猜是某个业务词的拼音首字母。直到我们把历史需求和现有系统逻辑全部翻出来对齐才意识到这个代号不是随手起的它恰好对应了会计与信息系统领域一套很有分量的建模框架Resource、Event、Agent也就是资源、事件、参与者。这套框架核心要回答的问题很简单在复杂业务场景中谁、在什么时间、处理了哪些资源、产生了什么变化并且保证这些记录事后查得到、对得上账、不会被中间状态坑掉。所有跟订单、库存、资金、报销、资产相关的系统本质都在做同一件事——把“钱和物”的每一次变化老老实实记下来。REA 恰好是这件事最稳妥的建模方式之一。这篇内容会围绕一个只有三字母代号的项目讲清楚它的核心架构拆解、数据落地方式、实操步骤和我在真实开发中遇到的坑适合正在做业务系统的开发者、产品经理以及需要梳理审计链路的相关同学。如果你觉得自己现在的系统“对账永远差几块钱”“历史数据说不清楚”“加一个业务类型就要改表结构”那这篇文章大概率能给你一个可参考的答案。1. 三字母代号背后项目定位与需求拆解1.1 先别急着写代码把代号拆开看接手一个只有三字母代号的系统第一步不是去猜它未来要做什么而是看它现在已经在做什么。我把代码审核了一遍发现模块里反复出现了几个概念库存记录、出入库单、经办人以及大量的余额查询逻辑。这不就是“资源—事件—参与者”的雏形嘛。REA 框架中有三个最基础的元素Resource资源库存里的实物、账户里的余额、系统中的积分权益、平台上的服务资格。资源的特征是稀缺、有明确数量、能被处置。Event事件一次入库、一次出库、一次转账、一次下单、一次审批。事件记录的是资源的“变化行为”带有时间戳和业务上下文。Agent参与者客户、供应商、内部操作员、自动化任务、外部系统调用。参与者是事件的发起方或承担方。在传统开发模式里我们习惯把业务抽象成“一张主表 多张字典 若干状态字段”看起来结构简单但随着业务往前走状态会越叠越多。曾经有一个模块处理订单时搞了十几个字段判断“已下单、未支付、已支付、已发货、部分退款、已完成、已关闭”每来一个新流程就加一个新的 if 分支。这种设计短期能用后期就是灾难。而当我尝试把这些流程拆到“资源—事件—参与者”三层时思路会立刻清晰起来订单本身不是一条要反复更新的状态记录它是“下单”这个事件发生之后留下的结果视图。我们不需要在一张订单表上维护十几个状态而是把下单、支付、发货、退款分别落成一个个不可变事件再按需聚合出当前状态。这样建模项目骨架自然就稳了。1.2 一个模型治住三个老大难对账、审计、口径统一这套建模思路能解决的其实不只是代码组织问题。我后来在实际落地中发现它对三个长期困扰业务系统的问题尤其有效。第一个是对账。“账面余额”和“实际库存”对不上是库存和财务系统最常见的矛盾。传统做法是把余额存在一张汇总表里每次业务发生时在这个字段上加加减减一旦某一次操作没有执行成功、脚本重复跑了一轮、或者业务判断漏了一种状态余额就悄悄错了而且很难定位是哪一个环节出了问题。REA 的思路则不维护中间余额作为唯一真相而是把所有增减的原始事件记录下来余额随时可以由事件流重新计算。这就像银行打印给你的交易流水余额永远可以靠逐行加回来而不是只给你一个最后数字。第二个是审计。审计人员问的最多的就是这笔数量变化的依据是什么谁授权的对应哪一张原始申请单如果你系统里只存了一张结果表这些问题很难回答。而 REA 把业务行为拆成“资源、事件、参与者”之后天然形成一条链路哪个参与者、做了什么操作、影响哪些资源、和哪些事件互相关联。审计需要的原始凭证本质上就是这套链路。第三个是口径统一。业务部门说“本月销量”财务部门可能算的是“本月回款”仓库部门又按“本月发货”来统计。三者数据经常不一样。REA 把不同口径落到不同的层次库存看资源层的存量销售看事件层的销售订单事件回款看资金流入事件。口径不同但底层的原始数据是同一条大家对上的是同一个发动机而不是各拿各的车身数据。1.3 模型选择逻辑为什么不是“余额加流水表”有人会问既然要记录变化那我直接建一张“交易流水表”不也行吗现实中很多系统确实就是这么做的表结构大概是“流水ID、业务类型、数量变化、变更后余额、备注”。这种做法已经比只存余额好很多但它仍然不是 REA。区别在于流水表通常只是“事实记录”它没有建立资源、事件、参与者三者之间的关系网络。比如一张出库单同时把商品库存和应收款关联起来在流水表里是一条数据加若干备注在 REA 里“出库”这个事件会通过库存流出关系和资金流关系产生两条关联记录同时对接客户这个参与方。查询“某个客户贡献了多少毛利”时流水表需要靠各种条件拼接REA 则从参与者出发沿着事件图直接走到资金和货品资源上路径清晰得多。更关键的是流水表为了查询方便常常会把业务结果冗余在同一行里。一旦出现后续更正比如退款发生时还要去改原流水。REA 的设计底线就一条原始事件只追加、不修改。如果要纠正就再记一个新事件。这样系统里不会存在“被悄悄改掉的旧账”任何一条数据的历史都能回溯。正因如此我对这个三字母项目最终定下的架构方向才是在基础表之上引入 REA 式的建模而不是在老代码上继续打补丁。2. 资源、事件、参与者怎么画领域建模的核心逻辑2.1 资源不是简单数据它是“可被处置的对象”资源是 REA 模型里最容易被低估的一个概念。很多同学把它等同于“数据库里的一张表、一个字段”例如拿库存表当作资源表拿余额字段当作资源。但这里面有一个微妙区别库存表和余额字段描述的是“当前时刻的状态”而资源实体描述的是“一个对象可以被保留、消耗、转化、产出”。举个例子“一批原材料”是资源“产成品”也是一种资源两者之间的变化不能简单说成“原材料减少、产成品增加”而是一次生产事件完成了资源的转换。如果不把资源实体单独建模只是在订单明细里写死“这个东西从 A 变成 B”后面想做物料溯源就会很难。研发团队整理一个物料从哪里来、成本如何分摊时往往只能靠人工翻单据就是吃了这个亏。实际操作中的一个经验是把资源的定义分成两层。上层是资源类型resource_type比如“商品”“资金”“优惠券”“原材料”下层是资源实例resource_instance 或者 resource_in_flow比如“SKU 编号为某个具体商品的这批货”“合同编号对应的那笔应收款”。这样资源既能做分类统计也能精确到每一批每一笔。2.2 事件是整个模型的脊柱只追加、不删除我参与过几个业务系统的重构最大的体会就是事件表是整个系统的脊柱。既然叫事件就要保证两点一是不可变性二是顺序性。不可变性指一条事件一旦写入就不能再更新或删除只能追加新事件去纠正旧事件。顺序性指事件要有统一的时序信息最好由数据库来分配单调递增序列而不是完全信任业务系统传入的时间。这套做法的直接好处是可以无压力地做“回放”。我去排查一个库存差异问题时有个环节需要看某商品过去两个月的所有变动。系统不是去找两张汇总表之间的差而是把这两个月的事件流全部按顺序拉出来逐条核对参与者和资源最终很快就定位到了某个定时任务重复触发的那两笔异常数据。如果再遇到一次对不上账这种回放能力就是救命稻草。事件也不是只有“大事件”。入库、出库、盘点、报损、调拨、调整期初每个动作都应该对应到事件类型上。哪怕是一次系统初始化也应该落成一条“初始化”事件这样整个数据生命周期从头到尾才是完整的。很多系统上线时把期初库存直接写成表格底数后来账不平了都不知道期初数据是哪来的这属于典型的脊柱断裂。2.3 参与者关系与三种常见关联参与者不只是“用户表”。在 REA 里参与者可以是人、部门、组织、机器人、外部系统账户。它对参与者的核心要求是必须具备唯一标识并且能承担经济责任。换句话说系统里只要有“谁操作了什么”的需求就必须把操作者作为参与者建模。REA 模型在事件和资源之间会产生几种典型的关系我把它拆成三种最常见的关联来理解存量关系stockflow事件影响资源的具体方向比如商品从“库存”这个池子流出资金从“银行账户”流入。stockflow 表达的是事件的增减方向。交换关系exchange一个经济事件的发生通常伴随着另一个经济事件比如“交钱”和“交货”是成对出现的两个事件之间存在交换关系。执行关系control参与者执行事件或者控制某项资源。比如某个业务员完成了一笔销售事件他就是该事件的控制参与者。这些关系看上去术语不少但落到数据库里其实就是外键关联和连接表。它们解决的痛点是不要只告诉我“库存变了”要告诉我“是什么原因导致它变了、这次变化对应哪个操作者、和哪笔资金流入是配对关系”。2.4 一个日常生活的类比买菜怎么映射成 REA如果觉得上面的概念太抽象我试一个生活场景周末你去菜市场买了两斤排骨花了一百块钱。在这个场景里“菜市场的可售排骨”是资源零售商的库存减少、你手里的现金减少都是资源的变化“购买猪肉”是事件“现金支付”是另一个伴随事件“你、卖肉的老板分别是两个参与者”。如果你是系统设计者你会记录“猪肉库存-2斤现金-100元”但这些数据变化必须和“哪一笔购买事件、哪个卖肉老板、哪个买主”联系在一起才有意义。将来某一天你想复盘“今年在菜市场花了多少钱”只要把购买事件全部拉出来沿着参与者维度聚合就能得出答案。这和传统只记一笔流水“买菜-100元”的本质区别在于流水只记录了结果REA 记录了完整的因果网络。做饭的朋友都能理解你光看冰箱里少了肉没用还得知道这肉是谁买的、在哪家买的、哪个时段买的。放在企业系统里这就是对账和审计的基础。3. 从概念到数据库一张业务流水表的设计实操3.1 核心表结构与建表语句理论说了一堆必须落到能抄作业的程度。这里我给一套简化版本的表结构覆盖 REA 最基本的资源、事件、参与者三层以及关联关系。实际项目里你可以在这个基础上扩展不用照搬全抄。参与者表agentCREATE TABLE agent ( agent_id BIGINT PRIMARY KEY COMMENT 参与者唯一标识, agent_type TINYINT NOT NULL COMMENT 1客户 2供应商 3内部人员 4系统任务, external_no VARCHAR(64) COMMENT 外部系统对应编号, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, UNIQUE KEY uk_external (external_no) ) COMMENT 参与者表;事件表eventCREATE TABLE event ( event_id BIGINT PRIMARY KEY COMMENT 事件唯一标识, event_type VARCHAR(32) NOT NULL COMMENT 事件类型: 采购、销售、盘点、调整等, event_no VARCHAR(64) NOT NULL COMMENT 业务单据号, occurred_at DATETIME NOT NULL COMMENT 业务发生时间, seq_no BIGINT NOT NULL AUTO_INCREMENT UNIQUE COMMENT 顺序号用于严格排序对账, remark VARCHAR(255) ) COMMENT 事件主表;资源表resource 简化版CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY COMMENT 资源唯一标识, resource_type VARCHAR(32) NOT NULL COMMENT 资源类型: 商品、资金、优惠券原料等, code VARCHAR(64) NOT NULL COMMENT 资源编号, name VARCHAR(128) NOT NULL, unit VARCHAR(16) COMMENT 单位, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_resource_code (resource_type, code) ) COMMENT 资源表;事件资源关联表stockflow表示某个事件对某种资源的增减CREATE TABLE stockflow ( flow_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL COMMENT 关联事件, resource_id BIGINT NOT NULL COMMENT 关联资源, quantity DECIMAL(18, 4) NOT NULL COMMENT 变动数量正负表示增加或减少, direction TINYINT NOT NULL COMMENT 1流入 2流出, unit_price DECIMAL(18, 4) COMMENT 单价可选, created_at DATETIME NOT NULL ) COMMENT 事件资源变动表;事件参与者关联表participationCREATE TABLE participation ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role_type VARCHAR(32) NOT NULL COMMENT 执行者、接收方、批准方, created_at DATETIME NOT NULL ) COMMENT 事件参与者关联表;这套结构的核心特征是事件主表是脊柱stockflow 记录资源增减participation 记录谁参与了事件。余额和库存数量不在这里冗余需要时通过聚合查询生成。3.2 业务场景映射从下单到回款下面用一个简化的销售场景演示如何把业务行为映射到 REA。假设业务链路是这样客户 A 下单购买 2 件商品 X每件售价 50 元客户付款 100 元仓库发货减少库存 2 件。映射到 REA 后至少会有一条销售事件和一条收款事件销售事件触发资源“商品 X”库存流出 2 件同时产生一条应收/现金流入 100 元的资金流收款事件产生资金流入 100 元并与销售事件形成交换关系。参与者客户 A 分别与销售事件、收款事件建立关联。这个设计的好处是查询“客户 A 在某一时间段带来了多少收入”时不需要去看所有订单明细和回款单对不对得上只需要把该客户参与的事件筛选出来再沿 stockflow 拿到对应的资金资源变动就好。审计人员问起来也能从“资金从哪里来”一路追到“货往哪里去”。3.3 数据写入与对账演示还是这个销售场景我演示一下核心写入语句。这里假设销售事件 ID 是 1001商品 X 资源 ID 是 2001资金资源 ID 是 3001客户 A 的 agent_id 是 5001。插入销售事件INSERT INTO event (event_id, event_type, event_no, occurred_at) VALUES (1001, SALE, OE2024001, 2024-06-01 10:00:00);插入销售对应的库存流出INSERT INTO stockflow (flow_id, event_id, resource_id, quantity, direction, unit_price) VALUES (9001, 1001, 2001, -2, 2, 50.0000);插入销售对应的资金流入INSERT INTO stockflow (flow_id, event_id, resource_id, quantity, direction, unit_price) VALUES (9002, 1001, 3001, 100, 1, NULL);插入客户参与关联INSERT INTO participation (id, event_id, agent_id, role_type) VALUES (7001, 1001, 5001, CUSTOMER);按这个思路插入多条数据后就能用 SQL 做最基础的对账。比如检查某个时间段内的库存账是否平衡SELECT r.code, r.name, SUM(sf.quantity) AS total_change FROM stockflow sf JOIN resource r ON r.resource_id sf.resource_id WHERE sf.resource_type 商品 AND sf.created_at BETWEEN 2024-06-01 AND 2024-06-30 GROUP BY r.code, r.name HAVING ABS(SUM(sf.quantity)) 0.001;如果期望这个期间期末剩余数量是 100期初是 98那么 SUM(quantity) 必须是 2否则中间一定存在未记录的事件。REA 模式下这种对账不是靠估算而是运算结果。3.4 幂等设计与重复事件防护很多同学在实践 REA 时会遇到一个非常现实的问题消息队列重试、定时任务重复跑、断网重传都会导致同一条业务事件被重复插入。一旦重复对账必然不平。解决方法是在事件表的设计上增加业务唯一键。常见的做法是用“事件类型 业务单号 资源 参与者方向”构造一个唯一索引。对于销售事件event_no 本身一般就是唯一的所以可以在 event 表上直接给 event_no 加唯一约束。对于更细粒度的 stockflow可以用“event_id resource_id direction”作为唯一约束这样同一个事件对同一个资源只会产生一条变动记录。写代码的时候我习惯先插入事件表再插入 stockflow 和 participation插入时捕捉到唯一键冲突就直接返回“事件已存在”而不是把整条业务当作异常抛出去。因为这个原因重构后的系统在运维上省了很多事没有再因为重复调度而出现账目翻倍的问题。4. 落库后的常见坑与排查思路4.1 坑一把“余额”和“事件”混在一张表这是我一开始频繁踩的坑。某模块为了查询方便在事件明细表里直接冗余了一个“变更后余额”字段。刚开始确实很顺手一条 SQL 就能看到当前余额。但到了月末对账发现两三笔数据不对想追查时却发现原事件还在但是“变更后余额”被不知哪段逻辑更新过了。也就是说用来做依据的数据本身已经不可信了。REA 的正确做法是余额永久只靠聚合计算得出事件表里不存变更后余额。如果查询有性能压力可以建一张“统计快照表”来定期生成余额结果但原始事件表绝不能被回写。一旦你打破了“事件只追加、不修改”这条底线对账能力就名存实亡了。4.2 坑二数值精度与舍入方向不一致涉及资金或者数量最头疼的问题就是小数。不同模块如果有的用浮点、有的用 Decimal有的四舍五入有的直接截断最后必然差几分钱。我的经验是金额一律用 decimal(18, 4) 甚至 decimal(20, 6) 存储展示层再自行处理小数位数数量根据业务需要统一定义精度库存场景一般用 4 位小数。计算时先乘后除、避免多次舍入导出报表时再统一四舍五入。这里有一个很容易忽略的细节同一张单据里的数量、单价、金额最好能从精确的数量和单价推导出金额。例如先确定数量 2、单价 50那金额就是 100不能脚本里先算出一个 99.999999再手动四舍五入成 100。所有计算必须在同一种精度下完成避免各算各的。4.3 坑三参与者标识混乱导致审计断裂参与者相关的坑通常不是建模的问题而是数据治理的问题。系统刚开始上线时大家都在库里手填参与者编号A 系统里叫“客户甲”B 系统里叫“客户 A”甚至同一批客户在两张表里存在多个 ID。只要是这种情况就算 REA 模型建得再漂亮聚合查询也会出错。处理方式就是在上游统一参与者主数据。无论哪个系统只要涉及同一类业务参与者都必须使用同一个“全局参与者编号external_no”。如果没有全局主数据至少也要建立一张映射表把各系统编号翻译成统一 ID。如果做审计链路这点不解决其他都白搭。4.4 坑四只靠时间字段判断先后顺序事件之间的先后顺序不能只靠业务时间字段来判断因为业务时间来自客户端、可能有时区问题、也可能被人为修改。要保证事件流的严密性最好的方式是给事件表增加一个数据库自增的 seq_no以写入顺序作为权威顺序。业务时间仍要保留但只是展示和业务语义参考。我遇到过的例子是同一分钟内有两条事件先后发生但因为客户端时间戳一样导致排序不确定。后来把所有事件读取逻辑都改成按 seq_no 排序问题才彻底消失。涉及资金和库存时顺序就是真相必须单独维护一个权威顺序字段。4.5 一次典型对账不平的排查实录有一阵子系统反馈月末对账总是差 0.35 元。按照传统排查方式要翻几十张报表费时费力。换成 REA 后我直接在事件流上做了三件事。第一步用 SQL 把所有资金类 stockflow 按日聚合和目标余额差对比缩小到某一天。第二步把那一天的事件全部拉出来逐条核对参与者和事件类型发现有一笔“手工调整”事件被记成了负向资金流入。第三步去查对应操作日志发现是运营人员在做调整单时选择了相反的方向导致。修正之后重新归档一条正确事件对账恢复平衡。这种排查能这么快核心还是因为数据模型里存在着“事件—资源—参与者”的完整链路。否则光靠一个数字反查原始凭证在传统表结构里几乎要人肉翻遍所有日志。5. 再往前走一步REA 带来的扩展价值5.1 审计日志顺手就有的底气项目上线一段时间后内部审计忽然提出要求所有货值变动必须能追溯到原始单据。以前遇到这种要求团队多少要临时加日志表、写拦截切面。但在 REA 模型的系统里这几乎不需要额外开发事件表本身就是审计记录stockflow 就是资源变动的明细participation 就是操作链路。当然事件表里可以再增加一些审计字段比如操作者 IP、调用链 trace_id、业务上下文摘要。这些不是 REA 模型的核心但可以很方便地放在 event 表或者扩展表里。相比于传统系统需要新增一张“审计日志表”REA 天然把业务日志和审计日志统一到了同一个底层数据源里既节省存储也避免了两张表数据不一致的风险。5.2 与外部系统的对接映射层在实际落地中REA 模型并不是要推翻所有现有系统。很多老系统已经有自己的订单表、库存表、财务凭证表我们只需要做一层适配把老系统产生的业务事件通过消息推送或者定时同步转换成标准 REA 事件写入核心事件库。比如老系统每次新增订单核心脚本会把“订单创建、支付、发货”拆成多条 REA 事件。这样既不影响老系统的查询展示也能让核心对账层获得完整业务链路。这里要注意的是转换过程必须做幂等。外部系统可能会重推同一个事件同步脚本必须用外部流水号事件类型作为唯一键保证重复推送不会重复记账。映射层本身很薄但它是 REA 系统能与其他业务系统并存的关键。5.3 事件累积后的性能优化方向如果按事件流全量存储时间长了之后事件表的数据量会非常大。这时候常规优化手段就可以派上用场按月份做分区表、对 seq_no 和 event_type 建立联合索引、历史月份数据归档到冷存储。查询端如果不需要回放全部历史可以维护一张“累计余额快照表”定期从事件流聚合结果只提供某个时间点之后的事件明细。但有一个原则不能丢原始事件表永远保留。快照表只是为了查询性能可以丢弃重算事件表是唯一真相。任何清理操作都不能直接 DELETE 事件数据。只有守住这条底线REA 模型才始终具备“任何一天、任何时候都能重新算账”的能力。最后再分享一个小心得不要试图把一个 REA 项目做成“大而全”的中台。很多团队一开始就把资源、事件、参与者的标准定得非常高结果实施周期无限拉长。我个人的做法是先选一条最痛的业务链路比如库存落地跑通再用同样的模型去扩订单、资金、报销。这就像先建好一段高铁试跑再逐步延长线路比一次性铺一张全国铁路网稳得多。架构上的核心道理其实都很朴素关键是一开始就要把“事件不可变”这件事咬死。数据模型正了后面所有的查询、对账、审计都会顺起来。
RELATED READING

延伸阅读

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