JAVA技术体系之分布式篇(四)——Redis缓存

1、Redis概述

1.1 Redis诞生历程

  Redis的作者笔名叫antirez,2008年的时候他做了一个记录网站访问情况的系统,比如每天有多少个用户,多少个页面被浏览,访客的IP、操作系统、浏览器、使用的搜索关键词等等(跟百度统计、CNZZ功能一样)。最开始存储方案用MySQL,效率太低,09年的时候antirez就自己写了一个内存的List,这个就是Redis。
JAVA技术体系之分布式篇(四)——Redis缓存_第1张图片
  最开始Redis只支持List。现在数据类型丰富了、功能也丰富了,在全世界都非常流行。Redis的全称是Remote Dictionary Service,直接翻译过来是远程字典服务。
  从Redis的诞生历史可以看出,在某些场景中,关系型数据库并不适合用来存储我们的Web应用的数据。那么,关系型数据库和非关系型数据库,或者说SQL和NoSQL有什么区别呢

1.2 SQL和NoSQL

  在绝大部分时候,我们都会首先考虑用关系型数据库来存储业务数据,比如 SQLServer, Oracle, MySQL等等。关系型数据库的特点:

  1. 它以表格的形式,基于行存储数据,是一个二维的模式。
  2. 它存储的是结构化的数据,数据存储有固定的模式(schema),数据需要适应表结构。
  3. 表与表之间存在关联(Relationship)。
  4. 大部分关系型数据库都支持SQL (结构化查询语言)的操作,支持复杂的关联查询。
  5. 通过支持事务(ACID酸)来提供严格或者实时的数据一致性。

  但是使用关系型数据库也存在一些限制,比如:

  1. 要实现扩容的话,只能向上(垂直)扩展,比如磁盘限制了数据的存储,就要扩大磁盘容量,通过堆硬件的方式,不支持动态的扩缩容。水平扩容需要复杂的技术来实现,比如分库分表。
  2. 表结构修改困难,因此存储的数据格式也受到限制。
  3. 关系型数据库通常会把数据持久化到磁盘,在高并发和高数据量的情况下, 基于磁盘的读写压力比较大。

  为了规避关系型数据库的一系列问题,我们就有了非关系型的数据库,我们一般把它叫做"non-relational"或者 “Not Only SQL”。NoSQL 最开始是不提供 SQL(Structured Query Language结构化查询语言)的数据库的意思,但是后来意思慢慢地发生了变化。非关系型数据库的特点:

  1. 存储非结构化的数据,比如文本、图片、音频、视频。
  2. 表与表之间没有关联,可扩展性强。
  3. 保证数据的最终一致性,遵循BASE (碱)理论。Basically Available (基本可用);Soft-state (软状态);Eventually Consistent (最终一致性)。
  4. 支持海量数据的存储和高并发的高效读写。
  5. 支持分布式,能够对数据进行分片存储,扩缩容简单。

  对于不同的存储类型,我们又有各种各样的非关系型数据库,比如有几种常见的类型:

  1. KV存储:Redis 和 Memcached
  2. 文档存储:MongoDB
  3. 列存储:HBase
  4. 图存储:Neo4j
  5. 对象存储
  6. XML存储等

  随着行业的发展,还出现了所谓的NewSQL数据库。NewSQL 结合了 SQL和 NoSQL 的特性。例如 TiDB (PingCAP)、VoltDB、ScaleDB等。
JAVA技术体系之分布式篇(四)——Redis缓存_第2张图片

2、Redis数据类型与结构

  Redis 有许多重要的数据结构,其存储结构从外层往内层依次是 redisDb、dict、dictht、dictEntry。redisDb 默认情况下有16个,每个 redisDb 内部包含一个 dict 的数据结构,dict 内部包含 dictht 数组,数组个数为2,主要用于 hash 扩容使用。dictht 内部包含 dictEntry 的数组,dictEntry 其实就是 hash 表的一个 key-value 节点。
  我们谈到的redis数据类型指的就是dictEntry里面value对象的数据类型。JAVA技术体系之分布式篇(四)——Redis缓存_第3张图片

2.1 String

2.1.1 String存储类型

  可以用来存储INT (整数)、float (单精度浮点数)、String (字符串)。

2.1.2 String操作命令

#获取指定范围的字符
getrange qingshan 0 1
#获取值长度
strlen qingshan
#字符串追加内容
append qingshan good
#设置多个值(批量操作,原子性)
mset qingshan 2673 huihui 666
#获取多个值
mget qingshan huihui
#设置值,如果key存在,则不成功
setnx qingshan pyy
#基于此可实现分布式锁。用del key释放锁。
#但如果释放锁的操作失败了,导致其他节点永远获取不到锁,怎么办?
#加过期时间。单独用expire加过期,也失败了,无法保证原子性,怎么办?多参数
set key value [expiration EX seconds|PX milliseconds] [NX|XX]
#使用参数南方式
set k1 v1 EX 10 NX
#(整数)值递增(值不存在会得到1)
incr qingshan
incrby qingshan 100
#(整数)值递减
decr qingshan
decrby qingshan 100
#浮点数增量
set mf 2.6
incrbyfloat mf 7.3

2.1.3 String底层编码

  以set hello world 为例,因为key是字符串,Redis自己实现了一个字符串类型,叫做SDS(Simple Dynamic String),所以hello指向一个SDS结构。而value我们已经知道是封装在redisObject里面,通过redisObject里的指针指向实际的数据结构,同样是SDS。
JAVA技术体系之分布式篇(四)——Redis缓存_第4张图片
  SDS本质上其实还是字符数组。它有多种结构(sds.h) : sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表25=32byte,28=256byte,216=65536byte=64KB, 232byte=4GB。
  下图显示了C字符数组与SDS的区别:
JAVA技术体系之分布式篇(四)——Redis缓存_第5张图片
  String类型有三种编码:

  1. int,存储8个字节的长整型(long, 2^63-1)。
  2. embstr,代表embstr格式的SDS,存储小于44个字节的字符串。
  3. raw,存储大于44个字节的字符串。

  embstr的使用只分配一次内存空间(因为RedisObject和SDS是连续的),而raw需要分配两次内存空间(分别为RedisObject和SDS)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个RedisObject和SDS都需要重新分配空间,因此,Redis中的embstr实现为只读,无法更改。

  三种编码的转换:

  1. int数据不再是整数——raw
  2. int大小超过了 long 的范围(263-1) ——embstr
  3. embstr长度超过了 44个字节——raw
  4. embstr内容被append追加修改(不是重新set)——raw

2.1.4 String应用场景

缓存
  String类型,缓存热点数据。例如明星出轨、网站首页、报表数据等等。 可以显著提升热点数据的访问速度。

分布式数据共享
  String类型,因为Redis是分布式的独立服务,可以在多个应用之间共享例如:分布式Session

分布式锁
  String类型的setnx方法/只有不存在时才能添加成功,返回true。

public Boolean getLock(Object lockObject) {
	jedisUtil = getJedisConnetion ();
	boolean flag = jedisUtil.setNX(lockObj, 1); 
	if(flag) {
		expire(locakObj/10);
	}
	return flag;
}
public void releaseLock(Object lockObject){
	del(lockObj);
}

全局ID
  INT类型,INCRBY,利用原子性

// 分库分表的场景,一次性拿一段
incrby userid 1000

计数器
  INT类型,INCR方法。例如:文章的阅读量,微博点赞数,允许一定的延迟,先写入Redis再定时同步到数据库。

限流
  INT类型,INCR方法。以访问者的IP和其他信息作为key,访问一次增加一次计数,超过次数则返回false。

2.2 Hash

2.2.1 Hash存储类型

  Hash用来存储多个无序的键值对。最大存储数量232-1 (40亿左右)。
JAVA技术体系之分布式篇(四)——Redis缓存_第6张图片
  注意:前面我们说Redis所有的KV本身就是键值对,用dictEntry实现的,叫做外层的哈希。现在我们讲的是内层的哈希。Hash的value只能是字符串,不能嵌套其他类型,比如hash或者list。

  Hash相对于String的优势:

  1. 把所有相关的值聚集到一个key中,节省内存空间。
  2. 只使用一个key,减少key冲突。
  3. 当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU的消耗。

  Hash不适合的场景:

  1. Field不能单独设置过期时间。
  2. 需要考虑数据量分布的问题(field非常多的时候,无法分布到多个节点)。

