三万字讲透Redis

redis经常被用来做缓存:
在这里插入图片描述

读写策略:

Cache Aside Pattern(旁路缓存模式)
Read/Write Through Pattern(读写穿透模式)
Write Behind Pattern(异步缓存写入)

高并发:
一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 redis 的情况,redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

所以,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高的系统整体的并发。

更新操作是先删除缓存还是先更新数据库?

这是一个两难的问题,但anyway要设置过期时间……

1、先删除缓存

如果是先删除缓存,在高并发的情况下,此时一个读请求过来,先走缓存,发现缓存中没有数据,从数据库中读取,此时读取的是旧数据(未更新前的数据),读取成功后又把旧数据set进缓存,然后数据库更新成功,但是缓存中仍然是旧数据,而且后续读请求过来,缓存能够命中,但缓存中都是旧数据………………此时的脏数据对系统影响较大

如何解决:
延时双删:

  1. 先删除缓存
  2. 此时可能会有其他请求过来…………更新数据库………………
  3. time.sleep(XXX)
  4. 再次删除缓存

此方案仍然有缺点,延时多长时间呢?这是一个玄学,需要根据业务来。第二次删除失败了怎么办?有什么补偿机制?

2、先更新数据库

如果是先更新数据库,在更新数据库到更新或者删除缓存的这一段时间内,如果有请求过来,在缓存中是取到的旧数据,这是其一。其二,在更新数据库成功,但是更新缓存时失败了—>意味着缓存中仍然是旧数据(脏数据),后续请求从缓存中读取的仍然是脏数据。其三,概率不高但理论仍然存在,一个请求先是走到缓存,但此时不巧因为种种原因(eg.网络问题,key的过期时间到了……)在缓存中就是没拿到数据,那么线程2就会从数据库中获取,假如此时拿到的是数据库更新之前的数据1,然后线程2把数据库把数据更新成2了,数据库更新成2之后线程2就会去更新或者删除缓存,线程2操作之后线程1开始操作,将缓存数据更新成1……但此时数据库中真实的数据是2呀

如何解决: 订阅MySQL Binlog + cananl + 消息队列 cananl是阿里的一个开源软件,可以伪装成一个MySQL的slave,然后向MySQL的master发送dump请求,来MySQL此时会觉得cananl是一个salve就会向其推送BinLog日志,cannel接收到MySQL的日志后会将日志转换成便于读取的数据,然后这个数据就可以放到mq中进行异步消费。这种方案可以解决的问题是:删除失败的问题(消费失败可以重试)、异步问题……

但此方案提高了系统复杂度……


具有缓存功能的中间件:Redis、Memcache、Tair(阿里 、美团)等等

Memcache和Redis区别

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略
  3. 两者的性能都非常高。

区别

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
  3. Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
  4. Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的.
  5. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO )

Redis简介

Redis 就是一个使用 C 语言开发的数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的 ,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向。
另外,Redis 除了做缓存之外,Redis 也经常用来做分布式锁,甚至是消息队列。
Redis 提供了多种数据类型来支持不同的业务场景。Redis 还支持事务 、持久化、Lua 脚本、多种集群方案。

Redis应用场景

  • 缓存使用,减轻DB压力
  • DB使用,用于临时存储数据(字典表,购买记录)
  • 解决分布式场景下Session分离问题(登录信息)
  • 任务队列(秒杀、抢红包等等) 乐观锁
  • 应用排行榜 zset
  • 签到 bitmap
  • 分布式锁
  • 冷热数据交换

Redis安装

第一步:安装 C 语言需要的 GCC 环境

yum install -y gcc-c++
yum install -y wget

第二步:下载并解压缩 Redis 源码压缩包

wget https://download.redis.io/releases/redis-6.2.4.tar.gz
mkdir /usr/local/redis
tar -zxvf redis-6.2.4.tar.gz -C /usr/local/redis

第三步:编译 Redis 源码,进入 redis-6.2.4 目录,执行编译命令,进行安装

cd /usr/local/redis/redis-6.2.4/src
make && make install

启动:

前端启动
  • cd /usr/local/redis/redis-6.2.4/src
  • 启动命令: ./redis-server ,直接运行 bin/redis-server 将以前端模式启动
  • 关闭命令: ctrl+c
  • 启动缺点:客户端窗口关闭则 redis-server 程序结束,不推荐使用此方法
后端启动(守护进程启动)
  • 第一步:拷贝 redis-6.2.4/redis.conf 配置文件到 Redis 安装目录的 bin 目录
  • cp redis.conf /usr/local/redis
  • 第二步:修改 redis.conf vim redis.conf
  • 修改daemonize no —> daemonize yes,目的是为了让redis启动在linux后台运行
  • 修改redis的工作目录: dir /usr/local/redis/working
  • 注释bind 127.0.0.1
  • 关闭保护模式,这样外网可以访问到 protected-mode no
  • 第四步:启动服务 cd /usr/local/redis/redis-6.2.4/src
  • ./redis-server /usr/local/redis/redis.conf
  • 后端启动的关闭方式 ./redis-cli shutdown

Redis命令行客户端

.redis-cli -h 127.0.0.1 -p 6379
-h:redis服务器的ip地址
-p:redis实例的端口号

默认方式:如果不指定主机和端口也可以 默认主机地址是127.0.0.1 默认端口是6379
.redis-cli


数据类型选择&应用场景

key名设计
可读性和可管理性
以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如 业务名:表名:id
简洁性
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视
不要包含特殊字符

反例:包含空格、换行、单双引号以及其他转义字符

避免bigkey

  • 情况一:键值对的值大小本身就很大,例如value为1MB的String数据类型。为了避免String类型的bigKey,在业务层,我们要尽量把String类型的大小控制在10KB以下。
  • 情况二:键值对的值是集合类型,集合元素个数非常多,例如包含100万个元素的Hash集合类型数据。为了避免集合类型的bigkey,对应的设计规范是,尽量把集合类型的元素个数控制在1万以下。此时可以这样: user1-1000 获取的时候先对key做一个取模运算,看这个key落在那个group中。

string字符串类型

  1. 介绍 :string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。
  2. 常用命令:set,get,strlen,exists,decr,incr,setex 等等。
  3. 应用场景 :一般常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。
  • 单值缓存
  • SET key value
  • GET key

对象缓存
MSET user:1:name zimu user:1:balance 1888
MGET user:1:name user:1:balance
分布式锁(「SET if Not eXists」)
SETNX product:10001 true // 返回1代表获取锁成功
SETNX product:10001 false // 返回0代表获取锁失败
…执行业务操作
DEL product:10001 // 执行完业务 释放锁
SET product:10001 true ex 10 nx // 防止程序意外终止导致死锁

  • 计数器

INCR article:readcount:101
在这里插入图片描述

hash类型(散列表)

在这里插入图片描述

  1. 介绍 :hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 hash 做了更多优化。另外,hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。
  2. 常用命令:hset,hmset,hexists,hget,hgetall,hkeys,hvals 等。
  3. 应用场景: 系统中对象数据的存储。

对象缓存

HMSET user {userId}:username zhangfei {userId}:password 123456
HMSET user 1:username zhangfei 1:password 123456
HMGET user 1:username 1:password

电商购物车
在这里插入图片描述

购物车操作
1)添加商品 —> hset cart:1001 10088 1

  1. 增加数量 —> 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集群架构下不适合大规模使用

