性能调优-Redis-思维导图

Redis Xmind 小结

01-Redis核心数据结构与高性能原理

1.Redis是单线程吗?

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

2.Redis单线程如何处理那么多的并发客户端连接?

Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器

socket连接   -->   IO多路复用程序   -->   文件事件分派器   -- >   事件处理器(连接应答处理器)

1.核心数据结构五种数据类型

string(字符串)hash(哈希)list(列表)set(集合)zset(有序集合)

2.微博与微信消息流、热搜Redis实现

list:key-list(栈结构),key是用户id、value是消息;10min前的消息在前,1h前的消息在后

最新消息排⾏榜、微博订阅、微信公众号消息推送

zset:key-zset,key是时间点、value是点击量-新闻id;hotNews20210101  100-xxx事件

当日热搜七日热搜(zset每个元素带可重复的分值)

3.微信点赞、收藏与标签基于Redis实现

set:key-set,key是朋友圈消息id、value是点赞的用户id;abc点赞,可统计点赞总数

string:key-value,key是文章id、value文章阅读量;文章A  阅读量10w+

4.微博与微信朋友关注模型基于Redis实现

set:key-set,a b个人关注seta setb

ab共同关注求seta setb交集、a推荐好友求seta setb差集(大于阈值)

5.电商购物车基于Redis实现

hash:key-field-value,key是用户id、field是商品id、value商品数量;A  显示器   1台

02-Redis持久化、主从与哨兵架构详解

1.RDB、AOF及混合持久化详解

RDB:达到触发条件进行内存快照(save 60 1000:满足“60秒内有至少有1000个键被改动”这一条件时,自动保存一次数据集,Redis默认将内存数据库快照保存在名字为dump.rdb的二进制文件中);有2种手动生成快照文件方式:save同步阻塞;bgsave异步非阻塞

AOF:将修改的每一条指令记录到文件appendonly.aof中(开启appendonly yes,先写入oscache每隔一段时间fsync到磁盘);重放增量日志:AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据生成aof文件重放增量日志 (定期自动重写,重写时把内存数据以命令方式存储)

混合:aof‐use‐rdb‐preamble yes(用RDB恢复内存状态可能会丢失大量数据而aof重放性能慢很多,4.0引入新的持久化选项混合持久化,必须先开启aof);如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。于是在Redis重启的时候,可以先加载RDB的内容,然后再重放增量AOF日志就可以完全替代之前的AOF全量文件重放,因此重启效率大幅得到提升。

命令 RDB AOF
启动优先级
体积
恢复速度
数据安全性 安全性容易丢数据 根据策略决定

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

2.并行持久化之写时复制机制详解

bgsave的写时复制机制(Copy-On-Write),在生成快照的同时,依然可以正常处理写命令。

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

3.Redis管道及lua脚本详解

管道(Pipeline): 客户端可以一次性发送多个请求而不用等待服务器的响应,待所有命令都发送完后再一次性读取服务的响应,这样可以极大的降低多条命令执行的网络传输开销,管道执行多条命令的网络开销实际上只相当于一次命令执行的网络开销。需要注意到是用pipeline方式打包命令发送,redis必须在处理完所有命令前先缓存起所有命令的处理结果。打包的命令越多,缓存消耗内存也越多。所以并不是打包的命令越多越好。

pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行。

管道:命令同时发送批量执行,减少网络开销,不具备原子性

lua脚本:替代redis的事务功能,减少网络开销,原子操作

KEYS[1],KEYS[2],ARGV[1],ARGV[2],k1,k2,v1,v2

Redis主从架构详解

https://note.youdao.com/ynoteshare/index.html?id=893c138fa39925f86b374fd46db322b4&type=note&_time=1646463911558

全量复制:slave同步psync/建立socket长连接 -> master bgsave -> send rdb/buffer、长连接保证一致性

