Redis 八股详解

文章目录


一、数据类型

总览

数据结构 底层实现 典型使用场景
String SDS动态字符串 缓存、计数器、分布式锁
Hash ziplist / hashtable 存对象、购物车
List quicklist 消息队列、最新列表
Set hashtable / intset 去重、共同好友、抽奖
ZSet ziplist / skiplist 排行榜、延迟队列
BitMap String扩展 签到、用户在线状态
HyperLogLog 概率算法 UV统计
GEO ZSet(GeoHash 编码) 附近的人、打车计算距离
Stream Radix Tree(基数树) + listpack 消息队列(支持多消费族、消息持久化、ACK)

五大基本类型

String

最大 512 MB

  1. 内部实现:int + SDS

    • SDS 不仅可以保存文本数据,还可以保存二进制数据,如图片、音频等
    • SDS 获取字符串长度的时间复杂度是 O(1),用 len 属性
    • Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出

    字符串对象的内部编码(encoding)有 3 种 :int、raw和 embstr,可以把 Redis 存储数据想象成搬家包装

    1. int(极简装),整数类型直接用 redisObject 里的指针位置存储,省略 SDS
    2. embstr(一体化礼盒),小件物品,如 44 字节内的短字符串(Redis 5.0),使用 redisObject + SDS 存储并放在一块连续的内存中
    3. raw(普通快递),大件,如长字符串,使用 SDS 存储并且用 redisObject 记录存储位置,找的时候先去后者找位置再顺着位置找物品

    embstr 实际上是只读的,一旦修改会暴力升级为 raw,因为其设计初衷就是为了极致的查询性能,分配的是固定大小的连续空间,一旦修改就有可能装不下,必须重新分配内存,即写时升级

  2. 常用命令

    redis 复制代码
    # 设置 key-value 类型的值(可带过期时间)
    SET name abc [EX seconds]
    
    # 根据 key 获得对应的 value
    GET name
    
    # 原子自增 +1
    INCR key     
    
    # 原子自增 +N
    INCRBY key 10
    
    # 不存在才设置(not exists)(分布式锁的基础)
    SETNX name abc
  3. 应用场景

    复制代码
    # ① 缓存用户信息(JSON序列化存入)
    SET user:1001 '{"name":"xmon","age":21}' EX 3600	# 直接缓存整个对象的JSON
    # 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值
    MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20
    
    # ② 计数器(文章阅读数、点赞数)
    INCR article:read:8888
    # 原子操作,不用担心并发问题
    
    # ③ 分布式锁
    SET lock:order:1001 uuid NX EX 30
    # NX = 不存在才设置,保证只有一个人能拿到锁
    # EX = 自动过期,防止死锁
    # 释放锁时,先比较 uuid 是否相等,避免锁的误释放
    if redis.call("get",KEYS[1]) == ARGV[1] then
    	return redis.call("del" ,KEYS[1])
    else
    	return 0
    end

List

有序、可重复的链表,支持两端操作,按照插入顺序排序

  1. 内部实现:双向链表或压缩列表

    • 元素小于 512 个,值小于 64 字节,使用压缩列表
    • 否则使用双向链表

    在 Redis 3.2 之后,List 只由 quicklist 实现,替代上面两者,从 Redis7.0 +,quicklist 节点内部用来保存元素的压缩列表也被 listpack 替代

    现在的 Quicklist 是一个双向链表,但每一个节点都是一个 Listpack,保证了插入删除的效率并且极大压缩了空间

  2. 常用命令

    复制代码
    LPUSH list a b c    # 从左边推入:c b a
    RPUSH list x        # 从右边推入:c b a x
    LPOP list           # 从左边弹出
    RPOP list           # 从右边弹出
    
    LRANGE list 0 -1    # 查看全部元素
    LLEN list           # 长度
    BLPOP list 10       # 阻塞式弹出,最多等10秒
  3. 应用场景

    复制代码
    # ① 最新消息列表(只保留最新100条)
    LPUSH news:feed article:9999
    LTRIM news:feed 0 99   # 只保留前100条
    
    # ② 简单消息队列
    # 生产者
    RPUSH queue:email task_001
    # 消费者(BLPOP 没消息时阻塞等待,不用轮询)
    BLPOP queue:email 0

    消息队列存取消息时的三个需求:消息保序(顺序性)、处理重复的消息(幂等性)和保证消息的可靠性(不丢失)

    1. 如何满足消息保序需求?

      Redis List 本身就是天然保序的,实现时保证单进单出,发送方使用 LPUSH 将消息按顺序放入队列,接收方使用 RPOP/BRPOP 按顺序取出

      并发问题:如果有多个消费者同时从 List 里拿消息,由于执行速度不同顺序会乱

      解决:将需要保序的消息通过 Hash 取模,发往同一个特定的 List,并保证一个 List 只由一个消费者处理

    2. 如何处理重复的消息?

      核心思想:做操作前先检查

      解决方案:全局唯一 ID + 去重表

      1. 生成 ID:生产者发送消息时,给每条消息报一个全局唯一ID(雪花算法ID/订单号)
      2. 去重判断:消费者拿到消息时,先去 Redis 的 SET 或 String 查一下这个 ID 是否存在
        • 没查到:新消息,处理业务,将 ID 存入 Redis
        • 查到:重复消息,直接丢弃,不执行业务逻辑
    3. 如何保证消息可靠性?

      消费者拿走消息后突然宕机,消息在内存中消失了

      解决:使用 RPOPLPUSH 指令(备份队列模式),让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存

    ⚠️ List 作消息队列有缺陷:不支持重复消费、无 ACK 机制。 生产环境消息队列建议用 Stream 或 RocketMQ/Kafka。

Hash

压缩列表或哈希表,适合存结构化对象

  1. 内部实现:压缩列表或哈希表

    • 元素个数小于 512 个且所有值小于 64 字节,使用压缩列表
    • 否则使用哈希表

    Redis 7.0 ,压缩列表已废弃,交由 Listpack 实现

  2. 常用命令

    复制代码
    HSET user:1001 name xmon age 21 city beijing	# 存储一个哈希表key的键值
    HGET user:1001 name          # 取单个字段
    HMGET user:1001 name age     # 取多个字段
    HGETALL user:1001            # 取全部
    HDEL user:1001 name age			 # 删除字段
    HLEN user:1001 							 # 返回filed数量
    HINCRBY user:1001 age 1      # 某字段自增
  3. 应用场景

    复制代码
    # ① 缓存对象
    # 存储一个哈希表uid:1的键值
    HMSET uid:1 name Tom age 152
    # 存储一个哈希表uid:2的键值
    HMSET uid:2 name Jerry age 13
    # 获取哈希表用户id为1中所有的键值
    HGETALL uid:1
    
    # ② 购物车:key=cart:uid,field=商品id,value=数量
    HSET cart:1001 item:5001 2    # 商品5001加2个
    HINCRBY cart:1001 item:5001 1 # 再加1个
    HGETALL cart:1001             # 获取完整购物车
    HDEL cart:1001 item:5001      # 删除某商品

和 String 存 JSON 的对比:

Hash String+JSON
修改单字段 ✅ 直接 HSET ❌ 需要整个反序列化再序列化
内存 字段少时用 ziplist,省内存 相对多
适合场景 字段经常单独更新 整存整取

存储对象时,一般用String + Json,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储

Set

无序、不重复的集合,支持集合运算

  1. 内部实现:哈希表或整数集合

    • 如果元素为整数且个数小于 512,使用整数集合
    • 否则使用哈希表
  2. 常用命令

    复制代码
    SADD set a b c      # 添加元素
    SREM set a          # 删除元素
    SISMEMBER set b     # 判断是否存在
    SMEMBERS set        # 获取所有元素
    SCARD set           # 元素数量
    
    # 集合运算
    SINTER set1 set2    # 交集(共同好友)
    SUNION set1 set2    # 并集
    SDIFF set1 set2     # 差集
    
    SRANDMEMBER set 3   # 随机取3个(不删除)
    SPOP set 1          # 随机弹出1个(删除)
  3. 应用场景

    复制代码
    # ① 共同好友
    SADD friends:A user1 user2 user3
    SADD friends:B user2 user3 user4
    SINTER friends:A friends:B   # → user2, user3
    
    # ② 抽奖(不重复中奖用SPOP,可重复用SRANDMEMBER)
    SADD lottery uid1 uid2 uid3 uid4 uid5
    SPOP lottery 3   # 随机抽3个中奖用户
    
    # ③ 文章标签
    SADD article:8888:tags java backend redis
    SMEMBERS article:8888:tags

ZSet(Sorted Set)

有序、不重复 ,每个元素带一个 score 分数,按 score 自动排序,最强大的数据结构

  1. 内部实现:压缩列表或跳表

    • 有序集合的元素小于128个,且值小于64字节,使用压缩列表
    • 否则使用条表

    Redis 7.0 压缩列表 -> Listpack

  2. 常用命令

    复制代码
    ZADD rank 100 user1 200 user2 150 user3
    ZRANGE rank 0 -1 WITHSCORES      # 升序
    ZREVRANGE rank 0 -1 WITHSCORES   # 降序
    ZRANK rank user1                  # 升序排名
    ZREVRANK rank user1               # 降序排名(排行榜用这个)
    ZSCORE rank user1                 # 获取分数
    ZINCRBY rank 50 user1             # 加分
    ZRANGEBYSCORE rank 100 200        # 按分数范围查
  3. 应用场景

    复制代码
    # ① 实时排行榜
    ZINCRBY game:score 10 user1001    # 用户得了10分
    ZREVRANGE game:score 0 9 WITHSCORES  # 取前10名
    
    # ② 延迟队列(score 存执行时间戳)
    ZADD delay:queue 1735000000 task_001  # 定时执行的任务
    # 定时轮询:取 score <= 当前时间 的任务执行
    ZRANGEBYSCORE delay:queue 0 [current_time]

特殊类型

BitMap

把每个 bit 位当作一个 boolean 用,极省内存 ,适合一些数据量大且使用二值统计的场景

  1. 内部实现:String

  2. 常用命令

    复制代码
    SETBIT key offset value	# 设置值,value只能为0/1
    GETBIT key offset				# 获取值
    BITCOUNT key start end 	# 获取指定范围内为1的个数
  3. 应用场景

    复制代码
    # ① 签到统计,1亿用户的签到,只需 ~12MB
    SETBIT sign:user:1001 20250101 1   # 1号签到
    GETBIT sign:user:1001 20250101     # 查询某天
    BITCOUNT sign:user:1001            # 统计签到总天数
    
    # ② 连续签到用户总数
    # 与操作:统计三天连续打卡的用户数
    BITOP AND sign:user 20250101 20250102 20250103
    # 统计 bit 位= 1 的个数
    BITCOUNT sign:user

HyperLogLog

提供不精确的去重计数 :用极小内存(固定 12KB )估算集合的基数(不重复元素个数),误差约 0.81%

  1. 常用命令

    复制代码
    PFADD uv:page:home user1 user2 user3   # 添加元素
    PFCOUNT uv:page:home                    # 估算UV数量
    PFMERGE uv:total uv:page:home uv:page:about  # 合并
  2. 应用场景

    复制代码
    # 每次用户访问,记录 uid
    PFADD uv:2025:0101 user_001
    PFADD uv:2025:0101 user_002
    PFADD uv:2025:0101 user_001  # 重复访问,不计入
    PFCOUNT uv:2025:0101          # → 2

UV 不需要精确值,允许少量误差,HyperLogLog 是最优解 如果用 Set 存 uid,1000万用户要几百MB;HyperLogLog 永远只用 12KB

GEO

  1. 内部实现:没有设计新的底层数据结构,而是直接使用ZSet 集合

  2. 应用场景

    复制代码
    # 滴滴打车
    # ID号为33的车辆的当前经纬度位置存入 GEO 集合中
    GEOADD cars: locations 116.034579 39.03045233		
    # 查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用
    GEORADIUS cars: locations 116.05457939.030452 5 km ASC COUNT10

Stream

Redis5.0 新增,是专门为消息队列设计的数据类型,它支持消息的持久化、支持自动生成全局唯一ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠

  1. 常用命令

    • XADD:向流中添加消息(自动生成唯一 ID)

    • XLEN:查询消息长度

    • XREAD:直接读取消息(支持阻塞模式)

    • XDEL:根据 ID 删除消息

    • DEL:删除整个 stream

    • XGROUP CREATE:创建消费组

    • XREADGROUP:组内成员读取消息(读取后消息进入 PEL 列表)

    • XPENDING 和 XACK

      • XPENDING:查询每个消费组内所有消费者「已读取、但尚未确认」的消息

      • XACK:确认消息处理完成,将其从 PEL(未确认列表)中移除

  2. 应用场景

    复制代码
    # 消息队列
    XADD mymq * name xiaolin	# 生产者通过 XADD 命令插入一条消息
    XREAD STREAMS mymq 1654254953807-0	# 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息
    XREAD BLOCK 10000 STREAMS mymq $	# 阻塞读,命令最后的"$"符号表示读取最新的消息
    
    # 特有功能
    # 1.XGROUP 创建消费组
    XGROUP CREATE mymq group1 0-0	# 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取
    XREADGROUP GROUP group1 consumer1 STREAMS mymq >	# 命令最后的参数">",表示从第一条尚未被消费的消息开始读取

    基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处

    理完的消息?

    消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息

    小结

    • 消息保序:XADD/XREAD
    • 阻塞读取:XREAD block
    • 重复消息处理:Stream 在使用 XADD 命令,会自动生成全局唯一ID;
    • 消息可靠性:内部使用 PENDING List 自动保存消息,使用 XPENDING命令查看消费组已经读取但是
      未被确认的消息,消费者使用 XACK确认消息;
    • 支持消费组形式消费数据

    Redis 基于 Stream 消息队列与专业的消息队列有哪些差距?

    1、Redis Stream 消息会丢失吗?

    Redis 在队列中间件环节无法保证消息不丢。像 RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。

    2、Redis Stream 消息可堆积吗?

    • 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的
    • 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧

底层数据结构

指的并不是 String, List, Hash, Set, ZSet 等数据类型(数据的保存形式),这些对象底层实现就用到了数据结构,它们的对应关系如下

总览

键值对数据库如何实现?

