Redis缓存穿透、击穿、雪崩:区别、排查与治理实战
2026/9/17 17:37:46 网站建设 项目流程

凌晨两点被电话叫醒,打开监控一看 QPS 没涨、CPU 不高,数据库连接池却打满了——这种场景我遇到过不止一次,最后定位下来基本都是 Redis 缓存层的三个老问题:缓存穿透、缓存击穿、缓存雪崩。它们三个经常被放在一起讲,面试题里也是常客,但真到了线上,很多人还是分不清到底是哪一个在搞事情,用错了方案,写了一堆防御代码,结果数据库该挂还是挂。这篇文章我打算按我的实际排查顺序来写,先把三个概念的边界划清楚,再逐个拆原因、给方案、算参数、贴代码,最后把我这几年的踩坑记录和线上速查表一并整理出来。内容偏实战,适合已经用过 Redis、能写基本缓存逻辑,但还没系统梳理过缓存治理的后端同学;刚入门的朋友也可以从第 1 章开始顺着看,不影响理解。

1. 先把三个问题的边界划清楚:别再混着说

1.1 三个概念的本质区别与判定方法

很多人背八股文的时候能把定义说得滚瓜烂熟,一旦线上出事就问「这是不是雪崩」,其实区分它们只需要盯着一个变量:被请求的那个 key,在 Redis 里到底存不存在,以及有几个 key 同时失效

缓存穿透的关键词是「不存在」。请求的数据在数据库里压根没有,所以缓存里也不会有,每一次请求都会绕过缓存直接压到数据库。它的特征是:命中率极低,但请求量可能不大,却能让数据库承受成倍的无效查询。典型的攻击场景就是拿一堆随机 ID 刷接口,或者业务上查询一个已经被删除的对象。

缓存击穿的关键词是「某一个热点 key 恰好过期」。这个 key 之前是存在的、访问量巨大的,比如首页的爆款商品详情、大促的活动配置。它过期的那一瞬间,所有并发请求同时发现缓存没了,一起冲向后端重建缓存。特征是:命中率平时很高,突然掉一下,数据库 QPS 出现一个尖刺

缓存雪崩的关键词是「大面积同时失效」。可能是大量 key 设置了同一个过期时间点,也可能是 Redis 实例本身挂了、主从切换期间不可用,导致所有请求瞬间压到数据库。特征是:命中率断崖式下跌,数据库连接数、CPU 一起飙高,故障面广且恢复慢

三者可以用一张表对比清楚:

维度缓存穿透缓存击穿缓存雪崩
数据是否存在数据库里也不存在存在,只是缓存过期了存在,但大批 key 同时失效
影响范围单个或一批不存在的 key单个热点 key大量 key 或整个实例
典型诱因恶意刷不存在的 ID、业务删除热点 key TTL 到期TTL 集中、实例宕机、网络抖动
数据库压力形态持续性的无效查询短时尖刺持续性高位,可能压垮
主要对策空值缓存、布隆过滤器、参数校验互斥重建、逻辑过期、热点永不过期TTL 打散、多级缓存、熔断降级

判定的时候我一般看两个指标:Redis 的keyspace_hitskeyspace_misses算出的命中率,以及数据库侧的 QPS 曲线形状。命中率长期偏低(比如低于 80%)而请求量稳定的,先怀疑穿透;命中率正常但某个时间点数据库出现尖刺的,怀疑击穿;命中率和数据库 QPS 同时剧烈波动的,怀疑雪崩。

1.2 为什么它们总被放在一起讲,却又必须分开处理

这三个问题被绑在一起,是因为它们共享同一条故障链路:缓存失效 → 请求穿透到数据库 → 数据库压力升高。根子上都是「缓存没兜住」,所以防御思路有大量重叠:限流、降级、布隆过滤器这些手段对三者都有效,多级缓存也能同时缓解击穿和雪崩。

但必须分开处理的原因在于成本结构完全不同。穿透的防御重点是挡住无效请求,布隆过滤器的内存开销和误判率需要仔细权衡;击穿的防御重点是让重建过程串行化,引入锁就意味着要处理死锁、锁超时、锁续期这些麻烦事;雪崩的防御重点是分散风险和提高整体可用性,涉及的是架构层面的多级缓存、集群部署、熔断降级。

