从结算需求出发:基于库存日快照与分区的结算报表的Java实践

从结算需求出发:我如何设计一套「库存日快照 + 分区」体系

本文记录了一次完整的库存结算报表能力建设:从业务需求出发,到技术方案设计与落地实现,再对比传统方案的优劣。

涉及的核心组件包括:EtonInventorySnapshotInventorySnapshotJobInventorySnapshotPartitionManageJobDataBackupStatus 等。


一、业务需求:结算报表到底要什么?

结算报表看起来只是几张报表,但背后有几个关键诉求:

  • 要看"历史那一天"的库存,而不是"现在"的库存

    • 财务要做月结/周期结算,需要知道「结算日 T 当时」的库存数量、库存金额。
    • 运营要看某一时间点的库存结构(SKU、仓库、款式等级、门店保有库存等)。
  • 要可复现、可对账

    • N 天后再跑这一天的结算报表,结果应该一致,方便和总账对比、和上下游系统对账。
    • 一旦发现差异,需要能回溯到当时的库存快照,判断是结算逻辑问题,还是后续有人改了数据。
  • 要可承载大数据量查询

    • 库存数据量本身就不小,再乘以"天数×快照",很容易到千万甚至上亿级别。
    • 不能因为结算报表,把线上数据库拖垮。
简单说:

需求本质 :在任意结算日 ,都能快速、稳定、可复现地拿到「当时的库存状态」,用来做结算与分析。


二、现状问题:直接查实时库存为什么不行?

最直观的做法:结算时直接查线上 inventory 表。

  • 问题 1:库存是实时变化的

    • inventory 一直在被订单出入库、调拨、盘点、纠错等操作修改。
    • 结算任务往往在 T+1 或 T+N 跑,这时的数据已经不是结算日的状态。
    • 结算报表结果会随着时间改变 ------ 无法重现历史场景
  • 问题 2:用库存流水「回放」复杂度极高

    • 理论上:期初库存 + 所有出入库流水 = 任意日库存。
    • 实际上:
      • 流水量巨大,计算开销高;
      • 需要处理各种异常场景(补单、作废、手工调整);
      • 逻辑极其复杂,难以保证绝对正确,也难以排查问题。
  • 问题 3:大表查询压力巨大

    • inventory 作为核心业务表,既承担在线读写,又要扛结算大查询 + 聚合。
    • 在高峰期,很容易出现慢 SQL,影响主业务。

结论:直接用实时库存做结算,不满足"历史可回溯"和"性能可控"两个基本要求。


三、目标与思路:给库存加一层「日快照」

结合以上问题,我给自己的目标是:

  • 业务侧

    • 能在 T+N 任何时间,准确还原「结算日 T」的库存状态。
    • 结算逻辑尽量简单,不要强依赖复杂流水回放。
  • 技术侧

    • 让结算查询不要压垮线上库存表。
    • 快照数据量增长可控,历史清理简单高效。

思路很自然地落到:

引入一张专门用于结算的「库存快照表」,每天"拍一张照片",并做按日分区管理。


四、设计方案:库存快照 + 分区 + 元数据管理

这一节按几个关键组件拆开讲。

1. 库存快照表:EtonInventorySnapshot

定位

  • 保存某一时刻的库存全量状态,是结算报表的基表

