实现场景
在分布式微服务场景下,实现用户提交订单的最后一步 submitOrder() 方法,首先需要将 order 实体保存到订单服务对应的数据库下,然后需要去仓储服务下锁定(预留)库存,锁定库存后还可能调用其他服务做其他事,又或者在当前订单服务下做其他事。
预留库存的原因是客户并不是立即支付的,往往会有预留一定的时间。下文的场景中,用户可以有 30 分钟的时间来支付。
图示:
使用 @Transactional 注解声明以上所有方法是事务后
可能出现的问题
考虑以下场景:
- saveOrder() 方法出现异常
- lockStore() 方法出现异常
- doSomething() 方法出现异常
结果是:
- 执行 saveOrder() 时抛出异常,submitOrder() 方法事务回滚,后面的方法不执行,无事发生。
- 执行 lockStore() 时抛出异常,该方法事务回滚,submitOrder() 方法事务回滚,后面的方法不执行,无事发生。
- 执行 doSomething() 时抛出异常,该方法事务回滚,submitOrder() 方法事务回滚,lockStore() 方法不会回滚。导致的结果是,订单没有生成,锁了库存。多来几次可能导致,一个订单没有,货没了。
- 执行 lockStore() 时正常,库存被锁,返回数据时远程调用超时,submitOrder() 方法事务回滚。导致的结果同上。
CAP 定理
CAP原则又称CAP定理,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
P 相当重要,系统需要在部分出现问题的情况继续运作,要想保持数据一致性,那么部分出现问题的数据库就不能去访问;要保持所有数据库可用,就可能出现数据不一致的情况。
BASE 定理
BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent(最终一致性)三个短语的简写。
BASE 是对 CAP 中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的结论,是基于 CAP 定理逐步演化而来的,其核心思想是即使无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual
consistency)。
Raft 算法
解决方案——Seata
点进去映入眼帘的全局锁,可以使用 Seata 实现数据的强一致性。但是数据库性能严重下降。
解决方案——延时队列
使用 RabbitMQ 的死信机制实现延时队列,用延时队列实现数据的弱一致性。
再次声明,以上问题的根源问题是订单存在的情况下,才有必要锁库存。
死信(Dead Letter,DL): 顾名思义,死掉的消息
死信的产生方式:
- 消息被拒,且没有重新入队
- 消息过期,超过了设置的 ttl 时间
- 队列中消息数量超过最大数目
死信交换机(Dead Letter Exchanges,DLX): 用来路由死信的交换机
死信路由键(Dead Letter Routing-Key,DLK): 用来路由死信的路由键
可以用以下思路实现延时队列:生产者向交换机发送消息,消息根据路由键来到 A 队列(实现延迟的队列),该队列设置一个 TTL,设置死信交换机和死信路由键,并不设置任何消费者监听。
经过 TTL 之后,原来 A 队列中的消息成为死信,来到死信交换机,经过死信路由键来到 B 队列,让消费者监听 B 队列,此时消费者收到的消息即是延迟了 TTL 的消息。
在当前场景下,在仓储服务中,锁库之后向 MQ 中发送一个消息,消息包括的内容是自己锁了哪个订单的库存信息,经过一定的延迟时间,仓储服务再收到该消息,去订单服务中查询该订单到底是否真的存在,有没有被支付或者被放弃(此功能可结合用户支付或者取消订单)。
如果真的存在,消息消费完成;如果不存在,则要根据该消息,把仓储服务下的数据库恢复原样。
延时队列方案具体实现
/**
* @author: lvshui5u
* @date: 2021/8/16 10:27
* @describe:
*/
@Configuration
public class MyMQConfig {
@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");
// ddl,用来实现延迟
arguments.put("x-message-ttl",120000);
Queue queue = new Queue(
"stock.delay.queue",
true,
false,
false, arguments);
return queue;
}
@Bean
public Queue stockReleaseStockQueue(){
Queue queue = new Queue(
"stock.release.stock.queue",
true,
false,
false);
return queue;
}
@Bean
public Exchange orderEventExchange(){
TopicExchange exchange = new TopicExchange(
"stock-event-exchange",
true,
false);
return exchange;
}
@Bean
public Binding stockLockedBinding(){
Binding binding = new Binding(
"stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.locked",
null);
return binding;
}
@Bean
public Binding stockReleaseStockBinding(){
Binding binding = new Binding(
"stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.release.#",
null);
return binding;
}
}
以上代码用来在服务启动时创建 Exchange、Queue、 Binding。
以上为实现场景
消费者监听到锁库相关信息后,会拿到订单号,可以根据订单号去订单服务中查询是否真的有这个订单(目前未考虑支付相关问题)。
- 有该订单,保留对库存的修改,向消息队列 channel 发送 ack 回复。
- 没有该订单,还原数据库,向消息队列 channel 发送 ack 回复。
- 去订单服务查询这个远程方法调用失败,将该消息拒绝并送回队列。
使用消息队列的相关问题
消息队列可能出现的问题:
- 消息丢失
- 消息积压
- 消息重复
消息丢失:
消息丢失可以发生在以下情况:
- 生产者向 MQ 服务器中投递消息时丢失 publisher->broker
- 消息到达 MQ 服务器的交换机,交换机找不到正确的路由键进行路由 exchange->queue
- 消息到达正确的队列,消费者没有监听到 queue->consumer
根据以上几种情况,分别有以下对应方案:
- 开启 publisher (MQ 服务器)端的 confirmCallback 机制
- 开启 publisher (MQ 服务器)端的 returnCallback 机制
- 开启 consumer 端的 手动 ack/reject 机制
消息积压: 消费者消费的能力远低于生产者的生产能力,造成消息积压在消息队列里。
解决方案:
- 上线更多的消费者
- 将消息持久化,之后慢慢处理
消息重复: 保证接口的幂等性,处理消息时采用“乐观锁” 的思维模式,可以维持一个状态量表示是否已经被处理过。
如需详细代码实现请留言