Redis缓存中间件-Ⅰ

缓存基础、Redis 核心机制与高可用方案梳理

Posted by Ekko on August 13, 2020

内容从缓存的基本思想出发,梳理 Redis 在读写模式、过期与淘汰、持久化、主从复制、哨兵和集群等方面的核心机制。整体定位偏向基础总览,重点是把常见概念之间的关系串起来。

内容重点放在缓存视角下最常见的基础问题,便于先建立对 Redis 核心机制的整体认识。

参考资料:

官方文档:Redis DocumentationRedis CommandsRedis PersistenceRedis ReplicationRedis Cluster Specification

实践资料:为什么 Redis 选择单线程模型Redis 命令参考Redis 设计与实现try.redis.io

[TOC]


缓存的基本思想

缓存最直接的收益通常体现在三个方面:降低响应延迟、减少后端数据源压力、提升系统整体吞吐量。

缓存的基本思想是:空间换时间

比如 CPU Cache 缓存的是内存数据,用于解决 CPU 处理速度和内存不匹配的问题

内存缓存的是硬盘数据,用于解决硬盘访问速度过慢的问题

操作系统在页表方案基础之上引入了快表(转换检测缓冲区),用来加速虚拟地址到物理地址的转换。它也可以看作一种特殊的高速缓冲存储器(Cache)。

缓存总结: 为了避免用户在请求数据的时候获取速度过于缓慢,所以在数据库之上增加了缓存这一层来弥补


使用缓存为系统带来了什么问题

针对分布式缓存:

  • 系统复杂性增加: 引入缓存之后,需要维护缓存和数据库的数据一致性、维护热点缓存等等
  • 系统开发成本往往会增加: 引入缓存意味着系统需要额外的缓存服务、运维资源以及一致性治理成本。当然,如果只是简单地在单体应用中缓存少量数据,而且没有分布式诉求,那么单独引入分布式缓存服务也未必划算

本地缓存解决方案:

本地缓存实际在很多项目中用的挺多,特别是单体架构的时候。数据量不大,并且没有分布式要求的话,使用本地缓存还是可以的

常见的单体架构图如下,使用 Nginx 来做负载均衡,部署两个相同的服务到服务器,两个服务使用同一个数据库,并且使用的是本地缓存

redis章节之本地缓存.png

那本地缓存的方案有:

  1. JDK 自带的 HashMap 和 ConcurrentHashMap

    ConcurrentHashMap 可以看作是线程安全版本的 HashMap ,两者都是存放 key/value 形式的键值对。但是,大部分场景来说不会使用这两者当做缓存,因为只提供了缓存的功能,并没有提供其他诸如过期时间之类的功能。一个稍微完善一点的缓存框架至少要提供:过期时间、淘汰机制、命中率统计这三点

  2. Ehcache 、 Guava Cache 、 Caffeine 是使用比较多的本地缓存方案;如果在 Spring 体系中使用缓存,通常还会配合 Spring Cache 抽象层

    Ehcache 相对更重一些,但它支持磁盘持久化、本地缓存以及与部分 ORM 方案集成,多级缓存能力也比较完整。

    Guava Cache 和 Caffeine 的 API 风格比较接近,但如今更常推荐直接使用 Caffeine。

    Guava Cache 提供了比较方便的本地缓存 API,也支持过期、淘汰等能力。不过在性能和维护状态上,通常已经把 Caffeine 作为优先选择。

    Spring Cache 更准确地说是缓存抽象,而不是一个独立缓存实现。它负责统一注解和编程模型,底层仍然需要接入 Caffeine、Ehcache、Redis 等具体实现。

  3. Caffeine

    相比于 Guava 来说,Caffeine 在命中率、吞吐和维护活跃度方面通常都更好,一般会作为本地缓存的优先方案。并且,Guava 和 Caffeine 的使用方式也比较接近。


为什么要有分布式缓存而不直接用本地缓存

可以把分布式缓存(Distributed Cache) 看作是一种内存数据库的服务,它的最终作用就是提供缓存数据的服务

如下图所示,就是一个简单的使用分布式缓存的架构图。使用 Nginx 来做负载均衡,部署两个相同的服务到服务器,两个服务使用同一个数据库和缓存

redis章节之分布式缓存.png

本地的缓存的优势是低依赖,比较轻量并且通常相比于使用分布式缓存要更加简单

再来分析一下本地缓存的局限性:

  • 本地缓存对分布式架构支持不友好,比如同一个相同的服务部署在多台机器上的时候,各个服务之间的缓存是无法共享的,因为本地缓存只在当前机器上有

  • 本地缓存容量受服务部署所在的机器限制明显。 如果当前系统服务所耗费的内存多,那么本地缓存可用的容量就很少

使用分布式缓存之后,缓存部署在一台单独的服务器上,即使同一个相同的服务部署在再多机器上,也是使用的同一份缓存。 并且,单独的分布式缓存服务的性能、容量和提供的功能都要更加强大

使用分布式缓存的缺点也很明显,那就是需要额外引入 Redis 或 Memcached 这类服务,并为它们单独建设高可用能力。


缓存读写模式/更新策略

Cache Aside Pattern(旁路缓存模式)

  • 写: 更新 DB,然后直接删除 cache
  • 读: 从 cache 中读取数据,读取到就直接返回,读取不到的话,就从 DB 中取数据返回,然后(客户端)再把数据放到 cache 中

Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准

另外,Cache Aside Pattern 有首次请求数据一定不在 cache 的问题,对于热点数据可以提前预热到缓存中。

Cache Aside Pattern 是业务系统里最常见的缓存模式,比较适合读多写少、并且希望应用层掌握缓存更新时机的场景。

Read/Write Through Pattern(读写穿透模式)

Read/Write Through 的思路是:应用把 cache 视为统一入口,由 cache 服务负责与 DB 交互,从而减轻业务代码的职责。

  • 写(Write Through): 先查 cache,cache 中不存在,直接更新 DB。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)
  • 读(Read Through): 从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 DB 加载,写入到 cache 后返回响应

Read-Through Pattern 可以理解为在 Cache-Aside Pattern(旁路缓存模式)之上又包了一层。

在 Cache-Aside Pattern(旁路缓存模式)下,发生读请求的时候,如果 cache 中不存在对应的数据,是由应用自己负责把数据写入 cache;而 Read Through Pattern(读穿透模式)则是由 cache 服务自己完成这一步,这对应用是透明的。

和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不在 cache 的问题,对于热点数据可以提前放入缓存中

Write Behind Pattern(异步缓存写入)

Write Behind Pattern (异步缓存写入)和 Read/Write Through Pattern (读写穿透模式)很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

但是,两个又有很大的不同:

  • Read/Write Through (读写穿透模式)是同步更新 cache 和 DB
  • Write Behind Caching (异步缓存写入)则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB

Write Behind Pattern(异步缓存写入)下 DB 的写性能通常更高,尤其适合计数器、统计类这类短时间内会被频繁更新的数据场景。例如文章点赞数、阅读数、曝光量等。

