拿到这个标题的时候我愣了一下——“rea”,三个字母,没有正文,没有关键词。如果这是某个项目在代码仓库里的代号,我会优先把它理解成 REA 模式,也就是 Resources-Events-Agents,资源-事件-代理者。这不是瞎猜,而是近几年做企业级应用时,我发现 REA 被讨论的频率越来越高,尤其是当团队开始认真思考“账怎么记才说得清”的时候。这篇文章不打算写成一个术语词典,而是想用一个我真正实践过的库存订单系统,把 REA 从概念到落地完整走一遍。如果你正在做领域建模、财务系统、供应链,或者任何需要把“业务事件”当作一等公民来设计的后端开发工作,这篇文章应该能给你一些能直接拿去用的东西。
1. 为什么我会把“rea”理解为 REA,而不是别的三个字母
1.1 三个字母在技术语境里的常见歧义
技术圈里三个字母的缩写太多了。REA 这个词,还有可能是 React 的截断、某个内部工具名、某家公司代号,甚至只是“re: something”的省略写法。我在没有更多上下文的时候会怎么处理?看场景。
如果标题背后挂着摘要描述和关键词,那我大概率会根据那些词来判断。但这次输入里这些信息全部为空,留给我的选择就只剩下一条:找一个解释力最强、能自然撑起一篇文章的方向。REA 模式的解释力恰恰足够强,因为它直接关联到一类几乎所有企业级系统都躲不开的问题——业务资源是怎么流转的。
所以这篇文章的定位,就是基于“rea 大概率指 REA 模式”这个假设来做完整的内容补全。如果发帖的人其实只想聊 React 或者某个叫 Rea 的开源项目,那我这篇文章至少也能提供一套完整的建模思路,不至于完全白看。
1.2 REA 语义模型解决的是哪一类“看不见的账”的问题
很多业务系统表面上看是订单管理、库存管理、资金管理,但拆到最底层,做的都是同一件事:记录经济资源在不同参与者之间的流动。传统系统最常见的做法是直接维护最终状态——库存表现在还剩多少、订单表现在是哪个状态、账户余额是多少。这套做法在业务简单的时候够用,但一旦进入对账、审计、追溯的场景,就很容易露馅。
举个例子。一张库存表里,某个 SKU 的可用数量从 10 变成了 8。系统只保留了最终数字 8,中间的过程被覆盖掉了。这时候如果有人问:是谁在什么时候因为哪张订单少了这 2 件?对应的钱到哪了?送货单号是多少?绝大多数传统表结构是回答不了的,因为你根本没有把这些信息留下来。
REA 模式解决的就是这一类问题。它把业务模型抽象成三个基本元素:资源(Resource)表示经济存量,比如库存、现金、应收账款;事件(Event)表示一次具体的经济行为,比如销售、入库、收款;代理者(Agent)表示参与这次行为的主体,比如客户、仓管、销售员。三者组合起来,就能回答“谁对什么资源做了什么,结果发生了什么”这套完整的问题。
这套模型最早出现在会计信息系统研究领域,上世纪八十年代初就被提出来了,学术沉淀几十年,不是这几年才冒出来的网红概念。我所在团队之所以开始真正使用 REA,是因为接手了一个仓库管理系统的重构项目,旧系统里有一堆“说不清楚”的数据,用了 REA 之后,很多问题才第一次有了明确答案。
1.3 我判断一套建模思路值不值得用的标准
纯理论的东西说再多也没用,我的判断标准一直很朴素:当系统遇到对账、审计、追溯这些场景时,这套模型能不能不靠补丁自然地回答出来。REA 是能做到的。
关键在于,REA 强制你放弃“只存最终状态”的思维,转而把每次业务行为当作不可变的记录来保存。这跟事件溯源的核心思想天然一致,所以即便你不上全套事件溯源架构,只要把 REA 的事件表建成,回溯能力就已经比绝大多数传统系统强一个量级。
2. 资源、事件、代理者:三个概念各自的分工与边界
2.1 资源:描述“有什么”,但重点是“别把表当资源”
资源在 REA 里指经济存量,比如库存数量、现金余额、应收账款、欠款额度。它描述的是一种“在某一个时刻有多少”的状态。很多人在第一次接触 REA 时,会直接把数据库表当作资源来建——库存表就是资源,订单表就是资源。这样想不算全错,但很容易掉进一个坑:把资源当成了一个可以被任意修改的普通表。
REA 真正强调的一点是:资源必须来源于事件。任何一项资源如果没有任何事件作为支撑,它的存在就是可疑的。库存数量是从入库事件、出库事件、报废事件里“算”出来的;现金余额是从收款事件、付款事件里“算”出来的。如果你允许任何服务直接 update 库存表,那这个数据就没人敢信了。
工程上比较稳妥的做法是:资源仍然可以有对应的表,但它的写入入口只能由事件处理器来控制。你可以在库存表上建约束,也可以在应用层强制走统一入口。重要的是让团队形成一种共识——资源状态本身不是事实的源头,事件才是。
2.2 事件:模型的真正心脏
事件是 REA 里最核心的部分。它记录一次具体的经济行为:销售、入库、收款、退货、盘点调整、报废。事件有三个特征值得特别注意。
第一,它一定是时点性的。事件必须发生在某个确定的时间点,而不是一段模糊的时间。即使业务上只有“月份”概念,系统里的记录也要精确到秒,因为后续的时序重建、审计追踪都依赖时间。
第二,它是不可变的。事件一旦落库,历史事实就不应该被修改。如果发现错误,正确做法不是去改旧事件,而是新增一个反向事件把它冲销掉。这一点做账的人应该很熟悉,但很多技术团队理解得不深。
第三,它自带方向。事件要么增加资源(increment),要么减少资源(decrement)。一次销售出库事件,对“库存”这个资源来说是减量;一次采购入库事件,对“库存”来说是增量。
事件在工程上落地时一般长这样:
event_id: 20250610001 event_type: STOCK_ISSUE occurred_at: 2025-06-10T10:23:11Z resource_deltas: - resource: INVENTORY_ITEM sku: SKU-10086 delta: -2 agents: - role: CUSTOMER agent_id: C-1001 - role: SALES_REP agent_id: U-203 - role: STOREKEEPER agent_id: U-107 reference: order_id: ORDER-20250610-002这个结构中,resources 和 agents 至少各有一个,事件才成立。没有资源的责任事件、没有代理者的系统事件,在 REA 语义里都需要被仔细审视。
2.3 代理者:给事件加上“责任主体”
代理者回答的是“谁干了这件事”。它可以是一个人、一个部门、一个系统,甚至可以是一个外部机构。在库存和订单场景里,代理者通常包括:内部代理者(仓管、销售员、财务专员)和外部代理者(客户、供应商、物流商)。
代理者设计最容易忽略的一点,是“内外分开”和“角色区分”。客户是一个外部代理者,当它出现在多个事件里时,我们需要能够识别出“同一个客户发生了哪些行为”,这是客户全生命周期的分析基础。而内部人员,比如仓管,通常不应该出现在销售收款事件里,因为业务上他没有参与。反过来,财务专员也不应该出现在库存出库事件里。
代理者除了参与事件,还往往存在一组“互换”关系:销售是销售员与客户之间的交互,采购是采购员与供应商之间的交互。这种“谁和谁之间发生”的信息,对权限设计、风控规则、组织绩效分析都非常重要。
2.4 一张表看懂三者关系
| 关系 | 含义 | 校验要点 |
|---|---|---|
| 资源—事件 | 事件改变了哪些资源的数量 | 每个事件至少有增量或减量之一 |
| 事件—代理者 | 谁参与了这次经济行为 | 每个事件至少有两个代理者或明确责任主体 |
| 资源—资源 | 一次经济交换涉及两种资源的互换 | 销售事件应同时关联库存减少与应收账款增加 |
| 资源—代理者 | 代理者拥有或控制某项资源 | 一般通过事件间接实现,不要直接建所有权关系 |
建过一段时间 REA 模型之后,你会发现它最大的好处是逼着你把“业务规则”转化成“数据结构约束”。哪些事件能发生、哪些代理者能参与、哪些资源能被改变,全都可以在建模阶段就明确下来。
3. 从借贷记账到 REA:换个视角后系统发生了什么变化
3.1 传统借贷分录隐藏了业务语义
会计领域早就有一套成熟的记账方法:复式记账。每一笔业务都拆成借贷分录,借一项,贷一项,两边永远平衡。这套系统的好处是准确、严谨、容错,但它有个问题——业务语义被“借贷”这个技术化动作压扁了。
举个例子。传统分录是这样写的:
借:应收账款 100 贷:销售收入 100这个分录里没有任何“客户”“商品”“仓库”的信息。业务上明明是客户付钱买走了商品,代码里却只有两个带方向的科目序号。财务系统看惯了还好,让一个后端开发直接看这套数据结构,完全看不懂业务逻辑。
更重要的是,借贷分录把“事件”直接压成了科目余额。每笔业务结束之后,明细被汇总进总账,过一段时间你只能看到余额,细节已经找不回来了。这个“汇总即丢失”的代价,在审计和风控场景里非常致命。
3.2 一次正常销售在 REA 眼里是三个事件的组合
REA 的处理方式完全不同。一次正常的销售行为,在 REA 里被拆成三个彼此独立但通过订单号关联的事件:
| 时间 | 事件 | 资源变化 | 代理者 |
|---|---|---|---|
| t1 | 订单创建 | 无资源变化,形成承诺 | 客户、销售员 |
| t2 | 库存出库 | 库存 -2 | 客户、销售员、仓管 |
| t3 | 收款 | 现金 +200,应收账款 0 | 客户、财务专员 |
订单创建在 REA 里属于承诺类事件(commitment),它本身没有资源增减,但为未来的经济事件提供了依据。库存出库是真正的经济事件,资源发生变化。收款事件则对应另一项资源的变化。
拆成多个事件之后,“这个订单走到哪一步了”就变成一个可计算的事实。订单状态不是靠一个 status 字段硬编出来的,而是通过查询已经发生的真实事件投影出来的。应收账款的余额也可以通过“销售事件减去收款事件”天然推演出来,不再需要额外维护一个容易被改错的字段。
3.3 为什么这种视角天然适配事件溯源与审计需求
REA 与事件溯源(Event Sourcing)之间有一种天然的亲和关系。REA 回答的是“哪些历史事实需要被保留”,事件溯源回答的是“如何把这些事实变成可重放的存储”。两者结合后,系统随时可以回答“任意时点的库存快照是多少”,因为它只需要重放到那个时点之前的所有事件,再做一次投影计算。
这对财务审计来说几乎是刚需。审计人员必问的一个问题就是“这段时间发生了什么”,而传统系统往往只能回答“现在是什么”。REA 模式下,这两个问题都能交给系统来回答。
我自己的实操体会是:你不需要为了 REA 而把整个系统都重构成事件溯源。只把 REA 事件表落地,让所有资源变化都通过事件记录,收益就已经非常可观了。真实项目里,往往只是这一张表 + 一套约束规则,就能让对账问题的排查时间从小时级降到分钟级。
4. 落地案例:用一个库存订单系统示范 REA 建模全过程
4.1 场景与边界:只做销售出库和回款,不做促销引擎
为了让案例足够清晰,我设定一个真实感比较强但边界明确的场景:某仓库的销售出库与回款流程。我们明确不做的事包括:促销活动、多币种结算、订单自动拆单、复杂的退货流程。边界越窄,越能看清 REA 的核心骨架。
领域对象设计如下:
- 资源:库存商品(InventoryItem)、现金(CashAccount)
- 事件:销售出库(StockIssue)、收现(CashReceipt)
- 代理者:客户(Customer)、销售员(SalesRep)、仓管(Storekeeper)、财务(FinanceUser)
- 聚合根:Order(订单)
这里订单是交易的载体,它在 REA 模型里负责把多个事件串起来:先有承诺(订单创建),后有经济事件(出库、收款)。
4.2 建表结构与资源模型:给数据加上增量/减量约束
先给出最基础的表结构。事件表是这个模型的核心,我建议用一张通用事件表来承载所有 REA 事件,同时保留类型字段和明细 JSON,方便后续追溯。
CREATE TABLE inventory_item ( sku VARCHAR(40) PRIMARY KEY, name VARCHAR(120) NOT NULL, available_qty BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL DEFAULT 0 ); CREATE TABLE rea_event ( event_id BIGSERIAL PRIMARY KEY, event_type VARCHAR(40) NOT NULL, occurred_at TIMESTAMP NOT NULL, order_id VARCHAR(64), sku VARCHAR(40), qty BIGINT NOT NULL DEFAULT 0, resource_direction CHAR(3) CHECK (resource_direction IN ('IN', 'OUT', 'N/A')), agent_customer_id VARCHAR(40), agent_sales_rep_id VARCHAR(40), agent_storekeeper_id VARCHAR(40), agent_finance_id VARCHAR(40), payload JSONB NOT NULL DEFAULT '{}' ); CREATE INDEX idx_rea_event_order_id ON rea_event(order_id); CREATE INDEX idx_rea_event_occurred_at ON rea_event(occurred_at);这张表的设计有几个细节值得说明。第一,resource_direction是检查约束,强制每个事件必须有明确方向。第二,sky和qty直接放在事件表里,是因为大多数情况下我们只需要按 SKU 纬度查询事件。如果你有非常复杂的资源类型,可以把资源变更抽一张子表,但在这个案例里保持简单更合适。
库存表本身就不应该允许随意 update。你可以在应用层做约束,也可以在数据库里加触发器保证:任何对available_qty的修改,必须同时存在对应的rea_event记录。我在实际项目里更推荐在应用层做,因为触发器很容易把问题藏起来,应用层校验的错误信息可以写得对开发者友好得多。
4.3 聚合设计与领域代码:事务边界放在事件产生的地方
接下来写领域模型。库存聚合的核心方法是扣减库存,它在扣减时必须校验可用数量并且记录版本号,防止并发更新把库存扣成负数。
public class InventoryItem { private final String sku; private long availableQty; private long version; public void decrease(long qty) { if (qty <= 0) { throw new IllegalArgumentException("扣减数量必须为正数"); } if (qty > availableQty) { throw new InsufficientStockException(sku, availableQty, qty); } this.availableQty -= qty; this.version++; } }订单聚合则维护当前推进到哪一步,并且允许追加事件记录。这里的事件记录先保留在内存中,等到应用服务统一落库。
public class Order { private final String orderId; private final String customerId; private final String salesRepId; private final List<ReaEvent> pendingEvents = new ArrayList<>(); public void issueStock(InventoryItem item, long qty, String storekeeperId, LocalDateTime now) { item.decrease(qty); ReaEvent event = ReaEvent.createStockIssue( orderId, item.getSku(), qty, customerId, salesRepId, storekeeperId, now); pendingEvents.add(event); } public void receiveCash(BigDecimal amount, String financeId, LocalDateTime now) { ReaEvent event = ReaEvent.createCashReceipt( orderId, amount, customerId, financeId, now); pendingEvents.add(event); } }把事务边界放在“事件被产生”的地方,是 REA 落地最关键的架构决策。库存扣减和订单推进必须在一个数据库事务里完成,否则就会出现“订单显示已出库,但库存没扣”这种灾难性问题。分布式场景下可以引入事务消息,但如果系统还没有到那个复杂度,老老实实吃掉一个本地事务是最稳的。
4.4 应用服务编排:把订单状态推进和库存变动放进同一事务
应用服务的职责是把领域对象组织起来,完成一次完整的业务操作。代码逻辑如下:
@Service public class SaleOrderAppService { private final OrderRepository orderRepository; private final InventoryItemRepository inventoryRepository; private final ReaEventRepository eventRepository; private final EventPublisher eventPublisher; @Transactional public void issueStock(IssueStockCommand cmd) { Order order = orderRepository.find(cmd.orderId()); InventoryItem item = inventoryRepository.findBySku(cmd.sku()); order.issueStock(item, cmd.qty(), cmd.storekeeperId(), LocalDateTime.now()); inventoryRepository.save(item); orderRepository.save(order); eventRepository.saveAll(order.getPendingEvents()); eventPublisher.publish(new StockIssuedIntegrationEvent( cmd.orderId(), cmd.sku(), cmd.qty(), LocalDateTime.now())); } }注意这里的@Transactional是全局一致性兜底。先扣库存、再推进订单状态、再落事件表,最后发布一个集成事件给下游系统。这个顺序很重要:事件表必须在发布消息之前落库,否则消息驱动的消费者一旦先收到事件并尝试关联数据,就会因为数据还没写入而报错。
关于消息发布,我最开始用的是事务内直接发消息,后来在项目里发现靠外发事务消息解决不了“本地事务回滚后消息依然发出去”的问题。实践下来比较稳的组合是事务表 + 本地消息表,也就是事件先和业务数据一起落库,再由一个定时任务或者事务消息机制把事件表里未发送的记录推给消息队列。这块稍微复杂,但对于真实系统来说值得投入。
4.5 正常流程走查:从下单到回款的事件时间线
把整个流程走一遍,你会看到 REA 事件是如何串成一条完整时间线的:
| 时间 | 事件类型 | 资源变化 | 关键代理者 | 业务含义 |
|---|---|---|---|---|
| 09:00 | ORDER_CREATED | N/A | 客户、销售员 | 产生购买承诺 |
| 09:15 | STOCK_ISSUE | 库存 -2,方向 OUT | 客户、销售员、仓管 | 商品出库 |
| 09:20 | CASH_RECEIPT | 现金 +200 | 客户、财务 | 客户付款 |
| 09:25 | ORDER_CLOSED | N/A | 系统 | 订单完成,无未完成事件 |
查询任意时点的库存方式也很简单:找到这个时间点之前所有STOCK_ISSUE事件和所有入库事件,把增减量汇总。如果数据量不大,直接在 SQL 里算也可以;如果数据量大,可以每天凌晨构建一份前一日结束后的快照,查询时从快照开始往前重放当天事件。这就是 REA 天然带来的可重放能力。
5. 踩过坑之后:REA 实践中常见的三个认知偏差与我的修正
5.1 别把 REA 直接当数据库建模工具,它是领域语义工具
第一个坑,也是我最开始踩的坑:把 REA 当成数据库建模工具,直接照搬概念到表结构。资源建一张表、事件建一张表、代理者建一张表,然后所有业务查询都变成三表 join 甚至四表 join。结果某项目 X 的库存查询性能直接崩了,一个列表页拖了好几秒。
这个教训很贵:REA 是领域语义工具,不是数据库范式。数据库层面,该做投影就做投影,该冗余就冗余。真实系统中,我会同时维护两种数据:事件表负责记录历史事实,状态表负责支撑日常查询。状态表可以由事件处理流程更新,但只有事件处理器能写它。平时查询走状态表,对账审计走事件表。这样既保留了 REA 的语义优势,又不至于把查询都拖累。
5.2 事件不可变不代表系统不能修正:冲销事件是常态
第二个坑来自团队对“不可变”三个字的误解。成员认为事件既然不可变,那写错了就只能改旧记录,于是真的有人直接去 update 了事件表,把错误的数量改掉,结果审计口径彻底乱了。
REA 的正确做法是:错误发生之后,新增一个反向事件冲销旧的错误事件,再新增一个正确事件。举个例子,如果实际出库是 3 件,但录成了 2 件,系统不需要把那条 2 件的记录改成 3 件,而是新增一条数量为 -2 的冲销事件,再新增一条数量为 3 的正确事件。这样最终净变化是 -3,而且每一步历史都清晰可查。
工程上的关键是给冲销事件挂一个reverse_of_event_id字段,指向被冲销的原始事件。查询的时候既能展开全部历史,也可以通过reverse_of_event_id过滤掉已经被冲销的事件,拿到“有效事件流”。
5.3 代理者与资源的关系要克制,所有关联都要有业务凭据
第三个坑是过度建模。建模的时候觉得“以后可能会用到”,于是在事件上挂了一堆关联:把仓管和设备也关联了,把客户和销售员做了历史关系表,把所有代理者跟所有可能相关的资源都建了关联。结果半年之后发现,绝大多数关联压根没被查询过,反而让事件插入变慢、模型变得极度难懂。
我后来给自己定了一条规矩:建代理者与资源的关联之前,先问一句“有没有一个业务规则必须用它”。如果只是“以后分析可能会用”,那就不建;等真实业务需求来了再补建,成本也完全可控。克制的 REA 模型是这样的:销售事件只关联客户、销售员、商品,出库事件只关联仓管、订单、商品,收款事件只关联客户、财务专员、金额。多一个冗余关联都是负债。
最后再说一点我个人的实际体会。REA 这三个字母确实经常被当作一本正经的学术缩写,但它落地时真的一点都不玄。我越来越发现,开发团队对业务的共同语言,就藏在这些最基本的语义里——资源是什么,谁负责,事件留下了什么。把它们讨论清楚了,后面写代码、做对账、应付审计都会顺很多。如果你刚接手一个库存、订单、财务之类的老系统,别急着重构,先把现有表里的核心字段提炼成 REA 三件套,往往能快速暴露出一堆隐藏问题。剩下的,就是不断用真实事件验证你的模型。