秒杀系统
1、瞬时高并发
在秒杀时间点(比如:12点)前几分钟,用户并发量才真正突增,达到秒杀时间点时,并发量会达到顶峰。正常情况下,大部分用户会收到商品已经抢完的提醒,收到该提醒后,大概率不会在那个活动页面停留,如此一来,用户并发量又会急剧下降。所以这个峰值持续的时间其实是非常短的。
从以下几个方面入手:
- 页面静态化
- CDN加速
- 缓存
- mq异步处理
- 限流
- 分布式锁
2、页面静态化
活动页面是用户流量的第一入口,所以是并发量最大的地方。活动页面绝大多数内容是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。
只做页面静态化还不够,因为用户分布在全国各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很远,网速各不相同。如何才能让用户最快访问到活动页面呢?
需要使用CDN,全称是Content Delivery Network,即内容分发网络。 使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。
3、读多写少
在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。
由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。应该改用缓存,比如:redis

4、缓存问题
需要在redis中保存商品信息,里面包含:商品id、商品名称、规格属性、库存等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。用户在点击秒杀按钮,请求秒杀接口的过程中,需要传入的商品id参数,然后服务端需要校验该商品是否合法。

缓存击穿
商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。如果从数据库中查到数据,则放入缓存的逻辑。在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库,数据库可能扛不住压力,直接挂掉。需要加锁,最好使用分布式锁

最好在项目启动之前,先把缓存进行预热。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题。是不是上面加锁这一步可以不需要了?如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除,如果不加锁同样可能出现缓存击穿。
缓存穿透
如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库。布隆过滤器绝大部分使用在缓存数据更新很少的场景中。就需要把不存在的商品id也缓存起来
库存问题
预扣库存:真正的秒杀商品的场景,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的。特别注意的是库存不足和库存超卖问题

数据库扣减库存
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); } }
查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况。使用synchronized关键字,性能不够好。基于数据库的乐观锁,会少一次数据库查询,而且能够天然的保证数据操作的原子性。
update product set stock=stock-1 where id=product and stock > 0;
stock > 0,就能保证不会出现超卖的情况
在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现死锁的问题。
redis扣减库存
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;
如果在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即库存超卖。代码优化如下:
boolean exist = redisClient.query(productId,userId); if(exist) { return -1; } if(redisClient.incrby(productId, -1)<0) { return 0; } redisClient.add(productId,userId); return 1;
如果在高并发场景中,有多个请求同时扣减库存,大多数请求的incrby操作之后,结果都会小于0。
库存出现负数,不会出现超卖的问题。但由于这里是预减库存,如果负数值负的太多的话,后面万一要回退库存时,就会导致库存不准。
lua脚本扣减库存
该代码的主要流程如下:
1)先判断商品id是否存在,如果不存在则直接返回。 2)获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。 3)如果库存大于0,则扣减库存。 4)如果库存等于0,是直接返回,表示库存不足。
mq异步处理
在真实的秒杀场景中,有三个核心流程:

真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。
1、消息丢失问题
秒杀成功了,往mq发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。那么,如何防止消息丢失呢?
答:加一张消息发送表。

在生产者发送mq消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。使用job,增加重试机制。

用job每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。
重复消费问题
本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。那么,如何解决重复消息问题呢?
答:加一张消息处理表。

消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。
有个比较关键的点是:下单和写消息处理表,要放在同一个事务中,保证原子操作。
垃圾消息问题
延迟消费问题

下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。
如何限流
1、对同一用户限流
为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。

2、对同一ip限流
只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了,需要加同一ip限流功能。这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。
3、对接口限流
限制请求的接口总次数,可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。
4、加验证码
加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。
用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。
此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。
秒杀系统数据库设计
秒杀有把服务器击垮的风险,如果让它与我们的其他业务使用在同一个数据库中,耦合在一起,就很有可能牵连和影响其他的业务。如何防止这类问题发生,就算秒杀发生了宕机、服务器卡死问题,也应该让他尽量不影响线上正常进行的业务

秒杀url的设计
为了避免有程序访问经验的人通过下单页面url直接访问后台接口来秒杀货品,我们需要将秒杀的url实现动态化,即使是开发整个系统的人都无法在秒杀开始前知道秒杀的url。具体的做法就是通过md5加密一串随机字符作为秒杀的url,然后前端访问后台获取具体的url,后台校验通过之后才可以继续秒杀。
服务降级
假如在秒杀过程中出现了某个服务器宕机,或者服务不可用
浙公网安备 33010602011771号