Redis

数据结构

数据类型

String(字符串)

最简单的 Key-Value,二进制安全,最大 512MB

内部实现 :String 类型的底层主要由 **SDS (Simple Dynamic String)**​ 实现。SDS 相比 C 语言原生的字符串,有三大优势:

    1. 通过 len属性实现 O(1) 复杂度的长度获取
    1. 通过空间预分配和惰性空间释放机制减少内存重分配次数,拼接字符串之前会检查SDS空间是否满足要求,如果空间不够会自动扩容。
    1. 二进制安全,可以存储任意格式的数据,而不仅仅是文本

Redis 会根据存储的值动态选择三种编码格式:

  • int :当字符串对象保存的是整数值,并且这个整数值可以表示为long类型时,直接将数值存储在指针位置(省去 SDS 结构)。

  • embstr :当字符串长度 ≤ 44 字节(Redis 5.0后),分配一块连续内存存放 RedisObject 和 SDS,提升缓存局部性。

  • raw :当字符串长度 > 44 字节时,采用标准的 SDS 动态结构 。

应用场景与举例

  • 缓存对象

    sql 复制代码
    SET user:1 '{"name":"wzy", age:"24"}' # 缓存对象的JSON
    
    MSET user:1:name wzy user:1:age 24 user:2:name zy user:2:age 18
    
    SET user:1001:session "user_data" EX 3600  # 设置带过期时间的会话
  • 计数器 :利用 INCR命令的原子性实现阅读量统计。

    sql 复制代码
    INCR article:1001:view_count
  • 分布式锁 :在分布式系统中,当多个进程或服务需要互斥地访问共享资源时,分布式锁就变得至关重要。Redis 因其单线程执行和原子操作特性,成为实现分布式锁的常用选择。通过**SETNX命令**实现简易分布式锁。

    sql 复制代码
    # SET key unique_value NX PX timeout
    
    SET lock:order_123 8b1e6c53-ae2a-4fe6-875d-106e3d7a9e01 NX PX 10000

    NX:仅当键 lock:order_123不存在时设置,用于互斥获取。

    PX 10000:设置锁的过期时间为10000毫秒(10秒),这是避免死锁的关键。

    8b1e6c53...:一个全局唯一的随机值(如UUID),代表锁的持有者,是安全释放锁的前提。释放锁时,必须验证唯一标识符是否匹配,并使用Lua脚本保证原子性(解锁的过程是,将lock_key键删除,但不能乱删,需要先判断锁的unique_key是否为加锁客户端,是的话才将lock_key键删除)。

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

    这段脚本的执行逻辑是:如果锁当前的值与传入的标识符(ARGV[1])一致,才会删除锁键。这可以防止客户端误删了其他客户端持有的锁(例如,客户端A的锁因操作缓慢而过期,客户端B获取了锁,此时客户端A再执行删除就会误删客户端B的锁)

Hash(哈希)

Hash是一个键值对(key - value)集合,其value的形式如:value=[{field1, value1}, ..., {fieldN, valueN}]。Hash特别适合用于存储对象。

内部实现:Hash 类型的底层实现有两种:

  • ziplist (压缩列表) / listpack (紧凑列表,Redis 7.0+) :当元素数量较少(默认 ≤ 512 个)且每个元素值较小(默认 ≤ 64 字节)时使用。元素按 field-value成对连续存储,内存紧凑 。

  • hashtable (哈希表):当不满足上述条件时使用。其实现与 Java 的 HashMap 类似,采用链地址法解决哈希冲突,并支持渐进式 rehash 以避免长时间阻塞 。

应用场景与举例

  • 存储对象信息:例如存储用户信息,比 String 序列化对象更高效。

    sql 复制代码
    # 存储一个哈希表uid:1的键值
    HMSET uid:1 name Tom age 15
    
    # 存储一个哈希表uid:2的键值
    HMSET uid:2 name Jerry age 13
    
    # 获取哈希表用户id为1中所有的键值
    HGETALL uid:1
    1) "name"
    2) "Tom"
    3) "age"
    4) "15"
  • 购物车:用户 ID 作为 key,商品 ID 作为 field,商品数量作为 value。

    sql 复制代码
    HSET cart:1001 item:123 2  # 用户1001的商品123数量为2
    HINCRBY cart:1001 item:123 1  # 增加购物车中该商品数量
    HSET cart:1001 item:123 1 # 向购物车添加商品
    HLEN cart:1001 # 购物车商品总数
    HDEL cart:1001 item:123 # 删除商品
    HGETALL cart:1001 # 获取所有商品

    当前仅仅是将商品ID存储到了redis中,在回显商品具体信息时,还需要再拿商品ID查询一次数据库,获取商品的完整信息。

Set(集合)

Set类型是一个无序并且唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。

内部实现

  • intset (整数集合):当集合中的所有元素都是整数且元素数量较少(默认 ≤ 512 个)时使用。底层是有序整数数组,支持升级机制(如从 int16 升级到 int32)以节省内存 。

  • hashtable (哈希表) :当不满足 intset 条件时使用。字典的键存储集合元素,值统一为 NULL

应用场景与举例

  • 唯一性约束:例如记录文章点赞用户 ID,确保一个用户只能点一次赞。假设key是文章id,value是用户id:

    sql 复制代码
    # uid:1 用户对文章 article:1 点赞
    SADD article:1 uid:1
    
    # uid:2 用户对文章 article:1 点赞
    SADD article:1 uid:2
    
    # uid:3 用户对文章 article:1 点赞
    SADD article:1 uid:3
    
    # uid:1 用户取消了对article:1 点赞
    SREM article:1 uid:1
    
    # 获取所有对article:1文章点赞的用户
    SMEMBERS article:1
    1) "uid:3"
    2) "uid:2"
    
    # 获取article:1文章的点赞数量
    SCARD article:1
    
    # 判断用户uid:1是否对文章article:1点赞了
    SISMEMBER article:1 uid:1
    (integer) 0  # 返回0说明没点赞,返回1则说明点赞了
  • 共同关注(交集) :计算两个用户的共同好友,或者共同关注的公众号。key可以是用户id,value则可以是已关注的公众号的id。

    sql 复制代码
    # uid:1 用户关注公众号 id 为 5、6、7、8、9
    SADD uid:1 5 6 7 8 9
    # uid:2  用户关注公众号 id 为 7、8、9、10、11
    SADD uid:2 7 8 9 10 11
    
    # 获取共同关注的公众号
    SINTER uid:1 uid:2
    1) "7"
    2) "8"
    3) "9"
    
    给uid:2推荐uid:1关注的公众号:
    SDIFF uid:1 uid:2
    1) "5"
    2) "6"
    
    验证某个公众号是否被uid:1或uid:2关注:
    SISMEMBER uid:1 5
    (integer) 1 # 返回0,说明关注了
    SISMEMBER uid:2 5
    (integer) 0 # 返回0,说明没关注

Zset(有序集合)

Zset类型比Set类型多了一个排序属性score(分数),所以对于Zset来说每个存储元素由两个值组成,一个是Zset的元素值,一个是排序值。

Zset保留了Set中不能有重复成员的特性,但是Zset中元素不再是无序的,而是可以排序。

  • 内部实现:这是 Redis 唯一一个需要同时支持按成员访问和按分值范围访问的数据结构,因此采用了混合实现:

    • ziplist / listpack :元素数量较少(默认 ≤ 128 个)且每个元素值较小(默认 ≤ 64 字节)时使用。元素按 member-score对连续存储,并按分值排序 。

    • skip list (跳跃表) + dict (哈希表):这是最核心的实现方式。跳跃表支持高效的范围查询(如 ZRANGE),平均时间复杂度 O(log N);哈希表则用于实现 O(1) 复杂度的按成员访问分值(如 ZSCORE)。两个结构共享元素的成员和分值 。

  • 应用场景与举例

    • 排行榜:以游戏积分榜为例。

      sql 复制代码
      ZADD leaderboard 2500 "玩家A" 1800 "玩家B"  # 添加玩家及分数
      
      ZREVRANGE leaderboard 0 2 WITHSCORES  # 获取前三名及分数
      
      ZSCORE user:xiaolin:ranking arcticle:4 # 返回有序集合key中元素个数    
      "50"

List(列表)

List列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向List列表添加元素。

内部实现 :Redis 3.2 版本之后,List 的底层统一由 quicklist (快速列表) ​ 实现。quicklist 可以看作是由多个ziplist (或 listpack) 节点 连接而成的双向链表,兼顾了内存效率和操作性能。在 3.2 版本之前,会根据元素大小和数量在 ziplist 和 linkedlist 之间转换。

应用场景与举例

  • 消息队列 :利用 **LPUSH/BRPOP**实现简易消息队列(注意:List 无法重复消费,重要消息建议用 Stream)。

  • 不过,消费者读取数据时,有一个潜在的问题:生产者向消息队列List写入数据时,并不会主动通知消费者有新消息写入 ,如果消费者要及时处理消息,就必须在程序中不停地调用RPOP命令,导致消费者程序的CPU一直消耗在执行RPOP命令上,为了解决这个问题,Redis提供了BRPOP命令,也叫阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列。

    • 有序性:List本身就是按照先进先出的顺序对数据进行存储的,所以使用List作为消息队列保存消息可以满足消息有序的要求。

    • 重复消息:消费者要实现对重复信息的判断,需要2个方面的要求:

      • 每个消息有一个全局ID。

      • 消费者要记录已经处理过的消息ID。

      • 但是List不会为每个消息生成ID,所以我们需要自行给每个消息生成一个全局唯一ID

        sql 复制代码
        LPUSH mq "111000102:stock:99"
    • 可靠存储(消息不丢失):消费者从List读取一条消息后,List便不会再留存这条消息。为留存消息,Redis 提供了BRPOPLPUSH 模式(备份队列),这是一个原子性操作,它从一个列表弹出消息的同时,会将其插入到另一个备份列表。只有在业务逻辑处理完毕后,才手动从备份列表中删除该消息。如果处理过程中消费者崩溃,重启后它可以先检查备份队列,从中重新获取未完成的消息进行处理,从而保证消息至少被消费一次。

    • 在 Redis 5.0 之后,Streams ​ 数据结构被引入,它提供了更完善的消息队列功能,可视为 BRPOPLPUSH模式的官方增强版

      • 消息确认(ACK) :消费者从流中读取消息后,消息处于"待处理"状态。只有在消费者明确发送 XACK命令后,消息才会被标记为已处理。

      • 消费者组(Consumer Group) :Streams 原生支持消费者组,允许多个消费者并行处理同一个队列中的不同消息,极大地提高了吞吐量,这是 List 难以实现的。

      • 消息回溯:可以重新读取历史消息。

List 的主要优势在于轻量简单。如果你的项目已经使用 Redis,并且消息量不大、对可靠性要求不极端,List 是一个快速上手的方案。

List 的主要局限性包括

  • 消息堆积:如果生产速度远大于消费速度,消息会堆积在内存中,有撑爆内存的风险。

  • 功能单一:缺乏专业消息队列的高级功能,如消息重试、优先级队列、死信队列等。

数据结构

SDS

SDS 是 Redis 为替代 C 语言原生字符串(char*字符数组)而设计的基础数据结构,它是二进制安全的,并且具有高效的动态扩容能力。

  • SDS 的结构体包含**len(已用长度)、alloc(分配的总容量)和 buf[](存储数据的字符数组)** 。在 Redis 3.2 之后,为了极致优化内存,SDS 根据字符串长度划分了 sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64五种不同的头部结构,例如短字符串使用更小的 lenalloc字段以节省内存 。结构体使用 __attribute__ ((__packed__))关键字修饰,表示按 1 字节对齐,避免内存对齐带来的空间浪费 。

  • 关键特性

    • O(1) 复杂度获取长度 :直接访问 len属性,无需遍历 。

    • 杜绝缓冲区溢出 :在修改字符串前,SDS API 会检查剩余空间 (alloc - len),若不足则自动扩容,确保安全 。

    • 内存分配优化 :通过 空间预分配 ​ 和 惰性空间释放​ 策略减少内存重分配次数。当 SDS 长度小于 1MB 时,扩容会多分配一倍的空间;超过 1MB 后,则每次多分配 1MB。缩短字符串时,多余空间不会立即归还,而是留给后续操作使用 。

  • 二进制安全 :SDS 依据 len属性判断字符串结束,而非 \0字符,因此可以存储包含空字符在内的任意二进制数据 。

双向链表 (Linkedlist)

Redis 自己实现的双向无环链表,广泛用于列表键、发布订阅等功能。

核心结构 :链表节点通过 prevnext指针组成双向链路整个链表结构 (list) 维护了头节点 (head)、尾节点 (tail) 以及链表长度 (len) 等信息 。此外,它还封装了 dup(复制节点值)、free(释放节点值)和 match(比较节点值)等函数指针,使得链表可以存储各种类型的值,具备多态性 。

关键特性

  • 高效的节点操作:获取头尾节点、获取链表长度、在两端插入或删除节点的时间复杂度均为 O(1) 。

  • 内存开销:每个节点除了保存值,还需要两个指针(在 64 位系统下各占 8 字节),因此相对于存储相同数据的数组或压缩列表,内存开销较大 。

哈希表 (Hashtable / Dict)

哈希表是一种保存键值对(key - value)的数据结构,每个key是独一无二的。它的优势在于能以O(1)的复杂度快速查询数据,将key通过Hash函数的计算,就能定位数据在表中的位置。但是哈希表也会有哈希冲突的问题,Redis采用了拉链法的策略解决哈希冲突。

Redis 的字典使用哈希表实现,是整个数据库、哈希键的基石。

  • 核心结构 :一个字典 (dict) 包含两个哈希表 (dictht),通常只使用 ht[0]ht[1]在 rehash 时使用。哈希表的核心是一个 dictEntry指针数组 (table),每个 dictEntry存储一个键值对,并通过 next指针解决哈希冲突,形成链表(新节点采用头插法)。

