先说句掏心窝的话只要是用 Java 连 PostgreSQL 的团队几乎都会在某一天被 An IO error occurred while sending to the backend 这个异常刷屏。我刚碰到它的时候第一反应也是哦网络抖动了后来发现事情没这么简单——同样的报错背后可能是负载均衡在偷偷断开长连接、可能是连接池里躺着僵尸连接、可能是数据库主动掐了会话甚至只是驱动版本的一个老毛病。如果你也被这种信息量很少、但出现频率很高的报错折磨过那这篇文章应该能帮你省下几个通宵。下面我把这几年在生产环境里的排查思路、实际踩坑和最终采用的修复配置完整梳理出来。1. 先弄明白这个异常是在哪一层被抛出来的很多人在网上搜这个报错得到的答案就俩字查网络。这话没错但太笼统了。要真正把它定位清楚得先搞清楚 PostgreSQL 的 JDBC 驱动到底是在哪一步喊出这句话的。1.1 驱动走到哪一步才会喊发送失败Java 应用通过 JDBC 操作 PostgreSQL底层是一条 TCP socket 连接。正常情况下驱动执行任何 SQL 都要先把请求数据写到这条 socket 上PgConnection把 SQL 交给QueryExecutorImpl.sendQuery()最后落到SocketOutputStream.write()。如果write()这一下没能把数据成功送进内核发送队列驱动就会把底层的IOException捕获包装成一个PSQLException对外统一显示为An IO error occurred while sending to the backend。你仔细品一下这句话它说的是发送的时候出错了而不是连接建立的时候出错了。这两者的差别非常关键——意思是驱动在发 SQL 之前一直以为这条连接是好的。真正发出去那一刻底层 TCP 才告诉它兄弟通道早就不通了。所以这类报错发生的时间点往往和连接真正断掉的时间点不在一起中间可能隔了几分钟甚至几个小时。1.2 发送失败和读取失败的区别如果你翻过其他项目日志应该还见过它的孪生兄弟An IO error occurred while reading from the backend。这两条虽然长得像指向的方向却完全反着报错文案方向通常暗示的问题An IO error occurred whilesendingto the backend客户端写入 socket 失败连接在发送前已经不可用常见于僵尸连接、中间设备静默回收、对端发 RSTAn IO error occurred whilereadingfrom the backend客户端等待/读取响应失败服务端迟迟没有响应常见于 socketTimeout 触发、服务端崩溃、长查询卡死我个人习惯把这句报错理解成事后发现型错误TCP 连接不是实时保活的除非显式配置 keepalive断开的瞬间双方都不知道直到下一次真正传数据才会爆出这么一句。这也解释了为什么它特别偏爱长连接场景连接池把连接养在池子里一养就是几十分钟中间完全没流量断没断根本没人知道等到业务取出来用咔嚓一下就是一条 IO 异常。2. 我见过最多的几类触发场景这个报错既然只是发送时发现连接坏了那真正要搞定的其实是另一个问题连接到底是怎么坏的根据我的经验生产环境里 90% 的情况可以归到下面几类。2.1 空闲连接被网关和中间设备静默回收这是最常见的一种没有之一。云环境里应用和数据库之间几乎必然隔着一些东西云负载均衡、防火墙、数据库代理。这些中间设备对长时间没有流量的 TCP 连接有个通行的处理策略——到时间直接回收。问题在于回收的一方是设备自己它不会通知客户端和服务端。你看着连接的 socket 还在内核状态也还是 ESTABLISHED但实际上中间那一段的转发通道已经被拆掉了。等到业务从连接池拿出这条连接执行 SQL请求发出去就像掉进黑洞没有 FIN没有 RST只是所有数据包都被安静地丢掉。客户端内核不知道对端已经消失只能按 TCP 重传机制一遍遍地重发重传次数耗尽之后write()才返回失败最终变成你看到的这个异常。我印象最深的一次排障客户反馈系统每天早上 9 点准时报错大约持续两三分钟后自己恢复。查了一圈慢查询、锁竞争全部没有异常。后来才发现罪魁祸首是他们凌晨的批处理任务凌晨跑完后一批专用连接被放回连接池再没人用闲置了整整一晚上。到了早上第一波上班高峰应用把这些睡死的连接捞出来用一做 SQL 就报 IO error。一批连接接连失败看起来就像系统在抽风。2.2 数据库侧主动断开连接池却还在假装没事第二种高发场景是 PostgreSQL 服务端自己把连接给关了但下游连接池没感知到。常见诱因有几个PostgreSQL 14 开始有idle_session_timeout参数连接空闲超过设定秒数服务端直接终止这个会话DBA 手动执行pg_terminate_backend(pid)清理会话发生主备切换、数据库实例重启、崩溃恢复所有旧连接瞬间失效慢查询触发statement_timeout后连接仍滞留。这些场景和第一种有个共同点连接断开这件事对客户端是不可见的。服务端关闭 socket 时会发 FIN但客户端如果一直不 I/O就收不到任何通知。连接池也默认不会主动验证连接有效性于是这条已经咽气的连接继续躺在池子里直到被业务捞起来用。2.3 网络抖动与 TCP 重传耗尽第三种场景是纯粹的网络问题。跨地域部署、公网环境、无线网络、机房交换机瞬间丢包都可能让某个时刻的 TCP 报文丢失。如果中间恰好有防火墙把异常的连接标记为无效或者丢包持续时间超过了客户端重传耐性TCP 就会放弃这条连接。这里的表现通常是报错集中在某个时间窗口应用日志里可能还会出现Connection reset by peer或Broken pipe的底层 cause。相比前两种这类问题更好判断——它往往不是某个时间段规律性出现而是跟着网络事件走比如发布变更、网络割接、流量突增。2.4 驱动版本和加密握手带来的隐性不兼容再提一个容易被忽视的场景JDBC 驱动版本过旧。PostgreSQL 服务端升级到新版本后如果驱动还停留在老版本在 SSL 协商、scram 认证、结果集协议处理上可能出现兼容问题轻则偶发 IO 异常重则连接直接失败。这种问题隐蔽性强因为平时并发不高时完全正常一到高峰期就报错。所以在排查一切 IO error 之前先看一眼自己的驱动版本是不是太老。很多所谓玄学报错升级一个驱动就彻底消失。3. 排查链路按这个顺序做少走弯路遇到这个报错最忌讳的就是一上来就乱调参数。我建议按下面的顺序一层层往下查每一步都有明确结论再走下一步。3.1 先翻应用堆栈看底层 cause 是哪种所有异常都会被驱动包装但真正有价值的信息在Caused by里。拿到完整堆栈后先找最底层的那个IOException不同文案对应不同方向底层 cause大致含义java.io.IOException: Broken pipe对端已经关闭 socket正常 FIN 或 RST但应用不知道还在继续写java.io.IOException: Connection reset by peer对端直接回了 RST常见于实例重启、防火墙主动干预、端口失效java.io.IOException: No route to host网络路由不通通常是网络层故障java.net.SocketTimeoutException: Read timed out应用设置了 socketTimeout服务端在超时时间内没有任何字节返回这一步能把排查范围缩小一大半。比如Broken pipe大多指向连接被单方面断开但没人通知客户端这时候重点就要放到空闲回收和服务端超时上而不是折腾应用代码。3.2 再查 PostgreSQL 服务端日志如果连接是被服务端主动断开的PG 日志里通常会有明确记录。默认日志在数据目录下的log/目录或者通过SHOW log_directory;查看实际路径。搜索几个关键词terminating connection due to idle-session timeout说明是idle_session_timeout干的terminating connection due to administrator command有人或工具执行了pg_terminate_backendunexpected EOF on client connection服务端在等待客户端数据时发现连接断开通常是客户端或中间设备先断了。这一步能直接区分服务端主动断和客户端/中间设备先断两种方向。如果是前者直接跳到第 4 章看参数调整如果日志干干净净啥都没有那你大概率碰上第 2.1 节说的场景——中间设备静默回收。3.3 用 tcpdump 确认 TCP 层面的行为日志查不出结果时就该抓包了。在应用服务器上执行tcpdump -i any -s 0 -w /tmp/pg_io_error.pcap tcp port 5432复现一次报错后用 Wireshark 打开 pcap 文件重点看这三样东西看有没有TCP RSTRST 出现在业务 SQL 之前说明连接早就被对端重置了看有没有大量TCP Retransmission如果有说明有数据包丢失对端没响应重传耗尽后放弃看有没有Zero Window窗口为 0 说明某一方处理不过来缓冲区塞满另一端暂时写不进去。抓包是成本最低、结论最硬核的手段。我见过太多人对着连接池参数改了半天最后抓包才发现压根是防火墙在丢包。3.4 检查连接池和 JDBC URL 参数如果应用层和数据库日志都正常那问题大概率出在连接池配置和 JDBC URL 上。以下几个参数几乎决定了连接池的长连接生命周期建议逐项核对参数默认值作用容易犯的错HikariCPmaxLifetime180000030分钟单个连接最大存活时间设得比服务端/中间设备超时时间长HikariCPidleTimeout60000010分钟空闲连接最大空闲时间设得过短导致频繁重建连接HikariCPkeepaliveTime0禁用定期对空闲连接发测试查询很多人根本不知道这个参数存在JDBC URLtcpKeepAlivefalse开启 TCP 层 keepalive以为开了就一定有效忽略了系统参数JDBC URLsocketTimeout0无限等待服务端响应的超时上限设太短导致大批量查询莫名中断JDBC URLconnectTimeout10秒建立连接的超时时间一般不用调保持默认即可注意socketTimeout不是连接超时它是从服务端读取数据的超时上限。如果一条正常的大查询执行时间超过这个值会被强制中断甚至抛出SocketTimeoutException: Read timed out。非必要别把它设成很短的数值。3.5 用空闲后发 SQL的测试复现问题排查中如果怀疑是空闲回收可以用一个最简单的场景复现# 先执行一次查询确认连接正常 PGPASSWORDxxx psql -h db-host -p 5432 -d appdb -U appuser -c select 1; # 空闲等待一段时间比如 15 分钟模拟中间设备超时 sleep 900 # 再执行一次查询观察是否报错 PGPASSWORDxxx psql -h db-host -p 5432 -d appdb -U appuser -c select 1;如果第二次执行报错或长时间卡住基本就能确认是中间链路对空闲连接做了回收。这个测试在 JAVA 应用连接池外面做是有意义的——它把连接池配置这个变量排除掉直接验证最底层的 socket 链路是否对空闲敏感。4. 每种根因对应的修复配置定位到具体根因后修复思路就清晰了。下面按场景分别给出我实际用过的配置和注意事项。4.1 连接池主动保活HikariCP keepaliveTime 的完整配置如果是中间设备回收空闲连接导致的报错最有效的不是去调中间设备而是在客户端用应用层保活把连接养住。HikariCP 从 4.0 开始提供了keepaliveTime原理是连接空闲超过这个时间后连接池后台会往这条连接发一个测试查询既验证连接是否还活着也让连接产生流量不至于被中间设备判定为空闲。我的配置习惯是这样的HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:postgresql://db-host:5432/appdb?tcpKeepAlivetrueconnectTimeout10); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setIdleTimeout(300000); // 5 分钟 config.setMaxLifetime(1800000); // 30 分钟 config.setKeepaliveTime(30000); // 30 秒 config.setValidationTimeout(5000);三个要点keepaliveTime必须小于maxLifetime否则会在启动时收到配置异常建议设置在 30 秒到 60 秒之间。30 秒一次select 1对每个空闲连接来说几乎零成本但能把连接的空闲间隔控制在分钟级以下让几十秒甚至几分钟的空闲回收机制全部失效即使有了 keepalivemaxLifetime也不能设成无限大。数据库、操作系统网络栈、中间设备都有自己的生命周期策略定期轮换连接是健康的。有人问tcpKeepAlivetrue不是也能保活吗这里有个大坑JDBC 的tcpKeepAlivetrue只是给 socket 开启了 TCP 层 keepalive 选项实际探测间隔走的是操作系统内核参数。Linux 默认的tcp_keepalive_time通常是 7200 秒2小时如果你不调整内核参数这个开关等于没开。而连接池的keepaliveTime是应用层真实流量效果立竿见影。4.2 调整内核 TCP keepalive 参数如果不想走连接池应用层保活或者连接池版本太老不支持keepaliveTime那就得把系统内核的 TCP keepalive 调短。在应用服务器上创建一个参数文件# /etc/sysctl.d/99-tcp-keepalive.conf net.ipv4.tcp_keepalive_time 60 net.ipv4.tcp_keepalive_intvl 10 net.ipv4.tcp_keepalive_probes 5 # 生效 sysctl --system三个参数的含义分别是空闲 60 秒后开始发探测包每 10 秒发一次连续 5 次无响应才认为连接死亡。这样从发现对端消失到断开连接最迟约 60 10×5 110 秒比默认的 2 小时敏感得多。但这里仍然要提醒TCP keepalive 探测包是基于内核层面的机制部分负载均衡和中间设备会把它当成连接仍活跃的标志但也有设备不认。所以内核 keepalive 可以作为辅助手段最稳的还是应用层查询保活也就是 4.1 的keepaliveTime。4.3 防火墙 conntrack 与负载均衡超时对策当你确认是防火墙连接跟踪表清理了连接时可以在防火墙所在的主机上调整 conntrack 超时时间# 查看当前值 sysctl net.netfilter.nf_conntrack_tcp_timeout_established # 调整为 3600 秒1小时 sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established3600注意如果主机上根本不存在nf_conntrack相关参数说明没启用连接跟踪模块那问题源头就不在这里。还要意识到conntrack 表是有条目上限的把 timeout 调大会导致更多连接长期占用表项改之前先评估连接规模。如果应用和数据库之间是云负载均衡或数据库代理去控制台找空闲超时配置项。不同云厂商默认值差别很大有默认 60 秒的也有默认 900 秒的。如果这个值过小建议直接改大比如 20 到 30 分钟然后配合连接池maxLifetime30分钟一起用。改负载均衡超时时原则是让中间设备的回收时间高于连接池的 maxLifetime这样连接会在被中间设备回收之前先由连接池主动轮换掉。4.4 PostgreSQL 服务端超时参数的正确设置如果日志显示是idle_session_timeout导致的断开那就得权衡一下这个参数该怎么设。对大多数内部系统我建议直接关闭# postgresql.conf idle_session_timeout 0如果一定要保留就记住一条铁律连接池的maxLifetime必须小于idle_session_timeout。举个例子数据库设了 5 分钟空闲超时连接池maxLifetime必须小于 300 秒否则连接池里的连接会被数据库先斩后奏。服务端同样可以设置 TCP keepalive 参数这样即使应用端没配置服务端也能主动探测并清理死连接# postgresql.conf tcp_keepalives_idle 60 tcp_keepalives_interval 10 tcp_keepalives_count 5这三个参数都是 PostgreSQL 服务端 socket 层面的配置调小后服务端会更勤快地探测对端状态。修改后执行SELECT pg_reload_conf();即可不用重启实例。4.5 应用层重试与幂等约束最后的兜底手段是应用层重试。对查询类操作遇到这个异常后重试两三次是可接受的伪代码大概长这样public T T queryWithRetry(SupplierT queryAction, int maxRetries) { for (int attempt 1; ; attempt) { try { return queryAction.get(); } catch (PSQLException e) { boolean ioError e.getMessage() ! null e.getMessage().contains(An IO error occurred); if (!ioError || attempt maxRetries) { throw e; } // 简单退避 Thread.sleep(500L * attempt); } } }但必须强调不要对非幂等写操作盲目重试。尤其是事务提交COMMIT阶段遇到 IO 异常时你根本不知道这次提交到底成功没有——可能写入已经落库只是响应没回来再重试就会出现重复数据或脏乱状态。正确做法是给写操作设计业务幂等键或者允许人工介入核对而不是无脑重试。重试只是缓解症状不是根治手段。如果一条连接已经在 TCP 层断了重试能拿到新连接继续干活但如果系统性网络故障重试只会把压力翻倍叠加在已经脆弱的链路上。5. 相关报错变体与防复发的手段处理完当前问题后我还会顺手做两件事看看有没有类似的报错隐患再把监控补齐。毕竟这种 IO error 是最容易反复出现的连接类故障一次性修完不算完。5.1 经常一起出现的几个报错连接类异常很少单独出现。排查和预防时建议把下面这些亲戚一起查报错/现象通常的根因An IO error occurred while reading from the backendsocketTimeout 太小或服务端异常、崩溃Connection reset by peer对端发 RST常见于服务端重启、防火墙干预FATAL: terminating connection due to administrator command有人或工具执行了 pg_terminate_backendFATAL: terminating connection due to idle-session timeoutPG 14 的 idle_session_timeout 生效HikariPool-1 - Connection is not available连接池被耗尽请求在等待获取连接时超时连接数达到 max_connections 上限长连接泄漏、连接池 maxPoolSize 过大、应用事务未释放把这些放同一张表里对照排查比单独看一条异常要高效得多。5.2 监控上该盯哪些指标防复发靠监控。我现在的排查依赖这么几个指标数据库日志关键词告警terminating connection、fatal、unexpected EOF任何一个出现都值得注意连接池指标活跃连接数、空闲连接数、等待获取连接超时次数。等待超时次数从 0 突然涨到几十往往是连接池配置跟实际并发不匹配系统网络指标TCP 重传率、ESTABLISHED 连接数、网络往返延迟。重传率长期偏高说明链路质量有问题数据库连接年龄定期查pg_stat_activity看看有多少连接是老化的。大量长时间空闲连接存在就是给中间设备回收制造机会。PostgreSQL 侧可以用这条 SQL 快速检查连接分布SELECT state, count(*), round(extract(epoch FROM max(now() - state_change))) AS max_idle_seconds FROM pg_stat_activity WHERE datname appdb GROUP BY state;5.3 从架构上把这个报错的发生概率压到最低最后说点架构层面的经验。An IO error occurred while sending to the backend这个异常本质上是在为长连接生命周期管理不到位买单。架构上做几个调整能明显减少它的出现频率应用和数据库尽量部署在同一内网/VPC避免公网、多级 NAT 和额外的负载均衡层。网络路径越短被中间设备无感介入的概率越低连接池要按业务模块隔离不要全系统共用一个超大连接池。某个模块的突发流量拖垮连接池时其他模块不至于跟着一起爆 IO error事务处理要快进快出不要在业务代码里长期持有连接。很多 IO error 的前置原因就是连接被某个休眠线程霸占太久到最后谁都指望不上它如果使用 PgBouncer 这类连接池代理留意它和后端之间的连接轮换策略。代理层引入后连接生命周期又多了一层排查时别漏了它容器化部署注意一点Docker 里如果实例重建旧连接会全部失效连接池要等所有连接被取用后才逐步剔除。容器频繁重启期间IO error 会比平时多这不是部署问题而是清理延迟。这些调整短期看只是规范操作长期坚持下来连接类报错的排查成本会低很多。现在我自己遇到这个异常基本流程已经固化了先看堆栈 cause再查 PG 日志抓包确认回头检查连接池maxLifetime和keepaliveTime。整套流程走下来极少有超过半小时还定位不到根因的情况。如果你的系统还没配置连接池 keepalive建议今天就去把keepaliveTime加上——这是我踩过多次坑之后唯一想对所有人先说的一句话。
阅读完成 · 觉得有帮助?