MySQL鎖解決併發問題詳解
文章分為以下幾個要點
- 問題描述以及解決過程
- 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張優惠券。如下
insert into coupon_activity values (1,'000000',1000,1,0);
不考慮到併發的話,使用者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的時候統計出當前領取數,並與可領取數進行對比,虛擬碼如下
select * from coupon_activity where act_code = '000000';
insert into coupon_detail (coup_id,act_code,coup_code,coup_user_id) select (coup_id,act_code,coup_code,coup_user_id) from (select count(id) as num from coupon_detail where coup_user_id = 10 and act_code = '000000')temp where temp.num < #{coup_per_num}
上面這條複雜的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 事務隔離級別
對鎖進行分析前必須要先了解事務隔離級別的關係
隔離級別 | 髒讀(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語句加鎖分析
#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%時,會進行全表掃描。