list列表类型

  1. 介绍list 即是 链表。链表是一种非常常见的数据结构,特点是易于数据元素的插入和删除并且且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现比如
    Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的
    list 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
  2. **常用命令:**rpush,lpop,lpush,rpop,lrange、llen 等。
  3. 应用场景: 发布与订阅或者说消息队列、慢查询。 在这里插入图片描述
    常用数据结构
    Stack(栈)= LPUSH(左边放) + LPOP(左边取) --> FILO
    Quece(队列)= LPUSH(左边放) + RPOP右边取)
    BLocking MQ(阻塞队列)= LPUSH(左边放) + BRPOP(右边阻塞取:没有数据就阻塞!)

可以用于:微博、朋友圈、公众号等,关注的文章列表展示 lpush msg:yida zst lpush msg:yida qsc

LRANGE msg:yida 0-6 ===> qsc zst

set集合类型

  1. 介绍 : set 类似于 Java 中的 HashSet 。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如_共同关注、共同粉丝、共同喜好_等功能。这个过程也就是求交集的过程。
  2. **常用命令:**sadd,spop,smembers,sismember,scard,sinterstore,sunion 等。
  3. 应用场景: 需要存放的数据不能重复以及需要获取多个数据源交集和并集等场景

微信抽奖小程序

1)点击 参与抽奖 加入集合
SADD key {userID}
2)查看排行榜
SMEMBERS key
3)抽取count名中奖者
SRANDMEMBER key [count] / SPOP key [count]

集合操作实现微博、微信关注模型
在这里插入图片描述

交集为:**SINTER **set1 set2 set3 ==> { c }
并集为:**SUNION **set1 set2 set3 ==> { a,b,c,d,e }
差集为:SDIFF set1 set2 set3 ==> { a }
差集计算方式:set1 - (set2并set3) = {a、b、c} - {b、c、d、e} = {a} 只保留a中单独存在的元素
共同关注A的人:可以用交集来实现
我可能认识的人
:可以使用差集来实现,把我关注的人求差集

sortedset有序集合类型

  1. 介绍: 和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。
  2. **常用命令:**zadd,zcard,zscore,zrange,zrevrange,zrem 等。
  3. 应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜弹幕消息(可以理解为按消息维度的消息排行榜)等信息。
    在这里插入图片描述

bitmap位图 类型

  1. 介绍 : bitmap 存储的是连续的二进制数字(0 和 1),通过 bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 bitmap 本身会极大的节省储存空间。在这里插入图片描述
  2. **常用命令:**setbit 、getbit 、bitcount、bitop
  3. 应用场景: 适合需要保存状态信息(比如是否签到是否登录…)并需要进一步对这些信息进行分析的场景。比如用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。

例:存储对比:
有1亿用户,5千万登陆用户,那么统计每日用户的登录数。每一位标识一个用户ID,当某个用户访问我们的网站就在Bitmap中把标识此用户的位设置为1。
这里做了一个使用set集合和BitMap存储的对比。

数据类型 每个 userid 占用空间 需要存储的用户量 全部占用内存量
set(集合) 32位也就是4个字节(假设userid用的是整型,实际很多网站用的是长整型) 50,000,000 32位 * 50,000,000 = 200 MB
BitMap 1 位(bit) 100,000,000 1 位 * 100,000,000 = 12.5 MB

时间在拉长一点

** ** 一天 一个月 一年
set(集合) 200M 6G 72G
BitMap 12.5M 375M 4.5G

使用场景一:用户行为分析 很多网站为了分析你的喜好,需要研究你点赞过的内容。

记录你喜欢过 001 号小姐姐

127.0.0.1:6379> setbit beauty_girl_001 uid 1

使用场景二:统计活跃用户

面试题:现在系统有亿级的活跃用户,为了增强用户粘性,该如何实现签到、日活统计?

使用时间作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1
那么我该如果计算某几天/月/年的活跃用户呢(暂且约定,统计时间内只要有一天在线就称为活跃),有请下一个 redis 的命令

对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。

BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数

BITOP operation destkey key [key …]

初始化数据:

127.0.0.1:6379> setbit 20210308 1 1
(integer) 0
127.0.0.1:6379> setbit 20210308 2 1
(integer) 0
127.0.0.1:6379> setbit 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数: 1

127.0.0.1:6379> bitop and desk1 20210308 20210309
(integer) 1
127.0.0.1:6379> bitcount desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数: 2

127.0.0.1:6379> bitop or desk2 20210308 20210309
(integer) 1
127.0.0.1:6379> bitcount desk2
(integer) 2

geo地理位置类型

Redis 3.2 中增加了对GEO类型的支持。GEO,Geographic,地理信息的缩写。该类型,就是元素的2维坐标,在地图上就是经纬度。redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度Hash等常见操作
应用场景:附近的人、摇一摇、附近的车、附近银行站点查询
在这里插入图片描述

GEORADIUS案例:在hubeiCities位置集合中查找距离经纬度为112.927076
28.235653(长沙)500km以内的位置信息,查找结果中应包含不超过5个位置的坐标信息,距离信息,并按距离由近到远排序:
georadius hubeiCities 112.927076 28.235653 500 km withcoord withdist asc count 5

在hubeiCities位置集合中查找距离襄阳200km以内的位置信息【这里指定的目标位置只能是hubeiCities中存在的位置,而不能指定位置坐标】,查找结果中应包含不超过2个位置的坐标信息,距离信息,并按距离由远到近排序。查询代码如下:
georadiusbymember hubeiCities xiangyang 200 km withcoord withdist desc count 2


Redis高级应用&拓展功能

1 发布订阅

在这里插入图片描述
在这里插入图片描述

unsubscribe:退订 channel unsubscribe ch1
psubscribe :模式匹配 psubscribe +模式 Redis客户端1订阅所有以ch开头的频道 psubscribe ch*

使用场景

在Redis哨兵模式中,哨兵通过发布与订阅的方式与Redis主服务器和Redis从服务器进行通信
Redisson是一个分布式锁框架,在Redisson分布式锁释放的时候,是使用发布与订阅的方式通知的
注:重业务的消息,推荐用消息队列

2 事务

所谓事务(Transaction) ,是指作为单个逻辑工作单元执行的一系列操作

ACID回顾

  • Atomicity(原子性):构成事务的的所有操作必须是一个逻辑单元,要么全部执行,要么全部不执行。
  • Consistency(一致性):数据库在事务执行前后状态都必须是稳定的或者是一致的。
  • Isolation(隔离性):事务之间不会相互影响。
  • Durability(持久性):事务执行成功后必须全部写入磁盘。

Redis事务

Redis 事务的本质是一组命令的集合

  • Redis的事务是通过multiexecdiscardwatch这四个命令来完成的。
  • Redis的单个命令都是原子性的,所以这里需要确保事务性的对象是命令集合。
  • Redis将命令集合序列化并确保处于同一事务的命令集合连续且不被打断的执行
  • Redis不能保障失败回滚

注意 redis的事务远远弱于mysql,严格意义上,它不能叫做事务,只是一个命令打包的批处理,不能保障失败回滚

关于回滚

注意!回滚要看两种情况:

  • 直接语法错误,redis完全无法执行,Redis 2.6.5之前的版本不会回滚,之后版本整个事务回滚
  • 执行期的错误,redis不会回滚,其他正确的指令会照样执行,不会回滚

watch

Redis Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断
关于上面的操作,如果遇到各种错误,multi可以自动帮你回滚
而watch命令提供了另一种机制,它通过监控某个key的变动,来决定是不是回滚。
主要应用于高并发的正常业务场景下,处理并发协调。
在这里插入图片描述
在这里插入图片描述

3 Lua脚本

lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。如果在redis中要想使用事务,那么第一时间应该想到lua脚本。

Redis使用lua脚本

