常见的缓存更新策略共有3种:
- Cache Aside(旁路缓存)策略;
- Read/Write Through(读穿 / 写穿)策略;
- Write Back(写回)策略;
实际开发中,更新策略使用的最多的就是 Cache Aside,另外两种策略应用不了。
Cache Aside(旁路缓存)
"Cache Aside"或"旁路缓存"这个名字来源于其设计原理。在这种策略中,应用程序直接与缓存和数据库进行交互,而不是通过缓存透明地访问数据库。也就是说,缓存并没有处于数据访问的主路径(或者说“主路”)上,而是像一条“旁路”一样,需要应用程序显式地去读写它。
当数据在缓存中未找到(缓存未命中)时,应用程序需要绕开缓存,直接从数据库中获取数据,然后再将数据放入缓存中。同样,当数据更新时,应用程序也需要同时更新数据库和缓存。
因此,这种策略被称为"Cache Aside"或"旁路缓存",强调了应用程序需要直接管理缓存和数据库之间的同步,而缓存并不自动地在主数据访问路径上进行更新或加载数据。
该策略又可以细分为读策略和写策略:
写策略的步骤:
- 先更新数据库中的数据,再删除缓存中的数据。
读策略的步骤:
- 如果读取的数据命中了缓存,则直接返回数据;
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
写策略深究
先更新数据库再更新缓存
因为网络原因,更新缓存的请求可能不是按顺序到达的,此时就会存在数据不一致的问题
先更新缓存再更新数据库
因为网络原因,更新数据库的请求可能不是按顺序到达的,此时就会存在数据不一致的问题;另外频繁更新缓存可能存在一种情况是缓存没有被使用到但却频繁更新了,这种重建缓存比较浪费资源。
先删缓存再更新数据库
在读写并发的情况下会出现数据不一致的情况:
先更新数据库再删缓存
考虑下面的并发情况,我们发现先更新数据库再删缓存也是存在数据不一致的情况的,但是这种情况出现的概率很小,因为读请求比写请求更快,缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 A 已经更新了数据库并且删除了缓存,请求 B 才更新完缓存的情况。
我们一般比较推荐这种方式,删除 Redis 如果失败,可以再多重试几次,否则报警出来;而且这个方案是实时性中最好的方案,在一些高并发场景中,推荐这种。
先更新 MySQL,通过canal监听 Binlog 异步更新 Redis
这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis,这个方案,是实现最终一致性的终极解决方案。对于异地容灾、数据汇总等,建议会用这种方式,比如 binlog + kafka,数据的一致性也可以达到秒级。
应用场景
Cache Aside 策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:
- 一种做法是在更新数据时也更新缓存,只是在更新数据前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
- 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。
Read/Write Through策略
Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。
Read Through
先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。
Write Through
当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:
- 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
- 如果缓存中数据不存在,直接更新数据库,然后返回;
"读穿(Read Through)"和"写穿(Write Through)"这两个术语的含义可能给人带来一些混淆。实际上,这两个术语并不是指应用程序穿过缓存去直接访问数据源,而是指在某些情况下(如缓存未命中或需要写入数据),缓存层会与底层数据源进行交互。 在Read Through策略中,如果缓存未命中,那么缓存层会"穿透"到底层数据源去加载所需的数据,然后将这些数据存储在缓存中以便后续使用。 同样,在Write Through策略中,当有数据需要更新时,这个更新操作会同时作用于缓存和底层数据源,也就是说这个写入操作会"穿透"缓存层并更新底层的数据源。
Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略,例如:
package main
import (
"fmt"
"github.com/golang/groupcache"
)
func main() {
// 创建一个 Group,用于缓存数据
g := groupcache.NewGroup("someGroup", 64<<20, groupcache.GetterFunc(
func(ctx groupcache.Context, key string, dest groupcache.Sink) error {
// 这里模拟从数据源加载数据,实际开发中可能需要进行数据库查询等操作
dataFromSource := "value for " + key
dest.SetString(dataFromSource)
return nil
},
))
var data string
// 从 Group 中获取 key 对应的值,如果缓存未命中,则会调用上面定义的 GetterFunc 来从数据源加载数据
if err := g.Get(nil, "someKey", groupcache.StringSink(&data)); err != nil {
fmt.Println(err)
} else {
fmt.Println(data)
}
}
Write Back 策略
Write Back 策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。
实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。
Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back 策略。
Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。
但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。