尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis缓存穿透、击穿、雪崩:区别、排查与治理实战

Redis缓存穿透、击穿、雪崩:区别、排查与治理实战 凌晨两点被电话叫醒打开监控一看 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_hits和keyspace_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 ResultProduct 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() { SetString 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。热点数据先从本地内存取取不到再走 RedisRedis 也没有才查数据库。这样即使 Redis 整体不可用本地缓存仍然能挡住相当一部分请求。private final CacheString, 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: 3timeout设 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(ListLong hotIds) { ListProduct products productMapper.selectBatchIds(hotIds); MapString, String batch new HashMap(products.size()); for (Product p : products) { batch.put(product:detail: p.getId(), JSON.toJSONString(p)); } redis.executePipelined((RedisCallbackObject) 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-lru或allkeys-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 接入阶段该做的检查新接入一个缓存场景时我一般会走一遍这个清单明确数据特征数据总量多少增量速度多快是否会被删除这决定了用布隆过滤器还是空值缓存。明确一致性要求能接受多少秒的脏读这决定了用互斥锁还是逻辑过期。确定 TTL基础 TTL 设多少随机扰动范围设多少热点数据是否要单独配置。设计 key 规范统一前缀、统一分隔符、避免大 key。我的习惯是业务:模块:标识:字段比如product:detail:10086。估算内存按上面的公式算一遍maxmemory显式配置淘汰策略选好。写好降级逻辑Redis 挂了返回什么数据库压力大了怎么办加监控埋点命中率、响应时间、错误率一个都不能少。6.2 运行阶段持续关注的指标服务上线之后有几个指标我会持续盯着缓存命中率健康值通常在 90% 以上、平均响应时间P99 是关键、Redis 内存使用率和淘汰次数evicted_keys持续上涨说明内存不够了、数据库 QPS 与连接数这是最终的兜底指标一旦异常就是真出事了。这些指标我建议做成一块大屏值班同学抬头就能看到不必每次都去查日志。另外定期巡检也很必要。每周跑一次--bigkeys和--hotkeys把大 key 拆掉、把热点 key 记下来每月复盘一次慢日志看看有没有新出现的慢命令。这些工作看起来琐碎但能避免大部分的突发故障。6.3 真出事时的处置顺序最后说一下故障处置的顺序这个顺序我踩过坑才总结出来。发现数据库压力异常时不要先去查 Redis 的配置第一步应该是看应用的错误日志和接口 P99确认是不是缓存层的问题确认之后第二步看命中率曲线判断属于哪一类故障第三步才是针对性处置——穿透加限流、击穿开降级、雪崩先把读请求切到本地缓存。处置过程中有两条铁律。一是先止血再找根因别在现场翻代码先把限流阈值调低、把降级开关打开让服务恢复到可用状态再慢慢定位。二是所有临时操作都要记录我见过因为临时把 TTL 改成永久结果三个月后没人记得缓存里的脏数据一直没更新。现场处置的每一步操作、原因、恢复时间都要记进故障文档事后复盘的时候才不会漏。我在实际项目里最深的一个体会是缓存治理这件事七成的功夫在设计和监控三成在写代码。方案本身都不复杂难的是持续关注、及时发现问题、并且有勇气在关键时刻按下降级开关。很多故障之所以演变成事故不是因为技术方案不对而是因为没人敢拍板做那个「牺牲一部分功能保住核心链路」的决定。所以预案一定要提前写好、演练过、并且明确谁有权触发真到那一刻才不会犹豫。
返回列表