学完这篇,你能给一台只跑 PHP + MySQL 的普通服务器加上队列、限流、熔断、降级四道防线,让它在流量翻十倍时先扛住、再慢慢处理,而不是直接 502。
下面按"先找瓶颈 → 加缓存 → 上队列 → 限流 → 熔断 → 降级"的顺序走,每一步都给出可直接抄的做法。文中以 Clara BBS 这类无 Composer、无命令行的 PHP 论坛为背景,思路对其他 PHP 项目同样适用。
第一步:先找到真正的瓶颈,别急着上队列
登录服务器看三样东西:MySQL 慢查询日志、PHP-FPM 的 `max_children` 打满情况、以及单次请求的 SQL 条数。
最常见的真相是:一个帖子列表页发了 80 条 SQL,每条都在查用户表。这种情况你上什么队列都没用。
注意:`max_children` 打满的典型症状是 502 或"连接被重置",而 CPU 不高——这是进程被占满,不是算力不够。
第二步:缓存挡在数据库前面
把变化不频繁的读请求走缓存,是性价比最高的"高并发方案"。缓存键设计成 `模块:标识:版本号`,版本号一变整批失效。
$board = Cache::remember('board:12:v' . $ver, 300, function () use ($id) {
return db()->query("SELECT * FROM boards WHERE id=?", [$id])->fetch();
});
注意:一定要设 TTL。哪怕只有 60 秒,也能把突发流量削掉一大半;不设过期时间的缓存迟早变成脏数据。
第三步:队列——把耗时活儿挪出请求
PHP 没有常驻进程,所以队列通常用"任务表 + 定时拉取"实现。建一张表:
CREATE TABLE jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
job VARCHAR(64) NOT NULL,
payload TEXT,
status TINYINT DEFAULT 0, -- 0待处理 1处理中 2完成 3失败
attempts TINYINT DEFAULT 0,
available_at INT NOT NULL,
locked_at INT DEFAULT 0,
KEY idx_status_time (status, available_at)
) ENGINE=InnoDB;
消费者每次只抢一条,靠 `UPDATE` 的受影响行数保证不重复:
// 1. 取候选
$row = $db->query("SELECT id FROM jobs WHERE status=0 AND available_at<=? ORDER BY id LIMIT 1", [time()])->fetch();
// 2. 抢占(关键:条件里必须带 status=0)
$ok = $db->exec("UPDATE jobs SET status=1, locked_at=? WHERE id=? AND status=0", [time(), $row['id']]);
if (!$ok) { return; } // 被别人抢走了,下轮再来
// 3. 执行
任务注册进计划任务体系即可,Clara BBS 的 `Cron::register` 是懒触发、零配置的,不用配 crontab 也能跑;其他系统就挂一条每分钟执行的 crontab。
几个必须处理的点:
- 幂等:任务可能被执行两次,比如发通知先查"是否已通知"再写入;
- 重试上限:`attempts >= 5` 直接标失败,别无限重试;
- 超时回收:`status=1 AND locked_at < time()-300` 的任务重置为 0,防止进程崩了任务卡死;
- 失败可见:后台要能看到失败任务列表和错误原因。
注意:MySQL 8.0 才有 `FOR UPDATE SKIP LOCKED`,5.7 用上面的 `UPDATE` 抢占法更稳。
第四步:限流——令牌桶挡在入口
限流要按"维度"做:IP、用户 ID、接口。用令牌桶,五秒钟写一次库:
function allow(string $key, float $rate, int $burst): bool {
$now = microtime(true);
$r = getRateRow($key); // tokens, ts
$tokens = min($burst, $r['tokens'] + ($now - $r['ts']) * $rate);
if ($tokens < 1) { return false; }
saveRateRow($key, $tokens - 1, $now);
return true;
}
典型参数:发帖 `rate=0.05, burst=3`(约每分钟 3 帖),登录按 IP `rate=0.2, burst=10`,开放 API 按 key 单独配额。系统自带"发帖/回帖最小间隔"其实是最粗的一层限流,先把这层打开再谈细粒度。
注意:限流状态别放 PHP 数组或 APCu——多进程不共享,等于没限。放数据库或 Redis 都行。
第五步:熔断——坏掉的依赖别再打
熔断器三个状态:关闭(正常)、打开(直接拒绝)、半开(放几个探子)。以调用外部 AI 接口为例:
if ($b->isOpen('ai_api')) { return $fallback; } // 打开状态直接返回兜底
$ok = callAiApi($text);
$b->record('ai_api', $ok); // 连续5次失败 → 打开30秒
判定规则用"连续失败 N 次"最简单,稳定后可以换成"滑动窗口失败率 > 50%"。
注意:熔断要按"依赖名"隔离。AI 接口挂了不该把搜索引擎接口也一起熔断。
第六步:降级——保住核心,砍掉枝节
按优先级给功能排三档:
- 一级(绝不能挂):看帖、发帖、登录;
- 二级(可延迟):通知推送、经验结算、统计计数——扔队列;
- 三级(可关闭):AI 回复、排行榜、实时访客统计、礼物动画——扛不住时后台一键关掉插件或开关。
降级时的返回方式有三种,按场景选:返回上一版缓存数据(排行榜降级)、转异步(提交后提示"稍后生效")、直接隐藏入口(首页去掉排行榜模块)。
第七步:上线前后各查一次
上线前压一轮,重点看:任务表积压条数、限流触发次数、熔断打开次数、降级开关状态。这四个数字就是你的仪表盘。Clara BBS 的后台插件中心可以保存即生效地开关插件,紧急时刻关掉三级功能不需要改代码、不用清缓存。
注意:压测别对着生产库跑。复制一份数据到测试库,不然慢查询会把线上拖死。
小结
- 顺序不能反:先查慢 SQL 和 N+1,再缓存,最后才队列、限流、熔断、降级;
- 队列靠"任务表 + 抢占式 UPDATE",必须做幂等、重试上限、超时回收、失败可见;
- 限流状态要跨进程共享,按 IP / 用户 / 接口三个维度分开配额;
- 熔断按依赖隔离,打开后直接走兜底,半开状态放探子;
- 降级要提前给功能排好优先级,扛不住时先砍三级功能;
- 四个关键指标常看:积压任务数、限流触发数、熔断打开数、降级开关状态。