ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot + Lettuce 堆外内存溢出:OutOfDirectMemoryError 复现与分析

Spring Boot + Lettuce 堆外内存溢出:OutOfDirectMemoryError 复现与分析 一、问题现象异常信息io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 2096896 byte(s) of direct memory (used: 7406854, max: 8388608)业务影响Redis 操作全部失败接口返回 500应用可能崩溃二、问题本质Netty 向操作系统申请的堆外内存超过了-XX:MaxDirectMemorySize的限制。关键认知-Xmx管的是堆内存管不了堆外内存堆外内存由-XX:MaxDirectMemorySize独立控制Netty 的ByteBuf默认分配在堆外三、复现环境组件版本Spring Boot2.2.5.RELEASEJDK8Lettuce5.2.2Netty4.1.45JVM 参数-Xms256m -Xmx256m -XX:MaxDirectMemorySize8mJMeter压测配置接口/redis/big大量数据set/get线程数200循环次数不限制四、复现步骤建 Spring Boot 2.2.5 项目引入spring-boot-starter-data-redis关闭连接共享share-native-connection: false写一个循环操作 Redis 的接口启动参数加-XX:MaxDirectMemorySize8mJMeter 200 并发压测五、异常堆栈关键路径io.netty.util.internal.OutOfDirectMemoryError at io.netty.util.internal.PlatformDependent.incrementMemoryCounter at io.netty.buffer.UnpooledUnsafeNoCleanerDirectByteBuf.reallocateDirect at io.netty.buffer.AbstractByteBuf.ensureWritable at io.lettuce.core.protocol.CommandArgs$ByteBufferArgument.writeByteBuf at io.lettuce.core.protocol.CommandEncoder.encode at io.netty.handler.codec.MessageToByteEncoder.write关键点Lettuce 在编码 Redis 命令时通过 Netty 向堆外申请 ByteBuf超过上限崩溃。六、原因分析6.1 完整因果链高并发请求 → 每个请求操作 Redis → Lettuce 编码命令编码结果是一个 ByteBuf → ByteBuf 分配在堆外内存 → 所有命令进入同一个 EventLoop 队列共享连接模式 → 队列无长度上限堆积不断增长 → 每条排队命令都持有一份堆外 ByteBuf → 堆外内存占用持续增长 → 超过 MaxDirectMemorySize → 抛 OutOfDirectMemoryError6.2 三个关键事实事实一-Xmx管不住堆外内存堆内存堆外内存上限参数-Xmx-XX:MaxDirectMemorySize回收机制Young GC / Full GCCleaner 或 Full GC 触发溢出异常Java heap spaceDirect buffer memory/OutOfDirectMemoryError是否独立独立独立事实二Lettuce 默认共享连接同一个 EventLoop 队列Lettuce 底层用 Netty 的Channel。Netty 的Channel是线程安全的多个线程可以并发调用channel.write(command)Netty 内部把这些写操作封装成任务投递到 Channel 绑定的EventLoop 线程的队列里EventLoop单线程串行执行队列里的任务最终按顺序写入 socket所以默认情况下所有线程共用同一个 SocketChannel同一条 TCP 连接。事实三排队的命令都占着堆外 ByteBuf每条 Redis 命令在写入 socket 之前都要先编码成ByteBuf。这个ByteBuf分配在堆外。命令在 EventLoop 队列里排队时已经编码好的 ByteBuf 仍然占着堆外内存。队列越长堆外占用越高。6.3 为什么共享连接模式容易爆100 线程 → 【无闸门】→ 1 个 EventLoop 队列 → 队列长度无上限 → 每条占一份堆外 ByteBuf → 堆外暴涨 → OOM核心问题入口没有并发闸门队列没有长度限制堆外占用与队列长度成正比没有上限。6.4 连接池如何解决背压思想100 线程 → 【池大小8 的闸门】→ 8 个连接 ↓ 借不到连接 → 线程在这里等待不占堆外内存 ↓ 等待超时 → 抛异常 ↓ 借到连接 → 进入该连接的 EventLoop 队列 ↓ 堆外占用受 8 个连接上限约束关键点连接池限制的不是队列长度而是活跃连接数没抢到连接的线程在连接池层面等待不进入 Netty 队列等待只占一个线程不占堆外内存Netty 本身不做等待——分配失败时直接抛OutOfDirectMemoryError本质连接池用等待软约束、可恢复替换了内存堆积硬约束、不可恢复。这就是背压Backpressure。6.5 三层内存保护模型连接池只能管住入口层完整的内存安全靠三层层级措施解决什么入口层连接池限制活跃连接数防止并发把堆外占满单连接层命令队列长度、pipeline 批量大小防止单连接内队列堆积单操作层限制 value 大小防止单个命令本身就超上限连接池解决的是并发峰值堆积不是绝对内存不溢出。如果单个操作需要的内存超过整个堆外上限如MaxDirectMemorySize8m却要写 10MB 的 value任何框架都救不了只能调大上限或拆小数据。七、版本差异版本组合能否复现原因Spring Boot 2.2.5 Lettuce 5.2.2✅ 能无连接池 旧版 Netty 分配策略Spring Boot 2.7.0 Lettuce 6.1.8❌ 不能连接池 新版 Netty 优化7.1 新版为什么不会炸变化一Lettuce 6.x 默认启用连接池形成并发闸门连接数有上限默认 8共享连接模式被削弱等待发生在连接池层堆外占用 连接数 × 单连接缓冲总量受限变化二Netty 池化分配器更成熟缓冲区可复用PooledByteBufAllocator从本地池取 ByteBuf用完归还不需要频繁向操作系统申请/释放堆外内存单连接的堆外占用稳定不随并发线性增长7.2 深层对比维度旧版会炸新版不会炸并发闸门无共享连接连接池上限队列堆积位置Netty 队列占堆外连接池等待队列占线程堆外占用上限无上限连接数 × 单连接缓冲Netty 分配策略较激进池化复用更克制后果OOM不可恢复超时可重试/降级八、解决方案方案说明升级 Spring Boot2.7 默认 Lettuce 6.x问题自动消失调大堆外内存-XX:MaxDirectMemorySize256m或更大换 JedisJedis 用连接池不走 Netty 堆外限制并发降低连接数减少堆外申请补充说明调大MaxDirectMemorySize是治标不治本因为堆外占用如果与并发线性相关迟早会再爆。首选升级版本其次是引入连接池最后才是调大上限。九、排查工具NMTjcmd pid VM.native_memory summary看堆外使用启动参数-XX:NativeMemoryTrackingsummarypmappmap -x pid看进程内存分布十、仓库地址https://gitee.com/shun-chun-li/springboot-lettuce-direct-memory-oom
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进