前言
说实话,大部分开发者的系统并发量比较小,尤其是很多从0到1搭建的新项目。
那么,我们平时是如何接触高并发技术的呢?
其实最经典的高并发项目就是秒杀系统,大量用户在极短的时间内购买少量商品。比如小米手机的秒杀功能,刚开始小米网站经常崩溃,后来经过不断的优化迭代升级,才变成现在这个样子。
针对高并发的业务场景,需要更多、更复杂的技术手段。
那么,如何设计一个高并发下的秒杀系统呢?这是一道经常被问到的面试题,这道题看似简单,其实非常复杂,考验的是高并发场景下从前端到后端的知识。
闪购通常出现在商场的促销活动中,指定一定数量(例如10台)的商品(例如手机),以极低的价格(例如0.1元)让大量用户参与活动,但只有极少数用户能够成功购买。参与此类活动的商家大部分都不赚钱,说白了就是找个噱头来宣传自己。
秒杀虽然只是一种促销活动,但对技术要求较高,下面介绍设计秒杀系统需要注意的9个细节。
最近无意中得到一套BAT大佬写的练习笔记,一下子打通了我的任督二脉,越来越觉得算法并没有想象中那么难。
[这位BAT大佬写的练题笔记让我好开心](这位BAT大佬写的练题笔记让我好开心)
1 瞬时高并发
一般情况下,限时抢购时间前几分钟(例如12点)会突然增加,限时抢购时同时在线人数会达到峰值。
但由于这类活动涉及大量用户争夺少量商品,难免会出现狼多肉少的情况。因此,事实上绝大多数用户在闪购中都会失败,只有极少数用户会成功。
正常情况下,大部分用户都会收到商品售完的提醒,收到提醒之后,很有可能不会在活动页面停留,所以用户并发量会急剧下降。所以这个峰值持续时间其实很短,所以会出现瞬间的高并发量。下图是流量变化情况:
传统系统很难应对这种瞬时高并发的场景,需要设计一套全新的系统。可以从以下几个方面入手:
页面静态化CDN加速缓存mq异步处理限流分布式锁2.页面静态化
活动页是用户流量的第一入口,因此是并发量最大的地方。
如果这些流量都能直接访问服务器的话,服务器可能会因为承受不了这么大的压力而崩溃。
活动页面大部分内容都是固定的,例如商品名称、商品描述、图片等,为了减少不必要的服务器请求,活动页面通常是静态的,用户浏览商品等常规操作不会请求服务器,只有当限时抢购时间点到来,用户主动点击限时抢购按钮时,才会允许服务器访问。
这将过滤掉大多数无效请求。
但是仅仅把页面静态化是不够的,因为用户分布在全国各地,有的在北京,有的在成都,有的在深圳,地域差异很大,网速也各有不同。
如何才能让用户尽快的进入活动页面呢?
这就需要使用CDN,其全称是内容分发网络。
使用户能够就近获取所需内容,减少网络拥塞,提高用户访问响应速度和命中率。
3. 秒杀按钮
大多数用户害怕错过抢购时间,所以通常会提前进入活动页面,此时抢购按钮是灰色的,无法点击,只有到了抢购时间,抢购按钮才会自动亮起,可以点击。
但此时,很多用户已经迫不及待,不断刷新页面,希望能够尽快看到限时抢购按钮亮起。
上一节我们知道活动页面是静态的,那么如何控制静态页面中的闪购按钮,让它只在闪购时间点亮呢?
是的,使用js文件控制。
由于性能原因,CSS、JS、图片等静态资源文件一般会提前缓存在CDN上,以便用户就近访问限时抢购页面。
看到这里可能有聪明的朋友会问了:CDN上的js文件是怎么更新的呢?
闪购开始前,js的logo是,还有另外一个随机参数。
当限时抢购开始时,系统会生成一个新的js文件,flag为true,随机参数生成新的值,然后同步到CDN。因为有了这个随机参数,CDN不会缓存数据,每次都能从CDN获取到最新的js代码。
另外可以在前端加一个定时器来控制,比如10秒内只允许一次请求,如果用户点击了一次闪购按钮,那么10秒内按钮就会变灰,不允许再次点击,超过时限后,才可以再次点击按钮。
4. 多读,少写
在秒杀过程中,系统一般会检查库存是否充足,如果库存充足,系统会允许下单并写入数据库,如果库存不足,系统会直接返回商品已售完。
由于大量用户争夺少量商品,最终只有极少数用户能够成功抢到货,因此对于大多数用户来说,限时抢购时其实是库存不足的,系统会直接返回商品已售完。
这是一个非常典型的场景:阅读多,写作少。
如果有几十万个请求进来,数据库同时在检查缓存是否足够,那么数据库可能会崩溃。这是因为数据库连接资源非常有限,例如:它无法同时支持那么多的连接。
相反,使用缓存,例如:
即使使用也需要部署多个节点。
5.缓存问题
正常情况下我们需要在缓存中保存商品信息,包括:商品id,商品名称,规格属性,库存等信息,而且数据库中也一定有相关信息,毕竟缓存并不是完全可靠的。
当用户点击秒杀按钮,请求秒杀接口时,需要传入商品ID参数,然后服务器需要验证该商品是否合法。
大致流程如下图所示:
根据商品ID,先从缓存中查询商品,如果商品存在就参与秒杀,如果不存在就需要从数据库里查询商品,如果存在就把商品信息放到缓存中再参与秒杀,如果商品不存在就直接提示失败。
这个过程表面看起来没什么问题,但是如果深入分析的话,就会发现一些问题。
5.1 缓存细分
比如A产品第一次售完的时候,缓存里是没有数据的,但是数据库里有数据,虽然上面有逻辑是数据库里有数据就放到缓存里。
但是在高并发下,会同时有大量的请求,都是针对同一个产品的,这些请求会同时检查缓存没有数据,然后同时访问数据库,结果很悲惨,数据库可能承受不住压力直接崩溃。
如何解决这个问题?
这就需要锁定,最好使用分布式锁。
当然,这种情况下,最好在项目启动前就预热一下缓存。也就是提前把商品全部同步到缓存中,这样基本上就可以直接从缓存中获取商品,不会出现缓存击穿的问题。
上面可以不需要加锁吗?
表面上看,确实没必要,但如果缓存中设置的过期时间不正确,缓存过早过期,或者缓存被误删除,不加速的话,同样可能出现缓存崩溃的情况。
其实在这里锁起来就相当于买了一张保险。
5.2 缓存穿透
如果大量请求传入缓存或者数据库中不存在的商品ID,这些请求每次都会穿透缓存,直接访问数据库。
由于之前已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接崩溃。
但是显然这些请求的处理性能不佳,有没有更好的解决办法呢?
这时,我们就想到了布隆过滤器。
根据商品ID,系统首先查询 是否存在,如果存在则允许从缓存中查询数据,如果不存在则直接返回失败。
这种方案虽然可以解决缓存穿透问题,但是会引发另一个问题:如何保持 中的数据和缓存中的数据一致?
这就要求如果缓存中的数据有更新,必须及时同步到 中,如果数据同步失败,需要增加重试机制,而且跨数据源的数据实时一致性能不能得到保证?
显然不是。
因此,布隆过滤器多用于缓存数据很少更新的场景。
如果缓存数据更新非常频繁怎么办?
这时候就需要把不存在的商品ID也缓存起来。
下次再有针对该商品ID的请求进来时,虽然可以在缓存中找到该数据,但是该数据比较特殊,提示该商品不存在,需要注意的是,这种特殊缓存设置的超时时间要尽量短。
6.库存问题
库存问题看似简单,但实际上涉及一些事情。
在真正的秒杀场景下,库存扣完并不是就结束了,如果用户在一段时间内没有完成支付,那么扣掉的库存还要重新加回来。
因此这里引入了扣留库存的概念,扣留库存的主要流程如下:
除了前面提到的扣留库存、退回库存外,扣减库存还要特别注意库存不足、库存超卖的问题。
6.1 库存数据库扣除
使用数据库扣减库存是最简单的实现方案,假设扣减库存的SQL如下:
update product set stock=stock-1 where id=123;
这种写法对于扣减库存来说还好,但是如何控制库存不足的情况,不让用户采取任何行动呢?
这就需要事先检查是否有足够的库存。
伪代码如下:
int stock = mapper.getStockById(123);
if(stock > 0) {
int count = mapper.updateStock(123);
if(count > 0) {
addOrder(123);
}
}
有人发现此代码有问题吗?
没错,查询操作和更新操作不是原子的,这会在并发场景下导致库存超卖。
有人可能会说这个很容易做到,只要加个锁就可以解决问题,比如使用关键字。
可以,但是性能不够好。
还有一种更优雅的解决方案,就是基于数据库的乐观锁,这样会减少一次数据库查询,而且天然的可以保证数据操作的原子性。
只需对上面的sql稍微调整一下:
update product set stock=stock-1 where id=product and stock > 0;
在SQL语句最后加上“>0”可以保证不会出现超卖的情况。
但是我们需要频繁的访问数据库,众所周知,数据库连接是非常昂贵的资源,在高并发场景下,可能会造成系统雪崩,而且很容易多个请求同时竞争行锁,造成相互等待,从而出现死锁问题。
6.2 库存扣除
incr方法是原子性的,可以用来扣减库存,伪代码如下:
boolean exist = redisClient.query(productId,userId);
if(exist) {
return -1;
}
int stock = redisClient.queryStock(productId);
if(stock <=0) {
return 0;
}
redisClient.incrby(productId, -1);
redisClient.add(productId,userId);
return 1;
代码流程如下:
首先检查用户是否曾经秒杀过该商品,如果有,直接返回-1。查询库存,如果库存小于等于0,直接返回0,表示库存不足,如果库存充足,扣除库存,保存秒杀记录。然后返回1,表示成功。
我猜很多人一开始都会这样写代码。但是如果你仔细想想,就会发现这段代码有问题。
有什么问题?
如果在高并发下,同时有多个查询库存的请求,就会导致库存大于0,由于查询库存与更新库存不是原理操作,所以库存会为负数,也就是库存超卖。
当然有人会说,加一个不就解决问题了吗?
调整后的代码如下:
boolean exist = redisClient.query(productId,userId);
if(exist) {
return -1;
}
synchronized(this) {
int stock = redisClient.queryStock(productId);
if(stock <=0) {
return 0;
}
redisClient.incrby(productId, -1);
redisClient.add(productId,userId);
}
1;
添加确实可以解决负库存的问题,但是这样会导致接口性能急剧下降,每个查询都需要竞争同一个锁,显然不太合理。
为了解决以上问题,对代码进行优化如下:
boolean exist = redisClient.query(productId,userId);
if(exist) {
return -1;
}
if(redisClient.incrby(productId, -1)<0) {
return 0;
}
redisClient.add(productId,userId);
return 1;
代码主要流程如下:
首先检查用户是否曾经秒杀过该商品,如果有,直接返回-1。扣除库存,检查返回值是否小于0,如果小于0,直接返回0,表示库存不足。如果扣除库存后,返回值大于等于0,则保存秒杀记录。然后返回1,表示成功。
乍一看,这个计划似乎不错。
但如果在高并发场景下,同时有多个扣减库存的请求,则大多数请求的结果都会小于0。
虽然负库存不会造成超卖,但是由于这是预先减库存,如果负值太负,后期退库存的时候,库存就会不准确。
那么,还有更好的解决方案吗?
6.3 Lua脚本扣减库存
我们都知道Lua脚本可以保证原子性,和 一起使用可以完美解决上述问题。
lua脚本中有一段非常经典的代码:

