一、问题现象
异常信息
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 Boot | 2.2.5.RELEASE |
JDK | 8 |
Lettuce | 5.2.2 |
Netty | 4.1.45 |
JVM 参数
-Xms256m -Xmx256m -XX:MaxDirectMemorySize=8mJMeter压测配置
- 接口:
/redis/big(大量数据set/get) - 线程数:200
- 循环次数:不限制
四、复现步骤
- 建 Spring Boot 2.2.5 项目,引入
spring-boot-starter-data-redis - 关闭连接共享(
share-native-connection: false) - 写一个循环操作 Redis 的接口
- 启动参数加
-XX:MaxDirectMemorySize=8m - JMeter 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管不住堆外内存
堆内存 | 堆外内存 | |
上限参数 |
|
|
回收机制 | Young GC / Full GC | Cleaner 或 Full GC 触发 |
溢出异常 |
|
|
是否独立 | 独立 | 独立 |
事实二: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 大小 | 防止单个命令本身就超上限 |
连接池解决的是并发峰值堆积,不是绝对内存不溢出。如果单个操作需要的内存超过整个堆外上限(如MaxDirectMemorySize=8m却要写 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 Boot | 2.7+ 默认 Lettuce 6.x,问题自动消失 |
调大堆外内存 |
|
换 Jedis | Jedis 用连接池,不走 Netty 堆外 |
限制并发 | 降低连接数,减少堆外申请 |
补充说明:调大MaxDirectMemorySize是治标不治本,因为堆外占用如果与并发线性相关,迟早会再爆。首选升级版本,其次是引入连接池,最后才是调大上限。
九、排查工具
- NMT:
jcmd <pid> VM.native_memory summary看堆外使用 - 启动参数:
-XX:NativeMemoryTracking=summary - pmap:
pmap -x <pid>看进程内存分布
十、仓库地址
https://gitee.com/shun-chun-li/springboot-lettuce-direct-memory-oom