前言
为什么要使用Redis?
如果熟悉JVM底层的话,就能了解Java程序的运行大多数都是基于对内存的操作,读取、并更、清理,并同时保证数据的可靠性。即使是数据库,例如MySQL几乎都是基于对缓冲区的操作,只是通过后台线程进行刷盘到磁盘中,来达到高响应的。毕竟面向缓冲区都是零拷贝。
但是数据库本身是为了Java程序提供数据的持久化,它是以外部组件的形式来存在。那么其内部类似Java程序一样给用户使用外,数据库也是为了给Java程序或者其他程序提供数据持久化。但是一个程序去工作的话,它的有它的能力范畴。比如MySQL也是有属于自己的对外线程池,用于提供给外部进行连接,若是超过就需要等待。
由此,Redis产生了。Redis是一种支持key-value等多种数据结构的存储系统。可用于缓存,事件发布或订阅,高速队列等场景。支持网络,提供字符串,哈希,列表,队列,集合结构直接存取,基于内存,可持久化。
Redis的特性
- **简单稳定。**使用单线程模型,线程安全;源码较少;不需要依赖操作系统中的类库。
- **数据类型丰富。**Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作
- **Redis运行速度快。**数据存储在内存中;单线程不需要考虑上下文切换问题。读的速度是10W/s,写的速度是8万次/s
- **丰富的功能。**数据结构丰富,满足多种场景
- **高可扩展、高性能、高可用。**提供RDB和AOF用于保证持久化;从2.8版本开始提供Redis Sentinel实现高可用,保证节点故障发现以及自动转移;支持主流语言接入Redis。
- **原子性。**Redis的所有操作都是原子性的,同时Redis还支持对几个操作全并后的原子性执行。
- 发布订阅。支持发布/订阅模式
- 分布式锁。利用redis的setnx命令进行
Redis 问题画像图:
Redis简介
介绍
Redis是一个开源(BSD许可)的数据结构存储系统,可以用作数据库、缓存和消息代理。Redis的值可以是字符串、哈希、列表、集合、有序集合、位图、超日志、地理空间索引和流等。Redis具有内置的复制功能、Lua脚本、LRU数据淘汰、事务和不同级别的磁盘持久性,并通过Redis Sentinel和Redis Cluster自动分片提供高可用性。
为了获得最佳性能,Redis使用内存中的数据集。根据自己的需求,可以通过定期将数据集转储到磁盘或通过将命令附加到基于磁盘的日志来持久化数据,Redis还支持异步复制。另外Redis是基于单线程IO多路复用的架构实现的NoSql,意味着它的操作都是串行化的,所以在命令操作上不会出现线程安全问题。
Redis还具有如下功能:
1)支持事务。
2)支持发布和订阅。
3)支持Lua脚本。
4)支持给数据设置生存时间。
5)LRU等数据淘汰策略。
使用场景
热点数据缓存数据。
主要是因为Redis读写性能优异,成为首选服务端缓存的组件。另外Redis内部是支持事务的,在使用时候能有效保证数据的一致性。但是通常使用中,只有特殊的数据只需要查询存储,但是不需要进行实际的落库,才采用缓冲。或者类似周期报表这种。
作为缓存使用时,一般有两种方式保存数据:
- 读取前,先去读Redis,如果没有数据,读取数据库,将数据拉入Redis。但是需要注意避免缓存击穿。
- 插入数据时,同时写入Redis。
排行榜。
**关系型数据库进行内部聚合操作查询速度非常慢,但是可以借助redis的SortedSet进行热点数据的排序。**比如点赞排行榜,先查询到符合范围内的数据,做一个SortedSet, 然后以用户的openid作为上面的username, 以用户的点赞数作为上面的score, 然后针对每个用户做一个hash, 通过zrangebyscore就可以按照点赞数获取排行榜,然后再根据username获取用户的hash信息,这个当时在实际运用中性能体验也蛮不错的。
计数器
计数器主要是基于incrby命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成、具体业务还体现在比如限制一个手机号发多少条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等等。
消息队列。
由于Redis有list push和list pop这样的命令,所以能够很方便的执行队列操作。
分布式锁。
分布式锁是利用setnx命令来实现。比如当服务是集群的,那么定时服务可能在两天机器上执行,所以在定时任务中首先 通过setnx设置一个lock, 如果成功设置则执行,如果没有成功设置,则表明该定时任务已执行。
知识全景
应用维度:缓冲应用、集群应用、数据结构应用
系统维度:高性能、高可靠、高可扩展
高性能主线,包括线程模型、数据结构、持久化、网络框架;
高可靠主线,包括主从复制、哨兵机制;
高可扩展主线,包括数据分片、负载均衡。
基础数据类型
前言
Redis 是个 KV 数据库,所有的key(键)都是字符串。键值对是按一定的数据结构来组织的,使用了一个哈希表来保存所有键值对,一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。**(Value)哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。当发生Hash冲突时, Redis 解决哈希冲突的方式,就是链式哈希。Redis 默认使用了两个全局哈希表。Redis 采用了渐进式 rehash。**巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
所以当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。也有可能是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 hash 结构,然后在后续的定时任务中以及 hash 操作指令中,循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。当搬迁完成了,就会使用新的 hash 结构取而代之。当 rehash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。
redis中很重要的三个数据结构:
- dict:是Redis中的字典结构,包含两个dictht;
- dictht:表示一个全局Hash表,包含一个或多个dictEntry;
- dictEntry: 表示一个Key-Value节点;
链式hash法也是一种比较常见的处理hash冲突的方法,但是随着hash冲突可能越来越多,就会导致某些hash冲突链过长,进而导致链上的元素查找耗时长,效率降低。而Redis也有类似优化,当桶中的节点数超过一定的数量时(已插入的元素数量是桶容量的5倍),就会进行相应的优化(Rehash)。
redis中rehash的核心思想是,增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
大概流程:
Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
1)给哈希表 2 分配更大的空间,一般是当前哈希表 1 大小的两倍;
2)把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中(在hash表2下进行重新计算hash值);每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。
3)释放哈希表 1 的空间。
Redis 是基于标准 C 写的,只有5种最基础的数据类型分别是:string(字符串)、list(列表)、hash(字典)、set(集合) 和 zset(有序集合)。Redis 底层的数据结构包括:简单动态数组SDS、链表、字典、跳跃链表、整数集合、压缩列表、对象。 Redis 为了平衡空间和时间效率,针对 value 的具体类型在底层会采用不同的数据结构来实现,其中哈希表和压缩列表是复用比较多的数据结构,如下图展示了对外数据类型和底层数据结构之间的映射关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gL9FIJla-1684919330705)(/Users/zhaoyanhong/Library/Application Support/typora-user-images/image-20220902200910671.png)]
String(字符串)
最基本的类型,可以包含任何数据。Redis 的字符串是动态字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。
内部为当前字符串实际分配的空间 capacity,一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。但是每次修改后会使设置的过期时间失效。
C语言是没有String类型,所以需要实现一个类似String的结构体,那就是动态字符串SDS。对于SDS只需访问len属性,就能知道SDS的长度。 SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在 SDS 中, buf
数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由 SDS 的 free
属性记录。
通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
1 //SDS
typedef struct sdshdr {
2 // buf中已经占用的字符长度
3 unsigned int len;
4 // buf中剩余可用的字符长度
5 unsigned int free;
6 // 数据空间
7 char buf[];
8 }
命令使用
命令 | 简述 | 使用 |
---|---|---|
GET | 获取存储在给定键中的值 | GET name |
SET | 设置存储在给定键中的值 | SET name value |
DEL | 删除存储在给定键中的值 | DEL name |
INCR | 将键存储的值加1 | INCR key |
DECR | 将键存储的值减1 | DECR key |
INCRBY | 将键存储的值加上整数 | INCRBY key amount |
DECRBY | 将键存储的值减去整数 | DECRBY key amount |
实际应用
- 分布式锁。利用Redis的串行化特性,可以轻松的实现分布式锁,其中用到的命令有:setnx key value , expire key time ,del key ,其中第一个setnx是指在key不存在时能赋值成功,expire 来设置key的存活时间来防止程序异常而没有及时del到key值的情况。但是程序也有可能在expire没有执行时就已经挂掉的时候,这是可以来一个增强版set key value NX EX time。这里的NX就是表示if not exist,而Ex表示时间单位秒,Px代表毫秒。
- 分布式session。这里仅仅是利用Redis的数据库功能,把分布式应用的session抽取到Redis中,普通的get、set,命令即可完成。
- 商品秒杀实现。把需要销售的商品提前放入Redis,通过redis的incr和decr命令安全的增加和减少库存。
- 限时验证。 expire key time ,判断exists key在短信验证时,当redis中存在数据则不允许再次请求验证发送。
List(列表)
Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。Redis 的列表结构常用来做异步队列使用,遵循先进先出原则,当最后一个元素被处理,该数据结构自动被删除,内存被回收。
Redis3.2之前的底层实现方式:压缩列表ziplist 或者 双向循环链表linkedlist。当list存储的数据量较少时,会使用ziplist存储数据。
ziplist基于连续内存实现(类似数组)。当然,它的每一个entry的大小可能不是一致的,这就需要特殊的控制手段去解决,所以才叫压缩表。那么数组有的特性它都会有,比如在lpush、lpop的时候就会有数据的搬移,时间复杂度是O(n)。所以,一般在数据元素较少时使用ziplist结构实现。
也就是同时满足下面两个条件:
- 列表中数据个数少于512个
- list中保存的每个元素的长度小于 64 字节
当不能同时满足上面两个条件的时候,list就通过双向循环链表linkedlist来实现了
Redis3.2及之后的底层实现方式:quicklist。 quicklist是一个双向链表,而且是一个基于ziplist的双向链表,quicklist的每个节点都是一个ziplist,结合了双向链表和ziplist的优点。
命令使用
命令 | 简述 | 使用 |
---|---|---|
RPUSH | 将给定值推入到列表右端 | RPUSH key value |
LPUSH | 将给定值推入到列表左端 | LPUSH key value |
RPOP | 从列表的右端弹出一个值,并返回被弹出的值 | RPOP key |
LPOP | 从列表的左端弹出一个值,并返回被弹出的值 | LPOP key |
LRANGE | 获取列表在给定范围上的所有值 | LRANGE key 0 -1 |
LINDEX | 通过索引获取列表中的元素。你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。 | LINDEX key index |
实际应用
- 消息队列。使用lpush,brpop两个命令可以模拟一个消息队列。其中brpop key time为阻塞式弹出,当队列中为空时会阻塞当前操作,该操作需要添加超时参数,单位为秒。
- **有限集合。**使用ltrim key start end操作可以获取一个固定位置的数据,可以快速实现一个有限的集合。
Hash(字典)
Hash的特性我类似Java集合中的HashMap,一个hash中可以有多个field:value(键值对)。
底层实现方式:压缩列表ziplist 或者 字典dict。只有当存储的数据量比较小的情况下,Redis 才使用压缩列表来实现字典类型。
具体需要满足两个条件:
1)字典中保存的键和值的大小都要小于 64 字节;
2)字典中键值对的个数要小于 512 个。
当不能同时满足上面两个条件的时候,Redis 就使用散列表来实现字典类型。散列表分别是分别是:dict、dictht、entry。
Redis 使用
MurmurHash2
这种运行速度快、随机性好的哈希算法作为哈希函数。对于哈希冲突问题,Redis 使用链表法来解决。除此之外,Redis 还支持散列表的动态扩容、缩容。当数据动态增加之后,散列表的装载因子会不停地变大。为了避免散列表性能的下降,当装载因子大于 1 的时候,Redis 会触发扩容,将散列表扩大为原来大小的 2 倍左右。 Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典, 内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
命令使用
命令 | 简述 | 使用 |
---|---|---|
HSET | 添加键值对 | HSET hash-key sub-key1 value1 |
HGET | 获取指定散列键的值 | HGET hash-key key1 |
HGETALL | 获取散列中包含的所有键值对 | HGETALL hash-key |
HDEL | 如果给定键存在于散列中,那么就移除这个键 | HDEL hash-key sub-key1 |
实际应用
1)存放对象Object。
从宏观上来说,这种一个key , field1 : value1 ,field2 : value2 一个键值对应多个字段field的格式非常适合用于描写一个对象。所以hash一般会用于描述一个对象,但其实我们在实际中也有可能会用一个Json格式的字符串来描述一个对象。
而利用hash描述一个对象:可以做到序列化开销小,可以单独修改某一个字段而不用读出全部数据,但是使用比较复杂。而使用json描述对象,使用简单但需要耗费额外的序列化开销。需要使用什么形式。
**2)结合Json描述对象的集合。**例如,在商城应用中,可以利用Hash的key来描述一个用户的id,而field用于描述用户的购物车列表中的一个物品的详细信息。
Set(集合)
集合这种数据类型用来存储一组不重复的数据。
底层实现方式:有序整数集合intset 或者 字典dict。当存储的数据同时满足下面这样两个条件的时候,Redis 就采用整数集合intset来实现set这种数据类型:
- 存储的数据都是整数
- 存储的数据元素个数小于512个
当不能同时满足这两个条件的时候,Redis 就使用字典dict来存储集合中的数据。
**Redis 的 Set 是 String 类型的无序集合,它是通过 HashTable 实现的。**相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL
。当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。
**IntSet是全部value都为整型时,这种数据结构Redis在数据都为整型的时候使用了一种基于动态数组的结构体,同时在存放元素时保正元素的大小顺序,这样就可以使用二分查找以时间复杂度O(logn)来完成增删改查的操作。**这样就节省了很多空间。至于增和删操作,同样会涉及到数组的数据搬移操作。
**当存在非整型数据的时候,Redis会自动把IntSet转换为HashTable的结构存放数据,但HashTable不能转换为IntSet。**这里的HashTable与上面Hash结构提到的HashTable没有太大的差别。唯一的差别就在于Set存放在HashTable中只有Key值,没有value值,所以在HashTable的Entry中,Enrty的value永远为null。
命令使用
命令 | 简述 | 使用 |
---|---|---|
SADD | 向集合添加一个或多个成员 | SADD key value |
SCARD | 获取集合的成员数 | SCARD key |
SMEMBERS | 返回集合中的所有成员 | SMEMBERS key member |
SISMEMBER | 判断 member 元素是否是集合 key 的成员 | SISMEMBER key member |
实际应用
1) **记录唯一的事物。**如ip值,身份证等。
2)**随机用户抽奖。**通过srandmember key 随机返回一个set中的数据。
3)**用户标签。**当用户在使用某个产品的时候,后台可能会记录该用户对某个东西的喜好,从而在该标签中记录该用户。同时,可以利用sinter key1 key2、sunion、sdiff返回标签中的交集、并集、差集。这样就可以轻松得出用户的共同喜好、所有喜好、非共同喜好等数据。
ZSet(sorted set)(有序集合)
**是一个实现了数据有序且唯一的键值对集合。其中,Entry的键为string类型,值为整型或浮点型,表示权值score。其中SortSet的顺序就是通过Score的值来确定的。**Redis 正是通过分数来为集合中的成员进行从小到大的排序。zset 的成员是唯一的,但分数(score)却可以重复。
**底层实现方式:压缩列表ziplist 或者 zset。**当 sorted set 的数据同时满足下面这两个条件的时候,就使用压缩列表ziplist来实现
- 元素个数要小于 128 个,也就是ziplist数据项小于256个
- 集合中每个数据大小都小于 64 字节
当不能同时满足这两个条件的时候,Redis 就使用zset来实现sorted set,这个zset包含一个dict(类似hashTbale) + 一个skiplist(跳表)。dict用来查询数据到分数(score)的对应关系,是优化增删改查的时间复杂度;而skiplist可以保证有序的情况下,优化范围查找的时间复杂度。
skiplist跳表是一种基于有序链表,通过建立多层索引,以空间换时间的方式实现平均查找效率为O(logn)复杂度的一种数据结构。
每一个节点可以看成一个跳表的节点同时也是HashTable中的一个节点。
- 在看跳表的时候,我们需要忽略hnext指针,每个节点通过双向链表来保证有序性。
- 在看HashTable的时候,可以忽略prev指针和next指针。看上去就是一个用拉链法解决冲突的HashTable,而hnext就是指向下一节点的指针。
命令使用
命令 | 简述 | 使用 |
---|---|---|
ZADD | 将一个带有给定分值的成员添加到有序集合里面 | ZADD zset-key 178 member1 |
ZRANGE | 根据元素在有序集合中所处的位置,从有序集合中获取多个元素 | ZRANGE zset-key 0-1 withccores |
ZREM | 如果给定元素成员存在于有序集合中,那么就移除这个元素 | ZREM zset-key member1 |
实际应用
-
排行榜。使用zrange key start end。根据热度、积分、评论等可以衡量的权值Score进行排行,其中score排序为从小到大,用ZREVRANGE实现从大到小排序。
-
**获取某个权值范围的用户。**例如在应用中获取积分为80到100的用户,可以使用ZRANGEBYSCORE key 80 100 WITHSCORES来输出score在80到100间的用户。
Stream(可持久化的消息队列)
Redis5.0 新增的数据类型。
基于Reids的消息队列实现有很多种,例如:
- **PUB/SUB,订阅/发布模式。**但是发布订阅模式是无法持久化的,如果出现网络断开、Redis 宕机等,消息就会被丢弃;
- **基于List LPUSH+BRPOP/基于Sorted-Set的实现 。**支持了持久化,但是不支持多播,分组消费等
Stream的结构
每个 Stream 都有唯一的名称,它就是 Redis 的 key,在首次使用 xadd 指令追加消息时自动创建。
Consumer Group
:消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer), 这些消费者之间是竞争关系。
last_delivered_id
:游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。
pending_ids
:消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有ack
(Acknowledge character:确认字符)。如果客户端没有ack,这个变量里面的消息ID会越来越多,一旦某个消息被ack,它就开始减少。这个pending_ids变量在Redis官方被称之为PEL,也就是Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
消息ID
: 消息ID的形式是timestampInMillis-sequence,例如1527846880572-5,它表示当前的消息在毫米时间戳1527846880572时产生,并且是该毫秒内产生的第5条消息。消息ID可以由服务器自动生成,也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的ID要大于前面的消息ID。
消息内容
: 消息内容就是键值对,形如hash结构的键值对,这没什么特别之处。
增删改查
XADD - 添加消息到末尾
XTRIM - 对流进行修剪,限制长度
XDEL - 删除消息
XLEN - 获取流包含的元素数量,即消息长度
XRANGE - 获取消息列表,会自动过滤已经删除的消息
XREVRANGE - 反向获取消息列表,ID 从大到小
XREAD - 以阻塞或非阻塞方式获取消息列表
独立消费
**在不定义消费组的情况下,默认进行Stream消息的独立消费。当Stream没有新消息时,甚至可以阻塞等待。Redis设计了一个单独的消费指令xread,可以将Stream当成普通的消息队列(list)来使用。使用xread时,我们可以完全忽略消费组(Consumer Group)的存在,就好比Stream就是一个普通的列表(list)。**客户端如果想要使用xread进行顺序消费,一定要记住当前消费到哪里了,也就是返回的消息ID。下次继续调用xread时,将上次返回的最后一个消息ID作为参数传递进去,就可以继续消费后续的消息。
block 0表示永远阻塞,直到消息到来,block 1000表示阻塞1s,如果1s内没有任何消息到来,就返回nil
消费组消费
**Stream通过xgroup create指令创建消费组(Consumer Group),需要传递起始消息ID参数用来初始化last_delivered_id变量。Stream提供了xreadgroup指令可以进行消费组的组内消费,需要提供消费组名称、消费者名称和起始消息ID。**它同xread一样,也可以阻塞等待新消息。读到新消息后,对应的消息ID就会进入消费者的PEL(正在处理的消息)结构里,客户端处理完毕后使用xack指令通知服务器,本条消息已经处理完毕,该消息ID就会从PEL中移除。
相关命令:
- XGROUP CREATE - 创建消费者组
- XREADGROUP GROUP - 读取消费者组中的消息
- XACK - 将消息标记为"已处理"
- XGROUP SETID - 为消费者组设置新的最后递送消息ID
- XGROUP DELCONSUMER - 删除消费者
- XGROUP DESTROY - 删除消费者组
- XPENDING - 显示待处理消息的相关信息
- XCLAIM - 转移消息的归属权
- XINFO - 查看流和消费者组的相关信息;
- XINFO GROUPS - 打印消费者组的信息;
- XINFO STREAM - 打印流信息
另外Stream提供了XINFO来实现对服务器信息的监控。
实际应用
可用作时通信等,大数据分析,异地数据备份等
问题延展1:消息ID的设计
XADD生成的1553439850328-0,就是Redis生成的消息ID,由两部分组成:时间戳-序号。时间戳是毫秒级单位,是生成消息的Redis服务器时间,它是个64位整型(int64)。序号是在这个毫秒时间点内的消息序号,它也是个64位整型。
由于一个redis命令的执行很快,所以可以看到在同一时间戳内,是通过序号递增来表示消息的。为了保证消息是有序的,因此Redis生成的ID是单调递增有序的。由于ID中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis的每个Stream类型数据都维护一个latest_generated_id属性,用于记录最后一个消息的ID。若发现当前时间戳退后(小于latest_generated_id所记录的),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用int64的原因,保证有足够多的的序号),从而保证ID的单调递增性质。
问题延展1:消费者崩溃会不会导致消息丢失?
STREAM 设计了 Pending 列表,用于记录读取但并未处理完毕的消息。命令XPENDIING 用来获消费组或消费内消费者的未处理完毕的消息。也就是说如果只是记录读取,不管读取多少次都显示没有完成,还是能进行消费的。可以通过使用命令 XACK 完成告知消息处理完成。
每个Pending的消息有4个属性:
- 消息ID
- 所属消费者
- IDLE,已读取时长
- delivery counter,消息被读取次数
问题延展3:消费者宕机后如何转移给其它消费者处理?
可以使用语法XCLAIM来实现消息转移,消息转移的操作时将某个消息转移到自己的Pending列表中。需要设置组、转移的目标消费者和消息ID,同时需要提供IDLE(已被读取时长),只有超过这个时长,才能被转移。
转移除了要指定ID外,还需要指定IDLE,保证是长时间未处理的才被转移。被转移的消息的IDLE会被重置,用以保证不会被重复转移,以为可能会出现将过期的消息同时转移给多个消费者的并发操作,设置了IDLE,则可以避免后面的转移不会成功,因为IDLE不满足条件。
问题延展4:坏消息问题,Dead Letter,死信问题
如果某个消息,不能被消费者处理,也就是不能被XACK,这是要长时间处于Pending列表中,即使被反复的转移给各个消费者也是如此。此时该消息的delivery counter就会累加,当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用XDEL语法。
bitmaps(位图)
**Redis 提供了位图数据结构,主要用于存储Boolean型的数据。**比如:统计用户信息,活跃,不活跃! 登录,未登录! 打卡,不打卡! 两个状态的数据。
位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理。Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自动将位数组进行零扩充。
HyperLogLog(基数统计的算法)
Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。但是会有误差。
HyperLogLogs 基数统计用来解决什么问题?
这个结构可以非常省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数、页面实时UV、在线用户数,共同好友数等。
一个大型的网站,每天 IP 比如有 100 万,粗算一个 IP 消耗 15 字节,那么 100 万个 IP 就是 15M。**而 HyperLogLog 在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么,它一个基于基数估算的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。**而且这个估算的基数并不一定准确,是一个带有 0.81% 标准错误的近似值。
命令 | 用法 | 描述 |
---|---|---|
pfadd | [PFADD key element [element …] | 添加指定元素到 HyperLogLog 中 |
pfcount | [PFCOUNT key [key …] | 返回给定 HyperLogLog 的基数估算值。 |
pfmerge | [PFMERGE destkey sourcekey [sourcekey …] | 将多个 HyperLogLog 合并为一个 HyperLogLog |
geospatial (地理位置)
主要用于推算地理位置的信息: 两地之间的距离, 方圆几里的人。
思维导图
Redis持久化
Redis 的数据全部在内存里,通过 Redis 的持久化机制,它会将内存中的数据库状态 保存到磁盘 中。Redis 有两种持久化的方式:快照(RDB
文件)和追加式文件(AOF
文件)。
RDB
在指定的时间间隔内将内存中的所有数据集快照写入磁盘,它执行的是全量快照,它恢复时是将快照文件直接读到内存里。RDB 的缺点是最后一次持久化后的数据可能丢失。
Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化(在执行快照的同时,正常处理写操作), fork 是类 Unix 操作系统上创建进程的主要方法。COW(Copy On Write)是计算机编程中使用的一种优化策略。
fork 的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程。 子进程读取数据,然后序列化写到磁盘中。
触发 RDB 快照
除了通过配置文件的方式,自动触发生成快照,也可以使用命令手动触发。
- save:save 时只管保存,在主线程中执行,会导致阻塞,所以请慎用;
- bgsave:**bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。**可以理解为
background save
,当执行 bgsave 命令时,redis 会 fork 出一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。可以通过lastsave
命令获取最后一次成功执行快照的时间 - 执行 flushall 命令,也会产生 dump.rdb 文件,但里面是空的,无意义
- 客户端执行 shutdown 关闭 redis 时,也会触发快照
快照的运作方式
当 Redis 需要保存 dump.rdb 文件时, 服务器执行以下操作:
- Redis 调用
fork()
,产生一个子进程,此时同时拥有父进程和子进程。 - 父进程继续处理 client 请求,子进程负责将内存内容写入到临时文件。由于 os 的写时复制机制,父子进程会共享相同的物理页面,当父进程处理写请求时, os 会为父进程要修改的页面创建副本,而不是写共享的页面。所以子进程的地址空间内的数据是 fork 时刻整个数据库的一个快照。
- 当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。
这种工作方式使得 Redis 可以从写时复制(copy-on-write)机制中获益。
如何恢复
将备份文件 (dump.rdb) 移动到 Redis 安装目录并启动服务即可(CONFIG GET dir
获取目录)
总结
适合灾难恢复,无法保证高可用性;另外RDB是通过fork子进程来协助,会可能存在2倍的膨胀性,数据集较大时,会导致整个服务停止服务。
为什么不每秒做一次快照?
一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了。
AOF
**以日志的形式来记录每个写操作,将 Redis 执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件,Redis 启动之初会读取该文件重新构建数据,也就是「重放」。**换言之,Redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。Redis先执行命令后记录,可以避免出现记录错误命令的情况。
AOF 启动/修复/恢复
- 正常恢复
- 启动:修改默认的 appendonly no,改为 yes
- 将有数据的 aof 文件复制一份保存到对应目录(
config get dir
) - 恢复:重启 redis 然后重新加载
- 异常恢复
- 启动:修改默认的 appendonly no,改为 yes
- 备份被写坏的 AOF 文件
- 修复:redis-check-aof --fix 进行修复 + AOF 文件
- 恢复:重启 redis 然后重新加载
三种写回策略 | AOF 耐久性
配置项 | 写回时机 | 优点 | 缺点 |
---|---|---|---|
Always | 同步写回 | 可靠性高,数据基本不丢失 | 每个写命令都要落盘,性能影响较大,慢但是安全 |
Everysec | 每秒写回 | 性能适中 | 宕机时丢失1秒内的数据 |
No | 操作系统控制的写回 | 性能好 | 宕机时丢失数据较多 |
AOF 重写
AOF 采用文件追加方式,文件会越来越大,为了避免出现这种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令 bgrewriteaof
,这个操作相当于对 AOF 文件“瘦身”。
-
重写原理:
AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写(也是先写临时文件最后再rename),遍历新进程的内存中数据,转换成一条条的操作指令,再序列化到一个新的 AOF 文件中。
重写 AOF 文件的操作,并没有读取旧的 AOF 文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 AOF 文件。
-
触发机制:
Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于 64M 时触发。
在客户端输入两次
set k1 v1
,然后比较bgrewriteaof
前后两次的 appendonly.aof 文件(先要关闭混合持久化)
总结
提供了三种策略每秒同步、每修改同步和不同步保证持久性,但是可能存在少部分数据会丢失;AOF 文件是一个只进行追加的日志文件,在 AOF 文件体积变得过大时,自动在后台对 AOF 进行重写;由于写入数据文件的体积比RDB大,所以恢复速度比RDB慢。
持久化整理
Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。也就是将 RDB 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志。
Redis4.0 版本的混合持久化功能 默认关闭,我们可以通过
aof-use-rdb-preamble
配置参数控制该功能的启用。5.0 版本之后 默认开启。
Redis 事务
为了确保连续多个操作的原子性,我们常用的数据库都会有事务的支持,Redis 也不例外,每个事务的操作都有 begin、commit 和 rollback,begin 指示事务的开始,commit 指示事务的提交,rollback 指示事务的回滚。
Redis 在形式上看起来也差不多,分为三个阶段
- 开启事务(multi)
- 命令入队(业务操作)
- 执行事务(exec)或取消事务(discard)
begin();
try {
command1();
command2();
....
commit();
} catch(Exception e) {
rollback();
}
事务可以一次执行多个命令, 并且带有以下两个重要的保证:
- 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行
事务中的错误
- 事务在执行
EXEC
之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误等等),或者其他更严重的错误,比如内存不足(如果服务器使用maxmemory
设置了最大内存限制的话)。 - 命令可能在
EXEC
调用之后失败。事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。
处理策略
1、全体连坐
2、冤头债主
对于发生在 EXEC
执行之前的错误,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC
命令时,拒绝执行并自动放弃这个事务。
对于那些在 EXEC
命令执行之后所产生的错误, 并没有对它们进行特别处理: 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行。
带 Watch 的事务
**WATCH
命令用于在事务开始之前监视任意数量的键: 当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了,那么整个事务将被打断,不再执行, 直接返回失败。 WATCH
命令可以被调用多次。 对键的监视从 WATCH
执行之后开始生效, 直到调用 EXEC 为止。当 EXEC
被调用时, 不管事务是否成功执行, 对所有键的监视都会被取消。**另外, 当客户端断开连接时, 该客户端对键的监视也会被取消。
WATCH 命令的实现原理
在代表数据库的 server.h/redisDb
结构类型中, 都保存了一个 watched_keys
字典, 字典的键是这个数据库被监视的键, 而字典的值是一个链表, 链表中保存了所有监视这个键的客户端,WATCH
命令的作用, 就是将当前客户端和要监视的键在 watched_keys
中进行关联。
通过 watched_keys
字典, 如果程序想检查某个键是否被监视; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值(一个链表), 然后对链表进行遍历即可。
当客户端发送 EXEC 命令、触发事务执行时, 服务器会对客户端的状态进行检查:
- 如果客户端的
CLIENT_DIRTY_CAS
选项已经被打开,那么说明被客户端监视的键至少有一个已经被修改了,事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端返回空回复,表示事务执行失败。- 如果
CLIENT_DIRTY_CAS
选项没有被打开,那么说明所有监视键都安全,服务器正式执行事务。
总结
3 个阶段
- 开启:以 MULTI 开始一个事务
- 入队:将多个命令入队到事务中,接到这些命令并不会立即执行,而是放到等待执行的事务队列里面
- 执行:由 EXEC 命令触发事务
3 个特性
- 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 没有隔离级别的概念:队列中的命令没有提交之前都不会实际的被执行,因为事务提交前任何指令都不会被实际执行,也就不存在”事务内的查询要看到事务里的更新,在事务外查询不能看到”这个让人万分头痛的问题
- 不保证原子性:Redis 同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚
在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的安全性。Redis 事务保证了其中的一致性(C)和隔离性(I),但并不保证原子性(A)和持久性(D)。
最后
Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络 IO 时间也会线性增长。所以通常 Redis 的客户端在执行事务时都会结合 pipeline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作。
Redis分布式锁
什么是分布式锁
分布式锁是控制不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰来保证一致性。
锁的使用场景有来看下边这 3 种锁:
线程锁:
synchronized
是用在方法或代码块中的,我们把它叫『线程锁』,线程锁的实现其实是靠线程之间共享内存实现的,说白了就是内存中的一个整型数,有空闲、上锁这类状态,比如 synchronized 是在对象头中的 Mark Word 有个锁状态标志,Lock 的实现类大部分都有个叫volatile int state
的共享变量来做状态标志。进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过 synchronized 等线程锁实现进程锁。比如说,我们的同一个 linux 服务器,部署了好几个 Java 项目,有可能同时访问或操作服务器上的相同数据,这就需要进程锁,一般可以用『文件锁』来达到进程互斥。
分布式锁:随着用户越来越多,我们上了好多服务器,原本有个定时给客户发邮件的任务,如果不加以控制的话,到点后每台机器跑一次任务,客户就会收到 N 条邮件,这就需要通过分布式锁来互斥了。
分布式锁的实现要求
- 互斥:在任意时刻,只能有一个客户端能持有锁。
- 防止死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。所以锁一般要有一个过期时间。
- 独占性:解铃还须系铃人,加锁和解锁必须是同一个客户端,一把锁只能有一把钥匙,客户端自己的锁不能被别人给解开,当然也不能去开别人的锁。
- 容错:外部系统不能太“脆弱”,要保证外部系统的正常运行,客户端才可以加锁和解锁。
分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于 Redis 的分布式锁;3. 基于 ZooKeeper 的分布式锁。
为什么不采用数据库乐观锁
需要注意两点:主键防重,版本号控制
- 使用主键冲突的策略进行防重,影响性能。在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象,比较好的办法是在程序中生产主键进行防重。
- **使用版本号策略。**源于 MySQL 的 MVCC 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 SQL 每次进行判断。
基于 Redisson 实现的 Redis 分布式锁
官网提供了各种手段去用 Redis 实现分布式锁,建议用 Redlock 实现。
Redis 实现分布式锁的主要步骤:
- 指定一个 key 作为锁标记,存入 Redis 中,指定一个 唯一的标识 作为 value。
- 当 key 不存在时才能设置值,确保同一时间只有一个客户端进程获得锁,满足 互斥性 特性。
- 设置一个过期时间,防止因系统异常导致没能删除这个 key,满足 防死锁 特性。
- 当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足 解铃还须系铃人 。
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,只是配置的不同。 RedissonLock 是 RLock 的直接实现,也是我们加锁、解锁操作的核心类。
Redisson 提供了看门狗,每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间,在释放锁的同时结束这个线程(设置合适的过期时间)。
RedLock
极端情况下会存在以下这些问题:
- 客户端长时间内阻塞导致锁失效
- Redis 服务器时钟漂移
- 单点实例安全问题
为了解决以上问题,提出了RedLock 红锁的算法,在 Redission 中也对 RedLock 进行了实现。
总结:
- 客户端在多个 Redis 实例上申请加锁,必须保证大多数节点加锁成功。解决容错性问题,部分实例异常,剩下的还能加锁成功。
- 大多数节点加锁的总耗时,要小于锁设置的过期时间。多实例操作,可能存在网络延迟、丢包、超时等问题,所以就算是大多数节点加锁成功,如果加锁的累积耗时超过了锁的过期时间,那有些节点上的锁可能也已经失效了,还是没有意义的。
- **释放锁,要向全部节点发起释放锁请求。**如果部分节点加锁成功,但最后由于异常导致大部分节点没加锁成功,就要释放掉所有的,各节点要保持一致。
Redis主从
**Redis 具有高可靠性,其实,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断。**AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是增加副本冗余量,将一份数据同时保存在多个实例上,来避免单点故障。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。这就是 Redis 的主从模式,主从库之间采用的是读写分离的方式。
主从复制
主从复制,或者叫 主从同步,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。且数据的复制是 单向 的,只能由主节点到从节点。Redis 主从复制支持 主从同步 和 从从同步 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。
主从复制的目的
- 数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 (实际上是一种服务的冗余)。
- 负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 (即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
- 高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的 基础,因此说主从复制是 Redis 高可用的基础。
主从复制的工作过程
全量复制 | 快照同步
复制的步骤简化成三个阶段**:**
**1)建立连接阶段。**从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
FULLRESYNC 响应这一步其实还有很多其他的流程,比如从节点会发送 ping 检查 socket 连接是否可用。如果 master 设置了 requirepass ,那 slave 节点就必须设置 masterauth 选项来进行身份验证…。
**2)数据同步阶段。**主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。
3)命令传播阶段。主库会把第二阶段执行过程中新收到的写命令,再发送给从库。
主库压力问题 | 主从级联模式
从主从库之间的第一次数据同步过程,可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件。 Redis 也支持 “ 主 - 从 - 从” 这样的模式,就是通过级联的方式,将主库的压力分担给部分从库。
如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。
无盘复制
主节点在进行快照同步时,会进行很重的文件 IO 操作,特别是对于非 SSD 磁盘存储时,快照会对系统的负载产生较大影响。特别是当系统正在进行 AOF 的 fsync 操作时如果发生快照,fsync 将会被推迟执行,这就会严重影响主节点的服务效率。
无盘复制是指主服务器直接通过套接字 socket 将快照内容发送到从节点,生成快照是一个遍历的过程,主节点会一边遍历内存,一边将序列化的内容发送到从节点,从节点还是跟之前一样,先将接收到的内容存储到磁盘文件中,再进行一次性加载。
命令传播
一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为 基于长连接的命令传播,可以避免频繁建立连接的开销。
在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PING 和 REPLCONF ACK。心跳机制对于主从复制的超时判断、数据安全等有作用。
增量复制 | 部分复制
在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。
从 Redis 2.8 开始,网络断了之后,主从库会采用 增量复制 的方式继续同步。
增量复制的原理主要是靠主从节点分别维护一个 复制偏移量,有了这个偏移量,断线重连之后一比较,之后就可以仅仅把从服务器断线之后缺失的这部分数据给补回来了。全量复制中有 replication buffer
这样的缓存区来保存 RDB 文件生成后收到的所有写操作,增量复制中也有一个缓存区,叫 repl_backlog_buffer
,默认是 1M。
总结
Redis 的主从库同步的基本原理,总结来说,有三种模式:全量复制、基于长连接的命令传播,以及增量复制。
全量复制虽然耗时,但是对于从库来说,如果是第一次同步,全量复制是无法避免的,所以,一个 Redis 实例的数据库不要太大,一个实例大小在几 GB 级别比较合适,这样可以减少 RDB 文件生成、传输和重新加载的开销。另外,为了避免多个从库同时和主库进行全量复制,给主库过大的同步压力,我们也可以采用“主 - 从 - 从”这一级联模式,来缓解主库的压力。
Redis Sentinel 哨兵
哨兵架构图,它由两部分组成,哨兵节点和数据节点:
- 哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据;
- 数据节点: 主节点和从节点都是数据节点;
在复制的基础上,哨兵实现了 自动化的故障恢复 功能,下面是官方对于哨兵功能的描述:
-
监控(Monitoring): 哨兵会不断地检查主节点和从节点是否运作正常。
监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。
-
通知(Notification): 当被监控的某个 Redis 服务器出现问题时, 哨兵可以通过 API 向管理员或者其他应用程序发送通知。
在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行
replicaof
命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。 -
自动故障转移(Automatic failover)/ 选主: 当 主节点 不能正常工作时,哨兵会开始 自动故障转移操作,它会将失效主节点的其中一个 从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
-
配置提供者(Configuration provider): 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。
其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。
哨兵机制的工作流程
其实哨兵主要负责的就是三个任务:监控、选主和通知。
在监控和选主过程中,哨兵都需要做一些决策,比如
- 在监控任务中,哨兵需要判断主库、从库是否处于下线状态
- 在选主任务中,哨兵也要决定选择哪个从库实例作为主库
这就引出了两个概念,“主观下线”和“客观下线”
哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。
在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。
选举领导者哨兵节点
当主节点被判断客观下线以后,各个哨兵节点会进行协商,选举出一个领导者哨兵节点,并由该领导者节点对其进行故障转移操作。监视该主节点的所有哨兵都有可能被选为领导者,选举使用的算法是 Raft 算法;Raft 算法的基本思路是先到先得:即在一轮选举中,哨兵 A 向 B 发送成为领导者的申请,如果 B 没有同意过其他哨兵,则会同意 A 成为领导者。
故障转移
选举出的领导者哨兵,开始故障转移操作,大概分 3 步:
-
第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器。
筛选规则:
1)优先级最高的从库得分最高。
2)和旧主库同步程度最接近的从库得分高。
3)ID 号小的从库得分高。
-
第二步,**更新主从状态。**向选出的从服务器发送
slaveof no one
命令,将这个从服务器转换为主服务器,并通过slaveof
命令让其他节点成为其从节点。 -
第三步将已下线的主节点设置为从节点。
哨兵集群的原理
一旦多个实例组成了哨兵集群,即使有哨兵实例出现故障挂掉了,其他哨兵还能继续协作完成主从库切换的工作,包括判定主库是不是处于下线状态,选择新主库,以及通知从库和客户端。
Redis 哨兵通过三个定时监控任务完成对各个节点发现和监控:
每隔 10 秒,每个哨兵节点会向主节点和从节点发送 info 命令获取最新的拓扑结构
这个定时任务的作用具体可以表现在三个方面:
- 通过向主节点执行 info 命令,获取从节点的信息,这也是为什么哨兵节点不需要显式配置监控从节点。
- 当有新的从节点加入时都可以立刻感知出来。
- 节点不可达或者故障转移后,可以通过 info 命令实时更新节点拓扑信息
每隔 2 秒,每个哨兵节点会向 Redis 数据节点的 sentinel:hello 频道上发送该哨兵节点对于主节点的判断以及当前哨兵节点的信息,同时每个哨兵节点也会订阅该频道,来了解其他哨兵节点以及它们对主节点的判断。
这个定时任务可以完成以下两个工作:
- 发现新的哨兵节点:通过订阅主节点的
__sentinel__:hello
了解其他的哨兵节点信息,如果是新加入的哨兵节点,将该哨兵节点信息保存起来,并与该哨兵节点创建连接。- 哨兵节点之间交换主节点的状态,作为后面客观下线以及领导者选举的依据。
每隔 1 秒,每个哨兵节点会向主节点、从节点、其余哨兵节点发送一条 ping 命令做一次心跳检测,来确认这些节点当前是否可达。
通过这个定时任务,哨兵节点对主节点、从节点、其余哨兵节点都建立起连接,实现了对每个节点的监控,这个定时任务是节点失败判定的重要依据
1)基于 pub/sub 机制的哨兵集群组成。
哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是 发布/订阅 机制。
2)哨兵和从库的连接
哨兵除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在哨兵的监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知从库,让它们和新主库进行同步。
3)哨兵和客户端的连接
哨兵不能只和主、从库连接。因为,主从库切换后,客户端也需要知道新主库的连接信息,才能向新主库发送请求操作。所以,哨兵还需要完成把新主库的信息告诉客户端这个任务。
总结
哨兵机制其实就有三大功能:
- 监控:监控主库运行状态,并判断主库是否客观下线;
- 选主:在主库客观下线后,选取新主库;
- 通知:选出新主库后,通知从库和客户端。
一个哨兵,实际上可以监控多个主节点,通过配置多条 sentinel monitor 即可实现。
哨兵集群的关键机制:
- 哨兵集群是基于 pub/sub 机制组成的
- 基于 INFO 命令的从库列表,这可以帮助哨兵和从库建立连接
- 基于哨兵自身的 pub/sub 功能,这实现了客户端和哨兵之间的事件通知
Sentinel
与 Redis
主节点 和 从节点 交互的命令,主要包括:
命令 | 作 用 |
---|---|
PING | Sentinel 向 Redis 节点发送 PING 命令,检查节点的状态 |
INFO | Sentinel 向 Redis 节点发送 INFO 命令,获取它的 从节点信息 |
PUBLISH | Sentinel 向其监控的 Redis 节点 __sentinel__:hello 这个 channel 发布 自己的信息 及 主节点 相关的配置 |
SUBSCRIBE | Sentinel 通过订阅 Redis 主节点 和 从节点 的 __sentinel__:hello 这个 channnel ,获取正在监控相同服务的其他 Sentinel 节点 |
Sentinel
与 Sentinel
交互的命令,主要包括:
命令 | 作 用 |
---|---|
PING | Sentinel 向其他 Sentinel 节点发送 PING 命令,检查节点的状态 |
SENTINEL:is-master-down-by-addr | 和其他 Sentinel 协商 主节点 的状态,如果 主节点 处于 SDOWN 状态,则投票自动选出新的 主节点 |
Redis集群
集群由多个节点(Node)组成,Redis 的数据分布在这些节点中。集群中的节点分为主节点和从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主节点数据和状态信息的复制。
集群的作用
- 数据分区: 数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。
- 高可用: 集群支持主从复制和主节点的 自动故障转移 (与哨兵类似),当任一节点发生故障时,集群仍然可以对外提供服务。
集群的基本原理
Redis 集群中内置了 16384
个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 集群的配置信息。
当客户端具体对某一个 key
值进行操作时,会计算出它的一个 Hash 值,然后把结果对 16384
求余数,这样每个 key
都会对应一个编号在 0-16383
之间的哈希槽,Redis 会根据节点数量 大致均等 的将哈希槽映射到不同的节点。再结合集群的配置信息就能够知道这个 key
值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 MOVED
命令来进行一个跳转,告诉客户端去连接这个节点以获取数据。
深入集群原理
Redis 集群最核心的功能就是数据分区,数据分区之后又伴随着通信机制和数据结构的建设,主要从三个方面入手:数据分区方案、集群功能受限、节点通讯。
数据分区方案
数据分区有顺序分区、哈希分区等,其中哈希分区由于其天然的随机性,使用广泛;集群的分区方案便是哈希分区的一种。
哈希分区的基本思路是:对数据的特征值(如key)进行哈希,然后根据哈希值决定数据落在哪个节点。常见的哈希分区包括:哈希取余分区、一致性哈希分区、带虚拟节点的一致性哈希分区等。
1)哈希取余分区。计算 key
的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。但是当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要 重新计算映射关系,引发大规模数据迁移。
2)**一致性哈希分区。**一致性哈希算法将 整个哈希值空间 组织成一个虚拟的圆环,范围一般是 0 - ,对于每一个数据,根据 key
计算 hash 值,确定数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器。但是会存在当 节点数量较少 时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。
3)带有虚拟节点的一致性哈希分区。该方案在 一致性哈希分区的基础上,引入了 虚拟节点 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 槽(slot)。
槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。槽的范围一般远大于节点数。槽是数据管理和迁移的基本单位。槽 解耦 了 数据和实际节点 之间的关系,增加或删除节点对系统的影响很小。
Redis 虚拟槽分区的特点:
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
- 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
- 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。
集群功能限制
Redis 集群相对单机在功能上存在一些限制。例如:
1)key 批量操作支持有限
2)key 事务操作支持有限。
3)key 作为数据分区的最小粒度,因此不能将一个大的键值对象如 hash、list 等映射到不同的节点。
4)不支持多数据库空间。单机下的 Redis 可以支持 16 个数据库,集群模式下只能使用一个数据库空间,即 db0。
5)复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
节点通讯
**集群的建立离不开节点之间的通信,**例如我们上面启动六个集群节点之后通过 redis-cli
命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 CLUSTER MEET
命令发送 MEET
消息完成的。
集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点出故障、新节点加入、主从角色变化、槽信息 变更等事件发生时,通过不断的
ping/pong
消息通信,经过一段时间后所有的节点都会知道整个集群全部节点的最新状态,从而达到集群状态同步的目的。通信过程说明:
- 集群中的每个节点都会单独开辟一个 TCP 通道,用于节点之间彼此通信,通信端口号在基础端口上加 10000
- 每个节点在固定周期内通过特定规则选择几个节点发送 ping 消息
- 接收到 ping 消息的节点用 pong 消息作为响应
在 哨兵系统 中,节点分为 数据节点 和 哨兵节点:前者存储数据,后者实现额外的控制功能。在 集群 中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个 TCP 端口:
- 普通端口: 即我们在前面指定的端口 (7000等)。普通端口主要用于为客户端提供服务 (与单机节点类似);但在节点间数据迁移时也会使用。
- 集群端口: 端口号是普通端口 + 10000 (10000是固定值,无法改变),如
7000
节点的集群端口为17000
。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。
- **广播是指向集群内所有节点发送消息。**优点 是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点 是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
- Gossip 协议的特点是:在节点数量有限的网络中,每个节点都 “随机” 的与部分节点通信 ,经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip 协议的 优点 有负载 (比广播) 低、去中心化、容错性高 (因为通信有冗余) 等;缺点 主要是集群的收敛速度慢。
Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播。**Gossip 协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的 Gossip 消息,**了解这些消息有助于我们理解集群如何完成信息交换。
消息类型
后续补充
集群自动故障转移
Redis 集群自身实现了高可用。高可用首先需要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。了解了哨兵机制的故障发现和故障转移。集群的实现有些思路类似,区别是一个是主节点从节点,一个是持有槽的。
Redis 消息队列(List、Streams、Pub/Sub)
当前使用较多的 消息队列 有 RabbitMQ
、RocketMQ
、ActiveMQ
、Kafka
、ZeroMQ
、MetaMQ
等,而部分数据库 如 Redis
、MySQL
以及 phxsql
。
消息队列 是指利用 高效可靠 的 消息传递机制 进行与平台无关的 数据交流,并基于数据通信来进行分布式系统的集成。通过提供 消息传递 和 消息排队 模型,它可以在 分布式环境 下提供 应用解耦、弹性伸缩、冗余存储、流量削峰、异步通信、数据同步 等等功能,
特点:
- 三个角色:生产者、消费者、消息处理中心
- 异步处理模式:生产者 将消息发送到一条 虚拟的通道(消息队列)上,而无须等待响应。消费者 则 订阅 或是 监听 该通道,取出消息。两者互不干扰,甚至都不需要同时在线,也就是我们说的 松耦合
- 可靠性:消息要可以保证不丢失、不重复消费、有时可能还需要顺序性的保证
实现消息队列
List实现消息队列
Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。所以常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。实现方式其实就是点对点的模式。
存在的问题:即时消费问题、缺少消息确认机制(可靠队列模式 | ack 机制)
Streams实现消息队列
Streams 是 Redis 专门为消息队列设计的数据类型,它提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
它就像是个仅追加内容的消息链表,把所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容,而且消息是持久化的。
发布、订阅(pub/sub) 模式
Redis 通过 PUBLISH
、 SUBSCRIBE
等命令实现了订阅与发布模式, 这个功能提供两种信息机制, 分别是订阅/发布到频道和订阅/发布到模式。
**缺点:消息无法持久化。**如果出现网络断开、Redis 宕机等,消息就会被丢弃。而且也没有 Ack 机制来保证数据的可靠性,假设一个消费者都没有,那消息就直接被丢弃了。
其他
一、Redis 内存淘汰机制
- volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
4.0 版本后增加以下两种:
- volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
- allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
二、缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
1、设置热点数据永远不过期。
2、接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些 服务 不可用时候,进行熔断,失败快速返回机制。
3、加互斥锁
三、缓存穿透
缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
- 布隆过滤器。bloomfilter就类似于一个hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于hash算法和容器大小,
四、缓存雪崩
缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。
针对 Redis 服务不可用的情况:
- 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
- 限流,避免同时处理大量的请求。
针对热点缓存失效的情况:
- 设置不同的失效时间比如随机设置缓存的失效时间。
- 缓存永不失效。
五、如何保证缓存和数据库数据的一致性
旁路缓存模式
如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:
- 缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
- 增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可
六、Redis进阶 - 版本特性:
Redis 4
模块系统、 PSYNC 2.0 、缓存驱逐策略优化、 Lazy Free 、交换数据库 、RDB/AOF混合持久化 、内存命令、 兼容 NAT 和 Docker 其他
Redis 5
Stream类型 、新的Redis模块API、 集群管理器更改、 Lua改进 、RDB格式变化、 动态HZ 、ZPOPMIN&ZPOPMAX命令、 CLIENT新增命令
Redis 6
多线程IO(读写IO多路)、 SSL支持、 ACL支持 、RESP3 客户端缓存 、集群代理、 Disque module
七、Redis 是如何判断数据是否过期的
Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。
常用的过期数据的删除策略只有两个惰性删除和定期删除。
Redisson
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格。
-
Netty 框架:Redisson采用了基于NIO的Netty框架,不仅能作为Redis底层驱动客户端,具备提供对Redis各种组态形式的连接功能,对Redis命令能以同步发送、异步形式发送、异步流形式发送或管道形式发送的功能,LUA脚本执行处理,以及处理返回结果的功能
-
基础数据结构:将原生的Redis
Hash
,List
,Set
,String
,Geo
,HyperLogLog
等数据结构封装为Java里大家最熟悉的映射(Map)
,列表(List)
,集(Set)
,通用对象桶(Object Bucket)
,地理空间对象桶(Geospatial Bucket)
,基数估计算法(HyperLogLog)
等结构, -
分布式数据结构:这基础上还提供了分布式的多值映射(Multimap),本地缓存映射(LocalCachedMap),有序集(SortedSet),计分排序集(ScoredSortedSet),字典排序集(LexSortedSet),列队(Queue),阻塞队列(Blocking Queue),有界阻塞列队(Bounded Blocking Queue),双端队列(Deque),阻塞双端列队(Blocking Deque),阻塞公平列队(Blocking Fair Queue),延迟列队(Delayed Queue),布隆过滤器(Bloom Filter),原子整长形(AtomicLong),原子双精度浮点数(AtomicDouble),BitSet等Redis原本没有的分布式数据结构。
-
分布式锁:Redisson还实现了Redis文档中提到像分布式锁
Lock
这样的更高阶应用场景。事实上Redisson并没有不止步于此,在分布式锁的基础上还提供了联锁(MultiLock)
,读写锁(ReadWriteLock)
,公平锁(Fair Lock)
,红锁(RedLock)
,信号量(Semaphore)
,可过期性信号量(PermitExpirableSemaphore)
和闭锁(CountDownLatch)
这些实际当中对多线程高并发应用至关重要的基本部件。正是通过实现基于Redis的高阶应用方案,使Redisson成为构建分布式系统的重要工具。 -
节点:Redisson作为独立节点可以用于独立执行其他节点发布到
分布式执行服务
和分布式调度服务
里的远程任务。