一 引言--為什么mysql提供了鎖
最近看到了mysql有行鎖和表鎖兩個概念,越想越疑惑。為什么mysql要提供鎖機制,而且這種機制不是一個擺設,還有很多人在用。在現代數據庫里幾乎有事務機制,acid的機制應該能解決並發調度的問題了,為什么還要主動加鎖呢?
后來看到一篇文章,“防止更新丟失,並不能單靠數據庫事務控制器來解決,需要應用程序對要更新的數據加必要的鎖來解決”。瞬間,世界觀都崩塌了。非常不敢相信,於是自己寫了代碼檢驗一下。
數據庫表是這樣的。用count字段來做100次累加。

為了保證實驗的科學性,先確認了數據庫是InnoDB的,這樣才有事務機制;也確認了隔離性級別

定義一個任務,讀count值--程序count++--寫數據庫
public class LostUpdate implements Runnable{ private CountDownLatch countDown; public LostUpdate(CountDownLatch countDown){ this.countDown = countDown; } @Override public void run() { Connection conn=null; try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "123"); } catch (Exception e) { e.printStackTrace(); return; } try { conn.setAutoCommit(false); //不加鎖的情況 PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1"); //加鎖的情況 //PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1 for update"); ResultSet rs=ps.executeQuery(); int count = 0; while(rs.next()){ count= rs.getInt("count"); } count++; ps =conn.prepareStatement("update LostUpdate set count=? where id =1"); ps.setInt(1, count); ps.executeUpdate(); conn.commit(); } catch (Exception e) { try { conn.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } e.printStackTrace(); } //表示一次任務完成 countDown.countDown(); } }
主線程下創建子線程,模擬多線程環境
public class TestLock { public static void main(String[] args) throws InterruptedException { //創建線程池,里面有10個線程,共執行100次+1操作 final int THREAD_COUNT=10; final int RUN_TIME=100; ExecutorService threadPool=Executors.newFixedThreadPool(THREAD_COUNT); //用CountDownLatch保證主線程等待所有任務完成 CountDownLatch count=new CountDownLatch(RUN_TIME); for(int i=0;i<RUN_TIME;i++) threadPool.execute(new LostUpdate(count)); threadPool.shutdown(); count.await(); //提示所有任務執行完 System.out.println("finish"); } }
運行結果是:

大概解釋一下程序,就是創建了一個線程池,里面10個線程,執行100次任務。每個任務就是 讀count值--程序count++--寫數據庫,經典的銀行存款(丟失修改)問題。事實勝於雄辯,結論就是上面的橙色字,解決丟失修改不能靠事務,要加必要的鎖,所以數據庫提供的鎖不是個擺設。
二 數據庫事務機制
為了找到問題的根源,為了拯救我崩潰的世界觀,我又去回顧了數據庫事務的知識。借鑒 這篇
數據庫的acid屬性
- 原性性(Actomicity):事務是一個原子操作單元,其對數據的修改,要么全都執行,要么全都不執行。
- 一致性(Consistent):在事務開始和完成時,數據都必須保持一致狀態。這意味着所有相關的數據規則都必須應用於事務的修改,以操持完整性;事務結束時,所有的內部數據結構(如B樹索引或雙向鏈表)也都必須是正確的。
- 隔離性(Isolation):數據庫系統提供一定的隔離機制,保證事務在不受外部並發操作影響的“獨立”環境執行。這意味着事務處理過程中的中間狀態對外部是不可見的,反之亦然。
- 持久性(Durable):事務完成之后,它對於數據的修改是永久性的,即使出現系統故障也能夠保持。
說好的一致性呢,童話里都是騙人的!!
事務並發調度的問題
- 臟讀(dirty read):A事務讀取B事務尚未提交的更改數據,並在這個數據基礎上操作。如果B事務回滾,那么A事務讀到的數據根本不是合法的,稱為臟讀。在oracle中,由於有version控制,不會出現臟讀。
- 不可重復讀(unrepeatable read):A事務讀取了B事務已經提交的更改(或刪除)數據。比如A事務第一次讀取數據,然后B事務更改該數據並提交,A事務再次讀取數據,兩次讀取的數據不一樣。
- 幻讀(phantom read):A事務讀取了B事務已經提交的新增數據。注意和不可重復讀的區別,這里是新增,不可重復讀是更改(或刪除)。這兩種情況對策是不一樣的,對於不可重復讀,只需要采取行級鎖防止該記錄數據被更改或刪除,然而對於幻讀必須加表級鎖,防止在這個表中新增一條數據。
- 第一類丟失更新:A事務撤銷時,把已提交的B事務的數據覆蓋掉。
- 第二類丟失更新:A事務提交時,把已提交的B事務的數據覆蓋掉。
三級封鎖協議
- 一級封鎖協議:事務T中如果對數據R有寫操作,必須在這個事務中對R的第一次讀操作前對它加X鎖,直到事務結束才釋放。事務結束包括正常結束(COMMIT)和非正常結束(ROLLBACK)。
- 二級封鎖協議:一級封鎖協議加上事務T在讀取數據R之前必須先對其加S鎖,讀完后方可釋放S鎖。
- 三級封鎖協議 :一級封鎖協議加上事務T在讀取數據R之前必須先對其加S鎖,直到事務結束才釋放。
可見,三級鎖操作一個比一個厲害(滿足高級鎖則一定滿足低級鎖)。但有個非常致命的地方,一級鎖協議就要在第一次讀加x鎖,直到事務結束。幾乎就要在整個事務加寫鎖了,效率非常低。三級封鎖協議只是一個理論上的東西,實際數據庫常用另一套方法來解決事務並發問題。
隔離性級別
mysql用意向鎖(另一種機制)來解決事務並發問題,為了區別封鎖協議,弄了一個新概念隔離性級別:包括Read Uncommitted、Read Committed、Repeatable Read、Serializable,見這篇。mysql 一般默認Repeatable Read。

終於發現自己為什么會誤會事務能解決丟失修改了。至於為什么隔離性級別不解決丟失修改,我猜是有更好的解決方案吧。
總結一下,repeatable read能解決臟讀和不可重復讀,但不嗯呢該解決丟失修改。
三 mysql的行鎖和表鎖
說了那么久,終於入正題了,先來說說什么是行鎖和表鎖。
- 表級鎖:每次操作鎖住整張表。開銷小,加鎖快;不會出現死鎖;鎖定粒度大,發生鎖沖突的概率最高,並發度最低;
- 行級鎖:每次操作鎖住一行數據。開銷大,加鎖慢;會出現死鎖;鎖定粒度最小,發生鎖沖突的概率最低,並發度也最高;
- 頁面鎖:開銷和加鎖時間界於表鎖和行鎖之間;會出現死鎖;鎖定粒度界於表鎖和行鎖之間,並發度一般。沒弄懂,有空再看。?
1 MyISAM的鎖
稍微提一下MyISAM,只說和InnoDB不同的。
a. MyISAM只有表鎖,鎖又分為讀鎖和寫鎖。

b. 沒有事務,不用考慮並發問題,世界和平~
c. 由於鎖的粒度太大,所以當該表寫並發量較高時,要等待的查詢就會很多了。優化見 這里。
2 InnoDB的行鎖和表鎖
沒有特定的語法。mysql的行鎖是通過索引體現的,參考。
如果where條件中只用到索引項,則加的是行鎖;否則加的是表鎖。比如說主鍵索引,唯一索引和聚簇索引等。如果sql的where是全表掃描的,想加行鎖也愛莫能助。
行鎖和表鎖對我們編程有什么影響,要在where中盡量只用索引項,否則就會觸發表鎖。另一個可能是,我們發瘋了地想優化查詢,但where子句中就是有非索引項,於是我們自己寫連接?
行鎖和表鎖各適合怎么樣的應用,待求證?。
3 讀鎖和寫鎖
InnoDB用意向鎖?實現隔離性級別,原理未名,貼張圖:

回想鎖協議,對什么操作加什么鎖是一個問題,加鎖加到什么時候有是一個問題。鎖協議里常常會看到“加鎖直到事務結束”的煩心字樣。而在InnoDB中,select,insert,update,delete等語句執行時都會自動加解鎖。select的鎖一般執行完就釋放了,修改操作的X鎖會持有到事務結束,效率高很多。至於詳細的加鎖原理,見這里,搜“InnoDB存儲引擎中不同SQL在不同隔離級別下鎖比較”
mysql也給用戶提供了加鎖的機會,只要在sql后加LOCK IN SHARE MODE 或FOR UPDATE
共享鎖(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
排他鎖(X):SELECT * FROM table_name WHERE ... FOR UPDATE
值得注意的是,自己加的鎖沒有釋放鎖的語句,所以鎖會持有到事務結束。
mysql 還提供了LOCK TABLES,UNLOCK TABLES,用於加表鎖,怎么用還不太清楚?
4 考察加鎖的情況
加了讀鎖還是寫鎖,加了行鎖還是表鎖,說什么時候釋放,可以從原理上分析。但剛開始時我不太懂原理,於是又寫了個程序。
public class ForUpdate1 implements Runnable{ private CountDownLatch countDown; public ForUpdate1(CountDownLatch countDown){ this.countDown = countDown; } @Override public void run() { Connection conn=null; try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "123"); } catch (Exception e) { e.printStackTrace(); return; } try { conn.setAutoCommit(false); /*PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1 for update"); ps.executeQuery();*/ PreparedStatement ps =conn.prepareStatement("update LostUpdate set count =1 where id =1"); ps.executeUpdate(); Thread.sleep(10000); conn.commit(); System.out.println("test 1 finish"); countDown.countDown(); } catch (Exception e) { try { conn.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } e.printStackTrace(); } } }
public class ForUpdate2 implements Runnable{ private CountDownLatch countDown; public ForUpdate2(CountDownLatch countDown){ this.countDown = countDown; } @Override public void run() { Connection conn=null; try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "123"); } catch (Exception e) { e.printStackTrace(); return; } try { Thread.sleep(2000); conn.setAutoCommit(false); PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1 for update"); ps.executeQuery(); /*PreparedStatement ps =conn.prepareStatement("update LostUpdate set count =1 where id =1"); ps.executeUpdate();*/ conn.commit(); System.out.println("test 2 finish"); countDown.countDown(); } catch (Exception e) { try { conn.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } e.printStackTrace(); } } }
public class TestForUpdate { public static void main(String[] args) throws InterruptedException { final int THREAD_COUNT=10; ExecutorService threadPool=Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch count=new CountDownLatch(2); threadPool.execute(new ForUpdate1(count)); threadPool.execute(new ForUpdate2(count)); threadPool.shutdown(); count.await(); System.out.println("finish"); } }
只有兩個線程,ForUpdate1先執行sql語句之后等10s,ForUpdate2先等待2s再執行sql語句。所以如果ForUpdate1持有鎖,而且ForUpdate2等待,輸出就應該是test 1 finish->test 2 finish->finish;否則就是test 2 finish->test 1 finish->finish。
這個程序改一下能測試上面說的理論:
- repeatable read能解決臟讀和不可重復讀
- 比如行鎖真的只鎖住一行
- s,x,is和ix的關系
判斷加鎖情況,mysql應該有工具,但沒找到?
可以通過檢查InnoDB_row_lock狀態變量來分析系統上的行鎖的爭奪情況:
mysql> show status like 'innodb_row_lock%';

如果發現鎖爭用比較嚴重,如InnoDB_row_lock_waits和InnoDB_row_lock_time_avg的值比較高,還可以通過設置InnoDB Monitors來進一步觀察發生鎖沖突的表、數據行等,並分析鎖爭用的原因。不明覺厲?,看這篇
總結一下這一章,mysql提供了行鎖和表鎖,我們寫語句時應該盡量啟動行鎖,以提高效率;另一方面,也說了一下讀鎖和寫鎖的原理。好了武器(原理)我們都懂了,那就看怎么優化了。
四 解決丟失修改--樂觀鎖和悲觀鎖
首先為什么要加鎖,加鎖就是為了解決丟失修改(也不知道這么說對不對)。如果一個事務中只有一句sql,數據庫是可以保證它是並發安全的。丟失修改的特征就是在一個事務中先讀P數據,再寫P數據,注意是同一個數據(也不知道這么說對不對)。只是自己推理了一下,沒有太強的理據。所謂丟失修改,一般是A事務有兩個操作,后一個操作依賴於前一個操作,之后后一個操作覆蓋了B事務的寫操作,可以表示為這樣。

pro1可能是Read(P),Write(P),Read(Q),Write(Q),其中P=2Q,數據庫中的冗余導致的關聯關系是很常見的。
- pro1=Read(P),就是我們結論中的情況;
- pro1=Write(P),pro1處會對P加IX鎖?,IX鎖會直至事務結束,不會丟失修改;
- pro1=Read(Q)或Write(Q),雖然語法上回發生這種情況,但既然數據時關聯的,那在兩個事務中都應該同時操作P,Q。這樣就規范到第一種情況。
綜上,如果一個事務先讀后寫同一份數據,就可能發生丟失修改,要做一些處理。可以用下面的樂觀鎖和悲觀鎖解決。
悲觀鎖和樂觀鎖的概念:
悲觀鎖(Pessimistic Concurrency Control,PCC):假定會發生並發沖突,屏蔽一切可能違反數據完整性的操作。至於怎么加鎖,加鎖的范圍也沒講。
樂觀鎖(Optimistic Concurrency Control,OCC):假設不會發生並發沖突,只在提交操作時檢查是否違反數據完整性。也沒具體指定怎么檢查。
就是這么概念,什么都不說清楚。畢竟樂觀鎖和悲觀鎖也不僅僅能用在數據庫中,也能用在線程中。
悲觀的缺陷是不論是頁鎖還是行鎖,加鎖的時間可能會很長,這樣可能會長時間的限制其他用戶的訪問,也就是說悲觀鎖的並發訪問性不好。
樂觀鎖不能解決臟讀,加鎖的時間要比悲觀鎖短(只是在執行sql時加了基本的鎖保證隔離性級別),樂觀鎖可以用較大的鎖粒度獲得較好的並發訪問性能。但是如果第二個用戶恰好在第一個用戶提交更改之前讀取了該對象,那么當他完成了自己的更改進行提交時,數據庫就會發現該對象已經變化了,這樣,第二個用戶不得不重新讀取該對象並作出更改。
可見,樂觀鎖更適合解決沖突概率極小的情況;而悲觀鎖則適合解決並發競爭激烈的情況,盡量用行鎖,縮小加鎖粒度,以提高並發處理能力,即便加行鎖的時間比加表鎖的要長。
悲觀鎖的例子
並沒有人說悲觀鎖要怎么加鎖,加鎖的范圍如何。這里僅僅提供一種解決丟失修改的悲觀鎖例子。
丟失修改我們用第一章講到的累積100次的例子。綜合前面講到的結論,丟失修改的特征就是在一個事務中先讀P數據,再寫P數據。而且一級鎖協議能解決丟失修改,所以如果事務A 中寫P,我們只要在A中第一次讀P前加X鎖。做法在第一章程序中有:
//把 PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1"); //換成 PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1 for update");
樂觀鎖的例子
樂觀鎖也沒有指定怎么檢測並發沖突,下面是常見的兩種做法(參考):
- 使用數據版本(Version)。在P數據上(通常每一行)加version字段(int),A事務在讀數據P 時同時讀出版本號,在修改數據前檢測最新版本號是否等於先前取出的版本號,如果是,則修改,同時把版本號+1;否則要么回滾,要么重新執行事務。另外,數據P的所有修改操作都要把版本號+1。有一個非常重要的點,版本號是用來查看被讀的變量有無變化,而不是針對被寫的變量,作用是防止被依賴的變量有修改。
- 使用時間戳(TimeStamp)。做法類似於1中。
下面寫兩個例子,背景還是一開始的累積100次的丟失修改問題,都是用version解決的。
1 當發生沖突時回滾並拋異常
任務類
public class LostUpdateOccDiscard implements Runnable{ private CountDownLatch countDown; public LostUpdateOccDiscard(CountDownLatch countDown){ this.countDown = countDown; } @Override public void run() { Connection conn=null; try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "123"); } catch (Exception e) { e.printStackTrace(); return; } try { conn.setAutoCommit(false); //讀的時候一並讀出version PreparedStatement ps =conn.prepareStatement("select * from LostUpdate where id =1"); ResultSet rs=ps.executeQuery(); int count = 0; int version = 0; while(rs.next()){ count= rs.getInt("count"); version= rs.getInt("version"); } count++; //更新操作,用cas原子操作來更新 ps =conn.prepareStatement("update LostUpdate set count=?, version=version+1 where id =1 and version=?"); ps.setInt(1, count); ps.setInt(2, version); int result = ps.executeUpdate(); //檢查有無因沖突導致執行失敗 //成功,則commit,完成任務 if(result>0) { conn.commit(); } //失敗,回滾,拋異常提醒調用者出現沖突。 else{ conn.rollback(); throw new Exception("更新count出現沖突"); } } catch (SQLException e) { try { conn.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } e.printStackTrace(); } catch (Exception e) { System.out.println(e.getMessage()); } //表示一次任務完成 countDown.countDown(); } }
主線程,和前面差不多,創建10個線程,執行100個任務。
public class TestLockOcc { public static void main(String[] args) throws InterruptedException { //創建線程池,里面有10個線程,共執行100次+1操作 final int THREAD_COUNT=10; final int RUN_TIME=100; ExecutorService threadPool=Executors.newFixedThreadPool(THREAD_COUNT); //用CountDownLatch保證主線程等待所有任務完成 CountDownLatch count=new CountDownLatch(RUN_TIME); for(int i=0;i<RUN_TIME;i++) threadPool.execute(new LostUpdateOccDiscard(count)); threadPool.shutdown(); count.await(); //提示所有任務執行完 System.out.println("finish"); } }
輸出結果:在console里出了一堆異常,看數據庫,大概累積了10-12次
不要懷疑,程序沒有問題。
a. 對着上面說的version方法的原理,程序也比較好懂。
b. 更新時要用cas(compare and set)的原子操作,一步搞定。而不是先讀一次version,比較完再執行依據update。想想也知道后者在多線程有問題。
至於為什么只累積了10-12次,原因是這個累加的並發量是10,就是有10個線程在爭奪着修改權。九死一生啊,1個線程commit了,就意味着9個線程要rollback拋異常。
2 當發生沖突時重試,有時我們我們不希望程序里那么多異常
任務類
public class LostUpdateOcc implements Runnable{ private CountDownLatch countDown; public LostUpdateOcc(CountDownLatch countDown){ this.countDown = countDown; } @Override public void run() { Connection conn=null; try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "123"); } catch (Exception e) { e.printStackTrace(); return; } try { int try_times=100; int count; int version; PreparedStatement ps; ResultSet rs; //把循環條件放在里面if里 while(try_times>0){ //開始事務 try_times--; conn.setAutoCommit(false); //讀操作 ps=conn.prepareStatement("select * from LostUpdate where id =1"); rs=ps.executeQuery(); //判斷事務執行的條件,首先是能執行,其次是需要執行 if(rs.next()){ count= rs.getInt("count"); version= rs.getInt("version"); count++; //更新操作,用cas原子操作來更新 ps =conn.prepareStatement("update LostUpdate set count=?, version=version+1 where id =1 and version=?"); ps.setInt(1, count); ps.setInt(2, version); int result = ps.executeUpdate(); //每次執行完更新操作,檢測一次沖突 //成功,則繼續事務 //失敗,回滾,睡100ms,避開競爭。結束這次循環,開啟新事務。 if(result==0) { conn.rollback(); Thread.sleep(100); continue; } //事務一路順風,沒遇到沖突,事務提交,跳出while conn.commit(); break; } //作為while條件不成立時的處理,比如該行數據被刪除。 else{ conn.rollback(); break; } } if(try_times<=0) throw new Exception("沖突重試的此時過多,事務失敗"); System.out.println(try_times); } catch (SQLException e) { try { conn.rollback(); } catch (SQLException e1) { e1.printStackTrace(); } e.printStackTrace(); }catch (Exception e) { System.out.println(e.getMessage()); } //表示一次任務完成 countDown.countDown(); } }
主線程,和前面差不多,創建10個線程,執行100個任務。
public class TestLockOcc { public static void main(String[] args) throws InterruptedException { //創建線程池,里面有10個線程,共執行100次+1操作 final int THREAD_COUNT=10; final int RUN_TIME=100; ExecutorService threadPool=Executors.newFixedThreadPool(THREAD_COUNT); //用CountDownLatch保證主線程等待所有任務完成 CountDownLatch count=new CountDownLatch(RUN_TIME); for(int i=0;i<RUN_TIME;i++) threadPool.execute(new LostUpdateOcc(count)); threadPool.shutdown(); count.await(); //提示所有任務執行完 System.out.println("finish"); } }
任務類里就有比較多要注意的
a. 為了不斷的重試,用了一個while。因為while的終止條件一般要讀了數據后才知道,所以while只放了try_times,把結束條件放在了里面的if。
b. 在while里的每一次循環就重新起一個事務。因為更新失敗我們要回滾的。下一次要重起一個。
c. 這里的事務執行條件,能執行且需要執行。比如id=1的記錄被刪掉了,那就不能執行了;需要執行,比如程序為了把商品記錄status由未上架改為已上架,但發現已經被改了,那就不需要執行。可想而知,在多線程條件每次都要判斷的。
d. try_times這個東西還是設置一下。至於設多少,要看並發量。
e. 每次更新,都要檢測一次沖突
f. 沖突了,要睡一陣子再重試,避開沖突。怎么設置這個值,我突然想起計網的擁塞控制,說笑的~
順手做了個小實驗,還是執行100次,沖突睡眠100ms,

總結一下:
樂觀鎖更適合並發競爭少的情況,最好隔那么3-5分鍾才有一次沖突。當並發量為10時就能明顯感覺樂觀鎖更慢;
上面只是一讀一寫。考慮如果一個事務中有3個寫,如果每次寫都是九死一生,事務提交比小蝌蚪找媽媽還難,這時就更要考慮是不是要用樂觀鎖了。
但是,當分布式數據庫規模大到一定程度后,又另說了。基於悲觀鎖的分布式鎖在集群大到一定程度后(從幾百台擴展到幾千台時),性能開銷就打得無法接受。所以目前的趨勢是大規模的分布式數據庫更傾向於用樂觀鎖來達成external consistency。
如果對樂觀鎖和悲觀鎖的選擇還不清楚,看這篇
五 待更
mvcc:http://blog.csdn.net/chen77716/article/details/6742128
意向鎖,間隙鎖,加鎖的查看
參考:
數據庫鎖協議等原理:http://blog.csdn.net/gklifg/article/details/38752691
InnoDB的幾種鎖:http://www.cnblogs.com/chenqionghe/p/4845693.html
InnoDB的幾種鎖:http://blog.csdn.net/xifeijian/article/details/20313977