但是,这种模式也会给 DB 和 Cache 一致性带来更大挑战。如果异步刷盘或者异步落库还没完成,缓存节点故障就可能导致数据丢失或者回退。

下表把三种常见读写模式放在一起看会更清楚:

模式 读路径 写路径 一致性特点 适用场景
Cache Aside 先查缓存,未命中再查 DB 并回填 先改 DB,再删缓存 业务层自己处理一致性 互联网业务最常见
Read/Write Through 统一从缓存层读 统一写缓存层,再由缓存层同步 DB 缓存层封装更多职责 希望弱化业务代码复杂度
Write Behind 通常先读缓存 先写缓存,再异步批量落库 性能好,但一致性和容灾压力更大 计数、统计、批量写入

下图对应最常用的 Cache Aside 读路径:

flowchart LR
    A[请求到达应用] --> B{缓存命中?}
    B -- 是 --> C[直接返回缓存结果]
    B -- 否 --> D[查询数据库]
    D --> E{数据库有结果?}
    E -- 否 --> F[返回空结果]
    E -- 是 --> G[写入缓存]
    G --> H[返回结果]

Redis 简介

Redis 就是一个使用 C 语言开发的一个开源的(遵从BSD协议)高性能键值对(key-value)的内存数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向

Redis 除了做缓存之外,Redis 也经常用来做分布式锁,甚至是消息队列

Redis 作为内存数据库,常见特点可以概括为:

  1. 数据主要驻留在内存中,读写延迟低,适合作为缓存或高频访问的状态存储
  2. 核心命令执行路径长期以单线程事件循环为主,配合 I/O 多路复用;Redis 6.0 之后又引入了 I/O 线程来提升网络读写能力
  3. 丰富的数据类型,支持字符串(strings)、散列(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等
  4. 支持数据持久化。可以将内存中数据保存在磁盘中,重启时加载
  5. 支持主从复制、哨兵和集群,便于构建高可用和可扩展方案
  6. 可以用作分布式锁
  7. 可以作为消息中间件使用,支持发布订阅 … …

Redis 和 Memcached

分布式缓存使用的比较多的主要是 Memcached 和 Redis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis

Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了

分布式缓存主要解决的是单机缓存的容量受服务器限制并且无法保存通用的信息。因为,本地缓存只在当前服务里有效,比如如果部署了两个相同的服务,他们两者之间的缓存数据是无法通用的

Memcached 的特点如下:

  • 支持简单数据类型
  • 不支持数据持久化存储
  • 不支持主从
  • 不原生支持 Redis Cluster 这种服务端分片模式,通常依赖客户端做分片

Redis 特点如下:

  • 数据类型丰富
  • 支持数据磁盘持久化存储
  • 支持主从
  • 原生支持主从、哨兵和 Cluster 等多种部署形态

两者的共同点和区别

共同点:

  1. 都是基于内存的数据库,一般都用来当做缓存使用
  2. 都有过期策略
  3. 两者的性能都非常高

区别:

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型
  2. Redis 支持数据持久化,可以将内存中的数据保存到磁盘中,重启后重新加载;而 Memcached 把数据全部放在内存中
  3. Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上
  4. Redis 可以通过过期策略和内存淘汰策略控制内存使用;Memcached 也有自己的内存淘汰机制,但不提供 Redis 那样丰富的数据结构和持久化能力
  5. Memcached 没有原生的集群模式,需要依靠客户端来实现分片写入;Redis 则原生支持 Cluster 集群模式
  6. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO )
  7. Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言
  8. Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除

为什么 Redis 能这么快

Redis 的效率很高,官方给出的数据是 100000+QPS,这是因为:

  • Redis 完全基于内存,绝大部分请求是纯粹的内存操作,执行效率高。

  • Redis 的核心命令执行路径采用单线程事件循环(主线程串行执行命令),减少上下文切换与锁竞争;需要利用多核时通常通过多实例/分片扩展。与此同时,Redis 也会用后台线程/子进程处理部分耗时工作(例如异步删除、AOF 刷盘、RDB/AOF 重写),Redis 6.0+ 还支持用 I/O 线程并行网络读写(见下文)。

  • 数据结构简单,对数据操作也简单,Redis 不使用表,不会强制用户对各个关系进行关联,不会有复杂的关系限制,其存储结构就是键值对,类似于 HashMap,HashMap 最大的优点就是存取的时间复杂度为 O(1)

  • Redis 使用多路 I/O 复用模型,为非阻塞 IO

Redis 采用的 I/O 多路复用函数:epoll/kqueue/evport/select

为什么常说 Redis 是单线程(以及 Redis 6+ 的“多线程”到底指什么)

“单线程”通常指:Redis 的命令执行(读写键值、维护数据结构、执行 Lua 脚本等)主要由一个主线程串行完成。这样的设计可以把并发控制成本降到很低,代码路径更短、可维护性更好,也避免了大量细粒度锁带来的开销与复杂度。

Redis 的高吞吐并不是因为“单线程比多线程更快”,而是因为它把常见路径做到了足够轻:

  • 绝大多数请求是内存读写,延迟非常低
  • 网络层采用 I/O 多路复用,一个线程也能高效管理大量连接
  • 很多场景的瓶颈更常见于网络带宽、内存容量、以及客户端/协议开销,而不是 CPU 算力本身

同时也要避免把“命令执行单线程”理解成“Redis 只有一个线程/完全不并发”。为了把耗时任务从主线程挪走,Redis 在不同版本里引入了额外的并发手段:

  • 后台线程:例如异步释放大对象(UNLINK/FLUSHDB ASYNC/FLUSHALL ASYNC)、AOF 刷盘、关闭文件等
  • 子进程:例如 RDB/AOF 重写通过 fork() 派生子进程完成,尽量避免阻塞主线程
  • I/O 线程(Redis 6.0+):可以把网络数据的读写并行化(io-threadsio-threads-do-reads),但命令执行仍保持主线程串行,从而在性能与语义(原子性、实现复杂度)之间做折中

可参考 Redis 官方文档的 Threaded I/O 说明:https://github.com/redis/redis-doc/blob/master/topics/threaded-io.md


缓存数据的处理流程是怎样的

缓存数据的处理流程.png

  • 如果用户请求的数据在缓存中就直接返回
  • 缓存中不存在的话就看数据库中是否存在
  • 数据库中存在的话就更新缓存中的数据
  • 数据库中不存在的话就返回空数据

为什么要用 Redis/为什么要用缓存

从系统设计角度看,使用缓存主要是为了同时提升响应速度和系统吞吐能力。

主要从“高性能”和“高并发”这两点来看待这个问题。

为什么要用Redis.png

高性能:

假设场景:

假如某些数据第一次从数据库读取时比较慢,而后续又会被高频访问,并且更新频率不高,那么就适合把这类数据前移到缓存中。

这样的好处就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快

不过,要保持数据库和缓存中的数据的一致性。如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可

