☰
从账务模型到REA事件驱动:进销存数据库设计重构实践
2026/10/14 12:48:47 网站建设 项目流程

最近在整理一个进销存系统重构方案,表结构已经改了快三轮。第一轮加税率字段,第二轮加渠道字段,第三轮把订单主表拆成了五张小表。改完那周,我自己都快认不出这套结构了。问题出在哪?其实就是底层一直在用“凭证、科目、余额”的账务思路去装业务流,业务一变形,表就跟着崩。

后来我把这个项目代号定为 rea,顺着 REA(Resource、Event、Agent)这套资源事件代理模型重新设计了数据底座。这轮改完,订单、库存、收付款不再是一张张互相硬拉的表格,而是一串可以随时回放的事件。这篇文章就记录我怎么从一张失控的台账表走到事件化建模的过程,也把实测中的几个坑一并写出来。

如果刚好在做进销存、电商、ERP 这类系统的数据库设计,或者总觉得自己的表字段越来越多、越来越乱,这篇值得往下看。我不打算堆理论,尽量用具体例子和 SQL 说话。

1. 从一张一直改不完的进销存表说起:账务模型为什么在失控

1.1 复式记账给系统设计留下的底子

复式记账这套机制能流传这么多年,确实有它的价值。每一笔交易至少进入两个科目,一借一贷,最后资产负债表能对平。做财务系统的人靠它发现笔误、防止差错,它天然自带一套校验逻辑。于是很多做企业信息系统的团队,顺理成章地把这套东西复制进了业务系统:建科目表、建凭证表、建余额表,把所有业务动作都翻译成借贷分录。

可这里藏着一个错位:复式记账擅长的是“价值运动的守恒校验”,它并不擅长表达“业务是怎么发生的”。销售、发货、收款、退货,在账面上都能变成一组借贷分录,但业务发生的时间顺序、参与人、库存变化这些过程信息,被压缩进了几张抽象的凭证表里。日常对账没问题,一旦要按订单、按用户、按批次去追溯,就发现这套抽象层次离业务细节太远。

这就好比记日记时只记“今日存款+500元,消费-300元”,却完全不写买的是什么、在哪个店、为什么买。月底确实能平衡,但回过头想追一笔账,你根本看不出当时发生了什么。业务系统的底子如果只关心金额平衡,那它天然就会把大量过程细节丢掉。

1.2 我是怎么一步步把表改乱的

我最初的设计其实很朴素:订单表、库存表、流水表。没过多久,业务方开始提需求:要支持折扣、要支持预售、要支持售后、要区分渠道。第一版我做了最直接的操作——加字段。tax_rate、discount_amount、channel_source、business_type 一个个往订单表上堆。堆到第三轮,订单主表已经有三十多列,其中十几个字段在不同业务线下永远是 NULL。

真正让我崩溃的是新业务接入的那次。产品提了个“预购模式”,用户付定金之后几个星期再发货。乍一听是小改动,但落到当时的结构里,要同时动订单表、付款表、库存预留表、退款表,还牵扯到发货时间的判断逻辑。我们评估了一个星期,越评估越觉得这套结构扛不住。因为底层的模型是在描述“账目”,而不是描述“业务本身”,所以业务的每个新动作都会翻出一堆连锁修改。

这也是很多项目共同的路径:刚开始是业务驱动,到中期变成表结构驱动,后期就是“改表救火”。根子往往不在需求多,而在模型抽象的层级从一开始就错了。

1.3 REA给出的不同答案:让“事件”成为核心

REA 模型做的第一件事,就是重新定义“数据库里应该记录什么”。它不关心余额,而是关心“事实”:谁在什么时间,对什么资源做了什么,资源的量发生了什么变化。它把所有东西拆成三类——资源(Resource)、事件(Event)、代理(Agent)。一笔销售不再是一条借贷凭证,而是多个事件组合成的过程:商品资源离开仓库,现金资源进入账户,销售员和买家各自作为代理参与在其中。

每个事件只描述一件原子事实:某商品减少了 3 件,或者某账户增加了 299 元。事件之间用一套业务流 ID 串起来,就是完整的一笔交易。这样做的好处很明显:当业务方说“我们要加一个新的动作”,你通常不需要改主表结构,只要为这个动作新增一个事件类型,让它关联到已有的资源和代理上。以前那种“每来一个需求就动一次主表”的毛病,从根本上被化解了。

2. REA的核心拆解:资源、事件、代理,三者到底在表达什么

2.1 资源:组织里一切可以被控制且有价值的流动量

