PHP高并发情况下防止商品库存超卖 | PHP 技术论坛
https://learnku.com/articles/66811

php 防止库存超卖之并发测试 | PHP 技术论坛
https://learnku.com/articles/66812

商城系统中,抢购和秒杀是很常见的营销场景,在一定时间内有大量的用户访问商场下单,主要需要解决的问题有两个:

1. 高并发对数据库产生的压力;

2. 竞争状态下如何解决商品库存超卖;

高并发对数据库产生的压力

对于第一个问题,使用缓存来处理,避免直接操作数据库,例如使用 Redis。

竞争状态下如何解决商品库存超卖

对于第二个问题,需要重点说明。

常规写法:查询出对应商品的库存,判断库存数量否大于 0,然后执行生成订单等操作,但是在判断库存是否大于 0 处,如果在高并发下就会有问题,导致库存量出现负数。

测试表 sql
把如下表数据导入到数据库中

  1. /*
  2. Navicat MySQL Data Transfer
  3. Source Server : 01 本地localhost
  4. Source Server Version : 50553
  5. Source Host : localhost:3306
  6. Source Database : test
  7. Target Server Type : MYSQL
  8. Target Server Version : 50553
  9. File Encoding : 65001
  10. Date: 2020-11-06 14:31:35
  11. */
  12. SET FOREIGN_KEY_CHECKS=0;
  13. -- ----------------------------
  14. -- Table structure for products
  15. -- ----------------------------
  16. DROP TABLE IF EXISTS `products`;
  17. CREATE TABLE `products` (
  18. `id` int(10) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  19. `title` varchar(50) DEFAULT NULL COMMENT '货品名称',
  20. `store` int(11) DEFAULT '0' COMMENT '货品库存',
  21. PRIMARY KEY (`id`)
  22. ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='货品表';
  23. -- ----------------------------
  24. -- Records of products
  25. -- ----------------------------
  26. INSERT INTO `products` VALUES ('1', '稻花香大米', '20');
  27. -- ----------------------------
  28. -- Table structure for order_log
  29. -- ----------------------------
  30. DROP TABLE IF EXISTS `order_log`;
  31. CREATE TABLE `order_log` (
  32. `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  33. `content` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '日志内容',
  34. `c_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  35. PRIMARY KEY (`id`)
  36. ) ENGINE=MyISAM DEFAULT CHARSET=latin1;
  37. -- ----------------------------
  38. -- Table structure for order
  39. -- ----------------------------
  40. DROP TABLE IF EXISTS `order`;
  41. CREATE TABLE `order` (
  42. `oid` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '订单号',
  43. `product_id` int(11) DEFAULT '0' COMMENT '商品ID',
  44. `number` int(11) DEFAULT '0' COMMENT '购买数量',
  45. `c_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  46. PRIMARY KEY (`oid`)
  47. ) ENGINE=MyISAM DEFAULT CHARSET=latin1 COMMENT='订单表';

下单处理代码

  1. <?php
  2. db();
  3. global $con;
  4. //step1 接收下单参数
  5. $product_id = 1;// 商品ID
  6. $buy_num = 1;// 购买数量
  7. //step2 查询商品信息
  8. $sql = "select * from products where id={$product_id}";
  9. $result = mysqli_query($con, $sql);
  10. $row = mysqli_fetch_assoc($result);
  11. //step3 判断商品下单数量是否大于商品库存数量
  12. //此处在高并发下,可能出现上一个下单后还没来得及更新库存,下一个下单判断库存数不是最新的库存
  13. if ($row['store'] > 0) {
  14. sleep(1);
  15. //step4 更新商品库存数量(减去下单数量)
  16. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  17. if (mysqli_query($con, $sql)) {
  18. echo "更新成功";
  19. //step5 生成订单号创建订单
  20. $oid = build_order_no();
  21. create_order($oid, $product_id, $buy_num);
  22. insertLog('库存减少成功,下单成功');
  23. } else {
  24. echo "更新失败";
  25. insertLog('库存减少失败');
  26. }
  27. } else {
  28. echo "没有库存";
  29. insertLog('库存不够');
  30. }
  31. function db()
  32. {
  33. global $con;
  34. $con = new mysqli('localhost','root','root','test');
  35. if (!$con) {
  36. echo "数据库连接失败";
  37. }
  38. }
  39. /**
  40. * 生成唯一订单号
  41. */
  42. function build_order_no()
  43. {
  44. return date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  45. }
  46. function create_order($oid, $product_id, $number)
  47. {
  48. global $con;
  49. $sql = "INSERT INTO `order` (oid, product_id, number) values('$oid', '$product_id', '$number')";
  50. mysqli_query($con, $sql);
  51. }
  52. /**
  53. * 记录日志
  54. */
  55. function insertLog($content)
  56. {
  57. global $con;
  58. $sql = "INSERT INTO `order_log` (content) values('$content')";
  59. mysqli_query($con, $sql);
  60. }

 将库存字段字段设为 unsigned

因为库存字段不能为负数,在下单后更新商品库存时,如果出现负数将返回 false

  1. <?php
  2. db();
  3. global $con;
  4. //step1 接收下单参数
  5. $product_id = 1;// 商品ID
  6. $buy_num = 1;// 购买数量
  7. //step2 查询商品信息
  8. $sql = "select * from products where id={$product_id} for UPDATE";//利用for update 开启行锁
  9. $result = mysqli_query($con, $sql);
  10. $row = mysqli_fetch_assoc($result);
  11. //step3 判断商品下单数量是否大于商品库存数量
  12. if ($row['store'] > 0) {
  13. sleep(1);
  14. //step4 更新商品库存数量(减去下单数量)
  15. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  16. if (mysqli_query($con, $sql)) {
  17. echo "更新成功";
  18. //step5 生成订单号创建订单
  19. $oid = build_order_no();
  20. create_order($oid, $product_id, $buy_num);
  21. insertLog('库存减少成功,下单成功');
  22. } else {
  23. // 如果出现负数将返回false
  24. echo "更新失败";
  25. insertLog('库存减少失败');
  26. }
  27. } else {
  28. //商品已经抢购完
  29. echo "没有库存";
  30. insertLog('库存不够');
  31. }
  32. function db()
  33. {
  34. global $con;
  35. $con = new mysqli('localhost','root','root','test');
  36. if (!$con) {
  37. echo "数据库连接失败";
  38. }
  39. }
  40. /**
  41. * 生成唯一订单号
  42. */
  43. function build_order_no()
  44. {
  45. return date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  46. }
  47. function create_order($oid, $product_id, $number)
  48. {
  49. global $con;
  50. $sql = "INSERT INTO `order` (oid, product_id, number) values('$oid', '$product_id', '$number')";
  51. mysqli_query($con, $sql);
  52. }
  53. /**
  54. * 记录日志
  55. */
  56. function insertLog($content)
  57. {
  58. global $con;
  59. $sql = "INSERT INTO `order_log` (content) values('$content')";
  60. mysqli_query($con, $sql);
  61. }

使用 mysql 的事务,锁住操作的行

在下单处理过程中,使用 mysql 的事务将正在下单商品行数据锁定

  1. <?php
  2. db();
  3. global $con;
  4. //step1 接收下单参数
  5. $product_id = 1;// 商品ID
  6. $buy_num = 1;// 购买数量
  7. mysqli_query($con, "BEGIN"); //开始事务
  8. //step2 查询商品信息
  9. $sql = "select * from products where id={$product_id} for UPDATE";//利用for update 开启行锁
  10. $result = mysqli_query($con, $sql);
  11. $row = mysqli_fetch_assoc($result);
  12. //step3 判断商品下单数量是否大于商品库存数量
  13. if ($row['store'] > 0) {
  14. sleep(1);
  15. //step4 更新商品库存数量(减去下单数量)
  16. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  17. if (mysqli_query($con, $sql)) {
  18. echo "更新成功";
  19. //step5 生成订单号创建订单
  20. $oid = build_order_no();
  21. create_order($oid, $product_id, $buy_num);
  22. insertLog('库存减少成功,下单成功');
  23. mysqli_query($con, "COMMIT");//事务提交即解锁
  24. } else {
  25. echo "更新失败";
  26. insertLog('库存减少失败');
  27. mysqli_query($con, "ROLLBACK");//事务回滚即解锁
  28. }
  29. } else {
  30. //商品已经抢购完
  31. echo "没有库存";
  32. insertLog('库存不够');
  33. mysqli_query($con, "ROLLBACK");//事务回滚即解锁
  34. }
  35. function db()
  36. {
  37. global $con;
  38. $con = new mysqli('localhost','root','root','test');
  39. if (!$con) {
  40. echo "数据库连接失败";
  41. }
  42. }
  43. /**
  44. * 生成唯一订单号
  45. */
  46. function build_order_no()
  47. {
  48. return date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  49. }
  50. function create_order($oid, $product_id, $number)
  51. {
  52. global $con;
  53. $sql = "INSERT INTO `order` (oid, product_id, number) values('$oid', '$product_id', '$number')";
  54. mysqli_query($con, $sql);
  55. }
  56. /**
  57. * 记录日志
  58. */
  59. function insertLog($content)
  60. {
  61. global $con;
  62. $sql = "INSERT INTO `order_log` (content) values('$content')";
  63. mysqli_query($con, $sql);
  64. }

使用非阻塞的文件排他锁
在处理下单请求的时候,用 flock 锁定一个文件,如果锁定失败说明有其他订单正在处理,此时要么等待要么直接提示用户” 服务器繁忙”,计数器存储抢购的商品数量,避免查询数据库。

阻塞 (等待) 模式:并发时,当有第二个用户请求时,会等待第一个用户请求完成、释放锁,获得文件锁之后,程序才会继续运行下去。

  1. <?php
  2. db();
  3. global $con;
  4. //step1 接收下单参数
  5. $product_id = 1;// 商品ID
  6. $buy_num = 1;// 购买数量
  7. $fp = fopen('lock.txt', 'w');
  8. if (flock($fp, LOCK_EX)) { //文件独占锁,阻塞
  9. //step2 查询商品信息
  10. $sql = "select * from products where id={$product_id}";
  11. $result = mysqli_query($con, $sql);
  12. $row = mysqli_fetch_assoc($result);
  13. //step3 判断商品下单数量是否大于商品库存数量
  14. if ($row['store'] > 0) {
  15. //处理订单
  16. sleep(1);
  17. //step4 更新商品库存数量(减去下单数量)
  18. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  19. if (mysqli_query($con, $sql)) {
  20. echo "更新成功";
  21. //step5 生成订单号创建订单
  22. $oid = build_order_no();
  23. create_order($oid, $product_id, $buy_num);
  24. insertLog('库存减少成功,下单成功');
  25. } else {
  26. echo "更新失败";
  27. insertLog('库存减少失败');
  28. }
  29. } else {
  30. //商品已经抢购完
  31. echo "没有库存";
  32. insertLog('库存不够');
  33. }
  34. flock($fp, LOCK_UN); //释放锁
  35. }
  36. fclose($fp);
  37. function db()
  38. {
  39. global $con;
  40. $con = new mysqli('localhost','root','root','test');
  41. if (!$con) {
  42. echo "数据库连接失败";
  43. }
  44. }
  45. /**
  46. * 生成唯一订单号
  47. */
  48. function build_order_no()
  49. {
  50. return date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  51. }
  52. function create_order($oid, $product_id, $number)
  53. {
  54. global $con;
  55. $sql = "INSERT INTO `order` (oid, product_id, number) values('$oid', '$product_id', '$number')";
  56. mysqli_query($con, $sql);
  57. }
  58. /**
  59. * 记录日志
  60. */
  61. function insertLog($content)
  62. {
  63. global $con;
  64. $sql = "INSERT INTO `order_log` (content) values('$content')";
  65. mysqli_query($con, $sql);
  66. }

 非阻塞模式:并发时,第一个用户请求,拿得文件锁之后。后面请求的用户直接返回系统繁忙,请稍后再试

  1. <?php
  2. db();
  3. global $con;
  4. //step1 接收下单参数
  5. $product_id = 1;// 商品ID
  6. $buy_num = 1;// 购买数量
  7. $fp = fopen('lock.txt', 'w');
  8. if (flock($fp, LOCK_EX|LOCK_NB)) { //文件独占锁,非阻塞
  9. //step2 查询商品信息
  10. $sql = "select * from products where id={$product_id}";
  11. $result = mysqli_query($con, $sql);
  12. $row = mysqli_fetch_assoc($result);
  13. //step3 判断商品下单数量是否大于商品库存数量
  14. if ($row['store'] > 0) {
  15. //处理订单
  16. sleep(1);
  17. //step4 更新商品库存数量(减去下单数量)
  18. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  19. if (mysqli_query($con, $sql)) {
  20. echo "更新成功";
  21. //step5 生成订单号创建订单
  22. $oid = build_order_no();
  23. create_order($oid, $product_id, $buy_num);
  24. insertLog('库存减少成功,下单成功');
  25. } else {
  26. echo "更新失败";
  27. insertLog('库存减少失败');
  28. }
  29. } else {
  30. //商品已经抢购完
  31. echo "没有库存";
  32. insertLog('库存不够');
  33. }
  34. flock($fp, LOCK_UN); //释放锁
  35. } else {
  36. //系统繁忙,请稍后再试
  37. echo "系统繁忙,请稍后再试";
  38. insertLog('系统繁忙,请稍后再试');
  39. }
  40. fclose($fp);
  41. function db()
  42. {
  43. global $con;
  44. $con = new mysqli('localhost','root','root','test');
  45. if (!$con) {
  46. echo "数据库连接失败";
  47. }
  48. }
  49. /**
  50. * 生成唯一订单号
  51. */
  52. function build_order_no()
  53. {
  54. return date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  55. }
  56. function create_order($oid, $product_id, $number)
  57. {
  58. global $con;
  59. $sql = "INSERT INTO `order` (oid, product_id, number) values('$oid', '$product_id', '$number')";
  60. mysqli_query($con, $sql);
  61. }
  62. /**
  63. * 记录日志
  64. */
  65. function insertLog($content)
  66. {
  67. global $con;
  68. $sql = "INSERT INTO `order_log` (content) values('$content')";
  69. mysqli_query($con, $sql);
  70. }

使用 redis 队列

  • 因为 pop 操作是原子的,即使有很多用户同时到达,也是依次执行,推荐使用
  • mysql 事务在高并发下性能下降很厉害,文件锁的方式也是
  1. 先将商品库存到 redis 队列

  1. <?php
  2. db();
  3. global $con;
  4. // 查询商品信息
  5. $product_id = 1;
  6. $sql = "select * from products where id={$product_id}";
  7. $result = mysqli_query($con, $sql);
  8. $row = mysqli_fetch_assoc($result);
  9. $store = $row['store'];
  10. // 获取商品在redis缓存的库存
  11. $redis = new Redis();
  12. $result = $redis->connect('127.0.0.1', 6379);
  13. $key = 'goods_store_' . $product_id;
  14. $res = $redis->llen($key);
  15. $count = $store - $res;
  16. for ($i=0; $i<$count; $i++) {
  17. $redis->lpush($key, 1);
  18. }
  19. echo $redis->llen($key);
  20. function db()
  21. {
  22. global $con;
  23. $con = new mysqli('localhost','root','root','test');
  24. if (!$con) {
  25. echo "数据库连接失败";
  26. }
  27. }

2. 抢购、秒杀逻辑

  1. <?php
  2. db();
  3. global $con;
  4. //step1 接收下单参数
  5. $product_id = 1;// 商品ID
  6. $buy_num = 1;// 购买数量
  7. //step2 下单前判断redis队列库存量
  8. $redis = new Redis();
  9. $result = $redis->connect('127.0.0.1',6379);
  10. $count = $redis->lpop('goods_store_' . $product_id);
  11. if (!$count) {
  12. insertLog('error:no store redis');
  13. return '秒杀结束,没有商品库存了';
  14. }
  15. sleep(1);
  16. //step3 更新商品库存数量(减去下单数量)
  17. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  18. if (mysqli_query($con, $sql)) {
  19. echo "更新成功";
  20. //step4 生成订单号创建订单
  21. $oid = build_order_no();
  22. create_order($oid, $product_id, $buy_num);
  23. insertLog('库存减少成功,下单成功');
  24. } else {
  25. echo "更新失败";
  26. insertLog('库存减少失败');
  27. }
  28. function db()
  29. {
  30. global $con;
  31. $con = new mysqli('localhost','root','root','test');
  32. if (!$con) {
  33. echo "数据库连接失败";
  34. }
  35. }
  36. /**
  37. * 生成唯一订单号
  38. */
  39. function build_order_no()
  40. {
  41. return date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
  42. }
  43. function create_order($oid, $product_id, $number)
  44. {
  45. global $con;
  46. $sql = "INSERT INTO `order` (oid, product_id, number) values('$oid', '$product_id', '$number')";
  47. mysqli_query($con, $sql);
  48. }
  49. /**
  50. * 记录日志
  51. */
  52. function insertLog($content)
  53. {
  54. global $con;
  55. $sql = "INSERT INTO `order_log` (content) values('$content')";
  56. mysqli_query($con, $sql);
  57. }

3.redis 乐观锁防止超卖

  1. <?php
  2. $redis =new Redis();
  3. $redis->connect("127.0.0.1", 6379);
  4. $redis->watch('sales');//乐观锁 监视作用 set() 初始值0
  5. $sales = $redis->get('sales');
  6. $n = 20;// 库存
  7. if ($sales >= $n) {
  8. exit('秒杀结束');
  9. }
  10. //redis开启事务
  11. $redis->multi();
  12. $redis->incr('sales'); //将 key 中储存的数字值增一 ,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。
  13. $res = $redis->exec(); //成功1 失败0
  14. if ($res) {
  15. //秒杀成功
  16. $con = new mysqli('localhost','root','root','test');
  17. if (!$con) {
  18. echo "数据库连接失败";
  19. }
  20. $product_id = 1;// 商品ID
  21. $buy_num = 1;// 购买数量
  22. sleep(1);
  23. $sql = "update products set store=store-{$buy_num} where id={$product_id}";
  24. if (mysqli_query($con, $sql)) {
  25. echo "秒杀完成";
  26. }
  27. } else {
  28. exit('抢购失败');
  29. }

版权声明:本文为Bashuslovakia原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/ianlab/p/16357749.html