最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 常见并发问题解决方案

    正文概述 转载于:掘金(赵猫猫2021)   2021-04-03   95

    现在网上关于秒杀,抢票,超卖等并发场景的文章已经烂大街了。之前看过很多,但从来没自己测试过。今天心血来潮,想落地一下。

    虽然解决的方法很多,可不一定都适合各种具体场景,所以过一遍流程,也能更好的把握哪些场景更适合怎样的方法,此篇文章的目的就是如此。

    再啰嗦一句:并发和大流量是两码事,小流量也可以有并发。

    业务逻辑

    老板发福利,400个奖,不能发重,不能发超,大家快来抢啊!

    准备工作

    环境

    脚本:php,框架:Laravel,web服务器:Nginx,数据库:MySQL,NoSQL:Redis,并发压测工具:Go-stress-testing-linux,系统:CentOS7。

    具体的脚本不重要,这里用的是自己比较熟悉的。

    数据库表结构

    code

    字段类型说明
    idint11 unsigned not null自增主键codechar14 not null14位Char uniquestatusbit1 not null0未发放 1已发放update_timedatetime发放时间 未发放为null

    code_out

    字段类型说明
    idint11 unsigned not null自增主键code_idnt11 unsigned not nullcode表主键create_timedatetime not null发放时间 默认CURRENT_TIMESTAMP

    code_out表主要用来表现并发问题。

    正常情况下,code_out表数据量和code表status=1的数据量必须一样,且code_out表一定没有code_id相同的记录,否则同一code肯定被发给了多个用户。

    这里补充下,时间为什么没有用timestamp。

    其实以前我也喜欢用timestamp类型的,可自从有一次遇到有记录的实际创建时间是18xx年,导致客户劈头盖脸来骂了一顿这种情况之后,就改掉了这个习惯。当然我也不是说timestamp不好,而是人总是有惯性思维。

    再补充一下,为什么很多字段要可以不允许为null。

    字段为null是很危险的,它可能导致查询的数据和实际逻辑要求的不一致,并且null比空字符串会占用更多的空间。所以,除非业务要求区分"0"和"没有",都建议字段不允许null,怎么算都不划算对吧。

    数据填充
    use Illuminate\Support\Str;
    
    // 原谅我放纵不羁爱自由,懒得建模型了,直接用DB类走起
    for ($i = 0; $i < 100; $i++) {
        \DB::table('code')
            ->insert([
                'code' => Str::random(14),
            ]);
    }
    
    安装go-stress-testing-linux

    go-stress-testing-linux是Go写的压测工具。

    git上有打成二进制的可执行文件,下载即可(github搜索link1st/go-stress-testing)。

    下载后记得赋予文件可执行权限哦。想偷懒的话,就直接拷贝到/usr/bin下吧。如果使用二进制文件的话,不需要装go环境。

    为什么选择go-stress-testing-linux?

    它的运行原理是利用Go的携程发起并发,是真正意义上的多线程并发。

    安装Redis

    不再赘述,网上教程很多。

    安装php redis扩展

    这一步可选,php有很多种方式可以和redis互通,个人更喜欢这种原始的方法。

    游戏开始吧

    压测参数

    go-stress-testing-linux -c 1500 -n 2 -u {url}
    

    模拟1500个用户,每个用户请求2次。看上去数字并不大对吧?

    压测过程

    没有任何保护措施
    开抢咯
    $remain = \DB::table('code')
        ->where('status', 0)
        ->select('id', 'code')
        ->first();
    if (null == $remain) {
        return [
            'code' => 500,
            'msg' => 'no code available',
            'data' => null
        ];
    }
    \DB::table('code')
        ->where('id', $remain->id)
        ->update([
            'status' => 1,
            'out_time' => date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME'])
        ]);
    \DB::table('code_out')
        ->insert([
            'code_id' => $remain->id
        ]);
    return [
        'code' => 200,
        'msg' => 'congratulations',
        'data' => $remain->code
    ];
    
    结果
    ┬────┬──────┬──────┬──────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬
    │ 耗时│ 并发数│ 成功数│ 失败数│   qps  │ 最长耗时 │ 最短耗时 │ 平均耗时│ 下载字节│ 字节每秒 │ 错误码  │
    ┼────┼──────┼──────┼──────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┼
    │  1s│    81│    81│     0│ 2080.32│ 1000.70│  389.09│  721.04│        │        │  200:81│
    │  2s│   310│   310│     0│ 1173.30│ 1971.56│  389.09│ 1278.44│        │        │ 200:310│
    │  3s│   545│   545│     0│  835.09│ 2949.67│  389.09│ 1796.22│        │        │ 200:545│
    │  4s│   778│   778│     0│  657.16│ 3924.38│  389.09│ 2282.54│        │        │ 200:778│
    │  5s│  1005│  1005│     0│  545.64│ 4908.34│  389.09│ 2749.07│        │        │200:1005│
    │  6s│  1233│  1233│     0│  464.19│ 5949.70│  389.09│ 3231.45│        │        │200:1233│
    │  7s│  1451│  1453│     0│  404.71│ 6909.48│  389.09│ 3706.35│        │        │200:1453│
    │  8s│  1500│  1680│     0│  365.77│ 7277.43│  389.09│ 4100.99│        │        │200:1680│
    │  9s│  1500│  1902│     0│  341.60│ 7277.43│  389.09│ 4391.14│        │        │200:1902│
    │ 10s│  1500│  2128│     0│  324.08│ 7277.43│  389.09│ 4628.53│        │        │200:2128│
    │ 11s│  1500│  2336│     0│  311.62│ 7277.43│  389.09│ 4813.55│        │        │200:2336│
    │ 12s│  1500│  2558│     0│  301.01│ 7277.43│  389.09│ 4983.29│        │        │200:2558│
    │ 13s│  1500│  2794│     0│  292.18│ 7277.43│  389.09│ 5133.82│        │        │200:2794│
    │ 14s│  1500│  3000│     0│  286.16│ 7277.43│  389.09│ 5241.89│        │        │200:3000│
    
    数据验证
    select count(*) from `code` where `status` = 1;
    # 400
    select count(*) from code_out;
    # 3000
    select count(*), code_id from code_out group by code_id having count(*) > 1;
    # 竟然有216条记录,其中吉尼斯记录获取者是code_id=2的奖项,它被发了43次!
    # 当然,其他很多code也被重复发了很多次
    
    结论

    可以看到,不加任何保护措施的情况下,代码造成了同一code发给了多个用户的情况,一上线那就是事故!

    为什么会造成这种情况呢?其实原因很简单:MySQL查询和更新都需要一定时间的,更新过程中,后来的线程读到的还是老数据!代码可不会管这么多,拿到就继续用咯。

    同时,这也证明压测工具确实模拟出了并发场景。

    版本控制
    准备
    # 给code加一个version列
    alter table `code` add version bit(1) not null default 0;
    
    开抢咯
    $remain = \DB::table('code')
        ->where('status', 0)
        ->select('id', 'code')
        ->first();
    if (null == $remain) {
        return [
            'code' => 500,
            'msg' => 'no code available',
            'data' => null
        ];
    }
    $res = \DB::table('code')
        ->where('id', $remain->id)
        ->where('version', 0)
        ->update([
            'status' => 1,
            'out_time' => date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME']),
            'version' => 1
        ]);
    if (0 == $res) {
        return [
            'code' => 500,
            'msg' => 'no code available',
            'data' => null
        ];
    }
    \DB::table('code_out')
        ->insert([
            'code_id' => $remain->id
        ]);
    return [
        'code' => 200,
        'msg' => 'congratulations',
        'data' => $remain->code
    ];
    
    结果
    ┼────┬──────┬──────┬──────┬───────┬────────┬────────┬────────┬────────┬────────┬────────┼
    │ 耗时│ 并发数│ 成功数│ 失败数│  qps  │ 最长耗时 │ 最短耗时 │ 平均耗时│ 下载字节│ 字节每秒 │  错误码 │
    ┼────┼──────┼──────┼──────┼───────┼────────┼────────┼────────┼────────┼────────┼────────┼
    │  1s│   104│   104│     0│2049.70│  993.69│  395.58│  731.81│        │        │ 200:104│
    │  2s│   338│   338│     0│1179.55│ 1988.44│  395.58│ 1271.67│        │        │ 200:338│
    │  3s│   557│   557│     0│ 853.74│ 2935.61│  395.58│ 1756.98│        │        │ 200:557│
    │  4s│   803│   803│     0│ 662.97│ 3952.94│  395.58│ 2262.55│        │        │ 200:803│
    │  5s│  1036│  1036│     0│ 549.07│ 4917.70│  395.58│ 2731.88│        │        │200:1036│
    │  6s│  1283│  1283│     0│ 463.21│ 5912.17│  395.58│ 3238.26│        │        │200:1283│
    │  7s│  1496│  1524│     0│ 402.64│ 6887.29│  395.58│ 3725.45│        │        │200:1524│
    │  8s│  1500│  1774│     0│ 366.77│ 7060.28│  395.58│ 4089.79│        │        │200:1774│
    │  9s│  1500│  2015│     0│ 345.61│ 7060.28│  395.58│ 4340.16│        │        │200:2015│
    │ 10s│  1500│  2252│     0│ 330.46│ 7060.28│  395.58│ 4539.15│        │        │200:2252│
    │ 11s│  1500│  2491│     0│ 319.09│ 7060.28│  395.58│ 4700.83│        │        │200:2491│
    │ 12s│  1500│  2733│     0│ 310.39│ 7060.28│  395.58│ 4832.66│        │        │200:2733│
    │ 13s│  1500│  2993│     0│ 302.99│ 7060.28│  395.58│ 4950.65│        │        │200:2993│
    │ 13s│  1500│  3000│     0│ 302.82│ 7060.28│  395.58│ 4953.50│        │        │200:3000│
    
    数据验证
    select count(*) from `code` where `status` = 1;
    # 333
    select count(*) from code_out;
    # 333
    select count(*), code_id from code_out group by code_id having count(*) > 1;
    # 无记录
    
    结论

    很遗憾,奖没发完呢,因为部分线程抢到了同一个记录,但由于收到了版本控制,所以那些没有更新到数据的线程只能怪自己运气不好咯。

    这里用到了MySQL默认的MVCC,不知道的童鞋赶紧Google一下吧。

    其实,利用InnoDB的事务隔离也可以达到目的哦,但是如果没有深刻理解的话,搞不好会玩火自焚呢(如果造成死锁,无论行表,都会严重影响业务)。

    顺便说一句,大名鼎鼎的Elasticsearch也是用的这种方式解决这种问题的哦。

    使用缓存
    准备
    // redis稍微封装一下
    private function redis(): \Redis {
        $redis = new \Redis();
        $redis->connect('{host}', {port});
        $redis->auth('{password}');
        return $redis;
    }
    
    // 预热数据,将code放入Redis set中
    $code = \DB::table('code')
        ->select('code')
        ->get();
    $redis = $this->redis();
    $redis->connect('{host}', {port});
    foreach ($code as $v) {
        $redis->sAdd('code', $v);
    }
    
    开抢咯
    $redis = $this->redis();
    $code = $redis->spop('code');
    if (null == $code) {
        return [
            'code' => 500,
            'msg' => 'no code available',
            'data' => null
        ];
    }
    $exist = \DB::table('code')
        ->where('code', $code)
        ->where('status', 0)
        ->select('id')
        ->first();
    if (null == $exist) {
        return [
            'code' => 500,
            'msg' => 'invalid code',
            'data' => null
        ];
    }
    \DB::table('code')
        ->where('id', $exist->id)
        ->update([
            'status' => 1,
            'out_time' => date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME'])
        ]);
    \DB::table('code_out')
        ->insert([
            'code_id' => $exist->id
        ]);
    return [
        'code' => 200,
        'msg' => 'congratulations',
        'data' => $code
    ];
    
    结果
    ┼────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┼
    │ 耗时│ 并发数 │ 成功数 │ 失败数 │  qps   │ 最长耗时 │ 最短耗时 │ 平均耗时│ 下载字节 │ 字节每秒│ 错误码  │
    ┼────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┼
    │  1s│     68│     68│      0│ 1880.27│  955.80│  704.57│  797.76│        │        │ 200:68 │
    │  2s│    278│    278│      0│ 1146.86│ 1979.88│  704.57│ 1307.92│        │        │ 200:278│
    │  3s│    540│    540│      0│  795.13│ 2928.10│  704.57│ 1886.49│        │        │ 200:540│
    │  4s│    697│    697│      0│  687.85│ 3467.25│  704.57│ 2180.72│        │        │ 200:697│
    │  5s│   1058│   1058│      0│  509.59│ 4935.67│  704.57│ 2943.54│        │        │200:1058│
    │  6s│   1207│   1207│      0│  464.16│ 5791.64│  704.57│ 3231.65│        │        │200:1207│
    │  7s│   1500│   1682│      0│  377.43│ 6835.16│  704.57│ 3974.30│        │        │200:1682│
    │  8s│   1500│   1966│      0│  359.36│ 6835.16│  704.57│ 4174.10│        │        │200:1966│
    │  9s│   1500│   2277│      0│  349.38│ 6835.16│  704.57│ 4293.34│        │        │200:2277│
    │ 10s│   1500│   2560│      0│  344.16│ 6835.16│  704.57│ 4358.40│        │        │200:2560│
    │ 11s│   1500│   2848│      0│  341.15│ 6835.16│  704.57│ 4396.88│        │        │200:2848│
    │ 11s│   1500│   3000│      0│  339.30│ 6835.16│  704.57│ 4420.93│        │        │200:3000│
    
    数据验证
    select count(*) from `code `where `status` = 1;
    # 400
    select count(*) from code_out;
    # 400
    select count(*), code_id from code_out group by code_id having count(*) > 1;
    # 无记录
    
    结论

    可以看到,利用Redis单线程特性,并发问题已经解决啦。

    并发锁
    开抢咯
    $redis = $this->redis();
    if (false === $redis->setnx('lock', 1)) {
        return [
            'code' => 500,
            'msg' => 'no code available',
            'data' => null
        ];
    }
    // 避免死锁
    $redis->expire('lock', 10);
    try {
        $remain = \DB::table('code')
            ->where('status', 0)
            ->select('id', 'status')
            ->first();
        if (null == $remain) {
            return [
                'code' => 500,
                'msg' => 'no code available',
                'data' => null
            ];
        }
        \DB::table('code')
            ->where('id', $remain->id)
            ->update([
                'status' => 1,
                'out_time' => date('Y-m-d H:i:s', $_SERVER['REQUEST_TIME'])
            ]);
        \DB::table('code_out')
            ->insert([
                'code_id' => $remain->id
            ]);
        return [
            'code' => 200,
            'msg' => 'congratulations',
            'data' => $remain->code
        ];
    } catch (\Exception $e) {
        // 异常
        return [
            'code' => 500,
            'msg' => 'no code available',
            'data' => null
        ];
    } finally {
        // 释放锁
        $redis->del('lock');
    }
    
    结果
    ┼────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┼
    │ 耗时│ 并发数 │ 成功数 │ 失败数 │   qps  │ 最长耗时 │ 最短耗时│ 平均耗时 │ 下载字节 │ 字节每秒│  错误码 │
    │────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────┼────────┼────────┼
    │  1s│      0│      0│      0│    0.00│    0.00│    0.00│    0.00│        │        │        │
    │  2s│     39│     39│      0│  814.37│ 1886.71│ 1754.72│ 1841.90│        │        │  200:39│
    │  3s│    287│    287│      0│  577.95│ 2974.69│ 1754.72│ 2595.40│        │        │ 200:287│
    │  6s│    922│    922│      0│  434.78│ 4880.62│ 1754.72│ 3450.04│        │        │ 200:922│
    │  5s│    695│    695│      0│  483.45│ 3675.15│ 1754.72│ 3102.72│        │        │ 200:695│
    │  6s│   1352│   1352│      0│  363.11│ 5881.57│ 1754.72│ 4130.97│        │        │200:1352│
    │  7s│   1453│   1489│      0│  352.77│ 6302.32│ 1754.72│ 4252.01│        │        │200:1489│
    │  8s│   1500│   2046│      0│  345.42│ 7439.63│ 1754.72│ 4342.48│        │        │200:2046│
    │  9s│   1500│   2304│      0│  344.51│ 7439.63│ 1754.72│ 4354.06│        │        │200:2304│
    │ 10s│   1500│   2559│      0│  345.93│ 7439.63│ 1754.72│ 4336.18│        │        │200:2559│
    │ 11s│   1500│   2818│      0│  342.97│ 7439.63│ 1754.72│ 4373.58│        │        │200:2818│
    │ 12s│   1500│   3000│      0│  340.21│ 7439.63│ 1754.72│ 4409.07│        │        │200:3000│
    
    数据验证
    select count(*) from `code` where `status` = 1;
    # 61
    select count(*) from code_out;
    # 61
    select count(*), code_id from code_out group by code_id having count(*) > 1;
    # 无记录
    
    结论

    虽然这里也用到了Redis的特性,但重点是并发锁的原理,用PHP的文件锁也可以实现这个功能。

    在这个例子中,很遗憾,3000个请求只完成了61个奖的发放。因为锁住的时候就直接返回了结果,导致很多请求被拒绝了。但重点是避免了重发的问题!

    总结

    这里通过几个简单的例子,验证了用不同方法解决并发问题。虽然实际业务会更加复杂,但解决问题的方式,原理就是这些啦。

    这里根据我的项目经验,给出一些建议:

    Redis虽然是单线程(新版本的Redis已经是多线程的啦),但是连续的Redis操作可不一定了哦。例子:先get一个key,再set它,在并发情况下,结果可不一定是你想要的啦。

    • 如果是数字的话,可以使用Redis的incr/decr这种连续操作的方法。
    • 其他类型的话,可以使用Lua脚本一并发送命令,特殊语言如Java,可以用自己的锁来锁住代码块。

    使用并发锁一定要注意死锁的问题,不管什么情况,都要及时释放锁,否则万一出现死锁问题,那就是重大事故!

    好了,就说这么多了,希望对你有所帮助。


    起源地 » 常见并发问题解决方案

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元