缓存(Cache)是计算机领域非常常见的存储机制,它的基本思想是让数据更接近于使用方,在需要时提供快速的数据访问,以减轻系统资源的负担,提升应用程序的性能和可扩展性。
在使用缓存时也需要注意一些问题,它们可能会影响数据准确性或者加剧系统性能的恶化,例如:
- 数据不一致:指缓存中的数据与数据库的数据不一致,会导致系统数据错误,影响业务正常运转。
- 缓存雪崩:指缓存服务器由于某些原因发生故障,导致大量请求涌向后端服务器或数据库,从而影响整个系统的可用性和性能。
- 缓存穿透:指查询一个不存在的数据,由于缓存中数据不存在,每次请求都会到后端服务器或数据库中查询,从而导致后端服务器负载上升,增加系统响应时间,影响整体稳定性。
随着技术应用经验的沉淀,有一些指导如何使用缓存来优化数据访问和提高系统性能的模式被总结出来,这些模式也称为缓存使用模式,可以根据不同的应用场景和需求采取不同的策略。在后端系统开发中,常见的缓存使用模式可以分为两类:
- Cache-As-SoR:即把缓存(Cache)当作记录系统(SoR,System-of-Record,也可以叫数据源),所有操作都是对缓存进行,然后缓存再委托给记录系统进行数据的真实读写。这种模式在业务代码中,只看得到缓存的操作,看不到记录系统的操作,通常有三种实现:Read-Through(读穿透)、Write-Through(写穿透)和Write-Behind(写延迟)。
- Cache-Aside:也称为缓存旁路模式,由业务代码直接管理缓存和数据库,是一种按需分配缓存的模式。
Cache-Aside
Cache-Aside是最常用的缓存模式,业务系统分别管理缓存和数据源,决定缓存数据的加载和更新。
在读场景,先从缓存中获取数据,如果数据存在则直接返回,如果没有命中,则回源到记录系统并将数据放入缓存,以供下次读取使用。示例代码如下:
Object value = cache.get(key);
if (value == null) {
value = loadFromSoR(key);
cache.put(key, value);
}
return value;
在写场景,先将数据写到记录系统,写入成功后将数据同步写入缓存,或使缓存数据失效,下次读取时再进行加载。示例代码如下:
boolean r = writeToSoR(key, value);
if (r) {
cache.put(key, value);
// or cache.invalidate(key);
}
这种模式适用于读多写少的场景,比如用户信息、商品信息、新闻报道等,数据一旦写入缓存,很少会再进行修改。在并发更新的时候,Cache-Aside模式可能会出现缓存和数据库双写不一致的情况,例如:
- 在读场景中,回源记录系统读取数据后,恰好其他线程更新了数据源,此时放入缓存的就是旧数据;
- 在写场景中,如果两条线程同时更新数据源,由于写记录系统和写缓存不是事务操作,可能旧数据后写缓存,导致了数据不一致。
对于该模式下数据不一致的问题,业务系统一般可以采取如下几种应对方式:
- 如果是更新较少的数据,可以不考虑这个问题,给缓存加上过期时间即可
- 可以使用延迟双删的方式减少脏数据的影响时长
- 通过订阅数据源更新日志(如binlog)确保有序更新缓存
- 通过互斥机制避免并发更新,如采用一致性哈希结合本地锁或者直接采用分布式锁来串行化更新操作
Cache-Aside也可以说是最基本的缓存使用模式,在缓存和数据源的读写框架下,给予业务系统足够的自由,去定制自己的管理逻辑,满足编码复杂度、系统性能和数据一致性的需求。相比之下,Cache-As-SoR模式的三种实现,更多的是提供特定场景的策略封装,原理上并没有太大差别。
Read-Through
Read-Through模式与Cache-Aside模式很相似,不同点在于应用系统不关注数据实际是从哪里读取的(缓存还是记录系统),也不关注数据是如何加载到缓存的,应用系统只面向缓存读取数据,而缓存中的数据从哪里来由缓存系统决定。
读穿透模式一般需要配置一个CacheLoader组件用来回源加载数据,如果缓存没有命中,则委托给CacheLoader,以Guava Cache为例,实现如下:
LoadingCache<String, Object> cache = CacheBuilder.newBuilder()
.softValues()
.maximumSize(1024)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> loadFromSoR(key)); // new CacheLoader<String, Object>
使用CacheLoader可以将缓存数据加载逻辑收敛到同一个地方,于业务系统开发有几个好处:
- 回源到记录系统查询源数据,可以限制返回值必须不为null,强制包装空对象,避免空值缓存穿透
- 应用代码更简洁,不需要像Cache-Aside模式分别管理缓存和数据源,减少重复代码
- 可以限制只有一个请求去加载数据,避免缓存穿透(也即解决Dog-pile effect问题)
Write-Through
在Write-Though模式下,应用系统调用缓存的写操作接口来变更数据,由缓存系统负责同时更新缓存数据和后端记录系统数据。
写穿透模式一般需要配置一个CacheWriter组件用来将数据回写到记录系统,以Ehcache为例,实现如下:
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
Cache<String, Object> cache = cacheManager.createCache(
"myCache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, Object.class)
.withExpiry(Expirations.timeToLiveExpiration(Duration.of(10, TimeUnit.MINUTES)))
.withLoaderWriter(new DefaultCacheLoaderWriter<String, Object>() {
// 以下方法省略
// write
// writeAll
// delete
// deleteAll
}).build());
这种模式实现上隐含对数据一致性的保障,但可能会影响系统的性能,适用于交易系统这类对缓存数据要求强一致的需求场景,一般不在高频写场景使用。
Write-Behind
Write-Behind也称为Write-Back(回写)模式,与Write-Though同步写缓存和记录系统不同,它是一种异步写入模式。在代码层面,应用系统也是只面向缓存的写操作接口,但在执行写操作时,缓存系统只更新缓存数据,后续再异步地更新到后端记录系统当中。
这种模式可以提高系统的性能,也可以增加更多的写入策略,例如批量写、合并写、延时写和写限流等等,但也很可能增加数据不一致的风险,比较适用于单点读但高频写的场景。