在数据库主机上,报告大约一秒完成;经 VPN 则要四秒,而 psql 打印的数字直接指向数据库。那个数字是客户端秒表,不是服务端测量。它包含语句发出、等待回复和返回行的全过程。把这三段分开,才能知道该调整哪一侧。

psql 计时器究竟测什么

\timing on 在客户端把语句写入套接字之前启动秒表,读到该结果的最后一行时停止。服务器自身的执行位于这个窗口内的某处,数据包在隧道里花掉的每一毫秒也在其中。输出里没有任何东西把两者分开。EXPLAIN 也会打印数字,但那个数字由服务器产生,并且不含客户端的等待。两个数字,两个不同的问题。

开一个会话,保持计时开启,并在其中运行你关心的语句。复用会话可以让连接开销不进入第一次测量。

psql "host=db.internal port=5432 dbname=appdb user=app" \
  -c "\timing on" \
  -c "select count(*) from orders where created_at > now() - interval '1 day';"

这个数字仍然包含结果传输,这正是这里需要的。它不再包含新调用会增加的连接与认证交换。

同一条语句,比较三条路径

把同一条语句分别通过 Unix 套接字、LAN 地址和 VPN 主机名执行,比较三者。如果只有最后一条慢,查询就不是变量。

每次调用都先连接,然后才计时语句,因此连接开销留在数字之外,传输则留在其中。保持 WHERE 子句、行数和客户端机器完全一致,否则你比较的是工作负载而不是路径。

psql -h /var/run/postgresql -U app -d appdb -c "\timing on" -c "select count(*) from orders;"
psql -h 10.40.0.12 -U app -d appdb -c "\timing on" -c "select count(*) from orders;"
psql -h db.vpn.internal -U app -d appdb -c "\timing on" -c "select count(*) from orders;"

在 LAN 上差异通常小到可以忽略;经 VPN 往往并非如此。把这三次结果排序,就能看出该攻击哪一段等待。

本地套接字慢,说明是服务器工作或计划。LAN 结果慢,说明是查询或它搬运的数据量。只有 VPN 结果慢,才说明路径上的延迟、MTU 或可用带宽,任何索引改动都解决不了。

取出服务器自身的执行时间

服务器可以报告自己花了多少时间,这个数字才是应该拿来和客户端对比的。EXPLAIN 加 ANALYZE 会执行语句,并在计划中打印计划时间和执行时间,BUFFERS 则会加上缓存行为,常常解释热运行与冷运行的差别。除非打算写入,否则不要把它指向写语句。在只读副本上,这是最安全的起点,而 psql 参考文档 记录了计时器在客户端一侧的范围。

对于应用已经在运行的语句,pg_stat_statements 会汇总服务端执行情况,而不重新执行任何东西。每一行对应规范化后的查询,因此一个条目可以覆盖多个调用点。

select calls, round(mean_exec_time::numeric, 2) as mean_ms, rows
from pg_stat_statements
order by mean_exec_time desc
limit 5;

列名随版本而异:PostgreSQL 13 及以后提供 mean_exec_time,更早的版本则称它为 mean_time,所以请用 \d pg_stat_statements 检查该查询是否报错。这些值不含计划,也不含到你客户端的往返。正是这种排除让它们适合比较。

数往返次数,而不只是毫秒

由两百条小依赖语句拼成的报告,会把路径增加的延迟放大两百倍。一条返回一百万行的语句只付一次延迟,然后长期支付带宽。两者在客户端计时器里看起来一模一样,却需要相反的修法。

测量语句条数,而不是估算。先读总数,运行一次报告,再读一次并相减。

select sum(calls) as calls from pg_stat_statements;
-- run the report here
select sum(calls) as calls from pg_stat_statements;

如果增量很大,而每条语句在服务器上都很便宜,批处理就是划算的修法。合并相互依赖的查询,改用一条基于集合的语句,或从游标驱动循环。在交互式 psql 里,\set FETCH_COUNT 1000 让大结果分批到达,并改变传输和客户端内存的表现。它不减少服务器工作,所以救不了慢查询。

检查隧道是否重传和卡在 MTU

当小语句很快而大结果中途停住,查询往往是好的,路径不是。MTU 低于底层链路的隧道,加上被阻断的 ICMP,正好产生这种模式:前几个包到达,大包消失,传输一直挂起到重试为止。客户端一侧的重传计数器会显示出来。

在故意放慢的传输运行时,观察套接字和内核计数器。重传总数是累计值,所以要记录前后,而不是只读一个样本。

ss -tin dst 10.40.0.12
netstat -s | grep -iE "retransmit|segments retransmitted"
ping -M do -s 1400 -c 3 10.40.0.12

传输过程中上升的重传,加上 1400 字节成功、1472 字节失败的 ping,指向 MTU 问题而不是数据库问题。降低隧道接口 MTU 或在网关上夹紧 MSS 是常见修法,同样三条命令事后可以确认。如果传输仍然慢而重传保持平稳,就看隧道被允许的带宽,而不是丢包。

一段简短流程可以覆盖下一次慢报告:把一条语句在本地、LAN 和 VPN 上比较;从 EXPLAIN 或 pg_stat_statements 读取服务器执行;在一次运行前后统计调用次数;并在大结果移动时观察重传。每一步都排除路径的一侧。没有一步需要猜该重启哪个组件。

相关文章