📖 相关阅读 — MySQL 专题系列

本文以16个后端问题驱动(NPE/OOM/线程死锁/缓存穿透等),MySQL只是其中一部分。如需深入MySQL专题,参见:

🔧 后端问题排查实战手册

16个真实后端问题 × 从发现到修复到验证 × 每个问题讲透背后的原理

NPE空指针OOM内存溢出CPU 100% 线程死锁API超时事务不回滚 缓存穿透序列化异常依赖冲突 接口幂等日志丢失Bean注入失败 慢SQL连接池耗尽定时任务堆积文件上传OOM
学习路径1. 理解概念类比+图解2. 看代码IDE窗口+实战3. 动手踩坑+FAQ
三步学习法:概念→代码→实战
1
NullPointerException — 生产环境最常遇到的NPE
严重程度:🔴 高 | 出现频率:⭐⭐⭐⭐⭐ | 排查难度:⭐⭐
线上告警日志排查空安全
🕐 下午 14:23,你正在工位写代码,突然钉钉告警群弹出消息:
🔔 告警群 - production-alert
🤖
监控机器人
🚨 [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返回nullkey不存在于Map中map.get("category").toString()
链式调用中间为null前置对象为null,后续调用必然NPEorder.getUser().getName()
自动拆箱null包装类型为null,拆箱时NPEint 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,而是返回友好的业务提示。告警群恢复平静 ✅
2
OutOfMemoryError — 内存溢出导致服务假死
严重程度:🔴 P0 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐⭐⭐
线上告警jmap/MATJVM内存模型
🕐 晚上 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日志——
# 看应用日志有没有OOM
[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)

# 定位到 ReportService 第52行,生成报表的接口!
# 堆dump还在吗?
[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 内存曲线恢复正常,报表导出不再报错。
3
CPU 100% — 死循环吃满CPU导致服务不可用
严重程度:🔴 P0 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐⭐⭐
线上告警top/jstack/Arthas线程调度
🕐 上午 10:05,你刚到公司泡了杯技术,运维同学就在群里喊了:
💬 #dev-ops 技术群
🔧
运维-老王
兄弟们,milktea-order 的 CPU 突然飙到 99%,接口全部超时了,能看下吗?
我来排查,先看下进程状态
# 第一步:top 找到 CPU 最高的 Java 进程
[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

# 第三步:线程ID转16进制
[root@order-23 ~]# printf "%x\n" 28501
6f55

# 第四步:jstack 看这个线程在干什么
[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%,服务恢复正常 ✅
4
线程死锁 — 两个接口互相等待导致功能不可用
严重程度:🔴 P0 | 出现频率:⭐⭐ | 排查难度:⭐⭐⭐⭐
功能不可用jstack/Arthas锁机制
🕐 下午 15:30,客服反馈用户无法同时进行"下单"和"取消订单"操作,一直转圈。
# jstack 发现死锁!
[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)
5
API 超时 — 第三方接口响应慢导致整个请求阻塞
严重程度:🟡 P1 | 出现频率:⭐⭐⭐⭐ | 排查难度:⭐⭐⭐
用户体验差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,不创建业务订单;支付回调确认成功后才创建业务订单,避免产生大量未支付订单。 ✅
降级策略:支付超时时返回"支付处理中"状态,前端可轮询或引导用户稍后重试。 ✅
6
事务不回滚 — 异常了但数据没回滚,出现脏数据
严重程度:🔴 P0 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐⭐
数据错误日志分析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("请勿重复下单"); // ✅ 重新抛出!
    }
}
✅ 验证
重复下单时,事务回滚,库存不会扣除。数据库状态一致。 ✅
7
缓存穿透 — 大量请求绕过Redis直击数据库
严重程度:🔴 P0 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐⭐
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。✅
8
序列化异常 — Redis存取对象报错 / JSON反序列化失败
严重程度:🟡 P1 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐
运行时异常日志序列化机制
🕐 上午 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;
}
✅ 验证
统一序列化配置后,新旧数据都能正常读写。部署上线,缓存读取恢复正常。✅
9
依赖冲突 — NoSuchMethodError / ClassNotFoundException
严重程度:🟡 P1 | 出现频率:⭐⭐⭐⭐ | 排查难度:⭐⭐⭐
启动失败mvn dependencyMaven依赖传递
🕐 新同事 clone 项目后,引入了一个新工具库,启动直接报错。
java.lang.NoSuchMethodError:
'void com.fasterxml.jackson.databind.ObjectMapper.setDefaultPrettyPrinter(com.fasterxml.jackson.core.PrettyPrinter)'

# Maven 依赖树看看 Jackson 版本冲突
[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,项目正常启动。✅
10
接口幂等性 — 用户重复点击导致创建了多个订单
严重程度:🔴 P0 | 出现频率:⭐⭐⭐⭐ | 排查难度:⭐⭐⭐
数据重复日志+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已被删除 → 返回"请勿重复提交"。✅
11
日志丢失 — 关键日志没打印导致问题无法定位
严重程度:🟡 P2 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐
排查困难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); // ✅ 带上异常堆栈
    }
}
✅ 验证
重新部署后,日志信息完整,下次出问题能快速定位入参和错误原因。✅
12
Bean 注入失败 — UnsatisfiedDependencyException
严重程度:🟡 P1 | 出现频率:⭐⭐⭐⭐ | 排查难度:⭐⭐⭐
启动失败IDEASpring 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注入成功。✅
13
慢SQL — MyBatis-Plus 自动生成的SQL性能差
严重程度:🟡 P1 | 出现频率:⭐⭐⭐⭐⭐ | 排查难度:⭐⭐⭐
接口慢慢查询日志/EXPLAIN索引优化
🕐 接口RT从200ms升到3s,查慢查询日志发现一条SQL执行了2.8秒。
# MySQL 慢查询日志
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 |
+----+-------------+------------+------+---------------+------+---------+------+--------+-----------------------------+
# type=ALL 全表扫描,key=NULL 没用索引,Extra有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 |
# type从ALL变成ref,扫描行数从52万降到8230!
✅ 验证
加索引后,查询从2.8秒降到0.05秒。接口RT恢复正常。✅
14
连接池耗尽 — HikariCP 报 "Connection is not available"
严重程度:🔴 P0 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐⭐
服务不可用监控+日志连接池原理
🕐 高峰期接口大面积报错,错误信息是获取不到数据库连接。
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"。✅
15
定时任务堆积 — @Scheduled 任务执行越来越慢
严重程度:🟡 P1 | 出现频率:⭐⭐⭐ | 排查难度:⭐⭐⭐
性能下降日志/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个线程后,定时任务互不阻塞。自动取消、同步库存、生成报表各自独立运行。✅
16
文件上传导致OOM — 大文件上传直接撑爆内存
严重程度:🔴 P0 | 出现频率:⭐⭐ | 排查难度:⭐⭐⭐
OOMjmap流式处理
🕐 管理员上传商品图片(一个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