结算单调整分页查询 Out of sort memory 问题分析与解决方案
一、问题现象
生产环境调用结算单调整分页查询接口时报错:
sql
[BaseException] BUSINESS_ERROR: ### Error querying database.
Cause: java.sql.SQLException: Out of sort memory, consider increasing server sort buffer size
### SQL: SELECT id,adjustment_code,...,provision,upload_provision,...
FROM settlement_adjustment
WHERE is_deleted=0 AND (created_by = ?)
ORDER BY created_date DESC,id DESC LIMIT ?
对应 MySQL 错误码:1038 (HY001)。
二、根因分析
2.1 表结构因素:JSON 列单行数据量过大
settlement_adjustment 表中 dimension_data、provision、upload_provision 为 JSON 类型列,用于存储调整单的维度数据和计提明细。实测数据:
| 列 | 单行最大长度 |
|---|---|
provision |
约 3.8 MB |
upload_provision |
约 3.7 MB |
| 单行合计(含 dimension_data) | 约 7.4 MB |
而数据库 sort_buffer_size 配置为 4 MB(@@sort_buffer_size = 4194304)。
MySQL 执行排序(filesort)时,需要把 SELECT 涉及的整行数据放入排序缓冲区。只要有一行参与 filesort,行大小超过 sort_buffer_size 就会报 1038 Out of sort memory。这与参与排序的行数无关,哪怕只排序 9 行,只要其中一行的 JSON 列数据超限,同样会报错。
2.2 索引因素:排序方向与索引方向不一致,导致走 filesort
生产环境该查询命中的索引为:
sql
idx_created_by_date (created_by, created_date DESC)
SHOW INDEX 确认 created_date 列的 Collation = D(降序)。
InnoDB 的二级索引会在索引末尾隐式追加主键列,且主键部分固定是升序。因此该索引实际的物理存储顺序是:
sql
(created_by, created_date DESC, id ASC)
但原代码的排序写法是:
java
wrapper.orderByDesc("created_date").orderByDesc("id");
// 即 ORDER BY created_date DESC, id DESC
created_date 和索引方向一致(都是 DESC),但 id 方向相反(索引里 id 是 ASC,SQL 要求 DESC)。两列排序方向"一顺一反",MySQL 无法通过正向或反向扫描索引同时满足两个方向,只能放弃索引排序,转而对结果集做 filesort。
生产环境 EXPLAIN 结果验证了这一点:
sql
type=ref, key=idx_created_by_date, rows=9, Extra: Using where; Using filesort
key 命中了索引,但只用于 created_by 的等值过滤,排序阶段仍走了 filesort,触发了整行数据进排序缓冲区,最终导致内存溢出。
补充说明:
id DESC是为了解决created_date撞值(同一时间创建多条记录)时分页结果不稳定而追加的次级排序键,用于保证分页语义正确。这个变更是问题触发的直接原因,但根本问题是"次级排序键方向与索引不一致",而不是"不该加次级排序键"。
2.3 两个因素叠加才会报错
- 只有 JSON 列过大,没有 filesort:不会报错(走索引正常返回)。
- 只有 filesort,没有大 JSON 列:filesort 只需要排序字段值,不会把整行放入缓冲区,也不会报错。
- 两者叠加:filesort 需要整行数据(含几 MB 的 JSON)进排序缓冲区,超过 4MB 限制,报错。
三、解决方案
采用两层修复,分别解决"排序退化为 filesort"和"即使 filesort 也不应因大字段而崩溃"两个问题。
3.1 修复一:排序方向与索引方向对齐,恢复索引排序
java
// 修改前
wrapper.orderByDesc("created_date").orderByDesc("id");
// 修改后
wrapper.orderByDesc("created_date").orderByAsc("id");
将次级排序键改为 id ASC,与索引 (created_by, created_date DESC, id ASC) 的物理顺序完全一致,MySQL 可以直接扫描索引得到有序结果,无需 filesort。
id 是唯一主键,升序或降序都能保证分页结果稳定(不会因为改变方向导致同一时间的记录漏读或重复读),对业务语义没有影响。
3.2 修复二:延迟关联(Deferred Join),排序阶段不携带大字段
即使排序方向对齐索引,仍存在以下风险,不能完全依赖"索引一定会被优化器选中":
- 前端可能传入其它排序字段(
ConditionBuilders.buildQueryWrapper支持动态排序条件拼接),此时索引可能对不上。 - 数据分布变化、统计信息过期等原因,优化器可能放弃索引改走全表扫描 + filesort。
因此在仓储层引入"延迟关联"模式,将排序和取整行拆成两步:
java
// 第一步:只查 id,完成排序和分页(PageHelper 作用于此次查询)
wrapper.select("id");
List<Long> pageIds = mapper.selectList(wrapper).stream()
.map(SettlementAdjustmentDO::getId)
.collect(Collectors.toList());
if (pageIds.isEmpty()) {
return new ArrayList<>();
}
// 第二步:按 id 回表取整行
Map<Long, SettlementAdjustmentDO> adjustmentDOMap = mapper.selectBatchIds(pageIds).stream()
.collect(Collectors.toMap(SettlementAdjustmentDO::getId, Function.identity(), (existing, replacement) -> existing));
// 第三步:按分页查询的 id 顺序还原排序(selectBatchIds 不保证返回顺序)
List<SettlementAdjustmentDO> settlementAdjustmentDOS = pageIds.stream()
.map(adjustmentDOMap::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
即使第一步的排序因为某些原因退化为 filesort,参与排序的也只有 id 一列,数据量极小,不会因为 JSON 大字段导致排序缓冲区溢出。第二步按主键回表是等值查询(selectBatchIds),走主键索引,成本很低。
3.3 两层修复的关系
| 修复 | 解决的问题 | 生效条件 |
|---|---|---|
| 排序方向对齐索引 | 让排序尽量走索引,避免 filesort | 依赖优化器选中该索引 |
| 延迟关联 | 即使走 filesort 也不会因大字段崩溃 | 始终生效,不依赖执行计划 |
两者叠加,既尽量避免了不必要的排序开销,也从根本上兜底了"大字段 + filesort"这个组合会导致崩溃的问题。
四、验证方式
4.1 验证排序是否已走索引(无 filesort)
sql
EXPLAIN SELECT id FROM settlement_adjustment
WHERE is_deleted = 0 AND created_by = '<报错用户>'
ORDER BY created_date DESC, id ASC LIMIT 10;
预期 Extra 只包含 Using where,不再出现 Using filesort。
4.2 确认索引实际定义与方向
sql
SHOW INDEX FROM settlement_adjustment WHERE Key_name = 'idx_created_by_date';
关注 created_date 行的 Collation:D 表示降序,A 表示升序。
五、遗留事项
- 接口响应体过大 :分页列表接口目前仍会返回
provision、upload_provision等大字段,单行可达数 MB。如果前端列表页不需要展示这些明细,建议评估在响应 DTO 层面排除,减少网络传输和前端解析开销(需与前端确认后再改动)。