部分复制:master内存中有一个复制数据用的缓存队列,缓存最近一段时间的数据;slave连接断开后重连,找到最近数据缓存buffer的offset,断点续传;offset太旧则进行全量数据复制

主从复制风暴:多个从节点同时复制主节点导致主节点压力过大(阶梯式架构让部分从节点之间同步数据)

Redis哨兵高可用架构详解

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

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

在主从切换的瞬间还是存在访问瞬断的情况,

哨兵leader选举流程

当一个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节点类似。

03-Redis Cluster集群运维与核心原理剖析

1.哨兵集群与Redis Cluster架构异同

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

2.搭建Redis高可用集群

// 第一步修改redis.conf配置文件,需修改
(1)daemonize yes
(2)port 6378(分别对每个机器的端口号进行设置)
(3)pidfile /var/run/redis_6378.pid  # 把pid进程号写入pidfile配置的文件
(4)dir /usr/local/redis-cluster/6378/(指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据)
(5)cluster-enabled yes(启动集群模式)
(6)cluster-config-file nodes-6378.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 xxx     (设置redis访问密码)
 (12) masterauth xxx      (设置集群节点间访问密码,跟上面一致)
// 第二步批量修改第2、3、4、6项里的端口号,拷贝放到各自的文件夹下 
// 第三步分别启动6个redis实例(shell脚本)
redis.sh   
```c
# rediscluster 根目录下
#      redis.sh   
#      redis 源码文件夹
#      redis6374  
#          redis.conf
#      redis6375  
#          redis.conf
#      redis6376  
#          redis.conf
#      redis6377  
#          redis.conf
#      redis6378    
#          redis.conf
#      redis6379   
#          redis.conf
rediscluster/redis/src/redis-server rediscluster/redis6374/redis.conf & \
rediscluster/redis/src/redis-server rediscluster/redis6375/redis.conf & \
rediscluster/redis/src/redis-server rediscluster/redis6376/redis.conf & \
rediscluster/redis/src/redis-server rediscluster/redis6377/redis.conf & \
rediscluster/redis/src/redis-server rediscluster/redis6378/redis.conf & \
rediscluster/redis/src/redis-server rediscluster/redis6379/redis.conf 

// 第四步用redis-cli创建整个redis集群
rediscluster/redis/src/redis-cli --cluster create --cluster-replicas 1 127.0.0.1:6374 127.0.0.1:6375 127.0.0.1:6376 127.0.0.1:6377 127.0.0.1:6377 127.0.0.1:6378 127.0.0.1:6379

>>> Performing hash slots allocation on 7 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:6377 to 127.0.0.1:6374
Adding replica 127.0.0.1:6378 to 127.0.0.1:6375
Adding replica 127.0.0.1:6379 to 127.0.0.1:6376
Adding extra replicas...
Adding replica 127.0.0.1:6377 to 127.0.0.1:6374
>>> Trying to optimize slaves allocation for anti-affinity
[WARNING] Some slaves are in the same host as their master
M: 8aa4e7d3f1bacef32d30abb26eba65e391f01768 127.0.0.1:6374
   slots:[0-5460] (5461 slots) master
M: 71e0d69320fce8dd8c19fa0a384c5d22083f60b7 127.0.0.1:6375
   slots:[5461-10922] (5462 slots) master
M: 123320f26b38f2ac063076e02d9b1d08c490302d 127.0.0.1:6376
   slots:[10923-16383] (5461 slots) master
S: 49ded912eb05531006222ce73735d2c35c6532bf 127.0.0.1:6377
   replicates 8aa4e7d3f1bacef32d30abb26eba65e391f01768
S: 49ded912eb05531006222ce73735d2c35c6532bf 127.0.0.1:6377
   replicates 123320f26b38f2ac063076e02d9b1d08c490302d
S: 5b5a89d8bb5c0ed85bc2285ce54ccdd62d78f930 127.0.0.1:6378
   replicates 71e0d69320fce8dd8c19fa0a384c5d22083f60b7
S: a8ce0ecdc013de2a89ac6d71b7a3fbc71b5b4e42 127.0.0.1:6379
   replicates 8aa4e7d3f1bacef32d30abb26eba65e391f01768

// 验证集群 连接任意一个客户端
rediscluster/redis/src/redis-cli -c -h 127.0.0.1 -p 6379
redis-cli -c --cluster call 127.0.0.1:6379 keys *

127.0.0.1:6379> cluster info (查看集群信息)
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:7
cluster_my_epoch:1
cluster_stats_messages_ping_sent:118
cluster_stats_messages_pong_sent:121
cluster_stats_messages_meet_sent:1
cluster_stats_messages_sent:240
cluster_stats_messages_ping_received:121
cluster_stats_messages_pong_received:119
cluster_stats_messages_received:240
127.0.0.1:6379> cluster nodes (查看节点列表)
123320f26b38f2ac063076e02d9b1d08c490302d 127.0.0.1:6376@16376 master - 0 1646468857962 3 connected 10923-16383
5b5a89d8bb5c0ed85bc2285ce54ccdd62d78f930 127.0.0.1:6378@16378 slave 71e0d69320fce8dd8c19fa0a384c5d22083f60b7 0 1646468854000 2 connected
a8ce0ecdc013de2a89ac6d71b7a3fbc71b5b4e42 127.0.0.1:6379@16379 myself,slave 8aa4e7d3f1bacef32d30abb26eba65e391f01768 0 1646468856000 1 connected
49ded912eb05531006222ce73735d2c35c6532bf 127.0.0.1:6377@16377 slave 123320f26b38f2ac063076e02d9b1d08c490302d 0 1646468856932 3 connected
8aa4e7d3f1bacef32d30abb26eba65e391f01768 127.0.0.1:6374@16374 master - 0 1646468857000 1 connected 0-5460
71e0d69320fce8dd8c19fa0a384c5d22083f60b7 127.0.0.1:6375@16375 master - 0 1646468855891 2 connected 5461-10922

Redis集群原理分析

https://note.youdao.com/ynoteshare/index.html?id=218d9ba28237a441217d0e024d410769&type=note&_time=1646466900530

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

槽位定位算法

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

为什么Redis集群有16384个槽?

CRC16算法产生的hash值有16bit,该算法可以产生2^16-=65536个值,值分布在0~65535之间,那作者在做mod运算的时候,为什么不mod65536,而选择mod16384?
集群节点之间判断存活,每秒都在发送ping/pong消息,包括了节点负责的槽信息,大小为16384÷8÷1024=2kb,若使用65536个槽会使得消息大小从2k到8k,带宽过大,会造成网络拥堵,
redis cluster节点数在1000以内的redis cluster集群,16384个槽位够用了

总结:
1、消息大小考虑:尽管crc16能得到65535个值,但redis选择16384个slot,是因为16384的消息只占用了2k,而65535则需要8k。浪费带宽,造成网络拥堵
2、集群规模设计考虑:集群设计最多支持1000个分片,16384是相对比较好的选择,需要保证在最大集群规模下,slot均匀分布场景下,每个分片平均分到的slot不至于太小。
需要注意2个问题:
1、为什么要传全量的slot状态?
因为分布式场景,基于状态的设计更合理,状态的传播具有幂等性
2、为什么不考虑压缩?
集群规模较小的场景下,每个分片负责大量的slot,很难压缩。

跳转重定位

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

Redis集群节点间的通信机制

  • 维护集群的元数据(集群节点信息,主从角色,节点数量,各节点共享的数据等)有两种方式:集中式和gossip;Redis cluster节点间采取gossip协议进行通信
  • 集中式:

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

  • gossip

gossip协议包含多种消息,包括ping,pong,meet,fail等等。
meet:通知新节点加入,某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信
ping:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息,频繁给其他节点发送ping,包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据(类似自己感知到的集群节点增加和移除,hash slot信息等)
pong: 对ping和meet消息的返回,包含自己的状态和其他信息,也可以用于信息广播和更新
fail:某个节点判断另一个节点fail之后,会向集群内广播一个fail消息,发送fail给其他节点,通知其他节点,指定的节点宕机了,其他节点接收到fail消息之后把对应节点更新为下线状态

优点:元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续到达所有节点上去更新,有一定的延时,降低了压力(类似流言传播)
缺点:元数据更新有延时可能导致集群的一些操作会有一些滞后

gossip通信的10000端口

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

网络抖动

真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。为解决这种问题,Redis Cluster 提供了一种选项cluster-node-timeout,表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。

集群 fail 状态

Redis 之间通过互相的 ping-pong 判断是否节点可以连接上。如果有一半以上的节点去ping 一个节点的时候没有回应,集群就认为这个节点宕机了,然后去连接它的从节点。

如果某个节点和所有从节点全部挂掉,我们集群就进入 fail 状态,整个集群挂了。

如果有一半以上的主节点宕机,那么我们集群同样进入 fail 了状态。

如果集群任意master挂掉,且当前master没slave,集群进入fail状态。

Redis集群Master选举原理剖析

当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后变成新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集群为什么至少需要三个master节点,并且推荐节点数为奇数?

因为新master的选举需要大于半数的集群master节点同意才能选举成功,如果只有两个master节点,当其中一个挂了,是达不到选举新master的条件的。

奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如三个master节点和四个master节点的集群相比,大家如果都挂了一个master节点都能选举新master节点,如果都挂了两个master节点都没法选举新master节点了,所以奇数的master节点更多的是从节省机器资源角度出发说的。

Redis集群脑裂数据丢失问题剖析

现象:网络分区导致脑裂后(网络原因集群出现了分区,哨兵认为master宕机,开始选举把其它slave切换成了master,这是集群里会有多个master),多个主节点对外提供写服务,一旦网络分区恢复, 会将其中一个主节点变为从节点,这时会有大量数据丢失
解决:Redis集群没有过半机制会有脑裂问题,集群半数写入才返回客户端操作成功,牺牲一定可用性(集群半数以上节点挂了主节点无法写入,整个集群挂了)
配置:min-slaves-to-write 1

Redis集群水平扩缩容

新的机器加入集群会做为一个新的主节点(master)。需要槽位迁移,主节点一定要有槽位,没有槽位客户端访问不到。客户端根据槽位值到映射表找到对应机器。注意:当添加节点成功以后,新增的节点不会有任何数据,因为它还没有分配任何的slot(hash槽),我们需要为新节点手工分配hash槽

04-深入底层C源码讲透Redis核心设计原理

05-Redis 核心数据结构&Redis6 新特性详解

06-Redis缓存设计与性能优化

Redis开发规范与性能优化

key设计:可读性、简洁性、不要包含空格、换行、单双引号以及其他转义字符这些特殊字符
value设计:拒绝bigkey,会造成redis阻塞、网络拥堵

  1. 字符串类型:它的big体现在单个value值很大,一般认为超过10KB就是bigkey。 最大512MB。
  2. 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。hash、list、set、zset元素个数不要超过5000。最大大约40亿个(2^32-1)个元素。
  • 缓存穿

现象:访问根本不存在的数据,缓存层和存储层都不存在
原因:业务代码问题、恶意攻击、爬虫访问
解决:缓存空对象、布隆过滤器

  • 缓击击穿

现象:大批量缓存同一时间失效,可能导致大量请求同时直达数据库,可能会造成数据库瞬间压力过大甚至挂掉
解决:缓存数据过期时间设置为一个时间段内的随机值

  • 缓存雪崩

现象:缓存层挂了宕掉支撑不住,大量请求打到存储层
解决:保证缓存层服务高可用性,比如使用RedisSentinel或RedisCluster;依赖隔离组件为后端限流熔断并降级,比如使用Sentinel或Hystrix限流降级组件;提前演练,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题

布隆过滤器

适用场景:在数据量很大(5亿以上)的场景下判断某一数据是否存在;去重;缓存穿透:对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送
数据结构:一个二进制数组来记录数据的相关性,数组中只有1或0,对于100w个元素的位数组,只占用100w/8/1024=122kb大小空间;当一个元素加入布隆过滤器时,用多个哈希函数对元素值分别进行计算,对应的hash结果放到数组的位上面
特点:当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在;布隆过滤器可以添加元素,不能删除数据,如果要删除得重新初始化数据,因为删掉元素会导致误判率增加

// 获取存储在redis中的布隆过滤器
BloomFilter<String> bloomFilter = redissonClient.getBloomFilter("BloomFilter");
// 不存在时初始化布隆过滤器 
// 预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
// 添加元素
bloomFilter.add("demo);
bloomFilter.add("luke");
// 判断是否存在
System.out.println(bloomFilter.contains("demo"));//true
System.out.println(bloomFilter.contains("luge"));//false

业务场景很简单,判断用户是否存在,不存在的用户不予访问,项目启动初始化布隆过滤器,每次添加新用户刷新布隆过滤器,用户删除为逻辑删除不用更新布隆过滤器

缓存与数据库双写不一致

在大并发下同时操作数据库与缓存会存在数据不一致性问题;通常有以下几种顺序:更新数据库后更新缓存、更新数据库后删除缓存、删除缓存后更新数据库

  • 更新数据库-更新缓存操作
  1. 线程安全角度: 更新缓存操作可能延时可能不延时,整体操作不是一个原子操作,并发写的情况下,无法控制缓存最后的值是多少
  2. 业务场景角度: 如果业务是写多读少的场景,可能数据压根没读过,缓存就频繁更新,浪费性能;如果写入缓存的值是经过复杂计算的值,浪费性能
  • 更新数据库-延时删除缓存操作
  1. 线程安全角度: 发现缓存为空,查到数据库的旧数据更新到缓存中,更新到缓存卡顿延时
  2. 业务场景角度: 数据库读写分离,读操作的速度远快于写操作的,发生概率低,可以采用异步延时删除

最经典的缓存+数据库读写的模式 Cache Aside Pattern

  • 失效:应用程序从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,删除缓存

解决方案:

1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。

2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。

3、如果不能容忍缓存数据不一致,可以通过加读写锁保证并发读写或写写的时候按顺序排好队,读读无锁,读写、写写加锁。

4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。

热点缓存key重建优化

现象:当前key是个热点key,并发量大,冷数据突变热点数据;重建缓存不能短时间内完成,涉及到复杂sql、多次io
解决:这个问题主要就是要避免大量线程同时重建缓存,利用互斥锁来解决,只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可(double check)

// 第一把分布式锁是用于解决并发重建的,防止冷门数据突然变成热点数据时大量请求打到数据库上,
// 这样保证了同时只有一个线程会重建缓存;第二把分布式锁是用于解决缓存和数据库双写不一致的,
// 线程重建缓存时如果有其他线程在更新这份数据就会阻塞住,这样重建缓存的数据会是最新的数据
public static final String LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX = "lock:product:hot_cache_create:";
public static final String LOCK_PRODUCT_UPDATE_PREFIX = "lock:product:update:";
public static final String PRODUCT_CACHE = "product:cache:";
public static final String EMPTY_CACHE = "{}";
// 查询商品
public Product get(Long productId) {
    Product product = null;
    String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
    // 从缓存里查数据
    product = getProductFromCache(productCacheKey);
    if (product != null)  return product; 
    try{
        // 加分布式锁解决热点缓存并发重建问题
        RLock hotCreateCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
        hotCreateCacheLock.lock();
        // 双检,其他线程等待重建缓存的线程执行完后可直接从缓存获取数据
        product = getProductFromCache(productCacheKey);
        if (product != null)  return product;
      
        // 加分布式读锁解决缓存双写不一致问题
        ReadWriteLock productUpdateLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
        RLock rLock = productUpdateLock.readLock();
        rLock.lock();
        try {
            product = productDao.get(productId);
            if (product != null) setProductCacheKey(product);
            // 设置空缓存解决缓存穿透问题
            else setEmptyCache(EMPTY_CACHE); 
        } finally {
            rLock.unlock();
        }
    } finally {
        hotCreateCacheLock.unlock();
    }
}
// 更新商品
public Product update(Product product) {
     Product productResult = null;   
     // 加分布式写锁解决缓存双写不一致问题
     RReadWriteLock productUpdateLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
     RLock writeLock = productUpdateLock.writeLock();  
     writeLock.lock();
     try {
         productResult = productDao.update(product);
         setProductCacheKey(productResult);
     } finally {
         writeLock.unlock();
     }
     return productResult;
}

// 走缓存,如果查到了缓存数据/EMPTY_CACHE,延期随机一段时间再返回,EMPTY_CACHE是用于防缓存穿透的
private Product getProductFromCache(String productCacheKey){}

07-Redisson分布式锁原理

// setnx需要加过期时间防死锁,也要判断锁是否自己持有的(过期了),自己持有的才能删
@GetMapping("/test")
public String test(Long productId){
    String lockKey = PRODUCT_ID_LOCK + productId;
    String clientId = UUID.randomUUID().toString();
    Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);  //setnx
    if (Boolean.FALSE.equals(res)){
        return "error_code";
    }
    decrement();  //扣减库存
    if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
        stringRedisTemplate.delete(lockKey);
    }
    return "success_code";
}
@GetMapping("/redissonLock")
public String deductStock() {
    String lockKey = PRODUCT_ID_LOCK + productId;
    //获取锁对象
    RLock redissonLock = redisson.getLock(lockKey);
    //加分布式锁
    redissonLock.lock();  
    try {
        decrement();  //扣减库存
    } finally {
        redissonLock.unlock();
    }
}

// RedissonRedLock: 这里需要自己实例化不同redis实例的redisson客户端连接,不同集群
// 根据多个 RLock 对象构建 RedissonRedLock 
@RequestMapping("/redlock")
public String redlock() {
    String lockKey = "product_001";
    RLock lock1 = redisson.getLock(lockKey);
    RLock lock2 = redisson.getLock(lockKey);
    RLock lock3 = redisson.getLock(lockKey);
    RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
    try {
        /**
         * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
         * leaseTime   锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)         
         */
        boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);
        if (res) {
            //成功获得锁,在这里处理业务
        }
        } catch (Exception e) {
            throw new RuntimeException("lock fail");
        } finally {
            //无论如何, 最后都要解锁
            redLock.unlock();
        }
	}
}

关键点:lua脚本、看门狗续命、发布订阅模式、信号量

加锁原理

public void _lock()
public void _lock(_long _leaseTime, TimeUnit unit) 
public boolean _tryLock() 
public boolean _tryLock(_long _waitTime, TimeUnit unit) 

底层逻辑:通过一段类似_setIfAbsent(K lockKey_, V lockValue, _long timeout, TimeUnit unit)_操作的Lua脚本
特点:支持可重入,第一个客户端请求加锁,加锁成功/锁重入返回null,第二个客户端再来请求加锁,加锁失败,返回锁的剩余生存时间。

Semaphore.tryAcquire(ttl) 第二个客户端通过信号量阻塞自己ttl时间来唤醒
Semaphore.release() 也可通过第一个客户端发布解锁消息来唤醒

	"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]);"
解释:
KEYS[1]  key = 我们设置的值
ARGV[1]  leaseTime = 我们设置过期时间,默认为-1
ARGV[2]  value = uuid+threadId
加锁逻辑简述:
127.0.0.1:6376> exists key_1
(integer) 0 // 返回0代表值不存在

    127.0.0.1:6376> hset key_1 uuid_thread_1 1
    (integer) 1 // 存储数据类型是 Hash类型    
        
    127.0.0.1:6376> pexpire key_1 10000 
    (integer) 1 // 设置key_1的过期时间为10000ms   
    return nil
    
(integer) 1 // 值存在,代表锁被持有

127.0.0.1:6376> hexists key_1 uuid_thread_1
(integer) 1 // 返回1代表锁被自己持有

    127.0.0.1:6376> hincrby key_1 uuid_thread_1 1
    (integer) 2 // 锁重入
    
    127.0.0.1:6376> pexpire key_1 10000 
    (integer) 1 // 设置key_1的过期时间为10000ms   
    return nil
    
(integer) 0 //锁不被自己持有

    127.0.0.1:6376> pttl key_1
    (integer) 8971 //返回锁剩下的时间8971ms
    return ttl

锁互斥机制

当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 Semaphore#tryAcquire() 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题
订阅:订阅锁释放事件,获取信号量阻塞自己
发布:锁释放,会通知被阻塞的线程

主流程:

  1. tryAcquire 获取锁
  2. subscribe 开启订阅,订阅锁释放消息message
  3. tryAcquire 再次获取锁同时获取最新的ttl
  4. 还是没获取到锁,获取 Semaphore� 信号量,ttl >=0阻塞当前线程最大等待时间为ttl, 否则一直阻塞,等待message
  • 代码
package com.example.demo.controller;

import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Slf4j
@RestController
public class LockController {
    @Autowired
    private RedissonClient redissonClient;

    @RequestMapping("/test/{id}")
    public String selectById(@PathVariable("id") Long id) {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy年MM月dd日-hh时mm分ss秒");
        String lockKey = "product_" + id;
        try {
            long startTime = System.currentTimeMillis();
            acquire(lockKey, 5L);
            long endTime = System.currentTimeMillis();
            log.info("开始时间:{}, 当前时间:{}", simpleDateFormat.format(new Date(startTime)), simpleDateFormat.format(new Date(endTime)));
            Thread.sleep(30 * 1000L);
        }catch (Exception e){
            System.out.println(e.getMessage());
        }finally {
            release(lockKey);
        }
        return "ok";
    }

    public boolean acquire(String key, Long leaseTime) {
        //获取锁对象
        RLock myLock = redissonClient.getLock(key);
        //加锁,并且设置锁过期时间,防止死锁的产生
        if (null != leaseTime && ( leaseTime > 0 || leaseTime == -1)) {
            myLock.lock(leaseTime, TimeUnit.SECONDS);
        }
        log.info("======lock======threadId:" + Thread.currentThread().getName() + "======");
        //加锁成功
        return true;
    }

    public void release(String key) {
        //必须是和加锁时的同一个key
        //获取所对象
        RLock myLock = redissonClient.getLock(key);
        //释放锁(解锁)
        myLock.unlock();
        log.info("======unlock======threadId:" + Thread.currentThread().getName()+ "======");
    }
}

  • 测试
// curl http://localhost:8080/test/1
// leaseTime=5L 同时3个线程发起请求

======lock======threadId:http-nio-8080-exec-3======
开始时间:2022年04月17日-02时06分17秒, 当前时间:2022年04月17日-02时06分17秒
======lock======threadId:http-nio-8080-exec-2======
开始时间:2022年04月17日-02时06分17秒, 当前时间:2022年04月17日-02时06分22秒
======lock======threadId:http-nio-8080-exec-1======
开始时间:2022年04月17日-02时06分17秒, 当前时间:2022年04月17日-02时06分27秒
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id:
// 锁释放时间设置得太短,线程还没执行完任务,锁就被自动释放掉了,到自己手动释放锁的时候抛异常

// leaseTime=-1L 同时3个线程发起请求
======lock======threadId:http-nio-8080-exec-2======
开始时间:2022年04月17日-02时07分59秒, 当前时间:2022年04月17日-02时07分59秒
======unlock======threadId:http-nio-8080-exec-2======
======lock======threadId:http-nio-8080-exec-1======
开始时间:2022年04月17日-02时07分59秒, 当前时间:2022年04月17日-02时08分29秒
======unlock======threadId:http-nio-8080-exec-1======
======lock======threadId:http-nio-8080-exec-3======
开始时间:2022年04月17日-02时07分59秒, 当前时间:2022年04月17日-02时08分59秒
======unlock======threadId:http-nio-8080-exec-3======
// 锁会通过锁续期机制被线程一直持有

锁续期机制

leaseTime 必须是 -1 才会开启 Watch Dog 机制,一旦设了时间RedissonLock就会认为你需要自己控制锁时间,而放弃执行续锁逻辑。

客户端加锁的锁 key 默认生存时间为 30 秒 (internalLockLeaseTime�=lockWatchdogTimeout)
看门狗续命机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP 里面,然后 每隔 10 秒 (internalLockLeaseTime / 3) 检查一下,如果客户端还持有锁 key(判断客户端是否还持有 key,其实就是遍历该MAP里面线程 id,然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间

释放锁机制

	"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
    "return nil;" +
    "end; " +
    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
    "if (counter > 0) then " +
    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
    "return 0; " +
    "else " +
    "redis.call('del', KEYS[1]); " +
    "redis.call('publish', KEYS[2], ARGV[1]); " +
    "return 1; "+
    "end; " +
    "return nil;"
解释:
KEYS[1]  key = 我们设置的值
KEYS[2]  redisson_lock__channel = 发布订阅channel
ARGV[1]  0L = 锁释放消息的值
ARGV[2]  internalLockLeaseTime = 30L
ARGV[3]  value = uuid+threadId

解锁逻辑简述:
127.0.0.1:6376> hexists key_1 uuid_thread_1
(integer) 0 // 返回0代表锁不被自己持有
return nil

(integer) 1 // 锁被自己持有

    127.0.0.1:6376> hincrby key_1 uuid_thread_1 -1                  
    (integer) 1 // 锁退出重入,返回值大于0
        
        127.0.0.1:6376> pexpire key_1 30
        (integer) 1 // 延长key_1的过期时间30s  
        return 0  
      
    (integer) 0 // 返回值=0 
        127.0.0.1:6376> del key_1 // 删掉key
        127.0.0.1:6376> publish channel 0 // 发布锁释放事件
        return 1

删除锁;取消 Watch Dog 机制;广播释放锁的消息

锁释放并发布释放锁的消息后,调用方法 LockPubSub#onMessage() 信号量的 release() 方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。

08-Redis队列Stream、HyperLogLog、Redis多线程详解

Stream:支持多播的可持久化的消息队列

// 追加3条消息 返回消息ID,由两部分组成:时间戳-序号
// -0是当前时间戳的第一条数据
xadd streamtest * name luke age 24
xadd streamtest * name luke1 age 25
xadd streamtest * name luke2 age 26
// 消息长度
xlen streamtest
// 获取消息列表,会自动过滤已经删除的消息 - 表示最小值 + 表示最大值
xrange streamtest - +  
// 查看消息队列的情况
xinfo stream streamtest
// 查看消费组的情况
xinfo groups streamtest

复习:Redis为什么这么快?
完全基于内存;单线程;NIO 文本协议;resp协议(redis客户端和服务端通信协议) 文本协议

HyperLogLog:是一种基数(即集合中元素的个数)算法,通过HyperLogLog可以利用极小的内存空间完成独立总数的统计,数据集可以是IP、Email、ID等。适用场景:统计网站的UV数据

posted @ 2022-10-22 21:29  Luke!  阅读(133)  评论(0编辑  收藏  举报