📖 相关阅读 — MySQL 专题系列
本文以16个后端问题 驱动(NPE/OOM/线程死锁/缓存穿透等),MySQL只是其中一部分。如需深入MySQL专题,参见:
07-mysql-principles.html — MySQL 原理全景(最全面的理论篇:存储引擎、B+树、事务、锁、Buffer Pool)
08-mysql-problem-driven.html — MySQL 问题实战手册(15个MySQL专属问题,比本文MySQL部分更深入)
04-backend-db-cache-issues.html — DB+缓存问题排查纪实(线上故事:MySQL死锁、连接池、Redis缓存)
05-sql-troubleshooting-stories.html — SQL 问题排查全纪实(专注SQL索引失效、EXPLAIN、SQL优化)
学习路径 1. 理解概念 类比+图解 2. 看代码 IDE窗口+实战 3. 动手 踩坑+FAQ 三步学习法:概念→代码→实战
线上告警 日志排查 空安全
🕐 下午 14:23,你正在工位写代码,突然钉钉告警群弹出消息:
🤖
监控机器人
🚨 [P0] OrderService.getDetail 出现 NullPointerException 最近 5 分钟累计 23 次 影响接口:/api/order/detail TraceId: a3f8e2c1-...
这是最常见的异常了。问题是——在订单系统里,哪个环节的空指针?用户查订单详情时崩了,说明某个对象为 null。
🔍 第一步:看日志
IntelliJ IDEA — Log Console
ERROR [2026-06-11 14:23:15] [http-nio-8080-exec-12] c.m.s.OrderServiceImpl - 订单详情查询异常
java.lang.NullPointerException: null
at com.milktea.service.OrderServiceImpl.getDetail(OrderServiceImpl.java:87)
at com.milktea.service.OrderServiceImpl$$FastClassBySpringCGLIB$$xxx.invoke()
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
... 32 common frames omitted
堆栈指向 OrderServiceImpl.java 第87行,打开看看!
🔍 第二步:看代码
IntelliJ IDEA — OrderServiceImpl.java
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserService userService;
public OrderVO getDetail (Long orderId) {
// 第85行:从数据库查订单
Order order = orderMapper.selectById(orderId);
// 第87行:用order对象取userId 💥 NPE!
User user = userService.getById(order.getUserId());
// ... 组装返回
}
}
问题定位: 第85行 orderMapper.selectById(orderId) 查不到数据,返回了 null。第87行直接调 order.getUserId() 就炸了。可能原因:① orderId 不存在 ② 已被删除 ③ 缓存和DB不一致
🔍 第三步:验证假设
mysql> SELECT * FROM order_info WHERE id = 20260611001;
Empty set (0.00 sec)
mysql> SELECT * FROM order_info WHERE id = 20260611001 AND deleted = 1;
+-------------+---------+---------+-----+
| id | user_id | status | del |
+-------------+---------+---------+-----+
| 20260611001 | 10086 | CLOSED | 1 |
+-------------+---------+---------+-----+
找到了!订单已被软删除(deleted=1),但selectById加了逻辑删除条件,查不到返回null,然后直接取属性就NPE了。
📖 原理:为什么NPE这么常见?
1. Java 引用类型的默认值是 null
对象引用不指向任何实例时就是 null。调用 null 的方法/属性 → JVM 抛出 NullPointerException。
2. 常见NPE场景
场景 原因 代码例子
查DB返回null 数据库中不存在该记录 Order o = mapper.selectById(id); o.getPrice()
Map.get返回null key不存在于Map中 map.get("category").toString()
链式调用中间为null 前置对象为null,后续调用必然NPE order.getUser().getName()
自动拆箱null 包装类型为null,拆箱时NPE int price = order.getPrice(); // getPrice返回null
方法参数为null 调用方传了null,方法内直接使用 public void process(String name){ name.length() }
✅ 第四步:修复
IntelliJ IDEA — OrderServiceImpl.java (修复后)
public OrderVO getDetail (Long orderId) {
Order order = orderMapper.selectById(orderId);
// ✅ 加上空检查
if (order == null ) {
throw new BizException ("订单不存在或已取消" );
}
User user = userService.getById(order.getUserId());
// Optional 防止user也为null
String userName = Optional.ofNullable(user)
.map(User::getName).orElse("未知用户" );
// ... 组装返回
}
✅ 验证结果
Postman — GET /api/order/detail?orderId=20260611001
Status: 200 OK
{
"code": 404,
"message": "订单不存在或已取消",
"data": null
}
不再报500,而是返回友好的业务提示。告警群恢复平静 ✅
线上告警 jmap/MAT JVM内存模型
🕐 晚上 21:15,你正准备下班,手机突然震动——Grafana 告警面板亮红:
Grafana — JVM Memory Dashboard
⚠️ Heap Memory Usage: 95.2% — 触发阈值 (85%)
服务: milktea-order-service | 实例: 10.0.1.23 | 时间: 21:14:33
内存飙到95%,不是正常的GC波动。得赶紧看是什么对象把堆撑爆了。先看看GC日志——
[root@order-23 ~]# tail -100 /data/logs/milktea-order/error.log | grep -i "OutOfMemory"
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.ArrayList.grow(ArrayList.java:262)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:244)
at java.util.ArrayList.add(ArrayList.java:467)
at com.milktea.service.ReportService.generateOrderReport(ReportService.java:52)
[root@order-23 ~]# ls -lh /data/logs/*.hprof
-rw------- 1 root root 1.2G Jun 11 21:14 java_pid28456.hprof
找到了!ReportService.generateOrderReport() 在第52行发生了OOM,ArrayList不断add导致内存撑爆。
🔍 看代码
IntelliJ IDEA — ReportService.java
public byte[] generateOrderReport (String date) {
// 第48行:查出当天所有订单
List<Order> orders = orderMapper.selectList(
new QueryWrapper<Order>().eq("create_date" , date));
// 第51-52行:把每个订单转成VO,全部加到list 💥 OOM!
List<OrderReportVO> vos = new ArrayList<>();
for (Order order : orders) {
OrderReportVO vo = convertToVO(order); // 每个VO约2KB
vos.add(vo); // 💥 50万订单 × 2KB = 1GB!
}
return exportToExcel(vos);
}
问题: 当天是618大促,订单量暴增到50万条。一次性查出来全部加载到内存,每个OrderReportVO约2KB,50万×2KB≈1GB,堆直接爆了!
📖 原理:JVM 内存模型 & OOM
1. JVM 堆内存结构
JVM 堆
Young Gen (年轻代)
→
Old Gen (老年代)
Eden
Survivor0
Survivor1
→
Old Gen
新对象在Eden分配 → GC后存活进入Survivor → 淘汰多次后进入Old Gen
大对象直接进Old Gen → 老年代放不下 → OutOfMemoryError: Java heap space
2. 内存区域分工
- Young Gen = 新对象分配区(GC频繁,存活率低)
- Old Gen = 长期存活对象存储区(GC少,但容量有限)
- OOM = 老年代满了,无法再分配新对象 💥
3. 常见OOM场景
- 一次性查太多数据加载到内存(本次情况)
- 内存泄漏:对象被引用无法GC(static List不断add)
- ThreadLocal未清理:线程池复用导致ThreadLocal堆积
- 大文件操作:整个文件读入内存
✅ 修复方案:分批查询 + 流式写入
IntelliJ IDEA — ReportService.java (修复后)
public void generateOrderReport (String date, OutputStream out) {
// ✅ 用 EasyExcel 流式写入,不在内存中持有全部数据
ExcelWriter writer = EasyExcel.write(out, OrderReportVO.class).build();
WriteSheet sheet = EasyExcel.writerSheet("订单报表" ).build();
// ✅ 分批查询,每次1000条
int pageSize = 1000;
int pageNum = 1;
while (true ) {
Page<Order> page = orderMapper.selectPage(
new Page<>(pageNum, pageSize) ,
new QueryWrapper<Order>().eq("create_date" , date));
List<OrderReportVO> batch = page.getRecords().stream()
.map(this::convertToVO).collect(toList());
writer.write(batch, sheet); // 写完一批,内存就释放
if (!page.hasNext()) break ;
pageNum++;
}
writer.finish();
}
✅ 验证结果
修复前: 50万订单 → 一次性加载 → 1GB内存 → OOM 💥
修复后: 50万订单 → 每次查1000条 → 流式写入Excel → 内存峰值仅30MB ✅
Grafana 内存曲线恢复正常,报表导出不再报错。
线上告警 top/jstack/Arthas 线程调度
🕐 上午 10:05,你刚到公司泡了杯技术,运维同学就在群里喊了:
🔧
运维-老王
兄弟们,milktea-order 的 CPU 突然飙到 99%,接口全部超时了,能看下吗?
[root@order-23 ~]# top -c
PID USER PR NI VIRT RES %CPU %MEM COMMAND
28456 root 20 0 4.2g 1.8g 99.7 22.5 java -jar milktea-order.jar
1234 root 20 0 2.1g 800m 2.3 9.8 java -jar milktea-gateway.jar
[root@order-23 ~]# top -Hp 28456
PID USER %CPU COMMAND
28501 root 98.2 http-nio-8080-exec-8
28502 root 0.3 http-nio-8080-exec-9
28460 root 0.1 VM Thread
[root@order-23 ~]# printf "%x\n" 28501
6f55
[root@order-23 ~]# jstack 28456 | grep -A 30 "6f55"
"http-nio-8080-exec-8" #52 daemon prio=5 os_prio=0 cpu=89234ms elapsed=432s
java.lang.Thread.State: RUNNABLE
at com.milktea.util.PriceCalculator.calculateDiscount(PriceCalculator.java:35)
at com.milktea.service.OrderService.applyCoupon(OrderService.java:78)
线程一直在 PriceCalculator.java:35 运行,99% CPU占用,疑似死循环!
IntelliJ IDEA — PriceCalculator.java
public static BigDecimal calculateDiscount (BigDecimal price, BigDecimal discount) {
BigDecimal result = price;
// 第33行:不断减去折扣,直到result小于折扣
while (result.compareTo(discount) >= 0) { // 💥 问题!
result = result.subtract(discount);
}
return result;
}
问题: 当 discount = 0 时(没有优惠),while 条件永远为 true → 死循环!
技术原理:BigDecimal 的 subtract(0) 永远不改变原值,compareTo(0) >= 0 永远为 true,循环条件无法被打破——纯数值层面无法收敛。
📖 原理:为什么死循环会吃满CPU?
1. CPU 是时间片轮转调度
操作系统给每个线程分配时间片(通常10ms),线程在时间片内执行代码。正常线程执行完会sleep/wait/yield,让出CPU。
但死循环中的线程永远不会主动让出CPU → 操作系统不断给它分配时间片 → CPU 100%。
2. 排查CPU高的标准流程
top 找进程
→
top -Hp 找线程
→
printf %x 转十六进制
→
jstack 看线程堆栈
→
定位代码行
3. Arthas 更简单的方式(推荐)
thread -n 3 → 直接显示最忙的3个线程和堆栈
✅ 修复
PriceCalculator.java (修复后)
public static BigDecimal calculateDiscount (BigDecimal price, BigDecimal discount) {
// ✅ 加上边界检查
if (discount == null || discount.compareTo(BigDecimal.ZERO) <= 0) {
return price;
}
BigDecimal result = price;
while (result.compareTo(discount) >= 0) {
result = result.subtract(discount);
}
return result;
}
✅ 验证结果
修复后 discount=0 直接返回原价,不再进入循环。
[root@order-23 ~]# top -c | grep milktea-order
28456 root 20 0 4.2g 1.8g 3.2% 22.5 java -jar milktea-order.jar
CPU 从 99.7% 降到 3.2%,服务恢复正常 ✅
功能不可用 jstack/Arthas 锁机制
🕐 下午 15:30,客服反馈用户无法同时进行"下单"和"取消订单"操作,一直转圈。
[root@order-23 ~]# jstack 28456 | grep -A 5 "Found one Java-level deadlock"
Found one Java-level deadlock:
=============================
"http-nio-8080-exec-3":
waiting to lock monitor 0x00007f8a4000a088 (object 0x000000076b37a0e0, a com.milktea.service.InventoryService),
which is held by "http-nio-8080-exec-5"
"http-nio-8080-exec-5":
waiting to lock monitor 0x00007f8a4000b100 (object 0x000000076b37a1f0, a com.milktea.service.OrderService),
which is held by "http-nio-8080-exec-3"
死锁了!线程A持有OrderService锁,等InventoryService锁;线程B持有InventoryService锁,等OrderService锁。两人互不相让!
线程A (下单)
⇄
线程B (取消)
✅ 持有 OrderService 锁
→
等待
→
🔒 InventoryService 锁
🔒 InventoryService 锁
←
等待
←
✅ 持有 OrderService 锁
技术原理:线程A 持有 OrderService 锁的同时请求 InventoryService 锁,线程B 持有 InventoryService 锁的同时请求 OrderService 锁 → 两个线程各持一把锁并互等对方释放 → 永久阻塞
📖 原理:死锁的四个必要条件
条件 技术原理 代码中
互斥 资源同一时刻只允许一个线程访问 synchronized 同一时刻只有一个线程
持有并等待 已持有一个资源,再去请求另一个资源 已获取锁A,又请求锁B
不可剥夺 资源不能被强制夺走,只能由持有者主动释放 线程不能强制释放别人的锁
循环等待 多个线程间形成首尾相接的等待环 线程A等线程B的锁,线程B等线程A的锁
✅ 修复:统一锁顺序 + 缩小锁范围
// ✅ 修复方案:所有需要同时锁 Order 和 Inventory 的地方,统一先锁 Order 再锁 Inventory
// 这样就不会出现 A先锁Order B先锁Inventory 的情况
public void placeOrder (OrderRequest req) {
synchronized (orderService) { // 先锁 Order
synchronized (inventoryService) { // 再锁 Inventory
// 业务逻辑
}
}
}
public void cancelOrder (Long orderId) {
synchronized (orderService) { // 也先锁 Order
synchronized (inventoryService) { // 再锁 Inventory
// 业务逻辑
}
}
}
✅ 验证
统一锁顺序后,死锁条件(循环等待)被打破。下单和取消操作可正常并发执行。✅
补充:生产环境推荐用数据库行锁代替 synchronized,或用分布式锁(Redis/Redisson)。
⚠️ 警告:synchronized 在 Spring Bean 上的 3 个问题
1. 单例锁 :Spring Bean 默认单例,synchronized锁的是整个对象,所有请求排队
2. 集群无效 :多台服务器各锁各的,分布式场景下 synchronized 完全失效
3. 死锁风险 :多个synchronized方法互相调用容易死锁
正确方案 :用数据库行锁(SELECT FOR UPDATE)或分布式锁(Redis SETNX / Redisson)
用户体验差 Arthas/日志 线程池+超时
🕐 周一上午,用户投诉小程序点菜页面打开要 10 秒。后台日志显示 /api/order/create 接口 RT(响应时间)飙到 8-12 秒。
Grafana — API Response Time
/api/order/create
P99: 12.3s
P95: 8.7s
⚠️ 最近30分钟RT持续上升
🔍 排查:加链路追踪日志
IntelliJ IDEA — OrderServiceImpl.java
public OrderResult createOrder (OrderRequest req) {
log.info("开始创建订单" );
// ① 检查库存
inventoryService.check(req.getProductId()); // ~50ms
// ② 调微信支付预下单接口 ⚠️
PayResult pay = wechatPayService.prepay(req); // 💥 这里慢!
// ③ 创建订单
orderMapper.insert(order);
}
[root@order-23 ~]# grep "TraceId:a3f8e2c1" /data/logs/milktea-order/info.log
14:23:15.001 [exec-3] 开始创建订单
14:23:15.052 [exec-3] 库存检查完成,耗时: 51ms
14:23:23.158 [exec-3] 微信预下单完成,耗时: 8106ms ← 就是它!
14:23:23.210 [exec-3] 订单创建完成,耗时: 52ms
总耗时: 8211ms
微信支付预下单接口耗时 8 秒!我们自己的逻辑才 100ms,全卡在第三方了。
📖 原理:同步调用的陷阱
1. 同步阻塞
当前代码在主线程直接调用微信支付接口,如果微信那边慢(网络抖动/限流),整个请求线程就被卡住了。
Tomcat 线程池默认200个线程,如果并发200个请求都卡在微信支付上 → 所有线程耗尽 → 服务假死!
2. 同步阻塞的本质
当前线程在调用方同步等待第三方响应,如果第三方慢(网络抖动/限流),当前线程被挂起阻塞。Tomcat 线程池默认200个线程,并发200个请求全部卡在第三方调用上 → 线程池耗尽 → 服务无法处理新请求。
正确做法:先向支付方发起预下单拿到预支付单号,立即返回给前端;前端拉起付款界面,用户自己完成支付;支付成功后支付方通过回调接口通知后端,后端再正式更新订单状态。
✅ 修复:超时控制 + 支付回调模式
// ✅ 1. 给HTTP客户端加超时(防线1:防止无限阻塞)
@Bean
public RestTemplate restTemplate () {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 连接超时 3s
factory.setReadTimeout(5000); // 读取超时 5s
return new RestTemplate(factory);
}
// ✅ 2. 正确的支付流程:预下单→返回支付URL→前端跳转→回调确认→创建业务订单
@Service
public class OrderServiceImpl {
// 第一步:只做预下单,不创建业务订单
public PrepayResult createPrepay (OrderRequest req) {
inventoryService.check(req.getProductId()); // 检查库存
try {
String payUrl = wechatPayService.prepay(req); // 拿到支付链接
return PrepayResult.ok(payUrl); // 返回给前端跳转支付
} catch (ResourceAccessException e) {
// ⚠️ 超时降级:提示用户稍后重试或走线下支付
log.warn("微信支付超时,走降级路径" , e);
return PrepayResult.degrade("支付处理中,请稍后在订单列表重试" );
}
}
// 第二步:微信支付成功回调,此时才创建业务订单
@Transactional
public void onPayCallback (PayCallbackRequest cb) {
if (cb.isSuccess()) {
orderMapper.insert(buildOrder(cb)); // ✅ 支付成功才创建订单
}
}
}
✅ 验证
超时控制 :3s连接+5s读取超时,微信支付慢则快速失败,不会无限阻塞Tomcat线程。 ✅
流程修正 :预下单只拿支付URL,不创建业务订单;支付回调确认成功后才创建业务订单,避免产生大量未支付订单。 ✅
降级策略 :支付超时时返回"支付处理中"状态,前端可轮询或引导用户稍后重试。 ✅
数据错误 日志分析 Spring事务
🕐 测试同学发现:创建订单失败后,库存扣了但订单没生成——扣了库存没退回来!
IntelliJ IDEA — OrderServiceImpl.java
@Transactional
public void createOrder (OrderRequest req) {
// ① 扣库存
inventoryMapper.decrement(req.getProductId(), req.getQuantity());
// ② 创建订单
try {
orderMapper.insert(buildOrder(req));
} catch (DuplicateKeyException e) {
log.error("订单重复" , e); // 💥 吞掉了异常!
}
}
问题: @Transactional 默认只对 RuntimeException 和 Error 回滚。但这里 catch 了异常并吞掉了,Spring 以为方法正常执行完毕,就不回滚了!
技术原理:异常被 catch 后未向上抛出,Spring AOP 代理监控不到异常,认为方法正常返回,事务提交,但实际业务数据已经不一致。
📖 原理:Spring @Transactional 失效的常见场景
场景 原因 技术原理
异常被 catch 吞掉 Spring 看不到异常,认为正常结束 代理层无异常可捕获,事务正常提交
抛出 checked 异常 @Transactional 默认只回滚 RuntimeException 需要 rollbackFor=Exception.class 才能覆盖
同类方法调用(this.xxx) 没走代理,@Transactional 不生效 this 引用是目标对象本身,非代理对象
非 public 方法 Spring AOP 只代理 public 方法 非 public 方法无法被 CGLIB/JDK 代理拦截
数据库引擎不支持 MyISAM 不支持事务 表引擎无事务日志,事务指令被忽略
✅ 修复
@Transactional(rollbackFor = Exception.class) // ✅ 所有异常都回滚
public void createOrder (OrderRequest req) {
inventoryMapper.decrement(req.getProductId(), req.getQuantity());
try {
orderMapper.insert(buildOrder(req));
} catch (DuplicateKeyException e) {
log.error("订单重复" , e);
throw new BizException("请勿重复下单" ); // ✅ 重新抛出!
}
}
✅ 验证
重复下单时,事务回滚,库存不会扣除。数据库状态一致。 ✅
DB压力大 监控+日志 缓存策略
🕐 下午茶高峰期 15:00,突然数据库连接数飙到 400(上限500),接口大面积超时。
Grafana — Database Connections
⚠️ Active Connections: 412 / 500
同时Redis命中率:12%(正常应该>95%)
Redis命中率只有12%?说明88%的请求都打到了数据库!这就是典型的缓存穿透。
🔍 看代码逻辑
public Product getProduct (Long productId) {
// 1. 先查缓存
Product cached = redisTemplate.opsForValue().get("product:" + productId);
if (cached != null ) return cached;
// 2. 缓存没有,查数据库
Product product = productMapper.selectById(productId);
if (product != null ) {
redisTemplate.opsForValue().set("product:" + productId, product, 30, TimeUnit.MINUTES);
}
// ⚠️ product == null 时,什么都不做,直接返回null
return product; // 💥 不存在的商品每次都打到DB!
}
问题: 当 productId 不存在时,缓存中没数据 → 每次都查DB → DB也没数据 → 返回null → 下次请求又走DB。
攻击者用脚本遍历 productId = -1, -2, -3 ... → 每个请求都穿透到DB!
📖 原理:缓存三大问题
问题 描述 技术原理
缓存穿透 查不存在的数据,绕过缓存直达DB 缓存和DB都没有该key,每次请求都打到DB层
缓存击穿 热点key过期瞬间,大量请求同时打到DB 缓存失效瞬间没有互斥机制,并发请求全部回源
缓存雪崩 大量key同时过期,DB压力暴增 多个key过期时间集中,DB瞬时压力激增
✅ 修复:缓存空值 + 布隆过滤器
public Product getProduct (Long productId) {
String key = "product:" + productId;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null ) return cached;
// ✅ 检查空值标记
if (redisTemplate.hasKey("product:null:" + productId)) {
return null ; // 直接返回,不查DB
}
Product product = productMapper.selectById(productId);
if (product != null ) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
} else {
// ✅ 缓存空值,短过期时间
redisTemplate.opsForValue().set("product:null:" + productId, "" , 5, TimeUnit.MINUTES);
}
return product;
}
✅ 验证
攻击者再请求 productId=-1 → Redis 有空值标记 → 直接返回 → 不再打到DB。
Redis命中率恢复到 96%,数据库连接数降到 80。✅
运行时异常 日志 序列化机制
🕐 上午 10:30,部署新版本后,读取Redis缓存的商品数据报错。
org.springframework.data.redis.serializer.SerializationException:
Cannot deserialize; nested exception is com.fasterxml.jackson.databind.exc.InvalidTypeIdException:
Missing type id when trying to resolve subtype of 'com.milktea.dto.ProductDTO':
missing type id property '@class'
at [Source: (byte[])"{"id":1,"name":"无线耳机","price":15}"; line: 1, column: 1]
旧的Redis序列化器用的 GenericJackson2JsonRedisSerializer(带@class类型信息),新版本改成了 Jackson2JsonRedisSerializer(不带)。老数据有@class,新代码读不了。
📖 原理:Java 序列化方式对比
序列化方式 格式 优点 缺点
JDK序列化 二进制 Java原生支持 不可读、体积大、有安全风险
JSON(Jackson) 文本 可读、跨语言 丢失类型信息(反序列化可能搞错类型)
JSON+@class 带类型的JSON 可读+保留类型 类路径变了就反序列化失败
Protobuf 二进制 高性能、体积小 需要定义schema
技术原理:JDK序列化=Java私有二进制格式,跨语言不可读且体积大;JSON=通用文本格式,跨语言可读但丢失类型信息;@class=在JSON中嵌入类型标记,反序列化时能还原为正确类型
✅ 修复
// ✅ 统一序列化配置,兼容新旧数据
@Bean
public RedisTemplate<String, Object> redisTemplate (RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// key 用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
// value 用 GenericJackson2Json(带@class,兼容旧数据)
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
✅ 验证
统一序列化配置后,新旧数据都能正常读写。部署上线,缓存读取恢复正常。✅
启动失败 mvn dependency Maven依赖传递
🕐 新同事 clone 项目后,引入了一个新工具库,启动直接报错。
java.lang.NoSuchMethodError:
'void com.fasterxml.jackson.databind.ObjectMapper.setDefaultPrettyPrinter(com.fasterxml.jackson.core.PrettyPrinter)'
[root@dev ~]# mvn dependency:tree -Dincludes=com.fasterxml.jackson.core
[INFO] +- com.milktea:milktea-common:1.0:compile
| \- com.fasterxml.jackson.core:jackson-databind:2.13.0:compile
[INFO] +- com.new-tool:some-library:1.5:compile
| \- com.fasterxml.jackson.core:jackson-databind:2.10.0:compile ← 冲突!
📖 原理:Maven 依赖传递与冲突解决
1. Maven 依赖传递
A 依赖 B,B 依赖 C → A 自动引入 C(传递依赖)。如果 A 还依赖了 D,D 也依赖了 C 但版本不同 → 冲突!
2. Maven 冲突解决规则
- 最短路径优先 :A→B→C(2.13) vs A→D→E→C(2.10) → 选 2.13
- 最先声明优先 :路径一样长时,谁先声明选谁
3. 冲突的实际后果
依赖 A(milktea-common)引入 jackson 2.13,依赖 B(新工具)引入 jackson 2.10。Maven 按最短路径/最先声明规则只保留一个版本(假设选了 2.10),但 A 编译时用的是 2.13 的 API,运行时找不到对应方法 → NoSuchMethodError!
✅ 修复
// ✅ 在 pom.xml 中排除冲突依赖
<dependency >
<groupId >com.new-tool</groupId >
<artifactId >some-library</artifactId >
<version >1.5</version >
<exclusions >
<exclusion >
<groupId >com.fasterxml.jackson.core</groupId >
<artifactId >jackson-databind</artifactId >
</exclusion >
</exclusions >
</dependency >
✅ 验证
排除冲突后,Maven 统一使用 jackson-databind:2.13.0,项目正常启动。✅
数据重复 日志+DB 幂等设计
🕐 客服反馈:用户说只点了一次下单,但扣了两次款,查数据库确实生成了两个一模一样的订单(间隔2秒)。
mysql> SELECT id, user_id, product_id, amount, create_time FROM order_info WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 5;
+------------+---------+------------+--------+---------------------+
| id | user_id | product_id | amount | create_time |
+------------+---------+------------+--------+---------------------+
| 2026061101 | 10086 | 101 | 15.00 | 2026-06-11 14:30:15 |
| 2026061102 | 10086 | 101 | 15.00 | 2026-06-11 14:30:17 | ← 重复!
+------------+---------+------------+--------+---------------------+
问题: 用户网络慢,点击"下单"后等了2秒没反应,又点了一次。两次请求都到了后端,创建了两个订单,扣了两次款。
📖 原理:什么是幂等性?
幂等性 :同一个操作执行一次和执行多次的效果相同。
核心示例 :
- 幂等操作:GET 查询商品价格 → 调1次和调100次,返回结果都是同一份商品数据
- 非幂等操作:POST 创建订单 → 调1次创建1个订单,调10次创建10个订单!
常见幂等方案 :
1. Token机制 :下单前先申请token → 提交时携带token → 服务端验证并删除token → 重复提交无token直接拒绝
2. 唯一索引 :订单号做唯一索引 → 重复插入报DuplicateKey → catch住返回"请勿重复提交"
3. 分布式锁 :同一用户+同一商品加锁 → 锁内判断是否已有未支付订单
✅ 修复:Token机制
// ✅ 获取Token(页面加载时调用)
@GetMapping("/token")
public String getToken () {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("order:token:" + token, "1" , 10, TimeUnit.MINUTES);
return token;
}
// ✅ 下单时验证Token(原子删除)
@PostMapping("/create")
public Result create (@RequestHeader ("X-Order-Token" ) String token, ...) {
Boolean deleted = redisTemplate.delete("order:token:" + token);
if (deleted == null || !deleted) {
return Result.fail("请勿重复提交" );
}
// 正常下单...
}
✅ 验证
第一次提交:token存在 → 删除成功 → 创建订单。
第二次提交:token已被删除 → 返回"请勿重复提交"。✅
排查困难 logback配置 日志框架
🕐 线上出bug了,但日志里只有一行 ERROR,没有入参、没有上下文,完全不知道用户传了什么参数。
ERROR [2026-06-11 14:30:15] [exec-3] c.m.s.OrderServiceImpl - 订单创建失败
INFO [14:30:14.980] [exec-3] OrderServiceImpl - 收到下单请求: userId=10086, productId=101, quantity=2
INFO [14:30:14.985] [exec-3] OrderServiceImpl - 库存检查通过: productId=101, 剩余库存=50
ERROR [14:30:15.001] [exec-3] OrderServiceImpl - 订单创建失败: userId=10086, productId=101, reason=DuplicateKeyException: ORDER_NO_UK
DEBUG [14:30:15.002] [exec-3] OrderServiceImpl - 事务已回滚
📖 原理:日志规范
规范 技术原理 例子
入参必须打 记录请求上下文,便于复现问题 log.info("收到请求: {}", req)
异常必须带上下文 出问题时需要知道是哪个业务、哪条数据 log.error("下单失败: userId={}, productId={}", userId, productId, e)
不要用e.printStackTrace() 输出流不受日志框架管控,无法归档和检索 printStackTrace走System.err,不受logback管控
关键节点打info 业务关键节点留痕,便于链路追踪 库存扣减、支付成功、订单完成
日志级别要合理 区分信息/警告/错误,避免日志淹没关键信息 DEBUG < INFO < WARN < ERROR
✅ 修复
// ✅ 修复后的代码:关键节点都有日志
public OrderResult createOrder (OrderRequest req) {
log.info("收到下单请求: userId={}, productId={}, quantity={}" ,
req.getUserId(), req.getProductId(), req.getQuantity());
try {
inventoryService.decrement(req.getProductId(), req.getQuantity());
log.info("库存扣减成功: productId={}" , req.getProductId());
Order order = orderMapper.insert(buildOrder(req));
log.info("订单创建成功: orderId={}" , order.getId());
} catch (Exception e) {
log.error("订单创建失败: userId={}, productId={}, error={}" ,
req.getUserId(), req.getProductId(), e.getMessage(), e); // ✅ 带上异常堆栈
}
}
✅ 验证
重新部署后,日志信息完整,下次出问题能快速定位入参和错误原因。✅
启动失败 IDEA Spring IoC
🕐 新建了一个 Service,启动 Spring Boot 时直接报错。
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.milktea.service.ReportServiceImpl
required a bean of type 'com.milktea.mapper.ReportMapper' that could not be found.
Action:
Consider defining a bean of type 'com.milktea.mapper.ReportMapper' in your configuration.
ReportMapper 没有被 Spring 扫描到。最常见的原因:1.没加@Mapper注解 2.MapperScan路径没覆盖到 3.新文件在错误的包下。
📖 原理:Spring Bean 注册与注入
Bean 注册的几种方式 :
1. @Component/@Service/@Repository → 类路径扫描自动注册
2. @Mapper/@MapperScan → MyBatis 的 Mapper 接口注册
3. @Bean → 手动在配置类中注册
4. @Import → 直接导入
核心机制 :
- Bean = 由容器管理的对象实例
- IoC容器 = 对象注册表(BeanDefinition 注册中心)
- @Autowired = "从容器中按类型/名称查找并注入一个实例"
- Bean注入失败 = "容器中找不到对应类型或名称的 BeanDefinition"
常见原因 :① @MapperScan("com.milktea.mapper") 但新Mapper放在了 com.milktea.dao 下 ② 漏加@Mapper注解 ③ 条件注入@ConditionalOnProperty不满足
✅ 修复
// ✅ 方案1:在 ReportMapper 上加 @Mapper
@Mapper
public interface ReportMapper extends BaseMapper<Report> { ... }
// ✅ 方案2:或者检查 @MapperScan 包路径
@MapperScan("com.milktea.**.mapper") // 用通配符覆盖所有mapper包
@SpringBootApplication
public class MilkTeaApplication { ... }
✅ 验证
添加@Mapper注解后,Spring Boot正常启动,Bean注入成功。✅
接口慢 慢查询日志/EXPLAIN 索引优化
🕐 接口RT从200ms升到3s,查慢查询日志发现一条SQL执行了2.8秒。
mysql> SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 1\G
start_time: 2026-06-11 14:30:15
sql_text: SELECT * FROM order_info WHERE status = 'PAID' AND create_time > '2026-06-01' ORDER BY amount DESC LIMIT 100
query_time: 2.834567 sec
rows_examined: 523841
rows_sent: 100
问题: 扫描了52万行只返回100行!status和create_time上没有合适的复合索引,MySQL只能全表扫描。
🔍 EXPLAIN 分析
mysql> EXPLAIN SELECT * FROM order_info WHERE status='PAID' AND create_time > '2026-06-01' ORDER BY amount DESC LIMIT 100;
+----+-------------+------------+------+---------------+------+---------+------+--------+-----------------------------+
| id | select_type | table | type | key | rows | filtered | Extra |
+----+-------------+------------+------+---------------+------+---------+------+--------+-----------------------------+
| 1 | SIMPLE | order_info | ALL | NULL | 523K | 1.11 | Using where; Using filesort |
+----+-------------+------------+------+---------------+------+---------+------+--------+-----------------------------+
✅ 修复:加复合索引
mysql> ALTER TABLE order_info ADD INDEX idx_status_create_time (status, create_time);
Query OK, 0 rows affected (2.31 sec)
mysql> EXPLAIN SELECT * FROM order_info WHERE status='PAID' AND create_time > '2026-06-01' ORDER BY amount DESC LIMIT 100;
| 1 | SIMPLE | order_info | ref | idx_status_create_time | 8230 | 33.33 | Using where; Using filesort |
✅ 验证
加索引后,查询从2.8秒降到0.05秒。接口RT恢复正常。✅
服务不可用 监控+日志 连接池原理
🕐 高峰期接口大面积报错,错误信息是获取不到数据库连接。
2026-06-11 12:30:15 ERROR [exec-8] c.z.hikari.HikariDataSource - HikariPool-1 - Connection is not available, request timed out after 30000ms.
Total connections: 20 | Active: 20 | Idle: 0 | Waiting: 156
问题: 20个连接全被占满,还有156个请求在排队等连接!看看谁占了连接不放——
mysql> SHOW PROCESSLIST;
+----+------+-----------------+------+---------+------+----------+------------------+
| Id | User | Host | db | Command | Time | State | Info |
+----+------+-----------------+------+---------+------+----------+------------------+
| 12 | app | 10.0.1.23:52341 | milk | Query | 0 | starting | SHOW PROCESSLIST |
| 15 | app | 10.0.1.23:52342 | milk | Sleep | 185 | | NULL |
| 16 | app | 10.0.1.23:52343 | milk | Sleep | 192 | | NULL |
| 17 | app | 10.0.1.23:52344 | milk | Sleep | 187 | | NULL |
... (20 rows, 全部 Sleep 180+秒)
所有连接都Sleep了180多秒(3分钟),说明有事务没提交或连接没归还!
📖 原理:数据库连接池
连接池工作原理 :
应用线程
→
连接池(20个)
→
数据库
线程需要连接 → 从池中借一个 → 用完归还 → 其他线程可以复用
如果有人借了不还 → 池子空了 → 后面的人都等着
内存模型 :
连接池 = 一组可复用的数据库连接对象。线程从池中借用 → 用完归还 → 其他线程复用。如果某个线程借了不归还 → 池中空闲连接耗尽 → 后续线程全部阻塞等待。
连接不归还的常见原因 :
1. 事务太长(本次情况:导出报表一次查50万条,事务持续3分钟)
2. 忘记关闭连接(手动JDBC操作没close)
3. 连接泄漏(在事务中调远程接口,远程接口慢)
✅ 修复
// ✅ 1. 缩短事务范围(只包裹必要的DB操作)
@Transactional(timeout = 30) // 设置超时30秒
public void createOrder (...) { ... }
// ✅ 2. 适当增加连接池大小
# application.yml
spring.datasource.hikari.maximum-pool-size: 30 # 默认10,改为30
spring.datasource.hikari.leak-detection-threshold: 60000 # 60秒未还报警
✅ 验证
增加连接池 + 加超时 + 缩短事务范围后,高峰期连接利用率稳定在60%,不再出现"Connection not available"。✅
性能下降 日志/Arthas 线程池调度
🕐 每天凌晨跑的"自动取消超时订单"任务,以前5分钟跑完,现在要30分钟。而且日志显示同一时间段的任务被重复执行了。
02:00:00 [scheduling-1] 开始执行:自动取消超时订单
02:25:31 [scheduling-1] 自动取消完成,处理了12000条
02:30:00 [scheduling-1] 开始执行:同步商品库存
02:31:00 [scheduling-1] 同步库存超时,下一次任务已到时间但还在等上一次完成
03:00:00 [scheduling-1] 开始执行:自动取消超时订单 ← 上一次还没完成又开始
问题: Spring 的 @Scheduled 默认只有一个线程(scheduling-1)。所有定时任务共用这一个线程 → 前面的任务慢,后面的任务全部排队 → 堆积!
📖 原理:Spring @Scheduled 线程池
1. 默认行为
Spring Boot 的 @Scheduled 默认使用单线程的 TaskScheduler(ThreadPoolTaskScheduler,poolSize=1)。所有 @Scheduled 方法共用这一个线程。
2. 线程模型
默认只有一个调度线程负责执行所有 @Scheduled 方法:每5分钟检查过期订单、每10分钟同步库存、每天2点生成报表。前一个任务未执行完 → 后续任务排队等待调度。
3. 堆积后果
- 任务A执行了25分钟 → 任务B延迟25分钟启动 → 任务C继续延迟 → 恶性循环
✅ 修复:配置定时任务线程池
@Configuration
@EnableScheduling
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks (ScheduledTaskRegistrar registrar) {
registrar.setScheduler(taskExecutor());
}
@Bean(destroyMethod = "shutdown")
public ScheduledExecutorService taskExecutor () {
return Executors.newScheduledThreadPool(4); // ✅ 4个线程
}
}
✅ 验证
配置4个线程后,定时任务互不阻塞。自动取消、同步库存、生成报表各自独立运行。✅
OOM jmap 流式处理
🕐 管理员上传商品图片(一个500MB的zip包),服务直接OOM重启了。
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at org.apache.tomcat.util.http.fileupload.ByteArrayOutputStream.toByteArray(ByteArrayOutputStream.java:160)
at org.springframework.web.multipart.commons.CommonsFileUploadSupport.parseFileItems(CommonsFileUploadSupport.java:162)
问题: Spring 默认把上传的文件全部加载到内存!500MB文件 → 500MB堆内存 → OOM。
🔍 看代码
@PostMapping("/upload")
public Result upload (@RequestParam ("file" ) MultipartFile file) {
// 💥 直接getBytes() 把整个文件读到内存
byte [] data = file.getBytes(); // 500MB!
String path = saveToDisk(data, file.getOriginalFilename());
return Result.ok(path);
}
📖 原理:文件上传的内存陷阱
1. Spring 文件上传两种模式 :
- 内存模式 (默认):小文件先放内存,超过 threshold 写临时文件
- 磁盘模式 :配置 file-size-threshold=0,直接写磁盘
2. 内存模型 :
- 内存模式 = 文件先完整读入JVM堆内存再处理 → 大文件导致OOM
- 磁盘模式 = 文件直接写入磁盘临时文件 → 堆内存不占用
3. MultipartFile.getBytes() 的问题
即使文件被Spring写到了临时文件,你调 getBytes() 还是会全部读进内存!
✅ 修复:流式传输
@PostMapping("/upload")
public Result upload (@RequestParam ("file" ) MultipartFile file) {
// ✅ 用流式方式,不一次性加载到内存
Path target = Paths.get("/data/uploads" , file.getOriginalFilename());
try (InputStream in = file.getInputStream();
OutputStream out = Files.newOutputStream(target)) {
byte [] buffer = new byte [8192]; // 8KB 缓冲区
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
return Result.ok(target.toString());
}
# application.yml 限制文件大小
spring.servlet.multipart.max-file-size: 100MB
spring.servlet.multipart.max-request-size: 100MB
✅ 验证
流式传输后,上传500MB文件时内存占用仅~30MB(缓冲区+少量对象),不再OOM。✅
同时配置了100MB大小限制,防止恶意上传超大文件。
// ✅ Spring Boot + Guava RateLimiter 限流
@RestController
public class OrderController {
// 每秒允许100个请求
private final RateLimiter rateLimiter = RateLimiter.create (100 );
@PostMapping ("/api/orders" )
public R createOrder (@RequestBody OrderRequest req) {
// 非阻塞式限流:尝试获取令牌,1秒内拿不到就拒绝
if (!rateLimiter.tryAcquire (1 , TimeUnit.SECONDS)) {
return R.fail ("系统繁忙,请稍后重试" );
}
// 正常业务逻辑...
return R.ok (orderService.createOrder (req));
}
}
// 分布式限流:Redis + Lua 脚本(推荐生产环境)
// 见 Redisson 的 RRateLimiter
限流算法对比 :
• 固定窗口 :简单但有临界突发问题
• 滑动窗口 :更平滑,实现稍复杂
• 令牌桶 :允许短时突发,最常用(Guava RateLimiter)
• 漏桶 :严格匀速,适合消息队列
# application.yml — 开启优雅停机
server :
shutdown : graceful # 等待正在处理的请求完成
spring :
lifecycle :
timeout-per-shutdown-phase : 30s # 最多等30秒
// 自定义清理逻辑
@Component
public class GracefulShutdown {
@Autowired
private ExecutorService businessExecutor;
@PreDestroy
public void onShutdown () {
// 1. 停止接收新任务
businessExecutor.shutdown ();
try {
// 2. 等待已有任务完成
if (!businessExecutor.awaitTermination (30 , TimeUnit.SECONDS)) {
// 3. 超时强制关闭
businessExecutor.shutdownNow ();
}
} catch (InterruptedException e) {
businessExecutor.shutdownNow ();
}
}
}
# Kubernetes 部署时的 Pod 终止流程:
# 1. kubectl delete pod → 发送 SIGTERM
# 2. Spring Boot 收到信号 → 标记为"不接受新请求"
# 3. 等待正在处理的请求完成(最多30秒)
# 4. 超时后强制退出 → 容器终止
# 5. Service 从 Endpoints 移除 → 新请求不再路由到此 Pod