ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从状态字段到事件事实:REA业务建模彻底解决订单与财务追溯难题

从状态字段到事件事实:REA业务建模彻底解决订单与财务追溯难题

业务系统做到一定复杂度,几乎都会撞上这样一类需求:“订单要支持部分退款,客户可能分多次付款,另外我们要能追溯到每一笔收入到底来自哪张原始单据。”如果你习惯用实体关系图一步步定义状态字段,这类需求会让你越改越别扭。我自己在帮某家公司重构进销存数据模型时就被逼了一回,最后把整套订单模型推翻,改成事件驱动的方式建模,问题才顺过来。

这套思路在业内有一个经典名字——REA(Resource-Event-Agent),资源-事件-主体。它不像ER图那样只描述“名词与关系”,而是把业务运行过程拆成“谁在什么时间对什么资源做了什么”,再用事件之间的配对关系构成完整业务循环。对做订单、库存、财务、数据中台的开发者和架构师来说,REA是值得认真掌握的一套模型。这篇文章我会先讲清楚REA的核心构造,然后用一个门店零售场景完整走一遍建模流程,最后把我实际落地时踩过的坑和注意事项一并列出来。读完之后,你可以拿这套框架重新审视手头的业务系统,哪怕不重构,也能把“状态”和“事实”的边界理清楚。

1. 为什么我建议业务建模别再只画实体关系图

1.1 实体关系图擅长描述名词,不擅长描述动词

ER图现在仍然是很多团队建模的首选,它确实成熟:客户、订单、商品之间的关系一目了然,数据库设计工具也好用。但ER图有一个天然偏向——它擅长描述名词,不擅长描述动词。客户、订单、商品是名词,建模很顺畅;可一旦遇到“销售”“支付”“退款”“调拨”这些动作,ER图里通常只能把它们压缩成一个状态字段,或者一张流水表。流水表稍好一点,但很多人只把流水当附属记录,核心还是那张大宽表。

状态字段的问题在于,随着业务规则扩张,状态机会越来越复杂。一个电商订单的状态往往有十几个,中间还可能插入“待接单”“仓库已接单”“配送中”之类的新节点。每新增一个状态,所有查询、判断逻辑、报表统计都要跟着改。更麻烦的是,很多所谓的“状态”其实可以由多条事件推导出来,根本不值得长期硬存。比如“已付款”不是订单固有属性,它是“存在一条对应该订单的收款事件”这个事实的结果。

1.2 REA模型是从会计信息系统里长出来的业务本体

REA模型从根本上换了一个问法:不再问“订单现在处于什么状态”,而是问“这笔业务活动里发生过哪些事件,每个事件涉及什么资源、由谁参与”。这个问法更接近业务运行的真实过程。

它的渊源很有意思。上世纪八十年代,会计信息系统领域的研究者试图设计一套不依赖借贷记账法、能直接描述业务事实的数据模型,最后收敛到三个基础概念:资源、事件、主体。这套模型比一般领域模型更抽象,但它触及了业务系统的本质——一切业务都是资源在主体之间流动,事件就是流动发生的记录。也正因为它足够抽象,才能稳定应对业务调整,而不是每换一个业务规则就推倒重来。

1.3 REA与借贷记账的差异

这里要特别区分一下:会计里的借贷分录是“面向财务报表”的语言,它关注借方、贷方、科目、余额;而REA是“面向业务真实”的语言,它关注业务活动中真正发生了什么。

打个比方,客户付了款,借贷记账法记的是“银行存款增加,应收账款减少”,这是财务视角的抽象;REA记录的是“收款事件发生,资金资源增加,参与主体是客户和收银员”。前者对财务人员友好,后者对业务人员、产品经理、开发人员更友好。两者可以转换,但REA位于更靠近业务的一层。也正因为如此,REA很容易成为业务部门和IT部门共同认可的一门通用语言。

2. REA模型的三大构造:资源、事件、主体到底在说什么

2.1 资源:不只是“商品”才有价值,服务和权利也是资源

REA里的资源,指业务中可计量、有价值的对象。很多人以为资源就是库存里的商品,其实不止。

