文章分為以下幾個要點
- 問題描述以及解決過程
- MySQL鎖機制
- 數據庫加鎖分析
下面討論的都是基於MySQL的InnoDB。
0. 問題描述以及解決過程
因為涉及到公司利益問題,所以下面很多代碼和數據庫信息,進行了縮減和修改,望見諒。
業務場景是優惠券系統規則規定了一個優惠券活動最多可發行多少張優惠券和每個用戶最多可領取優惠券數量。
下面列出兩張表的結構。
活動表
CREATE TABLE `coupon_activity` ( `act_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵', `act_code` char(6) NOT NULL DEFAULT '' COMMENT '活動編碼', `coup_issue_num` int(11) NOT NULL DEFAULT '0' COMMENT '優惠券發行量', `coup_per_num` int(11) NOT NULL DEFAULT '0' COMMENT '單個用戶可領取數', PRIMARY KEY (`act_id`), UNIQUE KEY `act_code_idx` (`act_code`) COMMENT '活動編碼唯一索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
優惠券明細表
CREATE TABLE `coupon_detail` ( `coup_id` int(11) NOT NULL AUTO_INCREMENT, `act_code` char(6) NOT NULL DEFAULT '' COMMENT '活動編號', `coup_code` char(6) NOT NULL DEFAULT '' COMMENT '優惠券編碼', `coup_user_id` int(11) NOT NULL DEFAULT '0' COMMENT '領取券用戶id', PRIMARY KEY (`coup_id`), UNIQUE KEY `coup_code_idx` (`coup_code`) USING BTREE COMMENT '優惠券編碼唯一索引', KEY `coup_user_idx` (`coup_user_id`) USING BTREE COMMENT '用戶id普通索引', KEY `act_code_idx` (`act_code`) USING BTREE COMMENT '活動編碼普通索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='優惠券明細表';
假設一個優惠券活動設置的最大發行量為1000張優惠券,單個用戶最多可領取1張優惠券。如下
不考慮到並發的話,用戶10領取act_code=’000000’活動的優惠券 執行的sql如下。注意#{}里面的字段表示之前的sql查詢出來的字段。
begin; select * from coupon_activity where act_code = '000000'; select count(coup_id) as count_all from coupon_detail where act_code = #{act_code}; select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code}; //插入明細表 首先判斷是否當前領用量小於活動發行量,當前用戶領取量是否小於每個用戶可領取數 if(#{count_all} < #{coup_issue_num} && #{count_per} < #{coup_per_num}){ insert into coupon_detail values(1,act_code,'000000',10); } commit;
其實上面的代碼不需要用到事務,但是為了體現接下來的並發時的情形,我就加上了事務。
首先我們來討論,最大發行量發生並發時的問題。
假設現在優惠券領取了999張,此時有兩個用戶進來領取,也就是兩個事務同時進行,
首先兩個事務數出活動當前領用量都是999張,所以if判斷通過,兩個用戶都可以執行insert語句,這樣的話就會多出一張券沒有限制住。
如何來解決這個問題呢,其實比較簡單,可以利用樂觀鎖的方式(后面會詳細介紹鎖的知識),就是我假設並發不會發生,但是我在update數據的時候我會判斷他是否滿足條件。
此時我們就要另外借助一個字段來完成coup_num_current:當前券領用量。每次領券后我們都要更新這個字段,使其加1. 此時我們加個判斷看起是否小於最大
發行量。
alter table coupon_activity add coup_num_current int(11) NOT NULL DEFAULT '0' COMMENT '當前券領用量';
那么此時的執行代碼就變成了下面這樣的。
begin; select * from coupon_activity where act_code = '000000'; select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code}; //插入明細表 首先判斷是否當前領用量小於活動發行量,當前用戶領取量是否小於每個用戶可領取數 if(#{count_per} < #{coup_per_num}){ insert into coupon_detail values(1,act_code,'000000',10); } int i = update coupon_activity set coup_num_current = coup_num_current + 1 where act_code = #{act_code} and coup_num_current < #{coup_issue_num} //如果未有數據更新 (表明不滿足coup_num_current < #{coup_issue_num}即已達最大領用量) if(i == 0){ throw new Exception("此處是為了讓之前的insert回滾"); } commit;
此時我們解決了最大領用量的並發問題,下面我們來討論下如何限制住單個用戶可領取數,這個要復雜一些。因為設計到的操作要多一些,首先你要統計出已經領取的數量,
然后插入新數據,而不像限制優惠券發行量那樣只有一個update語句,所以用之前的那種樂觀鎖不行,因為統計出來的數據很有可能是臟數據,那么樂觀鎖不行的話,那用
悲觀鎖來解決呢?先來分析下,偽代碼如下
#統計出單個用戶領取該券的數量,上了悲觀鎖 select count(coup_id) as count_per from coupon_detail where coup_user_id = 10 and act_code = #{act_code} for update; if(#{count_per} < #{coup_per_num}){ insert into coupon_detail values(1,act_code,'000000',10); }
分析一下上面的select count語句可以發現他對coup_user_id = 10 and act_code = ‘000000’的數據上了鎖,但是我們接下來要做的操作是insert操作,而不是update操作。
當兩個事務剛進來的時候統計的數據都為0,也沒辦法給coup_user_id = 10 and act_code = ‘000000’的數據上鎖,所以兩個selec count for update 都能執行,
那么后面的insert操作也自然能成功,但是當有數據的時候,其中一個select for update會等待,這樣的話就能成功。
這樣的話悲觀鎖也是不行的,但是其實我們再回過頭來想一下樂觀鎖為什么不行,是因為他分為了兩個語句,而前面那個語句select count可能會讀到臟數據,那么后面的利用某個字段去
update時判斷值就有可能不對,那么如何保證統計的數據跟判斷保持一致呢,因為mysql處理語句的時候是一條一條處理的,所以我們通過寫成一條sql就可以達到前后數據一致問題。
此處我們使用insert的時候統計出當前領取數,並與可領取數進行對比,偽代碼如下
insert into coupon_detail(coup_id,act_code,coup_code,coup_user_id) select 1 ,'000000' ,'000000' ,10 from (select count(coup_id) as num from coupon_detail where coup_user_id = 10 and act_code = '000000') temp where temp.num < 1
上面這條復雜的sql在高並發時會發生死鎖的情況,但是確能得到正確的結果。我們來分析一下死鎖的情形。
上面這條語句最里面的select where coup-user-id = 10 and act-code = ‘000000’ 會鎖住這一行數據,但是當數據庫沒有值的時候,就上不了鎖,那么另外一個事務的select也能查詢,
但是兩個事務都對coup_user-id = 10 and act-code = ‘000000’上鎖了,那么insert的時候兩者都處於等待對方釋放鎖的狀態,所以就發生了死鎖,數據庫解決死鎖之后,只有一條數據
插入成功,這樣也就得到了我們需要的結果。
在InnoDB中,鎖是逐步獲得的,因此發生死鎖是可能的。發生死鎖后,InnoDB一般都能自動檢測到,並使一個事務釋放鎖並回退,另外一個事務獲得鎖,並繼續完成事務。但在涉及外部鎖,或涉及表鎖的情況下,InnoDB並不能完全自動檢測到死鎖,
這需要通過設置鎖等待超時參數innodb_lock_wait_timeout來解決。
1. mysql鎖機制
InnoDB存儲引擎既支持行級鎖(row-level locking),也支持表級鎖,但默認情況下是采用行級鎖。
表級鎖:開銷小,加鎖快;不會出現死鎖;鎖定粒度大,發生鎖沖突的概率最高,並發度最低。
行級鎖:開銷大,加鎖慢;會出現死鎖;鎖定粒度最小,發生鎖沖突的概率最低,並發度也最高。
innodb 行級鎖 record-level
lock大致有三種:record lock, gap lock and Next-KeyLocks。
record lock 鎖住某一行記錄
gap lock 鎖住某一段范圍中的記錄
next key lock 是前兩者效果的疊加。
nnoDB實現了以下兩種類型的行鎖:
- 共享鎖:允許一個事務去讀一行,阻止其他事務獲得相同數據集的排他鎖;
- 排他鎖:允許獲得排他鎖的事務更新數據,阻止其他事務取得相同數據集的共享讀鎖和排他寫鎖。
為了允許行鎖和表鎖共存,實現多粒度鎖機制,InnoDB還有兩種內部使用的意向鎖(意向共享鎖和意向排他鎖)。這兩種意向鎖都是表鎖。意向鎖是InnoDB自動加的,不需要用戶干預。
對於UPDATE、DELETE和INSERT語句,InnoDB會自動給涉及數據集加排他鎖;對於普通SELECT語句,InnoDB不會加任意鎖。
事務可以通過以下語句顯示給記錄集加共享鎖或者排他鎖:
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE #共享鎖 SELECT * FROM table_name WHERE ... FOR UPDATE #排他鎖
InnoDB的行鎖實現的特點:只有通過索引條件檢索數據,InnoDB才會使用行級鎖,否則,InnoDB將會使用表鎖。因為MySQL的行鎖是針對索引加的鎖,
而不是針對記錄加的鎖,所以雖然是訪問不同行的記錄,但是如果是使用相同的索引建,是會出現鎖沖突的。
對於鍵值在條件范圍內但並不存在的記錄,叫做間隙。InnoDB會對這個間隙加鎖,這種鎖機制就是所謂的間隙鎖(Next-Key鎖)。
InnoDB使用間隙鎖的目的:一是為了防止幻讀,二是為了滿足其恢復和復制的需要。
InnoDB如何解決死鎖問題的:
在InnoDB中,鎖是逐步獲得的,因此發生死鎖是可能的。發生死鎖后,InnoDB一般都能自動檢測到,並使一個事務釋放鎖並回退,另外一個事務獲得鎖,並繼續完成事務。但在涉及外部鎖,
或涉及表鎖的情況下,InnoDB並不能完全自動檢測到死鎖,這需要通過設置鎖等待超時參數innodb_lock_wait_timeout來解決。
2. 數據庫加鎖分析
MySQL InnoDB存儲引擎,實現的是基於多版本的並發控制協議——MVCC (Multi-Version Concurrency Control) (注:與MVCC相對的,
是基於鎖的並發控制,Lock-Based Concurrency Control)。MVCC最大的好處,相信也是耳熟能詳:讀不加鎖,讀寫不沖突。
在讀多寫少的OLTP應用中,讀寫不沖突是非常重要的,極大的增加了系統的並發性能,這也是為什么現階段,幾乎所有的RDBMS,都支持了MVCC。
在MVCC並發控制中,讀操作可以分成兩類:快照讀 (snapshot read)與當前讀 (current read)。快照讀,讀取的是記錄的可見版本 (有可能是歷史版本),
不用加鎖。當前讀,讀取的是記錄的最新版本,並且,當前讀返回的記錄,都會加上鎖,保證其他事務不會再並發修改這條記錄。
在一個支持MVCC並發控制的系統中,哪些讀操作是快照讀?哪些操作又是當前讀呢?以MySQL InnoDB為例:
快照讀:簡單的select操作,屬於快照讀,不加鎖。(當然,也有例外,下面會分析)
select * from table where ?;
當前讀:特殊的讀操作,插入/更新/刪除操作,屬於當前讀,需要加鎖。
select * from table where ? lock in share mode; select * from table where ? for update; insert into table values (…); update table set ? where ?; delete from table where ?;
所有以上的語句,都屬於當前讀,讀取記錄的最新版本。並且,讀取之后,還需要保證其他並發事務不能修改當前記錄,對讀取記錄加鎖。
其中,除了第一條語句,對讀取記錄加S鎖 (共享鎖)外,其他的操作,都加的是X鎖 (排它鎖)。
2.1 事務隔離級別
對鎖進行分析前必須要先了解事務隔離級別的關系
詳細見,MySQL四種事務隔離級別
隔離級別 | 臟讀(Dirty Read) | 不可重復讀(NonRepeatable Read) | 幻讀(Phantom Read) |
---|---|---|---|
未提交讀(Read uncommitted) | 可能 | 可能 | 可能 |
已提交讀(Read committed) | 不可能 | 可能 | 可能 |
可重復讀(Repeatable read) | 不可能 | 不可能 | 可能 |
可串行化(Serializable) | 不可能 | 不可能 | 不可能 |
- 未提交讀(Read Uncommitted):允許臟讀,也就是可能讀取到其他會話中未提交事務修改的數據
-
提交讀(Read Committed):只能讀取到已經提交的數據。Oracle等多數數據庫默認都是該級別 (不重復讀)
-
可重復讀(Repeated Read):可重復讀。在同一個事務內的查詢都是事務開始時刻一致的,InnoDB默認級別。在SQL標准中,該隔離級別消除了不可重復讀,但是還存在幻象讀
-
串行讀(Serializable):完全串行化的讀,每次讀都需要獲得表級共享鎖,讀寫相互都會阻塞
MySQL InnoDB默認使用的級別是可重復讀級別(Repeatable read),查找命令如下
mysql>select @@session.tx_isolation; +------------------------+ | @@session.tx_isolation | +------------------------+ | REPEATABLE-READ | +------------------------+ 1 row in set
- 臟讀:當一個事務進行的操作還未提交時,另外一個事務讀到了修改的數據,這就是臟讀,但是RR級別事務避免了臟讀。
- 不可重復讀:是指在一個事務內,多次讀同一數據。在這個事務還沒有結束時,另外一個事務也訪問該同一數據。
那么,在第一個事務中的兩次讀數據之間,由於第二個事務的修改,那么第一個事務兩次讀到的的數據可能是不一樣的。
這樣就發生了在一個事務內兩次讀到的數據是不一樣的,因此稱為是不可重復讀。但是,RR級別是不會出現不一樣的結果的,即使另一個事務提交了修改他也查不到變化。 - 幻讀:第一個事務對一個表中的數據進行了修改,這種修改涉及到表中的全部數據行。同時,第二個事務也修改這個表中的數據,
這種修改是向表中插入一行新數據。那么,以后就會發生操作第一個事務的用戶發現表中還有沒有修改的數據行,就好象發生了幻覺一樣。
2.2 sql語句加鎖分析
參考:MySQL加鎖分析
#SQL語句1 select * from table where id = 1; #SQL語句2 update set age = age + 1 where id = 1; #SQL語句3 update set age = age + 1 where id = 1 and nickname = 'hello';
首先我們可以確定的是語句1,他是不加鎖的,屬於快照讀。語句2和語句3要復雜些,我們慢慢來分析。
下面我們默認事務級別為可重復讀(Repeated Read),因為這是MySQL InnoDB默認級別。
語句2分析:
-
如果id是主鍵或者是索引的話,那么鎖定的行只有符合條件的那幾行。
-
如果id非索引,那么會鎖表。
語句3分析:
- id或者nickname只要有一個是索引或者是主鍵的話,那么鎖住的行都是符合條件的行。
但是要注意一個情況,如果你查看索引數據值存在大量重復的數據的話(重復的數要是where條件值),那么有可能條件是不會走索引,而是進行全表查詢,所以此時鎖住的也是全表。
因為索引掃描書超過30%時,會進行全表掃描。