關於 es 資料同步的一次效能最佳化實踐

bauul發表於2020-09-07

緣由

開發同學有個資料同步的需求,正好我這邊有閒,就接過來做了,
主要是將 mysql 的幾張表,同步至 elasticsearch,全量同步

資料量

測試環境:4600 條
生產環境:3000 萬條

大套路

首先,es 叢集環境搭建
然後,編寫 java 程式碼,完成同步功能

程式碼設計套路

因為當前幾張表都是基於 accountId 的關聯的,而 accountId 是自增的
主要分以下幾步:

  1. 查出庫表中最小的 id 主鍵
  2. 查出庫表中最大的 id 主鍵
  3. 按每頁 1000 條,算出總頁數
  4. 按批查詢 mysql,一批 10 頁
  5. 將查到的資料轉化為 es 物件,使用執行緒池將資料插入到 es

叢集環境搭建

這裡借鑑了搜尋和其他業務線的開發同學搭建的方式,完成搭建,可另寫帖子完成

第一版 60 秒

@Override
public void memberAccountInfoSync() {
    int pageSize = 1000;
    long channelMinId = queryChannelMinId();
    long channelMaxId = queryChannelMaxId();
    int totalCount = queryTotalCount(channelMinId, channelMaxId);
    PageTaskUtil.handlePageTaskForBizInvoke(new PageTaskUtil.PageTaskForBizInvoke<ESMemberAccountInfo>() {

        @Override
        public List queryPageData(int pageNo, int pageSize) {
            long pageStartId = getPageStartId(channelMinId, pageSize, pageNo);
            long pageEndId = getPageEndId(pageStartId, pageSize);
            List<CpsBaseAccountInfo> cpsBaseAccountInfoList = cpsBaseAccountService.listAccountIdsWithInitialValue((byte) CURR_CHANNEL.getCode(), pageStartId, pageEndId);
            List<Long> accountIdList = cpsBaseAccountInfoList.stream().map(CpsBaseAccountInfo::getAccountId).collect(Collectors.toList());
            // 效能最佳化,如果accountIdList為空,則進入下一次迴圈
            if (CollectionUtils.isEmpty(accountIdList)) {
                return new ArrayList(0);
            }
            // 撈出其他表資料
            List<ESMemberAccountInfo> esMemberAccountInfoList = new ArrayList<>();

            cpsBaseAccountInfoList.forEach(one -> {
                long accountId = one.getAccountId();
                ESMemberAccountInfo e = new ESMemberAccountInfo();

                // 合併資料到es物件

                esMemberAccountInfoList.add(e);
            });

            return esMemberAccountInfoList;
        }

        @Override
        public void pageTask(List<ESMemberAccountInfo> pageList, Object res) {
            // 如果集合為空,直接返回
            if (CollectionUtils.isEmpty(pageList)) {
                return;
            }

            CountDownLatch countDownLatch = new CountDownLatch(pageList.size());
            for (ESMemberAccountInfo esMemberAccountInfo : pageList) {
                final Long accountId = esMemberAccountInfo.getAccountId();
                esSyncThreadPoolTaskExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // 查詢es中是否存在該使用者
                            if (!isExistAccountInES(esMemberAccountInfo)) {
                                insertAccountInfoToES(esMemberAccountInfo);
                            }
                        } catch (Exception e) {
                            log.error("es accountInfo sync error, accountId: {}", accountId);
                        } finally {
                            countDownLatch.countDown();
                        }
                    }
                });
            }
            try {
                countDownLatch.await();
            } catch (Exception e) {
                log.error("es sync error" + e.getMessage());
            }
        }
    }, totalCount, pageSize, 10, "esSync", null);
}

第一版,因為不知道 es 有批次插入的功能,是一條一條的往 es 中插入資料的,然後
在插入操作之前還查了一下 es 中是否存在該資料,
不存在則插入,整個功能完成後,測試環境用時近 60 秒

第二版 30 秒

