文章目录
什么是缓存?
缓存是指数据交换的缓冲区,目的是把读写速度慢的介质保存在读写速度快的介质中,从而提高读写速度,减少时间消耗,比如:
- CPU高速缓存:高速缓存的读写速度远高于内存
CPU读数据时,先从高速缓存中读,若没有则从内存中读
CPU写写数据时,先写高速缓存,在写回内存 - 磁盘缓存:磁盘缓存其实就把常用的磁盘数据保存在内存中,内存读写速度也远高于磁盘
读数据,先从内存读取
写数据时,可先写到内存或定时定量回写到磁盘,或者同步回写
缓存算法
- LRU(最近最少使用)
- LFU(最不经常用)
- FIFO(先进先出)
手写LRU代码
public class LRUCache {
private class CacheNode{
CacheNode prev;
CacheNode next;
int key;
int val;
public CacheNode(int key, int val) {
this.key = key;
this.val = val;
this.prev = null;
this.next = null;
}
}
private int capacity;
private CacheNode head = new CacheNode(-1,-1);
private CacheNode tail = new CacheNode(-1,-1);
private Map<Integer,CacheNode> map = new HashMap<>();
public LRUCache(int capacity) {
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
CacheNode current = map.get(key);
current.next.prev = current.prev;
current.prev.next = current.next;
moveToTail(current);
return map.get(key).val;
}
public void put(int key, int value) {
if (get(key) != -1) {
map.get(key).val = value;
return;
}
if (map.size() == capacity) {
map.remove(head.next.key);
head.next = head.next.next;
head.next.prev=head;
}
CacheNode insert = new CacheNode(key, value);
map.put(key, insert);
moveToTail(insert);
}
private void moveToTail(CacheNode current) {
current.prev = tail.prev;
tail.prev = current;
current.prev.next = current;
current.next = tail;
}
}
常见问题?
如何避免缓存“穿透”?
缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时被动写,并且处于容错考虑,如果从DB查不到数据则不写入缓存,所以就会导致每次请求都去DB查询,失去了缓存的意义
在流量大时,可能DB就挂掉了
解决方案:
- 缓存空对象
当从DB查询数据为空时,我们把空结果进行缓存,用具体的值和真正的数据区分开,并且设置一个较短的过期时间,防止之后有值却查询不到 - BollmFilter布隆过滤器
在缓存服务的基础上,构建BloomFilter数据结构,在BloomFilter中存储对应的KEY是否存在,若存在,说明该KEY可能存在,若不存在,则该KEY一定不存在:- 根据KEY查询布隆过滤器缓存。如果不存在对应的值,直接返回;如果存在,继续向下执行
- 根据KEY查询数据缓存的值,如果存在,则直接返回;如果不存在,继续向下执行
- 查询DB对应的值,存在则更新到缓存, 并返回该值
如何避免缓存“雪崩”?
缓存雪崩:是指缓存由于某些原因无法提供服务(挂掉、大批失效),导致所有请求全部达到DB,DB负荷大增。
解决方案:
-
缓存高可用
使用redis作为缓存,可以使用Redis Sentinel或Redis Cluster实现高可用 -
本地缓存
使用本地缓存,即使分布式缓存挂掉了,也可以将DB查询中的结果缓存至本地 -
请求DB限流
限制DB每秒请求数,避免把DB打挂,好处:- 一部分用户还能继续使用,系统没死透
- 未来缓存服务恢复后,系统立即回复
当然,被限流的请求,也要有响应的处理,走服务降级,提供一些默认的值
如何避免缓存“击穿”?
缓存击穿:是指某个热点数据在某个时期过期时,恰好这个时间点有大批的流量打进来,这些流量发现缓存过期一般都会从DB家在数据并写回缓存,但是这个时候大流量击穿到DB,会导致DB被压垮
与前两则的区别:
1. 和缓存雪崩的区别:前者针对某一KEY缓存,后者则是多个KEY
2. 和缓存穿透区别:这个KEY是真实存在的
解决方案:
-
使用互斥锁
请求发现缓存不存在时,去查询DB前,使用分布式锁,保证有且只有一个现场去查询DB,并更新到缓存:- 获取分布式锁,直到成功或超市。如果超时,抛出异常;成功则继续向下执行
- 获取缓存,如果存在值,则直接返回;不存在则继续向下执行
- 查询DB,并更新到缓存中
-
手动过期
缓存上从不设置过期时间,功能室将过期时间存在KEY对应的value上:- 获取缓存,通过VALUE的过期时间,判断是否过期。如果未过期,则直接返回,如果已过期,则继续向下执行
- 通过一个后台的异步线程进行缓存的构建,也就是手动过期。通过后台的异步线程,保证有且只有一个现场去查询DB
- 同时,随便VALUE已过期,还是直接返回。可以保证服务的可用性,损失了一定的时效性
对比:
优缺点 | 互斥锁 | 手动过期 |
---|---|---|
优点 | 思路简单,保证一致性 | 性价比高,用户无需等待 |
缺点 | 代码负责,存在死锁风险 | 无法保证缓存一致性 |
缓存和DB一致性如何保证?
原因:
- 更新DB数据之前,删除缓存的数据。此时,在删除缓存数据和更新DB数据的时间之内,恰好有一个请求,如果使用被动读取数据,此时DB数据还是老的,又会将老数据写入缓存中
- 缓存和DB的操作不在同一个事物中,可能一个DB操作成功,另一个缓存操作失败,导致不一致
解决方案:
实现数据最终一致性:
-
先淘汰缓存,在写入库
先淘汰缓存,即使写库异常,也就下次缓存读取多取一次库
但是在大并发请求下,会导致数据不一致情况引入分布式锁:
- 在写请求时,先淘汰缓存之前,先获取分布式锁
- 在读请求时,发现缓存不存在时,先获取分布式锁
-
先写库,在更新缓存
定时任务实现:- 首先,写入数据库
- 然后在写入数据库所在的事务中,插入一条记录到任务表,该记录会存储需要更新的KEY和VALUE
- 异步,定时任务每秒扫描任务表,更新到缓存中,之后删除该任务
基于消息队列实现:
- 首先,先写数据库
- 然后发送带有缓存的KEY和VALUE,此时需要有支持事务消息特性的消息队列。
- 异步,消费者消费该消息,更新到缓存中
这两种方式可以配合使用,可以先尝试更新缓存,如果失败则插入任务表,定时更新
- 双删机制,先淘汰内存,再写库,在淘汰内存
什么是缓存预热?如何实现缓存预热?
热点数据提前加载入缓存中
实现:
- 数据量不大时,项目启动,自动进行初始化
- 修复数据脚本,手动执行
- 管理界面,可以管理对应的热点数据
内存淘汰策略
- 定时去清理过期的缓存
- 当有用户请求过来时,判断这个请求所用的缓存是否过期,过期则从底层系统得到新数据更新到缓存
Redis优点
- 速度快,官方给出可以处理10w QPS
- 支持多数据类型:
支持String,List,Set,Sorted Set,Hash五种基础数据类型
同时,还提供Bitmaps等高级数据结构,比如布隆过滤器则是用了Bitmaps来实现的
单个Value最大限制保存1GB,Memcached 1MB
List可以做FIFO双向链表, 实现一个轻量级的高性能消息对类服务
Set可以做高性能tag系统
-
丰富特性
- 订阅发布 Pub/Sub
- key过期策略
- 事务
- 多DB
- 计数
- stream(5.0版本后)
-
持久化存储
AOF和RDB -
高可用
- Redis Sentinel,实现主从故障自动转移
- Redis Cluster,提供集群,实现基于槽的分片方案
Redis缺点
- 存储量基于单台服务器的内存大小
- 如果进行完全重同步,由于需要生成RDB文件,并进行传输,会占用主机的CPU,并消耗网络带宽
- 修改配置文件,重启,将硬盘数据加载进内存,时间较久,在完全恢复前,不能提供服务
Redis线程模型
Redis内部使用文件事件处理器,这个文件事件处理器是单线程的,所以Redsis才叫做单线程模型,它采用IO多路复用机制同时监听多个Socket,根据Socket上的事件来选择对应的事件处理器进行处理
文件事件处理器包含:
- 多个Socket
- IO多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器,命令请求处理器,命令恢复处理器)
多个Socket可能会并发产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个socket,会将socket产生的事件放入队列中排队,事件分派器每次从队列取出一个事件,把该事件交给对应的事件处理器进行处理:
- 客户端向redis服务请求建立连接,此时redis服务会产生一个readable事件,IO多路复用程序监听到产生的时间后,将该事件压入队列。
- 文件事件分派器从队列中获取该事件,交给连接应答处理器。
- 连接应答处理器会创建一个能与客户端通信的socket,并将该socket的readable事件与命令请求处理器关联
- 假如此时客户端发送一个 set key value请求,此时redis的socket会产生readable事件,IO多路复用器会把事件压入队列,
- 此时事件分派器获取队列中的事件,先把readble事件与命令请求处理器管理,命令请求处理器在自己的内存中完成set key value操作后,此时状态改变成writable事件,与回复处理器管理,并且写入队列回复给客户端
Redis为什么快
- C语言实现
- 纯内存操作
- 非阻塞IO多路复用机制(请求单线程,避免多线程频繁上下文切换)
redis6为多线程处理网络IO,单线程串行处理计算 - 丰富的数据结构
redis全程使用hash结构,读取速度快,对数据存储进行了优化。例如:压缩表、对短数据进行压缩,使用有序的数据结构加快读取速度
Redis的持久化方式
-
全量RDB持久化:是指在指定的时间间隔内将内存中的数据集快照写入磁盘。实际操作过程是,fork一个子进程,先将数据集写入临时文件,写入成功后,再体会之前的文件,用二进制压缩存储
优点:- 适合冷备份。并且可以非常轻松将一个单独的文件压缩后转移到其他存储介质上
- 性能最大化,对于redis服务进程而言,在开始持久化时,唯一需要做的只是fork出子进程,之后再由子进程完成这些持久化工作,极大的避免服务进程执行IO操作,RDB对redis对外提供读写服务影响非常小
- 恢复更快,更适合恢复数据,特别是在大数据情况下
缺点:
- 如果保证数据高可用性,最大限度避免数据丢失,不是一个很好的选择
- 由于RDB是fork子进程来协助完成持久化工作,若数据集较大时,可能导致整个服务器停止服务几百毫秒至1秒
-
增量AOF持久化:以日志的形式记录服务器处理的每一个写、删除操作,以文本的方式记录
优点:- 这种机制可以带来更高的数据安全性。redis提供3种同步策略,每秒同步、每修改同步和不同
- 这种机制的原理是对日志文件的写入操作采用的是append模式,因此在写入过程中即使出现宕机,也不会破坏日志文件中已经存在的内容:
- 以append-only模式写入,没有任何磁盘寻址的开销,写入性能非常高
- 若本次操作只写入一半数据出现系统崩溃问题,redis下一次启动之前,我们可以通过redis-check-aof工具来帮忙我们解决数据一致性问题
- 如果AOF日志过大,redis可以自动启用rewrite机制。即使出现后台重写操作也不会影响客户端读取。因为在rewrite log的时候,会对其中的指令进行压缩,创建出一份需要回复数据的最小日志出来。再创建日志文件的时候,老的日志文件还是找出写入,当新的merge后的日志文件ready的时候,在交换新老日志文件即可
缺点:- 对于相同数量的数据集来说,AOF大于RDB。RDB恢复大数据比AOF速度快
- 根据同步策略不同,AOF运行效率慢于RDB
选择:
- 不要只使用RDB,会导致丢失一大段时间内的数据
- 不要只是用AOF,如果用AOF做冷备,恢复速度慢
- 可以同时使用,AOF来保证数据不丢失,RDB做不同程度的冷备,在AOF文件丢失或不可用时,使用RDB进行快速回复
同时使用时,redis重启,会使用AOF来重新构建数据
总结:
- bgsave做镜像全量持久化,aof做增量持久化。因为bgsave会消耗较长时间,不够实时,在停机的时候会导致大量数据丢失,所以需要aof来配合使用。在redis实例重启时,会使用bgsave持久化文件重新构建内存,在使用aof重放近期的操作指令来完整恢复重启之前的状态
- 若机器掉电了,会取决于aof日志sync属性的配置,如果不要求性能,在每写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的情况下每次都sync是不现实的,一般使用定时sync,比如1秒1次,这样最多只会丢失1秒的数据
- bgsvae的原理:fork和cow,fork是指redis通过创建子进程来进行bgsave操作。cow是指copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会主键和子进程分离开来
- 不建议在主redis节点开启RDB功能,因为会带来一定时间的阻塞,特别是数据量大的时候
redis的过期策略
- 被动删除:当读写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期的key
- 主动删除:由于惰性删除策略无法保证冷数据及时删除,所以redis会定期主动淘汰一批已过期的key
- 主动删除:当前已用内存超过maxmemory限定时,触发主动淘汰策略,
redis的淘汰策略
- volatile-lru:从已设置 过期时间的数据集中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
- allkeys-lru:从数据集中挑选最少使用的淘汰
- allkeys-random:从数据集中任意选择数据淘汰
- no-enviction:当内存达到限制时,不淘汰任何数据,不可写入任何数据
如果有大量的key需要设置同一时间过期
- 如果大量的key过期时间设置的过于集中,到过期的那个时间点,redis可能会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一些
- 调大hz参数,每次过期的key更多,从而最终达到避免一次过期过多。hz调大将会提高redis主动淘汰的频率,如果你的redis存储中包含很多冷数据占用内存过大的话。可以考虑将这个值调大,不要超过100,默认为10。此时会观察到cpu会增加2%,但是对冷数据的内存释放速度确实有明显的提高
Redis使用场景
- 数据缓存
- 会话缓存
- 时效性数据
- 访问评率
- 社交列表
- 记录用户判定信息
- 交集、并集、差集
- 热门列表与排行榜
- 最新动态
- 消息队列
- 分布式锁
如何使用redis分布式锁
- 正确获取锁
set指令附带nx参数,保证有且只有一个进程获取 - 正确的释放锁
使用lua脚本,比对锁持有的是不是自己。如果是,则进行删除来释放 - 超时的自动释放锁
set指令附带expire参数,通过过期机制来实现超时释放 - 未获得到锁的等待机制
sleep或者基于redis的订阅pub/sub机制 - 锁的重入
通过ThreadLocal记录是第几次获得相同的锁
有且第一次计数为1获得锁时,才向redis发起获得锁操作
有且技术为0释放锁时,才向redis发起释放锁的操作 - 锁超时的处理
一般情况下,可以考虑告警+后台线程自动续锁的超时时间。通过这样的机制,保证有且仅有一个现场,正在持有锁 - redis分布式锁丢失(readLock)
如何使用redis实现消息队列
一般使用list结构作为队列,rpush生成消息,lpop消费消息。当lpop没有消息时,需要适当sleep一会再重试
- 如果不用sleep,list还有一个指令叫blpop,在没有消息时,它会阻塞直到消息到来
- 生产一次,多次消费,使用pub/sub主题订阅模式,缺点:在消费者下线的时候,会丢失生成数据
- 延时队列,使用sortedset,拿时间戳做score,消息内容做key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理
Redis事务
- multi、exec、discard、watch
我们可以通过multi命令开启一个事务 最后可以通过exec、discard命令来提交/回滚事务内的操作
- lua脚本
Redis CAS操作
使用watch命令
Redis集群方案
- Redis Sentinel:提供主从
- Redis Cluster:提供分区
- 客户端分片
Redis主从同步
- 一个主数据库可以有多个从数据库,一个从数据库只能有一个主数据库
- 第一次同步时,主节点偶一次bgsave操作,并同时将后续修改操作记录到内存buffer,待完成后将RDB文件全量同步到复制节点,复制节点接收完成后RDB镜像加载到内存,加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程
Redis怎么优化内存占用
- redisObject对象
- 缩减键值对象
- 共享对象池
- 字符串优化
- 编码优化
- 控制key的数量
假如redis里面有10w个key是以某个固定的已知前缀开头,如何找出
使用keys指令:但是可能会导致线程组设一段时间,线上服务会停顿,至到指令执行完毕,此时可以使用scan指令,可以无阻塞的提取出指定模式的key列表,但是有一定的重复,客户端进行一次去重就好
Redis常见性能问题
- master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件
- master 调用bgrewriteaof 重写aof文件,aof在重写的时候回占大量的cpu和内存资源,导致服务load过高,出现短暂的服务暂停
- 尽量避免在压力很大的主库上增加过多的从库
- 主从复制不要用图状结构,用单向链表结构更为稳定
主节点开启AOF,从节点开启RDB