常见资源大致分三类:

  • 实物资源:商品、现金、原材料、固定资产。
  • 服务资源:咨询工时、维修服务时长、培训课时。
  • 权利资源:会员积分、优惠券、软件授权、订阅时长。

一个东西能不能成为资源,有两个判断标准:第一,能被计量,有数量或金额;第二,能带来价值,无论是增加收益还是降低成本。像“顾客情绪”这种没法计量、业务系统也不管的东西,就不是资源。区分这一点很有用,它逼着建模者想清楚:一堆数据里,真正构成业务价值的池子到底是什么。

2.2 事件:系统里最值得存的那条“事实记录”

事件是REA模型里的一等公民,它指业务过程中发生的、影响资源流入流出的动作。销售、采购、收款、付款、入库、出库、退货、退款,这些都是事件。

事件有三个核心要素:发生了什么、什么时间发生、涉及哪些资源的增减以及数量和金额。此外,事件还要关联“谁参与了”。所以一张事件表至少要有事件类型、业务发生时间、录入时间、来源单据编号、备注等基础字段,再通过明细子表记录资源数量单价、参与者角色。

注意,事件和普通流水账不一样。流水账只是表格里的一行记录,常常是为了对账用的附属数据;而在REA模型里,事件本身就是业务事实的权威来源。你要判断“这单付钱了没有”,不是去看某个状态字段,而是去查关联的收款事件存不存在、金额是否匹配。状态是推导出来的,事件才是存下来的。

2.3 主体:业务动作由谁发起、由谁接收、由谁授权

主体是参与事件的人或组织。它可以是内部主体,比如店员、收银员、库管员、门店;也可以是外部主体,比如顾客、供应商、服务商。

一个事件可以有多个主体参与,而且每个主体的角色不同。销售事件里,顾客是接收方,店员是执行方;有的场景还需要店长授权,那店长就是授权方。所以在建表时,事件和主体的关系不是简单地加一个外键,而是一张专门的关联表,保存参与者ID和角色。

“门店”到底算主体还是订单里的一个属性,这个问题最好在建模初期就定下来。我的建议是:只要实际参与资源增减的责任方,都建模为主体,用主体类型字段区分配置。这样后续要做门店维度报表、加盟商维度报表,就不用改结构,直接按主体过滤就行。

2.4 二元性:REA的灵魂所在

业务很少是单边动作,通常成对发生。销售这件事,意味着商品资源流出,随后必然对应资金资源流入;采购意味着商品资源流入,对应资金资源流出。REA把这种配对称为二元性(Duality),这是整个模型最关键、也最容易被忽略的地方。

“销售”和“收款”是一对,“采购”和“付款”是一对,“领用”和“归还”是一对。强调这种配对,是为了从根本上解决“订单表加一个支付状态字段”的冲动。订单是否完成收款,不再是一列真值,而是“是否存在一笔收款事件与该销售事件配对,且配对金额是否充足”。这是可验证、可追溯的事实推导,不是拍脑袋维护的一个值。

实际建模时,二元性不是一比一那么简单。可以部分收款、多次收款、一次收款合并多笔销售单。所以我会单独建一张二元性配对表,而不是在销售事件表上加一个“收款事件ID”字段。这张配对表维护的是“哪个增量事件和哪个减量事件构成了哪笔金额”的关系,灵活性大得多。

3. 用一个门店销售场景完整走一遍REA建模

3.1 场景定义与建模边界

先设定一个具体的虚拟场景:某社区便利店,销售几十种商品,支付方式支持现金、微信、支付宝,偶尔有退货,进货来自几家供应商。老板的需求是:每天看到每个商品的实时库存、每个收银员的销售和收款明细,月底账目要对得上。

建模第一步是定边界:先不做赊销,不处理多门店多仓库,供应链模型先到“采购入库”为止。这个边界非常重要,否则事件类型一下铺开,模型还没建好就乱了。建模本来就是迭代的,第一版能覆盖住主营闭环就够了。

3.2 第一步:识别资源、事件、主体

把场景里的“名词”和“动词”分别梳理出来。

资源表:

资源类型说明
商品实物资源每个SKU一条资源记录
现金资金资源收银机里的备用金和营业款
微信零钱资金资源微信收款电子钱包
支付宝余额资金资源支付宝收款电子钱包

