分佈式事務介紹
所謂事務,就是一系列業務操作構成的獨立的執行單元。比如用戶購買商品下單的行爲,需要執行創建訂單,扣減商品庫存的兩個不同的數據庫操作,這就是一個事務。事務最重要的特性就是要支持原子性,要麼所有操作全部成功,要麼全部失敗。爲什麼要這樣設計呢?如果一切順利,當然什麼問題都不會有。但天有不測風雲,沒有誰能保證系統一直不會出錯,如果哪一天訂單已經創建成功了,但在扣減對應商品庫存時突然失敗了,那就麻煩大了,訂單數據和商品的庫存數據可能就對不上了,有可能商品早都沒貨了,客戶還能繼續下單購買。
所以,事務在保持數據一致性的方面是非常重要的。在單服務系統中我們一般不需要爲事務操心,數據庫已經爲我們考慮了一切,只要在操作開始前聲明瞭事務,那麼調用過程只要發生了錯誤事務會自動回滾到操作開始時的狀態。
但在微服務系統中,不同的業務操作可能被分割到不同的模塊,而不同業務模塊都會配置一個獨立的數據源,甚至訂單服務和倉儲服務可能都不在同一個數據庫中,這樣顯然就不能只依靠本地數據庫事務來解決問題。我們需要一種能夠跨網絡和應用的分佈式事務機制,分佈式事務的主要實現思路一般分爲兩種:
剛性事務:類似於上面說的數據庫的本地事務,遵循ACID原則,要求數據的強一致性;代表性的實現就是二階段提交(XA),通過引入一個事務協調者,所有事務的參與者將操作成敗通知協調者,再由協調者根據所有參與者的反饋情報決定各參與者是否要提交操作還是中止操作。XA一度是分佈式事務的事實標準,但實際實現上卻存在很多問題,比如事務在等待提交過程中處於同步阻塞狀態,導致資源長時間佔用,整體性能很差。
柔性事務:遵循BASE理論,最終一致性;與剛性事務不同,柔性事務允許一定時間內,不同節點的數據不一致,但要求最終一致。比如剛纔我們說到的客戶下單的例子,如果扣減庫存的時候出錯了,我們先不回滾系統,而是將扣減庫存的調用請求存起來(比如存放到一個消息隊列中),定時去重試。這樣雖然訂單數據和庫存數據在某個時間段內是不一致的,但一旦庫存服務恢復了,庫存扣減請求就能夠正常調用了,這樣數據在某個時間點之後就會同步成一致的狀態,這就是最終一致性。最終一致性提高了系統的可用性(客戶下單可以不受倉儲服務故障的影響),但也可能在數據不一致期間發生問題(比如超賣),這個需要考慮業務上是否能夠接受。柔性事務的實現主要有重試和補償兩種機制,重試就是剛纔描述方式,系統將出錯的請求存儲下來然後不斷進行重試,直到成功;而補償就是在出錯之後,執行類似的一個undo操作,消除已提交的操作對數據的影響,比如扣減庫存失敗以後就直接把之前創建成功的訂單再刪除掉,TCC(Try/Confirm/Cancel)型事務就是採用這樣的方式。
Seata 的相關概念
由於類似XA的剛性事務實現在複雜的分佈式環境中存在大量的問題,所以目前主流的分佈式事務實現方案都是走柔性事務路線的。比較流行的解決方案有阿里開源的seata,還有獨立開發者發佈的LCN框架,但LCN目前的維護更新遇到了困難(獨立開發者的悲哀啊)。seata提供了幾種不同的事務模式實現,包括:AT、TCC、SAGA 和 XA。在這裏我們重點介紹一下AT模式,其它模式的說明請查看 seata的官網。AT模式也是走的補償路線,但是不需要應用程序去關心調用失敗後如何恢復數據,而是由框架本身負責去恢復收當前事務影響的所有數據,這非常類似於我們已經習慣了的本地事務,對開發者的負擔也比較小。
seata可分爲三個角色:TC,TM和RM:
TC - 事務協調者: 維護全局和分支事務的狀態,驅動全局事務提交或回滾。業務無關,需要在服務端獨立進行部署。
TM - 事務管理器:發起全局事務的開始,提交或回滾(發起後由TC協調其它的RM執行相應的操作),該角色會集成到應用程序當中。
RM - 資源管理器:全局事務的參與者,管理分支事務處理的資源,與TC交談以註冊分支事務和報告分支事務的狀態,並驅動分支事務提交或回滾,該角色也是集成到應用程序當中的。
部署TC server
首先在 官網 下載seata server的二進制文件,其實就是一個spring boot的應用程序。在服務器上解壓之後,進入到conf文件夾,我們需要重點關注registry.conf這個文件,它的作用是配置seata server(TC)註冊到哪個註冊中心,目前支持幾乎所有主流的註冊中心,TC會從配置中心中讀取相應的配置,TM和RM實例也可以通過同一個註冊中心找到TC部署的位置,從而實現TC的集羣化部署。我們還是延用之前就部署好的consul來作爲註冊中心,只需要將registry.conf中的type修改爲“consul”即可,並調整配置文件中consul的部署地址:
# 註冊中心配置,用於TC,TM,RM的相互服務發現
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "consul"
consul {
cluster = "seata"
serverAddr = "192.168.1.220:8500"
}
}
# 配置中心配置,用於讀取TC的相關配置
config {
# file、nacos 、apollo、zk、consul、etcd3
type = "file"
file {
name = "file.conf"
}
}
file.conf中是TC的一些持久化配置選項,seata支持文件和數據庫兩種持久化方式,文件模式比數據庫更快,但不支持併發操作,如果要部署TC集羣就必須要使用數據庫進行持久化。這裏我們爲了簡化,就直接使用默認的file模式。file.conf也可以初始化到配置中心,通過配置中心來統一讀取,這對於集羣部署是有幫助的,初始化的方式可參考 這裏 。修改完配置後,直接運行seata-server.sh啓動TC server:
$ sh ./bin/seata-server.sh -p 8091 -h 127.0.0.1
啓動成功後我們可以在consul的控制檯看到TC註冊的服務:
集成TM和RM
seata的AT模式會在服務調用失敗後,自動恢復受影響的數據,其原理就是在啓動事務之後,會自動分析當前事務中執行的SQL對數據的影響,把受影響的數據直接存儲到本地數據庫中,如果事務回滾了,就通過存儲的數據備份對原始數據進行恢復( AT模式介紹 ),所以我們需要在每個RM所在的業務數據庫中初始化seata的undo_log表。我們之前的spring-cloud-demo中也還沒有建立相應的業務數據庫,爲了測試分佈式事務,我們需要爲order-service和storage-service創建兩個業務庫,並初始化相關的業務表格,整個初始化腳本如下:
-- 創建order-service數據庫
CREATE DATABASE `cloud-demo-order`;
CREATE TABLE IF NOT EXISTS `cloud-demo-order`.`t_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`customer_code` varchar(45) DEFAULT NULL COMMENT '客戶編碼',
`good_code` varchar(45) DEFAULT NULL COMMENT '產品編碼',
`good_quantity` int(11) DEFAULT NULL COMMENT '購買數量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- AT模式需要的undo log表
CREATE TABLE IF NOT EXISTS `cloud-demo-order`.`undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
-- 創建storage-service數據庫
CREATE DATABASE `cloud-demo-storage`;
CREATE TABLE IF NOT EXISTS `cloud-demo-storage`.`t_inventory` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`good_code` varchar(45) DEFAULT NULL COMMENT '產品編碼',
`good_quantity` int(11) DEFAULT NULL COMMENT '庫存總量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- AT模式需要的undo log表
CREATE TABLE IF NOT EXISTS `cloud-demo-storage`.`undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
初始化腳本後,在order-serivce模塊和storage-service模快引入seata的依賴包和相關配置:
<!--在spring cloud中自動配置seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2.2.1.RELEASE</version>
</dependency>
需要注意的是maven庫中還有一個包名稱是spring-cloud-alibaba-seata,我看了一下兩者的源碼是一致的,任意引用哪一個包都可以,不知道爲什麼會有兩個不同名稱。在模塊的application.yml中加入如下內容(以order-service模塊的配置爲例):
# Seata 配置項,對應 SeataProperties 類
seata:
application-id: ${spring.application.name} # Seata 應用編號,默認爲 ${spring.application.name}
tx-service-group: ${spring.application.name}-group # 該應用所屬事務組編號,用於尋找TC集羣的映射
# Seata 服務配置項,對應 ServiceProperties 類
service:
#事務分組與TC集羣(seata)集羣的映射關係,order-service-group對應tx-service-group參數,值爲一個虛擬的TC集羣名稱
#默認值就是default,如果無需分組可以不用設置
vgroup-mapping:
order-service-group: default
# Seata註冊中心配置項
registry:
type: consul # 註冊中心類型,默認爲 file
consul:
cluster: seata #對應seata集羣在consul中註冊的服務名
server-addr: 192.168.1.220:8500
需要注意的是 registry.consul.cluster 中指定的名稱需要與TC server的同名配置項中指定的名稱一致(默認的值是default),否則會找不到TC server,這個在官方文檔和很多教程中都沒有說明。 更多配置內容可以查看 配置說明。
另外爲了方便對數據庫進行操作,order-serivce模塊和storage-service模快都引入了mybatis-plus框架的相關依賴和配置,這裏就不做詳細介紹了,具體可以去查看源碼。一切準備好以後,將原有的orderService.createNewOrder方法做如下改造:
@GlobalTransactional
public Integer createNewOrder(OrderDTO orderDTO) {
Order newOrder = new Order();
newOrder.setCustomerCode(orderDTO.getCustomerCode());
newOrder.setGoodCode(orderDTO.getGoodCode());
newOrder.setGoodQuantity(orderDTO.getQuantity());
//向本地數據庫插入訂單信息
this.save(newOrder);
InventoryChangeDTO req = new InventoryChangeDTO();
req.setGoodCode(orderDTO.getGoodCode());
req.setQuantity(orderDTO.getQuantity());
//調用遠程倉儲服務變更庫存
Integer remainQuantity = storageService.updateInventoryOfGood(req);
return remainQuantity;
}
其實除了寫入數據庫的相關代碼,這裏最重要的變化就是加入了@GlobalTransactional註解,這個註解標記了當前方法會開啓一個全局事務。在本例中order-service模塊既是 TM (我的理解是聲明@GlobalTransactional的地方就算是一個TM)又是 RM,而storage-service模塊則是另一個 RM。
通過PostMan請求創建訂單的接口地址:http://localhost:9001/api/order/create-order,一切順利的話就能在數據庫中看到新增的訂單數據和庫存數據。
從相關的日誌中可以看出全局事務的開始和提交:
2020-05-06 15:28:21.311 INFO 11516 --- [nio-9001-exec-7] i.seata.tm.api.DefaultGlobalTransaction : Begin new global transaction [192.168.1.220:8091:2010342569]
2020-05-06 15:28:21.457 INFO 11516 --- [nio-9001-exec-7] i.seata.tm.api.DefaultGlobalTransaction : [192.168.1.220:8091:2010342569] commit status: Committed
2020-05-06 15:28:21.457 INFO 11516 --- [nio-9001-exec-7] c.g.d.s.o.controller.Controller : 剩餘數量:-20
2020-05-06 15:28:21.576 INFO 11516 --- [atch_RMROLE_1_4] i.s.core.rpc.netty.RmMessageListener : onMessage:xid=192.168.1.220:8091:2010342569,branchId=2010342570,branchType=AT,resourceId=jdbc:mysql://192.168.1.212:3306/cloud-demo-order,applicationData=null
2020-05-06 15:28:21.576 INFO 11516 --- [atch_RMROLE_1_4] io.seata.rm.AbstractRMHandler : Branch committing: 192.168.1.220:8091:2010342569 2010342570 jdbc:mysql://192.168.1.212:3306/cloud-demo-order null
2020-05-06 15:28:21.576 INFO 11516 --- [atch_RMROLE_1_4] io.seata.rm.AbstractRMHandler : Branch commit result: PhaseTwo_Committed
我們將 storageService.updateInventoryOfGood 方法稍稍修改一下,故意引發一個異常來測試全局事務的回滾:
public Integer changeInventory(InventoryChangeDTO req) {
Inventory inventory = this.getOne(Wrappers.<Inventory>lambdaQuery().eq(Inventory::getGoodCode, req.getGoodCode()));
if (inventory == null) {
inventory = new Inventory();
inventory.setGoodQuantity(0);
inventory.setGoodCode(req.getGoodCode());
}
inventory.setGoodQuantity(inventory.getGoodQuantity() - req.getQuantity());
this.saveOrUpdate(inventory);
//引發異常回滾
Object exceptionCause = null;
exceptionCause.toString();
return inventory.getGoodQuantity();
}
還需要注意一點,測試全局事務回滾時需要將我們直接配置的hystrix fallback關閉,否則應用程序調用遠程接口失敗後會觸發fallback機制,從而讓seata認爲遠程調用是成功的,就不會觸發回滾:
//@FeignClient(name = "storage-service", fallback = StorageServiceFallback.class)
//測試全局事務回滾時需要註釋掉fallback,否則接口會返回默認的值導致事務無法回滾
@FeignClient(name = "storage-service")
public interface StorageService {
@PostMapping("/api/storage/change-inventory")
Integer updateInventoryOfGood(InventoryChangeDTO inventoryChangeDTO);
}
然後我們再次請求創建訂單的服務,從日誌上可以看出,事務已成功觸發回滾操作:
2020-05-06 15:50:57.809 INFO 3804 --- [nio-9001-exec-7] i.seata.tm.api.DefaultGlobalTransaction : Begin new global transaction [192.168.1.220:8091:2010342574]
2020-05-06 15:50:59.157 INFO 3804 --- [orage-service-1] c.netflix.config.ChainedDynamicProperty : Flipping property: storage-service.ribbon.ActiveConnectionsLimit to use NEXT property: niws.loadbalancer.availabilityFilteringRule.activeConnectionsLimit = 2147483647
2020-05-06 15:50:59.275 INFO 3804 --- [orage-service-1] c.n.u.concurrent.ShutdownEnabledTimer : Shutdown hook installed for: NFLoadBalancer-PingTimer-storage-service
2020-05-06 15:50:59.275 INFO 3804 --- [orage-service-1] c.netflix.loadbalancer.BaseLoadBalancer : Client: storage-service instantiated a LoadBalancer: DynamicServerListLoadBalancer:{NFLoadBalancer:name=storage-service,current list of Servers=[],Load balancer stats=Zone stats: {},Server stats: []}ServerList:null
2020-05-06 15:50:59.290 INFO 3804 --- [orage-service-1] c.n.l.DynamicServerListLoadBalancer : Using serverListUpdater PollingServerListUpdater
2020-05-06 15:50:59.325 INFO 3804 --- [orage-service-1] c.netflix.config.ChainedDynamicProperty : Flipping property: storage-service.ribbon.ActiveConnectionsLimit to use NEXT property: niws.loadbalancer.availabilityFilteringRule.activeConnectionsLimit = 2147483647
2020-05-06 15:50:59.329 INFO 3804 --- [orage-service-1] c.n.l.DynamicServerListLoadBalancer : DynamicServerListLoadBalancer for client storage-service initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=storage-service,current list of Servers=[192.168.1.252:9002],Load balancer stats=Zone stats: {unknown=[Zone:unknown; Instance count:1; Active connections count: 0; Circuit breaker tripped count: 0; Active connections per server: 0.0;]
},Server stats: [[Server:192.168.1.252:9002; Zone:UNKNOWN; Total Requests:0; Successive connection failure:0; Total blackout seconds:0; Last connection made:Thu Jan 01 08:00:00 CST 1970; First connection made: Thu Jan 01 08:00:00 CST 1970; Active Connections:0; total failure count in last (1000) msecs:0; average resp time:0.0; 90 percentile resp time:0.0; 95 percentile resp time:0.0; min resp time:0.0; max resp time:0.0; stddev resp time:0.0]
]}ServerList:ConsulServerList{serviceId='storage-service', tag=null}
2020-05-06 15:51:00.119 INFO 3804 --- [nio-9001-exec-7] i.seata.tm.api.DefaultGlobalTransaction : [192.168.1.220:8091:2010342574] rollback status: Rollbacked
本文的相關代碼可以查看這裏 spring-cloud-demo