简介:海风域名查询工具 v1.0 是一套基于 PHP 编写的域名查询程序,面向站长、域名投资者以及习惯在 Linux 服务器上作业的运维与建站人员,用于把零散的域名状态核验集中到自建入口中完成,减少逐条手工查询的重复劳动。整包以 rar 格式分发,体积约 372KB,体量轻巧,主体为 PHP 源码,并附带站点地图、配置项与安装数据脚本等辅助文件,上传到自有主机即可部署。程序面向 Linux 主机环境,部署依赖文件权限调整(相关文件需设为 777)、安装入口访问与后台登录三个环节,默认管理员账号与密码均为 admin,便于安装后立即进入后台熟悉功能布局。对希望自建查询站点、练习 PHP 站点部署与权限配置的读者来说,这份源码提供了从文件权限设置、数据导入到后台登录的完整实践样本,也可作为二次开发或小型站点功能模块的参考。目前已有 118 人浏览学习。
1. 从两百个候选域名到一张状态表:这套 PHP 源码到底替你干了什么
手上有两百个候选域名,要在投放前筛一遍:哪些已经被注册、哪些还空着、空着的那些到底有没有解析记录。靠浏览器一个个开网页查,查到第五十个就想放弃。海风域名查询工具 v1.0 是一份 PHP 源码形态的小工具,干的就是把这件重复劳动收进一个入口——输入域名列表,走 WHOIS 的 43 端口拿注册信息,用 RDAP 补结构化字段,再用 DNS 记录判断域名是不是真的“活着”,最后吐出一张带状态和查询时间的表。它不适合当商业级域名监控平台用,但应付选品筛查、注册前查重、批量资产盘点这类活绰绰有余。下面按我自己拆包的顺序,把三条查询通道、配置项、批量调度和踩过的坑一条条摊开说。
2. 把查询链路拆成三条通道:WHOIS 43 端口、RDAP 与 DNS 怎么分工
拿到这份源码先别急着点运行,得先搞清楚它为什么同时用了三套查询方式。很多人第一反应是“抓网页不就行了”,但域名查询这个场景里,网页抓取是最不稳的一条路——页面结构改一次,正则全废,还会被前端渲染、验证码、Cookie 墙拦住。域名信息的官方出口其实很朴素:WHOIS 文本协议、RDAP 结构化接口、DNS 记录查询,三个都是公开、无鉴权、机器可读的通道。这份工具的价值就在于把三者编排成一条降级链:先 WHOIS 拿全量文本,再用 RDAP 拿结构化字段,最后用 DNS 判断可用性。
2.1 为什么把 WHOIS 文本协议放在第一顺位
WHOIS 协议简单到有点原始:向目标服务器的 43 端口建立 TCP 连接,把域名加一个换行发过去,服务器把纯文本吐回来,连接关闭。没有 HTTP 头,没有 TLS 握手,没有 JSON Schema,PHP 用fsockopen十几行就能实现一个客户端。放在第一顺位的理由有三个:覆盖最广(绝大多数 TLD 都提供 43 端口服务)、信息最全(注册商、注册时间、到期时间、域名状态码、DNS 服务器一应俱全)、依赖最少(不需要 curl,不需要证书链,不需要处理重定向)。
代价也很明显:返回的是自由格式文本,不同注册局的字段名、日期格式、空行习惯全都不一样,甚至连“域名未注册”这句话的措辞都不统一——有的写No match for,有的写NOT FOUND,有的写Domain not found,还有的干脆返回一段免责声明加一个空行。这就决定了 WHOIS 的解析层必须是“按后缀配置规则”,而不是一套通用正则打天下。我一般会把每个 TLD 的判定关键词、字段别名、日期格式单独存成一份配置,新增后缀时只改配置不动代码,这是这份工具能不能长期用下去的关键。
还有一个容易被忽略的机制叫 thin WHOIS。部分后缀的注册局只返回很薄的一层信息(域名、注册商 ID、状态码、DNS 服务器),完整的注册人信息要再向注册商服务器发起第二次查询。源码里如果没做这个二次跳转,你会看到“注册商”一栏是空的,然后误以为解析失败。判断方法不难:看返回文本里有没有ReferralServer或者whois:开头的一行地址,有就说明该发起第二次查询了。
2.2 一个能用的 WHOIS 客户端:连接、超时、分块读
先看核心类。这段是整个工具的地基,超时和分块读是它能不能在批量场景里活下来的分水岭。
<?php // src/WhoisClient.php class WhoisClient { private array $servers; // ['com' => 'xxx', 'cn' => 'yyy'] 按后缀索引 private int $timeout; // 单次连接与读取的超时秒数 public function __construct(array $servers, int $timeout = 8) { $this->servers = $servers; $this->timeout = $timeout; } public function query(string $domain): array { $domain = strtolower(trim($domain)); $tld = substr(strrchr($domain, '.'), 1); // 取最后一个点后面的后缀 if (!isset($this->servers[$tld])) { return ['ok' => false, 'error' => 'unsupported_tld', 'raw' => '']; } $server = $this->servers[$tld]; // 43 端口是 WHOIS 的标准端口,第三个参数是超时 $fp = @fsockopen($server, 43, $errno, $errstr, $this->timeout); if (!$fp) { return ['ok' => false, 'error' => "connect_failed:{$errno}:{$errstr}", 'raw' => '']; } stream_set_timeout($fp, $this->timeout); // 读超时,和连接超时分开设 fwrite($fp, $domain . "\r\n"); // 协议要求以 CRLF 结尾 $raw = ''; while (!feof($fp)) { $chunk = fread($fp, 8192); if ($chunk === false) { break; } $raw .= $chunk; $meta = stream_get_meta_data($fp); if ($meta['timed_out']) { // 读到一半卡死,必须主动放弃 fclose($fp); return ['ok' => false, 'error' => 'read_timeout', 'raw' => $raw]; } } fclose($fp); return ['ok' => true, 'error' => '', 'raw' => $raw]; } }逐点说清楚这段在干什么。substr(strrchr($domain, '.'), 1)取的是最后一个点之后的部分,处理a.b.com这种多级域名时仍然得到com;但如果遇到co.uk这类二级后缀,就必须换成后缀匹配表而不是简单截取,这是后面配置层要解决的问题。fsockopen的第三个参数是连接超时,很多实现只写 5 秒,在跨境查询里经常连不上,8 到 10 秒是个平衡点。stream_set_timeout管的是读取超时,和连接超时不是一回事——服务器可能连上了但数据发到一半不动,这种情况只能靠读超时兜住。
fread用 8192 字节分块读而不是stream_get_contents,是为了能在读的过程中检查timed_out标记。如果一把梭全读,卡死的连接会一直挂到 PHP 的max_execution_time才断,批量跑的时候几个这样的连接就能把整个进程拖死。feof循环配合timed_out检查,是这套客户端能扛住批量查询的核心。返回值统一成ok / error / raw三字段,上层不需要关心是连不上还是读超时,按ok分支处理即可。
2.3 RDAP 兜底:JSON 结构比正则稳
WHOIS 的文本格式注定了解析层要打补丁。RDAP 是同一份数据的结构化版本,走 HTTPS,返回 JSON,字段有明确的 schema,日期是 ISO 8601,注册状态是数组。对com、net、org、info这些主流后缀,我已经把 RDAP 当成首选,WHOIS 退成兜底。原因很简单:JSON 里的entities数组取注册商,events数组按eventAction取注册和到期时间,一次json_decode加两次数组遍历就完事,不用管免责声明在第几行。
<?php // src/RdapClient.php function rdapLookup(string $base, string $domain, int $timeout = 8): array { // base 是 RDAP 引导地址,形如 https://<rdap-host>/,实际值从配置读 $url = rtrim($base, '/') . '/domain/' . rawurlencode($domain); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_TIMEOUT => $timeout, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_USERAGENT => 'DomainLookup/1.0 (local)', CURLOPT_HTTPHEADER => ['Accept: application/rdap+json'], ]); $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($body === false) { return ['ok' => false, 'http' => 0, 'error' => $err, 'data' => null]; } // 404 是有效信息:表示该域名未注册,不是查询失败 if ($code === 404) { return ['ok' => true, 'http' => 404, 'error' => '', 'data' => null]; } $data = json_decode($body, true); if ($code !== 200 || !is_array($data)) { return ['ok' => false, 'http' => $code, 'error' => 'bad_response', 'data' => null]; } return ['ok' => true, 'http' => 200, 'error' => '', 'data' => $data]; }这里最值钱的一行是404的判断。RDAP 规范里,未注册域名返回 404,已注册返回 200 加完整对象。很多实现把 404 当成失败重试三次,白白浪费时间还触发限流。http字段单独返回,是为了让上层能区分“查不到”和“查挂了”两种语义。
CURLOPT_MAXREDIRS设 3 是因为 RDAP 引导服务会做一次 302 跳转到具体注册局的服务器,跳多了说明配置有问题。Accept头写application/rdap+json是规范要求,不写有的服务端会返回 HTML 错误页,json_decode直接返回 null。超时给 8 秒、连接超时给 4 秒,这个配比在跨境请求里比较稳——连接握手通常很快,慢的是服务端生成响应。
限流是 RDAP 绕不开的事。返回 429 时不要立刻重试,先读响应头里的Retry-After,按它给的值退避。我在批量场景里见过连续 429 之后整个 IP 被静默丢弃十分钟的情况,那时候再怎么重试都是徒劳。
2.4 DNS 记录补齐“域名是否活着”
WHOIS 和 RDAP 只回答“注册没注册”,不回答“有没有在用”。一个域名可能注册了但没配任何解析记录,也可能没注册但历史缓存还在。DNS 查询补的就是这一块。
<?php // src/DnsProbe.php function dnsProbe(string $domain): array { // 一次拿多种记录类型,减少往返 $raw = @dns_get_record( $domain, DNS_A | DNS_AAAA | DNS_NS | DNS_MX | DNS_TXT ); if ($raw === false) { return ['ok' => false, 'error' => 'dns_query_failed', 'records' => []]; } $out = ['A' => [], 'AAAA' => [], 'NS' => [], 'MX' => [], 'TXT' => []]; foreach ($raw as $r) { $type = $r['type'] ?? ''; if (!isset($out[$type])) { continue; } // 不同类型字段不一致,统一收敛成字符串 if ($type === 'A') { $out['A'][] = $r['ip'] ?? ''; } if ($type === 'AAAA') { $out['AAAA'][] = $r['ipv6'] ?? ''; } if ($type === 'NS') { $out['NS'][] = $r['target'] ?? ''; } if ($type === 'MX') { $out['MX'][] = ($r['pri'] ?? 0) . ' ' . ($r['target'] ?? ''); } if ($type === 'TXT') { $out['TXT'][] = is_array($r['txt'] ?? null) ? implode('', $r['txt']) : (string)($r['txt'] ?? ''); } } return ['ok' => true, 'error' => '', 'records' => $out]; }dns_get_record的第二个参数是位掩码,用|组合多个类型,一次调用拿回全部,比查五次快得多。注意这个函数在查询失败时返回false并抛E_WARNING,前面加@抑制是必要的,不然后端日志会被刷爆。返回值里TXT记录比较特殊,有的 PHP 版本返回字符串,有的返回数组,代码里做了兼容——这是实际跑出来的差异,不是防御性编程的洁癖。
判断“域名活着”的逻辑我一般这么定:有A或AAAA记录算“有站点”,只有MX算“只有邮件”,只有NS算“已委派未使用”,全空算“注册但闲置”。这四个状态在选品场景里价值完全不同,全塞进一个has_dns布尔值就浪费了。
3. 本地跑通第一次查询:环境、配置项与四个必改参数
代码看着不多,但真到自己机器上跑,卡人的往往不是逻辑而是环境。这份源码对运行环境的要求不高,麻烦的是几个默认关闭的扩展和一份必须自己填的服务器清单。
3.1 运行环境与扩展清单
PHP 7.4 以上就能跑,8.x 更省心,主要是数组和类型声明的行为更一致。扩展方面,curl和json基本是默认带的,pdo_mysql看你要不要落库,intl是可选的——只有处理中文域名的时候才需要。dns_get_record属于标准库,不用额外装,但它依赖系统的 DNS 解析器,容器里跑的时候要确认resolv.conf不是空的。
| 组件 | 是否必须 | 用途 | 缺了会怎样 |
|---|---|---|---|
| PHP 7.4+ | 必须 | 运行环境 | 语法不兼容,直接报错 |
| curl 扩展 | 必须 | RDAP 请求 | 调用curl_init未定义 |
| json 扩展 | 必须 | RDAP 解析 | json_decode未定义 |
| pdo_mysql | 可选 | 结果落库 | 只能用文件缓存模式 |
| intl 扩展 | 可选 | 中文域名转 punycode | 中文域名查询失败 |
| 出站 43 端口 | 必须 | WHOIS 查询 | 连接超时,全部降级到 RDAP |
最后一行是新手最容易翻车的地方。不少云主机的默认安全组只放行 80 和 443,43 端口的出站连接直接被丢包,现象是“所有 WHOIS 查询都超时,但 RDAP 正常”。排查时先用nc -vz或telnet试一下目标服务器的 43 端口通不通,别在代码里找 bug。
提示:本地开发机上 43 端口通常是通的,一上服务器就挂,先在服务器上验证端口再改代码。
3.2 配置文件里必须改的四处
源码里通常有一个config.php,里面四组配置决定了工具能不能用起来。这四处不改,跑起来就是一堆超时和空结果。
<?php // config.php return [ // 1. WHOIS 服务器清单:键是后缀,值是 43 端口的服务地址 // 按公开的 TLD 委派清单逐个核对填写,别凭记忆写 'whois_servers' => [ 'com' => 'whois-COM.example-endpoint', 'net' => 'whois-NET.example-endpoint', 'org' => 'whois-ORG.example-endpoint', 'cn' => 'whois-CN.example-endpoint', ], // 2. RDAP 引导地址,用来把域名路由到对应注册局 'rdap_base' => 'https://rdap-bootstrap.example-endpoint/', // 3. 每个后缀的“未注册”判定关键词,措辞各家不同 'not_found_keywords' => [ 'com' => ['No match for', 'NOT FOUND'], 'cn' => ['No matching record', 'not found'], ], // 4. 超时与限速 'timeout' => 8, // 单次查询超时秒数 'qps' => 3, // 每秒最多发起几次查询 'cache_ttl' => 86400, // 同一域名多久内不重复查(秒) ];第一组是硬骨头。后缀到服务器的映射不是靠猜,也不是靠试,公开的 TLD 委派清单里有权威记录,新增后缀时去查一次填进去即可。填错的表现是连上了但返回空字符串或者一段“请访问某某页面”的提示,这种情况一定要在配置阶段就发现,不要让它在批量阶段才暴露。
第二组 RDAP 引导地址的作用是省事——不同后缀对应不同注册局,引导服务会根据后缀自动 302 跳转,你不用维护一张巨大的映射表。代价是多一次跳转,所以CURLOPT_MAXREDIRS不能设成 0。
第三组是最容易被省略的一组。前面说过,不同注册局对未注册域名的措辞不一样,com和cn的判定关键词就完全不同。如果不配这一项,代码里写死一个No match,那对cn后缀就永远判定成“已注册”,结果表全是错的。这是我在实际项目里踩过的坑,后面对比预期结果才发现。
第四组的qps是保护自己也是保护对方。默认给 3,意思是每秒最多三次查询,配合usleep实现。有人图快调到 20,跑几十条就开始大面积超时,因为对端把你当异常流量处理了。cache_ttl默认一天,域名注册状态一天内基本不会变,重复查纯属浪费。
3.3 跑通第一次单域名查询
配置填好之后,先别碰批量,用单域名验证整条链路。源码一般会带一个命令行入口,没有的话自己写十行。
# 单域名查询,输出 JSON 便于肉眼看字段 php cli.php --domain=example-domain.com --format=json # 预期输出(结构示意) # { # "domain": "example-domain.com", # "status": "registered", # "source": "rdap", # "registrar": "...", # "created_at": "2019-05-11T00:00:00Z", # "expires_at": "2029-05-11T00:00:00Z", # "dns": {"A": ["203.0.113.10"], "NS": ["ns1.example-dns.com"]}, # "checked_at": "2025-01-01 10:00:00" # }--format=json是个实用开关,人类看表格舒服,但排查问题的时候 JSON 更直接——能一眼看出source字段走的是rdap还是whois,走的是哪条通道决定了后面该看哪个日志。--domain参数接受单个域名,批量入口另走--file。
第一次跑通之后,建议立刻换一个明确未注册的域名再跑一次。这一步的目的是验证not_found_keywords配置对不对,很多人只测了已注册的域名,觉得能返回数据就算成功,结果批量一跑发现全是“已注册”,回头查半天才发现是判定关键词没配。两个方向各测一次,成本不到一分钟。
3.4 换到 Web 入口:一个表单页的最小实现
命令行验证完,如果想给不写终端的同事用,源码里通常带一个index.php。核心就是一个表单加一段 POST 处理,不复杂,但有两个细节要注意:输入校验和输出转义。
<?php // index.php:极简 Web 入口 require __DIR__ . '/bootstrap.php'; $domain = $_POST['domain'] ?? ''; $result = null; $error = ''; if ($domain !== '') { // 只保留合法字符,长度上限 253 是域名总长的硬限制 $domain = strtolower(trim($domain)); if (!preg_match('/^[a-z0-9.-]{1,253}$/', $domain)) { $error = '域名格式不合法'; } else { $client = new WhoisClient($config['whois_servers'], $config['timeout']); $resp = $client->query($domain); $result = $resp['ok'] ? parseWhois($resp['raw'], $domain) : ['status' => 'error']; } } ?> <form method="post"> <input name="domain" value="<?= htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') ?>" /> <button type="submit">查询</button> </form> <?php if ($error): ?> <p class="err"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p> <?php endif; ?>preg_match里的字符集把中文、空格、斜杠全挡在外面,这在 Web 入口是必须的——命令行你还能自己控制输入,表单是开放给所有人的。htmlspecialchars两处都要加,一处是回填输入框,一处是错误信息,漏一处就是反射型 XSS。域名总长 253、单个标签 63 这些数字不是拍脑袋,是 DNS 规范里的硬限制,校验时顺手带上能挡掉一批畸形输入。
4. 从一条到一千条:批量调度、字段解析与结果落库
单条能跑通只能算玩具。这份源码真正的使用场景是成百上千条一起查,这时候瓶颈从“能不能查”变成“会不会被封、会不会漏、结果怎么存”。
4.1 批量的调度模型:分片、并发与退避
批量查询有三条铁律:控制并发、控制速率、失败要退避。三者缺一不可。
<?php // batch.php:分片 + 限速 + 指数退避 $domains = array_values(array_unique(array_filter(array_map('trim', file($argv[1]))))); $chunkSize = 20; $failStreak = 0; foreach (array_chunk($domains, $chunkSize) as $batch) { foreach ($batch as $domain) { $resp = (new WhoisClient($config['whois_servers'], $config['timeout']))->query($domain); if (!$resp['ok']) { $failStreak++; // 连续失败到 3 次,退避时间翻倍,最长 16 秒 if ($failStreak >= 3) { sleep(min(16, 2 ** min($failStreak - 2, 4))); } } else { $failStreak = 0; // 成功一次就重置计数 } saveResult($pdo, $domain, $resp); usleep((int)(1000000 / $config['qps'])); // 微秒,3 qps 约等于 33 万微秒 } sleep(1); // 每批之间喘口气,避免瞬时突发 }array_unique去重是省钱的第一步,列表里有重复域名的情况比想象中多。array_chunk分片的作用不是并发,而是给批间停顿一个自然的边界——每批结束后sleep(1),让对端有机会清空自己的计数窗口。
$failStreak那段是核心。连续失败说明问题不在单个域名,而在网络或对端限流,这时候继续以原速率猛冲只会让情况更糟。2 ** min($failStreak - 2, 4)算出来的退避是 2、4、8、16 秒封顶,成功一次立刻归零。我在实际跑的时候见过连续十几次超时之后退避到 16 秒,然后突然恢复正常的情况——这就是对端的限流窗口过了。
usleep的参数是微秒,1000000 / 3约等于 333333。这个数别写死,跟着配置里的qps走,改配置就能改速率。
4.2 返回文本的字段解析:正则表与归一化
WHOIS 返回的文本各家不同,但字段名有迹可循。做法是给每个字段准备一组候选正则,按顺序试,命中就用。
<?php // src/Parser.php function parseWhois(string $raw, string $domain): array { $fieldPatterns = [ 'registrar' => [ '/^\s*Registrar:\s*(.+)$/mi', '/^\s*Registrar Name:\s*(.+)$/mi', '/^\s*Sponsoring Registrar:\s*(.+)$/mi', ], 'created_at' => [ '/^\s*Creation Date:\s*(.+)$/mi', '/^\s*Registration Time:\s*(.+)$/mi', '/^\s*Created On:\s*(.+)$/mi', ], 'expires_at' => [ '/^\s*Registry Expiry Date:\s*(.+)$/mi', '/^\s*Expiration Time:\s*(.+)$/mi', '/^\s*Expiry Date:\s*(.+)$/mi', ], 'status' => [ '/^\s*Domain Status:\s*(.+)$/mi', ], ]; $out = []; foreach ($fieldPatterns as $field => $patterns) { $out[$field] = null; foreach ($patterns as $p) { if (preg_match($p, $raw, $m)) { // 有些行后面跟一堆说明文字,取第一个空白前的部分 $out[$field] = trim(preg_split('/\s{2,}/', trim($m[1]))[0]); break; } } } // 日期统一成 Y-m-d,解析不了就保留原串,绝不丢信息 foreach (['created_at', 'expires_at'] as $f) { if ($out[$f]) { $ts = strtotime($out[$f]); $out[$f] = $ts ? date('Y-m-d', $ts) : $out[$f]; } } $out['domain'] = $domain; return $out; }/mi两个修饰符的含义要分清:m让^和$匹配每一行的开头结尾,不然只能匹配整个字符串的首尾;i是忽略大小写,不同注册局的大小写习惯不统一。没有m,这段正则在多行文本里一条都匹配不上,这是个很隐蔽的错误——正则本身没错,就是不生效。
preg_split('/\s{2,}/', ...)处理的是行尾注释。比如Domain Status: clientTransferProhibited https://...这种,取第一个双空格之前的部分就得到纯状态码。用双空格而不是单空格,是因为注册商名称本身可能带空格,单空格切会把“Example Registrar Inc”切碎。
日期统一成Y-m-d是为了入库方便,strtotime解析失败时保留原始字符串而不是写 null,这个选择有讲究——写 null 等于丢失了“原始返回里其实有日期”这个事实,后面排查时就没法回溯了。
4.3 落库与缓存:表结构加命中策略
结果存哪里决定了这个工具能不能当资产台账用。文件存一份 CSV 也能看,但查重、按状态筛选、看历史变化都得靠数据库。
CREATE TABLE domain_check ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, domain VARCHAR(191) NOT NULL COMMENT '统一小写、去尾点', tld VARCHAR(32) NOT NULL, status ENUM('registered','available','unknown','error') NOT NULL DEFAULT 'unknown', source ENUM('whois','rdap','cache') NOT NULL DEFAULT 'whois', registrar VARCHAR(191) DEFAULT NULL, created_at DATE DEFAULT NULL, expires_at DATE DEFAULT NULL, raw_hash CHAR(32) DEFAULT NULL COMMENT '原始返回的 md5,用于判断内容是否变化', checked_at DATETIME NOT NULL, UNIQUE KEY uk_domain (domain), KEY idx_status (status), KEY idx_checked (checked_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;VARCHAR(191)不是随便写的,是给 utf8mb4 下的索引留余地——191 个字符乘以 4 字节刚好卡在一些索引长度限制以内。domain上的唯一索引配合INSERT ... ON DUPLICATE KEY UPDATE,天然实现了“同一个域名只留一条最新记录”。raw_hash存原始返回的 md5,用途是判断这次查到的内容和上次是否一致:一致就说明状态没变,可以把checked_at更新一下就走,不用重新解析。
status用 ENUM 而不是 VARCHAR,是为了让筛选查询走索引,同时避免出现Registered、registered、REGISTERED三种写法混在表里。归一化的工作在入库前做完,别指望数据库帮你兜。
缓存策略分两层。第一层是cache_ttl控制的时间窗,同一域名 24 小时内直接读库;第二层是raw_hash比对,超过时间窗但内容没变时,只更新时间戳不重算字段。两层叠加之后,日常增量盘点的实际请求量能降一个数量级。
5. 排查清单:五类翻车现场与对应的修法
这套东西跑通不难,跑稳很难。下面五条都是实际撞过的,按“现象 → 原因 → 解决”的顺序写,对着看能省不少时间。
5.1 状态误判:明明没注册却显示已注册
现象:批量结果里一大片registered,随手抽几个去浏览器验证,全是未注册的。
原因:判定关键词只配了com那一套。cn后缀对未注册域名返回的是另一套措辞,代码里没命中,就落到“默认已注册”的分支上。还有一种变体是 thin WHOIS,注册局返回里带了一行Registrar:但内容是个占位符,解析器把占位符当成了真实注册商。
解决:每个后缀单独配not_found_keywords,新增后缀时先手工查两个已知未注册的域名,把返回文本里的判定句抄进配置。thin WHOIS 的识别靠ReferralServer行,命中就发起二次查询,别直接采信第一层结果。
5.2 跑到一半全线超时:限速没做或者做假了
现象:前 150 条正常,之后所有请求都超时,重启进程又能跑一阵。
原因:对端按源 IP 做了速率限制,超出窗口的连接被静默丢弃。代码里虽然写了usleep,但位置放错了——放在循环开头而不是请求之后,实际速率翻倍。
解决:usleep紧跟查询之后,按qps换算微秒数;连续失败三次启用指数退避;把qps从 3 降到 2 观察一轮,确认是限速再考虑往上调。判断依据看失败是不是集中在某个时间点之后突然爆发,而不是零散分布。
5.3 中文域名查不到:没做 punycode 转换
现象:输入中文域名,要么直接报格式错误,要么查询返回空。
原因:WHOIS 和 RDAP 只认 ASCII 的 punycode 形式,原始中文字符串发过去对端不认。前面 Web 入口的正则[a-z0-9.-]还会直接把中文挡掉。
解决:入口处统一用idn_to_ascii()转成xn--开头的形式再查询,展示时用idn_to_utf8()转回来。转换前先判断intl扩展是否加载,没加载就给明确提示,别让它静默失败。转换失败(比如域名里有非法字符)要单独返回一个错误码,和“域名未注册”区分开。
5.4 入库撞主键:域名没归一化
现象:INSERT报唯一键冲突,或者表里同一个域名出现好几条。
原因:输入里混着大小写、末尾多余的点、www.前缀,Example.com、example.com.、www.example.com在唯一索引眼里是三个不同的值。
解决:入库前统一走一遍归一化——转小写、去掉末尾的点、去掉协议前缀和路径部分。www.要不要去得看业务语义,做资产盘点时我一般保留,因为www.example.com和example.com在 DNS 层面确实是两条记录。归一化函数写成单独一个,所有入口都调它,别在每个地方各写一遍。
5.5 服务器上 DNS 查询全空:解析器不通
现象:本地跑得好好的,部署到服务器之后dns_get_record全部返回空数组或者false。
原因:容器环境的resolv.conf是空的,或者出站 53 端口被安全组挡了。dns_get_record不像 curl 那样有明确的错误码,失败就是空,很容易误判成“域名没有解析记录”。
解决:先cat /etc/resolv.conf确认有 nameserver;再dig一个已知域名验证 53 端口通不通。如果环境确实不放行 53 端口,就换成走 HTTPS 的 DNS 查询接口,把 DNS 探测这一层做成可替换的适配器,别在业务代码里写死dns_get_record。
注意:DNS 查询失败和“域名无记录”在返回值上都是空,必须靠
false判断区分,不能用empty()。
6. 把“能不能用”做成评分:一套自检脚本与三个进阶技巧
工具跑顺之后,我习惯再加一层“可用性评分”,把散落的字段压成一个 0 到 100 的数字,选品的时候按分数排序,比一列列看字段快得多。评分维度不用多,五个够用:注册状态(未注册给高分)、DNS 是否解析、是否有 MX 记录、域名长度与连字符情况、后缀常见度。权重可以先拍一版,跑一批已知好域名再校准,别指望一次调准。
<?php // src/Score.php function scoreDomain(array $row): int { $score = 0; // 未注册是前提条件,已注册直接归零,不用往下算 if (($row['status'] ?? '') !== 'available') { return 0; } $score += 50; $dns = $row['dns'] ?? []; if (!empty($dns['A']) || !empty($dns['AAAA'])) { $score += 15; } // 有站点痕迹 if (!empty($dns['MX'])) { $score += 5; } // 有邮件能力 $name = explode('.', $row['domain'])[0]; $len = strlen($name); if ($len <= 8) { $score += 20; } // 短域名稀缺 elseif ($len <= 12) { $score += 12; } elseif ($len <= 18) { $score += 5; } if (substr_count($name, '-') === 0) { $score += 10; } // 无连字符 if (preg_match('/\d/', $name)) { $score -= 8; } // 数字降权 return max(0, min(100, $score)); }权重是我自己调的,不用照抄。要点在于“未注册才计分”这个前置判断——已注册域名算得再高也没有意义,先归零能省掉一堆无效计算。数字降权 8 分是个经验值,含数字的域名在记忆成本上确实吃亏,但具体到某些行业可能反而合适,按自己的场景改。
自检脚本比评分更重要。我一般准备一份二十条的对照清单,一半已知注册、一半已知未注册,每次改完配置或者换服务器,先跑这一份看命中率:
# selftest.sh:对照清单跑一遍,统计状态命中率 php cli.php --file=tests/known_domains.txt --format=csv > /tmp/out.csv # 用 awk 对比预期列和实际列,输出不一致的行 awk -F',' 'NR>1 && $2 != $3 {print "MISMATCH:", $1, "expect", $2, "got", $3}' /tmp/out.csv # 命中率低于 90% 就别往下跑批量了awk那句里$2是预期状态、$3是实际状态,列序按自己导出的 CSV 调整。命中率低于九成基本可以断定是配置问题,不是网络抖动——网络问题通常是零散的error,不会集中体现成状态判反。
最后说三个用久了才体会到的技巧。第一个是给raw_hash加保留期,超过 90 天的原始文本归档到单独的表,主表只留哈希,不然表会膨胀得很快。第二个是把后缀清单拆成“高频”和“长尾”两组,高频的走 RDAP 快通道,长尾的走 WHOIS 兜底,整体耗时能压掉三成。第三个是查询时间避开整点,不少注册局在整点做批处理,那几分钟的失败率明显高于其他时段。
这套工具我前后改过几版,最开始那版图省事,判定关键词写死了一个No match,批量跑完导出表格直接傻眼,两百条里有一百多条状态是错的,还得手工复核一遍。从那以后我每次改配置,都强制先跑那份二十条的对照清单,命中率不到 95% 绝不往下走。慢是慢了点,但比返工便宜。希望帮到你。
本文还有配套的精品资源,点击获取