Redis 深度指南

Redis 深度指南

本文面向有一定 Redis 使用经验、准备高级/资深 Java 岗位面试的同学。目标不是罗列 API,而是讲清楚"为什么这么设计""生产上会踩什么坑""业界怎么解决"。每个知识点尽量给出:原理 → 源码/机制细节 → 现实案例 → 代码/配置。

目录

每节标题保持简短,具体结论以 要点提示放在每节正文开头,方便扫读记忆。


一、Redis 使用场景总览

要点:Redis 好用是因为"丰富数据结构 + 单线程原子操作 + 微秒级响应"三者叠加;但它不是万能银弹,用 Redis 做 MQ/协调服务都是牺牲可靠性换成本和性能的工程选择,不是最优解。

Redis 能承担这么多角色,本质是因为它提供了丰富的数据结构 + 单线程无锁的原子操作 + 微秒级响应,这三者组合起来能低成本地模拟出很多"专用中间件"的能力:

场景 利用的特性 典型问题
缓存 内存读写快、TTL、丰富数据结构 穿透/击穿/雪崩/一致性
分布式锁 单线程命令原子性、SETNX、Lua 原子脚本 死锁、误删、不可重入、续期
消息队列 List/Pub-Sub/Stream 消息丢失、无 ACK、无法回溯
延时队列 ZSet 按 score 排序 轮询效率、精度、可靠性

关键认知

Redis 不是专业的 MQ(不如 Kafka/RocketMQ 可靠、不如它们支持海量堆积),也不是专业的分布式协调服务(不如 ZooKeeper/etcd 有强一致性保证)。

面试官问"为什么用 Redis 做 XXX",标准答法是:业务对可靠性要求没那么极致,但对性能和实现成本敏感,用 Redis 是权衡后的工程选择,而不是"最优解"。这个认知本身就是加分项——很多人会把 Redis 当银弹用。

一次完整的请求处理流程:为什么响应快

要点:Redis 快是"内存存储 + 单线程无锁 + I/O 多路复用"三者叠加的结果——内存解决"数据本身读写快",单线程无锁解决"处理每条命令快",epoll 这类 I/O 多路复用解决"一个线程也能同时扛住成千上万个连接"。下面用一次 SET name redis 的完整交互把这个过程具体拆开看。

客户端和 Redis 服务端之间,从建立连接到拿到结果,可以拆成六个阶段:

客户端(Client)              内核/网络(Kernel)            Redis 事件循环(epoll)         命令执行(单线程主线程)
     |                            |                              |                              |
     |──①TCP三次握手───────────>|                              |                              |
     |                            |──accept()新连接─────────────>|                              |
     |                            |                    注册client socket到epoll(监听可读)         |
     |                            |                              |                              |
     |──②发送RESP编码命令───────>|                              |                              |
     |  *3\r\n$3\r\nSET\r\n       |──数据到内核缓冲区────────────>|                              |
     |  $4\r\nname\r\n            |                    epoll_wait()检测到该socket可读             |
     |  $5\r\nredis\r\n           |                    ③读取数据到该client的输入缓冲区(query buffer)|
     |                            |                    解析RESP协议 → 还原出 SET / name / redis    |
     |                            |                              |──④命令入队,交给主线程执行───>|
     |                            |                              |                    查命令表,定位setCommand
     |                            |                              |                    执行前置检查(内存/ACL/事务)
     |                            |                              |                    ⑤单线程操作内存数据结构
     |                            |                              |                    (写全局哈希表 name→redis)
     |                            |                              |                    (若开启AOF/有从库,顺带记录)
     |                            |                              |<──生成RESP结果──────────────|
     |                            |                    +OK\r\n 写入该client的输出缓冲区(output buffer)
     |                            |<──⑥write()尝试写回socket─────|                              |
     |                            |   (若一次没写完,注册可写事件,下次epoll触发继续写)              |
     |<──返回 +OK\r\n────────────|                              |                              |
     |解析RESP,得到返回值"OK"      |                              |                              |
     |                            |                              |                              |

图上几个关键点:

  • RESP 协议(REdis Serialization Protocol):客户端发的命令统一编码成"字符串数组",如 *3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nredis\r\n——*3 表示数组有 3 个元素,每个元素用 $长度\r\n内容\r\n 表示一个二进制安全的字符串,靠长度前缀而不是特殊字符判断结尾,天然能处理任意二进制内容。
  • "内核/网络"这一列是理解"单线程为什么能扛高并发"的关键:epoll 只在某个连接真的有数据可读/可写时才通知事件循环去处理,不需要为每个连接单独起线程死等,图里②③之间"命令执行"这一列一直空闲,就是因为主线程不会傻等,而是被 epoll 唤醒了才动。
  • ④是唯一真正串行的环节:不管前面有多少个客户端的请求已经解析排队,"操作内存数据结构"这一步永远是一条命令完整执行完才轮到下一条,这也是"慢命令会阻塞所有其它客户端"的根源(呼应 3.2 节 KEYS * 阻塞的现实踩坑案例)。
  • Pipeline 省的是网络往返,不是省执行时间:如果客户端不等每条命令的回复、连续发多条命令,服务端这边②到⑥的步骤基本不变,只是输入缓冲区会一次攒好几条命令、执行阶段连续跑好几次、输出缓冲区也攒好几条结果一起写回——省掉的是客户端等每条命令网络往返(RTT)的时间,命令本身的执行速度并没有变化。

二、数据结构:不只是"有哪几种",而是"底层怎么存"

Redis 对外暴露 5 种基础类型(String/Hash/List/Set/ZSet)+ 几种高级类型(Bitmap/HyperLogLog/GEO/Stream/Bloom Filter 模块)。面试深度的分水岭在于你是否知道每种类型有多套底层编码,会根据数据量/大小自动切换。

2.1 每种类型的编码及切换阈值

要点:编码按数据量/大小自动切换,且切换方向不可逆——一旦升级为 hashtable/skiplist,之后数据变少也不会退回紧凑编码,这是内存"只涨不降"的常见根因。

类型 小数据编码 大数据编码 切换阈值(默认值)
String int / embstr raw 字符串 > 44 字节:embstr → raw(OBJ_ENCODING_EMBSTR_SIZE_LIMIT=44)
Hash listpack(7.0前叫ziplist) hashtable hash-max-listpack-entries 128,hash-max-listpack-value 64
List listpack quicklist(listpack 节点组成的双向链表) list-max-listpack-size 128
Set intset / listpack hashtable set-max-intset-entries 512,set-max-listpack-entries 128
ZSet listpack(7.0前叫ziplist) skiplist zset-max-listpack-entries 128,zset-max-listpack-value 64

为什么要搞这么多编码? 核心是内存与速度的权衡:小数据量下用连续内存的紧凑结构(listpack/intset)省内存、缓存友好;数据量一大就要换成哈希表/跳表保证 O(1)/O(logN) 的操作复杂度,否则遍历式操作会退化成 O(N)。

这是 Redis 用"空间换时间在小数据集上不划算,大数据集上划算"这条经验规律做的自适应设计。

面试常问坑点:如果一个 Hash 里只放了 129 个字段(超过 hash-max-listpack-entries),即使每个字段都很小,也会整体转成 hashtable,且编码转换不可逆——即使你后来 HDEL 删到只剩几个字段,它也不会退回 listpack。

这在内存敏感场景(比如用 Hash 存大量小对象,如商品的多维度统计)容易被忽视,长期运行后内存只涨不降。

2.1.1 编码结构图解(把上面的表格具象化)

String 三种编码——按长度和内容类型选择,不是"要么字符串要么数字"这么简单:

值 "12345"(长度<=20的整数)
  ---------------------------------> [int 编码]
  直接把 long 型整数值存进 redisObject 的 ptr 指针位,
  完全不需要额外的 SDS 结构,最省内存

值 "hello"(长度<=44字节的短字符串)
  ---------------------------------> [embstr 编码]
  redisObject 头 + SDS 结构 一次 malloc 连续分配:
  +------------------+------------------------------+
  |   redisObject    |   SDS(len,alloc,buf="hello") |
  +------------------+------------------------------+
  只读,一旦执行 APPEND 等修改操作,会转成 raw 编码

值 "一段超过44字节的长文本......"
  ---------------------------------> [raw 编码]
  redisObject 头 和 SDS 结构 分两次 malloc,各自独立一块内存:
  +------------------+        +-------------------------------+
  |   redisObject    | -ptr-> |  SDS(len,alloc,buf="很长的内容...") |
  +------------------+        +-------------------------------+
  可修改,扩容策略见下面 SDS 一节

Hash:listpack vs hashtable:

字段数<=128 且每个value<=64字节  ---> [listpack] 紧凑连续内存
  按写入(插入)顺序摆放,不做任何排序——Hash语义上不保证顺序,没必要排序
  +------+------+------+------+------+------+
  | f1   | v1   | f2   | v2   | f3   | v3   |
  +------+------+------+------+------+------+
  优点:省内存、CPU缓存命中率高    缺点:查找需要遍历,O(N)

字段数>128 或 某个value>64字节(转换不可逆)---> [hashtable]
  bucket[0] -> "name"  -> "张三"
  bucket[1] -> "age"   -> "28"  -> "score" -> "99"   (链地址法解决哈希冲突)
  bucket[2] -> NULL
  优点:O(1) 查找                  缺点:每个entry有额外指针开销,更耗内存

List:quicklist(双向链表 × listpack):

数据量小:整个List就是一个listpack,无需外层链表包裹

数据量变大后 ---> [quicklist] 双向链表,每个节点本身又是一个listpack:

  head <-> node1 <-> node2 <-> node3 <-> tail
            |          |          |
         listpack   listpack   listpack
        [e1,e2,e3] [e4,e5]   [e6,e7,e8,e9]

每个节点大小受 list-max-listpack-size 限制,避免单个listpack过大,
既保留了链表两端 O(1) 插入删除的优势,又避免了一个巨大listpack
(数据变化引发连锁更新,见ziplist的cascade update问题)

Set:intset → listpack → hashtable(三级递进):

全是整数 且 数量<=512 ---> [intset] 真正按数值大小**有序**的整数数组,二分查找 O(logN)
  [1, 5, 20, 100, 999]

数量<=128(可含非整数)---> [listpack]
  按插入顺序摆放,不排序(和intset的"数值有序"是两回事,别混淆)
  [apple, banana, 100, cherry]

数量>128 或 某元素>64字节 ---> [hashtable],value位统一用NULL占位,只利用key
  bucket[0] -> "apple"
  bucket[1] -> "banana" -> "cherry"

ZSet:listpack vs(skiplist + hashtable 双结构)——这里有两个很多人会漏掉的细节:

  • ① ZSet 的 listpack 是按 score 大小有序排列的,不是插入顺序,这点和 Hash/Set 的 listpack 不一样;
  • ② ZSet 数据量大时,skiplist 和 hashtable 是同时存在、一起工作的,不是二选一。
数据量小 ---> [listpack]
  按 score **升序**排列 member1,score1,member2,score2...(不是插入顺序!)
  这是刚需:ZRANGE/ZRANGEBYSCORE 要求数据本身有序才能高效返回区间结果,
  所以每次 ZADD 插入新元素时,不是直接append到末尾,而是线性扫描找到
  按score排序后该插入的位置再插入(这一步本身是O(N)的数据搬移),
  这也是为什么 zset-max-listpack-entries 要卡得比较小(默认128)的原因之一。

数据量大 ---> [skiplist + hashtable] 两套结构同时维护,各自分工:
  hashtable:member -> score,O(1) 查询单个member的分数(如 ZSCORE)
  skiplist: 按 score 排序的多层链表,O(logN) 做范围查询(如 ZRANGE/ZRANGEBYSCORE)
  两套结构共享同一份 member/score 数据的指针,不会重复存储成两份数据

2.2 SDS(Simple Dynamic String)—— Redis 为什么不用 C 字符串

要点:SDS 靠 len 字段 O(1) 取长度、二进制安全(可存任意字节)、修改前自动检查扩容,解决了 C 字符串 O(N)取长度、缓冲区溢出、只能存文本三个问题。

  • C 字符串以 \0 结尾,获取长度是 O(N);SDS 结构体里有 len 字段,O(1) 取长度。
  • C 字符串会有缓冲区溢出风险(strcat 类操作不检查容量);SDS 每次修改前检查空间是否足够,不够会自动扩容。
  • SDS 是二进制安全的(不依赖 \0 判断结束),所以 Redis 的 String 可以存图片、序列化对象等任意二进制数据,而不只是文本。
  • 扩容策略:小于 1MB 时加倍扩容,大于等于 1MB 时每次多分配 1MB(避免大字符串扩容时内存浪费翻倍)。

SDS 结构图(以 raw 编码、内容 "hello" 为例):

