HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • 技术面试完全指南

    • 技术面试完全指南
    • 8年面试官告诉你:90%的简历在第一轮就被刷掉了
    • 刷了500道LeetCode,终于明白大厂算法面试到底考什么
    • 高频算法题精讲-双指针与滑动窗口
    • 03-高频算法题精讲-二分查找与排序
    • 04-高频算法题精讲-树与递归
    • 05-高频算法题精讲-图与拓扑排序
    • 06-高频算法题精讲-动态规划
    • Go面试必问:一道GMP问题,干掉90%的候选人
    • 08-数据库面试高频题
    • 09-分布式系统面试题
    • 10-Kubernetes与云原生面试题
    • 11-系统设计面试方法论
    • 前端面试高频题
    • AI 与机器学习面试题
    • 行为面试与软技能

Redis面试三连击:穿透、击穿、雪崩,你真的能答清楚吗?

开篇

美团二面现场。

面试官:"说说缓存穿透、击穿、雪崩的区别。"

候选人:"呃...穿透是查不存在的key,击穿是热点key过期,雪崩是大量key同时过期..."

面试官追问:"那解决穿透用布隆过滤器,布隆过滤器的原理是什么?误判率怎么控制?"

候选人沉默了。

面试官继续:"分布式锁解决击穿,如果锁过期但业务没执行完怎么办?"

候选人更沉默了。


这三个问题,是Redis面试的标配连环追问。

问题在于:大多数候选人能背出名词解释,却答不出底层原理和生产实践。

面试官考察的不只是"是什么",而是:

  • 能不能从原理层面理解问题
  • 有没有真实的生产经验
  • 遇到边界情况怎么处理

本文将从面试官视角深度拆解Redis的核心考点,不只是告诉你怎么答,更告诉你为什么这么问。


本文结构

第一章 缓存三大问题(必问)
├── 1.1 穿透、击穿、雪崩的本质区别
├── 1.2 布隆过滤器原理与实现
├── 1.3 分布式锁的完整实现
└── 1.4 缓存一致性方案

第二章 数据结构底层实现(高频)
├── 2.1 String/SDS为什么比C字符串好
├── 2.2 跳表为什么能替代红黑树
├── 2.3 渐进式rehash机制
└── 2.4 各类型的编码转换条件

第三章 持久化机制(必问)
├── 3.1 RDB的fork与COW
├── 3.2 AOF的重写流程
├── 3.3 混合持久化
└── 3.4 生产环境最佳配置

第四章 高可用架构(加分项)
├── 4.1 主从复制流程
├── 4.2 哨兵故障转移
├── 4.3 Cluster分片原理
└── 4.4 脑裂问题与解决

第五章 性能优化实战
├── 5.1 大Key问题排查与处理
├── 5.2 热Key问题解决方案
├── 5.3 单线程模型与IO多路复用
└── 5.4 Redis 6.0多线程

第一章 缓存三大问题

1.1 穿透、击穿、雪崩的本质区别

这三个概念经常混淆,面试官问的第一个问题就是让你说清楚区别。

一张图搞清三者区别

                   请求来了
                      │
        ┌─────────────┼─────────────┐
        ▼             ▼             ▼
    缓存穿透       缓存击穿       缓存雪崩
        │             │             │
        ▼             ▼             ▼
  key根本不存在   热点key过期    大量key同时过期
        │             │             │
        ▼             ▼             ▼
  每次都打DB      并发都打DB     全部都打DB
        │             │             │
        ▼             ▼             ▼
   布隆过滤器      互斥锁        随机过期时间
   缓存空值       永不过期        多级缓存

详细对比

问题原因危害解决方案
穿透请求的key在DB中不存在恶意攻击可打垮DB布隆过滤器、缓存空值
击穿热点key突然过期瞬间并发全打DB互斥锁、永不过期
雪崩大量key同时过期DB压力骤增过期时间打散、多级缓存

面试常见追问

追问1:"穿透和击穿有什么本质区别?"