2.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
hdel h1 a 
hlen h1

2.2.3 Hash底层编码

2.2.3.1 ziplist压缩列表

  ziplist是一个经过特殊编码的,由连续内存块组成的双向链表。它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度。这样读写可能会慢一些,因为要去算长度,但是可以节省内存, 是一种时间换空间的思想。
JAVA技术体系之分布式篇(四)——Redis缓存_第7张图片

  当hash对象同时满足以下两个条件的时候,使用ziplist编码:

  1. 哈希对象保存的键值对数量< 512个。
  2. 所有的键值对的键和值的字符串长度都< 64byte (—个英文字母一个字节)。
hash-max-ziplist-value 64 // ziplist 中最大能存放的值长度 
hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 节点数量

  如果超过这两个阈值的任何一个,存储结构就会转换成hashtable。

2.2.3.2 hashtable

  在 Redis 中,hashtable 被称为字典(dictionary)。
  前面我们知道了,Redis的KV结构是通过一个dictEntry来实现的。在hashtable中,又对dictEntry进行了多层的封装。
JAVA技术体系之分布式篇(四)——Redis缓存_第8张图片
  redis的hash默认使用的是ht[O],ht[1]不会初始化和分配空间。哈希表dictht是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size属性)和它所保存的节点的数量(used属性)之间的比率。比率在1:1时(一个哈希表ht只存储一个节点entry),哈希表的性能最好;如果节点数量比哈希表的大小要大很多的话(这个比例用ratio表示,5表示平均一个ht存储5个entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。
  如果单个哈希表的节点数量过多,哈希表的大小需要扩容。Redis里面的这种操作叫做 rehash。rehash的步骤如下:

  1. 为字符ht[1]哈希表分配空间。ht[1]的大小为第一个大于等于ht[0].used的2的N次方幕。比如已经使用了10000,那就是16384。
  2. 将所有的ht[O]上的节点rehash到ht[1]上,重新计算hash值和索引,然后放入指定的位置。
  3. 当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.2.4 Hash应用场景

  String可以做的事情,Hash都可以做。除此之外,还可以用于缓存对象数据。

2.3 List

2.3.1 List存储类型

  存储有序的字符串(从左到右),元素可以重复。最大存储数量232-1 (40亿左右)。
在这里插入图片描述

2.3.2 List操作命令

  元素增减:

lpush queue a 
lpush queue b c 
rpush queue d e
lpop queue 
rpop queue

  取值:

lindex queue 0 
lrange queue 0 -1

JAVA技术体系之分布式篇(四)——Redis缓存_第9张图片

2.3.3 List底层编码

  在早期的版本中,数据量较小时用ziplist存储(特殊编码的双向链表),达到临界值时转换为linkedlist进行存储,分别对应OBJ_ENCODING_ZIPLIST和 OBJ_ENCODING_LINKEDLIST。
  3.2版本之后,统一用quicklist来存储。quicklist存储了一个双向链表, 每个节点都是一个ziplist,所以是ziplist和linkedlist的结合体。总体结构:
JAVA技术体系之分布式篇(四)——Redis缓存_第10张图片
  源码quicklist.h 105 行:

typedef struct quicklist (
quicklistNode *head;/*指向双向列表的表头*/
quicklistNode *tail; /*指向双向列表的表尾*/
unsigned long count; /*所有的ziplist中一共存了多少个元素*/
 	
unsigned long len; /*双向链表的长度,node的数量*/
int fill : QL_FILL_BITS; /* ziplist 最大大小,对应 list-max-ziplist-size*/
unsigned int compress : QL_COMP_BITS;/* 压缩深度,对应 list-compress-depth */
unsigned int bookmark count: QL_BM_BITS; /*4 位,bookmarks 数组的大小*/
quicklistBookmark bookmarks[]; /*bookmarks是一个可选字段,quicklist重新分配内存空间时使用,不 使用时不占用空间*/
} quicklist;

  redis.conf 相关参数:
JAVA技术体系之分布式篇(四)——Redis缓存_第11张图片
  源码quicklist.h 46 行:

typedef struct quicklistNode {
struct quicklistNode *prev;/* 扌旨向前一个节点 */
struct quicklistNode *next; /* 指向后一个节点 */
unsigned char *zl; /* 指向实际的 ziplist */
unsigned int sz; /* 当前 ziplist 占用多少字节 */
unsigned int count: 16; /*当前ziplist中存储了多少个元素,占16bit (下同),最大65536个*/
unsigned int encoding : 2; /* 是否采用 了 LZF 压缩算法压缩节点 *1 /* RAW== 1 or LZF==2 */
unsigned int container : 2; /* 2: ziplist,未来可能支持其他结构存储 */ /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /*当前ziplist是不是已经被解压岀来作临时使用*/
unsigned int attempted compress : 1; /* 测试用 */
unsigned int extra : 10; /* 预留给未来使用 */
} quicklistNode;

2.3.4 List应用场景

列表
  例如用户的消息列表、网站的公告列表、活动列表、博客的文章列表、评论列表等等。
队列/栈
  List还可以当做分布式环境的队列/栈使用。List提供了两个阻塞的弹出操作BLPOP/BRPOP,可以设置超时时间(单位:秒)。

blpop queue 
brpop queue

  BLPOP: BLPOP key1 timeout移出并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
  BRPOP: BRPOP key1 timeout移岀并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
JAVA技术体系之分布式篇(四)——Redis缓存_第12张图片
  队列:先进先出:rpush blpop,左头右尾,右边进入队列,左边出队列。
  栈:先进后出:rpush brpop。

2.4 Set

2.4.1 Set存储类型

  Set存储String类型的无序集合,最大存储数量232-1 (40亿左右)。

2.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

2.4.3 Set底层编码

  Redis用intset或hashtable存储set。如果元素都是整数类型,就用intset存储。
  源码intset.h 35 行:

typedef struct intsetuint32_t encoding; // 编码类型 intl6_t、int32_t、int64_t
uint32_t length; // 长度最大长度:2A32
int8_t contents[]; 〃用来存储成员的动态数组
} intset;

  如果不是整数类型,就用hashtable (数组+链表的存来储结构)。如果元素个数超过512个,也会用hashtable存储。跟一个配置有关:

set-max-intset-entries 512

2.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流畅至极

商品筛选

// 获取差集
sdiff set1 set2
// 获取交集(intersection)
sinter set1 set2
//获取并集
sunion set1 set2

  P40上市了。

sadd brand:huawei p40
sadd os:android p40
sadd screensize:6.0-6.24 p40

  筛选商品,华为的,android的,屏幕在6.0-624之间的。

sinter brand: huawei brand:android screensize:6.0-6.24

用户关注、推荐模型

  1. 相互关注?
  2. 我关注的人也关注了他?
  3. 可能认识的人?

2.5 Zset

2.5.1 Zset存储类型

  存储有序的元素,每个元素有个score,按照score从小到大排名。score相同时,按照key的ASCII码排序。
JAVA技术体系之分布式篇(四)——Redis缓存_第13张图片

2.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

2.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,查找的路径是沿着标红的指针所指向的方向进行的:

  1. 23首先和7比较,再和19比较,比它们都大,继续向后比较。
  2. 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与19 在第一层的下一个节点22比较。
  3. 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在。
    JAVA技术体系之分布式篇(四)——Redis缓存_第14张图片

  在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表。
  因为level是随机的,得到的skiplist可能是这样的,有些在第四层,有些在第三层, 有些在第二层,有些在第一层。
JAVA技术体系之分布式篇(四)——Redis缓存_第15张图片
  源码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;

2.5.4 Zset应用场景

排行榜

  例如百度热榜、微博热搜。
  id 为 6001 的新闻点击数加 1:

zincrby hotNews:20251111 1 n6001

  获取今天点击最多的 15 条:

zrevrange hotNews:20251111 0 15 withscores

2.6 其他

2.6.1 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

  支持按位与、按位或等等操作。

BITOP AND destkey key [key ...],对一个或多个key求逻辑并,并将结果保存到destkey。
BITOP OR destkey key [key ...],对一个或多个key求逻辑或,并将结果保存到destkey。
BITOP XOR destkey key [key ...],对一个或多个key求逻辑异或,并将结果保存到destkey。
BITOP NOT destkey key ,对给定key求逻辑非,并将结果保存到destkey。

  计算出7天都在线的用户(假设用户编号有序,依次放在位图):