struct sdshdr {
    int len;      // 已用长度:5
    int alloc;    // 总分配容量:10(预分配了冗余空间)
    char buf[];   // 实际字节数组,二进制安全(不依赖\0判断结束)
}

  len=5   alloc=10
 +------+--------+-----------------------------------+
 |  5   |   10   | h | e | l | l | o | \0| . | . | . |
 +------+--------+-----------------------------------+
                   <---------- buf[] ---------------->
                   已用5字节        剩余可用 = alloc-len = 5字节

取长度:O(1)直接读len字段,不需要像C字符串那样遍历到\0
剩余空间足够时,APPEND等操作不需要重新malloc,直接复用buf里的冗余空间

2.3 跳表(Skiplist)—— ZSet 为什么用跳表而不是红黑树

要点:范围查询效率和红黑树相当,但跳表靠多层指针实现、不需要旋转变色维护平衡,代码更简单、bug更少;层高按概率随机决定,期望复杂度 O(logN)。

面试高频问题,标准答案要点:

  1. 范围查询效率:跳表做 ZRANGE/ZRANGEBYSCORE 这类范围查询,只要定位到起点,沿着链表遍历即可,时间复杂度和红黑树的中序遍历相当,但代码实现远比红黑树简单(不需要旋转、变色去维护平衡)。
  2. 实现和维护成本:红黑树插入删除要做复杂的旋转操作,跳表只需要维护多层链表指针,出 bug 的概率更低,antirez 本人在博客里也提到这是重要考量。
  3. 跳表层高按概率(p=0.25)随机决定,最大层数 32 层,期望时间复杂度 O(logN)。

跳表结构图——多层链表相当于给底层完整链表加了"高速通道",层数越高跨度越大:

Level3   HEAD ------------------------------------> [70] --------> NULL
Level2   HEAD ------------> [30] -----------------> [70] --------> NULL
Level1   HEAD ------------> [30] -----------> [50] > [70] --------> NULL
Level0   HEAD -> [10] ----> [30] -> [40] ----> [50] > [70] -> [90] -> NULL
                (数字为score,Level0是包含全部元素的完整有序链表)

查找 score=50 的过程(体现"跳跃",这是跳表比顺序链表快的核心):
  1. 从 Level3 出发:HEAD -> 70,70>50,说明50在70之前,本层无法继续前进,降到Level2
  2. Level2:HEAD -> 30,30<50,前进到30;30 -> 70,70>50,降到Level1
  3. Level1:30 -> 50,命中,50=50,查找结束

整个过程只访问了 30、70、50 三个节点,完全跳过了10、40,
层数越多,每层能跳过的节点越多,期望复杂度收敛到 O(logN),
这就是"空间换时间"——用额外的多层指针换取免于逐一遍历的查找效率。

2.4 数据结构与业务场景的对应关系(面试官爱追问"你在项目里具体怎么用的")

要点:这是速查表——每种类型对应哪些典型业务场景(分布式锁/计数器用String,排行榜/延时队列用ZSet,UV统计用HyperLogLog等),面试被追问具体项目用法时直接对号即可。

  • String:普通 KV 缓存、分布式锁(SETNX)、计数器(INCR,如文章阅读量、接口限流计数)。
  • Hash:对象缓存(比如用户信息,字段级更新不用整体反序列化)、购物车(field=商品ID,value=数量)。
  • List:简单消息队列、最新列表(如朋友圈最新 N 条动态,LPUSH+LTRIM)。
  • Set:去重(如页面 UV)、共同好友(SINTER)、抽奖池(SPOP)。
  • ZSet:排行榜(score=分数)、延时队列(score=到期时间戳)、滑动窗口限流(score=时间戳,ZREMRANGEBYSCORE 清理窗口外数据)。
  • Bitmap:签到统计(一天一个 bit,一年 365 bit=约 46 字节,BITCOUNT 统计签到天数)、布隆过滤器底层。
  • HyperLogLog:海量数据基数统计(如百万级 UV 统计,标准误差 0.81%,只占 12KB 固定内存,典型案例:网页 UV 统计,不需要精确值只需要近似值时用它替代 Set 省内存)。
  • GEO:附近的人/门店(底层是 ZSet,score 是 GeoHash 编码后的值)。
  • Stream:轻量级消息队列(见第六节)。

三、实战运维配置

3.1 单机 → 主从 → 哨兵 → Cluster 的演进逻辑

要点:这是一条问题驱动的演进链——主从解决读高可用但故障转移靠人工,哨兵解决自动故障转移但写还是单点,Cluster 解决写扩容,每一层都在补上一层的短板。

这是面试官考察"你是否只会用单机 Redis"的核心问题,回答思路要讲清每一层解决了上一层的什么问题:

  1. 单机:无高可用,宕机即不可用;无法通过增加节点提升容量/QPS。
  2. 主从复制(replicaof):解决读高可用 + 读写分离,但故障转移是人工的——主库挂了需要手动把从库提为主库并通知客户端切换连接。
  3. 哨兵(Sentinel):解决主从的自动故障转移问题。哨兵集群监控主从节点,主观下线(单个哨兵认为节点挂了)→ 客观下线(超过 quorum 个哨兵都认为挂了)→ 通过 Raft 类似的算法选出 leader 哨兵执行故障转移(选新主、通知其余从库和客户端)。但哨兵模式下所有写请求还是打到一个主节点,没有解决写扩容问题。
  4. Cluster(集群模式):解决写扩容/数据分片问题。数据按 16384 个 slot 分布到多个主节点(slot = CRC16(key) % 16384),每个主节点可以有从节点做高可用;节点间用 Gossip 协议互相探测健康状态、传播元数据;客户端第一次请求可能收到 MOVED 重定向(数据在别的节点)需要按新地址重试,这也是很多 Java 客户端(Jedis/Lettuce)需要维护 slot 路由表的原因。

现实案例

中小型电商项目通常用"哨兵 + 单主多从"即可(QPS 在几万级别单节点能扛住,主要诉求是高可用不是扩容);大型电商大促场景(比如秒杀)会用 Cluster 做水平扩容,把不同商品的库存 key 分散到不同分片,避免单节点成为瓶颈,同时结合本地缓存挡掉大部分读流量。

3.1.1 Cluster 扩缩容时,slot 是怎么迁移的(resharding)

要点:迁移以 slot(不是单个 key)为最小单位,靠 MIGRATING/IMPORTING 双态标记 + 逐 key 搬运做到不停机;ASK 是本次临时重定向不能更新路由表,MOVED 才是永久重定向,这是最容易被问混的点。

前面只讲了"稳态下数据怎么分片",但生产上更常被问到、也更容易踩坑的是:新加一个节点,或者要下线一个节点,已经落在旧节点上的数据该怎么办?

Redis Cluster 的扩缩容不是自动的,加入一个空节点后它默认不持有任何 slot,需要运维主动触发重新分片(resharding),把一部分 slot(连同它们对应的 key)从旧节点迁移到新节点。这个迁移过程可以在不停机的情况下完成,这是 Cluster 相比"手动分库分表"方案的核心优势之一,但迁移过程本身的原理和边界情况是面试的高频考点。

迁移的最小单位是 slot,不是单个 key——因为 slot 是 Cluster 里数据分布和路由的基本单位,迁移时以 slot 为粒度批量处理该 slot 下的所有 key。核心是一个双态标记机制:

迁移 slot 8192(假设从 nodeA 迁到 nodeB)的过程:

第1步:标记状态
  nodeA:  CLUSTER SETSLOT 8192 MIGRATING <nodeB的ID>   # nodeA标记:这个slot正在迁出
  nodeB:  CLUSTER SETSLOT 8192 IMPORTING <nodeA的ID>   # nodeB标记:这个slot正在迁入

第2步:逐个搬运该slot下的key(用 MIGRATE 命令,本质是 DUMP+RESTORE+DEL 的原子封装)
  redis-cli --cluster 工具或运维脚本对该slot下的每个key执行:
  nodeA:  MIGRATE <nodeB地址> <port> <key> 0 <timeout>
  单个key的MIGRATE是原子的(这期间该key不可读写),
  但整个slot的迁移是"一个key一个key地搬",不是整体加锁,
  所以迁移期间集群其它请求不受影响,只有正在被搬的那个key短暂阻塞。
  3.0.6+ 支持 MIGRATE ... KEYS k1 k2 k3 批量搬运小key,减少网络往返提升效率。

