序
现在网上关于秒杀,抢票,超卖等并发场景的文章已经烂大街了。之前看过很多,但从来没自己测试过。今天心血来潮,想落地一下。
虽然解决的方法很多,可不一定都适合各种具体场景,所以过一遍流程,也能更好的把握哪些场景更适合怎样的方法,此篇文章的目的就是如此。
再啰嗦一句:并发和大流量是两码事,小流量也可以有并发。
业务逻辑
老板发福利,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,可以用自己的锁来锁住代码块。
使用并发锁一定要注意死锁的问题,不管什么情况,都要及时释放锁,否则万一出现死锁问题,那就是重大事故!
好了,就说这么多了,希望对你有所帮助。