高并发:

关系型数据库更擅长复杂查询、事务和持久化保证,而 Redis 这类内存型系统更适合承接高频、低延迟、访问模式相对简单的数据请求。把高频热点请求前移到缓存层之后,整体吞吐通常会比完全直连数据库高很多。

所以,直接操作缓存能够承受的请求量通常会明显高于直接访问数据库。将高频热点数据前移到缓存层之后,一部分请求会在缓存层就被消化掉,从而提高系统整体并发能力。


Redis 单线程模型详解

Redis 基于 Reactor 模式来设计开发了自己的一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以一般都说 Redis 是单线程模型

监听大量的客户端连接

Redis 通过 IO 多路复用程序 监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

这样的好处非常明显: I/O 多路复用 技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)

另外, Redis 服务器是一个事件驱动程序,服务器需要处理两类事件: 1. 文件事件; 2. 时间事件

时间事件暂不展开,最常接触的仍然是文件事件,也就是客户端读写请求对应的那一层网络通信处理。

《Redis 设计与实现》文件事件

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据 套接字目前执行的任务来为套接字关联不同的事件处理器。

当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性

可以看出,文件事件处理器(file event handler)主要是包含 4 个部分:

  • 多个 socket(客户端连接)
  • IO 多路复用程序(支持多个客户端连接的关键)
  • 文件事件分派器(将 socket 关联到相应的事件处理器)
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

Redis文件事件处理器.png


Redis 没有使用多线程?为什么不使用多线程

如果把“Redis 是单线程”理解为“整个 Redis 进程从头到尾都只有一个线程”,这个说法并不准确。更准确的表述是:Redis 的核心命令执行路径长期以单线程串行为主,但在新版本里逐步把部分耗时工作拆给后台线程或 I/O 线程处理。

Redis 4.0 开始,后台线程主要用于处理部分异步任务,例如大对象释放等操作,目的就是尽量减少主线程被阻塞的时间。

Redis 6.0 之前主要还是单线程处理,主要原因可能是:

  • 单线程编程容易并且更容易维护
  • Redis 的性能瓶颈通常不在 CPU,更多出现在内存和网络
  • 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能

Redis 6.0 引入 I/O 线程,主要是为了提高网络 I/O 读写性能,因为在高并发、大报文场景下,这一层确实可能成为瓶颈。

不过,Redis 6.0 的“多线程”主要用于网络数据的读写这类耗时操作,命令执行本身仍然保持单线程顺序执行。因此,Redis 依然保留了简单直接的执行语义,不会因为把命令执行全面并行化而显著增加锁竞争与实现复杂度。

Redis 6.0 的 I/O 线程默认是禁用的,只使用主线程。如需开启,需要修改 redis.conf

1
io-threads-do-reads yes

开启后还需要设置线程数,否则不会生效。同样需要修改 redis.conf

1
io-threads 4

线程数并不是越多越好。Redis 官方曾给出过经验值:4 核机器通常设置为 2 或 3 个 I/O 线程,8 核机器通常设置为 6 个左右,更高的线程数未必继续带来收益。


Redis 给缓存数据设置过期时间有什么用

一般情况下,设置保存的缓存数据的时候都会设置一个过期时间

因为内存是有限的,如果缓存中的所有数据都一直保留,那么很容易把内存吃满。

Redis 自带了给缓存数据设置过期时间的功能,比如:

1
2
3
4
5
127.0.0.1:6379> expire key 60   # 数据在 60s 后过期

127.0.0.1:6379> set key value ex 60  # 写入值并同时设置 60s 过期时间

127.0.0.1:6379> ttl key  # 查看数据还有多久过期

SETEX 仍然可以使用,但在较新的命令风格里,更常见的是直接使用 SET key value EX seconds。另外,PERSIST 命令可以移除一个键的过期时间。

过期时间除了有助于缓解内存的消耗,还有什么其他用么?

很多时候,业务场景要求某个数据只在特定时间窗口内有效,比如短信验证码只在 1 分钟内有效,登录 token 只在 1 天内有效,活动库存快照只在一小段时间内复用。

如果使用传统数据库自己做过期判断,往往需要额外字段、定时任务或者应用层判断,复杂度和访问成本都会更高。


Redis如何判断数据是否过期

Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间

过期字典的键指向 Redis 数据库中的某个 key(键) ,过期字典的值是一个 long 类型的整数,这个整数保存了字典 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)

Redis过期字典.png

过期字典是存储在redisDb这个结构里的:

1
2
3
4
5
6
7
typedef struct redisDb {
    ...
    
    dict *dict;     //数据库键空间,保存着数据库中所有键值对
    dict *expires   // 过期字典,保存着键的过期时间
    ...
} redisDb;

过期键删除策略

Redis 针对已经设置了 TTL 的键,主要结合两种删除策略:

  • 惰性删除:只会在访问 key 的时候才进行过期检查。这种方式对 CPU 更友好,但可能导致过期 key 长时间滞留在内存中
  • 定期删除:周期性抽样一批设置过期时间的 key,检查并删除已经过期的数据。Redis 会限制这类任务的执行时长和频率,避免长时间阻塞主线程

定期删除对内存更加友好

惰性删除对 CPU 更加友好

两者各有取舍,所以 Redis 采用的是“定期删除 + 惰性删除”的组合方案。

定期删除的理解方式: Redis 不会暴力扫描所有带 TTL 的 key,而是按周期做抽样检查。

为什么不全量扫描: 如果线上绝大多数 key 都设置了过期时间,那么每次全量扫描都会非常昂贵,主线程几乎无法承受这种开销。

如果某些过期 key 在定期删除里一直没被抽到,那么惰性删除会在这些 key 再次被访问时补上这一刀。

但是,仅仅设置 TTL 还不够。因为定期删除和惰性删除都不是“立刻精确删除”,仍然可能有部分过期 key 暂时留在内存里。

如果这时 Redis 继续写入数据并触发 maxmemory 限制,就会进一步进入内存淘汰机制。

Redis 内存淘汰机制可以解决大量过期 key 的问题


Redis 内存淘汰机制

在常见版本语境里,Redis 常用的内存淘汰策略可以分为以下几类:

  1. volatile-lru(least recently used): 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
  2. volatile-ttl: 从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random: 从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru(least recently used): 当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  5. allkeys-random: 从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-eviction: 禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错

4.0 版本后增加以下两种:

  1. volatile-lfu: 从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰

  2. allkeys-lfu: 当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

如果只是把 Redis 当缓存来使用,allkeys-lruallkeys-lfu 往往更常见;如果把 Redis 当更偏“内存数据库”的组件来使用,则更需要谨慎评估是否应当直接使用 noeviction

需要区分两个概念:

  • 过期键删除:处理的是“已经过期”的 key
  • 内存淘汰:处理的是“内存不够了,需要腾空间”的场景

两者经常一起出现,但并不是同一件事。


Redis 使用中的两个常见运维问题:BigKey 与 HotKey

