Redis 面试上篇围绕五块高频区域:快的原因与选型对比、单线程与多线程的表述边界、String 与 ZSet 的底层实现、过期与淘汰的内存治理、缓存之外的典型应用。每道题先给结论,再拆原理,最后留追问方向。

考察地图

  • 性能与选型:Redis 为什么这么快、与 Memcached 的区别。考察对内存存储、IO 模型与功能全景的理解。
  • 线程模型:单线程事件循环、6.0 引入多线程的动机。考察版本演进知识,以及「命令执行线程」与「网络 IO 线程」的表述精确度。
  • 数据类型:SDS 相对 C 字符串的改进、String 与 Hash 存对象的取舍、ZSet 跳表选型。考察底层实现功底,最容易拉开差距。
  • 内存管理:过期 key 删除策略、内存淘汰策略、大量 key 集中过期。考察生产治理经验。
  • 应用拓展:Redis 除了缓存还能做什么。考察技术广度与场景化表达。
  • 建议自行查阅:Redis Module 的作用、常见缓存读写策略、本地缓存与 Redis 的取舍、Set 实现抽奖、Bitmap 统计活跃用户、HyperLogLog 统计 UV、布隆过滤器判断海量元素——多为记忆或场景复述题,知道结论即可。

性能与选型

Redis 为什么这么快?

结论:纯内存操作是主因,再配合 IO 多路复用的单线程事件循环、高度优化的内部编码、极简的 RESP 协议,把单机 QPS 推到十万级。

原理:拆开是四点。一是数据全在内存,读写为纳秒级,与磁盘毫秒级相差数个数量级;二是单线程配合 epoll/kqueue 多路复用,一个线程监听海量连接,没有锁竞争与上下文切换;三是每种数据类型都有多套内部编码(listpack、quicklist、skiplist、intset 等),按数据规模动态切换,在空间与时间之间取平衡;四是 RESP 协议实现简单、二进制安全,解析开销极小。

延伸:既然如此快,为什么不直接拿 Redis 当主库?内存成本高,且 RDB 快照与 AOF 每秒刷盘都有明确的丢失窗口,给不了关系型数据库的可靠性承诺。

追问:既然瓶颈在内存与网络而非 CPU,为什么命令执行不多线程化?提示:要处理数据竞争与顺序一致性,会摧毁单线程模型的无锁红利——这正是 6.0 只把网络读写多线程化的原因。

Redis 和 Memcached 有什么区别?

结论:两者都是基于内存、支持过期策略的高性能 KV 缓存;但 Redis 在数据类型、持久化、集群与功能上全面胜出,这也是 Memcached 如今基本退场的原因。

原理:逐项对比。数据类型:Memcached 只有简单 KV,Redis 提供 String、List、Hash、Set、ZSet 及 Bitmap 等。持久化:Memcached 纯内存不落盘,Redis 有 RDB/AOF,重启可恢复。集群:Memcached 无原生集群,靠客户端分片;Redis 3.0 起原生支持 Cluster。线程模型:Memcached 是多线程加非阻塞 IO;Redis 是单线程 IO 多路复用,6.0 起网络读写引入 IO 线程。功能:Redis 额外支持 Lua 脚本、事务、发布订阅。过期删除:Memcached 只有惰性删除,Redis 是惰性加定期。

延伸:被问「为什么不用 Memcached」按以上维度作答即可;想展示广度可提 Dragonfly、KeyDB 这类兼容 Redis 协议的新项目,但生产首选仍是生态最成熟的 Redis。

追问:如果全是纯缓存大对象、不需要持久化,Memcached 还有优势吗?提示:多线程模型在大 value 高并发下吞吐不差,但生态与运维成本通常已不值得选它。

线程模型

Redis 单线程模型了解吗?

结论:常说的单线程指「命令执行由单个主线程串行完成」;这套模型基于 Reactor 模式,靠 IO 多路复用让单线程同时监听海量客户端连接。

原理:核心是文件事件处理器,由四部分组成:多个客户端 socket、IO 多路复用程序、事件分派器、事件处理器(连接应答、命令请求、命令回复)。多路复用程序把读、写事件注册到内核,事件就绪后由分派器交给对应处理器执行。全程单线程,既省掉线程切换与锁,又与同样单线程运行的内部模块无缝对接,保持了实现的简单性。

延伸:单线程不等于整个进程只有一个线程:4.0 起有 bio 后台线程负责大 key 懒释放、AOF 刷盘、关闭文件等慢操作。准确表述是「命令执行单线程,耗时辅助操作多线程」。

追问:单线程下 DEL 一个百万级大 key 会怎样?提示:释放内存是 O(N) 操作,会阻塞主线程,4.0 起应改用 UNLINK 交给后台线程。