版本:自2.6.0起可用,通过内置的lua编译/解释器,可以使用EVAL命令对lua脚本进行求值。
时间复杂度:取决于执行的脚本。
使用Lua脚本的好处:

  • 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延。
  • 原子操作。redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
  • 复用。客户端发送的脚本会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。

如何使用 EVAL命令

命令格式:

EVAL script numkeys key [key …] arg [arg …]

命令说明:

  • script:参数是一段 Lua 5.1 脚本程序
  • numkeys: 用于指定键名参数的个数。
  • key [key …],是要操作的键,可以指定多个,在lua脚本中通过KEYS[1], KEYS[2]获取
  • arg [arg …],附加参数,在lua脚本中通过ARGV[1], ARGV[2]获取。

实例:
eval “return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}” 2 key1 key2 first second

lua脚本中调用Redis命令

  • redis.call():
    • 返回值就是redis命令执行的返回值
    • 如果出错,则返回错误信息,不继续执行
  • redis.pcall():
    • 返回值就是redis命令执行的返回值
    • 如果出错,则记录错误信息,继续执行
  • 注意事项
    • 在脚本中,使用return语句将返回值返回给客户端,如果没有return,则返回nil

eval “return redis.call(‘set’,KEYS[1],ARGV[1])” 1 n1 zhaoyun

命令行里使用

如果直接使用redis-cli命令,格式会有点不一样:

redis-cli --eval lua_file key1 key2 , arg1 arg2 arg3

注意的地方:

  • eval 后面参数是lua脚本文件,.lua后缀
  • 不用写numkeys,而是使用,隔开。注意,前后有空格。

示例:
incrbymul.lua

local num = redis.call('GET', KEYS[1]);  

if not num then
  return 0;
else
  local res = num * ARGV[1]; 
  redis.call('SET',KEYS[1], res); 
  return res;
end

在这里插入图片描述

4 慢查询日志

客户端请求的生命周期的完整生命周期,4个阶段在这里插入图片描述

注意:慢查询只统计**步骤3**的时间,所以没有慢查询并不代表客户端没有超时问题。换句话说。redis的慢查询记录时间指的是不包括像客户端响应、发送回复等IO操作,而单单是执行一个查询命令所耗费的时间。
:::success
慢查询配置相关的参数

  • slowlog-log-slower-than:选项指定执行时间超过多少微秒(默认1秒=1,000,000微秒)的命令请求会被记录到日志上。例:如果这个选项的值为100,那么执行时间超过100微秒的命令就会被记录到慢查询日志; 如果这个选项的值为500 , 那么执行时间超过500微秒的命令就会被记录到慢查询日志;
  • slowlog-max-len:选项指定服务器最多保存多少条慢查询日志。服务器使用先进先出的方式保存多条慢查询日志: 当服务器储存的慢查询日志数量等于slowlog-max-len选项的值时,服务器在添加一条新的慢查询日志之前,会先将最旧的一条慢查询日志删除。例:如果服务器slowlog-max-len的值为100,并且假设服务器已经储存了100条慢查询日志, 那么如果服务器打算添加一条新日志的话,它就必须先删除目前保存的最旧的那条日志, 然后再添加新日志。
    :::

在Redis中有两种修改配置的方法,一种是修改配置文件,另一种是使用config set命令动态修改;
慢查询配置相关的命令

  1. config set slowlog-log-slower-than 20000
  2. config set slowlog-max-len 1024
  3. showlog get # 查看慢查询日志

慢查询日志的访问和管理

  1. 获取[n条]慢查询队列 slowlog get [n]
  2. 获取慢查询队列的当前长度 slowlog len
  3. 清空慢查询队列 slowlog reset

慢查询日志的使用案例

  1. 设置慢查询时长: config set slowlog-log-slower-than 0 # 0表示将所有命令都记录为慢查询
  2. 设置最多保存多少条慢查询日志: config set slowlog-max-len 3
  3. 获得慢查询日志: slowlog get

慢查询日志的组成
在这里插入图片描述
在生产环境中,慢查询功能可以有效地帮助我们找到Redis可能存在的瓶颈,但在实际使用过程中要注意以下几点:
1、slowlog-max-len:线上建议调大慢查询列表,记录慢查询时Redis会对长命令做阶段操作,并不会占用大量内存.增大慢查询列表可以减缓慢查询被剔除的可能,例如线上可设置为1000以上.
2、slowlog-log-slower-than:默认值超过10毫秒判定为慢查询,需要根据Redis并发量调整该值.
3、慢查询只记录命令的执行时间,并不包括命令排队和网络传输时间.因此客户端执行命令的时间会大于命令的实际执行时间.因为命令执行排队机制,慢查询会导致其他命令级联阻塞,因此客户端出现请求超时时,需要检查该时间点是否有对应的慢查询,从而分析是否为慢查询导致的命令级联阻塞.
4、由于慢查询日志是一个先进先出的队列,也就是说如果慢查询比较多的情况下,可能会丢失部分慢查询命令,为了防止这种情况发生,可以定期执行slowlog get命令将慢查询日志持久化到其他存储中(例如:MySQL等),然后可以通过可视化工具进行查询.


持久化:Redis是内存数据库,数据都是存储在内存中,为了避免进程退出导致数据的永久丢失,需要定期将Redis中的数据以某种形式(数据或命令)从内存保存到硬盘;当下次Redis重启时,利用持久化文件实现数据恢复。除此之外,为了进行灾难备份,可以将持久化文件拷贝到一个远程位置。

在我们安装了redis之后,所有的配置都是在redis.conf文件中,里面保存了RDB和AOF两种持久化机制的各种配置。

RDB详解

在Redis中RDB持久化的触发分为两种:指令手动触发redis.conf 配置自动触发
save命令和bgsave命令都可以生成RDB文件

手动触发:

  • save:会阻塞当前Redis服务器,直到RDB文件创建完毕为止,线上应该禁止使用。
  • bgsave:该触发方式会fork一个子进程,由子进程负责持久化过程,因此阻塞只会发生在fork子进程的时候。

自动触发:

  • 根据我们的 save m n 配置规则自动触发;
  • 从节点全量复制时,主节点发送rdb文件给从节点完成复制操作,主节点会触发 bgsave;
  • 执行 debug reload 时;
  • 执行 shutdown时,如果没有开启aof,也会触发。

redis.conf:

时间策略

save 900 1 # 表示900 秒内如果至少有 1 个 key 的值变化,则触发RDB
save 300 10 # 表示300 秒内如果至少有 10 个 key 的值变化,则触发RDB
save 60 10000 # 表示60 秒内如果至少有 10000 个 key 的值变化,则触发RDB

文件名称

dbfilename dump.rdb

文件保存路径

dir /home/work/app/redis/data/

如果持久化出错,主进程是否停止写入

stop-writes-on-bgsave-error yes

是否压缩

rdbcompression yes

导入时是否检查

rdbchecksum yes

  • save 900 1 表示900s内如果有1条是写入命令,就触发产生一次快照,可以理解为就进行一次备份
  • save 300 10 表示300s内有10条写入,就产生快照

下面的类似,那么为什么需要配置这么多条规则呢?因为Redis每个时段的读写请求肯定不是均衡的,为了平衡性能与数据安全,我们可以自由定制什么情况下触发备份。所以这里就是根据自身Redis写入情况来进行合理配置。

  • stop-writes-on-bgsave-error yes 这个配置也是非常重要的一项配置,这是当备份进程出错时,主进程就停止接受新的写入操作,是为了保护持久化的数据一致性问题。如果自己的业务有完善的监控系统,可以禁止此项配置, 否则请开启。
  • 关于压缩的配置 rdbcompression yes ,建议没有必要开启,毕竟Redis本身就属于CPU密集型服务器,再开启压缩会带来更多的CPU消耗,相比硬盘成本,CPU更值钱。
  • 当然如果你想要禁用RDB配置,也是非常容易的,只需要在save的最后一行写上:save “”

