秒杀接口主要考虑的几个关键问题是数据库超卖、接口限流,让系统能抗住大并发量,抛出非法请求等。
-
乐观锁方式超卖
在大并发量下,首先要考虑的问题就是线程安全问题。秒杀的三个步骤大致可分为 1.根据商品id查询商品的库存 2.如果库存充足扣减库存并更新 3.生成订单。
为什么会出现超卖问题?假设总库存是100,第一个线程进来,查询没问题,并且让商品对象的库存减一,此时的库存数量更新为99,但是,没有更新数据库,这个线程的时间片就用完了
第二个线程进来,查询没问题,并且让商品对象的库存减一
此时的库存数量更新为99,但是第二个线程还是没有更新数据库,这个线程的时间片就用完了第三个线程进来,查询没问题,并且让商品对象的库存减一
此时的库存数量更新为99,假设这个线程更新了数据库,那此时的数据库数量就是99,
此时第一个线程执行,又更新为了99,第二个线程执行,又更新为了99,所以这里产生了三个订单,但数据库商品的库存才扣减了1个。解决办法
1.使用 synchronized
2. 使用乐观锁
synchronized是一种悲观锁,会将整个方法同步,效率较低,而且synchronized和@Transactional注解一起使用时可能还会出现超卖问题。原因是@Transactional 本身也会有一个线程同步,而事务的这个线程同步的范围比sync的范围要大,也就是,sync释放了锁之后,这个事务线程控制的同步可能还没有提交下一个线程就进来了,也就是下一个线程可能会查询到上一个事务还没有提交的库存数量。 所以不要在业务层使用synchronized,因为可能会和事务注解产生问题,如果一定要用的话,可以使用在controller上。乐观锁的原理是在数据库每条记录上加一个version字段,然后更新的时候不但要更新库存,且每更新一次就把version加一,更新的条件是商品的id和之前的版本号。SQL示例如下:
<update id="updateStockByVersion">
UPDATE stock set sale = sale + 1 , version = version + 1
where id = #{id} and version = #{version}
</update>
由于数据库在更新时也会加锁,所以每一时刻只有一条SQL执行更新。我们按照刚才synchronized的流程来分析以下,首先第一个线程进来,查询库存假设第一个线程的库存是100,版本号是0,在更新SQL时没有了时间片,此时第二个线程进来,同样库存是100,版本号是0,在更新SQL时没有了时间片,第三个线程进来库存是100,版本号是0,第三个线程进行更新,将库存变为99,版本号+1变为1,但是更新的条件是id = 商品id and version = 当前线程的版本号,第三个线程的版本号是0,而数据库初始的版本号也是0,第三个线程更新成功,此时再第二个线程更新,由于第二个线程更新的时候的version为0,但数据库的version已经成为1,则更新失败。版本号的方式是一种乐观锁,因为大量的请求可能在判读库存这一步就由于库存不足返回了。
- 令牌桶实现限流
令牌桶算法的概述是每固定一段时间内产生一定数量的令牌,只有拿到令牌的线程才可以执行,如果拿不到可以等待,也可以规定一个时间,在某个时间窗口内拿不到就拒绝请求。
pom.xml引入令牌桶的实现。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.0-jre</version>
</dependency>
这个依赖提供了一个实现类:RateLimiter。
1.RateLimiter.create(), acquire方法
该方法可以传递一个参数,表示一秒内可以生成多少令牌,只有获取到令牌的线程才可以执行。
2.tryAcquire方法
该方法可以传递一个时间参数,表示多少单位时间内获取不到令牌就抛弃。
-
Redis缓存
在下单之前,可以先去redis中查询是否有这个要秒杀的商品,如果有,再继续下单,如果没有则拒绝下单。这里可能需要使用定时任务,在商品即将要开始秒杀的时候上线到Redis。 -
接口隐藏
为了避免某些人可以恶意请求接口,在请求下单接口时需要携带一个MD5的令牌,而这个令牌可以在下单前先发送请求获取一下,我们将令牌同时存储到Redis中一份,并且验证redis中与请求携带的令牌是否一致。这样可以避免恶意请求秒杀接口,因为如果要获取秒杀令牌是首先需要登录的。 -
限制单一用户的访问次数
针对于某个固定的用户在某一时间内访问频繁,我们可以使用redis来限制其访问次数。
@Override
public boolean limitUser(Integer userId) {
//判断用户的访问次数
String count = redisTemplate.opsForValue().get(PREFIX + userId);
if (StringUtils.isEmpty(count)){
//单位时间内第一次访问
redisTemplate.opsForValue().set(PREFIX + userId,"1",TIME_SECOND, TimeUnit.SECONDS);
return true;
}else {
//单位时间内不是第一次访问
int currentCount = Integer.parseInt(count);
if (currentCount >= COUNT){
return false;
}else {
//单位时间内没有达到访问次数的限制
currentCount++;
//更新的时候重新更新过期时间
Long expire = redisTemplate.getExpire(PREFIX + userId);
redisTemplate.opsForValue().set(PREFIX + userId,currentCount + "",expire,TimeUnit.SECONDS);
return true;
}
}
}
主要流程是:首先我们需要规定每个用户在单位时间内的最大访问次数,如每个用户在20秒内最多可以访问5次,那么当用户请求接口时,根据用户Id,我们判断是否为第一次访问,如果为第一次访问,我们给redis中设置一个key,key要与用户id相关,而value就是访问此时,注意,要同时设置一个过期时间,而且在更新的时候也要获取过去时间设置,否则这个key就成了永不过期了。
以上五点设计在高并发接口下可以参考。