这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战
Section 01 - 搭建环境
创建数据库
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for account
-- ----------------------------
DROP TABLE IF EXISTS `account`;
CREATE TABLE `account` (
`username` varchar(50) NOT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=gb2312;
-- ----------------------------
-- Records of account
-- ----------------------------
BEGIN;
INSERT INTO `account` VALUES ('Stark', 10000);
INSERT INTO `account` VALUES ('Peter', 10000);
COMMIT;
-- ----------------------------
-- Table structure for book
-- ----------------------------
DROP TABLE IF EXISTS `book`;
CREATE TABLE `book` (
`isbn` varchar(50) NOT NULL,
`book_name` varchar(100) DEFAULT NULL,
`price` int(11) DEFAULT NULL,
PRIMARY KEY (`isbn`)
) ENGINE=InnoDB DEFAULT CHARSET=gb2312;
-- ----------------------------
-- Records of book
-- ----------------------------
BEGIN;
INSERT INTO `book` VALUES ('ISBN-001', 'book01', 100);
INSERT INTO `book` VALUES ('ISBN-002', 'book02', 100);
INSERT INTO `book` VALUES ('ISBN-003', 'book03', 100);
INSERT INTO `book` VALUES ('ISBN-004', 'book04', 100);
INSERT INTO `book` VALUES ('ISBN-005', 'book05', 100);
COMMIT;
-- ----------------------------
-- Table structure for book_stock
-- ----------------------------
DROP TABLE IF EXISTS `book_stock`;
CREATE TABLE `book_stock` (
`isbn` varchar(50) NOT NULL,
`stock` int(11) DEFAULT NULL,
PRIMARY KEY (`isbn`)
) ENGINE=InnoDB DEFAULT CHARSET=gb2312;
-- ----------------------------
-- Records of book_stock
-- ----------------------------
BEGIN;
INSERT INTO `book_stock` VALUES ('ISBN-001', 1000);
INSERT INTO `book_stock` VALUES ('ISBN-002', 1000);
INSERT INTO `book_stock` VALUES ('ISBN-003', 1000);
INSERT INTO `book_stock` VALUES ('ISBN-004', 1000);
INSERT INTO `book_stock` VALUES ('ISBN-005', 1000);
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;
复制代码
创建一个Maven项目spring-declaration-transaction,增加maven依赖
<properties>
<spring-version>5.3.13</spring-version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
<!--spring jdbc依赖-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>${spring-version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.16</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.14</version>
</dependency>
</dependencies>
复制代码
增加数据库链接信息配置文件及application.xml配置文件
driverClassName=com.mysql.cj.jdbc.Driver
url=jdbc:mysql://localhost:3306/tx?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username=root
password=root
initialSize=5
maxActive=20
复制代码
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:util="http://www.springframework.org/schema/util"
xmlns:p="http://www.springframework.org/schema/p" xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util-4.3.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<context:component-scan base-package="com.citi"/>
<!--引用外部配置文件-->
<context:property-placeholder location="classpath:database.properties"></context:property-placeholder>
<!--数据库连接池配置-->
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="${driverClassName}"/>
<property name="url" value="${url}" />
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
<property name="initialSize" value="${initialSize}"/>
<property name="maxActive" value="${maxActive}"/>
</bean>
<!--配置JDBC Template,注入Spring容器中-->
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<constructor-arg name="dataSource" ref="dataSource"></constructor-arg>
</bean>
</beans>
复制代码
Section 02 - 事务初识
以用户买书为例,在项目中业务代码
首先在dao包中新增BookDao,包含了三个方法,用户购买图书后扣除用户余额及图书的库存,以及根据购买的图书id获取图书的价格
@Repository
public class BookDao {
@Resource
JdbcTemplate jdbcTemplate;
/**
* 减余额
*/
public void updateBalance(String username, int price){
String updateBanlanceSql = "UPDATE account SET balance=balance-? WHERE username=?";
jdbcTemplate.update(updateBanlanceSql,price,username);
}
// 获取图书的价格
public int getPrice(String isbn){
String getPriceSql = "SELECT price FROM book WHERE isbn=?";
return jdbcTemplate.queryForObject(getPriceSql,Integer.class,isbn);
}
// 减库存
public void updateStock(String isbn){
String updateStockSql = "UPDATE book_stock SET stock=stock-1 WHERE isbn=?";
jdbcTemplate.update(updateStockSql,isbn);
}
}
复制代码
在service包中新增BookService,包含结账checkout方法
@Service
public class BookService {
@Resource
BookDao bookDao;
// 用户买书结账
public void checkout(String username, String isbn){
// 1.减库存
bookDao.updateStock(isbn);
// 2.减余额
int price = bookDao.getPrice(isbn);
bookDao.updateBalance(username,price);
}
}
复制代码
对BookService进行测试,数据库中所有账户余额均为10000,所有图书价格为100,库存为1000
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:application.xml")
public class BookServiceTest {
@Resource
private BookService bookService;
@Test
public void testCheckoutSuccess() {
bookService.checkout("Stark","ISBN-001");
System.out.println("Checkout Success!");
}
}
复制代码
执行testCheckoutSuccess,控制它打印出Checkout Success!,数据库用户余额和图书库存扣减成功
对于checkout方法,只有余额扣减和库存扣减同时成功才算是成功,余额扣减和库存扣减任何一个失败,整个checkout事务都是失败的,余额扣减和库存扣减是作为一个整体
Section 03 - 声明式事务
声明式事务:以前通过复杂编程来编写一个事务,替换为只需要告诉Spring哪个方法是事务方法即可,由Spring进行事务控制,基于Spring AOP环绕通知。
事务管理器代码的固定模式作为一种横切关注点,可以通过Spring AOP方法模块化,借助Spring AOP框架实现生命是事务管理,事务切面即事务管理器
不同的数据库连接使用不同的事务管理器
xml中配置事务管理器,导入tx名称空间xmlns:tx="www.springframework.org/schema/tx"
<!--事务管理器配置-->
<bean id="dataSourceTransactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"></property>
</bean>
<!--开启基于注解的配置模式-->
<tx:annotation-driven transaction-manager="dataSourceTransactionManager" />
复制代码
验证事务 在BookService的checkout方法中增加异常代码
public void checkout(String username, String isbn){
// 1.减库存
bookDao.updateStock(isbn);
// 异常代码
System.out.println(10/0);
// 2.减余额
int price = bookDao.getPrice(isbn);
bookDao.updateBalance(username,price);
}
复制代码
执行测试方法,检查数据库,库存被扣减1,而余额没有变化
将库存恢复至1000,余额恢复至10000,在checkout方法上增加@Transactional注解,再次测试 控制台报错,余额和库存数量没有变化,删除checkout方法中的异常代码,再次进行测试。
均可以正常扣减
Section 04 - @Transactional
@Transactional注解的属性
- isolation:设置事务隔离级别
- propagation:设置事务传播行为
- noRollbakcFor:设置哪些异常事务不回滚,指定异常的class,是一个数组
- noRollbackForClassName:设置哪些事务不回滚,指定异常的全类名,是一个数组
- rollbackFor:设置哪些异常事务回滚,指定异常的class,是一个数组
- rollbackForClassName:设置哪些异常事务回滚,指定异常的全类名,是一个数组
- readOnly:布尔类型,设置事务为只读事务
- timeout:超时时间,事务执行时间超出设定的时间自动终止并回滚
timeout
int类型,单位是秒,超时时间,事务执行超过指定的时间会自动停止并回滚
@Transactional(timeout = 3)
public void checkout(String username, String isbn){
// 1.减库存
bookDao.updateStock(isbn);
// 休眠5s
try {
Thread.sleep(5000);
} catch (Exception e){
e.printStackTrace();
}
// 2.减余额
int price = bookDao.getPrice(isbn);
bookDao.updateBalance(username,price);
}
复制代码
执行测试方法,库存数和余额数无变化
readOnly
默认为false,readOnly=true可以设置事务为只读事务,实现对事务进行优化,提高查询速度,忽略事务的commit等操作
@Transactional(readOnly = true)
public void checkout(String username, String isbn){
// 1.减库存
bookDao.updateStock(isbn);
// 2.减余额
int price = bookDao.getPrice(isbn);
bookDao.updateBalance(username,price);
}
复制代码
执行测试方法 事务中有更新操作,是不能设置readOnly的
noRollbackFor 和 noRollbackForClassName
- 运行时异常,可以不用处理,默认都回滚
- 编译时异常,使用try-catch处理或者在方法上声明throws,默认不回滚
noRollbackFor可以配置指定异常不回滚,即让原来默认回滚的异常不回滚 noRollbackForClassName指定不回滚的异常的全类名,noRollbackFor指定的是异常的类型,它们都是数组
//@Transactional(noRollbackForClassName = {"java.lang.ArithmeticException"})
@Transactional(noRollbackFor = {ArithmeticException.class})
public void checkout(String username, String isbn) {
// 1.减库存
bookDao.updateStock(isbn);
// 2.减余额
int price = bookDao.getPrice(isbn);
bookDao.updateBalance(username, price);
// 增加运行时异常,默认回滚
System.out.println(10 / 0);
}
复制代码
将stock恢复至1000,余额恢复至10000,执行测试
余额和库存都进行了扣减,实现了让原本默认回滚的不回滚
rollBackFor 和 rollBackForClassName
指定让原本不回滚的异常回滚,所有的编译时异常默认不会滚
// @Transactional(rollbackForClassName = {"java.io.FileNotFoundException"})
@Transactional(rollbackFor = {FileNotFoundException.class})
public void checkout(String username, String isbn) throws FileNotFoundException {
// 1.减库存
bookDao.updateStock(isbn);
// 2.减余额
int price = bookDao.getPrice(isbn);
bookDao.updateBalance(username, price);
// 增加编译时异常,默认不回滚
new FileInputStream("../stark.txt");
}
复制代码
将stock恢复至1000,余额恢复至10000,执行测试
余额和库存没有变化
isolation
数据库事务并发问题
假设现在有两个事务,t1和t2并发执行
事务隔离级别
数据库系统必须具有隔离并发运行各个事务的能力,使它们不会相互影响,避免各种并发问题。一个事务与其他事务隔离的程度称为隔离级别。SQL标准中规定了多种事务隔离级别,不同隔离级别对应不同的干扰程度,隔离级别越高,数据一致性就越好,但并发性越弱。
①读未提交:READ UNCOMMITTED
- 允许t1读取t2未提交的修改,这就导致了脏读。
- t2提交,t1再次读取,读取到的数据是修改过的数据,与上一次读取到的数据不一致,这就导致了不可重复读
- t2向表中插入一些新的book信息,t1查询所有,可以查到新增加的数据,这就导致了幻读
②读已提交:READ COMMITTED
- t1只能读取t2已提交的修改,避免了脏读
③可重复读:REPEATABLE READ
- 确保t1可以多次从一个字段中读取到相同的值,即t1执行期间禁止其它事务对这个字段进行更新。
④串行化:SERIALIZABLE
- 确保t1可以多次从一个表中读取到相同的行,在t1执行期间,禁止其它事务对这个表进行添加、更新、删除操作。可以避免任何并发问题,但性能十分低下。
mysql查询隔离级别的命令
修改MySQL隔离级别
SET [SESSION(当前会话) | GLOBAL(全局)] TRANSACTION ISOLATION LEVEL
{
READ UNCOMMITTED |
READ COMMITTED |
REPEATABLE READ |
SERIALIZABLE
}如:设置当前会话的事务隔离级别为读未提交
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;查询MySQL的隔离级别
SELECT @@global.tx_isolation; //查询全局隔离级别
SELECT @@session.tx_isolation;//查询当前会话隔离级别
SELECT @@tx_isolation;//同上事务操作
开启事务 start transaction;
提交事务 commit;
回滚事务 rollback;
读未提交READ_UNCOMMITTED
BookService中增加getPrice方法,设置isolation属性为READ_UNCOMMITTED
@Transactional(readOnly = true, isolation = Isolation.READ_UNCOMMITTED)
public int getPrice(String isbn){
return bookDao.getPrice(isbn);
}
复制代码
BookServiceTest中增加测试方法
// 读未提交测试
@Test
public void getPrice() {
int price = bookService.getPrice("ISBN-001");
System.out.println("价格:" + price);
}
复制代码
首先执行查询图书价格测试,查询到的图书价格为100
然后在命令行中开启一个事务,修改图书价格,不提交
再次执行测试
控制台输出价格为200,读取到了命令行中未提交的脏数据
读已提交READ_COMMITTED
首先恢复price为100,将代码中隔离级别修改为READ_COMMITTED,此时新打开一个命令行窗口,执行修改图书价格为200的SQL,再次执行getPrice方法的测试
在未提交的情况下,READ_COMMITTED隔离级别读取到的数据仍然是修改前的数据,此时在命令行窗口执行commit提交修改命令,再次执行测试
读取到了提交修改后的值,避免了脏读,但是前后两次读取到的数据不一致导致了不可重复读
可重复读REPEATABLE-READ
任何时候读取都是一样的,打开两个命令行窗口,首先在第一个命令行中开启事务并设置隔离级别为REPEATABLE-READ,并查询一次price,结果为100;接着在第二个命令行中更新并提交price,在第一个命令行中再次查询price,结果仍然为100;在第二个命令行中执行删除并提交的操作,在第一个命令行中再次查询price,结果仍然为100;这就是可重复读,在一个会话SESSION中,读取到的数据自始至终都是一样的,避免了脏读和不可重复读。
plus:并发修改数据时会出现排队的现象,只有等待另一个的修改commit之后,才能继续修改。这种现象与隔离级别无关。
有事务的业务逻辑,容器中保存的是这个业务逻辑的代理对象,只有代理对象才可以执行事务
@Test
public void getClazz(){
System.out.println(bookService.getClass());
}
复制代码
propagation
事务传播行为,如果有多个事务嵌套运行,子事务是否要和上层事务或者已存在的事务共享同一个事务
- REQUIRED: 支持当前事务,没有则新建
- REQUIRES_NEW:新建事务,如果当前存在事务,把当前事务挂起
- SUPPORTS:如果有事务在运行,当前的方法就在这个事务内运行,否则它可以不运行在事务中
- NOT_SUPPORTS:当前的方法不应该运行在事务中,如果有运行的事务,将它挂起
- MANDATORY:当前的方法必须运行在事务内部,如果没有正在运行的事务,就抛出异常
- NEVER:当前方法不应该运行在事务中,如果运行在事务中就抛出异常
- NESTED:如果有事务在运行,当前的方法就应该在这个事务的嵌套事务内运行,否则就启动一个新的事务,并在它自己的事务内运行
以上属性,只有REQUIRED和REQUIRES_NEW是最常用的。
BookDao中新增方法,更新图书价格
public void updatePrice(String isbn, int price){
String updatePriceSql = "UPDATE book SET price=? WHERE isbn=?";
jdbcTemplate.update(updatePriceSql,price,isbn);
}
复制代码
BookService中调用BookDao的updatePrice方法,并增加事务@Transactional
@Transactional
public void updatePrice(String isbn,int price){
bookDao.updatePrice(isbn, price);
}
复制代码
新增一个ComplexService,注入BookService,新增一个collaborateTransaction方法,该方法中调用了BookService的checkout方法和updatePrice方法,并增加@Transactional注解,这样就形成了collaborateTransaction一个大的事务里面嵌套了checkout和updatePrice两个小的事务,当其中collaborateTransaction发生异常或者checkout或者updatePrice发生异常,其他事务是否会回滚呢?
@Service
public class ComplexService {
@Resource
private BookService bookService;
// 综合事务
@Transactional
public void collaborateTransaction(String username,String isbn, int price){
// 事务1,事务2失败事务1是否需要回滚?可设置是否回滚,这就是事务传播行为,
// 是否和上层事务共享一个事务
bookService.checkout(username,isbn);
// 事务2
bookService.updatePrice(isbn,price);
}
}
复制代码
REQUIRED
给BookService的checkout方法和updatePrice设置传播行为,即给@Transactional注解增加属性propagation = Propagation.REQUIRED,checkout的@Transactional注解中其他演示过的属性可以删除,REQUIRED的意思是该方法需要事务,如果存在事务就使用已存在的事务,如果没有就新建一个事务
新增一个测试类ComplexServiceTest, 继承BookServiceTest
public class ComplexServiceTest extends BookServiceTest{
@Resource
private ComplexService complexService;
@Test
public void collaborateTransaction() {
complexService.collaborateTransaction("Stark","ISBN-002",200);
}
}
复制代码
恢复默认数据,所有book的price改为100,stock都改为1000,并且在updatePrice方法中增加异常代码
// 异常代码
System.out.println(10/0);
复制代码
执行测试,查看数据库,price、stock、balance数据都没有变化,可以确定发生异常后chekcout和update都进行了回滚
REQUIRES_NEW
修改checkout的事务属性为Propagation.REQUIRES_NEW,即创建一个新事务,不与其他方法共享事务,发生异常时其他事务不会回滚,再次执行测试。
可以发现price价格不变,updatePrice发生了回滚
stock库存数量减少,checkout正常执行并没有进行回滚
将collaborateTransaction方法中的checkout方法和updatePrice方法都改为REQIURES_NEW,并且在collaborateTransaction中增加异常,注释updatePrice方法中的异常,恢复初始数据后执行测试
price发生变化,updatePrice方法没有回滚
stock库存数量减少,checkout方法也没有回滚
这是因为两个事务是新的事务,与上层方法的事务不属于同一个事务,所有上层方法出现异常并不会影响这两个方法
plus:与上层方法共享事务时,该事物本身设置的属性都失效,以上层事务设置的属性为准。