《谷粒商城》开发记录 13:秒杀、熔断和降级

一、秒杀

秒杀活动就是平台商家在某个固定时间段,发布一些超低价格的商品,买家集中在该时间段进行网上抢购的一种销售方式。由于商品价格低,往往一上架就被抢购一空,有时仅需一秒钟,所以被称为秒杀。

1 秒杀系统设计

面对秒杀业务极大的瞬时流量,我们在设计秒杀系统时,可以考虑以下方面。

1.1 独立部署

秒杀服务即使自己扛不住压力挂掉,也不能影响其他服务。

1. 新建秒杀服务gulimall-seckill,引入依赖:
    Spring Boot DevTools、Lombok、Spring Web、Spring Data Redis、OpenFeign。
    引入依赖gulimall-common。
2. 配置gulimall-seckill。
    spring.application.name=gulimall-seckill
    server.port=25000
    spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
    spring.redis.host=192.168.56.10
3. gulimall-seckill启动类上加@EnableDiscoveryClient注解。
4. 配置网关。
    在gulimall-gateway服务配置文件中添加:
        gateway: 
          routes: 
            - id: gulimall_seckill_route
              uri: lb://gulimall-seckill
              predicates: 
                - Host=seckill.gulimall.com

1.2 动静分离

使用Nginx做动静分离,使gulimall-seckill服务专注于处理秒杀请求。
详情见《谷粒商城》开发记录 7:压力测试和性能调优

1.3 缓存+信号量控制

提前将秒杀商品信息写入缓存。
在向缓存中写入秒杀商品信息时,根据秒杀商品数量创建分布式信号量一并写入缓存,处理秒杀商品请求时,扣减信号量而不是到数据库扣减商品库存。

● 引入Redisson:
    1. 引入依赖。
        groupId: org.redisson
        artifactId: redisson
    2. 创建配置类。
        @Configuration
        public class RedissonConfig {
            @Bean(destroyMethod="shutdown")
            public RedissonClient redisson() throws IOException {
                Config config = new Config();
                config.useSingleServer().setAddress("redis://192.168.56.10:6379");  // 单节点模式
                RedissonClient redissonClient = Redisson.create(config);
                return redissonClient;
            }
        }

1.4 队列削峰

秒杀时,扣减信号量,同时向消息队列发送一条消息。消息队列中的消息可以离线慢慢处理。
1. 引入依赖。
    groupId: org.springframework.boot
    artifactId: spring-boot-starter-amqp
2. 配置。
    spring.rabbitmq.host=192.168.56.10
    spring.rabbitmq.virtual-host=/
3. 创建配置类。
    @Configuration
    public class MyRabbitConfig{
        @Autowired
        RabbitTemplate rabbitTemplate;
        @Bean
        public MessageConverter messageConverter(){
            return new Jackson2JsonMessageConverter();
        }
    }
4. 创建队列。
    @Bean
    public Queue orderSeckillOrderQueue(){
        return new Queue("order.seckill.order.queue", true, false, false);
    }
5. 创建绑定关系。
    @Bean
    public Binding orderSeckillOrderQueueBinding(){
        return new Binding("order.seckill.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.seckill.order", null);
    }

1.5 登录状态检测+随机码

● 防止恶意请求攻击。
● 防止链接暴露,工作人员提前秒杀商品。

1.6 流量错峰

使用各种手段,将流量分担到更大的时间尺度上,比如使用验证码。

1.7 熔断和降级

详见本文第二部分。

2 秒杀商品上架

● 秒杀活动的单位是场次,商家会在每场秒杀中上架一些商品,每种商品都是限量的。
● 每个秒杀场次的时间是固定的,因此可以使用定时任务来开启。
● 需要提前将参与秒杀的商品的信息存入缓存。

2.1 定时任务

1. 引入依赖。
    不需要引入任何依赖。
2. 创建配置类。
    @EnableAsync
    @EnableScheduling
    @Configuration
    public class SchedulerConfig{
    }
3. 配置定时任务线程池。在配置文件中添加:
    spring.task.execution.pool.core-size=50
    spring.task.execution.pool.max-size=50
    (如果要使用自定义线程池,这一步可以跳过)
4. 测试。
    ● 定时任务默认是线程同步的,一个定时任务完成后其他定时任务才会开启,使用@Async注解可以使用定时任务线程池异步地执行定时任务。当然也可以不加@Async,手动地创建异步任务,使用自定义线程池异步执行。
    ● 定时调度的核心注解是@Scheduled,使用cron表达式控制该调度任务何时执行。
    @Component
    public class TestScheduler {
        @Async
        @Scheduled(cron = "0 0 0 * * ?")
        public void hello() {
            System.out.println("hello world");
        }
    }

