Redis知识点【基础】

Redis 数据结构

String

单值缓存
        SET key value
        GET key
对象缓存
        1) SET user:1 value(json 格式数据 )
        2) MSET user:1:name zhuge user:1:balance 1888  【批量操作】
            MGET user:1:name user:1:balance
分布式锁(入门)
        SETNX product:10001 true // 返回 1 代表获取锁成功
        SETNX product:10001 true // 返回 0 代表获取锁失败
        。。。执行业务操作
        DEL product:10001 // 执行完业务释放锁
        SET product:10001 true ex 10 nx // 防止程序意外终止导致死锁

SETNX(当前仅当key product:10001 不存在才执行成功)

计数器
        INCR article:readcount:{ 文章 id}
        GET article:readcount:{ 文章 id}
Web 集群 session 共享
        spring session + redis 实现 session 共享
分布式系统全局序列号

        【分库分表下要求主键id递增场景下】
        INCRBY orderId 1000 //redis 批量生成序列号提升性能

Hash

应用场景

1.对象缓存  
        HMSET user {userId}:name zhuge {userId}:balance 1888
        HMSET user 1:name zhuge 1:balance 1888
        HMGET user 1:name 1:balance

注意防止大key!!!

如果硬是要hash存储 则需要进行分片。

【双层map】【内层键field】

2.电商购物车
1 )以用户 id 为 key
2 )商品 id 为 field
3 )商品数量为 value

购物车操作
1) 添加商品 hset cart:1001 10088 1
2) 增加数量 hincrby cart:1001 10088 1
3) 商品总数 hlen cart:1001
4) 删除商品 hdel cart:1001 10088
5) 获取购物车所有商品 hgetall cart:1001

优缺点

优点
1 )同类数据归类整合储存,方便数据管理
2 )相比 string 操作消耗内存与 cpu 更小
3 )相比 string 储存更节省空间
缺点
1) 过期功能不能使用在 field 上,只能用在 key 上
2) Redis 集群架构下不适合大规模使用

hash导致某个key放到一个节点上,并没有达到集群部署的平均分配效果,出现数据倾斜

List

常用数据结构

  • Stack( 栈 ) = LPUSH + LPOP
  • Queue( 队列) = LPUSH + RPOP
  • Blocking MQ( 阻塞队列) = LPUSH + BRPOP

BRPOP【指队列中如果没有元素,会一直阻塞住(也可以设置超时时间)】

微博和微信公号消息流

消息流按照时间先后展示【list】

诸葛老师关注了MacTalk,备胎说车等大V
1)MacTalk发微博,消息ID为10018
LPUSH  msg:{诸葛老师-ID}  10018
2)备胎说车发微博,消息ID为10086
LPUSH  msg:{诸葛老师-ID} 10086
3)查看最新微博消息
LRANGE  msg:{诸葛老师-ID}  0  4 

【LRANGE  key  start  stop        //返回列表key中指定区间内的元素,区间以偏移量start和stop指定】

  • 推模式:给每一个粉丝的list推送。【博主粉丝少量,优化:先给在线用户发送】
  • 拉模式:粉丝到博主的list里取【博主粉丝上千万】

Set

应用场景

1.微信抽奖小程序
1)点击参与抽奖加入集合
SADD key {userlD}
2)查看参与抽奖所有用户
SMEMBERS key      
3)抽取count名中奖者

  • SRANDMEMBER  key  [count]            //从集合key中选出count个元素,元素不从key中删除
  • SPOP  key  [count]                //从集合key中选出count个元素,元素从key中删除
     

2.微信微博点赞,收藏,标签
1) 点赞
SADD  like:{消息ID}  {用户ID}
2) 取消点赞
SREM like:{消息ID}  {用户ID}
3) 检查用户是否点过赞
SISMEMBER  like:{消息ID}  {用户ID}
4) 获取点赞的用户列表
SMEMBERS like:{消息ID}
5) 获取点赞用户数 
SCARD like:{消息ID}

集合操作

SINTER set1 set2 set3 =》 { c }  交集
SUNION set1 set2 set3  =》{ a,b,c,d,e }  并集
SDIFF set1 set2 set3  =》{ a }   差集  【以第一个集合(set1)为基准 - 后面集合的并集】

1.集合操作实现微博微信关注模型
1) 诸葛老师关注的人: 
zhugeSet-> {guojia, xushu}
2) 杨过老师关注的人:
 yangguoSet--> {zhuge, baiqi, guojia, xushu}
3) 郭嘉老师关注的人: 
guojiaSet-> {zhuge, yangguo, baiqi, xushu, xunyu)
4) 我和杨过老师共同关注: 
SINTER zhugeSet yangguoSet--> {guojia, xushu}
5) 我关注的人也关注他(杨过老师): 
SISMEMBER guojiaSet yangguo 
SISMEMBER xushuSet yangguo
6) 我可能认识的人: 
SDIFF yangguoSet zhugeSet->(zhuge, baiqi}

2.集合操作实现电商商品筛选
SADD  brand:huawei  P40
SADD  brand:xiaomi  mi-10
SADD  brand:iPhone iphone12
SADD os:android  P40  mi-10
SADD cpu:brand:intel  P40  mi-10
SADD ram:8G  P40  mi-10  iphone12

SINTER  os:android  cpu:brand:intel  ram:8G   {P40,mi-10}

Zset

ZUNIONSTORE destkey numkeys key [key ...]     //并集计算
ZINTERSTORE destkey numkeys key [key …]    //交集计算

1.Zset集合操作实现排行榜
1)点击新闻
ZINCRBY  hotNews:20190819  1  守护香港
2)展示当日排行前十
ZREVRANGE  hotNews:20190819  0  9  WITHSCORES 
3)七日搜索榜单计算
ZUNIONSTORE  hotNews:20190813-20190819  7  hotNews:20190813  hotNews:20190814... hotNews:20190819
4)展示七日排行前十
ZREVRANGE hotNews:20190813-20190819  0  9  WITHSCORES

Redis 高级命令

1.keys:全量遍历键,用来列出所有满足特定正则字符串规则的key,当redis数据量比较大时, 性能比较差,要避免使用

2.scan:渐进式遍历键

SCAN cursor [MATCH pattern] [COUNT count]

scan 参数提供了三个参数,第一个是 cursor 整数值(hash桶的索引值),第二个是 key 的正则模式, 第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。

第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历 到返回的 cursor 值为 0 时结束。

注意:但是scan并非完美无瑕, 如果在scan的过程中如果有键的变化(增加、 删除、 修改) ,那 么遍历效果可能会碰到如下问题: 新增的键可能没有遍历到, 遍历出了重复的键等情况, 也就是说 scan并不能保证完整的遍历出来所有的键, 这些是我们在开发时需要考虑的

Redis是单线程吗?

Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外 提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其 实是由额外的线程执行的。

Redis 持久化

RDB快照(snapshot)

在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 二进制文件中。

如让 Redis 在满足“ 60 秒内有至少有 1000 个键被改动”这一条件时, 自动保存一次数据集:

# save 60 1000 //关闭RDB只需要将所有的save保存策略注释掉即可

还可以手动执行命令生成RDB快照,进入redis客户端执行命令save或bgsave可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件。

bgsave的复制(COW)机制

Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在生成快照的同时,依然可以正常处理写命令。

简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据,那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

save与bgsave对比:

命令

save

bgsave

IO类型

同步

异步

是否阻塞redis其它命令

否(在生成子进程执行调用fork函数时会有短暂阻塞)

复杂度

O(n)

O(n)

优点

不会消耗额外内存

不阻塞客户端命令

缺点

阻塞客户端命令

需要fork子进程,消耗内存

配置自动生成rdb文件后台使用的是bgsave方式。

AOF(append-only file)

快照功能并不是非常耐久(durable): 如果 Redis 因为某些原因而造成故障停机, 那么服务器将丢失最近写入、且仍未保存到快照中的那些数据。

