2022 年我做一个 Web 服务的数据访问层,每个进来的请求都要查一两次数据库。怎么拿到一条数据库连接?这件事我没多想,就有了方案:用的时候连,用完就关。第一版我做得很顺手——查询函数的开头 connect 一下,拿到连接,执行 SQL,函数返回前 close 掉,干净利落。本地开发环境我点了几十次接口,每次都查得又快又准,连接开了又关,毫无破绽。我心里很笃定:连接这东西用完就关,不留后患,这套数据访问层稳了。可等这个服务真正上线,扛起生产的并发流量,一串问题冒了出来。第一种最先把我打懵:接口偶发性变慢,而且慢得很怪——慢在"还没开始查数据"的那一段。第二种最难缠:流量高峰一来,数据库直接报 too many connections,大批新请求连数据库都连不上。第三种最头疼:我登上数据库一看,几百条连接挂在那里 sleep,什么都不干,可连接数就是降不下来。第四种最莫名其妙:我加了个连接池想救场,结果压测时一部分请求卡在那里很久,最后超时——池子明明开着,它们却拿不到连接。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为数据库连接是一种"想要就有、用完即弃"的廉价资源,connect 一下就好,close 一下就没。可一条数据库连接,从来都不廉价。建立它,要走一次 TCP 三次握手,再加一轮数据库的身份认证,这一套下来的耗时,常常比那条 SQL 本身的执行还久。而维持它,要占数据库的内存和文件描述符,数据库能同时撑住的连接数有一个写死的硬上限。"每次查询新建一条连接",意味着每个请求都白白付一次昂贵的握手成本,还意味着并发一高,连接数就会直冲数据库的上限把它顶垮。要把数据访问层做扎实,根上的办法只有一个:不要每次都新建连接,而要预先建好一批连接放进一个池子里,循环地借出、归还、复用。本文从头梳理:为什么每次新建连接又慢又危险,连接池到底在做什么,为什么连接借了必须还,池里的连接为什么会坏掉、又该怎么发现,池子大小到底该设多大,以及一些把它做扎实要避开的工程坑。
问题背景
先把现象说清楚。一个 Web 服务处理请求,几乎总要和数据库打交道。代码要执行 SQL,前提是手里得有一条"连接"——一条已经和数据库服务器建立好、且通过了认证的通信通道。最直觉的写法,就是把"建连接"和"用连接"绑在一起:要查询了,就建一条;查完了,就关掉。这种写法在单机、低并发、本地开发的环境里完全看不出毛病,所以极易让人误以为它是对的。
错误认知是:连接是廉价的、即取即用的,建立和销毁的成本可以忽略。真相是:建立一条连接是一个有明确成本的重操作,而数据库能同时维持的连接总数有硬上限。这两条真相一旦撞上真实并发,就会同时引发这样几类问题:
- 握手成本拖慢每个请求:每次查询都重新建连接,意味着每个请求都要先付一次 TCP 握手加认证的时间,这段时间花在"还没开始查数据"上。
- 连接数顶满数据库:并发请求一多,同时存在的连接数逼近数据库的 max_connections,新请求直接被拒,报 too many connections。
- 连接泄漏:某条代码路径忘了 close,或异常时跳过了 close,连接就一直挂着不释放,表现为数据库里一堆 sleep 连接降不下去。
- 资源耗尽的连锁反应:连接占着数据库的内存和文件描述符,泄漏累积到一定程度,整个数据库都会变慢甚至不可用。
连接池要解决的,正是这一组问题。它的思路一句话能说清:把"建连接"和"用连接"拆开——连接预先建好、放进池子,请求只是来"借"和"还",从不自己建、自己关。下面六节,从这个思路出发,一步步把它落到能在生产里稳住的程度。
一、为什么"每次查询新建连接"又慢又危险
要理解第一版为什么不行,得先看清一条连接的成本构成。当代码执行"连接数据库"时,底层发生的事不止一件:先是和数据库服务器完成 TCP 三次握手,建立一条网络通道;然后是数据库协议层的认证握手——发送用户名密码、协商字符集、交换能力标志。这一整套,是好几个网络往返。在同机房内也要几毫秒,跨网络则可能几十毫秒。而很多线上 SQL,本身只需要零点几毫秒就能查完。也就是说,"每次新建连接"的写法里,绝大部分时间花在了建连接上,真正查数据反而是零头。
# 反面教材:每次查询都新建连接,用完就关
import pymysql
DB_CONF = dict(host="10.0.0.5", user="app", password="...",
database="shop", charset="utf8mb4")
def get_user(user_id: int) -> dict:
# 每次进来都付一次完整的握手与认证成本
conn = pymysql.connect(**DB_CONF)
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM users WHERE id=%s", (user_id,))
return cur.fetchone()
finally:
conn.close() # 用完立刻关掉,下次再来又得重新建
# 单个请求看不出问题,但每秒上千请求时:
# 1) 每个请求都白付一次握手成本,接口 P99 被拖高
# 2) 同一瞬间在途的连接数 = 并发请求数,很容易顶满数据库
"危险"则来自第二条真相:数据库的连接数有硬上限。每条连接在数据库侧都要分配一块内存(用于会话状态、排序缓冲等),还要占一个文件描述符。所以数据库有一个 max_connections 参数,写死了它最多同时接受多少条连接。当你"每次查询新建连接"时,某一瞬间存在的连接数,几乎等于那一瞬间的并发请求数。流量平峰时也许两三百,看着没事;可一旦促销、一旦被刷,并发冲到几千,连接数就会直接撞上 max_connections。撞上之后,不是变慢,是新连接被数据库干脆利落地拒绝——报 too many connections,连"排队"的机会都没有。
这一节要建立的认知是:连接是一种昂贵且数量有限的资源,而"每次查询新建连接"这种写法,恰恰把它当成了廉价且无限的来用。它在两个维度上同时犯错:在时间维度上,让每个请求重复支付一笔本可以只付一次的握手成本;在数量维度上,让在途连接数随并发线性飙升,毫无节制地冲向数据库的硬上限。这两个错,本质是同一个错——没有把"连接的生命周期"和"请求的生命周期"解耦。请求是高频、短命、数量不可控的;连接应该是低频创建、长寿、数量被严格控制的。把这两者绑死,就是第一版所有问题的总根源。后面五节做的事,归结起来就是把它们解开。
二、连接池的本质:预建一批连接循环复用
把连接的生命周期和请求解耦,落到具体做法,就是连接池。它的结构非常朴素:进程启动时,预先建好一批连接(比如 10 条),放进一个容器里。此后,任何请求要用连接,不是去"建",而是去池子里"借"一条;用完之后,不是去"关",而是把它"还"回池子。连接被还回去之后是完好的、已认证的,下一个请求借到它可以直接用。这样,握手认证的成本,在进程的整个生命周期里只在创建时付一次,之后无数次查询都是零成本复用。
# 一个最小可用的连接池:预建连接,借出与归还
import queue
import pymysql
class ConnectionPool:
def __init__(self, conf: dict, size: int = 10):
self._conf = conf
self._size = size
# 用一个线程安全的队列存放空闲连接
self._idle = queue.Queue(maxsize=size)
# 进程启动时一次性把连接建好放进池子
for _ in range(size):
self._idle.put(self._new_conn())
def _new_conn(self):
return pymysql.connect(**self._conf)
def acquire(self, timeout: float = 5.0):
# 借:从空闲队列取一条;队列空了就最多等 timeout 秒
return self._idle.get(timeout=timeout)
def release(self, conn):
# 还:把连接放回空闲队列,而不是 close 掉
self._idle.put(conn)
pool = ConnectionPool(DB_CONF, size=10)
def get_user(user_id: int) -> dict:
conn = pool.acquire()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM users WHERE id=%s", (user_id,))
return cur.fetchone()
finally:
pool.release(conn) # 关键:还回池子,不是 close
这个最小实现已经能体现连接池的两个核心价值。第一个是复用,前面说过了。第二个常被忽略但同样重要:限流。注意池子的容量是固定的 size 条。当这 10 条都被借走,第 11 个请求调 acquire 时,队列是空的,它会在那里等待,而不是去新建一条。这意味着,无论上层涌进来多少并发请求,真正打到数据库的连接数,永远不会超过 size。连接池就像一个闸门,把不可控的请求并发,收敛成了一个可控的、有上限的数据库连接数。
这一节的认知是:连接池表面上是个"省钱"的优化(省掉重复握手),但它更深层的作用是"限流"——它给数据库连接数加了一道你说了算的硬闸门。第一版最危险的地方,是数据库连接数这个关键指标完全失控,跟着外部流量随波逐流。引入连接池后,这个指标被你牢牢攥在手里:它的上限就是你配置的池子大小,一个写在配置文件里、你完全掌控的数字。复用带来的性能提升是看得见的、容易被认可的;而限流带来的稳定性,平时看不见,却是在流量洪峰真正到来时,决定数据库是被顶垮还是被保住的那道防线。
三、连接一定要还回池子:用上下文管理器防泄漏
连接池引入了一条新的、必须严格遵守的纪律:借了就一定要还。在第一版"用完就关"的写法里,就算忘了 close,操作系统和数据库迟早会回收那条连接,顶多算泄漏得慢。但在连接池里,忘了 release 的后果要严重得多:那条连接永远回不到池子,池子里可用的连接就少了一条。借出去不还的连接攒够 size 条,池子就彻底空了,之后所有请求 acquire 时都会一直等到超时——整个服务对数据库的访问全面瘫痪。
而"忘记还"在真实代码里太容易发生了:函数中间 return、抛异常、有多个出口分支,任何一条没覆盖到 release 的路径都是泄漏点。靠人记得在每个出口都写 release,是靠不住的。正确做法是用语言机制保证"借了一定还"——把 acquire 和 release 包进一个上下文管理器,让 with 块的退出动作来兜底归还。
# 用上下文管理器把"借了一定还"变成语言层面的保证
from contextlib import contextmanager
class ConnectionPool:
# ... 省略 __init__ / _new_conn / acquire / release ...
@contextmanager
def connection(self, timeout: float = 5.0):
conn = self.acquire(timeout=timeout)
try:
yield conn
finally:
# 无论 with 块内是正常结束、return 还是抛异常,
# finally 都会执行,连接一定被还回池子
self.release(conn)
# 调用方再也不用手写 release,也就没有了忘记还的可能
def get_user(user_id: int) -> dict:
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT * FROM users WHERE id=%s", (user_id,))
return cur.fetchone()
def transfer(from_id: int, to_id: int, amount: int):
with pool.connection() as conn:
with conn.cursor() as cur:
# 即使这中间抛异常,连接也会被 finally 还回池子
cur.execute("UPDATE acct SET bal=bal-%s WHERE id=%s",
(amount, from_id))
cur.execute("UPDATE acct SET bal=bal+%s WHERE id=%s",
(amount, to_id))
conn.commit()
这一节的认知是:在连接池模式下,"归还"不是一个可选的好习惯,而是维持池子不枯竭的强制纪律,因此它必须由机制来保证,而不能依赖人的自觉。这是一个普遍的工程原则:凡是"必须成对出现"的操作——开和关、借和还、加锁和解锁、申请和释放——都不该把配对的责任交给程序员的记忆力,因为代码会有异常、有多分支、会被后人修改,人的记忆覆盖不全所有路径。正确的做法,是用语言提供的、能保证"块退出时必定执行"的机制(Python 的 with、try-finally,其他语言的 RAII、defer)把它兜起来。一旦用了上下文管理器,"连接泄漏"这种 bug 就从"很容易犯、很难查"变成了"几乎不可能犯"——因为调用方根本没有手动管理连接生命周期的机会。
四、池子里的连接会坏掉:借出前做存活校验
到这里,连接池能复用、能限流、不泄漏了。但还有一个隐蔽的问题:池子里那些"空闲"的连接,并不会永远保持可用。一条连接长时间没人用,可能已经悄悄坏掉了——数据库侧有个 wait_timeout,空闲超过这个时间,数据库会主动把连接断开;网络设备、负载均衡也可能掐掉长时间没有流量的 TCP 连接;数据库要是重启过一次,池子里所有的旧连接就全成了废纸。问题在于,连接坏掉是"静默"的:它在池子里看起来还是一条连接,直到某个请求借走它、拿它去执行 SQL,才会突然报错。
# 借出连接前先校验存活,坏连接就丢弃并补一条新的
class ConnectionPool:
# ... 省略其他方法 ...
def _is_alive(self, conn) -> bool:
try:
# ping 会发一个轻量探测包;reconnect=False 表示
# 只检测、不自动重连,坏了就如实抛错
conn.ping(reconnect=False)
return True
except Exception:
return False
def acquire(self, timeout: float = 5.0):
deadline_conn = self._idle.get(timeout=timeout)
# 借出前体检:活着就用,坏了就关掉、换一条新的
if self._is_alive(deadline_conn):
return deadline_conn
try:
deadline_conn.close()
except Exception:
pass
return self._new_conn()
def release(self, conn):
# 归还时如果连接已是坏的,就别放回池子污染它
if self._is_alive(conn):
self._idle.put(conn)
else:
try:
conn.close()
except Exception:
pass
self._idle.put(self._new_conn())
这里的校验时机值得说一下。最稳妥的是"借出前校验"——保证交到业务代码手里的一定是条活连接。代价是每次 acquire 多一个探测包的往返。如果对延迟极敏感,可以改成"按空闲时长校验":连接还回池子时记下时间戳,只有借出时发现它空闲超过某个阈值(比如比 wait_timeout 略小)才校验,没超过就直接信任。这是一个在"绝对可靠"和"低延迟"之间的权衡。
这一节的认知是:连接池里的连接不是一放进去就永久可靠的资产,它是一种会因为外部世界变化(超时、断网、数据库重启)而悄悄失效的东西。第一版"每次新建"的写法,反而歪打正着地不存在这个问题——因为它用的永远是刚建的、必然新鲜的连接。连接池用"复用"换来了性能,就必须同时承担"复用一个可能已经过期的东西"的风险,并主动管理它。所以一个成熟的连接池,一定有一套连接健康管理:借出前或归还时校验存活,坏的连接果断丢弃重建,绝不让一条坏连接在池子里继续待着、等下一个倒霉的请求来踩。这层校验,是连接池从"demo 能跑"到"生产能用"之间,最容易被漏掉的一段。
五、池子大小到底该设多大
连接池绕不开的一个问题:size 设多少。很多人下意识觉得"越大越好,大了能扛更多并发"。这是个错觉。池子太大,等于把限流的闸门开得很宽,大量连接同时压到数据库上,数据库会因为过多并发查询而争抢 CPU、内存、锁,整体反而更慢——而且池子大小一旦超过数据库的 max_connections,又会退化回 too many connections。池子太小,则会让请求大量排队:连接全被借走,后来的请求卡在 acquire 上等待,接口延迟飙升,等到超时还会直接失败。
# 池子大小:可配置,并对"借不到连接"的情况显式处理
import time
class PoolConfig:
def __init__(self):
# 经验起点:并非越大越好,需结合 DB 能力与压测调整
self.size = 16
# 借连接的最长等待;超时就快速失败,不要无限等
self.acquire_timeout = 3.0
class ConnectionPool:
def __init__(self, conf: dict, cfg: PoolConfig):
self._cfg = cfg
self._idle = queue.Queue(maxsize=cfg.size)
# ... 预建 cfg.size 条连接 ...
def acquire(self):
start = time.monotonic()
try:
conn = self._idle.get(timeout=self._cfg.acquire_timeout)
except queue.Empty:
waited = time.monotonic() - start
# 借不到连接是重要信号:要么池子偏小,要么有泄漏
raise RuntimeError(
f"连接池耗尽,等待 {waited:.1f}s 仍无空闲连接"
)
return conn
那到底设多少?没有一个适用所有场景的数字,但有可参考的原则。池子大小要同时受三个约束:一是不能超过数据库 max_connections 给单个应用实例分到的份额(如果有 N 个应用实例共用一个数据库,每个实例的池子大小之和要留在 max_connections 之内,还要留一部分给运维和监控);二是要匹配数据库实际的并行处理能力——对很多场景,一个不大的、能让 CPU 跑满又不过度争抢的并发数,往往比一个很大的数效果更好;三是要结合你的请求特征压测得出。下面这张表把这几个约束的关系列清楚:
连接池大小的约束关系
约束来源 含义 调整方向
----------------------------------------------------------
DB max_connections 所有实例池大小之和的硬上限 不可越过,留余量
应用实例数 N 每实例池大小 ≈ 份额 / N 实例越多每池越小
DB 并行处理能力 过大反而加剧 CPU 与锁争抢 压测找拐点
请求持有连接时长 持有越久,同并发下需要的池越大 优化慢查询更划算
acquire 超时 借不到的等待上限 宁可快速失败
经验做法:从一个偏小的值起步(如 CPU 核数的 2 到 4 倍),
压测观察 acquire 等待时间与 DB 负载,再逐步调整。
这一节的认知是:连接池大小不是一个"配得越大越安全"的参数,而是一个需要在"请求排队"和"数据库过载"两种坏情况之间求平衡的权衡值。把它配得很大,只是把压力从应用侧(请求排队)转移到了数据库侧(并发过载),问题没有消失,只是换了个地方爆发,而且数据库被压垮的后果比请求排队严重得多。还要认清一件事:如果发现池子总是不够用,第一反应不该是"调大池子",而该是"为什么连接被持有这么久"——往往是某些慢查询、或者把不该放在事务里的耗时操作放进了事务,导致连接被长时间占住。优化掉这些,比盲目调大池子根本得多。池子大小这个数字,从来都该是压测出来的,不是拍脑袋拍出来的。
把借连接、校验、排队、超时这几件事串起来,一个请求向连接池要连接的完整决策过程,就是下面这张图:
[mermaid]
flowchart TD
A[请求需要一条连接 调用 acquire] --> B{池里有空闲连接吗}
B -->|有| C[取出一条空闲连接]
B -->|没有 全被借走| D[在队列上等待 最多 acquire 超时时间]
D -->|等到了有人归还| C
D -->|超时仍无| E[抛错 连接池耗尽 请求快速失败]
C --> F{借出前校验连接是否存活}
F -->|存活| G[交给业务代码执行 SQL]
F -->|已坏| H[丢弃坏连接 新建一条补上]
H --> G
G --> I[业务用完 归还连接回池子]
六、把连接池做扎实,要避开的工程坑
前面五节搭出了一个能复用、能限流、不泄漏、会校验、大小合理的连接池。但要在生产里长期稳住,还有几个坑得专门讲。第一个,也是最该做的:给连接池本身加监控。连接池的几个内部指标——空闲连接数、借出连接数、acquire 的平均等待时间、acquire 超时的次数——是数据库出问题前最早的预警信号。等数据库报警了才发现,往往已经晚了。
# 给连接池加上可观测的指标:出问题前先看到征兆
import threading
class PoolMetrics:
def __init__(self):
self._lock = threading.Lock()
self.acquired = 0 # 当前借出未还的连接数
self.acquire_wait_total = 0.0 # 累计等待耗时
self.acquire_count = 0
self.acquire_timeout = 0 # 借不到而超时的次数
def on_acquire(self, wait_seconds: float):
with self._lock:
self.acquired += 1
self.acquire_count += 1
self.acquire_wait_total += wait_seconds
def on_release(self):
with self._lock:
self.acquired -= 1
def on_timeout(self):
with self._lock:
self.acquire_timeout += 1
def snapshot(self) -> dict:
with self._lock:
avg = (self.acquire_wait_total / self.acquire_count
if self.acquire_count else 0.0)
# acquired 持续接近池子上限、avg 持续上涨,
# 就是"池子要不够用了"的明确征兆
return {"acquired": self.acquired,
"avg_wait": round(avg, 4),
"timeout": self.acquire_timeout}
第二个坑是连接泄漏的兜底排查。第三节用上下文管理器基本堵死了泄漏,但万一有人绕过它、直接调 acquire 而忘了 release,还是会漏。可以给每条借出的连接记一个"借出时间戳",后台定时扫描:如果一条连接被借出去的时间长得离谱(远超任何正常请求该有的时长),就大概率是泄漏,记日志告警,把问题暴露出来。
# 后台巡检:揪出借出时间过长的"疑似泄漏"连接
import time, threading
class LeakDetector:
def __init__(self, pool, max_hold_seconds: float = 60.0):
self._pool = pool
self._max_hold = max_hold_seconds
# conn_id -> 借出时间戳
self._borrowed = {}
self._lock = threading.Lock()
def mark_acquire(self, conn):
with self._lock:
self._borrowed[id(conn)] = time.monotonic()
def mark_release(self, conn):
with self._lock:
self._borrowed.pop(id(conn), None)
def scan(self):
now = time.monotonic()
with self._lock:
for cid, t in list(self._borrowed.items()):
held = now - t
if held > self._max_hold:
# 借出超过阈值仍未归还,基本可判定为泄漏
print(f"[泄漏告警] conn={cid} 已借出 {held:.0f}s 未还")
def start_leak_scan(detector: LeakDetector, interval: float = 30.0):
def loop():
while True:
time.sleep(interval)
detector.scan()
threading.Thread(target=loop, daemon=True).start()
还有几个坑值得简单点一下。其一,长事务会长时间占住连接,所以事务里不要夹杂调用外部接口、读写文件这类耗时操作,事务该短则短。其二,多个应用实例共用一个数据库时,务必把"所有实例的池子大小之和"算清楚,别让它越过 max_connections。其三,空闲连接也别一直留着不管,可以让池子在低峰期回收一部分长期空闲的连接,只保留一个最小常驻数量,既省数据库资源,又能在流量回升时快速补齐。这几个坑串起来是同一个意思:连接池不是"创建好就不用管"的东西,它是一个有生命、有状态、需要持续观测和维护的子系统。它的健康度——连接够不够用、有没有泄漏、连接新不新鲜——必须是被监控、被告警、被定期巡检的,而不是等数据库出事了再回头猜是不是连接池的问题。
关键概念速查
| 概念 | 说明 |
|---|---|
| 连接建立成本 | 一次 TCP 三次握手加数据库认证握手,常比 SQL 本身执行还慢 |
| max_connections | 数据库能同时维持的连接数硬上限,超过即拒绝新连接 |
| too many connections | 连接数顶满 max_connections 时数据库返回的拒绝错误 |
| 连接池 | 预建一批连接循环复用的组件,把建连接与用连接的生命周期解耦 |
| 复用 | 连接池的第一重价值:握手认证只在创建时付一次,之后零成本复用 |
| 限流 | 连接池的第二重价值:池子大小即数据库连接数的可控硬上限 |
| 连接泄漏 | 借出的连接忘记归还,池中可用连接逐渐枯竭,最终全面阻塞 |
| 存活校验 | 借出或归还时探测连接是否仍可用,坏连接丢弃重建 |
| acquire 超时 | 池中无空闲连接时的最长等待,超时即快速失败而非无限等 |
| 池大小权衡 | 过大压垮数据库,过小请求排队,需结合 DB 能力压测确定 |
避坑清单
- 不要每次查询新建连接:每个请求都白付一次握手成本,并发一高就顶满数据库连接数。
- 不要把连接生命周期和请求绑死:连接应低频创建、长寿复用,请求只负责借和还。
- 不要手写 release:用上下文管理器或 try-finally 保证"借了一定还",杜绝泄漏。
- 不要相信池里的空闲连接永远可用:超时、断网、数据库重启都会让它静默坏掉。
- 不要把坏连接放回池子:归还时校验存活,坏的关掉并补一条新的。
- 不要觉得池子越大越好:过大只是把压力从请求排队转移到数据库过载。
- 不要让池大小超过 max_connections:多实例共用数据库时,所有池大小之和要留余量。
- 不要无限等待连接:acquire 设超时,借不到就快速失败,不要让请求卡死。
- 不要在事务里夹耗时操作:长事务长时间占住连接,会让池子提前枯竭。
- 不要不监控连接池:空闲数、借出数、acquire 等待时间是数据库出事前最早的预警。
总结
回头看第一版那个"用完就关"的数据访问层,它的错误很有代表性。它不在某一行写错的代码,而在一个对数据库连接的根本误解:以为连接是廉价的、即取即用、用完即弃的。真相是,连接的建立有实打实的握手成本,连接的总数有数据库写死的硬上限。把一个昂贵且有限的资源,当成廉价且无限的来用,在低并发时一切如常,在真实流量下就会同时从"慢"和"垮"两个方向出问题。
而把这件事做对,工程量并不小。它不是"引入一个连接池库"就万事大吉,而是要理解并处理一连串问题:连接要预建复用,借出必须归还且要用机制兜底,池里的连接会坏掉要校验,池子大小要在排队和过载之间权衡着压测,还要给它配上监控、泄漏巡检、空闲回收。一个能在生产里稳住的连接池,是这些环节一个都不少地拼起来的。
这件事其实很像公司里那间唯一的会议室和它的预约系统。会议室就那么几间,谁要用就去预约系统里借一个时段,用完了把这个时段释放出来,下一个人才能预约。没有人会"每次开会就新盖一间会议室开完拆掉"——那既慢又荒唐。预约系统还得处理那些麻烦事:有人借了会议室开完会忘了点结束(泄漏),系统得能发现并提醒;会议室的总数是固定的,不能超额预约(限流);预约的时段不能开得太满,否则楼里的人都在抢会议室(池大小权衡)。连接池就是数据库连接的这套预约系统。
这类问题还有一个共同的麻烦:它在本地几乎暴露不出来。你在开发机上点几十下接口,每次新建连接也好,池子配得不合理也好,都看不出任何毛病——因为本地没有成百上千的并发,连接数离数据库的上限差得很远,所有的裂缝都还没张开。真正会把这些问题撑开的,是上线后真实的并发流量。所以如果你正在写一个要连数据库的服务,别等线上报出 too many connections、别等接口在高峰期莫名变慢,才回头补连接池这一课。在它还只是个本地跑得飞快的 demo 时,就把连接的复用、归还、校验、限流这套机制搭好——这是这篇文章最想留给你的一句话。
—— 别看了 · 2026