常见并发问题解决方案

1,945 阅读5分钟

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

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

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

业务逻辑

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

准备工作

环境

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

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

数据库表结构

code

字段类型说明
idint11 unsigned not null自增主键
codechar14 not null14位Char unique
statusbit1 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│    818102080.321000.70389.09721.04│        │        │  200:81│
│  2s│   31031001173.301971.56389.091278.44│        │        │ 200:310│
│  3s│   5455450835.092949.67389.091796.22│        │        │ 200:545│
│  4s│   7787780657.163924.38389.092282.54│        │        │ 200:778│
│  5s│  100510050545.644908.34389.092749.07│        │        │200:1005│
│  6s│  123312330464.195949.70389.093231.45│        │        │200:1233│
│  7s│  145114530404.716909.48389.093706.35│        │        │200:1453│
│  8s│  150016800365.777277.43389.094100.99│        │        │200:1680│
│  9s│  150019020341.607277.43389.094391.14│        │        │200:1902│
│ 10s│  150021280324.087277.43389.094628.53│        │        │200:2128│
│ 11s│  150023360311.627277.43389.094813.55│        │        │200:2336│
│ 12s│  150025580301.017277.43389.094983.29│        │        │200:2558│
│ 13s│  150027940292.187277.43389.095133.82│        │        │200:2794│
│ 14s│  150030000286.167277.43389.095241.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│   10410402049.70993.69395.58731.81│        │        │ 200:104│
│  2s│   33833801179.551988.44395.581271.67│        │        │ 200:338│
│  3s│   5575570853.742935.61395.581756.98│        │        │ 200:557│
│  4s│   8038030662.973952.94395.582262.55│        │        │ 200:803│
│  5s│  103610360549.074917.70395.582731.88│        │        │200:1036│
│  6s│  128312830463.215912.17395.583238.26│        │        │200:1283│
│  7s│  149615240402.646887.29395.583725.45│        │        │200:1524│
│  8s│  150017740366.777060.28395.584089.79│        │        │200:1774│
│  9s│  150020150345.617060.28395.584340.16│        │        │200:2015│
│ 10s│  150022520330.467060.28395.584539.15│        │        │200:2252│
│ 11s│  150024910319.097060.28395.584700.83│        │        │200:2491│
│ 12s│  150027330310.397060.28395.584832.66│        │        │200:2733│
│ 13s│  150029930302.997060.28395.584950.65│        │        │200:2993│
│ 13s│  150030000302.827060.28395.584953.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│     686801880.27955.80704.57797.76│        │        │ 200:68 │
│  2s│    27827801146.861979.88704.571307.92│        │        │ 200:278│
│  3s│    5405400795.132928.10704.571886.49│        │        │ 200:540│
│  4s│    6976970687.853467.25704.572180.72│        │        │ 200:697│
│  5s│   105810580509.594935.67704.572943.54│        │        │200:1058│
│  6s│   120712070464.165791.64704.573231.65│        │        │200:1207│
│  7s│   150016820377.436835.16704.573974.30│        │        │200:1682│
│  8s│   150019660359.366835.16704.574174.10│        │        │200:1966│
│  9s│   150022770349.386835.16704.574293.34│        │        │200:2277│
│ 10s│   150025600344.166835.16704.574358.40│        │        │200:2560│
│ 11s│   150028480341.156835.16704.574396.88│        │        │200:2848│
│ 11s│   150030000339.306835.16704.574420.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│      0000.000.000.000.00│        │        │        │
│  2s│     39390814.371886.711754.721841.90│        │        │  200:39│
│  3s│    2872870577.952974.691754.722595.40│        │        │ 200:287│
│  6s│    9229220434.784880.621754.723450.04│        │        │ 200:922│
│  5s│    6956950483.453675.151754.723102.72│        │        │ 200:695│
│  6s│   135213520363.115881.571754.724130.97│        │        │200:1352│
│  7s│   145314890352.776302.321754.724252.01│        │        │200:1489│
│  8s│   150020460345.427439.631754.724342.48│        │        │200:2046│
│  9s│   150023040344.517439.631754.724354.06│        │        │200:2304│
│ 10s│   150025590345.937439.631754.724336.18│        │        │200:2559│
│ 11s│   150028180342.977439.631754.724373.58│        │        │200:2818│
│ 12s│   150030000340.217439.631754.724409.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,可以用自己的锁来锁住代码块。

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

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