📖 相关阅读 — MySQL 专题系列
本文聚焦线上DB+缓存问题排查故事 (MySQL死锁、连接池耗尽、Redis缓存等),与以下文件有话题重叠但角度不同:
07-mysql-principles.html — MySQL 原理全景(最全面:存储引擎、B+树、事务、锁、Buffer Pool 原理讲解)
08-mysql-problem-driven.html — MySQL 问题实战手册(15个MySQL问题,问题→现象→原理→修复)
05-sql-troubleshooting-stories.html — SQL 问题排查全纪实(专注SQL索引失效、EXPLAIN、SQL优化)
09-backend-problem-driven.html — 后端问题排查实战手册(更广范围:NPE/OOM/线程死锁等,MySQL只是其中一部分)
场景 1
MySQL 死锁 — Deadlock found when trying to get lock
场景: 线上大量报错 Deadlock found when trying to get lock; try restarting transaction,用户投诉转账操作失败
🚨 P0 告警 — 生产环境
服务 account-service 连续报错 Deadlock,过去5分钟错误数 237 ,影响用户转账操作。错误率飙升至 12%。
—— 2026-05-20 10:17 ——
运
运维-老王
account-service 大量报 Deadlock,最近5分钟237条,用户开始投诉了
开
后端-小陈
我看到了,正在登数据库查 InnoDB 状态
产
产品-小张
客服已经接到20多个投诉电话了,能快点吗?
场景: 上午10点,用户集中进行跨账户转账。系统有两个接口:接口A从账户1转出到账户2,接口B从账户2转到账户1。并发量大时,两个方向的转账互相等锁,产生死锁。
1
第一步:查看 InnoDB 死锁信息
在 DataGrip 中执行 SHOW ENGINE INNODB STATUS,看 LATEST DETECTED DEADLOCK 部分
看到了!事务1持有B002的锁,等待A001的锁;事务2持有A001的锁,等待B002的锁。这是典型的双向互等死锁。问题在于两个转账接口更新账户的顺序不一致!
死锁发生的精确时序:
T1 (10:15:31.002)
事务1执行 UPDATE account_balance SET balance = balance - 100 WHERE account_id = 'B002',获取B002的行锁 ✅
T2 (10:15:31.015)
事务2执行 UPDATE account_balance SET balance = balance + 100 WHERE account_id = 'A001',获取A001的行锁 ✅
T3 (10:15:31.023)
事务1执行 UPDATE ... WHERE account_id = 'A001',等待A001的锁 ❌(被事务2持有)
T4 (10:15:31.025)
事务2执行 UPDATE ... WHERE account_id = 'B002',等待B002的锁 ❌(被事务1持有)
T5 (10:15:33.001)
InnoDB死锁检测器发现环形等待 → 回滚事务2 ❌
2
第二步:画出死锁关系图
两个事务形成环形等待,这是死锁的必要条件
事务1 (TX 23456789)
🔒 持有: B002
⏳ 等待: A001
→ 等锁 →
事务2 (TX 23456790)
🔒 持有: A001
⏳ 等待: B002
→ 等锁 →
🔄 环形等待 → 死锁!
AccountService.java — account-service
TransferOutService.java
TransferInService.java
☕ TransferOutService
☕ TransferInService
📄 AccountMapper
📁 config/
📁 controller/
1 package com.example.account.service;
2
3 import org.springframework.stereotype.Service ;
4 import org.springframework.transaction.annotation.Transactional ;
5 import lombok.RequiredArgsConstructor ;
6
7 @Service
8 @RequiredArgsConstructor
9 public class TransferOutService {
10
11 // 接口A:从fromAccount转出到toAccount
12 // 先扣减fromAccount余额,再增加toAccount余额
13 @Transactional
14 public void transferOut (String fromId, String toId, BigDecimal amt) {
15 accountMapper.deductBalance (fromId, amt); // 先更新A
16 accountMapper.addBalance (toId, amt); // 再更新B
17 transferLogMapper.insert (fromId, toId, amt);
18 }
19 }
TransferOutService.java
UTF-8
LF
AccountService.java — account-service
TransferOutService.java
TransferInService.java
☕ TransferOutService
☕ TransferInService
📄 AccountMapper
📁 config/
📁 controller/
1 package com.example.account.service;
2
3 import org.springframework.stereotype.Service ;
4 import org.springframework.transaction.annotation.Transactional ;
5 import lombok.RequiredArgsConstructor ;
6
7 @Service
8 @RequiredArgsConstructor
9 public class TransferInService {
10
11 // 接口B:从toAccount转入到fromAccount(反向转账)
12 // 先增加fromAccount余额,再扣减toAccount余额
13 // ⚠️ 更新顺序和TransferOutService不一致!
14 @Transactional
15 public void transferIn (String fromId, String toId, BigDecimal amt) {
16 accountMapper.addBalance (fromId, amt); // 先更新A(增加)
17 accountMapper.deductBalance (toId, amt); // 再更新B(扣减)
18 transferLogMapper.insert (toId, fromId, amt);
19 }
20 }
TransferInService.java
UTF-8
LF
问题找到了!TransferOutService 先扣A再加B,TransferInService 先加A再扣B。当用户1从A转B的同时用户2从B转A,事务1锁住B等A,事务2锁住A等B,形成死锁。根本原因是更新顺序不一致!
🔴 根因定位: 两个转账接口更新账户余额的顺序不一致。接口A先更新fromAccount再更新toAccount,接口B先更新fromAccount再更新toAccount——但因为fromAccount和toAccount含义相反,实际加锁顺序相反,形成环形等待。
AccountService.java — 修复后 ✓
☕ TransferService
☕ DeadlockRetryAspect
📄 AccountMapper
1 package com.example.account.service;
2
3 @Service
4 @RequiredArgsConstructor
5 public class TransferService {
6
7 // ✅ 修复:统一更新顺序,始终按accountId排序
8 @Transactional
9 public void transfer (String fromId, String toId, BigDecimal amt) {
10 // 按ID排序保证加锁顺序一致
11 String first = fromId.compareTo (toId) < 0 ? fromId : toId;
12 String second = fromId.compareTo (toId) < 0 ? toId : fromId;
13
14 accountMapper.deductOrAdd (first, ...); // 始终先更新ID较小的
15 accountMapper.addOrDeduct (second, ...); // 再更新ID较大的
16 }
17 }
TransferService.java
UTF-8
LF
DeadlockRetryAspect.java — 死锁重试切面
1 @Aspect
2 @Component
3 public class DeadlockRetryAspect {
4
5 @Around ("@annotation(transactional)" )
6 public Object retryOnDeadlock (ProceedingJoinPoint pjp) throws Throwable {
7 int maxRetry = 3 ;
8 for (int i = 0 ; i < maxRetry; i++) {
9 try {
10 return pjp.proceed ();
11 } catch (CannotAcquireLockException e) {
12 if (i == maxRetry - 1 ) throw e;
13 Thread.sleep (50 + new Random().nextInt (100 )); // 随机退避
14 }
15 }
16 return null ;
17 }
18 }
✅ 修复方案
统一更新顺序 :按accountId排序,保证所有事务加锁顺序一致,消除环形等待
减小事务范围 :将transferLog插入移到事务外,减少持锁时间
添加重试机制 :用AOP切面捕获死锁异常自动重试(最多3次,随机退避)
场景 2
数据库连接池耗尽 — Connection is not available, request timed out
场景: 接口大面积超时,日志报 HikariPool-1 - Connection is not available, request timed out after 30000ms
🚨 P0 告警 — 生产环境
服务 order-service 大面积超时,平均响应时间从 200ms 飙升至 32s,错误率 45%。日志疯狂刷 HikariPool-1 - Connection is not available, request timed out after 30000ms
—— 2026-05-22 15:40 ——
运
运维-老王
order-service 全线超时!响应时间32秒,这不对劲
开
后端-小陈
日志全是 HikariPool connection not available,连接池满了?
DB
DBA-老李
数据库侧连接数正常,不是DB的问题。你们应用侧看看是不是连接泄露了
场景: 下午3点40分开始,订单服务响应变慢,逐渐恶化到完全不可用。日志中全是连接池获取超时的错误,但数据库侧一切正常。
1
第一步:查看 HikariCP 配置和连接池状态
application-prod.yml — order-service
📁 src/main/
📁 resources/
📄 application-prod.yml
📄 application.yml
1 spring :
2 datasource :
3 url : jdbc:mysql://rm-xxx.mysql.rds.aliyuncs.com:3306/order_prod
4 username : order_svc
5 password : ******
6 hikari :
7 maximum-pool-size : 20 # 最大连接数
8 minimum-idle : 5 # 最小空闲连接
9 connection-timeout : 30000 # 获取连接超时30s
10 idle-timeout : 600000 # 空闲超时10min
11 max-lifetime : 1800000 # 连接最大存活30min
12 leak-detection-threshold : 60000 # 连接泄露检测60s
application-prod.yml
UTF-8
Grafana
order-service / HikariCP Connection Pool
2026-05-22 15:30 → 15:50
Active Connections
20 / 20
Active
Max Pool
20个连接全部active,0个idle,还有237个线程在等待获取连接!这就是连接池耗尽。但数据库侧连接数正常,说明不是数据库慢导致连接用完,而是应用侧有连接泄露——拿到了连接但没有归还。
$ jstack 28456 | grep -A 5 "HikariPool"
"http-nio-8080-exec-87" #87 daemon prio=5 os_prio=0 tid=0x00007f8a3c001000 nid=0x4a57 waiting on condition [0x00007f8a4b2b4000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
at com.zaxxer.hikari.util.concurrent.QueuedSequenceSynchroniser.acquire(QueuedSequenceSynchroniser.java:71)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:184)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:147)
$ jstack 28456 | grep "WAITING" | wc -l
237
$ jstack 28456 | grep -B 2 "getConnection" | grep "http-nio" | head -5
"http-nio-8080-exec-87"
"http-nio-8080-exec-89"
"http-nio-8080-exec-91"
"http-nio-8080-exec-93"
"http-nio-8080-exec-95"
ReportExportService.java — order-service
☕ ReportExportService
☕ OrderService
📄 OrderMapper
1 @Service
2 public class ReportExportService {
3
4 @Autowired
5 private DataSource dataSource;
6
7 // ⚠️ 连接泄露!try-catch里忘记close connection
8 public void exportReport (String date) {
9 try {
10 Connection conn = dataSource.getConnection (); // 获取连接
11 PreparedStatement ps = conn.prepareStatement (
12 "SELECT * FROM orders WHERE create_time > ?" );
13 ps.setString (1 , date);
14 ResultSet rs = ps.executeQuery ();
15 // ... 处理大量数据 ...
16 // ❌ 如果这里抛异常,connection永远不会被关闭!
17 // ❌ 没有 finally 块来 close connection
18 } catch (Exception e) {
19 log.error ("export failed" , e); // 只是打了日志
20 }
21 // ❌ connection 泄露了!没有归还连接池
22 }
23 }
ReportExportService.java
UTF-8
⚠ Connection leak detected!
找到了!ReportExportService 直接用 DataSource.getConnection() 获取连接,但在 catch 块中没有关闭连接。一旦处理数据时抛异常(比如 OOM 或数据格式问题),连接就永远不会归还连接池。每次调用泄露一个连接,20个连接很快就耗尽了!
2026-05-22 15:32:17.342 WARN com.zaxxer.hikari.pool.ProxyLeakTask
Connection leak detection triggered for {conn-182756}, stack trace:
at com.example.order.service.ReportExportService.exportReport(ReportExportService.java:10)
at com.example.order.service.ReportExportService$$FastClassBySpringCGLIB$$xxx.invoke(<generated>)
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
...
🔴 根因定位: ReportExportService 手动获取 JDBC Connection,但 try-catch 中没有 finally 块来关闭连接。异常发生时连接泄露,连接池逐渐耗尽。
ReportExportService.java — 修复后 ✓
1 @Service
2 public class ReportExportService {
3
4 // ✅ 修复:使用 try-with-resources 自动关闭连接
5 public void exportReport (String date) {
6 try (Connection conn = dataSource.getConnection ();
7 PreparedStatement ps = conn.prepareStatement (
8 "SELECT * FROM orders WHERE create_time > ?" )) {
9 ps.setString (1 , date);
10 try (ResultSet rs = ps.executeQuery ()) {
11 // ... 处理数据 ...
12 }
13 } catch (Exception e) {
14 log.error ("export failed" , e);
15 }
16 // ✅ try-with-resources 保证连接一定会归还
17 }
18 }
✅ 修复方案
使用 try-with-resources :将 Connection、PreparedStatement、ResultSet 都用 try-with-resources 管理,确保异常时也能自动关闭
排查慢SQL :同时发现导出SQL没有分页,一次查询几十万行导致长时间占用连接
增大连接池 :maximum-pool-size 从20调整到30,给更多余量
开启泄露检测 :leak-detection-threshold 已配置60s,日志中会报告泄露的连接
场景 3
慢查询 — 索引失效,从3秒变成60秒
场景: 运营后台导出越来越慢,从3秒变成了60秒,用户一直在等
—— 2026-05-25 11:20 ——
运
运营-小美
后台导出越来越慢了,之前3秒就好,现在要等1分钟,转圈转得我头晕
运
运营-小美
每次导出订单列表,筛选条件是订单状态+商品ID+日期范围
场景: 运营后台的订单导出功能,随着数据量从10万增长到200万,导出时间从3秒逐渐变成60秒。用户反馈越来越难以接受。
1
第一步:用 EXPLAIN 分析 SQL 执行计划
type=ALL 全表扫描!possible_keys 有 idx_status 和 idx_create_time,但 key=NULL,说明一个索引都没用上!为什么?看一下这个SQL…… DATE(create_time) 对列用了函数,索引失效了!product_id LIKE '%138%' 前置百分号,索引也失效了!
索引存在但没被使用!三个索引失效原因同时出现
索引失效原因分析:
1. DATE(create_time) — 对索引列使用函数
对 create_time 列调用 DATE() 函数,MySQL 无法使用 idx_create_time 索引,必须逐行计算
2. product_id LIKE '%138%' — 前置通配符
LIKE 以 % 开头,B+Tree 索引无法定位起始位置,idx_product_id 索引失效
3. 单列索引 vs 复合索引
只有单列索引,MySQL 只能选一个,而且哪个都因为上述原因用不上
3
第三步:看 MyBatis XML 中的 SQL
OrderMapper.xml — order-service
1 <select id ="exportOrders" resultType ="OrderDTO" >
2 SELECT * FROM order_info
3 WHERE status = #{status}
4 <!-- ❌ DATE()函数导致索引失效 -->
5 AND DATE (create_time) = #{date}
6 <!-- ❌ 前置%导致索引失效 -->
7 AND product_id LIKE CONCAT('%' , #{productId}, '%' )
8 ORDER BY create_time DESC
9 LIMIT #{limit}
10 </select>
为什么选 (status, create_time) 而不是单列索引?
1. 复合索引遵循最左前缀原则 :先用 status 等值过滤,再用 create_time 范围扫描
2. 索引下推(ICP) :MySQL 5.6+ 可以在索引层面过滤,减少回表次数
3. 覆盖 ORDER BY create_time DESC,避免额外 filesort
5
第五步:优化SQL + 添加复合索引后,再看 EXPLAIN
✅ 修复方案
避免索引失效写法 :DATE(create_time) = ? 改为 create_time >= ? AND create_time < ?,范围查询走索引
避免前置通配符 :LIKE '%138%' 改为 LIKE '138%',支持前缀匹配走索引
添加复合索引 :(status, create_time) 覆盖主要查询条件,利用索引下推
必要时 FORCE INDEX :如果优化器仍然选错索引,使用 FORCE INDEX 指定
场景 4
缓存穿透 — 恶意请求不存在的ID,全部打到数据库
场景: 有人恶意用大量不存在的ID请求接口,缓存没命中全部打到数据库,数据库CPU飙升
🚨 P1 告警 — 数据库CPU飙升
MySQL CPU使用率飙升至 92%,Redis hit rate 从 95% 降到 23%。数据库慢查询数量暴增。
—— 2026-05-28 02:15 ——
运
运维-老王
凌晨2点数据库CPU飙到92%了!但正常流量很低啊
开
后端-小陈
Redis hit rate掉到23%?这不正常,我看看Redis监控
DB
DBA-老李
数据库全是 SELECT * FROM products WHERE id = xxx 的查询,这些id根本不存在
场景: 凌晨2点,有人在用脚本批量请求 /api/product/{id},但使用的id都是随机数,数据库中根本不存在。Redis中没有这些key,每次请求都穿透到数据库。
127.0.0.1:6379> INFO stats
keyspace_hits: 1247
keyspace_misses: 41823
instantaneous_ops_per_sec: 8932
127.0.0.1:6379> GET product:9999999
(nil)
127.0.0.1:6379> GET product:8888888
(nil)
127.0.0.1:6379> GET product:7777777
(nil)
Grafana
Redis + MySQL Monitoring
2026-05-28 02:00 → 02:30
Redis Hit Rate
23%
Hit Rate
Miss Rate
ProductService.java — product-service
1 @Service
2 public class ProductService {
3
4 public Product getProduct (String id) {
5 // 1. 先查缓存
6 String key = "product:" + id;
7 String cached = redis.get (key);
8 if (cached != null ) {
9 return JSON.parseObject (cached, Product.class );
10 }
11
12 // 2. 缓存miss,查数据库
13 Product product = productMapper.selectById (id);
14 if (product != null ) {
15 redis.set (key, JSON.toJSONString (product), 3600 ); // 缓存1小时
16 }
17 // ❌ product == null 时不缓存!下次同样的请求还会打到DB
18 return product;
19 }
20 }
缓存穿透流程:
请求 product:9999999
→
Redis MISS
→
查询数据库
→
返回 null
→
不缓存null
再次请求 product:9999999
→
Redis又MISS
→
又查数据库
→
无限循环!
典型的缓存穿透!查不到数据时不缓存null,导致每次请求同样的不存在的ID都会打到数据库。攻击者用随机ID大量请求,每次都穿透到DB。需要缓存null值 + 布隆过滤器。
🔴 根因定位: 查询结果为null时不缓存,导致不存在的ID反复穿透到数据库。没有布隆过滤器做前置校验,也没有对null值做缓存。
127.0.0.1:6379> GET product:9999999
(nil)
127.0.0.1:6379> GET product:9999999
(nil)
4
第四步:修复 — 缓存null值 + 布隆过滤器
127.0.0.1:6379> SET product:9999999 "NULL" EX 60
OK
127.0.0.1:6379> GET product:9999999
"NULL"
127.0.0.1:6379> TTL product:9999999
(integer) 47
127.0.0.1:6379> SET lock:product:12345 "1" NX EX 10
OK
127.0.0.1:6379> DEL lock:product:12345
(integer) 1
ProductService.java — 修复后 ✓
1 @Service
2 public class ProductService {
3
4 @Autowired
5 private BloomFilter<String> productBloomFilter; // 布隆过滤器
6
7 public Product getProduct (String id) {
8 // 1. 布隆过滤器前置校验
9 if (!productBloomFilter.mightContain (id)) {
10 return null ; // ✅ 一定不存在,直接返回
11 }
12
13 // 2. 查缓存
14 String key = "product:" + id;
15 String cached = redis.get (key);
16 if (cached != null ) {
17 if ("NULL" .equals(cached)) return null ; // ✅ null值缓存命中
18 return JSON.parseObject (cached, Product.class );
19 }
20
21 // 3. 查数据库
22 Product product = productMapper.selectById (id);
23 if (product != null ) {
24 redis.set (key, JSON.toJSONString (product), 3600 );
25 } else {
26 // ✅ 缓存null值,短TTL防止长期占用内存
27 redis.set (key, "NULL" , 60 );
28 }
29 return product;
30 }
31 }
✅ 修复方案
缓存null值 :查询结果为空时,缓存特殊值"NULL",设置短TTL(60秒),避免相同ID反复穿透
布隆过滤器 :启动时加载所有合法ID到布隆过滤器,请求先过布隆过滤器,不存在的ID直接返回
接口限流 :对 /api/product/{id} 接口添加限流,单个IP每秒最多10次请求
场景 5
缓存击穿 — 热点Key过期瞬间,大量并发打到数据库
场景: 某个热点Key过期瞬间,大量并发请求同时打到数据库
🚨 P0 告警 — 数据库QPS暴增
服务 product-service 数据库QPS在 14:00:03 突然飙升至平时10倍(从 500 飙到 5000+),MySQL CPU 85%。
—— 2026-06-01 14:02 ——
运
运维-老王
数据库QPS突然飙到5000!CPU 85%,但总请求数没变啊
开
后端-小陈
总请求没变但DB QPS暴增,这是缓存击穿吧?某个热点Key过期了?
场景: 下午2点整,某个热门商品的缓存Key刚好过期。这个商品是秒杀活动的主推商品,页面每秒有上千次请求。Key过期的瞬间,所有并发请求同时发现缓存miss,全部涌向数据库。
Grafana
product-service / DB QPS Spike
2026-06-01 13:55 → 14:10
Database QPS
5,234
DB QPS
CPU
127.0.0.1:6379> TTL product:hot:P10086
(integer) -2
127.0.0.1:6379> GET product:hot:P10086
(nil)
127.0.0.1:6379> HISTORY GET product:hot:P10086
127.0.0.1:6379> SCARD hot_keys
(integer) 3
缓存击穿时序:
14:00:00
Key product:hot:P10086 TTL到期,Redis自动删除
14:00:00.001
100个并发请求同时到达,全部发现缓存 miss
14:00:00.002
100个请求同时查询数据库,DB瞬间承受 100x 负载
14:00:00.050
数据库响应变慢,更多请求堆积,恶性循环
Key过期
→
100个请求同时miss
→
100个请求同时打DB
→
DB被打爆!
ProductService.java — 缓存逻辑(问题代码)
1 // ❌ 问题代码:没有互斥锁,Key过期时大量并发同时穿透
2 public Product getHotProduct (String id) {
3 String key = "product:hot:" + id;
4 String cached = redis.get (key);
5 if (cached != null ) return JSON.parseObject (cached, Product.class );
6
7 // ❌ 缓存miss时,所有线程都来查数据库
8 Product product = productMapper.selectById (id);
9 if (product != null ) {
10 redis.set (key, JSON.toJSONString (product), 3600 ); // TTL=1小时
11 }
12 return product;
13 }
热点Key过期瞬间,大量并发请求同时miss,都去查DB。需要加互斥锁——只有一个线程去查DB并重建缓存,其他线程等结果。
4
第四步:用 Redis CLI 验证互斥锁机制
127.0.0.1:6379> TTL product:hot:P10086
(integer) -2
127.0.0.1:6379> GET product:hot:P10086
(nil)
127.0.0.1:6379> SET lock:product:hot:P10086 "thread-A" NX EX 10
OK
127.0.0.1:6379> SET lock:product:hot:P10086 "thread-B" NX EX 10
(nil)
127.0.0.1:6379> SET product:hot:P10086 '{"id":"P10086","name":"秒杀商品","price":99.9}' EX 3600
OK
127.0.0.1:6379> DEL lock:product:hot:P10086
(integer) 1
127.0.0.1:6379> GET product:hot:P10086
'{"id":"P10086","name":"秒杀商品","price":99.9}'
互斥锁重建缓存流程:
缓存miss
→
尝试SETNX获取锁
→
获取成功:查DB+重建缓存
SETNX失败
→
sleep 100ms
→
重试读缓存
ProductService.java — 修复后 ✓
1 // ✅ 修复:互斥锁(SETNX)+ 热点Key逻辑过期
2 public Product getHotProduct (String id) {
3 String key = "product:hot:" + id;
4 String lockKey = "lock:" + id;
5 String cached = redis.get (key);
6 if (cached != null ) return JSON.parseObject (cached, Product.class );
7
8 // ✅ 只有一个线程能获取锁去查DB
9 if (redis.setnx (lockKey, "1" , 10 )) { // 10秒锁超时
10 try {
11 Product product = productMapper.selectById (id);
12 redis.set (key, JSON.toJSONString (product), 3600 );
13 return product;
14 } finally {
15 redis.del (lockKey);
16 }
17 } else {
18 Thread.sleep (100 ); // 等一会再重试
19 return getHotProduct (id); // 递归重试
20 }
21 }
✅ 修复方案
互斥锁(SETNX) :缓存miss时,只有一个线程去查DB重建缓存,其他线程等待后重试
热点Key永不过期(逻辑过期) :不设TTL,在value中存逻辑过期时间,后台异步更新
接口限流 :对热点商品接口限流,防止突发流量打爆DB
场景 6
缓存雪崩 — 大量Key同时过期,系统瞬间不可用
场景: 凌晨2点大量Key同时过期,数据库瞬间被打爆,整个系统不可用
🚨 P0 告警 — 系统不可用
凌晨 02:00 开始,product-service、order-service、user-service 同时报数据库连接超时,整体可用性降至 12%。Redis hit rate 从 92% 降到 15%。
—— 2026-06-05 02:03 ——
运
运维-老王
全线报数据库超时!product/order/user三个服务同时挂了
开
后端-小陈
Redis hit rate 暴跌到15%,大量缓存同时失效?
DB
DBA-老李
数据库连接数瞬间打满,CPU 98%。这不是单个Key的问题,是大规模Key同时过期
TL
组长-老赵
昨天晚上2点有个批量数据刷新任务,是不是把大量Key的TTL设成一样的了?
场景: 凌晨2点,系统执行每日数据刷新任务,批量写入缓存时都设置了 TTL=86400(24小时)。第二天凌晨2点,这些Key同时过期,大量请求同时穿透到数据库,造成雪崩效应。
1
第一步:看多指标监控——DB QPS + Redis Hit Rate 同时异常
Grafana
System Overview / Cache Avalanche
2026-06-05 01:50 → 02:15
Redis Hit Rate
15%
Hit Rate
DB QPS (All Services)
25,000+
QPS
2
第二步:分析 Redis Key 的 TTL 分布
127.0.0.1:6379> --scan --pattern "product:*" | head -20 | xargs -I {} redis-cli TTL {}
-2
-2
-2
-2
-2
# 大量Key已经同时过期!
127.0.0.1:6379> INFO keyspace
db0:keys=1234,expires=892,avg_ttl=0
# 892个有过期时间的Key,avg_ttl=0 说明大部分刚刚过期
Key TTL 分布分析:
⚠ 几乎所有Key都是相同的TTL=86400,昨晚2点写入的,今天凌晨2点同时过期!
DataRefreshJob.java — product-service
1 @Component
2 public class DataRefreshJob {
3
4 @Scheduled (cron = "0 0 2 * * ?" ) // 每天凌晨2点
5 public void refreshAllProducts () {
6 List<Product> products = productMapper.selectAll ();
7 for (Product p : products) {
8 String key = "product:" + p.getId ();
9 // ❌ 所有Key都设置相同的TTL=86400(24小时)
10 // ❌ 明天凌晨2点会同时过期!
11 redis.set (key, JSON.toJSONString (p), 86400 );
12 }
13 log.info ("Refreshed {} products" , products.size ());
14 }
15 }
问题清楚了!批量刷新时所有Key设了相同的TTL=86400,第二天凌晨2点全部同时过期。这不是单个Key的击穿,而是大规模的雪崩——比击穿更严重,因为影响的是整个缓存层。
三种缓存问题的本质区别:
穿透
数据根本不存在
Redis没有 → DB没有 → 不缓存 → 反复穿透
击穿
一个热点Key 过期
热点Key过期 → 大量并发同时miss → 打DB
雪崩
大量Key 同时过期
批量Key同时过期 → 大面积miss → DB暴增
🔴 根因定位: 批量数据刷新任务给所有Key设置相同的TTL=86400,导致第二天同一时刻大量Key同时过期,缓存集体失效,请求全部穿透到数据库。
4
第四步:用 Redis CLI 验证随机TTL打散过期时间
127.0.0.1:6379> SET product:1001 '{"name":"商品A"}' EX 86400
127.0.0.1:6379> SET product:1002 '{"name":"商品B"}' EX 86400
127.0.0.1:6379> SET product:1003 '{"name":"商品C"}' EX 86400
127.0.0.1:6379> SET product:1001 '{"name":"商品A"}' EX 86400
127.0.0.1:6379> SET product:1002 '{"name":"商品B"}' EX 88234
127.0.0.1:6379> SET product:1003 '{"name":"商品C"}' EX 87521
127.0.0.1:6379> SET product:1004 '{"name":"商品D"}' EX 89102
127.0.0.1:6379> SET product:1005 '{"name":"商品E"}' EX 86301
127.0.0.1:6379> TTL product:1001
(integer) 86400
127.0.0.1:6379> TTL product:1002
(integer) 88234
127.0.0.1:6379> TTL product:1003
(integer) 87521
DataRefreshJob.java — 修复后 ✓
1 // ✅ 修复:TTL加随机偏移,打散过期时间
2 @Scheduled (cron = "0 0 2 * * ?" )
3 public void refreshAllProducts () {
4 List<Product> products = productMapper.selectAll ();
5 Random random = new Random();
6 for (Product p : products) {
7 String key = "product:" + p.getId ();
8 // ✅ 基础TTL=86400 + 随机0~3600秒偏移
9 // ✅ 过期时间分散在1小时内,不会同时失效
10 int ttl = 86400 + random.nextInt (3600 );
11 redis.set (key, JSON.toJSONString (p), ttl);
12 }
13 }
✅ 修复方案
TTL加随机偏移 :基础TTL + 随机0~3600秒,将过期时间打散到1小时内,避免同时失效
多级缓存 :添加本地缓存(Caffeine)作为二级缓存,Redis失效后还有本地缓存兜底
熔断降级 :当数据库压力过大时触发熔断,返回降级数据而非直接报错
缓存预热 :批量刷新后不要清空旧缓存,等新缓存写入完成后再让旧缓存自然过期
📌 补充:缓存与数据库一致性
面试高频题 :先更新数据库还是先更新缓存?
答案 :都不对!应该用 Cache Aside Pattern (旁路缓存模式)
方案 操作顺序 问题 推荐
先更新DB再更新缓存 DB→Cache 并发下可能旧值覆盖新值 ❌
先更新缓存再更新DB Cache→DB DB失败时缓存是脏数据 ❌
先更新DB再删缓存(Cache Aside) DB→DEL Cache 极端并发下仍可能短暂不一致 ✅ 推荐
延迟双删 DEL→DB→sleep→DEL sleep时间难确定 ⚠️ 可选
Cache Aside Pattern — Spring Boot 实现
1 // ✅ Cache Aside Pattern — Spring Boot 实现
2 @Service
3 public class ProductService {
4
5 @Autowired
6 private ProductMapper productMapper;
7 @Autowired
8 private StringRedisTemplate redisTemplate;
9
10 // 读:先查缓存,没有则查DB并回填
11 public Product getProduct (Long id) {
12 String key = "product:" + id;
13 String json = redisTemplate.opsForValue ().get (key);
14
15 if (json != null ) {
16 return JSON .parseObject (json, Product .class ); // 缓存命中
17 }
18
19 // 缓存未命中 → 查DB → 写缓存
20 Product product = productMapper.selectById (id);
21 if (product != null ) {
22 redisTemplate.opsForValue ().set (key, JSON .toJSONString (product),
23 30 , TimeUnit .MINUTES); // 设置过期时间防止脏数据
24 }
25 return product;
26 }
27
28 // 写:先更新DB,再删缓存
29 @Transactional
30 public void updateProduct (Product product) {
31 productMapper.updateById (product); // 1. 先更新DB
32
33 String key = "product:" + product.getId ();
34 redisTemplate.delete (key); // 2. 再删缓存(不是更新缓存!)
35 }
36 }
为什么删缓存而不是更新缓存?
• 并发写时,更新缓存可能旧值覆盖新值
• 删缓存后下次读会从DB加载最新值
• 缓存是"锦上添花"不是"数据源",删了再建即可
📌 补充:Redis 高可用方案速查
方案 说明 适用场景
RDB 快照 定期保存全量数据到磁盘,恢复快但可能丢数据 备份数据
AOF 追加 每次写操作追加到日志,数据更安全但文件大 数据安全要求高
主从复制 1主N从,主写从读,主挂了需手动切换 读写分离
哨兵模式 哨兵进程监控主从,主挂了自动故障转移 自动故障转移(中小规模)
Cluster 集群 16384个槽位分片,每个主节点负责一部分 大数据量+高可用
面试回答模板 :
• 单机Redis挂了怎么办?→ 主从复制+哨兵自动切换
• 数据量太大一台装不下?→ Cluster分片存储
• 数据持久化?→ RDB+AOF混合持久化(4.0+)
• 详细配置和架构图见
基础设施架构手册
场景 7
Redis 热 Key / 大 Key — 节点CPU飙升 / DEL卡住几秒
场景: Redis某个节点CPU很高,其他节点正常;或者DEL一个大Key时Redis卡住了几秒
🚨 P1 告警 — Redis节点异常
Redis Cluster 节点 redis-node-3 CPU持续 90%+,其他节点正常。同时有业务反馈 DEL 操作超时,Redis 响应变慢。
—— 2026-06-08 16:30 ——
运
运维-老王
Redis Cluster 3号节点CPU 92%,其他节点都正常,是不是数据倾斜了?
开
后端-小陈
还有个问题,刚才有业务说DEL一个Key时Redis卡了好几秒
DB
DBA-老李
热Key + 大Key,经典组合。我帮你扫一下
场景: Redis Cluster中,3号节点CPU异常偏高,其他节点正常。同时有业务在DEL一个Hash类型的Key时,Redis主线程卡了3-4秒。这是热Key和大Key同时存在的典型场景。
1
第一步:用 redis-cli --bigkeys 扫描大Key
$ redis-cli --bigkeys -h redis-node-3 -p 6379
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.
...
Biggest hash found so far 'user:activities:2026-06' with 5023847 fields
Biggest string found so far 'config:global' with 128 bytes
Biggest list found so far 'queue:notifications' with 32847 entries
Biggest set found so far 'tags:popular' with 8923 members
Biggest zset found so far 'rank:daily' with 45678 members
...
-------- summary -------
Biggest hash: 'user:activities:2026-06' with 5023847 fields
Hashs: 23 keys, avg size 219283.5 fields
找到了!user:activities:2026-06 这个Hash有 502万+ 个field!这是一个大Key。DEL这个Key时,Redis主线程要逐个删除502万个field,会阻塞好几秒。
2
第二步:用 redis-cli --hotkeys 扫描热Key
$ redis-cli --hotkeys -h redis-node-3 -p 6379
# Scanning the entire keyspace to find hot keys ...
...
[HOT KEY] user:activities:2026-06 QPS=12,847
[HOT KEY] config:global QPS=3,291
[HOT KEY] rank:daily QPS=1,823
...
$ redis-cli -h redis-node-3 -p 6379 INFO memory
used_memory_human: 3.42G
used_memory_peak_human: 3.89G
used_memory_dataset_perc: 78.23%
mem_fragmentation_ratio: 1.42
UserActivityService.java — activity-service
1 @Service
2 public class UserActivityService {
3
4 // ❌ 每个用户活动都往同一个Hash里put,无限增长
5 public void recordActivity (String userId, String activity) {
6 String key = "user:activities:" + currentMonth; // 一个月一个Key
7 // ❌ 502万用户全部put到同一个Hash
8 redis.hset (key, userId, activity);
9 // ❌ 没有设置过期时间
10 // ❌ 没有对Hash大小做限制
11 }
12
13 // ❌ 获取用户活动时读取整个Hash(虽然HGET只取一个field,但Key整体很大)
14 public String getActivity (String userId) {
15 String key = "user:activities:" + currentMonth;
16 return redis.hget (key, userId);
17 }
18 }
大Key + 热Key 的影响:
1. DEL 阻塞主线程
DEL 一个502万field的Hash,Redis主线程要逐个释放内存,阻塞3-5秒,期间所有请求超时
2. 热Key导致节点CPU飙升
user:activities:2026-06 的QPS=12847,全部集中在3号节点,其他节点空闲
3. 网络带宽占用
HGETALL 这样的操作会返回海量数据,占用大量网络带宽
拆分前后对比:
拆分前(1个大Key)
user:activities:2026-06
field: user_001 → val1
field: user_002 → val2
...
field: user_5023847 → val5023847
502万field × 1个Key = 灾难
拆分后(多个小Key)
user:activities:2026-06:bucket_00 → ~5000 fields
user:activities:2026-06:bucket_01 → ~5000 fields
user:activities:2026-06:bucket_02 → ~5000 fields
...
user:activities:2026-06:bucket_1004 → ~5000 fields
~5000field × 1005个Key = 安全
🔴 根因定位: user:activities:2026-06 这个Hash包含502万field,是一个大Key,DEL时阻塞主线程。同时这个Key的QPS高达12847,是热Key,导致3号节点CPU飙升。
UserActivityService.java — 修复后 ✓
1 @Service
2 public class UserActivityService {
3
4 // ✅ 大Key拆分:按userId hash分桶
5 private static final int BUCKET_COUNT = 1024 ;
6
7 private String getBucketKey (String userId) {
8 int bucket = Math.abs (userId.hashCode ()) % BUCKET_COUNT;
9 return "user:activities:" + currentMonth + ":bucket_" + bucket;
10 }
11
12 // ✅ 每个桶只存~5000个field,DEL很快
13 public void recordActivity (String userId, String activity) {
14 String key = getBucketKey (userId);
15 redis.hset (key, userId, activity);
16 redis.expire (key, 2592000 ); // ✅ 设置30天过期
17 }
18
19 // ✅ 热Key加本地缓存
20 @Autowired
21 private Cache<String, String> localCache = Caffeine.newBuilder ()
22 .maximumSize (10000 ).expireAfterWrite (5 , TimeUnit.MINUTES).build ();
23
24 public String getActivity (String userId) {
25 // ✅ 先查本地缓存,减轻Redis压力
26 return localCache.get (userId, k -> {
27 String key = getBucketKey (userId);
28 return redis.hget (key, userId);
29 });
30 }
31 }
大Key删除的正确方式:
❌ DEL key — 同步删除,主线程阻塞,502万field要删3-5秒
✅ UNLINK key — 异步删除,主线程只标记删除,后台线程异步释放内存,毫秒级返回
✅ hscan + hdel — 对于不能UNLINK的场景,用hscan分批删除field,每次删100个
✅ 修复方案
大Key拆分 :按userId hash分桶(1024个桶),每个Hash只存~5000个field,避免单个Key过大
热Key加本地缓存 :用Caffeine做本地缓存,5分钟过期,减轻Redis压力
UNLINK代替DEL :删除大Key用UNLINK异步删除,避免阻塞主线程
设置过期时间 :给每个桶设置TTL,避免数据无限增长
监控告警 :添加Redis大Key/热Key的定期扫描和告警
7个场景总结速查表
场景
核心现象
根因
关键修复
MySQL死锁
Deadlock found when trying to get lock
更新顺序不一致
统一加锁顺序+重试
连接池耗尽
Connection not available, timed out
连接泄露
try-with-resources
慢查询
type=ALL全表扫描
索引失效
复合索引+避免函数
缓存穿透
不存在的ID反复打DB
null不缓存
缓存null+布隆过滤器
缓存击穿
热点Key过期DB突增
热点Key过期无保护
互斥锁+逻辑过期
缓存雪崩
大量Key同时过期
TTL相同
TTL随机偏移+多级缓存
热/大Key
节点CPU高/DEL卡住
单Key数据量过大
分桶拆分+本地缓存+UNLINK
缓存三大问题对比:
穿透 Penetration
数据不存在 ,请求绕过缓存直达DB
→ 缓存null + 布隆过滤器
击穿 Breakdown
热点Key 过期,大量并发同时打DB
→ 互斥锁 + 逻辑过期
雪崩 Avalanche
大量Key 同时过期,DB瞬间暴增
→ TTL随机 + 多级缓存 + 熔断
🐌 慢查询日志 — 找到慢SQL的第一步
什么是慢查询日志?
线上说"接口慢",你第一反应是什么?看代码?看网络?
正确做法:先看是不是SQL慢! MySQL 的慢查询日志会自动记录所有执行时间超过阈值的SQL,是你排查慢接口的"第一入口"。
📝 开启慢查询日志
方式一:临时开启(重启失效)
-- 查看当前状态
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
-- 设置阈值(超过1秒记录)
SET GLOBAL long_query_time = 1;
-- 是否记录没走索引的SQL(建议开启)
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- 设置日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
方式二:永久开启(写配置文件)
# my.cnf / my.ini(MySQL配置文件)
[mysqld]
# 开启慢查询日志
slow_query_log = 1
# 日志文件位置
slow_query_log_file = /var/log/mysql/slow.log
# 超过1秒的SQL记录
long_query_time = 1
# 没走索引的也记录
log_queries_not_using_indexes = 1
# 每分钟最多记录100条无索引SQL
log_throttle_queries_not_using_indexes = 100
# 修改后重启MySQL
# systemctl restart mysqld
🔍 用 mysqldumpslow 分析慢查询日志
# 慢查询日志可能很大,别用 cat 看!用 mysqldumpslow 汇总分析
# 按查询时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 按查询次数排序(找到最频繁的慢SQL)
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 按平均查询时间排序
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
# 常用参数:
# -s t 按总时间排序
# -s c 按次数排序
# -s at 按平均时间排序
# -t 10 只显示前10条
# -g "pattern" 只看匹配的SQL(类似grep)
输出示例:Count: 152 Time=3.5s Lock=0.01s Rows=1000: SELECT * FROM order_info WHERE status='S'
→ 这条SQL执行了152次,平均3.5秒,就是需要优化的目标!
🔄 慢查询排查完整流程
开启 慢查询日志
→
mysqldumpslow 找到Top慢SQL
→
EXPLAIN 看执行计划
→
发现问题 ALL/无索引/filesort
→
建索引/改SQL 优化解决
↑