项目代号REA乍看像是一个“重新写一套会计系统”的活儿实际上它是我和团队复盘过的一套数据建模方法。前阵子我们接手一个图书进销存系统的改造需求很朴素任何一个订单都能从源头追到资金每一分钱的去向任何一个库存数字都能说清楚它由哪几笔业务构成。按传统思路这种需求通常会做成订单表、库存表、账务表三张大表然后靠业务代码去拼。拼到后来SQL越写越长WHERE条件越来越多月底对账时没人说得清哪个数字对不上是从哪一环开始的。后来我们把整个系统按REA模型重新设计REA是Resource、Event、Agent三个单词的首字母也就是资源、事件、代理者。这一改系统行为逻辑从“记录单据”变成了“记录现实世界的经济活动”后面所有查询、报表、审计自然都顺了。这篇内容适合两类人一类是被订单、库存、财务三套数据互相打架折磨的开发或数据分析师另一类是准备做领域建模、想让系统结构更贴近业务本质的产品和架构同事。我不会堆教科书定义只结合这个小项目的实操记录把REA的核心概念、表结构设计、报表实现和踩过的坑一次讲透。如果你手上的系统也整天在“对不上账”“查不清来源”里面打转这套思路值得你花二十分钟看完。1. REA到底是什么它不是一张表而是一套看待业务的方式1.1 一句话理解REA传统进销存系统建模时大家习惯先问“系统里有哪些表”然后按订单、商品、客户、供应商各建一张表。REA模型的起点完全不同它先问“业务在世界里真实发生了什么”再把可能被系统记录下来的“发生的事”拆成三类Resource资源系统关心的、有经济价值的对象比如现金、库存图书、优惠券额度。Event事件经济主体之间发生的活动比如采购图书、卖出图书、支付货款。Agent代理者参与事件的个人或组织比如顾客、供应商、客服、仓库管理员。把这三类实体画成图再连上它们之间的因果关系就是一幅接近业务原貌的数据模型。这个思路最早来自会计信息系统领域上世纪八十年代被正式提出本质是在尝试回答“为什么一套信息系统不能直接记录经济现实而要通过借贷分录间接还原”。在REA里销售不是“借应收账款、贷主营业务收入”销售只是记录“某顾客在某时刻买走了某本书承诺了多少钱的债务库房在某时刻发出这本书造成库存资源减少顾客代理与销售员代理参与了这次的交易”。至于会计分录完全可以从这些事件里自动推导出来不需要手工维护两套账。注意项目代号REA在这里指的就是在进销存项目里应用资源-事件-代理模型的实践不是某个特定框架或工具。整套方法跟具体数据库、编程语言无关MySQL、PostgreSQL、各种大数据平台都能落地。我们项目最开始用的就是最普通的MySQL后续换过存储引擎模型完全没动。1.2 传统方案的三处硬伤一句话概括REA的出发点不要先把业务压缩成“单据”而是先记录“事实”。拿我们改造前的系统举例三处硬伤非常典型。第一业务事实被折叠。订单表同时承载了下单、扣库存、产生应收三个动作如果客户中途改了地址、退了货、取消了订单这些动作都会在同一个字段里反复覆盖。等到想审计“某个库存数字到底怎么变成负数”时发现历史痕迹早就没了。这就像用一块白板记录流水账写满了就擦掉重写最后只剩最新一行的状态中间的过程一点都查不到。第二账务链接靠人工。传统的表结构里库存、订单、账务之间没有天然的键全部由业务代码维护。一个事务里先插订单、再改库存、再写账流水哪个环节漏了数据就悄悄不一致。排查时无从下手只能靠对账脚本一遍遍跑跑出差异再人工判断。这种差异往往要花好几个小时甚至几天才能定位因为没有任何一张表记录了“这几张表之间本来应该是什么关系”。第三业务语言失真。WHAT买了什么、WHEN什么时候、WHO谁参与、WHY为什么发生被压成了状态字段和金额字段业务上明明很清楚的“换货”在两个系统里可能分别被叫成“退货重发”完全对不上话。产品经理要一个“客诉原因分析”数据库里只有“订单状态已退款”根本看不出退款到底是因为质量问题、物流问题还是顾客后悔。REA正是为了填这三个坑而存在的。它鼓励把业务拆成原子事件把资源和代理者显式建模让系统结构与业务结构一一对应。下面我逐步展开这套模型的骨架。2. REA核心概念拆解与设计思路2.1 三个主角资源、事件、代理建模时先认人再认事再认物。我把一个图书进销存场景里的对象对号入座资源Resource要满足三个条件稀缺、可被控制、系统需要跟踪。库存图书是资源仓库货架位不是资源——系统并不关心货架本身的经济价值优惠券余额是资源因为它会减少顾客实名认证信息不算资源因为它不产生经济流入流出。事件Event是REA的发动机。每一笔采购入库、销售出库、收款、退款、报废都是一个事件。事件往往要成对出现经济资源的增加和减少通常互有因果。比如销售图书引发的库存减少与收到现金引发的现金增加就是一对经济事件在REA里往往通过“交换”关系连在一起。代理者Agent分成内部代理与外部代理。内部代理是自己组织里的经办人外部代理是顾客、供应商、物流承运商。一个事件至少要关联一个内部代理和一个外部代理这样才能回答“谁参与了这件事”。只有内外代理同时在场一个经济事件才完整。实战心得识别事件的标准很简单——看这个动作有没有实际改变我们关心的资源。每登记一条“下单”记录如果它没有改变库存、没有产生应收、没有占用资金那它多半只是一个状态快照不值得作为业务事件建模。我在项目里为了省事曾经把每个前端点击都登记成一条事件结果表膨胀得很快后来不得不做事件与日志的分离有经济后果的才进事件表其余只进行为日志表。2.2 关系是模型的灵魂实体建完只是骨架REA真正值钱的是实体之间那三类关系。资源与事件之间的关联叫做“影响”。一个事件可以影响多个资源比如“采购一箱图书并用运费到付支付”同时影响了库存图书和现金。反过来一个资源可以被多个事件影响比如一本书连续被入库、出库、退货三个事件影响。因此资源和事件之间通常是多对多需要一张关联表去承载“数量、单价、金额”这种有时间属性的细节。事件与代理者之间的关联叫做“参与”。每个销售事件至少关联一个外部代理顾客和一个内部代理销售员每个采购事件至少关联一个外部代理供应商和一个内部代理采购员。如果一次销售由多个顾客共同承担事件的代理者关联可以是多对多。我们项目里做过企业团购一个订单对应多个部门联系人这时候顾客就不是单个人而是映射成“组织客户多个联系人”一样可以建模。事件与事件之间的关联是REA最特别的地方它表达“这个事件和另一个事件因什么而耦合”。最常见的是“交换”关系销售事件通过“顾客承诺付款”链接到未来或当期的收款事件采购事件通过“公司承诺付款”链接到付款事件。此外还有“转换”关系原料库存减少事件和成品库存增加事件之间靠生产活动连成一条链。可以这样理解REA的关系传统模型像把每张业务单据做成一张照片只看到某个瞬间的状态REA更像一段连续录像每个事件都带着因果关系往前延伸能回溯也能推演。这种关系设计带来的直接好处是以后做“这个订单的钱到底有没有回”“这批库存的周转率怎么算”这类问题不再依赖业务逻辑的记忆而是顺着关系图查询即可。我们的客服同事以前查一笔退款要开三个后台页面现在直接打开事件关系图点一次就能看到完整链路。2.3 三条约束规则少了哪条都不行REA建模被很多人误解成“多建几张表”其实真正约束模型不走形的是三条规则我建议你在画E-R图时逐条检查。规则一每个经济事件必须直接影响至少一个资源。只记录“顾客来了”而没记录“带来/带走资源”的事件不是经济事件应该降级为普通日志。这条规则能过滤掉大量无效数据让事件表保持干净。规则二每个经济事件必须关联两个代理者一个内部、一个外部。这条规则保证了每一笔业务都有清晰的权责边界。我们曾经设计过一个“库存调整事件”最初只关联了内部仓库管理员结果审计时发现无法回答“这批损耗是供应商多送了还是仓库保管不善”后来补上外部承运商关联问题才闭合。从那次以后我们所有新事件建模都强制执行这条规则。规则三每个经济事件必须至少参与一条“交换”或者“转换”关系链。也就是说孤立事件在模型里不该存在。一个库存减少事件要么最终链接到销售收款事件交换要么链接到生产消耗事件转换。这条规则保证了整个业务图是一张网而不是一堆孤岛。如果建模时发现某个事件挂不上关系链大概率是业务梳理漏了某一环回头去跟业务方聊通常能挖出一个被忽略的流程。注意三条规则是模型层面的事不是数据库约束层面的事。实际上建表时很难用外键强制“一个事件必须关联两个不同代理者”这类判断更多要放到服务端校验。别试图把业务约束全部翻译成数据库约束那会把实现搞得很拧巴。我们在服务端加了一个事件注册时的校验器每次写入事件都检查对应代理者和资源是否齐全不满足直接拒绝入库。3. 实操用REA重构一个图书进销存系统3.1 需求梳理把业务流程翻译成事件列表用的改造对象是一个模拟图书电商项目名字就按项目代号叫“REA Demo”。这个Demo既要支持采购入库也要支持销售出库、收款、退货、供应商退款、盘点调整。按REA思路我们不先画页面和接口而是先列业务事件清单。建议你从全团队聊天记录和线下需求讨论里把“所有会改变资源数量的动作”捞出来业务动作影响资源参与者因果事件向供应商采购图书库存图书增加采购员、供应商采购入库事件后续产生应付支付供应商货款现金减少出纳、供应商付款事件与采购入库对应顾客下单并发货库存图书减少销售员、顾客销售出库事件后续产生应收顾客支付货款现金增加收款员、顾客收款事件与销售出库对应顾客退货库存图书增加、现金减少客服、顾客退货事件与销售/退款事件对应盘点调整库存图书增减仓管员、复核员盘点事件与责任归属关联拿到这张清单后我会做一次“事件纯度检查”凡是不改变资源数量的动作比如“浏览商品”“加入购物车”全部剔除不进事件表。凡是改变资源数量的动作不管它现在有没有对应的页面或接口都必须列入建模范围。这一步做完后面设计表结构就有底了。我们当时列完清单发现系统里有三个入口都能改库存订单发货、客服手工调、接口批量同步这其实是同一类销售/调整事件的不同通道建模时统一收敛成同一个事件类型只是把来源渠道写进备注字段。3.2 表结构设计从模型到数据库DDL模型确认后落地到关系数据库就变得机械很多。仍以REA Demo为例我需要至少这样几张核心表resource_book图书资源表只保存图书静态信息比如书名、ISBN、定价。agent_customer顾客代理表保存顾客信息。agent_supplier供应商代理表保存供应商信息。agent_staff内部员工代理表保存仓库管理员、采购员、销售员等经办人。event_purchase采购入库事件表记录从供应商进货的批量事件。event_sale销售出库事件表记录顾客购买事件。event_payment_in收款事件表记录顾客付款。event_payment_out付款事件表记录向供应商付款。link_resourcing_sale销售资源影响表承接事件与图书的多对多明细。下面是一段简化后的建表SQL以MySQL为例我先创建资源和代理表CREATE TABLE resource_book ( book_id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(128) NOT NULL, base_price DECIMAL(12,2) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_isbn (isbn) ) ENGINEInnoDB; CREATE TABLE agent_staff ( staff_id BIGINT PRIMARY KEY AUTO_INCREMENT, staff_no VARCHAR(32) NOT NULL, staff_name VARCHAR(64) NOT NULL, role VARCHAR(32) NOT NULL COMMENT 角色采购员/销售员/收款员/仓管员, UNIQUE KEY uk_staff_no (staff_no) ) ENGINEInnoDB; CREATE TABLE agent_customer ( customer_id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_no VARCHAR(32) NOT NULL, customer_name VARCHAR(64) NOT NULL, credit_level TINYINT DEFAULT 3, UNIQUE KEY uk_customer_no (customer_no) ) ENGINEInnoDB;接下来是核心的事件表。以销售事件表为例我先定义一张事件头表再通过资源影响关联明细表衔接具体图书CREATE TABLE event_sale ( sale_id BIGINT PRIMARY KEY AUTO_INCREMENT, sale_no VARCHAR(32) NOT NULL, occur_at DATETIME NOT NULL COMMENT 业务实际发生时间不是写入时间, internal_agent_id BIGINT NOT NULL COMMENT 销售员, external_agent_id BIGINT NOT NULL COMMENT 顾客, status TINYINT NOT NULL DEFAULT 1, remark VARCHAR(255), UNIQUE KEY uk_sale_no (sale_no), CONSTRAINT fk_sale_staff FOREIGN KEY (internal_agent_id) REFERENCES agent_staff(staff_id), CONSTRAINT fk_sale_customer FOREIGN KEY (external_agent_id) REFERENCES agent_customer(customer_id) ) ENGINEInnoDB;这里必须强调一个细节事件表里不要直接放“总金额”字段金额应该由资源影响明细行计算得出。很多人习惯在事件头表加一个total_amount然后用程序维护它结果经常出现合计与明细对不上的问题。REA的建模习惯是让金额变成事件细节的派生值查询时统一汇总避免“双写不一致”。资源影响关联表长这样CREATE TABLE link_resourcing_sale ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sale_id BIGINT NOT NULL, book_id BIGINT NOT NULL, quantity INT NOT NULL COMMENT 影响数量, unit_price DECIMAL(12,2) NOT NULL COMMENT 成交单价, line_amount DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED, CONSTRAINT fk_lrs_sale FOREIGN KEY (sale_id) REFERENCES event_sale(sale_id), CONSTRAINT fk_lrs_book FOREIGN KEY (book_id) REFERENCES resource_book(book_id) ) ENGINEInnoDB;为什么把“销售数量”写成影响数量因为REA不区分“正向数量负向数量”事件本身就能说明方向销售事件导致的库存减少是事件语义不是负数。查询库存余量时我们把销售事件的影响数量按负号参与汇总即可。这样设计的好处是报表层可以根据需要定义口径模型层保持不变。比如财务要按“不含税口径”看库存金额模型不用动只是在查询层把单价换成不含税价。收款事件和付款事件的结构类似区别只在代理者角色。收款事件里内部代理是收款员、外部代理是顾客付款事件里内部代理是出纳、外部代理是供应商。此外还要一张事件关联表把销售事件和收款事件挂在一起表达“这次收款对应的是哪批销售”。最简单的方式是在收款事件表里增加一个source_sale_id字段如果一次收款对应多个销售事件就单独建关联表多对多保存。我们实际采用的是独立关联表因为顾客经常一次付清好几笔订单的钱多对多关系更真实。3.3 报表查询从事件还原业务全貌表结构落定后最关键的是验证这套模型能不能回答业务问题。我用三个典型查询来说明。第一个是库存余量查询实时余量等于采购入库的累计影响数量减销售出库的累计影响数量再加退货和盘点调整。SQL示意如下SELECT b.book_id, b.title, COALESCE(SUM(CASE WHEN ev.event_type PURCHASE THEN rs.quantity ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN ev.event_type SALE THEN rs.quantity ELSE 0 END), 0) COALESCE(SUM(CASE WHEN ev.event_type RETURN_IN THEN rs.quantity ELSE 0 END), 0) AS stock_qty FROM resource_book b LEFT JOIN link_resourcing_all rs ON b.book_id rs.book_id LEFT JOIN event_all ev ON rs.event_id ev.event_id GROUP BY b.book_id, b.title;这个查询在实际项目里只是示意真实场景中把采购、销售、退货各放在一个事件表里需要UNION多个关联比较繁琐。所以我推荐在数据库里建立一张统一的“事件明细视图”把所有事件类型的资源影响行做一次UNION后续报表全部面向这张视图查询。这样写出来的SQL会短很多也更好维护。第二个是销售毛利计算。传统模型里毛利 销售收入 - 销售成本而成本单价往往要从历史入库批次里取非常麻烦。在REA里我们把销售事件与资源关联后再关联到对应的采购事件链就能逐行算出成本。实践中最快的做法是在事件明细视图里给每一条销售明细打上批次来源成本来自对应的采购入库明细的unit_price。批次的维护可以在入库/出库时用先进先出法或移动加权平均法实时计算写成一个小函数。模型不必为此增加复杂度只要事件明细里保留batch_key字段即可。第三个是对账查询从销售事件出发关联收款事件汇总出“已销售未收款”名单。因为销售事件和收款事件有明确的关联键接待客服的同事终于可以在一个SQL里看到某位顾客的全部欠款来龙去脉而不是翻三个后台页面手工拼。我们把这个查询存成了视图客服后台直接引用上线后客诉处理效率明显提升。实操心得如果一个小团队只有一两个开发我建议先按“事件头表事件明细表”的方式实现不要一上来就设计十几张关联表。REA的价值是让你把业务讲清楚不是为了炫建模复杂度。我们项目上线后长期只有五张核心事件表其他都是辅助查询视图运行得很稳。3.4 落地细节日志、幂等和一致性REA模型落地的难点往往不在建表而在工程细节。我列几个真实踩过的点。第一事件表要设计成“只追加、不改写”。任何业务变更都不是修改原事件而是新增一个相反方向的调整事件。比如顾客退货不是把原销售事件里的数量改成0而是新增一笔RETURN_IN资源影响记录。这样库存流水天然就是审计日志任何状态都能追溯到源头。如果业务要求订单状态可变化建议把“订单状态”放在单独的订单业务表里不要把状态的变更刷到事件表上。第二事件必然发生在前端与后端之间记录重复很容易导致库存翻倍或资金重复入账。每个事件表都要有唯一业务编号比如sale_no、payment_no并在插入时捕获唯一键冲突冲突时直接返回已有事件而不是再次插入。这在消息队列重试场景下尤其重要。我们当时对接了一个外部支付回调对方偶尔会重发通知如果没有幂等控制顾客付款会被记录两遍。第三金额计算尽量走数据库生成列或事件明细汇总不要在应用层做两份存储。我们早期在事件表加过冗余的total_amount结果每次改折扣都要同步更新两个地方后来全部改成明细汇总问题才消失。数据库生成列在MySQL 5.7以上都支持性能也不错。第四时间字段要区分“发生时间occur_at”和“写入时间created_at”。审计报表按occur_at统计技术排查按created_at查日志。如果只留了一个created_at业务补录的历史单据会把时间语义完全搞乱。我们上线后遇到过一次全量补单补的单据发生时间分布在三个月前但因为系统只记写入时间月度报表全部错乱教训很深刻。4. 常见问题与排查技巧实录4.1 事件该记金额还是记明细这是被问得最多的一个问题。我们在建模时坚持事件明细必须记“数量×单价”事件头不记汇总金额。原因是汇总金额可以由明细推导反过来不行。一旦订单包含多本不同价位的书只有汇总金额的事件头根本无法还原“这本卖了多少钱、那本退了多少”。如果为了报表性能确实需要提前汇总就把它建成物化字段由事件入库时的明细触发重算而不是靠应用层维护。我们后来加过一个“订单维度汇总表”每天凌晨从明细事件计算生成报表秒开明细和汇总也从来没有对不上过。4.2 代理者边界怎么划代理者并不等同于“所有人”。在REA模型里一个代理被建模为“参与经济事件并承担权责的主体”。顾客、供应商、员工是代理物流公司的司机是不是代理看情况。如果司机只负责搬运不承担资金或货权责任那就不是代理顶多是事件的一个备注字段。如果仓储外包承运商要承担货损责任那就需要把承运商建模成代理并参与到库损/理赔事件里。划边界的原则是看它是否需要出现在对账、审计、责任认定里。我们采购部有一次想给每个快递员单独建代理表被我否掉了因为快递员的身份和业务权责无关联系方式存外部承运商代理的附加信息里就足够。4.3 REA模型和财务会计怎么共处很多财务同事第一次看到REA建模都会问那传统的借贷款到哪里去实际上两者不冲突。REA模型记录经济现实财务会计则是对同一现实的法定视角。你需要做的是在事件表旁边维护一张“会计转换视图”把销售事件按约定科目生成借方应收账款、贷方主营业务收入把收款事件生成借方银行存款、贷方应收账款。这个转换层可以是SQL视图也可以是结账程序。好处是只要事件表真实可信会计口径随时可以按新规则重跑不需要回改业务数据。我们后来换过一次税率财务规则变了只改转换视图里的科目映射历史数据全部自动重算全程没动业务事件表。4.4 读取性能扛不住怎么办事件表天然会越积越大所有报表都基于事件明细汇总读压力确实不小。我试过几类办法按性价比排序先说结论。优先为高频查询建汇总表比如按“资源×天”的库存流水汇总、按“客户×月”的销售汇总用定时任务从事件表增量刷写报表优先查汇总表明细穿透再查事件表。其次在事件明细表上建好覆盖索引查询一定要带occur_at时间范围。最后实在不行再考虑把历史事件归档到独立库。不要一上来就上大数据组件绝大多数场景下关系库加合理索引足够扛住。我们系统的历史事件目前已经积累了千万级一直在MySQL上跑报表查询稳定在秒级以内。4.5 常见误区速查表最后我把项目里见过的误区整理成速查表方便新手对照自查。这些误区大多不是从书本上来的而是我们团队在真实迭代中踩过的坑值得多花两分钟逐行看一遍。常见误区错误原因正确做法把前端点击都当事件没有经济后果只有改变资源数量/价值的事件才入事件表事件头表存汇总金额明细被折叠无法还原金额由明细行汇总头表无金额或金额仅供展示直接修改事件记录破坏审计链用新增反向事件表达变更用状态字段表达业务过程状态被覆盖过程丢失用事件序列表达过程状态只是最近一次快照一上来就设计二十张表过度建模维护成本高从五个核心事件起步再按需扩展事件重复写入库存/资金被虚增唯一业务编号幂等控制代理者身份化而非角色化责任边界过窄按权责角色建模身份只是辅助属性排查小技巧当某个库存数字对不上时不要先去翻汇总表而是把涉及该资源的所有事件明细按occur_at顺序全部打出来通常几百行记录就能定位问题发生在哪一笔。我们团队用这个方法修复过多次“库存神秘消失”最后发现都是某个调试接口只改了汇总数没有记录事件后来把这个接口彻底废掉问题再没出现过。最后聊点个人体会。项目代号REA不是某个高深框架它的核心其实是用更贴近真实世界的语言重新描述业务。我一开始以为引入REA会增加工作量真正做完后才发现多花的时间几乎全在建模讨论阶段后面写报表、做对账、补审计反而省了一大截。如果让我给准备尝试的人一句建议就是别急着画表先拿一张白纸把业务事件一个个列出来和团队吵清楚哪些是资源、哪些是事件、哪些是代理。这一遍梳理清楚后面所有设计都是水到渠成。REA模型的边界也很清楚它擅长处理有清晰经济后果的业务领域如果你手里正好有这样的系统不妨从一个小模块开始试。