Featured image of post Java 面试合集:Redis 数据结构与线程模型

Java 面试合集:Redis 数据结构与线程模型

Redis 面试合集:8 大数据类型应用场景、单线程 vs 多线程、I/O 多路复用、Redis 为什么快、内存淘汰策略、持久化机制

本文写于 2018 年 6 月——Redis 4.0 主线、Redis 5.0(Stream 类型)2018-10 发布前夜。

一、8 大数据类型应用场景

类型场景底层实现说明
String缓存 / 计数器 / 限流 / 分布式锁 / 共享 Sessionint + SDS(简单动态字符串)最基础类型
Hash缓存对象 / 购物车压缩列表 / 哈希表7.0 中,压缩列表已经废弃了,交由 listpack 来实现了
List消息队列双向链表 / 压缩列表简单的字符串列表,最大长度为 2^32 - 1,40 亿。3.2 版本之后,底层数据结构就只由 quicklist 实现了
Set点赞 / 共同关注 / 抽奖活动哈希表 / 整数集合无序并唯一的键值集合
ZSet排行榜 / 电话排序 / 姓名排序压缩列表 / 跳表7.0 中,压缩列表已经废弃了,交由 listpack 来实现了
BitMap(2.2 版新增)签到 / 用户登陆状态 / 布隆过滤器String统计二值状态
HyperLogLog(2.8 版新增)百万级以上的网页 UV 计数自定义用于「统计基数」
GEO(3.2 版新增)滴滴叫车Sorted Set地理坐标
Stream(5.0 版新增)消息队列自定义完美地实现消息队列

1.1 选型决策

二、Redis 线程模型

2.1 单线程 vs 多线程

Redis 单线程指的是「接收客户端请求 → 解析请求 → 进行数据读写等操作 → 发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

但是,Redis 程序并不是单线程的,Redis 在启动的时候是会启动后台线程(BIO)的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程

2.2 Redis 6.0 引入多线程 I/O

虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是 在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理。 所以大家不要误解 Redis 有多线程同时执行命令。

Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上

2.3 6.0 线程数配置

1
2
3
4
5
// 读请求也使用 io 多线程
io-threads-do-reads yes

// io-threads N,表示启用 N-1 个 I/O 线程(主线程也算一个 I/O 线程)
io-threads 4

官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

2.4 Redis 启动后的线程组成

因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会 额外创建 6 个线程这里的线程数不包括主线程 ):

  • Redis-server:Redis 的主线程,主要负责执行命令
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力

三、Redis 为什么这么快

3.1 4 大核心原因

Redis 快的核心原因,可以用一句话概括:它干的活极其简单,而且它把单线程的优势发挥到了极致,同时通过操作系统底层的"高级外挂"解决了高并发的排队问题。

原因 1:绝大多数请求是纯内存操作

这是最根本的基础。Redis 的所有数据都存储在内存中,CPU 读写内存的速度(纳秒级别)比起读写机械硬盘或 SSD(微秒甚至毫秒级别)快了数万倍。

原因 2:避免了多线程的"内耗"

多线程虽然能并发,但也带来了高昂的代价。Redis 采用单线程,反而完美避开了这些性能杀手:

  • 没有线程切换的开销:操作系统在切换线程时,需要保存当前线程的上下文并加载新线程的数据。单线程一个人干到底,零切换成本
  • 没有锁的竞争:多线程访问共享资源必须加锁,加锁、解锁以及等待锁的操作非常重。Redis 单线程天然保证了所有操作都是原子性的,根本不需要锁

原因 3:I/O 多路复用技术(底层外挂)

“单线程"指的是 Redis 核心的网络事件处理器和键值对读写命令 是由一个线程串行执行的。但面对海量的客户端连接,单线程怎么可能忙得过来?

这就是 I/O 多路复用(I/O Multiplexing) 大显身手的地方。Redis 利用了操作系统底层的非阻塞 I/O 机制(在 Linux 上通常是 epoll)。

epoll 工作原理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
┌─────────────────────────────────────────┐
│  Redis 主线程                            │
│  ┌──────────────────┐                  │
│  │ epoll_wait()      │ ← 阻塞等事件      │
│  └──────────────────┘                  │
│  ↓ 有事件发生                            │
│  遍历 fd_list,处理每个就绪 fd            │
│  ├─ Client 1: read()                    │
│  ├─ Client 5: read()                    │
│  └─ Client 100: read()                  │
│  ↓ 业务逻辑                              │
│  执行 Redis 命令(单线程原子)              │
│  ↓ 写回响应                              │
│  write() → 多线程异步                     │
└─────────────────────────────────────────┘