资源是组织拥有或控制的、能够带来价值的东西。最典型的例子是库存商品、现金、原材料,进一步还可以是积分、优惠券、服务工时、专利授权。识别一个东西是不是资源,有个很实用的判断方法:问它会不会“被消耗”或者“被增加”。会变化的,才值得建模成资源;永远不变的,很可能只是一个描述性属性。

传统账务里我们习惯叫“科目”,但科目是记账口径的分组,和“可流动的标的物”是两回事。比如库存现金科目下存的是钞票,应收账款科目下存的是债权;而 REA 直接把“现金”当成一类资源,把“应收账款”当成另一类资源。它不关心科目怎么分类,只关心资源如何流入流出。

建模初期最容易犯的错误是资源划分粒度不统一。我见过有人把“库存”当成一个资源,结果每次聚合时所有商品都混在一起;也有人把“某商品的某个批次”都当成一个资源,资源表膨胀得厉害。我的建议是:粒度至少到“用户可感知的业务对象”,比如 SKU、货币币种、积分账户。如果后续要支持批次和序列号,可以在资源上再加批次属性,而不是把资源无限细分。

2.2 事件:唯一记录“发生什么”的核心实体

事件是 REA 模型里真正的主角。每个事件记录了一次资源的流入或流出,它是审计的最小单位,也是所有余额的唯一数据来源。一件商品卖出去、一笔钱收进来、一项服务完成交付,这些都在事件表里留下一行不可变的记录。

在设计事件表时有两条铁律。第一,事件只可追加、不可修改、不可删除。如果录错了,就新增一个方向相反的事件去冲正。这一点和复式记账里的红字冲销思路一致,但它是在数据系统层面做的硬约束。第二,事件必须携带充分的时间信息和来源信息。做不到“一个键查清所有来龙去脉”,在审计阶段会非常痛苦。

有一点很多人会忽略:事件表本身不保存余额。余额是事件流聚合出来的结果,这意味着你可以从任意时间点重新计算历史状态,也可以在修复数据时通过重放事件找回一致性。在传统表结构里,余额字段被到处冗余和更新,对账往往演变成“到底哪个字段才是准的”这种问题。

2.3 代理:把责任落到具体的人或组织

代理是参与事件的个人或组织,它回答的是“谁”的问题。REA 把代理分成两类:内部代理和外部代理。内部代理是组织里的员工、仓库管理员、客服人员;外部代理是客户、供应商、物流公司。每个事件至少要关联一个代理,甚至可以同时关联多个代理。

为什么这个要素不能省?因为没有代理的事件是没有责任人的事件。某个库存调整发生了,如果不知道是谁做的操作,出了差错连追索的路径都断了。传统流水表里经常只记录 created_by 一个字段,甚至很多系统直接留空。在 REA 里,代理不是一个可选的“操作人”字段,而是建模的第一等要素,必须显式出现在表结构里。

实际项目中,代理表可以先简单建:agent_type 区分 internal 和 external,再根据业务需要挂上部门、职级、联系方式。不要一上来就设计复杂的组织架构表,大多数情况下用户只需要知道“这是哪个仓库的某个人”。

2.4 用一个订单全部走一遍

把三者串起来看,一段业务就很清楚了。假设一个买家在某电商下单买了一件售价 299 元的商品。拆成 REA 事件是两桩:一桩销售出库事件,商品资源减少 1 件;一桩收款事件,现金资源增加 299 元。两个事件都发生在同一个业务流里,前者由仓管员执行,后者由系统自动确认,而买家和卖家分别充当外部代理和内部代理。

如果把这笔业务放进传统凭证模型,它只是一张会计分录;在 REA 里,你能分别看到商品资源和货币资源的两次流动,还能顺着代理、时间、业务流 ID 把两个事件还原成一次完整交易。这个“事件配对”关系是 REA 模型里的一个核心概念,流入与流出共同构成一笔经济事务。很多报表字段能不能追溯出来,就看当时有没有把配对关系存好。

3. 用REA给一家小型电商企业建模:从圈资源到落数据库表

3.1 第一步:把所有资源列全

做任何建模都建议先列资源清单,而不是先画表。拿一个卖家居用品的小型电商团队来举例,它的资源大概有:可售库存商品、现金账户余额、预付款/押金、平台优惠券、积分账户,甚至可以包括未发货的订单占用的“承诺库存”。把资源列全面,之后的事件定义才有抓手。

列资源时不要漏掉“钱”。很多第一次建模的人只盯着实物商品,现金资源被忘得干干净净,结果收款事件无处安放。我的经验是:把“现金及等价物”拆成两个粒度——企业账户余额和客户预收款余额,它们在后续退款结算时会表现得完全不一样。

