
先说清楚rea不是拼写错误也不是什么英文单词的缩写玩梗。这是我最近一个原型项目的代号全称是Resource-Event-Agent翻译过来就是资源-事件-参与方模型。这套思路在会计信息系统领域很早就有人系统性地提过核心主张就一句话与其把业务压进科目-凭证-余额表三层结构里不如先把业务事实本身记下来——谁、在什么时间、对哪种资源、做了什么动作、涉及了谁。我照着这个思路用Python和SQLite做了个小原型从建表到跑通一笔完整销售只花了大半天。这篇文章适合正在设计进销存、账务中台、订单链路或者任何需要把账算清楚还能说清楚来源的系统的人看。新手可以跟着第4章的代码一步步走老手可以直接跳到第3章的设计说明和第5章的踩坑记录。1. 项目概述代号rea的原型到底解决什么问题1.1 为什么取名叫rea从一次糟糕的对账说起把事情说回起点。手头有一个库存与资金联动的系统最初按传统套路设计了商品表、库存流水表、账户流水表和一张月结的科目余额表。开发阶段没觉得有什么不对劲直到上线后某天财务拿着Excel过来说这批货从入库到卖掉中间每个环节的数怎么对不上我翻了半个小时发现要回答这个问题得把库存流水按商品维度join一遍再跟资金流水按单据号join一遍中间还要过滤掉盘点、调拨、退货这些异常单。数据都在结论却很难给。我给这个重构项目起名rea就是为了时刻提醒自己不要再在科目上打补丁了试着用资源、事件、参与方这三个词重新组织所有业务事实。1.2 这套资源-事件-参与方模型适合谁、怎么用先说结论rea模型最擅长解决三类问题。第一是事实追溯任何一笔余额变化都能顺着事件表找到是什么动作、由谁发起、跟哪张单子对应。第二是余额可信余额不是手工维护的字段而是事件投影出来的结果事件没错余额就不会凭空多出来。第三是流程扩展新增一种业务动作比如调拨借出盘点损益不用改表结构只要往事件表里加一种类型就行。反过来它不适合的场景也要说清楚。如果业务很简单、只有一张流水表就能覆盖没必要上REA那是杀鸡用牛刀。如果对实时余额的查询性能有极端要求光靠视图投影不够必须配套物化投影方案后面第5章会展开。如果是给老系统改造也不要指望一次性推翻重来比较稳妥的做法是并行跑一段时间再切换这个我在第6章也会讲。如果你是建模新手我的建议是别一上来就纠结事件和单据的区别这种理论问题先跟着第4章把代码跑通再回头读第2章的概念理解会快很多。2. 传统科目表不够用REA模型凭什么重构业务数据2.1 复式记账的四个优点和一个盲区先为复式记账说句公道话。它能流传几百年核心无非四个优点有借必有贷规则对称借贷金额天然平衡可以用来粗校验分类汇总口径固定适合出财务报表科目体系是行业沉淀会计们上手成本低。做财务系统不参考这些经验是不理智的。但它的盲区也很明显复式记账是为报告设计的不是为追溯设计的。一笔销售发生之后系统里留下的是两个分录——借方记现金、贷方记收入同时成本那边借方记成本、贷方记库存。乍看四行分录很完整可一旦要回答卖给谁了对应哪笔采购批次这批货是不是先借出去过分录层给不出答案因为参与方、批次、业务动作这些信息在落入科目表时已经被丢了。说得直白一点账是平的事实却散了。REA模型想做的就是在事实层多做一步存储让报告层以后随时可以从事实层重算出来。2.2 核心三角资源、事件、参与方到底指什么REA把企业里所有业务相关的信息归到三个类别这是整个模型的骨架。概念一句话定义生活化例子系统里的落点经济资源有价值、可被控制、能参与交换或转换的东西一袋面粉、一张百元钞、一小时人工商品表、资产表、资金账户经济事件资源数量变化的那一瞬间的动作把面粉做成面条、把面条卖出去事件表、流水表经济参与方发起或接收事件的内部/外部主体顾客、供应商、本店员工客户表、供应商表、组织表三个词放一起就是一句话参与方发起事件事件让资源的数量发生变化。注意事件强调的是发生了而不是应该发生。下单不是事件发货才是挂账不是事件对方确认收货、钱款入账才是。这个区别是后面最容易踩坑的地方第5章会单独讲。2.3 三种经济流程交换、转换、增减怎么区分把资源和事件连起来的动作组合REA归纳成三种基本流程。理解它们建模时就不会乱。第一种是交换。典型场景是销售我把货给你你把手里的钱给我。交换一定涉及两个参与方而且至少有一出一进两个事件配对。这里的关键不是货和钱谁先谁后而是两份资源的流动必须成套出现否则账就不平。第二种是转换。典型场景是生产领料把面粉、糖、油这些原料领出车间加工成蛋糕入库。转换不涉及外部参与方是企业自己内部资源形态的变化一个减一个增同样要配对。第三种是增减。典型场景是盘点后报废、丢失或者盘盈。它只涉及一种资源的变化不需要配对。很多同学在这里纠结报废是不是要记两个事件不需要真正发生的就是库存减少了这一个事实记一条decrement事件即可等月底要不要调整税务口径、财务口径那是报告层的事。区分三种流程有一个很实用的检验方法问自己这个动作如果只记一半业务能不能正常解释如果能说明它是增减如果不能说明它是交换或转换需要把配套事件找齐。这条经验比死记定义好使。3. 落地前的建模细节资源表、事件表、参与方表怎么设计3.1 三张核心表的职责划分与SQL定义到这一步理论说得再多也不如直接看表。原型用的是SQLite表结构我压到最简去掉业务系统里常见的冗余字段只保留REA模型必须的部分。CREATE TABLE economic_resource ( resource_id INTEGER PRIMARY KEY AUTOINCREMENT, resource_code TEXT NOT NULL UNIQUE, resource_name TEXT NOT NULL, unit TEXT NOT NULL, resource_type TEXT NOT NULL, -- asset/service/money status TEXT DEFAULT active ); CREATE TABLE economic_agent ( agent_id INTEGER PRIMARY KEY AUTOINCREMENT, agent_code TEXT NOT NULL UNIQUE, agent_name TEXT NOT NULL, agent_type TEXT NOT NULL, -- internal/external status TEXT DEFAULT active ); CREATE TABLE economic_event ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_time TEXT NOT NULL, event_type TEXT NOT NULL, -- increment / decrement resource_id INTEGER NOT NULL REFERENCES economic_resource(resource_id), quantity REAL NOT NULL CHECK (quantity 0), agent_id INTEGER REFERENCES economic_agent(agent_id), counterparty_id INTEGER REFERENCES economic_agent(agent_id), related_event_id INTEGER, -- 配对事件ID ref_no TEXT, -- 业务单据号 remark TEXT, CHECK (event_type IN (increment, decrement)) );其中经济事件表是整个模型的心脏。三个设计点值得单独说。第一quantity永远存正数方向用event_type表达。这样做的好处是增量和减量可以各自做SUM对账时一眼看出加了多少、减了多少而不是叠加在一堆正负号里。第二事件表不直接存余额余额由视图或查询实时算。这跟现在流行的存增量不存存量的思路一致等于给系统留了重放审计的后路。第三counterparty_id是可选字段。交换场景下它存外部参与方转换场景下它通常是空增减场景下它也是空。允许NULL是刻意的不要为了完整性强行填值。3.2 事件配对为什么必须用事务保证完整性再看related_event_id。它的作用是把一个交换或转换里的两个事件绑在一起。比如一次销售商品出库事件和现金入账事件要互相关联这样才能从任一个事件出发把整组业务链路拉出来。配对本身不复杂但落库必须保证原子性。实操时会遇到一个经典问题先插入出库事件成功再插入入账事件时SQL写错了导致只存了半条业务。库存减了、钱没记上账就平不了。解决方法是把两个插入和两次UPDATE放进同一个数据库事务里任何一个环节失败就整体回滚。我在原型里是把事务放在Python侧的with conn块里生产环境如果用ORM也要保证这四步在一个事务边界内。另外给ref_no业务单据号加唯一索引是防止重复记账最有效的一招。同一个销售单号如果被接口重放两次唯一约束会直接把第二次插入挡在门口。这是我在实际系统里被重复入账坑过之后养成的习惯。3.3 REA事件与会计分录的映射关系很多做财务系统的人看到这里会问REA建得再好财务还是得看借贷。没问题REA和复式记账不是替代关系是分层关系。事件表是事实层分录是报告层中间加一张映射规则表就能无缝转换。REA事件事实记录对应经典分录销售出库库存商品 decrement借主营业务成本 贷库存商品销售收款现金 increment借现金 贷主营业务收入 / 应收账款采购入库库存商品 increment借库存商品 贷应付账款支付采购款现金 decrement借应付账款 贷现金有了这张映射表传统的试算平衡依然可以做把所有事件按映射规则生成分录后借贷总和必然相等因为事实层本身就是对称配对记录的。反过来传统系统要补REA记忆力就比较痛苦因为原始业务动作在入账时已经被丢弃很难百分之百还原。这也是我推荐新系统直接采用事实层优先建模的原因——给未来留点可追溯的空间成本比现在想象的低得多。4. 实操记录用Python和SQLite跑通一个rea原型4.1 初始化表结构与环境准备原型我没引入任何外部依赖直接用Python自带的SQLite原因有两个一是SQLite文件即数据库跑完就能把库文件打包发给人复现二是这套模型的核心在表结构和事务边界不依赖特定数据库的高级特性。你完全可以把DDL原样搬到MySQL或PostgreSQL只需要注意后面提到的锁和物化方案差异。import sqlite3 DB_PATH rea_demo.db def init_db(conn): cur conn.cursor() cur.executescript( PRAGMA foreign_keys ON; CREATE TABLE IF NOT EXISTS economic_resource ( resource_id INTEGER PRIMARY KEY AUTOINCREMENT, resource_code TEXT NOT NULL UNIQUE, resource_name TEXT NOT NULL, unit TEXT NOT NULL, resource_type TEXT NOT NULL, status TEXT DEFAULT active ); CREATE TABLE IF NOT EXISTS economic_agent ( agent_id INTEGER PRIMARY KEY AUTOINCREMENT, agent_code TEXT NOT NULL UNIQUE, agent_name TEXT NOT NULL, agent_type TEXT NOT NULL, status TEXT DEFAULT active ); CREATE TABLE IF NOT EXISTS economic_event ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_time TEXT NOT NULL, event_type TEXT NOT NULL, resource_id INTEGER NOT NULL REFERENCES economic_resource(resource_id), quantity REAL NOT NULL CHECK (quantity 0), agent_id INTEGER REFERENCES economic_agent(agent_id), counterparty_id INTEGER REFERENCES economic_agent(agent_id), related_event_id INTEGER, ref_no TEXT, remark TEXT, CHECK (event_type IN (increment, decrement)) ); CREATE INDEX IF NOT EXISTS idx_event_resource_time ON economic_event(resource_id, event_time); CREATE INDEX IF NOT EXISTS idx_event_ref ON economic_event(ref_no); ) conn.commit()开PRAGMA foreign_keys ON是因为SQLite默认不强制外键不打开的话外键约束形同虚设生产系统里这一点要特别留意。4.2 两个核心方法单事件与配对事件REA落库只需要两个写方法一个记录不配对的增减事件一个记录配对的经济交换。def record_change(conn, event_time, event_type, resource_id, quantity, agent_id, counterparty_id, ref_no, remark): 记录单个经济事件适用于增减流程也供交换方法内部调用 cur conn.cursor() with conn: cur.execute( INSERT INTO economic_event (event_time, event_type, resource_id, quantity, agent_id, counterparty_id, ref_no, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?) , (event_time, event_type, resource_id, quantity, agent_id, counterparty_id, ref_no, remark), ) return cur.lastrowid def record_exchange(conn, event_time, agent_id, counterparty_id, out_resource_id, out_qty, in_resource_id, in_qty, ref_no, remark): 记录一次交换流出一种资源流入另一种资源两个事件必须同时落库 cur conn.cursor() with conn: cur.execute( INSERT INTO economic_event (event_time, event_type, resource_id, quantity, agent_id, counterparty_id, ref_no, remark) VALUES (?, decrement, ?, ?, ?, ?, ?, ?) , (event_time, out_resource_id, out_qty, agent_id, counterparty_id, ref_no, remark), ) out_event_id cur.lastrowid cur.execute( INSERT INTO economic_event (event_time, event_type, resource_id, quantity, agent_id, counterparty_id, ref_no, remark) VALUES (?, increment, ?, ?, ?, ?, ?, ?) , (event_time, in_resource_id, in_qty, agent_id, counterparty_id, ref_no, remark), ) in_event_id cur.lastrowid cur.execute( UPDATE economic_event SET related_event_id ? WHERE event_id ?, (in_event_id, out_event_id), ) cur.execute( UPDATE economic_event SET related_event_id ? WHERE event_id ?, (out_event_id, in_event_id), ) return out_event_id, in_event_idrecord_change把单次INSERT封装成一个事务record_exchange则把四个SQL操作包进一个with conn块。SQLite的with conn表示开启事务、提交或回滚这样即使第二次INSERT抛异常第一次的INSERT也不会留下。两个方法都返回event_id方便业务层拿来做后续关联。quantity和event_type的约束在数据库层已经写好代码里不需要重复判断但为了用户体验接口层还是建议做一次友好提示。4.3 从事件到余额用量投影视图像查表一样查账事件存的是流量用户要的是存量这中间需要一个映射动作。最直观的做法是建一张视图把事件表按资源分组increment累加decrement取负累加CREATE VIEW IF NOT EXISTS v_resource_balance AS SELECT r.resource_id, r.resource_code, r.resource_name, r.unit, COALESCE(SUM( CASE WHEN e.event_type increment THEN e.quantity WHEN e.event_type decrement THEN -e.quantity ELSE 0 END ), 0) AS balance_qty FROM economic_resource r LEFT JOIN economic_event e ON e.resource_id r.resource_id GROUP BY r.resource_id, r.resource_code, r.resource_name, r.unit;视图本身就是一个可查询的余额表但它不像传统余额表那样手工维护。事件表里只要多一条正确的记录余额自动跟着变事件写错了改事件而不是改余额。这个思路的价值在于余额永远是从事实推导出来的推导规则对了余额就不会跟事实打架。查询时直接SELECT这个视图就行接口层几乎不需要额外逻辑。演示场景数据量小视图完全够用。生产环境如果担心性能第5.4节给方案。4.4 完整演练一笔盘点、一笔销售的完整链路把代码串起来跑一遍。场景某门店期初盘点入库3件商品A再向顾客卖出1件收到120元。采购入库严格建模属于交换流程需要库存入库加应付或付款两个事件配对演示里先用期初盘点代替避免一上来就要处理三张表交换逻辑正好由接下来的销售来展现。conn sqlite3.connect(DB_PATH) init_db(conn) cur conn.cursor() # 准备最小基础数据 cur.execute(INSERT INTO economic_resource (resource_code, resource_name, unit, resource_type) VALUES (?,?,?,?), (SKU-A, 商品A, 件, asset)) cur.execute(INSERT INTO economic_resource (resource_code, resource_name, unit, resource_type) VALUES (?,?,?,?), (CASH, 人民币, 元, money)) cur.execute(INSERT INTO economic_agent (agent_code, agent_name, agent_type) VALUES (?,?,?), (STORE, 本门店, internal)) cur.execute(INSERT INTO economic_agent (agent_code, agent_name, agent_type) VALUES (?,?,?), (CUST-X, 顾客X, external)) conn.commit() # 1) 期初盘点入库商品A增加3件单事件增减流程 record_change(conn, 2025-01-10 09:00:00, increment, resource_id1, quantity3, agent_id1, counterparty_idNone, ref_noINIT-001, remark期初盘点入库商品A 3件) # 2) 销售收款商品A减少1件现金增加120元配对事件交换流程 record_exchange(conn, 2025-01-15 10:30:00, agent_id1, counterparty_id2, out_resource_id1, out_qty1, in_resource_id2, in_qty120, ref_noSO-001, remark顾客X购买商品A一件收款120元) # 3) 查询余额视图 for row in conn.execute(SELECT resource_code, resource_name, unit, balance_qty FROM v_resource_balance).fetchall(): print(row)输出应该是(SKU-A, 商品A, 件, 2) (CASH, 人民币, 元, 120)跑通这个流程后你能直接感受到REA和传统流水表的差别商品A的余额2件不是某个字段被UPDATE成2而是3件increment减1件decrement计算出来的现金120元同样由事件推导。如果想看销售那笔的完整链路按ref_noSO-001把事件表拉出来会看到一减一增两条事件通过related_event_id互相关联。这只是一条最简单的交换链路。把record_exchange换成产品加工场景out_resource_id写原料、in_resource_id写产成品、counterparty_id留空就自然变成转换流程只调record_change就是增减流程。三套流程共享同一张事件表不用为每种业务建单独的表这是REA比较舒服的地方。5. 实际踩坑记录事件粒度、防超卖与对账排查5.1 事件粒度别把订单状态当成经济事件我在原型上遇到的第一个坑是事件定义得太粗。一开始把客户下单也记成一条事件想着能快点出应收数据结果补货、拆单、改单一类状态变化把事件表搅得一团糟余额经常出现看着有货实际不能卖的怪状态。后来重新整理了事件粒度给自己定了两条规则。第一条事件必须对应一次真实的物理或资金动作出库、入库、收款、付款是事件下单、审批、确认是流程状态不是事件。第二条一张业务单据可以拆成多个事件但每个事件记录的是这件资源发生了什么变化不是这张单走到哪一步了。订单不是货订单状态的变化不会让库存少一件真正让库存少一件的是出库动作。注意下单、审批这类流程状态变化不是经济事件不要写进事件表。流程状态单独建状态表维护REA事件表只放真实发生的资源增减。这条规则看着简单实际设计接口时最容易违背。很多系统把订单创建订单取消也写进流水不是不行但会给后续查询埋雷。我的建议是事件表只放经济事件流程状态单独放状态表两者不要混存。5.2 负数余额与防超卖写事件前的资源校验REA事件表本身没有余额所以它也不会天然阻止减到负数。原型跑着跑着库存余额曾出现负数一查是并发下两笔销售同时扣了同一件货。这个坑和模型无关但REA架构下很多人会忽略因为会下意识认为余额表里有扣减校验。解决思路分两层。应用层写decrement事件之前先查一次可用余额余额不足直接拒绝。注意这层校验在并发下不完全可靠所以数据库层也要兜底。数据库层SQLite可以用事务加先查后写但并发场景建议给economic_resource表增加一个available_qty字段在同一个事务里UPDATE available_qty并校验受影响行数。PostgreSQL支持SELECT FOR UPDATE效果更稳。演示原型可以不做这么重但设计生产系统时防超卖校验必须落到数据库事务里。另外提醒一句如果校验逻辑写在应用服务里而服务是多实例部署的实例之间的并发问题是绕不开的一定要靠数据库层的锁或原子操作。5.3 对账排查怎么用REA快速定位差异REA模型最让我满意的就是对账体验。传统系统对不上账得在余额表、流水表、单据表之间来回猜。REA这里思路完全不同事件是唯一事实源余额只是投影差异只能来自事件本身。实际操作就三步。第一步把资源余额视图查出来跟业务方提供的实际库存或银行余额对比锁定对不上的资源。第二步拉出该资源下的全部事件按event_time排序一条条确认每条的ref_no和remark找到可疑的那条。第三步如果可疑事件确实写错了不要UPDATE它而是补一条反向事件把它冲掉再写正确事件。这样做的好处是事件表保留完整操作历史谁都能看出曾经有一条错的后来被冲掉了。注意事件写错之后不要UPDATE要补反向事件冲销。直接改事件会让历史失去可信度REA模型最大的价值就没了。这套排查逻辑特别适合给业务方写对账文档不用解释借贷关系直接按ref_no展示事件链路谁经手、什么时间、增减多少一目了然。我在原型里给每个事件写remark时都尽量留一句完整的人话三个月后再查账这句备注能帮你节省大量时间。5.4 高频资源性能优化从视图到物化投影视图查询在小数据量上没问题但生产环境里现金和库存这类高频资源可能每天产生几十万事件实时SUM会导致查询变慢。优化方案不是放弃REA而是把余额从每天算变成每次增量更新。我推荐的模式是投影表保持economic_event为唯一事实源另建resource_balance_projection表每次事件落库后在同一个事务里更新对应资源的余额字段。写事件和改投影绑定在一起投影只是缓存事件才是源头。这样读余额走投影表快查来源走事件表准。如果要进一步控制表膨胀可以按事件时间做分区或归档但归档前要确认不会破坏对账重放能力。性能优化和审计完整性的平衡点建议按业务场景单独评估不要因为追求性能就把事件历史删掉否则REA最有价值的那部分就没了。6. 从原型到生产rea模型的扩展思路与三条落地方案6.1 与事件溯源架构打通获得系统级审计能力如果你接触过事件溯源或者CQRS会发现REA的事件表跟事件溯源里的事件日志几乎是同一回事v_resource_balance视图就是典型的读模型投影。把这两套思想放一起等于给系统白送一套审计与重放能力所有业务状态变化都留痕任意历史时点的余额都可以通过重放事件重建。想做这块有三个准备动作。事件表增加event_id的全局递增约束保证顺序确定所有事件都带业务时间和落库时间两个字段避免时序错乱对外提供按ref_no或时间范围的重放接口方便排障。做到这三条审计需要的不可抵赖、可以重建就都有了。6.2 一次业务涉及多个资源时用事件组来聚合销售带折扣、生产用多个原料这类场景一次业务会涉及两条以上事件。比如商品减少1件、应收增加100元、折扣费用增加20元三条事件如果只用related_event_id两两配对会非常乱。更好的办法是引入事件组头表。实现上可以加一个exchange_group表字段包括group_id、ref_no、event_time、agent_id、counterparty_id、remark再给economic_event加一个group_id外键。所有同一次业务的事件都挂到同一个group_id下。查询时先按ref_no找到组再把组内所有事件一起拉出链路展示比两两配对更清晰。我在原型阶段没有做事件组因为演示只需要最简单的交换。但在真实系统里我强烈建议一开始就设计这个字段哪怕一时用不到。后补外键字段的成本比想象中高很多线上表加字段有锁表风险。6.3 老系统接入时的过渡方案与三条硬建议如果你的现状是已经跑了好几年的传统账务系统别急着推翻。比较稳妥的过渡方案是双写老系统继续按原逻辑记账REA事件表并行记录同一笔业务两边跑一个月后核对差异。核对通过后再把查询层逐步切到REA衍生出来的余额视图最后才停掉老系统的余额表维护逻辑。最后三条硬建议都是我实际踩过之后总结的。第一事件不可修改、不可删除错误靠反向事件冲销这是REA审计价值的根基。第二接口层对外仍返回余额和明细不要让调用方感知事件模型降低使用门槛。第三开工前先把资源清单列清楚哪些是钱、哪些是货、哪些是服务边界画不好后面配对所有事件都会跟着乱。把整个原型跑完我最大的感受是REA没有发明什么高深的数据结构它只是重新分配了事实和报表各自的职责。过去我把主要精力花在平衡报表现在把精力花在记录好每一个事件报表变成了一堆轻量查询。如果你手头也有一套数据都对但就是讲不清来龙去脉的系统不妨先拿一个业务流按这个思路重构试试。花一个周末写个原型比争论十次要不要换架构都来得实在。最后再分享一个小技巧动手之前先把资源清单和事件字典写在纸上字体多大无所谓写清楚比写美观重要这张纸就是你整个数据模型的第一版说明书。