缓存的思考与总结
新技术或中间的引入,一定是解决了亟待解决的问题或是显著提升了系统性能,并且这种改变所带来的增幅,可以让我们忽略引入新技术所带来的系统复杂性等负面影响。好比是CAP理论中对C和A的权衡。
什么是缓存
从内存中读取数据,从文件系统通过IO读取磁盘数据,两者在时间上存在较大差异,毫无疑问,从内存中读取数据相较于磁盘会更快,于是便有了缓存,很典型的以空间换时间的运用。
如果数据都放在内存中自然是最好不过,也就没有缓存这么一说了。但目前来看,内存依旧属于紧张资源,需要有选择的将数据(读多写少 )放入内存作为缓存来使用。
在缓存方面,大多数问题都可以归结到以下两个方面去讨论:
- 缓存命中率
- 数据一致性
缓存命中率
读取数据时,从缓存中是否读取到了数据。系统在设计添加缓存层时,一方面提升系统响应速度,另一方面也能拦截部分查询请求从而分担数据库压力。对于一些我们希望在缓存层被拦截的请求,如果缓存没有命中,那么缓存层将失去意义。比如常说的几个概念:
- 缓存穿透:查询缓存中不存在的键(非法的key),请求到达DB层
- 缓存击穿:查询缓存中不存在的键(尚未构建缓存),请求到达DB层
- 缓存雪崩:缓存大面积失效,请求到达DB层
你会发现,上述都是在描述一个概念:缓存命中率。只不过是不同场景下缓存未命中的情况:
我们希望请求在缓存层被拦截,但由于未命中缓存导致请求到达DB层。所以上述场景的解决方案也都是围绕着:如何提高缓存命中率 来展开的。
缓存击穿还涉及并发场景下缓存重建问题,需要通过加锁来避免。
数据一致性
有了缓存,就意味着有了两份数据,DB层一份,内存一份,那就必然会涉及数据一致性的问题,并且由于是两份数据,数据同步期间必然会存在不一致情况,除非将数据的修改和缓存的修改作为一个原子操作(单体应用)。所以,不能仅仅要设计数据如何读取提高命中率,还要设计数据更新时的策略。也就是说,加入缓存层后,要从读写两方面进行约束,形成闭环,这样才能保证缓存和DB层的数据一致性。
常见的场景必然有成熟的解决方案,对于缓存的数据一致性问题,常见的设计有以下几种:
- 旁路模式
- 直写模式
- 异步写模式
旁路模式 Cache aside
读取时先从缓存中读取数据。如果缓存中没有数据,则从数据库中读取,并将数据写入缓存。更新数据时,先更新数据库,然后再将缓存中的数据失效。
旁路模式中,并发场景下,先更新数据后再删除缓存 和 先删除缓存再更新数据 两者有所不同,即使双删策略保证第二次删除后读取到的都是新数据。
推荐使用先更新数据库再删除缓存 的做法,优点是不存在使用旧数据重建缓存的情况,且数据不一致的窗口期不依赖于第二次删除,也就是说:更新数据后删除缓存前,并发读会读到旧缓存,但更新数据且删除缓存后,不一致窗口期便结束了。假如设定的第二次删除的延时是1小时,先更新数据库再删除缓存 这种方式会在删除缓存后结束数据不一致;但先删除缓存再更新数据的方式则强依赖与第二次删除,会在1小时后才结束数据不一致。
双写模式
顾名思义,既写数据库,又写缓存。包括直写和异步写(严格的双写是指一份数据同时写入两种存储介质,这里的异步写归结到双写模式下便于记忆)
直写模式 write through
读取时先从缓存中读取数据,在写入数据时,数据同时写入缓存和数据库
异步写 Write Behind
写入数据时,数据只更新缓存,并异步批量刷新到数据库中
旁路和双写
为了方便理解和区分旁路和双写模式,最简单的区分就是:
旁路模式中,缓存更新(失效重建)是被动 的,由后续的读操作进行缓存重建;而双写模式则是主动更新缓存
旁路模式和双写模式是保证缓存和DB数据一致性的常用做法。分布式系统中也存在一些在旁路和双写基础上进行改进增强的设计,比如旁路模式+TTL过期时间,双写+补偿机制等,用来处理缓存操作失败时的场景,感兴趣的可以继续研究。
案例
写这篇偏总结性的文章,起因是之前写的一个IDEA插件 ,在第一版设计的时候,考虑到之后插件记录的数据增多,于是通过代理模式预留了扩展。
这里通过代理模式添加缓存层,在代理对象中统一进行缓存的处理。
由于插件开发是单体应用且不考虑多线程场景(多编辑器同时操作一个源文件时,会提示并拒绝),所以这里我们使用旁路模式实现最简单的LRU缓存。具体怎么做呢?
- 读操作优先读缓存,缓存没有再读DB(这里是持久化文件)
- 数据一旦发生修改,例如修改了高亮记录中的笔记内容,则失效缓存,等待下一次读取时重新构建缓存即可。
但是有个问题:这里使用的缓存key和value,key是源码文件名称,value是该文件所有的高亮笔记。这里缓存的粒度很大,如果每次修改一条高亮笔记就重建整个源码文件的数据,不是很合理。所以还是双写模式更适合,避免牵一发动全身。每次更新笔记,只更新缓存中笔记列表里面相同ID的缓存记录即可。