一、问题现象
生产环境调用结算单调整分页查询接口时报错:
[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
生产环境该查询命中的索引为:
idx_created_by_date (created_by, created_date desc)
show index 确认 created_date 列的 collation = d(降序)。
innodb 的二级索引会在索引末尾隐式追加主键列,且主键部分固定是升序。因此该索引实际的物理存储顺序是:
(created_by, created_date desc, id asc)
但原代码的排序写法是:
wrapper.orderbydesc("created_date").orderbydesc("id");
// 即 order by created_date desc, id desccreated_date 和索引方向一致(都是 desc),但 id 方向相反(索引里 id 是 asc,sql 要求 desc)。两列排序方向"一顺一反",mysql 无法通过正向或反向扫描索引同时满足两个方向,只能放弃索引排序,转而对结果集做 filesort。
生产环境 explain 结果验证了这一点:
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 修复一:排序方向与索引方向对齐,恢复索引排序
// 修改前
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。
因此在仓储层引入"延迟关联"模式,将排序和取整行拆成两步:
// 第一步:只查 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)
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 确认索引实际定义与方向
show index from settlement_adjustment where key_name = 'idx_created_by_date';
关注 created_date 行的 collation:d 表示降序,a 表示升序。
五、遗留事项
接口响应体过大:分页列表接口目前仍会返回 provision、upload_provision 等大字段,单行可达数 mb。如果前端列表页不需要展示这些明细,建议评估在响应 dto 层面排除,减少网络传输和前端解析开销(需与前端确认后再改动)。
到此这篇关于mysql 分页查询 out of sort memory 问题解决的文章就介绍到这了,更多相关mysql out of sort memory 内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论