尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

连接池耗尽背后的真相:一次接口变慢的底层原理排查复盘

连接池耗尽背后的真相:一次接口变慢的底层原理排查复盘 前阵子线上有个接口隔三差五地变慢重启后又能撑一阵排查了好几天都没揪到根子。后来有人扔过来一句话“去看看数据源连接池是不是满了。”一查果然。那一刻我突然意识到自己掌握的很多“进阶技巧”——调参、加缓存、重启大法——其实都只是表象上的腾挪。真正的进阶是把每个现象背后的底层原理看穿。这篇文章想聊的就是“进阶技巧与底层原理”之间的关系以及我从那次故障之后总结出来的一套思考方式。不吹不黑适合后端开发、运维和所有靠技术吃饭的人尤其适合那些“会用很多技巧但仍然心虚”的兄弟。1. 从一次线上事故说起技巧只能救火原理才能止损1.1 现场还原接口由偶发抖动到持续超时那次事故的接口本身不复杂就是一个典型的读多写少的查询接口平时响应都在几十毫秒。第一个异常信号是监控面板上的“P99耗时爬坡”从50ms一路涨到800ms持续了大概十分钟又掉回去。当时团队的第一反应是是不是数据库慢查询写了个脚本去抓慢SQL日志抓了半天只抓到几条无关痛痒的记录又怀疑是网络抖动检查了入口网关和负载均衡的丢包率一切正常。最让人困惑的是“重启即恢复”这个特征。我们把服务滚动重启了一遍接口耗时立竿见影地回到正常。可好景不长两三个小时后P99又开始缓慢抬头。如此反复折腾了两天每次都是重启后好转、过一段时间复发典型的“治标不治本”。那时候我们用过的技巧不少调大Feign的超时时间、给Redis缓存增加随机过期时间、把Tomcat的线程池从200调到400……没有一个真正解决问题反而因为盲目调大超时让用户端的等待时间更长了。1.2 技巧与原理的分水岭在哪里后来真正点醒我的是一句很朴素的话“你们查过连接池没有”这句话之所以重要不是因为它给出了答案而是它帮我区分了技巧与原理的差别。技巧是别人总结过的快捷方式原理是快捷方式成立的条件。比如“调大超时时间”这个技巧它的适用前提是“系统还有冗余资源只是响应慢了一点点”。但当我们已经出现连接耗尽时调大超时等于让更多请求堵在门口排队队列越排越长反而加速了雪崩。再比如“重启大法”它有效的原理很简单重启会回收掉所有被占用的连接、清空内存里的垃圾对象把系统恢复到初始状态。但如果引发耗尽的根因还在比如某条SQL长时间持锁不释放或者某个连接忘记归还重启只能赢得一个短暂的时间窗口。我后来在复盘文档里画了一张因果链把现象到根因的路径拉直了看是这样接口偶发变慢某个瞬间的并发查询超出了数据库连接池的空闲连接数请求在连接池队列里排队等待等待时间被计入接口耗时等待超时抛出异常负载均衡重试重试又带来新的连接占用连接被长时间占用的核心原因个别SQL扫描行数过大执行时间从几十毫秒变成了几秒几秒的占用乘以一定的并发量连接池迅速被打满打满之后所有请求开始排队P99快速爬坡这个过程一旦走通后面所有的排查都不再是盲人摸象。技巧还是那些技巧但你已经知道每一步操作在动哪根弦。2. 连接池耗尽背后的底层机制拆解把一次故障走成一条因果链2.1 数据库连接池的“工作台”模型很多人一听到“连接池原理”就头大觉得是那种要啃源码才能懂的东西。其实完全可以用一个“工作台”模型来理解连接池就是一个服务窗口数据库是操作间每个连接就是一个工作台。请求来了要先在窗口领一个工作台才能去操作间办事工作台全被占满时新来的请求只能在等待区排队排队超过一定时间窗口就会告诉客户“今天办不了你先走吧”。这个模型里藏着所有关键参数我把它们整理成表格参数含义用工作台类比设错会怎样最小空闲连接池里常驻的可用连接数窗口平时保留的闲置台位太小会导致高峰期临时建连最大连接数池子最多能同时占用的连接数工作台总数太小排队太大打爆数据库空闲超时连接闲置多久后销毁台位闲置多久被撤掉太短会造成频繁建连开销等待超时请求在池子里最多等多久客户最多等多久太短直接失败太长堆积队列容量等待区的最大容量等待区能站多少人满了以后新的连门都进不来我那次事故的核心就是“最大连接数”和“等待超时”没有匹配上。连接池当时配置的是50个连接等待超时30秒。按理说30秒的等待时间不算短问题出在连接被占用的时候不是在做正常的数据库查询而是在等一把行锁释放。50个连接全部堵在同一张表的同一行记录上后面的请求全部排队排到30秒就抛异常。2.2 从“超时”到“线程阻塞”故障是如何扩大的连接耗尽这件事从来不会只停留在连接池这一层。连接池的等待会传染给上层线程池。每个请求在处理时都会占用一个Tomcat工作线程连接池拿不到连接时这个线程不会消失它会在那里继续等待。一个请求等30秒一万个请求就会让Tomcat线程池也迅速堆满。线程堆积到一定数量后CPU开始大量消耗在线程上下文切换上而不是在真正干活上。到了这一步监控上看CPU可能并不算高但响应时间已经完全失控。想验证这个传染过程不需要什么高级工具只要能在出事的那几分钟内抓一份线程快照。Java环境下最直接的就是jstack pid thread_dump.txt然后把线程状态统计一下grep -E java.lang.Thread.State thread_dump.txt | sort | uniq -c正常情况下RUNNABLE和WAITING会占多数。但如果看到几十上百个线程都停在连接池getConnection()的调用上或者线程状态是BLOCKED基本就可以锁死方向了大量线程在等待同一个资源这个资源要么是锁要么是连接。这比翻半天监控报表都快。这里也解释了为什么重启能暂时“治好”重启会释放所有线程和连接把排队队列清空。但行锁没释放、慢SQL没优化业务流量一旦再涌进来连接池会在几分钟到几小时内重新被打满。原理上想明白了就不会再迷信重启。3. 进阶排查手段的排序逻辑先看线程再查SQL中间隔着一个仓库3.1 定位三层入口、线程、存储很多人排查性能问题时有个通病看到什么工具学得最熟练就先甩什么工具。一会儿抓网络包一会儿看GC日志一会儿又去查慢查询看起来每一步都在干活实际是在随机试错。我自己踩过这个坑之后总结了一套固定的排查顺序先看请求入口再看工作线程最后看存储资源。为什么是这个顺序因为这正好对应一次请求的完整生命周期。请求从网关进来先被服务框架接收分发给工作线程工作线程去访问下游资源比如数据库、缓存、其他微服务。接口变慢可能卡在任意一个环节。从入口开始一层层往下排查能最快地把范围缩小。反过来的顺序问题很大比如一上来就查数据库很可能数据库一切正常真正卡在入口处的负载均衡转发上白忙一场。3.2 我常用的三个命令与输出怎么看这已经不是“会用某个命令”的层次而是知道命令的输出对应底层哪个环节。我把最常用的三个工具列成了一张自查表排查目标命令或操作主要看什么请求入口是否堆积netstat -anp | grep 端口 | wc -l连接数是否远超日常基线工作线程是否卡死jstack pid/arthas thread -n 3线程状态、堆栈顶部的等待点存储是否变慢或锁等待MySQLshow processlistPGpg_stat_activity是否有大量Waiting for table lock或长事务用这套顺序排查那次故障过程非常干净入口连接数正常但工作线程快照里大量线程停在HikariCP的getConnection方法上于是跳过所有无关环节直接看数据库发现数据库侧有十几个线程在等同一把行锁。从“入口正常”到“线程等连接”再到“行锁竞争”整个链条只花了十分钟。3.3 进阶技巧的保质期问题我见过很多同事收藏了厚厚一沓技术文章遇到问题就去翻“最佳实践”。但技巧是有保质期的。框架版本一升级连接池参数名可能变了容器环境从虚拟机迁到Kubernetesnetstat未必还能直接抓到主机网络命名空间里的连接数据库从MySQL迁移到PostgreSQL慢查询日志的格式和等待事件名称完全不同。这些变化里唯一不变的是底层的因果逻辑资源有限、请求排队、超时放弃、失败重试放大压力。所以每学一个技巧我会逼自己把背后的因果链复述一遍。比如“调大maximumPoolSize”这个技巧背后的因果链是并发请求数超过连接数时请求会进入排队排队的时延直接累加到接口耗时时。于是我可以反推一个简单公式理论上限 QPS ≈ 最大连接数 / 单请求平均数据库占用时间假设连接池50个连接每个请求占用数据库20毫秒那么撑住的理论QPS也就是2500左右。超过这个数字队列就开始增长。有了这个判断就不会再盲目地把连接数从50改成500——改成500虽然能让排队变短但数据库侧同时只能跑这么多SQL它可能根本扛不住500个并发查询。这也叫“用原理约束技巧”。4. 把底层原理前置到设计阶段限流、降级与熔断其实是一件事4.1 资源是有限的时间是唯一硬约束经过那次故障我最大的转变是不再把排查当作救火而是把原理用在设计阶段。后端系统里所有的稳定性问题剥到最里面都是同一句话有限的资源面对无限的请求总有一个环节会先满。数据库连接池会满线程池会满缓冲区会满甚至CPU排队也会满。所谓可靠性设计本质就是回答一个问题哪个资源先满系统会怎样表现我们能不能接受这个表现。很多人以为限流、降级、熔断是三套独立的中间件配置这个认知其实很危险。它们的底层逻辑是同一个——队列和超时。限流是在入口处直接拒绝一部分请求避免它们进入排队熔断是在下游频繁失败时快速放弃避免请求在下游排队等到天荒地老降级是在资源紧张时主动舍弃非核心功能把资源让给核心链路。三者的共同目标都是不让请求无限堆积。4.2 容量估算小公式从配置项推导出真实上限与其等线上告警不如提前用公式估算系统的真实容量。我在设计每个接口时至少会做一次这样的小计算单接口下游平均耗时假设是30毫秒数据库连接池最大连接数50那这个接口在数据库层面能支撑的最大并发约50 / 0.03 ≈ 1666 QPS这个1666不是精确值但它给了我一个非常重要的判断基准。如果产品预期QPS是2000我就能提前知道连接池一定不够要么拆库分流要么加缓存降低下游耗时要么限流在1500以内。而不是等系统已经卡死再去调参。这种“先算后调”的习惯就是从连接池这件事里学来的。同样地熔断阈值也应该由底层推导而不是拍脑袋设一个“失败率50%”。如果没有超时控制一个慢接口会让所有线程都在等待此时即使失败率只有10%排队线程也会拖垮整个系统。所以设计熔断阈值之前先确认你的超时时间、最大线程数、平均处理时间三者是否匹配。4.3 三个开关之间的顺序关系我见过一个系统同时把限流阈值和熔断阈值都设得很高理由是“怕误伤正常请求”。结果下游一抖动所有请求都卡在等待下游返回上线程池先满了系统整体挂掉根本没有机会触发熔断。这就是没搞清三者顺序的表现。正确的顺序设计应当是入口先限流防止超出系统容量调用下游前设好超时防止线程被一个慢请求长期占用下游连续失败时熔断快速失败并把流量转移到降级逻辑。每一条都在不同层面防止“排队堆积”少了任何一条另外两条都会因为线程耗尽而失效。这些事情如果不在设计阶段想清楚等到线上事故才去开这三个开关通常已经晚了半步。5. 保持底层原理感的日常训练法5.1 把“会用”变成“能解释”很多人问我原理这东西书也看了源码也翻了怎么遇到问题还是想不起来我的经验是知识没有被组织成语境就永远是静态的。我给自己定了一个规矩每使用一个配置项都要能回答清楚三个问题——默认值是多少、满的时候会发生什么、我为什么设成这个数。举个例子HikariCP里有个参数叫minimumIdle它的默认值和maximumPoolSize相同。很多人抄配置时把它调成很小本意是节省资源。但实际底层行为是minimumIdle控制池子维持的最小空闲连接数如果设成0池子可能在空闲一段时间后把所有连接都关掉等到下一个高峰又得全部重新建连。而建连是一个网络往返加数据库认证的过程一次高峰如果突然涌入上千请求瞬间建连几百个数据库压力反而飙升。这就是“只知道填参数、不理解参数行为”的典型代价。5.2 用复盘模板沉淀因果链而不是收藏文章我有一个习惯每处理一个线上问题都会填一份简单的复盘模板字段就只有五六个。现象、第一时间反应、可疑变量、真正根因、因果链、下次改进。这些字段里因果链是最重要的。比如“连接耗尽”如果只写根因是“慢SQL”半年后大概率忘得一干二净但如果把因果链写成“索引缺失导致SQL扫描行数过大连接占用3秒并发到达20个就把50个连接耗尽新请求排队超时”这就是一份属于自己的原理库。我建议每个技术人都建一个这样的复盘文档不需要很正式个人笔记就可以。填过三五个案例之后你会发现自己看问题的速度明显快了一截。因为在下次遇到类似故障时你不是在回忆技巧而是在匹配因果链。5.3 每天15分钟的“探源”练习最后分享一个不花钱但特别有效的训练法每天花15分钟做一次“探源练习”。这个练习不需要完整读完一个框架的源码只需要做三件事第一挑一个你每天都在用的中间件类打开源代码注释框读它头部的方法说明第二找一个真实错误栈从最上面三行开始逐层追问“这一行为什么会被调用”第三给同事讲一遍你上周排查问题的完整过程讲到讲不下去的地方往往就是你的认知盲区。我自己坚持这个习惯大概半年后排查问题的路径肉眼可见地变短了。以前看到CPU飙高会先怀疑代码死循环现在第一反应是抓线程栈看热点以前遇到接口超时会去调超时时间现在会先去问连接池和线程池各自的队列状态。并不是我背住了更多参数而是同样的信息在我脑子里被重新组织过了一遍。说起这件事我个人在实际操作中的体会是进阶技巧本身并不难学难的是给技巧找“锚点”。一个技巧如果能在你脑子里挂接到底层原理上它会自动变形出十个衍生技巧如果没有挂接那它只是一条脆弱的经验换个环境就会失效。希望这篇分享能帮你少走一点弯路少经历几次“重启再观察”的无效循环。
返回列表