这里承接 知识补充篇 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的依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-amqpartifactId>
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
⑤ 按照下图创建交换机、队列、绑定关系
统一使用 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 arguments
return new TopicExchange("stock-event-exchange",true,false);
}
@Bean
public Queue stockReleaseStockQueue(){
//String name, boolean durable, boolean exclusive, boolean autoDelete, Map 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 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 arguments
return new Binding("stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.locked",
null);
}
}
出现问题: 并没创建交换机、队列、绑定关系
出现问题的原因:只有当第一次连接上RabbitMQ时,发现没有这些东西才会创建
解决方案:监听队列
交换机、队列、绑定关系创建成功后,将上述代码注释
库存解锁的两种场景:
①下单成功,订单过期没有支付被系统自动取消、被用户手动取消。都要解锁
②下单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁
①更改数据库表wms_ware_order_task_detail
添加两个字段,方便库存回滚
实体类中需要修改的:
WareOrderTaskDetailEntity 加上 全参和无参构造器方便消息传播该实体类数据。
WareOrderTaskDetailDao.xml
② 保存工作单详情方便回溯
③ Common服务中创建To,方便MQ发送消息
@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;
}
④ 向MQ发送库存锁定成功的消息
库存回滚解锁
1)库存锁定
在库存锁定是添加以下逻辑
- 由于可能订单回滚的情况,所以为了能够得到库存锁定的信息,在锁定时需要记录库存工作单,其中包括订单信息和锁定库存时的信息(仓库id,商品id,锁了几件…)
- 在锁定成功后,向延迟队列发消息,带上库存锁定的相关信息
逻辑:
遍历订单项,遍历每个订单项的每个库存,直到锁到库存
发消息后库存回滚也没关系,用id是查不到数据库的
锁库存的sql
这里编写了发送消息队列的逻辑,下面写接收消息队列后还原库存的逻辑。
解锁场景:
1.下单成功,库存锁定成功,接下来的业务如果调用失败导致订单回滚。之前锁定的库存就要自动解锁。
2.锁库存失败无需解锁
解决方案:通过查询订单的锁库存信息,如果有则仅仅说明库存锁定成功,还需判断是否有订单信息,如果有订单信息则判断订单状态,若订单状态已取消则解锁库存,反之:不能解锁库存,如果没有订单信息则需要解锁库存,如果没有锁库存信息则无需任何操作。
1.编写Vo,通过拷贝订单实体----> OrderEntity,用于接收订单信息
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
@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、订单失败。
* 锁库存失败。
*
* 只要解锁库存的消息失败。一定要告诉服务器解锁失败。
*
* @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
出现问题: 远程调用订单服务时被拦截器拦截
解决方案:请求路径适配放行
订单服务下的 拦截器。
将就配合着看。
1.定时关单代码编写
①订单创建成功,给MQ发送关单消息
订单服务下的 OrderServiceImpl 的 submitOrder提交订单方法
② 监听事件,进行关单
订单服务下
@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);
}
}
订单释放和库存解锁逻辑: 当订单创建成功,1分钟之后,向MQ发送关单消息,2分钟后,向MQ发送解锁库存消息,关单操作完成之后,过了1分钟解锁库存操作。
存在问题:由于机器卡顿、消息延迟等导致关单消息延迟发送,解锁库存消息正常发送和监听,导致解锁库存消息被消费,当执行完关单操作后便无法再执行解锁库存操作,导致卡顿的订单永远无法解锁库存。
③ 按上图创建绑定关系
订单服务MyMQConfig
④ common服务中,创建OrderTo(拷贝order实体)
⑤ 向MQ发送解锁库存消息
/**
* 关单操作
* @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);
}
}
⑥ 解锁库存操作
库存服务下
@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);
}
}
//防止订单服务卡顿,导致订单状态消息一直改不了,库存消息优先到期。查订单状态新建状态,什么都不做就走了。
//导致卡顿的订单,永远不能解锁库存。
@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;
}
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机制
情况三: 防止自动ack带来的缺陷,采用手动ack,解决方案上面都有这里不再细说
2、消息重复
- 消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息重新由unack变为ready,并发送给其他消费者
- 消息消费失败,由于重试机制,自动又将消息发送出去
- 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送
- 消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志
- 使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,处理过就不用处理
- rabbitMQ的每一个消息都有redelivered字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的
消息被成功消费,ack时宕机,消息由unack变成ready,Broker又重新发送。解决方案:将消费者的业务消费接口应该设计为幂等性的,比如扣库存有工作单的状态标志。
3、消息积压
- 消费者宕机积压
- 消费者消费能力不足积压
- 发送者发送流量太大
- 上线更多的消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理
消息积压即消费者的消费能力不够, 上线更多的消费者进行正常的消费。
支付宝开放平台传送门:支付宝开放平台
网站支付DEMO传送门:手机网站支付 DEMO | 网页&移动应用
网站支付DEMO是用Eclipse编写的,代码结构如下图所示:
对称加密:发送方和接收方用的是同一把密钥,存在问题:当某一方将密钥泄漏之后,发送的消息可以被截取获悉并且随意进行通信。
非对称加密:发送方和接收方使用的不是同一把密钥,发送方使用密钥A对明文进行加密,接收方使用密钥B对密文进行解密,然后接收方将回复的明文用密钥C进行加密,发送方使用密钥D进行解密。采用非对称加密的好处是:即使有密钥被泄漏也不能自由的通信。
密钥的公私性是相对于生成者而言的。发送方通过密钥A对明文进行加密,密钥A是只有发送方自己知道的,接收方想要解密密文,就需要拿到发送方公布出来的密钥B。
公钥:生成者发布的密钥可供大家使用的
私钥:生成者自己持有的密钥
签名:为了防止中途传输的数据被篡改和使用的方便,发送方采用私钥生成明文对应的签名,此过程被成为加签。接收方使用公钥去核验明文和签名是否对应,此过程被成为验签。
配置支付宝的沙箱环境:
沙箱环境配置查看传送门:登录 - 支付宝
接口加签方式共有两种:
①采用系统默认生成的支付宝的公钥、应用的私钥和公钥:
② 采用自定义密钥
传送门:密钥工具下载
将支付宝密钥工具生成的应用公钥复制进加签内容配置中,会自动生成支付宝的公钥
沙箱账号:用于测试环境中的商品支付
沙箱账号
使用eclipse测试:
注意如果项目报红:因为老师给的 沙箱测试Demo 默认使用的 是 tomact7.0 ,所以这里需要将 tomact7.0移除掉,使用我们自己本机安装的 tomcat。
选择 tomcat 7.0移除 ,然后导入自己的 就行。这里我已经移除好了。
参考:当eclipse导入新项目后出现红叉解决办法
老师课件:
一、支付宝支付
1、进入“蚂蚁金服开放平台”
https://open.alipay.com/platform/home.htm
2、下载支付宝官方demo,进行配置和测试
文档地址
https://open.alipay.com/platform/home.htm 支付宝&蚂蚁金服开发者平台
https://docs.open.alipay.com/catalog 开发者文档
https://docs.open.alipay.com/270/106291/ 全部文档=>电脑网站支付文档;下载demo
3、配置使用沙箱进行测试
1、使用RSA 工具生成签名
2、下载沙箱版钱包
3、运行官方demo 进行测试
4、什么是公钥、私钥、加密、签名和验签?
1、公钥私钥
公钥和私钥是一个相对概念
它们的公私性是相对于生成者来说的。
一对密钥生成后,保存在生成者手里的就是私钥,
生成者发布出去大家用的就是公钥
2、加密和数字签名
加密是指:
签名:
验签
如果别人直接访问我们自己电脑的本地项目,是不能访问的。
内网穿透的原理: 内网穿透服务商是正常外网可以访问的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博客
超好用的内网穿透工具【永久免费不限制流量】
上面两个 教程都可以参考,个人推荐 第二个教程。
已经成功建立连接。
【需要注意的是,对于免费版本的cpolar随机URL地址是会在24小时之后变化的,如果需要进一步使用,可以将站点配置成二级子域名,或自定义域名(使用自己的域名)长期使用。】
配置修改:
修改url前缀:
测试:访问成功。
注意,需要保证所有项目的编码格式都是 utf-8
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
直接复制老师给的课件。
更改一些,这里我使用的是 绑定配置文件的方式来声明一些变量的:因为老师使用了一个@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
@Data
public class PayVo {
private String out_trade_no; // 商户订单号 必填
private String subject; // 订单名称 必填
private String total_amount; // 付款金额 必填
private String body; // 商品描述 可空
}
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;
}
1.会员服务导入thymeleaf的依赖并配置
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
开发环境下,关闭thymeleaf的缓存
spring.thymeleaf.cache=false
2.将订单页文件夹中的index.html复制到会员服务的templates下并更名为orderList.html,将静态资源复制到Nginx中并替换访问路径【动静分离】
Nginx 中 配置静态资源位置。
修改 orderList.html中的静态资源前缀:
3. 配置网关及域名映射
配置 域名映射:192.168.56.10 member.gulimall.com
配置网关:
5. 引入Spring-Session
①导入依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
<exclusions>
<exclusion>
<groupId>io.lettucegroupId>
<artifactId>lettuce-coreartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
dependency>
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
dependency>
② 配置
spring.session.store-type=redis
spring.redis.host=192.168.56.10
复制订单服务中有关session的配置
/**一个新系统引入需要以下配置
* 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注解
④ 配置拦截器
远程服务调用获取运费信息,都给放过
@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;
}
}
}
注册拦截器
@Configuration
public class MemberMvcConfigurer implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor loginUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
}
}
6. 前端页面跳转修改
首页将我的订单处修改
7. controller编写
@Controller
public class MemberWebController {
@GetMapping("/memberOrder.html")
public String memberOrderPage(){
//查出当前登录的用户的所有订单列表数据
return "orderList";
}
}
8.配置支付成功后的跳转页面
订单服务的 application.properties中修改 成功回调的地址。
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类:
解决方案:
2.前端页面展示
只保留一个table用于遍历
遍历订单
获取订单号
遍历订单项
固定照片大小,取出图片
获取商品描述
获取订单项数量、收货人姓名、应付总额
获取订单状态
改进: 这些信息只出现一次,所占行数依据订单项数而定
打印结果如下:
只遍历一次,有几个商品占几行
缺失
<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:10span>
<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>
效果展示:
ps:注意:如果使用的 cpolar进行内网穿透测试,每隔24小时需要更换一下 url地址。
获取隧道地址:http://127.0.0.1:9200/#/status/online
更换这个 地址:
服务器[异步通知]页面路径
支付回调异步通知:异步通知参数说明 | 网页&移动应用
支付宝采用的是最终一致性中的最大努力通知策略
①搭建隧道
图形管理界面方式:
或者使用命令行方式:
cpolar http 192.168.56.10:80
测试:需要成功访问到Nginx
配置支付成功后的回调请求路径
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";
}
③ 配置拦截器放过
支付宝异步通知不需要进行登录。
④ 配置Nginx
注意细节:
1.配置域名,否则将会路由给静态页面
2.精确匹配要在模糊匹配的上面
在 gulimall.conf中进行配置:
重启Nginx
docker restart nginx
postman 客户端中测试:
1.将支付宝支付成功后的异步通知信息抽取成vo
复制老师课件。
@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. 验签,确保是支付宝返回的信息
验签核心代码
//获取支付宝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. 业务处理(①保存交易流水号②修改订单状态)
确保流水号的唯一性,添加索引
将订单号设置的长度变长一点,防止订单号设置错误。
以下两种状态都是支付成功状态
代码实现:
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 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 = ?
测试:
控制台成功打印:
页面显示:已付款
1、订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态改为已支付了,但是库 存解锁了。
2、由于时延等问题。订单解锁完成,正在解锁库存的时候,异步通知才到
3、网络阻塞问题,订单支付成功的异步通知一直不到达
4、其他各种问题
情况一:订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态已经改为已付款但是库存解锁了
解决方案:自动关单
AlipayTemplate
情况二: 由于网络延时原因,订单解锁完成,正要解锁库存时,异步通知才到
解决方案:订单解锁,手动关单
启动人人开源后台和前端,点击 优惠营销-每日秒杀。
完善这个每日秒杀业务,当点击每日秒杀,发送的请求:
①网关配置:
②访问成功后,添加两个秒杀场次:
③当点击 每个场次 的关联商品
修改 后台代码:
首先,这个请求是在 优惠券服务的 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表
sms_seckill_sku_relation表
后台管理系统查看效果:
因为 秒杀具有瞬间高并发的特点,针对这一特点,必须要做限流+ 异步+ 缓存(页面静态化) + 独立部署;
所以我们需要新建一个微服务来编写 秒杀业务,如果放到其他业务下,譬如放到 商品系统下,可能会因为秒杀业务带来的高并发将数据库或者商品系统压垮。
①新建 gulimall-seckill秒杀服务
暂时配置的依赖:
② 导入公共服务依赖,并排除 seata 依赖。
<dependency>
<groupId>com.atguigu.gulimallgroupId>
<artifactId>gulimall-commonartifactId>
<version>0.0.1-SNAPSHOTversion>
<exclusions>
<exclusion>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-seataartifactId>
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 的数据库设置,这里排除数据源设置。
1、cron 表达式
语法:秒分时日月周年(Spring 不支持)
官网文档
中文文档:
介绍
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 | , - * / |
*
( “所有值”)- 用于选择字段中的所有值。例如,分钟字段中的**“ * ”表示***“每分钟”*。?
( “无特定值”)- 当您需要在允许字符的两个字段之一中指定某些内容而不是另一个时很有用。例如,如果我希望我的触发器在一个月中的特定一天(比如 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默认是一个单线程,如果开启多个任务时,任务的执行时机会受上一个任务执行时间的影响。
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;
}
1、秒杀系统关注的问题
1、服务单一职责+独立部署
2、秒杀链接加密
3、库存预热+快速扣减
4、动静分离
5、恶意请求拦截
6、流量错峰
7、限流&熔断&降级
8、队列削峰
人人开源后台vue的后台管理系统里上架秒杀,打开F12看url然后去编写逻辑
2、秒杀架构设计
(1) 秒杀架构
nginx–>gateway–>redis分布式信号量–> 秒杀服务
秒杀活动:存在在scekill:sesssions这个redis-key里,。value为 skyIds[]
秒杀活动里具体商品项:是一个map,redis-key是seckill:skus,map-key是skuId+商品随机码
(2) redis存储模型设计
秒杀场次存储的List可以当做hash key在SECKILL_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:"; //+商品随机码
接下来完善 秒杀商品上架业务。
获取最近三天的秒杀信息
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;
}
@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依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.12.0version>
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;
}
}
避免高并发下多机器同时上架情况。
修改代码:
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
/**
* 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);
}
});
}
/**
* 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中效果图:
前面已经在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;
}
域名映射
网关服务
- id: gulimall_seckill_route
uri: lb://gulimall-seckill
predicates:
- Host=seckill.gulimall.com
首页获取并渲染
ul中的各个 li标签全部删除掉,使用 Ajax 局部获取刷新。
ajax请求:
$.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp) {
if (resp.data.length > 0){
resp.data.forEach(function (item) {
$("")
.append($(""))
.append($(""
+ item.skuInfo.skuTitle+""))
.append($(""+ item.seckillPrice+""))
.append($(""+ item.skuInfo.price+""))
.appendTo("#seckillSkuContent");
})
}
//append 添加元素
//appendTo 添加到哪个位置去
//
//
// 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千克) (日本官方直采) 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千
// ¥83.9¥99.9
//
});
效果展示:
/**
* 获取当前商品的秒杀信息
* @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方法完善
@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的差值
<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>
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) {
$("+item.skuId+")'> ")
.append($(""))
.append($(""
+ item.skuInfo.skuTitle+""))
.append($(""+ item.seckillPrice+""))
.append($(""+ item.skuInfo.price+""))
.appendTo("#seckillSkuContent");
})
}
效果展示:
该商品在秒杀时间中,显示秒杀价格
如果该商品不在秒杀时间内,显示 秒杀预告。
02:使用随机码
03:本次秒杀使用 redis预热库存(信号量)
05:本次秒杀使用登录拦截器
08:给MQ发送消息
之前我们已经做了 前面 4步,接下来我们要完善后面4步。
秒杀的最终的处理
①晚上 详情页的页面效果:如果不在秒杀时间范围内,显示 “加入购物车”;如果在秒杀时间范围内,显示“立即抢购”,并进行跳转,跳转的路径带上 商品 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;
})
②编写去秒杀时的登录检查
spring-boot-starter-data-redis
排除 lettuce-core
,使用jedis
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
<exclusions>
<exclusion>
<groupId>io.lettucegroupId>
<artifactId>lettuce-coreartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
dependency>
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
dependency>
spring.session.store-type=redis
复制有关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;
}
第一种:
优点:分散流量;缺点:流量会级联映射到其他系统里面,极限情况下,造成各个系统崩溃
第二种
优点:在秒杀开始到快速创建订单过程中,没有进行一次数据库操作或者远程调用,只需要校验数据的合法性,因为所有数据都在缓存中放着。
缺点:如果订单服务已经崩溃了,那秒杀服务发出的消息一直不能消费,订单一直支付不成功。
这里我们采用 第二种方案。
消息队列:
秒杀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;
}
以上秒杀流程最大的特点就是:流量削峰,不是每个请求过来都要去调用订单服务。
①引入 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;
}
发送消息
@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 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 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);
}
测试:点击 “立即抢购”:第一次成功
第二次直接返回null。
这里先修改一下 商品详情页的显示 “立即抢购” 和 “加入购物车”的逻辑。
如果不是秒杀商品也要显示
加入购物车
。
①复制 购物车页面 的 success.html 页面
②引入 thymeleaf依赖
<!--模板引擎:thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
application.properties配置
spring.thymeleaf.cache=false
③修改 业务逻辑
SeckillController
除了 /kill
这个请求外,其他都是返回 JSON数据,而 /kill
跳转到 指定页面。
④编写秒杀成功页面
修改前缀:静态资源改为从购物车服务直接获取。
编写秒杀成功显示:
秒杀成功,显示订单号,以及跳转的支付页面;
秒杀失败,温馨提示。
<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>
成功:
失败:
上面我们已经完成了秒杀( 高并发) 系统关注的问题:
01 服务单一职责**+**独立部署
02 秒杀链接加密:使用随机码
03 库存预热+快速扣减:使用redis缓存,预热库存(信号量)
04 动静分离
05 恶意请求拦截:登录拦截器
08 队列削峰:秒杀成功给MQ发送消息(给订单服务发送消息,让其慢慢消费)
06 流量削峰 和 07 限流&熔断&降级没有完成。
以上可以保证高并发系统下 能够快速处理,但是不能保证稳定。
下面我们使用 阿里巴巴 的sentinel进行 第7步:限流&熔断&降级。
参考 谷粒商城高级篇之额外知识补充篇 7 SpringCloud Alibaba-Sentinel
基础篇注重的是 基本的增删改查的能力;搭建的是一个前后分离的环境来做整个后台管理系统的增删改查,其中穿插了一些技术:数据校验、对象存储、一些VO数据的封装等等…
高级篇:实现的是整个商城功能,而不是后台管理系统;其中业务有购物车、结账、详情、检索;将所有的功能都抽取成了一个微服务:也就是说在整个商城系统中,将每个业务拆分成了微服务,由许多微服务共同组合成商城;其中用到的技术和难点:分布式开发期间:核心掌握 SpringCloud组件:包括SpringCloud Alibaba、SpringCloud。
在Springcloud组件中使用的最为频繁的就是 feign远程调用:开启接口,声明feign客户端&&复制目标方法及其签名。
01 响应式编程
只在最后网关SentinelGatewayConfig类中有使用:网关的sentinel的容错回调中使用了一下;
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流量哨兵进行服务的降级,保护整个系统。
技术中最重要的是使用缓存来加快速度。异步也是来加快速度和控制资源。此外通过消息队列,无论是流量削峰,还是分布式做最终一致也好,我们都可以通过消息队列来减轻某一个服务的压力【所有消息存入队列中,闲的时候再来慢慢消费】。
在高级篇构建一个高并发系统,除了引入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个手段,构建高并发系统并不难。