概述:
鎖機制在程序中是最常用的機制之一,當一個程序需要多線程並行訪問同一資源時,為了避免一致性問題,通常采用鎖機制來處理。在數據庫的操作中也有相同的問題,當兩個線程同時對一條數據進行操作,為了保證數據的一致性,就需要數據庫的鎖機制。每種數據庫的鎖機制都自己的實現方式,mysql作為一款工作中經常遇到的數據庫,它的鎖機制在面試中也經常會被問到。所以本文針對mysql數據庫,對其鎖機制進行總結。
mysql的鎖可以分為服務層實現的鎖,例如Lock Tables、全局讀鎖、命名鎖、字符鎖,或者存儲引擎的鎖,例如行級鎖。InnoDB作為MySQL中最為常見的存儲引擎,本文默認MySQL選擇InnoDB作為存儲引擎,將MySQL的鎖和InnoDB實現的鎖同時進行討論。
鎖的分類按照特性有多種分類,常見的比如顯式鎖和隱式鎖;表鎖和行鎖;共享鎖和排他鎖;樂觀鎖和悲觀鎖等等,后續會在下方補充概念。
服務級別鎖:
表鎖
表鎖可以是顯式也可以是隱式的。顯示的鎖用Lock Table來創建,但要記得Lock Table之后進行操作,需要在操作結束后,使用UnLock來釋放鎖。Lock Tables有read和write兩種,Lock Tables......Read通常被稱為共享鎖或者讀鎖,讀鎖或者共享鎖,是互相不阻塞的,多個用戶可以同一時間使用共享鎖互相不阻塞。Lock Table......write通常被稱為排他鎖或者寫鎖,寫鎖或者排他鎖會阻塞其他的讀鎖或者寫鎖,確保在給定時間里,只有一個用戶執行寫入,防止其他用戶讀取正在寫入的同一資源。
為了進行測試,我們先創建兩張測試表,順便加幾條數據
CREATE TABLE `test_product` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `code` varchar(255) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `price` decimal(10,2) DEFAULT NULL, `quantity` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8; CREATE TABLE `test_user` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) DEFAULT NULL, `age` int(3) DEFAULT NULL, `gender` int(1) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8; INSERT INTO `test_user` (`id`, `name`, `age`, `gender`) VALUES ('1', '張三', '16', '1'); INSERT INTO `test_user` (`id`, `name`, `age`, `gender`) VALUES ('2', '李四', '18', '1'); INSERT INTO `test_product` (`id`, `code`, `name`, `price`, `quantity`) VALUES ('1', 'S001', '產品1號', '100.00', '200'); INSERT INTO `test_product` (`id`, `code`, `name`, `price`, `quantity`) VALUES ('2', 'S001', '產品2號', '200.00', '200'); INSERT INTO `locktest`.`test_product` (`code`, `name`, `price`, `quantity`) VALUES ('S003', '產品3號', '300.00', 300); INSERT INTO `locktest`.`test_product` (`code`, `name`, `price`, `quantity`) VALUES ('S004', '產品4號', '400.00', 400); INSERT INTO `locktest`.`test_product` (`code`, `name`, `price`, `quantity`) VALUES ('S005', '產品5號', '500.00', 500);
打開兩個客戶端連接A和B,在A中輸入
LOCK TABLES test_product READ;
在B中輸入
SELECT * FROM test_product
B能正常查詢並獲取到結果。Lock Tables....Read不會阻塞其他線程對表數據的讀取。
讓A繼續保留鎖,在B中輸入
update test_product set price=250 where id=2;
此時B的線程被阻塞,等待A釋放鎖。
釋放A持有的鎖,在A中輸入
UNLOCK TABLES;
此時B中顯示下圖,並且數據已經被變更。
Lock Tables....Read會阻塞其他線程對數據變更。
接下來再對Lock Table....write進行測試,在A線程下執行以下語句,用排它鎖鎖定test_product。
LOCK TABLES test_product WRITE;
在B中輸入以下語句,對test_product表進行查詢。
SELECT * FROM test_product;
發現B的查詢語句阻塞,等待A釋放鎖。再開啟一個新命令窗口C,輸入
update test_product set price=250 where id=2;
同樣被阻塞。在A中使用UNLOCK釋放鎖,B、C成功執行。Lock Tables....Write會阻塞其他線程對數據讀和寫。
假設在A中進行給test_product加讀鎖后,對test_product進行更新或者對test_user進行讀取更新會怎么樣呢。
LOCK TABLES test_product READ;
之后在A中進行test_product更新
update test_product set price=250 where id=2; [SQL]update test_product set price=250 where id=2; [Err] 1099 - Table 'test_product' was locked with a READ lock and can't be updated
然后在A中讀取test_user
[SQL]SELECT * from test_user [Err] 1100 - Table 'test_user' was not locked with LOCK TABLES
Lock Tables....Read不允許對表進行更新操作(新增、刪除也不行),並且不允許訪問未被鎖住的表。
對Lock Table....WRITE進行相同的實驗,代碼相似,就不再貼出。
Lock Tables....WRITE允許對被鎖住的表進行增刪改查,但不允許對其他表進行訪問。
總結上面的結論:
- Lock Tables....READ不會阻塞其他線程對表數據的讀取,會阻塞其他線程對數據變更
- Lock Tables....WRITE會阻塞其他線程對數據讀和寫
- Lock Tables....READ不允許對表進行更新操作(新增、刪除也不行),並且不允許訪問未被鎖住的表
- Lock Tables....WRITE允許對被鎖住的表進行增刪改查,但不允許對其他表進行訪問
lock tables主要性質如上所述,當我們要去查詢mysql是否存在lock tables鎖狀態可以用下面語句進行查詢。第二條可以直接看到被鎖的表。也可以通過show process來查看部分信息。
LOCK TABLES test_product READ,test_user WRITE; show status like "%lock%"; show OPEN TABLES where In_use > 0;
使用LOCK TABLES時候必須小心,《高性能MySQL》中有一段話:
LOCK TABLES和事務之間相互影響的話,情況會變得非常復雜,在某些MySQL版本中甚至會產生無法預料的結果。因此,本書建議,除了事務中禁用了AUTOCOMMIT,可以使用LOCK TABLES之外,其他任何時候都不要顯示地執行LOCK TABLES,不管使用什么存儲引擎。
所以在大部分時候,我們不需要使用到LOCK TABLE關鍵字。
全局讀鎖
全局鎖可以通過FLUSH TABLES WITH READ LOCK獲取單個全局讀鎖,與任務表鎖都沖突。解鎖的方式也是UNLOCK TABLES。同樣設置A、B兩個命令窗口,我們對全局鎖進行測試。
在A中獲取全局讀鎖
FLUSH TABLES WITH READ LOCK;
然后在A窗口依次做以下實驗
1 LOCK TABLES test_user READ; 2 3 LOCK TABLES test_user WRITE; 4 5 SELECT * from test_user; 6 7 update test_product set price=250 where id=1;
第1、5行能夠執行成功,第2、7行執行會失敗
在B中執行
1 FLUSH TABLES WITH READ LOCK; 2 3 LOCK TABLES test_user READ; 4 5 LOCK TABLES test_user WRITE; 6 7 SELECT * FROM test_product; 8 9 update test_product set price=250 where id=2;
B窗口中執行1、3、7成功。執行5、9失敗。
全局讀鎖其實就相當於用讀鎖同時鎖住所有表。如果當前線程擁有某個表的寫鎖,則獲取全局寫鎖的時候會報錯。如果其他線程擁有某張表的寫鎖,則全局讀鎖會阻塞等待其他表釋放寫鎖。
該命令是比較重量級的命令,會阻塞一切更新操作(表的增刪改和數據的增刪改),主要用於數據庫備份的時候獲取一致性數據。
命名鎖
命名鎖是一種表鎖,服務器創建或者刪除表的時候會創建一個命名鎖。如果一個線程LOCK TABLES,另一個線程對被鎖定的表進行重命名,查詢會被掛起,通過show open tables可以看到兩個名字(新名字和舊名字都被鎖住了)。
字符鎖
字符鎖是一種自定義鎖,通過SELECT GET_LOCK("xxx",60)來加鎖 ,通過release_lock()解鎖。假設A線程執行get_lock("xxx",60)后執行sql語句返回結果為1表示拿到鎖,B線程同樣通過get_lock("xxx",60)獲取相同的字符鎖,則B線程會處理阻塞等待的狀況,如果60秒內A線程沒有將鎖釋放,B線程獲取鎖超時就會返回0,表示未拿到鎖。使用get_lock()方法獲取鎖,如果線程A調用了兩次get_lock(),釋放鎖的時候也需要使用兩次release_lock()來進行解鎖。
InnoDB鎖:
InnoDB存儲引擎在也實現了自己的數據庫鎖。一般談到InnoDB鎖的時候,首先想到的都是行鎖,行鎖相比表鎖有一些優點,行鎖比表鎖有更小鎖粒度,可以更大的支持並發。但是加鎖動作也是需要額外開銷的,比如獲得鎖、檢查鎖、釋放鎖等操作都是需要耗費系統資源。如果系統在鎖操作上浪費了太多時間,系統的性能就會受到比較大的影響。
InnoDB實現的行鎖有共享鎖(S)和排它鎖(X)兩種
共享鎖:允許事務去讀一行,阻止其他事務對該數據進行修改
排它鎖:允許事務去讀取更新數據,阻止其他事務對數據進行查詢或者修改
行鎖雖然很贊,但是還有一個問題,如果一個事務對一張表的某條數據進行加鎖,這個時候如果有另外一個線程想要用LOCK TABLES進行鎖表,這時候數據庫要怎么知道哪張表的哪條數據被加了鎖,一張張表一條條數據去遍歷是不可行的。InnoDB考慮到這種情況,設計出另外一組鎖,意向共享鎖(IS)和意向排他鎖(IX)。
意向共享鎖:當一個事務要給一條數據加S鎖的時候,會先對數據所在的表先加上IS鎖,成功后才能加上S鎖
意向排它鎖:當一個事務要給一條數據加X鎖的時候,會先對數據所在的表先加上IX鎖,成功后才能加上X鎖
意向鎖之間兼容,不會阻塞。但是會跟S鎖和X鎖沖突,沖突的方式跟讀寫鎖相同。例如當一張表上已經有一個排它鎖(X鎖),此時如果另外一個線程要對該表加意向鎖,不管意向共享鎖還是意向排他鎖都不會成功。
線程 A | 線程 B |
BEGIN;
SELECT * FROM test_product for UPDATE; |
|
SELECT * FROM test_product LOCK IN SHARE MODE; 結果:線程阻塞
SELECT * FROM test_product for UPDATE; 結果:線程阻塞 |
|
COMMIT; | ![]() |
上面的例子中,用的兩個加鎖方式,一個是SELECT........FOR UPDATE,SELECT........LOCK IN SHARE MODE。SELECT FOR UPDATE能為數據添加排他鎖,LOCK IN SHARE MODE為數據添加共享鎖。這兩種鎖,在事務中生效,而當事務提交或者回滾的時候,會自動釋放鎖。遺憾的是,當我們在項目中遇到鎖等待的時候,並沒有辦法知道是哪個線程正在持有鎖,也很難確定是哪個事務導致問題。但是我們可以通過這幾個表來確認消息Information_schema.processList、Information_schema.innodb_lock_waits、Information_schema.innodb_trx、Information_schema.innodb_locks來獲取事務等待的狀況,根據片面的鎖等待狀況來獲取具體的數據庫信息。
隱式加鎖:SELECT FOR UPDATE和LOCK IN SHARE 這種通過編寫在mysql里面的方式對需要保護的數據進行加鎖的方式稱為是顯式加鎖。還有一種加鎖方式是隱式加鎖,除了把事務設置成串行時,會對SELECT到的所有數據加鎖外,SELECT不會對數據加鎖(依賴於MVCC)。當執行update、delete、insert的時候會對數據進行加排它鎖。
自增長鎖:mysql數據庫在很多時候都會設置為主鍵自增,如果這個時候使用表鎖,當事務比較大的時候,會對性能造成比較大的影響。mysql提供了inodb_atuoinc_lock_mode來處理自增長的安全問題。該參數可以設置為0(插入完成之后,即使事務沒結束也立即釋放鎖)、1(在判斷出自增長需要使用的數字后就立即釋放鎖,事務回滾也會造成主鍵不連續)、2(來一個記錄就分配一個值,不使用鎖,性能很好,但是可能導致主鍵不連續)。
外鍵鎖: 當插入和更新子表的時候,首先需要檢查父表中的記錄,並對附表加一條lock in share mode,而這可能會對兩張表的數據檢索造成阻塞。所以一般生產數據庫上不建議使用外鍵。
索引和鎖:InnoDB在給行添加鎖的時候,其實是通過索引來添加鎖,如果查詢並沒有用到索引,就會使用表鎖。做個測試
線程 A | 線程 B |
set autocommit=0; BEGIN;
|
|
set autocommit=0;
線程阻塞 |
|
COMMIT; | ![]()
|
如上所示,如果正常鎖行的話,兩條線程鎖住不同行,不應該有沖突。我們現在給price添加索引再試一次。
ALTER TABLE `test_product` ADD INDEX idx_price ( `price` );
線程 A | 線程 B |
set autocommit=0; BEGIN; |
|
set autocommit=0;
|
|
Select * from test_product where price= 300 for UPDATE; 阻塞 |
添加索引以后會發現,線程A、B查詢不同的行的時候,兩個線程並沒有相互阻塞。但是,即使InnoDB中已經使用了索引,仍然有可能鎖住一些不需要的數據。如果不能使用索引查找,InnoDB將會鎖住所有行。因為InnoDB中用索引來鎖行的方式比較復雜,其中牽涉到InnoDB的鎖算法和事務級別,這個后續會講到。
《高性能MySQL》中有一句話:"InnoDB在二級索引上使用共享鎖,但訪問主鍵索引需要排他鎖,這消除了覆蓋索引的可能性,並且使得SELECT FOR UPDATE 比Lock IN SHARE LOCK 或非鎖定查詢要慢很多"。除了上面那句話還有一句話有必要斟酌,"select for update,lock in share mode這兩個提示會導致某些優化器無法使用,比如覆蓋索引,這些鎖定經常會被濫用,很容易造成服務器的鎖爭用問題,實際上應該盡量避免使用這兩個提示,通常都有更好的方式可以實現同樣的目的。
鎖算法和隔離級別:
鎖算法:InnoDB的行鎖的算法為以下三種
Record Lock:單挑記錄上的鎖
Gap Lock:間隙鎖,鎖定一個范圍,但不包括記錄本身
Next-Key Lock:Record Lock+Gap Lock,鎖定一個范圍,並且鎖定記錄本身
InnoDB會根據不同的事務隔離級別來使用不同的算法。網上關於InnoDB不同的事務隔離級別下的鎖的觀點各不一致,有些甚至和MVCC混淆,這一塊有時間再進行整理。可以去官網上詳細了解一下,Mysql官網對InnoDB的事務鎖的介紹。
MVCC:多版本控制,InnoDB實現MVCC是通過在每行記錄后面保存兩個隱藏的列來實現,一個保存創建的事務版本號,一個保存的是刪除的事務版本號。MVCC只有在REPEATABLE READ 和 READ COMMITED兩個隔離級別下工作。另外兩個隔離級別與MVCC並不兼容,因為READ UNCOMMITED總是讀取最新數據,跟事務版本無關,而SERIALIZABLE會對讀取的所有行都進行加鎖。
樂觀鎖和悲觀鎖:
悲觀鎖:指悲觀的認為,需要訪問的數據隨時可能被其他人訪問或者修改。因此在訪問數據之前,對要訪問的數據加鎖,不允許其他其他人對數據進行訪問或者修改。上述講到的服務器鎖和InnoDB鎖都屬於悲觀鎖。
樂觀鎖:指樂觀的認為要訪問的數據不會被人修改。因此不對數據進行加鎖,如果操作的時候發現已經失敗了,則重新獲取數據進行更新(如CAS),或者直接返回操作失敗。
電商賣東西的時候,必須解決的是超賣的問題,超賣是指商品的數量比如只有5件,結果賣出去6件的情況。我們用代碼來演示一下怎么用樂觀鎖和悲觀鎖解決這個問題。假設test_prodcut表中,S001和S002的產品各有100件。
@Service public class ProductService implements IProductService { @Resource private ProductMapper productMapper; private static final String product_code = "S001"; private static final String product_code1 = "S002"; //樂觀鎖下單成功數 private final AtomicInteger optimisticSuccess = new AtomicInteger(0); //樂觀鎖下單失敗數 private final AtomicInteger optimisticFalse = new AtomicInteger(0); //悲觀鎖下單成功數 private final AtomicInteger pessimisticSuccess = new AtomicInteger(0); //悲觀鎖下單失敗數 private final AtomicInteger pessimisticFalse = new AtomicInteger(0); //樂觀鎖下單 @Override @Transactional(rollbackFor = Exception.class) public void orderProductOptimistic() throws TestException { int num = productMapper.queryProductNumByCode(product_code); if (num <= 0) { optimisticFalse.incrementAndGet(); return; } int result = productMapper.updateOrderQuantityOptimistic(product_code); if (result == 0) { optimisticFalse.incrementAndGet(); throw new TestException("商品已經賣完"); } optimisticSuccess.incrementAndGet(); } //獲取售賣記錄 @Override public String getStatistics() { return "optimisticSuccess:" + optimisticSuccess + ", optimisticFalse:" + optimisticFalse + ",pessimisticSuccess:" + pessimisticSuccess + ", pessimisticFalse:" + pessimisticFalse; } //悲觀鎖下單 @Override @Transactional(rollbackFor = Exception.class) public void orderProductPessimistic() { int num = productMapper.queryProductNumByCodeForUpdate(product_code1); if (num <= 0) { pessimisticFalse.incrementAndGet(); return; } productMapper.updateOrderQuantityPessimistic(product_code1); pessimisticSuccess.incrementAndGet(); } //獲取產品詳情 @Override @Transactional public ProductResutl getProductDetail() { Random random = new Random(); String code = random.nextInt() % 2 == 0 ? product_code : product_code1; ProductResutl productResutl = productMapper.selectProductDetail(code); return productResutl; } //清楚記錄 @Override public void clearStatistics() { optimisticSuccess.set(0); optimisticFalse.set(0); pessimisticSuccess.set(0); pessimisticFalse.set(0); } }
對應sql如下。
1 <update id="updateOrderQuantityPessimistic"> 2 update test_product set quantity=quantity-1 where code=#{productCode} 3 </update> 4 5 <update id="updateOrderQuantityOptimistic"> 6 update test_product set quantity=quantity-1 where code=#{productCode} and quantity>0; 7 </update> 8 9 <select id="queryProductNumByCode" resultType="java.lang.Integer"> 10 SELECT quantity From test_product WHERE code=#{productCode} 11 </select> 12 13 14 <select id="queryProductNumByCodeForUpdate" resultType="java.lang.Integer"> 15 SELECT quantity From test_product WHERE code=#{productCode} for update 16 </select> 17 18 <select id="selectProductDetail" resultType="com.chinaredstar.jc.crawler.biz.result.product.ProductResutl"> 19 SELECT 20 id as id, 21 code as code, 22 name as name, 23 price as price, 24 quantity as quantity 25 FROM test_product WHERE code=#{productCode} 26 </select>
測試工具使用JMeter,開啟200個線程,分別對通過樂觀鎖和悲觀鎖進行下單。
悲觀鎖下單結果:
樂觀鎖下單結果:
售賣情況如下:
結果顯示樂觀鎖和悲觀鎖都能成功的防止產品超賣,上述的數據比較粗糙,不能代表實際生產中的一些情況,但是在很多時候。使用樂觀鎖因為不需要對數據加鎖,防止鎖沖突,可能得到更好的性能。但是也不代表樂觀鎖比悲觀鎖更好,還是看具體的生產情況,來判斷需要的是樂觀鎖還是悲觀鎖。