高并发下秒杀技术的实践案例

   从我们这次开发的接入滴滴打车红包的技术解决方案作为案例来进行分享,我们的功能需求很简单,在APP上,我们可以让用户看到自己喜欢的房子,然后选择滴滴打车叫专车去看房。当然活动名额每天是有限制的,并不是所有人都有资格能使用,而且对金额也是有限制的。
   对这个业务进行分析之后,我们可以快速提取两个部分,一个是让用户先抢到叫车的资格,其次是让有资格的用户才可以去完成叫车过程。叫车的部分主要是使获得了叫车资格的用户,可以通过接口能够跟滴滴打车接口进行交互实现叫车目的,这一块耗时费力,但是操作并不频繁,因为有叫车资格的人就是很少部分的人。将这整个业务分成两块,对业务处理,以及系统资源分配,都有很大的好处。
   其中第一个部分,最主要的工作是向APP端提供一个秒杀的接口,告之用户是否抢成功。
   有了这一个认识之后,我们就可以构造出我们的技术解决方案模型了,这个模型就是,实现用户秒杀的功能模块。先思考秒杀通常的解决方案模型:
   1)采用并发锁策略,先考虑使用悲观锁,这种机制是在某一个时间片,对资源加锁,确保这一个时间片中只有一个用户才能访问到该资源。这种方案简单易行,在并发量不是太大,尤其是资源只有唯一的时候,是最为合适。缺点是,要避免死锁的陷阱,在并发量更大的情况下会显得更慢。还有一种乐观锁机制,就是允许每个人都能抢,至于最后所抢到的结果是否有效就不一定了。
   2)采用异步队列FIFO,这是将用户的请求全部写入到队列中,而不马上处理,后台守候进程进行异步处理。这种方式可以解决更大并发量的场景,相对应的,技术实现成本也较高。需要一个长期的技术积累。
这是两种常用的技术解决方案模型,各有优缺点,从现实的角度上来说,这两种技术解决方案可能都会在一个项目中被采用,因此在代码开发中时,我们需要考虑我们开发时的设计模式。


       能够确保在不同的场景中快速切换这两种方案,刚开始,我们预估我们的业务量不会太大,但是尽管如此也很难确保没有万一的情况,尤其是在移动互联网端,突然的爆发是很有可能出现的。因此,在敏捷开发的原则下,我们先实现第一个方案,同时在上线之后可以逐步的实现第二个方案。一旦出现压力足够大的情况下,自动切换到第二个方案。
所以这里,我采用策略模式来进行编程。策略模式就是,定义统一的策略接口,在切换时,只需要切换不同的策略类就可以了。对代码的重构最小,这是现实中最佳的解决方案。
下面是策略模式的类图关系




我们继续分析,用户抢购的条件,并非每一个用户都资格进行抢购,大致可以罗列出一下几条:
       1)合法的用户才能参加,非登录用户是不可能参加的。暂时没有实行黑名单制度,否则进入黑名单的用户也是不能参与的。
       2)已经抢到名额的用户是不能再抢了。
       3)每天的限额满额了,也是无法继续再抢了。
       
       以上这三条,或许随着业务的调整可能还会增加,而且以上任何一条无法满足都会终止继续执行的资格,因此我们可以采取拦截器模式,任何一个条件都无法满足的情况下,通过跑出异常来直接终端整个流程。


