资讯详情

资讯详情

REA模型:用资源-事件-参与者重构业务系统,告别状态字段混乱

1. 别被会计术语劝退REA 模型到底在描述什么如果只给我一个词——rea我先问自己一句这到底是个项目代号还是某个缩写的残片说实话我第一次接触 REAResource-Event-Agent资源-事件-参与者模型时正卡在一个库存销售项目的需求里。当时我把订单状态、库存余量、收款记录做成了三张大表每次客户改需求我都得像拆积木一样把表之间的关系重新捏一遍。后来有人提议把业务事实存成事件流我才第一次认真看 REA 模型。它不是框架不是一门语言甚至没有一个官方开源实现可以抄它是一套思考业务系统的方式把业务拆成资源在参与者之间通过事件发生流转。这篇文章打算写给业务分析师、系统架构师、后端开发还有那些被财务和运营两头逼的项目负责人。如果你正在设计订单、库存、采购、会员积分、资金账户这类系统这套建模思路能帮你少踩很多坑。就算你完全没有财务背景只要会画表、写 SQL我也尽量把每个概念的来龙去脉拆开讲清楚。毕竟这个模型最核心的价值就是让不懂借贷记账法的人也能把业务结构画明白。1.1 三个核心概念资源、事件、参与者先说资源Resource。在 REA 模型里资源不是普通的数据表它必须同时满足几个特征有经济价值、存在稀缺性、能被组织控制。库存里的商品、银行里的现金、账上的预付款额度甚至一段可以出售的外包服务工时都算资源。判断一个对象算不算资源有一个很朴素的标准它会不会在业务动作里被消耗、产生或转移。如果会那它就是资源如果不会那它大概率只是某个资源的属性或者某条规则的配置项。再说事件Event。这是 REA 模型里最容易被误解的词。事件描述的是真实世界里发生的经济动作销售发货、采购收货、收到货款、支付货款、盘点、报损。它记录现实发生了什么而不是系统里点了哪个按钮。一个事件需要带时间、当事人、涉及资源、数量与金额。你甚至可以把它理解成一张不可篡改的流水账每进来一笔业务就添一行客观记录。后面你会发现几乎所有查询、对账、审计、财务映射都在围绕这张流水账展开。最后是参与者Agent。参与者是事件里的人和机构客户、供应商、销售员、仓管员、出纳、财务主管。在传统表结构里参与者经常被处理成订单表上的几个外键字段比如下单人审批人发货人。REA 模型则把这些参与者当成一等公民和资源一样要被建模。因为谁经手这件事和资源发生了什么同样重要尤其在权限控制、职责分离、审计追溯的场景里没有参与者信息的事件流等于白记。1.2 REA 与借贷记账法的对应关系传统的财务软件一般从借和贷开始建模。一个业务人员打开财务凭证看到借应收账款贷主营业务收入大概率要楞一下这到底是什么意思REA 模型换了一个顺序先记录业务事实比如销售员把货卖给客户并因此获得一项收款权利至于这笔业务在财务上怎么入账那是后续映射规则的事情不该让业务表结构来迁就。举几个对应关系会更清楚资源通常映射成会计上的科目比如库存商品、银行存款事件映射成分录里的交易摘要参与者映射成往来单位或内部部门。这样设计完之后财务团队想要数据时不需要跑到业务系统里反推这个字段当时是干嘛用的而是从事件流按规则生成分录即可。我后来在和财务系统对接时深刻体会到这个映射层的价值——财务科目结构调整了业务底层表不需要动改映射配置就能完成。这个视角最大的好处是业务系统不必为了迁就会计科目去建一堆名字解释不清的中间表。很多业务团队最头疼的事情就是财务说要调整核算口径、要拆成本项、要改收入确认方式如果底层表已经按照事件和资源存清楚了这些需求基本都能在规则层消化掉。1.3 为什么现代业务系统值得用 REA可能有人会问我直接用订单表、出库表、收款表不行吗十年前我也这么觉得。但你见过一个业务系统活过一年之后订单表里多出十几个状态字段的惨状吗每个业务环节都想往主表上加一个状态待确认、已审核、已发货、部分收款、部分退货、已关闭……这些状态本质上都是从事件流算出来的结果你却把它们塞进了数据的源头。一旦某个环节出现反向操作比如退货后再重新发货状态机立刻变成一团乱麻。REA 模型的做法是反过来的底层表只存事实也就是事件流资源的当前状态永远通过聚合事件来推导。这样做的好处是经得起时间考验。我实践下来的感受是新需求进来后绝大多数情况只是在加新的事件类型或者调整映射与计算规则而既有的表结构不需要大改。毕竟事实不会因为你改了一个规则就发生变化可规则会一直变。2. 用 REA 设计一个小型系统以库存销售流程为例2.1 先拆场景这套系统到底要管什么某制造企业内部的某公司要建设一套库存销售管理系统核心流程其实并不复杂销售员接受客户订单仓库根据订单发货财务向客户收款同时采购部向供应商采购原材料入库后安排付款。系统需要支持实时库存查询、月度对账、订单全链路追溯、以及给财务部门提供凭证生成依据。如果用传统思路第一版会长成这个样子订单表、出库单表、收款表、采购订单表、入库单表、付款表六张主表互相外键关联再配上若干张中间表。当时我们连订单部分发货这种最常见的业务场景都要在订单表和出库表之间反复横跳。后来切换到 REA 模型我先做了一件事把所有业务动词全部列出来。下单、发货、收款、收货、付款、退货、报损、盘点一个动词往往就对应一个事件。然后再问自己每个事件发生时什么资源的数量变了谁参与了。整个系统的骨架就慢慢清晰了。2.2 识别资源、事件与参与者一张表看清全部以这个库存销售系统为例我把识别结果整理成了下面这张表。你会发现资源不只有商品资金也要当成资源因为资金同样会通过事件发生转移。分类具体对象说明资源库存商品有编码、名称、规格、单位会被销售发货消耗、被采购收货增加资源银行存款或现金涉及与客户的收付款以及向供应商的付款事件顾客订单商务承诺事件表示打算卖事件销售发货执行事件表示真卖了库存商品减少事件顾客付款收款事件银行存款增加与销售发货构成配对关系事件采购订单商务承诺事件表示打算买事件采购收货执行事件库存商品增加事件向供应商付款付款事件银行存款减少与采购收货构成配对关系参与者客户、销售员、仓管员参与销售与发货流程参与者供应商、采购员、出纳参与采购与付款流程识别的时候有一个技巧很管用先从动词入手把所有业务动词找出来一个动词通常对应一个事件再把每个事件套进谁、在什么时间、对什么资源、做了什么动作这个句式里。比如仓管员在3月10日对库存商品A做了销售出库这句话里仓管员是参与者库存商品A是资源销售出库是事件。只要每个业务动作都能套进这个句式识别就基本不会漏。2.3 三组核心关系资源流转、事件配对与职权控制REA 模型里有很多关系但最核心的是三组。第一组叫 stock-flow 关系直译过来是存量-流量关系指的是事件改变了资源。销售发货让库存商品减少采购收货让库存商品增加都属于这一类。这种关系在表结构上表现为事件与资源之间的连接通常会带上数量、单价、金额这些具体业务信息。第二组叫 duality 关系也就是事件配对关系。REA 模型强调一个使资源流出的事件最终要对应一个使资源流入的事件。销售发货是货出去顾客付款是钱进来这一对事件形成闭环采购收货是货进来向供应商付款是钱出去这也是闭环。这个关系的意义在于它能帮你在系统里查出谁发货了却一直没收到钱这类问题而这恰恰是传统表结构最弱的地方。第三组是授权关系与控制关系。简单说就是谁有权参与某个事件、谁有责任保管某个资源。实体落地时表现为参与者和资源、参与者和事件之间的关系表。很多权限需求、审批流需求、职责分离需求其实都可以归到这一组关系上。你不用专门设计复杂的权限表来满足流程变化因为流程变化常常只是换了一个参与者角色。2.4 数据库表结构落地代码可以直接下手三组核心关系理解清楚之后数据库设计就顺理成章了。我这里用一套简化但足够完整的方式展示核心表结构。再次强调展示以常见开源关系型数据库的语法为主实际迁移到任何主流的库都没有难度。先看资源表和参与者表CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL UNIQUE, resource_name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, unit VARCHAR(16), is_deleted TINYINT DEFAULT 0 ); CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_code VARCHAR(64) NOT NULL UNIQUE, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(32) NOT NULL, contact_no VARCHAR(64) );然后是事件表与资源明细表。事件表记录发生了什么不记录剩下多少。资源明细表用于支持一个事件对应多行资源的情况比如一张发货单里有三种商品CREATE TABLE economic_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, event_no VARCHAR(64) NOT NULL UNIQUE, occurred_at TIMESTAMP NOT NULL, business_date DATE NOT NULL, counterparty_agent_id BIGINT, responsible_agent_id BIGINT, remark VARCHAR(256), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE event_resource_line ( line_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), amount DECIMAL(18,4) );事件之间的配对关系用独立的关系表保存这样既能表示销售发货与顾客付款的对应也能支持部分收款的多次配对CREATE TABLE event_relation ( relation_id BIGINT PRIMARY KEY, from_event_id BIGINT NOT NULL, to_event_id BIGINT NOT NULL, relation_type VARCHAR(32) NOT NULL, allocated_qty DECIMAL(18,4), allocated_amount DECIMAL(18,4) );这段设计里最需要克制的地方就是不要在economic_event表里加一个库存剩余量字段。这个字段是计算出来的结果不是业务事实。设计一开始你会觉得加个冗余字段查询很方便等后面所有统计口径开始打架你会哭着回来删它的。3. 实操过程从建表到可运行的服务层3.1 初始化基础数据把资源与参与者装进表里表结构建好之后第一件事是初始化基础数据。资源表里要先把商品主数据灌进去商品编码、名称、单位、类型都要准确。参与者表里则把客户、供应商、内部员工统一录入建议用agent_type字段区分内部参与者和外部参与者不要因为客户和供应商都算外部机构就把它们混在一起。这个区分在权限和审计里非常重要。我实际做的时候还会加一张扩展属性表用来保存资源的规格型号、条码、税率这类字段。不要一股脑往resource主表上堆列资源的基本属性和扩展属性分开才能避免表结构被业务方零散需求改烂。这一步看似不起眼却决定了后续一年你加字段的痛苦程度。3.2 核心业务动作的服务层实现方式接下来是服务层。用一段伪代码说明关键逻辑核心思路是每个业务动作只追加事件不更新状态字段。def create_sale_order(order_data): # 1. 创建承诺事件顾客订单 event_id insert_event(event_typeCUSTOMER_ORDER, event_nogen_event_no(SO), occurred_atnow(), counterparty_agent_idorder_data[customer_id], responsible_agent_idorder_data[sales_id]) # 2. 写入订单明细关联资源与数量 for line in order_data[lines]: insert_event_resource_line(event_idevent_id, resource_idline[sku_id], quantityline[qty], unit_priceline[price], amountline[qty] * line[price]) return event_id def ship_order(ship_data): # 1. 创建执行事件销售发货 ship_event_id insert_event(event_typeSALE_SHIPMENT, event_nogen_event_no(SH), occurred_atnow(), counterparty_agent_idship_data[customer_id], responsible_agent_idship_data[warehouse_keeper_id]) # 2. 写入发货明细 for line in ship_data[lines]: insert_event_resource_line(event_idship_event_id, resource_idline[sku_id], quantity-line[qty], amount-line[amount]) # 3. 建立发货与订单的追溯关系 link_parent_event(child_event_idship_event_id, parent_event_idship_data[order_event_id])这段代码背后有几个关键决定。第一发货明细里的数量写成负数表示资源流出这在后续 SQL 聚合时非常方便只需要做求和不需要区分流入和流出两种符号。第二我没有在发货动作里直接执行库存表扣减的 UPDATE 操作而是把发货写成一个新事件。库存可用量是通过事件流聚合出来的天然支持回滚、追溯和对账。也许有人担心性能每次查库存都全表聚合那不是慢死了吗实际上高频场景下可以做物化视图、可以按资源维度做汇总缓存但这些都只是派生数据。底层的事实永远存在于事件流中无论缓存被清理多少次都能重建出来。这一点我在后面会再展开。3.3 事务与并发控制要领避免超卖与重复入账事件流不是洪水它不是想怎么写就怎么写。每插入一个事件都必须保证三件事来源合法、引用有效、幂等唯一。来源合法指的是事件必须由有权限的参与者发起这需要在服务层做鉴权或者在数据库层通过外键约束兜底。引用有效指的是事件里引用的商品、客户、员工都必须真实存在否则事件流就是脏数据。幂等唯一指的是同一个业务动作不能生成两条事件解决方案是依赖event_no的唯一索引生成单号时使用业务类型 日期 序号的规则。在并发场景下真正复杂的是多个人同时卖同一个商品。传统做法是锁定库存记录行然后扣减。REA 的做法则略有不同def ship_order_with_stock_check(ship_data): # 1. 在事务内先查询该资源的可用量 available_qty get_available_qty(ship_data[sku_id], business_datetoday) if available_qty ship_data[qty]: raise BusinessError(库存不足) # 2. 可用量判断通过后插入发货事件 ship_event_id insert_event(...) insert_event_resource_line(...)这种做法的关键是get_available_qty和insert_event必须在同一个数据库事务里并且尽量对资源行加锁或使用乐观锁版本号。如果不加控制两个请求同时读到可用量 10并且都判断通过就会出现超卖。实际项目中我会用一个资源级锁表或者直接使用数据库的SELECT ... FOR UPDATE来锁定资源汇总行判断完成后插入事件提交事务。这样既保证事件流完整又不会牺牲太多并发性能。3.4 模型正确性验证用对账思维验证系统建完表、写完服务层之后最大的问题变成了我怎么知道这套系统算出来的数据是对的我的答案是对账思维。REA 系统的底账是事件流那么任何资源的变化都必须守恒。我通常在系统上线时写一组校验 SQL类似这样SELECT r.resource_code, r.resource_name, COALESCE(SUM(CASE WHEN erl.quantity 0 THEN erl.quantity ELSE 0 END), 0) AS total_in_qty, COALESCE(SUM(CASE WHEN erl.quantity 0 THEN erl.quantity ELSE 0 END), 0) AS total_out_qty FROM resource r LEFT JOIN event_resource_line erl ON r.resource_id erl.resource_id LEFT JOIN economic_event ee ON erl.event_id ee.event_id WHERE ee.business_date 2025-03-31 GROUP BY r.resource_id, r.resource_code, r.resource_name;把期初数量 期间流入 - 期间流出和实际盘点数量放在一起对比差异为零就是正确的。如果对不上就按事件逐条排查。这个时候你会体会到事件流表有多香你能看到每一笔差异对应的原始事件而不是看着一张库存剩余量字段发呆。4. 常见问题与排查技巧实录4.1 别把系统操作当成业务事件实践中最常见的问题就是把系统操作记录和业务事件混在一起。有人会把用户登录系统“打印送货单”下载对账单也写进事件表。这会导致什么后果呢事件表越来越膨胀真正的业务事件被淹没在一堆日志性数据里聚合统计时还要小心排除它们。我给自己定了一条检查规则一个事件是否值得进入economic_event表取决于它会不会改变资源的数量或金额。不会改变资源数量和金额的操作就不应该出现在业务事件表里。如果团队确实需要记录操作日志请单独建日志表不要和业务事件混用。这个区分做得好系统的语义会清晰非常多。4.2 应收账款不是资源这是一个高频设计误区很多人在用 REA 建模时会条件反射地把应收账款当成资源因为它在资产负债表里是资产。但从 REA 的视角看应收账款是一种权利是货已经发出但钱还没收到的承诺状态。真正的资源是钱应收账款的本质是一个未完成配对的销售发货事件。我见过不少团队把应收账款设计成资源表结果信用期调整、坏账计提、部分收款、退款重算这些需求一来他们就要不停改表结构。正确的做法是销售发货事件和收款事件通过event_relation表建立配对关系应收余额通过未配对数量/金额推导而不是通过一个应收资源科目存储。理解这一点之后坏账、折扣、信用期这些复杂业务就都变成规则层的事情了。4.3 参与者角色不要混在一起权限模型才稳还有一次踩坑是在权限设计上。我把客户、供应商、员工都放进一个agent表结果很快就发现某个公司既是供应商又是客户这时候判断它到底有什么角色、能操作什么事件字段写得乱七八糟。后来我做了两份东西一张是agent基础表只存身份信息另一张是角色关系表记录某参与者在某业务维度下是什么角色。比如同一个组织机构在采购流程里是供应商在销售流程里是客户。解决方式不是给它一个既是客户又是供应商的复杂类型而是用两条角色关系记录分别关联不同的事件类型。这个设计让权限校验变得非常直观判断当前用户能否发起某事件就是查他的角色关系表里有没有对应的类型。4.4 实时库存性能与事件追溯的平衡方案事件流聚合在数据量小的时候什么都不是问题但当你一天有几十万行发货明细每次查库存都要扫描事件明细表性能就会露馅。我的建议是分三层解决。第一层数据库物化视图按资源、按日期做预聚合应用程序直接查视图。第二层如果业务要求毫秒级别的实时库存就在内存缓存里维护资源可用量每次事件提交成功后同步更新缓存缓存可以随时从事件流重建。第三层对于超大流量的场景把事件写入消息队列下游消费后更新聚合表实现事件溯源加 CQRS 的思路。但这三层都不影响一个原则——底账事件流永远保留永远作为最可信的数据源。4.5 与财务系统对接时的映射规则最后说说和财务系统对接。传统业务系统给财务提供数据时经常掏出的是订单表和出库单表财务拿到后还要自己做二次加工。REA 系统则可以定义一套映射规则让财务直接从事件流生成凭证。比如销售发货事件映射成借主营业务成本贷库存商品顾客付款事件映射成借银行存款贷应收账款采购收货事件映射成借库存商品贷应付账款。映射规则变化时只改配置不改底层表。这种对接模式下的数据质量比传统模式稳定得多因为事件流本身就是按时间顺序记录的真实业务天然具备审计线索。审计人员最关心的这笔货谁发的、什么时候发的、对应哪张订单、钱收回来没有都能通过事件和关系表直接回答。最后分享一个我自己的实操体会用 REA 建模最难的不是画 ER 图也不是写 SQL而是控制住顺手加状态字段的冲动。事件表越纯净模型越耐用。如果哪天你觉得某个新需求改不动了多数原因不是 REA 不够用而是你在底层表里混入了太多派生状态。建议先停下写代码的手回到事件列表里重新把业务动词数一遍答案往往就在那里。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →