小Cの已经记不起来的博客

深入理解 Linux 连接池 的工作机制

先说句实话,Linux 里没有“连接池”这个内核组件

很多人搜 Linux 连接池,脑子里可能觉得内核偷偷帮你缓存了一堆 TCP 连接,随用随取,贼方便。结果真去翻 man 7 tcp,翻半天没看到这个概念,挺容易怀疑人生的。

其实通常说的“连接池”,是应用层/中间件在用户态维护的一组已经建立好的 socket。比如数据库驱动、Redis 客户端、HTTP client、gRPC channel pool。Linux 内核本身不关心你的连接池策略,它只负责 socket 生命周期、TCP 状态机、缓冲区、backlog、TIME_WAIT 这些东西。

你可以这么理解:

所以别把连接池神化成 Linux 的内置能力。它更像是用户态的一套规则,只是底下踩在内核 socket 上。

连接池为什么省资源,到底省在哪

三次握手、TLS 握手、认证登录、数据库初始化会话,这些动作在本地测试时没啥感觉,但放到云数据库、跨可用区、高并发场景,就很肉疼。

比如一个 HTTP client 没连接池,每请求一次就:

socket()
connect()
send()
recv()
close()

并发一上来,内核会看到大量新建连接、TIME_WAIT、CLOSE_WAIT,客户端也可能出现端口不够用、握手超时、TLS 重复握手。这时候看一眼:

ss -s
ss -tan state time-wait

就能看到一堆东西在堆。

连接池的作用,是尽量复用 ESTABLISHED 状态的 socket,省掉反复 connect() 和握手成本。但它不是魔法,它省的是“建连成本”,不是“请求处理成本”。

还有一个容易误会的地方:复用 socket 不等于复用业务会话。

比如数据库连接池,一条连接同一时间只能处理一个请求。你不能拿着同一个 MySQL 连接并发执行两个 SQL,否则结果会串。所以池子必须有借出、归还、等待队列、超时。Linux 层只是看到一堆 fd,真正的排队发生在应用锁、channel、mutex、goroutine wait 这些地方。

服务端 Linux:accept backlog 和连接队列才是硬约束

客户端连接池可以配 100、200、500 条连接,但如果服务端 Linux 没准备好,一样白搭。

Linux TCP 收到 SYN 时,会先进入半连接队列,也就是 SYN queue。三次握手完成后,连接进入全连接队列,等待应用调用 accept() 取走。

应用代码里常见的 listen(fd, backlog),这个 backlog 会被内核限制,关键参数:

sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog

可以查看监听状态:

ss -ltn

对 listening socket,Send-QRecv-Q 经常让人懵逼。简单说:

如果你看到某个端口 Recv-Q 一直很高,说明连接已经三次握手完成,但应用没及时取走。不一定是连接池问题,可能是业务线程卡住、锁竞争严重、日志阻塞、数据库慢查询拖住了整个服务。

我一般会先这样排查:

# 客户端看目标端口连接数
ss -tan dst :3306

# 服务端看监听队列
ss -ltn src :3306

# 看内核有没有 accept 队列溢出
nstat -az | egrep 'ListenDrops|ListenOverflows'

别一上来就改 tcp_tw_reuse。有时候只是应用忙不过来,根本没 accept()

客户端连接池的几个核心参数

不同语言、不同框架名字不一样,但思路差不多。比如 Java HikariCP、Go database/sql、Python SQLAlchemy/psycopg2、Node.js 各种 client,常见参数就这些:

max_size
min_idle
max_idle
wait_timeout
idle_timeout
connection_timeout
max_lifetime

我通常把它们翻译成这么几件事:

这些参数不是越大越好。

数据库自己的资源是有限的。CPU、内存、锁、事务槽位,扛不住太多并发执行。你把连接池 max_size=1000,请求全冲进去,数据库可能先被锁打爆。很多时候池子 max_size=32,让应用层排队,反而更稳。

Linux 进程这边也有文件描述符限制:

cat /proc/sys/fs/file-max
ulimit -n
ls -l /proc/<pid>/fd | wc -l

