在SpringBoot中使用Transmission Outbox模式构建可靠的微服务

481 阅读10分钟

在SpringBoot中使用Transmission Outbox模式构建可靠的微服务

在基于微服务的架构中,最大的挑战之一是避免微服务之间的数据不一致。数据不一致的原因有很多,其中一个原因是双写问题,我们将在本文中讨论这个问题。

问题陈述

当一段代码试图在单个事务中写入两个不同的数据源(即数据库和消息队列)时,会发生双写问题,其中一个写入失败,使系统处于不一致状态。为了更好地理解这个问题,让我们考虑以下代码:

    @Transactional
    public User createUser(UserRequestDto userRequestDto) {
        User user = userRequestDtoMapper.mapToUserEntity(userRequestDto);

        user = userRepository.save(user); // Write to the database

        kafkaTemplate.send(topic, user); // Write to the message queue

        return user;
    }

虽然上面的代码片段看起来很好,但仔细看后,我们可以发现其中存在一些问题。第一个问题是,如果Kafka代理在交易期间暂时不可用,该怎么办?是的,你可能认为我们可以简单地回滚事务,是的,我们可以,但是等等,由于临时失败而回滚整个事务是一个好的实践吗?答案肯定是否定的。

第二个不太容易通过查看代码发现的问题是,如果事务提交失败怎么办?你可能会认为这不会发生,在你的本地机器上,这种情况几乎不会发生,但是在生产环境中,有成千上万的原因可能导致数据库提交失败。如果你仍然不相信,那么这个stackoverflow线程可能会说服你。如果发生这种故障,那么我们将最终处于不一致的状态,因为消息将在事务回滚之前发送。因此,基本上用户不会持久化在数据库中,但会发布消息,指示用户已创建,而实际上并没有创建。

溶液

为了解决这个问题,我们有两个解决方案,一个是实现分布式事务模式,如佐贺。但是,问题是它有点难以实现和维护。好消息是,我们可以通过使用事务发件箱模式来避免分布式事务(在大多数情况下)。

transmitting发件箱模式

transmitting发件箱的想法是,为了避免双重写入问题,而不是写入两个不同的数据源,我们必须在单个事务中只写入一个,即我们的数据库,因此我们可以在失败的情况下将整个事务作为一个单元回滚而不会产生副作用。因此,本质上我们正在创建一个本地事务,而不是分布式事务。这样,我们将始终处于一种一致的状态。为了实现这种模式,我们必须创建一个名为outbox的新表,而不是直接写入队列,我们将在这个表中写入消息。并创建一个单独的消息中继服务(MRS),它将轮询未传递的消息并批量传递它们。从图表上看,这可以如下所示:

执行

本演示假定您在本地计算机上安装了以下工具:

  • Java 17
  • Maven
  • springboot3
  • MySQL 8
  • Kafka

使用初始化器初始化SpringBoot项目。项目设置完成后,在resources文件夹中创建以下liquibase迁移文件:

db/changelog/2024/05/changelog

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                      http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">


    <includeAll path="db/changelog/2024/05/migrations"/>

</databaseChangeLog>

db/changelog/2024/05/migrations/2024-05-27-01-create-order-table.sql

