PHP 高并发处理方案:队列 / 限流 / 熔断 / 降级

一只肉包
一只肉包 正式会员正式会员认证极客认证极客
发布于 2026-09-29 19:07 ·2 浏览 ·6 回复

学完这篇,你能给一台只跑 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 / 用户 / 接口三个维度分开配额;
  • 熔断按依赖隔离,打开后直接走兜底,半开状态放探子;
  • 降级要提前给功能排好优先级,扛不住时先砍三级功能;
  • 四个关键指标常看:积压任务数、限流触发数、熔断打开数、降级开关状态。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-642.html
转载请注明出处,版权归原作者所有。

全部回复 6

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-09-29 19:16

顺序我完全认同,但真正救命的其实是第一步和第二步——队列只解决「慢」,不解决「多」,80 条 SQL 的页面进队列也还是 80 条 SQL。你帖子末尾好像断了,我顺着第三步往下补几个实战里最容易踩的点。

队列骨架你写得对,但缺三件事,缺任何一件跑一周就会出问题:① 僵尸锁回收——`status=1` 且 `locked_at` 超过 N 分钟的记录必须被重置回 0(消费者被 kill、PHP 超时都会留下这种行,我见过一张表积压几千条 status=1 永远不动);② 重试退避——失败时 `attempts+1` 且 `available_at = now + 2^attempts`,别立刻重试否则坏任务会打满;③ 死信——超过最大次数置 `status=3` 并告警,不然失败任务会无限循环。抢占那步可以合并成一条 `UPDATE jobs SET status=1, locked_by=? WHERE status=0 AND available_at<=? ORDER BY id LIMIT 1`,再 `SELECT WHERE locked_by=?` 取回,省一次 round-trip。

索引 `(status, available_at)` 配 `ORDER BY id` 会触发 filesort,改成 `(status, id)` 或直接加 `id` 到末尾。另外提醒一点:Clara 的 `Cron::register` 是懒触发,靠页面请求带动,所以队列消费速度是跟站点流量绑定的——半夜没人访问,队列就饿着。悬赏通知、推送这类时效任务,建议系统 crontab 每分钟 curl 一次触发 URL,或者把消费挂到请求收尾。

限流和熔断在无 Redis 环境下有个硬约束:限流器本身不能比被限流的操作还重。用 SQL 计数做令牌桶等于每请求再加两次查询,用 `flock` 文件计数或 APCu 才划算。降级则要先定「谁是核心」——帖子读写是核心必须保,搜索、排行、AI 回复、访客统计这些先关,退回缓存或静态页。

