背景
在秒杀,抢购等并发场景下,可能会出现超卖的现象;
如:我们一共只有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 分布式锁等等…
此处列举皆为伪代码