过期、淘汰之外,线上 Redis 还经常会遇到两个非常典型的问题:BigKey 和 HotKey。

BigKey:

  • 指单个 key 对应的 value 过大,例如一个超大的 String、包含大量 field 的 Hash、超长的 List/Set/ZSet
  • 问题不只在于占内存,还在于删除、迁移、复制时可能引起明显阻塞
  • 常见治理方式包括拆分大 key、限制单 key 元素数量、使用 UNLINK 进行异步删除、对大对象做分桶

HotKey:

  • 指访问频率异常高的 key,可能集中打在单个 Redis 节点甚至单个槽位上
  • 问题主要体现在单点流量倾斜、CPU 飙高、网络带宽被打满以及尾延迟上升
  • 常见治理方式包括本地缓存前置、多副本读扩散、热点 key 拆分、请求合并以及限流降级

Redis 持久化机制

很多时候需要持久化数据,也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置

Redis 不同于 Memcached 的很重要一点就是:Redis 支持持久化,而且支持两种不同的持久化操作

Redis 的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file, AOF)。这两种方法各有侧重,分别对应不同的数据恢复目标与性能取舍。

快照(snapshotting)持久化(RDB)

Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本

RDB:快照形式是直接把内存中的数据保存到一个 dump 的文件中,定时保存,保存策略

工作原理:当 Redis 需要做持久化时,Redis 会 fork 一个子进程,子进程将数据写到磁盘上一个临时 RDB 文件中。当子进程完成写临时文件后,将原来的 RDB 替换掉,这样的好处是可以 copy-on-write

Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用

快照持久化是 Redis 默认采用的持久化方式,在 Redis.conf 配置文件中默认有此下配置:

1
2
3
4
5
save 900 1      #在900秒(15分钟)之后如果至少有1个key发生变化Redis就会自动触发BGSAVE命令创建快照

save 300 10     #在300秒(5分钟)之后如果至少有10个key发生变化Redis就会自动触发BGSAVE命令创建快照

save 60 10000   #在60秒(1分钟)之后如果至少有10000个key发生变化Redis就会自动触发BGSAVE命令创建快照

AOF(append-only file)持久化

与快照持久化相比,AOF 的数据完整性通常更好。默认情况下 Redis 不一定开启 AOF,是否开启取决于部署目标:纯缓存场景可能直接关闭持久化或只保留 RDB,而更关注恢复能力的场景往往会开启 AOF。

AOF 的思路是:把会修改数据集的写命令按追加日志的方式记录下来。

以 append-only 的模式写入一个日志文件中,因为这个模式是只追加的方式,所以没有任何磁盘寻址的开销,所以很快,有点像Mysql中的binlog

在配置中开启 appendonly yes 之后,Redis 会把修改命令记录到 AOF 中;重启时通过重放 AOF 恢复数据。可以设置不同的 fsync 策略,常见的 appendfsync everysec 意味着通常最多丢失 1 秒左右的数据。

1
appendonly yes

开启 AOF 持久化后,Redis 会把修改命令追加到 AOF 文件。AOF 文件目录由 dir 参数控制。需要注意的是,Redis 7.0 之后 AOF 在实现上演进为 multi-part AOF,底层可能由 base 文件和增量文件共同组成,但从理解上仍然可以把它看作“追加日志 + 重写压缩”这套机制。

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:

1
2
3
appendfsync always    #每次有数据修改发生时都会写入AOF文件,这样会严重降低 Redis 的速度
appendfsync everysec  #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no        #让操作系统决定何时进行同步

为了兼顾数据安全和写入性能,很多场景会选择 appendfsync everysec。这样通常能够把性能损失控制在较小范围内,同时把故障时的数据丢失窗口压缩到 1 秒左右。

Redis 4.0 开始支持 RDB (快照) 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差

AOF重写

AOF 重写可以产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小

AOF 重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。

在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作


RDB 与 AOF 优缺点

RDB

优点: RDB 文件紧凑、适合做备份和冷恢复,而且恢复速度通常比 AOF 更快。

缺点: RDB 本质上是时间点快照,因此两次快照之间的数据可能丢失;另外,在大实例上执行 fork() 和持久化时,也需要关注内存和延迟抖动。

AOF

优点: AOF 对数据变更记录得更细,配合 everysec 策略时,通常只会丢失极小时间窗口内的数据。

AOF 以追加写为主,可读性也更强,在某些误操作场景下更便于分析和恢复。

缺点: AOF 文件通常比 RDB 更大,写入路径也更重;如果 fsync 策略更激进,写性能会受到更明显影响。

两种策略选择:

如果更偏向备份恢复速度、能够接受时间点丢失,那么可以偏向使用 RDB。

如果更关心故障后的数据完整性,通常会开启 AOF,必要时再结合 RDB。

数据库备份和灾难恢复方面,RDB 的定时快照依然非常有价值,因为它便于归档、传输和快速恢复。

实际生产里,RDB + AOF 组合往往更常见:RDB 负责更快的备份与恢复,AOF 负责缩小数据丢失窗口。


Redis 事务

Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能

1
2
3
4
5
6
7
8
9
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1

使用 MULTI 命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令将执行所有命令

但是,Redis 的事务和关系型数据库事务并不是同一种语义。Redis 事务更准确地说是“命令队列 + 顺序执行”,而不是完整的 ACID 事务。

需要特别注意三点:

  • Redis 不支持关系型数据库那种 rollback
  • EXEC 时队列中的命令会按顺序执行,执行期间不会被其他客户端命令插入
  • 如果某条命令在执行阶段报错,不会自动回滚已经成功执行的前面几条命令

如果需要“先读后改”的并发保护,Redis 通常借助 WATCH 做乐观锁;如果需要把一段逻辑作为原子单元执行,也常常会使用 Lua 脚本。


如果有大量的key需要设置同一时间过期,一般需要注意什么?

如果大量 key 的过期时间过于集中,到达某个时间点时 Redis 可能会出现瞬时抖动,严重时还会诱发缓存雪崩。因此通常会给过期时间加一个随机扰动,让失效时间尽量分散。

电商首页经常会使用定时任务刷新缓存,可能大量的数据失效时间都十分集中,如果失效时间一样,又刚好在失效的时间点大量用户涌入,就有可能造成缓存雪崩


缓存穿透

产生这个问题的原因可能是外部的恶意攻击,例如,对用户信息进行了缓存,但恶意攻击者使用不存在的用户 id 频繁请求接口导致查询缓存不命中,然后穿透 DB 查询依然不命中。这时会有大量请求穿透缓存访问到 DB。缓存和数据库中都没有的数据,而攻击者不断发起请求。举个例子:如果数据库中的 id 都是从 1 自增的,那么持续发起 id=-1 或者极大且根本不存在的 id 请求,就会让数据库承受额外压力,严重时甚至可能被击垮。

