缓存策略通用分布式缓存解决方案
Cache Aside(旁路缓存)策略
Cache Aside(旁路缓存)策略是一种在应用程序中协调缓存与数据库交互的常用策略,是使用最多的策略。
基本原理
- 读操作:应用程序首先尝试从缓存中读取数据,如果缓存命中,则直接返回该数据。如果缓存未命中,应用程序会从数据库中查询读取数据,读取数据后,写入缓存并返回数据。
- 更新(写)操作:当数据需要更新时,应用程序先更新数据库中的数据,更新成功后,再删除缓存中的数据。之所以删除缓存而不是更新缓存,是因为更新操作可能只修改了部分字段,如果直接更新缓存,可能导致缓存数据与数据库数据的不一致,而删除缓存可以保证下次读取时从数据库获取最新的数据并重新写到缓存
更新(写)策略的顺序不能颠倒,因为缓存操作要远远快于数据库操作,所以很难出现以下并发情况:第一个请求读取缓存发现未命中,又读取数据库。第二个请求更新数据库并删除了缓存。第一个请求才将数据库的内容写入缓存。
适用场景
读多写少。因为写频繁时,缓存中的数据会被频繁的清理,这样会对缓存的命中有影响。如果业务对于缓存命中率有严格要求,那么可以考虑以下两种方案:
- 更新数据库后,在更新缓存时,先加一个分布式锁,这样同一时刻只允许一个线程修改缓存。这样做会对于写的性能有一定影响
- 更新数据库后,给缓存加一个较短的过期时间,这样虽然较短时间内数据有不一致情况,但缓存数据很快就会过期,对业务的影响也是接受的
通用分布式缓存解决方案
分布式缓存问题:
- 直接将数据直接存入Redis,如果数据是对象,从缓存中获取时,反序列化效率低;而且数据量大时,造成缓存会占用较大空间
- 将数据做本地缓存,数据更新时,要考虑在集群环境下,本地数据和数据库的一致性
Redis缓存标志位U0,每次数据修改同步修改U0。本地缓存标志位U1,从缓存取数据时,先判断Redis缓存标志位U0和本地标志位U1是否相等,相等代表是最新的数据,可以直接从本地缓存中取。否则就查数据库,更新本地缓存。
public class CacheUtil {
private static Map<String, Object[]> cache;
private static StringRedisTemplate stringRedisTemplate;
private static volatile CacheUtil cacheUtil = null;
public static CacheUtil getInstance() {
if (cacheUtil == null) {
synchronized (CacheUtil.class) {
if (cacheUtil == null) {
cacheUtil = new CacheUtil();
cacheUtil.init();
}
}
}
return cacheUtil;
}
private CacheUtil() {
}
private void init() {
cache = new ConcurrentHashMap<>();
stringRedisTemplate = ApplicationContextRegister.getBean(StringRedisTemplate.class);
}
public <T> T getAndSetCacheObject(Supplier<T> supplier, String key) {
if (!StringUtils.hasText(key)) {
return null;
}
T result;
// 获取redis中的标记
String redisFlag = stringRedisTemplate.boundValueOps(key).get();
// 未获取到,查数据库,做本地缓存,向redis和本地存入标记
if (redisFlag == null) {
result = supplier.get();
if (result != null) {
setCacheObject(key, result);
}
} else {
Object[] localCache = cache.get(key);
//本地缓存为空,查数据库,做本地缓存,并存入标记
if (localCache == null) {
result = supplier.get();
if (result != null) {
Object[] cacheInfo = {redisFlag, result};
cache.put(key, cacheInfo);
}
} else {
String localFlag = (String) localCache[0];
// 本地标记与redis中的标记相同,直接从本地获取
if (Objects.equals(redisFlag, localFlag)) {
result = (T) localCache[1];
} else {
// 否则,查数据,更新本地缓存和标记
result = supplier.get();
if (result != null) {
Object[] cacheInfo = {redisFlag, result};
cache.put(key, cacheInfo);
}
}
}
}
return result;
}
private void setCacheObject(String key, Object data) {
String uuid = UUID.randomUUID().toString();
Object[] cacheInfo = {uuid, data};
cache.put(key, cacheInfo);
stringRedisTemplate.boundValueOps(key).set(uuid);
}
public void refreshCache(String key) {
if (!StringUtils.hasText(key)) {
return;
}
stringRedisTemplate.boundValueOps(key).set(UUID.randomUUID().toString());
}
}
使用
@Service
public class StudentServiceImpl implements StudentService {
@Autowired
private StudentDao dao;
@Autowired
private StudentService studentService;
@Override
public Student getStudent(String id) {
return CacheUtil.getInstance().getAndSetCacheObject(new Supplier<Student>() {
@Override
public Student get() {
Student student = new Student();
student.setId(id);
return dao.selectOne(student);
}
}, "student:" + id);
}
@Override
public void update(Student student) {
// 这里注入studentService自身调用更新方法(不做详细讲解,知道的小伙伴可评论补充)
// 双更新尽最大可能减少数据不一致问题(不做详细讲解,知道的小伙伴可评论补充)
studentService.updateStudent(student);
// 事务外更新
CacheUtil.getInstance().refreshCache("student:" + student.getId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateStudent(Student student) {
// ...一系列校验
// ...其他操作
// 更新
dao.update(student);
// 事务内更新
CacheUtil.getInstance().refreshCache("student:" + student.getId());
}
}
Read/Write Through(读穿 / 写穿)策略
Read/Write Through(读穿 / 写穿)策略最大的特点是,应用程序不直接与数据库交互,而是应用程序只与缓存交互,缓存与数据库交互。相当于缓存在中间做了代理。
基本原理
- 读穿(Read Through):当应用程序请求数据时,他并不直接与数据库交互,而是向缓存请求数据,如果缓存中有需要的数据(缓存命中),则直接返回给应用程序;如缓存未命中,缓存系统自动查询数据库获取数据,将其放入缓存,然后返回给应用程序。
- 写穿(Write Through):当应用程序要更新数据时,也是将数据发送给缓存,缓存收到数据后,先判断缓存中是否存在该数据,若存在,会将数据写入数据库,同时更新缓存中的数据,确保缓存中的数据与数据库中的数据相同。若不存在,则直接更新数据库。
Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。
Write Back(写回)策略
-
Write Back(写回策略)的特点:在数据更新的时候,只会更新缓存中的数据,然后将该数据标记为 “脏” 的或 “已修改” 的状态,并不会更新数据库中的数据,而是在合适的时机或满足特定的条件下,将特定状态的缓存数据以特定的方式 “刷” 到数据源,从而达到数据的最终一致性。写回策略适合用于写多,并且读取数据实时性不高的场景,写回策略在操作系统的文件系统、CPU缓存都采用了写回策略。例如:在编辑World或其他文档时,如果未保存,发生了断电,就会造成原本缓存中的脏数据丢失,之前编辑的内容会丢失一部分,就是因为缓存数据还未刷新到磁盘导致的。
-
写回时机:基于时间的写回:固定时间间隔、基于空间的写回:达到缓存空间使用阈值、基于事件的写回:程序主动调用特定方法。