目录
一、业务场景
本来只是一次普通的优化改动,运维会定期排查一些慢sql,而对应微服务的负责人需要去协调人员情况去处理各个域的慢sql问题。而这个sql就到了我的手上。
改动点是一个站内信未读信息数量的查询sql优化改动,业务中站内信分为不同的消息类型,各自的消息类型又有不同的类型标签,每次查询用户不用消息类型的站内信未读数量时都需要从数据库中全量查一下该类型下的所有类型标签,而这些消息类型标签是基本长期不会改动的,而这个查询接口在app首页调用频率极高。所以优化考虑将这些类型标签放置于本地缓存中,每次查询时直接从缓存中取,这样能大大减少数据库查询次数,提升性能。
二、事故发生
代码逻辑进行优化后,只进行了简单的自测当晚就发布上线了,第二天十点左右,该服务开始陆续进行预警,然后该服务就OOM挂掉了,紧接着其它服务业务陆续报异常,发现后紧急让运维回退了昨天发布前回滚。当时第一反应就是昨天上线的代码有内存泄露风险bug,所以回滚业务正常后就第一时间去翻了昨天优化的代码逻辑,当然我自己翻来覆去看都没有发现问题,接着让部门主管也帮着分析,但是大家在最开始都没有看出来问题在哪?可能是这个问题真的很小并且在日常中容易被遗漏忽略掉。
三、事故排查
1.排查
紧接着开始排查dump文件
使用JProfiler打开dump文件进行分析,可以看到最大的两个类,点进去进行详细分析,可以看到都是select语句。
打开详细信息,select in 语句拼接了大量的id,以此定位了问题所在,找到这个sql使用的地方,发现就是昨天优化上线的查询代码,将消息类型id写在了本地缓存中,因为List集合的错误使用方式,导致消息类型id一直无限的重复向里边set,最终导致了程序的OOM。
2.定位
让我们定位到程序代码中,这是最开始导致OOM的代码。
//本地缓存消息类型
private Map<Integer, List<MsgType>> msgTypeCacheMap = new ConcurrentHashMap<>();
//在程序启动加载时,就查询数据库将所有消息类型写入到本地缓存map中
@PostConstruct
public void init() {
log.info("===初始化消息类型开始===");
List<MsgType> msgTypeList = lambdaQuery().list();
if (CollUtil.isEmpty(msgTypeList)) {
log.info("===消息类型为空,初始化消息类型结束===");
return;
}
msgTypeCacheMap = msgTypeList.stream()
.collect(Collectors.groupingBy(MsgType::getChannel));
log.info("===初始化消息类型结束===");
}
就是在这里出现的内存泄漏,每次来查询都会调用一次,原先此接口是直读数据库,现在是直接写到了内存中,直接get出对应消息类型集合后,下意识地认为这就是一个新的集合,所以直接对此进行了操作,每次都会又向其中add了新的集合数据,但实际上,get出的集合地址引用的还是内存中的原地址,所以每次add改动,都是直接改动了内存中的这个集合,所以导致每次查询,每次add,无限增大,最终导致内存泄漏,服务宕机。
public List<MsgType> getMsgTypeListByCache(Integer channel) {
if (MailConstants.WORKER_CHANNEL == channel) {
return msgTypeCacheMap.get(channel);
} else {
List<MsgType> msgTypes = msgTypeCacheMap.get(MailConstants.PROVIDER_CHANNEL);
//上边get之后,下边直接set进行返回,导致该list无限增大,最终OOM
msgTypes.addAll(msgTypeCacheMap.get(MailConstants.WORKER_CHANNEL));
return msgTypes;
}
}
后续也是对此处的代码进行了处理,测试无误后重新发布上线。
四、总结
其实不管是在日常工作或者是在学习中,往往那些错误都是一些最常见的使用错误,或者说存心大意造成的,说句实话其实遇到最多的异常就是空指针异常,这个最常见也是最容易被忽略的简单问题,它可能出现在任何地方。而这次OOM事件也是给我自己上了一课。类似这种错误,如果不进行压测是测不出来的,如果只是进行简单的功能测试,是无法暴露出问题的。所以更需要开发人员在工作中,对于内存的使用要注意,不要因为只是一些简单的方法或者接口就去忽略的内存本身使用的风险。