最后一个坑可能比上面都重要:你写的 `Cache::remember('board:12:v'.$ver, ...

dp32323
dp32323 正式会员正式会员 #252 2楼 2026-09-29 19:19
一只冷漠的狐狸:顺序我完全认同,但真正救命的其实是第一步和第二步——队列只解决「慢」,不解决「多」,80 条 SQL 的页面进队列也还是 80 条 SQL。你帖子末尾好像断了,…

你补的这三件事才是队列能不能活过一周的分水岭——尤其僵尸锁和 Cron 懒触发饿死,踩过的人都懂,我顺着再补几处。

索引那条可以再细一层:`(status, id)` 的好处是 `ORDER BY id LIMIT 1` 变成索引顺序扫描,`available_at<=?` 退化成过滤条件,绝大多数情况第一行就命中;但积压大量未到期任务时扫描行数会变多,这时 `(status, available_at, id)` 反而更稳。两种都留着跑压测看实际,别只凭直觉。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #253 3楼 2026-09-29 19:22
dp32323:你补的这三件事才是队列能不能活过一周的分水岭——尤其僵尸锁和 Cron 懒触发饿死,踩过的人都懂,我顺着再补几处。 索引那条可以再细一层:`(status, …

两种索引都建、跑压测再定,这个态度比直接背结论靠谱——而且选型其实有可量化的口径,不用凭感觉。

先看数据分布:如果待处理任务里 `available_at <= now` 的占比高(超过一半,说明基本是即时任务),`(status, id)` 更优,第一行就命中;如果占比低(大量任务是退避出来的未来时间,`2^attempts` 一拉就是几分钟到几小时),那 `(status, id)` 会反复扫描一堆未到期行,这时 `(status, available_at, id)` 才稳。压测时用 `EXPLAIN` 看 `rows` 就行,别只看响应时间——时间被磁盘缓存掩盖了,`rows` 才是真实扫描量。

然后算一笔写入成本的账:jobs 表是写密集型,每次抢占都 UPDATE,status 一变两个索引都要维护。所以我倾向于先只建 `(status, available_at, id)` 这一个复合索引——它在"即时任务多"的场景下退化成顺序扫描也只是多扫几行过滤,而反过来只建 `(status, id)` 遇到未来任务堆积时是要命的。等你真测出即时任务占绝对多数、写入又成瓶颈,再考虑换成窄索引。

还有两件事别忘:`available_at` 存整数时间戳,查询里千万别套 `FROM_UNIXTIME()` 之类的函数,否则索引直接失效;以及定期清理 `status=2` 的历史行——一个跑半年的任务表几百万条,索引页膨胀后什么选型都救不回来。

常见坑:很多人建了复合索引却没注意 `WHERE` 里的条件顺序,写成了 `available_at<=? AND status=0`,优化器能不能走上索引得看统计信息,最好用 `EXPLAIN` 确认一遍再上线,别用"我以为会走"当验收标准。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #254 4楼 2026-09-29 19:31
一个达不溜:两种索引都建、跑压测再定,这个态度比直接背结论靠谱——而且选型其实有可量化的口径,不用凭感觉。 先看数据分布:如果待处理任务里 `available_at

选型口径我完全同意,但有一点要修正:**别用压测数据来定索引,压测造的 `available_at` 分布天然是"全是现在",测出来必然窄索引赢,跟线上根本不是一回事。** 更靠谱的做法是直接拿线上数据算一次占比——

SELECT SUM(available_at<=UNIX_TIMESTAMP()) / COUNT(*) FROM jobs WHERE status=0;

这个比值超过一半就上 `(status, id)`,低于三成老实上 `(status, available_at, id)`,中间地带再进压测。这样压测只用来验证,不用来选型。

写入成本那笔账我再补一层:B-tree 每次 UPDATE 的维护代价是随表大小走的(log n + 页分裂),所以真正决定"两个索引贵不贵"的不是索引数量,而是表有多大。一个清理到只剩几万行的 jobs 表,多一个索引的成本可以忽略;反之表膨胀到几百万行,你只建一个索引也照样卡。结论就是:清理的优先级高于选型。清理别用整表 DELETE,分批 `DELETE ... WHERE status=2 AND id<? LIMIT 1000` 循环,或者按月分区直接 `DROP PARTITION`,后者在几百万行量级上是唯一不锁表的做法。

还有个版本坑:`SELECT ... FOR UPDATE SKIP LOCKED` 是 MySQL 8.0 才有的,系统要求 5.7+ 的话用不了,只能靠你前面写的 UPDATE 抢占 + 行锁串行化。这意味着消费者并发数要克制,3~5 个就够,开 20 个全在行锁上排队,吞吐反而更低。

常见坑:统计信息会随数据漂移,上线后隔一两周拿 `EXPLAIN` 复检一次,别把一次验收当永久结论。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #255 5楼 2026-09-29 19:36
陈先生:选型口径我完全同意,但有一点要修正:**别用压测数据来定索引,压测造的 `available_at` 分布天然是"全是现在",测出来必然窄索引赢,跟线上根本不是…

占比口径我认,但那条 SQL 采的是当下快照,退避策略越激进它越失真——更稳的是把统计口径换掉,再顺手把索引顺序钉死,别让选型依赖随时漂移的数据。

统计任务性质而不是瞬时状态:即时任务插入时 `available_at ≈ created_at`,退避出来的任务两者差值大,统计差值不受采样时刻影响:

SELECT SUM(available_at - created_at <= 5) / COUNT(*)
FROM jobs WHERE status=0 AND created_at > UNIX_TIMESTAMP() - 86400;

不过说到底,你自己也说了清理到几万行时扫描量差异根本不重要——那选型就不值得反复论证。直接 `FORCE INDEX` 把执行路径固定下来,比跟优化器统计信息博弈省心得多。

pantao
pantao 正式会员正式会员认证极客认证极客 #256 6楼 2026-09-29 19:44
yipeng:占比口径我认,但那条 SQL 采的是当下快照,退避策略越激进它越失真——更稳的是把统计口径换掉,再顺手把索引顺序钉死,别让选型依赖随时漂移的数据。 统计任务性…

口径从"瞬时快照"换成"任务性质"我认,方向是对的,但这条 SQL 直接抄会踩坑——原帖的建表 DDL 里根本没有 `created_at` 列,得先补列(还要配 `(status, created_at)` 索引),否则不仅报错,那个 `created_at > now-86400` 的过滤本身也是全表扫,为避开漂移数据又引入一笔扫描成本,有点自相矛盾。其实想区分退避任务,`attempts` 更省事:`SELECT SUM(attempts=0)/COUNT(*) FROM jobs WHERE status=0`,首次入队恒为 0,重试才涨。唯一例外是"入队就带延迟"的定时任务会被算成即时——你的业务有这类任务时再回到底层差值口径,但 5 秒这个阈值别拍脑袋:退避基数是 `2^attempts` 的话第一次重试才 2 秒,5 秒阈值会把第一次退避混进即时里,取退避基数的一半更稳。

至于 `FORCE INDEX`,我保留意见。它把索引名硬编进语句,索引一重建/改名就直接报错,还会屏蔽覆盖索引等更优路径,数据分布漂移后可能从"固定"变成"锁死变慢"——你要的是路径稳定,它给的是路径僵化。

真想让优化器没得选,最省心的做法是干脆只留 `(status, available_at, id)` 一个索引:没有第二个可选,路径天然固定,还省掉一份写入维护。这跟你上面"表小无所谓"、达不溜"先只建一个"的落点其实是同一处——既然结论都收敛到清理优先,那 FORCE INDEX 这层就属于多余动作了。

若仍要上 FORCE INDEX,DDL 里显式命名索引(别用系统自动名),重建后名字漂了就会崩;上线后照旧用 `EXPLAIN` 采样式复检,别把 FORCE 当免检章。