微服務進階
前面我們瞭解了微服務的一套解決方案,但是它是基於Netflix的解決方案,實際上我們發現,很多框架都已經停止維護了,來看看目前我們所認識到的SpringCloud各大元件的維護情況:
- 註冊中心:Eureka(屬於Netflix,2.x版本不再開源,1.x版本仍在更新)
- 服務呼叫:Ribbon(屬於Netflix,停止更新,已經徹底被移除)、SpringCloud Loadbalancer(屬於SpringCloud官方,目前的預設方案)
- 服務降級:Hystrix(屬於Netflix,停止更新,已經徹底被移除)
- 路由閘道器:Zuul(屬於Netflix,停止更新,已經徹底被移除)、Gateway(屬於SpringCloud官方,推薦方案)
- 配置中心:Config(屬於SpringCloud官方)
可見,我們之前使用的整套解決方案中,超過半數的元件都已經處於不可用狀態,並且部分元件都是SpringCloud官方出手提供框架進行解決。
因此,尋找一套更好的解決方案勢在必行,也就引出了我們本章的主角:SpringCloud Alibaba
阿里巴巴作為業界的網際網路大廠,給出了一套全新的解決方案,官方網站(中文):https://spring-cloud-alibaba-group.github.io/github-pages/2021/zh-cn/index.html
Spring Cloud Alibaba 致力於提供微服務開發的一站式解決方案。
此專案包含開發分散式應用服務的必需元件,方便開發者通過 Spring Cloud 程式設計模型輕鬆使用這些元件來開發分散式應用服務。
依託 Spring Cloud Alibaba,您只需要新增一些註解和少量配置,就可以將 Spring Cloud 應用接入阿里分散式應用解決方案,通過阿里中介軟體來迅速搭建分散式應用系統。
目前 Spring Cloud Alibaba 提供瞭如下功能:
- 服務限流降級:支援 WebServlet、WebFlux, OpenFeign、RestTemplate、Dubbo 限流降級功能的接入,可以在執行時通過控制檯實時修改限流降級規則,還支援檢視限流降級 Metrics 監控。
- 服務註冊與發現:適配 Spring Cloud 服務註冊與發現標準,預設整合了 Ribbon 的支援。
- 分散式配置管理:支援分散式系統中的外部化配置,配置更改時自動重新整理。
- Rpc服務:擴充套件 Spring Cloud 客戶端 RestTemplate 和 OpenFeign,支援呼叫 Dubbo RPC 服務
- 訊息驅動能力:基於 Spring Cloud Stream 為微服務應用構建訊息驅動能力。
- 分散式事務:使用 @GlobalTransactional 註解, 高效並且對業務零侵入地解決分散式事務問題。
- 阿里雲物件儲存:阿里雲提供的海量、安全、低成本、高可靠的雲端儲存服務。支援在任何應用、任何時間、任何地點儲存和訪問任意型別的資料。
- 分散式任務排程:提供秒級、精準、高可靠、高可用的定時(基於 Cron 表示式)任務排程服務。同時提供分散式的任務執行模型,如網格任務。網格任務支援海量子任務均勻分配到所有 Worker(schedulerx-client)上執行。
- 阿里雲簡訊服務:覆蓋全球的簡訊服務,友好、高效、智慧的互聯化通訊能力,幫助企業迅速搭建客戶觸達通道。
可以看到,SpringCloudAlibaba實際上是對我們的SpringCloud元件增強功能,是SpringCloud的增強框架,可以相容SpringCloud原生元件和SpringCloudAlibaba的元件。
開始學習之前,把我們之前打包好的拆分專案解壓,我們將基於它進行講解。
Nacos 更加全能的註冊中心
Nacos(Naming Configuration Service)是一款阿里巴巴開源的服務註冊與發現、配置管理的元件,相當於是Eureka+Config的組合形態。
安裝與部署
Nacos伺服器是獨立安裝部署的,因此我們需要下載最新的Nacos服務端程式,下載地址:https://github.com/alibaba/nacos。
可以看到目前最新的版本是1.4.3
版本(2022年2月27日釋出的),我們直接下載zip
檔案即可。
接著我們將檔案進行解壓,得到以下內容:
我們直接將其拖入到專案資料夾下,便於我們一會在IDEA內部啟動,接著新增執行配置:
其中-m standalone
表示單節點模式,Mac/Linux下記得將直譯器設定為/bin/bash
,由於Nacos在Mac/Linux預設是後臺啟動模式,我們修改一下它的bash檔案,讓它變成前臺啟動,這樣IDEA關閉了Nacos就自動關閉了,否則開發環境下很容易忘記關:
# 註釋掉 nohup $JAVA ${JAVA_OPT} nacos.nacos >> ${BASE_DIR}/logs/start.out 2>&1 &
# 替換成下面的
$JAVA ${JAVA_OPT} nacos.nacos
接著我們點選啟動:
OK,啟動成功,可以看到它的管理頁面地址也是給我們貼出來了: http://localhost:8848/nacos/index.html,訪問這個地址:
預設的使用者名稱和管理員密碼都是nacos
,直接登陸即可,可以看到進入管理頁面之後功能也是相當豐富:
至此,Nacos的安裝與部署完成。
服務註冊與發現
現在我們要實現基於Nacos的服務註冊與發現,那麼就需要匯入SpringCloudAlibaba相關的依賴,我們在父工程將依賴進行管理:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<!-- 這裡引入最新的SpringCloud依賴 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 這裡引入最新的SpringCloudAlibaba依賴,2021.0.1.0版本支援SpringBoot2.6.X -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
接著我們就可以在子專案中新增服務發現依賴了,比如我們以圖書服務為例:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
和註冊到Eureka一樣,我們也需要在配置檔案中配置Nacos註冊中心的地址:
server:
# 之後所有的圖書服務節點就81XX埠
port: 8101
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://cloudstudy.mysql.cn-chengdu.rds.aliyuncs.com:3306/cloudstudy
username: test
password: 123456
# 應用名稱 bookservice
application:
name: bookservice
cloud:
nacos:
discovery:
# 配置Nacos註冊中心地址
server-addr: localhost:8848
接著啟動我們的圖書服務,可以在Nacos的服務列表中找到:
按照同樣的方法,我們接著將另外兩個服務也註冊到Nacos中:
接著我們使用OpenFeign,實現服務發現遠端呼叫以及負載均衡,匯入依賴:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 這裡需要單獨匯入LoadBalancer依賴 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
編寫介面:
@FeignClient("userservice")
public interface UserClient {
@RequestMapping("/user/{uid}")
User getUserById(@PathVariable("uid") int uid);
}
@FeignClient("bookservice")
public interface BookClient {
@RequestMapping("/book/{bid}")
Book getBookById(@PathVariable("bid") int bid);
}
@Service
public class BorrowServiceImpl implements BorrowService{
@Resource
BorrowMapper mapper;
@Resource
UserClient userClient;
@Resource
BookClient bookClient;
@Override
public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
List<Borrow> borrow = mapper.getBorrowsByUid(uid);
User user = userClient.getUserById(uid);
List<Book> bookList = borrow
.stream()
.map(b -> bookClient.getBookById(b.getBid()))
.collect(Collectors.toList());
return new UserBorrowDetail(user, bookList);
}
}
@EnableFeignClients
@SpringBootApplication
public class BorrowApplication {
public static void main(String[] args) {
SpringApplication.run(BorrowApplication.class, args);
}
}
接著我們進行測試:
測試正常,可以自動發現服務,接著我們來多配置幾個例項,去掉圖書服務和使用者服務的埠配置:
然後我們在圖書服務和使用者服務中新增一句列印方便之後檢視:
@RequestMapping("/user/{uid}")
public User findUserById(@PathVariable("uid") int uid){
System.out.println("呼叫使用者服務");
return service.getUserById(uid);
}
現在將全部服務啟動:
可以看到Nacos中的例項數量已經顯示為2
:
接著我們呼叫借閱服務,看看能否負載均衡遠端呼叫:
OK,負載均衡遠端呼叫沒有問題,這樣我們就實現了基於Nacos的服務的註冊與發現,實際上大致流程與Eureka一致。
值得注意的是,Nacos區分了臨時例項和非臨時例項:
那麼臨時和非臨時有什麼區別呢?
- 臨時例項:和Eureka一樣,採用心跳機制向Nacos傳送請求保持線上狀態,一旦心跳停止,代表例項下線,不保留例項資訊。
- 非臨時例項:由Nacos主動進行聯絡,如果連線失敗,那麼不會移除例項資訊,而是將健康狀態設定為false,相當於會對某個例項狀態持續地進行監控。
我們可以通過配置檔案進行修改臨時例項:
spring:
application:
name: borrowservice
cloud:
nacos:
discovery:
server-addr: localhost:8848
# 將ephemeral修改為false,表示非臨時例項
ephemeral: false
接著我們在Nacos中檢視,可以發現例項已經不是臨時的了:
如果這時我們關閉此例項,那麼會變成這樣:
只是將健康狀態變為false,而不會刪除例項的資訊。
叢集分割槽
實際上叢集分割槽概念在之前的Eureka中也有出現,比如:
eureka:
client:
fetch-registry: false
register-with-eureka: false
service-url:
defaultZone: http://localhost:8888/eureka
# 這個defaultZone是個啥玩意,為什麼要用這個名稱?為什麼要要用這樣的形式來宣告註冊中心?
在一個分散式應用中,相同服務的例項可能會在不同的機器、位置上啟動,比如我們的使用者管理服務,可能在成都有1臺伺服器部署、重慶有一臺伺服器部署,而這時,我們在成都的伺服器上啟動了借閱服務,那麼如果我們的借閱服務現在要呼叫使用者服務,就應該優先選擇同一個區域的使用者服務進行呼叫,這樣會使得響應速度更快。
因此,我們可以對部署在不同機房的服務進行分割槽,可以看到例項的分割槽是預設:
我們可以直接在配置檔案中進行修改:
spring:
application:
name: borrowservice
cloud:
nacos:
discovery:
server-addr: localhost:8848
# 修改為重慶地區的叢集
cluster-name: Chongqing
當然由於我們這裡使用的是不同的啟動配置,直接在啟動配置中新增環境變數spring.cloud.nacos.discovery.cluster-name
也行,這裡我們將使用者服務和圖書服務兩個區域都分配一個,借閱服務就配置為成都地區:
修改完成之後,我們來嘗試重新啟動一下(Nacos也要重啟),觀察Nacos中叢集分佈情況:
可以看到現在有兩個叢集,並且都有一個例項正在執行。我們接著去呼叫借閱服務,但是發現並沒有按照區域進行優先呼叫,而依然使用的是輪詢模式的負載均衡呼叫。
我們必須要提供Nacos的負載均衡實現才能開啟區域優先呼叫機制,只需要在配製檔案中進行修改即可:
spring:
application:
name: borrowservice
cloud:
nacos:
discovery:
server-addr: localhost:8848
cluster-name: Chengdu
# 將loadbalancer的nacos支援開啟,整合Nacos負載均衡
loadbalancer:
nacos:
enabled: true
現在我們重啟借閱服務,會發現優先呼叫的是同區域的使用者和圖書服務,現在我們可以將成都地區的服務下線:
可以看到,在下線之後,由於本區域內沒有可用服務了,借閱服務將會呼叫重慶區域的使用者服務。
除了根據區域優先呼叫之外,同一個區域內的例項也可以單獨設定權重,Nacos會優先選擇權重更大的例項進行呼叫,我們可以直接在管理頁面中進行配置:
或是在配置檔案中進行配置:
spring:
application:
name: borrowservice
cloud:
nacos:
discovery:
server-addr: localhost:8848
cluster-name: Chengdu
# 權重大小,越大越優先呼叫,預設為1
weight: 0.5
通過配置權重,某些效能不太好的機器就能夠更少地被使用,而更多的使用那些網路良好效能更高的主機上的例項。
配置中心
前面我們學習了SpringCloud Config,我們可以通過配置服務來載入遠端配置,這樣我們就可以在遠端集中管理配置檔案。
實際上我們可以在bootstrap.yml
中配置遠端配置檔案獲取,然後再進入到配置檔案載入環節,而Nacos也支援這樣的操作,使用方式也比較類似,比如我們現在想要將借閱服務的配置檔案放到Nacos進行管理,那麼這個時候就需要在Nacos中建立配置檔案:
將借閱服務的配置檔案全部(當然正常情況下是不會全部CV的,只會複製那些需要經常修改的部分,這裡為了省事就直接全部CV了)複製過來,注意Data ID的格式跟我們之前一樣,應用名稱-環境.yml
,如果只編寫應用名稱,那麼代表此配置檔案無論在什麼環境下都會使用,然後每個配置檔案都可以進行分組,也算是一種分類方式:
完成之後點選發布即可:
然後在專案中匯入依賴:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
接著我們在借閱服務中新增bootstrap.yml
檔案:
spring:
application:
# 服務名稱和配置檔案保持一致
name: borrowservice
profiles:
# 環境也是和配置檔案保持一致
active: dev
cloud:
nacos:
config:
# 配置檔案字尾名
file-extension: yml
# 配置中心伺服器地址,也就是Nacos地址
server-addr: localhost:8848
現在我們啟動服務試試看:
可以看到成功讀取配置檔案並啟動了,實際上使用上來說跟之前的Config是基本一致的。
Nacos還支援配置檔案的熱更新,比如我們在配置檔案中新增了一個屬性,而這個時候可能需要實時修改,並在後端實時更新,那麼這種該怎麼實現呢?我們建立一個新的Controller:
@RestController
public class TestController {
@Value("${test.txt}") //我們從配置檔案中讀取test.txt的字串值,作為test介面的返回值
String txt;
@RequestMapping("/test")
public String test(){
return txt;
}
}
我們修改一下配置檔案,然後重啟伺服器:
可以看到已經可以正常讀取了:
現在我們將配置檔案的值進行修改:
再次訪問介面,會發現沒有發生變化:
但是後臺是成功檢測到值更新了,但是值卻沒改變:
那麼如何才能實現配置熱更新呢?我們可以像下面這樣:
@RestController
@RefreshScope //新增此註解就能實現自動重新整理了
public class TestController {
@Value("${test.txt}")
String txt;
@RequestMapping("/test")
public String test(){
return txt;
}
}
重啟伺服器,再次重複上述實驗,成功。
名稱空間
我們還可以將配置檔案或是服務例項劃分到不同的名稱空間中,其實就是區分開發、生產環境或是引用歸屬之類的:
這裡我們建立一個新的名稱空間:
可以看到在dev名稱空間下,沒有任何配置檔案和服務:
我們在不同的名稱空間下,例項和配置都是相互之間隔離的,我們也可以在配置檔案中指定當前的名稱空間。
實現高可用
由於Nacos暫不支援Arm架構晶片的Mac叢集搭建,本小節用Linxu雲主機(Nacos比較吃記憶體,2個Nacos伺服器叢集,至少2G記憶體)環境演示。
通過前面的學習,我們已經瞭解瞭如何使用Nacos以及Nacos的功能等,最後我們來看看,如果像之前Eureka一樣,搭建Nacos叢集,實現高可用。
官方方案:https://nacos.io/zh-cn/docs/cluster-mode-quick-start.html
http://ip1:port/openAPI 直連ip模式,機器掛則需要修改ip才可以使用。
http://SLB:port/openAPI 掛載SLB模式(內網SLB,不可暴露到公網,以免帶來安全風險),直連SLB即可,下面掛server真實ip,可讀性不好。
http://nacos.com:port/openAPI 域名 + SLB模式(內網SLB,不可暴露到公網,以免帶來安全風險),可讀性好,而且換ip方便,推薦模式
我們來看看它的架構設計,它推薦我們在所有的Nacos服務端之前建立一個負載均衡,我們通過訪問負載均衡伺服器來間接訪問到各個Nacos伺服器。實際上就,是比如有三個Nacos伺服器做叢集,但是每個服務不可能把每個Nacos都去訪問一次進行註冊,實際上只需要在任意一臺Nacos伺服器上註冊即可,Nacos伺服器之間會自動同步資訊,但是如果我們隨便指定一臺Nacos伺服器進行註冊,如果這臺Nacos伺服器掛了,但是其他Nacos伺服器沒掛,這樣就沒辦法完成註冊了,但是實際上整個叢集還是可用的狀態。
所以這裡就需要在所有Nacos伺服器之前搭建一個SLB(伺服器負載均衡),這樣就可以避免上面的問題了。但是我們知道,如果要實現外界對服務訪問的負載均衡,我們就得用比如之前說到的Gateway來實現,而這裡實際上我們可以用一個更加方便的工具:Nginx,來實現(之前我們沒講過,但是使用起來很簡單,放心後面會帶著大家使用)
關於SLB最上方還有一個DNS(我們在計算機網路
這門課程中學習過),這個是因為SLB是裸IP,如果SLB伺服器修改了地址,那麼所有微服務註冊的地址也得改,所以這裡是通過加域名,通過域名來訪問,讓DNS去解析真實IP,這樣就算改變IP,只需要修改域名解析記錄即可,域名地址是不會變化的。
最後就是Nacos的資料儲存模式,在單節點的情況下,Nacos實際上是將資料存放在自帶的一個嵌入式資料庫中:
而這種模式只適用於單節點,在多節點叢集模式下,肯定是不能各存各的,所以,Nacos提供了MySQL統一儲存支援,我們只需要讓所有的Nacos伺服器連線MySQL進行資料儲存即可,官方也提供好了SQL檔案。
現在就可以開始了,第一步,我們直接匯入資料庫即可,檔案在conf目錄中:
我們來將其匯入到資料庫,可以看到生成了很多的表:
然後我們來建立兩個Nacos伺服器,做一個迷你的叢集,這裡使用scp
命令將nacos服務端上傳到Linux伺服器(注意需要提前安裝好JRE 8或更高版本的環境):
解壓之後,我們對其配置檔案進行修改,首先是application.properties
配置檔案,修改以下內容,包括MySQL伺服器的資訊:
### Default web server port:
server.port=8801
#*************** Config Module Related Configurations ***************#
### If use MySQL as datasource:
spring.datasource.platform=mysql
### Count of DB:
db.num=1
### Connect URL of DB:
db.url.0=jdbc:mysql://cloudstudy.mysql.cn-chengdu.rds.aliyuncs.com:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=nacos
db.password.0=nacos
然後修改叢集配置,這裡需要重新命名一下:
埠記得使用內網IP地址:
最後我們修改一下Nacos的記憶體分配以及前臺啟動,直接修改startup.sh
檔案(記憶體有限,玩不起高的):
儲存之後,將nacos複製一份,並將埠修改為8802,接著啟動這兩個Nacos伺服器。
然後我們開啟管理皮膚,可以看到兩個節點都已經啟動了:
這樣,我們第二步就完成了,接著我們需要新增一個SLB,這裡我們用Nginx做反向代理:
Nginx (engine x) 是一個高效能的HTTP和反向代理web伺服器,同時也提供了IMAP/POP3/SMTP服務。它相當於在內網與外網之間形成一個閘道器,所有的請求都可以由Nginx伺服器轉交給內網的其他伺服器。
這裡我們直接安裝:
sudo apt install nginx
可以看到直接請求80埠之後得到,表示安裝成功:
現在我們需要讓其代理我們剛剛啟動的兩個Nacos伺服器,我們需要對其進行一些配置。配置檔案位於/etc/nginx/nginx.conf
,新增以下內容:
#新增我們在上游剛剛建立好的兩個nacos伺服器
upstream nacos-server {
server 10.0.0.12:8801;
server 10.0.0.12:8802;
}
server {
listen 80;
server_name 1.14.121.107;
location /nacos {
proxy_pass http://nacos-server;
}
}
重啟Nginx伺服器,成功連線:
然後我們將所有的服務全部修改為雲伺服器上Nacos的地址,啟動試試看。
這樣,我們就搭建好了Nacos叢集。
Sentinel 流量防衛兵
經過之前的學習,我們瞭解了微服務存在的雪崩問題,也就是說一個微服務出現問題,有可能導致整個鏈路直接不可用,這種時候我們就需要進行及時的熔斷和降級,這些策略,我們之前通過使用Hystrix來實現。
SpringCloud Alibaba也有自己的微服務容錯元件,但是它相比Hystrix更加的強大。
隨著微服務的流行,服務和服務之間的穩定性變得越來越重要。Sentinel 以流量為切入點,從流量控制、熔斷降級、系統負載保護等多個維度保護服務的穩定性。
Sentinel 具有以下特徵:
- 豐富的應用場景:Sentinel 承接了阿里巴巴近 10 年的雙十一大促流量的核心場景,例如秒殺(即突發流量控制在系統容量可以承受的範圍)、訊息削峰填谷、叢集流量控制、實時熔斷下游不可用應用等。
- 完備的實時監控:Sentinel 同時提供實時的監控功能。您可以在控制檯中看到接入應用的單臺機器秒級資料,甚至 500 臺以下規模的叢集的彙總執行情況。
- 廣泛的開源生態:Sentinel 提供開箱即用的與其它開源框架/庫的整合模組,例如與 Spring Cloud、Apache Dubbo、gRPC、Quarkus 的整合。您只需要引入相應的依賴並進行簡單的配置即可快速地接入 Sentinel。同時 Sentinel 提供 Java/Go/C++ 等多語言的原生實現。
- 完善的 SPI 擴充套件機制:Sentinel 提供簡單易用、完善的 SPI 擴充套件介面。您可以通過實現擴充套件介面來快速地定製邏輯。例如定製規則管理、適配動態資料來源等。
安裝與部署
和Nacos一樣,它是獨立安裝和部署的,下載地址:https://github.com/alibaba/Sentinel/releases
注意下載下來之後是一個jar
檔案(其實就是個SpringBoot專案),我們需要在IDEA中新增一些執行配置:
接著就可以直接啟動啦,當然預設埠占用8080,如果需要修改,可以新增環境變數:
啟動之後,就可以訪問到Sentinel的監控頁面了,使用者名稱和密碼都是sentinel
,地址:http://localhost:8858/#/dashboard
這樣就成功開啟監控頁面了,接著我們需要讓我們的服務連線到Sentinel控制檯,老規矩,匯入依賴:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
然後在配置檔案中新增Sentinel相關資訊(實際上Sentinel是本地在進行管理,但是我們可以連線到監控頁面,這樣就可以圖形化操作了):
spring:
application:
name: userservice
cloud:
nacos:
discovery:
server-addr: localhost:8848
sentinel:
transport:
# 新增監控頁面地址即可
dashboard: localhost:8858
現在啟動我們的服務,然後訪問一次服務,這樣Sentinel中就會存在資訊了(懶載入機制,不會一上來就載入):
現在我們就可以在Sentinel控制檯中對我們的服務執行情況進行實時監控了,可以看到監控的內容非常的多,包括時間點、QPS(每秒查詢率)、響應時間等資料。
按照上面的方式,我們將所有的服務全部連線到Sentinel管理皮膚中。
流量控制
前面我們完成了對Sentinel的搭建與連線,接著我們來看看Sentinel的第一個功能,流量控制。
我們的機器不可能無限制的接受和處理客戶端的請求,如果不加以限制,當發生高併發情況時,系統資源將很快被耗盡。為了避免這種情況,我們就可以新增流量控制(也可以說是限流)當一段時間內的流量到達一定的閾值的時候,新的請求將不再進行處理,這樣不僅可以合理地應對高併發請求,同時也能在一定程度上保護伺服器不受到外界的惡意攻擊。
那麼要實現限流,正常情況下,我們該採取什麼樣的策略呢?
- 方案一:快速拒絕,既然不再接受新的請求,那麼我們可以直接返回一個拒絕資訊,告訴使用者訪問頻率過高。
- 方案二:預熱,依然基於方案一,但是由於某些情況下高併發請求是在某一時刻突然到來,我們可以緩慢地將閾值提高到指定閾值,形成一個緩衝保護。
- 方案三:排隊等待,不接受新的請求,但是也不直接拒絕,而是進佇列先等一下,如果規定時間內能夠執行,那麼就執行,要是超時就算了。
針對於是否超過流量閾值的判斷,這裡我們提4種演算法:
-
漏桶演算法
顧名思義,就像一個桶開了一個小孔,水流進桶中的速度肯定是遠大於水流出桶的速度的,這也是最簡單的一種限流思路:
我們知道,桶是有容量的,所以當桶的容量已滿時,就裝不下水了,這時就只有丟棄請求了。
利用這種思想,我們就可以寫出一個簡單的限流演算法。
-
令牌桶演算法
只能說有點像訊號量機制。現在有一個令牌桶,這個桶是專門存放令牌的,每隔一段時間就向桶中丟入一個令牌(速度由我們指定)當新的請求到達時,將從桶中刪除令牌,接著請求就可以通過並給到服務,但是如果桶中的令牌數量不足,那麼不會刪除令牌,而是讓此資料包等待。
可以試想一下,當流量下降時,令牌桶中的令牌會逐漸積累,這樣如果突然出現高併發,那麼就能在短時間內拿到大量的令牌。
-
固定時間視窗演算法
我們可以對某一個時間段內的請求進行統計和計數,比如在
14:15
到14:16
這一分鐘內,請求量不能超過100
,也就是一分鐘之內不能超過100
次請求,那麼就可以像下面這樣進行劃分:雖然這種模式看似比較合理,但是試想一下這種情況:
- 14:15:59的時候來了100個請求
- 14:16:01的時候又來了100個請求
出現上面這種情況,符合固定時間視窗演算法的規則,所以這200個請求都能正常接受,但是,如果你反應比較快,應該發現了,我們其實希望的是60秒內只有100個請求,但是這種情況卻是在3秒內出現了200個請求,很明顯已經違背了我們的初衷。
因此,當遇到臨界點時,固定時間視窗演算法存在安全隱患。
-
滑動時間視窗演算法
相對於固定視窗演算法,滑動時間視窗演算法更加靈活,它會動態移動視窗,重新進行計算:
雖然這樣能夠避免固定時間視窗的臨界問題,但是這樣顯然是比固定視窗更加耗時的。
好了,瞭解完了我們的限流策略和判定方法之後,我們在Sentinel中進行實際測試一下,開啟管理頁面的簇點鏈路模組:
這裡演示對我們的借閱介面進行限流,點選流控
,會看到讓我們新增流控規則:
- 閾值型別:QPS就是每秒鐘的請求數量,併發執行緒數是按服務當前使用的執行緒資料進行統計的。
- 流控模式:當達到閾值時,流控的物件,這裡暫時只用直接。
- 流控效果:就是我們上面所說的三種方案。
這裡我們選擇QPS
、閾值設定為1
,流控模式選擇直接
、流控效果選擇快速失敗
,可以看到,當我們快速地進行請求時,會直接返回失敗資訊:
這裡各位最好自行嘗試一下其他的流控效果,熟悉和加深印象。
最後我們來看看這些流控模式有什麼區別:
- 直接:只針對於當前介面。
- 關聯:當其他介面超過閾值時,會導致當前介面被限流。
- 鏈路:更細粒度的限流,能精確到具體的方法。
我們首先來看看關聯,比如現在我們對自帶的/error
介面進行限流:
注意限流是作用於關聯資源的,一旦發現關聯資源超過閾值,那麼就會對當前的資源進行限流,我們現在來測試一下,這裡使用PostMan的Runner連續對關聯資源發起請求:
開啟Postman,然後我們會發現借閱服務已經涼涼:
當我們關閉掉Postman的任務後,恢復正常。
最後我們來講解一下鏈路模式,它能夠更加精準的進行流量控制,鏈路流控模式指的是,當從指定介面過來的資源請求達到限流條件時,開啟限流,這裡得先講解一下@SentinelResource
的使用。
我們可以對某一個方法進行限流控制,無論是誰在何處呼叫了它,這裡需要使用到@SentinelResource
,一旦方法被標註,那麼就會進行監控,比如我們這裡建立兩個請求對映,都來呼叫Service的被監控方法:
@RestController
public class BorrowController {
@Resource
BorrowService service;
@RequestMapping("/borrow/{uid}")
UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){
return service.getUserBorrowDetailByUid(uid);
}
@RequestMapping("/borrow2/{uid}")
UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid){
return service.getUserBorrowDetailByUid(uid);
}
}
@Service
public class BorrowServiceImpl implements BorrowService{
@Resource
BorrowMapper mapper;
@Resource
UserClient userClient;
@Resource
BookClient bookClient;
@Override
@SentinelResource("getBorrow") //監控此方法,無論被誰執行都在監控範圍內,這裡給的value是自定義名稱,這個註解可以加在任何方法上,包括Controller中的請求對映方法,跟HystrixCommand賊像
public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
List<Borrow> borrow = mapper.getBorrowsByUid(uid);
User user = userClient.getUserById(uid);
List<Book> bookList = borrow
.stream()
.map(b -> bookClient.getBookById(b.getBid()))
.collect(Collectors.toList());
return new UserBorrowDetail(user, bookList);
}
}
接著新增配置:
spring:
application:
name: borrowservice
cloud:
sentinel:
transport:
dashboard: localhost:8858
# 關閉Context收斂,這樣被監控方法可以進行不同鏈路的單獨控制
web-context-unify: false
然後我們在Sentinel控制檯中新增流控規則,注意是針對此方法,可以看到已經自動識別到borrow介面下呼叫了這個方法:
最後我們在瀏覽器中對這兩個介面都進行測試,會發現,無論請求哪個介面,只要呼叫了Service中的getUserBorrowDetailByUid
這個方法,都會被限流。注意限流的形式是後臺直接丟擲異常,至於怎麼處理我們後面再說。
那麼這個鏈路選項實際上就是決定只限流從哪個方向來的呼叫,比如我們只對borrow2
這個介面對getUserBorrowDetailByUid
方法的呼叫進行限流,那麼我們就可以為其指定鏈路:
然後我們會發現,限流效果只對我們配置的鏈路介面有效,而其他鏈路是不會被限流的。
除了直接對介面進行限流規則控制之外,我們也可以根據當前系統的資源使用情況,決定是否進行限流:
系統規則支援以下的模式:
- Load 自適應(僅對 Linux/Unix-like 機器生效):系統的 load1 作為啟發指標,進行自適應系統保護。當系統 load1 超過設定的啟發值,且系統當前的併發執行緒數超過估算的系統容量時才會觸發系統保護(BBR 階段)。系統容量由系統的
maxQps * minRt
估算得出。設定參考值一般是CPU cores * 2.5
。 - CPU usage(1.5.0+ 版本):當系統 CPU 使用率超過閾值即觸發系統保護(取值範圍 0.0-1.0),比較靈敏。
- 平均 RT:當單臺機器上所有入口流量的平均 RT 達到閾值即觸發系統保護,單位是毫秒。
- 併發執行緒數:當單臺機器上所有入口流量的併發執行緒數達到閾值即觸發系統保護。
- 入口 QPS:當單臺機器上所有入口流量的 QPS 達到閾值即觸發系統保護。
這裡就不進行演示了。
限流和異常處理
現在我們已經瞭解瞭如何進行限流操作,那麼限流狀態下的返回結果該怎麼修改呢,我們看到被限流之後返回的是Sentinel預設的資料,現在我們希望自定義改如何操作?
這裡我們先建立好被限流狀態下需要返回的內容,定義一個請求對映:
@RequestMapping("/blocked")
JSONObject blocked(){
JSONObject object = new JSONObject();
object.put("code", 403);
object.put("success", false);
object.put("massage", "您的請求頻率過快,請稍後再試!");
return object;
}
接著我們在配置檔案中將此頁面設定為限流頁面:
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8858
# 將剛剛編寫的請求對映設定為限流頁面
block-page: /blocked
這樣,當被限流時,就會被重定向到指定頁面:
那麼,對於方法級別的限流呢?經過前面的學習我們知道,當某個方法被限流時,會直接在後臺丟擲異常,那麼這種情況我們該怎麼處理呢,比如我們之前在Hystrix中可以直接新增一個替代方案,這樣當出現異常時會直接執行我們的替代方法並返回,Sentinel也可以。
比如我們還是在getUserBorrowDetailByUid
方法上進行配置:
@Override
@SentinelResource(value = "getBorrow", blockHandler = "blocked") //指定blockHandler,也就是被限流之後的替代解決方案,這樣就不會使用預設的丟擲異常的形式了
public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
List<Borrow> borrow = mapper.getBorrowsByUid(uid);
User user = userClient.getUserById(uid);
List<Book> bookList = borrow
.stream()
.map(b -> bookClient.getBookById(b.getBid()))
.collect(Collectors.toList());
return new UserBorrowDetail(user, bookList);
}
//替代方案,注意引數和返回值需要保持一致,並且引數最後還需要額外新增一個BlockException
public UserBorrowDetail blocked(int uid, BlockException e) {
return new UserBorrowDetail(null, Collections.emptyList());
}
可以看到,一旦被限流將執行替代方案,最後返回的結果就是:
注意blockHandler
只能處理限流情況下丟擲的異常,包括下面即將要介紹的熱點引數限流也是同理,如果是方法本身丟擲的其他型別異常,不在管控範圍內,但是可以通過其他引數進行處理:
@RequestMapping("/test")
@SentinelResource(value = "test",
fallback = "except", //fallback指定出現異常時的替代方案
exceptionsToIgnore = IOException.class) //忽略那些異常,也就是說這些異常出現時不使用替代方案
String test(){
throw new RuntimeException("HelloWorld!");
}
//替代方法必須和原方法返回值和引數一致,最後可以新增一個Throwable作為引數接受異常
String except(Throwable t){
return t.getMessage();
}
這樣,其他的異常也可以有替代方案了:
特別注意這種方式會在沒有配置blockHandler
的情況下,將Sentinel機制內(也就是限流的異常)的異常也一併處理了,如果配置了blockHandler
,那麼在出現限流時,依然只會執行blockHandler
指定的替代方案(因為限流是在方法執行之前進行的)
熱點引數限流
我們還可以對某一熱點資料進行精準限流,比如在某一時刻,不同引數被攜帶訪問的頻率是不一樣的:
- http://localhost:8301/test?a=10 訪問100次
- http://localhost:8301/test?b=10 訪問0次
- http://localhost:8301/test?c=10 訪問3次
由於攜帶引數a
的請求比較多,我們就可以只對攜帶引數a
的請求進行限流。
這裡我們建立一個新的測試請求對映:
@RequestMapping("/test")
@SentinelResource("test") //注意這裡需要新增@SentinelResource才可以,使用者資源名稱就使用這裡定義的資源名稱
String findUserBorrows2(@RequestParam(value = "a", required = false) int a,
@RequestParam(value = "b", required = false) int b,
@RequestParam(value = "c",required = false) int c) {
return "請求成功!a = "+a+", b = "+b+", c = "+c;
}
啟動之後,我們在Sentinel裡面進行熱點配置:
然後開始訪問我們的測試介面,可以看到在攜帶引數a時,當訪問頻率超過設定值,就會直接被限流,這裡是直接在後臺丟擲異常:
而我們使用其他引數或是不帶a
引數,那麼就不會出現這種問題了:
除了直接對某個引數精準限流外,我們還可以對引數攜帶的指定值單獨設定閾值,比如我們現在不僅希望對引數a
限流,而且還希望當引數a
的值為10時,QPS達到5再進行限流,那麼就可以設定例外:
這樣,當請求攜帶引數a
,且引數a
的值為10時,閾值將按照我們指定的特例進行計算。
服務熔斷和降級
還記得我們前所說的服務降級嗎,也就是說我們需要在整個微服務呼叫鏈路出現問題的時候,及時對服務進行降級,以防止問題進一步惡化。
那麼,各位是否有思考過,如果在某一時刻,服務B出現故障(可能就卡在那裡了),而這時服務A依然有大量的請求,在呼叫服務B,那麼,由於服務A沒辦法再短時間內完成處理,新來的請求就會導致執行緒數不斷地增加,這樣,CPU的資源很快就會被耗盡。
那麼要防止這種情況,就只能進行隔離了,這裡我們提兩種隔離方案:
-
執行緒池隔離
執行緒池隔離實際上就是對每個服務的遠端呼叫單獨開放執行緒池,比如服務A要呼叫服務B,那麼只基於固定數量的執行緒池,這樣即使在短時間內出現大量請求,由於沒有執行緒可以分配,所以就不會導致資源耗盡了。
-
訊號量隔離
訊號量隔離是使用
Semaphore
類實現的(如果不瞭解,可以觀看本系列 併發程式設計篇 視訊教程),思想基本上與上面是相同的,也是限定指定的執行緒數量能夠同時進行服務呼叫,但是它相對於執行緒池隔離,開銷會更小一些,使用效果同樣優秀,也支援超時等。Sentinel也正是採用的這種方案實現隔離的。
好了,說回我們的熔斷和降級,當下遊服務因為某種原因變得不可用或響應過慢時,上游服務為了保證自己整體服務的可用性,不再繼續呼叫目標服務而是快速返回或是執行自己的替代方案,這便是服務降級。
整個過程分為三個狀態:
- 關閉:熔斷器不工作,所有請求全部該幹嘛幹嘛。
- 開啟:熔斷器工作,所有請求一律降級處理。
- 半開:嘗試進行一下下正常流程,要是還不行繼續保持開啟狀態,否則關閉。
那麼我們來看看Sentinel中如何進行熔斷和降級操作,開啟管理頁面,我們可以自由新增熔斷規則:
其中,熔斷策略有三種模式:
-
慢呼叫比例:如果出現那種半天都處理不完的呼叫,有可能就是服務出現故障,導致卡頓,這個選項是按照最大響應時間(RT)進行判定,如果一次請求的處理時間超過了指定的RT,那麼就被判定為
慢呼叫
,在一個統計時長內,如果請求數目大於最小請求數目,並且被判定為慢呼叫
的請求比例已經超過閾值,將觸發熔斷。經過熔斷時長之後,將會進入到半開狀態進行試探(這裡和Hystrix一致)然後修改一下介面的執行,我們模擬一下慢呼叫:
@RequestMapping("/borrow2/{uid}") UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) throws InterruptedException { Thread.sleep(1000); return null; }
重啟,然後我們建立一個新的熔斷規則:
可以看到,超時直接觸發了熔斷,進入到阻止頁面:
-
異常比例:這個與慢呼叫比例類似,不過這裡判斷的是出現異常的次數,與上面一樣,我們也來進行一些小測試:
@RequestMapping("/borrow2/{uid}") UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) { throw new RuntimeException(); }
啟動伺服器,接著新增我們的熔斷規則:
現在我們進行訪問,會發現後臺瘋狂報錯,然後就熔斷了:
-
異常數:這個和上面的唯一區別就是,只要達到指定的異常數量,就熔斷,這裡我們修改一下熔斷規則:
現在我們再次不斷訪問此介面,可以發現,效果跟之前其實是差不多的,只是判斷的策略稍微不同罷了:
那麼熔斷規則如何設定我們瞭解了,那麼,如何自定義服務降級呢?之前在使用Hystrix的時候,如果出現異常,可以執行我們的替代方案,Sentinel也是可以的。
同樣的,我們只需要在@SentinelResource
中配置blockHandler
引數(那這裡跟前面那個方法限流的配置不是一毛一樣嗎?沒錯,因為如果新增了@SentinelResource
註解,那麼這裡會進行方法級別細粒度的限制,和之前方法級別限流一樣,會在降級之後直接丟擲異常,如果不新增則返回預設的限流頁面,blockHandler
的目的就是處理這種Sentinel機制上的異常,所以這裡其實和之前的限流配置是一個道理,因此下面熔斷配置也應該對value
自定義名稱的資源進行配置,才能作用到此方法上):
@RequestMapping("/borrow2/{uid}")
@SentinelResource(value = "findUserBorrows2", blockHandler = "test")
UserBorrowDetail findUserBorrows2(@PathVariable("uid") int uid) {
throw new RuntimeException();
}
UserBorrowDetail test(int uid, BlockException e){
return new UserBorrowDetail(new User(), Collections.emptyList());
}
接著我們對進行熔斷配置,注意是對我們新增的@SentinelResource
中指定名稱的findUserBorrows2
進行配置:
OK,可以看到熔斷之後,服務降級之後的效果:
最後我們來看一下如何讓Feign的也支援Sentinel,前面我們使用Hystrix的時候,就可以直接對Feign的每個介面呼叫單獨進行服務降級,而使用Sentinel,也是可以的,首先我們需要在配置檔案中開啟支援:
feign:
sentinel:
enabled: true
之後的步驟其實和之前是一模一樣的,首先建立實現類:
@Component
public class UserClientFallback implements UserClient{
@Override
public User getUserById(int uid) {
User user = new User();
user.setName("我是替代方案");
return user;
}
}
然後直接啟動就可以了,中途的時候我們吧使用者服務全部下掉,可以看到正常使用替代方案:
這樣Feign的配置就OK了,那麼傳統的RestTemplate呢?我們可以使用@SentinelRestTemplate
註解實現:
@Bean
@LoadBalanced
@SentinelRestTemplate(blockHandler = "handleException", blockHandlerClass = ExceptionUtil.class,
fallback = "fallback", fallbackClass = ExceptionUtil.class) //這裡同樣可以設定fallback等引數
public RestTemplate restTemplate() {
return new RestTemplate();
}
這裡就不多做贅述了。
Seata與分散式事務
重難點內容,坑也多得離譜,最好保持跟UP一樣的版本,官方文件:https://seata.io/zh-cn/docs/overview/what-is-seata.html
在前面的階段中,我們學習過事務,還記得我們之前談到的資料庫事務的特性嗎?
- 原子性:一個事務(transaction)中的所有操作,要麼全部完成,要麼全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。
- 一致性:在事務開始之前和事務結束以後,資料庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設規則,這包含資料的精確度、串聯性以及後續資料庫可以自發性地完成預定的工作。
- 隔離性:資料庫允許多個併發事務同時對其資料進行讀寫和修改的能力,隔離性可以防止多個事務併發執行時由於交叉執行而導致資料的不一致。事務隔離分為不同級別,包括讀未提交(Read uncommitted)、讀已提交(read committed)、可重複讀(repeatable read)和序列化(Serializable)。
- 永續性:事務處理結束後,對資料的修改就是永久的,即便系統故障也不會丟失。
那麼各位試想一下,在分散式環境下,有可能出現這樣一個問題,比如我們下單購物,那麼整個流程可能是這樣的:先呼叫庫存服務對庫存進行減扣 -> 然後訂單服務開始下單 -> 最後使用者賬戶服務進行扣款,雖然看似是一個很簡單的一個流程,但是如果沒有事務的加持,很有可能會由於中途出錯,比如整個流程中訂單服務出現問題,那麼就會導致庫存扣了,但是實際上這個訂單並沒有生成,使用者也沒有付款。
上面這種情況時間就是一種多服務多資料來源的分散式事務模型(比較常見),因此,為了解決這種情況,我們就得實現分散式事務,讓這整個流程保證原子性。
SpringCloud Alibaba為我們提供了用於處理分散式事務的元件Seata。
Seata 是一款開源的分散式事務解決方案,致力於提供高效能和簡單易用的分散式事務服務。Seata 將為使用者提供了 AT、TCC、SAGA 和 XA 事務模式,為使用者打造一站式的分散式解決方案。
實際上,就是多了一箇中間人來協調所有服務的事務。
專案環境搭建
這裡我們對我們之前的圖書管理系統進行升級:
- 每個使用者最多隻能同時借閱2本不同的書。
- 圖書館中所有的書都有3本。
- 使用者借書流程:先呼叫圖書服務書籍數量-1 -> 新增借閱記錄 -> 呼叫使用者服務使用者可借閱數量-1
那麼首先我們對資料庫進行修改,這裡為了簡便,就直接在使用者表中新增一個欄位用於儲存使用者能夠借閱的書籍數量:
然後修改書籍資訊,也是直接新增一個欄位用於記錄剩餘數量:
接著我們去編寫一下對應的服務吧,首先是使用者服務:
@Mapper
public interface UserMapper {
@Select("select * from DB_USER where uid = #{uid}")
User getUserById(int uid);
@Select("select book_count from DB_USER where uid = #{uid}")
int getUserBookRemain(int uid);
@Update("update DB_USER set book_count = #{count} where uid = #{uid}")
int updateBookCount(int uid, int count);
}
@Service
public class UserServiceImpl implements UserService {
@Resource
UserMapper mapper;
@Override
public User getUserById(int uid) {
return mapper.getUserById(uid);
}
@Override
public int getRemain(int uid) {
return mapper.getUserBookRemain(uid);
}
@Override
public boolean setRemain(int uid, int count) {
return mapper.updateBookCount(uid, count) > 0;
}
}
@RestController
public class UserController {
@Resource
UserService service;
@RequestMapping("/user/{uid}")
public User findUserById(@PathVariable("uid") int uid){
return service.getUserById(uid);
}
@RequestMapping("/user/remain/{uid}")
public int userRemain(@PathVariable("uid") int uid){
return service.getRemain(uid);
}
@RequestMapping("/user/borrow/{uid}")
public boolean userBorrow(@PathVariable("uid") int uid){
int remain = service.getRemain(uid);
return service.setRemain(uid, remain - 1);
}
}
然後是圖書服務,其實跟使用者服務差不多:
@Mapper
public interface BookMapper {
@Select("select * from DB_BOOK where bid = #{bid}")
Book getBookById(int bid);
@Select("select count from DB_BOOK where bid = #{bid}")
int getRemain(int bid);
@Update("update DB_BOOK set count = #{count} where bid = #{bid}")
int setRemain(int bid, int count);
}
@Service
public class BookServiceImpl implements BookService {
@Resource
BookMapper mapper;
@Override
public Book getBookById(int bid) {
return mapper.getBookById(bid);
}
@Override
public boolean setRemain(int bid, int count) {
return mapper.setRemain(bid, count) > 0;
}
@Override
public int getRemain(int bid) {
return mapper.getRemain(bid);
}
}
@RestController
public class BookController {
@Resource
BookService service;
@RequestMapping("/book/{bid}")
Book findBookById(@PathVariable("bid") int bid){
return service.getBookById(bid);
}
@RequestMapping("/book/remain/{bid}")
public int bookRemain(@PathVariable("bid") int uid){
return service.getRemain(uid);
}
@RequestMapping("/book/borrow/{bid}")
public boolean bookBorrow(@PathVariable("bid") int uid){
int remain = service.getRemain(uid);
return service.setRemain(uid, remain - 1);
}
}
最後完善我們的借閱服務:
@FeignClient(value = "userservice")
public interface UserClient {
@RequestMapping("/user/{uid}")
User getUserById(@PathVariable("uid") int uid);
@RequestMapping("/user/borrow/{uid}")
boolean userBorrow(@PathVariable("uid") int uid);
@RequestMapping("/user/remain/{uid}")
int userRemain(@PathVariable("uid") int uid);
}
@FeignClient("bookservice")
public interface BookClient {
@RequestMapping("/book/{bid}")
Book getBookById(@PathVariable("bid") int bid);
@RequestMapping("/book/borrow/{bid}")
boolean bookBorrow(@PathVariable("bid") int bid);
@RequestMapping("/book/remain/{bid}")
int bookRemain(@PathVariable("bid") int bid);
}
@RestController
public class BorrowController {
@Resource
BorrowService service;
@RequestMapping("/borrow/{uid}")
UserBorrowDetail findUserBorrows(@PathVariable("uid") int uid){
return service.getUserBorrowDetailByUid(uid);
}
@RequestMapping("/borrow/take/{uid}/{bid}")
JSONObject borrow(@PathVariable("uid") int uid,
@PathVariable("bid") int bid){
service.doBorrow(uid, bid);
JSONObject object = new JSONObject();
object.put("code", "200");
object.put("success", false);
object.put("message", "借閱成功!");
return object;
}
}
@Service
public class BorrowServiceImpl implements BorrowService{
@Resource
BorrowMapper mapper;
@Resource
UserClient userClient;
@Resource
BookClient bookClient;
@Override
public UserBorrowDetail getUserBorrowDetailByUid(int uid) {
List<Borrow> borrow = mapper.getBorrowsByUid(uid);
User user = userClient.getUserById(uid);
List<Book> bookList = borrow
.stream()
.map(b -> bookClient.getBookById(b.getBid()))
.collect(Collectors.toList());
return new UserBorrowDetail(user, bookList);
}
@Override
public boolean doBorrow(int uid, int bid) {
//1. 判斷圖書和使用者是否都支援借閱
if(bookClient.bookRemain(bid) < 1)
throw new RuntimeException("圖書數量不足");
if(userClient.userRemain(uid) < 1)
throw new RuntimeException("使用者借閱量不足");
//2. 首先將圖書的數量-1
if(!bookClient.bookBorrow(bid))
throw new RuntimeException("在借閱圖書時出現錯誤!");
//3. 新增借閱資訊
if(mapper.getBorrow(uid, bid) != null)
throw new RuntimeException("此書籍已經被此使用者借閱了!");
if(mapper.addBorrow(uid, bid) <= 0)
throw new RuntimeException("在錄入借閱資訊時出現錯誤!");
//4. 使用者可借閱-1
if(!userClient.userBorrow(uid))
throw new RuntimeException("在借閱時出現錯誤!");
//完成
return true;
}
}
這樣,只要我們的圖書借閱過程中任何一步出現問題,都會丟擲異常。
我們來測試一下:
再次嘗試借閱,後臺會直接報錯:
丟擲異常,但是我們發現一個問題,借閱資訊新增失敗了,但是圖書的數量依然被-1,也就是說正常情況下,我們是希望中途出現異常之後,之前的操作全部回滾的:
而這裡由於是在另一個服務中進行的資料庫操作,所以傳統的@Transactional
註解無效,這時就得藉助Seata提供分散式事務了。
分散式事務解決方案
要開始實現分散式事務,我們得先從理論上開始下手,我們來了解一下常用的分散式事務解決方案。
-
XA分散式事務協議 - 2PC(兩階段提交實現)
這裡的PC實際上指的是Prepare和Commit,也就是說它分為兩個階段,一個是準備一個是提交,整個過程的參與者一共有兩個角色,一個是事務的執行者,一個是事務的協調者,實際上整個分散式事務的運作都需要依靠協調者來維持:
在準備和提交階段,會進行:
-
準備階段:
一個分散式事務是由協調者來開啟的,首先協調者會向所有的事務執行者傳送事務內容,等待所有的事務執行者答覆。
各個事務執行者開始執行事務操作,但是不進行提交,並將undo和redo資訊記錄到事務日誌中。
如果事務執行者執行事務成功,那麼就告訴協調者成功Yes,否則告訴協調者失敗No,不能提交事務。
-
提交階段:
當所有的執行者都反饋完成之後,進入第二階段。
協調者會檢查各個執行者的反饋內容,如果所有的執行者都返回成功,那麼就告訴所有的執行者可以提交事務了,最後再釋放鎖資源。
如果有至少一個執行者返回失敗或是超時,那麼就讓所有的執行者都回滾,分散式事務執行失敗。
雖然這種方式看起來比較簡單,但是存在以下幾個問題:
- 事務協調者是非常核心的角色,一旦出現問題,將導致整個分散式事務不能正常執行。
- 如果提交階段發生網路問題,導致某些事務執行者沒有收到協調者發來的提交命令,將導致某些執行者提交某些執行者沒提交,這樣肯定是不行的。
-
-
XA分散式事務協議 - 3PC(三階段提交實現)
三階段提交是在二階段提交基礎上的改進版本,主要是加入了超時機制,同時在協調者和執行者中都引入了超時機制。
三個階段分別進行:
-
CanCommit階段:
協調者向執行者傳送CanCommit請求,詢問是否可以執行事務提交操作,然後開始等待執行者的響應。
執行者接收到請求之後,正常情況下,如果其自身認為可以順利執行事務,則返回Yes響應,並進入預備狀態,否則返回No
-
PreCommit階段:
協調者根據執行者的反應情況來決定是否可以進入第二階段事務的PreCommit操作。
如果所有的執行者都返回Yes,則協調者向所有執行者傳送PreCommit請求,並進入Prepared階段,執行者接收到請求後,會執行事務操作,並將undo和redo資訊記錄到事務日誌中,如果成功執行,則返回成功響應。
如果所有的執行者至少有一個返回No,則協調者向所有執行者傳送abort請求,所有的執行者在收到請求或是超過一段時間沒有收到任何請求時,會直接中斷事務。
-
DoCommit階段:
該階段進行真正的事務提交。
協調者接收到所有執行者傳送的成功響應,那麼他將從PreCommit狀態進入到DoCommit狀態,並向所有執行者傳送doCommit請求,執行者接收到doCommit請求之後,開始執行事務提交,並在完成事務提交之後釋放所有事務資源,並最後向協調者傳送確認響應,協調者接收到所有執行者的確認響應之後,完成事務(如果因為網路問題導致執行者沒有收到doCommit請求,執行者會在超時之後直接提交事務,雖然執行者只是猜測協調者返回的是doCommit請求,但是因為前面的兩個流程都正常執行,所以能夠在一定程度上認為本次事務是成功的,因此會直接提交)
協調者沒有接收至少一個執行者傳送的成功響應(也可能是響應超時),那麼就會執行中斷事務,協調者會向所有執行者傳送abort請求,執行者接收到abort請求之後,利用其在PreCommit階段記錄的undo資訊來執行事務的回滾操作,並在完成回滾之後釋放所有的事務資源,執行者完成事務回滾之後,向協調者傳送確認訊息, 協調者接收到參與者反饋的確認訊息之後,執行事務的中斷。
相比兩階段提交,三階段提交的優勢是顯而易見的,當然也有缺點:
- 3PC在2PC的第一階段和第二階段中插入一個準備階段,保證了在最後提交階段之前各參與節點的狀態是一致的。
- 一旦參與者無法及時收到來自協調者的資訊之後,會預設執行Commit,這樣就不會因為協調者單方面的故障導致全域性出現問題。
- 但是我們知道,實際上超時之後的Commit決策本質上就是一個賭注罷了,如果此時協調者傳送的是abort請求但是超時未接收,那麼就會直接導致資料一致性問題。
-
-
TCC(補償事務)
補償事務TCC就是Try、Confirm、Cancel,它對業務有侵入性,一共分為三個階段,我們依次來解讀一下。
-
Try階段:
比如我們需要在借書時,將書籍的庫存
-1
,並且使用者的借閱量也-1
,但是這個操作,除了直接對庫存和借閱量進行修改之外,還需要將減去的值,單獨存放到凍結表中,但是此時不會建立借閱資訊,也就是說只是預先把關鍵的東西給處理了,預留業務資源出來。 -
Confirm階段:
如果Try執行成功無誤,那麼就進入到Confirm階段,接著之前,我們就該建立借閱資訊了,只能使用Try階段預留的業務資源,如果建立成功,那麼就對Try階段凍結的值,進行解凍,整個流程就完成了。當然,如果失敗了,那麼進入到Cancel階段。
-
Cancel階段:
不用猜了,那肯定是把凍結的東西還給人家,因為整個借閱操作壓根就沒成功。就像你付了款買了東西但是網路問題,導致交易失敗,錢不可能不還給你吧。
跟XA協議相比,TCC就沒有協調者這一角色的參與了,而是自主通過上一階段的執行情況來確保正常,充分利用了叢集的優勢,效能也是有很大的提升。但是缺點也很明顯,它與業務具有一定的關聯性,需要開發者去編寫更多的補償程式碼,同時並不一定所有的業務流程都適用於這種形式。
-
Seata機制簡介
前面我們瞭解了一些分散式事務的解決方案,那麼我們來看一下Seata是如何進行分散式事務的處理的。
官網給出的是這樣的一個架構圖,那麼圖中的RM、TM、TC代表著什麼意思呢?
- RM(Resource Manager):用於直接執行本地事務的提交和回滾。
- TM(Transaction Manager):TM是分散式事務的核心管理者。比如現在我們需要在借閱服務中開啟全域性事務,來讓其自身、圖書服務、使用者服務都參與進來,也就是說一般全域性事務發起者就是TM。
- TC(Transaction Manager)這個就是我們的Seata伺服器,用於全域性控制,比如在XA模式下就是一個協調者的角色,而一個分散式事務的啟動就是由TM向TC發起請求,TC再來與其他的RM進行協調操作。
TM請求TC開啟一個全域性事務,TC會生成一個XID作為該全域性事務的編號,XID會在微服務的呼叫鏈路中傳播,保證將多個微服務的子事務關聯在一起;RM請求TC將本地事務註冊為全域性事務的分支事務,通過全域性事務的XID進行關聯;TM請求TC告訴XID對應的全域性事務是進行提交還是回滾;TC驅動RM將XID對應的自己的本地事務進行提交還是回滾;
Seata支援4種事務模式,官網文件:https://seata.io/zh-cn/docs/overview/what-is-seata.html
-
AT:本質上就是2PC的升級版,在 AT 模式下,使用者只需關心自己的 “業務SQL”
- 一階段,Seata 會攔截“業務 SQL”,首先解析 SQL 語義,找到“業務 SQL”要更新的業務資料,在業務資料被更新前,將其儲存成“before image”,然後執行“業務 SQL”更新業務資料,在業務資料更新之後,再將其儲存成“after image”,最後生成行鎖。以上操作全部在一個資料庫事務內完成,這樣保證了一階段操作的原子性。
- 二階段如果確認提交的話,因為“業務 SQL”在一階段已經提交至資料庫, 所以 Seata 框架只需將一階段儲存的快照資料和行鎖刪掉,完成資料清理即可,當然如果需要回滾,那麼就用“before image”還原業務資料;但在還原前要首先要校驗髒寫,對比“資料庫當前業務資料”和 “after image”,如果兩份資料完全一致就說明沒有髒寫,可以還原業務資料,如果不一致就說明有髒寫,出現髒寫就需要轉人工處理。
-
TCC:和我們上面講解的思路是一樣的。
-
XA:同上,但是要求資料庫本身支援這種模式才可以。
-
Saga:用於處理長事務,每個執行者需要實現事務的正向操作和補償操作:
那麼,以AT模式為例,我們的程式如何才能做到不對業務進行侵入的情況下實現分散式事務呢?實際上,Seata客戶端,是通過對資料來源進行代理實現的,使用的是DataSourceProxy類,所以在程式這邊,我們只需要將對應的代理類註冊為Bean即可(0.9版本之後支援自動進行代理,不用我們手動操作)
接下來,我們就以AT模式為例進行講解。
使用file模式部署
Seata也是以服務端形式進行部署的,然後每個服務都是客戶端,服務端下載地址:https://github.com/seata/seata/releases/download/v1.4.2/seata-server-1.4.2.zip
把原始碼也下載一下:https://github.com/seata/seata/archive/refs/heads/develop.zip
下載完成之後,放入到IDEA專案目錄中,新增啟動配置,這裡埠使用8868:
Seata服務端支援本地部署或是基於註冊發現中心部署(比如Nacos、Eureka等),這裡我們首先演示一下最簡單的本地部署,不需要對Seata的配置檔案做任何修改。
Seata存在著事務分組機制:
- 事務分組:seata的資源邏輯,可以按微服務的需要,在應用程式(客戶端)對自行定義事務分組,每組取一個名字。
- 叢集:seata-server服務端一個或多個節點組成的叢集cluster。 應用程式(客戶端)使用時需要指定事務邏輯分組與Seata服務端叢集(預設為default)的對映關係。
為啥要設計成通過事務分組再直接對映到叢集?幹嘛不直接指定叢集呢?獲取事務分組到對映叢集的配置。這樣設計後,事務分組可以作為資源的邏輯隔離單位,出現某叢集故障時可以快速failover,只切換對應分組,可以把故障縮減到服務級別,但前提也是你有足夠server叢集。
接著我們需要將我們的各個服務作為Seate的客戶端,只需要匯入依賴即可:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
然後新增配置:
seata:
service:
vgroup-mapping:
# 這裡需要對事務組做對映,預設的分組名為 應用名稱-seata-service-group,將其對映到default叢集
# 這個很關鍵,一定要配置對,不然會找不到服務
bookservice-seata-service-group: default
grouplist:
default: localhost:8868
這樣就可以直接啟動了,但是注意現在只是單純地連線上,並沒有開啟任何的分散式事務。
現在我們接著來配置開啟分散式事務,首先在啟動類新增註解,此註解會新增一個後置處理器將資料來源封裝為支援分散式事務的代理資料來源(雖然官方表示配置檔案中已經預設開啟了自動代理,但是UP主實測1.4.2版本下只能打註解的方式才能生效):
@EnableAutoDataSourceProxy
@SpringBootApplication
public class BookApplication {
public static void main(String[] args) {
SpringApplication.run(BookApplication.class, args);
}
}
接著我們需要在開啟分散式事務的方法上新增@GlobalTransactional
註解:
@GlobalTransactional
@Override
public boolean doBorrow(int uid, int bid) {
//這裡列印一下XID看看,其他的服務業新增這樣一個列印,如果一會都列印的是同一個XID,表示使用的就是同一個事務
System.out.println(RootContext.getXID());
if(bookClient.bookRemain(bid) < 1)
throw new RuntimeException("圖書數量不足");
if(userClient.userRemain(uid) < 1)
throw new RuntimeException("使用者借閱量不足");
if(!bookClient.bookBorrow(bid))
throw new RuntimeException("在借閱圖書時出現錯誤!");
if(mapper.getBorrow(uid, bid) != null)
throw new RuntimeException("此書籍已經被此使用者借閱了!");
if(mapper.addBorrow(uid, bid) <= 0)
throw new RuntimeException("在錄入借閱資訊時出現錯誤!");
if(!userClient.userBorrow(uid))
throw new RuntimeException("在借閱時出現錯誤!");
return true;
}
還沒結束,我們前面說了,Seata會分析修改資料的sql,同時生成對應的反向回滾SQL,這個回滾記錄會存放在undo_log 表中。所以要求每一個Client 都有一個對應的undo_log表(也就是說每個服務連線的資料庫都需要建立這樣一個表,這裡由於我們三個服務都用的同一個資料庫,所以說就只用在這個資料庫中建立undo_log表即可),表SQL定義如下:
CREATE TABLE `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`branch_id` BIGINT(20) NOT NULL,
`xid` VARCHAR(100) NOT NULL,
`context` VARCHAR(128) NOT NULL,
`rollback_info` LONGBLOB NOT NULL,
`log_status` INT(11) NOT NULL,
`log_created` DATETIME NOT NULL,
`log_modified` DATETIME NOT NULL,
`ext` VARCHAR(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
建立完成之後,我們現在就可以啟動三個服務了,我們來測試一下當出現異常的時候是不是會正常回滾:
首先第一次肯定是正常完成借閱操作的,接著我們再次進行請求,肯定會出現異常:
如果能在棧追蹤資訊中看到seata相關的包,那麼說明分散式事務已經開始工作了,通過日誌我們可以看到,出現了回滾操作:
並且資料庫中確實是回滾了扣除操作:
這樣,我們就通過Seata簡單地實現了分散式事務。
使用nacos模式部署
前面我們實現了本地Seata服務的file模式部署,現在我們來看看如何讓其配合Nacos進行部署,利用Nacos的配置管理和服務發現機制,Seata能夠更好地工作。
我們先單獨為Seata配置一個名稱空間:
我們開啟conf
目錄中的registry.conf
配置檔案:
registry {
# 註冊配置
# 可以看到這裡可以選擇型別,預設情況下是普通的file型別,也就是本地檔案的形式進行註冊配置
# 支援的型別如下,對應的型別在下面都有對應的配置
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"
# 採用nacos方式會將seata服務端也註冊到nacos中,這樣客戶端就可以利用服務發現自動找到seata服務
# 就不需要我們手動指定IP和埠了,不過看似方便,坑倒是不少,後面再說
nacos {
# 應用名稱,這裡預設就行
application = "seata-server"
# Nacos伺服器地址
serverAddr = "localhost:8848"
# 這裡使用的是SEATA_GROUP組,一會註冊到Nacos中就是這個組
group = "SEATA_GROUP"
# 這裡就使用我們上面單獨為seata配置的名稱空間,注意填的是ID
namespace = "89fc2145-4676-48b8-9edd-29e867879bcb"
# 叢集名稱,這裡還是使用default
cluster = "default"
# Nacos的使用者名稱和密碼
username = "nacos"
password = "nacos"
}
#...
註冊資訊配置完成之後,接著我們需要將配置檔案也放到Nacos中,讓Nacos管理配置,這樣我們就可以對配置進行熱更新了,一旦環境需要變化,只需要直接在Nacos中修改即可。
config {
# 這裡我們也使用nacos
# file、nacos 、apollo、zk、consul、etcd3
type = "nacos"
nacos {
# 跟上面一樣的配法
serverAddr = "127.0.0.1:8848"
namespace = "89fc2145-4676-48b8-9edd-29e867879bcb"
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
# 這個不用改,預設就行
dataId = "seataServer.properties"
}
接著,我們需要將配置匯入到Nacos中,我們開啟一開始下載的原始碼script/config-center/nacos
目錄,這是官方提供的上傳指令碼,我們直接執行即可(windows下沒對應的bat就很蛋疼,可以使用git命令列來執行一下),這裡我們使用這個可互動的版本:
按照提示輸入就可以了,不輸入就使用的預設值,不知道為啥最新版本有四個因為引數過長還匯入失敗了,就離譜,不過不影響。
匯入成功之後,可以在對應的名稱空間下看到對應的配置(為啥非要一個一個配置項單獨搞,就不能寫一起嗎):
注意,還沒完,我們還需要將對應的事務組對映配置也新增上,DataId格式為service.vgroupMapping.事務組名稱
,比如我們就使用預設的名稱,值全部依然使用default即可:
現在我們就完成了服務端的Nacos配置,接著我們需要對客戶端也進行Nacos配置:
seata:
# 註冊
registry:
# 使用Nacos
type: nacos
nacos:
# 使用Seata的名稱空間,這樣才能正確找到Seata服務,由於組使用的是SEATA_GROUP,配置預設值就是,就不用配了
namespace: 89fc2145-4676-48b8-9edd-29e867879bcb
username: nacos
password: nacos
# 配置
config:
type: nacos
nacos:
namespace: 89fc2145-4676-48b8-9edd-29e867879bcb
username: nacos
password: nacos
現在我們就可以啟動這三個服務了,可以在Nacos中看到Seata以及三個服務都正常註冊了:
接著我們就可以訪問一下服務試試看了:
可以看到效果和上面是一樣的,不過現在我們的註冊和配置都繼承在Nacos中進行了。
我們還可以配置一下事務會話資訊的儲存方式,預設是file型別,那麼就會在執行目錄下建立file_store
目錄,我們可以將其搬到資料庫中儲存,只需要修改一下配置即可:
將store.session.mode
和store.mode
的值修改為db
接著我們對資料庫資訊進行一下配置:
- 資料庫驅動
- 資料庫URL
- 資料庫使用者名稱密碼
其他的預設即可:
接著我們需要將對應的資料庫進行建立,建立seata資料庫,然後直接CV以下語句:
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
`lock_key` CHAR(20) NOT NULL,
`lock_value` VARCHAR(20) NOT NULL,
`expire` BIGINT,
primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('HandleAllSession', ' ', 0);
完成之後,重啟Seata服務端即可:
看到了資料來源初始化成功,現在已經在使用資料庫進行會話儲存了。
如果Seata服務端出現報錯,可能是我們自定義事務組的名稱太長了:
將globle_table
表的欄位transaction_server_group
長度適當增加一下即可:
到此,關於基於nacos模式下的Seata部署,就完成了。