实现:一个全局的哈希表(Dict)加上多态的底层数据结构(redisObject)
核心机制:
  1. 全局哈希表 Global Hash Table

    每个数据库的底层都是一个 redisDb 结构体,其中有最重要的 dict字典结构

    • 全局哈希表由一个一维数组组成,数组的每个元素指向一个哈希桶 Bucket
    • 当执行 GET/SET 时,Redis 会计算哈希值,然后对数组长度取模定位到对应的哈希桶(O(1))
  2. 万物皆可 redisObject

    全局哈希表中,Key 为固定字符串,由 SDS 实现,目的是解决获取长度为 O(N) + 杜绝缓冲区溢出 + 实现二进制安全,Value 则被统一包装成 redisObject

    c 复制代码
    typedef struct redisObject {
        unsigned type:4;       // 逻辑类型:String, List, Hash, Set, Zset 等
        unsigned encoding:4;   // 物理编码:如 int, embstr, raw, hashtable, skiplist 等
    		// 通过 type 和 encoding 的解藕,Redis 可以在不同场景下无缝切换底层结构
        unsigned lru:LRU_BITS; // 记录最后一次被访问的时间(用于内存淘汰策略)
        int refcount;          // 引用计数(用于内存回收)
        void *ptr;             // 指向底层实际数据结构的指针
    } robj;
  3. 哈希冲突与链式哈希 Chained Hashing

    链地址法解决哈希冲突:当多个 Key 被映射到同一个哈希桶时,它们会被组织成一个单向链表,新插入的节点采用头插法,因为 Redis 认为新插入的数据更容易被再次访问

  4. 渐进式 Rehash Progressive Rehash

    数据不断写入 -> 哈希冲突增多 -> 链表加长 -> 查询性能下降 -> 哈希表扩容(Rehash)

    考虑到一次性完成数据迁移引起线程阻塞问题,因此设计Rehash:

    1. 双表机制:dict结构包含两个哈希表数组 ht[0](default) & ht[1]
    2. 分配空间:扩容时,先为ht[1]分配两倍于ht[0]的空间
    3. 分批迁移:维护一个索引计数器rehashidx,在 Rehash 期间,每次处理 CRUD 时 Redis 会顺手将 ht[0] 在 rehashidx 索引上的所有键值对迁移到 ht[1] ,然后 rehashidx ++
    4. 无缝切换:当数据迁移完毕后,将 ht[1] 设置为 ht[0],并释放原来的 ht[0]

    Rehash 期间,先查 ht[0] 再查 ht[1],SET 操作一律写入ht[1],保证 ht[0] 只减不增

SDS

C 语言字符串缺陷
  1. 获取字符串长度时间复杂度为 O(N)
  2. 字符串不能含有 "\0" 字符,因此其不能保存像图片、音频、视频文化这样的二进制数据
  3. 字符串不会记录自身缓冲区大小,操作函数不高效不安全,有缓冲区溢出的风险,可能导致程序终止
数据结构
c 复制代码
struct sdshdr {
    int len;      // 已使用长度
    int alloc;    // 分配的总长度
    char flags;   // 类型标识
    char buf[];   // 实际数据
};
  • len,记录字符串长度:时间复杂度 O(1)(使得字符串能含有 \0 字符,能保存图片、音频等)

  • alloc,分配给字符数组的空间长度 :修改字符串-> alloc - Len 计算剩余空间 -> 不满足,扩容(解决缓冲区溢出和)

    复制代码
    修改后长度 < 1MB  → 额外分配同等大小(len * 2)
    修改后长度 ≥ 1MB  → 额外分配 1MB

    惰性空间释放:字符串缩短时不立即释放内存,len 减小但 alloc 不变,下次增长直接用

  • flags,用来表示不同类型的SDS :5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、

    sdshdr32 和 sdshdr64,它们数据结构中的 len 和 alloc 成员变量的数据类型不同,目的是为了能灵活保存不同大小的字符串,从而有效节省内存空间

  • buf\[\],字节数组,保存实际数据:不仅可以保存字符串,也可以保存二进制数据

链表 list

Redis 实现了一个经典的双向链表

结构
c 复制代码
// 节点
typedef struct listNode {
    struct listNode *prev;	// 前置节点
    struct listNode *next;	// 后置节点
    void *value;        		// 节点的值,void* 可以存任意类型
} listNode;

// 链表
typedef struct list {
    listNode *head;							// 链表头节点
    listNode *tail;							// 链表尾节点
    unsigned long len;					// 链表节点数量
    void *(*dup)(void *ptr);    // 复制函数
    void (*free)(void *ptr);    // 释放函数
    int (*match)(void *ptr, void *key);  // 比较函数
} list;
优点:
  • 双向:每个节点有 prev/next,方便从两端操作
  • 带头尾指针:获取头尾节点 O(1)
  • 带长度:获取长度 O(1)
  • 无环:头的 prev 和尾的 next 都是 NULL
  • 多态 :通过 void* + 函数指针支持存储任意类型
缺点(也是为什么后来被取代的原因)
  • 每个节点单独分配内存,内存不连续,碎片多,缓存命中率低,CPU 缓存不友好
  • 每个节点有 prev/next 两个指针,额外内存开销大

Redis 3.2 之后 List 类型的底层已经不用 linkedlist,改用 quicklist

压缩链表 ziplist

为了节省内存设计的紧凑型连续内存结构,没有指针,所有数据挨在一起

内存布局
  • zlbytes:整个 ziplist 占用字节数
  • zltail :尾部节点距离起始地址有多少字节,即最后一个 entry 的偏移量(方便从尾部操作)
  • zllen :entry 数量
  • zlend :结束标志,固定 0xFF(十进制255)
每个 entry 的结构
  • previous_entry_length:前一个entry的长度(用于从后往前遍历)
  • encoding:数据类型(字符串和整数)和长度
  • data:实际数据(整数或字节数组)

插入数据时,会根据数据大小和类型会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息(节省内存)

优点
  • 内存连续紧凑,没有指针开销
  • 小整数用特殊 encoding 直接编码,极省内存
致命缺点:连锁更新

ziplist 中,每个节点都会记录前一个节点的长度(prevlen):

  • 如果前一个节点长度 < 254 字节 ,prevlen 占用 1 字节

  • 如果前一个节点长度 ≥ 254 字节 ,prevlen 占用 5 字节

    假设有 N 个 entry,每个 previous_entry_length 都是 1 字节(前一个entry长度<254)且长度都在 250-253 字节之间

    现在在头部插入一个大 entry(长度≥254)
    → 第1个entry的 previous_entry_length 需要从1字节扩为5字节
    → 第1个entry变大了,第2个entry的 previous_entry_length 也要扩
    → 第2个entry变大了,第3个entry也要扩...
    → 最坏情况 O(n) 次内存重分配!

这就是连锁更新(cascade update),是 ziplist 被 listpack 取代的直接原因

哈希表 hash

Redis 的哈希表实现,也是 HashMap 的经典实现思路。

数据结构
c 复制代码
typedef struct dict {
    dictht ht[2];   // 两个哈希表!用于渐进式 rehash
    int rehashidx;  // -1 表示未在 rehash
} dict;

typedef struct dictht {
    dictEntry **table;    // 数组,每个槽是链表头
    unsigned long size;   // 数组大小(2的幂次)
    unsigned long sizemask; // size-1,用于取模
    unsigned long used;   // 已有节点数
} dictht;

typedef struct dictEntry {
  void *key;	// 键值对中的键
  union {			// 键值对中的值
    void *val;
    uint64_t u64;
    int64_t s64;
    double d;
  } v;				
  struct dictEntry *next;	// 指向下一个哈希表节点,形成链表
} dictEntry;

哈希表的优点在于,它能以 O(1) 的速度快速查询速度(将 key 通过 hash 函数计算),但随着数据的不断增多,哈希冲突的可能性也会越高

哈希冲突

哈希表是一个数组,数组中每一个元素就是一个哈希桶

**什么是哈希冲突?**当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突

有一个可以存放8个哈希桶的哈希表,key1 经过哈希函数计算后并取模,结果值为 1,那么就对应哈希桶1,类似的,key9和 key10分别对应哈希桶 1 和桶 6,此时,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突

链地址法 :冲突时新节点插入链表头部(O(1))

复制代码
table[0] → entry_A → entry_B → NULL
table[1] → NULL
table[2] → entry_C → NULL

链式哈希的局限性也很明显,随着链表长度增加,查询这一位置上的数据的耗时就会增加(O(n)),此时需要对哈希表的大小进行扩展(rehash)。

渐进式 rehash

负载因子 = 哈希表已保存节点数量 / 哈希表大小(used/size)

为什么需要 rehash? 负载因子(load factor)过高时查询退化,需要扩容。

为什么是渐进式? 如果一次性把几百万个 key 全部迁移,Redis 会卡住很长时间,影响服务。

渐进式 rehash 过程:

复制代码
1. 分配 ht[1],大小为 ht[0] 的 2 倍
2. 设置 rehashidx = 0,表示从第 0 个桶开始迁移
3. 每次对 dict 的增删改查操作,顺带迁移 rehashidx 对应桶的数据
4. 同时查询时,ht[0] 和 ht[1] 都查
   新写入只写 ht[1]
5. rehashidx 逐渐递增,直到 ht[0] 全部迁移完
6. ht[0] = ht[1],rehashidx = -1,完成
时间线:
[操作1] 迁移 bucket[0],rehashidx=1
[操作2] 迁移 bucket[1],rehashidx=2
...
[操作n] 迁移完所有桶,rehash 结束

渐进式的代价: rehash 期间同时存在两张表,内存占用翻倍。

触发条件

  • load factor >= 1 && 未执行 RDB 快照(bgsave)或 AOF 重写(bgrewriteaof)
  • load factor >= 5

整数集合 inset

Set 里全是整数且数量少时使用,比 hashtable 省内存。

结构
c 复制代码
typedef struct intset {
    uint32_t encoding;  // 编码方式:int16 / int32 / int64
    uint32_t length;    // 元素数量
    int8_t contents[];  // 元素数组(有序!)
} intset;
特性
  • 元素有序存储 ,查找用二分查找 O(log n)
  • 内存连续紧凑,无指针开销
升级机制

当新插入的整数超出当前 encoding 范围时,自动升级:

复制代码
原来全是 int16(−32768~32767)
插入 100000(超出int16范围)
→ 整体升级为 int32,所有元素重新编码
→ 升级是不可逆的,不会因为删了大数就降级

升级的好处: 平时用小类型省内存,需要时才扩,灵活节约。

跳表 zskiplist

ZSet 的核心底层结构,是有序链表的多级索引版本。

结构 & 查找
复制代码
level 3:  1 ──────────────────── 50 ──────────────── 100
level 2:  1 ──────── 20 ──────── 50 ──────── 80 ──── 100
level 1:  1 ── 10 ── 20 ── 30 ── 50 ── 60 ── 80 ──── 100

图中头节点有LO~L2三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:

  • L0层级共有 5个节点,分别是节点1、2、3、4、5;

  • L1 层级共有3个节点,分别是节点 2、3、5;

  • L2 层级只有1个节点,也就是节点3。

    查找节点4,从最高层开始:
    L2: 3,4>3,下降到 L1
    L1: 3 → 5,5>4,下降到 L0
    L0: 3 → 4,找到!
    时间复杂度期望 O(log n)。

节点结构
c 复制代码
typedef struct zskiplistNode {
    sds ele;           // 元素值
    double score;      // 分数,用于排序
    struct zskiplistNode *backward;  // 后退指针(只有第1层有)
  	// 节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;  // 跨越的节点数(用于计算排名)
    } level[];
} zskiplistNode;

span(跨度)的作用: 每个指针记录跨越了多少节点,累加即可得到节点的排名,ZRANK 因此是 O(log n)。
层高怎么决定?:随机决定,每个节点的层高 = 1 的概率是 1/2,每多一层概率减半(最高 32 层)。这保证了整体的概率平衡,期望效果接近平衡树。

跳表节点层数设置

跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN),那么如何维持这个比例?

跳表在创建节点的时候,随机生成每个节点的层数:

创建节点时,会生成范围为[0-1]的一个随机数,如果这个随机数小于0.25,那么层数就增加1层,然后继续生成下一个随机数,直到随机数的结果大于 0.25结束,最终确定该节点的层数。

如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点

为什么用跳表不用平衡树(红黑树)?
维度 跳表 红黑树
底层结构 多级有序链表 自平衡二叉搜索树
逻辑核心 随机性(抛硬币决定层数) 确定性(旋转和变色)
范围查询能力 极强,天然支持顺序遍历 一般,需要中序遍历
代码复杂度 低(几百行搞定) 高(需要处理复杂的删除情况)
内存开销 略高(平均每个节点 1.33 ∼ 2 1.33 \sim 2 1.33∼2 个指针) 略低(每个节点固定 3 指针 + 颜色位)
  • 实现简单:跳表代码非常直观,更容易调试和维护,而平衡树的插入和删除操作可能引发子树的调整,逻辑复杂。对于注重底层性能和代码质量的项目来说,简单往往意味着更少的 Bug。
  • 范围查询效率:在跳表中,找到范围的起点后,只需在最底层链表顺序遍历即可。红黑树做范围查询则需要进行繁琐的中序遍历。
  • 内存占用灵活:平衡树每个节点包含 2个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数p 的大小。如果像 Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

quicklist

linkedlist + ziplist 的结合体,List 类型的实际底层实现(Redis 3.2+)。

内存结构
c 复制代码
typedef struct quicklist {
  quicklistNode *head;	// quicklist 的链表头
  quicklistNode *tail;	// quicklist 的链表尾
  unsigned long count;	// 所有压缩列表中的总元素个数
  unsigned long len;		// quicklistNodes 的个数
} quicklist;

typedef struct quicklistNode {
  struct quicklistNode *prev;		// 前一个 quicklistNode
  struct quicklistNode *next;		// 后一个 quicklistNode
  unsigned char *zl;						// quicklistNode 指向的压缩列表
  unsigned int sz;							// 压缩列表的的字节大小
  unsigned int count : 16;			// ziplist 中的元素个数
} quicklistNode;
设计思路
复制代码
linkedlist:内存分散,指针开销大
ziplist:内存紧凑,但连锁更新风险大、不能太长

折中方案:
用链表把多个 ziplist 串起来
每个链表节点是一个 ziplist(控制在一定大小内)
  • 每个 ziplist 节点最多存多少个 entry

    list-max-ziplist-size 128 # 默认128个

  • 两端各压缩几个节点(0=不压缩,中间节点访问少,压缩省内存)

    list-compress-depth 0

优点
  • 链表部分解决了 ziplist 不能太长(连锁更新)的问题
  • ziplist 部分解决了 linkedlist 内存分散的问题
  • 两端操作 O(1),整体内存比 linkedlist 小很多

listpack

Redis 5.0 引入,ziplist 的改进版,目标是彻底解决连锁更新问题。

对比 ziplist 的改动

ziplist 的 entry:

复制代码
| previous_entry_length | encoding | data |
  ↑ 存前一个entry的长度,是连锁更新的根源

listpack 的 entry:

复制代码
| encoding | data | element-total-len |
                     ↑ 存的是当前entry自己的长度

改动的意义: 每个 entry 只记录自己的长度,和前一个 entry 完全解耦。 修改任意 entry,不会影响其他 entry,彻底消除连锁更新。

从后往前遍历:读当前 entry 尾部的 element-total-len,直接跳到前一个 entry 的起始位置。

现状:Redis 7.0 之后 ziplist 已全面被 listpack 替代:

  • Hash、ZSet 的小数据底层:ziplist → listpack
  • quicklist 的每个节点:ziplist → listpack

总结

结构 内存 查询 核心问题 现状
linkedlist 大(指针开销) O(n) 内存碎片 已废弃
ziplist 小(连续) O(n) 连锁更新 被listpack替代
hashtable 中 O(1) rehash期间内存翻倍 在用
skiplist 中 O(log n) 实现略复杂 在用
intset 最小 O(log n) 只能存整数 在用
quicklist 中 O(n) --- 在用(List底层)
listpack 小(连续) O(n) --- 当前最新

