前言

如今在电商行业里,秒杀抢购活动已经是商家常用促销手段。但是库存数量有限,而同时下单人数超过了库存量,就会导致商品超卖甚至库存变负数的问题。又比如:抢购火车票、论坛抢楼、抽奖乃至爆红微博评论等也会引发阻塞式高并发问题。如果不做任何措施可能在高瞬间造成服务器瘫痪,如何解决这个问题呢?

方案一:使用消息队列来实现

可以基于例如MemcacheQ等这样的消息队列,具体的实现方案这么表述吧。比如有100张票可供用户抢,那么就可以把这100张票放到缓存中,读写时不要加锁。当并发量大的时候,可能有500人左右抢票成功,这样对于500后面的请求可以直接转到活动结束的静态页面。进去的500个人中有400个人是不可能获得商品的。所以可以根据进入队列的先后顺序只能前100个人购买成功。后面400个人就直接转到活动结束页面。当然进去500个人只是举个例子,至于多少可以自己调整。而活动结束页面一定要用静态页面,不要用数据库。这样就减轻了数据库的压力。

方案二:当有多台服务器时,可以采用分流的形式实现

假设有m张票,有n台产品服务器接收请求,有x个请求路由服务器随机转发:

  • 直接给每台产品服务器分配 m/n张票
  • 每台产品服务器内存做计数器,比如允许m/n*(1+0.1)个人进来
  • 当内存计数器已满时,后面进的人直接跳到活动结束的静态页面
  • 通知路由服务器不再路由到这台服务器
  • 所有产品服务器进来的m/n*(1+0.1)个人再全部转发到一台付款服务器上,进入付款环节,看谁手快了,这时候人少,加锁什么的就简单的

方案三:单服务器使用Memcache锁来实现

当product_key存在于memcached中时,所有用户都可以进入下单流程。当进入支付流程时,首先往memcached存放add(product_lock_key, "1"),如果返回成功,进入支付流程。如果不成,则说明已经有人进入支付流程,则线程等待N秒,递归执行add操作。

方案四:借助文件排他锁

在处理下单请求的时候,用flock锁定一个文件,如果锁定失败说明有其他订单正在处理,此时要么等待要么直接提示用户"服务器繁忙"。

阻塞(等待)模式:

<?php
$fp = fopen("lock.txt", "w+");
if(flock($fp, LOCK_EX))
{
    // 处理订单
    flock($fp, LOCK_UN);
}
fclose($fp);
?>

非阻塞模式:

<?php
$fp = fopen("lock.txt", "w+");
if(flock($fp, LOCK_EX | LOCK_NB))
{
    // 处理订单
    flock($fp, LOCK_UN);
}
else
{
    echo "系统繁忙,请稍后再试";
}
fclose($fp);
?>

大规模并发带来的挑战

在电商的秒杀和抢购场景中,对一个Web系统是巨大的考验。当一个Web系统在一秒钟内收到数以万计甚至更多请求时,系统的优化和稳定至关重要。

1. 请求接口的合理设计

一个秒杀或者抢购页面,通常分为2个部分,一个是静态的HTML等内容,另一个就是参与秒杀的Web后台请求接口。通常静态HTML等内容,是通过CDN的部署,一般压力不大,核心瓶颈实际上在后台请求接口上。这个后端接口必须能够支持高并发请求,同时必须尽可能"快",在最短的时间里返回用户的请求结果。为了实现尽可能快这一点,接口的后端存储使用内存级别的操作会更好一点。仍然直接面向MySQL之类的存储是不合适的,如果有这种复杂业务的需求,都建议采用异步写入。

2. 高并发的挑战:一定要"快"

我们通常衡量一个Web系统的吞吐率的指标是QPS(Query Per Second,每秒处理请求数),解决每秒数万次的高并发场景,这个指标非常关键。举个例子,我们假设处理一个业务请求平均响应时间为100ms,同时系统内有20台Apache的Web服务器,配置MaxClients为500个(表示Apache的最大连接数目)。那么我们的Web系统的理论峰值QPS为(理想化的计算方式):

20 * 500 / 0.1 = 100000(10万QPS)

实际情况没有这么理想。在高并发的实际场景下,机器都处于高负载的状态,在这个时候平均响应时间会被大大增加。就Web服务器而言,Apache打开了越多的连接进程,CPU需要处理的上下文切换也越多,额外增加了CPU的消耗,然后就直接导致平均响应时间增加。

