Spring声明式事务
1. 前言
在上一篇博客中从一个案例 静态代理 -> 动态代理 -> AOP-CSDN博客 介绍了静态代理 -> 动态代理 -> SpringAOP相关内容。在Spring中声明式事务的底层就是通过AOP来实现的。趁热打铁,本篇博客介绍Spring的事务相关内容。
在此之前,我们先来说明下什么是声明式编程。和声明式编程对应的是命令式编程。凡是非命令式编程都可归结为声明式编程。编程范式可分为两大类:
- 命令式编程(Imperative Programming)
- 声明式编程(Declarative Programming)
- 函数式编程(Functional Programming,简称FP)
- 逻辑式编程(Logic Programming,简称LP)
- 属性式编程
其中命令式、函数式和逻辑式是最核心的三范式。这里引入网上看的一个图片,具体出处忘了是哪里。
在我们实际开发过程中,命令式编程和声明式编程的优缺点简单总结如下:
命令式编程
- 优点:代码调试(Debug)容易
- 缺点:代码量大,需要自己手动编写大量代码
声明式编程
- 优点:代码简洁,使用方便
- 缺点:封装太深,不易调试(Debug);
2. 项目需求概述
通过一个账户表和一个图书表来演示事务的作用。
2.1 SQL脚本
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for account
-- ----------------------------
DROP TABLE IF EXISTS `account`;
CREATE TABLE `account` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户名',
`age` int(0) NULL DEFAULT NULL COMMENT '年龄',
`balance` decimal(10, 2) NULL DEFAULT NULL COMMENT '余额',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of account
-- ----------------------------
INSERT INTO `account` VALUES (1, 'zhangsan', 18, 10000.00);
INSERT INTO `account` VALUES (2, 'lisi', 20, 10000.00);
INSERT INTO `account` VALUES (3, 'wangwu', 16, 10000.00);
-- ----------------------------
-- Table structure for book
-- ----------------------------
DROP TABLE IF EXISTS `book`;
CREATE TABLE `book` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT '图书id',
`bookName` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '图书名',
`price` decimal(10, 2) NULL DEFAULT NULL COMMENT '单价',
`stock` int(0) NULL DEFAULT NULL COMMENT '库存量',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of book
-- ----------------------------
INSERT INTO `book` VALUES (1, '剑指Java', 100.00, 100);
INSERT INTO `book` VALUES (2, '剑指大数据', 100.00, 100);
INSERT INTO `book` VALUES (3, '剑指Offer', 100.00, 100);
SET FOREIGN_KEY_CHECKS = 1;
2.2. 引入依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
2.3. 项目目录结构及代码
Account
import lombok.Data;
import java.math.BigDecimal;
@Data
public class Account {
private Integer id;
private String userName;
private Integer age;
private BigDecimal balance;
}
Book
import lombok.Data;
import java.math.BigDecimal;
@Data
public class Book {
private Integer id;
private String bookName;
private BigDecimal price;
private Integer stock;
}
AccountDao
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@Component
public class AccountDao {
private final JdbcTemplate jdbcTemplate;
public AccountDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* 按照userName扣减账户余额
*
* @param userName 用户名
* @param money 要扣减的金额
*/
public void updateBalanceByUserName(String userName, BigDecimal money) {
String sql = "update account set balance = account.balance - ? where username = ?";
jdbcTemplate.update(sql, money, userName);
}
}
BookDao
import com.shg.spring.tx.bean.Book;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Component
public class BookDao {
private final JdbcTemplate jdbcTemplate;
public BookDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* 按照图书id删除图书
*
* @param bookId 图书id
*/
public void deleteBookById(int bookId) {
String sql = "delete from book where id=?";
jdbcTemplate.update(sql, bookId);
}
/**
* 更新图书库存
*
* @param bookId 图书id
* @param num 需要减去的库存数量
*/
public void updateBookStockById(int bookId, Integer num) {
String sql = "update book set stock=stock - ? where id=?";
jdbcTemplate.update(sql, num, bookId);
}
/**
* 新增一个图书
*
* @param book
*/
public void addBook(Book book) {
String sql = "insert into book (bookName,price,stock) values (?, ?, ?)";
jdbcTemplate.update(sql, book.getBookName(), book.getPrice(), book.getStock());
}
/**
* 根据id查询书籍信息
*
* @param id 图书id
* @return Book
*/
@Transactional
public Book getBookById(Integer id) {
String sql = "select * from book where id = ?";
return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(Book.class), id);
}
}
UserService
import java.io.IOException;
public interface UserService {
void checkout(String userName, Integer bookId, int buyNum);
}
UserServiceImpl
import com.shg.spring.tx.bean.Book;
import com.shg.spring.tx.dao.AccountDao;
import com.shg.spring.tx.dao.BookDao;
import com.shg.spring.tx.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.math.BigDecimal;
@Service
public class UserServiceImpl implements UserService {
private final BookDao bookDao;
private final AccountDao accountDao;
public UserServiceImpl(BookDao bookDao, AccountDao accountDao) {
this.bookDao = bookDao;
this.accountDao = accountDao;
}
@Override
public void checkout(String userName, Integer bookId, int buyNum) {
// 1. 查询图书信息
Book bookById = bookDao.getBookById(bookId);
// 2. 计算总价
BigDecimal totalPrice = bookById.getPrice().multiply(new BigDecimal(buyNum));
// 3. 扣减库存
bookDao.updateBookStockById(bookId, buyNum);
// 4. 扣减余额
accountDao.updateBalanceByUserName(userName, totalPrice);
}
}
3. Spring事务案例
3.1. 没有事务的情况
在上述的UserServiceImp代码中,理想情况下代码执行后,图书book表 和 账户account表都表现正常。即图书库存减少,账户余额扣减成功。但是如果在执行结账(checkout)方法时,抛出了异常,理论上对数据库做的操作都应该回滚。但实际上数据库并没有回滚。所以默认情况下的业务逻辑是没有事务控制的。
3.2. 使用Spring声明式事务
(1)在启动类上标注 @EnableTransactionManagement注解;
(2)在需要事务的方法上标注 @Transactional
启动类
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableTransactionManagement
@SpringBootApplication
public class Spring03TxApplication {
public static void main(String[] args) {
SpringApplication.run(Spring03TxApplication.class, args);
}
}
事务方法
@Transactional
@Override
public void checkout(String userName, Integer bookId, int buyNum) throws InterruptedException, IOException {
// 1. 查询图书信息
Book bookById = bookDao.getBookById(bookId);
// 2. 计算总价
BigDecimal totalPrice = bookById.getPrice().multiply(new BigDecimal(buyNum));
// 3. 扣减库存
bookDao.updateBookStockById(bookId, buyNum);
// 4. 扣减余额
accountDao.updateBalanceByUserName(userName, totalPrice);
// 5. 模拟异常回滚
int i = 1 / 0;
}
3.3. Spring声明式事务的底层原理
(1)事务管理器 TransactionManager:控制事务的提交和回滚
(2)事务拦截器 TransactionInterceptor:控制事务何时提交和回滚
在TransactionInterceptor中有一个调用目标方法的逻辑:
进入 invokeWithTransaction方法,进入到TransactionAspectSupport类中,源码核心方法如下:
/**
* General delegate for around-advice-based subclasses, delegating to several other template
* methods on this class. Able to handle {@link CallbackPreferringPlatformTransactionManager}
* as well as regular {@link PlatformTransactionManager} implementations and
* {@link ReactiveTransactionManager} implementations for reactive return types.
* @param method the Method being invoked
* @param targetClass the target class that we're invoking the method on
* @param invocation the callback to use for proceeding with the target invocation
* @return the return value of the method, if any
* @throws Throwable propagated from the target invocation
*/
@Nullable
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
// If the transaction attribute is null, the method is non-transactional.
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final TransactionManager tm = determineTransactionManager(txAttr);
if (this.reactiveAdapterRegistry != null && tm instanceof ReactiveTransactionManager rtm) {
boolean isSuspendingFunction = KotlinDetector.isSuspendingFunction(method);
boolean hasSuspendingFlowReturnType = isSuspendingFunction &&
COROUTINES_FLOW_CLASS_NAME.equals(new MethodParameter(method, -1).getParameterType().getName());
ReactiveTransactionSupport txSupport = this.transactionSupportCache.computeIfAbsent(method, key -> {
Class<?> reactiveType =
(isSuspendingFunction ? (hasSuspendingFlowReturnType ? Flux.class : Mono.class) : method.getReturnType());
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(reactiveType);
if (adapter == null) {
throw new IllegalStateException("Cannot apply reactive transaction to non-reactive return type [" +
method.getReturnType() + "] with specified transaction manager: " + tm);
}
return new ReactiveTransactionSupport(adapter);
});
return txSupport.invokeWithinTransaction(method, targetClass, invocation, txAttr, rtm);
}
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
if (retVal != null && txAttr != null) {
TransactionStatus status = txInfo.getTransactionStatus();
if (status != null) {
if (retVal instanceof Future<?> future && future.isDone()) {
try {
future.get();
}
catch (ExecutionException ex) {
if (txAttr.rollbackOn(ex.getCause())) {
status.setRollbackOnly();
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
else if (vavrPresent && VavrDelegate.isVavrTry(retVal)) {
// Set rollback-only in case of Vavr failure matching our rollback rules...
retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
}
}
}
commitTransactionAfterReturning(txInfo);
return retVal;
}
else {
Object result;
final ThrowableHolder throwableHolder = new ThrowableHolder();
// It's a CallbackPreferringPlatformTransactionManager: pass a TransactionCallback in.
try {
result = cpptm.execute(txAttr, status -> {
TransactionInfo txInfo = prepareTransactionInfo(ptm, txAttr, joinpointIdentification, status);
try {
Object retVal = invocation.proceedWithInvocation();
if (retVal != null && vavrPresent && VavrDelegate.isVavrTry(retVal)) {
// Set rollback-only in case of Vavr failure matching our rollback rules...
retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
}
return retVal;
}
catch (Throwable ex) {
if (txAttr.rollbackOn(ex)) {
// A RuntimeException: will lead to a rollback.
if (ex instanceof RuntimeException runtimeException) {
throw runtimeException;
}
else {
throw new ThrowableHolderException(ex);
}
}
else {
// A normal return value: will lead to a commit.
throwableHolder.throwable = ex;
return null;
}
}
finally {
cleanupTransactionInfo(txInfo);
}
});
}
catch (ThrowableHolderException ex) {
throw ex.getCause();
}
catch (TransactionSystemException ex2) {
if (throwableHolder.throwable != null) {
logger.error("Application exception overridden by commit exception", throwableHolder.throwable);
ex2.initApplicationException(throwableHolder.throwable);
}
throw ex2;
}
catch (Throwable ex2) {
if (throwableHolder.throwable != null) {
logger.error("Application exception overridden by commit exception", throwableHolder.throwable);
}
throw ex2;
}
// Check result state: It might indicate a Throwable to rethrow.
if (throwableHolder.throwable != null) {
throw throwableHolder.throwable;
}
return result;
}
}
上面这段源码的重点:
completeTransactionAfterThrowing(txInfo, ex); 【异常通知】出现异常时回滚commitTransactionAfterReturning(txInfo); 【返回通知】方法正常执行后,提交事务
通常我们不会自己去实现事务管理器的接口,而是使用Spring给我们提供的事务管理器。Spring声明式事务的底层原理就是:
Spring底层通过 事务管理器 和 事务拦截器 实现Spring的声明式事务。具体来说就是:
(1)事务管理器的顶层接口是 TransactionManage,其子接口中定义了获取事务getTransaction方法、提交事务方法和回滚事务方法。控制事务的提交和回滚。我们默认是使用JdbcTransactionManage这个实现类。
(2)事务拦截器:是一个切面,如果目标方法正常执行,就会调用事务管理器的提交事务方法;如果目标方法执行出现异常,就会调用事务管理器的回滚事务方法。
3.4. 事务注解@Transaction的属性介绍
在@Transactional注解中有许多可以设置的属性,如下图:
下面针对这些属性进行介绍。
3.4.1. value 或 transactionManager属性
用来设置要使用的事务管理器,通常我们不指定,而是使用Spring提供的默认的事务管理器。如下图
3.4.2. label属性
通常不使用,不介绍
3.4.3. propagation属性
先理解什么是事务的传播行为:当一个大的事务方法里面嵌套小的事务方法时,该如何控制大事物和小事物的关系。比如一个结账方法是一个大事物,此方法内部调用了两个事务方法:扣减库存方法 和 扣减余额方法。伪代码如下:
大事物:结账方法
@Transactionpublic void checkout(String userName, Integer bookId, int buyNum) {
// 调用扣减库存方法
bookDao.updateBookStockById(bookId, buyNum);
// 调用扣减余额方法
accountDao.updateBalanceByUserName(userName, totalPrice);
}
小事物:扣减库存方法@Transactional public void updateBookStockById(int bookId, Integer num) { String sql = "update book set stock=stock - ? where id=?"; jdbcTemplate.update(sql, num, bookId); }小事物:扣减余额方法
@Transactional public void updateBalanceByUserName(String userName, BigDecimal money) { String sql = "update account set balance = account.balance - ? where username = ?"; jdbcTemplate.update(sql, money, userName); }有了上面的调用逻辑,当执行结账方法时,已经开启了一个事务;那么结账方法调用扣减库存或扣减余额方法时,扣减库存(后面直接以扣减库存为例)方法该如何 “使用事务”呢?
此时就需要传播行为这个属性进行控制了。
控制事务的传播行为有如下七种:
(1)propagation = Propagation.REQUIRED:支持当前事务,如果不存在,则创建一个新事务。
【解释】指的是内层的方法(updateBookStockById方法)支持当前外面这个方法(checkout方法)的事务。如果当前外面这个方法有事务,我就用你的事务;如果你没有事务我就自己新创建一个事务。
(2)propagation = Propagation.SUPPORTS:支持当前事务,如果不存在,则执行非事务。【解释】指的是内层的方法(updateBookStockById方法)支持当前外面这个方法(checkout方法)的事务。如果当前外面这个方法有事务,我就用你的事务;如果你没有事务我就以非事务方式运行。
(3)propagation = Propagation.MANDATORY:支持当前事务,如果不存在则抛出异常。
【解释】指的是内层的方法(updateBookStockById方法)支持当前外面这个方法(checkout方法)的事务。如果当前外面这个方法没有事务,则抛出异常。
(4)propagation = Propagation.REQUIRES_NEW:创建一个新事务,并挂起当前事务(如果存在)。
【解释】指的是内层的方法(updateBookStockById方法)会创建一个新事物,并在新事务里面执行。会挂起外面这个方法(checkout方法)的事务(如果外面方法存在事务)。
(5)propagation = Propagation.NOT_SUPPORTED:非事务性地执行,挂起当前事务(如果存在)【解释】指的是内层的方法(updateBookStockById方法)以非事务的方式执行,如果当前外面这个方法(checkout方法)有事务,则挂起当前事务。
(6)propagation = Propagation.NEVER:非事务执行,如果存在事务则抛出异常。【解释】指的是内层的方法(updateBookStockById方法)必须以非事务的方式执行,如果当前外面这个方法有事务,则抛出异常。
(7)paopagation = Propagation.NESTED:如果当前事务存在,则在嵌套事务中执行,否则表现为REQUIRED。【解释】指的是内层的事务方法(updateBookStockById方法)在执行时,判断当前外面的方法(checkout方法)是否有事务,如果有,就在外面这个方法的事务中再开启一个事务进行执行。如果外面的方法没有事务,则表现和 propagation = Propagation.REQUIRED 一样。
一张图总结:
总结来说:就是当大事物存在时,里面的小事物要不要和大事物进行绑定(和大事物的共生关系)
【属性传播】
注意:当内层的小事物和外层的大事物共用一个事务,内层小事物的其他一些属性就都失效了(使用外层大事物属性)。比如timeout属性,readOnly属性,isolation属性等。
3.4.4. isolation属性
isolation可以设置如下四种属性:
(1)@Transactional(isolation = Isolation.READ_UNCOMMITTED)
(2)@Transactional(isolation = Isolation.READ_COMMITTED)
(3)@Transactional(isolation = Isolation.REPEATABLE_READ)
(4)@Transactional(isolation = Isolation.SERIALIZABLE)MySQL的默认隔离级别是可重复读,Oracle的默认隔离级别是读已提交。在实际开发中,隔离级别通常从这两个中间选一个。一般使用默认的。
设置事务的隔离级别,针对关系型数据库的事务特性和隔离级别,可以参考我之前写的一篇博文:事务的特性和隔离级别-CSDN博客(1)隔离级别的目的是,当多个读写事务并发执行的时候,防止出现的脏读、不可重复读和幻读等情况的发生。
(2)不同的隔离级别,其可以解决的问题如下表所示:
隔离级别 级别/问题 脏读 不可重复读 幻读 读未提交 √ √ √ 读已提交 × √ √ 可重复读(快照读) × × √ 串行化 × × × (3)思考:为什么隔离级别叫 读未提交、读已提交、可重复读。而不是写未提交、写已提交和可重复写呢?这是因为隔离级别是控制 读 的。为啥控制读呢?
这是因为数据库底层在针对写(更新操作)时,会对其进行加锁,即使有并发写(并发修改)操作,也是要一个一个排队去执行更新操作。所以不会有并发问题。所以数据库的写操作会比较慢。
如果只是并发读,也不会出现并发问题,因为数据没有改变,读多少次数据都是一样的。
所以并发问题会出现在同时存在读写并发的场景。所以说读写一旦并发的时候,就需要有一种机制来控制有一个人在写时,控制这个读的人,应该何时可以读(而不用控制其他写的人,因为写操作,数据库本身就会加锁)。(a)如果写的人,写了一半就可以让另一个人去读到还未写完的数据。这就是读未提交;
(b)如果写的人,只有写完了,才可以让另一个人去读到刚才写入的数据。这就是读已提交
(c)如果写的人,写完了,事务也提交了,但是另一个读的人,再次读取还是和自己之前读取的数据一样,即:没有读到刚才那个人已经写入的数据。这就是可重复读。
3.4.5. timeout 或 timeoutString属性
控制事务的超时时间(以秒为单位),一旦超过约定时间,事务就会回滚。
注意:事务的超时时间是指从方法开始,到最后一次数据库操作结束经过的时间。代码示例如下:
@Transactional(timeout = 3)
@Override
public void checkout(String userName, Integer bookId, int buyNum) throws InterruptedException, IOException {
// 1. 查询图书信息
Book bookById = bookDao.getBookById(bookId);
// 2. 计算总价
BigDecimal totalPrice = bookById.getPrice().multiply(new BigDecimal(buyNum));
// 3. 扣减库存
bookDao.updateBookStockById(bookId, buyNum);
// 模拟事务超时
//Thread.sleep(3000);
// 4. 扣减余额(这是事务方法最后一次执行数据库操作,事务执行时间是以这次操作执行完成进行耗时统计的)
accountDao.updateBalanceByUserName(userName, totalPrice);
// 如果在这里进行睡眠(模拟业务耗时操作),则Spring事务不会统计超时。
Thread.sleep(3000);
}
3.4.6. readOnly属性
如果整个事务都只是一个读操作,则可以把readOnly设置成true,可以实现底层运行时优化。 总结就是:readOnly可以做到只读优化
3.4.7. rollbackFor 或 rollbackForClassName属性
(1)指明哪些异常出现时,事务进行回滚。默认并不是所有异常都一定进行回滚。
(2)默认运行时异常是可以进行回滚的,即Error和RuntimeException及其子类异常可以进行回滚。而编译时异常(也叫已检查异常)(除了运行时异常,其他都是编译时异常)默认是不回滚的。
(3)如果我们设置了rollbackFor属性,那么可以回滚的异常就是 运行时异常 + rollbackFor指定的异常。
(4)通常在实际业务中,我们都设置rollbackFor={Exception.class}
3.4.8. noRollbackFor 或 noRollbackForClassName 属性
指明哪些异常不需要回滚,默认不回滚的异常是:编译时异常+noRollbackFor指定的异常