事件表:

事件影响资源方向典型参与者
采购入库商品增加供应商、店员(收货)
销售出库商品减少顾客、店员
收款现金/微信/支付宝增加顾客、收银员
退货入库商品增加顾客、店员
退款现金/微信/支付宝减少顾客、收银员
付款给供应商现金/微信/支付宝减少供应商、店员

这里说一个关键判断:如果你把“收款”当作销售事件的属性,而不是独立事件,那后续处理部分付款、多次付款、组合支付时,就很容易把这张表或这个状态字段改到崩溃。这也是判断要不要上REA的一个直观标尺——只要你觉得“这单的付款状态很难用一列描述清楚”,那就是该拆事件的时候了。

3.3 第二步:定义事件之间的关系

资源、事件、主体识别出来之后,第二步是把它们连起来看。

销售出库是一条减量事件,对应商品池的减少;收款是一条增量事件,对应资金池的增加;销售出库和收款通过二元性配对,构成“卖出并收到钱”的完整业务循环。退货入库和退款构成一条逆向的双事件组合。采购入库对应付款给供应商,同样是一对。

主体连接关系则是:销售出库事件参与主体是顾客和店员;收款事件参与主体是顾客和收银员。这两个事件在建模时通过事件-主体关联表挂上参与者及角色。

3.4 第三步:映射成物理表结构

我不建议为了REA故意设计过度复杂的物理结构,只需要四张核心表加两张关联表。以下是我在实际项目中验证过的表结构,可以直接复用。

CREATE TABLE resource ( id BIGINT PRIMARY KEY, name VARCHAR(200) NOT NULL, resource_type VARCHAR(50) NOT NULL -- PRODUCT / FUND_POOL / SERVICE_RIGHT ); CREATE TABLE agent ( id BIGINT PRIMARY KEY, name VARCHAR(200) NOT NULL, agent_type VARCHAR(50) NOT NULL -- INTERNAL_STAFF / INTERNAL_ORG / EXTERNAL_CUSTOMER / EXTERNAL_SUPPLIER ); CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(50) NOT NULL, -- SALE / PURCHASE / COLLECTION / REFUND occurred_at TIMESTAMP NOT NULL, -- 业务发生时间 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 系统录入时间 src_bill_id BIGINT NULL, -- 来源单据ID,便于和业务单据关联 remark VARCHAR(500) ); CREATE TABLE event_resource_line ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction SMALLINT NOT NULL, -- 1 增加, -1 减少 quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,6) NOT NULL ); CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(50) NOT NULL -- CUSTOMER / CASHIER / SUPERVISOR / SUPPLIER ); CREATE TABLE event_pairing ( id BIGINT PRIMARY KEY, increment_event_id BIGINT NOT NULL, decrement_event_id BIGINT NOT NULL, amount DECIMAL(18,6) NOT NULL );

这里强调一个习惯:resource表不要存“当前库存”字段。当前库存的正确计算方式是“初始库存快照 + 所有事件明细方向上数量之和”。如果查询量确实大,就单独建一张读模型缓存表,每小时从事件流重算一次,但权威数据永远在事件明细里。

3.5 第四步:用模型回答真实业务查询

建表不是为了好看,得能回答业务问题。我列三个最常见的查询需求。

判断某笔销售单是否已收款:

SELECT * FROM event_pairing WHERE decrement_event_id = 某个销售出库事件ID;

统计今天某个收银员的现金收款总额:

SELECT SUM(erl.quantity * erl.unit_price) AS total_amount FROM event_resource_line erl JOIN event e ON e.id = erl.event_id JOIN event_agent ea ON ea.event_id = e.id WHERE e.event_type = 'COLLECTION' AND erl.resource_id = 现金资源ID AND ea.agent_id = 某个收银员ID AND ea.role = 'CASHIER' AND e.occurred_at >= '2024-01-01 00:00:00' AND e.occurred_at < '2024-01-02 00:00:00';

查询某个商品当前实时库存:

SELECT erl.resource_id, SUM(erl.direction * erl.quantity) AS current_stock FROM event_resource_line erl WHERE erl.resource_id = 某个商品ID GROUP BY erl.resource_id;

