Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题

目录

一、Redis 集群演变

1.1 Replication+Sentinel*高可用

1.2 Proxy+Replication+Sentinel(仅仅了解)

1.3 Redis Cluster 集群 (重点)

1.3.1 Redis-cluster架构图

1.3.2 工作原理

1.3.3 主从切换

1.3.4 副本漂移

1.3.5 分片漂移

二、Redis版本历史(增加了解)

三、Redis 5.0 源码清单 (对源码感兴趣的,看一下)

四、Redis和lua整合

4.1 什么是lua

4.2 Redis中使⽤lua的好处

4.3 lua的安装和语法

4.3.1 Redis整合lua脚本

4.4 lua 脚本调⽤Redis 命令

4.4.1 redis.call();

4.4.2 redis.pcall();

4.4.3 redis-cli --eval

4.5 Redis+lua 秒杀

五、Redis Stream

5.1 Redis Stream介绍

5.2 Redis Stream使⽤场景

六、Redis分布式

6.1 业务场景

6.2 锁的处理

6.3 分布式锁

6.3.1 分布式锁特点

6.3.2 分布式锁的实现⽅式

6.4 Redis⽅式实现分布式锁

6.4.1 获取锁

6.4.2 释放锁

6.4.3 Redis分布式锁--优缺点

6.4.4 本质分析

6.5 ⽣产环境中的分布式锁

6.5.1 加锁机制

6.5.2 Redisson分布式锁的使⽤

七、缓存常见问题

7.1 缓存预热

7.2 缓存雪崩

7.3 缓存击穿

7.4 缓存穿透

7.5 缓存降级

7.6 缓存更新

7.7 缓存数据库双写一致性 (重点)

7.7.1 先更新redis再更新db

7.7.2 先更新db再更新redis

7.7.3 先更新DB再删除redis

7.7.4 先删除redis再更新DB

7.7.5 延迟双删

7.7.6 思考变种

7.7.7 总结

7.8 多个系统同时操作(并发)Redis带来的数据问题

八、Redis 常见面试问题

8.1 Memcache特点

8.2 Reids 特点


首先是对上一篇文章的补充,接下来开始正题

一、Redis 集群演变

1.1 Replication+Sentinel*高可用

 这套架构使用的是社区版本推出的原生高可用解决方案,其架构图如下

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第1张图片

这里Sentinel的作用有三个:

监控:Sentinel 会不断的检查主服务器和从服务器是否正常运行。


通知:当被监控的某个Redis服务器出现问题,Sentinel通过API脚本向管理员或者其他的应用程序发送通知。


自动故障转移:当主节点不能正常工作时,Sentinel会开始一次自动的故障转移操作,它会将与失效主节点是主从关系的其中一个从节点升级为新的主节点,并且将其他的从节点指向新的主节点。

工作原理:

当Master宕机的时候,Sentinel会选举出新的Master,并根据Sentinel中client-reconfig-script脚本配置的内容,去动态修改VIP(虚拟IP),将VIP(虚拟IP)指向新的Master。我们的客户端就连向指定的VIP即可!故障发生后的转移情况,可以理解为下图

  Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第2张图片

缺陷:

(1)主从切换的过程中会丢数据

(2)Redis只能单点写,不能水平扩容

常用方案:

内网DNS,VIP和 封装客户端直连Redis Sentinel 端口

(1)内网DNS:

底层是 Redis Sentinel 集群,代理着 Redis 主,Web 端连接内网 DNS 提供服务。内网 DNS 按照一定的规则分配,比如 xxxx.redis.cache/queue.portxxx.xxx,第一个段表示业务简写,第二个段表示这是Redis 内网域名,第三个段表示 Redis 类型,cache 表示缓存,queue 表示队列,第四个段表示 Redis端口,第五、第六个段表示内网主域名。当主节点发生故障,比如机器故障、Redis 节点故障或者网络不可达,Sentinel集群会调用 client-reconfig- 配置的脚本,修改对应端口的内网域名。对应端口的内网域名指向新的 Redis 主节点。

优点:秒级切换,10秒之内 ,脚本自定义,架构可控,对应用透明,前端不用担心后端发生什么变化

缺点:维护成本高,依赖DNS,存在解析超时,哨兵存在短时间服务不可用,服务时通过外网不可采用

(2)VIP:

和第一种方案略有不同,把内网 DNS 换成了虚拟 IP。底层是 Redis Sentinel集群,代理着 Redis 主从,Web 端通过 VIP 提供服务。在部署 Redis 主从的时候,需要将虚拟P 绑定到当前的 Redis 主节点。当主节点发生故障,比如机器故障、Redis 节点故障或者网络不可达,Sentinel集群会调用 client-reconfig-配置的脚本,将VIP 漂移到新的主节点上。

优点:秒级切换,5秒之内 ;脚本自定义,架构可控,对应用透明,前端不用担心后端发生什么变化缺点:维护成本更高,使用VIP增加维护成本,并存在IP混乱风险

(3)封装客户端直连 Redis Sentinel 端口:

这个主要是因为有些业务只能通过外网访问 Redis,于是衍生出了这种方案。Web 使用客户端连接其中台 Redis Sentinel 集群中的一台机器的某个端口,然后通过这个端口获取到当前的主节点,然后再连接到真实的 Redis 主节点进行相应的业务员操作。需要注意的是,Redis Sentinel 端口和 Redis 主节点均需要开放访问权限。前端业务使用 lava,有JedisSentinelPool 可以复用。

