[数据同步]开发笔记

需求场景

同步任务:从一张大表同步所有数据到一张分库分表的表中。

一、历史数据

1、主流程编写规范

要点:逻辑清晰,直接在一个主流程方法里要能看到完整的步骤以及异常怎么处理。

背景:大量数据一次性导入表,考虑用游标查询的方式扫表,解决普通分页查带来的深分页问题。

同时为避免内存不够,直接在每个循环里完成数据查询和插入。

/** 核心主流程*/
public void processEvent()  {
  	//参数校验
  	//初始化游标查询结果
  	CursorPageResult<EventVO, Long> cursorPageResult = new CursorPageResult<>();
    cursorPageResult.setIsLastPage(false);
    cursorPageResult.setCursor(initCursor);
  	while (!cursorPageResult.getIsLastPage()) {
      	try {
            // 查询来源数据
            Response<CursorPageResult<EventVO, Long>> response = eventClient.cursorPageByTime(request);
            //控制查询频率(因为线上对客业务也查的同一Doris表),在Apollo配置变量queryFrequency,表示间隔多少ms再执行query
            Thread.sleep(queryFrequency);
            // 数据模型转换
          	cursorPageResult = response.getValue();
            List<TableDO> tableDOS = this.convertDO(cursorPageResult.getData());
            // 插入mysql
            this.insertTableDOS(tableDOS);
            request.setCursor(cursorPageResult.getCursor());
        } catch (Exception e) {
            // 标记任务为执行异常
            log.error("processEvent page error.xx", e);
            break;
        }
    }
}

2、异常流程处理

思路分析

从上面主流程来看,try里面主要是查询数据(rpc)和插入数据(db)可能会发生异常。

  • 若查询异常,当前页的cursor游标就拿不到,只能中断循环;

  • 若插入异常,当前页的cursor游标仍可拿到,可继续循环。

但可以保存上一页的cursor游标,不管查询还是插入异常,都记录一个异常游标到redis缓存。

同时再用一个状态缓存判断程序执行逻辑。

比如设置一个 job 状态缓存 :key=JOB_STATUS_${bizcode},value= 1=执行中 2=执行异常 3=完成

设置一个job异常缓存:key=JOB_INTERUPTED_PAGEINFO_${bizcode},value=lastcursor

异常处理步骤

1、循环开始前先判断当前状态:

  • 为1,抛异常:不允许重复执行。(避免插入了重复数据,使数据统计不正确)

  • 为3,抛异常:不允许重复执行。

  • 为2,续跑:获取异常缓存的值,放进每页请求参数里。

2、正常执行循环

3、若循环中出现异常,设置job状态缓存为执行异常,设置job异常页码缓存的值

4、若循环结束,设置job状态缓存为执行完成。

/** 核心主流程*/
public void processEvent()  {
  	//参数校验
  	//可执行条件判断
  	DateRequest request = this.canStart(bizcode);
  	Long    initCursor  = request.getCursor();
  	//初始化游标查询结果
  	CursorPageResult<EventVO, Long> cursorPageResult = new CursorPageResult<>();
    cursorPageResult.setIsLastPage(false);
    cursorPageResult.setCursor(initCursor);
  	Boolean isFinished     = Boolean.TRUE;
  	while (!cursorPageResult.getIsLastPage()) {
      	try {
            doSomething();
            request.setCursor(cursorPageResult.getCursor());
        } catch (Exception e) {
          	isFinished     = Boolean.FALSE;
          	Long    lastCursor  = cursorPageResult.getCursor();
          	doJobExeError(lastCursor,bizcode);
            // 标记任务为执行异常
            log.error("processEvent page error.xx", e);
            break;
        }
    }
  	if (isFinished){
      	doJobExeFinished(bizcode);
    }
}
并发控制

这里千万要注意缓存的使用。过期时间是否设置合理、程序是否允许多次执行、缓存的key删除策略、缓存的key范围都需考虑。

