限流加Redis队列:让站点被打挂变慢但活着

很多站长在 Nginx 上做完限流之后会发现一个现象:限流规则明明生效,日志里 503 也一直在涨,可后端 CPU 还是被打满。原因往往不是限流没顶上,而是被限住的请求本身太贵——一次搜索请求要扫全表、一次导出要跑十秒。限流只是把「每秒 100 个请求」压到「每秒 10 个」,如果单个请求本身就吃掉半秒 CPU,10 个也够把机器压趴。

这一篇讲的是个人站长的场景:单机、两核四 G、没有专职运维,预算有限,但已经被爬虫或者活动流量顶过几次。我们把两件事分开做——入口处快速失败(限流 + 缓存)和出口处削峰(Redis 队列异步化)。两件事都做完,从「被打挂」到「慢但活着」通常只差一两天的工作量。

一、先算清楚一个请求到底有多贵

动手改代码前先看一眼数据。Nginx 的 access log 里加上 request_time 和 upstream_response_time,能直接看出时间花在 Nginx 还是后端:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                'rt=$request_time urt=$upstream_response_time';

统计一次就够,用 awk 把慢请求挑出来:

awk -F'urt=' '{split($2,a," "); if (a[1]+0 > 1.0) print}' /var/log/nginx/access.log | wc -l

如果慢请求集中在几个固定的 URI 上,那就不是限流参数的问题,是那几个接口本身需要异步化。反过来如果慢请求分散、每个都慢,先查数据库连接池和慢查询,别急着上队列。

一个经验阈值:上游响应 P95 超过 800ms 的接口,就不要再让它同步跑了。用户在手机上等 800ms 就开始怀疑网络,等 3 秒基本就关页面了;而对服务端来说,一个挂着 3 秒的 PHP-FPM 进程占着内存不干活,10 个并发就能吃掉 200MB 和全部 worker。

二、Nginx 层做快速失败,不做排队

第一层永远是 Nginx。它的成本近乎为零,而且能在请求进入 PHP 之前就把它挡掉。

limit_req 的正确参数

limit_req_zone $binary_remote_addr zone=search:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=global:10m rate=20r/s;
limit_req_status 429;

server {
    location = /search.php {
        limit_req zone=search burst=10 nodelay;
        limit_req zone=global burst=30;
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php7.4-fpm.sock;
    }
}

三个参数要理解透:rate 是令牌补充速度,burst 是允许的突发桶大小,nodelay 决定超出的请求是排队还是立刻拒绝。个人站点的搜索、导出、列表页这些贵接口,建议 nodelay 加上,让超出的请求立刻拿 429——排队没有任何好处,只会把 Nginx 的 worker 连接一起占满,反而拖累静态资源。

返回 429 而不是 503 有实际意义:搜索引擎和各路爬虫对 429 的理解是「你太快了,我会退让」,对 503 的理解是「你挂了」,后者会触发更激进的调度。同时记得给 429 配一个静态的提示页,别让用户看到 Nginx 默认的白底黑字。

限流键不要只用 IP

很多教程都写 $binary_remote_addr,但真实环境里最常见的情况是:一大半流量来自少数几个 IP 段(云厂商机房、手机基站出口),你按 IP 限流,等于把这几个段里的正常用户全部一起限死;而真正的恶意爬虫在几十台机器上轮换 IP,每个 IP 都稳稳的不超限。

比较实用的组合键是 IP 加 UA,再给已登录用户加 cookie:

map $http_user_agent $ua_bucket {
    default            $binary_remote_addr;
    "~*bot|spider"     $binary_remote_addr;
}
map $cookie_uid $uid_key {
    default            $binary_remote_addr;
    "~^[A-Za-z0-9]{16}$"  $cookie_uid;
}
limit_req_zone $uid_key zone=member:10m rate=10r/s;

已登录用户用 uid 做键,好处是老用户不会被同办公室的同事连累,同时也让「换 IP 就绕过」失效——除非他不停地注册新账号。

三、能被缓存的结果,不要每次重新算

队列解决的是「必须算、但不用马上算」的请求。还有一大类是「已经算过、结果没变」的请求,这类请求的正解是缓存,不是队列。

Nginx 的 fastcgi_cache 对 PHP 站点效果非常直接。列表页、文章页、标签页这些内容型页面,命中率轻松到 90% 以上:

