1. 库存日快照与分区结算报表的业务背景
在零售、电商和供应链管理系统中,库存结算是财务对账的核心环节。传统做法是直接扫描实时库存表进行计算,但这种方法存在三个致命缺陷:
- 数据一致性风险:实时库存表在结算期间可能被其他业务(如采购入库、销售出库)修改,导致"边结算边变动"的问题
- 性能瓶颈:千万级库存数据全表扫描时,锁竞争和IO压力会拖慢整个系统
- 历史追溯困难:无法回答"某日23:59分的实际库存是多少"这类审计常见问题
我们采用的解决方案组合是:
- 库存日快照:每日业务低峰期(如凌晨2点)生成全量库存静态镜像
- 分区表:按自然月/周划分数据存储区域(如
p_202301、p_202302) - 增量合并:结算时只需处理当日增量变动+最新快照
这种架构下,月末结算的查询范围从全表3000万行缩减到当月分区约100万行,结合快照表的静态特性,查询时间从47秒降至1.8秒(实测数据)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL分区表设计实战
2.1 分区策略选型
针对结算报表场景,我们对比了四种分区方式:
| 分区类型 | 适用场景 | 结算场景匹配度 | 缺点 |
|---|---|---|---|
| RANGE | 按日期/数值范围 | ★★★★★ | 需预判数据增长 |
| LIST | 离散值(如地区) | ★★☆☆☆ | 不适合时间序列 |
| HASH | 均匀分布 | ★☆☆☆☆ | 破坏时间局部性 |
| KEY | 通用分布 | ★★☆☆☆ | 分区裁剪效率低 |
最终采用RANGE分区配合TO_DAYS()函数,将数据按自然月划分:
sql复制CREATE TABLE inventory_snapshot (
id BIGINT NOT NULL AUTO_INCREMENT,
sku_code VARCHAR(32) NOT NULL COMMENT '商品编码',
warehouse_id INT NOT NULL COMMENT '仓库ID',
quantity INT NOT NULL COMMENT '可用库存',
frozen_quantity INT DEFAULT 0 COMMENT '冻结库存',
snapshot_date DATE NOT NULL COMMENT '快照日期',
PRIMARY KEY (id, snapshot_date),
UNIQUE KEY idx_sku_warehouse (sku_code, warehouse_id, snapshot_date)
) PARTITION BY RANGE (TO_DAYS(snapshot_date)) (
PARTITION p_202301 VALUES LESS THAN (TO_DAYS('2023-02-01')),
PARTITION p_202302 VALUES LESS THAN (TO_DAYS('2023-03-01')),
PARTITION p_max VALUES LESS THAN MAXVALUE
);
2.2 分区维护操作
动态扩容分区(每月初执行):
sql复制ALTER TABLE inventory_snapshot REORGANIZE PARTITION p_max INTO (
PARTITION p_202303 VALUES LESS THAN (TO_DAYS('2023-04-01')),
PARTITION p_max VALUES LESS THAN MAXVALUE
);
过期数据清理(每年归档后执行):
sql复制ALTER TABLE inventory_snapshot DROP PARTITION p_202301;
关键经验:分区字段必须包含在主键中(复合主键),否则会报错"ERROR 1503 (HY000)"
3. Java端实现方案
3.1 日快照生成逻辑
采用Spring Batch+MyBatis实现分布式快照任务:
java复制@Bean
public Job dailySnapshotJob(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new JobBuilder("inventorySnapshotJob", jobRepository)
.start(snapshotStep(null, null))
.listener(new SnapshotJobListener())
.build();
}
@StepScope
@Bean
public Tasklet snapshotTasklet(@Value("#{jobParameters['snapshotDate']}") String snapshotDate) {
return (contribution, chunkContext) -> {
// 1. 清理当日已有快照
inventoryMapper.deleteBySnapshotDate(snapshotDate);
// 2. 从实时库存表生成快照
List<InventoryDO> liveData = inventoryMapper.selectAll();
List<InventorySnapshotDO> snapshots = liveData.stream()
.map(item -> new InventorySnapshotDO(item, snapshotDate))
.collect(Collectors.toList());
// 3. 批量插入(每1000条提交一次)
BatchInsertHelper.batchInsert(inventorySnapshotMapper, snapshots, 1000);
return RepeatStatus.FINISHED;
};
}
性能优化点:
- 使用
@StepScope避免单例状态问题 - 批处理采用游标查询+分片写入
- 添加
UNIQUE KEY防止重复执行
3.2 分区查询优化
在MyBatis中动态指定分区:
xml复制<select id="selectSettlementData" resultType="com.xxx.SettlementVO">
SELECT
sku_code,
SUM(quantity) AS total_quantity,
SUM(quantity * cost_price) AS total_amount
FROM inventory_snapshot PARTITION(p_${month})
WHERE snapshot_date BETWEEN #{startDate} AND #{endDate}
GROUP BY sku_code
HAVING total_quantity > 0
</select>
Java调用层通过ThreadLocal传递分区信息:
java复制public class PartitionContextHolder {
private static final ThreadLocal<String> context = new ThreadLocal<>();
public static void setPartition(String month) {
context.set("p_" + month);
}
public static String getPartition() {
return context.get();
}
public static void clear() {
context.remove();
}
}
// 在Service层使用
public SettlementReport generateReport(String month, Date start, Date end) {
try {
PartitionContextHolder.setPartition(month);
return settlementMapper.selectSettlementData(start, end);
} finally {
PartitionContextHolder.clear();
}
}
4. 踩坑实录与解决方案
4.1 分区锁竞争问题
现象:3月31日23:50开始的结算任务阻塞了4月1日00:10的快照生成
根因分析:
- 月末结算扫描
p_202303分区时持有元数据锁 - 新快照尝试创建
p_202304分区需要修改表结构
解决方案:
- 使用
WAIT n语法设置锁超时:
sql复制ALTER TABLE inventory_snapshot
ADD PARTITION (PARTITION p_202304 VALUES LESS THAN (TO_DAYS('2023-05-01')))
WAIT 5;
- 错峰执行:快照任务避开结算高峰时段(通过分布式锁控制)
4.2 跨分区查询性能骤降
错误示范:
sql复制SELECT * FROM inventory_snapshot
WHERE snapshot_date BETWEEN '2023-03-25' AND '2023-04-05'
优化方案:
- 显式指定分区:
sql复制SELECT * FROM inventory_snapshot PARTITION(p_202303, p_202304)
WHERE snapshot_date BETWEEN '2023-03-25' AND '2023-04-05'
- 在Java层拆分查询:
java复制public List<InventorySnapshotDO> queryCrossPartition(Date start, Date end) {
// 获取覆盖的分区列表
Set<String> partitions = calculatePartitions(start, end);
return partitions.stream()
.flatMap(partition -> {
PartitionContextHolder.setPartition(partition);
return inventorySnapshotMapper
.selectByDateRange(start, end).stream();
})
.collect(Collectors.toList());
}
5. 进阶优化方向
5.1 冷热数据分离
将分区表改造为三级存储:
- 热数据:最近3个月分区,使用SSD存储
- 温数据:3-12个月分区,普通磁盘
- 冷数据:归档到对象存储(如MinIO)
通过MySQL表空间实现:
sql复制ALTER TABLE inventory_snapshot
PARTITION BY RANGE (TO_DAYS(snapshot_date)) (
PARTITION p_hot VALUES LESS THAN (TO_DAYS('2023-04-01'))
TABLESPACE ts_ssd,
PARTITION p_warm VALUES LESS THAN (TO_DAYS('2023-07-01'))
TABLESPACE ts_hdd,
PARTITION p_cold VALUES LESS THAN MAXVALUE
TABLESPACE ts_archive
);
5.2 动态分片策略
对于超大规模库存(亿级以上),引入ShardingSphere实现二级分片:
- 一级分片:按
warehouse_id % 8分成8个逻辑库 - 二级分片:每个库内按时间分区
配置示例:
yaml复制spring:
shardingsphere:
datasource:
names: ds0,ds1,...,ds7
sharding:
tables:
inventory_snapshot:
actual-data-nodes: ds$->{0..7}.inventory_snapshot_$->{202301..202312}
database-strategy:
standard:
sharding-column: warehouse_id
precise-algorithm-class-name: com.xxx.WarehouseHashAlgorithm
table-strategy:
standard:
sharding-column: snapshot_date
precise-algorithm-class-name: com.xxx.MonthRangeAlgorithm
5.3 增量合并计算
优化结算时的计算逻辑:
java复制public SettlementResult calculate(SettlementQuery query) {
// 1. 获取最近快照基准
InventorySnapshot baseline = getLastSnapshot(query.getSkuCode());
// 2. 查询后续变动记录
List<InventoryTransaction> transactions =
transactionService.queryAfter(baseline.getSnapshotDate());
// 3. 内存合并计算
return transactions.stream()
.reduce(
new SettlementResult(baseline),
(result, tx) -> result.apply(tx),
(r1, r2) -> r1.merge(r2)
);
}
这种方案相比全量扫描快照表,在变动率<5%的场景下可提升60%性能。