2.2 Cron表达式

网上有很多在线的Cron表达式生成器。
在线Cron表达式生成器
quartz/Cron/Crontab表达式在线生成工具-BeJSON.com
所以对Cron表达式有个大概印象就可以了。

Cron基本规则:
● Cron表达式本身是一个长字符串,由7个短字符串组成,短字符串之间用空格隔开。7个字符串分别代表【秒 分 时 日 月 周 年】。其中年不被Spring支持,换言之,Spring中的Cron表达式只有6个短字符串。
● 特殊字符。
    , 表示枚举,cron="5,10 * * * * ?" 表示每分钟的第6秒(开始)和第11秒(开始)。
    - 表示范围,cron="0 3-5 * * * ?" 表示每小时的第4分钟第1秒、第5分钟第1秒、第6分钟第1秒。
    * 表示任意。
    / 表示步长,cron="0 0 0/2 * * ?" 表示每天0:00:00以后每经过2个小时的第1分钟第1秒。
    ? 用来防止日与周冲突,表示不指定,只能用在日和周两个位置,且不能同时使用。
    L 表示最后一个,cron="0 0 0 L * ?" 表示每个月的最后一天的0:00:00。
    W 表示工作日,cron="0 0 0 LW * ?" 表示每个月的最后一个工作日的0:00:00。
    # 表示第几个,cron="0 0 0 ? * 5#2" 表示每个月的第2个周四的0:00:00。
● 原生态的Cron表达式使用0-6表示周一到周日,但在Spring中,使用1-7来表示周一到周日。

2.3 商品定时上架

商品定时上架的规则是:每天凌晨3点,上架最近三天参与秒杀活动的商品。
1. 调用gulimall_coupon服务,获取最近三天参与秒杀活动的商品信息。
    1.1 根据系统当前时间,获取今天的0:00:00和后天的23:59:59两个时间点。推荐使用LocalDateTime。
    1.2 根据两个时间点查出期间的秒杀场次信息。
    1.3 根据秒杀场次ID查询关联的商品信息。
    1.4 返回一个秒杀场次列表。
2. 将商品信息缓存到session中。
    2.1 遍历秒杀场次列表。
        可以拼接场次的开始时间和结束时间得到一个长字符串,以这个长字符串为key,判断session中是否存在当前key,如果没有,将该秒杀场次信息(转化成JSON格式)存入session。
    2.3 再次遍历秒杀场次列表。
        2.3.1 为每个秒杀场次生成一个随机码token,可以用UUID。
        2.3.2 遍历每个场次关联的商品列表。
            2.3.2.1 可以拼接秒杀场次ID和SKU ID得到一个长字符串,以这个长字符串为key,判断session中是否存在当前key,如果没有,将该SKU信息存入session。
            2.3.2.2 向session存入SKU信息时,同时存入随机码。
            2.3.2.3 使用秒杀商品数目作为分布式信号量(秒杀商品时扣减信号量)。
                RSemaphore semaphore = redissonClient.getSemaphore("seckill:stock:" + token);
                semaphore.trySetPermits(num);

2.4 LocalDateTime

● 获取今天的0:00:00。
    LocalDate today = LocalDate.now();  // 获取今天的日期
    LocalTime min = LocalTime.MIN;  // 获取一天的最小时间
    LocalDateTime start = LocalDateTime.of(today, min);  // 组装日期和时间
    String startFormat = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  // 获取格式化的时间
● 获取后天的23:59:59。
    LocalDate today = LocalDate.now();  // 获取今天的日期
    LocalDate plus = today.plusDays(2);  // 将今天的日期向后推两天
    LocalTime max = LocalTime.MAX;  // 获取一天的最大时间
    LocalDateTime end = LocalDateTime.of(plus, min);  // 组装日期和时间
    String endFormat = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  // 获取格式化的时间

2.5 模型设计

秒杀缓存对象SeckillSessionEntity:
    Long id;  // 场次ID
    String name;  // 场次名称
    Date startTime;  // 开始时间
    Date endTime;  // 结束时间
    Integer status;  // 启用状态
    Date createTime;  // 创建时间
