一次线上OOM故障复盘:MySQL Blob字段引发的堆外内存泄漏

作者 lucy · 2026-04-11

⚠️ 声明:本文相关内容仅供参考,实际效果因场景不同可能差异很大,请结合自身情况判断,谨慎参考。




一次线上OOM故障复盘:MySQL Blob字段引发的堆外内存泄漏


🕐 背景:上周三凌晨2点,线上告警突然响起——Java服务进程被OOM Killer杀掉了。第一反应是应用内存泄漏,但查完GC日志和堆转储之后,发现问题根本不在堆内存里,而在堆外内存

📋 故障现象

  • Pod重启次数:3次/小时
  • JVM堆内存使用率:正常(~60%)
  • 系统内存:持续上涨,直至触发OOM Killer
  • 错误日志:dmesg | grep javaOut 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

💡 经验总结

  1. JVM堆内存正常≠应用内存正常:DirectByteBuffer、mmap、JNI等都会用堆外内存
  2. MySQL Blob字段是堆外内存泄漏的经典诱因:大字段查询务必评估大小
  3. 监控要到位:建议同时监控进程的RSS内存(process_resident_memory_bytes),不能只看JVM指标
  4. 连接参数要配置:生产环境务必配置 useCursorFetch=true 和合适的 BlobSendChunkSize

⚠️ 声明:本文相关内容仅供参考,实际排查请结合自身业务和系统环境,若有疑问欢迎留言交流。


发表评论

苏ICP备18039580号-2