第3步:迁移期间的请求路由(这是最容易问到的细节)
  迁移未完成时,slot里的key分布在两个节点,客户端怎么知道去哪个节点找:
  - 请求还没迁移的key -> nodeA正常处理,直接返回
  - 请求已经迁移完的key,客户端问到nodeA -> nodeA返回 -ASK <nodeB地址>
    客户端收到ASK后,先对nodeB发送 ASKING 命令(告诉nodeB"下一条命令即使
    这个slot还没正式转移完也请处理"),再重发原命令到nodeB
  - 请求这个key在两边都不存在(还没建过)-> 有可能落到nodeA的IMPORTING状态里
    处理不了,也会引导客户端用ASKING方式去nodeB

第4步:该slot下所有key搬完后,正式转移slot归属
  向集群内所有节点广播: CLUSTER SETSLOT 8192 NODE <nodeB的ID>
  之后该slot的路由信息通过Gossip协议扩散给全体节点,
  客户端后续再请求这个slot,nodeA会直接返回永久性的 -MOVED 重定向到nodeB。

ASK 和 MOVED 的本质区别,这是面试最容易问混的点:

  • MOVED:slot 已经永久性转移完毕,客户端收到后应该更新本地缓存的 slot 路由表,以后直接打到新节点,不用再问旧节点。
  • ASK:只是这一次命令的临时重定向(因为这个 key 恰好已经被迁移过去了,但 slot 整体还在迁移过程中,归属未最终确定),客户端不能更新永久路由表,下一次请求同一个 slot 里的另一个 key,仍然要先问旧节点。这个区别决定了 Java 客户端(Jedis/Lettuce)必须严格区分处理这两种重定向,处理错了会导致迁移过程中出现"路由表提前更新、部分请求打到还没准备好的新节点"的问题。

新节点该分配多少个 slot——分配算法:上面讲的是"一个 slot 具体怎么搬",但运维时更先要决定的是"分配多少个、哪些 slot 给新节点",这是另一个层面的问题:

  • 初始建集群时(redis-cli --cluster create):16384 个 slot 会尽量均分给所有 master 节点,公式是 每节点slot数 ≈ 16384 / master数量,除不尽的余数依次分给前面几个节点,保证任意两个节点持有的 slot 数最多相差 1 个。例如 3 个 master:16384 / 3 = 5461.33,实际分配是两个节点各 5461 个、一个节点 5462 个。
  • 新节点加入时是"裸节点",默认 0 个 slot:redis-cli --cluster add-node 只是让新节点通过 Gossip 协议加入集群拓扑(其它节点能感知到它),但不会自动给它分配任何 slot,此时它还不能处理任何 key 的读写,必须再手动触发一次 resharding 才能真正分担流量。这也印证了前面说的"Cluster 扩缩容不是自动的"。
  • 手动分配(redis-cli --cluster reshard):交互式询问"要迁移多少个 slot"和"从哪些源节点迁出"(可以指定具体某个节点,也可以选 all 表示自动从所有现有 master 按比例抽取),这种方式灵活但需要运维自己算好数量,人为操作容易出偏差。
  • 自动再平衡(redis-cli --cluster rebalance):按权重(--cluster-weight <node-id>=<weight>,默认每个节点权重相等为1)计算每个节点的目标 slot 数 = 该节点权重 / 所有节点权重之和 × 16384,和当前实际持有的 slot 数对比,只有偏差超过 --cluster-threshold(默认 2%)才会触发迁移(避免为了极小的不均衡反复做无谓迁移,产生不必要的网络和 CPU 开销)。如果新节点当前是 0 个 slot 的"空 master",rebalance 默认会跳过它(这是一个安全保护,防止误操作把数据分给一个还没准备好、可能配置错误的新节点),必须显式加上 --cluster-use-empty-masters 参数才会把空节点纳入再平衡范围。
  • 权重的实际用途:如果新扩容的节点硬件配置更好(比如内存是老节点的 2 倍),可以给它设置 2 倍权重,rebalance 时会按比例多分配约 2 倍的 slot,而不是不管硬件差异强行平均分配。
  • 补充一点容易被问到的:只有 master 节点持有 slot,replica(从节点)不持有 slot,它只是复制所属 master 的数据、在故障转移时接管 master 的 slot。所以如果新加入的节点是作为某个已有 master 的副本(--cluster-slave)加进来提升高可用能力,根本不涉及 slot 分配问题;只有新加入的节点要作为新的 master 承担独立分片时,才需要走上面这套 slot 分配/迁移流程。

实际操作:生产上一般不会手写底层的 MIGRATING/IMPORTING 命令,而是用 redis-cli --cluster reshard <host>:<port> 交互式指定"从哪些节点迁出多少个slot到目标节点",或者用 redis-cli --cluster rebalance 按节点权重自动做整体再平衡。

缩容(下线节点)是反过来的流程:先把要下线节点持有的所有 slot 迁移到其它存活节点(清空该节点),确认它不再持有任何 slot 和数据后,再用 CLUSTER FORGET <node-id> 把它从集群节点表里彻底摘除,最后才能安全关停这个实例——任何节点只要还持有 slot 就不能直接下线,这是新手最容易踩的坑(有人直接 kill 掉还持有数据的节点,导致对应 slot 的数据不可用)。

现实案例

电商大促前按预估流量提前扩容(比如从 3 主扩到 6 主),用 --cluster reshard 把每个旧节点上大约一半的 slot 迁移到新节点,均摊读写压力;大促结束后流量回落,再用 --cluster reshard 把新增节点的 slot 迁回原节点、缩容下线,节省成本。

因为迁移是在线进行、逐 key 搬运,大 key 会拖慢单个 slot 的整体迁移速度(因为 MIGRATE 单个大 key 本身耗时长,且这期间这个 key 不可写),所以扩缩容前最好先排查一遍大 key(redis-cli --bigkeys),避免迁移窗口被大 key 拖得过长影响业务。

另外,涉及需要多 key 原子操作(如用 MGET/事务同时操作多个 key)的场景,要用 Hash Tag(在 key 名中用 {} 包裹住希望强制分配到同一 slot 的部分,如 order:{1001}:info 和 order:{1001}:items 会因为 {1001} 相同而落入同一 slot)来保证这些 key 无论怎么扩缩容都始终在同一个节点,否则多 key 操作会因为 key 分散在不同节点而直接报错。

3.1.2 为什么 Redis Cluster 用"哈希槽"而不是一致性哈希环

要点:一致性哈希环靠虚拟节点打散负载,迁移范围隐式、难精确控制;Redis 用显式的"槽分配表"管理归属,迁移多少槽、迁哪些槽由管理员精确指定和控制。

这是分布式分片方案里的经典辨析题,很多人会下意识以为 Redis Cluster 和 Memcached 客户端分片、DynamoDB 那类方案一样用一致性哈希环(Consistent Hashing Ring),但Redis Cluster 从设计上明确没有用哈希环,而是自己发明了"哈希槽(hash slot)"模型,这是两种不同的思路。

一致性哈希环怎么工作(作为对比,不是 Redis 采用的方式):

把节点和 key 都用哈希函数映射到一个 0 ~ 2^32-1 的环上,
key 的归属 = 沿环顺时针方向找到的第一个节点:

              NodeA(hash=30)
                 /        \
        key(hash=95)      NodeB(hash=120)
     顺时针找到NodeB           |
     即为key的归属          NodeC(hash=200)
                 \            /
                  NodeD(hash=280)

问题:
- 不加"虚拟节点"的话,几个物理节点在环上的位置是随机的,容易导致
  某个节点承担的环区间过大/过小,数据分布严重不均
- 要解决不均,得给每个物理节点映射出成百上千个"虚拟节点"打散在环上,
  这样虽然能变得均匀,但 key 的归属关系变成"运行时按hash值动态计算+
  沿环查找",是隐式的,加节点时具体哪些 key 会被影响,只能事后统计,
  没法像"我要迁移xx个xx编号的槽"这样精确指定和控制

Redis 的哈希槽模型怎么工作:

第一步:预先把整个key空间"切"成固定的 16384 份,这一步与物理节点数量无关
  slot = CRC16(key) % 16384        // 某个key对应的slot编号永远固定不变

第二步:用一张"显式的槽分配表"记录每个槽当前归哪个节点持有
  slot 0     ~ 5460   -> nodeA
  slot 5461  ~ 10922  -> nodeB
  slot 10923 ~ 16383  -> nodeC

这张表是显式存储、可查询的(不是运行时公式现算),每个节点各自缓存一份,
客户端也可以缓存一份用于直接路由(查表 O(1));节点间通过 Gossip 协议
扩散最新的槽分配状态。加减节点时,只需要显式地把"若干个槽"在表里
改成指向新节点——管理员精确知道、也能精确控制要迁移多少个槽、迁移哪些槽
(对应第3.1.1节讲的reshard/rebalance),这一点是一致性哈希环很难做到的。

两者对比:

维度 一致性哈希环 Redis 哈希槽
key 到分片的映射方式 隐式:运行时按 hash 值在环上顺时针查找 显式:查一张固定的槽分配表
负载均衡手段 需要引入"虚拟节点"打散负载,增加元数据复杂度 天然靠 16384 个预分片的槽实现均衡,不需要额外的虚拟节点概念
扩缩容再平衡的可控性 新节点插入环上某点,受影响的 key 范围由哈希值分布决定,不易精确预测/控制迁移量 管理员显式指定迁移多少个槽、迁移哪些槽,迁移量完全可预测可控
元数据传播 客户端/节点需维护环拓扑及虚拟节点映射关系 借助 Gossip 协议,槽位图(bitmap)随心跳扩散,开销可控
典型场景 Memcached 客户端分片、DynamoDB 等 Redis Cluster 专用设计

为什么槽的数量定为 16384(2^14),不是更大或更小:这不是随便定的,Redis 官方 Cluster 设计文档专门解释过——每个节点要通过 Gossip 心跳包向其它节点广播"自己持有哪些槽",这个信息用位图(bitmap)表示最紧凑,16384 个槽对应的位图正好是 16384 / 8 = 2048 字节 = 2KB;如果槽数再大一个数量级(比如 2^16 = 65536),位图就要 8KB,在节点数较多、心跳频繁的集群里会显著增加 Gossip 消息的网络开销。

同时 Redis 官方并不建议 Cluster 规模超过约 1000 个 master 节点,在这个规模上限下,16384 个槽依然能保证每个节点分到足够多的槽(至少十几个)做到较细粒度的均衡,没必要为了"理论上更均匀"而牺牲心跳包的轻量化。

一句话总结这块的面试答法:Redis Cluster 的哈希槽模型,本质上可以理解成"提前用固定数量的槽把 key 空间做了预分片(有点像一致性哈希里虚拟节点的效果),但用一张显式可查、可精确控制、可随心跳广播的槽分配表来管理归属关系,而不是运行时在一致性哈希环上做隐式计算"——这样既避免了一致性哈希环"不加虚拟节点会不均衡、加了又元数据复杂"的两难,又让扩缩容的数据迁移量变得完全可控。

3.2 关键 redis.conf 参数(生产环境必须理解的)

要点:maxmemory+淘汰策略防OOM,appendfsync everysec是持久化安全性与性能的折中,slowlog是排查O(N)慢命令的利器,高危命令(如FLUSHALL)生产要禁用/改名。

# 内存与淘汰
maxmemory 4gb                      # 生产必须设置,否则OOM时可能被系统kill或阻塞写入
maxmemory-policy allkeys-lru       # 淘汰策略,见第四节

# 持久化
appendonly yes                     # 开启AOF
appendfsync everysec               # 折中策略,见第四节
aof-use-rdb-preamble yes           # 混合持久化,7.0+默认开启
save 900 1                         # RDB触发条件:900秒内至少1个key变化
save 300 10
save 60 10000

# 网络与连接
timeout 0                          # 客户端空闲超时,0=不超时
tcp-keepalive 300                  # 探测死连接,避免连接泄漏占用fd
maxclients 10000

# 慢查询与监控
slowlog-log-slower-than 10000      # 超过10ms记录慢日志,排查O(N)命令(如KEYS、大HGETALL)的利器
slowlog-max-len 128

# 安全
requirepass your_password
rename-command FLUSHALL ""         # 生产环境建议禁用/改名高危命令

现实踩坑案例

曾经真实发生过运维在生产环境执行 KEYS * 排查问题,Redis 单线程被这一条 O(N) 命令阻塞几秒,期间所有其他请求排队,业务大面积超时。这也是"Redis 是单线程处理命令"这个知识点的直接后果——4.0 之后虽然引入了多线程,但只用于网络 I/O 和异步删除大 key(UNLINK/lazyfree),命令执行本身仍然是单线程,任何一条慢命令都会阻塞后面所有请求。正确做法是用 SCAN 代替 KEYS。

maxmemory 没设置或设置过大,导致 Redis 把宿主机内存吃满,触发 OOM Killer 杀掉进程,或者操作系统开始 swap 导致性能断崖式下跌。

3.3 大 key / 热 key 治理(生产运维高频问题)

要点:大 key 用 UNLINK 异步删除避免阻塞主线程;热 key 靠"本地缓存削峰 + key 打散成多副本"分摊单节点压力。

  • 大 key:单个 key 的 value 过大(如 Hash 有几十万字段,或 String 存了几 MB 的大对象)。危害:读写该 key 阻塞主线程、主从同步/RDB 生成时该 key 序列化耗时长、内存分布不均。排查用 redis-cli --bigkeys 或 RDB 文件离线分析工具(如 redis-rdb-tools)。删除大 key 要用 UNLINK 而不是 DEL(前者异步释放内存,避免阻塞)。
  • 热 key:某个 key 的访问量远超其他 key(如秒杀商品、热搜词),导致单个分片节点被打满,其它分片空闲。解决思路:本地缓存 + 二级缓存(在应用层用 Caffeine 缓存热点数据几百毫秒到几秒,大幅削峰)、key 打散(如把热 key 拆成 key:0 ~ key:9 十个副本,读的时候随机选一个,写的时候广播更新所有副本)。

四、缓存专题

要点:穿透(key不存在)、击穿(热点key过期)、雪崩(大量key集中失效/Redis宕机)成因不同但表现类似,各有专门方案,限流+熔断降级是三者共用的最后一道防线。

4.1 缓存穿透(Cache Penetration)

要点:key 在缓存和数据库中都不存在,防护靠"参数校验 + 缓存空值 + 布隆过滤器"层层拦截,布隆过滤器有假阳性,最后还得靠限流兜底。

原理:请求的 key 在缓存和数据库中都不存在,每次都要打到数据库,缓存完全没起到保护作用。

现实案例

电商详情页按商品 ID 查询,如果攻击者故意构造大量不存在的商品 ID(比如负数、超大 ID)高并发请求,或者爬虫误抓了错误链接,会导致数据库被打爆——这不是理论场景,是真实发生过的安全事件类型(类似的还有恶意用户遍历不存在的用户 ID 探测系统)。

解决方案:

  1. 参数校验:在最外层拦截明显非法的请求(如 ID 格式、范围校验),成本最低、优先做。
  2. 缓存空值:查询数据库为空时,也在 Redis 里缓存一个空值(如 "" 或特殊标记),设置较短的 TTL(几十秒到几分钟),防止同一个不存在的 key 被反复穿透,但也不能设太长,否则数据库里后续新增了这个 key 会有短暂不可见。
  3. 布隆过滤器:在缓存前面加一层布隆过滤器,提前把所有存在的 key 加载进去,请求先过布隆过滤器,判断"一定不存在"的直接拦截,只有可能存在的才继续查缓存/数据库。布隆过滤器有误判率(可能存在的判成一定不存在的概率为0,但存在假阳性——判断存在但实际不存在),需要根据业务容忍度调整 bit数组大小和hash函数个数。生产上可以用 Redis 官方 RedisBloom 模块(BF.ADD/BF.EXISTS),或者应用层用 Guava BloomFilter(缺点是单机内存,多实例间不共享,需要定期同步)。
// 应用层 Guava 布隆过滤器示例(单机场景,分布式场景建议用 RedisBloom)
BloomFilter<Long> bloomFilter = BloomFilter.create(
    Funnels.longFunnel(), 1_000_000, 0.001); // 预期100万数据,误判率0.1%
// 初始化时把所有商品ID灌入
productIds.forEach(bloomFilter::put);

public Product getProduct(Long id) {
    if (!bloomFilter.mightContain(id)) {
        return null; // 一定不存在,直接返回,不查缓存不查库
    }
    // 走正常的缓存查询逻辑
    ...
}
  1. 限流兜底:参数校验和布隆过滤器能拦掉绝大多数无效请求,但布隆过滤器本身有假阳性(极小概率误判"可能存在"放行了一个实际不存在的 key),如果攻击者精心构造大量能骗过布隆过滤器的请求,依然可能形成一定的穿透流量。生产上通常还会在接口层加一层限流(比如按 IP/用户维度限流,识别短时间内高频请求不存在资源的异常行为),具体的实现方式见 4.4 节(这是穿透/击穿/雪崩三个问题通用的最后一道防线,统一放在那一节讲)。

4.2 缓存击穿(Cache Breakdown / Hotspot Invalidation)

要点:和穿透的区别是 key 本身存在、只是恰好过期,解决靠"互斥锁只让一个线程重建缓存"或"逻辑过期让读请求永不阻塞",秒杀场景更常用后者。

原理:某一个热点 key 在缓存中过期的瞬间,大量并发请求同时穿透到数据库,瞬间打爆数据库。和穿透的区别是:穿透是key 本身不存在,击穿是key 存在但恰好过期。

现实案例

秒杀活动中某爆款商品的库存/详情缓存到期,恰好在这个时间点上万个并发请求同时到达,如果没做保护,数据库瞬间收到上万条相同查询,可能直接被打挂——这是电商大促最容易出问题的场景之一。

解决方案:

  1. 互斥锁重建(Cache Aside + 分布式锁):缓存失效时,只让一个线程去查数据库重建缓存,其他线程等待或返回旧值/降级值。
public String getWithMutex(String key) {
    String value = redis.get(key);
    if (value != null) return value;

    String lockKey = "lock:" + key;
    try {
        // SETNX + EX 原子加锁,见第五节分布式锁
        boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);
        if (locked) {
            value = db.query(key);          // 只有拿到锁的线程查库
            redis.setex(key, 300, value);   // 重建缓存
        } else {
            Thread.sleep(50);
            return getWithMutex(key);        // 简单重试,生产建议加重试次数上限
        }
    } finally {
        redis.del(lockKey); // 生产环境需用Lua脚本保证判断+删除的原子性,防止误删他人的锁
    }
    return value;
}
  1. 逻辑过期(不设置物理 TTL):缓存 value 里额外存一个"逻辑过期时间"字段,缓存本身永不过期。读取时判断逻辑时间是否已过期,若过期,由一个线程异步去重建缓存,其余线程直接返回旧数据(牺牲短暂的一致性换取可用性)。这是秒杀场景更常用的方案,因为读请求永远不会因为重建缓存而阻塞等待,適合对一致性要求没那么高但对可用性要求极高的场景。
  2. 限流/熔断兜底:互斥锁和逻辑过期解决的是"别让所有并发都打到数据库",但如果热点 key 本身极度火爆(比如秒杀商品),即使只放一个请求去查库重建缓存,如果这类热点 key 同时存在很多个,累积起来对数据库依然是不小的压力,这时候还需要在数据库调用这一层加一道限流/熔断兜底,具体代码见 4.4 节(这是穿透/击穿/雪崩三个问题通用的最后一道防线,统一放在那一节讲)。

