【Redis】零碎知识点(易忘 / 易错)总结回顾

发布于:2025-05-18 ⋅ 阅读:(25) ⋅ 点赞:(0)

一、Redis 是一种基于键值对(key-value)的 NoSQL 数据库


二、Redis 会将所有数据都存放在内存中,所以它的读写性能非常惊人

Redis 还可以将内存的数据利用快照和日志的形式保存到硬盘上,这样在发生类似断电或者机器故障时,内存中的数据不会 “丢失”


三、特性

1、速度快

最主要原因:Redis 的所有数据都是存放在内存中的,所以它的读写性能非常惊人

Redis 使用了单线程,预防了多线程可能产生的竞争问题,减少了不必要的线程之间的竞争开销

Redis 后来引入了多线程机制,但主要也是在处理网络和 IO,不涉及到数据命令,即命令的执行仍然采用了单线程模式

对比:多线程提高效率的前提:CPU 密集型的任务,使用多个线程可以充分利用 CPU 多核资源。但 Redis 的核心任务是操作内存的数据结构,不会吃很多 CPU。如果此时选择多线程,多核资源也用不上,因为单个核心的处理速度已经很快了,不仅没有明显的提升,同时还要考虑线程安全、加锁(一旦竞争就会阻塞,阻塞又会涉及到什么时候唤醒)

从网络的角度上,Redis 使用了 IO 多路复用的方式(epoll),使用一个线程管理多个 socket


2、基于键值对的数据结构服务器

与很多键值对数据库不同的是,Redis 中的值不仅可以是字符串,而且还可以是具体的数据结构,这样便于在许多应用场景的开发,同时也能提高开发效率

Redis 的全称是 REmote Dictionary Server,它主要提供了 5 种数据结构:字符串(string)、哈希(hash)、列表(list)、集合(set)、有序集合(ordered set / zet),同时在字符串的基础之上演变出了位图(Bitmaps)和 HyperLogLog 两种神奇的 “数据结构”,并且随着 LBS(基于位置服务)的不断发展,Redis 3.2. 版本种加入有关 GEO(地理信息定位)的功能


3、丰富的功能

  • 键过期功能,可以用来实现缓存
  • 发布订阅功能,可以用来实现消息系统
  • 支持 Lua 脚本功能,可以利用 Lua 创造出新的 Redis 命令
  • 简单的事务功能,能在⼀定程度上保证事务特性
  • 流水线(Pipeline)功能,这样客户端能将一批命令一次性传到 Redis,减少网络的开销

4、简单稳定

Redis 的简单主要表现在 3 个方面

  1. Redis 的源码很少,相对于很多 NoSQL 数据库来说代码量相对要少很多
  2. Redis 使用单线程模型,这样不仅使得 Redis 服务端处理模型变得简单,而且也使得客户端开发变得简单
  3. Redis 不需要依赖于操作系统中的类库,Redis 自己实现了事件处理的相关功能

Redis 具备相当的稳定性,在大量使用过程中,很少出现因为 Redis 自身 BUG 而导致宕掉的情况


5、客户端语言多(扩展能力,Extensibility)

Redis 提供了简单的 TCP 通信协议,很多编程语言可以很方便地接入到 Redis

可以在 Redis 原有的功能基础上再进行扩展。Redis 提供了一组 API,通过 C、C++、Rust 这几个语言编写 Redis 扩展(本质上就是一个动态链接库),让 Redis 支持更多的数据结构,以及支持更多的命令


6、持久化(Persistence)

将数据放在内存中是不安全的(易失),一旦发生断电或者机器故障,重要的数据可能就会丢失,因此 Redis 提供了 2 种持久化方式

  1. RDB
  2. AOF

可以用两种策略将内存的数据保存到硬盘中(内存为主、硬盘为辅,硬盘相当于对内存中的数据进行了备份),就保证了数据的可持久性。如果 Redis 重启,那么就会在重启时加载硬盘中的备份数据,使 Redis 的内存恢复到重启前的状态

  • Redis 内存到硬盘的持久化


7、主从复制(Replication)

Redis 提供了复制功能,实现了多个相同数据的 Redis 副本(Replica),复制功能是分布式 Redis 的基础

Redis 主从复制架构:


8、高可用(High Availability)和分布式(Distributed)

Redis 提供了高可用实现的 Redis 哨兵(Redis Sentinel),能够保证 Redis 节点的故障发现和故障自动转移,也提供了 Redis 集群(Redis Cluster),是真正的分布式实现,提供了高可用、读写和容量的扩展性

Redis 自身也是支持 “主从” 结构的,从节点就相当于主节点的备份


9、可编程的 / 编程能力(Programmability)

针对 Redis 的操作,可以直接通过简单的交互式命令进行操作,也可以通过脚本的方式批量执行一些操作


10、集群(Clustering)

Redis 作为一个分布式系统的中间件,能够支持集群是非常关键的

这个水平扩展类似于 “分库分表”。一个 Redis 能够存储的数据是有限的(内存空间有限),如果想存储更多数据,就需要引入多个主机,部署多个 Redis 节点,每个 Redis 存储数据的一部分


四、主要应用场景

1、Redis 可以做什么

(1)缓存(Cache)& 会话(Session)

合理地使用缓存不仅可以加速数据的访问速度,还能够有效地降低后端数据源的压力。Redis 提供了键值过期时间设置,并且也提供了灵活控制最大内存和内存溢出后的淘汰策略。一个合理的缓存设计能够为一个网站的稳定保驾护航

之前 session 是存储在应用服务器上的,现在变成了分布式系统,引入了负载均衡

想办法让负载均衡器把同一个用户的请求始终打到同一个机器上,不能再采用轮询了,而是要通过 userId 之类的方式来分配机器

把会话数据单独拎出来,放到一组独立的机器上进行存储(Redis,把应用程序重启,会话也不会丢失,就算丢失了可以让用户重新登录)


(2)排行榜系统

例如按照热度排名的排行榜,按照发布时间的排行榜,按照各种复杂维度计算出的排行榜。Redis 提供了列表和有序集合的结构,合理地使用这些数据结构可以很方便地构建各种排行榜系统


(3)计数器应用

计数器在网站中的作用至关重要

例如视频网站有播放数、浏览数,为了保证数据的实时性,每一次播放和浏览都要做 +1 的操作,如果并发量很大对于传统关系型数据的性能是一种挑战。Redis 天然支持计数功能,而且计数的性能也非常好,可以说是计数器系统的重要选择


(4)社交网络

由于社交网站访问量通常比较大,而且传统的关系型数据不太合适保存这种类型的数据,Redis 提供的数据结构可以相对比较容易地实现这些功能


(5)消息队列(服务器)系统(Streaming & Messaging)

消息队列系统可以说是⼀个大型网站的必备基础组件,因为其具有业务解耦、非实时业务削峰等特性。基于这个可以实现一个网络版本的生产者消费者模型。对于分布式系统来说,服务器和服务器之间有时也需要使用到生产者消费者模型

优势:

  • 解耦合
  • 削峰填谷

Redis 提供了发布订阅功能和阻塞队列的功能,虽然和专业的消息队列比还不足够强大,但对于一般的消息队列功能基本可以满足


2、Redis 不可以做什么(可以站在数据规模和数据冷热的角度来进行分析)

  • 站在数据规模的角度看,数据可以分为大规模数据和小规模数据。Redis 的数据是存放在内存中的,虽然现在内存已经足够便宜,但如果数据量非常大,使用 Redis 来存储的话,经济成本相当高
  • 站在数据冷热的角度,数据分为热数据和冷数据。热数据通常是指需要频繁操作的数据,反之为冷数据。例如对于视频网站来说,视频基本信息基本上在各个业务线都是经常要操作的数据,而用户的观看记录不⼀定是经常需要访问的数据。这里暂且不讨论两者数据规模的差异,单纯站在数据冷热的角度上看,视频信息属于热数据,用户观看记录属于冷数据。如果将这些冷数据放在 Redis 上,基本上是对于内存的⼀种浪费,但是对于一些热数据可以放在 Redis 中加速读写,也可以减轻后端存储的负载,可以说是事半功倍
  • 将热数据放在 Redis 中存储,而将冷数据用 MySQL 来存储

很多领域都涉及到 “二八原则”:20% 的热数据能满足 80% 访问需求(系统的复杂程度大大提升,如果数据发生修改,还会涉及到 Redis 和 MySQL 之间的数据同步问题)


​​​​​​​五、Redis 客户端与服务端的交互过程


六、常见数据类型

1、最核心的两个命令

  • Redis 是按照键值对的方式存储数据的
    • get:根据 key 来取 value
      • 如果当前的 key 不存在,会返回 nil
        • nil 和 null / NULL 是一个意思
    • set:把 key 和 value 存储进去
  • key 和 value 本质上都是字符串
    • key value 不需要加上引号就是代表字符串的类型,加上也是可以的(单引号或双引号)

2、基本全局命令

  • Redis 有 5 种数据结构,都是键值对中的值,对于键来说有一些通用的命令(能够搭配任意一个数据结构来使用的命令),叫作全局命令
  • KEYS
    • 用来查询当前服务器上匹配的 key。通过一些特殊符号(通配符)来描述 key 的模样,匹配上述模样的 key 就能被查询出来
    • 支持如下统配样式
      • ? 匹配任意一个字符
      • * 匹配 0 个或者多个任意字符
      • h[ae]llo 匹配 hello 和 hallo 但不匹配 hillo(只能匹配到a、e,其它的不行,相当于给出固定选项)
      • [^e] 排除 e,只有 e 匹配不了,其它的都能匹配
      • [a-b] 匹配 a~b 这个范围内的字符,包含两侧边界
    • 语法
      • KEYS pattern
        • pattern 表示包含特殊符号的字符串
    • 时间复杂度
      • O(N)
      • 在生产环境上,一般都会禁止使用 keys 命令,尤其是 keys *
        • 生产环境上的 key 可能非常多,而 Redis 是一个单线程的服务器,那么执行 keys * 的时间非常长,就会使 Redis 服务器被阻塞了,而无法给其他客户端提供服务
    • 返回值
      • 返回所有满足样式的 key
  • EXISTS
    • 判断某个 key 是否存在,也可以一次判断多个
    • Redis 支持很多数据结构,指的是这些键值对是通过哈希表的方式来组织的,具体的某个值又可以是一些数据结构
    • 语法
      • EXISTS key [key ...]
    • 时间复杂度
      • O(1)
      • Redis 组织这些 key 是按照哈希表的方式组织的
    • 返回值
      • key 存在的个数
    •  
      • Redis 是一个客户端服务器结构的程序,客户端和服务器之间通过网络来进行通信
      • 蓝色框(一次请求和一次响应)

      • 红色框(一次请求和一次响应 + 一次请求和一次响应,四次网络通信,即 2 个轮次)

        • 分开写会产生更多轮次的网络通信(效率低、成本高,和直接操作内存比)
  • DEL
    • 删除指定的 key
    • 假设 Redis 存的只是一个热点数据,全量数据在 MySQL 中。此时如果删除了 Redis 的几个 key,一般问题不大,但如果把 Redis 中一大半甚至全部数据删了,那么影响就很大。所以相比之下,MySQL 误删了一个数据,可能影响就很大
    • 如果把 Redis 作为数据库,此时误删数据的影响也很大
    • 如果是把 Redis 作为消息队列 mq,此时误删数据的影响就应该根据具体问题来具体分析
    • 语法
      • DEL key [key ...]
    • 时间复杂度
      • O(1)
    • 返回值
      • 删除掉的 key 的个数
  • EXPIRE
    • 为指定的 key(key 已存在,否则设置失败)添加秒级的过期时间(Time To Live,TTL)
    • PEXPIRE(毫秒级)
    • key 的存活时间超出这个指定值就会被自动删除
      • 业务场景举例
        • 手机发送验证码(60s)
        • 外卖优惠券(7天)
      • 基于 Redis 实现的分布式锁(给 Redis 写一个特殊的 key value,删除就是解锁。为了避免出现不能正确解锁的情况,通常都会在加锁时设置过期时间)
    • 语法
      • EXPIRE key seconds
    • 时间复杂度
      • O(1)
    • 返回值
      • 1 表示设置成功,0 表示设置失败
  • TTL
    • 获取指定 key 的过期时间,秒级
    • IP 协议报头中有一个字段:TTL,但它是用次数来衡量过期的
    • 语法
      • TTL key
    • 时间复杂度
      • O(1)
    • 返回值
      • 剩余过期时间。-1 表示没有关联过期时间,-2 表示 key 不存在
    • 键的过期机制

    • EXPIRE 和 TTL 命令都有对应的支持毫秒为单位的版本:PEXPIRE 和 PTTL
    • Redis 的 key 的过期策略是如何实现的?
      • 一个 Redis 中可能同时存在很多 key,这些 key 中可能有很大一部分都有过期时间,那么此时 Redis 如何知道哪些 key 已经过期要被删除,哪些 key 还没过期呢?如果直接遍历所有 key 显然是不行的,效率非常低
      • Redis 整体的策略
        • 定期删除:每次抽取一部分进行验证过期时间,保证这个抽取检查的过程足够快
          • 为什么这里对于定期删除的时间有明确的要求呢?
            • 因为 Redis 是单线程程序,它的主要任务:处理每个命令的任务、扫描过期的 key 等等,如果扫描过期 key 消耗的时间太多,那么正常处理请求命令就被阻塞了(产生了类似于执行 keys * 这样的效果)
            • 虽然有上面讲到的两种策略结合,但整体结果一般,仍然可能会有很多过期的 key 被残留,没有及时删除掉
            • Redis 为了对上述进行补充,还提供了一系列的内存淘汰策略
              • 如果有多个 key 过期,也可以通过一个定时器(基于优先级队列或者时间轮都可以实现比较高效的定时器)来高效 / 节省 CPU 的前提下来处理多个 key。但 Redis 并没有采取定时器的方式来实现过期 key 删除
                • 可能是基于定时器实现,就需要引入多线程,但 Redis 的早起版本就奠定了单线程的基调,如果引入多线程就打破了初衷
              • 定时器:在某个时间到达之后,执行指定的任务,它是基于优先级队列 / 堆的(一般的队列是先进先出,而优先级队列则是按照指定的优先级(自定义)先出)。在 Redis 过期 key 的场景中,就可以通过 “过期时间越早,就是优先级越高”。此时定时器只需要分配一个线程,不需要遍历所有的 key,只需要让这个线程去检查队首元素,看是否过期即可。如果队首元素还没过期,那么后续元素一定没过期。另外,在扫描线程检查队首元素过期时间时,也不能检查的太频繁,此时可以根据时刻和队首元素的过期时间设置一个等待,当时间差不多到了,系统再唤醒这个线程(可以节省 CPU 的开销)
              • 万一在线程休眠时,来了一个新的任务呢?可以在新任务添加时,唤醒刚才的线程,重新检查一下队首元素,再根据时间差距重新调整阻塞时间即可
              • 基于时间轮实现的定时器(把时间划分成很多小段,具体划分的粒度看实际需求)

                • 每个小段都挂着一个链表,每个链表都代表一个要执行的任务,相当于一个函数指针以及对应的参数
                • 假设需要添加一个 key,这个 key 在 300ms 后过期。此时这个指针就会每隔固定的时间间隔(此处约定时 100ms)往后走一个,每次走到一个格子就会把这个格子上链表的任务尝试执行一下
                • 对于时间轮来说,每个格子是多少时间,一共有多少个格子都是需要根据实际场景来灵活调配的
        • 惰性删除:假设这个 key 已经到过期时间了,但暂时还没删除,key 还存在,紧接着后面又有一次访问,正好用到了这个 key,于是这次访问就会让 Redis 服务器触发删除 key 的操作,同时再返回一个 nil
  • TYPE
    • 返回 key 对应的数据类型
    • Redis 所有的 key 都是 string,key 对应的 value 可能会存在多种类型
    • 语法
      • TYPE key
    • 时间复杂度
      • O(1)
    • 返回值
      • none,string,list,set,zset,hash,stream
    • Redis 作为消息队列时,使用 stream 作为返回值类型
    • type 命令实际返回的就是当前键的数据结构类型,Redis 的 5 种主要的数据类型分别是:string(字符串)、list(列表)、hash(哈希)、set(集合)、zset(有序集合),但这些只是 Redis 对外的数据结构

3、数据结构和内部编码

  • Redis 底层在实现上述数据结构时,会在源码底层针对上述实现进行特定的优化(内部具体实现的数据结构(编码方式)还会有变数),来达到节省时间 / 空间的效果。实际上 Redis 针对每种数据结构都有自己的底层内部编码实现,且是多种实现,这样 Redis 会在合适的场景选择合适的内部编码
  • list 后来引入了新的实现方式:quicklist,同时兼顾了 linkedlist 和 ziplist 的优点。quicklist 就是一个链表,每个元素又是一个 ziplist(空间和效率都折中兼顾到),类似于 C++ 中的 std::deque
  • 可以看到每种数据结构都有至少 2 种以上的内部编码实现,比如 list 数据结构包含了 linkedlist 和 ziplist 两种内部编码。同时有些内部编码,比如 ziplist,可以作为多种数据结构的内部实现
  • 通过 OBJECT ENCODING 命令查询内部编码
  • 这样设计有 2 个好处
    • 可以改进内部编码,而对外的数据结构和命令没有任何影响,一旦开发出更优秀的内部编码,无需改动外部数据结构和命令
    • 多种内部编码实现可以在不同场景下发挥各自优势

4、单线程架构

  • Redis 使用了单线程架构来实现高性能的内存数据库服务,单线程模型是使用和运维 Redis 的关键
  • Redis 只使用一个线程处理所有的命令请求,并不是说一个 Redis 服务器进程内部就只有一个线程,其实也有多个线程,不过是在处理网络 IO
  • 虽然多个客户端看起来是同时要求 Redis 去执行命令的,也相当于并发的发起请求。但从微观角度来看,Redis 是串行 / 顺序执行这多个命令的(采用线性方式执行),只是原则上命令的执行顺序是不确定的,但一定不会有多条命令被同步执行
  • Redis 虽然是单线程模型,但其效率这么高,速度还能这么快的原因(与 MySQL、Oracle、Sql Server 做参照)
    • Redis 是纯内存访问,而数据库是访问硬盘。Redis 将所有数据放在内存中,内存的响应时长大约为 100ns,这是 Redis 达到每秒万级别访问的重要基础
    • Redis 的核心功能比数据库的核心功能更简单(数据库对于数据的增删改查都有更复杂的功能支持,这样的功能势必要花费更多开销,比如针对插入删除,数据库中的各种约束都会让数据库做额外工作)
    • Redis 是单线程模型,避免了线程切换和竞态产生的消耗。Redis 的每个基本操作都是 “短平快” 的,就是简单操作一下内存数据,不是特别消耗 CPU 的操作。即使新增几个线程,提升也不大。单线程可以简化数据结构和算法的实现,让程序模型更简单,也避免了在线程竞争同一份共享数据时带来的切换和等待消耗
    • 非阻塞 IO。Redis 使用 epoll 作为 I/O 多路复用技术的实现,再加上 Redis 自身的事件处理模型将 epoll 中的连接、读写、关闭都转换为事件,不在网络 I/O 上浪费过多时间
      • 本质上就是一个线程可以管理多个 socket。针对 TCP 来说,服务器这边每次要服务一个客户端都需要给这个客户端安排一个 socket。假设一个服务器服务多个客户端,同时就会有很多个 socket,但这些 socket 并不是每时每刻都在传输数据,很多情况下,每个客户端和服务器之间的通信并没有那么频繁,此时很多 socket 大部分时间都是静默的,上面是没有数据需要传输的,即同一时刻只有少数 socket 是活跃的
      • Redis 使用 I/O 多路复用模型

        • 虽然单线程给 Redis 带来很多好处,但有一个致命问题:对单个命令的执行时间是有要求的。如果某个命令执行过长,会导致其它命令都处于等待队列中,迟迟等不到响应,从而造成客户端阻塞,对于 Redis 这种高性能的服务来说是非常严重的,所以 Redis 是面向快速执行场景的数据库

