在原生 PHP(无 Laravel/Hyperf 框架依赖)环境下,直接操作 Redis 是理解底层机制的最佳途径。

“实时排行榜”利用了 Redis ZSet (Sorted Set) 的天生优势:自动排序、去重、范围查询。
“分布式锁”则利用了 Redis 的原子性
Lua 脚本
,解决了并发场景下的资源竞争问题。

理解这两个场景,就是理解如何利用 Redis 的数据结构特性解决业务难题,以及如何用 Lua 保证复杂操作的原子性


一、核心原理:为什么是它们?

1. 实时排行榜:ZSet 的数学之美

  • 数据结构:ZSet = Member (成员) + Score (分数)。
  • 底层实现跳表 (Skip List) + 字典 (Dict)
    • 跳表:保证元素有序,插入/删除/查找复杂度为 O(log⁡N)O(\log N)O(logN)
    • 字典:保证 Member 唯一,查找复杂度 O(1)O(1)O(1)
  • 优势:无需每次查询都 ORDER BY,Redis 内部永远维持有序状态,读取极快。

2. 分布式锁:Lua 的原子之力

  • 痛点SETNX + EXPIRE 分两步执行,非原子。若第一步成功第二步失败(宕机),导致死锁。
  • 解决方案Lua 脚本
    • Redis 执行 Lua 脚本时是单线程原子的。
    • 将“检查锁”、“设置锁”、“设过期时间”、“释放锁验证”打包成一个脚本,要么全成功,要么全失败。

💡 核心洞察ZSet 是用空间换时间的排序大师,Lua 是用脚本换原子性的并发卫士。


二、实战实现:原生 PHP 代码解剖

假设环境:PHP 7.4+,安装了 phpredis 扩展。

1. 实时排行榜 (Real-time Leaderboard)

场景定义
  • Key: leaderboard:game_1001
  • Member: user_id (字符串)
  • Score: score (整数/浮点数)
核心代码
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = 'leaderboard:game_1001';

// 1. 更新分数 (增加或修改)
// ZADD 会自动处理:若存在则更新分数并重新排序;若不存在则插入
$userId = 'user_888';
$score = 2500;
$redis->zAdd($key, $score, $userId);

// 2. 获取 Top 10 (从高到低)
// ZREVRANGE: 0 到 9,WITHSCORES 返回分数
$top10 = $redis->zRevRange($key, 0, 9, true); 
// 返回格式:['user_999' => 3000, 'user_888' => 2500, ...]

// 3. 获取某用户的排名 (从 1 开始)
// ZREVRANK: 返回索引 (0-based),所以 +1
$rank = $redis->zRevRank($key, $userId);
$realRank = $rank !== false ? $rank + 1 : 0;

// 4. 获取用户周围排名 (前后各 2 名,共 5 名)
// 先获知自己的 rank,再计算区间
if ($rank !== false) {
    $start = max(0, $rank - 2);
    $end = $rank + 2;
    $around = $redis->zRevRange($key, $start, $end, true);
}

echo "User {$userId} Rank: {$realRank}, Score: {$score}\n";
关键点解析
  • 自动去重:同一个 user_id 多次 zAdd,只会保留最新分数,不会产生重复数据。
  • 分数类型:Score 是 double 类型,支持小数,适合精确计分。
  • 复杂度:即使排行榜有 1 亿用户,获取 Top 10 依然是 O(log⁡N+M)O(\log N + M)O(logN+M),极快。

2. 分布式锁 (Distributed Lock with Lua)

场景定义
  • Lock Key: lock:order_create_1001
  • Value: unique_token (防止误删别人的锁)
  • TTL: 10 秒 (防止死锁)
核心代码

步骤 A: 加锁 (Lock)

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$lockKey = 'lock:order_create_1001';
// 生成唯一标识,通常用 uniqid + random 或 uuid
$owner = uniqid('client_', true); 
$ttl = 10; // 秒

