秒杀无异于一场自找的DDoS攻击,从这个角度来说:玩秒杀的电子商务网站,和那些不停喊着用力打我的受虐狂没有什么两样,因为他们都痛并快乐着。
在「中国数据库技术大会」上,淘宝分享了「秒杀场景下MySQL的低效」,详细分析了秒杀的技术难点及改进措施,简而言之,主要就是在高并发事务请求的情况下,数据库性能由于死锁检测等因素直线下降,在这种场景下,单纯的关闭死锁检测虽然可以提升一定的性能,但这顶多是治标而已,如何治本?淘宝给出来两个改进方法:
- 请求排队:如果请求一股脑的涌入数据库,势必会由于争抢资源造成性能下降,通过排队,让请求从混沌到有序,从而避免数据库在协调大量请求时过载。
- 请求合并:甲买了一个商品,乙也买了同一个商品,与其把甲乙当做当做单独的请求分别执行一次商品库存减一的操作,不如把他们合并后统一执行一次商品库存减二的操作,请求合并的越多,效率提升的就越大。
可惜的是淘宝的这些改进方法都是通过修改MySQL源代码在数据层实现的,对芸芸众生的我们而言,简直是一个无法逾越的技术门槛!那么是否可以在应用层实现呢?
请求排队
通过Redis实现队列是一件很简单的事情,使用LIST或者ZSET就可以搞定,如果没有优先级之类需求的话,通常LIST是一个更好的选择,因为它的时间复杂度更低,当然,如果处理队列的速度足够快,那么ZSET也不错。
BTW:关于Redis队列的介绍可以参考我以前写的「Redis消息通知系统的实现」。
把请求保存到队列里之后,可以通过Gearman实现Worker来消费队列,请求的生产和消费是异步的,所以不会出现并发拥堵,但是可能发生延迟,如果出现这种情况,可以通过增加Worker的数量可以加快消费队列的速度。
BTW:关于Gearman Worker的介绍可以参考我以前写的「管理Gearman」。
让我们从头捋捋:程序收到请求,然后把请求保存到Redis队列里,Gearman通过Worker处理队列里的请求,可是处理完之后如何通知程序呢?因为整个过程是异步的,所以除非程序支持某种形式的回调,否则很难通知。
最容易想到的解决办法是在程序里通过轮询来查询请求是否已经处理完成,但这无疑会增加数据库的负载,同时程序的实时性也会大打折扣。好在我们有其它的方法,比如说Redis提供了名为BLPOP和BRPOP的方法,它尝试从一个LIST里取元素,如果LIST为空则会堵塞连接,利用这个特性我们可以实现一个简易的通知功能:程序把请求保存到Redis队列里,然后调用BLPOP或BRPOP方法等通知,因为此时LIST为空,所以会堵塞连接,与此同时Gearman的Work处理完队列里的请求后,往LIST里保存一个状态码,程序感知到这个状态码,并通过状态码判断出请求是成功还是失败。
BTW:类似的,利用Redis的BRPOPLPUSH方法,还能实现一些有趣的功能。
整个过程中有一些需要注意的地方,比如说因为BLPOP和BRPOP都属于堵塞性质的操作,所以一旦队列处理速度跟不上,程序就会堆积大量连接,这可能会引起很多连锁问题:一方面可能导致内存不足,以PHP为例,一个连接通常占用10M左右,堆积一千个连接的话,10G内存就没有了;另一方面大量的连接可能耗尽端口资源,具体取决于内核参数「net.ipv4.ip_local_port_range」。此时提高处理队列的速度是唯一的出路。
请求合并
把类似的请求合并起来是一件既简单又复杂的事情,介于本文的标题是笨法玩秒杀,我们就挑简单的说,当我们通过Gearman的Work去处理队列里的请求时,通常是弹出一个请求处理一个请求,下面我们做出一些调整,每次不再只从队列里弹出一个请求,取而代之,我们一次性从队列里取出多个请求,然后在程序里完成合并后再执行。当然这里有很多细节问题,由于篇幅关系就不多说了。