解决方法:

  1. 对不存在的用户,在缓存中保存一个空对象进行标记,防止相同 ID 再次访问 DB。不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据
  2. 使用 Bloom Filter 做存在性预检。Bloom Filter 中不存在的数据一定不存在;如果判断存在,则再继续访问缓存和数据库
  3. 在接口层增加参数校验、鉴权、访问频率控制等保护手段,例如对明显非法的 id 直接拦截

缓存击穿

某个热点数据失效时,大量针对这个数据的请求会穿透到数据源。跟缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了 DB。而缓存击穿不同的是缓存击穿是指一个 Key 非常热点,在不停地扛着大量的请求,大并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的大并发直接落到了数据库上,就在这个 Key 的点上击穿了缓存

解决方法:

  1. 可以使用互斥锁更新,保证同一个进程中针对同一个数据不会并发请求到 DB,减小 DB 压力
  2. 使用随机退避或者请求合并的方式,避免在失效瞬间同时打向数据库
  3. 针对多个热点 key 同时失效的问题,可以在缓存时使用固定时间加上一个小的随机数,避免大量热点 key 同一时刻失效
  4. 设置热点数据永不过期

缓存雪崩

产生的原因是缓存挂掉,这时所有的请求都会穿透到 DB。目前电商首页以及热点数据都会去做缓存,一般缓存都是定时任务去刷新,或者查不到之后去更新缓存的,定时任务刷新就有一个问题

举个例子:如果首页一批 key 的失效时间都设置成同一个时刻,而此时又遇到活动流量高峰,那么大量请求会同时穿透到数据库,最终把后端数据源压垮,这就是缓存雪崩。

解决方法:

  1. 使用快速失败的熔断策略,减少 DB 瞬间压力
  2. 使用主从模式和集群模式来尽量保证缓存服务的高可用(如果 Redis 是集群部署,将热点数据均匀分布在不同的 Redis 库中也能避免全部失效)
  3. 在批量往 Redis 存数据的时候,把每个 Key 的失效时间都加个随机值就好了,这样可以保证数据不会再同一时间大面积失效
  4. 对极少数核心热点数据采用“逻辑过期”或者主动更新,而不是完全依赖 TTL 自然失效

穿透击穿雪崩避免总结

  1. 事前:做好 Redis 高可用、过期时间离散化、热点数据预热,避免缓存层整体失效
  2. 事中:增加本地缓存、限流、熔断、降级和请求隔离,避免把流量直接倾倒到 MySQL
  3. 事后:利用 RDB/AOF 快速恢复缓存数据,并通过降级策略保证系统在恢复阶段仍然可用

这一整套思路的核心不是“完全不出错”,而是在故障发生时把问题限制在可控范围内,避免数据库和核心服务被瞬间打穿。


Redis 其他保证集群高可用的方式

哨兵集群 Sentinel 一般至少部署 3 个实例,用来保证故障判断和选主过程具备基本的多数派语义。

哨兵 + 主从 并不能保证数据不丢失,但是可以保证集群的高可用

哨兵组件主要功能:

  1. 集群监控:负责监控 Redis master 和 replica 进程是否正常工作
  2. 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员
  3. 故障转移:如果 master node 挂掉了,会自动把某个 replica 提升为新的主节点
  4. 配置中心:如果故障转移发生了,通知客户端新的主节点地址

Redis 主从同步

主从复制最核心的价值有两个:一是把写流量集中到主节点、把读流量分散到副本节点,从而实现读写分离;二是为高可用和故障转移提供基础。

redis主从同步.png

当一个 replica 节点连接 master 时,会通过 PSYNC 协议与主节点协商复制进度。如果是第一次复制,或者主节点无法从复制积压缓冲区中补齐缺失数据,就会触发全量复制;否则只需要进行部分复制。

全量复制时,主节点会执行 BGSAVE 生成 RDB 快照,并把这份快照发送给 replica;快照传输期间产生的新写命令会被追加到复制缓冲区,待 replica 加载完 RDB 后再继续补发,从而尽可能追平状态。

复制过程

  1. 从节点执行 replicaof <masterIP> <masterPort>,保存主节点信息
  2. 从节点中的定时任务发现主节点信息,建立和主节点的 Socket 连接
  3. 从节点发送 Ping 信号,主节点返回 Pong,两边能互相通信
  4. 连接建立后,主节点将所有数据发送给从节点(数据同步)
  5. 主节点把当前的数据同步给从节点后,便完成了复制的建立过程。接下来,主节点就会持续的把写命令发送给从节点,保证主从数据一致性

早期资料和旧版本配置里经常会看到 slaveof,在较新的版本里官方更推荐使用 replicaof。两者表达的是同一类能力,但 replicaof 的命名更中性。

数据同步的过程

Redis 2.8 之前使用 sync[runId][offset] 同步命令,Redis 2.8 之后使用 psync[runId][offset] 命令。两者不同在于:Sync 命令仅支持全量复制过程;Psync 支持全量和部分复制

runId:每个 Redis 节点启动都会生成唯一的 uuid,每次 Redis 重启后,runId 都会发生变化

offset:主节点和从节点都各自维护自己的主从复制偏移量 offset,当主节点有写入命令时,offset=offset+命令的字节长度

从节点在收到主节点发送的命令后,也会增加自己的 offset,并把自己的 offset 发送给主节点。这样,主节点同时保存自己的 offset 和从节点的 offset,通过对比 offset 来判断主从节点数据是否一致

repl_backlog_size:保存在主节点上的一个固定长度的先进先出队列,默认大小是 1MB

主节点发送数据给从节点过程中,主节点还会进行一些写操作,这时候的数据存储在复制缓冲区中

从节点同步主节点数据完成后,主节点将缓冲区的数据继续发送给从节点,用于部分复制

主节点响应写命令时,不但会把命令传播给从节点,还会写入复制积压缓冲区,用于复制链路抖动后的补发

Psync执行流程.png

Psync(支持全量和部分复制)的执行流程中,从节点发送 psync[runId][offset] 命令后,主节点有三种响应:

  1. FULLRESYNC:第一次连接,进行全量复制
  2. CONTINUE:进行部分复制
  3. ERR:不支持 psync 命令,进行全量复制

主从全量复制过程.png

全量复制流程主要包括以下几步:

  1. 从节点发送 psync ? -1 命令(因为第一次发送,不知道主节点的 runId,所以为?,因为是第一次复制,所以 offset=-1)。
  2. 主节点发现从节点是第一次复制,返回 FULLRESYNC {runId} {offset},runId 是主节点的 runId,offset 是主节点目前的 offset。
  3. 从节点接收主节点信息后,保存到 info 中。
  4. 主节点在发送 FULLRESYNC 后,启动 bgsave 命令,生成 RDB 文件(数据持久化)。主节点发送 RDB 文件给从节点。到从节点加载数据完成这段期间主节点的写命令放入缓冲区。
  5. 从节点清理自己的数据库数据。
  6. 从节点加载 RDB 文件,将数据保存到自己的数据库中。如果从节点开启了 AOF,从节点会异步重写 AOF 文件

