你写了个定时任务,每分钟扫一遍 Whois,发现域名过期就抢注。脚本跑了一个月,一个域名都没抢到。
不是代码有 bug,是你根本没意识到:当那个域名真正释放的瞬间,成千上万个爬虫、接口、定时任务会同时冲进去查 Whois——你写的那个 file_get_contents('whois.verisign-grs.com') 就是整个链条里最脆弱的环节。
抢注册慢半拍,瓶颈在缓存窗口
过期域名释放那几毫秒,Whois 查询的并发量能冲到每秒几百次。PHP 脚本如果每次都直连远程 Whois 服务器,不用等数据库崩,光是 API 响应超时就够你喝一壶。所以大家都会把 Whois 结果缓存到 Redis 里,设个 5 分钟的过期时间——这本身没毛病。
问题出在缓存刚刚过期、新查询还没写完的那个时间窗口。比如你缓存 TTL 设在 08:00:00 整过期,那么 08:00:00.001 到 08:00:00.050 这 50 毫秒内涌进来的 30 个请求,全都会发现缓存是空的,然后齐刷刷地跑去查数据库(或者直接查 Whois)。这就是缓存击穿——单个热点 key 失效,所有请求瞬间穿透。
在域名抢注的场景里,这个击穿窗口直接决定了你是先手还是陪跑。如果 30 个请求同时打到数据库,数据库连接池瞬间撑爆,PHP 进程排队等连接,等第一个请求拿到数据重建缓存时,已经过去了 1.2 秒——域名早就被人用 API 抢走了。毫秒级的延迟,在过期域名这件事上就是有和没有的区别。
真正的战场就在那几十毫秒。你不去控制这波查询,它们就会像没头苍蝇一样撞进数据库,连带着把整个抢注流程拖慢半拍——而半拍足够别人拿走域名。
一次只让一个人进门
很多人想到加锁,但下意识就写成了文件锁或者数据库行锁;在分布式定时任务里,这些锁根本没法跨进程跨机器协调。Redis 的 SET NX(也就是 setIfAbsent)才是正解:它原子地设置键值并返回成功与否,别的请求同时执行会直接失败,不会发生竞态。
// 纯 PHP + Predis:仅首个进程能拿到锁并重建缓存
$lockKey = 'whois:lock:' . $domain;
$cacheKey = 'whois:' . $domain;
$ok = $client->set($lockKey, 1, ['nx', 'px' => 5000]);
if ($ok) {
try {
$record = query_whois_from_db_or_remote($domain); // 你的实际回源逻辑
$client->setex($cacheKey, 300, serialize($record));
} finally {
$client->del($lockKey); // 无论成败都释放,避免残留
}
} else {
usleep(20000); // 20ms 后重试读缓存
$record = $client->get($cacheKey);
}
这里的 5 秒只是上限;多数情况下,毫秒级就能完成重建,后面的请求几乎无感。要是你用的 ThinkPHP,可以直接利用 Redis 驱动的原子操作,不必自己拼命令。
ThinkPHP 里别把锁拆成两步
最容易踩的坑就是把 SETNX 和 EXPIRE 分成两次调用:两个指令之间一旦崩溃,锁就成了永久障碍。正确做法是一条指令同时带上 NX 与过期时间,TP 的 Redis 类提供了对应的接口,不用绕弯子。
// ThinkPHP Redis:单条指令完成排他 + 过期
$lock = \think\facade\Redis::set(
'whois:lock:' . $domain,
1,
['nx', 'px' => 5000]
);
if ($lock) {
try {
$record = query_whois_from_db_or_remote($domain);
\think\facade\Redis::setex(
'whois:' . $domain,
300,
serialize($record)
);
} finally {
\think\facade\Redis::del('whois:lock:' . $domain);
}
} else {
usleep(20000);
$record = \think\facade\Redis::get('whois:' . $domain);
}
别忘了 finally:哪怕程序崩了,只要锁自带 TTL,其他线程仍能在短暂阻塞后继续;而正常退出时主动删除,能把等待时间压到最小。
锁不住时就给个软着陆
极端并发下,拿不到锁的请求不必全部卡住;你可以先读旧缓存(如果还在),或者返回一个“处理中”标识,让上游稍后再来。配合连接池限流,数据库基本见不到抖动。
这套东西并不花哨,却能把“同一秒涌进来的所有查询”变成“排队过的单次查询”。当你发现日志里那种整齐的一排“acquire lock true / false”,就知道第一波流量终于被按住了。
定时任务与锁配合,抢注册才有戏
定时任务本身不复杂,crontab 里配个每分钟执行一次的脚本就行。但抢注这件事麻烦就麻烦在——同一秒可能有十几个定时任务进程同时启动,而你的 Whois 缓存刚好在那几毫秒里过期了。没有锁,这些进程就会排着队去查源站,轻则 Whois 接口把你 ban 掉,重则抢注时间直接错过窗口。
我最早犯过一个很低级的错:直接用文件锁。flock 在单机下还能凑合,但你的定时任务一旦部署到多台服务器,文件锁就形同虚设了。更别提抢注脚本通常需要毫秒级响应,文件锁的阻塞和释放时机根本不可控。
后来改用 Redis 锁。逻辑其实就三步:检查锁、拿锁干活、放锁。但细节里全是坑。
锁的超时时间,设短了死锁,设长了阻塞
假设你的 Whois 缓存重建平均耗时 200 毫秒,最慢一次跑了 800 毫秒(远程 Whois 服务器偶尔抽风)。锁超时设 1 秒行不行?不行。如果重建过程中 PHP 进程被 OOM killer 干掉,锁就永远留在 Redis 里,后续所有请求都会卡死在等待上。设 10 秒又太宽裕——万一某个进程在重建缓存时卡住了,其他进程得干等 10 秒才能接管。抢注册这种事,100 毫秒的延迟都可能让你输给机器人。
我的经验值是:超时时间 = 正常重建耗时 × 2 + 300ms 缓冲。比如你实测 200ms 搞定,那就设 700ms。既不会因为单次慢查询就死锁,也不会让异常进程把锁占太久。真出现极端情况,700ms 后下一个请求就能抢到锁重新干活。
// 锁的超时时间设为重建耗时的 2 倍,额外加 300ms 余量
$lockTtl = intval($avgBuildTime * 2) + 300; // 单位毫秒
// 用 SET NX PX 一次性搞定
$locked = $redis->set($lockKey, 1, ['nx', 'px' => $lockTtl]);
抢注册那几步,每一步都可能翻车
流程画出来很简单:加锁 → 查 Whois 缓存 → 缓存过期则回源查询 → 更新缓存 → 解锁。但你得考虑中间状态。
缓存过期,第一个请求拿到锁去回源了。第二个请求没拿到锁,是原地死循环空转,还是休眠重试?我见过有人写while(true) 套 sleep(1) 的,这等于把 CPU 时间片全浪费在轮询上。正确做法是 usleep(20000) 微秒级自旋,最多重试 50 次,超时就返回旧缓存或直接报错。
// 微秒级自旋等待锁释放
$retries = 0;
while (!$locked && $retries < 50) {
usleep(20000); // 20 毫秒
$data = $redis->get($cacheKey);
if ($data) {
break; // 对方已经重建完了,直接拿缓存走人
}
$locked = $redis->set($lockKey, 1, ['nx', 'px' => $lockTtl]);
$retries++;
}
这段代码有个隐含前提:你得先检查旧缓存还在不在。如果缓存是直接删除而不是设置过期,那么锁释放前所有请求都会陷入自旋。所以缓存过期最好用 EXPIRE 被动删除,而不是主动 DEL——这样在重建过程中,其他请求至少能读到旧值,不必空转。
别忘了 finally 里放锁
这是最容易被忽略的。PHP 脚本可能在重建缓存时抛出异常(Remote Whois 超时、数据库连接断开),如果锁没释放,后续请求全得卡死。必须用 try/finally 包裹重建逻辑。
if ($locked) {
try {
$whoisData = queryWhoisSource($domain);
$redis->setex($cacheKey, 300, serialize($whoisData));
} catch (\Throwable $e) {
// 记录日志,但不会死锁
logger->error('Whois 重建失败: ' . $e->getMessage());
} finally {
$redis->del($lockKey);
}
}
就算 try 块里崩了,finally 也能把锁删掉。加上锁自身的 TTL 兜底,双重保障基本能防死锁。
定时任务里几十个并发请求同时砸进来,最后只有一个能摸到源站,其他的全在缓存那层就直接返回了。毫秒级抢注册这事儿,压根不需要什么玄学算法,把锁、缓存、超时这些基础细节抠到牙缝里就行。80% 的抢注翻车,都是锁没玩明白,跟代码跑得快不快真没关系。
单域名抢注脚本跑通后,新问题是手里动辄上千个待抢域名,怎么把它们变成一条可控的流水线,既不乱抢同一枚域名,也不把 Redis 打爆。批量出价自动化:从单域名到并发队列
用 Redis 列表做轻量队列
把待出价域名塞进 Redis 列表 ,PHP 常驻进程(systemd 服务或 Swoole 定时器)充当消费者,循环 BRPOPLPUSH 拿任务,拿到后立即写一个临时标记 processing:{domain},防止同一域名被多个进程重复取出。
$listKey = 'pending:domains';
$tmpMark = 'processing:' . $domain;
// 原子转移:右弹左推,60 秒过期防死循环
$domain = $redis->brpoplpush($listKey, $tmpMark, 60);
// 抢不到列表元素就继续 sleep(100ms) 等待下一轮
分布式锁与指数退避
真正调用注册商 API 前,再用一次 SET NX;若返回 nil 就把任务重新塞回列表尾部并 sleep(2^retry * 50ms),最多重试 5 次才丢进死信队列。这样即使某台 PHP-FPM 突然崩溃,锁也会在 30 秒内自动释放,不会卡住整个批次。
缓存击穿防御的进阶策略:逻辑过期与动态预热
前面那套互斥锁加死信队列,单域名和批量流水线都能跑起来了。但有个问题一直让我睡不踏实——物理 TTL。当几千个域名的 Whois 缓存同时过期,Redis 里那堆 key 瞬间全灭,所有请求又齐刷刷扑向源站。锁能挡,但锁本身也有开销,如果每一个 key 失效都要走一遍抢锁、查源、回写,高峰期那几十毫秒的延迟就足够让抢注窗口溜走。
逻辑过期:把 TTL 从缓存层搬到业务层
换个思路:缓存永远不过期,过期时间自己写在 value 里。存的时候塞一个 expire_at 字段,读的时候自己比时间戳。如果发现已经超了,不删 key,而是异步去重建,旧数据继续提供服务。
$data = $redis->get($cacheKey);
if (!$data) {
// 缓存压根不存在,走互斥锁查源
return $this->rebuildCache($domain);
}
$cached = unserialize($data);
if ($cached['expire_at'] > time()) {
// 还没过期,直接返回
return $cached['whois_data'];
}
// 逻辑过期了,尝试抢锁异步更新
$lockKey = 'rebuild_lock:' . $domain;
$locked = $redis->set($lockKey, 1, ['nx', 'ex' => 5]);
if ($locked) {
// 拿到锁的进程去查源站更新缓存
$fresh = $this->fetchWhoisFromSource($domain);
$redis->setex($cacheKey, 86400, serialize([
'whois_data' => $fresh,
'expire_at' => time() + 300
]));
$redis->del($lockKey);
}
// 没抢到锁的进程直接返回旧数据,不用等
return $cached['whois_data'];
这样物理上缓存一直在,永远不会集体蒸发。逻辑过期那几百毫秒里,返回的数据可能是旧 5 秒的,但对域名抢注来说,5 秒前的状态完全够用——过期域名状态变更是分钟级的,犯不着为那点新鲜度炸掉整个系统。
动态预热:谁热谁续命
光靠逻辑过期还不够,有些域名查得特别频繁。比如每周三晚上八点大量域名到期,那几分钟内某个热门域名的查询 QPS 能飙到 800+。如果等它逻辑过期了再异步重建,重建期间 800 个请求都拿着旧数据,虽然能用,但总归不够优雅。
方案是给每个缓存 key 记一个访问计数器。在每次读取命中后,原子自增一个 Redis 计数器,比如hits:{domain},设个 60 秒的过期。当这个计数超过 500,说明该 key 是高频热点,自动把它的逻辑过期时间往后推 120 秒——相当于给热点 key 续命。
$hitKey = 'hits:' . $domain;
$count = $redis->incr($hitKey);
if ($count === 1) {
$redis->expire($hitKey, 60);
}
if ($count > 500) {
// 热门 key,主动延长逻辑过期时间
$cached['expire_at'] = time() + 120;
$redis->setex($cacheKey, 86400, serialize($cached));
}
这套逻辑跑下来,热点域名几乎不会进入逻辑过期状态,始终由异步线程在背后偷偷更新。冷门域名照常 5 分钟过期,既不浪费内存,也不浪费 CPU。
布隆过滤器:连查都不用查
另一个头疼的问题是穿透。抢注脚本里批量扫描是家常便饭,用户传进来的域名列表经常混着一堆根本不存在的玩意儿——随手敲的乱码、早被删了的过期域名。这些 key 查缓存肯定没戏,去源站问 Whois 也照样返回空,但每次都老老实实走一遍锁逻辑,连接数就这么被白白吃掉了。
在缓存层前面加一个布隆过滤器。把所有查过的、确实存在的域名 hash 进去。新请求来了先用过滤器判断,如果布隆说「不存在」,直接返回空,连缓存都不碰。$exists = $bloom->exists('whois_bloom', $domain);
if (!$exists) {
return null; // 布隆说没有,大概率真没有
}
// 布隆说可能存在,才走缓存逻辑
$data = $redis->get($cacheKey);
误判率压到 1%,意味着 100 个不存在的域名里最多漏掉 1 个。那个漏网之鱼顶多走一遍缓存流程、浪费一次锁,但剩下的 99 个直接拦在门外。每天几百万次查询下来,Redis 连接数唰地降了一大截——过滤器把穿透请求砍掉了八成。
逻辑过期保底、动态预热给热点开绿灯、布隆过滤器挡掉无效流量——这三板斧加起来,抢注集群里那几台 PHP 实例才真正扛住了一次 6 万 QPS 的到期潮。说到底,缓存击穿不是防不住的病,是得先承认物理 TTL 那套设计从一开始就不该用在抢注场景里。
评论