Skip to content

Spring教程 - 11 注解方式实现声明式事务

事务是一组数据库操作的集合,这些操作要么 全部成功,要么 全部失败

例如张三给李四转账100,要张三的账户减去100,李四的账户加上100,这是一组操作,必须全部成功,不能张三账户减去100成功,李四账户加上100失败,这会导致数据错误。

在前面介绍 JdbcTemplate 的时候,我们是没有处理事务的,默认情况下每条 SQL 都是一个单独的事务(即自动提交模式,autoCommit=true)。你执行一条更新的 SQL,它会立即提交到数据库。

关于事务的知识,可以查看 SQL 教程 - 事务


下面就来模拟一下张三给李四转账100的操作。

11.1 不处理事务

我们还是按照 Controller --> Service --> Dao 的方式,分层调用来实现。

1 实现Dao

接口:IUserDao.java

添加两个方法,一个是查询用户,一个是修改用户的余额。

java
package com.foooor.hellospring.dao;

import com.foooor.hellospring.pojo.User;

public interface IUserDao {
     /**
      * 根据id查询用户
      */
     User selectById(int userId);

     /**
      * 修改用户余额
      */
     void updateBalance(int userId, int balance);
}

实现类:UserDaoImpl.java

记得添加注解。

java
package com.foooor.hellospring.dao.impl;

import com.foooor.hellospring.dao.IUserDao;
import com.foooor.hellospring.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class UserDaoImpl implements IUserDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 根据id获取用户
     */
     @Override
     public User selectById(int userId) {
        // 1.准备sql
        String sql = "select balance from tb_user where id = ?";
        // 2.执行查询
        List<User> userList = jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(User.class), userId);
        // 3.返回结果
        return userList.isEmpty() ? null : userList.get(0);
     }

    /**
     * 修改用户余额
     */
     public void updateBalance(int userId, int balance) {
        // 1.准备sql
        String sql = "update tb_user set balance = balance + ? where id = ?";
        // 2.执行更新
        jdbcTemplate.update(sql, balance, userId);
    }

}
  • 上面添加了两个方法,一个是查询用户,这里我为了不让查询不到的时候报错,所以使用了列表的方式查询。

2 实现Service

接口:IUserService.java

我们是在 Service 中实现业务逻辑,所以也就在这一层实现转账操作。

java
package com.foooor.hellospring.service;

public interface IUserService {

     /**
      * 转账
      */
     void transfer(int fromUserId, int toUserId, int amount);
}

实现类:UserServiceImpl.java

记得添加注解。

java
package com.foooor.hellospring.service.impl;

import com.foooor.hellospring.dao.IUserDao;
import com.foooor.hellospring.pojo.User;
import com.foooor.hellospring.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements IUserService {

     @Autowired
     private IUserDao userDao;

    /**
     * 转账
     */
     @Override
     public void transfer(int fromUserId, int toUserId, int amount) {
        // 1.转账前的检查
        if (amount <= 0) {
            throw new IllegalArgumentException("转账金额必须大于0");
        }
        if (fromUserId == toUserId) {
            throw new IllegalArgumentException("转账方和收款方不能相同");
        }

        User fromUser = userDao.selectById(fromUserId);
        if (fromUser == null) {
            throw new IllegalArgumentException("转账方不存在");
        }
        if (userDao.selectById(toUserId) == null) {
            throw new IllegalArgumentException("收款方不存在");
        }

        // 2.检查转账方是否有足够的余额
        if (fromUser.getBalance() < amount) {
            throw new IllegalArgumentException("转账方余额不足");
        }

        // 3.更新转账方和收款方的余额
        userDao.updateBalance(fromUserId, -amount);  // 转账人余额减少
        userDao.updateBalance(toUserId, amount);      // 收款人余额增加
     }
}
  • 在转账前先做一些检查,判断用户是否存在等;
  • 然后将转账人的余额减去指定的金额,将收款人加上指定的金额;

3 实现Controller

UserController.java

记得添加注解。

java
package com.foooor.hellospring.controller;

import com.foooor.hellospring.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;

@Controller
public class UserController {

    @Autowired
    private IUserService userService;

    /**
     * 转账
     */
     public void transfer(int fromUserId, int toUserId, int amount) {
         userService.transfer(fromUserId, toUserId, amount);
    }
}
  • Controller 是接口层,是提供给前端调用的,所以调用 Service 就可以了。

4 测试