4.3 缓存雪崩(Cache Avalanche)

要点:区别于击穿的"单个热点key",雪崩是大量不同 key 同时失效或 Redis 整体宕机,解决靠"TTL 加随机值打散 + 高可用架构 + 本地多级缓存"。

原理:和击穿的区别是大量不同的 key 在同一时间集体失效,或者 Redis 实例本身宕机,导致所有请求同时涌向数据库。

现实案例

常见的诱因是运维在批量写入缓存时给所有 key 设置了相同的 TTL(比如整点刷新一批商品信息,都设置 2 小时过期),到点后集中失效;或者大促零点大量用户涌入时 key 集中过期,叠加流量高峰,双重打击数据库。另一种是 Redis 主节点宕机、故障转移期间的短暂不可用窗口,恰好碰上流量高峰。

解决方案:

  1. 过期时间打散:基础 TTL 之上加一个随机值,如 expire = baseExpire + random(0, 300)(秒),避免集中过期。
  2. Redis 高可用架构:哨兵/Cluster 保证单点故障不会导致整体不可用(见第三节)。
  3. 多级缓存:应用层加本地缓存(Caffeine/Guava Cache),即使 Redis 整体不可用,本地缓存也能兜住一部分请求,给恢复争取时间。

以上是雪崩本身的架构层面应对;限流/熔断降级作为穿透、击穿、雪崩三个问题共用的最后一道防线,拆到独立的 4.4 节统一讲。

4.4 解决缓存三兄弟的最终防线:限流 + 熔断降级

要点:限流是主动预防(不管下游死活先卡住QPS),熔断降级是被动应激(监测异常率/RT超阈值才切断),两者规则独立、作用时机不同,很多人混着说。

不管是穿透(布隆过滤器有假阳性、漏过去的恶意请求)、击穿(同一热点 key 并发查库,或者多个热点 key 同时存在,互斥锁/逻辑过期也扛不住)、还是雪崩(大量 key 集中失效),最终失控的表现都是同一件事:打到数据库的请求量超过了数据库能承受的范围。

前面三节讲的方案(缓存空值、布隆过滤器、互斥锁、逻辑过期、TTL 打散、多级缓存)都是在尽量减少打到数据库的流量,但没有一个方案能保证 100% 兜住——布隆过滤器有假阳性、锁重建有并发窗口、TTL 打散只是降低概率不是消除。所以生产上还需要在数据库调用这一层统一加一道最终防线:限流 + 熔断降级,不区分具体是三个问题里的哪一个导致的压力,只要请求量/故障率超过阈值就统一保护,这也是为什么把它单独拎出来成一节,而不是在三个问题里各写一遍重复内容。

限流和熔断降级不是一回事,代码实现上也是两套独立的规则,很多人混着说,这里先分清楚概念再给代码:

  • 限流:主动、预防性的,不管下游此刻健不健康,都提前把并发/QPS 卡在一个阈值以内,防止瞬间流量把数据库打垮——针对的正是"雪崩"这种瞬时高并发场景。
  • 熔断降级:被动、应激性的,持续监测调用数据库的异常比例或响应耗时,一旦超过阈值就判定"下游已经不健康了",主动切断后续请求(直接走降级逻辑,不再打到数据库),过一段时间再放一个探测请求去试探数据库是否恢复(半开状态),恢复了才重新放开流量——这是给已经在恶化的故障踩刹车、防止请求持续堆积把数据库彻底拖死。

生产上目前主流用阿里开源的 Sentinel(Hystrix 已停止维护,不建议新项目再选)。核心用法:

@Service
public class ProductService {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductMapper productMapper;

    // value:资源名,限流/熔断规则都是按这个名字匹配生效
    // blockHandler:请求被限流规则或熔断规则拦截时触发(抛出BlockException)
    // fallback:业务代码本身执行异常时触发(如DB连接超时、SQL报错)
    // 两个处理方法的入参要和原方法一致,blockHandler额外多一个BlockException参数
    @SentinelResource(value = "getProductDetail", blockHandler = "handleBlock", fallback = "handleFallback")
    public Product getProductDetail(Long productId) {
        String cacheKey = "product:" + productId;
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return JSON.parseObject(cached, Product.class);
        }
        // 缓存没命中才会走到这里;雪崩发生时大量请求会并发压到这一行,
        // 就是限流/熔断规则真正要保护的临界点
        Product product = productMapper.selectById(productId);
        long ttl = 7200 + ThreadLocalRandom.current().nextInt(300); // 过期时间打散,见方案1
        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), ttl, TimeUnit.SECONDS);
        return product;
    }

    // 被限流或被熔断时执行:返回兜底数据而不是让请求继续排队打DB
    public Product handleBlock(Long productId, BlockException ex) {
        log.warn("商品详情查询被限流/熔断, productId={}", productId);
        return Product.fallback(productId); // 兜底数据可以来自本地静态缓存/默认值
    }

    public Product handleFallback(Long productId, Throwable t) {
        log.warn("商品详情查询业务异常降级, productId={}", productId, t);
        return Product.fallback(productId);
    }
}
// 限流规则:控制打到这个资源(方法)上的QPS上限,是"雪崩"场景的第一道防线
@PostConstruct
public void initFlowRule() {
    FlowRule rule = new FlowRule();
    rule.setResource("getProductDetail");
    rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 按QPS限流(也可选按并发线程数)
    rule.setCount(1000);                         // 单机QPS超过1000触发blockHandler
    FlowRuleManager.loadRules(Collections.singletonList(rule));
}

// 熔断规则:数据库响应变慢/报错增多时,主动切断请求,避免请求持续堆积拖垮整个服务
@PostConstruct
public void initDegradeRule() {
    DegradeRule rule = new DegradeRule();
    rule.setResource("getProductDetail");
    rule.setGrade(RuleConstant.DEGRADE_GRADE_RT); // 按"慢调用比例"熔断(也可选异常比例/异常数)
    rule.setCount(200);                // 响应时间超过200ms算一次慢调用
    rule.setSlowRatioThreshold(0.5);   // 一个统计窗口内慢调用占比超过50%触发熔断
    rule.setMinRequestAmount(10);      // 窗口内请求数不足10个不评估(避免小流量时误判)
    rule.setStatIntervalMs(1000);      // 统计窗口1秒
    rule.setTimeWindow(10);            // 熔断持续10秒后自动进入"半开"状态放探测请求
    DegradeRuleManager.loadRules(Collections.singletonList(rule));
}

这些规则里的数字是怎么定出来的?——这也是面试常追问的"细节题",答不出具体依据比背出代码本身更容易露怯。

限流阈值怎么定:如果有上千个接口,难道每个都要单独压测算一个 QPS 吗?

要点:限流保护的是共享资源(连接池/线程池),不是接口本身;方法论是自顶向下——先测总资源容量上限,按核心/长尾分级配置,长尾接口靠 Sentinel 系统自适应保护规则统一兜底。

先纠正一个常见误区:给每个接口"各自独立"设一个限流阈值(比如都设 1000 QPS),这个思路本身有问题——限流保护的是下游共享资源(数据库连接池、线程池、CPU),不是"接口"本身。1000 个接口哪怕每个都只放 1000 QPS,只要它们背后打的是同一个数据库连接池,叠加起来完全可能是几十万 QPS 一起怼上去,各接口的规则互相不知道对方的存在,"分别达标"不等于"整体安全"。

真正的方法论是自顶向下分配,而不是逐个接口拍脑袋:

  1. 先测出瓶颈资源的总容量上限:压测数据库连接池、核心线程池等共享资源,得到系统整体能扛的总 QPS/总并发数——这是硬约束,不管接口数量多少都不会变。
  2. 按业务优先级把总容量切分下去:
    • 核心链路(下单、支付、库存扣减这类,数量通常不多,几十个量级):单独压测、单独精算阈值,预留充足配额。
    • 非核心/长尾接口(可能占了上千接口里的大多数,但单个流量不高):不需要逐个精细压测,用统一的保守默认规则兜底即可。
  3. 用 Sentinel 的"系统自适应保护规则"(System Rule)解决"接口太多配不过来"的问题:它不按单个资源设固定阈值,而是监控系统整体的 Load、CPU 使用率、平均 RT、并发线程数、入口总 QPS,联动地对全局入口流量做控制,相当于给整个系统设一道"总闸",不需要精确知道每个接口该给多少 QPS。
  4. 单机限流 vs 集群限流:单机各自限流,实例数一变(扩缩容),总允许流量就跟着变(这通常是符合预期的,因为阈值本身就是按单机容量测出来的);如果想要一个不随实例数变化的全局精确总阈值,要用 Sentinel 的集群限流(Cluster Flow Control),由一个 Token Server 统一计数分发令牌,但这引入了 Token Server 本身的可用性和延迟问题,一般只对真正的核心资源才这么做。

一句话总结:多接口场景下不是"每个接口都精算一个数",而是"先算总资源能扛多少,按重要性切分,长尾接口靠系统自适应保护兜底"。

"先测出瓶颈资源的总容量上限"具体怎么压测

要点:单接口压测数字会虚高,真正定阈值要看全链路压测(多核心链路同时施压);用阶梯加压法画出 QPS-RT 曲线找拐点,线上阈值在拐点基础上打七八折留余量。

上面第1点说"压测数据库连接池、核心线程池等共享资源",这里展开讲清楚压测本身怎么做,避免面试官追问"你说要压测,具体怎么压"答不上来。

工具选型:JMeter(图形化,Java 生态最常用)、Gatling(脚本化,报告更专业)、wrk/wrk2(轻量命令行)、阿里云 PTS(云端全链路压测服务);大厂全链路压测(如阿里双11)多是自研平台。

单接口压测 vs 全链路压测:单独压测一个接口拿到的 QPS 数字会"虚高",因为压测时这个接口独占了数据库连接池/线程池等共享资源;真实大促场景是几十个核心接口同时抢占同一份资源,所以给限流定阈值真正该参考的是全链路压测(多个核心链路按真实流量比例同时施压)下的数据。全链路压测有两个关键技术难点:

  • 流量隔离:压测流量不能污染生产数据,标准方案是"影子库/影子表"(生产库结构复制一份,压测数据落到影子表)或"请求打标"(压测请求带特殊标记,中间件识别后路由到影子资源)。
  • 仿真数据构造:不能直接用生产用户数据(隐私问题),需要构造分布上贴近真实业务的测试数据。

