千萬級數據量的插入操作(MYSQL)

前幾天因爲公司業務遷移需要,需要從數倉同步一張大表,數據總量大概三千多萬,接近四千萬的樣子,當遇到這種數據量的時候,綜合考慮之後,當前比較流行的框架都不能滿足於生產需求,使用框架對性能的損耗過於嚴重,所以有了以下千萬級數據量的插入方案。

當數據量達到一定規模的時候,假設一個語句爲這樣,還比較小的,只有三個字段。

INSERT INTO user_operation_min_temp(observer_id, access_id, access_name) VALUES (?, ?, ?)

如果使用單線程,一次INSET一條的話,那麼要插入千萬次,並且提交千萬次,衆所周知,數據庫有一定的瓶頸,大量的插入提交操作會嚴重損耗系統性能。

所以需要使用批量插入,Mybatis的批量插入雖然是批量插入,但那只是業務層面的批量插入,真正執行的時候,還是會幫你分解成單條插入,所以必須要放棄orm框架。而使用原生的插入

批量插入的代碼如下

/**
     * 數據存入新的臨時表
     * @param list
     */
    private void batch(List<UserOperation> list) {
        String sql = "INSERT INTO user_operation_min_temp(observer_id, access_id, access_name) VALUES (?, ?, ?) ";
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        try {
            connection = dataSource.getConnection();
            connection.setAutoCommit(false);
            preparedStatement = connection.prepareStatement(sql);
            for (UserOperation obj : list) {
                preparedStatement.setInt(1, obj.getObserverId());
                preparedStatement.setInt(2, obj.getAccessId());
                preparedStatement.setString(3, obj.getAccessName());
                preparedStatement.addBatch();
            }
            preparedStatement.executeBatch();
            connection.commit();
        } catch (Exception e) {

        } finally {
            if (preparedStatement != null) {
                try {
                    preparedStatement.close();
                    preparedStatement = null;
                } catch (Exception e) {

                }
            }
            if (connection != null) {
                try {
                    connection.close();
                    connection = null;
                } catch (Exception e) {

                }
            }
        }
    }

那麼問題來了,我是否可以把三千多萬條list直接傳入到這個batch方法呢,答案是不是不可以,但是卻是不太好,第一就是你的三千多萬條list這個對象或者數組一直要等待插入完成,系統中沒有地方使用纔可以進行釋放內存,對業務或者服務器的負荷比較大,並且容易造成OOM,也就是內存溢出問題。

所以可以利用多線程對list進行分割,比如說3000萬的話,可以按照每100萬一個批次,也就是30個批次,使用多線程提交這三十個批次。具體的可以按照自己的情況來分割。

list分割可以使用集合的工具類

List<List<UserOperation>> batch = Lists.partition(allList, 30);

需要引入guava依賴

<dependency>
	<groupId>com.google.guava</groupId>
	<artifactId>guava</artifactId>
	<version>24.1-jre</version>
</dependency>

然後

batch.parallelStream().forEach(list -> {
      //執行插入
      batch(list);
});

然後,你以爲這樣就可以了嗎,現實會告訴你,我不要面子的嗎、這樣缺失不行,第一,你的三千萬條數據的對象還是三千萬,還多分割了30個批次。內存不要錢嗎。所以在讀取數據的時候就要烤爐分割,而不會一下子把三千萬的數據一次性拿到內存來,然後一批處理完成之後及時釋放掉。

改造之後的方案。

1、讀取數據,因爲我這裏讀取數據是使用的接口進行調用。可以根據自己的業務進行改造

public void sync() throws SQLException {
        long startTime = System.currentTimeMillis();
        //獲取所有用戶
        List<User> users = syncService.all();
        if(users == null || users.size() == 0) {
            return;
        }
        //複製表結構
        syncService.like();
        //按照200個人一個批次進行分
        List<List<User>> batch = Lists.partition(users, 200);
        //併發處理 數據存入新表
        batch.parallelStream().forEach(list -> {
            //使用線程安全的集合
            List<UserOperation> userOperations = Collections.synchronizedList(new ArrayList<>());
            for(User user : list) {
                Integer obId = user.getObId();
                if(obId == null || "".equalsIgnoreCase(obId.toString())) {
                    continue;
                }
                //調用接口獲取每個用戶的權限
                List<UserOperation> temp = syncService.syncUserPermission(obId.toString());
                //當到線程的list中
                userOperations.addAll(temp);
                //logger.info("加入員工 obId:【{}】,姓名:【{}】", obId, user.getName());
            }
            try {
                //執行保存邏輯
                syncService.save(userOperations);
                logger.info("線程批次存入數據庫: 當前線程:{}", Thread.currentThread());
            } catch (SQLException e) {
                e.printStackTrace();
            } finally {
                //執行釋放
                userOperations = null;
            }
        });
        //數據寫完之後,進行臨時表的切換
        //一個事物。
        //1、刪除舊錶
        //2、臨時表重命名爲舊錶的名字
        syncService.transactional();
        long endTime = System.currentTimeMillis();
        logger.info("耗時:{} ms", endTime - startTime);
    }

