zhaojie.tech/me
项目实战复盘 2026-10-04T00:00:00.000Z

库存不是数,是流水:不可变事件账本的设计

做零售 ERP 的人迟早会面对一次”账对不上”:库存表显示还有 3 件,货架上没有;或者月底盘点,成本金额和库存变动对不出。当年我靠 UPDATE quantity = quantity - 1 记库存,一旦出现并发覆盖、重复提交或者反审核回退,那个数字就成了黑盒——你知道它错了,但不知道它是怎么错的。这次重写库存引擎时,我把底层模型换了:库存不是一行数字,是一条条不可变的事件流水,数字只是流水的聚合结果。

账本结构:只追加,不修改

引擎里所有库存写操作——入库、出库、锁定、冻结、消耗——最终都落到 ss_inventory_event 表,追加一条记录,含数量增减、成本变动、业务单据溯源(businessType/businessId)、操作人和时间。表上从不 UPDATE 业务字段,唯一的 UPDATE 是红冲配对标记(下文展开)。

防重放靠 event_id 的唯一约束。它是纯业务输入拼出来的确定性 ID:

// EventIdBuilder
{eventSource}:{businessId}:{eventType}:{murmur3_128截10位hex(inventoryKey)}:{sequence}

hash 截断只承担”同一业务单 + 同一事件类型”局部空间内的区分度,这个量级下 40bit 的碰撞概率在 1e-9 以下,全局唯一性由唯一索引兜底。

同样的单据、同样的库存键、同样的行号,算出来的 event_id 必然相同。于是重复执行整条 Pipeline(网络重试、用户双击、上层补偿)会生成同一个 ID,被 uk_event_id 唯一约束直接拦下——幂等不依赖任何”先查再判”的逻辑,落在存储层。库存键 hash 用 MurmurHash3 128 位截 10 个 hex(40 bit),又保住了 ID 可读性。顺带修过一个真 bug:早期版本事件表和单据明细表各自生成 event_id,一边是业务键、一边是随机 UUID,两张表永不相等,对账链路断裂——修复就是把生成权收敛到同一个 EventIdBuilder。

有了完整流水,“任意时点的库存”就有了另一个定义:按序重放事件到那个时点。快照表 ss_inventory_snapshot 是常驻的聚合缓存(配合 CAS 更新),账本才是事实来源,两者可互相对账。

红冲:同事务写反向事件,取代启发式配对

反审核(销售单冲回库存)最初的实现是事后 findByBusinessId 找”最后一条”事件去配对——典型启发式,单据并发反审两次就会配错。重写后改成 executeReversal,在同一事务内七步完成:

@Transactional(propagation = Propagation.MANDATORY)
public ExecutionResult executeReversal(String originalEventId, InventoryLineItem item) {
    // 1. 查原事件:不存在 / 自身是反向事件 / 已被反向 → 抛异常
    // 2. 查当前快照
    // 3. 构建反向命令(OUTBOUND↔入库、INBOUND↔出库,businessType + "_REVERSAL")
    // 4. CAS 反向更新快照(冲突由重试模板退避重试)
    // 5. INSERT 反向事件:reversal_flag=1,reversal_of_event_id 写入时即设
    // 6. UPDATE 原事件 SET reversed_by_event_id = 反向eventId
    // 7. 事务提交后发布 StockChangedEvent
}

配对关系在 INSERT 那一刻就写死,不再有”事后猜”。并发反审的拦截在第 6 步的原事件更新上:UPDATE ... SET reversed_by_event_id = #{rid} WHERE event_id = ? AND reversed_by_event_id IS NULL——第二个事务 rows=0 即抛”已被反审”回滚,防线始终在存储层;开头的非空检查只是省一次写的快路径。反向事件的业务时间沿用原事件(occurredAt 取原值),统计口径上净效果为零——如果取当前时间,红冲会污染”当天”的报表。事件本身依然只追加:所谓”修改”,是再写一条方向相反的记录并互相指向,原事件内容不动。

批量与缓冲:三策略 + 200 条分片

批量记账不是一个语义。采购入库 50 行要么全成要么全无;调拨失败一半需要知道哪些成功;盘点过账希望遇错即停。引擎显式提供三种策略:ALL_OR_NOTHING(同一事务逐条 CAS,任一失败整体回滚)、BEST_EFFORT(每条独立事务,失败跳过并上报,单次上限 50 条)、FAIL_FAST(首错即停)。

批量路径还叠了一层事件缓冲:ThreadLocal<List<InventoryEventEntity>> EVENT_BUFFER,执行期间事件先进缓冲,退出时按 200 条一片批量 insertBatch——切片上限是对着 MySQL max_allowed_packet 默认 4MB 定的,避免大单据一次插爆包。FAIL_FAST 失败时不 flush,外层事务回滚,半成品事件随之丢弃。

CAS 本体是单行乐观锁:UPDATE ... SET quantity = quantity - #{qty}, version = version + 1 WHERE id = #{id} AND version = #{version} AND quantity >= #{qty} AND sellable_quantity >= #{qty} AND status = 1。rows == 0 视为版本冲突,交给重试模板按 20→40→80→160→320ms 指数退避加随机抖动重试,最多 5 次;可售口径条件直接写进 SQL,Java 侧校验只是提前给出可诊断的报错,防线始终在数据库。

事件发布:afterCommit 才发

快照和事件落库后,StockChangedEvent 通过 TransactionSynchronization.afterCommit 发布——事务没提交就发事件,下游(统计、财务台账)会收到一个可能被回滚的”事实”。注册同步失败或无活跃事务时兜底同步发布并打 warn,宁可下游多对账一次,不让台账静默漏行。

一句原则

把”当前值”当作流水的派生物而非事实本身:事实只追加、幂等键防重放、冲销靠反向事件——账本对了,数字只是重放的结果。

← 全部文章 赵杰 · me.zhaojie.tech