部分复制的过程:

  1. 部分复制主要是 Redis 针对全量复制的过高开销做出的一种优化措施,使用 psync[runId][offset] 命令实现

当从节点正在复制主节点时,如果出现网络闪断或者命令丢失等异常情况时,从节点会向主节点要求补发丢失的命令数据,主节点的复制积压缓冲区将这部分数据直接发送给从节点。

这样就可以保持主从节点复制的一致性。补发的这部分数据一般远远小于全量数据。

  1. 主从连接中断期间主节点依然响应命令,但因复制连接中断命令无法发送给从节点,不过主节点内的复制积压缓冲区依然可以保存最近一段时间的写命令数据。

  2. 当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行 ID。因此会把它们当做 psync 参数发送给主节点,要求进行部分复制。

  3. 主节点接收到 PSYNC 命令后首先核对参数 runId 是否与自身一致,如果一致,说明之前复制的是当前主节点。

之后根据参数 offset 在复制积压缓冲区中查找,如果 offset 之后的数据仍然存在,则对从节点发送 +CONTINUE,表示可以进行部分复制;如果缓冲区已经覆盖了这段历史数据,就只能退化为全量复制。

  1. 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态

主从复制会存在哪些问题

  1. 一旦主节点宕机,从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令所有从节点去复制新的主节点,整个过程需要人工干预。
  2. 主节点的写能力受到单机的限制。
  3. 主节点的存储能力受到单机的限制。
  4. 如果复制链路中断且复制积压缓冲区无法覆盖缺失区间,就会退化为全量复制;在大实例下,这类全量复制的代价会比较明显

主从复制的问题主流解决方案:哨兵

哨兵架构图.png

Redis Sentinel(哨兵)主要功能包括主节点存活检测、主从运行情况检测、自动故障转移、主从切换。

Redis Sentinel 通常至少需要 3 个 Sentinel 实例,才能在故障判断和选主时形成比较稳定的多数派。

该系统可以执行以下四个任务:

  1. 监控:不断检查主服务器和从服务器是否正常运行
  2. 通知:当被监控的某个 Redis 服务器出现问题,Sentinel 通过 API 脚本向管理员或者其他应用程序发出通知
  3. 自动故障转移:当主节点不能正常工作时,Sentinel 会开始一次自动的故障转移操作,它会将与失效主节点是主从关系的其中一个从节点升级为新的主节点,并且将其他的从节点指向新的主节点,这样 人工干预就可以免了
  4. 配置提供者:在 Redis Sentinel 模式下,客户端应用在初始化时连接的是 Sentinel 节点集合,从中获取主节点的信息

哨兵工作原理

哨兵工作原理.png

  1. 每个 Sentinel 节点都会定期执行探测任务:它会以固定频率向主服务器、从服务器以及其他 Sentinel 实例发送 PING

哨兵工作原理2.png

  1. 如果一个实例距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 所指定的值,那么这个实例会被 Sentinel 标记为主观下线(如上图)

哨兵工作原理3.png

  1. 如果一个主服务器被标记为主观下线,那么正在监视它的其他 Sentinel 节点会参与确认

哨兵工作原理4.png

  1. 如果在指定时间窗口内有足够数量的 Sentinel 同意这一判断,那么主服务器会被标记为客观下线

哨兵工作原理5.png

  1. 一般情况下,每个 Sentinel 会以每 10 秒一次的频率向它已知的所有主服务器和从服务器发送 INFO 命令 当一个主服务器被标记为客观下线时,Sentinel 向下线主服务器的所有从服务器发送 INFO 命令的频率,会从 10 秒一次改为每秒一次

哨兵工作原理6.png

  1. Sentinel 会围绕已经客观下线的主节点发起选举,由某个 Sentinel 成为故障转移的领导者,并把一个合适的副本提升为新的主节点,再把其余副本重新指向它

哨兵工作原理7.png

  1. 当没有足够数量的 Sentinel 同意主服务器下线时,主服务器的客观下线状态会被移除

当主服务器重新对 Sentinel 的 PING 返回有效回复时,主观下线状态也会被移除


Redis 单例、主从、Sentinel 与 Cluster 的配置方式及优缺点对比

Redis 单例的安装和使用

Redis 的安装相对直接。下载源码后解压,进入 Redis 目录执行如下命令即可完成编译安装:

make install

执行 make 前需要准备好编译环境,例如 gccmake 等基础工具。Redis 中比较重要的可执行文件位于安装目录的 src 子目录下,例如 redis-serverredis-sentinelredis-cli

编译完成之后,在 src 目录下执行 ./redis-server 启动 Redis,然后新开一个终端执行 ./redis-cli 连接服务。执行如下命令即可验证是否安装成功:

1
2
3
4
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> get hello
"world"

按照上述方式启动 Redis 时,默认监听本机地址 127.0.0.1 和端口 6379,其余配置采用默认值。相关参数可以在 Redis 安装目录下的 redis.conf 中查看。如果需要按指定配置文件启动,可以在 redis-server 后追加配置文件名,例如:

./src/redis-server redis.conf

另外,上述 redis-cli 如果不带参数,默认连接的地址就是 127.0.0.1:6379。如果需要连接指定 IP 和端口,可以使用如下方式:

./src/redis-cli -h 127.0.0.1 -p 6379

其中 -h 表示目标 IP,-p 表示目标端口。


Redis 主从模式的配置

Redis 单例提供了基础的数据存储能力和丰富的命令集合,但把所有数据都放在单个 Redis 节点上,会遇到数据备份、容量上限和读写压力集中的问题。主从模式正是为这些问题提供的第一层解决方案。

主从模式指的是使用一个 Redis 实例作为主节点,其余实例作为副本节点。主节点负责写入,副本节点负责同步主节点数据并承担部分读请求。

主从模式可以较好地解决数据备份问题,并通过读写分离缓解主节点压力。如下所示,主机 redis-A 分别有 redis-B、redis-C、redis-D、redis-E 四个从机:

redis主从模式.png

主从模式本质上仍然是多个 Redis 实例,只不过通过配置显式声明了实例之间的主从关系。

每个 Redis 实例都需要占用一个本机端口。主从模式最核心的配置点有两个:当前实例监听哪个端口,以及它是否要复制某个主节点。通常可以复制多份 redis.conf,分别命名为 6379.conf6380.conf6381.conf。如下是端口为 6379 的主节点主要配置:

1
2
3
4
bind 127.0.0.1
port 6379
logfile "6379.log"
dbfilename "dump-6379.rdb"

如下是端口为 6380 和 6381 的 从机的配置

1
2
3
4
5
bind 127.0.0.1
port 6380
logfile "6380.log"
dbfilename "dump-6380.rdb"
replicaof 127.0.0.1 6379
1
2
3
4
5
bind 127.0.0.1
port 6381
logfile "6381.log"
dbfilename "dump-6381.rdb"
replicaof 127.0.0.1 6379

