分布式事務概述
說起分布式事務,是個讓人又愛又恨的話題。恨他,因為這個世界性難題始終沒有一個完美的解決方案。而愛他,因為他引出了一系列解巧妙決方案,不得不感嘆一代又一代計算機人的智慧。這篇文章我們就來談談分布式事務的一些解決方案,其中互聯網公司中最常用哪種?MQ又如何和事務聯系起來呢?
模型
在正式開始介紹之前,我們先通過兩張圖例來看看什么是分布式事務的情況。
上圖我們再熟悉不過,就是傳統的本地事務,我們操作的數據庫是一個單數據源,通過數據庫提供的事務就可以輕松完成,業務上通過spring的聲明式事務注解@Transitional就可以,可是如果多數據源的情況呢?
像這樣其實就是分布式事務的一種情況了,跨庫事務。很明顯本地事務那套方式已經玩不起來了,要怎么辦呢?當然是兩階段提交嘍,別急,先賣個關子,后面我們詳細來解釋,下面再來看一種情況。
以上大概是互聯網最常見的一種模型了,微服務間的分布式事務。這種情況甚至服務間存在于不同的jvm進程。這里要使用的方式就是大名鼎鼎的TCC了,這個我們后面再詳細探討具體的實現方式。
好了,了解了分布式事務產生的模型,我們就可以開始正式開始吹牛逼之旅了,等等,貌似還需要交代幾個小小的理論。
BASE理論
為什么說分布式事務是個難題?因為要達到強一致性(數據更新后立即達到一致狀態)是非常困難的,所以有了中間狀態(軟狀態),即允許數據在某一狀態下是不一致的,但是要盡可能保證最終一致性。這其實也就是BASE理論了,他的定義如下:
base理論
- 基本可用(Basically Availability)
指分布式系統出現不可預知錯誤的時候,允許損失部分可用性。 - 軟狀態(Soft State)
也就是中間狀態,允許存在這種狀態并認為不會影響系統的整體可用性,即允許不同節點在的數據傳輸之間存在延遲。 - 最終一致(Eventual Consistency)
在數據更新操作之后,在經過一定時間的同步之后,最終都能達到一個一致的狀態。不需要保證系統的強一致性。
兩階段提交(Two Phase Commitment)
所謂兩階段提交,顧名思義,就是把事務的提交分成兩個階段,但是注意,這兩個階段是在一組操作中的,不要誤以為是兩組操作。可能這么說不是很明白,我們再來看張圖例
還不明白也沒關系,等介紹完了XA和TCC再回過頭看看就GET了。好了,下面我們就從XA開始,正式開始來了解分布式事務的那些解決方案。
XA/JTA方案
XA是業界關于分布式管理的一個規范,而JTA是JAVA的一個實現。
在XA中,我們引入了一個中間協調者的角色。在第一階段中,所有的參與者需要鎖住要操作的資源,進行操作,然后通知協調者已經準備就緒,可以提交事務。
第二階段時協調者收到了某個參與者發送的請求,得知了他們都已經達到了可以提交事務的狀態,接著像所有參與者發送commit命令,事務提交。如果有一方參與者執行失敗,那協調器就會發送rollback命令,各個參與者都回滾。
都說talk is cheap,show me the code,下面我們就來看看上述過程使用JTA實現的一組代碼
boolean logXaCommands = true;
// 獲得資源管理器操作接口實例 RM1
Connection conn1 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "root");
XAConnection xaConn1 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn1, logXaCommands);
XAResource rm1 = xaConn1.getXAResource();
// 獲得資源管理器操作接口實例 RM2
Connection conn2 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test1", "root", "root");
XAConnection xaConn2 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn2, logXaCommands);
XAResource rm2 = xaConn2.getXAResource();
// AP請求TM執行一個分布式事務,TM生成全局事務id
byte[] gtrid = "g12345".getBytes();
int formatId = 1;
try {
// ==============分別執行RM1和RM2上的事務分支====================
// TM生成rm1上的事務分支id
byte[] bqual1 = "b00001".getBytes();
Xid xid1 = new MysqlXid(gtrid, bqual1, formatId);
// 執行rm1上的事務分支
rm1.start(xid1, XAResource.TMNOFLAGS);// One of TMNOFLAGS, TMJOIN,
// or TMRESUME.
PreparedStatement ps1 = conn1.prepareStatement("INSERT into user(name) VALUES ('tianshouzhi')");
ps1.execute();
rm1.end(xid1, XAResource.TMSUCCESS);
// TM生成rm2上的事務分支id
byte[] bqual2 = "b00002".getBytes();
Xid xid2 = new MysqlXid(gtrid, bqual2, formatId);
// 執行rm2上的事務分支
rm2.start(xid2, XAResource.TMNOFLAGS);
PreparedStatement ps2 = conn2.prepareStatement("INSERT into user(name) VALUES ('wangxiaoxiao')");
ps2.execute();
rm2.end(xid2, XAResource.TMSUCCESS);
// ===================兩階段提交================================
// phase1:詢問所有的RM 準備提交事務分支
int rm1_prepare = rm1.prepare(xid1);
int rm2_prepare = rm2.prepare(xid2);
// phase2:提交所有事務分支
boolean onePhase = false; // TM判斷有2個事務分支,所以不能優化為一階段提交
// 所有事務分支都prepare成功,提交所有事務分支
if (rm1_prepare == XAResource.XA_OK && rm2_prepare == XAResource.XA_OK) {
rm1.commit(xid1, onePhase);
rm2.commit(xid2, onePhase);
} else {// 如果有事務分支沒有成功,則回滾
rm1.rollback(xid1);
rm1.rollback(xid2);
}
} catch (XAException e) {
// 如果出現異常,也要進行回滾
e.printStackTrace();
}
再來看看atomikos的實現方式,atomikos的免費開源版也實現了XA
private static AtomikosDataSourceBean createAtomikosDataSourceBean(String dbName) {
// 連接池基本屬性
Properties p = new Properties();
p.setProperty("url", "jdbc:mysql://localhost:3306/" + dbName);
p.setProperty("user", "root");
p.setProperty("password", "root");
// 使用AtomikosDataSourceBean封裝com.mysql.jdbc.jdbc2.optional.MysqlXADataSource
AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
// atomikos要求為每個AtomikosDataSourceBean名稱,為了方便記憶,這里設置為和dbName相同
ds.setUniqueResourceName(dbName);
ds.setXaDataSourceClassName("com.mysql.jdbc.jdbc2.optional.MysqlXADataSource");
ds.setXaProperties(p);
return ds;
}
public static void main(String[] args) throws Exception {
AtomikosDataSourceBean ds1 = createAtomikosDataSourceBean("db_user");
AtomikosDataSourceBean ds2 = createAtomikosDataSourceBean("db_account");
Connection conn1 = null;
Connection conn2 = null;
PreparedStatement ps1 = null;
PreparedStatement ps2 = null;
UserTransaction userTransaction = new UserTransactionImp();
try {
// 開啟事務
userTransaction.begin();
// 執行db1上的sql
conn1 = ds1.getConnection();
ps1 = conn1.prepareStatement("INSERT into user(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS);
ps1.setString(1, "tianshouzhi");
ps1.executeUpdate();
ResultSet generatedKeys = ps1.getGeneratedKeys();
int userId = -1;
while (generatedKeys.next()) {
userId = generatedKeys.getInt(1);// 獲得自動生成的userId
}
// 模擬異常 ,直接進入catch代碼塊,2個都不會提交
// int i=1/0;
// 執行db2上的sql
conn2 = ds2.getConnection();
ps2 = conn2.prepareStatement("INSERT into account(user_id,money) VALUES (?,?)");
ps2.setInt(1, userId);
ps2.setDouble(2, 10000000);
ps2.executeUpdate();
// 兩階段提交
userTransaction.commit();
} catch (Exception e) {
try {
e.printStackTrace();
userTransaction.rollback();
} catch (SystemException e1) {
e1.printStackTrace();
}
} finally {
try {
ps1.close();
ps2.close();
conn1.close();
conn2.close();
ds1.close();
ds2.close();
} catch (Exception ignore) {
}
}
}
簡單很多了對吧?其實它們的原理時十分類似的,所以如果有面試問到atomikos的具體實現方式,你懂得!
說了這么多,其實這種方式更多的是提供一種解決問題的思路,實際環境中時不太可能這么玩的,因為這種方式性能太差了,他需要鎖住相應的資源,互聯網項目中有著很高并發吞吐量,這種方式很明顯不適合,所以還是得引入我們今天要討論的第二種方式:TCC
TCC兩階段補償性方案
根據上面的圖例,也可以看出TCC就是 Try-Confirm-Cancel的簡稱,為了讓大家更好的理解這種方案,我舉個例子來說明。
假設我們需要一張從合肥飛往大理的機票,但是沒有直飛,怎么辦?難道一張一張買嗎,更不用說還要留意中轉時間是否合理等等問題,所以我們往往會選擇一個機票預訂平臺,讓他幫我們一次性購買這兩張機票。
這就是一個典型的分布式事務的場景了,機票預訂平臺需要同時向兩家航空公司(不同DB,不同SERVER)發送下單請求,要么同時成功,要么同時失敗。很明顯XA那種方案是完全不適用的,總不能把人家的表資源給鎖了,誰會讓你這么干。所以我們需要的是一種業務上的手段。
好,這種業務手段其實就是TCC,在第一階段中,機票預訂平臺會像兩家航空公司提供的API接口發送請求,預留我們需要的機票。第二階段中, 如果預留操作任意一方不成功,就發送取消請求,訂票不成功。如果成功呢?發送確認請求,完成下單操作。這樣一來,就保證了預定機票這組操作要么同時成功,要么不成功。中間的預留就是中間狀態,但是最終保證了數據的一致性。
你可能有這樣的問題,要是確認階段有一方失敗怎么辦?首先呢,這個失敗幾率不高,但是對于互聯網公司來說,即使很低的幾率對應的訂單量可能也是非常龐大的,所以需要兩方共同提供一定的機制,進一步提高訂票的成功率。比如,機票預訂平臺在收到確認失敗的消息時,可能會有一定的重試機制,若重試若干次時候依然不成功,才會認為是真正的失敗。航空公司則會對接口保證冪等性,對網絡超時失敗的情況(訂單其實已經生成)也要有一定的處置方式。
如果還是失敗呢?BASE定理允許產生一定的不可用,所以我們要對這種情況進行補償。通常使用日志或者MQ的方式進行補償,甚至最后還是需要通過人工對賬的方式。
說到這里已經不難看出,XA是一種資源層面的分布式事務,在兩階段提交的整個過程中,它都會一致持有資源的鎖,是強一致性,而TCC則是業務層面的分布式事務,不會持有資源鎖,保證的是最終一致性。最后再給大家提供一個實現了tcc的框架,有興趣的話可以多看看,我們這里就不提供具體代碼了。
MQ事務方案
終于到了最后一種方案了,其實也很簡單,先看圖
服務1先向MQ發送一條中間狀態的prepare消息,此時這條消息不會被消費者收到。接著繼續執行服務1中的業務邏輯,成功后再向MQ發送confirm消息,將這條消息從中間狀態改為可被消費者接受的狀態,消費者收到消息后執行己方業務邏輯,成功后向MQ發送ACK。
這樣同樣保證了分布式事務,且因為存在中間狀態,所以保證的也是最終一致性。如果消費者一方收取消息出現異常或ack請求超時呢?MQ一般都有一定的消息補發重試機制,所以要做好接口的冪等優化。如果confirm請求失敗呢?這時候消息隊列需要像服務1對應的業務發送定時消息來確認當前狀態,如果已經成功,再修改中間狀態即可。
總結
無論哪種方案都不能十全十美的保證分布式事務,所以一定要做好補償。總而言之,業界對于這一難題的解決方案都是柔性事務+補償機制,強調的是最終一致性。要想保證強一致性又不影響性能,這就是一個世界性難題了。不過牛人輩出,說不定哪一天我們就能見到這樣的方案了不是嗎?