数据库心跳检测机制详解:自动清理僵尸连接的配置实战
2026/9/9 22:22:02 网站建设 项目流程

又是一个喜闻乐见的数据库连环拷问题。前两天有个刚转岗做运维的小朋友问我:客户端程序不按套路出牌,连一没处理完就崩了,数据库还傻乎乎地给它占着会话、锁着资源,这种情况到底怎么收拾?我反问他一个问题:你觉得数据库有没有可能自己知道你崩了?他愣了半天,憋出一句“应该……有心跳吧?”。对,就是这个心跳。但这里面的门道远不止一个参数那么简单。

先说结论:数据库确实有类似“心跳检测”的机制,用来发现那些客户端已经消失、但会话还赖在服务器上的“僵尸连接”。不同数据库的实现思路不一样,有主动探测的、有被动超时的、还有靠操作系统TCP保活兜底的。但问题在于,很多团队根本不会去调这些默认参数,导致僵尸连接长期占着连接数、占着锁,最后在某个业务高峰把整个数据库拖到无法连接。这篇文章就把这套机制从头到尾讲透,包括原理、配置、排查命令和那些文档里不会写的坑。

1. 会话是怎么变成“僵尸连接”的

1.1 先看一次正常会话是怎么结束的

要搞清楚异常退出,得先知道正常流程长什么样。一个客户端程序连上数据库,大体上经历这么几个步骤:建立网络连接、完成身份认证、开启一个会话(Session)、执行SQL、提交或回滚事务,最后正常断开。

在Oracle里,客户端正常退出时,OCI库会向服务器端发送一个断开消息,服务端收到后会主动释放会话资源;在MySQL里,客户端调用mysql_close的时候会发送COM_QUIT命令,服务端随即把连接资源归还给线程池,同时会话相关的临时表、锁、游标全部释放。这个“我走了”的信号,就是题目里说的EXIT——虽然各家协议的叫法不一样,但本质都是客户端主动告诉服务器:连接可以关了。

真正的麻烦在于,客户端程序崩了。进程异常退出、被kill -9干掉了、虚拟机突然宕机、网络专线闪断,这些场景下客户端根本来不及发送任何“我走了”的消息。从数据库的视角看,它只知道连接建立过、验证通过过、跑过几条SQL,然后就再也没有音讯了。

1.2 为什么TCP没有提前通知数据库

有人会问,客户端进程都没了,TCP不是应该立刻发RST或者FIN过去吗?答案是:不一定。

如果是正常kill掉一个进程,操作系统接管socket关闭,会发送FIN给对端。但如果是整台机器断电、崩溃,或者中间路由器掉链子,TCP连接就会一直挂在ESTABLISHED状态,因为没有对端消息,也没有超时触发。数据库服务器看到的是一个“半死不活”的socket:既不读数据、也不发数据,但谁也说不准它到底是暂停了还是彻底没了。

在这种状态下,会话就变成了“死而不僵”的僵尸连接:数据库内核里它有正常的会话ID、有进程信息、占用内存和锁,甚至某些情况下还能阻塞其他会话的访问。而这类连接如果只是偶尔出现一两根,影响不大;一旦高并发服务发生大规模崩溃,几十上百个僵尸连接一拥而入,直接把连接数干满,整个库就对外“假死”了。

1.3 僵尸连接到底吃掉了什么资源

顺着上面的思路,我们盘点一下一条僵尸连接实际占用的成本。

首先是连接数。这个最直观。Oracle的processes参数、MySQL的max_connections、PostgreSQL的max_connections,都有上限。连接数打满之后,新的应用请求哪怕再正常也进不来,错误日志里全是“Too many connections”。生产事故中很多连接数打满的案例,根因就是前一天晚上程序批量崩溃,留下了大量僵尸会话。

其次是内存。每个会话在数据库服务端都有一块私有内存。Oracle有个东西叫PGA,MySQL里每个连接也要分配线程栈、排序缓冲、网络缓冲,PostgreSQL是每连接一个进程,开销更大。量一上来,这部分的浪费就不是小数目。

