news 2026/10/27 12:22:36

原生PHP批量域名WHOIS查询工具:状态判定、缓存与导出实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
原生PHP批量域名WHOIS查询工具:状态判定、缓存与导出实践

简介:海风域名查询工具 v1.0 是一套面向站长、PHP 初学者与二次开发者的轻量级域名查询程序,适合部署在 Linux 主机环境,用于快速搭建域名信息查询页面。资源属于 PHP 源码类,压缩包约 372KB,体量小巧,便于上传、备份和迁移;由于文件总数与类型明细暂无数据,具体构成不做展开。程序提供安装引导与后台管理入口,配置集中,默认后台账号可用于首次登录调试,适合学习查询逻辑、后台权限控制及前后台数据交互,也能作为二次开发的基础模板。已有 118 人学习或下载。读者可获得一套可直接部署的 PHP 源码、基础后台管理框架和安装配置参考,并在此基础上调整查询参数、替换页面样式或扩展功能模块,减少从零搭建查询站点的时间。整体偏向轻量工具与学习型源码,适合需要快速验证想法或开展小型项目实践的用户。

1. 一个域名查询工具能省下的重复劳动

A 开发者把一份 v1.0 的域名查询工具源码丢过来时,说了一句很实在的话:手里攥着二十个备选域名,一个个丢进网页查询框里点,点完再把结果抄进表格,一上午就没了。这套工具解决的就是这个动作——批量丢域名进去,自动判断哪些还能注册,把 WHOIS 原始返回、DNS 解析结果、查询时间一起落库,最后导成一份能直接发给同事的 CSV。

它是 PHP 写的,没有框架依赖,扔进一台装了 PHP 7.4 以上和 MySQL 的机器就能跑起来,前端是原生 JS 加一层薄薄的模板。适合两类人:一类是自己注册域名、需要反复比价的独立开发者;另一类是手上有一批客户域名、要做到期监控的小团队。功能不花哨,但链路完整,从查询、判定、缓存到导出,每一段都能单独拆出来改。

2. 技术栈与目录结构:一个不依赖框架的 PHP 单体怎么分层

2.1 选型:为什么是原生 PHP 加 PDO

拿到源码第一件事是看 composer.json,结果里面除了 autoload 的 PSR-4 映射,一个 require 都没有。这在今天有点反直觉,但对这个项目来说恰恰是合适的——它的核心工作只有两件:向 43 端口的 WHOIS 服务器发一段文本,把返回的文本按关键词判定状态。没有复杂的 ORM 关系,没有需要中间件编排的请求管线,上一套框架反而要多解释一层「为什么这里要这么写」。

数据库层用的是 PDO,开了异常模式和预处理,没用任何查询构造器。好处是 SQL 全部肉眼可见,改字段的时候直接搜表名就行;代价是没有迁移工具,建表脚本老老实实放在database/schema.sql里,第一次部署手动导一次。我一般会在这类项目里保留这个朴素做法,因为表就那么三四张,上迁移框架属于给自己找事。

真正值得说的是它把「查询」和「判定」拆成了两个类。WhoisClient只负责拿到原始字符串,StatusResolver只负责把字符串翻译成三态:可注册、已注册、判定不出来。这个拆分是整个项目最值钱的地方,后面章节会展开讲为什么不能合并。

2.2 目录结构:每个文件负责什么

源码解压后大概长这样,文件数不多,但层次是清楚的:

domain-checker/ ├── public/ │ ├── index.php # 唯一入口,所有请求从这里进 │ └── assets/ │ ├── app.js # 前端交互:提交、轮询进度、渲染表格 │ └── style.css ├── app/ │ ├── Controller/ │ │ └── QueryController.php │ ├── Service/ │ │ ├── WhoisClient.php # socket 直连 43 端口 │ │ ├── DnsResolver.php # A / NS 记录解析 │ │ ├── StatusResolver.php # 原始文本 -> 三态判定 │ │ └── BatchRunner.php # 批量分片与限速 │ ├── Repository/ │ │ └── DomainRepository.php │ └── Support/ │ ├── Config.php │ └── Logger.php ├── config/ │ ├── config.php # 数据库、超时、限速等参数 │ └── whois_servers.php # 后缀 -> WHOIS 服务器映射表 ├── database/ │ └── schema.sql └── composer.json

