Redis 企业级场景实战:排行榜、限流、布隆过滤器,一次写全
作者:鱼宵 | 实战驱动系列 · 第 6 篇
完整课程与可运行源码已开源在 Gitee:gitee.com/j67mk2/redi… (10 课实战教程,本课源码在 lesson-09/)
一、真实场景:面试官最爱问的"你用 Redis 做过什么"
背完八股,面试官一句"说说你用 Redis 做过哪些业务场景"就能把你问住。如果只答"存缓存",等于白学。
生产里最常考的 5 个场景,这 5 个都实现了,简历才立得住:
| 场景 | 用的结构 | 一句话 |
|---|---|---|
| 排行榜 | ZSet | 天生按分数排好序 |
| 接口限流 | Lua + String | 原子计数,防击穿 |
| 附近的人 | GEO(底层 ZSet) | 经纬度编成整数 |
| 消息队列 | Stream / List | 有确认机制 vs 拿了就没了 |
| 防缓存穿透 | 布隆过滤器 | 筛子,说你没有就一定没有 |
这篇把每个场景的原理 + 代码 + 本机实测输出一次给全,跑一遍代码你就真会了。
二、排行榜:ZSet 天生就是干这个的
ZSet(有序集合)里每个 member 带一个 score,Redis 内部用跳表维护顺序。查 TopN、查自己排第几、加分,全是 O(logN)。
类比:学校门口那张红榜,从上到下按分数排好了。想知道"前三名是谁",眼睛从上往下扫三行就行;想知道"我排第几",找到自己名字那行就是。
为什么不用 MySQL order by score limit n?大表上这个查询要全表扫+排序,Redis 是 O(logN),量级不同。
代码就 4 句:
jedis.zadd("rank:game", 100, "玩家A"); // 加玩家,分数 100
jedis.zincrby("rank:game", 50, "玩家B"); // 玩家B 加 50 分
List<String> top3 = jedis.zrevrange("rank:game", 0, 2); // 高到低取 1~3 名
Long rankB = jedis.zrevrank("rank:game", "玩家B"); // 玩家B 排第几
实测输出(本机真实运行):
实时 Top3 = [玩家D, 玩家B, 玩家E]
玩家B 当前排名 = 第 2 名
玩家 D 400 分第 1,玩家 B 原来 250 又加 50 = 300 分第 2,玩家 E 199 第 3——加分明、排名对。
降级策略(面试加分点):Redis 榜单挂了,兜底从 MySQL 查历史榜单顶上,别让前端白屏。
三、限流:为什么必须用 Lua
需求:1 秒内同一个用户最多访问 3 次,超过拒绝。
最直觉的写法:
INCR rate:u1001 # 计数 +1
if 计数 == 1 then EXPIRE rate:u1001 1 # 第一次访问,设 1 秒过期
坑在哪?**如果 INCR 完、EXPIRE 之前进程崩了,这个 key 永远不过期,这个用户被永久限流。**两条命令不是原子的——中间能插进任何东西。
Lua 脚本在 Redis 里是原子执行的:脚本里的命令要么全做完、要么全不做,中间不会插别的命令。所以限流必须用 Lua:
String lua = ""
+ "local current = redis.call('INCR', KEYS[1]) " // 计数+1
+ "if current == 1 then "
+ " redis.call('EXPIRE', KEYS[1], ARGV[2]) " // 第一次才设过期(别每次刷TTL)
+ "end "
+ "if current > tonumber(ARGV[1]) then return 0 " // 超了,拒绝
+ "else return 1 end"; // 没超,放行
// KEYS[1]=限流key, ARGV[1]=最大次数3, ARGV[2]=窗口1秒
Object r = jedis.eval(lua, 1, "rate:u1001", "3", "1");
实测输出:
第 1 次请求 => 通过
第 2 次请求 => 通过
第 3 次请求 => 通过
第 4 次请求 => 拒绝
第 5 次请求 => 拒绝
窗口重置后第 1 次请求 => 通过
1 秒内前 3 次放行,第 4、5 次被拒;等 1.1 秒窗口过了又放行。节奏完全符合预期。
三种限流算法了解下(面试常问):
| 算法 | 原理 | 缺点 |
|---|---|---|
| 固定窗口 | 1 秒一个桶,INCR+EXPIRE | 窗口临界突发(第 1 秒末 3 次+第 2 秒初 3 次=瞬间 6 次) |
| 滑动窗口 | ZSet 存时间戳,删窗口外再数 | 平滑,但费内存 |
| 令牌桶 | 按速率放令牌,有就放行 | 允许一定突发,最常用 |
四、附近的人:GEO 底层就是 ZSet
"附近的人"用 GEO 命令。它的底层其实就是 ZSet:Redis 把经纬度用 geohash 算法编成一个 52 位的整数,当成 score 存进 ZSet,再用 ZSet 的范围查找找"附近"。
类比:把地球切成一个个小格子,每个格子一个编号(geohash)。两个人编号前缀一样,就说明住得近。
命令就三个:GEOADD(加坐标)、GEORADIUS(找半径内)、GEODIST(算距离)。Java 侧:
jedis.geoadd("geo:city", 113.6253, 34.7466, "二七广场");
List<GeoRadiusResponse> near = jedis.georadius("geo:city", 113.6253, 34.7466, 5, GeoUnit.KM);
Double dist = jedis.geodist("geo:city", "二七广场", "郑州东站", GeoUnit.KM);
实测输出:
二七广场 5km 内的点 = [二七广场, 紫荆山公园]
二七广场 到 郑州东站 距离 ≈ 5.0129 km
注意细节:郑州东站距离 5.0129 km 刚好超出 5km 圈,所以没出现在上面的列表里——距离和圈半径对得上,逻辑自洽。
五、消息队列:Stream vs List
Redis 能当消息队列用吗?能,两种做法差别很大:
| 对比项 | List(LPUSH + BRPOP) | Stream(XADD + XREADGROUP) |
|---|---|---|
| 模型 | 简单队列,一头进一头出 | 追加日志 + 消费组 |
| 消费者组 | 没有,只能大家抢 | 原生支持,多组独立消费 |
| 消息确认 | 没有,BRPOP 拿到就算删了 | 有 ACK,没 ACK 的能捞回重投 |
| 历史消息 | 看完就没 | 可以按 ID 回溯 |
类比:List 像自动售货机,货拿出来就掉了;Stream 像监控录像,谁来看、看到哪了、确认没确认,都有记录。
Stream 消费组实测(XADD 两条 → 建组 → 消费者 c1 一次拉到 2 条):
1790927520914-0 <- XADD apple 分配的消息 ID
1790927520914-1 <- XADD banana 分配的消息 ID
OK <- XGROUP CREATE 成功
item / apple / qty / 3 <- c1 拉到的第一条
item / banana / qty / 5 <- c1 拉到的第二条
之后 XACK 确认已处理,XPENDING 查待确认就是 0 条——失败重试的玩法就建立在 ACK 机制上。
六、布隆过滤器:筛子
缓存穿透:数据库里根本没有这个 key,每次请求都打到 Redis miss、再打到 MySQL,有人恶意刷就能把 MySQL 打挂。
布隆过滤器:在 Redis 前面再放一个"已存在的 id 名单"。请求来了先问它:
- 它说不在 → 一定不在,直接拒绝,不碰 Redis/MySQL;
- 它说在 → 可能在(也可能 hash 撞了),再去查 Redis。
原理(大白话):一个超大位图,初始全 0。来一个元素,用 k 个 hash 函数算出 k 个位置,全改成 1。判断时 k 个位置只要有一个是 0,就一定不存在;全是 1,才"可能存在"。
类比:一个筛子,孔很大。沙子(不存在的元素)从孔里漏下去;小石子(存在的元素)被挡住——但也可能是别的石子被挡在这儿误放进来。
口头禅:说你没有,你一定没有;说你有,你可能被冤枉(误判)。
手写版就两个操作(bitSize≈1MB,7 个 hash 位置):
// 添加:算 7 个位置全 SETBIT 为 1
jedis.setbit("bloom:user", pos, true);
// 判断:7 个位置只要有一个是 0 → 一定不存在
if (!jedis.getbit("bloom:user", pos)) return false;
return true; // 7 个全是 1 → 可能存在
实测输出:
alice 是否存在? => true
bob 是否存在? => true
u_ghost 是否存在? => false
u_hacker 是否存在? => false
alice/bob 在名单里返回 true;ghost/hacker 不在返回 false(这次没误判,因为只塞了 5 个元素、位图又大)。生产用 Redisson 的 RBloomFilter(MurmurHash + 算好的位数),误判率可控。
缓存三兄弟顺带背了:穿透=查不存在的数据(布隆挡);击穿=热点 key 过期瞬间被打爆(互斥锁);雪崩=大量 key 同时过期(加随机过期时间)。
七、挑战题(答案都在仓库里,跑起来才知道)
- ⭐ 把限流 Lua 改成"令牌桶":桶容量 5,每秒补 1 个令牌,跑 10 次请求看通过/拒绝的规律。
- ⭐⭐ 往布隆过滤器里塞 1 万个元素,再随机问 1 万个不存在的 id,把 bitSize 调小到 2^20,看看误判率怎么变化。
- ⭐⭐ 用
XREADGROUP+XACK写一个"失败重试"demo:模拟一条消息处理失败不 ACK,30 秒后用XPENDING+XCLAIM捞回来重投。
八、面试回答模板(背下来)
面试官:排行榜怎么做?
ZSet 有序集合,跳表结构,TopN/加分/查名次都是 O(logN);MySQL order by limit 大表上很慢。降级:榜单挂了兜底查 MySQL 历史榜单。
追问:限流为什么用 Lua?
INCR + EXPIRE 两条命令不原子,中间宕机会导致 key 永不过期、用户被永久限流;Lua 脚本在 Redis 里原子执行,要么全做要么全不做。
追问:布隆过滤器误判方向?
"一定不存在"是准的;"可能存在"会误判。解决缓存穿透。生产用 Redisson RBloomFilter 控制误判率。
九、总结
| 场景 | 核心结构 | 面试一句话 |
|---|---|---|
| 排行榜 | ZSet + 跳表 | TopN/名次 O(logN) |
| 限流 | Lua 原子脚本 | 防 INCR+EXPIRE 不原子 |
| 附近的人 | GEO = ZSet + geohash | 经纬度编 52 位整数 |
| 消息队列 | Stream | 有 ACK 能重投,List 拿了就没了 |
| 防穿透 | 布隆过滤器 | 说没有一定没有,说有可能冤枉 |
记住这条:这 5 个场景就是"Redis 不只是缓存"的最好证据——排行榜、限流、附近的人、消息队列、防穿透,全是生产刚需。
关于这个系列
本文是「Java 后端实战精通营」系列第 6 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Redis 实战精通营(10 课):gitee.com/j67mk2/redi…
- 本文对应源码位置:
lesson-09/(4 个场景 demo 串在一个 Main 里,挑战题答案也在) - 系列已发布:《分布式锁》《缓存一致性》《事务与 Lua》《Redis 高可用》《Redis 为什么快》
- 后续系列(陆续发布):Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch
下一篇预告:《Redis 入门:3 步把它跑起来》——系列补发基础篇(入门/数据结构/持久化/缓存三兄弟),零基础也能跟上;最后以《Redis 面试冲刺 30 问》收官。
跑完上面任何一步遇到问题,把终端输出发评论区,一起排查。