一止长渊

秒杀

N 人看过
字数:2.6k字 | 预计阅读时长:10分钟

一、秒杀业务

秒杀具有瞬间高并发的特点,针对这一特点,必须要做到限流 + 异步 + 缓存(页面静态化)+ 独立部署

秒杀业务最好要求独立部署,是一个独立模块,不和其他服务模块混写在一起,这样大并发进来,也不至于将其他服务模块挤占下线。
缓存需要使用 Redis(时机就是后台将商品上架时放入 Redis 中)否则所有请求打在 db 上,会让数据库崩溃
秒杀商品的库存信息也应该放进库存里,从而可以商品购买可以更新库存

二、定时任务

定时任务有很多场景使用:例如每天对交易流水需要和支付宝进行对账,每月定期进行财务汇总
定时任务有很多实现方法:例如 JAVA 原生里面的 Timer,QUARTZ 框架

1、cron 表达式

语法:秒 分 时 日 月 周 年(Spring 不支持年,周的含义是周几的意思,周日是 1,周一是 2 以此类推,周和日位置必须要一个写*,一个写?,防止周几和日冲突)
特殊字符:

,:枚举;
(cron="7,9,23 * * * * ?"):任意时刻的 7,9,23 秒启动这个任务;
-:范围:
(cron="7-20 * * * * ?"):任意时刻的 7-20 秒之间,每秒启动一次
*:任意;
指定位置的任意时刻都可以
/:步长;
(cron="7/5 * * * * ?"):第 7 秒启动,每 5 秒一次;
(cron="*/5 * * * * ?"):任意秒启动,每 5 秒一次;
?:(出现在日和周几的位置):为了防止日和周冲突,在周和日上如果要写通配符使
用?
(cron="* * * 1 * ?"):每月的 1 号,启动这个任务;
L:(出现在日和周的位置)”,
last:最后一个
(cron="* * * ? * 3L"):每月的最后一个周二
W:
Work Day:工作日
(cron="* * * W * ?"):每个月的工作日触发
(cron="* * * LW * ?"):每个月的最后一个工作日触发
#:第几个
(cron="* * * ? * 5#2"):每个月的第 2 个周 4

2、Spring 中使用 Scheduled 来定时任务

1.@EnableScheduling 开启定时任务,**@Component**该类要放入到容器中 2.只要在方法上添加@Scheduled,属性 cron 中写入时间表达式

/**
 * @PackageName:com.lookstarry.doermall.seckill.scheduled
 * @NAME:HelloSchedule
 * @Description:
 * 1、@EnableScheduling 开启定时任务
 * 2、@Scheduled默认不是整合Quartz任务的
 * 3、只要在方法上添加@Scheduled,属性cron中写入时间表达式
 * @author: yizhichangyuan
 * @date:2021/7/2 21:27
 */
@Slf4j
@Component
@EnableScheduling
public class HelloSchedule {
    @Scheduled(cron = "* * * * * ?")
    public void hello(){
        log.info("hello...");
    }
}

区别: 1.在 Spring 中 cron 表达式由六位组成,不允许第七位的年出现 2.在 cron 周的位置,1-7 分别代表周一到周日;MON-SUN 3.定时任务会进行阻塞,默认是阻塞的,业务实际执行应该是不阻塞的(即未完成的任务会影响下一个任务的执行时间)
加入业务的执行过程中时间比较长,例如下面模拟业务耗时3 秒

    @Scheduled(cron = "* * * * * ?")
    public void hello(){
        log.info("hello...");
        Thread.sleep(3000);
    }

但实际上每个任务不是每秒都执行一次,而是间隔了4 秒
截屏2021-07-02 21.41.19.png

3、那么如何使用@Scheduled 不阻塞下一个定时任务的执行呢?

方法一:使用异步编排,让业务运行以异步的方式,自己提交到线程池

    @Scheduled(cron = "* * * * * ?")
    public void hello(){
        CompletableFuture.runAsync(() -> {
            xxxService.hello();
        }, executor);
    }