fastcgi_cache_path /var/cache/nginx/article levels=1:2 keys_zone=art:128m
                   inactive=2h max_size=2g use_temp_path=off;

location ~ \.php$ {
    fastcgi_cache art;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
    fastcgi_cache_valid 200 301 10m;
    fastcgi_cache_valid 404 1m;
    fastcgi_cache_use_stale error timeout updating http_500 http_503;
    fastcgi_cache_background_update on;
    fastcgi_cache_lock on;
    add_header X-Cache $upstream_cache_status;
}

几个坑要说清楚。第一,缓存键里必须包含 cookie 相关的差异,如果你的页面会按登录状态输出不同内容,要么在 PHP 里给已登录用户发 Cache-Control: no-cache,要么在 Nginx 里加 map $cookie_uid $no_cache 把已登录用户排除掉,否则会把 A 用户的登录状态缓存给 B 用户,这是最严重的线上事故形态之一。第二,fastcgi_cache_use_stale 要配齐,后端重启的几秒钟里,Nginx 会继续发旧缓存而不是直接 502,用户基本感知不到。第三,X-Cache 头一定要加,否则你永远不知道缓存到底有没有命中;上线后第一件事就是看 HIT 比例。

四、真正的队列:什么该异步,什么绝对不能异步

队列不是万能药,判断标准只有一条:这个操作的结果,用户现在必须看到吗?

典型的该异步的操作:发邮件、发站内通知、生成缩略图、写访问统计、推送到搜索引擎、导入外部数据、生成报表和 PDF、批量重算标签。这些操作的共同点是「用户可以等 30 秒,但不能等 3 秒」,而且失败后重试完全没问题。

绝对不能异步的操作:登录、下单、支付、任何要立刻返回校验结果的接口。把这些放进队列,等于把「明确失败」变成「不明状态」,用户点了登录转圈十秒然后不知道成功没有,这种体验比慢更糟。

五、用 Redis 做最小可用的队列

个人站长没必要上 RabbitMQ 或者 Kafka,Redis 一个 LPUSH 加 BRPOP 就足够撑住每天几十万条任务。

<?php
// 生产端:入队
function enqueue($queue, array $job) {
    $r = new Redis();
    $r->connect('127.0.0.1', 6379);
    $payload = json_encode($job, JSON_UNESCAPED_UNICODE);
    $r->lPush('q:' . $queue, $payload);
    $r->lPush('q:' . $queue . ':log', $payload);   // 备份队列,兜底用
    $r->lTrim('q:' . $queue . ':log', 0, 9999);
}
enqueue('mail', ['to' => $email, 'tpl' => 'reset', 'args' => ['token' => $token]]);

消费端独立进程,用 supervisor 或者 systemd 守住:

<?php
$r = new Redis();
$r->connect('127.0.0.1', 6379);
while (true) {
    $item = $r->brPop(['q:mail', 'q:thumb'], 5);
    if (!$item) { continue; }
    $job = json_decode($item[1], true);
    if (!is_array($job)) { continue; }
    try {
        handle_job($item[0], $job);
        $r->lRem('q:' . substr($item[0], 2) . ':log', 1, $item[1]);
    } catch (Throwable $e) {
        $r->lPush('q:failed', $item[1]);
        error_log('[queue] ' . $e->getMessage());
        sleep(2);
    }
}

brPop 的第二个参数是阻塞超时秒数,一定要设,不能传 0 无限阻塞,否则进程被杀时无法优雅退出。brPop 支持同时监听多个队列并按顺序优先取,这样一个 worker 就能兼顾邮件和缩略图。

关于可靠性:Redis 的 List 做队列是「至少一次」语义,LPUSH 成功但 worker 崩在任务执行中途时,这条任务实际上已经出队了,会丢。所以上面的示例里多了一个备份队列,worker 处理完整之后再 lRem 删掉——挂掉之后备份队列里还留着,人工或者定时任务可以捞出来重放。这是个人规模下最省事的可靠性方案,成本就多一个 list。

六、流量洪峰时,队列本身会不会炸

会,而且比你想的更容易。一场活动上百万请求涌进来,每个请求都往 Redis 推一条任务,Redis 的内存会在几秒内被列表撑爆,然后 Redis 开始拒绝写入,你的站点跟着全挂。

