一文讲透缓存穿透、击穿和雪崩

面试官问:穿透、击穿和雪崩有什么区别?

我回答:先看请求的数据是否存在,再看失效的是一个热点 key、很多 key,还是整个缓存服务。

问题 请求对象 典型现象 主要处理
缓存穿透 数据库中不存在的数据 未设置保护时,每次请求都可能查询数据库 空值缓存、布隆过滤器、参数校验
缓存击穿 一个访问量很高的热点 key 单个 key 失效后,大量请求同时回源 互斥锁、逻辑过期
缓存雪崩 大量 key,或整个 Redis 服务 大量缓存同时失效,或缓存故障导致集中回源 随机 TTL、高可用、限流降级、多级缓存

三者也可能叠加:一批热门店铺同时过期形成雪崩,其中某个店铺又因并发重建表现出击穿;持续请求不存在的 ID 则会形成穿透流量。回答时先指出压力从哪里来,再说明对应方案控制哪一段请求。

面试官问:什么是缓存穿透?你怎么解决?

我回答:缓存穿透是请求的数据在 Redis 和数据库中都不存在。普通缓存旁路流程只会回填查到的记录;数据库没有记录时,Redis 中仍没有这个 key,相同的请求下次又会查询数据库。

解决方案:我会先校验明显非法的参数,再把数据库的"无记录"结果用短 TTL 缓存起来。相同无效 ID 的后续请求命中空值标记,直接结束。若无效 ID 分布很散,可以在查询前加布隆过滤器,提前筛掉一部分确定未加入的 ID,并做好新增数据的同步维护。

空值缓存只能减少同一个无效 ID 的重复查询。攻击者每次换一个新 ID 时,每个 ID 仍可能造成一次数据库回源,所以入口限流仍然有价值。

代码实现:用空字符串缓存无记录结果

查询一个不存在的店铺时,Redis 不能只留下"查不到"这件事。当前实现把空字符串写回 Redis,并给它设置 2 分钟 TTL;正常店铺数据的 TTL 是 30 分钟。这样,下一次请求还能区分三种状态:

  • null:key 不存在,说明还没有缓存过,需要查询数据库。
  • 空字符串:key 存在,但之前已经确认数据库没有记录,直接返回 null。
  • 非空字符串:命中店铺 JSON,反序列化后返回。
java 复制代码
public Shop querywithpassthrough(Long id) {
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    // 命中正常缓存
    if (StringUtils.isNotBlank(shopJson)) {
        return JSONUtil.toBean(shopJson, Shop.class);
    }

    // key 存在但值为空,表示数据库确认无记录
    if (shopJson != null) {
        return null;
    }

    // 只有 key 不存在时才回源数据库
    Shop shop = getById(id);
    if (shop == null) {
        stringRedisTemplate.opsForValue().set(
                key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }

    stringRedisTemplate.opsForValue().set(
            key,
            JSONUtil.toJsonStr(shop),
            CACHE_SHOP_TTL,
            TimeUnit.MINUTES
    );
    return shop;
}

空值只表示"这一次查询没有找到记录",所以有效期要短于正常数据。店铺在这段时间内新增,旧的空值仍可能挡住新数据;无效 ID 很多时,空值 key 留存太久也会增加 Redis 的存储压力。

这套处理能挡住同一个无效 ID 的重复回源。请求每次换一个从未出现过的 ID 时,Redis 没有可复用的空值,仍可能触发一次数据库查询,入口还需要配合参数校验和访问频率限制。

追问:布隆过滤器能完全替代空值缓存吗?

我回答:布隆过滤器只能判断"确定未加入"或"可能存在"。只要对应位置有一个为 0,就可以拦截;所有位置为 1 时仍需继续查询,因为可能发生误判。它放行后查不到数据库的 ID,还可以交给空值缓存处理重复请求。

布隆过滤器需要提前装载并持续同步。新增店铺没有及时加入时,业务层可能把真实存在的记录误拦截。

面试官问:一个热点 key 过期,大量请求同时查数据库怎么办?

我回答:这属于缓存击穿。数据在数据库中存在,但一个高访问量 key 失效后,多个请求同时发现未命中,可能重复查询同一条记录。

解决方案:如果请求需要尽量新的数据,我会用互斥锁,让一个请求负责重建,其他请求等待后重试。如果业务允许短时间旧数据,我会预热逻辑过期缓存:过期后先返回旧值,只让一个后台任务刷新。两种方案的取舍点是等待时间与数据时效。

代码实现一:互斥锁控制重建

缓存未命中后尝试抢锁。拿到锁的请求二次检查缓存,再回源和回填;没拿到锁的请求等待后重试。锁要有过期时间,并且只能由持有者释放。

java 复制代码
public Shop querywithmutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    if (StringUtils.isNotBlank(shopJson)) {
        return JSONUtil.toBean(shopJson, Shop.class);
    }
    if (shopJson != null) {
        return null;
    }

    try {
        for (int attempt = 0; attempt < 10; attempt++) {
            String lockKey = LOCK_SHOP_KEY + id;
            String lockToken = UUID.randomUUID().toString();
            boolean locked = false;
            try {
                locked = trylock(lockKey, lockToken);
                if (!locked) {
                    Thread.sleep(50);
                    continue;
                }

                // 拿锁后再次读取,避免重复回源
                shopJson = stringRedisTemplate.opsForValue().get(key);
                if (StringUtils.isNotBlank(shopJson)) {
                    return JSONUtil.toBean(shopJson, Shop.class);
                }
                if (shopJson != null) {
                    return null;
                }

                Shop shop = getById(id);
                if (shop == null) {
                    stringRedisTemplate.opsForValue()
                            .set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                    return null;
                }

                stringRedisTemplate.opsForValue().set(
                        key,
                        JSONUtil.toJsonStr(shop),
                        CACHE_SHOP_TTL,
                        TimeUnit.MINUTES
                );
                return shop;
            } finally {
                if (locked) {
                    unlock(lockKey, lockToken);
                }
            }
        }
        throw new IllegalStateException("缓存重建等待超时");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException("缓存重建被中断", e);
    }
}