/** 循环前的执行状态判断*/
private DateRequest canStart(String bizCode) {
  			/* 任务启动参数实例初始化*/
        DateRequest request = DateRequest().pageSize(xx).build()...;
  			/* job 状态缓存
           key = JOB_STATUS_${bizCode}
           value= 1=执行中 2=执行异常 3=完成
        */
        String jobStatusCacheKey   = "JOB_STATUS_" + bizCode;
        String jobStatusCacheValue = "1";
  			/**
  			* job 状态加锁这里有个并发控制,要用setNx	
  			*/
        boolean locked = cacheService.setNX(jobStatusCacheKey, jobStatusCacheValue);
        if (locked) {
            request.setLastRecordCount(0);
            request.setCriticalCursor(0L);
            return request;
        }
        String jobStatus = (String)cacheService.get(jobStatusCacheKey);
        /* 任务执行完成无需执行*/
        if ("1".equals(jobStatus)) {
            throw new RuntimeException("任务正在执行中,请勿重复执行," + bizCode);
        }
        /* 续跑*/
        if ("2".equals(jobStatus)) {
          /* 错误页码缓存
               key = JOB_INTERUPTED_PAGEINFO_${bizCode}
               value= cursor
             */
            String jobInterruptedPageInfoKey   = "JOB_INTERUPTED_PAGEINFO_" + bizCode;
            String jobInterruptedPageInfoValue = (String)cacheService.get(jobInterruptedPageInfoKey);
            if (org.apache.commons.lang3.StringUtils.isBlank(jobInterruptedPageInfoValue)){
                throw new RuntimeException("数据异常," + bizCode);
            }
            String cursor    = jobInterruptedPageInfoValue;
            request.setCursor(Long.valueOf(cursor));
            return request;
        }
        if ("3".equals(jobStatus)) {
            throw new RuntimeException("任务已执行完一次,请勿重复执行。" + bizCode);
        }
        return null;
}
/** 循环中出现异常处理*/
private void doJobExeError(Long cursor, String bizCode) {
    /* job 状态缓存,设置为执行异常
        key = JOB_STATUS_${bizCode}
        value= 1=执行中 2=执行异常 3=完成
    */
    String jobStatusCacheKey = "JOB_STATUS_" + bizCode;
    cacheService.set(jobStatusCacheKey, "2");

    /* job 异常页码缓存
        key = JOB_INTERUPTED_PAGEINFO_bizCode
        value= cursor
    */
    String jobInteruptedPageInfoKey   = "JOB_INTERUPTED_PAGEINFO_" + bizCode;
    String jobInteruptedPageInfoValue = cursor;
    cacheService.set(jobInteruptedPageInfoKey, jobInteruptedPageInfoValue);
}
/** 循环正常结束处理*/
private void doJobExeFinished(String bizCode) {
    /* job 状态缓存,设置为执行异常
        key = JOB_STATUS_${bizCode}
        value= 1=执行中 2=执行异常 3=完成
    */
    String jobStatusCacheKey = "JOB_STATUS_" + bizCode;
    cacheService.set(jobStatusCacheKey, "3");
}

3、分库分表路由配置

shardingjdbc笔记

4、计数器

用来统计当前已插入mysql表的条数。

//计数器
AtomicLong currentCount = new AtomicLong(0);
int pageNo = 0;
    while (!cursorPageResult.getIsLastPage()) {
      Assert.isTrue(tableDAO.insert(tableDO) == 1,
           "insert tableDO num error, messageId is :" + tableDO.getMessageId());                     
      currentCount[0] = currentCount.addAndGet(1);
      pageNo++;
      log.info("processEvent 第 {} 页 insert count :{}.", pageNo, currentCount);
    }            

5、批量数据insert技巧

