文章目录
前言
想象一下这种场景,发薪日到了,公司财务给我们的银行卡转账。最终的结果无法就两种:转账成功,公司账户余额减少,个人账号余额增长相应的数字;另外一种就是转账失败,双方账户余额都不改变。
这里我们讨论的场景就是数据的原子性和一致性,这个概念相信属性数据库的朋友都不会陌生,它正是大名鼎鼎的ACID,既原子性(Atomicity) ,一致性(Consistency) ,隔离性(Isolation)和持久性(Durability)。是保证数据库中事务正确执行的四个基本要素。
今天这篇文章我和大家一起梳理在Spring Boot中去实现复杂逻辑操作的单机事务,并理清事务的传播规则,从而保证数据的一致性。
一、事务(transaction)是什么?
事务的英文是transaction,在科林字典中它被解释为"A transaction is a piece of business, for example an act of buying or selling something.",直译过来就是商品交易。对于商品交易而言,要么卖出去换成金钱,要么就不卖出去。这里其实已经体现出了这是必须完成或不完成的操作。
对于我们计算机中的事务概念来说,它也就是指代一组必须全部完成或全部撤销的操作。事务将多个对数据库的操作视为一个整体,它们要么一起成功,要么一起失败,目的就是为了保证数据的一致性。事务有以下几点特性
- 原子性(Atomicity):借用物理中的概念,这个操作是最小的,不可拆分的。比如转账时A账户扣钱,把钱转到B账户。这个操作要么都成功,要么都不成功,不会出现A了钱B没到账。
- 一致性(Consistency):事务执行前后,数据总规则不变。比如转账前A账户和B账户总金额是 1000,转账后总金额还是 1000,不会凭空多或少。
- 隔离性(Isolation):多个事务同时执行时,互不干扰。比如A查询自己账户余额时,B正在给A的账户转账,A要么查到转账前的金额,要么查到转账后的金额,不会查到 "一半转账" 的中间状态。
- 持久性(Durability):事务提交后,数据变更永久生效。哪怕数据库宕机、重启,提交后的结果也不会丢失。
事务的核心操作一般分为5个步骤:
- 开启事务(Begin Transaction):标记事务的开始,接下来的数据库操作都是一组操作
- 执行操作语句:执行数据库操作语句,但是此刻的执行不会真正影响到数据库的数据,而是写入到一个隔离区里。
- 事务提交(Commit Transaction):确认事务中所有操作的有效性,将隔离区中的数据永久写入磁盘,事务正式结束。
- 事务回滚(Rollback Transaction)::若这个事务中,这些数据库操作逻辑里出现错误(不一定是数据库操作本身的错误),就撤销事务中所有已执行的操作,恢复到事务开启前的原始状态
- 事务结束(End Transaction):数据库释放该事务。
换句话说,事务是聚焦于数据库层面的。也就是没法把一个非数据的操作纳入到事务中。像流行的Redis这种,它自己有一套事务实现,通过Spring Boot中的事务是没法实现Redis回滚。
二、事务的实现------@Transactional
在Spring Boot中实现事务主要依赖于@Transactional这个注解。@Transactional不推荐加在接口上,优先加在 实现类的方法上。如果加在类上,那么表明该类里的每个方法执行的时候都需要开启事务。建议是直接加在方法上避免资源浪费。
假设我们有一个注册用户的业务,需要两个关联表数据插入用户基础信息和用户状态信息
java
@Mapper
public interface UserInfoMapper {
/**
* 插入用户基础信息,通过 @Options 获取自增 userId 并赋值给 UserInfo 对象
*/
@Insert("INSERT INTO user_info (phone, nickname, password, create_time, update_time) " +
"VALUES (#{phone}, #{nickname}, #{password}, NOW(), NOW())")
@Options(useGeneratedKeys = true, keyProperty = "userId") // 关键:获取自增主键
int insert(UserInfo userInfo);
}
java
@Mapper
public interface UserStatusMapper {
/**
* 插入用户状态信息,关联userId
*/
@Insert("INSERT INTO user_status (user_id, real_name_auth, member_level, login_status, create_time, update_time) " +
"VALUES (#{userId}, #{realNameAuth}, #{memberLevel}, #{loginStatus}, NOW(), NOW())")
int insert(UserStatus userStatus);
}
这两条语句分别是插入主表中的user_info信息,并且将user_info生成的主键userId返回,然后再插入用户状态信息,关联userId。这两个插入需要同时成功,或者同时失败。这时候我们只需要在业务执行的方法外部加上@Transactional 注解,那么该方法内的多次数据库操作都会被当作一个事务,要么
全部成功,要么全部失败。
java
@Override
@Transactional
public Long register(UserInfo userInfo) {
String encryptedPwd = passwordEncoder.encode(userInfo.getPassword());
userInfo.setPassword(encryptedPwd);
int infoInsertCount = userInfoMapper.insert(userInfo);
if (infoInsertCount != 1) {
throw new RuntimeException("用户基础信息插入失败");
}
Long userId = userInfo.getUserId();
if (userId == null) {
throw new RuntimeException("获取用户ID失败");
}
UserStatus userStatus = new UserStatus();
userStatus.setUserId(userId);
int statusInsertCount = userStatusMapper.insert(userStatus);
if (statusInsertCount != 1) {
throw new RuntimeException("用户状态信息插入失败");
}
return userId;
}
三、事务的回滚
我们在如上代码的基础上手动添加一个错误代码,代码执行到int error = 1/0会报除以0的错误,这个时候观察数据库表会明显发现两个数据都没有插入到数据里。
java
@Transactional
public Long register(UserInfo userInfo) {
/*省略*/
int infoInsertCount = userInfoMapper.insert(userInfo);
/*省略*/
int error = 1/0
/*省略*/
int statusInsertCount = userStatusMapper.insert(userStatus);
/*省略*/
}
但是当我们把除以0的报错换成一个抛出的异常,会发现用户基础信息表里多了一条记录,但是用户状态信息里没有多记录。这是因为@Transactional注解内有一个回滚的触发配置rollbackFor,rollbackFor默认是空数组,此时Spring会对RuntimeException和Error及其子类自动触发回滚,检查异常(如 IOException)不会回滚。
java
@Transactional
public Long register(UserInfo userInfo) {
/*省略*/
int infoInsertCount = userInfoMapper.insert(userInfo);
/*省略*/
if (1 == 1) {
throw new RuntimeException("用户基础信息插入失败");
}
/*省略*/
int statusInsertCount = userStatusMapper.insert(userStatus);
/*省略*/
}
java
/**
* Defines zero (0) or more exception {@linkplain Class types}, which must be
* subclasses of {@link Throwable}, indicating which exception types must cause
* a transaction rollback.
* <p>By default, a transaction will be rolled back on {@link RuntimeException}
* and {@link Error} but not on checked exceptions (business exceptions). See
* {@link org.springframework.transaction.interceptor.DefaultTransactionAttribute#rollbackOn(Throwable)}
* for a detailed explanation.
* <p>This is the preferred way to construct a rollback rule (in contrast to
* {@link #rollbackForClassName}), matching the exception type and its subclasses
* in a type-safe manner. See the {@linkplain Transactional class-level javadocs}
* for further details on rollback rule semantics.
* @see #rollbackForClassName
* @see org.springframework.transaction.interceptor.RollbackRuleAttribute#RollbackRuleAttribute(Class)
* @see org.springframework.transaction.interceptor.DefaultTransactionAttribute#rollbackOn(Throwable)
*/
Class<? extends Throwable>[] rollbackFor() default {};
我们可以通过在@Transactional内添加rollbackFor参数,比如rollbackFor = Exception.class这种,这样任何异常都会触发回滚。
java
@Transactional(rollbackFor = Exception.class)
public Long register(UserInfo userInfo) {
四、事务的传播
如果在一个事务内引入另一个事务,这便会触发事务传播这个特质。本质上就是当一个事务方法调用另一个事务方法时,两个事务如何协同工作的问题。
我们相信一下上面的注册业务需要保存插入日志,在每一次执行后往数据库里写入一条记录,因为它本质上也是一个保存到数据库的操作。我们也给它添加@Transactional注解,定义成一个事务方法。
java
@Override
@Transactional
public void insertLog(Log empLog) {
logMapper.insert(empLog);
}
那么完成的业务方法就变成了父子嵌套事务,父事务就是我们之前的业务逻辑,子事务是一个数据库日志插入。但是默认情况下父事务的回滚会影响到嵌套的子事务,也就是说我们在register方法内加一个报错,引起的回滚,会导致用户基础信息,用防护状态信息和日志都不保存。这里面变涉及了一个事务传播性的特质。
java
@Override
@Transactional(rollbackFor = Exception.class)
public Long register(UserInfo userInfo) {
try{
/*省略*/
int infoInsertCount = userInfoMapper.insert(userInfo);
/*省略*/
int statusInsertCount = userStatusMapper.insert(userStatus);
/*省略*/
}finally {
logService.insertLog(new Log(null, LocalDateTime.now(), userInfo.toString()));
}
}
Spring中提供了一个Propagation枚举来控制事务传播。
| 传播属性 | 核心含义 | 父事务存在时(如register调用日志方法) |
|---|---|---|
| REQUIRED(默认) | 支持当前事务,不存在则新建 | 子事务复用父事务(共用一个事务),父子任一失败,整体回滚 |
| SUPPORTS | 支持当前事务,不存在则以非事务方式执行 | 子事务复用父事务 |
| MANDATORY | 必须在当前事务中执行,否则抛出异常(IllegalTransactionStateException) | 子事务复用父事务 |
| REQUIRES_NEW | 新建独立事务,挂起当前事务(父事务等待子事务完成后再继续) | 父子事务完全独立:子事务失败仅回滚自身,不影响父事务;父事务失败不影响已提交的子事务 |
| NOT_SUPPORTED | 以非事务方式执行,挂起当前事务 | 子事务无事务(即使父事务存在,也按普通方法执行) |
| NEVER | 以非事务方式执行,存在当前事务则抛出异常 | 直接抛出异常(禁止在事务中执行) |
| NESTED | 嵌套事务(父事务的子事务),依赖数据库的保存点(Savepoint) | 子事务依赖父事务:子事务失败可回滚到保存点(不影响父事务);父事务失败,子事务一同回滚 |
这里我们如果给日志的Transactional注解上添加属性propagation = Propagation.REQUIRES_NEW,那么它会作为新建独立事务,挂起当前事务。