缓存的最佳实践

1. 本地缓存、分布式缓存、文件缓存

缓存就是数据交换的缓冲区,按照分布情况,可以分为:

  • 本地缓存:可用hashmap(注意并发)、guava-cache(推荐)等。对于一致性要求不高、访问频率高、总数据集小、重建成本低可以考虑使用本地缓存。本地缓存的不好的地方是占用JVM堆内存,影响垃圾回收,影响系统性能。

  • 分布式缓存:实现由redis(推荐)、memcache等。对于较大且不可预见的访问,最好采用分布式缓存。分布式缓存受到网络和对象序列化的影响,要慢于本地缓存。

  • 文件缓存:文件缓存一般是缓存文件,主要可以分为服务端缓存和浏览器端缓存。服务端缓存可以有Nginx缓存和CDN缓存。

2. 缓存的使用时机

2.1 什么时候用缓存

数据特性:访问频率高、变化频率低的数据 使用原则:在一致性要求不高(高一致性例如交易的账户)的情景下,数据层读服务必须加缓存 数据服务:直接跟DB交互的读服务需要根据数据一致性要求(强弱分辨)加缓存 业务服务:调用大量的外部读服务拼装结果的需要加缓存 缓存数据要尽可能的贴近用户端,尽量高的从各类缓存中命中数据,而不是访问数据库

2.2 什么适合不用缓存

对数据要求苛刻,变化频率高、访问频率低的数据不能缓存

3. 缓存的使用常见问题

3.1 缓存和DB数据一致性问题

ps:这里的一致性是最终一致性。使用缓存只要是提高读操作的性能,真正在写操作的业务逻辑,还是以数据库为准

发生原因

  1. 并发的情况下,读取老的DB数据,更新到缓存

  2. 缓存和 DB 的操作,不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。

解决办法:

(1)先淘汰缓存,再写数据库

原则上先淘汰缓存,再写数据库会保证数据一致性,但是在并发的场景下,会发生这样的现象:

缓存的最佳实践_第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和缓存的时候出现交叉处理,解决办法

  1. 在缓存值中,拼接上数据版本号或者时间戳。例如说:value = {value: 原值, version: xxx} 。

  2. 在任务表的记录,或者事务消息中,增加上数据版本号或者时间戳的字段。

  3. 在定时任务或消息队列执行更新缓存时,先读取缓存,对比版本号或时间戳,大于才进行更新。 当然,此处也会有并发问题,所以还是得引入分布式锁或 CAS 操作。

(3)基于数据库的 binlog 日志

缓存的最佳实践_第2张图片

  • 应用直接写数据到数据库中。

  • 数据库更新binlog日志。

  • 利用Canal中间件读取binlog日志。

  • Canal借助于限流组件按频率将数据发到MQ中。

  • 应用监控MQ通道,将MQ的数据更新到Redis缓存中。

3.2 缓存淘汰还是缓存修改

缓存修改:数据不但写入数据库,还会写入缓存;优点:缓存不会增加一次miss,命中率高。

缓存淘汰:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉;优点:简单。

具体是缓存淘汰还是缓存修改,取决于“更新缓存的复杂度”

举例说明:

(1)如果简单的将余额设置成一个值,存在缓存中

修改的方式:updateAccount( uid , newValue )

淘汰的方式:delectAccount( uid )

更新的代价比较小,为了提高命中率,可以采取缓存修改。

(2)如果余额是经过一系列复杂的计算得出来的,这样更倾向于缓存淘汰

结论:要缓存的数据如果是简单的数据,则倾向于使用缓存修改,如果需要复杂计算后再缓存,则倾向于使用缓存淘汰。

3.3 先处理缓存还是先处理数据库

当写操作发生时,需要考虑是缓存和数据库的先后处理顺序,假设使用缓存淘汰。

处理先后问题的原则:如果出现不一致,谁先做对业务的影响较小,就谁先执行。

(1)先处理DB,再处理缓存

缓存的最佳实践_第3张图片

假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。

(2)先处理缓存,在处理DB

缓存的最佳实践_第4张图片

假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。

结论:先淘汰缓存,再写数据库。

3.4 缓存穿透、缓存击穿、缓存雪崩、热点问题

(1)缓存穿透

(缓存没有,数据库也没有)缓存穿透是说收到一个请求,但是该请求缓存中不存在,只能去数据库中查询,然后放进缓存。但当有好多请求同时访问同一个数据时,业务系统把这些请求全发到了数据库;或者恶意构造一个逻辑上不存在的数据,然后大量发送这个请求,这样每次都会被发送到数据库,最总导致数据库挂掉。

解决办法:

  1. 缓存空值,第一次查询这个值的时候没有,缓存将这个key的value设置成null,下次请求的时候,直接返回null,无需再查询数据库,不过要设置过期时间

  2. 使用BloomFilter,场景的流程图

缓存的最佳实践_第5张图片

(2)缓存击穿

(缓存中没有,数据库中有)上面提到的某个数据没有,然后好多请求查询数据库,可以归为缓存击穿的范畴:对于热点数据,当缓存失效的一瞬间,所有的请求都被下放到数据库去请求更新缓存,数据库被压垮。

解决办法:

  1. 一种思路是加全局锁,就是所有访问某个数据的请求都共享一个锁,获得锁的那个才有资格去访问数据库,其他线程必须等待,比如Redis的setnx实现全局锁。

  2. 另一种思想是对即将过期的数据进行主动刷新,比如新起一个线程轮询数据,或者比如把所有的数据划分为不同的缓存区间,定期分区间刷新数据。第二个思路与缓存雪崩有点关系。

(3)缓存雪崩

缓存雪崩是指当我们给所有的缓存设置了同样的过期时间,当某一时刻,整个缓存的数据全部过期了,然后瞬间所有的请求都被抛向了数据库,数据库就崩掉了。

解决办法

事前:使用集群(Redis集群模式)缓存,保证缓存服务正常使用

事中:ehcache本地缓存 + hystrix限流&降级 避免MySQL被打死

事后:开启Redis的持久化机制,尽快回复缓存集群

(4)热点数据失效问题

  1. 设置不同的失效时间,为了避免热点数据集中失效,可以在设置热点数据的失效时间的时候,让他们错开

  2. 互斥锁,基于击穿的情况,可以在第一个请求去查询数据库的时候,增加互斥锁,等查询出来跟新缓存以后再将互斥锁释放掉,从而包含数据库

4. 缓存淘汰策略

常见的淘汰策略

FIFO:先进先出策略

LRU:淘汰最长时间未使用的页面,以时间为参考

LFU:淘汰一定时间内使用次数最少的页面,以个数为参考

5. 缓存使用注意事项

  • 缓存对象大小限制

  • 高频和低频分离

  • 缓存过期时间设置

  • 防止缓存击穿雪崩

  • 服务过载的cache应对策略

  • 缓存自动加载技法 guava-cache的loading模式可以参考

  • 禁用堆外内存

  • null值得缓存

  • 分布式换成考虑序列化和反序列化成本 & key的分布情况,不要分散到过多的节点

你可能感兴趣的:(redis,java)