Redis连接池泄漏那次,我差点把生产环境搞崩了

0 阅读1分钟

1. 凌晨3点的告警:TPS从2000跌到200

那是一次大促前的压测,我们的订单服务在流量涨到平时3倍时突然“哮喘”——TPS从2000暴跌到200,Redis平均响应时间突破2秒。更诡异的是,重启服务后一切正常,但半小时后必然复现。
你猜这时候运维在群里甩了什么截图?Redis客户端的连接数从默认的8个暴涨到1000+,而监控显示实际活跃请求只有几十。

2. 为什么连接会泄漏?

先看一段典型的“错误示范”。这是我们项目里一个“人畜无害”的Redis工具类:

public class RedisUtils {
    private static JedisPool pool = new JedisPool("redis-prod"); // 全局唯一连接池

    public static String get(String key) {
        Jedis jedis = pool.getResource(); // 关键点:这里借了连接
        try {
            return jedis.get(key);
        } finally {
            if (jedis != null) {
                jedis.close(); // 坑在这里!
            }
        }
    }
}
  • 致命细节在于jedis.close()的语义*。你以为它真会关闭连接?实际上:
  • 健康连接:调用close()会把连接返还给池
  • 异常连接(比如网络闪断):close()会直接销毁连接,而池子会补充新连接

当你的网络不稳定时,每次异常都会导致池子里的连接越来越少,最终触发池子的补偿机制疯狂创建新连接——这就是我们看到的1000+连接的真相。

3. 正确的关闭方式:用try-with-resources

Java 7+的解决方案是让资源自己决定生死:

public static String getFixed(String key) {
    try (Jedis jedis = pool.getResource()) { // 自动调用close()
        return jedis.get(key);
    }
}

Jedis实现了AutoCloseable接口,它的close()逻辑是:

  1. 如果当前连接健康:标记为“可用”并归还给池
  2. 如果连接异常:默默记录metric后销毁
  • 这才是连接池的标准用法*——让资源对象自己处理生命周期,而不是手动干预。

4. 数据对比:泄漏 vs 修复

我们用JMeter模拟了当时的场景:

场景连接数峰值平均响应时间错误率
未修复(手动close)12432100ms23%
修复后1228ms0%

最离谱的是,旧代码在持续压测30分钟后,Redis的TCP端口被耗尽,彻底拒绝新连接——这就是为什么重启能暂时恢复。

5. 避坑清单:连接池的隐藏规则

  1. 不要复用连接对象:比如把Jedis实例放到ThreadLocal里。连接池的借还机制依赖close()调用,复用会导致计数混乱
  2. 警惕“默默重试”:某些客户端在连接异常时会自动重试,如果没设超时,可能挂起整个线程池
  3. 监控必须加:至少采集active|idle|waiting连接数,像HikariCP的getMetrics()就能暴露泄漏
  4. 注意DNS缓存:连接池里的长连接可能缓存DNS解析结果,极端情况下会导致全集群流量倾斜

6. 最后一道防线:池子参数怎么调

如果你的服务真遇到连接泄漏,这两个参数能帮你顶住第一波:

# 控制池子最大扩容速度(默认无限制)
redis.pool.maxTotal=50
# 借连接时的阻塞超时(默认永远等待)
redis.pool.maxWaitMillis=3000

但记住——参数只能缓解症状,代码才是根治方案。

那次事故后,我在团队Wiki上加了一条铁律:所有资源申请必须用try-with-resources,PR里看到手动close()直接打回。

你有遇到过更诡异的连接池问题吗?评论区聊聊你的“战斗伤痕”。