Redis 缓存穿透、击穿、雪崩实战:基于 MySQL 高并发读场景的完整防护方案 先抛一个真实场景:某电商大促期间,商品详情页 QPS 从日常 200 飙到 8000,MySQL 单机 CPU 瞬间打满 100%,大量请求超时。查看慢查询日志,发现全是同一条 SELECT * FROM product WHERE id = ?,而这个 id 对应的商品其实早已下架,缓存里没有,数据库里也没有——但请求还在源源不断地打进来。
这就是典型的缓存穿透。而接下来半小时内,另一个热点商品突然过期,瞬时几千个请求同时回源 MySQL,把刚喘过气的数据库又打挂了——这是缓存击穿。紧接着,运维触发了一次缓存批量过期,导致大量 key 同时失效,缓存层形同虚设,MySQL 再次宕机——这是缓存雪崩。
三个问题,一个比一个致命。本文从真实高并发读场景出发,逐一剖析成因、给出可落地的 Java 代码方案,并用压测数据说话。
一、先理解:为什么缓存层会失守 在引入 Redis 之前,高并发读 MySQL 的典型架构是这样的:
所有请求直达数据库,MySQL 单机 QPS 撑死几千,大促秒杀场景下根本扛不住。
引入 Redis 后:
1 [App] --> [Redis] --> [MySQL]
正常流程:请求先查 Redis,命中直接返回;未命中则查 MySQL,回填 Redis,再返回。
但“未命中”有三种截然不同的情况,对应了三个经典问题:
问题
未命中的本质
危害
缓存穿透
请求的数据根本不存在 于数据库
大量无效查询击穿缓存层,直接压垮 MySQL
缓存击穿
热点 key 恰好过期
瞬时大量并发回源 MySQL,形成“惊群效应”
缓存雪崩
大量 key 同时过期 或 Redis 宕机
缓存层整体失效,全部流量压向 MySQL
下面逐一拆解,每个都给出完整可运行 的代码。
二、缓存穿透:请求了不存在的数据 2.1 成因 用户请求的数据既不在缓存中,也不在数据库中,典型如:
恶意攻击者用不存在的商品 id 发起大量请求(如 id=-1)
爬虫抓取已删除的商品
业务代码 bug 导致查询了错误的数据
每次请求都会穿过缓存直达 MySQL,缓存形同虚设。
2.2 方案一:缓存空值(简单直接) 原理 :当 MySQL 也查不到数据时,在 Redis 中缓存一个空值标识,下次请求直接命中缓存返回空,不再回源 MySQL。
适用场景 :数据量不大、数据一致性要求不极端的场景。
完整代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 import redis.clients.jedis.Jedis;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPoolConfig;import java.sql.Connection;import java.sql.DriverManager;import java.sql.PreparedStatement;import java.sql.ResultSet;public class CachePenetrationNullValue { private static final JedisPool jedisPool = new JedisPool (new JedisPoolConfig (), "127.0.0.1" , 6379 ); private static final String MYSQL_URL = "jdbc:mysql://127.0.0.1:3306/shop?useSSL=false&serverTimezone=UTC" ; private static final String MYSQL_USER = "root" ; private static final String MYSQL_PASSWORD = "root123" ; private static final String NULL_PLACEHOLDER = "__NULL__" ; private static final int NULL_EXPIRE_SECONDS = 60 ; public String getProductById (long id) { String cacheKey = "product:" + id; try (Jedis jedis = jedisPool.getResource()) { String cached = jedis.get(cacheKey); if (cached != null ) { if (NULL_PLACEHOLDER.equals(cached)) { System.out.println("[缓存穿透防护] id=" + id + " 命中空值缓存,直接返回 null" ); return null ; } System.out.println("[缓存穿透防护] id=" + id + " 命中正常缓存" ); return cached; } String product = queryMySQL(id); if (product != null ) { jedis.setex(cacheKey, 600 , product); } else { jedis.setex(cacheKey, NULL_EXPIRE_SECONDS, NULL_PLACEHOLDER); System.out.println("[缓存穿透防护] id=" + id + " 数据库不存在,已缓存空值" ); } return product; } catch (Exception e) { throw new RuntimeException ("缓存操作异常" , e); } } private String queryMySQL (long id) { String sql = "SELECT name FROM product WHERE id = ? AND deleted = 0" ; try (Connection conn = DriverManager.getConnection(MYSQL_URL, MYSQL_USER, MYSQL_PASSWORD); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1 , id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getString("name" ); } return null ; } } catch (Exception e) { throw new RuntimeException ("MySQL 查询异常" , e); } } public static void main (String[] args) { CachePenetrationNullValue demo = new CachePenetrationNullValue (); System.out.println("第一次查询不存在 id=9999: " + demo.getProductById(9999 )); System.out.println("第二次查询不存在 id=9999: " + demo.getProductById(9999 )); System.out.println("第一次查询存在 id=100: " + demo.getProductById(100 )); } }
运行输出示例 :
1 2 3 4 5 6 [缓存穿透防护] id=9999 数据库不存在,已缓存空值 第一次查询不存在 id=9999: null [缓存穿透防护] id=9999 命中空值缓存,直接返回 null 第二次查询不存在 id=9999: null [缓存穿透防护] id=100 命中正常缓存 第一次查询存在 id=100: iPhone 15 Pro Max
2.3 方案二:布隆过滤器(RedisBloom) 原理 :在缓存之前加一层布隆过滤器,先判断数据是否可能存在。布隆过滤器说“不存在”则一定不存在 ,直接拦截,无需查缓存和数据库;说“存在”则只是可能存在 ,继续走正常流程。
适用场景 :需要拦截大量无效请求、且能容忍极低误判率的场景。
架构图 :
1 2 3 [App] --> [BloomFilter] --> (可能存在) --> [Redis] --> (未命中) --> [MySQL] | +--> (一定不存在) --> 直接返回 null
完整代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 import redis.clients.jedis.Jedis;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPoolConfig;public class CachePenetrationBloomFilter { private static final JedisPool jedisPool = new JedisPool (new JedisPoolConfig (), "127.0.0.1" , 6379 ); private static final String BLOOM_KEY = "product:bloom" ; static { try (Jedis jedis = jedisPool.getResource()) { jedis.del(BLOOM_KEY); jedis.sendCommand( redis.clients.jedis.Protocol.Command.valueOf("BF.RESERVE" ), BLOOM_KEY, "0.01" , "100000" ); } } public String getProductById (long id) { String cacheKey = "product:" + id; try (Jedis jedis = jedisPool.getResource()) { Object bloomResult = jedis.sendCommand( redis.clients.jedis.Protocol.Command.valueOf("BF.EXISTS" ), BLOOM_KEY, String.valueOf(id) ); if (bloomResult != null && (Long) bloomResult == 0L ) { System.out.println("[布隆过滤器] id=" + id + " 判定不存在,直接拦截" ); return null ; } String cached = jedis.get(cacheKey); if (cached != null ) { return cached; } String product = queryMySQL(id); if (product != null ) { jedis.setex(cacheKey, 600 , product); } return product; } catch (Exception e) { throw new RuntimeException ("缓存操作异常" , e); } } public void addProductToBloom (long id) { try (Jedis jedis = jedisPool.getResource()) { jedis.sendCommand( redis.clients.jedis.Protocol.Command.valueOf("BF.ADD" ), BLOOM_KEY, String.valueOf(id) ); System.out.println("[布隆过滤器] 新商品 id=" + id + " 已加入" ); } } private String queryMySQL (long id) { return (id == 100 ) ? "iPhone 15 Pro Max" : null ; } public static void main (String[] args) { CachePenetrationBloomFilter demo = new CachePenetrationBloomFilter (); demo.addProductToBloom(100 ); demo.addProductToBloom(101 ); System.out.println("查询 id=9999: " + demo.getProductById(9999 )); System.out.println("查询 id=100: " + demo.getProductById(100 )); } }
运行输出示例 :
1 2 3 4 5 [布隆过滤器] 新商品 id=100 已加入 [布隆过滤器] 新商品 id=101 已加入 [布隆过滤器] id=9999 判定不存在,直接拦截 查询 id=9999: null 查询 id=100: iPhone 15 Pro Max
生产落地建议 :布隆过滤器初始化时需要全量加载数据库中的有效 id。可以在数据变更时通过 Canal 监听 MySQL binlog 增量同步,避免数据不一致。同时,布隆过滤器的误判率(如 0.01)意味着有约 1% 的请求会“漏网”穿过到数据库,这个概率可以接受,因为同时结合空值缓存可以进一步拦截。
三、缓存击穿:热点 key 恰好过期 3.1 成因 某个热点商品的缓存 key 在某一瞬间过期,恰好此时海量并发请求同时涌入,所有请求都发现缓存未命中,于是同时回源 MySQL 查询,形成“惊群效应”。
场景 :大促首页的热门商品,缓存过期时间设置较短(如 60 秒),过期瞬间 QPS 高达 5000,MySQL 承受 5000 次相同查询。
3.2 方案一:互斥锁(Mutex) 原理 :当缓存未命中时,只允许一个请求去 MySQL 查询并回填缓存,其他请求自旋等待(或短暂休眠)后重试获取。
架构图 :
1 2 3 4 5 6 7 8 9 10 11 12 13 请求进来 | v 缓存命中? --是--> 返回 | 否 | v 尝试获取互斥锁(SETNX) | ├--成功--> 查 MySQL --> 回填缓存 --> 释放锁 --> 返回 | └--失败--> 休眠 50ms --> 重试查缓存(循环)
完整代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 import redis.clients.jedis.Jedis;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPoolConfig;public class CacheBreakdownMutex { private static final JedisPool jedisPool = new JedisPool (new JedisPoolConfig (), "127.0.0.1" , 6379 ); private static final String LOCK_KEY_PREFIX = "lock:product:" ; private static final long LOCK_EXPIRE_MS = 10_000L ; private static final long RETRY_INTERVAL_MS = 50L ; private static final int MAX_RETRY_TIMES = 40 ; public String getProductById (long id) { String cacheKey = "product:" + id; String lockKey = LOCK_KEY_PREFIX + id; for (int i = 0 ; i < MAX_RETRY_TIMES; i++) { try (Jedis jedis = jedisPool.getResource()) { String cached = jedis.get(cacheKey); if (cached != null ) { return cached; } String result = jedis.set(lockKey, "1" , redis.clients.jedis.params.SetParams.setParams() .nx() .px(LOCK_EXPIRE_MS)); if ("OK" .equals(result)) { System.out.println("[互斥锁] 线程 " + Thread.currentThread().getName() + " 获取锁,查询 MySQL" ); try { String product = queryMySQL(id); if (product != null ) { jedis.setex(cacheKey, 600 , product); } return product; } finally { String releaseScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else return 0 end" ; jedis.eval(releaseScript, java.util.Collections.singletonList(lockKey), java.util.Collections.singletonList("1" )); System.out.println("[互斥锁] 线程 " + Thread.currentThread().getName() + " 释放锁" ); } } else { System.out.println("[互斥锁] 线程 " + Thread.currentThread().getName() + " 未获取锁,休眠重试" ); try { Thread.sleep(RETRY_INTERVAL_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } catch (Exception e) { throw new RuntimeException ("缓存操作异常" , e); } } System.out.println("[互斥锁] 重试超时,返回 null" ); return null ; } private String queryMySQL (long id) { try { Thread.sleep(300 ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "Product-" + id; } public static void main (String[] args) throws InterruptedException { CacheBreakdownMutex demo = new CacheBreakdownMutex (); Thread[] threads = new Thread [20 ]; for (int i = 0 ; i < 20 ; i++) { threads[i] = new Thread (() -> { String result = demo.getProductById(100 ); System.out.println("最终结果: " + result); }, "Thread-" + i); } for (Thread t : threads) t.start(); for (Thread t : threads) t.join(); } }
关键运行输出 (节选):
1 2 3 4 5 6 7 [互斥锁] 线程 Thread-3 获取锁,查询 MySQL [互斥锁] 线程 Thread-0 未获取锁,休眠重试 [互斥锁] 线程 Thread-7 未获取锁,休眠重试 ... [互斥锁] 线程 Thread-3 释放锁 [互斥锁] 线程 Thread-0 未获取锁,休眠重试 [互斥锁] 线程 Thread-7 未获取锁,休眠重试
可以看到,只有 1 个线程真正查了 MySQL,其余 19 个线程在自旋等待后直接从缓存拿到了数据。
3.3 方案二:逻辑过期(不物理过期) 原理 :热点 key 不设置物理过期时间 ,让缓存永不过期;在 value 中额外存一个“逻辑过期时间”。请求发现逻辑过期后,异步更新缓存(可以由后台线程或获取锁的线程去更新),其他线程仍然先返回旧数据。
适用场景 :对数据一致性容忍度较高、追求极致可用性的场景(如商品浏览量、排行榜)。
架构图 :
1 2 3 4 5 6 7 8 9 10 11 12 ## 请求进来 缓存命中? | ├--是--> 检查逻辑过期时间 | | | ├--未过期--> 直接返回新数据 | └--已过期--> 尝试获取互斥锁 | ├--成功--> 异步更新缓存(不阻塞当前请求),当前请求返回旧数据 | └--失败--> 直接返回旧数据 | └--否--> 互斥锁方案
完整代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 import redis.clients.jedis.Jedis;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPoolConfig;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;public class CacheBreakdownLogicalExpire { private static final JedisPool jedisPool = new JedisPool (new JedisPoolConfig (), "127.0.0.1" , 6379 ); private static final String CACHE_KEY = "product:hot:" ; private static final String LOCK_KEY_PREFIX = "lock:product:" ; private static final long LOCK_EXPIRE_MS = 5_000L ; private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss" ); public static class ProductCacheData { public String productName; public String logicalExpireTime; public ProductCacheData (String productName, String logicalExpireTime) { this .productName = productName; this .logicalExpireTime = logicalExpireTime; } } public String getProductById (long id) { String cacheKey = CACHE_KEY + id; String lockKey = LOCK_KEY_PREFIX + id; try (Jedis jedis = jedisPool.getResource()) { String cachedJson = jedis.get(cacheKey); if (cachedJson == null ) { return queryWithMutex(id); } ProductCacheData cacheData = parseJson(cachedJson); LocalDateTime expireTime = LocalDateTime.parse(cacheData.logicalExpireTime, FORMATTER); if (LocalDateTime.now().isBefore(expireTime)) { return cacheData.productName; } String result = jedis.set(lockKey, "1" , redis.clients.jedis.params.SetParams.setParams().nx().px(LOCK_EXPIRE_MS)); if ("OK" .equals(result)) { System.out.println("[逻辑过期] 线程 " + Thread.currentThread().getName() + " 获取锁,异步更新缓存" ); new Thread (() -> updateProductCache(id)).start(); } return cacheData.productName; } catch (Exception e) { throw new RuntimeException ("缓存操作异常" , e); } } private String queryWithMutex (long id) { String cacheKey = CACHE_KEY + id; String lockKey = LOCK_KEY_PREFIX + id; try (Jedis jedis = jedisPool.getResource()) { String result = jedis.set(lockKey, "1" , redis.clients.jedis.params.SetParams.setParams().nx().px(LOCK_EXPIRE_MS)); if ("OK" .equals(result)) { try { String product = queryMySQL(id); ProductCacheData cacheData = new ProductCacheData (product, LocalDateTime.now().plusMinutes(10 ).format(FORMATTER)); jedis.set(cacheKey, toJson(cacheData)); return product; } finally { jedis.del(lockKey); } } else { try { Thread.sleep(50 ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductById(id); } } } private void updateProductCache (long id) { String product = queryMySQL(id); try (Jedis jedis = jedisPool.getResource()) { String cacheKey = CACHE_KEY + id; String lockKey = LOCK_KEY_PREFIX + id; ProductCacheData cacheData = new ProductCacheData (product, LocalDateTime.now().plusMinutes(10 ).format(FORMATTER)); jedis.set(cacheKey, toJson(cacheData)); jedis.del(lockKey); System.out.println("[逻辑过期] 缓存更新完成" ); } } private String queryMySQL (long id) { try { Thread.sleep(300 ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "HotProduct-" + id + "-" + System.currentTimeMillis(); } private ProductCacheData parseJson (String json) { return new ProductCacheData (json, LocalDateTime.now().minusMinutes(1 ).format(FORMATTER)); } private String toJson (ProductCacheData data) { return data.productName; } public static void main (String[] args) { CacheBreakdownLogicalExpire demo = new CacheBreakdownLogicalExpire (); for (int i = 0 ; i < 10 ; i++) { new Thread (() -> { System.out.println("结果: " + demo.getProductById(100 )); }, "Thread-" + i).start(); } } }
四、缓存雪崩:大量 key 同时失效 4.1 成因 缓存雪崩的触发场景有两种:
大量 key 同时过期 :如批量缓存数据时设置了相同的过期时间,到点后大量请求同时回源 MySQL
Redis 宕机或网络异常 :缓存层整体不可用,所有请求直接打到 MySQL
4.2 方案一:过期时间加随机偏移 原理 :在设置过期时间时,给每个 key 的基础 TTL 加上一个随机值(如 ±30%),避免大量 key 同时过期。
完整代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import redis.clients.jedis.Jedis;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPoolConfig;import java.util.Random;public class CacheAvalancheRandomTTL { private static final JedisPool jedisPool = new JedisPool (new JedisPoolConfig (), "127.0.0.1" , 6379 ); private static final int BASE_TTL_SECONDS = 600 ; private static final int RANDOM_RANGE_SECONDS = 180 ; private static final Random random = new Random (); public void cacheProduct (long id, String productName) { String cacheKey = "product:" + id; int ttl = BASE_TTL_SECONDS + random.nextInt(RANDOM_RANGE_SECONDS * 2 + 1 ) - RANDOM_RANGE_SECONDS; ttl = Math.max(ttl, 1 ); try (Jedis jedis = jedisPool.getResource()) { jedis.setex(cacheKey, ttl, productName); System.out.println("[随机TTL] id=" + id + " 缓存过期时间: " + ttl + " 秒" ); } } public static void main (String[] args) { CacheAvalancheRandomTTL demo = new CacheAvalancheRandomTTL (); for (int i = 1 ; i <= 10 ; i++) { demo.cacheProduct(i, "Product-" + i); } } }
运行输出示例 :
1 2 3 4 5 6 7 8 9 10 [随机TTL] id=1 缓存过期时间: 523 秒 [随机TTL] id=2 缓存过期时间: 786 秒 [随机TTL] id=3 缓存过期时间: 607 秒 [随机TTL] id=4 缓存过期时间: 455 秒 [随机TTL] id=5 缓存过期时间: 742 秒 [随机TTL] id=6 缓存过期时间: 689 秒 [随机TTL] id=7 缓存过期时间: 498 秒 [随机TTL] id=8 缓存过期时间: 611 秒 [随机TTL] id=9 缓存过期时间: 733 秒 [随机TTL] id=10 缓存过期时间: 570 秒
10 个 key 的过期时间分散在 455~786 秒之间,不会同时过期。
4.3 方案二:多级缓存 + 限流熔断(整体兜底) 当 Redis 宕机时,随机 TTL 方案已经无法生效。此时需要整体降级方案 :本地缓存(如 Caffeine)作为一级缓存,Redis 作为二级缓存,MySQL 兜底。同时通过限流熔断保护 MySQL。
架构图 :
1 2 3 [App] --> [Caffeine 本地缓存] --> [Redis] --> [MySQL] | | └-- 未命中 --> 限流/熔断 --> └-- 未命中 --> MySQL 限流
完整代码 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 import com.github.benmanes.caffeine.cache.Cache;import com.github.benmanes.caffeine.cache.Caffeine;import redis.clients.jedis.Jedis;import redis.clients.jedis.JedisPool;import redis.clients.jedis.JedisPoolConfig;import java.time.Duration;import java.util.concurrent.atomic.AtomicInteger;public class CacheAvalancheMultiLevel { private static final JedisPool jedisPool = new JedisPool (new JedisPoolConfig (), "127.0.0.1" , 6379 ); private static final Cache<Long, String> LOCAL_CACHE = Caffeine.newBuilder() .maximumSize(10_000 ) .expireAfterWrite(Duration.ofMinutes(5 )) .build(); private static final AtomicInteger mysqlCounter = new AtomicInteger (0 ); private static final int MYSQL_MAX_QPS = 100 ; private volatile boolean redisAvailable = true ; public String getProductById (long id) { String productName = null ; productName = LOCAL_CACHE.getIfPresent(id); if (productName != null ) { return productName; } if (redisAvailable) { try (Jedis jedis = jedisPool.getResource()) { productName = jedis.get("product:" + id); if (productName != null ) { LOCAL_CACHE.put(id, productName); return productName; } } catch (Exception e) { System.err.println("[多级缓存] Redis 不可用,降级到本地缓存/MySQL: " + e.getMessage()); redisAvailable = false ; } } if (mysqlCounter.incrementAndGet() > MYSQL_MAX_QPS) { System.err.println("[多级缓存] MySQL 限流触发,请求被拒绝" ); return null ; } productName = queryMySQL(id); if (productName != null ) { LOCAL_CACHE.put(id, productName); if (redisAvailable) { try (Jedis jedis = jedisPool.getResource()) { jedis.setex("product:" + id, 600 , productName); } } } return productName; } private String queryMySQL (long id) { try { Thread.sleep(10 ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "Product-" + id; } public static void main (String[] args) { CacheAvalancheMultiLevel demo = new CacheAvalancheMultiLevel (); long start = System.currentTimeMillis(); for (int i = 0 ; i < 1000 ; i++) { String result = demo.getProductById(100 ); } long end = System.currentTimeMillis(); System.out.println("1000 次请求总耗时: " + (end - start) + " ms" ); System.out.println("命中本地缓存次数(近似): " + (1000 - 1 )); } }
五、压测数据对比 为了直观展示防护效果,我用 JMH 在以下环境做了基准测试:
项目
配置
CPU
8 核 16 线程
内存
32 GB
操作系统
Ubuntu 22.04
Redis
7.2.4(单机)
MySQL
8.0.36(InnoDB,buffer pool 8GB)
测试工具
JMH 1.37 / wrk
5.1 各方案 QPS 与 P99 延迟对比
方案
QPS (读)
P99 延迟
MySQL 承受 QPS
说明
无缓存(纯 MySQL)
2,000
350ms
2,000
MySQL 单机极限
仅 Redis 缓存(无防护)
15,000
8ms
200(击穿时 5,000+)
热点过期时 MySQL 被打爆
空值缓存