前言
最近跑了几场 Java 后端实习面试,发现一个规律:Redis 是面试官最爱顺着问的技术栈,几乎每一面都会被问到,而且不是问一两个点就停——数据结构和底层实现来回切,项目用法和八股原理交叉问。
这篇文章把我被问到的 Redis 高频题整理出来,不是单纯罗列八股,而是每个知识点都绑一个具体的项目使用场景——面试官问你怎么用的,你能从场景讲到原理再讲回代码。
读完你会收获:
- Redis 面试被问的五个核心方向,每个方向怎么结构化回答
- 项目里的秒杀、缓存、Feed 流、GEO 分别对应哪些八股考点
- 几个面试官最爱追问的"对比题"(Redisson vs SETNX、Stream vs RabbitMQ、推模式 vs 拉模式)怎么答出深度
- 从第一个问题一路扛到第 6 层追问的完整话术思路
一、Redis 是什么?别只背"内存数据库"
面试官问:讲讲你对 Redis 的理解。
别这样答:“Redis 是一个键值对内存数据库,读写速度快,一般用来做缓存。” 这个回答面试官听了一百遍了。
建议这样分层讲:
第一层,数据结构。Redis 不只是 KV——String、List、Hash、Set、ZSet 五种基本类型之外,还有 Bitmap、GEO、HyperLogLog、Stream 这些扩展类型。每种数据结构背后都有对应的业务场景。
第二层,性能。单线程 + epoll IO 多路复用 + 内存操作。单线程不是说只能处理一个连接——epoll 让它在同一时刻能监听成千上万个 socket,只是执行命令的时候是串行的。这就是为什么 Lua 脚本能保证原子性——执行期间不会穿插其他命令。
第三层,工程角色。Redis 在实际项目里承担的角色比缓存多得多。我在 O2O 项目里用到了:
| 场景 | 用的 Redis 什么 | 效果 |
|---|---|---|
| 秒杀库存扣减 | Lua 脚本 | 原子校验+扣减+投递,1000+ QPS 零超卖 |
| 秒杀削峰 | Stream + Consumer Group | 异步落库,消费失败 Pending 重试 |
| 订单超时释放 | Redisson 延迟队列 | 15 分钟未支付自动取消回补库存 |
| Feed 流推送 | ZSet 收件箱 | 时间游标分页,毫秒级拉取 |
| 附近商户 | GEO + GEOSEARCH | 5km 半径毫秒级返回 |
| 限流防刷 | ZSet 滑动窗口 | 四维度限流 |
| 分布式锁 | Redisson + WatchDog | DB 层二次去重 |
💡 面试技巧:这样回答,面试官会觉得你不是在背八股,是真的用 Redis 解决过实际问题。而且你主动抛出了 Lua、Stream、Redisson、GEO 这些关键词——面试官大概率会顺着其中一个往下问,而你已经准备好了。这就是好的自我介绍/项目介绍的核心逻辑:你不是在回答问题,你是在引导面试官往你准备好的方向问。
二、数据结构底层——面试官最爱追问的一集
面试官问:Redis 的 ZSet 底层是什么?
你要答:
ZSet 底层是skiplist(跳表)+ dict(哈希表)的组合。dict 用来 O(1) 按 member 查 score,skiplist 用来 O(log n) 做范围查询和排序。
跳表原理(面试高频)
跳表就是多层有序链表。最底层是一个完整的有序双向链表,每往上一层节点数约减半——通过随机函数决定一个节点是否"晋升"到上一层。
查找的时候从最高层开始,类似二分查找:下一个节点 score 大于目标就降一层,直到找到或者确认不存在。
为什么用跳表不用红黑树?
- 跳表实现比红黑树简单——旋转和变色真的容易写出 bug
- 跳表天然支持范围查询:底层是双向链表,找到起点后顺着往下扫就行
- 红黑树范围查询需要中序遍历,麻烦一些
其他数据结构的底层简表
| 数据结构 | 底层实现 |
|---|---|
| String | int / embstr(≤44字节)/ raw |
| List | quicklist(linkedlist + ziplist 混合) |
| Hash | listpack(7.0+)/ ziplist → 数据多转 hashtable |
| Set | intset(全整数+少元素)→ 转 hashtable |
| ZSet | listpack(7.0+)→ 转 skiplist + dict |
面试官追问:跳表和 B+ Tree 的区别?
跳表是 Redis 用的(内存、随机访问快),B+ Tree 是 MySQL 用的(磁盘 IO、顺序访问优化)。B+ Tree 非叶子节点不存数据,树更矮,磁盘 IO 更少。跳表节点随机分布,适合内存场景。两者都是 O(log n),但优化的方向不同——B+ Tree 优化磁盘寻道,跳表优化内存查找。
三、缓存三防——面试必考题,搞错顺序很致命
面试官问:缓存穿透、击穿、雪崩分别是什么?怎么解决?
第一步:先把概念说清楚
| 问题 | 现象 | 根因 |
|---|---|---|
| 穿透 | 查一个根本不存在的数据,缓存里没有,每次都打到 DB | 恶意攻击 / 查不存在的 Key |
| 击穿 | 一个热点 Key 过期,瞬间大量请求同时打到 DB | 热点 Key 过期 + 高并发 |
| 雪崩 | 大量 Key 同时过期 / Redis 挂了,DB 瞬间被打爆 | 缓存集体失效 |
第二步:每个问题对应的解决方案
防穿透:
// 方案一:布隆过滤器 —— 先问"这个 Key 可能存在吗"if(!bloomFilter.mightExist(key)){returnnull;// 一定不存在,直接返回,不用查 DB}// 方案二:空值缓存 —— 查了 DB 发现不存在,也缓存一个短 TTL 的空值if(data==null){redis.set(key,"",2,TimeUnit.MINUTES);// 2分钟后过期returnnull;}防击穿(最爱追问:请求合并 vs 互斥锁):
// 请求合并:只放一个请求去查 DB,其余的等结果共享privateConcurrentHashMap<String,CompletableFuture<String>>flyMap=newConcurrentHashMap<>();publicStringget(Stringkey){Stringcached=redis.get(key);if(cached!=null)returncached;// 同一个 key 只有一个 "飞行中" 的请求CompletableFuture<String>future=flyMap.computeIfAbsent(key,k->CompletableFuture.supplyAsync(()->{StringdbData=db.query(key);redis.set(key,dbData,30,TimeUnit.MINUTES);returndbData;}).whenComplete((r,e)->flyMap.remove(key)));returnfuture.get(3,TimeUnit.SECONDS);// 所有等待者共享同一个 Future}请求合并 vs SETNX 的本质区别:
- SETNX 互斥锁:排队执行——拿到锁的去查 DB,其他人等锁释放后自己去读缓存
- 请求合并:结果共享——拿到锁的去查 DB,其他人直接拿同一个结果,不用自己再查
这个区别面试官特别爱追问,因为它能看出你是真的理解了还是只是背了方案名。关键点在于是"各自重建"还是"共享结果"。
防雪崩:
// TTL 加随机抖动,避免集体过期intttl=30*60+ThreadLocalRandom.current().nextInt(300);// 30分钟 + 0~5分钟随机redis.set(key,value,ttl,TimeUnit.SECONDS);// 逻辑过期:value 里带一个过期时间戳// 过期后异步重建,读请求先返回旧值——不阻塞用户四、持久化——RDB vs AOF,别只说"两个都开"
面试官问:Redis 怎么保证数据不丢?
你要答:
两套机制,各有侧重:
| RDB | AOF | |
|---|---|---|
| 原理 | 快照:某个时间点全量内存数据写磁盘 | 日志:每条写命令追加到文件 |
| 文件大小 | 小(压缩二进制) | 大(文本命令) |
| 恢复速度 | 快(直接加载二进制) | 慢(逐条重放) |
| 数据安全 | 可能丢最后一次快照后的数据 | everysec最多丢 1 秒 |
| fork 影响 | fork 子进程瞬间阻塞(页表复制) | 持续 IO,轻微影响 |
生产推荐:Redis 4.0+ 用混合持久化——AOF 文件前半段是 RDB 快照,后半段是增量 AOF。兼顾恢复速度和数据安全。
面试官追问:RDB fork 子进程为什么有瞬间阻塞?
fork()系统调用会复制父进程的页表(不是复制整个内存——写时复制)。10GB 的 Redis 进程,页表可能有几十 MB,复制需要时间。期间主进程阻塞。所以bgsave虽然叫"后台保存",但不是完全没有影响。
五、过期删除和内存淘汰——别搞混了
面试官问:Redis 的 Key 过期了怎么删?内存满了怎么办?
这是两件事,千万别搞混:
过期删除策略(Key 设置了 TTL,到期了怎么删)
- 惰性删除:访问 Key 的时候顺便检查有没有过期 → 过期了删掉。优点是对 CPU 友好,缺点是有过期 Key 一直没人访问就占着内存。
- 定期删除:每 100ms 随机抽一批 Key 检查,过期就删,每次执行不超过 25ms。
- 两者结合:定期清理大部分,惰性兜底。
内存淘汰策略(内存满了,新写入怎么办,共 8 种)
| 策略 | 行为 |
|---|---|
noeviction | 不淘汰,写入直接报错(默认策略) |
allkeys-lru | 所有 Key 中淘汰最久未使用的 |
allkeys-lfu(4.0+) | 所有 Key 中淘汰最少使用频率的 |
allkeys-random | 所有 Key 中随机淘汰 |
volatile-lru | 只在设了过期时间的 Key 中淘汰最久未使用的 |
volatile-lfu(4.0+) | 只在设了过期时间的 Key 中淘汰最少使用的 |
volatile-random | 只在设了过期时间的 Key 中随机淘汰 |
volatile-ttl | 淘汰 TTL 最短(最早过期)的 |
LRU vs LFU:LRU 看"最近一次什么时候用的"——适合热点有明显时间特征的场景。LFU 看"一共用了多少次"——适合有些数据虽然最近用过但整体不频繁的场景。
一个易错点:Redis 的 LRU 是近似算法,随机抽 5 个 Key 淘汰其中最不合适的,不是全局遍历。面试官问到这一层,你说出"近似"两个字就已经领先了。
六、秒杀场景的 Redis 深度用法——面试官顺着问的根本停不下来
这一节是我被问得最多的。O2O 项目里秒杀链路重度依赖 Redis,面试官从"Lua 为什么原子性"一路问到"Redis 和 MySQL 没有事务怎么兜底"。
—
整体链路
用户请求 │ ▼ ┌─ Redis Lua 脚本 ──────────────────┐ │ ① 校验库存(GET stock) │ │ ② 一人一单去重(SISMEMBER) │ │ ③ 库存不足 → 候补入队(ZADD) │ │ ④ 扣库存(DECR) │ │ ⑤ 标记已购(SADD) │ │ ⑥ 投递订单消息(XADD Stream) │ │ 全部原子执行,不可打断 │ └────────────────────────────────────┘ │ ▼ Redis Stream + Consumer Group → 异步落库 │ ▼ Redisson 分布式锁 → DB 层二次去重 │ ▼ MySQL 条件更新 → 乐观锁兜底Lua 脚本核心逻辑
-- KEYS[1]: 库存 key KEYS[2]: 已购集合 KEYS[3]: 候补队列 KEYS[4]: Stream-- ARGV[1]: 用户ID ARGV[2]: 订单ID ARGV[3]: 秒杀券ID-- 1. 校验库存localstock=tonumber(redis.call('GET',KEYS[1]))ifstock==nilorstock<=0thenredis.call('ZADD',KEYS[3],redis.call('TIME')[1],ARGV[1]..':'..ARGV[2])return{-1,'no_stock_and_queued'}end-- 2. 一人一单ifredis.call('SISMEMBER',KEYS[2],ARGV[1])==1thenreturn{0,'already_bought'}end-- 3. 扣库存 + 标记 + 投递redis.call('DECR',KEYS[1])redis.call('SADD',KEYS[2],ARGV[1])redis.call('XADD',KEYS[4],'*','userId',ARGV[1],'orderId',ARGV[2])return{1,'success'}几个关键设计点
- 校验放在最前面:不可逆操作(扣库存、标记已购)放最后,脚本跑到一半 Redis 挂了也不会有脏数据
- DECR 一条命令完成扣减:不需要先 GET 再 SET,本身就是原子的
- Stream 投递在脚本内:保证"扣库存成功 = 消息一定投递了"
面试官追问:Redis 扣了库存但 MySQL 写失败了怎么办?
四层兜底:
- Lua 脚本先把关:前置拦截,校验不通过根本不扣
- Redisson 分布式锁做 DB 层二次去重:同一用户同一券只放一个进 DB
- MySQL 条件更新兜底:
UPDATE ... SET stock = stock - 1 WHERE stock > 0,affected_rows=0 就不扣 - 定时对账:异步比对 Redis 和 MySQL 库存差异,回补
为什么不用 Seata 分布式事务?秒杀场景 QPS 决定了不能用强一致性方案。Seata AT 模式的全局锁会成为瓶颈。而且秒杀业务允许"少卖"(Redis 多扣了回补),不允许"超卖"——库存方向一致性要求是单向的,最终一致就够了。
七、分布式锁——Redisson vs SETNX
面试官问:为什么用 Redisson 而不是自己用 SETNX 实现?
| SETNX 手写 | Redisson | |
|---|---|---|
| 锁过期 | 手动设 TTL,业务跑久了锁自动释放 | WatchDog 自动续期(每 10 秒续到 30 秒) |
| 解锁安全 | 直接 DEL 可能误删别人的锁 | Lua 脚本校验持有者再删 |
| 可重入 | 要自己实现计数器 | 内置,同一线程可多次获取 |
| 其他能力 | 无 | 读写锁、信号量、闭锁全套 JUC 等价实现 |
核心区别是 WatchDog:拿到锁后启动后台定时任务自动续期,只要 JVM 不挂锁就不过期。SETNX 设 10 秒 TTL——万一 GC 停顿或业务慢了一点,锁在第 10 秒自动释放,另一个请求拿到锁,并发问题就来了。
面试官追问:Redisson 延迟队列底层用什么实现的?
Redis 的 PUB/SUB + ZSet 混合方案。ZSet 做延迟排序(score 存到期时间戳)→ 定时轮询 ZSet 扫描到期元素 → 通过 PUB/SUB 通知订阅者。延迟精度秒级,适合订单超时释放(15 分钟级)这种场景。要毫秒级精度得用 RabbitMQ 延迟交换机。
八、Redis Stream vs RabbitMQ——项目中为什么两个都用?
面试官问:同一套系统里同时用了 Redis Stream 和 RabbitMQ——为什么不统一?
| Redis Stream | RabbitMQ | |
|---|---|---|
| 项目里的角色 | 秒杀订单削峰(轻量) | 跨服务业务消息(可靠) |
| 可靠性 | 依赖 RDB/AOF 持久化 | ACK + 磁盘持久化 + 死信队列 |
| 延迟消息 | 不原生支持 | 延迟交换机插件 |
| 额外组件 | 零额外(已有 Redis) | 需要 RabbitMQ 服务 |
| 路由灵活性 | Topic 级别 | Exchange(Direct/Topic/Fanout) |
秒杀走 Stream 的原因:秒杀链路已经重度依赖 Redis——库存、去重、候补全在 Redis 里完成。用 Stream 把消息投递也放在同一个 Lua 脚本里,"扣库存 + 发消息"原子完成,不引入额外中间件。
跨服务消息走 RabbitMQ 的原因:订单通知、积分发放这些消息每一条都很重要、不能丢。RabbitMQ 的 ACK + 死信队列 + 延迟交换机组合比 Redis Stream 更适合做"业务消息总线"。
如果只能选一个:选 RabbitMQ。它能同时覆盖秒杀订单消费和跨服务消息两个场景。Redis Stream 更多是为了展示"知道什么时候该轻量、什么时候该引入中间件"的判断力——这恰恰是面试官想听到的。
九、Feed 流推送——ZSet 收件箱 + 时间游标分页
面试官问:Feed 流怎么实现的?
推模式(我项目里用的)
用户发动态 → 把动态 ID 写入每个粉丝的 Redis ZSet 收件箱。粉丝打开页面 → 直接从自己的收件箱按时间倒序拉取。
// 发布动态(写扩散)longscore=System.currentTimeMillis();for(LongfollowerId:followerIds){redis.opsForZSet().add("feed:inbox:"+followerId,blogId.toString(),score);// 每个收件箱最多保留 1000 条redis.opsForZSet().removeRange("feed:inbox:"+followerId,0,-1001);}// 读取(时间游标分页,替代 offset)Set<ZSetOperations.TypedTuple<String>>result=redis.opsForZSet().reverseRangeByScoreWithScores("feed:inbox:"+userId,0,cursor-1,// cursor = 上一页最后一条的 score0,size);游标分页 vs offset 分页
offset 在两个请求之间如果有新数据插入,翻页会出现重复。游标分页用 score 定位——“给我 score 小于上一页最后一条时间戳的前 10 条”,天然对增量免疫。
推模式 vs 拉模式
- 推:发动态时扩散到粉丝收件箱,读的时候 O(1)。适合粉丝少的场景。
- 拉:发动态只写一条,读的时候聚合关注列表。适合大 V 场景。
- 大 V 方案:推拉结合——普通用户推,大 V 拉(微博/推特的经典架构)。
十、GEO 附近商户——底层是个 ZSet
面试官问:Redis GEO 底层是什么?
就是 ZSet。Redis 帮你做了经纬度 → GeoHash → Score 的转换。
GeoHash 编码原理
把二维经纬度编码成一维字符串。对经度 [-180, 180] 和纬度 [-90, 90] 交替做二分逼近,每次二分确定一个 bit,然后按 Base32 编码成字符。
以南昌红谷滩(115.85°E, 28.70°N)为例:
- 经度二分:115.85 在 [0, 180] 右半 → bit=1 → 区间缩到 [0, 180] → 继续在左半 → bit=0 → 区间缩到 [0, 90] → 继续在右半 → bit=1 → …
- 纬度二分:28.70 在 [-90, 90] 右半 → bit=1 → 区间缩到 [0, 90] → 继续在左半 → bit=0 → …
- 交替合并 bit,每 5 位转一个 Base32 字符 → 最终得到类似
wtc6v的字符串
关键性质:前缀越相同,位置越接近(但有边界效应——刚好在赤道两侧的两个点可能被漏掉,所以GEOSEARCH会同时搜周围 8 个格子)。
5km 毫秒级返回的原因
ZSet 的 score 范围查找是 O(log N + M),内存操作,没有磁盘 IO。
// GEOSEARCH:6.2+ 统一替代 GEORADIUSGeoResults<GeoLocation<String>>results=redis.opsForGeo().search("shop:geo",newGeoCoordinate(longitude,latitude),newDistance(5,DistanceUnit.KILOMETERS),GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().sortAscending().limit(20));十一、集群——主从、哨兵、Cluster,别再搞混了
面试官问:Redis 有哪几种集群模式?什么时候用哪种?
你要答:
Redis 的高可用演进路径是主从 → 哨兵 → Cluster,三个方案解决的是不同层次的问题。
| 模式 | 核心思路 | 解决的问题 | 局限 |
|---|---|---|---|
| 主从复制 | 一主多从,读写分离。从节点异步复制主节点数据。 | 读压力分担 + 数据冗余备份 | 主节点挂了要手动切换,无法自动故障转移 |
| 哨兵(Sentinel) | 在主从基础上加 Sentinel 进程集群(至少 3 个),监控主节点健康状态。主挂了自动选举新主。 | 自动故障转移(高可用) | 本质还是单主架构,所有写操作仍走一个节点,无法水平扩展写能力 |
| Cluster | 去中心化,数据按 slot 分片存储在多组主从节点上。每组负责一部分 slot。 | 水平扩展(海量数据 + 高并发写) | 不支持多 Key 跨 slot 操作(除非用 hash tag),客户端要支持重定向 |
Cluster 分片机制
Cluster 把整个 key 空间分为16384 个 slot(哈希槽):
slot = CRC16(key) % 16384每个 Master 节点负责一部分 slot(比如 3 个 Master 各负责约 5461 个 slot)。客户端可以请求任意节点——如果 key 不在当前节点的 slot 范围,节点返回MOVED重定向,客户端自动跳转到正确的节点。
为什么是 16384?这个数字是作者权衡的结果——足够大来均匀分布(每个节点能分到充足 slot 数),又足够小让节点间 gossip 协议的心跳包不携带过大的 slot 位图。16384 个 slot 的状态用 2KB 的位图就能表示。
CAP 取舍
Redis Cluster 在分布式场景下:
- P(分区容错)必须保证——网络分区是不可避免的
- 当半数以上 Master 挂了,整个集群拒绝服务——牺牲 A(可用性)保 C(一致性)
- 但单个 slot 的主从切换是秒级的(Sentinel 类似机制)——尽可能减少不可用窗口
面试常见误区:很多人以为 Cluster = 多主多从 + 自动切换 + 横向扩展一条龙。实际上 Cluster 的核心价值是数据分片——解决的是单机存不下、写不下的问题。如果数据量不大、QPS 不高,主从 + 哨兵就够了,上 Cluster 反而增加了运维复杂度和客户端适配成本。
总结
Redis 面试的核心就五个方向:
| 方向 | 必考点 |
|---|---|
| 数据结构 | ZSet 跳表、GeoHash 编码、Stream 消费模型 |
| 缓存三防 | 穿透/击穿/雪崩各怎么解决,请求合并 vs SETNX |
| 持久化 | RDB vs AOF vs 混合持久化,fork 阻塞原因 |
| 项目实战 | Lua 原子性、Redis 和 MySQL 一致性兜底、延迟队列、分布式锁 |
| 集群 | 主从/哨兵/Cluster 区别,Cluster slot 分片 |
面试中回答 Redis 问题的核心技巧:不要只背八股。面试官问"你用过 Redis 的什么数据结构",你回答"ZSet",这叫 10 秒的回答。你回答"我用 ZSet 做了 Feed 流收件箱,时间游标分页替代 offset 避免翻页重复,每个收件箱上限 1000 条",这就是 2 分钟的回答——面试官会顺着 Feed 流继续问,你就把自己准备好的东西全聊出来了。
记住三个"不是":
- Redis 不是只有缓存——它在项目里承担了 7 种不同角色
- 面试不是在考试——不要只背概念,每个知识点绑一个项目场景
- 深度不在第一问——面试官的套路是顺着一个点一路深挖,你要做的是在每一层都准备好"下一层的答案"
这篇文章整理了我在面试中被问到的 Redis 高频题。如果有帮助,欢迎评论区交流。