编写测试类,注入 Controller,然后进行测试即可:

java
package com.foooor.hellospring;

import com.foooor.hellospring.config.SpringConfig;
import com.foooor.hellospring.controller.UserController;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

@SpringJUnitConfig(classes = {SpringConfig.class})  // 指定 Spring 配置文件
public class JdbcTest {

    @Autowired
    private UserController userController;

    /**
     * 测试转账功能
     */
    @Test
    public void testTransfer() {
        userController.transfer(2, 3, 100);
    }
}
  • 直接运行测试方法,然后查看数据库的时候,发现可以转账成功。

项目结构如下:


11.2 存在的问题

在上面的代码中,是没有处理事务的,所以每一句 SQL 操作都是在自己独立的事务中的,执行完成就提交了。

在实际的开发中,我们的代码可能会报错,下面模拟一下在两个转账之间的代码报错:

java
// 3.更新转账方和收款方的余额
userDao.updateBalance(fromUserId, -amount);  // 转账人余额减少

// TODO 模拟报错
int a = 1/0;

userDao.updateBalance(toUserId, amount);      // 收款人余额增加

此时再执行转账的操作,出问题了!转账人的钱减少了,但是收款人的前没有增加!第一句 SQL 执行完成就提交了,不管后面的代码有没有报错。


这样可不行,所以得让操作在一个事务中才行,如果出现错误,需要回滚事务。

在以前学习 JDBC 的时候,我们通过编码的方式,手动开启和提交事务,在报错的时候,捕获异常,并回滚事务,这种方式编程式事务实现起来比较麻烦,每次都要自己封装,很麻烦。


11.3 声明式事务

在 Spring 中帮我们封装了声明式事务,Spring使用 AOP,对写有 @Transactional 注解的类(方法)创建代理对象,在执行方法的时候,会在切面中开启事务,然后再执行目标方法,根据方法执行情况,决定提交还是回滚事务。

下面就来介绍 Spring 声明式事务的使用。

1 开启事务管理

首先在配置类上开启声明式事务:

java
package com.foooor.hellospring.config;

import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration  // 配置类
@EnableTransactionManagement  // 开启事务管理
@ComponentScan("com.foooor.hellospring")  // 开启组件扫描,并指定扫描的包
@PropertySource("classpath:jdbc.properties")  // 加载外部配置文件
public class SpringConfig {

    // ...其他配置
  
    /**
     * 配置事务管理器
     */
    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        DataSourceTransactionManager txManager = new DataSourceTransactionManager();
        txManager.setDataSource(dataSource);
        return txManager;
    }
}
  • 首先在类上添加 @EnableTransactionManagement 注解开启事务管理;
  • 然后创建事务管理器 DataSourceTransactionManager

2 配置事务

使用起来非常的简单,我们只需要在 Service 或 Service 的方法上添加 @Transactional 注解即可。

  • 加在类上,是对 Service 中所有的方法生效;
  • 加在方法上,只对该方法生效;
java
@Transactional  // 开启事务
@Service
public class UserServiceImpl implements IUserService {
  // ...略
}

这样再次运行测试方法,会发现之前的问题不存在了,方法中的操作是在一个事务中完成的。


11.4 事务的属性

下面介绍一下事务@Transactional 的一些属性。

11.4.1 事务传播行为

在项目中,经常存在一个 Service 会调用另一个 Service,如果两个 Service 都开启了事务,那么这个事务是如何传播的呢?

例如: ServiceA 中的 methodA() 调用 ServiceBmethodB() 方法,两个 Service 都开启了事务。


Spring 一共定义了 7 种传播行为(在 Propagation 枚举里),最常用的是前 2 个:

含义
REQUIRED(默认)如果当前有事务就加入,没有就新建一个事务。
methodA() 调用 methodB() 的时候,methodA() 已经开启了事务,那么 methodB 就不会开启新的事务了,直接在 methodA 的事务中进行。
REQUIRES_NEW不管有没有事务,都新建一个事务,原事务挂起。
methodA() 调用 methodB() 的时候,methodB() 不管有没有事务,都会开启新的事务,methodB()执行完成,会提交事务,如果 methodA() 在后续的执行过程中报错,不影响methodB 事务的提交,只有 methodA 的执行会回滚。
SUPPORTS如果有事务就加入,没有就非事务方式运行
NOT_SUPPORTED始终以非事务方式运行,如果有事务则挂起
MANDATORY必须在事务中运行,否则抛异常
NEVER必须在非事务中运行,否则抛异常
NESTED如果有事务就在事务中嵌套一个子事务(依赖数据库 savepoint)