渐进式 Rehash :Redis 的哈希表采用了一种称为渐进式 rehash​ 的机制来进行扩容或收缩。这个过程非常精巧,其核心目标是避免在数据量巨大时,一次性 rehash 导致 Redis 服务长时间的阻塞。

触发 Rehash 的条件

Rehash 不会随意发生,它由哈希表的负载因子 ​ 决定。负载因子的计算公式为:负载因子 = 哈希表已保存的键值对数量 / 哈希表的大小

触发 rehash 主要有两种情况 :

  • 扩容

    • 当服务器没有 在执行 BGSAVEBGREWRITEAOF持久化命令 ,且负载因子 大于等于 1​ 时。

    • 当服务器正在 执行上述持久化命令,且负载因子 大于等于 5​ 时。提高阈值是为了尽量避免在子进程存在期间(使用写时复制技术)进行大规模内存重分配,节约内存 。

  • 收缩

    • 当负载因子 小于 0.1​ 时,程序会自动开始收缩哈希表 。

渐进式 Rehash 的详细步骤

1. 空间分配 :字典同时持有 ht[0]ht[1]两个哈希表。为 ht[1]分配新的空间:

  • 扩容 时,ht[1]的大小为第一个大于等于 ht[0].used * 2的 2n(2的n次幂)。例如,如果 ht[0]已使用 4 个槽,则新大小为 8 。

  • 收缩 时,ht[1]的大小为第一个大于等于 ht[0].used的 2n(但不得小于初始值4)。

2. 设置标记 :将字典的 rehashidx属性设置为 0 。这个属性是关键,它标志着 rehash 正式开始,并且表示下一次要迁移的桶在 ht[0]中的索引位置

3. 分批迁移 :在 rehash 进行期间,每次对字典执行添加、删除、查找或更新 操作时,程序除了执行指定的操作外,还会顺带ht[0]rehashidx索引上的整个桶 (即该索引对应的链表)中的所有键值对,重新计算哈希值并迁移到 ht[1]中。迁移完成后,将 rehashidx的值加 1,指向下一个需要迁移的桶

4. 完成与切换 :随着操作的不断执行,最终在某个时刻,ht[0]的所有桶都会被迁移至 ht[1]。此时,程序将 rehashidx属性设置为 -1 ,表示 rehash 操作已完成。然后释放 ht[0]的空间,将 ht[1]设置为新的 ht[0],并在 ht[1]上新创建一个空的哈希表,为下一次 rehash 做准备

Rehash 期间的操作规则

在渐进式 rehash 期间,为了保证数据一致性和服务可用性,所有的字典操作都有特殊的处理逻辑

  • 查找 :会先在 ht[0]里面进行查找,如果没找到,还会继续到 ht[1]里面进行查找。

  • 删除和更新:也会同时在两个哈希表上进行。

  • 新增 :新添加的键值对会一律被保存到 ht[1]里面 ,而 ht[0]不再执行任何添加操作。这一措施保证了 ht[0]包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。

压缩列表 (Ziplist)

Redis 的压缩列表(ziplist)是其为极致节省内存而设计的一种紧凑型数据结构,它是由连续内存块组成的顺序型数据结构,尤其在存储少量小元素时表现卓越。

压缩列表在表头有三个字段,表尾有一个字段:

组成部分 长度 作用与说明
**zlbytes**​ 4 字节 记录整个压缩列表占用的内存总字节数 。
**zltail**​ 4 字节 记录压缩列表尾节点 (entry)到列表起始地址的偏移量,用于快速定位尾节点,支持从后向前遍历 。
**zllen**​ 2 字节 记录压缩列表中的节点数量 。若节点数超过 65,535 (2^16 - 1),此值设为 65,535,需遍历整个列表才能获得真实数量 。
**entryX**​ 不定 压缩列表存储的各个节点,是实际数据的载体,长度由存储的内容决定 。
**zlend**​ 1 字节 固定的特殊值 0xFF(十进制255),用于标记压缩列表的结束​ 。

1. previous_entry_length(前驱节点长度)

  • 作用 :记录前一个节点的长度,是实现从尾到头逆向遍历的关键。

  • 变长设计

    • 如果前一个节点的长度 < 254 字节 ,则该字段用 1 字节存储其长度。

    • 如果前一个节点的长度 ≥ 254 字节 ,则该字段用 5 字节 存储:第1个字节固定为 0xFE(即254),后4个字节存储实际长度 。·

2. encoding(编码)

  • 作用 :记录当前节点 content所保存数据的类型(整数或字节数组)及其长度

  • 智能编码:Redis 会根据数据的具体情况选择最节省空间的编码方式 。例如:

    • 对于短字符串 ,用前缀 000110等区分,并用后续的位表示长度。

    • 对于整数 ,用前缀 11区分,并直接利用编码本身存储 0 到 12 的小整数,无需额外的 content字段。其他整数类型(如 int16, int32)也有对应编码 。

3. content(内容)

  • 作用 :实际保存节点的 ,可以是字节数组整数值 ,具体类型和长度由 encoding决定 。

压缩列表的紧凑设计也带来了一个潜在的性能风险------连锁更新 (Cascade Update)。压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配 。而当该元素比较大时,可能会导致后续元素的previous_entry_length占用空间都发生变化,导致每个元素的空间都需要重新分配。

  • 触发场景 :考虑一个特殊案例,压缩列表中存在多个连续的、长度介于 250 到 253 字节之间的节点 。此时,每个节点的 previous_entry_length属性仅需 1 字节。

  • 插入引发 :若在头部插入 一个长度 ≥ 254 字节 的新节点,其后的第一个节点(原第一个节点)的**previous_entry_length需要从 1 字节扩展为 5 字节** 。这导致该节点总长度增至 254-257 字节,进而又会引发其后的第二个节点的 previous_entry_length需要扩展......如此类推,可能引发后续节点的连续空间重分配 。

  • 删除也可能引发:若删除一个介于大节点和小节点之间的小节点,使原来由小节点隔开的大节点与后续节点直接相连,也可能触发类似的连锁更新

  • 影响评估 :连锁更新最坏情况下时间复杂度为 O(N²)。但实际发生概率较低(需要特定长度的大量连续节点),且即使发生,若涉及节点不多,对性能影响也有限 。

核心操作与特性

  • 查找 :由于元素非等长,压缩列表不支持索引直接访问 。查找需从头部或尾部(利用 zltail)开始遍历,时间复杂度为 O(N) 。这也解释了其为何适用于元素数量少的场景。

  • 插入与删除 :这两个操作都可能引发内存的重分配(realloc)。

    • 插入 :需计算新节点长度,并可能扩展后续节点的 previous_entry_length。若空间不足,需分配更大连续内存块并拷贝原有数据。

    • 删除 :需清理被删节点空间,并调整后续节点的 previous_entry_length和位置,可能触发内存收缩。

  • 内存分配策略 :Redis 的内存分配器(如 Jemalloc)在扩容时常会多分配一些额外空间,以减少未来频繁扩容的开销 。

优缺点与应用场景

  • 优点

    • 极致的内存效率:紧凑连续存储,避免了内存碎片和额外指针开销 。

    • 适合小规模数据:在元素数量少、元素本身尺寸小时,能极大节省内存 。

  • 缺点

    • 操作性能代价:插入、删除及可能引发的连锁更新可能导致内存重分配和数据拷贝,性能较低 。

    • 查询效率:需遍历,元素多时效率低 。

    • 不适合大数据量:数据量增大时,操作效率显著下降 。

  • 典型应用 :在 Redis 3.2 版本之前,压缩列表常用于**列表键(List)、哈希键(Hash)和有序集合键(Zset)**​ 的底层实现,当它们满足以下条件时 :

    • 哈希键 :键值对数量少(默认 ≤ 512,hash-max-ziplist-entries),且每个键和值长度短(默认 ≤ 64 字节,hash-max-ziplist-value)。

    • 列表键 :元素数量少(默认 ≤ 512,list-max-ziplist-entries),且每个元素长度短(默认 ≤ 64 字节,list-max-ziplist-value)。

    • 有序集合键 :成员数量少(默认 ≤ 128,zset-max-ziplist-entries),且每个成员长度短(默认 ≤ 64 字节,zset-max-ziplist-value)。

整数集合(intset)

Redis的整数集合(intset)是Set类型的一种高效底层实现,专门用于存储唯一、有序的整数值,在节省内存方面表现出色。当一个Set对象只包括整数值元素,且元素数量不大时,就会使用整数集合作为底层实现。

整数集合的本质是一个动态的、类型统一的数组 ,其核心结构体定义如下(位于 intset.h):

cpp 复制代码
typedef struct intset {
    uint32_t encoding;   // 编码方式,决定数组元素类型
    uint32_t length;     // 集合中元素的数量
    int8_t contents[];   // 柔性数组,实际存储元素
} intset;
  • encoding:关键字段,决定了contents数组中每个元素的实际类型。可选值包括 INTSET_ENC_INT16(16位,-32,768~32,767)、INTSET_ENC_INT32(32位)、INTSET_ENC_INT64(64位)。
  • length:记录当前集合包含的元素个数,即数组长度。
  • contents[]:虽然声明为int8_t,但其实际类型由encoding决定。它是一个连续的内存块,所有元素按值从小到大有序排列,并且不允许重复​ 。

核心机制:升级

这是整数集合最精妙的设计。当尝试加入一个超出当前编码范围的新整数时,就会触发升级(Upgrade)。如果新元素的类型为int32_t,比现在整数集合所有元素的类型int16_t都要长时,整数集合需要进行升级,也就是按照新元素的类型扩展contents数组的空间大小,然后才能将新元素加入到整数集合中,当然升级过程中仍然要维持整数集合的有序性。

  1. 空间重分配:根据新元素的类型(如原为16位,新元素需32位),计算新的总内存大小并重新分配空间 。
  2. 逆向转换与迁移 :将原有数组中的元素,从后往前依次转换为新的数据类型,并放置到新数组的正确位置上。此过程保证了有序性不变 。
  3. 添加新元素:将新元素插入到升级后的数组中。由于是新范围,它要么是新的最小值(插入头部),要么是新的最大值(插入尾部)

需要注意的是,升级是单向的,一旦升级便不支持降级​ 。

主要操作与复杂度

  • 查找:由于数组有序,使用二分查找,时间复杂度为 O(log N) 。
  • 插入:首先查找是否存在。若不存在,则可能需要升级并移动后续元素以空出位置,最坏情况时间复杂度为 O(N) 。
  • 删除:找到元素后,需要将后续元素前移以填补空隙,时间复杂度也为 O(N) 。

跳表(Skip List)

Redis的跳表(Skip List)是一种非常精巧的有序数据结构,它通过"空间换时间"的策略,在普通有序链表的基础上增加了多级索引,从而实现了高效的查找、插入和删除操作。

Redis只有Zset对象底层实现用到了跳表,跳表的优势是支持平均O(logN)复杂度的节点查找。Zset对象在执行数据插入或数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证跳表和哈希表数据一致性。

Redis为何需要跳表?

在Redis的有序集合(Sorted Set)中,需要同时支持两种核心操作:

  • 快速按分数排序:进行插入、删除和查找。

  • 高效范围查询 :例如 ZRANGE命令,按排名范围获取数据。

常见的其他有序数据结构在应对这些需求时存在明显短板:

  • 哈希表:无法保证元素顺序,范围查询需要遍历所有元素,时间复杂度为O(n)。

  • 平衡树 (如红黑树、AVL树):虽然增删查改的平均时间复杂度也是O(log n),但其实现异常复杂,需要处理各种旋转和重平衡操作。更重要的是,进行范围查询时不够直观高效,通常需要中序遍历。

    • Redis 有序集合需要频繁执行 ZRANGE、ZREVRANGE等范围查询命令。跳表在这方面的性能优势直接提升了核心功能的效率。(找到区间起点后,可在底层链表线性遍历)

    • Redis 追求简单和高效。跳表简单的实现降低了代码的维护成本,其 O(log N)的平均时间复杂度在绝大多数场景下已经足够优秀。

  • 数组:插入和删除操作的时间复杂度为O(n),难以接受。

跳表完美地平衡了性能与实现的复杂度:

  • 性能优异 :查找、插入、删除操作的平均时间复杂度均为 O(log n)

  • 天生为范围查询而生 :底层是所有元素的有序链表,进行范围查询(如 ZRANGE)时,只需定位到起点,然后沿底层链表遍历即可,非常高效。

  • 实现简洁:核心是维护多层指针,比平衡树的旋转操作简单得多,bug更少,易于维护。

因此,跳表成为了Redis有序集合理想的底层实现之一。

跳表是在链表基础上改进过来的,实现了一种"多层"的有序链表。

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

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

如果我们在链表中查找节点4这个元素,那么我们只能从头开始遍历,需要查找4次。如果我们使用了跳表,那么我们只需要查找2次:我们可以在L2层级跳到节点3,然后再继续遍历查找一次就到4

Redis的跳表由两个核心结构体定义:管理整个跳表的 zskiplist和代表每个节点的 zskiplistNode

结构体 字段 说明
**zskiplist(跳表本身)**​ *header, *tail 分别指向虚拟头节点和尾节点。头节点不存储实际数据,但层高固定为最大值(32),用于简化边界处理。
length 跳表中实际节点的数量(不包括头节点)。
level 当前跳表内,所有节点中层高的最大值(头节点的层高不计入)。
**zskiplistNode(节点)**​ sds ele 存储有序集合的成员(如用户ID),需要保证唯一性。
double score 该成员的分数,是排序的依据。节点按score从小到大排列,score相同时,再按ele的字典序排。
*backward 后退指针 。指向前一个节点,使得跳表支持从尾到头的逆向遍历,如 ZREVRANGE命令。
level[](数组) 层级数组。这是跳表的灵魂,每个节点拥有1到32层不等。
level[i].forward 该层级的前进指针,指向下一个节点。
level[i].span 跨度 。记录当前节点通过该层的forward指针到达下一个节点时,中间会跳过多少个节点 。跨度主要用于快速计算排名(Rank) ,例如 ZRANK命令。

