PHP 使用数据库的并发问题
- 陈大剩
- 2022-09-10 16:04:53
- 1658
背景
在秒杀,抢购等并发场景下,可能会出现超卖的现象;
如:我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是1个,然后都通过了这一个余量判断,最终导致超发。
在 PHP 语言中并没有原生提供并发的解决方案,因此就需要借助其他方式来实现并发控制,其实方案有很多种。总结下如何并发访问。
代码复现
数据库查询
# 查询库存还有 1
mysql> select * from goods;
+-----+
| num |
| 1 |
+-----+
后端代码
<?php
$conn = mysqli_connect('127.0.0.1', 'root', '123456') or die(mysqli_error());
mysqli_select_db($conn, 'shop');
// 查询出商品量
mysqli_query($conn, 'BEGIN');
$rs = mysqli_query($conn, 'SELECT num FROM goods WHERE id = 1');
$row = mysqli_fetch_array($rs);
$num = $row[0];
// 其他逻辑...
// ...
// 可用库存-1
mysqli_query($conn, 'UPDATE goods SET num = '.$num.' - 1 WHERE id = 1');
$affectRow = mysqli_affected_rows($conn);
if ($affectRow == 0 || mysqli_errno($conn)) {
// 回滚事务重新提交
mysqli_query($conn, 'ROLLBACK');
} else {
// 提交数据库逻辑
mysqli_query($conn, 'COMMIT');
}
mysqli_close($conn);
模拟高并发请求(ab)
# 模拟1000个请求 每次100个并发
ab -n 1000 -c 100 http://shop.com.test/index.php
# 查询库存还有-23 出现超卖
mysql> select * from goods;
+-----+
| num |
| -23 |
+-----+
改数据字段为unsigned类型
当后端库存为0时,因为字段不能为负数,将会返回false
mysql> show create table goods;
+---------+----------------------------------------------+
| Table | Create Table |
+---------+----------------------------------------------+
| goods | CREATE TABLE `counter` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`num` int(11) unsigned NOT NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 |
+---------+----------------------------------------------+
1 row in set (0.00 sec)
请求
# 模拟1000个请求 每次100个并发
ab -n 1000 -c 100 http://shop.com.test/index.php
# 查询库存还有0 未超卖
mysql> select * from goods;
+-----+
| num |
| 0 |
+-----+
更改隔离级别(不推荐)
Mysql 隔离级别默认为:可重复读(Repeatable read),这也是出现幻读唯一问题;
将隔离级改为更高级的:可串行化(Serializable),但是会牺牲很大的性能
查询全局和会话事务隔离级别:
# 全局
SELECT @@global.tx_isolation;
# 会话
SELECT @@session.tx_isolation;
# 当前
SELECT @@tx_isolation;
设置隔离级别
# 设置mysql的隔离级别:
set session transaction isolation level 设置事务隔离级别
# 设置read uncommitted级别:
set session transaction isolation level read uncommitted;
# 设置read committed级别:
set session transaction isolation level read committed;
# 设置repeatable read级别:
set session transaction isolation level repeatable read;
# 设置serializable级别:
set session transaction isolation level serializable;
完整操作
# 查询
SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
# 设置serializable级别:
set session transaction isolation level serializable;
# 查询
SELECT @@tx_isolation;
+----------------+
| @@tx_isolation |
+----------------+
| SERIALIZABLE |
+----------------+
请求
# 模拟1000个请求 每次100个并发
ab -n 1000 -c 100 http://shop.com.test/index.php
# 查询库存还有0 未超卖
mysql> select * from goods;
+-----+
| num |
| 0 |
+-----+
悲观锁解决
悲观锁的实现,往往依靠数据库提供的锁机制。在数据库中,悲观锁的流程如下:
- 在对记录进行修改前,先尝试为该记录加上排他锁(exclusive locks)。
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
- 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常。
修改后端代码
<?php
$conn = mysqli_connect('127.0.0.1', 'root', '123456') or die(mysqli_error());
mysqli_select_db($conn, 'shop');
// 查询出商品量
mysqli_query($conn, 'BEGIN');
$rs = mysqli_query($conn, 'SELECT num FROM goods WHERE id = 1 FOR UPDATE');
if($rs == false || mysqli_errno($conn)) {
// 回滚事务
mysqli_query($conn, 'ROLLBACK');
}
$row = mysqli_fetch_array($rs);
$num = $row[0];
// 其他逻辑...
// ...
// 可用库存-1
mysqli_query($conn, 'UPDATE goods SET num = '.$num.' - 1 WHERE id = 1');
$affectRow = mysqli_affected_rows($conn);
if ($affectRow == 0 || mysqli_errno($conn)) {
// 回滚事务重新提交
mysqli_query($conn, 'ROLLBACK');
} else {
// 提交数据库逻辑
mysqli_query($conn, 'COMMIT');
}
mysqli_close($conn);
请求
# 模拟1000个请求 每次100个并发
ab -n 1000 -c 100 http://shop.com.test/index.php
# 查询库存还有0 未超卖
mysql> select * from goods;
+-----+
| num |
| 0 |
+-----+
悲观锁在开始读取时即开始锁定,因此在并发访问较大的情况下性能会变差。对MySQL Inodb来说,通过指定明确主键方式查找数据会单行锁定,而查询范围操作或者非主键操作将会锁表。
乐观锁解决
主要就是两个步骤:
- 冲突检测
- 数据更新
使用乐观锁解决这个问题,首先我们为goods表增加一列字段:
mysql> select * from goods;
+------+---------+
| num | version |
+------+---------+
| 1 | 1 |
+------+---------+
修改后端代码
<?php
$conn = mysqli_connect('127.0.0.1', 'root', '123456') or die(mysqli_error());
mysqli_select_db($conn, 'shop');
// 查询出商品量
mysqli_query($conn, 'BEGIN');
$rs = mysqli_query($conn, 'SELECT num, version FROM goods WHERE id = 1');
$row = mysqli_fetch_array($rs);
$num = $row[0];
$version = $row[1];
// 其他逻辑...
// ...
// 可用库存-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');
} else {
// 提交数据库逻辑
mysqli_query($conn, 'COMMIT');
}
mysqli_close($conn);
请求
# 模拟1000个请求 每次100个并发
ab -n 1000 -c 100 http://shop.com.test/index.php
# 查询库存还有0 未超卖
mysql> select * from goods;
+-----+
| num |
| 0 |
+-----+
Redis中也有类似的乐观锁方案的watch
队列解决
直接将请求放入队列中的,采用FIFO(First Input First Output,先进先出),这样的话,我们就不会导致某些请求永远获取不到锁。
<?php
$conn = mysqli_connect('127.0.0.1', 'root', '123456') or die(mysqli_error());
mysqli_select_db($conn, 'shop');
// 查询出商品量
$rs = mysqli_query($conn, 'SELECT num FROM goods WHERE id = 1');
$row = mysqli_fetch_array($rs);
$num = $row[0];
mysqli_close($conn);
// 其他逻辑...
// ...
//队列数组
$user_id = 1;
$redis=new Redis();
$redis->lpush('goods_que',$user_id.'-'.$goods_id.$num)
处理队列
<?php
$conn = mysqli_connect('127.0.0.1', 'root', '123456') or die(mysqli_error());
mysqli_select_db($conn, 'shop');
while (true){
$redis=new Redis();
$str=$redis->rpop('goods_que')
// todo 处理逻辑
}
这只是简单实现,可靠性高的可以用RabbitMQ、kafka等其他强势消息中间件。
其他方案
还有很多好的方案,如:文件锁、Redis 分布式锁等等…
此处列举皆为伪代码