做后端开发这些年,我见过太多业务系统最终都栽在“记账”这件事上。这里的记账不只是算钱,还包括货品的进出、库存的增减、服务的消耗——但凡涉及“谁给了谁什么东西”的业务,只要没有一套统一的建模思路,代码里就会迅速长出一堆迁就式补丁。今天加优惠券,明天加退款,后天要兼容线下门店,最后没人说得清一笔业务从发生到结束到底经历了什么。直到我在一套进销存系统里认真用了一遍REA(Resource-Event-Agent,资源-事件-代理人)建模,才真正体会到这套源自会计信息系统的模型,其实是梳理“业务过程”的一把通用钥匙。
这篇文章写给正在设计订单、库存、结算这类业务模块的开发者,也写给想从“先建表、后写逻辑”切换到“先理清过程、再落代码”的架构师。我会先讲清楚REA到底在解决什么问题,再用一套电商进销存的例子完整推演一遍建模过程,接着给出能直接翻译成MySQL设计的落地方案,最后把我实际踩过的坑和取舍标准一并交代清楚。
1. 为什么“先建表”的思路会在业务过程上翻车
1.1 表是某一时刻的快照,业务是连续发生的流
很多团队拿到需求的第一反应,是先把订单表、商品表、库存表建出来。这个习惯本身没有错,问题在于这些表本质上是“某一时刻的状态快照”:订单表写着当前订单处于什么状态,库存表写着仓库里当前还剩多少货,支付表记着哪笔钱已经到账。
但真实的业务是一条连续流动的河。货物从供应商流进仓库,再从仓库流到客户手里;资金从客户流进公司账户,再从公司账户流向供应商。这套流转过程如果只靠状态表来描述,就只能靠“加字段、加状态、加新表”来硬撑。订单状态从1改成2,再从2改成5,每次改动背后都藏着一段没有落地的历史,一旦出问题,排查的起点根本找不到。
我在项目中见过最典型的例子:订单表上有十几个状态字段,有下单时间、付款时间、发货时间、签收时间,后来需求方要加“换货”,于是又加了换货状态和换货时间。两个月后新同事接手,光理解这张表就花了一周,更别说改代码时根本不敢动那些互相牵扯的字段。
1.2 业务人员嘴里讲的从来不是表,而是一件件“发生的事”
如果你去和业务人员聊需求,他们不会说“订单表的status需要加一个值”,他们说的是“货到仓库了”“给客户退款了”“供应商这个月发了三批货”。这些口语化的描述,本质上都是“事件”——某个时间点,某样资源在参与方之间发生了转移。
开发者的表和业务人员的语言之间,存在一道天然的翻译鸿沟。传统做法里,这道鸿沟靠产品经理的需求文档来弥合,但文档和代码一样会过时。REA的贡献在于,它把“事件”直接扶正为建模的中心,让数据库结构尽量贴近业务人员的心智模型,而不是贴近开发者的编码习惯。
1.3 REA从哪里来,为什么不是一套只能纸上谈兵的理论
REA最早是上世纪80年代在会计信息系统研究领域提出的,初衷是替代传统复式记账中“借”“贷”那套记法,用“经济事件”直接描述企业的经营活动。后来这套思想被逐步推广到更通用的企业建模里,很多研究型框架都把它当作领域建模的参考依据。
我在实际项目里用下来最大的感受是:REA之所以能落地,不是因为它有高深的理论,而是因为它正好长在“业务人员怎么描述世界”的同一个频率上。资源是什么、发生了什么、谁参与了——这三个问题,是任何业务里绕不开的原始问题。只要把这三个问题问清楚,代码结构再乱也乱不到哪里去。
下面这张对比表能说明传统状态建模和REA事件建模的差异所在。
| 维度 | 传统状态表建模 | REA事件建模 |
|---|---|---|
| 核心对象 | 订单、库存、账户等状态实体 | 资源、事件、代理人 |
| 描述方式 | 记录实体“当前是什么状态” | 记录资源“何时、在谁之间转移” |
| 时间维度 | 弱化,状态覆盖历史 | 强保留,事件天然带时间 |
| 业务沟通 | 需要多次翻译 | 几乎可以照着业务原话建模 |
| 扩展新业务 | 加字段、加状态、加关联表 | 新增事件类型即可 |
2. 拆解REA三个核心概念:资源、事件、代理人各管什么
2.1 资源:一切有价值、能被转移的东西
资源是REA里最好理解的概念,但也很容易被想窄了。很多人以为资源就是商品、现金这类有形资产,其实凡是“有价值、能被一方交给另一方”的东西都可以是资源。商品是资源,现金是资源,积分、优惠券额度、服务工时是资源,甚至预约系统里的“会议室可预约时段”也能抽象成一种资源。
判断一个东西能不能算资源,只需要问两个问题:它有没有明确的价值计量方式?它能不能从一方转移到另一方?这两个问题的答案都是“是”,那它就有资格成为资源。我在给某类业务建模时,曾经把整个仓库的“可用仓储面积”也建模成资源,因为不同客户确实会“占用”和“释放”这块面积,资源的视角让整个计费逻辑变得非常干净。
2.2 事件:资源转移的唯一入口
事件是REA模型的心脏,它的定义很严格:一切资源的转移,都必须有一个事件作为触发点。比如“采购入库”是一个事件,它让商品资源从供应商流向仓库;“销售收款”是一个事件,它让资金资源从客户流向公司账户。
事件记录的内容也很固定:什么时间发生了什么事、涉及哪类资源、数量是多少、流向是进还是出、由哪些代理人参与。这里最关键的设计纪律是事件只能追加、不能修改。一次销售出库错了,不是去把原事件的数量改小,而是新增一个反向的“销售退库”事件来冲抵。这跟会计上“红字冲销”的思路是一致的,核心目的是让每一笔历史都留痕,让追溯永远有路可走。
2.3 代理人:资源转移的双方和责任归属
代理人指所有参与资源转移的人或组织,包括外部参与者,比如客户、供应商、物流承运方,也包括内部的员工、部门、仓库。有人会好奇,仓库算不算代理人?我的习惯是算。因为仓库是一个承担“接收和发出货物”责任的主体,把仓库建模成代理人之后,“A仓调到B仓”这样的内部调动就不需要发明新概念,直接表示成货物资源在A仓和B仓这两个代理人之间的转移即可,建模上可以少掉不少特例。
代理人的价值在于责任追溯。每笔资源变动都能立刻回答“谁给的、谁收的、谁经手的”,这在排查纠纷和处理对账时特别有用。反过来说,如果一张流水表里只有金额和方向,没有清晰的代理人维度,那它终究只是一本糊涂账,查不清问题到底出在哪一环。
2.4 REA的基本规则:资源不凭空产生,也不凭空消失
REA建模有一条隐含的守恒铁律:每一种资源的流入和流出必须能对上账。客户的资金不会无端变少,它要么变成了商品,要么变成了退款,要么变成了优惠权益;仓库的库存不会无端变多,它要么来自采购入库,要么来自退货返库,要么来自调拨转入。
这条“有出必有进,有给必有收”的规则,是我在需求评审时最喜欢用的检测工具。每当业务方提一个“莫名其妙”的需求,比如“每周给用户送一张优惠券”,我就会顺着守恒铁律问一句:这个优惠券资源的来源是什么?用户使用后流向哪里?如果需求回答不上来,说明业务闭环没有设计完整,这时候上线一定会出账实不符的后遗症。
双效规则还有一层更实际的价值:它能把一个“看起来很简单”的需求拆成一组互相配对的事件。给用户发优惠券是“优惠券发放事件”,用户下单抵扣是“优惠券核销事件”,两件事成对出现,中间任何一环缺失,模型就是不闭合的,这种不闭合会直接在数据对账里暴露出来。
3. 完整推演:一套电商进销存怎么从零用REA建模
3.1 先别碰代码,把业务流程翻译成“事件清单”
我在实际操作中从不直接画表结构,而是先做一件事:把需求文档里出现的所有业务动词圈出来。采购到货、销售出库、客户付款、退款打款、盘点损耗、内部调拨……这些动词天然就是事件,把这个清单整理出来,REA建模的骨架就算立住了。
下面是某套电商进销存系统我在方案阶段整理出的核心事件清单,以及每个事件对应的资源和代理人的流向关系。
| 业务流程 | 事件类型 | 流入的资源 | 流出的资源 | 参与代理人 |
|---|---|---|---|---|
| 向供应商采购 | 采购入库 | 商品(库存) | — | 供应商、A仓管 |
| 客户下单支付 | 销售收款 | 资金(结算账户) | — | 客户、财务 |
| 仓库发货 | 销售出库 | — | 商品(库存) | 客户、A仓管 |
| 客户退货验收 | 销退收货 | 商品(库存) | — | 客户、A仓管 |
| 向客户退款 | 销退付款 | — | 资金(结算账户) | 客户、财务 |
| 盘点发现损耗 | 库存损耗 | — | 商品(库存) | A仓管、盘点主管 |
| A仓调往B仓 | 内部调拨 | 商品(B仓库存) | 商品(A仓库存) | A仓管、B仓管 |
这张表看起来朴素,但它解决了项目里最要命的“概念统一”问题。此后任何一次需求沟通,我都可以指着某个事件类型问“这个事件涉及的资源和代理人是谁”,答不上来就说明需求还没想清楚。
3.2 一笔订单是怎么变成一串事件的
假设一位客户花999元买了一件商品,用传统建模,一条订单记录加一条支付记录就结束了。用REA,这笔业务被拆成至少两个独立事件:销售收款事件,资金999元从客户流入公司账户;销售出库事件,一件商品从A仓库流出,最终流向客户。
这里有个细节值得展开说。最初我很想把“下单”本身也建模成一个事件,后来发现这会给模型添乱。下单只是表达购买意向,并没有真正发生资源转移,把它建模成一个“业务单据”更合适,真正的资源转移发生在支付和发货这两个节点上。订单在这里的作用是串联多个事件的上下文,本身并不承担资源变动的职责。
如果客户后来又申请退货,系统就会追加一组新的配对事件:销退收货事件,商品从客户手中流回仓库;销退付款事件,资金从公司结算户退回给客户。到这一步再回头看,最初那笔订单所造成的资源影响,被完整地“对冲”掉了,整个模型重新闭合。这种闭合带来的对账能力,是普通状态表很难做到的:每个事件都能找到对应事件,任何一笔资源变动都不会孤零零地悬着。
3.3 代理人不是只有“客户”和“供应商”
建模过程中最容易一带而过的,是代理人维度的设计。很多团队会把代理人默认成“客户”“供应商”这两种外部角色,但实际业务远比这复杂。仓库、售卖渠道、加盟门店、内部员工,甚至物流承运方,都可能在一笔资源流转中充当某个方向上的责任主体。
我建议用一张统一的代理人表来承载这些不同角色,而不是给“客户”“供应商”“门店”各建一张表。客户和供应商在本质上都是“资源转移的参与方”,只是类型不同,把它们收敛到同一张表,内部调拨、三方结算这类跨角色场景就不需要额外拼装数据。把仓库建模成代理人之后,A仓调拨到B仓这个动作,就变成了商品资源在“A仓代理人”和“B仓代理人”之间的定向流转,代码实现和业务描述完全一致,几乎不需要设计文档解释。
4. 从模型到MySQL:一套可以直接落地的表结构设计
4.1 四张基础表加两张链路表,构成事件账本
REA的落地表结构并不复杂,核心是两张“主表”加两张“链路表”。主表分别存资源和代理人,链路表分别记录“事件和资源的关系”以及“事件和代理人的关系”,业务事件本身单独建一张表。下面是基于我在项目中使用过的简化结构。
CREATE TABLE agent ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- CUSTOMER / SUPPLIER / WAREHOUSE / EMPLOYEE agent_name VARCHAR(128) NOT NULL, ext_info JSON, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL, -- PRODUCT / CASH / POINT / COUPON / SERVICE sku_code VARCHAR(64), name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE business_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- 采购入库 / 销售出库 / 销售收款 / 销退付款 biz_no VARCHAR(64) NOT NULL, -- 关联的业务单据号,如订单号 occur_time DATETIME NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'EFFECTIVE', remark VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE event_resource ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction TINYINT NOT NULL, -- 1 表示流入,-1 表示流出 quantity DECIMAL(18, 4) NOT NULL, unit_price DECIMAL(18, 4), UNIQUE KEY uk_event_resource (event_id, resource_id, direction) ); CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role_type VARCHAR(32) NOT NULL, -- 收款方 / 付款方 / 出库方 / 入库方 UNIQUE KEY uk_event_agent (event_id, agent_id, role_type) );这套结构有一个好处:业务规则的变更不会引发破坏性的表结构修改。新增一种业务,比如“满减活动”,只需要新增一种事件类型,并在事件资源链路里把“资金流出”和“商品流出”的方向配好,其他表完全不动。
4.2 方向和数量为什么放在链路表,而不是事件表
这个选择是踩过坑之后才想明白的。一个业务事件未必只涉及一种资源,更常见的形态是“一笔成交同时带来商品流出、现金流入、积分发放”。如果课程设计里把数量和方向直接挂在业务事件表上,要么逼着一条事件记录里塞一堆冗余字段,要么为每种组合拆出好几个事件,模型很快就碎片化。
把数量、方向这些变动属性放到 event_resource 链路表之后,一个事件可以在链路表里挂多行资源记录,每个资源对应一行流向数据。比如一笔组合促销销售:商品A流出1件、商品B流出2件、资金流入299元、用户积分流入100分,这样的事件在链路表里就有四行记录,语义清晰,查询也直接。
event_agent 链路表的道理也一样。一个销售出库事件,出库方是仓库,入库方是客户,可能还有一个经手的仓管员。把职责角色放进链路表,将来新增一个“验收人”角色,只是往链路表里插一行的事。
4.3 事件只追加不修改:这是REA落地的头号纪律
事件表设计的核心纪律是“只追加、不更新、不删除”。这张表本质上是业务账本,不是业务状态。系统算错了怎么办?不是去改原事件,而是新增一个反向冲抵事件,把错误的影响抹平。这条纪律看起来简单,但在工程层面并不容易坚持,因为需求方总是希望你“把错误记录直接改掉”。
坚持只追加的理由非常实际:账本要能回答“当时到底发生了什么”。如果允许修改历史事件,那么任何一笔金额对不上,都有可能是历史记录被改过,排查就没有尽头。而“新增冲抵事件”的做法虽然会多出一些数据量,但每一笔修改都留下了修改痕迹,整个追溯链是完整的。
配合这条纪律,还要在业务单据号和事件类型上加唯一约束。同一个订单的同一类型事件,理论上只能产生一次,如果重复执行就说明程序存在幂等问题,数据库直接拒绝会比应用层拦得更可靠。
4.4 库存余额别在事件表上实时SUM
事件链路表是账本,适合做追溯,但不适合做高频查询。每次查库存都去 SUM event_resource 表里所有流入流出数量,数据量大了之后性能一定会出问题。我在项目里采用的做法是:账本仍然只记录增量事件,同时维护一张独立的“资源余额表”,每次事件写入后同步增减余额。
这本质上是台账和账本分离的思路。账本负责完整性和追溯,台账负责查询效率。查询库存、查询账户余额走台账,出了纠纷就把账本翻出来对。两者偶尔出现不一致,以账本为准重新计算台账,这也是审计思路在技术系统上的体现。
5. 落地REA必踩的坑,以及我的取舍经验
5.1 坑一:把“系统操作”当成了“业务事件”
我第一次把REA讲给团队时,有人兴冲冲地把“用户修改收货地址”也记录成了事件。这是典型的过度建模。判断一条记录该不该成为一个业务事件,标准只有一个:它是否导致了资源在代理人之间发生转移?修改收货地址只是改了订单的配送属性,没有资源变动,就不配占用事件表。
真正该记录的,是那些改变资源归属和数量的动作:支付、发货、退货、退款、给用户发放积分、扣减库存。把系统操作和业务事件混在一起,会让账本里掺进大量噪音,反而掩盖真正需要追踪的经济活动。实践中我经常提醒自己:如果一个事件没有明确的资源流向,它就不应该出现在事件表里。
5.2 坑二:在旧系统上强推REA,迁移越大越难收场
这里必须说句公道话:REA是很优秀的建模思维,但不是银弹,更不应该被当成推倒重来的理由。我见过一个团队试图把已经运行多年的订单系统完全改造成REA模型,中间涉及几十张旧表的数据迁移,最终项目延期了几个月才勉强上线,而且很多人仍然用旧思维去理解新表。
我的建议是在旧系统上采用渐进式引入。不要把老订单表、老库存表一次性推翻,而是先拿一个相对独立的子域做试点,比如“结算对账”模块。新模块按REA建模,老模块的数据通过转换任务按事件类型补录进来。这样既规避了大迁移的风险,又能在一个可控范围内验证REA的收益。等团队真正理解了这套思维,再决定是否扩大应用范围。
5.3 坑三:忘了REA不负责解决并发问题
REA把业务流程理清楚了,但它本身不解决并发场景下的数据一致性。库存从100件被两个订单同时扣成负数,事件模型不会自动阻止,因为它只负责记录“发生了什么事”,而不是“这件事不该发生”。
我在实际项目中为资源余额的扣减增加了两道防线:第一道是更新余额表时使用条件更新,比如“扣减前余额必须大于等于扣减数量”,条件不满足就拒绝更新;第二道是给事件表加工,让同一业务单据的重放事件在数据库层面被唯一约束拦住。事件只追加保证了账本不乱,但业务规则校验的事,必须靠业务代码和数据库约束协同完成,这两者无法互相替代。
5.4 什么时候真的不需要REA
不是所有系统都值得上REA。如果一个系统的核心只是内容管理、简单的增删改查展示、或者单用户工具,资源和事件的概念会很勉强,硬套模板只会增加理解成本。REA最适合的场景,是那些资源流转频繁、多方参与、账目必须清晰的业务系统:电商、供应链、预约计费、企业内部资产流转,等等。
判断要不要用REA,我通常只问一句话:会不会有三个人以上,在不同时间点对同一种资源负责?只要答案是“会”,REA就基本不会让你失望;如果答案是“不会”,那就老老实实用普通的业务表,别给自己找麻烦。
6. 让REA真正融入团队:从一张事件流图开始
最后说一点团队落地层面的体会。想让REA在团队里真正生根,最好的切入点不是给每个人发一份理论文档,而是从下一次需求评审开始,带着大家画“事件流图”。把需求里的每个业务动词摘出来,标出它涉及的资源和代理人,再检查每个资源的流出是否都能找到对应的流入。
这个习惯坚持两三轮之后,团队会发现两个明显变化。第一,需求文档里的模糊表述变少了,因为“事件两边是谁、资源往哪流”这种问题很难蒙混过关;第二,跨模块沟通顺畅了,后端、前端、测试、产品都在说同一种语言,比如“这个事件要新增一个销退收货类型”,而不是“这里再加个状态位”。
命名规范在这里也很重要。我习惯把所有事件类型定义成“动词+资源形态”的格式,比如“采购入库”“销售出库”“销退付款”,不要用“INBOUND_A”“ORDER_REFUND”这种只有开发看得懂的英文缩写。事件类型本来就是业务语言,让业务方也能一眼看懂,命名才真正起到了沟通的作用。
在我个人经历里,REA带给我的最大收获其实不是某张表设计得有多好,而是一种问问题的习惯:任何一笔资源变动,我都会下意识追问它是被哪个事件触发的、在哪些代理人之间发生的、有没有配对的另一笔事件。这个习惯一旦养成,业务建模的准确性会有肉眼可见的提升。你不需要把整个系统都改造成REA,只需要先拿一个最头疼的模块试一次,大概率就能体会到“账终于能对上了”是什么感觉。