StringBuilder lua = new StringBuilder();
lua.append(“if (redis.call(‘exists’, KEYS[1]) == 1) then”);
lua.append(" local stock = tonumber(redis.call(‘get’, KEYS[1]));“);
lua.append(” if (stock == -1) then");
lua.append(" return 1;“);
lua.append(” end;“);
lua.append(” if (stock > 0) then");
lua.append(" redis.call(‘incrby’, KEYS[1], -1);“);
lua.append(” return stock;“);
lua.append(” end;“);
lua.append(” return 0;");
lua.append(“end;”);
lua.append(“return -1;”);
代码主要流程如下:
首先判断商品ID是否存在,如果不存在则直接返回。获取商品ID的库存,如果库存为-1则直接返回,表示无库存限制,如果库存大于0则扣除库存,如果库存等于0则直接返回,表示库存不足。 7 分布式锁
之前提到过,秒杀进行时,需要先检查缓存中是否存在该商品,如果不存在,再去数据库查询该商品,如果在数据库中,就把商品放入缓存中再返回,如果不在数据库中,就直接返回失败。
试想一下,如果在高并发的情况下,有大量的请求去搜索一个缓存中不存在的商品,这些请求会直接发送到数据库,数据库会因为承受不住压力而崩溃。
那么如何解决这个问题呢?
这就需要使用分布式锁。
7.1 锁定
当使用分布式锁时,首先想到的就是命令。
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}
这个命令其实可以锁定设备,但是它和后面的超时设置是分开的,并不是一个原子操作。
如果加锁成功,但是超时设置失败,那么这个锁就永远不会过期,在高并发场景下,这个问题会导致非常严重的后果。
那么,有没有什么锁定命令可以保证原子性?
7.2 设置锁定
使用 set 命令可以指定多个参数。
String result = jedis.set(lockKey, requestId, “NX”, “PX”, expireTime);
if (“OK”.equals(result)) {
return true;
}
return false;
在:
由于此命令只有一个步骤,因此它是一个原子操作。
7.3 释放锁
接下来有朋友可能会问:加锁的时候,既然已经有了锁标识,为什么还需要记录下来呢?
答:释放锁的时候用。
if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;
释放锁的时候,只能释放自己添加的锁,不允许释放别人添加的锁。
这里为什么要用呢?用了不就没问题了吗?
A:如果使用的话,假设当前请求流程已经完成,准备删除锁,此时锁恰好过期,另一个请求,恰好使用同一个锁,会成功,当这个请求删除锁时,其实是删除了别人的锁。
当然,使用Lua脚本也可以避免这个问题:
if redis.call(‘get’, KEYS[1]) == ARGV[1] then
return redis.call(‘del’, KEYS[1])
else
return 0
end
保证查询锁是否存在和删除锁是原子操作。
7.4 自旋锁
上面的锁定方法看上去没什么问题,但是仔细想想,如果同时有个请求竞争锁,那么可能只有一个请求能够成功,剩下的9999个请求都会失败。
闪购场景中会出现哪些问题?
A:每1万个请求,就有1个成功。每1万个请求,就有1个成功。如此循环,直到库存售完。这就变成了均匀分布的闪购,这和我们想象的不一样。
如何解决这个问题?
答:使用自旋锁。
try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, “NX”, “PX”, expireTime);
if (“OK”.equals(result)) {
return true;
}
long time = System.currentTimeMillis() - start; if (time>=timeout) { return false; } try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
} {
(,);
;
在指定的时间内,比如500毫秒,spin函数不断尝试获取线程锁,成功则直接返回,失败则休眠50毫秒后发起新一轮尝试,如果超时还未成功获取锁,则直接返回失败。
7.5
除了以上问题以外,分布式锁的使用还存在锁争用问题,更新问题,锁重入问题,多实例加锁问题等等。
这些问题都可以使用 来解决,由于文章篇幅较长,我在这里留一点悬念,如果有疑问可以私信我。后续会有分布式锁专题,敬请关注。
8 MQ异步处理
我们都知道,在现实的闪购场景中,核心流程有三个:
在这三个核心流程中,秒杀功能的并发量最大,而订购和支付功能的并发量非常小。因此在设计秒杀系统时,需要将订购和支付功能从秒杀主流程中分离出来,特别是订购功能应该使用MQ异步处理。而支付功能,比如支付宝支付,是异步的,这是由业务场景本身来保证的。
因此限时抢购后的订货流程变为如下:
如果使用MQ,需要注意以下问题:
8.1 消息丢失问题
即使秒杀成功,订单消息发送到MQ时也有可能失败,原因有很多,比如网络问题、挂了、MQ服务器磁盘问题等等,这些情况都可能造成消息丢失。
那么,如何防止消息丢失?
答:添加消息发送表单。
生产者在发送MQ消息前,会先将消息写入消息发送表,初始状态为,然后发送MQ消息;消费者在消费消息时,处理完业务逻辑后,回调生产者的接口,将消息状态改为。
如果生产者将消息写入消息发送表之后,没有成功将该MQ消息发送到MQ服务器,那么该消息将会丢失。
这时候我们应该做什么呢?
答案:使用job,添加重试机制。
最近无意中得到一套BAT大佬写的练习笔记,一下子打通了我的任督二脉,越来越觉得算法并没有想象中那么难。
[这位BAT大佬写的练题笔记让我好开心](这位BAT大佬写的练题笔记让我好开心)
使用job每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。
8.2 重复消费问题
消费者在消费消息时,如果在ack响应过程中网络超时,则可能会消费重复的消息。但由于消息发送方加入了重试机制,因此消费者消费重复消息的概率增大。
那么,如何解决消息重复的问题呢?
答:添加消息处理表。
消费者读到消息后,首先查看消息处理表中是否存在该消息,如果存在则说明是重复消费,消费者直接返回,如果不存在则消费者下单,将消息写入消息处理表,返回。
很关键的一点是下单和写入消息处理表应该放在同一个事务中,保证原子操作。
8.3 垃圾邮件问题
这种方案看上去没什么问题,但是一旦出现消息消费失败的情况,比如消息消费方因为某些原因下单失败,无法回调状态变更接口,作业就会不断重试发送消息,最终产生大量的垃圾消息。
那么,如何解决这个问题呢?
每次重试作业时,需要先判断消息发送表中该消息已发送次数是否已经达到最大限制,如果已经达到则直接返回,如果没有达到则将次数加1再发送消息。
这样,如果出现异常,只会产生少量的垃圾邮件,不会影响正常业务。
8.4 延迟消费问题
正常情况下,若用户参与秒杀活动成功,下单后15分钟内未能完成支付,订单将自动取消并退回库存。
那么,如何实现15分钟内未完成支付则自动取消订单的功能呢?
我们首先想到的可能是工作,因为它相对简单。
但是该作业有一个问题,它每隔一段时间就需要处理一次,实时性不太好。
有没有更好的解决办法?
答:使用延迟队列。
我们都知道它内置有延迟队列功能。
下单时,消息生产者会先生成一个订单,该订单处于待支付状态,然后向延时队列发送消息,当延时时间到达时,消息消费者会读取消息,检查该订单是否处于待支付状态,如果是待支付状态,则更新订单状态为已取消,如果不是待支付状态,则表示该订单已经支付,直接返回。
还有一个很关键的点,当用户完成支付之后,订单状态就会变为已付款。
9 如何限流?
通过限时抢购,如果我们运气极其好的话,也许能够以非常低的价格购买到好产品(这种概率堪比中彩票头奖)。
但有些高手不会像我们这么老实,直接点击秒杀页面的秒杀按钮去购买商品,他们可能会在自己的服务器上模拟普通用户登录系统,跳过秒杀页面,直接调用秒杀接口。
如果我们手动操作的话,一般来说,每秒只能点击一次限时抢购按钮。
但如果是服务器的话,一秒钟就可以请求几千个接口。
这个差距太明显了,如果不做限制的话,可能大部分商品都会被机器抢走,而不是普通用户,这就有点不公平了。
因此我们有必要对这些非法请求进行识别,并做出一些限制。那么,我们该如何处理这些非法请求呢?
目前常用的限流方式有两种:
基于限流基于限流9.1 限制同一用户的电流
为了防止某个用户过于频繁地请求接口,可以限制该用户。
限制同一个用户ID,比如每分钟只能进行5次接口请求。
9.2 限制同一 IP 的流量
有时候仅仅限制某个用户的流量是不够的,有些高手可以模拟多个用户请求,这种请求是无法被识别的。
这时候就需要增加同样的IP限流功能。
限制同一个IP地址,比如每分钟只能进行5次接口请求。
但这种限流方式容易出现误报,比如同一个公司或者网吧的出口IP地址相同,多个正常用户同时发起请求,就可能造成部分用户被限制。
9.3 限制接口电流
不要以为限制了用户和 IP 就万事大吉了,有些高手甚至可以使用代理,每次发出请求时都更改 IP。
这时候可以限制接口请求的总量。
在高并发场景下,这种限制对于系统的稳定性来说非常必要,但是由于非法请求过多,可能会达到接口的请求上限,影响其他正常用户对该接口的访问,看起来得不偿失。
9.4 添加验证码
与以上三种方法相比,添加验证代码可能更准确,也可以限制用户的访问频率,但优势是没有误报。
通常,在用户启动请求之前,用户需要输入验证代码。
此外,验证代码通常是一次性的,并且仅允许使用一次相同的验证代码,并且不允许重复使用。
普通的验证代码可能会破裂,因为生成的数字或模式相对简单。
还有一个验证代码:移动滑块的生成较慢,但更安全,目前是主要的互联网公司的首选。
最近,我意外地得到了一套由蝙蝠公司的大老板写的练习笔记。
[关于练习蝙蝠老板写的问题的注释使我感到非常高兴]
9.5提高业务阈值
尽管上面提到的验证代码可以限制非法用户请求,但在用户单击“闪存销售”按钮之前,它们必须首先输入验证代码,这似乎有点麻烦。
实际上,有时实现某个目标并不一定需要技术手段,而可以通过业务手段实现。
一开始,全国各地的人都在同时争取火车票。一次减少用户并发。
回到这里,我们提高了业务的阈值,例如,只有成员可以参加销售,普通的注册用户没有权利,或者只有达到3级或更高级别的普通用户才有资格参加活动。
即使阈值提高,也将是无助的。