分布式事务AP控制方案(上)

2 篇文章 0 订阅
2 篇文章 0 订阅

分布式事务控制方案

本篇文章给出一种要求高可用性(AP思想)的分布式事务控制方案

下篇新鲜出炉点我查看

1、业务背景

业务背景:在线学习平台,教学机构在上传课程时,需要将课程内容同步到数据库,缓存,文件系统,搜索系统,这里需要用到分布式事务,来确保四个组件的业务顺利完成。

CAP理论中,分布式系统只能满足一致性C、可用性A、分区容错性P三者中的两个,由于分布式系统天然要求分区容错,否则就是单体项目,所以只能选CP或AP

其中CP可以使用Seata框架基于AT和TCC模式去实现,AP也有多种实现方式。

我们的业务背景中这四个组件并不要求强一致性,而是要求高可用性,如果其中某个组件没有完成数据同步,那之前已经完成的组件不必回退到事务开始前的状态,所以我们实现AP思想,采用本地消息表+任务调度完成最终一致性

具体的项目环境:SpringBoot框架,数据库MySQL,使用MyBatis-Plus快速开发,缓存Redis,分布式文件系统MinIO,搜索系统ES

2、本地消息表的设计

在业务背景中,如果用户要进行课程发布,我们向MySQL中的消息表里插入一条记录,记录中应当包含四个组件(MySQL,Redis,MinIO,ES)完成的状态,如果四个组件全部完成,就删除这条记录,向历史消息表中插入一条记录,如果四个组件有哪个没有完成,通过查询记录就可以从未完成的地方重新进行数据同步,从而实现最终一致性。

我们除了课程这个业务场景,还会在其他业务场景执行相似的业务,所以我们要考虑如何进行代码的抽象、封装和复用。

我们发现在消息表中没有必要注明每个具体的组件,而是通过小任务一,小任务二…的方式设计数据表,具体的业务逻辑由具体的业务代码实现,而具体的业务代码通过继承抽象的类,来实现对数据库的控制。

设计一个抽象的类,这个类应当实现对数据表的处理,并提供一个接口,让具体的业务代码实现这个接口。

3、对消息表的操作

首先创建数据库和数据表,表中字段包括业务相关字段(消息类型代码,关联业务信息、代码等等),小任务的状态、上一次成功失败时间、重试次数(暂定五个小任务,提高适用性)。

其次创建一个微服务模块,添加MyBatis-Plus依赖和配置

<!-- MySQL 驱动 -->
<dependency>
	<groupId>mysql</groupId>
	<artifactId>mysql-connector-java</artifactId>
	<scope>runtime</scope>
</dependency>
<!-- mybatis plus的依赖 -->
<dependency>
	<groupId>com.baomidou</groupId>
	<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>

spring:
  application:
    name: service
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/table?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: xxxx
    password: xxxx

实现DAO和service层开发
在这里插入图片描述

在实现流程控制的抽象类之前我们要思考一个问题,具体的业务代码在实现了任务后,如何开启调用任务的执行?

首先肯定不是在发布课程的方法中直接调用方法,这是同步调用,并且只能执行一次,不适合AP思想的分布式事务。对于数据同步实时性要求不高的技术解决方案有很多,例如MQ、Canal、Logstash、任务调度等等

我们可以在插入数据表后,向消息队列添加一条消息,消费者收到消息后检查数据库是否存在对应记录,没有就执行一次任务,如果任务执行失败,就像消息队列添加一条消息。

我们也可以使用中间件canal,解决耦合性问题,canal通过模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 请求,得到 binglog 日志,

我们也可以通过任务调度的方式执行任务,在任务流程代码中查询数据库,根据数据库中的记录执行任务。

这里我们采用任务调度的方式执行任务。

4、任务调度

任务调度是指对计算任务进行合理安排和调度的过程。分布式任务调度是指在分布式系统中,将任务分割成若干份,根据调度规则交由不同的实例并行执行。

XXL-JOB,是一个轻量级分布式任务调度平台,开发迅速,学习简单,易扩展。包含调度中心(管理执行器、任务、日志,监控运维),执行器(注册服务,执行服务,执行结果上报,记录日志)和任务(具体的业务代码)。

如何确保任务不重复执行?任务调度采用分片广播的方式,查询数据表得到任务的id(自增id),模上分片总数,如果等于当前执行器的任务号,就执行该任务,否则不执行,对于任务分配超出执行器执行能力的情况,通过合理设置任务广播频率,以及设置任务拒绝策略为丢弃任务来确保没有任务被重复执行。