穿透:key从来不存在(恶意攻击或业务bug)
     └── 每次请求都100%打到DB

击穿:key曾经存在,但刚好过期(正常业务)
     └── 只在过期那一刻有并发问题

追问2:"雪崩和击穿的区别?"

击穿:单个热点key过期
     └── 影响单个热点数据的请求

雪崩:大量key同时过期
     └── 影响大面积请求,可能导致整个系统崩溃

1.2 布隆过滤器原理与实现

这是穿透问题的核心解决方案,面试官一定会追问原理。

布隆过滤器原理

核心结构:
├── m位的bit数组(全为0)
├── k个独立的哈希函数

添加元素时:
├── 对元素计算k个哈希值
└── 将对应k个位置设为1

查询元素时:
├── 对元素计算k个哈希值
├── 检查k个位置是否都为1
├── 全为1 → 可能存在(可能误判)
└── 有一个为0 → 一定不存在(100%准确)

图解:

添加元素 "apple":
  hash1("apple") = 2
  hash2("apple") = 5
  hash3("apple") = 8

bit数组变化:
位置:  0 1 2 3 4 5 6 7 8 9
之前:  0 0 0 0 0 0 0 0 0 0
之后:  0 0 1 0 0 1 0 0 1 0

查询 "orange":
  hash1("orange") = 2  → 1 ✓
  hash2("orange") = 5  → 1 ✓
  hash3("orange") = 7  → 0 ✗
  结论:一定不存在(因为7位是0)

查询 "banana":
  hash1("banana") = 2  → 1 ✓
  hash2("banana") = 5  → 1 ✓
  hash3("banana") = 8  → 1 ✓
  结论:可能存在(但其实我们没添加过,这是误判)

误判率公式

误判率 ≈ (1 - e^(-kn/m))^k

其中:
├── m:bit数组长度
├── n:已插入元素数量
├── k:哈希函数个数

最优k值:k = (m/n) × ln(2) ≈ 0.693 × (m/n)

示例计算:
├── 预计1000万元素
├── 期望误判率1%
├── 需要的bit数组大小:约9600万位 = 12MB
└── 最优哈希函数个数:7个

Redis中使用布隆过滤器

# Redis 4.0+ 使用RedisBloom模块

# 创建布隆过滤器
BF.RESERVE product_filter 0.01 10000000
#           过滤器名      误判率  预计元素数

# 添加元素
BF.ADD product_filter "product:10001"

# 批量添加
BF.MADD product_filter "product:10002" "product:10003"

# 检查元素
BF.EXISTS product_filter "product:10001"  # 返回1(存在)
BF.EXISTS product_filter "product:99999"  # 返回0(不存在)

完整代码实现

def get_product(product_id):
    """防止缓存穿透的完整实现"""

    # 1. 布隆过滤器判断
    if not bloom_filter.exists(f"product:{product_id}"):
        return None  # 一定不存在,直接返回

    # 2. 查缓存
    cache_key = f"product:{product_id}"
    data = redis.get(cache_key)

    if data is not None:
        if data == "NULL":  # 缓存的空值
            return None
        return json.loads(data)

    # 3. 查数据库
    product = db.query_product(product_id)

    if product:
        redis.setex(cache_key, 3600, json.dumps(product))
    else:
        # 缓存空值,短过期时间
        redis.setex(cache_key, 60, "NULL")

    return product

1.3 分布式锁的完整实现

这是解决缓存击穿的核心方案,也是面试必问的实战题。

基础实现

import uuid
import time

