在並行系統中併發問題永遠不可忽視。儘管PHP語言原生沒有提供多線程機制,那並不意味着全部的操做都是線程安全的。尤爲是在操做諸如訂單、支付等業務系統中,更須要注意操做數據庫的併發問題。php
接下來我經過一個案例分析一下PHP操做數據庫時併發問題的處理問題。html
首先,咱們有這樣一張數據表:java
1 mysql> select * from counter; 2 +----+-----+ 3 | id | num | 4 +----+-----+ 5 | 1 | 0 | 6 +----+-----+ 7 1 row in set (0.00 sec)
這段代碼模擬了一次業務操做:mysql
1 <?php 2 function dummy_business() { 3 $conn = mysqli_connect('127.0.0.1', 'public', 'public') or die(mysqli_error()); 4 mysqli_select_db($conn, 'test'); 5 for ($i = 0; $i < 10000; $i++) { 6 mysqli_query($conn, 'UPDATE counter SET num = num + 1 WHERE id = 1'); 7 } 8 mysqli_close($conn); 9 } 10 11 for ($i = 0; $i < 10; $i++) { 12 $pid = pcntl_fork(); 13 14 if($pid == -1) { 15 die('can not fork.'); 16 } elseif (!$pid) { 17 dummy_business(); 18 echo 'quit'.$i.PHP_EOL; 19 break; 20 } 21 } 22 ?>
上面的代碼模擬了10個用戶同時併發執行一項業務的狀況,每次業務操做都會使得num的值增長1,每一個用戶都會執行10000次操做,最終num的值應當是100000。程序員
運行這段代碼,num的值和咱們預期的值是同樣的:sql
1 mysql> select * from counter; 2 +----+--------+ 3 | id | num | 4 +----+--------+ 5 | 1 | 100000 | 6 +----+--------+ 7 1 row in set (0.00 sec)
這裏不會出現問題,是由於單條UPDATE語句操做是原子的,不管怎麼執行,num的值最終都會是100000。數據庫
然而不少狀況下,咱們業務過程當中執行的邏輯,一般是先查詢再執行,並不像上面的自增那樣簡單:後端
1 <?php 2 function dummy_business() { 3 $conn = mysqli_connect('127.0.0.1', 'public', 'public') or die(mysqli_error()); 4 mysqli_select_db($conn, 'test'); 5 for ($i = 0; $i < 10000; $i++) { 6 $rs = mysqli_query($conn, 'SELECT num FROM counter WHERE id = 1'); 7 mysqli_free_result($rs); 8 $row = mysqli_fetch_array($rs); 9 $num = $row[0]; 10 mysqli_query($conn, 'UPDATE counter SET num = '.$num.' + 1 WHERE id = 1'); 11 } 12 mysqli_close($conn); 13 } 14 15 for ($i = 0; $i < 10; $i++) { 16 $pid = pcntl_fork(); 17 18 if($pid == -1) { 19 die('can not fork.'); 20 } elseif (!$pid) { 21 dummy_business(); 22 echo 'quit'.$i.PHP_EOL; 23 break; 24 } 25 } 26 ?>
改過的腳本,將原來的原子操做UPDATE換成了先查詢再更新,再次運行咱們發現,因爲併發的緣故程序並無按咱們指望的執行:安全
1 mysql> select * from counter; 2 +----+------+ 3 | id | num | 4 +----+------+ 5 | 1 | 21495| 6 +----+------+ 7 1 row in set (0.00 sec)
程序員特別容易犯的錯誤是,認爲這是沒開啓事務引發的。如今咱們給它加上事務:多線程
1 <?php 2 function dummy_business() { 3 $conn = mysqli_connect('127.0.0.1', 'public', 'public') or die(mysqli_error()); 4 mysqli_select_db($conn, 'test'); 5 for ($i = 0; $i < 10000; $i++) { 6 mysqli_query($conn, 'BEGIN'); 7 $rs = mysqli_query($conn, 'SELECT num FROM counter WHERE id = 1'); 8 mysqli_free_result($rs); 9 $row = mysqli_fetch_array($rs); 10 $num = $row[0]; 11 mysqli_query($conn, 'UPDATE counter SET num = '.$num.' + 1 WHERE id = 1'); 12 if(mysqli_errno($conn)) { 13 mysqli_query($conn, 'ROLLBACK'); 14 } else { 15 mysqli_query($conn, 'COMMIT'); 16 } 17 } 18 mysqli_close($conn); 19 } 20 21 for ($i = 0; $i < 10; $i++) { 22 $pid = pcntl_fork(); 23 24 if($pid == -1) { 25 die('can not fork.'); 26 } elseif (!$pid) { 27 dummy_business(); 28 echo 'quit'.$i.PHP_EOL; 29 break; 30 } 31 } 32 ?>
依然沒能解決問題:
1 mysql> select * from counter; 2 +----+------+ 3 | id | num | 4 +----+------+ 5 | 1 | 16328| 6 +----+------+ 7 1 row in set (0.00 sec)
請注意,數據庫事務依照不一樣的事務隔離級別來保證事務的ACID特性,也就是說事務不是一開啓就能解決全部併發問題。一般狀況下,這裏的併發操做可能帶來四種問題:
一般數據庫有四種不一樣的事務隔離級別:
隔離級別 | 髒讀 | 不可重複讀 | 幻讀 |
Read uncommitted | √ | √ | √ |
Read committed | × | √ | √ |
Repeatable read | × | × | √ |
Serializable | × | × | × |
大多數數據庫的默認的事務隔離級別是提交讀(Read committed),而MySQL的事務隔離級別是重複讀(Repeatable read)。對於丟失更新,只有在序列化(Serializable)級別纔可獲得完全解決。不過對於高性能系統而言,使用序列化級別的事務隔離,可能引發死鎖或者性能的急劇降低。所以使用悲觀鎖和樂觀鎖十分必要。
併發系統中,悲觀鎖(Pessimistic Locking)和樂觀鎖(Optimistic Locking)是兩種經常使用的鎖:
上面的例子,咱們用悲觀鎖來實現:
1 <?php 2 function dummy_business() { 3 $conn = mysqli_connect('127.0.0.1', 'public', 'public') or die(mysqli_error()); 4 mysqli_select_db($conn, 'test'); 5 for ($i = 0; $i < 10000; $i++) { 6 mysqli_query($conn, 'BEGIN'); 7 $rs = mysqli_query($conn, 'SELECT num FROM counter WHERE id = 1 FOR UPDATE'); 8 if($rs == false || mysqli_errno($conn)) { 9 // 回滾事務 10 mysqli_query($conn, 'ROLLBACK'); 11 // 從新執行本次操做 12 $i--; 13 continue; 14 } 15 mysqli_free_result($rs); 16 $row = mysqli_fetch_array($rs); 17 $num = $row[0]; 18 mysqli_query($conn, 'UPDATE counter SET num = '.$num.' + 1 WHERE id = 1'); 19 if(mysqli_errno($conn)) { 20 mysqli_query($conn, 'ROLLBACK'); 21 } else { 22 mysqli_query($conn, 'COMMIT'); 23 } 24 } 25 mysqli_close($conn); 26 } 27 28 for ($i = 0; $i < 10; $i++) { 29 $pid = pcntl_fork(); 30 31 if($pid == -1) { 32 die('can not fork.'); 33 } elseif (!$pid) { 34 dummy_business(); 35 echo 'quit'.$i.PHP_EOL; 36 break; 37 } 38 } 39 ?>
能夠看到,此次業務以指望的方式正確執行了:
1 mysql> select * from counter; 2 +----+--------+ 3 | id | num | 4 +----+--------+ 5 | 1 | 100000 | 6 +----+--------+ 7 1 row in set (0.00 sec)
因爲悲觀鎖在開始讀取時即開始鎖定,所以在併發訪問較大的狀況下性能會變差。對MySQL Inodb來講,經過指定明確主鍵方式查找數據會單行鎖定,而查詢範圍操做或者非主鍵操做將會鎖表。
接下來,咱們看一下如何使用樂觀鎖解決這個問題,首先咱們爲counter表增長一列字段:
1 mysql> select * from counter; 2 +----+------+---------+ 3 | id | num | version | 4 +----+------+---------+ 5 | 1 | 1000 | 1000 | 6 +----+------+---------+ 7 1 row in set (0.01 sec)
實現方式以下:
1 <?php 2 function dummy_business() { 3 $conn = mysqli_connect('127.0.0.1', 'public', 'public') or die(mysqli_error()); 4 mysqli_select_db($conn, 'test'); 5 for ($i = 0; $i < 10000; $i++) { 6 mysqli_query($conn, 'BEGIN'); 7 $rs = mysqli_query($conn, 'SELECT num, version FROM counter WHERE id = 1'); 8 mysqli_free_result($rs); 9 $row = mysqli_fetch_array($rs); 10 $num = $row[0]; 11 $version = $row[1]; 12 mysqli_query($conn, 'UPDATE counter SET num = '.$num.' + 1, version = version + 1 WHERE id = 1 AND version = '.$version); 13 $affectRow = mysqli_affected_rows($conn); 14 if($affectRow == 0 || mysqli_errno($conn)) { 15 // 回滾事務從新提交 16 mysqli_query($conn, 'ROLLBACK'); 17 $i--; 18 continue; 19 } else { 20 mysqli_query($conn, 'COMMIT'); 21 } 22 } 23 mysqli_close($conn); 24 } 25 26 for ($i = 0; $i < 10; $i++) { 27 $pid = pcntl_fork(); 28 29 if($pid == -1) { 30 die('can not fork.'); 31 } elseif (!$pid) { 32 dummy_business(); 33 echo 'quit'.$i.PHP_EOL; 34 break; 35 } 36 } 37 ?>
此次,咱們也獲得了指望的結果:
1 mysql> select * from counter; 2 +----+--------+---------+ 3 | id | num | version | 4 +----+--------+---------+ 5 | 1 | 100000 | 100000 | 6 +----+--------+---------+ 7 1 row in set (0.01 sec)
因爲樂觀鎖最終執行的方式至關於原子化UPDATE,所以在性能上要比悲觀鎖好不少。
在使用Doctrine ORM框架的環境中,Doctrine原生提供了對悲觀鎖和樂觀鎖的支持。具體的使用方式請參考手冊:
http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/transactions-and-concurrency.html#locking-support
Hibernate框架中一樣提供了對兩種鎖的支持,在此再也不贅述了。
在高性能系統中處理併發問題,受限於後端數據庫,不管何種方式加鎖性能都沒法高效處理如電商秒殺搶購量級的業務。使用NoSQL數據庫、消息隊列等方式才能更有效地完成業務的處理。
參考文章