缓存一致性

缓存在计算机系统是无处不在,在CPU层面有L1-L3的Cache,在Linux中有TLB加速虚拟地址和物理地址的转换,在浏览器有本地缓存、手机有本地缓存等。缓存在计算机系统中有非常重要的地位,其主要作用是提高响应速度、减少磁盘访问等,本文主要讨论在高并发系统中的缓存系统。

一般来说,缓存由三座大山需要跨越,雪崩、穿透、击穿

缓存雪崩 Cache Avalanche

理解:系统像雪崩一样崩了

问题:如果缓存系统故障,大量的请求无法从缓存完成数据请求,就全量汹涌冲向磁盘数据库系统,导致数据库被打死,整个系统彻底崩溃。比如由于大量的热数据设置了相同或接近的过期时间,导致缓存在某一时刻密集失效,大量请求全部转发到 DB,或者是某个冷数据瞬间涌入大量访问,这些查询在缓存 MISS 后,并发的将请求透传到 DB,DB 瞬时压力过载从而拒绝服务

解决方案:目前常见的预防缓存雪崩的解决方案,主要是通过对 key 的 TTL 时间加随机数,打散 key 的淘汰时间来尽量规避,但是不能彻底规避。

缓存穿透 Cache Penetration

理解:请求过来了 转了一圈 一无所获 就像穿过透明地带一样。其实就是指查询一个一定不存在的数据

问题:如果某时段有大量恶意的不存在的key的集中请求,那么服务将一直处理这些根本不存在的请求,导致正常请求无法被处理,从而出现问题。

解决方案:有效甄别是否存在这个key再决定是否读取很重要,一般可以用布隆过滤器,布隆过滤器是个好东西,有非常多的用途,包括:垃圾邮件识别、搜索蜘蛛爬虫url去重等,主要借助K个哈希函数和一个超大的bit数组来降低哈希冲突本身带来的误判,从而提高识别准确性。

布隆过滤器也存在一定的误判,假如判断存在可能不一定存在,但是假如判断不存在就一定不存在,因此刚好用在解决缓存穿透的key查找场景,事实上很多系统都是基于布隆过滤器来解决缓存穿透问题的。

缓存击穿 Hotspot Invalid

理解:击穿是一人的雪崩,雪崩是一群人的击穿

问题:由于缓存系统中的热点数据都有过期时间,如果没有过期时间就造成了主存和缓存的数据不一致,因此过期时间一般都不会太长。设想某时刻一批热点数据同时在缓存系统中过期失效,那么这部分数据就都将请求磁盘数据库系统。

解决方案:

  • 在设置热点数据过期时间时尽量分散,比如设置100ms的基础值,在此基础上正负浮动10ms,从而降低相同时刻出现CacheMiss的key的数量。
  • 另外一种做法是多线程加锁,其中第一个线程发现CacheMiss之后进行加锁,再从数据库获取内容之后写到缓存中,其他线程获取锁失败则阻塞数ms之后再进行缓存读取,这样可以降低访问数据数据库的线程数,需要注意在单机和集群需要使用不同的锁,集群环境使用分布式锁来实现,但是由于锁的存在也会影响并发效率。
  • 一种方法是在业务层对使用的热点数据查看是否即将过期,如果即将过期则去数据库获取最新数据进行更新并延长该热点key在缓存系统中的时间,从而避免后面的过期CacheMiss,相当于把事情提前解决了。

cache aside

1
2
3
4
5
6
7
data = queryDataRedis(key);
if (data ==null) {
data = queryDataMySQL(key); //缓存查询不到,从MySQL做查询
if (data!=null) {
updateRedis(key, data);//查询完数据后更新MySQL最新数据到Redis
}
}

也就是说优先查询缓存,查询不到才查询数据库。如果这时候数据库查到数据了,就将缓存的数据进行更新。这是我们常说的 cache aside 的策略,也是最常用的策略。

缓存更新

缓存更新的问题是:先更新缓存还是先更新存储,缓存的处理是通过删除来实现还是通过更新来实现

  • Step1:先更新存储,保证数据可靠性;
  • Step2:再更新缓存,2个策略怎么选:
    • 惰性更新:删除缓存,等待下次读 MISS 再缓存(推荐方案,不一致的概率更低);
    • 积极更新:将最新的值更新到缓存(仅当写操作比较频繁时才选择这个额方案);

做个简单总结,足以适应绝大部分的互联网开发场景的决策:

  • 针对大部分读多写少场景,建议选择更新数据库后删除缓存的策略。
  • 针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略。

缓存淘汰

缓存的作用是将热点数据缓存到内存实现加速,内存的成本要远高于磁盘,因此我们通常仅仅缓存热数据在内存,冷数据需要定期的从内存淘汰,数据的淘汰通常有两种方案:

  • 主动淘汰,这是推荐的方式,我们通过对 Key 设置 TTL 的方式来让 Key 定期淘汰,以保障冷数据不会长久的占有内存。TTL 的策略可以保证冷数据一定被淘汰,但是没有办法保障热数据始终在内存,这个我们在后面会展开;
  • 被动淘汰,这个是保底方案,并不推荐,Redis 提供了一系列的 Maxmemory 策略来对数据进行驱逐,触发的前提是内存要到达 maxmemory(内存使用率 100%),在 maxmemory 的场景下缓存的质量是不可控的,因为每次缓存一个 Key 都可能需要去淘汰一个 Key。

缓存不一致无法完全避免

  • 客观条件:缓存与主存无法做到强一致,除非付出极大的代价,比如使用分布式事务,但这样会使得系统整体性能大幅度下降,甚至比不用缓存还慢,得不偿失
  • 但可以让缓存与主存做到最终一致,这个不一致的窗口越小约好
  • 做法:
    • 缓存设置过期时间,最终都能兜底查主存,然后回写到缓存
    • 如何减少缓存删除/更新的失败?可以借助mq的atleast-once机制,确保缓存能够缓存/更新;更极端场景,如果发mq失败,可以使用rocketmq的事务消息
    • 如果需要更新多个缓存,最好是通过mq解耦这些操作;或者用更优雅的方法,订阅MySQL的binlog