秒杀商品关联对象seckillSkuRelationEntity:
    Long id;  // 关联ID
    Long promotionId;  // 促销活动ID
    Long promotionSessionId;  // 促销活动缓存ID, 关联SeckillEntity.id
    Long skuId;  // SKU ID
    BigDecimal seckillPrice;  // 秒杀价格
    Integer seckillCount;  // 秒杀数目
    Integer seckillLimit;  // 秒杀限购数目
    Integer seckillSort;  // 秒杀排序

3 秒杀页面查询

用于页面展示秒杀商品,从session中查询已上架的商品信息。

4 用户秒杀商品

4.1 登录状态检测

详情见《谷粒商城》开发记录 10:注册和登录

4.2 秒杀商品

1. 从秒杀页面查询结果中获取参数:秒杀ID、随机码、秒杀商品数量。
    ● 秒杀ID:拼接秒杀场次ID和SKU ID得到的长字符串。
    ● 随机码:随机码。
    ● 秒杀商品数量:大于0,小于限购值。
2. 根据秒杀ID从session中获取该商品的详细信息。
3. 秒杀信息校验。
    3.1 校验时间。根据商品信息中的startTime和endTime两个属性校验秒杀时间是否合法。如果不合法,秒杀失败。
    3.2 校验随机码。判断页面传递随机码与商品信息中的randomCode属性是否一致,如果不一致,秒杀失败。
    3.3 验证秒杀商品数量是否合理。如果不合理,秒杀失败。
        3.3.1 秒杀商品数量小于等于商品信息中的seckillLimit属性值。
        3.3.2 秒杀商品数量小于等于当前信号量。
            redisTemplate.opsForValue().get("seckill:stock:" + randomCode);
    3.4 幂等性验证。如果bool为false,秒杀失败。
        Boolean bool = redisTemplate.opsForValue().setIfAbsent(key, num, ttl, TimeUnit.MILLISECONDS);
        ● key:用户ID拼接秒杀ID。
        ● num:秒杀商品数量。
        ● ttl:自动过期时间 = 秒杀场次结束时间 - 当前时间。
4. 尝试秒杀。有概率秒杀失败。
    RSemaphore semaphore = redissonClient.getSemaphore("seckill:stock:" + randomCode);
    boolean success = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
5. 如果秒杀成功,组装订单信息,向消息队列发送消息。
    rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", seckillOrderTo);
6. 如果秒杀成功,返回订单号。

4.3 处理秒杀消息

1. 监听器。
    @Component
    @RabbitListener(queues = "order.seckill.order.queue")
    public class OrderSeckillListener{
        @Autowired
        private OrderService orderService;
        @RabbitHandler
        public void listener(SeckillOrderTo orderTo, Channel channel, Message message) throws IOException{
            try {
                orderService.createSeckillOrder(orderTo);
                channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            } catch (Exception e){
                channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
            }
        }
    }
2. 创建秒杀单。
    保存订单、保存订单项信息。

4.4 模型设计

秒杀订单对象seckillOrderTo:
    String orderSn;  // 订单号
    Long promotionSessionId;  // 秒杀场次ID
    Long skuId;  // SKU ID
    BigDecimal seckillPrice;  // 秒杀价格
    Integer num;  // 购买数量
    Long memberId;  // 会员ID

二、熔断和降级

1 熔断和降级

● 降级:在网站处于流量高峰期时,根据当前的业务情况,我们可以有策略地停止一部分服务,以此缓解服务器的压力,保证核心业务正常运行。
● 熔断:如果A服务调用B服务时由于各种原因(网络不稳定、B服务宕机等)导致响应时间很长,我们可以直接将B服务切断,后面凡是发送给B服务的请求都直接返回降级数据,这样B服务的故障就不会级联影响到其他服务。

2 Sentinel

Home · alibaba/Sentinel Wiki · GitHub

2.1 整合Sentinel

1. common模块引入依赖。
    groupId: com.alibaba.cloud
    artifactId: spring-cloud-starter-alibaba-sentinel
2. 下载Sentinel控制台。
    https://github.com/alibaba/Sentinel/releases
    启动控制台。在下载路径下执行命令:
    java -jar sentinel-dashboard-1.6.3.jar --server.port=8333
    浏览器访问http://localhost:8333/可以登录控制台,用户名和密码默认都是sentinel。
3. 配置每个使用到Sentinel的服务。
    spring.cloud.sentinel.transport.dashboard=localhost:8333
    spring.cloud.sentinel.transport.port=8719
4. common模块创建配置类。
    @Configuration
    public class SeckillSentinelConfig{
        public SeckillSentinelConfig(){
            WebCallbackManager.setUrlBlockHandler(new UrlBlockHandler(){
                @Override
                public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException e){
                    R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getDesc());
                    response.setCharacterEncoding("UTF-8");
                    response.setContentType("application/json");
                    response.getWriter().write(JSON.toJSONString(error));
                }
            });
        }
    }

