今天总监很生气,原因是强调了很多年的缓存同步方案,硬是有人不按常理出牌,挑战权威。最终出了问题,这让总监很没面子。

同事乱用缓存,CTO发飙了..._缓存

图片来自 Pexels

"一群人拿着大老板的账号在哪里瞎测,结果把数据搞出问题来了吧",总监牛鼻子里喷着气,叉着腰说。

自从老板的账号有一次参与测试之后,就成了大家心照不宣的终极测试账号。很多人用,硬生生把一个账号的余额操作,给搞成了高并发的。

"老板的账号不就是个测试账号么...",下面有人小声的嘀咕。

"现实中哪会有账号有这样的密集型操作的...",又有人小声嘀咕,让总监的脸色越来越沉。

"你们觉得我在开玩笑么?",总监红着眼说,“我就曾经因为这样的数据不一致问题,吃过一个一级故障。正好,今天就带你们了解一下,为什么会有数据不一致的情况吧”。

我扶着眼镜摇摇晃晃的做到台下,心中暗笑,总监又要把 Cache Aside Pattern 给科普一遍了。

为什么数据会不一致?

数据库的瓶颈是大家有目共睹的,高并发的环境下,很容易 I/O 锁死。当务之急,就是把常用的数据,给捞到速度更快的存储里去。

这个更快的存储,就有可能是分布式的,比如 Redis,也有可能是单机的,比如 Caffeine。

但一旦加入缓存,就不得不面对一个蛋疼的问题:数据的一致性。

数据不一致的问题,人世间多了去了。进修过 Java 多线程的同学,肯定会对 JMM 的模型记忆犹新。一个数值,只要同时在两个地方存储,那就会产生问题。

但缓存系统和数据库,比 JMM 更加的不可靠。因为分布式组件更加的脆弱,它随时都可能发生问题。

Cache Aside Pattern

 

怎样保证数据在 DB 和缓存中的一致性呢?现在一个比较好的最佳实践方案,就是 Cache Aside Pattern。

先来看一下数据的读取过程,规则是:先读 Cache,再读 DB。

详细步骤如下:

  • 每次读取数据,都从 Cache 里读。

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

  • 如果读不到 Cache 的数据,则从 DB 里面捞一份,称作 cache miss。

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

再来看一下写请求。规则是:先更新 DB,再删除缓存 。

详细步骤如下:

  • 将变更写入到数据库中

  • 删除缓存里对应的数据

说到这里,我看着有几个人皱起了眉头。我知道,肯定会有人不服气,认为自己那一套是对的。

比如,为什么是删除缓存,不是更新缓存呢?效率会不会更低?为什么不先删除缓存再更新数据库?

好家伙,他们要向总监发问了。

 

为什么是删除缓存,而不是更新缓存?

 

这个比较好理解。当多个更新操作同时到来的时候,删除动作,产生的结果是确定的;而更新操作,则可能会产生不同的结果。

同事乱用缓存,CTO发飙了..._缓存_02

如上图,两个请求 A 和 B,请求 B 在请求 A 之后,数据是最新的。

由于缓存的存在,如果在保存的时许发生稍许的偏差,就会造成 A 的缓存值覆盖了 B 的值,那么数据库中的记录值,和缓存中的就产生了不一致,直到下一次数据变更。

而使用删除的方式,由于缓存会 Miss,所以会每次都会从 DB 中获取最新的数据进行填充,与缓存操作的时机关系不大。

同事乱用缓存,CTO发飙了..._缓存_03

为什么不先删缓存,再更新数据库?

 

这个问题是类似的。我们甚至都不需要并发写的场景就能发现问题。

 

我们上面提到的缓存删除动作,和数据库的更新动作,明显是不在一个事务里的。

如果一个请求删除了缓存,同时有另外一个请求到来,此时发现没有相关的缓存项,就从数据库里加载了一份到缓存系统。

 

接下来,数据库的更新操作也完成了,此时数据库的内容和缓存里的内容,就产生了不一致。

同事乱用缓存,CTO发飙了..._缓存_04

如上图,写请求首先删除了缓存。结果在这个时候,有其他的读请求,将数据库的旧值,读取到数据库中,此时缓存中的数据是 0。

接下来更新了 DB,将数据库记录改为了 100。经过这么一哆嗦,数据库和缓存中的数据,就产生了不一致。

 

大家都恍然大悟的点点头,不少人露出了迷之微笑。

 

Spring 中的缓存注解

 

使用 SpringBoot 可以很容易地对 Redis 进行操作,Java 的 Redis的客户端,常用的有三个:jedis、redisson 和 lettuce,Spring 默认使用的是 lettuce。

很多人,喜欢使用 Spring 抽象的缓存包 spring-cache。

它使用注解,采用 AOP 的方式,对 Cache 层进行了抽象,可以在各种堆内缓存框架和分布式框架之间进行切换。

 

这是它的 maven 坐标:
<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-cache</artifactId> 
</dependency>

 

使用 spring-cache 有三个步骤:

  • 在启动类上加入 @EnableCaching 注解。

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

  • 使用 @Cacheable 等注解对资源进行缓存。

 

而针对缓存操作的注解,有三个:
  • @Cacheable:表示如果缓存系统里没有这个数值,就将方法的返回值缓存起来。

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

  • @CacheEvict:表示执行方法的时候,清除某些缓存值。

