Java批量導入百萬級數據到mysql

需求:把一個500M的txt文件導入mysql數據庫,數據量大概有幾千萬。

1、項目架構

項目采用微服務架構,上傳文件的后臺管理系統是作為應用層,業務處理的作為服務層,應用層和服務層都有多個節點。

如果是單一節點處理,那么讀取500M的文件(可能更大),并把幾百萬的數據要快速的寫入數據庫,是非常有難度的。但是我們可以利用微服務多個節點分散處理,即應用層只讀取數據,讀10000條就放入線程池向服務層發起請求,而服務層有多個節點,可以把應用層的請求平均到多個節點處理,在應用層每個節點先做數據解析,然后插入mysql。

2、服務層處理

2.1 文件讀取

文件讀取我們用BufferedReader,BufferedReader使用裝飾器模式,它的IO行為是每次讀進來8K的數據到緩沖區(當然,緩沖區的大小我們是可以通過構造器修改的),如果需要使用數據的時候,再直接從緩沖區里面拿出數據來使用。

而FileReader的read方法,每調用一次就會read一次file,進行一次IO。不管是多次read還是一次性的read,都不是很優雅的在read文件的方式。多次read必然會產生多次IO,一次性的read如果遇到很大的文件,對內存是極不友好的。

所以BufferedReader既能提高的讀取速度,又節省了IO的次數,是一種比較優雅的讀取文件的方式。
BufferedWriter和FIleWriter同理。

研究一下BufferedReader的源碼,就會發現,BufferedReader中對文件的讀取還是通過FileReader來實現的,BufferedReader只是對其讀取到的數據做一下緩沖,api如下。其中buffer操作的api和java nio中對buffer的操作類似。

image.png

有緩沖區 VS 沒有緩沖區

  • 沒有緩沖區時,每次讀取操作都會導致一次文件讀取操作(就是告訴操作系統內核我要讀這個文件的這個部分,麻煩你幫我把它取過來)。
  • 有緩沖區時,會一次性讀取很多數據,然后按要求分次交給上層調用者。
    讀取塊大小通常是按最適合硬件的大小來讀的,因為對于硬件來說,一次讀取一塊連續數據(比如 1K)和一次讀取一個字節需要的時間幾乎是一樣的(都是一次讀操作,只是最終提交的數據量有差異),所以帶緩沖的 I/O 和不帶緩沖的相比效率差異是非常顯著的

我們這里的需求是順序讀取,如果是隨機讀取,則使用RandomAccessFile。
所謂隨機讀取,就是說我們需要自由訪問文件的任意位置(指定位置讀,指定位置寫),所以如果需要訪問文件的部分內容,RandomAccessFile將是更好的選擇。所以當我們要下載一個大文件時,可以通過多線程使用RandomAccessFile來實現。同樣的,對于文件的切割、合并,使用RandomAccessFile效率都會很高。

2.2 業務處理

BufferedReader讀取文件后,每讀10000行,就丟入線程池,然后調用服務層處理,應用層這里不做任何業務邏輯處理,因為應用層必然是用一個節點處理業務,但是=我們通過http調用服務層后,是可以通過多個節點處理的,這樣就可以提升處理效率。


ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 50, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
        
BufferedReader bufferedReader = null;
        try {
            bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream("E:\\code\\esg-cemp-core-nonactivity-01394067\\logs\\system\\system2.log"), StandardCharsets.UTF_8));
       
            List<String> lines = new ArrayList<>();
            String lineTXT="";
            long count = 0;
            while ((lineTXT = bufferedReader.readLine()) != null) {
               if(count % 10000 == 0){
                   executorService.execute(new Runnable() {
                       @Override
                       public void run() {
                           //調用微服務
                           System.out.println("------調用微服務--------");
                       }
                   });
                   lines.clear();
               }
               lines.add(lineTXT);
               count++;
            }

            if(lines.size()>0){
                //調用微服務
                System.out.println("-------調用微服務-------");
            }
            //及時關閉線程池,對于這種使用不是很頻繁的線程池使用完畢以后,可以及時關閉以節省資源
           //shutdown關閉以后,就不能再提交任務了,
            threadPoolExecutor.shutdown();
        } catch (Exception e) {
            e.printStackTrace();
        }

3、業務層處理

3.1 業務處理

業務處理比較簡單,就是把String 切割成多個字段,然后變換成PO對象。

