事务消息系统设计

本文背景

问题发生在云课堂(企业云)教学模块,运营多次跟测试、开发反馈——课程发布后,用户前台没有看到课程目录。 开发查看代码和数据后诊断是分布式事务引起的不一致(同一个事务中操作DDB和MongoDB)。 由于发生的概率较低,前几次都是让测试人员重新编辑目录后再发布。但最终还是要想办法彻底解决下这个问题。

问题分析

发布后的课程数据保存在DDB,后台管理的草稿数据保存在MongoDB
发布课程伪代码如下:

public void publishCourse(){
    CourseDraftDoc draft=getMongoDraft();
    if(!draft.isDraft()){
        return;
    }

    updateDDB();
    updateMongoDraft(PUBLISHED);
}

因为使用了声明式事务,实际上代码应该是这样:

public void publishCourse(){
   try {
       dbTransactionManager.setAutoCommit(false);
       CourseDraftDoc draft=getMongoDraft();
       if(!draft.isDraft()){
           return;
       }

       updateDDB();
       updateMongoDraft(PUBLISHED);

       dbTransactionManager.commit();
   } catch (Exception e) {
       dbTransactionManager.rollback();
   } finally {
       dbTransactionManager.setAutoCommit(true);
   }
}

以上代码正常情况下会运行的很好

  • updateDDB() 执行出错
    DDB回滚,MongoDB未执行,数据一致
  • updateMongoDraft() 执行出错
    DDB回滚,MongoDB执行失败,数据一致

但是下面情况会出现异常

  • dbTransactionManager.commit() 执行出错
    updateMongoDraft操作成功,DDB回滚,数据不一致

运营反馈的问题,很可能就是这种情况下出现的

  1. 课程服务升级,Web层调用服务失败
  2. Web层发起失败重试(Dubbo调用)
  3. 由于MongoDB草稿已被更新为发布,所以不会重新发布

解决方案

事实上处理这种场景下的问题,方案有很多种。

草稿数据迁移到DDB
分布式事务转成本地事务,通过数据库保证ACID

业务保证幂等
在DB表和MongoDB表增加一个版本号字段用于表示最后一次发布的数据是否同步。

public void publishCourse(){
   try {
       dbTransactionManager.setAutoCommit(false);
       CourseDraftDoc draft=getMongoDraft();
       Course course=getDDB();
       boolean canPublih=draft.isDraft() || course.getPublishVersion().equals(doc.getPublishVersion());
       if(!canPublih){
           return;
       }

       String publishVersion=UUID.gen();
       updateDDB(publishVersion);
       updateMongoDraft(publishVersion,PUBLISHED);

       dbTransactionManager.commit();
   } catch (Exception e) {
       dbTransactionManager.rollback();
   } finally {
       dbTransactionManager.setAutoCommit(true);
   }
}

当重试时,DDB的发布版本和MongoDB的发布版本不一致,会进行重新发布,数据最终一致。
返回给前端用于是否展示发布按钮的canPublish逻辑也要调整下。
这种解决方法看起来也不错,能较好的解决问题,接下来讨论另一种方案,通过事务消息系统来解决。

事务消息设计

业务耦合方案

在课程模块增加一个消息表tx_message,消息数据与业务数据保存在同一数据库,通过本地事务保证ACID。
在本地事务完成后投递消息,投递成功后删除消息表记录。
同时会有一个兜底定时任务(用于处理本地事务完成,消息投递失败的问题)取tx_message表的数据,投递消息成功后,删除消息表记录。
这个方案不好的地方在于不够通用,每个模块需要自行实现。所以重点看下通用方案。

通用方案


原理也比较简单,类似于二阶段提交实现
正常执行步骤:

  1. 业务方发送prepare消息到事务消息系统
  2. 事务消息系统将prepare消息持久化到数据库
  3. 业务方执行本地事务
  4. 根据本地事务执行结果确定是否提交、回滚事务消息
    • 本地事务成功
      1. 更新数据库消息状态为publish
      2. 将消息投递到真正的MQ Server(消费队列)
    • 本地事务失败
      1. 删除数据库prepare状态消息

回查执行步骤:

  1. 事务消息系统定时从数据库捞取prepare状态的消息
  2. 事务消息系统将prepare消息投递真正的MQ Server(回查队列)
  3. 业务方订阅MQ Server(回查队列)
  4. 业务方执行本地操作,回查事务完成状态
  5. 根据回查结果确定是否提交、回滚事务消息
    • 本地事务完成
      1. 更新数据库消息状态为publish
      2. 将消息投递到真正的MQ Server(消费队列)
    • 本地事务失败
      1. 删除数据库prepare状态消息

成功提交


失败回滚


回查提交


回查回滚


定时回查
定时回查prepare状态的消息需要注意,不要把正在执行的事务消息查出来(回查消息的prepare时间应该在间隔时间(1分钟)以前)

业务方接入

  • 根据事务消息系统提供的接口提交prepare消息、commit消息、rollback消息
  • 需要订阅回查队列确定prepare消息的终态

高可用保证
事务消息系统必须高可用,事务消息prepare、commit、rollback操作,应用自身不涉及状态,水平扩展即可。
回查消息处理并不会很多,为简化设计,我选了主从高可用,而不是使用多主设计。
主从切换,高可用设计原理同《延迟任务调度系统(技术选型与设计)》,这里不多说明。

消息重复投递
事务消息系统在更新消息状态和投递消息到真正的MQ时,有小概率可能会出现重复投递。 事务消息系统不打算解决这个问题,建议消费方业务处理逻辑保持幂等性。因为极端情况下即使投递一次的消息也是有可能出现多次消费的。

其他扩展


以上设计,消息是否在消费方完成消费是未知的,可以引入消费方ACK机制,消费完成后修改消息状态为consumed。 这样可以结合告警系统,对prepare状态且超过一定时间(可配置)的未进入终态的消息发送告警。

大家对事务消息系统设计和实现相关的有什么问题、建议或想法,欢迎popo(hzchenzhiliang@corp.netease.com)联系交流。

长文推荐

本文来自网易实践者社区,经作者陈志良授权发布。