优点: 服务探测故障及时,DBA维护成本低
缺点: 依赖客户端支持Sentinel:Sentinel 服务器和 Redis 节点需要开放访问权限;再有 对应用有侵入性

1.2 Proxy+Replication+Sentinel(仅仅了解)

这里的Proxy有两种选择:Codis (豌豆英) 和Twemproxy (推特)        

这套架构的时间为2015年,原因有二:

因为Codis开源的比较晚,考虑到更换组件的成本问题。毕竟本来运行好好的东西,你再去换组件,风险是很大的。

Redis Cluster在2015年还是试用版,不保证会遇到什么问题,因此不敢尝试

所以我没接触过Codis,之前一直用的是Twemproxy作为Proxy。这里以Twemproxy为例说明,如下图所示

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第3张图片

工作原理:

1.前端使用Twemproxy+KeepAlived做代理,将其后端的多台Redis实例分片进行统一管理与分配。

2.每一个分片节点的Slave都是Master的副本且只读

3.Sentinel持续不断的监控每个分片节点的Master,当Master出现故障且不可用状态时,Sentinel会通知/启动自动故障转移等动作


4.Sentinel 可以在发生故障转移动作后触发相应脚本 (通过 client-reconfig-script 参数配置),脚本获取到最新的Master来修改Twemproxy配置

缺陷:
(1)部署结构超级复杂
(2)可扩展性差,进行扩缩容需要手动干预
(3)运维不方便

1.3 Redis Cluster 集群 (重点)

这一章的其他内容请在Redis从基础到进阶篇(三)----架构原理与集群演变 中阅读,这里是对第三篇进行的补充

1.3.1 Redis-cluster架构图

    Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第4张图片

1.3.2 工作原理

1.客户端与Redis节点直连,不需要中间Proxy层,直接连接任意一个Master节点根据公式

2.HASH_SLOT=CRC16(key) mod 16384,计算出映射到哪个分片上,然后Redis会去相应的节点进行操作

优点:

(1)无需Sentinel哨兵监控,如果Master挂了,Redis Cluster内部自动将Slave切换Master

(2)可以进行水亚扩容

(3)支持自动化迁移,当出现某个slave宕机了,那么就只有Master了,这时候的高可用性就无法很好的保证了,万一Master也宕机了,咋办呢? 针对这种情况,如果说其他Master有多余的Slave,集群自动把多余的slave迁移到没有slave的Master 中

缺点:

(1)批量操作是个坑
(2)资源隔离性较差,容易出现相互影响的情况.

1.3.3 主从切换

        当集群中节点通过错误检测机制发现某个节点处于fail状态时,会执行主从切换。Redis 还提供了手动切换的方法,即通过执行 cluster failover 命令

自动切换:

切换流程如下 (假设被切换的主节点为M,执行切换的从节点为S)

1. s先更新自己的状态,将声明自己为主节点。并且将s从M中移除
2. 由于s需要切换为主节点,所以将s的同步数据相关信息清除 (即不再从M同步锁数据)
3. 将M提供服务的slot都声明到s中:.
4. 发送一个PONG包,通知集群中其他节点更新状态

手动切换:

当一个节点接受到 cluster failove 命令之后,执行手动切换,流程如下

1. 该从节点首先向主节点发送一个mfstart包。通知主节点从节点开始进行手动切换
2. 主节点会阻塞所有客户端指令的执行。之后主节点在周期函数clusterCron中发送ping 包时会在包头部分做特殊标记
3. 当从节点收到主节点的ping包并且检测到特殊标记之后,会从包头中获取主节点的复制偏移量

4. 从节点在周期函数clusterCron中检测当前处理的复制偏移量与主节点复制偏移量是否相等,当相等时开始执行切换流程

5. 切换完成后,主节点会讲阻塞的所有客户端命令通过发送+MOVED 指令重定向到新的主节点

通过流程可以看到,手动执行主从切换流程时不会丢失任何数据,也不会丢失任何执行命令,只在切换过程中会有暂时的停顿

1.3.4 副本漂移

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第5张图片

假设A发生故障,主A的A1会执行切换,切换完成后A1变为A1,此时主A1会出现单点问题
在周期性调度函数 clusterCron中会定期检查如下条件

1是否存在单点的主节点,即主节点没有任何一台可用的从节点
2是否存在有两台及以上可用从节点的主节点


如果以上两个条件都满足,从有最多可用从节点中选择一台从节点执行副本漂移。选择标准为按节点名称从小到大,选择最靠前的一台从节点执行漂移。具体漂移过程

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第6张图片

3.从C的记录中将C1移除

4.将C1所记录的主节点更改为A1

5.在A1中添加C1从节点

6.将C1的数据同步源设置为A1

漂移过程只是更改一些节点所记录的信息,之后会通过心跳包将该信息同步到所有的集群节点。

1.3.5 分片漂移

这点也请查看上一篇文章的6.7小节添加节点

二、Redis版本历史(增加了解)

1.Redis2.6 Redis2.6在2012年正式发布
2.Redis2.8
   Redis2.8在2013年11月22日正式发布
   Redis Sentinel第二版,相比于Redis2.6的Redis Sentinel,此版本已经变成生产可用。
3.Redis3.0
   Redis3.0在2015年4月1日正式发布

4.Redis3.2
   Redis3.2在2016年5月6日正式发布,集群高可用
