缓存双写一致性之更新策略

面试题

  • 下面业务逻辑你用 java 代码如何实现?

    Redis-缓存双写一致性面试题1

  • 只要用缓存,就可能会涉及到 redis 缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性问题,那么如何解决一致性问题?

  • 双写一致性,你先动缓存 redis 还是数据库 mysql?为什么?

  • 延时双删你做过吗?会有哪些问题?

  • 有这么一种情况,微服务查询 redis 无,mysql 有,为保证数据双写一致性回写 redis 你需要注意什么?双检加锁策略你了解过吗?如何尽量避免缓存击穿?

  • redis 和 mysql 双写 100% 会出纰漏,做不到强一致性,你如何保证最终一致性

缓存双写一致性

概述

  • 如果 redis 中有数据

    需要和数据库中的值相同

  • 如果 redis 中无数据

    数据库中的值要是最新值,且准备回写 redis

  • 缓存按照操作来分,细分 2 种

    • 只读缓存
    • 读写缓存
      • 同步直写策略
        • 写数据库后也同步写 redis 缓存,缓存和数据库中的数据一致;
        • 对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。
      • 异步缓写策略
        • 正常业务运行中,mysql 数据变动了,但是可以再业务容许出现一定时间后才作用于 redis,比如仓库,物流系统;
        • 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助 kafka 或者 RabbitMQ 等消息中间件实现重试重写。

面试题

Redis-缓存双写一致性面试题1

采用双检加锁策略

多个线程同时去查询数据库的这条数据,那么我们可以再第一个查询数据的请求上使用一个互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

代码示例

public String get(String key) {
    String value = redis.get(key); // 查询缓存
    if (value != null) {
        return value; // 缓存存在直接返回
    } else {
        // 缓存不存在则对方法加锁
        // 假设请求量很大,缓存过期
        synchronized (TestFuture.class) {
            value = redis.get(key); // 再查一遍 redis
            if (value != null) {
                // 查到数据直接返回
                return value;
            } else {
                // 二次查询缓存也不存在,直接查 DB
                value = dao.get(key);
                // 数据缓存
                redis.setnx(key, value, time);
                // 返回
                return value;
            }
        }
    }
}

代码实战

@Service
@Slf4j
public class UserService {
 
    public static final String CACHE_KEY_USER = "user:";
 
    @Resource
    private UserMapper userMapper;
 
    @Resource
    private RedisTemplate redisTemplate;
 
    /**
     * 业务逻辑没有写错,对于小厂中厂(QPS <= 1000)可以使用,但是大厂不行
     * @param id
     * @return
     */
    public User findUserById(Integer id) {
        User user = null;
        String key = CACHE_KEY_USER + id;
 
        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql
        user = (User) redisTemplate.opsForValue().get(key);
 
        if (user == null) {
            //2 redis里面无,继续查询mysql
            user = userMapper.selectByPrimaryKey(id);
            if(user == null) {
                //3.1 redis+mysql 都无数据
                //你具体细化,防止多次穿透,我们业务规定,记录下导致穿透的这个key回写redis
                return user;
            } else {
                //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率
                redisTemplate.opsForValue().set(key,user);
            }
        }
        return user;
    }
 
 
    /**
     * 加强补充,避免突然key失效了,打爆mysql,做一下预防,尽量不出现击穿的情况。
     * @param id
     * @return
     */
    public User findUserById2(Integer id) {
        User user = null;
        String key = CACHE_KEY_USER+id;
 
        //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql,
        // 第1次查询redis,加锁前
        user = (User) redisTemplate.opsForValue().get(key);
        if (user == null) {
            //2 大厂用,对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql
            synchronized (UserService.class) {
                //第2次查询redis,加锁后
                user = (User) redisTemplate.opsForValue().get(key);
                //3 二次查redis还是null,可以去查mysql了(mysql默认有数据)
                if (user == null) {
                    //4 查询mysql拿数据(mysql默认有数据)
                    user = userMapper.selectByPrimaryKey(id);
                    if (user == null) {
                        return null;
                    } else {
                        //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作
                        redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS);
                    }
                }
            }
        }
        return user;
    }
}

数据库和缓存一致性的集中更新策略

最终一致性目的

给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。

我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,以 mysql 的数据库写入库为准

上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,不是100%绝对正确,不保证绝对适配全部情况,自行酌情选择方案,合适自己的最好。

可以停机的情况

  • 挂牌报错,凌晨升级,温馨提示,服务降级;
  • 单线程,这样重量级的数据操作最好不要多线程。

四种更新策略

× 先更新数据库,再更新缓存

异常问题1