连接池吃 fd,HTTP client 吃 fd,日志文件、临时文件、eventfd、timerfd 都吃 fd。生产环境见过服务起来没问题,流量一高峰先报 Too many open files

一个特别常见的坑:连接池里的“死连接”

这个坑太经典了。

应用日志偶尔报:

broken pipe
connection reset by peer
i/o timeout
EOF

数量不多,不好复现,重启一下又“好了”。

后来抓包看,发现某些空闲连接其实已经被中间设备、LB、防火墙、服务端提前断了,但客户端连接池还把它当成可用连接。应用从池子里拿到这个 socket,一 write(),才报错。

抓包可以看看:

tcpdump -i any -nn 'tcp port 3306 and (tcp[tcpflags] & tcp.rst != 0)'
tcpdump -i any -nn 'tcp port 3306 and tcp[tcpflags] & tcp.fin != 0'

问题通常出在几层超时不一致:

Linux 默认 keepalive 往往很慢:

sysctl net.ipv4.tcp_keepalive_time
sysctl net.ipv4.tcp_keepalive_intvl
sysctl net.ipv4.tcp_keepalive_probes

默认可能是 2 小时级别。业务等不了那么久。你不可能每个请求前都干等 2 小时发现连接死了。

所以应用层通常要做:

比如 Java HikariCP 常见配置思路:

spring.datasource.hikari.maximum-pool-size=32
spring.datasource.hikari.minimum-idle=4
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.connection-timeout=3000

max-lifetime 一般建议小于数据库/网关空闲超时。比如 LB 空闲超时 60 秒,你池子保留 5 分钟空闲连接,那基本就是在赌。

连接池、连接复用、多路复用,别混成一锅

很多文章喜欢把这几个概念揉一起,结果越看越乱。

简单拆开:

1. 连接池

保存多条可用连接,避免反复建连。HTTP/1.1 很依赖这个。

2. 非阻塞 IO / epoll

一个线程同时监听多个 fd。Linux 上就是 epoll_create/epoll_ctl/epoll_wait。这不是连接池,它只是 IO 调度方式。

3. 多路复用

一个 TCP 连接上跑多个逻辑请求,比如 HTTP/2、gRPC、MySQL 协议里的某些 pipeline?不,MySQL 通常不能同一连接并发请求。

HTTP/2 可以一个连接上开很多 stream,所以 HTTP client 不一定需要很多 TCP 连接。但 Linux 内核仍然只看到一个 TCP 连接,它不知道你的 stream 是 HTTP/2 还是业务自定义帧。

这里有个很容易忽略的点:HTTP/2 少连接,不代表不需要管理连接。TLS session resumption、GOAWAY、流控、连接迁移、最大并发流,这些都会影响池化和复用策略。

怎么判断连接池健康不健康

只看应用指标容易自欺欺人。比如 QPS 很高,延迟也不高,可能只是队列已经满了,再压一点就崩。

我一般至少看四类东西。

内核层

ss -s
nstat -az | egrep 'TcpExtListenDrops|TcpExtListenOverflows|TcpRetransSegs|TcpAttemptFails|TcpCurrEstab'

TcpRetransSegs 很高,说明网络或链路质量有问题。
ListenDrops/ListenOverflows 非零,说明全连接队列溢出或者 accept 慢。
TcpAttemptFails 很高,可能是握手失败、连接重置、目标拒绝。

进程 fd 层

lsof -p <pid> -a -i tcp
ls -l /proc/<pid>/fd | wc -l

看看是不是连接数持续增长。有些池子关闭逻辑有问题,归还到池里的连接没真正复用,或者错误连接被放回可用队列。

池子指标

最好暴露出来:

pool.active
pool.idle
pool.pending
pool.max_size
pool.wait_time
pool.create_fail
pool.close_count

如果看到:

active=8 max=8 pending=50

说明池子满了,后面请求在等。要么数据库慢,要么池子太小,要么请求放大。

时间分布

平均值最会骗人。

