那些不得不知的缓存知识
1.缓存的本质
1.1 缓存是什么
缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。典型的应用场景:有 cpu cache, 磁盘 cache 等。
几种存储器读写速度也是相差很大的,读写速度从大到小的排列顺序:通用寄存器>高速缓存>主存>外存。
1.2 缓存的作用
在传统的后端业务场景中,访问量以及响应时间的要求均不是很高,通常只是用单 DB 即可满足要求,但是随着业务的发展,访问量慢慢上来了,DB 就有点吃不消了,响应时间也出现了大幅度增长,严重影响到用户体验(慢),这时会通过增加 DB 实例/读写分离等多种手段进行优化,增大 DB 层的性能,但这些远远不够,访问量一般都是指数级增长,但是这种扩展都是线性的,一下子就无法满足响应时间的要求。这样就引入 Cache 层,将高频访问到的热数据放入缓存中,可大大提高系统整体的承载能力,这样原有的架构也变成 Cache+DB 两层。
这样架构优化有,有以下几种好处:
可以大幅度提升数据读取速度;
提升了系统的可扩展能力,可通过扩展缓存,快速提升系统的承载能力;
降低了数据存储的成本,加入 Cache 层可分担 DB 层的请求量,节省机器成本.
举个例子,在实际业务中有一个查询接口的系统需求是系统能承载峰值为 10 万次/秒的请求查询,且查询结果和实际系统存在一定程度上的不一致性,但是延时时间不能超过 5 秒。大致可以推算出,Tomcat 的吞吐量为 5000/s,Redis 的吞吐量为 50000/s,MySQL 的吞吐量为 700/s,可以看出加了缓存之后大大提高了系统的承载能力,当然也需要综合业务需求考虑数据的一致性和用户体验问题。
适合的场景如下: 存在热点数据;对响应实效要求较高;对一致性要求不严格;需要实现分布锁.
不适合的场景如下:读少;更新频繁;对一致性要求严格.
1.3 访问缓存的模式
通常应用访问缓存有以下几种模式:
双读双写: 对于读操作,先读缓存,如果不存在则读取数据库,读取数据库后再回写缓存;对于写操作,先写数据库,再写缓存;好处是逻辑简单,实现成本较低,但可能造成缓存穿透、高并发情况下数据不一致的问题;
异步更新: 全量数据全部保存在缓存中,并且不设置缓存系统的过期时间,由异步服务将数据库里的变更或者新增的数据更新到缓存中,常用做法是通过 MySQL 的 binlog 将更新操作推送到缓存中。好处是不会有缓存穿透,数据强一致,但也存在数据异构成本大的问题;
串联模式: 应用直接在缓存上进行读写操作,缓存作为代理层,根据需要和配置与数据库进行读写操作,这样既兼顾了数据一致性、缓存穿透、异构成本等问题,但也带了设计和维护成本的提升。
2. 缓存的高可用与高并发
2.1 缓存高可用
一般的缓存方案是基于分片的主从来实现的,通过分片思路来分隔大数据量的查询,通过主从来说完成高可用和部分高性能需求。通过多个数据副本,来化解查询所带来的性能压力。使用异步复制的方式实现主从,通过强一致性协议本来保证,即写事务从主节点开始,并发送事务给从节点,所有从节点都会收到并返回数据的信息给主节点,然后主节点返回成功。在这个过程中都是内存操作,所有主节点和从节点都通过异步读写数据来保证同步。
2.2 缓存高并发
网上有大神实测过,MySQL 在命中内存索引的情况下可达到 10 万每秒的 QPS,Redis 也有大致相同的性能表现,这里关系到网络 IO 和内存。还有些提升缓存访问性能的其他办法,比如把没有依赖关系的缓存访问变成并行执行,把有依赖的关系的保留串行执行,这种模式会使用到 CompleteFuture 这样的任务工具。比如,要获取一个课表的信息,需要同时获取商品的课程、教师、课节等信息。
3. 常用的缓存介绍
经常使用的缓存有两种,一种是内存型缓存,比如 Map/Ehcache/Guava Cache,另一种缓存中间件如 Redis、Memcached 等分布式缓存。
本地缓存:指的是在应用中的缓存组件,和应用同一个进程,请求缓存非常快。单体应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;它的缺点是将缓存共享给多个应用或进程,容易导致数据不一致的问题,还受堆区域影响,缓存的数据量非常有限,同时缓存时间受 GC 影响。主要满足单机场景下的小数据量缓存需求,同时对缓存数据的变更无需太敏感感知,如上一般配置管理、基础静态数据等场景。
分布式缓存:指的是与应用分离的缓存组件或服务,与本地应用隔离,多个应用可直接通过共享缓存。易于扩展,有较强的数据一致性的,一般需要单独的缓存中间件的运维资源,成本较高。
** 一般目前常用的分布式缓存中间件有 Redis/Memcache/Tair,在做技术架构设计时,分布式缓存的技术选型常需要考虑以下几个因素: 数据类型/线程模型/持久机制/客户端/高可用/队列支持/事务/数据淘汰策略/内存分配。**
数据结构: Redis 支持 String/list/hash/set/zset 数据结构;Memcache 支持 key/value; Tair 支持数据结构跟 Redis 一致;
线程模型: Redis 是单线程的,其他两个都是多线程的;
复制模型: Redis 支持主从复制/主从链两种模式; Memcache 依赖第三方组件; Tair 是基于集群去实现的;
持久化机制: Redis 支持 ROF/AOF;Memcache 依赖第三方组件;Tair 由存储结构决定;
存储结构: Redis 支持压缩串/字典/跳跃表; Memcache 支持 Stab; Tair 支持 MDB(内存)/RDB(Redis)/FDB(持久化)/LDB(LevelDB);
高可用: Redis 通过主从/Sentinel/Cluster 模式保证; Memcache 通过第三方组件保存; Tair 通过 Cluster 模式保证.
4. 注意事项
4.1 缓存雪崩
缓存雪崩指的是大量缓存在同一时间全部失效,而假如恰巧这一段时间同时又有大量请求被发起,那么就会造成请求直接访问到数据库,可能会把数据库冲垮。缓存雪崩一般形容的是缓存中没有而数据库中有的数据,而因为时间到期导致请求直达数据库。解决缓存雪崩的方法:
加锁,保证单线程访问缓存。这样就不会有很多请求同时访问到数据库;
失效时间不要设置成一样。典型的就是初始化预热数据的时候,将数据存入缓存时可以采用实效时间+随机时间来确保不会在同一时间有大量缓存失效;
内存允许的情况下,可以将缓存设置为永不失效.
4.2 缓存击穿
缓存击穿和缓存雪崩很类似,区别就是缓存击穿一般指的是单个缓存失效,而同一时间又有很大的并发请求需要访问这个 key,从而造成了数据库的压力。解决缓存击穿的方法:
加锁,保证单线程访问缓存。这样第一个请求到达数据库后就会重新写入缓存,后续的请求就可以直接读取缓存;
内存允许的情况下,可以将缓存设置为永不失效.
4.3 缓存穿透
缓存穿透指的是不存在的 key 进行大量的高并发查询,导致缓存无法命中,每次请求都要穿透到后端的数据库进行查询,使数据库的负载过高,压力过大。对于缓存穿透问题,加锁并不能起到很好地效果,因为本身 key 就是不存在,所以即使控制了线程的访问数,但是请求还是会到达数据库。解决缓存穿透的方法:
接口层进行校验,发现非法的 key 直接返回。比如数据库中采用的是自增 id,那么如果来了一个非整型的 id 或者负数 id 可以直接返回,或者说如果采用的是 32 位 uuid,那么发现 id 长度不等于 32 位也可以直接返回;
将不存在的数据也进行缓存,可以直接缓存一个空或者其他约定好的无效 value。采用这种方案最好将 key 设置一个短期失效时间,否则大量不存在的 key 被存储到 Redis 中,也会占用大量内存.
5.相关实践
在正常情况下,读的顺序是先缓存,后数据库; 写的顺序是先数据库,后缓存;
在使用本地缓存,一定控制好缓存对象的个数以及生命周期,因为 JVM 的内存容量有限,会影响 JVM 的性能,甚至导致内存溢出等;
对缓存的数据结构、缓存大小、缓存数量、缓存的失效时间,然后根据业务情况推算出一定时间内的容量的使用功能,根据容量评估的结果来申请和分配缓存资源,否则很容易造成缓存资源浪费或者缓存空间不足;
写入缓存时,一定要写入完整正确的数数据;
从物理上隔离不同业务的使用缓存,核心业务和非核心业务使用不同的缓存实例,如果有条件,则请对每个业务使用单独的实例或者集群,以减少不同应用之间的相互影响的可能性;
所有缓存实例都需要添加监控,如慢查询、大对象、内存使用情况都要做可靠的监控;
通过一定的规范来限制每个应用的 key 的前缀如(系统:模块:功能)+ key 来进行隔离设计,避免相互覆盖的情况;
任何缓存的 key 都应该设置失效实践,而且失效时间不应该集中在一点,加个随机时间,否则会导致缓存占满内存或者缓存雪崩;
缓存的数据的数据不易过大, 尤其是 Redis,在单个缓存 key 的数据量过大时,会阻塞其他请求的处理;
对于存储较多 value 的 key,尽量不要使用 HGETALL 等集合操作,该操作会造成请求阻塞;
如果需要更新大量的数据时,尤其是批量处理时,请使用批量模式;
在使用缓存时,一定要有降级处理,缓存有问题或者失效时也可以回溯到数据库进行处理;
作者:Aston_Martin
链接:https://juejin.cn/post/6944249384137818143
来源:掘金
评论