面试官:如何保证缓存和数据库的一致性?

1,755 阅读10分钟

你好呀,我是苍何!

办公室里鸦雀无声,我木然的看着窗外射进来的阳光,它照在光滑的地板上,又反射到天花板上,再从天花板上反射下来时,就变成一片弥散的白光。

我在白光里偷偷放了一个恶毒的臭屁,如果是以前,同事们会偷偷捂住鼻子,然后目光看向旁边的人,没有人看向我,我也捂住鼻子,附和的来了句"谁又放毒了?"。

其实我却在心里偷偷笑,反正谁也猜不到是我。

如今办公室除了我再无一人,我还要在自己的臭屁下写文章,可能因此我的文章多少有些被「污染」。

但我依旧希望我的文章不是屁,最起码不能是臭屁。

那接下来,苍何给大家分享一个大厂面试常见问题:

如何保证缓存和数据库的一致性?,也即是既要又要怎么做?

缓存的重要性

缓存大家都并不陌生,说白了就是一种存储机制,用于暂时保存数据,以加速数据的读取和访问。

数据库好好的为什么要用缓存?关键点在于缓存快啊!

比如微信中的很大消息都是缓存在你本地手机的,(看到有自媒体博主发视频说能找回你 10 年前的微信聊天记录,大哥,前提是你 10 年不换手机?或者每次换手机你都能把所有聊天记录导出来?)

微信手机中的缓存是一种常见的本地缓存,对于开发来说本地缓存常用有以下这些:

  • JDK 自带的 HashMap 和 ConcurrentHashMap
  • Ehcache、Guava Cache、Spring Cache、Caffeine 等常见的本地缓存框架

除了本地缓存,还有常见的分布式缓存如常用的 Redis。本地缓存和分布式缓存很好区别,本地缓存和应用在同一个地方,而分布式缓存是可以独立部署在不同的服务器上的,比如 Redis 可以单机或者集群部署在不同的服务器上。

不管是本地缓存还是分布式缓存,都只有一个目的,那就是用空间换时间,用更多的存储空间来存储一些可能重复使用或计算的数据,从而减少数据的重新获取或计算的时间。

缓存介绍.png

缓存一致性问题

你有没有想过一个问题,既然一份数据同时在缓存中和数据库中都有,那这两者到底哪个是最新数据呢?如何保证其数据一致性呢?

那么导致不一致的原因主要有:

  • 缓存过期:缓存中的数据有一个生命周期,当数据过期后,如果没有及时更新,就会出现不一致的情况。
  • 写操作延迟:在执行写操作时,数据库更新和缓存更新的时间不同步,可能会导致缓存中的数据不一致。
  • 并发操作:多个并发操作同时进行,可能会导致数据更新时出现竞态条件,从而导致缓存和数据库数据不一致。
  • 缓存失效策略不当:使用不当的缓存失效策略,可能导致缓存中的数据无法及时更新,导致不一致。
  • 网络延迟或故障:由于网络延迟或故障,缓存服务器和数据库之间的通信出现问题,导致数据不一致。

常见的解决不一致问题有如下解决方案:

  • Cache Aside 模式:在读写操作中使用 Cache Aside 模式,确保在写操作后及时失效缓存中的数据。
  • 分布式锁:在并发写操作时使用分布式锁,确保同时只有一个操作能够更新缓存和数据库,避免竞态条件。
  • 双写一致性:在写操作时同时更新数据库和缓存,确保数据的一致性。
  • 延迟双删:在写操作时,先删除缓存中的数据,更新数据库后,再次删除缓存中的数据,确保缓存中数据的一致性。
  • 版本控制:在缓存和数据库中使用版本号或时间戳,确保数据更新时的一致性检查。
  • 监控和告警:对缓存和数据库中的数据进行监控,发现不一致时及时告警并处理。

而 PmHub 中主要围绕 Cache Aside 模式、分布式锁、监控和告警来解决一致性问题,之前章节有介绍分布式锁以及监控告警相关的能力,本节主要针对的 Cache Aside 模式。

常见缓存更新策略

先直接看苍何给做的汇总表吧:

模式名称描述优点缺点使用场景
Cache Aside Pattern (旁路缓存模式)读取数据时先检查缓存,缓存未命中则从数据库读取并更新缓存。写入数据时先更新数据库,然后使缓存失效。- 读取性能高
- 实现简单
- 首次请求数据一定不在缓存问题
- 写操作较频繁的话会导致缓存中数据被频繁删除,会影响缓存命中率
- 数据读取频率高,写入频率较低的场景
Read/Write Through Pattern (读写穿透模式)所有的读写操作都通过缓存进行,缓存负责同步数据库。- 数据一致性好
- 实现了读写操作的统一
- 实现复杂
- 依赖缓存的高可用性
- 数据读取和写入频率均较高的场景
Write Behind Pattern (异步缓存写入)写操作首先更新缓存,然后异步地将数据写入数据库。- 写操作性能高
- 减少数据库压力
- 存在数据丢失的风险
- 数据一致性较差
- 写操作频繁,且对实时一致性要求不高的场景

Cache Aside 模式概述

数据不一致的主要原因还是先写缓存还是先更新数据库的问题,不正确的方案我们就不阐述了,省的增加同学们的负担。那么 PmHub 采用的就是 Cache Aside 模式来保证缓存和数据库的一致性。

Cache Aside 模式其实就是读取数据时先检查缓存,缓存未命中则从数据库读取并更新缓存。写入数据时先更新数据库,然后使缓存失效。用一张图或许你更好理解一些:

Cache Aside时序图.png

