常见并发问题的解决方案

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

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

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

业务逻辑

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

准备工作

环境

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

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

数据库表结构

code

| 字段 | 类型 | 说明 |

| :---------- | :---------------------- | :------------------- |

| id | int11 unsigned not null | 自增主键 |

| code | char14 not null | 14位Char unique |

| status | bit1 not null | 0未发放 1已发放 |

| update_time | datetime | 发放时间 未发放为null |

code_out

| 字段 | 类型 | 说明 |

| :---------- | :---------------------- | :----------------------------- |

| id | int11 unsigned not null | 自增主键 |

| code_id | nt11 unsigned not null | code表主键 |

| create_time | datetime 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();
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,可以用自己的锁来锁住代码块。

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,064评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,606评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,011评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,550评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,465评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,919评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,428评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,075评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,208评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,185评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,191评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,914评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,482评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,585评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,825评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,194评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,703评论 2 339

推荐阅读更多精彩内容