中间件 - 缓存 - 架构师面试题库
侧重Redis核心原理、缓存架构设计、一致性方案、高可用集群、性能优化,考察候选人在缓存领域的实战深度。
一、Redis核心原理(1-25题)
1. 🔵 Redis为什么这么快?单线程模型是如何实现高性能的?
答:Redis的高性能来源于多个因素的叠加:
- 纯内存操作:数据存储在内存中,读写速度是磁盘的10万倍以上。
- 单线程模型:避免了多线程的锁竞争、上下文切换开销。单线程顺序执行命令,没有并发问题。
- IO多路复用:使用epoll/kqueue实现非阻塞IO,单线程可以同时处理数万个客户端连接。
- 高效的数据结构:SDS(Simple Dynamic String)、ziplist、quicklist、skiplist、intset等,针对不同场景优化。
- 单线程避免CPU缓存失效:数据在同一个CPU核心的缓存中,命中率高。
单线程的含义(Redis 6.0+):
- 命令执行是单线程的(保证原子性)。
- 网络IO可以是多线程的(io-threads配置,默认关闭)。Redis 6.0+支持多线程IO,网络读写由多个IO线程处理,命令执行仍然是单线程。
- 后台任务是多线程的:持久化(RDB/AOF)、惰性删除(lazyfree)、集群通信等由后台线程处理。
性能数据:单实例Redis可以达到10万+ QPS(简单命令),实际生产中通常在5-8万QPS。
2. 🔴 Redis的数据结构底层实现是怎样的?String、List、Hash、Set、ZSet分别用了什么编码?
答:Redis的每种数据类型都有多种底层编码,根据数据量和元素大小自动切换。
String:
- int:值是整数且在long范围内,直接存储整数值(不分配SDS)。
- embstr:字符串长度≤44字节,SDS和RedisObject在一块连续内存中分配(一次内存分配,缓存友好)。
- raw:字符串长度>44字节,SDS和RedisObject分开分配(两次内存分配)。
List:
- listpack(Redis 7.0+,替代ziplist):元素少且小时使用。连续内存,紧凑存储。
- quicklist:元素多时使用。双向链表,每个节点是一个listpack(压缩列表)。兼顾了链表的灵活性和紧凑存储的内存效率。
Hash:
- listpack:field数量≤hash-max-listpack-entries(默认128)且每个field/value长度≤hash-max-listpack-value(默认64字节)。
- hashtable:超过阈值后转为哈希表。
Set:
- intset:所有元素都是整数且数量≤set-max-intset-entries(默认512)。有序整数数组,二分查找。
- listpack(Redis 7.2+):非整数元素但数量少时使用。
- hashtable:超过阈值后转为哈希表。
ZSet(Sorted Set):
- listpack:元素数量≤zset-max-listpack-entries(默认128)且每个元素长度≤zset-max-listpack-value(默认64字节)。
- skiplist + hashtable:超过阈值后使用跳表(按score排序,支持范围查询)+ 哈希表(按member查找score,O(1))。
3. 🔵 什么是Redis的SDS(Simple Dynamic String)?它和C语言的char*有什么区别?
答:SDS是Redis自己实现的字符串类型,解决了C字符串的多个问题。
SDS结构(Redis 3.2+有多种类型:sdshdr5/8/16/32/64):
1 | struct sdshdr8 { |
与C字符串的区别:
| 特性 | C字符串(char*) | SDS |
|---|---|---|
| 获取长度 | O(n)遍历到\0 | O(1)直接读len |
| 缓冲区溢出 | 不检查,可能溢出 | 自动扩容,安全 |
| 修改时内存分配 | 每次修改都可能重新分配 | 预分配+惰性释放 |
| 二进制安全 | 不安全(\0截断) | 安全(按len读取) |
| 兼容C函数 | 是 | 是(buf末尾有\0) |
内存预分配策略:
- 修改后len < 1MB:分配2*len+1字节(翻倍)。
- 修改后len >= 1MB:分配len+1MB+1字节(每次多分配1MB)。
- 减少频繁修改时的内存重新分配次数。
惰性空间释放:缩短字符串时不立即释放多余空间(alloc不变),后续追加时可以直接使用。通过sdsRemoveFreeSpace()手动释放。
4. 🔴 Redis的跳表(Skip List)是如何实现的?为什么ZSet用跳表而不用红黑树?
答:跳表是ZSet的核心数据结构,支持O(log n)的查找、插入、删除和范围查询。
跳表结构:
- 多层有序链表。最底层包含所有元素,每上一层是下一层的”快速通道”。
- 每个节点随机决定层数(概率p=0.25,最大层数32)。
- 查找:从最高层开始,向右查找直到下一个节点大于目标值,然后下降一层继续查找。平均时间复杂度O(log n)。
Redis跳表的实现细节:
1 | typedef struct zskiplistNode { |
为什么用跳表而不用红黑树:
- 范围查询高效:ZRANGEBYSCORE等范围操作,跳表找到起始节点后沿链表顺序遍历即可。红黑树需要中序遍历,实现复杂。
- 实现简单:跳表的插入删除比红黑树简单得多(不需要旋转和变色)。Redis作者antirez明确表示选择跳表是因为实现简单。
- 内存局部性:跳表的节点在内存中可以更紧凑(虽然不如数组,但比红黑树的指针跳转好)。
- 并发友好:跳表更容易实现无锁并发(虽然Redis是单线程,但这是跳表的通用优势)。
- 排名计算:Redis跳表的span字段可以O(log n)计算元素排名(ZRANK),红黑树需要额外维护子树大小。
代价:跳表的空间开销比红黑树大(每个节点平均1.33个指针,红黑树是2个指针但节点更紧凑)。
5. 🔵 Redis的内存淘汰策略有哪些?LRU和LFU的区别是什么?
答:当Redis内存达到maxmemory限制时,根据淘汰策略决定如何处理新写入。
淘汰策略(maxmemory-policy):
- noeviction(默认):不淘汰,写入操作返回错误(OOM)。读操作正常。
- allkeys-lru:从所有key中淘汰最近最少使用的key。最常用。
- volatile-lru:只从设置了过期时间的key中淘汰LRU key。
- allkeys-lfu(Redis 4.0+):从所有key中淘汰最不经常使用的key。
- volatile-lfu:只从设置了过期时间的key中淘汰LFU key。
- allkeys-random:从所有key中随机淘汰。
- volatile-random:从设置了过期时间的key中随机淘汰。
- volatile-ttl:淘汰剩余TTL最短的key。
Redis的近似LRU(非精确LRU):
- 精确LRU需要维护全局链表,内存和CPU开销大。
- Redis采用采样淘汰:随机采样maxmemory-samples个key(默认5),淘汰其中最久未访问的。
- 每个key用24bit记录最后访问时间(lru字段),精度约为10秒。
- 采样数越大越接近精确LRU,但CPU开销越大。
LRU vs LFU:
- LRU(Least Recently Used):淘汰最久未访问的key。问题:偶尔被访问一次的冷数据不会被淘汰(如全表扫描导致大量冷数据被访问一次)。
- LFU(Least Frequently Used):淘汰访问频率最低的key。Redis的LFU使用Morris计数器(概率计数,8bit存储频率)+ 衰减机制(lfu-decay-time,默认1分钟衰减一半)。更适合区分热数据和冷数据。
生产建议:大多数场景用allkeys-lru。如果有明显的热点数据,用allkeys-lfu更优。
6. 🔴 Redis的持久化机制有哪些?RDB和AOF的原理和区别是什么?
答:Redis提供RDB和AOF两种持久化机制,以及混合持久化。
RDB(Redis Database):
- 原理:将内存中的数据快照保存为二进制文件(dump.rdb)。
- 触发方式:
- 手动:SAVE(阻塞主线程)、BGSAVE(fork子进程,不阻塞)。
- 自动:配置save规则(如save 900 1,900秒内至少1次修改则触发BGSAVE)。
- fork + Copy-on-Write:BGSAVE时fork子进程,子进程共享父进程的内存页。父进程继续处理请求,修改数据时触发COW(只复制被修改的内存页)。
- 优点:文件紧凑、恢复速度快、对性能影响小(子进程处理)。
- 缺点:可能丢失最后一次快照后的数据(分钟级数据丢失)。fork大内存实例时可能短暂阻塞。
AOF(Append Only File):
- 原理:将每个写命令追加到AOF文件末尾。恢复时重放所有命令。
- 写入策略(appendfsync):
- always:每个命令都fsync到磁盘。最安全但最慢。
- everysec(默认):每秒fsync一次。最多丢失1秒数据。推荐。
- no:由OS决定何时fsync。最快但可能丢失较多数据。
- AOF重写(BGREWRITEAOF):AOF文件过大时,fork子进程重写AOF(只保留最终状态的命令,去除冗余)。重写期间新命令写入AOF缓冲区,重写完成后追加到新AOF文件。
- 优点:数据安全性高(最多丢失1秒)。
- 缺点:文件比RDB大、恢复速度比RDB慢。
混合持久化(Redis 4.0+,aof-use-rdb-preamble=yes):
- AOF重写时,先写入RDB格式的快照,再追加增量的AOF命令。
- 恢复时先加载RDB部分(快),再重放AOF部分(少量命令)。
- 兼顾了RDB的恢复速度和AOF的数据安全性。推荐使用。
7. 🔵 Redis的过期键删除策略是怎样的?惰性删除和定期删除是如何配合的?
答:Redis使用惰性删除 + 定期删除两种策略配合处理过期键。
惰性删除(Lazy Expiration):
- 访问key时检查是否过期,过期则删除并返回nil。
- 优点:不浪费CPU(只在访问时检查)。
- 缺点:如果过期key一直不被访问,永远不会被删除,浪费内存。
定期删除(Active Expiration):
- Redis每100ms执行一次(hz配置,默认10,即每秒10次):
- 从设置了过期时间的key中随机采样20个。
- 删除其中已过期的key。
- 如果过期key比例>25%,重复步骤1(说明过期key很多,需要继续清理)。
- 每次执行时间不超过25ms(避免阻塞主线程太久)。
- 优点:主动清理过期key,避免内存浪费。
- 缺点:可能有延迟(过期key不会立即被删除)。
内存淘汰兜底:
- 如果惰性删除和定期删除都没有及时清理过期key,当内存达到maxmemory时,内存淘汰策略会清理key(包括已过期但未被删除的key)。
注意事项:
- 大量key同时过期可能导致Redis短暂卡顿(定期删除集中执行)。建议给过期时间加随机偏移(如TTL + random(0, 300)秒)。
- 从库不主动删除过期key(等待主库的DEL命令同步)。Redis 3.2之前从库可能返回已过期的key,3.2+从库会检查过期时间返回nil(但不删除)。
8. 🔴 Redis的主从复制原理是什么?全量复制和增量复制的流程是怎样的?
答:Redis主从复制实现数据冗余和读写分离。
全量复制(首次同步或复制积压缓冲区不足时):
- 从库发送PSYNC ? -1(首次)或PSYNC runid offset(断线重连)。
- 主库判断需要全量复制,返回FULLRESYNC runid offset。
- 主库执行BGSAVE生成RDB快照,同时将新的写命令缓存到复制缓冲区(replication buffer)。
- 主库将RDB文件发送给从库。
- 从库清空旧数据,加载RDB文件。
- 主库将复制缓冲区中的命令发送给从库执行。
- 同步完成,进入增量复制阶段。
增量复制(正常运行时):
- 主库执行写命令后,将命令传播给所有从库。
- 从库执行收到的命令,保持与主库数据一致。
断线重连的增量复制(PSYNC 2.0,Redis 4.0+):
- 主库维护复制积压缓冲区(repl-backlog-size,默认1MB),环形缓冲区存储最近的写命令。
- 从库断线重连时发送PSYNC runid offset。
- 如果offset在积压缓冲区范围内,主库发送缺失的命令(增量复制)。
- 如果offset不在范围内(断线太久),触发全量复制。
PSYNC 2.0改进:
- 支持从库重启后的增量复制(从库将runid和offset持久化到RDB文件)。
- 支持主库故障转移后的增量复制(从库记录主库和自己的replid,新主库可以识别)。
关键配置:
repl-backlog-size:积压缓冲区大小。建议根据写入速率和可能的断线时间计算:size = 写入速率(MB/s) × 断线时间(s) × 2。repl-diskless-sync:无盘复制。主库直接将RDB通过Socket发送给从库,不写入磁盘。适合磁盘慢但网络快的场景。
9. 🔵 Redis Sentinel(哨兵)的工作原理是什么?如何实现自动故障转移?
答:Redis Sentinel是Redis的高可用方案,监控主从节点,自动故障转移。
Sentinel架构:
- 多个Sentinel节点(建议3个或5个,奇数个)组成Sentinel集群。
- Sentinel监控主库和从库的健康状态。
- 主库故障时自动选举新主库并通知客户端。
故障检测:
- 主观下线(SDOWN):单个Sentinel认为节点不可用(超过down-after-milliseconds未响应PING)。
- 客观下线(ODOWN):多个Sentinel(≥quorum个)都认为主库不可用。只有主库才有客观下线。
故障转移流程:
- Sentinel集群通过Raft协议选举一个Leader Sentinel执行故障转移。
- Leader Sentinel从从库中选择新主库:
- 排除不健康的从库(断线、响应慢)。
- 优先级最高的(replica-priority最小的)。
- 复制偏移量最大的(数据最新的)。
- runid最小的(兜底)。
- Leader Sentinel向选中的从库发送SLAVEOF NO ONE,使其成为新主库。
- 向其他从库发送SLAVEOF new-master,让它们复制新主库。
- 将旧主库标记为从库(旧主库恢复后自动成为新主库的从库)。
- 通过Pub/Sub通知客户端主库地址变更。
客户端连接:
- 客户端连接Sentinel获取主库地址(SENTINEL get-master-addr-by-name)。
- 客户端订阅Sentinel的+switch-master频道,感知主库切换。
- Jedis/Lettuce等客户端库内置Sentinel支持,自动处理故障转移。
10. 🔴 Redis Cluster的原理是什么?数据分片和节点通信是如何实现的?
答:Redis Cluster是Redis的分布式方案,支持数据分片和高可用。
数据分片:
- 哈希槽(Hash Slot):共16384个槽(0-16383),每个key通过CRC16(key) % 16384映射到一个槽。
- 每个主节点负责一部分槽。如3个主节点:Node1(0-5460)、Node2(5461-10922)、Node3(10923-16383)。
- Hash Tag:key中用{}包裹的部分作为哈希计算的输入。如{user:1}.name和{user:1}.age映射到同一个槽,可以在同一个节点上执行多key操作。
节点通信(Gossip协议):
- 每个节点维护集群状态(节点列表、槽分配、节点状态)。
- 节点间通过Gossip协议交换信息:每秒随机选择几个节点发送PING消息,携带自己知道的部分节点信息。
- 消息类型:PING/PONG(心跳和信息交换)、MEET(新节点加入)、FAIL(节点故障通知)。
- 最终一致:通过Gossip协议,所有节点最终会收敛到一致的集群状态。
请求路由:
- 客户端发送命令到任意节点。
- 如果key在该节点负责的槽中,直接处理。
- 如果不在,返回MOVED重定向:
MOVED 3999 127.0.0.1:6381,客户端重新发送到正确节点。 - 智能客户端(如Jedis Cluster、Lettuce)缓存槽→节点的映射,直接发送到正确节点,避免重定向。
故障转移:
- 节点间通过PING/PONG检测故障。超过cluster-node-timeout未响应则标记为PFAIL(疑似故障)。
- 多数主节点都标记某节点为PFAIL时,标记为FAIL(确认故障)。
- 故障主节点的从节点自动发起选举(需要多数主节点投票),成为新主节点接管槽。
11. 🔵 Redis Cluster的槽迁移是如何实现的?迁移过程中如何保证数据一致性?
答:槽迁移用于集群扩缩容时重新分配槽。
迁移流程:
- 目标节点设置槽状态为IMPORTING:
CLUSTER SETSLOT <slot> IMPORTING <source-node-id>。 - 源节点设置槽状态为MIGRATING:
CLUSTER SETSLOT <slot> MIGRATING <target-node-id>。 - 逐个迁移key:
CLUSTER GETKEYSINSLOT <slot> <count>获取源节点该槽的key列表。MIGRATE <target-ip> <target-port> <key> 0 <timeout>将key从源节点迁移到目标节点(原子操作:源节点发送key到目标节点,目标节点确认后源节点删除key)。
- 所有key迁移完成后,通知所有节点更新槽分配:
CLUSTER SETSLOT <slot> NODE <target-node-id>。
迁移过程中的请求处理:
- 客户端请求源节点:
- key还在源节点:正常处理。
- key已迁移到目标节点:返回ASK重定向(
ASK <slot> <target-ip>:<target-port>)。客户端先发送ASKING命令到目标节点,再发送实际命令。
- ASK vs MOVED:ASK是临时重定向(只对当前请求),MOVED是永久重定向(更新客户端的槽映射缓存)。
注意事项:
- 迁移是逐key进行的,大key迁移可能阻塞源节点(MIGRATE是同步操作)。
- 迁移过程中不要执行涉及多个槽的命令(如MGET跨槽key)。
- 使用redis-cli –cluster reshard自动化迁移过程。
- 迁移速度取决于网络带宽和key大小,大规模迁移建议在低峰期执行。
12. 🔴 什么是Redis的大Key问题?如何检测和处理大Key?
答:大Key:单个key的value占用内存过大(如String > 10KB,集合类型元素 > 1万个)。
大Key的危害:
- 阻塞主线程:读取/删除大Key耗时长,阻塞其他命令。如DEL一个包含百万元素的Hash可能阻塞数秒。
- 网络带宽:大Key的读取占用大量网络带宽,影响其他请求。
- 内存不均:Cluster模式下大Key导致节点内存不均衡。
- 持久化影响:大Key的序列化/反序列化耗时长,影响RDB和AOF。
- 过期删除:大Key过期时的删除操作阻塞主线程。
检测方法:
- redis-cli –bigkeys:扫描所有key,找出每种类型最大的key。使用SCAN命令,不阻塞。
- redis-cli –memkeys:扫描所有key,按内存占用排序(Redis 4.0+,需要开启memory-usage)。
- MEMORY USAGE key:查看单个key的内存占用。
- RDB分析工具:redis-rdb-tools解析RDB文件,离线分析所有key的大小。
- 监控:通过slowlog发现耗时长的命令,排查是否涉及大Key。
处理方案:
- 拆分大Key:将大Hash拆分为多个小Hash(如user:1:basic、user:1:detail)。将大List拆分为多个小List(按时间或ID范围分段)。
- 压缩value:对String类型的value进行压缩(gzip/snappy),读取时解压。
- 异步删除:使用UNLINK代替DEL(Redis 4.0+),UNLINK在后台线程异步删除。或开启lazyfree-lazy-expire=yes,过期key自动异步删除。
- 渐进式删除:对集合类型,使用HSCAN/SSCAN/ZSCAN逐批删除元素,最后删除key。
- 避免产生大Key:设计时控制集合大小,超过阈值时分片存储。
13. 🔵 什么是Redis的热Key问题?如何检测和解决?
答:热Key:某个key被大量请求访问,导致该key所在的Redis节点成为瓶颈。
热Key的危害:
- 单节点CPU和网络带宽打满。
- Cluster模式下负载不均(热Key所在节点过载,其他节点空闲)。
- 极端情况下导致节点宕机,引发雪崩。
检测方法:
- redis-cli –hotkeys:使用LFU淘汰策略时可用,扫描找出访问频率最高的key。
- MONITOR命令:实时监控所有命令(生产环境慎用,性能影响大)。采样分析高频key。
- 代理层统计:如果使用Redis代理(如Twemproxy、Codis),在代理层统计key的访问频率。
- 客户端统计:在Redis客户端SDK中埋点,统计key的访问频率。
- Redis slowlog + 业务日志分析。
解决方案:
- 本地缓存(L1 Cache):在应用层增加本地缓存(Caffeine/Guava Cache),热Key优先从本地缓存读取。减少对Redis的请求。注意:本地缓存的一致性问题(TTL短一些,或通过MQ通知失效)。
- 读写分离:热Key的读请求分散到多个从库。
- Key分片:将热Key拆分为多个子Key(如hotkey:1、hotkey:2…hotkey:N),读取时随机选择一个子Key。写入时更新所有子Key。
- Cluster模式下的副本读取:
READONLY命令允许从Cluster的从节点读取数据。 - 代理层缓存:在Redis代理层缓存热Key的数据。
14. 🔴 Redis的内存碎片是什么?如何检测和解决?
答:内存碎片:Redis实际使用的物理内存大于存储数据所需的逻辑内存。
产生原因:
- 频繁修改不同大小的value:Redis使用jemalloc内存分配器,按固定大小的内存块分配(8/16/32/64/…字节)。如果value从100字节修改为200字节,原来的128字节块释放,分配256字节块。释放的128字节块可能无法被其他数据使用。
- 大量删除key:删除key后释放的内存块可能不连续,无法合并为大块。
- 数据类型转换:如Hash从listpack转为hashtable,内存布局变化。
检测:
INFO memory命令:used_memory:Redis数据占用的逻辑内存。used_memory_rss:Redis进程占用的物理内存(包含碎片)。mem_fragmentation_ratio= used_memory_rss / used_memory。- ratio > 1.5:碎片严重,需要处理。
- ratio < 1:说明使用了swap,性能严重下降,需要立即处理。
解决方案:
- 自动碎片整理(Redis 4.0+):
activedefrag yes:开启自动碎片整理。- Redis在后台扫描内存,将数据从碎片化的内存块移动到连续的内存块,释放碎片。
- 控制参数:
active-defrag-threshold-lower 10(碎片率>10%时开始整理)、active-defrag-cycle-min 1(最少使用1%的CPU)、active-defrag-cycle-max 25(最多使用25%的CPU)。
- 重启Redis:重启后从RDB/AOF恢复数据,内存重新分配,碎片消除。简单粗暴但有效。
- 预防:尽量使用固定大小的value,减少频繁修改value大小的操作。
15. 🔵 Redis的Pipeline是什么?它和事务(MULTI/EXEC)有什么区别?
答:Pipeline:将多个命令打包一次性发送给Redis,减少网络往返次数(RTT)。
Pipeline原理:
- 正常模式:每个命令一次RTT(发送命令→等待响应→发送下一个命令)。
- Pipeline模式:一次性发送N个命令,Redis依次执行并将N个响应一次性返回。N个命令只需要1次RTT。
- 性能提升:如果RTT是1ms,100个命令正常需要100ms,Pipeline只需要1ms+执行时间。
Pipeline vs 事务(MULTI/EXEC):
| 维度 | Pipeline | 事务(MULTI/EXEC) |
|---|---|---|
| 原子性 | 不保证(命令之间可能插入其他客户端的命令) | 保证(EXEC时所有命令连续执行) |
| 网络优化 | 是(减少RTT) | 是(命令缓存到EXEC时一起执行) |
| 错误处理 | 每个命令独立,部分失败不影响其他 | 语法错误全部不执行,运行时错误不回滚 |
| 回滚 | 不支持 | 不支持(Redis事务不支持回滚) |
| WATCH | 不支持 | 支持(乐观锁) |
Pipeline + 事务:可以在Pipeline中使用MULTI/EXEC,既减少RTT又保证原子性。
注意事项:
- Pipeline的命令数量不宜过多(建议100-1000个),过多会占用大量内存(Redis需要缓存所有响应)。
- Pipeline中的命令不能依赖前一个命令的结果(因为是批量发送的)。
- Cluster模式下,Pipeline中的命令必须在同一个节点上(使用Hash Tag保证)。
16. 🔴 Redis的Lua脚本有什么作用?它和事务相比有什么优势?
答:Redis支持在服务端执行Lua脚本,保证多个命令的原子性执行。
Lua脚本的优势(相比事务):
- 真正的原子性:Lua脚本执行期间不会被其他命令打断(单线程保证)。事务虽然也是原子执行,但不支持在命令之间做逻辑判断。
- 支持逻辑控制:Lua脚本可以使用if/else、循环等逻辑,根据中间结果决定后续操作。事务不支持。
- 减少网络往返:多个命令在服务端一次执行,只需要一次RTT。
- 复用:EVALSHA缓存脚本,多次调用只传SHA1摘要。
常见使用场景:
- 分布式锁:加锁和设置过期时间原子执行。
- 限流:检查计数器并递增原子执行。
- 库存扣减:检查库存并扣减原子执行。
- 复杂的条件更新:根据当前值决定是否更新。
示例(限流脚本):
1 | -- KEYS[1]: 限流key, ARGV[1]: 限流阈值, ARGV[2]: 窗口时间(秒) |
注意事项:
- Lua脚本执行时间不宜过长(阻塞主线程)。
lua-time-limit默认5秒,超时后其他客户端的命令会返回BUSY错误。 - Lua脚本中不要使用随机函数或时间函数(影响主从一致性)。Redis 7.0+的Function支持更灵活的脚本管理。
- Cluster模式下,Lua脚本中访问的所有key必须在同一个槽(使用Hash Tag)。
17. 🔴 什么是Redis的慢查询?如何排查和优化Redis的性能问题?
答:慢查询:执行时间超过阈值的命令。
慢查询配置:
slowlog-log-slower-than:慢查询阈值(微秒),默认10000(10ms)。生产建议设为1000(1ms)。slowlog-max-len:慢查询日志最大条数,默认128。建议设为1000+。SLOWLOG GET [count]:查看慢查询日志。SLOWLOG LEN:慢查询日志条数。SLOWLOG RESET:清空慢查询日志。
常见性能问题和优化:
O(n)命令:KEYS *、HGETALL(大Hash)、SMEMBERS(大Set)、LRANGE 0 -1(大List)。
- 优化:用SCAN代替KEYS,用HSCAN代替HGETALL,限制集合大小。
大Key操作:读取/删除大Key阻塞主线程。
- 优化:拆分大Key,使用UNLINK异步删除。
频繁的全量持久化:BGSAVE的fork操作在大内存实例上可能阻塞数百毫秒。
- 优化:减少BGSAVE频率,使用AOF+混合持久化。控制实例内存大小(建议<10GB)。
内存不足导致swap:Redis使用swap后性能下降100倍以上。
- 优化:监控used_memory_rss,确保物理内存充足。设置maxmemory。
网络延迟:客户端和Redis之间的网络延迟。
- 优化:同机房部署,使用Pipeline减少RTT。
CPU竞争:Redis与其他进程竞争CPU。
- 优化:Redis绑定CPU核心(taskset),避免与CPU密集型进程共存。
AOF重写:AOF重写期间fork和写入磁盘可能影响性能。
- 优化:
no-appendfsync-on-rewrite yes,重写期间不fsync(可能丢失少量数据)。
- 优化:
排查工具:
INFO命令:查看Redis各项指标。LATENCY命令(Redis 2.8.13+):延迟监控和分析。redis-cli --latency:测量客户端到Redis的延迟。redis-cli --intrinsic-latency:测量系统固有延迟。
18. 🔵 什么是Redis的发布订阅(Pub/Sub)?它有什么局限性?
答:Redis Pub/Sub:消息发布订阅机制,Publisher发送消息到Channel,所有订阅该Channel的Subscriber收到消息。
基本使用:
1 | # 订阅 |
局限性:
- 消息不持久化:消息发送后如果没有订阅者在线,消息丢失。不像MQ有消息存储。
- 不支持消息确认:Publisher不知道消息是否被Subscriber成功处理。
- 不支持消费者组:所有Subscriber收到所有消息(广播模式),不支持负载均衡。
- 不支持消息回溯:无法消费历史消息。
- Cluster模式下的问题:PUBLISH命令会在所有节点间广播,消耗集群内部带宽。
- 客户端断线丢消息:Subscriber断线期间的消息全部丢失。
替代方案:Redis Stream(Redis 5.0+)解决了Pub/Sub的大部分局限性。
19. 🔴 什么是Redis Stream?它和Pub/Sub以及Kafka有什么区别?
答:Redis Stream(5.0+)是Redis内置的消息队列数据结构,支持消息持久化、消费者组、消息确认。
核心特性:
- 消息持久化:消息存储在Stream中,不会因为没有消费者而丢失。
- 消费者组(Consumer Group):多个消费者分摊消费消息(负载均衡)。
- 消息确认(ACK):消费者处理完消息后ACK,未ACK的消息可以被重新消费。
- 消息ID:每条消息有唯一ID(时间戳-序号),支持范围查询。
- 消息回溯:可以从任意ID开始消费历史消息。
与Pub/Sub对比:
| 维度 | Pub/Sub | Stream |
|---|---|---|
| 持久化 | 不持久化 | 持久化 |
| 消费者组 | 不支持 | 支持 |
| 消息确认 | 不支持 | 支持(XACK) |
| 历史消息 | 不支持 | 支持(XRANGE) |
| 消息丢失 | 断线丢失 | 不丢失 |
与Kafka对比:
| 维度 | Redis Stream | Kafka |
|---|---|---|
| 定位 | 轻量级消息队列 | 分布式流平台 |
| 吞吐量 | 万级QPS | 百万级QPS |
| 持久化 | 内存+RDB/AOF | 磁盘(顺序写) |
| 分区 | 不支持(单Stream单节点) | 支持(Partition) |
| 消费者组 | 支持 | 支持 |
| 适用场景 | 轻量级异步任务、简单消息队列 | 大数据、日志、流处理 |
适用场景:不想引入Kafka/RocketMQ等重量级MQ,只需要简单的消息队列功能(如异步任务、事件通知)。
20. 🔴 Redis的事务为什么不支持回滚?WATCH的乐观锁机制是怎样的?
答:Redis事务(MULTI/EXEC)不支持回滚,这是Redis作者的设计决策。
不支持回滚的原因(antirez的解释):
- Redis命令只会因为语法错误或类型错误失败:如对String执行LPUSH。这类错误是编程错误,不应该在生产环境出现。
- 回滚增加复杂度:支持回滚需要记录undo日志,增加内存和CPU开销,与Redis的简单高效理念矛盾。
- Redis的定位:Redis不是关系数据库,不需要ACID事务。
Redis事务的行为:
- MULTI:开始事务,后续命令入队(不立即执行)。
- EXEC:执行所有入队的命令。
- 语法错误(编译期):如命令拼写错误,EXEC时所有命令都不执行。
- 运行时错误:如对String执行LPUSH,该命令失败但其他命令正常执行(不回滚)。
WATCH乐观锁:
- WATCH key1 key2…:监视一个或多个key。
- 在EXEC之前,如果被WATCH的key被其他客户端修改,EXEC返回nil(事务取消)。
- 实现原理:每个被WATCH的key关联一个版本号(或修改标记)。EXEC时检查版本号是否变化。
使用示例(CAS操作):
1 | WATCH balance |
注意:WATCH + MULTI/EXEC在高并发下可能频繁失败重试。对于复杂的原子操作,推荐使用Lua脚本。
21. 🔵 什么是Redis的内存优化?有哪些减少Redis内存使用的技巧?
答:Redis内存优化从数据结构选择、编码优化、配置调整三个层面入手。
数据结构优化:
- 使用Hash代替多个String:存储对象时,用一个Hash存储所有字段,比多个String节省内存(Hash在元素少时使用listpack,非常紧凑)。
- 使用intset/listpack编码:控制集合元素数量和大小在阈值内,保持紧凑编码。
- 短key名:key名越短越省内存。如用
u:1:n代替user:1:name(但要平衡可读性)。 - 使用位操作:BITMAP适合存储大量布尔值(如用户签到、在线状态)。每个用户只占1bit。
- 使用HyperLogLog:统计UV等基数估算场景,固定占用12KB,无论数据量多大。
编码优化:
- 控制Hash/Set/ZSet的元素数量:保持在listpack阈值内(hash-max-listpack-entries等配置)。listpack比hashtable/skiplist节省50%+内存。
- 使用整数值:整数可以使用int编码(不分配SDS),比字符串省内存。
- 压缩value:对大value进行gzip/snappy压缩后存储。
配置优化:
- maxmemory:设置内存上限,配合淘汰策略。
- activedefrag:开启自动碎片整理。
- lazyfree-lazy-expire/eviction/server-del:开启异步删除,减少主线程阻塞。
内存分析工具:
INFO memory:整体内存使用情况。MEMORY USAGE key:单个key的内存占用。MEMORY DOCTOR:内存问题诊断建议。- redis-rdb-tools:离线分析RDB文件中每个key的内存占用。
22. 🔴 Redis 7.0有哪些重要的新特性?Function和Lua Script有什么区别?
答:Redis 7.0的重要新特性:
Redis Function:替代EVAL/EVALSHA的新脚本机制。
- 函数持久化:函数存储在Redis中(随RDB/AOF持久化),不需要每次启动时重新加载。
- 库管理:函数组织在Library中,支持版本管理。
- 与Lua Script区别:
维度 Lua Script (EVAL) Function 持久化 不持久化(重启后丢失) 持久化(随数据持久化) 管理 通过SHA1引用 通过函数名引用 组织 无组织结构 Library→Function层级 主从复制 脚本本身不复制 函数定义复制到从库 引擎 只支持Lua 可扩展(目前只有Lua)
Multi-part AOF:AOF文件拆分为多个文件(base文件 + 增量文件 + manifest文件)。重写时只替换base文件,增量文件追加。避免了单个大AOF文件的问题。
listpack全面替代ziplist:所有使用ziplist的地方改为listpack。listpack解决了ziplist的级联更新问题(ziplist修改一个元素可能导致后续所有元素重新分配)。
Client-side Caching改进:支持广播模式(Broadcasting),服务端主动推送key失效通知。
Sharded Pub/Sub:Cluster模式下的分片Pub/Sub,消息只在负责该Channel的节点间传播(不再全集群广播),大幅减少集群内部带宽。
ACL v2:更细粒度的权限控制,支持Selector(多组权限规则)。
23. 🔵 什么是Redis的Client-side Caching?它是如何实现的?
答:Client-side Caching:客户端在本地缓存Redis数据,减少网络请求。Redis服务端在数据变更时通知客户端失效。
实现方式(Redis 6.0+):
Tracking模式(默认):
- 客户端开启Tracking:
CLIENT TRACKING ON。 - 客户端读取key时,Redis记录”这个客户端缓存了这个key”。
- 当key被修改时,Redis发送失效通知(Invalidation Message)给客户端。
- 客户端收到通知后删除本地缓存。
- 客户端开启Tracking:
广播模式(Broadcasting):
CLIENT TRACKING ON BCAST PREFIX user:- 客户端订阅key前缀,任何匹配前缀的key变更都会通知。
- 不需要Redis记录每个客户端缓存了哪些key(节省服务端内存)。
- 可能收到不需要的通知(客户端没缓存但前缀匹配的key)。
OPTIN模式:
CLIENT TRACKING ON OPTIN- 客户端在读取key前发送
CLIENT CACHING YES,只有显式标记的key才被追踪。 - 更精确,减少不必要的追踪开销。
通知机制:
- 使用RESP3协议的Push消息(Redis 6.0+)。
- 或通过Pub/Sub的
__redis__:invalidate频道(兼容RESP2)。
Lettuce/Jedis支持:Lettuce 6.0+原生支持Client-side Caching。
适用场景:读多写少的热数据(如配置信息、用户Profile),本地缓存命中率高时效果显著。
24. 🔴 Redis的IO多线程模型(Redis 6.0+)是怎样的?它和Memcached的多线程有什么区别?
答:Redis 6.0引入多线程IO,但命令执行仍然是单线程。
Redis多线程IO模型:
- 主线程接收客户端连接,将连接分配给IO线程。
- IO线程负责读取客户端请求数据(网络读)。
- 主线程单线程执行所有命令(保证原子性和线程安全)。
- IO线程负责将响应数据写回客户端(网络写)。
配置:
1 | io-threads 4 # IO线程数(建议CPU核心数的一半,不超过8) |
为什么命令执行仍然是单线程:
- Redis的数据结构不是线程安全的,多线程执行需要加锁,锁的开销可能抵消多线程的收益。
- 单线程执行保证了命令的原子性,简化了编程模型。
- Redis的瓶颈通常在网络IO而非CPU(命令执行很快),多线程IO已经足够。
与Memcached多线程对比:
| 维度 | Redis 6.0+ | Memcached |
|---|---|---|
| 线程模型 | IO多线程 + 命令单线程 | 完全多线程(IO和命令都多线程) |
| 数据结构 | 不需要锁 | 需要锁(CAS等) |
| 原子性 | 天然保证 | 需要CAS保证 |
| 性能 | 单实例10-20万QPS | 单实例数十万QPS |
| 复杂度 | 简单 | 较复杂(锁竞争) |
性能提升:开启多线程IO后,Redis的吞吐量提升约1倍(从10万QPS到20万QPS),主要收益在网络IO密集的场景。
25. 🔵 什么是Redis的ACL(Access Control List)?如何实现细粒度的权限控制?
答:Redis ACL(6.0+):细粒度的用户权限控制,替代旧版本的单一密码认证(requirepass)。
ACL功能:
- 用户管理:创建多个用户,每个用户独立的密码和权限。
- 命令权限:控制用户可以执行哪些命令。如只允许读命令,禁止FLUSHALL等危险命令。
- Key权限:控制用户可以访问哪些key(按模式匹配)。如只允许访问
user:*的key。 - Channel权限(Redis 7.0+):控制用户可以订阅哪些Pub/Sub Channel。
配置示例:
1 | # 创建用户:只能执行GET/SET命令,只能访问app:*的key |
命令分类(@category):
@read:所有读命令(GET、HGET、SMEMBERS等)。@write:所有写命令(SET、HSET、SADD等)。@admin:管理命令(CONFIG、SHUTDOWN等)。@dangerous:危险命令(KEYS、FLUSHALL、DEBUG等)。@slow:可能阻塞的命令。
生产最佳实践:
- 禁用default用户或设置强密码。
- 为不同应用创建独立用户,最小权限原则。
- 禁止所有用户执行KEYS、FLUSHALL、FLUSHDB、CONFIG等危险命令。
- ACL规则持久化到aclfile(
aclfile /etc/redis/users.acl)。
二、缓存架构设计(26-50题)
26. 🔵 什么是缓存穿透、缓存击穿、缓存雪崩?分别如何解决?
答:这是缓存架构中最经典的三个问题。
缓存穿透:查询不存在的数据,缓存和数据库都没有,每次请求都打到数据库。
- 原因:恶意攻击(大量随机ID查询)、业务bug。
- 解决方案:
- 缓存空值:查询数据库为空时,缓存一个空值(TTL短,如5分钟)。防止同一个不存在的key反复查询数据库。
- 布隆过滤器:在缓存前加一层布隆过滤器,存储所有合法的key。不在布隆过滤器中的key直接拒绝。
- 参数校验:在入口层校验参数合法性(如ID必须为正整数)。
缓存击穿:热点key过期的瞬间,大量请求同时打到数据库。
- 原因:热点key过期 + 高并发。
- 解决方案:
- 互斥锁:缓存未命中时,用分布式锁(SETNX)保证只有一个线程查询数据库并回填缓存,其他线程等待。
- 逻辑过期:不设置TTL,在value中存储逻辑过期时间。发现逻辑过期后异步更新缓存,当前请求返回旧数据。
- 热点key永不过期:对确定的热点key不设置过期时间,通过后台任务定期更新。
缓存雪崩:大量key同时过期,或Redis宕机,大量请求打到数据库。
- 解决方案:
- 过期时间加随机值:避免大量key同时过期。TTL = base + random(0, 300)。
- 多级缓存:L1(本地缓存)+ L2(Redis)+ L3(数据库)。Redis不可用时本地缓存兜底。
- 熔断降级:数据库压力过大时触发熔断,返回默认值或错误提示。
- Redis高可用:Sentinel或Cluster保证Redis不宕机。
27. 🔴 什么是缓存和数据库的一致性问题?有哪些保证一致性的方案?
答:缓存和数据库的数据不一致是分布式系统中的经典问题。
不一致的场景:
- 先更新数据库,再删除缓存:删除缓存失败,缓存中是旧数据。
- 先删除缓存,再更新数据库:删除缓存后、更新数据库前,另一个请求读取数据库旧数据写入缓存。
常见方案:
Cache Aside Pattern(旁路缓存,最常用):
- 读:先读缓存,未命中则读数据库,写入缓存。
- 写:先更新数据库,再删除缓存(不是更新缓存)。
- 为什么删除而非更新:避免并发写入时缓存和数据库不一致(两个线程同时更新,缓存可能存储先更新的旧值)。
- 问题:极端情况下仍可能不一致(读请求在数据库更新前读到旧值,在缓存删除后写入缓存)。概率很低(需要读请求的数据库查询比写请求的数据库更新慢)。
延迟双删:
- 先删除缓存→更新数据库→延迟N毫秒→再次删除缓存。
- 第二次删除清理可能被并发读请求写入的旧缓存。
- 延迟时间 > 读请求的数据库查询时间。
- 问题:延迟时间难以精确确定。
基于binlog的异步更新(推荐):
- 写操作只更新数据库。
- 通过Canal/Debezium监听binlog,异步删除/更新缓存。
- 优点:业务代码不需要操作缓存(解耦),binlog保证不丢失。
- 缺点:有短暂的不一致窗口(binlog传播延迟)。
Read/Write Through:
- 缓存层封装数据库操作。读未命中时缓存层自动从数据库加载。写操作由缓存层同步写入数据库。
- 优点:业务代码只操作缓存,逻辑简单。
- 缺点:需要缓存中间件支持(如Redis本身不支持,需要自己封装)。
Write Behind(异步写回):
- 写操作只更新缓存,缓存层异步批量写入数据库。
- 优点:写入性能极高。
- 缺点:缓存宕机可能丢数据。适合允许少量数据丢失的场景(如计数器)。
生产建议:大多数场景用Cache Aside + 合理的TTL。对一致性要求高的场景加上binlog异步更新。
28. 🔵 什么是多级缓存架构?如何设计L1+L2+L3的缓存体系?
答:多级缓存:在不同层级设置缓存,逐级降低对下游的压力。
典型架构:
1 | 客户端 → CDN(静态资源) → Nginx本地缓存 → 应用本地缓存(L1) → Redis(L2) → 数据库(L3) |
各层设计:
L1(应用本地缓存):
- 技术:Caffeine(Java最快的本地缓存)、Guava Cache。
- 特点:内存级访问,纳秒级延迟。容量有限(受JVM堆内存限制)。
- 适用:热点数据、变化不频繁的数据(如配置、字典)。
- TTL:较短(10秒-5分钟),避免数据不一致。
- 一致性:通过Redis Pub/Sub或MQ通知其他实例失效。
L2(分布式缓存 - Redis):
- 特点:毫秒级延迟,容量大(集群可达TB级)。
- 适用:大部分业务数据。
- TTL:中等(5分钟-24小时)。
- 高可用:Sentinel或Cluster。
L3(数据库):
- 兜底数据源,保证数据完整性。
多级缓存的挑战:
- 一致性:多级缓存的失效顺序和时机需要仔细设计。通常先失效L1再失效L2。
- 缓存穿透放大:L1未命中→L2未命中→数据库。需要在每一层都有防穿透措施。
- 监控复杂:需要监控每一层的命中率、延迟、容量。
开源方案:
- J2Cache(红薯开源):L1(Caffeine/Ehcache)+ L2(Redis),内置缓存同步。
- Jetcache(阿里开源):支持多级缓存、自动刷新、统计。
29. 🔴 什么是布隆过滤器(Bloom Filter)?Redis中如何使用?有什么局限性?
答:布隆过滤器:概率型数据结构,用于判断元素是否存在于集合中。可能误判(false positive),但不会漏判(no false negative)。
原理:
- 一个m位的位数组(初始全0)和k个哈希函数。
- 添加元素:用k个哈希函数计算k个位置,将这些位置设为1。
- 查询元素:用k个哈希函数计算k个位置,如果所有位置都是1则”可能存在”,如果有任何位置是0则”一定不存在”。
Redis中使用布隆过滤器:
- RedisBloom模块(推荐):Redis官方模块,提供BF.ADD、BF.EXISTS等命令。
1 | # 创建布隆过滤器(错误率0.01,预期容量100万) |
- 基于BITMAP手动实现:使用SETBIT/GETBIT命令操作位数组,应用层实现多个哈希函数。
应用场景:
- 缓存穿透防护:将所有合法key加入布隆过滤器,查询前先检查。
- 去重:爬虫URL去重、消息ID去重。
- 推荐系统:过滤用户已看过的内容。
局限性:
- 误判率:存在false positive(判断存在但实际不存在)。误判率取决于位数组大小和哈希函数数量。
- 不支持删除:标准布隆过滤器不支持删除元素(删除可能影响其他元素)。Counting Bloom Filter支持删除但占用更多空间。Cuckoo Filter支持删除且空间效率更高。
- 容量固定:创建时需要预估容量,超过容量后误判率急剧上升。Scalable Bloom Filter可以动态扩容。
30. 🔵 什么是Redis的分布式锁?如何正确实现?有哪些常见的坑?
答:分布式锁:在分布式系统中保证同一时刻只有一个进程执行某个操作。
基本实现:
1 | # 加锁(原子操作:SET + NX + EX) |
关键要素:
- 互斥性:NX保证只有一个客户端能加锁。
- 防死锁:EX设置过期时间,客户端崩溃后锁自动释放。
- 防误删:value使用唯一标识(UUID),解锁时检查value是否匹配,防止删除别人的锁。
- 原子性:加锁用SET NX EX(一条命令),解锁用Lua脚本。
常见的坑:
- 锁过期但业务未完成:业务执行时间超过锁的过期时间,锁被自动释放,其他客户端获取锁,导致并发问题。
- 解决:Redisson的看门狗机制(WatchDog),后台线程定期续期锁(默认每10秒续期到30秒)。
- Redis主从切换丢锁:客户端在Master上加锁成功,Master宕机,Slave升级为Master但未同步到锁数据。另一个客户端在新Master上加锁成功。
- 解决:RedLock算法(见下题)。
- 可重入性:同一个线程多次加锁需要支持重入。
- 解决:Redisson的RLock支持可重入(内部用Hash记录加锁次数)。
- 非阻塞:SETNX失败立即返回,不等待。需要阻塞等待的场景需要自己实现重试。
- 解决:Redisson的tryLock(waitTime, leaseTime, unit)支持等待。
31. 🔴 什么是RedLock算法?它真的能解决分布式锁的问题吗?Martin Kleppmann的批评是什么?
答:RedLock是Redis作者antirez提出的分布式锁算法,使用多个独立的Redis实例提高锁的可靠性。
RedLock算法:
- 获取当前时间T1。
- 依次向N个独立的Redis实例(建议5个)发送加锁请求(SET NX EX)。
- 如果在超过N/2+1个实例上加锁成功,且总耗时 < 锁的过期时间,则加锁成功。
- 锁的有效时间 = 过期时间 - 加锁耗时。
- 如果加锁失败,向所有实例发送解锁请求。
Martin Kleppmann的批评(”How to do distributed locking”):
- 时钟跳跃问题:RedLock依赖各节点的时钟大致同步。如果某个节点时钟跳跃(NTP调整),锁可能提前过期。
- GC停顿问题:客户端获取锁后发生长时间GC停顿,锁过期后其他客户端获取锁,GC恢复后两个客户端都认为自己持有锁。
- 本质问题:如果需要强一致的分布式锁,应该使用基于共识协议的系统(如ZooKeeper、etcd),而非Redis。
antirez的回应:
- 时钟跳跃可以通过配置NTP避免大幅跳跃。
- GC停顿问题在任何分布式锁实现中都存在(包括ZooKeeper)。
- RedLock在实际场景中足够可靠。
实际生产建议:
- 效率型锁(防止重复执行,允许偶尔失败):单Redis实例的SETNX就够了,简单高效。
- 正确性型锁(绝对不能并发执行):使用ZooKeeper或etcd的分布式锁(基于共识协议,更可靠)。
- RedLock在大多数场景下是过度设计。Redisson的单实例锁 + 看门狗续期已经能满足99%的需求。
32. 🔵 Redisson是什么?它提供了哪些分布式数据结构?
答:Redisson是Redis的Java客户端,提供了丰富的分布式数据结构和服务。
核心功能:
分布式锁:
- RLock:可重入锁,支持看门狗自动续期。
- RReadWriteLock:读写锁。
- RFairLock:公平锁(按请求顺序获取)。
- RMultiLock:联锁(多个锁同时获取)。
- RRedLock:RedLock算法实现。
- RSemaphore:分布式信号量。
- RCountDownLatch:分布式CountDownLatch。
分布式集合:
- RMap:分布式Map(支持本地缓存、淘汰策略)。
- RSet/RSortedSet:分布式Set。
- RList/RQueue/RDeque:分布式List/Queue。
- RBloomFilter:分布式布隆过滤器。
- RHyperLogLog:分布式HyperLogLog。
分布式服务:
- RRemoteService:分布式远程服务调用。
- RScheduledExecutorService:分布式定时任务。
- RMapCache:带过期时间的分布式Map。
- RTopic:分布式发布订阅。
其他:
- RateLimiter:分布式限流器。
- RAtomicLong:分布式原子计数器。
- RBitSet:分布式位图。
看门狗机制(WatchDog):
- 加锁时不指定leaseTime,Redisson自动启动看门狗。
- 看门狗每lockWatchdogTimeout/3(默认10秒)检查一次,如果锁还被持有则续期到lockWatchdogTimeout(默认30秒)。
- 客户端宕机后看门狗停止,锁在30秒后自动释放。
33. 🔴 如何设计一个高并发的计数器系统?Redis在其中如何使用?
答:高并发计数器(如微博点赞数、文章阅读数、商品库存)的设计。
方案1:Redis INCR(简单场景)
- 直接使用Redis的INCR/DECR命令,原子操作,单实例10万+ QPS。
- 适合:精确计数、实时性要求高。
- 问题:Redis宕机可能丢失数据(取决于持久化策略)。
方案2:Redis + 异步持久化(推荐)
- 计数操作写入Redis(INCR),定时任务将Redis中的计数同步到数据库。
- 读取优先从Redis读,Redis不可用时从数据库读。
- 同步策略:每N秒或每N次变更同步一次。
- 优点:高性能 + 数据不丢失(最多丢失一个同步周期的数据)。
方案3:Redis HyperLogLog(近似计数)
- 适合UV统计等不需要精确值的场景。
- 固定12KB内存,误差率0.81%。
- PFADD添加元素,PFCOUNT获取基数估算。
方案4:Redis Bitmap(布尔计数)
- 适合”用户是否已操作”的场景(如签到、点赞去重)。
- SETBIT user:1:likes 1001 1(用户1001点赞)。
- BITCOUNT user:1:likes(统计点赞数)。
库存扣减的特殊处理:
1 | -- 原子扣减库存(Lua脚本) |
注意:库存扣减需要保证Redis和数据库的一致性。推荐:Redis预扣减(快速响应)+ 异步同步数据库 + 对账机制兜底。
34. 🔵 如何设计一个基于Redis的排行榜系统?
答:排行榜是Redis ZSet(Sorted Set)的经典应用场景。
基本设计:
1 | # 更新分数 |
复杂排行榜设计:
多维度排序:分数相同时按时间排序。
- 方案:score = 主分数 * 10^10 + (MAX_TIMESTAMP - 时间戳)。分数相同时,时间早的排在前面。
分页查询:
- ZREVRANGEBYSCORE + LIMIT实现分页。
- 或ZREVRANGE + offset实现(大offset性能差,O(offset+count))。
实时排行榜 + 历史排行榜:
- 实时:直接操作ZSet。
- 历史:定时快照(ZUNIONSTORE或导出到数据库)。
- 日榜/周榜/月榜:不同的ZSet key(leaderboard:daily:20240101)。
大规模排行榜(亿级用户):
- 单个ZSet不宜过大(百万级以内)。
- 分段排行榜:按分数段分片(0-1000分一个ZSet,1001-2000分一个ZSet)。
- 或只维护Top N(如Top 10000),其他用户的排名通过分数估算。
附近排名:获取某用户前后N名的用户。
- ZREVRANK获取排名 → ZREVRANGE获取排名范围内的用户。
35. 🔴 如何设计一个基于Redis的分布式限流系统?
答:限流是保护系统的重要手段,Redis适合实现分布式限流。
限流算法:
- 固定窗口计数器:
1 | # 每分钟限制100次 |
- 问题:窗口边界突发(如第59秒100次+第60秒100次=1秒内200次)。
- 滑动窗口计数器:
1 | -- 使用ZSet实现滑动窗口 |
- 令牌桶(Token Bucket):
- 以固定速率向桶中添加令牌,请求消耗令牌。桶满时令牌溢出。
- 允许突发流量(桶中有积累的令牌)。
- Redis实现:记录上次添加令牌的时间和当前令牌数,每次请求时计算应该添加的令牌数。
- 漏桶(Leaky Bucket):
- 请求进入桶中,以固定速率流出处理。桶满时拒绝新请求。
- 平滑流量,不允许突发。
Redisson限流器:
1 | RRateLimiter limiter = redisson.getRateLimiter("myLimiter"); |
生产建议:
- 简单场景用固定窗口(实现简单)。
- 精确限流用滑动窗口或令牌桶。
- 多维度限流:用户级 + 接口级 + 全局级,不同维度不同阈值。
36. 🔵 什么是缓存预热?有哪些缓存预热的策略?
答:缓存预热:系统启动或缓存失效后,提前将热点数据加载到缓存中,避免冷启动时大量请求打到数据库。
预热策略:
启动时预热:
- 应用启动时从数据库加载热点数据到Redis。
- 适合:数据量不大、热点数据可预知的场景。
- 实现:Spring的@PostConstruct或ApplicationRunner。
定时预热:
- 定时任务定期刷新缓存(如每5分钟刷新热点商品缓存)。
- 适合:数据变化不频繁、可以接受短暂不一致的场景。
访问驱动预热(Lazy Loading):
- 第一次访问时从数据库加载并缓存。
- 适合:热点数据不可预知的场景。
- 问题:冷启动时第一批请求延迟高。
基于日志的预热:
- 分析历史访问日志,找出热点数据,提前加载。
- 适合:大促前的预热(分析历史大促的访问模式)。
渐进式预热:
- 新上线的缓存节点不立即承担全部流量。
- 通过权重控制,逐步增加流量比例(如10%→30%→50%→100%)。
- 适合:缓存集群扩容场景。
CDC驱动预热:
- 通过数据库CDC(binlog)实时将数据变更同步到缓存。
- 适合:需要缓存和数据库实时同步的场景。
注意事项:
- 预热不要一次性加载太多数据,避免Redis内存突增和数据库压力。
- 分批加载,控制加载速率。
- 预热数据设置合理的TTL,避免缓存中存储过期数据。
37. 🔴 如何设计一个基于Redis的分布式Session管理系统?
答:分布式Session:将用户Session存储在Redis中,实现多实例共享Session。
基本设计:
- key:
session:{sessionId}(sessionId由服务端生成,通过Cookie传递给客户端)。 - value:Hash类型,存储Session属性(userId、loginTime、权限等)。
- TTL:Session过期时间(如30分钟),每次访问时续期。
Spring Session + Redis实现:
1 |
|
设计要点:
- Session ID安全:使用安全随机数生成(SecureRandom),防止猜测。
- Session固定攻击防护:登录成功后重新生成Session ID。
- Session数据最小化:只存储必要信息(userId、角色),不存储大对象。
- 过期策略:绝对过期(创建后30分钟过期)+ 滑动过期(每次访问续期30分钟)。
- 序列化:使用JSON序列化(可读性好、跨语言)而非Java序列化(安全风险、不跨语言)。
高可用:
- Redis Sentinel或Cluster保证Session存储的高可用。
- 多数据中心:Session数据通过Redis复制同步到多个数据中心。
与JWT对比:
| 维度 | Redis Session | JWT |
|---|---|---|
| 存储 | 服务端(Redis) | 客户端(Token) |
| 注销 | 删除Redis中的Session | 需要黑名单机制 |
| 扩展性 | 依赖Redis | 无状态,天然可扩展 |
| 安全性 | Session ID泄露风险 | Token泄露风险 |
| 数据大小 | 不限 | Token不宜过大 |
38. 🔵 什么是Redis的GEO功能?如何实现附近的人/商家功能?
答:Redis GEO(3.2+):基于ZSet实现的地理位置功能,支持存储经纬度和距离计算。
底层实现:使用GeoHash算法将二维经纬度编码为一维整数,存储在ZSet的score中。GeoHash保证地理位置相近的点编码也相近。
基本命令:
1 | # 添加位置 |
“附近的人”功能设计:
- 用户更新位置时:
GEOADD user_locations lng lat userId。 - 查询附近的人:
GEOSEARCH user_locations FROMLONLAT lng lat BYRADIUS 5 km ASC COUNT 20。 - 过滤:结合用户属性(性别、年龄)在应用层过滤。
- 位置更新频率:不需要实时更新,每30秒-1分钟更新一次即可。
注意事项:
- GEO数据存储在ZSet中,数据量大时(百万级)单个key可能成为大Key。按区域分片(如按城市)。
- GEOSEARCH的时间复杂度是O(N+log(M)),N是范围内的元素数,M是ZSet总元素数。
- GEO不支持多边形范围查询(只支持圆形和矩形),复杂地理围栏需要PostGIS或Elasticsearch。
39. 🔴 Redis和Memcached的区别是什么?什么场景下应该选择Memcached?
答:Redis和Memcached都是内存缓存,但定位和能力差异很大。
核心区别:
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | String/Hash/List/Set/ZSet/Stream等 | 只有String(key-value) |
| 持久化 | RDB/AOF | 不支持 |
| 集群 | Cluster原生支持 | 客户端分片(一致性哈希) |
| 线程模型 | 单线程命令执行+多线程IO | 完全多线程 |
| 内存管理 | jemalloc | slab分配器 |
| 最大value | 512MB | 1MB |
| 事务 | MULTI/EXEC | 不支持 |
| Lua脚本 | 支持 | 不支持 |
| 发布订阅 | 支持 | 不支持 |
| 过期策略 | 惰性+定期删除 | 惰性删除 |
选择Memcached的场景:
- 纯缓存场景:只需要简单的key-value缓存,不需要复杂数据结构。
- 多线程优势:Memcached的多线程模型在多核CPU上可以更好地利用资源。单实例吞吐量可能高于Redis。
- 内存效率:Memcached的slab分配器在存储大量相同大小的value时内存碎片更少。
- 已有Memcached基础设施:迁移成本高,且当前功能满足需求。
选择Redis的场景(大多数场景):
- 需要复杂数据结构(排行榜、计数器、分布式锁等)。
- 需要持久化。
- 需要发布订阅、Stream等功能。
- 需要原生集群支持。
实际趋势:Redis已经在大多数场景下替代了Memcached。除非有特殊的性能需求或历史原因,新项目推荐使用Redis。
40. 🔵 什么是一致性哈希(Consistent Hashing)?它在缓存分片中有什么作用?
答:一致性哈希:将节点和数据映射到同一个哈希环上,数据存储在顺时针方向的第一个节点上。
传统哈希分片的问题:
- hash(key) % N(N是节点数)。
- 增减节点时N变化,几乎所有key的映射都会改变,导致大量缓存失效(缓存雪崩)。
一致性哈希的优势:
- 增减节点时,只有相邻节点的部分key需要迁移,其他key不受影响。
- 增加一个节点:只影响该节点和逆时针方向前一个节点之间的key。
- 删除一个节点:该节点的key迁移到顺时针方向的下一个节点。
虚拟节点:
- 问题:节点少时,哈希环上的分布可能不均匀(数据倾斜)。
- 解决:每个物理节点映射多个虚拟节点(如每个物理节点150个虚拟节点),虚拟节点均匀分布在哈希环上。
- 数据先映射到虚拟节点,再映射到物理节点。
在缓存中的应用:
- Memcached客户端(如Ketama)使用一致性哈希做客户端分片。
- Redis Cluster不使用一致性哈希,而是使用哈希槽(16384个固定槽),手动分配槽到节点。哈希槽方案更灵活(可以精确控制数据分布),但需要集群管理。
- Twemproxy/Codis等Redis代理使用一致性哈希或哈希槽做分片。
41. 🔴 如何设计一个支持秒杀场景的缓存架构?
答:秒杀场景的核心挑战:瞬时高并发、库存扣减的原子性和一致性、防止超卖。
架构设计:
1 | 用户 → CDN(静态页面) → 网关(限流+鉴权) → 应用层(业务逻辑) → Redis(库存扣减) → MQ(异步下单) → 数据库 |
关键设计:
前端:
- 静态页面CDN缓存,减少服务端压力。
- 按钮防重复点击(点击后置灰)。
- 前端随机延迟(分散请求到不同时间点)。
网关层:
- 限流:令牌桶限流,控制进入系统的请求量。
- 黑名单:拦截恶意请求(频率异常、非法参数)。
- 排队:超过处理能力的请求进入队列等待。
应用层:
- 本地缓存标记:库存为0后在本地缓存标记”已售罄”,后续请求直接返回,不再访问Redis。
- 请求去重:同一用户短时间内的重复请求去重。
Redis库存扣减(核心):
1 | -- 原子扣减库存 |
异步下单:
- 库存扣减成功后,发送消息到MQ(Kafka/RocketMQ)。
- 下游服务异步消费消息,创建订单、扣减数据库库存。
- 用户看到”排队中”,异步通知结果。
防超卖:
- Redis Lua脚本保证扣减原子性。
- 数据库层面用乐观锁兜底:
UPDATE stock SET count=count-1 WHERE id=? AND count>0。 - 对账机制:定时对比Redis库存和数据库库存。
42. 🔴 什么是Redis的读写分离?如何实现?有什么注意事项?
答:读写分离:写操作发送到主库,读操作分散到从库,提高读吞吐量。
实现方式:
- 客户端实现:应用层根据操作类型选择连接主库或从库。Lettuce支持ReadFrom配置(MASTER/SLAVE/NEAREST等)。
- 代理实现:Redis代理(如Twemproxy、Codis、Redis Proxy)自动将读写请求路由到主从节点。
- Sentinel + 读写分离:通过Sentinel获取主从节点地址,读操作发送到从库。
注意事项:
- 主从延迟:从库的数据可能落后于主库(异步复制)。写入后立即读取可能读到旧数据。
- 解决:关键读操作强制读主库。或使用WAIT命令等待从库同步完成(牺牲性能)。
- 从库过期key问题:Redis 3.2之前从库不主动删除过期key,可能返回已过期的数据。3.2+从库会检查过期时间返回nil。
- 从库数量:从库越多,主库的复制压力越大(每个从库都需要主库发送数据)。建议不超过3-5个从库。可以使用级联复制(从库的从库)减轻主库压力。
- 连接管理:从库故障时需要自动切换到其他从库或主库。Sentinel可以自动处理。
43. 🔵 什么是Redis的数据分片方案?客户端分片、代理分片、Cluster分片有什么区别?
答:Redis数据分片的三种方案:
客户端分片:
- 客户端根据key计算哈希值,决定发送到哪个Redis实例。
- 算法:一致性哈希或哈希取模。
- 优点:简单,无额外组件。
- 缺点:客户端逻辑复杂,扩缩容需要客户端配合,不支持跨节点操作。
- 代表:Jedis ShardedJedis。
代理分片:
- 在客户端和Redis之间加一层代理,代理负责路由。
- 优点:客户端无感知,支持多语言。
- 缺点:代理是额外的网络跳转(增加延迟),代理本身需要高可用。
- 代表:Twemproxy(Twitter开源,不支持在线扩缩容)、Codis(豌豆荚开源,支持在线扩缩容和Dashboard)。
Redis Cluster(官方方案):
- 16384个哈希槽分配到多个主节点。
- 优点:官方支持,无额外组件,支持在线扩缩容,自动故障转移。
- 缺点:部分命令受限(跨槽操作需要Hash Tag),客户端需要支持Cluster协议。
- 代表:Redis Cluster(Redis 3.0+)。
选型建议:
- 新项目:直接用Redis Cluster(官方方案,生态最好)。
- 已有Codis/Twemproxy:评估迁移成本,条件允许迁移到Cluster。
- 特殊需求(如需要代理层的统一管理):考虑Codis或商业方案。
44. 🔴 Redis Cluster的限制有哪些?哪些操作在Cluster模式下不支持或受限?
答:Redis Cluster的限制:
跨槽操作受限:
- MGET/MSET/DEL等多key命令:所有key必须在同一个槽(使用Hash Tag)。
- 事务(MULTI/EXEC):所有key必须在同一个槽。
- Lua脚本:所有访问的key必须在同一个槽。
- 集合操作(SUNION/SINTER等):所有key必须在同一个槽。
数据库限制:
- Cluster模式只支持db0,不支持SELECT切换数据库。
发布订阅:
- PUBLISH命令会在所有节点间广播(消耗集群内部带宽)。Redis 7.0+的Sharded Pub/Sub解决了这个问题。
大Key迁移:
- 槽迁移时大Key的MIGRATE操作是同步的,可能阻塞源节点。
节点数量:
- 官方建议不超过1000个节点(Gossip协议的通信开销随节点数增加)。
客户端复杂度:
- 客户端需要支持Cluster协议(MOVED/ASK重定向、槽映射缓存)。
- 不是所有Redis客户端都完整支持Cluster。
运维复杂度:
- 扩缩容需要手动迁移槽(或使用redis-cli –cluster reshard)。
- 故障转移可能导致短暂不可用(选举期间)。
解决方案:
- 跨槽操作:使用Hash Tag将相关key映射到同一个槽。如{order:1001}.info和{order:1001}.items。
- 大Key:拆分大Key,避免迁移问题。
- 发布订阅:使用Sharded Pub/Sub(Redis 7.0+)或外部MQ。
45. 🔴 什么是Redis的脑裂问题?如何防止脑裂导致的数据丢失?
答:脑裂:网络分区导致主库和从库/Sentinel之间失去联系,Sentinel将从库提升为新主库,此时出现两个主库同时接受写入。
脑裂场景:
- 主库和Sentinel之间网络断开(但主库和客户端之间网络正常)。
- Sentinel认为主库下线,将从库提升为新主库。
- 客户端仍然向旧主库写入数据。
- 网络恢复后,旧主库变为从库,从新主库同步数据,旧主库上的新写入数据丢失。
防止脑裂的配置:
1 | min-replicas-to-write 1 # 主库至少有1个从库连接才接受写入 |
工作原理:如果主库检测到连接的从库数量 < min-replicas-to-write,或所有从库的复制延迟 > min-replicas-max-lag,主库拒绝写入(返回错误)。这样在网络分区时,被隔离的旧主库无法接受写入,避免数据丢失。
代价:如果从库全部宕机或网络延迟过大,主库也无法写入(牺牲可用性保证一致性)。
Cluster模式的脑裂:
- Cluster模式下,主节点如果无法与多数主节点通信,会拒绝写入(cluster-require-full-coverage配置)。
- 从节点选举需要多数主节点投票,被隔离的少数派无法选举新主节点。
46. 🔵 什么是Redis的Bitmap?有哪些实际应用场景?
答:Bitmap:基于String类型的位操作,每个bit存储0或1。
基本命令:
1 | SETBIT key offset value # 设置指定位 |
应用场景:
用户签到:
- key:
sign:{userId}:{yyyyMM} - offset:日期(1-31)
- SETBIT sign:1001:202401 15 1(用户1001在1月15日签到)
- BITCOUNT sign:1001:202401(统计1月签到天数)
- 内存:每个用户每月只需4字节。
- key:
用户在线状态:
- key:
online:{yyyyMMdd} - offset:userId
- SETBIT online:20240101 1001 1(用户1001在线)
- BITCOUNT online:20240101(统计在线用户数)
- 1亿用户只需12MB内存。
- key:
活跃用户统计:
- 每天一个Bitmap记录活跃用户。
- BITOP AND result day1 day2 day3(连续3天都活跃的用户)。
- BITOP OR result day1 day2 … day7(7天内活跃过的用户)。
特征标记:
- 用户是否完成新手引导、是否开启通知等布尔特征。
- 每个特征一个bit,一个Bitmap存储所有特征。
布隆过滤器:
- 手动实现布隆过滤器的底层位数组。
内存效率:1亿个布尔值只需12MB(1亿bit ÷ 8 = 12.5MB)。如果用String存储(每个key约50字节),需要5GB。
47. 🔴 什么是Redis的HyperLogLog?它的原理是什么?误差率如何?
答:HyperLogLog:概率型数据结构,用于基数估算(统计不重复元素的数量)。
原理(简化版):
- 基于概率统计:对元素进行哈希,观察哈希值的二进制表示中前导零的最大数量。
- 直觉:如果观察到的最大前导零数量为k,则估算基数约为2^k。
- HyperLogLog使用多个桶(Redis使用16384个桶)分别统计,取调和平均值,提高精度。
Redis实现:
- 每个HyperLogLog使用12KB固定内存(16384个桶 × 6bit/桶)。
- 小基数时使用稀疏编码(Sparse),内存更小。基数增大后自动转为密集编码(Dense,12KB)。
命令:
1 | PFADD key element1 element2 ... # 添加元素 |
误差率:标准误差0.81%。即实际基数100万时,估算值在99.19万-100.81万之间。
应用场景:
- UV统计:统计网站/页面的独立访客数。
- 搜索关键词去重计数:统计不同搜索关键词的数量。
- 社交网络:统计用户的独立好友数、独立互动用户数。
与Set对比:
- Set存储100万个用户ID需要约50MB内存。
- HyperLogLog只需要12KB,但只能获取近似基数,不能获取具体元素。
适用条件:只需要知道”有多少个不同的元素”,不需要知道”具体是哪些元素”,且可以接受0.81%的误差。
48. 🔴 如何设计一个基于Redis的延迟队列?
答:延迟队列:消息在指定时间后才被消费。Redis没有原生延迟队列,需要自行实现。
方案1:ZSet实现(最常用)
1 | # 生产者:将消息加入ZSet,score为执行时间戳 |
Lua脚本保证原子性(获取+删除):
1 | local messages = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2]) |
方案2:Redis Stream + 消费者轮询
- 消息写入Stream,消费者定时检查是否有到期消息。
- 不如ZSet方案直观。
方案3:Redis Keyspace Notification
- 利用key过期事件通知:设置一个key,TTL为延迟时间。key过期时Redis发送通知。
- 配置:
notify-keyspace-events Ex。 - 问题:通知不可靠(Pub/Sub语义,消费者不在线则丢失)。不推荐生产使用。
ZSet方案的优化:
- 多消费者竞争:多个消费者同时ZRANGEBYSCORE可能获取相同消息。用Lua脚本原子获取+删除,或用分布式锁。
- 消息持久化:消息详情存储在Hash中(HSET msg:{id} …),ZSet只存储消息ID和执行时间。
- 失败重试:消费失败的消息重新加入ZSet,score设为下次重试时间。
- 监控:监控ZSet大小(ZCARD),过大说明消费能力不足。
生产建议:简单场景用Redis ZSet延迟队列。复杂场景(大量延迟消息、高可靠性要求)用RocketMQ延迟消息或专门的延迟队列服务。
49. 🔵 什么是Redis的Cluster Bus?节点间通信的协议是怎样的?
答:Cluster Bus是Redis Cluster节点间通信的内部通道,使用独立的端口(数据端口+10000,如6379→16379)。
通信协议:
- 基于TCP的二进制协议(不是RESP协议)。
- 使用Gossip协议交换集群状态信息。
消息类型:
- PING/PONG:心跳消息。每个节点每秒随机选择几个节点发送PING,收到PING的节点回复PONG。PING/PONG消息携带:
- 发送节点的信息(ID、IP、端口、负责的槽等)。
- 发送节点知道的部分其他节点的信息(Gossip Section,随机选择几个节点的信息)。
- MEET:邀请新节点加入集群。
CLUSTER MEET ip port。 - FAIL:某个节点被标记为FAIL时,广播给所有节点。
- PUBLISH:Pub/Sub消息在集群内广播。
- FAILOVER_AUTH_REQUEST/ACK:从节点发起故障转移选举时的投票请求和响应。
- UPDATE:槽分配变更通知。
Gossip协议的特点:
- 最终一致:信息通过多轮Gossip传播,最终所有节点收敛到一致状态。
- 传播速度:O(log N)轮(N是节点数)。100个节点约7轮(每秒1轮)即可传播到所有节点。
- 带宽开销:每个PING/PONG消息约2KB+(取决于携带的节点信息数量)。节点越多,Gossip消息越大。
cluster-node-timeout:节点超时时间(默认15秒)。超过此时间未收到某节点的PONG,标记为PFAIL。影响故障检测速度和Gossip频率。
50. ⚫ 如果让你设计一个分布式缓存系统,你会如何设计?需要考虑哪些核心问题?
答:这是一道开放性架构设计题。
核心设计决策:
数据模型:
- 只支持key-value(如Memcached,简单高效)?还是支持丰富的数据结构(如Redis)?
- 数据结构越丰富,实现越复杂,但应用场景越广。
内存管理:
- 内存分配器:jemalloc(Redis)、slab(Memcached)、tcmalloc。
- 淘汰策略:LRU/LFU/FIFO/Random。
- 内存碎片处理:碎片整理或定期重启。
持久化:
- 是否需要持久化?纯缓存不需要,但Redis的持久化使其可以作为数据库使用。
- 快照(RDB)vs 日志(AOF)vs 混合。
分布式:
- 数据分片:一致性哈希 vs 哈希槽。
- 副本:主从复制,同步还是异步?
- 故障转移:自动还是手动?
- 一致性:强一致(Raft)还是最终一致(Gossip)?
网络模型:
- 单线程+IO多路复用(Redis)vs 多线程(Memcached)。
- 协议:文本协议(简单调试)vs 二进制协议(高效)。
高可用:
- 主从复制 + 自动故障转移。
- 跨数据中心复制。
可观测性:
- 慢查询日志、内存使用统计、命中率统计。
- 监控指标暴露(Prometheus格式)。
安全:
- 认证(密码/ACL)。
- 加密(TLS)。
- 命令限制(禁止危险命令)。
好的回答应该展示对这些权衡的理解,以及根据具体场景做出合理选择的能力。
三、Redis高级特性与生产实践(51-75题)
51. 🔴 Redis的fork操作为什么可能导致延迟?如何优化?
答:Redis在BGSAVE(RDB持久化)和BGREWRITEAOF(AOF重写)时需要fork子进程。
fork的延迟来源:
- 页表复制:fork时OS需要复制父进程的页表(虚拟地址→物理地址的映射)。页表大小与进程使用的内存成正比。10GB内存的Redis实例,页表约20MB,fork耗时约20-50ms。
- Copy-on-Write(COW):fork后父子进程共享物理内存页。父进程修改数据时触发COW,复制被修改的内存页。写入密集时COW导致内存使用量翻倍。
优化方案:
- 控制实例内存大小:单实例内存建议不超过10GB。内存越大fork越慢。
- 使用大页内存(Huge Pages):Linux的Transparent Huge Pages(THP)会导致COW时复制2MB的大页(而非4KB的小页),内存开销和延迟都增大。建议关闭THP:
echo never > /sys/kernel/mm/transparent_hugepage/enabled。 - 减少fork频率:调整RDB的save规则,减少自动BGSAVE频率。使用AOF+混合持久化替代频繁的RDB。
- 无盘复制:
repl-diskless-sync yes,主从复制时不生成RDB文件,直接通过Socket发送。减少一次fork(但仍需要fork来序列化数据)。 - 监控fork耗时:
INFO persistence中的latest_fork_usec(最近一次fork的耗时,微秒)。超过100ms需要关注。
52. 🔵 什么是Redis的Lazy Free机制?它解决了什么问题?
答:Lazy Free(Redis 4.0+):将耗时的内存释放操作放到后台线程异步执行,避免阻塞主线程。
解决的问题:
- DEL一个大Key(如包含百万元素的Hash)时,释放内存需要遍历所有元素,可能阻塞主线程数秒。
- 过期key的删除、内存淘汰时删除大Key同样会阻塞。
Lazy Free命令:
UNLINK key:替代DEL,异步删除key。主线程只是将key从keyspace中移除(O(1)),实际内存释放由后台线程完成。FLUSHALL ASYNC/FLUSHDB ASYNC:异步清空数据库。
自动Lazy Free配置:
1 | lazyfree-lazy-eviction yes # 内存淘汰时异步删除 |
实现原理:
- 主线程将待释放的对象放入一个队列(bio_lazy_free)。
- 后台线程(bio线程)从队列中取出对象,执行实际的内存释放。
- 后台线程不影响主线程的命令执行。
注意:Lazy Free只对大对象有意义。小对象(如简单String)的释放本身就很快,异步反而增加了队列操作的开销。Redis内部会判断对象大小,小对象仍然同步释放。
53. 🔴 Redis的RESP协议是什么?RESP2和RESP3有什么区别?
答:RESP(Redis Serialization Protocol):Redis客户端和服务端之间的通信协议。
RESP2(Redis 2.0-5.x默认):
- 文本协议,简单易读。
- 数据类型:
- Simple String:
+OK\r\n - Error:
-ERR unknown command\r\n - Integer:
:1000\r\n - Bulk String:
$6\r\nfoobar\r\n(长度前缀) - Array:
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
- Simple String:
- 限制:类型信息不足(如HGETALL返回的是Array,客户端需要自己解析为Map)。
RESP3(Redis 6.0+):
- 新增数据类型:
- Map:
%2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n$3\r\nqux\r\n(HGETALL直接返回Map)。 - Set:
~2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n。 - Boolean:
#t\r\n或#f\r\n。 - Double:
,3.14\r\n。 - Null:
_\r\n。 - Push:
>2\r\n...(服务端主动推送,用于Client-side Caching的失效通知)。
- Map:
- 优势:
- 类型信息更丰富,客户端不需要猜测返回值类型。
- 支持Push消息(Client-side Caching)。
- 支持Attribute(附加元数据)。
切换到RESP3:客户端发送HELLO 3命令切换到RESP3协议。默认仍然是RESP2(向后兼容)。
54. 🔵 什么是Redis的Keyspace Notification?有什么应用场景?
答:Keyspace Notification:Redis在key发生特定事件时发送通知(通过Pub/Sub)。
配置:notify-keyspace-events参数,指定监听的事件类型:
- K:Keyspace事件(keyspace@
:key,按key通知) - E:Keyevent事件(keyevent@
:event,按事件类型通知) - g:通用命令(DEL、EXPIRE、RENAME等)
- $:String命令
- l:List命令
- s:Set命令
- h:Hash命令
- z:ZSet命令
- x:过期事件
- e:驱逐事件(内存淘汰)
- A:所有事件(g$lshzxe的别名)
示例:
1 | # 配置监听过期事件 |
应用场景:
- 订单超时取消:创建订单时设置key的TTL为30分钟。key过期时收到通知,触发订单取消逻辑。
- 缓存失效通知:key被删除或过期时通知应用层更新本地缓存。
- 数据变更监听:监听特定key的修改事件,触发业务逻辑。
局限性:
- 不可靠:基于Pub/Sub,如果订阅者不在线,通知丢失。
- 性能开销:开启通知会增加Redis的CPU和内存开销。
- Cluster模式:通知只在key所在的节点发送,订阅者需要连接所有节点。
- 过期事件不精确:Redis的过期删除是惰性+定期的,过期事件可能延迟触发。
生产建议:不要依赖Keyspace Notification做关键业务逻辑。订单超时等场景推荐使用延迟队列(ZSet方案或RocketMQ延迟消息)。
55. 🔴 什么是Redis的内存回收机制?jemalloc的工作原理是什么?
答:Redis使用jemalloc作为默认内存分配器(也支持libc malloc和tcmalloc)。
jemalloc的核心设计:
- Arena:jemalloc将内存分为多个Arena(默认CPU核心数×4个),每个线程绑定一个Arena,减少锁竞争。Redis是单线程,主要使用一个Arena。
- Size Class:内存按固定大小分类(8/16/32/48/64/80/96/112/128/…字节)。分配时向上取整到最近的Size Class。如请求100字节,分配112字节。
- Slab:相同Size Class的内存块组成Slab(连续内存页)。分配时从Slab中取一个空闲块,释放时归还到Slab。
- Page Run:大内存(>14KB)直接从Page Run分配(连续的内存页)。
内存碎片的产生:
- 内部碎片:分配的Size Class大于实际需要(如需要100字节分配112字节,浪费12字节)。
- 外部碎片:释放的内存块不连续,无法合并为大块。频繁的分配/释放不同大小的内存块会加剧外部碎片。
Redis的内存回收:
- DEL/UNLINK删除key时,释放key和value占用的内存(归还给jemalloc)。
- jemalloc不一定将内存归还给OS(保留在jemalloc的空闲列表中,供后续分配使用)。
MEMORY PURGE:强制jemalloc将空闲内存归还给OS(调用jemalloc的arena.purge)。used_memory(jemalloc分配的内存)和used_memory_rss(OS分配给Redis的物理内存)的差值反映了jemalloc保留的空闲内存和碎片。
56. 🔵 什么是Redis的Sentinel和Cluster的区别?如何选择?
答:Sentinel和Cluster是Redis的两种高可用方案,定位不同。
| 维度 | Sentinel | Cluster |
|---|---|---|
| 定位 | 高可用(主从故障转移) | 高可用 + 数据分片 |
| 数据分片 | 不支持(单主库存储所有数据) | 支持(16384个哈希槽) |
| 容量 | 受单机内存限制 | 可水平扩展(多主节点) |
| 写入能力 | 单主库写入 | 多主节点并行写入 |
| 架构 | 主从 + Sentinel监控 | 多主多从,自治 |
| 客户端 | 通过Sentinel获取主库地址 | 需要支持Cluster协议 |
| 运维复杂度 | 较低 | 较高 |
| 跨槽操作 | 无限制 | 受限(需要Hash Tag) |
选择建议:
- Sentinel:数据量不大(单机内存可以容纳)、不需要水平扩展写入能力、希望运维简单。典型场景:缓存、Session、小规模业务。
- Cluster:数据量大(超过单机内存)、需要水平扩展写入能力、高吞吐量要求。典型场景:大规模缓存、排行榜、计数器。
混合方案:
- 小规模:单实例Redis(不需要高可用)或Sentinel(需要高可用)。
- 中规模:Cluster(3主3从)。
- 大规模:多个Cluster集群(按业务拆分)。
57. 🔴 什么是Redis的WAIT命令?如何实现强一致性读?
答:WAIT命令:阻塞当前客户端,直到之前的写命令被指定数量的从库确认。
语法:WAIT numreplicas timeout
- numreplicas:等待确认的从库数量。
- timeout:超时时间(毫秒),0表示无限等待。
- 返回值:在超时前确认的从库数量。
使用场景:写入后需要立即从从库读取最新数据(强一致性读)。
1 | SET key value |
注意事项:
- WAIT只保证数据已发送到从库,不保证从库已执行(从库可能还在执行队列中)。但实际上从库执行速度很快,几乎等同于已执行。
- WAIT会阻塞客户端,增加延迟。不适合高频使用。
- 如果从库全部宕机,WAIT会阻塞到超时。
- WAIT不是事务性的,只保证WAIT之前的命令已同步。
强一致性读的其他方案:
- 读主库:关键读操作直接读主库。最简单但增加主库压力。
- WAIT + 读从库:写入后WAIT确认,再从从库读取。
- 版本号方案:写入时记录版本号,读取时检查从库的版本号是否最新。
58. 🔵 什么是Redis的Module(模块)?有哪些常用的Redis模块?
答:Redis Module(4.0+):允许通过C语言编写扩展模块,为Redis添加新的数据类型和命令。
常用模块:
- RedisJSON:原生JSON数据类型,支持JSON路径查询和部分更新。
1 | JSON.SET user:1001 $ '{"name":"张三","age":30,"address":{"city":"北京"}}' |
- RediSearch:全文搜索引擎,支持索引、查询、聚合。
1 | FT.CREATE idx:users ON HASH PREFIX 1 user: SCHEMA name TEXT age NUMERIC |
RedisBloom:概率型数据结构(布隆过滤器、Cuckoo过滤器、Count-Min Sketch、Top-K)。
RedisTimeSeries:时间序列数据类型,支持自动降采样、聚合查询。适合IoT、监控数据。
RedisGraph:图数据库模块,支持Cypher查询语言。
RedisAI:在Redis中运行机器学习模型(TensorFlow/PyTorch/ONNX)。
Redis Stack:Redis官方将常用模块打包为Redis Stack(包含RedisJSON、RediSearch、RedisBloom、RedisTimeSeries等),提供开箱即用的体验。
模块开发:使用Redis Module API(C语言),可以:
- 定义新的数据类型(自定义编码和存储)。
- 注册新的命令。
- 访问Redis的内部数据结构。
- 使用Redis的事件循环和IO。
59. 🔴 什么是Redis的Cluster Proxy?为什么需要它?
答:Redis Cluster Proxy:在客户端和Redis Cluster之间的代理层,让不支持Cluster协议的客户端也能使用Cluster。
为什么需要:
- 客户端兼容性:部分Redis客户端(尤其是非Java语言)不完整支持Cluster协议(MOVED/ASK重定向)。
- 简化客户端:客户端不需要维护槽映射缓存,不需要处理重定向。
- 跨槽操作:代理可以拆分跨槽的MGET/MSET等命令,分别发送到不同节点,合并结果返回。
- 连接管理:代理维护与所有Cluster节点的连接池,客户端只需要连接代理。
方案:
redis-cluster-proxy(官方,已停止维护):Redis官方的Cluster代理,C语言实现。
Twemproxy:Twitter开源,支持一致性哈希分片。不支持Cluster协议,但可以作为分片代理。不支持在线扩缩容。
Codis:豌豆荚开源,支持在线扩缩容、Dashboard管理。基于ZooKeeper/etcd做元数据管理。国内使用较多。
Predixy:高性能Redis代理,支持Sentinel和Cluster模式。C++实现,性能接近直连。
Envoy/HAProxy:通用代理,支持Redis协议。适合Service Mesh场景。
选型建议:
- 如果客户端支持Cluster协议(如Jedis、Lettuce、redis-py),直连Cluster(性能最好)。
- 如果需要代理,Predixy性能好,Codis功能全(但已不太活跃)。
- 云环境考虑云厂商提供的代理方案(如AWS ElastiCache Proxy)。
60. 🔵 什么是Redis的OBJECT命令?如何查看key的内部编码和内存使用?
答:OBJECT命令用于查看key的内部信息。
常用子命令:
1 | # 查看key的内部编码 |
MEMORY命令(Redis 4.0+):
1 | # 查看key的内存使用(字节,包含key本身、value、元数据的开销) |
实际用途:
- 排查大Key:
MEMORY USAGE key查看具体key的内存占用。 - 验证编码优化:
OBJECT ENCODING key确认key使用了预期的紧凑编码。 - 分析热点:
OBJECT FREQ key查看key的访问频率(需要LFU策略)。 - 排查内存问题:
MEMORY DOCTOR获取内存优化建议。
61. 🔴 如何设计一个基于Redis的全局唯一ID生成器?
答:全局唯一ID是分布式系统的基础需求。Redis可以作为ID生成器的核心组件。
方案1:Redis INCR(最简单)
1 | INCR global_id # 返回递增的唯一ID |
- 优点:简单、有序、性能高(10万+ QPS)。
- 缺点:单点依赖Redis,ID连续可能暴露业务量。
方案2:Redis + 号段模式(推荐)
- 应用启动时从Redis获取一个号段(如INCRBY global_id 1000,获取1000个ID)。
- 应用在本地内存中分配ID,号段用完再从Redis获取下一个号段。
- 优点:减少Redis访问频率,本地分配性能极高。Redis宕机时本地号段仍可用。
- 缺点:应用重启时可能浪费部分ID(号段未用完)。
方案3:Redis + 时间戳 + 序列号(类Snowflake)
1 | -- Lua脚本:生成时间戳+序列号的ID |
- 优点:ID包含时间信息,可以按时间排序。
- 缺点:依赖Redis时钟。
方案4:Snowflake算法(不依赖Redis)
- 64位ID = 1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号。
- 每毫秒每台机器可生成4096个ID。
- 优点:不依赖外部存储,本地生成。
- 缺点:需要解决机器ID分配问题(可以用Redis分配机器ID)。
生产建议:中小规模用Redis INCR或号段模式。大规模用Snowflake(本地生成,无网络开销)。美团Leaf、百度UidGenerator是成熟的开源方案。
62. 🔵 什么是Redis的SCAN命令?它和KEYS命令有什么区别?
答:SCAN是Redis的增量迭代命令,用于安全地遍历key。
KEYS的问题:
KEYS pattern:遍历所有key,时间复杂度O(n)。- 在key数量多时(百万级)阻塞主线程数秒,导致其他命令超时。
- 生产环境禁止使用KEYS命令。
SCAN的优势:
- 增量迭代,每次只返回少量key(默认约10个)。
- 不阻塞主线程(每次迭代只扫描少量数据)。
- 使用游标(cursor)记录迭代位置。
使用方式:
1 | SCAN 0 MATCH user:* COUNT 100 |
SCAN家族:
SCAN:遍历所有key。HSCAN:遍历Hash的field。SSCAN:遍历Set的member。ZSCAN:遍历ZSet的member。
注意事项:
- 不保证完整性:迭代过程中新增或删除的key可能被遗漏或重复返回。
- COUNT是建议值:实际返回数量可能多于或少于COUNT。
- MATCH在返回后过滤:SCAN先获取COUNT个key,再用MATCH过滤。如果匹配率低,可能返回空数组但游标不为0(需要继续迭代)。
- 时间复杂度:单次SCAN是O(COUNT),完整遍历是O(n)。但分散在多次调用中,不会阻塞。
63. 🔴 Redis在微服务架构中有哪些典型应用?
答:Redis在微服务架构中扮演多种角色。
分布式缓存:最核心的应用。缓存数据库查询结果、API响应、计算结果。减少数据库压力,降低响应延迟。
分布式Session:Spring Session + Redis,多实例共享用户Session。
分布式锁:Redisson实现的分布式锁,保证分布式环境下的互斥操作。
消息队列:Redis Stream或List实现轻量级消息队列。适合简单的异步任务,不需要引入Kafka/RocketMQ。
限流:基于Redis的分布式限流(令牌桶、滑动窗口),保护下游服务。
服务发现/配置中心:Redis Pub/Sub通知配置变更,Hash存储配置数据。(简单场景,复杂场景用Nacos/Consul)
幂等性保证:用SETNX记录已处理的请求ID,防止重复处理。
排行榜/计数器:ZSet实现排行榜,INCR实现计数器。
布隆过滤器:防止缓存穿透,去重。
地理位置:GEO功能实现附近的人/商家。
微服务中Redis的最佳实践:
- 每个微服务使用独立的Redis实例或独立的key前缀(命名空间隔离)。
- 统一的Redis客户端封装(连接池管理、序列化、异常处理)。
- 监控每个微服务的Redis使用情况(QPS、内存、慢查询)。
- 避免微服务之间通过Redis共享数据(违反微服务的独立性原则)。
64. 🔴 什么是Redis的连接池?如何正确配置Jedis/Lettuce的连接池?
答:连接池:预先创建一组Redis连接,复用连接避免频繁创建/销毁的开销。
Jedis连接池(JedisPool,基于Apache Commons Pool):
1 | JedisPoolConfig config = new JedisPoolConfig(); |
Lettuce连接(基于Netty,默认共享连接):
1 | // Lettuce默认使用单连接+多路复用(不需要连接池) |
Jedis vs Lettuce:
| 维度 | Jedis | Lettuce |
|---|---|---|
| 线程安全 | 不安全(需要连接池) | 安全(共享连接) |
| 底层 | BIO(阻塞IO) | Netty(NIO) |
| 连接模型 | 连接池 | 单连接多路复用 |
| 异步支持 | 不支持 | 支持(异步/响应式) |
| Cluster支持 | 支持 | 支持(更完善) |
| Spring Boot默认 | 2.0之前 | 2.0之后 |
连接池大小计算:
- 公式:连接数 = 业务线程数 × 每个线程的Redis操作时间 / Redis命令平均响应时间。
- 经验值:一般20-50个连接足够。过多的连接反而增加Redis的客户端管理开销。
65. 🔵 什么是Redis的慢查询日志和延迟监控?如何使用?
答:Redis提供慢查询日志和延迟监控两个工具来排查性能问题。
慢查询日志(Slowlog):
- 记录执行时间超过阈值的命令。
- 注意:只记录命令执行时间,不包括网络传输和排队等待时间。
1 | CONFIG SET slowlog-log-slower-than 1000 # 阈值1ms(微秒) |
延迟监控(Latency Monitoring,Redis 2.8.13+):
- 记录各种延迟事件(命令执行、fork、AOF写入等)。
1 | CONFIG SET latency-monitor-threshold 100 # 监控超过100ms的事件 |
延迟事件类型:
command:命令执行延迟。fast-command:O(1)/O(log n)命令的延迟。fork:fork操作延迟。aof-write:AOF写入延迟。aof-fsync-always:AOF fsync延迟。aof-rewrite:AOF重写延迟。rdb-save:RDB保存延迟。expire-cycle:过期key清理延迟。eviction-cycle:内存淘汰延迟。
redis-cli延迟测试:
1 | redis-cli --latency # 持续测量延迟 |
66. 🔴 如何设计Redis的容灾方案?跨机房/跨地域的Redis架构如何设计?
答:Redis容灾需要考虑单机房故障和跨地域灾备。
单机房高可用:
- Sentinel模式:1主2从+3 Sentinel,主库故障自动切换。
- Cluster模式:3主3从,分布在不同机架。
跨机房方案:
主从跨机房:
- 主库在机房A,从库在机房B。
- 正常时机房A读写,机房B只读。
- 机房A故障时,手动或自动将机房B的从库提升为主库。
- 问题:跨机房复制延迟(毫秒到秒级),切换时可能丢失少量数据。
双活方案(Active-Active):
- 两个机房各有独立的Redis集群。
- 应用层双写:写操作同时写入两个机房的Redis。
- 问题:双写一致性难保证(网络延迟、部分失败)。
- 适合:缓存场景(允许短暂不一致)。
基于复制的双活:
- 使用Redis Enterprise的Active-Active Geo-Distribution(CRDT技术)。
- 两个机房的Redis集群自动双向同步,冲突通过CRDT(Conflict-free Replicated Data Types)自动解决。
- 优点:自动冲突解决,应用无感知。
- 缺点:需要Redis Enterprise商业版。
基于中间件的同步:
- 使用开源工具(如redis-shake、DTS)实现跨机房数据同步。
- redis-shake支持全量+增量同步,支持单向和双向同步。
容灾切换流程:
- 检测故障(监控告警)。
- 评估数据丢失量(复制延迟)。
- 切换DNS/VIP指向备机房。
- 验证备机房Redis数据完整性。
- 通知应用层切换。
- 故障恢复后,反向同步数据。
67. 🔵 什么是Redis的RESP3协议中的Push消息?它有什么应用?
答:RESP3的Push消息:服务端主动向客户端推送消息,无需客户端请求。
Push消息格式:>count\r\n...(以>开头,区别于普通响应)。
应用场景:
Client-side Caching失效通知:
- 客户端开启Tracking后,Redis在key变更时通过Push消息通知客户端。
- 客户端收到通知后删除本地缓存。
- 不需要客户端轮询或订阅Pub/Sub。
Pub/Sub消息:
- RESP3中Pub/Sub消息以Push消息形式发送。
- 客户端可以在同一个连接上同时执行命令和接收Pub/Sub消息(RESP2中订阅后连接只能接收消息)。
集群状态变更通知(未来可能):
- 槽迁移、节点上下线等事件通过Push消息通知客户端。
RESP2的限制:
- 没有Push消息机制。
- Client-side Caching需要通过额外的Pub/Sub连接接收失效通知(浪费一个连接)。
- Pub/Sub订阅后连接进入”订阅模式”,不能执行其他命令。
RESP3的优势:
- 单连接同时支持命令执行和Push消息接收。
- 减少连接数,简化客户端实现。
客户端支持:Lettuce 6.0+支持RESP3。Jedis目前不支持RESP3。
68. 🔴 什么是Redis的数据迁移?有哪些迁移工具和方案?
答:Redis数据迁移场景:版本升级、架构变更(单机→Cluster)、云迁移、跨机房迁移。
迁移工具:
redis-shake(阿里开源,推荐):
- 支持:单机→单机、单机→Cluster、Cluster→Cluster、RDB文件导入。
- 模式:全量同步(RDB)+ 增量同步(AOF/复制流)。
- 支持数据过滤(按key前缀、按数据库)。
- 支持在线迁移(不停机)。
redis-cli –rdb/–pipe:
redis-cli --rdb dump.rdb:导出RDB文件。redis-cli --pipe:批量导入数据(使用Redis协议格式)。- 适合离线迁移。
MIGRATE命令:
- 将单个key从一个Redis实例迁移到另一个实例(原子操作)。
- Cluster的槽迁移内部使用MIGRATE。
- 不适合大规模迁移(逐key操作,效率低)。
SLAVEOF/REPLICAOF:
- 将目标Redis设为源Redis的从库,全量+增量同步。
- 同步完成后断开复制关系,目标Redis成为独立主库。
- 简单但只支持单机→单机。
云厂商DTS(Data Transmission Service):
- AWS DMS、阿里云DTS等,支持Redis迁移。
- 托管服务,无需自己部署工具。
迁移注意事项:
- 数据一致性验证:迁移后对比源和目标的key数量、抽样对比value。
- 大Key处理:大Key迁移可能阻塞源Redis。提前拆分大Key。
- 带宽控制:迁移流量可能影响正常业务。控制迁移速率。
- 版本兼容:不同Redis版本的RDB格式可能不兼容。确认目标版本支持源版本的RDB。
- 切换方案:DNS切换或客户端配置切换。准备回滚方案。
69. 🔴 什么是Redis的CRDT(Conflict-free Replicated Data Types)?它如何解决多活场景的冲突?
答:CRDT是一种特殊的数据结构,保证在多个副本并发修改时自动收敛到一致状态,无需协调。
CRDT在Redis中的应用(Redis Enterprise Active-Active):
- 多个地理分布的Redis集群可以同时接受写入。
- 冲突通过CRDT规则自动解决,不需要人工干预。
CRDT类型和冲突解决规则:
Counter(计数器):使用G-Counter(只增计数器)或PN-Counter(增减计数器)。每个副本维护自己的增量,合并时取各副本增量之和。
- 例:副本A执行INCR 3,副本B执行INCR 5,合并后值为8。
String(Last Write Wins):使用时间戳,最后写入的值胜出。
- 例:副本A在T1设置value=”a”,副本B在T2设置value=”b”(T2>T1),合并后value=”b”。
Set(OR-Set):添加操作总是生效,删除操作只删除已知的元素。
- 例:副本A添加元素x,副本B删除元素x(但B不知道A添加了x),合并后x存在(添加优先)。
Hash:每个field独立使用LWW(Last Write Wins)。
Sorted Set:score使用LWW,member使用OR-Set语义。
局限性:
- CRDT只能解决特定类型的冲突,复杂业务逻辑的冲突仍需要应用层处理。
- LWW依赖时钟同步,时钟偏差可能导致非预期的结果。
- 只有Redis Enterprise支持,开源Redis不支持。
开源替代:
- 应用层双写 + 冲突检测 + 人工/自动解决。
- 使用消息队列保证写入顺序(牺牲多活的写入能力)。
70. 🔵 什么是Redis的DEBUG命令?生产环境为什么要禁用?
答:DEBUG命令是Redis的调试工具,提供多种内部调试功能。
常用DEBUG子命令:
DEBUG SLEEP seconds:让Redis主线程睡眠指定秒数(阻塞所有命令)。DEBUG SET-ACTIVE-EXPIRE 0/1:开启/关闭主动过期。DEBUG OBJECT key:查看key的内部信息(编码、引用计数、序列化长度等)。DEBUG RELOAD:重新加载RDB文件(阻塞)。DEBUG LOADAOF:重新加载AOF文件。DEBUG CHANGE-REPL-ID:更改复制ID。DEBUG QUICKLIST-PACKED-THRESHOLD:调整quicklist的压缩阈值。DEBUG OOM:模拟OOM。DEBUG SEGFAULT:触发段错误(崩溃Redis)。
生产环境禁用的原因:
- DEBUG SLEEP:阻塞主线程,导致所有客户端超时。
- DEBUG SEGFAULT:直接崩溃Redis进程。
- DEBUG RELOAD:阻塞式重新加载数据,期间不可用。
- 安全风险:恶意用户可以利用DEBUG命令破坏Redis。
禁用方式:
1 | # ACL禁止DEBUG命令 |
其他应该禁用的危险命令:
KEYS *:全量扫描,阻塞主线程。FLUSHALL/FLUSHDB:清空数据。CONFIG:修改运行时配置。SHUTDOWN:关闭Redis。
71. 🔴 Redis在容器化环境(Docker/K8s)中有哪些注意事项?
答:Redis在容器化环境中面临一些特殊挑战。
内存管理:
- maxmemory设置:容器有内存限制(cgroup),Redis必须设置maxmemory小于容器内存限制。否则Redis可能被OOM Killer杀死。建议maxmemory = 容器内存 × 0.7(留30%给OS、fork、缓冲区等)。
- 禁用swap:容器中使用swap会严重影响Redis性能。确保容器不使用swap。
- Transparent Huge Pages:容器中THP可能导致fork延迟增大。在宿主机上关闭THP。
网络:
- 端口映射:Redis Cluster需要两个端口(数据端口和Cluster Bus端口=数据端口+10000)。Docker需要映射两个端口。
- IP地址:容器IP可能变化(重启后)。Cluster节点间通信使用IP,IP变化会导致集群异常。使用StatefulSet + Headless Service保证稳定的网络标识。
- NAT问题:Docker的NAT可能导致Cluster节点间通信失败。使用
cluster-announce-ip和cluster-announce-port配置外部可达的IP和端口。
持久化:
- 数据卷:RDB/AOF文件必须存储在持久化卷(PersistentVolume)中,不能存储在容器的临时文件系统中。
- 磁盘性能:网络存储(如EBS、NFS)的IO性能可能不如本地SSD。对延迟敏感的场景使用本地SSD(hostPath或local PV)。
K8s部署:
- 使用StatefulSet部署Redis(保证有序部署、稳定网络标识、持久化存储)。
- 使用Operator(如Spotahome Redis Operator、Redis Operator by OpsTree)自动化管理。
- 资源限制:设置CPU和内存的request/limit。Redis是CPU密集型(单线程),建议不限制CPU(只设request)。
72. 🔴 什么是Redis的Cluster Resharding?如何安全地进行集群扩缩容?
答:Resharding:重新分配Redis Cluster的哈希槽,实现集群扩缩容。
扩容流程(添加新节点):
- 将新节点加入集群:
redis-cli --cluster add-node new-host:port existing-host:port。 - 新节点加入后没有分配槽(不存储数据)。
- 从现有节点迁移槽到新节点:
redis-cli --cluster reshard existing-host:port。 - 指定迁移的槽数量和源节点。工具自动执行槽迁移。
- 为新主节点添加从节点:
redis-cli --cluster add-node new-slave:port new-master:port --cluster-slave。
缩容流程(移除节点):
- 将待移除节点的槽迁移到其他节点:
redis-cli --cluster reshard。 - 确认待移除节点没有槽。
- 移除节点:
redis-cli --cluster del-node host:port node-id。 - 如果移除的是主节点,其从节点会自动成为其他主节点的从节点。
安全操作要点:
- 分批迁移:不要一次迁移太多槽。每次迁移一部分,观察集群状态后再继续。
- 限速:大量数据迁移时限制速率,避免影响正常业务。
- 监控:迁移过程中监控集群状态(cluster info)、节点负载、Consumer Lag。
- 避免高峰期:在业务低峰期执行。
- 备份:操作前备份RDB。
- 验证:迁移完成后验证槽分配(cluster slots)和数据完整性。
自动化工具:
redis-cli --cluster rebalance:自动均衡槽分配(根据节点权重)。redis-cli --cluster check:检查集群状态和槽分配。redis-cli --cluster fix:修复集群问题(如槽分配不一致)。
73. 🔵 什么是Redis的Pub/Sub在Cluster模式下的问题?Sharded Pub/Sub如何解决?
答:传统Pub/Sub在Cluster模式下的问题:
- PUBLISH命令会将消息广播到集群中的所有节点(不管节点是否有该Channel的订阅者)。
- 集群内部带宽消耗 = 消息大小 × 节点数量。
- 节点越多,带宽浪费越严重。
Sharded Pub/Sub(Redis 7.0+):
- Channel被映射到特定的槽(类似key的哈希槽映射)。
- 消息只在负责该槽的节点上发布和订阅。
- 订阅者只需要连接负责该Channel的节点。
命令:
1 | # Sharded Pub/Sub(新命令) |
区别:
| 维度 | 传统Pub/Sub | Sharded Pub/Sub |
|---|---|---|
| 消息传播 | 全集群广播 | 只在负责的节点 |
| 带宽消耗 | O(节点数) | O(1) |
| 订阅方式 | 连接任意节点 | 连接负责该Channel的节点 |
| 模式订阅 | 支持(PSUBSCRIBE) | 不支持 |
| 命令 | SUBSCRIBE/PUBLISH | SSUBSCRIBE/SPUBLISH |
客户端支持:需要客户端支持Sharded Pub/Sub(Lettuce 6.2+、Jedis 4.3+)。
74. 🔴 如何排查Redis的内存泄漏问题?
答:Redis内存泄漏:内存持续增长,即使没有新数据写入。
排查步骤:
确认内存增长趋势:
INFO memory定期采集used_memory,绘制趋势图。- 如果used_memory持续增长且没有对应的key增长,可能是内存泄漏。
检查key数量和大小:
DBSIZE:key总数。redis-cli --bigkeys:找出大Key。redis-cli --memkeys:按内存排序的key。
检查过期key:
- 大量key设置了过期时间但未被及时清理(惰性删除+定期删除不够及时)。
INFO keyspace查看每个数据库的key数量和过期key数量。
检查客户端缓冲区:
INFO clients:查看connected_clients和client_recent_max_output_buffer。CLIENT LIST:查看每个客户端的输出缓冲区大小(omem)。- 大量Pub/Sub订阅者或慢消费者可能导致输出缓冲区膨胀。
- 配置:
client-output-buffer-limit限制缓冲区大小。
检查复制缓冲区:
- 主从复制的replication buffer和repl-backlog可能占用大量内存。
INFO replication查看复制状态。
检查Lua脚本:
- 长时间运行的Lua脚本可能持有大量临时数据。
SCRIPT EXISTS检查缓存的脚本数量。
内存碎片:
mem_fragmentation_ratio过高说明碎片严重。- 开启activedefrag或重启Redis。
RDB分析:
- 导出RDB文件,使用redis-rdb-tools离线分析每个key的内存占用。
- 找出异常增长的key或命名空间。
75. ⚫ 你在生产环境中遇到过最严重的Redis故障是什么?你是如何处理的?
答:这是一道开放性经验题,考察候选人的实战经验和应急处理能力。
优秀回答应该包含:
- 故障描述:清晰描述故障现象、影响范围、持续时间。
- 应急处理:第一时间做了什么止血?如何减少业务影响?
- 根因分析:故障的根本原因是什么?
- 修复方案:如何彻底修复?
- 改进措施:事后做了哪些改进防止再次发生?
典型故障示例:
示例1:Redis主库OOM被Kill
- 现象:Redis主库进程被OS的OOM Killer杀死,所有写入失败。
- 应急:Sentinel自动将从库提升为主库,服务恢复。但丢失了最后几秒的数据。
- 根因:未设置maxmemory,数据量持续增长超过物理内存。fork时COW导致内存翻倍,触发OOM。
- 改进:设置maxmemory和淘汰策略。监控内存使用率,80%告警。关闭THP。控制实例大小<10GB。
示例2:缓存雪崩导致数据库崩溃
- 现象:大量缓存key同时过期,请求全部打到数据库,数据库CPU 100%,服务不可用。
- 应急:限流降级,返回默认值。手动预热热点缓存。
- 根因:批量导入数据时设置了相同的TTL,导致同时过期。
- 改进:TTL加随机偏移。多级缓存(本地缓存兜底)。熔断降级机制。
示例3:大Key删除导致Redis卡顿
- 现象:业务反馈Redis响应超时,持续约5秒。
- 排查:slowlog发现一个DEL命令耗时4.8秒,删除了一个包含200万元素的Hash。
- 改进:开启lazyfree-lazy-expire。使用UNLINK替代DEL。定期扫描大Key并拆分。
四、缓存实战问题与故障处理(76-105题)
76. 🔵 什么是缓存的命中率?如何监控和提高缓存命中率?
答:缓存命中率 = 缓存命中次数 / (缓存命中次数 + 缓存未命中次数)。
监控:
INFO stats中的keyspace_hits和keyspace_misses。- 命中率 = keyspace_hits / (keyspace_hits + keyspace_misses)。
- 生产环境命中率应该>95%,<90%需要优化。
提高命中率的方法:
- 合理的TTL:TTL太短导致频繁失效,太长导致数据不一致。根据数据变化频率设置。
- 缓存预热:启动时加载热点数据,避免冷启动。
- 增大缓存容量:maxmemory调大,减少淘汰。
- 优化淘汰策略:使用LFU替代LRU(更好地保留热点数据)。
- 缓存空值:防止缓存穿透导致的无效查询。
- 合理的key设计:避免key过于细粒度(命中率低)或过于粗粒度(更新频繁)。
- 多级缓存:L1本地缓存拦截大部分请求。
- 分析未命中原因:是key不存在(穿透)?还是key过期(失效)?还是key被淘汰(容量不足)?针对性优化。
77. 🔴 如何设计一个基于Redis的分布式限流+熔断系统?
答:限流和熔断是微服务保护的两道防线。
限流(Rate Limiting):控制请求速率,防止过载。
- Redis实现:滑动窗口或令牌桶(见第35题)。
- 多维度:用户级、接口级、服务级、全局级。
- 限流后的处理:返回429 Too Many Requests,或排队等待。
熔断(Circuit Breaker):检测下游故障,快速失败,防止级联故障。
- 状态机:Closed(正常)→ Open(熔断,快速失败)→ Half-Open(试探恢复)。
- Redis实现:
1 | -- 记录失败次数 |
组合设计:
- 请求进入 → 检查熔断状态(Redis GET)→ 如果熔断则快速失败。
- 未熔断 → 检查限流(Redis限流脚本)→ 如果超限则拒绝。
- 通过限流 → 执行业务逻辑。
- 业务失败 → 记录失败次数(Redis INCR)→ 超过阈值则触发熔断。
- 熔断超时后 → Half-Open状态 → 放行少量请求试探 → 成功则关闭熔断。
生产建议:简单场景用Redis自行实现。复杂场景用Sentinel(阿里开源)或Resilience4j,它们内置了限流、熔断、降级等功能。
78. 🔵 什么是Redis的数据序列化?不同序列化方式的性能对比如何?
答:Redis存储的是字节数组,应用层需要将对象序列化为字节数组存储,读取时反序列化。
常见序列化方式:
| 方式 | 大小 | 速度 | 可读性 | 跨语言 |
|---|---|---|---|---|
| JSON | 大 | 慢 | 好 | 好 |
| Protobuf | 小 | 快 | 差 | 好 |
| Kryo | 小 | 最快 | 差 | 差(Java) |
| Hessian | 中 | 中 | 差 | 中 |
| Java Serializable | 大 | 慢 | 差 | 差(Java) |
| MessagePack | 小 | 快 | 差 | 好 |
选型建议:
- JSON:调试方便、跨语言。适合数据量不大、需要可读性的场景。Spring Boot默认使用Jackson JSON。
- Protobuf:性能好、体积小、跨语言。适合高性能要求的场景。需要定义.proto文件。
- Kryo:Java生态中最快的序列化框架。适合纯Java环境、对性能要求极高的场景。
- Java Serializable:不推荐。体积大、速度慢、安全风险(反序列化漏洞)。
Spring Data Redis配置:
1 |
|
注意:不同序列化方式存储的数据不兼容。切换序列化方式需要迁移数据或清空缓存。
79. 🔴 什么是Redis的Pipeline和Lua脚本的性能对比?各自适用什么场景?
答:Pipeline和Lua脚本都能减少网络往返,但适用场景不同。
性能对比:
| 维度 | Pipeline | Lua脚本 |
|---|---|---|
| 网络往返 | 1次RTT(批量发送) | 1次RTT(脚本执行) |
| 原子性 | 不保证 | 保证 |
| 命令间依赖 | 不支持 | 支持 |
| 服务端开销 | 低(逐条执行) | 中(脚本解析+执行) |
| 灵活性 | 低(固定命令序列) | 高(支持逻辑控制) |
| 调试 | 简单 | 较复杂 |
适用场景:
Pipeline适合:
- 批量读取/写入(如批量GET、批量SET)。
- 命令之间没有依赖关系。
- 不需要原子性保证。
- 示例:批量加载缓存、批量删除key。
Lua脚本适合:
- 需要原子性(如分布式锁、库存扣减)。
- 命令之间有依赖(如先GET再根据结果SET)。
- 需要逻辑控制(if/else、循环)。
- 示例:限流、CAS操作、复杂的条件更新。
性能数据(参考):
- 单命令:1万QPS(受RTT限制)。
- Pipeline(100条/批):10万+ QPS。
- Lua脚本(等效100条命令):5-8万QPS(脚本解析有开销)。
最佳实践:简单批量操作用Pipeline,需要原子性或逻辑控制用Lua脚本。两者可以结合使用(Pipeline中包含EVALSHA调用)。
80. 🔵 什么是Redis的CLUSTER FAILOVER命令?手动故障转移有什么用?
答:CLUSTER FAILOVER:在Redis Cluster中手动触发从节点升级为主节点。
使用场景:
- 计划内维护:需要重启或升级某个主节点时,先手动将其从节点提升为主节点,避免服务中断。
- 负载均衡:某个主节点负载过高,将其部分槽的从节点提升为主节点。
- 测试:测试故障转移流程是否正常。
命令:
1 | # 在从节点上执行 |
安全模式流程:
- 从节点向主节点发送FAILOVER请求。
- 主节点停止接受新的写入,将所有未同步的数据发送给从节点。
- 从节点确认数据同步完成。
- 从节点发起选举,获得多数主节点投票后成为新主节点。
- 旧主节点变为从节点。
与自动故障转移的区别:
- 自动故障转移:主节点故障后触发,可能丢失少量未同步的数据。
- 手动故障转移(安全模式):主节点正常运行,先同步数据再切换,不丢数据。
滚动升级中的应用:
- 对每个主节点执行:先在其从节点上执行CLUSTER FAILOVER。
- 从节点成为新主节点后,升级旧主节点(现在是从节点)。
- 升级完成后,可以再次CLUSTER FAILOVER切回(可选)。
81. 🔴 什么是Redis的内存分析?如何找出Redis中占用内存最多的数据?
答:Redis内存分析是排查内存问题的关键步骤。
在线分析(不影响服务):
INFO memory:整体内存使用情况。MEMORY USAGE key:单个key的内存占用。redis-cli --bigkeys:扫描找出每种类型最大的key。redis-cli --memkeys:按内存占用排序的key(Redis 4.0+)。MEMORY STATS:详细的内存使用统计(数据、元数据、缓冲区等)。
离线分析(更全面):
- 导出RDB:
BGSAVE生成RDB文件。 - redis-rdb-tools分析:
1 | # 导出所有key的内存信息为CSV |
- redis-memory-analyzer:可视化分析工具。
内存组成分析(MEMORY STATS):
dataset.bytes:实际数据占用的内存。overhead.total:Redis元数据开销(key的指针、过期时间、字典结构等)。replication.backlog:复制积压缓冲区。clients.normal:客户端输出缓冲区。aof.buffer:AOF缓冲区。
常见内存占用大户:
- 大Key(大Hash、大List、大Set)。
- 大量小Key的元数据开销(每个key约70字节的元数据)。
- 客户端输出缓冲区(大量Pub/Sub订阅者或慢客户端)。
- 复制缓冲区(repl-backlog-size)。
- 内存碎片。
82. 🔵 什么是Redis的RESP协议中的Inline命令?它有什么用?
答:Redis支持两种命令格式:RESP格式和Inline格式。
RESP格式(标准):
1 | *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n |
Inline格式(简化):
1 | SET key value\r\n |
Inline格式的用途:
- telnet调试:直接用telnet连接Redis,输入命令。不需要构造RESP格式。
1 | telnet localhost 6379 |
- 简单客户端:不需要实现完整的RESP协议解析,直接发送文本命令。
- redis-cli:redis-cli内部使用RESP格式,但用户输入的是Inline格式。
限制:
- Inline格式不支持二进制安全(value中不能包含空格和换行)。
- 生产环境的客户端应该使用RESP格式(二进制安全、性能更好)。
83. 🔴 如何设计一个基于Redis的分布式配置中心?
答:分布式配置中心:集中管理应用配置,支持动态更新和实时推送。
设计方案:
存储:
- 使用Hash存储配置:
HSET config:{app}:{env} key1 value1 key2 value2。 - 支持多应用、多环境(dev/test/prod)。
- 配置版本:
INCR config:{app}:{env}:version,每次修改递增版本号。
读取:
- 应用启动时从Redis加载配置:
HGETALL config:{app}:{env}。 - 本地缓存配置,减少Redis访问。
动态更新:
- 修改配置后,通过Redis Pub/Sub通知所有应用实例。
1 | # 修改配置 |
高可用:
- Redis Sentinel或Cluster保证配置存储的高可用。
- 应用本地缓存配置,Redis不可用时使用本地缓存。
与专业配置中心对比:
| 维度 | Redis方案 | Nacos/Apollo |
|---|---|---|
| 复杂度 | 简单 | 功能丰富 |
| 版本管理 | 简单版本号 | 完整的版本历史和回滚 |
| 灰度发布 | 需要自行实现 | 原生支持 |
| 权限管理 | Redis ACL | 完整的权限体系 |
| 审计日志 | 需要自行实现 | 原生支持 |
| 适用场景 | 小规模、简单配置 | 大规模、复杂配置管理 |
生产建议:简单场景(几十个配置项、几个服务)用Redis方案。复杂场景用Nacos或Apollo。
84. 🔵 什么是Redis的OBJECT HELP命令?Redis 7.0+的命令文档有什么改进?
答:Redis 7.0+对命令文档和自省能力做了重大改进。
COMMAND DOCS(Redis 7.0+):
COMMAND DOCS [command-name]:获取命令的详细文档(描述、参数、复杂度、版本等)。- 替代了之前需要查阅外部文档的方式。
COMMAND LIST(Redis 7.0+):
COMMAND LIST:列出所有命令。COMMAND LIST FILTERBY MODULE module-name:按模块过滤。COMMAND LIST FILTERBY PATTERN get*:按模式过滤。
COMMAND GETKEYS(Redis 7.0+改进):
COMMAND GETKEYS command args...:获取命令涉及的key。- 用于代理层分析命令的key,实现正确的路由。
这些改进使得Redis客户端和工具可以在运行时自省Redis的能力,不需要硬编码命令信息。
85. 🔴 什么是Redis的Cluster模式下的Multi-key操作?如何处理跨槽操作?
答:Redis Cluster中,涉及多个key的命令要求所有key在同一个槽中。
受影响的命令:
- MGET/MSET/DEL(多key)
- SUNION/SINTER/SDIFF(集合操作)
- ZUNIONSTORE/ZINTERSTORE(有序集合操作)
- RENAME(源key和目标key必须同槽)
- SORT … STORE(源和目标必须同槽)
- MULTI/EXEC中的多key命令
- Lua脚本中访问的多个key
解决方案:
- Hash Tag:在key中使用{}指定哈希计算的部分。
1 | # 以下key都映射到同一个槽(基于"user:1001"计算哈希) |
- 客户端拆分:将跨槽的MGET拆分为多个单key GET,并行发送到不同节点,客户端合并结果。
1 | // Lettuce自动处理跨槽MGET |
代理层处理:Redis代理(如Predixy、Codis)可以自动拆分跨槽命令。
Lua脚本:所有key使用相同的Hash Tag,在Lua脚本中执行复杂的多key操作。
Hash Tag的注意事项:
- 过度使用Hash Tag可能导致数据倾斜(大量key映射到同一个槽)。
- Hash Tag应该基于业务实体(如用户ID、订单ID),而非随意设置。
- 空的Hash Tag(如
{})会导致整个key作为哈希输入(等同于没有Hash Tag)。
86. 🔴 什么是Redis的Sentinel选举算法?Raft协议在Sentinel中是如何应用的?
答:Sentinel使用类Raft协议选举Leader Sentinel来执行故障转移。
选举流程:
- 某个Sentinel检测到主库客观下线(ODOWN)。
- 该Sentinel将自己的epoch(纪元)+1,向其他Sentinel发送
SENTINEL is-master-down-by-addr请求,请求投票。 - 每个Sentinel在同一个epoch中只能投票一次(先到先得)。
- 获得多数票(>= quorum且>= Sentinel总数/2+1)的Sentinel成为Leader。
- Leader Sentinel执行故障转移(选择新主库、通知从库、更新配置)。
与标准Raft的区别:
- Sentinel的选举是事件驱动的(只在需要故障转移时选举),不是持续的Leader维护。
- Sentinel不需要日志复制(不是状态机复制),只需要选出一个Leader执行一次性操作。
- 选举失败后(没有获得多数票),等待一段时间后重新选举(failover-timeout的2倍)。
quorum参数:
sentinel monitor mymaster 127.0.0.1 6379 2中的2就是quorum。- quorum的作用:至少quorum个Sentinel认为主库下线才触发故障转移。
- 选举Leader需要的票数:max(quorum, Sentinel总数/2+1)。
- 建议:3个Sentinel设quorum=2,5个Sentinel设quorum=3。
87. 🔵 什么是Redis的CLUSTER RESET命令?什么时候需要使用?
答:CLUSTER RESET:重置Redis Cluster节点的集群状态。
两种模式:
CLUSTER RESET SOFT:保留数据,清除集群状态(槽分配、已知节点列表、epoch等)。节点变为独立节点。CLUSTER RESET HARD:清除集群状态+清空数据+生成新的节点ID。完全重置。
使用场景:
- 重建集群:集群状态混乱无法修复时,RESET所有节点后重新组建集群。
- 移除节点:将节点从集群中移除后,RESET该节点使其成为独立节点。
- 测试:测试环境中快速重置集群。
注意事项:
- RESET前确保节点上没有重要数据(HARD模式会清空数据)。
- RESET后节点不再属于任何集群,需要重新CLUSTER MEET加入集群。
- 不要在生产环境的正常节点上执行RESET。
88. 🔴 什么是Redis的AOF重写?重写过程中如何保证数据不丢失?
答:AOF重写:将AOF文件中的冗余命令合并,生成更紧凑的AOF文件。
为什么需要重写:
- AOF文件记录所有写命令,文件会持续增长。
- 同一个key被修改100次,AOF中有100条命令,但只需要最后一条。
- 重写后只保留每个key的最终状态对应的命令。
重写流程(BGREWRITEAOF):
- 主进程fork子进程。
- 子进程遍历内存中的所有数据,将每个key的当前值转换为写命令,写入新的AOF文件。
- fork后主进程继续处理客户端请求。新的写命令同时写入:
- 旧AOF文件(保证旧AOF的完整性)。
- AOF重写缓冲区(aof_rewrite_buf)。
- 子进程完成重写后通知主进程。
- 主进程将AOF重写缓冲区中的命令追加到新AOF文件。
- 原子替换旧AOF文件为新AOF文件。
数据不丢失的保证:
- 重写期间旧AOF文件仍然在追加新命令(步骤3),如果重写失败,旧AOF文件仍然完整。
- 重写缓冲区记录了fork后的所有写命令(步骤3),追加到新AOF后数据完整。
- 文件替换是原子操作(rename系统调用)。
Redis 7.0的Multi-part AOF:
- AOF文件拆分为base文件(重写生成)+ incr文件(增量命令)+ manifest文件(索引)。
- 重写时只替换base文件,incr文件持续追加。
- 避免了单个大AOF文件的问题。
89. 🔵 什么是Redis的WAIT命令和WAITAOF命令?它们有什么区别?
答:WAIT和WAITAOF都是等待数据持久化的命令。
WAIT(Redis 3.0+):
WAIT numreplicas timeout- 等待之前的写命令被指定数量的从库确认(复制到从库的内存)。
- 保证:数据已复制到从库,但不保证从库已持久化到磁盘。
- 用途:强一致性读(写入后从从库读取最新数据)。
WAITAOF(Redis 7.2+):
WAITAOF numlocal numreplicas timeout- numlocal:等待本地AOF fsync完成的确认数(0或1)。
- numreplicas:等待从库AOF fsync完成的确认数。
- 保证:数据已持久化到磁盘(本地和/或从库)。
- 用途:强持久化保证(数据不会因为进程崩溃或机器重启而丢失)。
区别:
| 维度 | WAIT | WAITAOF |
|---|---|---|
| 保证级别 | 复制到从库内存 | 持久化到磁盘(AOF fsync) |
| 数据安全 | 从库进程崩溃可能丢失 | 进程崩溃不丢失 |
| 延迟 | 较低(内存复制) | 较高(磁盘fsync) |
| 版本 | 3.0+ | 7.2+ |
使用场景:
- WAIT:需要从从库读取最新数据,但可以接受极端情况下的数据丢失。
- WAITAOF:金融等对数据持久性要求极高的场景。
90. 🔴 什么是Redis的Cluster模式下的读写分离?READONLY命令的作用是什么?
答:Redis Cluster默认所有读写都发送到主节点。READONLY命令允许从从节点读取数据。
READONLY命令:
- 在从节点连接上执行
READONLY,该连接进入只读模式。 - 只读模式下,从节点接受读命令(GET、HGET等),不再返回MOVED重定向。
READWRITE命令退出只读模式。
使用场景:
- 读写分离:读请求发送到从节点,减轻主节点压力。
- 就近读取:跨可用区部署时,客户端从同可用区的从节点读取(减少延迟和跨区流量)。
客户端支持:
- Lettuce:
ReadFrom.REPLICA或ReadFrom.NEAREST配置。
1 | StatefulRedisClusterConnection<String, String> connection = clusterClient.connect(); |
- Jedis:需要手动管理从节点连接。
注意事项:
- 数据一致性:从节点的数据可能落后于主节点(异步复制)。读取可能返回旧数据。
- 从节点故障:从节点故障时需要自动切换到主节点读取。
- 写操作:写操作仍然必须发送到主节点。从节点收到写命令返回MOVED。
- 热点key:如果热点key的读请求集中在一个从节点,该从节点可能过载。需要多个从节点分担。
91. 🔵 什么是Redis的CONFIG REWRITE命令?它有什么用?
答:CONFIG REWRITE:将当前运行时的配置写回配置文件(redis.conf),使动态修改的配置持久化。
使用场景:
- 通过
CONFIG SET动态修改了配置(如maxmemory、slowlog-log-slower-than等)。 - 这些修改只在内存中生效,Redis重启后丢失。
CONFIG REWRITE将当前配置写回redis.conf,重启后仍然生效。
工作原理:
- 读取原始redis.conf文件。
- 对于已存在的配置项,更新为当前值。
- 对于新增的配置项(原文件中没有),追加到文件末尾。
- 保留原文件的注释和格式。
- 原子写入(先写临时文件,再rename)。
注意事项:
- 需要Redis进程对配置文件有写权限。
- 如果Redis启动时没有指定配置文件(redis-server不带参数),CONFIG REWRITE会失败。
- 建议在CONFIG SET后立即CONFIG REWRITE,避免遗忘。
- 配置文件的变更应该纳入版本管理(git)。
92. 🔴 什么是Redis的OBJECT ENCODING优化?如何通过调整编码阈值优化内存?
答:Redis的数据结构会根据数据量自动选择编码。通过调整编码阈值,可以在内存和性能之间权衡。
编码阈值配置:
1 | # Hash |
优化策略:
- 增大阈值节省内存:如将hash-max-listpack-entries从128调大到256,更多Hash使用listpack编码(比hashtable节省50%+内存)。代价:listpack的查找是O(n),元素多时性能下降。
- 减小阈值提升性能:如将阈值调小,更早切换到hashtable/skiplist编码。代价:内存占用增加。
- List压缩:
list-compress-depth 1,quicklist中间节点使用LZF压缩。节省内存但增加CPU开销。
实际优化案例:
- 存储大量小Hash(如用户Profile,5-10个field):保持默认阈值,确保使用listpack编码。
- 存储大Hash(如商品详情,50+个field):如果读写频繁,可以降低阈值提前切换到hashtable。
- 存储大量整数Set(如用户ID集合):增大set-max-intset-entries,保持intset编码。
验证:OBJECT ENCODING key确认key使用了预期的编码。
93. 🔴 什么是Redis的Cluster模式下的数据迁移原子性?MIGRATE命令的实现原理是什么?
答:MIGRATE命令在Redis Cluster的槽迁移中负责将key从源节点迁移到目标节点。
MIGRATE命令:
1 | MIGRATE target-host target-port key|"" destination-db timeout [COPY] [REPLACE] [AUTH password] [KEYS key1 key2 ...] |
实现原理:
- 源节点将key序列化(DUMP格式,包含value和TTL)。
- 源节点通过Socket将序列化数据发送到目标节点。
- 目标节点接收数据,执行RESTORE命令恢复key。
- 目标节点返回OK。
- 源节点收到OK后删除本地的key。
原子性保证:
- MIGRATE是同步阻塞操作。在整个过程中,源节点的主线程被阻塞。
- 如果迁移失败(网络错误、目标节点拒绝),源节点保留key,不删除。
- 使用COPY选项:迁移后源节点不删除key(复制而非移动)。
- 使用REPLACE选项:如果目标节点已存在同名key,覆盖。
性能影响:
- MIGRATE是阻塞操作,大Key迁移会阻塞源节点。
- 批量迁移(KEYS选项)比逐个迁移效率高(减少网络往返)。
- 建议:迁移前拆分大Key,使用批量MIGRATE。
94. 🔵 什么是Redis的SUBSCRIBE和PSUBSCRIBE的区别?模式订阅有什么性能问题?
答:
SUBSCRIBE channel1 channel2:精确订阅指定Channel。PSUBSCRIBE pattern*:模式订阅,匹配模式的所有Channel。
区别:
| 维度 | SUBSCRIBE | PSUBSCRIBE |
|---|---|---|
| 匹配方式 | 精确匹配 | 通配符匹配(*/?/[…]) |
| 性能 | O(1)查找 | O(N)遍历所有模式 |
| 消息接收 | 只收到订阅Channel的消息 | 收到所有匹配模式的Channel的消息 |
| 消息格式 | [“message”, channel, data] | [“pmessage”, pattern, channel, data] |
性能问题:
- PUBLISH时,Redis需要遍历所有PSUBSCRIBE的模式,逐个匹配。模式数量多时性能下降。
- 如果有1000个模式订阅,每次PUBLISH需要匹配1000次。
- 精确订阅(SUBSCRIBE)使用哈希表查找,O(1)。
最佳实践:
- 尽量使用精确订阅(SUBSCRIBE),避免大量模式订阅。
- 如果需要模式订阅,控制模式数量。
- Redis 7.0+的Sharded Pub/Sub不支持模式订阅(PSUBSCRIBE),只支持精确订阅。
95. 🔴 什么是Redis的RDB文件格式?如何解析RDB文件?
答:RDB文件是Redis数据的二进制快照,格式紧凑高效。
RDB文件结构(简化):
1 | [REDIS magic string "REDIS0011"] -- 文件头(REDIS + 版本号) |
解析工具:
- redis-rdb-tools(Python):最常用的RDB解析工具。
1 | pip install rdbtools |
- redis-cli –rdb:导出RDB文件。
- OBJECT ENCODING + DEBUG OBJECT:在线查看key的编码信息。
RDB版本演进:
- RDB版本随Redis版本升级(如Redis 7.0使用RDB版本10)。
- 高版本RDB不能被低版本Redis加载。
- 低版本RDB可以被高版本Redis加载(向后兼容)。
96. 🔵 什么是Redis的LATENCY命令?如何使用延迟监控排查性能问题?
答:LATENCY命令(Redis 2.8.13+)提供延迟事件的监控和分析。
开启延迟监控:
1 | CONFIG SET latency-monitor-threshold 50 # 监控超过50ms的事件 |
命令:
1 | LATENCY LATEST # 最近的延迟事件 |
LATENCY LATEST输出示例:
1 | 1) 1) "command" # 事件名称 |
延迟事件类型及排查方向:
command:命令执行慢 → 检查slowlog,排查大Key或O(n)命令。fork:fork操作慢 → 实例内存过大,关闭THP。aof-fsync-always:AOF每次fsync慢 → 磁盘IO问题,考虑改为everysec。aof-write:AOF写入慢 → 磁盘IO问题。rdb-unlink-temp-file:删除临时RDB文件慢 → 磁盘IO问题。expire-cycle:过期key清理慢 → 大量key同时过期。eviction-cycle:内存淘汰慢 → 淘汰的key太大。
排查流程:
LATENCY LATEST查看有哪些延迟事件。LATENCY HISTORY event查看事件的历史趋势。LATENCY GRAPH event可视化延迟趋势。- 根据事件类型针对性优化。
97. 🔴 如何设计一个基于Redis的实时推荐系统的缓存层?
答:实时推荐系统需要低延迟、高吞吐的缓存支持。
缓存层设计:
用户画像缓存:
- 数据结构:Hash(
user:{userId}:profile)。 - 内容:用户标签、偏好、历史行为统计。
- TTL:较长(24小时),定时从数据库/数据仓库刷新。
- 更新:用户行为触发异步更新(MQ消费后更新Redis)。
- 数据结构:Hash(
物品特征缓存:
- 数据结构:Hash(
item:{itemId}:features)。 - 内容:物品标签、类目、热度分数。
- TTL:中等(1-6小时)。
- 数据结构:Hash(
推荐结果缓存:
- 数据结构:List或ZSet(
recommend:{userId}:feed)。 - 内容:预计算的推荐列表(物品ID + 分数)。
- TTL:较短(5-30分钟),保证推荐的新鲜度。
- 策略:用户请求时先查缓存,缓存未命中则实时计算并缓存。
- 数据结构:List或ZSet(
热门物品缓存:
- 数据结构:ZSet(
hot:items:daily)。 - 内容:按热度排序的物品列表。
- 更新:定时任务每分钟更新。
- 用途:冷启动用户(无历史行为)的推荐兜底。
- 数据结构:ZSet(
实时特征缓存:
- 数据结构:String或Hash。
- 内容:实时计算的特征(如最近1小时的点击率、转化率)。
- 更新:Flink实时计算后写入Redis。
- TTL:很短(1-5分钟)。
布隆过滤器去重:
- 数据结构:RedisBloom。
- 用途:过滤用户已看过的物品,避免重复推荐。
- 每个用户一个布隆过滤器(
seen:{userId})。
性能优化:
- Pipeline批量读取用户画像和物品特征。
- 本地缓存热门物品(L1 Cache)。
- 预计算推荐结果,减少实时计算压力。
98. 🔵 什么是Redis的CONFIG SET和CONFIG GET?有哪些常用的运行时配置?
答:CONFIG SET/GET用于在运行时查看和修改Redis配置,无需重启。
常用运行时配置:
内存相关:
1 | CONFIG SET maxmemory 4gb # 最大内存 |
持久化相关:
1 | CONFIG SET save "900 1 300 10 60 10000" # RDB触发规则 |
性能相关:
1 | CONFIG SET slowlog-log-slower-than 1000 # 慢查询阈值(微秒) |
安全相关:
1 | CONFIG SET requirepass "strong-password" # 设置密码 |
复制相关:
1 | CONFIG SET min-replicas-to-write 1 # 最少从库数 |
注意:CONFIG SET的修改只在内存中生效,重启后丢失。使用CONFIG REWRITE持久化到配置文件。
99. 🔴 什么是Redis的CLUSTER SLOTS和CLUSTER SHARDS命令?它们有什么区别?
答:两个命令都用于获取Redis Cluster的槽分配信息,但格式不同。
CLUSTER SLOTS(旧命令):
1 | CLUSTER SLOTS |
- 返回格式:数组嵌套,按槽范围组织。
- 每个槽范围包含:起始槽、结束槽、主节点信息、从节点信息。
CLUSTER SHARDS(Redis 7.0+,推荐):
1 | CLUSTER SHARDS |
- 返回格式:更结构化,使用Map格式(RESP3)。
- 包含更多节点信息(health、replication-offset等)。
- 更易解析和使用。
区别:
| 维度 | CLUSTER SLOTS | CLUSTER SHARDS |
|---|---|---|
| 版本 | 3.0+ | 7.0+ |
| 格式 | 数组嵌套 | 结构化Map |
| 信息量 | 基本信息 | 更丰富(健康状态等) |
| 解析难度 | 较难 | 较易 |
客户端使用:智能客户端(Jedis、Lettuce)使用这些命令获取槽→节点的映射,缓存在本地,直接将命令发送到正确的节点。
100. ⚫ 作为架构师,你如何评估一个系统是否需要引入Redis?引入Redis后需要关注哪些风险?
答:这是一道架构决策题,考察全局视角和风险意识。
引入Redis的评估标准:
需要引入的信号:
- 数据库查询成为性能瓶颈(QPS高、响应慢)。
- 存在热点数据(少量数据被大量访问)。
- 需要分布式锁、限流、排行榜等功能。
- 需要低延迟的数据访问(毫秒级)。
- 需要临时数据存储(Session、验证码、Token)。
不需要引入的信号:
- 数据库性能足够,没有瓶颈。
- 数据量小、访问量低。
- 数据一致性要求极高(Redis是最终一致)。
- 团队没有Redis运维经验(引入新组件有学习成本)。
引入后的风险:
数据一致性:缓存和数据库的数据不一致。需要设计一致性方案(Cache Aside + TTL + binlog异步更新)。
可用性依赖:系统对Redis产生依赖,Redis故障影响业务。需要:高可用部署(Sentinel/Cluster)、降级方案(Redis不可用时直接查数据库或返回默认值)。
内存成本:Redis是内存存储,成本高于磁盘。需要:合理的TTL和淘汰策略、控制缓存数据量、定期清理无用数据。
运维复杂度:增加了一个需要监控和维护的组件。需要:完善的监控告警、备份恢复方案、扩缩容方案。
缓存穿透/击穿/雪崩:缓存失效时的连锁反应。需要:布隆过滤器、互斥锁、随机TTL、多级缓存。
大Key/热Key:影响Redis性能和稳定性。需要:定期扫描、拆分大Key、本地缓存热Key。
安全风险:Redis默认无认证,暴露在公网可能被攻击。需要:ACL、网络隔离、TLS加密。
好的架构师不是盲目引入技术,而是在充分评估收益和风险后做出合理决策。
五、补充题(101-105题)
101. 🔴 什么是Redis的COPY命令(Redis 6.2+)?它有什么用?
答:COPY命令:将一个key的值复制到另一个key,支持跨数据库复制。
语法:COPY source destination [DB destination-db] [REPLACE]
使用场景:
- 数据备份:复制key作为备份,修改前保留原始数据。
- 数据迁移:跨数据库复制key(DB选项)。
- 原子复制:COPY是原子操作,比GET+SET更安全。
与DUMP+RESTORE对比:
- DUMP+RESTORE:序列化→传输→反序列化,支持跨实例。
- COPY:内存级复制,只支持同实例(或同Cluster节点)。更快。
注意:Cluster模式下,source和destination必须在同一个槽。
102. 🔵 什么是Redis的LPOS命令(Redis 6.0.6+)?它解决了什么问题?
答:LPOS:在List中查找元素的位置(索引)。
语法:LPOS key element [RANK rank] [COUNT count] [MAXLEN len]
之前的问题:Redis没有直接查找List中元素位置的命令。需要LRANGE获取所有元素后在客户端查找,效率低。
使用示例:
1 | RPUSH mylist a b c d c b a |
103. 🔴 什么是Redis的GETDEL和GETEX命令(Redis 6.2+)?
答:
GETDEL key:获取key的值并删除key。原子操作,等同于GET+DEL。GETEX key [EX seconds] [PX ms] [EXAT timestamp] [PXAT ms-timestamp] [PERSIST]:获取key的值并设置/修改/移除过期时间。
使用场景:
- GETDEL:一次性消费的数据(如验证码:获取后立即删除)。
- GETEX:读取数据时顺便续期(如Session:每次访问时延长过期时间)。
之前需要两条命令(GET+DEL或GET+EXPIRE),现在一条命令完成,减少网络往返且保证原子性。
104. 🔵 什么是Redis的CLIENT NO-EVICT命令(Redis 7.0+)?
答:CLIENT NO-EVICT ON:标记当前客户端连接为”不可驱逐”。
背景:当Redis内存达到maxmemory时,如果客户端的输出缓冲区占用大量内存,Redis可能断开该客户端连接来释放内存。
NO-EVICT的作用:保护重要的客户端连接不被驱逐。适用于:
- 监控客户端(持续订阅Pub/Sub)。
- 管理客户端(执行运维命令)。
- 关键业务客户端。
注意:过多的NO-EVICT客户端可能导致内存无法释放,需要谨慎使用。
105. ⚫ 如果Redis的QPS已经达到瓶颈,你有哪些扩展方案?
答:Redis单实例QPS瓶颈(通常10-20万QPS)的扩展方案:
读写分离:写操作走主库,读操作分散到多个从库。适合读多写少的场景。线性扩展读能力。
Redis Cluster分片:数据分散到多个主节点,每个节点处理一部分请求。线性扩展读写能力。
本地缓存(L1 Cache):在应用层增加Caffeine等本地缓存,拦截大部分读请求。减少Redis访问量。
多线程IO(Redis 6.0+):开启io-threads,提升网络IO处理能力。单实例QPS可提升到20万+。
Pipeline/Lua批量操作:减少网络往返,提高单次请求的效率。
业务优化:减少不必要的Redis访问(合并请求、缓存预计算结果)。优化数据结构(使用更高效的编码)。
多实例部署:在同一台机器上部署多个Redis实例(利用多核CPU)。每个实例绑定不同的CPU核心。
客户端优化:使用连接池、异步客户端(Lettuce)、批量操作。减少客户端到Redis的网络延迟。
选择顺序:先优化(Pipeline/本地缓存/业务优化)→ 再扩展(读写分离/Cluster)→ 最后换架构(多级缓存体系)。
四、多级缓存架构(106-125题)
106. 🔵 什么是多级缓存架构?为什么单一Redis缓存不够用?
答:多级缓存是在不同层级设置缓存,逐级降低对下游的访问压力。
单一Redis缓存的局限:
- 网络延迟:即使Redis在同机房,一次网络往返也需要0.5-1ms。高并发下累积延迟可观
- 带宽瓶颈:热点数据的大量读取可能打满Redis网络带宽(单实例通常10Gbps)
- 单点风险:Redis故障时所有请求直接打到数据库
- 序列化开销:每次从Redis读取都需要反序列化,CPU开销不可忽视
多级缓存架构:
1 | 请求 → CDN(静态资源) |
各层的价值:
- CDN:缓存静态资源和可缓存的API响应,拦截80%+的静态请求
- Nginx缓存:在网关层缓存热点API响应,减少后端服务压力
- L1本地缓存:纳秒级访问,零网络开销,拦截最热的数据请求
- L2 Redis:毫秒级访问,容量大,所有实例共享
- L3数据库:兜底数据源
性能对比:
- L1(Caffeine):~100ns,亿级QPS/实例
- L2(Redis):~1ms,10万级QPS/实例
- L3(MySQL):~10ms,万级QPS/实例
每一层缓存命中都能避免访问下一层,整体性能提升是指数级的。
107. 🔴 Caffeine作为L1本地缓存的核心原理是什么?它为什么比Guava Cache快?
答:Caffeine是Java生态中性能最高的本地缓存库,被Spring Boot 2.x+作为默认缓存实现。
核心设计:
W-TinyLFU淘汰算法:
- 结合了LRU和LFU的优点
- 分为Window Cache(1%容量,LRU)和Main Cache(99%容量,分为Probation和Protected两段)
- 新数据先进入Window Cache
- Window Cache淘汰的数据与Main Cache Probation段的数据通过TinyLFU频率比较,频率高的留下
- TinyLFU使用Count-Min Sketch(概率计数器)记录访问频率,只需要极少内存
高并发数据结构:
- 使用ConcurrentHashMap存储数据
- 读写操作记录到Ring Buffer(环形缓冲区),由专门的维护线程异步处理淘汰和过期
- 避免了Guava Cache在每次读写时同步执行淘汰检查的开销
异步维护:
- 缓存的淘汰、过期、统计等维护操作由后台线程异步执行
- 读写操作几乎无额外开销
为什么比Guava Cache快:
| 维度 | Guava Cache | Caffeine |
|---|---|---|
| 淘汰算法 | LRU(Segmented LRU) | W-TinyLFU(命中率更高) |
| 并发机制 | 分段锁(Segment Lock) | ConcurrentHashMap + Ring Buffer |
| 维护操作 | 同步执行(读写时触发) | 异步执行(后台线程) |
| 过期检查 | 读写时惰性检查 | 定时轮(Timing Wheel)精确调度 |
| 性能 | 百万级ops/s | 千万级ops/s |
使用示例:
1 | Cache<String, Object> cache = Caffeine.newBuilder() |
108. 🔴 L1本地缓存和L2 Redis缓存如何协同工作?数据一致性如何保证?
答:L1+L2协同是多级缓存的核心难题。
读取流程:
1 | 1. 查L1(Caffeine)→ 命中则直接返回 |
写入/更新流程:
1 | 1. 更新数据库 |
L1一致性问题:
- 多个应用实例各自有独立的L1缓存
- 数据更新后,只有处理更新请求的实例知道数据变了
- 其他实例的L1缓存仍然是旧数据
L1缓存失效通知方案:
- Redis Pub/Sub通知:
1 | // 数据更新时发布失效消息 |
- 优点:实时性好,实现简单
- 缺点:Redis Pub/Sub不持久化,实例重启期间的消息会丢失
- RocketMQ/Kafka广播消费:
- 使用广播模式消费失效消息,每个实例都能收到
- 优点:消息不丢失,可靠性高
- 缺点:引入MQ依赖,延迟略高
- 短TTL兜底:
- L1缓存设置较短的TTL(如10秒-1分钟)
- 即使通知丢失,最多在TTL时间内数据不一致
- 这是最简单也最实用的兜底方案
生产建议:Redis Pub/Sub通知 + 短TTL兜底,双重保障。
109. 🔵 CDN缓存在多级缓存体系中扮演什么角色?如何设计CDN缓存策略?
答:CDN是多级缓存的最外层,距离用户最近,效果最显著。
CDN缓存的适用内容:
- 静态资源:JS/CSS/图片/字体/视频,命中率可达95%+
- 可缓存的API响应:商品详情、文章内容等变化不频繁的数据
- 页面片段:SSR渲染的HTML页面
CDN缓存策略设计:
缓存时间(TTL):
- 静态资源(带hash指纹):1年(文件名变了就是新URL)
- 静态资源(无hash):1天-7天
- API响应:10秒-5分钟(根据数据变化频率)
- 用户个性化数据:不缓存
缓存Key设计:
- 默认按URL缓存
- 需要区分的维度加入Key:设备类型(PC/Mobile)、语言、地域
- 避免Cookie等用户相关信息进入Key(导致缓存命中率极低)
缓存失效:
- TTL自然过期
- 主动Purge:数据更新时调用CDN API清除缓存
- 版本化URL:
/api/products/123?v=20240101,更新时改版本号
回源策略:
- 缓存未命中时回源到源站
- 源站通过
Cache-Control头控制CDN缓存行为 Cache-Control: public, max-age=300:CDN缓存5分钟Cache-Control: private, no-cache:不缓存(用户个性化数据)Stale-While-Revalidate:缓存过期后先返回旧数据,后台异步回源更新
CDN + 多级缓存的协同:
1 | 用户请求 → CDN(命中率90%)→ 网关Nginx缓存 → L1 Caffeine → L2 Redis → DB |
CDN拦截了90%的请求后,到达后端的流量只有10%,后端的多级缓存压力大幅降低。
110. 🔴 Nginx层的缓存如何实现?OpenResty+Lua在缓存架构中有什么作用?
答:Nginx/OpenResty层缓存是多级缓存中的重要一环,位于CDN和应用之间。
Nginx原生缓存(proxy_cache):
1 | proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m |
OpenResty + Lua的高级缓存:
- OpenResty = Nginx + LuaJIT,可以在Nginx中执行Lua脚本
- 使用
lua-resty-lrucache实现Nginx Worker进程内的本地缓存 - 使用
lua-resty-redis直接从Nginx访问Redis
多级缓存在OpenResty中的实现:
1 | -- 三级缓存:Worker本地缓存 → 共享内存 → Redis |
优势:在Nginx层就拦截大量请求,后端应用服务器压力大幅降低。适合读多写少的热点数据。
111. 🔴 多级缓存架构下的缓存穿透、击穿、雪崩问题如何升级应对?
答:多级缓存下这三个经典问题的应对策略需要在每一层都考虑。
缓存穿透(查询不存在的数据):
- L1层:本地布隆过滤器(Guava BloomFilter),拦截明显不存在的Key
- L2层:Redis布隆过滤器(RedisBloom),所有实例共享
- 兜底:缓存空值到L2(短TTL),L1也缓存空值
- 多级防护:请求依次经过L1布隆过滤器 → L2布隆过滤器 → 空值缓存,穿透到数据库的概率极低
缓存击穿(热点Key过期瞬间):
- L1层:使用Caffeine的
refreshAfterWrite,过期前异步刷新,永远有数据可用 - L2层:分布式锁(Redisson)保证只有一个线程回源数据库
- 逻辑过期:L2中存储逻辑过期时间,过期后异步更新,当前请求返回旧数据
- 多级防护:L1的异步刷新 + L2的互斥锁,双重保障
缓存雪崩(大量Key同时过期或Redis宕机):
- L1层:Redis宕机时L1本地缓存兜底,保证核心功能可用
- L2层:过期时间加随机偏移,避免同时过期
- 熔断降级:Sentinel/Resilience4j熔断,数据库压力过大时返回默认值
- Redis高可用:Cluster模式 + 多副本,避免整体宕机
关键原则:每一层缓存都是下一层的保护屏障。即使某一层完全失效,其他层仍然能提供服务。
112. 🔴 缓存预热在多级缓存架构中如何实现?大促前的预热策略是什么?
答:多级缓存的预热比单级缓存更复杂,需要逐层预热。
预热顺序(从下到上):
1 | 1. 预热L2(Redis):从数据库加载热点数据到Redis |
大促前预热策略:
数据分析阶段(大促前3天):
- 分析历史大促的访问日志,找出Top 1000热点商品
- 分析搜索热词,预测热点品类
- 与运营确认主推商品和活动页面
L2预热(大促前1天):
- 批量将热点商品数据加载到Redis
- 设置较长的TTL(如24小时),避免大促期间过期
- 预热商品详情、库存、价格等核心数据
L1预热(大促前1小时):
- 应用实例启动时通过
@PostConstruct从Redis加载Top热点数据 - 或通过预热接口触发:
POST /admin/cache/warmup
- 应用实例启动时通过
CDN预热(大促前2小时):
- 调用CDN预热API,提交热点URL列表
- CDN主动回源缓存这些URL的响应
- 预热静态资源(商品图片、活动页面JS/CSS)
验证阶段(大促前30分钟):
- 检查各层缓存命中率
- 模拟请求验证热点数据是否已缓存
- 监控Redis内存使用率和连接数
113. 🔵 Spring Cache如何集成多级缓存?有哪些开源的多级缓存框架?
答:Spring Cache原生只支持单级缓存,需要扩展或使用第三方框架实现多级缓存。
Spring Cache的局限:
@Cacheable只能指定一个CacheManager- 不支持L1+L2的级联查找和回填
- 不支持缓存失效通知
开源多级缓存框架:
J2Cache(红薯/OSChina开源):
- L1:Caffeine/Ehcache + L2:Redis
- 内置Redis Pub/Sub缓存失效通知
- 与Spring Cache无缝集成
- 配置简单,开箱即用
Jetcache(阿里开源):
- 支持多级缓存(Local + Remote)
- 注解驱动:
@Cached、@CacheUpdate、@CacheInvalidate - 支持自动刷新(
@CacheRefresh) - 内置缓存统计和监控
1
2
3
4
public User getUserById(Long id) { ... }自定义实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MultiLevelCache {
private final Cache<String, Object> l1Cache; // Caffeine
private final RedisTemplate<String, Object> redisTemplate; // Redis
public Object get(String key) {
// L1
Object val = l1Cache.getIfPresent(key);
if (val != null) return val;
// L2
val = redisTemplate.opsForValue().get(key);
if (val != null) {
l1Cache.put(key, val); // 回填L1
return val;
}
return null;
}
public void put(String key, Object val, long ttlSeconds) {
redisTemplate.opsForValue().set(key, val, ttlSeconds, TimeUnit.SECONDS);
l1Cache.put(key, val);
// 通知其他实例失效L1
redisTemplate.convertAndSend("cache:invalidate", key);
}
}
推荐:中小项目用J2Cache(简单),大型项目用Jetcache(功能丰富)或自定义实现(灵活可控)。
114. 🔴 多级缓存的监控和可观测性如何设计?需要关注哪些指标?
答:多级缓存的监控比单级缓存复杂得多,需要监控每一层的状态。
核心监控指标:
命中率(Hit Rate):最重要的指标
- L1命中率:Caffeine的
stats().hitRate(),目标>80% - L2命中率:Redis的
INFO stats中的keyspace_hits/(keyspace_hits+keyspace_misses),目标>95% - 总命中率:1 - (数据库查询次数 / 总请求次数),目标>99%
- 命中率下降是缓存问题的第一信号
- L1命中率:Caffeine的
延迟(Latency):
- L1访问延迟:通常<1ms,超过说明GC或锁竞争
- L2访问延迟:通常1-5ms,超过说明网络或Redis负载问题
- 回源延迟:数据库查询延迟,缓存未命中时的代价
容量和内存:
- L1:缓存条目数、内存占用、淘汰次数
- L2:Redis内存使用率、Key数量、大Key检测
- 淘汰频率过高说明容量不足
一致性:
- 失效通知的延迟和丢失率
- L1和L2数据不一致的比例(采样对比)
监控实现:
1 | // Caffeine统计 |
告警规则:
- L1命中率 < 70%:Warning
- L2命中率 < 90%:Warning
- 总命中率 < 95%:Critical
- Redis内存使用率 > 80%:Warning
- 缓存失效通知延迟 > 5s:Warning
115. 🔴 什么是缓存的”惊群效应”(Thundering Herd)?在多级缓存中如何避免?
答:惊群效应:缓存失效瞬间,大量请求同时穿透到下游(数据库),造成瞬间压力激增。
与缓存击穿的区别:
- 缓存击穿:单个热点Key过期
- 惊群效应:更广义,包括缓存重建、服务重启、批量Key过期等场景
多级缓存中的惊群场景:
- L1缓存重建:应用重启后L1为空,所有请求打到L2
- L2缓存重建:Redis故障恢复后缓存为空,所有请求打到数据库
- 批量L1过期:大量L1缓存同时过期,瞬间打到L2
解决方案:
互斥锁(Mutex Lock):
- L1未命中时,只有一个线程去查L2/DB,其他线程等待或返回旧值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public Object getWithLock(String key) {
Object val = l1Cache.getIfPresent(key);
if (val != null) return val;
Lock lock = lockMap.computeIfAbsent(key, k -> new ReentrantLock());
if (lock.tryLock()) {
try {
val = loadFromL2OrDB(key);
l1Cache.put(key, val);
} finally {
lock.unlock();
}
} else {
// 等待或返回默认值
Thread.sleep(50);
return l1Cache.getIfPresent(key);
}
return val;
}Caffeine的异步刷新:
refreshAfterWrite:过期前异步刷新,始终有旧值可用- 只有一个线程执行刷新,其他线程返回旧值
- 最优雅的方案,推荐使用
渐进式预热:
- 应用启动后不立即承担全部流量
- 通过负载均衡权重控制,逐步增加流量(10%→30%→50%→100%)
- 给L1缓存足够的预热时间
L1过期时间加随机偏移:
TTL = baseTTL + random(0, baseTTL * 0.2)- 避免大量L1缓存同时过期
116. 🔵 本地缓存的容量规划如何做?如何避免OOM?
答:本地缓存直接占用JVM堆内存,规划不当会导致OOM或频繁GC。
容量估算:
1 | 单条数据大小 × 最大缓存条目数 = 缓存总内存 |
- 使用JOL(Java Object Layout)工具精确测量对象大小
- 或通过
Runtime.getRuntime().totalMemory()前后对比估算
Caffeine的容量控制:
1 | Caffeine.newBuilder() |
容量规划原则:
- 本地缓存总量不超过堆内存的20-30%:留足空间给业务对象和GC
- 单个缓存实例不超过1万条:条目太多会增加GC压力(每个对象都是GC Root的可达对象)
- 大对象不放本地缓存:超过10KB的对象放Redis,避免堆内存膨胀
- 监控缓存大小:
cache.estimatedSize()定期上报,接近上限时告警
避免OOM的措施:
- 设置
maximumSize硬上限,Caffeine会自动淘汰 - 使用弱引用(
weakKeys()/weakValues()),GC压力大时自动回收 - 定期清理:
cache.cleanUp()手动触发清理 - JVM参数:
-XX:+HeapDumpOnOutOfMemoryError,OOM时自动dump分析
117. 🔴 如何设计一个电商商品详情页的多级缓存方案?
答:商品详情页是多级缓存的经典应用场景,读多写少,热点集中。
数据分析:
- 商品基本信息:变化不频繁(名称、描述、图片),强缓存
- 商品价格:可能频繁变化(促销),短缓存
- 库存状态:实时性要求高,极短缓存或不缓存
- 用户评价:变化频繁但实时性要求不高,中等缓存
多级缓存设计:
CDN层:
- 商品图片:CDN缓存1年(URL带hash指纹)
- 商品详情页HTML(SSR):CDN缓存5分钟,
Stale-While-Revalidate: 60 - 商品API响应:CDN缓存1分钟(非登录态)
Nginx/OpenResty层:
- 热门商品的API响应缓存在Nginx共享内存中
- TTL:30秒
- 使用Lua脚本实现缓存逻辑,命中后直接返回,不转发到后端
L1本地缓存(Caffeine):
- 商品基本信息:TTL 5分钟,最大1万条
- 商品价格:TTL 30秒(价格变化需要快速感知)
- 分类/品牌等字典数据:TTL 30分钟
L2分布式缓存(Redis):
- 商品完整信息:TTL 1小时,Hash结构存储各字段
- 库存:TTL 10秒或不缓存(直接查库存服务)
- 评价摘要:TTL 5分钟
数据库层:
- 兜底数据源
- 读写分离:详情页查询走从库
缓存更新策略:
- 商品信息变更 → Canal监听binlog → 删除L2缓存 → Redis Pub/Sub通知删除L1
- 价格变更 → 直接删除L2 → 广播删除L1(价格敏感,不能有延迟)
- 库存变更 → 不走缓存或极短TTL(库存准确性要求高)
118. 🔴 多级缓存架构下如何处理缓存与数据库的最终一致性?
答:多级缓存的一致性比单级缓存更复杂,因为需要保证多层缓存的一致性。
一致性挑战:
- L1缓存分布在多个应用实例中,各自独立
- L2缓存是共享的,但与数据库之间有延迟
- CDN缓存在边缘节点,更新延迟更大
分层一致性策略:
数据库 → L2(Redis)一致性:
- 方案:Canal监听binlog + 异步删除Redis缓存
- 延迟:通常100ms-1s
- 兜底:Redis缓存设置TTL,即使Canal延迟也能在TTL后自动过期
L2(Redis)→ L1(Caffeine)一致性:
- 方案:Redis Pub/Sub广播失效通知
- 延迟:通常10ms-100ms
- 兜底:L1设置短TTL(10秒-1分钟)
L2(Redis)→ CDN一致性:
- 方案:数据变更时调用CDN Purge API
- 延迟:通常1-5秒(CDN全球节点同步)
- 兜底:CDN缓存设置较短的TTL
整体一致性保障:
1 | 数据库更新 |
最坏情况下的不一致窗口:
- L1:Pub/Sub通知延迟 + L1 TTL = 最多1分钟
- L2:Canal延迟 + Redis TTL = 最多几秒到1小时
- CDN:Purge延迟 + CDN TTL = 最多几秒到5分钟
对于大多数业务场景,秒级的最终一致性是可以接受的。对于价格、库存等敏感数据,应该绕过缓存直接查询。
119. 🔴 什么是缓存的”读写穿透”模式?Read-Through和Write-Through如何在多级缓存中实现?
答:Read-Through和Write-Through将缓存操作封装在缓存层内部,业务代码只操作缓存。
Read-Through(读穿透):
- 缓存未命中时,缓存层自动从数据源加载数据并缓存
- 业务代码不需要关心”缓存未命中→查数据库→回填缓存”的逻辑
- Caffeine原生支持:
1 | LoadingCache<String, User> cache = Caffeine.newBuilder() |
Write-Through(写穿透):
- 写操作时,缓存层同步写入数据源
- 保证缓存和数据源的强一致性
- 代价:写入延迟增加(需要等待数据源写入完成)
Write-Behind(异步写回):
- 写操作只更新缓存,缓存层异步批量写入数据源
- 优点:写入性能极高(只写内存)
- 缺点:缓存故障可能丢数据
- 适合:计数器、浏览量等允许少量丢失的场景
多级缓存中的Read-Through实现:
1 | // L1 Read-Through:未命中自动从L2加载 |
这种模式让业务代码完全不感知缓存的存在,缓存逻辑集中在CacheLoader中管理。
120. 🔵 多级缓存架构的降级策略如何设计?各层故障时如何保证系统可用?
答:降级策略是多级缓存高可用的关键。
各层故障的降级方案:
CDN故障:
- 降级:DNS切换到备用CDN或直接回源
- 影响:延迟增加,源站压力增大
- 预案:多CDN厂商备份,自动切换
Nginx缓存层故障:
- 降级:请求直接到达应用层
- 影响:L1/L2缓存仍然有效,影响较小
- 预案:Nginx集群多实例,负载均衡自动摘除故障节点
L1(Caffeine)故障(通常是应用重启):
- 降级:所有请求打到L2(Redis)
- 影响:延迟从纳秒级升到毫秒级,Redis压力增大
- 预案:渐进式预热,启动后逐步增加流量
L2(Redis)故障:
- 降级:L1本地缓存兜底 + 数据库直接查询
- 影响:L1未命中的请求直接打到数据库,压力大
- 预案:
- Redis Sentinel/Cluster自动故障转移
- 熔断器:Redis不可用时快速失败,不等待超时
- L1延长TTL:Redis故障期间L1缓存不过期
- 限流:保护数据库不被打垮
数据库故障:
- 降级:返回缓存中的旧数据(即使已过期)
- 影响:数据可能不是最新的,但系统仍然可用
- 预案:缓存设置
stale-while-error策略,数据库不可用时返回过期缓存
降级代码示例:
1 | public Object getWithDegradation(String key) { |
121. 🔴 Redis Client-side Caching与应用层L1缓存有什么区别?如何选择?
答:两者都是”本地缓存”,但实现机制和适用场景不同。
Redis Client-side Caching(Redis 6.0+):
- 客户端缓存Redis中的数据,Redis服务端在数据变更时主动推送失效通知
- 一致性由Redis服务端保证(Tracking机制)
- 客户端不需要自己实现失效逻辑
应用层L1缓存(Caffeine等):
- 应用自己管理的本地缓存,与Redis无直接关联
- 一致性需要自己实现(Pub/Sub通知、TTL兜底等)
- 更灵活,可以缓存任何数据源的数据(不限于Redis)
对比:
| 维度 | Client-side Caching | 应用层L1缓存 |
|---|---|---|
| 失效机制 | Redis服务端推送 | 自己实现(Pub/Sub/MQ) |
| 一致性 | 强(服务端保证) | 最终一致(有延迟窗口) |
| 数据来源 | 只能缓存Redis中的数据 | 任何数据源 |
| 实现复杂度 | 低(客户端库支持) | 中(需要自己实现通知) |
| 灵活性 | 低(受Redis Tracking限制) | 高(完全自定义) |
| 客户端支持 | Lettuce 6.0+支持 | 任何语言/框架 |
选择建议:
- 数据全部来自Redis且使用Lettuce客户端:优先用Client-side Caching,实现简单一致性好
- 数据来源多样(Redis+DB+API):用应用层L1缓存,更灵活
- 两者可以结合使用:Client-side Caching处理Redis数据,Caffeine处理其他数据源
122. 🔴 如何设计一个支持”读写分离”的多级缓存框架?
答:读写分离的多级缓存需要区分读路径和写路径,保证一致性的同时最大化性能。
读路径设计:
1 | 读请求 → L1(Caffeine,本地) |
写路径设计:
1 | 写请求 → DB(主库) |
框架核心接口:
1 | public interface MultiLevelCacheManager { |
读写分离的关键点:
- Redis读写分离:写操作走Master,读操作走Slave(Lettuce原生支持
ReadFrom.REPLICA) - DB读写分离:缓存未命中时查从库,写操作走主库
- 一致性保障:写操作完成后,短时间内的读请求强制走主库/Master(避免主从延迟导致读到旧数据)
实现技巧:
- 使用ThreadLocal标记”刚写入”状态,短时间内(如2秒)读请求绕过缓存直接查主库
- 或使用”写后读主”策略:写操作后设置一个短TTL的标记Key,有标记时读走主库
123. ⚫ 如果让你设计一个千万级QPS的缓存架构,你会怎么做?
答:千万级QPS意味着单一缓存层无法承载,必须多级缓存+水平扩展。
架构设计:
流量分层:
- CDN拦截80%的静态请求(图片、JS/CSS、可缓存API)
- 到达后端的有效请求约200万QPS
网关层缓存(OpenResty):
- 100台Nginx实例,每台2万QPS
- Worker本地LRU缓存热点数据
- 命中率目标50%,拦截后剩余100万QPS
应用层L1缓存(Caffeine):
- 500台应用实例,每台2000QPS
- Caffeine缓存Top 1万热点数据
- 命中率目标80%,拦截后剩余20万QPS
L2分布式缓存(Redis Cluster):
- 50个主节点的Redis Cluster,每节点4万QPS
- 总容量200万QPS,实际承载20万QPS(留足余量)
- 命中率目标99%,穿透到DB的只有2000QPS
数据库层:
- 主从集群,从库承载2000QPS绰绰有余
关键设计决策:
- 热点发现:实时统计访问频率,动态识别热点Key,推送到L1和网关层
- 一致性:分层TTL(L1: 10s, L2: 5min, CDN: 1min),Pub/Sub通知失效
- 容灾:任何一层故障,上下层自动承接流量。Redis故障时L1延长TTL兜底
- 弹性扩缩:根据流量自动扩缩应用实例和Redis节点
成本估算:
- CDN:按流量计费
- Nginx:100台4核8G ≈ 中等成本
- 应用服务器:500台8核16G ≈ 主要成本
- Redis:50台16核64G ≈ 中等成本
124. 🔴 多级缓存中的序列化问题如何解决?不同层使用不同序列化格式的利弊?
答:序列化是多级缓存中容易被忽视但影响很大的问题。
各层的序列化选择:
L1(Caffeine):
- 不需要序列化:直接存储Java对象引用
- 优点:零序列化开销,访问速度最快
- 注意:缓存的对象是引用,修改对象会影响缓存中的值。需要返回深拷贝或使用不可变对象
L2(Redis):
- 必须序列化:数据需要通过网络传输和存储
- 常见选择:
- JSON(Jackson/Gson):可读性好,跨语言,但体积大、速度慢
- Protobuf:体积小、速度快,但需要定义Schema
- Kryo:Java生态最快的序列化框架,但不跨语言
- MessagePack:类似JSON但二进制格式,体积小速度快
CDN层:
- 通常是HTTP响应体:JSON或HTML
- 使用Gzip/Brotli压缩减少传输体积
不同层使用不同格式的利弊:
- 优点:每层使用最适合的格式(L1无序列化、L2用Protobuf、CDN用JSON+Gzip)
- 缺点:L1和L2之间需要格式转换,增加复杂度
推荐方案:
- L1:直接存储Java对象(零开销)
- L2:统一使用JSON(兼容性好,调试方便)或Protobuf(性能优先)
- 避免在L1和L2之间频繁转换格式
序列化的坑:
- 类变更兼容性:添加/删除字段后旧缓存反序列化失败。JSON天然兼容,Kryo需要注册类
- 泛型擦除:
List<User>反序列化后可能变成List<LinkedHashMap>。需要传入TypeReference - 循环引用:对象间循环引用导致序列化死循环。Jackson需要
@JsonIdentityInfo
125. ⚫ 你在生产中遇到过哪些多级缓存的坑?如何解决的?
答:这是一道开放性题目,考察真实的多级缓存实战经验。以下是几个典型案例:
案例1:L1缓存对象被意外修改
- 场景:从Caffeine获取的User对象,业务代码修改了其中的字段,导致缓存中的数据也被改了
- 原因:L1缓存存储的是对象引用,不是副本
- 解决:返回深拷贝(
user.clone()),或使用不可变对象(Lombok的@Value) - 教训:L1缓存的对象必须是不可变的,或者返回时做深拷贝
案例2:Redis Pub/Sub通知丢失导致L1数据不一致
- 场景:数据更新后L2已删除,但部分实例的L1仍然是旧数据
- 原因:Redis Pub/Sub不持久化,网络抖动时消息丢失
- 解决:L1设置短TTL(30秒)兜底 + 定期全量校验(每5分钟随机抽样对比L1和L2)
- 教训:不能完全依赖Pub/Sub通知,必须有TTL兜底
案例3:缓存预热导致Redis瞬间OOM
- 场景:应用重启后500个实例同时从DB加载数据写入Redis,Redis内存瞬间打满
- 原因:预热没有限流,大量数据同时写入
- 解决:预热时加限流(每秒最多写入1000条),分批预热,错峰启动
- 教训:预热必须限流,避免对Redis和DB造成冲击
案例4:L1和L2的TTL设置不合理导致”缓存踏空”
- 场景:L1 TTL=5分钟,L2 TTL=5分钟。L1过期后查L2,L2也刚好过期,直接打到DB
- 原因:L1和L2的TTL相同,可能同时过期
- 解决:L2的TTL必须大于L1(如L1=1分钟,L2=10分钟),保证L1过期时L2仍然有数据
- 教训:多级缓存的TTL必须递增,下层TTL > 上层TTL