举个栗子:

如果我们创建了一个新的 TransferServiceImpl,在这个 Service 中多次调用之前 UserService 中的转账:

java
package com.foooor.hellospring.service.impl;

import com.foooor.hellospring.service.ITransferService;
import com.foooor.hellospring.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Transactional
@Service
public class TransferServiceImpl implements ITransferService {

    @Autowired
    private IUserService userService;

     /**
     * 多次转账
     */
     @Override
     public void multiTransfer(int fromUserId, int toUserId, int amount) {

         // 转账1
         userService.transfer(fromUserId, toUserId, amount);

         // TODO 模拟报错
         int a = 1 / 0;

         // 转账2
         userService.transfer(fromUserId, toUserId, amount);

     }
}
  • 然后修改 Controller 调用 TransferService。

此时我们没有修改事务的传播行为,默认是 REQUIRED ,所以两次转账都没有开启新的事务,都是在 TransferServiceImpl 的事务中。当上面抛出异常后,整个事务都回滚了,所以没有进行转账。


如果修改 UserServiceImpl 事务的传递方式,修改为 Propagation.REQUIRES_NEW ,如下:

java
@Transactional(propagation = Propagation.REQUIRES_NEW)  // 开启事务
@Service
public class UserServiceImpl implements IUserService {
  // ...略
}

那么在 TransferServiceImpl 多次调用 UserServiceImpl 转账的方法,都会开启一个新的事务,所以在 int a = 1 / 0; 报错的时候,第一次转账已经执行完成,并提交了,所以只有第二次转账没有成功。


需要注意:上面调用 UserServiceImpl 中的方法,都是从外部调用的,所以其实是通过代理对象来调用的,在调用目标方法之前,Spring 会通过切面开启事务。

但是如果你的 @Transactional 是添加在 Service 的方法上的,例如一个 Service 中有两个方法,methodA() 和methodB() ,如果只有 methodB 添加了 @Transactional ,此时通过外部调用 methodA() 不会进入事务切面,显然不会开启事务,然后通过 methodA 调用 methodB,即使 methodB 添加了 @Transactional ,此时也不会开启事务,因为不会经过切面了。

11.4.2 回滚策略

默认情况下,不是抛出所有异常,事务都会回滚的。

在Spring 声明式事务(@Transactional)中,默认事务的回滚条件是:

  • 运行时异常(RuntimeException)Error → 会触发回滚。
  • 受检异常(Checked Exception)不会回滚

例如:

  • RuntimeException, NullPointerException, IllegalArgumentException → 默认回滚。

  • IOException, SQLException → 默认 不回滚


通过指定事务的回滚策略,可以定义哪些异常触发回滚。


1 指定异常回滚

举个栗子:

下面设置遇到 Exception 异常就回滚:

java
@Transactional(rollbackFor = Exception.class)
  • 通过上面的设置,出现任何错误或异常,都会触发事务的回滚。

也可以指定某个异常:

java
@Transactional(rollbackFor = SQLException.class)

除了通过 rollbackFor 指定,还可以通过 rollbackForClassName 指定:

java
@Transactional(rollbackForClassName = "java.io.IOException")
  • rollbackForClassName 是按照异常类名匹配,和 rollbackFor 类似,但用字符串写全类名。

但是需要注意,如果异常被捕获处理了,是不会回滚的,除非把异常重新抛出来:

java
// 错误示例:异常被"吞掉",事务不会回滚
@Transactional(rollbackFor = Exception.class)
public void exampleMethod() {
    try {
        // ... 数据库操作
    } catch (Exception e) {
        e.printStackTrace(); // 仅仅打印日志,没有再次抛出
    }
}

// 正确示例:将异常抛出
@Transactional(rollbackFor = Exception.class)
public void exampleMethod() throws Exception {
    try {
        // ... 数据库操作
    } catch (Exception e) {
        // ... 可以记录日志
        throw e; // 或者抛出新的业务异常
    }
}

2 指定异常不会滚

还可以指定哪些异常不会滚:

java
@Transactional(noRollbackFor = BusinessException.class)

3 回滚策略混用

回滚和不会滚可以混合使用:

java
@Transactional(
    rollbackFor = {SQLException.class, IOException.class},
    noRollbackFor = {BusinessException.class}
)

11.4.3 隔离级别

