陈大剩博客

PHP 使用数据库的并发问题

  • 陈大剩
  • 2022-09-10 16:04:53
  • 1832

背景

在秒杀,抢购等并发场景下,可能会出现超卖的现象;
如:我们一共只有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 |
+-----+

悲观锁解决

悲观锁的实现,往往依靠数据库提供的锁机制。在数据库中,悲观锁的流程如下:

  1. 在对记录进行修改前,先尝试为该记录加上排他锁(exclusive locks)。
  2. 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
  3. 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
  4. 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常。

修改后端代码

<?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来说,通过指定明确主键方式查找数据会单行锁定,而查询范围操作或者非主键操作将会锁表。

乐观锁解决

主要就是两个步骤:

  1. 冲突检测
  2. 数据更新

使用乐观锁解决这个问题,首先我们为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 分布式锁等等…
此处列举皆为伪代码

分享到:
0

说点儿什么吧

头像

表情

本站由陈大剩博客程序搭建 | 湘ICP备2023000975号| Copyright © 2017 - 陈大剩博客 | 本站采用创作共用版权:CC BY-NC 4.0

站长统计| 文章总数[115]| 评论总数[9]| 登录用户[22]| 时间点[118]

logo

登入

社交账号登录