Zset对象同时保存了"元素"和"元素的权重",对应了表中的sds类型的ele变量和double类型的score变量。每个跳表节点都有一个后向指针*backward指向前一个节点,方便从尾节点开始倒序查找。

跳表是一个带有层级关系的链表,每一个层级包括很多节点,每一个节点通过指针连接起来,实现这一特点靠的是level[](数组)。level数组中的每一个元素代表了跳表的一层。除此之外,还定义了一个叫"跨度"的东西,跨度用来记录两个节点之间的距离。

跨度是为了计算这个节点在跳表中的排位,因为跳表中的节点是有序排列的,那么计算某个节点的排位时,从头节点到该节点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。

跳表节点查询过程

查找分数为 target的节点,过程如同"坐电梯下楼":

  • 从最高层开始:从头节点的最高层出发。

  • 向右或向下 :在当前层,比较下一个节点的score与 target

    • 如果 下一个节点.score < target,则向右移动(沿当前层前进)。

    • 如果 下一个节点.score >= target,则向下移动一层。

  • 重复直到底层:重复步骤2,直到在第0层找到目标节点或确认其不存在。

这个过程跳过了大量不必要的比较,将查找复杂度从链表的O(n)降到了O(log n)。

随机层数:跳表的"概率平衡"魔法

与平衡树严格的平衡规则不同,跳表通过一种概率算法来维持性能平衡,这也是它实现简单的原因。

当一个新节点插入时,其层数由以下算法随机决定:

cpp 复制代码
int zslRandomLevel(void) {
    int level = 1;
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) // ZSKIPLIST_P 值为 0.25
        level++;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

这意味着:

  • 约有 75% ​ 的概率,节点的层数就为 1

  • 约有 **25%**​ 的概率晋升到第2层,之后再以25%的概率晋升到第3层,以此类推。

  • 层数越高,概率越低,最高不超过32

这种"抛硬币"式的设计,使得高层节点自然变得稀疏,形成了有效的"快速通道",无需复杂的再平衡操作。

插入操作:三步走策略

插入一个新节点 (ele, score)需要三步:

  1. 查找并记录路径 :像查找一样,从最高层开始,记录在每一层中,最后一个小于新节点score的节点 ,存入 update数组。这是新节点在各层的前驱节点。

  2. 随机生成层数 :调用 zslRandomLevel为新节点生成一个随机层数 new_level

  3. "搭桥"连接 :从第0层到 new_level层,将新节点插入到 update数组记录的对应前驱节点之后,并更新相关指针和跨度(span)。

删除操作

删除是插入的逆过程。同样先查找到目标节点及其路径,然后直接将各层的前驱节点的指针指向目标节点的后继节点,并更新跨度,最后释放内存。

单线程

为什么单线程Redis这么快?

首先厘清一个事实,我们通常说,Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。所以,严格来说,Redis 并不是单线程。

Redis 采用单线程模型却能实现极高的性能,这背后是一系列精妙设计的综合体现。其核心在于,单线程处理命令 ​ 结合 I/O 多路复用技术纯内存操作 ​ 以及 高效的数据结构,使得它在特定场景下能发挥出惊人效率,同时避免了多线程编程的复杂性 。

  1. Redis 的单线程主要是指其核心的网络 I/O 和键值对读写操作是由一个主线程串行处理的,这带来了几个关键优势:
  • 性能瓶颈不在 CPURedis 的数据完全存储在内存中,访问速度极快。其性能瓶颈主要在于内存大小和网络带宽,而非 CPU 的计算能力。在这种背景下,单线程通常足以高效处理请求 。
  • 降低实现复杂度:单线程模型使 Redis 内部数据结构的实现无需考虑复杂的并发控制逻辑,代码更简单、稳定,易于维护和调试
  • 避免锁竞争和上下文切换:多线程环境下,线程间的上下文切换以及为保护共享数据而产生的锁竞争会带来显著性能开销。单线程模型天然避免了这些问题,使 CPU 时间能更集中地用于处理请求
  1. 并发的关键:I/O 多路复用:单线程如何同时处理成千上万的客户端连接?秘诀在于 I/O 多路复用技术(如 Linux 下的 epoll) 。
  • 主线程通过一个事件循环(Event Loop)来监听大量的网络连接。当某个连接有事件发生(如数据可读、可写)时,操作系统会通知主线程,主线程再依次处理这些就绪的事件。这样,单个线程就能高效地管理高并发连接,虽然在 I/O 操作上需要等待,但等待期间 CPU 不会阻塞,而是去处理其他就绪的事件 。
  • IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

多线程

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

Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务另外创建单独的线程来处理,因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

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

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

