序
现在网上关于秒杀,抢票,超卖等并发场景的文章已经烂大街了。之前看过很多,但从来没自己测试过。今天心血来潮,想落地一下。
虽然解决的方法很多,可不一定都适合各种具体场景,所以过一遍流程,也能更好的把握哪些场景更适合怎样的方法,此篇文章的目的就是如此。
再啰嗦一句:并发和大流量是两码事,小流量也可以有并发。
业务逻辑
老板发福利,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,怎么算都不划算对吧。
数据填充
1 | php复制代码use Illuminate\Support\Str; |
安装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互通,个人更喜欢这种原始的方法。
让游戏开始吧
压测参数
1 | go复制代码go-stress-testing-linux -c 1500 -n 2 -u {url} |
模拟1500个用户,每个用户请求2次。看上去数字并不大对吧?
压测过程
没有任何保护措施
开抢咯
1 | php复制代码$remain = \DB::table('code') |
结果
1 | php复制代码┬────┬──────┬──────┬──────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┬ |
数据验证
1 | sql复制代码select count(*) from `code` where `status` = 1; |
结论
可以看到,不加任何保护措施的情况下,代码造成了同一code发给了多个用户的情况,一上线那就是事故!
为什么会造成这种情况呢?其实原因很简单:MySQL查询和更新都需要一定时间的,更新过程中,后来的线程读到的还是老数据!代码可不会管这么多,拿到就继续用咯。
同时,这也证明压测工具确实模拟出了并发场景。
版本控制
准备
1 | sql复制代码# 给code加一个version列 |
开抢咯
1 | php复制代码$remain = \DB::table('code') |
结果
1 | php复制代码┼────┬──────┬──────┬──────┬───────┬────────┬────────┬────────┬────────┬────────┬────────┼ |
数据验证
1 | sql复制代码select count(*) from `code` where `status` = 1; |
结论
很遗憾,奖没发完呢,因为部分线程抢到了同一个记录,但由于收到了版本控制,所以那些没有更新到数据的线程只能怪自己运气不好咯。
这里用到了MySQL默认的MVCC,不知道的童鞋赶紧Google一下吧。
其实,利用InnoDB的事务隔离也可以达到目的哦,但是如果没有深刻理解的话,搞不好会玩火自焚呢(如果造成死锁,无论行表,都会严重影响业务)。
顺便说一句,大名鼎鼎的Elasticsearch也是用的这种方式解决这种问题的哦。
使用缓存
准备
1 | php复制代码// redis稍微封装一下 |
开抢咯
1 | php复制代码$redis = $this->redis(); |
结果
1 | php复制代码┼────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┼ |
数据验证
1 | sql复制代码select count(*) from `code `where `status` = 1; |
结论
可以看到,利用Redis单线程特性,并发问题已经解决啦。
并发锁
开抢咯
1 | php复制代码$redis = $this->redis(); |
结果
1 | php复制代码┼────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┼ |
数据验证
1 | sql复制代码select count(*) from `code` where `status` = 1; |
结论
虽然这里也用到了Redis的特性,但重点是并发锁的原理,用PHP的文件锁也可以实现这个功能。
在这个例子中,很遗憾,3000个请求只完成了61个奖的发放。因为锁住的时候就直接返回了结果,导致很多请求被拒绝了。但重点是避免了重发的问题!
总结
这里通过几个简单的例子,验证了用不同方法解决并发问题。虽然实际业务会更加复杂,但解决问题的方式,原理就是这些啦。
这里根据我的项目经验,给出一些建议:
Redis虽然是单线程(新版本的Redis已经是多线程的啦),但是连续的Redis操作可不一定了哦。例子:先get一个key,再set它,在并发情况下,结果可不一定是你想要的啦。
- 如果是数字的话,可以使用Redis的incr/decr这种连续操作的方法。
- 其他类型的话,可以使用Lua脚本一并发送命令,特殊语言如Java,可以用自己的锁来锁住代码块。
使用并发锁一定要注意死锁的问题,不管什么情况,都要及时释放锁,否则万一出现死锁问题,那就是重大事故!
好了,就说这么多了,希望对你有所帮助。
本文转载自: 掘金