从 1.1 版本开始, Redis 增加了一种完全耐久的持久化方式: AOF 持久化,将修改的每一条指令记录进文件appendonly.aof中(先写入os cache,每隔一段时间fsync到磁盘)

比如执行命令“set zhuge 666”,aof文件里会记录如下数据

*3 $3 set $5 zhuge $3 666 【resp协议】

这是一种resp协议格式数据,星号后面的数字代表命令有多少个参数,$号后面的数字代表这个参数有几个字符

注意,如果执行带过期时间的set命令,aof文件里记录的是并不是执行的原始命令,而是记录key过期的时间戳

比如执行“set tuling 888 ex 1000”,对应aof文件里记录如下

*3 $3 set $6 tuling $3 888 *3 $9 PEXPIREAT $6 tuling $13 1604249786301

你可以通过修改配置文件来打开 AOF 功能:

# appendonly yes

从现在开始, 每当 Redis 执行一个修改数据集的命令时(比如 
SET), 这个命令就会被追加到 AOF 文件的末尾。

这样的话, 当 Redis 重新启动时, 程序就可以通过重新执行 AOF 文件中的命令来达到重建数据集的目的。

你可以配置 Redis 多久才将数据 fsync 到磁盘一次。

有三个选项:

  • appendfsync always:每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。
  • appendfsync everysec:每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。
  • appendfsync no:从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择。

推荐(并且也是默认)的措施为每秒 fsync 一次, 这种 fsync 策略可以兼顾速度和安全性。

AOF重写

AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据生成aof文件

例如,执行了如下几条命令:

127.0.0.1:6379> incr readcount (integer) 

127.0.0.1:6379> incr readcount (integer) 

127.0.0.1:6379> incr readcount (integer) 

127.0.0.1:6379> incr readcount (integer)  

127.0.0.1:6379> incr readcount (integer) 

重写后AOF文件里变成

*3 $3 SET $2 readcount $1 5

如下两个配置可以控制AOF自动重写频率

  • # auto-aof-rewrite-min-size 64mb //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大
  • # auto-aof-rewrite-percentage 100 //aof文件自上一次重写后文件大小增长了100%则再次触发重写

当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF

注意,AOF重写redis会fork出一个子进程去做(与bgsave命令类似),不会对redis正常命令处理有太多影响

RDB 和 AOF 选择

命令

RDB

AOF

启动优先级

体积

恢复速度

数据安全性

容易丢数据

根据策略决定

生产环境可以都启用,redis启动时如果既有rdb文件又有aof文件则优先选择aof文件恢复数据,因为aof一般来说数据更全一点。

混合持久化

【具有不丢数据+重启恢复速度快的特点!!!】

重启 Redis 时,我们很少使用 RDB来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。 Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。

通过如下配置可以开启混合持久化(必须先开启aof):

# aof-use-rdb-preamble yes

如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。

于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,因此重启效率大幅得到提升。

混合持久化AOF文件结构如下

0

 Redis数据备份策略:

  1. 写crontab定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近48小时的备份
  2. 每天都保留一份当日的数据备份到一个目录中去,可以保留最近1个月的备份
  3. 每次copy备份的时候,都把太旧的备份给删了
  4. 每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏

Redis 客户端通信

RESP (REdis Serialization Protocol) 协议,它工作在 TCP 协议的上层,作为Redis和客户端之间进行通讯的标准形式。

*<参数数量> CRLF
$<参数1的字节长度> CRLF
<参数1的数据> CRLF
$<参数2的字节长度> CRLF
<参数2的数据> CRLF
...
$<参数N的字节长度> CRLF
<参数N的数据> CRLF

首先解释一下每行末尾的 CRLF,转换成程序语言就是 \r\n,也就是回车加换行。

相关阅读

Redis:我是如何与客户端进行通信的 - 华为云开发者联盟的个人空间 - OSCHINA - 中文开源技术交流社区

Redis 管道

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

普通命令模式,如下图所示:

管道模式,如下图所示:

使用管道技术可以解决多个命令执行时的网络等待,[极大的降低多条命令执行的网络传输开销]。

它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

  • 但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。
  • 管道中前面命令失败,后面命令不会有影响,继续执行。
  • 要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。

jedis连接示例

Pipeline pl = jedis.pipelined();
for (int i = 0; i < 10; i++) {
    pl.incr("pipelineKey");
    pl.set("zhuge" + i, "zhuge");
    //模拟管道报错
    // pl.setbit("zhuge", -1, true);
}
List<Object> results = pl.syncAndReturnAll();
System.out.println(results);

Redis Lua脚本

Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:

  • 1、减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。
  • 2、原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。
  • 3、替代redis的事务功能:redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务功能,官方推荐如果要使用redis的事务功能可以用redis lua替代。

官网文档上有这样一段话:

A Redis script is transactional by definition, so everything you can do with a Redis transaction, you can also do with a script, and usually the script will be both simpler and faster.

从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。EVAL命令的格式如下:

EVAL script numkeys key [key ...] arg [arg ...]

  • script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一个Lua函数。
  • numkeys参数用于指定键名参数的个数
  • 键名参数 key [key ...] 从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在 Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。

在命令的最后,那些不是键名参数的附加参数 arg [arg ...] ,可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。例如

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

1) "key1"

2) "key2"

3) "first"

4) "second"

其中 "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 是被求值的Lua脚本,数字2指定了键名参数的数量, key1和key2是键名参数,分别使用 KEYS[1] 和 KEYS[2] 访问,而最后的 first 和 second 则是附加参数,可以通过 ARGV[1] 和 ARGV[2] 访问它们。

在 Lua 脚本中,可以使用redis.call()函数来执行Redis命令

jedis.set("product_stock_10016", "15");  //初始化商品10016的库存
String script = " local count = redis.call('get', KEYS[1]) " +
                " local a = tonumber(count) " +
                " local b = tonumber(ARGV[1]) " +
                " if a >= b then " +
                "   redis.call('set', KEYS[1], a-b) " +
                "   return 1 " +
                " end " +
                " return 0 ";
Object obj = 
  jedis.eval(script, Arrays.asList("product_stock_10016"), Arrays.asList("10"));
System.out.println(obj);

注意,不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis。

Redis 缓存

缓存雪崩、击穿、穿透

用户的数据一般都是存储于数据库,数据库的数据是落在磁盘上的,磁盘的读写速度可以说是计算机里最慢的硬件了。

因为 Redis 是内存数据库,我们可以将数据库的数据缓存在 Redis 里,相当于数据缓存在内存,内存的读写速度比硬盘快好几个数量级,这样大大提高了系统性能。

引入了缓存层,就会有缓存异常的三个问题,分别是缓存雪崩、缓存击穿、缓存穿透



缓存雪崩

通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。

那么,当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。

可以看到,发生缓存雪崩有两个原因:

  • 大量数据同时过期;
  • Redis 故障宕机;

大量数据同时过期

针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 均匀设置过期时间;
  • 互斥锁;
  • 后台更新缓存;

1. 均匀设置过期时间

如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。

2. 互斥锁

当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求构建缓存,当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

实现互斥锁的时候,最好设置超时时间。

不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

3. 后台更新缓存

业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。

解决上面的问题的方式有两种:

1.后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。

这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。

2.在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。


Redis 故障宕机

针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 服务熔断或请求限流机制;
  • 构建 Redis 缓存高可靠集群;

1. 服务熔断或请求限流机制

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作

为了减少对业务的影响,我们可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

2. 构建 Redis 缓存高可用集群

服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。



缓存击穿

我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。

应对缓存击穿可以采取前面说到两种方案:

  • 互斥锁方案,保证对于每个 Key 同时只有一个线程去查询后端的服务,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。【不过在高并发场景下,这种解决方案对于分布式锁的访问压力比较大。
  • 不给热点数据设置过期时间【永不过期】,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;


缓存穿透

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

  • 当用户访问的数据,既不在缓存中,也不在数据库中没办法构建缓存数据。
  • 缓存穿透问题在一定程度上与缓存命中率有关。如果我们的缓存设计的不合理,缓存的命中率非常低。

