MySQL连接泄漏:那些年我发现的一个隐藏了五年的Bug

2026-06-10 9 0

MySQL连接泄漏:那些年我发现的一个隐藏了五年的Bug

事情是这样的。上个月,一个朋友找我帮忙,说他们的服务每隔几天就会报"Too many connections"错误重启一次。很标准的MySQL问题对吧?很多人第一反应就是去调max_connections,从151改到1024,再到2048。治标不治本,过几天又爆了。

我上去一看代码——Python服务,用的是pymysql连接池,连接池大小是50。听起来很合理对吧?50个连接,够用了。

然后我打开监控面板看了一眼,实际连接数——800多。

50的池,800多的连接?这里面肯定有什么不对劲。

连接池的谎言

很多人以为连接池是银弹。配置一个池,往里拿连接,用完还回去,齐活。Too simple。

但现实是,连接池只是管理连接的健康状态,它不是魔法。当你从池里拿走一个连接,然后业务抛异常了——这个连接会怎样?

答案是:它可能永远回不去了。

# 典型的有问题代码
def get_user(user_id):
    conn = pool.get_connection()
    user = conn.execute("SELECT * FROM users WHERE id = %s", user_id)
    conn.close()  # 问题来了:如果上面抛异常,这行根本不执行
    return user

很多人会说,我用了try-finally,肯定没问题。但你真的检查过你所有的代码路径吗?异步上下文呢?信号处理呢?Gevent的monkey patch呢?

这些边角case,90%的开发者从来没仔细想过。

真正的Bug在哪里

我朋友的代码里,有一个看起来完全无害的函数:

def fetch_with_retry(sql, params, retries=3):
    for i in range(retries):
        try:
            conn = get_connection()
            result = conn.execute(sql, params)
            conn.close()
            return result
        except OperationalError as e:
            if i == retries - 1:
                raise
            time.sleep(0.1 * (i + 1))

猜猜问题在哪?重试逻辑。MySQL在遇到某些错误(比如死锁超时)时会自动断开连接,但你的代码不知道它已经断了。当重试时,你从池里拿到的可能是一个已经坏掉的连接。

更糟糕的是,这个坏连接在还回池之前不会被清理。它就躺在池里,占着一个坑,下一个人拿到,继续失败。

这就是经典的连接池污染

我见过的三种经典泄漏模式

1. 异常吞掉连接

try:
    conn = pool.get_connection()
    # 业务代码
except Exception as e:
    logger.error(e)  # 吞掉异常,什么都不管
finally:
    pass  # 连接永远没还

这是最常见的。异常被捕获了,但连接没释放。很多人觉得"反正有异常,业务不走了,还回去干嘛"——错。异常情况下更应该还回去,或者标记为不可用。

2. 上下文管理器形同虚设

with pool.get_connection() as conn:
    # 某次循环里continue了
    if condition:
        continue  # 跳过了with块?No,with只执行一次
    conn.execute(sql)

Python的with语句不是循环的每一次都会执行。它只在进入with块时执行一次。所以如果你在with内部循环并continue,连接依然会被正确释放——但很多人误解了这一点,然后在with外面又写了一遍逻辑。

3. 异步环境下的幽灵连接

@app.task
def async_task():
    conn = pool.get_connection()
    # 做了一些IO等待
    #突然收到信号,进程要退出了
    # 连接被强制关闭,但还在池的计数里

异步 + 信号处理 = 连接泄漏重灾区。Celery的worker收到shutdown信号时,正在执行的任务可能根本来不及清理连接。

怎么修?

核心原则只有一条:连接的生命周期必须无懈可击

方案一:上下文管理器强制执行

from contextlib import contextmanager

@contextmanager
def safe_connection(pool):
    conn = pool.get_connection()
    try:
        yield conn
    finally:
        conn.close()  # 无论异常还是正常,一定执行

# 使用
with safe_connection(pool) as conn:
    result = conn.execute(sql)

方案二:连接健康检查

class HealthCheckedPool:
    def get_connection(self):
        conn = self.pool.get_connection()
        try:
            conn.execute("SELECT 1")
        except Exception:
            conn = self.pool._create_connection()  # 重建连接
        return conn

方案三:监控连接池状态

# 每分钟检查
if pool.size() != len(pool._available):
    logger.warning(
        f"连接池不平衡!总连接数: {pool.size()}, "
        f"可用连接数: {len(pool._available)}, "
        f"差异: {pool.size() - len(pool._available)}"
    )

工具推荐

如果你用Django,django.db.connection默认有连接管理,基本不会泄漏。但如果你用原始pymysql或者SQLAlchemy,请务必装上dbmaster——它可以监控你所有活动的连接,并且支持自动回收泄漏的连接。

如果你用Go,那边的连接池管理更显式,但也更容易忘记defer rows.Close()。一个经验法则:只要你的代码涉及查询结果集,关闭它比查询本身更重要

最后说两句

连接泄漏这个问题,说大不大,说小不小。服务少的时候不明显,等你用户量上来——凌晨三点报警,运维打电话,紧急扩容,重启服务,完事第二天继续。

但其实只要在代码里多做一步检查,一个监控,一个上下文管理器,就能省掉无数个凌晨三点的加班电话。

技术债这种东西,要么早点还,要么迟早连本带利一起还。你选哪个?


附:我整理了一个连接池调试的checklist,有需要可以找我拿。

相关文章

我与OpenClaw:从青铜到王者的踩坑手记
RESTful API 设计:我踩过的那些坑,顺便救了你一命
RESTful API 设计:我踩过的那些坑,顺便救了你一命
RESTful API 错误处理完全指南:让错误信息不再恶心开发者
为什么你的API设计是一坨屎,以及如何修复它
你以为TCP连接还活着?它可能早就偷偷死了

发布评论