Redis缓存更新策略
操作缓存和操作数据库时有三个问题需要考虑:
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都要更新缓存,无效写操作较多(X)
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(√)
- 如何保证缓存与数据库的操作的同时成功和失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
- 先操作缓存再操作数据库,会导致在操作数据库的时间中会导致读取到旧数据并存入缓存的问题,而且数据库操作慢,发生概率大
- 先操作数据库在操作缓存,会导致先查到的旧数据被存入到缓存中,但是由于操作数据库完成到写入缓存这段时间非常的短,发生概率小。
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
解决办法
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗;可能造成短期的不一致
- 布隆过滤器
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂;可能误判
- 增加ID的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户全新校验
- 做好热点参数的限流
缓存雪崩
指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降低限流策略
- 给业务添加多级缓存
缓存击穿
也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案:
- 互斥锁
- 优点:没有额外的内存消耗;保证一致性;实现简单
- 缺点:线程需要等待,性能受影响;可能有死锁风险
- 逻辑过期
- 优点:线程无需等待,性能较好
- 缺点:不保证一致性;有额外内存消耗;实现复杂
互斥锁实现
Redis中的setnx只有在键值对不存在的情况下才能设置,而有值之后再setnx是无法修改值的,所以可以用setnx来获取和释放锁,只有成功set的线程才能拿到锁,释放锁就是删除这个键值对。在java中是setIfAbsent
,返回Boolean值,设置成功为返回True,失败返回False。
Redis分布式锁
实现分布式锁时需要实现的两个基本方法:
-
获取锁:
-
互斥:确保只有一个线程获取锁
set lock thread NX EX 10
-
-
释放锁:
-
手动释放
-
超时释放:获取锁时添加一个超时时间
del key
-
Redis分布式锁的并发问题
如果业务阻塞时间超过了锁的TTL,那么当前业务就可能删除其他业务所加的锁,导致出现误删情况,进而会出现严重的并发问题。
解决办法:每次释放锁之前查看当前锁的value,也就是线程ID是否是当前线程,如果是那么就是自己的锁,可以释放;如果不是,就不是当前线程的锁,不允许释放。
由于判断锁和释放锁不是原子操作导致的并发问题
解决办法:Redis的Lua脚本,RedisTemplate调用Lua脚本
Redisson
基于Setnx实现的分布式锁存在下面的问题:
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
- 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从还并未同步到主的锁数据,则会出现锁数据丢失的情况。
Redisson是一个在Redis基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redisson可重入锁原理
类似于ReentrantLock实现,底层使用的是Redis的hash结构,key是锁名,value是hash结构,hash结构的field是线程名,value是state变量。
集群模式(multiLock)
集群模式要求获取锁要在所有的节点上都获取到锁,如果有一个几点宕机了,那么就无法在此节点获取锁,就算集群模式下的主从同步出现数据丢失问题,由于需要再所有节点都获取到锁,所以最终还是无法获取锁的。
Redis消息队列
最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也被称为消息代理
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
Redis提供了三种不同的方式来实现消息队列:
- list结构:基于List结构模拟消息队列
- PubSub:基本的点对点消息模型
- Stream:比较完善的消息队列模型
基于List结构模拟消息队列
Redis的list数据结构是一个双向链表,很容易模拟队列,我们利用LPUSH结合RPOP、或者RPUSH结合LPOP来实现,不过要注意的是,当队列中没有消息时,RPOP或LPOP操作会返回null,而我们需要阻塞的方式,所以要使用BRPOP和BLPOP来实现阻塞效果。
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失(pop后如果没有处理就丢失了)
- 只支持单消费者(一个消息只能被一个消费者消费)
基于PubSub的消息队列
PubSub是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSCRIBE channel [channel]:订阅一个或多个频道
- PUBLISH channel msg:向一个频道发送消息
- PSUBSCRIBE pattern [pattern]:订阅与pattern格式匹配的所有频道
优点:
- 采用发布订阅模型,支持多生产、多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失,如果某个频道没人订阅,那么发送的消息不会被持久化,将会被直接丢弃
- 消息堆积有上限,超出时数据丢失
基于Stream的消费队列-消费者组
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点
- 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
- 消息标识:消费者组会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每个消息都被消费
- 消息确认:消费者获取消息后,消息处于pending状态,并存入一个pending-list,当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。
分布式缓存
Redis持久化RDB
RDB全程Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取文件,恢复数据。
RDB方式bgsave的基本流程:
- fork主进程得到一个子进程,通过相同的页表映射来共享内存空间
- 子进程读取内存数据并写入新的RDB文件
- 用新RDB文件替换旧的RDB文件。
RDB会在什么时候执行,save 60 1000代表什么含义?
- 默认是服务停止时
- 代表60秒内至少执行1000次修改则触发RDB
RDB的缺点?
- RDB执行间隔时间长,两次RDB之间写入数据由丢失的风险
- fork子进程、压缩、写出RDB文件都比较耗时
Redis持久化AOF
AOF全称为Append only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件中,可以看做事命令日志文件。
配置
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF
# 是否开启AOF功能
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"
AOF的命令记录的频率可以通过redis.conf文件来配置:
# 表示每执行一次写命令,立即记录到AOF文件,性能最差,最可靠
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓存区数据写到AOF文件,是默认方案,可能丢失这1秒内的内容,性能适中
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓存区内容写回磁盘,性能最好,但是安全最低
appendfsync no
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF胡记录对同一个Key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
Redisu也会在触发阈值时自动去重写AOF文件,阈值也可以在redis.conf中配置:
# AOF文件比上次文件增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
AOF与RDB的优缺点
Redis主从
单节点Redis的并发能力是有上限的,要进一步提高redis的并发能力,就需要搭建主从集群,实现读写分离。
开启主从关系
使用replicaof或者slaveof命令。
有临时和永久两种模式:
- 修改配置文件(永久生效)
- 在redis.conf中添加一行配置:
slaveof <masterip> <masterport>
- 在redis.conf中添加一行配置:
- 使用redis-cli客户端连接到redis服务,执行slaveof命令(重启后失效):
slaveof <masterip> <masterport>
主从同步原理
主从第一次同步是全量同步
数据同步原理
主从第一次同步是全量同步,但如果是slave重启后同步,则执行增量同步
注意:repl_baklog大小是有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。
可以从以下几个方面来优化主从集群:
可以从以下几个方面来优化Redis主从集群:
- 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。
- 在Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
- 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
- 限制一个master上的slave结点数量,如果实在是太多slave,则可以采用主从从链式结构,减少master压力
总结
简述全量同步和增量同步区别?
- 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发生给slave。
- 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave
什么时候执行全量同步?
- slave结点第一次链接master结点时
- slave结点断开时间太久,repl_baklog中的offser已经被覆盖时
什么时候执行增量同步?
- slave结点断开又恢复,并且在repl_baklog中能够找到offset时
Redis哨兵
哨兵的作用
Redis提供了哨兵机制实现主从集群的自动故障恢复。
- 监控:Sentienl会不断检查您的master和slave是否按预期工作。
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主。
- 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端。
服务状态监控
Sentinel基于心跳机制检测服务状态,每一秒向每个实例发送ping命令:
- 主观下线:如果某Sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线
- 客观下线:如果超过指定数量的Sentinel节点都认为该实例主观下线,则该实例客观下线。
选举新的master
选择依据:
- 首先会判断slave结点与master结点断开时间长短,如果超过指定值,则会排除该slave结点
- 然后判断slave结点的slave-priority值,值越小优先值越高
- 如果slave-priority值一样,则判断slave结点的offset值,越大说明越新,优先级越高
- 最后是判断slave结点的运行id大小,越小优先级越高
如何实现故障转移
当选中一个slave作为新的master后,故障的转移的步骤如下:
- Sentinel给备选的slave1节点发送slaveof no one命令,让该结点成为master
- Sentinel给所有其他slave发送slaveof 192.168.150.101 7002命令,让这些slave成为新master的从结点,开始从新的master上同步数据。
- 最后,Sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave结点
Redis分片集群
分片集群结构
主从哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
- 海量数据存储问题
- 高并发写的问题
使用分片集群可以解决上述问题,分片集群特征:
- 集群中有多个master,每个master保存不同数据
- 每个master都可以有多个slave节点
- master之间通过ping监测彼此健康状态
- 客户端请求可以访问集群任意节点,最终都会被转发到正确节点
散列插槽
Redis会把每一个master结点映射到16384个插槽上,数据key不是与节点绑定,而是与插槽绑定。redis根据整个key或者key中包含可{}的部分,根据CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot
数据迁移
当master出现宕机时,执行以下的数据迁移步骤:
多级缓存
首先使用nginx作为网关,然后使用nginx作为本地缓存,使用OpenResty直接操作Redis,Redis没有命中再去访问后端,这样设计的原因:nginx性能>Redis>Tomcat>MySQL
JVM进程缓存
- 优点:读取本地内存,没有网络开销,速度快
- 缺点:存储容量有限,可靠性较低,无法共享
- 场景:性能要求高,缓存数据量较小
JVM进程缓存主要使用Caffeine,使用方法跟HashMap相同,不同点可以主动设置缓存的数量上限和有效时间。
JVM的进程缓存是在集群中无法共享的,所以我们要按照请求的id来做负载均衡。
location /item{
proxy_pass http://tomcat-cluster;
}
upstream tomcat-cluster {
# 一致性hash来将不同的请求路径映射到不同的服务器
hash $request_uri;
server 192.168.150.1:8081;
server 192.168.150.1:8082;
}
Nginx缓存
在nginx中要使用OpenResty来对请求进行处理,也可以对后端发起请求,也可以直接处理Redis。
在Nginx中本地缓存功能由OpenResty的shared dict来提供。
nginx查询redis
使用lua脚本,以及OpenResty也提供了操作Redis的模块
-- 引入Redis模块
local redis = require("resty.redis")
-- 初始化redis对象
local red = redis:new()
-- 设置Redis超时时间
red:set_timeouts(1000,1000,1000)
-- 获取一个连接
local ok, err = red:connect(ip,port)
nginx查询后端
local resp = ngx.location.capture("/path",{
method = ngx.HTTP_GET, -- 请求方式
args = {a=1,b=2}, -- get方式传参数
body = "c=3&d=4", -- post方式传参数
})
缓存同步
缓存数据同步的常见方法有三种:
- 设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
- 优势:简单、方便
- 缺点:时效性差,缓存过期之前可能不一致
- 场景:更新频率较低,时效性要求低的业务
- 同步双写:在修改数据库的同时,直接修改缓存
- 优势:时效性强,缓存与数据库强一致
- 缺点:有代码入侵,耦合度高
- 对一致性、时效性要求较高的缓存数据
- 异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
- 优势:低耦合,可以同时通知多个缓存服务
- 缺点:时效性一般,可能存在中间状态不一致
- 场景:时效性要求一般,有多个服务需要同步
基于Canal的异步通知:
Canal客户端
编写监听器,监听Canal消息:
@CanalTable("tb_item")
@Component
public class ItemHandler implements EntryHandler<Item> {
@Overide
public void insert(Item item){
//新增数据到redis
}
@Override
public void update(Item before, Item after) {
//更新redis数据
//更新本地缓存
}
@Override
public void delete(Item item){
//删除redis数据
//清理本地缓存
}
}
Canal推送给canal-client的是被修改的这一行数据,而我们引入的canal-client则会帮我们把数据封装到Item实体类中。这个过程中需要知道数据库与实体的映射关系,要用到JPA的几个注解:
@Data
@TableName("tb_item")
public class Item {
@TableId(type=IdType.AUTO)
@Id //标记 表中的id字段
private Long id;
@Column(name="name") //标记表中与属性名不一致的字段
private String name;
//...其他字段
private Data updateTime;
@TableField(exist=false)
@Transient //标记不属于表中的字段
private Integer stock;
@TableField(exist=false)
@Transient
private Integer sold;
}
Redis网络模型
Redis到底是单线程还是多线程?
- 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
- 如果是聊整个Redis,那么答案就是多线程
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
- Redis 4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令unlink
- Redis 6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率
为什么Redis要选择单线程?
- 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
- 多线程会导致过多的上下文切换,带来不必要的开销
- 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