RDB总结

优势:

1、执行效率高,适用于大规模数据的备份恢复。自动备份不会影响主线程工作。
2、备份的文件占用空间小。其备份的是数据快照,相对于AOF来说文件大小要小一些。

劣势:

1、可能会造成部分数据丢失。因为是自动备份,所以如果修改的数据量不足以触发自动备份,同时发生断电等异常导致redis不能正常关闭,所以也没有触发关闭的备份,那么在上一次备份到异常宕机过程中发生的写操作就会丢失。
2、自动备份通过fork进程来执行备份操作,而fork进程会阻塞主进程

AOF详解

AOF(append only file):记录每次对服务器写的操作(命令),当服务器重启的时候会重新执行这些命令来恢复原始的数据。默认不开启

AOF特点:

  1. 以日志的形式来记录用户请求的写操作,读操作不会记录,因为写操作才会存储
  2. 文件以追加的形式而不是修改的形式
  3. redis的aof恢复其实就是把追加的文件从开始到结尾读取 执行 写操作
    在这里插入图片描述

如上图所示,AOF 持久化功能的实现可以分为命令追加( append )文件写入( write )文件同步( sync )文件重写(rewrite)重启加载(load)。其流程如下:

  • 所有的写命令会追加到AOF 缓冲中。
  • AOF 缓冲区根据对应的策略向硬盘进行同步操作。
  • 随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
  • 当 Redis 重启时,可以加载 AOF 文件进行数据恢复。

可以通过修改redis.conf配置文件中的appendonly参数开启

appendonly yes

AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的。

dir .

默认的文件名是appendonly.aof,可以通过appendfilename参数修改

appendfilename appendonly.aof

命令追加

当 AOF 持久化功能处于打开状态时,Redis 在执行完一个写命令之后,会以协议格式(也就是RESP,即 Redis 客户端和服务器交互的通信协议 )将被执行的写命令追加到 Redis 服务端维护的 AOF 缓冲区末尾。

文件写入和同步(触发)

Redis 每次结束一个事件循环之前,它都会调用 flushAppendOnlyFile 函数,判断是否需要将 AOF 缓存区中的内容写入和同步到 AOF 文件中。
flushAppendOnlyFile 函数的行为由 redis.conf 配置中的 appendfsync 选项的值来决定。该选项有三个可选值,分别是 always、 everysec 和 no:
在这里插入图片描述

  • always:每执行一个命令保存一次 高消耗,最安全
  • everysec :每一秒钟保存一次 (一般我们就用这种)
  • no:只写入 不保存, AOF 或 Redis 关闭时执行,由操作系统触发刷新文件到磁盘

写入保存概念
WRITE:根据条件,将 aof_buf 中的缓存写入到 AOF 文件。
SAVE:根据条件,调用 fsync 或 fdatasync 函数,将 AOF 文件保存到磁盘中。

AOF “重写”

为了解决 AOF 文件体积膨胀的问题,Redis 提供了 AOF 文件重写( rewrite) 策略
在这里插入图片描述

rewrite的触发机制主要有:

  • 手动调用 bgrewriteaof 命令,如果当前有正在运行的 rewrite 子进程,则本次rewrite 会推迟执行,否则,直接触发一次 rewrite
  • 自动触发 就是根据配置规则来触发

重写机制:避免文件越来越大,自动优化压缩指令,会fork一个新的进程去完成重写动作,新进程里的内存数据会被重写,此时旧的aof文件不会被读取使用

当前AOF文件的大小是上次AOF大小的100%(体积增加一倍) 并且文件体积达到64m,满足两者则触发重写

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

stat appendonly.aof 查看aof文件

AOF重写原理

AOF 重写函数会进行大量的写入操作,调用该函数的线程将被长时间阻塞,所以 Redis 在子进程中执行 AOF 重写操作。
在这里插入图片描述

持久化优先级

如果一台服务器上有既有RDB文件,又有AOF文件,该加载谁呢?
在这里插入图片描述

性能与实践

通过上面的分析,我们都知道RDB的快照、AOF的重写都需要fork,这是一个重量级操作,会对Redis造成阻塞。因此为了不影响Redis主进程响应,我们需要尽可能降低阻塞。
:::success

  1. 降低fork的频率,比如可以手动来触发RDB生成快照、与AOF重写;
  2. 控制Redis最大使用内存,防止fork耗时过长;
  3. 使用更牛逼的硬件;
  4. 合理配置Linux的内存分配策略,避免因为物理内存不足导致fork失败

线上实践经验

  1. 如果Redis中的数据并不是特别敏感或者可以通过其它方式重写生成数据,可以关闭持久化,如果丢失数据可以通过其它途径补回;
  2. 自己制定策略定期检查Redis的情况,然后可以手动触发备份、重写数据;
  3. 可以加入主从机器,利用一台从机器进行备份处理,其它机器正常响应客户端的命令;
  4. RDB持久化与AOF持久化可以同时存在,配合使用。
    :::

过期删除策略&内存淘汰策略

设置Redis键过期时间

Redis提供了四个命令来设置过期时间(生存时间)。
①、EXPIRE :表示将键 key 的生存时间设置为 ttl 秒。
②、PEXPIRE :表示将键 key 的生存时间设置为 ttl 毫秒。
③、EXPIREAT :表示将键 key 的生存时间设置为 timestamp 所指定的秒数时间戳。
④、PEXPIREAT :表示将键 key 的生存时间设置为 timestamp 所指定的毫秒数时间戳。
PS:在Redis内部实现中,前面三个设置过期时间的命令最后都会转换成最后一个PEXPIREAT 命令来完成。

Redis过期时间的判定

在Redis内部,每当我们设置一个键的过期时间时,Redis就会将该键带上过期时间存放到一个过期字典中。当我们查询一个键时,Redis便首先检查该键是否存在过期字典中,如果存在,那就获取其过期时间。然后将过期时间和当前系统时间进行比对,比系统时间大,那就没有过期;反之判定该键过期。

过期删除策略

①、定时删除

在设置某个key 的过期时间同时,我们创建一个定时器,让定时器在该过期时间到来时,立即执行对其进行删除的操作。

②、惰性删除

设置该key 过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。

③、定期删除

每隔一段时间,我们就对一些key进行检查,删除里面过期的key。

Redis过期删除策略

Redis的过期删除策略就是:惰性删除和定期删除两种策略配合使用

惰性删除:Redis的惰性删除策略由 db.c/expireIfNeeded 函数实现,所有键读写命令执行之前都会调用 expireIfNeeded 函数对其进行检查,如果过期,则删除该键,然后执行键不存在的操作;未过期则不作操作,继续执行原有的命令。
定期删除:由redis.c/activeExpireCycle 函数实现,函数以一定的频率运行,每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
注意:并不是一次运行就检查所有的库,所有的键,而是随机检查一定数量的键
定期删除函数的运行频率,在Redis2.6版本中,规定每秒运行10次,大概100ms运行一次。在Redis2.8版本后,可以通过修改配置文件redis.conf 的 hz 选项来调整这个次数。

内存淘汰策略

设置Redis最大内存

在配置文件redis.conf 中,可以通过参数 maxmemory 来设定最大内存

设置内存淘汰方式