面试

Q:Redis 数据类型以及使用场景分别是什么?

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2版新增)、HyperLogLog (2.8版新增)、GEO(3.2版新增)、Stream(5.0版新增)。

Redis 五种数据类型的应用场景:

  • String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session信息等。
  • List 类型的应用场景:消息队列、最新动态/时间轴(用户最新的 10 条浏览记录)等
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。形式消费数据)等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生
    成全局唯一消息ID,支持以消费组形式消费数据。

Q:五种常见的 Redis 数据类型如何实现?

  1. String

    String 类型的底层的数据结构实现主要是SDS(简单动态字符串)。SDS和我们认识的C字符串不太一样,之所以没有使用C语言的字符串表示,因为 SDS 相比于 C的原生字符串:

    • SDS 不仅可以保存文本数据,还可以保存二进制数据。
    • SDS 获取字符串长度的时间复杂度是 O(1) 。
    • Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。
  2. List

    List 类型的底层数据结构是由双向链表或压缩列表实现的:

    • 如果列表的元素个数小于 512个(默认值,可由 list-max-ziplist-entries 配置),且列表每个元素的值都小于64字节(默认值,可由 list-max-ziplist-value 配置),使用压缩列表;
    • 如果列表的元素不满足上面的条件,使用双向链表;

    Redis 3.2 +,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

  3. Hash

    Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

    • 如果哈希类型元素个数小于 512个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于64 字
      节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作 Hash 类型的底层数
      据结构;
    • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。

    在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

  4. Set

    Set 类型的底层数据结构是由哈希表或整数集合实现的:

    • 如果集合中的元素都是整数且元素个数小于 512(默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
    • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
  5. ZSet

    Zset 类型的底层数据结构是由压缩列表或跳表实现的:

    • 如果有序集合的元素个数小于128个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为
      Zset 类型的底层数据结构;
    • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

    在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由listpack 数据结构来实现了。

二、持久化

为什么需要持久化?

Redis 数据全在内存,进程崩溃或服务器重启,数据全丢。持久化就是把内存数据写到磁盘,重启后能恢复。

Redis 提供三种方案:

复制代码
持久化方案
├── RDB(快照)
├── AOF(日志)
└── RDB + AOF 混合(推荐)

RDB(Redis Database Backup)

把某一时刻内存中全量数据 以二进制快照的形式写入磁盘(全量快照 ),文件名默认 dump.rdb。

触发方式
  1. 手动触发

    bash 复制代码
    SAVE      # 同步,直接在主线程执行,期间阻塞所有命令(生产不用)
    BGSAVE    # 异步,fork 子进程写 RDB,主线程继续服务
  2. 自动触发(配置文件)

    bash 复制代码
    # 满足任意一条就触发 BGSAVE
    save 900 1      # 900秒内有 1 次写操作
    save 300 10     # 300秒内有 10 次写操作
    save 60 10000   # 60秒内有 10000 次写操作
  3. 其他自动触发场景

    • 执行 SHUTDOWN 命令时

    • 主从复制时,主节点自动 BGSAVE 发给从节点

BGSAVE 核心原理:fork + COW
复制代码
主进程
  │
  ├── fork() ──→ 子进程(复制主进程的页表,极快)
  │                  │
  │                  └── 遍历内存,写 RDB 文件
  │
  └── 继续处理客户端请求

Copy-On-Write(写时复制):

fork 出来的子进程和主进程共享同一块物理内存,并不是真的复制所有数据。

复制代码
fork 后:
  主进程页表 ──┐
               ├──→ 同一块物理内存
  子进程页表 ──┘

主进程收到写请求,要修改某个 key:
  → 操作系统把该内存页复制一份
  → 主进程修改自己的副本
  → 子进程继续读原始页,写 RDB

最终子进程写的是 fork 那一刻的内存快照

COW 的代价: 如果 fork 期间写操作非常多,大量内存页被复制,内存占用可能接近 2 倍,需要注意预留内存。

RDB 文件格式
复制代码
| REDIS魔数 | 版本号 | 数据库数据 | EOF标志 | CRC64校验 |

重启时 Redis 读取 RDB 文件,把数据全量加载回内存。

优缺点

优点:

  • 文件紧凑,体积小,恢复速度快
  • BGSAVE 对主线程影响小
  • 适合全量备份和灾难恢复

缺点:

  • 两次快照之间的数据会丢失(最多丢几分钟数据)
  • fork 子进程本身有开销,数据量大时 fork 耗时可能达到秒级,期间主线程卡顿

AOF(Append Only File)

把每条写命令 以文本形式追加到 AOF 文件(默认 appendonly.aof),恢复时重放所有命令。

bash 复制代码
# 开启 AOF
appendonly yes
AOF 写入流程
复制代码
客户端写命令
    ↓
执行命令(先写内存)
    ↓
写入 AOF 缓冲区(aof_buf)
    ↓
根据刷盘策略 → 写入磁盘

注意:Redis 先执行命令,再写 AOF(和 MySQL WAL 先写日志相反) 好处:不会因为命令语法错误污染 AOF;坏处:执行后崩溃来不及写日志,会丢当前命令

三种刷盘策略
bash 复制代码
appendfsync always    # 每条命令都 fsync → 最安全,性能最差
appendfsync everysec  # 每秒 fsync 一次 → 最多丢1秒数据(推荐)
appendfsync no        # 由操作系统决定何时 fsync → 最快,最不安全
策略 丢数据风险 性能 适用场景
always 几乎不丢 最差 金融,不允许丢数据
everysec 最多丢1秒 好 绝大多数业务
no 可能丢较多 最好 可以接受丢数据

AOF 重写(Rewrite)

Q: AOF 只追加,文件越来越大。同一个 key 被修改100次,AOF 里有100条记录,但实际只需要最后一条。

A:AOF 重写 ------ 对当前内存数据生成等价的最小命令集,替换旧 AOF 文件。

bash 复制代码
# 手动触发
BGREWRITEAOF

# 自动触发配置
auto-aof-rewrite-percentage 100  # 文件比上次重写后大 100% 时触发
auto-aof-rewrite-min-size 64mb   # 且文件大小至少 64MB

BGREWRITEAOF 流程:

复制代码
主进程                           				子进程
          │                               │
 触发重写  │──── fork() ──────────────────→│
          │  (短暂阻塞,复制页表)           │
          │                               │ 遍历内存
 继续处理  │                               │ 生成等价命令
 写命令   │                               │ 写入新AOF
          │                               │
 写命令A  ──→ ① 旧AOF                     │
          │   ② 重写缓冲区[A]              │
 写命令B  ──→ ① 旧AOF                     │
          │   ② 重写缓冲区[A,B]            │
          │                               │ 写完,发信号
          │←────────────────────────────── │
          │
 把缓冲区 ──→ 追加[A,B]到新AOF
 (短暂阻塞)
          │
 rename ──→ 新AOF替换旧AOF
          │
        完成 ✅

Redis 7.0 引入的 Multi Part AOF(MP-AOF)

Redis 7.0 + ,重构了 AOF 重写机制,引入了 Multi Part AOF(MP-AOF),它把 AOF 拆分成了多个文件进行管理:

  • 一个清单文件 manifest :记录当前 AOF 由哪些文件组成;
  • 一个基础文件 base:使用 RDB 格式保存重写开始那一刻内存中的全量数据;
  • 若干个增量文件 incr :使用 AOF格式记录重写开始之后新产生的写命令。

重写过程:

  1. 重写开始时,主进程先打开一个新的 incr 文件,之后新的写命令会直接追加到这个新的 incr 里,而
    不再写入「AOF 重写缓冲区」;
  2. 子进程负责把当前内存状态以 RDB 格式写入到一个新的 base 文件;
  3. 重写完成后,Redis 只需原子地更新 manifest 文件,让它指向新的 base 和新的 incr 即可,旧的
    base 和旧的 incr 可以安全删除。
优缺点

优点:

  • 数据安全性高,最多丢1秒(everysec 策略)
  • AOF 文件是可读的文本,误操作可以手动编辑恢复

误删恢复例子:

bash 复制代码
# 手抖执行了 FLUSHALL
# 立刻停 Redis,找到 AOF 文件
# 删掉最后一行 FLUSHALL 命令
# 重启 Redis,数据恢复

缺点:

  • 文件比 RDB 大得多
  • 重放命令恢复速度比 RDB 慢
  • 高并发写时 always 策略性能瓶颈明显

RDB + AOF 混合持久化

Redis 4.0 引入,解决 AOF 恢复慢的问题。

开启方式
bash 复制代码
aof-use-rdb-preamble yes  # 开启混合持久化
appendonly yes             # 同时要开启 AOF
原理

AOF 重写时不再全写命令,而是:

复制代码
新 AOF 文件结构:
┌─────────────────────────────────────────┐
│  RDB 部分(fork那一刻的全量二进制快照)    │  ← 快,紧凑
├─────────────────────────────────────────┤
│  AOF 部分(重写期间新产生的写命令)        │  ← 增量日志
└─────────────────────────────────────────┘
恢复流程
复制代码
读 AOF 文件
  → 前半段是 RDB 格式 → 快速加载全量数据
  → 后半段是 AOF 格式 → 重放增量命令
  → 完成恢复

好处:

  • 恢复速度接近 RDB(主体是二进制快照)
  • 数据完整性接近 AOF(增量部分补全快照后的数据)

对比

RDB AOF 混合
数据安全 差(可能丢分钟级) 好(最多丢1秒) 好
恢复速度 快 慢 快
文件大小 小 大 中
系统开销 中(fork) 中(fsync) 中
适用场景 备份/可接受丢数据 不允许丢数据 生产推荐

RDB 是快照 ,恢复快但可能丢数据;AOF 是日志 ,安全但恢复慢;混合持久化结合两者优点,是生产环境首选。RDB + AOF 混合持久化

Redis 大 Key 对持久化有什么影响?

什么是大 Key?

String → value 超过 10KB 算大,超过 1MB 算非常大

List/Hash/Set/ZSet → 元素数量超过 10000,或总大小超过 10MB

对 RDB 快照的影响

  1. fork 时内存占用翻倍风险更高

    大 Key 意味着内存占用大,fork 子进程后如果主线程继续写这个大 Key,COW 会复制整个内存页。

    复制代码
    普通情况:修改小 key → 只复制一个内存页(4KB)
    大 Key:  修改一个 100MB 的 value → 复制大量内存页
              → 内存峰值可能暴涨,OOM 风险
  2. RDB 文件变大,写磁盘时间变长

    一个大 Key 可能占 RDB 文件很大比例,导致:

    • 子进程写磁盘时间变长
    • 主从全量同步时传输 RDB 耗时增加,同步变慢

对 AOF 日志的影响

  1. AOF 写入放大

    大 Key 每次修改,AOF 追加的命令体积就很大。

    bash 复制代码
    # 比如一个 100MB 的 String,每次 SET 都往 AOF 追加 100MB
    SET big_key <100MB数据>
  2. AOF 重写时间变长

    BGREWRITEAOF 子进程遍历内存生成新 AOF,遇到大 Key 需要写大量数据,重写期间:

    • 子进程写磁盘时间拉长
    • 重写缓冲区积压更多命令(因为重写时间长)
    • 最后把缓冲区追加到新 AOF 时,主线程阻塞时间变长

对混合持久化的影响

两者的问题都有,RDB 阶段大 Key 导致快照写入慢,AOF 阶段增量命令体积大。

主线程阻塞

大 Key 删除时才是最危险的:

复制代码
DEL big_key(value 是 100MB 的 Hash)
→ 主线程同步释放内存
→ 阻塞几百毫秒甚至几秒
→ 期间所有命令全部等待
→ 如果正好在 RDB/AOF 重写窗口期,雪上加霜

解决:用 UNLINK 代替 DEL

bash 复制代码
UNLINK big_key  # 异步删除,把释放内存的工作交给后台线程,主线程不阻塞

如何排查大 Key

bash 复制代码
# 官方工具,扫描 RDB 文件,不影响线上服务
redis-cli --bigkeys

# 或者用 SCAN 遍历 + MEMORY USAGE 查大小
MEMORY USAGE key_name

大 Key 让 RDB fork 的内存复制代价更高、让 AOF 写入和重写更慢,本质都是大内存操作拖慢 IO 和阻塞主线程 。根本解决办法是拆分大 Key ,删除时用 UNLINK 异步释放。

面试

Q:Redis 如何实现数据不丢失?

Redis 读写操作都是在内存中,性能高,但是当其重启之后数据会丢失,为了保证内存中数据不丢失,Redis 实现了数据持久化的机制(将数据存放至磁盘,然后从磁盘中恢复),共有三种数据持久化的方式:

  • RDB 快照:将某一时刻的内存数据以二进制的方式写入磁盘
  • AOF 日志:每执行一条写操作命令,就将该命令以追加的方式写入一个文件
  • 混合持久化:Redis 4.0 新增,集成 RDB & AOF 的优点

Q:AOF 日志如何实现?

执行完一条写操作命令后,将该命令以追加的方式写入文件,重启后 Redis 会读该文件,然后逐一执行以进行数据恢复

为什么先执行命令再将命令写入日志?

  • 避免额外的检查开销

  • 不会阻塞 当前的写命令
    AOF 写回策略有几种?(appendfsync)

  • Always,命令执行后,同步将 AOF 日志数据写回硬盘

  • Everysec,命令执行后,先写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区内容写回硬盘

  • No,Redis 不控制写回硬盘的时机,命令执行后先写入内核缓冲区,再由 OS 决定何时将缓冲区内容写回硬盘
    AOF 日志过大会触发什么机制?

AOF 重写机制( 对当前内存数据生成等价的最小命令集,替换旧 AOF 文件)
重写 AOF 日志的过程是怎样的?(bgrewriteaof)

  1. fork 子进程(短暂阻塞,复制页表)
  2. 子进程 和 主进程 并行进行,子进程先生成命令并写入新 AOF,主进程写入旧 AOF (保证旧文件完整)并重写缓冲区(记录重写期间产生的新命令以便写入新 AOF)
  3. 子进程通知主进程写完了(发信号)
  4. 主进程将重写缓冲区的命令追加到新 AOF(短暂阻塞)
  5. 原子替换旧 AOF(rename系统调用)

fork 子进程遍历内存生成新 AOF,期间主进程的新写命令存入重写缓冲区,子进程写完后主进程把缓冲区追加进去,最后原子替换旧文件。两次短暂阻塞:fork 时复制页表、最后追加缓冲区时。

Q:RDB 快照如何实现?

记录某一个瞬间的内存数据到 dump.rdb(default),恢复时直接将 RDB 文件读入内存

RDB 做快照会阻塞线程吗?(save | bgsave)

  • save,会在主线程生成 RDB 文件,由于和写命令在同一个线程,如果写入文件的时间太长,会阻塞主线程;
  • bgsave,创建子进程生成 RDB 文件,避免主线程的阻塞
    RDB 在执行快照时,数据能修改吗?

可以修改,这正是 fork + COW(写时复制) 机制解决的核心问题。BGSAVE 触发后,主线程 fork 出子进程,子进程负责写 RDB,与此同时如果主进程执行读操作,则主线程与 bgsave 子进程相互不影响,如果主进程执行写操作,则 COW 开始工作(将被修改数据所在的内存页复制一份)bgsave 在副本上操作,主线程仍然可以修改原数据,代价是写操作越多,内存复制越多。

Q:为什么会有混合持久化?

为了解决 RDB 和 AOF 各自的短板

  1. RDB 的问题:丢数据

    快照是定时触发的,两次快照之间如果 Redis 宕机,这段时间的数据全丢。

    复制代码
    09:00 RDB快照
    09:01 ~ 09:14 写了大量数据
    09:15 宕机
    09:15 RDB快照还没触发
    
    → 丢了 15 分钟的数据
  2. AOF 的问题:恢复慢

    AOF 记录的是每一条写命令,数据量大时 AOF 文件可能有几十 GB,恢复时需要逐条重放命令,可能需要几十分钟甚至几小时。

    复制代码
    AOF 文件 50GB
    → Redis 重启
    → 逐条执行命令重建内存
    → 恢复完成需要 1 小时
    → 这 1 小时服务不可用

混合持久化怎么解决的:AOF 重写时,新文件不再全写命令,而是:

复制代码
┌──────────────────────────────┐
│  RDB 二进制快照(全量,快)    │  ← 解决恢复慢
├──────────────────────────────┤
│  AOF 增量命令(重写期间新产生)│  ← 解决丢数据
└──────────────────────────────┘

恢复时先加载 RDB 部分(秒级),再重放少量 AOF 增量命令,恢复速度接近 RDB,数据完整性接近 AOF。

Q:RDB 和 AOF 同时存在,Redis 重启用哪个?

优先用 AOF,因为 AOF 数据更完整。只有 AOF 关闭时才用 RDB。

Q:fork 子进程期间主进程会阻塞吗?

fork 系统调用本身会短暂阻塞主线程(复制页表),但通常只有几毫秒。fork 完成后主线程立刻恢复服务,子进程独立写磁盘。数据量越大,fork 越慢。

Q:AOF 重写期间新来的写命令怎么处理?

同时写入旧 AOF 和重写缓冲区。子进程写完新 AOF 后,把重写缓冲区的内容追加进去,保证数据不丢。

Q:Redis 能保证强一致吗?

不能。即使 AOF everysec,也可能丢最近1秒数据。Redis 定位是高性能缓存,不是强一致存储,强一致场景用 MySQL。

三、缓存

减少数据库压力 + 提升响应速度

  • 穿透 → 布隆过滤器拦截非法请求
  • 击穿 → 互斥锁保证只有一个线程重建缓存
  • 雪崩 → TTL 随机化 + Redis 高可用
  • 一致性 → 先更新DB再删缓存,允许短暂不一致
  • 淘汰 → 一般选 allkeys-lru 或 allkeys-lfu

缓存三兄弟

缓存穿透

指当查询一个不存在的 key 时,请求直接透过 Redis 打到数据库(缓存未命中),原因如下:

  • 业务误操作,缓存和数据库中的数据均被误删
  • 恶意攻击,故意访问不存在的 key

解决

  1. 缓存空值:将查询过的不存在 key 缓存起来,如果重复查询这个 key 就可以直接返回无需打数据库

    java 复制代码
    Object result = db.query(id);
    if (result == null) {
        // 缓存一个空值,设置较短过期时间
        redis.set(key, "NULL", 60);
    }
  2. 布隆过滤器:查询前加一层布隆过滤(询问这个 key 是否存在)

    复制代码
    请求 id=9999
        ↓
    布隆过滤器:不存在 → 直接返回,不查数据库
               存在  → 查 Redis → 查 MySQL
    java 复制代码
    // 初始化:把数据库所有合法 id 加入布隆过滤器
    bloomFilter.add(id);
    
    // 请求时先检查
    if (!bloomFilter.contains(id)) {
        return null;  // 直接拦截
    }

    工作原理是利用多个哈希函数将一个元素映射到一个位数组中(BitMap),其工作流程如下:

    1. Init:BitMap(长度m,值为 0) & 哈希函数集(k 个相互独立的哈希函数,将输入映射到 BitMap 中)
    2. Add:添加元素时,使用函数集对该元素计算,得到 k 个哈希值,将这 k 个哈希值在 BitMap 中对应位置设置为1
    3. Query:查询元素时,先计算得到哈希值,查询它们是否为全为1,如果有一个位置是 0,那么绝对不存在,如果全为 1 可能存在

    特性:

    • 说不存在 → 一定不存在
    • 说存在 → 可能存在(有误判率,约 1%)
    • 不支持删除(变种 Counting Bloom Filter 支持),会影响其他元素判断

缓存击穿

某个热点 key 过期的一瞬间,大量并发请求同时未命中,全部打到数据库

击穿是热点 key,雪崩是大量 key

解决

  1. 互斥锁(Mutex):缓存未命中时只让一个线程去查数据库重建缓存,其他线程等待(数据一致性强但性能稍差)

    java 复制代码
    String value = redis.get(key);
    if (value == null) {
        // 尝试拿分布式锁
        if (redis.setnx(lockKey, "1", 10)) {
            try {
                value = db.query(id);
                redis.set(key, value, 3600);
            } finally {
                redis.del(lockKey);  // 释放锁
            }
        } else {
            // 没抢到锁,等一会重试
            Thread.sleep(50);
            return get(id);  // 递归重试
        }
    }
    return value;
  2. 逻辑过期:在 value 中存放一个字段判断是否逻辑过期(性能好但数据一致性稍差)

    java 复制代码
    // value 结构
    {
      "data": {...},
      "expireTime": 1735000000
    }
    
    // 读取逻辑
    RedisData redisData = redis.get(key);
    if (redisData.expireTime > now()) {
        return redisData.data;  // 未过期,直接返回
    }
    // 逻辑过期了,异步重建缓存
    if (tryLock(lockKey)) {
        asyncRebuildCache(key);  // 后台线程重建
    }
    // 先返回旧值,不阻塞
    return redisData.data;

缓存雪崩

大量 key 在同一时间集中过期(TTL 设置成一样的) or Redis 宕机,导致大量请求同时打到数据库

大量 key 同时过期
  1. TTL 加随机值

    java 复制代码
    int ttl = 3600 + RandomUtil.nextInt(0, 300);  // 加0~5分钟随机
    redis.set(key, value, ttl);
  2. 互斥锁:如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(记得设置超时时间)

  3. 后台更新缓存(缓存预热):由于系统内存紧张时会淘汰掉一些缓存数据,因此我们想让缓存"永久有效",并将更新缓存的工作交由后台线程定时更新

    1. 定时更新缓存并频繁检测缓存是否有效
    2. 发现缓存失效时,通过 MQ 通知后台线程更新缓存
Redis 故障宕机
  1. 构建 Redis 缓存高可靠集群:靠架构解决 Redis 本身宕机导致的雪崩

    • 主从 + 哨兵:主节点宕机自动切换从节点
    • Redis Cluster:分片集群,部分节点宕机不影响整体
  2. 服务熔断(暂停业务应用对缓存服务的访问,直接返回错误)或请求限流机制(只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务):防止 Redis 崩溃之后数据库被打垮

    java 复制代码
    // 请求限流机制示例
    // 限流:超过阈值直接返回降级结果
    if (requestCount > threshold) {
        return "服务繁忙,请稍后再试";
    }
    // 降级:查数据库失败时返回兜底数据
    try {
        return db.query(id);
    } catch (Exception e) {
        return defaultValue;
    }

缓存更新策略

  1. Cache Aside(旁路缓存)

    读流程:

    复制代码
    查 Redis → 命中 → 返回
             → 未命中 → 查 MySQL → 写入 Redis → 返回

    写流程:

    复制代码
    更新 MySQL → 删除 Redis 缓存

    为什么是删除缓存,而不是更新缓存? 更新缓存在并发下可能写入旧值(两个线程先后更新,顺序不一致);删除是幂等操作(无论执行多少次,系统的最终状态和执行一次的结果都是一样的),更安全。下次读时自然重建缓存。

  2. Read/Write Through(穿透读写)

    应用只和缓存交互,由缓存层负责同步数据库,应用无感知。实现复杂,一般用成熟的缓存中间件才会考虑,业务开发少用。

  3. Write Behind(异步写回)

    只写缓存,由后台异步批量写数据库。性能最高,但有丢数据风险,适合日志、点赞计数等允许少量丢失的场景。

缓存一致性

Cache Aside 写流程有一个经典问题:先删缓存还是先更新数据库?

先删缓存,再更新数据库
复制代码
线程A:删除缓存
                    线程B:查缓存未命中 → 查数据库(旧值)→ 写入缓存(旧值)
线程A:更新数据库(新值)

结果:缓存里是旧值,数据库是新值 → 不一致!❌
先更新数据库,再删缓存
复制代码
线程A:更新数据库(新值)→ 删除缓存

线程B 读请求:缓存未命中 → 查数据库(新值)→ 写入缓存(新值)

结果:一致 ✅
延迟双删(并发)

还有一个极小概率的问题:

复制代码
缓存恰好过期
线程B:查缓存未命中 → 查数据库(旧值,还没更新完)
线程A:更新数据库 → 删除缓存
线程B:把旧值写入缓存

结果:缓存是旧值 → 不一致

这个窗口极小(需要缓存恰好在那一刻过期),且下次缓存过期后会自动修正,一般业务上可以接受 ,但对一致性要求高时,可以用延迟双删兜底:

java 复制代码
// 第一次删缓存
redis.del(key);
// 更新数据库
db.update(data);
// 延迟一段时间,再删一次(清除期间可能写入的旧值)
Thread.sleep(500);
redis.del(key);

延迟时间要大于一次数据库读写的时间,确保把"读旧值写缓存"的窗口覆盖掉。

如何保证两个操作都能执行成功?

数据库和缓存是两个独立的系统,没有原生的事务保证。

失败场景分析

复制代码
① 更新数据库成功 → 删除缓存失败
   结果:数据库是新值,缓存是旧值 → 不一致

② 更新数据库失败 → 删除缓存成功
   结果:数据库是旧值,缓存为空 → 下次读数据库,重建旧值
   这种影响不大,数据库失败本身就该回滚

麻烦的是删除缓存失败导致的不一致,解决方案如下:

  1. 重试机制:删缓存失败时,捕获异常,重试几次。

    java 复制代码
    boolean success = false;
    int retries = 3;
    while (retries-- > 0) {
        try {
            redis.del(key);
            success = true;
            break;
        } catch (Exception e) {
            // 稍等再试
            Thread.sleep(50);
        }
    }
    if (!success) {
        // 重试全失败,写入消息队列,后续补偿
        mq.send(new DeleteCacheMessage(key));
    }

    重试是同步的,失败次数多时会阻塞请求,影响接口响应时间。

  2. 消息队列异步重试:删缓存失败后,把 key 发到消息队列,由消费者异步重试删除,主流程不阻塞。

    复制代码
    更新数据库
        ↓
    删除缓存
        ↓ 失败
    发送消息到 MQ(key)
        ↓
    消费者拉取消息 → 重试删除缓存
        ↓ 还是失败
    消息重新入队(设置最大重试次数)
    java 复制代码
    // 生产者
    db.update(data);
    try {
        redis.del(key);
    } catch (Exception e) {
        // 删缓存失败,丢消息队列
        mq.send("cache_delete", key);
    }
    
    // 消费者
    @MQListener("cache_delete")
    public void onMessage(String key) {
        redis.del(key);
    }

    优点:解耦,异步,重试有保障

    缺点:引入 MQ,架构复杂度增加;消费者消费前有短暂不一致窗口

  3. Canal 订阅 MySQL binlog (最终一致性方案):完全不在业务代码里处理缓存,而是监听数据库的变更日志,自动删除对应缓存。

    复制代码
    业务代码 只管更新 MySQL
        ↓
    MySQL 写 binlog
        ↓
    Canal 订阅 binlog,解析变更
        ↓ 
    Canal 发消息到 MQ
        ↓ ↑ACK(删除缓存成功)
    消费者:删除 Redis 对应 key

    优点:

    • 业务代码和缓存完全解耦,不需要写任何缓存删除逻辑
    • binlog 是 MySQL 内部机制,只要数据库写成功,删缓存一定会被触发

    缺点:

    • 引入 Canal + MQ,运维成本高
    • 有一定延迟(binlog → Canal → MQ → 消费者)

    这是大厂处理缓存一致性的主流方案,延迟通常在毫秒到秒级,绝大多数业务可以接受。

同步重试 MQ异步重试 Canal+binlog
实现复杂度 低 中 高
对业务侵入 有 有 无
可靠性 一般 好 最好
一致性延迟 最低 低 略高
适用场景 小项目 中型项目 大型项目

本质上是分布式环境下两个系统的原子性问题 ,无法做到真正的强一致,只能做最终一致性。小项目用重试兜底,大项目用 Canal 监听 binlog 自动补偿,把缓存删除从业务逻辑中彻底剥离出去。

过期删除策略 vs 内存淘汰策略

  • 过期删除策略:针对设置了 TTL 的 key,到期了怎么删
  • 内存淘汰策略:Redis 内存不够用了,怎么腾空间
过期删除策略
相关命令
bash 复制代码
# 对 key 设置过期时间
expire/expireat <key> <n>
pexpire/pexpireat <key> <n>

# 设置字符串时对 key 设置过期时间
set <key> <value> ex/px <n>
setex <key> <n> <valule>

# 查看剩余的存活时间
ttl key

# 取消过期时间
persist key
如何判定 key 已过期了?

Redis 每个数据库(redisDb)内部维护两个字典:

c 复制代码
typedef struct redisDb {
    dict *dict;     // 存所有 key-value
    dict *expires;  // 单独存 key 的过期时间(绝对时间戳,毫秒)
} redisDb;

设置过期时间时,只往 expires 字典里写一条记录:

bash 复制代码
SET name "xmon"
EXPIRE name 300

# expires 字典里:
# "name" → 1735000000000(当前时间戳 + 300000ms)

判断逻辑:用当前时间戳和 expires 字典里存的过期时间戳比大小,超过了就是过期。

c 复制代码
int keyIsExpired(redisDb *db, robj *key) {
    // 1. 去 expires 字典查这个 key 有没有过期时间
    mstime_t when = getExpire(db, key);

    // 2. 没有过期时间,永不过期
    if (when < 0) return 0;

    // 3. 获取当前时间戳(毫秒)
    mstime_t now = mstime();

    // 4. 当前时间 > 过期时间 → 已过期
    return now > when;
}

流程示意

复制代码
访问 key "name"
    ↓
去 expires 字典查 → 没有记录 → 永不过期,正常返回
                  → 有记录(timestamp = T)
                        ↓
                    now > T ?
                    → 是:已过期,删除,返回 null
                    → 否:未过期,正常返回
过期删除策略有哪些?

Redis 有三种删除策略,实际上同时使用了两种。

  1. 定时删除(Redis 没用这个):给每个 key 创建一个定时器,到期立即删除。

    优点:内存释放最及时

    缺点:大量定时器占用 CPU,key 多时性能极差

  2. 惰性删除:不主动删,等到被访问时再检查

    复制代码
    客户端访问 key
        ↓
    Redis 检查该 key 是否过期
        ↓ 过期
    删除,返回 null
        ↓ 未过期
    正常返回
    c 复制代码
    // Redis 源码中每次访问 key 都会调用这个
    int expireIfNeeded(redisDb *db, robj *key) {
        if (!keyIsExpired(db, key)) return 0;  // 未过期,放行
        deleteExpiredKeyAndPropagate(db, key); // 过期,删除
        return 1;
    }

    优点: 对 CPU 友好,不主动消耗资源

    缺点: 如果 key 一直不被访问,永远不会删,内存泄漏

  3. 定期删除:每隔一段时间,随机抽查一批设置了 TTL 的 key,删除其中过期的。

    复制代码
    每隔 100ms 执行一次:
      从 expires 字典中随机抽取 20 个 key
      删除其中已过期的
      如果过期比例 > 25%,再抽一批继续删
      直到过期比例 < 25% 或执行时间超过阈值(默认25ms)才停止
    模式 触发 执行时间 场景
    SLOW 定时任务,100ms一次 最长 25ms 常规清理
    FAST 每次事件循环前执行 最长 1ms 快速补充清理

    优点: 兼顾 CPU 和内存,折中方案

    缺点: 随机抽样,不能保证所有过期 key 及时删除

Redis 实际的组合策略

复制代码
惰性删除 + 定期删除 配合使用

定期删除:后台主动清理,释放内存
惰性删除:访问时兜底,保证不返回过期数据

两者互补,但仍然无法保证所有过期 key 都被删除
→ 这就是为什么还需要内存淘汰策略
内存淘汰策略

当 Redis 内存达到 maxmemory 上限时,新写入请求触发淘汰。

bash 复制代码
# 配置最大内存
maxmemory 4gb
# 配置淘汰策略
maxmemory-policy allkeys-lru
Redis 内存淘汰策略有哪些?

8 种策略 ,按作用范围分两类:

  • allkeys 系列:对所有 key 生效
  • volatile 系列:只对设置了过期时间的 key 生效
策略 范围 算法 含义
noeviction 全部 无 不淘汰,内存满直接报错(默认)
allkeys-lru 所有key LRU 淘汰最久未使用的
allkeys-lfu 所有key LFU 淘汰访问频率最低的
allkeys-random 所有key 随机 随机淘汰
volatile-lru 有TTL的key LRU 有TTL的key中淘汰最久未使用的
volatile-lfu 有TTL的key LFU 有TTL的key中淘汰频率最低的
volatile-random 有TTL的key 随机 有TTL的key中随机淘汰
volatile-ttl 有TTL的key TTL 淘汰剩余过期时间最短的
LRU vs LFU
  • LRU (Least Recently Used,最近最少使用),淘汰最久没被访问的 key,适合热点数据随时间变化的场景。

    传统的 LRU 是基于链表的,链表元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,删除表尾元素即可,Redis 并未使用该方式实现 LRU,原因如下:

    1. 链表管理缓存数据,带来额外开销
    2. 链表移动操作很耗时,进而降低 Redis 缓存性能

    Redis 的 LRU 是近似的,不是精确的:

    复制代码
    Redis 的做法:
    	每个 key 记录最后一次访问时间(lru 字段,24bit)
      淘汰时随机采样 N 个 key(默认 maxmemory-samples=5)
      淘汰其中 lru 时间最老的那个
    
    多次采样近似达到 LRU 效果,牺牲少量精确度换取性能

    但 LRU 算法无法解决缓存污染问题,比如应用有且仅读取一次的数据,那么这些数据会留存在缓存中很长一段时间,造成缓存污染。因此,Redis 4.0 后引入 LFU 解决该问题

  • LFU (Least Frequently Used, 最不常用),淘汰访问次数最少的 key,更能反映长期热度,适合热点相对固定的场景。

    但直接记录真实访问次数有两个问题:

    • 计数器可能非常大,占用内存多
    • 历史遗留问题:一个 key 以前访问很频繁,最近完全没人用,但计数还是很高,永远不会被淘汰

    Redis 的 LFU 针对这两个问题都做了处理。

    复用 lru 字段

    Redis 没有新增字段,而是把 redisObject 里原来存 LRU 时间戳的 24 bit 字段拆成两部分:

    c 复制代码
    unsigned lru:24;
    
    // LRU 模式:24bit 全部存最后访问时间戳
    // LFU 模式:拆成两段
    //   高 16 bit → ldt(last decrement time,上次衰减时间,分钟级)
    //   低  8 bit → counter(访问频率计数器,Morris Counter)
    
    | 16 bit (ldt) | 8 bit (counter) |

    8 bit 最大值 255,不直接存真实访问次数,而是用Morris Counter(概率计数器)

    Morris Counter:对数级近似计数

    每次访问 key,counter 不是直接 +1,而是按概率递增:

    c 复制代码
    // 伪代码
    double r = random();           // 0~1 之间的随机数
    double p = 1.0 / (counter * lfu_log_factor + 1);
    if (r < p) counter++;          // 概率越来越小

    效果:

    复制代码
    counter 越小 → 概率越大 → 容易涨(冷数据快速区分)
    counter 越大 → 概率越小 → 很难涨(热数据增长平缓)
    
    counter = 0   → 每次必然 +1
    counter = 10  → 约 1/10 概率 +1
    counter = 100 → 约 1/100 概率 +1
    counter 上限  → 255(8bit 最大值)
    
    实际映射关系(lfu_log_factor=10,默认值):
      counter = 10  → 约 100 次访问
      counter = 18  → 约 1000 次访问
      counter = 255 → 约 100万次访问

    用 255 以内的数字就能表示从几次到百万次的访问量差异,极省内存。

    LFU 还有衰减机制:

    光有计数还不够,如果一个 key 以前很热现在没人用,counter 一直高,永远不会被淘汰。

    Redis 引入时间衰减:每隔一段时间,counter 自动减小。

    c 复制代码
    // 计算衰减量
    unsigned long LFUDecrAndReturn(robj *o) {
        // ldt:上次衰减时间(分钟)
        unsigned long ldt = o->lru >> 8;
        unsigned long counter = o->lru & 255;
    
        // 距离上次衰减过了多少分钟
        unsigned long num_periods = 
            (now_in_minutes - ldt) / lfu_decay_time;
    
        // 衰减:过了多少个周期,counter 减多少
        if (num_periods > 0) {
            counter = (num_periods > counter) ? 0 : counter - num_periods;
        }
        return counter;
    }
    bash 复制代码
    # 衰减速度配置(默认1,即每分钟衰减1点)
    lfu-decay-time 1
    一个 key counter = 50,lfu-decay-time = 1
    10 分钟没被访问 → counter = 50 - 10 = 40
    100 分钟没被访问 → counter = 50 - 50 = 0(最小到0)
    → 下次内存满,优先被淘汰

    完整流程

    复制代码
    每次访问 key:
        ↓
    ① 计算衰减:距上次衰减多久了 → counter 减去对应值
        ↓
    ② 概率递增:按 Morris Counter 概率 counter +1
        ↓
    ③ 更新 ldt 为当前时间
    
    内存满,需要淘汰:
        ↓
    随机采样 N 个 key(同 LRU,也用淘汰池)
        ↓
    比较各 key 的 counter(先做衰减计算)
        ↓
    淘汰 counter 最小的
  • 对比

    LRU LFU
    判断依据 最后访问时间 访问频率(计数)
    24bit 用法 全部存时间戳 16bit时间 + 8bit计数
    冷热区分 看最近 看长期
    新 key 保护 刚加入就是最热 counter 从0开始,容易被淘汰
    适合场景 热点随时间变化 热点长期稳定

两套策略配合

复制代码
key 设置了 TTL,到期了
    ↓
惰性删除:被访问时检查,删除
定期删除:后台随机抽查,删除

↓ 但是有漏网之鱼(还没被访问也没被抽到)

内存快满了
    ↓
内存淘汰策略介入
根据配置的策略,强制淘汰 key 腾出内存

缓存预热

系统刚启动时缓存是空的,如果直接上线,所有请求都会打到数据库。

java 复制代码
// 项目启动时,主动把热点数据加载进 Redis
@PostConstruct
public void initCache() {
    List<Product> hotProducts = db.queryHotProducts();
    hotProducts.forEach(p -> redis.set("product:" + p.getId(), p, 3600));
}

面试

问题 关键词
穿透 不存在的key → 布隆过滤器
击穿 热点key过期 → 互斥锁/逻辑过期
雪崩 大量key同时过期 → TTL随机+高可用
一致性 先更新DB再删缓存 → Canal兜底
淘汰 生产用allkeys-lru/lfu
热key 本地缓存+key分片
大key 拆分+UNLINK异步删除
分布式锁 SET NX EX + Lua释放 + Redisson看门狗

Q:什么是缓存穿透、击穿、雪崩?分别怎么解决?

穿透:查询数据库不存在的 key,缓存永远未命中,每次都打数据库。 解决:布隆过滤器拦截非法请求;或缓存空值(TTL 设短)。

击穿:单个热点 key 过期瞬间,大量并发全部打到数据库。 解决:互斥锁保证只有一个线程重建缓存;或逻辑过期(key 不设物理过期,异步重建,先返回旧值)。

雪崩:大量 key 同时过期,或 Redis 宕机,数据库被打垮。 解决:TTL 加随机值错开过期时间;Redis 主从+哨兵保证高可用;服务层限流降级兜底。

Q:布隆过滤器的原理?有什么缺陷?

本质是一个 bit 数组 + 多个哈希函数。插入元素时,使用哈希函数集计算出多个位置,将这些位置上的 bit 全置为1。查询时,同样算出多个位置,如果有一个不为1则一定不存在,全为1则有可能存在

缺陷:

  • 有误判:bit 位可能被其他元素设置,导致实际不存在而显示存在(假阳性)
  • 不支持删除:删除一个元素会影响共享 bit 位,影响其他元素的判断

Q:如何保证缓存和数据库一致性?

使用旁路缓存(Cache Aside),先更新数据库再删除缓存

为什么是删而不是更新?

  1. 并发下两个线程同时更新缓存,顺序不保证会写入旧值;
  2. 删除是幂等操作更安全;
  3. 不是每次缓存都会被读,惰性重建更合理。

删除缓存失败怎么办?两种方案:

  1. MQ 异步重试删除
  2. Canal 监听 MySQL binlog,自动触发删缓存,业务代码零侵入,大厂主流方案。

Q:为什么先更新数据库,再删缓存?反过来有什么问题?

先删缓存再更新数据库,有较大概率出现不一致:

复制代码
线程A:删缓存
线程B:读缓存未命中 → 查数据库(旧值)→ 写入缓存(旧值)
线程A:更新数据库(新值)
结果:数据库新值,缓存旧值 → 不一致,且会一直保持到缓存过期

先更新数据库再删缓存,不一致的窗口极小(需要缓存恰好在那一刻过期且并发读),且下次缓存过期后自动修正。

Q:什么是延迟双删?解决了什么问题?

先删缓存,更新数据库,延迟一段时间再删一次缓存:

解决"先删缓存再更新数据库"方案中,并发读写将旧值写入缓存的问题。延迟时间要覆盖一次完整的数据库读写耗时,确保把期间写入的旧值清掉。 缺点:延迟删除期间仍有短暂不一致;sleep 阻塞线程,一般放异步执行。

Q:Redis 的内存淘汰策略有哪些?生产怎么选?

8 种策略,按范围分两类:

  • allkeys 系列:对所有 key 生效
  • volatile 系列:只对设置了过期时间的 key 生效

按算法分:lru(最近最少使用)、lfu(最不常用)、random(随机)、ttl(最快过期)

生产选择:

  • 通用场景:allkeys-lru,淘汰最久未访问的
  • 热点相对固定:allkeys-lfu,更能反映长期热度
  • 缓存和持久数据混用:volatile-lru,只淘汰有过期时间的
  • 不淘汰(noeviction)默认值,内存满直接报错,生产一般不用

Q:LRU 和 LFU 的区别?Redis 的 LRU 是精确的吗?

LRU (Least Recently Used):淘汰最久没被访问的,适合热点随时间变化的场景。 LFU(Least Frequently Used):淘汰访问次数最少的,适合热点相对固定的场景。

Redis 的 LRU 不是精确的 ,是近似 LRU。精确 LRU 需要维护一个全局链表,内存和性能开销大。Redis 的做法是:随机采样 N 个 key(默认5个),淘汰其中最久未访问的那个,多次采样近似达到 LRU 效果。可通过 maxmemory-samples 调大采样数量提高精确度,但相应增加 CPU 开销。

Q:热 key 问题是什么?怎么解决?

某个 key 访问频率极高(比如明星突发新闻、秒杀商品),导致该 key 所在的 Redis 节点压力过大,甚至打垮单节点。

解决方案:

  1. 本地缓存:在 JVM 内存(Caffeine/Guava Cache)缓存一份,请求先走本地缓存,不到 Redis
  2. key 分片 :把热 key 复制成多份,hot_key_0、hot_key_1...hot_key_N,请求随机打到其中一个,分散压力
  3. 读写分离:从节点分担读压力

Q:大 key 问题是什么?怎么排查和解决?

什么是大 key:String value 超过 10KB,或集合类型元素超过 10000 个/总大小超过 10MB。

危害:

  • 读写大 key 阻塞主线程
  • 删除大 key(DEL)同步释放内存,可能阻塞几百毫秒
  • RDB/AOF 持久化耗时增加,内存峰值高
  • 主从同步传输慢

排查:

bash

bash 复制代码
redis-cli --bigkeys          # 扫描找大 key
MEMORY USAGE key_name        # 查某个 key 内存占用

解决:

  • 拆分大 key:Hash 拆成多个小 Hash,List 分页存储
  • 删除用 UNLINK 代替 DEL(异步释放内存)
  • 压缩 value(gzip 压缩后存入)

Q:缓存预热怎么做?

服务启动时主动把热点数据加载进 Redis,避免冷启动时所有请求都打数据库。

常见做法:

  1. 项目启动时用 @PostConstruct 加载热点数据
  2. 发布前手动跑脚本提前预热
  3. 根据历史访问日志,定时任务预热次日热点数据

Q:如何实现一个简单的分布式锁?有哪些坑?

基础实现:

bash 复制代码
SET lock:key uuid NX EX 30
# NX:不存在才设置(互斥)
# EX:自动过期(防死锁)
# uuid:标识锁的持有者(防误删)

释放锁时必须用 Lua 脚本保证原子性:

lua 复制代码
if redis.call("get", KEYS[1]) == ARGV[1] then
 return redis.call("del", KEYS[1])
else
 return 0
end

主要坑:

  • 锁超时业务没执行完:用 Redisson 的看门狗(watchdog)自动续期
  • 主从切换锁丢失:主节点宕机,锁还没同步到从节点,另一个线程在新主上能拿到锁 → 用 RedLock(但有争议)
  • 不可重入:同一线程重复加锁会死锁 → Redisson 实现了可重入锁

四、分布式锁

基础用 SET NX EX + Lua 释放;生产用 Redisson,自带看门狗续期、可重入、发布订阅等待;主从安全问题用 RedLock 有争议,大多数业务单节点 Redisson 足够。

为什么需要分布式锁

单机环境用 JVM 的 synchronized / ReentrantLock 就够了,但分布式环境下多个服务实例各自的 JVM 锁互不感知:

复制代码
用户下单,扣减库存

实例A  ────┐
           ├──→ 同时读到 stock=1,同时扣减 → stock=-1(超卖!)
实例B  ────┘

JVM锁锁不住跨进程的并发,需要一个所有实例都认可的第三方来协调
→ Redis 分布式锁

单节点锁 SETNX

适用于业务不是支付、下单这种核心场景的简单场景

加锁
bash 复制代码
SET lock:key uuid NX EX 30
# NX  = Not Exists,key 不存在才设置(互斥的关键)
# EX  = 过期时间,防止宕机后锁永不释放(死锁)
# uuid = 唯一标识,标记锁的持有者(防误删)
  1. uuid 唯一
  2. EX 合理预留缓冲
  3. 加锁后别做无效等待,重试/抛异常
释放锁(必须用 Lua 脚本)

将判断和删除这两个操作封装成一个原子操作

lua 复制代码
-- 先判断是不是自己的锁,再删除,两步必须原子执行
-- KEYS[1]对应传入的锁的key(比如lock_key)
-- ARGV[1]对应传入的锁的唯一标识(比如unique_value)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

为什么释放锁必须用 Lua?

复制代码
线程A:GET lock → 值是自己的 uuid ✅
                                        (此时锁恰好过期,被线程B拿到)
线程A:DEL lock → 把线程B的锁删了!❌

GET + DEL 不是原子操作,中间可能被打断
Lua 脚本在 Redis 中原子执行,不会被打断
Java 代码实现
java 复制代码
public class SimpleRedisLock {
    private StringRedisTemplate redisTemplate;
    private String lockKey;
    private String uuid;

    public boolean tryLock(long expireTime) {
        uuid = UUID.randomUUID().toString();
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, uuid, expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    public void unlock() {
        String script =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "    return redis.call('del', KEYS[1]) " +
            "else " +
            "    return 0 " +
            "end";
        redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            List.of(lockKey),
            uuid
        );
    }
}

单节点锁的问题

问题1:锁超时,业务没执行完
复制代码
线程A 拿到锁,设置过期时间 30s
业务执行到第 25s,还没完成
第 30s 锁自动过期,线程B 拿到锁
线程A 和线程B 同时在临界区执行!❌

解决:看门狗(Watchdog)自动续期

问题2:不可重入
java 复制代码
// 同一个线程,加锁两次
lock();      // 成功
doSomething();
lock();      // 失败!key 已存在,自己把自己锁死了

解决:可重入锁(记录持有线程 + 重入次数)

问题3:主从切换锁丢失
复制代码
线程A 在主节点写入锁
主节点还没来得及同步给从节点,主节点宕机
从节点升级为新主节点,锁数据丢失
线程B 在新主节点拿到同一把锁

两个线程同时持有锁!❌

解决:RedLock(有争议)或业务层容忍

高可用锁 RedLock

针对主从切换锁丢失问题,Redis 作者 antirez 提出了 RedLock 算法。

适用于支付、下单等核心业务场景,并部署至少 3 个独立的 Redis 锁节点。具体部署建议如下:

  • 节点数量选择奇数:如 3、5、7 等,便于快速计算成功条件,确保锁机制的有效性
  • 保证节点独立性:节点之间不能有主从复制关系
  • 合理设置请求超时:向节点发送加锁请求时,建议设置 50-100ms 的超时时间,避免单个节点相应缓慢拖流程
原理
复制代码
部署 N 个(一般5个)完全独立的 Redis 主节点(无主从关系)

加锁:向所有 N 个节点发送 SET NX EX 请求
      超过半数(>=3)成功,且总耗时 < 锁过期时间
      → 加锁成功

释放:向所有 N 个节点发送释放命令
即使一个节点宕机,锁数据还存在其他多数节点上
新的加锁请求无法获得多数同意,保证互斥
RedLock 的争议

Martin Kleppmann(《DDIA》作者)和 antirez 有过著名的公开论战:

Martin 的质疑:

复制代码
线程A 在5个节点都加锁成功
线程A 发生 GC Stop-The-World 停顿(比如几秒)
期间锁在所有节点自动过期
线程B 加锁成功
GC 结束,线程A 认为自己还持有锁,继续执行

两个线程同时在临界区!RedLock 没有解决这个问题

结论:

RedLock
解决主从切换问题 ✅
解决 GC 停顿问题 ❌
实现复杂度 高(需要5个独立Redis)
生产使用 争议较大

实际生产怎么选?

  • 大多数业务:单节点 Redisson + 看门狗 足够
  • 对锁要求极高(金融核心链路):用数据库做分布式锁(悲观锁 FOR UPDATE),或 ZooKeeper

Redlock 切勿滥用,性能开销大

生产级分布式锁 Redisson

Redisson 解决了单节点锁的所有问题,是生产环境的标准选择。

基本使用
java 复制代码
RLock lock = redissonClient.getLock("lock:order:1001");

// 尝试加锁,最多等3秒,锁自动过期30秒
boolean success = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (success) {
    try {
        // 业务逻辑
        deductStock();
    } finally {
        lock.unlock();
    }
}
核心一:看门狗自动续期
复制代码
线程A 拿到锁,leaseTime 默认 30s(watchdog 超时时间)

后台 watchdog 线程每隔 10s(leaseTime/3)检查一次:
  线程A 还持有锁吗?
    → 是:重置过期时间为 30s
    → 否(线程A崩溃):不续期,锁自然过期释放

只有调用 unlock() 或线程崩溃,锁才会真正释放
java 复制代码
// 不指定 leaseTime,watchdog 自动续期
lock.lock();  // 默认启动看门狗

// 指定 leaseTime,不启动看门狗(自己管过期时间)
lock.lock(30, TimeUnit.SECONDS);

但是它并非绝对可靠,如果服务器宕机,其线程会随之终止,锁释放。

核心二:可重入锁

Redisson 用 Hash 结构存锁信息:

bash 复制代码
# key = 锁名,field = 线程标识,value = 重入次数
HSET lock:order:1001  thread_A_uuid  1

# 同一线程再次加锁
HSET lock:order:1001  thread_A_uuid  2  # 重入次数 +1

# 释放一次
HSET lock:order:1001  thread_A_uuid  1  # 重入次数 -1

# 再释放一次,重入次数=0,删除 key
DEL lock:order:1001

加锁 Lua 脚本:

lua 复制代码
-- 锁不存在,直接加锁
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

-- 锁存在,判断是不是自己的
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)  -- 重入次数+1
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