epoll 优势

  • select:O(n) 遍历所有 fd
  • poll:O(n) 遍历所有 fd
  • epoll:O(1) 事件通知(内核维护就绪链表)

原因 4:精心优化的数据结构

数据结构优化
SDSO(1) 长度获取、避免缓冲区溢出、二进制安全
跳跃表O(log N) 范围查询,替代红黑树
压缩列表连续内存 + 紧凑编码
dict(哈希表)渐进式 rehash,不阻塞

四、内存淘汰策略

4.1 8 大策略

策略描述
noeviction(默认)不淘汰,写入失败返回错误
allkeys-lru所有 key 中淘汰最近最少使用
allkeys-lfu(4.0+)所有 key 中淘汰最不经常使用
allkeys-random所有 key 中随机淘汰
volatile-lru设置过期的 key 中淘汰 LRU
volatile-lfu(4.0+)设置过期的 key 中淘汰 LFU
volatile-random设置过期的 key 中随机淘汰
volatile-ttl设置过期的 key 中淘汰最近过期

4.2 选型建议

  • 缓存场景(容忍丢失):allkeys-lru / allkeys-lfu
  • 数据不能丢noeviction + 监控告警
  • 混合场景volatile-lru(仅淘汰过期 key)

五、持久化机制

5.1 三大方式

方式文件频率性能数据安全
RDBdump.rdb定时 / 手动较低(可能丢失最后一次快照后的数据)
AOFappendonly.aof每秒较高(仅丢失 1s 数据)
混合(4.0+)dump + aof-中高

5.2 RDB 触发

1
2
3
4
5
6
7
8
# 自动触发(save 命令)
save 900 1        # 900 秒内至少 1 个 key 变更
save 300 10       # 300 秒内至少 10 个 key 变更
save 60 10000     # 60 秒内至少 10000 个 key 变更

# 手动触发
bgsave  # 后台异步保存
save    # 同步保存(阻塞主线程)

5.3 AOF 三种策略

1
2
3
4
# appendfsync 策略
appendfsync always    # 每次写入同步刷盘(最安全,最慢)
appendfsync everysec  # 每秒刷盘(默认,推荐)
appendfsync no        # 操作系统决定(最快,最不安全)

六、Redis 集群方案

方案数据分片高可用适用
主从复制手动故障切换简单备份
Sentinel自动故障切换中小规模
Codis手动 + 代理国产分片方案
Cluster✓(16384 slot)自动故障切换大规模生产

七、Redis 经典问题

7.1 缓存雪崩

现象:大量 key 同时过期,请求打到数据库。

解决方案

  • 过期时间加随机值expire = base + random(0, 300)
  • 熔断降级:数据库压力过大时直接拒绝部分请求
  • 缓存预热:系统启动时把热点数据加载到缓存

7.2 缓存穿透

现象:查询不存在的 key,每次都打到数据库。

解决方案

  • 空值缓存key -> null(短 TTL)
  • 布隆过滤器:先过布隆过滤器
  • 接口校验:参数合法性检查

7.3 缓存击穿

现象:热点 key 突然过期,瞬间大量请求打到数据库。

解决方案

  • 分布式锁:第一个请求查 DB,后续请求等
  • 永不过期:逻辑过期(不设 TTL,值带过期时间字段)
  • 熔断降级:直接返回旧值

7.4 数据不一致

现象:数据库和缓存数据不一致。

解决方案

  • 先更新 DB,再删除缓存(Cache-Aside Pattern)
  • 延迟双删:更新 DB → 删除缓存 → 异步延迟再删
  • binlog 订阅:监听 binlog 异步更新缓存
  • 设置较短 TTL:最终一致性兜底

八、写在最后

Redis 面试要点

  1. 基础:8 大数据类型 + 选型
  2. 线程模型:单线程 vs 多线程 I/O、I/O 多路复用
  3. 快的原因:内存操作 + 无锁 + epoll + 优化数据结构
  4. 持久化:RDB / AOF / 混合
  5. 集群:主从 / Sentinel / Cluster
  6. 经典问题:雪崩 / 穿透 / 击穿 / 一致性

参考资料

使用 Hugo 构建
主题 StackJimmy 设计