当现有内存大于 maxmemory 时,便会触发redis主动淘汰内存方式,通过设置 maxmemory-policy
有如下几种淘汰方式:

  • volatile-lru:设置了过期时间的key使用LRU算法淘汰;
  • allkeys-lru:所有key使用LRU算法淘汰;
  • volatile-lfu:设置了过期时间的key使用LFU算法淘汰;
  • allkeys-lfu:所有key使用LFU算法淘汰;
  • volatile-random:设置了过期时间的key使用随机淘汰;
  • allkeys-random:所有key使用随机淘汰;
  • volatile-ttl:设置了过期时间的key根据过期时间淘汰,越早过期越早淘汰;
  • noeviction:默认策略,当内存达到设置的最大值时,所有申请内存的操作都会报错(如set,lpush等),只读操作如get命令可以正常执行;

LRU、LFU和volatile-ttl都是近似随机算法;

#配置文件
maxmemory-policy noeviction

#命令行
127.0.0.1:6379> config get maxmemory-policy

1) "maxmemory-policy"
2) "noeviction"
   127.0.0.1:6379> config set maxmemory-policy allkeys-random
   OK
   127.0.0.1:6379> config get maxmemory-policy
3) "maxmemory-policy"
4) "allkeys-random"

LRU算法

LRU(Least Recently Used)表示最近最少使用,该算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
LRU算法的常见实现方式为链表:
新数据放在链表头部 ,链表中的数据被访问就移动到链头,链表满的时候从链表尾部移出数据。
在这里插入图片描述

而在Redis中使用的是近似LRU算法,为什么说是近似呢?Redis中是随机采样5个(可以修改参数maxmemory-samples配置)key,然后从中选择访问时间最早的key进行淘汰,因此当采样key的数量与Redis库中key的数量越接近,淘汰的规则就越接近LRU算法。但官方推荐5个就足够了,最多不超过10个,越大就越消耗CPU的资源。
但在LRU算法下,如果一个热点数据最近很少访问,而非热点数据近期访问了,就会误把热点数据淘汰而留下了非热点数据,因此在Redis4.x中新增了LFU算法。

在LRU算法下,Redis会为每个key新增一个3字节的内存空间用于存储key的访问时间;

LFU算法

LFU(Least Frequently Used)表示最不经常使用,它是根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。
LFU算法反映了一个key的热度情况,不会因LRU算法的偶尔一次被访问被误认为是热点数据。
LFU算法的常见实现方式为链表:
新数据放在链表尾部 ,链表中的数据按照被访问次数降序排列,访问次数相同的按最近访问时间降序排列,链表满的时候从链表尾部移出数据。
在这里插入图片描述

Redis在实现LFU策略的时候,只是把原来24bit大小的LRU字段,又进一步拆分成了两部分

  • Idt:lru字段的前16bit,表示数据的访问时间戳
  • counter值:lru字段的后8bit,表示数据的访问次数总结:当LFU策略筛选数据时,Redis会在候选集合中,根据数据lru字段的后8bit选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据lru字段的前16bit值大小,选择访问时间最久远的数据进行淘汰.

性能压测

Redis 的性能测试工具,目前主流使用的是 redis-benchmark
Redis 官方提供 redis-benchmark 的工具来模拟 N 个客户端同时发出 M 个请求,可以便捷对服务器进行读写性能压测
redis 性能测试工具可选参数如下所示:

序号 选项 描述 默认值
1 -h 指定服务器主机名 127.0.0.1
2 -p 指定服务器端口 6379
3 -s 指定服务器 socket
4 -c 指定并发连接数 50
5 -n 指定请求数 10000
6 -d 以字节的形式指定 SET/GET 值的数据大小 2
7 -k 1=keep alive 0=reconnect 1
8 -r SET/GET/INCR 使用随机 key, SADD 使用随机值
9 -P 通过管道传输 请求 1
10 -q 仅显示 query/sec 值
11 –csv 以 CSV 格式输出
12 -l(L 的小写字母) 生成循环,永久执行测试
13 -t 仅运行以逗号分隔的测试命令列表。
14 -I(i 的大写字母) Idle 模式。仅打开 N 个 idle 连接并等待。

快速测试

redis-benchmark

在这里插入图片描述

精简测试

redis-benchmark -t set,get,incr -n 1000000 -q

  • 通过 -t 参数,设置仅仅测试 SET/GET/INCR 命令
  • 通过 -n 参数,设置每个测试执行 1000000 次操作。
  • 通过 -q 参数,设置精简输出结果。

在这里插入图片描述


Redis高可用

主从复制

5.1.1 面临问题

Redis有两种不同的持久化方式,Redis服务器通过持久化,把Redis内存中持久化到硬盘当中,当Redis宕机时,我们重启Redis服务器时,可以由RDB文件或AOF文件恢复内存中的数据。

问题1:不过持久化后的数据仍然只在一台机器上,因此当硬件发生故障时,比如主板或CPU坏了,这时候无法重启服务器,有什么办法可以保证服务器发生故障时数据的安全性?或者可以快速恢复数据呢?
问题2:容量瓶颈

解决办法

针对这些问题,redis提供了复制(replication)的功能,通过"主从(一主多从)"和"集群(多主多从)"的方式对redis的服务进行水平扩展,用多台redis服务器共同构建一个高可用的redis服务系统。

主从复制

主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master),后者称为从节点(slave),数据的复制是单向的,只能由主节点到从节点。
在这里插入图片描述

常用策略

策略1 :一主多从 主机(写),从机(读)

在这里插入图片描述

策略2:薪火相传

在这里插入图片描述

主从复制原理

Redis的主从复制是异步复制,异步分为两个方面,一个是master服务器在将数据同步到slave时是异步的,因此master服务器在这里仍然可以接收其他请求,一个是slave在接收同步数据也是异步的。

  • 全量复制

master服务器会将自己的rdb文件发送给slave服务器进行数据同步,并记录同步期间的其他写入,再发送给slave服务器,以达到完全同步的目的,这种方式称为全量复制。
在这里插入图片描述

  • 增量复制

因为各种原因master服务器与slave服务器断开后,slave服务器在重新连上master服务器时会尝试重新获取断开后未同步的数据即部分同步,或者称为部分复制。

在这里插入图片描述

工作原理

master服务器会记录一个replicationId的伪随机字符串,用于标识当前的数据集版本,还会记录一个当数据集的偏移量offset,不管master是否有配置slave服务器,replication Id和offset会一直记录并成对存在,我们可以通过以下命令查看replication Id和offset:info repliaction
当master与slave正常连接时,slave使用PSYNC命令向master发送自己记录的旧master的replication id和offset,而master会计算与slave之间的数据偏移量,并将缓冲区中的偏移数量同步到slave,此时master和slave的数据一致。
而如果slave引用的replication太旧了,master与slave之间的数据差异太大,则master与slave之间会使用全量复制的进行数据同步(repl_backlog_size值调大可以尽量避免)。


配置主从复制

注:主从复制的开启,完全是在从节点发起的;不需要我们在主节点做任何事情。

从节点开启主从复制,有3种方式:
(1)配置文件:在从服务器的配置文件中加入:slaveof
(2)redis-server启动命令后加入 --slaveof
(3)Redis服务器启动后,直接通过客户端执行命令:slaveof ,则该Redis实例成为从节点

演示:

准备工作:
在同一台机器上启动3台redis 端口分别是:6379、6380、6381
redis-server ./redis.conf --port 6380
在这里插入图片描述

在这里插入图片描述

分别连接:
redis-cli -p 6380

①、通过 info replication 命令查看三台节点角色
在这里插入图片描述

②、设置主从关系,从节点执行命令:SLAVEOF 127.0.0.1 6379
在这里插入图片描述

测试主从关系

