在并行系统中并发问题永远不可忽视。尽管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数据库、消息队列等方式才能更有效地完成业务的处理。
参考文章