【项目】数据库事务与MQ发送一致性

  • Post author:
  • Post category:其他




数据库事务与MQ发送一致性



技术背景

在进行服务改造的时候,会需要进行服务技术架构的选型。其中比较关键的就是通信中间件的选择。

不同服务之间,同步通信比较典型的代表是 RPC ,异步通信的典型代表是 MQ 。



问题现状

在电商业务中,如果需要使用 MQ 作为消息通信中间件,那么需要解决 MQ 的一致性问题。MQ 提供一致性保障又分为两个方面。

发消息时确保业务操作和发消息是一致的;接收消息的时候要确保消息最终被正常处理了。

发送方一般通过事务保证,消费方通常使用消费 ACK 和重试来达到一致性。



数据库事务解决 MQ 发送一致性

以 MySQL 为例,在同一实例中的不同 db ,如果共享同一个 Connection 的话,是可以在同一个事务中的。

同一实例,指的是使用同一台机器;同一个 Connection ,指的是同一个服务使用这台机器。

那么这种情况下,执行下面这种操作,是可以支持在同一事务中进行操作的。

begin transaction;
insert into A.tbl1(name, age) values('admin', 18);
insert into B.tbl2(num) values(20);
end transaction;

依托于这样的实现,我们可以在所有的 MySQL 实例中,均创建出一个专门负责 MQ 的 db 。且这个 db 对于应用透明,那么我们可以在使用的时候,将发送消息与业务操作放在同一个事务中即可。

在电商业务场景中,在支付的时候要插入支付流水,同时还需要发送一条消息通知其他业务系统。那么在这种场景下,我们需要保证两个处理同时完成。

@Transactional
public void pay(order order) {
    PayTransaction t = buildPayTransaction(order);
    payDao.append(t);
    producer.sendMessage(buildMessage(t));
    final Message message = buildMessage(t);
    messageDao.insert(message);
    // 在事务提交后执行
    triggerAfterTransactionCommit(()-> {
        messageclient.send(message);
        messageDao.delete(message);
    });
}

实际上在 producer.sendMessage 执行的时候,消息并没有通过网络发送出去,而仅仅是往业务 DB 同一个实例上的消息库插入一条记录,然后注册事务的回调。在这个事务真正提交后消息才从网络发送出去,这个时候如果发送到 consumer 成功的话消息会被立即删除掉。而如果消息发送失败则消息就留在消息库里,这个时候我们会有一个补偿任务会将这些消息从指定的 message db 消息库里捞出然后重新发送,直到发送成功。

整个流程图如图所示:



这样的结构下,每次发送消息,第一个可以利用 db 中已经持久化的数据进行,第二个可以使用定时任务做数据补偿。



简单模式下的事务 MQ 一致性

在一个事务中,同时保证事务和 MQ 的发送一致性,可以使用事务监听的方式实现。

在 Spring 中的声明式事务方式中,可以使用

TransactionSynchronizationManager

事务同步管理器,对事务进行后置增强,指定发送 MQ 的操作在事务提交之后完成。但是对于 MQ 的发送成功,需要做一些其他的补偿机制。 MQ 发送到 Exchange 的过程中,如果发生问题,可能会导致发送失败的情况。

这种情况下,比较好的能提高发送成功的概率的方法,是可以使用

@Retryable

注解的。多重试几次,直到确认为止。

@Transactional
public void finishOrder(Order order){
    // 添加订单成功
    insertOrderSuccess(order);
    
    // 发送消息到 MQ
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter(){
        @Override
        public void afterCommit() {
            mqService.send(order);
        }
    });
}

这种直接写样板模式的代码,耦合度比较高,而且会造成大量重复。这种情况下,需要判断当前是否存在事务,否则会报错:

java.lang.IllegalStateException: Transaction synchronization is not active

正确方式是:

private final ExecutorService executor = Executors.newSingleThreadExecutor();

public void sendLog() {
    // 判断当前是否存在事务
    if (!TransactionSynchronizationManager.isSynchronizationActive()) {
        // 无事务,异步发送消息给kafka
        
        executor.submit(() -> {
            // 发送消息给kafka
            try {
                // 发送消息给kafka
            } catch (Exception e) {
                // 记录异常信息,发邮件或者进入待处理列表,让开发人员感知异常
            }
        });
        return;
    }
    
    // 有事务,则添加一个事务同步器,并重写afterCompletion方法(此方法在事务提交后会做回调)
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
        
        @Override
        public void afterCompletion(int status) {
            if (status == TransactionSynchronization.STATUS_COMMITTED) {
                // 事务提交后,再异步发送消息给kafka
                executor.submit(() -> {
                    try {
                        // 发送消息给kafka
                    } catch (Exception e) {
                        // 记录异常信息,发邮件或者进入待处理列表,让开发人员感知异常
                    }
                });
            }
        }
        
    });
    
}