Redis 6.0 版本支持的 I/O 多线程特性,默认是 I/O 多线程只处理写操作(write client socket),并不会以多线程的方式处理读操作(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。

同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。因此, **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 的压力。

核心区别总结

特性 I/O 处理 命令处理
核心职责 数据搬运(网络字节流) 数据加工(执行命令逻辑)
关注点 连接管理、数据收发 业务逻辑、数据操作
关键实现 I/O 多路复用(epoll)、缓冲区 命令表、单线程执行器
线程模型 Redis 6.0+ 可多线程(读写网络) 严格单线程(保证原子性)
性能瓶颈 网络带宽、延迟 CPU、内存访问速度

缓存过期删除

Redis 的缓存过期删除策略并非依赖单一方法,而是结合了惰性删除定期删除两种主动与被动相结合的策略 。

惰性删除

惰性删除是 Redis 中的被动删除策略 。它的核心思想是:不主动去检查数据是否过期,而是在数据被访问时,才进行过期检查

  • 工作流程 :当客户端执行任何一个读取键的命令时,该命令在执行核心逻辑之前,会先调用 expireIfNeeded函数来检查这个键是否过期。如果键已过期,Redis 会先将其删除,然后才返回键不存在的响应。如果键未过期,则正常执行命令 。
  • 优点:对 CPU 非常友好。因为删除操作只发生在实际被访问的键上,不会消耗额外的 CPU 时间去扫描那些未被访问但已过期的键 。
  • 缺点 :对内存不友好。如果一个键已经过期,但长期没有被访问,那么它就会一直占用着内存空间,相当于一种内存泄漏,直到被访问或通过其他方式被删除 。

定期删除

为了弥补惰性删除可能造成的内存浪费,Redis 同时采用了定期删除作为主动清理策略。

  • 工作流程 :Redis 会周期性 地(默认每秒10次)执行一个定时任务 。在这个任务中,它会随机 地从设置了过期时间的键的字典中抽取一定数量的键进行检查和删除 。(每当我们对一个key设置了过期时间时,Redis会把该key带上过期时间存储到一个过期字典中)

  • Redis 中的过期字典是用于精确管理每个键的过期时间的核心数据结构。下面这个表格汇总了它的关键特性,帮你快速建立整体印象。

    特性 说明
    本质 一个独立的哈希表(字典),专门记录哪些键设置了过期时间及其具体过期时刻 。
    存储位置 存在于每个 Redis 数据库的结构体 redisDb中,与存储所有键值对的**键空间字典 (dict)**​ 并列 。
    **键 (Key)**​ 是一个指针,指向键空间字典中某个键的对象。这意味着它直接关联到具体的数据库键,且不会存储键对象本身,避免了内存浪费 。
    **值 (Value)**​ 是一个 long long类型的整数,保存的是该键的过期时间,这是一个毫秒精度的 UNIX 时间戳​ 。
  • 抽样与循环机制:每次检查,Redis 默认随机抽取 20 个键。这个过程是循环的:

    1. 随机抽取一批键。

    2. 删除其中已过期的键。

    3. 判断 :如果本轮抽查的键中,过期键的比例超过 25% ,则立即重复步骤1,再次抽取和删除。这样做的目的是,如果发现过期键很多,就加大本轮清理的力度 。

  • 时间限制:为了防止这个主动清理过程占用过多 CPU 时间从而阻塞主线程,Redis 为每次定期删除任务设置了执行时间的上限(默认 25 毫秒)。即使过期键比例依然很高,一旦达到时间上限,本次任务也会停止,等待下一周期再继续

内存淘汰策略

Redis 内存淘汰策略在内存使用达到上限(由 maxmemory参数设定)时触发,用于决定如何清理数据以腾出空间。以下是各种策略的详细说明。

Redis 内存淘汰策略概览

策略类型 策略名称 工作机制
默认策略 noeviction 新写入数据会报错,读、删除操作正常。不淘汰任何数据 。
**全体键 (allkeys-)**​ allkeys-lru 所有键 中淘汰最近最少使用的键 。
allkeys-lfu 所有键 中淘汰最不经常使用(访问频率最低)的键 。
allkeys-random 所有键随机淘汰​ 。
**过期键 (volatile-)**​ volatile-lru 设置了过期时间的键 中淘汰最近最少使用的键 。
volatile-lfu 设置了过期时间的键 中淘汰最不经常使用的键 。
volatile-ttl 设置了过期时间的键 中淘汰存活时间最短(TTL最小)的键 。
volatile-random 设置了过期时间的键随机淘汰​ 。

Redis 在实现这些策略时,采用了高效的近似算法。

  • 近似 LRU (Least Recently Used)Redis 并非遍历所有键来找到最久未使用的那个 ,而是随机采样 一批键(默认5个,可通过 maxmemory-samples调整),然后从这批键中淘汰最久未使用的。采样数越大,结果越接近严格LRU,但消耗也更多CPU 。Redis 3.0 后引入了淘汰池 进一步优化,提高了采样结果的准确性 。在Redis的对象结构体中添加一个额外的字段,用来记录此数据的最后一次访问时间。

    • Redis 没有为所有键维护一个全局链表,而是选择了一种更节省空间的方式。每个存储在 Redis 中的值都被一个**redisObject结构体** 包裹,这个结构体里有一个名为 lru的 24 位字段 。

    • 启用 LRU 策略后,这个 lru字段就被用来记录该对象最后一次被访问的时间戳 ​ 。这个时间戳并非一个绝对的 UNIX 时间,而是来自一个全局的、精度为秒级的 LRU 时钟 。这个全局时钟会定期更新(例如,默认每 100 毫秒),而每次键被访问时(如执行 GETSET命令),它的 lru字段就会被更新为当前的全局时钟值 。这种设计使得记录每个键的访问时间戳仅需 3 字节额外开销,非常高效 。

    • LRU算法有一个问题,无法解决缓存污染问题,比如应用一次性读取了大量的数据,但是这些数据只会被读取这一次,那么这些数据可能会留在Redis中很长一段时间,造成缓存污染。因此Redis 4.0引入了LFU算法来解决这个问题。

  • LFU (Least Frequently Used):最近最不常用,Redis 4.0 引入。它基于访问频率淘汰数据。Redis 通过一种概率计数器来实现LFU,、该计数器会随着时间衰减,使得Redis不仅能统计频率,还能识别出"近期"的热点 。

    • Redis 为了极致节省内存,复用了 redisObject结构体中的 lru字段(共 24 位)来存储 LFU 所需的全部信息。这 24 位被巧妙地划分为两部分 :

      • 高 16 位 :存储一个以分钟为精度的 UNIX 时间戳(通过 LFUGetTimeInMinutes()获得)。这个时间戳称为最后递减时间(Last Decrement Time),主要用于计算访问次数的衰减。

      • 低 8 位 :作为一个计数器(counter),用于记录访问频率。因为只有 8 位,所以它的最大值是 255。

    • 当一个键被访问时(例如通过 GET命令),Redis 会调用 updateLFU函数来更新其访问信息。这个过程包含三个关键步骤 :

      • 频率衰减

        这是 LFU 算法区别于简单计数器的关键一步。函数首先会计算自上次访问以来,已经过去了多少个"衰减周期"(lfu-decay-time,默认为 1分钟)。然后,将计数器的值减去这个周期数。公式大致是:衰减周期数 = (当前时间 - 键的最后递减时间) / lfu-decay-time

        这意味着,如果一个键很长时间没有被访问,它的计数器值会逐渐减少,从而准确反映出其访问频率在随时间降低​ 。

      • 概率递增

        衰减之后,会根据本次访问来增加计数器。但增加并不是简单的 +1,而是采用了一种对数递增的概率策略 。计数器当前值越大,它再被增加的概率就越小。其概率公式约为:p = 1 / ((counter - 5) * lfu_log_factor + 1)

        • lfu_log-factor是一个可配置参数(默认 10),它控制了计数器增长的难度。因子越大,计数器越难增长。

        • 这种设计防止了计数器因为短期内的密集访问而急速膨胀,使得算法能够更好地反映长期访问热度 。

      • 更新字段

        最后,将新的(经过衰减和可能增加的)计数器值和当前时间戳重新组合,写入键的 lru字段。

持久化

RDB 和 AOF 的区别,要抓住它们记录数据的根本方式不同。

  • RDB(Redis Database) ​ 的工作方式像是给数据库拍一张快照 。在特定的时间点(例如每隔5分钟),Redis 会将内存中完整的数据集 以紧凑的二进制格式保存到一个名为 dump.rdb的文件中。这个过程中,Redis 会 fork 出一个子进程来负责实际的写入工作,主进程则可以继续处理客户端请求,从而最大限度地减少阻塞 。

  • AOF(Append Only File) ​ 的工作方式则像是记录数据库的所有操作日记 。它会把每一次能修改数据库状态的写命令(如 SET, HSET),以 Redis 协议的格式追加到一个文本文件的末尾。当 Redis 重启时,它会从头到尾"重放"一遍这个日记文件中的所有命令,从而重建出完整的数据集 。

AOF日志

Redis 的 AOF(Append Only File)持久化机制,其核心在于通过保存所有修改数据库状态的写命令来记录数据变更。当 Redis 重启时,通过重新执行(重放)AOF 文件中的命令序列来恢复数据。

AOF 的实现可以分解为三个核心步骤 :

  1. 命令追加每当 Redis 服务器执行完一个写命令(如 SET, HSET)后 ,会将该命令以 Redis 序列化协议(RESP)的格式追加到服务器状态 redisServer结构体 的**aof_buf缓冲区** 末尾。这是一个在内存中的操作,速度极快 。

  2. 文件写入与同步aof_buf缓冲区中的内容需要被写入并持久化到磁盘 上的 AOF 文件。这个关键步骤由 flushAppendOnlyFile函数负责,其行为由 appendfsync配置项决定,它体现了在数据安全性和性能之间不同的权衡 。

    配置值 行为 优点 缺点
    **always**​ 每次写操作命令执行完后,同步将AOF日志数据写回硬盘; 数据安全性最高,最多只丢失一个事件循环中的命令。 性能最低,因为频繁的同步磁盘操作会增加延迟。
    **everysec**​ 每次写操作命令执行完后,先将命令写入到AOF文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘; 在性能和数据安全间取得平衡,通常最多丢失2秒的数据 。 是默认和推荐的策略。
    **no**​ Redis控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到AOF文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。 性能最好 数据安全性最低,若服务器宕机,可能丢失最后一次同步之后的所有写命令。
  3. AOF 重写: 随着运行时间的推移,AOF 文件会因记录所有历史命令而不断膨胀。AOF 重写 机制就是为了解决这个问题而生,它会创建一个新的、体积更小的 AOF 文件来替代旧文件 。

    1. 触发重写 :可以通过执行 BGREWRITEAOF命令手动触发,也可以在配置文件中设置条件(如AOF文件增长比例和最小大小),由 serverCron定时函数自动触发
    2. 后台重写 :重写工作非常消耗I/O和CPU,因此 Redis 会 fork 出一个子进程 来负责创建新的 AOF 文件,这样主进程可以继续处理客户端请求,避免阻塞 。
    3. 重写原理 :新 AOF 文件并非简单分析旧文件,而是通过遍历当前数据库的所有键值对 ,直接生成能够重建当前数据状态的最小命令集合 。例如,一个列表经过多次 push/pop 操作后,最终状态是 [A, B, C],那么新文件可能只需记录一条 LPUSH mylist A B C命令 。
    4. 重写期间的数据一致性 :在子进程重写期间,主进程执行的新的写命令会同时被记录到 AOF 重写缓冲区。当子进程完成重写后,主进程会将重写缓冲区中的数据追加到新 AOF 文件的末尾,从而确保新旧两个 AOF 文件在状态上的一致性。最后,原子性地用新文件替换旧文件。
    5. 混合持久化(Redis 4.0+) :在重写时,可以开启 aof-use-rdb-preamble选项。这样,子进程会先将数据库状态的 RDB 快照 写入新 AOF 文件的前半部分 ,然后再将重写缓冲区的增量 AOF 命令 追加到后半部分。这种方式在恢复时能更快地加载数据

AOF后台重写

主进程会首先检查当前是否已经有后台持久化任务(如 RDB 保存或另一个 AOF 重写)在执行,以避免资源竞争 。确认无误后,主进程通过 fork()系统调用创建出一个子进程。这个子进程拥有主进程此刻内存数据的完整副本 。

子进程的任务是遍历当前数据库中的所有键,并根据每个键的当前值,生成一条或多条最精简的 Redis 协议命令 ,写入一个临时的新 AOF 文件 (例如 temp-rewriteaof-bg-%d.aof)。这个过程是 AOF 文件变小的关键,因为它将可能成千上万条的历史操作命令,合并为一条能重现当前数据状态的命令。例如,对于一个经过多次 LPUSHLPOP操作的列表,新文件可能只记录一条 RPUSH key current_value1 current_value2 ...命令 。

在子进程忙于创建新 AOF 文件的同时,主进程继续正常服务所有客户端请求为了确保在重写期间执行的新写命令不丢失 ,主进程会将这些命令同时写入两个缓冲区​ :

  1. 现有的 AOF 缓冲区,用于保证原有的 AOF 文件同步机制正常工作。

  2. 专门的 AOF 重写缓冲区,这个缓冲区专门用于保存从重写开始后产生的所有写命令。

当子进程完成新 AOF 文件的写入后,它会向主进程发送一个完成信号。主进程收到信号后,会执行最后的收尾工作 :

  1. AOF 重写缓冲区 ​ 中积累的所有写命令追加到子进程刚刚生成的临时新 AOF 文件的末尾。这一步确保了新文件包含了重写开始到结束期间的所有数据变更,数据状态与当前数据库完全一致。

  2. 使用临时新 AOF 文件原子性地替换旧的 AOF 文件。这个操作是瞬间完成的,保证了在切换时不会损坏旧的或新的文件。

  3. 清理现场,包括关闭文件描述符、删除临时文件、清空 AOF 重写缓冲区等。

Redis 的 AOF(Append Only File)后台重写过程深度依赖并巧妙运用了操作系统的写时复制(Copy-On-Write, COW)机制 。这套机制是保证 AOF 重写能够在不长时间阻塞主进程的前提下,安全生成完整数据快照的关键。

简单来说,写时复制机制允许父子进程在初始时共享大部分内存数据 ,仅在需要修改数据时才进行复制。这极大地优化了 fork()操作的效率和内存使用 :

  1. fork()与共享内存:当主进程(父进程)通过 fork()系统调用创建用于执行 AOF 重写的子进程时,操作系统并不会立即复制整个父进程的物理内存。取而代之的是,新创建的子进程会获得一份与父进程完全相同的页表(Page Table)的副本​ 。**页表是虚拟内存地址到物理内存地址的映射表。**这意味着,在 fork()之后,父子进程的虚拟地址空间不同,但它们的页表条目指向的是同一片物理内存 。此时,操作系统会将这些共享的内存页标记为只读 。

  2. 触发写时复制

    在子进程安静地遍历内存数据、生成新 AOF 文件的同时,主进程需要继续正常处理客户端的写命令。当主进程尝试修改某个已被共享且标记为只读的数据页 时,CPU 会触发一个写保护中断​ 。操作系统捕获到这个中断后,会介入并执行以下操作 :

    • 复制一份该数据页的物理内存副本。

    • 将主进程的页表项更新为指向这个新复制的数据页,并标记为可读写。

    • 然后,主进程就在这个新副本上进行数据修改。

    至此,父子进程关于这块特定数据就有了各自独立的副本。这个"谁需要写,谁才复制"的策略,就是"写时复制"名字的由来。

RDB快照

Redis 的 RDB(Redis Database)持久化方式,是通过创建内存数据的时间点快照 来实现的。它会在特定条件下,将内存中的所有数据生成一个压缩的二进制文件 (默认名为 dump.rdb),非常适合用于备份、灾难恢复和快速数据加载 。

RDB 的核心是 fork()系统调用写时复制(Copy-On-Write, COW)机制,这使它能在不长时间阻塞主服务的情况下完成数据快照 。

其创建流程如下:

  1. 触发快照 :当满足预设条件(如 save 60 10000)或执行 BGSAVE命令时,持久化过程启动 。

  2. 创建子进程 :Redis 主进程(父进程)使用 fork()创建一个子进程。在 fork()瞬间,子进程拥有与父进程完全相同的内存数据副本(实际上,在 Linux 下,父子进程最初共享同一片物理内存)。

  3. 子进程写入临时文件 :子进程专责持久化。它遍历自己看到的内存数据,将其序列化并写入一个临时 RDB 文件。在此过程中,子进程不会修改任何数据 。

  4. 处理写请求与 COW :父进程继续正常服务客户端请求。如果父进程需要修改某块数据,操作系统会先复制该数据页的副本 给父进程,然后再进行修改。这就是 写时复制 机制。它保证了子进程看到的数据在 fork()瞬间就被冻结,快照数据的一致性得以维持,同时避免了完全复制内存的开销 。

  5. 文件替换 :子进程完成数据写入后,用新的临时 RDB 文件原子性地替换旧的 RDB 文件。至此,一次完整的 RDB 快照完成 。

有三种主要方式可以触发 RDB 快照的生成:

触发方式 机制说明 特点与影响
自动触发 redis.conf中配置规则,如 save <seconds> <changes>,表示在 seconds秒内有 changes次数据变更则触发 。 最常用 的方式。可配置多条规则,满足任意一条即触发 bgsave
手动命令 执行 SAVEBGSAVE命令 。 SAVE ​ 会阻塞 所有请求,直到持久化完成,生产环境慎用 。BGSAVE ​ 是后台异步执行,主进程可继续服务,是推荐的手动触发方式 。
特殊操作 执行 SHUTDOWNFLUSHALL命令时 。 SHUTDOWN ​ 会先执行快照再关闭 。**FLUSHALL**​ 也会触发,但生成的是空数据的 RDB 文件,通常无实用价值 。

使用 RDB 文件恢复数据非常简单:

  1. 确保 Redis 服务已关闭。

  2. 将备份的 dump.rdb文件放置到 Redis 配置的 dir目录下。

  3. 启动 Redis 服务。服务启动时会自动检测并加载该 RDB 文件,将数据恢复到内存中 。

AOF和RDB的优缺点、使用场景

📸 RDB 的利与弊

优势:

  1. 高效的数据恢复与备份 :由于 RDB 文件是内存数据的二进制快照,结构非常紧凑。在需要恢复数据时,直接将其读入内存即可,速度非常快 。这使得它非常适合做灾难恢复和定期备份,单个文件也便于传输到远程数据中心 。

  2. 对主进程性能影响最小:RDB 持久化时,主进程的唯一工作就是 fork 一个子进程,后续繁重的磁盘 I/O 工作全部由子进程完成,主进程可以继续以极高的性能提供服务。这意味着 RDB 在持久化期间对读写操作的影响相对较小 。

  3. 大数据集启动更快:相比于 AOF,在数据集非常大的情况下,RDB 文件能让 Redis 服务启动得更快 。

劣势:

  1. 数据安全性较低,可能丢失较多数据 :这是 RDB 最显著的缺点。因为快照是周期性进行的(例如 5 分钟一次),如果 Redis 在两次快照之间发生宕机,那么从上一次快照到宕机时刻之间的所有数据更新都会丢失 。这对于对数据一致性要求高的场景是难以接受的。

  2. Fork 可能引发延迟:虽然子进程负责持久化,但 fork 操作本身在数据集巨大时可能是一个耗时的过程,可能会导致主进程在毫秒级甚至秒级内停止响应客户端请求 。

📝 AOF 的利与弊

优势:

  1. 极高的数据安全性AOF 提供了多种同步策略(appendfsync默认是 everysec,即每秒将日志同步到磁盘一次。这样即使宕机,最多也只会丢失 1 秒钟的数据 。你甚至可以设置为 always,即每次写命令都同步,这能最大程度保证数据不丢失,但会显著影响性能 。

  2. 更好的可读性与可修复性 :AOF 文件是纯文本格式,记录的是原始的 Redis 命令。这意味着你不仅可以直接阅读这个文件来了解数据变更历史,在发生误操作(如误执行 FLUSHALL)时,还可以通过编辑 AOF 文件、删除错误命令的方式来手动恢复数据,这为故障修复提供了很大灵活性 。

  3. 后台重写机制:为了解决 AOF 文件不断增大的问题,Redis 提供了 AOF 重写功能。它会 fork 出子进程,根据当前数据库状态生成一个新的、更精简的 AOF 文件(例如,对一个键的多次操作会被合并为一条最终状态的命令),此过程不会阻塞主进程 。

劣势:

  1. 文件体积通常更大即使有重写机制,在记录相同数据集的情况下,AOF 文件通常也会比 RDB 文件大,因为它保存的是每条命令的日志 。

  2. 数据恢复速度较慢在需要恢复数据时,回放整个 AOF 日志文件中的命令通常比加载 RDB 二进制快照要慢 。

  3. 对性能的潜在影响 :特别是在 appendfsync always模式下,每次写操作都需要同步磁盘,会对 Redis 的吞吐量造成较大影响。即使是默认的 everysec模式,其性能通常也低于 RDB 。

RDB 和 AOF ,我应该用哪一个?
  • 追求极致性能,可容忍分钟级数据丢失: 如果你非常关心你的数据, 但仍然可以承受数分钟以内的数据丢失, 那么你可以只使用 RDB 持久。
  • 要求数据高可靠,几乎不能丢失:AOF 将 Redis 执行的每一条命令追加到磁盘中,处理巨大的写入会降低 Redis 的性能,不知道你是否可以接受。

数据库备份和灾难恢复:定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快。

Redis 支持同时开启 RDB 和 AOF,系统重启后,Redis 会优先使用 AOF 来恢复数据,这样丢失的数据会最少。

Redis高可用

模式 核心目标 数据一致性 自动容错 水平扩展 适用场景
主从模式 数据备份、读写分离 异步复制,可能延迟 不支持 读多写少,允许手动故障恢复的场景
哨兵模式 高可用性(自动故障转移) 异步复制,可能延迟 不支持 对可用性要求高,但数据量未超过单机上限的业务
Cluster模式 高可用 + 水平扩展 异步复制,可能延迟 支持 数据量巨大、高并发读写的大型分布式应用

主从复制是怎么实现的?

Redis 的主从复制是一种核心机制,它通过异步的方式,将主节点(Master)的数据同步到一个或多个从节点(Replica/Slave),以此实现故障恢复和读写分离。

一、连接建立阶段

复制过程始于从节点与主节点建立连接 。

  • 保存主节点信息 :当在从节点上执行 REPLICAOF <master_ip> <master_port>命令后,从节点会将主节点的IP和端口号保存下来 。

  • 建立Socket连接:从节点内部一个定时任务会每秒检查是否存在需要连接的主节点。如果存在,它会尝试与主节点建立一个专用的Socket连接 。

  • 发送PING命令 :连接建立后,从节点会发送一个 PING命令。这主要有两个目的:检查Socket连接是否可用,以及判断主节点当前是否能够处理请求。如果收到 PONG回复,则继续后续步骤 。

  • 身份验证 :如果主节点配置了密码(通过 requirepass设置),从节点需要发送 AUTH命令进行验证,密码由从节点的 masterauth配置指定 。

二、数据同步阶段

连接建立后,便进入最核心的数据同步阶段。从节点会向主节点发送 PSYNC命令 ,其中包含了主节点的运行ID(runID) 和它当前记录的复制偏移量(offset) 。如果是初次复制,从节点不知道这些信息,会发送 PSYNC ? -1

主节点根据 PSYNC命令带来的信息,决定进行全量复制 还是部分复制​ :

  • 全量复制 :发生于 从节点是第一次连接主节点,或者从节点请求的偏移量对应的数据已经不在主节点的复制积压缓冲区中 。

    • 流程
      • 主节点收到全量同步请求后,会执行 BGSAVE 命令,在后台fork一个子进程 生成当前数据的RDB快照文件
      • 在生成RDB文件的同时,主节点会将在此期间接收到的新写命令存入一个名为"复制缓冲区"的内存区域
      • RDB文件生成后,主节点将其发送给从节点。从节点会先清空自身旧数据 ,然后加载这个RDB文件,将数据库状态更新到主节点执行 BGSAVE时的那个时间点
      • 最后,主节点再将复制缓冲区积压的写命令发送给从节点执 行,从而使从节点的数据与主节点完全同步 。注意:全量复制是一个非常消耗资源的操作,会对主节点的CPU、内存、磁盘I/O和网络带宽带来压力 。
  • 增量复制 :从节点短暂断开后重连,并且它之前同步的偏移量(offset)仍然存在于主节点维护的复制积压缓冲区中 。

    • 流程

      • 主节点在复制积压缓冲区 这个固定长度的环形队列中,找到从节点缺失的那部分命令数据 。

      • 然后将这部分数据发送给从节点执行即可 。

三、基于长连接的命令传播阶段与心跳机制

当数据同步完成后,主从节点就进入了长期的命令传播阶段,以维持数据的最终一致性 :

  • 命令传播 :此后,主节点每执行一个会修改数据的写命令 ,都会异步地 将这个命令发送给所有相连的从节点执行,同时也会将写命令写入复制积压缓冲区 。因为这个过程是异步的,所以从节点的数据相对于主节点会有毫秒级的延迟,在极端情况下可能造成短暂的数据不一致 。
  • 心跳机制 :为了维持连接和检测数据丢失,主从节点之间维持着一个心跳机制 。
    • 主->从 :主节点会定期(由 repl-ping-slave-period控制,默认10秒)向从节点发送 PING命令,以判断从节点是否在线
    • 从->主 :从节点会每秒 向主节点发送 REPLCONF ACK <offset>命令,其中携带它当前的复制偏移量。这有三个重要作用 :
        1. 实时监测主从节点的网络连接状态。

        2. 辅助检测命令是否丢失。如果主节点发现自己的偏移量大于从节点上报的,就会从复制积压缓冲区中取出缺失的数据发送给从节点,实现"增量补发"。

        3. 为实现 min-slaves-to-write等功能提供数据支持,该配置可定义在主节点连接的健康从节点数量不足时,拒绝执行写命令,以提升数据安全性 。

如何应对主从数据不一致?

监控复制进度,并对滞后的从节点进行降级。

方法一:优化基础环境(治标)

尽量保证主从节点间的网络连接状况良好,例如避免将主从节点部署在不同的机房。这能有效降低网络延迟,从而减少因异步复制而产生的数据不一致的时间窗口。这是一个基础且必要的运维最佳实践。

方法二:开发监控程序进行主动治理(治本)

监控指标获取

  • 利用 Redis 的 **INFO replication**​ 命令,分别从主节点和从节点获取关键的复制进度信息。

  • 从命令返回结果中,提取 master_repl_offset (主节点已写入的最新命令偏移量)和 slave_repl_offset(从节点已复制的命令偏移量)。

计算与判断

  • 开发一个外部监控程序(如脚本或Agent),定期(例如每秒)执行上述命令。

  • 计算复制进度差值:master_repl_offset - slave_repl_offset。这个差值直观反映了从节点的数据滞后程度。

执行降级策略

  • 为这个差值预设一个阈值。当某个从节点的进度差值超过阈值时,就认为该从节点的数据延迟过大,继续从中读取可能获得旧数据。

  • 此时,监控程序可以触发一个动作:让客户端不再从这个延迟过大的从节点读取数据。实现上,可以通过服务发现、配置中心或负载均衡器,临时将该从节点从"可读节点池"中移除。

  • 客户端后续的读请求将被导向主节点或其他延迟正常的从节点,从而保证读到相对较新的数据。

  • 为了避免在从节点普遍延迟时,所有从节点都被剔除导致客户端无节点可读,需要将这个阈值设置得大一些。这是一个重要的容错设计,确保方案的可用性。

主从切换时如何减少数据丢失?

Redis 主从切换时,通过合理的配置和策略,可以最大程度地减少数据丢失。其核心思路是:利用 Redis 的配置参数,在主节点出现异常时,主动限制其接收写请求,从而将潜在的数据丢失控制在可控范围内

Redis 服务端核心配置

这是减少数据丢失最有效、最核心的手段。你需要在 Redis 的主节点配置文件(redis.conf)中设置以下两个关键参数:

  • min-slaves-to-write x:要求主节点必须至少有 x个从节点保持连接。
  • min-slaves-max-lag x:要求主节点与从节点之间的数据复制延迟不能超过 x秒。

当这两个条件无法同时满足时(例如,连接的从节点数量不足,或所有从节点的复制延迟都超过了设定值),主节点会拒绝执行新的写命令,并向客户端返回错误。这个机制能有效应对两种主要的数据丢失场景:

  • 减少异步复制丢失 :在默认的异步复制下,如果主节点在数据同步到从节点前宕机,数据就会丢失。通过设置 min-slaves-max-lag(例如10秒),可以将数据差控制在10秒内。即使主节点宕机,也最多丢失10秒的数据,而不是无限量的新数据。

  • 防止脑裂导致的数据丢失 :在网络分区(脑裂)场景下,旧主节点虽然还在运行,但无法与任何从节点通信。由于无法满足 min-slaves-to-writemin-slaves-max-lag的要求,它会停止接收写请求。这样,客户端就无法再向这个即将失效的旧主节点写入数据,从而避免了网络恢复后这部分数据被清空的命运。

客户端应用层降级策略

服务端的配置需要客户端的配合才能发挥最佳效果。当客户端收到主节点的写拒绝错误时,不应简单放弃,而应启动降级方案:

  • 写入临时存储:将本应写入Redis的数据暂时写入其他高可用的地方,例如:

    • 本地磁盘或文件系统

    • 独立的临时消息队列(如Kafka、RocketMQ)。

  • 延迟重试:服务降级期间,可以记录这些写请求。待Redis集群恢复稳定(哨兵完成切换,新主节点生效)后,再从临时存储中取出数据,重新写入新的主节点。

运维与架构建议

除了上述核心策略,以下措施也能提升数据可靠性:

  • 优化网络环境:尽量保证主从节点之间的网络质量,避免将主从节点部署在不同的机房或网络延迟较高的环境中,这能从根源上减少复制延迟和脑裂的发生概率。

  • 合理配置哨兵:确保哨兵部署的合理性,哨兵节点数量应为奇数(如3个),并分布在不同物理机器上,以避免哨兵自身出现误判。

  • 持久化策略作为最后防线 :虽然开启AOF持久化(appendfsync everysec)可以在主节点宕机后保留数据,但在主从切换的语境下,它主要用于节点重启后的数据恢复,并不能完全解决异步复制和脑裂带来的数据丢失问题。

为什么要有哨兵机制?

Redis 引入哨兵机制,核心是为了解决主从复制模式下的高可用问题,实现故障的自动发现与恢复,从而让服务能够持续不间断地运行。

主从复制的瓶颈

在原生主从复制架构中,如果主节点发生故障,会立即面临两个严峻问题:

  • 写服务中断:所有写请求都无法处理,因为从节点是只读的。
  • 数据同步停滞:从节点失去了可以复制的数据源。

这时,恢复服务必须手动干预:运维人员需要将一个从节点提升为主节点,并修改所有应用程序的配置,指向新的主节点。这个过程不仅耗时,而且在复杂的分布式环境中容易出错,无法满足现代应用对高可用的要求。

哨兵的核心价值:自动化故障转移

哨兵机制的核心价值在于,它将上述手动故障恢复流程变成了全自动的。简单来说,你可以将哨兵理解为 Redis 的"自动运维管家"。它的诞生是为了填补主从复制在自动故障恢复方面的能力空白,是构建高可用 Redis 服务的关键基石。

需要注意的是,哨兵主要解决的是高可用问题,它并不解决写操作的负载均衡和海量数据存储的瓶颈(存储能力仍受单机内存限制)。对于后者,你需要考虑更高级的 Redis Cluster​ 方案

哨兵机制

Redis 的哨兵机制是为解决主从模式下的高可用性 而设计的分布式系统。它的核心价值在于,当主节点故障时,能够自动进行故障检测、主从切换并通知客户端,从而实现服务的自动恢复,减少对人工干预的依赖 。

核心功能与原理

哨兵机制主要承担着三大核心职责 :

  1. 监控 :哨兵会定期向所有主从节点发送 PING 命令​ 检查其健康状态,这是整个高可用架构的感知基础 。

  2. 自动故障转移 :一旦确认主节点故障,哨兵能自动从从节点中选举出新的主节点,并重新配置整个集群,这是实现"高可用"的关键动作 。

  3. 通知 :故障转移完成后,哨兵负责将新的主节点信息通知给客户端,使客户端能够无缝切换到新的主节点上,这对业务透明性至关重要 。

工作机制详解

1. 故障检测:从主观到客观

故障检测采用两层判断机制,有效防止了因网络波动造成的误判 :

  • 主观下线 :当单个哨兵在配置的超时时间(down-after-milliseconds,如5000毫秒)内未收到主节点的有效回复(PONG),它会主观地​ 认为该主节点下线 。

  • 客观下线 :仅一个哨兵认为下线还不够。发现主节点主观下线的哨兵会询问集群中的其他哨兵。当认为主节点下线的哨兵数量达到法定人数 时,主节点才会被标记为"客观下线",此时才真正触发故障转移流程 。这个法定人数由配置项 sentinel monitor <master-name> <ip> <port> <quorum>中的 quorum值决定,通常要求是哨兵集群中的多数 。

2. 领导者选举:谁来做决策?

主节点被判定为客观下线后,哨兵集群会通过投票协议选举出一个领导者哨兵 ,由它来独裁地 执行后续所有的故障转移操作,这样可以避免决策混乱 。一个哨兵需要获得超过半数的投票才能当选为领导者 。

3. 选举新主节点:筛选与三轮打分

领导者哨兵会从剩余的从节点中按照"筛选+三轮打分"的规则选出最合适的新主节点 :

  • 筛选:首先会排除掉网络连接状况不佳(如断线次数过多)或本身处于不正常状态的从节点,留下健康的候选者 。

  • 三轮打分

    • 第一轮:优先级 。检查从节点的 slave-priority配置值,数值最小的从节点优先级最高。这允许管理员手动指定候选顺序 。

    • 第二轮:复制进度 。如果优先级相同,则选择复制偏移量最大的从节点。这意味着它拥有最接近原主节点的数据,数据丢失最少 。

    • 第三轮:进程ID 。如果前两者都相同,则选择运行ID最小的从节点(ID是实例启动时自动生成的唯一标识),这是一个简单的最终裁决规则 。

4. 切换与通知:更新拓扑

选出新主节点后,领导者哨兵会执行以下操作 :

  • 向被选中的从节点发送 SLAVEOF NO ONE命令,使其晋升为主节点。

  • 向其他所有从节点发送 SLAVEOF命令,让它们开始从新的主节点复制数据。

  • 更新自身配置,将旧主节点标记为新主节点的从节点(待其恢复后)。

  • 通过 发布/订阅​ 机制向客户端广播新主节点的连接信息 。

配置与部署实践

一个典型的生产环境会部署至少三个哨兵实例,且通常为奇数,这有助于在领导者选举和客观下线判定中更容易形成多数派,避免脑裂

哨兵机制虽然强大,但并非万能,它主要解决的是高可用问题,而非可扩展性问题 :

  • 写操作集中:所有写请求仍然会集中到单一的主节点上,其写性能和存储容量受限于单机能力。
  • 异步复制:采用异步复制,在故障转移时,可能丢失原主节点上尚未同步至新主节点的少量数据。

哨兵集群是如何组成的?

Redis哨兵集群的组成不依赖手动配置各个实例的地址,而是通过Redis内置的发布/订阅机制自动完成的。

哨兵集群的自动发现能力完全建立在 Redis 的发布/订阅​ 模式之上。

  • 共享频道Redis 主库上有一个名为 __sentinel__:hello的专用频道 。所有监控同一主库的哨兵实例都会连接到这个主库,并订阅这个频道。

  • 信息广播 :每个哨兵实例会定期(例如每隔2秒)通过这个频道发布 一条消息,消息中包含其自身的运行ID、IP地址、端口号以及监控的主库配置信息

  • 相互感知 :**由于所有哨兵都订阅了该频道,因此任何一个哨兵发布的消息都会被其他哨兵接收到。**通过这种"信息广播",哨兵之间就能动态地获知彼此的存在,从而感知到集群中的其他成员。

当哨兵实例通过发布/订阅机制相互发现后,它们会彼此建立持久的网络连接。这些连接组成的网状网络,就是哨兵集群。之后,集群成员间的心跳检测、主库下线判断、领导者选举等所有协调通信,都通过这个网络进行。

哨兵不仅要监控主库,还需要监控所有从库,并在主库故障时从从库中选举新的主库。那么哨兵是如何知道主库下面有哪些从库的呢?

答案是哨兵会定期向主库 发送 INFO命令 。主库会返回详细的复制信息,其中就包含了所有从库的IP地址和端口列表 。哨兵获取到这个列表后,就会主动与每一个从库建立连接,并对它们进行监控。这样,哨兵就掌握了整个主从集群的完整拓扑结构。

为了让哨兵集群能高效协同工作,一个至关重要的前提是:所有哨兵实例的基础配置必须一致。其中最关键的两个参数是:

  • down-after-milliseconds:判定一个实例"主观下线"的毫秒数。如果不同哨兵的这个值设置不同,有的认为主库已下线,有的认为没有,会导致集群无法就主库状态达成共识。

  • quorum:判断主库为"客观下线"所需的法定票数。这个值必须在所有哨兵配置中相同,才能进行有效的投票仲裁。

总而言之,哨兵集群的组成是一个自动化的过程:配置相同的哨兵实例指向同一主库,它们借助Redis的发布/订阅机制相互发现,并通过INFO命令获取从库信息,最终形成一个能够协同工作的监控网络。这种去中心化的自组织设计,是Redis高可用架构得以实现的基础。

为什么需要cluster集群?

Redis Cluster 集群的诞生,是为了解决 Redis 在单一主节点架构下遇到的几个核心瓶颈。下面这个表格清晰地展示了这些瓶颈以及 Cluster 是如何解决它们的。

核心瓶颈 单一主节点/主从架构的问题 Redis Cluster 的解决方案
数据容量 受限于单机内存大小,无法存储海量数据(如TB级)。 数据分片:将数据分散到多个主节点,突破单机内存限制,实现海量数据存储 。
并发性能 所有写请求集中在一个主节点,容易达到性能瓶颈(通常约10万QPS)。 负载均衡:写请求被分摊到多个主节点上,成倍提升集群的整体吞吐量 。
网络流量 所有读写流量都经过主节点所在服务器的网卡,易成瓶颈。 流量分散:网络流量随数据分片而分散到不同节点,避免单网卡瓶颈 。
高可用与扩展 主从+哨兵模式难以无缝水平扩展,扩缩容需手动干预且可能停机。 无缝水平扩展:可通过简单命令在线添加或移除节点,数据会自动重新分布,业务无感知 。

核心工作机制

  • 数据分片与虚拟槽(Virtual Slots) :这是 Cluster 的基石。Redis 将整个数据库划分为 16384 个哈希槽。每个键(Key)通过 CRC16 算法计算哈希值,再对 16384 取模,被分配到一个唯一的槽中( Slot = CRC16(key) mod 16384。集群中的每个主节点负责处理一部分槽位上的数据。这种方式将数据管理粒度从"键"提升到"槽",使得数据的迁移和重新分配变得非常高效和灵活 。
  • 高可用性 :Cluster 内置了高可用能力。每个主节点都可以配置一个或多个从节点。当某个主节点宕机时,集群会通过类似 Raft 的共识算法,自动从其从节点中选举出一个新的主节点来接管槽位,继续提供服务,从而实现故障自动转移 。
  • 去中心化与 Gossip 协议 :Cluster 采用无中心节点的 P2P 架构。节点间通过 Gossip 协议 来交换信息(如节点状态、槽位分配),最终实现整个集群状态的一致性。这种设计避免了代理层的性能开销和单点故障 。
    • Cluster 采用去中心化架构,节点间通过 Gossip 协议 来交换状态信息,最终实现所有节点元数据的一致性 。常用的 Gossip 消息类型包括:
      • meet:邀请一个新节点加入集群。
      • ping:用于检测节点是否在线和交换彼此状态信息(如自身和已知其他节点的槽分配、主从角色等)。这是最核心、最频繁的消息 。
      • pong:对 ping或 meet的响应,同时也携带自身状态数据。
      • fail:当某个节点被判断为下线时,会在集群内广播此消息,通知其他节点 。

客户端可以连接集群中的任意节点。其处理流程如下:

  1. 计算数据位置:客户端在本地缓存一份槽位到节点的映射表。当要操作一个 Key 时,它先计算出该 Key 属于哪个 Slot 。

  2. 直接发送与重定向:客户端根据本地映射表,将请求直接发送到被认为是正确的节点。

    1. 命中目标:如果该节点确实负责此 Slot,则执行命令并返回结果。

    2. MOVED 重定向如果该节点不负责此 Slot (例如,集群刚刚完成了数据迁移,客户端本地缓存已过期),节点会返回一个 MOVED错误,并告知客户端应访问的正确节点地址。智能客户端会根据此响应更新本地缓存,然后重新向正确节点发送请求。

  3. ASK 重定向在 Slot 数据正在从源节点迁移到目标节点的过程中如果客户端访问的 Key 已被迁移到目标节点,而源节点不再持有该 Key ,源节点会返回 ASK错误 ,引导客户端临时转向目标节点查询。与 MOVED错误不同,ASK重定向只是临时的,客户端不会更新本地 Slot 映射缓存 。

高可用与故障转移

Cluster 的高可用性建立在主从复制之上。每个主节点都应有至少一个从节点。

  1. 故障检测 :节点间通过定期发送 ping消息进行心跳检测。若一个节点在指定时间(cluster-node-timeout)内未响应,它将被其他节点标记为"疑似下线/主观下线"(PFAIL)。当超过半数的持有 Slot 的主节点都认为某主节点下线时,它将被标记为"已下线/客观下线"(FAIL),并开始故障转移流程 。
  2. 故障转移 :一旦主节点被确认为 FAIL,其下属的从节点会开始选举。数据复制的偏移量最大的从节点(即数据最完整的节点)更有可能获胜。获胜的从节点会转换为主节点,接管原主节点负责的所有 Slot,并广播通知整个集群,更新元数据。此时,集群恢复可用 。

为什么Redis Cluster的Hash Slot数量是16384bits?

核心原因:网络效率与心跳开销

最直接、最重要的原因是为了控制集群节点间心跳通信的数据量,避免网络带宽被过多占用

Redis Cluster 中的每个节点需要定期(例如每秒)向其他节点发送 PING 心跳包,以交换集群状态信息,确保数据一致性和进行故障检测。这些心跳包中必须包含一个关键信息:该节点负责哪些哈希槽。

这个信息是通过一个位图​ 来表示的。位图中的每一位代表一个哈希槽的状态(1 表示负责,0 表示不负责)。

  • 如果槽数是 16,384,这个位图的大小是:16,384 bits / 8 = 2,048 bytes = 2 KB。
  • 如果槽数采用 CRC16 理论最大值 65,536(CRC16算法产生的哈希值是16bits),位图大小将变为:65,536 bits / 8 = 8,192 bytes = 8 KB。

在一个拥有数百甚至近千个节点的大规模集群中,每个节点每秒都要与大量其他节点通信。心跳包大小从 2KB 激增至 8KB 会使集群内部网络流量成倍增长,可能造成网络拥堵,影响集群稳定性。因此,2KB 的心跳包在带宽消耗和携带必要信息之间取得了更好的平衡。

Redis Cluster 采用 CRC16 计算 + 哈希槽映射的方式,而不是维护一个全局的"键-实例"映射表,为什么呢?

直接维护一个映射表听起来很直观,但在 Redis 这种高性能、海量数据的场景下,这种方案会遇到巨大的挑战。

哈希槽方案的精妙之处
  1. 内存开销极小且固定

    哈希槽方案维护的是一张 "槽位-实例"映射表由于槽位的数量是固定的(16384个),这个映射表的大小是恒定的,无论你的集群中存储了1亿个key还是10亿个key,这张表都只记录16384个槽属于哪个节点。 这使得每个Redis节点存储这份元数据的内存开销是可控且微不足道的。

  2. 高效的数据定位

    对key进行CRC16计算和取模是一个非常快的操作,时间复杂度是O(1)。客户端可以本地缓存这份精简的"槽位-实例"映射表。当要访问一个key时,客户端可以直接计算出目标槽位,并准确地发送请求到正确的实例,绝大多数情况下无需代理或重定向,这是低延迟的保障。

  3. 平滑的集群扩缩容

    这是哈希槽方案最核心的优势,如流程图所示。当需要增加或减少节点时,数据的移动单位是槽位,而不是单个的key。

    1. 例如,从一个3节点集群扩容到4节点,只需将现有3个节点上的一部分槽位(以及槽内的所有数据)迁移到新节点上。

    2. 迁移完成后,只需更新一次集群的"槽位-实例"映射关系并广播给所有节点和客户端。客户端更新了这份映射后,后续请求就会直接发往新节点。

    3. 这个过程对应用是透明的,并且元数据更新的开销极小(只需更新16384个槽位中一部分的归属信息),这使得水平扩展变得非常高效。

直接映射表方案的致命缺陷
  1. 无法承受的内存开销

    一个记录每个"key-实例"对应关系的全局表,其大小与key的数量成正比。如果集群中有1亿个key,这个表就会有1亿条记录。这不仅会消耗巨大的内存,而且要在集群的所有节点间同步和维护这份庞大的元数据,网络带宽和存储成本都是不可接受的。

  2. 灾难性的扩缩容成本

    一旦集群节点发生变化,问题会变得非常严重。因为映射关系是基于key的,任何节点的增减都意味着需要对全局映射表中海量的记录进行更新。这个元数据同步过程会非常缓慢,在此期间集群可能处于不稳定状态,极易成为性能瓶颈和故障点。

  3. 复杂的数据定位

    在这种模式下,客户端无法像哈希槽方案那样智能地直接定位数据。它可能只能随机连接一个节点,然后依赖该节点查询庞大的全局映射表来重定向请求。这增加了延迟和集群的复杂性与耦合度。

简单来说,Redis 采用 CRC16 + 哈希槽的方式,实质是引入了一个固定的大小的"逻辑分组"(槽位)作为中间层 。这个中间层将海量的、易变的 key ​ 与需要精细管理的物理实例解耦开来。

缓存

什么是缓存雪崩、缓存击穿、缓存穿透?

缓存穿透

查询一个数据库中根本不存在的数据。由于缓存中肯定没有,数据库里也没有,导致这个请求每次都会穿透缓存直达数据库。如果有人利用这一点恶意攻击,频繁请求不存在的数据(如id为负数的用户),数据库压力会非常大 。

应对策略

  1. 布隆过滤器 :这是解决此问题的利器 。它在缓存之前再加一道"安检门"。将所有可能存在的Key 预先存入布隆过滤器。当一个查询请求到来时,先通过布隆过滤器判断Key是否存在。如果布隆过滤器说"不存在",则直接返回,无需查询缓存和数据库。它的优点是空间效率和查询时间都极高,缺点是存在一定的误判率(但绝不会漏判)。
  2. 缓存空对象即使数据库查询返回为空,也在缓存中存储一个空值(或特殊标记,如"NULL")并设置一个较短的过期时间(如1-5分钟)。这样,后续相同的请求在缓存层就能被拦截。需要注意,此方法可能消耗更多内存,并且可能短时间存在数据不一致(如果之后数据库中插入了该数据)。

缓存击穿

某个特定的热点Key(例如,当红明星的微博、秒杀商品)在缓存中过期的瞬间,大量并发请求同时涌来,导致所有请求都直接访问数据库。

应对策略

  1. 互斥锁 :这是最主流的解决方案。当缓存失效时,只允许一个请求的线程去查询数据库并重建缓存 ,其他线程等待。等缓存重建完成后,其他线程可以直接从缓存中获取数据 。这通常通过Redis的 SETNX命令实现分布式锁。
  2. 逻辑过期/永不过期:不设置物理过期时间,而是将过期时间信息存储在Value值中当业务线程发现数据逻辑上已过期时,它会触发一个异步任务去更新缓存,而在此期间,它仍然会返回旧的缓存数据。 这样可以避免高并发直接冲击数据库 。对于极端热点数据,可以不设置过期时间,并通过后台任务或消息队列在数据变更时主动更新缓存,实现"永不过期" 。

缓存雪崩

大量缓存数据在同一时刻集体失效,或者整个Redis缓存服务突然宕机,导致所有原本应该访问缓存的请求,像雪崩一样全部"砸"向数据库 。

  1. 设置随机过期时间 :这是最基本且有效的预防措施。在为数据设置过期时间时,增加一个随机数,打散失效时间点,从而避免集体失效 。例如,基础过期时间是1小时,可以在此基础上增加一个0-5分钟的随机偏移。
  2. 启用多级缓存:引入本地缓存(如Caffeine、Guava Cache)作为Redis之外的第二级缓存。即使Redis崩溃,部分请求也能被本地缓存拦截,为数据库提供一层保护 。
  3. 设置缓存不过期: 我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。。

如何设计一个缓存策略,可以动态缓存热点数据呢?

由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来。举电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品

那么缓存策略的总体思路:就是通过判断数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据,具体细节如下:

前面的内容中,我们都是将缓存操作与业务代码耦合在一起,这样虽然在项目初期实现起来简单容易,但是随着项目的迭代,代码的可维护性会越来越差,并且也不符合架构的"高内聚,低耦合"的设计原则,那么如何解决这个问题呢?

  1. 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前。
  2. 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中。
  3. 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。
  4. 在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。

思路可以是这样:将缓存操作与业务代码解耦,实现方案上可以通过 MySQL Binlog + Canal + MQ 的方式。

我举一个实际的场景,比如用户在应用系统的后台添加一条配置信息,配置信息存储到了 MySQL 数据库中,同时数据库更新了 Binlog 日志数据,接着再通过使用 Canal 组件来获读取最新的 Binlog 日志数据然后解析日志数据,并通过事先约定好的数据格式,发送到 MQ 消息队列中,最后再由应用系统将 MQ 中的数据更新到 Redis 中,这样就完成了缓存操作和业务代码之间的解耦。

这个解耦架构如何设想的"Top 1000商品"缓存策略呢?

  • 访问队列维护 :我们设想的用ZADDZRANGE维护一个访问排序队列,这个队列本身也可以放在Redis里。每当有商品被访问,就在业务代码中执行 ZADD hot_products <当前时间戳> <商品ID>。这个操作很快,对主业务影响很小。

  • 缓存数据更新 :当Canal+MQ的体系监听到商品表发生变更(如价格修改、库存变化)时,缓存更新服务除了刷新商品详情缓存外,还可以去检查发生变化的这个商品ID是否在hot_products这个有序集合中。如果在,说明它是热点商品,则需要立即更新它的详情缓存;如果不在,则可以忽略或设置一个较短的过期时间。定期淘汰冷门商品并随机补充新商品的逻辑,可以作为一个独立的定时任务来运行,完全与主业务分离开。

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

mysql的数据随着时间流逝,可能会发生更新,此时redis缓存数据就会落后,此时需要保证二者数据的一致性。

首先,我们需要建立一个基本认知:在引入缓存的系统中,强一致性(在任何时刻数据都完全一致) ​ 极难实现且代价高昂,因为它会严重牺牲性能。因此,工程实践中的目标是追求最终一致性,即系统允许数据在短暂时间内不一致,但通过一系列措施,能确保数据最终达到一致状态

业界最常用、最基础的策略是 Cache-Aside Pattern(旁路缓存策略) 。其核心设计原则是:缓存的角色是数据库的"辅助副本",应用程序主动管理缓存,而非让缓存成为数据的权威来源​ 。

Cache-Aside(旁路缓存策略)

读流程(缓存未命中时,先查缓存再查数据库,然后写回缓存)

  • 检查缓存:应用程序首先尝试从缓存(如Redis)中读取数据。
  • 缓存命中:若数据存在,直接返回结果,流程结束,无需查询数据库,性能极高。
  • 缓存未命中:若数据不存在,则查询数据库。
  • 回填缓存:从数据库获取数据后,将其写入缓存中(此过程常被称为"延迟加载"),并返回结果 。

写流程(先更新数据库,后删除缓存)

  • 更新数据库:应用程序先更新主数据库。
  • 删除缓存:在数据库更新成功后,删除(而非更新)缓存中对应的旧数据 。

为什么是"删除"而不是"更新"缓存?

  • 性能:如果写多读少,数据可能被频繁更新但很少被读取,那么每次更新都去刷新缓存会造成不必要的性能浪费 。
  • 并发安全:在并发写操作时,如果采用更新缓存的策略,可能会出现线程A更新数据库后,线程B又更新了数据库,但线程B却比线程A先更新了缓存,导致缓存中存储的是旧数据(写乱序) 。

「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。所以,如果我们的业务对缓存命中率有很高的要求,我们可以采用「更新数据库+更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况。

此外,在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。

为了进一步降低以上这种问题的情况,有以下几种主流增强方案:

设置缓存过期时间(TTL)

这是最简单有效的兜底方案。为缓存数据设置一个合理的过期时间。即使出现不一致,数据也会在过期后自动失效,下次读取时便会从数据库加载最新值,实现最终一致性 。这招非常实用。

延迟双删策略

可以在写流程中增加一个延迟删除步骤 :

  1. 更新数据库。

  2. 第一次删除缓存。

  3. 延迟几百毫秒(具体时间需根据业务读取耗时评估)。

  4. 再次删除缓存。

    第二次删除的目的是清除可能在上述"时间窗口"内被其他读请求回填到缓存中的旧数据​ 。这个延迟时间需要根据业务仔细考量。

通过消息队列确保删除成功

核心问题是:如果"删除缓存"这一步失败了怎么办?我们可以引入消息队列的重试机制 。

  1. 业务代码更新数据库。
  2. 向消息队列(如RabbitMQ、Kafka)发送一条删除缓存的消息。
  3. 一个独立的消费者服务消费该消息,执行删除操作。如果删除失败,消息会被重新投递,直到成功为止,这个就是重试机制。当然,如果重试超过了一定次数,还没有成功,我们就需要向业务层发送报错信息了。

这种方式将可能失败的操作异步化,并通过重试保证其最终成功。

订阅数据库 Binlog(终极解耦方案)

这是最彻底解耦业务代码和缓存同步逻辑的方案,在大厂中广泛应用,例如通过阿里巴巴的Canal中间件实现 。

  • 业务代码正常写数据库,对缓存无任何操作。

  • MySQL数据库会生成一份二进制日志(Binlog),记录所有数据变更。

  • Canal伪装成MySQL的从库,订阅并实时解析Binlog。

  • Canal将解析出的数据变更事件发送给消息队列或直接通知一个缓存更新服务。

  • 该服务根据变更事件,删除或更新Redis中的对应数据。

    这个方案的巨大优势在于业务代码完全无需关心缓存问题,缓存同步由一个独立的、高可用的数据管道负责,保证了极高的可靠性

实践建议

  • 绝大多数场景:直接采用 Cache-Aside ( "先更新数据库,再删除缓存" )+ 设置TTL 的组合。这是经过实践检验的、性价比最高的方案 。
  • 对一致性要求极高的热点数据:可考虑在Cache-Aside基础上结合延时双删或为关键数据操作加分布式锁(但会牺牲部分性能)。
  • 大型、复杂系统:如果希望架构清晰、业务代码纯净,并且有能力运维中间件,强烈推荐使用Canal订阅Binlog的方案 。

分布式锁

怎么用redis实现分布式锁?

锁可以理解为针对某项资源使用权限的管理,它通常用来控制共享资源,比如一个进程内有多个线程竞争一个数据的使用权限,解决方式之一就是加锁。

顾名思义,分布式锁就是分布式场景下的锁,比如多台不同机器上的进程,去竞争同一项资源,就是分布式锁。

加锁操作:保证互斥性与安全性

加锁的核心是使用一条原子性的 Redis 命令,确保只有一个客户端能够成功。

  • 核心命令 :使用 SET命令结合 NX(Not eXists)和 PX(以毫秒为单位的过期时间)选项 。
bash 复制代码
SET lock:order123 550e8400-e29b-41d4-a716-446655440000 NX PX 30000
  • lock:order123:锁的键,应与要保护的资源名对应。
  • 550e8400...:一个全局唯一的随机值(如 UUID) 。这是实现安全释放的关键,用于标识锁的真正持有者,防止客户端误删别人的锁
  • NX:只有这个键不存在时,设置才会成功。这保证了互斥性 。
  • PX 30000:设置一个过期时间(例如 30 秒)即使锁的持有者崩溃,锁也会在超时后自动释放
  • 绝对要避免的做法不要分开执行 SETNX和 EXPIRE命令,因为这两个操作不是原子的。如果客户端在 SETNX成功后、EXPIRE执行前崩溃,锁将永远不会释放,导致死锁

释放锁操作:确保安全与原子性

  • 核心挑战释放锁需要两步操作:1) 检查当前锁的值是否等于自己设置的那个唯一值;2) 如果相等,才删除锁 。在分布式环境下,如果这两个步骤不是原子性的,就会发生危险 。
    • 错误场景:客户端 A 比较值成功后,锁恰好过期并被客户端 B 获取。接着客户端 A 执行 DEL,错误地删除了客户端 B 的锁
  • 正确做法 :使用 Lua 脚本。Redis 会单线程、原子性地执行整个脚本
Lua 复制代码
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

这个脚本的意思是:如果传入的唯一标识(ARGV[1])与 Redis 中锁的当前值相等,则删除该锁;否则,返回 0 表示失败。在解锁时,将锁的 Key 和锁持有者的唯一标识作为参数传递给 Redis 来执行此脚本 。

对于要求更高的生产环境,还需要考虑以下问题:

  • 锁的续期(Watchdog 机制) :如果业务操作的执行时间不确定,或者可能超过预设的锁过期时间怎么办?如果锁在业务完成前过期,会导致数据混乱。一个成熟的解决方案是引入一个 "看门狗"(Watchdog) ​ 机制。它在后台启动一个守护线程,定期(例如,在锁过期时间的三分之一时)检查业务是否还在执行。如果仍在执行,就延长锁的过期时间 。Java 社区流行的 Redisson​ 库就内置了这个功能 。

  • 集群环境下的挑战 :在 Redis 哨兵或集群模式中,一个极端情况可能发生:客户端 A 在主节点上获取锁成功,但主节点在将数据异步复制到从节点前宕机了。此时哨兵提升了一个从节点为新主,但这个新主节点上没有锁数据,客户端 B 也能成功获取锁,违背了互斥性 。对于这种极端场景,Redis 作者提出了 Redlock ​ 算法,其核心思想是让客户端依次向多个独立的 Redis 主节点申请锁,只有当超过半数的节点(如 3/5)且在总耗时小于锁过期时间内都成功获取锁,才算真正成功 。Redlock 实现复杂且对性能有影响,除非业务对一致性要求极高,否则应仔细评估是否真的需要 。

Redis分布式锁单机实例挂了怎么办?

1. 设置锁的自动过期时间

这是最基础的容错手段,旨在防止死锁,但无法解决锁服务中断问题。

  • 核心做法 :在加锁时使用 SET命令的 PX参数为锁设置一个过期时间(TTL),例如 SET lock_name unique_value NX PX 10000(表示10秒后自动过期)。

  • 作用即使持有锁的客户端因Redis实例挂掉而无法主动释放锁,锁也会在过期后自动失效,避免系统永久死锁。

  • 局限性:这只是一个"兜底"措施,无法保证在实例宕机期间锁服务的可用性。

2. 使用 Redis 主从复制架构

通过增加副本提升可用性,但并非绝对安全。

  • 核心做法:部署 Redis 主从集群。写请求(加锁、释放锁)发往主节点,主节点异步复制数据到从节点。当主节点故障时,哨兵(Sentinel)或集群模式可自动将一个从节点提升为新的主节点 。

  • 局限性由于复制是异步的,在主节点故障瞬间,若锁数据未同步至从节点,新主节点上将丢失该锁,可能导致多个客户端同时获取锁,存在数据不一致风险 。此方案适用于可接受极低概率锁失效的场景。

3. 采用 Redlock 算法

Redis 作者为应对单点故障和主从切换问题提出的分布式锁算法 。

该算法的核心思想是**"多数派"思想** :不再依赖单个 Redis 实例,而是让客户端向多个完全独立无主从关系)的 Redis 主节点申请锁,只有当超过半数的节点加锁成功,才认为客户端真正获得了锁 。

  1. 获取起始时间 :客户端记录开始加锁前的当前精确时间(毫秒精度的 Unix 时间戳)。

  2. 依次向所有实例申请锁客户端使用相同的键名和唯一随机值,依次向 N 个独立的 Redis 实例发送加锁命令。此命令与单实例分布式锁相同 。

    为了减少因某个节点故障造成的长时间等待,应为每个节点的请求设置一个远小于锁过期时间的网络超时时间(例如,锁超时 10 秒,网络超时 50 毫秒)。

  3. 计算总耗时与判定成功当客户端从所有节点都收到响应后,计算整个加锁过程的总耗时。

    加锁成功必须同时满足两个条件 :

    • 多数派成功 :客户端从至少 N/2 + 1个节点上成功获取了锁。

    • 有效性检查:获取锁的总耗时必须小于锁的预设过期时间(TTL)。

  4. 释放锁 :无论加锁成功与否,客户端都必须向所有参与加锁的 Redis 实例发送释放命令。释放时必须使用 Lua 脚本验证随机值,确保不会误删其他客户端的锁 。