5、String 字符串

  • Redis 中所有键的类型都是字符串类型,且其它几种数据结构也都是在字符串类似基础上构建的,例如列表和集合的元素类型是字符串类型
  • 字符串类型的值可以是字符串,包含一般格式的字符串或者类似 JSON、XML 格式的字符串;数字可以是整型或者浮点型;甚至是二进制流数据,例如图片、⾳频、视频等。不过一个字符串的最大值不能超过 512 MB
  • 由于 Redis 内部存储字符串完全是按照二进制流的形式保存的,所以 Redis 是不处理字符集编码问题的,客户端传入的命令中使用什么字符集编码,就存储什么字符集编码
  • 字符串数据类型
  • 常见命令
    • SET
      • 将 string 类型的 value 设置到 key 中。如果 key 之前存在则覆盖,无论原来的数据类型是什么,之前关于此 key 的 TTL 也全部失效
      • 语法
        • SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
      • 时间复杂度
        • O(1)
      • SET 命令支持多种选项来影响它的行为(选项)
        • EX seconds —— 使用秒作为单位设置 key 的过期时间
        • PX milliseconds —— 使用毫秒作为单位设置 key 的过期时间
        • NX —— 只在 key 不存在时才进行设置,创建新的键值对,即如果 key 之前已经存在,设置不执行
        • XX —— 只在 key 存在时才进行设置,让新的 value 覆盖旧的 value,可能会改变原来的数据类型,即如果 key 之前不存在,设置不执行
      • 返回值
        • 如果设置成功,返回 OK
        • 如果由于 SET 指定了 NX 或者 XX 但条件不满足,SET 不会执行,并返回 (nil)
      • FLUSHALL:清空所有数据(类似于 MySQL 里的 drop database)
    • GET
      • 获取 key 对应的 value
      • 只支持字符串类型的 value,如果是其它类型,那么使用 GET 获取就会出错
      • 语法
        • GET key
      • 时间复杂度
        • O(1)
      • 返回值
        • key 对应的 value,当 key 不存在则返回 nil
    • MSET
      • 一次性设置多个 key 的值
      • 语法
        • MSET key value [key value ...]
      • 时间复杂度
        • O(N),N 是 key 数量
      • 返回值
        • 永远是 OK
    • MGET
      • 一次性获取多个 key 的值。如果对应的 key 不存在或者对应的数据类型不是 string,返回 nil
      • 语法
        • MGET key [key ...]
      • 时间复杂度
        • O(N) N 是 key 数量
      • 返回值
        • 对应 value 的列表
      • 多次 get VS 单次 mget
    • 使用 mget / mset 由于可以有效地减少了网络时间,所以性能相较更高
      • 使用批量操作可以有效提高业务处理效率,但要注意每次批量操作所发送的键的数量不是无节制的,否则可能造成单一命令执行时间过长,导致 Redis 阻塞
    • SETNX
      • 设置 key-value,但只允许在 key 之前不存在的情况下
      • 语法
        • SETNX key value
      • 时间复杂度
        • O(1)
      • 返回值
        • 1 表示设置成功,0 表示没有设置
      • SET、SET NX 和 SET XX 执行流程
  • 计数命令
    • INCR
      • 将 key 对应的 string 表示的数字 +1。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型则报错
      • 语法
        • INCR key
      • 时间复杂度
        • O(1)
      • 返回值
        • integer 类型的加完后的数值
    • INCRBY
      • 将 key 对应的 string 表示的数字加上对应的值。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过 64 位有符号整型则报错
      • 语法
        • INCRBY key decrement
      • 时间复杂度
        • O(1)
      • 返回值
        • integer 类型加完后的数值
    • DECR
      • 将 key 对应的 string 表示的数字 -1。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型则报错,运算结果也是计算后的值
      • 语法
        • DECR key
      • 时间复杂度
        • O(1)
      • 返回值
        • integer 类型的减完后的数值
    • DECYBY
      • 将 key 对应的 string 表示的数字减去对应的值。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型则报错
      • 语法
        • DECRBY key decrement
      • 时间复杂度
        • O(1)
      • 返回值
        • integer 类型的减完后的数值
    • INCRBYFLOAT
      • 将 key 对应的 string 表示的浮点数加上对应的值。如果对应的值是负数,则视为减去对应的值。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的不是 string,或者不是一个浮点数则报错。允许采用科学计数法表示浮点数
      • 语法
        • INCRBYFLOAT key increment
      • 时间复杂度
        • O(1)
      • 返回值
        • 加 / 减完后的数值
      • 很多存储系统和编程语言内部使用 CAS 机制实现计数功能,会有一定的 CPU 开销,但在 Redis 中完全不存在这个问题,因为 Redis 是单线程架构,任何命令到了 Redis 服务端都要顺序执行
  • 其他命令
    • APPEND
      • 如果 key 已经存在且是⼀个 string,命令会将 value 追加到原有 string 的后边。如果 key 不存在,则效果等同于 SET 命令
      • 语法
        • APPEND KEY VALUE
      • 时间复杂度
        • O(1),追加的字符串一般长度较短,可以视为 O(1)
      • 返回值
        • 追加完成后 string 的长度
        • append 的返回值长度的单位是字节,Redis 的字符串不会对字符编码做任何处理
      • 当前 XShell 终端默认的字符编码是 utf-8,在终端中输入汉字后,也是按照 utf8 编码,一个汉字在 utf8 字符集中通常是 3 个字节
      • 在启动 Redis 客户端时,加上 --raw 选项,可以使 Redis 客户端自动把二进制数据尝试翻译
    • GETRANGE
      • 返回 key 对应的 string 的子串,由 start 和 end 确定(左闭右闭)。可以使用负数表示倒数,-1 代表倒数第一个字符,即下标为 len-1 的元素,-2 代表倒数第二个。超过范围的偏移量会根据 string 的长度调整成正确的值
      • 语法
        • GETRANGE key start end
      • 时间复杂度
        • O(N),N 为 [start, end] 区间的长度,由于 string 通常比较短,可以视为是 O(1)
      • 返回值
        • string 类型的子串
      • 如果字符串中保存的是汉字,此时进行子串切分很可能切出来的就不是完整的汉字
    • SETRANGE
      • 覆盖字符串的一部分,从指定的偏移开始
      • 语法
        • SETRANGE key offset value
      • 时间复杂度
        • O(N),N 为 value 的长度,由于一般的 value 比较短,通常视为 O(1)
      • 返回值
        • 替换后的 string 的长度
      • 如果 value 是一个中文字符串,进行 setrange 可能会出问题
      • 这里凭空生成了一个字节,这个字节里的内容就是 "0x00",aaa 就被追加到 "0x00" 后面了。setange 针对不存在的 key 也是可以操作的,不过会把 offset 之前的内容填充成 "0x00"
    • STRLEN
      • 获取 key 对应的 string 的长度。当 key 存放的类型不是 string 时报错
      • 语法
        • STRLEN key
      • 时间复杂度
        • O(1)
      • 返回值
        • string 的长度。如果 key 不存在则返回 0
        • 单位是字节
      • 在 MySQL 中,varchar(N) 的 N 的单位就是字符,MySQL 中的字符也是完整的汉字,这样的一个字符也可能是多个字节
  • 命令小结
    • 下表是字符串类型命令的效果、时间复杂度

  • 内部编码
    • 字符串类型的内部编码有 3 种
      • int:64 位 / 8 个字节的长整型
      • embstr:小于等于 39 个字节的字符串,压缩字符串,适用于表示比较短的字符串
      • raw:大于 39 个字节的字符串,普通字符串,适用于表示更长的字符串,只是单纯的持有字节数组
    • Redis 会根据当前值的类型和长度动态决定使用哪种内部编码实现
    • Redis 存储小数,本质上还是当作字符串来存储,这和整数相比差别很大。整数直接使用 int 来存储(准确来说是一个 long long)比较方便进行算术运算。小数则是使用字符串来存储,意味着每次进行算术运算都需要把字符串转成小数来进行运算,然后结果再转回字符串保存
  • 典型使用场景
    • 缓存(Cache)
      • Redis + MySQL 组成的缓存存储架构

        • 其中 Redis 作为缓冲层,MySQL 作为存储层,绝大部分请求数据都是从 Redis 中获取。由于 Redis 具有支撑高并发的特性,所以缓存通常能起到加速读写和降低后端压力的作用
        • 整体思路:应用服务器访问数据时,先查询 Redis。如果 Redis 上数据存在,就直接从 Redis 中取出数据交给应用服务器,不继续访问数据库了。如果 Redis 上数据不存在,那么此时再读取 MySQL,把读到的结果返回给应用服务器,同时把这个数据也写入到 Redis 中
        • 上述策略存在一个明显问题:随着时间推移,会有越来越多 key 在 Redis 上访问不到,从而从 MySQL 中读取并写入 Redis 了,此时 Redis 中的数据不是会越来越多吗?
          • 在把数据写给 Redis 的同时,给这个 key 设置一个过期时间
          • Redis 也在内存不足时,提供了淘汰策略
      • 通过增加缓存功能,在理想情况下,每个用户信息一个小时期间只会有一次 MySQL 查询,极大提高了查询效率,也降低了 MySQL 的访问数
      • 与 MySQL 等关系型数据库不同的是,Redis 没有表、字段这种命名空间,而且也没有对键名有强制要求,除了不能使用一些特殊字符。但设计合理的键名,有利于防止键冲突和项目的可维护性,推荐使用 "业务名:对象名:唯一标识:属性" 作为键名。例如:MySQL 的数据库名为 vs,用户表名为 user_info,那么对应的键可以使用 "vs:user_info:6379"、"vs:user_info:6379:name" 来表示,如果当前 Redis 只会被一个业务使用,则可以省略业务名。如果键名过长,则可以使用团队内部都认同的缩写替代。毕竟键名过长会导致 Redis 的性能明显下降
    • 计数(Counter)
      • 许多应用都会使用 Redis 作为计数的基础工具,它可以实现快速计数、查询缓存的功能,同时数据可以异步处理或落地到其它数据源
      • 记录视频播放次数

      • 写入统计数据仓库(可能是 MySQL,或者是 HDFS)的步骤往往是异步的,所以并不是说来一个播放请求,就必须立马写一个数据
    • 共享会话(Session)
      • Session 分散存储

        • 一个分布式 Web 服务将用户的 Session 信息(比如用户登录信息)保存在各自的服务器中,但会造成一个问题:出于负载均衡的考虑,分布式服务会将用户的访问请求均衡到不同的服务器上,且通常无法保证用户每次请求都会被均衡到同一台服务器上,当用户刷新一次访问可能需要重新登录,这是用户无法容忍的
      • Redis 集中管理 Session


