Appearance
Redis 三大典型场景实战:排行榜、限流、分布式锁
更新: 7/25/2026 字数: 0 字 时长: 0 分钟
Redis 之所以能在高并发后端架构中占据核心位置,关键在于它把数据结构作为一等公民对外暴露。选对数据结构,往往意味着一个场景的实现从"几十行加锁逻辑"简化为"一条原子命令"。下面按排行榜、限流、分布式锁三个场景,分别拆解选型逻辑、实现方案与落地坑点。
场景一:排行榜(Leaderboard)

选型:为什么是 Sorted Set(ZSet)
排行榜的本质需求是:按分数动态排序 + 快速查排名 + 快速取 TopN。这几乎是为 Sorted Set 量身定做的。
ZSet 底层是 跳表(skiplist)+ 哈希表 的组合:哈希表保证 O(1) 定位成员分数,跳表保证 O(log N) 的插入、删除和范围查询。相比用 List 排序(每次重排 O(N log N))或 Hash 存分数再应用层排序(拉全量数据),ZSet 在写入时就维护好了有序性,查询近乎零成本。
核心实现思路
bash
# 加分/更新分数(玩家 1001 增加 50 分),ZADD 是幂等 upsert
ZADD game:rank:20260725 GT 50 user:1001
# 也可用 ZINCRBY 做累加
ZINCRBY game:rank:20260725 50 user:1001
# 取 Top 10(从高到低),WITHSCORES 带出分数
ZREVRANGE game:rank:20260725 0 9 WITHSCORES
# 查某用户排名(0 基,需 +1 展示)
ZREVRANK game:rank:20260725 user:1001
# 查某用户分数
ZSCORE game:rank:20260725 user:1001伪代码封装:
java
// 上分
redis.zadd("rank:daily", newScore, userId);
// 我的排名与分数(一次 pipeline 减少 RTT)
Long rank = redis.zrevrank("rank:daily", userId); // 0-based
Double sc = redis.zscore("rank:daily", userId);
int myRank = rank == null ? -1 : rank.intValue() + 1;常见问题与优化方向
1. 同分排序问题。 ZSet 同分时按成员字典序排列,可能与业务预期(先达到该分数者靠前)不符。常见做法是把时间戳编码进 score:score = 真实分数 * 10^10 + (最大时间戳 - 当前时间戳),用整数高位存分数、低位存"越早越大"的时间因子,从而实现"同分先到先得"。注意 ZSet 的 score 是 double,有效精度约 52 位,需控制编码位数防止精度丢失。
2. 榜单周期与过期。 日榜/周榜/月榜用不同 key 区分(如 rank:20260725),并对日榜 EXPIRE 设置过期时间自动清理;总榜则常驻。
3. 大 key 与分片。 单个 ZSet 成员达千万级时会成为大 key,影响主从同步与迁移。可按业务维度(如大区、赛季)拆分,或只保留 TopN(定期 ZREMRANGEBYRANK key 0 -(N+1) 裁剪尾部)。
4. 分页深翻页。 ZREVRANGE 大 offset 仍需扫描,深分页建议改用 ZREVRANGEBYSCORE 配合上一页最后一个分数做游标翻页。
场景二:限流(Rate Limiting)

选型:三种主流方案对应不同数据结构
| 算法 | 数据结构 | 特点 |
|---|---|---|
| 固定窗口 | String(计数器) | 实现最简,存在窗口临界突刺 |
| 滑动窗口 | Sorted Set | 精确平滑,内存随请求数增长 |
| 令牌桶 | String/Hash + Lua | 平滑限流且允许突发,业界主流 |
方案一:固定窗口(String + INCR)
bash
# key 带时间窗口后缀,首次设置过期
INCR limit:api:user1001:1784977500
EXPIRE limit:api:user1001:1784977500 60问题在于临界突刺:窗口末尾和下个窗口开头的瞬间,可能放过 2 倍阈值的流量。为保证 INCR 与 EXPIRE 的原子性,应合并进 Lua 脚本执行。
方案二:滑动窗口(Sorted Set)
用 ZSet 存每次请求的时间戳(score 和 member 都用时间戳/唯一 ID),窗口滑动即删除过期成员:
lua
-- KEYS[1]=限流key ARGV[1]=当前时间ms ARGV[2]=窗口ms ARGV[3]=阈值
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) -- 清理窗口外
local cnt = redis.call('ZCARD', KEYS[1])
if cnt < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1 -- 放行
end
return 0 -- 拒绝滑动窗口精度高,但每个请求都占一个 ZSet 成员,高 QPS 下内存开销大,需评估。
方案三:令牌桶(生产推荐)
按固定速率往桶里放令牌,请求到来取令牌,取到放行、取不到拒绝——既平滑又允许一定突发。用 Hash 存"当前令牌数"和"上次填充时间",取令牌时按时间差惰性补充,全程用 Lua 保证原子:
lua
-- 惰性计算:本次应补的令牌 = (now - lastRefill) * rate
-- tokens = min(capacity, tokens + refill)
-- tokens >= 1 ? tokens-- 并放行 : 拒绝常见问题与优化方向
- 原子性是第一要务。 任何"读-判断-写"三步都必须放进 Lua 脚本或用原子命令,否则并发下限流失效。
- 集群环境的 key 路由。 单条 Lua 涉及的多个 key 必须落在同一 slot(用
{hashtag}),否则 Redis Cluster 报CROSSSLOT。 - 限流维度设计。 按用户、IP、接口、租户等维度组合 key;热点 key(如全局限流)可能成为单点瓶颈,可做本地限流 + Redis 全局限流的两级方案。
- 成熟组件优先。 生产环境可直接用 Redisson 的
RRateLimiter或 Sentinel/网关内置限流,减少自研踩坑。
场景三:分布式锁(Distributed Lock)

