从结算需求出发:我如何设计一套「库存日快照 + 分区」体系
本文记录了一次完整的库存结算报表能力建设:从业务需求出发,到技术方案设计与落地实现,再对比传统方案的优劣。
涉及的核心组件包括:
EtonInventorySnapshot、InventorySnapshotJob、InventorySnapshotPartitionManageJob、DataBackupStatus等。
一、业务需求:结算报表到底要什么?
结算报表看起来只是几张报表,但背后有几个关键诉求:
-
要看"历史那一天"的库存,而不是"现在"的库存
- 财务要做月结/周期结算,需要知道「结算日 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:仓库维度。skuSize、itemName、styleLevel等:方便报表维度分析。
-
数量类字段
realQuantity(真实库存)preorderQuantity(在途)withholdQuantity(锁定)userHoldQuantity(用户锁定)occupyQuantity(占用)safeQuantity(安全库存)virtualQuantity(虚拟库存)maintainQuantity/maintainQuantityRatio(门店保有库存及比例)warningQty(预警库存)
-
快照时间
snapshotTime:标记这条记录属于哪一次快照(哪一天/哪一时间点)。
-
辅助计算方法
-
可用库存(不为负):
javapublic 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) ); } -
可用库存(允许负数,用于风险分析):
javapublic 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。
设计要点:
-
分布式锁,避免多实例重复执行
javaAutoReleaseLock lock = inventorySnapshotLockCache.tryLock( "inventory-snapshot-job", 300, TimeUnit.SECONDS); if (lock == null) { // 其他实例已经在跑,当前实例跳过 return; } -
使用一条 SQL 全量复制(性能优先)
javaprivate void createInventorySnapshotBySQL() { Date snapshotTime = new Date(); int count = etonInventorySnapshotDao.batchInsertAllFromInventory(snapshotTime); } -
DAO 中的核心 SQL(示意)
sqlINSERT 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 每天都在长胖,再不做分区和归档,很快就会变成"超级大表"。
这个任务的职责:
-
提前创建未来 30 天的日分区
确保每天的快照写入时,对应的分区已经准备好。
-
删除 30 天前的空分区
防止历史分区无限堆积,控制表结构规模。
-
将分区操作记录到元数据表
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)执行:sqlALTER 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、类型为PARTITION、transfer_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.status为DROP_COMPLETED,写回data_backup_status。
- 执行
- 取出
安全原则:只删除"空分区",防止误删仍在被结算/分析使用的历史数据。
4. 分区与备份状态表:DataBackupStatus
维护分区时,有两类信息:
- 数据库本身的分区状态:
SHOW CREATE TABLE/INFORMATION_SCHEMA中可以查到; - 但我们更需要一个业务级别的操作日志,记录是"我们系统主动建/删了哪些分区"。
DataBackupStatus 就是这个"元数据表":
-
字段设计:
tableName:表名,如"inventory_snapshot";type:"PARTITION"/"TRANSFER"等;transferDate:分区对应的逻辑日期;status:CREATE_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,组装查询条件:
javaMap<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 分区实践"),我也可以帮你再按主题拆分和精简。