class RedisLock:
    def __init__(self, redis_client):
        self.redis = redis_client

    def acquire(self, key, timeout=10):
        """
        获取锁
        返回锁标识(成功)或None(失败)
        """
        # 生成唯一标识,防止误删其他客户端的锁
        identifier = str(uuid.uuid4())

        # SET key value NX EX timeout
        # NX: 只有key不存在时才设置
        # EX: 设置过期时间
        result = self.redis.set(
            key, identifier,
            nx=True,
            ex=timeout
        )

        return identifier if result else None

    def release(self, key, identifier):
        """
        释放锁
        使用Lua脚本保证原子性
        """
        script = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        """
        return self.redis.eval(script, 1, key, identifier)

面试必问:锁续期问题

问题:如果业务执行时间超过锁的过期时间怎么办?

场景:
├── 加锁,设置10秒过期
├── 业务执行了15秒
├── 第10秒时锁过期被删除
├── 其他客户端获得锁
├── 第15秒业务完成,删除锁
└── 但删除的是别人的锁!

解决方案:Watchdog自动续期

import threading

class RedisLockWithWatchdog:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.watchdog_running = False
        self.watchdog_thread = None

    def acquire(self, key, timeout=30):
        identifier = str(uuid.uuid4())

        if self.redis.set(key, identifier, nx=True, ex=timeout):
            # 启动watchdog线程,定期续期
            self._start_watchdog(key, identifier, timeout)
            return identifier
        return None

    def _start_watchdog(self, key, identifier, timeout):
        """每1/3过期时间续期一次"""
        def watchdog():
            while self.watchdog_running:
                time.sleep(timeout / 3)
                if self.watchdog_running:
                    # 续期
                    script = """
                    if redis.call("GET", KEYS[1]) == ARGV[1] then
                        return redis.call("EXPIRE", KEYS[1], ARGV[2])
                    end
                    """
                    self.redis.eval(script, 1, key, identifier, timeout)

        self.watchdog_running = True
        self.watchdog_thread = threading.Thread(target=watchdog, daemon=True)
        self.watchdog_thread.start()

    def release(self, key, identifier):
        # 停止watchdog
        self.watchdog_running = False

        # 释放锁
        script = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        end
        """
        return self.redis.eval(script, 1, key, identifier)

面试必问:Redlock算法

问题:单Redis实例的锁有什么问题?

问题:主从切换时锁可能丢失

场景:
├── 客户端A在主节点加锁成功
├── 主节点宕机,锁未同步到从节点
├── 从节点提升为主节点
├── 客户端B在新主节点加锁成功
└── 两个客户端同时持有锁!

Redlock解决方案:

1. 准备N个独立的Redis实例(N>=5,推荐5个)
2. 客户端依次向所有实例加锁
3. 在超过半数实例(N/2+1)成功加锁
4. 且总耗时小于锁过期时间
5. 才认为加锁成功

6. 解锁时向所有实例发送DEL命令

生产环境推荐:使用Redisson客户端,内置完整实现。


1.4 缓存一致性方案

这是另一个高频面试题:如何保证缓存和数据库的一致性?

Cache Aside模式(推荐)

# 读操作
def get_user(user_id):
    # 1. 先读缓存
    cache_key = f"user:{user_id}"
    data = redis.get(cache_key)

    if data:
        return json.loads(data)

    # 2. 缓存未命中,读数据库
    user = db.query_user(user_id)

    # 3. 写入缓存
    if user:
        redis.setex(cache_key, 3600, json.dumps(user))

    return user

# 写操作(重点!)
def update_user(user_id, new_data):
    # 1. 先更新数据库
    db.update_user(user_id, new_data)

    # 2. 再删除缓存(不是更新!)
    cache_key = f"user:{user_id}"
    redis.delete(cache_key)

为什么是"先更新DB,再删缓存"?

错误方案1:先删缓存,再更新DB

问题场景:
├── 线程A删除缓存
├── 线程B读取,缓存未命中
├── 线程B读DB(旧数据),写入缓存
├── 线程A更新DB
└── 结果:缓存是旧数据,DB是新数据!

错误方案2:先更新DB,再更新缓存

问题场景:
├── 线程A更新DB为100
├── 线程B更新DB为200
├── 线程B更新缓存为200
├── 线程A更新缓存为100
└── 结果:DB是200,缓存是100!

正确方案:先更新DB,再删缓存

即使有并发问题:
├── 线程A查询缓存未命中
├── 线程A读DB(旧数据)
├── 线程B更新DB
├── 线程B删除缓存
├── 线程A将旧数据写入缓存
└── 问题:缓存还是旧数据

