文章目录
- Transactional outbox pattern
- 事件驱动架构(Event Driven Architecture, EDA)
- 数据库事务和消息发布的一致性问题
- Transactional outbox如何解决数据事务和消息发布之间的一致性问题
- 如何实现Transactional outbox pattern
- 消息幂等性问题
- Transactional outbox pattern能保证最终一致性吗?
- 消息的可靠性
- Producer的可靠性
- Broker的可靠性
- Consumer的可靠性
- Transactional outbox pattern能保证消息顺序性吗?
- 队列投递顺序 (Broker Delivery Order)
- 业务事件顺序 (Business Event Order)
- 我们真的需要引入Transactional outbox pattern吗?
- 参考和学习资料
Transactional outbox pattern
事件驱动架构(Event Driven Architecture, EDA)
许多现代应用都采用了事件驱动设计。因为事件驱动架构可以最大程度减少耦合度,因此是现代化分布式应用架构的理想之选。
与传统的请求/响应驱动模型(同步)不同的是,事件驱动架构耦合性更低,因为事件发起者并不知道哪个事件使用者在监听事件,而且事件也不知道其所产生的后续结果。
上图来源于:What is microservices architecture?
一个好的微服务应该是一个松耦合的服务,应该尽可能少地知道与之协作的那些服务的信息,所以在微服务开发中,我们常常会使用事件驱动架构来帮我们打造一个更好的松耦合的服务。
数据库事务和消息发布的一致性问题
当我们需要发送消息并提交一些数据库更改时,存在一个难题,我们应该在事务中还是在事务之后发送消息?
通常的业务处理过程中我们都会更新数据库(包括create,update,delete)然后发送message/event 给 message broker(消息中间件)。我们需要保证数据库更新和事件发布之间的原子性,也即要么二者都成功,要么都失败
下图在事务中发送消息。 这会有问题,如果步骤5事务commit失败,发生回滚;但此时消息已经发送出去,就有不一致的情况。
下图在事务commit之后发送消息。 这也存在问题,如果步骤4的发送失败,则下游系统将永远不会收到该消息。
在传统的实践方式中,Global Transaction(全局事务)和 XA Transaction(分布式事务)通常用于解决此类问题,它们都是利用 2PC(两阶段提交)协议来保证事务一致性,通过协调者来确保多个参与者之间的事务保持一致性。然而,这种事务本身的效率是很低的,另外,一些技术框架并不提供对全局事务的支持。
Transactional outbox如何解决数据事务和消息发布之间的一致性问题
为了解决这个问题,ebay 架构师 Dan Pritchett 在 2008 年发表的一篇论文: Base: An Acid Alternative中描述了 outbox模式。 在这种模式下,我们有一个outbox表来保存要发送的消息。 该消息与数据库事务一起提交,这确保了该消息与业务数据原子地持久化。 然后异步作业将扫描待处理的消息,并将其发布到Broker,如果成功,则将消息更新为“已发送”,并在以后进行归档。
我们来看看Transactional outbox是如何解决数据库更新和事件发布之间的原子性问题。
下图来源于:Pattern: Transactional outbox
大致的流程如下:
- Order Service接受用户请求;
- 在同一个事务下处理用户请求;
- 写入ORDER table业务表;
- 写入OUTBOX table事件表,ORDER table和OUTBOX table更新在同一个本地数据库事务中;
- 事务完成后,需要去触发事件的发送(比如可以通过Spring AOP的方式完成,也可以定时扫描事件表,还可以借助诸如MySQL的binlog之类的机制比如Change data capture (CDC)的方式);
- 后台任务读取OUTBOX table事件表;
- 后台任务发送事件到消息队列;
- 发送成功后删除事件。
通过在数据库中创建 Outbox 表,将要发送的消息记录在 Outbox 表中,然后在事务提交之后,异步地读取 Outbox 表中的消息并将其发布到消息队列中。并且通过定时任务的方式去拉取未成功发送的消息去发送,来确保最终事务和消息发布的原子性。
如何实现Transactional outbox pattern
下面是我对于Transactional outbox pattern提供的一些思路和伪代码:
首先我们需要定义出outbox table,outbox table中我们需要定义几个重要的字段:channel(retry的时候可以知道要往哪个channel发送),mssageStatus(标记当前message是发送成功还是失败),messagePayload(保存当前发送的消息实体,以便retry的时候进行发送),retryTimes(记录retry次数,用于控制最大的retry次数)。其他的字段可以根据自己的业务要求进行定制。
@Data
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "outbox_table")
public class OutboxEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String channel;
private String messageBody;
private String messageStatus;
private Integer retryTimes;
//more...
}
这里我是用spring-cloud-stream-3.2.4版本为例,写一些简单的伪代码
@Slf4j
@RequiredArgsConstructor
@Component
public class MessageSender {
private final StreamBridge streamBridge;
private final OutboxRepository outboxRepository;
private final ApplicationContext applicationContext;
public <T extends BaseEvent> void sendMessage(String bindingChannel, T event) {
//Make sure it's in the same transaction
OutboxEntity outbox = outboxRepository.save(OutboxEntity.builder()
.channel(bindingChannel)
.messageStatus("Ready To Send")
.messageBody(JsonUtils.toJson(event))
.retryTimes(0).build());
applicationContext.publishEvent(outbox);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendMessageAfterCommit(OutboxEntity outbox) {
boolean isSendSuccess = streamBridge.send(outbox.getChannel(), MessageBuilder.withPayload(outbox.getMessageBody()).build());
if (isSendSuccess) {
outbox.setMessageStatus("Sent");
outboxRepository.save(outbox);
}
}
}
streamBridge.send
方法里面的实现是同步的,在方法里面最后会调用messageChannel.send
方法
public boolean send(String bindingName, @Nullable String binderName, Object data, MimeType outputContentType) {
ProducerProperties producerProperties = this.bindingServiceProperties.getProducerProperties(bindingName);
MessageChannel messageChannel = this.resolveDestination(bindingName, producerProperties, binderName);
Function functionToInvoke = this.getStreamBridgeFunction(outputContentType.toString(), producerProperties);
if (producerProperties != null && producerProperties.isPartitioned()) {
functionToInvoke = new PartitionAwareFunctionWrapper(functionToInvoke, this.applicationContext, producerProperties);
}
String targetType = this.resolveBinderTargetType(bindingName, binderName, MessageChannel.class,
this.applicationContext.getBean(BinderFactory.class));
Message<?> messageToSend = data instanceof Message
? MessageBuilder.fromMessage((Message) data).setHeaderIfAbsent(MessageUtils.TARGET_PROTOCOL, targetType).build()
: new GenericMessage<>(data, Collections.singletonMap(MessageUtils.TARGET_PROTOCOL, targetType));
Message<?> resultMessage;
synchronized (this) {
resultMessage = (Message<byte[]>) functionToInvoke.apply(messageToSend);
}
resultMessage = (Message<?>) this.functionInvocationHelper.postProcessResult(resultMessage, null);
return messageChannel.send(resultMessage);
}
我们从MessageChannel的send方法注释可以看出来,返回值代表该消息是否已经发送出去了。所以我们利用该返回值来判断是否需要修改messageStatus的状态。
/**
* Send a {@link Message} to this channel. If the message is sent successfully,
* the method returns {@code true}. If the message cannot be sent due to a
* non-fatal reason, the method returns {@code false}. The method may also
* throw a RuntimeException in case of non-recoverable errors.
* <p>This method may block indefinitely, depending on the implementation.
* To provide a maximum wait time, use {@link #send(Message, long)}.
* @param message the message to send
* @return whether the message was sent
*/
default boolean send(Message<?> message) {
return send(message, INDEFINITE_TIMEOUT);
}
接下来就是需要设置job定时去触发未发送成功的记录,为了防止服务的多个实例同时运行job而产生竞争或破坏消息的排序,我们这里使用另一个独立的微服务整合quartz实现分布式调度,然后去触发其他微服务去扫描outbox表并进行消息的发布。而且还有一点很重要,我们要注意poison message的处理,如果一个消息在扫出来之后多次处理都失败,那么你需要把它报告给support,不要让它阻碍其他正常消息的处理。所以我们这里会加入retryTimes字段,在每次触发之后都会累加,当达到最大的重试次数之后,这条数据就不会再被扫出来了。最后我们就可以把这些已经发送成功的outbox记录进行归档了。
还有一点很重要,就是每次从outbox table中拉取未发送成功的消息,需要limit一下条数,防止一次性拉取出很多的消息。
消息幂等性问题
上面提到的Message Relay可能不止一次地发布消息。例如,它可能在发布消息之后,但在还没有删除掉outbox表中对应的record就发生了服务宕机。当服务重新启动时,它将再次发布消息。所以消息是“至少一次投递”的。因此,消息的消费方必须是幂等的,即多次消费事件与单次消费该事件的效果相同。
在分布式系统中,消息可靠性是数据一致性的基础,通常需要一次或至少一次传递消息。实际上,严格来讲,没有人能够以完全一次(exactly once)的保证来实现消息传递系统,这是不可能实现的。实际上,我们让消费方至少一次处理幂等和排除重复数据,以模拟exactly once语义。所以作为消费方进行幂等性处理是很重要的。
Transactional outbox pattern能保证最终一致性吗?
很多人包括我之前也会认为使用了Transactional outbox pattern就能保证最终一致性了,其实这是错的。最终一致性需要很多条件的支持,其中最重要的两个条件就是
- 消息传递:为了实现数据的同步,我们需要可靠的消息传递系统,以确保消息的传递正确性、可靠性和有序性。
- 事务性支持:需要实现分布式事务支持,以保证多个节点上的数据修改操作具有原子性和一致性。
而Transactional outbox pattern只是保证了第二点,他保证了数据事务和消息发布之间的一致性。使用了Transactional outbox pattern之后消息总是在事务提交之后发送,这确保了事务操作和相应事件的消息发布是原子性的。因此,在可靠的消息传递系统中只要消息成功发送到消息队列中,最终一定会被处理,从而达到最终一致性的目的。
然而我们往往却忽略另一个重要的条件,那就是一个可靠的消息传递系统。
消息的可靠性
一个可靠的消息传递系统要求消息将始终及时传递。因此,最重要的是最终至少要传递一次消息而不会丢失。也就是需要保证Producer ,Broker和Consumer三个参与者都是可靠的。
Producer的可靠性
大多数MQ框架在生产者端都具有in-memory buffer或TCP Socket buffer,以提高批处理或管道性能。但是缓冲区是易失的,如果连接断开或进程崩溃,缓冲区中的消息可能会丢失。由于缓冲区的非持久性,仅当接收到ACK时,消息才被视为已发送。所以RabbitMQ和Kafka这些Broker都提供了ACK应答机制。
Broker的可靠性
即使我们在Broker和Producer之间具有ACK,在Broker端仍然有可能丢失消息。 这取决于Broker如何答复ACK。 如果消息尚未持久保存到磁盘时,Broker就回复ACK,则此时节点崩溃将导致Broker缓冲区中的消息丢失。
Consumer的可靠性
在消费者方面,通常我们需要原子处理数据库事务的消息。 换句话说,如果消息ACK失败,则不要提交事务,如果消息是ACK,则必须提交事务。
如果我们在提交事务之前对消息进行了确认,万一后面的数据库上的业务操作失败,这个消息相当于没有触发业务操作。这显然是不可接受的。
如果我们在事务中对消息进行了确认,我们会遇到和事务前确实一样的问题。比如我们在下图的第5步中未能提交事务,则由于已对消息进行了ACK,因此我们将再也没有机会再次使用它,数据库中的数据将永远不会 被更新。
处理此问题的更好方法是在事务处理后对消息进行确认。 如果事务已提交但消息未确认,我们稍后将再次收到这个消息。 如果您的消息处理是幂等的,你相当于具备了exactly-once的能力。见下图:
在 Spring Cloud Stream 中,我们一般使用默认的自动ACK模式就可以了。自动ACK模式:消费端消费消息之后,Spring Cloud Stream 会自动帮助发送ACK确认消息,此时机制应该是只要消费端正确消费消息且未发生异常,都会返回ACK确认消息。只有消费者正确消费并发送 ACK 确认消息时,才会将消息从队列中删除。如果消费者在处理消息期间发生异常或者 ACK 确认未正确发送,消息将会被重新投递。这意味着,即使消费者出现异常情况,消息也不会被立即丢弃而是重新放回队列中或者死信队列中等待下一次被消费。这种机制可以有效保证消息的可靠传递性,但也需要注意处理异常情况以免出现重复消费等问题。
Transactional outbox pattern能保证消息顺序性吗?
上面讲到了可靠的消息传递系统需要确保消息的传递正确性、可靠性和有序性,那Transactional outbox pattern能保证消息顺序性吗?答案很显然是不能完全保证消息的顺序性
正常情况下(不发生消息发送到Broker失败的情况),只要我们是按照顺序往outbox table存入数据,并且Message Relay使用单线程从outbox table顺序拉取消息,并把消息投递到Message Broker,那么在正常情况下(不发生消息发送到Broker失败的情况)我们是能保证消息是顺序发送到Broker的。
那如果消息是顺序发送到Broker的,那就能和下面图中的一样,保证消息消费的顺序性了吗? 这可不一定!
许多人谈论消息排序,但通常不同的人对消息顺序有不同的理解, 大家一起讨论消息顺序的时候经常是鸡同鸭讲。 在这里,我们明确定义了消息顺序的两个不同概念来避免混淆:队列投递顺序(Broker Delivery Order)和业务事件顺序(Business Event Order)。
队列投递顺序 (Broker Delivery Order)
队列投递顺序意味着,如果消息A在B之前到达Broker,则A也应该在B之前被消费。在Kafka/RabbitMQ中,单个分区(partition)中的消息按投递顺序消费的,满足Broker Delivery Order。
业务事件顺序 (Business Event Order)
业务事件顺序表示消息是按因果顺序生成和处理的。 例如,您的存款帐户从$0变为$1000,然后从$1000变为$500,下游消息消费者必须能够识别这两个事件的业务顺序。 如果下游首先收到事件$1000至$500,然后收到$0至$1000,则它必须能够识别出两条消息是乱序的,并在处理它们之前对其重新排序。
另一方面,如果将两个事务应用于两个帐户,并且这两个事务不在两个帐户之间操作,则可以说两个事务是独立的,之间没有因果关系,那么消费者可以按任何顺序处理这两个事件。 例如,事件A表示在帐户X上提款,事件B表示在帐户Y上存款,这两个事件无关紧要,没有因果关系,消费者可以按任何顺序对其进行处理。但是如果事务是从X转账到Y,再从Y转账到X,那么我们就要严格按照业务逻辑顺序重新排序和消费这两个消息。
因此,业务事件顺序是指辨别出因果相关的消息,无论broker投递顺序如何,我们都能按预期的业务顺序对其进行重新排序,丢弃和处理。 业务事件顺序才是您真正需要的“顺序”。
比如在使用Transactional outbox pattern并且保证了队列投递顺序 (Broker Delivery Order)的情况下,下面两个例子依然无法保证消息顺序消费(或者说是业务事件顺序)。
总而言之,我们根本不必太过关心消息是否顺序发送到broker或者是队列投递顺序,Transactional outbox pattern是能从一定程度上保证顺序,但我们不能过分依赖这个顺序,我们真正需要的是业务事件顺序。
我们真的需要引入Transactional outbox pattern吗?
从上面的流程看,引入Transactional outbox pattern之后系统复杂性增加了。所以我们有时候是需要思考一下在我们的系统中真的需要引入入Transactional outbox pattern吗?是否非要不可?有没有其他办法?我给出我个人的结论:我个人认为在中小型的微服务系统中都不是特别必要引入入Transactional outbox pattern。我的理由和解决方法有以下几点:
- 绝大数情况下都是happy case,而异常情况往往很少,那对于这些异常的情况我们能否通过监控的方式以及一些support tool来帮助我们进行消息的重发?
- 为了监控这些异常数据,我们可以引入一些业务流程中的中间状态,比如“处理中的订单“来帮助我们这些有“问题“的订单
- 之后我们可以通过support tool工具重新触发这些有“问题”的订单
- 或者我们可以从业务角度出发,给用户在界面提供一些reprocess之类的功能,让用户有办法让业务流程继续走下去
参考和学习资料
消息可靠性和顺序(中文)
What do you mean by “Event-Driven”?
Pattern: Transactional outbox
What is microservices architecture?
MQ 消息的可靠性投递
如何解决微服务的数据一致性分发问题?
spring cloud实现可靠消息一致性
分布式事务解决方案实战
研读《可靠消息最终一致性方案(本地消息服务)》
研读《可靠消息最终一致性方案(独立消息服务)》
【进阶之路】可靠消息最终一致性解决方案
可靠消息最终一致性分布式事务实现方案
可靠消息的最终一致性解决方案
Spring Cloud 实现可靠消息一致性
分布式消息队列:如何保证消息的可靠性传输
后端开发实践系列——事件驱动架构(EDA)编码实践
分布式事务?No, 最终一致性
(三)从0开始写框架—可靠消息事务最终一致性
分布式一致性协议之2PC和3PC
2PC和3PC
基于可靠消息服务的分布式事务
分布式事务——消息最终一致性方案
RocketMQ使用及分布式事务解决思路
基于MQ消息中间件的分布式事务解决方案
(微服务)分布式事务-最大努力交付 && 消息最终一致性方案
9.交易性能优化-事务型消息
Spring Cloud 分布式事务管理
可靠消息最终一致(异步确保型)
我说分布式事务之可靠消息最终一致性事务1-原理及实现
我说分布式事务之最大努力通知型事务
Implementing At Least Once Delivery With RabbitMQ and Spring’s RabbitTemplate
波波老师: 解决微服务的数据一致性分发问题?
空谈发件箱模式(outbox pattern)
聚焦JAVA性能优化 打造亿级流量秒杀系统【学习笔记】07_交易性能优化技术之事务型消息