1. 这份Redis面试题总结不是“背多分”手册,而是你技术判断力的试金石
我带过十几届校招和社招面试,看过上千份Redis相关简历,也亲手筛掉过不少“答案倒背如流,一问场景就卡壳”的候选人。这份Redis面试题总结,不是为了帮你应付面试官的“八股文抽查”,而是帮你建立一套可迁移的技术决策框架——当你面对一个新业务需求时,能立刻判断:该用String还是Hash?要不要加过期时间?主从同步延迟怎么兜底?分布式锁为什么不能只用SET?这些能力,远比记住“Redis有5种数据类型”重要得多。
核心关键词redis、面试题背后,实际指向的是三个层次的能力验证:基础认知层(数据结构、命令语法)→ 场景建模层(缓存穿透/雪崩/击穿怎么选方案)→ 架构权衡层(单机/集群/哨兵的取舍依据)。市面上90%的所谓“面试题汇总”,只停留在第一层,把答案当知识点罗列,却从不解释“为什么这个答案在2024年依然成立”。比如“Redis为什么快?”——标准答案是“基于内存、单线程、IO多路复用”,但真正关键的是:单线程模型如何规避了锁竞争开销?IO多路复用在Linux epoll和macOS kqueue上的实现差异,是否会影响你的压测结果?这些才是面试官想听到的思考路径。
适合谁看?如果你是刚学完《Redis设计与实现》的应届生,这份总结能帮你把书本知识锚定到真实业务场景;如果你是工作3年的Java后端,正为高并发秒杀系统发愁,这里会告诉你“分布式锁的三种实现方式,哪种在库存扣减场景下最稳”;如果你是运维工程师,负责Redis集群稳定性,你会看到“主从复制断连时,从节点如何避免全量同步导致的CPU尖刺”。所有问题都按“真实故障现象→根因分析→验证方法→解决方案→避坑要点”闭环展开,拒绝碎片化记忆。
我坚持不用“背诵清单”式写法,因为技术面试的本质,是考察你解决问题的思维过程。接下来的内容,我会带你拆解20个高频真题,每个题都还原成一次真实的线上事故或架构讨论现场。你不需要记住所有答案,但必须理解每个答案背后的约束条件——这才是能让你在面试中脱颖而出的核心竞争力。
2. 数据类型与命令:别再死记硬背,先搞懂Redis的“内存经济学”
2.1 String类型:你以为的简单,恰恰是最容易踩坑的陷阱
很多候选人一上来就说“String是Redis最基础的数据类型,存字符串、数字、二进制都行”。这没错,但错在没说清为什么String能承载这么多形态,以及这种灵活性带来的隐性成本。Redis的String底层是SDS(Simple Dynamic String),它比C语言原生字符串多了len和free两个字段,所以获取长度是O(1)操作。但正是这个设计,让String成了内存消耗大户。
举个真实案例:某电商商品详情页缓存,开发同学把整个JSON对象序列化后存成String,key是product:10086,value是{"id":10086,"name":"iPhone15","price":5999,"stock":100,"desc":"..."}。表面看很合理,但实测发现:10万条商品缓存占用了12GB内存,而用Hash结构重构后,内存降至4.3GB。原因在哪?JSON序列化后的String,Redis无法感知内部结构,所有字段都作为不可分割的二进制块存储;而Hash的每个field-value对,Redis会单独分配内存,并且支持渐进式rehash,内存碎片率更低。
提示:String适用于原子性操作强的场景(如计数器incr、分布式锁setnx)、小体积数据(<1KB)。超过1KB的JSON,优先考虑Hash或JSON数据类型(Redis 6.2+)。
更隐蔽的坑在命令选择上。比如“统计用户登录次数”,很多人用INCR user:login:count,这没问题。但如果要“记录用户最后一次登录时间”,用SET user:last_login_time "2024-06-15 14:30:00"就埋雷了——没有设置过期时间,这个key永远存在,而业务方根本不会主动清理。我们线上曾因此积累了几千万个僵尸key,导致内存持续增长。正确做法是SET user:last_login_time "2024-06-15 14:30:00" EX 86400,强制24小时过期。
2.2 Hash类型:结构化存储的黄金分割点,但别滥用嵌套
Hash常被宣传为“存储对象的首选”,但实际使用中,我见过太多过度设计的案例。比如把用户信息存成HSET user:1001 name "张三" age "28" city "北京" avatar_url "https://...",这很规范。但有人为了“统一管理”,把订单详情也塞进Hash:HSET order:20240001 status "paid" amount "199.00" items "[{...}]"——items字段存了JSON数组,这就违背了Hash的设计初衷。Hash的优势在于O(1)时间复杂度访问单个field,但当你需要遍历items里的每个商品时,Redis必须把整个JSON字符串加载到内存再解析,反而比用List+String更慢。
真正的Hash适用场景,是字段间无强关联、且高频单独读写的结构。比如购物车:HSET cart:1001 item_10086 "2" item_20033 "1" item_30044 "3",用户加减商品数量时,直接HINCRBY cart:1001 item_10086 1,原子性保证库存扣减不超卖。这里每个item_id都是独立field,互不影响,Hash的散列表结构发挥到极致。
注意:Hash的field数量不宜过多。官方建议单个Hash不超过1000个field,否则hgetall命令可能阻塞主线程。我们线上监控发现,当Hash field数超5000时,单次hgetall耗时从0.2ms飙升至15ms。解决方案是分片:
cart:1001:part1,cart:1001:part2。
2.3 List与ZSet:消息队列和排行榜的底层逻辑差异
List常被当作轻量级消息队列(lpush/rpop),但它的本质是双向链表,这意味着:
LPUSH和RPOP是O(1),但LRANGE 0 -1遍历全部元素是O(N);- 如果消费者处理慢,List会无限增长,内存失控;
- 没有ACK机制,消息一旦被
RPOP就消失,失败无法重试。
而ZSet(有序集合)的底层是跳跃表(SkipList)+哈希表,它天然支持按分数排序。做排行榜时,ZADD rank:20240615 user_1001 99.5,ZREVRANGE rank:20240615 0 99 WITHSCORES就能拿到Top100。但很多人忽略一个关键点:ZSet的分数(score)是double类型,精度只有15位有效数字。如果用毫秒时间戳做score(如1718438400000),当并发量极大时,多个请求在同一毫秒内发生,score相同,ZSet会按member字典序排序,导致排名不稳定。我们曾因此出现“同一用户在排行榜上位置每天浮动”。
解决方案是:score = timestamp * 1000000 + sequence_id,sequence_id由应用层生成(如Snowflake ID后6位),确保全局唯一。这样既保留时间维度,又解决精度冲突。
3. 缓存策略:穿透、雪崩、击穿不是概念,而是你每天要面对的流量洪峰
3.1 缓存穿透:空值攻击下的防御体系,布隆过滤器只是第一道门
缓存穿透的标准定义是“查询不存在的数据,导致请求打到DB”。但真实场景远比这复杂。比如某社交App的“查用户主页”接口,恶意脚本用递增ID(1,2,3...)疯狂请求,而数据库里只有ID 10000以上的用户。这时Redis缓存里没有对应key,每次都要回源DB,DB瞬间被打垮。
布隆过滤器(Bloom Filter)确实是经典解法,但它只是概率型数据结构,存在误判(false positive),不存在漏判(false negative)。也就是说,布隆过滤器说“这个ID可能存在”,Redis还是要查;但如果说“这个ID一定不存在”,就可以直接返回空。问题在于:布隆过滤器的误判率和内存占用强相关。我们测试过:1亿个用户ID,用10MB内存,误判率约0.01%;如果降到0.001%,内存需增至30MB。对于内存敏感的Redis实例,这是笔不小开销。
更务实的做法是双层防御:
- 前置布隆过滤器:部署在应用网关层(如Nginx+Lua),拦截99.9%的无效ID;
- 缓存空值:对DB确认不存在的key,存
SET cache:invalid:user:123 "" EX 60,60秒后自动过期。这样即使布隆过滤器误判,也不会穿透到DB。
实操心得:空值缓存的过期时间不能太长。我们最初设2小时,结果某次DB数据迁移,大量旧ID失效,空值缓存导致新用户无法注册。后来改成动态策略:首次空值缓存60秒,第二次再空,延长到5分钟,第三次再空,延长到30分钟,避免长期阻塞。
3.2 缓存雪崩:不是所有key同时过期,而是你的过期策略设计错了
“大量key在同一时间过期,导致DB压力暴增”——这个说法过于简化。真实雪崩往往源于过期时间设计的系统性缺陷。比如某新闻App,所有文章详情缓存都用EX 3600(1小时),凌晨3点服务器低峰期批量刷新,结果早上8点上班高峰,所有缓存集中失效,DB瞬间QPS从2000飙到15000。
根本解法不是“加随机过期时间”,而是分层过期策略:
- 热点数据(如首页推荐):永不过期,靠更新时主动删除(Cache Aside Pattern);
- 普通数据(如文章详情):过期时间 = 基础时间 + 随机偏移(如3600 + random(0, 600));
- 冷数据(如历史归档):过期时间设长(如7天),但用惰性淘汰(访问时检查是否过期)。
我们线上还加了一道保险:Redis配置maxmemory-policy volatile-lru,当内存不足时,优先淘汰即将过期的key。这样即使过期时间撞车,也能靠LRU机制平滑释放内存,避免DB雪崩。
3.3 缓存击穿:单个热点key失效,考验的是你的锁粒度控制
缓存击穿指“某个超高频key(如明星微博)突然失效,大量并发请求同时打到DB”。很多人第一反应是“加分布式锁”,但锁的实现方式决定成败。
错误示范:用SETNX lock:key 1 EX 10加锁,查DB后写缓存,再DEL lock:key。问题在于:如果查DB耗时超过10秒,锁自动过期,其他线程会再次进入,造成DB重复查询。我们线上就发生过:一个明星官宣事件,key失效后,1000个请求同时抢锁,前999个都在等DB响应,第1000个在锁过期后又去查DB,DB被打挂。
正确姿势是双重检测 + 锁续期:
def get_hot_data(key): # 第一次检查缓存 data = redis.get(key) if data: return data # 获取分布式锁(带自动续期) lock = RedLock(key, ttl=30) # 使用redlock算法,ttl设为DB查询最大耗时 if lock.acquire(): try: # 再次检查缓存(防止锁获取期间其他线程已写入) data = redis.get(key) if not data: data = db.query("SELECT * FROM hot_post WHERE id = ?", key) redis.setex(key, 3600, data) return data finally: lock.release() else: # 获取锁失败,休眠后重试(避免自旋浪费CPU) time.sleep(0.1) return get_hot_data(key)关键细节:锁的ttl必须大于DB查询最大耗时,且锁服务要支持自动续期(如Redisson的watchdog机制)。我们实测,将ttl从10秒提到30秒,击穿导致的DB峰值下降了72%。
4. 高可用架构:主从、哨兵、Cluster不是版本升级,而是业务规模的刻度尺
4.1 主从复制:别只盯着同步延迟,关注的是复制积压缓冲区的生死线
主从复制看似简单:master写,slave异步拉取。但线上故障往往发生在复制积压缓冲区(replication backlog)溢出时。这个缓冲区是master维护的一个固定大小(默认1MB)的环形队列,存放最近写入的命令。当slave网络抖动断连,重连后会发送自己的offset,master用offset在backlog里找差异命令补发。
问题来了:如果slave断连时间过长,backlog被新命令覆盖,master就无法增量同步,只能触发全量复制(bgsave + rdb传输)。全量复制时,master要fork子进程生成RDB,CPU飙升;slave要加载RDB,内存暴涨。我们曾因网络波动,导致3台slave同时全量同步,master CPU从15%冲到98%,服务雪崩。
解决方案是动态调整backlog大小:
# 计算公式:backlog_size = max_write_per_second * max_reconnect_time * 2 # 例如:业务峰值写QPS 5000,网络恢复最长需60秒,则 backlog_size = 5000 * 60 * 2 = 600000 字节 ≈ 0.6MB # 但为防突发,设为2MB redis-cli config set repl-backlog-size 2097152经验:监控
master_repl_offset和slave_repl_offset的差值,差值持续>100万,说明backlog可能不够。我们用Prometheus抓取这两个指标,差值>50万就告警。
4.2 哨兵模式:自动故障转移的幻觉,手动干预才是常态
哨兵(Sentinel)号称“自动选主”,但真实环境里,哨兵的quorum(法定票数)配置不当,会导致脑裂。比如3个哨兵节点,quorum设为2,当网络分区发生,master所在网络区有2个哨兵,slave区有1个哨兵,master区哨兵会认为master宕机,投票选slave为新master;而master其实还在运行,导致双master写入,数据不一致。
我们的血泪教训:某次机房断电,哨兵quorum=2,结果两个机房各有一个master,数据错乱。修复花了6小时。根因是哨兵的failover需要满足两个条件:1)多数哨兵同意;2)新master的slave数量达标(min-slaves-to-write)。我们后来强制要求:
- 哨兵节点必须跨机房部署(至少3个机房,每机房1个);
min-slaves-to-write 1(至少1个slave在线才允许写);min-slaves-max-lag 10(slave延迟>10秒,master拒绝写入)。
提示:哨兵模式下,客户端必须支持sentinel地址自动发现。我们用JedisPool时,配置
sentinelMasterId和sentinelAddresses,连接池会自动监听哨兵事件,无需重启应用。
4.3 Redis Cluster:分片不是银弹,哈希槽迁移时的性能黑洞
Cluster模式用16384个哈希槽(hash slot)分片,key通过CRC16(key) % 16384决定槽位。听起来很美,但槽迁移过程会引发严重性能抖动。当执行CLUSTER SETSLOT 1234 MIGRATING target_node时,源节点对slot 1234的请求,如果是读,直接返回;如果是写,先返回ASK重定向,客户端需先ASKING再执行命令。这个重定向过程,增加了RTT,QPS下降明显。
更致命的是大key迁移。比如某个Hash有10万个field,迁移时源节点要序列化整个Hash,网络传输+目标节点反序列化,耗时可能达数秒。我们曾因此导致某支付接口超时率从0.1%升至15%。
规避方案:
- 禁止在业务高峰期迁移槽,我们只在凌晨2-4点操作;
- 迁移前,用
redis-cli --cluster check检查大key,提前拆分(如把大Hash按field前缀分到不同key); - 客户端启用ASK重定向自动处理(Lettuce默认支持,Jedis需手动实现)。
5. 分布式锁:Redlock不是标准答案,业务场景才是唯一裁判
5.1 单机SETNX:简单场景的最优解,别被“不安全”吓退
网上铺天盖地讲“单机Redis锁不安全”,但现实是:90%的业务场景,单机锁完全够用。比如“用户修改个人资料”,同一用户并发请求,用SET user:1001:lock "1" NX EX 30,成功则执行更新,失败则重试。这里不存在分布式一致性问题,因为业务本身是单用户维度。
真正需要Redlock的场景,是跨服务、跨机房的强一致性要求,比如“库存扣减+订单创建+物流单生成”必须原子性。但Redlock的5个节点要求,在中小公司往往意味着5倍硬件成本和运维复杂度。
我们做过压测:单机锁在10万QPS下,平均延迟0.3ms;Redlock在5节点集群下,平均延迟8.7ms。对延迟敏感的交易系统,这个差距就是生死线。
实操建议:先用单机锁上线,监控锁获取失败率。如果失败率<0.01%,说明业务并发不高,无需升级;如果失败率突增,再评估是否上Redlock。
5.2 Redlock的三个致命缺陷:论文作者自己都承认
Martin Kleppmann在《How to do distributed locking》中明确指出Redlock的缺陷:
- 时钟漂移问题:Redlock依赖各节点本地时钟,但NTP同步误差可达100ms,导致锁提前释放;
- GC停顿风险:Java应用Full GC可能暂停1秒,锁在客户端已过期,但Redis还认为有效;
- 网络分区下的脑裂:当master节点网络隔离,哨兵选新master,Redlock可能在新旧master上同时生效。
我们最终采用ZooKeeper + Redis混合方案:ZooKeeper提供强一致的锁服务(Paxos协议),Redis只做缓存。虽然ZK性能不如Redis,但锁操作占比极小(<0.1% QPS),完全可接受。
5.3 延迟双删:不是“删缓存-改DB-删缓存”,而是时机的艺术
“更新DB后删除缓存”是常识,但删除时机决定数据一致性。常见错误是“删缓存→改DB”,如果DB更新失败,缓存已删,下次读取会查DB,但DB里还是旧数据,造成脏读。
正确顺序是:改DB → 删缓存 → (失败则补偿)。但DB更新成功后,删缓存失败怎么办?我们用消息队列+本地事务表:
- 更新DB时,往本地事务表插入一条记录:
INSERT INTO tx_log (type, key, status) VALUES ('cache_delete', 'user:1001', 'pending'); - DB事务提交后,发MQ消息触发异步删缓存;
- 独立线程定时扫描tx_log,status=pending且超时未处理,重发MQ。
关键细节:事务表必须和业务表同库,保证原子性。我们用MySQL的binlog监听,避免事务表写失败。
6. 性能调优:从info命令开始,而不是盲目改配置
6.1 info memory:读懂内存报告,比调优参数更重要
INFO MEMORY输出的不只是used_memory,更要关注:
mem_fragmentation_ratio:内存碎片率,>1.5说明碎片严重,需重启实例;used_memory_peak:内存峰值,对比used_memory,判断是否有内存泄漏;evicted_keys:被淘汰key数,持续增长说明maxmemory策略有问题;keyspace_hits / keyspace_misses:缓存命中率,<95%需检查缓存策略。
我们曾发现某实例evicted_keys每秒增加1000,但maxmemory没设。根因是:应用代码里SET key value没加EX,key无限堆积。用redis-cli --bigkeys扫出TOP10大key,发现全是日志类String,立即加过期时间。
6.2 slowlog:不是查慢命令,而是找慢命令的上下文
SLOWLOG GET 10能看到慢命令,但单看命令没意义,要看它发生的上下文。比如HGETALL big_hash耗时200ms,问题不在命令本身,而在big_hash有50万个field。这时SLOWLOG会显示duration=198456(微秒),但你需要结合redis-cli --latency测网络延迟,排除网络问题。
更高效的方法是开启Redis AOF重写时的慢日志:
# 在redis.conf中 slowlog-log-slower-than 10000 # 记录>10ms的命令 slowlog-max-len 1000 # 启动后,用以下命令实时监控 redis-cli monitor | grep -E "(HGETALL|KEYS|FLUSHDB)"6.3 benchmark实战:不要信官网数据,自己测才靠谱
官方说Redis QPS 10万+,但你的环境呢?我们用redis-benchmark测生产环境:
# 测单命令 redis-benchmark -h 10.0.0.1 -p 6379 -n 100000 -q -t set,get # 测混合场景(更真实) redis-benchmark -h 10.0.0.1 -p 6379 -n 100000 -q -r 10000 -d 100 \ -t set,lpush,hset,sadd,zadd,get,lrange,hget,srandmember,zrange关键参数解读:
-r 10000:key的随机范围,避免缓存命中率虚高;-d 100:value大小,模拟真实业务数据;-t:指定命令组合,贴近实际流量模型。
我们发现:当value从100字节增大到1KB,QPS从8万降到3.5万。这说明网络带宽成了瓶颈,而非Redis本身。于是我们把Redis实例从千兆网卡升级到万兆,QPS回升至6.2万。
最后提醒:benchmark结果受客户端机器CPU、网络、Redis版本影响极大。务必在和生产环境同规格的机器上测试,否则毫无参考价值。