但这种情况概率极低:
├── 需要"读DB"比"删缓存"慢
├── 实际上DB读写通常在ms级
├── 而且下次读会重新加载

延时双删(可选增强)

def update_user_with_double_delete(user_id, new_data):
    cache_key = f"user:{user_id}"

    # 1. 先删缓存
    redis.delete(cache_key)

    # 2. 更新数据库
    db.update_user(user_id, new_data)

    # 3. 延时后再删一次
    time.sleep(0.5)  # 延时500ms
    redis.delete(cache_key)

延时时间怎么定?

延时时间 > 一次读DB+写缓存的时间
通常设置100ms~1s

第二章 数据结构底层实现

2.1 String/SDS为什么比C字符串好

面试官问"Redis String的底层实现",不是想听你说"就是字符串",而是想考察你对SDS的理解。

SDS结构

struct sdshdr {
    int len;        // 已使用长度
    int free;       // 剩余空间
    char buf[];     // 字节数组
};

SDS vs C字符串

特性C字符串SDS
获取长度O(n)遍历O(1)直接读len
二进制安全否(\0截断)是(用len判断结束)
缓冲区溢出可能自动扩容
修改时内存分配每次都分配预分配+惰性释放

空间预分配策略

修改后的新长度 < 1MB:
    新空间 = 2 × 新长度

修改后的新长度 >= 1MB:
    新空间 = 新长度 + 1MB

示例:
├── 原长度10字节,追加5字节
├── 新长度15字节 < 1MB
├── 分配空间 = 15 × 2 = 30字节
└── free = 30 - 15 = 15字节

2.2 跳表为什么能替代红黑树

Zset使用跳表(skiplist)而不是红黑树,这是面试常问的设计抉择。

跳表结构

Level 3:  1 --------------------------> 30 --------------------> 60
Level 2:  1 --------> 10 -------------> 30 --------> 50 -------> 60
Level 1:  1 --> 5 --> 10 --> 20 --> 30 --> 40 --> 50 --> 55 --> 60
Level 0:  1 → 3 → 5 → 7 → 10 → 15 → 20 → 25 → 30 → 35 → 40 → 45 → 50 → 55 → 60

跳表 vs 红黑树

特性跳表红黑树
查找复杂度O(log n)O(log n)
插入复杂度O(log n)O(log n)
范围查询O(log n + m),链表遍历O(log n + m),中序遍历
实现难度简单复杂(旋转、变色)
内存占用略高较低
并发友好好(局部修改)差(旋转影响大)

Redis选择跳表的原因

1. 实现简单:代码更少,bug更少
2. 范围查询快:ZRANGE直接遍历链表
3. 调参灵活:可以调整层数概率
4. 并发友好:插入删除只影响局部节点

2.3 渐进式rehash机制

Hash类型的底层是hashtable,扩容时使用渐进式rehash。

为什么需要渐进式rehash?

问题:一次性rehash大字典会阻塞主线程

例如:100万key的字典扩容
├── 一次性迁移:可能阻塞几秒
└── 渐进式迁移:每次操作迁移一个桶,无感知

渐进式rehash流程

1. 创建新哈希表ht[1](2倍大小)
2. 维护索引计数器rehashidx = 0
3. 每次操作时:
   ├── 迁移ht[0]中rehashidx桶的所有节点到ht[1]
   ├── rehashidx++
   └── 如果ht[0]为空,释放ht[0],将ht[1]设为ht[0]

4. 迁移期间:
   ├── 写操作:只写ht[1]
   ├── 读操作:先查ht[0],再查ht[1]
   └── 删除操作:两个表都要删

图解:

初始状态:
ht[0]: [A, B, C, D, E]  rehashidx = 0
ht[1]: []

步骤1(一次操作后):
ht[0]: [   B, C, D, E]  rehashidx = 1
ht[1]: [A]

步骤2:
ht[0]: [      C, D, E]  rehashidx = 2
ht[1]: [A, B]