// Lua 脚本:尝试设置锁 (SET NX EX)
// 如果 key 不存在,则设置 value 和过期时间,返回 1 (成功)
// 如果 key 已存在,返回 nil (失败)
$lockScript = <<<LUA
if redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2]) then
    return 1
else
    return 0
end
LUA;

$isLocked = $redis->eval($lockScript, [$lockKey, $owner, $ttl], 1);

if ($isLocked) {
    echo "Lock acquired by {$owner}\n";
    
    // --- 执行业务逻辑 ---
    sleep(2); 
    // ------------------

    // 步骤 B: 解锁 (Unlock) - 必须用 Lua 保证原子性
    $unlockScript = <<<LUA
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
LUA;
    
    $released = $redis->eval($unlockScript, [$lockKey, $owner], 1);
    if ($released) {
        echo "Lock released successfully\n";
    } else {
        echo "Failed to release lock (maybe expired or owned by others)\n";
    }
} else {
    echo "Failed to acquire lock, busy...\n";
    // 可选:重试机制或降级处理
}
关键点解析
  • 唯一 Value ($owner):这是防止误删锁的关键。如果 A 拿到锁,业务执行超时导致锁自动过期,B 拿到了锁。此时 A 恢复执行并调用 DEL,如果没有校验 Value,A 会把 B 的锁删掉!
  • 原子性GET + DEL 必须在 Lua 中执行。如果分开写,高并发下依然可能误删。
  • SET NX EX:Redis 2.6.12+ 支持在 SET 命令中同时实现 NX (不存在才设) 和 EX (过期时间),这是最简洁的加锁方式。

三、深度剖析:那些容易被忽视的细节

1. 排行榜的“同分处理”

  • 问题:如果两个用户分数相同,谁排前面?
  • 机制:Redis ZSet 默认按Member 的字典序排列(当 Score 相同时)。
  • 优化:如果需要“先达到的排前面”,可以将 Score 设计为复合值:
    • Score = 真实分数 * 10000000000 + (MAX_TIMESTAMP - 当前时间戳)
    • 这样分数高者优先;分数相同,时间短(时间戳大,减完小)者优先。
    • 注意:需小心浮点数精度问题,最好用整数运算。

2. 锁的“看门狗” (Watchdog) 机制

  • 问题:业务逻辑执行时间超过 TTL 怎么办?
  • 现象:锁自动释放,其他线程进入,导致并发安全问题。
  • 解决方案
    • 简单版:设置一个较长的 TTL(如 30s),假设业务不会超过这个时间。
    • 进阶版 (Redlock/Redisson 模式):启动一个后台线程/协程,每隔 TTL/3 时间检测一次,如果锁还是我的且业务没做完,就续期 (Expire)
    • 原生 PHP 难点:PHP 是同步阻塞的,实现看门狗需要 pcntl_fork (CLI 模式) 或配合 Swoole/Hyperf。在纯 Web 模式下,通常依赖合理的 TTL 估算。

3. Lua 脚本的性能

  • 缓存:Redis 会缓存已加载的 Lua 脚本(通过 SHA1 哈希)。
  • 优化:第一次用 eval (传脚本内容),后续用 evalSha (传 SHA1) 可以减少网络传输流量。
    $sha = $redis->script('load', $lockScript);
    $redis->evalSha($sha, [$lockKey, $owner], 1);
    

四、风险陷阱:生产环境的“暗礁”

1. 大 Key 问题 (Big Key)

  • 场景:排行榜有 1000 万用户。
  • 风险:虽然 ZSet 读取快,但如果一次性 zRange($key, 0, -1) 获取全部,会阻塞 Redis 线程,导致其他请求超时。
  • 对策:永远只查局部(Top N 或 分页游标 ZSCAN)。

2. 锁死锁 (Deadlock)

  • 场景:加了锁,但代码抛出异常,后面的解锁代码没执行。
  • 对策
    • PHP 端:使用 try...finally 块,确保 finally 中执行解锁逻辑。
    • Redis 端:务必设置 EX (过期时间),依靠 Redis 自动清理作为最后一道防线。