3.2 mysql 插入

  • 方法一:mybatis foreach
    foreach的作用是構建in條件,通過foreach可以在SQL語句中進行迭代一個集合。
    優點是使用方便;
    缺點是每條sql是有長度限制的,所以list的數量跟表字段多少直接關聯;

    activityProductUserFlowPoMapper.insertList(list2);
    
    <insert id="insertList" >
      insert into t_non_standard_act_user_flow (act_pro_id, user_id,
        mobile, point, status,
        extend)
      values
      <foreach collection="list" separator="," item="item">
        (#{item.actProId,jdbcType=BIGINT}, #{item.userId,jdbcType=VARCHAR},
        #{item.mobile,jdbcType=VARCHAR}, #{item.point,jdbcType=INTEGER}, #{item.status,jdbcType=TINYINT},
        #{item.extend,jdbcType=VARCHAR})
      </foreach>
    </insert>
    
  • 方法二:mybatis batch
    Mybatis內置的ExecutorType有3種,默認的是simple,該模式下它為每個語句的執行創建一個新的預處理語句,單條提交sql;而batch模式重復使用已經預處理的語句,并且批量執行所有更新語句。

    SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
    Mapper userMapper = sqlSession.getMapper(Mapper.class);
    int size = 10000;
      try {
          for (int i = 0; i < size; i++) {
                  User user = new User();
                  user.setName(String.valueOf(System.currentTimeMillis()));
                  fooMapper.insert(foo);
                 if (i % 1000 == 0 || i == size - 1) {
                      //手動每1000個一提交,提交后無法回滾
                      session.commit();
                      //清理緩存,防止溢出
                     session.clearCache();
                  }
          }
      } catch (Exception e) {
         //沒有提交的數據可以回滾
        session.rollback();
      } finally {
        session.close();
      }
    

    需要在jdbc連接url上追加rewriteBatchedStatements=true,否則不起作用

  • 方法三:jdbc batch
    采用PreparedStatement.addBatch()方式實現,發送的是預編譯后的SQL語句,執行效率高。

         Long start = System.currentTimeMillis();
          DruidDataSource dataSource = SpringUtils.getBean("dataSource");
          //從連接池中獲取connection
          DruidPooledConnection conn =dataSource.getConnection();
          conn.setAutoCommit(false);
          try {
              String sql = "insert into act_user_flow (act_pro_id, user_id, mobile, point, status) values(?,?,?,?,?)";
              PreparedStatement ps = conn.prepareStatement(sql);
    
              for (int i = 0; i < list.size; i++) {
                  ps.setLong(1,list[i].getActProId());
                  ps.setString(2,list[i].getUserId());
                  ps.setString(3,list[i].getMobile());
                  ps.setInt(4,list[i].getPoint());
                  ps.setInt(5,DuobaoGoodEnum.ACTIVITY_PRODUCT_ONLINE.getCode());
                  ps.addBatch();
                  //小批量提交,避免OOM
                  if((i+1) % 1000 == 0) {
                      ps.executeBatch();
                      ps.clearBatch();
                  }
              }
              //提交剩余的數據
              ps.executeBatch();
              conn.commit();
          } catch (SQLException throwables) {
              throwables.printStackTrace();
          } finally {
              //使用完以后放回連接池
              conn.close();
          }
          System.out.println("insert cost time = " + (System.currentTimeMillis()-start));
    

    需要在jdbc連接url上追加rewriteBatchedStatements=true,否則不起作用

  • 性能比較
    同個表插入一萬條數據時間近似值:
    JDBC BATCH 1.1秒左右 > Mybatis BATCH 2.2秒左右 >Mybatis foreach 4.5秒左右

    方法二和方法三利用的是mysql的批量提交,需要在jdbc連接url上追加rewriteBatchedStatements=true,如下

    jdbc:mysql://xxx.com.cn/xxx?useUnicode=true&amp;characterEncoding=utf8&allowMultiQueries=true&rewriteBatchedStatements=true
    

    MySQL JDBC驅動在默認情況下會無視executeBatch()語句,把我們期望批量執行的一組sql語句拆散,一條一條地發給MySQL數據庫,批量插入實際上是單條插入,直接造成較低的性能。
    只有把rewriteBatchedStatements參數置為true, 驅動才會幫你批量執行SQL
    另外這個選項對INSERT/UPDATE/DELETE都有效

擴展:

  • jdbc連接url上allowMultiQueries=true的作用:
1.可以在sql語句后攜帶分號,實現多語句執行。
2.可以執行批處理,同時發出多個SQL語句。
  • $ 和#的區別
#{}:是以預編譯的形式,將參數設置到sql語句中;PreparedStatement;防止sql注入
${}:取出的值直接拼裝在sql語句中;會有安全問題;
如下所示:
select * from tbl_employee where id=${id} and last_name=#{lastName}
Preparing: select * from tbl_employee where id=2 and last_name=?

如果id為 '2 or 1=1',則會發生數據泄露,這就是sql注入。
Preparing: select * from tbl_employee where id=2 or 1=1 and last_name=?
  • 獲取DruidDataSource當前活躍連接數
//當前活躍的,即正在被使用的連接數
System.out.println(dataSource.getActiveCount()); ——活躍連接都在activeConnections(Map)中

//最大連接數
System.out.println("========="+dataSource.getMaxActive()); 

//poolingCount值代表剩余可用的連接數,每次從末尾拿走連接
System.out.println(dataSource.getPoolingCount());  ——剩余可用連接都在connections(數組)中

//activeCount + poolingCount只能小于等于maxActive
activeCount + poolingCount <= maxActive

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。