缓存就是数据交换的缓冲区,按照分布情况,可以分为:
本地缓存:可用hashmap(注意并发)、guava-cache(推荐)等。对于一致性要求不高、访问频率高、总数据集小、重建成本低可以考虑使用本地缓存。本地缓存的不好的地方是占用JVM堆内存,影响垃圾回收,影响系统性能。
分布式缓存:实现由redis(推荐)、memcache等。对于较大且不可预见的访问,最好采用分布式缓存。分布式缓存受到网络和对象序列化的影响,要慢于本地缓存。
文件缓存:文件缓存一般是缓存文件,主要可以分为服务端缓存和浏览器端缓存。服务端缓存可以有Nginx缓存和CDN缓存。
2.1 什么时候用缓存
数据特性:访问频率高、变化频率低的数据 使用原则:在一致性要求不高(高一致性例如交易的账户)的情景下,数据层读服务必须加缓存 数据服务:直接跟DB交互的读服务需要根据数据一致性要求(强弱分辨)加缓存 业务服务:调用大量的外部读服务拼装结果的需要加缓存 缓存数据要尽可能的贴近用户端,尽量高的从各类缓存中命中数据,而不是访问数据库
2.2 什么适合不用缓存
对数据要求苛刻,变化频率高、访问频率低的数据不能缓存
3.1 缓存和DB数据一致性问题
ps:这里的一致性是最终一致性。使用缓存只要是提高读操作的性能,真正在写操作的业务逻辑,还是以数据库为准。
发生原因
并发的情况下,读取老的DB数据,更新到缓存
缓存和 DB 的操作,不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。
解决办法:
(1)先淘汰缓存,再写数据库
原则上先淘汰缓存,再写数据库会保证数据一致性,但是在并发的场景下,会发生这样的现象:
(a)发生了写请求A,A的第一步淘汰了cache(如上图中的1)
(b)A的第二步写数据库,发出修改请求(如上图中的2)
(c)发生了读请求B,B的第一步读取cache,发现cache中是空的(如上图中的步骤3)
(d)B的第二步读取数据库,发出读取请求,此时A的第二步写数据还没完成,读出了一个脏数据放入cache(如上图中的步骤4)
即在数据库层面,后发出的请求4比先发出的请求2先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了
处理办法:实现写的串行化,比较简单的方式,引入分布式锁。
在写请求时,先淘汰缓存之前,获取该分布式锁。
在读请求时,发现缓存不存在时,先获取分布式锁。
(2)先写数据库,再更新缓存
按照“先写数据库,再更新缓存”,我们要保证 DB 和缓存的操作,能够在“同一个事务”中,从而实现最终一致性。
基于定时任务来实现
首先,写入数据库。
然后,在写入数据库所在的事务中,插入一条记录到任务表。该记录会存储需要更新的缓存 KEY 和 VALUE 。
【异步】最后,定时任务每秒扫描任务表,先尝试更新缓存,如果失败则重新插入任务表中,更新到缓存中,之后删除该记录。
基于消息队列来实现
首先,写入数据库。
然后,发送带有缓存 KEY 和 VALUE 的事务消息。此时,需要有支持事务消息特性的消息队列,或者我们自己封装消息队列,支持事务消息。
【异步】最后,消费者消费该消息,先尝试更新缓存,如果失败则重新插入消息队列中,更新到缓存中。
异步处理缓存,在并发的情况下,可能存在数据不一致的情况,如两个线程处理DB和缓存的时候出现交叉处理,解决办法
在缓存值中,拼接上数据版本号或者时间戳。例如说:value = {value: 原值, version: xxx} 。
在任务表的记录,或者事务消息中,增加上数据版本号或者时间戳的字段。
在定时任务或消息队列执行更新缓存时,先读取缓存,对比版本号或时间戳,大于才进行更新。 当然,此处也会有并发问题,所以还是得引入分布式锁或 CAS 操作。
(3)基于数据库的 binlog 日志
应用直接写数据到数据库中。
数据库更新binlog日志。
利用Canal中间件读取binlog日志。
Canal借助于限流组件按频率将数据发到MQ中。
应用监控MQ通道,将MQ的数据更新到Redis缓存中。
3.2 缓存淘汰还是缓存修改
缓存修改:数据不但写入数据库,还会写入缓存;优点:缓存不会增加一次miss,命中率高。
缓存淘汰:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉;优点:简单。
具体是缓存淘汰还是缓存修改,取决于“更新缓存的复杂度”
举例说明:
(1)如果简单的将余额设置成一个值,存在缓存中
修改的方式:updateAccount( uid , newValue )
淘汰的方式:delectAccount( uid )
更新的代价比较小,为了提高命中率,可以采取缓存修改。
(2)如果余额是经过一系列复杂的计算得出来的,这样更倾向于缓存淘汰
结论:要缓存的数据如果是简单的数据,则倾向于使用缓存修改,如果需要复杂计算后再缓存,则倾向于使用缓存淘汰。
3.3 先处理缓存还是先处理数据库
当写操作发生时,需要考虑是缓存和数据库的先后处理顺序,假设使用缓存淘汰。
处理先后问题的原则:如果出现不一致,谁先做对业务的影响较小,就谁先执行。
(1)先处理DB,再处理缓存
假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。
(2)先处理缓存,在处理DB
假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。
结论:先淘汰缓存,再写数据库。
3.4 缓存穿透、缓存击穿、缓存雪崩、热点问题
(1)缓存穿透
(缓存没有,数据库也没有)缓存穿透是说收到一个请求,但是该请求缓存中不存在,只能去数据库中查询,然后放进缓存。但当有好多请求同时访问同一个数据时,业务系统把这些请求全发到了数据库;或者恶意构造一个逻辑上不存在的数据,然后大量发送这个请求,这样每次都会被发送到数据库,最总导致数据库挂掉。
解决办法:
缓存空值,第一次查询这个值的时候没有,缓存将这个key的value设置成null,下次请求的时候,直接返回null,无需再查询数据库,不过要设置过期时间
使用BloomFilter,场景的流程图
(2)缓存击穿
(缓存中没有,数据库中有)上面提到的某个数据没有,然后好多请求查询数据库,可以归为缓存击穿的范畴:对于热点数据,当缓存失效的一瞬间,所有的请求都被下放到数据库去请求更新缓存,数据库被压垮。
解决办法:
一种思路是加全局锁,就是所有访问某个数据的请求都共享一个锁,获得锁的那个才有资格去访问数据库,其他线程必须等待,比如Redis的setnx实现全局锁。
另一种思想是对即将过期的数据进行主动刷新,比如新起一个线程轮询数据,或者比如把所有的数据划分为不同的缓存区间,定期分区间刷新数据。第二个思路与缓存雪崩有点关系。
(3)缓存雪崩
缓存雪崩是指当我们给所有的缓存设置了同样的过期时间,当某一时刻,整个缓存的数据全部过期了,然后瞬间所有的请求都被抛向了数据库,数据库就崩掉了。
解决办法
事前:使用集群(Redis集群模式)缓存,保证缓存服务正常使用
事中:ehcache本地缓存 + hystrix限流&降级 避免MySQL被打死
事后:开启Redis的持久化机制,尽快回复缓存集群
(4)热点数据失效问题
设置不同的失效时间,为了避免热点数据集中失效,可以在设置热点数据的失效时间的时候,让他们错开
互斥锁,基于击穿的情况,可以在第一个请求去查询数据库的时候,增加互斥锁,等查询出来跟新缓存以后再将互斥锁释放掉,从而包含数据库
常见的淘汰策略
FIFO:先进先出策略
LRU:淘汰最长时间未使用的页面,以时间为参考
LFU:淘汰一定时间内使用次数最少的页面,以个数为参考
缓存对象大小限制
高频和低频分离
缓存过期时间设置
防止缓存击穿雪崩
服务过载的cache应对策略
缓存自动加载技法 guava-cache的loading模式可以参考
禁用堆外内存
null值得缓存
分布式换成考虑序列化和反序列化成本 & key的分布情况,不要分散到过多的节点