尽管 Redlock 的设计很精巧,但其安全性在分布式系统领域存在争议,主要围绕以下两点 :

进程暂停(如 GC)导致锁失效

  • 场景 :客户端 A 在大多数节点加锁成功,但在执行业务逻辑时发生长时间 GC 暂停 ,导致进程"冻结"。在此期间,锁因超时而被自动释放 。此时,客户端 B 成功获取了锁并开始操作共享资源。当客户端 A 从 GC 中恢复后,会误以为仍持有锁,导致两个客户端同时进入临界区 。

  • 本质:Redlock 依赖超时机制,无法区分"客户端业务逻辑执行缓慢"和"客户端进程已崩溃"这两种情况 。

系统时钟跳跃

  • 场景:如果某个 Redis 实例的系统时钟发生跳跃,可能导致其上的锁提前过期,从而使另一个客户端能够获取该锁,破坏互斥性 。

怎么用Redis实现可重复的分布式锁?

不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。 同一个人拿一个锁 ,只能拿一次不能同时拿2次。

可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。 说白了就是同一个线程再次进入同样代码时,可以再次拿到该锁。 它的作用是:防止在同一线程中多次获取锁而导致死锁发生。

Redisson 是如何实现可重入分布式锁的? 它通过在 Redis 中维护一个 Hash 数据结构 来记录锁的持有者和重入次数,从而实现了同一个线程可以多次获取同一把锁。