那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

缓存穿透的发生一般有这两种情况:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

应对缓存穿透的方案,常见的方案有三种。

  • 第一种方案,非法请求的限制;
  • 第二种方案,缓存空值或者默认值;
  • 第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在;

第一种方案,非法请求的限制

主要是指参数校验、鉴权校验等。当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

第二种方案,缓存空值或者默认值

如果从缓存取不到的数据,在数据库中也没有取到,那我们仍然把这个空结果进行缓存,同时设置一个较短的过期时间。通过这个设置的默认值存放到缓存,这样第二次到缓存中获取就有值了,而不会继续访问数据库,可以防止有大量恶意请求是反复用同一个 key 进行攻击。

第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。

我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。

即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。


那问题来了,布隆过滤器是如何工作的呢?

可以引入了多个相互独立的哈希函数,保证在给定的空间和误判率下,完成元素判重

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。

布隆过滤器会通过 3 个操作完成标记:

  • 第一步,使用 N 个相互独立的哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。

在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否在数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

  • 布隆过滤器增强版
    增强版是将布隆过滤器的 bitmap 更换成数组,当数组某位置被映射一次时就 + 1, 当删除时就 - 1, 这样就避免了普通布隆过滤器删除数据后需要重新计算其余数据包 Hash 的问题,但是依旧没法避免误判。

  • 布谷鸟过滤器
    但是如果这两个位置都满了,它就不得不「鸠占鹊巢」,随机踢走一个,然后自己霸占了这个位置。不同于布谷鸟的是,布谷鸟哈希算法会帮这些受害者(被挤走的蛋)寻找其它的窝。因为每一个元素都可以放在两个位置,只要任意一个有空位置,就可以塞进去。所以这个伤心的被挤走的蛋会看看自己的另一个位置有没有空,如果空了,自己挪过去也就皆大欢喜了。但是如果这个位置也被别人占了呢?好,那么它会再来一次「鸠占鹊巢」,将受害者的角色转嫁给别人。然后这个新的受害者还会重复这个过程直到所有的蛋都找到了自己的巢为止。

缺点:
如果数组太拥挤了,连续踢来踢去几百次还没有停下来,这时候会严重影响插入效率。这时候布谷鸟哈希会设置一个阈值,当连续占巢行为超出了某个阈值,就认为这个数组已经几乎满了。这时候就需要对它进行扩容,重新放置所有元素。

优化处理

(一)缓存预热

缓存预热就是系统上线前后,将相关的缓存数据直接加载到缓存系统中去,而不依赖用户。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据,这样可以避免那么系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。根据数据不同量级,可以有以下几种做法:

  • 数据量不大:项目启动的时候自动进行加载。
  • 数据量较大:后台定时刷新缓存。
  • 数据量极大:只针对热点数据进行预加载缓存操作。

(二)缓存降级

缓存降级是指当缓存失效或缓存服务出现问题时,为了防止缓存服务故障,导致数据库跟着一起发生雪崩问题,所以也不去访问数据库,但因为一些原因,仍然想要保证服务还是基本可用的,虽然肯定会是有损服务。因此,对于不重要的缓存数据,我们可以采取服务降级策略。一般做法有以下两种:

  • 直接访问内存部分的数据缓存。
  • 直接返回系统设置的默认值

总结

缓存异常会面临的三个问题:缓存雪崩、击穿和穿透。

其中,缓存雪崩和缓存击穿主要原因是数据不在缓存中,而导致大量请求访问了数据库,数据库压力骤增,容易引发一系列连锁反应,导致系统奔溃。不过,一旦数据被重新加载回缓存,应用又可以从缓存快速读取数据,不再继续访问数据库,数据库的压力也会瞬间降下来。因此,缓存雪崩和缓存击穿应对的方案比较类似。

而缓存穿透主要原因是数据既不在缓存也不在数据库中。因此,缓存穿透与缓存雪崩、击穿应对的方案不太一样。

我这里整理了表格,你可以从下面这张表格很好的知道缓存雪崩、击穿和穿透的区别以及应对方案。

Redis 数据一致性问题

1.先更新数据库,还是先更新缓存?

由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题

  • 先更新数据库,再更新缓存;
  • 先更新缓存,再更新数据库;

缓存和数据库的数据不一致的现象,是因为并发问题

先更新数据库,再更新缓存

举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:

先更新缓存,再更新数据库

假设「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:

所以,无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象

​​

2.先更新数据库,还是先删除缓存?

阿旺定位出问题后,思考了一番后,决定在更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

阿旺想的这个策略是有名字的,是叫 Cache Aside 策略,中文是叫旁路缓存策略

该策略又可以细分为「读策略」和「写策略」。

写策略的步骤:

  • 更新数据库中的数据;
  • 删除缓存中的数据。

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

到底该选择哪种顺序呢?

  • 先删除缓存,再更新数据库;
  • 先更新数据库,再删除缓存。

先删除缓存,再更新数据库

假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。

可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题

(这种方法在并发下最容易出现长时间的脏数据,不可取)

先更新数据库,再删除缓存

继续用「读 + 写」请求的并发的场景来分析。

假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。

最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。

从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高

因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作进入数据库操作,而又要于写操作更新缓存,【因为缓存的写入通常要远远快于数据库的写入】所有的这些条件都具备的概率基本并不大。


所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的

而且阿旺为了确保万无一失,还给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致

「先更新数据库, 再删除缓存」其实是两个操作,前面的所有分析都是建立在这两个操作都能同时执行成功,而这次客户投诉的问题就在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值

好在之前给缓存加上了过期时间,所以才会出现客户说的过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。

所以新的问题来了,如何保证「先更新数据库 ,再删除缓存」这两个操作能执行成功?

3.只更新缓存,同步/异步更新数据库

同步

只更新缓存,由缓存自己同步更新数据库(Read/Write Through Pattern)

这种方法相当于是业务只更新缓存,再由缓存去同步更新数据库。 

  • 客户端 1 触发更新数据 A 的逻辑

  • 客户端 2 触发查询数据 A 的逻辑

  • 客户端 1 更新缓存中数据 A,缓存同步更新数据库中数据 A,再返回结果

  • 客户端 2 查询缓存中数据 A,命中返回

Read Through 和 WriteThrough 的流程类似,只是在客户端查询数据 A 时,如果缓存中数据 A 失效了(过期或被驱逐淘汰),则缓存会同步去数据库中查询数据 A,并缓存起来,再返回给客户端

这种方式缓存不一致的概率极低,只不过需要对缓存进行专门的改造。

异步

只更新缓存,由缓存自己异步更新数据库(Write Behind Cache Pattern)

  • 客户端 1 触发更新数据 A 的逻辑

  • 客户端 2 触发查询数据 A 的逻辑

  • 客户端 1 更新缓存中的数据 A,返回

  • 客户端 2 查询缓存中的数据 A,命中返回

  • 缓存异步更新数据 A 到数据库中

这种方式的优势是读写的性能都非常好,基本上只要操作完内存后就返回给客户端了,但是非强一致性,存在丢失数据的情况。

如果在缓存异步将数据更新到数据库中时,缓存服务挂了,此时未更新到数据库中的数据就丢失了。

小结

阿旺的事情就聊到这,我们继续说点其他。

先更新数据库,再删除缓存」:

「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。

更新数据库 + 更新缓存」:

所以,如果我们的业务对缓存命中率有很高的要求,我们可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况

但是这个方案前面我们也分析过,在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。

所以我们得增加一些手段来解决这个问题,这里提供两种做法:

  • 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
  • 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。

先删除缓存,再更新数据库」:

对了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。

延迟双删实现的伪代码如下:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。

所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。

但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

因此,还是比较建议用「先更新数据库,再删除缓存」的方案。


前情回顾

  • 先更新数据库,再删缓存」的策略,这个策略即使在并发读写时,也能最大程度保证数据一致性。
  • 聪明的阿旺还搞了个兜底的方案,就是给缓存加上了过期时间。