...

完成:
ht[0]: []  (释放)
ht[1]: [A, B, C, D, E]  → 变成新的ht[0]

2.4 各类型的编码转换条件

这是面试常问的细节题。

String编码

编码条件说明
int整数且可用long表示直接存数值,最省空间
embstr字符串且 ≤ 44字节SDS和对象头连续内存
raw字符串且 > 44字节SDS和对象头分开分配

Hash编码

# 同时满足用ziplist:
hash-max-ziplist-entries 512  # 元素数 ≤ 512
hash-max-ziplist-value 64     # 单值 ≤ 64字节

# 否则用hashtable

List编码(Redis 3.2+)

全部使用quicklist
= 多个ziplist组成的双向链表

配置:
list-max-ziplist-size -2  # 每个ziplist最大8KB
list-compress-depth 0     # 压缩深度(0不压缩)

Zset编码

# 同时满足用ziplist:
zset-max-ziplist-entries 128  # 元素数 ≤ 128
zset-max-ziplist-value 64     # 单值 ≤ 64字节

# 否则用skiplist + hashtable

第三章 持久化机制

3.1 RDB的fork与COW

这是持久化面试的必问题。

RDB持久化流程

1. 父进程fork子进程
   └── fork速度快,只复制页表

2. 子进程遍历数据,写入临时RDB文件
   └── 父进程继续处理请求

3. 父进程修改数据时,触发COW
   └── 复制修改的页,子进程读到的仍是旧数据

4. 子进程完成后,替换旧RDB文件

COW(Copy-On-Write)原理

初始状态(fork后):
父进程页表 ────┐
              ├──→ 物理页1 [数据A]
子进程页表 ────┘

父进程修改数据时:
1. 复制物理页1为物理页2
2. 父进程页表指向物理页2
3. 子进程页表仍指向物理页1