看起来SQL比传统单表查询复杂一点,但这个复杂是值得的,因为每个数字都能沿事件链追溯到底层凭证。

3.6 与传统订单模型的关键对照

业务需求传统订单模型REA事件模型
部分付款订单表加部分付款金额字段,逻辑越堆越乱一条销售事件可对应多条收款事件,天然支持
变更支付方式改订单支付方式字段,历史数据难追溯错误支付方式用冲正事件纠错,再新增正确事件
退款订单状态机增加“退款中”“已退款”新增退款事件,与原收款事件配对即可
审计追溯依赖操作日志,碎片化事件链完整,可精确追溯
库存余额库存表直接存余额,需要反复做库存更新由事件流推导,读模型缓存,权威数据清晰

4. REA模型在真实项目中解决过的那些“改需求之痛”

4.1 新增支付渠道不用再改表结构

我之前参与的一个零售项目,第一版只支持现金,很快客户要求支持微信和支付宝,后来又要求“现金+微信组合支付”。放在传统模型里,订单表加字段、流水表加类型、统计报表重写,一轮轮改下来很累。用REA之后,收款事件本身就是一条记录,支付渠道只是资源的选择:收现金就关联现金资源,收微信就关联微信资源。组合支付,就是同一条销售事件配对多条收款事件。模型层面完全不用动。

4.2 退款不再依赖状态机

传统模式里,“支付状态”和“订单状态”两个状态机叠加,一旦出现“部分退款后再取消订单”这种组合,判断逻辑很容易失控。REA的处理方式则很干脆:退款就是方向相反的收款事件,和原收款事件配对,金额在配对表里体现。订单有没有欠款,计算“已收金额之和 - 已退金额之和”即可,不需要维护一个不断膨胀的状态字段。

4.3 审计追溯直达原始凭证

财务要求“报表里某笔收入来自哪张单据、哪个收银员、几点发生的”,在REA模型里是一条自然查询。收入报表只是事件的重组视图,点击金额就能打开销售事件、收款事件、对应的业务单据和操作人。对准备融资或要过审计的公司来说,这个能力是刚需。

4.4 多组织、多门店扩展不推翻模型

后来客户开了三家店,每家店独立收银。传统模型的做法是所有表加store_id,然后每个报表重写一遍。REA模型完全不需要动核心表:门店就是一个agent,参与每个事件时带上门店角色。门店维度报表不过是一次按agent过滤的查询。这个优势在规模扩大时会越来越明显。

4.5 与财务系统的衔接变得顺畅

REA事件是财务记账的事实基础。有了事件流,财务系统生成借贷分录只是增加一层转换视图:收款事件可以映射为“银行卡增加、应收账款减少”,销售出库可以映射为“主营业务收入、库存减少”。两个方向的数据源头是同一套事件,业财一体化的根基其实就是这一层统一的业务事实层。

5. 把REA落地到项目里:几个绕不开的坑

5.1 简单CRUD别硬套REA

REA不是万能锤子。如果业务只是一个留言板、一个内容管理后台,事件配对、主体角色、二元性都是多余的重量。我给的判断标准是三条:是否有跨周期的多段业务活动(比如先销售后收款、先采购后付款);是否有审计追溯需求;业务规则是否经常变化。三条满足两条以上,REA才划算。最合适的试点,是从“采购到付款、销售到收款”这样一条完整闭环开始。

5.2 别把“事件表”和“业务单据表”混为一谈

业务单据是流程载体,比如销售单、退货单;REA事件是业务事实。一张销售单可能对应一次发货事件和一次收款事件,一个付款单可能对应多张销售单。所以我会保留原有的业务单据表,比如biz_bill,让流程审批、打印、线下跟踪继续用单据表;事件表通过src_bill_id关联单据,两者并行,各司其职。如果把单据字段全塞进事件表,反而会失去REA的灵活性。

5.3 状态字段可以留,但要明确它是缓存不是真相