-- 是别人的锁,返回剩余过期时间(告诉调用方等多久)
return redis.call('pttl', KEYS[1])
核心三:加锁失败时的等待机制

tryLock(waitTime, ...) 失败后不是无脑轮询,而是用 Redis 的发布订阅:

复制代码
线程B 尝试加锁失败
  → 订阅 lock:order:1001 的释放消息频道
  → 进入等待(不占用 CPU)

线程A 释放锁
  → 发布释放消息到频道

线程B 收到消息
  → 重新尝试加锁

比无脑 while(true) 轮询更高效,减少 CPU 和 Redis 压力。

各实现方案对比

SETNX 手写 Redisson RedLock
可重入 ❌ ✅ ✅
自动续期 ❌ ✅ 看门狗 ✅
主从安全 ❌ ❌ ✅
实现复杂度 低 低(开箱即用) 高
生产推荐 ❌ ✅ 看场景

面试

Q:为什么需要分布式锁?和 JVM 锁有什么区别?

JVM 锁只在单机环境生效,分布式环境下多个服务实例各自的 JVM 锁互不感知,无法解决这种跨进程的并发问题,因此我们才有了分布式锁

分布式锁借助第三方存储(Redis / ZooKeeper / 数据库),让所有实例都认可同一个锁,才能协调跨进程的互斥访问