基于Hash结构与Lua脚本的原子操作

Redisson 没有使用简单的 String 类型和 SETNX命令,而是使用 Hash​ 结构来存储锁,这正是实现可重入性的关键。

存储结构

  • Key锁的名称 ,例如 "my_lock"

  • Field锁的持有者标识 。这个标识并非简单的线程ID,而是 UUID + threadId,其中 UUID 是客户端实例的唯一标识。这样就确保了在不同机器、不同 JVM 上的线程标识绝不会重复。

  • Value :一个整数值,代表该线程重入锁的次数

  • 原子性保证加锁和解锁的所有逻辑 (检查、设置值、增加计数、设置过期时间)都被封装在 Lua 脚本中。Redis 会单线程地原子性执行整个脚本,这完美解决了并发环境下判断和操作非原子性可能导致的竞态条件问题

加锁过程

当一个线程尝试获取锁时,执行的 Lua 脚本逻辑如下:

  1. 检查锁是否存在exists my_lock

    • 如果锁不存在(结果为0),则直接初始化锁:hset my_lock UUID:threadId 1,并设置过期时间:pexpire my_lock 30000,然后返回成功。

    • 如果锁已存在,则进入下一步判断。

  2. 检查当前线程是否已是持有者hexists my_lock UUID:threadId

    • 如果存在(结果为1),说明是锁重入。此时将重入次数加1:hincrby my_lock UUID:threadId 1,并重新设置过期时间,然后返回成功。

    • 如果不存在(结果为0),说明锁被其他线程占用,获取失败。脚本返回锁的剩余存活时间(PTTL),客户端会根据这个时间进行重试或等待。

