作者 lucy · 2026-04-11
⚠️ 声明:本文相关内容仅供参考,实际效果因场景不同可能差异很大,请结合自身情况判断,谨慎参考。
🕐 背景:上周三凌晨2点,线上告警突然响起——Java服务进程被OOM Killer杀掉了。第一反应是应用内存泄漏,但查完GC日志和堆转储之后,发现问题根本不在堆内存里,而在堆外内存。
📋 故障现象
- Pod重启次数:3次/小时
- JVM堆内存使用率:正常(~60%)
- 系统内存:持续上涨,直至触发OOM Killer
- 错误日志:
dmesg | grep java→Out of memory: Kill process
🔍 排查过程
Step 1:先看堆,堆没问题
# 查看JVM堆内存
jstat -gcutil <pid> 1000
# 堆大小设置:-Xmx2g -Xms2g
# 结果:Old区稳定在 45%,无明显泄漏
堆内存完全正常,排除堆泄漏嫌疑。
Step 2:堆外内存分析
# 查看进程内存映射(Top按内存排序找到Java进程)
pmap -x <pid> | sort -k3 -n -r | head -30
# 结果发现大量 nnio 申请的内存段:
# 00007f0a80000000 262144 131072 131072 131072 rw--- [ anon ] ← NIO直接缓冲区
Step 3:追踪根因——MySQL Blob字段
通过arthas监控java.nio.ByteBuffer.allocateDirect调用:
# arthas 中监控直接内存分配
watch java.nio.ByteBuffer allocateDirect '{params[0]}' -x 2
# 同时开启MySQL慢查询日志,发现问题SQL:
SELECT id, title, content, attach_blob FROM article WHERE id = ?
问题出在代码里这段查询:
// ❌ 错误写法:每次查询都读取整个Blob到堆外内存
Article article = articleMapper.selectById(id);
byte[] blob = article.getAttachBlob(); // MyBatis自动将Blob转为byte[]
// 问题:MySQL Connector/J 默认把>1024字节的Blob用DirectByteBuffer存储
🛠️ 根因分析
根因:MySQL JDBC驱动在读取
TINYTEXT/TEXT/MEDIUMBLOB/BLOB 字段时,默认使用堆外内存(DirectByteBuffer)存储,当单条数据Blob超过1KB时就会触发。Blob大小是动态的,当content字段插入超过阈值的内容时,每个请求都会分配一块约等于Blob大小的堆外内存,且依赖GC才能释放——而DirectByteBuffer的回收依赖PhantomReference,Full GC不够及时就会堆积。
✅ 解决方案
方案一:限制Blob读取大小(推荐,最小改动)
# 在数据库连接URL中加入参数,限制最大blob大小读取到堆内
jdbc:mysql://host:3306/db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowLoadLocalInfile=true&defaultFetchSize=100&useCursorFetch=true&BlobSendChunkSize=1048576
# 或在MyBatis中单独控制查询,只返回必要字段
<select id="selectBaseById" resultType="Article">
SELECT id, title, create_time FROM article WHERE id = #{id}
</select>
方案二:流式读取Blob
// ✅ 推荐:使用流式读取,避免一次性加载大Blob
public InputStream getAttachBlobStream(Long id) {
SqlSession sqlSession = sqlSessionFactory.openSession();
try {
ArticleMapper mapper = sqlSession.getMapper(ArticleMapper.class);
// MyBatis 流式查询配置
return mapper.selectAttachBlobStream(id);
}
}
<select id="selectAttachBlobStream" resultType="java.io.InputStream">
SELECT attach_blob FROM article WHERE id = #{id}
</select>
方案三:Nginx限制上传大小(兜底)
# nginx.conf 中添加限制,防止超大文件进入应用层
client_max_body_size 10M;
proxy_buffering off;
📊 优化效果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| Pod重启频率 | 3次/小时 | 0次/天 |
| 堆外内存峰值 | 持续上涨至1.8GB | 稳定在200MB内 |
| 平均响应时间 | 320ms | 85ms |
💡 经验总结
- JVM堆内存正常≠应用内存正常:DirectByteBuffer、mmap、JNI等都会用堆外内存
- MySQL Blob字段是堆外内存泄漏的经典诱因:大字段查询务必评估大小
- 监控要到位:建议同时监控进程的RSS内存(
process_resident_memory_bytes),不能只看JVM指标 - 连接参数要配置:生产环境务必配置
useCursorFetch=true和合适的BlobSendChunkSize
⚠️ 声明:本文相关内容仅供参考,实际排查请结合自身业务和系统环境,若有疑问欢迎留言交流。