private boolean trylock(String key, String token) {
    Boolean result = stringRedisTemplate.opsForValue()
            .setIfAbsent(key, token, LOCK_SHOP_TTL, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(result);
}

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT =
        new DefaultRedisScript<>(
                "if redis.call('get', KEYS[1]) == ARGV[1] " +
                "then return redis.call('del', KEYS[1]) " +
                "else return 0 end",
                Long.class
        );

private void unlock(String key, String token) {
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT, Collections.singletonList(key), token);
}

拿到锁时,请求会用 UUID 生成自己的锁值。释放锁前,Lua 脚本先核对 Redis 中的锁值;确认锁仍属于当前请求,才执行删除。即使旧锁在处理期间到期、又被其他请求拿到,先前的请求也删不掉新锁。DefaultRedisScript 保存这段脚本,RedisTemplate.execute 将锁 key 和锁值传给 Redis 执行,Collections.singletonList 用来组装 key 列表。

没有拿到锁的请求每隔 50 毫秒重试一次,最多尝试 10 次,仍未成功就抛出等待超时异常。锁的有效期要覆盖正常的数据库查询与缓存回填;等待次数也要结合接口的超时时间调整。

需要新数据、又能接受短暂等待时,可以用互斥锁控制热点重建。它按店铺 ID 分锁,正常持锁期间,同一家店只由一个请求负责回源。如果大量不同店铺同时失效,各自的锁仍会放行一次数据库查询,还需要限制整体回源量。

代码实现二:逻辑过期与异步刷新

逻辑过期把业务过期时间放进 value,Redis key 本身不设置物理 TTL。请求读到过期数据后,先尝试抢锁;抢到锁的请求提交异步重建任务,当前请求仍返回旧值。

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

写入逻辑过期缓存时,项目使用 set 保存 JSON,不传入 Redis TTL:

java 复制代码
public void setwithLogicalExpire(
        String key, Object value, Long time, TimeUnit unit) {
    RedisData data = new RedisData();
    data.setExpireTime(
            LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
    data.setData(value);

    // Redis key 不设置物理 TTL
    stringRedisTemplate.opsForValue()
            .set(key, JSONUtil.toJsonStr(data));
}

读取逻辑过期缓存时,项目的 CacheClient 使用固定大小线程池异步刷新。以下示例沿用上方带 token 的 trylock 和 unlock 辅助方法;放入 CacheClient 时需一并使用这两个方法:

java 复制代码
private static final ExecutorService CACHE_REBUILD_POOL =
        Executors.newFixedThreadPool(10);

public <R, ID> R querywithlogicalexpire(
        String keyPrefix,
        ID id,
        Class<R> type,
        Function<ID, R> dbFallback,
        Long time,
        TimeUnit unit) {
    String key = keyPrefix + id;
    String json = stringRedisTemplate.opsForValue().get(key);
    if (StringUtils.isBlank(json)) {
        return null;
    }

    RedisData data = JSONUtil.toBean(json, RedisData.class);
    R value = JSONUtil.toBean((JSONObject) data.getData(), type);
    if (data.getExpireTime().isAfter(LocalDateTime.now())) {
        return value;
    }

    String lockKey = LOCK_SHOP_KEY + id;
    String lockToken = UUID.randomUUID().toString();
    if (trylock(lockKey, lockToken)) {
        boolean submitted = false;
        try {
            // 抢锁后重新读取,确认其他请求尚未完成刷新
            String latestJson = stringRedisTemplate.opsForValue().get(key);
            RedisData latest = StringUtils.isBlank(latestJson)
                    ? null
                    : JSONUtil.toBean(latestJson, RedisData.class);
            if (latest != null
                    && !latest.getExpireTime().isAfter(LocalDateTime.now())) {
                CACHE_REBUILD_POOL.submit(() -> {
                    try {
                        R freshValue = dbFallback.apply(id);
                        if (freshValue != null) {
                            setwithLogicalExpire(key, freshValue, time, unit);
                        }
                    } catch (Exception e) {
                        log.error("缓存重建失败,key={}", key, e);
                    } finally {
                        unlock(lockKey, lockToken);
                    }
                });
                submitted = true;
            }
        } finally {
            // 未提交任务时,锁仍由当前请求持有
            if (!submitted) {
                unlock(lockKey, lockToken);
            }
        }
    }

    // 无论是否抢到锁,都先返回旧值
    return value;
}

逻辑过期缓存要先把热点数据写入 Redis。预热时先从数据库查出店铺,再把店铺信息和过期时间一起保存。查询方法只读取已经写入的逻辑过期数据;如果 key 不存在,它会直接返回空结果,不会在这个分支里再次查询数据库。

逻辑过期时间到了,后台线程会尝试刷新缓存。刷新失败时,原有数据仍可能留在 Redis 中,因此要给旧值设定最长可接受时限。超过这个时限后,系统应进入受控回源或业务降级路径,避免刷新持续失败时长期返回旧数据。

物理 TTL 由 Redis 管理。时间到了,Redis 可以删除对应的 key;逻辑过期把过期时间放在 value 中,key 仍然存在,由应用读取过期时间并决定是否刷新。逻辑过期依赖 Redis 能够正常读取,仍然需要配合复制、Sentinel 或 Cluster 等高可用方案。

面试官问:很多缓存同时失效,或者 Redis 宕机,你怎么保护数据库?

我回答:这属于缓存雪崩,需要先分清故障来源。大量 key 在相近时间写入并采用相同 TTL,会集中到期;Redis 节点或网络故障,会让应用无法读取缓存。两种情况都可能造成大量回源。

解决方案:批量过期时,对允许的缓存时长增加随机偏移,必要时分批预热。Redis 故障时,用复制、Sentinel 或 Cluster 降低节点故障影响,并在应用侧限制数据库回源总量,为超限请求准备明确的降级结果。业务允许旧数据时,可以再用本地缓存提供一条独立读取路径。

这些措施保护的范围不同:随机 TTL 分散到期时间,高可用处理节点故障,回源限流保护数据库。本地缓存还要处理各实例的数据失效和允许多旧的问题。

Redis 故障时的处理:主从复制保存数据副本,Sentinel 监控主从节点并执行自动故障转移,Cluster 通过分片和副本支持扩展与故障转移。节点切换期间仍可能短暂失败,应用客户端需要能够发现新主节点并恢复连接。

缓存未命中或 Redis 读取异常时,回源入口要限制数据库承受的总请求量。单 key 互斥锁只约束同一条数据的重建;大量不同 key 同时失效时,仍需业务级限流。超过限额的请求应返回符合业务语义的降级结果,不能把 Redis 连接失败伪装成"店铺不存在"。多实例部署时,各实例限额会叠加,应按数据库总容量设置。

本地缓存可以在 Redis 短暂不可用时提供独立的旧值读取路径,但需要管理实例间更新、容量和最大允许旧值时间。

代码实现:给正常数据设置随机 TTL

如果店铺缓存基础 TTL 为 30 分钟,可以在允许的范围内加入随机偏移:

java 复制代码
long baseSeconds = TimeUnit.MINUTES.toSeconds(CACHE_SHOP_TTL);
long randomSeconds = ThreadLocalRandom.current().nextLong(301);
long ttlSeconds = baseSeconds + randomSeconds;

stringRedisTemplate.opsForValue().set(
        key,
        JSONUtil.toJsonStr(shop),
        ttlSeconds,
        TimeUnit.SECONDS
);

这个例子把 TTL 分布在 1800 到 2100 秒之间。随机范围必须服从业务的数据时效上限。业务数据最长只允许缓存 30 分钟时,随机 TTL 可设置在 25 到 30 分钟之间。

随机 TTL 只能分散物理过期时间。它不能防止单个热点并发重建,也不能在 Redis 故障时继续读取缓存。

逻辑过期也可以加入随机的业务过期时间,以分散后台刷新任务,但大量 key 仍然可能同时触发刷新,所以还要配合互斥重建和回源限流。

面试官追问:这几种方案如何选择?

我回答:先确认业务允许的数据旧值时长,以及数据库能够承受的总回源量。

条件 优先考虑
数据必须尽量新,可以接受短暂等待 互斥锁
读多写少,允许返回短时间旧值 逻辑过期
无效 ID 重复请求较多 空值缓存
无效 ID 分布很散,数据规模大 布隆过滤器加空值缓存
大批缓存集中写入 随机 TTL、分批预热
Redis 节点可能故障 复制、Sentinel 或 Cluster,加限流降级

面试官追问:怎么证明这些方案生效了?

我回答:在开发或隔离测试环境分别制造无效 ID、热点 key 失效和批量过期,观察数据库查询量、Redis 状态和请求结果。

穿透验证

准备一条确认存在的店铺 ID 和一条确认不存在的 ID。无效 ID 第一次访问应查询数据库并写入空字符串;空值 TTL 内再次访问时,应直接结束且不再触发该方法的数据库查询。Redis 中可以使用 GET、EXISTS 和 TTL 区分空字符串 key 与不存在的 key。

击穿验证

先删除一个热点 key,再用并发请求访问同一个 ID。互斥锁方案应看到一次缓存重建,其余请求等待后命中;逻辑过期方案应立即返回旧值,并观察只有一个请求提交异步刷新任务。检查锁 TTL、锁释放和重建失败分支。

雪崩验证

批量写入测试 key 后查看 TTL 分布,确认随机偏移确实产生不同到期时间。对比固定 TTL 与随机 TTL 下的数据库查询量、接口耗时、错误率和连接池等待情况。模拟 Redis 连接失败时,确认请求不会无条件把全部流量转给数据库;使用 Sentinel 或 Cluster 时,还要观察故障发现、切换和客户端重连过程。

Redis TTL 命令返回剩余秒数;返回 -1 表示 key 存在但没有物理过期时间,返回 -2 表示 key 不存在。逻辑过期缓存通常会得到 -1,此时应读取 JSON 中的 expireTime,不能把 -1 当作业务数据永不过期。

面试时先判断故障形状,再说明方案的作用范围,最后用代码交代请求在哪里停止、由谁回源、缓存如何重建。排查线上问题也遵循同一顺序:先看 Redis 返回空值、缺失 key,还是连接异常;再看受影响的是一个热点、很多 key,还是整个缓存服务。

相关推荐
wjjzhbb1 小时前
Jenkins到ArgoCD——GitOps持续交付实践
运维·缓存·jenkins·argocd
xcl09251 小时前
西安同城货运系统源码开发实战 核心功能实现与部署全指南
java·spring boot
pippocao1 小时前
王者荣耀日志组件BqLog为什么这么快之1——高性能实时压缩日志
java
AC赳赳老秦1 小时前
公开 CSV 数据集批量处理实战:用 OpenClaw 高效完成下载、清洗与标准化分析样本生成
java·开发语言·汇编·c++·python·deepseek·openclaw
ym hyd 1112 小时前
慢性病精细化管理平台源码 Java+SpringBoot+Vue3 前后分离
java·vue.js·spring boot·毕设
程序猿乐锅2 小时前
【黑马点评 | 第七篇】Redis 分布式锁的两种实现
java·数据库·redis·分布式·spring·缓存
SL_staff2 小时前
JVS私有化交付为何敢承诺100%源码开放与无兜底风险?
java·低代码·全栈
需要8262 小时前
MySQL MVCC 与事务隔离级别:从一条 update 看版本链
java·数据库·spring boot·mysql·spring cloud
Memory_荒年2 小时前
订单超时未支付?从“定时扫库”一步步到最终形态
java·后端