6、哈希

  • 几乎所有的主流编程语言都提供了哈希(hash)类型,它们的叫法可能是哈希、字典、关联数组、映射。在 Redis 中,哈希类型是指值本身又是⼀个键值对结构,形如 key = "key",value = { { field1, value1 }, ..., { fieldN, valueN } }
  • Redis 键值对和哈希类型二者的关系

    • 哈希类型中的映射关系通常称为 field-value,用于区分 Redis 整体的键值对(key-value),注意这里的 value 是指 field 对应的值,不是键(key)对应的值,请注意 value 在不同上下文的作用
  • 命令
    • HSET
      • 设置 hash 中指定的字段(field)的值(value)
      • 语法
        • HSET key field value [field value ...]
      • 时间复杂度
        • 插⼊一组 field 为 O(1),插⼊ N 组 field 为 O(N)
      • 返回值
        • 添加的字段的个数,也就是设置成功的键值对的个数
    • HGET
      • 获取 hash 中指定字段的值
      • 语法
        • HGET key field
      • 时间复杂度
        • O(1)
      • 返回值
        • 字段对应的值或者 nil
    • HEXISTS
      • 判断 hash 中是否有指定的字段
      • 语法
        • HEXISTS key field
      • 时间复杂度
        • O(1)
      • 返回值
        • 1 表示存在,0 表示不存在
    • HDEL
      • 删除 hash 中指定的字段
      • 语法
        • HDEL key field [field ...]
      • 时间复杂度
        • 删除一个元素为 O(1),删除 N 个元素为 O(N)
      • 返回值
        • 本次操作删除的字段个数
    • HKEYS
      • 获取 hash 中的所有字段
      • 语法
        • HKEYS key
      • 时间复杂度
        • O(N),N 为 field 的个数,当前的 O(N) 可以说成是 O(1)
      • 返回值
        • 字段列表
    • HVALS
      • 获取 hash 中的所有的值
      • 语法
        • HVALS key
      • 时间复杂度
        • O(N),N 为 field 的个数
        • 如果 field(哈希)非常大,那么这个操作就可能导致 Redis 服务器被阻塞住
      • 返回值
        • 所有的值
    • HGETALL
      • 获取 hash 中的所有字段以及对应的值
      • 这个操作的风险比较大,但多数情况下,不需要查询所有的 field,可能只查其中几个 field
      • 语法
        • HGETALL key
      • 时间复杂度
        • O(N),N 为 field 的个数
      • 返回值
        • 字段和对应的值
      • hash 类型没有下标的概念
    • HMGET
      • 一次获取 hash 中多个字段的值
      • 语法
        • HMGET key field [field ...]
      • 时间复杂度
        • 只查询⼀个元素为 O(1),查询多个元素为 O(N) N 为查询元素个数
      • 返回值
        • 字段对应的值或者 nil
      • 多个 value 的顺序和 field 的顺序是匹配的
      • 在使用命令 HKEYS,HVALS,HGETALL 完成所有的遍历操作时,都是存在一定风险的,如果 hash 的元素个数太多,执行的耗时就比较长,那么就会存在阻塞 Redis 的可能
      • 如果只需要获取部分 field,可以使用 HMGET,如果一定要获取全部 field,可以尝试使用 HSCAN 命令,该命令采用渐进式遍历哈希类型(敲一次命令,遍历一小部分,时间是可控的,连续执行多次就可以完成整个遍历过程)
      • 也有 hmset 一次设置多个 field 和 value,但是并不需要使用,因为 hset 已经支持一次设置多个 field 和 value 了
    • HLEN
      • 获取 hash 中的所有字段的个数
      • 语法
        • HLEN key
      • 时间复杂度
        • O(1)
      • 返回值
        • 字段个数
    • HSETNX
      • 在字段不存在的情况下,设置 hash 中的字段和值
      • 语法
        • HSETNX key field value
      • 时间复杂度
        • O(1)
      • 返回值
        • 1 表示设置成功,0 表示失败
    • HINCRBY
      • 将 hash 中字段对应的数值添加指定的值
      • 语法
        • HINCRBY key field increment
      • 时间复杂度
        • O(1)
      • 返回值
        • 该字段变化之后的值
    • HINCRBYFLOAT
      • HINCRBY 的浮点数版本
      • 语法
        • HINCRBYFLOAT key field increment
      • 时间复杂度
        • O(1)
      • 返回值
        • 该字段变化之后的值
    • 小结
      • 哈希类型命令的效果、时间复杂度
  • 内部编码
    • 哈希的内部编码有 2 种
      • ziplist(压缩列表)
        • 当哈希类型元素个数小于 hash-max-ziplist-entries 配置(默认 512 个)、同时所有值都小于 hash-max-ziplist-value 配置(默认 64 字节)时(这两个配置项是可以写到 redis.conf 文件中的),Redis 会使用 ziplist 作为哈希的内部实现,ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀
      • hashtable(哈希表)
        • 当哈希类型无法满足 ziplist 的条件时,Redis 会使用 hashtable 作为哈希的内部实现,因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O(1)
  • 使用场景
    • 关系型数据表保存用户信息

      • 用户的属性表现为表的列,每条用户信息表现为行
    • 映射关系表示用户信息

    • 哈希类型和关系型数据库有 2 点不同之处
      • 哈希类型是稀疏的,而关系型数据库是完全结构化的,例如哈希类型每个键可以有不同的 field,而关系型数据库一旦添加新的列,所有行都要为其设置值,即使为 null
      • 关系数据库可以做复杂的关系查询,而 Redis 去模拟关系型复杂查询,例如联表查询、聚合查询等基本不可能,维护成本高
    • 关系型数据库稀疏性

  • 缓存方式对比
    • 使用字符串类型,每个属性一个键
      • 优点:实现简单,针对个别属性变更也很灵活
      • 缺点:占用过多的键,内存占用量较大,同时用户信息在 Redis 中比较分散,缺少内聚性,所以这种方案基本没有实用性
    • 序列化字符串类型,例如 JSON 格式
      • 优点:针对总是以整体作为操作的信息比较合适,编程也简单。同时,如果序列化方案选择合适,内存的使用效率很高
      • 缺点:本身序列化和反序列需要一定开销,同时如果总是操作个别属性则非常不灵活
    • 哈希类型
      • 优点:简单、直观、灵活。尤其是针对信息的局部变更或者获取操作
      • 缺点:需要控制哈希在 ziplist 和 hashtable 两种内部编码的转换,可能会造成内存的较大消耗
  • List 列表
    • 列表两端插入和弹出操作
      • 列表类型是用来存储多个有序的字符串
      • 在 Redis 中,可以对列表两端插入(push)和弹出(pop),还可以获取指定范围的元素列表、获取指定索引下标的元素等
        • 列表的获取、删除等操作

        • 列表是一种比较灵活的数据结构,它可以充当栈和队列的角色,在实际开发上有很多应用场景
        • 特点
          • 列表中的元素是有序的(指的是顺序很关键,不是指升序 / 降序),这意味着可以通过索引下标获取某个元素或者某个范围的元素列表,例如要获取第 5 个元素,可以执行 lindex user:1:messages 4 或者倒数第 1 个元素 lindex user:1:messages -1
          • 列表中的元素允许重复
    • 命令
      • LPUSH
        • 将一个或多个元素从左侧放入(头插)到 list 中
        • 语法
          • LPUSH key element [element ...]
        • 时间复杂度
          • 只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数
        • 返回值
          • 插入后 list 的长度
      • LPUSHX
        • 当 key 存在时,将一个或多个元素从左侧放入(头插)到 list 中。不存在则直接返回
        • LPUSHX 指的是:left push exists
        • 语法
          • LPUSHX key element [element ...]
        • 时间复杂度
          • 只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数
        • 返回值
          • 插入后 list 的长度
      • RPUSH
        • 将一个或者多个元素从右侧放入(尾插)到 list 中
        • 语法
          • RPUSH key element [element ...]
        • 时间复杂度
          • 只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数
        • 返回值
          • 插入后 list 的长度
      • RPUSHX
        • 在 key 存在时,将一个或者多个元素从右侧放入(尾插)到 list 中
        • 语法
          • RPUSHX key element [element ...]
        • 时间复杂度
          • 只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数
        • 返回值
          • 插入后 list 的长度
      • LRANGE
        • 获取从 start 到 end 区间的所有元素,左闭右闭(闭区间),下标支持负数
        • LRANGE 指的是:list range
        • 语法
          • LRANGE key start stop
        • 时间复杂度
          • O(N)
        • 返回值
          • 指定区间的元素
        • 尽可能的获取到给定区间范围内的元素,如果给定区间非法,就会尽可能的获取对应的内容
      • LPOP
        • 从 list 左侧取出元素,即头删
        • 语法
          • LPOP key
        • Redis 后续新增了一个 count 参数,用来描述要删除几个元素
        • 时间复杂度
          • O(1)
        • 返回值
          • 取出的元素或者 nil
      • RPOP
        • 从 list 右侧取出元素,即尾删
        • 语法
          • RPOP key
        • 时间复杂度
          • O(1)
        • 返回值
          • 取出的元素或者 nil
        • 搭配使用 rpush 和 lpop 就相当于队列
        • 搭配使用 rpush 和 rpop 就相当于栈
      • LINDEX
        • 获取从左数第 index 位置的元素
        • LINDEX 指的是:list index
        • 语法
          • LINDEX key index
        • 时间复杂度
          • O(N)
        • 返回值
          • 取出的元素或者 nil
      • LINSERT
        • 在特定位置插入元素
        • 语法
          • LINSERT key <BEFORE | AFTER> pivot element
        • 时间复杂度
          • O(N),N 表示列表长度
        • 返回值
          • 插入后的 list 长度
        • 要根据基准值找到对应的位置,从左往右找,找到第一个符合基准值的位置即可
      • LLEN
        • 获取 list 长度
        • 语法
          • LLEN key
        • 时间复杂度
          • O(1)
        • 返回值
          • list 的长度
      • LREM
        • 根据参数 count 的值,移除列表中与参数 element 相等的元素
          • count > 0:从表头开始向表尾搜索,移除与 element 相等的元素,数量为 count
          • count < 0:从表尾开始向表头搜索,移除与 element 相等的元素,数量为 count 的绝对值
          • count = 0:移除表中所有与 element 相等的值
        • 语法
          • LREM key count element
        • 时间复杂度
          • O(N)
        • 返回值
          • 被移除元素的数量。 列表不存在时返回 0
      • LTRIM
        • 对一个列表进行修剪(trim),即让列表只保留 start 和 stop 区间内(闭区间)的元素,不在区间之内的元素都将被直接删除
        • 语法
          • LTRIM key start stop
        • 时间复杂度
          • O(N)
        • 返回值
          • 命令执行成功时,返回 OK
      • LSET
        • 通过索引来设置元素的值。当索引参数超出范围,或对一个空列表进行 LSET 时,返回一个错误
        • 语法
          • LSET key index element
        • 时间复杂度
          • O(N)
        • 返回值
          • 操作成功返回 OK,否则返回错误信息
        • lindex 可以很好的处理下标越界的情况,直接返回 nil
        • lset 则会报错,不会直接在 10 下标新增一个元素
    • 阻塞版本命令
      • blpop 和 brpop 是 lpop 和 rpop 的阻塞版本,和对应非阻塞版本的作用基本一致,除了几点
        • 在列表有元素的情况下,阻塞和非阻塞表现是一致的。但如果列表中没有元素,非阻塞版本会直接返回 nil,但阻塞版本会根据 timeout 阻塞⼀段时间(可以显示设置阻塞时间的),期间 Redis 可以执行其他命令(此处的 blpop 和 brpop 看起来好像耗时很长,但实际上并不会对 Redis 服务器产生负面影响),但要求执行该命令的客户端会表现为阻塞状态
        • 如果命令中设置了多个键 key,那么会从左向右遍历键,一旦有一个键对应的列表中可以弹出元素,立即返回命令
        • 如果多个客户端同时执行 pop,则最先执行命令的客户端会得到弹出的元素
      • 阻塞版本的 blpop 和非阻塞版本 lpop 的区别
      • BLPOP
        • LPOP 的阻塞版本
        • 语法
          • BLPOP key [key ...] timeout
          • 可以指定超时时间,单位是秒(Redis 后来超时时间允许设定成小数)
        • 时间复杂度
          • O(1)
        • 返回值
          • 取出的元素或者 nil
      • BRPOP
        • RPOP 的阻塞版本。效果和 BLPOP 类似,但这里是头删
        • 语法
          • BRPOP key [key ...] timeout
        • 时间复杂度
          • O(1)
        • 返回值
          • 取出的元素或者 nil
      • BLPOP 和 BRPOP 这两个阻塞命令的用途主要是用来作为消息队列。虽然这两个命令在一定程度上可以满足消息队列的需求,但整体功能还是比较有限
    • 小结
      • 列表命令
    • 内部编码
      • quicklist
        • 相当于是链表和压缩列表的结合,整体还是一个链表,链表的每个节点是一个压缩列表。每个压缩列表都不让它太大,同时再把多个压缩列表通过链式结构连起来
    • 使用场景
      • 消息队列
        • 可以使用 lpush + brpop 组合实现经典的阻塞式生产者-消费者模型队列,生产者客户端使用 lpush 从列表左侧插入元素,多个消费者客户端使用 brpop 命令阻塞式地从队列中争抢队首元素。通过多个客户端来保证消费的负载均衡和高可用性
        • 阻塞消息队列模型
          • brpop 是阻塞操作,当列表为空时,brpop 就会阻塞等待,一直等到其它客户端 push 元素为止。当新元素到达后,首先是第一个消费者拿到元素,从 brpop 中返回。如果第一个消费者还想继续消费,则需要重新执行 brpop,排在最后。此时再来一个新元素,就是第二个消费者拿到该元素
      • 分频道的消息队列
        • 同样使用 lpush + brpop 命令,但通过不同的键模拟频道的概念,不同的消费者可以通过 brpop 不同的键值,实现订阅不同频道的理念
        • Redis 分频道阻塞消息队列模型
        • 多个列表(channel)/ 频道(topic)的场景很常见,像日常使用的一些程序,比如抖音。有一个通道用来传输短视频数据,还可以有一个通道来传输弹幕,一个通道来传输点赞、转发、收藏数据,一个通道来传输评论数据等。弄成多个频道就可以在某种数据发生问题时,不会对其他数据造成影响,即解耦合
      • 微博 Timeline
        • 每个用户都有属于自己的 Timeline(微博列表),现需要分页展示文章列表。可以考虑使用列表,因为列表不但是有序的,同时支持按照索引范围获取元素

7、Set 集合

  • 集合类型是保存多个字符串类型的元素的,可以使用 json 格式让 string 也能存储结构化数据,与列表类型有一些不同
    • 集合中,元素之间是无序的,此处的无序是和 list 的有序相对应的
    • 集合中,元素不允许重复
  • 集合类型
    • 除了支持集合内的增删查改操作,还支持多个集合取交集、并集、差集
      • list:[1, 2, 3] 和 [2, 1, 3] 是两个不同的 list
      • set:[1, 2, 3] 和 [2, 1, 3] 是同一个集合
  • 普通命令
    • SADD
      • 将⼀个/多个元素添加到 set 中
      • 重复的元素无法添加到 set 中
      • 语法
        • SADD key member [member ...]
      • 时间复杂度
        • O(1)
      • 返回值
        • 本次添加成功的元素个数
    • SMEMBERS
      • 获取一个 set 中的所有元素
      • 语法
        • SMEMBERS key
      • 时间复杂度
        • O(N),N 是集合中的元素个数
      • 返回值
        • 所有元素的列表
    • SISMEMBER
      • 判断一个元素在不在 set 中
      • 语法
        • SISMEMBER key member
      • 时间复杂度
        • O(1)
      • 返回值
        • 1 表示元素在 set 中,0 表示元素不在 set 中或 key 不存在
    • SCARD
      • 获取一个 set 的基数(cardinality),即 set 中的元素个数
      • 语法
        • SCARD key
      • 时间复杂度
        • O(1)
      • 返回值
        • set 内的元素个数
    • SPOP
      • 从 set 中删除并返回一个或多个元素
      • 由于 set 内的元素是无序的,所以取出哪个元素实际是未定义行为,即可以看作是随机的
      • 语法
        • SPOP key [count]
      • 时间复杂度
        • O(N),N 是 count
      • 返回值
        • 取出的元素
    • SMOVE
      • 将一个元素从源 set 取出并放入目标 set 中
      • 语法
        • SMOVE source destination member
      • 时间复杂度
        • O(1)
      • 返回值
        • 1 表示移动成功,0 表示失败
    • SREM
      • 将指定的元素从 set 中删除
      • 语法
        • SREM key member [member ...]
      • 时间复杂度
        • O(N),N 是要删除的元素个数
      • 返回值
        • 本次操作删除的元素个数
  • 集合间操作
    • 集合求交集(inter)、并集(union)、差集(diff)的概念
    • SINTER
      • 获取给定 set 的交集中的元素
      • 语法
        • SINTER key [key ...]
      • 时间复杂度
        • O(N * M),N 是最小的集合元素个数,M 是最大的集合元素个数
      • 返回值
        • 交集的元素
    • SINTERSTORE
      • 获取给定 set 的交集中的元素并保存到目标 set 中
      • 想知道交集的内容,直接按照集合的方式访问目标 set 这个 key 即可
      • 语法
        • SINTERSTORE destination key [key ...]
      • 时间复杂度
        • O(N * M),N 是最小的集合元素个数,M 是最大的集合元素个数
      • 返回值
        • 交集的元素个数
    • SUNION
      • 获取给定 set 的并集中的元素
      • 语法
        • SUNION key [key ...]
      • 时间复杂度
        • O(N),N 给定的所有集合的总的元素个数
      • 返回值
        • 并集的元素
    • SUNIONSTORE
      • 获取给定 set 的并集中的元素并保存到目标 set 中
      • 语法
        • SUNIONSTORE destination key [key ...]
      • 时间复杂度
        • O(N),N 给定的所有集合的总的元素个数
      • 返回值
        • 并集的元素个数
    • SDIFF
      • 获取给定 set 的差集中的元素
      • 语法
        • SDIFF key [key ...]
      • 时间复杂度
        • O(N),N 给定的所有集合的总的元素个数
      • 返回值
        • 差集的元素
    • SDIFFSTORE
      • 获取给定 set 的差集中的元素并保存到目标 set 中
      • 语法
        • SDIFFSTORE destination key [key ...]
      • 时间复杂度
        • O(N),N 给定的所有集合的总的元素个数
      • 返回值
        • 差集的元素个数
  • 命令小结
    • 集合类型命令
  • 内部编码
    • 集合类型的内部编码有 2 种
      • intset(整数集合):当集合中的元素都是整数且元素个数小于 set-max-intset-entries 配置时,Redis 会选用 intset 来作为集合的内部实现,从而减少内存的使用
        • 当元素个数较少并且都为整数时,内部编码为 intset
      • hashtable(哈希表):当集合类型无法满足 intset 的条件时,Redis 会使用 hashtable 作为集合的内部实现
        • 当元素个数超过 512 个,内部编码为 hashtable
        • 当存在元素不是整数时,内部编码为 hashtable
  • 使用场景
    • 集合类型比较典型的使用场景是标签(tag)。例如 A 用户对娱乐、体育板块比较感兴趣,B 用户对历史、新闻比较感兴趣,这些兴趣点可以被抽象为标签。有了这些数据就可以得到喜欢同一个标签的人,以及用户的共同喜好的标签,这些数据对于增强用户体验和用户黏度都非常有帮助。例如一个电子商务网站会对不同标签的用户做不同的产品推荐
      • 通过集合类型来实现标签的若干功能
        • 给用户添加标签
        • 给标签添加用户
        • 删除用户下的标签
        • 删除标签下的用户
    • 可以使用 Set 来计算用户之间的共同好友(基于集合求交集),基于此还可以做一些好友推荐
    • 一个互联网产品衡量用户量、用户规模,其主要的指标有 2 点
      • PV(Page View),用户每次访问该服务器都会产生一个 pv
      • UV(User View),每个用户访问服务器都会产生一个 uv,但同一个用户多次访问并不会使 uv 增加。uv 需要按照用户进行去重,去重的过程就可以使用 Set 来实现

8、Zset 有序集合

  • 保留了集合不能有重复成员的特点,但有序集合中的每个元素都有一个唯一的浮点类型的分数(score)与之关联,这使得有序集合中的元素是可以维护有序性的,但这个有序不是用下标作为排序依据而是用这个分数
  • Zset 的内部数据结构是跳表
  • 有序集合
    •  
      • 有序集合提供了获取指定分数和元素范围查找、计算成员排名等功能
    • 有序集合中的元素是不能重复的,但分数允许重复。类比于一次考试之后,每个人一定有一个唯一的分数,但分数允许相同
  • 列表、集合、有序集合三者的异同点
  • 普通命令
    • ZADD
      • 添加或更新指定的元素以及关联的分数到 zset 中,分数应该符合 double 类型,+inf/-inf 作为正负极限也是合法的(负无穷大不是无穷小,负无穷大的绝对值和无穷大是一样的)
      • ZADD 的相关选项
        • XX:仅仅用于更新已经存在的元素,不会添加新元素
        • NX:仅用于添加新元素,不会更新已经存在的元素
        • LT:仅当新分数小于当前分数时才更新现有元素,不会阻止添加新元素
        • GT:仅当新分数大于当前分数时才更新现有元素,不会阻止添加新元素
        • CH:默认情况下,ZADD 返回的是本次添加的元素个数,但指定这个选项之后,就会还包含本次更新的元素的个数
        • INCR:此时命令类似 ZINCRBY 的效果,将元素的分数加上指定的分数。此时只能指定⼀个元素和分数
      • 语法
        • ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]
        • member 和 score 称为是一个 pair,类似于 C++ 里的 std::pair。不要理解成键值对(key - value pair),键值对中是有明确的角色区分,一定是根据键 -> 值。而对于有序集合来说,既可以通过 member 找到对应的 score,也可以通过 score 找到匹配的 member
      • 时间复杂度
        • O(log(N))
      • 返回值
        • 返回新增成功的元素个数
    • ZCARD
      • 获取一个 zset 的基数(cardinality),即 zset 中的元素个数
      • 语法
        • ZCARD key
      • 时间复杂度
        • O(1)
      • 返回值
        • zset 内的元素个数
    • ZCOUNT
      • 返回分数在 min 和 max 之间的元素个数,默认情况下,min 和 max 都是包含的,如果不想要边界值,可以通过在边界值前加上 '(' 来排除
      • 语法
        • ZCOUNT key min max
      • 时间复杂度
        • O(log(N))
      • 先根据 min 找到对应的元素,再根据 max 找到对应的元素,两次都是 O(log(N))。Zset 内部会记录每个元素当前的排行 / 次序,查询到元素就直接知道了元素所在的次序(下标),就可以直接把 max 对应的元素次序和 min 对应的元素次序做减法即可
      • 返回值
        • 满足条件的元素列表个数
    • ZRANGE
      • 返回指定区间里的元素,分数按照升序。带上 WITHSCORES 可以把分数也返回
      • 语法
        • ZRANGE key start stop [WITHSCORES]
        • 此处的 [start, stop] 为下标构成的区间,从 0 开始,支持负数
      • 时间复杂度
        • O(log(N)+M),N 是整个有序集合的元素个数,M 是 start - stop 区间内的元素个数
      • 返回值
        • 区间内的元素列表
      • Redis 内部存储数据是按照二进制的方式存储的,意味着 Redis 服务器是不负责字符编码的,所以要把二进制对回到汉字需要客户端支持
    • ZREVRANGE
      • 返回指定区间里的元素,分数按照降序,带上 WITHSCORES 可以把分数也返回
      • 这个命令可能在 6.2.0 之后废弃,且功能合并到 ZRANGE 中
      • 语法
        • ZREVRANGE key start stop [WITHSCORES]
      • 时间复杂度
        • O(log(N)+M)
      • 返回值
        • 区间内的元素列表
    • ZPOPMAX
      • 删除并返回分数最高的 count 个元素
      • 语法
        • ZPOPMAX key [count]
      • 时间复杂度
        • O(log(N) * M),N 是有序集合的元素个数,M 表示 count,要删除的元素个数
      • 返回值
        • 分数和元素列表
      • 如果存在多个元素分数相同(分数是主要因素,相同的情况下会按照 member 字符串的字典序来决定先后顺序),同时为最大值,那么 zpopmax 删除最大元素时,仍然只会删除其中一个元素
    • BZPOPMAX
      • ZPOPMAX 的阻塞版本。可以同时读多个有序集合
      • 语法
        • BZPOPMAX key [key ...] timeout
        • timeout 单位是 s,支持小数形式
      • 时间复杂度
        • O(log(N)),删除最大值花费的时间
        • 如果当前 BZPOPMAX 同时监听多个 key,假设 key 是 M 个,那么此时时间复杂度是 O(log(N)*M) 吗?
          • 每个这样的 key 上面都删除一次元素才需要 *M,而这里是从这若干个 key 中只删除一次
      • 返回值
        • 元素列表
    • ZPOPMIN
      • 删除并返回分数最低的 count 个元素
      • 语法
        • ZPOPMIN key [count]
      • 时间复杂度
        • O(log(N)*M)
      • 返回值
        • 分数和元素列表
    • BZPOPMIN
      • ZPOPMIN 的阻塞版本
      • 语法
        • BZPOPMIN key [key ...] timeout
      • 时间复杂度
        • O(log(N))
      • 返回值
        • 元素列表
    • ZRANK
      • 返回指定元素的排名,升序
      • 语法
        • ZRANK key member
      • 时间复杂度
        • O(log(N))
        • ZRANK 查找元素的过程和 ZCOUNT 是一样的
      • 返回值
        • 排名
    • ZREVRANK
      • 返回指定元素的排名,降序
      • 语法
        • ZREVRANK key member
      • 时间复杂度
        • O(log(N))
      • 返回值
        • 排名
    • ZSCORE
      • 返回指定元素的分数
      • 语法
        • ZSCORE key member
      • 时间复杂度
        • O(1)
        • 此处相当于 Redis 对于这样的查询操作做了特殊优化,付出了额外的空间代价
      • 返回值
        • 分数
    • ZREM
      • 删除指定的元素
      • 语法
        • ZREM key member [member ...]
      • 时间复杂度
        • O(M*log(N))
      • 返回值
        • 本次操作删除的元素个数
    • ZREMRANGEBYRANK
      • 按照排序,升序删除指定范围的元素,左闭右闭
      • 语法
        • ZREMRANGEBYRANK key start stop
      • 时间复杂度
        • O(log(N)+M)
      • 返回值
        • 本次操作删除的元素个数
    • ZREMRANGEBYSCORE
      • 按照分数删除指定范围的元素,左闭右闭,也可以使用 '(' 来排除边界值
      • 语法
        • ZREMRANGEBYSCORE key min max
      • 时间复杂度
        • O(log(N)+M)
      • 返回值
        • 本次操作删除的元素个数
    • ZINCRBY
      • 为指定的元素的关联分数添加指定的分数值
      • 语法
        • ZINCRBY key increment member
      • 时间复杂度
        • O(log(N))
      • 返回值
        • 增加后元素的分数
      • 不光会修改分数内容,还能同时移动元素位置,保证整个有序集合仍然是升序的
  • 集合间操作
    • 有序集合的交集操作
      • ZINTERSTORE
        • 求出给定有序集合中元素的交集并保存进目标有序集合中,在合并过程中以元素为单位进行合并,元素对应的分数按照不同的聚合方式和权重得到新的分数
        • 在有序集合中,member 是元素的本体,score 只是辅助排序的工具人。因此,在进行比较相同时,只要 member 相同即可。如果 member 相同,score 不同,进行交集合并之后的最终分数看 AGGREGATE 后面的属性
        • 语法
          • ZINTERSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE <SUM | MIN | MAX>]
          • numkeys 是一个整数,用来描述后续有几个 key 参与交集运算
        • 时间复杂度
          • O(N*K)+O(M*log(M)),N 是输入的有序集合中,最小的有序集合的元素个数;K 是输入了几个有序集合;M 是最终结果的有序集合的元素个数
        • 返回值
          • 目标集合中的元素个数
    • 有序集合的并集操作
      • ZUNIONSTORE
        • 求出给定有序集合中元素的并集并保存进目标有序集合中,在合并过程中以元素为单位进行合并,元素对应的分数按照不同的聚合方式和权重得到新的分数
        • 语法
          • ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE <SUM | MIN | MAX>]
        • 时间复杂度
          • O(N)+O(M*log(M)) N 是输入的有序集合总的元素个数,M 是最终结果的有序集合的元素个数
        • 返回值
          • 目标集合中的元素个数
  • 命令小结
    • 有序集合命令
  • 内部编码
    • 有序集合类型的内部编码有 2 种
      • ziplist(压缩列表):当有序集合的元素个数小于 zset-max-ziplist-entries 配置(默认 128 个),同时每个元素的值都小于 zset-max-ziplist-value 配置(默认 64 字节)时,Redis 会用 ziplist 来作为有序集合的内部实现,ziplist 可以有效减少内存的使用
        • 当元素个数较少且每个元素较小时,内部编码为 ziplist
      • skiplist(跳表):当 ziplist 条件不满足时,有序集合会使用 skiplist 作为内部实现,因为此时 ziplist 的操作效率会下降
        • 当元素个数超过 128 个,内部编码 skiplist
        • 当某个元素大于 64 字节时,内部编码 skiplist
    • 简单来说,跳表是一个复杂链表,查询元素的时间复杂度是 O(logN)。相比于树形结构,更适合按照范围获取元素
  • 使用场景
    • 排行榜系统。例如常见网站上的热榜信息,榜单的维度可能是多方面的:按照时间、按照阅读量、按照点赞量。本例中我们使用点赞数这个维度,维护每天的热榜

