Redis 在 PHP 中的高级用法:分布式锁、延时队列与限流器

Redis 在 PHP 生态中早已不只是缓存工具。凭借原子操作、Lua 脚本、过期机制和发布订阅等特性,它可以承担分布式锁、延时队列和限流器这类基础设施角色。本文从实战角度出发,结合 PHP 代码示例,讲解这三种高级用法的实现思路与关键细节。

一、分布式锁:从 SET NX 到 Redlock

1.1 为什么需要分布式锁

在单机环境中,PHP 的 flock() 或文件锁足以应对并发写入。但在多台 PHP-FPM 服务器同时处理同一业务时,文件锁失效,必须依赖外部协调服务。Redis 的 SET key value NX PX milliseconds 命令天然适合作为锁原语:NX 保证互斥,PX 保证锁自动释放,避免死锁。

1.2 基础实现

class RedisLock
{
    private $redis;
    private $prefix = 'lock:';

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

    public function acquire(string $key, string $token, int $ttl = 10000): bool
    {
        $result = $this->redis->set(
            $this->prefix . $key,
            $token,
            ['nx', 'px' => $ttl]
        );
        return $result === true;
    }

    public function release(string $key, string $token): bool
    {
        // Lua 脚本保证“判断 + 删除”的原子性
        $script = <<<'LUA'
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
LUA;
        return (bool) $this->redis->eval(
            $script,
            [$this->prefix . $key, $token],
            1
        );
    }
}

关键点有两个:value 必须使用唯一 token(如 uniqid('', true)),防止误删他人持有的锁;释放锁必须用 Lua 脚本,保证 GET 和 DEL 之间不被其他客户端插入操作。

1.3 锁续期与 Redlock 的取舍

如果业务执行时间可能超过 TTL,需要启动一个“看门狗”协程或定时任务定期续期。PHP 中可以用 Swoole\Timer 或简单的 pcntl_alarm 实现。对于跨多个 Redis 主节点的高可用场景,Redis 官方提出的 Redlock 算法要求在多数节点上成功获取锁才算成功。不过 Redlock 在业界存在争议(Martin Kleppmann 曾质疑其安全性),若业务对一致性要求极高,建议使用 etcd 或 ZooKeeper。对于大多数 PHP 业务,单实例 Redis 锁配合合理 TTL 已足够。

二、延时队列:用 ZSET 实现精确调度

2.1 业务场景

订单 30 分钟未支付自动取消、用户注册 24 小时后发送召回短信——这类需求不适合用 sleep 阻塞进程,也不适合用 RabbitMQ 的延迟插件(增加运维复杂度)。Redis 的 ZSET(有序集合)以时间戳为 score,天然支持按时间排序取出到期任务。

2.2 生产者:投递延时任务

class DelayQueue
{
    private $redis;
    private $key = 'delay:queue';

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

    public function push(string $job, int $delaySeconds): void
    {
        $score = microtime(true) + $delaySeconds;
        $this->redis->zAdd($this->key, $score, $job);
    }
}

// 投递一个 30 分钟后执行的订单取消任务
$queue->push(json_encode([
    'type' => 'cancel_order',
    'order_id' => 1024,
]), 1800);

2.3 消费者:轮询到期任务

public function consume(int $batch = 10): array
{
    $now = microtime(true);
    // 取出 score <= 当前时间的任务
    $jobs = $this->redis->zRangeByScore(
        $this->key,
        '-inf',
        (string) $now,
        ['limit' => [0, $batch]]
    );

    $result = [];
    foreach ($jobs as $job) {
        // ZREM 返回 1 表示抢到该任务,避免多消费者重复处理
        if ($this->redis->zRem($this->key, $job) === 1) {
            $result[] = $job;
        }
    }
    return $result;
}

消费者可以用 while(true) 循环加 usleep(500000) 轮询,也可以借助 zRangeByScore 的阻塞替代方案——用 Redis 的 BLPOP 配合一个“最近到期时间”通知键,减少空轮询。生产环境中建议用 Supervisor 管理多个消费者进程,并加入失败重试逻辑(如处理失败后重新 zAdd 并增加重试计数)。

三、限流器:滑动窗口与令牌桶

3.1 固定窗口的缺陷

最简单的限流是 INCR + EXPIRE,比如限制每分钟 100 次请求。但固定窗口存在临界问题:在第 59 秒和第 61 秒各发 100 次请求,虽然两个窗口内都没超限,但实际 2 秒内通过了 200 次请求。滑动窗口能解决这个问题。

3.2 滑动窗口限流(ZSET 实现)

class SlidingWindowLimiter
{
    private $redis;

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

    public function allow(string $key, int $limit, int $window): bool
    {
        $now = microtime(true);
        $zkey = 'limit:' . $key;

        $pipe = $this->redis->multi(\Redis::PIPELINE);
        // 移除窗口外的旧请求
        $pipe->zRemRangeByScore($zkey, '-inf', (string) ($now - $window));
        // 统计当前窗口内请求数
        $pipe->zCard($zkey);
        // 记录本次请求
        $pipe->zAdd($zkey, $now, $now . ':' . mt_rand());
        // 设置过期,避免冷 key 占用内存
        $pipe->expire($zkey, $window + 1);
        $results = $pipe->exec();

        $count = $results[1];
        if ($count >= $limit) {
            // 超限,回滚刚才的 zAdd
            $this->redis->zRemRangeByScore($zkey, (string) $now, (string) $now);
            return false;
        }
        return true;
    }
}

这里用 Pipeline 减少网络往返,但严格来说“统计 + 添加”不是原子的。若要求绝对精确,应改用 Lua 脚本一次性完成。Lua 版本只需把上述逻辑写进 eval() 即可,性能也更好。

3.3 令牌桶的替代方案

滑动窗口限制的是“窗口内请求数”,而令牌桶允许突发流量。Redis 官方在 redis-cell 模块中提供了 CLTHROTTLE 命令,一条命令实现令牌桶。若无法安装模块,可以用 Lua 模拟:维护 tokenslast_refill 两个字段,每次请求按时间差补充令牌。对于 PHP 应用,滑动窗口已能覆盖 90% 的限流场景,令牌桶更适合网关层。

四、生产环境注意事项

连接管理:使用 pconnect 长连接可减少 TCP 开销,但在 PHP-FPM 下需注意连接数上限。推荐用 RedisArrayRedisCluster 做分片。

序列化igbinary 比 PHP 默认序列化更快更省内存,安装扩展后设置 $redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY)

监控:用 INFO 命令关注 blocked_clientsused_memoryinstantaneous_ops_per_sec,限流器和延时队列容易因 key 过多导致内存膨胀。

降级:Redis 不可用时,分布式锁应快速失败而非阻塞,限流器可切换为本地内存计数,延时队列需将任务落库兜底。

结语

分布式锁、延时队列和限流器是 PHP 后端进阶的必修课。Redis 用简洁的数据结构解决了这些问题,但“能用”和“用好”之间隔着原子性、续期、临界问题和降级策略等细节。建议在测试环境用 redis-benchmark 压测,并用 Lua 脚本替代多命令组合,让每一步操作都具备原子语义。掌握这些模式后,你会发现 Redis 不只是缓存,更是轻量级协调服务的瑞士军刀。

未经允许不得转载:任鹏个人博客 » Redis 在 PHP 中的高级用法:分布式锁、延时队列与限流器

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