这两年面过不少PHP岗位,也帮身边朋友做过模拟面试,Redis基本上是所有PHP面试里绕不开的一块。很多候选人一听到"Redis核心知识",第一反应就是背"五种数据类型":String、Hash、List、Set、ZSet,背得滚瓜烂熟。但真往深了问一句"你们项目里Key是怎么设计的",或者"缓存穿透你们怎么防的",不少人就卡住了。这篇文章我不打算给你列一份标准答案背诵清单,而是把面试官真正想听的东西拆开讲清楚,从数据类型到持久化,从缓存雪崩到分布式锁,每一块都说说是怎么在生产环境里落地、被问到的时候怎么回答才能拿高分。适合准备跳槽的PHP开发,也适合刚接触Redis、想搞懂核心概念的初学者。
1. 面试官考Redis,不只是让你报数据类型
1.1 从简历上"熟悉Redis"四个字说起
很多PHP候选人简历里都写着"熟悉Redis",但真正聊起来,往往停留在"会用"的层面:SpringBoot整合一下、存个Session、做个缓存,顶多再知道个分布式锁。可面试官心里很清楚,Redis在PHP项目里承担的角色远不止"缓存"两个字。它可以做计数器、做排行榜、做消息队列、做分布式锁、做限流器、做布隆过滤器,甚至做附近的人这种地理位置计算。所以面试官问Redis,表面在问知识点,实际在判断两件事:第一,你有没有在真实项目里用过它,而不是只在本地写过Demo;第二,你遇到问题的时候,是能自己分析原理去解决,还是只会网上搜一段代码贴上去。
我有一个很直观的感受:面试官问"Redis支持哪些数据类型"的时候,本质上不是考察记忆力,而是在给你递话。他希望你顺着这个话题讲出应用场景、底层结构、踩过的坑,从而判断你的实战深度。如果你只回一句"五种,String、Hash、List、Set、ZSet",那这道题就变成了送分题,你等于把一个展示自己的机会直接扔掉了。
1.2 面试官判断"用过还是背过"的三个信号
根据我多次参与技术面试的经验,面试官问Redis相关问题时,主要看三个信号来判断候选人是不是真的在项目里用过。
第一个信号是Key的命名习惯。真的用过Redis的人,开口就是"项目里我们的Key规范是业务名:对象名:唯一ID,比如 order:payment:20260701001",而背题的人只会说"Key就是字符串"。这个差异特别明显,因为生产环境里Redis的Key命名规范直接影响到后续的排查、维护和集群迁移。
第二个信号是是否主动谈到"坑"。比如说到Hash类型时,真正用过的人会提到"Hash在底层有两种编码,ziplist和hashtable,当字段少值小的时候用ziplist省内存",或者"用HGETALL拉大Hash对象时会有阻塞风险"。这类细节是背八股文背不出来的,只有真正写代码Debug过的人才会有体感。
第三个信号是遇到异常时的处理方式。比如线上Redis连接超时了你怎么排查,是大规模缓存失效导致的,还是big key拖垮了网络,还是连接池配置不合理。面试官问这类开放性问题时,就是想听你的排查路径是否清晰。
所以我一直跟身边准备面试的朋友说,Redis这部分不要当文科背,要当成理科理解。每一种数据类型的底层结构、每个命令的时间复杂度、每个参数配置的含义,都应该知道"为什么"。
2. 五种数据类型怎么答才能拿高分:场景、底层与命令细节
2.1 String类型:最基础却最容易被问出破绽
String是Redis里最基础也是用得最多的类型,PHP项目里缓存一个用户信息、存个验证码、做访问量计数器,用的都是它。但面试官问String时往往不满足于"能存字符串",而是会往下追问几个层次。
第一层是问你String底层用的什么结构。Redis的String并不是C语言原生字符串,而是封装了一个叫SDS(Simple Dynamic String,简单动态字符串)的结构。SDS相比C字符串有两个核心优势:一是O(1)复杂度获取字符串长度,因为SDS结构中直接保存了len字段;二是自动扩容,不会出现缓冲区溢出。很多人在这个点上一带而过,其实你可以多讲一句"SDS在Redis 3.2之后分成了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64这几种类型,根据字符串长度不同选用不同header,目的是省内存",这一句话就能让你的回答和其他人拉开差距。
第二层是追问String的三种编码方式。int编码用于整数,embstr编码用于短字符串,raw编码用于长字符串。这里有个很经典的坑:如果一个字符串原来被存成int编码,你对它做APPEND操作导致它变长了,编码就会从int转成raw,这个转换过程是不可逆的。面试官问这个通常是想看的你能不能聊出"内存优化"的思路。
第三层就是应用场景。PHP里最典型的两个场景是缓存和计数器。计数器用INCR/DECR命令实现,INCR本身是原子操作,底层就是单线程执行命令,天然线程安全。我在项目里用它做过发帖数统计、点赞数统计,也做过秒杀场景的库存扣减。还有一个细节,用INCR做自增统计时,DB里存的值可能不是最终值,要考虑定时刷盘和主从一致性的方案。
命令层面还有个高频考察点:SETNX、SETEX、GETSET这些命令分别解决什么问题,以及SETNX和SET NX EX这种组合写法有什么区别。后文讲分布式锁的时候会展开。
2.2 Hash类型:对象存储的实战价值和ziplist陷阱
Hash类型在设计上就是为了存储对象。比如一个用户对象,有username、age、email这些字段,如果用String存,要么序列化成JSON整存整取,要么拆成多个Key,这两种方式都不理想。序列化成JSON的问题是:修改其中一个字段时,必须整个对象读出来反序列化、修改字段、再序列化写回,高并发下会有性能隐患。拆成多个Key的问题是:会产生大量Key,且无法保证这些Key的原子性操作。Hash类型原生支持对一个对象的多个字段分别操作,HMSET/HGET/HDEL/HLEN,修改一个字段不需要动整个对象,这在PHP里很适合存用户资料、购物车、文章详情这类结构化数据。
但Hash这里有个高频陷阱就是底层编码转换。当Hash对象里的字段数量小于hash-max-ziplist-entries配置值(默认128)且每个字段的value长度小于hash-max-ziplist-value配置值(默认64)时,Redis会用ziplist(压缩列表)存储来节省内存;一旦超过条件,就会转为hashtable(哈希表)结构。转换后内存占用会明显上升,而且这个转换是不可逆的,元素删回128个以内也不会变回ziplist。面试时你要能说出:为什么Redis要这么做?因为小对象用ziplist连续内存存储没有额外指针开销,省内存省得很可观;但这个机制也意味着生产环境里要关注大对象问题——如果某个Hash的值特别大,HGETALL会一次性拿到所有数据,造成网络阻塞和内存飙升。
针对这种场景,我实际项目里的做法是给每个Hash对象预估最大值,在配置上做限制,同时用HSCAN分批遍历,避免HGETALL一把梭。代码上PHP的phpredis扩展用法大概是:
$redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->hMset('user:10001', ['username' => '张三', 'age' => 28, 'email' => 'zhangsan@example.com']); // 只取部分字段,不要用 hGetAll $user = $redis->hMGet('user:10001', ['username', 'email']); // 对大Hash做增量遍历 $cursor = null; do { $result = $redis->hScan('user:10001', $cursor, '*', 100); // process $result } while ($cursor > 0);2.3 List类型:消息队列旧方案与阻塞语义
List类型底层在Redis 3.2之前是ziplist或linkedlist,3.2之后重构为quicklist,也就是把多个ziplist用双向链表串起来,兼顾了内存紧凑性和两端操作的效率。面试时能说出来quicklist这个结构,说明你不是停留在"List就是链表"的层面。
场景方面,List最常见的用途就是简单的消息队列:LPUSH消息、BRPOP消费消息。BRPOP是阻塞读取,如果队列空就挂起等待,超时时间可以设置。在老项目里,这确实是最简单的队列实现,不需要额外引入RabbitMQ或Kafka这类中间件。但你要知道它的局限性:无法支持消息确认机制,消费者把消息取走了但处理失败,消息就丢了;也不支持延迟消息、死信队列这些高级特性。所以在面试中如果你主动讲"我们用List做过简单的异步队列,但后来数据量上来之后换成了专业MQ",这就是一个非常加分的实战信号,说明你对技术选型有判断力。
List还有一个很特殊的用法是作为时间线/动态流存储。比如用户发了新动态,用LPUSH塞进粉丝的feed列表,最新动态排前面,Limit分页用LRANGE取。这个场景也经常被问到,它考察的是对List双向链表语义的理解。
2.4 Set与ZSet:去重、抽奖和排行榜的底层差异
Set的底层是哈希表或者整数集合(intset)。当元素全是整数且数量不多时用intset存储,非常省内存;不满足条件就转为hashtable。Set的典型场景是去重、抽奖、点赞、关注关系。比如抽奖功能,SADD把参与用户ID塞进集合,SRANDMEMBER随机取几个,面试时你甚至可以聊到SRANDMEMBER和SPOP的区别——前者不删除元素,适合"抽奖但用户还能继续参与"的场景,后者随机弹出并删除,适合"抽完就剔除"的玩法。
ZSet是Redis里比较特殊也最有含金量的一种类型。它在Set的基础上增加了score字段,每个成员都带一个分数,内部用跳跃表(skiplist)加哈希表实现。ZSet的灵魂在于排序,ZADD添加元素时指定score,ZREVRANGE按分数从大到小取数据,ZINCRBY给某个成员的分数加分。排行榜功能是ZSet最典型的应用:比如文章的实时热度榜,每次被阅读就把分数加一,然后定期取Top N。
面试官问到ZSet底层时,很容易追问一个经典问题:为什么ZSet用跳跃表而不用平衡树(比如红黑树)?这个问题的答案有几个层次:第一,跳跃表实现比红黑树简单很多,调试和查错成本低;第二,跳跃表的区间查找性能很不错,支持从某个分数段直接遍历;第三,Redis对有序集合的主要操作就是单点插入删除和区间遍历,不需要红黑树那样复杂的旋转平衡操作。我的建议是面试时把这三层全部说出来,面试官一般会很满意。
// PHP + Redis 实现一个简单的阅读排行榜 $redis->zIncrBy('article:rank', 1, 'article:1001'); $redis->zIncrBy('article:rank', 1, 'article:1002'); $top10 = $redis->zRevRange('article:rank', 0, 9, true); // 带score返回2.5 关于Redis 7新数据类型:别在面试里翻车
这里再多说一句。很多人只知道五种经典类型,但Redis 5.0之后加入了Stream类型,Redis 7.0又对Stream做了增强。Stream定位就是专门的消息队列,支持消费者组、消息ACK、Pending Entries List,解决的就是List做队列时消息会丢的问题。如果面试官聊到"你们用Redis做队列遇到过什么问题",你顺着说"我们后来看了Stream的消费者组机制,它能记录每个消费者的消费位置,消费失败的消息还能查看和重新投递",这就不只是知道五个类型的水平了。面试时提到Stream要谨慎,因为一旦主动抛出来就要接得住后续追问,建议是真的有实际使用或深入研究过再说,否则容易暴露深度不足。
3. 持久化机制:RDB和AOF的取舍,以及一句话答好的技巧
3.1 RDB快照:为什么fork子进程不会阻塞主线程
Redis持久化是面试必问题,而且往往是第一轮技术面就会问到。很多PHP开发平时只管调用,很少关心Redis重启后数据从哪来,所以这个问题最能筛选出候选人有没有真正运维过Redis。
RDB的核心原理是fork一个子进程,由子进程把当前内存里的全量数据生成快照写到磁盘。这里有个很容易被问到的点:Redis是单线程的,生成全量快照为什么不会阻塞主进程?答案是fork出来的子进程复制了父进程的内存页表,子进程做RDB时读取的是fork那一刻的内存数据快照,而主进程继续处理命令。如果主进程在快照期间修改了某些内存页,就会触发写时复制(Copy On Write)机制,系统为子进程复制一份修改前的内存页。所以RDB的阻塞风险主要来自fork瞬间的内存页表复制,如果Redis实例特别大,fork也可能会卡个几十毫秒甚至更久,这也是很多大厂要求Redis实例内存上限的原因之一。
RDB的触发方式有几种:手动执行SAVE或BGSAVE命令、配置文件里设置save规则(比如save 900 1表示900秒内至少1次修改就触发一次快照)、关闭Redis时自动保存。这里有个小坑:SAVE是同步阻塞的,生产环境千万不要手动执行SAVE;BGSAVE才是后台异步的。有些运维不熟悉Redis,直接在线上执行SAVE,直接把服务干停了,这种情况我见过不止一次。
3.2 AOF日志:三种刷盘策略和rewrite机制
AOF和RDB的思路完全不同。RDB存的是某一时刻的内存数据快照,AOF则是把Redis收到的每一条写命令追加到日志文件里,Redis重启时重放这些命令来恢复数据。AOF持久化的核心配置就是appendfsync,有三个值:always表示每条写命令都强制刷盘,最安全但性能损耗最大;everysec表示每秒刷一次盘,兼顾性能和数据安全,这也是默认配置;no表示交给操作系统决定什么时候刷盘,性能最好但可能丢的数据最多。
AOF还有一个非常重要的机制叫AOF重写(rewrite)。因为AOF会随着运行不断变大,里面全是历史命令,重写就是基于当前内存中的数据重新生成一份最小的AOF文件,把冗余的命令去掉。触发重写的条件是auto-aof-rewrite-percentage和auto-aof-rewrite-min-size,比如AOF文件比上次重写时增长了100%且大于64MB就触发。另外,BGREWRITEAOF命令可以手动触发AOF重写。AOF重写过程中,Redis同样使用fork子进程的方式,所以和RDB一样需要考虑fork阻塞风险。
面试时拿RDB和AOF做对比是个非常经典的问题,我给一个可以直接套用的简洁回答结构:
RDB是内存快照,恢复速度快、文件紧凑,但快照之间有数据丢失窗口;AOF是命令日志,数据完整性高,最多丢一秒数据,但AOF文件大、恢复速度慢。如果追求数据安全,用AOF,甚至设成appendfsync always;如果追求性能和简化运维,可以只开RDB;生产环境比较稳妥的做法是同时开启,Redis默认优先用AOF恢复。
3.3 混合持久化:Redis 4.0之后的最优解
我不知道还有多少人面试时只知道RDB和AOF二选一,实际上Redis 4.0之后已经支持混合持久化了。这个选项目前在真实项目中已经是主流做法,面试说出去会显得跟进了新东西。
混合持久化的逻辑是:AOF重写的时候,不再只写命令,而是先把当前内存数据以RDB格式写到AOF文件开头,再记录后续增量命令。这样Redis重启恢复时,先加载RDB部分快速恢复大部分数据,再重放少量增量命令,既比纯AOF恢复快,又比纯RDB丢的数据更少。配置项是aof-use-rdb-preamble,默认开启。
2018年左右我在一个电商项目里调过Redis持久化,当时线上用的是纯AOF + everysec,AOF文件一度膨胀到好几个G,恢复一次要很久。后来改成混合持久化之后,AOF重写完的文件体积小了很多,重启恢复时间从十几分钟降到一分钟内。这个真实经历在面试中非常加分,因为你把"为什么用混合持久化"的前因后果都讲清楚了,而不是背一句"Redis 4.0之后支持混合持久化"。
4. 缓存穿透、击穿、雪崩:三个连环坑和完整对策
4.1 三个"缓存故障"到底是什么,怎么和面试官讲清
缓存穿透、击穿、雪崩这几个概念,几乎是Redis面试必问中的必问。但很多人把它们混在一起,回答的时候逻辑不清。面试官最烦的,就是候选人说"反正都是缓存出问题了就加个互斥锁"。所以这几个概念一定要厘清:
缓存穿透是指查询一个根本不存在的Key,请求直接打到数据库。由于数据库里没有这个数据,缓存里自然也不会有,于是一个恶意请求用不存在的ID连续刷,每次都会打穿Redis直接压到数据库。这种情况用"空值缓存"和"布隆过滤器"来解决。
缓存击穿是指某一个热点Key在缓存过期的那一瞬间,突然有大量并发请求同时访问这个Key,结果缓存里没数据,请求全部穿透到数据库,数据库瞬间压力暴增。它和穿透的区别是:穿透查的是不存在的数据,击穿查的是真实存在但刚好过期的热点数据。解决方案是互斥锁或逻辑过期。
缓存雪崩是指大量Key在同一时间段集体过期,或者Redis实例直接宕机,导致大量请求同时打到数据库。它和击穿的区别是:击穿是一个热点Key过期,雪崩是很多人Key一起失效或者Redis整体不可用。解决方案是过期时间加随机值、做多级缓存、做限流降级。
4.2 空值缓存、布隆过滤器、互斥锁的细节展开
先说缓存穿透的解法。空值缓存听起来特别简单——查不到数据就把空值也放进缓存,设置一个短过期时间,比如5分钟。但有个细节容易被忽略:如果某个恶意请求拿随机不存在的ID疯狂刷,空值缓存会写入大量垃圾Key,Redis内存会快速膨胀。所以生产环境一定要给这种空值缓存设计一个"短过期+总数量限制"的组合策略。
布隆过滤器是更优雅的解法。把数据库里存在的所有ID提前存入布隆过滤器,查询前先判断这个ID是否存在,如果过滤器说"不存在",直接返回,根本不会走到缓存层。不过布隆过滤器有几个特点必须说清楚:它判断"不存在"是准确的,判断"存在"是概率性的,也就是会有一定的误判率。误判率可以通过位数组大小和哈希函数个数来控制。还有,布隆过滤器不支持删除操作,所以对删除数据频繁的场景不友好,一般配合定期重建或使用布谷鸟过滤器处理。
缓存击穿最经典的解法是让重建缓存的操作只允许一个请求去执行,其他请求等待。这就是互斥锁方案,业界也经常叫缓存重建锁。实现思路是在缓存失效时,先尝试获取一个分布式锁,拿到锁的请求去数据库拉数据、重建缓存;没拿到锁的请求可以短暂sleep后重新读缓存,或者直接把旧逻辑过期值返回。这里要特别注意死锁问题,锁必须设置过期时间,释放锁还要用Lua保证原子性,这部分到后面会细讲。
互斥锁方案有个小的变种叫"逻辑过期",思路是:Value在缓存里永远不设置物理过期时间,而是在Value里塞一个逻辑过期时间戳。每次读取时判断这个时间戳是否过期,如果没过期直接返回;如果过期了,先尝试拿分布式锁去后台重建缓存,同时把旧数据返回给调用方。这种方案的好处是热点Key根本没有"空窗期"——其他请求始终能拿到旧数据,缺点是旧数据会短暂返回给用户,对一致性要求高的场景不适合。
4.3 缓存雪崩的预防和降级,别只说"加随机值"
缓存雪崩最常见的触发原因有两个:一是批量Key同时过期,二是Redis实例宕机。
针对批量Key同时过期,最普及的做法是在设置缓存过期时间时加一个随机值,把过期时间打散。比如原本都是10分钟过期,现在每个Key设置成600秒加上一个随机秒数。我在项目里的写法是:
$ttl = 600 + random_int(0, 60); $redis->setex($cacheKey, $ttl, $value);但面试官如果只听到"加随机值",往往还会继续问:那如果Redis真的宕机了怎么办?这时候你至少得有以下几个层次的对策:首先要有高可用架构,Redis主从加哨兵,或者直接用集群方案,这是最基础的兜底;其次应用层要做降级策略,数据库连接池和调用方的超时时间、重试次数都需要限流,防止数据库被打爆;再有就是多级缓存,本地缓存(如PHP的APCu)作为Redis之上的一层缓存,即使Redis不可用,部分请求还能靠本地缓存顶住。
缓存这块面试还有个大热点是缓存与数据库一致性。经典的Cache Aside Pattern(旁路缓存模式)你应该能脱口而出:读的时候先读缓存,缓存没有就读数据库再写缓存;写的时候先更新数据库,再删除缓存。虽然业界对"到底先更DB还是先删缓存"有争议,但比较公认的结论是:Cache Aside模式下,选择先更新数据库,再删除缓存,比先删缓存再更新数据库更稳妥。具体原因是因为先删缓存后,在重写缓存之前如果有并发读进来,不仅会压垮数据库,还可能导致数据不一致。更进一步的"延迟双删"方案是用在要求比较严格的场景,先删缓存、更新DB、等几百毫秒再删一次缓存,就是为了解除并发窗口期的脏数据。
5. 分布式锁:从最原始SETNX到Redisson思路,逐层展开
5.1 SETNX加锁没问题,释放锁才是真正的坑点
分布式锁是Redis面试里的高阶考点。PHP项目场景最常见的锁是秒杀、防重复提交、任务调度防止多实例重复执行。比如同一个定时任务部署在两台机器上,到点后两台机器同时执行,如果没有分布式锁就会重复处理。用Redis做分布式锁的核心思想就是"多个进程争抢同一个Key,谁设置成功谁就拿到锁"。
最原始的实现方法是SETNX命令,也就是"如果Key不存在则设置"。但网上很多老文章里的写法有个大坑:先SETNX抢锁,抢到后再用EXPIRE设置过期时间。这两个操作不是原子的,如果进程在SETNX之后、EXPIRE之前崩溃了,这个Key就永远不删除,锁就永久死锁了。所以正确的姿势是用Redis 2.6.12之后提供的扩展SET命令,一步到位:
$redis = new Redis(); $redis->connect('127.0.0.1', 6379); $lockKey = 'lock:order:payment:10001'; $requestId = uniqid('', true); // 用唯一标识来区分是不是自己的锁 $lockAcquired = $redis->set($lockKey, $requestId, ['NX', 'EX' => 30]); if ($lockAcquired) { try { // 执行业务逻辑 } finally { // 释放锁:必须检查value是否是自己,再用Lua删除 $lua = <<<LUA if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end LUA; $redis->eval($lua, [$lockKey, $requestId], 1); } }这里两个细节是面试问得最多的。第一个是为什么释放锁要用Lua脚本?因为"判断value是否是自己的"和"删除Key"是两个操作,如果拆开,就有可能在判断完之后、删除之前,锁过期被别的请求抢到了,然后你把自己的和解锁操作把别人的锁删了。Lua脚本保证了这两个操作的原子性。第二个细节是为什么要给value设置一个唯一标识?因为如果不判断value,一个请求的锁过期后,另一个请求抢到了锁,第一个请求执行完之前的DEL会把第二个请求的锁意外删除,导致锁完全失效。用唯一标识做到"谁加的锁谁释放",是分布式锁的底线。
5.2 锁自动续期:Redisson看门狗的底层逻辑
上面的加锁方案有一个隐患:如果业务执行时间超过了锁的过期时间,比如锁设了30秒,但业务代码执行了60秒,30秒时锁就自动释放了,其他请求就能进来了,分布式锁就形同虚设。
业界最成熟的解法是Redisson的看门狗机制。Redisson加锁成功后,会启动一个后台定时任务,每隔锁过期时间的三分之一去检查一次锁是否还在,如果还在就自动把锁的过期时间续期。比如锁默认30秒过期,看门狗每10秒续期一次,把锁的生命周期延长到30秒。这样只要持有锁的进程还活着,锁就永远不会提前释放;进程一旦宕机,看门狗线程也会跟着停掉,锁到了过期时间自然释放,不会死锁。
PHP生态里虽然没有官方Redisson这么完整的客户端,但你可以自己实现一个简化版看门狗:用定时任务或者后台脚本定期执行EXPIRE命令给锁续期,比如每10秒执行一次EXPIRE,把锁的过期时间重新设置为30秒。不过自己做续期方案要小心,最简单可靠的还是"评估业务最大执行时间,把过期时间设得足够长",或者业务逻辑里手动保证"长任务不要在线程里同步执行"。面试的时候主动提到看门狗机制,能说明你对分布式锁的长期可用性有思考,这在候选人里算是加分项。
5.3 Redlock和"主从切换丢锁"这个争议题
聊到分布式锁,Nine成以上面试官会追一个问题:如果Redis是主从架构,主节点宕机了怎么办?锁存在主节点上,主从切换后从节点没有同步到这把锁,其他请求就能拿到锁了,锁就失效了。这里牵扯出当前业界对Redis分布式锁最经典的质疑之一。
Redlock算法的基本思路是:不依赖单台Redis节点,而是向多个相互独立的Redis节点依次尝试加锁,只有超过半数节点(N/2+1)加锁成功,才算真正拿到锁。这样即使某个节点发生故障,只要大部分节点还活着,锁就不会丢失。这个算法最早是Redis作者antirez提出来的,但后来分布式领域的大佬Martin Kleppmann专门写文章质疑过它,认为Redlock在遇到时钟跳跃、GC暂停这类情况时依然无法保证绝对安全。这个争论到今天都没有统一结论。
面试时被问到这个问题,正确的姿态不是表态"Redlock就一定行或一定不行",而是把两边的观点讲清楚:如果业务是资金支付这类强一致场景,需要考虑更可靠的分布式协调组件(比如ZooKeeper或etcd);如果只是普通的防重复提交、任务调度,单机Redis加锁加上合理的过期时间和续期已经够用了。这个回答展现的是工程判断力,比死记一个算法要值钱得多。
6. 主从复制与哨兵,以及Redis集群的基础认知
6.1 为什么Redis要做主从复制,全量同步和部分同步要分清
单机Redis一旦宕机,整个缓存都无法服务;即使有RDB/AOF持久化,重启恢复数据也需要时间,这期间服务还是不可用。所以生产环境Redis必须做高可用,第一步就是主从复制:一个主节点负责写,多个从节点负责读,主节点数据变更后同步到从节点。PHP项目里应用层读多写少,主从架构既能提高可用性,又能分担读压力。
主从复制的全量同步过程大概是:从节点发送PSYNC命令,主节点执行BGSAVE生成RDB快照,把快照发给从节点;从节点清空自己的旧数据并加载快照。这个过程中主节点产生的新写命令会存在复制缓冲区里,快照同步完之后再发给从节点执行。这部分面试官非常爱问"为什么从节点第一次连接主节点时,主节点要执行BGSAVE",答案就是因为从节点没有任何数据,必须先把全量数据拷过去。
除了全量同步,还有部分同步。如果主从之间的网络断开重连,Redis不会傻乎乎地重新全量复制,而是通过复制积压缓冲区(repl_backlog)来决定能不能只把断线期间的增量命令同步过去。如果从节点的偏移量还在缓冲区里,就做增量同步;如果缓冲区已经覆盖不了,就只能全量同步。这个机制平时不接触,但面试聊到主从复制时说出来,能明显体现你对同步机制的理解比一般人深。
6.2 哨兵和Cluster:高可用和水平扩展的正确答案
有了主从复制,如果主节点宕机了怎么办?写操作就不可用了。哨兵(Sentinel)解决的就是这个问题:哨兵节点会持续监控主节点和从节点的健康状态,一旦检测到主节点主观下线并且经过多数哨兵节点确认后,会执行故障转移——从从节点里选举一个新的主节点,并通知其他从节点和客户端更新主节点地址。
哨兵通常要部署奇数个节点(至少3个),因为需要超过半数确认才能判定主节点故障并执行自动切换。如果只有两个哨兵节点,其中一个宕机,剩下那个就没法形成多数派,无法完成故障转移。这个设计逻辑和分布式系统中的"多数派共识"是一脉相承的。
主从加哨兵解决的是可用性问题,但解决不了容量问题:单台Redis能承载的内存毕竟有限,读写性能也有上限。真正要水平扩展,需要Redis Cluster集群模式。Cluster采用数据分片的思路,把整个键空间划分成16384个哈希槽,每个节点负责一部分槽,通过一致性哈希的方式把Key映射到具体节点。当一个节点宕机,它负责的槽会被其他节点接管,高可用和扩展性都得到了保证。
面试的时候我建议你把主从、哨兵、Cluster的区别总结成一句话:主从复制解决的是数据冗余和读写分离,哨兵解决的是自动故障转移,Cluster解决的是数据分片和水平扩容。有这个大的框架感,后面不管怎么追问细节都不容易乱。
7. 一份能落地的Redis面试复盘清单
7.1 按考察层级梳理,从会用"升级"到会设计
准备Redis面试题,最忌讳的就是零散地刷各种面经。我自己复盘过几十场技术面试,Redis相关的问题通常会遵循一个由浅入深的层级结构,你按这个结构去准备,会高效得多。
第一层是能说出是什么:Redis是什么、为什么快、单线程模型、五种基础数据类型、常用命令、默认端口6379,这些东西很基础,但必须脱口而出。
第二层是能说出用过什么:你在项目里用Redis做过哪些事,比如缓存热点商品、Session共享、排行榜、分布式锁、异步队列。每个场景你都能说出Key怎么设计、数据怎么存取、为什么选Redis不选其他方案。
第三层是能说出原理:为什么Redis快,除了单线程还有IO多路复用和内存存储;为什么ZSet用跳表;RDB和AOF各自的工作流程;缓存过期删除策略怎么工作。这些原理不要求背诵源码,但核心机制要能讲清楚。
第四层是能说出坑和解决方案:缓存穿透、击穿、雪崩怎么防;主从延迟怎么处理;big key和热Key怎么发现怎么治理;分布式锁的安全边界在哪里。这层最能拉开差距,能把第四层讲清楚的人,基本可以认定是真正有生产环境经验的。
7.2 一组可以拿来练手的高频追问题
最后分享一套我在模拟面试时常用的追问链,建议你对着自测,在脑子里组织语言,最好能出声讲一遍,看能不能每层都接得住:
问题一:Redis有哪些数据类型,你们项目里各用在哪里?
追问:ZSet底层是什么数据结构?为什么用跳表不用红黑树?
问题二:你们用Redis做缓存,怎么保证缓存和数据库的一致性?
追问:为什么先更新数据库再删缓存?如果删缓存失败怎么办?
问题三:万一Redis缓存全挂了,服务会不会雪崩?
追问:主从切换的哨兵模式最少要几个节点?为什么?
问题四:怎么用Redis实现分布式锁?
追问:锁过期时间怎么定?如果业务还没执行完锁就过期了怎么办?
问题五:Redis持久化是怎么做的?
追问:AOF重写会不会阻塞主进程?为什么?
这五条追问链基本覆盖了PHP中级开发到高级开发对Redis的要求。你能不看资料把每条链顺下来,面试就稳了大半。我见过不少候选人,前面基础题答得不错,一到"你们项目里怎么用的"就露怯,问题通常就出在没把技术点和自己的真实业务建立连接。所以最后再强调一句:准备Redis面试题,最好的方式不是背题,而是打开自己的项目代码,找到每一处Redis调用的位置,问自己一句"这里为什么用它、换成别的行不行、出问题了怎么办",把这三个问题想明白,比刷一百道面经都有用。