part2-exception-analysis
约 3314 字大约 11 分钟
2026-05-24
系列文章:《深入Spring事务与数据源:从注解到数据库连接的全链路解析》
系列四第2篇:常见异常场景、根因分析与解决方案
基于版本:Spring 5.3.x + Druid 1.2.x + MyBatis 3.5.x
前置阅读:系列四第1篇:三大框架协作全链路分析
前言
经过前面三个系列和系列四第1篇的学习,咱们已经对Spring事务、Druid连接池、MyBatis执行器的协作机制有了深入的理解。
但是,理论知识再扎实,遇到实际问题时还是会踩坑。为什么呢?因为异常往往发生在细节上!
今天这篇文章,咱们就来系统地梳理一下常见的异常场景,从源码角度分析根因,并给出解决方案。我敢保证,看完这篇文章,你以后遇到问题时,能迅速定位到根因,而不是盲目地试错。
而且这篇文章的内容,都是基于真实项目中的踩坑经验总结出来的,绝对干货满满!
好了,废话不多说,开干!
一、Connection泄漏:连接池被耗尽
1.1 异常现象
com.alibaba.druid.pool.GetConnectionTimeoutException:
wait millis 60000, active 20, maxActive 20, creating 0或者:
org.springframework.jdbc.CannotGetJdbcConnectionException:
Could not get JDBC Connection; nested exception is
com.alibaba.druid.pool.GetConnectionTimeoutException1.2 异常场景
场景1:忘记关闭Connection
@Service
public class UserService {
@Autowired
private DataSource dataSource;
public void createUser(User user) throws SQLException {
Connection conn = dataSource.getConnection();
// 执行SQL
PreparedStatement stmt = conn.prepareStatement("INSERT INTO user ...");
stmt.executeUpdate();
// 忘记关闭!
// stmt.close();
// conn.close();
}
}场景2:异常导致没有关闭
@Service
public class UserService {
@Autowired
private DataSource dataSource;
public void createUser(User user) {
Connection conn = null;
try {
conn = dataSource.getConnection();
// 执行SQL
PreparedStatement stmt = conn.prepareStatement("INSERT INTO user ...");
stmt.executeUpdate();
// 抛出异常,没有执行到close()
throw new RuntimeException("故意抛出的异常");
} catch (Exception e) {
// 没有finally块,连接没有关闭
e.printStackTrace();
}
}
}场景3:MyBatis使用不当
@Service
public class UserService {
@Autowired
private SqlSessionFactory sqlSessionFactory;
public void createUser(User user) {
// 手动打开SqlSession,但没有关闭
SqlSession session = sqlSessionFactory.openSession();
try {
UserMapper mapper = session.getMapper(UserMapper.class);
mapper.insert(user);
// 忘记提交和关闭
// session.commit();
// session.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}1.3 源码分析
从Druid的源码角度分析,连接泄漏是怎么发生的。
核心流程:
// 1. 获取连接
Connection conn = dataSource.getConnection();
// → pollLast()从连接池获取连接
// → activeCount++(活跃连接数加1)
// 2. 使用连接
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.executeUpdate();
// 3. 忘记关闭
// conn.close(); // 没有调用!
// 结果:
// - 连接没有归还到连接池
// - activeCount不会减1
// - poolingCount不会加1
// - 连接池可用连接越来越少Druid的泄漏检测机制:
文件位置:druid/src/main/java/com/alibaba/druid/pool/DruidDataSource.java
private class DestroyConnectionThread extends Thread {
public void run() {
while (true) {
try {
// 定期执行
Thread.sleep(timeBetweenEvictionRunsMillis);
// 检测泄漏连接
if (isRemoveAbandoned()) {
removeAbandoned();
}
} catch (InterruptedException e) {
break;
}
}
}
}
public int removeAbandoned() {
int removeCount = 0;
long currrentNanos = System.nanoTime();
List<DruidPooledConnection> abandonedList = new ArrayList<>();
lock.lock();
try {
// 遍历所有活跃连接
for (DruidPooledConnection pooledConnection : activeConnections) {
// 如果连接持有时间超过阈值
if (pooledConnection.getConnectedTimeNano() +
removeAbandonedTimeoutMillis * 1000 * 1000 < currrentNanos) {
// 标记为泄漏
abandonedList.add(pooledConnection);
}
}
} finally {
lock.unlock();
}
// 关闭泄漏连接
for (DruidPooledConnection pooledConnection : abandonedList) {
pooledConnection.abandon();
removeCount++;
}
return removeCount;
}看到了吧?Druid有一个后台线程会定期检测泄漏连接,如果连接持有时间超过阈值,就会强制关闭。
1.4 解决方案
方案1:使用try-with-resources(推荐)
public void createUser(User user) {
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement("INSERT INTO user ...")) {
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
} // 自动关闭,即使发生异常
}方案2:在finally块中关闭
public void createUser(User user) {
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = dataSource.getConnection();
stmt = conn.prepareStatement("INSERT INTO user ...");
stmt.executeUpdate();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 必须关闭
if (stmt != null) {
try { stmt.close(); } catch (SQLException e) { /* ignore */ }
}
if (conn != null) {
try { conn.close(); } catch (SQLException e) { /* ignore */ }
}
}
}方案3:开启Druid的泄漏检测
spring:
datasource:
druid:
# 开启泄漏检测
remove-abandoned: true
# 泄漏超时时间(秒)
remove-abandoned-timeout: 180
# 记录泄漏日志
log-abandoned: true这样,连接泄漏时会自动关闭,并输出日志:
abandon connection, owner thread: http-nio-8080-exec-1
ownerThread current stack:
at com.example.UserService.createUser(UserService.java:20)
at com.example.OrderService.createOrder(OrderService.java:30)方案4:使用Spring的@Transactional
@Transactional
public void createUser(User user) {
// Spring会自动管理Connection的生命周期
// 方法结束时自动归还连接
userMapper.insert(user);
}二、事务不回滚:数据不一致
2.1 异常现象
数据库中有部分数据被修改了,但是业务方法抛出了异常,按理说应该回滚,但是没有回滚。
2.2 异常场景
场景1:异常类型不对
@Service
public class UserService {
@Transactional // 默认只回滚RuntimeException和Error
public void createUser(User user) throws Exception {
userMapper.insert(user);
// 抛出受检异常,不会回滚!
throw new Exception("故意抛出的异常");
}
}场景2:同类方法内部调用
@Service
public class UserService {
@Transactional
public void createUser(User user) {
userMapper.insert(user);
// 调用同类的方法,事务不生效!
sendEmail(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendEmail(User user) {
// 这个事务不会生效,因为没有经过代理对象
logMapper.insert(new Log("发送邮件"));
}
}场景3:catch了异常没有抛出
@Service
public class UserService {
@Transactional
public void createUser(User user) {
try {
userMapper.insert(user);
// 抛出异常
throw new RuntimeException("故意抛出的异常");
} catch (Exception e) {
// catch了异常,没有抛出
// Spring捕获不到异常,不会回滚
e.printStackTrace();
}
}
}场景4:多线程环境下
@Service
public class UserService {
@Transactional
public void createUser(User user) {
userMapper.insert(user);
// 开启新线程
new Thread(() -> {
// 新线程有自己的事务上下文
// 这里的异常不会影响主线程的事务
throw new RuntimeException("新线程的异常");
}).start();
}
}2.3 源码分析
从Spring事务的源码角度分析,为什么不回滚。
核心方法:completeTransactionAfterThrowing
文件位置:spring-tx/TransactionAspectSupport.java
protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
if (txInfo != null && txInfo.getTransactionStatus() != null) {
// ★★★★★ 核心:判断是否应该回滚 ★★★★★
if (txInfo.transactionAttribute != null
&& txInfo.transactionAttribute.rollbackOn(ex)) {
// 回滚
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
else {
// 不回滚,反而提交!
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}
}rollbackOn()方法的实现:
文件位置:spring-tx/RuleBasedTransactionAttribute.java
@Override
public boolean rollbackOn(Throwable ex) {
// 如果没有配置回滚规则,使用默认规则
if (this.rollbackRules != null) {
for (RollbackRuleAttribute rule : this.rollbackRules) {
int depth = rule.getDepth(ex);
if (depth >= 0) {
return depth > 0; // true表示回滚,false表示不回滚
}
}
}
// ★★★★★ 默认规则:RuntimeException和Error回滚 ★★★★★
return (ex instanceof RuntimeException || ex instanceof Error);
}看到了吧?默认情况下,只有RuntimeException和Error会回滚,受检异常(Exception)不会回滚!
2.4 解决方案
方案1:配置rollbackFor
@Transactional(rollbackFor = Exception.class) // 所有异常都回滚
public void createUser(User user) throws Exception {
userMapper.insert(user);
throw new Exception("故意抛出的异常"); // 现在会回滚了
}方案2:通过代理对象调用
@Service
public class UserService {
@Autowired
private UserService self; // 注入自己
@Transactional
public void createUser(User user) {
userMapper.insert(user);
// 通过代理对象调用
self.sendEmail(user); // 现在事务会生效了
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendEmail(User user) {
logMapper.insert(new Log("发送邮件"));
}
}方案3:catch后手动回滚
@Transactional
public void createUser(User user) {
try {
userMapper.insert(user);
throw new RuntimeException("故意抛出的异常");
} catch (Exception e) {
// 手动回滚
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
e.printStackTrace();
}
}方案4:使用编程式事务
@Autowired
private TransactionTemplate transactionTemplate;
public void createUser(User user) {
transactionTemplate.execute(status -> {
try {
userMapper.insert(user);
throw new RuntimeException("故意抛出的异常");
} catch (Exception e) {
// 手动回滚
status.setRollbackOnly();
return null;
}
});
}三、连接池满:系统无法获取连接
3.1 异常现象
com.alibaba.druid.pool.GetConnectionTimeoutException:
wait millis 60000, active 20, maxActive 20, creating 03.2 异常场景
场景1:并发太高,连接池不够
spring:
datasource:
druid:
max-active: 20 # 最大20个连接系统并发100个请求,但是连接池只有20个连接,后面的请求只能等待。
场景2:慢SQL占用连接时间太长
@Transactional
public void createUser(User user) {
// 执行慢SQL,占用连接10秒
userMapper.slowQuery();
// 其他操作
userMapper.insert(user);
}场景3:连接泄漏导致可用连接越来越少
参考第一节的场景。
3.3 源码分析
从Druid源码分析连接池满的情况。
核心方法:pollLast
文件位置:druid/src/main/java/com/alibaba/druid/pool/DruidDataSource.java
private DruidConnectionHolder pollLast(long nanos) throws InterruptedException {
long estimate = nanos;
for (;;) {
lock.lock();
try {
// 检查是否有空闲连接
if (poolingCount > 0) {
// 有空闲连接,直接返回
DruidConnectionHolder holder = connections[--poolingCount];
activeCount++;
return holder;
}
// 没有空闲连接,检查是否可以创建
if (activeCount < maxActive) {
// 可以创建新连接
emptySignal();
}
// 不能创建,等待
if (estimate <= 0) {
// 超时,返回null
return null;
}
// 等待连接可用
notEmpty.await(estimate, TimeUnit.NANOSECONDS);
estimate = notEmpty.getWaitNanos();
} finally {
lock.unlock();
}
}
}看到了吧?当连接池满了(activeCount >= maxActive),并且没有空闲连接(poolingCount == 0),就会进入等待。如果等待超时,就抛异常。
3.4 解决方案
方案1:调大maxActive
spring:
datasource:
druid:
max-active: 50 # 从20调到50但是要注意,不能超过数据库的最大连接数。
方案2:优化慢SQL
// 优化前:执行10秒
userMapper.slowQuery();
// 优化后:添加索引,执行0.1秒
userMapper.fastQuery();方案3:减少事务范围
// 优化前:整个方法都在事务中
@Transactional
public void createUser(User user) {
userMapper.slowQuery(); // 占用连接10秒
userMapper.insert(user);
sendEmail(user); // 占用连接5秒
}
// 优化后:只有必要的操作在事务中
@Transactional
public void createUser(User user) {
userMapper.insert(user); // 只占用连接0.1秒
}
public void sendEmail(User user) {
// 非事务操作
}方案4:设置合理的maxWait
spring:
datasource:
druid:
max-wait: 5000 # 5秒,快速失败快速失败比长时间等待更好,可以避免雪崩效应。
四、慢SQL:系统性能差
4.1 异常现象
Druid监控页面显示慢SQL:
SQL: SELECT * FROM user WHERE name = '张三'
Execute Time: 5000ms4.2 异常场景
场景1:没有索引
-- user表有100万条数据,name字段没有索引
SELECT * FROM user WHERE name = '张三'; -- 全表扫描,5秒**场景2:SELECT ***
-- 查询所有字段,但是只用了2个字段
SELECT * FROM user WHERE id = 1; -- 返回100个字段,浪费带宽场景3:N+1查询
// 查询订单列表
List<Order> orders = orderMapper.selectList();
// 循环查询每个订单的用户
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId()); // N次查询
}4.3 源码分析
从Druid的StatFilter分析慢SQL检测。
核心方法:statement_execute
文件位置:druid/src/main/java/com/alibaba/druid/filter/stat/StatFilter.java
@Override
public void statement_execute(FilterChain chain, String sql) throws SQLException {
// 记录开始时间
long startNanos = System.nanoTime();
try {
// 执行SQL
chain.statement_execute(sql);
} finally {
// 计算执行耗时
long elapsedNanos = System.nanoTime() - startNanos;
long elapsedMillis = elapsedNanos / (1000 * 1000);
// 检查是否是慢SQL
if (elapsedMillis >= slowSqlMillis) {
// 慢SQL
getDataSourceStat().getSlowSqlCount().incrementAndGet();
// 记录慢SQL日志
if (logSlowSql) {
LOG.error("slow sql: " + sql + " executed in " + elapsedMillis + "ms");
}
}
}
}看到了吧?StatFilter会记录SQL的执行时间,如果超过阈值,就标记为慢SQL。
4.4 解决方案
方案1:添加索引
-- 添加索引
CREATE INDEX idx_user_name ON user(name);
-- 现在查询只需要0.01秒
SELECT * FROM user WHERE name = '张三';方案2:只查询需要的字段
-- 只查询需要的字段
SELECT id, name FROM user WHERE id = 1;方案3:使用JOIN代替N+1查询
// 优化前:N+1查询
List<Order> orders = orderMapper.selectList();
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId());
}
// 优化后:JOIN查询
List<OrderVO> orders = orderMapper.selectListWithUser();方案4:开启Druid慢SQL监控
spring:
datasource:
druid:
filters: stat
stat:
slow-sql-millis: 2000 # 2秒
log-slow-sql: true五、MyBatis一级缓存导致的数据不一致
5.1 异常现象
同一个SqlSession内,第一次查询返回旧数据,第二次查询还是返回旧数据,即使数据库已经被其他事务修改。
5.2 异常场景
场景1:同一个SqlSession内查询
SqlSession session = sqlSessionFactory.openSession();
// 第一次查询
User user1 = session.selectOne("com.example.UserMapper.selectById", 1);
System.out.println(user1.getName()); // 张三
// 其他事务修改了数据库
// UPDATE user SET name = '李四' WHERE id = 1;
// 第二次查询(同一个SqlSession)
User user2 = session.selectOne("com.example.UserMapper.selectById", 1);
System.out.println(user2.getName()); // 还是张三!(缓存命中)
session.close();5.3 源码分析
从BaseExecutor源码分析一级缓存机制。
核心方法:query
文件位置:mybatis-3/BaseExecutor.java
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, CacheKey key, BoundSql boundSql) {
// ★★★★★ 核心:从一级缓存获取结果 ★★★★★
List<E> list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
// 缓存命中,直接返回
return list;
} else {
// 缓存未命中,从数据库查询
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
return list;
}看到了吧?如果缓存命中,直接返回缓存的结果,不会查询数据库。
5.4 解决方案
方案1:手动清理缓存
SqlSession session = sqlSessionFactory.openSession();
// 第一次查询
User user1 = session.selectOne("com.example.UserMapper.selectById", 1);
// 清理缓存
session.clearCache();
// 第二次查询(会查询数据库)
User user2 = session.selectOne("com.example.UserMapper.selectById", 1);方案2:关闭一级缓存
mybatis:
configuration:
local-cache-scope: STATEMENT # 每次查询后清理缓存方案3:使用不同的SqlSession
// 第一次查询
SqlSession session1 = sqlSessionFactory.openSession();
User user1 = session1.selectOne("com.example.UserMapper.selectById", 1);
session1.close();
// 第二次查询(新的SqlSession)
SqlSession session2 = sqlSessionFactory.openSession();
User user2 = session2.selectOne("com.example.UserMapper.selectById", 1);
session2.close();六、总结
通过这篇文章的分析,咱们总结了6大类常见异常:
- Connection泄漏:忘记关闭连接,导致连接池耗尽
- 事务不回滚:异常类型不对、同类方法调用、catch异常
- 连接池满:并发太高、慢SQL、连接泄漏
- 慢SQL:没有索引、SELECT *、N+1查询
- 一级缓存导致的数据不一致:同一个SqlSession内查询旧数据
每类异常都从源码角度分析了根因,并给出了解决方案。
以后再遇到这些问题,你就能迅速定位到根因,而不是盲目地试错了!
参考资料
- Spring Framework 5.3.x 源码
- Druid 1.2.x 源码
- MyBatis 3.5.x 源码
- 《Spring源码深度解析》
- 《MyBatis技术内幕》
- 实际项目踩坑经验总结