浅谈并发加锁

出现的问题

在我们的工作中,常常会出现一些对数量控制有精确要求的需求,比如商品库存量、奖品数量、报名人数限制等等,这些应用场景往往都存在高并发可能,比较容易出现数据量超量问题。以下做一下示例探索:

首先设计一个存量表

CREATE TABLE `product` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `product_name` varchar(255) NOT NULL DEFAULT '',
  `count` int(10) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

添加一行数据如下,设定基础库存量为10

浅谈并发加锁

问题代码如下:

        $process_num = 50; //开50个进程,模拟50个用户
        for ($i = 0; $i < $process_num; $i++) {
            MultiProcessHelper::instance($process_num)->multiProcessTask(function () use ($i) {
                if (Db::name('product')->where('id', 1)->value('count') > 0) {
                    $res = Db::name('product')->where('id', 1)->setDec('count');
                    if ($res) {
                        dump('获取到更新资源权限:' . $i);
                    }
                }
            });
        }

执行结果,50个用户都获取到了更新资源的权限

浅谈并发加锁

数据库相应数据存量变成了-40

浅谈并发加锁

这显然不是我们所期待的,这就是高并发带来的问题,同一时刻有多个进程读取同一条数据,同一时刻有多个进程更新同一条数据

解决问题

数据库自有的锁机制解决

  1. 要进行DML层面的限制(最后关卡安全,报错总比出现数据问题产生的影响小),主要的修改是将count的类型改成了无符号整数,这样该值就不可能再出现负数值
    CREATE TABLE `product` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `product_name` varchar(255) NOT NULL DEFAULT '',
    `count` int(10) unsigned NOT NULL DEFAULT '0',
    PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

执行一下代码,当count值从10减到0时,就不能再减少了,再减就会出现数据库报错

浅谈并发加锁

  1. mysql提供的行级锁select ... lock in share mode(阻塞写),select ... for update(阻塞读写,悲观锁),所以for update机制能满足我们的原子要求。编辑代码如下:
        $process_num = 50; //开50个进程,模拟50个用户
        for ($i = 0; $i < $process_num; $i++) {
            MultiProcessHelper::instance($process_num)->multiProcessTask(function () use ($i) {
                Db::startTrans(); //行级锁必须在事务中才能生效
                //设置for update,进程会阻塞在这里,只能允许一个进程获取到行锁,其他等待获取
                if (Db::name('product')->where('id', 1)->lock('for update')->value('count') > 0) { 
                    $res = Db::name('product')->where('id', 1)->setDec('count');
                    if ($res) {
                        dump('获取到更新资源权限:' . $i);
                    }
                }
                Db::commit();
            });
        }

只有十个进程获取到了更新权限,消费正常

浅谈并发加锁

浅谈并发加锁

  1. 将条件语句放到update上,保持语句执行的原子性,杜绝并发幻读
    修改代码如下:
        $process_num = 50; //开50个进程,模拟50个用户
        for ($i = 0; $i < $process_num; $i++) {
            MultiProcessHelper::instance($process_num)->multiProcessTask(function () use ($i) {
                    //合并两条语句为一条更新语句
                    $res = Db::name('product')->where('id', 1)->where('count', '>', 0)->setDec('count');
                    if ($res) {
                        dump('获取到更新资源权限:' . $i);
                    }
            });
        }

只有十个进程获取到了更新权限,消费正常

浅谈并发加锁

浅谈并发加锁

文件锁机制解决

编辑代码如下:

        $process_num = 50; //开50个进程,模拟50个用户
        for ($i = 0; $i < $process_num; $i++) {
            MultiProcessHelper::instance($process_num)->multiProcessTask(function () use ($i) {
                $filename = app()->getRootPath() . 'runtime/lock';
                $file = fopen($filename, 'w'); //打开文件
                $lock = flock($file, LOCK_EX);
                // $lock=flock($handle, LOCK_EX|LOCK_NB); (异步非阻塞,所有进程如果出现获取不到锁,不等待跳过,加锁失败)
                //获取文件排他锁:LOCK_EX(异步阻塞,只有一个进程获得锁,其他竞争进程等待)
                //还有一种共享锁:LOCK_SH(所有进程都可以获取共享锁,读取文件,当且只有一个锁时,才允许写操作,否则操作失败,容易出现死锁问题)
                if ($lock) {
                    try {
                        if (Db::name('product')->where('id', 1)->lock('for update')->value('count') > 0) {
                            $res = Db::name('product')->where('id', 1)->setDec('count');
                            if ($res) {
                                dump('获取到更新资源权限:' . $i);
                            }
                        }
                    } catch (\Exception $e) {
                        dump($e->getMessage());
                    } finally {
                        flock($file, LOCK_UN); //无论如何都要释放锁
                    }
                }
                fclose($file); //关闭文件句柄
            });
        }