事务的隔离级别主要用来解决脏读、不可重复读和幻读问题。

脏读(Dirty Read)

  • 事务 A 读到了事务 B 尚未提交的数据。
  • 如果 B 回滚,A 读到的数据就是不存在的 → 脏数据。

不可重复读(Non-Repeatable Read)

  • 事务 A 读取一条数据,然后事务 B 对该条数据进行了修改并提交了数据。
  • 事务 A 再次读取数据,发现两次读到的结果不同。

幻读(Phantom Read)

  • 事务 A 根据某个条件查询了多行数据。
  • 中间事务 B 插入了一些符合条件的新数据。
  • A 再次查询时,发现数据“凭空多了几条”。

详细的隔离级别说明,见: SQL 教程 - 事务

隔离级别有四种:

隔离级别 (Isolation Level)脏读 (Dirty Read)不可重复读 (Non-Repeatable Read)幻读 (Phantom Read)
READ UNCOMMITTED(读未提交)❌ 可能发生❌ 可能发生❌ 可能发生
READ COMMITTED(读已提交)✔️ 避免❌ 可能发生❌ 可能发生
REPEATABLE READ(可重复读)✔️ 避免✔️ 避免❌ 可能发生*
SERIALIZABLE(可串行化)✔️ 避免✔️ 避免✔️ 避免

需要注意,隔离级别越高,性能越低,但数据一致性越强。

MySQL 默认的隔离级别是:REPEATABLE READ;Oracle 默认的隔离级别是:READ COMMITTED

  • 按照 SQL 标准,REPEATABLE READ 仍可能幻读
  • 但在 MySQL InnoDB 中,使用了 间隙锁(Gap Lock),实际上连幻读也避免了,因此比标准更强。

设置 Spring 事务的隔离级别的方式如下:

java
@Transactional(isolation = Isolation.DEFAULT)  // 默认隔离级别
@Transactional(isolation = Isolation.READ_UNCOMMITTED)  // 读未提交隔离级别
@Transactional(isolation = Isolation.READ_COMMITTED)  // 读已提交隔离级别
@Transactional(isolation = Isolation.REPEATABLE_READ)  // 可重复读隔离级别
@Transactional(isolation = Isolation.SERIALIZABLE)  // 串行化隔离级别

根据需要设置即可, 一般采用默认数据的隔离级别就可以了。

11.4.4 只读

对于查询方法而言,显然是不会修改数据库的数据的,那么我们可以把它设置为只读,这样可以告诉数据库当前的操作不涉及写操作,数据库可以针对查询操作来进行优化。

举个栗子:

java
@Transactional(readOnly = true)

这样就设置了当前的操作为只读操作,如果进行了写操作,那么会报错!

11.4.5 超时

如果操作出现异常,导致卡住超时,那么我们可以设置事务的超时时间,

我们还可以设置事务的超时时间(单位秒),如果操作出现异常,导致超时,那么可以触发回滚。

java
@Transactional(timeout = 5) // 5 秒

11.5 总结

完整的配置类如下:

java
package com.foooor.hellospring.config;

import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration  // 配置类
@EnableTransactionManagement  // 开启事务管理
@ComponentScan("com.foooor.hellospring")  // 开启组件扫描,并指定扫描的包
@PropertySource("classpath:jdbc.properties")  // 加载外部配置文件
public class SpringConfig {

    @Value("${jdbc.driver}")  // 读取从配置文件注入的属性值。
    private String driver;

    @Value("${jdbc.url}")
    private String url;

    @Value("${jdbc.username}")
    private String username;

    @Value("${jdbc.password}")
    private String password;

    /**
     * 配置 Druid 数据源
     */
    @Bean
    public DataSource dataSource() {
        DruidDataSource ds = new DruidDataSource();
        ds.setDriverClassName(driver);
        ds.setUrl(url);
        ds.setUsername(username);
        ds.setPassword(password);
        return ds;
    }

    /**
     * 配置 JdbcTemplate
     * 方法的参数,Spring IoC 容器会自动注入对应的 Bean
     */
    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }


    /**
     * 配置事务管理器
     */
    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        DataSourceTransactionManager txManager = new DataSourceTransactionManager();
        txManager.setDataSource(dataSource);
        return txManager;
    }
}

而事务的配置,只需要在 Service 上添加注解即可:

java
@Transactional(rollbackFor = Exception.class)

到这里 Spring 的重点已经都讲完了!

内容未完......