执行流程:

启动XXL-JOB调度中心,创建执行器和任务,任务执行实现通过cron表达式设置为每小时一次,设置分片广播和丢弃策略。

在课程发布微服务中添加XXL-JOB的依赖、配置文件和配置类,创建任务方法,在该方法前添加注解@XxlJob(“JobHandler”)

@XxlJob("JobHandler")
public void coursePublishJobHandler() throws Exception {
	// 分片参数
	int shardIndex = XxlJobHelper.getShardIndex();
	int shardTotal = XxlJobHelper.getShardTotal();
	// 在下一节中实现process,这里仅是测试方法
	System.out.println("XXL-JOB任务调度测试成功");
	// process(......);
}

启动微服务,可以在物理地址中看到一个实例,执行一次任务,在输出窗口看到
在这里插入图片描述

我们可以发现,任务的触发是根据创建任务时设置的执行时间来完成的,但是从用户的角度出发,有些用户允许在一段时间以内完成数据同步,例如一到两个工作日内完成,但是有的用户希望在发布课程后能及时的完成数据同步,例如一小时内完成数据同步,这就需要在代码端对xxl-job的控制中心进行通知,xxl-job也提供了这个接口

打开从github下载的xxl-job项目,在JobInfoController的接口中有体现,通过调用start方法,传入任务的id,来触发一次任务。

下面是代码逻辑


	@Override
	public ReturnT<String> start(int id) {
		XxlJobInfo xxlJobInfo = xxlJobInfoDao.loadById(id);

		// valid
		ScheduleTypeEnum scheduleTypeEnum = ScheduleTypeEnum.match(xxlJobInfo.getScheduleType(), ScheduleTypeEnum.NONE);
		if (ScheduleTypeEnum.NONE == scheduleTypeEnum) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type_none_limit_start")) );
		}

		// next trigger time (5s后生效,避开预读周期)
		long nextTriggerTime = 0;
		try {
			Date nextValidTime = JobScheduleHelper.generateNextValidTime(xxlJobInfo, new Date(System.currentTimeMillis() + JobScheduleHelper.PRE_READ_MS));
			if (nextValidTime == null) {
				return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
			}
			nextTriggerTime = nextValidTime.getTime();
		} catch (Exception e) {
			logger.error(e.getMessage(), e);
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
		}

		xxlJobInfo.setTriggerStatus(1);
		xxlJobInfo.setTriggerLastTime(0);
		xxlJobInfo.setTriggerNextTime(nextTriggerTime);

		xxlJobInfo.setUpdateTime(new Date());
		xxlJobInfoDao.update(xxlJobInfo);
		return ReturnT.SUCCESS;
	}

上述代码逻辑:
首先根据传入的任务id获取任务,判断任务的触发方式,如果为空,就报错,返回异常码500。
然后设置任务下一次的触发时间,为当前时间的5s后,更新触发状态,返回success。

我们可以在课程发布的逻辑中中调用这个方法,来实现及时同步课程内容。

注意,需要在调用方法头添加注解@PermissionLimit(limit = false),来绕开登录验证,但是这增加了代码的不安全性,需要对这种权限的使用进行限制。


	@RequestMapping("/startJob")
	@ResponseBody
	@PermissionLimit(limit = false)
	public ReturnT<String> startJob(@RequestBody XxlJobInfo jobInfo) {
		return xxlJobService.start(jobInfo.getId());
	}

5、任务流程控制的抽象类

接下来实现抽象类,在这个类中需要提供任务执行的流程,而非具体的代码,提供一个抽象方法,业务代码通过实现这个抽象方法,在这个方法中实现具体的业务执行代码

此时我们已经得到分片总数和分片号,通过查询数据库中记录,自增id模上分片总数等于分片号的方式判断是否由当前执行实例实行

MyBatis-Plus没有提供查询方法,在Mapper中进行实现

@Select("SELECT t.* FROM mq_message t WHERE t.id % #{shardTotal} = #{shardindex} and t.state='0' and t.message_type=#{messageType} limit #{count}")
List<MqMessage> selectListByShardIndex(@Param("shardTotal") int shardTotal, @Param("shardindex") int shardindex, @Param("messageType") String messageType,@Param("count") int count);

在得到消息记录后,这是一个列表的形式,我们开启线程池,使用newFixedThreadPool,线程总数就是任务数,没有临时线程,使用CountDownLatch控制线程完成情况,每个线程中执行process方法,process是一个抽象方法,由具体的实现类进行实现,返回一个boolean变量,表示任务是否完成,并记录日志。


