NoSQL 几乎是必备的知识,其中 Redis 大概是适用范围最广的非关系型数据库,应该也是我个人接触比较早,了解比较多的,因此整理下 Redis 的相关知识点。先从比较基础的内容整理开始,后续更多复杂和深入的内容考虑慢慢补充或者再开专门的文章来整理。
简述
直接抄来的一些关于 Redis 的简介:
Redis 是一个高性能的 key-value 数据库。
Redis 有以下优势:
- 性能极高 – 我在本机上用自带的 redis-benchmark 测试,读写能超过 15 万次每秒 。
- 丰富的数据类型 – Redis 支持二进制的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
- 原子 – Redis 的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过 MULTI 和 EXEC 指令包起来。
- 丰富的特性 – Redis 还支持 publish/subscribe, 通知, key 过期等等特性。
应用
Redis 能用来做什么?互联网公司一般怎么使用它?
除了常见的缓存(cache),Redis 能做的还有很多,比如:
- 分布式锁:基于 Redis 的分布式锁实现
- 排序:sort 命令
- 去重:小数据量可以直接用 Redis 的 Set 来去重,数据量非常大而且不要是百分百的精准可以考虑用布隆过滤器。
- 计数器、限速器:基于lua 脚本和
incr()
- 使用 bitmap 实现用户上线次数统计
数据结构
Redis 有 5 种常用的基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈 希) 和 zset (有序集合)。熟练掌握这 5 种基本数据结构的使用是 Redis 知识最基础也最重要的部分,它也是在 Redis 面试题中问到最多的内容。除此之外,Redis 官网上还提到了三种数据类型:Bit arrays 、HyperLogLogs 和 Streams。
key
官网上这么介绍 Redis 的 key:
Redis 密钥是二进制安全的,这意味着您可以使用任何二进制序列作为密钥,从“ foo”之类的字符串到 JPEG 文件的内容。空字符串也是有效的键。有关密钥的其他一些规则:
- 太长的密钥不是一个好主意。例如,一个 1024 字节的密钥是一个坏主意,不仅是内存方面的问题,而且因为在数据集中查找密钥可能需要进行一些代价高昂的密钥比较。即使当手头的任务是匹配一个大值的存在时,对它进行散列(例如使用SHA1)也是一个更好的主意,尤其是从内存和带宽的角度来看。
- 非常短的键通常不是一个好主意。如果您可以改写“ user:1000:followers”,那么将“ u1000flw”作为密钥写的毫无意义。与键对象本身和值对象使用的空间相比,后者更具可读性,并且添加的空间较小。虽然短键显然会消耗更少的内存,但您的工作是找到合适的平衡。
- 尝试坚持使用架构。例如,“ object-type:id”是一个好主意,例如“ user:1000”。点或破折号通常用于多字字段,例如“ comment:123:reply.to”或“ comment:123:reply-to”中。
- 允许的最大密钥大小为512 MB。
key 是所有数据类型通用的键,关于 key 的操作有如下几个:
- DEL key 删除给定的一个或多个
key
。不存在的key
会被忽略。 - DUMP key 序列化给定
key
,并返回被序列化的值,使用 RESTORE 命令可以将这个值反序列化为 Redis 键。 - EXISTS key 检查键是否存在。若 key 存在,返回1,否则返回0。
- EXPIRE key seconds 为给定
key
设置生存时间,当key
过期时(生存时间为0
),它会被自动删除。注意: - EXPIREAT key timestamp EXPIREAT 的作用和 EXPIRE 类似,都用于为
key
设置生存时间。不同在于 EXPIREAT 命令接受的时间参数是 UNIX 时间戳(unix timestamp)。 - PEXPIRE key milliseconds 这个命令和 EXPIRE 命令的作用类似,但是它以毫秒为单位设置
key
的生存时间,而不像 EXPIRE 命令那样,以秒为单位。 - PEXPIREAT key milliseconds-timestamp 这个命令和 EXPIREAT 命令类似,但它以毫秒为单位设置
key
的过期 unix 时间戳,而不是像 EXPIREAT 那样,以秒为单位。 - TTL key 以秒为单位,返回给定
key
的剩余生存时间(TTL, time to live)。 - PTTL key 这个命令类似于 TTL 命令,但它以毫秒为单位返回
key
的剩余生存时间,而不是像 TTL 命令那样,以秒为单位。 - KEYS pattern 查找所有符合给定模式
pattern
的key
。 - MIGRATE host port key destination-db timeout [COPY] [REPLACE] 将
key
原子性地从当前实例传送到目标实例的指定数据库上,一旦传送成功,key
保证会出现在目标实例上,而当前实例上的key
会被删除。这个命令是一个原子操作,它在执行的时候会阻塞进行迁移的两个实例,直到以下任意结果发生:迁移成功,迁移失败,等到超时。 - MOVE key db 将当前数据库的
key
移动到给定的数据库db
当中。如果当前数据库(源数据库)和给定数据库(目标数据库)有相同名字的给定key
,或者key
不存在于当前数据库,那么MOVE
没有任何效果。 - OBJECT subcommand [arguments [arguments] OBJECT 命令允许从内部察看给定
key
的 Redis 对象。 - PERSIST key 移除给定
key
的生存时间,将这个key
从『易失的』(带生存时间key
)转换成『持久的』(一个不带生存时间、永不过期的key
)。 - RANDOMKEY 从当前数据库中随机返回(不删除)一个
key
。 - RENAME key newkey 将
key
改名为newkey
。当key
和newkey
相同,或者key
不存在时,返回一个错误。当newkey
已经存在时, RENAME 命令将覆盖旧值。 - RENAMENX key newkey 当且仅当
newkey
不存在时,将key
改名为newkey
。当key
不存在时,返回一个错误。 - RESTORE key ttl serialized-value 反序列化给定的序列化值,并将它和给定的
key
关联。参数ttl
以毫秒为单位为key
设置生存时间;如果ttl
为0
,那么不设置生存时间。 - SORT key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern …]] [ASC | DESC] [ALPHA] [STORE destination] 返回或保存给定列表、集合、有序集合
key
中经过排序的元素。排序默认以数字作为对象,值被解释为双精度浮点数,然后进行比较。因为 SORT 命令默认排序对象为数字, 当需要对字符串进行排序时, 需要显式地在 SORT 命令之后添加ALPHA
修饰符。 - TYPE key 返回
key
所储存的值的类型。
string (字符串)
字符串 string 是 Redis 最简单的数据结构。Redis 所有的数据结构都是以唯一的 key 字符串作为名称,然后通过这个唯一 key 值来获取相应的 value 数据。
字符串结构使用非常广泛,一个常见的用途就是缓存用户信息。我们将用户信息结构体 使用 JSON 序列化成字符串,然后将序列化后的字符串塞进 Redis 来缓存。
Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时, 扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是,字符串最大长度为 512M。
Redis 中字符串对象的编码可以是 int
,raw
或者 embstr
中的某一种,分别如下:
- int 编码:保存long 型的64位有符号整数,系统会缓存包含了 1~1000 数字的对象,相同对象会指向同一个地址。
- embstr 编码:保存长度小于44字节的字符串,和字符串对象的其他元信息保存在一段连续的内存地址中。
- raw 编码:保存长度大于44字节的字符串,和字符串对象的其他元信息分开保存。
Hash(哈希)
Hash 是一个键值(key => value)对集合。Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。 常用命令:hget,hset,hgetall 等。
Hash 的底层数据结构有两种表示:ziplist 和 hashtable。当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
- 哈希对象保存的键值对数量小于 512 个;
hash 对象使用ziplist 保存时,程序会将保存了键的ziplist节点推入到列表的表尾,然后再将保存了值的ziplist节点推入列表的表尾。使用这种方式保存时,并不需要申请多余的内存空间,而且每个Key都要存储一些关联的系统信息(如过期时间、LRU等),因此和String类型的Key/Value相比,Hash类型极大的减少了Key的数量(大部分的Key都以Hash字段的形式表示并存储了),从而进一步优化了存储空间的使用效率
hashtable 编码的哈希对象使用字典作为底层实现时, 哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
- 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。
List(列表)
List 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。
实现方式:Redis中的列表对象在版本3.2之前,列表底层的编码是 ziplist 和 linkedlist 实现的,但是在版本 3.2 之后,重新引入了一个 quicklist 的数据结构,列表的底层都由 quicklist 实现。在早期的设计中, 当列表对象中元素的长度比较小或者数量比较少的时候,采用 ziplist 来存储,当列表对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表 linkedlist 来存储。
这两种存储方式的优缺点
- 双向链表 linkedlist 便于在表的两端进行 push 和 pop 操作,在插入节点上复杂度很低,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
- ziplist 存储在一段连续的内存上,所以存储效率很高。但是,它不利于修改操作,插入和删除操作需要频繁的申请和释放内存。特别是当 ziplist 长度很长的时候,一次 realloc 可能会导致大批量的数据拷贝。
Set(集合)
Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
Sorted Set(有序集合)
Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。Redis 正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。
集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 2^32 - 1 (4294967295, 每个集合可存储40多亿个成员)。
Bitmaps(位图) and HyperLogLogs(?这个怎么翻译)
Redis 还支持位图和 HyperLogLogs,它们实际上是基于 String 基本类型的数据类型,但是具有自己的语义。 可以参考官网中introduction to Redis data types以获取有关这些类型的信息。
其他功能
Transaction(事务)
MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事务的基础。
事务可以一次执行多个命令, 并且带有以下两个重要的保证:
事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
EXEC 命令负责触发并执行事务中的所有命令:
- 如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。
- 另一方面,如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。
当使用 AOF 方式做持久化的时候, Redis 会使用单个
write(2)
命令将事务写入到磁盘中。然而,如果 Redis 服务器因为某些原因被管理员杀死,或者遇上某种硬件故障,那么可能只有部分事务命令会被成功写入到磁盘中。
如果 Redis 在重新启动时发现 AOF 文件出了这样的问题,那么它会退出,并汇报一个错误。
使用
redis-check-aof
程序可以修复这一问题:它会移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动。
从 2.2 版本开始,Redis 还可以通过乐观锁(optimistic lock)实现 CAS (check-and-set)操作。
Replication(复制)
Redis 支持简单且易用的主从复制(master-slave replication)功能, 该功能可以让从服务器(slave server)成为主服务器(master server)的精确复制品。
以下是关于 Redis 复制功能的几个重要方面:
Redis 使用异步复制。 从 Redis 2.8 开始, 从服务器会以每秒一次的频率向主服务器报告复制流(replication stream)的处理进度。
一个主服务器可以有多个从服务器。
不仅主服务器可以有从服务器, 从服务器也可以有自己的从服务器, 多个从服务器之间可以构成一个图状结构。
复制功能不会阻塞主服务器: 即使有一个或多个从服务器正在进行初次同步, 主服务器也可以继续处理命令请求。
复制功能也不会阻塞从服务器: 只要在
redis.conf
文件中进行了相应的设置, 即使从服务器正在进行初次同步, 服务器也可以使用旧版本的数据集来处理命令查询。不过, 在从服务器删除旧版本数据集并载入新版本数据集的那段时间内, 连接请求会被阻塞。
你还可以配置从服务器, 让它在与主服务器之间的连接断开时, 向客户端发送一个错误。
复制功能可以单纯地用于数据冗余(data redundancy), 也可以通过让多个从服务器处理只读命令请求来提升扩展性(scalability): 比如说, 繁重的 [SORT key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern …]] [ASC | DESC] [ALPHA] [STORE destination]](http://redisdoc.com/database/sort.html#sort) 命令可以交给附属节点去运行。
可以通过复制功能来让主服务器免于执行持久化操作: 只要关闭主服务器的持久化功能, 然后由从服务器去执行持久化操作即可。
pub/sub(发布与订阅)
SUBSCRIBE
、UNSUBSCRIBE
和 PUBLISH
三个命令实现了发布与订阅信息泛型(Publish/Subscribe messaging paradigm), 在这个实现中, 发送者(发送信息的客户端)不是将信息直接发送给特定的接收者(接收信息的客户端), 而是将信息发送给频道(channel), 然后由频道将信息转发给所有对这个频道感兴趣的订阅者。
发送者无须知道任何关于订阅者的信息, 而订阅者也无须知道是那个客户端给它发送信息, 它只要关注自己感兴趣的频道即可。
对发布者和订阅者进行解构(decoupling), 可以极大地提高系统的扩展性(scalability), 并得到一个更动态的网络拓扑(network topology)。
比如说, 要订阅频道 foo
和 bar
, 客户端可以使用频道名字作为参数来调用 SUBSCRIBE
命令:
1 | redis> SUBSCRIBE foo bar |
当有客户端发送信息到这些频道时, Redis 会将传入的信息推送到所有订阅这些频道的客户端里面。
正在订阅频道的客户端不应该发送除 SUBSCRIBE
、UNSUBSCRIBE
之外的其他命令。 其中, SUBSCRIBE
可以用于订阅更多频道, 而 UNSUBSCRIBE
则可以用于退订已订阅的一个或多个频道。
SUBSCRIBE
的执行结果会以信息的形式返回, 客户端可以通过分析所接收信息的第一个元素, 从而判断所收到的内容是一条真正的信息, 还是 SUBSCRIBE
或UNSUBSCRIBE
命令的操作结果。
信息的第一个元素标识了信息的类型:
subscribe
: 表示当前客户端成功地订阅了信息第二个元素所指示的频道。 而信息的第三个元素则记录了目前客户端已订阅频道的总数。unsubscribe
: 表示当前客户端成功地退订了信息第二个元素所指示的频道。 信息的第三个元素记录了客户端目前仍在订阅的频道数量。 当客户端订阅的频道数量降为0
时, 客户端不再订阅任何频道, 它可以像往常一样, 执行任何 Redis 命令。message
: 表示这条信息是由某个客户端执行 PUBLISH channel message 命令所发送的, 真正的信息。 信息的第二个元素是信息来源的频道, 而第三个元素则是信息的内容。
编程示例
Pieter Noordhuis 提供了一个使用 EventMachine 和 Redis 编写的 高性能多用户网页聊天软件 , 这个软件很好地展示了发布与订阅功能的用法。
Sentinel(哨兵)
Redis 的 Sentinel 系统用于管理多个 Redis 服务器(instance), 该系统执行以下三个任务:
- 监控(Monitoring): Sentinel 会不断地检查你的主服务器和从服务器是否运作正常。
- 提醒(Notification): 当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
- 自动故障迁移(Automatic failover): 当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中一个从服务器升级为新的主服务器, 并让失效主服务器的其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。
Redis Sentinel 是一个分布式系统, 你可以在一个架构中运行多个 Sentinel 进程(progress), 这些进程使用流言协议(gossip protocols)来接收关于主服务器是否下线的信息, 并使用投票协议(agreement protocols)来决定是否执行自动故障迁移, 以及选择哪个从服务器作为新的主服务器。
虽然 Redis Sentinel 释出为一个单独的可执行文件 redis-sentinel
, 但实际上它只是一个运行在特殊模式下的 Redis 服务器, 你可以在启动一个普通 Redis 服务器时通过给定 --sentinel
选项来启动 Redis Sentinel 。
持久化
Redis 提供了多种不同级别的持久化方式:
- RDB 持久化可以在指定的时间间隔内生成数据集的时间点快照(point-in-time snapshot)。
- AOF 持久化记录服务器执行的所有写操作命令,并在服务器启动时,通过重新执行这些命令来还原数据集。 AOF 文件中的命令全部以 Redis 协议的格式来保存,新命令会被追加到文件的末尾。 Redis 还可以在后台对 AOF 文件进行重写(rewrite),使得 AOF 文件的体积不会超出保存数据集状态所需的实际大小。
- Redis 还可以同时使用 AOF 持久化和 RDB 持久化。 在这种情况下, 当 Redis 重启时, 它会优先使用 AOF 文件来还原数据集, 因为 AOF 文件保存的数据集通常比 RDB 文件所保存的数据集更完整。
- 你甚至可以关闭持久化功能,让数据只在服务器运行时存在。
了解 RDB 持久化和 AOF 持久化之间的异同是非常重要的, 以下几个小节将详细地介绍这这两种持久化功能, 并对它们的相同和不同之处进行说明。
RDB 的优点
- RDB 是一个非常紧凑(compact)的文件,它保存了 Redis 在某个时间点上的数据集。 这种文件非常适合用于进行备份: 比如说,你可以在最近的 24 小时内,每小时备份一次 RDB 文件,并且在每个月的每一天,也备份一个 RDB 文件。 这样的话,即使遇上问题,也可以随时将数据集还原到不同的版本。
- RDB 非常适用于灾难恢复(disaster recovery):它只有一个文件,并且内容都非常紧凑,可以(在加密后)将它传送到别的数据中心,或者亚马逊 S3 中。
- RDB 可以最大化 Redis 的性能:父进程在保存 RDB 文件时唯一要做的就是
fork
出一个子进程,然后这个子进程就会处理接下来的所有保存工作,父进程无须执行任何磁盘 I/O 操作。 - RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
RDB 的缺点
- 如果你需要尽量避免在服务器故障时丢失数据,那么 RDB 不适合你。 虽然 Redis 允许你设置不同的保存点(save point)来控制保存 RDB 文件的频率, 但是, 因为RDB 文件需要保存整个数据集的状态, 所以它并不是一个轻松的操作。 因此你可能会至少 5 分钟才保存一次 RDB 文件。 在这种情况下, 一旦发生故障停机, 你就可能会丢失好几分钟的数据。
- 每次保存 RDB 的时候,Redis 都要
fork()
出一个子进程,并由子进程来进行实际的持久化工作。 在数据集比较庞大时,fork()
可能会非常耗时,造成服务器在某某毫秒内停止处理客户端; 如果数据集非常巨大,并且 CPU 时间非常紧张的话,那么这种停止时间甚至可能会长达整整一秒。 虽然 AOF 重写也需要进行fork()
,但无论 AOF 重写的执行间隔有多长,数据的耐久性都不会有任何损失。
AOF 的优点
- 使用 AOF 持久化会让 Redis 变得非常耐久(much more durable):你可以设置不同的
fsync
策略,比如无fsync
,每秒钟一次fsync
,或者每次执行写入命令时fsync
。 AOF 的默认策略为每秒钟fsync
一次,在这种配置下,Redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据(fsync
会在后台线程执行,所以主线程可以继续努力地处理命令请求)。 - AOF 文件是一个只进行追加操作的日志文件(append only log), 因此对 AOF 文件的写入不需要进行
seek
, 即使日志因为某些原因而包含了未写入完整的命令(比如写入时磁盘已满,写入中途停机,等等),redis-check-aof
工具也可以轻易地修复这种问题。 - Redis 可以在 AOF 文件体积变得过大时,自动地在后台对 AOF 进行重写: 重写后的新 AOF 文件包含了恢复当前数据集所需的最小命令集合。 整个重写操作是绝对安全的,因为 Redis 在创建新 AOF 文件的过程中,会继续将命令追加到现有的 AOF 文件里面,即使重写过程中发生停机,现有的 AOF 文件也不会丢失。 而一旦新 AOF 文件创建完毕,Redis 就会从旧 AOF 文件切换到新 AOF 文件,并开始对新 AOF 文件进行追加操作。
- AOF 文件有序地保存了对数据库执行的所有写入操作, 这些写入操作以 Redis 协议的格式保存, 因此 AOF 文件的内容非常容易被人读懂, 对文件进行分析(parse)也很轻松。 导出(export) AOF 文件也非常简单: 举个例子, 如果你不小心执行了 FLUSHALL 命令, 但只要 AOF 文件未被重写, 那么只要停止服务器, 移除 AOF 文件末尾的 FLUSHALL 命令, 并重启 Redis , 就可以将数据集恢复到 FLUSHALL 执行之前的状态。
AOF 的缺点
- 对于相同的数据集来说,AOF 文件的体积通常要大于 RDB 文件的体积。
- 根据所使用的
fsync
策略,AOF 的速度可能会慢于 RDB 。 在一般情况下, 每秒fsync
的性能依然非常高, 而关闭fsync
可以让 AOF 的速度和 RDB 一样快, 即使在高负荷之下也是如此。 不过在处理巨大的写入载入时,RDB 可以提供更有保证的最大延迟时间(latency)。 - AOF 在过去曾经发生过这样的 bug : 因为个别命令的原因,导致 AOF 文件在重新载入时,无法将数据集恢复成保存时的原样。 (举个例子,阻塞命令 BRPOPLPUSH source destination timeout 就曾经引起过这样的 bug 。) 测试套件里为这种情况添加了测试: 它们会自动生成随机的、复杂的数据集, 并通过重新载入这些数据来确保一切正常。 虽然这种 bug 在 AOF 文件中并不常见, 但是对比来说, RDB 几乎是不可能出现这种 bug 的。
RDB 和 AOF ,应该用哪一个?
一般来说, 如果想达到足以媲美 PostgreSQL 的数据安全性, 你应该同时使用两种持久化功能。
如果你非常关心你的数据, 但仍然可以承受数分钟以内的数据丢失, 那么你可以只使用 RDB 持久化。
有很多用户都只使用 AOF 持久化, 但我们并不推荐这种方式: 因为定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快, 除此之外, 使用 RDB 还可以避免之前提到的 AOF 程序的 bug 。
集群
集群部分的内容官方文档介绍的比较详细了,这里直接大段抄自官方文档的中文翻译,Redis集群规范和Redis集群教程章节。
什么是 Redis 集群?
Redis 集群是一个分布式(distributed)、容错(fault-tolerant)的 Redis 实现, 集群可以使用的功能是普通单机 Redis 所能使用的功能的一个子集(subset)。
Redis 集群中不存在中心(central)节点或者代理(proxy)节点, 集群的其中一个主要设计目标是达到线性可扩展性(linear scalability)。
Redis 集群为了保证一致性(consistency)而牺牲了一部分容错性: 系统会在保证对网络断线(net split)和节点失效(node failure)具有有限(limited)抵抗力的前提下, 尽可能地保持数据的一致性。
Redis 集群是一个可以在多个 Redis 节点之间进行数据共享的设施(installation)。
Redis 集群不支持那些需要同时处理多个键的 Redis 命令, 因为执行这些命令需要在多个 Redis 节点之间移动数据, 并且在高负载的情况下, 这些命令将降低 Redis 集群的性能, 并导致不可预测的行为。
Redis 集群通过分区(partition)来提供一定程度的可用性(availability): 即使集群中有一部分节点失效或者无法进行通讯, 集群也可以继续处理命令请求。
Redis 集群提供了以下两个好处:
- 将数据自动切分(split)到多个节点的能力。
- 当集群中的一部分节点失效或者无法进行通讯时, 仍然可以继续处理命令请求的能力。
Redis 集群数据共享
Redis 集群使用数据分片(sharding)而非一致性哈希(consistency hashing)来实现: 一个 Redis 集群包含 16384
个哈希槽(hash slot), 数据库中的每个键都属于这 16384
个哈希槽的其中一个, 集群使用公式 CRC16(key) % 16384
来计算键 key
属于哪个槽, 其中 CRC16(key)
语句用于计算键 key
的 CRC16 校验和 。
集群中的每个节点负责处理一部分哈希槽。 举个例子, 一个集群可以有三个哈希槽, 其中:
- 节点 A 负责处理
0
号至5500
号哈希槽。 - 节点 B 负责处理
5501
号至11000
号哈希槽。 - 节点 C 负责处理
11001
号至16384
号哈希槽。
这种将哈希槽分布到不同节点的做法使得用户可以很容易地向集群中添加或者删除节点。 比如说:
- 如果用户将新节点 D 添加到集群中, 那么集群只需要将节点 A 、B 、 C 中的某些槽移动到节点 D 就可以了。
- 与此类似, 如果用户要从集群中移除节点 A , 那么集群只需要将节点 A 中的所有哈希槽移动到节点 B 和节点 C , 然后再移除空白(不包含任何哈希槽)的节点 A 就可以了。
因为将一个哈希槽从一个节点移动到另一个节点不会造成节点阻塞, 所以无论是添加新节点还是移除已存在节点, 又或者改变某个节点包含的哈希槽数量, 都不会造成集群下线。
Redis 集群中的主从复制
为了使得集群在一部分节点下线或者无法与集群的大多数(majority)节点进行通讯的情况下, 仍然可以正常运作, Redis 集群对节点使用了主从复制功能: 集群中的每个节点都有 1
个至 N
个复制品(replica), 其中一个复制品为主节点(master), 而其余的 N-1
个复制品为从节点(slave)。
在之前列举的节点 A 、B 、C 的例子中, 如果节点 B 下线了, 那么集群将无法正常运行, 因为集群找不到节点来处理 5501
号至 11000
号的哈希槽。
另一方面, 假如在创建集群的时候(或者至少在节点 B 下线之前), 我们为主节点 B 添加了从节点 B1 , 那么当主节点 B 下线的时候, 集群就会将 B1 设置为新的主节点, 并让它代替下线的主节点 B , 继续处理 5501
号至 11000
号的哈希槽, 这样集群就不会因为主节点 B 的下线而无法正常运作了。
不过如果节点 B 和 B1 都下线的话, Redis 集群还是会停止运作。
Redis 集群的一致性保证(guarantee)
Redis 集群不保证数据的强一致性(strong consistency): 在特定条件下, Redis 集群可能会丢失已经被执行过的写命令。
使用异步复制(asynchronous replication)是 Redis 集群可能会丢失写命令的其中一个原因。 考虑以下这个写命令的例子:
- 客户端向主节点 B 发送一条写命令。
- 主节点 B 执行写命令,并向客户端返回命令回复。
- 主节点 B 将刚刚执行的写命令复制给它的从节点 B1 、 B2 和 B3 。
如你所见, 主节点对命令的复制工作发生在返回命令回复之后, 因为如果每次处理命令请求都需要等待复制操作完成的话, 那么主节点处理命令请求的速度将极大地降低 —— 我们必须在性能和一致性之间做出权衡。
Note
如果真的有必要的话, Redis 集群可能会在将来提供同步地(synchronou)执行写命令的方法。
Redis 集群另外一种可能会丢失命令的情况是, 集群出现网络分裂(network partition), 并且一个客户端与至少包括一个主节点在内的少数(minority)实例被孤立。
举个例子, 假设集群包含 A 、 B 、 C 、 A1 、 B1 、 C1 六个节点, 其中 A 、B 、C 为主节点, 而 A1 、B1 、C1 分别为三个主节点的从节点, 另外还有一个客户端 Z1 。
假设集群中发生网络分裂, 那么集群可能会分裂为两方, 大多数(majority)的一方包含节点 A 、C 、A1 、B1 和 C1 , 而少数(minority)的一方则包含节点 B 和客户端 Z1 。
在网络分裂期间, 主节点 B 仍然会接受 Z1 发送的写命令:
- 如果网络分裂出现的时间很短, 那么集群会继续正常运行;
- 但是, 如果网络分裂出现的时间足够长, 使得大多数一方将从节点 B1 设置为新的主节点, 并使用 B1 来代替原来的主节点 B , 那么 Z1 发送给主节点 B 的写命令将丢失。
注意, 在网络分裂出现期间, 客户端 Z1 可以向主节点 B 发送写命令的最大时间是有限制的, 这一时间限制称为节点超时时间(node timeout), 是 Redis 集群的一个重要的配置选项:
- 对于大多数一方来说, 如果一个主节点未能在节点超时时间所设定的时限内重新联系上集群, 那么集群会将这个主节点视为下线, 并使用从节点来代替这个主节点继续工作。
- 对于少数一方, 如果一个主节点未能在节点超时时间所设定的时限内重新联系上集群, 那么它将停止处理写命令, 并向客户端报告错误。
常见问题
1、为什么 Redis 这么快?
- 数据存于内存
- 用了多路复用I/O
- 核心存取过程使用单线程
2、key 过期清除(超时剔除)策略
- 惰性过期(类比懒加载,这是懒过期):只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
- 定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果
3、 Redis 的内存淘汰策略
Redis 的内存淘汰策略是指在 Redis 的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。
4、如何防止缓存穿透?
- 数据命中不高,变化实时性比较高的情况下,可以缓存空对象。
- 数据命中不高,相对固定实时性低的时候,用 BloomFilter 布隆过滤器。
5、为什么 Redis 不支持回滚(roll back)
如果你有使用关系式数据库的经验, 那么 “Redis 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。
以下是这种做法的优点:
- Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
- 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 INCR key 命令将键的值加上 1
, 却不小心加上了 2
, 又或者对错误类型的键执行了 INCR key , 回滚是没有办法处理这些情况的。
鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现, 所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。
RedLock
Redis 官方站这篇文章 Distributed locks with Redis提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:
- 安全特性:互斥访问,即永远只有一个客户端能拿到锁。
- 避免死锁:最终客户端都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的客户端挂掉了或者出现了网络分区。
- 容错性:只要大部分 Redis 节点存活就可以正常提供服务。
故障切换(failover)实现方式的局限性
通过 Redis 为某个资源加锁的最简单方式就是在一个 Redis 实例中使用过期特性(expire)创建一个 key, 如果获得锁的客户端没有释放锁,那么在一定时间内这个 Key 将会自动删除,避免死锁。
这种做法在表面上看起来可行,但分布式锁作为架构中的一个组件,为了避免Redis宕机引起锁服务不可用, 我们需要为 Redis 实例(master)增加热备(slave),如果 master 不可用则将 slave 提升为 master。
这种主从的配置方式存在一定的安全风险,由于 Redis 的主从复制是异步进行的,可能会发生多个客户端同时持有一个锁的现象。比如以下情况:
- Client A 获得在 master 节点获得了锁
- 在 master 将 key 备份到 slave 节点之前,master宕机
- slave 被提升为 master
- Client B 在新的 master 节点处获得了锁,Client A 也持有这个锁
RedLock 算法介绍
下面例子中的分布式环境包含 N 个 Redis Master 节点,这些节点相互独立,无需备份。这些节点尽可能相互隔离的部署在不同的物理机或虚拟机上(故障隔离)。
节点数量暂定为 5 个(在需要投票的集群中,5 个节点的配置是比较合理的最小配置方式)。获得锁和释放锁的方式仍然采用之前介绍的方法。
一个 Client 想要获得一个锁需要以下几个操作:
得到本地时间
client 使用相同的 key 和随机数,按照顺序在每个 master 实例中尝试获得锁。在获得锁的过程中,为每一个锁操作设置一个快速失败时间(如果想要获得一个 10 秒的锁, 那么每一个锁操作的失败时间设为 5 - 50ms)。
这样可以避免客户端与一个已经故障的 master 通信占用太长时间,通过快速失败的方式尽快的与集群中的其他节点完成锁操作。
客户端计算出与 master 获得锁操作过程中消耗的时间,当且仅当 client 获得锁消耗的时间小于锁的存活时间,并且在一半以上的 master 节点中获得锁。才认为 client 成功的获得了锁。
如果已经获得了锁,client 执行任务的时间窗口是锁的存活时间减去获得锁消耗的时间。
如果 client 获得锁的数量不足一半以上,或获得锁的时间超时,那么认为获得锁失败。客户端需要尝试在所有的 master 节点中释放锁,即使在第二步中没有成功获得该 master 节点中的锁,仍要进行释放操作。
RedLock 性能及崩溃恢复的相关解决方法
如果 redis 没有持久化功能,在 client A 获取锁成功后,所有 redis 重启,client B 能够再次获取到锁,这样违法了锁的排他互斥性;
如果启动 AOF 永久化存储,事情会好些, 举例:当我们重启 redis 后,由于 redis 过期机制是按照 unix 时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务。但是由于 AOF 同步到磁盘的方式默认是每秒一次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效。但如果同步磁盘方式使用 Always(每一个写命令都同步到硬盘)造成性能急剧下降。所以在锁完全有效性和性能方面要有所取舍。
有效解决既保证锁完全有效性及性能高效及即使断电情况的方法是 redis 同步到磁盘方式保持默认的每秒,在 redis 无论因为什么原因停掉后要等待 TTL 时间后再重启(学名:延迟重启),缺点是 在TTL时间内服务相当于暂停状态。
总结:
TTL 时长 要大于正常业务执行的时间+获取所有 redis 服务消耗时间+时钟漂移
获取 redis 所有服务消耗时间要远小于 TTL 时间,并且获取成功的锁个数要在总数的一半以上:N/2+1
尝试获取每个 redis 实例锁时的时间要远小于 TTL 时间
尝试获取所有锁失败后重新尝试一定要有一定次数限制
在 redis 崩溃后(无论一个还是所有),要延迟 TTL 时间重启 redis
在实现多 redis 节点时要结合单节点分布式锁算法共同实现
结合Java
Jedis:是老牌的 Redis 的 Java 实现客户端,提供了比较全面的 Redis 命令的支持,
Redisson:实现了分布式和可扩展的 Java 数据结构。
Lettuce:高级 Redis 客户端,用于线程安全同步,异步和响应使用,支持集群,Sentinel,管道和编码器。
优点:
- Jedis:比较全面的提供了Redis的操作特性
- Redisson:促使使用者对 Redis 的关注分离,提供很多分布式相关操作服务,例如,分布式锁,分布式集合,可通过 Redis 支持延迟队列
- Lettuce:基于 Netty 框架的事件驱动的通信层,其方法调用是异步的。Lettuce 的 API 是线程安全的,所以可以操作单个Lettuce 连接来完成各种操作
Jedis 直接连接 redis server,如果在多线程环境下是非线程安全的,这个时候只有使用连接池,为每个 Jedis 实例增加物理连接 。
lettuce 的连接是基于 Netty 的,连接实例(StatefulRedisConnection)可以在多个线程间并发访问,StatefulRedisConnection是线程安全的,所以一个连接实例可以满足多线程环境下的并发访问,当然这也是可伸缩的设计,一个连接实例不够的情况也可以按需增加连接实例。
Redisson 实现了分布式和可扩展的 Java 数据结构,和 Jedis 相比,功能较为简单,不支持字符串操作,不支持排序、事务、管道、分区等 Redis 特性。Redisson 的宗旨是促进使用者对 Redis 的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
总结:
优先使用 Lettuce,如果需要分布式锁,分布式集合等分布式的高级特性,添加 Redisson 结合使用,因为 Redisson 本身对字符串的操作支持很差。
参考文章