5.Redis4.0
   Redis 4.0在2017年7月发布为GA,主要是增加了混合持久化和LFU淘汰策略
6.Redis5.0
   Redis5.0 2018年10月18日正式发布,stream 是重要新增特性

三、Redis 5.0 源码清单 (对源码感兴趣的,看一下)

1.基本数据结构
   动态字符串sds.c
   整数集合intset.c
   压缩列表ziplist.c
   快速链表quicklist.c
   字典dict.c

2.Redis数据类型的底层实现
   Redis对象object.c
   字符串t_string.c
   列表t list.c
   字典t_hash.c
   集合及有序集合t set.c和t_zset

3.Redis数据库的实现
   数据库的底层实现db.c
   持久化rdb.c和aof.c

4.Redis服务端和客户端实现
   事件驱动ae.c和ae_epoll.c
   网络连接anet.c和networking.c
   服务端程序server.c
   客户端程序redis-cli.c

5. 集群相关
    主从复制replication.c

    哨兵sentinel.c

    集群cluster.c

6.特殊数据类型

   其他数据结构,如hyperloglog.c、geo.c

   数据流t stream.c
   streams的底层实现结构listpack.c和rax.c

四、Redis和lua整合

4.1 什么是lua

lua 是⼀种轻量⼩巧的 脚本语⾔ ,⽤标准 C 语⾔ 编写并以源代码形式开放, 其设计⽬的是为了嵌⼊应⽤程序中,从⽽为应⽤程序提供灵活的扩展和定制功能。

4.2 Redis中使⽤lua的好处

1. 减少⽹络开销 ,在 Lua 脚本中可以把多个命令放在同⼀个脚本中运⾏
2. 原⼦操作 redis 会将整个脚本作为⼀个整体执⾏,中间不会被其他命令插⼊。换句话说,编写脚本 的过程中⽆需担⼼会出现竞态条件。 隔离性
3. 复⽤性 ,客户端发送的脚本会永远存储在 redis 中,这意味着其他客户端可以复⽤这⼀脚本来完成同样的逻辑

4.3 lua的安装和语法

lua 教程 https://www.runoob.com/lua/lua-tutorial.html

4.3.1 Redis整合lua脚本

Redis2.6.0 版本开始,通过 内置的 lua 编译 / 解释器 ,可以使⽤ EVAL 命令对 lua 脚本进⾏求值。

EVAL命令

(1) 在redis客户端中,执⾏以下命令:
EVAL script numkeys key [key ...] arg [arg ...]

命令说明: 

script 参数: 是⼀段 Lua 脚本程序,它会被运⾏在 Redis 服务器上下⽂中,这段脚本不必 ( 也不
应该 ) 定义为⼀个 Lua 函数。
numkeys 参数: ⽤于指定键名参数的个数。
key [key ...] 参数: EVAL 的第三个参数开始算起,使⽤了 numkeys 个键( key ),表示在脚本中所⽤到的那些Redis (key) ,这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,⽤ 1 为基址的形式访问( KEYS[1] KEYS[2] ,以此类推 )
arg [arg ...] 参数: 可以在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似 ( ARGV[1] 、 ARGV[2] ,诸如此类 )
例如
./redis-cli
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

4.4 lua 脚本调⽤Redis 命令

4.4.1 redis.call();

返回值就是 redis 命令执⾏的返回值
如果出错,返回错误信息,不继续执⾏

4.4.2 redis.pcall();

返回值就是 redis 命令执⾏的返回值
如果出错了 记录错误信息,继续执⾏
注意事项:
在脚本中,使⽤ return 语句将返回值返回给客户端,如果没有 return ,则返回 nil
示例:
127.0.0.1:6379> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK

4.4.3 redis-cli --eval

