分页查询 Out of sort memory 问题

结算单调整分页查询 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 表示升序。

五、遗留事项

  1. 接口响应体过大 :分页列表接口目前仍会返回 provision、upload_provision 等大字段,单行可达数 MB。如果前端列表页不需要展示这些明细,建议评估在响应 DTO 层面排除,减少网络传输和前端解析开销(需与前端确认后再改动)。
相关推荐
xyLJ1 小时前
为什么 Spring Boot 自动配置了 Redis,还要自己写 RedisTemplate?
后端
xixiaoyunya2 小时前
Spring Boot 3 统一响应与全局异常处理实战
java·spring boot·后端
JavaGuide2 小时前
DeepSeek Harness 官方桌面端终于有了!
前端·后端
SFLYQ2 小时前
隔离内网下 AI Agent 工程实战
后端·agent·ai编程
凤山老林2 小时前
Spring Boot 集成 Netty 构建高性能 TCP 长连接网关:协议解析、心跳检测与集群广播实战
spring boot·后端·tcp/ip
Json____3 小时前
基于 Spring Boot + Vue3 的在线考试管理系统实战
java·spring boot·后端·it学习·wwwoop.com
Sam_Deep_Thinking3 小时前
如何理解java的信号量
java·后端·面试·程序员
东风破_3 小时前
NestJS 快速入门:先别背装饰器,把一条请求跑明白
后端
晴空蓝天3 小时前
MDC traceId 全链路日志追踪:Spring Boot 3.5 里把日志串成一条线
java·spring boot·后端·python