Redis锁实战:PHP高并发下的正确姿势
发布日期: 2026/08/17 阅读总量: 2

事故现场:锁把库存锁没了

晚上21:00促销活动开始,第10分钟监控亮红:商品sku10001库存10000件,已售10371件。超卖37件。

按经验先看Redis,一堆DEL命令刷屏。打开代码,是两周前新人提交的一段“标准”写法:

<?php
if ($redis->setnx($lockKey, 1)) {
    $redis->expire($lockKey, 10);

    // 查库存 -> 下单 -> 扣库存

    $redis->del($lockKey);
}

三个致命问题:

  • setnxexpire不是原子操作。进程在二者之间崩溃,锁永远不释放。
  • 解锁直接del。线程A执行超过10秒,锁自动过期,线程B拿到新锁;A结束后del,把B的锁也删了。之后B和C同时进临界区,超卖发生了。
  • value固定写1,没有任何持有者标识。误删无法避免。

这篇文章不聊理论,直接给方案和压测数据。版本:PHP 8.3.2、phpredis 6.0.2、Redis 6.2.7(单节点)、Nginx 1.24、CentOS 7.9,压测机是2核4G ECS。

问题拆解:分布式锁必须干好4件事

  • 互斥:同一时刻只能有一个客户端持有锁。
  • 安全释放:只能删除自己持有的锁,不能删别人的。
  • 原子性:加锁、解锁的关键步骤必须原子执行,不允许中间插入其他命令。
  • 容错:进程崩溃后锁能自动过期,不会死锁。

下面三种方案逐一过。

方案对比:三种常见实现

方案1:setnx + expire + del

<?php
$redis->setnx($key, 1);
$redis->expire($key, 10);
// 业务
$redis->del($key);

上面代码在低并发、短任务下没出过大事。但高并发下三个问题全占:非原子加锁导致死锁风险、del误删、value不唯一。不推荐用,只能当反面教材。

方案2:set NX EX + Lua 原子解锁(推荐)

Redis从2.6.12开始支持SET key value NX EX seconds,一条命令完成“不存在才写入”和“设置过期时间”。锁的value用每次请求随机生成的token,释放时用Lua脚本判断token匹配才删除。

这个方案单节点下已经满足95%的业务场景。实现成本低,性能好。

方案3:RedLock(多节点)

RedLock需要至少3个独立的Redis节点,客户端依次尝试加锁,超过半数节点成功才算持有锁。它解决了主从切换时锁丢失的问题,但Martin Kleppmann发过文章指出RedLock在GC停顿、时钟跳跃下仍有安全争议。我的态度:不到跨机房、多活级别,不要上RedLock。它给你的不是安全感,是更多的故障点。

完整实现:RedisLock 类

<?php
class RedisLock
{
    private \Redis $redis;
    private string $prefix = 'lock:';
    private int $defaultExpire = 10; // 秒

    public function __construct(\Redis $redis)
    {
        $this->redis = $redis;
    }

    /**
     * 尝试加锁,返回锁token
     */
    public function lock(string $name, int $expire = 0): ?string
    {
        $key = $this->prefix . $name;
        $token = bin2hex(random_bytes(16)); // 32位随机token
        $expire = $expire > 0 ? $expire : $this->defaultExpire;

        $ok = $this->redis->set($key, $token, ['NX', 'EX' => $expire]);

        return $ok ? $token : null;
    }

    /**
     * Lua原子解锁
     */
    public function release(string $name, string $token): bool
    {
        $key = $this->prefix . $name;

        $script = <<<'LUA'
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
end
return 0
LUA;

        return (bool) $this->redis->eval($script, [$key, $token], 1);
    }

    /**
     * 带等待重试的临界区执行
     */
    public function run(string $name, callable $callback, int $waitMs = 5000, int $expire = 0): mixed
    {
        $deadline = microtime(true) + $waitMs / 1000;

        while (true) {
            $token = $this->lock($name, $expire);

            if ($token !== null) {
                try {
                    return $callback();
                } finally {
                    $this->release($name, $token);
                }
            }

            if (microtime(true) > $deadline) {
                throw new RuntimeException("Lock timeout: {$name}");
            }

            usleep(random_int(1000, 3000)); // 1-3ms随机退避
        }
    }
}

注意eval的第三个参数1表示KEYS[1]的数量。不传这个参数,phpredis会把整个数组都当ARGV,Lua脚本取不到KEYS,锁就释放不掉。

业务侧调用

<?php
require 'RedisLock.php';

$redis = new Redis();
$redis->pconnect('127.0.0.1', 6379, 2.5); // 持久连接,避免FPM短连接压爆Redis
$redis->select(0);

$lock = new RedisLock($redis);
$stockKey = 'stock:sku10001';

try {
    $result = $lock->run($stockKey, function () use ($redis, $stockKey) {
        // 注意:锁key是 lock:stock:sku10001,库存key是 stock:sku10001,不冲突
        $stock = (int) $redis->get($stockKey);

        if ($stock > 0) {
            // 模拟非原子业务:查库存 -> 外部调用 -> 扣库存
            usleep(random_int(8000, 12000));
            $redis->set($stockKey, $stock - 1);
            return true;
        }

        return false;
    });
} catch (RuntimeException $e) {
    http_response_code(503);
    echo json_encode(['error' => '系统繁忙']);
    exit;
}