BITOP "AND" "7 davs both online users" "dav 1 online users11 ndav 2 online users11 ... Hdav 7 online users"

  主要用于用户访问统计、在线用户统计等。

2.6.2 Hyperloglogs

  Hyperloglogs提供了一种不太精确的基数统计方法,用来统计一个集合中不重复的元素个数,比如统计网站的UV,或者应用的日活、月活,存在一定的误差。
  在Redis中实现的HyperLogLog,只需要12K内存就能统计264个数据。

2.6.3 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"

  支持的操作有增加地址位置信息、获取地址位置信息、计算两个位置的距离、获取指定范围内的地理位置集合等等。

2.6.4 Streams

  5.0推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了 kafka的设计。

2.7 总结

数据结构总结
JAVA技术体系之分布式篇(四)——Redis缓存_第16张图片
编码转换总结
JAVA技术体系之分布式篇(四)——Redis缓存_第17张图片
应用场景总结

缓存——提升热点数据的访问速度
共享数据——数据的存储和共享的问题
全局ID —— 分布式全局ID的生成方案(分库分表)
分布式锁——进程间共享数据的原子操作保证
在线用户统计和计数
队列、栈——跨进程的队列/栈
消息队列——异步解耦的消息机制
服务注册与发现—— RPC通信机制的服务协调中心(Dubbo支持Redis)
购物车
新浪/Twitter用户消息时间线
抽奖逻辑(礼物、转发)
点赞、签到、打卡
商品标签
用户(商品)关注(推荐)模型
电商产品筛选
排行榜

3、Redis核心原理

3.1 Redis为什么这么快(10W TPS)

3.1.1 纯内存结构

  Redis是KV结构的内存数据库,时间复杂度0(1)。

3.1.2 请求处理单线程

  这里说的单线程其实指的是处理客户端的请求是单线程的,可以把它叫做主线程。从4.0的版本之后,还引入了一些线程处理其他的事情,比如清理脏数据、无用连接的释放、大key的删除。

单线程的优势:

  1. 没有创建线程、销毁线程带来的消耗。
  2. 避免了上线文切换导致的CPU消耗(什么叫上下文切换?)。
  3. 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等等。

  这里还有一个问题,单线程的这些优势是普适性的,那么为什么业务系统还是使用多线程,显然是因为多线程可以更好地利用CPU资源,从而达到更高的效率,Redis使用单线程难道不会浪费CPU资源吗?
  这个问题官方已经给出了解释:在Redis中单线程已经够用了,CPU不是redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。
  注意,因为请求处理是单线程的,不要在生产环境运行长命令,比如keys,flushall,flushdb,否则会导致请求被阻塞。

3.1.3 IO多路复用机制

  后续Netty内容补充。

3.2 Redis通信协议

  Redis服务器与客户端通过RESP(REdis Serialization Protocol)协议通信。它是一种直观的文本协议,优势在于实现异常简单,解析性能极好。

  RESP传输的最小单元类型

  • 单行字符串: 以 + 符号开头
  • 多行字符串 以 $ 符号开头,后跟字符串长度
  • 整数值 以 : 符号开头,后跟整数的字符串形式
  • 错误消息 以 - 符号开头
  • 数组 以 * 号开头,后跟数组的长度

  注:每个单元结束时统一加上回车换行符号\r\n
  客户端向服务器发送的指令只有一种格式,多行字符串数组。比如一个简单的 set 指令set name alex会被序列化成下面的字符串。

*3\r\n$3\r\nset\r\n$4\r\nname\r\n$4\r\nalex\r\n

  服务器向客户端回复的响应要支持多种数据结构,所以消息响应在结构上亦是如此。

#单行字符串响应,如:set author codehole
+OK
#错误响应,如:incr author
-ERR value is not an integer or out of range
#整数响应,如:incr books
:1
#多行字符串响应,如:get author
$8
codehole
#数组响应,如:hgetall info
*2
$4
name
$6
laoqian
#嵌套,如:scan 0
#1) "0"
#2) 1) "info"
#   2) "books"
#   3) "author"
*2
$1
0
*3
$4
info
$5
books
$6
author

  RESP 协议的简单性、易理解性和易实现性,使它成为互联网技术领域非常受欢迎的一个文本协议。有很多开源项目使用 RESP 作为它的通讯协议。

3.3 Redis内存回收

3.3.1 过期策略

  常规的过期策略有如下3种。

3.3.1.1 立即过期

  每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

3.3.1.2 惰性过期

  只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

3.3.1.3 定期过期

  每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
  Redis中同时使用了惰性过期和定期过期两种过期策略,并不是实时地清除过期的key。

3.3.2 淘汰策略

  Redis的内存淘汰策略,是指当内存使用达到最大内存极限时,需要使用淘汰算法来决定清理掉哪些数据,以保证新数据的存入。redis.conf参数配置:

#maxmemory <bytes>

  如果不设置maxmemory或者设置为0, 32位系统最多使用3GB内存,64位系统不限制内存。淘汰策略参数在redis.conf中配置#maxmemory-policy noeviction:

#volatile-lru -> Evict using approximated LRU, only keys with an expire set.
#allkeys-lru -> Evict any key using approximated LRU.
#volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
#allkeys-lfu -> Evict any key using approximated LFU.
#volatile-random -> Remove a random key having an expire set.
#allkeys-random -> Remove a random key, any key.
#volatile-ttl -> Remove the key with the nearest expire time (minor TTL)

  先从后缀的算法名来看:

  • LRU, Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。
  • LFU, Least Frequently Used,最不常用,按照使用频率删除,4.0版本新增。
  • random,随机删除。

  从前缀针对的对象来分:volatile是针对设置了 ttl的key, all keys是针对所有key。
JAVA技术体系之分布式篇(四)——Redis缓存_第18张图片
在这里插入图片描述
  如果没有设置ttl或者没有符合前提条件的key被淘汰,那么volatile-lru、 volatile-random、volatile-ttl 相当于 noeviction (不做内存回收)。
  建议使用volatile-lru,在保证正常服务的情况下,优先删除最近最少使用的key。

3.3.2.1 LRU淘汰原理(Least Recently Used)

  LRU是一个很常见的算法,比如InnoDB的Buffer Pool也用到了 LRU。传统的LRU:通过链表+HashMap实现,设置链表长度,如果新增或者被访问,就移动到头节点。超过链表长度,末尾的节点被删除。
JAVA技术体系之分布式篇(四)——Redis缓存_第19张图片
  如果基于传统LRU算法实现Redis LRU会需要额外的数据结构存储,消耗内存。Redis LRU对传统的LRU算法进行了改良,通过随机采样来调整算法的精度。
  如果淘汰策略是LRU,则根据配置的采样值maxmemory_samples (默认是5个),随机从数据库中选择m个key,淘汰其中热度最低的key对应的缓存数据。所以采样参数m配置的数值越大,就越能精确的查找到待淘汰的缓存数据,但是也消耗更多的CPU计算,执行效率降低。
  那么如何找出热度最低的数据?Redis中所有对象结构都有一个Iru字段,且使用了 unsigned的低24位,这个字段 用来记录对象的热度。对象被创建时会记录Iru值。在被访问的时候也会更新Iru的值。但并不是获取系统当前的时间戳,而是设置为全局变量server.lruclock的值。
  源码server.h 622 行

typedef struct redisObject (
	unsigned type:4; /* 对藁的类型,包括:OBJ STRING, OBJ_LIST、OBJ_HASH> OBJ SET、OBJ ZSET */
	unsigned encoding:4;/* 具体的数据结构 */
	unsigned lru:LRU_BITS; /* 24位,对象最后一次被命令程序访问的时间,与内存回收有关*/
		/* LRU time (relative to global lru_clock) or
		*LFU data (least significant 8 bits frequency
		*and most significant 16 bits access time). */
	int refcount; /*引用计数。当refcoiint为0的时候,表示该对象已经不被任何对象引用,则可以进行垃 圾回收了 */
	void *ptr;/*指向对象实际的数据结构*/
} robj;

  Redis中有个定时处理的函数serverCron,默认每100毫秒调用函数 updateCachedTime更新一次全局变量的server.lruclock的值,它记录的是当前unix 时间戳。
  源码server.c 1756 行