9、渐进式遍历

  • Redis 使用 scan 命令进行渐进式遍历键,进而解决直接使用 keys 获取键时可能出现的阻塞问题。不是一个命令将所有 key 都拿到,而是每执行一次命令,只获取其中一小部分,就可以保证当前这一次操作不会太卡。每次 scan 命令的时间复杂度是 O(1),但要完整完成所有键的遍历,需要执行多次 scan。渐进式遍历其实是一组命令,这一组命令的使用方法是一样的
  • scan 命令渐进式遍历

    • 首次 scan 从 0 开始
    • 当 scan 返回的下次位置为 0 时,遍历结束
    • 返回值的前半部分 1) 是说明下次继续遍历的光标(当作一个字符串即可)要从哪里开始,第二部分 2) 是真正遍历到的 key 的内容
  • SCAN
    • 以渐进式的方式进行键的遍历
    • 渐进式遍历再遍历过程中不会在服务器这边存储任何的状态信息,此处的遍历是随时可以终止的,不会对服务器产生任何的副作用
    • 语法
      • SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
      • count 是限制此次遍历能获取到多少个元素,默认是 10
      • 此处的 count(给 Redis 服务器一个提示 / 建议,写入的 count 和实际返回的 key 的个数不一定完全相同,但是不会差很多)和 MySQL 的 limit 不一样
    • 时间复杂度
      • O(1)
    • 返回值
      • 下一次 scan 的游标(cursor)以及本次得到的键
    • 除了 scan 以外,Redis 面向哈希类型、集合类型、有序集合类型分别提供了 hscan、sscan、zscan 命令,用法和 scan 基本类似
    • 渐进性遍历 scan 虽然解决了阻塞问题,但如果在遍历期间键有所变化,比如增删查改,可能会导致遍历时键的重复遍历或者遗漏

10、数据库管理

  • 几个面向 Redis 数据库的操作,分别是命令:dbsize、select、flushdb、flushall
  • 切换数据库
    • select dbIndex
    • 许多关系型数据库,例如 MySQL 支持在一个实例下有多个数据库存在的,但是与关系型数据库用字符来区分不同数据库名,Redis 只是用数字作为多个数据库的实现。Redis 默认配置中是有 16 个数据库,不能创建新的数据库,也不能删除已有的数据库,数据库中的数据是隔离的
      • 默认情况下处于数据库 0
    • select 0 操作会切换到第⼀个数据库,select 15 会切换到最后⼀个数据库。0 号数据库和 15 号数据库有各自的键值对
    • 虽然支持多数据库,但随着版本的升级,不建议使用多数据库特性。如果真的需要完全隔离的两套键值对,建议维护多个 Redis 实例,而不是在一个 Redis 实例中维护多数据库。因为 Redis 并没有为多数据库提供太多的特性,其次无论是否有多个数据库,Redis 都是使用单线程模型,所以彼此之间还是需要排队等待命令的执行。同时多数据库还会让开发、调试和运维工作变得复杂,所以始终使用数据库 0 其实是⼀个很好的选择
  • 清除数据库
    • flushdb / flushall 命令用于清除数据库
      • flushdb:只清除当前数据库
      • flushall:清除所有数据库
    • 语法
      • FLUSHDB [ASYNC | SYNC]
      • FLUSHALL [ASYNC | SYNC]
        • ASYNC:异步
        • SYNC:同步

11、6379 是 Redis 服务器的默认端口


七、持久化

Redis 持久化 和 MySQL 里的持久性是一回事,把数据存储在硬盘上,重启进程 / 主机后数据仍然存在;把数据存储在内存上,重启进程 / 主机后数据消失 —— 不持久。持久化功能有效避免因进程退出造成数据丢失问题,当下次重启时利用之前持久化的文件即可实现数据恢复

Redis 为了保证速度快,数据肯定还得存储在内存中。但为了持久化,数据还得想办法存储在硬盘上。所以最后决定在内存中和硬盘上都存储数据,这样的两份数据在理论上是完全相同的。当要插入一个新的数据时,就需要把这个数据同时写入到内存和硬盘。当查询某个数据时,直接从内存中读取,硬盘的数据只是在 Redis 重启时,用来恢复内存中的数据。代价就是消耗了更多的空间,同一份数据存储两遍,但毕竟硬盘价格便宜,开销不会带来太大的成本,而且实际上具体怎么写硬盘还有不同的策略,是可以保证整体的效率足够高的


1、RDB

  • RDB(Redis DataBase)持久化是把定期的将当前的进程数据生成快照保存到硬盘的过程,触发 RDB 持久化过程分为手动触发和自动触发
  • 触发机制
    • 手动触发分别对应 save 和 bgsave 命令
      • save 命令:阻塞当前 Redis 服务器,直到 RDB 过程完成为止,对于内存比较大的实例造成长时间阻塞,一般不建议使用
      • bgsave(background save)命令:Redis 进程执行 fork 操作创建子进程,RDB 持久化过程由子进程负责,完成后自动结束。阻塞只发生在 fork 阶段,一般时间很短。不会影响 Redis 服务器处理其他客户端的请求和命令
        • 此处 Redis 使用的是 “多进程” 的方式来实现 bgsave
    • Redis 内部的所有涉及 RDB 的操作都采用类似 bgsave 的方式
    • 如果插入新的 key,而此时不手动执行 bgsave,直接重新启动 Redis 服务器,那么刚刚插入的数据在重启之后仍然存在。所以,Redis 生成快照操作不仅仅是手动执行命令才触发,也可以自动触发
    • 除了手动触发之外,Redis 运行自动触发 RDB 持久化机制
      • 使用 save 配置,比如 "save m n" 表示 m 秒内数据集发生了 n 次修改,自动 RDB 持久化
      • 从节点进行全量复制(主从复制)操作时,主节点自动进行 RDB 持久化生成快照,随后将 RDB 快照文件内容发送给从节点
      • 如果执行 shutdown 命令(service redis-server restart,正常关闭)关闭 Redis 时,或者通过正常流程重新启动 Redis 服务器,那么此时 Redis 服务器会在退出时自动执行 RDB 持久化。但如果是异常重启(kill -9 或者服务器掉电),那么此时 Redis 服务器来不及生成 rdb,内存中尚未保存到快照中的数据,就会随着重启而丢失
    • 并不是说 Redis 客户端这边插入了数据,rdb 文件中的数据就会立即更新。插入几个键值对后,没有运行手动触发的命令,达不到自动触发的条件,那么就不会更新
    • 对于 Redis 来说,配置文件发生修改后,一定要重新启动服务器才能生效。如果想要立即生效,也可以通过命令的方式进行修改
    • 如果把 rdb 文件故意改坏会怎么样?
      • 手动把 rdb 文件内容改坏,如果是通过 service redis-server restart 重启,就会在 Redis 服务器退出时重新生成 rdb 快照,那么刚才改坏的文件就会被替换掉
      • 通过 kill 进程的方式再重新启动 Redis 服务器,此时 rdb 文件还是错的,但看起来 Redis 好像没受到什么影响,还是能正常启动,正确获取到 key。是因为刚才修改的位置应该正好是文件的末尾,对前面的内容没有什么影响,但如果修改了中间位置的内容,那么 Redis 服务器就启动不了了
        • 此时 Redis 服务器挂了,可以看看 Redis 日志。Redis 也提供了 rdb 文件的检查工具,可以先通过检查工具来检查 rdb 文件格式是否符合要求
        • 检查工具和 Redis 服务器是同一个可执行程序,可以在运行时加入不同的选项,使用其中不同的功能,运行时加入 rdb 文件作为命令行参数,那么此时就是以检查工具的方式来运行,不会真的启动 Redis 服务器
  • 流程说明
    • bgsave 是主流的 RDB 持久化方式
    • bgsave 命令的运作流程
      •  
        • 执行 bgsave 命令,Redis 父进程(Redis 服务器)判断当前进程是否存在其他正在执行的子进程,比如 RDB / AOF 子进程,如果存在 bgsave 命令直接返回
        • 如果没有其它工作子进程,父进程通过执行 fork 系统调用来创建一个子进程(该场景中的绝大部分内存数据是不需要改变的,所以在短时间内父进程中不会有大批的内存数据变化,因此子进程的 “写时拷贝” 并不会触发很多次),fork 过程中父进程会阻塞
        • 父进程 fork 完成后,bgsave 命令返回 "Background saving started" 信息,并不再阻塞父进程,可以继续响应其他命令
        • 子进程创建 RDB 文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换
        • 进程发送信号给父进程表示完成,父进程更新统计信息,子进程就可以结束销毁了
      • bgsave 操作流程是创建子进程,子进程完成持久化操作(持久化速度太快了,难以观察到子进程,因为数据少),持久化会把数据写入到新的文件中,然后使用新的文件替换旧的文件
        • 可以通过 Linux 的 stat 命令来查看文件的 inode 编号
        • 这两个文件不再是同一个文件了,只不过文件内容是一样的。inode 编号就相当于文件的身份标识。如果是直接使用 save 命令,那么此时是不会触发子进程和文件替换逻辑的,会直接在当前进程中往同一文件中写入数据
        • 文件系统典型的组织方式(ext4)主要是把整个文件系统分成三个大的部分
          • 超级块(存放一些管理信息)
          • inode 区(存放 inode 节点,每个文件都会分配一个 inode 数据结构,包含文件的各种元数据)
          • block 区(存放文件的数据内容)
  • RDB 文件的处理
    • 保存:RDB 文件(把内存中的数据以压缩的形式保存到这个二进制文件中,需要消耗一定的 CPU 资源,但是能节省存储空间)保存在 dir 配置指定的目录下,文件名通过 dbfilename 配置(默认 dump.rdb)指定
    • 压缩:Redis 默认采用 LZF 算法对生成的 RDB 文件做压缩处理,压缩后的文件远远小于内存大小,默认开启
      • 虽然压缩 RDB 会消耗 CPU,但可以大幅降低文件的体积,方便保存到硬盘或通过网络发送到从节点
    • 校验:如果 Redis 启动时加载到损坏的 RDB 文件会拒绝启动,这时可以使用 Redis 提供的 redis-check-dump 工具检测 RDB 文件并获取对应的错误报告
      • 当执行生成 rdb 镜像操作时,此时就会把要生成的快照数据先保存到一个临时文件中,当这个快照生成完毕后,再删除之前的 rdb 文件,把新生成的临时 rdb 文件名改成刚才的 dump.rdb,也就保证了 rdb 文件自始至终只有一个
    • 执行 flushall 命令会自动清空 rdb 文件
  • RDB 的优缺点
    • 优点
      • RDB 是⼀个紧凑压缩的二进制文件,代表 Redis 在某个时间点上的数据快照。非常适用于备份,全量复制等场景。比如每 6 小时执行 bgsave 备份,并把 RDB 文件复制到远程机器或者文件系统中(如 hdfs)用于灾备
      • Redis 加载 RDB 恢复数据远远快于 AOF 的方式。 (二进制的方式则直接将数据读取到内存中,按照字节的格式取出来放到结构体 / 对象即可,文本方式组织数据则需要进行一系列的字符串切分操作)
    • 缺点
      • 数据没法做到实时持久化 / 秒级持久化(在两次生成快照之间,实时数据可能会随着重启而丢失),这就导致快照里的数据和当前实时的数据情况可能存在偏差,因为 bgsave 每次运行都要执行 fork 创建子进程,属于重量级操作,频繁执行成本过高
      • RDB 文件使用特定二进制格式保存,Redis 版本演进过程中有多个 RDB 版本,兼容性可能有风险,旧版本的 Redis 的 rdb 文件放到新版本的 Redis 中不一定能实现。但一般来说,实际工作中 Redis 版本都是统一的,实在不行也可以通过写一个程序的方式来直接遍历旧的 Redis 中的所有 key,把数据取出来插入到新的 Redis 服务器中即可