private void insertAccountInfoToESBatch(List<ESMemberAccountInfo> esMemberAccountInfoList) throws Exception  {
    BulkRequest bulkRequest = new BulkRequest();
    for (ESMemberAccountInfo esMemberAccountInfo: esMemberAccountInfoList) {
        UpdateRequest updateRequest = new UpdateRequest(ESIndexEnum.ES_INDEX_BASE_ACCOUNT_INFO.getIndex(), ESIndexEnum.ES_INDEX_BASE_ACCOUNT_INFO.getType(),
                esMemberAccountInfo.getAccountId().toString() + esMemberAccountInfo.getBizChannel().toString());
        updateRequest.doc(JSON.parseObject(JSON.toJSONString(esMemberAccountInfo)));
        updateRequest.docAsUpsert(true);
        bulkRequest.add(updateRequest);
    }
    client.getRhlClient().bulk(bulkRequest, RequestOptions.DEFAULT);
}

第二版,改為批次插入 es,並且移除查詢請求,測試環境用時近 30 秒

第三版 6 秒

@Override
public void memberAccountInfoSync() {
    int pageSize = 1000;
    long channelMinId = queryChannelMinId();
    long channelMaxId = queryChannelMaxId();
    log.info("es全量同步任務,channelMinId is: {}, channelMaxId is: {}", channelMinId, channelMaxId);
    int totalCount = queryTotalCount(channelMinId, channelMaxId);

    int pageCount = totalCount % pageSize == 0 ? totalCount / pageSize : totalCount / pageSize + 1;
    log.info("es全量同步任務,pageCount is: {}", pageCount);

    // druid資料庫連線池的大小
    final int dbConnection = 10;
    final int repeatTimes = pageCount % dbConnection == 0 ? pageCount / dbConnection : pageCount / dbConnection + 1;
    log.info("es全量同步任務,repeatTimes is: {}", repeatTimes);

    for (int i=1; i<repeatTimes+1; i++) {

        CountDownLatch countDownLatch = new CountDownLatch(dbConnection);
        for (int j=1; j<dbConnection+1; j++) {
            log.info("es全量同步任務,channelMinId is: {}", channelMinId);
            Runnable r = syncTask(channelMinId, pageSize, j + (i-1)*dbConnection, countDownLatch);
            esSyncThreadPoolTaskExecutor.execute(r);
        }

        try {
            countDownLatch.await();
        } catch (Exception e) {
            log.error("es全量同步任務 countDownLatch.await() error" + e.getMessage());
        }
    }
}

在第二版的基礎上,對查詢資料的動作也進行非同步批次處理,所以整個不使用開發提供的幫助類了,自己寫,但保留了批次查詢處理的思想,測試環境用時近 6 秒

第四版 3 秒

@Override
public void memberAccountInfoSync() {
    int pageSize = 2000;
    long channelMinId = queryChannelMinId();
    long channelMaxId = queryChannelMaxId();
    int totalCount = queryTotalCount(channelMinId, channelMaxId);

    int pageCount = totalCount % pageSize == 0 ? totalCount / pageSize : totalCount / pageSize + 1;
    log.info("es全量同步任務,pageCount: {}", pageCount);

    CountDownLatch c = new CountDownLatch(pageCount);
    // druid資料庫連線池的大小,不要超過最大值40
    esSyncThreadPoolTaskExecutor.setCorePoolSize(30);
    esSyncThreadPoolTaskExecutor.setMaxPoolSize(30);

    for (int i=0; i<pageCount; i++) {
        try {
            esSyncThreadPoolTaskExecutor.execute(syncTask(CURR_CHANNEL, channelMinId, channelMaxId, pageSize, i, c));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    try {
        c.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

因為第三版仍然使用了批次處理的思想,在同一批任務中,是以最後一個任務跑完了時間為準的,所以會導致一些效能浪費,
在這裡直接透過執行緒池本身提供的佇列功能,透過引數控制執行緒池大小,避免將資料庫連線池耗盡
這裡需要注意的是,任務數最好要小於執行緒池的佇列大小,避免生產者過快,將執行緒池佇列塞滿導致異常
同時,每頁數量不能過大,這可能導致在寫 es 時,操作時間過長,導致超過 es 客戶端預設的最大連線時間

其他補充

實際在上到預發環境的時候,測試了下,寫 3000 萬的資料,因為網路的關係,導致需要 15 個小時才能跑完;
最佳化方法是將應用與 es 布在同一個區域網裡面,結果是 17 分鐘完成

總結

主要使用批次操作,執行緒池非同步操作完成了單機最佳化

問題

  1. 假如要再一步提升,使用分散式任務,如何處理

相關文章