第三个是。如果崩溃的时候正好有一个事务没提交,它持有的行锁、表锁就不会释放。轻则几个前台页面转圈,重则影响核心表的所有更新操作,甚至引发锁等待链式反应。

最后还有撤销数据。事务没提交、连接没断的情况下,undo信息(MySQL里叫undo log,Oracle里叫undo segment)会一直被保留,文件只增不减,磁盘被吃光也只是时间问题。

这三样加起来,你就明白了:清理僵尸连接不只是“洁癖”问题,而是实打实的数据库稳定性治理。

2. 数据库怎么发现“人已经跑了”:心跳检测机制拆解

2.1 站在TCP层面的第一道防线:Keepalive

讲到心跳检测,首先要说到TCP协议自带的一个机制——TCP Keepalive。它很简单,就是操作系统定期往对方发一个空包,如果对面没回应,多次重试后就把连接断开。

问题在于,Linux系统默认情况下这个机制是关闭的,或者参数设得很保守。默认的tcp_keepalive_time一般是7200秒,也就是两个小时才探测一次;如果客户端没响应,之后还要隔75秒再探测一次,连续9次失败才算连接死亡。也就是说,一个僵尸连接在最坏情况下能存活两个多小时才被系统兜底踢掉。

这个时间太长,在生产环境根本指望不上。所以大多数时候,我们需要在更高层做更快的主动检测。

2.2 数据库自身的“查岗”机制:三种主流方案

这里就是题目里说的“心跳检测”的核心了。数据库层面的心跳检测,本质上就是服务器主动去探测那个闲置连接到底还有没有活着。各家实现方式不太一样。

Oracle:SQLNet.EXPIRE_TIME

Oracle在这块的设计非常经典。它不是在程序运行期要求客户端定时发心跳包,而是服务端定期对空闲连接发送一个探测报文。只要这个连接还通着,客户端系统就会回一个响应;如果客户端已经彻底消失,探测就没有回音,Oracle会等待一段时间后主动把会话标注为SNIPED(被剪断),然后释放资源。

这个机制在Oracle里对应的参数就是SQLNET.EXPIRE_TIME。它配置在数据库服务器的sqlnet.ora文件里,单位是分钟。比如设为10,表示Oracle每隔10分钟对所有“空闲超过一定时间”的连接做一次探测。如果探测失败,连接会在稍后被清除。

MySQL:wait_timeout + 网络读/写超时

MySQL的思路和Oracle不太一样,它主要靠超时时间来判断。最核心的是wait_timeout,它表示一个非交互连接在空闲多少秒后,服务器主动把它关闭。默认值通常是8小时(28800秒),这对很多生产系统来说实在太长了。interactive_timeout则是针对命令行这种交互式会话的超时。

另外MySQL还有net_read_timeoutnet_write_timeout,分别控制服务器从客户端读取数据、向客户端写入数据时的等待时间。如果连接断了而MySQL正等着客户端发数据,net_read_timeout能在超时后触发断开。

PostgreSQL:TCP保活参数 + idle_in_transaction_session_timeout

PostgreSQL通过两个层面来清理僵尸连接:一个是把TCP Keepalive的调参权限暴露给了数据库,即tcp_keepalives_idletcp_keepalives_intervaltcp_keepalives_count;另一个是idle_in_transaction_session_timeout,专门对付那种事务开了但不干活、一直攥着锁不放的连接。

2.3 心跳检测的两种模式:主动探测 vs 被动超时

把上面的机制抽象一下,其实心跳检测就两种流派。

主动探测派以Oracle的SQLNet.EXPIRE_TIME为代表,服务器主动发消息问“你还活着吗”,没回应就回收。它的优点是能比较快地发现僵尸连接,缺点是需要协议栈支持,配置不当可能对网络产生额外心跳流量。

被动超时派以MySQL的wait_timeout为代表,服务器不主动问,而是盯着一根秒表——你空闲超过设定值我就赶人。它的优点是实现简单,缺点是它无法区分“正常的长连接空闲”和“已经死了的僵尸连接”,所以超时值不能设太小,否则会让大量正常连接频繁断开重连,反而引发连接风暴。

