1、数据一致性问题

        缓存虽能有效降低后端负载、提升读写效率并缩短响应时间,看起来似乎是“百利而无一害”的银弹。但事实并非如此——它并非适用于所有场景的万能方案。在实际项目中,盲目引入缓存往往会带来新的挑战:一方面增加了系统的运维复杂度和维护成本,另一方面也引入了数据一致性、缓存失效、雪崩等潜在风险。因此,缓存的使用并非“多多益善”,而是一项需要权衡利弊的架构决策。在决定引入缓存之前,团队应充分进行性能测试、容量评估和成本预算,综合判断缓存所带来的实际价值是否大于其引入的额外代价,从而做出理性、务实的技术选型。

        那么,面对缓存带来的数据一致性问题,我们该如何应对呢?解决问题的关键,首先在于厘清问题的根源。数据一致性问题的本质,在于缓存中的数据与数据库中的真实数据之间产生了不同步。而要最大程度地缩小这种差异,核心就在于选择并设计一套合适的缓存更新策略。

        下面给出了常见的缓存更新策略:

1.1内存淘汰(全自动)

Redis在内存不足时,根据预设策略自动淘汰部分数据,这是缓存层级的被动兜底机制。

常见淘汰策略:

策略 说明
noeviction(默认) 内存达到限制后,写入操作直接返回错误,保证数据完整性
allkeys-lru 从所有Key中淘汰最近最少使用(LRU)的数据
allkeys-random 从所有Key中随机淘汰
volatile-lru 从设置了过期时间的Key中淘汰最近最少使用的数据
volatile-random 从设置了过期时间的Key中随机淘汰
volatile-ttl 从设置了过期时间的Key中淘汰剩余生存时间(TTL)最短的数据

适用场景:对一致性要求较低的场景(如店铺类型数据的查询缓存)。

1.2、超时剔除(半自动)

        手动为缓存数据设置TTL(过期时间),到期后由Redis自动删除,属于时间维度的兜底保障。

核心价值:即使主动更新失败,数据也不会永久不一致,TTL到期后会自动从数据库重建。

1.3、主动更新(手动)

        通过编码在数据库变更时主动干预缓存,是保证一致性的核心手段。主要包含三种实现方案:

1. 双写方案(Cache-Aside Pattern)

调用者通过编码方式,在更新数据库的同时维护缓存。实现最灵活,但需要人工处理一致性问题。

执行流程:

  • 读取:先查缓存,命中则直接返回;未命中则查数据库并回填缓存。

  • 写入:先更新数据库,再根据策略处理缓存。

关键决策点:

① 更新缓存 vs 删除缓存?

  • 更新缓存:每次数据库更新都同步更新缓存。不推荐,因为无效写操作多——若多次更新期间无查询请求,缓存更新毫无意义。

  • 删除缓存:更新数据库后删除缓存,等下次查询时再重建。推荐,无效写操作少。

② 先操作缓存 vs 先操作数据库?

  • 先删缓存,再更新数据库:不推荐。删缓存到更新数据库之间存在时间窗口,大量并发请求会直接穿透到数据库,引发缓存击穿,且概率较大。

  • 先更新数据库,再删缓存:推荐。仅在缓存未命中且恰好有并发写入时可能出现脏读,但该窗口极短(缓存查询毫秒级),发生概率很低。

③ 如何保证原子性?

  • 单体系统:将缓存和数据库操作放在同一个事务中。

  • 分布式系统:采用TCC等分布式事务方案。

2. 读写穿透方案(Read/Write Through Pattern)

缓存层作为唯一入口,由缓存服务自身负责与数据库的同步,调用者无需关心一致性维护。

  • 读取穿透:查缓存 → 未命中则查数据库 → 回填缓存 → 返回数据。

  • 写入穿透:写缓存 → 缓存立即同步写入数据库 → 返回成功。

特点:对调用方透明,维护成本低,但缓存层实现复杂,适用于对实时性和一致性要求严格的场景。

3. 写回方案(Write Behind Caching Pattern)

调用者只操作缓存,由独立线程异步将数据批量或延迟写入数据库,追求最终一致性