在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,而数据库是最新值

好在之前给缓存加上了过期时间,所以才会出现客户说的过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。


如何保证两个操作都能执行成功?

应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。

其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。

问题原因知道了,该怎么解决呢?有两种方法:

  • 重试机制。
  • 订阅 MySQL binlog,再操作缓存。

重试机制【MQ】

根据事件触发 MQ 消息,需要业务服务先保证业务数据已经持久化且需要生产端保证消息投递无丢失

我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据事件触发加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

订阅 MySQL binlog,再操作缓存
  • 这种方式可以保证业务数据已经持久化
  • 且可保证消息投递不丢失

先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过增量订阅消费binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

Redis更新

1)数据操作主要分为两大块:

  • 一个是全量(将全部数据一次写入到redis)
  • 一个是增量(实时更新)

这里说的是增量,指的是mysql的update、insert、delate变更数据。

2)读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据


所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作!!!缓存。

Redis使用

Jedis/Lettuce/Redisson/RedisTemplate

Java操作redis有三种客户端供选择:Jedis、Lettuce、Redisson。

Jedis

优点:
1、Jedis 的 API 提供了比较全面的 Redis 命令的支持
2、Jedis 中的 Java 方法基本和 Redis 的 API 保持着一致,也就是说了解 Redis 的API,可以熟练的使用 Jedis
3、支持 pipelining、事务、LUA Scripting、Redis Sentinel、Redis Cluster等等 redis 提供的高级特性
4、客户端轻量,简洁,便于集成和改造
5、使用广泛,开发人员易上手

缺点:
1、Jedis 使用阻塞的 IO,且其方法调用都是同步的,程序流需要等到 sockets 处理完 IO 才能执行,不支持异步
2、Jedis 在实现上是直接连接的 redis server,如果在多线程环境下是非线程安全的,这个时候可以使用连接池来管理 Jedis,解决 Jedis 客户端实例存在非线程安全的问题(也就是可以通过配置JedisPool来实现基于Jedis的连接池)

通过配置 JedisPool 设置连接池并将JedisPool对象注入到spring容器内,使用时通过 @Autowired 方式注入JedisPool 使用。

3、不支持读写分离,需要自己实现
4、技术文档差,可以说几乎没有


Lettuce

Lettuce 是一种可扩展的、线程安全的 Redis 高级客户端,从 Spring Boot 2.x 开始, Lettuce 已取代 Jedis 成为SpringBoot 默认的 Redis 客户端
优点:
1、相比于 Jedis,Lettuce 属于后起之秀,对 Redis 支持更加全面,并且解决了 Jedis 客户端实例存在非线程安全的问题
2、支持同步编程,异步编程,响应式编程,自动重新连接,主从模式,集群模块,哨兵模式,管道和编码器等等高级的 Redis 特性
3、Lettuce 底层基于 Netty 框架的事件驱动与 redis 通信,采用了非阻塞的 I/O 操作,可异步调用,相比 Jedis,性能高
4、Lettuce 的 API 是线程安全的,如果不是执行阻塞和事务操作,如 BLPOP 和MULTI/EXEC 等命令,多个线程就可以共享一个连接,性能方面差异很小

Netty 是一个基于 NIO 的客户、服务器端的编程框架,使用 Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户、服务端应用。Netty 相当于简化和流线化了网络应用的编程开发过程,例如:基于 TCP 和 UDP 的 socket 服务开发。

Channel 表示一个连接通道,用于承载连接管理及读写操作

EventLoop 则是事件处理的核心抽象。一个 EventLoop 可以服务于多个 Channel,但它只会与单一线程绑定。EventLoop 中所有 I/O 事件和用户任务的处理都在该线程上进行;其中除了选择器 Selector 的事件监听动作外,对连接通道的读写操作均以非阻塞的方式进行 —— 这是 NIO 与 BIO(blocking I/O,即阻塞式 I/O)的重要区别,也是 NIO 模式性能优异的原因。

Lettuce 凭借单一连接就可以支持业务端的大部分并发需求,这依赖于以下几个因素的共同作用:

1.Netty 的单个 EventLoop 仅与单一线程绑定,业务端的并发请求均会被放入 EventLoop 的任务队列中,最终被该线程顺序处理。同时,Lettuce 自身也会维护一个队列,当其通过 EventLoop 向 Redis 发送指令时,成功发送的指令会被放入该队列;当收到服务端的响应时,Lettuce 又会以 FIFO 的方式从队列的头部取出对应的指令,进行后续处理。【redis指令与处理结果如何绑定?】

2.Redis 服务端本身也是基于 NIO 模型,使用单一线程处理客户端请求。虽然 Redis 能同时维持成百上千个客户端连接,但是在某一时刻,某个客户端连接的请求均是被顺序处理及响应的。

3.Redis 客户端与服务端通过 TCP 协议连接,而 TCP 协议本身会保证数据传输的顺序性。

  • 如此,Lettuce 在保证请求处理顺序的基础上,天然地使用了管道模式(pipelining)与 Redis 交互 —— 在多个业务线程并发请求的情况下,客户端不必等待服务端对当前请求的响应,即可在同一个连接上发出下一个请求。这在加速了 Redis 请求处理的同时,也高效地利用了 TCP 连接的全双工特性(full-duplex)【TCP 连接的两端,同一时间里,双方都可以主动向对方发送数据】
  • 而与之相对的,在没有显式指定使用管道模式的情况下,Jedis 只能在处理完某个 Redis 连接上当前请求的响应后,才能继续使用该连接发起下一个请求。

【Redis管道主要用于减少网络延迟和提高吞吐量 使用事务时其他的命令会阻塞 使用管道时其他的命令不会阻塞】

在并发场景下,业务系统短时间内可能会发出大量请求,在管道模式中,这些请求被统一发送至 Redis 服务端,待处理完成后统一返回,能够大大提升业务系统的运行效率,突破性能瓶颈。

R2M 采用了 Redis Cluster 模式,在通过 Lettuce 连接 R2M 之前,应该先对 Redis Cluster 模式有一定的了解。

缺点:
1、API 更加抽象,学习使用成本高
 

Redisson

Redisson 是一个在 Redis 的功能基础上实现的 Java 驻内存数据网格客户端。实现了分布式和可扩展的 Java 数据结构,提供很多分布式相关操作服务,如分布式锁,分布式集合,可通过 Redis 支持延迟队列
优点:
1、实现了分布式特性和可扩展的 Java 数据结构,例如分布式锁,分布式集合,分布式对象,分布式远程调度等等高级功能,适合分布式开发
2、与 Lettuce 一样,基于 Netty 框架的事件驱动与 redis 通信,支持异步调用,性能高
3、Redisson 的 API 是线程安全的,所以可以使用单个 Redisson 连接来完成各种操作
4、支持读写分离,支持读负载均衡,在主从复制和 Redis Cluster 架构下都可以使用
5、内建 Tomcat Session Manager,为 Tomcat 6/7/8 提供了会话共享功能,可以与 Spring Session 集成,实现基于 Redis 的会话共享
6、相比于 Jedis、Lettuce 等基于 redis 命令封装的客户端,Redisson 提供的功能更加高端和抽象,Redisson 可以类比 Spring 框架,这些框架搭建了应用程序的基础框架和功能,可以显著提升开发效率,让开发者有更多的时间来关注业务逻辑
7、文档较丰富,有中文文档

缺点:
1、和 Jedis、Lettuce 客户端相比,基础功能较为简单,对字符串的支持比较差,不支持排序、事务、管道、分区等 Redis 特性
2、API 更加抽象,学习使用成本高
 

RedisTemplate