2、AOF

  • AOF(Append Only File)持久化(类似于 MySQL 中的 binlog,会把用户的每个操作都记录到文件中):以独立日志的方式记录每次写命令,重启时再重新执行 AOF 文件中的命令达到恢复数据的目的。AOF 的主要作用是解决数据持久化的实时性,目前已经是 Redis 持久化的主流方式
  • Redis 重新启动时,又读 RDB、又读 AOF,到底以哪个为准呢?
    • 当开启 AOF 时,rdb 就不生效了,启动时就不再读取 rdb 文件内容
  • 使用 AOF
    • 开启 AOF 功能需要设置配置:appendonly yes,默认是关闭状态。AOF 文件名通过 appendfilename 配置(默认是 appendonly.aof)设置
    • 保存目录同 RDB 持久化方式一致,通过 dir 配置指定。AOF 的工作流程操作:命令写入(append)、文件同步(sync)、文件重写(rewrite)、重启加载(load)
      • AOF 工作流程

        • 所有的写入命令会追加到 aof_buf(内存中的缓冲区,大大降低了写硬盘的次数)
        • AOF 缓冲区根据对应的策略向硬盘做同步操作
        • 随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,以达到压缩的目的
        • 当 Redis 服务器启动时,可以加载 AOF 文件进行数据恢复
      • 写硬盘时,写入数据的多少对于性能的影响不是很大,但写入次数影响很大
      • 硬盘上读写数据,顺序读写的速度还是比较快的,但还是比内存要慢很多,随机访问则速度比较慢。AOF 是每次将新的数据写入到原有文件的末尾,属于顺序写入
  • 命令写入
    • AOF 命令写入的内容是文本协议格式
      • Redis 选择文本协议可能的原因
        • 文本协议具备较好的兼容性
        • 实现简单
        • 具备可读性
    • 每次进行的操作都会被记录到文本文件中,通过一些特殊符号作为分隔符,来对命令的细节做出区分
    • 为什么需要 aof_buf 这个缓冲区?
      • Redis 虽然是使用单线程响应命令,但速度很快,因为它只是操作内存。引入 AOF 后,又要写内存,又要写硬盘,同时还要保持之前的速度,实际上这并没有影响到 Redis 处理请求的速度
      • AOF 机制并非是直接让工作线程把数据写入硬盘,而是先写入一个内存中的缓冲区,积累一波之后再统一写入。如果每次写 AOF 文件都直接同步硬盘,性能从内存的读写变成 IO 读写,必然会下降。先写入缓冲区可以有效减少 IO 次数,同时,Redis 还可以提供多种缓冲区同步(刷新)策略,让用户根据自己的实际情况和需求来做出合理的平衡
    • 把数据写入到缓冲区中的本质还是在内存中,万一此时进程突然挂了或者主机掉点了,那缓冲区中没来得及写入硬盘的数据也就丢了
      • 缓冲区刷新频率越高,性能影响就越大,同时数据的可靠性就越高
      • 缓冲区刷新频率越低,性能影响就越小,同时数据的可靠性就越低
  • 文件同步
    • Redis 提供了多种 AOF 缓冲区同步文件策略,由参数 appendfsync 控制
    • AOF 缓冲区同步文件策略
      • 默认采用 everysec 配置
      • 系统调用 write 和 fsync 说明
        • write 操作会触发延迟写(delayed write)机制。Linux 在内核提供页缓冲区来提供硬盘 IO 性能,write 操作在写入系统缓冲区后立即返回。同步硬盘操作依赖于系统调度机制,例如缓冲区页空间写满或达到特定时间周期。同步文件之前,如果此时系统故障宕机,缓冲区内数据将丢失
        • fsync 针对单个文件操作,做强制硬盘同步,将阻塞直到数据写入到硬盘
        • 配置为 always 时,每次写入都要同步 AOF 文件,性能很差,在⼀般的 SATA 硬盘上,只能⽀持大约几百 TPS 写入。除非是非常重要的数据,否则不建议配置
        • 配置为 no 时,由于操作系统同步策略不可控,虽然提高了性能,但数据丢失风险大增,除非数据重要程度很低,一般不建议配置
        • 配置为 everysec,是默认配置,也是推荐配置,兼顾了数据安全性和性能,理论上最多丢失 1s 的数据
  • 重写机制
    • 随着命令不断写入 AOF,文件持续增长,会影响到 Redis 下次的启动时间(Redis 在启动时需要读取 AOF 文件内容,该文件记录了中间过程,但实际上 Redis 在重启时只关注最终结果)。为了解决这个问题,引入 AOF 重写机制(能够针对 AOF 文件进行整理操作,剔除其中的冗余操作并合并一些操作)压缩文件体积
      • AOF 文件重写是把 Redis 进程内的数据转化为写命令,同步到新的 AOF 文件
      • 重写后的 AOF 为什么可以变小?
        • 进程内已超时的数据不再写入文件
        • 旧的 AOF 中的无效命令,例如 del、hdel、srem 等重写后将会删除,只需要保留数据的最终版本
        • 多条写操作合并为⼀条,例如 lpush list a、lpush list b、lpush list c 可以合并为 lpush list a b c
    • 较小的 AOF 文件一方面降低了硬盘空间占用,另一方面可以提升启动 Redis 时数据恢复的速度
    • AOF 重写过程可以手动触发和自动触发
      • 手动触发:调用 bgrewriteaof 命令
        • 如果在执行 bgrewriteaof 时,发现当前 Redis 已经正在进行 AOF 重写了,此时不会再次执行 AOF 重写,而是直接返回
        • 如果在执行 bgrewriteaof 时,发现当前 Redis 在生成 rdb 文件的快照,此时 AOF 重写操作就会等待 rdb 快照生成完毕之后再进行执行 AOF 重写
      • 自动触发:根据 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 参数确定自动触发时机
        • auto-aof-rewrite-min-size:表示触发重写时 AOF 的最小文件大小,默认为 64MB
        • auto-aof-rewrite-percentage:代表当前 AOF 占用大小相比较上次重写时增加的比例
    • AOF 重写流程

      • 执行 AOF 重写请求。如果当前进程正在执行 AOF 重写,请求不执行。如果当前进程正在执行 bgsave 操作,重写命令延迟到 bgsave 完成后再执行
      • 父进程执行 fork 创建子进程
      • 重写
        • 父进程 fork 之后,继续响应其它命令,仍然负责接收客户端的新请求。父进程还是会把这些请求产生的 AOF 数据先写入到缓冲区,并根据 appendfsync 策略同步到硬盘,保证旧 AOF 文件机制正确
        • 子进程只有 fork 之前的所有内存信息,父进程中需要将 fork 之后这段时间的修改操作写入 AOF 重写缓冲区中。重写的时候不关心 AOF 文件中原来有什么,只关心内存中最终的数据状态
      • 在创建子进程的一瞬间,子进程就根据内存快照,将命令合并到新的 AOF 文件中,也就是继承了当前父进程的内存状态。因此,子进程里的内存数据是父进程 fork 之前的状态。fork 后新来的请求对内存造成的修改是子进程感知不到的,父进程这里又准备了一个 aof_rewrite_buf 缓冲区,专门用来存放 fork 后收到的数据
      • 子进程完成重写
        • 子进程这边把 AOF 新文件数据写入完成后,子进程会通过发送信号通知父进程
        • 父进程再把 aof_rewrite_buf 缓冲区中的内容也追加到新 AOF 文件里
        • 用新 AOF 文件代替旧 AOF 文件
      • 此处子进程写数据的过程非常类似于 RDB 生成一个镜像快照,只不过 RDB 是按照二进制的方式来生成的,而 AOF 重写则是按照 AOF 要求的文本格式来生成的,二者目的都是把当前内存中的所有数据状态记录到文件中
    • 为什么 RDB 对于 fork 之后的新数据就直接置之不理了,而不选择采用和 AOF 一样的处理机制呢?
      • RDB 本身的设计理念就是用来 “定期备份” 的,只要是定期备份就难以和最新数据保持一致
        • 实时备份不一定就比定期备份更好,具体还是要看实际场景需求。现在系统中的系统资源一般都是比较充裕的,AOF 的开销并不大,所以一般 AOF 的适用场景更多一些
    • 父进程 fork 完毕之后,就已经让子进程写新的 AOF 文件了,并且随着时间的推移,子进程很快就写完了新文件,要让新的 AOF 文件代替旧的 AOF 文件,父进程此时还在继续写这个即将消亡的旧 AOF 文件是否还有意义呢?
      • 需要考虑到极端情况:假设在重写的过程中服务器突然挂了,此时子进程内存的数据就会丢失,新 AOF 文件内容还不完整,所以如果父进程不坚持写旧的 AOF 文件,那么重启就无法保证数据的完整性
    • AOF 本来是按照文本的方式来写入文件的,但后续加载成本较高,所以 Redis 就引入了 “混合持久化” 的方式,结合了 rdb 和 aof 的特点。按照 aof 的方式,将每一个请求 / 操作都记录到文件中。在触发 aof 重写之后,就会把当前内存的状态按照 rdb 的二进制格式写入到新的 aof 文件中,后续再进行的操作仍然是按照 aof 文本的方式追加到文件后面
  • 启动时数据恢复
    • 当 Redis 启动时,会根据 RDB 和 AOF 文件的内容,进行数据恢复


3、RDB 和 AOF 的区别和联系

  • RDB 视为内存的快照,产生的内容更为紧凑,占用空间较小,恢复时速度更快。但产生 RDB 的开销较大,不适合进行实时持久化,一般用于冷备和主从复制
  • AOF 视为对修改命令保存,在恢复时需要重放命令,并且有重写机制来定期压缩 AOF 文件
  • RDB 和 AOF 都使用 fork 创建子进程,利用 Linux 子进程拥有父进程内存快照的特点进行持久化,尽可能不影响主进程继续处理后续命令

八、事务

  • Redis 的事务和 MySQL 的事务概念上是类似的,都是把一系列操作绑定成一组,让这一组能够批量执行
  • Redis 的事务和 MySQL 事务的区别
    • MySQL 事务
      • 原子性:把多个操作打包成一个整体(要么全都做,要么都不做)
      • ⼀致性:事务执行前和执行后,数据得保持相同
      • 隔离性:事务并发执行涉及到的一些问题(脏读、幻读等)
      • 持久性:事务中做出的修改都会存储到硬盘中
    • Redis 的事务
      • 弱化的原子性:redis 没有 “回滚机制”,只能 “批量执行” 这些操作 ,不能做到 “一个失败就恢复到初始状态”,也就是无法保证执行成功(网上有的说 Redis 事务有原子性(只是打包一起执行),有的说没有原子性(打包一起执行 + 带有回滚 —— 打包一起正确执行))
      • 不保证一致性:不涉及 “约束”,也没有回滚(MySQL 的一致性体现的是运行事务前和运行后,结果都是合理有效的,不会出现中间非法状态)。如果事务在执行过程中某个修改操作出现失败,就可能引起不一致的情况
      • 不需要隔离性:没有隔离级别,因为不会并发执行事务(Redis 是一个单线程模型的服务器程序,所有的请求 / 事务都是 “串行” 执行的)
      • 不需要持久性:Redis 本身就是内存数据库。虽然 Redis 也有持久化机制,但是否开启持久化是 redis-server 的事,和事务无关
    • Redis 事务本质上是在服务器上搞了⼀个 “事务队列”,每次客户端在事务中进行一个操作,都会把命令先发给服务器,放到 “事务队列” 中,但并不会立即执行,而是在收到 EXEC 命令后,才按照顺序依次执行队列中的所有操作(在 Redis 主线程中完成的,主线程会把事务中的操作都执行完,再处理别的客户端)。因此,Redis 的事务功能相比于 MySQL 来说,是弱化很多的,只能保证事务中的这几个操作是 “连续的”,不会被别的客户端 “加塞”,仅此而已
    • 为什么 Redis 不设计成和 MySQL 一样强大呢?
      • MySQL 的事务付出了很大代价
        • 在空间上需要花费更多的空间来存储更多的数据
        • 在时间上也有更大的执行开销
      • 正是因为 Redis 简单、高效的特点,才能够在分布式系统中弥补一些 MySQL 不擅长的场景
    • 什么时候需要使用到 Redis 事务呢?
      • 如果需要把多个操作打包进行,使用事务是比较合适的。之前在多线程中是通过加锁的方式来避免 “插队” 的,而在 Redis 中直接使用事务即可
      •  
        • 在上面这个场景没有加锁也能解决问题
        • Redis 原生命令中确实没有类似这种条件判断,但 Redis 支持 lua 脚本,通过 lua 脚本就可以实现上述的条件判定,并且和事务一样也是打包批量执行的(lua 脚本的实现方式是 Redis 事务的进阶版本)
      • 如果 Redis 是按照集群模式部署的话,是不支持事务的
  • 事务操作
    • MULTI
      • 开启一个事务,执行成功返回 OK
    • EXEC
      • 真正执行事务
      • 每次添加一个操作,都会提示 "QUEUED",说明命令已经进入客户端的事务队列中。此时如果另外开一个客户端,再尝试查询这几个 key 对应的数据,是没有结果的。只有当真正执行 EXEC 时,客户端才会真正把上述操作发送给服务器,此时就可以获取到上述 key 的值了
    • DISCARD
      • 放弃当前事务,此时直接清空事务队列,之前的操作都不会真正执行到
      • 当开启事务并给服务器发送若干个命令之后,服务器重启,此时效果就等同于 discard
    • WATCH
      • 在执行事务时,如果某个事务中修改的值被别的客户端修改了,此时就容易出现数据不一致的问题
      • watch 在该客户端上监控一组具体的 key,看看这个 key 在事务的 multi 和 exec 之间,set key 之后,是否在外部被其它客户端修改了
        • 当开启事务时,如果对 watch 的 key 进行修改,就会记录当前 key 的 “版本号”(版本号可以理解成一个整数,每次修改都会使版本变大,服务器来维护每个 key 的版本号情况)
        • 在真正提交事务时,如果发现当前服务器上的 key 的版本号已经超过了事务开始时的版本号,就会让事务执行失败(事务中的所有操作都不执行)
        • watch 本质上是给 exec 加一个判定条件
      • exec 在执行上述事务中的命令时,此处就会做出判定。对比版本发现客户端的 key 的版本号是 0,服务器上的版本号是 1,版本不一致,说明有其他客户端在事务中间修改了 key,说明事务被取消了,于是执行 set 命令时就没有真正执行
      • watch 的实现原理
        • watch 的实现类似于一个 “乐观锁”
          • 乐观锁(成本低):加锁之前就有一个心理预期,预期接下来锁冲突的概率比较低
          • 悲观锁(成本高):加锁之前就有一个心理预期,预期接下来锁冲突(两个线程针对同一个锁加锁,一个能加锁成功,另一个就得阻塞等待)的概率比较高
        • 锁冲突概率高和冲突概率低,意味着接下来要做的工作是不一样的
        • C++ Linux 中涉及到的锁 mutex / std::mutex 都是悲观锁,Java synchronized 则是可以在悲观和乐观之间自适应
    • UNWATCH
      • 取消对 key 的监控,相当于 WATCH 的逆操作

