谷粒商城之高级篇(3)

时间:2022-12-30 16:01:10

2 商城业务

2.7 订单服务

2.7.22 创建业务交换机&队列

这里承接 知识补充篇 6 RabbitMQ

订单分布式主体逻辑

  • 订单超时未支付触发订单过期状态修改与库存解锁

创建订单时消息会被发送至队列order.delay.queue,经过TTL的时间后消息会变成死信以order.release.order的路由键经交换机转发至队列order.release.order.queue,再通过监听该队列的消息来实现过期订单的处理

  • 如果该订单已支付,则无需处理
  • 否则说明该订单已过期,修改该订单的状态并通过路由键order.release.other发送消息至队列stock.release.stock.queue进行库存解锁
  • 库存锁定后延迟检查是否需要解锁库存

在库存锁定后通过路由键stock.locked发送至延迟队列stock.delay.queue,延迟时间到,死信通过路由键stock.release转发至stock.release.stock.queue,通过监听该队列进行判断当前订单状态,来确定库存是否需要解锁

  • 由于关闭订单库存解锁都有可能被执行多次,因此要保证业务逻辑的幂等性,在执行业务是重新查询当前的状态进行判断
  • 订单关闭和库存解锁都会进行库存解锁的操作,来确保业务异常或者订单过期时库存会被可靠解锁

解锁库存的实现:

①库存服务导入RabbitMQ的依赖

  <!--  RabbitMQ的依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

② RabbitMQ的配置

spring.rabbitmq.host=192.168.56.10
spring.rabbitmq.virtual-host=/

配置RabbitMQ的序列化机制

@Configuration
public class MyRabbitConfig {
    /**
     * 使用JSON序列化机制,进行消息转换
     * @return
     */
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }
}

④ 开启RabbitMQ

谷粒商城之高级篇(3)

⑤ 按照下图创建交换机、队列、绑定关系

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

统一使用 topic交换机:因为交换机需要绑定多个队列,不同的路由键,且具有模糊匹配功能。

@Configuration
public class MyRabbitConfig {
    /**
     * 使用JSON序列化机制,进行消息转换
     * @return
     */
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }

    /**
     出现问题: 并没创建交换机、队列、绑定关系

     出现问题的原因:只有当第一次连接上RabbitMQ时,发现没有这些东西才会创建

     解决方案:监听队列
     * @param message
     */
    @RabbitListener(queues = "stock.release.stock.queue")
    public void handle(Message message){

    }

    @Bean
    public Exchange stockEventExchange(){
        //String name, boolean durable, boolean autoDelete, Map<String, Object> arguments
        return new TopicExchange("stock-event-exchange",true,false);
    }

    @Bean
    public Queue stockReleaseStockQueue(){

        //String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        return new Queue("stock.release.stock.queue",true,false,false,null);
    }

    /**
     * 延时队列
     * @return
     */
    @Bean
    public Queue stockDelayQueue() {
        Map<String, Object> arguments = new HashMap<>();

        arguments.put("x-dead-letter-exchange","stock-event-exchange");
        arguments.put("x-dead-letter-routing-key","stock.release");
        arguments.put("x-message-ttl",120000);

        return new Queue("stock.delay.queue",true,false,false,arguments);
    }

    @Bean
    public Binding stockReleaseBinding(){
        //String destination, DestinationType destinationType, String exchange, String routingKey,
        // 			Map<String, Object> arguments
       return  new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.release.#",
                null);
    }

    @Bean
    public Binding stockLockedBinding(){
        //String destination, DestinationType destinationType, String exchange, String routingKey,
        // 			Map<String, Object> arguments
        return  new Binding("stock.delay.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.locked",
                null);
    }

}

出现问题: 并没创建交换机、队列、绑定关系

出现问题的原因:只有当第一次连接上RabbitMQ时,发现没有这些东西才会创建

解决方案:监听队列

谷粒商城之高级篇(3)

交换机、队列、绑定关系创建成功后,将上述代码注释

2.7.23 监听库存解锁

库存解锁的两种场景:

①下单成功,订单过期没有支付被系统自动取消、被用户手动取消。都要解锁

②下单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁

更改数据库表wms_ware_order_task_detail

添加两个字段,方便库存回滚

谷粒商城之高级篇(3)

实体类中需要修改的:

WareOrderTaskDetailEntity 加上 全参和无参构造器方便消息传播该实体类数据。

谷粒商城之高级篇(3)

WareOrderTaskDetailDao.xml

谷粒商城之高级篇(3)

② 保存工作单详情方便回溯

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

③ Common服务中创建To,方便MQ发送消息

谷粒商城之高级篇(3)

@Data
public class StockLockedTo {

    private Long id;//库存工作单的id

    private Long detailId;//库存工作单详情id


}

如果To仅仅保存这个两个数据的话,会存在一些问题, 当1号订单在1号仓库扣减1件商品成功,2号订单在2号仓库扣减2件商品成功,3号订单在3号仓库扣减3件商品失败时,库存工作单的数据将会回滚,此时,数据库中将查不到1号和2号订单的库存工作单的数据,但是库存扣减是成功的,导致无法解锁库存

解决方案: 保存库存工作详情To

@Data
public class StockDetailTo {


    private Long id;
    /**
     * sku_id
     */
    private Long skuId;
    /**
     * sku_name
     */
    private String skuName;
    /**
     * 购买个数
     */
    private Integer skuNum;
    /**
     * 工作单id
     */
    private Long taskId;

    /**
     * 仓库id
     */
    private Long wareId;

    /**
     * 库存锁定状态
     */
    private Integer lockStatus;
}

谷粒商城之高级篇(3)

④ 向MQ发送库存锁定成功的消息

谷粒商城之高级篇(3)

库存回滚解锁
1)库存锁定
在库存锁定是添加以下逻辑

  • 由于可能订单回滚的情况,所以为了能够得到库存锁定的信息,在锁定时需要记录库存工作单,其中包括订单信息和锁定库存时的信息(仓库id,商品id,锁了几件…)
  • 在锁定成功后,向延迟队列发消息,带上库存锁定的相关信息

逻辑:

  • 遍历订单项,遍历每个订单项的每个库存,直到锁到库存

  • 发消息后库存回滚也没关系,用id是查不到数据库的

  • 锁库存的sql

这里编写了发送消息队列的逻辑,下面写接收消息队列后还原库存的逻辑。

2.7.24 库存解锁逻辑&库存自动解锁完成&测试库存自动解锁

解锁场景:

1.下单成功,库存锁定成功,接下来的业务如果调用失败导致订单回滚。之前锁定的库存就要自动解锁。

2.锁库存失败无需解锁

解决方案:通过查询订单的锁库存信息,如果有则仅仅说明库存锁定成功,还需判断是否有订单信息,如果有订单信息则判断订单状态,若订单状态已取消则解锁库存,反之:不能解锁库存,如果没有订单信息则需要解锁库存,如果没有锁库存信息则无需任何操作。

1.编写Vo,通过拷贝订单实体----> OrderEntity,用于接收订单信息

谷粒商城之高级篇(3)

2. 远程服务编写,获取订单状态

订单服务下编写:OrderController

    @GetMapping("/status/{orderSn}")
    public R getOrderStatus(@PathVariable("orderSn") String orderSn){
       OrderEntity orderEntity =  orderService.getOrderByOrderSn(orderSn);
       return R.ok().setData(orderEntity);
    }

实现:

  @Override
    public OrderEntity getOrderByOrderSn(String orderSn) {
        OrderEntity order_sn = this.getOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));

        return order_sn;
    }

3.监听事件

接收消息

  • 延迟队列会将过期的消息路由至"stock.release.stock.queue",通过监听该队列实现库存的解锁
  • 为保证消息的可靠到达,我们使用手动确认消息的模式,在解锁成功后确认消息,若出现异常则重新归队

库存服务 编写 StockReleaseListener

谷粒商城之高级篇(3)

@Service
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {

    @Autowired
    WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
        System.out.println("收到解锁库存的消息...");
        try {
            wareSkuService.unlockStock(to);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        }catch (Exception e){
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}

WareSkuServiceImpl

库存解锁

  • 如果工作单详情不为空,说明该库存锁定成功

    • 查询最新的订单状态,
    • 如果订单不存在,说明订单提交出现异常回滚,
    • 如果订单存在(但订单处于已取消的状态),我们要对已锁定的库存进行解锁
  • 如果工作单详情为空,说明库存未锁定,自然无需解锁

  • 为保证幂等性,我们分别对订单的状态和工作单的状态都进行了判断,只有当订单过期且工作单显示当前库存处于锁定的状态时,才进行库存的解锁


    @Autowired
    WareSkuDao wareSkuDao;

    @Autowired
    WareOrderTaskService orderTaskService;

    @Autowired
    WareOrderTaskDetailService orderTaskDetailService;


    @Autowired
    RabbitTemplate rabbitTemplate;

    @Autowired
    OrderFeignService orderFeignService;


/**
     * 1、库存自动解锁。
     * 下订单成功,库存锁定成功,接下来的业务如果调用失败,导致订单回滚。之前锁定的库存就要自动解锁。
     * 2、订单失败。
     * 锁库存失败。
     * <p>
     * 只要解锁库存的消息失败。一定要告诉服务器解锁失败。
     *
     * @param to
     */
    @Override
    public void unlockStock(StockLockedTo to) {
        StockDetailTo detail = to.getDetail();
        Long detailId = detail.getId();
        //解锁
        //1、查询数据库关于这个订单的锁定库存信息。
        //有:证明库存锁定成功了。
        //    解锁:查询订单情况。
        //          1、没有这个订单。必须解锁
        //          2、有这个订单。不是解锁库存。
        //                订单状态:已取消:解锁库存
        //                         没取消:不能解锁
        //没有:库存锁定失败了,库存回滚了。这种情况无需解锁
        WareOrderTaskDetailEntity byId = orderTaskDetailService.getById(detailId);
        if (byId != null) {
            //解锁
            Long id = to.getId();
            WareOrderTaskEntity taskEntity = orderTaskService.getById(id);
            String orderSn = taskEntity.getOrderSn();//根据订单号查询订单的状态
            R r = orderFeignService.getOrderStatus(orderSn);
            if (r.getCode() == 0) {
                //订单数据返回成功
                OrderVo data = r.getData(new TypeReference<OrderVo>() {
                });
                if (data == null || data.getStatus() == 4) {
                    //订单不存在
                    //订单已经被取消了。才能解锁库存
                    // detailId
                    if (byId.getLockStatus() == 1){
                        //当前库存工作单详情,状态 是1 :已锁定但是未解锁才可以解锁
                        unLockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId);
                    }
                }
            } else {
                //消息拒绝以后重新放到队里里面,让别人继续消费解锁。
                throw new RuntimeException("远程服务失败");
            }
        } else {
            //无需解锁
        }
    }

    /**
     * 解锁方法
     */
    private void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) {
        //库存解锁
        wareSkuDao.unlockStock(skuId, wareId, num);
        //更新库存工作单的状态
        WareOrderTaskDetailEntity entity = new WareOrderTaskDetailEntity();
        entity.setId(taskDetailId);
        entity.setLockStatus(2);//变为已解锁
        orderTaskDetailService.updateById(entity);

    }

WareSkuDao.xml

SQL编写:

UPDATE `wms_ware_sku` SET stock_locked = stock_locked -1
WHERE sku_id = 1 AND ware_id = 2
    <update id="unlockStock">
        UPDATE `wms_ware_sku` SET stock_locked = stock_locked -#{num}
        WHERE sku_id = #{skuId} AND ware_id = #{wareId}
    </update>

库存服务下编写:调用远程服务 OrderFeignService

@FeignClient("gulimall-order")
public interface OrderFeignService {

    @GetMapping("/order/order/status/{orderSn}")
    R getOrderStatus(@PathVariable("orderSn") String orderSn);
}

4. 远程服务调用可能会出现失败,需要设置手动ACK,确保其它服务能消费此消息

#手动ACK设置
spring.rabbitmq.listener.simple.acknowledge-mode=manual

出现问题: 远程调用订单服务时被拦截器拦截

解决方案:请求路径适配放行

订单服务下的 拦截器。

谷粒商城之高级篇(3)

2.7.25 定时关单完成

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

将就配合着看。

1.定时关单代码编写

①订单创建成功,给MQ发送关单消息

订单服务下的 OrderServiceImpl 的 submitOrder提交订单方法

谷粒商城之高级篇(3)

② 监听事件,进行关单

订单服务下

@Service
@RabbitListener(queues = "order.release.order.queue")
public class OrderCloseListener {

    @Autowired
    OrderService orderService;

    /**
     * 定时关单
     * @param entity
     * @param channel
     * @param message
     * @throws IOException
     */
    @RabbitHandler
    public void listener(OrderEntity entity, Channel channel, Message message) throws IOException {
        System.out.println("收到过期的订单消息:准备关闭订单" + entity.getOrderSn());
        try {
            orderService.closeOrder(entity);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            //失败了重新放到队列中,让其他消费者能够消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}

OrderServiceImpl

/**
     * 关单操作
     * @param entity
     */
    @Override
    public void closeOrder(OrderEntity entity) {

        //查询当前这个订单的最新状态
        OrderEntity orderEntity = this.getById(entity.getId());
        if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()){
            //关单
            OrderEntity update = new OrderEntity();
            update.setId(entity.getId());
            update.setStatus(OrderStatusEnum.CANCLED.getCode());
            this.updateById(update);
        }

    }

谷粒商城之高级篇(3)

订单释放和库存解锁逻辑: 当订单创建成功,1分钟之后,向MQ发送关单消息,2分钟后,向MQ发送解锁库存消息,关单操作完成之后,过了1分钟解锁库存操作。

存在问题:由于机器卡顿、消息延迟等导致关单消息延迟发送,解锁库存消息正常发送和监听,导致解锁库存消息被消费,当执行完关单操作后便无法再执行解锁库存操作,导致卡顿的订单永远无法解锁库存。

解决方案:采取主动补偿的策略。当关单操作正常完成之后,主动去发送解锁库存消息给MQ,监听解锁库存消息进行解锁。
谷粒商城之高级篇(3)

③ 按上图创建绑定关系

订单服务MyMQConfig

谷粒商城之高级篇(3)

④ common服务中,创建OrderTo(拷贝order实体)

谷粒商城之高级篇(3)

⑤ 向MQ发送解锁库存消息

谷粒商城之高级篇(3)

 /**
     * 关单操作
     * @param entity
     */
    @Override
    public void closeOrder(OrderEntity entity) {

        //查询当前这个订单的最新状态
        OrderEntity orderEntity = this.getById(entity.getId());
        if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()){
            //未付款状态,进行关单
            OrderEntity update = new OrderEntity();
            update.setId(entity.getId());
            update.setStatus(OrderStatusEnum.CANCLED.getCode());
            this.updateById(update);
            OrderTo orderTo = new OrderTo();
            BeanUtils.copyProperties(orderEntity,orderTo);
            //发给MQ一个消息:解锁库存
            rabbitTemplate.convertAndSend("order-event-exchange","order.release.other",orderTo);
        }

    }

⑥ 解锁库存操作

库存服务下

谷粒商城之高级篇(3)

