库存扣减必须用Redis原子命令或数据库行级锁,推荐Redis DECR配合EXISTS判断;限流需Nginx+PHP双层,PHP用Redis令牌桶;队列削峰优先Redis List+Worker;连接必须复用并启用tcp-keepalive。

库存扣减必须用原子操作,别信 PHP 的 file_get_contents
PHP 单进程模型下,fopen + flock 或文件锁根本扛不住并发写,file_get_contents 读出 100,100 个请求同时 $stock-- 再 file_put_contents,最终库存大概率是 99 或 98。真正能用的只有两类:Redis 原子命令、数据库行级锁。
推荐方案是 Redis DECR 配合 EXISTS 判断:
if (redis->exists('stock:123')) {
$left = redis->decr('stock:123');
if ($left >= 0) {
// 扣减成功,进下单队列
} else {
redis->incr('stock:123'); // 回滚,避免负数
// 库存超卖
}
}注意点:
-
DECR是原子的,但回滚那句INCR必须加,否则超卖后库存卡在 -1 - 不要用
GETSET模拟,它不保证“先查后减”的原子性 - MySQL 方案要用
SELECT ... FOR UPDATE,且事务必须短,不能包 HTTP 请求全程
限流不能只靠 Nginx,PHP 层得有 fallback 熔断
Nginx 的 limit_req 能挡掉 70% 流量,但秒杀开始瞬间的连接洪峰会绕过它(比如大量长连接保活、HTTP/2 多路复用)。PHP 层必须自己做二级限流,否则 FPM 进程池直接打满,整个站挂掉。
立即学习“PHP免费学习笔记(深入)”;
用 Redis 实现令牌桶最稳:
// 每秒 500 令牌,最大积压 1000
$bucket = 'token:seckill:123';
$now = time();
$tokens = redis->eval(
"local last = redis.call('hget', KEYS[1], 'last');"
. "local count = tonumber(redis.call('hget', KEYS[1], 'count') or '0');"
. "local rate = tonumber(ARGV[1]); local capacity = tonumber(ARGV[2]);"
. "local delta = math.min(math.floor((ARGV[3] - last) * rate), capacity);"
. "local new_count = math.min(capacity, count + delta);"
. "if new_count > 0 then "
. "redis.call('hset', KEYS[1], 'count', new_count - 1);"
. "redis.call('hset', KEYS[1], 'last', ARGV[3]);"
. "return 1 "
. "else "
. "return 0 "
. "end",
1, $bucket, 500, 1000, $now
);关键细节:
- 别用 PHP 的
microtime()当时间戳传入,必须用 RedisTIME或统一用$now = time(),避免时钟漂移 - 令牌桶 key 要带业务维度(如商品 ID),不能全局限流
- 熔断开关建议用 Redis
SETNX控制,比如连续 10 次限流触发就自动打开熔断,返回503 Service Unavailable
队列削峰别用 RabbitMQ 直连 PHP,优先走 Redis List + Worker
RabbitMQ 的 AMQP 协议和连接开销在 PHP-FPM 场景下太重,每秒吞吐难破 300。更轻量的做法是:PHP 接口只往 Redis LPUSH 订单请求,后台起常驻 PHP Worker(用 pcntl_fork 或 Supervisor 管理)轮询 BRPOP 处理。
示例结构:
// 秒杀接口里
redis->lpush('queue:seckill:order', json_encode([
'user_id' => $uid,
'item_id' => 123,
'ts' => time()
]));
<p>// Worker 脚本(cli 模式)
while (true) {
$data = redis->brpop('queue:seckill:order', 5); // 阻塞 5 秒
if ($data) {
$order = json_decode($data[1], true);
// 执行真实下单:生成订单、扣余额、发 MQ 通知等
}
}要注意的坑:
-
BRPOP的 timeout 设为 5 秒而非 0,避免 Worker 占满 CPU 不释放 - Worker 必须捕获所有异常并记录日志,否则崩一个就丢一批订单
- Redis List 长度要监控,超过阈值(如 10 万)说明下游处理不过来,该自动扩容 Worker 数量或告警
真实压测时,Redis 连接池和 MySQL 连接数最容易爆
本地开发用 new Redis() 没问题,但线上单机 2000 QPS 时,每个请求新建 Redis 连接会迅速耗尽 ulimit -n,报错 Cannot assign requested address。MySQL 同理,max_connections 默认 151,不够用。
必须改两处:
- PHP 侧用
Predis\Client或phpredis的pconnect,连接复用。注意pconnect在 FPM 下实际是进程级复用,需配合redis.conf的timeout参数(设为 300)防僵死连接 - MySQL 连接池不推荐自己写,直接上
mysqlnd_ms插件或 ProxySQL,把连接管理交给中间层 - 压测前务必用
ss -s和netstat -an | grep :6379 | wc -l看连接数是否线性增长——如果翻倍涨,说明没用上连接池
最常被忽略的是 Redis 的 tcp-keepalive。内网环境默认关闭,长连接空闲 5 分钟会被交换机回收,导致 PHP 报 Connection lost。必须在 redis.conf 开启 tcp-keepalive 60。



