可以使⽤ redis-cli --eval 命令指定⼀个 lua 脚本⽂件去执⾏。
脚本⽂件 (redis.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
redis 客户机,执⾏脚本命令:
[root@localhost bin]# ./redis-cli --eval redis.lua lua:incrbyml , 8
(integer) 0
[root@localhost bin]# ./redis-cli incr lua:incrbyml
(integer) 1
[root@localhost bin]# ./redis-cli --eval redis.lua lua:incrbyml , 8
(integer) 8
[root@localhost bin]# ./redis-cli --eval redis.lua lua:incrbyml , 8
(integer) 64
[root@localhost bin]# ./redis-cli --eval redis.lua lua:incrbyml , 2
(integer) 128
[root@localhost bin]# ./redis-cli
命令格式说明:
--eval :告诉 redis 客户端去执⾏后⾯的 lua 脚本
redis.lua :具体的 lua 脚本⽂件名称
lua:incrbymul : lua 脚本中需要的 key
8 lua 脚本中需要的 value
注意事项:
上⾯命令中 keys values 中间需要使⽤逗号隔开,并且逗号两边都要有空格
执⾏ .lua 脚本 不需要写 key 的个数

4.5 Redis+lua 秒杀

秒杀场景经常使⽤这个东⻄,主要利⽤他的原⼦性
1.⾸先定义redis数据结构
goodId:
 {
    "total":100,
    "released":0;
 }
其中 goodId 为商品 id 号,可根据此来查询相关的数据结构信息, total 为总数, released 为发放出去
的数量,可使⽤数为 total-released
2.编写lua脚本
local n = tonumber(ARGV[1])
if not n or n == 0 then
    return 0
end
    local vals = redis.call("HMGET", KEYS[1], "total", "released");
    local total = tonumber(vals[1])
    local blocked = tonumber(vals[2])
if not total or not blocked then
    return 0
end
    if blocked + n <= total then
    redis.call("HINCRBY", KEYS[1], "released", n)
    return n;
end
return 0
执⾏脚本命令 EVAL script_string 1 goodId apply_count
若库存⾜够则返回申请的数量,否则返回0,不返回可满⾜的剩余数
3.spring boot 调⽤
pom dependency
org.springframework.boot 
spring-boot-starter-data-redis 
2.0.1.RELEASE
long count = redisHelper.getStrCache().execute(new RedisCallback() {
     @Nullable
     @Override
     public Long doInRedis(RedisConnection redisConnection) throws DataAccessException {
         long ret =redisConnection.eval(script.getScriptAsString().getBytes(),ReturnType.INTEGER, 1, key.getBytes(), String.valueOf(count).getBytes());
         return ret;
     }
 });
4.redis->database
针对redis到databases的更新,思考了很久,没有找到较好的解决办法,先采⽤定时任务异步更新。⾄于数据是否丢失的问题,如果redis挂了,重启后redis会恢复数据,等下次定时任务就可以 将数据库中的数据保持⼀致,缺点是redis挂了秒杀活动会失败。
⾄于 redis database 更新⽅案:
redis 存⼀份相关 hash 键名单表,通过读取名单表来读取更新
通过流式读取 databases 中的表来读取更新。

五、Redis Stream

5.1 Redis Stream介绍

Redis 5.0 全新的数据类型: streams ,官⽅把它定义为:以更抽象的⽅式建模⽇志的数据结构。 Redis 的streams 主要是⼀个 append only AOF )的数据结构,⾄少在概念上它是⼀种在内存中表示的抽象 数据类型,只不过它们实现了更强⼤的操作,以克服⽇志⽂件本身的限制。
如果你了解 MQ ,那么可以把 streams 当做基于内存的 MQ 。如果你还了解 kafka ,那么甚⾄可以把streams当做基于内存的 kafka listpack 存储信息, Rax 组织 listpack 消息链表
listpack 是对 ziplist 的改进,它⽐ ziplist 少了⼀个定位最后⼀个元素的属性
另外,这个功能有点类似于 redis 以前的 Pub/Sub ,但是也有基本的不同:
1.streams ⽀持多个客户端(消费者)等待数据( Linux 环境开多个窗⼝执⾏ XREAD 即可模拟),并 且每个客户端得到的是完全相同的数据。
2.Pub/Sub 是发送忘记的⽅式,并且不存储任何数据; streams 模式下,所有消息被⽆限期追加在 streams 中,除⾮⽤于显式执⾏删除( XDEL XDEL 只做⼀个标记位 其实信息和⻓度还在。
3.streams Consumer Groups 也是 Pub/Sub ⽆法实现的控制⽅式。
它主要有消息、⽣产者、消费者、消费组 4 组成
streams 数据结构本身⾮常简单,但是 streams 依然是 Redis 到⽬前为⽌最复杂的类型,其原因是实现的 ⼀些额外的功能:⼀系列的阻塞操作允许消费者等待⽣产者加⼊到streams 的新数据。另外还有⼀个称为Consumer Groups 的概念, Consumer Group 概念最先由 kafka 提出, Redis 有⼀个类似实现,和kafka的 Consumer Groups 的⽬的是⼀样的:允许⼀组客户端协调消费相同的信息流!
发布消息
127.0.0.1:6379> xadd mystream * message apple
"1589994652300-0"
127.0.0.1:6379> xadd mystream * message orange
"1589994679942-0"
读取消息
127.0.0.1:6379> xrange mystream - + 
1) 1) "1589994652300-0"
   2) 1) "message"
      2) "apple"
2) 1) "1589994679942-0"
   2) 1) "message"
      2) "orange"
阻塞读取
xread block 0 streams mystream $
发布新消息
127.0.0.1:6379> xadd mystream * message strawberry
创建消费组
127.0.0.1:6379> xgroup create mystream mygroup1 0
OK
127.0.0.1:6379> xgroup create mystream mygroup2 0
OK
通过消费组读取消息
127.0.0.1:6379> xreadgroup group mygroup1 zange count 2 streams mystream >
1) 1) "mystream"
   2) 1) 1) "1589994652300-0"
         2) 1) "message"
             2) "apple"
      2) 1) "1589994679942-0"
         2) 1) "message"
            2) "orange"

127.0.0.1:6379> xreadgroup group mugroup1 tuge count 2 streams mystream >
1) 1) "mystream"
   2) 1) 1) "1589995171242-0"
         2) 1) "message"
            2) "strawberry"
 
127.0.0.1:6379> xreadgroup group mugroup2 tuge count 1 streams mystream >
1) 1) "mystream"
   2) 1) 1) "1589995171242-0"
         2) 1) "message"
            2) "apple"

5.2 Redis Stream使⽤场景

可⽤作时通信等,⼤数据分析,异地数据备份等

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第7张图片

客户端可以平滑扩展,提⾼处理能⼒

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第8张图片

六、Redis分布式

6.1 业务场景

1 、库存超卖 ⽐如 5 个笔记本 A 准备买 3 B 2 C 4 ⼀下单 3+2+4 =9
2 、防⽌⽤户重复下单
3 MQ 消息去重
4 、订单操作变更
分析:
业务场景共性:
共享资源竞争
    ⽤户 id 、订单 id 、商品 id 。。。