app/下面按职责分四层:Controller 只做参数校验和响应组装,Service 装业务动作,Repository 管 SQL,Support 放配置和日志这类横切工具。这种分法在原生 PHP 项目里算是自律的,很多同类源码会把所有逻辑塞进一个index.php,改起来就是一场灾难。

config/whois_servers.php是单独拆出来的,这一点很关键。它是一张纯映射表,加后缀只需要动这一个文件,不用碰任何类:

<?php // config/whois_servers.php // 键是小写后缀,值是 WHOIS 服务器域名(43 端口) return [ 'com' => 'whois.verisign-grs.com', 'net' => 'whois.verisign-grs.com', 'org' => 'whois.pir.org', 'cn' => 'whois.cnnic.cn', 'io' => 'whois.nic.io', 'co' => 'whois.nic.co', 'me' => 'whois.nic.me', 'dev' => 'whois.nic.google', ];

这个数组的键必须和后缀完全一致,strtolower之后再查表,否则.CN这种大写输入会直接命中不到。值是裸域名,代码里会拼成fsockopen($host, 43),不要在这里写http://或者带端口,端口是固定 43,写进去会连接失败。

2.3 环境准备与首次运行

跑起来需要的东西不多:PHP 7.4+(要开sockets和curl扩展)、MySQL 5.7+ 或 8.0、一个能出网的运行环境。本地验证推荐直接用 PHP 内置服务器,不用配 Nginx:

# 1. 装依赖(只有 autoload,几秒钟) composer install --no-dev # 2. 建库建表 mysql -uroot -p -e "CREATE DATABASE domain_checker DEFAULT CHARSET utf8mb4;" mysql -uroot -p domain_checker < database/schema.sql # 3. 改配置 cp config/config.example.php config/config.php # 编辑 config/config.php 填入数据库账号密码 # 4. 起服务,文档根必须指向 public/ php -S 127.0.0.1:8080 -t public

第四步的-t public不能省。项目的入口在public/index.php,配置文件和app/目录都在上一级,如果文档根指到项目根目录,config/config.php会被浏览器直接下载下来,数据库密码就露了。这是原生 PHP 项目最常见的部署事故,血泪经验告诉我,上线前必须确认 Nginx 的root写的是.../public。

config/config.php里值得关注的几个参数:

配置项默认值说明
db.host/db.name127.0.0.1 / domain_checkerPDO 连接信息
whois.timeout5单次 socket 连接与读取超时,秒
whois.retry2连接失败重试次数,不含首次
batch.chunk_size50单个批量请求处理的域名数量
batch.delay_ms300每处理一个域名后的休眠毫秒数
cache.ttl86400查询结果缓存有效期,秒

whois.timeout设成 5 是有讲究的。有些后缀的 WHOIS 服务器响应很慢,2 秒会大量误判为超时;设到 15 秒又会让批量任务整体拖垮。5 秒是个经验值,遇到.io这种偏慢的可以在后缀维度单独覆盖。

2.4 配置与查询服务器的对接点

app/Support/Config.php做的事情很简单,把 PHP 数组包一层点号取值,避免在业务代码里到处写$config['whois']['timeout']:

