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. 常用的缓存介绍
经常使用的缓存有两种,一种是内存型缓存,比如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模式保证.
3. 缓存的高可用与高并发
3.1 缓存高可用
一般的缓存方案是基于分片的主从来实现的,通过分片思路来分隔大数据量的查询,通过主从来说完成高可用和部分高性能需求。通过多个数据副本,来化解查询所带来的性能压力。使用异步复制的方式实现主从,通过强一致性协议本来保证,即写事务从主节点开始,并发送事务给从节点,所有从节点都会收到并返回数据的信息给主节点,然后主节点返回成功。在这个过程中都是内存操作,所有主节点和从节点都通过异步读写数据来保证同步。
3.1.1 Redis主从架构
主从架构是最简单实现高可用的方案,核心原理是主从同步:
- slave发送sync指令到master;
- master收到sync指令后,执行bgsave,生成RDB文件;
- 由于bgsave过程需要一定时间,但master依然在接受客户端的写命令,master会把这段过程的写命令记录入缓存中;
- bgsave执行完后, 发送RDB文件给slave,slave执行;
- master发送缓存中的的写命令给slave执行,这样就保证了数据一致性.
3.1.2 Redis哨兵模式
基于主从的同步模式具有明显的缺点,如果主节点挂了,那就无法写入数据, 那么slave也就失去了作用,整个架构不可用,除了人工手动切换,不然无法进行故障转移,而Sentinel的功能
哨兵可以同时监视多个主从服务器,并且在被监视的master下线时,自动将某个slave提升为master,然后由新的master继续接收命令。整个过程如下:
- 初始化sentinel,将普通的redis代码替换成sentinel专用代码
- 初始化masters字典和服务器信息,服务器信息主要保存ip:port,并记录实例的地址和ID
- 创建和master的两个连接,命令连接和订阅连接,并且订阅sentinel:hello频道
- 每隔10秒向master发送info命令,获取master和它下面所有slave的当前信息
- 当发现master有新的slave之后,sentinel和新的slave同样建立两个连接,同时每个10秒发送info命令,更新master信息
- sentinel每隔1秒向所有服务器发送ping命令,如果某台服务器在配置的响应时间内连续返回无效回复,将会被标记为下线状态
- 选举出领头sentinel,领头sentinel需要半数以上的sentinel同意
- 领头sentinel从已下线的的master所有slave中挑选一个,将其转换为master
- 让所有的slave改为从新的master复制数据
- 将原来的master设置为新的master的从服务器,当原来master重新回复连接时,就变成了新master的从服务器
sentinel中间件会每隔1秒向所有实例(包括主从服务器和其他sentinel)发送ping命令,并且根据回复判断是否已经下线,这种方式叫做主观下线。当判断为主观下线时,就会向其他监视的sentinel询问,如果超过半数的投票认为已经是下线状态,则会标记为客观下线状态,同时触发故障转移。
3.1.3 redis集群模式
如果说依靠哨兵可以实现redis的高可用,如果还想在支持高并发同时容纳海量的数据,那就需要redis集群。redis集群是redis提供的分布式数据存储方案,集群通过数据分片sharding来进行数据的共享,同时提供复制和故障转移的功能。
一个redis集群由多个节点node组成,而多个node之间通过cluster meet命令来进行连接,节点的握手过程:
- 节点A收到客户端的cluster meet命令
- A根据收到的IP地址和端口号,向B发送一条meet消息
- 节点B收到meet消息返回pong
- A知道B收到了meet消息,返回一条ping消息,握手成功
- 最后,节点A将会通过gossip协议把节点B的信息传播给集群中的其他节点,其他节点也将和B进行握手
槽slot
redis通过集群分片的形式来保存数据,整个集群数据库被分为16384个slot,集群中的每个节点可以处理0-16383个slot,当数据库16384个slot都有节点在处理时,集群处于上线状态,反之只要有一个slot没有得到处理都会处理下线状态。通过cluster addslots命令可以将slot指派给对应节点处理。slot是一个位数组,数组的长度是16384/8=2048,而数组的每一位用1表示被节点处理,0表示不处理,如图所示的话表示A节点处理0-7的slot。
3.2 缓存高并发
网上有大神实测过,MySQL在命中内存索引的情况下可达到10万每秒的QPS,Redis也有大致相同的性能表现,这里关系到网络IO和内存。还有些提升缓存访问性能的其他办法,比如把没有依赖关系的缓存访问变成并行执行,把有依赖的关系的保留串行执行,这种模式会使用到CompleteFuture这样的任务工具。比如,要获取一个课表的信息,需要同时获取商品的课程、教师、课节等信息。
4. 注意事项
4.1 缓存雪崩
缓存雪崩指的是大量缓存在同一时间全部失效,而假如恰巧这一段时间同时又有大量请求被发起,那么就会造成请求直接访问到数据库,可能会把数据库冲垮。缓存雪崩一般形容的是缓存中没有而数据库中有的数据,而因为时间到期导致请求直达数据库。解决缓存雪崩的方法:
- 针对不同key设置不同的过期时间,避免同时过期;
- 限流,如果缓存实例宕机,可以限流,避免同时刻大量请求打崩DB;
- 二级缓存,同热key的方案。
4.2 缓存击穿
缓存击穿和缓存雪崩很类似,区别就是缓存击穿一般指的是单个缓存失效,而同一时间又有很大的并发请求需要访问这个key,从而造成了数据库的压力。解决缓存击穿的方法:
- 加锁,保证单线程访问缓存。这样第一个请求到达数据库后就会重新写入缓存,后续的请求就可以直接读取缓存;
- 将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。
4.3 缓存穿透
缓存穿透指的是不存在的key进行大量的高并发查询,导致缓存无法命中,每次请求都要穿透到后端的数据库进行查询,使数据库的负载过高,压力过大。对于缓存穿透问题,加锁并不能起到很好地效果,因为本身key就是不存在,所以即使控制了线程的访问数,但是请求还是会到达数据库。解决缓存穿透的方法:
-
接口层进行校验,发现非法的key直接返回。比如数据库中采用的是自增id,那么如果来了一个非整型的id或者负数id可以直接返回,或者说如果采用的是32位uuid,那么发现id长度不等于32位也可以直接返回;
-
将不存在的数据也进行缓存,可以直接缓存一个空或者其他约定好的无效value。采用这种方案最好将key设置一个短期失效时间,否则大量不存在的key被存储到Redis中,也会占用大量内存.
-
针对这个问题,加一层布隆过滤器。布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把他们置为1。
这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。
4.4 热点数据
所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机引发雪崩。解决热点问题有几种方法:
- 提前把热key打散到不同的服务器,降低压力
- 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询
4.5 过期策略
惰性删除指的是当我们查询key的时候才对key进行检测,如果已经达到过期时间,则删除。显然,他有一个缺点就是如果这些过期的key没有被访问,那么他就一直无法被删除,而且一直占用内存。
定期删除指的是redis每隔一段时间对数据库做一次检查,删除里面的过期key。由于不可能对所有key去做轮询来删除,所以redis会每次随机取一些key去做检查和删除。
假设redis每次定期随机查询key的时候没有删掉,这些key也没有做查询的话,就会导致这些key一直保存在redis里面无法被删除,这时候就会走到redis的内存淘汰机制。
-
volatile-lru:从已设置过期时间的key中,移除最近最少使用的key进行淘汰;
-
volatile-ttl:从已设置过期时间的key中,移除将要过期的key
-
volatile-random:从已设置过期时间的key中随机选择key淘汰
-
allkeys-lru:从key中选择最近最少使用的进行淘汰
-
allkeys-random:从key中随机选择key进行淘汰
-
noeviction:当内存达到阈值的时候,新写入操作报错
5.相关实践
-
在正常情况下,读的顺序是先缓存,后数据库; 写的顺序是先数据库,后缓存;
-
在使用本地缓存,一定控制好缓存对象的个数以及生命周期,因为JVM的内存容量有限,会影响JVM的性能,甚至导致内存溢出等;
-
对缓存的数据结构、缓存大小、缓存数量、缓存的失效时间,然后根据业务情况推算出一定时间内的容量的使用功能,根据容量评估的结果来申请和分配缓存资源,否则很容易造成缓存资源浪费或者缓存空间不足;
-
写入缓存时,一定要写入完整正确的数数据;
-
从物理上隔离不同业务的使用缓存,核心业务和非核心业务使用不同的缓存实例,如果有条件,则请对每个业务使用单独的实例或者集群,以减少不同应用之间的相互影响的可能性;
-
所有缓存实例都需要添加监控,如慢查询、大对象、内存使用情况都要做可靠的监控;
-
通过一定的规范来限制每个应用的key的前缀如(系统:模块:功能)+ key来进行隔离设计,避免相互覆盖的情况;
-
任何缓存的key都应该设置失效实践,而且失效时间不应该集中在一点,加个随机时间,否则会导致缓存占满内存或者缓存雪崩;
-
缓存的数据的数据不易过大, 尤其是Redis,在单个缓存key的数据量过大时,会阻塞其他请求的处理;
-
对于存储较多value的key,尽量不要使用HGETALL等集合操作,该操作会造成请求阻塞;
-
如果需要更新大量的数据时,尤其是批量处理时,请使用批量模式;
-
在使用缓存时,一定要有降级处理,缓存有问题或者失效时也可以回溯到数据库进行处理;
参考文献
mp.weixin.qq.com/s/\_WCg3TDZ… Mysql与Redis缓存的同步方案
blog.csdn.net/u011983531/… redis缓存容量评估
www.sohu.com/a/219011898… 分布式缓存基础
database.51cto.com/art/202012/… 测试
tech.meituan.com/2017/03/17/… 美团-缓存那些事