关键字段设计:

  • 与源库存关联

    • inventoryId:对应原始 inventory 表的 ID。
    • entityId / etonSkuCode:SKU 编码。
    • warehouseCode / warehouseName:仓库维度。
    • skuSizeitemNamestyleLevel 等:方便报表维度分析。
  • 数量类字段

    • realQuantity(真实库存)
    • preorderQuantity(在途)
    • withholdQuantity(锁定)
    • userHoldQuantity(用户锁定)
    • occupyQuantity(占用)
    • safeQuantity(安全库存)
    • virtualQuantity(虚拟库存)
    • maintainQuantity / maintainQuantityRatio(门店保有库存及比例)
    • warningQty(预警库存)
  • 快照时间

    • snapshotTime:标记这条记录属于哪一次快照(哪一天/哪一时间点)。
  • 辅助计算方法

    • 可用库存(不为负):

      java 复制代码
      public Long ableQuantity() {
          if (occupyQuantity < 0) {
              occupyQuantity = 0L;
          }
          return Math.max(0L,
              realQuantity
            + Optional.ofNullable(preorderQuantity).orElse(0L)
            - Optional.ofNullable(withholdQuantity).orElse(0L)
            - Optional.ofNullable(occupyQuantity).orElse(0L)
            - Optional.ofNullable(safeQuantity).orElse(0L)
          );
      }
    • 可用库存(允许负数,用于风险分析):

      java 复制代码
      public Long ableNegativeQty() {
          return Optional.ofNullable(realQuantity).orElse(0L)
               + Optional.ofNullable(preorderQuantity).orElse(0L)
               - Optional.ofNullable(withholdQuantity).orElse(0L)
               - Optional.ofNullable(occupyQuantity).orElse(0L)
               - Optional.ofNullable(safeQuantity).orElse(0L);
      }

对结算的意义

  • 结算报表只要按 snapshotTime 过滤,就能拿到那一天"冻结"的库存视图
  • 不再依赖实时库存或者复杂流水回放。

2. 快照生成任务:InventorySnapshotJob

职责 :每天定时将 inventory 表生成一份快照,写入 inventory_snapshot

设计要点:

  • 分布式锁,避免多实例重复执行

    java 复制代码
    AutoReleaseLock lock = inventorySnapshotLockCache.tryLock(
        "inventory-snapshot-job", 300, TimeUnit.SECONDS);
    if (lock == null) {
        // 其他实例已经在跑,当前实例跳过
        return;
    }
  • 使用一条 SQL 全量复制(性能优先)

    java 复制代码
    private void createInventorySnapshotBySQL() {
        Date snapshotTime = new Date();
        int count = etonInventorySnapshotDao.batchInsertAllFromInventory(snapshotTime);
    }
  • DAO 中的核心 SQL(示意)

    sql 复制代码
    INSERT INTO inventory_snapshot (
        unique_code, warehouse_code, real_quantity, safe_quantity, preorder_quantity,
        withhold_quantity, occupy_quantity, virtual_quantity, status,
        entity_id, entity_type,
        created_at, updated_at, item_name, version, warehouse_type, able_quantity,
        warehouse_name, sku_size, user_hold_quantity, eton_sku_code, warning_qty,
        style_level, maintain_quantity, maintain_quantity_ratio,
        snapshot_time, inventory_id
    )
    SELECT
        unique_code, warehouse_code, real_quantity, safe_quantity, preorder_quantity,
        withhold_quantity, occupy_quantity, virtual_quantity, status,
        entity_id, entity_type,
        NOW(), NOW(), item_name, version, warehouse_type, able_quantity,
        warehouse_name, sku_size, user_hold_quantity, eton_sku_code, warning_qty,
        style_level, maintain_quantity, maintain_quantity_ratio,
        #{snapshotTime}, id
    FROM inventory;

为什么用全量 SQL 而不是 Java 循环插?

  • 数据全部在数据库内部流转,避免 Java 拉取全表数据到内存;
  • 一条 SQL 利用 MySQL 自身的执行引擎与批处理能力,性能更稳定;
  • 简化业务逻辑:不用在代码里关心分页/重试/批次拆分等复杂问题。

3. 分区管理任务:InventorySnapshotPartitionManageJob

inventory_snapshot 每天都在长胖,再不做分区和归档,很快就会变成"超级大表"。

这个任务的职责:

  1. 提前创建未来 30 天的日分区

    确保每天的快照写入时,对应的分区已经准备好。

  2. 删除 30 天前的空分区

    防止历史分区无限堆积,控制表结构规模。

  3. 将分区操作记录到元数据表 data_backup_status

    支持可观测、可审计、可重试。