场景:秒杀扣减库存、定时任务防止重复执行,幂等控制

Q:分布式锁需要满足哪些条件?

  • 互斥性:同一时刻只有一个客户端持有锁
  • 防死锁:持有锁的客户端崩溃后,锁能自动释放
  • 防误删:只能由持有者释放自己的锁,不能删别人的
  • 原子性:加锁和释放锁的操作必须是原子的
  • 可重入:同一线程可以多次加锁不死锁
  • 高可用:锁服务本身不能是单点

Q:Redis 分布式锁的基本实现是什么?

SET lock:key uuid NX EX 30

释放时使用 Lua 脚本保证原子性

Q:为什么加锁要用 SET NX EX 一条命令,而不是 SETNX + EXPIRE 两条?

两条命令不是原子操作,SETNX 成功后进程崩溃,EXPIRE 没有执行,锁永远不会过期,造成死锁。

Redis 2.6.12 之后支持 SET key value NX EX seconds 一条命令原子完成,必须用这个。

Q:为什么释放锁要用 Lua 脚本?直接 DEL 有什么问题?

GET + DEL 两步不是原子操作,中间锁可能过期被别人拿走,导致误删别人的锁。Lua 在 Redis 中原子执行,整个过程不会被其他命令插入,彻底避免这个问题。

