共计 3100 个字符,预计需要花费 8 分钟才能阅读完成。
前言
微服务架构现在十分的风行,这个架构下可能常常会遇到“双写”的场景。双写是指您的应用程序须要在两个不同的零碎中更改数据的状况,比方它须要将数据存储在数据库中并向音讯队列发送事件。您须要保障这两个操作都会胜利。如果两个操作之一失败,您的零碎可能会变得不统一。那针对这样的状况有什么好的办法或者设计保障呢?本文就和大家分享一个“发件箱模式”, 能够很好的防止此类问题。
欢送关注集体公众号『JAVA 旭阳』交换沟通
下订单的例子
假如咱们有一个 OrderService 类,它在创立新订单时被调用,此时它应该将订单实体保留在数据库中并向交付微服务发送一个事件,以便交付部门能够开始打算交付。
你的代码可能是上面这样子的:
@Service
public record OrderService(
IDeliveryMessageQueueService deliveryMessageQueueService,
IOrderRepository orderRepository,
TransactionTemplate transactionTemplate) implements IOrderService {
@Override
public void create(int id, String description) {String message = buildMessage(id, description);
transactionTemplate.executeWithoutResult(transactionStatus -> {
// 保留订单
orderRepository.save(id, description);
});
// 发送音讯
deliveryMessageQueueService.send(message);
}
private String buildMessage(int id, String description) {// ...}
}
复制代码
能够看到咱们在事务中将订单保留在数据库中,而后咱们应用音讯队列将事件发送到交付服务。这是双写的一个场景。
这么写,会遇到什么问题呢?
首先,如果咱们保留了订单然而发送音讯失败了怎么办?送货服务永远不会收到音讯。
那你可能想到把保留订单和发消息放到同一个事务中不就能够了吗,就是是将 deliveryMessageQueueService#send 挪动到与 orderRepository#save 雷同的事务中,如下图:
transactionTemplate.executeWithoutResult(transactionStatus -> {
// 保留订单
orderRepository.save(id, description);
// 发送音讯
deliveryMessageQueueService.send(message);
});
复制代码
实际上,在数据库事务外部建设 TCP 连贯是一种蹩脚的做法,咱们不应该这样做。
有没有更好的办法呢?
咱们能够订单表所在的同一数据库中有一个表“发件箱”(在最简略的状况下,它能够有一个列“音讯”和以后工夫戳)。保留订单时,在同一个事务中,咱们在“发件箱”表中保留了一条音讯。音讯一发送,咱们就能够将其从发件箱表中删除,代码如下:
@Service
public record OrderService(
IDeliveryMessageQueueService deliveryMessageQueueService,
IOrderRepository orderRepository,
IOutboxRepository outboxRepository,
TransactionTemplate transactionTemplate) implements IOrderService {
@Override
public void create(int id, String description) {UUID outboxId = UUID.randomUUID();
String message = buildMessage(id, description);
transactionTemplate.executeWithoutResult(transactionStatus -> {
// 保留订单
orderRepository.save(id, description);
// 保留到发件箱
outboxRepository.save(new OutboxEntity(outboxId, message));
});
deliveryMessageQueueService.send(message);
// 删除
outboxRepository.delete(outboxId);
}
private String buildMessage(int id, String description) {// ...}
}
复制代码
能够看到,咱们在一次事务中将订单和发件箱实体保留在咱们的数据库中。而后咱们发送一条音讯,如果胜利,咱们删除这条音讯。
如果 deliveryMessageQueueService#send 失败会怎么?(例如,您的应用程序被终止或音讯队列或数据库不可用)。在这种状况下,outboxRepository#delete 将不会运行,咱们必须重试发送音讯。
它能够应用将在后盾运行的打算工作来实现,该工作将尝试发送在表发件箱中显示超过 X 秒(例如 10 秒)的音讯,如上面的代码。
@Service
public record OutboxRetryTask(IOutboxRepository outboxRepository,
IDeliveryMessageQueueService deliveryMessageQueueService) {@Scheduled(fixedDelayString = "10000")
public void retry() {List<OutboxEntity> outboxEntities = outboxRepository.findAllBefore(Instant.now().minusSeconds(60));
for (OutboxEntity outbox : outboxEntities) {deliveryMessageQueueService.send(outbox.message());
outboxRepository.delete(outbox.id());
}
}
}
复制代码
在这里你能够看到,咱们每 10 秒运行一个工作,并发送之前没有发送过的音讯。如果音讯胜利发送到音讯队列,但发件箱实体没有从数据库中删除(例如因为数据库问题),那么下次该后台任务将尝试再次将此音讯发送到音讯队列。但这也意味着咱们音讯的消费者必须做好幂等解决,因为可能会屡次接管雷同的音讯。
发件箱模式
通过下面的例子,咱们能够形象出“发件箱模式”。
在数据库外面额定减少一个 outbox 表用于存储须要发送的 event
把间接发送 event 的步骤换成先把 event 存储到数据库 outbox 表
程序启动一个 job 一直去抓取 outbox 表外面的记录,通过推送线程实现不同业务的推送
最初删除发送胜利的记录
揭示音讯生产端要做好幂等解决
总结
发件箱模式尽管听下来可能很简略,然而在平时开发中可能会疏忽掉。如果还不能了解,咱们能够将它类比到生存的场景,寄信人只须要写好函件,放入收件箱,之后就不必管了。送信的人会来收件箱取走函件,依据函件里须要送到的地址,将函件送至目的地。这样做的益处就是,寄信人写好信之后,就不须要期待收信人有空的时候能力寄信,只须要往发件箱里丢就好了。