    @RabbitHandler
    public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
        System.out.println("订单关闭准备解锁库存...");
        try {
            wareSkuService.unlockStock(orderTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        }catch (Exception e){
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }

谷粒商城之高级篇(3)

//防止订单服务卡顿,导致订单状态消息一直改不了,库存消息优先到期。查订单状态新建状态,什么都不做就走了。
    //导致卡顿的订单,永远不能解锁库存。
    @Transactional
    @Override
    public void unlockStock(OrderTo orderTo) {

        String orderSn = orderTo.getOrderSn();
        //查一下最新库存的状态,防止重复解锁库存
        WareOrderTaskEntity task = orderTaskService.getOrderTaskByOrderSn(orderSn);
        Long id = task.getOrderId();
        //按照工作单找到所有 没有解锁的库存,进行解锁
        List<WareOrderTaskDetailEntity> entities = orderTaskDetailService.list(
                new QueryWrapper<WareOrderTaskDetailEntity>()
                        .eq("task_id", id)
                        .eq("lock_status", 1));

        // Long skuId, Long wareId, Integer num, Long taskDetailId

        for (WareOrderTaskDetailEntity entity : entities) {
            unLockStock(entity.getSkuId(),entity.getWareId(),entity.getSkuNum(),entity.getId());
        }
    }

WareOrderTaskServiceImpl

  @Override
    public WareOrderTaskEntity getOrderTaskByOrderSn(String orderSn) {

        WareOrderTaskEntity one = this.getOne(new QueryWrapper<WareOrderTaskEntity>().eq("order_sn", orderSn));
        return one;

    }

2.7.26 消息丢失、积压、重复等解决方案

  • 如何保证消息可靠性-消息丢失

1、消息丢失

  • 消息发送出去,由于网络问题没有抵达服务器

    • 做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机制,可记录到数据库,采用定期扫描重发的方式
    • 做好日志记录,每个消息状态是否都被服务器收到都应该记录
    • 做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进行重发
  • 消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚未持久化完成,宕机。

    • publisher也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。
  • 自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机

    • 一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重新入队

情况一: 消息发送出去但是由于网络原因未到达服务器,解决方案:采用try-catch将发送失败的消息持久化到数据库中,采用定期扫描重发的方式。

drop table if exists mq_message;
CREATE TABLE `mq_message` (
	`message_id` CHAR(32) NOT NULL,
	`content` TEXT,#json
	`to_exchange` VARCHAR(255) DEFAULT NULL,
	`routing_key` VARCHAR(255) DEFAULT NULL,
	`class_type` VARCHAR(255) DEFAULT NULL,
	`message_status` INT(1) DEFAULT '0' COMMENT '0-新建  1-已发送  2-错误抵达  3-已抵达',
	`create_time` DATETIME DEFAULT NULL,
	`update_time` DATETIME DEFAULT NULL,
	PRIMARY KEY (`message_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4

情况二:消息抵达服务器的队列中才算完成消息的持久化,解决方案----->publish + consumer的两端的ack机制

谷粒商城之高级篇(3)

情况三: 防止自动ack带来的缺陷,采用手动ack,解决方案上面都有这里不再细说

  • 如何保证消息可靠性-消息重复

2、消息重复

  • 消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息重新由unack变为ready,并发送给其他消费者
  • 消息消费失败,由于重试机制,自动又将消息发送出去
  • 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送
    • 消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志
    • 使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,处理过就不用处理
    • rabbitMQ的每一个消息都有redelivered字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的

消息被成功消费,ack时宕机,消息由unack变成ready,Broker又重新发送。解决方案:将消费者的业务消费接口应该设计为幂等性的,比如扣库存有工作单的状态标志。

  • 如何保证消息可靠性-消息积压

3、消息积压

  • 消费者宕机积压
  • 消费者消费能力不足积压
  • 发送者发送流量太大
    • 上线更多的消费者,进行正常消费
    • 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理

消息积压即消费者的消费能力不够, 上线更多的消费者进行正常的消费。

2.8 支付业务

支付宝开放平台传送门:支付宝开放平台

网站支付DEMO传送门:手机网站支付 DEMO | 网页&移动应用

网站支付DEMO是用Eclipse编写的,代码结构如下图所示:

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

2.8.1 RSA、加密加签、密钥等相关概念

谷粒商城之高级篇(3)

对称加密:发送方和接收方用的是同一把密钥,存在问题:当某一方将密钥泄漏之后,发送的消息可以被截取获悉并且随意进行通信。

谷粒商城之高级篇(3)

非对称加密:发送方和接收方使用的不是同一把密钥,发送方使用密钥A对明文进行加密,接收方使用密钥B对密文进行解密,然后接收方将回复的明文用密钥C进行加密,发送方使用密钥D进行解密。采用非对称加密的好处是:即使有密钥被泄漏也不能*的通信

密钥的公私性是相对于生成者而言的。发送方通过密钥A对明文进行加密,密钥A是只有发送方自己知道的,接收方想要解密密文,就需要拿到发送方公布出来的密钥B。

公钥:生成者发布的密钥可供大家使用的

私钥:生成者自己持有的密钥

谷粒商城之高级篇(3)

签名:为了防止中途传输的数据被篡改和使用的方便,发送方采用私钥生成明文对应的签名,此过程被成为加签。接收方使用公钥去核验明文和签名是否对应,此过程被成为验签。

配置支付宝的沙箱环境:

沙箱环境配置查看传送门:登录 - 支付宝

谷粒商城之高级篇(3)

接口加签方式共有两种:

①采用系统默认生成的支付宝的公钥、应用的私钥和公钥:

谷粒商城之高级篇(3)

② 采用自定义密钥

谷粒商城之高级篇(3)

传送门:密钥工具下载

谷粒商城之高级篇(3)

将支付宝密钥工具生成的应用公钥复制进加签内容配置中,会自动生成支付宝的公钥

沙箱账号:用于测试环境中的商品支付

沙箱账号

谷粒商城之高级篇(3)

使用eclipse测试:

注意如果项目报红:因为老师给的 沙箱测试Demo 默认使用的 是 tomact7.0 ,所以这里需要将 tomact7.0移除掉,使用我们自己本机安装的 tomcat。

谷粒商城之高级篇(3)

选择 tomcat 7.0移除 ,然后导入自己的 就行。这里我已经移除好了。

谷粒商城之高级篇(3)

参考:当eclipse导入新项目后出现红叉解决办法


老师课件:

一、支付宝支付
1、进入“蚂蚁金服开放平台”
https://open.alipay.com/platform/home.htm

2、下载支付宝官方demo,进行配置和测试

文档地址
https://open.alipay.com/platform/home.htm 支付宝&蚂蚁金服开发者平台

谷粒商城之高级篇(3)

https://docs.open.alipay.com/catalog 开发者文档
https://docs.open.alipay.com/270/106291/ 全部文档=>电脑网站支付文档;下载demo

谷粒商城之高级篇(3)

3、配置使用沙箱进行测试
1、使用RSA 工具生成签名
2、下载沙箱版钱包
3、运行官方demo 进行测试

4、什么是公钥、私钥、加密、签名和验签?
1、公钥私钥
公钥和私钥是一个相对概念
它们的公私性是相对于生成者来说的。
一对密钥生成后,保存在生成者手里的就是私钥,
生成者发布出去大家用的就是公钥

2、加密和数字签名

  • 加密是指:

    • 我们使用一对公私钥中的一个密钥来对数据进行加密,而使用另一个密钥来进行解
      密的技术。
    • 公钥和私钥都可以用来加密,也都可以用来解密。
    • 但这个加解密必须是一对密钥之间的互相加解密,否则不能成功。
    • 加密的目的是:
      • 为了确保数据传输过程中的不可读性,就是不想让别人看到。
  • 签名:

    • 给我们将要发送的数据,做上一个唯一签名(类似于指纹)
    • 用来互相验证接收方和发送方的身份;
    • 在验证身份的基础上再验证一下传递的数据是否被篡改过。因此使用数字签名可以
      用来达到数据的明文传输。
  • 验签

    • 支付宝为了验证请求的数据是否商户本人发的,
    • 商户为了验证响应的数据是否支付宝发的

2.8.2 内网穿透

谷粒商城之高级篇(3)

如果别人直接访问我们自己电脑的本地项目,是不能访问的。

谷粒商城之高级篇(3)

内网穿透的原理: 内网穿透服务商是正常外网可以访问的ip地址,我们的电脑通过下载服务商软件客户端并与服务器建立起长连接,然后服务商就会给我们的电脑分配一个域名,别人的电脑访问hello.hello.com会先找到hello.com即一级域名,然后由服务商将请求转发给我们电脑的二级域名;从而实现了别人可以通过IP地址访问我们本地的项目。

下面是老师课件:

二、内网穿透

1、简介
内网穿透功能可以允许我们使用外网的网址来访问主机;
正常的外网需要访问我们项目的流程是:

  • 1、买服务器并且有公网固定IP
  • 2、买域名映射到服务器的IP
  • 3、域名需要进行备案和审核

2、使用场景

  • 1、开发测试(微信、支付宝)
  • 2、智慧互联
  • 3、远程控制
  • 4、私有云

3、内网穿透的几个常用软件
1、natapp:https://natapp.cn/ 优惠码:022B93FD(9 折)[仅限第一次使用]
2、续断:www.zhexi.tech 优惠码:SBQMEA(95 折)[仅限第一次使用]
3、花生壳:https://www.oray.com/

老师课件中使用的 是 续断进行测试的,这里我们使用免费的内网穿透工具进行测试。

内网穿免费工具下载地址:cpolar - 安全的内网穿透工具

使用教程: Win系统如何下载安装使用cpolar内网穿透工具?_Cpolar Lisa的博客-CSDN博客

超好用的内网穿透工具【永久免费不限制流量】

上面两个 教程都可以参考,个人推荐 第二个教程。

已经成功建立连接。

谷粒商城之高级篇(3)

【需要注意的是,对于免费版本的cpolar随机URL地址是会在24小时之后变化的,如果需要进一步使用,可以将站点配置成二级子域名,或自定义域名(使用自己的域名)长期使用。】

配置修改:

修改url前缀:

谷粒商城之高级篇(3)

测试:访问成功。

谷粒商城之高级篇(3)

2.8.3 整合支付

注意,需要保证所有项目的编码格式都是 utf-8

谷粒商城之高级篇(3)

1.导入支付宝支付SDK的依赖

阿里支付依赖传送门

订单服务

  <!-- https://mvnrepository.com/artifact/com.alipay.sdk/alipay-sdk-java -->
        <!--导入支付宝的SDK-->
        <dependency>
            <groupId>com.alipay.sdk</groupId>
            <artifactId>alipay-sdk-java</artifactId>
            <version>4.35.7.ALL</version>
        </dependency>

2. 编写AlipayTemplate工具类和PayVo

直接复制老师给的课件。

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

更改一些,这里我使用的是 绑定配置文件的方式来声明一些变量的:因为老师使用了一个@ConfigurationProperties(prefix = “alipay”)。

AlipayTemplate

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {

    //在支付宝创建的应用的id
    @Value("${alipay.app_id}")//这里使用的是绑定配置文件的方式
    private   String app_id;

    // 商户私钥,您的PKCS8格式RSA2私钥
    @Value("${alipay.merchant_private_key}")
    private  String merchant_private_key;

    // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
    @Value("${alipay.alipay_public_key}")
    private  String alipay_public_key;

    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    @Value("${alipay.notify_url}")
    private  String notify_url;

    // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    @Value("${alipay.return_url}")
    private  String return_url;

    // 签名方式
    private  String sign_type = "RSA2";

    // 字符编码格式
    private  String charset = "utf-8";

    // 支付宝网关; https://openapi.alipaydev.com/gateway.do
    @Value("${alipay.gatewayUrl}")
    private  String gatewayUrl;

    public  String pay(PayVo vo) throws AlipayApiException {

        //AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
        //1、根据支付宝的配置生成一个支付客户端
        AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
                app_id, merchant_private_key, "json",
                charset, alipay_public_key, sign_type);

        //2、创建一个支付请求 //设置请求参数
        AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
        alipayRequest.setReturnUrl(return_url);
        alipayRequest.setNotifyUrl(notify_url);

        //商户订单号,商户网站订单系统中唯一订单号,必填
        String out_trade_no = vo.getOut_trade_no();
        //付款金额,必填
        String total_amount = vo.getTotal_amount();
        //订单名称,必填
        String subject = vo.getSubject();
        //商品描述,可空
        String body = vo.getBody();

        alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\","
                + "\"total_amount\":\""+ total_amount +"\","
                + "\"subject\":\""+ subject +"\","
                + "\"body\":\""+ body +"\","
                + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

        String result = alipayClient.pageExecute(alipayRequest).getBody();

        //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
        System.out.println("支付宝的响应:"+result);

        return result;

    }
}

application.properties

谷粒商城之高级篇(3)PayVo

@Data
public class PayVo {
    private String out_trade_no; // 商户订单号 必填
    private String subject; // 订单名称 必填
    private String total_amount;  // 付款金额 必填
    private String body; // 商品描述 可空
}

3.访问支付接口

谷粒商城之高级篇(3)

4. 编写支付接口

produces属性:用于设置返回的数据类型

AlipayTemplate的pay()方法返回的就是一个用于浏览器响应的付款页面

PayWebController

@Controller
public class PayWebController {

    @Autowired
    AlipayTemplate alipayTemplate;


    @Autowired
    OrderService orderService;

    /**
     * 1、将支付页让浏览器展示。
     * 2、支付成功以后,我们要跳到用户的订单列表页。
     * @param orderSn
     * @return
     * @throws AlipayApiException
     */
    @ResponseBody
    @GetMapping(value = "/payOrder",produces = "text/html")
    public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {

        // PayVo payVo = new PayVo();
        // payVo.setBody();//订单的备注
        // payVo.setOut_trade_no();//订单号
        // payVo.setSubject();//订单的主题
        // payVo.setTotal_amount();//订单的金额
       PayVo payVo =  orderService.getOrderPay(orderSn);
       //返回的是一个页面。将此页面直接交给浏览器就行
        String pay = alipayTemplate.pay(payVo);
        System.out.println(pay);
        return "hello";


    }


}

实现:

OrderService

    /**
     * 获取当前订单的支付信息
     * @param orderSn
     * @return
     */
    PayVo getOrderPay(String orderSn);

OrderServiceImpl

应付金额需要处理,支付宝只能支付保留两位小数的金额,采用ROUND_UP的进位模式

  @Override
    public PayVo getOrderPay(String orderSn) {

        PayVo payVo = new PayVo();
        OrderEntity order = this.getOrderByOrderSn(orderSn);

        //数据库 应付金额显示的4位小数:1215.0000
        //支付宝要求是两位小数,此外我们设置有余数就进位:譬如:12.0001   变为 12.01
        BigDecimal bigDecimal = order.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
        //设置金额
        payVo.setTotal_amount(bigDecimal.toString());
        //设置订单号
        payVo.setOut_trade_no(order.getOrderSn());

        List<OrderItemEntity> order_sn = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
        OrderItemEntity entity = order_sn.get(0);//得到订单中的第一个商品

        payVo.setSubject(entity.getSkuName());//这里我们将订单的第一个商品的名字设置为提示
        payVo.setBody(entity.getSkuAttrsVals());//销售属性设为 订单的备注
        return payVo;


    }

2.8.4 支付成功同步回调

1.会员服务导入thymeleaf的依赖并配置

        <!--  Thymeleaf的依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

开发环境下,关闭thymeleaf的缓存

spring.thymeleaf.cache=false

2.将订单页文件夹中的index.html复制到会员服务的templates下并更名为orderList.html,将静态资源复制到Nginx中并替换访问路径【动静分离】

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

Nginx 中 配置静态资源位置。

谷粒商城之高级篇(3)

修改 orderList.html中的静态资源前缀:

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

3. 配置网关及域名映射

配置 域名映射:192.168.56.10 member.gulimall.com

谷粒商城之高级篇(3)

配置网关:

谷粒商城之高级篇(3)

5. 引入Spring-Session

①导入依赖

        <!--引入 redis-->
		<!--导入Spring Session with redis 依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

        <!--导入SpringBoot整合Redis的依赖-->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

② 配置

spring.session.store-type=redis

spring.redis.host=192.168.56.10

复制订单服务中有关session的配置

谷粒商城之高级篇(3)

/**一个新系统引入需要以下配置
 * 1、spring-session依赖等导入
 * 2、spring-session配置
 * 3、引入 LoginUserInterceptor  WebMvcConfig等
 */
@Configuration
public class GulimallSessionConfig {


    @Bean
    public CookieSerializer cookieSerializer() {


        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();

        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");


        return cookieSerializer;


    }


    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

}

③ 启用Spring-Session

主启动类 加入 @EnableRedisHttpSession注解

谷粒商城之高级篇(3)

④ 配置拦截器

远程服务调用获取运费信息,都给放过

谷粒商城之高级篇(3)

@Component
public class LoginUserInterceptor implements HandlerInterceptor {


    public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 库存解锁需要远程调用---->需要登录:所以设置拦截器不拦截 order/order/status/{orderSn}。
        // 对于查询订单等请求直接放行。
        // /order/order/status/45648913346789494


        //远程服务调用获取运费信息,都给放过
        ///member/memberreceiveaddress/info/{id}
        String uri = request.getRequestURI();
        boolean match = new AntPathMatcher().match("/member/**", uri);
        if (match){
            return true;
        }


        //获取登录用户
        MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
        if (attribute != null) {
            //登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息:
            //加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
            loginUser.set(attribute);
            return true;
        } else {
            //没登录就去登录
            request.getSession().setAttribute("msg", "请先进行登录");
            response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }

    }
}

谷粒商城之高级篇(3)

注册拦截器

谷粒商城之高级篇(3)

@Configuration
public class MemberMvcConfigurer implements WebMvcConfigurer {

    @Autowired
    LoginUserInterceptor loginUserInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
    }
}

6. 前端页面跳转修改

首页将我的订单处修改

谷粒商城之高级篇(3)

7. controller编写

@Controller
public class MemberWebController {


    @GetMapping("/memberOrder.html")
    public String memberOrderPage(){

        //查出当前登录的用户的所有订单列表数据
        return "orderList";
    }

}

8.配置支付成功后的跳转页面

订单服务的 application.properties中修改 成功回调的地址。

谷粒商城之高级篇(3)

2.8.5 订单列表页渲染完成

1.远程服务调用获取订单项详情

①订单服务中编写获取订单项详情接口

OrderController

    /**
     * 分页查询当前登录用户的所有订单
     */
    @PostMapping("/listWithItem")
    //@RequiresPermissions("order:order:list")
    public R listWithItem(@RequestBody Map<String, Object> params){
        PageUtils page = orderService.queryPageWithItem(params);

        return R.ok().put("page", page);
    }

② 订单实体中编写订单项属性

OrderEntity

	//表明不是数据库中的字段
	@TableField(exist = false)
	private List<OrderItemEntity> itemEntities;

③ 分页查询订单项详情接口实现

OrderServiceImpl

    @Override
    public PageUtils queryPageWithItem(Map<String, Object> params) {
        //获取用户信息
        MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();

        //根据用户id获取最新的订单
        IPage<OrderEntity> page = this.page(
                new Query<OrderEntity>().getPage(params),
                new QueryWrapper<OrderEntity>().eq("member_id",memberRespVo.getId()).orderByDesc("id")
        );

        List<OrderEntity> order_sn = page.getRecords().stream().map(order -> {
            //根据订单号获取订单项数据
            List<OrderItemEntity> itemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>()
                    .eq("order_sn", order.getOrderSn()));

            order.setItemEntities(itemEntities);
            return order;
        }).collect(Collectors.toList());

        page.setRecords(order_sn);

        return new PageUtils(page);
    }

④ 会员服务远程调用订单服务查询订单项详情接口编写

@FeignClient("gulimall-order")
public interface OrderFeignService {

    @PostMapping("/order/order/listWithItem")
    //@RequiresPermissions("order:order:list")
    R listWithItem(@RequestBody Map<String, Object> params);
}

MemberWebController

@Controller
public class MemberWebController {


    @Autowired
    OrderFeignService orderFeignService;


    @GetMapping("/memberOrder.html")
    public String memberOrderPage(@RequestParam(value = "pageNum",defaultValue = "1") Integer pageNum,
                                  Model model){

        //查出当前登录的用户的所有订单列表数据
        HashMap<String, Object> page = new HashMap<>();
        page.put("page",pageNum.toString());

        R r = orderFeignService.listWithItem(page);
        System.out.println(JSON.toJSONString(r));
        model.addAttribute("orders",r);
        return "orderList";
    }

}

会出现两个问题:

①远程服务调用未携带cookie信息被拦截器拦截需要登录

解决方案:远程调用时拦截器将老请求的请求头信息再次封装

@Configuration
public class GuliFeignConfig {

    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                //1、RequestContextHolder拿到刚进来的这个请求
                ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                // System.out.println("RequestInterceptor线程..." + Thread.currentThread().getId());
                if (attributes != null) {
                    HttpServletRequest request = attributes.getRequest();//老请求
                    if (request != null) {
                        //同步请求头数据,Cookie
                        String cookie = request.getHeader("Cookie");
                        //给新请求同步了老请求的cookie
                        template.header("Cookie", cookie);
                    }
                }
            }
        };
    }


}

②getPage()将String类型的page又强转为String

com.atguigu.common.utils.Query类:

谷粒商城之高级篇(3)

解决方案:

谷粒商城之高级篇(3)

2.前端页面展示

只保留一个table用于遍历

谷粒商城之高级篇(3)

遍历订单

谷粒商城之高级篇(3)

获取订单号

谷粒商城之高级篇(3)

遍历订单项

谷粒商城之高级篇(3)

固定照片大小,取出图片

谷粒商城之高级篇(3)

获取商品描述

谷粒商城之高级篇(3)

获取订单项数量、收货人姓名、应付总额

谷粒商城之高级篇(3)

获取订单状态

谷粒商城之高级篇(3)

改进: 这些信息只出现一次,所占行数依据订单项数而定

谷粒商城之高级篇(3)

打印结果如下:

谷粒商城之高级篇(3)

只遍历一次,有几个商品占几行

谷粒商城之高级篇(3)

缺失

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

<table class="table" th:each="order : ${orders.page.list}">
          <tr>
            <td colspan="7" style="background:#F7F7F7" >
              <span style="color:#AAAAAA">2017-12-09 20:50:10</span>
              <span><ruby style="color:#AAAAAA">订单号:</ruby> [[${order.orderSn}]]</span>
              <span>谷粒商城<i class="table_i"></i></span>
              <i class="table_i5 isShow"></i>
            </td>
          </tr>
          <tr class="tr" th:each=" item,itemStat : ${order.itemEntities}">
            <td colspan="3" style="border-right: 1px solid #ccc;">
              <img style="height: 60px;width: 60px" th:src="${item.skuPic}" alt="" class="img">
              <div>
                <p style="width: 242px;height: auto;overflow: auto">
                  [[${item.skuName}]]
                </p>
                <div><i class="table_i4"></i>找搭配</div>
              </div>
              <div style="margin-left:15px;">x[[${item.skuQuantity}]]</div>
              <div style="clear:both"></div>
            </td>
            <td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}">[[${order.receiverName}]]<i><i class="table_i1"></i></i></td>
            <td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}" style="padding-left:10px;color:#AAAAB1;">
              <p style="margin-bottom:5px;">总额 ¥[[${order.payAmount}]]</p>
              <hr style="width:90%;">
              <p>在线支付</p>
            </td>
            <td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}">
              <ul>
                <li style="color:#71B247;" th:if="${order.status==0}">待付款</li>
                <li style="color:#71B247;" th:if="${order.status==1}">已付款</li>
                <li style="color:#71B247;" th:if="${order.status==2}">已发货</li>
                <li style="color:#71B247;" th:if="${order.status==3}">已完成</li>
                <li style="color:#71B247;" th:if="${order.status==4}">已取消</li>
                <li style="color:#71B247;" th:if="${order.status==5}">售后中</li>
                <li style="color:#71B247;" th:if="${order.status==6}">售后完成</li>
                <li style="margin:4px 0;" class="hide"><i class="table_i2"></i>跟踪<i class="table_i3"></i>
                    <div class="hi">
                      <div class="p-tit">
                        普通快递   运单号:390085324974
                      </div>
                      <div class="hideList">
                        <ul>
                          <li>
                            [北京市] 在北京昌平区南口公司进行签收扫描,快件已被拍照(您
                            的快件已签收,感谢您使用韵达快递)签收
                          </li>
                          <li>
                            [北京市] 在北京昌平区南口公司进行签收扫描,快件已被拍照(您
                            的快件已签收,感谢您使用韵达快递)签收
                          </li>
                          <li>
                            [北京昌平区南口公司] 在北京昌平区南口公司进行派件扫描
                          </li>
                          <li>
                            [北京市] 在北京昌平区南口公司进行派件扫描;派送业务员:业务员;联系电话:17319268636
                          </li>
                        </ul>
                      </div>
                    </div>
                </li>
                <li class="tdLi">订单详情</li>
              </ul>
            </td>
            <td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}">
              <button>确认收货</button>
              <p style="margin:4px 0; ">取消订单</p>
              <p>催单</p>
            </td>
          </tr>
        </table>

效果展示:

谷粒商城之高级篇(3)

ps:注意:如果使用的 cpolar进行内网穿透测试,每隔24小时需要更换一下 url地址。

获取隧道地址:http://127.0.0.1:9200/#/status/online

更换这个 地址:

服务器[异步通知]页面路径

谷粒商城之高级篇(3)

2.8.6 异步通知内网穿透环境搭建

支付回调异步通知:异步通知参数说明 | 网页&移动应用

支付宝采用的是最终一致性中的最大努力通知策略

谷粒商城之高级篇(3)

①搭建隧道

图形管理界面方式:

谷粒商城之高级篇(3)

或者使用命令行方式:

cpolar http 192.168.56.10:80

测试:需要成功访问到Nginx

谷粒商城之高级篇(3)

配置支付成功后的回调请求路径

alipay.notify_url=http://81953a3.vip.cpolar.cn/payed/notify

订单服务下回调接口编写,成功响应后必须返回给支付宝success

@RestController
public class OrderPayedListener {



    @PostMapping("/payed/notify")
    public String handleAlipayed(HttpServletRequest request){
        //只要我们收到了支付宝给我们的异步通知,告诉我们订单支付成功。
        //我们返回 success,支付宝就再也不会通知。
        Map<String, String[]> map = request.getParameterMap();
        System.out.println("支付宝通知到位了...数据:"+ map);
        return "success";
    }

③ 配置拦截器放过

支付宝异步通知不需要进行登录。

谷粒商城之高级篇(3)

④ 配置Nginx

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

注意细节:

1.配置域名,否则将会路由给静态页面

2.精确匹配要在模糊匹配的上面

在 gulimall.conf中进行配置:

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

重启Nginx

docker restart nginx

postman 客户端中测试:

谷粒商城之高级篇(3)

2.8.7 支付完成

1.将支付宝支付成功后的异步通知信息抽取成vo

复制老师课件。

谷粒商城之高级篇(3)

@ToString
@Data
public class PayAsyncVo {

    private String gmt_create;
    private String charset;
    private String gmt_payment;
    private Date notify_time;
    private String subject;
    private String sign;
    private String buyer_id;//支付者的id
    private String body;//订单的信息
    private String invoice_amount;//支付金额
    private String version;
    private String notify_id;//通知id
    private String fund_bill_list;
    private String notify_type;//通知类型; trade_status_sync
    private String out_trade_no;//订单号
    private String total_amount;//支付的总额
    private String trade_status;//交易状态  TRADE_SUCCESS
    private String trade_no;//流水号
    private String auth_app_id;//
    private String receipt_amount;//商家收到的款
    private String point_amount;//
    private String app_id;//应用id
    private String buyer_pay_amount;//最终支付的金额
    private String sign_type;//签名类型
    private String seller_id;//商家的id

}

2. 配置SpringMVC日期转化格式

spring.mvc.date-format=yyyy-MM-dd HH:mm:ss

3. 验签,确保是支付宝返回的信息

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

验签核心代码

	//获取支付宝POST过来反馈信息
	Map<String,String> params = new HashMap<String,String>();
	Map<String,String[]> requestParams = request.getParameterMap();
	for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
		String name = (String) iter.next();
		String[] values = (String[]) requestParams.get(name);
		String valueStr = "";
		for (int i = 0; i < values.length; i++) {
			valueStr = (i == values.length - 1) ? valueStr + values[i]
					: valueStr + values[i] + ",";
		}
		//乱码解决,这段代码在出现乱码时使用
		valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
		params.put(name, valueStr);
	}
	
	boolean signVerified = AlipaySignature.rsaCheckV1(params, AlipayConfig.alipay_public_key, AlipayConfig.charset, AlipayConfig.sign_type); //调用SDK验证签名

	//——请在这里编写您的程序(以下代码仅作参考)——
	
	/* 实际验证过程建议商户务必添加以下校验:
	1、需要验证该通知数据中的out_trade_no是否为商户系统中创建的订单号,
	2、判断total_amount是否确实为该订单的实际金额(即商户订单创建时的金额),
	3、校验通知中的seller_id(或者seller_email) 是否为out_trade_no这笔单据的对应的操作方(有的时候,一个商户可能有多个seller_id/seller_email)
	4、验证app_id是否为该商户本身。
	*/
	if(signVerified) {//验证成功
		//商户订单号
		String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8");
	
		//支付宝交易号
		String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8");
	
		//交易状态
		String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"),"UTF-8");
		
		if(trade_status.equals("TRADE_FINISHED")){
			//判断该笔订单是否在商户网站中已经做过处理
			//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
			//如果有做过处理,不执行商户的业务程序
				
			//注意:
			//退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
		}else if (trade_status.equals("TRADE_SUCCESS")){
			//判断该笔订单是否在商户网站中已经做过处理
			//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
			//如果有做过处理,不执行商户的业务程序
			
			//注意:
			//付款完成后,支付宝系统发送该交易状态通知
		}
		
		out.println("success");
		
	}else {//验证失败
		out.println("fail");
	
		//调试用,写文本函数记录程序运行情况是否正常
		//String sWord = AlipaySignature.getSignCheckContentV1(params);
		//AlipayConfig.logResult(sWord);
	}

4. 业务处理(①保存交易流水号②修改订单状态)

确保流水号的唯一性,添加索引

谷粒商城之高级篇(3)

将订单号设置的长度变长一点,防止订单号设置错误。

谷粒商城之高级篇(3)

以下两种状态都是支付成功状态

谷粒商城之高级篇(3)

代码实现:

OrderPayedListener

@RestController
public class OrderPayedListener {


    @Autowired
    OrderService orderService;

    @Autowired
    AlipayTemplate alipayTemplate;


    @PostMapping("/payed/notify")
    public String handleAlipayed(PayAsyncVo vo,HttpServletRequest request) throws UnsupportedEncodingException, AlipayApiException {
        //只要我们收到了支付宝给我们的异步通知,告诉我们订单支付成功。
        //我们返回 success,支付宝就再也不会通知。
        // Map<String, String[]> map = request.getParameterMap();
        // for (String key : map.keySet()) {
        //     String value = request.getParameter(key);
        //     System.out.println("参数名:"+ key +"==>参数值:"+ value);
        // }
        // System.out.println("支付宝通知到位了...数据:"+ map);
        //验签
        //获取支付宝POST过来反馈信息
        Map<String,String> params = new HashMap<String,String>();
        Map<String,String[]> requestParams = request.getParameterMap();
        for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
            String name = (String) iter.next();
            String[] values = (String[]) requestParams.get(name);
            String valueStr = "";
            for (int i = 0; i < values.length; i++) {
                valueStr = (i == values.length - 1) ? valueStr + values[i]
                        : valueStr + values[i] + ",";
            }
            //乱码解决,这段代码在出现乱码时使用
            // valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
            params.put(name, valueStr);
        }

        boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名

        if (signVerified){
            System.out.println("签名验证成功...");
            String result = orderService.handlePayResult(vo);
            return result;
        }else {
            System.out.println("签名验证失败...");
            return "error";
        }
    }
}

实现:

OrderServiceImpl

 	@Autowired
    PaymentInfoService paymentInfoService;    
	/**
     * 处理支付宝支付成功修改订单状态
     *
     * @param vo
     * @return
     */
    @Override
    public String handlePayResult(PayAsyncVo vo) {

        //1、保存交易流水
        PaymentInfoEntity infoEntity = new PaymentInfoEntity();
        infoEntity.setAlipayTradeNo(vo.getTrade_no());
        infoEntity.setOrderSn(vo.getOut_trade_no());
        infoEntity.setPaymentStatus(vo.getTrade_status());
        infoEntity.setCallbackTime(vo.getNotify_time());

        paymentInfoService.save(infoEntity);


        //修改订单的状态信息
        if (vo.getTrade_status().equals("TRADE_SUCCESS") || vo.getTrade_status().equals("TRADE_FINISHED")) {
            //支付成功状态
            String outTradeNo = vo.getOut_trade_no();
            this.baseMapper.updataOrderStatus(outTradeNo, OrderStatusEnum.PAYED.getCode());
        }
        return "success";

    }

OrderDao

void updataOrderStatus(@Param("outTradeNo") String outTradeNo, @Param("code") Integer code);

OrderDao.xml

    <update id="updataOrderStatus">
        UPDATE `oms_order` SET `status` = #{code} WHERE order_sn = #{outTradeNo}
    </update>

sql代码:

UPDATE `oms_order` SET `status` = ? WHERE order_sn = ?

测试:

控制台成功打印:

谷粒商城之高级篇(3)

页面显示:已付款

谷粒商城之高级篇(3)

2.8.8 收单

1、订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态改为已支付了,但是库 存解锁了。

  • 使用支付宝自动收单功能解决。只要一段时间不支付,就不能支付了。

2、由于时延等问题。订单解锁完成,正在解锁库存的时候,异步通知才到

  • 订单解锁,手动调用收单

3、网络阻塞问题,订单支付成功的异步通知一直不到达

  • 查询订单列表时,ajax获取当前未支付的订单状态,查询订单状态时,再获取一下支付宝 此订单的状态

4、其他各种问题

  • 每天晚上闲时下载支付宝对账单,一一进行对账

情况一:订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态已经改为已付款但是库存解锁了

解决方案:自动关单

谷粒商城之高级篇(3)

AlipayTemplate

谷粒商城之高级篇(3)

情况二: 由于网络延时原因,订单解锁完成,正要解锁库存时,异步通知才到

解决方案:订单解锁,手动关单

谷粒商城之高级篇(3)

2.9 秒杀业务

2.9.1 后台管理系统完善

启动人人开源后台和前端,点击 优惠营销-每日秒杀。

谷粒商城之高级篇(3)

完善这个每日秒杀业务,当点击每日秒杀,发送的请求:

谷粒商城之高级篇(3)

①网关配置:

谷粒商城之高级篇(3)

②访问成功后,添加两个秒杀场次:

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

③当点击 每个场次 的关联商品

谷粒商城之高级篇(3)

发送如下请求:谷粒商城之高级篇(3)

修改 后台代码:

首先,这个请求是在 优惠券服务的 SeckillSkuRelationController 的 list方法下,所以修改查询时的参数。

SeckillSkuRelationServiceImpl

@Service("seckillSkuRelationService")
public class SeckillSkuRelationServiceImpl extends ServiceImpl<SeckillSkuRelationDao, SeckillSkuRelationEntity> implements SeckillSkuRelationService {

    @Override
    public PageUtils queryPage(Map<String, Object> params) {
        QueryWrapper<SeckillSkuRelationEntity> queryWrapper = new QueryWrapper<SeckillSkuRelationEntity>();
        //场次 Id不为 null
        String promotionSessionId = (String) params.get("promotionSessionId");
        if (!StringUtils.isEmpty(promotionSessionId)){
            queryWrapper.eq("promotion_session_id",promotionSessionId);
        }
        IPage<SeckillSkuRelationEntity> page = this.page(
                new Query<SeckillSkuRelationEntity>().getPage(params),
                queryWrapper

        );

        return new PageUtils(page);
    }

}

数据库中按照场次 id 添加一些测试数据。

sms_seckill_session表

谷粒商城之高级篇(3)

sms_seckill_sku_relation表

谷粒商城之高级篇(3)

后台管理系统查看效果:

谷粒商城之高级篇(3)

2.9.2 秒杀微服务搭建

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

所以我们需要新建一个微服务来编写 秒杀业务,如果放到其他业务下,譬如放到 商品系统下,可能会因为秒杀业务带来的高并发将数据库或者商品系统压垮。

①新建 gulimall-seckill秒杀服务

谷粒商城之高级篇(3)

暂时配置的依赖:

谷粒商城之高级篇(3)

② 导入公共服务依赖,并排除 seata 依赖。

        <dependency>
            <groupId>com.atguigu.gulimall</groupId>
            <artifactId>gulimall-common</artifactId>
            <version>0.0.1-SNAPSHOT</version>
            <exclusions>
                <exclusion>
                    <groupId>com.alibaba.cloud</groupId>
                    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

③application.properties配置

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

④主启动类加上 @EnableDiscoveryClient 服务发现注解,因为 common服务引入了 mybatis 的数据库设置,这里排除数据源设置。

谷粒商城之高级篇(3)

2.9.3 SpringBoot整合定时任务&异步任务

1、cron 表达式
语法:秒分时日月周年(Spring 不支持)
官网文档

谷粒商城之高级篇(3)

中文文档:

介绍

cron`是一个已经存在很长时间的 UNIX 工具,因此它的调度功能非常强大且经过验证。CronTrigger类基于 cron 的调度功能`。

CronTrigger使用“cron 表达式”,它能够创建触发时间表,例如:“每周一至周五上午 8:00”或“每月最后一个周五凌晨 1:30”。

Cron 表达式很强大,但也很容易混淆。本教程旨在揭开创建 cron 表达式的神秘面纱,为用户提供一个资源,他们可以在不必在论坛或邮件列表中提问之前访问该资源。

格式

cron 表达式是由 6 或 7 个字段组成的字符串,由空格分隔。字段可以包含任何允许的值,以及该字段允许的特殊字符的各种组合。字段如下:

字段名称 强制的 允许值 允许的特殊字符
是的 0-59 , - * /
分钟 是的 0-59 , - * /
小时 是的 0-23 , - * /
一个月中的第几天 是的 1-31 , - * ?/ 长宽
是的 1-12 或 1 月至 12 月 , - * /
星期几 是的 1-7 或 SUN-SAT , - * ?/大号#
空,1970-2099 , - * /

谷粒商城之高级篇(3)

  • *“所有值”)- 用于选择字段中的所有值。例如,分钟字段中的**“ * ”表示***“每分钟”*。
  • “无特定值”)- 当您需要在允许字符的两个字段之一中指定某些内容而不是另一个时很有用。例如,如果我希望我的触发器在一个月中的特定一天(比如 10 号)触发,但不关心碰巧是星期几,我会在日期中输入“10” -月字段,和“?” 在星期字段中。请参阅下面的示例以进行说明。
  • - - 用于指定范围。例如,小时字段中的“10-12”表示*“第 10、11 和 12 小时”*。
  • , - 用于指定附加值。例如,星期几字段中的“MON,WED,FRI”表示*“星期一、星期三和星期五”*。
  • / - 用于指定增量。例如,秒字段中的“0/15”表示*“第 0、15、30 和 45 秒”。秒字段中的“5/15”表示“第 5、20、35 和 50 秒”。您还可以在“ ”字符之后指定“/”——在这种情况下,“ ”相当于在“/”之前添加“0”。day-of-month 字段中的 ‘1/3’ 表示“从该月的第一天开始每 3 天触发一次”*。
  • L“last”)——在允许使用的两个字段中各有不同的含义。例如,day-of-month 字段中的值“L”表示*“该月的最后一天”* - 1 月的第 31 天,非闰年的 2 月的第 28 天。如果单独用于星期几字段,它仅表示“7”或“SAT”。但如果在星期几字段中用在另一个值之后,则表示*“该月的最后一个 xxx 日”* ——例如“6L”表示*“该月的最后一个星期五”*。您还可以指定从该月最后一天开始的偏移量,例如“L-3”,这表示该日历月的倒数第三天。 使用“L”选项时,重要的是不要指定列表或值范围,因为您会得到令人困惑/意外的结果。
  • W“工作日”)- 用于指定离给定日期最近的工作日(周一至周五)。例如,如果您指定“15W”作为日期字段的值,则含义是: “离该月 15 日最近的工作日”。因此,如果 15 日是星期六,触发器将在 14 日星期五触发。如果 15 号是星期天,触发器将在 16 号星期一触发。如果 15 号是星期二,那么它将在 15 号星期二触发。但是,如果您指定“1W”作为日期的值,并且 1 号是星期六,触发器将在 3 号星期一触发,因为它不会“跳过”一个月的日期边界。“W”字符只能在日期是一天而不是日期范围或列表时指定。

‘L’ 和 ‘W’ 字符也可以在日期字段中组合以产生 ‘LW’,转换为 “该月的最后一个工作日”

  • # - 用于指定该月的“第 n 个”XXX 日。例如,day-of-week 字段中的值“6#3”表示*“该月的第三个星期五”*(第 6 天 = 星期五,“#3”= 该月的第 3 个星期五)。其他示例:“2#1”= 每月的第一个星期一,“4#5”= 每月的第五个星期三。请注意,如果您指定“#5”并且该月中给定的星期几不是第 5 天,那么该月将不会触发。

合法字符以及月份和星期几的名称不区分大小写。MON与``mon 相同。

2、cron 示例

表达 意义
0 0 12 * * ? 每天中午 12 点(中午)触发
0 15 10 ?* * 每天上午 10:15 触发
0 15 10 * * ? 每天上午 10:15 触发
0 15 10 * * ?* 每天上午 10:15 触发
0 15 10 * * ?2005年 2005 年每天上午 10:15 触发
0 * 14 * * ? 每天从下午 2 点开始到下午 2:59 结束,每分钟触发一次
0 0/5 14 * * ? 每天从下午 2 点开始到下午 2:55 结束,每 5 分钟触发一次
0 0/5 14,18 * * ? 从下午 2 点开始到下午 2:55 结束,每 5 分钟触发一次,从下午 6 点开始到下午 6:55 结束,每 5 分钟触发一次,每天
0 0-5 14 * * ? 每天从下午 2 点开始到下午 2:05 结束,每分钟触发一次
0 10,44 14 ?3 星期三 在 3 月份的每个星期三的下午 2:10 和下午 2:44 触发。
0 15 10 ?* 周一至周五 每周一、周二、周三、周四和周五上午 10:15 触发
0 15 10 15 * ? 每月第 15 天上午 10:15 触发
0 15 10 升 * ? 每月最后一天上午 10:15 触发
0 15 10 L-2 * ? 在每个月的倒数第二天上午 10:15 触发
0 15 10 ?* 6L 在每个月的最后一个星期五上午 10:15 触发
0 15 10 ?* 6L 在每个月的最后一个星期五上午 10:15 触发
0 15 10 ?* 6L 2002-2005 在 2002、2003、2004 和 2005 年的每个月的最后一个星期五上午 10:15 触发
0 15 10 ?* 6#3 在每个月的第三个星期五上午 10:15 触发
0 0 12 1/5 * ? 从每月的第一天开始,每月每 5 天在中午 12 点(中午)触发。
0 11 11 11 11 ? 每年 11 月 11 日上午 11:11 触发。

注意’?'的影响 在星期几和星期几字段中使用“*”!

可以使用在线工具进行表达式的快速编写:https://cron.qqe2.com/

3、SpringBoot 整合

秒杀 服务下编写 HelloSchedule

/**
 * 定时任务
 *   1、@EnableScheduling //开启定时任务
 *   2、@Scheduled  开启一个定时任务
 *   3、自动配置类 TaskSchedulingAutoConfiguration   属性绑定在  TaskSchedulingProperties
 *
 * 异步任务
 *   1、@EnableAsync 开启异步任务功能
 *   2、@Async 给希望异步执行的方法上标注
 *   3、自动配置类  TaskExecutionAutoConfiguration  属性绑定在  TaskExecutionProperties
 */
@EnableAsync //开启异步任务
@EnableScheduling //开启定时任务
@Slf4j
@Component
public class HelloSchedule {


    /**
     * 1、Spring中6位组成,不允许第7位的年
     * 2、在周几的位置,1-7代表周一到周日;也可以使用  MON-SUN
     * 3、定时任务不应该阻塞。默认是阻塞的。
     *   1)、可以让业务运行以异步的方式,自己提交到线程池
     *       CompletableFuture.runAsync(()->{
     *             xxxxService.hello();
     *         },executor);
     *  2)、支持定时任务线程池:设置 TaskSchedulingProperties;
     *      spring.task.scheduling.pool.size=5
     *
     * 3)、让定时任务异步执行
     *      异步任务;
     *
     * 解决:使用异步 + 定时任务来完成定时任务不阻塞的功能。
     *
     */
    @Async
    @Scheduled(cron = "* * * ? * 2")
    public void hello() throws InterruptedException {
        log.info("hello...");
        Thread.sleep(3000);
    }



}

application.properties

#定时任务线程池设置
#不同版本下,该配置有时候生效,有时候不生效
#spring.task.scheduling.pool.size=5

#异步任务线程池设置
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=50

最终方案:使用异步 + 定时任务来完成定时任务不阻塞的功能。

因为 @Scheduled默认是一个单线程,如果开启多个任务时,任务的执行时机会受上一个任务执行时间的影响。

2.9.4 时间日期处理

1、创建 SeckillSkuScheduled

@EnableAsync // 3.开启异步任务:防止定时任务之间相互阻塞
@EnableScheduling // 2.开启定时任务
@Configuration //1.主要用于标记配置类,兼备Component的效果。
public class ScheduledConfig {
    
}

2、编写 接口

SeckillSkuScheduled

/**
 * 秒杀商品 的定时上架:
 *   每天晚上3点:上架最近3天需要秒杀的商品
 *   当天 00:00:00  -  23:59:59
 *   明天 00:00:00  -  23:59:59
 *   后天 00:00:00  -  23:59:59
 */
@Slf4j
@Service
public class SeckillSkuScheduled {


    @Autowired
    SeckillService seckillServicel;


    @Scheduled(cron = "0 0 3 * * ?")
    public void uploadSeckillSkuLatest3Days(){
        //1、重复上架无需处理
        seckillServicel.uploadSeckillSkuLatest3Days();
    }


}

实现:

SeckillService

public interface SeckillService {
    void uploadSeckillSkuLatest3Days();
}

SeckillServiceImpl

@Service
public class SeckillServiceImpl implements SeckillService {



    @Override
    public void uploadSeckillSkuLatest3Days() {
        //1、扫描最近3天需要参与秒杀的活动

    }
}

远程查询 最近3天需要参与秒杀的活动商品

@FeignClient("gulimall-coupon")
public interface CouponFeignService {
    
}

3、远程服务编写

SeckillSessionController

 @GetMapping("/lates3DaySession")
    public R getLates3DaySession() {
        List<SeckillSessionEntity> sessions = seckillSessionService.getLates3DaysSession();
        return R.ok().setData(sessions);

    }

时间日期处理:

获取最近 3天的时间范围测试:

 @Test
   public void contextLoads() {
        LocalDate now = LocalDate.now();
        LocalDate plus = now.plusDays(1);
        LocalDate plus2 = now.plusDays(2);

        /**
         * 2022-12-20
         * 2022-12-21
         * 2022-12-22
         */
        System.out.println(now);
        System.out.println(plus);
        System.out.println(plus2);


        LocalTime min = LocalTime.MIN;
        LocalTime max = LocalTime.MAX;
        /**
         * 00:00
         * 23:59:59.999999999
         */
        System.out.println(min);
        System.out.println(max);


        /**
         * 2022-12-20T00:00
         * 2022-12-22T23:59:59.999999999
         */
        LocalDateTime start = LocalDateTime.of(now, min);
        LocalDateTime end = LocalDateTime.of(plus2, max);
        System.out.println(start);
        System.out.println(end);


    }

SeckillSessionServiceImpl

   @Override
    public List<SeckillSessionEntity> getLates3DaysSession() {

        //计算最近3天
        // Date date = new Date();// 2022-12-20 13:59:16

        List<SeckillSessionEntity> list = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", startTime(), endTime()));

        return list;
    }


	//当前天数的 00:00:00
    private String startTime(){
        LocalDate now = LocalDate.now();
        LocalTime min = LocalTime.MIN;
        LocalDateTime start = LocalDateTime.of(now, min);

        String format = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        return format;
    }


	//当前天数+2 23:59:59
    private String endTime(){
        LocalDate now = LocalDate.now();
        //加 2天
        LocalDate localDate = now.plusDays(2);

        LocalTime max = LocalTime.MAX;
        LocalDateTime end = LocalDateTime.of(localDate, max);

        String format = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        return format;
    }

2.9.5 秒杀商品上架

1、秒杀系统关注的问题

  • 1、服务单一职责+独立部署

    • 秒杀服务即使自己扛不住压力,挂掉。不要影响别人
  • 2、秒杀链接加密

    • 防止恶意攻击,模拟秒杀请求,1000次/s攻击
    • 防止链接暴露,自己工作人员,提前秒杀商品。
  • 3、库存预热+快速扣减

    • 秒杀读多写少。无需每次实时校验库存。我们库存预热,放到redis中。信号量控制进来秒杀的请求
  • 4、动静分离

    • nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。
      使用cDN网络,分担本集群压力
  • 5、恶意请求拦截

    • 识别非法攻击请求并进行拦截,网关层
  • 6、流量错峰

    • 使用各种手段,将流量分担到更大宽度的时间点。比如验证码,加入购物车
  • 7、限流&熔断&降级

    • 前端限流+后端限流
    • 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
  • 8、队列削峰

    • 1万个商品,每个1000件秒杀。双11所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可。

人人开源后台vue的后台管理系统里上架秒杀,打开F12看url然后去编写逻辑

  • 秒杀名称
  • 开始时间
  • 结束时间
  • 启用状态

2、秒杀架构设计
(1) 秒杀架构
nginx–>gateway–>redis分布式信号量–> 秒杀服务

  • 项目独立部署,独立秒杀模块gulimall-seckill
  • 使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
  • 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口
  • 库存预热,先从数据库中扣除一部分库存以redisson 信号量的形式存储在redis中
  • 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单

秒杀活动:存在在scekill:sesssions这个redis-key里,。value为 skyIds[]

秒杀活动里具体商品项:是一个map,redis-key是seckill:skusmap-key是skuId+商品随机码

(2) redis存储模型设计

谷粒商城之高级篇(3)

  • 秒杀场次存储的List可以当做hash keySECKILL_CHARE_PREFIX中获得对应的商品数据

  • 随机码防止人在秒杀一开放就秒杀完,必须携带上随机码才能秒杀

  • 结束时间

  • 设置分布式信号量,这样就不会每个请求都访问数据库。seckill:stock:#(商品随机码)

  • session里存了session-sku[]的列表,而seckill:skus的key也是session-sku,不要只存储sku,不能区分场次

//存储的秒杀场次对应数据
//K: SESSION_CACHE_PREFIX + startTime + "_" + endTime
//V: sessionId+"-"+skuId的List
private final String SESSION_CACHE_PREFIX = "seckill:sessions:";

//存储的秒杀商品数据
//K: 固定值SECKILL_CHARE_PREFIX
//V: hash,k为sessionId+"-"+skuId,v为对应的商品信息SeckillSkuRedisTo
private final String SECKILL_CHARE_PREFIX = "seckill:skus";

//K: SKU_STOCK_SEMAPHORE+商品随机码
//V: 秒杀的库存件数
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:";    //+商品随机码

接下来完善 秒杀商品上架业务。

获取最近三天的秒杀信息

  • 获取最近三天的秒杀场次信息,再通过秒杀场次id查询对应的商品信息
  • 防止集群多次上架

SeckillSessionServiceImpl

    @Override
    public List<SeckillSessionEntity> getLates3DaysSession() {

        //计算最近3天
        // Date date = new Date();// 2022-12-20 13:59:16
        //获取最近3天的秒杀活动
        List<SeckillSessionEntity> list = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", startTime(), endTime()));


        //获设置秒杀活动里面的秒杀商品
        if (list != null && list.size()>0){
            List<SeckillSessionEntity> collect = list.stream().map(session -> {
                //给每一个活动写入他们的秒杀项
                Long id = session.getId();
                //根据活动场次 id 获取每个 sku项
                List<SeckillSkuRelationEntity> relationEntities = seckillSkuRelationService.
                        list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", id));
                session.setRelationSkus(relationEntities);
                return session;
            }).collect(Collectors.toList());

            return collect;
        }

        return null;
    }
  • SeckillSessionEntity中添加字段
	@TableField(exist = false)//表示不是数据库中存在的字段
	private List<SeckillSkuRelationEntity> relationSkus;
  • 秒杀服务编写调用库存远程服务

CouponFeignService

@FeignClient("gulimall-coupon")
public interface CouponFeignService {

    @GetMapping("/coupon/seckillsession/lates3DaySession")
    R getLates3DaySession();
}

完善 SeckillSkuScheduled 的 uploadSeckillSkuLatest3Days方法

SeckillServiceImpl

秒杀商品上架

	
    @Autowired
    CouponFeignService couponFeignService;
	/**
     * 秒杀商品上架
     */
    @Override
    public void uploadSeckillSkuLatest3Days() {
        //1、扫描最近3天需要参与秒杀的活动
        R session = couponFeignService.getLates3DaySession();
        if (session.getCode() == 0){
            //上架商品
            List<SeckillSessionsWithSkus> sessionData = session.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {
            });
            //缓存到redis
            //1、缓存活动信息
            saveSessionInfo(sessionData);
            //2、缓存活动的关联商品信息
            saveSessionSkuInfos(sessionData);

        }

    }

SeckillSessionsWithSkus:复制SeckillSessionEntity

@Data
public class SeckillSessionsWithSkus {

    /**
     * id
     */
    private Long id;
    /**
     * 场次名称
     */
    private String name;
    /**
     * 每日开始时间
     */
    private Date startTime;
    /**
     * 每日结束时间
     */
    private Date endTime;
    /**
     * 启用状态
     */
    private Integer status;
    /**
     * 创建时间
     */
    private Date createTime;


    private List<SeckillSkuVo>  relationSkus;


}

SeckillSkuVo:复制 SeckillSkuRelationEntity

@Data
public class SeckillSkuVo {
    /**
     * id
     */
    private Long id;
    /**
     * 活动id
     */
    private Long promotionId;
    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;
    /**
     * 秒杀总量
     */
    private BigDecimal seckillCount;
    /**
     * 每人限购数量
     */
    private BigDecimal seckillLimit;
    /**
     * 排序
     */
    private Integer seckillSort;
}

Redis保存秒杀场次信息

      @Autowired
    StringRedisTemplate redisTemplate;

    private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";

/**
     * 1 缓存活动信息
     * @param sessions
     */
    private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){

        sessions.stream().forEach(session ->{
            Long startTime = session.getStartTime().getTime();
            Long endTime = session.getEndTime().getTime();
            String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
            List<String> collect = session.getRelationSkus().stream().map(item -> item.getSkuId().toString()).collect(Collectors.toList());
            //缓存活动信息
            redisTemplate.opsForList().leftPushAll(key,collect);
        });

    }

redis保存秒杀商品信息

前面已经缓存了sku项的活动信息,但是只有活动id和skuID,接下来我们要保存完整是sku信息到redis中

    @Autowired
    ProductFeignService productFeignService;

    @Autowired
    RedissonClient redissonClient;

    private final String SKUKILL_CACHE_PREFIX = "seckill:skus";

    private final  String SKU_STOCK_SEMAPHORE = "seckill:stock:";// + 商品随机码
 

/**
     * 2 缓存商品信息
     * @param sessions
     */
    private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions){

        sessions.stream().forEach(session ->{
            //准备hash操作
            BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
            session.getRelationSkus().stream().forEach(seckillSkuVo -> {
                //缓存商品
                SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                //1、sku的基本数据
                R skuInfo = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
                if (skuInfo.getCode() == 0 ){
                    SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
                    });
                    redisTo.setSkuInfo(info);
                }
                //2、sku的秒杀信息
                BeanUtils.copyProperties(seckillSkuVo,redisTo);

                //3、设置上当前商品的秒杀时间信息
                redisTo.setStartTime(session.getStartTime().getTime());
                redisTo.setEndTime(session.getEndTime().getTime());

                //4、随机码   seckill?skuId=1&key=jaojgoajgoa;
                String token = UUID.randomUUID().toString().replace("-", "");
                redisTo.setRandomCode(token);

                //5、使用库存作为分布式的信号量  :限流
                RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                //商品可以秒杀的数量作为信号量
                semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());


                String jsonString = JSON.toJSONString(redisTo);
                ops.put(seckillSkuVo.getSkuId().toString(),jsonString);
            });
        });
    }

这里需要远程调用 商品服务 下 SkuInfoController的 info()方法查询sku信息。

编写 ProductFeignService

@FeignClient("gulimall-product")
public interface ProductFeignService {

    @RequestMapping("/product/skuinfo/info/{skuId}")
    //@RequiresPermissions("product:skuinfo:info")
     R getSkuInfo(@PathVariable("skuId") Long skuId);
}

编写 to 封装数据

@Data
public class SeckillSkuRedisTo {

    /**
     * 活动id
     */
    private Long promotionId;
    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;
    /**
     * 秒杀总量
     */
    private BigDecimal seckillCount;
    /**
     * 每人限购数量
     */
    private BigDecimal seckillLimit;
    /**
     * 排序
     */
    private Integer seckillSort;

    //sku详细信息
    private SkuInfoVo skuInfo;

    //当前sku的秒杀开始时间
    private Long startTime;

    //当前sku的秒杀结束时间
    private Long endTime;

    //商品秒杀随机码
    private String randomCode;


}

SkuInfoVo:复制SkuInfoEntity

@Data
public class SkuInfoVo {

    /**
     * skuId
     */
    private Long skuId;
    /**
     * spuId
     */
    private Long spuId;
    /**
     * sku名称
     */
    private String skuName;
    /**
     * sku介绍描述
     */
    private String skuDesc;
    /**
     * 所属分类id
     */
    private Long catalogId;
    /**
     * 品牌id
     */
    private Long brandId;
    /**
     * 默认图片
     */
    private String skuDefaultImg;
    /**
     * 标题
     */
    private String skuTitle;
    /**
     * 副标题
     */
    private String skuSubtitle;
    /**
     * 价格
     */
    private BigDecimal price;
    /**
     * 销量
     */
    private Long saleCount;
}

需要引入 redisson依赖

        <!-- 以后使用Redisson作为所有分布式锁,分布式对象等功能框架 -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.12.0</version>
        </dependency>

复制MyRedissonConfig

@Configuration
public class MyRedissonConfig {


    /**
     * 所有对 Redisson的使用都是通过RedissonClient对象
     * @return
     * @throws IOException
     */
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redisson() throws IOException {
        //1、创建配置
        Config config = new Config();
        // Redis url should start with redis:// or rediss:// (for SSL connection)
        config.useSingleServer().setAddress("redis://192.168.56.10:6379");

        //2、根据Config 创建出 RedissonClient实例
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }


}

2.9.6 幂等性处理

谷粒商城之高级篇(3)

避免高并发下多机器同时上架情况。

修改代码:

SeckillSkuScheduled

  /**
     * 定时任务
     * 每天三点上架最近三天的秒杀商品
     */
    //TODO 幂等性处理
    @Scheduled(cron = "*/3 * * * * ?")
    public void uploadSeckillSkuLatest3Days(){
        //1、重复上架无需处理
        log.info("上架秒杀的商品信息...");
        //分布式锁:拿到锁的机器才执行:锁的业务执行完成,状态已经更新完成,释放锁以后,其他人获取到就会拿到最新的状态。
        //为避免分布式情况下多服务同时上架的情况,使用分布式锁
        RLock lock = redissonClient.getLock(upload_lock);
        lock.lock(10, TimeUnit.SECONDS);//锁住
        try{
            seckillServicel.uploadSeckillSkuLatest3Days();
        }finally {
            lock.unlock();//解锁
        }
    }

ps:这里为了开发测试效果,改为了每 3秒 做一次上架商品的定时任务。

SeckillServiceImpl

谷粒商城之高级篇(3)

    /**
     * 1 缓存活动信息
     * @param sessions
     */
    private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){
        sessions.stream().forEach(session ->{
            Long startTime = session.getStartTime().getTime();
            Long endTime = session.getEndTime().getTime();
            String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
            Boolean hasKey = redisTemplate.hasKey(key);
            //幂等性处理
            // 防止重复添加活动到redis中
            if (!hasKey){
                // 获取所有商品id // 格式:活动id-skuId
                List<String> collect = session.getRelationSkus().stream().map(item ->
                        item.getPromotionSessionId().toString() +"_"+item.getSkuId().toString())
                        .collect(Collectors.toList());
                //缓存活动信息
                redisTemplate.opsForList().leftPushAll(key,collect);
            }
        });


    }

谷粒商城之高级篇(3)

    /**
     * 2 缓存商品信息
     * @param sessions
     */
    private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions){

        // 遍历session
        sessions.stream().forEach(session ->{
            //准备hash操作
            BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
            // 遍历sku
            session.getRelationSkus().stream().forEach(seckillSkuVo -> {
                //4、随机码   seckill?skuId=1&key=jaojgoajgoa;
                String token = UUID.randomUUID().toString().replace("-", "");

                //幂等性处理
                //只需要上架一次,如果已经上架,就不需要再上架
                // 缓存中没有再添加
                if(!ops.hasKey(seckillSkuVo.getPromotionSessionId().toString()+"_"+ seckillSkuVo.getSkuId().toString())){

                    //缓存商品
                    SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                    //1、sku的基本数据   sku的秒杀信息
                    R skuInfo = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
                    if (skuInfo.getCode() == 0 ){
                        SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
                        });
                        redisTo.setSkuInfo(info);
                    }
                    //2、sku的秒杀信息
                    BeanUtils.copyProperties(seckillSkuVo,redisTo);

                    //3、设置上当前商品的秒杀时间信息
                    redisTo.setStartTime(session.getStartTime().getTime());
                    redisTo.setEndTime(session.getEndTime().getTime());

                    // 设置随机码
                    redisTo.setRandomCode(token);

                    String jsonString = JSON.toJSONString(redisTo);
                    // 活动id-skuID   秒杀sku信息
                    ops.put(seckillSkuVo.getPromotionSessionId().toString()+"_"+seckillSkuVo.getSkuId().toString(),jsonString);

                    //5、使用库存作为分布式的信号量  :限流
                    RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                    //商品可以秒杀的数量作为信号量
                    semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());

                }
            });
        });
    }

redis中效果图:

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

2.9.7 查询秒杀商品

前面已经在redis中缓存了秒杀活动的各种信息,现在写获取缓存中当前时间段在秒杀的sku,用户点击页面后发送请求。

  • 接口方法编写

SeckillController

@RestController
public class SeckillController {


    @Autowired
    SeckillService seckillService;

    /**
     * 返回当前时间可以参与的秒杀商品信息
     * @return
     */
    @GetMapping("/currentSeckillSkus")
    public R getCurrentSeckillSkus(){
      List<SeckillSkuRedisTo>  vos  =  seckillService.getCurrentSeckillSkus();

      return R.ok().setData(vos);
    }


}

SeckillServiceImpl

//返回当前时间可以参与的秒杀商品信息
    @Override
    public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {

        //1、确定当前时间属于哪个秒杀场次
        long time = new Date().getTime();

        Set<String> keys = redisTemplate.keys(SESSIONS_CACHE_PREFIX + "*");
        for (String key : keys) {
            //seckill:sessions:1671674400000_1671678000000
            String replace = key.replace(SESSIONS_CACHE_PREFIX, "");//截串
            String[] s = replace.split("_");//分割
            Long start = Long.parseLong(s[0]);
            Long end = Long.parseLong(s[1]);
            if (time >= start  && time <= end){
                //2、获取这个秒杀场次需要的所有商品信息
                List<String> range = redisTemplate.opsForList().range(key, -100, 100);
                //获取到hash   key----seckill:skus
                BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
                List<String> list = hashOps.multiGet(range);
                if (list != null){
                    List<SeckillSkuRedisTo> collect = list.stream().map(item -> {
                        SeckillSkuRedisTo redis = JSON.parseObject((String) item, SeckillSkuRedisTo.class);
                        // redis.setRandomCode(null);//当前秒杀开始就需要随机码
                        return redis;
                    }).collect(Collectors.toList());

                    return collect;
                }
                break;
            }

        }
        return null;


    }
  • 网关配置

域名映射

谷粒商城之高级篇(3)

网关服务

        - id: gulimall_seckill_route
          uri: lb://gulimall-seckill
          predicates:
            - Host=seckill.gulimall.com

首页获取并渲染

ul中的各个 li标签全部删除掉,使用 Ajax 局部获取刷新。

谷粒商城之高级篇(3)

ajax请求:

 $.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp) {
    if (resp.data.length > 0){
      resp.data.forEach(function (item) {
        $("<li></li>")
            .append($("<img style='width: 130px;height: 130px' src='"+item.skuInfo.skuDefaultImg+"'/>"))
            .append($("<p>"+ item.skuInfo.skuTitle+"</p>"))
            .append($("<span>"+ item.seckillPrice+"</span>"))
            .append($("<s>"+ item.skuInfo.price+"</s>"))
            .appendTo("#seckillSkuContent");
      })
    }

    //append 添加元素
    //appendTo 添加到哪个位置去
     
    // <li>
    //   <img src="/static/index/img/section_second_list_img1.jpg" alt="">
    //     <p>花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千克) (日本官方直采) 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千</p>
    //     <span>¥83.9</span><s>¥99.9</s>
    // </li>
  });

效果展示:

谷粒商城之高级篇(3)

2.9.8 秒杀页面渲染

  • 秒杀服务下的SeckillController
    /**
     * 获取当前商品的秒杀信息
     * @param skuId
     * @return
     */
    @GetMapping("/sku/seckill/{skuId}")
    public R getSkuSeckillInfo(@PathVariable("skuId") Long skuId){
      SeckillSkuRedisTo to  =   seckillService.getSkuSeckillInfo(skuId);
      return R.ok().setData(to);
    }

SeckillServiceImpl

    // 获取当前商品的秒杀信息
    @Override
    public SeckillSkuRedisTo getSkuSeckillInfo(Long skuId) {
        //1、找到所有需要参与秒杀的商品的key
        BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);

        Set<String> keys = hashOps.keys();
        if (keys != null && keys.size() > 0) {
            String regx = "\\d_" + skuId; //6_4   正则匹配
            for (String key : keys) {
                if (Pattern.matches(regx, key)) {
                    String json = hashOps.get(key);
                    SeckillSkuRedisTo skuRedisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);

                    //随机码
                    long current = new Date().getTime();
                    if (current >= skuRedisTo.getStartTime() && current <= skuRedisTo.getEndTime()) {

                    } else {
                        skuRedisTo.setRandomCode(null);
                    }
                    return skuRedisTo;
                };
            }
        }
        return null;
    }

商品服务

编写 SeckillFeignService远程调用

@FeignClient("gulimall-seckill")
public interface SeckillFeignService {


    @GetMapping("/sku/seckill/{skuId}")
    R getSkuSeckillInfo(@PathVariable("skuId") Long skuId);

}

在查询商品详情页的接口中查询秒杀对应信息:

SkuInfoServiceImpl 下的 item方法完善

谷粒商城之高级篇(3)

    @Autowired
    SeckillFeignService seckillFeignService;

	@Override
    public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {

        SkuItemVo skuItemVo = new SkuItemVo();

        // 第一步获得的数据,第3步、4步、5步也要使用
        CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {

            //1、sku基本信息获取  pms_sku_info
            SkuInfoEntity info = getById(skuId);
            skuItemVo.setInfo(info);

            return info;
        }, executor);

        CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
            //3、获取的spu的销售属性组合。
            List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());
            skuItemVo.setSaleAttr(saleAttrVos);
        }, executor);

        CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
            //4、获取spu的介绍 pms_spu_info_desc
            SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
            skuItemVo.setDesp(spuInfoDescEntity);
        }, executor);

        CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
            //5、获取spu的规格参数信息。
            List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
            skuItemVo.setGroupAttrs(attrGroupVos);
        }, executor);

        CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
            //2、sku的图片信息  pms_sku_images
            List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
            skuItemVo.setImages(images);
        }, executor);

        CompletableFuture<Void> secKillFuture = CompletableFuture.runAsync(() -> {
            //3、查询当前sku是否参与秒杀优惠
            R seckillInfo = seckillFeignService.getSkuSeckillInfo(skuId);
            if (seckillInfo.getCode() == 0) {
                SeckillInfoVo seckillInfoVo = seckillInfo.getData(new TypeReference<SeckillInfoVo>() {
                });
                skuItemVo.setSeckillInfoVo(seckillInfoVo);
            }
        }, executor);


        //等待所有任务都完成
        CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture,secKillFuture).get();

        return skuItemVo;


    }

注意所有的时间都是距离1970的差值

  • 更改商品详情页的显示效果:显示秒杀预告、秒杀价等

谷粒商城之高级篇(3)

 <li style="color: red;" th:if="${item.seckillInfoVo != null}">
							        <span th:if="${#dates.createNow().getTime() < item.seckillInfoVo.startTime}">
									商品将会在[[${#dates.format(new java.util.Date(item.seckillInfoVo.startTime),"yyyy-MM-dd HH:mm:ss")}]]进行抢购
							        </span>

                                    <span th:if="${#dates.createNow().getTime() >= item.seckillInfoVo.startTime && #dates.createNow().getTime() <= item.seckillInfoVo.endTime}">
										秒杀价:[[${#numbers.formatDecimal(item.seckillInfoVo.seckillPrice,1,2)}]]
							        </span>
                                </li>
  • 首页点击秒杀商品,跳转到商品详情页

谷粒商城之高级篇(3)

  function to_href(skuId){
    location.href = "http://item.gulimall.com/" + skuId + ".html";
  }

  $.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp) {
    if (resp.data.length > 0){
      resp.data.forEach(function (item) {
        $("<li οnclick='to_href("+item.skuId+")'></li>")
            .append($("<img style='width: 130px;height: 130px' src='"+item.skuInfo.skuDefaultImg+"'/>"))
            .append($("<p>"+ item.skuInfo.skuTitle+"</p>"))
            .append($("<span>"+ item.seckillPrice+"</span>"))
            .append($("<s>"+ item.skuInfo.price+"</s>"))
            .appendTo("#seckillSkuContent");
      })
    }

效果展示:

该商品在秒杀时间中,显示秒杀价格

谷粒商城之高级篇(3)

如果该商品不在秒杀时间内,显示 秒杀预告。

谷粒商城之高级篇(3)

2.9.9 秒杀系统设计

谷粒商城之高级篇(3)

02:使用随机码

03:本次秒杀使用 redis预热库存(信号量)

谷粒商城之高级篇(3)

05:本次秒杀使用登录拦截器

08:给MQ发送消息

之前我们已经做了 前面 4步,接下来我们要完善后面4步。

2.9.10 登录检查

秒杀的最终的处理

  • 秒杀商品定时上架

谷粒商城之高级篇(3)

  • 秒杀业务开始

谷粒商城之高级篇(3)

①晚上 详情页的页面效果:如果不在秒杀时间范围内,显示 “加入购物车”;如果在秒杀时间范围内,显示“立即抢购”,并进行跳转,跳转的路径带上 商品 id 和 场次 id 和 随机码、以及数量。

item.html

                            <div class="box-btns-two" th:if="${#dates.createNow().getTime() >= item.seckillInfoVo.startTime && #dates.createNow().getTime() <= item.seckillInfoVo.endTime}">
                                <a href="#" id="secKillA" th:attr="skuId=${item.info.skuId},sessionId=${item.seckillInfoVo.promotionSessionId},code=${item.seckillInfoVo.randomCode}">
                                    立即抢购
                                </a>
                            </div>
                            <div class="box-btns-two" th:if="${#dates.createNow().getTime() < item.seckillInfoVo.startTime || #dates.createNow().getTime() > item.seckillInfoVo.endTime}">
                                <a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}">
                                    加入购物车
                                </a>
                            </div>

跳转函数。

        $("#secKillA").click(function () {
            var isLogin = [[${session.loginUser != null}]];//true
            if (isLogin){
                var killId = $(this).attr("sessionId") + "_" + $(this).attr("skuId");
                var key = $(this).attr("code");
                var num = $("#numInput").val();
                location.href = "http://seckill.gulimall.com/kill?killId="+killId+"&key="+key+"&num="+num;
            }else {
                alert("秒杀请先登录");
            }
            return false;
        })

②编写去秒杀时的登录检查

  • 加入session有关等依赖:spring-boot-starter-data-redis排除 lettuce-core,使用jedis
	 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
  • application.properties配置
spring.session.store-type=redis
  • 主启动类加上 开启 @EnableRedisHttpSession 注解

谷粒商城之高级篇(3)

复制有关session配置和拦截器配置:

GulimallSessionConfig:全局 session配置

@Configuration
public class GulimallSessionConfig {


    @Bean
    public CookieSerializer cookieSerializer() {


        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();

        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");


        return cookieSerializer;


    }


    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

}

LoginUserInterceptor:拦截器

@Component
public class LoginUserInterceptor implements HandlerInterceptor {


    public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 库存解锁需要远程调用---->需要登录:所以设置拦截器不拦截 order/order/status/{orderSn}。
        // 对于查询订单等请求直接放行。
        // /order/order/status/45648913346789494
        String uri = request.getRequestURI();
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        //不是真正点击去秒杀【立即抢购】的都放行
        boolean match = antPathMatcher.match("/kill", uri);
        if (match){
            //获取登录用户
            MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
            if (attribute != null) {
                //登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息:
                //加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
                loginUser.set(attribute);
                return true;
            } else {
                //没登录就去登录
                request.getSession().setAttribute("msg", "请先进行登录");
                response.sendRedirect("http://auth.gulimall.com/login.html");
                return false;
            }
        }

        return true;

    }
}

SecKillWebController:注册拦截器

@Configuration
public class SecKillWebController implements WebMvcConfigurer {


    @Autowired
    LoginUserInterceptor loginUserInterceptor;


    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
    }
}
  • 接口方法编写

SeckillController

    /**
     * 秒杀商品
     * @param killId
     * @param key
     * @param code
     * @return
     */
    @GetMapping("/kill")
    public R secKill(@RequestParam("killId") String killId,
                     @RequestParam("key") String key,
                     @RequestParam("code") String code){


        //1、判断是否登录
        return null;
    }

2.9.11 秒杀流程

  • 秒杀方案:

第一种:

谷粒商城之高级篇(3)

优点:分散流量;缺点:流量会级联映射到其他系统里面,极限情况下,造成各个系统崩溃

第二种

谷粒商城之高级篇(3)

优点:在秒杀开始到快速创建订单过程中,没有进行一次数据库操作或者远程调用,只需要校验数据的合法性,因为所有数据都在缓存中放着。
缺点:如果订单服务已经崩溃了,那秒杀服务发出的消息一直不能消费,订单一直支付不成功。

这里我们采用 第二种方案。

消息队列:

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

秒杀controller:

    /**
     * 秒杀商品
     * @param killId
     * @param key
     * @param code
     * @return
     */
    @GetMapping("/kill")
    public R secKill(@RequestParam("killId") String killId,
                     @RequestParam("key") String key,
                     @RequestParam("num") Integer num){


        //秒杀成功就创建一个订单号
        String orderSn = seckillService.kill(killId,key,num);


        //1、判断是否登录
        return R.ok().setData(orderSn);
    }

秒杀service:创建订单、发消息

 @Override
    public String kill(String killId, String key, Integer num) {

        //从拦截器中获取当前用户信息
        MemberRespVo respVo = LoginUserInterceptor.loginUser.get();

        //1、获取当前秒杀商品的详细信息
        BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);

        String json = hashOps.get(killId);
        if (StringUtils.isEmpty(json)){
            return null;
        }else{
            SeckillSkuRedisTo redis = JSON.parseObject(json, SeckillSkuRedisTo.class);
            //校验合法性
            Long startTime = redis.getStartTime();
            Long endTime = redis.getEndTime();
            long time = new Date().getTime();
            //1、校验时间的合法性
            if (time >= startTime && time <= endTime){
                //2、校验随机码和商品id
                String randomCode = redis.getRandomCode();
                String skuId = redis.getPromotionSessionId() + "_" + redis.getSkuId();
                if (randomCode.equals(key) && killId.equals(skuId)){
                    //3、验证购物数量是否合理
                    if (num <= redis.getSeckillLimit().intValue()){
                        //4、验证这个人是否已经购买过。幂等性:只要秒杀成功,就去占位。  userId_SessionId_skuId
                        //SETNX
                        String redisKey = respVo.getId() + "_" + skuId;
                        // 让数据自动过期
                        long ttl = redis.getEndTime() - redis.getStartTime();
                        //自动过期
                        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
                        if (aBoolean){
                            //占位成功说明从来没有买过

                            RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);

                            try {
                                // 120 ms
                                boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
                                //秒杀成功;
                                // 快速下单。发送MQ消息  10ms
                                String timeId = IdWorker.getTimeId();
                                return timeId;
                            } catch (InterruptedException e) {
                                return null;
                            }

                        }else{
                            //说明已经买过了
                            return null;
                        }
                    }
                }else{
                    return null;
                }
            }else{
                return null;
            }
        }


        return null;
    }
  • 给 MQ发消息:流程如下:

谷粒商城之高级篇(3)

以上秒杀流程最大的特点就是:流量削峰,不是每个请求过来都要去调用订单服务。

①引入 rabbitmq依赖

 <!--引入 操作Rabbitmq依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

② application.properties配置

spring.rabbitmq.virtual-host=/
spring.rabbitmq.host=192.168.56.10

③ 关于 rabbitmq 的配置类

@Configuration
public class MyRabbitConfig {


    /**
     * 使用JSON序列化机制,进行消息转换
     * @return
     */
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }


}

④秒杀服务给 MQ发消息

秒杀的to数据准备(直接放在公共服务中)

SeckillOrderTo

@Data
public class SeckillOrderTo {

    private String orderSn;//订单号
    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;
    /**
     * 秒杀总量
     */
    private Integer num;

    //会员id
    private Long memberId;
}

发送消息

谷粒商城之高级篇(3)

@Override
    public String kill(String killId, String key, Integer num) {

        long s1 = System.currentTimeMillis();

        //从拦截器中获取当前用户信息
        MemberRespVo respVo = LoginUserInterceptor.loginUser.get();

        //1、获取当前秒杀商品的详细信息
        BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);

        String json = hashOps.get(killId);
        if (StringUtils.isEmpty(json)) {
            return null;
        } else {
            SeckillSkuRedisTo redis = JSON.parseObject(json, SeckillSkuRedisTo.class);
            //校验合法性
            Long startTime = redis.getStartTime();
            Long endTime = redis.getEndTime();
            long time = new Date().getTime();

            //过期时间
            long ttl = endTime - startTime;

            //1、校验时间的合法性
            if (time >= startTime && time <= endTime) {
                //2、校验随机码和商品id
                String randomCode = redis.getRandomCode();
                String skuId = redis.getPromotionSessionId() + "_" + redis.getSkuId();
                if (randomCode.equals(key) && killId.equals(skuId)) {
                    //3、验证购物数量是否合理
                    if (num <= redis.getSeckillLimit().intValue()) {
                        //4、验证这个人是否已经购买过。幂等性:只要秒杀成功,就去占位。  userId_SessionId_skuId
                        //SETNX
                        String redisKey = respVo.getId() + "_" + skuId;

                        //自动过期
                        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
                        if (aBoolean) {
                            //占位成功说明从来没有买过
                            RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
                            // 120 ms
                            boolean b = semaphore.tryAcquire(num);
                            if (b) {
                                //秒杀成功;
                                // 快速下单。发送MQ消息  10ms
                                String timeId = IdWorker.getTimeId();
                                SeckillOrderTo orderTo = new SeckillOrderTo();
                                orderTo.setOrderSn(timeId);
                                orderTo.setMemberId(respVo.getId());
                                orderTo.setNum(num);
                                orderTo.setPromotionSessionId(redis.getPromotionSessionId());
                                orderTo.setSkuId(redis.getSkuId());
                                orderTo.setSeckillPrice(redis.getSeckillPrice());
                                rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);
                                long s2 = System.currentTimeMillis();
                                log.info("耗时...{},(s2-s1)");
                                return timeId;
                            }
                            return null;

                        } else {
                            //说明已经买过了
                            return null;
                        }
                    }
                } else {
                    return null;
                }
            } else {
                return null;
            }
        }


        return null;
    }

在订单服务设置一个队列和一个绑定关系:创建秒杀所需队列

订单服务中的 MyMQConfig

  @Bean
    public Queue orderSeckillOrderQueue(){

        //String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        Queue queue = new Queue("order.seckill.order.queue", true, false, false);
        return queue;
    }


    @Bean
    public Binding orderSeckillOrderBinding(){

        //String destination, DestinationType destinationType, String exchange, String routingKey,
        // 			Map<String, Object> arguments
        return  new Binding("order.seckill.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.seckill.order",
                null);
    }

监听队列:接收消息

SeckilOrderListener

@Slf4j
@RabbitListener(queues = "order.seckill.order.queue")
@Component
public class SeckilOrderListener {

    @Autowired
    OrderService orderService;

    /**
     * 创建秒杀单
     * @param seckillOrder
     * @param channel
     * @param message
     * @throws IOException
     */
    @RabbitHandler
    public void listener(SeckillOrderTo seckillOrder, Channel channel, Message message) throws IOException {
        try {
            log.info("准备创建秒杀单的详细信息." +
                    "" +
                    "..");
            orderService.createSeckillOrder(seckillOrder);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            //失败了重新放到队列中,让其他消费者能够消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }

}

创建订单: createSeckillOrder


    @Override
    public void createSeckillOrder(SeckillOrderTo seckillOrder) {
        //TODO 保存订单信息
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setOrderSn(seckillOrder.getOrderSn());
        orderEntity.setMemberId(seckillOrder.getMemberId());
        orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());

        BigDecimal multiply = seckillOrder.getSeckillPrice().multiply(new BigDecimal("" + seckillOrder.getNum()));
        orderEntity.setPayAmount(multiply);

        this.save(orderEntity);


        //TODO 保存订单项信息
        OrderItemEntity orderItemEntity = new OrderItemEntity();
        orderItemEntity.setOrderSn(seckillOrder.getOrderSn());
        orderItemEntity.setRealAmount(multiply);
        //TODO 获取当前sku的详细信息进行设置  productFeignService.getSpuInfoBySkuId()

        orderItemEntity.setSkuQuantity(seckillOrder.getNum());

        orderItemService.save(orderItemEntity);
    }

测试:点击 “立即抢购”:第一次成功

谷粒商城之高级篇(3)

第二次直接返回null。

2.9.12 秒杀页面完成

这里先修改一下 商品详情页的显示 “立即抢购” 和 “加入购物车”的逻辑。

谷粒商城之高级篇(3)

如果不是秒杀商品也要显示 加入购物车

①复制 购物车页面 的 success.html 页面

谷粒商城之高级篇(3)

②引入 thymeleaf依赖

        <!--模板引擎:thymeleaf-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

application.properties配置

spring.thymeleaf.cache=false

③修改 业务逻辑

SeckillController

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

除了 /kill这个请求外,其他都是返回 JSON数据,而 /kill跳转到 指定页面。

④编写秒杀成功页面

修改前缀:静态资源改为从购物车服务直接获取。

谷粒商城之高级篇(3)

谷粒商城之高级篇(3)

编写秒杀成功显示:

秒杀成功,显示订单号,以及跳转的支付页面;

秒杀失败,温馨提示。

谷粒商城之高级篇(3)

 <div class="main">

            <div class="success-wrap">
                <div class="w" id="result">
                    <div class="m succeed-box">
                        <div th:if="${orderSn != null}"    class="mc success-cont">
                            <h1>恭喜,秒杀成功,订单号[[${orderSn}]]</h1>
                            <h2>正在准备订单数据,10s以后自动跳转支付
                                <a style="color: red" th:href="${'http://order.gulimall.com/payOrder?orderSn=' + orderSn}">去支付</a>
                            </h2>
                        </div>
                        <div th:if="${orderSn == null}"    class="mc success-cont">
                            <h2>手气不好,秒杀失败,下次再来</h2>
                        </div>
                    </div>
                </div>
            </div>

        </div>

成功:

谷粒商城之高级篇(3)

失败:

谷粒商城之高级篇(3)

上面我们已经完成了秒杀( 高并发) 系统关注的问题:

  • 01 服务单一职责**+**独立部署

  • 02 秒杀链接加密:使用随机码

  • 03 库存预热+快速扣减:使用redis缓存,预热库存(信号量)

  • 04 动静分离

  • 05 恶意请求拦截:登录拦截器

  • 08 队列削峰:秒杀成功给MQ发送消息(给订单服务发送消息,让其慢慢消费)

06 流量削峰 和 07 限流&熔断&降级没有完成。

以上可以保证高并发系统下 能够快速处理,但是不能保证稳定。

下面我们使用 阿里巴巴 的sentinel进行 第7步:限流&熔断&降级。

参考 谷粒商城高级篇之额外知识补充篇 7 SpringCloud Alibaba-Sentinel

3 高级篇总结

基础篇注重的是 基本的增删改查的能力;搭建的是一个前后分离的环境来做整个后台管理系统的增删改查,其中穿插了一些技术:数据校验、对象存储、一些VO数据的封装等等…

高级篇:实现的是整个商城功能,而不是后台管理系统;其中业务有购物车、结账、详情、检索;将所有的功能都抽取成了一个微服务:也就是说在整个商城系统中,将每个业务拆分成了微服务,由许多微服务共同组合成商城;其中用到的技术和难点:分布式开发期间:核心掌握 SpringCloud组件:包括SpringCloud Alibaba、SpringCloud。

在Springcloud组件中使用的最为频繁的就是 feign远程调用:开启接口,声明feign客户端&&复制目标方法及其签名。

  • 课件详解

    谷粒商城之高级篇(3)

01 响应式编程

只在最后网关SentinelGatewayConfig类中有使用:网关的sentinel的容错回调中使用了一下;

谷粒商城之高级篇(3)

02 接口幂等性

分布式开发中最需要关注的就是接口幂等性问题:无论服务怎么调用,都希望接口是幂等的;这样我们无论调用多少次,结果都是一样的;这个是我们分布式系统里面业务功能接口的保证可以通过加锁、数据库的乐观锁、悲观锁字段、加事务等都可以实现接口幂等性;我们在系统中使用最多的就是令牌机制【类似于验证码】:我给你发一个令牌,你下一次给我带上,用过一次就销毁。

03 事务

在分布式系统中,除了每个系统要解决自己的事务问题之外,分布式系统中调用还要有分布式事务的存在;在这个过程中,还顺带讲解了 springcloud alibaba 中的 seata组件:它可以解决分布式事务,但是它最好用的地方在于 后台管理系统中使用:比如我们在后台管理系统中添加 一个商品,保存的时候需要将 它的优惠信息、库存等在系统中保存,要成功都成功,要失败都失败,【对于并发性能要求不高的,例如后台管理系统,我们可以使用seata 来做分布式事务】;
但是对于高并发系统,我们使用的是最终一致性,所以分布式事务的最终解决方案就是 RabbitMQ:首先发送一个消息:【可靠消息的保证:发送端和消费端的两端确认机制】,将消息发送出去后,最终的系统只需要监听这些消息,完了之后根据消息改变我们系统的数据;【不能达到强一致,达到的是柔性事务的最终一致:最终看到的数据是我们想要的就行。】

04 性能与压力测试

对于高并发系统,使用Jmeter进行压力测试;在压测期间,还可以监控JVM的性能,【使用jvisualvm控制台,或者Jconsole】。

05 缓存和分布式锁
特别在分布式系统中,我们要保证接口的吞吐量,性能的提升**【缓存是必须的】**,所以在高并发系统中,缓存是一个很重要的提升性能的手段,但是使用缓存期间,也有一些问题:缓存的击穿【缓存的单点问题】,缓存雪崩【缓存的大面积问题】,缓存穿透【一直从数据库查询null值】:解决方案:使用分布式

当大并发量全部要来查询数据库,大家先来查询缓存,使用分布式锁来锁住,只有一个请求进来,查询不到缓存再来查询数据库,最终的效果就是只会给数据库放一条查询,而其他人得到锁之后,下次只会来查询缓存,也就不用再去查询数据库了,【分布式锁除了缓存问题使用外,接口幂等性的保证也可以使用】
比如现在有 几个并发的请求,都来调用了一个接口,这个接口只能调用一次,这一次数据是怎么样,那就是怎么样:我们释放锁之后,如果再来调用,就判断一下数据的状态,如果已经被处理了,那就不再处理。
定时任务中也有使用:比如定时启动了3台机器,这3台机器定时同时启动了一个任务,比如都来上架我们的秒杀商品,只要我们使用了分布式锁,我们上架过了,就不需要再次 上架;分布式锁可以来锁住我们所有的机器,让
一个机器来执行这个事情即可。

06 ElasticSearch

我们商品的检索肯定不能放在mysql中进行,通过写一些SQL执行,这样性能会大幅度下降;我们的检索功能:无论是分类检索,还是按照名字检索;所有的检索数据都保存在ElasticSearch中【一个非常专业的检索引擎】,注意它的安装使用都需要非常清楚。

07 异步和线程池

在高并发系统中,异步是非常必须的:我们复习了以前那种简单的 new Thread start这种简单的异步,如果在高并发系统中,我们每一个请求进来都new Thread start,这样资源将很快耗尽;所以为了控制住系统的资源,我们使用线程池:以后所有的异步任务都要提交给线程池;这样的话,线程池中就有一个最大量,比如核心是20个线程,最大是200个,排队可以排500个、800个、乃至更多,我们通过线程池控制住资源,峰值也就是200个正在运行的,以及800个正在排队的,再也不可能占用更多的资源。这样有了资源的统一管控后,我们就不用害怕因为系统的某些资源耗尽导致系统崩溃
我们使用异步,将任务提交给线程池,但是可能异步任务中有顺序,我们可以使用异步编排CompetableFuture

08 单点登录和社交登录

我们着重演示了使用微博进行社交登录,此外还演示了单点登录,比如我们在不同域名下如何登录。但是我们后来是相同域名的,所以我们暂时可以不用使用单点登录。
但是在相同域名下,登录一处,在处处都可以实现单点登录效果,这时我们就整合了 spring session:一处登录,处处可用。【将所有微服务的session进行了同步,只要登录成功之后,去任何微服务,都可以获取到session】—> 使用spring session 解决了分布式系统session不一致问题。

09 商城业务

特别是购物车、订单、秒杀等,所有的业务都做了实现,但是一些细节需要我们自己填充。核心业务:商品上架【后台管理系统】、商品检索、商品详情、购物车、订单、秒杀。其中在商品详情里面,使用最多的是缓存技术:除了整合redis,还整合了springCache

整合springCache来方便的使用缓存,以后我们的全系统都应该使用这种方式来进行缓存。
缓存出现的不一致问题该如何解决缓存的清空以及缓存的更新,使用spring cache 都可以很方便的解决这些问题。

10 RabbitMQ

做分布式事务,来实现最终一致性的时候,rabbitmq是一个非常必要的工具。
我们只需要给A服务给B服务发送一个消息,A服务不用关心怎么做。【在订单系统中,我们使用rabbitmq来完成分布式事务】;【在秒杀系统中,加入rabbitmq队列来进行削峰处理:将所有的流量排队放到队列中,由后台慢慢的进行消费】。此外,通过rabbitmq,A服务和B服务不用关心接口调用了,A都不需要调用B,相当于我们将应用之间进行了解耦。
在订单的关单中关于rabbitmq的使用:我们通过死信队列,保证关单的数据都能被关掉。

11 支付

我们整合了支付宝的沙箱来进行支付。

12 定时任务与分布式调度

我们秒杀系统的所有上架,都需要定时任务来做

13 ShardingSphere

对于 13 分库分表 ShardingSphere在高可用做mysql集群时在使用。

14 SpringCloud组件

SpringCloud中的组件都需要进行掌握:除了nacos作为注册中心我们在一直使用外,配置中心只进行了演示;以后我们需要将所有微服务的配置都放给配置中心,【通过nacos的控制台也可以修改配置,修改完了之后微服务可以在不下线的情况下,应用到最新的配置】
最后,我们为了保护整个系统,引入了 sentinel【作为一个流量哨兵】:从流量入口开始,保护我们任何想要保护的资源;当然,我们最好结合 Sleuth + Zipkin进行服务链路追踪,这样可以看到我们整个系统的运行状况,链路追踪。可以找出一些异常问题,通过sentinel流量哨兵进行服务的降级,保护整个系统。

技术中最重要的是使用缓存来加快速度。异步也是来加快速度和控制资源。此外通过消息队列,无论是流量削峰,还是分布式做最终一致也好,我们都可以通过消息队列来减轻某一个服务的压力【所有消息存入队列中,闲的时候再来慢慢消费】。

谷粒商城之高级篇(3)

在高级篇构建一个高并发系统,除了引入springcloud组件或者是SpringCloud Alibaba来作为周边设施外,高并发有三宝:
缓存、异步、队排好

缓存就是使用redis作为缓存,保证它的缓存一致性,保证缓存的击穿、穿透等这些问题不会出现;

异步结合线程池,不能只是new Tread start;结合线程池以及异步编排来作为整个异步功能。

队排好:使用rabbitmq将所有的高峰流量,或者要做分布式事务的这些消息,全都放进rabbitmq消息队列中,让他们排好队,后台服务一个个处理。

font>

  • 课件详解

    [外链图片转存中…(img-pm0w0T8a-1672324216619)]

01 响应式编程

只在最后网关SentinelGatewayConfig类中有使用:网关的sentinel的容错回调中使用了一下;

[外链图片转存中…(img-JVcQRMQa-1672324216619)]

02 接口幂等性

分布式开发中最需要关注的就是接口幂等性问题:无论服务怎么调用,都希望接口是幂等的;这样我们无论调用多少次,结果都是一样的;这个是我们分布式系统里面业务功能接口的保证可以通过加锁、数据库的乐观锁、悲观锁字段、加事务等都可以实现接口幂等性;我们在系统中使用最多的就是令牌机制【类似于验证码】:我给你发一个令牌,你下一次给我带上,用过一次就销毁。

03 事务

在分布式系统中,除了每个系统要解决自己的事务问题之外,分布式系统中调用还要有分布式事务的存在;在这个过程中,还顺带讲解了 springcloud alibaba 中的 seata组件:它可以解决分布式事务,但是它最好用的地方在于 后台管理系统中使用:比如我们在后台管理系统中添加 一个商品,保存的时候需要将 它的优惠信息、库存等在系统中保存,要成功都成功,要失败都失败,【对于并发性能要求不高的,例如后台管理系统,我们可以使用seata 来做分布式事务】;
但是对于高并发系统,我们使用的是最终一致性,所以分布式事务的最终解决方案就是 RabbitMQ:首先发送一个消息:【可靠消息的保证:发送端和消费端的两端确认机制】,将消息发送出去后,最终的系统只需要监听这些消息,完了之后根据消息改变我们系统的数据;【不能达到强一致,达到的是柔性事务的最终一致:最终看到的数据是我们想要的就行。】

04 性能与压力测试

对于高并发系统,使用Jmeter进行压力测试;在压测期间,还可以监控JVM的性能,【使用jvisualvm控制台,或者Jconsole】。

05 缓存和分布式锁
特别在分布式系统中,我们要保证接口的吞吐量,性能的提升**【缓存是必须的】**,所以在高并发系统中,缓存是一个很重要的提升性能的手段,但是使用缓存期间,也有一些问题:缓存的击穿【缓存的单点问题】,缓存雪崩【缓存的大面积问题】,缓存穿透【一直从数据库查询null值】:解决方案:使用分布式

当大并发量全部要来查询数据库,大家先来查询缓存,使用分布式锁来锁住,只有一个请求进来,查询不到缓存再来查询数据库,最终的效果就是只会给数据库放一条查询,而其他人得到锁之后,下次只会来查询缓存,也就不用再去查询数据库了,【分布式锁除了缓存问题使用外,接口幂等性的保证也可以使用】
比如现在有 几个并发的请求,都来调用了一个接口,这个接口只能调用一次,这一次数据是怎么样,那就是怎么样:我们释放锁之后,如果再来调用,就判断一下数据的状态,如果已经被处理了,那就不再处理。
定时任务中也有使用:比如定时启动了3台机器,这3台机器定时同时启动了一个任务,比如都来上架我们的秒杀商品,只要我们使用了分布式锁,我们上架过了,就不需要再次 上架;分布式锁可以来锁住我们所有的机器,让
一个机器来执行这个事情即可。

06 ElasticSearch

我们商品的检索肯定不能放在mysql中进行,通过写一些SQL执行,这样性能会大幅度下降;我们的检索功能:无论是分类检索,还是按照名字检索;所有的检索数据都保存在ElasticSearch中【一个非常专业的检索引擎】,注意它的安装使用都需要非常清楚。

07 异步和线程池

在高并发系统中,异步是非常必须的:我们复习了以前那种简单的 new Thread start这种简单的异步,如果在高并发系统中,我们每一个请求进来都new Thread start,这样资源将很快耗尽;所以为了控制住系统的资源,我们使用线程池:以后所有的异步任务都要提交给线程池;这样的话,线程池中就有一个最大量,比如核心是20个线程,最大是200个,排队可以排500个、800个、乃至更多,我们通过线程池控制住资源,峰值也就是200个正在运行的,以及800个正在排队的,再也不可能占用更多的资源。这样有了资源的统一管控后,我们就不用害怕因为系统的某些资源耗尽导致系统崩溃
我们使用异步,将任务提交给线程池,但是可能异步任务中有顺序,我们可以使用异步编排CompetableFuture

08 单点登录和社交登录

我们着重演示了使用微博进行社交登录,此外还演示了单点登录,比如我们在不同域名下如何登录。但是我们后来是相同域名的,所以我们暂时可以不用使用单点登录。
但是在相同域名下,登录一处,在处处都可以实现单点登录效果,这时我们就整合了 spring session:一处登录,处处可用。【将所有微服务的session进行了同步,只要登录成功之后,去任何微服务,都可以获取到session】—> 使用spring session 解决了分布式系统session不一致问题。

09 商城业务

特别是购物车、订单、秒杀等,所有的业务都做了实现,但是一些细节需要我们自己填充。核心业务:商品上架【后台管理系统】、商品检索、商品详情、购物车、订单、秒杀。其中在商品详情里面,使用最多的是缓存技术:除了整合redis,还整合了springCache

整合springCache来方便的使用缓存,以后我们的全系统都应该使用这种方式来进行缓存。
缓存出现的不一致问题该如何解决缓存的清空以及缓存的更新,使用spring cache 都可以很方便的解决这些问题。

10 RabbitMQ

做分布式事务,来实现最终一致性的时候,rabbitmq是一个非常必要的工具。
我们只需要给A服务给B服务发送一个消息,A服务不用关心怎么做。【在订单系统中,我们使用rabbitmq来完成分布式事务】;【在秒杀系统中,加入rabbitmq队列来进行削峰处理:将所有的流量排队放到队列中,由后台慢慢的进行消费】。此外,通过rabbitmq,A服务和B服务不用关心接口调用了,A都不需要调用B,相当于我们将应用之间进行了解耦。
在订单的关单中关于rabbitmq的使用:我们通过死信队列,保证关单的数据都能被关掉。

11 支付

我们整合了支付宝的沙箱来进行支付。

12 定时任务与分布式调度

我们秒杀系统的所有上架,都需要定时任务来做

13 ShardingSphere

对于 13 分库分表 ShardingSphere在高可用做mysql集群时在使用。

14 SpringCloud组件

SpringCloud中的组件都需要进行掌握:除了nacos作为注册中心我们在一直使用外,配置中心只进行了演示;以后我们需要将所有微服务的配置都放给配置中心,【通过nacos的控制台也可以修改配置,修改完了之后微服务可以在不下线的情况下,应用到最新的配置】
最后,我们为了保护整个系统,引入了 sentinel【作为一个流量哨兵】:从流量入口开始,保护我们任何想要保护的资源;当然,我们最好结合 Sleuth + Zipkin进行服务链路追踪,这样可以看到我们整个系统的运行状况,链路追踪。可以找出一些异常问题,通过sentinel流量哨兵进行服务的降级,保护整个系统。

技术中最重要的是使用缓存来加快速度。异步也是来加快速度和控制资源。此外通过消息队列,无论是流量削峰,还是分布式做最终一致也好,我们都可以通过消息队列来减轻某一个服务的压力【所有消息存入队列中,闲的时候再来慢慢消费】。

[外链图片转存中…(img-AMRqMfb3-1672324216619)]

在高级篇构建一个高并发系统,除了引入springcloud组件或者是SpringCloud Alibaba来作为周边设施外,高并发有三宝:
缓存、异步、队排好

缓存就是使用redis作为缓存,保证它的缓存一致性,保证缓存的击穿、穿透等这些问题不会出现;

异步结合线程池,不能只是new Tread start;结合线程池以及异步编排来作为整个异步功能。

队排好:使用rabbitmq将所有的高峰流量,或者要做分布式事务的这些消息,全都放进rabbitmq消息队列中,让他们排好队,后台服务一个个处理。

加上这3个手段,构建高并发系统并不难。