①、增量复制
master 操作写入:
在这里插入图片描述
②、全量复制
通过执行 SLAVEOF 127.0.0.1 6379,如果主节点 6379 以前还存在一些 key,那么执行命令之后,从节点会将以前的信息也都复制过来。
③、主从读写分离
尝试slave操作写入:会报错
在这里插入图片描述

原因是在配置文件 6380redis.conf 中对于 slave-read-only 的配置
如果我们将其修改为 no 之后,执行写命令是可以的,但是从节点写命令的数据从节点或者主节点都不能获取的。
④、主节点宕机
主节点 Maste 挂掉,两个从节点角色会发生变化吗?
在这里插入图片描述

上图可知主节点 Master 挂掉之后,从节点角色还是不会改变的。
⑤、主节点宕机后恢复
主节点Master挂掉之后,马上启动主机Master,主节点扮演的角色还是 Master 吗?YES!
也就是说主节点挂掉之后重启,又恢复了主节点的角色。


sentinel哨兵模式

在这里插入图片描述

通过前面的配置,主节点Master 只有一个,一旦主节点挂掉之后,从节点没法担起主节点的任务,那么整个系统也无法运行。
如果主节点挂掉之后,从节点能够自动变成主节点,那么问题就解决了,于是哨兵模式诞生了。
在这里插入图片描述

哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个Redis实例。
**哨兵模式搭建步骤: **
①、在配置文件目录下新建 sentinel.conf 文件,名字绝不能错,然后配置相应内容

sentinel monitor mymaster 192.168.88.128 6381 1

sentinel monitor 被监控机器的名字(自己起名字) ip地址 端口号 得票数

分别配置被监控的名字,ip地址,端口号,以及得票数。上面的得票数为1表示表示主机挂掉后salve投票看让谁接替成为主机,得票数大于1便成为主机
②、启动哨兵
redis-sentinel /redis/sentinel.conf
接下来,我们干掉主机 6379,然后看从节点有啥变化。
在这里插入图片描述

干掉主节点之后,我们查看后台打印日志,发现 6381投票变为主节点
在这里插入图片描述

PS:哨兵模式也存在单点故障问题,如果哨兵机器挂了,那么就无法进行监控了,解决办法是哨兵也建立集群,Redis哨兵模式是支持集群的。
当再启动原来的主节点时,仍然是slave master已经变为、6381了
在这里插入图片描述



在并发编程中,我们通过锁,来避免由于竞争而造成的数据不一致问题。通常,我们以synchronized 、Lock来使用它(单机情况)。
synchronized、Lock是本地锁,只能锁本地进程,多JVM情况下就要用到分布式锁。锁的本质是让同一个代码块在同一时间只有一个进程在执行。让方法或者代码块并行执行---->串行执行

高并发下单超卖问题

	@Autowired
    RedisTemplate<String,String> redisTemplate;

    String maotai = "maotai20210321001";// 茅台商品编号

    @PostConstruct
    public void init(){
    
    
        //此处模拟向缓存中存入商品库存操作
        redisTemplate.opsForValue().set(maotai,"100");
    }


    @GetMapping("/get/maotai2")
    public String seckillMaotai2() {
    
    
        synchronized (this) {
    
    
            Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
            //如果还有库存
            if (count > 0) {
    
    
                //抢到了茅台,库存减一
                redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
                //后续操作 do something
                log.info("我抢到茅台了!");
                return "ok";
            }else {
    
    
                return "no";
            }
        }
    }

上述代码在并发的场景下回出现 超卖 的问题,因为当count=1的时候,可能会有多个线程走到1位置,然后读到count=1,1>0 执行减库存,此时就会超卖了…………


@GetMapping("/get/maotai2")
public String seckillMaotai2() {
    
    
    synchronized (this) {
    
      //  注意,此处加了锁,确保只有一个线程进入方法块
        Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai));
        //如果还有库存
        if (count > 0) {
    
    
            //抢到了茅台,库存减一
            redisTemplate.opsForValue().set(maotai, String.valueOf(count - 1));
            //后续操作 do something
            log.info("我抢到茅台了!");
            return "ok";
        } else {
    
    
            return "no";
        }
    }
}

上述代码在单JVM进程中是没有问题的,因为加了synchronized 锁。
但要是把此服务部署多个,进行集群,那就是多jvm进程了,本地锁synchronized只能锁住本地进程,对于其他jvm进程是锁不住的……所以仍然会导致超卖……
Nginx 80 8080 服务1
Nginx 80 8081 服务2


何为分布式锁

  • 当在分布式架构下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
  • 用一个状态值表示锁,对锁的占用和释放通过状态值来标识。
    :::info

分布式锁特点

  1. 互斥性:不仅要在同一jvm进程下的不同线程间互斥,更要在不同jvm进程下的不同线程间互斥
  2. 锁超时:支持锁的自动释放,防止死锁
  3. 正确,高效,高可用:解铃还须系铃人(加锁和解锁必须是同一个线程),加锁和解锁操作一定要高效,提供锁的服务要具备容错性
  4. 可重入:如果一个线程拿到了锁之后继续去获取锁还能获取到,我们称锁是可重入的(方法的递归调用)
  5. 阻塞/非阻塞:如果获取不到直接返回视为非阻塞的,如果获取不到会等待锁的释放直到获取锁或者等待超时,视为阻塞的
  6. 公平/非公平:按照请求的顺序获取锁视为公平的
    :::

基于Redis实现分布式锁

实现思路:

锁的实现主要基于redis的SETNX命令:
SETNX key value
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
**返回值:**设置成功,返回 1 设置失败,返回 0
使用SETNX完成同步锁的流程及事项如下:

  1. 使用SETNX命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功
  2. 为了防止获取锁后程序出现异常,导致其他线程/进程调用SETNX命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间
  3. 释放锁,使用DEL命令将锁数据删除
String lockey = "maotailock";

@GetMapping("/get/maotai3")
    public String seckillMaotai3() {
    
    
    /*redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Boolean setNX = redisConnection.setNX(lockey.getBytes(), "1".getBytes());
return setNX;
}
});*/
    Boolean islock = redisTemplate.opsForValue().setIfAbsent(lockey, "1");  // 1
    if (islock) {
    
    
        redisTemplate.expire(lockey, 5, TimeUnit.SECONDS);   // 2
        try {
    
    
            Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai));
            //如果还有库存
            if (count > 0) {
    
    
                //抢到了茅台,库存减一
                redisTemplate.opsForValue().set(maotai, String.valueOf(count - 1));
                //后续操作 do something
                log.info("我抢到茅台了!");
                return "ok";
            } else {
    
    
                return "no";
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            //释放锁
            redisTemplate.delete(lockey);
        }
    }
    return "dont get lock";
}

问题分析:

  1. (步骤1和步骤2)setnx 和 expire是非原子性操作(解决:2.6以前可用使用lua脚本,2.6以后可用set命令)====》如果setnx执行成功,expire执行失败,那么就会导致锁一直得不到释放
  2. 错误解锁(如何保证解铃还须系铃人:给锁加一个唯一标识),如果线程1获取到锁,设置锁过期时间为5s,业务执行完成后想执行删除锁操作(redisTemplate.delete(lockey)),此时切换到另一个进程执行……执行,然后执行删除锁操作,那么此时就会出现错误解锁的情况(虽然此种情况下不影响当前情况的业务)

问题1两种做法:
1,2.6以前借助lua
2,2.6以后使用set命令


上面错误解锁问题解决:

