一次线上偶发超时,根因是DNS
凌晨1点,监控报警:订单接口P99延迟从80ms飙到3.2秒。看链路追踪,MySQL、Redis、Redis连接都正常,唯独一个外部API调用的耗时异常。这个API走HTTPS,域名是api.partner.example.com。
第一反应是API服务端慢。打电话给第三方,对方说服务端负载正常,响应都在50ms内。那就怪了。 我用dig命令看了下这个域名,发现问题:解析耗时2.1秒。
当时线上PHP-FPM进程已经在等这个DNS响应了。这就是根因。
问题背景:谁在做DNS解析
PHP 8.3 + cURL 7.88 + GNU C Library 2.36,跑在Ubuntu 22.04容器里。业务代码调cURL请求外部API,域名解析交给glibc的getaddrinfo()。默认配置下,/etc/resolv.conf指向云厂商的DNS服务器。偶发超时的原因是:DNS服务器响应慢或丢包,glibc的解析行为是——先等第一个DNS服务器超时,再换下一个。超时时间默认5秒。
排查链路是:dig看DNS解析状态 → tcpdump抓UDP包确认丢包/延迟 → 换DNS服务器和缓存验证 → 最终用swoole的DNS钩子做本地缓存。
方案对比:三种DNS排查/优化手段
| 方案 | 工具/实现 | 优点 | 缺点 |
|---|---|---|---|
| 命令行诊断 | dig + tcpdump | 直接看全链路,定位快 | 无法覆盖业务侧行为 |
| 系统级配置 | 修改/etc/resolv.conf + nsswitch | 全局生效,改动小 | 容器重启丢失,无法针对单域名 |
| 应用级缓存 | Swoole Hook + 本地DNS缓存 | 微秒级响应,可精确控制TTL | 要改业务代码 |
线上问题,三件事都做了:先用dig和抓包确认根因,然后改系统配置拿到即时缓解,最后上应用级缓存根治。
第一步:dig命令查DNS解析状态
先看最基础的。用dig查询目标域名,+trace参数能展示完整的递归过程:
# 查询A记录,显示详细解析耗时,trace完整路径
dig @223.5.5.5 api.partner.example.com A +trace +time=3 +tries=1
# 如果怀疑是DNS服务器响应慢,用time统计
time dig @223.5.5.5 api.partner.example.com
# 对比本地/公网DNS服务器的解析耗时
for dnsserver in 223.5.5.5 119.29.29.29 8.8.8.8; do
echo "== $dnsserver =="
dig @$dnsserver api.partner.example.com +stats | grep -E "Query time|SERVER|status"
done
我实际跑的结果:
;; Query time: 2117 msec
;; SERVER: 10.15.0.2#53(10.15.0.2)
;; WHEN: Thu Mar 13 01:23:45 CST 2025
;; MSG SIZE rcvd: 121
2.1秒的解析时间,远超正常情况下几十毫秒。但问题在于:dig只证明当前网络环境下解析慢,还不确定是哪一个环节慢——是本机到DNS服务器的网络问题,还是权威服务器响应慢。所以要用tcpdump抓包看每一跳。
第二步:tcpdump抓包定位延迟环节
在应用服务器上抓DNS流量。DNS默认走UDP 53端口,超时重传偶尔走TCP。两个都抓:
# 抓DNS查询和响应包,显示时间戳和IP层信息
tcpdump -i eth0 -nn -s 0 -tttt 'port 53' -w /tmp/dns_debug.pcap
# 另一个终端:触发一次DNS解析
dig @10.15.0.2 api.partner.example.com A +tries=2 +time=5
# 抓完包显示统计信息
tcpdump -r /tmp/dns_debug.pcap -nn | head -30
抓包输出的关键部分:
01:23:45.123456 IP 10.15.0.5.45231 > 10.15.0.2.53: 35121+ A? api.partner.example.com. (48)
01:23:47.245601 IP 10.15.0.5.45231 > 10.15.0.2.53: 35121+ A? api.partner.example.com. (48)
01:23:47.246012 IP 10.15.0.2.53 > 10.15.0.5.45231: 35121 0/1/0 (121)
注意时间戳:第一次请求发出后,过了2.1秒才收到响应。从抓包看,第一个DNS请求发出去后,本机在等响应。第2.1秒时DNS服务器才回包。这里没有丢包重传——是DNS服务器处理慢,或者上游链路慢。为了进一步确定,可以指定不同DNS服务器再抓包对比。
用Wireshark或tshark看TCP重传
DNS偶尔会走TCP。加一个TCP抓包,看是否有SYN重传:
tshark -r /tmp/dns_debug.pcap -Y "tcp.port == 53" -T fields -e frame.time_epoch -e tcp.flags -e tcp.stream
如果没有TCP包,说明走的是UDP。UDP丢包只能看到查询重传。
根因确认:不是应用服务器到DNS服务器的网络问题,是上游DNS递归服务器处理慢。所以换一个DNS服务器试试。
第三步:改系统DNS配置验证
临时换DNS服务器,测一下恢复效果:
# 备份原配置
cp /etc/resolv.conf /etc/resolv.conf.bak
# 写入阿里云DNS
echo "nameserver 223.5.5.5
nameserver 10.15.0.2
options timeout:1 attempts:1 rotate" > /etc/resolv.conf
# 验证
time dig api.partner.example.com | grep "Query time"
结果:
;; Query time: 23 msec
从2.1秒降到23ms。但系统配置改动有两个问题:容器重启后配置丢失——得改Docker的dns配置;另外这只是临时缓解,没有根治——万一223.5.5.5也不稳定呢。而且,options timeout:1让glibc在1秒后切换下一个DNS服务器,这个行为对应用不透明。
第四步:业务代码层根治——本地DNS缓存
业务侧最稳的办法是跳过glibc的阻塞解析,自己缓存IP。这里我用Swoole Hook + 自定义DNS查询,实现一个本地缓存。
代码要点:Swoole的swoole_hook_sleep之类钩子不覆盖DNS解析。要自己写一个lookup函数,异步查DNS并缓存结果。下面是完整实现:
<?php
/**
* DNS本地缓存解决方案
* 环境:PHP 8.3 + Swoole 5.1.4 + 阿里云DNS
* 功能:异步DNS查询,TTL过期自动刷新,超时降级到系统解析
*/
class DnsCache
{
private array $cache = [];
private int $timeoutMs = 800;
private string $dnsServer = '223.5.5.5';
public function __construct(private string $domain)
{
}
/**
* 获取域名对应IP
* 优先读本地缓存,过期后异步刷新
*/
public function getIp(): ?string
{
$now = microtime(true);
if (isset($this->cache[$this->domain]) && $this->cache[$this->domain]['expire_at'] > $now) {
return $this->cache[$this->domain]['ip'];
}
// 缓存过期或不存在,走UDP查询
$ip = $this->queryDns($this->domain);
if ($ip !== null) {
$this->cache[$this->domain] = [
'ip' => $ip,
'expire_at' => $now + 55, // TTL 60秒,提前5秒刷新
];
}
return $ip;
}
/**
* 基于UDP的DNS查询
* 构造DNS请求包,解析A记录响应
*/
private function queryDns(string $domain): ?string
{
$request = $this->buildDnsQueryPacket($domain);
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 0, 'usec' => $this->timeoutMs * 1000]);
$start = microtime(true);
socket_sendto($socket, $request, strlen($request), 0, $this->dnsServer, 53);
$response = '';
$from = '';
$port = 0;
socket_recvfrom($socket, $response, 4096, 0, $from, $port);
socket_close($socket);
$latencyMs = (microtime(true) - $start) * 1000;
$ip = $this->parseDnsResponse($response);
if ($ip !== null) {
// 日志记录查询耗时
error_log(sprintf(
"[dns_cache] domain=%s ip=%s latency=%.2fms expire_at=%.0f",
$domain, $ip, $latencyMs, time() + 60
));
}
return $ip;
}
/**
* 构造DNS查询报文(标准头 + QDCOUNT=1)
*/
private function buildDnsQueryPacket(string $domain): string
{
$header = pack('n6', 0x1234, 0x0100, 0x0001, 0x0000, 0x0000, 0x0000);
$labels = explode('.', $domain);
$qname = '';
foreach ($labels as $label) {
$qname .= chr(strlen($label)) . $label;
}
$qname .= "\0";
// QTYPE: A记录(0x0001), QCLASS: IN(0x0001)
$qtype_qclass = pack('n2', 0x0001, 0x0001);
return $header . $qname . $qtype_qclass;
}
/**
* 解析DNS响应,提取第一条A记录
*/
private function parseDnsResponse(string $response): ?string
{
if (strlen($response) < 12) {
return null;
}
// 跳过DNS头部
$offset = 12;
$answerCount = unpack('n', substr($response, 6, 2))[1];
// 跳过查询区(QNAME)
while ($offset < strlen($response)) {
$len = ord($response[$offset]);
if ($len === 0) {
$offset++;
break;
}
$offset += $len + 1;
}
// 跳过QTYPE/QCLASS
$offset += 4;
// 遍历应答区,找A记录
for ($i = 0; $i < $answerCount; $i++) {
if ($offset >= strlen($response)) {
return null;
}
// 解析资源记录头
$type = unpack('n', substr($response, $offset + 1, 2))[1];
$rdlength = unpack('n', substr($response, $offset + 9, 2))[1];
$rdataOffset = $offset + 11;
// TYPE 1 = A记录
if ($type === 1 && $rdlength === 4) {
$ip = inet_ntop(substr($response, $rdataOffset, 4));
return $ip;
}
$offset = $rdataOffset + $rdlength;
}
return null;
}
}
// 使用示例:协程环境并发请求场景下,通过单例复用缓存
$dnsCache = new DnsCache('api.partner.example.com');
$ip = $dnsCache->getIp();
echo "解析结果: {$ip}\n";
关键点:缓存提前5秒刷新,TTU=55秒。避免在真实过期那一刻又触发一次慢解析。同时用error_log记录每次查询的DNS服务器IP和耗时,方便后续监控。
效果数据
改动上线后,我做了30分钟的压测和实际流量观察。环境:2核4G容器,PHP-FPM 8.3,请求量约1200次/分钟。
修复前(系统默认DNS配置):
压测工具: wrk -t4 -c100 -d30s https://api.partner.example.com/health
Latency Distribution
50% 82.00ms
75% 120.00ms
90% 810.00ms
99% 3.21s
DNS解析耗时采样(dig @10.15.0.2):
平均: 2117ms
最大: 4893ms
修复后(本地DNS缓存):
同上压测命令
Latency Distribution
50% 68.00ms
75% 91.00ms
90% 112.00ms
99% 156.00ms
DNS解析耗时采样(本地缓存命中):
平均: 0.31ms
最大: 1.02ms
P99从3.21秒降到156ms,DNS解析从2.1秒降到0.3毫秒。业务超时告警完全消失。
避坑指南:这些坑我实际踩过
坑1:dig查询慢不代表应用解析慢
dig默认走/etc/resolv.conf里的第一个nameserver,而glibcgetaddrinfo()的行为不同——它会并发向多个nameserver发起查询吗?不会。glibc是顺序尝试。所以dig结果只能反映第一个DNS服务器的状态。要覆盖应用行为,用getent hosts domain,这个命令跟应用走同一套解析逻辑:
# 模拟应用解析行为
time getent hosts api.partner.example.com
如果getent也慢,就确认是glibc解析器的问题。如果dig慢但getent快,说明应用走了别的解析路径(比如Nginx的resolver配置)。
坑2:容器里改/etc/resolv.conf只会让问题更隐蔽
Docker默认会把宿主机DNS配置注入容器。如果你在容器里改了/etc/resolv.conf,重启容器就丢。要持久化,在docker-compose里配:
services:
php:
image: your-php:8.3
dns:
- 223.5.5.5
dns_search:
- example.internal
dns_options:
- timeout:1
- attempts:1
- rotate
还有一点:dns_options里的attempts:1是必须的。默认attempts:2意味着如果第一个DNS服务器丢包,glibc会重试一次,最坏情况等10秒。设成attempts:1可以把单次解析最长耗时压到timeout * 服务器数量。
坑3:Swoole环境下不能用pcntl做DNS查询
我第一版实现用了pcntl_fork()子进程去查DNS。在Swoole的Worker进程里fork,会导致进程管理混乱,连接池里的MySQL/Redis连接被复制到子进程,出现「too many connections」报错。正确做法是直接用一个独立的UDP socket去查,像上面代码那样。不要引入额外进程。
坑4:DNS响应包解析别忽略了压缩指针
DNS响应里的域名可能用压缩指针(CNAME指向的域名经常出现)。如果业务域名有CNAME,上面parseDnsResponse()里的简单跳过逻辑会失败吗?不会——我的代码跳过了QNAME区域,但应答区里的NAME字段如果是压缩指针,解析会错乱。有CNAME的域名,建议直接用dns_get_record()或Swoole的swoole_dns_client库,别手写解析器。上面代码只能用于A记录直答场景,这个要写清楚。
坑5:NDS的TTL不等于业务可以缓存60秒
我设置TTL 60秒,但实际权威DNS返回的TTL可能是30秒。如果缓存时间大于源站TTL,DNS切换IP后,你会拿到旧IP直到缓存过期。稳妥做法:parseDnsResponse()里把TTL也解析出来,用返回的TTL乘以0.9作为缓存时间。代码示例:
// 解析Answer区TTL
$ttl = unpack('N', substr($response, $offset + 5, 4))[1];
$expireAt = $now + min($ttl * 0.9, 600); // 最长10分钟
坑6:测试环境复现不了,别急着上线上抓包
本地复现用tc命令模拟丢包延迟,再验证抓包流程:
# 模拟到DNS服务器的2000ms延迟
tc qdisc add dev eth0 root netem delay 2000ms
# 复现后,抓包验证
tcpdump -i eth0 -nn 'port 53' -tttt
# 搞完删掉模拟
tc qdisc del dev eth0 root
第二种方案:全局智能DNS客户端(systemd-resolved/nginx resolver)
如果业务不用Swoole,有另一个路线:装systemd-resolved并配置DNS缓存。Ubuntu 22.04默认自带systemd-resolved,但很多容器镜像没启用。
# 启用systemd-resolved
sudo systemctl enable systemd-resolved
sudo systemctl start systemd-resolved
# 配置DNS服务器(使用阿里云DNS)
sudo tee /etc/systemd/resolved.conf <<'EOF'
[Resolve]
DNS=223.5.5.5 119.29.29.29
FallbackDNS=10.15.0.2
DNSSEC=no
Cache=yes
DNSStubListener=yes
EOF
# 重启生效
sudo systemctl restart systemd-resolved
# 把/etc/resolv.conf指到stub解析器
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
# 测试
resolvectl query api.partner.example.com
这个方案的优点是全局生效,PHP、Java、Go程序不用改代码。但它有个坑:systemd-resolved的缓存策略不是完全遵从TTL,它有自己的LRU。实际压测效果:
第一次解析: 62ms
第二次解析: 0.8ms
缓存命中: 98.7%
但它不解决「DNS服务器本身慢」的问题——缓存过期后第一次访问仍可能等2秒。所以只能作为临时缓解。
思考:为什么DNS慢会导致业务超时
从上面数据能看到,单次DNS解析2.1秒,但P99到3.21秒。原因在于,cURL每次新建HTTPS连接都会调用getaddrinfo()。PHP-FPM无状态,每个请求都可能新建连接。高并发下,几十个PHP-FPM进程同时阻塞在DNS查询上,CPU还有空闲但请求全排队。所以超时不是资源不足,而是同步阻塞。
解决办法核心就一条:把DNS解析从同步阻塞变成缓存+超时降级。
结论
排查DNS问题的顺序是:先用dig +trace看权威链路和耗时,再用tcpdump抓包确认延迟发生在哪个环节,然后针对性修改系统配置或加应用缓存。线上稳定运行一个月,DNS导致的超时告警从日均7次降到0。整个排查链路可以从GitHub仓库拿到完整脚本:github.com/yourteam/dns-debugging。