生产环境里最稳妥的做法是两种模式配合:TCP keepalive负责兜底,数据库超时负责清理长期空闲,连接池负责维持合理的活跃连接数。

3. 实操:把僵尸连接自动回收的机制真正配置起来

3.1 Oracle侧:配置SQLNET.EXPIRE_TIME

在Oracle的环境里,设置这个参数非常简单。找到数据库服务器上的sqlnet.ora文件,一般在$ORACLE_HOME/network/admin目录下,添加一行:

SQLNET.EXPIRE_TIME = 10

这里10表示10分钟。设置之后需要重启监听器才能让新连接生效:

lsnrctl reload

注意,这个参数并不会对已经建立的连接生效,一定是新建立的连接才带入新的配置,所以上线这个参数一般选在维护窗口做。

修改后怎么验证有没有生效?有一种很直观的方式就是模拟一个僵尸会话出来。你开着SQL*Plus连上数据库,然后直接把客户端机器的网线拔了或者把客户端进程kill -9,过10分钟再去查询:

SELECT sid, serial#, username, status, last_call_et FROM v$session WHERE username IS NOT NULL;

正常且长期空闲的会话,status显示为INACTIVE;被SQLNET.EXPIRE_TIME揪出来的会话,会被标记为SNIPED。要注意,被标记为SNIPED之后,Oracle不会立刻释放掉所有资源,但任何客户端尝试通过这个会话再发新SQL时,服务器会直接报错并回收它。如果想更激进一点,可以配合profile里的IDLE_TIME做二级兜底。

坦白说,SQLNET.EXPIRE_TIME是Oracle DBA手里最好用的防僵尸连接大招,没有之一。我给客户做数据库巡检的时候,第一件事就是检查这个参数有没有设置,很多跑了多年的库居然还是默认的0——也就是说,完全没启用服务端探测。

3.2 MySQL侧:wait_timeout和连接池配合

MySQL的默认wait_timeout是28800秒,也就是8小时。这个值对绝大多数OLTP系统来说太保守了。很多团队会把它调整到600秒左右(10分钟)甚至更低。

修改方式有两种,一种是动态修改全局参数:

SET GLOBAL wait_timeout = 600; SET GLOBAL interactive_timeout = 600;

需要注意,全局参数修改之后,已经存在的会话不生效,需要新建的连接才会用新值。如果要彻底固化,还得去my.cnf里加上:

[mysqld] wait_timeout = 600 interactive_timeout = 600 max_connections = 500

同时MySQL每连接一个线程,还需要注意thread_cache_size不要设太小,这样连接断开后线程可以复用,避免频繁创建线程带来的性能抖动。

光靠MySQL自己的超时还不够,因为wait_timeout对“正在执行但客户端已死”的查询无能为力。这时候需要net_read_timeoutnet_write_timeout配合,建议均设置为30秒左右。遇到客户端网络异常、大查询防写入半途无响应的情况,MySQL能主动断开连接,不会让线程一直吊着。

应用侧连接池也要配合。如果你用的是HikariCP,可以这样配置:

spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 validation-timeout: 3000

HikariCP默认会在获取连接时做一下连接存活性检查。生产环境我一般推荐把validation-timeout调小,把连接存活检查SQL默认的SELECT 1保留,这样即使数据库已经把空闲连接回收了,连接池也能及时感知,而不是把失效连接发给业务线程去踩坑。

3.3 PostgreSQL侧:TCP保活与事务超时的组合拳

PostgreSQL的参数通常在postgresql.conf里。主要关注这几个:

tcp_keepalives_idle = 60 tcp_keepalives_interval = 10 tcp_keepalives_count = 6 idle_in_transaction_session_timeout = 30000

前三项是控制TCP保活的,单位是秒,含义是:连接空闲60秒后开始探测,每10秒探一次,连续6次无响应就断开。idle_in_transaction_session_timeout单位是毫秒,这里设的是30秒,意思是开启事务但一直不提交/回滚的会话最多存活30秒。这个参数非常有用,因为它能直接把那些“开了事务就忘了关”的连接清掉,释放掉事务持有的锁。