3.1 分区创建逻辑
  • 计算未来 30 天的日期列表 next30Days
  • data_backup_status 查出表为 inventory_snapshot、类型为 PARTITION、状态为 CREATE_COMPLETED 且日期大于等于今天的记录,得到 existingPartitions
  • 通过集合差集,得到还需要创建的 missingDates
  • 对每个 dateStr
    • 分区名:pYYYYMMDD

    • 边界值:第二天 boundaryDate = date + 1 日

    • 调用 snapshotWriteService.addPartition(boundaryDate, partitionName) 执行:

      sql 复制代码
      ALTER TABLE inventory_snapshot
      ADD PARTITION (
        PARTITION p20251101 VALUES LESS THAN (TO_DAYS('2025-11-02'))
      );
    • 将这次创建记录写入 data_backup_status,状态为 CREATE_COMPLETED

3.2 分区删除逻辑
  • data_backup_status 查出表为 inventory_snapshot、类型为 PARTITIONtransfer_date < 当前日期 - 30天 且状态为 CREATE_COMPLETED 的记录,得到待处理列表 oldPartitions
  • 对每条 po
    • 取出 partitionName = po.getExtraOne()
    • 调用 snapshotWriteService.isExistDataByPartition(partitionName)
      • 本质是对该分区执行 SELECT COUNT(0) FROM inventory_snapshot PARTITION (p20250101)
    • 只有在 count=0(空分区)时:
      • 执行 DROP PARTITION p20250101
      • 更新 po.statusDROP_COMPLETED,写回 data_backup_status

安全原则:只删除"空分区",防止误删仍在被结算/分析使用的历史数据。


4. 分区与备份状态表:DataBackupStatus

维护分区时,有两类信息:

  • 数据库本身的分区状态:SHOW CREATE TABLE / INFORMATION_SCHEMA 中可以查到;
  • 但我们更需要一个业务级别的操作日志,记录是"我们系统主动建/删了哪些分区"。

DataBackupStatus 就是这个"元数据表":

  • 字段设计:

    • tableName:表名,如 "inventory_snapshot"
    • type"PARTITION" / "TRANSFER" 等;
    • transferDate:分区对应的逻辑日期;
    • statusCREATE_COMPLETED / DROP_COMPLETED
    • extraOne:分区名 pYYYMMDD
    • extraTwo:分区边界 YYYY-MM-DD
    • 时间戳等。
  • 读写服务:DataBackupStatusReadService / DataBackupStatusWriteService + DAO + Mapper:

    • 按日期区间查询已有/过期分区记录;
    • 批量插入新建分区记录;
    • 更新删除状态。

好处

  • 分区管理 Job 不依赖数据库系统表,逻辑更简单稳定;
  • 可以清晰看到每一天分区的操作历史(何时创建、何时删除);
  • 出现异常时(比如某天建分区失败),可以通过 data_backup_status 快速排查并补偿。

五、读服务与结算报表:如何消费这些快照?

在服务层,我封装了:

  • EtonInventorySnapshotReadService

    • snapshotTime 时间段查询;
    • 按 SKU + 仓库查最新快照;
    • 支持分页与列表查询。
  • EtonInventorySnapshotWriteService

    • 批量创建快照(备用方案);
    • 手工删除指定日期之前的数据(补充手段);
    • 管理分区(addPartition / dropPartition / isExistDataByPartition)。

结算报表接口侧,只需要:

  • 根据结算日 T,组装查询条件:

    java 复制代码
    Map<String, Object> params = new HashMap<>();
    params.put("snapshotTimeStart", T 00:00:00);
    params.put("snapshotTimeEnd",   T 23:59:59);
    // 其他过滤条件:仓库、品牌、款式等级、SKU 列表等
  • 调用 paging(params) / queryList(params),然后在此基础上做金额计算和聚合。

报表开发同学基本"把快照当成一张普通历史表用",完全不需要关心背后的快照生成与分区管理细节。


六、为什么这样设计?与传统方案对比