九、主从复制(重点理解流程和原理)

  • 在分布式系统中为了解决单点问题(某个服务器程序只有一个节点(只用一个物理服务器来部署这个服务器程序)可用性不高:如果这个机器挂了意味着服务中断了;支持的并发量比较有限),通常会把数据复制多个副本部署到其它服务器,满足故障恢复和负载均衡等需求。在分布式系统中,往往希望有多个服务器来部署 Redis 服务,从而构成一个 Redis 集群,此时就可以让这个集群给整个分布式系统中的其他服务提供更稳定、更高效的数据存储功能
  • 存在几种 Redis 的部署方式
    • 主从模式
      • 从节点上的数据要随着主节点变化,和主节点保持一致。从节点是主节点的副本,在该模式中,从节点上的数据不允许修改,只能读取数据。后续如果有客户端来读取数据,就可以从所有节点中随机挑选一个节点,给这个客户端提供读取数据的服务
      • 引入更多的计算资源,那么能够支撑的并发量也就大幅提高了。如果是挂掉了某个从节点是没有什么影响的,此时继续从主节点(如果是主节点挂掉了,那还是有一定影响的,因为从节点只能读数据,如果需要写数据就没得写了)或者其它的从节点读取数据,得到的效果是完全相同的
      • 主从模式主要是针对 “读操作” 进行并发量和可用性的提高,而写操作无论是可用性还是并发都是非常依赖主节点的,但主节点又不能设置多个。在实际业务场景中,读操作往往是比写操作更频繁的
      • 主从结构是分布式系统中比较经典的一种结构。不仅是 Redis 支持,MySQL 等其它的常用组件也是支持的
      • 主从复制的特点
        • Redis 通过复制功能实现主节点的多个副本
        • 主节点用来写,从节点用来读,这样做可以降低主节点的访问压力
        • 复制支持多种拓扑结构,可以在适当的场景选择合适的拓扑结构
        • 复制分为全量复制,部分复制和实时复制
        • 主从节点之间通过心跳机制保证主从节点通信正常和数据⼀致性
    • 主从 + 哨兵模式
    • 集群模式
  • Redis 提供了复制的功能,实现了相同数据的多个 Redis 副本。复制功能是高可用 Redis 的基础,哨兵和集群都是在复制的基础上构建的
  • 配置
    • 建立复制
      • 如何在一个云服务器上实现分布式呢?
        • 可以在一个云服务器主机上运行多个 redis-server 进程,此处需要保证多个 redis-server 的端口是不相同的
      • 如何去指定 redis-server 的端口呢?
        • 在启动程序时,通过命令行来指定端口号,--port 选项
        • 直接在配置文件中来设定端口
      • 将 redis.conf 配置文件复制两份:slave1.conf 和 slave2.conf(准备一个主节点和两个从节点)
        • 以 6379 为主节点,6380 和 6381 为从节点
        • 修改配置主要是修改从机的配置,主机配置不变
        • 通过命令行启动上述两个 Redis 实例作为从 Redis,并且通过 ps aux | grep redis 确保两个 Redis 均已正确启动
        • 当前这几个节点并没有构成主从结构,而是各自为政。要想成为主从结构,还需要进一步的进行配置
        • 通过 redis-cli 可以连接主 Redis 实例,通过 redis-cli -p 6380 连接从 Redis,观察复制关系
      • 参与复制的 Redis 实例划分为主节点和从节点。每个从节点只能有⼀个主节点,而一个主节点可以同时具有多个从节点。复制的数据流是单向的,只能由主节点到从节点。配置复制的方式有 3 种
        • 在配置文件中加入 slaveof {masterHost} {masterPort} 随 Redis 启动生效
        • 在 redis-server 启动命令时加入 --slaveof {masterHost} {masterPort} 生效
        • 直接使用 redis 命令:slaveof {masterHost} {masterPort} 生效
      • 通过 kill -9 进程的方式来停止 redis-server 是和通过直接运行 redis-server 命令的方式搭配使用的,而如果是使用 service redis-server start 这种方式启动的,就必须使用 service redis-server stop 来进行停止,此时如果使用 kill -9 的方式停止,那么在 kill 掉之后,这个 redis-server 进程还能自动启动
      • 服务器就是要稳定性和高可用,但服务器上的某些程序也难以避免出现挂了的情况。如果服务进程挂了后能自动重启进程,那么对于整体的服务不会产生严重影响
      • 复制已经工作后,针对主节点 6379 这边数据产生的任何修改,从节点都能立即感知到并同步,就是那些 tcp 连接产生的效果。其次,从节点不能写入数据
      • Redis 主从节点复制过程
        • 可以通过 info replication 命令查看复制相关状态
          • 主节点 6379 复制状态信息
            • 主节点上会源源不断地收到 “修改数据” 请求,从节点就需要从主节点这里同步这些修改请求。从节点和主节点之间的数据同步不是瞬间完成的,offset 就相当于是从节点和主节点之间同步数据的进度,lag 表示延迟情况
          • 从节点 6380 复制状态信息
            • repl_backlog:挤压缓冲区,支持部分同步机制的实现
    • 断开复制
      • slaveof 命令不仅可以建立复制,还可以在从节点执行 slaveof no one 来断开与主节点复制关系
      • 断开复制主要流程
        • 断开与主节点复制关系
        • 从节点晋升为主节点
      • 从节点断开复制后,它就不再从属于其它节点了,但并不会抛弃原有数据,后续主节点如果针对数据做出修改,从节点无法再自动获取主节点上的数据变化
      • 通过 slaveof 命令还可以实现切主操作,将当前从节点的数据源切换到另⼀个主节点。执行 slaveof {newMasterIp} {newMasterPort} 命令即可
        • 切主操作主要流程
          • 断开与旧主节点复制关系
          • 与新主节点建立复制关系
          • 删除从节点当前所有数据
          • 从新主节点行复制操作
      •  
        • 此时的 6381 只是看起来像是个主节点,但实际上并不是,它仍然是个从节点,只是作为 6380 同步数据的来源,自身仍然是不能修改数据的
        • 前面是通过 salveof 修改了主从结构,这个修改是临时性的。如果重新启动 Redis 服务器,仍然会按照最初在配置文件中设置的内容来建立主从关系
    • 安全性
      • 对于数据比较重要的节点,主节点会通过设置 requirepass 参数进行密码验证,这时所有的客户端访问必须使用 auth 命令实行校验。从节点与主节点的复制连接是通过⼀个特殊标识的客户端来完成,因此需要配置从节点的 masterauth 参数与主节点密码保持一致,这样从节点才可以正确地连接到主节点并发起复制流程
    • 只读
      • 默认情况下,从节点使用 slave-read-only=yes 配置为只读模式。由于复制只能从主节点到从节点,对于从节点的任何修改主节点都无法感知,修改从节点会造成主从数据不⼀致,所以建议线上不要修改从节点的只读模式
    • 传输延迟
      • 主节点和从节点之间通过网络来传输(TCP),TCP 内部支持 nagle 算法(默认开启),开启后会增加 tcp 的传输延迟,节省网络带宽;关闭就会减少 tcp 的传输延迟,增加网络带宽,从节点能够更快速的和主节点进行同步,其目的和 tcp 的捎带应答是一样的,针对小的 tcp 数据报进行合并,减少包的个数。主从节点一般部署在不同机器上,复制时的网络延迟就成为需要考虑的问题
        • 当关闭时,主节点产生的命令数据无论大小都会及时地发送给从节点,这样主从之间延迟会变小,但增加了网络带宽的消耗,适用于主从之间的网络环境良好的场景,如同机房部署
        • 当开启时,主节点会合并较小的 TCP 数据包从而节省带宽。默认发送时间间隔取决于 Linux 的内核,一般默认为 40 毫秒。这种配置节省了带宽但增大主从之间的延迟,适用于主从网络环境复杂的场景,如跨机房部署
  • 拓扑
    • Redis 的复制拓扑结构可以支持单层或多层复制关系,根据拓扑复杂性可以分为 3 种
      • 一主一从结构
        • 一主一从结构是最简单的复制拓扑结构,用于主节点出现宕机时从节点提供故障转移支持
        • 当应用写命令并发量较高且需要持久化时,此时也是会给主节点造成一定压力,那么可以只在从节点上开启 AOF,这样既可以保证数据安全性,同时也避免了持久化对主节点的性能干扰。但这种设定方式有一个严重缺陷:当主节点关闭持久化功能时,如果主节点宕机要避免自动重启操作,否则就会丢失数据,进一步的主从同步也会把从节点的数据也给删了
          • 改进办法:当主节点挂了后,需要让主节点从从节点这里获取 AOF 文件,再重新启动
      • 一主多从结构
        • 在实际开发中,读请求往往远超过写请求,所以一般会选择一主多从结构
        • 一主多从结构(星形结构)使得应用端可以利用多个从节点实现读写分离
        • 主节点上的数据发生改变时,就会把改变的数据同时同步给所有的从节点
          • 对于读比重较大的场景,可以把读命令负载均衡到不同的从节点上来分担压力。同时一些耗时的读命令可以指定一台专门的从节点执行,避免破坏整体的稳定性
          • 对于写并发量较高的场景,多个从节点会导致主节点写命令的多次发送从而加重主节点的负载
      • 树形主从结构
        • 树形主从结构(分层结构)使得从节点不仅可以复制主节点数据,同时可以作为其它从节点的主节点继续向下层复制。通过引入复制中间层,可以有效降低主节点按负载和需要传送给从节点的数据量
        •  
          • 数据写如节点 A 之后会同步给 B 和 C 节点,B 节点进一步把数据同步给 D 和 E 节点。当主节点需要挂载等多个从节点时,为了避免对主节点的性能干扰,可以采用这种拓扑结构。此时,主节点就不需要很高的网卡带宽了,但一旦数据进行修改,同步的延时比之前的结构更长
  • 原理
    • 复制过程
      • 主从节点建立复制过程大致分为 6 个过程
        • 保存主节点(master)的信息
          • 开始配置主从同步关系之后,从节点只保存主节点的地址信息,此时建立复制流程还没有开始,在从节点 6380 执行 info replication
          • 主节点的 ip 和 port 被保存下来,但是主节点的连接状态是下线状态
        • 从节点内部通过每秒运行的定时任务维护复制相关逻辑
          • 当定时任务发现存在新的主节点后,会尝试与主节点建立基于 TCP 的网络连接(三次握手,是为了验证(系统层面)通信双方是否能够正确读写数据)。如果从节点无法建立连接,定时任务会无限重试直到连接成功或者用户停止主从复制
        • 发送 ping 命令
          • 连接建立成功之后,从节点通过 ping 命令确认主节点在应用层上是否能够正常工作。如果 ping 命令的结果 pong 回复超时,从节点会断开 TCP 连接,等待定时任务下次重新建立连接
        • 权限验证
          • 如果主节点设置了 requirepass 参数,则需要密码验证,从节点通过配置 masterauth 参数来设置密码。如果验证失败,则从节点的复制将会停止
        • 同步数据集
          • 对于首次建立复制的场景,主节点会把当前持有的所有数据全部发送给从节点,这步操作基本是耗时最长的,所以又划分称两种情况:全量同步和部分同步
        • 命令持续复制
          • 当从节点复制了主节点的所有数据后,针对之后的修改命令,主节点会持续把命令发送给从节点,从节点执行修改命令,保证主从数据的一致性
    • 数据同步 psync
      • Redis 使用 psync 命令(不需要手动执行,Redis 服务器会在建立好主从同步关系之后自动执行。从节点负责执行 psync,从节点从主节点这边拉取数据)完成主从数据同步,同步过程分为:全量复制和部分复制
        • 全量复制:一般用于初次复制场景,Redis 早期支持的复制功能只有全量复制,它会把主节点全部数据一次性发送给从节点,当数据量较大时,会对主从节点和网络造成很大的开销
        • 部分复制:用于处理在主从复制中因网络闪断等原因造成的数据丢失场景,当从节点再次连上主节点后,如果条件允许,主节点会补发数据给从节点。因为补发的数据远小于全量数据,可以有效避免全量复制的过高开销
      • PSYNC 的语法格式
        • PSYNC replicationid offset
        • 如果 replicationid 设为 ? 并且 offset 设为 -1,就是在尝试进行全量复制
        • 如果 replicationid offset 设置为具体数值,就是在尝试进行部分复制
      • replicationid / replid(复制 id)
        • 主节点的复制 id。主节点重新启动或者从节点晋级成主节点都会生成⼀个 replicationid(同一个节点每次重启生成的 replicationid 都是不同的)
        • 从节点在和主节点建立复制关系之后,就会获取到主节点的 replicationid
          • 通过 info replication 即可看到 replicationid
        • replication id VS run id
          • 在一个 Redis 服务器上,replication id 和 run id 都是存在的
            • 每个节点的 run id 都不相同,而具有主从关系的节点的 replid 是相同的
          • replid + offset 共同标识了一个数据集合,这个结构体包含了 Redis 服务器各自重要的数据,标识一次 Redis 的 “运行”
            • runid 主要是用在支撑实现 Redis 哨兵这个功能的,和主从复制没什么关系
        • master_replid 和 master_replid2
          • 每个节点需要记录两组 master_replid
          • 假设有一个主节点 A 和一个从节点 B,从节点 B 获取到 A 的 replid。如果 A 和 B 在通信过程中出现了一些网络抖动,那么 B 可能会以为 A 挂了,B 自己就会成为主节点,于是 B 给自己生成一个 master_replid,此时 B 也会记得旧的 replid,也就是会使用 master_replid2 来保存之前 A 的 master_replid。后续如果网络恢复稳定了,B 就可以根据 master_replid2 找回之前的主节点(该过程要么手动干预,要么通过哨兵机制可以自动完成这个过程)。后续如果网络没有恢复,B 就按照新的 master_replid 自成一派,继续处理后续的数据
      • offset(偏移量)
        • 参与复制的主从节点都会维护自身复制偏移量。主节点在处理完写入命令后,会把命令的字节长度做累加记录。从节点在接受到主节点发送的命令后,也会累加记录自身的偏移量
        • 从节点每秒钟上报自身的复制偏移量给主节点,因此主节点也会保存从节点的复制偏移量
        • 通过对比主从节点的复制偏移量,可以判断主从节点数据是否一致
          • 复制偏移量维护

            • replid + offset 共同标识了⼀个 “数据集”。如果两个节点的 replid 和 offset 都相同,则这两个节点上持有的数据一定相同
          • psync 运行流程

            • 从节点发送 psync 命令给主节点,replid 和 offset 的默认值分别是 ? 和 -1
            • 并不是说从节点索要哪一部分,主节点就一定给哪一部分,而是主节点根据 psync 参数和自身数据情况决定响应结果
              • 如果回复 +FULLRESYNC replid offset,则从节点需要进行全量复制流程
              • 如果回复 +CONTINEU,从节点进行部分复制流程
              • 如果回复 -ERR,说明 Redis 主节点版本过低,不支持 psync 命令(可以使用 sync 代替),从节点可以使用 sync 命令进行全量复制
                • psync 一般不需要手动执行,Redis 会在主从复制模式下自动调用执行
                • sync 会阻塞 redis server 处理其他请求,psync 则不会
    • 全量复制
      • 全量复制是 Redis 最早支持的复制方式,也是主从第一次建立复制时必须经历的阶段
      • 进行全量复制的时间点
        • 首次和主节点进行数据同步
        • 主节点不方便进行部分复制时
      • 全量复制的运行流程
        •  
          • 从节点发送 psync 命令给主节点进行数据同步,由于是第一次进行复制,从节点没有主节点的运行 ID 和复制偏移量,所以发送 psync ? -1
          • 主节点根据命令解析出要进行全量复制,回复 +FULLRESYNC 响应
          • 从节点接收主节点的运行信息进行保存
          • 主节点执行 bgsave 进行 RDB 文件的持久化(不能使用已有的 RDB 文件,而是要重新生成,因为已有的 RDB 文件可能和当前最新数据存在较大差异)
          • 主节点发送 RDB 文件给从节点,从节点保存 RDB 数据到本地硬盘(RDB 文件更节省空间)
          • 主节点将从生成 RDB 到接收完成期间执行的写命令写入缓冲区中,等从节点保存完 RDB 文件后,主节点再将缓冲区内的数据补发给从节点,补发的数据仍然按照 RDB 的二进制格式追加写入到收到的 RDB 文件中,保持主从一致性
          • 从节点清空自身原有旧数据,加载 RDB 文件得到与主节点一致的数据
          • 如果从节点加载 RDB 完成后,开启了 AOF 持久化功能,那么在前面加载数据的过程中,从节点就会产生很多 AOF 日志。由于当前收到大批量数据,此时产生的 AOF 日志可能会存在一定的冗余信息,因此针对 AOF 日志进行 bgrewrite 操作,得到最近的 AOF 文件也是必要的过程
      • 通过分析全量复制的所有流程,会发现全量复制是一件高成本的操作:主节点 bgsave 的时间,RDB 在网络传输的时间,从节点清空旧数据的时间,从节点加载 RDB 的时间等,所以应该尽可能避免对已经有大量数据集的 Redis 进行全量复制
      • 有磁盘复制 VS 无磁盘复制(diskless)
        • 进行全量复制需要主节点生成 RDB 文件到主节点的磁盘中,再把磁盘上的 RDB 文件发送给从节点
        • 主节点在执行 RDB 生成流程时,不会生成 RDB 文件到磁盘中了,而是直接把生成的 RDB 数据通过网络发送给从节点,节省了⼀系列的写硬盘和读硬盘的操作开销
        • 从节点之前也是先把收到的 RDB 数据写入到硬盘中,再进行加载,现在也可以省略这个过程,直接把收到的数据进行加载
        • 即使引入了无硬盘模式,整个操作仍然是比较重量、耗时的,网络传输(大规模数据全量复制)的过程是没有办法省的。相比于网络传输来说,读写硬盘的开销是很小的
    • 部分复制
      • 需要部分复制的时刻
        • 从节点之前已经从主节点上复制过数据了。因为网络抖动或者从节点重启,从节点需要重新从主节点这边同步数据,此时看看是否能够只同步一小部分,因为大部分数据都是一致的
      • 部分复制主要是 Redis 针对全量复制的过高开销做出的一种优化措施,使用 psync replicationId offset 命令实现。当从节点正在复制主节点时,如果出现网络闪断或者命令丢失等异常情况时,从节点会向主节点要求补发丢失的命令数据,如果主节点的复制积压缓冲区存在数据则直接发送给从节点,就可以保持主从节点复制的一致性。补发的这部分数据一般远远小于全量数据,所以开销很小
      • 部分复制过程
        •  
          • 当主从节点之间出现网络中断时,如果超过 repl-timeout 时间,主节点会认为从节点故障并终端复制连接
          • 主从连接中断期间主节点依然响应命令,但这些复制命令都因网络中断无法及时发送给从节点,所以暂时将这些命令滞留在复制积压缓冲区(一个内存中简单的队列,会记录最近一段时间修改的数据,因为总量有限,随着时间推移就会把之前的旧数据逐渐删除)中
          • 当主从节点网络恢复后,从节点再次连上主节点
          • 从节点将之前保存的 replicationId 和 复制偏移量(offset,主节点看这个进度来判断它是否在当前的积压缓冲区内,如果是则可以直接进行部分复制)作为 psync 的参数发送给主节点,请求进行部分复制
          • 主节点接到 psync 请求后,进行必要的验证,随后根据 offset 去复制积压缓冲区查找合适的数据,并响应 +CONTINUE 给从节点
          • 主节点将需要从节点同步的数据发送给从节点,最终完成一致性
      • 复制积压缓冲区
        • 复制积压缓冲区是保存在主节点上的一个固定长度的队列,当主节点有连接的从节点时被创建,这时主节点响应写命令时,不但会把命令发送给从节点,还会写入复制积压缓冲区

        • 由于缓冲区本质上是定长队列,所以能实现保存最近已复制数据的功能,用于部分复制和复制命令丢失的数据补救。复制缓冲区相关统计信息可以通过主节点的 info replication 查看
        • 如果当前从节点需要的数据已经超出了主节点的积压缓冲区范围,则无法进行部分复制,只能进行全量复制
    • 实时复制
      • 主从节点在建立复制连接后,主节点会把自己收到的修改操作,通过 tcp 长连接的方式,源源不断的传输给从节点,从节点会根据这些请求来同时修改自身数据,从而保持和主节点数据的一致性。在进行实时复制时需要保证连接处于可用状态,所以这样的长连接需要通过心跳包的方式来维护连接状态(这里的心跳是指应用层自己实现的心跳,而不是 TCP 自带的心跳)
        • 主从节点彼此都有心跳检测机制,各自模拟成对方的客户端进行通信
        • 主节点默认每隔 10 秒对从节点发送 ping 命令,从节点收到就返回 pong,以此来判断从节点的存活性和连接状态
        • 从节点默认每隔 1 秒向主节点发送一个 replconf ack {offset} 命令,给主节点上报自身当前的复制偏移量
      • 如果主节点发现从节点通信延迟超过 repl-timeout 配置的值,则判定从节点下线,断开复制客户端连接。从节点恢复连接后,心跳机制继续进行
  • 关于从节点何时晋升成主节点的问题
    • 从节点和主节点之间断开连接有 2 种情况
      • 从节点主动和主节点断开连接:slaveof no one(此时,从节点就能够晋升成主节点),意味着要主动修改 Redis 的组成结构
      • 主节点挂了(此时从节点不会晋升成主节点的,必须通过人工干预的方式来恢复主节点)。这种是脱离掌控的,是一个高可用的典型问题
  • 关于 redis 主节点无法重启的问题
    • 从节点是通过手动启动的方式运行的,此时在 root 用户下启动 redis 服务器,于是生成的 aof 文件也就是 root 用户的文件
    • 通过 service redis-server start 启动的 redis 服务器是通过一个 redis 用户来启动的,主要是担心通过 root 启动 redis 权限太高,一旦 redis 被黑客攻破,后果比较严重。所以,redis server 需要安装可读可写的方式打开这个 aof 文件,而这个文件对于 root 之外的用户只有读权限
    • 解决方案:将多个 Redis 服务器生成的文件给区分开,也就是把这多个 Redis 的工作目录给区分开:修改配置文件中的 dir 选项
      • 停止之前的 Redis 服务器
      • 删除之前工作目录下已经生成的 aof 文件或者通过 chown 命令修改 aof 文件所属的用户
      • 给从节点创建出新的目录,作为从节点的工作目录,并且修改从节点的配置文件,设定新的目录为工作目录
      • 启动 redis 服务器,从节点有了自己的 rdb 文件和 aof 文件

