开端
中午一点多,正在午休,我被连环夺命call惊醒。
“线上服务OOM,全挂了!用户都在投诉!”
这是我入职这家公司以来,最漫长的一一天。而起因,竟是一行看似无害的循环代码。
那个致命的循环
先来看看罪魁祸首:
public void exportData(List<Long> userIds) {
int page = 1;
int pageSize = 100;
List<ExportData> allData = new ArrayList<>();
do {
PageHelper.startPage(page, pageSize);
List<UserData> dataList = userDao.selectByCondition(params);
// 转换并累积数据
List<ExportData> exportList = convertToExportData(dataList);
allData.addAll(exportList);
page++; // 页码递增
} while (dataList.size() == pageSize); // 看起来没问题的退出条件
// 后续处理
writeToExcel(allData);
}
看起来没什么问题,对吧?分页查询,每次100条,查完就退出。
但就是这个“看起来没问题”的逻辑,在昨晚的生产环境上,把8GB的堆内存啃得渣都不剩。
诡异的无限循环
让我们一步步拆解这个陷阱:
- 1. 分页插件的小心思:MyBatis PageHelper有个
reasonable参数,默认是true
然而,当reasonable=true时:如果你查询的页码超过最大页数,PageHelper不会返回空,而是返回最后一页的数据!
所以:
- •
dataList.size() == 100,满足循环条件
为什么测试时没发现?
问得好!因为我们的测试数据很“贴心”:
看到了吗?最后一页不是100条!所以循环在第四页正常退出了。
生产环境呢?总数据刚好是100的整数倍……
解决:不只是改个参数
第一步:紧急修复(5分钟)
把while条件改成:
do {
// ... 查询逻辑
} while (dataList.isEmpty().size()>=totalSize); // 空就退出
简单有效。
第二步:深入理解分页(第二天)
研究了PageHelper的源码,发现reasonable参数的逻辑是:
// PageHelper源码简化版
if (reasonable && pageNum > totalPages) {
pageNum = totalPages; // 超过总页数?给你最后一页!
}
这设计本身没问题,是为了用户体验——用户输入了不存在的页码,给ta最后一页总比报错好。
但用在do-while里,就是灾难。
第三步:固化开发规范
循环分页查询绝对不能依赖dataList.size()判断,而是用更安全的方式:
// 方案1:先查总数,或者从分页结果中获取总量,或总页码
int total = userDao.countByCondition(params);
int totalPages = (total + pageSize - 1) / pageSize; // 向上取整
for (int page = 1; page <= totalPages; page++) {
PageHelper.startPage(page, pageSize);
List<UserData> dataList = userDao.selectByCondition(params);
// 处理逻辑
}
// 方案2:游标方式(推荐大数据量)
Long lastId = null;
while (true) {
List<UserData> dataList = userDao.selectAfterId(lastId, pageSize);
if (dataList.isEmpty()) break;
// 处理数据
lastId = dataList.get(dataList.size() - 1).getId();
}
从这次事故学到的
- •
reasonable=true在某些场景下是合理的
- 3. 分页查询的坑
// 错误的:依赖结果集大小判断
while (list.size() == pageSize)
// 正确的:明确知道何时结束
// 要么先查总数
// 要么用游标/ID范围
// 要么确保空结果集时退出
- 4. 监控要能看到“趋势”
如果当时有监控能告警“同一个接口在短时间被循环调用”,我们可能早就发现了。
长效措施:建立防呆体系
- • 所有分页查询必须显式设置
reasonable=false
- 2. 测试用例模板
@Test
public void testPagination() {
// 测试各种数据量
testWithDataCount(0); // 空数据
testWithDataCount(99); // 不满一页
testWithDataCount(100); // 刚好一页
testWithDataCount(101); // 多一条
testWithDataCount(300); // 整页倍数
}
- 3. 生产环境熔断
@Aspect
public class LoopGuardAspect {
private static ThreadLocal<Integer> loopCount = new ThreadLocal<>();
@Around("execution(* *..*Dao.*(..))")
public Object guard(ProceedingJoinPoint pjp) throws Throwable {
Integer count = loopCount.get();
if (count == null) count = 0;
if (count > 100) { // 单个请求最多查100次
throw new TooManyQueriesException("疑似无限循环");
}
loopCount.set(count + 1);
try {
return pjp.proceed();
} finally {
loopCount.remove();
}
}
}
最后的话
那天晚上8点,修复上线,服务恢复。
老板在群里@我:“问题解决了?根本原因是什么?”
我写了又删,删了又写,最后回复:
“分页查询的循环条件有漏洞,已经修复。深层原因是我们对使用的工具理解不够透彻,测试用例覆盖不全。我会写一份详细的事故报告。”
他没再追问,但我知道,这次教训值多少钱。
在技术这条路上,最危险的从来不是那些复杂的算法、高深的架构,而是这些“看起来没问题”的代码。
它们像潜伏在草丛里的蛇,平时安静温顺,一旦条件吻合,就会给你致命一击。
从那天起,我养成了一个习惯:看到while循环,就心里一紧;看到分页查询,就检查三遍。
因为我知道,在深夜的生产环境里,没有“看起来”这回事。
转自公众号:码上生存指南