1. 方案 A:直接查实时库存表 inventory
  • 问题
    • 无法还原历史状态;
    • 结算结果随着时间推移会变化;
    • 对线上主表压力大,易引发性能问题;
    • 出问题难排查(到底是实时数据变了,还是结算逻辑错了)。
2. 方案 B:按库存流水「回放」还原库存
  • 优点

    • 理论上可以省掉快照表,直接从日志还原任意时间点库存。
  • 缺点

    • 流水量大时,回放计算开销巨大,对数据库和服务端都是挑战;
    • 逻辑极为复杂(要处理补单、撤单、人工调整、异常修复等各种场景),一旦有 bug,整个账目都难以解释;
    • 报表查询每次都要重算,用户体验差。
3. 方案 C:当前方案(每日全量快照 + 日分区 + 元数据管理)
  • 优点

    • 业务侧

      • 任意结算日 T 都有一份对应日的"冻结快照",结算结果可复现、可对账;
      • 结算逻辑简单:按 snapshot_time 过滤 + 做聚合即可。
    • 性能侧

      • 快照生成:一条 INSERT INTO ... SELECT ...,利用 DB 内部能力,性能稳定;
      • 分区查询:MySQL 自动做分区裁剪,只扫几个相关日期的分区;
      • 历史清理:DROP PARTITION 替代大批量 DELETE,秒级完成。
    • 运维侧

      • 通过 data_backup_status 管理分区元数据,操作可追踪;
      • Job 自动补建未来分区、自动清理过期空分区,不需要 DBA 人肉维护;
      • 方案可平滑扩展(将来需要更长保留期,只需调整保留天数和分区策略)。
  • 权衡点

    • 需要存储额外的快照数据(每天一份),但在合理的保留策略(30 天)下完全可控;
    • DDL 操作(建/删分区)需要谨慎控制护栏,因此才引入 data_backup_status 做元数据管理。

综合来看,这个方案在 实现复杂度、运行成本、业务价值 之间,取得了一个比较好的平衡。


七、个人收获与可复用点

这次设计实现,对我来说有几个比较有价值的经验,可以在其他项目中复用:

  • 从业务问题倒推技术方案

    一开始就明确"结算要的是历史某天的状态",而不是"一张大而全的超级 SQL 报表",有助于避免在错误方向上堆技术方案。

  • 用"快照 + 分区"模式处理时间序列历史数据

    • 非常适合类似:库存、账户余额、KPI 指标等"按日冻结"的场景;
    • 日志型数据(流水)负责"事件还原可能性",快照型数据负责"高效查询与结算"。
  • 用一张元数据表,统一管理结构性操作

    • 分区创建/删除、数据迁移、归档等操作,都可以通过类似 data_backup_status 的表来驱动;
    • Job 就不需要直接跟系统表打交道,逻辑更可控,也更易观测。

如果你后续打算把这篇文章拆成多篇(比如一篇专讲"库存快照",一篇专讲"MySQL 分区实践"),我也可以帮你再按主题拆分和精简。

相关推荐
Yungoal2 小时前
SQL基础0
数据库·sql
韩立学长2 小时前
基于Springboot的商品库存管理系统369jr3t9(程序、源码、数据库、调试部署方案及开发环境)系统界面展示及获取方式置于文档末尾,可供参考。
java·数据库·spring boot·后端
长安11082 小时前
mysql(C++)----常用的sql命令
java·sql·mysql
scofield_gyb2 小时前
MySQL 批量插入详解:快速提升大数据导入效率的实战方法
大数据·数据库·mysql
醇氧2 小时前
Spring AI Alibaba 学习(一) 集成阿里云百炼大模型应用
java·学习·spring
I_LPL2 小时前
day52 代码随想录算法训练营 图论专题5
java·算法·图论·并查集
不过普通话一乙不改名2 小时前
七:EXPLAIN 深度解析与 SQL 优化实战指南
数据库·sql
y = xⁿ2 小时前
【Java八股锁机制的认识】synchronized和reentrantlock区分,锁升级机制
java·开发语言·后端