PHP使用數據庫的併發問題

摘要: 在並行系統中併發問題永遠不可忽視。儘管PHP語言原生沒有提供多線程機制,那並不意味着所有的操作都是線程安全的。尤其是在操作諸如訂單、支付等業務系統中,更需要注意操作數據庫的併發問題。 接下來我通過一個案例分析一下PHP操作數據庫時併發問題的處理問題。

 

原載於我的博客 http://starlight36.com/post/php-db-concurrency

在並行系統中併發問題永遠不可忽視。儘管PHP語言原生沒有提供多線程機制,那並不意味着所有的操作都是線程安全的。尤其是在操作諸如訂單、支付等業務系統中,更需要注意操作數據庫的併發問題。 接下來我通過一個案例分析一下PHP操作數據庫時併發問題的處理問題。 

首先,我們有這樣一張數據表:

mysql> select * from counter;
+----+-----+
| id | num |
+----+-----+
|  1 |   0 |
+----+-----+
1 row in set (0.00 sec)
這段代碼模擬了一次業務操作:
<?php
function 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。 然而很多情況下,我們業務過程中執行的邏輯,通常是先查詢再執行,並不像上面的自增那樣簡單:
<?php
function 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)
入門程序員特別容易犯的錯誤是,認爲這是沒開啓事務引起的。現在我們給它加上事務:
<?php
function 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語句)。
  • 樂觀鎖認爲,別人訪問正在改變的數據的概率是很低的,因此直到修改完成準備提交所做的的修改到數據庫的時候纔會將數據鎖住,完成更改後釋放。
上面的例子,我們用悲觀鎖來實現:
<?php
function 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)
實現方式如下:
<?php
function 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數據庫、消息隊列等方式才能更有效地完成業務的處理。 

 

轉自:http://my.oschina.net/starlight36/blog/344986

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章