標籤:blog http io ar os 使用 sp java for
原載於我的部落格 http://starlight36.com/post/php-db-concurrency
在並行系統中並發問題永遠不可忽視。儘管PHP語言原生沒有提供多線程機制,那並不意味著所有的操作都是安全執行緒的。尤其是在操作諸如訂單、支付等業務系統中,更需要注意操作資料庫的並發問題。 接下來我通過一個案例分析一下PHP操作資料庫時並發問題的處理問題。
首先,我們有這樣一張資料表:
mysql> select * from counter;+----+-----+| id | num |+----+-----+| 1 | 0 |+----+-----+1 row in set (0.00 sec)
這段代碼類比了一次業務操作:
<?phpfunction dummy_business() {$conn = mysqli_connect(‘127.0.0.1‘, ‘public‘, ‘public‘) or die(mysqli_error());mysqli_select_db($conn, ‘test‘);for ($i = 0; $i < 10000; $i++) {mysqli_query($conn, ‘UPDATE counter SET num = num + 1 WHERE id = 1‘);}mysqli_close($conn);}for ($i = 0; $i < 10; $i++) {$pid = pcntl_fork();if($pid == -1) {die(‘can not fork.‘);} elseif (!$pid) {dummy_business();echo ‘quit‘.$i.PHP_EOL;break;}}?>
上面的代碼類比了10個使用者同時並發執行一項業務的情況,每次業務操作都會使得num的值增加1,每個使用者都會執行10000次操作,最終num的值應當是100000。
運行這段代碼,num的值和我們預期的值是一樣的:
mysql> select * from counter;+----+--------+| id | num |+----+--------+| 1 | 100000 |+----+--------+1 row in set (0.00 sec)
這裡不會出現問題,是因為單條UPDATE語句操作是原子的,無論怎麼執行,num的值最終都會是100000。 然而很多情況下,我們業務過程中執行的邏輯,通常是先查詢再執行,並不像上面的自增那樣簡單:
<?phpfunction dummy_business() {$conn = mysqli_connect(‘127.0.0.1‘, ‘public‘, ‘public‘) or die(mysqli_error());mysqli_select_db($conn, ‘test‘);for ($i = 0; $i < 10000; $i++) {$rs = mysqli_query($conn, ‘SELECT num FROM counter WHERE id = 1‘);mysqli_free_result($rs);$row = mysqli_fetch_array($rs);$num = $row[0];mysqli_query($conn, ‘UPDATE counter SET num = ‘.$num.‘ + 1 WHERE id = 1‘);}mysqli_close($conn);}for ($i = 0; $i < 10; $i++) {$pid = pcntl_fork();if($pid == -1) {die(‘can not fork.‘);} elseif (!$pid) {dummy_business();echo ‘quit‘.$i.PHP_EOL;break;}}?>
改過的指令碼,將原來的原子操作UPDATE換成了先查詢再更新,再次運行我們發現,由於並發的緣故程式並沒有按我們期望的執行:
mysql> select * from counter;+----+------+| id | num |+----+------+| 1 | 21495|+----+------+1 row in set (0.00 sec)
入門程式員特別容易犯的錯誤是,認為這是沒開啟事務引起的。現在我們給它加上事務:
<?phpfunction dummy_business() {$conn = mysqli_connect(‘127.0.0.1‘, ‘public‘, ‘public‘) or die(mysqli_error());mysqli_select_db($conn, ‘test‘);for ($i = 0; $i < 10000; $i++) {mysqli_query($conn, ‘BEGIN‘);$rs = mysqli_query($conn, ‘SELECT num FROM counter WHERE id = 1‘);mysqli_free_result($rs);$row = mysqli_fetch_array($rs);$num = $row[0];mysqli_query($conn, ‘UPDATE counter SET num = ‘.$num.‘ + 1 WHERE id = 1‘);if(mysqli_errno($conn)) {mysqli_query($conn, ‘ROLLBACK‘);} else {mysqli_query($conn, ‘COMMIT‘);}}mysqli_close($conn);}for ($i = 0; $i < 10; $i++) {$pid = pcntl_fork();if($pid == -1) {die(‘can not fork.‘);} elseif (!$pid) {dummy_business();echo ‘quit‘.$i.PHP_EOL;break;}}?>
依然沒能解決問題:
mysql> select * from counter;+----+------+| id | num |+----+------+| 1 | 16328|+----+------+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)是兩種常用的鎖:
- 悲觀鎖認為,別人訪問正在改變的資料的機率是很高的,因此從資料開始更改時就將資料鎖住,直到更改完成才釋放。悲觀鎖通常由資料庫實現(使用SELECT...FOR UPDATE語句)。
- 樂觀鎖認為,別人訪問正在改變的資料的機率是很低的,因此直到修改完成準備提交所做的的修改到資料庫的時候才會將資料鎖住,完成更改後釋放。
上面的例子,我們用悲觀鎖來實現:
<?phpfunction dummy_business() {$conn = mysqli_connect(‘127.0.0.1‘, ‘public‘, ‘public‘) or die(mysqli_error());mysqli_select_db($conn, ‘test‘);for ($i = 0; $i < 10000; $i++) {mysqli_query($conn, ‘BEGIN‘);$rs = mysqli_query($conn, ‘SELECT num FROM counter WHERE id = 1 FOR UPDATE‘);if($rs == false || mysqli_errno($conn)) {// 復原事務mysqli_query($conn, ‘ROLLBACK‘);// 重新執行本次操作$i--;continue;}mysqli_free_result($rs);$row = mysqli_fetch_array($rs);$num = $row[0];mysqli_query($conn, ‘UPDATE counter SET num = ‘.$num.‘ + 1 WHERE id = 1‘);if(mysqli_errno($conn)) {mysqli_query($conn, ‘ROLLBACK‘);} else {mysqli_query($conn, ‘COMMIT‘);}}mysqli_close($conn);}for ($i = 0; $i < 10; $i++) {$pid = pcntl_fork();if($pid == -1) {die(‘can not fork.‘);} elseif (!$pid) {dummy_business();echo ‘quit‘.$i.PHP_EOL;break;}}?>
可以看到,這次業務以期望的方式正確執行了:
mysql> select * from counter;+----+--------+| id | num |+----+--------+| 1 | 100000 |+----+--------+1 row in set (0.00 sec)
由於悲觀鎖在開始讀取時即開始鎖定,因此在並發訪問較大的情況下效能會變差。對MySQL Inodb來說,通過指定明確主鍵方式尋找資料會單行鎖定,而查詢範圍操作或者非主鍵操作將會鎖表。 接下來,我們看一下如何使用樂觀鎖解決這個問題,首先我們為counter表增加一欄欄位:
mysql> select * from counter;+----+------+---------+| id | num | version |+----+------+---------+| 1 | 1000 | 1000 |+----+------+---------+1 row in set (0.01 sec)
實現方式如下:
<?phpfunction dummy_business() {$conn = mysqli_connect(‘127.0.0.1‘, ‘public‘, ‘public‘) or die(mysqli_error());mysqli_select_db($conn, ‘test‘);for ($i = 0; $i < 10000; $i++) {mysqli_query($conn, ‘BEGIN‘);$rs = mysqli_query($conn, ‘SELECT num, version FROM counter WHERE id = 1‘);mysqli_free_result($rs);$row = mysqli_fetch_array($rs);$num = $row[0];$version = $row[1];mysqli_query($conn, ‘UPDATE counter SET num = ‘.$num.‘ + 1, version = version + 1 WHERE id = 1 AND version = ‘.$version);$affectRow = mysqli_affected_rows($conn);if($affectRow == 0 || mysqli_errno($conn)) {// 復原事務重新提交mysqli_query($conn, ‘ROLLBACK‘);$i--;continue;} else {mysqli_query($conn, ‘COMMIT‘);}}mysqli_close($conn);}for ($i = 0; $i < 10; $i++) {$pid = pcntl_fork();if($pid == -1) {die(‘can not fork.‘);} elseif (!$pid) {dummy_business();echo ‘quit‘.$i.PHP_EOL;break;}}?>
這次,我們也得到了期望的結果:
mysql> select * from counter;+----+--------+---------+| id | num | version |+----+--------+---------+| 1 | 100000 | 100000 |+----+--------+---------+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資料庫、訊息佇列等方式才能更有效地完成業務的處理。
參考文章
- 資料庫交易隔離等級
- MySQL資料庫事務的隔離等級介紹
- 資料庫事務並髮帶來的問題
- 樂觀鎖與悲觀鎖
- 樂觀鎖和悲觀鎖說的什麼意思
PHP使用資料庫的並發問題