先更新 mysql 的某商品的库存,当前商品的库存是 100,更新为 99 个;

先更新 mysql 修改为 99 成功,然后更新 redis;

此时假设异常出现,更新 redis 失败,这导致 mysql 里面的库存是 99 而 redis 里面的还是 100;

上述发生,会让数据库里面和缓存 redis 里面的数据不一致,读到 redis 脏数据

异常问题2

【先更新数据库,再更新缓存】A、B 两个线程发起调用

  • 正常逻辑

    A update mysql 100

    A update redis 100

    B update mysql 80

    B update redis 80

  • 异常逻辑,多线程环境下,A、B 两个线程有快又慢,右前右后有并行

    A update mysql 100

    B update mysql 80

    B update redis 80

    A update redis 100

    最终结果,mysql 和 redis 数据不一致。redis 80,mysql 100。

× 先更新缓存,在更新数据库

异常问题1

业务上一般把 mysql 作为底单数据库,保证最后解释。

异常问题2

【先更新缓存,再更新数据库】,A、B 两个线程发起调用

  • 正常逻辑

    A update redis 100

    A update mysql 100

    B update redis 80

    B update mysql 80

  • 【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行

    A update redis 100

    B update redis 80

    B update mysql 80

    A update mysql 100

    最终结果,mysql 和 redis 数据不一致。redis 80,mysql 100。

× 先删除缓存,再更新数据库