解决⽅案:
    共享资源互斥
    共享资源串⾏化
问题转化
    锁的问题 (将需求抽象后得到问题的本质)

6.2 锁的处理

单应⽤中使⽤锁:(单进程多线程)
synchronize ReentrantLock
分布式应⽤中使⽤锁:(多进程多线程)
分布式锁是控制分布式系统之间同步访问共享资源的⼀种⽅式。

6.3 分布式锁

流程图

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第9张图片

分布式锁的状态:
1. 客户端通过竞争获取锁才能对共享资源进⾏操作 ( ①获取锁 )
2. 当持有锁的客户端对共享资源进⾏操作时(②占有锁)
3. 其他客户端都不可以对这个资源进⾏操作(③阻塞)
4. 直到持有锁的客户端完成操作( ④释放锁 )

6.3.1 分布式锁特点

互斥性
        在任意时刻,只有⼀个客户端可以持有锁(排他性)
⾼可⽤,具有容错性
        只要锁服务集群中的⼤部分节点正常运⾏,客户端就可以进⾏加锁解锁操作
避免死锁
        具备锁失效机制,锁在⼀段时间之后⼀定会释放。(正常释放或超时释放)
加锁和解锁为同⼀个客户端

        ⼀个客户端不能释放其他客户端加的锁了

6.3.2 分布式锁的实现⽅式

基于数据库实现分布式锁
基于 zookeeper 时节点的分布式锁
基于 Redis 的分布式锁
基于 Etcd 的分布式锁

6.4 Redis⽅式实现分布式锁

6.4.1 获取锁

Redis2.6.12 版本之前,使⽤ Lua 脚本保证原⼦性,获取锁代码

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第10张图片

Redis2.6.12 版本开始,使⽤ Set ⼀个命令实现加锁,获取锁代

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第11张图片

6.4.2 释放锁

redis+lua 脚本
public static boolean releaseLock(String lockKey, String requestId) {
     String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
    redis.call('del', KEYS[1]) else return 0 end";
     Object result = jedis.eval(script, Collections.singletonList(lockKey),
    Collections.singletonList(requestId));
     if (result.equals(1L)) {
         return true;
     }
     return false;
 }

6.4.3 Redis分布式锁--优缺点

优点:
        Redis是基于内存存储,并发性能好。
缺点:
        1. 需要考虑原⼦性、超时、误删等情形。
        2. 获锁失败时,客户端只能⾃旋等待,在⾼并发情况下,性能消耗⽐较⼤。
rediscluster--redis主从复制的坑
redis ⾼可⽤最常⻅的⽅案就是 主从复制 (master-slave ),这种模式也给 redis 分布式锁 挖了⼀坑。 redis cluster 集群环境下,假如现在 A 客户端 想要加锁,它会根据路由规则选择⼀台 master 节点写⼊ key mylock ,在加锁成功后, master 节点会把 key 异步复制给对应的 slave 节点。如果此时 redis master 节点宕机,为保证集群可⽤性,会进⾏ 主备切换 slave 变为了 redis master 。 B 客户端 在新的 master 节点上加锁成功,⽽ A 客户端 也以为⾃⼰还是成功加了锁的。 此时就会导致同⼀时间内多个客户端对⼀个分布式锁完成了加锁,导致各种脏数据的产⽣。⾄于解决办法嘛,⽬前看还没有什么根治 的⽅法, 只能尽量保证机器的稳定性 ,减少发⽣此事件的概率。

6.4.4 本质分析

CAP 模型分析
P:容错
A:  ⾼可⽤
C:⼀致性
在分布式环境下不可能满⾜三者共存,只能满⾜其中的两者共存,在分布式下 P 不能舍弃 ( 舍弃 P 就是单机了)
所以只能是 CP (强⼀致性模型)和 AP( ⾼可⽤模型 )
分布式锁是 CP 模型, Redis 集群是 AP 模型。 (base)
为什么还可以⽤Redis实现分布式锁?
与业务有关
当业务不需要数据强⼀致性时,⽐如:社交场景,就可以使⽤ Redis 实现分布式锁
当业务必须要数据的强⼀致性,即不允许重复获得锁,⽐如⾦融场景(重复下单,重复转账)就不要使⽤
可以使⽤ CP 模型实现,⽐如: zookeeper etcd

6.5 ⽣产环境中的分布式锁

⽬前落地⽣产环境⽤分布式锁,⼀般采⽤开源框架,⽐如 Redisson 。下⾯来讲⼀下 Redisson Redis 分布式锁的实现。
Redisson 分布式锁的实现原理

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第12张图片

6.5.1 加锁机制

如果该客户端⾯对的是⼀个 redis cluster 集群,他⾸先会根据 hash 节点选择⼀台机器。
发送 lua 脚本到 redis 服务器上,脚本如下 :
"if (redis.call('exists',KEYS[1])==0) then "+
 "redis.call('hset',KEYS[1],ARGV[2],1) ; "+
 "redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
 "return nil; end ;" +
"if (redis.call('hexists',KEYS[1],ARGV[2]) ==1 ) then "+
 "redis.call('hincrby',KEYS[1],ARGV[2],1) ; "+
 "redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
 "return nil; end ;" +