端口为 6380 和 6381 的实例被配置为端口为 6379 的实例的从机。配置完成后使用 redis-server 分别执行如下命令启动三个实例:

1
2
3
./src/redis-server 6379.conf
./src/redis-server 6380.conf
./src/redis-server 6381.conf

启动之后分别开启三个命令行工具分别执行以下命令连接 redis 实例:

1
2
3
./src/redis-cli -p 6379
./src/redis-cli -p 6380
./src/redis-cli -p 6381

分别在三个命令行工具中执行一个 get 命令,获取键名为 msg 的数据,如下所示:

1
2
127.0.0.1:6379> get msg
(nil)
1
2
127.0.0.1:6380> get msg
(nil)
1
2
127.0.0.1:6381> get msg
(nil)

在三个 redis 实例中都不存在键为 msg 的数据,现在在主机 6379 上设置一个键为 msg 的数据,如下所示:

1
2
127.0.0.1:6379> set msg "hello"
OK

接着在 6380 和 6381 的实例上执行 get msg 命令:

1
2
127.0.0.1:6380> get msg
"hello"
1
2
127.0.0.1:6381> get msg
"hello"

另外,如果不在配置文件中指定主从节点的关系,也可以在启动相关 Redis 实例之后使用 replicaof 命令把当前节点设置为某个主节点的副本,如:

1
127.0.0.1:6380> replicaof 127.0.0.1 6379

Redis 中哨兵 Sentinel 配置

Redis 主从模式解决了数据备份和部分性能问题,但也引入了新的运维复杂度。客户端需要知道当前应该连接哪个主节点,一旦主节点故障,原生主从复制并不会自动把客户端切到新的主节点。

另外,主节点故障后,副本节点虽然还在,但需要有人负责判断故障、执行提升和重配置其余副本;这正是 Sentinel 要解决的问题。

为了解决这两个问题,Redis 在 2.8 之后正式提供了 Sentinel 架构。

每个 Sentinel 节点本质上也是一个特殊用途的 Redis 进程,但它的职责不是存储业务数据,而是监控 Redis 数据节点、协商主节点状态并在故障时完成自动切换。多个 Sentinel 组成一个监控集合,用来共同判定某个主节点是否真的失效。

redis哨兵模式.png

从图中可以看出,对于一组主从节点,sentinel 只是在其外部额外添加的一组用于监控作用的 redis 实例。在主从节点和 sentinel 节点集合配置好之后,sentinel 节点之间会相互发送消息,以检测其余 sentinel 节点是否正常工作,并且 sentinel 节点也会向主从节点发送消息,以检测监控的主从节点是否正常工作

Sentinel 架构的核心价值就在于把“主从模式下的故障转移”自动化。某个 Sentinel 在超时时间内收不到主节点回复时,会先把主节点标记为主观下线,然后再和其他 Sentinel 协商是否达到客观下线条件。

当达到客观下线条件后,Sentinel 会在副本中挑选一个更合适的节点,执行 replicaof no one 将其提升为新的主节点,再把其余副本重新指向新主节点。

故障转移完成后,Sentinel 仍然会持续监控原主节点。如果原主节点重新上线,通常会被重新配置成新主节点的副本。

每个 Sentinel 节点在本质上仍然是一个 Redis 实例,只不过其主要职责是监控 Redis 数据节点。Redis 安装目录下通常会提供默认的 sentinel.conf,和配置主从节点类似,可以复制三份配置文件:sentinel-26379.confsentinel-26380.confsentinel-26381.conf。配置示例如下:

1
2
3
4
5
6
7
8
port 26379  
daemonize yes  
logfile "26379.log"  
dir /opt/soft/redis/data  
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 30000  
sentinel parallel-syncs mymaster 1  
sentinel failover-timeout mymaster 180000

对于端口为 2638026381 的 Sentinel,其配置方式相同,只需要把相应端口号改成对应值即可。需要注意两点:

  1. sentinel monitor mymaster 127.0.0.1 6379 2 中的 2 表示做客观下线判断所需的 quorum
  2. sentinel myid 一般由 Sentinel 首次启动后自动生成,通常不需要手工写入配置文件

配置完成后首先启动三个主从节点,然后分别使用三个配置文件使用如下命令启用sentinel:

1
2
3
./src/redis-sentinel sentinel-26379.conf
./src/redis-sentinel sentinel-26380.conf
./src/redis-sentinel sentinel-26381.conf

由于 sentinel 节点也是一个 redis 实例,因而可以通过如下命令使用 redis-cli 连接 sentinel 节点:

1
./src/redis-cli -p 26379

连上 sentinel 节点之后可以通过如下命令查看 sentinel 状态:

1
127.0.0.1:26379> info sentinel

结果:

1
2
3
4
5
6
7
# Sentinel
sentinel_masters:1
sentinel_tilt:0
sentinel_running_scripts:0
sentinel_scripts_queue_length:0
sentinel_simulate_failure_flags:0
master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3

此时 Sentinel 已经识别出 1 个主节点、2 个从节点以及 3 个 Sentinel 实例。启动完成之后,可以通过主动下线主节点来模拟 Sentinel 的故障转移过程。首先连接端口为 6379 的主节点,使用如下命令查看主从节点状态:

1
127.0.0.1:6379> info replication

结果:

1
2
3
4
5
6
7
8
9
10
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6380,state=online,offset=45616,lag=1
slave1:ip=127.0.0.1,port=6381,state=online,offset=45616,lag=1
master_repl_offset:45616
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:45615

此时主节点有两个从节点,端口分别为 63806381。然后对主节点执行如下命令:

1
127.0.0.1:6379> shutdown save

然后连接上端口号为 6380 的从节点,并执行如下命令:

1
127.0.0.1:6380> info replication 

结果如下:

1
2
3
4
5
6
7
8
9
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6381,state=online,offset=12344,lag=0
master_repl_offset:12477
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:12476

当端口为 6379 的实例下线之后,端口为 6380 的实例被重新竞选为新的主节点,并且端口为6381 的实例被设置为 6380 的实例的从节点。如果此时重新启用端口为 6379 的节点,然后再查看主从状态,结果如下:

1
2
3
4
5
6
7
8
9
10
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6381,state=online,offset=59918,lag=0
slave1:ip=127.0.0.1,port=6379,state=online,offset=59918,lag=1
master_repl_offset:60051
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:60050

端口为 6379 的 Redis 实例重新连接后,Sentinel 会检测到它已恢复,并把它重新配置为新主节点的从节点。


Redis Cluster 集群的配置

Redis Cluster 是 Redis 3.0 引入的原生分片方案,主要用来解决单机内存、单机吞吐和节点高可用方面的瓶颈。

从职责上看,Sentinel 更偏向“主从复制 + 自动故障转移”,而 Cluster 进一步把“数据分片”也做成了 Redis 自己的一等能力。

