事务踩坑指南,事务并不可以随便用!

📑 文章目录

一、事务介绍

Transaction 事务是**数据库中一组操作的集合。**核心特点就一句话:要么全部成功执行、要么全部不执行,不会只执行一半。

1.1 核心特性:ACID

  1. 原子性:(Atomicity)

事务是最小执行单元,不可分割。失败就全部回滚,成功就全部提交。

  1. 一致性:(Consistency)

执行前后,数据库的完整性不变(余额、库存等不会出现逻辑错误)。

  1. 隔离性:(Isolation)

多个事务同时运行时,互不干扰,避免脏读、不可重复读、幻读。

  1. 持久性:(Durability) 一旦事务提交,结果永久保存,断电也不会丢失。

1.2 事务隔离级别(从低到高)

  1. 读未提交(Read Uncommitted)

别人没提交、你也能读到。所存在的问题:脏读、幻读、不可重复读。

  1. 读已提交(Read Committed)

只能读到别人已经提交的数据。解决脏读,仍存在不可重复读和幻读,Oracle、SQL Server 默认级别。

  1. 可重复读(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 不管你字段是数字、字符串、日期,只要是索引,在数据库里都会排成有序队列。间隙锁锁的就是这个有序队列里的 “空隙”

  1. 串行化(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 三种配置回滚对比

  1. 看到这里小伙伴应该还没分清 RuntimeExceptionCheckedException 的区别:
  • **RuntimeException:**代码逻辑错误、运行时才会出现,编译器不强制你捕获(不写 try-catch 也能编译通过)默认会导致 Spring 事务回滚。

常见异常有:NullPointerException 空指针、IndexOutOfBoundsException 数组越界、IllegalArgumentException 参数不合法、ArithmeticException 除零异常、ClassCastException 类型转换失败、SqlException 数据库底层错误。

  • **CheckedException(受检异常):**可预期、可处理的业务异常,编译器强制捕获,必须 try-catch 或 throws,默认不会导致 Spring 事务回滚。所以在实际开发中@Transactional(rollbackFor=Exception.class)更加安全一些。

常见异常有:IOException 文件找不到读写失败、SQLException 部分驱动包装类异常、ClassNotFoundException 类找不到、InterruptedException 线程中断堵塞等。

  1. @Transactional(ReadOnly=true)为什么快?

不写 undo log,写文件就意味着 IO 操作不加任何写锁/排他锁,无锁竞争。MVCC 快照更轻量数据库引擎内部优化:如下

  • 关闭事务的自动提交相关检测
  • 关闭事务回滚能力相关结构
  • 优化查询执行计划
  • 减少事务上下文切换开销
  • 减少锁等待队列检查

只读事务 ≈ 无锁 + 无日志 + 无 IO 开销 + 无阻塞

三、懒加载

为什么要在事务里面提懒加载,是因为这部分新手很容易就踩坑,因为懒加载中必须有事务存在!编码不规范很有可能导致数据库压力过大而宕机。接下来我来好好讲解一波!

3.1 核心概念

  1. Hibernate Session,数据库连接的媒介,session 活着才能执行 sql,若 session 关闭,任何懒加载将会直接报错!
  2. 懒加载:我用的时候再查,所以总结来说就是:懒加载 = 第二次 SQL 查询,只查主表,用到关联数据时才去查第二次,第二次查询必须复用**同一个连接、同一个 Session。**所以懒加载可以减少不必要的联表查询,减轻数据库压力节省内存、提升速度。但是必须在事务 / 会话存活时才能用!
  3. Spring + Hibernate/JPA 默认:一个事务 = 一个 Session、事务开启 → Session 打开、事务提交 / 回滚 → Session 自动关闭、事务外 = 没有可用 Session

3.2 懒加载是如何触发的

分两类:

  1. 集合懒加载(@OneToMany / @ManyToMany)
  2. 实体懒加载(@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 查询关联数据

  1. 最常用触发(高频)
size()isEmpty()get(index)contains(obj)add(obj)remove(obj)clear()
iterator()listIterator()subList()toArray()
  1. 遍历触发(只要遍历就加载)
  • 增强 for 循环:for (Order o : orderList)
  • stream 操作:orderList.stream()
  • forEach:orderList.forEach(...)
  1. 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. 执行 1 条 SQL:查所有用户
select * from user;
  1. 循环 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 模式) 思路:

  1. 先查主表(分页)
  2. 提取所有主表 ID
  3. 一次 IN 查询把关联数据全部查出来
  4. 内存组装

最终只执行 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/

未经作者禁止转载!