执行流程

  • 读取:查缓存,未命中则查数据库并回填。

  • 写入:先更新数据库,再将待写入数据放入队列,通过批量或异步方式写入底层存储。

特点:写入性能最高,但一致性和可靠性最弱,适用于对数据实时性要求不高的高吞吐场景。

三种主动更新方案对比

方案 写入方式 一致性保障 性能特点 适用场景
双写方案 同步更新/删除缓存 由应用程序主动管理 中等 读多写少,业务灵活度高
读写穿透 缓存同步写入数据库 由缓存层统一维护 中等 实时性和一致性要求高
写回方案 异步批量写入 最终一致性 最高 追求写入性能,容忍数据延迟

1.4、使用缓存添加剔除和主动更新策略

        使用双写方案删除缓

存模式来减少线程安全问题发生的概率,采用TTL过期+内存淘汰机制作为兜底方案,同时将缓存和数据库的操作放到同一个事务来保障操作的原子性,现在就让我们来通过下面的案例进行实现;

        使用缓存主动更新策略(采用删除缓存模式,并且先操作数据库再操作缓存,同时添加事务保证数据库操作和缓存操作的原子性)解决数据一致性问题:

    /**
     * 根据id查询商铺数据(查询时,重建缓存)
     *
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1、从Redis中查询店铺数据
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        Shop shop = null;
        // 2、判断缓存是否命中
        if (StrUtil.isNotBlank(shopJson)) {
            // 2.1 缓存命中,直接返回店铺数据
            shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 2.2 缓存未命中,从数据库中查询店铺数据
        shop = this.getById(id);

        // 4、判断数据库是否存在店铺数据
        if (Objects.isNull(shop)) {
            // 4.1 数据库中不存在,返回失败信息
            return Result.fail("店铺不存在");
        }
        // 4.2 数据库中存在,重建缓存,并返回店铺数据
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

    /**
     * 更新商铺数据(更新时,更新数据库,删除缓存)
     *
     * @param shop
     * @return
     */
    @Transactional
    @Override
    public Result updateShop(Shop shop) {
        // 参数校验, 略

        // 1、更新数据库中的店铺数据
        boolean f = this.updateById(shop);
        if (!f){
            // 缓存更新失败,抛出异常,事务回滚
            throw new RuntimeException("数据库更新失败");
        }
        // 2、删除缓存
        f = stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
        if (!f){
            // 缓存删除失败,抛出异常,事务回滚
            throw new RuntimeException("缓存删除失败");
        }
        return Result.ok();
    }

2、缓存穿透

        缓存穿透是指查询一个根本不存在的数据(缓存与数据库中都不存在),由于缓存中也不会有这条数据,所以请求会直接穿透缓存层,打到数据库上。

2.1解决方案

2.1.1缓存空对象

        当数据库查询结果为空时,仍然将这个空结果缓存起来,设置一个较短的过期时间(如30秒~5分钟)

优点:实现简单,能挡住大量重复的穿透请求。

缺点:如果恶意攻击使用大量不同的不存在Key,空对象缓存无法应对(每个Key还是第一次会穿透)、占用一定的缓存内存

2.1.2布隆过滤

布隆过滤的原理:

        布隆过滤器本质上是一个大型的位数组(由0和1组成)和若干个哈希函数的组合。它的操作分为两步:

  1. 添加元素:当要存入一个元素时,将它依次输入k个不同的哈希函数,得到k个数组位置,然后将这些位置全部设为1

  2. 查询元素:检查一个元素是否存在时,同样用这k个哈希函数计算出k个位置。如果其中任何一个位置是0,则该元素一定不存在;如果全部是1,则该元素可能存在

在缓存前面加一层布隆过滤器,存储所有可能存在的Key。请求进来时先经过布隆过滤器判断:

  • 如果过滤器说“不存在”→ 直接返回,不查缓存也不查数据库

  • 如果过滤器说“可能存在”→ 继续走正常流程(查缓存→查数据库)

优点:内存占用极小,能从根源上拦截绝大部分不存在的Key。

缺点:布隆过滤器有误判率(可能把不存在的Key判定为存在)、数据新增时,需要同步更新布隆过滤器,维护成本较高

