这里提供一个示例项目 transaction-demo,这个项目包含 Spring 框架、MyBatis 以及 JUnit。
对应的表结构见 bank.sql。
服务层有一个方法可以用于在不同的账户间进行转账:
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
public Account getAcountByName(String name) {
return accountMapper.selectByName(name);
}
@Override
public void transfer(String from, String to, double amount) {
//从转出账户扣款
accountMapper.delAmount(from, amount);
//给转入账户加钱
accountMapper.addAmount(to, amount);
}
}
这里我编写了一个简单的测试用例用于测试:
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = SpringConfig.class)
public class AccountServiceTests {
@Autowired
private AccountService accountService;
@Test
public void testTransfer(){
this.printAccounts();
accountService.transfer("jack", "icexmoon", 20);
this.printAccounts();
}
private void printAccounts(){
System.out.println(accountService.getAcountByName("icexmoon"));
System.out.println(accountService.getAcountByName("jack"));
}
}
但实际上这里是有 bug 的,如果转出账户的余额小于要转出的金额,转出账户的金额就会变成负数。
最朴素的想法是在转出金额前先检查账户余额是否足够:
@Override
public void transfer(String from, String to, double amount) {
//查询并检查转出账户的余额是否足够
Account account = accountMapper.selectByName(from);
if (account == null) {
throw new RuntimeException("账户 %s 不存在");
}
if (account.getAmount() - amount < 0) {
throw new RuntimeException("账户 %s 的余额不足");
}
//从转出账户扣款
accountMapper.delAmount(from, amount);
//给转入账户加钱
accountMapper.addAmount(to, amount);
}
将测试用例中的转账金额改成一个很大的数字(比如10000)后再次测试,就能发现会抛出异常,转账不会进行。
似乎这样做已经没有问题了。但是,显然我们的数据库操作是可以并行的,同时不可能只存在一个对 account 表的操作。如果同时存在多个对同一个账户的操作,会发生什么?
看这个测试用例:
@Test
public void testTransfer2() throws InterruptedException {
this.printAccounts();
new Thread(()->{
accountService.saveMoney("jack", 1000);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
accountService.getBackMoney("jack", 1000);
}).start();
new Thread(()->{
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
accountService.transfer("jack", "icexmoon", 3000);
}).start();
Thread.sleep(2000);
this.printAccounts();
}
这里有两个线程,一个尝试进行转账,从 jack 账户转账 3000 到 icexmoon 账户。另一个线程会先存 1000 再取 1000。
数据库里此时 jack 账户 2000,icexmoon 账户 1000。
理想情况是应该有两种结果:
如果你多执行几次,应该就能看到某次结果如下:
Account(id=1, name=icexmoon, amount=1000.0)
Account(id=2, name=jack, amount=2000.0)
Account(id=1, name=icexmoon, amount=4000.0)
Account(id=2, name=jack, amount=-1000.0)
这相当诡异,着表明转账、存钱和取钱都成功了。且 jack 账户余额变成了负数,明明我们有提前检查余额是否足够了。
为了能够“恰好”出现这种情况,我在代码中添加了一些
Thread.sleep()
,以确保这种错误出现的概率提高。
出现这种情况的原因本质上和多线程的问题是一致的,即资源共享。本质上 account 表上 name 为 jack 的数据行在这里充当了共享资源。如果我们在访问该资源时不对其“锁定”(独占),就有可能出现:
要解决这个问题也很简单,使用 Spring 事务。
使用事务要定义一个PlatformTransactionManager
:
@Configuration
public class TransactionConfig {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource){
DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager();
dataSourceTransactionManager.setDataSource(dataSource);
return dataSourceTransactionManager;
}
}
DataSourceTransactionManager
是PlatformTransactionManager
的一个实现类,它底层使用 JDBC 的事务,所以需要设置一个数据源。
还需要在配置类上添加@EnableTransactionManagement
注解以开启事务:
@EnableTransactionManagement
public class SpringConfig {
}
在 Service 接口的相关方法上添加@Transactional
:
public interface AccountService {
/**
* 查看账户信息
*
* @return
*/
@Transactional
Account getAcountByName(String name);
/**
* 转账
*
* @param from 转出账户
* @param to 转入账户
* @param amount
*/
@Transactional
void transfer(String from, String to, double amount);
/**
* 存钱
*
* @param name 账户名
* @param amount 金额
*/
@Transactional
void saveMoney(String name, double amount);
/**
* 取钱
*
* @param name 账户名
* @param amount 金额
*/
@Transactional
void getBackMoney(String name, double amount);
}
如果接口的所有方法都需要开启事务,可以在接口上使用@Transactional
注解:
@Transactional
public interface AccountService {
}
当然也可以在实现类或方法上使用
@Transactional
注解,但在接口上使用更灵活——如果替换了实现类依然会使用事务。实际上 Spring 的事务是用 AOP 实现的,所以这种规则实际上是 AOP 的通知匹配 Bean 的规则。
如果测试用例中使用 Spring 事务,还需要在测试套件上添加注解:
@Transactional(transactionManager = "transactionManager")
@Rollback(value = false)
public class AccountServiceTests {
// ...
}
现在再执行测试用例,就不会出现金额为负数的情况。
Spring 事务除了可以发挥 JDBC 事务的用途——锁定共享资源以外。另一个重要的用途就是保证数据一致性,也就是说 Spring 事务生效的过程中,任意的异常产生都会让事务涉及的数据层操作回滚。
之所以 Spring 事务可以做到这一点,是因为 Spring 事务通过两个角色,将多个数据层事务(JDBC 事务)纳入了Spring 事务(通常定义在 Service 层)的管理,并形成一个事务整体。
在 Spring 事务中,两个角色分别是:
具体到我们这个示例中,在服务层代码中:
public interface AccountService {
// ...
@Transactional
void transfer(String from, String to, double amount);
}
@Service
public class AccountServiceImpl implements AccountService {
// ...
@Override
@SneakyThrows
public void transfer(String from, String to, double amount) {
//查询并检查转出账户的余额是否足够
this.checkAccountAmountIsEnough(from, amount);
Thread.sleep(1000);
//从转出账户扣款
accountMapper.delAmount(from, amount);
//给转入账户加钱
accountMapper.addAmount(to, amount);
System.out.println("转账成功");
}
}
AccountService.transfer
方法上的 Spring 事务就是事务管理员,这个方法中调用的两个数据层方法accountMapper.delAmount
和accountMapper.addAmount
上的 JDBC 事务就是事务协调员。
其实还调用了数据层的查询方法,这里省略。
之所以 Spring 可以做到这一点(统一管理 JDBC 事务),是因为我们定义的事务管理器(DataSourceTransactionManager
)中使用的数据源(DataSource
)和数据层(MyBatis)使用的数据源是同一个数据源。
Spring 事务并非对所有异常的产生都会回滚,比如:
@Override
public void transfer(String from, String to, double amount) throws InterruptedException, IOException {
//查询并检查转出账户的余额是否足够
this.checkAccountAmountIsEnough(from, amount);
Thread.sleep(1000);
//从转出账户扣款
accountMapper.delAmount(from, amount);
if (true) throw new IOException();
//给转入账户加钱
accountMapper.addAmount(to, amount);
System.out.println("转账成功");
}
这里强制抛出一个IOException
类型的异常。
- 注意,这里没有使用
@SneakyThrow
处理异常,原因之后会说明。if(true)
是为了骗过编译器的语法检查。
执行测试用例:
@Test
@SneakyThrows
public void testTransfer() {
this.printAccounts();
accountService.transfer("jack", "icexmoon", 1000);
this.printAccounts();
}
会发现 jack 账户的钱减少了,但 icexmoon 账户的钱没有增加,这说明事务回滚并没有生效。
原因是,默认情况下,Spring 事务只会对 Error
或RuntimeException
类型的异常进行回滚。
换言之,Spring 事务不会对“被检查的异常”进行回滚。而在上面的示例中,IOException
就是一个被检查的异常。
很容易分辨异常是不是“被检查异常”,因为如果代码中有被检查的异常存在,编译器就会强制要求你进行处理(转换为运行时异常或在方法签名中声明异常抛出)。
解决的方式也很简单,将被检查的异常加入@Transactional
的rollback
属性:
public interface AccountService {
// ...
@Transactional(rollbackFor = {InterruptedException.class, IOException.class})
void transfer(String from, String to, double amount) throws InterruptedException, IOException;
}
现在再执行测试用例,事务回滚就会正常生效。
此外,还可以用noRollbackFor
属性指定哪些异常发生后不进行回滚。
当然,也可以将被检查的异常转换为运行时异常:
@Override
public void transfer(String from, String to, double amount) throws InterruptedException {
//查询并检查转出账户的余额是否足够
this.checkAccountAmountIsEnough(from, amount);
Thread.sleep(1000);
//从转出账户扣款
accountMapper.delAmount(from, amount);
try {
if (true) throw new IOException();
}
catch (Exception e){
throw new RuntimeException(e);
}
//给转入账户加钱
accountMapper.addAmount(to, amount);
System.out.println("转账成功");
}
这样就不存在我们之前说的问题,同样可以触发事务回滚。
在这里我们并不能使用@SneakyThrows
,因为@SneakyThrows
仅仅是骗过编译器,在不用在方法签名中声明异常的情况下抛出异常,并不会将被检查的异常转换为运行时异常:
@Override
@SneakyThrows
public void transfer(String from, String to, double amount) {
//查询并检查转出账户的余额是否足够
this.checkAccountAmountIsEnough(from, amount);
Thread.sleep(1000);
//从转出账户扣款
accountMapper.delAmount(from, amount);
if (true) throw new IOException();
//给转入账户加钱
accountMapper.addAmount(to, amount);
System.out.println("转账成功");
}
如果你像上面示例中那样做了,实际上代码将抛出一个方法签名中不存在的被检查异常IOException
,显然不会触发事务回滚。此外因为方法签名中没有声明的被检查异常被抛出,JVM 会抛出一个UndeclaredThrowableException
。
这件事告诉我们,要谨慎使用@SneakyThrows
。
添加一张日志表:
CREATE TABLE `log` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '标识符',
`content` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '内容',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='日志表'
添加 Mapper:
public interface LogMapper {
@Insert("insert into log(content,create_time) values (#{content},NOW())")
void addLog(String content);
}
添加 Service:
public interface LogService {
@Transactional
void addTransferLog(String from, String to, double amount);
}
@Service
public class LogServiceImpl implements LogService {
@Autowired
private LogMapper logMapper;
@Override
public void addTransferLog(String from, String to, double amount) {
logMapper.addLog("%s 转账 %.2f 到 %s".formatted(from, amount, to));
}
}
在转账操作中添加日志记录:
@Override
public void transfer(String from, String to, double amount) {
try {
//查询并检查转出账户的余额是否足够
this.checkAccountAmountIsEnough(from, amount);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
//从转出账户扣款
accountMapper.delAmount(from, amount);
//给转入账户加钱
accountMapper.addAmount(to, amount);
System.out.println("转账成功");
} finally {
logService.addTransferLog(from, to, amount);
}
}
现在可以成功转账,并写入日志信息。
但这里存在一个问题,如果我们希望无论转账是否成功,都写一条日志信息。就会发现一些问题。
在转账逻辑中添加一条代码,触发“除零异常”:
@Override
public void transfer(String from, String to, double amount) {
try {
//查询并检查转出账户的余额是否足够
this.checkAccountAmountIsEnough(from, amount);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
//从转出账户扣款
accountMapper.delAmount(from, amount);
int i = 1/0;
//给转入账户加钱
accountMapper.addAmount(to, amount);
System.out.println("转账成功");
} finally {
logService.addTransferLog(from, to, amount);
}
}
这是一个运行时异常,所以事务回滚被触发,账户金额不会改变。但问题在于,日志同样没有写入。
因为在上面这个示例中,LogService.addTransferLog()
方法的事务是一个事务协调员,它同样加入了AccountService.transfer()
方法管理的事务,所以在异常发生后被一同回滚了。
要让日志添加操作不被回滚,我们就需要将其设置为单独的事务。
方法也很简单:
public interface LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
void addTransferLog(String from, String to, double amount);
}
现在LogService.addTransferLog()
将会在单独事务中执行,所以无论转账成功与否,都会有日志信息添加。
在上面案例中,我们修改了@Transactional
的propagation
属性,实际上是修改了“事务的传播行为”。
事务协调员的传播行为会影响到最终的执行效果,传播行为分为以下几种:
REQUIRED
,默认行为。如果事务管理员开启了事务,就加入该事务。如果没有,新建事务。REQUIRES_NEW
,无论事务管理员是否开启事务,都新建一个事务。SUPPORTS
,如果事务管理员开启了事务,加入。如果没有,不使用事务。NOT_SUPPORTED
,无论事务管理员是否开启事务,都不使用事务。MANDATORY
,如果事务管理员开启了事务,加入。如果没有,报错。NEVER
,与MANDATORY
规则相反。如果事务管理员开启了事务,报错。如果没有,不使用事务。NESTED
,设置回滚点,让事务回滚到指定的回滚点。本文的完整示例可以从这里获取。