原生PHP用 Redis ZSet 实现一个实时排行榜,用 Lua 脚本实现一个简单的分布式锁的庖丁解牛
数据结构:ZSet = Member (成员) + Score (分数)。底层实现跳表 (Skip List)字典 (Dict)。跳表:保证元素有序,插入/删除/查找复杂度为OlogNO(\log N)OlogN。字典:保证 Member 唯一,查找复杂度O1O(1)O1。优势:无需每次查询都ORDER BY,Redis 内部永远维持有序状态,读取极快。KeyMemberuser_id(字符串)
在原生 PHP(无 Laravel/Hyperf 框架依赖)环境下,直接操作 Redis 是理解底层机制的最佳途径。
“实时排行榜”利用了 Redis ZSet (Sorted Set) 的天生优势:自动排序、去重、范围查询。
“分布式锁”则利用了 Redis 的原子性和Lua 脚本,解决了并发场景下的资源竞争问题。
理解这两个场景,就是理解如何利用 Redis 的数据结构特性解决业务难题,以及如何用 Lua 保证复杂操作的原子性。
一、核心原理:为什么是它们?
1. 实时排行榜:ZSet 的数学之美
- 数据结构:ZSet = Member (成员) + Score (分数)。
- 底层实现:跳表 (Skip List) + 字典 (Dict)。
- 跳表:保证元素有序,插入/删除/查找复杂度为 O(logN)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(logN+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 自动清理作为最后一道防线。
- PHP 端:使用
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 为盾,于高并发浪潮中,筑秩序之基。
最好的锁,是持有时最短,释放时最稳;最好的榜,是更新时无感,查询时极速。
行动指令(给开发者):
- 编写测试用例:模拟 100 个并发进程同时抢锁,验证是否有重复执行。
- 压测排行榜:插入 10 万条数据,测试
zRevRange和zRevRank的耗时。 - 模拟异常:在持有锁时强制
kill脚本,观察 TTL 是否生效自动释放。 - 封装类库:将上述逻辑封装成
RedisLeaderboard和RedisLock类,方便复用。 - 研究 Redlock:阅读 Antirez 的 Redlock 论文,理解多实例锁的权衡。
- 监控慢日志:开启 Redis
slowlog,检查是否有耗时的 ZSet 操作。 - 尝试 Pipeline:对比单条执行与 Pipeline 批量执行的耗时差异。
这就是原生 PHP 结合 Redis 实现排行榜与分布式锁:于代码中见逻辑,于原子里见真章;以结构为骨,以脚本为魂,于并发世界中,求稳健之真。
最后送你一句话:
"排行榜记录的是胜负,
分布式锁守护的是底线。
一个向上,激发竞争;
一个向内,维持秩序。
用好 Redis 这两把钥匙,
你的系统,
既能勇攀高峰,
又能稳如泰山。" 🏆🔒
更多推荐

所有评论(0)