📖 相关阅读 — MySQL 专题系列

本文聚焦线上DB+缓存问题排查故事(MySQL死锁、连接池耗尽、Redis缓存等),与以下文件有话题重叠但角度不同:

后端数据库与缓存生产问题排查纪实

7个线上实战故事 — 像在旁边看老手排错一样

MySQL死锁 连接池耗尽 慢查询 缓存穿透 缓存击穿 缓存雪崩 Redis热/大Key

每个故事都是真实生产环境的还原。你会看到告警、排查、定位、修复的全过程。

场景 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%。
💬 oncall 值班群 (5)
—— 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 部分

account_prod @ rm-xxxxx.mysql.rds.aliyuncs.com
📁 account_prod
📋 account_balance
📋 transfer_log
📋 user_info
SHOW ENGINE INNODB STATUS;
... LATEST DETECTED DEADLOCK ------------------------ 2026-05-20 10:15:33 0x7f8a3c2b4700 *** (1) TRANSACTION: TRANSACTION 23456789, ACTIVE 2 sec starting index read mysql tables in use 1, locked 1, lock struct 2, heap size 1136 3 row locks, undo log entries 1 MySQL thread id 12345, OS thread handle 140228945512832, query id 9876543 localhost 10.0.1.5 account_svc UPDATE account_balance SET balance = balance - 100 WHERE account_id = 'A001' *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `account_prod`.`account_balance` trx id 23456789 lock_mode X locks rec but not gap Record lock, heap no 3 PHYSICAL RECORD: account_id='B002' *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `account_prod`.`account_balance` trx id 23456789 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: account_id='A001' *** (2) TRANSACTION: TRANSACTION 23456790, ACTIVE 1 sec starting index read mysql tables in use 1, locked 1, lock struct 2, heap size 1136 3 row locks, undo log entries 1 MySQL thread id 12346, OS thread handle 140228937120026, query id 9876544 localhost 10.0.1.6 account_svc UPDATE account_balance SET balance = balance + 100 WHERE account_id = 'B002' *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `account_prod`.`account_balance` trx id 23456790 lock_mode X locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: account_id='A001' *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `account_prod`.`account_balance` trx id 23456790 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: account_id='B002' *** WE ROLL BACK TRANSACTION (2)
看到了!事务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
→ 等锁 →
🔄 环形等待
→ 死锁!
3
第三步:查看代码,找到顺序不一致的根源
AccountService.java — account-service
TransferOutService.java
TransferInService.java
TransferOutService
TransferInService
📄 AccountMapper
📁 config/
📁 controller/
1package com.example.account.service;
2
3import org.springframework.stereotype.Service;
4import org.springframework.transaction.annotation.Transactional;
5import lombok.RequiredArgsConstructor;
6
7@Service
8@RequiredArgsConstructor
9public 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/
1package com.example.account.service;
2
3import org.springframework.stereotype.Service;
4import org.springframework.transaction.annotation.Transactional;
5import lombok.RequiredArgsConstructor;
6
7@Service
8@RequiredArgsConstructor
9public 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含义相反,实际加锁顺序相反,形成环形等待。
4
第四步:修复代码
AccountService.java — 修复后 ✓
TransferService.java
TransferService
DeadlockRetryAspect
📄 AccountMapper
1package com.example.account.service;
2
3@Service
4@RequiredArgsConstructor
5public 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
3public 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
💬 oncall 值班群 (5)
—— 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
1spring:
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
2
第二步:看 Grafana 连接池监控曲线
order-service / HikariCP Connection Pool 2026-05-22 15:30 → 15:50
Active Connections 20 / 20
max=20
Active Max Pool
20
Active Conn
0
Idle Conn
237
Pending Threads
20个连接全部active,0个idle,还有237个线程在等待获取连接!这就是连接池耗尽。但数据库侧连接数正常,说明不是数据库慢导致连接用完,而是应用侧有连接泄露——拿到了连接但没有归还。
3
第三步:jstack 查看线程等待情况
$ 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"
4
第四步:找到未关闭连接的代码
ReportExportService.java — order-service
ReportExportService.java
ReportExportService
OrderService
📄 OrderMapper
1@Service
2public 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个连接很快就耗尽了!
# HikariCP 连接泄露检测日志(leak-detection-threshold=60000) 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) ... # 这个连接已经持有超过60秒了!确认是泄露
🔴 根因定位:ReportExportService 手动获取 JDBC Connection,但 try-catch 中没有 finally 块来关闭连接。异常发生时连接泄露,连接池逐渐耗尽。
ReportExportService.java — 修复后 ✓
1@Service
2public 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,日志中会报告泄露的连接
✅ HikariCP 推荐配置
# application-prod.yml — HikariCP 连接池配置
spring:
  datasource:
    hikari:
      maximum-pool-size: 30 # 最大连接数(修复前=20)
      minimum-idle: 10 # 最小空闲连接
      connection-timeout: 30000 # 获取连接超时30s
      idle-timeout: 600000 # 空闲超时10min
      max-lifetime: 1800000 # 连接最大存活30min
      leak-detection-threshold: 60000 # 泄露检测60s
      connection-test-query: SELECT 1 # 连接有效性检测

