Spring Boot 使用事務(wù)
1. 前言
工作中確實碰到過一些不知道使用事務(wù)的朋友,毫無疑問會給項目帶來一些風(fēng)險。
舉個簡單的例子吧,網(wǎng)購的時候需要扣減庫存,同時生成訂單。如果扣庫存成功了,沒生成訂單,結(jié)果是庫存不知道為何變少了;如果生成訂單了,沒扣庫存,那就有可能賣出去的數(shù)量比庫存還多。
這兩種情況都是不能接受的,我們必須保證這兩個對數(shù)據(jù)庫的更新操作同時成功,或者同時失敗。
事務(wù)就是這樣一種機制,將對數(shù)據(jù)庫的一系列操作視為一個執(zhí)行單元,保證單元內(nèi)的操作同時成功,或者當(dāng)有一個操作失敗時全部失敗。
2. 實例場景
在 Spring Boot 中使用事務(wù)非常簡單,本小節(jié)我們通過商品扣減庫存、生成訂單的實例,演示下 Spring Boot 中使用事務(wù)的具體流程。
3. 數(shù)據(jù)庫模塊實現(xiàn)
需要有一個商品表,保存商品的唯一標(biāo)識、名稱、庫存數(shù)量,結(jié)構(gòu)如下:
實例:
CREATE TABLE `goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '唯一標(biāo)識',
`name` varchar(255) DEFAULT NULL COMMENT '商品名稱',
`num` bigint(255) DEFAULT NULL COMMENT '庫存數(shù)量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
購買商品后還需要生成訂單,保存訂單唯一標(biāo)識、購買商品的 id 、購買數(shù)量。
實例:
CREATE TABLE `order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '唯一標(biāo)識',
`goods_id` bigint(20) DEFAULT NULL COMMENT '商品id',
`count` bigint(20) DEFAULT NULL COMMENT '購買數(shù)量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
4. Spring Boot 后端實現(xiàn)
接下來,我們開始開發(fā) Spring Boot 后端項目,并且使用事務(wù)實現(xiàn)扣減庫存、生成訂單功能。數(shù)據(jù)庫訪問部分使用比較流行的 MyBatis 框架。
4.1 使用 Spring Initializr 創(chuàng)建項目
Spring Boot 版本選擇 2.2.5 ,Group 為 com.imooc , Artifact 為 spring-boot-transaction,生成項目后導(dǎo)入 Eclipse 開發(fā)環(huán)境。
4.2 引入項目依賴
我們引入熱部署依賴、 Web 依賴、數(shù)據(jù)庫訪問相關(guān)依賴及測試相關(guān)依賴,具體如下:
實例:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 熱部署 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
<!-- Web支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MySQL驅(qū)動 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- 集成MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.2</version>
</dependency>
<!-- junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<!-- 測試 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
4.3 數(shù)據(jù)源配置
修改 application.properties 文件,配置數(shù)據(jù)源信息。
實例:
# 配置數(shù)據(jù)庫驅(qū)動
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
# 配置數(shù)據(jù)庫url
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/shop?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC
# 配置數(shù)據(jù)庫用戶名
spring.datasource.username=root
# 配置數(shù)據(jù)庫密碼
spring.datasource.password=Easy@0122
4.4 開發(fā)數(shù)據(jù)對象類
開發(fā) goods 表對應(yīng)的數(shù)據(jù)對象類 GoodsDo ,代碼如下:
實例:
/**
* 商品類
*/
public class GoodsDo {
/**
* 商品id
*/
private Long id;
/**
* 商品名稱
*/
private String name;
/**
* 商品庫存
*/
private Long num;
// 省略 get set
}
然后開發(fā) order 表對應(yīng)的數(shù)據(jù)對象類 OrderDo,代碼如下:
實例:
/**
* 訂單類
*/
public class OrderDo {
/**
* 訂單id
*/
private Long id;
/**
* 商品id
*/
private Long goodsId;
/**
* 購買數(shù)量
*/
private Long count;
// 省略 get set
}
4.5 開發(fā)數(shù)據(jù)訪問層
首先定義商品數(shù)據(jù)訪問接口,實現(xiàn)查詢剩余庫存與扣減庫存功能。
實例:
/**
* 商品數(shù)據(jù)庫訪問接口
*/
@Repository // 標(biāo)注數(shù)據(jù)訪問組件
public interface GoodsDao {
/**
* 查詢商品信息(根據(jù)id查詢單個商品信息)
*/
public GoodsDo selectForUpdate(Long id);
/**
* 修改商品信息(根據(jù)id修改其他屬性值)
*/
public int update(GoodsDo Goods);
}
注意,在查詢商品剩余庫存時,我們采用面向?qū)ο蟮姆椒?,將對?yīng) id 的商品信息全部取出,更加方便點。采用 selectForUpdate 命名,表示該方法使用了 select ... for update
的 SQL 語句查詢方式,以鎖定數(shù)據(jù)庫對應(yīng)記錄,規(guī)避高并發(fā)場景下庫存修改錯誤問題。同樣 update 方法也采用了面向?qū)ο蟮姆绞?,根?jù) id 修改其他信息,方便復(fù)用。
然后定義訂單數(shù)據(jù)訪問接口,實現(xiàn)生成訂單的功能。
實例:
/**
* 訂單數(shù)據(jù)庫訪問接口
*/
@Repository // 標(biāo)注數(shù)據(jù)訪問組件
public interface OrderDao {
/**
* 新增訂單
*/
public int insert(OrderDo order);
}
然后,我們修改 Spring Boot 配置類,添加 @MapperScan 注解,掃描數(shù)據(jù)訪問接口所在的包。
實例:
@SpringBootApplication
@MapperScan("com.imooc.springboottransaction") // 指定MyBatis掃描的包,以便將數(shù)據(jù)訪問接口注冊為Bean
public class SpringBootTransactionApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootTransactionApplication.class, args);
}
}
4.6 添加 MyBatis 映射文件
編寫 GoodsDao 、 OrderDao 對應(yīng)的映射文件, 首先我們通過 application.properties 指定映射文件的位置:
實例:
# 指定MyBatis配置文件位置
mybatis.mapper-locations=classpath:mapper/*.xml
然后在 resources/mapper 目錄下新建 GoodsMapper.xml 文件,該文件就是 goods 表對應(yīng)的映射文件,內(nèi)容如下:
實例:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 本映射文件對應(yīng)GoodsDao接口 -->
<mapper namespace="com.imooc.springboottransaction.GoodsDao">
<!-- 對應(yīng)GoodsDao中的selectForUpdate方法 -->
<select id="selectForUpdate" resultMap="resultMapBase" parameterType="java.lang.Long">
select <include refid="sqlBase" /> from goods where id = #{id} for update
</select>
<!-- 對應(yīng)GoodsDao中的update方法 -->
<update id="update" parameterType="com.imooc.springboottransaction.GoodsDo">
update goods set name=#{name},num=#{num} where id=#{id}
</update>
<!-- 可復(fù)用的sql模板 -->
<sql id="sqlBase">
id,name,num
</sql>
<!-- 保存SQL語句查詢結(jié)果與實體類屬性的映射 -->
<resultMap id="resultMapBase" type="com.imooc.springboottransaction.GoodsDo">
<id column="id" property="id" />
<result column="name" property="name" />
<result column="num" property="num" />
</resultMap>
</mapper>
同樣我們在 resources/mapper 目錄下新建 OrderMapper.xml 文件,該文件是 order 表對應(yīng)的映射文件,內(nèi)容如下:
實例:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 本映射文件對應(yīng)OrderDao接口 -->
<mapper namespace="com.imooc.springboottransaction.OrderDao">
<!-- 對應(yīng)OrderDao中的insert方法 -->
<insert id="insert" parameterType="com.imooc.springboottransaction.OrderDo">
insert into `order` (goods_id,count) values (#{goodsId},#{count})
</insert>
</mapper>
4.7 編寫服務(wù)方法
下單這個操作,可以封裝為一個服務(wù)方法,不管是手機端下單還是電腦端下單都可以調(diào)用。
我們新建訂單服務(wù)類 OrderService ,并在其中實現(xiàn)下單方法 createOrder ,代碼如下:
實例:
/**
* 訂單服務(wù)類
*/
@Service // 注冊為服務(wù)類
public class OrderService {
@Autowired
private GoodsDao goodsDao;
@Autowired
private OrderDao orderDao;
/**
* 下單
*
* @param goodsId 購買商品id
* @param count 購買商品數(shù)量
* @return 生成訂單數(shù)
*/
@Transactional // 實現(xiàn)事務(wù)
public int createOrder(Long goodsId, Long count) {
// 鎖定商品庫存
GoodsDo goods = goodsDao.selectForUpdate(goodsId);
// 扣減庫存
Long newNum = goods.getNum() - count;
goods.setNum(newNum);
goodsDao.update(goods);
// 生成訂單
OrderDo order = new OrderDo();
order.setGoodsId(goodsId);
order.setCount(count);
int affectRows = orderDao.insert(order);
return affectRows;
}
}
我們在 createOrder 方法上添加了 @Transactional
注解,該注解為 createOrder 方法開啟了事務(wù),當(dāng)方法結(jié)束時提交事務(wù)。這樣保證了 createOrder 內(nèi)方法全部執(zhí)行成功,或者全部失敗。
5. 測試
5.1 構(gòu)造測試數(shù)據(jù)
在數(shù)據(jù)庫中構(gòu)造一條測試數(shù)據(jù)如下:
5.2 正常測試
編寫測試方法發(fā)起測試:
實例:
/**
* 訂單測試
*/
@SpringBootTest
class OrderTest {
@Autowired
private OrderService orderService;
/**
* 新增一個商品
*/
@Test
void testCreateOrder() {
// 購買id為1的商品1份
int affectRows = orderService.createOrder(1L, 1L);
assertEquals(1, affectRows);
}
}
運行測試方法后,手機的庫存變?yōu)?19 ,且生成一條訂單記錄,測試通過,具體結(jié)果如下:
5.3 模擬異常測試
修改下單方法,在扣減庫存后拋出異常,看看事務(wù)能否回滾到修改全部未發(fā)生的狀態(tài)。為了便于測試我們將庫存重新設(shè)為 20 ,然后將下單方法修改如下:
實例:
@Transactional // 實現(xiàn)事務(wù)
public int createOrder(Long goodsId, Long count) {
// 鎖定商品庫存
GoodsDo goods = goodsDao.selectForUpdate(goodsId);
// 扣減庫存
Long newNum = goods.getNum() - count;
goods.setNum(newNum);
goodsDao.update(goods);
// 模擬異常
int a=1/0;
// 生成訂單
OrderDo order = new OrderDo();
order.setGoodsId(goodsId);
order.setCount(count);
int affectRows = orderDao.insert(order);
return affectRows;
}
運行測試方法后,拋出異常,查看數(shù)據(jù)庫發(fā)現(xiàn),庫存還是 20 ,說明 goodsDao.update(goods);
的修改沒有提交到數(shù)據(jù)庫,具體結(jié)果如下:
6. 使用注意事項
Spring 事務(wù)在一些情況下不能生效,需要特別注意。
6.1 拋出檢查型異常時事務(wù)失效
首先了解下異常類型:
- Exception 受檢查的異常:在程序中必須使用 try…catch 進行處理,遇到這種異常不處理,編譯器會報錯。例如 IOException 。
- RuntimeException 非受檢查的異常:可以不使用 try…catch 進行處理。例如常見的 NullPointerException 。
在大多數(shù)人潛意識中,只要發(fā)生異常,事務(wù)就應(yīng)該回滾,實際上使用 @Transactional 時,默認(rèn)只對非受檢查異?;貪L。例如:
實例:
@Transactional // 實現(xiàn)事務(wù)
public int createOrder(Long goodsId, Long count) {
// 鎖定商品庫存
GoodsDo goods = goodsDao.selectForUpdate(goodsId);
// 扣減庫存
Long newNum = goods.getNum() - count;
goods.setNum(newNum);
goodsDao.update(goods);
if (count > goods.getNum()) {
// 非受檢查異常拋出時,會回滾
throw new RuntimeException();
}
// 生成訂單
OrderDo order = new OrderDo();
order.setGoodsId(goodsId);
order.setCount(count);
int affectRows = orderDao.insert(order);
return affectRows;
}
實例:
@Transactional // 實現(xiàn)事務(wù)
public int createOrder(Long goodsId, Long count) throws Exception {
// 鎖定商品庫存
GoodsDo goods = goodsDao.selectForUpdate(goodsId);
// 扣減庫存
Long newNum = goods.getNum() - count;
goods.setNum(newNum);
goodsDao.update(goods);
if (count > goods.getNum()) {
//注意!此處為受檢查的異常,就算拋出也不會回滾
throw new Exception();
}
// 生成訂單
OrderDo order = new OrderDo();
order.setGoodsId(goodsId);
order.setCount(count);
int affectRows = orderDao.insert(order);
return affectRows;
}
如果想實現(xiàn)只要拋出異常就回滾,可以通過添加注解 @Transactional(rollbackFor=Exception.class)
實現(xiàn)。
實例:
@Transactional(rollbackFor = Exception.class) // 拋出異常即回滾
public int createOrder(Long goodsId, Long count) throws Exception {
// 鎖定商品庫存
GoodsDo goods = goodsDao.selectForUpdate(goodsId);
// 扣減庫存
Long newNum = goods.getNum() - count;
goods.setNum(newNum);
goodsDao.update(goods);
if (count > goods.getNum()) {
throw new Exception();
}
// 生成訂單
OrderDo order = new OrderDo();
order.setGoodsId(goodsId);
order.setCount(count);
int affectRows = orderDao.insert(order);
return affectRows;
}
OK,我們將在測試類中,將購買數(shù)量設(shè)為大于庫存數(shù)量的 100 ,然后一次測試上面三種情況,就能驗證上面的說法了。
實例:
/**
* 訂單測試
*/
@SpringBootTest
class OrderTest {
@Autowired
private OrderService orderService;
/**
* 創(chuàng)建訂單測試
*/
@Test
void testCreateOrder() throws Exception {
// 購買id為1的商品1份
int affectRows = orderService.createOrder(1L, 100L);
assertEquals(1, affectRows);
}
}
6.2 一個事務(wù)方法調(diào)用另一個事務(wù)方法時失效
先看下面的實例,我們修改下 OrderService 類,通過一個事務(wù)方法調(diào)用 createOrder 方法。
實例:
/**
* 訂單服務(wù)類
*/
@Service // 注冊為服務(wù)類
public class OrderService {
@Autowired
private GoodsDao goodsDao;
@Autowired
private OrderDao orderDao;
@Transactional // 開啟事務(wù)
public int startCreateOrder(Long goodsId, Long count) throws Exception {
return this.createOrder(goodsId, count);
}
/**
* 下單
*
* @param goodsId 購買商品id
* @param count 購買商品數(shù)量
* @return 生成訂單數(shù)
*/
@Transactional(rollbackFor = Exception.class) // 拋出異常即回滾
public int createOrder(Long goodsId, Long count) throws Exception {
// 鎖定商品庫存
GoodsDo goods = goodsDao.selectForUpdate(goodsId);
// 扣減庫存
Long newNum = goods.getNum() - count;
goods.setNum(newNum);
goodsDao.update(goods);
if (count > goods.getNum()) {
// 非受檢查異常拋出時,會回滾
throw new Exception();
}
// 生成訂單
OrderDo order = new OrderDo();
order.setGoodsId(goodsId);
order.setCount(count);
int affectRows = orderDao.insert(order);
return affectRows;
}
}
此時我們在測試類中通過 startCreateOrder 方法再去調(diào)用 createOrder 方法,代碼如下:
實例:
/**
* 訂單測試
*/
@SpringBootTest
class OrderTest {
@Autowired
private OrderService orderService;
/**
* 創(chuàng)建訂單測試
*/
@Test
void testCreateOrder() throws Exception {
// 購買id為1的商品1份
int affectRows = orderService.startCreateOrder(1L, 100L);
assertEquals(1, affectRows);
}
}
startCreateOrder 和 createOrder 方法都是事務(wù)方法,且這兩個方法事務(wù)特性不同 (一個沒有 rollbackFor=Exception.class),如果我們調(diào)用 startTransaction 方法,則 createOrder 中的事務(wù)并不會生效。
也就是說,如果在同一個類中,一個事務(wù)方法調(diào)用另一個事務(wù)方法,可能會導(dǎo)致被調(diào)用的事務(wù)方法的事務(wù)失效!
這是因為 Spring 的聲明式事務(wù)使用了代理,具體機制此處不再探討,但是一定要注意規(guī)避這種事務(wù)失效的場景。
7. 小結(jié)
Spring Boot 中的事務(wù)使用非常簡單,是因為進行了高度的封裝。正是由于封裝的很徹底,所以我們一般接觸不到其具體原理和實現(xiàn)方式,這就需要我們注意一些事務(wù)可能失效的情況,避免因事務(wù)失效帶來風(fēng)險和損失。