void updateCachedTime(int update daylight infb) {
	server.ustime = ustime();
	server.mstime = server.ustime / 1000;
	server.unixtime = seiver.mstime / 1000;
/* To get information about daylight saving time, we need to call
* localtime r and cache the result. However calling localtime_r in this
*context is safe since we will never fbrk() while here, in the main
*thread. The logging fimction will call a thread safe version of
*localtime that has no locks. */
	if (update_daylight_info) {
		struct tm tm;
		time_t ut = seiver.unixtime;
		localtime_r(&ut,&tm);
		server.daylight_active = tm.tmisdst;
	}
}

  Redis并没有获取精确的时间而是放在全局变量中,这样函数査询key调用lookupKey中更新数据的Iru热度值时,就不用每次调用系统函数time,可以提高执行效率。
  当对象里面已经有了 LRU字段的值,就可以评估对象的热度了。
  源码evict.c 90行

/* Given an object returns the min number of milliseconds the object was never
* requested, using an approximated LRU algorithm. */
unsigned long long estimateObjectIdleTime(robj *o) {
	unsigned long long lruclock = LRU_CLOCK();
	if (lruclock >= o->lru) (
		return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
	} else {
		return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
				LRU_CLOCK_RESOLUTION;
	}
}

  函数estimateObjectldleTime评估指定对象的Iru热度,方法就是对象的Iru值和全局的server.lruclock的差值越大(越久没有得到更新),该对象热度越低。server.lruclock只有24位,按秒为单位来表示才能存储194天。当超过24bit能表示的最大时间的时候,它会从头开始计算。在这种情况下,可能会出现对象的Iru大于server.lruclock的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。
  Redis没有用常规的哈希表+双向链表的方式实现,因为需要额外的数据结构,消耗资源。

3.3.2.2 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

3.4 Redis持久化机制

  Redis速度快,很大一部分原因是因为它所有的数据都存储在内存中。如果断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案,—种是 RDB 快照(Redis DataBase),—种是 AOF (Append Only File)。 持久化是Redis跟Memcache的主要区别之一。

3.4.1 RDB

  RDB是Redis默认的持久化方案(注意如果开启了 AOF,优先用AOF)。当满足 一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdb。Redis 重启会通过加载dump.rdb文件恢复数据。

3.4.1.1 RDB触发

自动触发

  1)配置规则触发。
  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

JAVA技术体系之分布式篇(四)——Redis缓存_第20张图片
  除了根据配置触发生成RDB, RDB还有两种自动触发方式:
  2)shutdown触发,保证服务器正常关闭。
  3)flushall, rdb文件是空的,没什么意义

手动触发

  如果我们需要重启服务或者迁移数据,这个时候就需要手动触RDB快照保存。Redis 提供了两条命令:
  1) save
  save在生成快照的时候会阻塞当前Redis服务器,Redis不能处理其他命令。如果 内存中的数据比较多,会造成Redis长时间的阻塞。生产环境不建议使用这个命令。
为了解决这个问题,Redis提供了第二种方式。
  2) bgsave
  执行bgsave时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程执行fork操作创建子进程(copy-on-write) , RDB持久化 过程由子进程负责,完成后自动结束。它不会记录fork之后产生的数据。阻塞只发生在 fork阶段,一般时间很短。

3.4.1.2 RDB数据恢复

shutdown 持久化

  添加键值

redis> set k1 1
redis> set k2 2
redis> set k3 3
redis> set k4 4
redis> set k5 5

  停服务器,触发save:

redis> shutdown

  备份dump.rdb文件:

cp dump.rdb dump.rdb.bak

  启动服务器:

/usr/local/soft/redis-6.0.9/src/redis-server /usr/local/soft/redis-6.0.9/redis.conf

  数据都在:

redis> keys

模拟数据丟失

  模拟数据丢失(删库跑路了),触发save:

redis> flushall

  停服务器:

redis> shutdown

  启动服务器:

/usr/local/soft/redis・6.0.9/src/redis・server /usr/local/soft/redis-6.0.9/redis.conf

  啥都没有:

redis> keys *

通过备份文件恢复数据

  停服务器:

redis> shutdown

  重命名备份文件:

mv dump.rdb.bak dump.rdb

  启动服务器:

/usr/local/soft/redis-6.0.9/src/redis-server /usr/local/soft/redis-6.0.9/redis.conf

  查看数据:

redis> keys *
3.4.1.3 RDB文件的优势和劣势

优势:

  1. RDB是一个非常紧凑(compact)的文件,它保存了 redis在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
  2. 生成RDB文件的时候,redis主进程会fork。一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
  3. RDB在恢复大数据集时的速度比AOF的恢复速度要快。

劣势:

  1. RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,频繁执行成本过高。
  2. 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照之后的所有修改(数据有丟失)。
  3. 如果数据相对来说比较重要,希望将损失降到最小,则可以使用AOF方式进行持久化。

3.4.2 AOF

  Redis默认不开启AOF。AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。

3.4.2.1 AOF配置

  配置文件redis.conf

#开关 
appendonly no
#文件名
appendfilename "appendonly.aof"

在这里插入图片描述
  由于AOF持久化是Redis不断将写命令记录到AOF文件中,随着Redis不断的进行,AOF的文件会越来越大,文件越大,占用服务器内存越大以及AOF恢复要求时间越长。例如计数器增加100万次,100万个命令都记录进去了,但是结果只有1个。为了解决这个问题,Redis新增了重写机制,当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集。可以使用命令bgrewriteaof来重写。
  AOF文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对, 然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的AOF文件。

#重写触发机制
auto-aof-rewrite-percentage 100
auto-aof^rewrite-min-size 64mb

JAVA技术体系之分布式篇(四)——Redis缓存_第21张图片
  问题:重写过程中,AOF文件被更改了怎么办?
JAVA技术体系之分布式篇(四)——Redis缓存_第22张图片
  另外有两个与AOF相关的参数:
JAVA技术体系之分布式篇(四)——Redis缓存_第23张图片

3.4.2.2 AOF数据恢复

  重启Redis之后就会进行AOF文件的恢复。

3.4.2.1 AOF优势与劣势

优点
  AOF持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步
一次,Redis最多也就丢失1秒的数据而已。
缺点
  对于具有相同数据的的Redis, AOF文件通常会比RDF文件体积更大(RDB存的是数据快照)。
  虽然AOF提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB比AOF具好更好的性能保证。

3.4.3 两种方案比较

  那么对于AOF和RDB两种持久化方式,我们应该如何选择呢?
  如果可以忍受一小段时间内数据的丢失,毫无疑问使用RDB是最好的,定时生成 RDB快照(snapshot)非常便于进行数据库备份,并且RDB恢复数据集的速度也要比 AOF恢复的速度要快。
  否则就使用AOF重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。

3.5 发布-订阅模式

  前面我们说通过队列的rpush和blpop可以实现消息队列(队尾进队头出),没有任何元素可以弹出的时候,连接会被阻塞。但是基于list实现的消息队列,不支持一对多的消息分发,相当于只有一个消费者。如果要实现一对多的消息分发,就需要使用redis的发布-订阅功能。

订阅频道
  消息的生产者和消费者是不同的客户端,连接到同一个Redis的服务。通过什么对象把生产者和消费者关联起来呢?在RabbitMQ里面叫Queue,在Kafka里面叫Topic。Redis的模型里面这个叫channel (频道)。
  订阅者可以订阅一个或者多个channeL消息的发布者可以给指定的channel发布消息。只要有消息到达了 channel,所有订阅了这个channel的订阅者都会收到这条消息。
JAVA技术体系之分布式篇(四)——Redis缓存_第24张图片

  订阅者订阅频道:可以一次订阅多个,比如这个客户端订阅了 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

JAVA技术体系之分布式篇(四)——Redis缓存_第25张图片
  生产者,向3个频道发布3条信息,对应的订阅者能收到消息:

publish news-sport kobe
publish news-music jaychou
publish news-weather sunny

  一般来说,考虑到性能和持久化的因素,不建议使用Redis的发布订阅功能来实现MQ。Redis的一些内部机制用到了发布订阅功能。

3.6 Redis事务

3.6.1 为什么要使用事务

  Redis的单个命令是原子性的(比如get set mget mset)要么成功要么失败,不存在并发干扰的问题。如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就必须要依赖Redis的功能特性来实现了。Redis提供了事务的功能,可以把一组命令一起执行。Redis的事务有3个特点:

  • 按进入队列的倾序执行。
  • 不会受到其他客户端的请求的影响。
  • 事务不能嵌套,多个multi命令效果一样。

