ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Redis连接池报Connection closed by server?从timeout到testWhileIdle的排查与优化方案

Redis连接池报Connection closed by server?从timeout到testWhileIdle的排查与优化方案 半夜三点告警群开始刷屏核心接口的P99从80毫秒直接被拉到2秒开外。我打开日志满屏都是同一个熟悉的身影Unexpected end of stream.caused by 那句java.io.IOException: Connection closed by server。不用多猜这是Redis连接池又出问题了而且是那种最典型的Redis pool里的连接被服务端悄悄关掉客户端却还蒙在鼓里的幺蛾子。这个问题在前后端、Java/Go/Python栈里都很常见尤其是用JedisPool这类连接池的团队几乎人人都被它折腾过。网上的帖子大多停留在把testOnBorrow开一下或把timeout改大这类治标操作真正把原理、排查链路和配置组合讲透的很少。这篇我把自己的排查过程和最终落地的一套方案完整复述一遍内容不挑框架、不挑版本只要你用的是连接池连Redis就值得花十分钟看完。1. 先还原一个真实的线上现场1.1 这个报错到底长什么样不同客户端对连接被服务端关闭的文案不一样但本质是同一个TCP层事件。我用最常见的技术栈举例Java JedisPool完整异常一般是redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream. at redis.clients.jedis.util.SafeEncoder.encode(SafeEncoder.java:20) at redis.clients.jedis.Connection.flush(Connection.java:326) ... Caused by: java.io.IOException: Connection closed by server at sun.nio.ch.SocketDispatcher.read0(Native Method) at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43) at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:410) ...关键就在那句Connection closed by server。它表示客户端在往一个已经建立的TCP连接上发命令时发现Socket已经读不到任何响应而且是被对端Redis服务器或中间网络设备主动断开的。换到Lettuce场景异常可能长成io.lettuce.core.RedisConnectionException: Connection closed by serverRedisson的文案又有自己的说法。中文社区里讨论得最多的就是这个Jedis版本的报错。1.2 这类报错的出现时机与规律我总结了一下这问题爆发通常有三类典型时机服务闲置一段时间后突然来流量。比如凌晨的定时任务、早上刚上班的第一波用户请求。连接池里的连接在低峰期长时间没人用Redis服务端早就悄悄把它们关闭了等请求一到从池里借出来的全是尸体。固定周期性的小流量任务。每5分钟或每10分钟跑一次的定时任务如果你Redis的timeout恰好小于任务周期那就是每次跑必报错报完又能恢复正常特别有规律。发布扩缩容或Redis侧运维之后。比如主从切换、实例重启、连接数被限流都会导致存量连接突然失效。如果你在日志里看到这个报错又恰好能对上其中一个时间特征那么恭喜问题定位已经完成一半了。因为什么时候爆这一点直接决定了你要往哪个方向深挖。2. 服务端为什么会主动关闭看似正常的连接2.1 Redis自带的时间守卫timeout参数Redis服务端判断一个连接是否该被关闭最核心的开关是timeout配置项。它表示如果一个客户端连接在N秒内没有任何读写交互Redis会主动断开它。redis-cli -h your_host -p 6379 config get timeout默认情况下这个值通常是0也就是不主动断开空闲连接。但很多生产环境、云数据库的初始化脚本或DBA的调优基线里会把它设置成300、600之类的数值。一旦设置成300意味着连接空闲超过5分钟就会被Redis回收。你在连接池里存着的那批连接如果低峰期超过5分钟没有读写下一次borrow出来用就会撞上那句Connection closed by server。这里有个很多人容易混淆的点Redis的timeout判断的是连接的空闲时间idle time不是连接的总存活时间。只要在这段时间里有任何一条命令执行过空闲计时器就会重置。所以它的本质是空闲连接回收而不是长连接限时回收。2.2 tcp-keepalive、maxclients 与运维操作的边界除了timeout还有几个服务端因素会制造断连但作用机制不太一样。tcp-keepalive是另一个常见配置它控制的是TCP层的心跳探测Redis服务端会周期性地向对端发送探测包确认客户端是否还活着。默认值通常是300秒。它解决的是客户端进程已经崩了但TCP连接还挂在服务端的情况和timeout配合起来一个管空闲回收一个管死链清理。maxclients限制的是最大连接数。当连接数接近上限时Redis会拒绝新连接报错一般是max number of clients reached不会主动去关老连接。但有一种变种情况如果某个时间段连接数暴涨把上限打满客户端连接池尝试创建新连接失败连接池里的连接又恰好被服务端回收掉一部分那报错就会混合出现——既有Cannot get Jedis connection又有Connection closed by server。还有一种容易忽略的运维操作主从切换、实例重启、缩容。这些动作本质上是服务器主动关闭所有连接不需要等到timeout触发。如果你们有这类操作安排在低峰期那第二天早上必有一波故障告警。有些人把这归因为Redis不稳定其实冤枉了Redis这纯粹是连接生命周期管理没做好。2.3 比Redis更隐蔽的断连凶手LB/NAT空闲回收这是很多人在本地复现不出来、一到线上就疯的原因。一旦服务不是直连Redis而是经过四层负载均衡、NAT网关、云数据库proxy或者容器网络里的sidecar那中间这些网络节点多数都有自己的空闲连接超时策略。比如某些负载均衡产品对闲置连接的回收时间可能在60秒到900秒不等NAT网关的conntrack表项也有老化机制。当一条Redis连接空闲超过网络设备的阈值时设备会先替后端把连接断掉。此时Redis服务端可能根本没执行过close客户端也不知道直到下一次发命令才发现连接已经死了。从客户端视角来看报错文案和Redis主动断开几乎一模一样。判断到底是谁断的通常需要抓包看FIN或RST的来源IP或者对比从空闲到报错的时间和Redis timeout配置值是否吻合。如果Redis的timeout是0但报错还是周期性出现那十有八九是网络链路里某个节点在悄悄回收空闲连接。3. 连接池为什么不能提前发现坏连接3.1 连接池视角下的连接生命周期我们换个角度站在连接池的视角看这个问题。连接池管理着一堆Socket连接每个连接有三个关键状态空闲idle、借出borrowed、可能已损坏broken。池本身没有读心术它判断一个连接能不能用靠的是两样东西最后一次成功读写的时间以及是否配置了主动验证。问题就在这里。如果池里的连接已经空闲了300秒而这期间服务端早就关了它连接池并不知道。它只知道这个连接上次用的时候是好的现在还应该没问题。这就像你把钥匙插在门锁上管理员从里面把门反锁了你站在门外不动是察觉不到门已经打不开的只有再次转动钥匙那一刻才会发现。如果连接池没有开启任何验证机制那它就会放心地把一条死连接交给业务线程。业务线程拿着它发命令读到一半发现Socket流已经EOF了于是抛出Connection closed by server。更麻烦的是如果这条连接还被错误地归还回池中它会继续害人同一个时间窗口内并发请求越多报错就越密集。3.2 testOnBorrow与testWhileIdle两个老实人的区别commons-pool2为连接池设计了三个验证开关testOnBorrow、testOnReturn、testWhileIdle。它们的作用时机完全不同很多人只听说过testOnBorrow却忽略了另一个更重要的testWhileIdle。参数作用时机失败后的表现验证指令开销testOnBorrow每次从池里借出连接前销毁该连接尝试新建连接再借出每次borrow多一次RTTtestOnReturn连接归还到池里时验证失败则销毁不放入池中每次归还多一次RTTtestWhileIdle后台驱逐线程周期性检查空闲连接验证失败则从池中移除并关闭周期性的少量RTTtestOnBorrow是冤大头模式。你每次拿连接都先PING一下确保拿到的连接是活的。代价是每次borrow都多一次网络往返。在低QPS场景下完全没问题QPS一高比如每秒钟几千次borrow这就意味着每秒多几千次Redis请求纯属给Redis增加无谓压力。testWhileIdle是乖孩子模式。它不占用业务请求路径而是由连接池的后台Evictor线程周期性扫描空闲连接把那些已经失效的连接提前清理掉。配合timeBetweenEvictionRunsMillis和minEvictableIdleTimeMillis一起用可以让坏连接在池子里存活的时间被压缩到极致。我现在的实践结论很明确testWhileIdle必须开testOnBorrow按QPS水平取舍。高QPS服务我直接关掉testOnBorrow只靠testWhileIdle和客户端侧的连接空闲管理来控制坏连接比例低QPS服务才开testOnBorrow因为borrow频率低多一次PING的开销可以忽略但能避免把坏连接交给调用方。3.3 不同客户端的不同脾气Jedis、Lettuce与Redisson同样是Redis连接问题客户端不同表现和修复方式也有差异。Jedis典型的多连接池模型每个JedisPool管着一批独立连接。问题高发原因是连接池默认不开启验证而且旧版本写法复杂很多人用错returnResource/returnBrokenResource导致坏连接回流进池子。新版本3.x/4.x改成Jedis implements Closeable用try-with-resourcesclose时会根据连接状态决定是归还还是销毁比老版本安全一些但仍然要正确配置验证逻辑。LettuceSpring Boot 2.x的默认客户端底层是Netty默认情况下不是连接池模型而是一条共享连接shareNativeConnection所有线程复用。如果这条共享连接被服务端timeout回收影响面比Jedis大得多因为所有线程的Redis操作都会同一时间报错。好在Lettuce自带自动重连机制连接断开后会尝试恢复恢复期间的操作可能出现失败或短暂阻塞。另外注意Spring Boot 2.x里要启用Lettuce连接池必须额外引入org.apache.commons:commons-pool2否则你在配置文件里写spring.redis.lettuce.pool.max-active是无效的。Redisson相对会省心一点它有自己的一套连接管理和空闲回收机制客户端侧会主动管理空闲连接服务端timeout导致的问题通常不明显。但它也有自己的idleConnectionTimeout之类的参数需要和服务端配置形成合理配合。我一直强调选型只是降低问题概率真正决定系统稳不稳的还是你对连接生命周期的理解。4. 一次晚间告警驱动的完整排查链路理论讲完说一次真实的排查经历。那天的场景是一个只在工作时间有稳定流量的服务每天凌晨3点有个定时任务会扫描一批数据并把结果写Redis任务启动后总会报一批Connection closed by server任务本身因为代码里做了几次重试最终能跑完但日志很难看告警也被磨得没人看了。4.1 第一步先确认异常的类型与客户端第一步不是去翻配置而是先看异常类型和客户端版本。日志里明确是Jedis的Unexpected end of stream.底层caused by是Connection closed by server。这说明是已经建立的连接突然失效而不是连接数不够连不上。如果是Could not get a resource from the pool那是另一类问题排查方向完全不同。我用了一段伪代码复现完整过程// 模拟一个JedisPool服务端timeout10秒 JedisPoolConfig poolConfig new JedisPoolConfig(); poolConfig.setTestWhileIdle(false); poolConfig.setTestOnBorrow(false); JedisPool pool new JedisPool(poolConfig, 127.0.0.1, 6379); // 第一次使用正常 try (Jedis jedis pool.getResource()) { jedis.set(foo, bar); } // 模拟业务空闲sleep 15秒超过服务端timeout Thread.sleep(15_000); // 第二次使用此时连接已被服务端关闭 try (Jedis jedis pool.getResource()) { System.out.println(jedis.get(foo)); // 这里抛Unexpected end of stream. / Connection closed by server }这个Demo能100%复现。看到这里我基本已经能断定问题和服务端空闲回收有关了接下来就是找到具体的timeout值。4.2 第二步看Redis配置判断是不是timeout的锅在Redis上执行下面几条命令redis-cli -h your_host -p 6379 config get timeout redis-cli -h your_host -p 6379 config get tcp-keepalive redis-cli -h your_host -p 6379 config get maxclients redis-cli -h your_host -p 6379 info clients那次查出来的结果是timeout300而定时任务正好每5分钟跑一次任务执行完了之后下个周期开始前的空窗期刚好超过300秒。也就是说低峰期连接池里的连接大概率在空闲超过5分钟时被服务端回收然后定时任务启动从池里借出这些连接一用就炸。时间特征和配置值吻合到这种程度基本就是实锤了。如果Redis的timeout是0但报错还是规律出现下一个要怀疑的就是网络链路。此时最好再看一眼redis-cli client list关注每个连接的idle字段。如果有大量连接的idle值集中在某个固定秒数附近并且这个秒数和中间设备的空闲超时时间对得上那断连方基本可以锁定是LB或NAT而不是Redis本身。4.3 第三步抓包和复现实验锁定断连发起方在明确了timeout是300之后我们还在测试环境做了一次抓包确认。在客户端机器上跑tcpdump -i eth0 -n port 6379 -w redis_capture.cap然后复现连接空闲超过300秒再发命令的场景在Wireshark里看包能清楚看到服务端先发来FIN四次挥手客户端在很久之后才发下一条命令。这说明Redis确实主动关闭了连接客户端完全不知情。如果中间还有LB就要看FIN的来源IP用来确认断连方到底是Redis还是负载均衡器。这一步虽然稍微费时间但它能防止你改了半天Redis配置结果发现真正断连的是网络设备。4.4 第四步结合监控流量回归根因最后把Redis实例和服务的监控翻了一下报错时间点之前Redis实例上的connected_clients有明显的小幅下降下降发生在任务启动前一两分钟正好对应timeout到点回收连接的动作。同时客户端的池监控里idle连接数在高位borrowed连接数几乎为零。两个信号一叠加根因链路就完全闭环了。这类问题的本质就是服务端空闲连接回收策略和客户端连接池保活策略没有对齐一方已经决定断另一方还在傻傻地留。5. 修复方案从参数到代码再到运维5.1 服务端timeout怎么设置才算合适很多人第一反应是config set timeout 0一劳永逸。这个做法可以但有一个前提你已经评估过空闲连接数量对Redis实例的影响。如果服务很多、每个服务的连接池都不小而且业务本身不适合长连接长期挂载那把timeout设为0会带来连接数堆积的问题。更稳妥的思路是让timeout的值大于业务中最长的空闲窗口。如果业务有周期任务周期是5分钟timeout至少设到900秒以上留足余量。如果业务面向真实用户夜里基本没人访问那你需要接受早上第一波请求可能会撞上连接被回收这一现实。解决方式不是把timeout调大那么简单还要靠连接池预热或者客户端侧的定时检测把坏连接提前清掉。如果timeout设置为0就绝不能忘记客户端侧的testWhileIdle要开着用来对抗网络设备层面的空闲回收。另外config set timeout只是临时生效一定要同步更新到redis.conf否则Redis一重启又回到旧值。这种重启丢配置的坑我见过不止一次。5.2 JedisPool参数推荐与性能权衡服务端改完timeout客户端连接池的配置也要跟着改。下面是一套我目前比较推荐的基线配置可以根据业务做微调JedisPoolConfig config new JedisPoolConfig(); config.setMaxTotal(64); config.setMaxIdle(64); config.setMinIdle(8); config.setMaxWaitMillis(3000); // 高QPS建议false低QPS可以改为true config.setTestOnBorrow(false); config.setTestOnReturn(false); // 后台兜底检测必须开 config.setTestWhileIdle(true); config.setTimeBetweenEvictionRunsMillis(30_000); config.setNumTestsPerEvictionRun(-1); config.setMinEvictableIdleTimeMillis(60_000); config.setSoftMinEvictableIdleTimeMillis(30_000);这套配置的逻辑是minIdle8保证池子里至少有8条连接待命避免流量突增时瞬间建连。testWhileIdletrue配合timeBetweenEvictionRunsMillis30000让后台线程每30秒扫一遍空闲连接。minEvictableIdleTimeMillis60000意思是空闲超过60秒的连接才会被驱逐线程盯上。注意如果这个值设得太小比如5秒连接会被频繁创建销毁极端场景下TIME_WAIT连接会堆积。numTestsPerEvictionRun-1表示每次检测所有空闲连接。这个参数在高连接数场景下要注意如果池里有几千条空闲连接每30秒全量检测也是一笔开销此时可以改成固定值比如100。这套配置配合服务端timeout300能保证一条连接空闲到300秒之前早就被客户端后台线程清理掉了。两边形成了速度差服务端根本等不到timeout触发的那一步。5.3 Spring Boot Lettuce场景下的特别提醒如果你用的是Spring Boot 2.x Lettuce有两点必须强调。第一默认情况下Spring Boot 2.x会让Lettuce复用一条共享连接没有真正的连接池。要启用连接池先确认pom里有commons-pool2dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency然后在配置文件里显式开启spring: redis: lettuce: pool: enabled: true max-active: 32 max-idle: 32 min-idle: 8 max-wait: 3s没有commons-pool2依赖写了这些配置也不会生效还很容易让你误以为已经用上了连接池。第二因为Lettuce底层是Netty异步模型你不应该照搬Jedis的连接池测试思路。Lettuce的validateConnection在连接池模式下也有但更重要的是设置一个合理的commandTimeout。断线重连期间命令会排队如果timeout太短业务会立刻收到失败如果太长线程容易堆积。我一般配置3秒。5.4 兜底重试与监控告警即使配置做到位线上也难免有极端情况Redis实例重启、网络抖动、突发连接数打满。所以应用层有一个快速失败有限重试的兜底逻辑是有价值的。需要注意重试不是无脑做。对于SET、DEL这类幂等命令重试很安全对于INCR这类非幂等命令重试要非常小心最好在上层做业务幂等。我通常会这样区分连接池borrow时如果不幸遇到坏连接JedisPool在testOnBorrow开启时会自动重试一次上层不需要再做多余处理。如果testOnBorrow是关闭的业务代码拿到连接后抛了JedisConnectionException此时可以重新borrow一次再执行这属于业务侧的一层保险。监控方面至少要盯这几个指标连接池的active/idle/borrow失败次数Redis的connected_clients以及报错日志中出现Connection closed by server的频率。告警阈值可以按波动幅度来设置比如borrow失败数在5分钟内超过20次才告警减少噪音。6. 三个我实际踩过的坑希望你别再踩6.1 只开testOnBorrow结果高并发瞬间打爆Redis有一段时间我图省事给连接池开了testOnBorrow就不再管testWhileIdle。平时流量平稳没问题某天业务方做了一次秒杀活动活动开始后第一波请求瞬间涌入池子里大量连接早就是死的。testOnBorrow逐条验证发现一条销毁一条、销毁后立刻新建一条。这导致Redis在同一秒内收到大量PING和新建连接请求建连数直接冲到几百Redis的CPU被打到高位业务延迟反而更夸张。这件事给我的教训是testOnBorrow只能避免坏连接被业务使用没法避免批量发现坏连接造成的瞬时冲击。真正能平缓冲击的是让testWhileIdle的周期足够短把坏连接在后台悄悄清理掉而不是等到请求高峰来临时集中爆发。6.2 把坏连接又还回了池里早期用Jedis老版本代码里拿到连接后不知道怎么写归还逻辑。有些同事为了方便直接在finally块里无条件调用类似归还连接的操作导致一个已经被服务端关闭的连接被放回池中。这种尸体级连接在池子里再被借出、再一次报错循环往复问题被无限放大。新版本Jedis里try-with-resources配合Jedis.close()已经能根据连接内部状态自动判断是归还还是销毁但这不是万能保险。如果业务在命令执行过程中捕获了异常却没有向上抛出连接可能被标记为可用然后归还。我自己实践时的硬性要求是任何Redis操作代码里一旦捕获到连接层面的异常这个连接必须显式销毁绝不允许再还回池子。6.3 把Redis timeout直接改0就完事没那么简单还有一次另外一个团队被这问题逼急了直接在线上执行config set timeout 0当时确实不报错了。结果到了月底Redis实例的connected_clients一路涨到接近maxclients因为大量客户端的长连接长期挂着没有一个回收机制。而且由于连接数接近上限其他服务的连接池开始出现拿不到连接的故障。所以timeout0不是不能设设了之后你必须有另外一套机制来控制连接总量。要么客户端侧的minIdle不要设置太高要么用中间件或vpc网络层的空闲回收来控制要么定期对客户端连接做主动检测和重建。总之不能既希望连接长久保留又希望连接数可控这在分布式环境里是矛盾的。现在再遇到Connection closed by server我第一反应已经不再是把testOnBorrow打开这种表面功夫而是先把时间规律和服务端timeout对上再检查网络链路最后落一套服务端合理超时 客户端后台检测 有限重试兜底的组合拳。这套组合在多个服务上跑了一年多半夜被Redis问题吵醒的次数基本降到了零。最后再分享一个小技巧如果你每次都要靠翻日志才发现连接出问题可以写一个小脚本定时扫Redis的client list把idle值异常的连接单独报警出来这比等业务报错要敏感得多。
返回列表