有些团队担心idle_in_transaction_session_timeout设得太小会影响长事务场景。确实,如果你的业务里有合法的大事务——比如大批量数据加工任务,跑几分钟很正常——那建议不要设这个参数,或者设成一个足够大且不影响日常的值。更好的做法是单独为这些任务修改所在会话的idle_in_transaction_session_timeout,比如连完数据库后先SET一下:

SET SESSION idle_in_transaction_session_timeout = 0;

3.4 连接池侧:防止“回收风暴”的细节

连接池如果配置不合理,即使数据库侧已经把僵尸连接清了,应用也可能反而出问题。最常见的就是三个坑:连接池最大连接数设置过大、最小空闲连接数设置过高、连接空闲时间太长。

比如一个系统,max_connections打到500,但业务的真实并发只有50,那么数据库侧只要配合wait_timeout一清,大量连接会被断开;等业务请求一来,连接池发现空闲连接不够用,拼命创建新连接,瞬间把数据库的线程和CPU打满。这种场景叫“连接风暴”,我在客户现场见过不止一次。

所以连接池的minimum-idle不要设得太高,保持和真实的平均并发量匹配就足够了。HikariCP文档里其实也没有“一定要设多少”的说法,正确姿势是压测出业务的高峰并发,再在峰值数据上留20%到30%的余量。

4. 手动排查与清理:DBA的救命三板斧

4.1 找出僵尸会话:这才是问题的起点

就算上面这些自动机制都配置好了,总有赶不上趟的时候。比如某厂商中间件半夜崩溃,连着几百个会话集体断头,但数据库的超时参数又设得特别长,这时候只能手动救火。

先学会找到僵尸会话。不同数据库查询方式不一样。

Oracle查v$session视图,重点是status和last_call_et:

SELECT s.sid, s.serial#, s.username, s.status, s.last_call_et, s.program, s.machine FROM v$session s WHERE s.username IS NOT NULL AND s.status = 'INACTIVE' AND s.last_call_et > 600 ORDER BY s.last_call_et DESC;

last_call_et表示这个会话最后一次执行操作到现在过去了多少秒。如果一个INACTIVE的会话last_call_et非常大,比如超过600秒,那么它极有可能就是僵尸连接。

MySQL里直接看threads列表:

SHOW FULL PROCESSLIST;

重点筛选Command为Sleep、Time大于一个阈值的连接:

SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command = 'Sleep' AND time > 600;

PostgreSQL查pg_stat_activity:

SELECT pid, usename, state, query, now() - state_change AS idle_time FROM pg_stat_activity WHERE state = 'idle' AND now() - state_change > interval '10 minutes';

4.2 安全清理的先后顺序

排查到僵尸会话后,清理不能上来就KILL,我给一个推荐的顺序。

第一步,先确认会话身上有没有正在跑的活动事务。不是说INACTIVE或Sleep状态就一定没有事务,MySQL里Sleep状态的连接可能已经把事务打开了,只是还没有提交,这种情况杀掉会造成事务回滚,影响写入量大的系统。

第二步,观察几分钟,确认它确实长时间没有活动。如果程序只是响应慢、SQL卡住了,它显示的可能是Query/Active状态,而不是僵尸连接。

第三步,执行清理。Oracle里我习惯先标记会话为SNIPED,再回头观察,确认彻底没反应了才杀:

ALTER SYSTEM KILL SESSION 'sid,serial#' IMMEDIATE;

MySQL杀连接:

KILL 12345;

PostgreSQL终止后台会话:

SELECT pg_terminate_backend(12345);

需要特别注意,杀会话是一个破坏性操作。如果有正在执行的长事务,强行KILL会导致部分或全部事务回滚,业务侧需要具备重试机制。所以在清理之前,最好和业务负责人对一下有没有正在跑的任务,避免误伤。

4.3 三个我在现场反复见到的翻车操作

先把最容易踩的坑列一下,这些可都是真金白银换来的教训。