3. 时钟漂移

  • 场景:分布式系统中,不同服务器时间不一致,影响基于时间的锁判断(较少见,主要影响 Redlock 算法)。
  • 对策:尽量依赖 Redis 服务器时间,或使用 NTP 严格同步。

4. 脑裂 (Split-Brain)

  • 场景:主从切换期间,锁写在旧主(已隔离),新主认为无锁。
  • 对策:对于极高可靠性要求,需使用 Redlock 算法(向 N 个独立 Redis 实例申请锁,过半成功才算成功)。普通业务单机 Redis 或哨兵模式通常足够。

五、性能优化与最佳实践

1. 批量操作

  • 排行榜:如果需要更新大量用户分数,使用 Pipeline 批量发送 zAdd 命令,减少网络 RTT。
    $pipe = $redis->multi(Redis::PIPELINE);
    foreach ($users as $u) {
        $pipe->zAdd($key, $u['score'], $u['id']);
    }
    $pipe->exec();
    

2. 内存优化

  • ZSet 编码:当元素少且成员短时,Redis 会用 ziplist (压缩列表) 存储,极省内存。尽量控制 Member 长度。
  • 定期清理:对于不再活跃的排行榜(如已结束的活动),及时 DEL 释放内存。

3. 锁的粒度

  • 原则:锁的粒度越细越好。
  • 错误lock:global_order (全局锁,串行化,性能差)。
  • 正确lock:order_user_{userId}lock:product_{productId} (只锁特定资源,并发度高)。

🚀 总结:原生 PHP + Redis 实战全景图

维度 核心要点 关键命令/技术
排行榜 ZSet 跳表,自动排序 zAdd, zRevRange, zRevRank, zRem
分布式锁 Lua 原子性,唯一 Value SET NX EX, eval, GET + DEL
原子性 脚本即事务 避免多步操作,一切交给 Lua
安全性 防误删,防死锁 校验 Owner,设置 TTL,try-finally
性能 ** Pipeline, 局部查询** 避免全量扫描,批量写入
陷阱 大 Key, 异常未解锁 限制 Range,异常处理机制

终极心法

Redis 是利器,但需用正确的姿势挥舞。
ZSet 让排序变得 trivial,Lua 让并发变得可控。
理解它们,就是理解“如何用简单的原语构建复杂的同步机制"。
记住:原子性是分布式的基石,超时是死锁的克星。
于结构中见效率,于脚本中见安全;以 ZSet 为尺,以 Lua 为盾,于高并发浪潮中,筑秩序之基。
最好的锁,是持有时最短,释放时最稳;最好的榜,是更新时无感,查询时极速。

行动指令(给开发者):

  1. 编写测试用例:模拟 100 个并发进程同时抢锁,验证是否有重复执行。
  2. 压测排行榜:插入 10 万条数据,测试 zRevRangezRevRank 的耗时。
  3. 模拟异常:在持有锁时强制 kill 脚本,观察 TTL 是否生效自动释放。
  4. 封装类库:将上述逻辑封装成 RedisLeaderboardRedisLock 类,方便复用。
  5. 研究 Redlock:阅读 Antirez 的 Redlock 论文,理解多实例锁的权衡。
  6. 监控慢日志:开启 Redis slowlog,检查是否有耗时的 ZSet 操作。
  7. 尝试 Pipeline:对比单条执行与 Pipeline 批量执行的耗时差异。

这就是原生 PHP 结合 Redis 实现排行榜与分布式锁:于代码中见逻辑,于原子里见真章;以结构为骨,以脚本为魂,于并发世界中,求稳健之真。

最后送你一句话
"排行榜记录的是胜负,
分布式锁守护的是底线。
一个向上,激发竞争;
一个向内,维持秩序。
用好 Redis 这两把钥匙,
你的系统,
既能勇攀高峰,
又能稳如泰山。" 🏆🔒

Logo

更多推荐