<?php namespace App\Support; class Config { private static array $data = []; public static function load(string $file): void { self::$data = require $file; } // 支持 "whois.timeout" 这种点号路径,取不到时返回默认值 public static function get(string $key, $default = null) { $node = self::$data; foreach (explode('.', $key) as $seg) { if (!is_array($node) || !array_key_exists($seg, $node)) { return $default; } $node = $node[$seg]; } return $node; } }

load()在public/index.php里最先调用一次,之后全流程都用Config::get()取值。这样做的直接好处是:改超时时间不用翻遍代码,搜Config::get('whois就能定位所有依赖点。参数说明上,$default一定要给,尤其是新增配置项的时候,老配置文件里没有这个键,不给默认值会直接抛 notice 甚至 fatal。

3. 核心链路拆解:一次查询从提交到出结果

3.1 入口与路由:public/index.php 的薄控制器

入口文件保持得极薄,只做三件事:加载配置、分发动作、兜底异常。没有路由器,靠$_GET['action']手写 switch,对这个体量的项目足够了:

<?php // public/index.php declare(strict_types=1); require dirname(__DIR__) . '/vendor/autoload.php'; use App\Support\Config; use App\Controller\QueryController; Config::load(dirname(__DIR__) . '/config/config.php'); // 兜底:任何未捕获异常都返回 JSON,避免前端拿到一坨 HTML set_exception_handler(function (Throwable $e) { http_response_code(500); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'msg' => $e->getMessage()], JSON_UNESCAPED_UNICODE); }); $controller = new QueryController(); $action = $_GET['action'] ?? 'index'; switch ($action) { case 'query': $controller->query(); break; // 单个查询 case 'batch': $controller->batch(); break; // 批量一片 case 'export': $controller->export(); break; // 导出 CSV default: $controller->index(); break; // 主页面 }

set_exception_handler这一段看着不起眼,实际上是排障的关键。原生 PHP 默认把错误打到输出里,前端fetch拿到一段 HTML 再去JSON.parse,报的错永远是 "Unexpected token <",真正的异常信息被吞掉了,等于给自己造了个黑匣子。统一转成 JSON 之后,浏览器 Network 面板里点开就能看到原始 message。

注意QueryController是无参构造的,依赖在内部自己 new。这在严格意义上不利于测试,但换来的是入口文件不需要容器,部署少一层心智负担。要写单元测试的时候,把 Service 单独拿出来测就够了,Controller 那层本来就没什么逻辑。

3.2 WhoisClient:socket 直连 43 端口与超时控制

WHOIS 协议本身极简:TCP 连上 43 端口,把域名加换行发过去,对方吐回一段纯文本,然后关闭连接。没有握手,没有鉴权,也没有标准化的响应格式。这段代码是整套工具的心脏:

<?php namespace App\Service; use App\Support\Config; class WhoisClient { private array $servers; private int $timeout; private int $retry; public function __construct() { $this->servers = require dirname(__DIR__, 2) . '/config/whois_servers.php'; $this->timeout = (int) Config::get('whois.timeout', 5); $this->retry = (int) Config::get('whois.retry', 2); } /** * @return array{ok:bool, code:string, raw:string} */ public function query(string $domain): array { $domain = strtolower(trim($domain)); $suffix = substr(strrchr($domain, '.'), 1); if (!isset($this->servers[$suffix])) { return ['ok' => false, 'code' => 'UNSUPPORTED_TLD', 'raw' => '']; } $host = $this->servers[$suffix]; $attempt = 0; while ($attempt <= $this->retry) { $attempt++; $errno = 0; $errstr = ''; // 连接超时和读取超时分开设,连接阶段更容易卡住 $fp = @fsockopen($host, 43, $errno, $errstr, $this->timeout); if (!$fp) { continue; // 连接失败,进入下一次重试 } stream_set_timeout($fp, $this->timeout); fwrite($fp, $domain . "\r\n"); $raw = ''; while (!feof($fp)) { $chunk = fread($fp, 8192); if ($chunk === false || $chunk === '') { break; } $raw .= $chunk; } $meta = stream_get_meta_data($fp); fclose($fp); if (!empty($meta['timed_out'])) { return ['ok' => false, 'code' => 'READ_TIMEOUT', 'raw' => $raw]; } return ['ok' => true, 'code' => 'OK', 'raw' => $raw]; } return ['ok' => false, 'code' => 'CONNECT_FAIL', 'raw' => $errstr]; } }

逐段说明逻辑。strrchr($domain, '.')取最后一个点及其后面部分,substr(..., 1)去掉点,得到后缀;用strrchr而不是explode是为了正确处理a.b.com这种多级域名,explode取[1]会拿到b。

发送内容是$domain . "\r\n",CRLF 是 WHOIS 协议惯例,虽然多数服务器只认\n也能处理,但带上更稳。读取用 8192 字节循环,因为响应可能超过一次fread的返回量,一次性stream_get_contents在部分服务器上会提前截断。

超时判定分两层:fsockopen的第五个参数是连接超时,stream_set_timeout是读写超时。两者含义不同,连接超时表现为$fp === false,读写超时表现为$meta['timed_out'] === true。代码里把后者单独返回READ_TIMEOUT,是因为排障时这两种错误的处理方式完全不同——连接失败大概率是 DNS 或网络问题,读取超时则往往是那个后缀的服务器本身慢,调大超时或降速就能缓解。

重试放在while里,$attempt <= $this->retry意味着retry=2时最多尝试 3 次。这里没有做指数退避,因为批量任务本身已经有delay_ms限速,退避会让总耗时失控。

3.3 状态判定:为什么不能用一套关键词打天下

拿到原始文本只是第一步,真正难的是把它翻译成「这个域名能不能注册」。不同后缀的 WHOIS 服务器返回格式差异极大,没有任何统一标准:

<?php namespace App\Service; class StatusResolver { // 后缀 => 表示「未注册」的关键词,命中任一即可 private const AVAILABLE_KEYWORDS = [ 'com' => ['No match for'], 'net' => ['No match for'], 'org' => ['NOT FOUND'], 'cn' => ['no matching record', 'No matching record'], 'io' => ['NOT FOUND', 'Domain not found'], 'co' => ['Not found'], 'me' => ['NOT FOUND'], 'dev' => ['NOT FOUND'], ]; /** * @return int 1=可注册 2=已注册 0=判定失败 */ public function resolve(string $raw, string $suffix): int { if ($raw === '') { return 0; // 空响应,交给上层标记为待重查 } foreach (self::AVAILABLE_KEYWORDS[$suffix] ?? [] as $kw) { if (stripos($raw, $kw) !== false) { return 1; } } // 反过来判断:出现这些字段基本可以确定已被占用 foreach (['Domain Name:', 'Registry Domain ID:', 'Registrar:'] as $kw) { if (stripos($raw, $kw) !== false) { return 2; } } return 0; // 判定不出来,不能瞎猜 } }

stripos而不是strpos,是因为 WHOIS 响应里大小写并不统一,.cn的返回经常是no matching record全小写,.net则可能是No match for。用不区分大小写的匹配能少踩一半坑。

返回三态而不是布尔值,是整个设计里最该保留的一点。很多同类工具图省事,只返回true/false,判定不出来就默认「可注册」,结果导出的表格里混进一堆其实已被注册的域名,用户拿去下单才发现,非常尴尬。这里显式返回0,让上层有机会把它标成「待人工确认」并在界面上高亮。

3.4 DNS 解析与结果聚合

WHOIS 之外,还加了一层 DNS 查询做交叉验证。逻辑是:如果 WHOIS 判定为可注册,但dns_get_record能查到 A 记录或 NS 记录,那这个判定大概率有问题,标记为可疑:

<?php namespace App\Service; class DnsResolver { /** * @return array{has_a:bool, has_ns:bool, ips:array} */ public function inspect(string $domain): array { $ips = []; $ns = []; // 加 @ 抑制告警,查不到记录是正常情况 $a = @dns_get_record($domain, DNS_A); if (is_array($a)) { foreach ($a as $row) { if (!empty($row['ip'])) $ips[] = $row['ip']; } } $n = @dns_get_record($domain, DNS_NS); if (is_array($n)) { foreach ($n as $row) { if (!empty($row['target'])) $ns[] = $row['target']; } } return [ 'has_a' => count($ips) > 0, 'has_ns' => count($ns) > 0, 'ips' => $ips, ]; } }

参数上,DNS_A和DNS_NS是 PHP 内置常量,分别对应 1 和 2。dns_get_record在域名不存在时会返回空数组并抛一个 warning,@是必须的,否则批量查询几百个域名会把 PHP 日志刷满。

聚合顺序是:先查缓存表,命中且未过期直接返回;未命中走 WHOIS;WHOIS 拿到ok=false就重试,重试仍失败记0;拿到原始文本后走StatusResolver;判定为1时再跑一次DnsResolver,若发现 A 记录或 NS 记录则降级为0并打上suspicious标记。这个顺序保证了昂贵的网络操作只在必要的时候发生。

3.5 结果渲染与接口返回

接口统一返回一个固定结构,前端不需要根据 action 做分支判断:

// app/Controller/QueryController.php 片段 private function json(array $payload, int $code = 200): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } public function query(): void { $domain = trim($_GET['domain'] ?? ''); // 只允许字母数字连字符和点,挡住奇怪的输入 if (!preg_match('/^[a-z0-9\-\.]{1,253}$/i', $domain)) { $this->json(['ok' => false, 'msg' => '域名格式不合法'], 400); } $result = $this->service->check($domain); $this->json([ 'ok' => true, 'data' => [ 'domain' => $result->domain, 'suffix' => $result->suffix, 'status' => $result->status, // 1 / 2 / 0 'label' => $result->label(), // 中文展示用 'has_a' => $result->hasA, 'cached' => $result->fromCache, // 前端可提示「秒回」 'checked' => date('Y-m-d H:i:s', $result->checkedAt), ], ]); }

JSON_UNESCAPED_UNICODE不加的话,中文状态标签会被转成\u53ef\u6ce8\u518c,虽然前端解析没问题,但调试时肉眼看着难受。正则里的{1,253}是域名的长度上限,超过这个长度 DNS 层本身就解析不了,提前挡住能省一次网络往返。

4. 批量查询工程化:分片、限速、缓存与导出

4.1 任务分片:前端切片还是后端切片

批量查询最怕的是「一次请求丢 500 个域名进去」。PHP 默认max_execution_time是 30 秒,500 个域名按每个 300 毫秒限速算,光休眠就要 150 秒,必然超时,而且超时之后前端什么都拿不到。

这个项目选的做法是前端分片:前端把用户粘贴的列表切成 50 个一组,串行发多次action=batch请求,每次带offset和size,后端只负责处理这一片并返回进度。

// public/assets/app.js 片段 async function runBatch(domains) { const size = 50; const results = []; for (let offset = 0; offset < domains.length; offset += size) { const chunk = domains.slice(offset, offset + size); const resp = await fetch('index.php?action=batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domains: chunk, offset: offset, total: domains.length, }), }); const json = await resp.json(); if (!json.ok) throw new Error(json.msg); results.push(...json.data.items); renderProgress(results.length, domains.length); // 实时更新进度条 } return results; }

这么切的好处是每个 HTTP 请求都在 30 秒内完成,不存在僵尸任务;坏处是中途关掉页面,剩下的分片就不会再发了。所以后端在处理每一片时,结果必须立刻落库,而不是攒到最后统一写。这一点很关键——如果把落库放在循环外面,用户刷新页面后前面的钱就白花了。

服务端BatchRunner对应的部分是:

<?php namespace App\Service; use App\Support\Config; class BatchRunner { public function __construct( private QueryService $service, private DomainRepository $repo ) {} public function run(array $domains): array { $chunk = (int) Config::get('batch.chunk_size', 50); $delay = (int) Config::get('batch.delay_ms', 300); $items = []; foreach (array_slice($domains, 0, $chunk) as $domain) { $domain = strtolower(trim($domain)); if ($domain === '') continue; $result = $this->service->check($domain); // 处理完一个立刻落库,避免中途中断丢结果 $this->repo->save($result); $items[] = $result->toArray(); // 限速:休眠降低对 WHOIS 服务器的压力 usleep($delay * 1000); } return $items; } }

usleep的单位是微秒,所以$delay * 1000把毫秒换算过去。array_slice($domains, 0, $chunk)是服务端再兜一层,即使前端传多了也不会一次跑满。

4.2 限速与并发:打太快会被服务器掐断

WHOIS 服务器普遍有速率限制,连续高频查询会被临时拒绝,表现是返回一段包含limit exceeded或干脆空响应的文本。项目里只做了串行加休眠,没有做并发,这是刻意的取舍——PHP 原生做并发要么上 curl_multi 写起来绕,要么上 Swoole 引入额外依赖,而串行 300 毫秒的处理速度对一个批量几百条的场景已经够用。

我一般会建议按后缀调整延迟:.com、.net这类大后缀的服务器扛得住 200 毫秒,.cn、.io建议拉到 500 毫秒以上。配置里加一层后缀覆盖是最省事的做法:

// config/config.php 片段 return [ 'batch' => [ 'chunk_size' => 50, 'delay_ms' => 300, // 后缀级覆盖,键为后缀,值为毫秒 'delay_override' => [ 'cn' => 600, 'io' => 500, ], ], ];

BatchRunner里取延迟的时候先查覆盖表再取默认值,一行??就能搞定。这个改动不复杂,但能显著降低被拒率,属于投入产出比很高的小改造。

4.3 缓存表设计:怎么避免重复查同一个域名

同一批域名反复测是常态,每次都走网络纯属浪费。缓存表设计成以域名为唯一键,查询时先看有没有未过期的记录:

CREATE TABLE `domain_cache` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `domain` VARCHAR(191) NOT NULL COMMENT '统一小写,不含协议和路径', `suffix` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '顶级后缀', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0判定失败 1可注册 2已注册', `has_a` TINYINT NOT NULL DEFAULT 0 COMMENT '是否解析到 A 记录', `whois_raw` MEDIUMTEXT COMMENT '原始返回,便于事后回溯', `checked_at` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '查询时间戳', `expire_at` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '缓存过期时间戳', PRIMARY KEY (`id`), UNIQUE KEY `uk_domain` (`domain`), KEY `idx_expire` (`expire_at`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

domain长度定 191 是为了兼容老版本 InnoDB 的索引长度限制,纯 ASCII 域名一般不会超。whois_raw用 MEDIUMTEXT 而不是 TEXT,因为部分后缀的完整 WHOIS 返回会超过 64KB。

写入用INSERT ... ON DUPLICATE KEY UPDATE,靠唯一键做幂等,避免并发下同一条记录被插两次:

$sql = "INSERT INTO domain_cache (domain, suffix, status, has_a, whois_raw, checked_at, expire_at) VALUES (:domain, :suffix, :status, :has_a, :raw, :now, :expire) ON DUPLICATE KEY UPDATE status = VALUES(status), has_a = VALUES(has_a), whois_raw = VALUES(whois_raw), checked_at = VALUES(checked_at), expire_at = VALUES(expire_at)";

注意VALUES()这种写法在 MySQL 8.0.20 之后被标记为废弃,新版本可以改成别名语法AS new ON DUPLICATE KEY UPDATE status = new.status。如果你在 8.0.20+ 上跑,日志里会看到 deprecated 提示,不影响功能但建议顺手改掉。

4.4 CSV 导出:BOM 与流式输出

导出这块最容易出问题的是 Excel 打开中文乱码。解决方式是在输出最前面加 UTF-8 BOM,同时用php://output流式写,不要先拼成一个大字符串:

public function export(): void { header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="domains_' . date('Ymd_His') . '.csv"'); $out = fopen('php://output', 'w'); fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM,Excel 识别编码用 fputcsv($out, ['域名', '后缀', '状态', '是否有A记录', '查询时间']); // 游标遍历,避免一次把所有行读进内存 foreach ($this->repo->cursorAll() as $row) { fputcsv($out, [ $row['domain'], $row['suffix'], match ((int) $row['status']) { 1 => '可注册', 2 => '已注册', default => '待确认', }, $row['has_a'] ? '是' : '否', date('Y-m-d H:i:s', $row['checked_at']), ]); } fclose($out); exit; }

match表达式要求 PHP 8.0+,如果目标环境还是 7.4,换成switch或者三目嵌套。cursorAll()用的是 PDO 的fetch循环而不是fetchAll,导几万行的时候内存差好几个数量级。exit不能漏,否则框架的后续输出会追加到 CSV 尾部,Excel 打开就报格式错误。

5. 避坑与排查:上线后最容易翻车的五个点

5.1 查询超时,页面一直转圈

现象:单个域名查询要等十几秒才返回,批量任务跑到一半直接 502。

原因:whois.timeout设得过小(比如 2 秒)导致重试,或者后缀服务器本身在那段时间响应慢,三次重试叠加起来就是 6 秒以上。更隐蔽的一种是 DNS 解析那一步没设超时,dns_get_record在解析器配置有问题时会卡住很久。

解决:先把retry降到 1,把总耗时压下来;再用time php -r单独跑一次WhoisClient::query()定位是哪一步慢。如果是连接阶段慢,检查运行环境的出网 DNS;如果是读取阶段慢,把该后缀的延迟调到 600 毫秒以上并考虑跳过重试直接标0。别指望靠调大 PHP 的max_execution_time解决,那只会让用户等更久。

5.2 所有域名都被判成「可注册」

现象:导出的表格里,明明已经被注册的老域名也显示可注册。

原因:99% 是StatusResolver的关键词匹配写错了。典型的翻车场景是给.com配了NOT FOUND这个关键词,而.com已注册域名的 WHOIS 返回里恰好包含NOT FOUND字样(比如某些注册商的附加字段),于是全部命中可注册分支。

解决:判定顺序要先判「已注册特征」,再判「可注册特征」。代码里那三个Domain Name:/Registry Domain ID:/Registrar:字段比任何否定关键词都可靠。另外要养成习惯:改关键词之前,先拿三个已知已注册、三个已知未注册的真实域名跑一遍resolve(),确认三态结果符合预期再上线。

5.3 WHOIS 返回乱码,中文后缀解析失败

现象:.cn域名的whois_raw存进数据库后变成问号或者一堆?。

原因:部分 WHOIS 服务器返回的是 GBK 编码,直接json_encode加JSON_UNESCAPED_UNICODE输出会乱。另外数据库连接没设charset=utf8mb4也会在写入阶段就损坏数据。

解决:在WhoisClient返回前做一次编码探测,非 UTF-8 的用mb_convert_encoding($raw, 'UTF-8', 'GBK')转一道。数据库 DSN 里显式带上;charset=utf8mb4,建表也指定DEFAULT CHARSET=utf8mb4。两步都做,缺一个都可能出问题。

5.4 批量任务跑到一半卡死

现象:进度条停在 60% 不动,Network 面板里那个请求一直 pending。

原因:前端分片是串行的,某一片的请求如果卡在后端(比如某个后缀的服务器不响应),fetch没有超时控制,就会一直等。

解决:给fetch套一层AbortController,设 20 秒超时;超时后跳过这一片,把里面的域名标成待重查,继续下一片。后端也加一层保护,BatchRunner里记录当前处理的域名,超过阈值就break返回已完成的部分。宁可少查几个,也不要整批挂掉。

5.5 部署后 500,日志里什么都没有

现象:本地跑得好好的,传到服务器上一访问就是 500,Nginx 的error_log里只有一行PHP Fatal error,没说具体文件。

原因:display_errors关掉了,而error_log的路径没配,异常信息无处可去。另外文档根指错目录导致require的相对路径找不到文件,也是常见诱因。

解决:public/index.php里那层set_exception_handler就是为这个准备的,任何异常都会变成带 message 的 JSON;Logger里再补一层error_log()落文件。部署时把php -S换成 Nginx + PHP-FPM 后,务必确认root指向public,fastcgi_param SCRIPT_FILENAME也要对。排障顺序是:先看 Nginx access log 确认请求到没到,再看 PHP-FPM 的 slow log,最后才是代码。

6. 二次开发:换后缀、接缓存、开接口三步走

这套源码最值钱的地方不在于功能多,而在于每一层都能单独替换。想加一个新后缀,改config/whois_servers.php加一行映射,再到StatusResolver::AVAILABLE_KEYWORDS里补关键词,两处改完就能用,不用碰任何逻辑代码。加完之后千万别直接上线,先用三个已知状态的域名手动跑一遍check(),确认三态都对——这个验证习惯我是从那句「所有域名都判成可注册」的事故之后强制自己执行的。

想把查询能力开放给别的系统,最省事的做法是加一个action=api分支,走 token 校验:

case 'api': $token = $_SERVER['HTTP_X_API_TOKEN'] ?? ''; if (!hash_equals(Config::get('api.token', ''), $token)) { http_response_code(401); echo json_encode(['ok' => false, 'msg' => 'unauthorized']); exit; } $controller->query(); break;

hash_equals而不是===,是为了避免时序攻击,虽然在这个场景下有点小题大做,但成本为零。token 放配置文件里,别硬编码进代码,否则换一次就要重新部署。

缓存层也是可以换的。当前是 MySQL 表缓存,如果查询量大到数据库扛不住,把DomainRepository::findFresh()换成 Redis 实现即可,接口签名保持不变:

public function findFresh(string $domain): ?array { // 换成 Redis 时,只需实现同样的语义:过期返回 null $key = 'dc:' . $domain; $raw = $this->redis->get($key); if ($raw === false) return null; return json_decode($raw, true); }

这里的关键是保持「过期返回 null、未过期返回数组」这个约定不变,上层的QueryService完全不需要感知底层换没换。

最后一个建议是加个简单的自检页。上线之前访问一次action=index,页面顶部显示当前配置的超时、限速、支持的后缀数量,以及数据库连通状态。这个东西看着土,但省下了无数次「为什么线上查得这么慢」的排查——一眼就能看出是不是配置文件没传对。从那以后我每次部署这套工具,都会先点一遍自检页再交付,比事后翻日志便宜太多。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 12:22:09

Zigbee与Qt室内环境检测系统:组网、串口解析与实时监控

简介&#xff1a;一套面向物联网与嵌入式方向学习者的室内环境检测系统上位机代码&#xff0c;聚焦 Qt 人机交互部分的实现&#xff0c;可作为课程设计、毕业设计或 Zigbee 项目联调的参考工程。工程围绕串口通信展开&#xff0c;完成上位机与下位机的数据交互&#xff0c;实时…

作者头像 李华
网站建设 2026/10/27 12:21:58

Text-to-CAD:让自然语言直接生成可编辑三维模型

不用再为了一个法兰盘画三小时草图了。最近我把大量时间投在 text-to-cad 这条新赛道上&#xff0c;试了一圈主流工具&#xff0c;也自己搭过离线方案&#xff0c;最大的感受是&#xff1a;这东西不是来替代工程师的&#xff0c;它是来替你把那些“长在图纸上的废话”直接变成模…

作者头像 李华
网站建设 2026/10/27 12:21:40

YOLO与SENetV2融合:提升目标检测精度的注意力机制实践

1. 项目概述&#xff1a;YOLO与注意力机制的融合创新 在目标检测领域&#xff0c;YOLO(You Only Look Once)系列算法因其出色的实时性能而广受欢迎。但传统YOLO模型在处理复杂场景时&#xff0c;仍存在对小目标和遮挡物体检测效果不佳的问题。这次我们要探讨的改进方案&#xf…

作者头像 李华
网站建设 2026/10/27 12:21:27

像扎马尾一样收纳浏览器标签页:ponytail 插件使用指南

说实话&#xff0c;我第一次看到 ponytail 这个名字的时候&#xff0c;愣了一下。马尾辫&#xff1f;怎么听都像是一个美发类 App 的名字&#xff0c;而不是一个浏览器插件。但当我把它装进浏览器、点下工具栏上那个小小的橙色图标之后&#xff0c;一下就明白了&#xff1a;它做…

作者头像 李华
网站建设 2026/10/27 12:21:26

抖音搜索故障解析:分布式架构与高可用设计

1. 事件背景&#xff1a;抖音搜索功能突发异常 2023年8月某日上午&#xff0c;"抖音崩了"话题突然冲上微博热搜榜首&#xff0c;大量用户反馈抖音App的搜索功能无法正常使用。根据用户报告显示&#xff0c;当尝试在抖音搜索框输入关键词时&#xff0c;系统会显示&quo…

作者头像 李华
网站建设 2026/10/27 12:20:37

从“无可挑剔”到可执行:高质量软件交付的工程化方法

impeccable&#xff0c;这个单词我第一次认真对待是在接手一个高要求的项目的时候。当时甲方反复强调&#xff0c;他们要的是“impeccable”的交付物——无可挑剔、挑不出毛病。我一开始以为这只是一句口号&#xff0c;等到项目中期因为各种小问题被连续打回&#xff0c;才意识…

作者头像 李华