RedisTemplate是Spring Data Redis框架提供的对Jedis和Lettuce的封装客户端,本质上还是使用Jedis或Lettuce,spring boot1.x的版本默认采用Jedis实现,spring boot2.x的版本默认采用Lettuce实现;可以方便的在Jedis和Lettuce之间切换具体的客户端实现;和日志门面与日志实现框架的关系一样,日志门面统一了操作日志的api,而具体日志的记录交给日志实现框去做,这样在切换日志实现时不用修改日志相关代码;

RedisTemplate性能上不及Jedis,使用RedisTemplate时项目中至少需要有Jedis或Lettuce客户端之一的依赖包,否则会报错,RedisTemplate会自动根据项目中依赖的客户端选择底层使用Jedis还是Lettuce。
 

使用

spring 封装了 RedisTemplate 对象来进行对redis的各种操作,它支持所有的 redis 原生的 api。在RedisTemplate中提供了几个常用的接口方法的使用,分别是:

redisTemplate.opsForValue();//操作字符串
redisTemplate.opsForHash();//操作hash
redisTemplate.opsForList();//操作list
redisTemplate.opsForSet();//操作set
redisTemplate.opsForZSet();//操作有序set

RedisTemplate中定义了对5种数据结构操作

private ValueOperations<K, V> valueOps;
private HashOperations<K, V> hashOps;
private ListOperations<K, V> listOps;
private SetOperations<K, V> setOps;
private ZSetOperations<K, V> zSetOps;

StringRedisTemplate继承自RedisTemplate,也一样拥有上面这些操作。

  • StringRedisTemplate默认采用的是String的序列化策略,保存的key和value都是采用此策略序列化保存的。
  • RedisTemplate默认采用的是JDK的序列化策略,保存的key和value都是采用此策略序列化保存的。

Redis客户端命令对应的RedisTemplate中的方法列表:

String类型结构

Redis

RedisTemplate rt

set key value

rt.opsForValue().set("key","value")

get key

rt.opsForValue().get("key")

del key

rt.delete("key")

strlen key

rt.opsForValue().size("key")

getset key value

rt.opsForValue().getAndSet("key","value")

getrange key start end

rt.opsForValue().get("key",start,end)

append key value

rt.opsForValue().append("key","value")

Hash结构

hmset key field1 value1 field2 value2...

rt.opsForHash().putAll("key",map) //map是一个集合对象

hset key field value

rt.opsForHash().put("key","field","value")

hexists key field

rt.opsForHash().hasKey("key","field")

hgetall key

rt.opsForHash().entries("key")  //返回Map对象

hvals key

rt.opsForHash().values("key") //返回List对象

hkeys key

rt.opsForHash().keys("key") //返回List对象

hmget key field1 field2...

rt.opsForHash().multiGet("key",keyList)

hsetnx key field value

rt.opsForHash().putIfAbsent("key","field","value"

hdel key field1 field2

rt.opsForHash().delete("key","field1","field2")

hget key field

rt.opsForHash().get("key","field")

List结构

lpush list node1 node2 node3...

rt.opsForList().leftPush("list","node") 

rt.opsForList().leftPushAll("list",list) //list是集合对象

rpush list node1 node2 node3...

rt.opsForList().rightPush("list","node") 

rt.opsForList().rightPushAll("list",list) //list是集合对象

lindex key index

rt.opsForList().index("list", index)

llen key

rt.opsForList().size("key")

lpop key

rt.opsForList().leftPop("key")

rpop key

rt.opsForList().rightPop("key")

lpushx list node

rt.opsForList().leftPushIfPresent("list","node")

rpushx list node

rt.opsForList().rightPushIfPresent("list","node")

lrange list start end

rt.opsForList().range("list",start,end)

lrem list count value

rt.opsForList().remove("list",count,"value")

lset key index value

rt.opsForList().set("list",index,"value")

Set结构

sadd key member1 member2...

rt.boundSetOps("key").add("member1","member2",...)

rt.opsForSet().add("key", set) //set是一个集合对象

scard key

rt.opsForSet().size("key")

sidff key1 key2

rt.opsForSet().difference("key1","key2") //返回一个集合对象

sinter key1 key2

rt.opsForSet().intersect("key1","key2")//同上

sunion key1 key2

rt.opsForSet().union("key1","key2")//同上

sdiffstore des key1 key2

rt.opsForSet().differenceAndStore("key1","key2","des")

sinter des key1 key2

rt.opsForSet().intersectAndStore("key1","key2","des")

sunionstore des key1 key2

rt.opsForSet().unionAndStore("key1","key2","des")

sismember key member

rt.opsForSet().isMember("key","member")

smembers key

rt.opsForSet().members("key")

spop key

rt.opsForSet().pop("key")

srandmember key count

rt.opsForSet().randomMember("key",count)

srem key member1 member2...

rt.opsForSet().remove("key","member1","member2",...)

区别

  • Jedis是Redis官方推荐的面向Java的操作Redis的客户端,
  • RedisTemplate,StringRedisTemplate是SpringDataRedis中对JedisApi的高度封装。SpringDataRedis相对于Jedis来说可以方便地更换Redis的Java客户端,比Jedis多了自动管理连接池的特性,方便与其他Spring框架进行搭配使用如:SpringCache

RedisTemplate,StringRedisTemplate之间的区别:

  • 两者的数据是不共通的;StringRedisTemplate只能管理StringRedisTemplate里面的数据,RedisTemplate只能管理RedisTemplate中的数据
  • 序列化类;RedisTemplate使用的是JdkSerializationRedisSerializer 存入数据会将数据先序列化成字节数组然后在存入Redis数据库。StringRedisTemplate使用的是StringRedisSerializer

使用时注意事项:

  • 当你的redis数据库里面本来存的是字符串数据或者你要存取的数据就是字符串类型数据的时候,那么你就使用StringRedisTemplate即可。
  • 但是如果你的数据是复杂的对象类型,而取出的时候又不想做任何的数据转换,直接从Redis里面取出一个对象,那么使用RedisTemplate是更好的选择。

总结

  • 如果项目中对分布式功能的需求场景不多,推荐使用 Lettuce或Jedis
  • 如果项目中除了对基本的数据缓存操作需求以外,还需要用到分布式锁等功能,推荐采用Lettuce + Redisson组合方式使用(使用Lettuce弥补Redisson对于基础功能支持的不足,为了方便切换Jedis和Lettuce可以通过RedisTemplate来使用Jedis或Lettuce)。

文章来源: 

史上最全的RedisTemplate和StringRedisTemplate以及Jedis的入门使用讲解_stringredistemplate和jedis-CSDN博客

spring boot整合常用redis客户端(Jedis、Lettuce、RedisTemplate、Redisson)常见场景解决方案_spring data redis lettuce redission-CSDN博客

Redis 高可用

Redis 主从架构

配置

redis主从架构搭建,配置从节点步骤

1、复制一份redis.conf文件

2、将相关配置修改为如下值:
port 6380
pidfile /var/run/redis_6380.pid  # 把pid进程号写入pidfile配置的文件
logfile "6380.log"
dir /usr/local/redis-5.0.3/data/6380  # 指定数据存放目录

daemonize yes   #后台启动
protected-mode no #关闭保护模式,开启的话只有本机才能访问

# 需要注释掉bind!!!!!!!!!

# bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,
代表允许客户端通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可)

3、配置主从复制
replicaof 192.168.0.60 6379   # 从本机6379的redis实例复制数据,Redis 5.0之前使用slaveof
replica-read-only yes  # 配置从节点只读

4、启动从节点
redis-server redis.conf   # redis.conf文件务必用你复制并修改了之后的redis.conf文件

5、连接从节点
redis-cli -p 6380

6、测试在6379实例上写数据,6380实例是否能及时同步新修改数据

7、可以自己再配置一个6381的从节点

工作原理

如果你为master配置了一个slave,不管这个slave是否是第一次连接上Master,它都会发送一个PSYNC命令给master请求复制数据。

  • master收到PSYNC命令后,会在后台进行数据持久化通过bgsave生成最新的rdb快照文件。
  • 持久化期间,master会继续接收客户端的请求,它会把这些可能修改数据集的请求缓存在内存中。
  • 当持久化进行完毕以后,master会把这份rdb文件数据集发送给slave,slave会把接收到的数据进行持久化生成rdb,然后再加载到内存中。然后,master再将之前缓存在内存中的命令发送给slave。

