Redis 原理与使用场景:快是怎么来的,坑都藏在哪

面试题:讲一下 Redis 的原理。为什么快?缓存穿透、击穿、雪崩怎么处理?分布式锁用 Redis 怎么实现?

Redis 的题有个特点:名词特别多。单线程、RDB、AOF、跳表、LRU、哨兵、集群……背名词很容易,但面试官追问一句"单线程为什么反而快"或者"过期删除是准时的吗",就得看是不是真理解了。

这篇按两条线走:前一半讲原理——快是怎么来的、数据在内存里长什么样、持久化和过期淘汰分别在干什么;后一半讲场景——什么问题该交给 Redis,以及每个场景的坑。文中的机制描述以 Redis 主流版本(6.x/7.x)为准,没有虚构性能数字。

一、快是怎么来的

Redis 单线程处理命令,但吞吐能到十万级,靠的是四件事叠加:

第一,纯内存。 磁盘是慢的根源,Redis 的常规读写全程不碰盘(持久化是后台另说)。这是它和 MySQL 拉开数量级差距的根本原因,别的都是锦上添花。

第二,IO 多路复用。 一个线程用 epoll 同时盯几万个连接,谁就绪处理谁,没有线程切换和锁竞争的开销。注意"单线程"说的是命令执行——6.0 之后网络读写可以多线程,但命令执行仍然是串行的,所以一条慢命令(比如 keys *)照样阻塞所有请求。

第三,数据结构为内存定制。 不是拿 C 语言的 char* 和 malloc 硬上,每个类型都有专门的结构(下一节展开)。

第四,单线程带来的简化。 没有锁、没有竞态,所有操作天然原子——INCR 是原子的、SETNX 是原子的,这是后面分布式锁能成立的前提。

一句话:内存决定了快,单线程决定了简单,两者一起才撑起了 Redis 的生态位。 所以"为什么快"这道题只答单线程是不到位的——单线程是结果的一部分,不是原因的全部。

二、数据在内存里长什么样

命令是 STRING、HASH、LIST、SET、ZSET,底层是另一套结构,面试爱问的其实是底下那层:

对外类型 底层结构(简) 记住它干嘛
STRING SDS(简单动态字符串) 记长度,追加不重复扫;二进制安全
HASH / ZSET 小数据 listpack(7.0 起,替代 ziplist) 小数据压成一块连续内存,省指针
HASH / SET 大数据 dict(哈希表) 渐进式 rehash,扩容不卡顿
ZSET 大数据 dict + 跳表 dict 管"按成员查分",跳表管"按分数排序"
LIST quicklist(双端链表套 listpack) 两头进出都快

两个高频追问:

为什么 ZSET 用跳表不用红黑树? 按分数范围查询(ZRANGEBYSCORE)跳表天然支持,实现简单,还方便拿"排名"(ZRANK)。红黑树也能干,但范围查询要中序遍历,实现别扭。这是工程取舍题,不是算法优劣题。

渐进式 rehash 是干嘛的? 哈希表扩容时如果一口气把几十万个桶全搬过去,会卡一次主线程。Redis 的做法是扩容时新旧两张表并存,每次读写顺手搬一小批,后台定时再搬一点——把一次大开销摊成无数次小开销。这个思路和后面"分批删除大 key"是同一个哲学。

三、持久化:RDB 和 AOF 是两条思路

Redis 是内存库,进程一挂数据就没了,持久化是它"敢当数据库缓存"的底气。

RDB:定时把整个内存快照压缩落盘。文件小、恢复快;但两次快照之间的数据会丢,SAVE/BGSAVE 里的 BGSAVE 是 fork 子进程干活的——fork 的写时复制(copy-on-write)在大内存实例上本身就有成本。

AOF:把每条写命令追加进日志。最多丢 1 秒(appendfsync everysec);但日志越写越长,恢复时重放全量命令,很慢。所以有 AOF 重写:根据当前内存状态生成一份等效的最小命令集(一百次 INCR 变成一条 SET),重写期间的新写入写进缓冲区,最后补进去。

4.0 起的混合持久化:重写时,重放部分直接写成 RDB 格式,之后的增量用 AOF 命令接上。恢复快 + 丢得少,现在的默认推荐。

判断点:缓存场景可以只开 RDB 甚至全关(数据丢了回源就行);当存储用(分布式锁、计数器)就必须 AOF,且要掂量 everysec 那 1 秒的丢失窗口。丢 1 秒数据对"缓存"无感,对"计数"可能是事故。

四、过期和淘汰:两件经常被混为一谈的事

过期删除是"这条 key 到时间了怎么删";内存淘汰是"内存满了怎么腾地方"。

Redis 删过期 key 是懒的:不启定时器逐条删,而是访问时发现过期才删(惰性删除),配合后台周期性抽样清理(定期删除)。所以"设置了 TTL,到期那一刻 key 一定没了"是错的——它可能在没人访问又没被抽到时多活一会儿。这通常无所谓,但如果你拿"过期即事件"做设计,就会踩坑。

