1. 核心数据结构与底层编码:为什么面试官总从这张图问起
每次面试聊到Redis,基本绕不开的第一座山就是数据结构。很多人觉得这题简单,张口就来五种类型,但真到追问底层实现的时候,十个里有八个会卡壳。我参与过不少技术面试,问"String的底层结构是什么",能说上SDS的其实不少,但能讲清楚SDS比C字符串好在哪儿的,就明显变少了。这不是背不背的问题,是你有没有真正理解Redis在设计上的取舍。
1.1 String底层SDS设计精要
C语言字符串用\0结尾,获取长度得遍历,拼接还得自己管内存。Redis的String并没有直接用C字符串,而是包了一层SDS(Simple Dynamic String),结构大致包含len、free和buf三块。len记录已用长度,所以取长度是O(1);buf里可以存二进制数据,因为判断结束不再依赖\0;free配合空间预分配,能让连续多次追加操作减少内存重分配。
这里有个容易被忽视的点:空间预分配的策略分两种情况。如果修改后SDS长度小于1MB,就分配和len一样大的free空间,也就是你要改到10字节,直接留20字节的容量;如果修改后长度已经大于等于1MB,free只额外分配1MB。这套策略是为了在频繁执行APPEND、SETRANGE这类操作时,尽量别一次一次去malloc。惰性空间释放也是一样,删除数据不是立刻把内存缩回去,而是先记录在free里,等后面再复用。理解这一层,面试里被追问"为什么Redis的String适合频繁修改"你就能答到根上。
还有一个高频考点:embstr和raw的转换阈值。embstr适合短字符串,因为它把redisObject和SDS分配在连续内存里,省一次内存分配,也利于CPU缓存。可一旦字符串变长(通常超过44字节),Redis就会转成raw。这个数字怎么来的?跟Redis对象头和SDS头占用的字节相关,64字节的内存块减去对象头长度,剩余给SDS的容量约44字节。不用死记硬背,但是要知道"短字符串用embstr更省内存"这个结论来自连续内存分配这件事。
1.2 List、Hash、Set、ZSet的编码演进
List在Redis 3.2之前是ziplist和linkedlist二选一,3.2之后就统一成quicklist了。quicklist本质是"双向链表加压缩列表"的组合:每个节点是一个ziplist,节点之间用指针串联。这样既保留了链表两端插入删除快的特性,又通过压缩列表降低内存占用。Redis 7.0开始,官方又用listpack替换了ziplist作为节点存储结构,原因是ziplist在极端情况下存在"连锁更新"问题——前一个节点变大挤到下一个节点,引发级联扩容。listpack从设计上规避了这个问题,每个元素自带长度信息,不需要反向依赖前一项。面试如果聊到这里,直接抛出"升级是为了解决连锁更新带来的性能抖动",比单纯背类型名高明得多。
Hash的小编码是listpack(7.0前是ziplist),大编码是hashtable。转换阈值主要看两个参数:hash-max-listpack-entries默认128,hash-max-listpack-value默认64,意思是元素个数超过128或者某个value长度超过64字节,就升级成真正的哈希表。Set的小编码是intset,要求元素都是整数且数量不超过512(set-max-intset-entries)。ZSet的小编码同样受zset-max-listpack-entries和zset-max-listpack-value控制,阈值分别是128和64。
ZSet的经典面试题是:为什么用跳表而不是红黑树或者平衡树?我的回答通常分三点。第一,跳表实现简单,区间查找友好,比如ZRANGEBYSCORE这种按分数范围扫描的操作,跳表天然有序,沿着层往下走就行;第二,Redis里ZSet还配了一个dict,用来做成员到分数的映射,这样查某个成员的分数是O(1),而跳表负责的范围操作是O(logN);第三,跳表调整平衡的操作比红黑树简单,不需要复杂的旋转变色,对于写多读多的缓存场景更省心。这个组合设计是Redis里非常经典的一招,答好了面试官对你的好感会明显上升。
我在实际项目里用到ZSet最多的场景是排行榜和延迟队列。排行榜按分数倒序取前N名,ZREVRANGE一行搞定;延迟队列则用分数代表执行时间,轮询的时候ZRANGEBYSCORE取当前时间之前的任务。这里有个经验:如果单key的ZSet特别大,比如百万级成员,每次范围查询的耗时会上来,务必拆分key维度,比如按天或按业务ID分片。
2. 持久化机制:RDB和AOF到底怎么选,面试最怕问什么
数据要落地,Redis就得解决持久化。面试里这块的高频套路是先问"RDB和AOF各自是什么",再问"你线上怎么选",最后追一句"如果机器突然宕机,最多丢多少数据"。如果你只会背概念,第三问基本接不住。
2.1 RDB快照的fork与Copy-On-Write原理
RDB是内存快照,通过SAVE或BGSAVE生成。线上几乎不会用SAVE,因为它直接在主线程执行,数据量大了会卡死所有请求。BGSAVE则靠fork()出一个子进程去写文件。这里的关键是fork之后子进程和父进程共享同一份内存页,利用操作系统的写时复制(Copy-On-Write)技术:在子进程生成快照的过程中,如果父进程收到写命令要修改某块内存,操作系统会先复制这块内存页给父进程,父进程在副本上改,子进程看到的仍是fork那一刻的数据。所以RDB备份的是"某个时间点的一致性快照",不是绝对实时。
RDB触发的默认配置是save 900 1、save 300 10、save 60 10000,满足其一就会触发。至于stop-writes-on-bgsave-error,默认是yes,意思是快照写失败后直接拒绝写请求,避免数据越积越脏。生产环境我会把它保持默认,同时监控INFO persistence里的rdb_last_bgsave_status。
RDB的优点很实在:文件紧凑、恢复速度快、适合做灾备和冷备。缺点同样明显:两次快照之间如果宕机,这段时间写入的数据全部丢失。要降低丢失窗口,就得缩小快照间隔,但频繁fork大实例又可能造成主线程短暂阻塞。所以纯粹的RDB方案,在数据敏感性较高的业务里很难满足要求。
2.2 AOF追加日志与三种刷盘策略
AOF记录的是每条写命令,以Redis协议文本追加到文件。appendfsync决定命令写入缓冲区后何时刷到磁盘,三个选项分别是always、everysec、no。always每次写都刷盘,最安全但性能下降明显;everysec每秒刷一次,是性能和安全的折中,最多丢一秒数据;no交给操作系统决定,数据丢失窗口最大。线上我默认用everysec,实测在普通业务量下性能影响很小。
AOF有个必须理解的机制叫AOF重写。随着写入越来越多,AOF文件会持续膨胀,重启时恢复数据的耗时也变长。BGREWRITEAOF会基于当前内存里的数据生成最小化命令集,比如对某个key执行了100次INCR,重写后只保留最终的SET。具体执行时Redis会fork子进程,主线程同时维护一个重写缓冲区,子进程重写期间产生的新命令会追加到这个缓冲区,重写完成后父进程把缓冲区的增量再追加到新AOF尾部,保证新文件不丢重写期间的数据。这个细节面试里被问到的概率很高,你要能说清楚"重写不是简单地把旧文件改小,而是重新生成一份当前数据集对应的命令集"。
2.3 混合持久化与数据恢复优先级
Redis 4.0引入了混合持久化,aof-use-rdb-preamble yes开启。它把AOF重写出的文件前半部分用RDB格式存储,后半部分继续用AOF增量命令,兼顾RDB加载快和AOF丢失少的优点。启动恢复时,如果开启了AOF且AOF文件存在,Redis会优先加载AOF文件,因为AOF里的数据更新;如果AOF关闭,则加载RDB。注意一点:主从节点第一次全量同步,本质上也是主节点生成RDB传给从节点,而不是逐个重放命令,因此RDB在复制链路里依然是核心角色。
我踩过一次坑,早期在某系统里为了追求写性能直接关了AOF只用RDB,结果一次凌晨断电,数据库状态回退到几十分钟前。后来我定了一条线上铁律:只要数据不能丢,就开AOF,everysec起步;即使开了RDB,也绝不能关AOF。备份文件要定期做恢复演练,别等真出故障时发现备份文件是坏的。文件损坏时可以用redis-check-aof --fix修复,RDB对应redis-check-rdb,但能通过监控提前发现比事后修复更值钱。
3. 高可用架构演进:从主从复制到哨兵再到Cluster
单机Redis再强也有上限,一旦宕机整条业务链路直接挂。面试对高可用的考查,本质上是一层层追问:主从复制怎么做?主库挂了谁接管?集群数据怎么分片?每一层都有大量细节可以深挖。
3.1 主从复制的全量与增量机制
主从复制分两个阶段。全量复制发生在从库刚接入时:从库发送PSYNC请求,主库返回replid和offset,随后主库执行BGSAVE生成RDB,把RDB传给从库,从库加载后,主库再把复制期间积压的写命令发给从库。增量复制发生在连接断开重连后,主库会从repl_backlog缓冲区内找到从库需要的偏移量,把这段命令继续推送过去。这里有一个关键参数repl-backlog-size,默认是1MB,如果从库断连时间太长,积压缓冲区被覆盖,就不得不重新全量复制。
全量复制的代价很大,尤其是数据量上百GB时,生成RDB、传输、加载都可能耗时很长,主库fork瞬间和带宽占用都会影响线上。所以生产环境要从库尽量靠近主库,并且调大repl-backlog-size,比如设成64MB甚至更大,给断线重连留出缓冲。还有个隐性坑:从库的client-output-buffer-limit replica设置太小时,大流量下主库发给从库的数据可能被切断,导致复制反复失败。
想确认主从是否健康,直接看INFO replication里master_link_status是不是up,以及从库的延迟秒数lag。主从延迟是不可避免的,读写分离场景下就要接受"读到旧数据"的可能,核心读请求尽量还是打主库。
3.2 哨兵的三个定时任务与故障转移流程
哨兵解决的是"主库挂了,谁能及时发现并完成切换"的问题。一个哨兵实例对主库有三个定时任务:每10秒发一次INFO拿主从拓扑信息;每1秒发一次PING探活;每2秒通过发布订阅交换哨兵之间的判断结果。当某个哨兵发现主库PING超时,先把主库标记为主观下线,然后通过SENTINEL is-master-down-by-addr请求其它哨兵确认。当超过quorum个哨兵都认为主库不可达,主库就被标记为客观下线,随后哨兵集群会选举出一个leader来主导故障转移。
故障转移的细节是:从所有从库里筛选出优先级最高、复制偏移量最新、runid最小的从库,执行SLAVEOF NO ONE让它变成新主库,再让其它从库指向它,最后向客户端通知新主库地址。这里有个要点:哨兵节点数量至少3个,且配置quorum为2,才能避免单个哨兵误判导致脑裂。我自己部署哨兵时,会把3个哨兵放到独立的机器上,不跟Redis实例混部,这样实例宕机不影响哨兵存活。
还有一个面试必问的老问题:主从切换期间为什么会出现"老主库数据丢失"。场景是这样的:Master和Sentinel之间网络分区,Master还在运行,客户端也还连着Master正常写入;另一边Sentinel判断Master下线,把从库提升为新Master。网络恢复后老Master降级为从库,它会清空本地数据重新同步新Master,于是分区期间写入老Master的数据全部丢失。解决思路有两个方向:一是min-replicas-to-write和min-replicas-max-lag,当从库连接数不足或主从延迟超过阈值时直接拒绝写入,减少分区窗口内的脏数据;二是从架构上尽量降低网络不稳定的概率。你在回答时提到参数名,面试官一般就知道你真的处理过这类故障。
3.3 Cluster分片原理:16384个槽位是怎么来的
Cluster把整个键空间划分成16384个槽,每个key通过CRC16(key) % 16384确定归属槽,集群节点各自负责一段槽区间。客户端访问某个key时,如果请求落到了错误的节点,节点会返回MOVED错误并带上正确的节点地址,客户端据此重发请求。槽在迁移过程中还会出现ASK重定向,表示数据正在迁移,只允许一次跳转。
一个被追问最多的细节是:为什么Redis选的是16384而不是更多的槽位?官方给的答案里有一个很实际的考量:节点之间通过心跳包交换状态,每个节点在心跳里带上自己负责的槽位信息(用bitmap表示)。16384个槽对应2KB位图,如果槽数扩到65536个,位图就有8KB,集群上千个节点时,心跳包消耗的带宽会明显增大;而且CRC16虽然输出16位,但Redis集群节点数量通常不会超过1000,16384已经足够均匀分布。分布式系统的设计常常是"够用就好",这个槽位数就是典型的工程取舍。
Cluster的搭建要求最少6个节点,通常3主3从。数据迁移用redis-cli --cluster reshard,会经历"导出、导入、存量迁移、增量追赶、完成"几个阶段,期间key不一定都在源节点或目标节点,所以才有ASK重定向机制。扩容缩容时一定不要在业务高峰期做,迁移过程对热key的影响比想象中大得多。
4. 缓存三大难题与分布式锁:你以为答完了,其实坑在后面
缓存穿透、缓存击穿、缓存雪崩,这三兄弟基本是Redis面试的保留节目。但我要提醒你的是,面试官通常不满足于你背出定义,他会给你一个具体的业务场景,让你现场说方案、说参数、说回退策略。
4.1 穿透、击穿、雪崩的完整应对
穿透是查了一个根本不存在的数据,每次请求都落到数据库。最省事的办法是"缓存空值",把null也缓存起来,TTL设短一点,比如30秒到60秒;更严谨的方案是布隆过滤器,把所有可能存在的数据hash到位数组里,查缓存之前先过滤,位向量里查不到就说明一定不存在。布隆过滤器的缺点是存在误判率和不能删除,误判只会让极少数请求穿透到DB,不能删除则导致更新数据时处理麻烦,需要定期重建。两个方案我一般一起用:布隆过滤器在前面拦截大部分非法id,空值兜底处理剩余的极小概率。
击穿是指某个热点key在过期瞬间,大量请求同时打到数据库。解决思路有两个:互斥锁和逻辑过期。互斥锁就是抢到SETNX的线程去查库回填缓存,其它线程短暂等待后重试,代价是会有一部分请求阻塞,但保护了数据库。逻辑过期则更进一步:value里存的不再是单纯的业务数据,而是数据和过期时间的封装,查询时发现逻辑过期,先返回旧数据,同时异步派一个线程去刷新缓存。这个方案对延迟更友好,代价是实现复杂度高,而且会有一段时间读到旧数据。我实际做秒杀场景时用的是逻辑过期,因为互斥锁的等待时间在流量峰值时不可控。
雪崩是大量key在同一时段集中过期,数据库瞬间被压垮。最常见的手段是给TTL加上随机偏移量,让过期时间散开。还有个容易忽略的点:如果同一业务用同一个过期时间戳生成缓存,那么即使加了随机值,在缓存预热时也可能出现阶段性集中失效,最好把"过期时间基准"也错开。多级缓存和降级限流是另一层保险,本地缓存先扛一部分,DB前面再加一层限流,保证数据库永远是最后那道防线。
4.2 分布式锁怎么实现才不会被面试官追问倒
分布式锁的经典实现是SET key value NX PX timeout,其中key是锁名称,value是一个唯一标识(用UUID或雪花ID),NX保证只有key不存在时才能设置成功,PX设置过期时间。这里最大的坑是"没设置过期时间,锁永远不释放"和"锁过期了,业务还没执行完,被别人抢到锁"。光用一条SET命令解决不了所有问题,至少要做到三点:
第一,释放锁时必须用Lua脚本保证原子性,先比对value再DEL,防止把别人的锁误删。第二,设置过期时间不能拍脑袋,要基于业务最大耗时来定。第三,如果业务可能超过锁的过期时间,就得引入"看门狗"机制来自动续期。Redisson里就内置了这套:默认锁超时30秒,看门狗每10秒检查一次,只要业务线程还在执行就续期,客户端异常宕机时锁会自然过期。要注意的是,看门狗续期只对某些模式生效,手动指定leaseTime时它不会自动续期,这一点我见过不少人用错。
还有一类面试题是"把锁加在某条数据上,到底要锁什么"。如果锁粒度太粗,比如整个订单体系一把锁,并发直接降为零;太细又可能锁不住关联操作。我的经验是先梳理操作涉及的数据范围,再决定锁的key粒度,比如按用户ID和商品ID组合加锁。锁的时间也要按最慢的调用链路评估,留出20%-30%余量。
关于RedLock,我只能说实现复杂度高、争议也大,连作者本人都承认它不是银弹,在生产里大规模使用有网络延迟和时钟漂移的隐患。如果面试官提起,可以说"这个概念我了解,但不建议在分布式场景盲目应用,MySQL或持久化共享存储的方案在某些业务下反而更稳"。这个回答通常能体现你对选型的独立思考。
5. 性能排查与大Key、热Key治理实录
这一节想聊聊真实场景里怎么定位Redis性能问题。我在一线排查过不少Redis引发的故障,绝大多数逃不出三类:慢查询、大Key、热Key。
5.1 慢查询与延迟定位三板斧
第一板斧看SLOWLOG GET,Redis会把执行时间超过slowlog-log-slower-than(默认10000微秒)的命令记录下来。很多慢命令不是某一条操作复杂,而是命令本身设计有问题,比如KEYS *在几百万key的实例上直接卡主线程,SMEMBERS取超大集合全部元素也特别危险。只要在代码里见到KEYS,基本可以直接改掉,用SCAN替代。
第二板斧用redis-cli --latency观察实例响应延迟基线,再结合INFO commandstats看哪个命令调用次数多、平均耗时高。如果延迟呈现周期性抖动,优先检查是不是有BGSAVE或AOF rewrite正在执行,fork大实例的瞬间可能造成毫秒级卡顿。
第三板斧排查CPU和内存。CPU飙高不一定是命令问题,可能是客户端把Redis当计算引擎使用了,比如放了一个复杂的Lua脚本在那儿做大数据量聚合。内存暴涨则要查INFO memory里的键空间大小和碎片率,mem_fragmentation_ratio持续高于1.5就要考虑activefrag或者重启调整内存分配策略了。
5.2 大Key和热Key的处理经验
大Key的判定标准我按这个来:单个String的value超过10KB,或者集合类型元素数量超过5000个、总大小超过1MB,就算大Key。大Key的危害不只是内存问题,删除它的时候主线程会阻塞,网络传输时也会拖慢其他命令,集群模式下还会造成节点间数据不均。快速扫描大Key的办法是redis-cli --bigkeys,它会遍历整个实例找出每个类型里最大的key;更精确的评估用MEMORY USAGE key。
处理大Key的经验,我把它们分成三类。第一类,可拆分:一个超大的Hash按业务ID拆成多个小Hash;第二类,可压缩:把业务里的大字段压缩后存入,读取时解压;第三类,不应该留在Redis:应该扔给搜索引擎或对象存储,Redis只存索引和摘要。删除大Key必须用异步命令,Redis 4.0之后有UNLINK,它把释放内存的动作放到后台线程做;老版本就得分批删,比如SSCAN边扫边删除集合元素。我见过有人在生产环境直接DEL一个400MB的key,那几秒钟线上请求直接被拖垮,这个坑千万不要踩。
热Key的判断可以从redis-cli --hotkeys(需要开启LFU)或监控某个key的访问量来分析。热Key最直接的影响是单节点CPU被打满,即使集群模式也解决不了,因为一个key只会落到一个槽位。缓解手段就是"本地缓存加多副本":把热点数据缓存到应用进程内,比如Caffeine或者手写一个简单的LRU,只在本地失效时才回源Redis;还可以对热key做副本,比如给key加后缀创建多个副本,把读请求分散到不同节点。副本方案要注意一致性,适合读多写少的场景,写的时候需要同步更新所有副本,否则会产生脏读。
5.3 过期删除与内存淘汰必须分清
Redis删除过期key用的是"惰性删除加定期删除"组合:惰性删除是每次访问key时才检查是否过期,过期就删;定期删除是每隔一段时间抽查一部分带过期时间的key,发现过期的就删。这两种方式都可能导致一部分过期key长期占用内存,所以最终防线是内存淘汰策略。Redis 8种策略里,常见的几个要能背出区别:noeviction不淘汰直接报错;allkeys-lru对所有key做LRU淘汰;volatile-lru只对设置了过期时间的key做LRU;allkeys-lfu和volatile-lfu是基于访问频率的淘汰。还有volatile-ttl按剩余寿命短的优先淘汰。线上业务如果缓存容忍丢失,我一般用allkeys-lru;如果某些key绝对不允许被淘汰,就得volatile-lru,给不允许丢失的key不设过期时间即可。
经常有人把"过期删除"和"内存淘汰"混为一谈,面试时这是送分题,你把"删除针对过期key、淘汰针对内存空间不足"这个边界讲清楚,回答质量直接上一个档次。
最后分享一点个人实践体会。我处理过的Redis问题里,真正难查的从来不是命令不会写,而是你不理解它在极端情况下会怎么表现。比如大key删除卡住主线程、主从全量复制打满带宽、哨兵脑裂造成数据丢失,这些问题在文档里都有零散提示,但只有在真人真事里踩过一遍,你才会记住那个参数、那个阈值。建议每个用Redis的人,都把自己线上实例的INFO关键指标和CONFIG配置沉淀成一份基线文档,出问题时先对比基线再下手。Redis的知识点看起来又多又散,其实只要抓住"数据结构、持久化、高可用、缓存策略、性能治理"这几条主线反复加深,面试和实战都会从容很多。