Cache Aside 模式优点和局限

优点

  1. 读取性能高
    • 缓存命中时可以直接从缓存中读取数据,速度非常快,显著减少了数据库的访问压力。
  2. 实现简单
    • 该模式逻辑清晰,容易理解和实现,只需在读取和写入操作时分别处理缓存和数据库即可。使用的也比较多的一种方案了。
  3. 灵活性强
    • 程序员可以根据需要灵活地控制缓存的更新和失效策略,适应不同的应用场景和需求。
  4. 缓存利用率高
    • 只有在缓存未命中时才从数据库读取数据并更新缓存,避免了不必要的数据冗余。

局限

  1. 写操作较慢
    • 每次写操作都需要更新数据库并使缓存失效,这样的操作过程较长,导致写操作性能较低。
  2. 数据一致性挑战
    • 缓存和数据库之间可能会出现数据不一致的情况,特别是在高并发环境下,更容易出现缓存失效不及时或者更新顺序问题。
  3. 缓存预热问题
    • 初始时缓存为空,第一次读取时会有较大的延迟,直到缓存被逐渐填充起来。所以热点数据建议直接放入缓存
  4. 复杂的失效策略
    • 程序员需要设计合理的缓存失效策略,以确保缓存的数据及时更新,这增加了实现和维护的复杂性。具体怎么设置失效策略又体现了经验了。
  5. 过期数据风险
    • 如果没有合适的失效机制,缓存中可能会存在过期数据,从而返回错误或过时的信息给用户。

Cache Aside 模式在读取性能和实现简单性上有显著的优点,但在写操作性能和数据一致性上面临挑战。在使用这种模式时,需要根据具体应用场景设计合理的缓存更新和失效策略,以最大化其优点并最小化其局限。

对于 PmHub 业务来说,Cache Aside 模式就是最大化利用其优点以及最小化利用其局限性的实践。

PmHub 最佳实践

数据读取

原理很简单,就是先查询缓存,如果有就直接返回,没有就去数据库查询,然后再写入缓存。

这个在 PmHub 中很多地方的代码都有使用,随便举个例子吧:

/**
 * 根据键名查询参数配置信息
 *
 * @param configKey 参数key
 * @return 参数键值
 */
@Override
public String selectConfigByKey(String configKey) {
    String configValue = Convert.toStr(redisService.getCacheObject(getCacheKey(configKey)));
    if (StringUtils.isNotEmpty(configValue)) {
        return configValue;
    }
    SysConfig config = new SysConfig();
    config.setConfigKey(configKey);
    SysConfig retConfig = configMapper.selectConfig(config);
    if (StringUtils.isNotNull(retConfig)) {
        redisService.setCacheObject(getCacheKey(configKey), retConfig.getConfigValue());
        return retConfig.getConfigValue();
    }
    return StringUtils.EMPTY;
}

数据更新

更新数据的时候,先去更新数据库信息,然后再删除缓存,这个在 PmHub 中很多地方都有使用,比如在批量删除参数信息的场景下,先删除数据库信息,然后再删除缓存信息,代码如下:


/**
 * 批量删除参数信息
 *
 * @param configIds 需要删除的参数ID
 */
@Override
public void deleteConfigByIds(Long[] configIds) {
    for (Long configId : configIds) {
        SysConfig config = selectConfigById(configId);
        if (StringUtils.equals(UserConstants.YES, config.getConfigType())) {
            throw new ServiceException(String.format("内置参数【%1$s】不能删除 ", config.getConfigKey()));
        }
        configMapper.deleteConfigById(configId);
        redisService.deleteObject(getCacheKey(config.getConfigKey()));
    }
}

当然了,大家可以直接下载 PmHub 的源码,单体版本和微服务版本都可以看看里面具体的使用场景,如果有问题,也欢迎评论区留言,一起讨论。

面试

基于 PmHub 中缓存数据一致性解决方案,面试官可能会问到如下点,同学们只需要好好准备即可。

你们项目中是如何保证缓存和数据一致性的?

这个问题,建议大家学习完本章后,按照自己的理解作答,更加具有真实性

redis 有哪里数据类型

三分恶面渣逆袭:Redis基本数据类型

这个是比较基础的问题了,细节网上搜搜,背一背八股。

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

主要原因有 2 点: 1、更新缓存浪费服务器资源:频繁的缓存更新可能导致缓存服务器的负载增加,通过删除缓存而不是频繁更新,可以减少缓存服务器的压力,提高系统整体性能。

2、避免脏读:在高并发环境下,如果在写操作时直接更新缓存,可能会导致并发读操作获取到未完全更新的数据,从而产生脏读现象。删除缓存可以避免这种情况,因为在缓存被删除后,所有读操作都会直接从数据库读取最新数据。

3、减少并发冲突:如果在写操作时直接更新缓存,可能会引发大量的并发冲突,特别是在频繁写操作的情况下。通过删除缓存,可以减少并发冲突的可能性,简化并发控制。

4、避免双写问题:在写操作时同时更新数据库和缓存,可能会导致双写不一致的问题。如果在更新数据库后失败了,缓存和数据库数据可能不同步。删除缓存可以避免这种双写问题,只需保证数据库写入成功。

先更新 DB,再删除缓存就是完美解决方案了吗?

其实面试官问这个问题,主要是想考哈你严禁性,通常来说,没有完美的解决方案,但你得能 BB 个所以然出来。

那这个问题其实就转换为让你说说 Cache Aside 的局限性,所以完全可以参考本章节关于局限性的解释来作答。

好啦,今天分享了关于数据和缓存一致性方案的一些见解,感谢阅读。