列表变慢先别急着加索引

后台列表变慢,先看查询模型,不要先加索引。

这类列表一开始通常都很简单:

1、按条件查表

2、分页返回

3、前端展示几列字段

但做着做着就会长出很多额外需求:

1、多条件筛选

2、多种排序

3、列表里直接带关联信息

4、导出和列表共用一套查询

5、详情页也复用同一个查询对象

问题往往不是单条 SQL 慢,而是一个查询承担了太多职责。

先看典型 SQL

这类列表 SQL 往往长这样:

1
2
3
4
5
select *
from t_partner
where status = ? and create_time > ? and update_time < ?
order by update_time desc
limit ${offset}, ${rows}

请求量上来以后,问题基本集中在三块:

1、orderByClause 动态开放过大

2、深分页 limit offset 越翻越慢

3、查询字段越来越多

查询是怎么一步步变重的

下面这个例子只说明问题,不代表固定耗时。

初始版本:

1
2
3
4
select * from t_partner
where status = 1 and create_time > '2025-01-01'
order by update_time desc
limit 0, 20;

这个写法通常不是单点问题,而是字段过多、排序字段不稳、索引没贴住条件一起叠上来。

第一步:先把过滤条件和排序字段收拢,再补复合索引

1
create index idx_status_create_update on t_partner(status, create_time, update_time);

这一步通常能先收掉全表扫描,但还不代表列表已经稳定。

第二步:列表页只查必需列

1
2
3
4
select id, name, status, create_time from t_partner
where status = 1 and create_time > '2025-01-01'
order by update_time desc
limit 0, 20;

如果列表请求量不大,这一步可能就够了。只有页码翻得很深时,才需要继续改分页模型。

第三步:深分页改游标分页

1
2
3
4
5
-- 旧方式(第100页,offset=100000)
select * from t_partner where status = 1 limit 100000, 20;

-- 改成游标分页(上次最后一条id=999)
select * from t_partner where status = 1 and id < 999 order by id desc limit 20;

所以只盯索引,很多时候治不了根。

数据分层先拆查询职责

这里说的数据分层,不是大架构概念,就是把查询职责拆开:

1、主列表只查核心字段

2、补充展示信息按需二次查询

3、详情页单独查明细

4、导出单独走一条查询链路

一个 SQL 同时兼顾列表、详情、导出、排序、筛选,最后一定会越来越重。

先收字段和排序

先看两件事:

1、列表到底需要哪些列

2、排序到底开放了哪些字段

很多列表前端只展示几列,但查询时已经把整张表的列都带出来了。再叠加大字段、JSON 字段,数据库和网络开销都会上来。

下面这个例子只说明一件事:列表字段要收。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 不好:直接返回整个entity,50列都查出来
public List<Partner> getPartnerList(PartnerQuery query) {
return partnerMapper.selectByExample(example);
}

// 好:只查需要的字段
public List<PartnerDTO> getPartnerList(PartnerQuery query) {
return partnerMapper.selectByExample(
new Example(Partner.class)
.selectProperties("id", "name", "status", "createTime", "updateTime")
.and()...
);
}

动态排序要做白名单

orderByClause 最大的问题不是难写,而是太容易放开。

如果完全放开给前端传入,通常会出现两个后果:

1、前端开始按没有索引的字段排序

2、排序表达式不可控,维护和安全风险一起上来

更稳的做法是服务端只开放有限几个排序类型:

1
2
3
4
5
6
7
if ("updateTimeDesc".equals(sortType)) {
example.setOrderByClause("update_time desc");
} else if ("createTimeAsc".equals(sortType)) {
example.setOrderByClause("create_time asc");
} else {
example.setOrderByClause("id desc");
}

这样不算通用,但线上可控。

深分页要换模型

浅分页用 limit offset 还能接受,页码一深,扫描成本就会快速上去。

这种场景我更倾向换成基于游标或主键翻页:

1
2
3
4
5
select id, name, status, update_time
from t_partner
where id < #{lastId}
order by id desc
limit #{pageSize}

这不一定适合所有后台页面,但对深分页通常比继续扛 offset 更稳。

导出不要复用列表查询

这是很常见的坑。

导出通常具备两个特点:

1、数据量更大

2、字段更多

如果沿用列表页那套分页、排序、字段装配逻辑,最后两边都会别扭。

处理方式一般是:

1、列表保持轻量查询

2、导出单独做查询链路

3、数据量大时走异步导出

详情页也不要复用列表查询

列表和详情本来就是两种诉求:

1、列表要快,字段少

2、详情可以重一点,但只查单条

如果详情页继续复用列表查询,再在返回对象上不断补字段,最后列表查询很容易变成大而全接口。

列表变慢后的处理顺序

处理顺序直接按这个来:

1、先限制排序字段

2、再裁掉列表非必要列

3、再把详情和导出拆出去

4、最后再评估是否改成游标分页

核心就是先收查询模型,再谈索引和参数优化。

先告警,再决定是否降级

列表接口不是都值得自动降级。很多后台列表一天没多少请求,先把慢日志和分段耗时接起来更实用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/list")
public Result<List<PartnerDTO>> list(PartnerQuery query) {
long start = System.currentTimeMillis();
List<PartnerDTO> result = partnerService.getList(query);
long cost = System.currentTimeMillis() - start;

if (cost > 200) {
logger.warn("Partner list query slow: {}ms", cost);
metrics.recordSlowQuery("partner_list", cost);
}

// 是否需要降级,取决于这个接口是不是高频入口
if (cost > 500) {
cache.setSlowQueryFlag("partner_list", true, 5 * 60);
}

return Result.success(result);
}

如果这个接口本身是高频后台入口,再考虑缓存或异步预热,不然很容易把问题从数据库挪到缓存一致性。

慢查询触发后先在数据库检查:

1
2
3
4
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
EXPLAIN SELECT ... WHERE status = 1 ...;
-- 先看type和rows,再决定是调索引还是拆查询

小结

后台列表慢,很多时候不是单条 SQL 写得差,而是一个接口承担了太多查询职责。

主列表只查核心字段,排序做白名单,深分页及时换模型,导出和详情拆独立链路,最后补监控和告警。这比单纯堆索引更稳。