"return redis.call('pttl',KEYS[1]) ;"
lua 的作⽤:保证这段复杂业务逻辑执⾏的原⼦性。
lua 的解释:
KEYS[1]) : 加锁的 key
ARGV[1] key 的⽣存时间,默认为 30
ARGV[2] : 加锁的客户端 ID ( UUID.randomUUID() + “:” + threadId )
第⼀段 if 判断语句,就是⽤ “exists myLock” 命令判断⼀下,如果你要加锁的那个锁 key 不存在的话,你就进⾏加锁。如何加锁呢?很简单,⽤下⾯的命令:
hset myLock
8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置⼀个 hash 数据结构,这⾏命令执⾏后,会出现⼀个类似下⾯的数据结构:
myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1 }
上述就代表 “8743c9c0-0795-4907-87fd-6c719a6b4586:1” 这个客户端对 “myLock” 这个锁 key 完成了加锁。
接着会执⾏ “pexpire myLock 30000” 命令,设置 myLock 这个锁 key 的⽣存时间是 30 秒。

(1) 锁互斥机制

那么在这个时候,如果客户端 2 来尝试加锁,执⾏了同样的⼀段 lua 脚本,会咋样呢?
很简单,第⼀个 if 判断会执⾏ “exists myLock” ,发现 myLock 这个锁 key 已经存在了。
接着第⼆个 if 判断,判断⼀下, myLock key hash 数据结构中,是否包含客户端 2 ID ,但是明显不是的,因为那⾥包含的是客户端1 ID
所以,客户端 2 会获取到 pttl myLock 返回的⼀个数字,这个数字代表了 myLock 这个锁 key 剩余⽣存时 间。 ⽐如还剩 15000 毫秒的⽣存时间。
此时客户端 2 会进⼊⼀个 while 循环,不停的尝试加锁。

(2) ⾃动延时机制

只要客户端 1 ⼀旦加锁成功,就会启动⼀个 watch dog 看⻔狗, 他是⼀个后台线程,会每隔 10 秒检查⼀ ,如果客户端 1 还持有锁 key ,那么就会不断的延⻓锁 key 的⽣存时间。

(3) 可重⼊锁机制

第⼀个 if 判断肯定不成⽴, “exists myLock” 会显示锁 key 已经存在了。
第⼆个 if 判断会成⽴,因为 myLock hash 数据结构中包含的那个 ID ,就是客户端 1 的那个 ID ,也就是 “8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执⾏可重⼊加锁的逻辑,他会⽤:
incrby myLock
8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端 1 的加锁次数,累加 1 。数据结构会变成:
myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2 }

(4) 释放锁机制

执⾏ lua 脚本如下:
#如果key已经不存在,说明已经被解锁,直接发布(publish)redis消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
     "redis.call('publish', KEYS[2], ARGV[1]); " +
     "return 1; " +
    "end;" +
# key和field不匹配,说明当前客户端线程没有持有锁,不能主动解锁。
     "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
         "return nil;" +
     "end; " +
# 将value减1
     "local counter = redis.call('hincrby', KEYS[1], ARGV[3],-1); " +
# 如果counter>0说明锁在重⼊,不能删除key
     "if (counter > 0) then " +
         "redis.call('pexpire', KEYS[1], ARGV[2]); " +
         "return 0; " +
# 删除key并且publish 解锁消息
     "else " +
         "redis.call('del', KEYS[1]); " +
         "redis.call('publish', KEYS[2], ARGV[1]); " +
         "return 1; "+
      "end; " +
      "return nil;",
– KEYS[1] :需要加锁的 key ,这⾥需要是字符串类型。
– KEYS[2] redis 消息的 ChannelName, ⼀个分布式锁对应唯⼀的⼀个 channelName:
“redisson_lock channel {” + getName() + “}”
– ARGV[1] reids 消息体,这⾥只需要⼀个字节的标记就可以,主要标记 redis key 已经解锁,再结合redis的 Subscribe ,能唤醒其他订阅解锁消息的客户端线程申请锁。
– ARGV[2] :锁的超时时间,防⽌死锁
– ARGV[3] :锁的唯⼀标识,也就是刚才介绍的 id UUID.randomUUID() + “:” + threadId
如果执⾏ lock.unlock() ,就可以释放分布式锁,此时的业务逻辑也是⾮常简单的。
其实说⽩了,就是每次都对 myLock 数据结构中的那个加锁次数减 1
如果发现加锁次数是 0 了,说明这个客户端已经不再持有锁了,此时就会⽤:
“del myLock” 命令,从 redis ⾥删除这个 key
然后呢,另外的客户端 2 就可以尝试完成加锁了。

6.5.2 Redisson分布式锁的使⽤

(1) 加⼊jar包的依赖


   org.redisson
   redisson
   3.7.2

(2) 配置Redisson

 private static Config config = new Config();
     //声明redisso对象
     private static Redisson redisson = null;
     //实例化redisson
    static{
         config.useClusterServers()
        // 集群状态扫描间隔时间,单位是毫秒
         .setScanInterval(2000)
         //cluster⽅式⾄少6个节点(3主3从,3主做sharding,3从⽤来保证主宕机后可以⾼可⽤)
         .addNodeAddress("redis://127.0.0.1:6379" )
         .addNodeAddress("redis://127.0.0.1:6380")
         .addNodeAddress("redis://127.0.0.1:6381")
         .addNodeAddress("redis://127.0.0.1:6382")
         .addNodeAddress("redis://127.0.0.1:6383")
         .addNodeAddress("redis://127.0.0.1:6384");
 
         //得到redisson对象
         redisson = (Redisson) Redisson.create(config);
    }
    //获取redisson对象的⽅法
     public static Redisson getRedisson(){
         return redisson;
     }
}

