05谷粒商城-高级篇五
前言
知不足而奋进,望远山而前行
13.商城业务-分布式事务
13.1本地事务在分布式下的问题
主要步骤:
- 远程服务其实成功了,由于网络故障没有返回,导致订单回滚,存库成功扣减
- 远程服务执行完成,下面的其他方法出现问题,导致已执行的远程请求不能回滚
13.2本地事务隔离级别&传播行为等复习
事务的基本性质
数据库事务的几个特性:原子性(Atomicity )、一致性( Consistency )、隔离性或独立性( Isolation) 和持久性(Durabilily),简称就是 ACID
- 原子性:一系列的操作整体不可拆分,要么同时成功,要么同时失败
- 一致性:数据在事务的前后,业务整体一致。
- 隔离性:事务之间互相隔离。
- 持久性:一旦事务成功,数据一定会落盘在数据库。
事务的隔离级别
- READ UNCOMMITTED(读未提交):该隔离级别的事务会读到其它未提交事务的数据,此现象也称之为脏读。
- READ COMMITTED(读提交:一个事务可以读取另一个已提交的事务,多次读取会造成不一样的结果,此现象称为不可重 复读问题,Oracle 和 SQL Server 的默认隔离级别。
- REPEATABLE READ(可重复读):该隔离级别是 MySQL 默认的隔离级别,在同一个事务里,select 的结果是事务开始时时间 点的状态,因此,同样的 select 操作读到的结果会是一致的,但是,会有幻读现象。MySQL 的 InnoDB 引擎可以通过 next-key locks 机制(参考下文"行锁的算法"一节)来避免幻读。
- SERIALIZABLE(序列化):在该隔离级别下事务都是串行顺序执行的,MySQL 数据库的 InnoDB 引擎会给读操作隐式 加一把读共享锁,从而避免了脏读、不可重读复读和幻读问题。
事务的传播行为
-
1、PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务, 就加入该事务,该设置是最常用的设置。
-
2、PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当 前不存在事务,就以非事务执行。
-
3、PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果 当前不存在事务,就抛出异常。
-
4、PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
-
5、PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当 前事务挂起。
-
6、PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
-
7、PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务, 则执行与 PROPAGATION_REQUIRED 类似的操作。
SpringBoot 事务关键点
事务的自动配置:TransactionAutoConfiguration
事务的坑:在同一个类里面,编写两个方法,内部调用的时候,会导致事务设置失效。原因是没有用到代理对象的缘故。
解决:
-
0)、导入 spring-boot-starter-aop
-
1)、@EnableTransactionManagement(proxyTargetClass = true)
-
2)、@EnableAspectJAutoProxy(exposeProxy=true)
-
3)、AopContext.currentProxy() 调用方法
使用代理对象来调用事务方法
13.3分布式CAP&Raft原理
为什么有分布式事务
分布式系统经常出现的异常 机器宕机、网络异常、消息丢失、消息乱序、数据错误、不可靠的 TCP、存储数据丢失...
分布式事务是企业集成中的一个技术难点,也是每一个分布式系统架构中都会涉及到的一个 东西,特别是在微服务架构中,几乎可以说是无法避免。
CAP 定理与 BASE 理论
CAP 定理
CAP 原则又称 CAP 定理,指的是在一个分布式系统中
- 一致性(Consistency):
- 在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访 问同一份最新的数据副本)
- 可用性(Availability):
- 在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据 更新具备高可用性)
- 分区容错性(Partition tolerance):
- 大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。 分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务 器放在美国,这就是两个区,它们之间可能无法通信。
CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们, 剩下的 C 和 A 无法同时做到。
raft 算法
分布式系统中实现一致性的 raft 算法、paxos算法
Raft (thesecretlivesofdata.com)
13.4BASE
面临的问题
对于多数大型互联网应用的场景,主机众多、部署分散,而且现在的集群规模越来越大,所 以节点故障、网络故障是常态,而且要保证服务可用性达到 99.99999%(N 个 9),即保证 P 和 A,舍弃 C
BASE 理论
是对 CAP 理论的延伸,思想是即使无法做到强一致性(CAP 的一致性就是强一致性),但可 以采用适当的采取弱一致性,即最终一致性。
BASE 是指
- 基本可用(Basically Available)
- 基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、 功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系 统不可用。
- 响应时间上的损失:正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的 查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询 结果的响应时间增加到了 1~2 秒
- 功能上的损失:购物网站在购物高峰(如双十一)时,为了保护系统的稳定性, 部分消费者可能会被引导到一个降级页面。
- 基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、 功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系 统不可用。
- 软状态( Soft State)
- 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布 式存储中一般一份数据会有多个副本,允许不同副本同步的延时就是软状态的体 现。mysql replication 的异步复制也是一种体现。
- 最终一致性( Eventual Consistency)
- 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状 态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。
强一致性、弱一致性、最终一致性
从客户端角度,多进程并发访问时,更新过的数据在不同进程如何获取的不同策略,决定了 不同的一致性。对于关系型数据库,要求更新过的数据能被后续的访问都能看到,这是强一 致性。如果能容忍后续的部分或者全部访问不到,则是弱一致性。如果经过一段时间后要求 能访问到更新后的数据,则是最终一致性。
13.5分布式事务常见解决方案
1.2PC 模式
数据库支持的 2PC【2 phase commit 二阶提交】,又叫做 XA Transactions。 MySQL 从 5.5 版本开始支持,SQL Server 2005 开始支持,Oracle 7 开始支持。 其中,XA 是一个两阶段提交协议,该协议分为以下两个阶段:
第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是 否可以提交.
第二阶段:事务协调器要求每个数据库提交数据。 其中,如果有任何一个数据库否决此次提交,那么所有数据库都会被要求回滚它们在此事务 中的那部分信息。
-
XA 协议比较简单,而且一旦商业数据库实现了 XA 协议,使用分布式事务的成本也比较 低。
-
XA 性能不理想,特别是在交易下单链路,往往并发量很高,XA 无法满足高并发场景
-
XA 目前在商业数据库支持的比较理想,在 mysql 数据库中支持的不太理想,mysql 的 XA 实现,没有记录 prepare 阶段日志,主备切换回导致主库与备库数据不一致。
-
许多 nosql 也没有支持 XA,这让 XA 的应用场景变得非常狭隘。
-
也有 3PC,引入了超时机制(无论协调者还是参与者,在向对方发送请求后,若长时间 未收到回应则做出相应处理)
2.柔性事务-TCC 事务补偿型
刚性事务:遵循 ACID 原则,强一致性。
柔性事务:遵循 BASE 理论,最终一致性;
与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。
一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
二阶段 commit 行为:调用 自定义 的 commit 逻辑。
三阶段 rollback 行为:调用 自定义 的 rollback 逻辑。
所谓 TCC 模式,是指支持把 自定义 的分支事务纳入到全局事务的管理
3.柔性事务-最大努力通知型方案
按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接口进行核对。这种 方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。这种 方案也是结合 MQ 进行实现,例如:通过 MQ 发送 http 请求,设置最大通知次数。达到通 知次数后即不再通知。 案例:银行通知、商户通知等(各大交易业务平台间的商户通知:多次通知、查询校对、对 账文件),支付宝的支付成功异步回调。
4.柔性事务-可靠消息+最终一致性方案(异步确保型)
实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送消息,实时消息服务只 记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确 认发送。只有在得到确认发送指令后,实时消息服务才会真正发送。
防止消息丢失:
/**
*1、做好消息确认机制(pulisher,consumer【手动 ack】)
*2、每一个发送的消息都在数据库做好记录。定期将失败的消息再次发送一 遍
*/
13.6Seata&环境准备
主要步骤:
- 导入
seata
数据库 - 部署
seata
服务 - 给所有的微服务创建
undo_log
表
官网:https://seata.apache.org/zh-cn/
服务下载地址:https://seata.apache.org/zh-cn/unversioned/release-history/seata-server
源码地址:https://github.com/apache/incubator-seata/blob/1.5.2/server/src/main/resources/application.yml
配置application.yml
导入seata
数据库
# 把sql文件拷贝到mysql的docker容器里
docker cp seata-tc.sql mysql:/seata-tc.sql
# 进入mysql容器
docker exec -it mysql bash
# 连接mysql
mysql -uroot -p
# 执行sql文件
source /seata-tc.sql
# 执行完成的sql文件可以删除
rm -f seata-tc.sql
部署seata
如果下载太慢,可以使用docker load -i
从压缩包加载 Docker 镜像
# 加载镜像
docker load -i seata-1.5.2.tar
我是使用docekrcompose
部署的
seata:
image: seataio/seata-server:1.5.2
container_name: seata
privileged: true # 设置容器的特权模式为 true
environment:
SEATA_IP: 192.168.188.180
ports:
- "8099:8099" # 映射主机的7091端口到容器的7091端口
- "7099:7099" # 映射主机的8091端口到容器的8091端口
depends_on:
- mysql # 启动顺序,先启动 mysql 服务
- nacos # 启动顺序,先启动 nacos 服务
volumes:
- ./seata:/seata-server/resources # 挂载本地 seata 目录到容器的 /seata-server/resources
networks:
- mall-net # 指定连接的网络
restart: always
访问http://192.168.188.180:7099/
用户名密码是application.yml
配置的
给所有的微服务创建undo_log
表
13.7Seata分布式事务体验
主要步骤:
- 订单服务
gulimall-order
、购物车服务gulimall-cart
、商品服务gulimall-product
、库存服务gulimall-ware
都要导入seata
,并且添加seata
配置 - 测试下单服务
订单服务gulimall-order
、购物车服务gulimall-cart
、商品服务gulimall-product
、库存服务gulimall-ware
都要导入seata
,并且添加seata
配置
导入seata
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
添加seata
配置
seata:
# data-source-proxy-mode: XA
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 192.168.188.180:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-server # seata服务名称
username: nacos
password: nacos
tx-service-group: peng # 事务组名称
service:
vgroup-mapping: # 事务组与tc集群的映射关系
peng: "default"
然后运行项目,seata
日志显示gulimall-order
、gulimall-product
、gulimall-ware
、gulimall-cart
加入成功
再次添加购物车,我选了一个没有库存的商品,然后提交订单
没有订单生成,也没有锁定库存,分布式事务回滚成功
13.8最终一致性库存解锁逻辑
seata
分布式事务不适合高并发场景
也不考虑2PC
模式和TCC
模式
建议最大努力通知和可靠消息+最终一致性方案
14.商城业务-订单服务
14.1RabbitMQ延时队列
RabbitMQ延时队列(实现定时任务)
场景: 比如未付款订单,超过一定时间后,系统自动取消订单并释放占有物品。
常用解决方案: spring的 schedule
缺点: 消耗系统内存、增加了数据库的压力、存在较大的时间误差
解决:rabbitmq的消息TTL和死信Exchange结合
消息的TTL(Time To Live)
-
消息的TTL就是消息的存活时间。
-
RabbitMQ可以对队列和消息分别设置TTL。
- 对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的 设置。超过了这个时间,我们认为这个消息就死了,称之为死信。
- 如果队列设置了,消息也设置了,那么会取小的。所以一个消息如果被路由到不同的队 列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的 TTL,因为它才是实现延迟任务的关键。可以通过设置消息的expiration字段或者x- message-ttl属性来设置时间,两者是一样的效果。
Dead Letter Exchanges(DLX)
- 一个消息在满足如下条件下,会进死信路由,记住这里是路由而不是队列, 一个路由可以对应很多队列。(什么是死信)
- 一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不 会被再次放在队列里,被其他消费者使用。(basic.reject/ basic.nack)requeue=false
- 上面的消息的TTL到了,消息过期了。
- 队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上
- Dead Letter Exchange其实就是一种普通的exchange,和创建其他 exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有 消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。
- 我们既可以控制消息在一段时间后变成死信,又可以控制变成死信的消息 被路由到某一个指定的交换机,结合二者,其实就可以实现一个延时队列
- 手动ack&异常消息统一放在一个队列处理建议的两种方式
- catch异常后,手动发送到指定队列,然后使用channel给rabbitmq确认消息已消费
- 给Queue绑定死信队列,使用nack(requque为false)确认消息消费失败
延时队列实现-1
延时队列实现-2
14.2延时队列定时关单模拟
主要步骤:
- 创建交换机
order-event-exchange
- 创建延时队列
order.delay.queue
,arguments.put("x-dead-letter-exchange", "order-event-exchange");
:绑定死信交换机arguments.put("x-dead-letter-routing-key", "order.release.order");
:设置死信路由routing-key
arguments.put("x-message-ttl", 20000)
:设置过期时间
- 创建普通队列
order.release.order.queue
- 两个队列都绑定
order-event-exchange
,当消息过期带上routing-key=order.release.order
转发给order-event-exchange
,最后转发给order.release.order.queue
第一种方式
设置消息过期时间,1分钟后转发给user.order.exchange
,客户端监听user.order.exchange
进行消费
第二种方式
生产者生产消息设置消息过期时间和routingkey=order.create.order
转发给order-event-exchange
,1分钟后带上routingkey=order.release.order
转发给order-event-exchange
,order-event-exchange
根据路由转发给order.release.order.queue
,客户端监听order.release.order.queue
进行消费
SpringBoot中使用延时队列
使用Bean
创建交换机、队列,我这里设置的过期时间是20s
创建绑定关系
监听order.release.order.queue
队列
@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);
}
模拟创建订单的消息
@ResponseBody
@GetMapping("/test/createorder")
public String createOrderTest() {
// 订单下单成功
OrderEntity entity = new OrderEntity();
entity.setOrderSn(UUID.randomUUID().toString());
entity.setModifyTime(new Date());
// 给MQ发送消息。
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", entity);
return "ok";
}
发送创建订单请求
http://order.gulimall.com/test/createorder
查看控制台,发现20s后order.delay.queue
收到消息并消费
14.3创建业务交换机&队列
主要步骤:
- 创建交换机
stock-event-exchange
- 创建延时队列
stock.delay.queue
arguments.put("x-dead-letter-exchange", "stock-event-exchange");
:绑定死信交换机arguments.put("x-dead-letter-routing-key", "stock.release");
:设置死信路由routing-key
arguments.put("x-message-ttl", 120000);
:设置过期时间2分钟
- 创建普通队列
stock.release.stock.queue
- 两个队列都绑定
stock-event-exchange
,当消息过期带上routing-key=stock.release
转发给stock-event-exchange
,最后转发给stock.release.stock.queue
14.4监听库存解锁
库存解锁场景
这里主要解决第二种场景
- 1.下订单成功,订单过期没有支付被系统自动取消、被用户手动取消。都要解锁库存
- 2.下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。
- 之前锁定的库存就要自动解锁。
主要步骤:
- 数据库
mall_wms.wms_ware_order_task_detail
添加字段仓库idware_id
、锁定状态lock_status
- 如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
- 锁定失败。前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所有就不用解锁
- 监听
stock.release.stock.queue
,成功解锁发送ack
,不成功就reject
,消息重新放回队列,让别人消费
数据库mall_wms.wms_ware_order_task_detail
添加字段仓库idwareId
、锁定状态lockStatus
如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
监听stock.release.stock.queue
,成功解锁发送ack
,不成功就reject
,消息重新放回队列,让别人消费
14.5库存解锁逻辑
主要步骤:
- 远程调用订单服务
gulimall-order
根据orderSn
查询订单信息 - 解锁库存
- 订单不存在
- 订单已关闭
- 库存工作单已锁定
解锁
14.6库存自动解锁完成
主要步骤:
-
gulimall-order
拦截器调用/order/order/status/**
时不需要进行登录验证 -
优化解锁库存代码,单独创建监听类
StockReleaseListener
gulimall-order
拦截器调用/order/order/status/**
时不需要进行登录验证
优化解锁库存代码,单独创建监听类StockReleaseListener
14.7测试库存自动解锁
清除相关表数据
truncate table mall_oms.oms_order;
truncate table mall_oms.oms_order_item;
truncate table mall_wms.wms_ware_order_task;
truncate table mall_wms.wms_ware_order_task_detail;
主要步骤:
- 1.创建订单
submitOrder
时,在调用gulimall-ware
的方法orderLockStock
锁定库存成功后,模拟异常,这样创建订单就会失败,创建订单会回滚(oms_order
、oms_order_item
),但是库存已经成功扣除 - 2.
gulimall-ware
的方法orderLockStock
锁定库存方法成功时向mq
延时队列stock.delay.queue
添加消息,并添加溯源数据(wms_ware_order_task
、wms_ware_order_task_detail
),消息会在20s(设置的过期时间)时转发给普通队列stock.release.stock.queue
- 3.监听普通队列
stock.release.stock.queue
,根据orderSn
获取订单状态,在订单状态已关闭或者订单不存在和当前库存工作单详情状态已锁定(1),可以进行解锁,解锁完成更新库存工作单详情状态已解锁
创建订单submitOrder
时,在调用gulimall-ware
的方法orderLockStock
锁定库存成功后,模拟异常,这样创建订单就会失败,创建订单会回滚(oms_order
、oms_order_item
),但是库存已经成功扣除
gulimall-ware
的方法orderLockStock
锁定库存方法成功时向mq
延时队列stock.delay.queue
添加消息,并添加溯源数据(wms_ware_order_task
、wms_ware_order_task_detail
),消息会在20s(设置的过期时间)时转发给普通队列stock.release.stock.queue
监听普通队列stock.release.stock.queue
,根据orderSn
获取订单状态,在订单状态已关闭或者订单不存在和当前库存工作单详情状态已锁定(1),可以进行解锁
解锁完成更新库存工作单详情状态已解锁
14.8定时关单完成
主要步骤:
-
order-event-exchange
交换机和stock.release.stock.queue
队列建立绑定关系,关闭订单后向stock.release.stock.queue
发送消息解锁库存 -
gulimall-order
创建订单submitOrder
完成时向延时队列order.delay.queue
发送消息,20s到期转发给普通队列order.delay.queue
,模拟20s后关闭订单 -
gulimall-ware
监听关闭订单后发送的消息,然后解锁库存,这是主动解锁 -
gulimall-ware
解锁库存时发送的延时消息是被动解锁
order-event-exchange
交换机和stock.release.stock.queue
队列建立绑定关系,关闭订单后向stock.release.stock.queue
发送消息解锁库存
gulimall-order
创建订单submitOrder
完成时向延时队列order.delay.queue
发送消息,30s到期转发给普通队列order.delay.queue
,模拟30s后关闭订单
gulimall-ware
监听关闭订单后发送的消息,然后解锁库存,这是主动解锁
gulimall-ware
解锁库存时发送的延时消息是被动解锁
创建订单30s收到延时队列的消息关闭订单
关闭订单后向普通队列stock.release.stock.queue
发送消息,gulimall-ware
进行主动解锁
锁定库存时向延时队列stock.delay.queue
发送消息,并设定过期时间30s,主动解锁后再进行被动解锁
主动解锁是创建订单后20s,被动解锁时锁定库存后30s
14.9消息丢失、积压、重复等解决方案
消息丢失
- 消息发送出去,由于网络问题没有抵达服务器
- 做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机 制,可记录到数据库,采用定期扫描重发的方式
- 做好日志记录,每个消息状态是否都被服务器收到都应该记录
- 做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进 行重发
- 消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚 未持久化完成,宕机。
- publisher也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。
- 自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机
- 一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重 新入队
消息重复
- 消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息 重新由unack变为ready,并发送给其他消费者
- 消息消费失败,由于重试机制,自动又将消息发送出去
- 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送
- 消费者的业务消费接口应该设计为幂等性的。比如扣库存有 工作单的状态标志
- 使用防重表(redis/mysql),发送消息每一个都有业务的唯 一标识,处理过就不用处理
- rabbitMQ的每一个消息都有redelivered字段,可以获取是否 是被重新投递过来的,而不是第一次投递过来的
没有解锁的库存才进行解锁,保证方法幂等性
防重表
CREATE TABLE `mq_message` (
`message_id` char(32) NOT NULL,
`content` json,
`to_exchane` 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;
rabbitMQ的每一个消息都有redelivered字段,可以获取是否 是被重新投递过来的,而不是第一次投递过来的
//当前消息是否被第二次及以后(重新)派发过来了
Boolean redelivered = message.getMessageProperties().getRedelivered();
消息积压
- 消费者宕机积压
- 消费者消费能力不足积压
- 发送者发送流量太大
- 上线更多的消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理
15.商城业务-支付
15.1支付宝沙箱&代码
开发者文档 https://openhome.alipay.com/docCenter/docCenter.htm
全部文档=>电脑网站支付文档;下载 demo https://opendocs.alipay.com/open/270/106291/
15.2RSA、加密加签、密钥等
资料地址
沙箱环境:https://openhome.alipay.com/develop/sandbox/account
demo下载地址:https://opendocs.alipay.com/open/270/106291/
密钥工具下载地址:https://opendocs.alipay.com/common/02kipk?pathHash=0d20b438
idea运行demo,参考https://blog.csdn.net/lovoo/article/details/118770354
买家账号
kalnhn3954@sandbox.com
登录密码
111111
公钥、私钥、加密、签名和验签
公钥私钥
- 公钥和私钥是一个相对概念
- 它们的公私性是相对于生成者来说的。
- 一对密钥生成后,保存在生成者手里的就是私钥,
- 生成者发布出去大家用的就是公钥
加密和数字签名
加密
- 我们使用一对公私钥中的一个密钥来对数据进行加密,而使用另一个密钥来进行解 密的技术。
- 公钥和私钥都可以用来加密,也都可以用来解密。
- 但这个加解密必须是一对密钥之间的互相加解密,否则不能成功。
- 加密的目的是: 为了确保数据传输过程中的不可读性,就是不想让别人看到。
签名
- 给我们将要发送的数据,做上一个唯一签名(类似于指纹)
- 用来互相验证接收方和发送方的身份
- 在验证身份的基础上再验证一下传递的数据是否被篡改过。因此使用数字签名可以 用来达到数据的明文传输。
验签
- 支付宝为了验证请求的数据是否商户本人发的
- 商户为了验证响应的数据是否支付宝发的
下载demo并打开
下载demo
然后解压
使用idea打开
选择Eclipse,剩下一路下一步即可
移除红色模块
在Facet
设置web.xml
在工件添加war
包
配置tomcat
地址和端口
配置tomcat
访问路径
下载支付宝开放平台密钥工具
安装路径记得不要有空格
配置应用和支付宝的公钥、私钥、APPID
,运行demo
生成密钥
在沙盒应用中选择自定义密钥,然后点击查看
请复制“应用公钥”至支付宝开放平台,进而获取支付宝公钥
在项目中配置应用私钥
把沙盒里支付公钥配置到项目中
配置APPID
运行项目
点击付款,跳转登录界面,输入沙盒账号里的买家信息账号、密码和支付密码
支付成功
15.3内网穿透
内网穿透工具有很多,我这里使用的是OpenFrp
首次注册需要实名认证,可能话费1-2元
地址:https://console.openfrp.net/helpcenter
下载客户端并安装
首先创建一个隧道,本地端口就是你demo运行端口,然后随机生成远程端口即可
创建完成后,打开客户端,开启隧道,并复制域名用于访问
再次访问支付demo,能正常运行即可
16.商城业务-订单服务
16.1整合支付前需要注意的问题
保证所有项目编码都是utf-8
16.2整合支付
主要步骤:
- 导入支付宝
SDK
,application.yaml
添加支付配置 - 封装支付宝支付帮助类
- 根据
orderSn
查询订单,并把订单数据传入支付功能 - 前端请求支付宝支付接口
导入支付宝SDK
,application.yaml
添加支付配置
封装支付宝支付帮助类
根据orderSn
查询订单,并把订单数据传入支付功能
前端请求支付宝支付接口
16.3支付成功同步回调
主要步骤:
- 修改
gulimall-order
支付成功页面跳转链接,跳转到会员服务gulimall-member
,显示订单列表 gulimall-member
导入SpringSession
依赖,application.yaml
添加thymeleaf
、redis
、session
的配置gulimall-member
添加SpringSession
配置gulimall-member
添加登录拦截器,放行OpenFiegn
远程调用接口- 将订单页静态资源上传
nginx
- 将
index.html
拷贝到``gulimall-member的
src/main/resources/templates目录下,并改名
orderList.html,修改
orderList.html页面静态资源地址,添加
thymeleaf`命名空间 - 管理员启动
SwitchHosts
,添加gulimall-member
的域名映射 gulimall-gateway
网关服务添加gulimall-member
会员服务的网关地址gulimall-member
添加MemberWebController
显示orderList.html
修改gulimall-order
支付成功页面跳转链接,跳转到会员服务gulimall-member
,显示订单列表
支付完成跳转到订单列表页
gulimall-member
导入SpringSession
依赖,application.yaml
添加thymeleaf
、redis
、session
的配置
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
gulimall-member
添加SpringSession
配置
gulimall-member
添加登录拦截器,放行OpenFiegn
远程调用接口
将订单页静态资源上传nginx
将index.html
拷贝到``gulimall-member的
src/main/resources/templates目录下,并改名
orderList.html,修改
orderList.html页面静态资源地址,添加
thymeleaf`命名空间
href="
href="/static/member/
src="
src="/static/member/
管理员启动SwitchHosts
,添加gulimall-member
的域名映射
192.168.188.180 member.gulimall.com
gulimall-gateway
网关服务添加gulimall-member
会员服务的网关地址
- id: gulimall-member_route
uri: lb://gulimall-member
predicates:
- Host=member.gulimall.com
gulimall-member
添加MemberWebController
显示orderList.html
@GetMapping(value = "/orderList.html")
public String memberOrderPage(@RequestParam(value = "pageNum",required = false,defaultValue = "0") Integer pageNum,
Model model, HttpServletRequest request) {
return "orderList";
}
16.4订单列表页渲染完成
主要步骤:
gulimall-member
添加OpenFeign
配置gulimall-member
添加gulimall-order
的远程调用,获取用户所有订单列表gulimall-order
实现queryPageWithItem
分页获取用户所有订单列表
gulimall-member
添加OpenFeign
配置
gulimall-member
添加gulimall-order
的远程调用,获取用户所有订单列表
gulimall-order
实现queryPageWithItem
分页获取用户所有订单列表
@Override
public PageUtils queryPageWithItem(Map<String, Object> params) {
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
IPage<OrderEntity> page = this.page(
new Query<OrderEntity>().getPage(params),
new QueryWrapper<OrderEntity>() .eq("member_id",memberResponseVo.getId()).orderByDesc("create_time")
);
//遍历所有订单集合
List<OrderEntity> orderEntityList = page.getRecords().stream().map(order -> {
//根据订单号查询订单项里的数据
List<OrderItemEntity> orderItemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>()
.eq("order_sn", order.getOrderSn()));
order.setOrderItemEntityList(orderItemEntities);
return order;
}).collect(Collectors.toList());
page.setRecords(orderEntityList);
return new PageUtils(page);
}
16.5异步通知内网穿透环境搭建
支付宝异步通知
支付宝异步通知文档:https://opendocs.alipay.com/open/270/105902/
使用nps作内网穿透,无法使用域名必须使用IP:PORT,所以会造成nginx无法根据访问的域名gulimall.com来匹配请求
解决:修改nginx
配置文件gulimall.conf
监听server_name 124.223.7.41(这是自己的远程地址)
添加以上域名监听后,访问124.223.7.41:8888(这是自己的远程地址))出现404异常
原因:
- 网关88未拦截到请求
解决:
- 方案一:在网关增加拦截规则,拦截124.223.7.41,将请求发送到order.gulimall.com
- 方案二:在nginx转发时,设置host=order.gulimall.com,使网关可以正确拦截【推荐】
- 方案三:内网穿透的地址直接配成192.168.56.1:9000【缺点:没有负载均衡了】
bug1:
修改gulimall.conf
server {
listen 80;
server_name gulimall.com *.gulimall.com 124.223.7.41;
location /static/ {
root /usr/share/nginx/html;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
bug2:
方案一:
- id: gulimall_order_route2
uri: lb://gulimall-order
predicates:
- Host=124.223.7.41
方案二:
修改gulimall.conf
server {
listen 80;
server_name gulimall.com *.gulimall.com 124.223.7.41;
location /static/ {
root /usr/share/nginx/html;
}
location /payed/ {
proxy_set_header Host order.gulimall.com;
proxy_pass http://gulimall;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
主要步骤:
- 配置本地链接为自己的本地服务虚拟接
ip
,远程端口可随机 - 修改
nginx
配置文件gulimall.conf
监听server_name
111.199.237.178(自己的远程内网穿透地址) - 在
nginx
转发时,设置host=order.gulimall.com
,使网关可以正确拦截【推荐】 gulimall-order
配置支付成功异步回调地址gulimall-order
登录拦截放行支付宝的异步回调通知gulimall-order
添加支付宝的异步回调通知接口
配置本地链接为自己的本地服务虚拟接ip
,远程端口可随机
修改nginx
配置文件gulimall.conf
监听server_name
111.199.237.178(自己的远程内网穿透地址)
在nginx
转发时,设置host=order.gulimall.com
,使网关可以正确拦截【推荐】
gulimall-order
配置支付成功异步回调地址
boolean match = antPathMatcher.match("/order/order/status/**", uri);
boolean match1 = antPathMatcher.match("/payed/notify", uri);
if (match || match1) {
return true;
}
gulimall-order
登录拦截放行支付宝的异步回调通知
@RestController
public class OrderPayedListener {
@PostMapping("/payed/notify")
public String handleAlipayed(HttpServletRequest request) {
// 只要我们收到了支付宝给我们异步的通知,告诉我们订单支付成功。返回success,支付宝就再也不通知
Map<String, String[]> requestParams = request.getParameterMap();
System.out.println("支付宝通知到位了..数据:" + requestParams);
return "success";
}
}
gulimall-order
添加支付宝的异步回调通知接口
测试
16.6支付完成
主要步骤:
- 支付宝验签
- 处理支付宝的支付结果
- 保存交易流水
- 更新支付状态
- 配置
spring.mvc
日期格式化
支付宝验签
@PostMapping(value = "/payed/notify")
public String handleAlipayed(PayAsyncVo asyncVo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
// 只要收到支付宝的异步通知,返回 success 支付宝便不再通知
// 获取支付宝POST过来反馈信息
//TODO 需要验签
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (String name : requestParams.keySet()) {
String[] values = 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(asyncVo);
return result;
} else {
System.out.println("签名验证失败...");
return "error";
}
}
处理支付宝的支付结果
- 保存交易流水
- 更新支付状态
/**
* 处理支付宝的支付结果
* @param asyncVo
* @return
*/
@Override
public String handlePayResult(PayAsyncVo asyncVo) {
//保存交易流水信息
PaymentInfoEntity paymentInfo = new PaymentInfoEntity();
paymentInfo.setOrderSn(asyncVo.getOut_trade_no());
paymentInfo.setAlipayTradeNo(asyncVo.getTrade_no());
paymentInfo.setTotalAmount(new BigDecimal(asyncVo.getBuyer_pay_amount()));
paymentInfo.setSubject(asyncVo.getBody());
paymentInfo.setPaymentStatus(asyncVo.getTrade_status());
paymentInfo.setCreateTime(new Date());
paymentInfo.setCallbackTime(asyncVo.getNotify_time());
//添加到数据库中
this.paymentInfoService.save(paymentInfo);
//修改订单状态
//获取当前状态
String tradeStatus = asyncVo.getTrade_status();
if (tradeStatus.equals("TRADE_SUCCESS") || tradeStatus.equals("TRADE_FINISHED")) {
//支付成功状态
String orderSn = asyncVo.getOut_trade_no(); //获取订单号
this.updateOrderStatus(orderSn,OrderStatusEnum.PAYED.getCode(), PayConstant.ALIPAY);
}
return "success";
}
/**
* 修改订单状态
* @param orderSn
* @param code
*/
private void updateOrderStatus(String orderSn, Integer code,Integer payType) {
this.baseMapper.updateOrderStatus(orderSn,code,payType);
}
mall_oms.oms_payment_info
的order_sn
字段改为char(64)
配置spring.mvc
日期格式化
spring:
mvc:
format:
date: yyyy-MM-dd HH:mm:ss
16.7收单
支付宝支付接口
https://opendocs.alipay.com/open/cd12c885_alipay.trade.app.pay?pathHash=ab686e33&ref=api&scene=20
主要步骤:
- 订单超时,不允许支付
- 解决:支付时设置超时时间:应该设置订单绝对超时时间,而不是30m,按照创建订单+30m来算截止时间
time_expire
- 解决:支付时设置超时时间:应该设置订单绝对超时时间,而不是30m,按照创建订单+30m来算截止时间
- 订单解锁完成,异步通知才到
- 解决:释放库存的时候,手动调用收单功能(参照官方demo的实现)
- 对账:每晚启动定时任务和请求支付包接口进行对账
订单超时,不允许支付
订单解锁完成,异步通知才到
关闭交易:https://opendocs.alipay.com/open/ce0b4954_alipay.trade.close?pathHash=7b0fdae1&ref=api&scene=common
对账:每晚启动定时任务和请求支付包接口进行对账
https://opendocs.alipay.com/open/82ea786a_alipay.trade.query?pathHash=0745ecea&ref=api&scene=23
创作不易,感谢支持。