2.2 定义受保护的资源

● 抛出异常的方式。
    try (Entry entry = SphU.entry("resourceName")){
        // do something
    } catch (BlockException ex){
        // 降级处理
    }
    在Sentinel控制台可以对资源resourceName做流控、降级等操作。
● 注解的方式。
    @SentinelResource(blockHandler="blockHandlerForGetUser")
    public User getUserById(String id){
        throw new RuntimeException("getUserById command failed");
    }
    // 降级处理方法
    public User blockHandlerForGetUser(String id, BlockException ex){
        return new User("admin");
    }
    在Sentinel控制台可以对资源getUserById做流控、降级等操作。

2.3 支持远程调用

1. 引入依赖(创建gulimall-seckill服务时已完成)。
    groupId: org.springframework.cloud
    artifactId: spring-cloud-starter-openfeign
2. 配置。
    feign.sentinel.enabled=true
3. 创建远程调用降级回调方法。
    @FeignClient(value="gulimall-seckill", fallback=SeckillFeignServiceFallBack.class)
    public interface SeckillFeignService{
        @GetMapping("/sku/seckill/{skuId}")
        R getSkuSeckillInfo(@PathVariable("skuId") Long skuId);
    }
    @Component
    public class SeckillFeignServiceFallBack implements SeckillFeignService{
        @Override
        public R getSkuSeckillInfo(Long skuId){
            return R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getDesc());
        }
    }

2.4 支持网关流控

网关流控的思想是,当请求量过大时,直接在网关拒绝请求,执行降级处理方法。
1. 引入依赖。
    groupId: com.alibaba.cloud
    artifactId: spring-cloud-alibaba-sentinel-gateway
2. 创建配置类。
    @Configuration
    public class SentinelGatewayConfig{
        public SentinelGatewayConfig(){
            GatewayCallbackManager.setBlockHandler(new BlockRequestHandler(){
                @Override
                public Mono handleRequest(ServerWebExchange exchange, Throwble t){
                    R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getDesc());
                    String errJson = JSON.toJSONString(error);
                    Mono body = ServerResponse.ok().body(Mono.just(errJson), String.class);
                    return body;
                }
            });
        }
    }
3. 在Sentinel控制台对gulimall_seckill_route进行流控、降级等操作。

3 响应式编程

略。

三、链路追踪

1 基本术语

● Span:跨度,基本工作单元。发送一个远程调度任务就会产生一个Span,Span是一个64位ID唯一标识的,Trace是用另一个64位ID唯一标识的,Span还有其他数据信息,比如摘要、时间戳事件、Span的ID、以及进度ID。
● Trace:跟踪,一系列Span组成的一个树状结构。请求一个微服务系统的API接口,这个API接口需要调用多个微服务,调用每个微服务都会产生一个新的Span,所有由这个请求产生的Span组成了这个Trace。
● Annotation:标注,用来及时记录一个事件。一些核心注解用来定义一个请求的开始和结束。

2 整合Sleuth

1. 服务提供者和消费者都要引入Sleuth依赖。
    (下面要引入的Zipkin依赖包含了Sleuth,所以这一步可以跳过)
    groupId: org.springframework.cloud
    artifactId: spring-cloud-starter-sleuth
2. 开启debug日志。向配置文件中写入:
    logging.level.org.springframework.cloud.openfeign=debug
    logging.level.org.springframework.cloud.sleuth=debug

3 整合Zipkin

OpenZipkin · A distributed tracing system

Sleuth用来链路追踪,Zipkin用来可视化观察。
1. 引入依赖。
    groupId: org.springframework.cloud
    artifactId: spring-cloud-starter-zipkin
2. 配置。
    # Zipkin服务器的地址
    spring.zipkin.base-url=http://192.168.56.10:9411/
    # 关闭服务发现,否则Spring Cloud会把Zipkin的url当作服务名称
    spring.zipkin.discovery-client-enabled=false
    # 使用HTTP协议传输数据
    spring.zipkin.sender.type=web
    # 设置抽样采集率为100%,默认为10%
    spring.sleuth.sampler.probability=1
3. 网页登录。
    http://192.168.56.10:9411/

4 Zipkin数据持久化

推荐使用Elasticsearch作为Zipkin的存储数据库。
这个项目没有做,以后有机会再看下吧。

你可能感兴趣的:(谷粒商城,软件开发)