场景 3
慢查询 — 索引失效,从3秒变成60秒

场景:运营后台导出越来越慢,从3秒变成了60秒,用户一直在等

💬 运营支持群 (8)
—— 2026-05-25 11:20 ——
运营-小美
后台导出越来越慢了,之前3秒就好,现在要等1分钟,转圈转得我头晕
后端-小陈
数据量增长了吧?我看看SQL执行计划
运营-小美
每次导出订单列表,筛选条件是订单状态+商品ID+日期范围
场景:运营后台的订单导出功能,随着数据量从10万增长到200万,导出时间从3秒逐渐变成60秒。用户反馈越来越难以接受。
📋 表结构 order_info
DESC order_info;
FieldTypeNullKeyDefaultExtra
idbigint(20)NOPRINULLauto_increment
user_idbigint(20)NOMULNULL
product_idbigint(20)NOMULNULL
amountdecimal(10,2)NONULL
statusvarchar(20)NOMULNULL
create_timedatetimeNOMULNULL
❌ 问题SQL — 运营后台导出
SELECT * FROM order_info
WHERE status = 'PAID'
  AND DATE(create_time) = '2026-05-24'
  AND product_id LIKE '%138%'
ORDER BY create_time DESC
LIMIT 1000;
1
第一步:用 EXPLAIN 分析 SQL 执行计划
order_prod @ rm-xxxxx.mysql.rds.aliyuncs.com
📁 order_prod
📋 order_info
📋 order_items
📋 users
EXPLAIN SELECT * FROM order_info
WHERE status = 'PAID'
  AND DATE(create_time) = '2026-05-24'
  AND product_id LIKE '%138%'
ORDER BY create_time DESC
LIMIT 1000;
idselect_typetabletypepossible_keyskeykey_lenrowsExtra
1 SIMPLE order_info ALL idx_status,idx_create_time NULL NULL 1,987,432 Using where; Using filesort
type=ALL 全表扫描!possible_keys 有 idx_status 和 idx_create_time,但 key=NULL,说明一个索引都没用上!为什么?看一下这个SQL…… DATE(create_time) 对列用了函数,索引失效了!product_id LIKE '%138%' 前置百分号,索引也失效了!
2
第二步:查看表结构和已有索引
SHOW INDEX FROM order_info;
TableNon_uniqueKey_nameSeq_in_indexColumn_nameCollation
order_info0PRIMARY1idA
order_info1idx_status1statusA
order_info1idx_create_time1create_timeA
order_info1idx_product_id1product_idA
索引存在但没被使用!三个索引失效原因同时出现
索引失效原因分析:
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>
4
第四步:添加复合索引
✅ 创建复合索引
-- 创建复合索引:status + create_time
CREATE INDEX idx_status_create_time ON order_info (status, create_time);
为什么选 (status, create_time) 而不是单列索引?

1. 复合索引遵循最左前缀原则:先用 status 等值过滤,再用 create_time 范围扫描

2. 索引下推(ICP):MySQL 5.6+ 可以在索引层面过滤,减少回表次数

3. 覆盖 ORDER BY create_time DESC,避免额外 filesort

5
第五步:优化SQL + 添加复合索引后,再看 EXPLAIN
✅ 优化后的SQL
-- 优化:去掉DATE()函数,改为范围查询;去掉前置%
SELECT * FROM order_info
WHERE status = 'PAID'
  AND create_time >= '2026-05-24 00:00:00'
  AND create_time < '2026-05-25 00:00:00'
  AND product_id LIKE '138%'