解锁过程

解锁过程与之对称,同样是原子性的 Lua 脚本:

  1. 验证身份:首先检查锁是否存在,并且当前线程是否是锁的持有者。

  2. 减少重入计数 :如果是持有者,则通过 hincrby将重入次数减1。

  3. 判断是否完全释放

    • 如果减1后的重入次数大于0,说明线程仍然持有着锁(只是重入层数减少了一层),此时只需重置一下过期时间即可。

    • 如果重入次数等于0 ,说明线程已经完全释放了锁,此时会执行 del命令删除这个 Key,彻底释放资源。

  4. 发布通知 :在完全释放锁后,还会通过 publish命令向特定频道发送一条消息,通知其他正在等待这个锁的客户端,它们可以再次尝试获取,这实现了高效的锁等待机制。

事务

Redis事务和MySQL事务有什么区别?

1. 原子性(Atomicity)的差异

这是两者最显著的差异,主要体现在"失败"时的处理方式上。

  • MySQL的原子性 ​ 是 "全有或全无" ​ 的强原子性。通过BEGIN开启一个事务后,系列操作要么全部成功(COMMIT),要么如果中间任何一步出错,整个事务都会回滚 ,数据库会恢复到事务开始前的状态。这是通过Undo Log实现的 。

  • Redis的原子性 ​ 是 "批量执行" ​ 的原子性。使用MULTI将命令入队,再用EXEC一次性执行。它的原子性仅意味着:EXEC命令会使队列中的所有命令被连续、不被中断地执行 。但是,它不支持回滚如果在EXEC执行过程中,某条命令(例如对错误的数据类型进行操作)运行时失败,它之后的命令仍然会继续执行,之前已成功的命令不会撤销 。Redis官方的设计哲学是,错误应在开发阶段被发现,而不是在生产环境通过昂贵的回滚机制来弥补 。

