Redis缓存+事务+一致性

709 阅读5分钟

背景

数据库的特性ACID大家都知道,如果单纯的只使用数据库,正常开启和关闭事务就可以了,但是如果加上了缓存,那么如果操作不当,就有可能会引发一些严重的问题

阅读本文大约花费10分钟

场景描述

假设有一个电商后台,其中有一个Product模块,有获取详情更新状态两个方法,基础代码如下:

@Service
public class ProductService {
   @Autowired
   private ProductMapper productMapper;
   @Autowired
   private ProductCache productCache;

   @Transactional
   public void updateStatus(Integer productId, Integer newStatus) {
      //检查数据有效性
      Product beforeUpdate = productMapper.get(productId);
      if (beforeUpdate == null) {
         throw new RuntimeException("商品不存在!");
      }
      //执行更新操作
      productMapper.updateStatus(productId, newStatus);
      //删除缓存
      productCache.del(productId);
   }

   public Product get(Integer id) {
      //查缓存
      Product product = productCache.get(id);
      if (product == null) {
         product = productMapper.get(id);
         productCache.set(product);
      }
      return product;
   }
}

更新核心逻辑:检查数据是否存在 → 更新 → 删缓存

get方法逻辑:查缓存 → 未命中就查库 → 写缓存 → 返回

基本上常规的CRUD代码都是这么写的,如果业务就这么简单,确实没什么问题。

然而,大多数产品都是迭代开发的,现在来了一个新需求,要对产品做搜索,因此引入了ElasticSearch(以下简称ES),将所有Product的信息同步到ES中,用于搜索业务。

那么显然,当商品信息更新后,需要更新ES的数据,代码该怎么写呢?

当然首先是要有一个操作ES的类

@Component
public class ProductES {

   @Autowired
   private ProductService productService;

   public void updateES(Integer productId) {
      Product product = productService.get(productId);
      if (product != null){
         //刷新es的操作
      }
   }
}

然后在Product信息更新后,去调用这个updateES方法。通常情况下,我们会将更新ES放到更新Product的事务内,否则就可能出现搜索结果不准确的情况。

所以ProductService.update方法,就需要再加一行代码

image.png

看似好像很合理,而且按照我之前的经验,很多人也都是这么写的,但实际上这里隐藏着巨大的危机

在分析问题前,先看看这个业务的流程图

image.png

重点关注一下红圈里面的流程,这里会调用ProductService的get方法,假如缓存未命中,就会把最新的数据查出来并写入缓存(最新是指当前事务更新后的数据)

这里就引发了第一个问题

数据库的四大特性之隔离性:事务在提交前,别的事务是无法查询到它的修改的。

但是这里,在事务提交前就把最新数据写入了redis,那么别的请求就能够获取到,但实际上,事务还未提交,严格来说,这是不应该的。

接着看第二个问题

流程接着往下走,就是更新ES数据了,假如这时候,ES Server发生了一些网络抖动或者其他什么原因,导致这次更新失败,抛了异常,这就会导致整个事务回滚,数据库的更新会被还原,但是,redis里的数据,却没办法还原

这种就是相当严重的数据错误了,后续的查询,竟然得到了被回滚掉的数据!!!

怎么解决

办法一

这种更新ES的操作,就直接查数据库了,不用走Service

优点:简单直接

缺点:只有当你意识到有这个问题的时候,才会去采取相应的措施。实际项目中,经常见到很多张表互相关联,一张表更新后,要同步多张表,并且如果业务流程复杂,很有可能就会在事务中,调用了带缓存的查询方法

办法二(本文的重点)

Spring的事务回调机制,工具类:TransactionSynchronizationManager

先说结论

  1. 在事务内执行写操作,如果需要删除缓存,调用工具方法注册一个事务回调,在事务提交后再去删缓存
  2. 如果某个查询需要加缓存,判断下当前是否正在事务中,如果是,那么就直接查库返回(完全忽略掉缓存的存在)

image.png

image.png

解释一下

首先是TransactionSynchronizationManager这个类,它是Spring提供的事务相关的类,比较实用的方法有:

  1. isActualTransactionActive:可以判断当前线程是否处在事务中
  2. registerSynchronization:注册一个事务相关的回调函数TransactionSynchronization(注意:如果当前未开启事务,这个方法会报错)
  3. 事务回调函数TransactionSynchronization,常用的方法有:beforCommit(事务提交前的操作),afterCommit(事务提交后的操作),afterRollback(回滚后的操作),afterCompletion(回滚或提交后的操作)

为什么要在事务提交后和提交前都去删缓存?

  1. 提交前删缓存,是因为假如删缓存失败,抛出异常,这样可以保证缓存和数据库之间的一致性(假如数据库更新成功,但删缓存却失败了,那么后续的查询,拿到的,仍然是旧数据)

  2. 提交后删缓存,因为大多数后端都是集群部署的,比如在删除缓存后,提交事务前,服务器遇到了一次500ms的fullgc,在这500ms内,别的服务器恰巧执行了一次查询(Oh No,旧数据又被写进redis了),所以在事务提交后,再删一次,算是很保险的操作(当然,redis缓存一致性,也有延迟双删的操作,可以结合起来使用)

为什么事务内的查询都跳过缓存?

因为不管缓存是否命中,都会引发问题:

  1. 如果命中,那么拿到的就是旧数据,比如ES这种场景,那就是拿旧数据去做同步,导致业务逻辑错误

  2. 如果未命中,那么就会把事务内的修改提前暴露到redis中,导致非常严重的数据错误

  3. 当然,这可能会增加一些数据库主库的压力,不过大多数情况下,问题不大(写操作除了读取数据外,还要写redolog,undolog,处理事务等等,这些开销相比于读来说要大得多,所以增加一点读压力,一般也没多大影响)

结尾

这是一个常见的问题,如果是CRUD程序员,也许不用去关注(出了问题再改也没多大问题)。

但如果你是后端的leader,就应该重点关注一下了(除了做业务,还应该要未雨绸缪,及早找出系统中的隐患)