前言如何设计高并发下的闪购系统?这是一道高频面试题。这个问题看似简单,其实里面的水很深。它在高并发场景下从前端到后端检查知识。秒杀一般出现在商场的促销活动中,以极低的价格(例如:0.1元)指定一定数量(例如:10件)的产品(例如:手机),让大量用户参与在活动中,但只有少数用户能够购买成功。做这种活动的商家,大部分是不赚钱的。说白了,他们就是想找个噱头来宣传自己。虽然秒杀只是一种促销活动,但对技术的要求并不低。下面总结了设计秒杀系统需要注意的9个细节。1、瞬时高并发一般在闪购时间点前几分钟(比如:12点),并发用户量确实突然增加,到了闪购时间点,并发流量就会达到它的高峰。但由于这类活动是大量用户抢少量商品的场景,难免会出现狼多肉少的情况。所以,实际上绝大部分用户都会秒杀失败,只有极少数用户会成功。一般情况下,大部分用户都会收到商品已售罄的提醒。收到提醒后,大概率不会停留在那个活动页面。结果,并发用户数将急剧下降。所以这个峰值持续的时间其实很短,以至于会出现瞬间高并发的情况。我们用一张图来直观感受一下流量的变化:传统系统很难应对这种瞬时高并发的场景,我们需要设计一个全新的系统。可以从以下几个方面入手:静态页面CDN加速缓存mq异步处理限流分布式锁2.静态页面活跃页面是用户流量的第一入口,所以是并发量最大的地方。如果这些流量能够直接访问到服务器,恐怕服务器会因为承受不了这么大的压力而直接挂掉。活动页面的大部分内容是固定的,例如:商品名称、商品描述、图片等。为了减少不必要的服务器端请求,通常会对活动页面进行静态处理。用户浏览商品等日常操作不会向服务器请求。只有达到秒杀时间,用户主动点击秒杀按钮,才允许访问服务器。这会过滤掉大多数无效请求。但是仅仅让页面静态化是不够的,因为用户分布在全国各地,有的在北京,有的在成都,有的在深圳。地区相隔较远,网速不同。如何让用户尽快访问到活动页面?这就需要用到CDN,它代表ContentDeliveryNetwork,也就是内容分发网络。使用户就近获取想要的内容,减少网络拥塞,提高用户访问响应速度和命中率。3.秒杀按钮大多数用户怕错过秒杀时间,所以一般会提前进入活动页面。你此时看到的秒杀按钮是灰色的,无法点击。只有到达秒杀时间点的瞬间,秒杀按钮才会自动亮起,变为可点击状态。但这时候,很多用户已经等不及了。通过不断刷新页面,他们试图尽快看到闪光灯按钮亮起。从前面知道,活动页面是静态的。那么我们如何控制静态页面中的秒杀按钮,只在秒杀的时候亮起呢?是的,使用js文件控件。出于性能的考虑,一般会提前将css、js、图片等静态资源文件缓存在CDN上,方便用户就近访问秒杀页面。看到这里,有聪明的朋友可能会问:CDN上的js文件是怎么更新的?秒杀开始前,js标志为false,还有一个随机参数。当秒杀启动时,系统会生成一个新的js文件,此时flag为true,随机参数生成一个新的值,然后同步到CDN。因为这个随机参数,CDN不会缓存数据,每次都能从CDN获取最新的js代码。另外,前端还可以加一个定时器来控制,比如:10秒内,只允许一个请求。如果用户点击一次秒杀按钮,它会在10秒内变灰,并且不允许再点击。超过时间限制后,允许再次单击该按钮。4、多读少写在秒杀的过程中,系统一般会检查库存是否充足,充足才允许下单写入数据库。如果不够,直接返回商品已售罄。由于大量用户抢劫的是少量产品,只有极少数用户才能抢劫成功。所以,大部分用户在抢购的时候,其实库存是不够的,系统会直接返回商品被抢了。这是很典型的:读多写少。如果有几十万个请求,同时通过数据库查看缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:mysql,不能同时支持那么多的连接。相反,使用缓存,例如redis。即使使用redis,也需要部署多个节点。5、缓存问题通常我们需要在redis中保存商品信息,其中包括:商品id、商品名称、规格属性、库存等信息,相关信息也必须存储在数据库中。毕竟缓存也不是完全可靠的。当用户点击秒杀按钮请求秒杀接口时,需要传入商品ID参数,然后服务器需要验证商品是否合法。大致流程如下图所示:根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果该项不存在,则直接提示失败。这个过程表面上看还可以,但深入分析就会发现一些问题。5.1缓存分解比如产品A第一次销售时,缓存中没有数据,而数据库中有。虽然上面有逻辑,如果从数据库中查到数据,就会放入缓存。但是在高并发下,会同时出现大量的请求,都是在秒杀同一个产品。这些请求同时检查缓存中没有数据,然后同时访问数据库。结果是一场悲剧。数据库可能会承受不住压力,直接挂掉。如何解决这个问题呢?这个需要加锁,最好使用分布式锁。当然,在这种情况下,最好在项目启动前预热缓存。即提前将所有商品同步到缓存中,这样基本上可以直接从缓存中获取商品,不会出现缓存崩溃的问题。上面加锁的步骤是不是不需要了?表面上看,确实是大可不必。但是如果缓存中设置的过期时间不正确,缓存提前过期,或者缓存被不小心删除,不加速也可能会导致缓存崩溃。其实这里加一把锁就相当于买了一份保险。5.2缓存穿透如果有大量传入的产品ID在缓存或数据库中不存在,这些请求每次都会经过缓存,直接访问数据库。由于前面加了锁,即使这里的并发量再大,也不会直接导致数据库挂掉。但是显然这些请求的处理性能并不好。有更好的解决方案吗?这时候就可以想到布隆过滤器。系统根据产品id,先从Bloomfilter中检查id是否存在。如果存在,则允许从缓存中查询数据。如果不存在,则直接返回失败。这种方案虽然可以解决缓存穿透的问题,但是会带来另一个问题:布隆过滤器中的数据如何才能与缓存中的数据更加一致?这就要求如果缓存中的数据有更新,必须同步到Bloomfilter。如果数据同步失败,需要增加重试机制,跨数据源能否保证数据的实时一致性?很明显不是。因此布隆过滤器多用于缓存数据很少更新的场景。如果缓存数据更新的很频繁,怎么处理呢?这时候就需要缓存不存在的productid。下次如果有请求商品id,也可以从缓存中查到数据,但是数据比较特殊,说明该商品不存在。需要注意的是,这个特殊缓存设置的超时时间应该越短越好。6、库存问题库存问题看似简单,其实还是有一些东西在里面的。在真正的闪杀场景中,并不是说库存一扣就完了。如果用户在一定时间内没有完成支付,则必须将扣除的存货补回来。所以这里引入一个预扣库存的概念。预扣库存的主要流程如下:除了上述的预扣库存和退货外,还要特别注意库存不足和库存超卖的问题。6.1数据库推算存货利用数据库推算存货是最简单的实施方案。假设扣除库存的sql如下:updateproductsetstock=stock-1whereid=123;这样写对于扣库存没有问题,但是怎么控制在库存不足的情况下,不让用户操作呢?这就需要在更新前检查库存是否充足。伪代码如下:intstock=mapper.getStockById(123);if(stock>0){intcount=mapper.updateStock(123);if(count>0){addOrder(123);}}你有没有发现这个代码问题?是的,查询操作和更新操作不是原子的,在并发场景下会导致库存超卖。可能有人会说,这个好办,加个锁就搞定了,比如用synchronized关键字。是的,你可以,但性能还不够好。还有一种更优雅的方案,就是基于数据库的乐观锁,这样会省去一次数据库查询,自然可以保证数据操作的原子性。稍微调整一下上面的sql:updateproductsetstock=stock-1whereid=productandstock>0;在sql末尾添加:stock>0,保证不会超卖。但是数据库需要经常访问,我们都知道数据库连接是非常昂贵的资源。在高并发场景下,可能会造成系统雪崩。而且,多个请求很容易同时竞争行锁,导致彼此等待,从而产生死锁问题。6.2Redis扣除库存redis的incr方法是原子的,可以使用该方法来扣除库存。伪代码如下:booleanexist=redisClient.query(productId,userId);if(exist){return-1;}intstock=redisClient.queryStock(productId);if(stock<=0){return0;}redisClient.incrby(productId,-1);redisClient.add(productId,userId);return1;代码流程如下:首先判断用户是否秒杀了商品,如果是则直接返回-1。查询库存,如果库存小于等于0,则直接返回0,说明库存不足。如果库存充足,则会扣除库存,然后保存本次闪购的记录。然后返回1,表示成功。估计一开始很多小伙伴都会这样写代码。但是仔细想想,你会发现这段代码有问题。有什么问题?如果高并发下同时有多个查询库存的请求,此时都大于0。由于查询库存和更新库存的无原则操作,会出现库存为负的情况,即库存超卖。当然,可能有人会说,加个synchronized不就解决问题了吗?调整后的代码如下:booleanexist=redisClient.query(productId,userId);if(exist){return-1;}synchronized(this){intstock=redisClient.queryStock(productId);if(stock<=0){return0;}redisClient.incrby(productId,-1);redisClient.add(productId,userId);}return1;加入synchronized确实可以解决负库存问题,但是这样会导致界面性能急剧下降,而且每个查询都需要竞争同一个锁,这显然是不合理的。为了解决上述问题,代码优化如下:booleanexist=redisClient.query(productId,userId);if(exist){return-1;}if(redisClient.incrby(productId,-1)<0){return0;}redisClient.add(productId,userId);返回1;代码主要流程如下:首先判断用户是否闪杀了商品,如果是则直接返回-1。扣除库存,判断返回值是否小于0,小于0则直接返回0,说明库存不足。如果扣除库存后返回值大于等于0,则保存本次秒杀的记录。然后返回1,表示成功。乍一看,程序似乎没有问题。但是在高并发场景下如果同时有多个扣库存的请求,大部分请求的incrby操作结果都会小于0,虽然库存为负,但是不会出现超卖的问题。但是,由于这是预减库存,如果负值太负,后面要退回库存,库存就会不准确。那么,有更好的解决方案吗?6.3Lua脚本扣库存我们都知道lua脚本可以保证原子性,与redis配合使用可以完美解决以上问题。lua脚本有一段很经典的代码:StringBuilderlua=newStringBuilder();lua.append("if(redis.call('exists',KEYS[1])==1)then");lua.append("localstock=tonumber(redis.call('get',KEYS[1]));");lua.append("if(stock==-1)then");lua.append("return1;");lua.append("end;");lua.append("if(stock>0)then");lua.append("redis.call('incrby',KEYS[1],-1);");lua.append("returnstock;");lua.append("end;");lua.append("return0;");lua.append("end;");lua.append("return-1;");代码主要流程如下:首先判断商品id是否存在,不存在直接返回。获取商品id的库存,如果库存为-1则直接返回,说明库存没有限制。如果存货大于0,则扣除存货。如果库存等于0,则直接退回,说明库存不足。7、分布式锁前面提到过,闪杀的时候,需要先检查缓存中是否存在该产品,如果不存在,则从数据库中检查该产品。如果在数据库中,则将产品放入缓存中并返回。如果数据库中不存在,则直接返回失败。试想一下,如果在高并发下,有大量的请求去查询缓存中不存在的商品,这些请求会直接发送到数据库。数据库承受不住压力直接挂了。那么如何解决这个问题呢?这就需要使用redis分布式锁。7.1setNx锁在使用redis的分布式锁时,首先想到的就是setNx命令。if(jedis.setnx(lockKey,val)==1){jedis.expire(lockKey,timeout);}使用这个命令其实是可以加锁的,但是和后面的超时设置是分开的,不是原子操作。如果加锁成功,但超时设置失败,则lockKey永不过期。在高并发场景下,这个问题会导致非常严重的后果。那么,有没有保证原子性的加锁命令呢?7.2setlocking使用redis的set命令,可以指定多个参数。Stringresult=jedis.set(lockKey,requestId,"NX","PX",expireTime);if("OK".equals(result)){returntrue;}returnfalse;其中:lockKey:锁标识requestId:请求idNX:只有当key不存在时才设置key。PX:设置key的过期时间为millisecond毫秒。expireTime:过期时间由于这个命令只有一步,所以是一个原子操作。7.3释放锁接下来可能有朋友会问:在加锁的时候,既然已经有了lockKey锁标识,为什么还要记录requestId呢?答:requestId是在释放锁的时候使用的。如果(jedis.get(lockKey).equals(requestId)){jedis.del(lockKey);returntrue;}returnfalse;释放锁时,只能释放自己加的锁,不允许释放别人加的锁。这里为什么要用requestId?userId不能用吗?答:如果使用userId,则假定请求过程完成,准备删除锁。此时重合锁到期即失效。而另一个请求,巧合地使用了相同的userId来锁定,就会成功。这次请求删除锁的时候,实际删除的是别人的锁。当然,使用lua脚本也可以避免这个问题:ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end可以保证查询是否成功锁存在和删除锁是原子操作。7.4自旋锁上面的加锁方式貌似没什么问题,但是仔细想想,如果同时有10000个请求竞争锁,可能只有一个请求成功,剩下的9999个请求都会失败。秒杀场景有什么问题?答:每10000个请求,1个成功。对于另外10,000个请求,1个成功。依此类推,直到库存用完。这就变成了一个均匀分布的尖峰,跟我们想象的不一样。如何解决这个问题呢?答:使用自旋锁。try{Longstart=System.currentTimeMillis();while(true){Stringresult=jedis.set(lockKey,requestId,"NX","PX",expireTime);if("OK".equals(result)){returntrue;}longtime=System.currentTimeMillis()-start;if(time>=timeout){returnfalse;}try{Thread.sleep(50);}catch(InterruptedExceptione){e.printStackTrace();}}}finally{unlock(lockKey,requestId);}返回假;在指定时间内,比如500毫秒,spinner不断尝试加锁,成功则直接返回。如果失败,休眠50毫秒并开始新一轮的尝试。如果超时,还没有成功获取到锁,则直接返回失败。7.5redisson使用redis分布式锁除了上述问题外,还有锁竞争问题,更新问题,锁重入问题,多个redis实例锁定问题等,这些问题使用redisson都可以解决。限于篇幅,这里先保留一点悬念,有问题可以私聊。后面会出专题介绍分布式锁,敬请期待。8、MQ异步处理我们都知道真正的秒杀场景有3个核心进程:3个核心进程中,真正并发的是秒杀功能,下单和支付功能的实际并发量很小。所以我们在设计秒杀系统的时候,需要把下单和支付功能从秒杀的主进程中分离出来,尤其是下单功能要用mq异步处理。支付功能,比如支付宝支付,是异步的,由业务场景自己保证。所以,秒杀后下单的流程就变成了:如果使用mq,需要注意以下问题:8.1消息丢失问题秒杀成功,但是向mq发送订单消息时,可能失败。原因有很多,比如:网络问题、broker挂了、mq服务器磁盘问题等等,这些情况下都可能导致消息丢失。那么,如何防止消息丢失呢?答:添加消息发送表。生产者发送mq消息前,先将消息写入消息发送表,初始状态为待处理,然后发送mq消息。消费者在消费消息时,处理完业务逻辑后,回调生产者的一个接口,将消息状态修改为已处理。如果生产者将消息写入消息发送表后向mq服务器发送消息失败,则消息丢失。这个时候,怎么处理呢?答:使用job并添加重试机制。每隔一段时间使用job查询消息发送表中的pending数据,然后重新发送mq消息。8.2重复消费问题本来消费者在消费一条消息的时候,如果在应答ack的时候网络超时,那么消费者本身就可能消费一条重复的消息。但是由于消息发送方增加了重试机制,消费者重复消息的概率会增加。那么,如何解决重复消息的问题呢?答:添加消息处理表。消费者读取消息后,首先判断消息处理表中是否存在该消息。如果存在,则表示重复消费,直接返回。如果不存在,则下订单,然后将消息写入消息处理表,然后返回。比较关键的一点是:下订单和写消息处理表必须放在同一个事务中,保证原子操作。8.3垃圾邮件问题这个解决方案表面上看没有问题,但是如果出现消息消费失败。例如:由于某些原因,消息消费者一直下单失败,一直无法回调状态变化接口,所以作业会不断重试发送消息。最后,产生了大量的垃圾邮件。那么,如何解决这个问题呢?每次重试作业时,都需要判断消息发送表中的消息发送次数是否达到最大限制。如果是,它将直接返回。如果不是,则将计数加1并发送消息。这样,如果出现异常,只会产生少量的垃圾短信,不会影响正常业务。8.4延迟消费问题正常情况下,用户闪购成功,下单后15分钟内未完成支付,订单将自动取消,库存退回。那么,如何实现15分钟内未完成支付自动取消订单的功能呢?我们首先想到的可能是job,因为比较简单。但是job有个问题,需要每隔一段时间处理一次,实时性不是很好。有更好的解决方案吗?答:使用延迟队列。我们都知道rocketmq自带延迟队列功能。下单时,消息生产者会先生成订单,此时状态为待支付,然后向延迟队列发送消息。当到达延迟时间时,消息消费者会在阅读消息后检查订单状态是否为待付款。如果是待付款,订单状态将更新为取消状态。如果不是pendingpayment状态,说明订单已经付款,直接退回。另一个关键点是,用户完成支付后,订单状态会变为已支付。9、如何限流?通过秒杀活动,如果运气好的话,我们可以用很低的价格买到好产品(这个概率相当于买福利彩票中大奖)。但是有些高手不会像我们这么老实,点击秒杀页面的秒杀按钮抢购商品。他们可能模拟普通用户在自己的服务器上登录系统,跳过秒杀页面,直接调用秒杀接口。如果我们手动操作的话,一般情况下,我们每秒只能点击一次秒杀按钮。但是如果是服务器,一秒钟可以请求上千个接口。这个差距太明显了。如果没有限制,大部分产品可能会被机器抢走,而不是普通用户,这有点不公平。因此,我们有必要识别这些非法请求并做出一些限制。那么,我们如何处理这些非法请求呢?目前常用的限流方式有两种:基于nginx的限流基于redis的限流9.1限制同一用户的电流为了防止某个用户过于频繁地请求接口,可以只针对该用户做限制。限制同一个用户id,比如每分钟只能请求5次。9.2同ip限流有时候只限某个用户的流量是不够的。有的高手可以模拟多个用户请求,这种nginx无法识别。这时候需要增加同一个ip限流功能。限制同一个ip,比如每分钟只能请求5个接口。但是,这种限行方式可能会引发事故。比如同一个公司或者网吧,出口ip相同,如果有多个正常用户同时发起请求,可能会有部分用户被限制。9.3限制接口的流量不要以为限制用户和ip就万事大吉了。一些高手甚至可以使用代理每次请求时更改ip。这时候可以限制接口请求的总数。在高并发场景下,这个限制对于系统稳定性是非常必要的。但是也有可能是因为非法请求过多,导致部分非法请求达到了该接口的请求上限,影响了其他正常用户访问该接口。似乎有点得不偿失。9.4添加验证码与以上三种方式相比,添加验证码的方式可能更准确,也可以限制用户访问频率,但好处是不会误杀。通常,用户在请求之前需要输入验证码。用户发起请求后,服务器会验证验证码是否正确。只有正确才允许进行下一步操作,否则直接返回并提示验证码错误。另外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。普通验证码由于生成的数字或图案比较简单,可能会被破解。优点是生成速度比较快,缺点是存在安全隐患。还有一种验证码叫:手机滑块,生成速度比较慢,但是比较安全,目前是各大互联网公司的首选。9.5提高业务门槛虽然增加上述验证码可以限制用户的非法请求,但多少影响了用户体验。用户在点击秒杀按钮之前,必须先输入验证码。过程有点麻烦。秒杀功能的过程不是越简单越好吗?其实,有时候实现某个目标不一定非要用技术手段,通过商业手段也是一样的。12306伊始,全国人民同时抢火车票。由于并发量大,系统经常挂掉。后来经过重构优化,延长了采购周期。火车票可提前20天购票,整点9点、10点、11点、12点等时段均可购票。经过业务调整(当然还有很多技术上的调整),之前集中的请求分散了,并发量一下子就减少了。回到这里,我们提高了业务门槛??,比如只有会员才能参与秒杀活动,普通注册用户是没有权限的。或者,只有达到3级以上的普通用户才有资格参加本次活动。这么简单的提高门槛,连黄牛都束手无策,总不可能为了参加闪购活动而额外花钱给会员充值吧?