方法二:SpringBoot 支持定时任务线程池
通过查看 TaskScheduledAutoConfiguration 的默认自动配置 TaskSchedulingProperties
截屏2021-07-02 21.48.36.png
可以看到执行定时任务的线程池只有一个,因此@Scheduled 在执行耗时比较长的任务,因此会阻塞下一个定时任务执行,造成不守时
配置文件中添加如下:

spring:
  task:
    scheduling:
      pool:
        size: 5

可以看出如下也还是 4 秒才执行,在高版本中的 Spring 中可以生效,但经常容易失效。
截屏2021-07-02 22.00.00.png

方法三、让定时任务异步执行(也可以在普通方法上使用这个,A.B()也是异步调用运行方法 B)
1.@EnableAsync 开启异步任务功能 2.需要异步定时任务方法上标注@Async

@Slf4j
@EnableAsync
@Component
@EnableScheduling
public class HelloSchedule {
    @Async
    @Scheduled(cron = "* * * * * ?")
    public void hello() throws InterruptedException {
        log.info("hello...");
        Thread.sleep(3000);
    }
}

可以看到定时任务不会被上一个定时任务阻塞,而是变成每秒执行一次
截屏2021-07-02 22.16.00.png
异步执行的自动配置类在 TaskExecutionAutoConfiguration,其也是使用线程池ThreadPoolTaskExecutor来实现异步任务,默认属性是绑定在 TaskExecutionProperties 中截屏2021-07-02 22.21.20.png
截屏2021-07-02 22.21.50.png
默认线程池数量大小为 8,最大为 Integer.MAX_VALUE,所以要限定一下

spring:
  task:
    execution:
      pool:
      core-size: 20
      max-size: 50

最终应该采用的配置是定时任务 + 异步执行


4、秒杀商品的结构设计

商品秒杀,由于瞬时流量很大,需要将秒杀商品信息放入到 Redis 中,这里上架的含义就是将相应的秒杀商品放入到 Redis 中。
Redis 设计结构

  • 秒杀活动 :

key 为活动的开始时间和结束时间(startTime - endTime)
value 为参与该活动的所有商品 秒杀活动 id-skuId [sessionId-skuIds]

  • 秒杀商品 Hash 结构,该结构的 redis key 为秒杀活动的 id:

hash 中每个 entry 的 key 为 skuId
value 为商品基本信息、活动开始时间及结束时间、随机码

  • 秒杀商品分布式信号量(分布式对象 Redisson Semaphare,进行限流)

key 为随机码作为 key,防止内部开发人员知晓商品 id 后,恶意修改秒杀商品库存
每个秒杀请求进来后都会将库存量-1,当库存量为 0 的时候,后续秒杀请求就不应该再创建成功,为了满足分布式以及锁机制,使用秒杀商品库存量作为分布式信号量的值,依此作为秒杀商品库存扣减信息。这样可以将获取到信号量的请求才视为秒杀成功,进行后续的操作。此外 Semophare 的名称也是一个值得考究的地方,为了防止内部开发人员知道秒杀商品 id 后,恶意修改秒杀商品库存,Semophare 的名称使用上面的随机码。

随机码目的(防止恶意攻击,隐藏秒杀地址):
如果秒杀请求设置为 seckill?skuId=1,别人可以通过常规 sku 商品页面知晓 skuId 后,在秒杀时利用软件恶意发送请求进行秒杀操作;通过设置随机码后,秒杀请求改为 seckill?skuId=1&key=fasdfasfdasga,不仅需要验证 skuId 是否是秒杀商品,还要验证随机码是否与 Redis 中正确,这样可以防止别人利用软件恶意秒杀

秒杀商品分布式信号量 Semophare 目的:
信号量是一种锁,它可以让用户限制一项资源最多能够同时被多少个进程访问,获取信号量失败通常直接退出,并向用户提示“资源繁忙”,可以快速结束请求的处理,也起到一个限流的目的。假如商品秒杀库存只要 100 个,现在有 100 万请求放进来,每进来一个请求就将这个分布式信号量-1,如果能减就放行该请求来进行后续数据库的操作,如果不能减就不进行后续操作,就会阻塞很短的时间,整个请求就会很快地处理释放,才能拥有处理大并发的能力。

