问题现象

这是一次典型的后台列表接口超时。

接口除了查主表,还会补下游状态和展示字段。高峰期一上来就开始抖:

1、列表接口平时大约 200ms ~ 300ms

2、高峰期偶发升到 3s ~ 5s

3、数据库连接占用上升,但应用 CPU 并不高

4、网关有超时报警,应用日志里经常看到分页查询耗时偏长

这类问题不能只盯一层。表面是接口超时,实际往往是分页 SQL、事务边界和补充查询叠在一起。

第一轮排查

先看应用日志

先补分段耗时,结果很快就出来了:主要时间都耗在列表查询。

1
2
3
long start = System.currentTimeMillis();
List<ActivityEntry> entryList = activityEntryMapper.selectByExample(req.toExample());
log.info("queryPage cost={}ms", System.currentTimeMillis() - start);

到这一步已经能先排掉 JSON 序列化和返回组装,问题在数据库查询。

再看慢查询

再从慢查询日志里捞 SQL,马上能看到典型的 order by + limit offset 深分页。

1
2
3
4
5
6
SELECT id, biz_id, group_id, status, update_time
FROM t_activity_entry
WHERE biz_id = #{bizId}
AND status = #{status}
ORDER BY update_time DESC
LIMIT #{offset}, #{pageSize};

数据量小时这类 SQL 看不出问题,数据一上来,深分页代价就全出来了。

看执行计划

1
2
3
4
5
6
EXPLAIN SELECT id, biz_id, group_id, status, update_time
FROM t_activity_entry
WHERE biz_id = 1001
AND status = 1
ORDER BY update_time DESC
LIMIT 5000, 20;

执行计划重点看三项:

1、扫描行数很大

2、Using where

3、Using filesort

这说明过滤条件虽然生效了,但排序和分页没有命中合适索引,数据库还在扫和排大量记录。

根因分析

最后定位下来,不是单一 SQL 问题,是两层问题叠在一起。

1、索引不合适

原来的索引偏单列或通用索引,但这条 SQL 的过滤和排序是组合出现的:biz_id + status + update_time

单列索引帮不上太多,最后还是要回表、排序、扫大量数据。

2、分页过深

LIMIT 5000, 20 这种深分页,本质上前面 5000 条也得先扫一遍,页数越深越慢。

后台管理列表最容易出现这种情况,前几页正常,翻深了就开始抖。

第二轮排查

只改索引后,RT 下来了,但高峰期还是偶发超时。继续看监控,发现事务持有时间偏长。

顺着调用链往下追,发现原代码把“查列表 + 补状态 + 补展示信息”全包在一个大事务里。

1
2
3
4
5
6
7
@Transactional(rollbackFor = Exception.class)
public PageResult<ActivityEntryVO> queryPage(QueryReq req) {
List<ActivityEntry> entryList = activityEntryMapper.selectByExample(req.toExample());
extService.fillStatus(entryList);
extService.fillDisplayInfo(entryList);
return buildResult(entryList);
}

问题很直接:

1、数据库事务时间被无谓拉长

2、补充状态和展示信息一旦慢,就会放大接口整体 RT

3、并发高时更容易把连接和锁占住

解决方案

1、补组合索引

按查询条件和排序方式补组合索引,不要继续堆单列索引。

1
2
ALTER TABLE t_activity_entry
ADD INDEX idx_biz_status_utime_id (biz_id, status, update_time, id);

这里把 id 也带上,是为了让排序和分页路径更稳定。

2、改深分页

能不用深分页,就不要一直 LIMIT offset, size

更稳的方式是基于上一页最后一条记录继续翻页,比如按 update_time + id 做游标翻页。

1
2
3
4
5
6
7
8
SELECT id, biz_id, group_id, status, update_time
FROM t_activity_entry
WHERE biz_id = #{bizId}
AND status = #{status}
AND (update_time < #{lastUpdateTime}
OR (update_time = #{lastUpdateTime} AND id < #{lastId}))
ORDER BY update_time DESC, id DESC
LIMIT 20;

3、缩短事务范围

事务里只保留必要的数据库操作,补充查询和结果组装放到事务外。

1
2
3
4
5
6
7
8
9
10
11
public PageResult<ActivityEntryVO> queryPage(QueryReq req) {
List<ActivityEntry> entryList = queryPageFromDb(req);
extService.fillStatus(entryList);
extService.fillDisplayInfo(entryList);
return buildResult(entryList);
}

@Transactional(rollbackFor = Exception.class)
public List<ActivityEntry> queryPageFromDb(QueryReq req) {
return activityEntryMapper.selectByExample(req.toExample());
}

其他补充查询也放到事务外,不要把事务时间拖长。

结果

改完以后,结果直接看这几项:

1、慢查询数量明显下降

2、接口 TP99 从秒级回落到几百毫秒

3、数据库连接占用更平稳

4、超时报警基本消失

这类问题以后怎么避免

这不是一次性的失误,是列表查询里很容易重复出现的问题。

以后排这类问题,顺序就按这个来:

1、先分清是本地 SQL 慢,还是补充查询慢

2、慢查询先看执行计划,不要凭感觉猜索引

3、分页接口重点看是否存在 order by + limit offset 的深分页

4、事务里不要包下游调用和复杂组装逻辑

5、改完以后一定压测,不要只看本地跑通

小结

线上超时很多时候不是某个服务突然不行了,而是分页查询、索引设计、事务边界和补充调用叠出来的。

排这类问题,不要只盯一层,要把接口、SQL、事务边界和补充调用串起来看。