资源清单不是一次定死,后续新业务引入新的标的物时,就把它追加到资源表。这是追加型变更,不会推翻已有结构。

3.2 第二步:给每个资源定义事件

资源定了,事件就围绕资源的流入和流出来定义。以库存商品为例:采购入库是流入,销售出库是流出,盘点调整可能是流入也可能是流出,售后退货入库是流入,报废出库是流出。对现金账户而言,收款是流入,付款是流出,退款是逆向流出。每个事件都要能明确回答:它让哪个资源增了还是减了,量是多少。

建议给事件类型维护一张字典表,并在代码中禁止随意裸写字符串。我一开始图省事直接写 event_type='in',后来根本分不清是采购入库还是退货入库。改成 purchase_in、sale_out、payment_in 这类带语义的名称之后,报表和审计都顺眼多了。

一个小技巧:事件类型名称用“业务语义”命名,不要用“in/out”命名。in 和 out 是方向,会被多个资源复用;purchase_in 能直接说清来源和去向,后面查问题省了很多事。

3.3 第三步:识别代理并确定事件归属

接下来给每个事件安排代理。库存类事件通常是内部代理,比如仓库管理员、质检员;资金类事件涉及外部代理,比如买家、供应商。在每个事件里至少保留两个代理引用:internal_agent_id 和 external_agent_id。有些系统里还能看到“经办人”和“复核人”,那就需要一张事件-代理角色关联表,我这里为了讲清楚主干,先按两个代理字段来设计。

这一步看起来简单,却是很容易被忽视的环节。我见过不少项目把代理合并到什么 created_by 字段里,结果“谁执行了操作”和“这单是谁的”彻底分离,查起账来极其痛苦。建模阶段多留两个外键,后期的运营报表和风控审计都会感谢你。

3.4 第四步:翻译成表结构

下面是一组能直接跑起来的 PostgreSQL 建表语句,我把它作为落地参考。

CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, resource_code VARCHAR(32) UNIQUE NOT NULL, resource_name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_code VARCHAR(32) UNIQUE NOT NULL, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(16) NOT NULL, contact_info TEXT ); CREATE TABLE flow ( id BIGSERIAL PRIMARY KEY, flow_code VARCHAR(32) UNIQUE NOT NULL, business_type VARCHAR(32) NOT NULL, external_agent_id BIGINT REFERENCES agent(id), occurred_at TIMESTAMPTZ NOT NULL ); CREATE TABLE business_event ( id BIGSERIAL PRIMARY KEY, flow_id BIGINT NOT NULL REFERENCES flow(id), event_type VARCHAR(32) NOT NULL, resource_id BIGINT NOT NULL REFERENCES resource(id), quantity NUMERIC(18, 4) NOT NULL, unit_amount NUMERIC(18, 4), currency VARCHAR(8) NOT NULL DEFAULT 'CNY', internal_agent_id BIGINT REFERENCES agent(id), occurred_at TIMESTAMPTZ NOT NULL, source_ref VARCHAR(64), event_uuid UUID UNIQUE NOT NULL, CHECK ( (event_type IN ('purchase_in', 'payment_in', 'adjust_in', 'return_in') AND quantity >= 0) OR (event_type IN ('sale_out', 'payment_out', 'adjust_out', 'return_out') AND quantity <= 0) ) );

解释一下其中的设计取舍。第一,quantity 的语义由资源决定:库存商品的 quantity 是件数,货币资源的 quantity 是金额,所以现金收款事件和商品出库事件共用一张表也不违和。第二,unit_amount 是带语义的冗余字段,用于记录商品的成交单价或成本单价,它不是聚合所需的原始事实,但财务和运营报表需要它。第三,CHECK 约束在数据库层保证方向语义不被滥用,比在代码里维护要可靠得多。

最后是 source_ref 字段。它是业务单据上的单号或外部流水号,用来把事件和原始合同、订单、物流单关联。事件表尽可能保持纯净,不要去存收货地址、促销文案这类业务上下文,它们是单据的属性,不是资源的流动事实。

3.5 关联事件:用 flow_id 把一笔交易串起来

一张销售订单会拆出商品出库和收款两个事件,如果系统里有分期付款,收款还可能拆成多个事件。为了把这些事件还原成一笔完整交易,我在 business_event 里引入了 flow_id,指向 flow 表。flow 表记录业务流的整体信息:业务类型、发生时间、外部代理,相当于“这笔买卖的总卡片”。

