Redis 详解
1. 什么是 Redis
Redis 是一个基于内存的高性能 key-value 数据库。是完全开源免费的,用C语言编写的,遵守BSD协议。
Redis 特点:
- Redis 是基于内存操作的,吞吐量非常高,可以在 1s内完成十万次读写操作
- Redis 的读写模块是单线程,每个操作都具原子性
- Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启可以再次加载,但可能会有极短时间内数据丢失
- Redis 支持多种数据结构,String,list,set,zset,hash等
2. Redis 基本使用
3. Redis 数据结构
2.1 String
字符串 string 是 Redis 最简单的数据结构。Redis 所有的数据结构都是以唯一的 key 字符串作为名称,然后通过这个唯一 key 值来获取相应的 value 数据。不同类型的数据结构的差异就在于 value 的结构不一样。Redis 的 string 可以包含任何数据,比如 jpg图片或者序列化的对象(java 中对象序列化函数 serialize)
内部实现,其本质是一个byte数组
struct sdshdr {
long len; //buf数组的长度
long free; //buf数组中剩余可用字节数
char buf[]; //存储实际字符串内容
}
String 采用预分配冗余空间的方式来减少内存的频繁分配,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
键值对
> set name codehole
OK
> get name
"codehole"
> exists name
(integer) 1
> del name
(integer) 1
> get name
(nil)
批量键值对
可以批量对多个字符串进行读写,节省网络耗时开销
> set name1 codehole
OK
> set name2 holycoder
OK
> mget name1 name2 name3 # 返回一个列表
1) "codehole"
2) "holycoder"
3) (nil)
> mset name1 boy name2 girl name3 unknow
> mget name1 name2 name3
1) "boy"
2) "girl"
3) "unknown"
过期和 set 命令扩展
可以对 key 设置过期时间,到点自动删除,这个功能常用来控制缓存的失效时间。不过这个 [自动删除] 的机制是比较复杂的。
> set name codehole
> get name
"codehole"
> expire name 5 # 5s 后过期
... # wait for 5s
> get name
(nil)
> setex name 5 codehole # 5s 后过期,等价于 set + expire
> get name
"codehole"
... # wait for 5s
> get name
(nil)
> setnx name codehole # set 和 expire 原子执行,因为 name 不存在就执行创建成功
(integer) 1
> get name
"codehole"
> setnx name holycoder # set 和 expire 原子执行,因为 name 存在 set 创建不成功
(integer) 0
> get name
"codehole"
计数
如果 value 值是一个整数,还可以对它进行自增操作。自增是有范围的,它的范围是 signed long 的最大最小值,超过了这个值,Redis 会报错。
> set age 30
OK
> incr age
(integer) 31
> incrby age 5
(integer) 36
> incrby age -5
(integer) 31
set codehole 9223372036854775807
# Long.Max
Ok
字符串是由多个字节组成,每个字节又是由 8 个 bit 组成,如此便可以将一个字符串看成很多 bit 的组合,这便是 bitmap「位图」数据结构
2.2 List
Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组,而且是双向链表。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为
O(n),这点让人非常意外。
当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。
Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符
串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。
右边进左边出:队列
> rpush books python java golang
(integer) 3
> llen books
(integer) 3
> lpop books
"python"
> lpop books
"java"
> lpop books
"golang"
> lpop books
(nil)
右边进右边出:栈
> rpush books python java golang
(integer) 3
> rpop books
"golang"
> rpop books
"java"
> rpop books
"python"
> rpop books
(nil)
慢操作
lindex 相当于 java 链表的 get(int index)
方法,它需要对链表进行遍历,性能随着参数 index 增大而变差。ltrim 和字面上的含义不太一样,个人觉得它叫 lretain(保留) 更适合一些,因为 ltrim 跟两个参数 start_index
和 end_index
定义了一个区间,在这个区间内的值,ltrim 是要保留,区间之外统统砍掉。我们可以通过 ltrim 来实现一个定长的链表,这一点非常有用。index 可以为负数,index=-1 表示倒数第一个元素,同样 index=-2 表示倒数第二个元素。
> rpush books python java golang
(integer) 3
> lindex books 1 # O(n) 慎用,并不会删除 "java"
"java"
> lrange books 1 -1 # 获取从1开始到最后一个元素,O(n) 慎用, 并不会删除
1) "python"
2) "java"
3) "golang"
> ltrim books 1 -1 # O(n) 慎用
OK
> lrange books 0 -1 # 获取所有元素
1) "java"
2) "golang"
> ltrim books 1 0 # 这其实是清空了整个列表,因为区间范围长度为负
OK
> llen books
(integer) 0
2.3 Hash(字典)
2.3.1 Hash 底层数据结构
hash的底层存储有两种数据结构
- ziplist:如果hash对象保存的键和值字符串长度都小于64字节且hash对象保存的键值对数量小于512,则采用这种
- dict(字典):其他情况采用这种数据结构
hash 结构也可以用来存储用户信息,不同于字符串一次性需要全部序列化整个对象,hash 以对用户结构中的每个字段单独存储。这样当我们需要获取用户信息时可以进行部分获取。而以整个符串的形式去保存用户信息的话就只能一次性全部读取,这样就会比较浪费网络流量。
hash 也有缺点,hash 结构的存储消耗要高于单个字符串,到底该使用 hash 还是字符串,需要根据实际情况再三权衡。
ziplist
上图中可以看到,当数据量比较小的时候,我们会将所有的key及value都当成一个元素,顺序的存入到ziplist中,构成有序。
dict(字典)
字典结构定义
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表,两个元素
dictht ht[2]
// rehash时记录的索引下标,当没有rehash时,值为-1
int rehashidx;
} dict;
其中,哈希表dictht 的结构定义为:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
unsigned long sizenask;
// 该哈希表已有节点的数量
unsigned long uesd;
} dictht;
图示结构如下
Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。
渐进式 rehash
渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 hash 结构,然后在后续的定时任务中以及 hash 的子指令中,循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。
当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。
渐进式 rehash 详细步骤
- 为 ht[1] 分配空间,让dict字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为0,表示 rehash 工作正式开始。
- 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引(table[rehashidx]桶上的链表)上的所有键值对 rehash 到 ht[1] 上,当 rehash 工作完成之后,将 rehashidx 属性的值增一,表示下一次要迁移链表所在桶的位置。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有桶对应的键值对都会被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为-1,表示 rehash 操作已完成。
渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。
2.3.2 基本操作命令
> hset books java "thinks in java" # books 是 key,Java 是 hash中的 key
# 如果 字符串包含空格 要用引号括起来
(integer) 1
> hset books golang "concurrency in go"
(integer) 1
> hgetall books # entries(),key 和 value 间隔出现
1) "java"
2) "thinks in java"
3) "golang"
4) "concurrency in go"
> hlen books
(integer) 2
> hget books golang
"concurrency in go"
> hset books golang "learning go programming" # 因为是更新操作,所以返回 0
(integer) 0
> hget books golang
"learning go programming"
> hmset books java "effective java" golang "modern golang
programming" # 批量 set
OK
同字符串一样,hash 结构中的单个子 key 也可以进行计数,它对应的指令是 hincrby,和 incr 使用基本一样。
> hincrby user-laoqian age 1
(integer) 30
2.4 Set(集合)
set 是一个无序的、自动去重的集合数据类型,Set 底层用两种数据结构存储
- intset:如果元素个数少于默认值512且元素可以用整型,则用这种数据结构
- dict(字典):其他情况采用这种数据结构
当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。 set 结构可以用来存储活动中奖的用户 ID,因为有去重功能,可以保证同一个用户不会中奖两次。
2.4.1 intset 定义
typedef struct intset {
// 编码类型
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
2.4.2 基本操作命令
> sadd books python
(integer) 1
> sadd books python # 重复
(integer) 0
> sadd books golang
(integer) 1
> smembers books # 注意顺序,和插入的并不一致,因为 set 是无序的
1) "golang"
2) "python"
> sismember books python # 查询某个 value 是否存在,相当于 contains(o)
(integer) 1
> sismember books rust
(integer) 0
> scard books # 获取长度相当于 count()
(integer) 2
> spop books # 弹出一个
"python"
2.5 zset(有序集合)
zset 可能是 Redis 提供的最为特色的数据结构,它也是在面试中面试官最爱问的数据结构。zset为有序(有限score排序,score相同则元素字典排序),自动去重的集合数据类型,其底层实现为 字典(dict) + 跳表(skiplist),当数据比较少的时候用 ziplist 编码结构存储。
- ziplist :如果有序集合保存的所有元素的长度小于默认值64字节且有序集合保存的元素数量小于默认值128个,则采用这种数据结构
- 字典(dict) + 跳表(skiplist):其他情况采用这种数据结构
2.5.1 字典(dict) + 跳表(skiplist)数据结构
zset 中最后一个 value 被移除后,数据结构自动删除,内存被回收。 zset 可以用来存粉丝列表,value 值是粉丝的用户 ID,score 是关注时间。我们可以对粉丝列表按关注时间进行排序。
zset 还可以用来存储学生的成绩,value 值是学生的 ID,score 是他的考试成绩。我们可以对成绩按分数进行排序就可以得到他的名次。
2.5.2 基本操作命令
> zadd books 9.0 "think in java"
(integer) 1
> zadd books 8.9 "java concurrency"
(integer) 1
> zadd books 8.6 "java cookbook"
(integer) 1
> zrange books 0 -1 # 按 score 排序列出,参数区间为排名范围
1) "java cookbook"
2) "java concurrency"
3) "think in java"
> zrevrange books 0 -1 # 按 score 逆序列出,参数区间为排名范围
1) "think in java"
2) "java concurrency"
3) "java cookbook"
> zcard books # 相当于 count()
(integer) 3
> zscore books "java concurrency" # 获取指定 value 的 score
"8.9000000000000004" # 内部 score 使用 double 类型进行存储,所以存在小数点精度问题
> zrank books "java concurrency" # 排名
(integer) 1
> zrangebyscore books 0 8.91 # 根据分值区间遍历 zset
1) "java cookbook"
2) "java concurrency"
> zrangebyscore books -inf 8.91 withscores # 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。
1) "java cookbook"
2) "8.5999999999999996"
3) "java concurrency"
4) "8.9000000000000004"
> zrem books "java concurrency" # 删除 value
(integer) 1
> zrange books 0 -1
1) "java cookbook"
2) "think in java"
关于数据结构详细请看这篇文章:redis 数据结构
4. 过期策略
Redis 所有的数据结构都可以设置过期时间,时间一到,就会自动删除。你可以想象 Redis 内部有一个死神,时刻盯着所有设置了过期时间的 key,寿命一到就会立即收割。
你还可以进一步站在死神的角度思考,会不会因为同一时间太多的 key 过期,以至于忙不过来。同时因为 Redis 是单线程的,收割的时间也会占用线程的处理时间,如果收割的太过于繁忙,会不会导致线上读写指令出现卡顿。在过期这件事上,Redis 非常小心。
过期 key 集合
redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的 key。除了定时遍历之外,它还会使用惰性策略来删除过期的 key,所谓惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期了就立即删除。定时删除是集中处理,惰性删除是零散处理。
定时扫描策略
Redis 默认会每秒进行十次过期扫描,过期扫描不会遍历过期字典中所有的 key,而是
采用了一种简单的贪心策略。
- 从过期字典中随机 20 个 key;
- 删除这 20 个 key 中已经过期的 key;
- 如果过期的 key 比率超过 1/4,那就重复步骤 1;
同时,为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时间的上限,默认不会超过 25ms。
设想一个大型的 Redis 实例中所有的 key 在同一时间过期了,会出现怎样的结果?
毫无疑问,Redis 会持续扫描过期字典 (循环多次),直到过期字典中过期的 key 变得稀疏,才会停止 (循环次数明显下降)。这就会导致这期间线上读写 QPS 下降明显。还有另外一种原因是内存管理器需要频繁回收内存页,这也会产生一定的 CPU 消耗。
这里解析一下,假如单台 Redis 读写请求 QPS 是 10w,也就是每个请求需要 0.00001s 来完成,每秒执行十次过期扫描,每次过期扫描都达到上限 25ms,那么每秒过期扫描总花费 0.25s,相当于 QPS 降低了 2.5W。
所以业务开发人员一定要注意过期时间,如果有大批量的 key 过期,要给过期时间设置一个随机范围,而不能全部在同一时间过期。
# 在目标过期时间上增加一天的随机时间
redis.expire_at(key, random.randint(86400) + expire_ts)
从库的过期策略
从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
因为指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在。
5. Redis 内存淘汰机制
Redis 数据库可以通过配置文件来配置最大缓存,当写入的数据发现没有足够的内存可用的时候,Redis 会触发内存淘汰机制。Redis 为了满足多样化场景,提供了八种策略,可以在 redis.config 文件中配置。
- volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
- volatile-lfu:从已设置过期时间的数据集中挑选使用频率最低的数据淘汰
- allkeys-lru:从所有数据集中挑选最近最少使用的数据淘汰
- allkeys-lfu:从所有数据集中挑选使用频率最低的数据淘汰
- allkeys-random:从所有数据集中任意选择数据淘汰
- noenviction:不回收任何数据,返回一个写操作的错误信息。这也是默认策略
6. Redis 持久化
Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。
Redis 的持久化机制有两种,第一种是快照,第二种是 AOF 日志。快照是一次全量备份,AOF 日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本。AOF 日志在长期的运行过程中会变的无比庞大,数据库重启时需要加载 AOF 日志进行指令重放,这个时间就会无比漫长。所以需要定期进行 AOF 重写,给 AOF 日志进行瘦身。
快照原理
我们知道 Redis 是单线程程序,这个线程要同时负责多个客户端套接字的并发读写操作和内存数据结构的逻辑读写。
在服务线上请求的同时,Redis 还需要进行内存快照,内存快照要求 Redis 必须进行文件 IO 操作,可文件 IO 操作是不能使用多路复用 API。
这意味着单线程同时在服务线上的请求还要进行文件 IO 操作,文件 IO 操作会严重拖垮服务器请求的性能。还有个重要的问题是为了不阻塞线上的业务,就需要边持久化边响应客户端请求。持久化的同时,内存数据结构还在改变,比如一个大型的 hash 字典正在持久化,结果一个请求过来把它给删掉了,还没持久化完呢,这尼玛要怎么搞?
那该怎么办呢?
Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化,这个机制很有意思,也很少人知道。多进程 COW 也是鉴定程序员知识广度的一个重要指标。
fork( 多进程)
Redis 在持久化时会调用 glibc 的函数 fork 产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。
子进程做数据持久化,它不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是父进程不一样,它必须持续服务客户端请求,然后对内存数据结构进行不间断的修改。
这个时候就会使用操作系统的 COW 机制来进行数据段页面的分离。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。
随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存就会持续增长。但是也不会超过原有数据内存的 2 倍大小。另外一个 Redis 实例里冷数据占的比例往往是比较高的,所以很少会出现所有的页面都会被分离,被分离的往往只有其中一部分页面。每个页面的大小只有 4K,一个 Redis 实例里面一般都会有成千上万的页面。
子进程因为数据没有变化,它能看到的内存里的数据在进程产生的一瞬间就凝固了,再也不会改变,这也是为什么 Redis 的持久化叫「快照」的原因。接下来子进程就可以非常安心的遍历数据了进行序列化写磁盘了。
AOF 原理
AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的指令记录。
假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。
Redis 会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令存储到 AOF 日志缓存中,AOF 日志缓存 copy 到 内核缓存,但还没有刷到磁盘,也就是先写日志,然后再执行指令。这样即使遇到突发宕机,已经存储到 AOF 日志的指令进行重放一下就可以恢复到宕机前的状态。
Redis 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 AOF 日志瘦身。
AOF 重写
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
fsync
AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中 OS buffer,然后内核会异步将脏数据刷回到磁盘的。
这就意味着如果机器突然宕机,AOF 日志内容可能还没有来得及完全刷到磁盘中,这个时候就会出现日志丢失。那该怎么办?
Linux 的 glibc 提供了 fsync(int fd)函数可以将指定文件的内容强制从内核缓存刷到磁盘。只要 Redis 进程实时调用 fsync 函数就可以保证 aof 日志不丢失。但是 fsync 是一个磁盘 IO 操作,它很慢!如果 Redis 执行一条指令就要 fsync 一次,那么 Redis 高性能的地位就不保了。
所以在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作,周期 1s是可以配置的。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能使得数据少丢失。
Redis 同样也提供了另外两种策略,一个是永不 fsync——让操作系统来决定合适同步磁盘,很不安全,另一个是来一个指令就 fsync 一次——非常慢。但是在生产环境基本不会使用,了解一下即可。
运维
快照是通过开启子进程的方式进行的,它是一个比较耗资源的操作。
- 遍历整个内存,大块写磁盘会加重系统负载
- AOF 的 fsync 是一个耗时的 IO 操作,它会降低 Redis 性能,同时也会增加系统 IO 负担
所以通常 Redis 的主节点是不会进行持久化操作,持久化操作主要在从节点进行。从节点是备份节点,没有来自客户端请求的压力,它的操作系统资源往往比较充沛。
但是如果出现网络分区,从节点长期连不上主节点,就会出现数据不一致的问题,特别是在网络分区出现的情况下又不小心主节点宕机了,那么数据就会丢失,所以在生产环境要做好实时监控工作,保证网络畅通或者能快速修复。另外还应该再增加一个从节点以降低网络分区的概率,只要有一个从节点数据同步正常,数据也就不会轻易丢失。
Redis 4.0 混合持久化
重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。
于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。
7. Redis 集群
7.1 Sentinel
目前我们讲的 Redis 还只是主从方案,最终一致性。读者们可思考过,如果主节点凌晨 3 点突发宕机怎么办?就坐等运维从床上爬起来,然后手工进行从主切换,再通知所有的程序把地址统统改一遍重新上线么?毫无疑问,这样的人工运维效率太低,事故发生时估计得至少 1 个小时才能缓过来。如果是一个大型公司,这样的事故足以上新闻了。
所以我们必须有一个高可用方案来抵抗单点故障,当故障发生时可以自动进行从主切换,程序可以不用重启,运维可以继续睡大觉,仿佛什么事也没发生一样。Redis 官方提供了这样一种方案 —— Redis Sentinel(哨兵)。
我们可以将 Redis Sentinel 集群看成是一个 ZooKeeper 集群,它是集群高可用的心脏,它一般是由 3~5 个节点组成,这样挂了个别节点集群还可以正常运转。
它负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为主节点。客户端来连接集群时,会首先连接 sentinel,通过 sentinel 来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向 sentinel 要地址,sentinel 会将最新的主节点地址告诉客户端。如此应用程序将无需重启即可自动完成节点切换。比如上图的主节点挂掉后,集群将可能自动调整为下图所示结构。
从这张图中我们能看到主节点挂掉了,原先的主从复制也断开了,客户端和损坏的主节点也断开了。从节点被提升为新的主节点,其它从节点开始和新的主节点建立复制关系。客户端通过新的主节点继续进行交互。Sentinel 会持续监控已经挂掉了主节点,待它恢复后,集群会调整为下面这张图。
此时原先挂掉的主节点现在变成了从节点,从新的主节点那里建立复制关系。
7.2 主从同步设置-消息丢失
Redis 主从采用异步复制,意味着当主节点挂掉时,从节点可能没有收到全部的同步消息,这部分未同步的消息就丢失了。如果主从延迟特别大,那么丢失的数据就可能会特别多。Sentinel 无法保证消息完全不丢失,但是也尽可能保证消息少丢失。它有两个参数可以限制主从延迟过大。
min-slaves-to-write 1
min-slaves-max-lag 10
第一个参数表示主节点必须至少有一个从节点在进行正常复制,否则就停止对外写请求,丧失可用性。
何为正常复制,何为异常复制?这个就是由第二个参数控制的,它的单位是秒,表示如果 10s 没有收到从节点的反馈,就意味着从节点同步不正常,是谓异常复制。
7.3 Sentinel 基本使用
接下来我们看看客户端如何使用 sentinel,标准的流程应该是客户端可以通过 sentinel 发现主从节点的地址,然后在通过这些地址建立相应的连接来进行数据存取操作。我们来看看 Python 客户端是如何做的。
>>> from redis.sentinel import Sentinel
>>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)
>>> sentinel.discover_master('mymaster')
('127.0.0.1', 6379)
>>> sentinel.discover_slaves('mymaster')
[('127.0.0.1', 6380)]
sentinel 的默认端口是 26379,不同于 Redis 的默认端口 6379,通过 sentinel 对象的 discover_xxx 方法可以发现主从地址,主地址只有一个,从地址可以有多个。
>>> master = sentinel.master_for('mymaster', socket_timeout=0.1)
>>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1)
>>> master.set('foo', 'bar')
>>> slave.get('foo')
'bar'
通过 xxx_for 方法可以从连接池中拿出一个连接来使用,因为从地址有多个,redis 客户端对从地址采用轮询方案,也就是 RoundRobin 轮着来。
有个问题是,但 sentinel 进行主从切换时,客户端如何知道地址变更了 ? 通过分析源码,我发现 redis-py 在建立连接的时候进行了主库地址变更判断。
连接池建立新连接时,会去查询主库地址,然后跟内存中的主库地址进行比对,如果变更了,就断开所有连接,重新使用新地址建立新连接。如果是旧的主库挂掉了,那么所有正在使用的连接都会被关闭,然后在重连时就会用上新地址。
但是这样还不够,如果是 sentinel 主动进行主从切换,主库并没有挂掉,而之前的主库连接已经建立了在使用了,没有新连接需要建立,那这个连接是不是一致切换不了?
继续深入研究源码,我发现 redis-py 在另外一个点也做了控制。那就是在处理命令的时候捕获了一个特殊的异常 ReadOnlyError,在这个异常里将所有的旧连接全部关闭了,后续指令就会进行重连。
主从切换后,之前的主库被降级到从库,所有的修改性的指令都会抛出 ReadonlyError。如果没有修改性指令,虽然连接不会得到切换,但是数据不会被破坏,所以即使不切换也没关系。
7.4 Cluster
RedisCluster 是 Redis 的亲儿子,它是 Redis 作者自己提供的 Redis 集群化方案。
相对于 Codis 的不同,它是去中心化的,如图所示,该集群有三个 Redis 节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。这三个节点相互连接组成一个对等的集群,它们之间通过一种特殊的二进制协议相互交互集群信息。
Redis Cluster 将所有数据划分为 16384 的 slots,它比 Codis 的 1024 个槽划分的更为精细,每个节点负责其中一部分槽位。槽位的信息存储于每个节点中,它不像 Codis,它不需要另外的分布式存储来存储节点槽位信息。
当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息。这样当客户端要查找某个 key 时,可以直接定位到目标节点。
这点不同于 Codis,Codis 需要通过 Proxy 来定位目标节点,RedisCluster 是直接定位。客户端为了可以直接定位某个具体的 key 所在的节点,它就需要缓存槽位相关信息,这样才可以准确快速地定位到相应的节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整。
另外,RedisCluster 的每个节点会将集群的配置信息持久化到配置文件中,所以必须确保配置文件是可写的,而且尽量不要依靠人工修改配置文件。
7.5 槽位定位算法
Cluster 默认会对 key 值使用 crc32 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。
Cluster 还允许用户强制某个 key 挂在特定槽位上,通过在 key 字符串里面嵌入 tag 标记,这就可以强制 key 所挂在的槽位等于 tag 所在的槽位。
def HASH_SLOT(key)
s = key.index "{"
if s
e = key.index "}",s+1
if e && e != s+1
key = key[s+1..e-1]
end
end
crc16(key) % 16384
end
7.6 跳转
当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。
GET x
-MOVED 3999 127.0.0.1:6381
MOVED 指令的第一个参数 3999 是 key 对应的槽位编号,后面是目标节点地址。MOVED 指令前面有一个减号,表示该指令是一个错误消息。
客户端收到 MOVED 指令后,要立即纠正本地的槽位映射表。后续所有 key 将使用新的槽位映射表。
7.7 迁移
Redis Cluster 提供了工具 redis-trib 可以让运维人员手动调整槽位的分配情况,它使用 Ruby 语言进行开发,通过组合各种原生的 Redis Cluster 指令来实现。这点 Codis 做的更加人性化,它不但提供了 UI 界面可以让我们方便的迁移,还提供了自动化平衡槽位工具,无需人工干预就可以均衡集群负载。不过 Redis 官方向来的策略就是提供最小可用的工具,其它都交由社区完成。
7.7.1 迁移过程
Redis 迁移的单位是槽,Redis 一个槽一个槽进行迁移,当一个槽正在迁移时,这个槽就处于中间过渡状态。这个槽在原节点的状态为 migrating,在目标节点的状态为 importing,表示数据正在从源流向目标。
迁移工具 redis-trib 首先会在源和目标节点设置好中间过渡状态,然后一次性获取源节点槽位的所有 key 列表(keysinslot 指令,可以部分获取),再挨个 key 进行迁移。每个 key 的迁移过程是以原节点作为目标节点的「客户端」,原节点对当前的 key 执行 dump 指令得到序列化内容,然后通过「客户端」向目标节点发送指令 restore 携带序列化的内容作为参数,目标节点再进行反序列化就可以将内容恢复到目标节点的内存中,然后返回「客户端」OK,原节点「客户端」收到后再把当前节点的 key 删除掉就完成了单个 key 迁移的整个过程。
从源节点获取内容 => 存到目标节点 => 从源节点删除内容。
注意这里的迁移过程是同步的,在目标节点执行 restore 指令到原节点删除 key 之间,原节点的主线程会处于阻塞状态,直到 key 被成功删除。
如果迁移过程中突然出现网络故障,整个 slot 的迁移只进行了一半。这时两个节点依旧处于中间过渡状态。待下次迁移工具重新连上时,会提示用户继续进行迁移。
在迁移过程中,如果每个 key 的内容都很小,migrate 指令执行会很快,它就并不会影响客户端的正常访问。如果 key 的内容很大,因为 migrate 指令是阻塞指令会同时导致原节点和目标节点卡顿,影响集群的稳定型。所以在集群环境下业务逻辑要尽可能避免大 key 的产生。
在迁移过程中,客户端访问的流程会有很大的变化。
首先新旧两个节点对应的槽位都存在部分 key 数据。客户端先尝试访问旧节点,如果对应的数据还在旧节点里面,那么旧节点正常处理。如果对应的数据不在旧节点里面,那么有两种可能,要么该数据在新节点里,要么根本就不存在。旧节点不知道是哪种情况,所以它会向客户端返回一个-ASK targetNodeAddr 的重定向指令。客户端收到这个重定向指令后,先去目标节点执行一个不带任何参数的 asking 指令,然后在目标节点再重新执行原先的操作指令。
为什么需要执行一个不带参数的 asking 指令呢?
因为在迁移没有完成之前,按理说这个槽位还是不归新节点管理的,如果这个时候向目标节点发送该槽位的指令,节点是不认的,它会向客户端返回一个-MOVED 重定向指令告诉它去源节点去执行。如此就会形成 重定向循环。asking 指令的目标就是打开目标节点的选项,告诉它下一条指令不能不理,而要当成自己的槽位来处理。
从以上过程可以看出,迁移是会影响服务效率的,同样的指令在正常情况下一个 ttl 就能完成,而在迁移中得 3 个 ttl 才能搞定。
7.8 网络抖动
真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。
为解决这种问题,Redis Cluster 提供了一种选项 cluster-node-timeout,表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。
还有另外一个选项 cluster-slave-validity-factor 作为倍乘系数来放大这个超时时间来宽松容错的紧急程度。如果这个系数为零,那么主从切换是不会抗拒网络抖动的。如果这个系数大于 1,它就成了主从切换的松弛系数。
7.9 可能下线 (PFAIL-Possibly Fail) 与确定下线 (Fail)
因为 Redis Cluster 是去中心化的,一个节点认为某个节点失联了并不代表所有的节点都认为它失联了。所以集群还得经过一次协商的过程,只有当大多数节点都认定了某个节点失联了,集群才认为该节点需要进行主从切换来容错。
Redis 集群节点采用 Gossip 协议来广播自己的状态以及自己对整个集群认知的改变。比如一个节点发现某个节点失联了 (PFail),它会将这条信息向整个集群广播,其它节点也就可以收到这点失联信息。如果一个节点收到了某个节点失联的数量 (PFail Count) 已经达到了集群的大多数,就可以标记该节点为确定下线状态 (Fail),然后向整个集群广播,强迫其它节点也接收该节点已经下线的事实,并立即对该失联节点进行主从切换。
8. Redis 经典运用
8.1 分布式锁
分布式应用进行逻辑处理时经常会遇到并发问题。
比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。(Wiki 解释:所谓 原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。)
这个时候就要使用到分布式锁来限制程序的并发执行。Redis 分布式锁使用非常广泛,它是面试的重要考点之一,很多同学都知道这个知识,也大致知道分布式锁的原理,但是具体到细节的使用上往往并不完全正确。
分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。
占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。
简单使用如下:
> setnx lock_codehole true # setnx 不存在 key 会添加,存在 不会覆盖
(integer) 1
...do something critical...
> del lock_codehole
(integer) 1
8.1.1 锁释放问题 setnx + expire
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用, 这样就会陷入死锁,锁永远得不到释放。
于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。
> setnx lock_codehole true
(integer) 1
>expire lock_codehole 5
...do something critical...
> del lock_codehole
(integer) 1
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。 也许你会想到用 Redis 事务来解决。 但是这里不行, 因为 expire是依赖于 setnx 的执行结果的, 如果 setnx 没抢到锁, expire 是不应该执行的。 事务里没有 if-else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问题。 实现方法极为复杂, 小白用户一般要费很大的精力才可以搞懂。 如果你需要使用分布式锁,意味着你不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。
为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁library 可以休息了。
> set lock:codehole true ex 5 nx
OK
... do something critical ...
> del lock:codehole
(integer) 1
上面这个指令就是 setnx 和 expire 组合在一起的原子指令,它就是分布式锁的
奥义所在。
8.1.2 锁超时问题 redisson
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制, 就会出现问题。 因为这时候锁过期了, 第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了。这样就会陷入无限循环中。
为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。
有一个更加安全的方案使用 Redisson 客户端。
原理:redisson 内部提供了一个监控锁的看门狗,可以设置 lockWatchdogTimeout(监控锁的看门狗超时时间,默认是 30s),在监控锁被主动关闭前,会不断的延长监控锁的有效期,如果 redisson 客户端节点(也就是我们的服务器)宕机,那么监控锁时间到后自动关闭。
使用方式
在使用 redisson 之前需要先设置pom依赖和配置 redisson 的配置文件
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.1</version>
</dependency>
redisson 配置类
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient configRedisson(){
// 单例redis模式
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
//设置看门狗的时间,不配置的话默认30000
config.setLockWatchdogTimeout(6000);
return Redisson.create(config);
}
}
业务逻辑中引入调用
@RestController
@RequestMapping("/lock")
public class LockController {
@Autowired
RedissonClient redisson;
@GetMapping("/test")
public String lock() {
RLock watchDogLock = null;
try {
// 这步骤只是创建了一个RLock对象,并非执行了获取锁的操作
watchDogLock = redisson.getLock("watchDogLock");
// 如果设置锁过期时间,会导致看门狗无效
watchDogLock.lock();
System.out.println(Thread.currentThread().getName() + " : 获取锁成功");
Thread.sleep(30000);
System.out.println(Thread.currentThread().getName() + " : 执行业务完成");
}catch (InterruptedException e) {
// e.printStackTrace();
}finally {
if (Objects.nonNull(watchDogLock)) {
try {
watchDogLock.unlock();
}catch (Exception e) {
// e.printStackTrace();
}
}
}
return "成功";
}
}
测试结果:
在上述配置文件中设置了看门狗监控锁的时间为 6s,每当时间减少了1/3 就会重新恢复到 6
127.0.0.1:6379> ttl watchDogLock
(integer) 6
127.0.0.1:6379> ttl watchDogLock
(integer) 5
127.0.0.1:6379> ttl watchDogLock
(integer) 6
127.0.0.1:6379> ttl watchDogLock
(integer) 5
127.0.0.1:6379> ttl watchDogLock
(integer) 4
127.0.0.1:6379> ttl watchDogLock
(integer) 6
127.0.0.1:6379> ttl watchDogLock
(integer) 5
127.0.0.1:6379> ttl watchDogLock
(integer) 4
127.0.0.1:6379> ttl watchDogLock
redisson使用守护线程来进行锁的续期,(守护线程的作用:当用户线程销毁,会和用户线程一起销毁。)防止程序宕机后,线程依旧不断续命,造成死锁!如果服务器宕机,锁会在 6s 后自动释放。
redisson 守护线程的续命机制是依靠 netty 中的定时机制 HashedWheelTimer(时间轮)来完成的
8.1.3 拾遗漏补 —— 再谈分布式锁 redLock
redisson 锁在集群环境下,也是有缺陷的,它不是绝对安全的。比如在 Sentinel 集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。
不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。
8.1.3.1 Redlock 算法
为了解决这个问题,Antirez 发明了 Redlock 算法,它的流程比较复杂,不过已经有了很多开源的 library 做了良好的封装,用户可以拿来即用,比如 redlock-py。
import redlock
addrs = [{
"host": "localhost",
"port": 6379,
"db": 0
}, {
"host": "localhost",
"port": 6479,
"db": 0
}, {
"host": "localhost",
"port": 6579,
"db": 0
}]
dlm = redlock.Redlock(addrs)
success = dlm.lock("user-lck-laoqian", 5000)
if success:
print 'lock success'
dlm.unlock('user-lck-laoqian')
else:
print 'lock failed'
为了使用 Redlock,需要提供多个 Redis 实例。同很多分布式算法一样,redlock 也使用「大多数机制」。
加锁时,它会向过半节点发送 set(key, value, nx=True, ex=xxx) 指令,只要过半节点 set 成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为 Redlock 需要向多个节点进行读写,意味着相比单实例 Redis 性能会下降一些。
8.1.3.2 Redlock 使用场景
如果你很在乎高可用性,希望挂了一台 redis 完全不受影响,那就应该考虑 redlock。不过代价也是有的,需要更多的 redis 实例,性能也下降了,代码上还需要引入额外的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌。
8.1.3.3 Rredisson 整合了 redlock算法
业务使用方式:
@RestController
@RequestMapping("/lock")
public class LockController {
@Autowired
RedissonClient redisson;
@Autowired
RedissonClient redisson1;
@GetMapping("/redLock")
public String redLock() {
RLock redLock = null;
try {
// 这步骤只是创建了一个RLock对象,并非执行了获取锁的操作
RLock watchDogLock = redisson.getLock("watchDogLock");
RLock watchDogLock1 = redisson1.getLock("watchDogLock");
// watchDogLock, watchDogLock1中分别配置了2个redis单位的信息,为主、从关系
redLock = redisson.getRedLock(watchDogLock, watchDogLock1);
// 如果设置锁过期时间,会导致看门狗无效
redLock.lock();
System.out.println(Thread.currentThread().getName() + " : 获取锁成功");
Thread.sleep(30000);
System.out.println(Thread.currentThread().getName() + " : 执行业务完成");
}catch (InterruptedException e) {
// e.printStackTrace();
}finally {
if (Objects.nonNull(redLock)) {
try {
redLock.unlock();
}catch (Exception e) {
// e.printStackTrace();
}
}
}
return "成功";
}
}
9. 缓存各种问题
9.1 缓存处理流程
前台请求,后台先从缓存中取数据,取到直接返回结果,取不到时从数据库中取,数据库取到更新缓存,并返回结果,数据库也没取到,那直接返回空结果。注意,缓存是这种处理方式为前提
9.2 缓存穿透
描述:
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案:
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
9.3 缓存击穿
描述:
缓存击穿是指缓存中没有但数据库中有的单条数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决方案:
- 设置热点数据永远不过期。
- 加互斥锁。读数据库时需要获取锁,一条请求拿到锁之后读取数据并更新缓存,为了防止那些抢锁失败线程重新获取到锁后又进行读数据库操作,这里采用双重检验锁方式。
加互斥锁伪代码如下:
public String getData(String key) {
while (true) {
// 从缓存读取数据
String result = getDataForRedis(key);
if(result != null) {
return result;
}
// 尝试获取锁
if(tryLock(this)) {
try {
// 再次从缓存读取数据,这里想想为什么?
result = getDataForRedis(key);
if (result != null) {
return result;
}
// 从数据库获取数据
result = getDataForMySql(key);
if (result != null) {
// 添加到 redis 缓存
setDataToRedis(key, result);
// 返回
return result;
}
}finally {
unLock();
}
}else {
// 获取锁失败,睡眠
Thread.sleep(30);
}
}
}
9.4 缓存雪崩
描述:
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 设置热点数据永远不过期。