CREATE TABLE `user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `firstname` varchar(45) NOT NULL,
  `lastname` varchar(45) DEFAULT NULL,
  `address` varchar(100) DEFAULT NULL,
  `dob` date NOT NULL,
  `email` varchar(255) NOT NULL,
  `created_date` timestamp NULL DEFAULT NULL,
  `last_modified_date` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `email_UNIQUE` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

db/changelog/2024/05/migrations/2024-05-27-02-create-outbox-table.sql

CREATE TABLE `outbox` (
  `id` int NOT NULL AUTO_INCREMENT,
  `aggregate` varchar(10) NOT NULL,
  `message` text NOT NULL,
  `is_delivered` tinyint NOT NULL DEFAULT '0',
  `created_date` timestamp NOT NULL,
  `last_modified_date` timestamp NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

我们创建了两个表,一个用于存储用户,称为user,另一个用于存储消息,称为outbox。有很多方法来构造发件箱表,我们上面使用的结构包含id,aggregate(发起消息的聚合的名称,在我们的例子中它将是USER),message(包含消息的实际内容),is_delivered(0表示未送达的消息,1表示已送达),created_date,last_modified_date。但是同样,创建发件箱表没有固定的结构,请选择最适合您的结构。

接下来,为这些表创建实体和存储库:

package com.example.microservices.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@EntityListeners(AuditingEntityListener.class)
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @Column(name = "firstname", nullable = false, length = 45)
    private String firstname;

    @Column(name = "lastname", length = 45)
    private String lastname;

    @Column(name = "dob", nullable = false)
    private LocalDate dob;

    @Column(name = "address", nullable = true, length = 100)
    private String address;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @CreatedDate
    @Column(name = "created_date")
    private LocalDateTime createdDate;

    @LastModifiedDate
    @Column(name = "last_modified_date")
    private LocalDateTime lastModifiedDate;

    private static final String USER_AGGREGATE = "USER";

    public User() {
    }

    public User(String firstname, String lastname, LocalDate dob, String address, String email) {
        this.firstname = firstname;
        this.lastname = lastname;
        this.dob = dob;
        this.address = address;
        this.email = email;
    }

    public LocalDateTime getLastModifiedDate() {
        return lastModifiedDate;
    }

    public void setLastModifiedDate(LocalDateTime lastModifiedDate) {
        this.lastModifiedDate = lastModifiedDate;
    }

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public void setCreatedDate(LocalDateTime createdDate) {
        this.createdDate = createdDate;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public LocalDate getDob() {
        return dob;
    }

    public void setDob(LocalDate dob) {
        this.dob = dob;
    }

    public String getLastname() {
        return lastname;
    }

    public void setLastname(String lastname) {
        this.lastname = lastname;
    }

    public String getFirstname() {
        return firstname;
    }

    public void setFirstname(String firstname) {
        this.firstname = firstname;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}
package com.example.microservices.entity;

import com.example.microservices.constant.Aggregate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Outbox {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(name = "aggregate")
    private Aggregate aggregate;


    @Column(name = "message", length = 500)
    private String message;

    @Column(name = "is_delivered", nullable = false)
    private Boolean isDelivered = false;

    @CreatedDate
    @Column(name = "created_date")
    private LocalDateTime createdDate;

    @LastModifiedDate
    @Column(name = "last_modified_date")
    private LocalDateTime lastModifiedDate;


    public Outbox() {
    }

    public Outbox(Aggregate aggregate, String message, Boolean isDelivered) {
        this.aggregate = aggregate;
        this.message = message;
        this.isDelivered = isDelivered;
    }

    public Boolean getIsDelivered() {
        return isDelivered;
    }

    public void setIsDelivered(Boolean isDelivered) {
        this.isDelivered = isDelivered;
    }

    public LocalDateTime getLastModifiedDate() {
        return lastModifiedDate;
    }

    public void setLastModifiedDate(LocalDateTime lastModifiedDate) {
        this.lastModifiedDate = lastModifiedDate;
    }

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public void setCreatedDate(LocalDateTime createdDate) {
        this.createdDate = createdDate;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Aggregate getAggregate() {
        return aggregate;
    }

    public void setAggregate(Aggregate aggregate) {
        this.aggregate = aggregate;
    }
}
package com.example.microservices.repository;

import com.example.microservices.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

}
package com.example.microservices.repository;

import com.example.microservices.entity.Outbox;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface OutboxRepository extends JpaRepository<Outbox, Long> {

}

接下来,创建一个rest控制器和一个用于创建用户的请求DTO:

package com.example.microservices.controller;

import com.example.microservices.dto.UserRequestDto;
import com.example.microservices.entity.User;
import com.example.microservices.service.UserService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public User createUser(@RequestBody @Valid UserRequestDto userRequestDto) {
        return userService.createUser(userRequestDto);
    }

}
package com.example.microservices.dto;

import com.example.microservices.entity.User;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDate;

public class UserRequestDto {

    @NotNull
    private String firstName;
    private String lastName;
    private String address;
    @NotNull
    private LocalDate dob;
    @Email
    private String email;


    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public LocalDate getDob() {
        return dob;
    }

    public void setDob(LocalDate dob) {
        this.dob = dob;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

最后,创建一个服务类来处理用户创建,沿着关联的映射器类和聚合的枚举:

package com.example.microservices.constant;

public enum Aggregate {
    USER
}
package com.example.microservices.mapper;

import com.example.microservices.constant.Aggregate;
import com.example.microservices.entity.Outbox;
import com.example.microservices.entity.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;

@Component
public class UserMapper {

    private final ObjectMapper objectMapper;

    public UserMapper(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public Outbox mapToOutBoxEntity(User user) throws JsonProcessingException {
        return new Outbox(
                Aggregate.USER,
                objectMapper.writeValueAsString(user),
                false
        );
    }
}
package com.example.microservices.mapper;

import com.example.microservices.dto.UserRequestDto;
import com.example.microservices.entity.User;
import org.springframework.stereotype.Component;

@Component
public class UserRequestDtoMapper {

    public User mapToUserEntity(UserRequestDto userRequestDto) {
        return new User(
                userRequestDto.getFirstName(),
                userRequestDto.getLastName(),
                userRequestDto.getDob(),
                userRequestDto.getAddress(),
                userRequestDto.getEmail()
        );
    }

}
package com.example.microservices.service;

import com.example.microservices.dto.UserRequestDto;
import com.example.microservices.entity.Outbox;
import com.example.microservices.entity.User;
import com.example.microservices.mapper.UserMapper;
import com.example.microservices.mapper.UserRequestDtoMapper;
import com.example.microservices.repository.OutboxRepository;
import com.example.microservices.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final OutboxRepository outboxRepository;
    private final UserRequestDtoMapper userRequestDtoMapper;
    private final UserMapper userMapper;


    public UserService(UserRepository userRepository, OutboxRepository outboxRepository,
                       UserRequestDtoMapper userRequestDtoMapper, UserMapper userMapper) {
        this.userRepository = userRepository;
        this.outboxRepository = outboxRepository;
        this.userRequestDtoMapper = userRequestDtoMapper;
        this.userMapper = userMapper;
    }

    @Transactional
    public User createUser(UserRequestDto userRequestDto) {
        try {
            User user = userRequestDtoMapper.mapToUserEntity(userRequestDto);

            user = userRepository.save(user);

            Outbox outbox = userMapper.mapToOutBoxEntity(user);

            outboxRepository.save(outbox); // This time saving message in a table instead of directly publishing

            return user;

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

}

正如我们可以在CNOWUser方法中看到的那样,我们现在将消息存储在发件箱表中,而不是像以前那样直接发布消息。这样,如果事务失败,整个操作将回滚,并且不会发布任何消息,这将确保我们系统中的一致性。

我们的第一部分已经完成,但问题是我们如何实际发布消息。如上所述,消息中继服务(MRS)来了。

消息中继服务(MRS)

有多种方法来实现MRS。其中一些是:

  • 创建一个定期运行的作业,轮询发件箱表中未送达的邮件,并批量送达这些邮件。
  • 使用CDC(变更数据捕获)工具(如Debezium)在数据插入发件箱表时自动发布消息。

虽然这两种技术都很好,但在本演示中,我们将重点介绍第一种技术。我们将创建一个作业,但在此之前,我们必须在我们之前创建的发件箱存储库中添加一个查询方法,以查找前10个未送达的邮件:

package com.example.microservices.repository;

import com.example.microservices.entity.Outbox;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface OutboxRepository extends JpaRepository<Outbox, Long> {

    List<Outbox> findTop10ByIsDelivered(boolean status);

}

现在我们可以继续创建我们的工作:

package com.example.microservices.task;

import com.example.microservices.entity.Outbox;
import com.example.microservices.entity.User;
import com.example.microservices.repository.OutboxRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Component
public class OutboxProcessorTask {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, Object> kafkaTemplate;
    private final String topic;
    private final ObjectMapper objectMapper;

    public OutboxProcessorTask(OutboxRepository outboxRepository,
                               KafkaTemplate<String, Object> kafkaTemplate, @Value("${kafka.topic.user}") String topic, ObjectMapper objectMapper) {
        this.outboxRepository = outboxRepository;
        this.kafkaTemplate = kafkaTemplate;
        this.topic = topic;
        this.objectMapper = objectMapper;
    }

    @Scheduled(fixedRate = 5000)
    @Transactional
    public void process() throws JsonProcessingException {
        System.out.println("Task executed");

        List<Outbox> outboxes = outboxRepository.findTop10ByIsDelivered(false);

        for (Outbox outbox : outboxes) {
            kafkaTemplate.send(topic,  objectMapper.readValue(outbox.getMessage(), User.class));
            outbox.setIsDelivered(true);
        }
    }

}

上述作业执行以下操作:

  1. Scheduled annotation将方法标记为作业执行,fixedRate参数将此作业设置为每五秒运行一次。
  2. 从is_delivered列为false的发件箱表中获取前10封邮件。
  3. 遍历消息,在每次迭代中发布消息并将is_delivered字段设置为true。

请注意,这个作业也是事务性的,因此如果在处理过程中出错,该批处理中的所有消息都将在下一次执行中重试。

测试

测试时间到了首先,我们必须启动我们的Kafka服务器和消费者。在下载了Kafka的目录中运行以下命令:

# Start the ZooKeeper service
$ bin/zookeeper-server-start.sh config/zookeeper.properties

# Start the Kafka broker service
$ bin/kafka-server-start.sh config/server.properties

Kafka服务器启动后,在单独的选项卡中运行以下命令以启动Kafka消费者,该消费者将订阅user-events主题:

# Create user-events topic
$ bin/kafka-topics.sh --create --topic user-events --bootstrap-server 
  localhost:9092

# Start a consumer
$ bin/kafka-console-consumer.sh --topic user-events --from-beginning 
  --bootstrap-server localhost:9092

现在我们的Kafka服务器已经启动并准备好消费事件,我们可以通过在项目目录中运行以下命令来启动SpringBoot应用程序:

mvn spring-boot:run

这将启动我们的应用程序,应用程序启动后,我们可以在日志中观察到,“任务执行”每五秒打印一次,此日志确认我们的作业正在正常运行。

现在让我们使用curl调用我们的API:

curl --location 'localhost:8085/api/v1/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "Haris",
    "lastName": "Masood",
    "dob": "2024-05-27",
    "address": "test address",
    "email": "haris@test.com" 
}'

要验证我们的消息是否已成功发布和消费,请返回到消费者选项卡,您可以看到打印在那里的消息。

让我们也查询我们的数据库:

正如我们从上面的截图中看到的,用户和发件箱表都被填充了。此外,发件箱表的is_delivered列被设置为1,这进一步确认了我们的消息被正确发布。

事情要考虑

在实现transmitting发件箱模式时,我们需要考虑几件事。在消息传递中,有三种语义:

  • 至少一次交货
  • 最多交付一次
  • 尽最大努力交付

此模式提供了至少一次交付语义,这意味着在某些情况下可以多次交付消息,因为如果在批处理过程中出现异常,MRS将在下一次执行中重试所有消息。由于这个原因,我们必须使我们的消费者幂等。

另一件需要考虑的事情是,这个模式处理的是我们系统的生产端。我们应该通过使用重试和死信队列等技术来使我们的消费端可靠。

第三件事是,如果正确实现,这种模式可以消除实现和管理复杂分布式事务的需要。然而,在某些情况下,最终的一致性是绝对不可取的,在这些情况下,应该考虑佐贺模式。

最后,在某些时候发件箱表会变得太大,所以我们必须编写某种清理作业,定期删除已发送的消息。

结论

正如我们所看到的,transmitting发件箱模式是一种非常强大的模式,可以缓解微服务架构中的数据不一致问题。这也不难理解和实现。