String semaphoreKey = SecKillRedisKey.SECKILL_STOCK_SEMPHORE + token;
RSemaphore semaphore = redisson.getSemaphore(semaphoreKey);
// 信号量的值就为商品可以秒杀的库存量
semaphore.trySetPermits(skuRelationEntity.getSeckillCount().intValue());

5、定时任务——分布式下的问题(幂等性问题)

分布式情况下,多台机器都有相同的定时任务,我们其实只要求有一台机器的定时任务进行运行即可,如果不处理那么多台机器的定时任务都会进行,就会造成 Redis 中由于随机码不同,造成 Key 不同,Semophare 重复设置。
可以考虑使用分布式定时任务 xxl-job 或者加入分布式锁,首先获得到分布式锁的机器才会进行定时任务,此外业务层放入 redis 时,也要提前判断 key 是否存在

@Slf4j
@Service
public class SecKillSkuScheduled {
    @Autowired
    SeckillService seckillService;

    @Autowired
    RedissonClient redisson;

    @Scheduled(cron = "0 * * * * ?")
    public void uploadSeckillSkuLatest3Days(){
        // 秒杀商品重复上架无需处理
        System.out.println("上架秒杀商品信息...");
        RLock lock = redisson.getLock(SecKillRedisKey.UPLOAD_LOCK);
        try{
            lock.lock(10, TimeUnit.SECONDS); // 该锁只锁住十秒,此时任务应该完成
            seckillService.uploadSeckillSkuLatest3Days();
        }catch(Exception e){
            System.out.println(e.getMessage());
        }finally {
            // 不管是否有异常,最后一定要解锁
            lock.unlock();
        }
    }
}

6.查询秒杀商品逻辑

1、首先根据请求的时间判断当前有哪些活动时间范围位于该时间
2、然后根据参与活动的活动 id,去查询相应的商品信息的 hash 结构

查询秒杀商品一定要注意,如果是在秒杀活动范围时间内,那么从 Redis 中取出的商品信息要带上随机码,方便验证;而不在秒杀活动范围时间内,预告秒杀商品时,则从 Redis 查询的商品信息一定要再把随机码字段设置为 null,再返回给页面,防止泄漏随机码

@Override
    public List<SecKillSkuRedisTo> getCurrentSeckillSkus() {
        ArrayList<SecKillSkuRedisTo> secKillSkuRedisTos = new ArrayList<>();

        // 1、获取请求发生时刻属于哪个秒杀场次
        long time = new Date().getTime();

        // 2、获取所有的场次的时间
        String sessionKey = SecKillRedisKey.SECKILL_SESSION_PRIFIX + "*";
        Set<String> keys = stringRedisTemplate.keys(sessionKey);
        for (String key : keys) {
            Boolean sessionInTime = timeInSession(time, key);
            if(sessionInTime){
                // 当前活动时间确认在当前时间
                Set<String> sessionIdWithSkuIdSet = stringRedisTemplate.opsForSet().members(key);
                String sessionId = null;
                for (String s : sessionIdWithSkuIdSet) {
                    sessionId = s.split("-")[0];
                    break;
                }
                BoundHashOperations<String, Object, Object> seckillOps = getSeckillOperations(Long.valueOf(sessionId));
                Collection<Object> values = seckillOps.entries().values();
                for (Object value : values) {
                    SecKillSkuRedisTo secKillSkuRedisTo = JSON.parseObject((String) value, SecKillSkuRedisTo.class);
                    // 当前秒杀开始了,所以需要随机码数据
//                    secKillSkuRedisTo.setRandomCode(null);
                    secKillSkuRedisTos.add(secKillSkuRedisTo);
                }
            }
        }
        return secKillSkuRedisTos;
    }

    /**
     * 判断活动活动时间范围是否包括当前时间
     * @param currentTime
     * @param key
     * @return
     */
    private Boolean timeInSession(Long currentTime, String key){
        String[] timeDuration = key.replace(SecKillRedisKey.SECKILL_SESSION_PRIFIX, "").split("-");
        Long startTime = Long.valueOf(timeDuration[0]);
        Long endTime = Long.valueOf(timeDuration[1]);
        return currentTime <= endTime && currentTime >= startTime;
    }

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 (CC BY-NC-ND 4.0) 进行许可。