==》即 主节点bgsave成rdb+命令缓存后再发送

当master与slave之间的连接由于某些原因而断开时,slave能够自动重连Master,如果master收到了多个slave并发连接请求,它只会进行一次持久化,而不是一个连接一次,然后再把这一份持久化的数据发送给多个并发连接的slave。

主从复制(全量复制)流程图

数据部分复制

当master和slave断开重连后,一般都会对整份数据进行复制。但从redis2.8版本开始,redis改用可以支持部分数据复制的命令PSYNC去master同步数据,slave与master能够在网络连接断开重连后只进行部分数据复制(断点续传)。

master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master和它所有的slave都维护了复制的数据下标offsetmaster的进程id

因此,当网络连接断开后,slave会请求master继续进行未完成的复制,从所记录的数据下标开始。

==>可以理解为增量复制

缓存队列+维护下标offset

如果master进程id变化了,或者从节点数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制。

主从复制(部分复制,断点续传)流程图:

主从复制风暴 

如果有很多从节点,为了缓解主从复制风暴(多个从节点同时复制主节点导致主节点压力过大),可以做如下架构,让部分从节点与从节点(与主节点同步)同步数据

0

Jedis连接代码示例

引入相关依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>
public class JedisSingleTest {
    public static void main(String[] args) throws IOException {

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPoolConfig.setMinIdle(5);

        // timeout,这里既是连接超时又是读写超时,从Jedis 2.8开始有区分connectionTimeout和soTimeout的构造函数
        JedisPool jedisPool = new JedisPool(jedisPoolConfig, "192.168.0.60", 6379, 3000, null);

        Jedis jedis = null;
        try {
            //从redis连接池里拿出一个连接执行命令
            jedis = jedisPool.getResource();

            System.out.println(jedis.set("single", "zhuge"));
            System.out.println(jedis.get("single"));

            //管道示例
            //管道的命令执行方式:cat redis.txt | redis-cli -h 127.0.0.1 -a password - p 6379 --pipe
            /*Pipeline pl = jedis.pipelined();
            for (int i = 0; i < 10; i++) {
                pl.incr("pipelineKey");
                pl.set("zhuge" + i, "zhuge");
            }
            List<Object> results = pl.syncAndReturnAll();
            System.out.println(results);*/

            //lua脚本模拟一个商品减库存的原子操作
            //lua脚本命令执行方式:redis-cli --eval /tmp/test.lua , 10
            /*jedis.set("product_count_10016", "15");  //初始化商品10016的库存
            String script = " local count = redis.call('get', KEYS[1]) " +
                            " local a = tonumber(count) " +
                            " local b = tonumber(ARGV[1]) " +
                            " if a >= b then " +
                            "   redis.call('set', KEYS[1], a-b) " +
                            "   return 1 " +
                            " end " +
                            " return 0 ";
            Object obj = jedis.eval(script, Arrays.asList("product_count_10016"), Arrays.asList("10"));
            System.out.println(obj);*/

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。
            if (jedis != null)
                jedis.close();
        }
    }
}

Redis 哨兵高可用架构

0

sentinel哨兵是特殊的redis服务(也是redis节点),不提供读写服务,主要用来监控redis实例节点

  • 哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的节点,不会每次都通过sentinel代理访问redis的主节点。
  • 当redis的主节点发生变化,哨兵会第一时间感知到,并且将新的redis主节点通知给client端(这里面redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息)

缺点

  • 哨兵的配置略微复杂,并且性能和高可用性等各方面表现一般。特别是在主从切换的瞬间存在访问瞬断的情况。
  • 哨兵模式只有一个主节点对外提供服务,没法支持 很高的并发
  • 单个主节点内存也不宜设置得过大,否则会导致持久化文件过大,影响数据恢复或主从同步的 效率

redis哨兵架构搭建步骤

1、复制一份sentinel.conf文件
cp sentinel.conf sentinel-26379.conf

2、将相关配置修改为如下值:
port 26379
daemonize yes   #后台启动

pidfile "/var/run/redis-sentinel-26379.pid"
logfile "26379.log"
dir "/usr/local/redis-5.0.3/data"
# sentinel monitor <master-redis-name> <master-redis-ip> <master-redis-port> <quorum>
# quorum是一个数字,指明当有多少个sentinel认为一个master失效时(值一般为:sentinel总数/2 + 1),master才算真正失效
sentinel monitor mymaster 192.168.0.60 6379 2   # mymaster这个名字随便取,客户端访问时会用到

3、启动sentinel哨兵实例
src/redis-sentinel sentinel-26379.conf

4、查看sentinel的info信息
src/redis-cli -p 26379
127.0.0.1:26379>info
可以看到Sentinel的info里已经识别出了redis的主从

5、可以自己再配置两个sentinel,端口26380和26381,注意上述配置文件里的对应数字都要修改

sentinel集群都启动完毕后,会将哨兵集群的元数据信息写入所有sentinel的配置文件里去(追加在文件的最下面),我们查看下如下配置文件sentinel-26379.conf,如下所示:

sentinel known-replica mymaster 192.168.0.60 6380 #代表redis主节点的从节点信息
sentinel known-replica mymaster 192.168.0.60 6381 #代表redis主节点的从节点信息
sentinel known-sentinel mymaster 192.168.0.60 26380 52d0a5d70c1f90475b4fc03b6ce7c3c56935760f  #代表感知到的其它哨兵节点
sentinel known-sentinel mymaster 192.168.0.60 26381 e9f530d3882f8043f76ebb8e1686438ba8bd5ca6  #代表感知到的其它哨兵节点

当redis主节点如果挂了,哨兵集群会重新选举出新的redis主节点,同时会修改所有sentinel节点配置文件的集群元数据信息。

比如6379的redis如果挂了,假设选举出的新主节点是6380,则sentinel文件里的集群元数据信息会变成如下所示:

sentinel known-replica mymaster 192.168.0.60 6379 #代表主节点的从节点信息
sentinel known-replica mymaster 192.168.0.60 6381 #代表主节点的从节点信息
sentinel known-sentinel mymaster 192.168.0.60 26380 52d0a5d70c1f90475b4fc03b6ce7c3c56935760f  #代表感知到的其它哨兵节点
sentinel known-sentinel mymaster 192.168.0.60 26381 e9f530d3882f8043f76ebb8e1686438ba8bd5ca6  #代表感知到的其它哨兵节点

同时还会修改sentinel文件里之前配置的mymaster对应的6379端口,改为6380

sentinel monitor mymaster 192.168.0.60 6380 2

当6379的redis实例再次启动时,哨兵集群根据集群元数据信息就可以将6379端口的redis节点作为从节点加入集群

哨兵的Jedis连接代码【普通】

public class JedisSentinelTest {
    public static void main(String[] args) throws IOException {

        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(20);
        config.setMaxIdle(10);
        config.setMinIdle(5);

        String masterName = "mymaster";
        Set<String> sentinels = new HashSet<String>();
        sentinels.add(new HostAndPort("192.168.65.60",26379).toString());
        sentinels.add(new HostAndPort("192.168.65.60",26380).toString());
        sentinels.add(new HostAndPort("192.168.65.60",26381).toString());
        //JedisSentinelPool其实本质跟JedisPool类似,都是与redis主节点建立的连接池
        //JedisSentinelPool并不是说与sentinel建立的连接池,而是通过sentinel发现redis主节点并与其建立连接
        JedisSentinelPool jedisSentinelPool = new JedisSentinelPool(masterName, sentinels, config, 3000, null);
        Jedis jedis = null;
        try {
            jedis = jedisSentinelPool.getResource();
            System.out.println(jedis.set("sentinel666", "zhuge"));
            System.out.println(jedis.get("sentinel666"));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //注意这里不是关闭连接,在JedisPool模式下,Jedis会被归还给资源池。
            if (jedis != null)
                jedis.close();
        }
    }
}