(3) 锁的获取和释放 

public class DistributedRedisLock {
     //从配置类中获取redisson对象
     private static Redisson redisson = RedissonManager.getRedisson();
     private static final String LOCK_TITLE = "redisLock_";
     //加锁
     public static boolean acquire(String lockName){
         //声明key对象
         String key = LOCK_TITLE + lockName;
         //获取锁对象
         RLock mylock = redisson.getLock(key);
         //加锁,并且设置锁过期时间3秒,防⽌死锁的产⽣ uuid+threadId
         mylock.lock(3,TimeUtil.SECOND);
         //加锁成功
         return true;
     }
     //锁的释放
     public static void release(String lockName){
         //必须是和加锁时的同⼀个key
         String key = LOCK_TITLE + lockName;
         //获取锁对象
         RLock mylock = redisson.getLock(key);
         //释放锁(解锁)
         mylock.unlock();
 
     }
}

(4) 业务逻辑中使⽤分布式锁

public String discount() throws IOException{
     String key = "test123";
     //加锁
     DistributedRedisLock.acquire(key);
     //执⾏具体业务逻辑
     dosoming
     //释放锁
     DistributedRedisLock.release(key);
     //返回结果
     return soming;
 }

七、缓存常见问题

7.1 缓存预热

1. 直接写个缓存刷新页面,上线前手工操作一下
2. 数据量不大的时候,可以在项目启动的时候自动加载
3. 定时刷新缓存

7.2 缓存雪崩

什么叫缓存雪崩?

当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统 ( 比如 DB)带来很大压力。
如何解决?
1 :在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个 key 只允许一个线程查询数据和写缓存,其他线程等待。
2 :不同的 key ,设置不同的过期时间,让缓存失效的时间点尽量均匀。
setRedis Key value time + Math.random() * 10000 ) 代码
3 :做二级缓存, A1 为原始缓存, A2 为拷贝缓存, A1 失效时,可以访问 A2 A1 缓存失效时间设置为短期,A2设置为长期(此点为补充)
多级缓存 头条的时候 文章 - 热点文章
CDN Nginx 启动 THP 拒敌于国门之外

7.3 缓存击穿

什么叫缓存击穿?
对于一些设置了过期时间的 key ,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常 热点” 的数据。这个时候,需要考虑一个问题:缓存被 击穿 的问题,这个和缓存雪崩的区别在于这里针对某一key 缓存,前者则是很多 key
缓存在某个时间点过期的时候,恰好在这个时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮。
如何解决?
使用 redis setnx 互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。
if(redis.sexnx()==1){
        //先查询缓存
        //查询数据库
        //加入缓存
}
设置监控 当某个热点是超级热点 一般设置为持久化

7.4 缓存穿透

什么叫缓存穿透?
一般的缓存系统,都是按照 key 去缓存查询,如果不存在对应的 value ,就应该去后端系统查找(比如DB)。如果 key 对应的 value 是一定不存在的,并且对该 key 并发请求量很大,就会对后端系统造成很大的压力。
也就是说,对不存在的 key 进行高并发访问,导致数据库压力瞬间增大,这就叫做【缓存穿透】。
如何解决?
1. 在服务器端,接收参数时业务接口中过滤不合法的值, null ,负值,和空值进行检测和空值。
2.bloom filter :类似于哈希表的一种算法,用所有可能的查询条件生成一个 bitmap ,在进行数据库查询之前会使用这个bitmap 进行过滤,如果不在其中则直接过滤,从而减轻数据库层面的压力。采用的是一票否决 只要有一个认为你不存在 就认为你是不存在的
3. 空值缓存:一种比较简单的解决办法,在第一次查询完不存在的数据后,将该 key 与对应的空值也放入缓存中,只不过设定为较短的失效时间,例如几分钟,这样则可以应对短时间的大量的该key 攻击,设置为较短的失效时间是因为该值可能业务无关,存在意义不大,且该次的查询也未必是攻击者发起,无过久存储的必要,故可以早点失效
缓存降级的使用 我们在缓存中没有数据的时候,会给一个随机值。 水贴,随贴。

7.5 缓存降级

当访问量出现剧增、服务出现问题(相应时间慢或者不响应) 或非核心业务影响到核心流程的性能,还需要保证服务的可用性,即便有损服务。
11 的时候 一般会对 退款 降级。
方式: 系统根据一些关键数据进行降级
            配置开关实现人工降级
有些服务时无法降级(加入购物车,结算)
参考日志级别:
一般: ex 有些服务偶尔网络抖动或者服务正在上线超时,可以自定降级.
警告:有些服务在一端时间内有波动( 95%-100% ),可以自定降级或人工降级,还有发送
告警.
错误:可利用率低于 90% redis 连接池被打爆了,数据库连接池被打爆,或者访问量突然猛
增到系统能承受的最大阈值,这时候根据情况自动降级或人工降级.
严重错误:比如因为特殊原因数据错误了,需要紧急人工降级。 redis服务出问题了, 不去查数据库,而是直接返回一个默认值(自定义一些随机值).

7.6 缓存更新

自定义的缓存淘汰策略:
1. 定期去清理过期的缓存
2. 当有用户请求过来时,先判断这个请求用到的缓存是否过期,过期的话就去底层系统得到新数据进行缓存更新

