数据类型及使用场景
Redis 有许多重要的数据结构,其存储结构从外层往内层依次是 redisDb、dict、dictht、dictEntry。redisDb 默认情况下有16个,每个 redisDb 内部包含一个 dict 的数据结构,dict 内部包含 dictht 数组,数组个数为2,主要用于 hash 扩容使用。dictht 内部包含 dictEntry 的数组,dictEntry 其实就是 hash 表的一个 key-value 节点。
我们谈到的redis数据类型指的就是dictEntry里面value对象的数据类型。
1. String
1.1 String 存储类型
可以用来存储 String,Number,Float,Bits等等.....但是最大的大小是512M。Redis中Key也是基于String类型存储,所以最大大小也是512M
1.2 String 底层编码
以set hello world 为例,因为key是字符串,Redis自己实现了一个字符串类型,叫做SDS(Simple Dynamic String),所以hello指向一个SDS结构。而value我们已经知道是封装在redisObject里面,通过redisObject里的指针指向实际的数据结构,同样是SDS。
SDS本质上其实还是字符数组。它有多种结构(sds.h) : sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表25=32byte,28=256byte,216=65536byte=64KB, 232byte=4GB。
下图显示了C字符数组与SDS的区别:
String类型有三种编码:
int,存储8个字节的长整型(long, 2^63-1)。
embstr,代表embstr格式的SDS,存储小于44个字节的字符串。
raw,存储大于44个字节的字符串。
embstr的使用只分配一次内存空间(因为RedisObject和SDS是连续的),而raw需要分配两次内存空间(分别为RedisObject和SDS)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个RedisObject和SDS都需要重新分配空间,因此,Redis中的embstr实现为只读,无法更改。
1.3 String应用场景
缓存:缓存热点数据,可以显著提升热点数据的访问速度。
分布式数据共享: String类型,因为Redis是分布式的独立服务,可以在多个应用之间共享例如:分布式Session
分布式锁:String类型的setnx方法/只有不存在时才能添加成功,返回true。
全局ID:INT类型,INCRBY,利用原子性。
计数器:INT类型,INCR方法。
限流:INT类型,INCR方法。以访问者的IP和其他信息作为key,访问一次增加一次计数,超过次数则返回false。
2. Hash
2.1 Hash存储类型
Hash用来存储多个无序的键值对。最大存储数量232-1 (40亿左右)
前面我们说Redis所有的KV本身就是键值对,用dictEntry实现的,叫做外层的哈希。现在我们讲的是内层的哈希。Hash的value只能是字符串,不能嵌套其他类型,比如hash或者list。
Hash相对于String的优势:
a. 把所有相关的值聚集到一个key中,节省内存空间。
b. 只使用一个key,减少key冲突。
c. 当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU的消耗。
Hash不适合的场景:
a. Field不能单独设置过期时间。
b. 需要考虑数据量分布的问题(field非常多的时候,无法分布到多个节点)。
2.2 Hash 操作的基本指令
hset h1 f 6
hset h1 e 5
hmset h1 a 1 b 2 c 3 d 4
hget h1 a
hmget h1 a b c d
hkeys h1
hvals h1
hgetall h1
hincrby h1 a 10 给某个字段添加值
2.3 Hash 底层编码
ziplist是一个经过特殊编码的,由连续内存块组成的双向链表。它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度。这样读写可能会慢一些,因为要去算长度,但是可以节省内存, 是一种时间换空间的思想。
当hash对象同时满足以下两个条件的时候,使用ziplist编码:
- 哈希对象保存的键值对数量< 512个。
- 所有的键值对的键和值的字符串长度都< 64byte (—个英文字母一个字节)。
hash-max-ziplist-value 64 // ziplist 中最大能存放的值长度
hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 节点数量
如果超过这两个阈值的任何一个,存储结构就会转换成hashtable。
2.4 Hashtable
在 Redis 中,hashtable 被称为字典(dictionary)。
前面我们知道了,Redis的KV结构是通过一个dictEntry来实现的。在hashtable中,又对dictEntry进行了多层的封装。
redis的hash默认使用的是ht[O],ht[1]不会初始化和分配空间。哈希表dictht是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size属性)和它所保存的节点的数量(used属性)之间的比率。比率在1:1时(一个哈希表ht只存储一个节点entry),哈希表的性能最好;如果节点数量比哈希表的大小要大很多的话(这个比例用ratio表示,5表示平均一个ht存储5个entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。
如果单个哈希表的节点数量过多,哈希表的大小需要扩容。Redis里面的这种操作叫做 rehash。rehash的步骤如下:
为字符ht[1]哈希表分配空间。ht[1]的大小为第一个大于等于ht[0].used的2的N次方幕。比如已经使用了10000,那就是16384。
将所有的ht[O]上的节点rehash到ht[1]上,重新计算hash值和索引,然后放入指定的位置。
当ht[O]全部迁移到了 ht[1]之后,释放ht[O]的空间,将ht[1]设置为ht[O]表,并创建新的ht[1 ],为下次rehash做准备。
触发扩容的时机由负载因子决定(源码diet.c):
static int dict_can_resize = 1; // 是否需要扩容
static unsigned int dict_force_resize_ratio = 5; // 扩容因子
2.5 Hash应用场景
String可以做的事情,Hash都可以做。除此之外,还可以用于缓存对象数据。
3. List
3.1 List存储类型
存储有序的字符串(从左到右),元素可以重复。最大存储数量232-1 (40亿左右)。
3.2 基本指令
左添加元素
lpush queue a
lpush queue b c
右边添加元素
Rpush queue d e
取值
lindex queue 0
lrange queue 0 -1
3.2 List底层编码
在早期的版本中,数据量较小时用ziplist存储(特殊编码的双向链表),达到临界值时转换为linkedlist进行存储,分别对应OBJ_ENCODING_ZIPLIST和 OBJ_ENCODING_LINKEDLIST。
3.2版本之后,统一用quicklist来存储。quicklist存储了一个双向链表, 每个节点都是一个ziplist,所以是ziplist和linkedlist的结合体。总体结构:
3.3 List应用场景
列表:例如用户的消息列表、网站的公告列表、活动列表、博客的文章列表、评论列表等等。
队列/栈:List还可以当做分布式环境的队列/栈使用。
4. Set
4.1 Set存储类型
Set存储String类型的无序集合,最大存储数量232-1 (40亿左右)。
4.2 Set操作命令
//添加一个或者多个元素
sadd myset a b c d e f g
//获取所有元素
smembers myset
//统计元素个数
scard myset
//随机获取一个元素
srandmember myset
//随机弹出一个元素
spop myset
//移除一个或者多个元素
srem myset def
//查看元素是否存在
sismember myset a
4.3 Set底层编码
Redis用intset或hashtable存储set。如果元素都是整数类型,就用intset存储。
源码intset.h 35 行:
typedef struct intset (
uint32_t encoding; // 编码类型 intl6_t、int32_t、int64_t
uint32_t length; // 长度最大长度:2A32
int8_t contents[]; 〃用来存储成员的动态数组
} intset;
如果不是整数类型,就用hashtable (数组+链表的存来储结构)。如果元素个数超过512个,也会用hashtable存储。跟一个配置有关:
set-max-intset-entries 512
4.4 Set应用场景
抽奖
随机获取元素:spop myset
点赞、签到、打卡
微博的ID是t1001z,用户ID是u3001。用like:t1001来维护t1001这条微博的所有点赞用户。
- 点赞了这条微博:sadd like:t1001 u3001
- 取消点赞:srem like:t1001 u3001
- 是否点赞:sismember like:t1001 u3001
- 点赞的所有用户:smembers like:t1001
- 点赞数:scard like:t1001
比关系型数据库简单许多。
商品标签
用tags:i5OO1来维护商品所有的标签。
- sadd tags:i5001画面清晰细腻
- sadd tags:i5001真彩清晰显示屏
- sadd tags:i5001流畅至极
5. ZSet
5.1 Zset存储类型
存储有序的元素,每个元素有个score,按照score从小到大排名。score相同时,按照key的ASCII码排序。
5.2 Zset操作命令
//添加元素
zadd myzset 10 java 20 php 30 ruby 40 cpp 50 python
//获取全部元素
zrange myzset 0 -1 withscores
zrevrange myzset 0 -1 withscores
//根据分值区间获取元素
zrangebyscore myzset 20 30
//根据score rank删除元素
zrem myzset php cpp
//统计元素个数
zcard myzset
//分值递增
zincrby myzset 5 python
//根据分值统计个数
zcount myzset 20 60
//获取元素rank
zrank myzset python
//获取元素score
zscore myzset python
5.3 Zset底层编码
默认使用ziplist编码(第三次用到了,hash的小编码,quicklist的Node,都是 ziplist)。在ziplist的内部,按照score排序递增来存储。插入的时候要移动之后的数据。如果元素数量大于等于128个,或者任一member长度大于等于64字节使用 skiplist+dict 存储。
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
我们先来看一下有序链表:
在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较, 直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止。时间复杂度为0(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。二分查找法只适用于有序数组,不适用于链表。
假如我们每相邻两个节点增加一个指针,让指针指向下下个节点(或者理解为有三个元素进入了第二层)。
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26) 。
在插入一个数据的时候,决定要放到那一层,取决于一个算法。
源码t_zset.c122行:
int zslRaiidoniLevel(void) {
int level = 1;
while ((random()&OxFFFF) < (ZSKIPLIST_P * OxFFFF))
level += 1;
return (level<ZSIOPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再到下一层进行查找。
比如,我们想查找23,查找的路径是沿着标红的指针所指向的方向进行的:
- 23首先和7比较,再和19比较,比它们都大,继续向后比较。
- 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与19 在第一层的下一个节点22比较。
- 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在。
在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表。
因为level是随机的,得到的skiplist可能是这样的,有些在第四层,有些在第三层, 有些在第二层,有些在第一层。
源码server.h 904 行:
typedef struct zskiplistNode {
sds ele; /* zset 的元素 */
double score; /* 分值 */
struct zskiplistNode *backward; /* 后退指针 */
struct zskiplistLevel {
struct zskiplistNode *forward; /* 前进指针,对应 level 的下一个节点 */
unsigned long span; /*从当前节点到下一个节点的跨度(跨越的节点数)*/
} level[]; /* 层 */
} zskiplistNode;typedef struct zskiplist {
struct zskiplistNode *header, *tail; /*指向跳跃表的头结点和尾节点*/
unsigned long length; /* 跳跃表的节点数 */
int level; /* 最大的层数 */
} zskiplist;typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
5.4 Zset应用场景
排行榜
例如百度热榜、微博热搜。
id 为 6001 的新闻点击数加 1:
zincrby hotNews:20251111 1 n6001
获取今天点击最多的 15 条:
zrevrange hotNews:20251111 0 15 withscores
6. BitMaps
Bitmaps是在字符串类型上面定义的位操作。一个字节由8个二进制位组成。
set k1 a
//获取value在offset处的值(a对应的ASCII码是97,转换为二进制数据是01100001)。
getbit k1 0
//修改二进制数据。结果得到b(b对应的ASCII码是98,转换为二进制数据是01100010)。
setbit k1 6 1
setbit k1 7 0
get k1
//统计二进制位中1的个数。
bitcount k1
//获取第一个1或者0的位置。
bitpos k1 1
bitpos k1 0//因为bit非常节省空间(1 MB=8388608 bit),可以用来做大数据量的统计。
//例如:在线用户统计,留存用户统计:
setbit onlineusers 0 1
setbit onlineusers 1 1
setbit onlineusers 2 0
7. Hyperloglog
Hyperloglogs提供了一种不太精确的基数统计方法,用来统计一个集合中不重复的元素个数,比如统计网站的UV,或者应用的日活、月活,存在一定的误差。
在Redis中实现的HyperLogLog,只需要12K内存就能统计264个数据。
8. Geo
假设客户使用的客户端有这么一种需求,要获取半径1公里以内的门店,那么我们就要把门店的经纬度保存起来。如果直接把经纬度保存在数据库的,一个字段存经度一个字段存维度。计算距离比较复杂。Redis的GEO直接提供了这个方法。
127.0.0.1:6379> geoadd location 112.881953 28.238426 gupao
(integer) 1
127.0.0.1:6379> geopos location gupao
1) "112.8819546103477478"
2) "28.23842480810194644"
支持的操作有增加地址位置信息、获取地址位置信息、计算两个位置的距离、获取指定范围内的地理位置集合等等。
9. Streams
5.0推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了 kafka的设计。
高级特性
1. 高性能
1.1 纯内存KV数据结构
Redis是KV结构的内存数据库,时间复杂度0(1)。
1.2 单线程
这里说的单线程其实指的是处理客户端的请求是单线程的,可以把它叫做主线程。从4.0的版本之后,还引入了一些线程处理其他的事情,比如清理脏数据、无用连接的释放、大key的删除。
单线程的优势:
- 没有创建线程、销毁线程带来的消耗。
- 避免了上线文切换导致的CPU消耗。
- 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等等。
这里还有一个问题,单线程的这些优势是普适性的,那么为什么业务系统还是使用多线程,显然是因为多线程可以更好地利用CPU资源,从而达到更高的效率,Redis使用单线程难道不会浪费CPU资源吗?
这个问题官方已经给出了解释:在Redis中单线程已经够用了,CPU不是redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。
注意,因为请求处理是单线程的,不要在生产环境运行长命令,比如keys,flushall,flushdb,否则会导致请求被阻塞。
1.3 IO多路复用机制
这里"多路"指的是多个网络连接,"复用"指的是复用同一个线程。
多路复用I/O模型是一种同步I/O模型。实现一个线程监听多个文件句柄(也叫做文件描述符,FileDescription,简称FD),当有一个FD就绪时,则通知对应的应用程序进行读写操作。当没有FD就绪时,就会阻塞并交出CPU。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。
redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。
redis是以socket方式通信,socket服务端可同时接受多个客户端请求连接,也就是说,redis服务同时面对多个redis客户端连接请求,而redis服务本身是单线程运行。
I/O 多路复用其实是使用一个线程来检查多个 Socket 的就绪状态,在单个线程中通过记录跟踪每一个 socket(I/O流)的状态来管理处理多个 I/O 流。
客户端与服务端建立连接交由socket,可以同时建立多个连接(这里应该是多线程/多进程), 从探测到数据处理再到数据返回,全程单线程。这应该就是所谓的redis单线程。
Redis为什么要引入多路复用I/O技术
I/O多路复用的本质是同步阻塞I/O模型,但是,它最大的优势在于可以在一次阻塞中监听多个文件描述符(FD)。我们带入redis的场景,来思考一下redis为什么使用多路复用I/O技术。
- 首先采用普通的同步阻塞I/O,那么Redis可能会在一个客户端上长期阻塞。该客户端可能长期没有数据到达,而Redis需要处理多个客户端的通信,当其他客户端有请求到达时,Redis则无法处理了,这显然是无法接受的。
- 如果使用同步非阻塞I/O,那么就需要不断轮循客户端,那么这种频繁的轮循会很浪费CPU资源,如果轮循不频繁,那么可能就会出现数据不能实时获取的问题。
- 如果使用 异步IO模型,线程的创建和频繁的上下文切换会浪费更多的资源。其次Redis本身就是单进程单线程的模式工作,多线程等待多个客户端显然与其系统思想不符。
- 综上,多路复用I/O技术是首选: “多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。可以直接理解为:单线程的原子操作,避免上下文切换的时间和性能消耗**;加上对内存中数据的处理速度,很自然的提高redis的吞吐量。
2. 事务
2.1 为什么要使用事务
Redis的单个命令是原子性的(比如get set mget mset)要么成功要么失败,不存在并发干扰的问题。如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就必须要依赖Redis的功能特性来实现了。Redis提供了事务的功能,可以把一组命令一起执行。Redis的事务有3个特点:
- 按进入队列的倾序执行。
- 不会受到其他客户端的请求的影响。
- 事务不能嵌套,多个multi命令效果一样。
2.2 事务的用法
Redis的事务涉及到四个命令: multi (开启事务), exec (执行事务), discard(取消事务), watch (监视)。
开启事务
127.0.0.1:6379> multi
OK
事务中的多条指令:这个时候 不会去执行这些指令 ,而是先将它们排队
127.0.0.1:6379> incr testid
QUEUED
127.0.0.1:6379> incr myid
QUEUED
提交事务 :exec
127.0.0.1:6379> exec
1) (integer) 1
2) (integer) 1
全部回滚不提交:DISCARD 返回OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr myid
QUEUED
127.0.0.1:6379> incr testid
QUEUED
127.0.0.1:6379> DISCARD
OK
2.3 watch命令
为了防止事务过程中某个key的值被其他客户端请求修改,带来非预期的结果,在 Redis中还提供了一个watch命令。也就是多个客户端更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。它可以为Redis事务提供CAS乐观锁行为(Compare and Swap) 。
我们可以用watch监视一个或者多个key,如果开启事务之后,至少有一个被监视 key键在exec执行之前被修改了,那么整个事务都会被取消(key提前过期除外)。可以用unwatch取消。
2.4 事务可能遇到的问题
事务执行遇到的问题分成两种,一种是在执行exec之前发生错误,一种是在执行exec之后发生错误。
2.4.1 在执行exec之前发生错误
比如:入队的命令存在语法错误,包括参数数量,参数名等等(编译器错误)。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> seta a
(error) ERR unknown command `seta`, with args beginning
with: `a`,
事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。
2.4.2 在执行exec之后发生错误
比如对String使用了 Hash的命令,参数个数正确,但数据类型错误,这是一种运行时错误。
flushall
multi
set k1 1
hset k1 a b
exec
1)OK
2)(error) WRONGTYPE Operation against a key holding the wrong kind of value
get k1
最后我们发现set k1 1的命令是成功的,也就是在这种发生了运行时异常的情况下,只有错误的命令没有被执行,但是其他命令没有受到影响。这个显然不符合我们对原子性的定义。也就是我们没办法用Redis的这种事务机制来实现原子性,保证数据的一致。
2.4.3 为什么不回滚
官方的解释是这样的:
- Redis命令只会因为错误的语法而失败,也就是说,从实用性的角度来说,失败的命令是由代码错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中(这个是程序员的锅)。
- 因为不需要对回滚进行支持,所以Redis的内部可以保持简单且快速。需要知道的是:回滚不能解决代码的问题(程序员的锅必须程序员来背)。
Redis从2.6版本开始引入了 Lua脚本,也就是说Redis可以用Lua来执行Redis 命令。
3. lua脚本
Lua是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。
使用Lua脚本来执行Redis命令的好处:
- 一次发送多个命令,减少网络开销。
- Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
- 对于复杂的组合命令,我们可以放在文件中,可以实现命令复用。
3.1 在Redis中调用Lua脚本
使用eval /I’vael/方法,语法格式:
redis> eval lua-script key-num [key1 key2 key3 ...] [value1 value2 value3 ....]
- eval代表执行Lua语言的命令。
- lua-script代表Lua语言脚本内容。
- key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0。
- [key1 key2 key3…]是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应起来。
- [valuel value2 value3…」这些参数传递给Lua语言,它们是可填可不填的。
redis中完美契合了lua脚本功能 redis可以调用lua脚本中的api,lua脚本也可以调用redis中的指令。
3.2 在Lua脚本中调用Redis命令
3.2.1 命令格式
使用 redis.call(command, key [param1, param2…])进行操作。语法格式:
redis.call(command, key [param1,param2...])
- command 是命令,包括 set、get、del 等。
- key是被操作的键。
- param1,param2…代表给 key 的参数。
3.2.2 Lua脚本文件
在redis-cli中直接写Lua脚本不够方便,也不能实现编辑和复用,通常我们会把Lua 脚本放在文件里面,然后执行这个文件。
创建Lua脚本文件:
cd /usr/local/soft/redis-6.0.9/src
vim gupao.lua
Lua脚本内容,先赋值,再取值:
redis.call('set','qingshan','lua666')
return redis.call('get','qingshan')
调用脚本文件:
cd /usr/local/soft/redis-6.0.9/src
redis-cli --eval gupao.lua 0
3.2.3 案例:对IP进行限流
需求
每个用户在X秒内只能访问Y次。
设计思路:
- 首先是数据类型。用String的key记录IP,用value记录访问次数。几秒钟和 几次要用参数动态传进去。
- 拿到IP以后,对IP+1o如果是第一次访问,对key设置过期时间(参数1)。否则 判断次数,超过限定的次数(参数2),返回0。如果没有超过次数则返回1。超过时间, key过期之后,可以再次访问。
- KEY[1]是IP, ARGV[1]是过期时间X, ARGV[2]是限制访问的次数Y。
--ip_limit.lua
--ip限流,对某个IP频率进行限制,6秒钟访问10次
local num=redis.call('incr',KEYS[1])
if tonumber(num)== 1 then
redis.call('expire',KEYS[1],ARGV[1])
return 1
elseif tonumber(num)>tonumber(ARGV[2]) then
return 0
else
return 1
end
6秒钟内限制访问10次,调用测试(连续调用10次):
redis-cli --eval ip_limit.lua app:ip:limit: 192.168.8.111 , 6 10
- app:ip:limit:192.168.8.111是key值,后面是参数值,中间要加上一个空格和 —个逗号,再加上一个空格。即:redis-cli -eval [lua 脚本][key…]空格,空格[args…]
- 多个参数之间用空格分割。
3.2.4 缓存Lua脚本
在Lua脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给Redis服务 端,会产生比较大的网络开销。为了解决这个问题,Redis可以缓存Lua脚本并生成SHA1 摘要码,后面可以直接通过摘要码来执行Lua脚本。
这里面涉及到两个命令,首先是在服务端缓存脚本生成一个摘要码,用script load命令。
script load "return 'Hello World'"
第二个命令是通过摘要码执行缓存的脚本:
evalsha ”470877a599ac74fbfda41caa908de682c5fc7d4b” 0
3.2.5 脚本超时
Redis的指令执行本身是单线程的,这个线程还要执行客户端的Lua脚本,如果Lua 脚本执行超时或者陷入了死循环,是不是没有办法为客户端提供服务了呢?
eval 'while(true) do end' 0
确实是这样,它会导致其他的命令都会进入等待状态,这种问题是如何解决的呢。
首先,脚本执行有一个超时时间,默认为5秒钟。超过5秒钟,其他客户端的命令不会等待,而是直接会返回"BUSY"错误。
这样也不行,不能一直拒绝其他客户端的命令执行吧。在提示里面我们也看到了, 有两个命令可以使用,第一个是script kill,中止脚本的执行。
script kill
但是需要注意:并不是所有的lua脚本执行都可以kill。如果当前执行的Lua脚本对 Redis的数据进行了修改(SET、DEL等),那么通过script kill命令是不能终止脚本运行的。
eval "redis.call('set','gupao','666') while true do end" 0
这时候执行script kill会返回UNKILLABLE错误。为什么要这么设计?为什么包含 修改的脚本不能中断?因为要保证脚本运行的原子性。如果脚本执行了一部分被终止,那就违背了脚本原子性的目标。
遇到这种情况,只能通过shutdown nosave命令,直接把Redis服务停掉。正常关机是 shutdowno shutdown nosave 和 shutdown 的区别在于 shutdown nosave不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。
4. Redission分布式锁
我们用LUA脚本简单做一把分布式锁没啥问题,但是又遇到了2个问题
1.我要实现重入锁怎么做?
2.因为防止死锁设置了过期时间,那么假如锁的时间到期了,但是我们的业务没有执行完毕,会导致锁失效怎么办?
4.1 怎么实现重入锁
为什么要有重入锁?
在有些场景下,我需要同一个线程能对同一把锁进行反复加锁!如下:
1.假如我有个定时调度的批处理,批量处理未支付的订单去进行支付,这个逻辑需要加锁,查询未支付的订单不能有并发。
2.这个支付的方法,除了批量处理这块,其他地方也会调用,所以也需要加锁
3.加的是同一把锁,因为synchronized加锁范围是当前实例,如果同一个线程加锁后不能再进行加锁的话,会导致死锁。
数据类型是hash,是一种key 、filed 、value的格式,所以完美的契合了重入锁的实现。
我们的大key可以做为 互斥条件、filed保存线程信息、value保存重入次数。
Redission分布式锁流程图
4.2 怎么解决锁时间到了,但是业务没有执行完
如果锁时间到了,我的业务没有执行完,那么我是希望这个锁要一直被我占有的!所以我们能不能申请去加时!直到我执行完任务,手动去删除锁。
那么怎么做?无非就是我希望开个定时任务,定时去判断下这个锁还存不存在,如果存在,续期,如果不存在,不续期。
定时调度的方式有很多:比如es-job xxl-job 、rabbitMq死信队列等分布式的,也有像基于delayqueue实现的、还有一个netty下面的时间轮算法。
重点来分析下时间轮 ,因为Redis就是基于时间轮实现的.
4.3 Redission释放锁
unlock方法
unlockAsync
4.4 联锁(红锁)
Redis做分布式锁遇到的问题?
因为Redis属于CAP中的AP,为了优先保证高可用性,所以会牺牲一定的数据一致性。比如主从方案中,如果主库挂的话,从库是不管数据有没有同步完主库的数据,都会自动升级为主。
那么这样就会出现一种情况:加锁返回是成功的,但是由于发生了主库挂的,从库切换的时候,没有同步到这个锁,从而导致锁失效。
那么这个怎么解决? 解决不了,能做的是我尽可能的去减少这种情况!
怎么解决?既然你一个主从实例,会导致锁丢失,那么假如说我把这种风险分担,同时在5台机器加锁,这5台机器只要有一半以上的锁能成功获取,这样就尽可能的减少了锁丢失的场景。
就相当于你家的门钥匙,假如钥匙只有一把,你丢了就丢了,但是把这个钥匙给你们家里面所有的人,那么你丢了家门还是能进去。因为家里其他人还有。
那么这种方案的实现,在Redission就有现成的红锁!
5. 消息广播
前面我们说通过队列的rpush和blpop可以实现消息队列(队尾进队头出),没有任何元素可以弹出的时候,连接会被阻塞。但是基于list实现的消息队列,不支持一对多的消息分发,相当于只有一个消费者。如果要实现一对多的消息分发,就需要使用redis的发布-订阅功能。
消息的生产者和消费者是不同的客户端,连接到同一个Redis的服务。通过什么对象把生产者和消费者关联起来呢?在RabbitMQ里面叫Queue,在Kafka里面叫Topic。Redis的模型里面这个叫channel (频道)。
订阅者可以订阅一个或者多个channeL消息的发布者可以给指定的channel发布消息。只要有消息到达了 channel,所有订阅了这个channel的订阅者都会收到这条消息。
订阅者订阅频道:可以一次订阅多个,比如这个客户端订阅了 3个频道,频道不用实现创建。
subscribe channel-1 channel-2 channel-3
发布者可以向指定频道发布消息(并不支持一次向多个频道发送消息):
publish channel-1 2673
取消订阅(不能在订阅状态下使用):
unsubscribe channel-1
按规则(Pattern)订阅频道
支持?和* 占位符。?代表一个字符, * 代表0个或者多个字符。例如,现在有三个新闻频道,运动新闻(news-sport)、音乐新闻(news-music)、天气新闻(news-weather) 。
三个消费者,消费端1关注运动信息:
psubscribe *sport
消费端2关注所有新闻:
psubscribe news*
消费端3关注天气新闻:
psubscribe news-weather
生产者,向3个频道发布3条信息,对应的订阅者能收到消息:
publish news-sport kobe
publish news-music jaychou
publish news-weather sunny
一般来说,考虑到性能和持久化的因素,不建议使用Redis的发布订阅功能来实现MQ。Redis的一些内部机制用到了发布订阅功能。
6. pipelining管道技术
那么这个客户端到服务端是需要时间的,包括网络传输,连接等等! 那么这个时间就叫做RTT时间(Round Trip Time)。并且这个时间将影响我们的性能。
所以Redis出现了一个pipelining管道技术,目的就是不要每次执行一次指令都经过一次RTT时间,可以使多个指令只需要经历一次RTT时间
客户端会打包将指令打包发送给服务端,管道一直执行接收命令执行,直到管道里所有的指令执行完毕,然后一起返回执行结果。
因为你发送的是批量指令,那么服务器会使用内存对指令跟回复进行排队,不能无限制的使用指令,不然会浪费内存。
管道里的指令是先等到管道里的指令全部执行完才会返回
使用场景
- 在客户端下次写之前不需要获取读的场景的大数据量并发写入的场景。
- 对性能有要求
- 管道里的命令不具备原子性,只能保证某个客户端发送的指令是顺序执行的,但是多客户端的命令会交叉执行
- 不需要以上一个指令的返回结果来判断后续的逻辑
底层数据结构
SDS
ZipList
hashtable
quickList
intset
skipList
数据管理
1 过期机制
因为我们的redis是一个内存型数据库,我们的数据都是放在内存里面的,如果我们不淘汰,那么它的数据就会满,满了肯定就不能再放数据,发挥不了redis的作用! 所以我们需要通过一定的方式去保证我们Redis的可用性。
1. 1 立即过期
每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
1.2 惰性过期
只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
1.3 定期过期
每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
Redis中同时使用了惰性过期和定期过期两种过期策略,并不是实时地清除过期的key。
具体实现流程如下:
1. 定时serverCron方法去执行清理,执行频率根据redis.conf中的hz配置的值.
2. 执行清理的时候,不是去扫描所有的key,而是去扫描所有设置了过期时间的key(redisDb.expires)
3. 如果每次去把所有过期的key都拿过来,那么假如过期的key很多,就会很慢,所以也不是一次性拿取所有的key
4. 根据hash桶的维度去扫描key,扫到20(可配)个key为止。假如第一个桶是15个key ,没有满足20,继续扫描第二个桶,第二个桶20个key,由于是以hash桶的维度扫描的,所以第二个扫到了就会全扫,总共扫描
35个key
5. 找到扫描的key里面过期的key,并进行删除
6. 如果取了400个空桶,或者扫描的删除比例跟扫描的总数超过10%,继续执行4、5步。
7. 也不能无限的循环,循环16次后回去检测时间,超过指定时间会跳出。
2 淘汰策略
由于Redis内存是有大小的,并且我可能里面的数据都没有过期,当快满的时候,我又没有过期的数据进行淘汰,那么这个时候内存也会满。内存满了,Redis也会不能放入新的数据。
所以,我们不得已需要一些策略来解决这个问题来保证可用性。
noeviction: New values aren’t saved when memory limit isreached. When a database uses replication, this applies to the primary database 默认,不淘汰 能读不能写
allkeys-lru: Keeps most recently used keys; removes least recently used (LRU) keys 基于伪LRU算法 在所有的key中去淘汰
allkeys-lfu: Keeps frequently used keys; removes least frequently used (LFU) keys 基于伪LFU算法 在所有的key中去淘汰
volatile-lru: Removes least recently used keys with the expire field set to true . 基于伪LRU算法 在设置了过期时间的key中去淘汰
volatile-lfu: Removes least frequently used keys with the expire field set to true . 基于伪LFU算法 在设置了过期时间的key中去淘汰
allkeys-random: Randomly removes keys to make space for the new data added. 基于随机算法 在所有的key中去淘汰
volatile-random: Randomly removes keys with expire field set to true . 基于随机算法 在设置了过期时间的key中去淘汰
volatile-ttl: Removes least frequently used keys with expire field set to true and the shortest remaining time-to-live (TTL) value. 根据过期时间来,淘汰即将过期的
我们发现,官网提供了8种不同的策略,只要在我们的config中配置maxmemory-policy即可指定相关的淘汰策略
# maxmemory-policy noeviction //默认不淘汰数据,能读不能写
2.1 淘汰流程
1.首先,我们会有个淘汰池,默认大小是16,并且里面的数据是末尾淘汰制。
2.每次指令操作的时候,自旋会判断当前内存是否满足指令所需要的内存
3.如果当前内存不能满足,会从淘汰池中的尾部拿取一个最适合淘汰的数据
3.1 会取样(配置 maxmemory-samples)从Redis中获取随机获取到取样的数据 解决一次性读取所有的数据慢的问题
3.2 在取样的数据中,根据淘汰算法 找到最适合淘汰的数据
3.3 将最合适的那个数据跟淘汰池中的数据比较,是否比淘汰池中的更适合淘汰,如果更适合,放入淘汰池
3.4 按照适合的程度进行排序,最适合淘汰的放入尾部
4.将需要淘汰的数据从Redis删除,并且从淘汰池移除。
2.2 LRU淘汰原理(Least Recently Used)
实现原理
得到对象隔多久没访问,隔的时间越久,越容易被淘汰
1. 首先,LRU是根据这个对象的访问操作时间来进行淘汰的,那我们需要知道这个对象最后的操作访问时间。
2. 知道了对象的最后操作访问时间后,我们只需要跟当前的系统时间来进行对比,就能计算出对象已经多久没访问了
用lruclock与 redisObject.lru进行比较,因为lruclock只获取了当前秒单位时间的后24位,所以肯定有个轮询。
所以,我们会用lruclock跟 redisObject.lru进行比较,如果lruclock>redisObject.lru,那么我们用lruclock- redisObject.lru,否则lruclock+(24bit的最大值-redisObject.lru),得到lru越小,那么返回的数据越大,相差越大的越优先会被淘汰!
2.3 LFU(Least Frequently Used)
LRU并不一定是最优算法,有时候一些数据虽然访问时间间隔不规律(有时候比较长),但是整体访问频率比较高,针对这种情况,就需要使用LFU算法。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
当这24 bits用作LFU时,其被分为两部分:
- 高16位用来记录访问时间(单位为分钟,Idt, last decrement time)
- 低8位用来记录访问频率,简称counter (logc, logistic counter)
counter是用基于概率的对数计数器实现的,8位可以表示百万次的访问频率。 对象被读写的时候,lfu的值会被更新。
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
counter = LFULogIncr(counter);
val->lru = (LFUGetTimelnMinutes()«8) | counter;
当然,这里并不是访问一次,计数就加1 ,增长的速率由一个参数决定,Ifu-log-factor越大,counter增长的越慢。
# lfu-log-factor 10
注意一下,这个算法是LFU,如果一段时间热点高,就一直保持这个热度,肯定也是不行的,体现不了整体频率。所以,没有被访问的时候,计数器还要递减。减少的值由衰减因子Ifu-decay-time (分钟)来控制,如果值是1的话,N分钟没有访问,计数器就要减少N。Ifu-decay-time越大,衰减越慢。
# lfu-decay-time 1
持久化方案
Redis速度快,很大一部分原因是因为它所有的数据都存储在内存中。如果断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案,—种是 RDB 快照(Redis DataBase),—种是 AOF (Append Only File)。 持久化是Redis跟Memcache的主要区别之一。
1 RDB
RDB是Redis默认的持久化方案(注意如果开启了 AOF,优先用AOF)。当满足 一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdb。Redis 重启会通过加载dump.rdb文件恢复数据。
1.1 RDB触发
1.1.1 自动触发
a) 配置规则触发
redis.conf, SNAPSHOTTING,其中定义了触发把数据保存到磁盘的触发频率。如果不需要rdb方案,注释save或者配置成空字符串.
save 900 1 # 900秒内至少有一个key被修改(包括添加)
save 300 10 # 400秒内至少有10个key被修改
save 60 10000 # 60秒内至少有10000个key被修改
注意上面的配置是不冲突的,只要满足任意一个都会触发。用lastsave命令可以查看最近一次成功生成快照的时间。rdb文件位置和目录(默认在安装根目录下):
#文件路径,
dir ./
#文件名称
dbfilename dump.rdb
#是否以LZF压缩rdb文件 rdbcompression yes
#开启数据校验
rdbchecksum yes
b) shutdown触发,保证服务器正常关闭.
c) flushall, rdb文件是空的,没什么意义.
1.1.2 手动触发
如果我们需要重启服务或者迁移数据,这个时候就需要手动触RDB快照保存。Redis 提供了两条命令:
1) save
save在生成快照的时候会阻塞当前Redis服务器,Redis不能处理其他命令。如果 内存中的数据比较多,会造成Redis长时间的阻塞。生产环境不建议使用这个命令。
为了解决这个问题,Redis提供了第二种方式。
2) bgsave
执行bgsave时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程执行fork操作创建子进程(copy-on-write) , RDB持久化 过程由子进程负责,完成后自动结束。它不会记录fork之后产生的数据。阻塞只发生在 fork阶段,一般时间很短。
1.2 RDB数据恢复
通过备份文件恢复数据
1.3 RDB文件的优势和劣势
优势:
- RDB是一个非常紧凑(compact)的文件,它保存了 redis在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
- 生成RDB文件的时候,redis主进程会fork。一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
- RDB在恢复大数据集时的速度比AOF的恢复速度要快。
劣势:
- RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,频繁执行成本过高。
- 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照之后的所有修改(数据有丟失)。
- 如果数据相对来说比较重要,希望将损失降到最小,则可以使用AOF方式进行持久化。
2 AOF
Redis默认不开启AOF。AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。
AOF优势与劣势
优点
AOF持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步
一次,Redis最多也就丢失1秒的数据而已。
缺点
对于具有相同数据的的Redis, AOF文件通常会比RDF文件体积更大(RDB存的是数据快照)。
虽然AOF提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB比AOF具好更好的性能保证。
两种方案比较
那么对于AOF和RDB两种持久化方式,我们应该如何选择呢?
如果可以忍受一小段时间内数据的丢失,毫无疑问使用RDB是最好的,定时生成 RDB快照(snapshot)非常便于进行数据库备份,并且RDB恢复数据集的速度也要比 AOF恢复的速度要快。
否则就使用AOF重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
3 混合持久化
高可用
主从
Redis的主从复制分为两类,一种叫全量复制,就是一个节点第一次连接到master 节点,需要全部的数据。第二种叫做增量复制,比如之前已经连接到master节点,但是中间网络断开,或者slave节点宕机了,缺了一部分的数据。
主从复制不足
Redis主从复制解决了数据备份和一部分性能的问题,但是没有解决高可用的问题。在一主一从或者一主多从的情况下,如果主服务器挂了,对外提供的服务就不可用了,单点问题没有得到解决。如果每次都是手动把之前的从服务器切换成主服务器,然后再把剩余节点设置为它的从节点,这个比较费时费力,还会造成一定时间的服务不可用。
哨兵
怎么实现高可用呢?第一个对于服务端来说,能够实现主从自动切换;第二个,对于客户端来说,如果发生了主从切换,它需要获取最新的master节点。
Redis的高可用是通过哨兵Sentinel来保证的。它的思路就是通过运行监控服务器来保证服务的可用性。从Redis2.8版本起,提供了一个稳定版本的Sentinel (哨兵),用来解决高可用的问题。我们会启动奇数个的Sentinel的服务(通过src/redis-sentinel)。
它本质上只是一个运行在特殊模式之下的Redis。Sentinel通过info命令得到被监听Redis机器的master, slave等信息。
为了保证监控服务器的可用性,我们会对Sentinel做集群的部署。Sentinel既监控所有的Redis服务,Sentinel之间也相互监控。Sentinel本身没有主从之分,地位是平等的,只有Redis服务节点有主从之分。
这里就有个问题了,Sentinel唯一的联系,就是他们监控相同的master,那一个Sentinel节点是怎么知道其他的Sentinle节点存在的呢?因为Sentinel是一个特殊状态的Redis节点,它也有发布订阅的功能。哨兵上线时,给所有的Reids节点(master/slave)的名字为_sentinel_:hello的 channle发送消息。每个哨兵都订阅了所有Reids节点名字为_sentinel_:hello的channle,所以能互相感知对方的存在,而进行监控。
Sentinel和Sentinel集群是怎么运行的我们知道了,下面来看看它是怎么干活的。 它最大的作用就是管理Redis节点服务状态,还有切换主从。
Sentinel默认以每秒钟1次的频率向Redis服务节点发送PING命令。如果在指定时间内没有收到有效回复,Sentinel会将该服务器标记为下线(主观下线)。
但是,只有你发现master下线,并不代表master真的下线了。也有可能是你自己 的网络出问题了。所以,这个时候第一个发现master下线的Sentinel节点会继续询问 其他的Sentinel节点,确认这个节点是否下线,如果多数Sentinel节点都认为master 下线,master才真正确认被下线(客观下线)。确定master下线之后,就需要重新选举master。
好了,问题又来了,选举这件事情谁来做呢? Redis节点自己选举?还是谁?
Raft算法
Redis的选举和故障转移都是由Sentinel完成的。问题又来了,既然有这么多的Sentinel节点,由谁来做故障转移的事情呢?
故障转移流程的第一步就是在Sentinel集群选择一个Leader,由Leader完成故障转移流程。Sentinle通过Raft算法,实现Sentinel选举。
我们前面说过,只要有了多个副本,就必然要面对副本一致性的问题。如果要所有的节点达成一致,必然要通过复制的方式实现。但是这么多节点,以哪个节点的数据为准呢?所以必须选岀一个Leader。所以数据保持一致需要两个步骤:领导选举,数据复制。数据复制我们前面讲过了。 这里关注一下选举的实现。
Raft是一个共识算法(consensus algorithm)。比如比特币之类的加密货币,就需要共识算法。Spring Cloud的注册中心解决方案Consul也用到了Raft协议。
Raft的核心思想:先到先得,少数服从多数。
- 分布式环境中的节点有三个状态:Follower、Candidate (虚线外框)、Leader (实线外框)。
- 一开始所有的节点都是Follower状态。如果Follower连接不到Leader (Leader 挂了),它就会成为Candidate。Candidate请求其他节点的投票,其他的节点会投给 它。如果它得到了大多数节点的投票,它就成为了主节点。这个过程就叫做Leader Election。
- 现在所有的写操作需要在Leader节点上发生。Leader会记录操作日志。没有同 步到其他Follower节点的日志,状态是uncommitted。等到超过半数的Follower同步 了这条记录,日志状态就会变成committed。 Leader会通知所有的Follower B志已经 committed,这个时候所有的节点就达成了一致。这个过程叫Log Replication。
- 在Raft协议里面,选举的时候有两个超时时间。第一个叫election timeout0 也就是说,为了防止同一时间大量节点参与选举,每个节点在变成Candidate之前需要 随机等待一段时间,时间范围是150ms and 300ms之间。第一个变成Candidate的节 点会先发起投票。它会先投给自己,然后请求其他节点投票(Request Vote) 。
- 如果还没有收到投票结果,又到了超时时间,需要重置超时时间。只要有大部分 节点投给了一个节点,它就会变成Leader。
- 成为Leader之后,它会发消息让来同步数据(Append Entries),发消息的间 隔是由heartbeat timeout来控制的。Followers会回复同步数据的消息。
- 只要Followers收到了同步数据的消息,代表Leader没挂,他们就会清除 heartbeat timeout 的计时。
- 但是一旦 Followers 在 heartbeat timeout 时间之内没有收到 Append Entries 消息,它就会认为Leader挂了,开始让其他节点投票,成为新的Leader。
- 必须超过半数以上节点投票,保证只有一个Leader被选出来。
- 如果两个Follower同时变成了 Candidate,就会出现分割投票。比如有两个节 点同时变成Candidate,而且各自有一个投票请求先达到了其他的节点。加上他们给自己的投票,每个Candidate手上有2票。但是/因为他们的election timeout不同,在 发起新的一轮选举的时候,有一个节点收到了更多的投票,所以它变成了 Leader。
到了这一步,我们还只是从所有的Sentinle节点里面选出来了一个Leader而已,也就是所谓了选举委员会主席。下面才是真正的选举。
Redis的master节点的选举规则又是什么样的呢?
对于所有的slave节点,一共有四个因素影响选举的结果,分别是断开连接时长、优先级排序、复制数量、进程id。
- 如果与哨兵连接断开的比较久,超过了某个阈值,就直接失去了选举权。
- 如果拥有选举权,那就看谁的优先级高,这个在配置文件里可以设置 (replica-priority 100),数值越小优先级越高。
- 如果优先级相同,就看谁从master中复制的数据最多(复制偏移量最大),选最多的那个
- 如果复制数量也相同,就选择进程id最小的那个。
Sentinel不足
- 主从切换的过程中会丢失数据,因为只有一个master。
- 只能单点写,没有解决水平扩容的问题。
- 如果数据量非常大,这个时候就要对Redis的数据进行分片了。这个时候我们需要多个master-slave的group,把数据分布到不同的group中
集群
Redis Cluster可以看成是由多个Redis实例组成的数据集合。客户端不需要关注数据的子集到底存储在哪个节点,只需要关注这个集合整体。以3主3从为例,节点之间两两交互,共享数据分片、节点状态等信息。
1 高可用和主从切换原理
当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程,其过程如下:
- slave发现自己的master变为FAIL。
- 将自己记录的集群currentEpoch加1,并广播FAILOVER AUTH REQUEST信息.
- 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个 epoch 只发送一次 ack.
- 尝试 failover 的 slave 收集 FAILOVER AUTH ACK.
- 超过半数后变成新Master.
- 广播Pong通知其他集群节点。
总结:RedisCluster既能够实现主从的角色分配,又能够实现主从切换,相当于集成了 Replication 和 Sentinel 的功能。
2 Redis Cluster 特点总结
- 无中心架构。
- 数据按照slot存储分布在多个节点,节点间数据共享,可动态调整数据分布。
- 可扩展性,可线性扩展到1000个节点(官方推荐不超过1000个),节点可动态添加或删除。
- 高可用性,部分节点不可用时,集群仍可用。通过增加Slave做standby数据副本,能够实现故障自动failover,节点之间通过gossip协议交换状态信息,用投票机制完成Slave到Master的角色提升。
- 降低运维成本,提高系统的扩展性和可用性。
常见问题和解决方案
1 数据一致性
1.1 缓存使用场景
针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。当我们使用Redis作为缓存的时候,一般流程是这样的:
1、如果数据在Redis存在,应用就可以直接从Redis拿到数据,不用访问数据库。
2、应用新增了数据,只保存在数据库中,这个时候Redis没有这条数据。如果Redis里面没有,先到数据库查询,然后写入到Redis,再返回给应用
1.2 一致性问题的定义
因为数据最终是以数据库为准的(这是我们的原则),如果Redis没有数据,就不存在这个问题。当Redis和数据库都有同一条记录,而这条记录发生变化的时候,就可能出现一致性的问题。—旦被缓存的数据发生变化(比如修改、删除)的时候,我们既要操作数据库的数据,也要操作Redis的数据,才能让Redis和数据库保持一致。所以问题来了。现在我们有两种选择:
- 先操作Redis的数据再操作数据库的数据
- 先操作数据库的数据再操作Redis的数据
首先需要明确的是,不管选择哪一种方案,我们肯定是希望两个操作要么都成功, 要么都一个都不成功。但是,Redis的数据和数据库的数据是不可能通过事务达到统一的, 我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。
1.3 方案选择
删除还是更新
当存储的数据发生变化,Redis的数据也要更新的时候,我们有两种方案,一种就是直接更新Redis数据,调用set;还有一种是直接删除Redis数据,让应用在下次查询的时候重新写入。这两种方案怎么选择呢?
这里我们主要考虑更新缓存的代价。更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据,而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单, 而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也认为一次缓存命中的损失低于更新缓存的消耗,所以推荐使用删除的方案。
所以,更新操作和删除操作,只要数据变化,都用删除。
先删缓存还是先更新数据库
a) 先更新数据库
正常情况:更新数据库,成功。删除缓存,成功。
异常情况:
- 更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
- 更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。
这种问题怎么解决呢?我们可以提供一个重试的机制。例如,如果删除缓存失败,我们捕获这个异常,把需要删除的key发送到消息队列。 然后自己创建一个消费者消费,尝试再次删除这个key。
b) 先删缓存
正常情况:删除缓存,成功。更新数据库,成功。
异常情况:
- 删除缓存失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
- 删除缓存成功,更新数据库失败。因为以数据库的数据为准,所以不存在数据不一致的情况。
看起来好像没问题,但是如果有程序并发操作的情况下:
1)线程A需要更新数据,首先删除了 Redis缓存
2)线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回
3)线程A更新了数据库
这个时候,Redis是旧的值,数据库是新的值,发生了数据不一致的情况。这个是由于线程并发造成的问题。能不能让对同一条数据的访问串行化呢?代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个应用实例(应用做了集群部署)。数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是强制串行操作,吞吐量太低了。
所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。
2 缓存雪崩
缓存雪崩就是Redis的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候Redis请求的并发量又很大,就会导致所有的请求落到数据库。
解决方案如下:
- 加互斥锁,针对同一个key只允许一个线程到数据库查询。
- 缓存定时预先更新,避免同时失效。
- 通过加随机数,使key在不同的时间过期。
- 热点数据永不过期。
3 缓存击穿
缓存击穿跟缓存雪崩类似,区别就是缓存雪崩是群体失效,缓存击穿是单体失效,比如一个非常热点的数据。比较典型的场景就是新浪微博的热点事件,比如鹿晗和关晓彤事件,因为超高并发的访问,如果这个时间点缓存过期,在系统从后端DB加载数据到缓存这个过程中,这段时间超大并发的请求会同时打到DB上,很有可能瞬间把DB压垮。
解决方案如下:
- 加互斥锁,针对同一个key只允许一个线程到数据库查询。
- 热点数据永不过期。
4 缓存穿透
缓存穿透,一般是指当前访问的数据在redis和mysql中都不存在的情况,有可能是一次错误的查询,也可能是恶意攻击。
在这种情况下,因为数据库值不存在,所以肯定不会写入Redis,那么下一次查询相同的key的时候,肯定还是会再到数据库查一次。试想一下,如果有人恶意设置大量请求去访问一些不存在的key,这些请求同样最终会访问到数据库中,有可能导致数据库的压力过大而宕机。
这种情况一般有两种处理方法。
4.1 缓存空值
我们可以在数据库缓存一个空字符串,或者缓存一个特殊的字符串,那么在应用里面拿到这个特殊字符串的时候,就知道数据库没有值了,也没有必要再到数据库查询了。
但是这里需要设置一个过期时间,不然的会数据库已经新增了这一条记录,应用也还是拿不到值。
这个是应用重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?即使你每次都缓存特殊字符串也没用,因为它的值不一样,比如我们的用户系统登录的场景,如果是恶意的请求,它每次都生成了一个符合ID规则的账号,但是这个账号在我们的数据库是不存在的,那Redis就完全失去了作用,因此我们有另外一种方法,布隆过滤器。
4.2 布隆过滤器解决缓存穿透
先来了解一下布隆过滤器的原理,
- 首先,项目在启动的时候,把所有的数据加载到布隆过滤器中。
- 然后,当客户端有请求过来时,先到布隆过滤器中查询一下当前访问的key是否存在,如果布隆过滤器中没有该key,则不需要去数据库查询直接反馈即可
综合探讨问题
1. 单线程快还是多线程快?
多线程不一定比单线程速度快,只有在特定的场景下,多线程才能发挥优势。
例如数据库的存储,单线程速度就比多线程快。
多线程适用于复杂任务,并发任务,往往响应需要一定的时间,这时候通过调用多个线程,同时处理一些任务从而提高速度。
多线程在一定程度上提升了复杂任务的的工作效率。最终你会发现这么一些流程里面最终影响你这个系统性能的一定是其中最差的那一个部分。最差的那一部分,这就是木桶效应。
对redis来说,网络IO带来的,瓶颈会比它CPU计算带来的瓶颈更大。你可能比如说你一个redis一台服务器,然后你的带宽假设是一兆,假设你的带宽是一兆,然后你的CPU运行可能是非常快,比如说每秒可能可以执行十万笔,也就是说你可以从内存里面去取出十万个数据来,但是你的带宽。每秒你只能传输,比如说只能传输一万个。这时候你不管你怎么去用你的CPU永远只能用到10%,当然这是个理论数. 你的CPU只能用到10%, 因为你的带宽已经满了。这它的瓶颈本身不在CPU上。
2. 各个数据结构的核心设计思想是什么,如何实现?
字符串类型SDS,本质上其实还是字符数组;redis的hash架构就是标准的hashtab的结构,通过挂链解决冲突问题。第一维hash的数组位置碰撞时,就会将碰撞的元素使用链表串接起来;
在列表元素较少的情况下,会使用一块连续的内存存储,这个结构是ziplist,即压缩列表。它将所有的元素彼此紧挨着一起存储,分配一块连续的内存。
当数据量比较大的时候ziplist才会变成quicklist。所以Redis将链表和ziplist结合起来组成了quicklist。也就是将多个ziplist使用双向指针串起来使用。
quickList 是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。
zset的内部实现用的是“跳跃列表”的数据结构,它的结构非常特殊,也很复杂。
3. Redis最好的持久化方案是什么?
a) 那么对于AOF和RDB两种持久化方式,我们应该如何选择呢?
如果可以忍受一小段时间内数据的丢失,毫无疑问使用RDB是最好的,定时生成 RDB快照(snapshot)非常便于进行数据库备份,并且RDB恢复数据集的速度也要比 AOF恢复的速度要快。
否则就使用AOF重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
b) 不做持久化。因为RDB或AOF或者是混合这种方式。都没有办法完全保证数据不丢失.
c) 系统可不可靠,是一个前提。就大部分业务系统来说,你既然用分布式锁了,你肯定不能让它出现没锁住的情况,那如果说你,你想要在数据可靠性的角度上,又要保证它的一个可用性的话,有一个方案是redis跟数据库锁一起用,就是说我再去加锁的时候我加redis的同时我去加数据库。那看起来好像它性能会被消耗一点,但是其实这个加数据库锁还是比较快的,因为它是唯一索引的insert或者update。我们竞争越激烈,就我们的并发越高,对性能要求越高的时候,我们大部分情况下是加速失败的,而这些失败的请求其实它是不需要去查到数据库的你去验证。
这把锁在普通情况下,你去验证这把锁有没有被抢占的时候,你去读redis,你就可以发现说这把锁被人抢占,然后你就可以直接返回了。也就是说你的数据库锁,你在成功加锁的时候会去更新或者会去写入,它本身的并发压力也是比较小的。一旦你出现了不可用的情况,这时候他就可以通过判断数据库锁。当然不能用的时候我系统会跑的慢一点,但是我系统还是可用的,并且还是可靠的。
4. 一致性哈希和哈希槽的原理和区别,哈希槽设计思想的本质?
redis cluster采用数据分片的哈希槽来进行数据存储和数据的读取。
它并不是闭合的,key的定位规则是根据CRC-16(key)%16384的值来判断属于哪个槽区,从而判断该key属于哪个节点,而一致性哈希是根据hash(key)的值来顺时针找第一个hash(ip)的节点,从而确定key存储在哪个节点。
一致性哈希是创建虚拟节点来实现节点宕机后的数据转移并保证数据的安全性和集群的可用性的。redis cluster是采用master节点有多个slave节点机制来保证数据的完整性的,master节点写入数据,slave节点同步数据。当master节点挂机后,slave节点会通过选举机制选举出一个节点变成master节点,实现高可用。
5. 布隆过滤器的应用?
布隆过滤器(Bloom Filter)主要用来检索一个元素是否在一个集合中。它是一种数据结构bitMap,优点是高效的插入和查询,而且非常节省空间。缺点是存在误判率和删除困难。一般使用较多的场景就是避免缓存穿透。
做黑名单的过滤