ORDER BY create_time DESC
LIMIT 1000;
✅ 优化后 EXPLAIN
EXPLAIN SELECT * FROM order_info
WHERE status = 'PAID'
  AND create_time >= '2026-05-24 00:00:00'
  AND create_time < '2026-05-25 00:00:00'
  AND product_id LIKE '138%'
ORDER BY create_time DESC
LIMIT 1000;
idselect_typetabletypepossible_keyskeykey_lenrowsExtra
1 SIMPLE order_info range idx_status_create_time idx_status_create_time 27 8,432 Using index condition
优化效果对比
1,987,432
优化前扫描行数
8,432
优化后扫描行数
=
236x
性能提升
✅ 修复方案

场景 4
缓存穿透 — 恶意请求不存在的ID,全部打到数据库

场景:有人恶意用大量不存在的ID请求接口,缓存没命中全部打到数据库,数据库CPU飙升

🚨 P1 告警 — 数据库CPU飙升
MySQL CPU使用率飙升至 92%,Redis hit rate 从 95% 降到 23%。数据库慢查询数量暴增。
💬 oncall 值班群 (5)
—— 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,每次请求都穿透到数据库。
1
第一步:查看 Redis 监控
127.0.0.1:6379> INFO stats # 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) # 全部 miss!这些ID根本不存在
Redis + MySQL Monitoring 2026-05-28 02:00 → 02:30
Redis Hit Rate 23%
95%
Hit Rate Miss Rate
MySQL QPS 8,932
QPS CPU
2
第二步:查看缓存逻辑代码
ProductService.java — product-service
1@Service
2public 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值做缓存。
3
第三步:用 Redis CLI 复现穿透场景
# ❌ 当前逻辑:查询不存在的ID,Redis miss后直接打DB 127.0.0.1:6379> GET product:9999999 (nil) # ← Redis没有,去查DB # DB查询结果也为null,但没有缓存这个null # 下次同样的请求还会穿透到DB! 127.0.0.1:6379> GET product:9999999 (nil) # ← 又miss了,又打DB!无限循环
4
第四步:修复 — 缓存null值 + 布隆过滤器
# ✅ 修复1:缓存null值,短TTL防止长期占用内存 127.0.0.1:6379> SET product:9999999 "NULL" EX 60 OK # 再次请求同一ID,直接命中null缓存,不再打DB 127.0.0.1:6379> GET product:9999999 "NULL" # ← 命中缓存!不再穿透到DB ✅ # 60秒后自动过期,如果确实是新商品可以重新查询 127.0.0.1:6379> TTL product:9999999 (integer) 47 # ✅ 修复2:用 SETNX 做互斥锁,防止缓存重建时并发穿透 127.0.0.1:6379> SET lock:product:12345 "1" NX EX 10 OK # ← 获取锁成功,只有一个线程去查DB # 查完DB后释放锁 127.0.0.1:6379> DEL lock:product:12345 (integer) 1
ProductService.java — 修复后 ✓
1@Service
2public 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}
✅ 修复方案

场景 5
缓存击穿 — 热点Key过期瞬间,大量并发打到数据库

场景:某个热点Key过期瞬间,大量并发请求同时打到数据库