哨兵Spring Boot整合Redis

连接代码见示例项目:redis-sentinel-cluster

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-pool2</artifactId>
</dependency>
server:
  port: 8080

spring:
  redis:
    database: 0
    timeout: 3000
    sentinel:    #哨兵模式
      master: mymaster #主服务器所在集群名称
     nodes: 192.168.0.60:26379,192.168.0.60:26380,192.168.0.60:26381
   lettuce:
      pool:
        max-idle: 50
        min-idle: 10
        max-active: 100
        max-wait: 1000
@RestController
public class IndexController {

    private static final Logger logger = LoggerFactory.getLogger(IndexController.class);

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 测试节点挂了哨兵重新选举新的master节点,客户端是否能动态感知到
     * 新的master选举出来后,哨兵会把消息发布出去,客户端实际上是实现了一个消息监听机制,
     * 当哨兵把新master的消息发布出去,客户端会立马感知到新master的信息,从而动态切换访问的masterip
     *
     * @throws InterruptedException
     */
    @RequestMapping("/test_sentinel")
    public void testSentinel() throws InterruptedException {
        int i = 1;
        while (true){
            try {
                stringRedisTemplate.opsForValue().set("zhuge"+i, i+"");
                System.out.println("设置key:"+ "zhuge" + i);
                i++;
                Thread.sleep(1000);
            }catch (Exception e){
                logger.error("错误:", e);
            }
        }
    }
}

Redis 高可用集群模式

  • redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性
  • Redis集群不需要sentinel哨兵·也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。
  • redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单 

Redis 高可用集群搭建

redis集群需要至少三个master节点。

我们这里搭建三个master节点,并且给每个master再搭建一个slave节点,总共6个redis节点,这里用三台机器部署6个redis实例,每台机器一主一从,搭建集群的步骤如下:

第一步:在第一台机器的/usr/local下创建文件夹redis-cluster,然后在其下面分别创建2个文件夾如下
(1)mkdir -p /usr/local/redis-cluster
(2)mkdir 8001 8004

第一步:把之前的redis.conf配置文件copy到8001下,修改如下内容:
(1)daemonize yes
(2)port 8001(分别对每个机器的端口号进行设置)
(3)pidfile /var/run/redis_8001.pid  # 把pid进程号写入pidfile配置的文件
(4)dir /usr/local/redis-cluster/8001/(指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据)
(5)cluster-enabled yes(启动集群模式)
(6)cluster-config-file nodes-8001.conf(集群节点信息文件,这里800x最好和port对应上)
(7)cluster-node-timeout 10000
 (8)# bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可)
 (9)protected-mode  no   (关闭保护模式)
 (10)appendonly yes
如果要设置密码需要增加如下配置:
 (11)requirepass zhuge     (设置redis访问密码)
 (12)masterauth zhuge      (设置集群节点间访问密码,跟上面一致)

第三步:把修改后的配置文件,copy到8004,修改第2、3、4、6项里的端口号,可以用批量替换:
:%s/源字符串/目的字符串/g 

第四步:另外两台机器也需要做上面几步操作,第二台机器用8002和8005,第三台机器用8003和8006

第五步:分别启动6个redis实例,然后检查是否启动成功
(1)/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/800*/redis.conf
(2)ps -ef | grep redis 查看是否启动成功
    
第六步:用redis-cli创建整个redis集群(redis5以前的版本集群是依靠ruby脚本redis-trib.rb实现)
# 下面命令里的1代表为每个创建的主服务器节点创建一个从服务器节点
# 
执行这条命令需要确认三台机器之间的redis实例要能相互访问,
可以先简单把所有机器防火墙关掉,如果不关闭防火墙则需要打开redis服务端口
和集群节点gossip通信端口16379(默认是在redis端口号上加1W)
# 关闭防火墙
# systemctl stop firewalld # 临时关闭防火墙
# systemctl disable firewalld # 禁止开机启动
# 注意:下面这条创建集群的命令大家不要直接复制,里面的空格编码可能有问题导致创建集群不成功
(1)/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster create --cluster-replicas 1 192.168.0.61:8001 192.168.0.62:8002 192.168.0.63:8003 192.168.0.61:8004 192.168.0.62:8005 192.168.0.63:8006 

第七步:验证集群:
(1)连接任意一个客户端即可:./redis-cli -c -h -p (-a访问服务端密码,-c表示集群模式,指定ip地址和端口号)
    如:/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 800*
(2)进行验证: cluster info(查看集群信息)、cluster nodes(查看节点列表)
(3)进行数据操作验证
(4)关闭集群则需要逐个进行关闭,使用命令:
/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.60 -p 800* shutdown
  • 注意:
  • 1.创建整个redis集群,redis-cli -a zhuge --cluster create  【用create命令】
  • 2.--cluster-replicas 1  【1代表给每个集群的主节点但配一个副本slave】
  • 3.创建完create 后,后续关机不需要再重新创建。直接启动即可
  • 4.创建的集群主副一般不在同一台机器上!!!!
  • 5.关闭防火墙
  • 6.验证: cluster info(查看集群信息)、cluster nodes(查看节点列表)

Redis 集群原理分析

  • Redis Cluster 将所有数据划分为 16384 个 slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。
  • 当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端要查找某个 key 时,可以直接定位到目标节点
  • 同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制【跳转重定位】来实现槽位信息的校验调整。

槽位定位算法

Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位

HASH_SLOT = CRC16(key) mod 16384

查看jedisCluster.set方法:

new JedisClusterCommand<String>(connectionHandler, maxAttempts) {
  @Override
  public String execute(Jedis connection) {
    return connection.set(key, value);
  }
}.run(key);
  • JedisClusterCommand匿名内部类(实现execute),execute为抽象方法
  • run方法最后调用了runWithRetries给connection赋值
  • connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));

跳转重定位
  • 当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理。
  • 这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。
  • 客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有 key 将使用新的槽位映射表

集群节点间通信机制

redis cluster节点间采取gossip协议进行通信 

维护集群的元数据(集群节点信息,主从角色,节点数量,各节点共享的数据等)有两种方式:集中式和gossip 

集中式

  • 优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取的时候立即就可以立即感知到;
  • 不足在于所有的元数据的更新压力全部集中在一个地方,可能导致元数据的存储压力。 

很多中间件都会借助zookeeper集中式存储元数据。

gossip

gossip协议包含多种消息,包括ping,pong,meet,fail等等。 

  • meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信;
  • ping:每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据(类似自己感知到的集群节点增加和移除,hash slot信息等); 
  • pong: 对ping和meet消息的返回,包含自己的状态和其他信息,也可以用于信息广播和更新; 
  • fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了。
  • gossip协议的优点在于元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力;
  • 缺点在于元数据更新有延时可能导致集群的一些操作会有一些滞后。

gossip通信的10000端口 

  • 每个节点都有一个专门用于节点间gossip通信的端口,就是自己提供服务的端口号+10000。
  • 比如7001,那么用于节点间通信的就是17001端口。 每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他几点接收到ping消息之后返回pong消息。

网络抖动

真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。

为解决这种问题,Redis Cluster 提供了一种选项cluster-node-timeout(5000-5秒),表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。

如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。

Redis集群选举原理分析

当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收集master返回的FAILOVER_AUTH_ACK
  • 5.slave收到超过半数master(主节点ack!!!)的ack后变成新Master(这里解释了集群为什么至少需要三个主节点,如果只有两 个,当其中一个挂了,只剩一个主节点是不能选举成功的)
  • 6.slave广播Pong消息通知其他集群节点。

从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟(防止票数相同),一定的延迟确保我们等待 FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票

延迟计算公式: DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms

SLAVE_RANK表示此slave已经从master复制数据的总量的rank。Rank越小代表已复制的数据越新。这种方 式下,持有最新数据的slave将会首先发起选举(理论上)。