假设我们的系统在5w/s的高并发状态下,平均响应时间从100ms变为250ms(实际情况甚至更多):

20 * 500 / 0.25 = 40000(4万QPS)

系统剩下了4w的QPS,面对5w每秒的请求,中间相差了1w。这就好比高速路口,1秒钟来5部车,每秒通过5部车,高速路口运作正常。突然这个路口1秒钟只能通过4部车,车流量仍然依旧,结果必定出现大塞车。

3. 重启与过载保护

如果系统发生"雪崩",贸然重启服务是无法解决问题的。最常见的现象是启动起来后立刻挂掉。这个时候最好在入口层将流量拒绝,然后再重启。如果是redis/memcache这种服务也挂了,重启的时候需要注意"预热",并且很可能需要比较长的时间。秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候过载保护是必要的,如果检测到系统满负载状态,拒绝请求也是一种保护措施。

作弊的手段:进攻与防守

1. 同一个账号,一次性发出多个请求

部分用户通过浏览器的插件或者其他工具,在秒杀开始的时间里,以自己的账号一次发送上百甚至更多的请求。应对方案:在程序入口处,一个账号只允许接受1个请求,其他请求过滤。可以通过Redis这种内存缓存服务,写入一个标志位(只允许1个请求写成功,结合watch的乐观锁的特性),成功写入的则可以继续参加。

2. 多个账号,一次性发送多个请求

通过编写自动注册脚本积累了一大批"僵尸账号",专门做各种刷的行为。应对方案:通过检测指定机器IP请求频率,如果发现某个IP请求频率很高,可以给它弹出验证码或者直接禁止它的请求。

3. 多个账号,不同IP发送不同请求

所谓道高一尺,魔高一丈。这些工作室发现你对单机IP请求频率有控制之后,他们会不断改变IP。应对方案:通常只能通过设置业务门槛高来限制这种请求,或者通过账号行为的"数据挖掘"来提前清理掉它们。

4. 火车票的抢购

高级的黄牛刷票时使用真实的人搭建中转软件服务,真人浏览图片并填写验证码返回给中转软件,验证码的保护限制作用被废除。解决方案:并没有很好的解决方案,唯一可以动心思的也许是对账号数据进行"数据挖掘",将这些黄牛账号分析出来再做进一步处理和甄别。

高并发下的数据安全

我们知道在多线程写入同一个文件的时候,会出现"线程安全"的问题。在秒杀和抢购的场景中,还有另外一个问题就是"超发"。

1. 超发的原因

假设某个抢购场景中,我们一共只有100个商品,在最后一刻我们已经消耗了99个商品,仅剩最后一个。这个时候系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。

2. 悲观锁思路

悲观锁也就是在修改数据的时候采用锁定状态,排斥外部请求的修改。遇到加锁的状态就必须等待。虽然上述方案解决了线程安全问题,但别忘了场景是"高并发"。每个请求都需要等待锁,某些线程可能永远没有机会抢到这个锁,这种请求就会死在那里。同时这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。

3. FIFO队列思路

直接将请求放入队列中,采用FIFO(First Input First Output,先进先出),不会导致某些请求永远获取不到锁。虽然解决了锁的问题,但新的问题是高并发场景下很可能一瞬间将队列内存"撑爆",系统又陷入异常。或者说设计一个极大的内存队列,但系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比,队列内的请求会越积累越多,最终Web系统平均响应时间还是会大幅下降。

4. 乐观锁思路

乐观锁是相对于"悲观锁"采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是:这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样就不用担心队列的问题了,不过会增大CPU的计算开销。综合来说,这是一个比较好的解决方案。有很多软件和服务都支持"乐观锁"功能,例如Redis中的watch就是其中之一。

总结

互联网正在高速发展,使用互联网服务的用户越多,高并发的场景也变得越来越多。电商秒杀和抢购是两个比较典型的互联网高并发场景。虽然我们解决问题的具体技术方案可能千差万别,但是遇到的挑战却是相似的,因此解决问题的思路也异曲同工。

在实际项目中,我们需要根据自身的业务场景、服务器规模和技术栈选择合适的方案。对于中小型项目,文件排他锁和乐观锁是不错的起点;对于大型分布式系统,消息队列和服务化架构则是更优的选择。关键在于理解各种方案的原理和适用场景,做到因地制宜。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部