第4篇-那些年我们踩过的事务坑
约 2491 字大约 8 分钟
2026-05-24
四、连接泄漏(第四经典的坑!)
1. 问题描述
场景描述: 你的应用程序运行一段时间后,突然报错了,错误信息是:Cannot get a connection, pool exhausted(无法获取连接,连接池已耗尽)!
你可能会问:为什么连接池会耗尽呢?
答案:因为你没有正确关闭连接(或者归还连接到连接池),导致连接池中的连接越来越少,最终耗尽!
错误示例:
@Service
public class UserService {
@Autowired
private DataSource dataSource;
public void createUser(User user) throws SQLException {
// 【错误】手动获取连接,但是没有关闭!
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("INSERT INTO user(name, age) VALUES(?, ?)");
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.execute();
// 【错误】没有关闭 PreparedStatement 和 Connection!
}
}正确示例:
@Service
public class UserService {
@Autowired
private DataSource dataSource;
public void createUser(User user) throws SQLException {
Connection conn = null;
PreparedStatement ps = null;
try {
conn = dataSource.getConnection();
ps = conn.prepareStatement("INSERT INTO user(name, age) VALUES(?, ?)");
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.execute();
} finally {
// 【核心】在 finally 块中关闭资源!
if (ps != null) {
try { ps.close(); } catch (SQLException e) { /* 忽略 */ }
}
if (conn != null) {
try { conn.close(); } catch (SQLException e) { /* 忽略 */ }
}
}
}
}【注意】 如果你使用的是 Spring 事务管理,就不需要手动关闭连接了!因为 Spring 会自动帮你关闭(或者归还到连接池)。
但是,如果你手动获取连接,并且没有使用 Spring 事务管理,就需要手动关闭连接!
【扩展点】 你可以通过 Druid 的监控统计功能,查看连接池的使用情况:
@Configuration
public class DruidConfig {
@Bean
public ServletRegistrationBean<StatViewServlet> druidServlet() {
ServletRegistrationBean<StatViewServlet> bean = new ServletRegistrationBean<>(new StatViewServlet(), "/druid/*");
Map<String, String> initParams = new HashMap<>();
initParams.put("loginUsername", "admin"); // 用户名
initParams.put("loginPassword", "123456"); // 密码
bean.setInitParameters(initParams);
return bean;
}
}然后,访问 http://localhost:8080/druid,就可以看到连接池的使用情况了!
五、死锁(第五经典的坑!)
1. 问题描述
场景描述: 你的应用程序突然报错了,错误信息是:Deadlock found when trying to get lock(尝试获取锁时发现死锁)!
你可能会问:为什么会出现死锁呢?
答案:因为两个事务互相等待对方释放锁!比如:
- 事务 A 锁定了记录 1,然后尝试锁定记录 2
- 事务 B 锁定了记录 2,然后尝试锁定记录 1
- 事务 A 和事务 B 互相等待对方释放锁,形成死锁!
错误示例:
@Service
public class TransferService {
@Autowired
private AccountMapper accountMapper;
@Transactional
public void transfer1(Long fromAccountId, Long toAccountId, BigDecimal amount) {
// 【错误】先锁定账户 A
Account fromAccount = accountMapper.selectForUpdate(fromAccountId);
// 【错误】然后锁定账户 B
Account toAccount = accountMapper.selectForUpdate(toAccountId);
// ... 转账逻辑
}
@Transactional
public void transfer2(Long fromAccountId, Long toAccountId, BigDecimal amount) {
// 【错误】先锁定账户 B(和 transfer1 相反!)
Account toAccount = accountMapper.selectForUpdate(toAccountId);
// 【错误】然后锁定账户 A(和 transfer1 相反!)
Account fromAccount = accountMapper.selectForUpdate(fromAccountId);
// ... 转账逻辑
}
}你可能会问:如何避免死锁呢?
答案:按照相同的顺序锁定记录!
正确示例:
@Service
public class TransferService {
@Autowired
private AccountMapper accountMapper;
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
// 【正确】按照 ID 从小到大的顺序锁定账户
Long smallerId = Math.min(fromAccountId, toAccountId);
Long largerId = Math.max(fromAccountId, toAccountId);
Account account1 = accountMapper.selectForUpdate(smallerId);
Account account2 = accountMapper.selectForUpdate(largerId);
// ... 转账逻辑
}
}【扩展点】 你可以通过 MySQL 的死锁日志来分析死锁的原因:
-- 【核心】查看 MySQL 的死锁日志
SHOW ENGINE INNODB STATUS;六、事务传播机制使用不当(第六经典的坑!)
1. 问题描述
场景描述: 你在一个加了 @Transactional 的 Service 方法中调用另一个加了 @Transactional 的 Service 方法,但是事务的传播行为不符合你的预期!
错误示例:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional
public void createUser(User user) {
userMapper.insert(user);
// 【错误】希望 createLog() 有自己的事务,但是忘记了指定传播行为!
createLog(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // 希望有自己的事务
public void createLog(User user) {
// ... 创建日志 ...
}
}你可能会问:为什么会错呢?
答案:因为 createLog() 方法是 private 的(或者是自调用),所以 @Transactional 注解不会生效!事务传播行为还是 REQUIRED(加入当前事务)。
正确示例:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private LogService logService; // 【核心】注入另一个 Service
@Transactional
public void createUser(User user) {
userMapper.insert(user);
// 【正确】通过代理对象调用,事务传播行为会生效!
logService.createLog(user);
}
}
@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW) // 【核心】指定传播行为
public void createLog(User user) {
// ... 创建日志 ...
}
}七、事务隔离级别使用不当(第七经典的坑!)
1. 问题描述
场景描述: 你在一个加了 @Transactional 的 Service 方法中读取数据,但是读到了未提交的数据(脏读)!
错误示例:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional(isolation = Isolation.READ_UNCOMMITTED) // 【错误】读未提交,会出现脏读!
public User getUserById(Long id) {
return userMapper.selectById(id);
}
}你可能会问:为什么会出现脏读呢?
答案:因为你使用了错误的隔离级别!比如,你使用了 READ_UNCOMMITTED(读未提交),它允许读取未提交的数据。
正确示例:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional(isolation = Isolation.READ_COMMITTED) // 【正确】读已提交,避免脏读!
public User getUserById(Long id) {
return userMapper.selectById(id);
}
}【扩展点】 不同的隔离级别可以避免不同的并发问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ_UNCOMMITTED | ❌ 会 | ❌ 会 | ❌ 会 |
| READ_COMMITTED | ✅ 不会 | ❌ 会 | ❌ 会 |
| REPEATABLE_READ | ✅ 不会 | ✅ 不会 | ❌ 会(MySQL 不会) |
| SERIALIZABLE | ✅ 不会 | ✅ 不会 | ✅ 不会 |
八、总结与完结感言
1. 本文总结
今天,我们详细探讨了那些年我们踩过的事务坑,包括:
- 自调用问题:为什么 @Transactional 不生效?
- 异常不回滚:为什么事务没有回滚?
- 方法可见性问题:为什么非 public 方法上的 @Transactional 不生效?
- 连接泄漏:为什么连接池耗尽了?
- 死锁:为什么 MySQL 报死锁错误?
- 事务传播机制使用不当:为什么事务的传播行为不符合预期?
- 事务隔离级别使用不当:为什么会出现脏读?
你可能会问:我知道了这些坑,但是如何避免踩坑呢?
答案:
- 遵循最佳实践:比如,不要自调用、使用正确的异常类型、使用 public 方法、正确关闭连接、按照相同顺序锁定记录、使用正确的事务传播行为、使用正确的事务隔离级别
- 使用代码审查:让同事帮你审查代码
- 使用单元测试:编写单元测试,覆盖各种场景
- 使用集成测试:编写集成测试,验证事务行为
2. 整个系列的总结
到这里,整个系列(22 篇文章)就全部讲完了!让我们来回顾一下:
系列一:Spring 事务系列(7 篇文章)
- 第 1 篇:@Transactional 注解的"前世今生"
- 第 2 篇:AOP 代理 —— 事务的"隐身衣"
- 第 3 篇:事务传播机制源码深度解析
- 第 4 篇:事务隔离级别的实现原理
- 第 5 篇:事务管理器的设计哲学
- 第 6 篇:事务资源的绑定与传递
- 第 7 篇:提交、回滚与异常规则
系列二:数据源系列(6 篇文章)
- 第 1 篇:JDBC 规范与 DriverManager 的 legacy
- 第 2 篇:Druid 连接池的整体架构
- 第 3 篇:连接的借与还(getConnection 全流程)
- 第 4 篇:Druid 过滤链与监控统计
- 第 5 篇:MySQL 驱动的 JDBC 实现
- 第 6 篇:连接的生命周期与事务边界
系列三:MyBatis 系列(5 篇文章)
- 第 1 篇:从 Mapper 接口到 SQL 执行的奇幻漂流
- 第 2 篇:Executor 执行器的三种形态
- 第 3 篇:事务管理 —— SpringManagedTransaction 的秘密
- 第 4 篇:SQL 执行的全链路(StatementHandler)
- 第 5 篇:插件机制与责任链
系列四:总结系列(4 篇文章)
- 第 1 篇:从 @Transactional 到 MySQL 返回的完整旅程
- 第 2 篇:异常全景图 —— 每个环节都可能出什么错?
- 第 3 篇:基于源码的调优实践
- 第 4 篇:那些年我们踩过的事务坑(本文)
看到了吗? 这 22 篇文章覆盖了 Spring 事务、数据源、MyBatis、MySQL 的全生命周期!
从 @Transactional 注解开始,到 MySQL 返回结果为止,我们已经把全链路都讲透了!
3. 完结感言
写到这里,我不禁感叹:这段时间真的是一段非常有趣的旅程!
我们从 Spring 事务开始,深入探讨了 @Transactional 注解的实现原理、AOP 代理的机制、事务传播机制、隔离级别、事务管理器、资源绑定、提交与回滚。
然后,我们进入了 数据源的世界,探讨了 JDBC 规范、Druid 连接池的架构、连接的借与还、过滤链与监控统计、MySQL 驱动的 JDBC 实现、连接的生命周期与事务边界。
接着,我们进入了 MyBatis 的世界,探讨了 Mapper 接口到 SQL 执行的奇幻漂流、Executor 执行器的三种形态、SpringManagedTransaction 的秘密、SQL 执行的全链路、插件机制与责任链。
最后,我们进入了 总结系列,探讨了从 @Transactional 到 MySQL 返回的完整旅程、异常全景图、基于源码的调优实践、那些年我们踩过的事务坑。
你可能会问:接下来呢?还有什么要讲的吗?
当然有! 虽然这个系列已经完结了,但是技术在不断发展,你可能会遇到新的问题、新的挑战。
所以,我希望这些文章能够帮你:
- 快速定位问题:知道每个环节可能出什么错
- 快速解决问题:知道如何排查、如何修复
- 快速调优性能:知道如何调优事务、数据源、MyBatis、MySQL
最后,我希望这些文章能够帮到你!如果你觉得有收获,别忘了点赞、收藏、关注哦!你的支持,是我创作的最大动力!❤️
我们下次见! 👋
P.S. 如果你有任何问题、建议、或者想要我写其他主题的文章,欢迎随时联系我!😊