2. 隔离性(Isolation)的实现方式

  • MySQL的隔离性 ​ 通过锁机制和多版本并发控制​ 实现。它提供了多个隔离级别来平衡性能和数据一致性。在高隔离级别下,它会通过加锁来阻止其他事务访问正在修改的数据 。

  • Redis的隔离性 ​ 由其单线程模型天然保证。由于Redis使用一个线程来处理所有命令,在任何给定时刻,只有一个命令在执行。因此,当一个事务执行时,其他客户端的命令必须等待,这天然提供了最高级别的串行化隔离,不会出现"脏读"等问题 。

3. 并发控制策略

这与隔离性紧密相关,但侧重点不同。

  • MySQL采用悲观锁 。它假设数据竞争很可能会发生,因此在访问数据前就先加锁。例如使用SELECT ... FOR UPDATE来锁定行,防止在事务完成前被其他事务修改 。

  • Redis采用乐观锁 ,通过WATCH命令实现。它的工作方式是:在MULTI之前,使用WATCH监控一个或多个键。如果在该事务执行前,有其他客户端修改了被WATCH的键,那么当前客户端的事务在执行EXEC时会失败。应用程序需要捕获这个失败并决定重试或放弃 。这适用于数据竞争不那么激烈的场景。

4. 持久性(Durability)保证

  • MySQL的持久性 ​ 是默认强保证的。一旦事务提交,更改就会通过预写日志​ 持久化到磁盘,即使系统崩溃,数据也不会丢失 。

  • Redis的持久性​ 是一个可配置的选项。作为内存数据库,数据主要存储在内存中以求极速。持久化到磁盘是异步的,可以通过RDB快照或AOF日志方式配置,但这意味着在故障时可能会有少量数据丢失的风险。持久性与Redis事务机制本身无关 。

这些区别根植于它们的设计目标:

  • MySQL事务 ​ 源于关系型数据库的世界,其核心目标是保证数据的绝对可靠性和强一致性。它严格遵循ACID原则,为此不惜牺牲一部分性能,通过复杂的日志系统、锁机制和回滚功能来确保在并发、故障等任何情况下,数据都能从一个一致状态转移到另一个一致状态 。

  • Redis事务 ​ 则体现了NoSQL和内存数据库的理念,将高性能和简单性 置于首位。它提供的是一种"批处理"式的原子操作,其核心目标是将多个命令打包,作为一个序列顺序执行,并在执行期间不被其他命令打断​ 。它放弃了对复杂事务特性的支持(如回滚),以换取极致的执行速度。

如何选择:场景决定一切

  • 选择MySQL事务当操作要求"强一致性"和"数据绝对正确"。典型场景包括金融交易、订单处理、账户扣款等,这些场景下数据的正确性是第一位的,可以接受一定的性能开销 。

  • 选择Redis事务当操作需要"高性能"且可以接受"最终一致性"。典型场景包括:

    • 批量数据操作:需要一次性更新多个计数器或键值。

    • 乐观锁控制 :如秒杀库存检查,结合WATCH防止超卖。

    • 对性能要求极高,且即使中间步骤失败,其影响也是可接受的场景 。

Redis Hot Key问题

Redis 热 Key 问题是指某个特定的 Key 在短时间内被极高频率地访问,导致其所在的 Redis 实例负载远高于其他实例,从而可能引发性能瓶颈、服务抖动甚至实例宕机的现象 。

Hot key 引发缓存系统异常,主要是因为突发热门事件发生时,超大量的请求访问热点事件对应的 key,比如微博中数十万、数百万的用户同时去吃一个新瓜。数十万的访问请求同一个 key,流量集中打在一个缓存节点机器,这个缓存机器很容易被打到物理网卡、带宽、CPU 的极限,从而导致缓存访问变慢、卡顿。

如何发现热 Key?

在解决问题之前,首先要能准确地发现热 Key。

  1. 监控与经验预判:通过监控 Redis 各个节点的 QPS、CPU 使用率、网络流量等指标,可以发现流量倾斜的迹象 。同时,对于可预见的业务活动(如秒杀、热点新闻),可以提前预测可能的热 Key 。

  2. Redis 内置命令

    1. redis-cli --hotkeys:在 Redis 4.0.3+ 版本中,可以使用此命令扫描并列出当前实例中的热点 Key。但需要注意,在大数据量下执行可能较慢 。

    2. MONITOR :可以实时打印所有命令,但生产环境慎用,因为它会严重消耗 CPU 和内存,影响性能 。

核心解决方案详解

1. 多级缓存(最常用)

这是应对热 Key 读请求最有效的方案之一。其核心思想是:在应用程序内部(JVM)建立一层本地缓存(如 Caffeine、Guava Cache),避免所有请求都穿透到 Redis 。

  • 工作流程:当请求到来时,首先查询本地缓存。如果未命中,再去查询 Redis,并将结果回写到本地缓存一段时间。

  • 优点:极大降低对 Redis 的读压力,响应速度极快 。

  • 缺点:引入了数据一致性问题。本地缓存的数据可能不是最新的,需要设置合理的过期时间(如几百毫秒到数秒),在业务可接受的短暂不一致性和 Redis 压力之间取得平衡 。

2. Key 分片(拆 Key)

将一个热 Key 拆分成多个子 Key,将流量分散到不同的 Redis 实例上 。

  • 操作方式 :例如,原始 Key 为 hot:product:1001,可以将其拆分为 hot:product:1001:shard1, hot:product:1001:shard2****, ... , hot:product:1001:shardN

  • 读写策略

    • 写操作:更新数据时,需要同时写入所有 N 个子 Key。

    • 读操作 :读取时,随机选择一个子 Key 后缀(如 shardX)进行查询 。

  • 优点:能从根源上分散压力,保证数据强一致性。

  • 缺点:业务改造复杂,增加了读写开销(需要操作多个 Key)。

3. 数据副本

与分片类似,但更简单。只为特定的热 Key 创建几个完整的副本。

  • 操作方式 :例如,为 hot:news创建 hot:news:replica1, hot:news:replica2等副本。读取时,客户端随机选择一个副本进行访问 。

  • 优点:实现简单,能快速分散读压力。

  • 缺点:浪费内存,同样需要维护多个副本之间的一致性 。

4. 读写分离

如果热 Key 场景是读多写少,可以利用 Redis 主从复制架构,将读请求引导到从节点上,减轻主节点的压力 。

  • 优点:架构支持,改造相对简单。

  • 缺点:存在主从同步延迟,可能读到旧数据(最终一致性)。如果热 Key 的写操作也很频繁,此方案效果有限。

Redis Big Key问题

Redis 大 Key 问题本质上是指某个特定的 Key 对应的 Value 值体积异常庞大,导致在对它进行操作时,会严重消耗系统资源,进而影响 Redis 的性能和稳定性。

判断一个 Key 是否为"大 Key",通常有两条核心标准:

  1. Value 的体积过大:对于 String 类型,如果单个 Value 的值超过了 10KB,通常就被认为是过大了。更极端的情况,有些 Key 的 Value 可能达到 MB 甚至 GB 级别,比如将一个大的图片或文件序列化后存入 Redis。

  2. 集合的元素过多:对于复合数据类型(Hash, Set, List, ZSet 等),如果其包含的成员数量过于庞大,例如超过 5000 个,即使每个成员体积很小,整个 Key 也会因为元素数量过多而成为"大 Key"。

大 Key 的严重危害

大 Key 的危害源于 Redis 的单线程处理模型。当一个耗时很长的操作(如删除一个包含百万成员的 Set)到来时,它会阻塞后续所有命令,引发连锁反应。

  • 操作耗时,引发阻塞:由于 Redis 核心工作线程是单线程的,读写、删除一个大 Key 会非常耗时。这会导致其他命令被阻塞,引起请求延迟增加,甚至超时。

  • 网络拥塞:读取一个 1MB 的 Key,如果每秒有 1000 次访问,就会产生 1000MB/s 的流量,极易打满网卡带宽,影响同一台服务器上的其他服务。

  • 内存分布不均:在集群模式下,一个大的 Key 只能存在于某个分片上,无法被拆分。这会导致该分片的内存使用率远高于其他分片,造成资源倾斜。

  • 删除风险 :直接使用 DEL命令删除大 Key 会长时间阻塞 Redis。虽然可以使用 UNLINK命令(Redis 4.0+)进行异步删除以避免阻塞,但在大 Key 过期或驱逐时,仍可能因内存回收不及时而引发问题。

如何发现大 Key?

及时发现是治理的第一步,主要有以下方法:

  • 使用官方工具redis-cli内置了 --bigkeys参数,可以快速扫描并找出每种数据类型中最大的 Key。

bash 复制代码
redis-cli --bigkeys

此命令可能会对线上服务造成一定压力,建议在从节点或低峰期执行,并可使用 -i参数加入间隔(如 -i 0.1表示每扫描100个key暂停0.1秒)。

  • 使用 MEMORY USAGE 命令:此命令可以精确计算某个指定 Key 及其 Value 实际占用的内存字节数,适合针对性的检查。

    bash 复制代码
    MEMORY USAGE your_key_name
  • 分析 RDB 文件 :通过离线分析 Redis 的持久化 RDB 文件,可以全面、精准地获取所有 Key 的大小信息,且对线上服务无任何影响。可以使用 redis-rdb-tools这样的第三方工具来完成

核心解决方案

治理大 Key 的核心思路是"拆分"和"优化"。

  1. 数据拆分(治本之策)

    这是最有效的方案。将一个大的 Value 拆分成多个小的 Key-Value。

    • String 类型 :将一个大的字符串拆分成多个小块。例如,一个大文本可以拆分为 bigkey:part1, bigkey:part2... 存储,读取时使用 MGET批量获取。

    • 集合类型 :将一个大集合按规则分片。例如,一个拥有亿级用户ID的 Set,可以按用户 ID 的哈希值取模,分散存储到 users:set:0, users:set:1... 等多个 Key 中。

  2. 调整数据结构与压缩

    • 选择合适的数据结构 :例如,如果只需要统计独立用户数(UV),使用 HyperLogLog ​ 可以极大地节省空间。如果需要记录用户是否完成某个行为,使用 Bitmap​ 比使用 Set 或 String 更节省内存。

    • 数据压缩:如果 Value 是文本(如 JSON、XML),可以在写入 Redis 前先进行压缩(如 GZIP),读取时再解压。这是一种用 CPU 资源换取内存和网络带宽的策略。

  3. 设置过期时间与监控

    为缓存数据设置合理的过期时间(TTL),并建立监控告警机制,对 Key 的大小增长和内存使用率进行监控,以便在问题发生前介入处理。

相关推荐
零叹17 小时前
Redis热Key——大厂是怎么解决的
数据库·redis·缓存·热key
王五周八17 小时前
基于 Redis+Redisson 实现分布式高可用编码生成器
数据库·redis·分布式
win x18 小时前
Redis事务
数据库·redis·缓存
飞翔的小->子>弹->18 小时前
CMK、CEK
服务器·数据库·oracle
peixiuhui18 小时前
Iotgateway技术手册-7. 数据库设计
数据库·iotgateway·开源dotnet·arm工控板·开源网关软件·开源数据采集
麦兜*18 小时前
【Spring Boot】 接口性能优化“十板斧”:从数据库连接到 JVM 调优的全链路提升
java·大数据·数据库·spring boot·后端·spring cloud·性能优化
qq_3344668618 小时前
U9补丁同步的时候报错
数据库
施嘉伟18 小时前
KSQL Developer 测试记录
数据库·kingbase
谱度众合18 小时前
【蛋白互作研究】邻近标记PL-MS实验指南:如何精准获取目标蛋白的基因序列?
数据库·科技·蛋白质组学·药物靶点·生物科研