@GetMapping("/get/maotai4")
    public String seckillMaotai4() {
    
    
        String requestid = UUID.randomUUID().toString() + Thread.currentThread().getId();
        /*String locklua ="" +
                "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then redis.call('expire',KEYS[1],ARGV[2]) ; return true " +
                "else return false " +
                "end";
        Boolean islock = redisTemplate.execute(new RedisCallback<Boolean>() {
            @Override
            public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
                Boolean eval = redisConnection.eval(
                        locklua.getBytes(),
                        ReturnType.BOOLEAN,
                        1,
                        lockey.getBytes(),
                        requestid.getBytes(),
                        "5".getBytes()
                );
                return eval;
            }
        });*/
        //获取锁
        Boolean islock = redisTemplate.opsForValue().setIfAbsent(lockey,requestid,5,TimeUnit.SECONDS);
        if (islock) {
    
    
            try {
    
    
                Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
                //如果还有库存
                if (count > 0) {
    
    
                    //抢到了茅台,库存减一
                    redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
                    //后续操作 do something
                    log.info("我抢到茅台了!");
                    return "ok";
                } else {
    
    
                    return "no";
                }
            } catch (Exception e) {
    
    
                e.printStackTrace();
            } finally {
    
    
                //释放锁
                //判断是自己的锁才能去释放 这种操作不是原子性的
                /*String id = redisTemplate.opsForValue().get(lockey);
                if (id !=null && id.equals(requestid)) {
                    redisTemplate.delete(lockey);
                }*/
                String unlocklua = "" +
                        "if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('del',KEYS[1]) ; return true " +
                        "else return false " +
                        "end";
                redisTemplate.execute(new RedisCallback<Boolean>() {
    
    
                    @Override
                    public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
    
    
                        Boolean eval = redisConnection.eval(
                                unlocklua.getBytes(),
                                ReturnType.BOOLEAN,
                                1,
                                lockey.getBytes(),
                                requestid.getBytes()
                        );
                        return eval;
                    }
                });
            }
        }
        return "dont get lock";
    }

上述代码仍然有问题:
线程1抢到锁,设置锁过期时间为5s,但要是业务比较复杂,业务要执行10s怎么办?5s后锁就到期了,会自动删除…………
此时要是业务没执行完,锁能自动续期就好了……


锁续期/锁续命

/**
     * 3,锁续期/锁续命
     *  拿到锁之后执行业务,业务的执行时间超过了锁的过期时间
     *
     *  如何做?
     *  给拿到锁的线程创建一个守护线程(看门狗),守护线程定时/延迟 判断拿到锁的线程是否还继续持有锁,如果持有则为其续期
     *
     */
    //模拟一下守护线程为其续期
    ScheduledExecutorService executorService;//创建守护线程池
    ConcurrentSkipListSet<String> set = new ConcurrentSkipListSet<String>();//队列

    @PostConstruct
    public void init2(){
    
    
        executorService = Executors.newScheduledThreadPool(1);

        //编写续期的lua
        String expirrenew = "" +
                "if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('expire',KEYS[1],ARGV[2]) ; return true " +
                "else return false " +
                "end";

        executorService.scheduleAtFixedRate(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                Iterator<String> iterator = set.iterator();
                while (iterator.hasNext()) {
    
    
                    String rquestid = iterator.next();

                    redisTemplate.execute(new RedisCallback<Boolean>() {
    
    
                        @Override
                        public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
    
    
                            Boolean eval = false;
                            try {
    
    
                                eval = redisConnection.eval(
                                        expirrenew.getBytes(),
                                        ReturnType.BOOLEAN,
                                        1,
                                        lockey.getBytes(),
                                        rquestid.getBytes(),
                                        "5".getBytes()
                                );
                            } catch (Exception e) {
    
    
                                log.error("锁续期失败,{}",e.getMessage());
                            }
                            return eval;
                        }
                    });

                }
            }
        },0,1,TimeUnit.SECONDS);
    }

@GetMapping("/get/maotai5")
    public String seckillMaotai5() {
    
    
        String requestid = UUID.randomUUID().toString() + Thread.currentThread().getId();
        //获取锁
        Boolean islock = redisTemplate.opsForValue().setIfAbsent(lockey,requestid,5,TimeUnit.SECONDS);
        if (islock) {
    
    
            //获取锁成功后让守护线程为其续期
            set.add(requestid);
            try {
    
    
                Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
                //如果还有库存
                if (count > 0) {
    
    
                    //抢到了茅台,库存减一
                    redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
                    //后续操作 do something
                    //seckillMaotai5();
                    //模拟业务超时
                    TimeUnit.SECONDS.sleep(10);
                    log.info("我抢到茅台了!");
                    return "ok";
                }else {
    
    
                    return "no";
                }
            } catch (Exception e) {
    
    
                e.printStackTrace();
            } finally {
    
    
                //解除锁续期
               set.remove(requestid);
                //释放锁
                String unlocklua = "" +
                        "if redis.call('get',KEYS[1]) == ARGV[1] then redis.call('del',KEYS[1]) ; return true " +
                        "else return false " +
                        "end";
                redisTemplate.execute(new RedisCallback<Boolean>() {
    
    
                    @Override
                    public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
    
    
                        Boolean eval = redisConnection.eval(
                                unlocklua.getBytes(),
                                ReturnType.BOOLEAN,
                                1,
                                lockey.getBytes(),
                                requestid.getBytes()
                        );
                        return eval;
                    }
                });
            }
        }
        return "dont get lock";
    }

有了锁的续期后,代码仍然有问题:
如果线程1获取到锁并把锁设置有效期为5s,如果业务中还要执行其他方法,而执行其他方法也需要获取到锁,否则阻塞,但其实锁在自己手里啊!就相当于我要开一扇门,但钥匙在门里,要先进去取钥匙……这就导致了死锁。
所以我们要想不死锁就需要一把 可重入锁


