让我们聊聊秒杀这东西
万事皆有因
这段似乎都成我写blog标准开头。言归正转,公司以前业务涉及到秒杀,并且是白天从10点起到晚上10点每小时一次(TT天天心惊肉跳的),周六还有个大礼包活动(重量级,经常会出一些你意想不到的事情,例如不活跃的用户突然间活跃了,量级飙升TT)。同时,最近随着创业的兴起,还是有很多人关注秒杀这技术怎么做。虽然很多NB的大厂(小米,淘宝,JD等)已经讲过这东西了,但是我还是想讲讲这件事情。下面我就说说一个小厂是如何做秒杀的。
小厂有多小,小厂有多大
后端只有2个研发工程师和2个前端工程师,当时还没有全职的运维,不过服务器的数量有40多台(还是挺多的)。用户量呢,下载和注册都在千万级别了,活跃也在百万级别。好了,小厂很小,但是小厂也很大。
初出茅庐
很多人感觉,敢用初出茅庐这标题,应该很牛吧,然而并没有。并且是意想不到的惨,惨不忍睹。第一个版本的秒杀系统,完全是依赖MySQL的事务,不言而喻,大家都会知道有多惨。我直接告诉大家结果就可以了:
-
整个系统在秒杀期间基本上停摆了,500和超时异常的多。
-
准备秒杀的产品数量是100,最后卖出去了400份。
我们来分析下为什么会这样:
-
MySQL本身能承载链接数量有限,在秒杀的时候大量的链接处在事务状态,且绝大部分事务是需要回滚的,这就造成了很大的IO压力和计算压力
-
那为什么会超卖呢,因为最开始使用的主从结构,读写是分离的,主库压力那么大,从库同步跟不上,造成了卖出去的产品在毫秒级内再查询结果看起来就是没卖出去。简而言之就是就是技术不熟悉导致设计失误。
初窥门径
出第一次事故的时候,说句心里话,对一个刚毕业1年的工程师还是挺蒙,然后就各种猜想。不过好在当时淘宝的一个人的blog上提了MySQL句事务的问题,算是找到方向了。然后就这样,秒杀活动就先暂停了一个星期,这个星期中我和同事都做了什么呢?
-
搭建了一个测试环境,模拟了下秒杀的情况,观察了MySQL的事务和主从的整体情况
-
修改秒杀流程
我先说下第一版的流程:
-
从用户数据库查询用户积分是否充足,从规则数据库中查询用户是否符合条件
-
从数据库中读出一个产品的ID
-
然后事务性的将产品ID和用户ID关联,减少用户积分和更新用户规则数据,更新产品ID的状态
那么问题就明显了,读产品ID的时候是没有事务的,这必然会存在问题的。那么我们是如何修改的呢?将读取产品ID这件事放入了整个事务中。那么整个流程就变成了:
-
从用户数据库查询用户积分是否充足,从规则数据库中查询用户是否符合条件
-
事务性的读出符合条件的产品,并立刻更新状态,接着完成用户ID和产品ID的关联及减少积分等工作
那这样还有问题吗?依然有,最后还是超卖了,大家会问为什么?这里面我们犯了另一个错误,使用代码判断产品的状态而非存储过程,这样即便是在数据库事务内,但没有可以触发数据库事务回滚的条件,所以还会错误的将卖出的产品再次更新为卖出的状态。经历两次惨痛的教训,我们才逐步的走上正轨,一个地方不会跌倒三次。
登堂入室
我们已经发现了很多问题,最后该怎么解决,我们决定先解决正确性,再解决速度的问题,我们使用了一段时间的存储过程加关键ID做成唯一主键的方式,整个秒杀流程的第二部分,就是个完整的存储过程(往事不堪回首,天天被用户骂非常慢)。这个时候唯一能做的就是补充理论知识,发奋图强了。
在这个第二个版本的设计中,我们开始采用Redis,我们测试了Redis的pubsub机制,最开始想使用Redis的pubsub进行排队(现在想想有点幼稚,但是老天帮了我一把,当时鬼使神差的就感觉这机制不靠谱)。但是最终的方案嗯,使用了正向队列。何为正向队列?我们将产品的ID在秒杀开始前,全部读入指定的队列中,秒杀流程就变成了:
-
判读Redis队列是否为0,为0结束
-
判读用户是否符合规则,是否有足够多的积分
-
从队列pop出一个产品ID,如果pop不出来就结束
-
开事务,改变产品ID的状态,关联用户ID和产品ID,更新规则和积分
这个时候基本上彻底解决了超卖和性能的问题了,但是还会有用户在骂,为什么?因为还不够快。
渐入佳境
我们发现为什么会慢,因为数据库的事务,回滚虽然少了,但是还是处理不过来,1s也就那100多个事务能完成,剩下的各种跟不上。此时此刻,我们直接采购了当时算是比较强劲的数据库服务器,事务量一下提高到了1000tps。但是这远远跟不上用户的增长速度(TT没业务也哭,有业务也哭)。
我们既然已经发现了排队理论这么有用,我们决定使用RabbitMQ,延迟处理队列。经过这次改造,我们秒杀的流程就变成了:
-
判断Redis队列是否为0,为0结束
-
判读用户是否符合规则,是否有足够积分
-
从队列pop出一个产品ID,如果pop不出来就结束
-
将用户ID和产品ID放入RabbitMQ中,后面的消费者慢慢的吞下去
这时候用户在速度上算是基本满意了,不过却带来了新的问题。判断用户是否符合规则的时候,由于消费者慢慢的消化而数据库没有实时的更新,导致一个用户可以秒杀多个商品,很多用户就不满意了(TT用户是上帝)。
略有小成
我们再次拿出了强大的Redis,我们将Redis当作缓存。我们把秒杀的业务逻辑直接变成了这样:
-
先判断Redis的队列是否为0,为0结束
-
判断Redis中用户的信息是否符合规则,积分是否符合规则
-
从队列pop出一个产品ID,如果pop不出来就立刻结束
-
立刻更新Redis中用户的缓存信息和积分信息,再放入RabbitMQ,让消费者消费
这样看起起来似乎没什么问题了,但是还是存在问题的,就是pop出产品ID到更新Redis用户信息的一瞬间还是能让部分用户钻空子的,毕竟Redis没有MySQL那种强事务机制。
心领神会
在这个阶段,我们用Erlang的mnesia写了一个Redis特定功能替代品,但使用了段时间很快放弃了,因为我们找到了更好的解决方式。让RabbitMQ的消费者使用一致性的hash,那么特定的用户一定会落到特定的消费者身上,消费者做去重判断。这样减少了,我们自己维护基础软件的成本(2个后端工程师TT,别瞎折腾)。
随心所欲
当我们的用户量逐步上升,系统依然出现吃紧和性能跟不上的阶段。
这个时候,我们大量使用一致性Hash和随机算法,其中过程就变成了。
-
将秒杀的产品ID分成多个队列放在Redis集群上,然后将一个产品总数量放在一个Redis上(这个Redis是瓶颈,但是基本上20W的TPS满满的达到了)
-
为用户随机一个数字,在一定范围内,直接告诉秒杀失败(纯看运气,纯丢给应用服务器去玩了)
-
检查用户规则和用户积分,还有产品总数量,总数量为0,直接结束。
-
为用户随机一个产品ID队列,尝试pop,pop不出数据,直接结束(还是看运气)
-
更新用户Redis的缓存和产品总数量的缓存(decr),然后交给RabbitMQ和消费者慢慢处理。
这个时候,基本上30wTPS,随便玩。
返璞归真
说了这么多废话,总结下吧。对于秒杀这种业务,优先保稳定和正确,最后才能保服务量。不稳定没得玩,不正确,很可能一单亏死。技术上,我个人认为小厂也能做看似很NB的秒杀只要用好以下几个相关技术:
-
削峰,不管是随机丢弃,还是多层筛选,尽可能减少进入核心业务的用户数
-
排队,在秒杀场景下,排队不单单可以减少系统压力,还能保证正确性
-
分区,使用分区可以降低一个节点当机带来整体性的损害或者雪崩性的系统不可用
-
最终一致,很多时候,不一定要强一致性,只要能保证最后数据的正确,哪怕是手工修复,都能带来大规模的性能提升