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,有需要可以找我拿。