🚨 P0 告警 — 数据库QPS暴增
服务 product-service 数据库QPS在 14:00:03 突然飙升至平时10倍(从 500 飙到 5000+),MySQL CPU 85%。
💬 oncall 值班群 (5)
—— 2026-06-01 14:02 ——
运维-老王
数据库QPS突然飙到5000!CPU 85%,但总请求数没变啊
后端-小陈
总请求没变但DB QPS暴增,这是缓存击穿吧?某个热点Key过期了?
场景:下午2点整,某个热门商品的缓存Key刚好过期。这个商品是秒杀活动的主推商品,页面每秒有上千次请求。Key过期的瞬间,所有并发请求同时发现缓存miss,全部涌向数据库。
1
第一步:看 Grafana 监控曲线
product-service / DB QPS Spike 2026-06-01 13:55 → 14:10
Database QPS 5,234
14:00:03 Key过期
DB QPS CPU
10x
QPS倍数
85%
DB CPU
2
第二步:查看 Redis Key 的 TTL
127.0.0.1:6379> TTL product:hot:P10086 (integer) -2 # -2 表示key不存在(已过期) 127.0.0.1:6379> GET product:hot:P10086 (nil) 127.0.0.1:6379> HISTORY GET product:hot:P10086 # 这个Key之前设置了TTL=3600(1小时),13:00写入,14:00刚好过期 127.0.0.1:6379> SCARD hot_keys (integer) 3 # P10086 是秒杀活动的主推商品,QPS极高
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过期时大量并发同时穿透
2public 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 验证互斥锁机制
# ❌ 当前状态:热点Key已过期 127.0.0.1:6379> TTL product:hot:P10086 (integer) -2 # 已过期 127.0.0.1:6379> GET product:hot:P10086 (nil) # ✅ 线程A 先用 SETNX 获取锁 127.0.0.1:6379> SET lock:product:hot:P10086 "thread-A" NX EX 10 OK # ← 获取锁成功!线程A去查DB # ❌ 线程B 也尝试 SETNX 127.0.0.1:6379> SET lock:product:hot:P10086 "thread-B" NX EX 10 (nil) # ← 获取锁失败!线程B等待重试 # ✅ 线程A 查完DB后重建缓存 127.0.0.1:6379> SET product:hot:P10086 '{"id":"P10086","name":"秒杀商品","price":99.9}' EX 3600 OK # ✅ 线程A 释放锁 127.0.0.1:6379> DEL lock:product:hot:P10086 (integer) 1 # ✅ 线程B 重试时发现缓存已重建 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逻辑过期
2public 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}
✅ 修复方案

场景 6
缓存雪崩 — 大量Key同时过期,系统瞬间不可用

场景:凌晨2点大量Key同时过期,数据库瞬间被打爆,整个系统不可用

🚨 P0 告警 — 系统不可用
凌晨 02:00 开始,product-serviceorder-serviceuser-service 同时报数据库连接超时,整体可用性降至 12%。Redis hit rate 从 92% 降到 15%。
💬 oncall 值班群 (7)
—— 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 同时异常
System Overview / Cache Avalanche 2026-06-05 01:50 → 02:15
Redis Hit Rate 15%
02:00
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 分布分析:
TTL=-2
892 keys (已过期)
TTL=86400
0 keys
TTL=其他
42 keys

⚠ 几乎所有Key都是相同的TTL=86400,昨晚2点写入的,今天凌晨2点同时过期!

3
第三步:找到批量设置TTL的代码
DataRefreshJob.java — product-service
1@Component
2public 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打散过期时间
# ❌ 修复前:所有Key同一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 # ↑ 全部86400秒,明天同一时刻一起过期! # ✅ 修复后:基础TTL + 随机偏移,打散过期时间 127.0.0.1:6379> SET product:1001 '{"name":"商品A"}' EX 86400 # TTL=86400 127.0.0.1:6379> SET product:1002 '{"name":"商品B"}' EX 88234 # TTL=86400+1834 127.0.0.1:6379> SET product:1003 '{"name":"商品C"}' EX 87521 # TTL=86400+1121 127.0.0.1:6379> SET product:1004 '{"name":"商品D"}' EX 89102 # TTL=86400+2702 127.0.0.1:6379> SET product:1005 '{"name":"商品E"}' EX 86301 # TTL=86400-99 # ↑ 过期时间分散在1小时内,不会同时失效 ✅ # 验证TTL分布 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 # ✅ TTL各不相同,过期时间被成功打散!
DataRefreshJob.java — 修复后 ✓
1// ✅ 修复:TTL加随机偏移,打散过期时间
2@Scheduled(cron = "0 0 2 * * ?")
3public 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}
✅ 修复方案

📌 补充:缓存与数据库一致性

面试高频题:先更新数据库还是先更新缓存?
答案:都不对!应该用 Cache Aside Pattern(旁路缓存模式)
方案操作顺序问题推荐
先更新DB再更新缓存DB→Cache并发下可能旧值覆盖新值
先更新缓存再更新DBCache→DBDB失败时缓存是脏数据
先更新DB再删缓存(Cache Aside)DB→DEL Cache极端并发下仍可能短暂不一致✅ 推荐
延迟双删DEL→DB→sleep→DELsleep时间难确定⚠️ 可选
Cache Aside Pattern — Spring Boot 实现
1// ✅ Cache Aside Pattern — Spring Boot 实现
2@Service
3public 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 响应变慢。
💬 oncall 值班群 (5)
—— 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
3
第三步:查看往Hash里无限put的代码
UserActivityService.java — activity-service
1@Service
2public 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
2public 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个
✅ 修复方案

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
优化解决