锁的可重入/阻塞锁(redisson)

 /**
     *
     * 4,如何支持可重入
     *   重入次数/过期时间
     *    获取
     *         获取
     *               获取
     *
     *               释放
     *         释放
     *    释放
     *
     *   基于本地实现
     *   还是基于redis但是更换了数据类型,采用hash类型来实现
     *    key   field  value
     *   锁key  请求id  重入次数
     *   用lua实现
     *
     *
     *   5,阻塞/非阻塞的问题:现在的锁是非阻塞的,一旦获取不到锁直接返回了
     *   如何做一个阻塞锁呢?
     *    获取不到就等待锁的释放,直到获取到锁或者等待超时
     *    1:基于客户端轮询的方案
     *    2:基于redis的发布/订阅方案
     *
     *
     *    有没有好的实现呢?
     *    Redisson
     *
     */
    @Value("${spring.redis.host}")
    String host;
    @Value("${spring.redis.port}")
    String port;

 @Bean
    public RedissonClient redissonClient() {
    
    
        Config config = new Config();
        config.useSingleServer().setAddress("redis://"+host+":"+port);
        return Redisson.create(config);
    }

    @Autowired
    RedissonClient redissonClient;


    @GetMapping("/get/maotai6")
    public String seckillMaotai6() {
    
    
        //要去获取锁
        RLock lock = redissonClient.getLock(lockey);
        lock.lock();
        try {
    
    
            Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
            //如果还有库存
            if (count > 0) {
    
    
                //抢到了茅台,库存减一
                redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
                //后续操作 do something
                log.info("我抢到茅台了!");
                return "ok";
            }else {
    
    
                return "no";
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            lock.unlock();;
        }
        return "";
    }

如果要用redis实现一把分布式锁,需要考虑的方面还是挺多的,比如可重入,redis命令的原子性,锁的续期,未获取到锁进行阻塞……


redisson

概述

Redisson内置了一系列的分布式对象分布式集合分布式锁分布式服务等诸多功能特性,是一款基于Redis实现,拥有一系列分布式系统功能特性的工具包,是实现分布式系统架构中缓存中间件的最佳选择。

实现

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.8.2</version>
</dependency>
@Bean
    public RedissonClient redissonClient() {
    
    
        Config config = new Config();
        config.useSingleServer().setAddress("redis://"+host+":"+port);
        return Redisson.create(config);
    }

    @Autowired
    RedissonClient redissonClient;


    @GetMapping("/get/maotai6")
    public String seckillMaotai6() {
    
    
        //要去获取锁
        RLock lock = redissonClient.getLock(lockey);
        lock.lock();
        try {
    
    
            Integer count = Integer.parseInt(redisTemplate.opsForValue().get(maotai)); // 1
            //如果还有库存
            if (count > 0) {
    
    
                //抢到了茅台,库存减一
                redisTemplate.opsForValue().set(maotai,String.valueOf(count-1));
                //后续操作 do something
                log.info("我抢到茅台了!");
                return "ok";
            }else {
    
    
                return "no";
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            lock.unlock();
        }
        return "";
    }

redission帮我们把这些细节都实现好了……
RLock lock = redissonClient.getLock(lockey);
lock.lock();
lock.unlock();;



Redis Cluster

主从 + 哨兵 问题分析

在这里插入图片描述

(1)在主从 + 哨兵模式中,仍然只有一个Master节点。当并发写请求较大时,哨兵模式并_不能缓解写压力_
(2) 在Redis Sentinel模式中,每个节点需要保存全量数据,冗余比较多

Cluster概念

从3.0版本之后,官方推出了Redis Cluster,它的主要用途是实现数据分片(Data Sharding),不过同样可以实现HA,是官方当前推荐的方案。
在这里插入图片描述

  • 1.Redis-Cluster采用无中心结构
  • 2.只有当集群中的大多数节点同时fail整个集群才fail。
  • 3.整个集群有**16384(0-16383)**个slot,当需要在 Redis 集群中放置一个 key-value 时,根据 CRC16(key) mod 16384的值,决定将一个key放到哪个桶中。读取一个key时也是相同的算法。
  • 4.当主节点fail时从节点会升级为主节点,fail的主节点online之后自动变成了从节点

故障转移

在这里插入图片描述

Redis集群的主节点内置了类似Redis Sentinel的节点故障检测和自动故障转移功能,当集群中的某个主节点下线时,集群中的其他在线主节点会注意到这一点,并对已下线的主节点进行故障转移。

集群分片策略

Redis-cluster分片策略,是用来解决key存储位置的
常见的数据分布的方式:顺序分布、哈希分布、节点取余哈希、一致性哈希…
在这里插入图片描述

Redis 集群的数据分片

Redis 集群没有使用一致性hash, 而是引入了 哈希槽的概念.
预设虚拟槽,每个槽就相当于一个数字,有一定范围
Redis Cluster中预设虚拟槽的范围为0到16383
在这里插入图片描述

步骤:

  • 1.把16384槽按照节点数量进行平均分配,由节点进行管理
  • 2.对每个key按照CRC16规则进行hash运算
  • 3.把hash结果对16383进行取余
  • 4.把余数发送给Redis节点
  • 5.节点接收到数据,验证是否在自己管理的槽编号的范围
    • 如果在自己管理的槽编号范围内,则把数据保存到数据槽中,然后返回执行结果
    • 如果在自己管理的槽编号范围外,则会把数据发送给正确的节点,由正确的节点来把数据保存在对应的槽中

需要注意的是:Redis Cluster的节点之间会共享消息,每个节点都会知道是哪个节点负责哪个范围内的数据槽
虚拟槽分布方式中,由于每个节点管理一部分数据槽,数据保存到数据槽中。当节点扩容或者缩容时,对数据槽进行重新分配迁移即可,数据不会丢失。


搭建Redis Cluster

步骤分析:

  • 启动节点:将节点以集群方式启动,此时节点是独立的。
  • 节点握手:将独立的节点连成网络。
  • 槽指派:将16384个槽位分配给主节点,以达到分片保存数据库键值对的效果。
  • 主从复制:为从节点指定主节点。

(1)新建目录,并拷贝出6个节点的配置文件

cd /usr/local/redis/
mkdir redis-cluster
cd redis-cluster
mkdir 900{1,2,3,4,5,6}

在这里插入图片描述

cd /usr/local/redis/redis-6.2.4
在这里插入图片描述

(2)将redis.conf,依次拷贝到每个900X目录内,并修改每个900X目录下的redis.conf配置文件:

以集群方式启动

cluster-enabled yes 将前面的 # 去掉

集群节点nodes信息配置文件(是自动生成的)

cluster-config-file nodes-6379.conf 修改为 cluster-config-file “/usr/local/redis/redis-cluster/nodes-9001.conf” # 对应各个端口

daemonize yes #后台启动

(3)启动6个Redis实例

[root@yida1 redis-cluster]# redis-server ./9001/redis.conf --port 9001
[root@yida1 redis-cluster]# redis-server ./9002/redis.conf --port 9002
[root@yida1 redis-cluster]# redis-server ./9003/redis.conf --port 9003
[root@yida1 redis-cluster]# redis-server ./9004/redis.conf --port 9004
[root@yida1 redis-cluster]# redis-server ./9005/redis.conf --port 9005
[root@yida1 redis-cluster]# redis-server ./9006/redis.conf --port 9006

查看进程:
在这里插入图片描述

节点握手&槽指派&主从复制
redis5.0使用redis-cli作为创建集群的命令,使用c语言实现,不再使用ruby语言。
1)有了实例后,搭建集群非常简单,使用redis-cli一行命令即可

#replicas表示副本数,如果指定1则表示1个从库做备用
[root@yida1 redis-cluster]# redis-cli --cluster create 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003 127.0.0.1:9004 127.0.0.1:9005 127.0.0.1:9006 --cluster-replicas 1

参数解释:

–cluster-replicas 1:表示希望为集群中的每个主节点创建一个从节点(一主一从)。 –cluster-replicas
2:表示希望为集群中的每个主节点创建两个从节点(一主二从)。

在这里插入图片描述

2)备注:如果节点上有数据,可能会有错误提示:

[ERR] Node 127.0.0.1:8004 is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0.

删除dump.rdb,nodes.conf,登录redis-cli,flushdb即可
查看cluster集群信息
在这里插入图片描述

在这里插入图片描述

#注意,redis-cli参数:

#-c : 自动重定向到对应节点获取信息,如果不加,只会返回重定向信息,不会得到值

在这里插入图片描述

扩容

1)按上面方式,新起一个redis , 9007端口

redis-server ./9007/redis.conf --port 9007

在这里插入图片描述

#第一个参数是新节点的地址,第二个参数是任意一个已经存在的节点的IP和端口
redis-cli --cluster add-node 127.0.0.1:9007 127.0.0.1:9001

在这里插入图片描述

在这里插入图片描述

重新分片

redis-cli --cluster reshard 127.0.0.1:9001

redis-cli --cluster reshard 127.0.0.1:9001 --cluster-from
10ac7df576168e7f6ec86b20b249e02b1fc13a25,43284b05c5a359b28507b49c29a49637f1f6312b,02a79c59682b7c05f13d41e46e814fc792fa2c50 --cluster-to 07e3416aba80cfb8a8ef81d27228559e5a9d6415 --cluster-slots 1024
#根据提示一步步进行,再次查看node分片,可以了!
在这里插入图片描述

平衡哈希槽

为了保证redis哈希槽的在每一个节点的均衡,需要对哈希槽进行均衡

redis-cli --cluster rebalance 127.0.0.1:9001

在这里插入图片描述

在这里插入图片描述


猜你喜欢

转载自blog.csdn.net/qq_40585384/article/details/127177881