Q:锁过期了但业务还没执行完怎么办?

  1. 评估业务最大执行时间,设置比它更长的 TTL。
  2. Redisson 的看门狗机制自动续期,不指定 leastTime (default = 30s)时,每隔 leastTime/3 检查一次,如果业务还未执行完成就重置过期时间为 30s 直至业务完成主动释放

Q:加锁的 value 为什么要用 UUID?用线程 ID 不行吗?

线程 ID 在单机内唯一,而在分布式环境下可能存在相同的线程 ID,会导致锁会被其他线程误删

而 UUID 是全局唯一的,可以精确标识哪台机器的哪个线程,并且 Redisson 的实现是 UUID + 线程ID,更严谨。

Q:Redisson 的看门狗原理是什么?什么时候不生效?

原理:Redisson 加锁时如果不指定 leastTime,后台会启动一个定时任务 WatchDog,并且每隔 leastTime / 3 检查当前线程是否还持有锁,是的话重置过期时间为 leastTime。线程崩溃后 watchdog 也会停,锁自动在 30s 后过期

不生效情况:显式调用 lock.lock(leaseTime, unit)手动设置过期时间,Redisson 认为你自己管理过期时间

Q:Redisson 如何实现可重入锁(Reentrant Lock)?

用 Hash 结构存锁信息,field 是线程标识,value 是重入次数:

bash 复制代码
HSET lock:key  "uuid:threadId"  1   # 第一次加锁
HSET lock:key  "uuid:threadId"  2   # 同一线程重入
# 释放一次 → 2-1=1
# 再释放   → 1-1=0 → DEL lock:key

加锁和释放都用 Lua 脚本保证原子性,重入次数归零才真正删除 key。

Q:tryLock 等待时用的是轮询吗?

不是,用的是 发布订阅机制,避免无效轮询浪费 CPU:

复制代码
线程B tryLock 失败
 ↓
订阅锁释放的频道(不占 CPU,进入等待)
 ↓
线程A 释放锁,发布释放消息
 ↓
线程B 收到消息,重新尝试加锁

比 while(true) { sleep; tryLock } 更高效,减少 Redis 压力。

Q:主从切换会导致锁丢失吗?怎么解决?

会。Redis 主从是异步复制,主节点加锁成功但还没同步到从节点时主节点宕机,从节点升为新主,锁数据丢失,另一个线程可以在新主上加同一把锁,导致两个线程同时持有锁。

解决方案一:RedLock 向 N 个(通常5个)独立 Redis 主节点同时加锁,超过半数成功才算加锁成功,单个节点宕机不影响锁的有效性。

解决方案二:业务层容忍 大多数业务接受极小概率的锁失效,用幂等 + 补偿机制兜底,不上 RedLock。

Q:RedLock 有什么争议?

Martin Kleppmann(《DDIA》作者)指出 RedLock 无法解决 GC Stop-The-World 问题:

复制代码
线程A 在5个节点加锁成功
线程A 发生长时间 GC 停顿(几秒)
期间锁在所有节点过期
线程B 加锁成功
GC 结束,线程A 认为自己还持有锁,继续执行
→ 两个线程同时在临界区!

antirez 认为这是极端情况,实际概率极低,RedLock 已经够用。

结论: 真正需要强一致的场景(金融核心链路),用 ZooKeeper 或数据库锁,Redis 分布式锁定位是高性能,不是强一致。

Q:分布式锁有哪些实现方式?怎么选?

Redis ZooKeeper 数据库
性能 最高 中 最低
可靠性 中(异步复制) 高(强一致) 高
实现复杂度 低 中 低
死锁风险 有过期时间兜底 临时节点自动删除 需手动处理
适用场景 高并发,允许极小概率失效 强一致要求 并发量低

日常业务首选 Redisson ;强一致场景用 ZooKeeper (Curator 封装);并发量极低的简单场景可以用数据库 SELECT FOR UPDATE。

Q:如何用数据库实现分布式锁?

sql 复制代码
-- 加锁(利用唯一索引)
INSERT INTO distributed_lock(lock_key, holder, expire_time)
VALUES ('order:1001', 'uuid', NOW() + 30s);
-- 插入成功 = 加锁成功,失败 = 锁被占用

-- 释放锁
DELETE FROM distributed_lock
WHERE lock_key = 'order:1001' AND holder = 'uuid';

缺点:性能差(磁盘 IO)、需要定时清理过期锁、没有阻塞等待机制。

总结

问题 关键词
基础实现 SET NX EX + Lua 释放
防死锁 EX 过期时间
防误删 UUID 标识持有者
锁超时 Redisson 看门狗续期
可重入 Hash 存重入次数
等待机制 发布订阅,非轮询
主从安全 RedLock(有争议)
生产选型 Redisson,强一致用 ZK

五、高可用

高可用的三个层次

复制代码
主从复制
  └── 解决:单点故障读压力,提供数据备份
        但:主节点宕机需要手动切换

哨兵(Sentinel)
  └── 解决:主从基础上实现自动故障转移
        但:写操作只有一个主节点,存储容量有限

集群(Cluster)
  └── 解决:数据分片,突破单机容量和写性能瓶颈

主从复制

三种模式:全量复制、基于长连接的命令传播、增量复制。

是什么

一个主节点(master)+ 多个从节点(slave),主节点负责读写,从节点只读,主节点的数据自动同步到从节点。

复制代码
         写
客户端 ──→ Master ──┬──→ Slave1(只读)
         读        └──→ Slave2(只读)
                   └──→ Slave3(只读)
配置
bash 复制代码
# 从节点配置文件里加一行
replicaof 192.168.1.1 6379

# 或运行时动态配置
REPLICAOF 192.168.1.1 6379
第一次同步:全量复制

从节点第一次连接主节点,触发全量同步(主服务器会把所有的数据都同步给从服务器):