问题:直接用mybatis-plus的batchInsert容易报唯一约束错误(Duplicate entry xx for key xxIndexName

解决:单条数据insert,同时做好insert控制频率。避免因同步job影响线上业务(操作同一数据源)。

//按分库分片键和分表分片键分组之后
transactionTemplate.execute(transactionStatus -> {
    try {
        for (TableDO tableDO : tableList) {
            //为解决不同页的数据重复导致的唯一约束问题,直接单条数据insert
            try {
                Assert.isTrue(tableDAO.insert(tableDO) == 1,
               "insert tableDO num error, messageId is :" + tableDO.getMessageId());
                //insert控频,在Apollo配置变量insertFrequency,表示间隔多少ms再执行insert
                Thread.sleep(insertFrequency);
            } catch (DuplicateKeyException e) {
                //跳过唯一索引冲突
              	log.warn("tableDO data has exist. uuid:[{}]", uuid, e);
            }
        }
    } catch (Exception e) {
        transactionStatus.setRollbackOnly();
        log.error("tableDO.insert batch error", e);
    }
    return true;
});

6、线程池的使用

这里主要针对for循环中使用线程池。

@RestController
@RequestMapping(value = "/api/biz/scheduler")
@Slf4j
public class DataProcessTask {
  	/**
  	* 根据使用场景,比如数据处理业务尽量少占资源,核心线程数2个够了,最大线程数3个够了。
  	* 合理配置线程池拒绝策略。线程池默认是丢弃,如果业务上不能允许丢弃,就需要修改。
  	*/
  	private ThreadPoolExecutor threadPoolExecutorProcessDetail = new ThreadPoolExecutor(2,3,30,TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(50),
            new ThreadPoolExecutor.CallerRunsPolicy());

    @PostMapping(value = "/process")
    public void execute() {
        log.info("DataProcessTask execute begin.");
        new Thread(() -> {
            try {
              	// 走sharding强制路由策略扫表(路由策略不展开,见上文1.3)
                for (int db = 1; db <= 2; db++) {
                    for (int table = 1; table <= 64; table++) {
                        DateRequest request = DateRequest().build()...;
                        threadPoolExecutorProcessDetail.execute(() ->
                            this.processDetail(request, db, table));
                    }
                }
            } catch (Exception e) {
                log.error("DataProcessTask error.", e);
            }
        }, "Thread-DataProcess").start();
        log.info("DataProcessTask execute end.");
    }
}

二、实时数据

7、Kafka发送消费

/** 发消息*/
@Slf4j
@Component
public class KafkaUtils implements CommandLineRunner {
  	@Autowired
    private KafkaProducerFactory kafkaProducerFactory;

    private KafkaProducer<String, String> kafkaProducer;

    @Override
    public void run(String... args) throws Exception {
        kafkaProducer = kafkaProducerFactory.newProducer();
    }

    @PreDestroy
    public void close() {
        if (kafkaProducer != null) {
            kafkaProducer.close();
        }
    }
  	
  	/** 发送方可靠性处理:使用带回调的send */
  	public void produceMessage(String topic, String key, String value) {
        log.info("start send message to kafka. topic:[{}], uuId:[{}], messageBody:[{}]", topic, key, value);
        ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, value);
        try {
            kafkaProducer.send(record, (metadata, e) -> {
                if (e != null) {
                    log.error("send message to kafka failed. topic:[{}], messageId:[{}]", topic, key);
                } else {
                    log.info("send message to kafka succeed. topic:[{}], messageId:[{}], partition:[{}], offset:[{}]",
                            topic, key, metadata.partition(), metadata.offset());
                }
            });
        } catch (Exception e) {
            log.error("send message to kafka failed. topic:[{}], messageId:[{}] ", topic, key, e);
        }
    }
}
/** 消费消息*/
@Slf4j
@Component
public class KafkaListener implements CommandLineRunner {
    private KafkaConsumer<String, String> kafkaConsumer;
    private static final Integer KAFKA_CONSUMER_POLL_DURATION = 500;
  	/** topicName配置在Apollo */
    @Value("${biz.topic:xx}")
    private String topicName;
    @Autowired
    private KafkaConsumerFactory kafkaConsumerFactory;
    @PreDestroy
    public void close() {
        if (kafkaConsumer != null) {
            kafkaConsumer.close();
        }
    }

    @Override
    public void run(String... args) throws Exception {
        kafkaConsumer = kafkaConsumerFactory.newConsumer(new StringDeserializer(), new StringDeserializer());
        consume();
    }
  
  	private void consume() {
        new Thread(() -> {
            log.info("开始监听kafka消息!");
            kafkaConsumer.subscribe(Lists.newArrayList(topicName));
            ConsumerRecords<String, String> consumerRecords;
            while (true) {
                try {
                    Duration duration = Duration.ofMillis(KAFKA_CONSUMER_POLL_DURATION);
                    consumerRecords = kafkaConsumer.poll(duration);
                    // 消费kafka
                    for (ConsumerRecord consumerRecord : consumerRecords) {
                        Object key = consumerRecord.key(); //键
                        Object value = consumerRecord.value();//值
                        /* 单条数据业务处理 */
                      	doSomething();
                    }
                } catch (Exception e) {
                    log.error("consumer message error", e);
                }
            }
        }).start();
    }

8、jdk事件监听

这里的一个设计是将自己领域内多个业务事件用一个事件发布监听模型处理。

可选择jdk事件发布监听模型或Spring事件监听机制完成。

/**
 * 业务生命周期事件发布者
 */
@Component
@Slf4j
public class BizLifeCycleEventPublisher extends Observable implements InitializingBean {

    @Autowired
    private BizLifeCycleEventListener bizLifeCycleEventObserver;

    @Override
    public void afterPropertiesSet() throws Exception {
        this.addObserver(bizLifeCycleEventObserver);
    }

    /**
     * 功能描述:事件通知。
     * 声明:异常由【调用事件发布者处】进行捕获
     */
    public void notify(StatisticsEventMessage statisticsEventMessage) {
        if (statisticsEventMessage == null){return;}
        super.setChanged();
        // 功能描述:内部是通过调用'订阅者'的update方法来进行通知的
        super.notifyObservers(statisticsEventMessage);
    }
}
/**
 * 事件监听者
 * 职责:处理BizLifeCycleEventPublisher 发布出来的StatisticsEventMessage事件
 */
@Component
@Slf4j
public class BizLifeCycleEventListener implements Observer {
    /**
     *  功能描述:订阅来自发布者的事件通知.
     *  声明:异常由【调用事件发布者处】进行捕获
     */
    @Override
    public void update(Observable publisher, Object event) {
        if (publisher instanceof BizLifeCycleEventPublisher && event instanceof StatisticsEventMessage) {
          	doSomething();
        }
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值