你写了个定时任务,每分钟扫一遍 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 抢走了。毫秒级的延迟,在过期域名这件事上就是有和没有的区别。

真正的战场就在那几十毫秒。你不去控制这波查询,它们就会像没头苍蝇一样撞进数据库,连带着把整个抢注流程拖慢半拍——而半拍足够别人拿走域名。 PHP cron job with Redis lock for domain grabbing

一次只让一个人进门

很多人想到加锁,但下意识就写成了文件锁或者数据库行锁;在分布式定时任务里,这些锁根本没法跨进程跨机器协调。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”,就知道第一波流量终于被按住了。 automated domain bidding queue with Redis

定时任务与锁配合,抢注册才有戏

定时任务本身不复杂,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 那套设计从一开始就不该用在抢注场景里。