有了 flow,审计就非常舒服。根据单据号找到 flow,再按 flow_id 把事件全部捞出来,就能看到一笔交易从开单、出库到收款的完整时间线。做对账、做财务月结、做逆向流程时,都只需要围绕 flow 去操作,不需要在各张表之间反复猜测关系。

4. 事件表不是流水账:聚合查询、余额计算与幂等设计

4.1 计算当前库存/账户余额的两种方式

事件表只存增量事实,所以余额永远在聚合的时候才能得到。实现方式有两种。一种是全量聚合,每次查询都把表扫一遍;另一种是维护周期物化视图或汇总表,定时从增量事件里刷新。事件量小的时候,全量聚合没有任何问题;事件量到了几十万甚至上千万,就必须引入快照机制。

我倾向于把事件表当作唯一的“事实来源”,所有物化视图、汇总表都是可以随时重建的投影。一旦发现投影数据不对,优先做的不是手动改数据,而是重建投影。这套原则让运维过程中的应急修复变得很简单——你永远相信事件表是准的,其他都是派生物。

打个比方:事件表是一卷原始监控录像,汇总表是监控室整理的警情摘要。摘要写错了可以重写,但录像带既不能剪也不能误覆盖。只要录像还在,任何时刻的状态都能重新推出来。

4.2 关键SQL示例:按资源聚合、按时间点找余额

先看当前余额,一条 GROUP BY 就够。

SELECT r.id, r.resource_name, r.unit, SUM(e.quantity) AS balance FROM business_event e JOIN resource r ON r.id = e.resource_id GROUP BY r.id, r.resource_name, r.unit ORDER BY r.id;

如果需要某个时间点的余额,加上时间过滤条件。

SELECT r.id, r.resource_name, SUM(e.quantity) AS balance_at_time FROM business_event e JOIN resource r ON r.id = e.resource_id WHERE e.occurred_at <= '2025-06-30 23:59:59' GROUP BY r.id, r.resource_name;

这里的核心逻辑很简单:余额,就是把截止到某个时间点的所有事件累加起来。正因为简单,它才不容易被业务规则撬动。你新增任何业务事件,只要方向正确,余额天然正确。

如果事件量真的上来了,再建物化视图。注意物化视图不是必须的,可以先用一张 balance_snapshot 汇总表记录“每个资源上一次结算到哪个事件、当时余量是多少”,下次刷新时只处理那个事件 ID 之后的新记录。这样把计算量从全表聚合降成增量聚合。

CREATE TABLE resource_balance_snapshot ( resource_id BIGINT PRIMARY KEY, balance NUMERIC(18, 4) NOT NULL, last_event_id BIGINT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );

4.3 event_uuid 幂等与并发控制

事件表追加写入本身不难,难在保证同一笔业务事实不会因为重试、并发而被写两遍。我在 event 表上保留了一个唯一键 event_uuid,它由业务系统在创建事件时生成,通常用业务流水号加一个随机后缀拼出来。只要这个 UUID 唯一,数据库唯一索引天然保证了幂等:同一个业务事实再怎么重复提交,也只能落一行。

这个设计在对接支付回调时尤其有用。支付平台可能同时推送多条通知,传统做法是在业务代码里加锁、加状态判断,但分布式环境下很容易漏。把 event_uuid 的唯一约束下沉到数据库,就让重复提交在最后一层被挡死,省心很多。

还需要补充的是,纠正错误不要 DELETE。事件发生了就发生,即使录错了,也应该新增一个“冲正事件”把量抵消回去。数据库层甚至可以用权限控制,让应用账号没有 DELETE 和 UPDATE 权限,从技术上逼着所有修改走追加路线。

5. 拿REA和传统方案对比:什么变容易了,什么变麻烦了

5.1 一张表看区别

对比维度传统凭证/余额模型REA 模型
核心实体科目、凭证、余额资源、事件、代理
余额来源余额字段随交易实时更新事件流聚合得出
扩展新业务通常要加字段、加表、改流程新增事件类型,追加表达
审计追溯凭证分录与业务单据反复 JOIN事件按时间追加,按 flow 还原
修改/删除需要软删除和复杂权限设计上禁止修改删除,只能冲正
学习成本会计思维,上手直观更抽象,需要切换建模视角

这张表没有绝对的好坏,它只是两种建模哲学的对立:一个面向结果,一个面向过程。前者适合状态明确、规则稳定的业务;后者适合流程复杂、变化频繁的业务。

5.2 变得容易的地方