以上可以总结下,在代码编程中我们采用拦截器和策略模式来进行我们的编程。一旦设计好我们的开发模型之后,我们需要开始实现下一个重要的一层,我们应该考虑的不是去直接coding,而是思考更深层的一个问题,就是我们整个模块的数据模型。我们的秒杀模块中,设计的核心数据结构是什么呢?我们先考虑下,我们整个过程中,我们需要记录的数据有:
       1)每天秒杀的限额;使用sprintf('ananzu:didi:quato:number:%s',date('Ymd))来表示key,对应值为int类型的数量值。
       2)记录每一个已经抢到名额的用户;可以使用单独的key来记录,sprintf('ananzu:didi:uid:%d',$uid),初始值为0,表示用户已经抢到但是未使用,如果为1表示已经使用过。

       设计好我们要使用的数据结构了,剩下的工作就是,先考虑使用什么样的数据存储工具,显然这里最优的方案之一是采取redis来进行存储。读写性能都超强,也支持相应的数据结构,用来存储热数据,小数据非常的方便和高性能。
在控制器中,我们的代码片段如下:
/**
 * 用户进行秒杀
 * 使用并发锁机制处理方案
 */
public function doSecKill($uid)
{
    // 进行秒杀操作
    $redis = self::redis();
    if ($redis->setNx(self::DIDI_SECKILL_LOCK_KEY, self::DIDI_SECKILL_LOCK_VALUE)) {
        $redis->expire(self::DIDI_SECKILL_LOCK_KEY, 1);
        try {
            if (!self::hasSystemQuotaToday()) {
                throw new Exception('今天资格已经抢完!', 404);
            }
            $redis->multi(Redis::MULTI)
                  ->incrBy(self::quotaNumberPerDayKey(), self::INCR_QUOTA_NUMBER)
                  ->set(self::getUserTicketKey($uid), self::HAS_INIT_TICKET_VALUE)
                  ->delete(self::DIDI_SECKILL_LOCK_KEY)// 最后执行解锁
                  ->exec();
            return true;
        } catch (Exception $e) {
            $redis->delete(self::DIDI_SECKILL_LOCK_KEY);
        }
    }
    return false;
}

代码片段解释:
       new HasTicketCantSeckillFilter(),new HasQuotaTodayFilter()都是拦截器,相互独立,一旦出现不符合要求,则抛出异常中断后续的执行。而$callCarRequest->doSecKill(new SecKillStrategy())所采用的是策略模式,根据需要可以随时替换SecKillStrategy策略类,而不需要修改其他代码,大大提升了代码的服用和扩展。


       应对高并发状况下,技术重点之一就是关于分布式锁的技术使用。
       先谈谈锁的来源种类,一种常见的是文件锁,使用flock函数来操作,还有一种是数据库的锁,使用MYSQL的锁语句,例如LOCK TABLE,以及事务,我们现在最常用的就是内存锁,典型的就是redis的应用,因为可以实现分布式锁。
我们重点谈谈redis锁的使用以及死锁的解决方案。
       以下是涉及到的命令
       SETNX命令(SET if Not eXists)
       语法:
       SETNX key value
       功能:
       当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。
       GETSET命令
       语法:
       GETSET key value
       功能:
       将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。
       GET命令
       语法:
       GET key
       功能:
       返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。
       DEL命令
       语法:
       DEL key [KEY …]
       功能:
       删除给定的一个或多个 key ,不存在的 key 会被忽略。
       使用redis的锁,关键命令是setNx,只有成功获得锁的用户才能设置key,并且返回1提示成功,用这个命令来进行上锁的操作,但是首先必须考虑到死锁的问题。考虑解决死锁方法有两种,第一种方法设置锁的失效时间,使用expire设置失效秒数;第二种方法,通过setNx设置key的value值为当前的时间戳,这样下一个进程访问的时候,对照下时间戳就可以主动删除锁。这两种方式各有优缺点,前一种方式相对简单,后一种方式相对复杂,有陷阱需要处理,否则很容易出现多个进程抢到锁的情况。
/**
 * 用户进行秒杀
 * 使用并发锁机制处理方案
 */
public function doSecKill($uid)
{
    // 进行秒杀操作
    $redis = self::redis();
    if ($redis->setNx(self::DIDI_SECKILL_LOCK_KEY, self::DIDI_SECKILL_LOCK_VALUE)) {
        $redis->expire(self::DIDI_SECKILL_LOCK_KEY, 1);
        try {
            if (!self::hasSystemQuotaToday()) {
                throw new Exception('今天资格已经抢完!', 404);
            }
            $redis->multi(Redis::MULTI)
                  ->incrBy(self::quotaNumberPerDayKey(), self::INCR_QUOTA_NUMBER)
                  ->set(self::getUserTicketKey($uid), self::HAS_INIT_TICKET_VALUE)
                  ->delete(self::DIDI_SECKILL_LOCK_KEY)// 最后执行解锁
                  ->exec();
            return true;
        } catch (Exception $e) {
            $redis->delete(self::DIDI_SECKILL_LOCK_KEY);
        }
    }
    return false;
}
       如果采取第二种方式来避免死锁的情况,则需要用到GETSET命令,来获取锁的时间戳,这样避免多个进程读取到被修改过的时间戳,导致多个进程都删除其他进程创建的锁,然后新建锁,最后造成并发修改数据的情况,出现超卖的情况出现。

       最后提下乐观锁的方式,所谓乐观锁的方式,是允许用户先抢,每个进程都可以抢,但是先抢的是一个号,类似医院看病先叫好,然后去排队。这个号类似一个版本号,先每个进程都获取一个数据的版本号,然后最后等去更新数据的时候,查看版本号是否有变化,如果有变化则更新失败,通过这种方式来保证不冲突。这关键点是用到redis的watch和multi命令,watch是关注指定key是否变化,如果变化则所对应的事务操作失败,回滚。
 以上就是分布式锁的技术原理,使用内存分布式锁能够很好的解决分布式部署的环境对锁的要求,也同时满足了对性能的要求,因此在现在的移动端系统普遍被应用。redis适合做小数据的高性能的读写,也可以做数据的持久化,比memcache更适合存储比较复杂的数据结构。

这次开发的思考点:
       1)理清业务流程,时序图是一个很好的工具,理清了业务流程之后,能够有效的帮助我们选择合适的解决方案,采用何种设计模式来优化我们的代码。
       过去我们总有一个误区,就是觉得业务时间紧急,然后习惯写类似一锅粥一样的代码,觉得这种是最快的,其实单从写代码角度来说,这种其实并不快,反而是最慢的一种方式,因为随后的功能测试,不停地在代码里面,加print_r,exit来打断点。把代码弄得更加混乱。不得不需要不断地加班,来做很多无谓的调试。其次是,日后的维护成本更高,一个问题查找,需要翻遍整个代码甚至要print_r去调试才能找到问题,或者某个配置是什么意义,某个变量当初写的原因。而我们经常给的理由之一,是先实现功能,然后以后可以重构,实际上这种情况下,重构几乎是不可能的,也不会有人去重构,因为一重构就相当于重新写一遍,而时过境迁谁还记得当初有什么需求点。更何况,其他人接受,随便改一点就会爆莫名其妙的错误,那谁还愿意去重构这些代码呢?