3.6.2 事务的用法

  Redis的事务涉及到四个命令: multi (开启事务), exec (执行事务), discard(取消事务), watch (监视)。
  案例场景:tom和mic各有1000元,tom向mic转账100元。

set tom 1000 
set mic 1000 
multi 
decrby tom 100 
incrby mic 100 
exec
get tom
get mic

  通过multi的命令开启事务。multi执行后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中。当exec命令被调用时,所有队列中的命令才会被执行。
  如果没有执行exec,所有的命令都不会被执行。如果中途不想执行事务了,可以调用discard可以清空事务队列,放弃执行。

multi
decrby tom 100 discard
get tom

3.6.3 watch命令

  为了防止事务过程中某个key的值被其他客户端请求修改,带来非预期的结果,在 Redis中还提供了一个watch命令。也就是多个客户端更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。它可以为Redis事务提供CAS乐观锁行为(Compare and Swap) 。
JAVA技术体系之分布式篇(四)——Redis缓存_第26张图片
  我们可以用watch监视一个或者多个key,如果开启事务之后,至少有一个被监视 key键在exec执行之前被修改了,那么整个事务都会被取消(key提前过期除外)。可以用unwatch取消。

3.6.4 事务可能遇到的问题

  事务执行遇到的问题分成两种,一种是在执行exec之前发生错误,一种是在执行exec之后发生错误。

3.6.4.1 在执行exec之前发生错误

  比如:入队的命令存在语法错误,包括参数数量,参数名等等(编译器错误)。

multi
set key value
hset key value
exec

  这里出现了参数个数错误,事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。

3.6.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的这种事务机制来实现原子性,保证数据的一致。

3.6.4.3 为什么不回滚?

  官方的解释是这样的:

  • Redis命令只会因为错误的语法而失败,也就是说,从实用性的角度来说,失败的命令是由代码错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中(这个是程序员的锅)。
  • 因为不需要对回滚进行支持,所以Redis的内部可以保持简单且快速。需要知道的是:回滚不能解决代码的问题(程序员的锅必须程序员来背)。

  Redis从2.6版本开始引入了 Lua脚本,也就是说Redis可以用Lua来执行Redis 命令。

3.7 Lua脚本

  Lua是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。
  使用Lua脚本来执行Redis命令的好处:

  1. 一次发送多个命令,减少网络开销。
  2. Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
  3. 对于复杂的组合命令,我们可以放在文件中,可以实现命令复用。

3.7.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语言,它们是可填可不填的。

  示例,返回一个字符串,0个参数:

redis> eval "return 'Hello World'" 0

  这样虽然可以再Redis客户端中执行Lua脚本,但是意义不大。实际上,Lua脚本在Redis里面真正的用途是用来执行Redis命令。
  重新理一下:Redis中可以运行Lua脚本,用Lua脚本来执行Redis命令。

3.7.2 在Lua脚本中调用Redis命令

3.7.2.1 命令格式

  使用 redis.call(command, key [param1, param2…])进行操作。语法格式:

redis.call(command, key [param1,param2...])
  • command 是命令,包括 set、get、del 等。
  • key是被操作的键。
  • param1,param2…代表给 key 的参数。

  来看一个简单的案例,让Lua脚本执行set qingshan 2673 (Redis客户端执行):

eval "return redis.call('set', 'qingshan', '2673')" 0

这种方式是写死值的,当然也可以用传参的方式:

eval "return redis.call('set',KEYS[1], argv[1])" 1 qingshan miaomiao
3.7.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.7.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.7.2.4 缓存Lua脚本

  在Lua脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给Redis服务 端,会产生比较大的网络开销。为了解决这个问题,Redis可以缓存Lua脚本并生成SHA1 摘要码,后面可以直接通过摘要码来执行Lua脚本。
  这里面涉及到两个命令,首先是在服务端缓存山a脚本生成一个摘要码,用script load命令。

script load "return 'Hello World'"

第二个命令是通过摘要码执行缓存的脚本:

evalsha ”470877a599ac74fbfda41caa908de682c5fc7d4b” 0
3.7.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、Redis分布式实现

4.1 为什么Redis需要支持分布式

  Redis本身的QPS已经很高了,但是如果在一些并发量非常高的情况下,性能还是会受到影响。这个时候我们希望有更多的Redis服务来分摊压力,实现负载均衡。
  第二个是可用性和安全的问题。如果只有一个Redis服务一旦服务宕机,那么所有的客户端都无法访问,会对业务造成很大的影响。另一个,如果硬件发生故障,而单机的数据无法恢复的话,带来的影响也是灾难性的。
  第三个是出于存储的考虑。因为Redis所有的数据都放在内存中,如果数据量大,很容易受到硬件的限制。升级硬件(scale up)收效和成本比太低,所以我们需要有一种横向扩展的方法。
  高性能、高可用、扩展性需要依赖两种关键的技术,一种是分片,一种是冗余。分片的意思是把所有的数据拆分到多个节点分散存储。冗余的意思是每个节点都有一个或者多个副本。那么,Redis必须要提供数据分片和主从复制的功能。副本有不同的角色, 如果主节点发生故障,则把某个从节点改成主节点,访问新的主节点,实现高可用。
  下面我们讨论的内容就是围绕这三点,第一个是主从复制怎么实现。第二个是主从自动切换怎么实现。第三个是数据分片怎么实现。

4.2 Redis主从复制

4.2.1 主从复制原理

  Redis的主从复制分为两类,一种叫全量复制,就是一个节点第一次连接到master 节点,需要全部的数据。第二种叫做增量复制,比如之前已经连接到master节点,但是中间网络断开,或者slave节点宕机了,缺了一部分的数据。

4.2.1.1 连接阶段

  slave节点启动时(或者执行slaveof命令时),会在自己本地保存master节点的信息,包括master node的host和ip。slave节点内部有个定时任务replicationCron,每隔1秒钟检查是否有新的 master node 要连接和复制。如果发现有master节点,就跟master节点建立连接。如果连接成功,从节点就为连接连理一个专门处理复制工作的文件事件处理器负责后续的复制工作。为了让主节点感知到slave节点的存活,slave节点定时会给主节点发送ping请求。
  建立连接以后,就可以同步数据了,这里也分成两个阶段。

4.2.1.2 数据同步阶段

  如果是新加入的slave节点,那就需要全量复制。master通过bgsave命令在本地生成一份RDB快照,将RDB快照文件发给slave节点(如果超时会重连,可以调大 repl-timeout 的值)。
  如果slave节点自己本来有数据,需要先清除自己的旧数据,然后用RDB文件加载数据。
  开始生成RDB文件时,master会把所有新的写命令缓存在内存中。在slave节点保存了RDB之后,再将新的写命令复制给slave节点。(跟AOF重写rewrite期间接收到的命令的处理思路是一样的)
  第一次全量同步完了,主从已经保持一致了,后面就是持续把接收到的命令发送给slave节点。

4.2.1.3 传播阶段

  master node持续把写命令,异步复制给slave node。
  总结起来非常地简单,前面用RDB文件,后面把命令发给slave节点,就实现了主从复制。
  注意,一般情况下我们不会用Redis做读写分离,因为Redis的吞吐量已经够高了, 做集群分片之后并发的问题更少,所以不需要考虑主从延迟的问题。
  如果从节点有一段时间断开了与主节点的连接。slave可以通过master记录的偏移量实现增量复制。
  为了降低主节点磁盘开销,Redis支持无盘复制,master生成的RDB文件不保存到磁盘而是直接通过网络发送给从节点。无盘复制适用于主节点所在机器磁盘性能较差但网络宽带较充裕的场景。

4.2.2 主从复制不足

  Redis主从复制解决了数据备份和一部分性能的问题,但是没有解决高可用的问题。在一主一从或者一主多从的情况下,如果主服务器挂了,对外提供的服务就不可用了,单点问题没有得到解决。如果每次都是手动把之前的从服务器切换成主服务器,然后再把剩余节点设置为它的从节点,这个比较费时费力,还会造成一定时间的服务不可用。

4.3 Sentinel哨兵

4.3.1 Sentinel原理

  怎么实现高可用呢?第一个对于服务端来说,能够实现主从自动切换;第二个,对于客户端来说,如果发生了主从切换,它需要获取最新的master节点。
  Redis的高可用是通过哨兵Sentinel来保证的。它的思路就是通过运行监控服务器来保证服务的可用性。从Redis2.8版本起,提供了一个稳定版本的Sentinel (哨兵),用来解决高可用的问题。我们会启动奇数个的Sentinel的服务(通过src/redis-sentinel)。
  它本质上只是一个运行在特殊模式之下的Redis。Sentinel通过info命令得到被监听Redis机器的master, slave等信息。
