看到"REA"这三个字母,懂行的人会先想到资源、事件、主体(Resource-Event-Agent),而不是某个框架或库名。这套模型最早是给会计信息系统做业务建模用的,但我发现它解决的不只是记账问题:电商、供应链、积分、钱包这些系统里最烦人的"状态不同步",本质上都是因为把"当前状态"当成了事实。这篇我结合在一个订单和库存反复对不上的项目里踩过的坑,把 REA 的建模思路和落地表结构一次讲清楚,适合正在做订单、库存、账务、积分这类业务的后端开发、架构师和数据产品经理。
如果你只是想找一个能直接抄作业的表结构,可以直接跳到第3章;如果你想弄明白为什么"改状态字段"这种最顺手的设计会在半年后变成事故源头,建议从第1章往下读。我的结论先放在这:状态不是数据,状态是事件的投影。这句话理解了,整个 REA 模型就通了。
1. 为什么"当前状态"式建表,会让业务系统越改越乱
1.1 我栽过的跟头:状态字段引发的连锁事故
之前做过一个零售订单系统,早期设计非常"正常":订单表里放一个status字段,从待支付到已支付、已发货、已完成;库存表直接存当前剩余数量;用户钱包表直接存当前余额。所有业务逻辑都围绕"判断当前状态 + 修改当前状态"展开。
刚开始数据量小,一切岁月静好。等促销、退款、分批发货、客服人工改单开始出现,问题就来了。
第一个事故发生在退款流程:用户申请退款,代码把订单状态改成了"已退款",也把钱包余额加回去了,但库存表没有回补。为什么?因为退款逻辑关注的只是订单状态,它压根不知道这个订单对应哪几个库存流水。第二个事故是运营手工把订单状态改成"已完成",实际上仓库根本没发货,数据凭空丢失了事实依据。
这些问题的根源,不是某一段代码写错了,而是整个建模方式有问题:status字段是"当前值",不是一个"业务事实"。改当前值不留任何可审计的痕迹,一旦多个功能模块都要改动这个值,状态机就会以指数级膨胀。今天加一个"部分发货",明天加一个"售后中",后天再加一个"风控冻结",每个状态都得考虑前一状态、后一状态、谁可以改、改完要不要触发回调。系统不爆炸才怪。
1.2 REA 的第一性原理:状态是事件的投影
REA 模型给出的回答很简单:不要存"现在是什么状态",只存"历史上发生了什么事件"。
三个字母分别是:
- Resource(资源):有价值、可计量、可被组织控制的东西,比如现金、库存商品、应收账款。
- Event(事件):导致资源增加或减少的经济动作,比如收款、发货、入库、退货。
- Agent(主体):参与事件的人、部门或组织,比如客户、仓库操作员、供应商。
它们之间的关系也很好理解:一个主体在某个时间点发起或参与了某个事件,这个事件改变了某种资源的数量。所有"当前状态",比如库存剩多少、客户还欠多少钱、订单走到哪一步,都不是独立存储的事实,而是把事件按时间汇总之后算出来的结果。
这跟银行账户是一个道理:流水表才是事实,余额表只是把流水加总后的投影。银行不会因为余额显示异常就去改流水,而是重新计算余额。REA 就是把这种思维方式从金融领域搬到了更宽泛的业务系统建模里。
在这个模型里,订单状态不是字段,而是"有没有支付事件、有没有发货事件、有没有退货事件"的逻辑组合。库存也不是字段,而是"入库事件数量减出库事件数量"的聚合结果。谁想问为什么,都能顺着事件追溯回去。
2. REA 三类核心对象拆解:资源、事件、主体怎么选
2.1 资源:有价值、可计量、可控制,三者缺一不可
很多人在建资源表时,会把"资源"等同于"商品"或"钱",然后就开始纠结优惠券算不算、服务算不算、客户信任算不算。我的判断标准只有三个,缺一个都不建模成资源:
- 有价值:直接或间接带来经济利益。现金有价值,库存商品有价值,应收账款有价值,积分有价值;
- 可计量:可以用数量或金额量化。计量单位不一定是"件",还可以是小时、次数、公里、克;
- 可控制:企业或系统对其有支配权或管理权。客户满意度虽然重要,但你既不能准确计量,也不能单方面控制它,所以不适合做资源。
服务类业务也适用。比如一个咨询项目,计量单位是"小时";一次云服务调用,计量单位是"次"。只要你能定义"单位",就能把它放进资源表。
实战中我会给资源表加上类型字段:
create table resource ( id bigint primary key, name varchar(100) not null, unit varchar(20) not null, -- 件、小时、元、次 resource_type varchar(30) not null, -- inventory / cash / receivable / point is_monetary boolean not null default false );货币性资源和非货币性资源的处理方式不太一样。现金、应收账款这类金额资源,事件里记录的是金额;库存、积分这类资源,事件里记录的是数量,可能还需要换算金额。这样拆开,后续做总账和业务台账时会清爽很多。
2.2 事件:真正能改写资源账目的原子动作
事件是整个 REA 模型里最重要,也最容易搞错的对象。很多人会把"业务操作"直接当成事件,比如"用户登录""客服改备注",这些操作不改变任何资源的经济价值,不该进事件表。
我判断一个动作能不能成为经济事件,只看四点:
- 是否发生在某个时间点,而不是持续模糊的时间段;
- 是否明确地增加或减少了某种资源;
- 是否有可计量的数量或金额;
- 是否至少涉及一个主体。
比如"下单"本身不是经济事件,因为商品还没有转移、钱还没付,它只是产生了一个承诺;"支付""发货""退货""入库"才是经济事件。如果按这个标准,绝大多数状态字段问题都会暴露出来:你根本没有支付事件,凭什么把订单改成已支付?
事件之间还存在配对关系。一个典型的交易,我的资源减少,必然伴随着另一个资源的增加,REA 里叫事件对偶(duality)。比如发货,库存商品减少,同时应收账款增加;收款,应收账款减少,现金增加。后面第3章的案例会具体演示这种配对。
2.3 主体:谁有权操作,比"是谁"更重要
主体可能是我实际项目里最容易被忽略的一块。很多系统的操作记录只有user_id,但user_id代表的是登录账号,它无法回答两个关键问题:这个人当时代表哪个组织?他有没有权限做这个操作?
采购场景特别明显:下单人是一个采购专员,付款方是公司,收货地址是某个门店仓库。如果事件表里只记录了下单人的user_id,后面的对账、审计、责任追溯全都会卡住。所以在事件表里,我至少会区分两个主体字段:操作主体(谁发起的动作)和对手主体(和谁发生的交易)。
对于内部授权关系,可以单独建模,也可以简化成一张主体关系表。原则是:事件记录必须回答"是谁、代表谁、对谁"三个问题,否则它就不是一个完整的事实。
3. 用一个商城订单案例,把 REA 完整落成表结构
3.1 从下单到收款,一条订单拆成哪几个经济事件
用一个最常见的场景:某客户在线买一件衣服,价格 100 元,优惠券抵扣 10 元,实际支付 90 元,仓库发货,客户收货。
按 REA 的拆法,这条订单不是一条记录,而是几个独立事件:
- 支付事件:客户的钱包或银行账户减少 90 元,平台钱包或待清算资金增加 90 元;
- 发货事件:库存商品减少 1 件,同时产生一笔应收账款(如果发货时还没收款)或确认资金入账(如果已经收款);
- 后续收款核销事件:如果发货时挂的是应收账款,收到钱后再把应收账款清零、现金增加。
这里有一个关键认知:"收入"不是资源,也不是事件。收入是发货事件和收款事件共同作用的统计结果。你不要在系统里专门插入一个"收入增加"事件,那样会造成事实重复,对账时极易出现两边数字对不上。REA 的做法是只记录资源变化,收入报表通过事件聚合算出来。
下单这个动作我通常会留在承诺层,也就是继续保留订单头、订单行,但订单行只表示"承诺要交付的商品"。实际库存变化必须来自发货事件。这样"承诺量"和"实际量"就分开了,后面做部分履约会非常自然。
3.2 表结构怎么设计:一张事件表撑起库存和应收
最小可用的落地方案,就是一张扁平的经济事件表。每个事件记录一笔资源增减,可以满足的库存、余额、应收账款查询。
create table economic_event ( id bigint primary key, event_type varchar(50) not null, -- payment / shipment / return / adjustment occurred_at timestamptz not null, resource_id bigint not null, direction varchar(10) not null, -- in / out quantity numeric(18,4) not null, unit_price numeric(18,4) not null, base_amount numeric(18,4) not null, original_amount numeric(18,4) not null, currency varchar(10) not null, rate_to_base numeric(18,6) not null, agent_id bigint not null, -- 操作主体 counterparty_id bigint not null, -- 对手主体 ref_document varchar(64) not null, -- 关联订单号/支付流水号 ref_event_id bigint null, -- 关联事件,如退货指向原发货 created_at timestamptz not null default now() ); create index idx_event_resource_time on economic_event(resource_id, occurred_at); create unique index uq_event_ref on economic_event(ref_document);这个表能解决大部分问题,但它有一个明显不足:一个支付事件既影响应收账款,又影响现金,如果只建一行,就得把两个资源塞进一条记录,语义会很拧巴。所以我在实际项目中更推荐"事件头 + 事件分录"的结构,类似记账凭证和分录的关系:
create table economic_event ( id bigint primary key, event_type varchar(50) not null, occurred_at timestamptz not null, agent_id bigint not null, counterparty_id bigint not null, ref_document varchar(64) not null ); create table event_entry ( id bigint primary key, event_id bigint not null references economic_event(id), resource_id bigint not null, direction varchar(10) not null, quantity numeric(18,4) not null, unit_price numeric(18,4) not null, base_amount numeric(18,4) not null, currency varchar(10) not null, rate_to_base numeric(18,6) not null );一个支付事件下挂两条分录:应收账款减少 90,现金增加 90。一个发货事件下挂两条分录:库存减少 1 件,应收账款增加 90。每次对账,都可以从事件重新推导,永远不会出现"钱包余额加了但库存没回补"的情况,因为所有改动都发生在同一套事件机制里。
3.3 余额和库存怎么算:一条 SQL 复现账目
有了事件表,当前库存就是所有库存资源的流入减流出:
select resource_id, sum(case when direction = 'in' then quantity else -quantity end) as current_balance from event_entry where occurred_at <= now() group by resource_id;某个客户当前还欠多少钱,也就是应收账款余额,可以这样算:
select counterparty_id, sum(case when direction = 'in' then base_amount else -base_amount end) as receivable_balance from event_entry where resource_id = 1001 -- 应收账款资源ID group by counterparty_id;这两条 SQL 看起来很简单,但它的意义是:任何时刻的余额,都可以用同一套事件重算出来。审计时不需要相信某个"当前值"字段,只需要把事件重新加一遍。这也让我在排查问题时节省了大量时间——某个数字不对,不再去猜哪张表被谁改过,直接按事件筛选,很快就能定位到是哪条流水写错了。
3.4 价格、折扣、税费这种"附加属性"放哪里
这是我在项目里被问得最多的问题:订单有商品原价 100,优惠券抵扣 10,税费 5,实付 90,这些字段该塞哪?
如果用的是扁平事件表,最省事的办法是把单价记成 90,把原价和优惠折扣放在订单表里供展示。但这样一做,审计链就断了一半:你无法从事件表看出这 90 元是由 100 元商品、-10 元折扣、0 元税组成的,后续如果要对折扣单独分析,就得去 join 订单表。
更干净的做法是:把折扣和税费也建模成资源分录。一个发货事件可以有三条分录:
- 库存商品 out:数量 1,金额 100;
- 折扣资源 in(负向调整):金额 -10;
- 税费资源 in:金额 5。
这样收入报表、折扣费用报表、税费报表都可以直接看事件分录,不用再从 UI 层硬挖数据。缺点是表行数会增加,事件写入会变复杂。如果业务对折扣和税费没有独立审计要求,也可以妥协:事件分录只记最终成交金额,订单行保留原始明细。取舍的标准只有一个——将来会不会有人拿着这个数据去问"为什么是这个数"?如果会,就别省。
4. 边界场景怎么保持模型干净:退货、部分履约、多币种
4.1 退货不是负数流水,而是独立反向事件
我第一次用 REA 建模退货时,也想走捷径:既然发货是库存 out 1,退货就记库存 in -1,等于把原事件冲平。后来发现这是一个典型的坑。
如果退货以负数数量的形式并入原发货事件,你的领导或审计会看到一条数量为负的出库记录,它到底是系统异常、数据错误,还是真的退货,完全不可判定。而且退货本身还有验收、质检、是否重新入库、退款金额是多少这些独立信息,把它们揉在一个旧事件里,等于把所有后续业务都锁死在历史数据上。
正确姿势是:创建一个新的退货事件,它有自己的事件 ID、时间、操作主体,ref_event_id指向原发货事件。分录如下:
- 库存商品 in:数量 1;
- 应收账款或现金 out/调整:金额 90。
原发货事件完全不动。查询原发货量,仍然是 1;查询退货量,也能单独统计出来。这种"追加不修改"的模式,才符合"事件不可变"原则,也方便日后做售后分析、退款结算和库存效期管理。
4.2 部分履约和分批发货:让状态自然推导
订单买了 3 件商品,仓库先发了 2 件,剩 1 件等补货后再发。传统状态机方案里,你得新增一个"部分发货"状态,然后开始处理"部分发货到已发货"之间的各种组合。
REA 方案完全没有这个状态。订单行记录的是承诺数量 3,发货事件记录的是实际出库 2,过几天再补一条发货事件出库 1。任何时候想知道这个订单发完了没有,只需要比较承诺数量和发货事件的汇总数量:
select ol.order_id, sum(ol.quantity) as ordered_qty, coalesce(sum(case when ee.direction = 'out' then ee.quantity else 0 end), 0) as shipped_qty from order_line ol left join event_entry ee on ee.ref_document = ol.order_line_doc where ol.order_id = 12345 group by ol.order_id;这里的关键是:订单履行状态不是一个字段,而是"承诺量减实际量"的计算结果。发货不足,就继续待履约;全部发完,自然就是已完成。需要往状态机里加的状态,在这里全都消失了。这个设计对供应链里的分批到货、部分缺货、多次售后特别实用。
4.3 多币种与汇率:把汇率锁进事件时间点
做跨境业务时,最容易出现的问题是金额漂移。假设订单发生时美元兑人民币汇率是 7.1,事件分录里记了 $100 对应 ¥710;一个月后汇率变成 7.3,如果资产余额表里的值被定期重估,那你翻历史报表时,会发现上个月的收入数字跟着当前汇率变了,这显然不可接受。
REA 的解决思路是:汇率是事件发生时刻的事实,不是余额表的属性。每条分录必须同时记录原始币种金额、原始币种、汇率和折算后的基准币种金额。基准币种金额在事件写入时锁定,不做自动重算。
期末如果需要按最新汇率重估货币性资产,可以作为一个独立的"重估事件"写入,分录方向是"汇兑损益"调整。这样既保持了历史事件原貌,又满足了财务上按期末汇率披露的需求。两点必须同时存在,缺一个都会在未来某天对不上账。
4.4 幂等与并发:防止账目重复入账
事件表是追加式的,但这不代表不会重复。支付渠道回调重试、用户重复点击提交、消息队列重复消费,都可能让同一个业务动作产生两条事件分录。所以我对事件表有一个硬性要求:业务原始凭证号必须唯一。
在上面的表结构里,ref_document字段承载的就是这个幂等键。支付回调时,用支付渠道返回的流水号作为ref_document,并建唯一索引。如果来了重复回调,数据库会直接拒绝第二次插入,事件就不会写重。
并发方面,两个仓库操作员同时出库同一资源时,事件表本身很难做简单的行锁约束。我的做法是:写入事件前,先对资源 ID 做一次聚合校验,比如库存是否够用;在强一致性要求高的场景,可以给资源记录加乐观锁版本号,事件写入时校验版本号。普通业务量下,这种程度的保护已经够用了。
5. 渐进落地策略:不推翻系统,也能用上 REA
5.1 事件溯源是存储机制,REA 是业务语义
很多人会把 REA 和事件溯源(Event Sourcing)混为一谈。它们其实是两层的概念:事件溯源说的是"怎么存",REA 说的是"怎么对业务建模"。
事件溯源要求系统只追加事件日志,不直接修改当前状态;REA 告诉这些事件应该长什么样:必须包含资源、方向、数量、金额、主体、对手主体、时间,并且状态全部可以投影计算。你可以只做事件溯源而不遵循 REA,那样事件虽然不可变,但每个团队可能各写各的事件,类型五花八门,最后还得靠人脑去对语义。也可以只用 REA 的概念设计关系表,不用事件溯源那一整套基础设施。
我倾向于两者结合:存储层用只追加的事件表,业务语义层用 REA 的三类对象来约束事件结构。这样既保证了可追溯,又避免了"什么都能塞事件"带来的混乱。
5.2 查询走投影:为库存和账单准备读模型
有人会质疑:如果所有查询都实时聚合事件表,量一大肯定扛不住。这个担心有道理,但解决方案不是回到"存状态字段",而是建投影表,也就是读模型。
投影表就是一个缓存起来的统计数据。比如:
inventory_snapshot:按资源维度存当前库存数量和最后一次事件 ID;account_balance:按客户和资源维度存当前余额;order_fulfillment:按订单维度存已发货数量、待履约数量。
写入事件后,在同一个事务里更新对应投影表,或者在事务提交后发消息异步更新投影。关键是:投影表可以被丢弃和重建,因为它只是事件表的缓存;事件表才是唯一真相。如果某天发现投影数据不对,直接从事件表重放,几分钟就能恢复。这种"从事件重算"的安全性,传统"改余额"模型是给不了的。
5.3 从最容易出事的模块开始迁移
如果是一个已经跑了两年的老系统,别指望一次性把所有表都改成 REA。我的经验是从最容易出问题的模块切入,通常有三个候选:库存台账、客户余额或钱包、积分和优惠券核销。它们的共同点是:金额或数量敏感,状态变化多,事后对账频繁。
迁移步骤可以这样设计:
- 把现在的库存、余额作为"初始快照事件"写入事件表,记录快照时点;
- 后续所有新增的入库、出库、支付、退款操作,全部写入事件表;
- 投影表承担原有业务查询,老系统的状态字段不再作为主数据;
- 新旧系统并行跑一段时间,每天对账,确认数据一致后,再把后台管理页面切换到新逻辑。
这里要特别说明:初始快照事件不是篡改历史,它是"截至某时点为止的累计事实"。快照之后的流水全部追加,任意时刻的余额都可以用"快照 + 后续流水"重算出。这个技巧能让老系统平稳过渡,不用推倒重来。
5.4 我现在的选型边界和一些体会
做了几个项目之后,我反而不建议所有系统都无脑上 REA。如果只是内部一个几十人用的管理后台,直接改状态字段最省事,引入事件建模的成本反而会拖慢进度。我的判断标准是三个问题:
- 这个数据如果错了,需不需要知道是谁在什么时间做了什么?
- 这个"状态"是不是由多个业务动作同时决定?
- 历史数据有没有可能被随时按新口径重新计算?
只要有一个答案是"是",REA 就值得用。我自己在真正落地时还有一条底线:事件只增不改,投影随时可拆。团队里必须有人能把"状态是投影"这件事讲清楚,否则过两个月就会有人忍不住去 update 事件表。把这些想明白,REA 就不只是又一个建模理论,而是能实实在在帮你睡个好觉的对账工具。