内存淘汰是 maxmemory 满了之后的策略,8 种,记三个就够:

  • noeviction:写报错,默认。缓存场景一般别用它。
  • allkeys-lru / allkeys-lfu:全键里挑最久没用的 / 最不常用的踢——缓存场景的常见选择。
  • volatile-lru:只在设了 TTL 的键里踢——同一个实例里"缓存"和"不能丢的数据"混放时用。

注意 LRU 是近似的:真 LRU 要全局维护链表,开销大,Redis 是抽样一小撮里挑最旧的踢。够用,但别在面试里说成精确 LRU。

五、场景:什么问题交给 Redis

场景 用法 关键点 / 坑
热点缓存 读:先查缓存,miss 回源再回填;写:更新库 + 删缓存 删缓存别在删库前;见下面"一致性"
计数器 / 限流 INCR + EXPIRE 原子性靠单线程;INCR 和 EXPIRE 不是一条命令,要么 Lua,要么接受首次不加 TTL 的边界
排行榜 ZSET + ZINCRBY / ZRANGE 并列排名要自己处理;大排行注意分页 LIMIT
会话 / token STRING 带 TTL 天然贴合"过期"语义
分布式锁 SET key val NX EX 必须带过期;解锁要校验是自己的锁(Lua 比较后删);锁过期但业务没跑完的续期问题看业务容忍度
简单队列 LIST 的 LPUSH/BRPOP,或 Stream LIST 无 ack,消费挂了消息就丢;要可靠就 Stream 或上 MQ
去重 / 标记 SET / BITMAP BITMAP 极省内存,活跃用户这类统计的利器

缓存三连问:穿透、击穿、雪崩

穿透:拿一个具体例子拆开

查 id=100 的商品:先查 Redis 没有(miss),查库也没有(商品根本不存在),返回 404。这本身没问题,问题在下一次——用户又查 id=100,Redis 里还是没有(上次压根没往里放东西),又去查库,又是 404。第三次、第四次……每次都空手打到数据库。

正常缓存的工作前提是"第一次 miss,回源之后填进缓存,后面就打缓存了"。但**"不存在的数据"没法填**——你往 Redis 里写什么呢?什么都没写,所以每一次都 miss。攻击面就在这:有人拿一万个不存在的 id 轮着打,每个 id 都 100% 绕过缓存砸到库。这就是"穿透"——不是缓存坏了,是这种请求天然穿过去。

解法一:把"不存在"本身当成结果缓存起来(空值 + 短 TTL)。

查 id=100 → Redis 放一个标记:id=100 → "不存在",TTL 60 秒
下次再查 id=100 → Redis 命中:"不存在" → 直接 404,不碰数据库

值虽然是空的,但这个 key 有缓存——miss 变成了 hit。TTL 设短,是因为万一这商品后来创建了,别让"不存在"的记录活太久。

解法二:布隆过滤器,在 Redis 之前再拦一道。

先把所有真实存在的商品 id 灌进布隆过滤器,请求来了先问它:

布隆说"不在" → 这个 id 肯定不存在 → 直接 404,Redis 和库都不碰
布隆说"在"   → 大概率存在 → 走正常缓存流程

差别在适用规模:空值缓存要为每个"被查过的假 id"各存一条,攻击者换着 id 打就存不过来;布隆过滤器只需要"全量真实 id"那份内存,不管造多少假 id 都能拦。一句话:穿透 = 不存在的数据永远填不进缓存,每次都砸库;解法就是把"不存在"也变成可缓存、可拦截的东西。

击穿:热点 key 过期的一瞬间

和穿透不同,这次是真实存在的爆款商品,id=1:

Redis:id=1 → 商品数据,TTL 10 分钟
每秒 5000 个请求 → 全部命中 Redis,数据库很清闲

第 10 分钟一到,key 过期被删。下一秒的 5000 个请求同时 miss,全部去查数据库,同一个商品查了 5000 遍——这查询可能是关联好几张表的慢 SQL,库瞬间被打死。等缓存回填上,又恢复正常。

和穿透的区别:穿透是"数据根本不存在",怎么都填不进缓存;击穿是"数据存在、也有缓存",只是过期的那个瞬间集体穿过。就疼这一下,但这一下可能把库打挂。

解法一:逻辑过期(不物理删)。

缓存里存两样:数据 + 一个"逻辑过期时间"。Redis 层面不设 TTL,key 永远存在。读到的人自己看逻辑时间:

没到逻辑过期时间 → 直接返回缓存
到了逻辑过期时间 → 返回旧数据(先用着),同时派一个后台线程查库刷新

精髓:永远有旧数据兜底,过期瞬间没有"真空期",库只被一个后台线程打一次。用"数据旧一小会儿"换"库不被打死"。

解法二:回源加互斥锁。

过期瞬间大家都 miss,去查库之前先抢锁:

5000 个请求都 miss
→ 抢锁:只有 1 个抢到,去查库、回填缓存、放锁
→ 其余 4999 个抢不到,原地等一会儿再查缓存
→ 醒来时缓存已填好 → 命中返回

库只被打一次,代价是那 4999 个请求多等几十毫秒。