JAVA技术体系之分布式篇(四)——Redis缓存_第27张图片
  为了保证监控服务器的可用性,我们会对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节点自己选举?还是谁?

4.3.2 Raft算法

  Redis的选举和故障转移都是由Sentinel完成的。问题又来了,既然有这么多的Sentinel节点,由谁来做故障转移的事情呢?
  故障转移流程的第一步就是在Sentinel集群选择一个Leader,由Leader完成故障转移流程。Sentinle通过Raft算法,实现Sentinel选举。
  我们前面说过,只要有了多个副本,就必然要面对副本一致性的问题。如果要所有的节点达成一致,必然要通过复制的方式实现。但是这么多节点,以哪个节点的数据为准呢?所以必须选岀一个Leader。所以数据保持一致需要两个步骤:领导选举,数据复制。数据复制我们前面讲过了。 这里关注一下选举的实现。
  Raft是一个共识算法(consensus algorithm)。比如比特币之类的加密货币,就需要共识算法。Spring Cloud的注册中心解决方案Consul也用到了Raft协议。
  Raft的核心思想:先到先得,少数服从多数。

  Raft算法演示:http://thesecretlivesofdata.com/raft/

  1. 分布式环境中的节点有三个状态:Follower、Candidate (虚线外框)、Leader (实线外框)。
  2. 一开始所有的节点都是Follower状态。如果Follower连接不到Leader (Leader 挂了),它就会成为Candidate。Candidate请求其他节点的投票,其他的节点会投给 它。如果它得到了大多数节点的投票,它就成为了主节点。这个过程就叫做Leader Election。
  3. 现在所有的写操作需要在Leader节点上发生。Leader会记录操作日志。没有同 步到其他Follower节点的日志,状态是uncommitted。等到超过半数的Follower同步 了这条记录,日志状态就会变成committed。 Leader会通知所有的Follower B志已经 committed,这个时候所有的节点就达成了一致。这个过程叫Log Replication。
  4. 在Raft协议里面,选举的时候有两个超时时间。第一个叫election timeout0 也就是说,为了防止同一时间大量节点参与选举,每个节点在变成Candidate之前需要 随机等待一段时间,时间范围是150ms and 300ms之间。第一个变成Candidate的节 点会先发起投票。它会先投给自己,然后请求其他节点投票(Request Vote) 。
  5. 如果还没有收到投票结果,又到了超时时间,需要重置超时时间。只要有大部分 节点投给了一个节点,它就会变成Leader。
  6. 成为Leader之后,它会发消息让来同步数据(Append Entries),发消息的间 隔是由heartbeat timeout来控制的。Followers会回复同步数据的消息。
  7. 只要Followers收到了同步数据的消息,代表Leader没挂,他们就会清除 heartbeat timeout 的计时。
  8. 但是一旦 Followers 在 heartbeat timeout 时间之内没有收到 Append Entries 消息,它就会认为Leader挂了,开始让其他节点投票,成为新的Leader。
  9. 必须超过半数以上节点投票,保证只有一个Leader被选出来。
  10. 如果两个Follower同时变成了 Candidate,就会出现分割投票。比如有两个节 点同时变成Candidate,而且各自有一个投票请求先达到了其他的节点。加上他们给自己的投票,每个Candidate手上有2票。但是/因为他们的election timeout不同,在 发起新的一轮选举的时候,有一个节点收到了更多的投票,所以它变成了 Leader。

  到了这一步,我们还只是从所有的Sentinle节点里面选出来了一个Leader而已,也就是所谓了选举委员会主席。下面才是真正的选举。
  Redis的master节点的选举规则又是什么样的呢?
  对于所有的slave节点,一共有四个因素影响选举的结果,分别是断开连接时长、优先级排序、复制数量、进程id。

  1. 如果与哨兵连接断开的比较久,超过了某个阈值,就直接失去了选举权。
  2. 如果拥有选举权,那就看谁的优先级高,这个在配置文件里可以设置 (replica-priority 100),数值越小优先级越高。
  3. 如果优先级相同,就看谁从master中复制的数据最多(复制偏移量最大),选最多的那个
  4. 如果复制数量也相同,就选择进程id最小的那个。

4.3.3 Sentinel不足

  1. 主从切换的过程中会丢失数据,因为只有一个master。
  2. 只能单点写,没有解决水平扩容的问题。
  3. 如果数据量非常大,这个时候就要对Redis的数据进行分片了。这个时候我们需要多个master-slave的group,把数据分布到不同的group中。
    JAVA技术体系之分布式篇(四)——Redis缓存_第28张图片

4.4 Redis分布式方案

  数据源的动态选择?分库分表的分片策略?问题是这段代码逻辑在哪里实现?如果要实现Redis数据的分片,我们有三种方案。
  第一种是在客户端实现相关的逻辑,例如用取模或者一致性哈希对key进行分片, 查询和修改都先判断key的路由。
  第二种是把做分片处理的逻辑抽取出来,运行一个独立的代理服务,客户端连接到这个代理服务,代理服务做请求的转发。
  第三种就是基于服务端实现。

4.4.1 客户端 Sharding

  在我们用得非常多的Jedis客户端中,支持分片功能。它是Spring Boot 2.x版本之前默认的Redis客户端,RedisTemplate就是对Jedis的封装。
  Jedis有几种连接池,其中有一种支持分片。比如这里一个连接到186、一个连接到Windows的Redis服务。

public class ShardingTest {
	public static void main(String[] args) {
		JedisPoolConfig poolConfig = new JedisPoolConfig();
		// Redis服务器
		JedisShardInfo shardinfo1 = new JedisShardInfo ("127.0.0.1", 6379);
		JedisShardhifo shardinfo2 = new JedisShardInfo("192.168.44.186", 6379);
		//连接池
		List<JedisShardInfo > infbList = Arrays.asList(shardinfo1 , shardinfo2); 
		ShardedJedisPool jedisPool = new ShardedJedisPool(poolConfig, infbList);
		ShardedJedis jedis = null;
		try{
			jedis = jedisPool.getResource();
			for(int i=0; i<100; i++)( 
				jedis.set("k"+i, ""+i);
			}
			for(int i=0; i<100; i++){ 
				System.out.println(jedis.get("k"+i));
			}
		} finally ( 
			if(jedis!=null) { 
				jedis.close();
			}
		}
	}
}

  通过dbsize命令发现,一台服务器有44个key,—台服务器有56个key。ShardedJedis是怎么做到的呢?如果是希望数据分布相对均匀的话,我们首先可以考虑哈希后取模(因为key不一定是整数,所以先计算哈希)。例如,hash(key)%N,根据余数,决定映射到那一个节点。这种方式比较简单,属于静态的分片规则。但是一旦节点数量变化(新增或者减少),由于取模的N发生变化,数据需要重新分布。
  为了解决这个问题,我们又有了一致性哈希算法。ShardedJedis实际上用的就是一致性哈希算法。一致性哈希的原理:
  把所有的哈希值空间组织成一个虚拟的圆环(哈希环),整个空间按顺时与十方向组 织。因为是环形空间,0和232-1是重叠的。
  假设我们有四台机器要哈希环来实现映射(分布数据),我们先根据机器的名称或者IP计算哈希值,然后分布到哈希环中(红色圆圈)。
JAVA技术体系之分布式篇(四)——Redis缓存_第29张图片
  现在有4条数据或者4个访问请求,对key计算后,得到哈希环中的位置(绿色圆圈)。沿哈希环顺时针找到的第一个Node,就是数据存储的节点。
JAVA技术体系之分布式篇(四)——Redis缓存_第30张图片
  在这种情况下,新增了一个Node5节点,只影响一部分数据的分布。
JAVA技术体系之分布式篇(四)——Redis缓存_第31张图片
  删除了节点Node4,只影响相邻的一个节点。
JAVA技术体系之分布式篇(四)——Redis缓存_第32张图片
  —致性哈希解决了动态增减节点时,所有数据都需要重新分布的问题,它只会影响到下一个相邻的节点,对其他节点没有影响。
  但是这样的一致性哈希算法有一个缺点,因为节点不一定是均匀地分布的,特别是在节点数比较少的情况下,所以数据不能得到均匀分布。解决这个问题的办法是引入虚拟节点(Virtual Node)。比如:2个节点,5条数据,只有1条分布到Node2/ 4条分布到Node1,不均匀。