說明一下,這裏不使用清空表再進行插入是因爲避免對業務系統的使用造成影響,保證這些操作是在一個事物中發生。

調用全椒縣接口的代碼

public List<UserOperation> syncUserPermission(String obId) {
        Map<String, Object> params = ImmutableMap.of("data", ImmutableMap.of("observerId", obId));
        ResponseEntity<String> content = null;
        try {
            content = new RetryTemplate() {
                @Override
                protected ResponseEntity<String> doService() {
                    return RestTemplateUtils.post("", params, String.class);
                }
            }.setRetryTime(3).setSleepTime(1000).execute();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (content == null) {
            return new ArrayList<>();
        }
        String body = content.getBody();
        JSONArray jsonArray = JSONArray.parseArray(body);
        if (jsonArray == null || jsonArray.size() == 0) {
            return new ArrayList<>();
        }
        List<UserOperation> operations = jsonArray.stream().map(p -> {
            JSONObject json = (JSONObject) p;
            UserOperation userOperation = new UserOperation();
            userOperation.setObserverId(Integer.parseInt(obId));
            userOperation.setAccessId(json.getInteger("iD"));
            userOperation.setAccessName(json.getString("name"));
            userOperation.setActive(1);
            return userOperation;
        }).collect(Collectors.toList());
        return operations;
    }

表結構的方法

@Transactional
    public void transactional() throws SQLException {
        drop();
        reName();
    }

    /**
     * 複製表結構
     */
    public void like() throws SQLException {
        String sql = "CREATE TABLE user_operation_min_temp LIKE user_operation_min;";
        new QueryRunner(dataSource).update(sql);
    }

    /**
     * 刪掉舊錶
     * @throws SQLException
     */
    public void drop() throws SQLException {
        String sql = "drop table user_operation_min;";
        new QueryRunner(dataSource).update(sql);
    }

    /**
     * 新表重命名
     */
    public void reName() throws SQLException {
        String sql = "ALTER TABLE user_operation_min_temp RENAME TO  user_operation_min;";
        new QueryRunner(dataSource).update(sql);
    }

然後重點來了,之前提到過得批量INSET方法真的可以用嗎?還需要進行一下處理,上面我的INSET語句是三個字段,也就是三個?,三個佔位符,PreparedStatement ,一次提交的佔位符不能超過65535個,65536 / 3 = 21845 所以我一個批次不能超過21848,多於的還是要分割。這個因爲上層已經分割過了,所以就按照最粗的粒度分割。

public void save(List<UserOperation> list) throws SQLException {
        if (list == null || list.size() == 0) {
            return;
        }
        pre(list);
    }

    /**
     * 預處理佔位符問題
     * @param list
     * @throws SQLException
     */
    private void pre(List<UserOperation> list) throws SQLException {
        // 21845 * 3 = 65535  佔位符不可以超過這個數
        if (list.size() > 21845) {
            List<List<UserOperation>> parts = Lists.partition(list, 21845);
            parts.forEach(this::batch);
            return;
        }
        batch(list);
    }

    /**
     * 數據存入新的臨時表
     * @param list
     */
    private void batch(List<UserOperation> list) {
        String sql = "INSERT INTO user_operation_min_temp(observer_id, access_id, access_name) VALUES (?, ?, ?) ";
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        try {
            connection = dataSource.getConnection();
            connection.setAutoCommit(false);
            preparedStatement = connection.prepareStatement(sql);
            for (UserOperation obj : list) {
                preparedStatement.setInt(1, obj.getObserverId());
                preparedStatement.setInt(2, obj.getAccessId());
                preparedStatement.setString(3, obj.getAccessName());
                preparedStatement.addBatch();
            }
            preparedStatement.executeBatch();
            connection.commit();
        } catch (Exception e) {

        } finally {
            if (preparedStatement != null) {
                try {
                    preparedStatement.close();
                    preparedStatement = null;
                } catch (Exception e) {

                }
            }
            if (connection != null) {
                try {
                    connection.close();
                    connection = null;
                } catch (Exception e) {

                }
            }
        }
    }

代碼中很多我業務中的類,這裏只分享技巧和經驗,在使用中還是要根據自己的數據來進行處理。

最後再分享一個微信公衆號,關注公衆號,是您來過的儀式感。

號主爲一線大廠架構師,博客訪問量突破一千萬。主要分享Java、golang架構,源碼,分佈式,高併發等技術,用大廠程序員的視角來探討技術進階、面試指南、職業規劃等。15W技術人的選擇!

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章