一句话:击穿 = 热点 key 过期的一瞬间,流量集体砸库;解法要么让"过期"不产生真空(逻辑过期 + 旧数据兜底),要么让回源排队(互斥锁,只放一个人进库)。

雪崩:一大批 key 同一时间出事

击穿是"一个"热点 key,雪崩是"一片"。最典型的成因是自己埋的:批量预热缓存时给几千个 key 设了一样的 TTL,到点集体过期,本来分散的流量同一秒全部砸向库——单看每个 key 都不热,合起来就是海啸。另一种成因更直接:Redis 实例本身挂了,所有 key 等于同时没了。

解法对着两种成因来:

TTL 加随机抖动        → 如 10 分钟 + rand(0~3 分钟),过期时间错开,不再集体到点
高可用(哨兵/集群)   → Redis 挂了能自动切,不至于全量打到库
熔断降级兜底          → 库扛不住时限流、返回兜底数据,宁可响应变慢变旧,别雪崩

一句话:雪崩 = 一大片 key 同时失效(或 Redis 整个没了),合起来的流量冲垮库;解法核心就一个词——别让它们"同时"。

三个词只差一个字,记法:穿透是"查不存在的",击穿是"一个热点的",雪崩是"一大片的"。

缓存一致性

最常被问的方案:先更新数据库,再删缓存(cache aside)。为什么是"删"不是"更新"——更新缓存的值可能是算出来的,并发下两次写会乱序;删了让下次读自己去回填,简单且多数场景正确。

但它也有边界:读多写少时基本稳;写频繁 + 读的时候刚好回填旧值,仍有小概率不一致。要求更强就上延迟双删或订阅 binlog 删缓存——代价换正确性,别无脑上。

Redis 和数据库配合的常见场景

上面三连问和一致性说的都是同一种配合(cache aside 挡读流量),实际项目里两个的配合模式不止这一种:

模式 怎么配合 典型场景 注意
读缓存(cache aside) 读走 Redis,miss 回源回填;写更库删缓存 商品详情、用户资料这类读多写少 一致性窗口 + 三连问防护
写缓冲(削峰) 写先进 Redis LIST/Stream 攒着,异步批量落库 秒杀扣库存、点赞计数这类高频小写 落库挂了要能重放;Redis 本身要持久化兜底
计数器分家 热点计数只在 Redis INCR,定时任务同步落库做报表 阅读量、播放量 先想清楚容不容忍计数丢一段(Redis 挂了)
延迟双删 更库 → 删缓存 → 延迟几百毫秒再删一次 读写都频繁、又要求较新 第二次删的延迟拍脑袋不严谨,本质是兜底
binlog 订阅同步 只写库,订阅 binlog(Canal 这类)删/更新缓存 大系统、多缓存消费方 架构重,小团队别为它上中间件
互斥回源 miss 时抢锁,一个人查库 配合读缓存用,防击穿 锁本身别成为新瓶颈

选的模式就一个问题:这个数据,读多还是写多,一致性要多强。 读多写少 → cache aside;写多且可容忍延迟 → 写缓冲;读写都多又要求新 → 延迟双删或 binlog,代价一级级涨。别跳级上方案。

六、大 key 和热 key:运维侧的两大坑

大 key:value 特别大(如百万成员的 SET)。删除时阻塞主线程(新版有 UNLINK 异步删)、集群下数据倾斜、网卡被打满。预防:拆分(大 hash 拆成 N 个小 hash)、控制成员数。发现:redis-cli --bigkeys 或 RDB 离线分析。

热 key:某个 key 承担了不成比例的流量,单分片 CPU 打满。解法:本地缓存挡一层、key 加随机后缀拆多份轮询、集群里把热 key 复制多份。发现:--hotkeys 或监控单分片 QPS。

这两个坑的共同点:功能上线前一切正常,流量上来才炸——所以属于"面试讲了加分、没踩过也该知道"的部分。

七、面试里可以这样回答

Redis 快,根本原因是纯内存;单线程加 IO 多路复用是放大器——命令执行串行,没有锁竞争,所有操作原子。但代价是一条慢命令会阻塞全部请求,所以生产上禁 keys *,用 scan。

数据结构上它不是拿通用结构硬上,SDS、跳表、listpack 都是为内存和访问模式定制的。ZSET 用跳表不用红黑树,是范围查询和实现复杂度的取舍。持久化上我按用途选:纯缓存可以只开 RDB,当存储用就得 AOF,混合持久化是现在的默认。

场景上我主要用它做缓存、分布式锁和计数。缓存要防三件事——穿透、击穿、雪崩,分别对应空值缓存、互斥回源、TTL 加抖动;一致性用先更库再删缓存,知道它在读写并发的极端时序下仍有小概率不一致。锁要带过期、解锁要校验持有者,Lua 里做。

然后我会补一个实际项目的例子:当时缓存了什么、TTL 怎么定的、出过什么问题。Redis 这类题,"你踩过什么坑"永远比"你背了多少机制"可信。

真正要准备的不是机制清单,而是一个"我当时为什么用 Redis、后来有没有后悔"的故事。

不积跬步,无以至千里;不积小流,无以成江海。
荀子《劝学》
← 返回知识库