实现方式

  • Redis原生支持布隆过滤器插件(RedisBloom)。

  • 客户端库如Guava(Java)、Pybloom(Python)。

2.2采用缓存空对象解决缓存穿透

    /**
     * 根据id查询商铺数据
     *
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1、从Redis中查询店铺数据
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        Shop shop = null;
        // 2、判断缓存是否命中
        if (StrUtil.isNotBlank(shopJson)) {
            // 2.1 缓存命中,直接返回店铺数据
            shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        // 2.2 缓存未命中,判断缓存中查询的数据是否是空字符串(isNotBlank把null和空字符串给排除了)
        if (Objects.nonNull(shopJson)){
            // 2.2.1 当前数据是空字符串(说明该数据是之前缓存的空对象),直接返回失败信息
            return Result.fail("店铺不存在");
        }
        // 2.2.2 当前数据是null,则从数据库中查询店铺数据
        shop = this.getById(id);

        // 4、判断数据库是否存在店铺数据
        if (Objects.isNull(shop)) {
            // 4.1 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
            return Result.fail("店铺不存在");
        }
        // 4.2 数据库中存在,重建缓存,并返回店铺数据
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 
                                              CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

3.缓存雪崩的解决方案

        大量 key 同时失效或 Redis 集群宕机重启,导致所有请求压垮数据库。与缓存击穿(单个热点Key失效)不同,雪崩的核心是大规模Key集体失效。

  • 缓存雪崩的常见解决方案
    • 给不同的Key的TTL添加随机值
    • 利用Redis集群提高服务的可用性
    • 给缓存业务添加降级限流策略,比如快速失败机制,让请求尽可能打不到数据库上
    • 给业务添加多级缓存

3.1电商大促“秒杀”活动引发的缓存雪崩

        某电商平台在零点开启“秒杀”活动,运营同学提前将一批热门商品数据全部放入Redis缓存,统一设置了 凌晨0点过期(便于零点刷新价格和库存)。

结果到了零点:

  1. 大量热门商品Key在同一时刻全部过期(接近1000个)

  2. 瞬间涌入的秒杀请求(几十万QPS)全部绕过缓存,直接打到数据库

  3. 数据库连接池迅速耗尽,CPU飙升至100%,数据库响应超时

  4. 超时的请求不断重试,进一步加剧数据库压力

  5. 短短30秒内,数据库崩溃,整个商品服务不可用

这就是典型的缓存雪崩——大量Key同时失效,流量瞬间击穿缓存层,压垮了数据库。

解决方案演进

第一步:紧急止损(当天凌晨)

方案:快速失败 + 限流

在网关层(如Nginx或Spring Cloud Gateway)临时开启了限流,将QPS限制在数据库能承受的范围内(比如5000 QPS),超出部分直接返回“系统繁忙,请稍后再试”。

  • 效果:挡住了大部分流量,数据库存活下来,服务在5分钟内恢复。

  • 代价:大量用户无法下单,体验很差,属于牺牲体验保命的临时措施。

第二步:短期修复(次日修复)

方案①:TTL添加随机值

修改缓存加载逻辑,不再统一设置相同的过期时间,而是加上随机偏移量:

// 原方案:统一过期时间
redisTemplate.opsForValue().set(key, data, 0, TimeUnit.HOURS);  // 凌晨0点过期

// 修复后:基础时间 + 随机偏移
long baseExpire = 3600; // 1小时
long randomOffset = ThreadLocalRandom.current().nextInt(0, 300); // 0~300秒随机
redisTemplate.opsForValue().set(key, data, baseExpire + randomOffset, TimeUnit.SECONDS);
  • 效果:1000个Key的过期时间被分散在 1小时 ~ 1小时5分钟 之间,不再集中爆发。

方案②:多级缓存(本地缓存作为缓冲)

在Redis之上增加一层Caffeine本地缓存,存储最核心的热点商品数据,TTL设置为 12小时(远长于Redis的1小时):

@Cacheable(value = "product", key = "#productId", cacheManager = "caffeineCacheManager")
public Product getProduct(Long productId) {
    // 如果本地缓存未命中,再去查Redis -> 数据库
}

链路变成:本地缓存(Caffeine)→ Redis → 数据库

  • 效果:即使Redis Key集中过期,本地缓存还能挡住30%~40%的热点请求,进一步减少数据库压力。

方案③:Redis集群高可用

将单节点Redis升级为 Redis Cluster(三主三从),确保即使部分节点宕机,服务仍可用。

第三步:长期预防(架构优化)

方案④:缓存预热 + 定时刷新

        在活动开始前,通过后台任务提前将热门数据加载到缓存中,并错峰设置过期时间。同时启用了定时刷新任务,在缓存即将过期前自动续期,避免被动过期导致的集中失效。

4.缓存击穿的解决方案

        缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

4.1缓存击穿的常见解决方案

4.1.1互斥锁解决

  • 互斥锁(时间换空间)
    • 优点:内存占用小,一致性高,实现简单
    • 缺点:性能较低,容易出现死锁

互斥锁在低并发一致性要求极高的场景下(如库存扣减、订单状态)依然是首选:

  • 锁粒度控制:不要对整个缓存重建过程加锁,而是对 "同一个Key" 加锁。即不同Key的请求可以并发执行,只有请求同一个Key且缓存失效时才会阻塞。

  • 双重检查锁(DCL):获取锁之后,再次检查缓存是否已被其他线程重建,避免重复执行。

  • 设置合理的超时时间:给锁设置过期时间(如Redis的SETNX + 过期时间),防止因异常导致死锁。

  • 使用Redisson等封装好的分布式锁:自带看门狗(Watchdog)自动续期机制,大大降低死锁风险。

4.1.2逻辑过期

  • 逻辑过期(空间换时间)
    • 优点:性能高
    • 缺点:内存占用较大,容易出现脏读

"容易出现脏读" 是逻辑过期最大的痛点。在实践中,可以通过以下方式降低影响:

  • 缩短重建窗口期:提高后台线程的执行优先级,或使用线程池并行处理多个重建任务,尽快完成缓存刷新。

  • 脏读容忍度评估:逻辑过期适合对数据实时性不敏感的业务,如商品详情描述、用户昵称、文章内容等。对于金额、库存等敏感数据,不建议使用。

  • 版本号机制:在缓存中增加版本号字段,后台更新时递增版本号,业务逻辑根据版本号判断数据新旧,但实现复杂度会明显增加。

适用场景 推荐方案
强一致性要求(库存、支付、订单状态) 互斥锁
读多写少、对响应时间敏感(商品详情、文章、配置信息) 逻辑过期
并发量中等、可接受短暂等待 互斥锁(实现简单,够用)
并发量极高、且对一致性有一定容忍度 逻辑过期
追求极致性能,同时需要一致性保障 逻辑过期 + 版本号机制(折中方案)

4.2同时利用互斥锁和逻辑过期解决缓存击穿问题

4.2.1基于互斥锁解决

    /**
     * 根据id查询商铺数据
     *
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1、从Redis中查询店铺数据,并判断缓存是否命中
        Result result = getShopFromCache(key);
        if (Objects.nonNull(result)) {
            // 缓存命中,直接返回
            return result;
        }
        try {
            // 2、缓存未命中,需要重建缓存,判断能否能够获取互斥锁
            String lockKey = LOCK_SHOP_KEY + id;
            boolean isLock = tryLock(lockKey);
            if (!isLock) {
                // 2.1 获取锁失败,已有线程在重建缓存,则休眠重试
                Thread.sleep(50);
                return queryById(id);
            }
            // 2.2 获取锁成功,判断缓存是否重建,防止堆积的线程全部请求数据库(所以说双检是很有必要的)
            result = getShopFromCache(key);
            if (Objects.nonNull(result)) {
                // 缓存命中,直接返回
                return result;
            }

            // 3、从数据库中查询店铺数据,并判断数据库是否存在店铺数据
            Shop shop = this.getById(id);
            if (Objects.isNull(shop)) {
                // 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
                return Result.fail("店铺不存在");
            }

            // 4、数据库中存在,重建缓存,响应数据
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
                    CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return Result.ok(shop);
        }catch (Exception e){
            throw new RuntimeException("发生异常");
        } finally {
            // 5、释放锁(释放锁一定要记得放在finally中,防止死锁)
            unlock(key);
        }
    }

    /**
     * 从缓存中获取店铺数据
     * @param key
     * @return
     */
    private Result getShopFromCache(String key) {
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 判断缓存是否命中
        if (StrUtil.isNotBlank(shopJson)) {
            // 缓存数据有值,说明缓存命中了,直接返回店铺数据
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 判断缓存中查询的数据是否是空字符串(isNotBlank把 null 和 空字符串 给排除了)
        if (Objects.nonNull(shopJson)) {
            // 当前数据是空字符串,说明缓存也命中了(该数据是之前缓存的空对象),直接返回失败信息
            return Result.fail("店铺不存在");
        }
        // 缓存未命中(缓存数据既没有值,又不是空字符串)
        return null;
    }


    /**
     * 获取锁
     *
     * @param key
     * @return
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        // 拆箱要判空,防止NPE
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     *
     * @param key
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

备注

  1. 这里使用Redis中的setnx指令实现互斥锁,只有当值不存在时才能进行set操作

  2. 锁的有效期更具体业务有关,需要灵活变动,一般锁的有效期是业务处理时长10~20倍

  3. 线程获取锁后,还需要查询缓存(也就是所谓的双检),这样才能够真正有效保障缓存不被击穿

4.2.2基于逻辑过期解决

首先创建一个逻辑过期类:

@Data
public class RedisData {
    /**
     * 过期时间
     */
    private LocalDateTime expireTime;
    /**
     * 缓存数据
     */
    private Object data;
}