public abstract class MessageProcessAbstract {

    @Autowired
    MqMessageService mqMessageService;

    /**
     * @param mqMessage 执行任务内容
     * @return boolean true:处理成功,false处理失败
     * @description 任务处理
     * @author zkp15
     * @date 2023/9/21 19:47
     */
    public abstract boolean execute(MqMessage mqMessage);

    /**
     * @description 扫描消息表多线程执行任务
     * @param shardIndex 分片序号
     * @param shardTotal 分片总数
     * @param messageType  消息类型
     * @param count  一次取出任务总数
     * @param timeout 预估任务执行时间,到此时间如果任务还没有结束则强制结束 单位秒
     * @return void
     * @author zkp15
     * @date 2023/9/21 20:35
    */
    public void process(int shardIndex, int shardTotal,  String messageType,int count,long timeout) {

        try {
            //扫描消息表获取任务清单
            List<MqMessage> messageList = mqMessageService.getMessageList(shardIndex, shardTotal,messageType, count);
            //任务个数
            int size = messageList.size();
            if(size<=0){
                return ;
            }

            //创建线程池
            ExecutorService threadPool = Executors.newFixedThreadPool(size);
            //计数器
            CountDownLatch countDownLatch = new CountDownLatch(size);
            messageList.forEach(message -> {
                threadPool.execute(() -> {
                    //处理任务
                    try {
                        boolean result = execute(message);
                        if(result){
                            //更新任务状态,删除消息表记录,添加到历史表
                            int completed = mqMessageService.completed(message.getId());
                            if (completed>0){
                                log.debug("任务执行成功:{}",message);
                            }else{
                                log.debug("任务执行失败:{}",message);
                            }
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                        log.debug("任务出现异常:{},任务:{}",e.getMessage(),message);
                    }
                    //计数
                    countDownLatch.countDown();

                });
            });

            //等待,给一个充裕的超时时间,防止无限等待,到达超时时间还没有处理完成则结束任务
            countDownLatch.await(timeout,TimeUnit.SECONDS);
        } catch (InterruptedException e) {
           e.printStackTrace();
        }
    }
}


6、课程发布的实现类

在这个实现类中,继承上一节的抽象类,以及抽象方法execute,在execute中,分别执行数据库操作,建立缓存,上传分布式文件系统,建立搜索索引。


@Component
public class CoursePublishTask extends MessageProcessAbstract {

	......

    //任务调度入口
    @XxlJob("CoursePublishJobHandler")
    public void coursePublishJobHandler() throws Exception {
        // 分片参数
        int shardIndex = XxlJobHelper.getShardIndex();
        int shardTotal = XxlJobHelper.getShardTotal();
        log.debug("shardIndex="+shardIndex+",shardTotal="+shardTotal);
        //参数:分片序号、分片总数、消息类型、一次最多取到的任务数量、一次任务调度执行的超时时间
        process(shardIndex,shardTotal,"course_publish",30,60);
    }

    //课程发布任务处理
    @Override
    public boolean execute(MqMessage mqMessage) {
        //获取消息相关的业务信息
        String businessKey1 = mqMessage.getBusinessKey1();
        long courseId = Integer.parseInt(businessKey1);
        // 课程发布表
        saveCourseToMQ(mqMessage, courseId);
        // 课程缓存
        saveCourseCache(mqMessage, courseId);
        // 课程静态化
        generateCourseHtml(mqMessage, courseId);
        // 课程索引
        saveCourseIndex(mqMessage, courseId);
        return true;
    }

	......

}

7、总结

本文在实际开发业务场景的基础上,给出了一种遵循AP思想的分布式事务控制方案,通过本地消息表+任务调度的方式实现。

项目亮点有:

  • 本地消息表通过任务123代替具体的任务,结合流程控制抽象类,只给出流程控制的代码,具体的业务实现由具体的实现类完成,从而实现解耦合,提高代码复用。

  • 任务流程控制中开启多实例和多线程,并行高效的执行任务。

  • 使用任务调度XXL-JOB进行任务执行,采用分片广播的方式,保证了任务执行的幂等性。其中控制中心提供了两种任务调度的规则,按照Cron的定时执行策略,和非登录任务执行通知的及时执行策略,为用户提供了多样化的体验

由于篇幅原因,四个小任务的实现,数据库、缓存、文件系统、搜索系统的数据同步,我们放在下一篇继续论述

  • 26
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值