十、哨兵 Sentinel(重点理解流程和原理)

  • Redis 的主从复制模式下,一旦主节点由于故障不能提供服务,需要人工进行主从切换,同时大量的客户端需要被通知切换到新的主节点上,对于上了一定规模的应用来说,这种方案是无法接受的,于是 Sentinel(哨兵)来解决这个问题
  • 基本概念
    • Redis Sentinel 相关名词解释
    • Redis Sentinel 是 Redis 的高可用实现方案,在实际的生产环境中,对提高整个系统的高可用是非常有帮助的
    • 主从复制的问题
      • Redis 的主从复制模式可以将主节点的数据改变同步给从节点,这样从节点就可以起到 2 个作用
        • 作为主节点的⼀个备份,⼀旦主节点出了故障不可达的情况,从节点可以作为后备顶上来,并保证数据尽量不丢失(主从复制表现为最终一致性)
        • 从节点可以分担主节点上的读压力,让主节点只承担写请求的处理,将所有的读请求负载均衡到各个从节点上
      • 但主从复制模式并不是万能的,它同样遗留下几个问题
        • 主节点发生故障时,进行主备切换的过程是复杂的,需要完全的人工参与,导致故障恢复时间无法保障
          • 高可用问题,即 Redis 哨兵主要解决的问题
          • 人工恢复主节点故障
            • Redis 主节点故障后需要进行的操作
              • 实际开发过程中,对于服务器后端开发,监控程序是非常重要的。服务器要求要有比较高的可用性,而服务器长期运行总会出现一些意外,但也没法全靠人工来盯着这些服务器运行。此时就可以写一个程序,用程序来盯着服务器的运行状态(监控程序,往往还需要搭配报警程序来发现服务器的运行出现状态异常)
              • 从所有节点中选择⼀个(此处选择了 slave 1)执行 slaveof no one,使其作为新的主节点

              • 让剩余从节点(此处为 slave 2)执行 slaveof {newMasterIp} {newMasterPort},连上新的主节点,从新的主节点开始数据同步

              • 告知客户端(修改客户端配置),让客户端能够连接新的主节点,用来完成修改数据的操作,需要更新应用方连接的主节点信息到 {newMasterIp} {newMasterPort}

              • 如果原来挂了的主节点恢复,执行 slaveof {newMasterIp} {newMasterPort},让其成为⼀个从节点,挂到这组机器中

              • 上述过程基本需要人工介入,无法被认为架构是高可用的,而这就是 Redis Sentinel 所要做的
        • 主节点可以将读压力分散出去,但写压力 / 存储压力是无法被分担的,还是受到单机的限制
          • 存储分布式的问题,留给 Redis 集群去解决
    • 哨兵自动恢复主节点故障
      • 当主节点出现故障时,Redis Sentinel 能自动完成故障发现和故障转移,并通知应用方,从而实现真正的高可用
      • Redis Sentinel 是一个分布式架构,其中包含若干个 Sentinel 节点和 Redis 数据节点,每个 Sentinel 节点会对数据节点和其余 Sentinel 节点进行监控,当它发现节点不可达时,会对节点做下线表示。如果下线的是主节点,它还会和其他的 Sentinel 节点进行协商,当大多数 Sentinel 节点对主节点不可达这个结论达成共识,它们会在内部选举出一个领导节点来完成自动故障转移的工作,同时将这个变化实时通知给 Redis 应用方(整个过程是完全自动的,不需要人工介入)
        • 这里的分布式架构是指:Redis 数据节点、Sentinel 节点集合、客户端分布在多个物理节点上,不要与 Redis Cluster 分布式混淆
      • Redis Sentinel 架构
        • Redis Sentinel 相比于主从复制模式多了若干单独的(建议保持奇数,最少应该是 3 个)Sentinel 节点用于实现监控数据节点,哨兵节点会定期监控(这些进程之间会建立 tcp 长连接,定期发送心跳包)所有节点(包含数据节点和其他哨兵节点)
        • 针对主节点故障的情况,故障转移流程
          • 主节点故障,从节点同步连接中断,主从复制停止
          • 哨兵节点通过定期监控发现主节点出现故障。哨兵节点与其他哨兵节点进行协商,达成多数认同主节点故障的共识。这步主要是防止:出故障的不是主节点,而是发现故障的哨兵节点,该情况经常发生于哨兵节点的网络被孤立的场景下
          • 哨兵节点之间使用 Raft 算法选举出⼀个 leader(领导角色),由该节点负责后续的故障转移工作
          • 哨兵领导者开始执行故障转移:leader 从节点中选择⼀个作为新的主节点,挑选出新的主节点之后,哨兵节点就会自动控制这个被选中的节点,执行 slaveof no one,并且控制其他从节点,修改 slaveof 到新的主节点上,哨兵节点会自动通知客户端程序,告知新的主节点是谁,并且后续客户端再进行写操作,就会针对新的节点进行操作

            • 可以看出 Redis Sentinel 具有以下几个功能
              • 监控:Sentinel 节点会定期检测 Redis 数据节点、其余哨兵节点是否可达
              • 故障转移:实现从节点晋升为主节点,并维护后续正确的主从关系
              • 通知:Sentinel 节点会将故障转移的结果通知给应用方
          • 只有一个 Redis 哨兵节点也是可以的,但万一这个哨兵节点挂了,后续 Redis 节点也挂了,就无法进行自动回复的过程,而且出现误判的概率也比较高,毕竟网络传输数据容易出现抖动、延迟或者丢包等情况,只有一个哨兵节点出现上述情况影响较大
          • 基本原则:在分布式系统中,应该避免使用 单点
  • 重新选举
    • 哨兵存在的意义:能够在 Redis 主从结构出现问题时,比如主节点挂了,哨兵节点自动重新选出一个主节点来代替之前挂了的节点,保证整个 Redis 仍然是可用状态
    • redis-master 宕机之后
      • 手动把 redis-master 干掉
      • 当主节点挂了后,哨兵节点就开始工作了
        • 可以看到哨兵发现了主节点 sdown,进一步的由于主节点宕机得票达到 3/2,达到法定得票,于是 master 被判定为 odown
          • 主观下线(Subjectively Down,SDown):哨兵感知到主节点没心跳了,判定为主观下线
          • 客观下线(Objectively Down,ODown):多个哨兵达成一致意见,才能认为 master 确实下线了
        • 接下来,哨兵们挑选出了⼀个新的 master
    • redis-master 重启之后
      • 手动把 redis-master 启动起来
      • 观察哨兵日志,可以看到刚才新启动的 redis-master 被当成了 slave,使用 redis-cli 也可以进⼀步的验证这⼀点
    • Redis 主节点如果宕机,哨兵会把其中一个从节点提拔成主节点
    • 当之前的 Redis 主节点重启之后,这个主节点被加入到哨兵的监控中,但只会被作为从节点使用
  • 选举原理
    • 假定当前环境为三个哨兵(sentenal1、sentenal2、sentenal3),一个主节点(redis-master),两个从节点(redis-slave1、redis-slave2)
    • 当主节点出现故障,就会触发重新一系列过程

      • 主观下线
        • 哨兵节点通过心跳包来判断 Redis 服务器是否正常工作。当 redis-master 宕机,此时 redis-master 和三个哨兵之间的心跳包就没有了。站在三个哨兵的角度来看,redis-master 出现严重故障,此时还不能排除网络波动的影响,因此三个哨兵均会把 redis-master 判定为主观这个 Redis 节点下线
      • 客观下线
        • 哨兵 sentenal1、sentenal2、sentenal3 均会对主节点故障这件事情进行投票。当故障得票数 >= 配置的法定票数之后,客观判定这个 Redis 节点下线
        • 有可能出现非常严重的网络波动而导致所有的哨兵都联系不上 Redis 主节点,从而被误判为挂了
          • 如果出现这个情况,那么用户的客户端也连不上 Redis 主节点了,此时这个主节点基本无法正常工作了
          • 挂了不一定是进程崩了,只要是无法访问,都可以被视为是挂了
      • 选举出哨兵的 leader
        • 接下来需要哨兵从剩余的 slave 中挑选出一个新的 master,这个工作不需要所有的哨兵都参与,只需要选出个代表,由 leader 负责进行 slave 升级到 master 的提拔过程,这个选举的过程涉及到 Raft 算法
        • 假定一共三个哨兵节点:S1、S2、S3
          • 每个哨兵节点都给其他所有哨兵节点,发起⼀个拉票请求
          • 收到拉票请求的节点,会回复一个投票响应。响应的结果有 2 种可能:投 / 不投。比如 S1 给 S2 发了个投票请求,S2 就会给 S1 返回投票响应。到底 S2 是否要投 S1,取决于 S2 是否给别⼈投过票了,因为每个哨兵只有一票。如果 S2 没有给别⼈投过票,且 S1 是第一个向 S2 拉票的,那么 S2 就会投 S1,否则就不投
          • ⼀轮投票完成之后, 发现得票超过半数的节点,自动成为 leader。如果出现平票的情况,就重新再投一次,这也是为什么建议哨兵节点设置成奇数个的原因
          • leader 节点负责挑选一个 slave 成为新的 master,当其他的 sentenal 发现新的 master 出现了,说明选举结束了
        • Raft 算法的核心就是 “先下手为强”。谁率先发出了拉票请求,谁就有更大的概率成为 leader。这里的决定因素成了网络延时,网络延时本身就带有一定随机性
        • 具体选出的哪个节点是 leader 并不重要,能选出一个节点即可
      • leader 挑选出合适的 slave 成为新的 master
        • 挑选规则
          • 比较优先级。优先级高(数值小)的上位,优先级是配置文件中的配置项(slave-priority 或者 replica-priority)
          • 比较 replication offset 谁复制的数据多,高的上位
          • 比较 run id,谁的 id 小,谁上位
        • 当某个 slave 节点被指定为 master 之后,leader 指定该节点执行 slave no one,成为 master,leader 指定剩余的 slave 节点,都依附于这个新 master
  • 上述过程都是 “无人值守”,Redis 自动完成的,这样做解决了主节点宕机之后需要人工干预的问题,提高了系统的稳定性和可用性
  • 注意事项
    • 哨兵节点不能只有一个,否则哨兵节点挂了也会影响系统可用性
    • 哨兵节点最好是奇数个,方便选举 leader,得票更容易超过半数
    • 哨兵节点不负责存储数据,仍然是 redis 主从节点负责存储
    • 哨兵 + 主从复制解决的问题是提高可用性,不能解决数据在极端情况下写丢失的问题
      • 哨兵 + 主从复制不能提高数据的存储容量,当需要存的数据接近或者超过机器的物理内存,这样的结构就难以胜任了
        • 为了能存储更多的数据,就引入了集群

十一、集群(重点理解流程和原理)

1、基本原理

  • 哨兵模式提高了系统的可用性,但真正用来存储数据的还是 master 和 slave 节点,所有数据都需要存储在单个 master 和 slave 节点中。如果数据量很大,接近超出了 master / slave 所在机器的物理内存,就可能出现严重问题
  • 如何获取更大的空间?
    • 加机器即可,所谓大数据的核心就是一台机器搞不定,用多台机器来解决
    • Redis 的集群就是在上述思路之下,引入多组 Master / Slave,每⼀组 Master / Slave 存储数据全集的一部分,从而构成一个更大的整体,称为 Redis 集群(Cluster)
      • 广义的 “集群”:只要是多个机器构成了分布式系统,都可以称为是一个集群
        • 主从结构、哨兵模式,也可以称为广义的集群
      • 狭义的 “集群”:Redis 提供的集群模式,在这个集群模式之下,主要是解决存储空间不足的问题(扩展存储空间)
  • 假定整个数据全集是 1 TB,引入三组 Master / Slave 来存储,那么每⼀组机器只需要存储整个数据全集的 1/3 即可

    • Master1 和 Slave11 和 Slave12 保存的是同样的数据,占总数据的 1/3;Master2 和 Slave21 和 Slave22 保存的是同样的数据,占总数据的 1/3;Master3 和 Slave31 和 Slave32 保存的是同样的数据,占总数据的 1/3
    • 这三组机器存储的数据都是不同的,每个 Slave 都是对应 Master 的备份(当 Master 挂了,对应的 Slave 会补位成 Master)。每个红框部分都可以称为是一个分片(Sharding),如果全量数据进一步增加,只要再增加更多的分片即可
  • 数据量多了,使用硬盘来保存可以吗?硬盘只是存储空间大了,但是访问速度是比内存慢很多的。但实际上还存在很多应用场景:既希望存储较多的数据,又希望有非常高的读写速度
    • 比如搜索引擎

2、数据分片算法

  • Redis cluster 的核心思路是用多组机器来存数据的每个部分
  • 给定一个数据(一个具体的 key),那么这个数据应该存储在哪个分片上?读取时又应该去哪个分片读取呢?
    • 哈希求余
      • 借鉴哈希表的基本思想:借助 hash 函数把一个 key 映射到整数,再针对数组的长度进行求余,就可以得到一个数组下标
      • 设有 N 个分片,使用 [0, N-1] 这样序号进行编号。针对某个给定的 key,先计算 hash 值,再将得到的结果 %N,得到的结果即为分片编号。例如 N 为 3,给定的 key 为 "hello",对 "hello" 计算 hash 值
        • 特点:md5 计算结果是定长的,其计算结果是分散的且不可逆的

          • 实际工作中涉及到的系统,计算 hash 的方式不一定是 md5,但思想是一致的
      • 如果要取某个 key 的记录,也是针对 key 计算 hash , 再对 N 求余,就可以找到对应的分片编号了
      • 优点:简单高效,数据分配均匀
      • 缺点:一旦需要进行扩容,N 发生了改变,那么原有的映射规则被破坏,就需要让节点之间的数据相互传输,重新排列,以满足新的映射规则,此时需要搬运的数据量比较多,开销较大
      • N 为 3 时,[100, 120] 这 21 个 hash 值的分布
        • (此处假定计算出的 hash 值是一个简单的整数,方便肉眼观察)当引入一个新的分片,N 从 3 => 4 时,大量的 key 需要重新映射

    • 一致性哈希算法
      • 交替出现导致搬运成本高,为了降低上述的搬运开销,能够更高效扩容,业界提出了 “一致性哈希算法”(key 映射到分片序号的过程不再是简单求余了)
        • 把 0 -> 2^32-1 这个数据空间映射到一个圆环上,数据按照顺时针方向增长

        • 假设当前存在三个分片,就把分片放到圆环的某个位置上

        • 假定有一个 key,计算得到 hash 值 H,从 H 所在位置顺时针往下找,找到的第一个分片,即为该 key 所从属的分片

        • 相当于 N 个分片的位置把整个圆环分成了 N 个管辖区间,Key 的 hash 值落在某个区间内,就归对应区间管理
      • 如果扩容一个分片,则原有分片在环上的位置不动,只要在环上新安排一个分片位置即可

        • 这里只需要把 0 号分片上的部分数据搬运给 3 号分片即可,而 1 号和 2 号分片管理的区间是不变的
      • 优点:大大降低了扩容时数据搬运的规模,提高了扩容操作的效率
      • 缺点:数据分配不均匀,出现数据倾斜
    • 哈希槽分区算法(Redis 使用)
      • 为了解决搬运成本高和数据分配不均匀的问题,Redis cluster 引入了哈希槽(hash slots)算法
        • hash_slot = crc16(key) % 16384
          • crc16 也是一种 hash 算法
          • 16384(16*1024,即 2^14=16k),相当于是把整个哈希值映射到 16384 个槽位上,即 [0, 16383],再把这些槽位比较均匀的分配给每个分片,每个分片的节点都需要记录自己持有哪些分片
        • 假设当前有三个分片,一种可能的分配方式
          • 0 号分片:[0, 5461],共 5462 个槽位
          • 1 号分片:[5462, 10923],共 5462 个槽位
          • 2 号分片:[10924, 16383],共 5460 个槽位
        • 这里的分片规则很灵活,虽然每个分片持有的槽位也不一定连续,但此时这三个分片上的数据比较均匀了。每个分片的节点使用位图来表示自己持有哪些槽位。对于 16384 个槽位来说,需要 2048 个字节(2KB)大小的内存空间表示
        • 如果需要进行扩容,比如新增一个 3 号分片,就可以针对原有的槽位进行重新分配,可以把之前每个分片持有的槽位各拿出一点,分给新分片
        • 一种可能的分配方式
          • 0 号分片:[0, 4095],共 4096 个槽位
          • 1 号分片:[5462, 9557],共 4096 个槽位
          • 2 号分片:[10924, 15019],共 4096 个槽位
          • 3 号分片:[4096, 5461] + [9558, 10923] + [15019, 16383],共 4096 个槽位
          • 在上述过程中,只有被移动的槽位所对应的数据才需要搬运
        • 在实际使用 Redis 集群分片时,不需要手动指定哪些槽位分配给某个分片,只需要告诉某个分片应该持有多少个槽位即可,Redis 会自动完成后续的槽位分配,以及对应的 key 搬运的工作
      • Redis 集群是最多有 16384 个分片吗?
        • 并非如此,如果一个分片只有一个槽位,key 是要先映射到槽位,再映射到分片的,这对于集群的数据均匀其实是难以保证的。实际上建议集群分片数不应该超过 1000,而且 16000 这么大规模的集群,其本身的可用性也是一个大问题。一个系统越复杂,出现故障的概率是越高的
      • 为什么是 16384 个槽位?
        • 节点之间通过心跳包通信,心跳包中包含了该节点持有的 slots,这个是使用位图数据结构表示的,表示 16384(16k)个 slots,需要的位图大小是 2KB。如果给定的 slots 数更多,比如 65536 个,那么此时就需要消耗更多的空间:8 KB 位图。虽然 8 KB 对于内存来说不算什么,但在频繁的网络心跳包中还是一个不小的开销
        • Redis 集群一般不建议超过 1000 个分片,所以 16k 对于最大 1000 个分片来说是足够用的,同时也会使对应的槽位配置位图体积不至于很大

3、集群搭建(基于 Docker)

  • 基于 docker 搭建一个集群,每个节点都是一个容器
  • 拓扑结构
  • 在 Linux 上,以 .sh 后缀结尾的文件称为 “shell 脚本”。使用 Linux 时,是通过一些命令来进行操作的,非常适合把命令写到一个文件中,批量化进行,同时还能加入条件、循环、函数等机制,因此便可以基于这些来完成更复杂的工作
    • 需要创建多个 redis 节点,这些 redis 的配置文件内容大同小异,此时就可以使用脚本来批量生成
    • shell 中 { } 用来表示变量,而不是代码块
    • \ 是续行符,把下一行的内容和当前行合并成一行。在 shell 默认情况下,要求把所有的代码都写到一行里,使用续行符来换行
    • shell 中拼接字符串是直接写到一起,不需要使用 +

4、主节点宕机

  • 如果集群中的从节点挂了,是没有什么影响的,但如果挂了的节点是主节点,此时就会产生影响了。因为主节点才能处理写操作,从节点是不能写的
  • 处理流程
    • 故障判定
      • 集群中所有节点都会周期性的使用心跳包进行通信
        • 节点 A 给节点 B 发送 ping 包,B 就会给 A 返回⼀个 pong 包。ping 和 pong 除了 message type 属性外,其它部分都是一样的
          • 这里包含了集群的配置信息:该节点的 id,该节点从属于哪个分片,是主节点还是从节点,从属于谁,持有哪些 slots 的位图等
        • 每个节点每秒钟都会给一些随机的节点发起 ping 包,而不是全发一遍,这样设定是为了避免在节点很多时,心跳包也非常多
          • 比如有 9 个节点,如果全发,就是 9*8 有 72 组心跳了(按照 N^2 这样的级别增长的)
        • 当节点 A 给节点 B 发起 ping 包,B 不能如期回应时,此时 A 会尝试重置和 B 的 TCP 连接,看能否连接成功。如果仍然连接失败,A 就会把 B 设为 PFAIL 状态,相当于主观下线。A 判定 B 为 PFAIL 之后,会通过 redis 内置的 Gossip 协议和其他节点进行沟通,向其它节点确认 B 的状态
          • 每个节点都会维护一个自己的下线列表,由于视角不同,每个节点的下线列表也不一定相同
        • 当 A 发现其它很多节点也认为 B 为 PFAIL,且数目超过总集群个数的一半,那么 A 就会把 B 标记成 FAIL,相当于客观下线,并且把这个消息同步给其它节点。其它节点收到后,也会把 B 标记成 FAIL。至此,B 就彻底被判定为故障节点了
      • 某个或者某些节点宕机,有时候会引起整个集群都宕机(fail 状态)
      • 这三种情况会出现集群宕机
        • 某个分片,所有的主节点和从节点都挂了(该分片就无法提供数据服务了)
        • 某个分片,主节点挂了,但是没有从节点(该分片就无法提供数据服务了)
        • 超过半数的 master 节点都挂了(情况严重)
      • 核心原则是保证每个 slots 都能正常工作(存取数据)
    • 故障迁移
      • B 故障且 A 把 B FAIL 的消息告知集群中的其他节点
        • 如果 B 是从节点,那么不需要进行故障迁移
        • 如果 B 是主节点,那么会由 B 的从节点触发故障迁移了
      • 故障迁移就是指把从节点提拔成主节点,继续给整个 redis 集群提供支持
      • 具体流程
        • 从节点判定自己是否具有参选资格。如果从节点和主节点已经太久没通信(此时认为从节点的数据和主节点差异太大了),时间超过阈值则失去竞选资格
        • 具有资格的节点,比如 C 和 D 就会先休眠一定时间,休眠时间 = 500ms 基础时间 + [0, 500ms] 随机时间 + 排名*1000ms
          • offset 的值越大,则排名越靠前(越小)
        • 比如 C 的休眠时间到了,C 就会给其他所有集群中的节点,进行拉票操作,但只有主节点才有投票资格
        • 主节点就会把自己的票投给 C(每个主节点只有 1 票)
        • 当 C 收到的票数超过主节点数目的一半,C 就会晋升成主节点(C 自己负责执行 slaveof no one,并且让 D 执行 slaveof C)
        • 同时 C 还会把自己成为主节点的消息同步给其它集群的节点,其它节点也都会更新自己保存的集群结构信息
      • 上述选举的过程称为 Raft 算法,是一种在分布式系统中广泛使用的算法。在随机休眠时间的加持下,基本上就是谁先唤醒,谁就能竞选成功