为了查询方便,在订单表里保留pay_status字段,项目开发中也说得过去。关键是团队心里必须清楚:状态字段只是从事件流异步计算出来的一份缓存,不是权威事实。我会在开发规范里写明:所有状态字段的更新必须由事件流驱动,禁止业务代码直接改状态字段。否则过半年,支付状态又被改成了手工维护的“真假混合体”,REA就白做了。

5.4 事件不可变,纠错只能冲正

这是最容易让人别扭的一点。如果某条收款事件录错了,很多人的第一反应是DELETE掉重录。这一删,审计链就断了。正确做法是:为错误事件追加一条冲正事件,金额方向反转,然后再增加一条正确事件。保留所有历史事实,纠错本身也变成事件,才能支撑审计追溯。这也是为什么从事财务系统的人比较容易接受REA,因为他们早就习惯“红字冲销”而不是DELETE。

5.5 主体粒度必须在模型设计时定清楚

“门店”算主体还是订单属性?“部门”算主体还是员工属性?如果建模时摇摆不定,后面报表口径一定会乱。我的建议是:只要实际参与资源增减的责任方,就建模为主体,用agent_type区分内外。哪怕现在用不到,也先留好类型字典。部门归属、门店归属这类纯组织架构内容,放在另一个组织维度表里,不要把两者混在一起。

5.6 三个时间戳不能搞混

事件模型里至少有三个时间概念:业务发生时间(happened_at)、系统录入时间(created_at)、会计期间(period)。月末统计用period,日常报表用happened_at,审计排查用created_at。如果只用一列时间,跨日销售、延迟录入、跨期报表都会对不上账。这个细节我几乎在每个REA落地项目里都要反复强调。

5.7 团队认知磨合期要留足

刚引入REA时,开发团队容易觉得“多此一举”——明明一个状态字段就能解决,非要建五六张表。我的经验是不要靠概念说服人,直接拿真实需求做演练:让大家列出“支持部分退款”在传统模型里的改动清单,再看REA模型里的改动范围。用报表可追溯做demo展示给产品经理和财务,他们会最先支持这种建模方式。认知转过弯之后,后面协作效率会大幅提升。

6. REA模型在数据中台、审计系统和事件溯源里的新位置

6.1 数据中台的主题域建模可以换一种切法

很多数据中台的主题域是按部门划分的,比如销售域、采购域、财务域,结果同一个业务事件被拆到三个域里,口径不一致。REA提供的是按“业务活动”切分的统一骨架:资源、事件、主体、二元性对。数据部门、业务部门、财务部门如果共用这一套语言,很多口径冲突会自动消解。

6.2 REA可以成为事件溯源架构的领域语义骨架

微服务架构里经常碰到Event Sourcing,但不少团队把event仅仅当成一个JSON payload,导致事件类型、事件语义五花八门,后续消费端很难统一解释。REA恰好回答了“业务事件到底应该记录什么”:至少要有事件类型、发生时间、来源单据、资源明细、参与主体、配对关系。把REA作为事件溯源在业务域落地的领域语义骨架,比自定义一套事件规范要成熟得多。

6.3 审计存证与不可篡改场景的结合

REA事件天然是不可变记录,与区块链式的哈希链、数字签名存证非常契合。把关键事件表做成只追加事务,配合哈希摘要存储,就能形成一套可验证的业务证据链。在合规审计要求越来越高的行业里,这套模型比普通状态模型更接近“能直接过审”的数据结构。

6.4 落地的起步路径

给正在犹豫要不要上REA的团队一个建议:从一条业务主线开始,比如“销售-收款-退货退款”,先跑通报表和审计追溯,再逐渐把“采购-付款-入库”接进来,一路延伸到库存、报表、财务。不要试图第一次建模就把整个企业模型全部REA化,那样大概率会因为过度设计而失败。小步快跑,让业务方看到可追溯报表带来的实际好处,自然就有动力继续推进。

我自己最大的体会是,REA最难的部分从来不是建表,而是让团队从上到下接受“状态是快照,事件是事实”这个观念转变。一旦观念转过来了,后续的新需求都会变得好沟通——开发不再问“订单状态字段还能不能加”,而是问“这个需求对应哪些新事件,和哪些已有事件配对”。这个转变,本身就是建模水平的一次跃升。

RELATED READING

延伸阅读

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