只有十个进程获取到了更新权限,消费正常

浅谈并发加锁

浅谈并发加锁

分布式锁机制解决

以上文件锁,只适应于单体架构的需求,在集群架构、分布式等多机联网结构中就是掩耳盗铃了,所以适应性更好地锁机制还是要使用分布式锁,分布式锁最常用和最易用就是redis的setnx锁了。
编辑代码如下:

        $process_num = 50; //开50个进程,模拟50个用户
        for ($i = 0; $i < $process_num; $i++) {
            MultiProcessHelper::instance($process_num)->multiProcessTask(function () use ($i) {
                //获取redis锁
                //关于CacheHelper::getRedisLock是怎样获取锁的,注意几个点就行:1.如何避免死锁;2.如何设置过期时间;3.如何设置抢占条件;4.如何循环等待判断。这些不在本文讨论范围,可自行研究,以后有空我也可以写一篇博文
                $lock = CacheHelper::getRedisLock('redis_lock');
                if ($lock) {
                    try {
                        if (Db::name('product')->where('id', 1)->lock('for update')->value('count') > 0) {
                            $res = Db::name('product')->where('id', 1)->setDec('count');
                            if ($res) {
                                dump('获取到更新资源权限:' . $i);
                            }
                        }
                    } catch (\Exception $e) {
                        dump($e->getMessage());
                    }
                } else {
//                    dump('获取redis锁失败');
                }
            });
        }

只有十个进程获取到了更新权限,消费正常

浅谈并发加锁

浅谈并发加锁

总结

加锁并不是最好的解决方案,只能达到数据安全的要求,同时性能会很大的损耗,可能出现死锁或者请求长期等待返回5XX的问题,要根据场景和需求来做选择和设计。





原创不易,分享快乐,渴望动力

浅谈并发加锁

本作品采用《CC 协议》,转载必须注明作者和本文链接
我只想看看蓝天
附言 1  ·  4年前

enjoy it

本帖由系统于 4年前 自动加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 24

:+1: 很棒的文章

4年前 评论
luler (楼主) 4年前

redis 加锁了,mysql还用加锁吗?

4年前 评论
luler (楼主) 4年前
KayuHo

学习了 :+1:

4年前 评论

哇 MultiProcessHelper可以分享下吗

4年前 评论
luler (楼主) 4年前
Leimon1314 2年前

楼主方便看下CacheHelper::getRedisLock里面逻辑么?

4年前 评论

求分享 MultiProcessHelper

4年前 评论

老哥,厉害啊! :+1:

4年前 评论

通过 CacheHelper::getRedisLock 获得的锁,在执行完操作后是如何释放的呢,是在方法内部实现的吗?没太明白,楼主有空可以帮忙解答一下 :smile:

4年前 评论
luler (楼主) 4年前

如果库存1万 一个用户下单时间1s 是不是每个用户下单都要等一秒后才能下单,人多了是不是又有问题了

4年前 评论
luler (楼主) 4年前

这种加库存减少 直接redis 不需要加锁。 下单写数据库就可以。

4年前 评论
╰ゝSakura

redis setnx设置下过期时间,一般整个都不会挂吧

4年前 评论

我就想知道这个收钱码怎么弄的 哈哈哈 是社区的功能吗 还是作为图片传上去的

4年前 评论

file
这个地方是因为只有单进程能进来所以会按照每个进程顺序去执行,,但是没有开事务啊,,,for update 需要用到事务里面吗,,

我的理解是,,没有必要加事务,for update 也可以直接作用于锁行。和事务无关吧

3年前 评论
luler (楼主) 3年前

没看你开启事务啊,,,Db::startTrans(); :joy:

3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!