我见过最典型的错误是:线上出现大面积超时,工程师第一反应是加了分布式锁去重建缓存,结果锁的粒度过粗,把整个服务拖成了串行,QPS 掉到个位数。锁能治击穿,但对雪崩基本无效,甚至会加重问题。所以我一直建议团队在接入缓存之前,先把这三类故障的预案分别写清楚,别等到出事再临时拍脑袋。

2. 缓存穿透:查一个根本不存在的数据

2.1 穿透是怎么发生的:把请求链路完整走一遍

假设有个商品详情接口,逻辑是标准的 Cache Aside:先查 Redis,没有就查数据库,查到再写回 Redis。现在有个请求携带的productId是数据库里不存在的,会发生什么?

第一步,查 Redis,返回 null;第二步,查数据库,返回空结果;第三步,因为结果是空,很多实现会直接if (result != null)才写缓存,于是什么都没写;第四步,返回给调用方一个空对象或者 404。下一个请求带着同样的 ID 进来,重复上面四步。

如果这个 ID 是被大批量、高并发地请求,比如有人写了个脚本遍历随机 ID 刷接口,或者上游服务出了 bug 传入了一个错误的参数,每一个请求都会完整地穿过缓存打到数据库。这时 Redis 看起来一切正常——内存占用低、CPU 不高、连接数很小,因为它根本没干什么活,所有压力都转嫁给了数据库。数据库的慢查询日志里会出现大量WHERE id = ?却返回空集的记录,QPS 上涨但数据量没变。

还有一个容易被忽略的变种:业务逻辑导致的穿透。比如用户注销后,缓存被删了,但页面上还挂着旧链接,每次点击都查一次空。或者某个定时任务扫描全表时用了错误的 ID 规则。这类穿透的请求量未必大,但持续时间长,日积月累同样能把数据库拖慢。

2.2 方案一:空值缓存与短 TTL 的取舍

最直接的办法是把空结果也缓存起来,给一个比较短的过期时间,比如 60 秒。这样同一个不存在的 ID 在 60 秒内只会打一次数据库,后续请求都被 Redis 挡住了。