为了方便使用,可以进行事务监听,遵循这样的业务流程:

注册事件 -> 事件监听 -> 事务提交 -> 事件执行

解耦之后的代码:

@Service
@Slf4j
public class UserServiceImpl extends implements UserService {

	@Autowired
    UserMapper userMapper;
    	
	@Autowired
    ApplicationEventPublisher eventPublisher;
	
	public void userRegister(User user){
		userMapper.insertUser(user);
		eventPublisher.publishEvent(new UserRegisterEvent(new Date()));
	}
}

自定义事件:

public class UserRegisterEvent extends ApplicationEvent {

    private Date registerDate;

    public UserRegisterEvent(Date registerDate) {
        super(registerDate);
        this.registerDate = registerDate;
    }
}

事件监听器:

@Slf4j
@Component
public class UserListener {

    @Autowired
    UserService userService;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, classes = UserRegisterEvent.class)
    public void onUserRegisterEvent(UserRegisterEvent event) {
        userService.sendActivationCode(event.getRegisterDate());
    }
}



消息驱动事务补偿



事件背景

在 DDD 架构中,要实现领域事件驱动优化,需要将不同的领域消息进行抽象,使用 MQ 消息来进行事件驱动,完成事件的最终一致性。



领域事件

在实践之前,需要了解一下领域事件的概念。

领域事件(Domain Events)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获我们所建模的领域中所发生过的事情。

它用来表示领域中发生的事件。一个领域事件将导致进一步的业务操作,在实现业务解耦的同时,还有助于形成完整的业务闭环。

举例来说的话,领域事件可以是业务流程的一个步骤,比如投保业务缴费完成后,触发投保单转保单的动作;也可能是定时批处理过程中发生的事件,比如批处理生成季缴保费通知单,触发发送缴费邮件通知操作;或者一个事件发生后触发的后续动作,比如密码连续输错三次,触发锁定账户的动作。

如何识别领域事件:在做用户旅程或者场景分析时,我们要捕捉业务、需求人员或领域专家口中的关键词:“如果发生……,则……”“当做完……的时候,请通知……”“发生……时,则……”等。在这些场景中,如果发生某种事件后,会触发进一步的操作,那么这个事件很可能就是领域事件。



实践过程

在实践中,需要做的是:

事件发布 -> 消息发布 -> 消息传递 -> 消息消费 -> 事件处理

主要分为五个步骤来划分这一过程。现在需要着重处理的是上游业务,也就是事件发布和消息发布。

在事件发布中,我们通常定义一个通用的领域事件,通常需要包含事件 ID 、时间、主题、数据等。

public class DomainEvent<T> {
    private String eventId;
    private String eventTopic;
    private Date eventTime;
    private T eventData;
}

之后通常会需要创建一个事件发布器:

/**
* 事件预发布,在有事务的时候需要先保存事件,之后再通过MQ进行发布
*/
public <T> void prePublish(DomainEvent<T> domainEvent) {
    domainEventService.save(domainEvent);
    // 将消息添加到ThreadLocal中
    addDomainEvent(domainEventEntity);
}

消息的发布:

public void publish() {
    // 移除ThreadLocal中的消息并进行发送
    List<DomainEvent> list = removeAndGet();
    for (DomainEvent event : list) {
        try {
            // 发送消息
            MqService.send(event);
        } catch (Exception e) {
            // 抛出异常
            throw new Exception();
        }
    }
}

整体的执行流程:

事务执行 -> 生成事件 -> 事件预发送 -> 事件发送

整体的流程没问题,但是需要做的是事件补偿。事务补偿,可以利用定时任务,扫描对应的数据库中的持久化事件数据是否存在。如果存在,就取出来做发送;反之则不操作。

添加到 ThreadLocal 中,是为了减少数据库的 I/O 操作,直接从 ThreadLocal 中查询数据会更快速,也更方便做事件补偿。



版权声明:本文为qq_43103529原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。