集群脑裂数据丢失问题

redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个节点对外提供写服务,一旦网络分区恢复, 会将其中一个主节点变为从节点,这时会有大量数据丢失。

规避方法可以在redis配置里加上参数(这种方法不可能百分百避免数据丢失,参考集群leader选举机制): 

  • min‐replicas‐to‐write 1 //写数据成功最少同步的slave数量,这个数量可以模仿大于半数机制配置,比如 集群总共三个节点可以配置1,加上leader就是2,超过了半数
  • 注意:这个配置在一定程度上会影响集群的可用性【保证了一致性】,比如slave要是少于1个【都挂掉了】,这个集群就算leader正常也不能 提供服务了,需要具体场景权衡选择。

为什么脑裂?

脑裂解决
1	#写数据成功最少同步的slave数量,这个数量可以模仿⼤于半数机制配置,
2	#⽐如集群总共三个节点可以配置  1,加上leader就是2,超过了半数
3	min-replicas-to-write 2

这个配置在⼀定程度上会影响集群的可⽤性,⽐如slave要是少于1个,这个集群就算leader正常也不能提供服务了,需要具体场景权衡选择。

集群是否完整才能对外提供服务

当redis.conf的配置cluster-require-full-coverage为no时,表示当负责一个插槽的主库下线【即一个小集群的主库down】且没有相应的从 库进行故障恢复时,集群仍然可用,如果为yes则集群不可用。

Redis集群对批量操作命令的支持

对于类似mset,mget这样的多个key的原生批量操作命令,redis集群只支持所有key落在同一slot的情况。

如果有多个key一定要用mset命令在redis集群上操作,则可以在key的前面加上{XX},这样参数数据分片hash计 算的只会是大括号里的值,这样能确保不同的key能落到同一slot里去。

示例如下:

mset {user1}:1:name zhuge {user1}:1:age 18

假设name和age计算的hash slot值不一样,但是这条命令在集群下执行,redis只会用大括号里的 user1 做 hash slot计算,所以算出来的slot值肯定相同,最后都能落在同一slot。

哨兵leader选举流程

0

当一个master服务器被某sentinel视为下线状态后,该sentinel会与其他sentinel协商选出sentinel的leader进 行故障转移工作。

每个发现master服务器进入下线的sentinel都可以要求其他sentinel选自己为sentinel的 leader,选举是先到先得。同时每个sentinel每次选举都会自增配置纪元(选举周期),每个纪元中只会选择一 个sentinel的leader。如果所有超过一半的sentinel选举某sentinel作为leader。

之后该sentinel进行故障转移 操作,从存活的slave中选举出新的master,这个选举过程跟集群的master选举很类似。

  • 哨兵集群只有一个哨兵节点,redis的主从也能正常运行以及选举master,如果master挂了,那唯一的那个哨 兵节点就是哨兵leader了,可以正常选举新master。
  • 不过为了高可用一般都推荐至少部署三个哨兵节点。为什么推荐奇数个哨兵节点原理跟集群奇数个master节点 类似。

Java操作redis集群【普通】

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>
public class JedisClusterTest {
    public static void main(String[] args) throws IOException {

        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(20);
        config.setMaxIdle(10);
        config.setMinIdle(5);

        Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
        jedisClusterNode.add(new HostAndPort("192.168.0.61", 8001));
        jedisClusterNode.add(new HostAndPort("192.168.0.62", 8002));
        jedisClusterNode.add(new HostAndPort("192.168.0.63", 8003));
        jedisClusterNode.add(new HostAndPort("192.168.0.61", 8004));
        jedisClusterNode.add(new HostAndPort("192.168.0.62", 8005));
        jedisClusterNode.add(new HostAndPort("192.168.0.63", 8006));

        JedisCluster jedisCluster = null;
        try {
            //connectionTimeout:指的是连接一个url的连接等待时间
            //soTimeout:指的是连接上一个url,获取response的返回等待时间
            jedisCluster = new JedisCluster(jedisClusterNode, 6000, 5000, 10, "zhuge", config);
            System.out.println(jedisCluster.set("cluster", "zhuge"));
            System.out.println(jedisCluster.get("cluster"));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedisCluster != null)
                jedisCluster.close();
        }
    }
}

Spring Boot整合Redis集群

连接代码见示例项目:redis-sentinel-cluster

1、引入相关依赖:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-pool2</artifactId>
</dependency>

springboot项目核心配置:

server:
  port: 8080

spring:
  redis:
    database: 0
    timeout: 3000
    password: zhuge
    cluster:
      nodes: 192.168.0.61:8001,192.168.0.62:8002,192.168.0.63:8003,192.168.0.61:8004,192.168.0.62:8005,192.168.0.63:8006
   lettuce:
      pool:
        max-idle: 50
        min-idle: 10
        max-active: 100
        max-wait: 1000
@RestController
public class IndexController {

    private static final Logger logger = LoggerFactory.getLogger(IndexController.class);

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/test_cluster")
    public void testCluster() throws InterruptedException {
       stringRedisTemplate.opsForValue().set("zhuge", "666");
       System.out.println(stringRedisTemplate.opsForValue().get("zhuge"));
    }
}

Redis 集群水平扩展

有道云笔记 (youdao.com)

  • 1.create:创建一个集群环境host1:port1 ... hostN:portN
  • 2.call:可以执行redis命令
  • 3.add-node:将一个节点添加到集群里,第一个参数为新节点的ip:port,第二个参数为集群中任意一个已经存在的节点的ip:port
  • 4.del-node:移除一个节点
  • 5.reshard:重新分片
  • 6.check:检查集群状态

注意:

  • 1.每次add-node节点都会成为master节点
  • 2.当添加节点成功以后,新增的节点不会有任何数据,因为它还没有分配任何的slot(hash槽),我们需要为新节点手工分配hash槽【reshard
  • 3.配置新节点为从节点:cluster replicate 2728a594a0498e98e4b83a537e19f9a0a3790f38 #后面这串id为8007的节点id

总结部署

redis 的部署方式主要有单机、主从、哨兵和 cluster 模式。

  • 单机
    只有一台机器,所有数据都存在这台机器上,当机器出现异常时,redis 将失效,可能会导致 redis 缓存雪崩。

  • 主从
    主从其实就是一台机器做主,一个或多个机器做从,从节点从主节点复制数据,可以实现读写分离,主节点做写,从节点做读。
    优点:当某个从节点异常时,不影响使用。
    缺点:当主节点异常时,服务将不可用。

  • 哨兵
    哨兵模式也是一种主从,只不过增加了哨兵的功能,用于监控主节点的状态,当主节点宕机之后会进行投票在从节点中重新选出主节点。
    优点:高可用,当主节点异常时,自动在从节点当中选择一个主节点。
    缺点:只有一个主节点,当数据比较多时,主节点压力会很大。

  • cluster 模式
    集群采用了多主多从,按照一定的规则进行分片,将数据分别存储,一定程度上解决了哨兵模式下单机存储有限的问题。
    优点:高可用,配置了多主多从,可以使数据分区,去中心化,减小了单台机子的负担.
    缺点:机器资源使用比较多,配置复杂。

  • 小结
    从高可用得角度考虑,使用哨兵模式和 cluster 模式可以防止因为 redis 不可用导致的缓存雪崩问题。

数据倾斜

redis 数据倾斜分为数据访问倾斜数据量倾斜,会导致该 Key 所在的数据分片节点 CPU 使用率、带宽使用率升高,从而影响该分片上所有 Key 的处理。

  • 数据访问倾斜:某节点中 key 的 QPS 高于其他节点中的 Key
  • 数据量倾斜:某节点中 key 的大小高于其他节点中的 Key,如下图,实例 1 中的 Key1 存储高于其他实例。

参考:

浅析Redis大Key | 京东云技术团队 - 京东云开发者的个人空间 - OSCHINA - 中文开源技术交流社区

  • 24
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值