ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PostgreSQL I/O error排查全攻略:从连接池到网络层的实战

PostgreSQL I/O error排查全攻略:从连接池到网络层的实战 凌晨两点运维群里突然有人at我说线上订单服务挂了日志里刷屏的全是同一行异常org.postgresql.util.PSQLException: An I/O error occurred while sending to the backend.。我赶回电脑前第一件事是查数据库状态主从健康、连接数正常、慢查询也没有明显变化——数据库端的表现和客户端日志完全是两个世界。这种数据库说自己没问题应用说自己连不上的诡异场面是PostgreSQL IO error排查里最磨人的地方。这个报错直奔主题地告诉你客户端驱动往数据库的backend进程写数据时socket写入失败底层连接已经不可用了。但连接为什么不可用才是问题真正的核心。本文把这几年反复踩过的几类原因、完整排查路径和预防手段一次讲透给所有用PostgreSQL做生产环境的研发、DBA和SRE做参考。1. 错误表象驱动层报错问题却不总在驱动先说结论这个报错是客户端驱动抛出来的最常见的是PostgreSQL JDBC驱动psycopg2、libpq等也会有类似文本。它发生在客户端→服务端这个发送方向驱动尝试把数据写到TCP socket里结果写入失败。这意味着在驱动看来这条连接已经死了至于连接是什么时候死的、被谁弄死的驱动不知道它只是在动手的时候发现联系不上对方。很多人看到这个报错会下意识去查数据库但PostgreSQL异常报错里它是比较特殊的一类它几乎从来不是SQL语法问题也不是权限问题。你可以把它理解成打电话聊到一半突然断线对方是谁、什么原因挂了电话那头不会告诉你你只知道没声了。和它类似的几个报错需要先分清楚报错文本含义常见原因Connection refused对端端口根本没有监听数据库没启动、端口被防火墙DROP等Connection reset / Broken pipe对端曾经建立过连接但主动关闭了backend进程崩溃、连接超时被中间设备断开An I/O error occurred while sending to the backend连接建立过但发送数据时已不可用空闲连接被掐断、服务端崩溃、驱动超时等排查思路必须分层应用层连接池、驱动参数→ 网络层防火墙、NAT、负载均衡→ 服务端层PostgreSQL进程、系统资源。任何一层出问题反馈到应用层日志里都可能是这同一个报错。所以第一步永远是先看时间点报错是持续性的还是一次性的是所有应用都报还是只有某个实例报这些信息能帮你快速收敛排查范围。PostgreSQL本身是进程模型postmaster主进程负责监听端口每来一个新连接就fork一个backend process专门服务它。这个底层机制决定了backend这个单词在报错文本里的含义——客户端和某个backend进程之间的连接出了问题。这也意味着如果服务端所有backend进程都死掉那个报错的形态和单个连接被杀是完全不同的这个区别后面会展开说。2. 第一类真凶空闲连接被中间设备悄悄掐断这类原因占了所有生产事故的很大比例但又是最隐蔽的。现象是业务高峰期一切正常凌晨低峰期或上午刚上班时应用日志里突然冒出来一批An I/O error occurred while sending to the backend持续时间几分钟到几十分钟不等然后自己恢复。2.1 中间设备为什么会静默断连应用与PostgreSQL之间往往不是直连的。云数据库前面可能有SLB/负载均衡自建机房可能有防火墙、NAT网关容器环境里还可能经过Kubernetes的Service。这些中间设备为了回收资源会对空闲连接做空闲超时处理——比如某个TCP连接在300秒内没有任何数据流动就把它静默丢弃。问题在于设备丢弃连接时两端都是不知道的。PostgreSQL和客户端都还保持着这个socket觉得连接是好的直到某一天客户端主动往这个socket里写数据才发现对面已经没人应了。这就是为什么报错总发生在应用发起查询或提交事务的时候而不是发生在连接刚建立的时候。你可以把它类比成家里宽带光猫长时间不产生流量运营商侧可能就会把连接释放掉等你下次打开网页才发现需要重新拨号。只是宽带的重新拨号是无感的数据库连接一旦被掐应用里的连接池不会自动感知只有真正发请求时才会暴露。2.2 用TCP keepalive让连接看起来是活的对付这种静默断连标准手段是让连接在空闲时也有心跳也就是TCP keepalive机制。PostgreSQL服务端专门有三个参数控制这个行为-- 连接空闲多少秒后开始发送keepalive探测包 ALTER SYSTEM SET tcp_keepalives_idle 60; -- 探测包的重发间隔 ALTER SYSTEM SET tcp_keepalives_interval 30; -- 连续多少次无响应后判定连接死亡 ALTER SYSTEM SET tcp_keepalives_count 5;执行完记得SELECT pg_reload_conf();生效。这三个参数是per-connection级别的已有连接不受影响新建立的连接会按新参数运行。单纯靠服务端参数还不够。客户端驱动也要配合开启TCP keepalive以JDBC为例连接串需要显式加上jdbc:postgresql://你的数据库地址:5432/db?tcpKeepAlivetruesocketTimeout300注意tcpKeepAlivetrue是让驱动在socket层面启用keepalive而socketTimeout300是驱动层面的socket读超时这两个是不同维度的东西千万别混。psycopg2则可以通过keepalives_idle、keepalives_interval、keepalives_count这些libpq参数传给服务端。还有一个容易被忽略的地方如果应用侧用了连接池比如HikariCP但没有开启keepalive连接池里的连接可能已经被中间设备掐断了池子却毫不知情。后面第6章会专门讲连接池参数怎么配合。2.3 实测下来的验证方式调完参数能不能生效建议不要只看配置要实际验证。在应用服务器上建立一个到PostgreSQL的长连接放置超过空闲超时阈值然后执行一条简单查询psql host数据库地址 port5432 dbnametest userxxx -c select 1如果之前在空闲后被掐断的场景这条命令会直接报No connection to the server或I/O error调整配置之后同样的操作应该能正常返回。如果仍报错可以抓包确认TCP层面是不是有keepalive探测包在持续发出tcpdump -i eth0 port 5432 -nn看到一个方向周期性发送长度为0的ACK包keepalive probe就说明keepalive已经生效了。这个方向的坑还有一个隐藏场景如果连接是经过Kubernetes的ClusterIP或NodePort访问的还需要确认kube-proxy的conntrack超时和云LB空闲超时如果两者都比PG的keepalive间隔短依然会被掐。中间设备越复杂你要调的层级就越多。最稳妥的一刀切方案是保证PG的keepalive空闲时间60s远小于所有中间设备的空闲超时通常是300s或900s同时连接池的maxLifetime也要比中间设备空闲超时短。3. 第二类真凶backend进程非正常退出数据库侧的案发现场中间设备的问题排查完如果报错仍然存在就该进入服务端视角了。这次要回答的问题是backend进程是不是真的死了3.1 先确认数据库有没有崩溃记录PostgreSQL的日志是排查这个方向的案发现场。如果backend进程是被信号杀掉的日志里通常会留下一行非常典型的记录格式大概是2025-06-15 02:11:23.123 UTC [12345] LOG: server process (PID 12345) was terminated by signal 9: Killed 2025-06-15 02:11:23.123 UTC [12345] DETAIL: Failed process was running: SELECT * FROM orders WHERE created_at now() - interval 1 day 2025-06-15 02:11:23.123 UTC [12345] LOG: terminating any other active server processes看到signal 9先别慌它不代表是别人手动kill的。更常见的是操作系统OOM killer出手了尤其是backend进程里正在跑一个内存消耗巨大的查询时。这时去系统日志里翻一下dmesg -T | grep -i -E killed process|out of memory journalctl -k | grep -i -E oom|killed如果能看到类似Out of memory: Killed process 12345 (postgres)的提示问题就定位了。PostgreSQL单个backend进程的内存使用不是固定的而是在执行查询过程中动态增长的work_mem、hash_mem_multiplier、maintenance_work_mem这些参数会直接影响单个查询的内存峰值。很多人图省事把work_mem设成几百MB结果就是几十个并发排序/哈希连接同时跑内存瞬间爆掉。3.2 磁盘满和WAL膨胀另一个会被忽视的杀手除了OOM磁盘满也会让backend进程异常退出。PostgreSQL写入数据要写WAL预写日志WAL目录在数据目录下的pg_wal旧版本叫pg_xlog。如果这个目录所在文件系统满了不仅写入会失败严重的还会导致整个数据库拒绝服务。排查命令很简单df -h du -sh /var/lib/postgresql/14/main/pg_wal如果在df里看到使用率100%而pg_wal目录又大得离谱说明WAL文件没有被及时清理。WAL不能被清理的常见原因是有非常老的backup、有长期未结束的replication slot、或者有一个长期未提交的事务idle in transaction状态一直占着最早的WAL位置不释放。再深入一点还要看replication slot的占用SELECT slot_name, active, restart_lsn, confirmed_flush_lsn FROM pg_replication_slots;如果某个slot的restart_lsn一直停在很久以前的位置而这个slot已经没人用了那就是WAL膨胀的直接原因。3.3 内存参数到底该怎么设才安全这里给一个我实测下来比较稳的分配思路以16GB物理内存的数据库服务器为例参数建议值理由shared_buffers4GBPostgreSQL自己的共享缓存池官方建议25%系统内存左右再高容易和其他缓存机制叠加反而降低命中率work_mem8MB每个连接排序/哈希操作的初始内存这个值是会被并发乘上去的初始宁可小一点maintenance_work_mem256MBVACUUM、CREATE INDEX等维护操作使用可以比work_mem大很多max_connections200连接数直接决定连接占用内存的上限别设太高很多人踩过的算式是这样的一个连接默认分配约5-10MB的内核/socket缓冲和进程私有内存如果设置work_mem64MB理论上100个连接同时做排序操作就需要6.4GB加上shared_buffers 4GB系统可用内存16GB就会非常紧张。所以我的建议是大查询不要靠全局调大work_mem解决而是单独在会话里SET LOCAL work_mem或者用resource group去控制全局参数保持一个够用且不撑爆的保守值。如果确认是backend进程因为资源问题被杀处理完根因后要观察一段时间确认同样的报错不再出现。这里特别提醒只调大内存参数而不解决为什么那么多连接同时吃内存的问题是没用的根因往往是连接数失控、慢查询堆积或SQL没有合理的LIMIT。4. 第三类真凶客户端/驱动侧的超时设定大查询排查到服务端没有任何异常记录而且报错还时不时出现这时候就要把矛头转向客户端自己了。这个方向的坑很反直觉有时候恰恰是防超时的努力制造了报错。4.1 statement_timeout和socketTimeout完全不是一回事这是最容易混淆的一段。statement_timeout是PostgreSQL服务端参数客户端发SQL给服务端后如果服务端执行时间超过这个值服务端会主动取消查询并且给客户端返回一个明确的错误文本比如ERROR: canceling statement due to statement timeout这种错误是驱动能正常收到的连接本身没有坏事务可以继续或回滚。而socketTimeout是客户端驱动层面的socket读超时。JDBC里设为比如60秒意思是驱动发起请求后如果60秒内没有从socket读到任何数据驱动就认为这个连接没救了直接抛IO error并关闭socket。它的报错文本就是今天这个An I/O error occurred while sending to the backend或者更常见的变体是An I/O error occurred while reading from the backend。维度statement_timeoutsocketTimeout生效层级PostgreSQL服务端客户端驱动超时后的动作服务端取消SQL执行驱动直接关闭socket客户端反应收到正常错误文本收到IO error默认值0不限制JDBC默认0不限制生产环境里很多人为了快速失败把socketTimeout设得很小比如10秒或30秒。结果某个查询正常执行需要1分钟驱动在45秒时等到不耐烦主动把连接杀了。所以我的建议是socketTimeout不要小于你的最长业务SQL耗时通常设在120秒到300秒之间真正要防的长查询应该用数据库层的statement_timeout或SQL层面的执行计划优化来解决。4.2 大查询、批量写入和COPY操作的假超时另一个需要警惕的场景是应用写入大批量数据或者执行一个大聚合查询一次往socket里写的数据量很大、耗时很长。比如一条INSERT语句要插入几十万行驱动在JDBC的rewriteBatchedInserts模式下会把一批语句合并成多值INSERT再发送这个过程涉及大量的网络帧传输。如果中间任何一个环节NAT网关、云防火墙、LB的空闲超时比整个操作的数据传输间隔更短连接就会被掐断。注意这里我说的是数据传输间隔而不是总耗时——即使整个COPY操作只花了10秒但如果中间某两个数据包之间的间隔超过300秒比如客户端攒了半天的行再一次性flush同样会被判定为空闲。这类场景最容易发生在应用先长时间计算再突然写入的批处理任务里。解决办法一是确认连接池、驱动、服务端已按第2章的方式开启keepalive二是把大批量写入拆成合理批次每批几万行不要一次性堆几百万三是如果确实需要长时间大对象传输考虑用COPY协议而非逐行INSERT。4.3 重试机制盲目重试会给数据一致性挖坑报错出现后不少应用第一反应是重试。对读操作来说重试问题不大但对写操作必须极度小心。假设一个UPDATE语句已经在服务端执行成功了但客户端发送提交或接收返回的过程中连接断开客户端收到的就是IO error。这时应用如果无脑重试同样的UPDATE可能会执行两次。怎么判断到底执行没执行最靠谱的办法是业务层面做幂等要么让SQL本身具备幂等性比如UPDATE SET counter counter 1这种自增操作重试会导致数值多1必须改成UPDATE SET counter $1 WHERE id $2 AND counter $3的CAS形式要么在事务里用unique约束、版本号、业务流水号做防重。重试是治标幂等设计才是治本。如果只是单纯把重试加上而不解决幂等问题后面的数据对账会让你更痛苦。5. 完整排查链路从一次半夜报错到定位真凶说了这么多理论和参数我把一次真实事故的排查过程完整走一遍你们照着这个链路操作即可。5.1 案例背景某业务线凌晨3点开始出现零星报错应用日志里的堆栈指向An I/O error occurred while sending to the backend报错只出现在某一个Java微服务实例上其他实例正常。数据库侧没有收到任何告警主从延迟正常。5.2 排查步骤拆解第一步先看数据库日志有没有backend进程被杀记录tail -500 /var/log/postgresql/postgresql-14-main.log | grep -E terminated|signal|PANIC|FATAL结果没有任何输出说明服务端进程层面是健康的。这一步可以直接排除所有数据库崩溃、OOM杀进程、断言失败等硬错误。第二步看服务端的连接状态和空闲分布SELECT pid, state, client_addr, backend_start, state_change, now() - state_change AS idle_duration FROM pg_stat_activity WHERE datname your_db ORDER BY idle_duration DESC;发现大量连接的state_change停留在几小时前且state为idle说明这些连接已经空闲很久了。idle_duration最长的几个连接恰好就是报错的那台应用实例连过来的。第三步在应用服务器上抓包看有没有TCP层面的异常行为tcpdump -i eth0 host 数据库服务IP and port 5432 -nn -c 500抓包结果里能看到连接建立之后长时间没有数据包直到某一刻应用发了一个查询请求紧接着收到一个RST包。RST包在wireshark里显示的标记是不带ACK的RESET这说明中间某个设备把连接从它的状态表里清掉了客户端发数据过去后设备直接甩了个RST回来。第四步比对中间设备配置。这个业务是通过云负载均衡访问数据库的控制台里查到LB空闲超时配置为300秒。应用连接池配置的maxLifetime是30分钟也就是说如果应用在某段时间没有任何请求连接在LB处超过300秒没有数据流动LB就静默断开数据库和驱动都不知情直到下一次真正的SQL请求发过来才暴露。5.3 定位结论与修复根因锁定了连接池的连接空闲时间超过LB的空闲超时阈值LB静默掐断连接应用下一次请求时触发IO error。修复动作# HikariCP连接池配置 maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 300000 # 5分钟 max-lifetime: 600000 # 10分钟要小于LB的300秒空闲超时 keepalive-time: 60000 # 每60秒对空闲连接发一次探测 connection-test-query: SELECT 1JDBC连接串同步加上jdbc:postgresql://host:5432/db?tcpKeepAlivetruesocketTimeout300PostgreSQL服务端设置keepalive参数如第2章所述。修复后观察一周报错彻底消失。这个案例里有意思的点在于数据库、驱动、网络设备都各自正常但组合在一起就是会出故障。排查这类问题最忌讳单看任何一端的日志必须把链路串起来看。6. 防患于未然把连接生命周期当成基础设施来管说实话这个报错在真实生产里反反复复出现很大程度上不是因为技术多难而是没有人把连接生命周期当作一个整体去管理。下面这几件事如果能做好90%的IO error事故根本不会发生。6.1 连接池参数要形成配套方案连接池不是只设个最大连接数就完事了它和网络设备、数据库参数必须是一个整体。我习惯用下面这张表去核对每一层的超时关系层级关键参数我的推荐值说明PostgreSQLtcp_keepalives_idle60s让服务端主动探测空闲连接PostgreSQLtcp_keepalives_interval30s探测间隔PostgreSQLtcp_keepalives_count5次连续失败5次判定连接死亡JDBC驱动tcpKeepAlivetrue客户端socket层面启用keepaliveJDBC驱动socketTimeout300s不要设太短留足慢查询余量HikariCPkeepaliveTime60s连接池主动向DB发探测包维持活跃HikariCPmaxLifetime10min必须小于所有中间设备的空闲超时中间设备空闲超时300s起如果设备固定300s池子的maxLifetime请设为小于它核心原则只有一条所有空闲判定时间从上层到下层要逐级缩小。连接池的keepaliveTime要短于驱动和服务端的keepalive间隔而连接池的maxLifetime要短于任何中间设备的空闲超时。只要这个递进关系成立任何一层都不会出现一方觉得连接还活着、另一方悄悄掐掉的情况。6.2 连接池模式和连接健康检查说到连接池简单提一句PgBouncer很多大型PostgreSQL部署会用它在数据库前面做连接池。PgBouncer的session模式和transaction模式差异很大transaction模式下连接是每次事务结束后就归还给池子的天然比session模式更容易暴露空闲连接问题也因此更需要配好server_idle_timeout来控制空闲连接回收。如果你用PgBouncerserver_lifetime、server_idle_timeout同样要参与上面的逐级缩小规则。对于Java应用HikariCP在连接“借出”和“归还”时默认会用JDBC 4.1的isValid()方法做一次快速检查但这个检查通常只是执行一个/* ping */ select 1指令。如果连接已经被中间设备掐了这个ping会返回失败HikariCP会扔掉旧连接重新建一个。所以连接池的keepaliveTime和maxLifetime配合好了问题就基本解决了一半而另一半还在驱动和服务端参数上。6.3 得在监控里加这几个指标最后说下监控。连接池层面要盯活跃连接数、待获取连接数、连接创建速率数据库层面要盯pg_stat_activity里stateidle和stateidle in transaction的连接数、backend进程数量、WAL目录大小。尤其是idle in transaction——它既是内存压力的隐患也是WAL膨胀的元凶之一。如果用的是Prometheus postgres_exporter推荐直接加上这几个查询做告警-- 空闲事务超过5分钟告警 SELECT count(*) FROM pg_stat_activity WHERE state idle in transaction AND state_change now() - interval 5 minutes; -- 连接数占用率告警 SELECT count(*) * 100.0 / current_setting(max_connections)::int FROM pg_stat_activity;其实这类问题的排查思路完全可以沉淀成一套可复用的动作先看数据库日志确认服务端是否崩溃再看连接状态和空闲分布再抓包确认网络层面最后检查驱动和连接池参数。按这个顺序来绝大多数An I/O error occurred while sending to the backend都能在半小时内定位而不是靠猜和重启碰运气。我从个人经验出发真正遇到无法定位的情况少之又少九成最后都指向连接生命周期没管好要么是中间设备空闲超时要么是驱动socketTimeout设置有误要么是连接池和数据库参数不配套。在生产环境里少折腾一些花哨的优化多花点时间把这套连接管理做扎实比什么魔法参数都管用。
返回列表