怎么科学地找到"容量上限"这个具体值——阶梯加压法:不是一次性打满,而是从低到高阶梯式递增 QPS/并发数,同时画出"QPS-响应时间"曲线——正常范围内 QPS 上升、RT 基本平稳,到某个临界点 RT 会陡然上升(这就是拐点,说明共享资源开始排队/打满),同时观察错误率和 CPU/连接池使用率是否同步走高。这个拐点对应的 QPS 就是资源的实际容量上限。线上限流阈值一般在这个临界值基础上打七到八折作为安全余量,而不是直接拿临界值用,因为压测环境和生产还有细微差异,且要给突发流量留缓冲。

压测该覆盖哪些接口:为什么不能拿 QPS 高低作为选择标准

要点:接口 QPS 上限由 Little's Law 决定(QPS_max ≈ 资源池大小 / RT),选压测接口不看 QPS 高低,看 QPS×RT(资源占用量)排序,核心链路无条件精测,长尾按类型分桶抽样。

一个容易被忽略但很关键的问题:接口能扛多高 QPS,本身不是接口的固有属性,而是由排队论里的 Little's Law(利特尔法则)决定的——QPS_max ≈ 共享资源池大小 / 平均响应时间(RT)。

比如数据库连接池有 100 个连接,一个走索引的简单查询 RT=5ms,理论上能支撑 100 / 0.005s = 20000 QPS;而一个多表 join 或带事务的复杂写接口 RT=200ms,同样 100 个连接只能支撑 100 / 0.2s = 500 QPS。这就是为什么"快接口 QPS 天然高、慢接口 QPS 天然低"——RT 长的接口占用共享资源的时间长,单位时间内能服务的请求数天然就少,这不是接口设计得好坏,是排队论的必然结果。

这也说明"哪个接口 QPS 高就优先压测哪个"这个直觉是错的维度——真正决定资源池会不会被打满的是 QPS × RT(该接口对资源池的实际占用量),而不是 QPS 本身。据此,压测接口应该这样选:

  1. 先按"是否共享同一瓶颈资源"分组:同一个数据库连接池、同一个下游 RPC 连接池背后挂的所有接口是一组,因为它们互相竞争同一份资源,压测要验证的是这一组接口加总起来会不会打满资源池。
  2. 组内按 预估QPS × 平均RT 排序,选头部接口重点压测:这个乘积代表该接口对资源池的占用量,不管是"QPS 高的轻接口"还是"QPS 低但 RT 很长的重接口",乘积大的才是真正决定资源池会不会被打满的关键变量。
  3. 核心链路(下单/支付/库存)无条件单独精测:这是业务重要性决定的,不看 QPS × RT 排名,前面第2点已经提到。
  4. 长尾接口按"类型"分桶抽样,不逐个压测:按"纯内存读"、"走索引的简单DB查询"、"复杂DB写/join"、"涉及外部调用"分类,每类抽 1-2 个代表接口跑基准测试拿到该类型的单位RT系数,其余同类接口用这个系数乘以预估调用量估算即可,不需要重复压测。
  5. 全链路压测按真实流量比例混合施压,验证的核心不等式是 Σ(QPS_i × RT_i) ≤ 资源池总容量,而不是孤立验证某一个接口的极限。

一句话总结:压测选接口不看"谁的QPS高",看"谁占用共享资源的时间总量大"(QPS×RT),这才是决定资源池会不会被打满的真实变量。

熔断规则里的每个参数怎么定

要点:count 按正常 P95 的 2~3 倍定,slowRatioThreshold 看业务对可用性的容忍度,minRequestAmount 防小样本误判——没有放之四海皆准的数字,方法论是"压测初值→观察Dashboard→持续调整"。

以前面 DegradeRule 的例子逐项拆解:

  • count(RT模式下是"多慢算慢调用"的阈值,例子里是200ms):依据是这个接口正常情况下的响应时间分布(P95/P99),而不是随便定的数字,通常设成正常 P95 的 2~3 倍。设太接近正常值,网络抖动/GC 停顿这类正常波动就会被误判成"慢调用"导致频繁误熔断;设太高,等真出问题时数据库已经响应到秒级了才触发,保护来得太晚。
  • slowRatioThreshold(慢调用比例阈值,例子里是0.5):更多是业务对可用性的容忍度决定的,不是纯技术指标算出来的——核心支付/交易链路可以设得更敏感(比如0.3),宁可提前熔断也要保护系统;非核心链路可以设得更宽松(比如0.6~0.7),避免过度敏感导致频繁误熔断影响可用性。
  • minRequestAmount(窗口内最小请求数,例子里是10):目的是防止小样本导致的统计误判(窗口里只有2个请求、1慢1快,慢比例50%就触发熔断没有统计意义)。QPS 越高的接口可以设更大的最小样本数让统计更可信;QPS 本身很低的接口这个值要设小一点,否则规则永远触发不了。
  • statIntervalMs(统计窗口时长,例子里是1000ms):是"灵敏度"和"抗抖动能力"的权衡——窗口太短容易被瞬时抖动误伤,窗口太长对真实故障的反应会滞后。这个值要和 minRequestAmount 配套算,前提是这个接口在这个窗口时长内能攒够最小样本量。
  • timeWindow(熔断持续时长,恢复探测的等待时间,例子里是10s):依据是下游资源的典型自愈时间(比如数据库连接池被打满后清空堆积、恢复正常通常需要几秒到十几秒,照这个量级设)。太短容易"熔断→恢复→再熔断"反复抖动;太长则下游已经恢复了但业务恢复得比实际情况慢。

最关键的一点:这些数值没有放之四海皆准的标准答案,方法论是"先给一个基于压测/监控数据的初始值 → 上线后观察 Sentinel Dashboard 的实际触发情况(有没有误熔断、该熔断时有没有触发)→ 根据效果持续迭代调整",不是一次性写死。面试被追问"这个数怎么定的"时,讲清楚判断依据和调整方法论,比背一个具体数字更能体现深度。

现实案例

某次大促零点,缓存里大量商品详情 key 因为运维批量刷新时设置了相同 TTL 集中过期(典型雪崩诱因),叠加零点流量高峰,数据库连接池瞬间被打满、响应时间从几毫秒飙升到几秒。

因为提前配置了熔断规则(慢调用比例超过 50% 触发),Sentinel 在几秒内就自动切断了对数据库的持续调用,所有请求走 handleBlock 降级返回缓存的静态兜底数据(哪怕数据不是最新的),数据库因为没有继续被打,响应时间很快恢复正常,10 秒熔断窗口过后 Sentinel 自动放探测请求验证数据库已恢复,重新放开流量——整个过程没有人工介入,避免了"数据库雪崩式过载 → 响应更慢 → 请求堆积更多 → 数据库彻底打死"这个恶性循环滚雪球。

这也是为什么说熔断降级是"雪崩"场景里,业务代码本身的一致性/穿透/击穿方案都失效时的最后一道防线。

4.5 双写一致性(缓存与数据库的数据不一致)

要点:先更新数据库再删缓存(而非反过来),因为"先删后更"在并发下必然留下脏缓存;这个顺序仍有极小概率的不一致窗口,延时双删和兜底 TTL 用来降低/自愈这个概率,强一致场景则用 Canal 订阅 binlog。

这是面试深度的分水岭问题,很多人只会说"先更新数据库再删缓存",但讲不清楚为什么,也讲不清楚极端情况下依然会不一致。

为什么是"更新数据库 + 删除缓存"而不是"更新数据库 + 更新缓存"?

  • 如果两个写请求并发更新同一个 key,"更新缓存"模式下可能因为网络延迟导致写入顺序颠倒,后写入数据库的请求先写完缓存,最终缓存里是旧值。
  • "删除缓存"模式下,下次读请求会触发懒加载重新从数据库读最新值回填,天然避免了这个顺序问题,而且如果这个 key 之后不会被频繁读到,删除还能省去无谓的重建开销。

为什么是"先更新数据库,再删除缓存",而不是"先删除缓存,再更新数据库"?

标准的时序问题分析(面试必考):假设"先删缓存再更新数据库":

  1. 线程A:删除缓存
  2. 线程B:读缓存 miss,去查数据库(此时数据库还是旧值,因为线程A还没更新完)
  3. 线程B:把旧值写回缓存
  4. 线程A:更新数据库为新值

结果:数据库是新值,缓存是旧值,且这个旧值会一直脏下去直到下次更新(因为没有 TTL 兜底的话)。这就是经典的"先删后更"的并发漏洞。

而"先更新数据库,再删除缓存",理论上也有一个极小概率的漏洞窗口:

  1. 线程B:读缓存 miss(此时还没有人更新)
  2. 线程A:更新数据库为新值
  3. 线程A:删除缓存
  4. 线程B:把(步骤1查到的)旧值写回缓存

但这个窗口要求"读缓存 miss → 查数据库 → 写回缓存"这一整套操作比"更新数据库 → 删除缓存"还慢,在实际场景中发生概率极低(因为写操作通常比读操作慢,读操作在写操作开始前就已经完成了大半)。

所以业界公认"先更新数据库再删缓存"是工程上更优的选择,也就是经典的 Cache Aside Pattern。

延时双删:为了进一步降低上面这个小概率漏洞发生的可能性(尤其是有主从复制延迟的场景),常见做法是:

删除缓存 → 更新数据库 → sleep(比如500ms,覆盖主从同步延迟+读请求的执行时间)→ 再删除一次缓存

第二次删除是为了把"更新数据库和第一次删除缓存之间"可能因为并发读请求脏写回的缓存再清掉一次。这不是理论完美方案,只是进一步降低概率。

生产上一般还会给缓存设置一个兜底 TTL,即使真的出现了不一致,过期后也能自愈,这一点很多人会漏说,但恰恰是面试官想听的"工程上的最终兜底思维"。

强一致性场景(如订单、库存等金融级数据)的做法:延时双删只是"降低概率",如果业务对一致性要求非常高,业界的标准方案是订阅数据库 binlog 异步更新/删除缓存,用阿里开源的 Canal 组件模拟 MySQL 从库的身份拉取 binlog,解析出数据变更后异步删除对应缓存,这样即使应用代码本身漏删缓存(比如程序异常退出导致删除缓存那一步没执行到),也有 binlog 这个"最终真相源"来兜底保证最终一致性。

现实案例

电商的库存/价格类核心数据,很多大厂用 Canal + MQ 的组合来保证缓存最终与数据库一致,而不是仅依赖业务代码里手写的删除逻辑。

4.6 持久化:RDB / AOF / 混合持久化

要点:RDB 靠 fork+写时复制做全量快照,恢复快但两次快照间的数据会丢;AOF 逐命令追加最安全但文件大、需要 rewrite;混合持久化=重写时前半用RDB格式+后半用AOF格式,兼顾恢复速度与数据完整性。

RDB(快照):

  • 触发方式:SAVE(阻塞主线程,生产禁用)、BGSAVE(fork 子进程执行)、按 save 配置的条件自动触发。
  • 原理:fork() 出子进程,利用操作系统的写时复制(Copy-On-Write)机制——fork 瞬间子进程和父进程共享同一份内存页,只有当父进程(主线程继续处理写请求)要修改某个内存页时,才会真正复制那一页出来,这样子进程能拿到 fork 那一刻的数据快照,同时不阻塞主进程处理新请求。
  • 需要注意:fork 本身的瞬间是要阻塞的,大内存实例 fork 耗时可能到几十甚至上百毫秒,这也是为什么超大内存实例做 RDB 有短暂抖动的原因。
  • 优点:文件紧凑(二进制),恢复速度快,适合灾备/迁移。
  • 缺点:两次快照之间的数据会丢失,如果 Redis 在两次 BGSAVE 之间宕机,这段时间的写入全部丢失。

AOF(追加日志):

  • 原理:每条写命令执行后追加写入 AOF 文件(先执行后记录,避免记录了语法错误的命令)。
  • appendfsync 三种策略:
    • always:每条命令都 fsync 落盘,最安全但性能差(几乎退化成同步写磁盘的性能)。
    • everysec(默认推荐):每秒 fsync 一次,折中方案,最多丢 1 秒数据。
    • no:由操作系统决定何时刷盘,性能最好但可能丢失较多数据。
  • AOF 重写(Rewrite):随着命令不断追加,文件会越来越大(比如对同一个 key INCR 一万次,AOF 里就有一万条记录,但其实只需要记录最终结果)。BGREWRITEAOF 会 fork 子进程,根据当前内存中的数据直接生成一份新的、精简的 AOF(相当于把历史命令合并压缩成最少的命令集),同样利用写时复制不阻塞主进程。

