Redis缓存一致性设计笔记

Spring 注解使用:控制 Redis 缓存更新
使用 SpringBoot 可以很容易地对 Redis 进行操作。Java 的 Redis 的客户端常用的有三个:jedis、redisson、lettuce。其中,Spring 默认使用的是 lettuce。

很多人喜欢使用 Spring 抽象的缓存包 spring-cache,它可以使用注解,非常方便。它的注解采用 AOP 的方式,对 Cache 层进行了抽象,可以在各种堆内缓存框架和分布式框架之间进行切换。

 
    org.springframework.boot 
    spring-boot-starter-cache 

使用 spring-cache 有三个步骤:

在启动类上加入 @EnableCaching 注解;

使用 CacheManager 初始化要使用的缓存框架,使用 @CacheConfig 注解注入要使用的资源;

使用 @Cacheable 等注解对资源进行缓存。
而针对缓存操作的注解有三个:

@Cacheable 表示如果缓存系统里没有这个数值,就将方法的返回值缓存起来;

@CachePut 表示每次执行该方法,都把返回值缓存起来;

@CacheEvict 表示执行方法的时候,清除某些缓存值。
非常简单,对缓存的操作也无非是 CRUD。

一致性问题是如何产生的?

我们先看一下具体的 API 操作,缓存操作和数据库的 CRUD 结合起来,我们可以抽象成下面几个方法:

getFromDB(key)

getFromRedis(key)

putToDB(key,value)

putToRedis(key,value)

deleteFromDB(key)

deleteFromRedis(key)

把 Redis 作缓存用,就说明 Redis 是不适合作为落地存储的。

我们一般把最终的数据存放在数据库中。一般情况下,Redis 的操作速度比数据库的操作速度快得多。毕竟是 10wQPS 和上千 QPS 的对比。关于它们的速度,我们暂时可以画一张图,表明它们之间的速度差异。


image.png
上面这些 API 很简单,但把它们的顺序调整一下,一致性就会出现问题。一致性,简单说就是“数据库里的数据”与“Redis 中的数据”不一样了。

对于读取过程,一般是没有什么异议的。

首先,读缓存;

如果缓存里没有值,那就读取数据库的值;

同时把这个值写进缓存中。

我们下面主要看一下写模式。

双更新模式:操作不合理,导致数据一致性问题

public void putValue(key,value){
    putToRedis(key,value);
    putToDB(key,value);//操作失败了
}

比如我要更新一个值,首先刷了缓存,然后把数据库也更新了。但过程中,更新数据库可能会失败,发生了回滚。所以,最后“缓存里的数据”和“数据库的数据”就不一样了,也就是出现了数据一致性问题。

如果更新数据库,再更新缓存

public void putValue(key,value){
    putToDB(key,value);
    putToRedis(key,value);
}

考虑到下面的场景:操作 A 更新 a 的值为 1,操作 B 更新 a 的值为 2。由于数据库和 Redis 的操作,并不是原子的,它们的执行时长也不是可控制的。当两个请求的时序发生了错乱,就会发生缓存不一致的情况。

image.png

放到实操中,就如上图所示:A 操作在更新数据库成功后,再更新 Redis;但在更新 Redis 之前,另外一个更新操作 B 执行完毕。那么操作 A 的这个 Redis 更新动作,就和数据库里面的值不一样了。

其实双更新模式的问题,主要不是体现在并发的一致性上,而是业务操作的合理性上。

我们大多数业务代码并没有经过良好的设计。一个缓存的值,可能是多条数据库记录拼凑或计算得出来的。比如一个余额操作,可能是“钱包里的值”加上“基金里的值”计算得出来的。

要是采用“更新”的方式,那这个计算代码就分散在项目的多个地方,这就不合理了。

那么怎么办呢?其实,我们把“缓存更新”改成“删除”就好了。

“后删缓存”能解决多数不一致

因为每次读取时,如果判断 Redis 里没有值,就会重新读取数据库,这个逻辑是没问题的。唯一的问题是:我们是先删除缓存?还是后删除缓存?

1.如果先删缓存

public void putValue(key,value){
    deleteFromRedis(key);
    putToDB(key,value);
}

就和上面的图一样。操作 B 删除了某个 key 的值,这时候有另外一个请求 A 到来,那么它就会击穿到数据库,读取到旧的值。无论操作 B 更新数据库的操作持续多长时间,都会产生不一致的情况。

2.如果后删缓存

而把删除的动作放在后面,就能够保证每次读到的值都是新鲜的,从数据库里面拿到最新的。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);
}

这就是Cache-Aside Pattern,也是我们平常使用最多的模式。我们看一下它的具体方式。

先看一下数据的读取过程,规则是“先读 cache,再读 db”,详细步骤如下:

每次读取数据,都从 cache 里读;

如果读到了,则直接返回,称作 cache hit;

如果读不到 cache 的数据,则从 db 里面捞一份,称作 cache miss;

将读取到的数据塞入到缓存中,下次读取时,就可以直接命中。

再来看一下写请求,规则是“先更新 db,再删除缓存”,详细步骤如下:

1.将变更写入到数据库中;

2.删除缓存里对应的数据。
为什么说最常用呢?因为 Spring cache 就是默认实现了这个模式。

Spring 的源码。缓存的移除,是在 Cache-Aside Pattern 中实现的


image.png

image.png

并发量更大时,“后删缓存”依旧不一致

所以在高并发情况下,Cache Aside Pattern 会不够用。下面就描述一个“先更新再删除”这种场景下,依然会产生不一致的情况。场景很好理解、很极端,但在高并发多实例的情况下很常见。


image.png

如上图所示,有一系列的高并发操作,一直执行着更新、删除的动作。某个时刻,它更新数据库的值为 1,然后删除了缓存。

正在这时,有两个请求发生了:

一个是读操作,读到的当然是数据库的旧值 1,我们记作操作 A;

同时,另外一个请求发起了更新操作,把数据库记录更新为 2,我们记作操作 B。

一般情况下,读取操作都是比写入操作快的,但我们要考虑两种极端情况:

一种是这个读取操作 A,发生在更新操作 B 的尾部;

一种是操作 A 的这个 Redis 的操作时长,耗费了非常多的时间。比如,这个节点正好发生了 STW。
那么很容易地,读操作 A 的结束时间就超过了操作 B 删除的动作。就像上图虚线部分画的一样,这个时候,数据也是不一致的。

实际上,你也无法控制它们的执行顺序。只要发生这种情况,大概率数据库和 Redis 的值会不一致。

如何解决

1.延时双删

而假如我有一种机制,能够确保删除动作一定被执行,那就可以解决问题,起码能缩小数据不一致的时间窗口。常用的方法就是延时双删,依然是先更新再删除,唯一不同的是:我们把这个删除动作,在不久之后再执行一次,比如 5 秒之后。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);

    ...deleteFromRedis(key,after5sec);
}

而删除动作也有多种选择:

如果放在 DelayQueue 中,会有随着 JVM 进程的死亡,丢失更新的风险;

如果放在 MQ 中,会增加编码的复杂性。
所以到了这个时候,并没有一个能够行走天下的解决方案。我们得综合评价很多因素去做设计,比如团队的水平、工期、不一致的忍受程度等。

2.闪电缓存

还有一种不太常用的,那就是采用闪电缓存。就是把缓存的失效时间设置非常短,比如 3~4 秒。一旦失效,就会再次去数据库读取最新数据到缓存。但这种方式,在非常高的并发下,同一时间对某个 key 的请求击穿到 DB,会锁死数据库,所以很少用。

对于一般并发场景,上面的各种修修补补,已经把不一致问题降低到很小的概率了。但是它仍然是有问题的,因为它引入了一个高可用问题:缓存击穿。

缓存击穿

两种不同的解决方式:

1.读操作互斥

我们依然采用 Cache-Aside Pattern,只不过在读的时候进行一下处理。来看一下伪代码,从 Redis 读取不到值的时候,我们要上锁去从数据库中读这个值。我们这里默认这个值是有的,否则就得处理缓存穿透的问题。

get(key){

    res = getFromRedis(key);

    //读取缓存为null

    if(null == res){

        lock.lock(...);

        //再次读取缓存为null

        res = getFromRedis(key);

        if(res == null){

            res = getFromDB(key);

            if(null != res){

                //读取设值

                putToRedis(key,res);

            }

        }

        lock.unlock();

    }

    return res;

}

getFromDB(key){

    ...

}

使用分布式锁和非分布式锁的主要区别,还是在于数据一致性窗口上:

-对于多线程锁来说,可能某些节点执行得非常慢,更新了旧的值到 Redis;

-对于分布式锁来说,肯定又是一个效率上的话题。

2.集中更新

集中更新。这个很美好,但大多数业务很复杂,这对业务架构的前期设计要求非常高。比如通过 Binlog 方式,典型的如 Canal。我们不会在代码里做任何 Redis 更新的操作,而是会设计一个服务,订阅最新的 binlog 更新信息,然后解析它们,主动去更新缓存。这个一般在大并发大厂才会采用。

还有一种就是弱化数据库。所有的数据首先在 Redis 落地,也就是把 Redis 作为数据库使用,把数据库作为备份库使用。有定时任务,定期把 Redis 中的数据,保存到数据库或其他地方。

一般,重要业务还要配备一个对账系统,定时去扫描,以便快速发现不一致的情况

小结

针对 Redis 的缓存一致性问题,我们聊了很多。可以看到,无论你怎么做,一致性问题总是存在,只是几率慢慢变小了。

随着对不一致问题的忍受程度越来越低、并发量越来越高,我们所采用的方案也越来越极端。一般情况下,到了延时双删这一步,就证明你的并发量已经够大了;再往下走,无不是对高可用、成本、一致性的权衡,进入到了特事特办的场景,甚至要考虑基础设施,关于这些每个公司的策略都是不一样的。

除了 Cache-Aside Pattern,一致性常见的还有 Read-Through、Write-Through、Write-Behind 等模式,它们都有自己的应用场景

你可能感兴趣的:(Redis缓存一致性设计笔记)