5、集群扩容

  • 集群扩容操作(风险高、成本大)是一个在开发中比较常遇到的场景。随着业务的发展,现有集群很可能无法容纳日益增长的数据,此时给集群中加入更多新机器,就可以使存储的空间更大
  • 分布式的本质就是使用更多的机器来引入更多的硬件资源
  • 把新的主节点加入到集群
    • add-node 后的第一组地址是新节点的地址,第二组地址是集群中的任意节点地址
  • 重新分配 slots
    • reshard 后的地址是集群中的任意节点地址
    • 如果在搬运 slots / key 的过程中,此时客户端能否访问 redis 集群呢?
      • 在搬运 key 的过程中,大部分的 key 是不用搬运的,针对这些未搬运的 key,此时是可以正常访问的。但针对正在搬运的 key,进行访问就可能会出现短暂的访问错误的情况(key 的位置出现了变化)。随着搬运的完成,这样的错误自然就恢复了
      • 如果想要追求更高的可用性,让扩容对于用户的影响更小,那么就需要搞一组新的机器,重新搭建集群,并且把数据导入过来,使用新集群代替旧集群,但成本高
  • 给新的主节点添加从节点
    • 有主节点了,此时扩容的目标已经初步达成,但为了保证集群的可用性,还需要给这个新的主节点添加从节点,保证该主节点宕机之后有从节点能够顶上

十二、Redis 典型应用

1、缓存(cache)

  • 缓存(cache)的核心思路就是把一些常用的数据放到触手可及(访问速度更快)的地方,方便随时读取
    • 对于硬件的访问速度来说,通常情况下,CPU 寄存器 > 内存 > 硬盘 > 网络
    • 对于计算机硬件来说,往往访问速度越快的设备成本越高,存储空间越小。缓存是更快,但空间上往往是不足的,因此大部分时候,缓存只放一些热点数据(访问频繁的数据)就非常有用了
  • “二八定律”:20% 的热点数据能够应对 80% 的访问场景
    • 只需要把少量的热点数据缓存起来就可以应对大多数场景,从而在整体上有明显的性能提升
  • 使用 Redis 作为缓存
    • 在一个网站中,经常会使用关系型数据库(比如 MySQL)来存储数据。关系型数据库虽然功能强大,但有一个大缺陷:性能不高(换而言之,进行一次查询操作消耗的系统资源较多)
      • 数据库把数据存储在硬盘上,硬盘的 IO 速度并不快,尤其是随机访问
      • 如果查询不能命中索引,就需要进行表的遍历,会大大增加硬盘 IO 次数
      • 关系型数据库对于 SQL 的执行会做一系列的解析、校验、优化工作
      • 如果是⼀些复杂查询,比如联合查询,需要进行笛卡尔积操作,效率更是降低很多
    • 如果访问数据库的并发量比较高,对于数据库的压力是很大的,很容易就会使数据库服务器宕机
      • 服务器每次处理一个请求都是需要消耗一定的硬件资源的
        • 所谓的硬件资源包括不限于 CPU、内存、硬盘、网络带宽...
        • ⼀个服务器的硬件资源是有限的,一个请求消耗一份资源,请求多了自然把资源就耗尽了。后续的请求没有资源可用,就无法正确处理,更严重还会导致服务器程序的代码出现崩溃
    • 如何让数据库能够承担更大的并发量呢?
      • 核心思路主要有 2 个
        • 开源:引入更多的机器,部署更多的数据库实例,构成数据库集群(主从复制、分库分表等)
        • 节流:引入缓存,使用其它方式保存热点数据,从而降低直接访问数据库的请求数量
        • 实际开发中,这两种方案往往是会搭配使用的
    • Redis 是一个作为数据库缓存的常见方案
      • Redis 访问速度比 MySQL 快很多,或者说处理同⼀个访问请求,Redis 消耗的系统资源比 MySQL 少很多,因此 Redis 能支持的并发量更大
        • Redis 数据在内存中,访问内存比硬盘快很多
        • Redis 只是支持简单的 key-value 存储,不涉及复杂查询的那么多限制规则
    • Redis 就像一个护盾一样,把 MySQL 给罩住了

      • 客户端访问业务服务器,发起查询请求
      • 业务服务器先查询 Redis,看想要的数据是否在 Redis 中存在
      • 如果已经在 Redis 中存在就直接返回,此时不必访问 MySQL 了。如果不存在,再查询 MySQL
    • 按照 “二八定律”,只需要在 Redis 中放 20% 的热点数据就可以使 80% 的请求不再真正查询数据库了。当然,实践中究竟是 “二八”,还是 “一九”,或是 “三七”,这个情况可能会根据业务场景的不同存在差异,但至少绝大多数情况下,使用缓存都能够大大提升整体的访问效率,降低数据库的压力
    • 缓存是用来加快 “读操作” 的速度的,如果是 “写操作”,还是要老老实实写数据库,缓存并不能提高性能
  • 缓存的更新策略
    • 定期生成
      • 每隔一定周期,对于访问的数据频次进行统计,挑选出访问频次最高的前 N% 的数据
      • 搜索引擎的服务器会把哪个用户在什么时间搜了什么词,都通过日志的方式记录下来,然后每隔一段时间对这期间的搜索结果进行统计(日志的数量可能非常巨大,这个统计的过程可能需要使用 hadoop 或者 spark 等方式完成),从而就可以得到 “高频词表”
    • 实时生成
      • 先给缓存设定容量上限,接下来用户每次查询
        • 在 Redis 查到了,就直接返回
        • 如果 Redis 中不存在,就查数据库,把查到的结果同时也写入 Redis(经过一段时间的动态平衡,redis 中的 key 就逐渐成了热点数据)
      • 如果缓存已经满了,即达到上限,就触发缓存淘汰策略,把⼀些相对不那么热门的数据淘汰掉,按照上述过程持续一段时间后,Redis 的内部数据自然就是热门数据了
      • 通用的淘汰策略主要有几种(并非局限于 Redis,其他缓存也可以按这些策略展开)
        • FIFO(First In First Out)先进先出
          • 把缓存中存在时间最久的(也就是先来的数据)淘汰掉
        • LRU(Least Recently Used)淘汰最久未使用的
          • 记录每个 key 的最近访问时间,把最近访问时间最老的 key 淘汰掉
        • LFU(Least Frequently Used)淘汰访问次数最少的
          • 记录每个 key 最近⼀段时间的访问次数,把访问次数最少的淘汰掉
        • Random 随机淘汰
          • 从所有的 key 中随机抽取并淘汰掉
          • 这里的淘汰策略可以自己实现,也可以直接使用 Redis 提供的内置的淘汰策略
            • volatile-lru 当内存不足以容纳新写入数据时,从设置了过期时间的 key 中使用 LRU 算法进行淘汰
            • allkeys-lru 当内存不足以容纳新写入数据时,从所有 key 中使用 LRU 算法进行淘汰
            • volatile-lfu 4.0 版本新增,当内存不足以容纳新写入数据时,在过期的 key 中,使用 LFU 算法进行删除 key
            • allkeys-lfu 4.0 版本新增,当内存不足以容纳新写入数据时,从所有 key 中使用 LFU 算法进行淘汰
            • volatile-random 当内存不足以容纳新写入数据时,从设置了过期时间的 key 中,随机淘汰数据
            • allkeys-random 当内存不足以容纳新写入数据时,从所有key中随机淘汰数据
            • volatile-ttl 在设置了过期时间的 key 中,根据过期时间进行淘汰,越早过期的优先被淘汰(相当于 FIFO,只不过是局限于过期的 key)
            • noeviction 默认策略,当内存不足以容纳新写入数据时,新写入操作会报错
            • Redis 提供的策略和上述介绍的通用策略是基本一致的,只不过 Redis 会针对过期 key 和全部 key 分别做处理
  • 缓存预热(Cache preheating)
    • 使用 Redis 作为 MySQL 的缓存时,当 Redis 刚刚启动或者 Redis 大批 key 失效之后,此时由于 Redis 服务器是没有什么缓存数据的,那么所有的请求都会打给 MySQL,MySQL 就可能直接被访问到,从而造成较大的压力。随着时间的推移,Redis 上的数据积累的越来越多,MySQL 承担的压力也就越来越小,因此就需要提前把热点数据准备好,直接写入到 Redis 中,使 Redis 可以尽快为 MySQL 撑起保护伞
    • 热点数据不一定准确,只要能帮助 MySQL 抵挡大部分请求即可。随着程序运行的推移,缓存的热点数据会逐渐自动调整,来更适应当前情况
  • 缓存穿透(Cache penetration)
    • 访问的 key 在 Redis 和 数据库中都不存在,此时这样的 key 不会被放到缓存上,后续如果仍然在访问该 key,依然会访问到数据库,这就会导致数据库承担的请求太多,压力很大,这种情况称为缓存穿透
    • 产生缓存穿透的原因
      • 业务设计不合理
        • 比如缺少必要的参数校验环节,导致非法的 key 也被查询了
      • 开发 / 运维误操作
        • 把数据库中的部分数据误删了
      • 黑客恶意攻击
    • 解决缓存穿透的方法
      • 针对要查询的参数进行严格的合法性校验,比如要查询的 key 是用户的手机号,那么就需要校验当前 key 是否满足⼀个合法的手机号的格式
      • 针对数据库上也不存在的 key , 也存储到 Redis 中,比如 value 就设成 "",避免后续频繁访问数据库
      • 可以通过引入布隆过滤器,在每次查询 redis / mysql 之前都先判定一下 key 是否在布隆过滤器上存在(把所有的 key 都插入到布隆过滤器中)。布隆过滤器本质上是结合了 hash + bitmap 的思想,以较少的空间开销,比较快的时间速度,判定 key 是否存在
  • 缓存雪崩(Cache avalanche)
    • 短时间内大量 key 在缓存上失效,导致数据库压力骤增,甚至直接宕机
    • 产生缓存雪崩的原因
      • 大规模 key 失效,其可能性主要有 2 种
        • Redis 挂了
        • Redis 上的大量的 key 同时过期
    • 出现大量的 key 同时过期的原因
      • 可能是短时间内在 Redis 上缓存了大量的 key,并且设定了相同的过期时间
    • 解决缓存雪崩的方法
      • 部署高可用的 Redis 集群,并完善监控报警体系
      • 不给 key 设置过期时间或者设置过期时间时添加随机时间因子
  • 缓存击穿(Cache breakdown)
    • 把 breakdown 翻译成 “瘫痪” / “崩溃” 也许更合适一些
    • 相当于缓存雪崩的特殊情况,针对热点 key 突然过期,导致大量的请求直接访问到数据库上,甚至引起数据库宕机
    • 解决缓存击穿的方法
      • 基于统计的方式发现热点 key,并设置永不过期
      • 进行必要的服务降级。例如访问数据库时使用分布式锁,限制同时请求数据库的并发数

2、分布式锁

  • 在一个分布式的系统中,也会涉及到多个节点访问同一个公共资源的情况,此时就需要通过锁来做互斥控制,避免出现类似于线程安全的问题
    • Java 的 synchronized 或者 C++ 的 std::mutex 都是只能在当前进程中生效,在分布式的这种多个进程多个主机的场景下就无能为力了,此时就需要使用到分布式锁
  • 本质就是使用一个公共的服务器来记录加锁状态
    • 这个公共的服务器可以是 Redis,也可以是其它组件(比如 MySQL 或者 ZooKeeper 等),还可以是自己写的一个服务
  • 分布式锁的基础实现
    • 本质:通过一个键值对来标识锁的状态
    •  
      • 上述场景存在线程安全问题,需要使用锁来控制,否则可能出现超卖的情况
      • 加锁的方式
        • 可以在上述架构中引入一个 Redis 来作为分布式锁的管理器
      • 分布式锁也是一个 / 组单独的服务器程序,给其它服务器提供加锁这样的服务(Redis 是一种典型的可以用来实现分布式锁的方案,但不是唯一方案,也可能使用 mysql / zookeeper 这样的组件来实现分布式锁的效果)
      •  
        • 如果买票服务器 1 尝试买票,就需要先访问 Redis,在 Redis 上设置一个键值对,比如 key 就是车次,value 随便设置个值,比如 1
        • 如果这个操作设置成功,就视为当前没有节点对该 001 车次加锁,往 redis 上设置一个特殊的键值对就可以进行数据库的读写操作,操作完成后,再删除刚才这个键值对
        • 如果在买票服务器 1 操作数据库的过程中,买票服务器 2 也想买票,也会去尝试给 Redis 上写一个键值对,key 同样是车次。但如果此时设置时发现该车次的 key 已经存在,则认为已经有其他服务器正在持有锁,此时服务器 2 就需要等待或暂时放弃,就可以保证服务器 1 执行查询 -> 更新的过程中,服务器 2 不会执行查询,也就解决了超卖问题
      • 前面买票场景使用 mysql 的事务也可以批量执行查询+修改操作,但在分布式系统中,要访问的共享资源不一定是 mysql,也可能是其它存储介质
      • 如果 key 不存在就设置,存在则直接失败。但假设某个服务器加锁(setnx)成功了,但在执行后续逻辑的过程中挂了,没有执行到解锁(del)命令,所以上述场景并不完整
  • 过期时间
    • 当服务器 1 加锁后,开始处理买票的过程中,如果服务器 1 意外宕机了,就会导致解锁操作(删除该 key)不能执行,就可能引起其它服务器始终无法获取到锁的情况
      • 可以在设置 key 的同时引入过期时间,即这个锁最多持有多久,就应该被释放
    • 可以使用 set ex nx 的方式,在设置锁的同时设置过期时间
    • 此处的过期时间只能使用一个命令的方式设置,因为 Redis 上多个命令之间无法保证原子性
      • 如果分开多个操作,比如 set nx 之后再来一个单独的 expire,由于 Redis 的多个指令之间不存在关联,并且即使使用了事务也不能保证这两个操作都一定成功,因此就可能出现 set nx 成功,但是 expire 失败的情况,此时仍然会出现无法正确释放锁的问题
  • 校验 id
    • 对于 Redis 中写入的加锁键值对,其它节点也是可以删除的。比如服务器 1 写入一个 "001":1 这样的键值对,服务器 2 是完全可以把 "001" 给删除掉的,虽然说服务器 2 不会进行这样恶意删除的操作,但不能保证因为一些 Bug 而导致服务器 2 把锁误删除
      • 可以引入一个校验 id。可以把设置的键值对的值不再是简单的设为⼀个 1,而是设成服务器的编号,形如 "001": "服务器 1",这样就可以在删除 key(解锁)时,先校验当前删除 key 的服务器是不是当初加锁的服务器,如果是才能真正删除,如果不是则不能删除
  • lua
    • 为了解锁操作原子,可以使用 Redis 内嵌的 Lua 脚本功能
    • Lua 的语法类似于 JS,是一个动态弱类型的语言,Lua 的解释器一般使用 C 语言实现。Lua 语法简单精炼,执行速度快,解释器也比较轻量。redis 执行 lua 脚本的过程也是原子的,相当于执行一条命令一样(实际上 lua 中可以写多条命令)
  • watch dog(看门狗)
    • 上述方案仍然存在一个重要问题:当设置了 key 过期时间后,就可能当任务在业务逻辑还没执行完,key 就先过期(释放锁)了,从而导致锁提前失效
    • 把这个过期时间设置的足够长,是否能够解决上述这个问题呢?
      • 设置多长时间合适是无止境的。即使设置的时间再长,也不能完全保证就没有提前失效的情况,而且如果设置的时间太长了,万一对应的服务器挂了(锁释放不及时),此时其他服务器也不能及时获取锁,因此相比于设置一个固定长时间,不如动态调整时间更合适
      • watch dog 本质就是加锁的服务器上的一个单独线程,通过这个线程来对锁的过期时间进行动态续约
        • 这个线程是业务服务器上的,不是 Redis 服务器的
    • 假设初始情况下设置过期时间为 10s,同时设定看门狗线程每隔 3s 检测一次,那么当 3s 到时,看门狗就会判定当前任务是否完成
      • 如果任务已经完成,则直接通过 lua 脚本的方式,释放锁(删除 key)
      • 如果任务未完成,则把过期时间重写设置为 10s,即续约
      • 这样就不用担心锁提前失效的问题了,而且如果该服务器挂了,看门狗线程也就随之挂了,此时无人续约,这个 key 自然就可以迅速过期,让其它服务器获取到锁了
  • Redlock 算法
    • 实践中的 Redis 一般是以集群的方式部署的(至少是主从的形式,而不是单机),那么就可能出现比较极端的情况
      • 服务器 1 向 master 节点进行加锁操作,这个写入 key 的过程刚刚完成,master 就挂了,此时 slave 节点升级成了新的 master 节点。但由于刚才写入的 key 还没来得及同步给 slave,就相当于服务器 1 的加锁操作形同虚设,服务器 2 仍然可以进行加锁,即给新的 master 写入 key,因为新的 master 不包含刚才的 key
        • 可以引入一组 Redis 节点,其中每一组 Redis 节点都包含一个主节点和若干从节点,并且组和组之间存储的数据都是一致的,相互之间是备份关系,而并非是数据集合的一部分,这点有别于 Redis cluster
      • 加锁时按照一定的顺序针对这些组 redis 都进行加锁操作,在写锁时需要设定操作的超时时间,如果 setnx 操作超过了超时时间还没有成功,就视为加锁失败

        • 如果给某个节点加锁失败,则立即尝试下一个节点。当加锁成功(写入 key)的节点数超过总节点数的一半,就视为加锁成功。即使有某些节点挂了,也不影响锁的正确性
      • 是否可能出现上述节点都同时遇到大冤种的情况呢?
        • 理论上这件事是有可能发生的,但概率很小,可以忽略不计
        • 释放锁时也需要把所有节点都进行解锁操作,即使是之前的超时节点,也要尝试解锁,尽量保证逻辑严密
    • Redlock 算法核心:加锁操作不能只写给一个 Redis 节点,而要写多个。分布式系统中任何一个节点都是不可靠的,最终加锁成功的结论:少数服从多数,引入最多的冗余来提高 Redis 作为分布式锁的可读性。因为一个分布式系统不至于大部分节点同时出现故障,所以这样的可靠性要比单个节点来说靠谱
  • 其他功能
    • 上述锁只是一个简单的互斥锁,但实际上在⼀些特定场景中,还有一些其他特殊的锁
      • 可重入锁
      • 公平锁(遵守先来后到原则)
      • 读写锁
    • 基于 Redis 的分布式锁,也可以实现上述特性,只不过对应的实现逻辑也会更复杂
    • 在实际开发中并不会自己去实现一个分布式锁,因为已经有很多现成的库封装好了,直接使用即可(比如:C++ 中的 redis-plus-plus、Java 中的 Redisson)

网站公告

今日签到

点亮在社区的每一天
去签到