复制代码
Slave                          Master
  │                               │
  ├── 发送 PSYNC ? -1 ─────────→  │  (我是新节点,不知道runID和offset)
  │                               │
  │  ← 返回 FULLRESYNC            │  (给你我的 runID 和当前 offset)
  │    runID offset ──────────────┤
  │                               ├── 执行 BGSAVE,生成 RDB
  │                               ├── 期间新写命令存入 replication buffer
  │  ← 发送 RDB 文件 ─────────────┤
  │                               │
  ├── 加载 RDB                    │
  │                               │
  │  ← 发送 replication buffer ───┤  (补发 RDB 期间的增量命令)
  │                               │
  ├── 执行增量命令                 │
  │                               │
  └── 同步完成                    │
  1. 建立连接:执行 psync 拿主服务器的 runID 和复制进度 offset,主服务器收到后响应 FULLRESYNC(全量复制) 以携带 runID 和 offset。
    • runID:每个 Redis 服务器启动时产生的随机 ID 用于标识自己,第一次同步被设置为 ?
    • offset:复制进度,第一次同步设置为 -1
  2. 开始同步:Master 执行 bgsave 异步生成 RDB 文件后发给 Slave,Slave 收到后清空当前数据并载入 RDB 文件,该过程中的写命令存入 replication buffer(主从一致):
    • Master 生成 RDB 文件期间
    • Master 发送 RDB 文件给 Slave 期间
    • Slave 加载 RDB 文件期间
  3. 发送命令:Slave 完成载入后回复一个 ACK,接着 Master 将 replication buffer 缓冲区的命令发送出去,然后 Slave 执行,此时主从一致。

我们还可以通过将 Slave 作为 Master 继续 replicaof,从而减轻主服务器的压力

日常同步:基于长连接的命令传播

完成第一次同步后,为保证后续主从一致,双方之间会维护一个 TCP 连接,并且是长连接(避免频繁的 TCP 连接和断开带来的性能开销)。主节点每执行一条写命令,实时通过这条长连接发给从节点,从节点同步执行。

复制代码
主节点执行:SET name xmon
    ↓
通过长连接实时发送给所有从节点
    ↓
从节点执行:SET name xmon
    ↓
数据保持一致

这是正常运行期间持续同步的核心机制。

注意:主从同步是异步的,主节点不等从节点确认就返回客户端,所以极端情况下(主节点宕机)可能丢失少量数据。

心跳检测:

长连接期间主从之间有心跳机制互相检测对方是否存活:

复制代码
从节点每秒发送:REPLCONF ACK offset
  → 告诉主节点自己当前同步到哪个 offset
  → 主节点借此判断从节点是否存活、同步是否有延迟

主节点每 10s 发送:PING
  → 检测从节点是否存活
断线重连:增量复制

从节点断线重连后触发,不重新做全量复制,只补同步断线期间缺失的数据。

复制代码
Slave                              Master
  │                                   │
  ├── 断线重连                         │
  ├── 发送 PSYNC runID offset ───────→ │
  │    (我记得你的 runID,我同步到 offset X)
  │                                   │
  │                        Master 检查:
  │                        ① runID 是我吗? ✅
  │                        ② offset 还在 repl_backlog_buffer 范围内吗?
  │                           ✅ → 增量复制
  │                           ❌ → 重新全量复制
  │                                   │
  │  ← 发送 offset 之后的命令 ────────  │
  │                                   │
  └── 执行增量命令,同步完成            │
复制代码

增量复制时,主服务器怎么知道要将哪些增量数据发送给从服务器呢?

  • repl_backlog_buffer:环形缓冲区,用于主从断练后找寻差异数据
  • replication offset:标记上面缓冲区的同步进度,主从都有各自的偏移量

repl_backlog_buffer 缓冲区何时写入?

重连后,Slave 会发送 psync 将自己的复制偏移量 slave_repl_offset 发送给 Master,Master 则根据自己和 Slave 的复制偏移量决定从服务器执行哪种同步操作:

  • offset 还在缓冲区内 → 增量复制
  • offset 已被覆盖 → 只能全量复制

断线时间越长、写流量越大,越容易触发全量复制,建议适当调大:

bash 复制代码
repl-backlog-size 10mb
主从复制的问题
复制代码
主节点宕机
    ↓
从节点有数据,但无法自动升为主节点
    ↓
需要人工介入,修改配置,重启
    ↓
期间服务不可用

→ 这就是哨兵要解决的问题

哨兵(Sentinel)

是什么

哨兵是一组独立的 Redis 进程(通常 3 个,设定为奇数),专门负责监控主从节点,自动完成故障转移。

复制代码
Sentinel1  Sentinel2  Sentinel3
    │           │           │
    └───────────┼───────────┘
                │ 监控
         ┌──────┴──────┐
       Master        Slave1
                     Slave2
哨兵的三大职责
  1. 监控(Monitoring)

    每个哨兵每隔 1 秒向主从节点发送 PING,检查是否在线。

    复制代码
    节点回复 PONG → 正常
    节点超时无响应 → 该哨兵标记为【主观下线(SDOWN)】
  2. 仲裁(Quorum)

    单个哨兵说宕机了不算,需要多数哨兵达成共识才能确认:

    复制代码
    Sentinel1:Master 没响应,我认为它主观下线
        ↓
    Sentinel1 询问 Sentinel2、Sentinel3:你们觉得 Master 还活着吗?
        ↓
    超过 quorum 数量(默认2,默认哨兵/2+1)的哨兵都认为下线
        ↓
    确认【客观下线(ODOWN)】,触发故障转移

    为什么需要多数仲裁? 防止网络抖动导致的误判,一个哨兵网络隔离了,不代表主节点真的宕机。

  3. 故障转移(Failover)

    确认主节点客观下线后,哨兵们先选出一个 Leader 哨兵来执行故障转移:

    复制代码
    Leader 哨兵选举(Raft 算法):
      每个哨兵给自己投票,同时拉票
      第一个获得超过半数票的哨兵成为 Leader

    Leader 哨兵执行故障转移:

    1. 从所有 Slave 中选出新 Master(选举规则见下)
    2. 让选出的 Slave 执行 SLAVEOF NO ONE(脱离从节点身份)
    3. 让其他 Slave 执行 SLAVEOF 从而指向新 Master 地址
    4. 通知客户端新 Master 地址(通过 pub/sub 频道)
    5. 原 Master 恢复后,作为新 Master 的 Slave 加入
新 Master 的选举规则

按优先级依次判断:

复制代码
① slave-priority(replica-priority)配置值,越小越优先
   → 可以手动指定哪个从节点优先升主

② 与原 Master 数据最接近的(offset 最大的)
   → 数据最全,丢失最少

③ runID 最小的
   → 前两条都一样时的最终决策
哨兵的问题
复制代码
哨兵解决了高可用,但写操作仍然只有一个 Master
→ 写性能无法水平扩展
→ 单机内存上限就是存储上限(比如最多 64GB)
→ 数据量很大时,单机撑不住

→ 这就是 Cluster 要解决的问题

Cluster 集群

是什么

Redis Cluster 把数据分散存储到多个主节点,每个主节点负责一部分数据,同时每个主节点有自己的从节点做高可用。

复制代码
Master1(slot 0~5460)     + Slave1
Master2(slot 5461~10922) + Slave2
Master3(slot 10923~16383)+ Slave3
数据分片:哈希槽(Hash Slot)

Redis Cluster 使用 CRC16 哈希算法把所有数据映射到 16384 个 slot(哈希槽):

复制代码
key 属于哪个 slot:
  slot = CRC16(key) % 16384

slot 属于哪个节点:
  由集群配置决定,比如 0~5460 在 Master1

为什么是 16384 个槽? 16384 = 2^14,用 bitmap 表示每个节点负责的槽,每个节点只需要 2KB 就能存完整的槽位图,节点间心跳包传输成本低。

一致性哈希 vs 哈希槽:

一致性哈希 哈希槽
扩容影响 只影响相邻节点 需要手动迁移槽
数据倾斜 可能不均匀 可以精确控制
实现复杂度 高 简单直观

由上我们可以知道数据是如何分的了,那么,客户端是怎么知道该访问哪台 Redis 的?

客户端是怎么知道该访问哪台机器的?
客户端自行缓存「槽 → 节点」的地图

客户端可以连接任意节点,在启动的时候,会主动去任意一个节点拉一份「哪个槽归哪台节点管」的映射表,把它缓存在自己本地,节点会计算 key 应该在哪个槽,如:0 ~ 5460 号槽在 A 节点等。

客户端访问 key 的流程如下:

  1. CRC16 计算 key 所属的槽位
  2. 查看地图,查看这个槽归哪个节点
  3. 向节点发请求

整个过程都是本地计算,并且一次请求就能命中,效率高,这种带本地缓存地图的客户端被称为Smart Client(智能客户端)。Java的Jedis、Lettuce,Go 的 go-redis,甚至你用 redis-cli -c 加了-c 参数后,都是 Smart Client的行为。

地图过期:MOVED 重定向

由于该地图是客户端一次性拉取下来的,有一定的时效性,后续的集群的扩缩容会导致其过期,因此 Redis 的解决方案称为 MOVED 重定向,过程如下:

复制代码
客户端 → 访问 key="name" → 连接 Master1(地图上显示,但是已过期,槽已被迁移到 Master2)
Master1 计算:slot = CRC16("name") % 16384 = 5798
5798 不在我的范围(0~5460),在 Master2

Master1 返回:(error)MOVED 5798 192.168.1.2:6379(通知客户端)

客户端 → 立即更新本地地图 → 重新连接 Master2 → 访问成功
槽迁移:ASK 重定向

新增节点(扩缩容)时,把部分槽从旧节点迁移到新节点:

复制代码
原来:Master1(0~5460),Master2(5461~10922),Master3(10923~16383)
新增:Master4

迁移:从 Master1、Master2、Master3 各拿出一部分槽给 Master4
之后:每个节点负责约 4096 个槽

迁移过程中,槽处于 MIGRATING(迁出) 和 IMPORTING(迁入) 状态,期间的请求用 ASK 重定向处理,不影响正常访问,错误消息如下:

复制代码
(error) ASK 100 192.168.1.2:6379
意思是:这个 key 这一次你去 Master2 问问看,但这只是临时的,不要更新你的地图

客户端(不更新本地地图) → 发送 ASKING 给 Master2 → 重新连接 Master2 → 访问成功

MOVED 和 ASK 核心区别:

MOVED 是永久搬家通知 ,客户端要更新地图;ASK是临时借道通知,客户端这一次跳过去,地图不动。

节点之间同步信息:Gossip 协议

总结:Gossip 协议让每个节点定期随机找几个邻居互换信息,像流言一样扩散,最终整个集群达成一致。优点是去中心化、扩展性强、容错好;缺点是只能做到最终一致,有传播延迟。Redis Cluster 用它来同步节点状态和槽位信息,故障检测用 PFAIL/FAIL 两阶段仲裁防止误判。

Gossip 是一种去中心化的信息传播协议,灵感来自现实中的流言传播:

复制代码
你告诉两个朋友一个消息
这两个朋友各自再告诉两个朋友
...
最终所有人都知道了这个消息

集群总线端口(cluster bus)不是 6379(业务端口),而是 16379(业务端口 + 10000),注意:部署 Cluster 时,两个端口都要放行

核心思想

每个节点周期性地随机选几个邻居节点,互相交换自己知道的信息,经过若干轮传播后,整个集群的所有节点都能获得一致的状态。

复制代码
集群有 A B C D E 五个节点

第1轮:
  A 随机选 B、C → 把自己的状态发给 B、C
  B 随机选 A、D → 把自己的状态发给 A、D
  ...

第2轮:
  B 已经知道了 A 的状态,再传播给 D、E
  ...

几轮之后:所有节点都知道整个集群的状态
Gossip 消息类型

Redis Cluster 中节点间交换四种消息:

  • PING

    复制代码
    节点 A 定期向随机选取的几个节点发送 PING
    携带信息:
      - 自己的状态(地址、负责的槽位、主从关系)
      - 自己知道的部分其他节点状态
  • PONG

    复制代码
    收到 PING 的节点回复 PONG
    携带信息:同 PING,也会带上自己知道的节点状态
    双向交换信息,一次通信两个节点都能更新状态
  • MEET

    复制代码
    新节点加入集群时发送 MEET 消息
    告诉已有节点:我来了,把我加入集群
    收到 MEET 的节点回复 PONG,建立连接
    之后新节点的信息通过 Gossip 扩散到整个集群
  • FAIL

    复制代码
    某节点被判定为故障时,立即向所有节点广播 FAIL 消息
    (这里不用 Gossip 随机传播,而是直接广播,加快故障感知速度)
Gossip 的特性
  • 优点

    1. 去中心化:

      复制代码
      没有中心节点,任何节点都对等
      不存在单点故障,一个节点宕机不影响协议运行
    2. 扩展性强:

      复制代码
      新节点加入只需要发送 MEET 给任意一个节点
      信息自动扩散到整个集群,不需要通知所有节点
    3. 容错性强:

      复制代码
      消息会通过多条路径传播
      即使部分节点宕机或消息丢失,信息仍能最终传达
    4. 实现简单:

      复制代码
      每个节点逻辑相同,只需要随机选邻居互换信息
      不需要复杂的全局协调机制
  • 缺点

    1. 最终一致性,不是强一致:

      复制代码
      信息传播需要时间(几轮 Gossip 后才能扩散完)
      在信息完全扩散前,不同节点看到的集群状态可能不同
    2. 消息冗余:

      复制代码
      同一条信息会被多个节点重复传播
      节点越多,冗余消息越多
    3. 传播延迟:

      复制代码
      信息扩散速度 = O(log N)(N 为节点数)
      不适合对实时性要求极高的场景
      (这也是 FAIL 消息改用广播而非 Gossip 的原因)
对比其他一致性协议
Gossip Raft Paxos
一致性 最终一致 强一致 强一致
中心节点 无 有 Leader 有
实现复杂度 低 中 高
扩展性 强 一般 一般
适用场景 状态同步、成员发现 日志复制、选举 分布式事务
典型应用 Redis Cluster、Cassandra etcd、Raft Redis ZooKeeper
集群的故障转移 failover
主从关系

Redis Cluster 里的每个主节点,都可以配一个或多个从节点(默认 3 主 3 从),但从节点默认不对外提供读写服务,如果连上从节点去 GET 一个 key,从节点会返回 MOVED 让你回去找主节点(READONLY 可更改)

节点故障检测
  1. 主观下线(PFAIL, Probably FAILed)

    复制代码
    节点 A 向节点 B 发送 PING
    超过 cluster-node-timeout(默认15s)没有收到 PONG
    → 节点 A 将节点 B 标记为 PFAIL(主观下线)
    → 只是 A 自己认为,可能是网络抖动
  2. 客观下线(FAIL)

    复制代码
    节点 A 在 Gossip 消息里传播"B 可能挂了"
    其他节点也陆续反馈对 B 的 PFAIL
    当超过集群半数的主节点都认为 B 是 PFAIL
    → 将 B 标记为 FAIL(客观下线)
    → 广播 FAIL 消息,触发故障转移
Cluster 的选举机制