一是新业务接入不再炸表。预售、拼团、礼卡、虚拟商品,本质上都是“给某个资源增加或减少一定量”,一次建模之后,新业务只是多一种 event_type 和 resource 组合。二是审计追溯变得通顺。想查某笔订单的问题,先按单号找 flow,再顺着 flow 事件列表看资源变化和代理,天然就有一条完整链路。三是数据修护有据可依。因为事件是唯一事实源,发生账实不符时,优先怀疑的是投影而不是源头。

5.3 变得麻烦的地方

麻烦也很明显。报表不再是一个整数余额字段直接读,而是要先算聚合或者依赖物化视图,初期开发量会多一截。对不熟悉这套模型的同事来说,“为什么表里没有余额列”是每天都会被问一遍的问题。另外,如果需要频繁展示实时余额,你还得额外做一个高吞吐读模型,或者引入缓存,复杂度并没有消失,只是从写路径挪到了读路径。

读路径的优化要提前规划,否则上线后才发现报表查询慢,就只能在事发后补物化视图。建议业务跑起来之前就设计好事件量的预估和刷新策略,别让事件表真的变成一辆没有刹车的车。

5.4 什么项目不要硬上REA

如果一个系统只有一张业务表和一个库存余额字段,业务规则也非常固定,比如就是简单的实体店记账,那直接用传统余额模型就行,REA 属于过度设计。REA 真正发挥价值,是在业务类型多、事件种类杂、流程经常调整的中大型系统里。

衡量标准就一句话:未来三个月内,你要不要在一个实体上支持三种以上会产生“增减”的业务?如果需要,REA 值得认真考虑;如果不需要,余额字段够了。

6. 我在模拟项目X中踩过的三个坑与最终体会

6.1 坑一:把“台账”当成事件来建模

第一次重构时,我把“库存台账表”直接换了一张皮,当成事件表在用。每算一次账,就往里头写一条“当前余额”的记录。结果事件表里堆积了海量重复的快照,聚合时余额翻倍,账目一团糟。后来才想明白:台账是事件的投影,不是另一个事实。仓库里真正发生的只有每次增减,余额应该是查的时候算出来的,而不是每次写入的动作。

这个坑的本质是混淆了“写模型”和“读模型”。事件表是写模型,只记录不可变事实;余额表是读模型,服务于展示和查询。两者可以并存,但绝不能混在一张表里。

6.2 坑二:事件表里塞太多业务上下文

第二个坑是过度亢奋,想让事件表包罗万象。后来我往事件表里塞了税率、优惠分摊、收货地址、营销活动标签。最后发现这些字段大多是业务单据属性,不是资源流动事实。当我要写一个通用的余额计算 SQL 时,被一大堆无关字段干扰;做审计时,也因为事件表的字段过多而变得沉重。

后来的做法是:事件表只保留身份、时间、资源、数量、代理、来源引用这几个核心字段,其他业务上下文放进 flow 表或专门的单据表。source_ref 是桥,业务详情通过它去关联,而不是把整个宇宙都塞进事件里。

6.3 坑三:没有提前设计余额物化视图

第三个坑发生在数据量上来之后。当时事件表大约跑了八个月,累积了四百多万行。原本跑得飞快的按资源余额统计,开始出现一次查询两三秒甚至更久的情况。更难受的是,按时间点回溯历史余额的报表,每次都要扫一大片事件,监控告警几乎每天都在响。

补救方案就是前一章说的增量汇总表:维护 balance_snapshot 和 last_event_id,每小时跑一次增量刷新。上线之后,常规查询从秒级掉到毫秒级。这件事给我最大的教训是:如果预估半年后事件量会破百万,就在建表的同时把快照机制设计好,别等报表炸了再救火。

6.4 最终体会

在模拟项目X里走完这一轮,我最大的感受是:REA 其实不只是一套表结构方案,它更像一种对“业务事实”的建模态度。设计任何模块前先问自己——这里真正发生的事件是什么?哪个资源在变动?谁参与的?把这三个问题回答清楚了,表结构通常是水到渠成的事。

如果后续系统要考虑事件溯源或者 CQRS,REA 的事件表几乎可以直接作为事件流底座,两者在理念上非常契合。我也打算下一步把异步通知、消息重放和事件表统一起来,让下游系统直接订阅事件来同步各自的读模型。这几个月的实践证明,在业务变化频繁的系统里,把事实写对,比把结果算对更容易长期维护。

最后聊一个实操细节:开始用 REA 就别在应用代码里留 UPDATE 的入口。哪怕团队初期不习惯,也尽量通过数据库权限和唯一索引把它堵死。只要这个口子开着,改数据的人迟早会把事件表改成一锅粥。追加事实、冲正错误、聚合出结果,这套循环才是模型能跑下去的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询