方案解决代码:

    /**
     * 缓存重建线程池
     */
    public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 根据id查询商铺数据
     *
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 1、从Redis中查询店铺数据,并判断缓存是否命中
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(shopJson)) {
            // 1.1 缓存未命中,直接返回失败信息
            return Result.fail("店铺数据不存在");
        }
        // 1.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 当前缓存数据未过期,直接返回
            return Result.ok(shop);
        }

        // 2、缓存数据已过期,获取互斥锁,并且重建缓存
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 获取锁成功,开启一个子线程去重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShopToCache(id, CACHE_SHOP_LOGICAL_TTL);
                } finally {
                    unlock(lockKey);
                }
            });
        }

        // 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
        shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(shopJson)) {
            // 3.1 缓存未命中,直接返回失败信息
            return Result.fail("店铺数据不存在");
        }
        // 3.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
        redisData = JSONUtil.toBean(shopJson, RedisData.class);
        // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
        data = (JSONObject) redisData.getData();
        shop = JSONUtil.toBean(data, Shop.class);
        expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 当前缓存数据未过期,直接返回
            return Result.ok(shop);
        }

        // 4、返回过期数据
        return Result.ok(shop);
    }

    /**
     * 将数据保存到缓存中
     *
     * @param id            商铺id
     * @param expireSeconds 逻辑过期时间
     */
    public void saveShopToCache(Long id, Long expireSeconds) {
        // 从数据库中查询店铺数据
        Shop shop = this.getById(id);
        // 封装逻辑过期数据
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 将逻辑过期数据存入Redis中
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 获取锁
     *
     * @param key
     * @return
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        // 拆箱要判空,防止NPE
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     *
     * @param key
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

在此之前,逻辑过期需要首先预热,将热点数据加载到缓存当中:

        数据预热可以编写一个CommandLineRunner继承类,然后SpringBoot程序启动时就执行启动的run方法,实现数据预热,或者像下面一样编写一个测试累,实现数据预热

    /**
     * 预热店铺数据
     */
    @Test
    public void testSaveShopToCache(){
        shopService.saveShopToCache(1L, RedisConstants.CACHE_SHOP_LOGICAL_TTL);
    }

        逻辑过期时间根据具体业务而定,逻辑过期过长,会造成缓存数据的堆积,浪费内存,过短造成频繁缓存重建,降低性能,所以设置逻辑过期时间时需要实际测试和评估不同参数下的性能和资源消耗情况,可以通过观察系统的表现,在业务需求和性能要求之间找到一个平衡点。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