JAVA技术体系之分布式篇(四)——Redis缓存_第33张图片
  Node1设置了两个虚拟节点,Node2也设置了两个虚拟节点(虚线圆圈)。这时候有3条数据分布到Node1, 1条数据分布到Node2。
JAVA技术体系之分布式篇(四)——Redis缓存_第34张图片
  一致性哈希在分布式系统中,负载均衡、分库分表等场景中都有应用。使用ShardedJedis之类的客户端分片代码的优势是配置简单,不依赖于其他中间件,分区的逻辑可以自定义,比较灵活。但是基于客户端的方案,不能实现动态的服务增减,每个客户端需要自己维护分片策略,存在重复代码。
  第二种思路就是把分片的代码抽取出来,做成一个公共服务,所有的客户端都连接
到这个代理层。由代理层来实现请求和转发。

4.4.2 代理Proxy

  典型的代理分区方案有Twitter开源的Twemproxy和国内的豌豆荚开源的Codis。但由于其需要单独部署一种甚至多种服务,现在已经接近淘汰,不作过多赘述。

4.4.3 Redis Cluster

  Redis Cluster是在Redis 3.0的版本正式推出的,用来解决分布式的需求,同时也可以实现高可用。跟Codis不一样,它是去中心化的,客户端可以连接到任意一个可用节点。数据分片有几个关键的问题需要解决:

  1. 数据怎么相对均匀地分片。
  2. 客户端怎么访问到相应的节点和数据。
  3. 重新分片的过程,怎么保证正常服务。
4.4.3.1 架构

  Redis Cluster可以看成是由多个Redis实例组成的数据集合。客户端不需要关注数据的子集到底存储在哪个节点,只需要关注这个集合整体。以3主3从为例,节点之间两两交互,共享数据分片、节点状态等信息。
JAVA技术体系之分布式篇(四)——Redis缓存_第35张图片

4.4.3.2 数据分布

  那么Cluster如何解决分片的问题,数据怎么分布?Redis既没有用哈希取模,也没有用一致性哈希,而是用虚拟槽来实现的。Redis创建了16384个槽(slot),每个节点负责一定区间的slot。比如Node1负责 0-5460, Node2 负责 5461-10922,Node3 负责 10923-16383。
JAVA技术体系之分布式篇(四)——Redis缓存_第36张图片
  对象分布到Redis节点上时,对key用CRC16算法计算再%16384,得到一个slot 的值,数据落到负责这个slot的Redis节点上。Redis的每个master节点都会维护自己负责的slot用一个bit序列实现,比如:序列的第0位是1,就代表第一个slot是它负责;序列的第1位是0,代表第二个slot 不归它负责。
  注意:key与slot的关系是永远不会变的,会变的只有slot和Redis节点的关系。
  怎么让相关的数据落到同一个节点上?比如有些multi key操作是不能跨节点的,例如用户2673的基本信息和金融信息?在key里面加入{hash tag}即可。Redis在计算槽编号的时候只会获取{}之间的字符 串进行槽编号计算,这样由于上面两个不同的键,{}里面的字符串是相同的,因此他们可以被计算出相同的槽。

4.4.3.3 客户端重定向

  那么客户端要连接到哪一台服务器呢?访问的数据不在当前节点上,怎么办?当访问的节点上没有数据时,会返回报错信息:

(error) MOVED 13724 127.0.0.1:7293

  服务端返回MOVED,也就是根据key计算出来的slot不归7291端口管理,而是归7293端口管理,服务端返回MOVED告诉客户端去7293端口操作。这个时候更换端口,用redis-cli-p 7293操作,才会返回OK。或者用./redis-cli -c -p port的命令。
  这样客户端需要连接两次。Jedis等客户端会在本地维护一份slot——node的映射
关系,大部分时候不需要重定向,所以叫做smart jedis (需要客户端支持)。

4.4.3.4 数据迁移

  因为key和slot的关系是永远不会变的,当新增了节点的时候,需要把原有的slot分配给新的节点负责,并且把相关的数据迁移过来。
  添加新节点(新增一个7297):

redis-cli -cluster add-node 127.0.0.1:7291 127.0.0.1:7297

  新增的节点没有哈希槽,不能分布数据,在原来的任意一个节点上执行:

redis-cli —cluster reshard 127.0.0.1:7291

  输入需要分配的哈希槽的数量(比如500)和哈希槽的来源节点(可以输入all或者 id) 。

4.4.3.5 高可用和主从切换原理

  当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程,其过程如下:

  1. slave发现自己的master变为FAIL。
  2. 将自己记录的集群currentEpoch加1,并广播FAILOVER AUTH REQUEST信息.
  3. 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个 epoch 只发送一次 ack.
  4. 尝试 failover 的 slave 收集 FAILOVER AUTH ACK.
  5. 超过半数后变成新Master.
  6. 广播Pong通知其他集群节点。

  总结:RedisCluster既能够实现主从的角色分配,又能够实现主从切换,相当于集成了 Replication 和 Sentinel 的功能。

4.4.3.6 Redis Cluster 特点总结:
  1. 无中心架构。
  2. 数据按照slot存储分布在多个节点,节点间数据共享,可动态调整数据分布。
  3. 可扩展性,可线性扩展到1000个节点(官方推荐不超过1000个),节点可动态添加或删除。
  4. 高可用性,部分节点不可用时,集群仍可用。通过增加Slave做standby数据副本,能够实现故障自动failover,节点之间通过gossip协议交换状态信息,用投票机制完成Slave到Master的角色提升。
  5. 降低运维成本,提高系统的扩展性和可用性。

5、Redis常见问题及解决方案

5.1 数据一致性问题

5.1.1 缓存使用场景

  针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。当我们使用Redis作为缓存的时候,一般流程是这样的:
  1、如果数据在Redis存在,应用就可以直接从Redis拿到数据,不用访问数据库。
JAVA技术体系之分布式篇(四)——Redis缓存_第37张图片
  2、应用新增了数据,只保存在数据库中,这个时候Redis没有这条数据。如果Redis里面没有,先到数据库查询,然后写入到Redis,再返回给应用。
JAVA技术体系之分布式篇(四)——Redis缓存_第38张图片

5.1.2 一致性问题的定义

  因为数据最终是以数据库为准的(这是我们的原则),如果Redis没有数据,就不存在这个问题。当Redis和数据库都有同一条记录,而这条记录发生变化的时候,就可能出现一致性的问题。—旦被缓存的数据发生变化(比如修改、删除)的时候,我们既要操作数据库的数据,也要操作Redis的数据,才能让Redis和数据库保持一致。所以问题来了。现在我们有两种选择:

  1. 先操作Redis的数据再操作数据库的数据
  2. 先操作数据库的数据再操作Redis的数据

  首先需要明确的是,不管选择哪一种方案,我们肯定是希望两个操作要么都成功, 要么都一个都不成功。但是,Redis的数据和数据库的数据是不可能通过事务达到统一的, 我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。

5.1.3 方案选择

5.1.3.1 删除还是更新

  这里我们先要补充一点,当存储的数据发生变化,Redis的数据也要更新的时候,我们有两种方案,一种就是直接更新Redis数据,调用set;还有一种是直接删除Redis数据,让应用在下次查询的时候重新写入。这两种方案怎么选择呢?
  这里我们主要考虑更新缓存的代价。更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据,而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单, 而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也认为一次缓存命中的损失低于更新缓存的消耗,所以推荐使用删除的方案。
  所以,更新操作和删除操作,只要数据变化,都用删除。

5.1.3.2 先删缓存还是先更新数据库

先更新数据库

正常情况:更新数据库,成功。删除缓存,成功。

异常情况:

  1. 更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
  2. 更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。

  这种问题怎么解决呢?我们可以提供一个重试的机制。例如,如果删除缓存失败,我们捕获这个异常,把需要删除的key发送到消息队列。 然后自己创建一个消费者消费,尝试再次删除这个key。

先删缓存

正常情况:删除缓存,成功。更新数据库,成功。