修改后:
父进程页表 ──→ 物理页2 [数据A']
子进程页表 ──→ 物理页1 [数据A]  ← 保持原数据

fork性能问题

问题:大内存实例fork可能阻塞

1GB内存 → fork约20ms
10GB内存 → fork约200ms

优化:
1. 关闭透明大页(THP)
   echo never > /sys/kernel/mm/transparent_hugepage/enabled

2. 减少RDB触发频率
   save 900 1  # 只保留这一条

3. 使用AOF或混合持久化

3.2 AOF的重写流程

为什么需要AOF重写?

问题:AOF文件会越来越大

原始命令:
INCR count  # count = 1
INCR count  # count = 2
INCR count  # count = 3
INCR count  # count = 4
INCR count  # count = 5

重写后:
SET count 5  # 一条命令搞定

AOF重写流程

1. fork子进程
   └── 子进程遍历数据库,生成新AOF

2. 父进程继续处理请求
   └── 新命令同时写入:
       ├── 旧AOF文件
       └── AOF重写缓冲区

3. 子进程完成后,通知父进程
   └── 父进程将重写缓冲区追加到新AOF

4. 原子替换旧AOF文件

图解:

              ┌──────────────┐
              │   父进程     │
              └──────┬───────┘
     fork           │
       ┌────────────┴────────────┐
       ▼                         ▼
┌──────────────┐         ┌──────────────────┐
│   子进程     │         │  继续处理请求    │
│ 生成新AOF    │         │                  │
└──────┬───────┘         │  同时写入:      │
       │                 │  ├── 旧AOF       │
       │                 │  └── 重写缓冲区  │
       ▼                 └────────┬─────────┘
  新AOF完成                       │
       │                          │
       ▼                          ▼
  追加重写缓冲区 ←───────────  缓冲区内容
       │
       ▼
  原子替换旧AOF

3.3 混合持久化

Redis 4.0引入,结合RDB和AOF的优点。

# 开启混合持久化
aof-use-rdb-preamble yes

混合持久化文件格式

┌─────────────────────────────┐
│        RDB格式数据           │  ← 文件开头是RDB二进制
│    (fork时刻的全量数据)      │
├─────────────────────────────┤
│      AOF格式增量命令         │  ← 后面是AOF增量命令
│  (fork后到重写完成的命令)   │
└─────────────────────────────┘

优势

恢复速度:RDB快(二进制直接加载)
数据安全:AOF好(最多丢1秒)
文件大小:比纯AOF小

3.4 生产环境最佳配置

# === RDB配置 ===
save 900 1           # 900秒内有1次修改就触发
save ""              # 或者完全关闭RDB

# === AOF配置 ===
appendonly yes                    # 开启AOF
appendfsync everysec              # 每秒刷盘(平衡性能和安全)
no-appendfsync-on-rewrite yes     # 重写期间不fsync
auto-aof-rewrite-percentage 100   # 文件增长100%触发重写
auto-aof-rewrite-min-size 64mb    # 最小64MB才触发

# === 混合持久化 ===
aof-use-rdb-preamble yes          # 开启混合持久化

第四章 高可用架构

4.1 主从复制流程

全量同步

1. 从节点发送:PSYNC ? -1
   └── ?:不知道主节点runid
   └── -1:没有offset

2. 主节点判断需要全量同步
   └── 返回:+FULLRESYNC <runid> <offset>

3. 主节点执行BGSAVE
   └── 同时缓存新命令到复制缓冲区

4. 发送RDB文件给从节点
   └── 从节点清空数据,加载RDB

5. 发送缓冲区命令
   └── 从节点执行,数据完全一致

增量同步

1. 从节点发送:PSYNC <runid> <offset>
   └── 携带之前记录的主节点信息

2. 主节点判断:
   ├── runid匹配
   └── offset在复制积压缓冲区范围内

3. 返回:+CONTINUE
   └── 只发送offset之后的命令

复制积压缓冲区

作用:避免断线后重新全量同步

配置:
repl-backlog-size 10mb  # 默认1MB,建议调大

计算:
缓冲区大小 >= 断线时间 × 写入速率
例如:断线5秒 × 2MB/秒 = 10MB

4.2 哨兵故障转移

完整流程

1. 哨兵定期PING主节点

2. 超时无响应 → 主观下线(SDOWN)
   └── 单个哨兵认为主节点下线

3. 询问其他哨兵 → 超过半数确认 → 客观下线(ODOWN)
   └── 真的下线了

4. 选举Leader哨兵(Raft算法)
   └── 由Leader执行故障转移

5. Leader选择新主节点
   ├── 过滤不健康节点
   ├── 优先级最高的(replica-priority)
   ├── 复制偏移量最大的
   └── runid最小的

6. 执行故障转移
   ├── 向新主发送:SLAVEOF NO ONE
   ├── 向其他从发送:SLAVEOF <新主IP> <新主Port>
   └── 更新配置,通知客户端

4.3 Cluster分片原理

哈希槽

共16384个槽(0~16383)

key → 槽位计算:
slot = CRC16(key) % 16384

槽位分配示例(3主节点):
├── 节点A:0~5460
├── 节点B:5461~10922
└── 节点C:10923~16383

请求路由

客户端请求流程:
1. 计算key的槽位
2. 查本地槽位映射表,找到节点
3. 发送请求到该节点
4. 如果槽位已迁移,节点返回:
   -MOVED <slot> <ip>:<port>
5. 客户端更新映射表,重试

槽位迁移中的请求:
-ASK <slot> <ip>:<port>
└── 临时重定向,不更新映射表

4.4 脑裂问题与解决

什么是脑裂?

场景:
├── 主节点与哨兵网络分区
├── 哨兵选举新主
├── 旧主仍在接受写入
├── 网络恢复后,旧主降为从
└── 旧主的写入数据丢失!

解决方案

# 主节点配置(Redis 5.0+)

# 至少1个从节点延迟小于10秒,才接受写入
min-replicas-to-write 1
min-replicas-max-lag 10

# 效果:
# 网络分区后,旧主无法写入(从节点不可达)
# 避免数据丢失

第五章 性能优化实战

5.1 大Key问题排查与处理

什么是大Key?

String:value > 10KB
Hash/Set/Zset/List:元素 > 5000个

排查方法

# 1. 扫描大Key(生产环境慎用)
redis-cli --bigkeys

# 2. 分析RDB文件(推荐)
rdb -c memory dump.rdb --bytes 10240 -f bigkeys.csv

# 3. 内存分析
MEMORY USAGE key

处理方案

# 方案1:拆分

# 原来:一个大Hash
user:1000 → {field1: val1, ..., field10000: val10000}

# 拆分后:分片
user:1000:0 → {field1: val1, ..., field100: val100}
user:1000:1 → {field101: val101, ..., field200: val200}
...

# 方案2:异步删除
# Redis 4.0+
UNLINK key  # 后台线程删除

# vs
DEL key     # 同步删除,阻塞主线程

5.2 热Key问题解决方案

什么是热Key?

单个Key的QPS远超平均值
例如:秒杀商品、热门新闻

解决方案

# 方案1:本地缓存
from cachetools import TTLCache

local_cache = TTLCache(maxsize=1000, ttl=1)  # 1秒过期

def get_hot_data(key):
    # 先查本地缓存
    if key in local_cache:
        return local_cache[key]

    # 查Redis
    data = redis.get(key)
    local_cache[key] = data
    return data

# 方案2:读写分离
# 热Key只读从节点

# 方案3:Key分片
# 原来:product:1000
# 分片:product:1000:0, product:1000:1, ...
# 随机选择一个读取

5.3 单线程模型与IO多路复用

为什么单线程还能高并发?

1. 纯内存操作
   └── 内存操作是ns级,不是瓶颈

2. 避免锁竞争
   └── 无锁,无上下文切换

3. IO多路复用(epoll)
   ├── 单线程监听多个socket
   ├── 有数据时才处理
   └── 避免阻塞等待

4. 高效数据结构
   └── SDS、跳表、压缩列表等

IO多路复用原理

传统模型(每个连接一个线程):
Thread1 ──→ Socket1 ──→ 阻塞等待...
Thread2 ──→ Socket2 ──→ 阻塞等待...
Thread3 ──→ Socket3 ──→ 阻塞等待...

问题:线程多,切换开销大

epoll模型(单线程监听所有连接):
                   ┌→ Socket1
EventLoop ────────→├→ Socket2
                   └→ Socket3

1. 注册socket到epoll
2. epoll_wait阻塞等待事件
3. 有事件时返回,处理对应socket
4. 继续epoll_wait

5.4 Redis 6.0多线程

多线程做了什么?

Redis 6.0的多线程:
├── 网络IO(读写socket):多线程
└── 命令执行:仍是单线程

为什么只用于IO?
├── IO是性能瓶颈(尤其是网络带宽)
├── 命令执行本身够快
└── 保持原子性,无需加锁

开启多线程

# redis.conf

# 开启IO多线程
io-threads 4

# 开启读的多线程(写默认开启)
io-threads-do-reads yes

本章小结

Redis面试的核心考点:

考点必问题面试官关注
缓存问题穿透/击穿/雪崩区别能否说清原理和生产方案
数据结构跳表/SDS/渐进式rehash是否理解设计思想
持久化RDB的fork、AOF重写知道COW和混合持久化
高可用哨兵故障转移流程知道脑裂问题
分布式锁完整实现续期、Redlock

面试回答技巧:

  1. 先答是什么:30秒给出定义
  2. 再答为什么:解释设计原因
  3. 举个例子:用场景说明
  4. 说出坑点:展示生产经验

下期预告

下一篇:《Java并发编程:synchronized锁升级全过程,从偏向锁到重量级锁的JVM底层揭秘》

将揭秘:

  • 锁升级的完整流程
  • 偏向锁/轻量级锁/重量级锁的区别
  • CAS和AQS的原理
  • 线程池参数配置的最佳实践

💬 互动话题

评论区聊聊:你们生产环境遇到过哪些Redis相关的坑?