缓存穿透
指查询一个一定不存在的数据,缓存不命中走DB,大量请求落到DB上把数据库压垮
解决方式
-
上游限流,如网关限流,接口调用次数限制
RateLimiter(guava令牌桶限流)
可用在网关上,可用在获取缓存的公共方法上 public static T cacheHelper(String key, Supplier supplier)
-
缓存空对象,这样可以请求不落DB,但业务层需要加上相应空值判断
-
布隆过滤器,内部存储不存在的key,但是在新加key时要去除布隆过滤器里相应的拦截位
缓存雪崩
redis挂了,全部请求走DB
解决方式
-
缓存高可用,降低全挂的概率
-
服务降级,分布式缓存挂了,走本地缓存
如何保证本地缓存性?
- 设定较短过期时间
- 引入MQ机制,异步更新
-
DB限流
- Java Semaphore 控制并发量,达到并发数后阻塞等待
- RateLimiter(guava令牌桶限流)加在获取缓存的公共方法上,缓存没命中读DB之前(supplier.get())先获取令牌,如果QPS超过了限制,那么就用30%概率响应失败,降低DB访问频率。对于用户来说,系统并没有死透,刷新几下就会成功一次
- 开源框架 Hystrix (可限流,可降级,降成只返默认值、欢迎页等等)
缓存击穿
指某个极度热点数据在某个时间点过期时,恰好在这个时间点对这个 KEY 有大量的并发请求
解决方式
-
热点数据不设置过期时间,更新操作依赖于其他逻辑,比如后台管理系统
-
使用互斥锁,缓存不命中,查询 DB 前,使用分布式锁,保证有且只有一个线程去查询 、更新
String get(String key) { String value = reids.get(key); if(value == null){ String randomStr = "XXX"; if(redis.set(nxKey, randomStr, "NX", "PX", 60)){ value = db.get(); if(value != null){ redis.setex(key, timeOut, value); } } else{ Thread.sleep(10); get(key);//未获取到锁的线程,等一下再试 } } return value; }
-
不依赖redis过期策略,用程序判断过期更新,过期时间存在value里,发现快要过期时,使用互斥锁去更新(不太靠谱,因为可能那段时间就是没人请求)
String get(String key) { String value = reids.get(key); boolean logicTimeOut = timeOut(value);//判断是否将要过期 if(logicTimeOut){ String randomStr = "XXX"; if(redis.set(nxKey, randomStr, "NX", "PX", 60)){ value = db.get(); if(value != null){ redis.set(key, value); } } } return value; }
缓存数据一致性问题
引起一致性问题即数据库与缓存数据不一样,引原因展开来说有很多种情况,最主要是分清到底是哪种情况引发的问题,主要分为两类
一、并发更新,缓存覆盖
-
高并发下,读DB老数据,更新至缓存(覆盖了其他程序的更新)
- A读缓存,未命中
- A读取DB
- B更新DB及缓存
- A更新缓存(此时缓存和DB数据不一致)
-
缓存操作和DB操作不在同一个事务中(DB更新成功,缓存更新失败、DB更新失败,缓存更新成功)
这种情况只能依赖分布式事务去解决了
解决方式
-
使用互斥锁,把并行写操作变成串行
读操作时,若命中则直接返回,若缓存未命中,则先获取分布式锁,再去读取DB及更新缓存
写操作时,先获取分布式锁,再去写
二、数据库、缓存双写过程存在部分失败(这里又可以区分成:删除缓存还是更新缓存,一般是删,懒加载,因为可能很长时间都不读)
-
先删缓存再更新DB(这种方式部分失败不会引起不一致,但会有以下情况产生不一致)
- A删缓存
- B读缓存,未命中
- B读取数据库并更新缓存
- A更新数据库,此时缓存内数据是由
-
先更新DB再删缓存
- 更新DB成功
- 更新缓存时失败,此时缓存内还是老数据,引发不一致
其实不管是什么操作都有可能引起不一致问题,最常用的解决方式就是
- 设置较短过期时间,依赖过期来去除脏数据
- 异步强制失效,更新后写人任务表或队列,执行删除key操作,让程序重新读,降低不一致概率
- 留个后门,比如管理后台,手动更新
Redis为什么这么快?
1、多路复用线程模型
2、数据结构优化
Redis线程模型
redis采用单线程多路复用文件处理器模型,类似Reactor模型,IO多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理。
主要包含4个部分:
-
IO多路复用程序
-
事件队列
-
事件分发器
-
事件处理器
连接应答处理器
命令请求处理器
命令回复处理器
处理过程:
- 客户端 socket01 向 redis 的 server socket 请求建立连接,此时 server socket 会产生一个
AE_READABLE
事件,IO 多路复用程序监听到 server socket 产生的事件后,将该事件压入事件队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器
。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的AE_READABLE
事件与命令请求处理器关联。 - 假设此时客户端发送了一个
set key value
请求,此时 redis 中的 socket01 会产生AE_READABLE
事件,IO 多路复用程序将事件压入队列,此时事件分派器从队列中获取到该事件,由于前面 socket01 的AE_READABLE
事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的key value
并在自己内存中完成key value
的设置。操作完成后,它会将 socket01 的AE_WRITABLE
事件与命令回复处理器关联。 - 如果此时客户端准备好接收返回结果了,那么 redis 中的 socket01 会产生一个
AE_WRITABLE
事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如ok
,之后解除 socket01 的AE_WRITABLE
事件与命令回复处理器的关联。
Linux epoll IO多路复用机制
1、通过epoll_create()创建eventPoll结构,包含“IO操作事件对象红黑树”、“已就绪IO操作事件双向链表”
2、通过**epoll_ctl()**向红黑树中添加/修改IO操作事件,同时将IO操作事件对象与网络驱动程序绑定
3、当绑定的事件发生时,通过回调,将IO操作事件对象回写进“已就绪IO操作事件双向链表”
4、调用epoll_wait()获取“已就绪IO操作事件链表”的节点数量,当数量大于0时遍历链表,操作IO
epoll_create()
| —— IO操作事件对象[红黑树]
| o
| / \
| o o <—— epoll_ctl()
| | 1、添加/修改[IO操作事件对象]
| | 2、将事件对象绑定到[网卡驱动程序]
|
| —— 已就绪IO操作事件链表 [驱动程序] ——事件就绪——> ep_poll_calback()//事件添加至就绪链表
| o <-> o <-> o <———————————————|
|
| int readyFdsCount = epoll_wait(); //获取“已就绪IO操作事件链表”的节点数量
| for(int i = 0; i < readyFdsCount; i++){
| // events[i].do io...
| }
- 没有FD监听数量限制(客户端操作服务器时就会产生文件描述符(简称FD):writefds(写)、readfds(读)、和exceptfds(异常) )
- 使用回调通知而不是轮询方式,不会随着FD数目的增加效率下降
Linux select多路复用机制
1、select用于阻塞监听一组IO操作的IO事件,发生IO事件则结束阻塞,若无事件变化则线程空闲
2、它只能监听变化,并不能知道到底具体是哪个IO事件发生了变化
3、监听IO操作数量有限制,默认1024个
while true {
select(IO操作事件数组[]);
for i in streams[] {
if i has data
read until unavailable
}
}
redis数据结构
string
简单动态字符串(simple dynamic string SDS),不直接使用C语言的字符串而是单独定义一个结构,增加已占用、剩余可用这两个属性,目的是为了减少判断
C语言字符串是以空白符为结束标记,这样带空白符的数据就不能用字符串存,现在可以直接通过len属性判断结尾,这样就没有了存储限制
C语言的字符串拼接要判断当前申请的空间是否能存的下,只能遍历字符串才能知道长度,现在可以通过free属性直接判断剩余可用空间
struct sdshdr {
int len; // buf 中已占用空间的长度
int free; // buf 中剩余可用空间的长度
char buf[]; // 字符数据数组
}
hash
- 所有元素长度都小于64字节,元素个数小于512时,使用zipList结构作为底层实现
- 不满足以上条件时,用hash结构
最主要的特点是使用两个桶结构,一个用来存储,一个用来扩容,即渐进式扩容模式
当桶全部存满以后触发扩容(还有其他条件),首先将计数器rehashidx设置为0,表示目前处于rehash状态,随后分配ht[1]空间,大小是原桶的两倍(即dictht中的table大小扩大两倍),当下一次对字典CRUD时,在ht[1]中进行操作的同时,找到ht[0]对应的桶位置,将对应位置上的数据向ht[1]迁移,rehashidx加一,当ht[0]全部迁移完毕(used数为0)则将ht[1]与ht[0]位置对换,同时rehashidx设为-1表示非rehash状态
typedef struct dict {
dictType *type;// 类型特定函数
void *privedata;// 私有数据
dictht ht[2];// 哈希表
in trehashidx; // rehash 索引(标识)
}
typedef struct dictht {
dictEntry **table;//哈希表数组(桶)
unsigned long size; //哈希表大小
unsigned long sizemask; //哈希表大小掩码,用于计算索引值
unsigned long used; //该哈希表已有节点的数量
}
typeof struct dictEntry{
void *key; //键
union{ //值
void *val;
uint64_tu64;
int64_ts64;
}
struct dictEntry *next;//下一个节点指针
}
zset
- 所有元素长度都小于64字节,元素个数小于512时,使用zipList结构作为底层实现(按照元素memeber和分值socore有序)
- 不满足以上条件时使用skiplist(跳表)+ hash作为底层实现,类似与TreeMap(hash + 红黑树),数据用hash存储,用跳表来实现有序,跳表的查询效率不如红黑树但也很高,插入的效率比红黑树高,属于综合性能比较好的数据结构
Set底层实现也是分两种
- 元素都是整数、元素个数少于512时用intSet作为底层实现
- 不满足以上条件时,使用hash作为底层实现,key作为set元素的value,value为null
skiplist
跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:
- 由多层结构组成
- 每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的null节点
- 最底层的链表包含了所有的元素
- 如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集)
- 每个节点包含上下左右4个指针
- 搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空
- 插入:首先通过随机法确定插入的层数,插入时要保持前后左右的节点关联性。
- 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层
zipList
-
zlbytes: ziplist的长度(单位: 字节),是一个32位无符号整数
-
zltail: ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候有用。
-
zllen: ziplist的节点(entry)个数
-
entry: 节点
-
zlend: 固定值0xFF,用于标记ziplist的结尾
-
prevlengh: 记录上一个节点的长度,为了方便反向遍历ziplist
-
encoding: 当前节点的编码规则,下文会详细说
-
data: 当前节点的值,可以是数字或字符串
过期策略与淘汰策略
过期策略
Redis 提供了 3 种数据过期策略并同时使用!,它们是非互斥的
- 被动删除:当读/写一个已经过期的 key 时,会触发惰性删除策略,直接删除掉这个过期 key 。
- 主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以 Redis 会定期主动淘汰一批已过期的 key 。
- 主动删除:当前已用内存超过 maxmemory 限定时,触发主动清理策略,即**「淘汰策略」**
淘汰策略
redis.conf -> maxmemory这个值来开启内存淘汰功能
# maxmemory 0 设置最大内存使用量,如果这个值设置成0,代表无内存使用限制
# maxmemory-policy noeviction 设置淘汰策略 默认是什么?
从已设置过期时间的数据集中挑选最近最少使用的数据淘汰(随机挑选)当内存达到限制的时候无法写入非过期时间的数据集
1、volatile-lru
从已设置过期时间的数据集中挑选最少使用的数据淘汰(随机挑选)当内存达到限制的时候无法写入非过期时间的数据集
2、volatile-ttl
从已设置过期时间的数据集中挑选将要过期的数据淘汰(随机挑选)当内存达到限制的时候无法写入非过期时间的数据集
3、volatile-random
从已设置过期时间的数据集中任意选择数据淘汰
4、allkeys-lru
从数据集中挑选最近最少使用的数据淘汰。当内存达到限制的时候,对所有数据集挑选最近最少使用的数据淘汰,可写入新的数据集
5、allkeys-random
从数据集中任意选择数据淘汰,当内存达到限制的时候,对所有数据集挑选随机淘汰,可写入新的数据集
6、no-enviction
当内存达到限制的时候,不淘汰任何数据,不可写入任何数据集,所有引起申请内存的命令会报错
Cluster
cluster适用场景 高可用、海量数据、横向扩容,如果数据量不大,用主从 + 哨兵sentinel就行(哨兵如何读写分离)
什么时候整个集群不可用?
- 如果集群任意master挂掉且当前master没有slave集群进入fail状态,因集群的slot映射[0-16383]不完整,cluster-require-full-coverage参数(兼容部分失败)默认关闭
- 集群超过半数以上master挂掉,无论是否有slave,集群进入fail状态.
集群元信息采用非集中式,即每个节点都有集群完整信息,采用gossip协议进行集群通信(展开),节点采用哈希槽机制,每个节点占用一定范围的槽位,共16383(2的14次方)
redis-cli请求集群时,集群首先用CRC16算法对key的有效部分(即用"{}"扩上的部分)进行计算(n & 16383)算出对应槽位,并判断当前节点是否匹配这个槽位,如果不是,则向客户端回复重定向信息(MOVED{slot}{ip}{port}格式)客户端再次发起请求(注意:集群模式mget这种批量操作如果键槽比较分散会查询较慢需要优化)
故障转移过程,集群节点故障后其他节点如果发现心跳失败,则对其进行主观下线,并且把这个消息在集群内广播,当超过一半的节点都发现主观下线时,会升级为客观下线,此时如果这个节点有从节点,则在从节点里选一个作为主节点,选举方式raft,选完主节点将触发旧的主节点替换,即把槽位委派给自己,并且向集群内广播,通知自己是主节点
节点的新增和删除
新增:
- 创建新主节点
- 调用ruby脚本 ruby redis-trib.rb add-node (新)192.168.127.130:7006 (原集群任意主节点)192.168.127.130:7000
- 选择一个主节点并从其上面抽取一部分槽位,会提示输入抽取槽位数量、接收槽位的节点ID(槽位来源某个节点还是所有节点?) ruby redis-trib.rb reshard (原集群任意主节点,因为只有主节点知道集群信息)192.168.127.130:7000
删除:
- 迁移待删除节点的槽位至其他主节点,ruby redis-trib.rb reshard (待删除节点)192.168.127.130:7006 会提示输入分配的槽位数量、接收槽位的节点ID
- ruby redis-trib.rb del-node (待删除节点)192.168.127.130:7006 71ecd970838e9b400a2a6a15cd30a94ab96203bf
Cluster模式下的批量操作的优化
-
一种是使用**”{}”来将要hash的key的部分**包裹起来,rediscluster写入数据时只会对key中被”{}”包裹部分进行哈希取模计算slot位置。即存入时使用 “a{123}”和”b{123}”是在同一个slot上。这样就可以批量读取存放在同一个slot上的数据。
-
第二种方法是在批量读取时,先计算所有数据的存放节点。具体做法是,我们已经知道了rediscluster对数据哈希取模的算法,可以先计算数据存放的slot位置,可以通过jedis.clusterSlots()方法知道每个节点分管的slot段。这样就可以通过key来计算出数据存放在哪个节点上。然后根据不同的节点将数据分成多批。对不同批的数据进行分批pipeline处理。
cluster集群详细搭建过程
https://www.cnblogs.com/PatrickLiu/p/8458788.html
Sentinel
Redis-Sentinel是Redis官方推荐的高可用性(HA)解决方案,Master-slave的模式时主节点宕机从节点并不会自动切换。Redis-sentinel能监控多个master-slave集群,**发现Master宕机后,sentinel集群进行选举,选举出一个sentinel,根据Master配置找到Slave节点并选举其中一个升级为新的主节点(会修改相关节点的配置文件)来实现自动切换,主从切换统筹工作由单个sentinel去做。**sentinel本身也是一个redis进程,当使用哨兵模式时,客户端就不再直连redis服务机器了,而是连接哨兵机器
- sentinel侧重于高可用,而cluster侧重于存储与扩展(cluster相当于在哨兵基础上增加了扩展性)
- 当Master宕机时才会触发切换
- sentinel自身的高可用至少要有3个节点,在选举leader时必须超过50%的节点投票才能选出来,如果只有2个节点,挂了一个后sentinel集群直接不可用
- 集群模式也好单个哨兵也好,配置文件内只需配置要监控的Master节点,无需配置其他哨兵节点,它可以自动识别监控该Master的其他哨兵,并维护到一个列表中,这个列表保存了 Sentinel 已知的,监视同一个主服务器的所有其他Sentinel。
应用场景
1、【ZSET】榜单应用 TopN 游戏分数排行
redis 120.0.0.1:6379> zadd game 98 lucy
redis 120.0.0.1:6379> zadd game 99 lilei
redis 120.0.0.1:6379> zadd game 85 poly
取所有人的分数
redis 120.0.0.1:6379> ZREVRANGE game 0 -1
1> “lilei”
2> “lucy”
3> “poly”
获取Top2
redis 120.0.0.1:6379> ZREVRANGE game 0 1
1> “lilei”
2> “lucy”
2、【LIST】最新操作(最新评论、最新关注的粉丝)
利用list结构来做,可以从头插入元素也可以从尾部插入,再利用“ltrim”来截取/保留指定长度的数据,这就可以实现“最新”操作。
redis 120.0.0.1:6379> RPUSH mylist one
redis 120.0.0.1:6379> RPUSH mylist two
redis 120.0.0.1:6379> RPUSH mylist three
redis 120.0.0.1:6379> LREANGE mylist 0 -1
1> “one”
2> “two”
3> “three”
redis 120.0.0.1:6379> LTRIM mylist 0 1
redis 120.0.0.1:6379> LREANGE mylist 0 -1
1> “one”
2> “two”
3、【INCR】计数-统计某用户点击量/访问频率
不同数据结构的计数命令不同,拿set结构举例,它使用“scard”命令获取集合内元素的数量
redis 120.0.0.1:6379> SET login_times 0
redis 120.0.0.1:6379> INCR login_times
redis 120.0.0.1:6379> GET login_times
redis 120.0.0.1:6379> 1
4、消息队列
Redis只提供了阻塞获取,因队列长度是无限的,所以不存在队列存满需等待消费者消费的情况,即只提供阻塞版本的获取即可。
生产者/消费者模式(PUSH/POP)
基于阻塞队列实现
BRPOP key [key …] timeout 超时时间内,从队列右侧阻塞式弹出元素
BLPOP key [key …] timeout
(可先生产再消费,也可消费者先阻塞获取等待生产者入队)
入队
127.0.0.1:6379>RPUSH my_list “Spark” “hadoop”"hive"
(integer) 3
127.0.0.1:6379>LRANGE my_list 0 -1
1)“Spark”
2)“Hadoop”
3)“Hive”
出队
127.0.0.1:6379>BLPOP my_list other_list1 other_list2 60
1)“my_list1” #执行弹出操作的列表
2)“Spark” #被弹出的项
发布-订阅模式(PUB/SUB)
基于Redis自身提供了PUB/SUB机制
SUBSCRIBE my_channel_1 [my_channel_2 …]
订阅给定的一个或多个频道的信息
PUBLISH my_channel_1 message
将信息message发送到指定频道my_channel_1
ClientA
127.0.0.1:6379>SUBSCRIBE my_chnnel_1
Redinging message… <press Ctrl + c to quit>
(等待生产者发布…,发布完成,收到消息)
1)“subscribe”
2)“chennel”
3)“1”
1)“message”
2)“my_chennel_1”
3)“this is my message!”
ClientB
127.0.0.1:6379>PUBLISH my_chnnel_1 "this is my message!"
(integer) 1
https://github.com/doocs/advanced-java
https://github.com/Snailclimb/JavaGuide
https://github.com/CL0610/Java-concurrency
SH my_channel_1 message**
将信息message发送到指定频道my_channel_1
ClientA
127.0.0.1:6379>SUBSCRIBE my_chnnel_1
Redinging message… <press Ctrl + c to quit>
(等待生产者发布…,发布完成,收到消息)
1)“subscribe”
2)“chennel”
3)“1”
1)“message”
2)“my_chennel_1”
3)“this is my message!”
ClientB
127.0.0.1:6379>PUBLISH my_chnnel_1 "this is my message!"
(integer) 1
https://github.com/doocs/advanced-java
https://github.com/Snailclimb/JavaGuide
https://github.com/CL0610/Java-concurrency