选型:String + SET NX EX
分布式锁的核心是互斥:同一时刻只有一个客户端能持有锁。Redis 单线程执行命令的特性天然适合做互斥判断,用一条 SET key value NX EX 即可完成"不存在才设置 + 自动过期"两个语义。
核心实现思路
加锁——三个要素缺一不可:
bash
SET lock:order:1001 <uuid> NX EX 30NX:仅当 key 不存在时设置,保证互斥;EX 30:设置过期时间,防止持锁者宕机导致死锁;<uuid>:唯一标识(线程/请求 ID),用于安全释放。
解锁——必须"验证是自己的锁再删",且要原子:
lua
-- 用 Lua 保证 GET 比对 + DEL 的原子性,防止误删他人的锁
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end伪代码:
java
String token = UUID.randomUUID().toString();
boolean locked = "OK".equals(
redis.set("lock:order:1001", token, "NX", "EX", 30));
if (locked) {
try {
doBusiness();
} finally {
redis.eval(UNLOCK_LUA, 1, "lock:order:1001", token); // 原子释放
}
}常见问题与优化方向
1. 为什么不能用 DEL 直接解锁? 若业务执行超过锁过期时间,锁已自动释放并被他人持有,此时直接 DEL 会误删别人的锁。因此必须用 GET+DEL 比对 UUID(见上方 Lua)。
2. 锁提前过期怎么办?——看门狗(Watchdog)。 业务执行时间不确定时,给锁设固定过期时间总有风险。Redisson 的解决方案是:加锁成功后启动后台线程,每隔 1/3 过期时间自动续期(默认 30s 锁、10s 续一次),业务结束才停止续期。这是生产环境的标准做法,强烈建议直接用 Redisson 而非手写。
3. 可重入与阻塞等待。 手写的 SET NX 不可重入(同一线程二次加锁会失败)。Redisson 用 Hash 结构存 <线程ID, 重入次数> 实现可重入锁,并支持 tryLock(waitTime, leaseTime) 阻塞等待。
4. 主从切换下的锁丢失。 单点 Redis 主从异步复制,主节点加锁后未同步到从节点就宕机,从节点晋升为主会导致锁丢失、两个客户端同时持锁。对强一致要求极高的场景,可用 RedLock(向多数派 N/2+1 个独立 Redis 节点申请锁)。但 RedLock 存在争议(Martin Kleppmann 曾质疑其在时钟漂移下的安全性),复杂度和成本较高,多数业务用"单点 Redis + Redisson 看门狗 + 业务幂等兜底"即可,真正的强一致场景应考虑 ZooKeeper/etcd。
总结对比
| 场景 | 首选数据结构 | 核心命令/组件 | 最大坑点 |
|---|---|---|---|
| 排行榜 | Sorted Set | ZADD / ZREVRANGE / ZREVRANK | 同分排序、大 key |
| 限流 | Sorted Set / String + Lua | 滑动窗口 / 令牌桶 Lua | 原子性、集群 CROSSSLOT |
| 分布式锁 | String | SET NX EX + 解锁 Lua / Redisson | 误删锁、锁过期、主从丢锁 |
贯穿三个场景有两条通用原则:其一,凡涉及"读-改-写"的复合操作,一律用 Lua 脚本或原子命令保证原子性;其二,能用成熟组件(Redisson、Sentinel)就不要手写,自研方案在边界条件(续期、可重入、集群路由)上极易埋雷。