Redis 企业级场景实战:排行榜、限流、布隆过滤器,一次写全

0 阅读9分钟

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 同时过期(加随机过期时间)。


七、挑战题(答案都在仓库里,跑起来才知道)

  1. ⭐ 把限流 Lua 改成"令牌桶":桶容量 5,每秒补 1 个令牌,跑 10 次请求看通过/拒绝的规律。
  2. ⭐⭐ 往布隆过滤器里塞 1 万个元素,再随机问 1 万个不存在的 id,把 bitSize 调小到 2^20,看看误判率怎么变化。
  3. ⭐⭐ 用 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 问》收官。

跑完上面任何一步遇到问题,把终端输出发评论区,一起排查。