实际上真正的成本是心智的成本,也是学习的成本,因为从代码角度上,其实这些方案早就存在,甚至有的时候无非是copy and paste而已。写代码的时间其实是分分钟的时间,两者也差不了太多,甚至后一种采用规范化,模式化的代码结构,恰恰是最快速的方式。
       2)考虑软件的层次,先思考和整理业务流程,然后再更深入思考,核心的数据层结构的事情。用户在进行秒杀请求的时候,会产生消息的传递,最终系统会记录下核心的数据。通过抽象,分析出核心的数据存储,能够很清晰的检查到系统的核心问题所在,比如秒杀中超卖的问题,核心数据的读写保护,使用何种有效地数据结构来保存和处理数据,开发出数据通用接口,也可以优化你的数据核心操作代码。确保无论业务如何修改变化,核心数据层代码基本保持不变。在性能测试时,数据层代码也可以单独进行测试,跟其他层代码毫无关系,可以完全独立测试。
       3)勤写日志,开发中不仅仅只是写实现功能的代码而已,还要写入大量的日志,来确保其他人可以读懂你的业务逻辑,尤其是自己可以随时随地的查看,跟踪自己的业务处理状况,随时发现问题所在。测试人员是无法在外部,测试到内部代码的数据流向,代码质量,以及可能的隐藏bug。所以一个高质量,合格的代码,必须包含大量的日志。尤其是对现代软件开发工程来说,这是一个标配,也是一个必须品。任何人都不应该只是简单实现了一个功能,而不愿意花时间去思考自己的代码逻辑,数据走向,如何跟踪记录这个业务的痕迹,协商有效的日志流。通常代码中要有三分之一以上是日志部分的代码。
       4)使用异常的好习惯。这里需要调整我们一个写代码的实现功能的思维路径的问题,理论上,我们要做的是最短路径就是,正常的最简单的实现用户的合法请求,假设用户都是合法合规按照正常的流程请求实现最核心的需求。例如,用户写表单注册用户,最短的路径是,用户填写的所有的结果都是合法的,这样提交之后,直接处理保存到数据库,并提示成功。所以,在控制其中,按照这种最简单的方式写代码,会发现,其实只要两三行就结束了。但是,相信大家都知道,这几乎是理想状态,实际上是不可能的。我们在开发中,一般都是先会考虑各种非法情况,写一大堆的if esle来做判断。最后结果是把代码弄成一锅粥,不好维护也不好跟踪可能错误。使得开发成本提升,而异常的出现,大大的提升我们开发的效率,使得我们的开发思维变得更加简单。
首先明确什么是异常,异常不是错误,我们通常认为的错误,是语法错误,程序本身的错误。这种错误是必须在开发过程中就需要彻底解决的,而异常主要是指因为用户的错误操作,导致的程序流程的中断,这是不可控的。因此对出现异常中断,只需要抛出异常然后通知终端异常的原因就可以了。
       使用异常的方式,好处是,可以在内层直接终端程序运行,而外层业务代码的开发,按照正常执行流程去设计,而不需要判断内层代码的结果,通过大量的if else 判断来处理结果,而是架设一切正常照常执行。并且代码也变得非常的简洁,在终端通过try catch来统一捕获异常,然后进一步的处理异常结果。

 
       最后来一个彩蛋,解释下秒杀的相关术语,其中提下什么是惊群效应。从技术上来说,秒杀实际上是惊群效应最典型发生的场景。先解释什么是惊群效应,最经典的一个场景解释就是在广场上面喂鸽子,原先平静的广场,一旦有人喂鸽子的时候,一小块食物就会引起一大群的鸽子的骚动。简单深入描述下,在技术场景下,平时的流量,对服务器的资源也足够用,但是一旦出现业务井喷的时候,会在瞬时出现流量的高峰,消耗所有的服务器资源,导致整个系统瘫痪。惊群效应带来的负面影响是,在损失消耗光服务器资源,影响系统的运行,如果采用购买更多的服务器的策略,却会在平时时候又产生大量闲置的资源。但是另外一方面,我们又会希望创造出这种效应,其中随着电商行业的迅猛发展,秒杀作为一个非常重要的营销手段,已经普遍被运用。秒杀就是一个人为的希望产生的一个惊群效应的案例,这是我们喜欢要的惊群效应,此外还有其他有害的惊群效应,比如缓存失效,导致瞬间请求穿透后层,导致数据库崩溃等。这些是我们必须避免的情况。处理惊群效应是高并发场景下开发必须要考虑的基本情况之一。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值