混合持久化(4.0+,7.0+ 默认开启,aof-use-rdb-preamble yes):AOF 重写时,文件前半部分用 RDB 格式(紧凑、恢复快),后半部分是重写过程中新产生的增量命令用 AOF 格式(保证不丢失重写期间的写入)。这样兼顾了 RDB 的恢复速度和 AOF 的数据完整性,是目前生产环境的推荐配置。

7.0 之后 AOF 进一步变成多文件目录结构(Multi-Part AOF),把 base 文件(RDB或AOF格式的基础文件)、incr 文件(增量命令)、manifest 清单文件分开管理,重写过程中即使进程崩溃也不会破坏原有 AOF 的可用性(旧版本重写中途崩溃可能导致 AOF 损坏)。

现实案例

大促期间某电商曾遇到 AOF 重写触发的 CPU/IO 抖动问题——大内存实例(几十 GB)在写入高峰期触发 BGREWRITEAOF,fork 子进程瞬间导致的内存页表复制、以及子进程写盘占用大量 IO,与主线程处理请求抢资源,造成响应时间毛刺。

解决思路:避开高峰期做重写(可以关闭自动重写用定时任务在低峰期手动触发)、控制单实例内存大小(别把一个实例撑到几十 GB,用 Cluster 分片)、监控 latest_fork_usec 指标。

4.7 数据过期策略:Redis 怎么知道一个 key 过期了该删

要点:Redis 用"惰性删除+定期删除"组合(定时删除对CPU不友好,没被采用);从库不会主动删过期key,必须等主库同步删除命令过来,这是为了保证主从一致性。

三种理论策略:

  1. 定时删除:设置 key 的同时创建一个定时器,到期立即删除。优点:内存友好,及时释放。缺点:如果 key 特别多,会创建大量定时器,消耗大量 CPU 去管理这些定时器,对 CPU 不友好,Redis 没有采用。
  2. 惰性删除:只有当访问这个 key 时才检查是否过期,过期就删除并返回不存在。优点:删除操作只发生在访问时,CPU 开销最小。缺点:如果一个 key 过期后一直没人访问,会一直占用内存,内存不友好。
  3. 定期删除:每隔一段时间主动扫描一批设置了 TTL 的 key,删除其中过期的。是前两者的折中。

Redis 实际采用惰性删除 + 定期删除的组合:

  • 惰性删除:每次访问 key 前调用 expireIfNeeded 检查。
  • 定期删除(activeExpireCycle):默认每秒执行 10 次(可配置),每次从设置了过期时间的 key 中随机抽取一定数量进行检查删除,如果本轮抽取的 key 中过期比例超过 25%,会立即再抽取一批继续清理(因为说明这个库里过期 key 比例很高,值得多清理),但整个过程有时间片限制(默认不超过 25ms,避免长时间占用主线程阻塞正常请求)。

主从复制中的特殊处理:从库不会主动删除过期 key(即使从库本地判断这个 key 已经过期),而是继续返回给客户端"不存在"的逻辑效果(读命令层面过滤),真正的删除动作要等主库执行删除后同步 DEL/UNLINK 命令给从库。

这个设计是为了保证主从数据的一致性——如果从库自行删除,而这条删除操作还没同步到其他从库,会导致不同从库之间数据不一致。这是一个非常容易被问到但很少有人答对的细节。

4.8 数据淘汰策略:内存满了怎么办

要点:8种淘汰策略里,LRU 是"随机采样近似"实现(不是真链表),LFU 用概率计数器+衰减机制识别真正热点;通用场景选 allkeys-lru,缓存+业务数据混存选 volatile-lru,冷热差异大选 allkeys-lfu。

当 used_memory 达到 maxmemory 限制时,触发淘汰策略(maxmemory-policy),8 种:

策略 说明
noeviction(默认) 不淘汰,写命令直接报错,读命令仍可执行
allkeys-lru 所有 key 中淘汰最近最少使用的
allkeys-lfu(4.0+) 所有 key 中淘汰访问频率最低的
allkeys-random 所有 key 中随机淘汰
volatile-lru 只在设置了 TTL 的 key 中淘汰最近最少使用的
volatile-lfu 只在设置了 TTL 的 key 中淘汰访问频率最低的
volatile-random 只在设置了 TTL 的 key 中随机淘汰
volatile-ttl 只在设置了 TTL 的 key 中淘汰剩余存活时间最短的

近似 LRU 的实现原理(重点,很多人只知道有 LRU 不知道是"近似"的):Redis 没有维护一个真正的双向链表按访问顺序排列所有 key(那样内存开销太大),而是用随机采样近似实现:每个 key 的对象头(redisObject)里有一个 24bit 的 lru 字段,记录最近一次访问的秒级时间戳(近似)。

淘汰时,根据 maxmemory-samples(默认 5)随机采样 N 个 key,淘汰其中 lru 时间最早(最久未访问)的那个。采样数越大,淘汰结果越接近真正的 LRU,但计算开销也越大,这是内存精确度和性能的权衡。

LFU 的实现原理:用 8bit 存储访问频率计数器(counter,取值 0-255),不是简单的自增计数,而是用概率递增算法(Morris Counter 思想)——计数值越大,再次自增的概率越低(避免 8bit 很快打满饱和,让计数器能反映更长时间跨度的相对热度差异)。

同时有衰减机制(lfu-decay-time),一段时间不访问,counter 会周期性衰减,避免"曾经很热但现在已经不再访问的 key"一直占据高优先级不被淘汰。

现实案例选型

  • 通用缓存场景:allkeys-lru,符合大部分业务"最近访问的数据更可能再被访问"的局部性原理。
  • 缓存 + 会话/业务数据混存的实例(既有设了 TTL 的缓存 key,也有不设 TTL 的持久业务数据):用 volatile-lru,保证不会误删没设 TTL 的重要数据,只在缓存类数据里做淘汰。
  • 访问模式呈现明显热点、冷热差异大的场景(比如热搜榜、爆款商品):allkeys-lfu 比 LRU 更能准确识别真正的热点数据(LRU 只看"最近有没有访问",一个刚被偶然访问一次的冷数据也会被认为"最近使用",而 LFU 看的是频率,更抗噪声)。

五、分布式锁

5.1 手写 SETNX 实现及所有的坑

要点:手写 SETNX 会依次踩到"非原子加锁+过期"、"误删他人锁"、"无法续期"、"不可重入"四个坑,Redisson 用 Lua 脚本+看门狗后台线程把这些坑都解决了。

最基础版本:

SETNX lock_key 1
EXPIRE lock_key 10

第一个坑:SETNX 和 EXPIRE 不是原子操作,如果 SETNX 成功后进程恰好崩溃/被 kill,EXPIRE 没来得及执行,这把锁就永远不会过期,变成死锁。

解法:用 SET key value NX EX 10 一条命令原子完成加锁+设置过期时间(Redis 2.6.12+ 支持)。

第二个坑:value 如果都设成固定值(如 "1"),线程 A 加的锁,可能被线程 B 误删——比如 A 的业务执行时间超过了锁的 TTL,锁自动过期后,B 加锁成功,随后 A 业务执行完毕执行 DEL lock_key,删掉的其实是 B 的锁,导致锁失效保护失效。

解法:value 设置成当前线程/客户端的唯一标识(如 UUID),释放锁时先 GET 判断 value 是否是自己的,是才 DEL。但"判断+删除"两步操作又不是原子的,中间可能被切走(这个 key 在判断和删除之间恰好过期,另一个线程加锁成功,然后被误删)。

解法:用 Lua 脚本保证判断+删除的原子性(Redis 执行 Lua 脚本本身是原子的,不会被其他命令打断):

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

第三个坑(锁超时/续期问题):即使前面都做对了,还有一个本质问题——你没法准确预估业务逻辑要执行多久,TTL 设短了业务没执行完锁就过期,设长了万一持锁的客户端崩溃,其他人要等很久才能拿到锁。这是手写 SETNX 方案的天花板,解决这个问题需要自动续期机制,也就是 Redisson 要解决的核心问题之一。

第四个坑(不可重入):同一个线程内如果需要重复获取同一把锁(比如方法A调用方法B,两个方法都要加同一把锁),原生 SETNX 会直接加锁失败,因为它不知道"这个请求是不是自己之前已经持有的锁又发起的"。

5.2 Redisson 原理

要点:看门狗每隔 leaseTime/3 自动续期锁的 TTL,但只有不显式传 leaseTime(用 lock())才会启用;可重入靠 Hash 结构记录线程标识+重入次数;RedLock 是否可靠有争议,强一致场景更推荐 ZooKeeper/etcd。

Redisson 是生产环境的标准选择,它内部通过 Lua 脚本 + 后台线程解决了上面所有问题。

看门狗(Watch Dog)自动续期:

  • 默认锁的过期时间是 30 秒(lockWatchdogTimeout)。
  • 加锁成功后,Redisson 会启动一个后台定时任务,每隔 internalLockLeaseTime / 3(也就是 10 秒)检查锁是否还被当前线程持有,如果是,就把过期时间重新刷新回 30 秒(renewExpiration)。
  • 这样只要业务线程还活着(没有崩溃、没有被卡死),锁就会一直被续期,直到业务执行完主动 unlock。如果客户端进程崩溃了,看门狗线程也会随之停止,锁到了 TTL 之后自然过期释放,不会出现死锁。
  • 注意:只有不指定 leaseTime(用 lock() 而不是 lock(10, TimeUnit.SECONDS))才会启用看门狗,如果显式指定了锁的过期时间,Redisson 认为你明确知道自己要锁多久,就不会启动自动续期。这是很多人用 Redisson 却踩到"锁提前过期"这个坑的根本原因——手滑传了个 leaseTime 参数把看门狗关掉了。

可重入实现:Redisson 的锁底层用 Hash 结构存储,field 是加锁线程的唯一标识(UUID + threadId),value 是重入次数。加锁的 Lua 脚本逻辑大致是:

-- 锁不存在,直接加锁,重入次数记为1
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end
-- 锁存在且是当前线程持有,重入次数+1
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end
-- 锁被其他线程持有,返回剩余TTL,加锁失败
return redis.call('pttl', KEYS[1])

解锁时对应 hincrby -1,减到 0 才真正 del 释放锁并发布解锁消息(通过 Redis 的 Pub/Sub 通知其他等待的线程可以来抢锁了,而不是让等待线程无脑自旋轮询,这也是 Redisson 比手写 SETNX 自旋等待更高效的地方)。

公平锁(FairLock):底层用 List(记录等待队列的顺序)+ ZSet(记录每个等待者超时时间用于清理僵尸等待者)组合实现,保证多个线程按照请求锁的先后顺序获取锁,避免非公平锁下可能出现的线程饥饿(一直有新线程插队导致某个等待很久的线程一直抢不到)。

读写锁(RReadWriteLock):读锁之间共享不互斥,写锁与读锁、写锁与写锁互斥。适合读多写少场景(如配置数据:更新很少,但大量请求要读取)。

RedLock(红锁)—— 有争议,面试可以展开讲讲两派观点体现深度:

背景:如果只对一个 Redis 主节点加锁,即使这个节点有从库做高可用,也存在这样的风险场景——主节点加锁成功后还没来得及把这条写命令同步给从库就宕机了,哨兵把从库提升为新主库,这个新主库上并没有这把锁的数据,导致另一个客户端在新主库上成功加到了同一把"锁",出现两个客户端同时持锁的问题。

RedLock 方案:向 N 个相互独立的 Redis 主节点(官方建议 5 个,且部署上要保证互相独立,不是同一套主从关系)分别尝试加锁,只有当客户端在多数节点(N/2+1 个)上都加锁成功,且加锁的总耗时小于锁的有效期,才认为加锁成功。这样即使个别节点宕机,只要多数节点的锁状态一致,就能保证互斥性。

争议:分布式系统专家 Martin Kleppmann 曾专门写文章批评 RedLock,核心论点是:RedLock 依赖系统时钟和执行时间的假设(比如客户端在拿到多数节点的锁之后,如果发生了一次很长的 GC 停顿或者网络延迟,等它醒来时锁可能已经过期,但它并不知道,依然认为自己持有锁继续执行业务,这期间别的客户端可能已经拿到了锁),RedLock 并不能像 ZooKeeper 那样通过单调递增的 fencing token(写屏障令牌)机制在下游资源层面(如数据库)真正杜绝这种"过期后仍在执行"的并发问题。

antirez(Redis 作者)随后写了反驳文章,认为 Kleppmann 的场景过于极端,实际工程中通过合理设置 TTL 和监控 GC 停顿,风险是可控的。

