Redis 常见面试题
Redis 常见面试题
大家可以参考以下知识点讲解,对其二次精炼,换成自己的语言风格,保证自己在面试的时候可以答出。
1. 有 MySQL 不就够了吗?为什么还要使用 Redis?
这个题目可以改写成,redis 是用来解决什么问题的?不是已经有 mysql 存储数据了吗?为什么还需要 redis ?
MySQL 是一个关系型数据库,它在处理复杂的事务和大量的关联查询时表现出色但是,它的性能在高并发读写场景下会受到一定的限制。
例如,当有大量的短时间内的读写请求时,MySQL 需要处理复杂的磁盘 I/O 操作(因为数据存储在磁盘中)和事务逻辑,这可能导致响应时间变长。
Redis 是基于内存的存储系统,数据读写操作主要在内存中进行。
这使得它的读写速度极快,能够轻松应对高并发的读写请求。
例如,对于一个热门网站的计数器功能(如统计文章的阅读次数),如果使用 MySQL,每次计数的更新可能需要进行数据库事务操作,包括磁盘 I/O,速度会比较慢;而使用 Redis,由于其基于内存的特性,能够几乎实时地更新计数,大大提高了系统的响应速度。
数据结构丰富度差异
MySQL 数据结构:MySQL 主要的数据结构是基于关系模型的表,通过行和列来存储数据。
虽然可以通过复杂的查询和关联操作来处理数据,但在某些非关系型数据的存储和操作场景下显得不够灵活。
例如,要存储一个用户的偏好列表(如喜欢的音乐类型、电影类型等),在 MySQL 中可能需要创建一个专门的表,通过用户 ID 进行关联,并且在查询和更新操作时需要进行复杂的 SQL 操作。
Redis 数据结构:Redis 支持多种数据结构,如字符串、列表、集合、哈希和有序集合。
这种丰富的数据结构使得它在很多场景下更具优势。
对于上述用户偏好列表的例子,在 Redis 中可以使用列表或者集合来存储用户的偏好,操作起来更加简单直接。
例如,使用 Redis 的列表结构,可以很方便地通过LPUSH(从左边添加元素)和LRANGE(获取列表范围内的元素)等命令来更新和查询用户偏好列表。
应用场景差异
缓存场景:Redis 非常适合用于缓存。在一个多层架构的应用系统中,如 Web 应用,将频繁访问的数据存储在 Redis 缓存中,可以大大减少对后端 MySQL 数据库的访问压力。
例如,一个电商网站的商品详情页面,商品的基本信息(如名称、价格、图片等)可以存储在 Redis 缓存中。
当用户访问商品详情页时,首先从 Redis 中获取信息,如果不存在再从 MySQL 中获取,并将其存入 Redis 缓存中,供后续用户访问。这样可以显著提高页面加载速度。
实时数据处理场景:Redis 能够快速处理实时数据,如消息队列。
在一个分布式系统中,消息的传递和处理需要高效快速。Redis 可以作为消息队列来使用,它的列表数据结构可以很方便地实现消息的入队和出队操作。
相比之下,MySQL 在这种实时数据处理场景下,由于其磁盘 I/O 和事务处理的复杂性,效率会比较低。
排行榜场景:对于排行榜等功能,Redis 的有序集合数据结构非常适合。
例如,游戏中的玩家排行榜,通过 Redis 的有序集合可以轻松地根据玩家的积分等指标来更新和查询排行榜。而在 MySQL 中实现类似的排行榜功能,需要复杂的查询和排序操作,性能可能不如 Redis。
2. Redis 和 Memcached 有什么区别?
先说 Redis 和 Memcached 是什么
Redis 和 Memcached 都是基于内存的数据存储系统,主要用来解决数据库访问延迟较高、热点数据查询压力过大的问题。
两者的核心定位有所不同:
- Memcached 更专注于简单的分布式内存缓存;
- Redis 不仅可以作为缓存,还提供丰富的数据结构、持久化、消息处理、原子操作和高可用能力。
核心区别(简单了解就好,Redis 特点要充分了解)
| 对比维度 | Redis | Memcached |
|---|---|---|
| 产品定位 | 内存数据结构服务器,可用于缓存、消息、计数和临时数据存储 | 以简单 Key-Value 缓存为核心 |
| 数据结构 | 支持 String、Hash、List、Set、Sorted Set、Stream、Bitmap、HyperLogLog、GEO 等 | 服务端主要将 Value 作为不透明的字节数据保存 |
| 线程模型 | 核心命令主要由主线程串行执行,可使用 I/O 线程处理部分网络读写 | 使用多个工作线程处理连接和请求,可以利用多核 CPU |
| 持久化 | 支持 RDB 和 AOF | 核心定位是临时缓存,通常不提供 Redis 式持久化 |
| 高可用 | 支持主从复制、Sentinel 和 Redis Cluster | 传统部署通常由客户端或代理完成分片,不提供与 Redis Sentinel、Cluster 对等的高可用体系 |
| 分布式能力 | Redis Cluster 使用哈希槽分片并支持故障转移 | 通常由客户端或代理通过一致性哈希选择节点 |
| 原子操作 | 支持丰富的原子命令、事务、Lua 脚本和 Functions | 支持 add、incr、decr、CAS 等基础原子操作 |
| 淘汰策略 | 支持 LRU、LFU、随机淘汰、TTL 优先、noeviction 等 | 默认采用基于 LRU 的缓存淘汰机制 |
| 消息能力 | 支持 Pub/Sub、List 队列和 Stream | 不提供 Redis Stream、Pub/Sub 等同级消息能力 |
| 单值大小 | Redis String Value 最大为 512 MB | 单个 Item 默认最大为 1 MB,但可以调整 |
| 适用场景 | 缓存、排行榜、计数器、会话、限流、分布式锁、消息流等 | 主要用于保存简单序列化对象和页面缓存 |
数据结构不同
Redis 是数据结构服务器,不只是简单保存字节数据。
它支持:
- String;
- Hash;
- List;
- Set;
- Sorted Set;
- Stream;
- Bitmap;
- HyperLogLog;
- GEO。
例如,可以直接使用 Sorted Set 实现排行榜:
ZADD game:ranking 100 player:1001
ZREVRANK game:ranking player:1001
使用 Hash 保存对象字段:
HSET user:1001 name "程序厨" age 18
HGET user:1001 name
Memcached
Memcached 服务端主要将 Value 看作不透明的原始数据,不理解对象内部结构。Java 对象、JSON 或其他数据通常需要由客户端先序列化,再完整写入 Memcached。
对象 → 客户端序列化 → Memcached
Memcached 支持 incr、decr 等简单操作,但不能像 Redis 一样直接操作 Hash 字段、集合成员或排行榜分数。
因此,不能简单表述为“Memcached 只支持字符串和整数”。更准确的说法是:
Memcached 保存的是任意字节数据,只是服务端不理解复杂数据结构;部分命令可以把符合要求的数值内容当作计数器处理。
线程模型不同
Redis
Redis 的核心命令主要由主线程串行执行,具有以下特点:
- 避免大量锁竞争;
- 单条命令天然具有原子性;
- 执行模型简单;
- 慢命令可能阻塞其他客户端请求。
Redis 6.0 之后可以使用 I/O 线程分担部分网络读写,但核心命令仍主要由主线程串行执行。
Memcached
Memcached 使用多个工作线程处理客户端连接和协议请求,每个工作线程运行事件循环,并共享缓存数据。
这种设计能够利用多核 CPU,但访问共享数据时需要进行必要的并发控制。
因此,Memcached 在简单 Key-Value、高并发、多核环境中可能具有很好的吞吐量;Redis 则更擅长提供丰富的数据操作能力。
性能不能简单判断谁更快
Memcached 的读写速度一定慢于 Redis是不准确的。
两者的性能取决于:
- 数据大小;
- 命令类型;
- 客户端并发数;
- CPU 核数;
- 网络延迟;
- 是否使用 Pipeline 或批量查询;
- 序列化和反序列化开销;
- 是否开启持久化;
- Redis 是否执行复杂命令。
在只进行简单的 GET/SET、数据量较小,并且需要充分利用多核 CPU 的场景中,Memcached 可能表现得非常好。
Redis 在需要集合运算、排行榜、原子计数和 Lua 脚本等场景中,能够直接在服务端完成操作,避免客户端多次请求,因此整体业务性能可能更好。
正确结论是:
不能脱离业务场景判断 Redis 和 Memcached 谁一定更快,应该基于真实数据和访问模型进行压测。
持久化能力不同
Redis 支持:
- RDB 快照;
- AOF 日志;
- RDB 与 AOF同时开启;
- 重启后恢复数据。
Memcached 的核心定位是缓存,通常假设数据丢失后可以从数据库重新加载。
Memcached 的较新版本支持特定条件下的 Warm Restart,但它主要用于受控、正常重启时恢复缓存,并不等价于 Redis 的 AOF、RDB,也不是通用的崩溃安全持久化机制。
因此,需要重启恢复或一定数据持久性时,Redis 更合适;数据随时可以丢弃并重新生成时,Memcached 的简单模型可能更加合适。
高可用和分布式方式不同
Redis
Redis 提供相对完整的服务端高可用方案:
- 主从复制;
- Sentinel 自动故障转移;
- Redis Cluster 数据分片;
- 哈希槽迁移;
- 主节点故障后提升从节点。
Memcached
Memcached 的传统分布式方式主要由客户端完成:
- 客户端维护 Memcached 节点列表;
- 根据 Key 计算哈希值;
- 选择对应服务器;
- 向该服务器读写缓存。
较新的 Memcached 还提供代理及相关路由能力,但其核心设计仍偏向可丢失的分布式缓存,不等同于 Redis 的原生复制、持久化和自动故障转移体系。
原子操作能力不同
Redis 支持:
INCR、DECR;HINCRBY;SET NX PX;MULTI/EXEC;WATCH;- Lua 脚本;
- Functions。
因此,可以实现计数器、条件更新、分布式锁和多步原子操作。
Memcached 支持:
add;replace;incr、decr;- CAS。
CAS 可以在 Value 没有被其他客户端修改时完成更新,用来解决简单的并发覆盖问题。
但 Memcached 不支持 Redis 这种通用事务、服务端 Lua 脚本和丰富数据结构的组合操作。
淘汰策略不同
Redis 支持多种可配置的淘汰策略,例如:
allkeys-lru
allkeys-lfu
volatile-lru
volatile-lfu
volatile-ttl
allkeys-random
noeviction
Memcached 默认使用基于 LRU 的缓存淘汰机制,内存不足时会从相应的内存分配区域中回收过期或较旧的数据。
Redis 的策略选择更加丰富,可以根据热点访问、TTL 和数据重要性进行配置。
Value 大小限制不同
Redis
Redis String 类型的单个 Value 最大为 512 MB:
SET key value
Redis Key 本身的最大允许长度也是 512 MB。
需要注意,512 MB 是协议允许的上限,不代表适合在生产环境中保存几百 MB 的单个 Key。大 Value 会增加网络、阻塞、持久化、复制和集群迁移开销。
另外,Redis 的集合类型可以包含多个元素,不能把 String 的 512 MB 限制简单套用到所有数据结构的整个集合上。
Memcached
Memcached 单个 Item 的默认最大值通常是 1 MB,但可以通过 -I 参数调整:
memcached -I 8m
当前 Memcached 文档中的配置范围最高可以达到 1 GB,但具体应以所使用版本为准。
因此,“Memcached 最大只能存 1 MB”不准确。正确说法是:
Memcached 单个 Item 默认上限通常为 1 MB,可以通过启动参数调整,但不建议保存过大的缓存对象。
Memcached 基础协议中的 Key 长度上限通常为 250 字节。
内存管理不同
Memcached 使用 Slab Allocation,将内存划分成不同大小的 Slab Class,再从相应区域中分配固定大小的 Chunk。
优点是内存分配速度快,并能减少传统内存分配产生的外部碎片;缺点是不同大小数据分布不合理时,可能造成 Slab 内部空间浪费或某个 Slab Class 提前发生淘汰。
Redis 通常配合内存分配器,并针对小型 Hash、Set、Sorted Set 等数据使用紧凑编码,从而在数据结构能力和内存使用之间做平衡。
因此,两者的内存利用率也需要根据 Value 大小分布和数据结构进行实际测试。
如何选择?
适合选择 Memcached 的场景
- 只需要简单的 Key-Value 缓存;
- Value 已经由客户端完成序列化;
- 数据丢失后可以从数据库重新加载;
- 不需要持久化、排行榜、消息流等能力;
- 希望通过多线程充分利用多核 CPU;
- 希望缓存系统尽量简单。
适合选择 Redis 的场景
- 需要缓存之外的数据结构能力;
- 需要排行榜、计数器、集合运算;
- 需要分布式锁或限流;
- 需要 Pub/Sub、List 队列或 Stream;
- 需要 RDB、AOF持久化;
- 需要主从复制、Sentinel 或 Cluster;
- 需要 Lua 脚本和复杂原子操作。
面试总结
Redis 和 Memcached 都可以作为高性能内存缓存,但定位不同。
Memcached 更专注于简单的 Key-Value 缓存,服务端将 Value 视为不透明数据,采用多工作线程,分片通常由客户端或代理完成,适合数据可以随时重新加载的纯缓存场景。
Redis 支持丰富的数据结构、持久化、复制、高可用、消息和服务端原子操作,适合缓存、排行榜、计数器、限流、分布式锁和消息流等复杂场景。
不能笼统地说 Memcached 一定比 Redis 慢。Memcached 单个 Item 默认上限通常是 1 MB,但可以配置;Redis String Value 的上限是 512 MB,但两者都不适合保存特别大的单个缓存对象。
参考:Redis Data Types、Redis Strings、Memcached Documentation、Memcached Configuration、Memcached Manual
3. Redis 为啥那么快?
Redis 的“快”指什么?
Redis 的“快”主要是指,在数据量和命令复杂度合理、没有发生 Swap、网络延迟较低的情况下,Redis 可以提供很低的单次请求延迟和很高的吞吐量。
它主要用来解决数据库在高并发场景下访问延迟较高、热点数据读取压力过大的问题。
Redis 速度快并不是由单一原因决定的,而是内存存储、执行模型、网络模型、数据结构和通信协议共同作用的结果。
1. 数据主要存储在内存中
Redis 的数据主要保存在内存中,大多数命令可以直接访问内存数据,不需要把随机磁盘 I/O 作为常规请求路径的一部分。
客户端请求
↓
内存中定位数据
↓
执行命令
↓
返回结果
内存的访问延迟远低于磁盘,因此 Redis 非常适合保存热点数据。
Redis 虽然支持 RDB 和 AOF 持久化,但持久化工作通常会尽量与正常命令处理分离。具体性能仍然会受到 AOF 刷盘策略、RDB、AOF 重写和磁盘性能的影响。
需要注意,MySQL、PostgreSQL 等数据库也会使用 Buffer Pool 或操作系统页缓存,并不是每次查询都一定读取磁盘。Redis 的主要优势是数据模型和执行路径从设计上就以内存访问为主。
2. 核心命令主要由单线程串行执行
Redis 的核心命令执行通常由主线程串行完成,同一时刻不会有多个线程并发修改同一份数据。
这样设计主要有以下优势:
- 避免多线程之间频繁加锁和释放锁;
- 减少锁竞争;
- 减少线程上下文切换;
- 降低并发控制的实现复杂度;
- 单条命令天然具有原子性;
- 请求执行时间更容易预测。
但 Redis 并不是所有工作都只使用一个线程:
- Redis 6.0 开始可以使用 I/O 线程处理部分网络数据读写;
- 异步释放内存可以由后台线程完成;
- RDB 快照和 AOF 重写通常由子进程完成;
- 核心命令执行仍主要由主线程串行完成。
因此,更准确的说法是:
Redis 的核心命令执行模型主要是单线程,但整个 Redis 进程并不是完全单线程。
单线程本身并不一定意味着速度快。Redis 能够采用这种模型,是因为大部分命令操作内存数据,执行时间很短。
3. 使用非阻塞 I/O 和 I/O 多路复用
Redis 会将客户端连接设置为非阻塞模式,并使用 I/O 多路复用机制同时监听大量 Socket。
不同操作系统下可能使用:
- Linux:
epoll; - BSD、macOS:
kqueue; - 其他环境:
select等机制。
Redis 不需要为每个客户端连接创建一个线程,而是通过事件循环统一处理连接、可读和可写事件:
多个客户端连接
↓
I/O 多路复用器
↓
事件循环
↓
读取请求 → 执行命令 → 返回响应
只有当某个 Socket 上出现可处理事件时,Redis 才处理该连接,因此能够用较少的线程管理大量客户端连接。
4. 使用高效的数据结构和算法
Redis 并不是简单地把所有数据都保存成字符串,而是针对不同场景设计了不同的数据结构和编码方式。
String
Redis 使用 SDS 保存字符串,具有以下特点:
- 可以在
O(1)时间获取字符串长度; - 使用长度字段界定数据范围,二进制安全;
- 通过空间预分配等方式减少频繁扩容;
- 可以根据内容选择更紧凑的对象编码。
Hash Table
Redis 字典使用哈希表实现,查询、插入和删除的平均时间复杂度接近 O(1)。
发生哈希冲突时使用链地址法解决;负载因子升高时通过渐进式 Rehash 扩容,避免一次性迁移大量数据长时间阻塞主线程。
List
Redis 的 List 使用适合两端插入、删除和紧凑存储的内部结构,能够高效执行:
LPUSH
RPUSH
LPOP
RPOP
Sorted Set
有序集合在元素较多时通常结合字典和跳表实现:
- 字典用于根据成员快速查找分数;
- 跳表用于按照分数排序和执行范围查询。
常见操作复杂度为 O(log N),适合排行榜等场景。
紧凑编码
对于元素较少、数据较小的集合,Redis 会采用更加紧凑的内部编码,减少指针和对象带来的额外内存占用。
紧凑的数据布局不仅节省内存,也有利于提高 CPU 缓存命中率。
5. RESP 协议简单且解析效率高
Redis 客户端与服务端之间使用 RESP,也就是 Redis Serialization Protocol。
RESP 的特点包括:
- 数据格式简单;
- 解析速度较快;
- 使用长度前缀表示批量数据;
- 支持整数、字符串、数组、错误等类型;
- 二进制安全;
- 支持 Pipeline。
RESP 并不是“文本协议之外再额外支持一种二进制协议”。更准确地说,RESP 的部分控制信息具有较强的可读性,但它本身是二进制安全的,可以传输任意二进制数据。
例如,正常客户端发送的 GET key 会被编码成由命令和参数组成的 RESP 数组,而不是必须依赖复杂的 SQL 语法分析、执行计划生成和优化过程。
6. Redis 命令通常比较简单
Redis 的很多常用命令复杂度是 O(1) 或 O(log N),例如:
GET:平均 O(1)
SET:平均 O(1)
INCR:O(1)
HGET:平均 O(1)
ZADD:O(log N)
ZREVRANK:O(log N)
Redis 通常直接操作指定的数据结构,不需要像关系型数据库一样完成 SQL 解析、权限分析、执行计划选择、多表连接和复杂事务协调,因此单条命令的执行路径相对较短。
但并不是所有 Redis 命令都很快。对大集合执行全量查询、删除大 Key 或运行耗时 Lua 脚本,都可能长时间占用主线程。
7. Pipeline 可以减少网络往返和系统调用
即使 Redis 服务端处理命令很快,客户端与 Redis 之间仍然存在网络往返时间,也就是 RTT。
普通请求模式:
发送命令1 → 等待响应1
发送命令2 → 等待响应2
发送命令3 → 等待响应3
使用 Pipeline 后:
一次发送命令1、命令2、命令3
一次读取多个响应
Pipeline 可以:
- 减少网络往返次数;
- 减少
read()、write()等系统调用次数; - 提高单连接吞吐量;
- 让服务端能够连续处理多个命令。
Pipeline 主要提升吞吐量,不保证其中的多条命令具有事务原子性。批次也不能无限增大,否则客户端和 Redis 都需要占用更多缓冲区内存。
Redis 单线程为什么还能支持高并发?
Redis 的高并发处理过程可以简化为:
内存访问速度快
+
单条命令执行时间短
+
非阻塞 I/O
+
I/O 多路复用
+
高效数据结构
+
简单协议和 Pipeline
↓
主线程可以快速处理大量请求
因此,Redis 并不是同时并行执行大量命令,而是每条命令执行得足够快,通过事件循环快速切换不同客户端的请求。
Redis 在什么情况下会变慢?
Redis 快是有前提的,以下情况可能造成明显延迟:
- 执行
KEYS *等需要扫描大量数据的命令; - 对大集合执行全量查询;
- 删除、过期或淘汰大 Key;
- 执行耗时 Lua 脚本;
- 使用过大的 Pipeline;
- 客户端输出缓冲区积压;
- 内存不足并发生 Swap;
- RDB 或 AOF 重写产生较高的写时复制开销;
- 网络距离较远,RTT 较高;
- 出现热点 Key,单个节点负载过高。
由于核心命令主要由主线程串行执行,一个慢命令可能阻塞后面的其他请求。因此,使用 Redis 时应避免慢命令、大 Key 和无边界增长的数据结构。
面试总结
Redis 速度快主要有以下几个原因:
- 数据主要保存在内存中,常规访问不依赖随机磁盘 I/O;
- 核心命令主要由单线程串行执行,减少锁竞争和上下文切换;
- 使用非阻塞 I/O、I/O 多路复用和事件循环处理大量连接;
- 使用哈希表、跳表、SDS 和紧凑编码等高效数据结构;
- 大量常用命令的复杂度是
O(1)或O(log N); - RESP 协议结构简单、解析高效并且二进制安全;
- 支持 Pipeline、批量命令和 Lua 脚本,减少网络往返;
- Redis 6.0 之后还可以使用 I/O 线程分担部分网络读写压力。
其中最核心的原因是:以内存访问为主、单条命令执行路径短,再配合非阻塞网络模型和高效数据结构。单线程只是减少锁竞争并简化执行模型,并不是 Redis 高性能的唯一原因。
参考:Redis Latency Diagnosis、Redis Pipelining、RESP Specification、Redis Benchmark
4. Redis 是如何扩容的?
Redis 的“扩容”有两个不同层面的含义:
- 底层哈希表扩容:通过渐进式 Rehash 增加桶数量,降低哈希冲突;
- Redis 服务扩容:通过增加实例内存或增加集群节点,提高数据容量和吞吐量。
Rehash 只是在 Redis 实例内部调整哈希表结构,不能增加服务器的物理内存,也不能替代集群扩容。
一、底层哈希表如何扩容?
什么是哈希表扩容?
Redis 的全局键空间以及部分数据结构底层会使用哈希表。随着元素不断增加,负载因子升高,多个 Key 被映射到同一个桶的概率也会增加,冲突链会变长。
哈希表扩容就是增加桶的数量,并将旧哈希表中的数据重新映射到新哈希表中。
它主要解决以下问题:
- 降低哈希冲突概率;
- 避免冲突链过长;
- 保持查询、插入和删除操作平均接近
O(1); - 在查询性能和内存占用之间取得平衡。
Redis 如何解决哈希冲突?
Redis 使用链地址法解决哈希冲突。同一个桶中的多个键值对通过链表连接:
bucket[3]
↓
key3 → key2 → key1 → null
链地址法能够保证冲突后数据仍然可以正确保存,但如果冲突链越来越长,查询效率就会下降。因此,Redis 还需要通过 Rehash 扩大哈希表。
什么是 Rehash?
Redis 的字典内部会维护两张哈希表,可以简化理解为:
ht[0]:当前使用的旧哈希表
ht[1]:扩容时创建的新哈希表
rehashidx:当前迁移到的桶位置
正常情况下,Redis 只使用 ht[0],ht[1] 为空。当哈希表需要扩容时,Redis 会创建 ht[1],并逐步将 ht[0] 中的数据迁移到 ht[1]。
Rehash 的基本过程
1. 创建新哈希表
Redis 为 ht[1] 分配更大的桶数组。
新哈希表的大小通常会选择一个能够容纳当前数据量的 2 的幂。在常见的扩容场景下,表现为容量扩大到原来的两倍左右,但不能将“永远严格扩大两倍”理解为对所有版本和场景都成立的固定规则。
2. 重新计算桶位置
由于哈希表大小发生变化,即使 Key 的哈希值没有变化,它在新表中的桶位置也可能变化。
旧桶下标 = hash & old_sizemask
新桶下标 = hash & new_sizemask
Redis 需要将旧表中的节点重新映射到新表对应的桶中。
这个过程主要是迁移已有节点并调整链表关系,并不是把业务 Value 完整复制一份后再删除。
3. 逐步迁移数据
Redis 通过 rehashidx 记录当前迁移进度,每次迁移旧哈希表中的一个或多个桶。
ht[0] ht[1]
bucket[0] ──迁移────────────→ bucket[x]
bucket[1] ──迁移────────────→ bucket[y]
bucket[2] ← rehashidx
...
一个桶中如果存在冲突链,该桶中的所有节点都会被迁移到新表。
4. 释放旧哈希表
当 ht[0] 中的所有数据都迁移完成后:
- 释放旧表的桶数组;
- 将
ht[1]设置为新的ht[0]; - 清空
ht[1]; - 将
rehashidx重置,表示 Rehash 完成。
为什么采用渐进式 Rehash?
如果哈希表中有几百万个元素,一次性迁移所有数据会长时间占用 Redis 主线程,导致客户端请求阻塞。
因此,Redis 不会一次完成全部迁移,而是把迁移工作分散到多次操作中,这就是渐进式 Rehash。
渐进式 Rehash 的推进方式主要有两种:
- 执行查询、插入、修改或删除等字典操作时,顺带迁移少量桶;
- Redis 的定时任务会分配少量时间主动推进迁移,避免实例空闲时 Rehash 一直无法完成。
因此,不能简单理解为“访问到哪个 Key,才迁移哪个 Key”。Redis 是按照 rehashidx 记录的桶位置逐步迁移。
渐进式 Rehash 仍然由 Redis 主线程执行,并不是交给后台线程完成。
Rehash 期间如何读写?
Rehash 期间,新旧两张哈希表会同时存在。
新增数据
新增的键值对直接写入 ht[1]:
新数据 → ht[1]
这样可以保证 ht[0] 中的数据只减少、不增加,最终能够完成迁移。
查询数据
Redis 会根据迁移进度查找相关哈希表。概念上可以理解为先查旧表,没有找到时再查新表;已经迁移完成的桶可以跳过旧表。
查询 Key
↓
查找 ht[0]
↓ 未找到
查找 ht[1]
修改和删除数据
Redis 会在新旧两张表中定位目标 Key,然后执行修改或删除操作。
什么情况下触发扩容?
负载因子的计算方式为:
负载因子 = 元素数量 / 桶数量
正常允许调整哈希表大小时,当元素数量达到桶数量,即负载因子达到 1 左右,Redis 会考虑扩容。
执行 RDB 快照或 AOF 重写时,Redis 为了减少写时复制带来的额外内存开销,可能暂缓普通扩容,容忍更高的负载因子;只有负载因子达到强制扩容阈值时才继续扩容。
常见面试资料中的经典数值是:
- 正常情况下,负载因子达到
1左右触发扩容; - Redis 7.2 等经典版本存在后台子进程时,负载因子大于
5才强制扩容。
具体常量属于内部实现细节,不同 Redis 版本可能调整,不应把固定数值当成所有版本都不变的协议。
渐进式 Rehash 的优缺点
优点:
- 避免一次性迁移大量数据;
- 减少主线程长时间阻塞;
- 降低单次请求的延迟;
- 保持服务在扩容期间继续处理请求。
缺点:
- Rehash 期间需要同时维护两张哈希表;
- 会暂时增加内存占用;
- 查询和删除需要处理两张表;
- 每次字典操作可能附带少量迁移开销。
二、Redis 服务如何扩容?
如果 Redis 实例的内存或处理能力不足,仅靠 Rehash 无法解决,需要进行实例级或集群级扩容。
1. 垂直扩容
垂直扩容是增加单个 Redis 实例的资源,例如:
- 增加内存;
- 增加 CPU;
- 提高网络带宽;
- 升级云 Redis 实例规格。
优点是改造相对简单,应用通常不需要改变数据访问方式。
缺点是单机资源存在上限,实例越大,故障恢复、持久化和主从同步的时间通常也越长。
2. 使用 Redis Cluster 水平扩容
当单个实例无法容纳全部数据时,可以使用 Redis Cluster 将数据分散到多个主节点。
Redis Cluster 将键空间划分为 16384 个哈希槽:
slot = CRC16(key) % 16384
每个主节点负责其中一部分哈希槽:
Master A:0~5460
Master B:5461~10922
Master C:10923~16383
扩容时通常需要:
- 向集群添加新的主节点;
- 将部分哈希槽从旧节点迁移到新节点;
- 将属于这些槽的 Key 一起迁移;
- 更新集群的槽位与节点映射;
- 客户端根据
MOVED、ASK等响应刷新路由信息。
可以使用集群管理工具执行槽位迁移或再平衡。Redis 开源版添加节点后,并不意味着已有槽位一定会自动均衡,需要执行相应的 Reshard 或 Rebalance 操作。
3. 增加从节点是否等于扩容?
增加从节点主要用于:
- 提高可用性;
- 故障转移;
- 分担读请求;
- 保存主节点的数据副本。
从节点保存的是主节点数据的副本,不会分担主节点所负责的数据总量,因此增加从节点通常不等于增加集群的写入容量或可存储数据量。
要提升数据容量,一般需要增加主节点并重新分配哈希槽。
4. 热点 Key 不能只靠增加节点解决
如果大量请求集中访问同一个 Key,那么这个 Key 只会落在一个哈希槽、由一个主节点负责。
即使增加很多集群节点,该热点 Key 的请求仍然集中在同一个节点上。
这时需要结合以下方式处理:
- 拆分热点 Key;
- 使用本地缓存;
- 增加只读副本分担读流量;
- 对数据进行分桶;
- 在业务层聚合结果。
面试总结
如果题目问的是 Redis 底层哈希表扩容,答案是:
Redis 使用链地址法解决哈希冲突。当负载因子升高时,通过 Rehash 增加哈希表桶数量。Redis 创建新的哈希表 ht[1],然后利用 rehashidx 按桶渐进式地将 ht[0] 中的数据迁移过去。Rehash 期间新增数据写入新表,查询和删除需要兼顾两张表。迁移完成后释放旧表,并将新表设置为当前哈希表。
如果题目问的是 Redis 服务容量扩展,答案是:
可以通过增加单机资源进行垂直扩容,也可以使用 Redis Cluster 增加主节点,并将部分哈希槽和数据迁移到新节点,实现水平扩容。增加从节点主要提高可用性和读能力,并不能直接增加数据分片容量。
参考:Redis 哈希表源码、Redis 7.2 哈希表源码、Redis Cluster 扩容
5. Redis 如何实现事务?
事务的生命周期:
开启事务(Redis 中使用 MUlTI 来实现)
执行事务中的操作:增删改查等操作,Redis 会将这些操作保存在一个队列中,暂且先不执行
提交事务 EXEC 提交事务
Redis 的事务能保证哪些特性?
原子性
命令入队时就报错,会放弃事务执行,保证原子性;命令入队时没报错,
实际执行时报错,不保证原子性;
EXEC 命令执行时实例故障,如果开启了 AOF 日志,可以保证原子性。
一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等,Redis 事务满足一致性
隔离性:如果开启了WATCH 机制则没有被破坏,因为其会监控键值对的改变。保证一致性
持久性:不能保证持久性
6. Redis 的常见使用场景有哪些?
Redis 适合解决什么问题?
Redis 是一个基于内存、支持多种数据结构的数据存储系统,主要用于解决传统数据库在高并发场景下访问延迟较高、吞吐量有限,以及多个应用节点之间需要共享临时状态等问题。
Redis 的主要优势包括:
- 数据保存在内存中,读写延迟较低;
- 支持 String、Hash、List、Set、Sorted Set、Stream 等数据结构;
- 单条命令具有原子性;
- 支持过期时间、持久化、主从复制和集群;
- 适合处理缓存、计数、排行、会话和实时数据。
1. 缓存
缓存是 Redis 最常见的使用场景,用来减少数据库访问次数,降低数据库压力并提高接口响应速度。
例如,电商系统可以将商品详情缓存到 Redis:
请求商品详情
↓
查询 Redis
↓ 命中
直接返回
Redis 未命中
↓
查询数据库
↓
写入 Redis
↓
返回结果
这种模式称为 Cache Aside。
SET product:1001 '{"name":"手机","price":3999}' EX 1800
适合缓存的数据包括:
- 商品详情;
- 用户信息;
- 配置信息;
- 热点文章;
- 查询结果。
需要注意缓存与数据库的一致性,并防范缓存穿透、缓存击穿和缓存雪崩。重要业务数据不能只保存在缓存中,数据库通常仍然是最终数据源。
2. 排行榜
排行榜需要按照分数排序,并快速查询指定用户的排名。Redis 的 Sorted Set 会同时保存成员和分数,非常适合实现排行榜。
例如,更新玩家积分:
ZINCRBY game:ranking 100 player:1001
查询前十名:
ZREVRANGE game:ranking 0 9 WITHSCORES
查询指定玩家排名:
ZREVRANK game:ranking player:1001
常见场景包括:
- 游戏积分榜;
- 礼物排行榜;
- 商品销量榜;
- 文章热度榜;
- 用户活跃度排名。
Sorted Set 的插入和更新通常为 O(log N),适合频繁更新并按分数查询排名,但应控制排行榜的元素数量,避免形成大 Key。
3. 计数器
计数器用于解决多个请求并发修改同一个数值时的数据竞争问题。
Redis 提供原子自增和自减命令:
INCR article:1001:view_count
DECR product:1001:stock
HINCRBY user:1001:stats login_count 1
常见场景包括:
- 文章阅读量;
- 视频播放量;
- 点赞数量;
- 接口调用次数;
- 在线人数;
- 商品剩余库存。
INCR 等单条命令具有原子性,不会出现两个请求同时读取旧值并覆盖更新的问题。
但是,如果客户端因为超时重试,同一个业务请求可能被执行多次。因此,订单扣库存、支付次数等需要严格准确的业务,还必须使用业务请求号实现幂等,不能只依赖 INCR 或 DECR。
4. 消息队列与事件流
Redis 可以实现异步处理,用来解决生产者和消费者处理速度不一致,以及业务流程耦合的问题。
List:简单任务队列
可以使用 List 实现简单队列:
LPUSH sms:tasks '{"mobile":"138xxxx","content":"下单成功"}'
BRPOP sms:tasks 0
生产者将任务写入 List,消费者通过阻塞命令获取任务。
List 实现简单、性能较高,但确认、重试、消息追踪和多消费组等功能需要业务自己补充。
Stream:需要确认和重试的队列
对于要求消费组、消息确认、待处理记录和失败重试的场景,更适合使用 Stream:
XADD order:events * order_id 1001 status created
XREADGROUP GROUP order_group consumer_1 STREAMS order:events >
XACK order:events order_group 1710000000000-0
Stream 支持:
- 消息持久保留;
- 消费者组;
- 消息确认;
- Pending 列表;
- 故障消费者的消息转移;
- 消息重放和范围查询。
但 Redis 复制默认仍然是异步的,故障切换时理论上可能丢失尚未复制的消息。如果是支付、交易等不能丢失的重要消息,通常应使用专业消息队列或配合 AOF、复制确认、幂等和数据库事务消息。
Pub/Sub:实时广播
如果只需要实时广播,可以使用 Pub/Sub,例如聊天室消息、配置变更通知。
Pub/Sub 不保存历史消息,消费者离线期间的消息无法重新消费,因此不适合要求可靠投递的任务队列。
5. 分布式锁
分布式锁用于解决多个服务实例同时修改同一份共享资源时的并发冲突问题。
例如,多个应用节点同时更新同一订单时,可以通过 Redis 争抢锁:
SET lock:order:1001 unique_token NX PX 30000
其中:
NX:只有锁不存在时才能创建成功;PX 30000:锁在30秒后自动过期,避免客户端崩溃后形成死锁;unique_token:每次加锁生成唯一标识,防止误删其他客户端持有的锁。
释放锁时,必须判断锁中的唯一标识是否属于当前客户端,然后再删除。这个过程需要原子执行,可以使用 Lua 脚本:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
不能只使用 SETNX,因为它不能原子地同时完成“加锁和设置过期时间”;也不能直接执行 DEL 解锁,否则可能删除其他客户端后来获得的锁。
对于执行时间可能超过锁有效期的任务,还需要考虑锁续期、业务幂等和 fencing token。对资金、库存等强一致场景,不能仅依赖一个普通 Redis 锁保证绝对正确。
6. 实时数据处理
Redis 适合接收和处理需要低延迟访问的实时数据,例如:
- 传感器数据;
- 用户点击流;
- 实时交易指标;
- 在线状态;
- 实时监控数据。
例如,物联网设备可以将温度数据写入 Stream:
XADD sensor:temperature * device_id 1001 temperature 26.5
如果需要计算平均温度,可以维护温度总和和采样次数,并使用 Lua 脚本保证两个字段同时更新:
HINCRBYFLOAT temperature:stats total 26.5
HINCRBY temperature:stats count 1
平均温度 = total / count
也可以使用 Stream 保存原始事件,使用 Sorted Set 实现时间窗口,或者使用支持时间序列能力的相关产品组件。
Redis 适合实时聚合和近期数据查询,但大量长期历史数据通常应落入数据库、数据仓库或时序数据库。
7. 分布式会话
在分布式系统中,请求可能被负载均衡到不同应用节点。如果会话只保存在某一台应用服务器中,其他节点无法获取。
可以将登录会话统一保存到 Redis:
SET session:token_abc '{"user_id":1001}' EX 1800
这样所有应用节点都可以读取同一份会话数据。
常见场景包括:
- 用户登录状态;
- 短信验证码;
- 临时授权信息;
- OAuth Token;
- 购物车数据。
会话数据应设置合理的 TTL,并评估 Key 被淘汰、Redis 故障和主从切换带来的影响。
8. 接口限流
Redis 可以通过计数器、Lua 脚本或令牌桶算法实现分布式限流,用来防止接口被突发流量或恶意请求压垮。
简单的固定窗口限流示例:
INCR rate_limit:user:1001:202609231200
EXPIRE rate_limit:user:1001:202609231200 60
实际使用时,INCR 和设置过期时间应通过 Lua 脚本等方式原子执行,避免计数器没有成功设置 TTL。
常见限流维度包括:
- 用户 ID;
- IP 地址;
- API 接口;
- 租户;
- 短信发送次数。
固定窗口存在边界突发问题,要求更平滑时可以使用滑动窗口、漏桶或令牌桶算法。
9. 去重和集合运算
Set 适合保存不重复的数据,可以实现:
- 用户签到;
- 已处理订单号;
- 抽奖名单;
- 共同好友;
- 共同关注;
- 标签交集。
例如查询两个用户的共同关注:
SINTER user:1001:follows user:1002:follows
如果只需要近似统计独立用户数量,可以使用 HyperLogLog,以较小内存完成 UV 统计,但结果存在一定误差。
10. 地理位置查询
Redis 的 GEO 命令可以保存经纬度,并查询附近位置:
GEOADD shops 116.397 39.908 shop:1001
GEOSEARCH shops FROMLONLAT 116.40 39.90 BYRADIUS 5 km
常见场景包括:
- 附近的商家;
- 附近的骑手;
- 车辆位置;
- 门店距离计算。
Redis GEO 适合基础的附近位置查询,复杂路线规划和专业空间分析通常需要专门的地图或空间数据库服务。
面试总结
Redis 常见使用场景包括:
- 使用 String、Hash 实现热点数据缓存;
- 使用 Sorted Set 实现排行榜;
- 使用
INCR、HINCRBY实现原子计数器; - 使用 List 实现简单队列,使用 Stream 实现带确认和重试的消息处理;
- 使用
SET NX PX和安全解锁逻辑实现分布式锁; - 使用 Stream、Hash、Sorted Set 处理实时数据;
- 保存分布式会话和验证码;
- 使用计数器或 Lua 脚本实现接口限流;
- 使用 Set、Bitmap、HyperLogLog 实现集合运算、状态记录和近似统计;
- 使用 GEO 实现附近位置查询。
Redis 适合高并发、低延迟和临时共享数据场景,但内存成本较高,复制默认是异步的,也不能替代所有数据库和专业消息队列。选型时需要综合考虑一致性、可靠性、数据规模和持久化要求。
参考:Redis Use Cases、Redis Streams、Redis Distributed Locks
7. 什么是 Redis 脑裂?
什么是脑裂?
Redis 脑裂是指由于网络分区等故障,旧主库仍然能够对部分客户端提供写服务,同时哨兵或集群又将一个从库提升为新主库,导致系统中短时间内存在两个可写主库。
正常状态:
客户端 → 主库 M1 → 从库 R1
网络分区后:
客户端 A → 旧主库 M1
客户端 B → 新主库 R1
此时,M1 和 R1 分别接收写请求,两边的数据会逐渐产生分歧,这就是脑裂。
脑裂机制需要解决的问题是:网络分区发生时,如何避免多个节点同时对外提供写服务,以及故障恢复后如何避免分叉数据丢失。
脑裂不只是“客户端不知道向哪个主库写入”,其本质是多个节点同时具备或认为自己具备主库身份,并分别接受写请求。
脑裂为什么会发生?
最常见的原因是网络分区导致主库“假下线”:
- 原主库 M1 仍然正常运行,并且部分客户端仍然能够访问它;
- M1 与从库、Sentinel 或其他集群节点之间的网络中断;
- 多个 Sentinel 判断 M1 已经下线;
- Sentinel 将从库 R1 提升为新主库;
- 另一部分客户端连接到新主库 R1;
- 旧主库 M1 没有及时停止写服务,于是两个主库同时接收写请求。
除了网络分区,还可能由以下原因引起:
- 主库发生长时间阻塞或卡顿;
- 网络抖动、丢包或者防火墙配置错误;
- Sentinel 与 Redis 节点部署位置不合理;
- 客户端缓存了旧主库地址,没有及时更新;
- 故障检测和切换参数配置不合理。
脑裂为什么会造成数据丢失?
假设网络分区后形成以下结构:
客户端 A → 旧主库 M1:写入 order:1001
客户端 B → 新主库 M2:写入 order:1002
此时两个主库的数据已经产生分叉:
M1:包含 order:1001
M2:包含 order:1002
网络恢复后,Sentinel 会以最新的主从拓扑为准,将旧主库 M1 降级为新主库 M2 的从库:
REPLICAOF M2_IP M2_PORT
旧主库随后会与新主库进行主从同步。根据复制状态,可能进行部分同步,也可能进行全量同步,并不一定每次都直接接收完整 RDB。
但是,无论采用哪种同步方式,最终都要以新主库 M2 的数据集为准。旧主库 M1 在脑裂期间独立接收、但没有出现在 M2 上的写入无法自动合并,因此这些数据会丢失。
脑裂期间写入旧主库的数据
↓
旧主库被降级为从库
↓
以新主库数据为准进行同步
↓
旧主库上的分叉数据被覆盖或丢弃
异步复制丢数据与脑裂丢数据的区别
Redis 主从复制默认是异步的,因此即使没有发生脑裂,也可能发生数据丢失。
例如:
- 客户端向主库写入数据;
- 主库向客户端返回成功;
- 数据还没有复制到从库;
- 主库突然宕机;
- 从库被提升为新主库;
- 尚未复制的数据丢失。
这种情况属于异步复制延迟导致的数据丢失。
脑裂造成的数据丢失则是:旧主库和新主库同时接收写请求,形成两套分叉数据,最终只有新主库的数据被保留。
如何降低脑裂风险?
1. 配置主库写入条件
可以配置:
min-replicas-to-write 1
min-replicas-max-lag 10
含义是:
- 主库至少要有
1个状态正常的从库; - 该从库最近一次有效通信延迟不能超过
10秒; - 如果不满足条件,主库拒绝可能修改数据的写命令。
当旧主库与从库发生网络分区后,它最多在检测窗口内继续接受写入。超过配置的延迟时间后,由于没有满足条件的从库,旧主库会拒绝写请求,从而限制脑裂期间的数据丢失窗口。
这里并不是“配置 ACK 消息的发送延迟”,而是主库根据收到从库 ACK 等交互信息的时间,判断是否还有足够数量、延迟可接受的从库。
这种方案不能完全消除数据丢失,只能将风险控制在一定时间范围内,因为:
- 网络刚中断时,旧主库可能暂时继续接受写入;
- 从库的 ACK 不代表当前这条具体写命令一定已经安全持久化;
- 参数越严格,可用性越低,短暂网络抖动也可能导致主库拒绝写入。
2. 合理部署 Sentinel
建议至少部署 3 个 Sentinel,并放在相互独立的机器或故障域中。
例如:
sentinel monitor mymaster 10.0.0.1 6379 2
这里的 2 表示至少需要两个 Sentinel 同意主库客观下线。真正执行故障转移时,还需要获得 Sentinel 多数派的授权。
Sentinel 的多数派机制可以防止网络分区两侧都独立发起故障转移,但它本身不能直接强制处于少数派网络中的旧主库立即停止写入。因此,通常还需要配合 min-replicas-to-write 等配置。
3. 客户端必须支持拓扑感知
使用 Sentinel 时,客户端应通过 Sentinel 查询当前主库地址;发生连接异常或主从切换后,应重新获取主库,而不是一直使用写死或长期缓存的旧主库地址。
使用 Redis Cluster 时,客户端需要正确处理:
MOVED
ASK
等重定向信息,并及时刷新槽位与节点的映射关系。
客户端还可以在建立连接时校验节点角色,避免将写请求发送到已经被降级的旧主库。
4. 对关键写入使用 WAIT
关键写操作之后可以执行:
SET order:1001 "created"
WAIT 1 1000
表示等待至少一个从库确认收到前面的写命令,最多等待 1000 毫秒。
WAIT 可以提高写入已经复制到从库的概率,但 Redis 复制仍然不是强一致复制,WAIT 不能把 Redis 变成支持严格一致性的分布式数据库,也不能完全保证故障切换后绝不丢数据。
对于同时关注 AOF 落盘确认的场景,新版本 Redis 还可以根据部署情况考虑 WAITAOF。
5. 合理配置 Redis Cluster
Redis Cluster 使用多数派完成故障转移。发生网络分区后,少数派一侧的节点在超过 cluster-node-timeout 且无法联系多数主节点时,会停止接受写请求。
因此,Redis Cluster 能够限制少数派继续写入的时间窗口,但在 cluster-node-timeout 到期之前,少数派一侧仍然可能接收部分最终会丢失的写入。
cluster-node-timeout 设置过大会扩大脑裂期间的写入窗口,设置过小则可能因为短暂网络抖动频繁触发故障判断,需要结合网络质量和故障恢复目标进行设置。
6. 做好监控和故障演练
重点监控:
connected_slaves
master_link_status
master_last_io_seconds_ago
master_repl_offset
slave_repl_offset
role
主从切换次数
Sentinel SDOWN/ODOWN 事件
写命令 OOM 或拒绝错误
同时定期进行网络分区、主库故障和主从切换演练,确认:
- Sentinel 是否能够形成多数派;
- 客户端能否及时切换到新主库;
- 旧主库是否会在限定时间后拒绝写入;
- 故障恢复后的数据丢失窗口是否符合业务要求。
能否完全避免脑裂和数据丢失?
Redis 使用异步复制,并优先提供较高性能和可用性,因此只能通过配置缩小数据丢失窗口,不能仅依赖 Sentinel、WAIT 或复制配置实现绝对的零丢失。
如果业务要求严格一致、任何已确认写入都不能丢失,应考虑:
- 将数据库作为最终数据源,Redis 只作为缓存;
- 使用事务消息、Outbox 和补偿机制;
- 为关键操作增加幂等控制;
- 使用具备共识协议和强一致写入能力的存储系统;
- 在业务层使用任期号或 fencing token,拒绝旧主节点产生的过期写入。
面试总结
Redis 脑裂是指网络分区后,旧主库仍然接受部分客户端的写入,同时 Sentinel 或集群又选出了新主库,导致两个主库同时写入并产生数据分叉。
网络恢复后,旧主库会被降级为新主库的从库,并以新主库的数据为准进行同步,所以脑裂期间写入旧主库、但没有同步到新主库的数据会丢失。
降低脑裂风险的主要措施包括:
- 配置
min-replicas-to-write和min-replicas-max-lag,限制失去从库后的写入时间; - 至少部署 3 个 Sentinel,并通过多数派授权故障转移;
- 使用支持 Sentinel 或 Cluster 拓扑感知的客户端;
- 对关键写入使用
WAIT或根据版本使用WAITAOF提高数据安全性; - 合理配置
cluster-node-timeout; - 配合监控、持久化、备份和业务补偿机制。
这些措施本质上是在一致性与可用性之间做权衡:越严格地限制旧主库写入,数据越安全,但网络故障时服务越容易暂时不可写。
参考:Redis Sentinel、Redis Replication、Redis Cluster Specification
8. Redis 如何解决哈希冲突?什么是 Rehash 和负载因子?
什么是哈希冲突?
Redis 的全局键空间以及部分数据类型,底层会使用字典,也就是哈希表存储数据。
Redis 会先计算 Key 的哈希值,再根据哈希表大小计算桶的位置。由于哈希表的桶数量有限,不同 Key 可能被映射到同一个桶中,这种情况称为哈希冲突。
哈希冲突无法完全避免,需要解决的是:发生冲突后仍能正确保存和查询数据,并避免冲突过多导致查询性能下降。
需要注意,这里讨论的是 Redis 底层字典的哈希冲突,不是 Redis Cluster 的哈希槽冲突。
1. Redis 如何解决哈希冲突?
Redis 使用链地址法解决哈希冲突。
哈希表中的每个桶保存一个链表的头指针。多个 Key 被映射到同一个桶时,会通过链表连接起来:
bucket[3]
↓
key3 → key2 → key1 → null
写入数据时,Redis 先计算 Key 所在的桶,然后将新的节点插入该桶对应的冲突链中。
查询时,Redis会:
- 计算 Key 的哈希值;
- 定位对应的桶;
- 遍历桶中的冲突链;
- 比较 Key,找到对应的键值对。
哈希冲突较少时,查询的平均时间复杂度接近 O(1);如果大量 Key 集中在同一个桶中,链表会变长,最坏情况下查询时间复杂度可能退化为 O(N)。
Redis 还会使用带随机种子的哈希算法降低大量恶意 Key 制造哈希碰撞攻击的风险。
2. 什么是负载因子?
负载因子用于衡量哈希表的拥挤程度,计算公式为:
负载因子 = 哈希表中元素数量 / 哈希表桶数量
例如,一个哈希表有 8 个桶,保存了 8 个键值对:
负载因子 = 8 / 8 = 1
负载因子越大,说明平均每个桶中的元素越多,发生哈希冲突的概率也越高;负载因子过小,则说明桶数量相对过多,会浪费内存。
负载因子的作用是在查询性能和内存占用之间取得平衡:
- 负载因子过大时,通过扩容降低哈希冲突;
- 负载因子过小时,通过缩容减少内存浪费。
3. 什么是 Rehash?
Rehash 是 Redis 调整哈希表大小,并将旧哈希表中的数据迁移到新哈希表的过程。
Redis 的字典结构内部会维护两张哈希表,可以简化理解为:
ht[0]:当前正在使用的哈希表
ht[1]:Rehash 时使用的新哈希表
rehashidx:当前迁移到哪个桶
正常情况下只使用 ht[0],ht[1] 为空。需要扩容或缩容时,Redis 会为 ht[1] 分配新的桶数组,然后逐步把 ht[0] 中的数据迁移过去。
4. Redis 为什么使用渐进式 Rehash?
如果哈希表中有几百万个元素,一次性完成全部迁移,会长时间占用 Redis 主线程,导致客户端请求无法及时处理。
因此,Redis 使用渐进式 Rehash,将迁移工作分散到多个步骤中完成:
- 为
ht[1]分配新的哈希表空间; - 将
rehashidx设置为0; - 每次执行部分字典读写操作时,顺带迁移少量桶;
- Redis 定时任务也会推进 Rehash,避免实例空闲时迁移长期无法完成;
ht[0]中的数据全部迁移完成后,释放旧表;- 将
ht[1]变成新的ht[0],并将rehashidx重置。
需要注意,渐进式 Rehash 仍然由 Redis 主线程执行,只是把大量迁移工作拆分到多次操作中,并不是交给后台线程迁移。
5. Rehash 期间如何进行读写?
Rehash 期间,两张哈希表会同时存在。
查询操作
Redis 会根据迁移进度查询对应哈希表。概念上可以理解为:必要时先查询 ht[0],没有找到再查询 ht[1]。
查询 Key
↓
查找旧表 ht[0]
↓ 未找到
查找新表 ht[1]
新增操作
新增加的键值对会直接写入 ht[1],避免新数据继续进入旧表,保证 ht[0] 中的数据最终能够全部迁移完成。
删除和修改操作
Redis 会在两张表中定位目标 Key,然后完成删除或修改。同时,部分常规字典操作还会顺带推进一次 Rehash。
6. 什么情况下会触发扩容?
正常允许调整哈希表大小时,当元素数量达到或超过桶数量,也就是负载因子达到 1 左右,Redis 会考虑扩容。
used >= size
负载因子 >= 1
新哈希表的桶数量通常会扩展为能够容纳当前元素数量的下一个 2 的幂。因为桶数量为 2 的幂,可以通过位运算快速计算桶下标。
在执行 RDB 快照、AOF 重写等后台任务时,Redis 会尽量避免哈希表扩容和 Rehash,因为迁移大量指针可能触发写时复制,增加额外内存消耗。
因此,这时 Redis 会容忍更高的负载因子,只有冲突已经非常严重时才强制扩容。
常见面试资料会给出以下经典阈值:
- 正常情况下:负载因子达到
1左右触发扩容; - 存在后台持久化子进程时:负载因子大于
5才强制扩容。
其中“大于 5”对应 Redis 7.2 等经典版本的实现。具体强制扩容和缩容常量属于内部实现细节,不同 Redis 版本可能调整,因此不应把 5 当成所有版本永远不变的协议规则。Redis 7.2 dict.c
7. 什么情况下会触发缩容?
当大量数据被删除、哈希表的桶使用率很低时,Redis可能进行缩容,将数据迁移到更小的哈希表中,减少空桶造成的内存浪费。
经典 Redis 版本通常会在填充率低于约 10% 时考虑缩容,较新版本的内部阈值可能有所调整。
缩容同样采用渐进式 Rehash,避免一次性迁移大量数据导致 Redis 长时间阻塞。
8. 渐进式 Rehash 的优缺点
优点:
- 避免一次性迁移大量数据;
- 降低单次操作的阻塞时间;
- 保持 Redis 较稳定的请求延迟;
- 可以在处理正常请求时逐步完成迁移。
缺点:
- Rehash 期间需要同时维护两张哈希表,内存占用会暂时增加;
- 查询、删除等操作需要考虑两张表,逻辑更加复杂;
- 每次字典操作可能附带少量迁移工作,产生额外开销;
- 如果 Redis 内存非常紧张,创建新哈希表可能进一步增加内存压力。
9. Redis 的 Hash 数据类型一定使用哈希表吗?
不一定。
Redis 的 Hash 数据类型在元素较少、字段和值较小时,可能采用紧凑编码,例如 listpack,以节省内存;达到相关转换条件后,才会转换成哈希表编码。
因此,哈希冲突和渐进式 Rehash 主要针对底层采用哈希表编码的字典结构,不是所有 Hash 对象从创建开始都会发生。
面试总结
Redis 使用链地址法解决哈希冲突,把映射到同一个桶的键值对组织成冲突链。
随着数据增多,负载因子会上升,哈希冲突会变多。Redis 会通过 Rehash 扩大哈希表,降低冲突概率;当数据大量减少时,也可能缩小哈希表以节省内存。
为了避免一次性迁移大量数据阻塞主线程,Redis 使用渐进式 Rehash:通过两张哈希表和 rehashidx 记录迁移进度,把桶迁移分散到后续字典操作和定时任务中。Rehash 期间,新增数据写入新表,查询和删除则需要兼顾新旧两张表。
正常情况下,负载因子达到 1 左右会触发扩容;存在后台持久化子进程时,Redis 会尽量推迟扩容,具体强制扩容阈值与版本实现有关。
参考:Redis 哈希表源码、Redis 7.2 哈希表源码
9. 当 Redis 内存使用率达到 100% 时,可能会出现哪些问题?
Redis 内存使用率达到 100% 是什么含义?
需要区分两个概念:
- Redis 达到
**maxmemory**上限:Redis 使用的内存达到配置的最大内存限制; - 机器物理内存耗尽:Redis 及其他进程几乎用完服务器的 RAM。
达到 maxmemory 后,Redis 会根据 maxmemory-policy 决定淘汰数据还是拒绝写入;物理内存耗尽则可能引发 Swap、进程崩溃或被操作系统 OOM Killer 终止。
设置内存上限和淘汰策略,是为了控制 Redis 的内存占用,避免其无限增长并拖垮整台机器。
1. 触发缓存淘汰
如果配置了淘汰策略,例如:
maxmemory-policy allkeys-lru
当内存达到 maxmemory 后,Redis 会先淘汰部分 Key,释放出足够空间,然后再执行需要增加内存的命令。
常见策略包括:
allkeys-lru:从所有 Key 中淘汰最近最少使用的数据;allkeys-lfu:从所有 Key 中淘汰访问频率较低的数据;volatile-lru:只从设置了 TTL 的 Key 中淘汰最近最少使用的数据;volatile-ttl:优先淘汰剩余过期时间较短的数据;noeviction:不淘汰数据,内存不足时拒绝部分写入。
如果 Redis 只用于缓存,数据在数据库中有可靠副本,那么淘汰通常表现为缓存未命中,而不是业务数据永久丢失。
如果用户会话、库存或其他关键数据只存放在 Redis 中,被淘汰后则可能造成会话失效或业务数据丢失。因此,不同重要程度的数据最好使用不同的 Redis 实例。
2. 写命令可能失败
如果配置的是:
maxmemory-policy noeviction
达到内存上限后,Redis 不会删除已有 Key,但会让可能继续增加内存的命令返回 OOM 错误,例如:
OOM command not allowed when used memory > 'maxmemory'
此时通常会出现:
- 新数据无法写入;
- 已有 Value 无法继续扩容;
- 业务请求失败或事务中断;
- 消息、会话和缓存回填可能失败。
只读命令以及部分能够释放内存的命令通常仍可执行。因此,不能简单理解为“Redis 完全不可用”,但业务的写入能力会受到影响。
如果使用 volatile-* 策略,但没有足够的带 TTL Key 可以淘汰,其最终效果也可能类似 noeviction。
3. 请求延迟上升
Redis 达到内存上限后,每次需要增加内存时,都可能先执行 Key 淘汰。淘汰对象的选择和内存释放会增加额外开销,导致:
- 写请求延迟上升;
- P99、P999 延迟出现抖动;
- QPS 发生波动;
- 大 Key 被淘汰或释放时延迟更加明显。
因此,对于纯缓存场景,内存使用率达到 maxmemory 并不一定意味着 Redis 已经故障,但持续淘汰会增加写请求的处理成本。
4. 缓存命中率下降,数据库压力升高
大量 Key 被淘汰后,后续请求会出现更多缓存未命中,需要重新查询数据库并回填缓存。
可能形成以下连锁反应:
Redis 内存不足
↓
大量 Key 被淘汰
↓
缓存命中率下降
↓
数据库请求量上升
↓
数据库响应变慢
↓
请求超时甚至系统雪崩
如果被淘汰的是热点数据,大量请求可能同时查询数据库,还可能产生缓存击穿问题。
5. 可能发生 Swap,导致延迟激增
maxmemory 达到 100% 不代表一定会发生 Swap。只有当整台机器出现物理内存压力,并且操作系统启用了 Swap 时,Redis 的部分内存页才可能被换到磁盘。
当 Redis 再次访问这些内存页时,操作系统需要从磁盘加载数据。由于磁盘访问远慢于内存访问,会造成明显的延迟尖刺。
这里没有“内存达到 95% 就必然发生 Swap”的固定规则,是否发生 Swap 取决于:
- 机器可用物理内存;
- Redis 实际驻留内存;
- 其他进程的内存占用;
- 操作系统的内存管理策略;
- 是否开启 Swap。
Swap 主要导致严重的性能下降,并不意味着 Swap 中的数据会自动丢失。数据能否在 Redis 重启后恢复,取决于 RDB、AOF 和备份配置。
6. Redis 可能被 OOM Killer 终止
如果机器物理内存耗尽且无法通过 Swap 缓解,操作系统可能启动 OOM Killer,并直接终止 Redis 进程。
这会造成:
- Redis 服务中断;
- 客户端连接断开;
- 未持久化的数据丢失;
- 触发主从切换;
- 数据库承受突然增加的回源流量。
因此,maxmemory 不能直接配置成机器全部物理内存,还必须为以下内容预留空间:
- Redis 进程和内存碎片;
- 客户端连接及输出缓冲区;
- 主从复制缓冲区;
- AOF 缓冲区;
- RDB 和 AOF 重写产生的 Copy-on-Write 内存;
- 操作系统及其他进程。
7. RDB 或 AOF 重写可能失败
执行 BGSAVE 或 AOF 重写时,Redis 会通过 fork() 创建子进程,并利用写时复制机制生成持久化文件。
在持久化期间,如果主进程继续处理大量写请求,被修改的内存页可能被复制,从而产生额外内存开销。在写入频繁的极端场景下,Redis 的实际内存占用可能显著增加。
内存不足可能导致:
fork()失败;- RDB 快照生成失败;
- AOF 重写失败;
- 持久化期间延迟上升;
- 进一步加剧 Swap 或触发 OOM Killer。
Swap 可能让持久化过程变慢,但通常不会直接导致快照数据不完整。是否持久化成功,应检查 Redis 返回状态和相关监控指标。
8. Redis Cluster 可能出现单节点内存倾斜
在 Redis Cluster 中,每个节点分别管理自己的内存和数据。
即使整个集群的平均内存使用率不高,某个节点也可能因为以下原因先达到上限:
- 大 Key 集中在某个哈希槽;
- 热点业务数据分布不均;
- 不同节点的数据过期速度不同;
- 分片规划不合理。
该节点可能提前发生 Key 淘汰、写入失败或延迟上升,因此不能只监控集群平均内存,还要分别监控每个主节点。
应对措施
1. 设置合理的内存上限和淘汰策略
maxmemory 8gb
maxmemory-policy allkeys-lru
- 纯缓存场景可以考虑
allkeys-lru或allkeys-lfu; - 不允许淘汰的数据可以使用独立实例并配置
noeviction; - 不要把关键业务数据与普通缓存混合存放;
maxmemory应低于机器可用物理内存,为系统和后台任务预留空间。
2. 提前监控和告警
重点监控以下指标:
used_memory
used_memory_rss
maxmemory
mem_fragmentation_ratio
evicted_keys
keyspace_hits
keyspace_misses
current_cow_peak
rdb_last_bgsave_status
aof_last_bgrewrite_status
同时监控操作系统的可用内存、Swap、进程 RSS 和 OOM 日志。
告警阈值应根据业务类型和历史数据确定。可以在达到 maxmemory 之前分级告警,而不是等到 100% 后再处理。
3. 控制数据增长
- 清理无效和冗余 Key;
- 为缓存设置合理的 TTL;
- 避免 List、Set、Hash、Sorted Set 和 Stream 无限增长;
- 拆分大 Key;
- 限制单次写入的数据大小;
- 使用
MEMORY USAGE、--bigkeys、--memkeys定期检查异常 Key。
需要注意,没有统一的“String 必须小于 10KB”标准,应根据网络带宽、访问频率和延迟目标制定业务阈值。
4. 扩容或分片
如果数据量长期增长,可以:
- 增加实例内存;
- 增加 Redis Cluster 节点;
- 调整槽位分布;
- 将冷数据迁移到数据库、对象存储或磁盘存储;
- 对不同业务进行实例隔离。
总结
Redis 内存达到 100% 后,主要可能出现以下问题:
- 根据淘汰策略删除部分 Key;
noeviction或无可淘汰 Key 时,部分写命令返回 OOM;- 淘汰操作增加延迟,导致 P99 延迟和 QPS 波动;
- 缓存命中率下降,数据库压力升高;
- 物理内存不足时可能发生 Swap,造成严重延迟;
- 极端情况下 Redis 可能被 OOM Killer 终止;
- RDB 和 AOF 重写可能因内存不足而失败;
- Redis Cluster 中可能出现单个节点率先耗尽内存。
需要特别强调:达到 maxmemory 与机器物理内存耗尽不是一回事。对于配置了合理淘汰策略的纯缓存实例,达到 maxmemory 后仍然可以继续工作;真正危险的是没有预留内存、持续发生高频淘汰、写入被拒绝,或者机器已经出现 Swap 和 OOM。
参考:Redis Key Eviction、Redis Latency Diagnosis、Redis Persistence、Redis Administration
10. 如何避免 Redis 出现大 Key?
什么是大 Key?
Redis 大 Key 通常不是指 Key 的名称很长,而是指某个 Key 对应的 Value 占用内存过大,或者集合中的元素数量过多,例如:
- String 中保存了体积很大的 JSON、图片或序列化对象;
- Hash 中包含大量字段;
- List、Set、Sorted Set 中包含大量元素;
- Stream 长期没有裁剪,消息数量不断增长。
Redis 官方没有规定统一的大 Key 标准。实际项目应根据网络带宽、Redis 内存、命令耗时和业务延迟要求设定阈值。
大 Key 会带来什么问题?
Redis 执行大 Key 相关操作时,可能产生以下问题:
- 读写大 Value 会占用较多网络带宽,增加响应延迟;
- 对大集合执行全量查询或删除,可能长时间占用 Redis 主线程;
- 大 Key 过期或被淘汰时,释放大量内存可能造成延迟抖动;
- 执行 RDB、AOF 重写及主从复制时,会增加磁盘和网络压力;
- Redis Cluster 迁移槽位时,大 Key 会增加节点迁移时间;
- 单个 Key 占用大量内存,容易造成集群节点之间的数据倾斜。
因此,避免大 Key 的核心是限制单个 Key 的内存大小和元素数量,防止数据无限增长。
1. 在数据建模阶段拆分大 Key
拆分大 Key 是最主要的预防方式,可以按照时间、用户、业务类型或哈希分桶拆分。
例如,不要把所有用户的订单放在一个集合中:
orders:all
可以按照月份拆分:
orders:2026-01
orders:2026-02
orders:2026-03
也可以按照哈希分桶:
orders:bucket:0
orders:bucket:1
...
orders:bucket:99
分桶编号可以通过用户 ID 或订单 ID 取模计算:
bucket = user_id % 100
拆分后需要在应用层维护分片规则,范围查询时可能需要访问多个 Key,因此应根据实际查询方式选择拆分维度。
2. 限制集合元素数量
List、Set、Hash、Sorted Set 和 Stream 都要避免无限增长。
可以采用以下方式:
- List 只保留最近 N 条数据;
- Stream 写入时设置近似最大长度;
- 排行榜只保留排名靠前的数据;
- 历史数据转移到数据库、搜索引擎或对象存储;
- 按日期、业务 ID 或哈希桶拆分集合。
例如,限制 List 只保留最近 1000 条记录:
LPUSH user:1001:messages "new message"
LTRIM user:1001:messages 0 999
限制 Stream 的近似长度:
XADD order:events MAXLEN ~ 10000 * type created order_id 9001
3. 限制单次写入的数据大小
在应用写入 Redis 前,应检查数据体积,避免将超大对象直接写入 Redis。
可以通过以下方式控制:
- 设置单个缓存对象的最大字节数;
- 限制批量写入的元素数量;
- 对超出阈值的数据拒绝缓存,只保存到数据库或对象存储;
- 只缓存业务需要的字段,不缓存完整对象;
- 避免将图片、文件和超大文本直接保存在 Redis 中。
压缩数据可以减少内存和网络开销,但压缩、解压会增加 CPU 消耗,也无法从根本上解决集合元素不断增长的问题,因此只能作为辅助优化。
4. 选择合适的数据结构
应根据业务场景选择更加节省空间的数据结构:
- 对象字段需要独立修改时,可以使用 Hash;
- 布尔状态较多时,可以考虑 Bitmap;
- 只需要近似去重计数时,可以考虑 HyperLogLog;
- 排行榜使用 Sorted Set,但要限制元素数量;
- 消息流使用 Stream,但需要定期裁剪;
- 大文件或大段二进制数据应存入对象存储,Redis 只保存访问地址或元数据。
选择数据结构只能改善内存利用率,不能代替容量限制。即使使用 Hash,如果字段数量无限增加,仍然会形成大 Key。
5. 设置合理的过期时间
临时缓存应设置 TTL,防止无效数据长期积累:
SET product:1001 "value" EX 3600
为了避免大量 Key 在同一时间过期,可以在基础过期时间上增加随机偏移:
实际过期时间 = 基础过期时间 + 随机时间
例如:
3600 秒 + 0~300 秒随机值
需要注意,随机过期时间解决的是大量 Key 集中过期引起的缓存雪崩,并不能防止单个 Key 变成大 Key。
6. 建立大 Key 检测机制
可以定期检测 Key 的内存占用和集合元素数量。
检查指定 Key 的内存占用:
MEMORY USAGE user:1001
使用命令行工具扫描大 Key:
redis-cli --bigkeys
redis-cli --memkeys
redis-cli --keystats
其中:
--bigkeys主要查找各数据类型中元素数量较多的 Key;--memkeys主要查找内存占用较大的 Key;--keystats综合统计 Key 的内存大小和元素数量,具体支持情况取决于 Redis 版本。
这些工具底层会扫描键空间,生产环境执行时应控制扫描速度,尽量避开业务高峰。不要使用 KEYS * 全量查询生产环境中的 Key,因为它可能长时间阻塞 Redis。
7. 对已经存在的大 Key进行渐进式拆分
发现大 Key 后,不应在高峰期一次性读取、迁移或删除全部数据。可以采用以下步骤:
- 创建新的分片 Key;
- 新写入的数据同时写入新旧结构,或者直接写入新结构;
- 使用
HSCAN、SSCAN、ZSCAN等命令分批迁移旧数据; - 校验新旧数据是否一致;
- 将读取流量切换到新结构;
- 异步删除旧的大 Key。
这种方式能够减少一次性操作对 Redis 主线程、网络和业务的影响。
8. 删除大 Key 时使用异步删除
直接使用 DEL 删除大 Key时,释放大量内存可能阻塞 Redis 主线程。可以使用 UNLINK:
UNLINK orders:all
UNLINK 会先将 Key 从键空间中移除,再由后台线程异步回收相关内存,可以减少删除大 Key 对主线程的影响。
还可以根据实际情况配置异步释放机制,降低大 Key 过期或被淘汰时释放内存造成的延迟抖动。
需要注意,异步删除只是降低大 Key 删除时的阻塞,并不能防止大 Key 产生;如果后台积压了大量待释放对象,也可能造成额外的内存压力。
总结
避免 Redis 出现大 Key,可以从以下几个方面入手:
- 按时间、用户或哈希桶拆分大 Key;
- 限制 String 大小以及集合元素数量;
- 对 List、Stream、排行榜等可能持续增长的数据进行裁剪;
- 只缓存必要字段,大文件和历史数据存放到其他存储系统;
- 设置合理的 TTL,但要明确随机 TTL 解决的是缓存雪崩;
- 使用
MEMORY USAGE、--bigkeys、--memkeys等方式定期检查; - 对已有大 Key进行分批迁移和渐进式拆分;
- 删除大 Key时使用
UNLINK或异步释放机制,减少阻塞。
参考:Redis CLI、MEMORY USAGE、Redis Commands Reference
阅读导航
上一章:Redis 分布式




