一、事务介绍
Transaction 事务是**数据库中一组操作的集合。**核心特点就一句话:要么全部成功执行、要么全部不执行,不会只执行一半。
1.1 核心特性:ACID
- 原子性:(Atomicity)
事务是最小执行单元,不可分割。失败就全部回滚,成功就全部提交。
- 一致性:(Consistency)
执行前后,数据库的完整性不变(余额、库存等不会出现逻辑错误)。
- 隔离性:(Isolation)
多个事务同时运行时,互不干扰,避免脏读、不可重复读、幻读。
- 持久性:(Durability) 一旦事务提交,结果永久保存,断电也不会丢失。
1.2 事务隔离级别(从低到高)
- 读未提交(Read Uncommitted)
别人没提交、你也能读到。所存在的问题:脏读、幻读、不可重复读。
- 读已提交(Read Committed)
只能读到别人已经提交的数据。解决脏读,仍存在不可重复读和幻读,Oracle、SQL Server 默认级别。
- 可重复读(Repeatable Read)
同一个事务中,多次读同一行数据永远一样。解决脏读和不可重复读,仍存在幻读(MySql InnoDB 用间隙锁基本解决了幻读) MySQL 默认隔离级别。
间隙锁(Gap Lock) 是 MySQL InnoDB 引擎在 可重复读(RR) 隔离级别下,专门用来防止幻读的核心锁机制。它锁定的不是数据行本身,而是索引记录之间的空隙,阻止其他事务在这个空隙里插入新数据。
这样说基本概念大家肯定很迷惑,啥是幻读?啥是锁定数据行?啥是索引之间的空隙?怎么阻止的其他事务插入,间隙锁如何实现的? 我来给大家一一解释!
幻读(这里先简单说一下,下面 1.3 会有更详细的介绍): 幻读就是在同一个事务里,两次执行完全相同的范围查询,第二次结果里突然多出了第一次没有的新行(像幻影一样)。
幻读示例(无间隙锁):
-- 事务A
BEGIN;
SELECT * FROM user WHERE age > 20; -- 查到2条
-- 事务B插入 age=25 的新数据并提交
INSERT INTO user (name, age) VALUES ('新用户', 25); COMMIT;
-- 事务A再次查询
SELECT * FROM user WHERE age > 20; -- 变成3条!幻读发生
- 锁定数据行:
锁定对象:其实并不是锁一个已经存在的行,而是索引之间的“空隙”;
核心作用:在你查询的范围周围加一个“禁止插入”的边界,防止其他事务在范围内偷偷插入新数据;
依赖条件:必须是 InnoDB 引擎、必须是可重复读、必须基于索引;
记录锁(Record Lock):锁住车位(不让别人占用 / 修改)。防修改 / 删除。 间隙锁(Gap Lock):锁住 1 和 4 之间的空地(不让别人新建)。防插入。
所以解决幻读实际上是:
Next-Key Lock:记录锁 + 间隙锁(InnoDB 默认),既锁行又锁间隙。
又有小伙伴会问了,那万一不是数字呢? 一样锁,跟是不是数字没关系,只跟索引顺序有关。 极简核心: InnoDB 不管你字段是数字、字符串、日期,只要是索引,在数据库里都会排成有序队列。间隙锁锁的就是这个有序队列里的 “空隙”。
- 串行化(Serializable)
事务排队执行、完全串行。全部问题都可以解决但是性能太差了,一般没人用。
一张表总结:
1.3 三个“读”问题
1.3.1 脏读(Dirty Read)
读到别人还没提交的数据。
例子:
- 事务 A:给用户加 100 元,但还没提交
- 事务 B:立刻去查,看到余额多了 100
- 结果事务 A 出错,回滚了
- 事务 B 刚才读到的 100 元就是脏数据
一句话:读到未提交数据,别人一滚,你就白读了。
1.3.2 不可重复读(Non-repeatable Read)
同一个事务里,两次读同一条数据,结果不一样。
例子:
- 事务 B:第一次查余额 = 1000
- 事务 A:修改这条数据并提交 → 余额变成 1200
- 事务 B:再查一次 → 变成 1200
一句话:同一行数据,前后读不一样。
1.3.3 幻读(Phantom Read)
范围查询时,突然多 / 少了几条记录,像幻觉一样。
例子:
- 事务 B:查询 “年龄 = 18” 的人,共 10 条
- 事务 A:插入一条年龄 = 18 的数据并提交
- 事务 B:再查一次 → 变成 11 条
一句话:不是某行变了,是整个结果集变了。
二、事务控制层
了解完以上基础概念,接下来我们回到代码中。那么请小伙伴思考事务应该放在哪一层?
对没错,一般放在 Service 层,不在 Controller 也不在 Dao。因为事务控制层就是专门负责 “开启、提交、回滚事务” 的代码层。
- Controller:只接收请求、参数校验
- Service:业务逻辑 + 事务边界
- Dao:只做单条 SQL,不控制事务
事务控制层 = Service 层里管理事务的那部分逻辑。
2.1 事务的实现方式
① 注解方式(最常用)
@Service
public class OrderService {
@Transactional(
rollbackFor = Exception.class,
isolation = Isolation.REPEATABLE_READ,
propagation = Propagation.REQUIRED
)
public void createOrder() {// 扣库存// 生成订单// 扣余额// 任意一步抛异常 → 全部回滚}}
② 编程式事务(精细控制)
transactionTemplate.execute(status -> {
// 操作1
// 操作2
if (出错) {
status.setRollbackOnly(); // 手动回滚
}
return true;
});
2.2 事务的配置
相信小伙伴也看到上面代码,@Transaction()括号里面的东西,其实一般我们常用的有三种参数配置。我来给大家说说优缺点的适用场景。
2.2.1 适用场景示例
2.2.2 三种配置回滚对比
- 看到这里小伙伴应该还没分清 RuntimeException 和 CheckedException 的区别:
- **RuntimeException:**代码逻辑错误、运行时才会出现,编译器不强制你捕获(不写 try-catch 也能编译通过)默认会导致 Spring 事务回滚。
常见异常有:NullPointerException 空指针、IndexOutOfBoundsException 数组越界、IllegalArgumentException 参数不合法、ArithmeticException 除零异常、ClassCastException 类型转换失败、SqlException 数据库底层错误。
- **CheckedException(受检异常):**可预期、可处理的业务异常,编译器强制捕获,必须 try-catch 或 throws,默认不会导致 Spring 事务回滚。所以在实际开发中@Transactional(rollbackFor=Exception.class)更加安全一些。
常见异常有:IOException 文件找不到读写失败、SQLException 部分驱动包装类异常、ClassNotFoundException 类找不到、InterruptedException 线程中断堵塞等。
- @Transactional(ReadOnly=true)为什么快?
不写 undo log,写文件就意味着 IO 操作。不加任何写锁/排他锁,无锁竞争。MVCC 快照更轻量。数据库引擎内部优化:如下
- 关闭事务的自动提交相关检测
- 关闭事务回滚能力相关结构
- 优化查询执行计划
- 减少事务上下文切换开销
- 减少锁等待队列检查
只读事务 ≈ 无锁 + 无日志 + 无 IO 开销 + 无阻塞
三、懒加载
为什么要在事务里面提懒加载,是因为这部分新手很容易就踩坑,因为懒加载中必须有事务存在!编码不规范很有可能导致数据库压力过大而宕机。接下来我来好好讲解一波!
3.1 核心概念
- Hibernate Session,数据库连接的媒介,session 活着才能执行 sql,若 session 关闭,任何懒加载将会直接报错!
- 懒加载:我用的时候再查,所以总结来说就是:懒加载 = 第二次 SQL 查询,只查主表,用到关联数据时才去查第二次,第二次查询必须复用**同一个连接、同一个 Session。**所以懒加载可以减少不必要的联表查询,减轻数据库压力节省内存、提升速度。但是必须在事务 / 会话存活时才能用!
- Spring + Hibernate/JPA 默认:一个事务 = 一个 Session、事务开启 → Session 打开、事务提交 / 回滚 → Session 自动关闭、事务外 = 没有可用 Session
3.2 懒加载是如何触发的
分两类:
- 集合懒加载(@OneToMany / @ManyToMany)
- 实体懒加载(@ManyToOne / @OneToOne)
懒加载实现 Demo
先来准备一下基础实体类(一对多,默认懒加载)
//User.java
@Entity
@Table(name = "user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 默认 LAZY
@OneToMany(mappedBy = "user")
private List<Order> orders;
// getter/setter/toString
}
//Order.java
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNo;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
// getter/setter/toString
}
//UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
// 普通查询
@Query("select u from User u")
List<User> findAllUsers();
// join fetch 优化查询
@Query("select u from User u left join fetch u.orders")
List<User> findAllUsersWithOrders();
}
继续 service 层的实现
@Service
public class UserService {
@Autowired
UserRepository userRepository;
// ====================== 1. 正常懒加载(事务内)======================
@Transactional
public User lazyLoadNormal(Long userId) {
User user = userRepository.findById(userId).orElse(null);
// 触发懒加载:执行第二条 SQL
System.out.println("订单数量:" + user.getOrders().size());
return user;
}
// ====================== 2. 事务外懒加载:报错 ======================
public User lazyLoadError(Long userId) {
User user = userRepository.findById(userId).orElse(null);
// 事务已结束,Session 关闭
// 下面这行会抛:no Session
// LazyInitializationException: could not initialize proxy - no Session
System.out.println("订单数量:" + user.getOrders().size());
return user;
}
}
3.2.1 集合懒加载触发方法(List/Set/Map)
只要调用这些方法,立刻发 SQL 查询关联数据
- 最常用触发(高频)
size()、isEmpty()、get(index)、contains(obj)、add(obj)、remove(obj)、clear()
iterator()、listIterator()、subList()、toArray()
- 遍历触发(只要遍历就加载)
- 增强 for 循环:
for (Order o : orderList) - stream 操作:
orderList.stream() - forEach:
orderList.forEach(...)
- Jackson 序列化触发(最容易踩坑)
Spring MVC 返回 JSON 时,会自动调用:
- getter 方法
- toString()
只要序列化 → 触发懒加载 → no Session 报错
3.2.2 实体懒加载触发方法(@ManyToOne)
代理对象(如 User user)只要调用以下方法,立刻发 SQL
user.getId()不会触发(id 存在外键里,已预先知道)除了 getId (),任何其他 getter 都会触发
user.getName()user.getAge()user.getEmail()
toString()equals()/hashCode()强转类型
3.2.3 不会触发懒加载的方法
集合:
user.getOrderList()→ 仅仅获取引用,不触发- 只有调用方法才触发
实体:
user.getId()→ 不触发- 仅仅赋值:
User u = user;→ 不触发
3.3 N+1 查询和 Join Fetch
很多程序员宝子应该都听过 N+1 查询吧,但是具体是什么让我再次给你梳理一下。
一句话,查 1 次主表,再循环 N 次查关联表,总共执行 N+1 条 SQL。
3.3.1 N+1 场景
数据库里:
- 10 个用户
- 每个用户有若干订单
你要做:遍历所有用户,并打印每个用户的订单数量
普通懒加载的执行流程:
- 执行 1 条 SQL:查所有用户
select * from user;
- 循环 10 次:每遍历一个用户,就查一次订单
select * from orders where user_id = 1;select * from orders where user_id = 2;...select * from orders where user_id = 10;
最终 SQL 数量:1 + 10 = 11 条 SQL,那数据量多起来数据库的压力就很大了!数据库直接被打爆,接口卡死。
3.3.2 join fetch
一句话:一次查询,用 left join 把主表 + 关联表一次性全部查出来,只发 1 条 SQL。
话不多说我们直接上代码演示:
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// ==================== N+1 查询 ====================
@Transactional
public void demoNPlusOne() {
// 1. 查所有用户:1 条 SQL
List<User> userList = userRepository.findAllUsers();
// 2. 遍历每个用户,触发懒加载
// 有多少个用户,就执行多少条 SQL
for (User user : userList) {
// 触发懒加载 → 执行 select * from order where user_id = ?
System.out.println("用户:" + user.getName()
+ " 订单数:" + user.getOrders().size());
}
}
}
控制台日志:
select * from user; -- 1条
select * from order where user_id=1; -- N条
select * from order where user_id=2;
select * from order where user_id=3;
...
Join fetch 优化:
// ==================== join fetch 优化 ====================
@Transactional
public void demoJoinFetch() {
// 一次 left join 查出所有 user + order
List<User> userList = userRepository.findAllWithOrders();
for (User user : userList) {
// 不再执行任何 SQL!数据已经全部加载
System.out.println("用户:" + user.getName()
+ " 订单数:" + user.getOrders().size());
}
}
3.3.3 OOM?
对的!没错,就是你想的那样,有一些经验的宝宝应该发现,这 join fetch 直接一次性加载,数据量大不就 OOM 了吗?是的,很容易 OOM,那么该怎么解决内存溢出的问题呢?
标准答案:批量查询方案(1+1 模式) 思路:
- 先查主表(分页)
- 提取所有主表 ID
- 一次 IN 查询把关联数据全部查出来
- 内存组装
最终只执行 2 条 SQL,既没有 N+1,也不会 OOM。
让我们来看代码示例:
Step 1:查用户(分页)
// 只查用户,不查订单
Page<User> userPage = userRepository.findAll(pageable);
List<Long> userIds = userPage.getContent().stream()
.map(User::getId)
.collect(Collectors.toList());
Step 2:一次 IN 查询所有订单
// 一次性查出所有订单
List<Order> orderList = orderRepository.findByUserIdIn(userIds);
Step 3:按 userId 分组
Map<Long, List<Order>> orderMap = orderList.stream()
.collect(Collectors.groupingBy(Order::getUserId));
Step 4:内存组装
for (User user : userPage.getContent()) {
user.setOrders(orderMap.get(user.getId()));
}
这时候有宝宝就想,那我直接在分页的时候做 join fetch,这样有分页 limit 限制,再 join 订单,保证不会数据量过大,安全还方便。
**现实是:数据库做不到,Hibernate 也做不到。**你执行:
select *
from user u
left join order o on u.id=o.user_id
limit 3
你以为会得到 3 个用户。实际上你只会得到 1 个用户 + 3 个订单!
因为 join fetch 是笛卡尔积操作,来看实际查询:
用户1 订单1
用户1 订单2
用户1 订单3
用户2 订单1
用户2 订单2
...
limit 3 直接把前 3 行拿走 → **只有 1 个用户**。
**这不是你想要的分页!**
Hibernate 为了 “修复” 这个错误,干了一件蠢事:
Hibernate 发现你分页 + join fetch 集合,它知道数据库会错,于是它:
把 limit 去掉 → 查全表 → 全部加载进内存 → 自己在内存里分页
结果:
- 10 万用户 → 全部加载
- 100 万订单 → 全部加载
- 封装实体、缓存、去重
直接 OOM 爆内存。
所以你还是老老实实的:分页查用户->userid in 查询订单-> 内存分组封装,总共 2 条 SQL,永不 OOM
或者可以使用注解 @BatchSize(size = xx),N+1 → 变成 1 + 少量批量查询,不会爆内存。
或者还可以使用 Hibernate 官方推荐:@FetchMode.SUBSELECT,**专门解决一对多 N+1 的神器,**自动变成 2 条 SQL(1 条查主表,1 条查所有关联)
大家可以自行探索!!
四、总结
经过上面的高强度思考后,相信小伙伴们对于事务有了更深入的了解,那我们在遇到一些事务中的 for 循环、大文件 IO、同类方法的调用事务失效、邮件发送等一些外部长时间 api 调用等情况的时候要额外多一些心眼!欢迎大家在评论区一起讨论~
本文链接: https://hyuzz-nuc.github.io/posts/transaction-guide/
未经作者禁止转载!


