PHP使用數據庫的併發問題

在並行系統中併發問題永遠不可忽視。儘管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)是兩種經常使用的鎖:

  • 悲觀鎖認爲,別人訪問正在改變的數據的機率是很高的,所以從數據開始更改時就將數據鎖住,直到更改完成才釋放。悲觀鎖一般由數據庫實現(使用SELECT…FOR UPDATE語句)。
  • 樂觀鎖認爲,別人訪問正在改變的數據的機率是很低的,所以直到修改完成準備提交所作的的修改到數據庫的時候纔會將數據鎖住,完成更改後釋放。

上面的例子,咱們用悲觀鎖來實現:

 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數據庫、消息隊列等方式才能更有效地完成業務的處理。

參考文章

相關文章
相關標籤/搜索