Redis Cluster 中的数据和槽位(slot)绑定。整个集群固定定义 16384 个槽,key 会通过 CRC16(key) % 16384 映射到某个槽,再由负责该槽的节点存储和处理请求。

比如启动了三个主节点:cluster-A、cluster-B 和 cluster-C,可以把 0-5460 号槽分配给 A,把 5461-10922 号槽分配给 B,把 10923-16383 号槽分配给 C。客户端访问某个 key 时,Redis 会先算出该 key 属于哪个槽,再把请求路由到负责这个槽的节点。

Redis Cluster 很容易和“一致性哈希”混淆,但它并不是传统的哈希环方案,而是固定槽位 + 槽位迁移。它的好处在于:

  • 集群扩缩容时,迁移的是槽,而不是让所有 key 重新分布
  • 槽位总数固定,节点变化时更容易观察和控制数据迁移范围
  • 客户端能够根据 MOVED/ASK 重定向感知请求应该落到哪个节点

此外,Redis Cluster 还支持 hash tag。如果 key 中包含 {...},Redis 只会对花括号里的内容做槽位计算,这让多个相关 key 可以被强制落到同一个槽中,便于执行多 key 操作。

对于 redis 集群的配置,首先将 redis 安装目录下的 redis.conf 文件复制六份,分别取名为:cluster-6379.conf、cluster-6380.conf、cluster-6381.conf、cluster-6382.conf、cluster-6383.conf、cluster-6384.conf

对于一个高可用集群方案,每个主节点通常都会分配一个从节点,以防止数据节点因为故障下线。示例中使用六份配置文件定义六个 Redis 实例,其中三个作为主节点,剩余三个分别作为其从节点。以下是一份配置文件中需要修改的参数:

1
2
3
4
5
6
7
8
port 6379
cluster-enabled yes
cluster-node-timeout 15000
cluster-config-file "nodes-6379.conf"
pidfile /var/run/redis_6379.pid
logfile "cluster-6379.log"
dbfilename dump-cluster-6379.rdb
appendfilename "appendonly-cluster-6379.aof"

对于其余的配置文件,只需要将其中对应项的端口号和带有端口号的文件名修改为当前要指定的端口号和端口号的文件名即可

实际搭建集群时,更常见的做法是直接使用 redis-cli --cluster create 完成建群和主从分配;手工执行 cluster meetcluster addslots 更适合用来理解底层过程。

配置文件配置好之后使用如下命令启动集群中的每个实例:

1
2
3
4
5
6
./src/redis-server cluster-6379.conf
./src/redis-server cluster-6380.conf
./src/redis-server cluster-6381.conf
./src/redis-server cluster-6382.conf
./src/redis-server cluster-6383.conf
./src/redis-server cluster-6384.conf

上述配置文件中,当前配置和启动过程中并没有指定这 六个实例的主从关系,也没有对 16384 个槽位进行分配。因此还需要进一步配置。手工方式的一种做法是先使用 redis-cli 连接到集群节点,再通过 cluster meet 命令把其他节点加入进来,例如先连接到 6379 端口节点:

./src/redis-cli -p 6379

连接上后使用 cluster meet 命令分别连接其余节点:

1
2
3
4
5
127.0.0.1:6379>cluster meet 127.0.0.1 6380
127.0.0.1:6379>cluster meet 127.0.0.1 6381
127.0.0.1:6379>cluster meet 127.0.0.1 6382
127.0.0.1:6379>cluster meet 127.0.0.1 6383
127.0.0.1:6379>cluster meet 127.0.0.1 6384 

连接好后可以使用 cluster nodes 命令查看当前集群状态:

1
2
3
4
5
6
7
127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 master - 0 1468073975551 5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 master - 0 1468073978579 4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 master - 0 1468073980598 3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468073974541 1 connected
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468073979589 2 connected

此时六个节点虽然都已经加入集群,但还不能真正对外提供完整服务,因为 16384 个槽还没有分配到主节点中。槽位分配的思路如下:

1
2
3
4
5
6
# 6379 负责 0-5460
# 6380 负责 5461-10922
# 6381 负责 10923-16383
#
# 实际执行 cluster addslots 时需要把槽号展开为具体整数列表,
# 生产环境通常借助 redis-cli 的建群能力或脚本批量完成

添加完槽位后可使用 cluster info 命令查看当前集群状态:

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:5
cluster_my_epoch:0
cluster_stats_messages_sent:4874
cluster_stats_messages_received:4726 

16384 个虚拟槽位分配给三个主节点之后,剩余三个节点可以通过如下命令配置为它们的从节点,从而形成更完整的高可用结构:

1
2
3
4
5
6
127.0.0.1:6382>cluster replicate cfb28ef1deee4e0fa78da86abe5d24566744411e
OK
127.0.0.1:6383>cluster replicate 8e41673d59c9568aa9d29fb174ce733345b3e8f1
OK
127.0.0.1:6384>cluster replicate 40b8d09d44294d2e23c7c768efc8fcd153446746
OK 

完成这些步骤后,所有集群节点就配置完毕并处于可用状态。此时可以使用 cluster nodes 命令查看当前节点状态:

1
2
3
4
5
6
7
127.0.0.1:6379> cluster nodes
4fa7eac4080f0b667ffeab9b87841da49b84a6e4 127.0.0.1:6384 slave 40b8d09d44294d2e23c7c768efc8fcd153446746 0 1468076865939 5 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 0 connected 0-5461
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa9d29fb174ce733345b3e8f1 0 1468076868966 4 connected
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa78da86abe5d24566744411e 0 1468076869976 3 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1468076870987 1 connected 5462-10922
40b8d09d44294d2e23c7c768efc8fcd153446746 127.0.0.1:6381 master - 0 1468076867957 2 connected 10923-16383

使用 redis-cli 使用如下命令连接集群:

./src/redis-cli -c -p 6380

注意连接集群模式的 redis 实例时需要加上参数 -c ,表示连接的是集群模式的实例。连接上后执行 get 命令:

1
2
3
127.0.0.1:6380> get hello
-> Redirected to slot [866] located at 127.0.0.1:6379
(nil)

6380 端口实例上执行 GET 命令时,客户端会先根据 key 算出对应槽位;如果该槽不归当前节点负责,就会被重定向到目标节点执行命令。


分片插槽深度理解

可以把 16384 个槽理解为 Redis 预先切好的 16384 个逻辑分区。

如果有 3 个主节点,那么它们各自负责其中一部分槽位。

如果总数据量是 15G,那么在分布相对均匀的情况下,可以近似理解成每个主节点承担 5G 左右的数据。

将Redis数据分布到不同机器上,根本目的是为了用多台普通硬件的组合,去解决单台顶级硬件也无法解决的超大容量、超高并发和高可用性问题


水平扩容时,本质上是把一部分槽位从旧节点迁移到新节点,并同步这部分槽对应的数据。


每个主节点最好都配置从节点。这样主节点故障时,可以由其副本提升为新的主节点,并继承原来的槽位归属,不需要重新设计整套分片布局。