public Product getById(Long id) { String key = "product:detail:" + id; // 用特殊占位符表示「数据库里没有」 String cache = redis.opsForValue().get(key); if (EMPTY_FLAG.equals(cache)) { return null; } if (cache != null) { return JSON.parseObject(cache, Product.class); } Product product = productMapper.selectById(id); if (product == null) { redis.opsForValue().set(key, EMPTY_FLAG, 60, TimeUnit.SECONDS); return null; } redis.opsForValue().set(key, JSON.toJSONString(product), 1800, TimeUnit.SECONDS); return product; }

TTL 的选择是有讲究的。太长,业务数据新增之后缓存里还是「不存在」,会出现数据已经插入但接口仍然返回空的尴尬情况;太短,起不到挡量的作用。我的经验值是30 到 120 秒,具体看业务对数据可见性的要求。如果是订单、支付这类强一致场景,空值缓存就别用了,改用布隆过滤器更稳妥。

注意:空值缓存必须用一个不可能与真实业务数据冲突的占位符,比如__EMPTY__或者单独的null标记位。我见过有人直接存字符串"null",结果反序列化时抛出异常,反而把服务搞挂了。

还有一个坑是内存膨胀。如果攻击者用完全随机的 ID 刷接口,每个不存在的 ID 都会在 Redis 里留下一个占位键,几分钟内就能塞进去几十万个 key。所以空值缓存一定要配合参数校验和限流使用,不能单独裸奔。

2.3 方案二:布隆过滤器,以及那几个参数怎么算

布隆过滤器的思路是:在真正查缓存之前,先用一个位数组判断「这个 key 是不是可能存在」。如果判断为「一定不存在」,直接返回,连 Redis 都不用查。它的特点是只会误判为存在,不会误判为不存在,也就是说,它说没有就一定没有,它说有不一定有。这个特性正好契合穿透防御的需求——我们只需要拦住那些确定不存在的请求。

实现上通常用 Redis 的 Bitmap,或者用 Guava、Redisson 提供的现成封装。关键是要把参数算对,位数组太小会导致误判率飙升,太大又浪费内存。公式如下:

  • 位数组长度:m = -n * ln(p) / (ln2)^2
  • 哈希函数个数:k = (m / n) * ln2

其中n是预期要存的元素个数,p是可接受的误判率。举个实际的例子:商品表预计有 1000 万条数据,允许的误判率设为 1%。

m = -10,000,000 × ln(0.01) / (ln2)^2 = -10,000,000 × (-4.6052) / 0.4805 ≈ 95,850,000 bit ≈ 11.4 MB k = (95,850,000 / 10,000,000) × 0.693 ≈ 6.64,向上取整为 7

也就是说,11.4 MB 的内存换来 1% 的误判率,性价比非常高。如果误判率放宽到 3%,内存能降到 7 MB 左右;如果收紧到 0.1%,内存要涨到 17 MB 以上。生产环境我一般取1% 到 3%,因为误判的后果只是偶尔多打一次数据库,代价可以接受。

用 Redis Bitmap 自己实现一个简易版本:

public class RedisBloomFilter { private final StringRedisTemplate redis; private final String key; private final long bitSize; // m private final int hashCount; // k public RedisBloomFilter(StringRedisTemplate redis, String key, long bitSize, int hashCount) { this.redis = redis; this.key = key; this.bitSize = bitSize; this.hashCount = hashCount; } public void add(String value) { long[] offsets = hashOffsets(value); for (long offset : offsets) { redis.opsForValue().setBit(key, offset, true); } } public boolean mightContain(String value) { long[] offsets = hashOffsets(value); for (long offset : offsets) { Boolean bit = redis.opsForValue().getBit(key, offset); if (!Boolean.TRUE.equals(bit)) { return false; } } return true; } private long[] hashOffsets(String value) { long[] result = new long[hashCount]; long h1 = MurmurHash.hash64(value); long h2 = MurmurHash.hash64(value + "#salt"); for (int i = 0; i < hashCount; i++) { long combined = h1 + i * h2; result[i] = Math.abs(combined % bitSize); } return result; } }

注意:布隆过滤器不支持删除元素。如果业务上会删除商品,位数组里的标记没法撤销,只能靠定期重建整个过滤器。重建的时候不要直接删旧 key,而是新建一个 key 逐步写入,写完之后用RENAME原子切换,避免切换期间出现真空期。

2.4 方案三:接口层的参数校验与限流兜底

前面两个方案都是在缓存层做防御,但最经济的手段其实是在入口就把非法请求挡掉。绝大多数穿透攻击的请求参数都是有规律的:ID 为负数、超出最大范围、格式不对、长度异常。这些校验放在 Controller 或者网关层,成本几乎为零。

@GetMapping("/product/{id}") public Result<Product> detail(@PathVariable Long id) { if (id == null || id <= 0 || id > 999_999_999L) { return Result.fail("参数非法"); } return Result.ok(productService.getById(id)); }

再往上一层是限流。同一个 IP 在一分钟内请求了几百个不同的不存在的 ID,这个行为特征非常明显,用 Redis 做一个简单的滑动窗口计数就能识别。

-- 滑动窗口限流:KEYS[1] 为限流键,ARGV[1] 为窗口秒数,ARGV[2] 为阈值 local key = KEYS[1] local window = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local now = tonumber(redis.call('time')[1]) redis.call('zremrangebyscore', key, 0, now - window) local count = redis.call('zcard', key) if count >= limit then return 0 end redis.call('zadd', key, now, now .. '-' .. math.random(100000)) redis.call('expire', key, window) return 1

网关层限流的好处是它不依赖业务逻辑,对所有接口都生效,而且能拦住那些根本没走正常业务流程的异常流量。我一般会在 Nginx 或网关配置一层粗粒度限流(比如单 IP 每秒 100 次),再在应用层做细粒度的业务限流。

2.5 穿透治理中容易踩的几个坑

第一个坑是布隆过滤器和数据库不同步。新增数据时忘了往过滤器里加,导致新数据一直被判定为不存在,用户查不到刚发布的商品,这是很严重的问题。我的做法是:把布隆过滤器的写入放在数据落库之后的事务提交回调里,或者用 Canal 监听 binlog 异步更新,保证最终一致。

第二个坑是空值缓存和布隆过滤器重复建设。两者选一个就够了,同时上会增加维护成本。我的选型习惯是:数据总量在千万级以内、删除操作不频繁的,用布隆过滤器;数据实时性要求高、增删频繁的,用空值缓存配合短 TTL。

第三个坑是误以为加了这些东西就万无一失。布隆过滤器有误判,空值缓存有 TTL 空窗,参数校验有绕过可能。所以数据库层的保护才是最后一道防线:连接池大小要设合理,慢查询要有超时熔断,绝不能让它被无限量的无效查询拖死。

3. 缓存击穿:单个热 key 过期的那一瞬间

3.1 击穿的触发条件与影响面评估

击穿的触发条件比穿透苛刻得多,需要同时满足三点:这个 key 的访问量足够大(比如每秒几千次)、它在某一刻过期了重建缓存的耗时足够长(比如数据库查询要 200 毫秒)。三者叠加,就会在 TTL 到期后的那 200 毫秒里,让所有并发请求同时涌向数据库。

量化一下就更清楚了。假设一个热点 key 每秒被请求 5000 次,重建需要 200 毫秒。过期瞬间,这 200 毫秒内到达的 1000 个请求全部落空,全部去查数据库。如果数据库单实例能扛 2000 QPS,那么这一瞬间的冲击虽然不一定直接压垮它,但会引起排队和响应变慢;如果这个热点 key 有十个八个同时到期,情况就危险了。

击穿的影响面是局部的、短时的,但破坏力集中。它不会像雪崩那样让整个系统瘫痪,却能让一个核心接口在几秒内完全不可用,进而拖累依赖它的上游服务。大促期间的商品详情、秒杀库存、活动配置,都是典型的高危 key。

3.2 互斥锁重建缓存:怎么写才不出事

最经典的解法是加锁,让只有一个线程去重建缓存,其他线程等待或直接返回旧值。核心是锁的粒度和超时时间,这两个参数错了,方案就废了。

private static final String EMPTY_FLAG = "__EMPTY__"; private final Random random = new Random(); public String getWithMutex(String key) { String cache = redis.opsForValue().get(key); if (cache != null) { return EMPTY_FLAG.equals(cache) ? null : cache; } String lockKey = "lock:" + key; String token = UUID.randomUUID().toString(); try { // 锁超时必须大于重建耗时,留出足够余量 Boolean locked = redis.opsForValue() .setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 双重检查:可能在等锁期间别人已经重建好了 cache = redis.opsForValue().get(key); if (cache != null) { return EMPTY_FLAG.equals(cache) ? null : cache; } String dbValue = loadFromDb(key); if (dbValue == null) { redis.opsForValue().set(key, EMPTY_FLAG, 60, TimeUnit.SECONDS); return null; } // TTL 加随机扰动,避免二次雪崩 int ttl = 1800 + random.nextInt(300); redis.opsForValue().set(key, dbValue, ttl, TimeUnit.SECONDS); return dbValue; } // 没抢到锁,短暂等待后重试读取缓存 Thread.sleep(50); cache = redis.opsForValue().get(key); return cache == null ? null : (EMPTY_FLAG.equals(cache) ? null : cache); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException("等待缓存重建被中断", e); } finally { releaseLock(lockKey, token); } }

释放锁必须校验 token,防止误删别人的锁:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

注意:锁超时时间一定要显著大于数据库查询的最坏耗时。我一般按 P99 耗时的 2 到 3 倍设。如果重建逻辑可能超过锁超时,就必须引入看门狗续期机制,否则锁提前释放会导致多个线程同时重建。

还有一个细节容易忽略:等待线程的重试次数要有上限。上面代码里等 50 毫秒读一次,如果重建失败一直没好,就会无限循环。生产代码里应该改成自旋固定次数(比如 3 次),超过就直接返回兜底数据或者抛出业务异常。

3.3 逻辑过期方案:牺牲一致性换吞吐

互斥锁的缺点是等待期间的线程被阻塞了,接口响应时间会拉长。如果你的场景对响应时间很敏感、对数据新鲜度要求没那么高,可以用逻辑过期:缓存里的值永不过期,但值本身带一个过期时间戳,读到过期的数据时先返回旧值,再异步触发重建。

public class RedisData { private Object data; private LocalDateTime expireTime; public boolean isExpired() { return expireTime != null && expireTime.isBefore(LocalDateTime.now()); } } private static final ExecutorService REBUILD_POOL = Executors.newFixedThreadPool(8, r -> { Thread t = new Thread(r, "cache-rebuild"); t.setDaemon(true); return t; }); public String getWithLogicalExpire(String key) { RedisData redisData = JSON.parseObject( redis.opsForValue().get(key), RedisData.class); if (redisData == null) { return null; } if (redisData.isExpired()) { REBUILD_POOL.submit(() -> { String lockKey = "lock:" + key; String token = UUID.randomUUID().toString(); Boolean locked = redis.opsForValue() .setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { String dbValue = loadFromDb(key); RedisData fresh = new RedisData(); fresh.setData(dbValue); fresh.setExpireTime(LocalDateTime.now().plusSeconds(1800)); redis.opsForValue().set(key, JSON.toJSONString(fresh)); } finally { releaseLock(lockKey, token); } } }); } return (String) redisData.getData(); }

数据写入缓存时不再设 TTL,而是把过期时间写进 JSON 体内:

RedisData redisData = new RedisData(); redisData.setData(dbValue); redisData.setExpireTime(LocalDateTime.now().plusSeconds(1800)); redis.opsForValue().set(key, JSON.toJSONString(redisData));

这个方案的优点是所有请求都不会被阻塞,最坏情况返回一份稍旧的数据,保证了高吞吐和低延迟。代价是数据一致性被弱化了,会有短暂的脏读窗口。所以它只适合那些「数据晚几秒更新无所谓」的场景,比如商品浏览量、排行榜、非核心的展示类信息。订单状态、库存数量这种绝对不能用在逻辑过期上。

3.4 热点 key 永不过期加上后台刷新

比逻辑过期更彻底的做法是物理上不设 TTL,改由后台定时任务周期性刷新。这样从 Redis 的视角看,这个 key 永远不会失效,也就不存在击穿的瞬间。

实现上可以用一个调度任务,每隔一段时间(比如 TTL 的 1/3)主动去更新热点数据。哪些 key 算热点?可以从 Redis 的访问统计里筛,或者干脆在业务代码里维护一份热点 ID 白名单,大促前手动配置。

@Scheduled(fixedRate = 300_000) // 每 5 分钟刷一次 public void refreshHotKeys() { Set<String> hotIds = hotKeyConfig.getHotProductIds(); for (String id : hotIds) { try { Product product = productMapper.selectById(Long.valueOf(id)); if (product == null) { continue; } String key = "product:detail:" + id; // 不设过期时间 redis.opsForValue().set(key, JSON.toJSONString(product)); } catch (Exception e) { log.error("刷新热点 key 失败, id={}", id, e); } } }

这个方案的好处是简单粗暴、效果稳定,坏处是热点数据要靠人工维护,而且不设 TTL 意味着一旦业务下线或者数据变更逻辑出问题,脏数据会长期留在缓存里。所以我一般会再加一个兜底:给这些 key 设一个很长的 TTL(比如 24 小时)并配合监控告警,一旦发现它真的过期了,说明刷新任务出问题了,能第一时间报警。

3.5 三种击穿方案的选型对照

方案一致性响应延迟实现复杂度适用场景
互斥锁重建强,读到的一定是最新数据等待线程被阻塞,P99 会抬升中等,要处理锁超时和死锁订单、库存等对一致性敏感的接口
逻辑过期弱,允许短暂脏读低,不阻塞任何请求中等,需要异步线程池商品展示、排行榜、浏览量
永不过期 + 后台刷新取决于刷新周期最低低,但需要维护热点清单大促活动配置、固定热点数据

我的实际选择顺序是:先看数据一致性要求,强一致就用互斥锁;能接受秒级延迟就用逻辑过期;热点明确且数量可控就用后台刷新。三者并不互斥,我手上一个项目就是核心接口用互斥锁,展示类接口用逻辑过期,活动配置用后台刷新,按接口分层治理。

4. 缓存雪崩:大面积同时失效

4.1 雪崩的三种典型成因拆解

雪崩的成因比前两个更杂,我把它归成三类。

第一类是TTL 集中过期。最常见的情况是系统在凌晨跑一次全量预热,把所有商品缓存统一设成 30 分钟过期。那么到了凌晨 0:30,所有 key 一起失效,请求全部落到数据库。这种问题的隐蔽性在于,它只在特定时间点爆发,平时看起来一切正常,而且很容易被误判为「数据库定时任务的锅」。

第二类是Redis 实例本身出问题。主节点宕机、主从切换耗时过长、网络分区、内存打满触发淘汰策略疯狂 evict,都会导致大批 key 同时不可用。这类雪崩的破坏力最大,因为它不分热点冷门,所有缓存一起失效。

第三类是依赖链路的连锁反应。比如 Redis 所在机器带宽被打满,或者应用侧的连接池被耗尽,导致即使 Redis 还活着,应用也拿不到数据。这种情况下,缓存实际上已经失效了。

4.2 TTL 打散:最便宜也最有效的第一道防线

解决 TTL 集中过期,核心思路是把过期时间打散。不要用固定的 1800 秒,而是加上一个随机扰动:

private static final Random RANDOM = new Random(); public void setWithJitter(String key, String value, int baseSeconds) { // 在基础 TTL 上叠加 0 到 300 秒的随机值 int jitter = RANDOM.nextInt(300); redis.opsForValue().set(key, value, baseSeconds + jitter, TimeUnit.SECONDS); }

随机范围取多大?我一般是基础 TTL 的 10% 到 20%。基础 TTL 是 30 分钟的,随机 3 到 6 分钟就够;基础 TTL 是 1 小时的,随机 6 到 12 分钟。这个扰动足以把同一个时间点的失效压力摊平到几分钟的窗口里,对数据库来说就是可以轻松承受的平滑负载。

如果是批量预热写入,还有一个更彻底的写法:按数据 ID 做哈希取模来分配 TTL。比如ttl = base + (id.hashCode() % 600),这样同一批数据天然分布在不同时间点过期,而且每次重新加载得到的结果是确定的,排查问题时更容易复现。

注意:加随机扰动会让缓存的实际过期时间变得不确定,如果业务上有「缓存必须在写入后 30 分钟内失效」的硬性要求,就不能这么做,得改用其他方案。大多数场景下这个扰动是可以接受的。

4.3 多级缓存与集群高可用:架构层面的抗雪崩

TTL 打散只能防住第一类成因,实例级别的故障必须靠架构来解决。

多级缓存的思路是在 Redis 前面加一层进程内的本地缓存,比如 Caffeine 或 Guava Cache。热点数据先从本地内存取,取不到再走 Redis,Redis 也没有才查数据库。这样即使 Redis 整体不可用,本地缓存仍然能挡住相当一部分请求。

private final Cache<String, String> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.SECONDS) .build(); public String getWithMultiLevel(String key) { // 第一级:本地缓存 String value = localCache.getIfPresent(key); if (value != null) { return value; } // 第二级:Redis value = redis.opsForValue().get(key); if (value != null) { localCache.put(key, value); return value; } // 第三级:数据库 value = loadFromDb(key); if (value != null) { redis.opsForValue().set(key, value, 1800 + RANDOM.nextInt(300), TimeUnit.SECONDS); localCache.put(key, value); } return value; }

本地缓存的 TTL 要设得很短,比如 3 到 5 秒。设长了会导致多实例之间的数据不一致,运维扩缩容时也更难处理。它的定位是「抗住 Redis 抖动的那几秒」,不是替代 Redis。

集群高可用方面,生产环境我建议用 Sentinel 或者 Cluster 模式,避免单点。但要注意,主从切换本身是需要时间的,通常几秒到几十秒,这段时间内写入会失败。所以应用侧必须做好降级,不能傻等。

配置上,连接池参数也是关键:

spring: redis: timeout: 2000ms lettuce: pool: max-active: 64 max-idle: 16 min-idle: 8 max-wait: 500ms cluster: nodes: - 10.0.0.1:6379 - 10.0.0.2:6379 - 10.0.0.3:6379 max-redirects: 3

timeout设 2000 毫秒是我的常用值,太短会把正常慢查询误判为故障,太长会让线程池在故障期间被迅速打满。max-wait设 500 毫秒,拿不到连接直接失败返回兜底数据,比无限等待要好。

4.4 熔断降级与限流:保护数据库的最后一道闸

无论前面做得多好,都要假设 Redis 会挂。这时候唯一的目标就是别让数据库跟着一起挂。手段就是熔断和降级。

熔断的逻辑是:当 Redis 的失败率在统计窗口内超过阈值,比如 10 秒内失败率超过 50%,就打开断路器,后续请求直接走降级逻辑,不再尝试访问 Redis,等一段时间后再放少量流量试探。用 Resilience4j 或者 Sentinel 都能实现,也可以自己用滑动窗口计数写一个简易版。

降级逻辑则要看业务。常见的处理有三种:返回本地缓存里的旧数据、返回默认值或空对象、直接抛业务异常让上游处理。我的习惯是按接口的重要性分级:核心下单链路宁可失败也不返回错误数据,展示类接口返回缓存快照或者兜底文案。

限流放在最外层,控制进入系统的总请求量,让数据库永远工作在它能力范围之内。阈值怎么定?先压测出数据库单机能稳定承受的 QPS,然后取 70% 作为限流阈值,留出余量。限流算法用令牌桶或者滑动窗口都可以,关键是阈值要能动态调整,大促前可以临时放宽,故障时能一键收紧。

4.5 缓存预热与容量评估

预热是个容易被当成「锦上添花」但实际很关键的环节。服务刚启动时缓存是空的,如果直接把流量放进来,等于瞬间制造一次雪崩。所以启动阶段要做两件事:先加载热点数据,再逐步接入流量

预热任务的写法很简单,从数据库里捞出访问频率最高的那批 ID,批量查询后写入 Redis。注意要用 Pipeline 批量写,别一条条 SET,否则光是预热就能把 Redis 的 QPS 打满。

public void warmUp(List<Long> hotIds) { List<Product> products = productMapper.selectBatchIds(hotIds); Map<String, String> batch = new HashMap<>(products.size()); for (Product p : products) { batch.put("product:detail:" + p.getId(), JSON.toJSONString(p)); } redis.executePipelined((RedisCallback<Object>) connection -> { batch.forEach((k, v) -> { byte[] rawKey = k.getBytes(StandardCharsets.UTF_8); byte[] rawValue = v.getBytes(StandardCharsets.UTF_8); connection.setEx(rawKey, 1800 + RANDOM.nextInt(300), rawValue); }); return null; }); }

容量评估方面,我习惯按这个顺序算:热点数据总量 × 单条平均大小 × 1.5 的安全系数。比如 50 万个商品,单条 JSON 平均 2 KB,那就是 50 万 × 2 KB × 1.5 ≈ 1.5 GB。再考虑主从复制的内存开销和 RDB 持久化时的 copy-on-write,实际给 Redis 分配的内存应该是这个数字的两倍以上。maxmemory一定要显式设置,并且选好淘汰策略——纯缓存场景用allkeys-lruallkeys-lfu,不要用默认的noeviction,否则内存打满后会直接报错而不是淘汰旧数据。

5. 常见问题与排查技巧实录

5.1 线上问题速查表

我把这些年处理过的典型现象整理成了一张表,出问题时可以对着找。

现象可能原因快速验证方式处置动作
Redis 命中率骤降,CPU 不高大量请求查询不存在的 keyinfo stats看 hits/misses 比值上线空值缓存或布隆过滤器,网关加限流
数据库出现周期性尖刺批量 key 在同一时刻过期看数据库 QPS 曲线是否规律给 TTL 加随机扰动,错开预热时间
单个接口响应时间飙高热点 key 过期,线程都在等锁看接口 P99 和应用线程池状态检查锁超时设置,改用逻辑过期
Redis 报 OOM 或大量 evict内存不足,淘汰策略触发info memory看 used_memory 和 evicted_keys扩容,调整 maxmemory-policy
连接超时大量出现Redis 实例负载高或网络抖动info clients看连接数,slowlog看慢命令检查大 key,优化慢命令,开启熔断
主从切换期间大量报错Sentinel 切换耗时看 Sentry 日志和切换时间戳应用侧加重试和降级,缩短切换时间

5.2 排查时真正管用的几条命令

首先是命中率:

redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"

命中率 = hits / (hits + misses)。低于 0.8 就要警惕了。但这个数字是累计值,重启后会清零,所以最好是接监控系统按分钟采集,看趋势而不是看单点。

其次是找出大 key,大 key 会拖慢网络传输和持久化:

redis-cli --bigkeys redis-cli memory usage product:detail:10086

--bigkeys会扫描全库,生产环境建议在从节点执行,或者用SCAN自己遍历,避免阻塞主节点。

然后是热点 key。这个需要maxmemory-policy设置为allkeys-lfu才能用:

redis-cli --hotkeys

最后是慢日志:

redis-cli slowlog get 10 redis-cli config set slowlog-log-slower-than 10000

阈值的单位是微秒,10000 就是 10 毫秒。我一般会临时调到 5 毫秒定位问题,排查完再调回去,避免慢日志堆积占用内存。

注意:MONITOR命令会打印所有执行的命令,在高并发下对性能影响极大,绝不要在生产的线上实例上长时间执行。我在紧急排查时最多开几秒钟,抓完样本立刻关掉。

5.3 一些不太好写进文档的经验

第一,别指望一个方案解决所有问题。我见过太多团队上了布隆过滤器就以为穿透问题解决了,结果热点 key 过期导致击穿;上了互斥锁就以为稳了,结果 Redis 实例挂掉直接雪崩。这三类问题是不同维度上的,必须组合防御。

第二,监控比方案更重要。缓存治理做得好不好,很大程度上取决于你能不能第一时间发现问题。我现在负责的项目里有几个必备的告警:Redis 命中率低于 80% 持续 5 分钟、数据库 QPS 环比上涨 3 倍、接口 P99 超过基线 2 倍、Redis 连接数超过 80%。这几条覆盖了绝大多数场景。

第三,压测才能验证方案是否真的有效。加了随机 TTL 到底有没有用?互斥锁会不会成为瓶颈?这些问题的答案只能靠压测给出。我一般会用 JMeter 或者 wrk 模拟高并发场景,专门测三个场景:大量不存在 key 的请求、热点 key 集中过期的瞬间、Redis 实例被手动下线。第三个场景最能暴露问题,建议每个季度演练一次。

第四,降级预案要提前写好并演练。很多团队写了降级开关,但从没真正打开过,等到真出事的时候发现开关是坏的。我现在的做法是把降级开关纳入日常演练流程,每个月在预发环境手动触发一次,确认降级逻辑真的能生效、返回的数据真的可用。

6. 一套可以直接抄的缓存治理清单

6.1 接入阶段该做的检查

新接入一个缓存场景时,我一般会走一遍这个清单:

  1. 明确数据特征:数据总量多少?增量速度多快?是否会被删除?这决定了用布隆过滤器还是空值缓存。
  2. 明确一致性要求:能接受多少秒的脏读?这决定了用互斥锁还是逻辑过期。
  3. 确定 TTL:基础 TTL 设多少?随机扰动范围设多少?热点数据是否要单独配置。
  4. 设计 key 规范:统一前缀、统一分隔符、避免大 key。我的习惯是业务:模块:标识:字段,比如product:detail:10086
  5. 估算内存:按上面的公式算一遍,maxmemory显式配置,淘汰策略选好。
  6. 写好降级逻辑:Redis 挂了返回什么?数据库压力大了怎么办?
  7. 加监控埋点:命中率、响应时间、错误率,一个都不能少。

6.2 运行阶段持续关注的指标

服务上线之后,有几个指标我会持续盯着:缓存命中率(健康值通常在 90% 以上)、平均响应时间(P99 是关键)、Redis 内存使用率和淘汰次数evicted_keys持续上涨说明内存不够了)、数据库 QPS 与连接数(这是最终的兜底指标,一旦异常就是真出事了)。这些指标我建议做成一块大屏,值班同学抬头就能看到,不必每次都去查日志。

另外,定期巡检也很必要。每周跑一次--bigkeys--hotkeys,把大 key 拆掉、把热点 key 记下来;每月复盘一次慢日志,看看有没有新出现的慢命令。这些工作看起来琐碎,但能避免大部分的突发故障。

6.3 真出事时的处置顺序

最后说一下故障处置的顺序,这个顺序我踩过坑才总结出来。发现数据库压力异常时,不要先去查 Redis 的配置,第一步应该是看应用的错误日志和接口 P99,确认是不是缓存层的问题;确认之后,第二步看命中率曲线,判断属于哪一类故障;第三步才是针对性处置——穿透加限流、击穿开降级、雪崩先把读请求切到本地缓存。

处置过程中有两条铁律。一是先止血再找根因,别在现场翻代码,先把限流阈值调低、把降级开关打开,让服务恢复到可用状态,再慢慢定位。二是所有临时操作都要记录,我见过因为临时把 TTL 改成永久,结果三个月后没人记得,缓存里的脏数据一直没更新。现场处置的每一步操作、原因、恢复时间,都要记进故障文档,事后复盘的时候才不会漏。

我在实际项目里最深的一个体会是:缓存治理这件事,七成的功夫在设计和监控,三成在写代码。方案本身都不复杂,难的是持续关注、及时发现问题、并且有勇气在关键时刻按下降级开关。很多故障之所以演变成事故,不是因为技术方案不对,而是因为没人敢拍板做那个「牺牲一部分功能保住核心链路」的决定。所以预案一定要提前写好、演练过、并且明确谁有权触发,真到那一刻才不会犹豫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询