1- 缓存与数据库
应用程序通常使用缓存来提高系统系能,特别是对于只读事务来说。当数据发生变化时,这些应用程序会直接更新数据库。问题在于随着负载的增加,响应时间将会增加,响应时间将随着更新的增长而延长。关系型数据库其实并不擅长大量处理少量记录的并发事务(多而分散),处理批量事务才是数据库的强项(少而集中)。
1.1- 缓存更新的必要性
由于硬件内存空间的限制,以及满足JVM的空余内存空间的限制,内存中缓存的数据的上限是一定的,所以需要不停的释放旧数据,才能持续不断的加载数据到内存
1.2- 缓存的实现方式
JVM 堆内存的实现 基于SoftReference或者WeakReference
使用堆内存实现的方式,其缓存大小受制于JVM堆空间的大小
内存服务器如Redis
不受JVM堆内存大小的限制,但是受制于操作系统总的内存大小的限制。
2- 缓存更新的设计模式
当数据时效性要求很高时,需要保证缓存中的数据与缓存中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存。
2.1- 引子:在更新缓存时,到底是,先删除缓存,然后再更新数据库,还是先更新数据库,然后再删除缓存?
- 先删后更:考虑并发的情况(只读缓存):两个并发请求,一个是要更新操作,另一个是查询操作,更新操作会致使当前缓存失效,删除缓存后;这时查询操作没有命中缓存,就会将数据库中的数据读出来放到缓存中,然后更新操作更新了数据库。于是此时缓存中数据并不是更新操作的新值,而是原来的数据库中的值。所以说这种更新策略是错误的。
- 先更后删:如果更新数据库的时候,正好有读请求到达,此时读到的数据将是脏数据,但是当更新完数据库,会删除旧的缓存,等下次读请求到达时,没有命中缓存,会从数据库重新load到内存中,保证只出现因此脏数据,之后都是正确的数据。
以下将介绍四种缓存更新的四种设计模式,遵循最佳实践少走弯路
Cache Aside Pattern(常用)
- 失效:程序先从缓存中读数据,没有命中缓存,则程序从数据库中load到内存中。
- 命中:程序先从缓存中读数据,正好命中缓存,则程序将缓存中的数组直接返回。
- 更新:程序写操作先把数据存储到数据库中,成功后,程序再让缓存失效。(可能会出现几次脏读)
Read/Write Through Pattern(读写穿透模式)
Cache Aside模式中,同是要维护两个数据存储,一个是缓存,一个是数据库。所以比较啰嗦。而 Read/Write Through模式是将把数据库的操作由缓存自己代理了,所以就将缓存和数据库的操作合并在一次,对于数据访问来说面对的是单一存储,所以对于存储来说要同时维护自己的缓存。
Read Through
读操作中更新缓存:用缓存服务自己加载,对程序调用来说是透明的(不许要像Cache Aside模式程序还要自己加载数据到缓存),当读操作没有命中缓存时将触发Read Through
Write Through
写操作中更新缓存:当数据进行更新时,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存自己更新数据库(这是同步操作)
Write Behind Caching Pattern
在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库,优点是将缓存与数据库的同步异步化,带来飞快的数据I/O操作,而且可以将数据库的读写合并,所以对性能的会有较大的显著(特别是大数据量,数据库读写成为性能瓶颈你能过得时候),当缓存需要失效的时候,才会真正的进行持久化。
可能会出现数据不一致的情况:当系统掉电,缓存中数据还没有来得及写到数据库,则会造成一定的数据丢失,数据不是强一致的。但是性能和强一致性往往需要权衡。
注意
- 上面的缓存更新的设计模式还没有考虑缓存和数据库的整体事务的问题,比如更新了缓存,但是数据库更新失败了怎么办?或者数据库更新失败,但是缓存load失败了怎么办?解决方法是就是使用“两阶段提交协议”——prepare,commit/rollback。当然这样保证数据强一致性将导致性能的下降,实际设计时还需要权衡一下。
- 从上面的缓存更新的策略,也能看出来当只是查询数据时,缓存能抵挡绝大多数的DB查询,但是如果都是插入或者修改的操作业务(如秒杀等),每一次都将命中DB,这时候为了平滑瞬间并发DB操作,应该采用异步+消息队列的形式,或者使用Write Behind Caching Pattern。不管使用哪种方式,必须保证高可用,实时的将内存操作的数据刷盘。
3- 缓存饱和替换策略
探讨的是缓存中的数据达到缓存设定的内存使用上限,新插入的数据时,替选择什么样的原来的旧数据来替换的问题。
- Least Frequently Used(LFU)
每个缓存对象计算他们被使用的频率,把最不常用的缓存对象替换掉。
- Least Recently User(LRU):
把最近最少使用的缓存对象给替换,实现上会把最新被访问的缓存对象,放到缓存队列的顶部,可基于array 或者是LinkedHashMap来实现。
- Least Recently Used 2(LRU2):
也叫最近最少使用 twice。把被两次访问过的对象放入缓存队列中,当缓存池满了之后,会把有两次最少使用的缓存对象替换掉。因为需要跟踪对象2次,访问负载就会随着缓存池的增加而增加。在大容量的缓存池中,就会有较高性能开销问题。另外,还需要跟踪那些不在缓存的对象,因为他们还没有被第二次读取。效果比LRU好,但是性能开销也大。
- Two Queues(2Q):
把被访问的数据放到 LRU 的缓存中,如果这个对象再一次被访问,我就把他转移到第二个、更大的 LRU 缓存。转移缓存对象是为了保持第一个缓存池是第二个缓存池的1/3。当缓存的访问负载是固定的时候,把 LRU 换成 LRU2,就比增加缓存的容量更好。这种机制使得我比 LRU2 更好,改善了LRU2的性能。
- Adaptive Replacement Cache(ARC):
介于 LRU 和 LFU 之间,为了提高效果。由2个 LRU 组成: L1,包含的条目是最近只被使用过一次的; L2,包含的是最近被使用过两次的条目。因此, L1 放的是新的对象,而 L2 放的是常用的对象。被认为是性能最好的缓存算法之一,能够自调,并且是低负载的、很快,适用性也强。
- Most Recently Used(MRU):
和 LRU 相对应会移除最近最多被使用的对象。作用:接下来访问的随机性,并且在缓存系统中找出最少最近使用的对象是一项时间复杂度非常高的运算,这就是MRU存在的原因。
- First in First out(FIFO):
先进先出,是一个低负载的算法,并且对缓存对象的管理要求不高。通过一个队列去跟踪所有的缓存对象,最近最常用的缓存对象放在后面,而更早的缓存对象放在前面,当缓存容量满时,排在前面的缓存对象会被替换,然后把新的缓存对象加进去。很快,但是并不适用。
- Second Chance:
通过 FIFO 修改而来的,改善了 FIFO 的成本。在替换缓存,移除队首元素式,会检查即将要被移除的对象有没有之前被使用过的标志(1一个 bit 表示),没有被使用过,就把他移除;否则,会把这个标志位清除,然后把这个缓存对象当做新增缓存对象加入队列。这就像一个环队列。当再一次在队头碰到这个对象时,由于他已经没有这个标志位了,所以立刻就把他踢开了,在速度上比 FIFO 快。
- CLock
持有一个装有缓存对象的环形列表,头指针指向列表中最老的缓存对象。当缓存 miss 发生并且没有新的缓存空间时,会问问指针指向的缓存对象的标志位去决定应该怎么做。如果标志是0,我会直接用新的缓存对象替代这个缓存对象;如果标志位是1,会把头指针递增,然后重复这个过程,直到新的缓存对象能够被放入,比 second chance 更快。
- Simple time-based:
通过绝对的时间周期去失效那些缓存对象。对于新增的对象,会保存特定的时间。很快,但是并不适用。
- Extended time-based expiration:
通过相对时间去失效缓存对象的;对于新增的缓存对象,会保存特定的时间,比如是每5分钟,每天的12点。
- Sliding time-based expiration:
与前面不同的是,被管理的缓存对象的生命起点是在这个缓存的最后被访问时间算起的,很快,但是也不太适用。
其他需要注意的:
成本:如果缓存对象有不同的成本,应该把那些难以获得的对象保存下来。
容量:如果缓存对象有不同的大小,应该把那些大的缓存对象清除,这样就可以让更多的小缓存对象进来了。