环境搭建:
搭建SpringSession 环境:
1.导入SpringSession 的依赖
2. 在配置文件中配置session 是用redis 进行存储的
3.编写Session 的配置
线程池配置:
引入redis:
服务开启SpringSession:
一个订单的核心调度过程:
我们的项目没有售后服务。
完成功能:购物车中点击“去结算” 时,去到订单页面。注意:订单页面是只有登录以后才能进入的,所以order 服务要配置一个拦截器
配置拦截器
配置拦截器路径:
以上就能保证在用户点击购物车的“去结算” 功能时,用户是处于登录状态的。
构建订单模型vo:
订单页面展示:
尤其要注意的是商品的价格一定是最新商品的价格,而不是当时下订单时的价格。
整体请求代码:
Controller:
ServiceImpl:
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
@Autowired
MemberFeignService memberFeignService;
@Autowired
CartFeignService cartFeignService;
@Autowired
ThreadPoolExecutor executor;
@Autowired
WmsFeignService wmsFeignService;
@Override
public PageUtils queryPage(Map<String, Object> params) {
IPage<OrderEntity> page = this.page(
new Query<OrderEntity>().getPage(params),
new QueryWrapper<OrderEntity>()
);
return new PageUtils(page);
}
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = new OrderConfirmVo();
//从拦截器中获取用户信息
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//从主线程中获取到request 的原信息
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//给新开的每一个线程都共享之前的数据
RequestContextHolder.setRequestAttributes(requestAttributes);
//1.远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor).thenRunAsync(()->{
List<OrderItemVo> items = confirmVo.getItems();
List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
R hasStock = wmsFeignService.getSkuHasStock(collect);
List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
});
if (data != null){
Map<Long, Boolean> map = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHashStock));
confirmVo.setStocks(map);
}
},executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
//2.远程查询购物车所有选中的购物项(价格是最新状态)
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(currentUserCartItems);
}, executor);
//3.查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4.total(订单总额),payPrice(应付金额) 可以来vo 中自动计算
//TODO 5.防重令牌
CompletableFuture.allOf(getAddressFuture,cartFuture).get();
return confirmVo;
}
}
细化地址栏的功能:
当选中哪个地址时,哪个地址就会变成高亮红色
效果:
对应代码:
运费计算(距离 + 货物重量):
根据收货地址的id 在数据库中查询应该要收多少钱运费。
给收货地址的p 标签添加两个属性def 是选中的地址属性(选中为1,不选中为 0),addrId 是地址的id。
要根据gateway 的规则来发送后端请求:
对应发送的后端请求:
最终实现效果:
当选中地址时,就能把运费给算出
完成功能:在计算运费的时候同时返回收货人
为返回的这个数据构成一个Vo 对象:包含地址信息,运费。
前端用ajax 进行获取:
接口幂等性:
当一个人网速很慢,在点击提交订单的时候点击了很多次,那么数据库中就会生成很章订单对象。所以订单的防重复提交是很重要的。专业术语叫:要保证提交订单的幂等性。也就是订单提交一次和提交100 次的结果都是一样的,数据库只有一份订单。
所以数据库中,可以利用添加唯一索引来具有数据幂等性:
后删除token(令牌)危害:当我连续两次点击提交订单时,服务器处理第一次订单时,token 没有删除,然后此时第二次请求又进来了,所以第二个请求还是能被处理,所以此时就有两个订单同时创建。
项目中我们使用令牌机制:
整体流程
因为是在分布式情况下,所以我们要在Redis 中保存这个令牌。
给页面也保存一个令牌号
当点击“提交订单” 的时候,我们要有一个数据模型来接收它,所以构建提交订单的vo 对象。
点击“提交订单”所要提交的数据。
为什么不提交商品Id?
因为商品是实时都去Redis 中进行查询的,包括它的价格,因为有可能在提交订单的时候,又回去购物车再勾上几个商品,又生成了一张新的订单,所以此时要以购物车中勾中的商品为准,所以这里也不直接获取商品id了。而是单独去redis 中自己获取。
提交订单以后无非两种结果:生成订单成功,生成订单失败。所以也要为这两种可能构建一个vo 对象以便提交订单以后页面的显示与走向。
提交订单流程:
锁定库存的逻辑:
远程调用出现的问题:
1.出现网络抖动问题,或者在远程调用方法时执行了,但之后网络断了,此时没有接受到返回值,然后一个事务的操作中,我们会对这些不健康的返回值进行抛出异常,然后回滚订单。所以此时的效果就是:订单下了,同时库存也给扣除了,但是因为出现异常,订单回滚了,此时数据又没来到订单中,那么库存就会被锁没了。
2. 当远程调用没有问题时,但是它下面的操作出现了问题,按逻辑是整体回滚的,但是远程调用的是已经执行了的,没法回滚的。所以:已经执行了的远程服务没办法回滚
@Transactional : 本地事务,只能回滚本地方法。在分布式场景中,有要调用其他的服务,这不属于本地方法。
分布式事务最大问题:网络问题+ 分布式机器;
本地事务:
可重复读:在整个事务期间内,只要事务没结束,比如我要读1号记录数据100,无论后面读多少次,1 号数据还是100,即使外面的人在我们事务操作期间把1 号数据都删了,或者把1 号数据修改成200 了,我们读到的1 号数据还是100.
隔离级别由上至下增大,并且由上至下并发能力越低。
事务隔离级别可用注释的值来进行设置.
b 事务就是和a 事务共用一个事务。 c 事务不和a 公用一个事务,自己单独一个事务。
MYSQL 默认的事务传播行为是:request
当a 事务实现异常时,a ,b事务都会回滚,但c 事务中没有出现异常的话它不会回滚。
a 事务设置超时时间:30s, b 事务设置超时时间:2s。如果b 事务的传播行为是request,那么b 事务的超时时间也是30s 。总结: 只要传播行为是request,那么a 事务的设置就传播到了和它公用的一个事务的方法中
事务的本质是使用代理对象来控制的。如果同一个类中,一个事务方法a调用另一个同类中的另一个事务方法b。a 方法是用代理对象调用的,但b 方法不是代理对象对用的,所以b 方法的事务会失效。
解决方法:
1.引入aop,aop 中会引入aspectJ,它具有动态代理功能。
2.@EnableAspectJAutoProxy(exposeProxy = true): 开启aspectj 动态代理功能。以后所有的动态代理都是由aspectJ 创建的(即使没有接口也可以创建动态代理),对外暴露代理对象
3.通过aspectJ 创建的代理对象,由代理对象去调用同类方法,就能实现内部也有事务的方法了。
以上都是本地事务的讨论。
分布式事务:
定理:不能打破的理论。
一致性:比如现在有3 台机器。往1 号数据中存储了一个1 号数据,那么当请求访问2号机器和3 号机器时都要有这个数据。所有数据的备份在同一时刻是否都有同样的值。
可用性: 有3 台机器,有一台机器完蛋了,其他的机器集群还能不能响应客户端的所有操作。如果可以,那说明是可用的。如果不能响应,那说明是不可用的。
分区容错性:3 台机器分布在不同的节点,他们之间的通信是通过网络进行通信的,如果通信期间出现网络问题,这就叫分区容错。
在分布式系统中,永远都要满足分区容错,因为网络肯定会出现问题。当分区出错了,一定要想办法解决。
所以一定要在“一致性”和“可用性”二者选一。如果满足可用性时,一台机器出现故障,但是还是能访问到,那么用户访问到的将是不一致的数据。但如果想满足一致性,我们一定让用户访问每个节点数据都是一样的话,就不能让整个集群可用。当一台机器出现问题时,数据必然也会出现问题,那么此时已经选择了一致性,所以为了保证数据的一致性,只能让这台故障的机器停掉,也就牺牲掉了可用性。
所以分布式系统中,要么是CP (一致性+分区容错性)系统,要么是AP (可用性+分区容错性)系统。
只有本地系统才会出现acp 系统。
一致性算法的两个代表:raft,paxos
下面的链接是raft 算法的动画显示效果。或者看p285
cp 定理:假如有6 台服务器,运行过程中出现分区错误,3台机器一个分区。那么此时就一定要选取一个领导出来,而选取领导的条件是获取到大部分的选票,即6 张票要占4 张,而这两个分区只有3 台机器,所以这两个分区永远都选不出一个领导出现。那么此时就算有数据传送过来,也不能响应数据给它,即使这6 台机器都是好的。
分布式事务常见的解决方案:
我们使用Spring-cloud alibaba 提供的seata(它是2pc 的变形)
我们使用的是Seata 的AT 模式,根据官方文档进行操作:
当其中一个服务出现异常需要回滚时,由TC 来命令回滚所有的服务。
那么需要回滚的服务就要在数据库中增添一张回滚表,这张表记录着在回滚操作前的操作,以便TC 执行回滚操作。
在我们的项目中,给每一个数据库都添上这张表
下载的这个服务器就是图中的TC 部分
点击网站进行下载:
项目整合Seata:
导入SeaTa 依赖:
注意:当导入seata 依赖时它还会帮你自动导入seata-all ,这个依赖会带有一个版本号,而这个版本号就得和seata-server 的版本号对应。
所以我们就得下载seata-server 0.7.1 的版本
下载完成后,进入conf 文件夹,修改registry.conf 的配置
然后先启动nacos, 再启动seata.bat
然后在nacos 中就能看到seata 在注册列表中了。
在需要的地方写上该注解就算使用了seata 事务了。(注意:在使用@GlobalTransactional 注释时,该方法还得标注上@Transactional 来证明这是一个事务)。主事务才需要加这个注释,分支事务不需要写这个注释,依然保持@Transactional 即可。
给需要用到seata 分布式事务的服务都加上以下配置:
给每一个用到seata 事务的服务都加上file.conf 和registry.conf 文件
给file.conf 中修改配置
但是seata 的AT 模式不适用于高并发场景。因为它只是2pc 的一个变形。所以要用“柔性事务-最大努力通知型方案”,“柔性事务-可靠消息+最终一致方案"这些适用于高并发场景的分布式事务。我们项目使用的是"柔性事务-可靠消息+最终一致方案”。
锁库存的增强版逻辑:
下订单到关闭订单,锁库存到解锁库存。这两个过程都能用RabbitMQ 的延时队列。
没用RabbitMQ 的延时队列情况下,当定时任务开始进行计时的时候,下一分钟刚好有一个订单生成,然后当定时任务经过30min 进行扫描失效订单时,此时这个订单只是生成了29 分钟,还没到30 min 的失效时间,所以只能等到下一次定时任务的生效时间才能被扫描到。
使用延时队列:
当在锁库存的时候,只要锁库存成功了,就发消息给MQ,MQ 先把消息保持上一段时间,到时间以后把消息发出去,然后此时检查订单是否没支付,是的话就直接解锁库存,而不像普通的定时任务那样出现大面积的时效性问题。
所以RabbitMQ 解决了事务最终一致性。
TTL:只要这段消息在设置的时间内没有被消费者取走,那么就被判定为死信。然后被服务器抛弃
DLX: 它也是一个普通的路由(Exchange),只不过专门用于接收死信。
那么结合TTL 就是:当消息一过期,让服务器别乱丢,让这些死信都去到这个DLX(死信路由)中,然后再由另一个队列来监听这个路由,然后让消费者监听这个队列。这个队列中的消息都是过了30 min(设置的过期时间)。
推荐使用给队列设置过期时间。
为什么不用给消息设置过期时间来实现延时队列:
比如现在我连续发送3 条消息。第一条消息设置5 min过期,第二条是1 min过期,第三条是1 s以后过期。按正常逻辑,应该是1 s过期的这条消息优先弹出这条队列。但服务器不是这么检查的,服务器先从队列中那第一条消息,这条消息是5 min后才过期,所以服务器会等5 min后才来拿这条消息,那么服务器会在5 min之后再把队列中的消息拿出来,那么此时第一条消息才算过期。所以第二条消息,得等第一条消息过期以后,即过了5 分钟以后,才能轮到自己过期,然后才来到第三条消息。所以后两条消息都在5 min后才扔的。所以就应该使用给整个队列设置过期时间,这样队列中的所有消息都是这个过期时间。
库存服务消息队列模型:
我们项目的延时队列模型:
此时order 和ware服务要用到RabbitMQ,就要引入它的依赖:
Order 服务配置RabbitMQ 配置:
ware 服务:
分别在他们的主类中开启RabbitMQ
让order 服务自动创建Queue,Exchange
@Configuration
public class MyMQConfig {
@RabbitListener(queues = "order.release.order.queue")
public void listener(OrderEntity entity, Channel channel, Message message) throws IOException {
System.out.println("收到过期的订单信息,准备关闭订单"+entity.getOrderSn());
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}
/**
* @Bean 的作用: 容器中的Binding, Queue, Exchange 都会自动创建(RabbitMQ 没有的情况)
* @return
*/
@Bean
public Queue orderDelayQueue(){
Map<String,Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange","order-event-exchange");
arguments.put("x-dead-letter-routing-key","order.release.order");
arguments.put("x-message-ttl",60000);
Queue queue = new Queue("order.delay.queue", true, false, false,arguments);
return queue;
}
@Bean
public Queue orderReleaseOrderQueue(){
Queue queue = new Queue("order.release.order.queue", true, false, false);
return queue;
}
@Bean
public Exchange orderEventExchange(){
return new TopicExchange("order-event-exchange", true, false);
}
@Bean
public Binding orderCreateOrderBinding(){
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
@Bean
public Binding orderReleaseOrderBinding(){
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
}
注意:想第一次登陆就看到RabbitMQ 中有我们代码创建的Queue 和Exchange。一定要加上@RabbitListener 来监听一个队列,方法体可以为空,这样RabbitMQ 就会识别出当前Queue 和Exchange 中没有我们代码中的队列,然后就会自己去创建Queue 和Exchange 了。
库存锁定的场景:
1)下订单成功,订单过期没有支付被系统自动取消,被用户手动取消,都要解锁库存
2) 下订单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚,之前锁定的库存就要自动解锁(用seata 来解决分布式事务,太慢了,所以不用seata 了。而改用了最终一致性的策略)
锁库存逻辑