异常情况:

  1. 删除缓存失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
  2. 删除缓存成功,更新数据库失败。因为以数据库的数据为准,所以不存在数据不一致的情况。

  看起来好像没问题,但是如果有程序并发操作的情况下:
    1)线程A需要更新数据,首先删除了 Redis缓存
    2)线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回
    3)线程A更新了数据库
  这个时候,Redis是旧的值,数据库是新的值,发生了数据不一致的情况。这个是由于线程并发造成的问题。能不能让对同一条数据的访问串行化呢?代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个应用实例(应用做了集群部署)。数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是强制串行操作,吞吐量太低了。
  所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。

5.2 缓存雪崩

  缓存雪崩就是Redis的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候Redis请求的并发量又很大,就会导致所有的请求落到数据库。
  解决方案如下:

  1. 加互斥锁,针对同一个key只允许一个线程到数据库查询。
  2. 缓存定时预先更新,避免同时失效。
  3. 通过加随机数,使key在不同的时间过期。
  4. 热点数据永不过期。

5.3 缓存击穿

  缓存击穿跟缓存雪崩类似,区别就是缓存雪崩是群体失效,缓存击穿是单体失效,比如一个非常热点的数据。比较典型的场景就是新浪微博的热点事件,比如鹿晗和关晓彤事件,因为超高并发的访问,如果这个时间点缓存过期,在系统从后端DB加载数据到缓存这个过程中,这段时间超大并发的请求会同时打到DB上,很有可能瞬间把DB压垮。
  解决方案如下:

  1. 加互斥锁,针对同一个key只允许一个线程到数据库查询。
  2. 热点数据永不过期。

5.4 缓存穿透

5.4.1 缓存穿透何时发生

   我们已经知道了 Redis使用的场景了。缓存存在和缓存不存在的情况我们都了解了。
JAVA技术体系之分布式篇(四)——Redis缓存_第39张图片
  在这里Redis起到了提升査询速度和保护数据库的作用。还有一种情况,数据在数据库和Redis里面都不存在,可能是一次条件错误的査询。在这种情况下,因为数据库值不存在,所以肯定不会写入Redis,那么下一次査询相同的key的时候,肯定还是会再到数据库查一次。那么这种循环查询数据库中不存在的值,并且每次使用的是相同的key的情况,我们有没有什么办法避免应用到数据库查询呢?
  我们可以在数据库缓存一个空字符串,或者缓存一个特殊的字符串,那么在应用里面拿到这个特殊字符串的时候,就知道数据库没有值了,也没有必要再到数据库查询了。但是这里需要设置一个过期时间,不然的会数据库已经新增了这一条记录,应用也还是拿不到值。
  这个是应用重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?这种因为每次查询的值都不存在导致的Redis失效的情况,我们就把它叫做缓存穿透。

5.4.2 一道经典面试题

  其实它也是一个通用的问题,关键就在于我们怎么知道请求的key在我们的数据库里面是否存在,如果数据量特别大的话,我们怎么去快速判断。这也是一个非常经典的面试题:如何在海量元素中(例如10亿无序、不定长、不重复)快速判断一个元素是否存在?
  如果是缓存穿透的这个问题,我们要避免到数据库查询不存在的数据,肯定要把这10 亿放在别的地方。为了加快检索速度,我们要把数据放到内存里面来判断,问题来了:如果我们直接把这些元素的值放到基本的数据结构(List、Map、Tree)里面,比如一个元素1kB,10亿的数据大概需要900G的内存空间,这个对于普通的服务器来说是承受不了的。
  所以,我们存储这几十亿个元素,不能直接存值,我们应该找到一种最简单的最节省空间的数据结构,用来标记这个元素有没有出现。(比如签到表按顺序打钩)
  这个东西我们就把它叫做位图,是一个有序的数组,只有两个值,0和1。0代表不存在,1代表存在。
JAVA技术体系之分布式篇(四)——Redis缓存_第40张图片
  那我们怎么用这个数组里面的有序的位置来标记这10亿个元素是否存在呢?我们是不是必须要有一个映射方法,把元素映射到一个下标位置上?
对于这个映射方法,我们有几个基本的要求:

  1. 因为我们的值长度是不固定的,我希望不同长度的输入,可以得到固定长度的输出。
  2. 转换成下标的时候,我希望他在我的这个有序数组里面是分布均匀的,不然的话全部挤到一对去了,我也没法判断到底哪个元素存了,哪个元素没存。

  这个就是哈希函数,比如MD5、SHA-1等等这些都是常见的哈希算法。
JAVA技术体系之分布式篇(四)——Redis缓存_第41张图片
  比如,这6个元素,我们经过哈希函数和位运算,得到了相应的下标。

5.4.3 Hash碰撞

  这个时候,Tom和Mic经过计算得到的哈希值是一样的,那么再经过位运算得到的 下标肯定是一样的,我们把这种情况叫做哈希冲突或者哈希碰撞。如果发生了哈希碰撞,这个时候对于我们的容器存值肯定是有影响的,从数据结构和映射方法这两个角度来分析,我们可以通过哪些方式去降低哈希碰撞的概率呢?
  第一种就是扩大维数组的长度或者说位图容量。因为我们的函数是分布均匀的,所以,位图容量越大,在同一个位置发生哈希碰撞的概率就越小。
  那么是不是位图容量越大越好呢?不管存多少个元素,都创建一个几万亿大小的位图, 可以吗?当然不行,因为越大的位图容量,意味着越多的内存消耗,所以我们要创建一 个合适大小的位图容量。
  除了扩大位图容量,我们还有什么降低哈希碰撞概率的方法呢?
  如果两个元素经过一次哈希计算,得到的相同下标的概率比较高,我可以不可以计算多次呢?原来我只用一个哈希函数,现在我对于每一个要存储的元素都用多个哈希函数计算,这样每次计算出来的下标都相同的概率就小得多了。
  同样的,我们能不能引入很多个哈希函数呢?比如都计算100次,都可以吗?当然也会有问题,第一个就是它会填满位图的更多空间,第二个是计算是需要消耗时间的。
  所以总的来说,我们既要节省空间,又要很高的计算效率,就必须在位图容量和函数个数之间找到一个最佳的平衡。
  比如说:我们存放100万个元素,到底需要多大的位图容量,需要多少个哈希函数呢?

5.4.4 布隆过滤器原理

  实际上,这个事情早就有人研究过了,在1970年的时候,有一个叫做布隆的前辈对于判断海量元素中元素是否存在的问题进行了研究,也就是到底需要多大的位图容量和多少个哈希函数,它发表了一篇论文,提出的这个容器就叫做布隆过滤器。我们来看一下布隆过滤器的工作原理。
  首先,布隆过滤器的本质就是我们刚才分析的,一个位数组,和若干个哈希函数。
JAVA技术体系之分布式篇(四)——Redis缓存_第42张图片
  集合里面有3个元素,要把它存到布隆过滤器里面去,应该怎么做?首先是a元素, 这里我们用3次计算。b、c元素也一样。
  元素已经存进去之后,现在我要来判断一个元素在这个容器里面是否存在,就要使用同样的三个函数进行计算。
  比如d元素,我用第一个函数f1计算,发现这个位置上是1,没问题。第二个位置也是1,第三个位置也是1。
  如果经过三次计算得到的下标位置值都是1,这种情况下,能不能确定d元素一定在这个容器里面呢?实际上是不能的。比如这张图里面,这三个位置分别是把a,b,c存进去的时候置成1的,所以即使d元素之前没有存进去,也会得到三个1,判断返回true。
  所以,这个是布隆过滤器的一个很重要的特性,因为哈希碰撞不可避免,所以它会存在一定的误判率。这种把本来不存在布隆过滤器中的元素误判为存在的情况,我们把它叫做假阳性(False Positive Probability, FPP) 。
  我们再来看另一个元素,e元素。我们要判断它在容器里面是否存在,一样地要用这三个函数去计算。第一个位置是1,第二个位置是1,第三个位置是0。
  e元素是不是一定不在这个容器里面呢?可以确定一定不存在。如果说当时已经把 e元素存到布隆过滤器里面去了,那么这三个位置肯定都是1,不可能出现0。
总的来说:

  1. 如果布隆过滤器判断元素在集合中存在,不一定存在。
  2. 如果布隆过滤器判断不存在,一定不存在。

反过来说:

  1. 如果元素实际存在,布隆过滤器一定判断存在。
  2. 如果元素实际不存在,布隆过滤器可能判断存在。

你可能感兴趣的:(分布式篇,java,redis,nosql)