那么问题来了,spring-cache 中的 @CacheEvict 注解,到底是先删缓存,还是后删缓存呢?不弄明白这一点,真的是让人夜不能寐。关键技术嘛,不仅要用的开心,也要用的放心。

 

缓存的移除,是在 CacheAspectSupport 中实现的,我们注意到下面的代码:

// Process any early evictions
processCacheEvicts(contexts.get(CacheEvictOperation.class), true,
  CacheOperationExpressionEvaluator.NO_RESULT);
...
// Process any late evictions
processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
它有一个前置的清除动作,还有后置的清除动作,是通过一个 bool 变量 boolean beforeInvocation 进行设置的。

这个值从哪里来的呢?还是得看 @CacheEvict 注解:

/**
Whether 
the eviction should occur before the method is invoked.
<p>Setting this attribute to {@code true}, causes 
the eviction to
occur irrespective of 
the method outcome (i.e., whether it threw an
exception or not).
<p>Defaults to {@code false}, meaning that 
the cache eviction operation
will occur <em>after</em> the advised method is invoked successfully (i.e.,
only if 
the invocation did not throw an exception).
*/

boolean beforeInvocation() default false;
很好很好,它的默认值是 false,证明删除动作是滞后的,践行的也是 Cache Aside Pattern。

还有其他模式?

我听说,还有 Read Through Pattern,Write Through Pattern,Write Behind Caching Pattern 等其他常见的缓存同步模式,为什么不用这些呢?

 

有位同学的屁股一直在椅子上来回挪动,跃跃欲试,逮住机会,他终于发言了。

 

其实,这些方式使用的也非常广泛,但由于对业务大多数是无感知的,所以很多人都忽略了。

 

换句话说,这几个模式,大多数是在一些中间件,或者比较底层的数据库中实现的,写业务代码可能接触不到这些东西。

 

比如,Read Through,其实就是让你对读操作感知不到缓存层的存在。通常情况下,你会手动实现缓存的载入,但 Read Through 可能就有代理层给你捎带着做了。

 

再比如,Write Through,你不用再考虑数据库和缓存是不是同步了,代理层都给你做了,你只管往里塞数据就行。

 

Read Through 和 Write Through 是不冲突的,它们可以同时存在,这样业务层的代码里就没有同步这个概念了。爽歪歪。

 

至于 Write Behind Caching,意思就是先落地到缓存,然后有异步线程缓慢的将缓存中的数据落地到 DB 中。

 

要用这个东西,你得评估一下你的数据是否可以丢失,以及你的缓存容量是否能够经得起业务高峰的考验。

 

现在的操作系统、DB、甚至消息队列如 Kafka 等,都会在一定程度上践行这个模式。

 

但它现在和我们的业务需求没半点关系。

Cache Aside Pattern 也有问题

总监上马,一个顶俩,科普了这半天,所有的同学都心服口服。正在大家想要把掌声送给总监的时候,一个不和谐的声音传来了。

 

我发现了一个天大的问题。有同学说, 如果数据库更新成功了,但缓存删除失败了,也会造成缓存不一致。

 

这个问题问的好啊,故障大多数就是由于这些极端情况造成的。这个时候就有意思了,我们要拼概率,毕竟没有 100% 的安全套。总监笑了。

 

方法一:将数据更新和缓存删除动作,放在一个事务里,同进退。

 

方法二:缓存删除动作失败后,重试一定的次数。如果还是不行,大概率是缓存服务的故障,这时候要记录日志,在缓存服务恢复正常的时候将这些 key 删除掉。

 

方法三:再多一步操作,先删缓存,再更新数据,再删缓存。这样虽然操作多一些,但也更保险一些。

 

是不是没有问题了?总监环顾四周,看到大家都在点头。No no no,依然还有数据不一致的情况。

 

所有人都一头雾水。

 

上面那张看起来正确的图,其实是错误的。为什么呢?因为数据在从数据库读到缓存中的操作,并不是原子性的。

同事乱用缓存,CTO发飙了..._缓存_05

比如上图,当缓存失效(或者被删除)的时候,有一个读请求正好到来。这个读请求,拿到了旧的数据库值,但它由于多方面的原因(比如网络抽风),没有立马写入到缓存中,而是发生了延迟。

 

在它打算写入到缓存的这段时间,发生了很多事情,有另外一个请求,将数据库的值更新为 200,并删除了缓存。

 

直到第二个请求全部完成,第一个请求写入缓存的操作,才真正落地。但其实,这时候数据库和缓存的值,已经不是同步的了。

 

那么为什么大家在平常的设计中,几乎把这个场景给忽略掉了呢?因为它发生的概率实在太低了。

 

它要求在读取数据的时候,有两个或者多个并发写操作(或者发生了数据失效),这在实际的应用场景中实在是太少了。

 

而且,我们要注意虚线所持续的周期,是一个数据库的更新操作,加上一个 Cache 的删除操作,这个操作一般情况下,也会比缓存的设置持续的时间长,所以进一步降低了概率。

 

所以,你们知道正确的操作方式了么?总监问。

 

知道了!以后我们就用 spring-cache 的注解去完成工作,再也不在代码中手写一致性逻辑了。
很好很好,如果这么做的话,再发生问题,好像可以把锅甩给 Spring 团队了呢。
作者:小姐姐味道,聚焦基础架构和 Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。

编辑:陶家龙

出处:转载自公众号

同事乱用缓存,CTO发飙了..._缓存_06