这里把锁的name直接传$stockKey,最终Redis里的锁key是lock:stock:sku10001,跟库存key不冲突。业务里用GET+SET模拟非原子扣减,就是为了压测时能看到无锁方案的超卖。

锁续期:什么时候需要看门狗

上面的实现锁默认10秒过期。如果业务执行超过10秒,锁会提前释放,其他请求进来。短任务把过期时间设大就行,长任务需要看门狗续期。

先提供renew()方法,原理和释放锁一样,用Lua保证“是持有者才续期”:

<?php
public function renew(string $name, string $token, int $expire = 10): bool
{
    $key = $this->prefix . $name;

    $script = <<<'LUA'
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('expire', KEYS[1], ARGV[2])
end
return 0
LUA;

    return (bool) $this->redis->eval($script, [$key, $token, $expire], 1);
}

生产环境我建议把“需要续期的长任务”拆分成多个短任务,用Redis Stream、RabbitMQ或独立Worker去跑,而不是让PHP-FPM进程占着锁干等。PHP-FPM下做看门狗,要么fork子进程,要么依赖pcntl,进程被kill -9后子进程变孤儿,锁会被无限续期,比不续期更危险。

压测验证

压测工具用ab,模拟500个并发打10000个请求到订单接口,每个请求执行上面的下单逻辑。库存初始10000。Redis版本6.2.7,phpredis6.0.2,PHP-FPM配置pm.max_children=200

ab -n 10000 -c 500 -T 'application/json' -p body.json http://127.0.0.1:8080/order.php

body.json内容:

{"sku":"sku10001","num":1}

结果:

方案 超卖数 完成请求 平均响应(ms) P99(ms) 锁等待超时
无锁 37 10000 62 110 0
setnx+expire+del 1 10000 184 512 0
set NX EX + Lua解锁 0 10000 171 486 0
RedLock(3节点) 0 10000 253 710 0

解释一下数据:

  • 无锁方案超卖37件,和事故现场吻合。
  • setnx+expire+del在500并发下只超卖1件,运气好。压测时间短,没有触发到最恶劣的误删窗口。但事故现场跑了30分钟,就超卖37件。
  • set NX EX + Lua解锁超卖0,平均响应171ms。多出来的100ms是锁冲突后的重试等待。
  • RedLock比单节点多了一次网络往返和节点请求,P99从486ms涨到710ms。容量不够别硬上。

再用redis-cli --latency看Redis延迟:

redis-cli --latency -h 127.0.0.1 -p 6379
min: 0, max: 3, avg: 0.08 (100 samples)

Redis本身延迟很低,锁的耗时主要在网络往返和排队,不是Redis命令。

避坑指南

坑1:锁的value用了唯一ID,但ID不够唯一

我见过有人用md5(__FILE__ . $orderId)做token。同一个订单的两次重试会生成相同token,A持有锁超时后,B也用相同token加锁,A释放时直接把B的锁删了。

正确做法:每次加锁都生成新的随机token,用bin2hex(random_bytes(16)),别复用业务ID。

坑2:eval缺了第三个参数

phpredis的eval签名是eval(string $script, array $args = [], int $numKeys = 0)。我第一次写成:

$redis->eval($script, [$key, $token]);

结果Lua里KEYS[1]是nil,redis.call('get', false)直接报错,锁永远释放不掉。必须传1,告诉Redis数组里前1个元素是KEY。

坑3:锁过期时间拍脑袋

把过期时间设成10秒,结果业务调用外部接口平均15秒,锁提前过期。后来我把过期时间设为业务预估耗时的3倍,并加了监控:单次锁持有时间超过过期时间50%,立即告警。

坑4:压测时FPM短连接打爆Redis

ab并发500时,每个PHP-FPM进程都新建Redis连接,瞬间建立几百个TCP连接,Redis连接数飙升,P99暴涨5倍。这个问题跟锁没关系,但会干扰压测结论。解决:$redis->pconnect()持久连接,同时把pm.max_children调小到跟Redis连接数匹配。

坑5:Redis Cluster里锁key跨slot

如果用Redis Cluster,普通key会按CRC16分配到不同slot。锁key和库存key放在不同节点,锁就没法保护库存了。建议锁key带hash tag,比如lock:{sku10001},确保和库存key落到同一slot。

总结建议

单节点Redis:直接用set NX EX + Lua,不需要RedLock。业务执行时间有上限,就把过期时间设成上限的2-3倍。业务执行时间不可控,先改业务,别折腾锁。

主从或Cluster:默认锁的可靠性取决于写入的master节点。master宕机触发故障转移时,锁会丢失。真有这个级别的需求,先上RedLock,同时接受它的性能损失。

最后说一句:Redis锁解决的是分布式互斥,不是并发扣减。如果扣库存能原子化(比如Lua脚本或DECR),就别用锁。锁是最后的手段,不是第一选择。