类似 Raft 的投票机制,流程大概如下:

  1. 资格检查:cluster-replica-validity-factor 参数判断从节点与节点是否失联太久,如果失联很久则没资格。
  2. 排队发起选举:Slave 按照与 Master 的数据同步进度排队,复制的越新的 Slave 等待时间越短。
  3. 发起选举,请求投票:Slave 开始拉票(只有持有槽的主节点才有投票权)。
  4. 收集选票
  5. 成为新主:半数以上主节点的票当选新主
Cluster 的限制
复制代码
① 不支持多 key 事务(key 可能在不同节点)
   解决:用 hash tag 强制同类 key 落在同一个槽
   {user}:name 和 {user}:age → CRC16 只算 {} 里的内容 → 同一个槽

② 只有 db0(不支持 SELECT 切换数据库)

③ 批量操作(MSET/MGET)要求所有 key 在同一个槽

④ 最少需要 3 主 3 从,共 6 个节点

三种方案对比

主从复制 哨兵 Cluster
高可用 ❌ 需手动切换 ✅ 自动故障转移 ✅ 自动故障转移
读扩展 ✅ 从节点分担读 ✅ ✅
写扩展 ❌ 只有一个主 ❌ 只有一个主 ✅ 多主分片
容量扩展 ❌ ❌ ✅
复杂度 低 中 高
适用场景 读多写少,数据量小 需要自动故障转移 数据量大,高并发写

面试

Q:主从复制是同步还是异步?会丢数据吗?

异步复制,主节点不等从节点确认就返回给客户端。主节点宕机时,未同步到从节点的数据会丢失。可以用 min-replicas-to-write 要求至少 N 个从节点同步成功才返回,降低丢数据风险,但影响写性能。

Q:哨兵为什么至少要部署 3 个?

需要多数仲裁,2 个哨兵时一个挂了,剩下 1 个无法达成多数(需要 >=2),集群失去故障转移能力。3 个哨兵允许 1 个挂掉还能正常工作。同理,哨兵数量建议是奇数。

Q:Cluster 为什么是 16384 个槽而不是 65536 个?

节点间心跳包里包含槽位图,16384 个槽用 bitmap 表示只需 2KB;65536 个槽需要 8KB,心跳包太大,网络开销高。且一般 Redis Cluster 不超过 1000 个节点,16384 个槽完全够用。

Q:脑裂问题是什么?如何解决?

网络分区导致集群中出现两个 Master,各自接受写入,网络恢复后数据冲突。 解决:设置 min-replicas-to-write 1,要求至少 1 个从节点在线才接受写入,孤立的 Master 自动停止写服务,避免脑裂期间产生脏数据。

总结

主从 解决读扩展和数据备份,哨兵 在主从基础上加了自动故障转移,Cluster 在哨兵基础上加了数据分片突破单机容量瓶颈。三者层层递进,根据数据量和可用性要求选型:小项目主从够了,中型项目上哨兵,大规模高并发上 Cluster。

六、原理

Redis 为什么这么快?

复制代码
① 纯内存操作(最核心)
② 单线程模型,避免线程切换和锁竞争
③ IO 多路复用,单线程处理大量并发连接
④ 高效的数据结构(SDS、跳表、ziplist...)
⑤ 渐进式 rehash、惰性删除等机制避免集中阻塞

之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

  • Redis 大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • 采用单线程模型可以避免多线程之间的竞争,省去多线程切换带来的时间和性能上的开销,而且也不会导致死锁
  • 采用 I/O 多路复用机制处理大量的客户端 Socket 请求

单线程模型

Redis 到底哪里是单线程?

这个问题要说清楚,Redis 并不是所有地方都单线程:

复制代码
单线程的部分:
  命令处理(网络读写 + 命令执行)← 核心,这里是单线程

多线程的部分(Redis 4.0+):
  AOF 刷盘
  RDB 持久化(fork 子进程)
  大 key 异步删除(UNLINK)
  
多线程的部分(Redis 6.0+):
  网络 IO 读写(多线程解析请求、写回响应)
  但命令执行本身仍然是单线程

所以准确的说法是:命令执行是单线程的,网络 IO 在 6.0 后引入了多线程。

为什么命令执行用单线程?
复制代码
多线程的代价:
  ① 线程切换(上下文切换)消耗 CPU
  ② 共享数据需要加锁(synchronized / mutex)
  ③ 锁竞争、死锁问题
  ④ 代码复杂度高

Redis 的命令执行是纯内存操作,本身极快(微秒级)
瓶颈不在 CPU,而在网络 IO
→ 多线程带来的收益远小于它的开销
→ 单线程反而更简单、更快
单线程的执行流程
复制代码
Redis 启动
    ↓
初始化 server,创建 epoll 实例
    ↓
监听端口,注册 accept 事件
    ↓
进入事件循环(死循环):
    ├── epoll_wait 等待事件(可读/可写)
    ├── 处理文件事件(客户端请求)
    │     ├── 新连接:accept,注册读事件
    │     ├── 可读:读取命令,解析,执行,写响应到缓冲区
    │     └── 可写:把缓冲区响应发给客户端
    └── 处理时间事件(定时任务,如定期删除过期key)

整个过程在一个线程里完成,没有锁,没有切换。

Redis 6.0 为什么引入多线程网络 IO?
复制代码
随着网络带宽提升,瓶颈从内存操作转移到了网络 IO:
  读取客户端请求(read syscall)
  解析请求协议(RESP 协议)
  把响应写回客户端(write syscall)

这些 IO 操作比命令执行慢得多,单线程处理成了瓶颈

Redis 6.0 方案:
  多个 IO 线程并行读取请求、解析协议、写回响应
  但命令执行仍然由主线程串行处理(保证线程安全)
IO 线程1 ──→ 读取并解析请求 ──┐
IO 线程2 ──→ 读取并解析请求 ──┼──→ 主线程串行执行命令 ──→ IO 线程并行写响应
IO 线程3 ──→ 读取并解析请求 ──┘

IO 多路复用

先理解网络 IO 的问题

假设 Redis 要同时处理 10000 个客户端连接,朴素的做法:

复制代码
方案一:每个连接一个线程
  10000 个连接 = 10000 个线程
  → 内存爆炸(每个线程约 1MB 栈)
  → 线程切换开销极大

方案二:单线程轮询
  while(true) {
      for each connection:
          if has data: read and process
  }
  → 大量无效轮询,CPU 空转
  → 延迟高

IO 多路复用 是正确解法:用一个线程监听多个连接,哪个连接有事件就处理哪个,没有事件就阻塞等待,不浪费 CPU。

三种 IO 多路复用机制
select(最古老)
c 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds, ...);

// 原理:
// 把所有 fd(文件描述符)复制到内核
// 内核遍历所有 fd,检查哪些有事件
// 返回有事件的 fd 数量,用户再遍历找出哪些有事件
缺点:
  ① fd 数量上限 1024(FD_SETSIZE)
  ② 每次调用都要把全部 fd 从用户态复制到内核态
  ③ 内核遍历所有 fd,O(n) 复杂度
  ④ 返回后用户还要遍历一遍找出就绪的 fd
poll(改进版 select)
c 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
改进:用链表存 fd,去掉了 1024 上限
仍然存在:
  ① 每次调用复制全部 fd 到内核
  ② O(n) 遍历
  ③ 返回后用户还要轮询
epoll(Linux 主流,Redis 使用)
c 复制代码
// 三个核心函数
int epoll_create(int size);                           // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, ...);         // 注册/修改/删除 fd
int epoll_wait(int epfd, epoll_event *events, ...);   // 等待事件

核心改进:

复制代码
① 内核维护一棵红黑树存所有监听的 fd
   → 增删 fd 是 O(log n),不需要每次复制全部 fd

② 内核维护一个就绪链表
   fd 有事件时,硬件中断回调把该 fd 加入就绪链表
   → epoll_wait 只返回就绪的 fd,不用遍历全部

③ 返回的就是就绪事件列表
   → 用户不需要再轮询一遍
性能对比:

          select / poll       epoll
fd 复制    每次全部复制        只注册一次
时间复杂度  O(n)              O(1)(就绪事件数)
fd 上限    1024 / 无限        无限
返回结果    需要用户轮询        直接返回就绪 fd
epoll 的两种触发模式

水平触发(LT,Level Triggered)默认

复制代码
fd 有数据未读完 → 每次 epoll_wait 都会通知
→ 不容易漏事件,编程简单

边缘触发(ET,Edge Triggered)

复制代码
fd 状态变化时只通知一次 → 必须一次性读完所有数据
→ 减少系统调用次数,性能更高
→ 编程复杂,必须配合非阻塞 IO

Redis 使用的是水平触发,编程简单,不容易丢事件。

Redis 的事件驱动模型

Redis 自己封装了一套事件驱动框架(ae),屏蔽了底层 select/epoll 的差异:

复制代码
┌─────────────────────────────────────┐
│           Redis ae 事件库             │
├─────────────────────────────────────┤
│  epoll(Linux)                      │
│  kqueue(macOS/BSD)                 │
│  select(Windows/兜底)              │
└─────────────────────────────────────┘

两类事件:

复制代码
文件事件(File Event):网络 IO 相关
  ├── 连接事件:新客户端连接(acceptTcpHandler)
  ├── 读事件:客户端发来命令(readQueryFromClient)
  └── 写事件:把响应写回客户端(sendReplyToClient)

时间事件(Time Event):定时任务
  └── serverCron:每 100ms 执行一次
        ├── 定期删除过期 key
        ├── 持久化检查
        ├── 主从同步
        └── 统计信息更新...

完整处理流程:

复制代码
epoll_wait(阻塞等待事件,超时时间 = 最近时间事件的剩余时间)
    ↓ 有事件
处理所有就绪的文件事件(网络读写)
    ↓
检查时间事件是否到期,到期则执行
    ↓
回到 epoll_wait

Redis 事务

基本命令
bash 复制代码
MULTI           # 开启事务,后续命令进入队列
SET key1 val1   # 入队(不立即执行)
SET key2 val2   # 入队
INCR counter    # 入队
EXEC            # 提交,按顺序执行队列里的命令
DISCARD         # 放弃事务,清空队列
bash 复制代码
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET name xmon
QUEUED                   ← 注意是 QUEUED,不是执行结果
127.0.0.1:6379> INCR age
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 22
WATCH:乐观锁
bash 复制代码
WATCH key1 key2     # 监听 key,事务执行前如果被修改,事务自动失败

MULTI
SET key1 newval
EXEC                # 如果 key1 在 WATCH 之后被其他客户端修改 → 返回 nil(事务取消)
经典用法(类似 CAS):

WATCH balance                  # 监听余额
val = GET balance               # 读取当前值
MULTI
SET balance val-100             # 扣款
EXEC                            # 如果期间 balance 被改了,返回 nil
                                # 应用层重试
Redis 事务和 MySQL 事务的区别
Redis 事务 MySQL 事务
原子性 弱(命令运行时错误不回滚) 强(任何错误全部回滚)
隔离性 执行期间不被打断 多种隔离级别
回滚 ❌ 不支持 ✅ 支持
持久性 依赖持久化配置 WAL 保证
Redis 事务的两种错误
  1. 语法错误(入队时报错)

    整个事务不执行:

    复制代码
    MULTI
    SET name xmon        # QUEUED
    WRONGCMD             # ERROR:未知命令
    SET age 21           # QUEUED
    EXEC                 # 返回错误,整个事务取消
  2. 运行时错误(执行时出错)

    只有出错的命令失败,其他命令正常执行,不回滚:

    复制代码
    MULTI
    SET name xmon        # QUEUED(正确命令)
    INCR name            # QUEUED(name是字符串,INCR会出错,但入队成功)
    SET age 21           # QUEUED(正确命令)
    EXEC
    1) OK                # SET name 成功
    2) ERR               # INCR name 失败,但不影响其他
    3) OK                # SET age 成功

    这是 Redis 事务最大的局限:不支持运行时回滚。 Redis 认为运行时错误是编程错误,不需要回滚机制。

Q:事务为什么不支持回滚?

A:Redis 官方的解释:

  • Redis 命令只有在语法正确且数据类型匹配时才会失败,这类错误是编程失误,应该在开发阶段发现
  • 不支持回滚让 Redis 内部实现更简单、更快

面试

Q:Redis 单线程为什么还这么快?

纯内存操作本身极快;单线程避免了线程切换和锁竞争开销;IO 多路复用(epoll)用一个线程高效处理大量并发连接;命令执行是 O(1) 或 O(log n);数据结构针对性优化(SDS、跳表、ziplist)。

Q:Redis 6.0 的多线程和之前有什么区别?

之前完全单线程;6.0 对网络 IO 部分(读请求、解析协议、写响应)引入多线程提高吞吐量,但命令执行本身仍是单线程串行,保证数据安全,不需要加锁。

Q:epoll 比 select 好在哪里?

select 每次调用需要把全部 fd 复制到内核,O(n) 遍历,fd 上限 1024;epoll 用红黑树维护 fd,只在注册时复制一次,内核通过回调维护就绪链表,epoll_wait 直接返回就绪 fd,O(1) 复杂度,无 fd 数量限制。

Q:Redis 事务能保证原子性吗?

不完全能。语法错误(入队时报错)会导致整个事务取消;但运行时错误(执行时出错)不会回滚,其他命令照常执行,这点和 MySQL 的原子性不同。严格来说 Redis 事务只保证命令按顺序执行且不被打断,不保证要么全成功要么全失败。

总结

复制代码
单线程模型:
  命令执行单线程,避免锁竞争,简单高效
  6.0+ 网络IO多线程,命令执行仍单线程

IO 多路复用:
  epoll 监听大量连接,有事件才处理,不空转
  文件事件(网络IO)+ 时间事件(定时任务)构成事件循环

过期删除:
  惰性删除 + 定期删除配合,expires 字典存过期时间戳

内存淘汰:
  8种策略,生产推荐 allkeys-lru/lfu

事务:
  MULTI/EXEC 保证命令顺序执行不被打断
  不支持运行时回滚,不是真正的强原子性
  WATCH 实现乐观锁,应对并发冲突
相关推荐
ShineWinsu10 小时前
对于Redis:Hash类型的解析
c++·redis·分布式·缓存·面试·hash·哈希表
.Hypocritical.10 小时前
Redis从入门到实战:核心原理、场景落地与避坑指南
数据库·redis·缓存
用户62228840481911 小时前
Redis 分布式锁:从 2.8 之前到 2.8+,一篇讲透
redis
程序猿乐锅13 小时前
【黑马点评 | 第十一篇】关注 Feed 流实现
java·数据库·redis·分布式·后端·缓存·maven
ShineWinsu14 小时前
对于Redis:List类型的解析
数据库·c++·redis·链表·缓存·面试·list
XS03010615 小时前
Redis 学习指南
数据库·redis·缓存
夕除15 小时前
redis--集群
java·redis
喜欢的名字被抢了15 小时前
09-Redis 进阶原理篇:单线程、多线程、过期、LRU-LFU、Fork 与 Lua
数据库·redis·lua
zl_dfq1 天前
Redis 之 【高可用架构】(从主从复制、哨兵到集群)
redis
hweiyu001 天前
Redis命令:HPERSIST
redis·缓存