比如一个服务平均延迟 20ms,看起来挺健康,但 p99 是 5s。很多时候就是连接池等待、数据库锁、网络重传造成的。

连接池数量怎么配,给点不那么玄学的思路

很多文章一上来就说“根据 QPS 和 RT 算”,公式没错,但真实环境里坑更多。

粗略模型:

所需连接数 ≈ QPS × 平均请求耗时

比如:

但别忘了:

生产上我更倾向于:

  1. 先从小池子开始,比如 8、16、32;
  2. 压测时观察 pending 和数据库资源;
  3. 池子满但数据库 CPU/IO/锁不高,再适当加;
  4. 池子没满但 pending 高,先查业务是不是占连接太久;
  5. 池子满且数据库已经很高,说明问题不在客户端池子,而在依赖容量。

还有一点:多实例部署时,总连接数要算总和。

比如单机池子 32,服务有 100 个 Pod,理论最大就是 3200 条数据库连接。MySQL 默认 max_connections 往往才几百,这就可能直接把数据库打爆。

这时候不是 Linux 连接池问题,是全局容量规划没算。

TIME_WAIT 到底怕不怕

这个也经常被拿出来说。

TIME_WAIT 是 TCP 协议状态机的一部分,用来处理迟到的报文,避免旧连接数据污染新连接。Linux 客户端主动关闭连接时,会进入 TIME_WAIT。

常见现象:

ss -tan state time-wait | wc -l

看到几万条。

如果目标地址是同一个固定服务,大量出站连接反复关闭,可能耗尽本地端口:

cat /proc/sys/net/ipv4/ip_local_port_range

比如默认范围 32768-60999,也就三万多个端口。每个 TIME_WAIT 连接会占一个源端口。

这时候有几个选择:

sysctl net.ipv4.tcp_tw_reuse

tcp_tw_reuse 主要适合出站连接复用,而且要求对端支持 timestamps,不是万能药。别照着老文章开一堆内核参数,最后不知道是谁起了作用。

至于 tcp_tw_recycle,老内核时代有些场景用它,但 NAT 环境很坑,新内核基本已经移除。真别抄。

一个偏实战的连接池错误处理

拿到连接后直接写,是常见代码思路:

conn = pool.get()
try:
    do_query(conn)
finally:
    pool.put(conn)

但如果连接池里混进了死连接,do_query() 一开始就炸了。这时候应该:

更稳一点伪代码:

conn = pool.get(timeout=1)
try:
    result = do_query(conn)
    pool.put(conn)
except BrokenPipeError:
    conn.close()
    if not request_sent:
        retry_once()

难点在于:你很难判断请求到底有没有写出去。如果写出去一半,服务端执行没执行你也不知道。对非幂等请求,比如扣款、下单、写业务表,盲目重试会出大事。

连接池机制越简单,故障越容易复现;重试逻辑越多,越容易把偶发问题放大成全局问题。

别忽略应用日志和 socket 选项

Linux 下还可以调 socket 选项:

setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, ...);
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, ...);

SO_KEEPALIVE 打开后,内核会在空闲时探测连接是否还活着。但默认频率很慢,应用层通常还是要配合探活。

TCP_NODELAY 对延迟敏感系统有用,但也会让小包更多,影响吞吐。高并发短请求服务里,这个值会影响连接复用效果和内核处理成本。

另外,很多语言运行时有自己的默认池策略。Go 的 database/sql 默认 MaxIdleConns=2MaxOpenConns=unlimited,如果不看文档,真以为它默认池子很聪明,结果就是连接数失控。

最后说一句

Linux 连接池的工作机制,拆开以后没那么玄:

生产事故很少是“连接池算法完全看不懂”。更多时候是几个参数互相打架:池子太大打爆数据库,空闲太短连接不够用,空闲太长死连接一堆,backlog 太小 accept 不过来,fd 限制太低服务还没打满就报 Too many open files

排查时别急着改参数。先把 ssnstatlsoftcpdump 和应用池指标放一起看,经常一眼就知道该动客户端、服务端、中间件,还是 Linux 内核。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放