上周公司支付系統(tǒng)出現(xiàn)了故障,在故障處理后的善后排查中發(fā)現(xiàn)了某賬戶某段時間內(nèi)資金和流水對不上树绩,最終發(fā)現(xiàn)了一個并發(fā)BUG峦筒。由于該BUG具有一定普通適性,故整理成文到內(nèi)部知識庫锄列,簡書也順便update了图云。
數(shù)據(jù)定義
能夠說明問題的最簡數(shù)據(jù)如下。
CREATE TABLE `purse` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`money` DECIMAL(13,2) NOT NULL DEFAULT 0.0 COMMENT '賬戶金額',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='錢包' ;
CREATE TABLE `user_order` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`money` DECIMAL(10,2) NOT NULL DEFAULT 0.0 COMMENT '訂單金額',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='訂單';
INSERT purse VALUE (1,1);
INSERT user_order VALUE (1,10);
INSERT user_order VALUE (2,20);
偽代碼
能構(gòu)成該BUG的最簡偽代碼如下
1.sql('start transaction');
2.orderMoney=sql('select money from user_order where id =:id ')
3.sql('select money from user_purse where id =:id for update')
4.nowMoney=sql('select * from orders where id =:order_id ')+orderMoney
5.sql('user_purse set avail_money=:nowMoney where id =:id')
6.sql('commit')
故障原理
定義兩個客戶端/事務為A/B,其中一種能夠觸發(fā)BUG的執(zhí)行流程如下邻邮。
//事務隔離級別為默認的REPEATABLE READ
A1 sql('start transaction');
B1 sql('start transaction');
A2 orderMoney=sql('select money from user_order where id =1' )//10
B2 orderMoney=sql('select money from user_order where id =2 ')//20
B3.sql('select money from user_purse where id = 1 for update')//1
B4.nowMoney=sql('select money from user_purse where id =1 ' )+orderMoney//21
B5.sql('user_purse set avail_money=:nowMoney where id =3')//purse=21
B6.sql('commit')//purse =21
A3.sql('select money from user_purse where id =:id for update')//purse=21
A4.nowMoney=sql('select money from user_purse where id =1 ')+orderMoney //問題觸發(fā)點 purse=1+10
A5.sql('user_purse set avail_money=:nowMoney where id =1')//purse =11
A6.sql('commit')//purse =11
示例代碼中竣况,通過語句3的for update
對賬戶表的某一條記錄來施加行鎖以達到并發(fā)控制。
語句3~6間的流程即語句4筒严,5都處于臨界區(qū)丹泉,案例中由于事務B率先拿到了行x鎖,所以事務A的語句3一直阻塞到事務B語句6執(zhí)行完成鸭蛙。
臨界區(qū)的處理本身沒有任何問題摹恨。問題在于開發(fā)者在這里沒有考慮MVCC和隔離級別,導致語句4在臨界區(qū)引入了快照讀娶视。
SELECT FOR UPDATE
和SELECT LOCK IN SHARE MODE
這兩個操作在Innodb中被成為一致性鎖定讀
這兩者的性質(zhì)是通過加鎖來控制并發(fā)訪問晒哄,在讀取到的是記錄的(已提交)實時值。
普通的SELECT
稱為一致性非鎖定讀
其特性是通過MVCC(多版本)控制的方式讀取數(shù)據(jù)肪获。SELECT
并不會去等待鎖的釋放寝凌,而是直接獲取數(shù)據(jù)的一個快照副本,在RR的隔離級別下孝赫,這個快照即事務開始時的數(shù)據(jù)快照较木。
因此A4讀到了事務B修改前的數(shù)據(jù)。最終導致事務B在purse表的同一行記錄被覆蓋了青柄。
解決方案
這個場景的修復方式有3種:
- 將隔離級別降低為RC(Read committed):RC的快照讀獲取的是數(shù)據(jù)已提交的最后一個版本伐债,本案例中A4拿到的將會是B6提交后更新21预侯,但這種辦法本質(zhì)是為事務引入’不可重復讀‘。
2.將語句3和語句4合并泳赋,如果代碼或框架上下文不允許合并雌桑。可以將語句4改成SELECT FOR UPDATE
/SELECT LOCK IN SHARE MODE
祖今,避免快照讀校坑。
3.使用update set money+XX
這種自帶X鎖的表達式語法,這種是最建議的解決方案千诬,除了避免了此處的快照讀耍目,還避免了應用層到mysql數(shù)據(jù)轉(zhuǎn)換的潛在坑坑以及避免了加鎖和事務處理不當?shù)姆N種低級問題。在應用層計算數(shù)據(jù)更新后的值再寫入數(shù)據(jù)庫徐绑,是一種非常不健壯的方案邪驮,特別是在PHP這種弱類型語言中,服務異常導致的null隨時會讓你把某些數(shù)據(jù)初始化傲茄。
尾注:上文提到的“事務開啟時的快照”是業(yè)內(nèi)包括《高性能Mysql》《Mysql技術(shù)內(nèi)幕》對RR下MVCC策略的一般描述毅访。嚴格來說并不完全準確。
all consistent reads within the same transaction read the snapshot established by the first such read in that transaction.
RR隔離級別下盘榨,實際上快照讀使用的是事務中第一條查詢執(zhí)行時的數(shù)據(jù)快照喻粹,所以如果想要復現(xiàn),該問題例子中看似無關(guān)的語句2不能刪除草巡。如果語句2不存在守呜,除非事務B的提交(B6)在在事務A的for update查詢(A3)之前,才能復現(xiàn)該問題山憨。