7.7 缓存数据库双写一致性 (重点)

DB KV

双写 就一定会出现数据一致性问题

一般来说,在读取缓存方面,我们都是先读取缓存,再读取数据库的。
但是,在更新缓存方面,我们是需要先更新缓存,再更新数据库?还是先更新数据库,再更新缓存?还 是说有其他的方案?

7.7.1 先更新redis再更新db

按下面步骤会有问题 ,AB 是两个线程
A_update_redis 
B_update_redis 
B_update_db 
A_update_db
最终 db a 值但是 redis b 值,不一致

7.7.2 先更新db再更新redis

A_update_db 
B_update_db 
B_update_redis 
A_update_redis
最终 db b 值但是 redis a

7.7.3 先更新DB再删除redis

A_update_db 
B_update_db 
B_rm_redis 
A_rm_redis
是不是不明白。想不出来怎么不一致了?
不是这样的,没这么简单,第二次 rm_redis 就会保证后面的 redis db 是一致的
实际是下面这种形式
A_get_data 
redis_cache_miss 
A_get_db 
B_update_db 
B_rm_redis 
(此时如果拿db是b值,但是redis没有值) 
A_update_redis
依赖于 A_update_redis B_update_db 之后,极端情况
此时 redis old db new

7.7.4 先删除redis再更新DB

A_rm_redis 
B_get_data 
B_redis_miss
B_get_db 
B_update_redis 
A_update_db
此时 redis old 值, db new

7.7.5 延迟双删

rm_redis 
update_db 
sleep xxx ms 
rm_redis
这样叫做双删,最后一次 sleep 一段时间再 rm_redis 保证再次读请求回溯打到 db ,用最新值写 redis

7.7.6 思考变种

上面的 3 5 情况可以直接变种,即
update_db 
sleep xxx ms 
rm_redis
解决了 3 中的极端情况(靠 sleep 解决),
并且减少 5 中第一次不必要的 rm redis 请求
当然,这个 rm_redis 还可以考虑异步化(提高吞吐)以及重试(避免异步处理失败),这里不展示

7.7.7 总结

db 回源到 redis ,需要考虑上面这些极端情况的 case
适用场景
当然这些极端情况本身要求同一个 key 是多写的,这个根据业务需求来看是否需要,比如某些场景本身就是写少读多的

7.8 多个系统同时操作(并发)Redis带来的数据问题

系统 A B C 三个系统,分别去操作 Redis 的同一个 Key ,本来顺序是 1 2 3 是正常的,但是因为系统A网络突然抖动了一下, B C 在他前面操作了 Redis ,这样数据不就错了么。
就好比下单,支付,退款三个顺序你变了,你先退款,再下单,再支付,那流程就会失败,那数据不就乱了?你订单还没生成你却支付,退款了?明显走不通了,这在线上是很恐怖的事情。
这种情况怎么解决呢?
可以找个管家帮我们管理好数据的嘛!

Redis从基础到进阶篇(四)----性能调优、分布式锁与缓存问题_第13张图片

某个时刻,多个系统实例都去更新某个 key 。可以基于 Zookeeper 实现分布式锁。每个系统通过 Zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 Key ,别人都不允许读和写。
要写入缓存的数据,都是从 MySQL 里查出来的,都得写入 MySQL 中,写入 MySQL 中的时候必须保 存一个时间戳,从 MySQL 查出来的时候,时间戳也查出来
每次要 写之前,先判断 一下当前这个 Value 的时间戳是否比缓存里的 Value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据

八、Redis 常见面试问题

8.1 Memcache特点

MC 处理请求时使用多线程异步 IO 的方式,可以合理利用 CPU 多核的优势,性能非常优秀;
MC 功能简单,使用内存存储数据
MC 对缓存的数据可以设置失效期,过期后的数据会被清除;
失效的策略采用延迟失效,就是当再次使用数据时检查是否失效;
当容量存满时,会对缓存中的数据进行剔除,剔除时,除了会对过期 key 进行清理,还会按 LRU 策略对数据进行剔除。
MC 有一些限制,这些限制在现在的互联网场景下很致命,成为大家选择 Redis MongoDB 的重要原因:
key 不能超过 250 个字节
value 不能超过 1M 字节
key 的最大失效时间是 30 天;
只支持 K-V 结构,不提供持久化和主从同步功能
MC 没有原生的集群,可以依靠客户端实现往集群中做分片写入数据。

8.2 Reids 特点

MC 不同的是, Redis 采用单线程模式处理请求。这样做的原因有 2 个:一个是因为采用了非阻塞的异步事件处理机制;另一个是缓存数据都是内存操作 IO 时间不会太长,单线程可以避免线程上下文切换产生的代价。
Redis 支持持久化,所以 Redis 不仅仅可以用作缓存,也可以用作 NoSQL 数据库。
相比 MC Redis 还有一个非常大的优势,就是除了 K-V 之外,还支持多种数据格式,例如 list 、set、 sorted set hash 等。
Redis 提供主从同步机制,以及 Cluster 集群部署能力,能够提供高可用服务。
MC redis 的性能对比
存储小数据时候 Redis 性能是比 MC 性能高
100K 以上 ,MC 的性能是高于 Redis
MC 本身没有集群功能,可以使用客户端做分片

你可能感兴趣的:(Redis,缓存,redis,分布式)