异常问题
  • 步骤分析 1,先删除缓存,再更新数据库

    A 线程先成功删除了 redis 里面的数据,然后去更新 mysql,此时 mysql 正在更新中,还没有结束(比如网络延时)。

    B 突然出现要来读取缓存数据。

    public void deleteOrderData(Order order) {
        try (Jedis jedis = RedisUtils.getJedis()) {
            // 线程A先成功删除redis缓存
            jedis.del(order.getId() + "");
            // 线程A再更新mysql
            orderDao.update(order);
            // 暂停20秒,其他业务逻辑导致耗时延时
            try {
                TimeUtil.SECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  • 步骤分析 2,先删除缓存,再更新数据库

    此时 redis 里面的数据是空的,B 线程来读取,先去读 redis 里数据(已经被 A 线程 delete 掉了,此时会出现两个问题)

    • B 从 mysql 获得了旧值,B 线程发现 redis 里没有(缓存缺失)马上去 mysql 里面读取,从数据库里面读取来的是旧值

    • B 会把获得的旧值写回 redis获得旧值数据后返回前台并回写进 redis(刚被 A 线程删除的旧数据有极大可能又被写回了)。

    public Order selectOrderData(Order order) {
        try (Jedis jedis = RedisUtils.getJedis()) {
            if (result != null) {
                // 先去 redis 里面查找,找到返回数据找不到去mysql查找
                String result = jedis.get(order.getId() + "");
                return (Order) JSON.parse(result);
            } else {
                order = orderDao.getOrderById(order.getId());
                // 线程B会将从mysql查到的旧数据写回到redis
                jedis.set(order.getId() + "", order.toString());
                return order;
            }
        } catch(Exception e) {
            e.printStackTrace();
        }
        return null;
    }
  • 步骤分析 3,先删除缓存,再更新数据库

    A 线程更新完 mysql,发现 redis 里面的缓存是脏数据;

    两个并发操作,一个是更新操作,另一个是查询操作,A 删除缓存后,B 查询操作没有命中缓存,B 先把老数据读出来后放到缓存中,然后A更新操作更新了数据库;

    于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。

  • 上面 3 步骤串讲梳理

    (1)请求 A 进行写操作,删除 redis 缓存后,工作正在进行中,更新 mysql......A 还没有彻底更新完 mysql,还没 commit

    (2)请求 B 开工查询,查询 redis 发现缓存不存在(被 A 从 redis 中删除了)

    (3)请求 B 继续,去数据库查询得到了mysql 中的旧值(A 还没更新完)

    (4)请求 B 将旧值写回 redis 缓存

    (5)请求 A 将新值写入 mysql 数据库

    上述情况就会导致不一致的情形出现。

    时间线程 A线程 B出现的问题
    t1请求 A 进行写操作,删除缓存成功后,工作正在 mysql 进行中......
    t2缓存中读取不到,立刻读 mysql,由于 A 还没有对 mysql 更新完,读到的是旧值
    还把从 mysql 读取的旧值,写回了 redis
    A 还没有更新完 mysql,导致 B 读到了旧值
    线程 B 遵守回写机制,把旧值写回 redis,导致其它请求读取的还是旧值,A 白干了。
    t3A 更新完 mysql 数据库的值,结束redis 是被 B 写回的旧值,mysql 是被 A 更新的新值。出现了,数据不一致问题。

总结:如果数据库更新失败或超时或返回不及时,导致 B 线程请求访问缓存时发现 redis 里面没数据,缓存缺失,B 再去读取 mysql 时,从数据库中读取到旧值,还写回 redis,导致 A 白干了

解决方案
  • 采用延时双删策略

    public void deleteOrderData(Order order) {
        try (Jedis jedis = RedisUtils.getJedis()) {
            // 线程 A 先成功删除 redis 缓存
            jedis.del(order.getId() + "");
            // 线程 A 更新mysql
            orderDao.update(order);
            // 暂停2秒,其他业务逻辑导致耗时延时
            try { TimeUnit.SECONDS.sleep(2); } catch (interruptedException e) { e.printStackTrace(); }
            jedis.del(order.getId() + "");
        } catch (interruptedException e) {
            e.printStackTrace();
        }
    }

    加上 sleep 的这段时间,就是为了让线程 B 能够从数据库读取数据,再把缺失的数据写入缓存。然后,线程 A 再进行删除。所以,线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。这样一来,其他线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延时双删”。

  • 双删方案面试题

    • 这个删除该休眠多久捏?如何确定休眠时间

      线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。

      第一种方法:在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

      第二种方法新启动一个后台监控程序,比如后面要讲解的 WatchDog 监控程序,会加时。

    • 这种同步淘汰策略,吞吐量降低怎么办?

      public void deleteDoubleOrderDelay2(Order order) {
          try (Jedis jedis = RedisUtils.getJedis()) {
              // 线程A先成功删除redis缓存
              jedis.del(order.getId() + "");
              // 线程A再更新mysql
              orderDao.update(order);
              // 暂停20秒,其他业务逻辑导致耗时延时
              try { TimeUnit.SECONDS.sleep(20); } catch (interruptedException e) { e.printStackTrace(); }
              CompletableFuture.supplyAsync(() -> {
                  // 将第二次删除作为异步,自己起一个线程,异步删除。这样写的请求就不用沉睡一段时间再返回,加大吞吐量。
                  return jedis.del(order.getId() + "");
              }).whenComplete((t, u) -> {
                  System.out.println("------t:" + t);
                  System.out.println("------u:" + u);
              }).exceptionally(e -> {
                  System.out.println("------e:" + e.getMessage());
              	return 44L;
              }).get();
          } catch(Exception e) {
              e.printStackTrace();
          }
      }
    • 后续看门狗 WatchDog 源码分析

☆ 先更新数据库,再删除缓存

异常问题
时间线程 A线程 B出现的问题
t1更新数据库中的值
t2缓存中立刻命中,此时 B 读取的是缓存旧值A 还没有来得及删除缓存的值,导致 B 缓存命中读到旧值
t3更新缓存的数据

假如缓存删除失败或者来不及,导致请求再次访问 redis 时缓存命中,读取到的是缓存旧值

业务指导思想
解决方案

流程如下图所示:

(1)更新数据库数据;

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

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

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

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

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

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

Redis-先更新数据库再删除缓存

  • 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka/RabbitMQ 等);
  • 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新;
  • 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试;
  • 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
类似经典的分布式事务问题

只有一个权威答案:最终一致性

  • 流量充值,先发短信实际充值可能滞后 5 分钟,可以接受;
  • 电商发货,短信下发但是物流信息第二天更新。

总结

  • 如何选择方案?利弊分析

    个人建议:优先使用先更新数据库,再删除缓存的方案理由如下。

    • 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满 mysql;
    • 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延时双删中的等待时间就不好设置。

    如果使用先更新数据库,再删除缓存的方案

    如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在 Redis 缓存客户端暂停并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性,这是理论可以达到的效果。但实际不推荐,因为真实生产环境中,分布式下很难做到实时一致性,一般都是最终一致性,请大家参考。

  • 小结

    策略高并发多线程条件下问题现象解决方案
    先删除 redis 缓存,再更新 mysql缓存删除成功但数据库更新失败Java 程序从数据库中读到旧值再次更新数据库,重试
    缓存删除成功但数据库更新中……有并发读请求并发请求从数据库读到旧值并回写 redis,导致后续都是从 redis 读取到旧值延时双删
    先更新 mysql,再删除 redis 缓存数据库更新成功,但缓存删除失败Java 程序从 redis 中读到旧值再次删除缓存,重试
    数据库更新成功但缓存删除中……有并发读请求并发请求从缓存读到旧值等待 redis 删除完成,这段时间有数据不一致,短暂存在。