Redis 6.0 为什么引入多线程?

结论:网络 IO 的读写与协议解析在大流量下成了瓶颈,6.0 把这部分交给 IO 线程池并行处理;命令执行仍由主线程串行完成,不存在线程安全问题。

原理:Redis 的瓶颈主要在内存和网络而非 CPU,QPS 极高或 value 较大时,主线程耗在 socket 读写上的时间占比明显升高。多线程默认关闭,需在配置中开启:

io-threads 4             # 官方建议 4 核设 2~3,8 核设 6
io-threads-do-reads yes # 默认只并行写回,读并行收益有限

两个注意点:io-threads 设定后不能通过 config 动态调整;启用 TLS 后 IO 线程不生效。

延伸:面试里别说「Redis 6.0 变成多线程了」——命令执行模型没有变,变化的只是网络层。这句话本身就是面试官埋的表述陷阱。

追问:IO 线程与主线程如何交接?提示:主线程把待处理的客户端分配给 IO 线程并行读写,自己等待全部完成后继续事件循环。

数据类型

String 底层 SDS 相比 C 字符串强在哪?

结论:Redis 没有直接用 C 字符串,而是自研 SDS(简单动态字符串),换来 O(1) 取长度、防缓冲区溢出、二进制安全和更少的内存重分配。

原理:C 字符串以 \0 结尾,取长度要遍历(O(N)),拼接不检查容量会溢出,也无法存含 \0 的二进制数据。SDS 结构为 len(已用长度)、alloc(总容量)、flags(类型)、buf[](数据):len 直接读出长度;修改前按 len 与 alloc 检查并自动扩容;以 len 判结束而非 \0,天然二进制安全;配合空间预分配与惰性释放减少重分配。3.2 起 SDS 按长度细分 sdshdr8/16/32/64 四种头部,短字符串只花 1 字节存 len 与 alloc,进一步省内存。

延伸:「头部加元数据、容量预分配」的思路与 Java 的 StringBuilder、Go 的 slice 一脉相承,是动态数组工程的通用解法。

追问:SDS 空间预分配的规则是什么?提示:扩容后小于 1MB 则翻倍预留,大于等于 1MB 则每次多留 1MB。

存储对象用 String 还是 Hash?

结论:对象结构简单、以整体读写为主选 String(序列化后整体存取);需要频繁读写个别字段,或字段多而短,选 Hash。

原理:String 存整个序列化对象,读写是 O(1) 的整体操作,简单直接;但改一个字段要整体读出、反序列化、修改、写回,网络与 CPU 双重浪费。Hash 按 field 独立存储,可单独读写某字段,省流量也省解析;字段少且短时采用 listpack 紧凑编码,内存往往比 String 更省。代价是字段一多就转为哈希表编码,内存优势消失,多次字段操作也有额外开销。多层嵌套的复杂对象用 String 更省心,Hash 得自行展平。

延伸:购物车是 Hash 的教科书场景:用户 id 作 key,商品 id 作 field,数量作 value,加购、改数量、删单品、清空都落在 field 级操作上。

追问:Hash 的紧凑编码什么时候升级为哈希表?提示:由 hash-max-listpack-entries(默认 128)和 hash-max-listpack-value(默认 64 字节)两个阈值控制。

ZSet 为什么用跳表而不用红黑树、B+ 树?

结论:跳表用概率平衡替代严格平衡,实现更简单、写入更快、区间查询顺手,与 ZSet 主打的排名和范围场景高度匹配。

原理:逐树对比。红黑树等平衡树:查、插、删同为 O(logN),范围查询靠中序遍历也能做,但每次写入都可能触发旋转或染色来维持严格平衡,代价高于跳表的随机建层;区间查找也不如跳表沿层前进直接。B+ 树:核心价值是矮胖多叉、减少磁盘 IO,适合数据库索引;Redis 是内存数据库,没有 IO 定位问题,跳表按概率维护索引更省内存,也没有节点分裂合并的复杂逻辑。跳表本身是多层链表,上层是下层的快车道,查询自顶向下逐层逼近,期望复杂度 O(logN)。

延伸:ZSet 实际是「哈希表 + 跳表」复合结构:哈希表让按成员查分数 O(1),跳表支撑排名与范围操作;元素少且小时则用 listpack 编码。

追问:跳表的晋升概率与最大层数是多少?提示:Redis 中晋升概率为 1/4,最大 32 层,这个概率取值直接决定空间开销与查询效率的平衡点。

内存管理

Redis 过期 key 的删除策略是什么?

