分布式系統
分布式系統一定是由多個節點組成的系統。其中,節點指的是計算機服務器,而且這些節點一般不是孤立的,而是互通的。
這些連通的節點上部署了我們的節點,並且相互的操作會有協同。
分布式應用用到的技術: 網絡通信,基於消息方式的系統間通信和基於遠程調用的系統間通信。
基於Java實現消息方式的系統間通信的方式有: TCP/IP+BIO、TCP/IP+NIO、UDP/IP+BIO、UDP/IP+NIO 4種方式
TCP/IP+BIO 在 Java 中可基於 Socket、ServerSocket 來實現 TCP/IP+BIO 的系統間通信。
TCP/IP +NIO 異步通信:JAVA NIO 通道技術實現。
-
分布式與單機情況下最大的不同在於其不是多線程而是
多進程
。 -
多線程由於可以共享堆內存,因此可以簡單的采取內存作為標記存儲位置。而進程之間甚至可能都不在同一台物理機上,因此需要將標記存儲在一個所有進程都能看到的地方。
分布式鎖
基本應用
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
String uuid = UUID.randomUUID().toString();
// 分布式鎖, setIfAbsent就是setnx
// 相當於redis命令:set lock 111 EX 30 NX
// ttl lock 可查看當前過期時間
// 分布式鎖, setIfAbsent就是setnx
// 設置過期時間, 防止無法及時釋放鎖或者忘記釋放鎖
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
// 搶占key為lock的鎖成功
Map<String, List<Catelog2Vo>> dataFromDb = null;
if(lock) {
try {
dataFromDb = getDataFromDb();
} finally {
// lua腳本釋放鎖
String script = "if redis.call('get', KEY[1]) == ARGV[1] then return.call('del', KEYS[1]) else return 0 end";
// 原子釋放鎖
redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
}
// String lockValue = redisTemplate.opsForValue().get("lock"); // 在返回查詢結果過程中該lock剛好過期,可能造成誤刪
// if(uuid.equals(lockValue)) {
// redisTemplate.delete(lockValue); // 釋放鎖
// }
return dataFromDb;
} else {
// 加鎖失敗,重試
return getCatalogJsonFromDbWithRedisLock(); // 自旋的方式
}
}
為了防止分布式系統中的多個進程之間相互干擾,我們需要一種分布式協調技術來對這些進程進行調度。而這個分布式協調技術的核心就是來實現這個分布式鎖。
可以保證在分布式部署的應用集群中,同一個方法在同一時間只能被一台機器上的一個線程執行。
這把鎖要是一把可重入鎖(避免死鎖)
這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
這把鎖最好是一把公平鎖(根據業務需求考慮要不要這條)
有高可用的獲取鎖和釋放鎖功能
獲取鎖和釋放鎖的性能要好
分布式鎖應該具備哪些條件
- 在分布式系統環境下,一個方法在同一時間只能被一個機器的一個線程執行
- 高可用的獲取鎖與釋放鎖
- 高性能的獲取鎖與釋放鎖
- 具備可重入特性(可理解為重新進入,由多於一個任務並發使用,而不必擔心數據錯誤)
- 具備鎖失效機制,防止死鎖
- 具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗
分布式鎖的實現有哪些
- Redis:和 Memcached 的方式類似,利用 Redis 的
setnx
命令。此命令同樣是原子性操作,只有在key
不存在的情況下,才能set
成功。 - Zookeeper:利用 Zookeeper 的順序臨時節點,來實現分布式鎖和等待隊列。Zookeeper 設計的初衷,就是為了實現分布式鎖服務的。
- MySQL基於數據庫實現分布式鎖
- Memcached:利用 Memcached 的
add
命令。此命令是原子性操作,只有在key
不存在的情況下,才能add
成功,也就意味着線程得到了鎖。 - Chubby:Google 公司實現的粗粒度分布式鎖服務,底層利用了 Paxos 一致性算法。
從理解的難易程度角度(從低到高)數據庫 > 緩存 > Zookeeper
從實現的復雜性角度(從低到高)Zookeeper >= 緩存 > 數據庫
從性能角度(從高到低)緩存 > Zookeeper >= 數據庫
從可靠性角度(從高到低)Zookeeper > 緩存 > 數據庫
MySQL實現分布式鎖
基於數據庫實現原理
新建鎖表記錄
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '備注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存數據時間,自動生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
1.唯一約束實現
當想要鎖住某個方法時執行insert方法,插入一條數據,method_name有唯一約束,可以保證多次提交只有一次成功,而成功的這次就可以認為其獲得了鎖,而執行完成后執行delete語句釋放鎖
缺點:
這把鎖強依賴數據庫的可用性,數據庫是一個單點,一旦數據庫掛掉,會導致業務系統不可用。
這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
這把鎖只能是非阻塞的,因為數據的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因為數據中數據已經存在了。
這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖。
2.排它鎖(悲觀鎖)實現
還是使用上方的表結構,可以通過數據庫的排他鎖來實現分布式鎖
在查詢語句后面增加for update
,數據庫會在查詢過程中給數據庫表增加排他鎖。當某條記錄被加上排他鎖之后,其他線程無法再在該行記錄上增加排他鎖。
我們可以認為獲得排它鎖的線程即可獲得分布式鎖,當獲取到鎖之后,可以執行方法的業務邏輯,執行完方法之后,再通過connection.commit();
操作來釋放鎖
代碼:
public boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
result = select * from methodLock where method_name=xxx for update;
if(result==null){
return true;
}
} catch(Exception e){}
sleep(1000);
}
return false;
}
public void unlock(){
connection.commit();
}
3.樂觀鎖實現
一般是通過為數據庫表添加一個 “version”字段來實現讀取出數據時,將此版本號一同讀出,之后更新時,對此版本號加1,在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行本次操作;如果版本號不一致,則會更新失敗,實際就是個diff過程
缺點:
(1). 這種操作方式,使原本一次的update操作,必須變為2次操作: select版本號一次;update一次。增加了數據庫操作的次數。
(2). 如果業務場景中的一次業務流程中,多個資源都需要用保證數據一致性,那么如果全部使用基於數據庫資源表的樂觀鎖,就要讓每個資源都有一張資源表,這個在實際使用場景中肯定是無法滿足的。而且這些都基於數據庫操作,在高並發的要求下,對數據庫連接的開銷一定是無法忍受的。
(3). 樂觀鎖機制往往基於系統中的數據存儲邏輯,因此可能會造成臟數據被更新到數據庫中。
數據庫鎖現在使用較多的就上面說的3種方式,排他鎖(悲觀鎖),版本號(樂觀鎖),記錄鎖,各有優缺點
數據庫鎖的優點就是 直接借助DB簡單易懂
缺點也很明顯:
會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越復雜。
操作數據庫需要一定的開銷,性能問題需要考慮
Redis分布式鎖
獲取鎖
最簡單的方法是使用 setnx
命令。key
是鎖的唯一標識,按業務來決定命名。比如想要給一種商品的秒殺活動加鎖,可以給 key
命名為 “lock_sale_商品ID” 。
當一個線程執行 setnx
返回 1
,說明 key
原本不存在,該線程成功得到了鎖;當一個線程執行 setnx
返回 0
,說明 key
已經存在,該線程搶鎖失敗。
釋放鎖
有加鎖就得有解鎖。當得到鎖的線程執行完任務,需要釋放鎖,以便其他線程可以進入。釋放鎖的最簡單方式是執行 del
指令:del(lock_sale_id)
釋放鎖之后,其他線程就可以繼續執行 setnx
命令來獲得鎖。
鎖超時
鎖超時是什么意思呢?如果一個得到鎖的線程在執行任務的過程中掛掉,來不及顯式地釋放鎖,這塊資源將會永遠被鎖住(死鎖),別的線程再也別想進來。所以,setnx
的 key
必須設置一個超時時間,以保證即使沒有被顯式釋放,這把鎖也要在一定時間后自動釋放。setnx
不支持超時參數,所以需要額外的指令,偽代碼:expire(lock_sale_id, 30);
偽代碼示例:
if(setnx(lock_sale_商品ID,1) == 1){ // 獲得鎖
expire(lock_sale_商品ID,30) // 設置鎖超時時間
try {
// 業務邏輯代碼
} finally {
del(lock_sale_商品ID) // 釋放鎖
}
}
setnx
和 expire
的非原子性
setnx
剛執行成功,還未來得及執行 expire
指令,節點 掛掉了
這樣一來,這把鎖就沒有設置過期時間,變成死鎖,別的線程再也無法獲得鎖了。
解決:setnx
指令本身是不支持傳入超時時間的,set
指令增加了可選參數,偽代碼:set(lock_sale_商品ID,1,30,NX)
del
導致誤刪
如果某些原因導致線程 A 執行的很慢很慢,過了 30 秒都沒執行完,這時候鎖過期自動釋放,線程 B 得到了鎖。隨后,線程 A 執行完了任務,線程 A 接着執行 del
指令來釋放鎖。但這時候線程 B 還沒執行完,線程A實際上 刪除的是線程 B 加的鎖
。
解決:可以在 del
釋放鎖之前做一個判斷,驗證當前的鎖是不是自己加的鎖。至於具體的實現,可以在加鎖的時候把當前的線程 ID 當做 value
,並在刪除之前驗證 key
對應的 value
是不是自己線程的 ID。
加鎖:
String threadId = Thread.currentThread().getId()
set(key,threadId ,30,NX)
解鎖:
if(threadId .equals(redisClient.get(key))){
del(key)
}
出現並發的可能性
同一時間有 A,B 兩個線程在訪問代碼塊,可以讓獲得鎖的線程開啟一個守護線程,用來給快要過期的鎖“續航”。當過去了 29 秒,線程 A 還沒執行完,這時候守護線程會執行 expire
指令,為這把鎖“續命”20 秒。守護線程從第 29 秒開始執行,每 20 秒執行一次,直到線程A執行完畢釋放鎖,會顯式關掉守護線程。
另一種情況,如果節點 1 忽然斷電,由於線程 A 和守護線程在同一個進程,守護線程也會停下。這把鎖到了超時的時候,沒人給它續命,也就自動釋放了。
使用RestTemplate方案
獲取鎖的命令:SET resource_name my_random_value NX PX 30000
方案:
try{
lock = redisTemplate.opsForValue().setIfAbsent(lockKey, LOCK);
logger.info("cancelCouponCode是否獲取到鎖:"+lock);
if (lock) {
// TODO
redisTemplate.expire(lockKey,1, TimeUnit.MINUTES); //成功設置過期時間
return res;
}else {
logger.info("cancelCouponCode沒有獲取到鎖,不執行任務!");
}
}finally{
if(lock){
redisTemplate.delete(lockKey);
logger.info("cancelCouponCode任務結束,釋放鎖!");
}else{
logger.info("cancelCouponCode沒有獲取到鎖,無需釋放鎖!");
}
}
缺點:
在這種場景(主從結構)中存在明顯的競態:
客戶端A從master獲取到鎖,
在master將鎖同步到slave之前,master宕掉了。
slave節點被晉級為master節點,
客戶端B取得了同一個資源被客戶端A已經獲取到的另外一個鎖。安全失效!
分布式鎖框架Redisson
不用Redison:
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
String uuid = UUID.randomUUID().toString();
// 分布式鎖, setIfAbsent就是setnx
// 設置過期時間, 防止無法及時釋放鎖或者忘記釋放鎖
// 相當於redis命令:set lock 111 EX 30 NX
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
// 搶占key為lock的鎖成功
Map<String, List<Catelog2Vo>> dataFromDb = null;
if(lock) {
try {
dataFromDb = getDataFromDb();
} finally {
// lua腳本釋放鎖
String script = "if redis.call('get', KEY[1]) == ARGV[1] then return.call('del', KEYS[1]) else return 0 end";
// 原子釋放鎖
redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock"), uuid);
}
// String lockValue = redisTemplate.opsForValue().get("lock"); // 在返回查詢結果過程中該lock剛好過期,可能造成誤刪
// if(uuid.equals(lockValue)) {
// redisTemplate.delete(lockValue); // 釋放鎖
// }
return dataFromDb;
} else {
// 加鎖失敗,重試
return getCatalogJsonFromDbWithRedisLock(); // 自旋的方式
}
}
Redisson程序化配置
@Bean(destroyMethod = "shutdown") // 銷毀方法
public RedissonClient redisson() throws IOException {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.200.128:6379");
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
基本使用
/**
* RLock鎖有看門狗機制 會自動幫我們續期,默認三秒自動過期
* lock.lock(10,TimeUnit.SECONDS); 十二猴子的時間一定要大於業務的時間 否則會出現死鎖的情況
* 如果我們傳遞了鎖的超時時間就給redis發送超時腳本 默認超時時間就是我們指定的
* 如果我們未指定,就使用 30 * 1000 [LockWatchdogTimeout]
* 只要占鎖成功 就會啟動一個定時任務 任務就是重新給鎖設置過期時間 這個時間還是 [LockWatchdogTimeout] 的時間 1/3 看門狗的時間續期一次 續成滿時間
*/
@ResponseBody
@GetMapping("/index/hello")
public String hello() {
RLock lock = redissonClient.getLock("my-lock");
lock.lock(); // 阻塞等待,默認加鎖的都是30秒
try {
// 如果業務超時,看門狗會自動延時新的30s
Thread.sleep(30000);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return "hello";
}
讀寫鎖, 讀鎖是排它鎖、寫鎖的共享鎖
寫 + 寫:阻塞
寫 + 讀:等待寫鎖釋放
讀 + 讀:相當於無鎖
讀 + 寫:等待讀鎖釋放
@ResponseBody
@GetMapping("/index/write")
public String writeValue() {
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
RLock rLock = lock.writeLock(); // 讀寫加鎖轉換
String s = "";
try {
// 改數據加寫鎖,讀數據加讀鎖
rLock.lock();
s = UUID.randomUUID().toString();
Thread.sleep(3000);
stringRedisTemplate.opsForValue().set("rw-lock", s);
} catch(Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return s;
}
閉鎖,
// 閉鎖 只有設定的人全通過才關門
@ResponseBody
@GetMapping("index/lockDoor")
public String lockDoor() {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.trySetCount(5);
door.await(); // 每次被獲取到door鎖就減一直到為0。5個鎖都被獲取完才接着往下走
return "5個人全部通過";
}
@ResponseBody
@GetMapping("index/go/{id}")
public String go(@PathVariable("id") Long id) {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.countDown();// 減一
return id + "走了";
}
信號量,設置一定信號量,可用於限流
/**
* 嘗試獲取車位 [信號量]
* 信號量:也可以用作限流
*/
@ResponseBody
@GetMapping("/index/park")
public String park() {
RSemaphore park = redissonClient.getSemaphore("park");
boolean acquire = park.tryAcquire(); // 一直嘗試獲取信號量
return "獲取車位 =>" + acquire;
}
/**
* 嘗試獲取車位
*/
@ResponseBody
@GetMapping("/index/go/park")
public String goPark() {
RSemaphore park = redissonClient.getSemaphore("park");
park.release();// 釋放信號量
return "ok => 車位+1";
}
zookeeper分布式鎖
Zookeeper的數據存儲結構就像一棵樹,這棵樹由節點組成,這種節點叫做Znode。
Znode分為四種類型:
1.持久節點 (PERSISTENT)
默認的節點類型。創建節點的客戶端與zookeeper斷開連接后,該節點依舊存在 。
2.持久節點順序節點(PERSISTENT_SEQUENTIAL)
所謂順序節點,就是在創建節點時,Zookeeper根據創建的時間順序給該節點名稱進行編號:
3.臨時節點(EPHEMERAL)
和持久節點相反,當創建節點的客戶端與zookeeper斷開連接后,臨時節點會被刪除:
4.臨時順序節點(EPHEMERAL_SEQUENTIAL)
顧名思義,臨時順序節點結合和臨時節點和順序節點的特點:在創建節點時,Zookeeper根據創建的時間順序給該節點名稱進行編號;當創建節點的客戶端與zookeeper斷開連接后,臨時節點會被刪除。
Zookeeper分布式鎖的原理
Zookeeper分布式鎖恰恰應用了臨時順序節點。具體如何實現呢?讓我們來看一看詳細步驟:
獲取鎖
首先,在Zookeeper當中創建一個持久節點ParentLock。當第一個客戶端想要獲得鎖時,需要在ParentLock這個節點下面創建一個臨時順序節點 Lock1。
之后,Client1查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock1是不是順序最靠前的一個。如果是第一個節點,則成功獲得鎖。
這時候,如果再有一個客戶端 Client2 前來獲取鎖,則在ParentLock下載再創建一個臨時順序節點Lock2。
Client2查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock2是不是順序最靠前的一個,結果發現節點Lock2並不是最小的。
於是,Client2向排序僅比它靠前的節點Lock1注冊Watcher,用於監聽Lock1節點是否存在。這意味着Client2搶鎖失敗,進入了等待狀態。
這時候,如果又有一個客戶端Client3前來獲取鎖,則在ParentLock下載再創建一個臨時順序節點Lock3。
Client3查找ParentLock下面所有的臨時順序節點並排序,判斷自己所創建的節點Lock3是不是順序最靠前的一個,結果同樣發現節點Lock3並不是最小的。
於是,Client3向排序僅比它靠前的節點Lock2注冊Watcher,用於監聽Lock2節點是否存在。這意味着Client3同樣搶鎖失敗,進入了等待狀態。
這樣一來,Client1得到了鎖,Client2監聽了Lock1,Client3監聽了Lock2。這恰恰形成了一個等待隊列,很像是Java當中ReentrantLock所依賴的
釋放鎖
釋放鎖分為兩種情況:
1.任務完成,客戶端顯示釋放
當任務完成時,Client1會顯示調用刪除節點Lock1的指令。
2.任務執行過程中,客戶端崩潰
獲得鎖的Client1在任務執行過程中,如果Duang的一聲崩潰,則會斷開與Zookeeper服務端的鏈接。根據臨時節點的特性,相關聯的節點Lock1會隨之自動刪除。
由於Client2一直監聽着Lock1的存在狀態,當Lock1節點被刪除,Client2會立刻收到通知。這時候Client2會再次查詢ParentLock下面的所有節點,確認自己創建的節點Lock2是不是目前最小的節點。如果是最小,則Client2順理成章獲得了鎖。
同理,如果Client2也因為任務完成或者節點崩潰而刪除了節點Lock2,那么Client3就會接到通知。最終,Client3成功得到了鎖。
方案
可以直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。
Curator提供的InterProcessMutex是分布式鎖的實現。acquire方法用戶獲取鎖,release方法用於釋放鎖。
缺點:
性能上可能並沒有緩存服務那么高。因為每次在創建鎖和釋放鎖的過程中,都要動態創建、銷毀瞬時節點來實現鎖功能。ZK中創建和刪除節點只能通過Leader服務器來執行,然后將數據同不到所有的Follower機器上。
其實,使用Zookeeper也有可能帶來並發問題,只是並不常見而已。考慮這樣的情況,由於網絡抖動,客戶端可ZK集群的session連接斷了,那么zk以為客戶端掛了,就會刪除臨時節點,這時候其他客戶端就可以獲取到分布式鎖了。就可能產生並發問題。這個問題不常見是因為zk有重試機制,一旦zk集群檢測不到客戶端的心跳,就會重試,Curator客戶端支持多種重試策略。多次重試之后還不行的話才會刪除臨時節點。(所以,選擇一個合適的重試策略也比較重要,要在鎖的粒度和並發之間找一個平衡。)
分布式事務
分布式事務指事務的操作位於不同的節點上,需要保證事務的 ACID 特性。例如在下單場景下,庫存、訂單、購物車如果不在同一個節點上,就涉及分布式事務。
兩階段提交(2PC)
XA 事務是基於兩階段提交協議的,所以需要有一個事務協調者(transaction manager)來保證所有的事務參與者都完成了准備工作(第一階段)。如果事務協調者(transaction manager)收到所有參與者都准備好的消息,就會通知所有的事務都可以提交了(第二階段)。MySQL 在這個XA事務中扮演的是參與者的角色,而不是事務協調者(transaction manager)。
通過引入協調者(Coordinator,類似Seata的協調者)來協調參與者的行為,並最終決定這些參與者是否要真正執行事務
准備階段:
所有的參與者准備執行事務並鎖住需要的資源,協調者詢問參與者事務是否執行成功,參與者發回事務執行結果。
提交階段
如果事務在每個參與者上都執行成功,事務協調者發送通知讓參與者提交事務;否則,協調者發送通知讓參與者回滾事務。
需要注意的是,在准備階段,參與者執行了事務,但是還未提交。只有在提交階段接收到協調者發來的通知后,才進行提交或者回滾。
存在的問題
-
同步阻塞:所有事務參與者在等待其它參與者響應的時候都處於同步阻塞狀態,無法進行其它操作。
-
單點問題:協調者在 2PC 中起到非常大的作用,發生故障將會造成很大影響。特別是在階段二發生故障,所有參與者會一直等待狀態,無法完成其它操作。
-
數據不一致:在階段二,如果協調者只發送了部分 Commit 消息,此時網絡發生異常,那么只有部分參與者接收到 Commit 消息,也就是說只有部分參與者提交了事務,使得系統數據不一致。
-
太過保守:任意一個節點失敗就會導致整個事務失敗,沒有完善的容錯機制。
補償事務(TCC)
TCC 其實就是采用的補償機制,其核心思想是:針對每個操作,都要注冊一個與其對應的確認和補償(撤銷)操作。它分為三個階段:
- Try 階段主要是對業務系統做檢測及資源預留
- Confirm 階段主要是對業務系統做確認提交,Try階段執行成功並開始執行 Confirm階段時,默認 Confirm階段是不會出錯的。即:只要Try成功,Confirm一定成功。
- Cancel 階段主要是在業務執行錯誤,需要回滾的狀態下執行的業務取消,預留資源釋放。
優點: 跟2PC比起來,實現以及流程相對簡單了一些,但數據的一致性比2PC也要差一些
缺點: 缺點還是比較明顯的,在2,3步中都有可能失敗。TCC屬於應用層的一種補償方式,所以需要程序員在實現的時候多寫很多補償的代碼,在一些場景中,一些業務流程可能用TCC不太好定義及處理。
常見問題:
1. 冪等處理
因為網絡抖動等原因,分布式事務框架可能會重復調用同一個分布式事務中的一個分支事務的二階段接口。所以分支事務的二階段接口Confirm/Cancel需要能夠保證冪等性。如果二階段接口不能保證冪等性,則會產生嚴重的問題,造成資源的重復使用或者重復釋放,進而導致業務故障。
從上圖中紅色部分可以看到:如果當TC調用參與者的二階段方法時,發生了異常(TC本身異常或者網絡異常丟失結果)。此時TC無法感知到調用的結果。為了保證分布式事務能夠走到終態,此時TC會按照一定的規則重復調用參與者的二階段方法。
對於冪等類型的問題,通常的手段是引入冪等字段進行防重放攻擊。對於分布式事務框架中的冪等問題,同樣可以祭出這一利器。我們可以通過增加一張事務狀態控制表來實現,這個表的關鍵字段有以下幾個:
- 主事務ID
- 分支事務ID
- 分支事務狀態
其中1和2構成表的聯合主鍵來唯一標識一筆分布式事務中的一條分支事務。3用來標識該分支事務的狀態,一共有3種狀態:
- INIT(I) - 初始化
- CONFIRMED© - 已提交
- ROLLBACKED® - 已回滾
2. 空回滾
當沒有調用參與方Try方法的情況下,就調用了二階段的Cancel方法,Cancel方法需要有辦法識別出此時Try有沒有執行。如果Try還沒執行,表示這個Cancel操作是無效的,即本次Cancel屬於空回滾;如果Try已經執行,那么執行的是正常的回滾邏輯。
可以通過查詢控制表的對應記錄進行判斷。如果記錄存在且狀態為INIT,就表示一階段已成功執行,可以正常執行回滾操作,釋放預留的資源;如果記錄不存在則表示一階段未執行,本次為空回滾,不釋放任何資源。
3. 資源懸掛
當分布式事務到終態后,參與者的一階段Try才被執行,此時參與者會根據業務需求預留相關資源。預留資源只有當前事務才能使用,然而此時分布式事務已經走到終態,后續再沒有任何手段能夠處理這些預留資源。至此,就形成了資源懸掛。
處理方案為事務狀態控制記錄作為控制手段,二階段發現無記錄時插入記錄,一階段執行時檢查記錄是否存在
本地消息表(異步確保)
本地消息表與業務數據表處於同一個數據庫中,這樣就能利用本地事務來保證在對這兩個表的操作滿足事務特性,並且使用了消息隊列來保證最終一致性。
- 在分布式事務操作的一方完成寫業務數據的操作之后向本地消息表發送一個消息,本地事務能保證這個消息一定會被寫入本地消息表中。
- 之后將本地消息表中的消息轉發到 Kafka 等消息隊列中,如果轉發成功則將消息從本地消息表中刪除,否則繼續重新轉發。
- 在分布式事務操作的另一方從消息隊列中讀取一個消息,並執行消息中的操作。
優點: 一種非常經典的實現,避免了分布式事務,實現了最終一致性。
缺點: 消息表會耦合到業務系統中,如果沒有封裝好的解決方案,會有很多雜活需要處理。
MQ 事務消息
有一些第三方的MQ是支持事務消息的,比如RocketMQ,他們支持事務消息的方式也是類似於采用的二階段提交,但是市面上一些主流的MQ都是不支持事務消息的,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中間件為例,其思路大致為:
第一階段Prepared消息,會拿到消息的地址。 第二階段執行本地事務,第三階段通過第一階段拿到的地址去訪問消息,並修改狀態。
也就是說在業務方法內要向消息隊列提交兩次請求,一次發送消息和一次確認消息。如果確認消息發送失敗了RocketMQ會定期掃描消息集群中的事務消息,這時候發現了Prepared消息,它會向消息發送者確認,所以生產方需要實現一個check接口,RocketMQ會根據發送端設置的策略來決定是回滾還是繼續發送確認消息。這樣就保證了消息發送與本地事務同時成功或同時失敗。
優點: 實現了最終一致性,不需要依賴本地數據庫事務。
缺點: 實現難度大,主流MQ不支持,RocketMQ事務消息部分代碼也未開源。
面試
多台機器同時執行某一任務,要求某一時刻最多只有一台機器執行
思路:分布式鎖:在mysql中插入一條記錄,表明獲取鎖。刪除一條記錄,表明釋放鎖。 且在mysql表中設置一個unique key字段, 當有一台機器獲得鎖后, 其他機器無法獲取。
1. 如果一台機器獲得鎖,在釋放鎖之前進程掛了, 那么其他機器無法獲取到鎖。 可以引入鎖有效時間的概念,超時后,刪除記錄,釋放鎖(必須做到可刪除), 同時產生告警。
2. 萬一獲取鎖的操作失敗了,就直接做錯誤處理, 也不太好。 可以引入循環重試的方式來解決,控制重試次數。
處理億級流量以上的高並發
https://mp.weixin.qq.com/s/CxqnaB9cUemvCcWkw2nUcwhttps://mp.weixin.qq.com/s/CxqnaB9cUemvCcWkw2nUcw
可以圍繞“支撐高並發的業務場景怎么設計系統才合理、硬件和軟件層面怎么支撐高並發” 進行回答
面對超高的並發,首先硬件層面機器要能扛得住,其次架構設計做好微服務的拆分,代碼層面各種緩存、削峰、解耦等等問題要處理好,數據庫層面做好讀寫分離、分庫分表,穩定性方面要保證有監控,熔斷限流降級該有的必須要有,發生問題能及時發現處理
一、集群+微服務
集群架構的架構開始出現,單機無法抗住的壓力,最簡單的辦法就是水平拓展橫向擴容了,這樣,通過負載均衡把壓力流量分攤到不同的機器上,暫時是解決了單點導致服務不可用的問題。
集群:硬件上橫向擴展 =》微服務:根據業務拆分服務
把每個獨立的業務拆分開獨立部署,開發和維護的成本降低,集群能承受的壓力也提高了,再也不會出現一個小小的改動點需要牽一發而動全身了。
微服務化的拆分帶來的好處和便利性是顯而易見的,但是與此同時各個微服務之間的通信就需要考慮了。傳統HTTP的通信方式對性能是極大的浪費,這時候就需要引入諸如Dubbo類的RPC框架,基於TCP長連接的方式提高整個集群通信的效率。
二、RPC
Dubbo工作原理
- 服務啟動的時候,provider和consumer根據配置信息,連接到注冊中心register,分別向注冊中心注冊和訂閱服務
- register根據服務訂閱關系,返回provider信息到consumer,同時consumer會把provider信息緩存到本地。如果信息有變更,consumer會收到來自register的推送
- consumer生成代理對象,同時根據負載均衡策略,選擇一台provider,同時定時向monitor記錄接口的調用次數和時間信息
- 拿到代理對象之后,consumer通過代理對象發起接口調用
- provider收到請求后對數據進行反序列化,然后通過代理調用具體的接口實現
Dubbo負載均衡策略
- 加權隨機:假設我們有一組服務器 servers = [A, B, C],他們對應的權重為 weights = [5, 3, 2],權重總和為10。現在把這些權重值平鋪在一維坐標值上,[0, 5) 區間屬於服務器 A,[5, 8) 區間屬於服務器 B,[8, 10) 區間屬於服務器 C。接下來通過隨機數生成器生成一個范圍在 [0, 10) 之間的隨機數,然后計算這個隨機數會落到哪個區間上就可以了。
- 最小活躍數:每個服務提供者對應一個活躍數 active,初始情況下,所有服務提供者活躍數均為0。每收到一個請求,活躍數加1,完成請求后則將活躍數減1。在服務運行一段時間后,性能好的服務提供者處理請求的速度更快,因此活躍數下降的也越快,此時這樣的服務提供者能夠優先獲取到新的服務請求。
- 一致性hash:通過hash算法,把provider的invoke和隨機節點生成hash,並將這個 hash 投射到 [0, 2^32 - 1] 的圓環上,查詢的時候根據key進行md5加密然后進行hash,得到第一個節點的值大於等於當前hash的invoker。
4、加權輪詢:比如服務器 A、B、C 權重比為 5:2:1,那么在8次請求中,服務器 A 將收到其中的5次請求,服務器 B 會收到其中的2次請求,服務器 C 則收到其中的1次請求。
集群容錯
- Failover Cluster失敗自動切換:dubbo的默認容錯方案,當調用失敗時自動切換到其他可用的節點,具體的重試次數和間隔時間可用通過引用服務的時候配置,默認重試次數為1也就是只調用一次。
- Failback Cluster快速失敗:在調用失敗,記錄日志和調用信息,然后返回空結果給consumer,並且通過定時任務每隔5秒對失敗的調用進行重試
- Failfast Cluster 失敗自動恢復:只會調用一次,失敗后立刻拋出異常
- Failsafe Cluster 失敗安全:調用出現異常,記錄日志不拋出,返回空結果
- Forking Cluster並行調用多個服務提供者:通過線程池創建多個線程,並發調用多個provider,結果保存到阻塞隊列,只要有一個provider成功返回了結果,就會立刻返回結果
- Broadcast Cluster廣播模式:逐個調用每個provider,如果其中一台報錯,在循環調用結束后,拋出異常。
三、消息隊列
削峰填谷、解耦。依賴消息隊列,同步轉異步的方式,可以降低微服務之間的耦合。
對於一些不需要同步執行的接口,可以通過引入消息隊列的方式異步執行以提高接口響應時間。在交易完成之后需要扣庫存,然后可能需要給會員發放積分,本質上,發積分的動作應該屬於履約服務,對實時性的要求也不高,我們只要保證最終一致性也就是能履約成功就行了。對於這種同類性質的請求就可以走MQ異步,也就提高了系統抗壓能力了。
消息可靠性
消息丟失可能發生在生產者發送消息、MQ本身丟失消息、消費者丟失消息3個方面。
生產者丟失消息
生產者丟失消息的可能點在於程序發送失敗拋異常了沒有重試處理,或者發送的過程成功但是過程中網絡閃斷MQ沒收到,消息就丟失了。
由於同步發送的一般不會出現這樣使用方式,所以我們就不考慮同步發送的問題,我們基於異步發送的場景來說。
異步發送分為兩個方式:異步有回調和異步無回調,無回調的方式,生產者發送完后不管結果可能就會造成消息丟失,而通過異步發送+回調通知+本地消息表的形式我們就可以做出一個解決方案。以下單的場景舉例。
- 下單后先保存本地數據和MQ消息表,這時候消息的狀態是發送中,如果本地事務失敗,那么下單失敗,事務回滾。
- 下單成功,直接返回客戶端成功,異步發送MQ消息
- MQ回調通知消息發送結果,對應更新數據庫MQ發送狀態
- JOB輪詢超過一定時間(時間根據業務配置)還未發送成功的消息去重試
- 在監控平台配置或者JOB程序處理超過一定次數一直發送不成功的消息,告警,人工介入。
一般而言,對於大部分場景來說異步回調的形式就可以了,只有那種需要完全保證不能丟失消息的場景我們要做一套完整的解決方案。
MQ丟失
如果生產者保證消息發送到MQ,而MQ收到消息后還在內存中,這時候宕機了又沒來得及同步給從節點,就有可能導致消息丟失。
比如RocketMQ:
RocketMQ分為同步刷盤和異步刷盤兩種方式,默認的是異步刷盤,就有可能導致消息還未刷到硬盤上就丟失了,可以通過設置為同步刷盤的方式來保證消息可靠性,這樣即使MQ掛了,恢復的時候也可以從磁盤中去恢復消息,但是性能會有所下降
比如Kafka也可以通過配置做到:
acks=all 只有參與復制的所有節點全部收到消息,才返回生產者成功。這樣的話除非所有的節點都掛了,消息才會丟失。
replication.factor=N,設置大於1的數,這會要求每個partion至少有2個副本
min.insync.replicas=N,設置大於1的數,這會要求leader至少感知到一個follower還保持着連接
retries=N,設置一個非常大的值,讓生產者發送失敗一直重試
雖然我們可以通過配置的方式來達到MQ本身高可用的目的,但是都對性能有損耗,怎樣配置需要根據業務做出權衡。
消費者丟失
消費者丟失消息的場景:消費者剛收到消息,此時服務器宕機,MQ認為消費者已經消費,不會重復發送消息,消息丟失。
RocketMQ默認是需要消費者回復ack確認,而kafka需要手動開啟配置關閉自動offset。
消費方不返回ack確認,重發的機制根據MQ類型的不同發送時間間隔、次數都不盡相同,如果重試超過次數之后會進入死信隊列,需要手工來處理了。(Kafka沒有這些)
消息最終一致性
事務消息可以達到分布式事務的最終一致性,事務消息就是MQ提供的類似XA的分布式事務能力。
半事務消息就是MQ收到了生產者的消息,但是沒有收到二次確認,不能投遞的消息。
實現原理如下:
- 生產者先發送一條半事務消息到MQ
- MQ收到消息后返回ack確認
- 生產者開始執行本地事務
- 如果事務執行成功發送commit到MQ,失敗發送rollback
- 如果MQ長時間未收到生產者的二次確認commit或者rollback,MQ對生產者發起消息回查
- 生產者查詢事務執行最終狀態
- 根據查詢事務狀態再次提交二次確認
最終,如果MQ收到二次確認commit,就可以把消息投遞給消費者,反之如果是rollback,消息會保存下來並且在3天后被刪除。
數據庫
對於整個系統而言,最終所有的流量的查詢和寫入都落在數據庫上,數據庫是支撐系統高並發能力的核心。怎么降低數據庫的壓力,提升數據庫的性能是支撐高並發的基石。主要的方式就是通過讀寫分離和分庫分表來解決這個問題。
對於整個系統而言,流量應該是一個漏斗的形式。比如我們的日活用戶DAU有20萬,實際可能每天來到提單頁的用戶只有3萬QPS,最終轉化到下單支付成功的QPS只有1萬。那么對於系統來說讀是大於寫的,這時候可以通過讀寫分離的方式來降低數據庫的壓力。
讀寫分離也就相當於數據庫集群的方式降低了單節點的壓力。而面對數據的急劇增長,原來的單庫單表的存儲方式已經無法支撐整個業務的發展,這時候就需要對數據庫進行分庫分表了。針對微服務而言垂直的分庫本身已經是做過的,剩下大部分都是分表的方案了。
水平分表
首先根據業務場景來決定使用什么字段作為分表字段(sharding_key),比如我們現在日訂單1000萬,我們大部分的場景來源於C端,我們可以用user_id作為sharding_key,數據查詢支持到最近3個月的訂單,超過3個月的做歸檔處理,那么3個月的數據量就是9億,可以分1024張表,那么每張表的數據大概就在100萬左右。
比如用戶id為100,那我們都經過hash(100),然后對1024取模,就可以落到對應的表上了。
分表后ID唯一
因為我們主鍵默認都是自增的,那么分表之后的主鍵在不同表就肯定會有沖突了。有幾個辦法考慮:
- 設定步長,比如1-1024張表我們分別設定1-1024的基礎步長,這樣主鍵落到不同的表就不會沖突了。
- 分布式ID,自己實現一套分布式ID生成算法或者使用開源的比如雪花算法這種
- 分表后不使用主鍵作為查詢依據,而是每張表單獨新增一個字段作為唯一主鍵使用,比如訂單表訂單號是唯一的,不管最終落在哪張表都基於訂單號作為查詢依據,更新也一樣。
主從同步原理
- master提交完事務后,寫入binlog
- slave連接到master,獲取binlog
- master創建dump線程,推送binlog到slave
- slave啟動一個IO線程讀取同步過來的master的binlog,記錄到relay log中繼日志中
- slave再開啟一個sql線程讀取relay log事件並在slave執行,完成同步
- slave記錄自己的binglog
由於mysql默認的復制方式是異步的,主庫把日志發送給從庫后不關心從庫是否已經處理,這樣會產生一個問題就是假設主庫掛了,從庫處理失敗了,這時候從庫升為主庫后,日志就丟失了。由此產生兩個概念。
全同步復制
主庫寫入binlog后強制同步日志到從庫,所有的從庫都執行完成后才返回給客戶端,但是很顯然這個方式的話性能會受到嚴重影響。
半同步復制
和全同步不同的是,半同步復制的邏輯是這樣,從庫寫入日志成功后返回ACK確認給主庫,主庫收到至少一個從庫的確認就認為寫操作完成。
緩存
緩存作為高性能的代表,在某些特殊業務可能承擔90%以上的熱點流量。對於一些活動比如秒殺這種並發QPS可能幾十萬的場景,引入緩存事先預熱可以大幅降低對數據庫的壓力,10萬的QPS對於單機的數據庫來說可能就掛了,但是對於如redis這樣的緩存來說就完全不是問題。
以秒殺系統舉例,活動預熱商品信息可以提前緩存提供查詢服務,活動庫存數據可以提前緩存,下單流程可以完全走緩存扣減,秒殺結束后再異步寫入數據庫,數據庫承擔的壓力就小的太多了。當然,引入緩存之后就還要考慮緩存擊穿、雪崩、熱點一系列的問題了。
熱key問題
所謂熱key問題就是,突然有幾十萬的請求去訪問redis上的某個特定key,那么這樣會造成流量過於集中,達到物理網卡上限,從而導致這台redis的服務器宕機引發雪崩。
針對熱key的解決方案:
- 提前把熱key打散到不同的服務器,降低壓力
- 加入二級緩存,提前加載熱key數據到內存中,如果redis宕機,走內存查詢
緩存擊穿
緩存擊穿的概念就是單個key並發訪問過高,過期時導致所有請求直接打到db上,這個和熱key的問題比較類似,只是不同的點在於過期導致請求全部打到DB上而已。
解決方案:
- 加鎖更新,比如請求查詢A,發現緩存中沒有,對A這個key加鎖,同時去數據庫查詢數據,寫入緩存,再返回給用戶,這樣后面的請求就可以從緩存中拿到數據了。
- 將過期時間組合寫在value中,通過異步的方式不斷的刷新過期時間,防止此類現象。
緩存穿透
緩存穿透是指查詢不存在緩存中的數據,每次請求都會打到DB,就像緩存不存在一樣。
針對這個問題,加一層布隆過濾器。布隆過濾器的原理是在你存入數據的時候,會通過散列函數將它映射為一個位數組中的K個點,同時把他們置為1。
這樣當用戶再次來查詢A,而A在布隆過濾器值為0,直接返回,就不會產生擊穿請求打到DB了。
顯然,使用布隆過濾器之后會有一個問題就是誤判,因為它本身是一個數組,可能會有多個值落到同一個位置,那么理論上來說只要我們的數組長度夠長,誤判的概率就會越低,這種問題就根據實際情況來就好了。
緩存雪崩
當某一時刻發生大規模的緩存失效的情況,比如你的緩存服務宕機了, 或者大量的key過期,會有大量的請求進來直接打到DB上,這樣可能導致整個系統的崩潰,稱為雪崩。雪崩和擊穿、熱key的流量過於集中問題不太一樣的是,雪崩是指大規模的緩存都過期失效了。
針對雪崩幾個解決方案:
- 針對不同key設置不同的過期時間,避免同時過期
- 限流,如果redis宕機,可以限流,避免同時刻大量請求打崩DB
- 二級緩存,同熱key的方案。
穩定性
熔斷
比如營銷服務掛了或者接口大量超時的異常情況,不能影響下單的主鏈路,涉及到積分的扣減一些操作可以在事后做補救。
限流
對突發如大促秒殺類的高並發,如果一些接口不做限流處理,可能直接就把服務打掛了,針對每個接口的壓測性能的評估做出合適的限流尤為重要。
降級
熔斷之后實際上可以說就是降級的一種,以熔斷的舉例來說營銷接口熔斷之后降級方案就是短時間內不再調用營銷的服務,等到營銷恢復之后再調用。
預案
一般來說,就算是有統一配置中心,在業務的高峰期也是不允許做出任何的變更的,但是通過配置合理的預案可以在緊急的時候做一些修改。
核對
針對各種分布式系統產生的分布式事務一致性或者受到攻擊導致的數據異常,非常需要核對平台來做最后的兜底的數據驗證。比如下游支付系統和訂單系統的金額做核對是否正確,如果收到中間人攻擊落庫的數據是否保證正確性。
總結
其實可以看到,怎么設計高並發系統這個問題本身他是不難的,無非是基於你知道的知識點,從物理硬件層面到軟件的架構、代碼層面的優化,使用什么中間件來不斷提高系統的抗壓能力。
但是這個問題本身會帶來更多的問題,微服務本身的拆分帶來了分布式事務的問題,http、RPC框架的使用帶來了通信效率、路由、容錯的問題,MQ的引入帶來了消息丟失、積壓、事務消息、順序消息的問題,緩存的引入又會帶來一致性、雪崩、擊穿的問題,數據庫的讀寫分離、分庫分表又會帶來主從同步延遲、分布式ID、事務一致性的問題,而為了解決這些問題我們又要不斷的加入各種措施熔斷、限流、降級、離線核對、預案處理等等來防止和追溯這些問題。