秒杀场景
秒杀场景关注点
严格防止超卖
:库存1000件卖了1020件,要杀个码农祭天了!防止超卖是秒杀系统设计最核心的部分。防止黑产
:防止不怀好意的羊毛党薅羊毛。保证用户体验
:高并发下,给用户提供友善的购物体验,尽可能支持比较高的QPS等等。接下来就让我们按照关注点,不断细化秒杀场景。
裸奔秒杀 不加思考,上来直接按照 SpringBoot + MyBatis 模式进行秒杀系统的设计,流程如下:
Controller
层获得用户秒杀请求后调用Service
层。Service
层获得请求后要要检查已售数据跟库存总量是否一致,一致说明商品卖没了,不一致说明还有库存,那就调用DAO
层对已售数量进行加1。DAO
层获得请求后直接通过MyBatis
操作数据库实现已售数量加1跟订单创建。如果你用Postman
去测试会发现是OK的,但如果你用专业的并发测试工具JMeter
模式多用户并发请求会发现订单创建数量 >
库存量 - 已售量。原因解释下,比如用户A、B并发进行秒杀请求,此时库存=100,已售=64。
Service
层,发现已售不等于库存,此时拿到库存数是64,A将库存更新为63,然后创建订单。Service
层,发现已售不等于库存,此时拿到库存数是64,B将库存更新为63,然后创建订单。无锁并发请求,卖超了
syn悲观锁
遇见 并发问题 很容易想到以前学过并发编程嘛,既然Controller
默认是单例模式,那我用 synchronized 将Controller
层调用Service
层的代码进行加锁同步即可。
这样就可以解决卖超问题了,但是须知,既然是悲观锁,如果有1000个并发请求,那只有1个拿到锁了。有999个会去竞争这个锁的。
@Transactional
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService
{
//校验库存
Stock stock = checkStock(id);
//更新库存
updateSale(stock);
//创建订单
return createOrder(stock);
}
当然了你也可以用Spring自带的事务注解来实现悲观锁
的操作,因为用了@Transactional
就可以实现通过事务来控制,要么全部成功,要么全部失败,用事务时有两点需注意:
需注意:悲观锁状态下会保证商品卖出去,如果没拿到锁的线程会阻塞的等待拿锁。但是他的阻塞也会给用户带来非常不良好的体验。
MySQL版本号
我们为每个数量的已售数据配备个版本号,在Service
层调用时获得用户的已售数跟对应版本号,然后更新时将已售数跟版本号同时更新。因为 MySQL在更新时会自带乐观加速机制,如果更新成功则表示抢购成功,更新失败则表示抢购失败,此时你会发现不是手速越快就一定能抢到的哦,但起码保证了不会超卖,
update 库存表 set
已售数=已售数+1,版本号=版本号+1
where 秒杀id =#{id} and 版本号 = #{version}
需注意:乐观锁状态下,由于是随机性的秒杀失败,所以可能活动结束后还会有几个没售出去的!
最核心的超卖
问题已经解决了,接下来就是各种优化手段了。在高并发请求中如果不对接口限流会对后台服务器造成极大压力,所以一般秒杀系统为了不影响其他业务会单独部署到个某个服务器上,同时还会设置好限流。
常用的限流方法有我们在 Redis 中曾经说过,主要有漏桶算法
、令牌桶算法
。而Google
开源项目Guava
中RateLimiter
使用的就是令牌桶控制算法。在开发高并发系统时有三把利器用来保护系统:缓存
、降级
、限流
漏桶算法思路
:把水比作是请求,漏桶比作是系统处理能力极限,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流。
令牌桶算法原理
:可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病。
流程大致:
工程中一般用令牌桶算法为多,一般用Google
的Guava
中 RateLimiter
即可。
//创建令牌桶实例
private RateLimiter rateLimiter = RateLimiter.create(20);
// 阻塞式获得令牌才继续往下执行
rateLimiter.acquire();
// 就等3秒看是否可以获得令牌,返回Boolean值。
rateLimiter.tryAcquire(3, TimeUnit.SECONDS)
有了乐观锁跟限流,接下来再思考写细节问题。
限时抢购
。秒杀接口隐藏
。频率限制
。很简单,将秒杀商品放入Redis并设置超时,比如我们以kill + 商品id作为key,以商品id作为value,设置180秒超时。
127.0.0.1:6379> set kill1 1 EX 180
OK
加入时间校验:
public Integer createOrder(Integer id) {
//redis校验抢购时间
if(!stringRedisTemplate.hasKey("kill" + id)){
throw new RuntimeException("秒杀超时,活动已经结束啦!!!");
}
//校验库存
Stock stock = checkStock(id);
//扣库存
updateSale(stock);
//下订单
return createOrder(stock);
}
接口隐藏
// 根据商品id 跟 用户id生成个md5。
@Override
public String getMd5(Integer id, Integer userid) {
//检验用户的合法性
User user = userDAO.findById(userid);
if(user==null)throw new RuntimeException("用户信息不存在!");
//检验商品的合法行
Stock stock = stockDAO.checkStock(id);
if(stock==null) throw new RuntimeException("商品信息不合法!");
String hashKey = "KEY_" + userid + "_" + id;
//生成md5,此处的 !AW# 是一个盐,可以跟找个Random随机生成。
String key = DigestUtils.md5DigestAsHex((userid + id + "!AW#").getBytes());
stringRedisTemplate.opsForValue().set(hashKey, key, 3600, TimeUnit.SECONDS);
return key;
}
此时如果用户直接请求秒杀接口就会被限制了,但如果黑客技术升级,将请求MD5跟请求秒杀接口写到一起,还是无法防止被薅羊毛!咋办呢?再限制下用户访问频率。
秒杀
获取。访问频率限制
CDN加速
:为何京东物流快,因为人在全国各地配置了多个仓库。同理,我们可以将前端的一些静态东西配置在全国各个不同的地方,用户请求时,直接请求距离自己最近的前端资源即可。前端按钮灰色化
:如果参与过秒杀活动会发现,没到秒杀时间时秒杀按钮是灰色状态的,只有时间到了才是可点击状态。并且秒杀开始咯也不是一直可以点的,可能只允许1秒内点10次那种的。Nginx负载均衡
:一个tomcat的QPS一般在200~1000左右,如果淘宝或京东性质的秒杀,就需要搞个Nginx负载均衡来支持几万级别的并发了。信息存储Redis化
:单独的MySQL是无法支撑上万的QPS的,既然Redis号称可支持10W级的QPS,我们把数据信息存到Redis中就好咯嘛!有人可能会说MySQL有乐观锁跟事务性啊,Redis不是没有事务性么,其实我们可以通过 Lua 脚本来实现并发情况下Redis的事务性操作。消息中间件-流量削峰
:秒杀成功后,如果秒杀的成功量过大,全部订单直接写入MySQL也是不太恰当的,可以把秒杀成功的用户信息写入消息中间件。比如RabbitMQ、Kafka,给用户返回抢购成功信息,然后专门代码消费中间件信息(生成订单,数据持久化),因为是异步消费,为防止用户秒杀成功后无法看到订单信息,在订单生成前给用户提示订单提交排队中,啥时候订单异步消费成功了再告知用户成功。短URL
:有时你别人发给你个超短的URL你打开后就直接跳转为日常看到的购物页面了,这就涉及到短URL映射了,大致思路就是做个链接映射,在此基础上也可以玩出各种花样,反正挺有趣的(有兴趣可以水一篇)。
秒杀大致流程图
工业化秒杀
:真正工业化的秒杀绝对不止我前面说的那么简单哦,起码你会接触到 MQ
、SpringBoot
、Redis
、Dubbo
、ZK
、Maven
、lua
等知识点,我也从同性交友网站GitHub找到了份爆赞的工业化秒杀项目,公众号回复秒杀
就可以获取啦。