2021 年我做一个 web 服务的后端。每个 HTTP 请求进来,都要查一下数据库。第一版我写得很直接:请求进来,connect() 建一个数据库连接,查询,然后……第一版我连 close 都忘了写。本地开发,我自己点几下页面,一切正常。压测,QPS 不高,也正常。可一上线,扛了一阵,数据库开始报错:Too many connections。整个服务集体 500。我登上数据库一看,连接数被打满了——几百个连接,全都显示"在用",可实际上没几个请求在跑。我才反应过来:我忘了 close,每个请求建的连接,用完都没还,全泄漏了。我赶紧补上 close。补完,Too many connections 不报了,但新问题来了:服务变慢了,尤其是高峰期,每个请求都肉眼可见地卡一下。我抓了下,发现慢在 connect() 这一步——每个请求都要现场建一个新连接,而建一个数据库连接,根本不是"连一下"那么轻:它要走一次完整的 TCP 三次握手,然后是数据库的身份认证、权限校验、会话初始化……这一套下来,几十毫秒就没了。我每个请求,都在重新付一遍这个开销。我盯着这两个问题——连接泄漏、和建连接太慢——想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"数据库连接,用的时候连一下就行,随用随建,没什么成本"。可它两头都错了:一头,建立一个连接很贵,你不该每个请求都重新建;另一头,数据库能同时承受的连接数有一个硬上限,你不能无节制地建。这两件事合起来,逼出了唯一正确的思路:连接不该"随用随建、用完就扔",而该被预先建好一批、循环复用——用的时候借一个,用完了还回去,而不是关掉。这,就是连接池。我以为它不过是"建一个数组放连接",结果真做下来,坑一个接一个。这篇文章就把它梳理一遍:为什么每个请求新建连接行不通、为什么共用一个连接也不行、连接池的本质是什么、怎么实现借还机制、借不到连接怎么超时、连接坏了怎么检测回收,以及池大小怎么定、连接泄漏、优雅关闭这些把连接池真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个 web 服务,每个请求都现场 connect() 一个新数据库连接。忘记 close 时,连接泄漏,数据库连接数被打满,报 Too many connections,服务集体 500。补上 close 后,又发现每个请求都卡在建连接这一步,高峰期明显变慢。
我当时的错误认知:"数据库连接是个轻量的东西,用的时候 connect() 一下、用完 close() 掉就行,随用随建没什么成本。"
真相:建立一个数据库连接很贵——TCP 握手加认证加会话初始化,要几十毫秒;而且数据库能同时承受的连接数有硬上限,无节制地建会把它打满。正确的做法是连接池:服务启动时预先建好一批连接,放在池子里;请求来了从池里借一个,用完还回去(不是 close),让这批连接被成千上万个请求循环复用。但连接池远不止"一个数组"那么简单:借不到连接要超时、连接会失效需要体检、用完忘了还就是泄漏——每一个都是坑。
要把连接池做对,需要几块认知:
- 为什么每个请求新建连接,既慢又会把数据库连接数打满;
- 为什么所有请求共用一个连接也行不通;
- 连接池的本质——预建一批连接,借了还、还了再借;
- 借不到连接要超时、连接失效要体检回收;
- 池大小怎么定、连接泄漏怎么防、服务下线怎么优雅关闭。
一、为什么每个请求新建连接行不通
先把这件最根本的事钉死:建立一个数据库连接是个"重"操作,而数据库能同时持有的连接数有硬上限;你不能每个请求都新建一个,也不能无节制地建。
下面这段代码,就是我那个会把数据库连接数打满的第一版——它每个请求新建一个连接,而且忘了关:
import db_driver # 假想的数据库驱动
def query_naive(sql: str):
# 反面教材:每个请求都现场建一个新连接,而且……忘了关。
conn = db_driver.connect(host="localhost", user="app", db="shop")
cur = conn.cursor()
cur.execute(sql)
return cur.fetchall()
# 两个问题叠在一起:
# 1. 函数返回了,conn 却没 close —— 这个连接【泄漏】了,
# 数据库那头它还占着,QPS 一高,连接数被打满,
# 数据库报 "Too many connections",服务集体 500。
# 2. 就算记得 close,每个请求都重新 connect 一次 ——
# TCP 握手 + 认证 + 会话初始化,几十毫秒,白白重付。
这段代码没有语法错误,处理一两个请求时也完全正确。它的问题是一个错误的成本观:它默认"连接随手可得、用完即弃没代价"。可真相是,连接既贵又稀缺。意识到"不能每个都新建"之后,我的下一个念头是:那就反过来,只建一个,所有请求共用它?
# 反面教材 2:那就建【一个】全局连接,所有请求共用它?
_global_conn = db_driver.connect(host="localhost", user="app", db="shop")
def query_shared(sql: str):
cur = _global_conn.cursor()
cur.execute(sql)
return cur.fetchall()
# 问题:一个连接【同一时刻只能跑一个查询】。
# 多个请求并发进来,挤着用这一个连接,要么互相
# 串了数据、要么驱动直接抛错。而且这个连接一旦断了,
# 整个服务就【全军覆没】—— 没有任何后备。
这个"共用一个"的方案,走向了另一个极端,同样不行。一个数据库连接,同一时刻只能服务一个查询——它是个"独占"资源。让成百上千个并发请求挤一个连接,轻则查询互相串扰,重则驱动直接报错;而且这唯一的连接一旦断了,整个服务没有任何后备。所以问题的根子清楚了:"每请求一个"是太多,"全局一个"是太少。正确的数量,在两者之间——预先建好不多不少的一批,大家轮流复用。
二、连接池的本质:借了还,还了再借
上一节的死结是:连接不能每请求一个(太多),也不能全局一个(太少)。连接池(Connection Pool)的破局点就一句话:预先建好"一批"连接,养在一个池子里,让所有请求轮流借用。
它的运作方式,和现实里共享单车几乎一模一样。城市不会给每个人配一辆车(那是"每请求一个"),也不会全城只放一辆车(那是"全局一个");它会预先投放一批车,你要用,就借一辆,骑完还回去,让下一个人接着骑。连接池就是这个道理:服务启动时,一次性建好比如 10 个连接,放进池子;请求来了,从池子里借一个;用完,把它还回池子。
这里有一个最关键、也最容易想错的点:用完连接,是"还",不是"关"。如果你用完 close() 掉它,那池子就少一个连接,十个请求过后池子就空了,"复用"无从谈起。"还"的意思是,这个连接原封不动地放回池子,它和数据库之间那条已经建好的链路依然活着,下一个请求借到它,不用再付握手和认证的代价——这正是连接池省下那几十毫秒的地方。这套"借了还、还了再借"的循环,就解决了第一节的全部矛盾:连接总数固定可控(不会打满数据库),建连接的昂贵开销只在启动时付一次(每个请求都不用重付)。理解了这个,剩下的就是工程问题——第一个问题是:这个"池子",用什么装?
三、最简连接池:一个装着连接的队列
"池子"该用什么数据结构?答案是队列——更准确说,一个线程安全的队列。因为多个请求会并发地来借、来还,这个池子本身必须能扛住并发。Python 的 queue.Queue 天生线程安全,正好。
import queue
class ConnectionPool:
"""最简连接池:开服时预先建好一批连接,放进一个队列里。"""
def __init__(self, size: int = 10):
self._pool = queue.Queue(maxsize=size)
# 关键:连接是【开服时一次性建好】的,不是请求来了才建。
for _ in range(size):
self._pool.put(self._new_conn())
def _new_conn(self):
return db_driver.connect(host="localhost", user="app", db="shop")
def borrow(self):
# 借一个连接:从队列里取出来。队列空了说明连接都被借走了。
return self._pool.get()
def give_back(self, conn):
# 还一个连接:放回队列。注意是【放回】,不是 close。
self._pool.put(conn)
这个最简版本,已经抓住了连接池的骨架:__init__ 里那个 for 循环,在服务启动时就把 10 个连接全建好塞进队列——这是连接池"预建"的体现;borrow 从队列取出一个,give_back 把它放回,这是"借还"。但它有一个致命的天真:borrow 里那个 self._pool.get(),如果池子空了(10 个连接全被借走),它会无限期地阻塞在那里傻等。这,就是下一节要补的洞。
四、借不到连接怎么办:超时,而不是无限等
上一节的洞是:池子借空时,get() 会无限期傻等。设想一个高峰期:10 个连接全被借出去了,第 11 个请求来借——它会卡死在 borrow 里。如果前 10 个请求迟迟不还(比如某个慢查询卡住了),这第 11 个请求就会永远挂在那里,它占用的线程、内存,全泄漏了。
修补的思路很明确:借连接,要给一个耐心的上限——等,可以,但最多等这么久;等过了,就果断放弃、报错,绝不无限期空等。queue.Queue 的 get() 正好支持 timeout 参数:
def borrow_with_timeout(self, timeout: float = 3.0):
"""借连接,但最多等 timeout 秒 —— 绝不无限期空等。"""
try:
# Queue.get 的 timeout:池里没有空闲连接时,最多等这么久。
return self._pool.get(timeout=timeout)
except queue.Empty:
# 等满 timeout 还没等到 —— 说明连接全被占着、且迟迟不还。
# 这时要果断报错,而不是把这个请求永远挂住。
raise RuntimeError("连接池耗尽:暂时借不到数据库连接")
这个 timeout,是连接池面对压力时的"保险丝"。它的意义不只是"不卡死",更是一种快速失败:当连接池真的扛不住了,与其让成千上万个请求默默挂死、把整个服务拖垮,不如明明白白地对一部分请求说"现在很忙,请稍后"——让它们快速失败、快速重试,保住整体的不崩溃。借连接的等待问题解决了。但还有一个更隐蔽的问题:你借到的那个连接,它真的还能用吗?
五、连接坏了怎么办:健康检查与回收
上一节末尾那个问题,是连接池里最阴险的坑。池子里的连接,是很久以前(服务启动时)建好的,之后就一直静静躺在池子里。可在它"躺着"的这段时间里,它和数据库之间的链路可能早就断了:数据库为了回收资源,会主动踢掉长时间空闲的连接;网络可能抖动过;数据库可能重启过。于是你从池子里借出一个看起来好端端、实际已经是死的连接,拿去查询,当场报错。
解法是:连接借出去之前,先给它做个体检——拿它去跑一句最轻量的查询(通常是 SELECT 1),跑得通,说明还活着;跑不通,说明已经死了。
def _is_alive(conn) -> bool:
"""检查一个连接是不是还活着 —— 拿它跑一句最轻的查询试试。"""
try:
cur = conn.cursor()
cur.execute("SELECT 1") # 能跑通,说明连接还正常
cur.fetchone()
return True
except Exception:
# 跑不通 —— 连接已经断了(被数据库踢了、网络抖了、超时了)
return False
有了体检,borrow 就要升级:借出一个连接后,先体检;体检通过才交给业务用;体检没过,这个死连接既不能用、也不能还回池子(还回去下一个人借到还是死的),必须就地丢弃,并补建一个新的顶上,让池子的总数保持不变。
def borrow_checked(self, timeout: float = 3.0):
"""借连接前先体检:借到死连接,就丢掉、补一个新的。"""
conn = self.borrow_with_timeout(timeout)
if _is_alive(conn):
return conn # 连接是好的,直接用
# 连接已经坏了:这个废连接不能再用,也不能还回池里。
try:
conn.close() # 尽量关掉它,释放数据库那头的资源
except Exception:
pass
# 当场补建一个新的还给调用方,池子的总数不变。
return self._new_conn()
到这里,连接池已经能预建复用、能超时、能自愈坏连接了。主干基本成型。但要把它真正用在生产上,还有几个绕不开的工程坑,其中第一个,正是我那次事故的元凶——连接忘了还。
六、工程坑:连接泄漏、池大小与优雅关闭
连接池的主干通了,但有几个工程坑,不处理就会在生产上出事。
坑 1:借了不还,就是连接泄漏——必须用 with 兜住。这是我那次事故的根源。如果借了连接,业务代码中途抛了异常,直接跳过了 give_back,这个连接就再也回不到池子了。漏掉几个,池子就空了,服务卡死。靠人记得写 give_back 是靠不住的——要用 Python 的上下文管理器(with 语法),把"还"这个动作焊死在 finally 里:
from contextlib import contextmanager
@contextmanager
def borrowed(pool: ConnectionPool, timeout: float = 3.0):
"""用 with 借连接:无论业务正常结束还是抛异常,都保证连接被还回。"""
conn = pool.borrow_checked(timeout)
try:
yield conn
finally:
# finally 保证:哪怕 with 块里抛了异常,
# 这一步也一定执行 —— 连接【一定】会还回池里,杜绝泄漏。
pool.give_back(conn)
有了 borrowed,业务代码就干净了。对比第一节那个会泄漏、会变慢的 query_naive,改造后的查询既不新建连接、也不可能漏还:
pool = ConnectionPool(size=10)
def query_with_pool(sql: str):
"""改造后的查询:从池里借连接,用完自动归还。"""
with borrowed(pool) as conn:
cur = conn.cursor()
cur.execute(sql)
return cur.fetchall()
# 对比第一节的 query_naive:连接不再每次新建、也不会泄漏。
# 开服时建好的那 10 个连接,被成千上万个请求循环复用。
坑 2:池子大小是个权衡,不是越大越好。池子设太小,高峰期连接不够借,请求大量超时;设太大,又会反向打满数据库的连接上限——你把"每请求新建"的错误,换成了"池子开太大"的同款错误。池大小要同时看两头:不能超过数据库允许的连接上限(还要给别的服务留份额),也要够覆盖你的并发量。它没有标准答案,得拿真实压测去定。坑 3:连接和事务是绑定的。一个事务的若干条 SQL,必须在同一个连接上执行——你不能查一半把连接还了、下一半又借一个新连接。所以一个事务,要从头到尾借用同一个连接,事务提交或回滚之后,才能把连接还回池子。坑 4:服务下线要优雅关闭。进程退出时,池子里那些连接不能就那么扔下——要挨个真正 close(),通知数据库释放那头的资源,干净退出:
def close_all(self):
"""服务下线时:把池里所有连接挨个真正关闭,干净退出。"""
while True:
try:
conn = self._pool.get_nowait()
except queue.Empty:
break # 池空了,全部关完
try:
conn.close()
except Exception:
pass
下面这张图,把一次"从池里借连接、用完归还"的完整路径串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 每请求新建连接 | 建连接要走 TCP 握手加认证,几十毫秒,每个请求重付一遍,很慢 |
| 连接泄漏 | 连接用完不归还,数据库连接数被打满,报 Too many connections |
| 连接数硬上限 | 数据库能同时承受的连接数有上限,不能无节制地建连接 |
| 共用一个连接 | 一个连接同一时刻只能跑一个查询,多请求并发会串数据或报错 |
| 连接池的本质 | 开服时预建一批连接循环复用,用时借、用完还,而不是关掉 |
| 借还机制 | 借连接是从队列取出,还连接是放回队列,绝不是 close 掉 |
| 借不到要超时 | 池耗尽时借连接要带超时,等不到就快速失败报错,绝不无限空等 |
| 连接健康检查 | 借出前用 SELECT 1 体检,死连接要丢弃补新的,不能用也不能还回 |
| with 保证归还 | 用上下文管理器借连接,finally 保证业务抛异常时连接也一定归还 |
| 优雅关闭 | 服务下线时把池里连接挨个真正 close,通知数据库释放资源 |
避坑清单
- 别每个请求都新建数据库连接,建连接要走 TCP 握手加认证有几十毫秒开销,每次重付高峰期明显变慢。
- 连接用完必须归还,忘了归还就是连接泄漏,数据库连接数被打满后报 Too many connections,服务集体挂掉。
- 数据库能同时承受的连接数有硬上限,不能靠无节制新建连接来扛并发。
- 别让所有请求共用一个全局连接,一个连接同一时刻只能跑一个查询,并发会串数据或直接报错。
- 连接池的本质是开服时预建一批连接循环复用,用的时候借、用完还回去,而不是关掉。
- 还连接是把它放回池子不是 close 掉,close 掉池子就少一个,十几个请求后池子就空了。
- 池里连接借光时,借连接要带超时,等不到就果断报错快速失败,绝不能把请求无限期挂住。
- 连接可能被数据库踢掉或网络抖断,借出前要用 SELECT 1 体检,死连接要丢弃并补建新的顶上。
- 借连接一定要用 with 上下文管理器,靠 finally 保证业务抛异常时连接也一定被归还。
- 池大小要按数据库上限和并发量权衡不是越大越好,服务下线时要把池里连接挨个真正关闭。
总结
回头看那次"连接数被打满、服务集体 500"的事故,以及我后来在连接池上接连踩的坑,最该记住的不是某一段队列代码,而是我动手前那个想当然的判断——"数据库连接,用的时候连一下就行,随用随建没什么成本"。这句话错在它把数据库连接,当成了一个像局部变量一样廉价、可以随手 new、随手扔的东西。可它根本不是。一个数据库连接,是一条横跨网络、经过认证、维护着会话状态的真实链路——它建起来昂贵(那几十毫秒的握手认证),它占着稀缺资源(数据库连接数的硬上限),它本身还会老化失效(空闲太久被踢掉)。连接池想清楚的,正是这件事:对于这样一种又贵、又稀缺、又会坏的资源,你不能"用时即建、用完即弃",你必须把它当成需要被精心管理的资产——预先备好一批,循环复用,小心地借、确保地还。
所以做连接池,真正的工程量不在"建一个队列装连接"那个一目了然的主干上。那个主干,十几行代码就能写完。真正的工程量,在于你有没有把那一连串"万一"都想到、并堵上:万一池子被借空了,第 11 个请求怎么办?——所以要有超时。万一借到的连接早就断了呢?——所以要有健康检查和回收补建。万一业务代码抛异常、跳过了归还呢?——所以要有 with 把归还焊死在 finally 里。万一池子开太大反而压垮了数据库呢?——所以池大小要压测着定。万一进程退出时一池子连接没人管呢?——所以要有优雅关闭。这篇文章的几节,其实就是顺着这一串"万一"展开的:每一节,都是在修补上一节留下的那个新洞。
你会发现,连接池的思路,和现实里管理任何一种"贵且有限"的共享资源,完全相通。一个公司不会给每个员工配一辆专车(那是"每请求一个",太奢侈),也不会全公司就一辆车大家排队(那是"全局一个",太低效);它会备一个不大不小的车队,谁出差谁登记借用,回来登记归还。借的时候,如果车全出去了,你等一会儿可以,但不能请假在车库门口等一整天(这是超时)。借到一辆车,你得先打火试试,别开一辆电瓶亏了的车上路(这是健康检查)。最重要的是,你出差回来,无论顺利还是出了岔子,那辆车都必须还回车队——不能因为你这趟不顺,车就停在外面再也不回来了(这是 finally 里的归还)。连接池所有的复杂,归根到底,都是在把"管好一批贵重的共享资源"这件事,一个缝隙都不留地做对。
最后想说,连接池做没做扎实,差距永远不会在开发期暴露——开发时你自己点几下页面,QPS 低得可怜,有没有连接池、池子调得对不对,功能跑起来一模一样。它只在真实的、被高并发流量持续冲刷的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,某天高峰期被告警叫醒——数据库 Too many connections,服务集体 500,你登上去一看,几百个连接全是泄漏出去回不来的死连接;或者每个请求都慢那么几十毫秒,用户在抱怨"你们网站怎么这么卡",你查了半天才发现是每个请求都在重新建连接。而做对了,它会安安静静地、不被任何人注意地,让你启动时建好的那一小批连接,被成千上万个请求从容地借走、又归还,数据库那头的连接数稳稳地、纹丝不动。所以别等连接打满的告警找上门,在你写下第一行"查一下数据库"的代码时就该想清楚:这个连接,我是每次新建,还是从池里借?借了,我一定还得回去吗?借到一个坏的,我认得出吗?池子借空了,我会无限傻等吗?这几个问题都有了答案,你的服务才不只是开发库里那个点一下就响应的样子,而是一个无论流量怎么冲,都能用一池子有限的连接稳稳扛住的可靠系统。
—— 别看了 · 2026