结论:采用「惰性删除 + 定期删除」组合:访问时才检查并删除过期 key,同时周期性随机抽查一批带过期时间的 key,清掉其中已过期者。

原理:过期时间存在每个库的过期字典里(redisDb.expires,值为毫秒级时间戳),查询时先查字典,过期即删并返回空——这是惰性删除,对 CPU 友好,但冷的过期 key 可能长期占内存。定期删除兜底:每轮随机抽 20 个带过期时间的 key,删掉已过期的;若样本中过期比例偏高(超过约四分之一)就再抽一轮,单轮耗时设上限以免拖垮主线程。执行频率由 hz 参数控制(默认每秒 10 次),并默认开启动态调整。

延伸:正因为删除靠「抽查」而非到期即删,key 到期后仍可能短暂存活——这也是 keyspace 过期事件做不了精确延时任务的根本原因。

追问:为什么不给每个 key 挂定时器到期即删?提示:海量 key 下定时器或延迟队列的维护成本(内存、调整、并发控制)远超收益。

Redis 内存淘汰策略有哪些?怎么选?

结论:内存达到 maxmemory 上限后按 maxmemory-policy 淘汰;共八种策略,沿两个维度组合:范围(volatile 只淘带过期时间的,allkeys 淘全部)乘以算法(LRU、LFU、TTL、random),外加默认的 noeviction——不淘汰,写入直接报错。

原理:触发条件是 used_memory 超过 maxmemory(64 位默认 0 表示不限)。4.0 新增的 LFU 用访问频率计数,对抗 LRU「长期不访问但近期突热」的误判。选型经验:纯缓存场景用 allkeys-lru 或 allkeys-lfu;缓存与持久数据混存(如不设 TTL 的 Session)用 volatile 系保护持久 key;把 Redis 当存储、宁可失败也不丢数据时用 noeviction。

延伸:Redis 的 LRU 是近似实现:每个对象记录 24 位访问时钟,随机采样若干条(maxmemory-samples 默认 5)淘汰最旧者,用极小代价逼近真实 LRU;LFU 则是 8 位对数计数器配衰减时间。

追问:「MySQL 有 2000 万数据,Redis 只存 20 万,如何保证存的都是热点?」提示:标准答案就是 allkeys-lru/lfu——让访问行为自然沉淀热点。

大量 key 集中过期怎么办?

结论:预防为主,给 TTL 加随机抖动打散到期时刻;已经发生时开启 lazyfree-lazy-expire,让过期 key 的内存释放走后台线程,避免阻塞主线程。

原理:集中过期有两重伤害:其一,定期删除会因样本过期比例高而连续多轮执行,清理工作抢占主线程 CPU,正常请求延迟抖动;其二,若这批 key 是缓存,同一时刻集体失效会把请求直接打到数据库,演变成缓存雪崩。治理分两头:写入侧在基础 TTL 上加随机值(如 300 秒加 0~60 秒随机),让到期时刻自然散开;运行侧配置 lazyfree-lazy-expire yes,主线程只做逻辑删除,内存释放交给 bio 后台线程。

延伸:如果集中过期源于「每天零点统一刷新缓存」这类设计,更好的解法是错峰加载或永不过期加主动更新,而不是指望淘汰机制硬扛。

追问:集中过期和缓存雪崩是同一回事吗?提示:集中过期只是雪崩的诱因之一,实例宕机同样会造成雪崩,两者治理手段并不完全重合。

应用拓展

Redis 除了做缓存还能做什么?

结论:Redis 本质是「内存数据结构服务器」,缓存只是用法之一:分布式锁、限流、消息队列、延时队列、分布式 Session、排行榜、UV 统计等都是生产级用法。

原理:按数据结构对号入座。String:SET NX PX 实现简易分布式锁(生产环境用 Redisson),INCR 做计数与简单限流。List:LPUSH 加 BRPOP 是最朴素的队列。Stream(5.0 起)带消费组与 ACK,是轻量 MQ 的正解。ZSet:score 作排序依据天然做排行榜,score 存执行时间戳即延时队列(Redisson DelayedQueue 的底层正是它)。Set 的交并差运算做共同好友,SPOP/SRANDMEMBER 做抽奖。Bitmap 按位记录签到与活跃;HyperLogLog 用固定 12KB 估算 UV,误差约 0.81%;GEO 存坐标做「附近的人」。

延伸:这类题的关键不是穷举,而是给出「场景、选型理由、注意点」的闭环——比如延时队列要点出可靠性边界,消息队列要说清与 Kafka 的能力差距。

追问:这些用法里哪些对可靠性最敏感、Redis 方案有硬伤?提示:延时任务与消息队列都绕不开丢消息问题,红线业务应交给专业 MQ。