SpringBoot實戰電商專案mall(25k+star)地址:github.com/macrozheng/…
摘要
Seata是Alibaba開源的一款分散式事務解決方案,致力於提供高效能和簡單易用的分散式事務服務,本文將通過一個簡單的下單業務場景來對其用法進行詳細介紹。
什麼是分散式事務問題?
單體應用
單體應用中,一個業務操作需要呼叫三個模組完成,此時資料的一致性由本地事務來保證。
微服務應用
隨著業務需求的變化,單體應用被拆分成微服務應用,原來的三個模組被拆分成三個獨立的應用,分別使用獨立的資料來源,業務操作需要呼叫三個服務來完成。此時每個服務內部的資料一致性由本地事務來保證,但是全域性的資料一致性問題沒法保證。
小結
在微服務架構中由於全域性資料一致性沒法保證產生的問題就是分散式事務問題。簡單來說,一次業務操作需要操作多個資料來源或需要進行遠端呼叫,就會產生分散式事務問題。
Seata簡介
Seata 是一款開源的分散式事務解決方案,致力於提供高效能和簡單易用的分散式事務服務。Seata 將為使用者提供了 AT、TCC、SAGA 和 XA 事務模式,為使用者打造一站式的分散式解決方案。
Seata原理和設計
定義一個分散式事務
我們可以把一個分散式事務理解成一個包含了若干分支事務的全域性事務,全域性事務的職責是協調其下管轄的分支事務達成一致,要麼一起成功提交,要麼一起失敗回滾。此外,通常分支事務本身就是一個滿足ACID的本地事務。這是我們對分散式事務結構的基本認識,與 XA 是一致的。
協議分散式事務處理過程的三個元件
- Transaction Coordinator (TC): 事務協調器,維護全域性事務的執行狀態,負責協調並驅動全域性事務的提交或回滾;
- Transaction Manager (TM): 控制全域性事務的邊界,負責開啟一個全域性事務,並最終發起全域性提交或全域性回滾的決議;
- Resource Manager (RM): 控制分支事務,負責分支註冊、狀態彙報,並接收事務協調器的指令,驅動分支(本地)事務的提交和回滾。
一個典型的分散式事務過程
- TM 向 TC 申請開啟一個全域性事務,全域性事務建立成功並生成一個全域性唯一的 XID;
- XID 在微服務呼叫鏈路的上下文中傳播;
- RM 向 TC 註冊分支事務,將其納入 XID 對應全域性事務的管轄;
- TM 向 TC 發起針對 XID 的全域性提交或回滾決議;
- TC 排程 XID 下管轄的全部分支事務完成提交或回滾請求。
seata-server的安裝與配置
-
我們先從官網下載seata-server,這裡下載的是
seata-server-0.9.0.zip
,下載地址:github.com/seata/seata… -
這裡我們使用Nacos作為註冊中心,Nacos的安裝及使用可以參考:Spring Cloud Alibaba:Nacos 作為註冊中心和配置中心使用;
-
解壓seata-server安裝包到指定目錄,修改
conf
目錄下的file.conf
配置檔案,主要修改自定義事務組名稱,事務日誌儲存模式為db
及資料庫連線資訊;
service {
#vgroup->rgroup
vgroup_mapping.fsp_tx_group = "default" #修改事務組名稱為:fsp_tx_group,和客戶端自定義的名稱對應
#only support single node
default.grouplist = "127.0.0.1:8091"
#degrade current not support
enableDegrade = false
#disable
disable = false
#unit ms,s,m,h,d represents milliseconds, seconds, minutes, hours, days, default permanent
max.commit.retry.timeout = "-1"
max.rollback.retry.timeout = "-1"
}
## transaction log store
store {
## store mode: file、db
mode = "db" #修改此處將事務資訊儲存到資料庫中
## database store
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
datasource = "dbcp"
## mysql/oracle/h2/oceanbase etc.
db-type = "mysql"
driver-class-name = "com.mysql.jdbc.Driver"
url = "jdbc:mysql://localhost:3306/seat-server" #修改資料庫連線地址
user = "root" #修改資料庫使用者名稱
password = "root" #修改資料庫密碼
min-conn = 1
max-conn = 3
global.table = "global_table"
branch.table = "branch_table"
lock-table = "lock_table"
query-limit = 100
}
}
複製程式碼
-
由於我們使用了db模式儲存事務日誌,所以我們需要建立一個seat-server資料庫,建表sql在seata-server的
/conf/db_store.sql
中; -
修改
conf
目錄下的registry.conf
配置檔案,指明註冊中心為nacos
,及修改nacos
連線資訊即可;
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos" #改為nacos
nacos {
serverAddr = "localhost:8848" #改為nacos的連線地址
namespace = ""
cluster = "default"
}
}
複製程式碼
- 先啟動Nacos,再使用seata-server中
/bin/seata-server.bat
檔案啟動seata-server。
資料庫準備
建立業務資料庫
- seat-order:儲存訂單的資料庫;
- seat-storage:儲存庫存的資料庫;
- seat-account:儲存賬戶資訊的資料庫。
初始化業務表
order表
CREATE TABLE `order` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`user_id` bigint(11) DEFAULT NULL COMMENT '使用者id',
`product_id` bigint(11) DEFAULT NULL COMMENT '產品id',
`count` int(11) DEFAULT NULL COMMENT '數量',
`money` decimal(11,0) DEFAULT NULL COMMENT '金額',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;
ALTER TABLE `order` ADD COLUMN `status` int(1) DEFAULT NULL COMMENT '訂單狀態:0:建立中;1:已完結' AFTER `money` ;
複製程式碼
storage表
CREATE TABLE `storage` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`product_id` bigint(11) DEFAULT NULL COMMENT '產品id',
`total` int(11) DEFAULT NULL COMMENT '總庫存',
`used` int(11) DEFAULT NULL COMMENT '已用庫存',
`residue` int(11) DEFAULT NULL COMMENT '剩餘庫存',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
INSERT INTO `seat-storage`.`storage` (`id`, `product_id`, `total`, `used`, `residue`) VALUES ('1', '1', '100', '0', '100');
複製程式碼
account表
CREATE TABLE `account` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
`user_id` bigint(11) DEFAULT NULL COMMENT '使用者id',
`total` decimal(10,0) DEFAULT NULL COMMENT '總額度',
`used` decimal(10,0) DEFAULT NULL COMMENT '已用餘額',
`residue` decimal(10,0) DEFAULT '0' COMMENT '剩餘可用額度',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
INSERT INTO `seat-account`.`account` (`id`, `user_id`, `total`, `used`, `residue`) VALUES ('1', '1', '1000', '0', '1000');
複製程式碼
建立日誌回滾表
使用Seata還需要在每個資料庫中建立日誌表,建表sql在seata-server的/conf/db_undo_log.sql
中。
完整資料庫示意圖
製造一個分散式事務問題
這裡我們會建立三個服務,一個訂單服務,一個庫存服務,一個賬戶服務。當使用者下單時,會在訂單服務中建立一個訂單,然後通過遠端呼叫庫存服務來扣減下單商品的庫存,再通過遠端呼叫賬戶服務來扣減使用者賬戶裡面的餘額,最後在訂單服務中修改訂單狀態為已完成。該操作跨越三個資料庫,有兩次遠端呼叫,很明顯會有分散式事務問題。
客戶端配置
-
對seata-order-service、seata-storage-service和seata-account-service三個seata的客戶端進行配置,它們配置大致相同,我們下面以seata-order-service的配置為例;
-
修改application.yml檔案,自定義事務組的名稱;
spring:
cloud:
alibaba:
seata:
tx-service-group: fsp_tx_group #自定義事務組名稱需要與seata-server中的對應
複製程式碼
- 新增並修改file.conf配置檔案,主要是修改自定義事務組名稱;
service {
#vgroup->rgroup
vgroup_mapping.fsp_tx_group = "default" #修改自定義事務組名稱
#only support single node
default.grouplist = "127.0.0.1:8091"
#degrade current not support
enableDegrade = false
#disable
disable = false
#unit ms,s,m,h,d represents milliseconds, seconds, minutes, hours, days, default permanent
max.commit.retry.timeout = "-1"
max.rollback.retry.timeout = "-1"
disableGlobalTransaction = false
}
複製程式碼
- 新增並修改registry.conf配置檔案,主要是將註冊中心改為nacos;
registry {
# file 、nacos 、eureka、redis、zk
type = "nacos" #修改為nacos
nacos {
serverAddr = "localhost:8848" #修改為nacos的連線地址
namespace = ""
cluster = "default"
}
}
複製程式碼
- 在啟動類中取消資料來源的自動建立:
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableDiscoveryClient
@EnableFeignClients
public class SeataOrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(SeataOrderServiceApplication.class, args);
}
}
複製程式碼
- 建立配置使用Seata對資料來源進行代理:
/**
* 使用Seata對資料來源進行代理
* Created by macro on 2019/11/11.
*/
@Configuration
public class DataSourceProxyConfig {
@Value("${mybatis.mapperLocations}")
private String mapperLocations;
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource druidDataSource(){
return new DruidDataSource();
}
@Bean
public DataSourceProxy dataSourceProxy(DataSource dataSource) {
return new DataSourceProxy(dataSource);
}
@Bean
public SqlSessionFactory sqlSessionFactoryBean(DataSourceProxy dataSourceProxy) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSourceProxy);
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
.getResources(mapperLocations));
sqlSessionFactoryBean.setTransactionFactory(new SpringManagedTransactionFactory());
return sqlSessionFactoryBean.getObject();
}
}
複製程式碼
- 使用@GlobalTransactional註解開啟分散式事務:
package com.macro.cloud.service.impl;
import com.macro.cloud.dao.OrderDao;
import com.macro.cloud.domain.Order;
import com.macro.cloud.service.AccountService;
import com.macro.cloud.service.OrderService;
import com.macro.cloud.service.StorageService;
import io.seata.spring.annotation.GlobalTransactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 訂單業務實現類
* Created by macro on 2019/11/11.
*/
@Service
public class OrderServiceImpl implements OrderService {
private static final Logger LOGGER = LoggerFactory.getLogger(OrderServiceImpl.class);
@Autowired
private OrderDao orderDao;
@Autowired
private StorageService storageService;
@Autowired
private AccountService accountService;
/**
* 建立訂單->呼叫庫存服務扣減庫存->呼叫賬戶服務扣減賬戶餘額->修改訂單狀態
*/
@Override
@GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
public void create(Order order) {
LOGGER.info("------->下單開始");
//本應用建立訂單
orderDao.create(order);
//遠端呼叫庫存服務扣減庫存
LOGGER.info("------->order-service中扣減庫存開始");
storageService.decrease(order.getProductId(),order.getCount());
LOGGER.info("------->order-service中扣減庫存結束:{}",order.getId());
//遠端呼叫賬戶服務扣減餘額
LOGGER.info("------->order-service中扣減餘額開始");
accountService.decrease(order.getUserId(),order.getMoney());
LOGGER.info("------->order-service中扣減餘額結束");
//修改訂單狀態為已完成
LOGGER.info("------->order-service中修改訂單狀態開始");
orderDao.update(order.getUserId(),0);
LOGGER.info("------->order-service中修改訂單狀態結束");
LOGGER.info("------->下單結束");
}
}
複製程式碼
分散式事務功能演示
-
執行seata-order-service、seata-storage-service和seata-account-service三個服務;
-
資料庫初始資訊狀態:
- 我們在seata-account-service中製造一個超時異常後,呼叫下單介面:
/**
* 賬戶業務實現類
* Created by macro on 2019/11/11.
*/
@Service
public class AccountServiceImpl implements AccountService {
private static final Logger LOGGER = LoggerFactory.getLogger(AccountServiceImpl.class);
@Autowired
private AccountDao accountDao;
/**
* 扣減賬戶餘額
*/
@Override
public void decrease(Long userId, BigDecimal money) {
LOGGER.info("------->account-service中扣減賬戶餘額開始");
//模擬超時異常,全域性事務回滾
try {
Thread.sleep(30*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
accountDao.decrease(userId,money);
LOGGER.info("------->account-service中扣減賬戶餘額結束");
}
}
複製程式碼
- 此時我們可以發現下單後資料庫資料並沒有任何改變;
- 我們可以在seata-order-service中註釋掉@GlobalTransactional來看看沒有Seata的分散式事務管理會發生什麼情況:
/**
* 訂單業務實現類
* Created by macro on 2019/11/11.
*/
@Service
public class OrderServiceImpl implements OrderService {
/**
* 建立訂單->呼叫庫存服務扣減庫存->呼叫賬戶服務扣減賬戶餘額->修改訂單狀態
*/
@Override
// @GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
public void create(Order order) {
LOGGER.info("------->下單開始");
//省略程式碼...
LOGGER.info("------->下單結束");
}
}
複製程式碼
- 由於seata-account-service的超時會導致當庫存和賬戶金額扣減後訂單狀態並沒有設定為已經完成,而且由於遠端呼叫的重試機制,賬戶餘額還會被多次扣減。
參考資料
Seata官方文件:github.com/seata/seata…
使用到的模組
springcloud-learning
├── seata-order-service -- 整合了seata的訂單服務
├── seata-storage-service -- 整合了seata的庫存服務
└── seata-account-service -- 整合了seata的賬戶服務
複製程式碼
專案原始碼地址
公眾號
mall專案全套學習教程連載中,關注公眾號第一時間獲取。