本文主要通过操作系统缓存引出缓存的基本概念,然后再通过各种一些语言级、中间件级缓存的实现-Mysql、Redis等进行对于各个场景下缓存问题的探讨。
参考:掘金小册-《从根上儿理解Mysql》
、掘金小册-《Redis深度历险》
、《C专家编程》
、《操作系统导论》
、《深入理解Java虚拟机 Edition 2》
、《Redis设计与实现》
为什么要出现缓存
缓存出现的本质是访问介质速度的不匹配,用来降低低速设备对于高速设备运行效率的影响。
- 因为CPU与内存的访问速度不匹配,增加了LLC缓存;
- 因为内存与磁盘的访问速度不匹配,增加了Page Cache;
本文主要关注的是内存与磁盘(操作系统级)、Java以及redis、Mysql中缓存针对不同场景的实现以及管理方式。
Cache操作
Write Cache
当我们的进程调用了write()
函数时,本质上是将相应的数据写入内存中的Page Cache
中,然后再按照一定的缓存策略触发fsync()
等函数将缓存同步至磁盘中。
缓存策略-缓存一致性问题
每当使用到缓存时,都需要考虑缓存与被缓存的对象的一致性问题,其实主要是关心何时进行缓存同步。 针对这个问题,操作系统中使用了两种缓存策略-全写法与写回法。
Write Through(全写法)
全写法即每次对缓存进行写入时同步对磁盘进行写入来保证缓存与磁盘的一致性,当然这样的性能会比较低;优化的方案即为同步->异步。
Write Back(写回法)
写回法即写入缓存时不需要立即同步写入磁盘,而是通过后台线程定时将缓存中的脏页(被写入过) 的页刷新到磁盘对应的inode
上。
操作系统采用写回法策略是有这样的背景:我们更新磁盘中的数据都是通过应用程序写入Cache
后同步到磁盘中的,即写缓存是修改磁盘的入口;但是在日常使用诸如Redis
中间件用用作数据库缓存时,我们是不可以通过Redis
写数据库的,因此我们日常使用写回法时需要引入额外的中间件-消息队列(Kafka
),当redis写入数据后向Kafka
的相应Topic
中打入一条消息,并由主题的消费者负责按照相应的策略异步写回数据库。
因此在日常中使用Write Back
会引入额外的复杂性和不稳定因素-kafka
,因此我们引出下一种策略-Cache Aside
。
Cache Aside
既然我们无法通过更新Redis
来更新数据库,那么不妨我们就直接更新数据库,然后再删除缓存。
这样当我们一个线程更新DB并删除缓存后,另一个线程读缓存发现为空便会重新从DB重新拉取并写入缓存,这样比引入额外的中间件要简洁很多。
为什么不是第一个线程更新缓存? 如果第一个线程直接更新缓存那么第二个线程不就省去了读db的损耗了吗?
涉及缓存的问题一定要考虑并发场景,因为我们对数据库和缓存的操作不是原子的,可能存在请求1与请求2依次先后到来:
- 请求1更新了数据库,但是还没有更新缓存;
- 请求2更新了数据库,也更新了缓存;
- 请求1更新了缓存。 这种情况下缓存与数据库值的不一致。
为什么是先操作DB后操作缓存? 与Write Back
与 Write Through
先写缓存不同,主要考虑到网络的不稳定性,可能存在请求1与请求2依次先后到来:
- 请求1删除了缓存,但是数据库还没有更新成功(未获取到X锁);
- 请求2发现缓存为空,会从数据库中获取旧值并更新至缓存(获取到S锁);
- 请求1更新完数据库。 而这会导致数据库中的值与缓存的不一致性。
所以直接更新数据库再删除缓存便是万无一失的吗?当然不是,如果更新数据库成功了,但是因为网络原因导致删除缓存失败,也会造成数据库中值与缓存的不一致性,但是这种方法可以避免因为并发操作顺序错乱而导致的数据不一致性问题,而上述两种情况在网络正常时会因为请求的先后到达导致问题。
Read Cache
读缓存就简单很多了,触发了Cache Miss
时从数据库或磁盘中获取到相应的数据并写回缓存即可。当然具体的写回缓存的策略在不同的场景下可能有不同的实现(讲Mysql
的时候会讲到)。
操作系统缓存
首先对于内存与磁盘间缓存操作的发起者是进程,因此我们需要先了解进程内存模型。
内存模型
进程内存模型
操作系统为每个进程分配内存空间的方式可能不尽相同,但必不可少的是堆、栈、代码空间,上述模型即为Heap
和Stack
自进程空间的两端开始增长,这样可以更清晰的利用内存空间;其中堆用来管理动态分配的、用户管理的内存,比如说我们new
一个Java对象,或者通过malloc()
函数动态分配空间等操作,都是通过堆去管理的;而栈用于存储程序运行时函数的调用信息、局部变量等信息,因此不适合用于存储需要共享的信息,因为函数调用结束相关的信息可能会丢失。
线程内存模型
线程为轻量级进程,是对进程的再次细化,通过共享进程空间减少进程的切换以降低时间成本,并且更好的利用了多核CPU的性能,因为多核CPU是共享一块内存的,这与线程的模型具有比较高的契合性。
因此操作系统支持线程需要对上述进程模型进行优化,最重要的是要为每个线程分配自己的栈来保证线程并发执行的安全性,堆则可以多个线程共享,当然也可以为每个线程在堆中预先分配一定的空间来降低对共享空间的并发访问成本-Java中的TLAB(Thread Local Allocation Buffer)
。
如果你对进程的实现细节感兴趣,不妨阅读下这篇文章:Linux是如何实现进程这个概念的?
JMM(Java内存模型)
对于上层应用开发者来讲,比起底层操作系统的内存抽象,我们更应该关心由编程语言提供的Memory Model
,因为底层是个无底洞,很容易陷入各种细节中无法自拔,最后还对于自身所处的编程层次没有半点帮助。 Java内存模型结构如下图所示:
可以发现其本质上是操作系统线程内存模型的语言层面的抽象与实现,线程读取变量只从Cache(工作内存)
中读取,缓存中没有则从主内存中拷贝对应的变量到Cache
中进行读取;
并且为了保证多个线程的高速缓存-如写缓存(高速缓存分为读写缓存) 之间对于共享变量的可见性-缓存一致性,实现了Volatile
关键字。
PS: 此处的变量不是只在栈中进行分配的线程私有的局部变量,而是指诸如实例字段、静态字段、数组对象元素等等。
Java通过两个层面实现了缓存一致性策略:1. 缓存操作的原子性 2. 过期缓存的及时失效
缓存操作的原子性
Java内存模型要求对应的操作系统的虚拟机实现8种操作完成工作内存和主内存之间的数据传递。
工作内存 -> 主内存 (写缓存)
JMM
规定,将工作内存中修改变量并写回主内存中时需要依次调用assign
命令给变量赋值,调用store
命令将变量传递到主内存中,最后调用write
命令将变量值放入主内存的变量中。
其实本质上是Wrtie Through
算法的实现,写缓存与主内存同步进行,因此需要保证该操作assign -> store -> write
的原子性。
主内存 -> 工作内存(读缓存)
JMM
规定,读取主内存中的变量至工作内存中时需要调用read
命令将变量加载至工作内存中,接着调用load
命令将该变量放入工作内存的变量副本中,最后调用use
命令将一个工作内存中的变量传递给执行引擎,同样需要保证read -> load -> use
的原子性。
过期缓存及时失效
因为每个线程的工作内存中都可能会存在变量的副本,因而如果我们将一个缓存修改后写回至主内存中后,其他线程的工作内存中相应的变量的缓存需要及时失效,否则会导致将工作内存中旧的变量副本修改后写回主内存而导致第一个线程修改丢失。
通过在对被Volatile
修饰的变量的写操作前增加StoreStore
屏障,写操作后加StoreLoad
屏障实现,具体的本文不再过多展开探讨。
synchronized与volatile是如何保证原子、可见、有序的?(博客重写计划Ⅰ)
Mysql缓存
Mysql出现缓存本质上也是为了缓解数据库对于磁盘频繁的读写带来的性能问题。
但是Mysql的缓存基于数据库访问这个场景下的一些问题进行了特殊化的设计,我们还是从读缓存与写缓存这两个方面来看。
读缓存
我们上面讲的Read Cache
是如果缓存中没有则读磁盘后写入缓存,但是在Mysql
中因为存在诸如select * from xxx where xxx
这样的全表扫(all
)的sql
语句,如果将包含这些Row
的数据页全部写入缓存中,并且这个语句的使用频率并不是很高,就会挤占缓存区,导致其他页面需要写回磁盘。
因此针对这种场景,我们需要在写入缓存时增加一个频率
的维度来保证缓存的命中率。可以参考的做法是 Java虚拟机中对于堆内存进行Eden和Old的划分,只有经常使用的Eden
才会进入Old
。
LRU List
而Mysql
通过将缓冲区的Page Cache
列表划分为Young
和Old
来进行管理:
Old -> Young
第一次进入缓冲区的会放入Old
中,当相应页面间隔一定时间继续被访问到时才会加入Young
中,间隔一段时间是因为一个页面有多个行,因此对于一个查询可能会在一小段时间中频繁访问该页面。而我们的缓存命中率是基于查询层面的,而这并不是完全可以基于页面访问次数可以体现的,所以做了一个折中。
LRU 维护方法
当Old
区满时,如果又有新的缓存需要写入,Mysql通过LRU(最近最久未使用)
算法淘汰老页面,因此Mysql需要维护如上的LRU List
。
正常的LRU维护方法即为访问到对应的缓存页后就把当前页放置LRU链表的头部,比如Old
区的链表被访问到后就作为Old
区的头部,Young
区同理。但是数据库对页面的访问比较频繁,而移动链表也是需要成本的,因此Mysql进行了优化,只有在对应区域链表的后配置百分比%
的页面被访问到后才会移动到链表的头部。
写缓存
因为之前读缓存时已经将页面写入Buffer Pool
,并且数据库缓存是介于内存-磁盘间的缓存,所以我们可以直接使用Write Back
方法,将Dirty
的缓存通过线程异步写入磁盘中。
线程如何知道哪些页面需要写回? 针对这个问题,我们可以在页缓存的控制块中打上一个Dirty
的标志让Purge
线程遍历控制块;但是显然我们可以额外维护一个Flush List
,将脏页放入该List后线程直接异步写入数据库即可。
Redis缓存
Redis的日志持久化机制-AOF
需要将增量命令在每个事件周期中将执行的命令写入AOF缓冲区(进程空间)
中,并按照一定的策略写回磁盘中。
写缓存
通常我们调用Write()
函数时,并不会直接写入磁盘,操作系统会将该数据放入Page Cache
中,然后通过Write Back
的策略进行异步写回磁盘,通常在Linux
中为30s
。
PS: 异步写回磁盘可以通过子线程调用fsync
或fdatasync
两个函数来进行。
而Redis
为了我们可以通过不同的策略使用操作系统的Page Cache
,使用了Redis进程自身同步刷新、Redis子线程异步刷新、Redis进程自身不刷新这三种策略。
appendfsync | 缓存同步磁盘策略 |
---|---|
always(Write Through ) | 每次调用write() 函数将aof_buf用户缓冲中的数据写入文件时,都会调用fsync()函数强制让操作系统将内核缓冲区中的数据写入到磁盘文件中,因此几乎不会丢数据 |
everysec(Wrtie Back ) | 直接调用write() 函数将aof_buf中的数据进行文件写入,并周期性(每秒)通过一个线程调用fsync 将内核缓冲区的数据写入到磁盘文件中,因此可能会丢数据 |
no | 仅仅调用write() 写入文件,通过操作系统自身维护内核缓冲区的写入,因此可能会丢数据,可能比everysec策略丢的多,一位内Linux中通常为30s |
读缓存
即为加载AOF文件
到内核缓冲区再copy
到用户AOF缓冲区
中按照相应的命令还原数据库状态即可。
总结
-
缓存出现的原因是解决访问介质速度不一致的问题-即我们的系统为什么要引入缓存?
-
使用缓存主要考虑从读缓存、写缓存两部分考虑,
Cache Miss
后写入缓存的策略是什么,写入缓存后同步磁盘的策略是什么,异步持久化带来的丢失问题是否可以忍受? -
缓存带来的问题-缓存与被缓存的介质数据不一致的问题,如何通过不同的读写缓存策略保证在各个场景下的缓存一致性问题-
Cache Aside
?