DNS解析排查实战:从dig到抓包
发布日期: 2026/08/04 阅读总量: 2

一次线上偶发超时,根因是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