第一个坑,把连接池保活连接当成了僵尸连接。有些连接池比如Druid默认会周期性发送testWhileIdle的探测SQL,从数据库侧看这些连接永远在活跃或者刚活跃过,但如果你只看Time大于600秒就去杀,很可能杀掉连接池正在复用的健康连接。判断的时候一定要结合program/host字段看一眼,确认是应用服务器还是纯外部客户端。

第二个坑,杀完连接没有处理应用侧的重连逻辑。数据库好不容易把僵尸连接清干净,应用却还抱着旧连接不放,连接池也没配置重连校验,业务依然报错。所以清理数据库之前,先确认应用侧连接池有没有开启连接有效性检查,否则等于白杀。

第三个坑,杀得太猛。我曾经见过有运维同事写了个脚本,把Sleep超过60秒的全杀了,结果很多应用的长轮询连接和消息队列消费连接全被干掉,引发大面积重连,数据库负载瞬间爆表。清理僵尸连接要“温柔”,先杀那些持续时间特别长的,再逐步收紧阈值,千万别一把梭。

5. 问题速查与配置上的经验之谈

5.1 心跳检测与僵尸连接问题排查速查表

现象可能原因推荐操作
连接数被打满,全是Sleep/INACTIVE会话当前超时参数过大,僵尸连接没有被及时回收调小wait_timeout / 设置SQLNET.EXPIRE_TIME,同时查应用连接池是否合理
事务不提交,锁一直不释放客户端进程崩溃时事务未回滚靠idle_in_transaction_session_timeout或手动杀会话,先确认事务来源
程序重启后频繁报“连接被重置”连接池保存了已经被数据库回收的连接开启连接池的testOnBorrow/validation检查,配置最大生存期
网络闪断后,会话半夜才被清掉依赖系统默认的TCP Keepalive(2小时)调短tcp_keepalives_idle等参数,在数据库层做主动探测
杀会话反而引发连接风暴手动清理范围过大,应用重建连接太猛分批清理,观察数据库负载再继续,配合连接池下限调整

5.2 几个真正值得抄走的配置经验

分场景来说。开发测试环境,所有超时参数都可以调得很短,wait_timeout设60秒都没有问题,目的就是让资源尽快释放。生产OLTP系统,我习惯把wait_timeout设置在600秒左右,tpcc压力测试场景下也验证过不会引发连接抖动。

Oracle生产环境的SQLNET.EXPIRE_TIME,我给的默认建议值是10分钟。再小的话,比如1分钟,会频繁发探测包,网络和CPU的额外开销比较大,收益并不明显。之前的实践数据是,10分钟的探测频率对应用完全无感知,但对僵尸连接的回收效率已经足够。

PostgreSQL方面,tcp_keepalives_idle建议60秒起步,idle_in_transaction_session_timeout根据业务确认,常规OMS系统30秒就能满足,有长事务的系统单独豁免。

还要提一个日常运维习惯:把僵尸连接的监控做成巡检项。Oracle用v$session的状态统计,MySQL用information_schema.processlist里Sleep会话的数量,PostgreSQL看pg_stat_activity里idle的连接数。每天跑一次,超过阈值就告警,这样就不会出现“僵尸连接堆积了一个月才发现、一上线就被打满”的惨剧。

5.3 意外收获:把“会话清理”纳入变更复盘

最后说一个我在实战中摸索出的习惯。每次处理完这类僵尸连接问题,我都会把当天的时间线、数据库参数、应用侧日志、连接池配置全部贴到变更记录里,然后拉上开发和运维一起过一遍:为什么程序会崩溃?为什么崩溃后没有触发客户端的清理逻辑?为什么数据库没有在第一时间识别到异常?

很多时候,僵尸连接只是结果,根因是程序的异常处理没有做好、连接池参数没有配合好、数据库心跳参数没有开启。这三层只要漏了一层,其他层再努力也可能白搭。这也是为什么我始终强调:心跳检测不是某一个参数的事,而是一整套从操作系统、数据库到连接池的协同机制。

哪怕你现在没有在生产环境遇到僵尸连接的问题,也建议你登录一台测试库,把客户端进程kill掉,然后观察一下数据库需要多久才能发现这条“死”连接,再对照本文把参数逐项调整一遍。等真出了事故,练过和没练过,反应速度完全不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询