面试上怎么回答这个问题体现深度:不要简单说"RedLock 就是好"或"RedLock 有问题不能用",而是要讲清楚——如果业务场景要求锁的绝对正确性(比如涉及资金的强一致性操作),更推荐用 ZooKeeper/etcd 这类通过强一致性协议(ZAB/Raft)实现的分布式锁,它们天然支持 fencing token;如果业务场景对锁的可用性和性能要求更高、能容忍极小概率的边界风险(比如秒杀限流、防重复提交这类场景),Redis 分布式锁(包括 RedLock)是性价比更高的工程选择。这才是资深工程师该有的权衡视角。

现实案例

电商秒杀场景中,用 Redisson 分布式锁包裹"查库存 → 扣库存 → 创建订单"这个流程,防止超卖(当然更优的做法是用 DECR 原子扣减库存本身规避锁的使用,锁通常用在流程更复杂、涉及多个资源、无法简单原子化的场景,比如需要同时锁定多个不同商品的库存、或者涉及优惠券核销+库存扣减的组合操作)。

5.3 其他中间件怎么做分布式锁:ZooKeeper / etcd / 数据库,与 Redis 怎么选

要点:ZooKeeper/etcd 靠强一致协议 + 锁与客户端会话(session/lease)绑定,天然不需要 TTL 和看门狗、天然支持 fencing token,正确性比 Redis 更有保障,但性能不如 Redis;数据库锁实现最简单但性能最差,只适合低频兜底场景。四种方案本质上是在"性能"和"正确性保障"之间做不同取舍。

上一节 RedLock 争议里提到"强一致场景更推荐 ZooKeeper/etcd",这里展开讲清楚它们具体怎么实现锁,以及和 Redis 方案的本质差异在哪。

5.3.1 ZooKeeper:临时顺序节点 + Watch 机制

原理:在锁对应的目录(如 /locks/order_10086)下,每个想加锁的客户端创建一个 EPHEMERAL_SEQUENTIAL(临时顺序)节点:

  1. 创建后获取该目录下所有子节点并按序号排序,判断自己创建的节点是否是序号最小的:是则加锁成功;不是则对排在自己前一位的节点注册 Watcher(只监听前一位,不是监听全部,避免所有等待者被同时唤醒去抢锁的"惊群效应"),自己阻塞等待。
  2. 持锁客户端用完后删除自己的节点,触发下一个等待者的 Watcher 回调,唤醒它重新判断是否变成了最小节点。

为什么不需要 TTL 和看门狗——这是和 Redis 方案最本质的区别:节点是 EPHEMERAL(临时)类型,生命周期绑定在客户端与 ZK 之间的 session 上。客户端主动断开、崩溃、或网络分区超过 session 超时时间,ZK 服务端都会自动删除这个节点,锁随之释放。这是协议层面的能力,不需要业务代码设置固定 TTL、也不需要额外起一个后台线程续期,天然避开了 Redis 方案"TTL 设短了业务没跑完锁就掉、设长了崩溃后锁迟迟不释放"的两难。

一致性保证:ZK 集群基于 ZAB 协议(类似 Paxos 的原子广播协议),所有写操作经 Leader 协调、过半数 Follower 确认才算成功,保证任意时刻集群内数据视图强一致,这是它能保证锁绝对互斥的基础。

Fencing Token:节点序号本身就是全局单调递增的,可以直接当 fencing token 用(下游资源收到旧序号的请求可以直接拒绝),这正是 Kleppmann 批评 RedLock 时认为 Redis 方案缺失的能力。

// Curator(Netflix 开源的 ZK 客户端框架)标准用法
CuratorFramework client = CuratorFrameworkFactory.newClient(
    "zk1:2181,zk2:2181,zk3:2181", new ExponentialBackoffRetry(1000, 3));
client.start();

InterProcessMutex lock = new InterProcessMutex(client, "/locks/order_10086");
if (lock.acquire(10, TimeUnit.SECONDS)) {  // 最多等10秒拿不到就放弃
    try {
        // 业务逻辑;临时顺序节点的存在本身就是锁凭证,不需要单独设置过期时间
    } finally {
        lock.release();  // 删除节点,释放锁
    }
}

5.3.2 etcd:Lease 租约 + Revision + Raft

原理:etcd 底层是 Raft 协议(和 ZAB 类似都是基于日志复制的强一致性协议),锁基于两个核心概念实现:

  • Lease(租约):客户端先创建一个带 TTL 的 Lease(比如 10 秒),并启动 KeepAlive 定期自动续约——这一步官方 SDK 内置实现,不用像 Redisson 那样自己写看门狗逻辑。
  • Revision(版本号):每次写操作 etcd 都会附加一个全局单调递增的版本号。

加锁流程:客户端在锁的 key 前缀(如 /locks/order_10086/)下写入一个绑定该 Lease 的 key;查询该前缀下所有 key 按 revision 排序,自己的 revision 最小则加锁成功,否则监听 revision 比自己小的前一个 key 的删除事件阻塞等待——思路和 ZK 的临时顺序节点几乎一致。

释放锁:主动删除 key,或者 Lease 到期没被续约(客户端崩溃导致 KeepAlive 停止)后 etcd 自动删除该 key,同样是"锁的生命周期绑定客户端存活状态",不依赖固定 TTL 硬顶。

Fencing Token:revision 天然单调递增,直接可当 fencing token 用。

典型场景:Kubernetes 的 Leader Election(如 controller-manager、scheduler 多副本选主)就是基于 etcd 这套机制实现的,是云原生生态的标准做法;国内不少分布式任务调度框架(如 Elastic-Job)用 ZooKeeper 做类似的集群选主和分片管理。

// etcd 官方 clientv3/concurrency 包(Go),Java 生态可用官方维护的 jetcd 客户端,API 思路一致
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"etcd1:2379"}})
session, _ := concurrency.NewSession(cli, concurrency.WithTTL(10)) // 内置KeepAlive自动续约
mutex := concurrency.NewMutex(session, "/locks/order_10086")

mutex.Lock(context.Background())    // 阻塞直到拿到锁
// 业务逻辑
mutex.Unlock(context.Background())

5.3.3 数据库锁:唯一索引 / SELECT ... FOR UPDATE

最朴素的方案,适合系统已经强依赖 MySQL、不想引入额外中间件、并发量很低的场景:

  • 唯一索引:建一张锁表 distributed_lock(lock_key VARCHAR UNIQUE),加锁即 INSERT INTO distributed_lock(lock_key) VALUES('order_10086'),利用唯一索引冲突天然互斥(第二个请求 INSERT 报 Duplicate entry 视为加锁失败);解锁即 DELETE FROM distributed_lock WHERE lock_key='order_10086'。
  • 悲观锁:事务里执行 SELECT * FROM resource WHERE id=1 FOR UPDATE,该行数据被锁住直到事务提交/回滚,其他事务的相同查询阻塞等待。

局限:性能是几种方案里最差的(本质是数据库行锁/索引冲突,数据库连接池是稀缺资源,高并发容易把连接池打满);没有内置自动过期机制,客户端崩溃且没配合好事务/连接超时设置,锁可能长期不释放;不像 Redis/ZK/etcd 那样天然支持横向扩展。生产核心链路不建议用,只适合应急或低频兜底。

四种方案对比

维度 Redis(Redisson) ZooKeeper etcd MySQL
一致性协议 主从复制+哨兵/Cluster,非强一致(RedLock是折中方案) ZAB(类Paxos) Raft 依赖事务隔离级别
性能量级 最高(万级 QPS+) 中等(千级,写要过半数确认) 中等(同 ZK 量级) 最低(受连接池和行锁限制)
锁自动释放机制 TTL 过期,需看门狗续期兜底 临时节点绑定 session,客户端断开自动删除 Lease + KeepAlive,客户端崩溃到期自动删除 依赖事务/连接超时,无原生机制
原生 Fencing Token 不支持(需应用层自己加版本号) 支持(节点序号天然单调递增) 支持(revision天然单调递增) 可用行的 version 字段模拟
接入/运维成本 低(通常已有 Redis) 较高(独立部署 ZK 集群) 中等(单二进制部署简单,云原生生态成熟) 低(通常已有 MySQL)
典型场景 秒杀限流、防重复提交等高并发场景 强一致要求的核心资源调度(任务分片、配置管理) K8s 等云原生场景的 Leader 选举 低频、非核心、系统已重度依赖 MySQL

选型建议

  • 高并发、能容忍极小概率边界风险的场景(秒杀、防重复提交):Redis/Redisson,性价比最高,也是大多数业务的默认选择。
  • 需要精确调度、强一致性的核心资源协调(分布式任务调度选主、分片管理):ZooKeeper/etcd,正确性有强一致协议背书,不需要操心锁超时/续期这套问题。
  • 系统本身已经是 K8s/云原生体系、已经在用 etcd 做服务注册发现:直接复用 etcd 做锁,不必再引入 ZK 或 Redis。
  • 传统单体应用、并发量极低、还没上 Redis/ZK 的系统:数据库锁应急,但这只是过渡方案,不建议长期作为核心方案。

六、消息队列与延时队列

要点:List 无 ACK 消息会丢,Pub/Sub 完全不持久化离线即丢,Stream 靠 ACK+PEL 机制做到消息不丢,是三者里最接近专业 MQ 的方案;延时队列则靠 ZSet 的 score 排序实现。

6.1 用 List 实现简单队列

要点:没有 ACK 机制,消费者拿到消息后如果处理中崩溃,这条消息彻底丢失,只适合对可靠性要求很低的场景。

LPUSH queue_key msg1
BRPOP queue_key 0    # 阻塞式弹出,0表示一直阻塞直到有消息

局限:

  • 一条消息只能被一个消费者消费(无法多播/广播给多个消费组)。
  • 没有 ACK 机制:消费者 BRPOP 拿到消息后如果处理过程中崩溃,这条消息就彻底丢失了,没有重新投递的机制。
  • 消息不落盘(依赖 Redis 的 RDB/AOF 持久化,但和专业 MQ 的持久化保证不是一个量级)。

适合对可靠性要求很低的场景,比如简单的异步任务通知。

6.2 Pub/Sub(发布订阅)

要点:比 List 更极端,完全不持久化,订阅者不在线就直接丢消息,新订阅者也拿不到历史消息。

SUBSCRIBE channel1
PUBLISH channel1 "message"

局限(比 List 队列更极端):完全不持久化。如果订阅者在消息发布时没有在线监听,这条消息直接丢失,不会有任何补偿机制,甚至新连接进来的订阅者也无法获取历史消息。

适合"在线的才需要收到,不在线丢了也无所谓"的场景,比如实时聊天室的在线状态广播、简单的服务间事件通知(非核心链路)。

6.3 Stream(5.0+,Redis 官方给的"正经"消息队列方案)

要点:消费者组模式支持多消费者负载均衡;PEL 记录"已投递未确认"的消息,配合 XACK/XCLAIM 实现故障转移和消息不丢,但堆积能力和生态仍不如 Kafka/RocketMQ。

Stream 借鉴了 Kafka 的设计思路,是目前 Redis 里最接近专业 MQ 的实现。

核心概念:

  • XADD stream_key * field1 value1:写入一条消息,* 表示自动生成消息 ID(格式是 毫秒时间戳-序号,保证单调递增)。
  • 消费者组(Consumer Group):XGROUP CREATE stream_key group1 0,允许多个消费者组各自独立消费同一个 Stream(类似 Kafka 的消费组概念),同一组内的多个消费者共同消费、互不重复(组内负载均衡)。
  • XREADGROUP GROUP group1 consumer1 COUNT 10 STREAMS stream_key >:以消费者组的身份读取消息,> 表示只读没被组内任何消费者读过的新消息。
  • PEL(Pending Entries List,待确认列表):消息被某个消费者读取后,会自动进入这个消费者的 PEL,代表"已投递但未确认"。
  • XACK stream_key group1 message_id:消费者处理完消息后显式确认,该消息才会从 PEL 中移除。如果消费者读取了消息但处理过程中崩溃,没有执行 XACK,消息会一直留在 PEL 里,不会丢失。
  • XCLAIM/XAUTOCLAIM:其他消费者可以把长时间停留在别的消费者 PEL 里(超过一定 idle 时间,说明原消费者可能已经挂了)的消息转移给自己处理,实现故障转移和消息不丢失的保证。

Stream 相比 List/Pub-Sub 的本质提升:有了 ACK 和 PEL 机制,就有了"消息投递了但没处理成功要重新投递"的可靠性保证,这是它能被称为"消息队列"而不只是"消息通知"的关键。

