更新数据时的操作主要分为两种:写时更新和读时更新

什么是写时更新和读时更新

写时更新:当我们往数据库写数据的时候我们去更新缓存,包括先更新缓存再更新数据库和先更新数据库再更新缓存。
写时删除,读时更新:当我们往数据库写数据的时候我们直接删除缓存,然后其他请求读数据的时候更新缓存。包括先删除缓存再更新数据和先更新数据库再删除缓存。

缓存更新到底是读更新好还是写更新好?

对比写时更新方案,读时更新更好,为什么?

  1. 如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
  2. 同时有请求A和请求B进行更新操作,那么会出现 (1)线程A更新了数据库 (2)线程B更新了数据库 (3)线程B更新了缓存 (4)线程A更新了缓存。这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

那么也就是说,如果是写更新的话,不管从性能的角度还是从线程安全的角度来说这种方案都不好。

读时更新有没有什么问题?

前面我们说了,如果是写更新,会有效率和线程安全性的问题,那如果是读更新又会有什么问题呢?
读时更新:当我们往数据库写数据的时候我们直接删除缓存,然后其他请求读数据的时候更新缓存。读时更新又包括如下2种方案。

  1. 先删除缓存再更新数据库
  2. 先更新数据库再删除缓存。

先更新数据库再删除缓存

问题一、如果在高并发的场景下,会出现数据库与缓存数据不一致

  1. 缓存刚好失效 线程 A 查询数据库,得一个旧值
  2. 线程 B 将新值写入数据库
  3. 线程 B 删除缓存
  4. 线程 A 将查到的旧值写入缓存

但出现的概率特别低,为什么呢?

我们需要线程A读操作必需在B线程写操作前进入数据库操作,而又要晚于B写操作更新缓存,所有的这些条件都具备的概率基本并不大。那么如何解决这个低概率问题呢?
设置缓存的过期时间,这样可以达到最终一致性

问题二、如果删除缓存失败或更新数据库失败了会怎样?

  • 第一步操作数据库成功,第二步删除缓存失败,会导致数据库里是新数据,而缓存里是旧数据。
  • 第一步操作数据库就失败了,第二步更新缓存不会执行,不会出现数据不一致。

如何解决删除缓存失败的问题?

将需要删除的 key 发送到消息队列中 自己消费消息,获得需要删除的 key 不断重试删除操作,直到成功

先删除缓存再更新数据库

问题一、如果在高并发的场景下,会出现数据库与缓存数据不一致

  1. 线程 A 删除了缓存 线程 B 查询,发现缓存已不存在
  2. 线程 B 去数据库查询得到旧值
  3. 线程 B 将旧值写入缓存
  4. 线程 A 将新值写入数据库

如何解决?设置缓存的过期时间,这样可以达到最终一致性

问题二、如果删除缓存失败或更新数据库失败了会怎样?

  • 第一步删除缓存成功,第二步更新数据库失败,数据库和缓存的数据还是一致的。
  • 第一步删除缓存就失败了,第二步更新数据库不会去执行,数据库和缓存的数据还是一致的。

也就是说并不会导致数据不一致问题。

小总结:对比两种策略

先删除缓存,再更新数据库:在高并发下相对更容易出现数据不一致问题,但在原子性被破坏时(删除缓存失败或更新数据库失败)并不会出现数据一致性问题
先更新数据库,再删除缓存:在高并发下相对出现数据不一致问题概率很低,但在原子性被破坏时(删除缓存失败或更新数据库失败)会出现数据一致性问题

要解决这一切的比较简单的解决方案就是要给KEY设置过期时间

方案延申

延时双删策略

在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。具体步骤是:

1)先删除缓存

2)再写数据库

3)休眠500毫秒(根据具体的业务时间来定)

4)再次删除缓存。

那么,这个500毫秒怎么确定的,具体该休眠多久呢?

需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

当然,这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间:则在读数据业务逻辑的耗时的基础上,加上几百ms即可。比如:休眠2秒。

为什么这种方式可以解决脏缓存(缓存数据与数据库不一致)的问题呢?要造成脏缓存,就需要在缓存被删除后,数据库被更新前有请求读取到了旧数据并更新了缓存,那么这边睡眠一秒后再次删除缓存就可以把这个短暂的时间间隔内产生的脏缓存再次删除掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 延迟双删代码案例
* @author xiaowu
*/
@Component
@Aspect
@Slf4j
public class DoubleClearCacheAop {

/**
* 声明一个用于延时的定时线程池代替线程sleep
*/
ScheduledExecutorService task = new ScheduledThreadPoolExecutor(10, new ThreadPoolExecutor.AbortPolicy());

@Pointcut("@annotation(ClearCache)")
private void clearCachePoint() {
}

@Around("clearCachePoint()")
public Object clearCacheAop(ProceedingJoinPoint proceeds) throws Throwable {
Method method = ((MethodSignature) proceeds.getSignature()).getMethod();
ClearCache annotation = method.getAnnotation(ClearCache.class);
Object proceed = null;
//如果清除注解开启了
if (annotation.open()) {
//上下文获取信息
//执行清除缓存的动作
_clearCache();
//业务处理,更新数据库操作
proceed = proceeds.proceed();
//延时两秒后再删
task.schedule(() -> {
_clearCache();
if (log.isInfoEnabled()) {
log.info(Thread.currentThread().getName() + ":double delete cache completed");
}
}, 2L, TimeUnit.SECONDS);
}
if (Objects.isNull(proceed)){
proceed = proceeds.proceed();
}
return proceed;
}
}

/**
*@author xiaowu
*延时双删
**/
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
@interface ClearCache {
boolean open() default true;
}

延时双删 + 设置缓存的过期时间

从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存

结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致,而且又增加了写请求的耗时。

但是,如何保障写完数据库后,再次删除缓存成功?

上述的方案有一个缺点,那就是操作完数据库后,由于种种原因删除缓存失败,这时,可能就会出现数据不一致的情况。这里,我们需要提供一个保障重试的方案。

监听binlog,进行删除缓存操作

具体流程

(1)更新数据库数据;

(2)数据库会将操作信息写入binlog日志当中;

(3)订阅程序提取出所需要的数据以及key;

(4)另起一段非业务代码,获得该信息;

(5)尝试删除缓存操作,发现删除失败;

(6)将这些信息发送至消息队列;

(7)重新从消息队列中获得该数据,重试操作。

利用阿里的canal实现

该方案的基本原理为:启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

具体使用见阿里canal GitHub:https://github.com/alibaba/canal