所以入口必须有一个「队列水位」闸门:入队之前先查 llen,超过阈值就直接放弃入队并返回「稍后重试」。

$len = $r->lLen('q:mail');
if ($len > 50000) {
    // 水位过高,降级:只记录日志,不发邮件
    error_log('[queue] overflow, drop mail to ' . $email);
    return false;
}

这个 5 万的数字按你的内存算:每条任务 300 字节左右,5 万条大约 15MB,加上 Redis 自身的开销翻倍也就 30MB,两核四 G 的机器完全扛得住。但如果是缩略图任务,一条可能带几 KB 的路径和参数,阈值就要相应降到一万以内。

另外记得给 Redis 设 maxmemory 和 maxmemory-policy noeviction。noeviction 是关键:如果设成 allkeys-lru,内存满了 Redis 会开始淘汰你的队列元素,任务在没有报错的情况下静默消失,排查起来极其痛苦。设成 noeviction 后,内存满时写入直接报错,你的程序能立刻看到并降级。

七、削峰之后还要限速消费

有一个很容易忽略的问题:队列把请求攒起来了,消费端如果没有节流,会把积压的任务在几秒内全跑完,等于把高峰从「入口」搬到了「出口」。比如邮件服务商的 API 有每分钟 200 封的配额,worker 一上来就并发跑,直接触发限流封号。

做法很简单,worker 每处理一条就 usleep 一下,或者用 Redis 的计数器做一个每秒令牌:

$key = 'rate:mail:' . date('YmdHi');
$n = $r->incr($key);
if ($n == 1) { $r->expire($key, 120); }
if ($n > 180) {
    // 本分钟配额用尽,任务塞回队列尾部
    $r->rPush('q:mail', $item[1]);
    sleep(30);
    continue;
}

多开几个 worker 也没关系,incr 是原子的,所有 worker 共享同一个配额计数。这个模式比重试退避更好控制,因为它从源头上避免了「撞到对方限流再被惩罚」。

八、监控:队列要能看出积压

异步化最大的代价是「问题不再立刻暴露」——邮件发不出去,用户不会报错,只是收不到。所以必须有监控。

最少三件事:队列长度、消费速率、失败堆积。用一条 shell 采集进 crontab,每五分钟写一份到日志,再配一个简单的阈值告警:

#!/bin/bash
R="redis-cli"
len=$($R lLen q:mail)
failed=$($R lLen q:failed)
echo "$(date '+%F %T') mail=$len failed=$failed" >> /var/log/queue-monitor.log
if [ "$len" -gt 20000 ]; then
    echo "队列积压 $len" | mail -s "queue alert" ops@example.com
fi
if [ "$failed" -gt 100 ]; then
    echo "失败任务 $failed 条" | mail -s "queue failed" ops@example.com
fi

还要注意日志本身的轮转,这个脚本每天追加 288 行,一年十万行不算多,但加上 DEBUG 级别的 worker 日志就不一样了,记得给 logrotate 配一份。

九、上线顺序:先限流,再缓存,最后队列

这三件事的价值是递减的,实施成本是递增的。所以顺序一定是先做最便宜的:

第一步,限流加监控。半小时能做完,立刻能挡住爬虫和刷接口。这一步不做,后面所有优化都可能被一波流量直接冲垮。

第二步,页面缓存。半天到一天,收益最大。九成的流量都能被缓存接住,后端实际压力会掉一个数量级。

第三步,队列异步化。这个要看具体情况,如果慢请求集中在邮件、缩略图、统计写入上,做;如果慢请求是数据库查询,做队列没用,得优化索引。

顺序反过来的典型后果是:先花两周搭了一套完整的队列和 worker 体系,结果一上线发现 80% 的流量是爬虫在爬列表页,压根不产生任何异步任务,白干。

十、小结

削峰和异步化解决的是不同层面的问题:限流让入口快速失败,缓存让重复请求不再重算,队列把「不必现在做」的工作挪到后台。三者加起来,一台两核四 G 的机器能顶住的日活从几千提到几万,而且成本基本没变。

最后提醒一句:这些手段都是「保命」的,不是「治病」的。如果数据库慢查询一直没清理,索引一直是缺的,那么队列只会让问题延后爆发。真正的性能问题还是要回到 SQL 和索引上,异步化只是给你争取时间去修它。

Last modification:October 28th, 2026 at 09:26 pm

Leave a Comment