局限(相比 Kafka/RocketMQ 依然弱):单机内存容量限制了消息堆积能力(Kafka 靠磁盘顺序写可以堆积海量消息,Stream 数据在内存里,虽然可以用 XTRIM 控制长度,但没法像 Kafka 那样支撑 TB 级别的消息堆积);没有 Kafka 那种基于分区的水平扩展模型(Cluster 模式下可以把不同 Stream 分散到不同分片,但单个 Stream 内部没有分区概念);生态和运维工具链(监控、管理界面)远不如成熟 MQ 丰富。

现实案例

日志采集、埋点上报这类允许小概率丢失、但希望比 Pub/Sub 更可靠的场景,用 Stream 作为轻量级方案能省掉引入 Kafka 的运维成本。但涉及资金、订单这类核心业务,业界主流依然是用 RocketMQ/Kafka,Stream 更多是"中小规模、追求简单性"场景下的补充方案,这个取舍判断在面试里主动讲出来是加分项。

6.4 延时队列:用 ZSet 实现

要点:用到期时间戳作为 score,轮询取出已到期消息;用 ZREM 的返回值判断"是不是自己摘到的",天然避免多实例重复处理,不需要额外加锁。

原理:ZSet 的 score 天然支持排序,把消息的到期时间戳作为 score,消息体(或消息ID)作为 member:

ZADD delay_queue 1721000000000 "order:10086"   # score=到期时间戳

后台起一个轮询线程,周期性地取出所有已到期的消息处理:

while (running) {
    long now = System.currentTimeMillis();
    // 取出score <= 当前时间的消息,最多取100条避免一次处理过多
    Set<String> expired = jedis.zrangeByScore("delay_queue", 0, now, 0, 100);
    for (String msg : expired) {
        // ZREM 保证原子摘除,避免多个消费者线程/实例重复处理同一条消息
        Long removed = jedis.zrem("delay_queue", msg);
        if (removed != null && removed > 0) {
            handleExpiredMessage(msg); // 只有摘除成功的线程才处理
        }
    }
    Thread.sleep(200); // 轮询间隔,决定了延时精度
}

关键细节:

  • 用 ZREM 的返回值判断"是不是自己摘到的",这是防止多实例部署时消息被重复处理的关键(如果服务部署了多个实例都在跑这个轮询逻辑,必须保证一条消息只被一个实例真正处理,ZREM 返回删除成功的个数天然具备这个互斥效果,不需要额外加锁)。
  • 轮询间隔决定了延时的精度上限(比如 200ms 轮询一次,实际触发时间可能比理论到期时间晚 0~200ms),如果业务对精度要求高,可以用 Redis 的 keyspace notification 结合 EXPIRE 事件做事件驱动(notify-keyspace-events Ex,监听 key 过期事件),但这个方案有个大坑:Redis 的 key 过期事件通知不保证一定触发或保证时序(尤其在 Cluster 模式或从库场景下),生产环境更推荐用上面这种"ZSet + 主动轮询"的方案,虽然不是纯事件驱动,但更可控可靠。

现实案例

  1. 订单超时自动关闭:下单成功后把 订单ID 以"下单时间+30分钟"为 score 写入延时队列,后台服务轮询到期订单,检查支付状态,未支付则关闭订单并回滚库存——这是电商系统里最经典的延时队列应用。
  2. 优惠券到期提醒/自动核销:优惠券领取后按有效期写入延时队列,到期前一天触发提醒推送。
  3. 外卖/物流超时预警:订单在承诺送达时间前 N 分钟仍未完成配送,触发预警通知客服介入。

对比:如果业务对延时精度和可靠性要求非常高(金融级场景),业界更成熟的方案是 RocketMQ 的原生延时消息(RocketMQ 5.0 之前是固定 18 个延时等级,5.0+ 支持任意精度)或者 RabbitMQ 的死信队列(DLX)+ TTL 方案。

Redis ZSet 方案的优势是实现简单、不需要额外引入中间件,劣势是轮询本身有性能开销、可靠性依赖自己写的消费者逻辑做好幂等和异常处理,这个取舍同样建议在面试里主动点出来。


七、Redis 版本演进与新特性

面试官问这块,考察的不是你能不能背版本号,而是你是否持续关注技术动态、理解每个新特性解决了什么老问题。以下核心机制我有把握,个别最新版本细节标注了时效性,建议面试前去官网 Release Notes 核实一遍版本号(我的知识截止 2026年1月,你现在已经是 7 月,可能有更新我没覆盖到)。

7.1 Redis 6.0(2020)

要点:多线程只用于网络IO读写,命令执行本身依然单线程;此外还引入了 ACL 细粒度权限、RESP3 协议和客户端缓存(Tracking)。

  • 多线程 I/O:注意这不是"命令执行变成多线程",而是网络数据的读取/解析、以及回写结果给客户端这两步可以用多个 I/O 线程并行处理,真正的命令执行逻辑依然是单线程,这样设计是为了保留 Redis 最核心的优势——单线程执行命令天然避免了多线程并发访问数据结构需要加锁的复杂度和性能损耗,同时又缓解了高并发场景下网络 I/O 本身成为瓶颈的问题。面试容易被问到"Redis 6.0 之后还是单线程吗?"——标准答案是"网络层多线程,执行层依然单线程"。
  • ACL(Access Control List):细粒度权限控制,可以给不同用户配置能访问哪些命令、哪些 key 模式,替代了之前只有一个 requirepass 全局密码的简单模型,生产环境做多租户/多业务线权限隔离时很实用。
  • RESP3 协议:新的通信协议,支持更丰富的数据类型返回、以及下面的客户端缓存功能。
  • Client-Side Caching(客户端缓存/Tracking):允许客户端本地缓存部分数据,Redis 服务端在这些数据被修改时主动推送失效通知给客户端,客户端收到通知后让本地缓存失效。这本质上是把"多级缓存"里的失效同步问题往下沉了一层,由 Redis 官方协议原生支持,而不需要应用层自己实现推送逻辑。

7.2 Redis 7.0(2022)

要点:Functions 让 Lua 脚本能持久化并随主从/集群同步(区别于不持久化的 EVAL 脚本);AOF 变成 base+incr+manifest 多文件结构,重写更安全;listpack 全面替代 ziplist 解决连锁更新问题。

  • Functions:用 Lua 编写、持久化存储在 Redis 服务端的函数(区别于之前的 EVAL 脚本,脚本本身不持久化,每次调用要么带上脚本内容要么依赖 SCRIPT LOAD 缓存,重启或者主从切换后缓存的脚本可能丢失)。Functions 通过 FUNCTION LOAD 注册后会随 RDB/AOF 持久化,主从/集群间也能正常同步,更适合把复杂业务逻辑封装成可靠的服务端函数。
  • Multi-Part AOF:前面第四节已经讲过,AOF 从单文件变成 base+incr+manifest 的目录结构,重写更安全。
  • listpack 全面替代 ziplist:ziplist 存在一个已知的连锁更新(cascade update)性能问题——链式结构中每个节点记录前一个节点的长度,当中间某个节点变化时可能引发后续所有节点长度字段连锁更新,listpack 改进了编码方式规避了这个问题,Hash/ZSet 的小数据编码从 ziplist 迁移到了 listpack(List 稍早在这方面已经动手)。
  • Sharded Pub/Sub:Cluster 模式下,之前的 Pub/Sub 消息会被广播到集群所有节点(不管订阅者在哪个分片),造成不必要的网络开销;Sharded Pub/Sub 让发布订阅按 key 的 slot 分片,消息只在对应分片内传播,更适合大规模集群场景。

7.3 Redis 7.2 / 7.4

要点:核心新增是 HEXPIRE/HPERSIST,让 Hash 的单个字段可以有独立过期时间,不用再靠应用层额外维护或拆成独立 key。

  • 一系列性能与稳定性优化(如更快的 RDB 加载、OBJECT FREQ 等观测性命令增强)。
  • Hash 字段级 TTL(HEXPIRE/HPERSIST 等,7.4 引入):在这之前,Redis 的 TTL 只能设置在整个 key 级别,如果想要 Hash 里某个字段单独过期,只能自己在应用层维护一份"字段+过期时间"的额外记录,或者把这个字段拆成独立的 key。HEXPIRE 让 Hash 的单个字段可以有独立的过期时间,对于"一个对象里某些属性需要短期缓存、某些属性需要长期保留"这类场景(比如用户信息里,基础资料长期有效,但某个临时验证状态只需要存在几分钟)能省掉很多应用层的额外设计。

7.4 许可证事件(面试近两年高频追问,体现"是否关注行业动态")

要点:2024年 Redis Ltd. 把协议从 BSD 改成非OSI认可的 SSPL/RSALv2,限制云厂商白嫖托管服务;社区随即基于改协议前的版本 fork 出中立治理的 Valkey;后续 Redis 又重新引入 AGPLv3。

这是技术之外但面试经常被问到的内容,因为它直接影响了很多公司的技术选型:

  • 2024年3月,Redis Ltd.(商业公司,注意区别于 Redis 这个开源项目本身)宣布把开源许可证从原来 OSI 认可的 BSD 3-Clause(真正意义上的开源协议)改为 SSPL(Server Side Public License)/ RSALv2(Redis Source Available License) 的双重可选协议。这两个协议本质上都不是 OSI 认可的开源协议,核心限制是:如果你是云厂商,把 Redis 作为托管服务对外提供(比如做一个"XX云 Redis"产品),必须要么开源你自己的管理平台代码,要么另外向 Redis Ltd. 购买商业授权,这明显是针对 AWS/阿里云/腾讯云这类靠开源软件做云托管服务盈利、但没有反哺开源社区的商业模式。
  • 这个变更引发了社区强烈反弹,随后 AWS、Google Cloud、Oracle 等厂商联合 Linux Foundation 基于 Redis 改协议前最后一个 BSD 协议版本(7.4 之前),fork 出了一个新项目 Valkey,继续保持 BSD 开源协议,由 Linux Foundation 这样的中立基金会治理(而不是单一商业公司)。这次事件的性质和当年 MongoDB 改成 SSPL、以及更早 Elasticsearch/Kibana 改协议导致 OpenSearch 被 fork 出来的情况高度相似,是开源商业化模式冲突的又一个典型案例。
  • 后续影响:不少云厂商的托管 Redis 服务(如 AWS ElastiCache)转向基于 Valkey 提供服务而不是继续跟随 Redis 官方新版本;国内也有云厂商在做类似的表态或迁移。
  • 据我了解(这部分时效性较强,建议你核实最新进展),Redis Ltd. 在后续版本(Redis 8.0 前后)迫于社区压力,又重新引入了 AGPLv3 作为可选的开源协议之一,一定程度上算是"部分回归开源",但具体最新的协议策略、以及 Valkey 和 Redis 官方分支后续是否会有进一步分裂或合并,这块变化比较快,我不能保证是最新状态,建议你面试前去 Redis 官网和 Valkey 官网各看一眼当前的协议声明。

面试上怎么答这类问题:不需要背协议条款细节,讲清楚"发生了什么、为什么发生(商业公司想从云厂商身上收回一部分价值)、行业怎么反应(fork出中立治理的Valkey)、对我们做技术选型有什么影响(选型时要关注协议变化、避免未来被许可证问题卡住、Cluster/命令层面 Valkey 目前基本兼容 Redis 协议可以平滑替换)"这个逻辑链条,比背哪个版本改了哪个协议更重要。

7.5 Redis 8.0 及之后

要点:协议上重新纳入 AGPLv3;能力上新增 Vector Set 面向 AI 向量检索场景,是 Redis 向"轻量向量数据库"方向扩展的信号。

  • 协议策略的进一步调整(如上所述,重新纳入 AGPLv3)。
  • 新增 Vector Set 数据类型,面向 AI/向量检索场景(存储向量并支持近似最近邻检索),这是 Redis 顺应 AI 应用爆发、往"向量数据库"方向做能力扩展的信号,和 Pinecone/Milvus 这类专业向量数据库形成一定的竞争/互补关系。这块内容较新,建议结合最新官方文档验证细节。

结语:面试回答的通用框架

回顾整篇文章会发现一个共性套路,建议你在真实面试里套用这个框架组织语言,会比单纯背知识点显得更有工程思维:

  1. 这个技术点解决了什么问题(不要一上来讲怎么用,先讲为什么需要它)。
  2. 朴素方案的问题在哪(比如手写 SETNX 锁的种种坑)。
  3. 成熟方案怎么解决的,原理是什么(Redisson 看门狗、Canal 订阅 binlog)。
  4. 这个方案自己还有没有局限/争议(RedLock 的争议、Stream 相比 Kafka 的局限)。
  5. 实际项目里怎么权衡选择的(不同场景选不同淘汰策略/一致性方案)。

这个"问题→朴素方案→成熟方案→局限→权衡"的结构,本身就是资深工程师和初级工程师回答问题时最大的区别。

posted @ 2026-07-14 14:16  zhangph  阅读(33)  评论(0)    收藏  举报