【筆記】JdbcPagingItemReader 資料遺失

Spring Batch Logo

前言

在 JAVA Spring Batch 中,JDBC 分頁讀取器(JdbcPagingItemReader)可用於從資料庫中逐筆讀取資料。透過分頁讀取的方式,避免了處理龐大資料量可能導致的記憶體不足問題。然而,我最近遇到了一個問題:查出來的總筆數竟然與直接下 SQL 查詢的總筆數不一致!在本文中,我將分享解決這個問題的過程和相應的解決方法。

遇到的問題

以下是我簡化後的範例:

預期從 TB_TEST_0 t0、TB_TEST_1 t1 取出 t0.CUSTOMER_ID、t0.TX_DATE、t1.ACCOUNT_NO、t1.SEQ_NO 的資料,並使用 ACCOUNT_NO 和 SEQ_NO 進行排序。查詢到的資料會轉成 TestEntity 的物件。

因為資料量較大,實際 LEFT JOIN 的 Table 又比較多,為了避免批次 timeout,我的 fetchSize 設定 500、pageSize 設定 5000。

@Bean(READER_BEAN)
@StepScope
public JdbcPagingItemReader<TestEntity> reader() {
    Map<String, Object> whereKeys = new HashMap<>()
    Map<String, Order> sortKeys = new LinkedHashMap<>();

    sortKeys.put("ACCOUNT_NO", Order.ASCENDING);
    sortKeys.put("SEQ_NO", Order.ASCENDING);

    return new JdbcPagingItemReaderBuilder<TestEntity>()
            .name(READER)
            .beanRowMapper(TestEntity.class)
            .dataSource(dataSource)
            .fetchSize(500)
            .pageSize(5000)
            .selectClause("SELECT t0.CUSTOMER_ID, t0.TX_DATE, t1.ACCOUNT_NO, t1.SEQ_NO ")
            .fromClause("TEST.TB_TEST_0 t0 " +
                    "LEFT JOIN TEST.TB_TEST_1 t1 ON t1.ID = t0.T1_ID ")
            .whereClause("WHERE 1=1")
            .parameterValues(whereKeys)
            .sortKeys(sortKeys)
            .saveState(false)
            .build();
}

fetchSize 和 pageSize 的意義

fetchSize 是指 JDBC 每次從資料庫中撈取的行數,pageSize 是指 Spring Batch 每批(每頁)處理的行數。

結果撈出來的資料大概是 28,448 筆,資料庫中實際筆數是 28,451 筆,少了 3 筆

仔細檢查 JdbcPagingItemReader 的 SQL 寫法、whereClause、DATE 的比對、前端傳入的參數甚至編譯出來的 SQL 語法⋯⋯都沒有任何問題,真的不知道為什麼資料會遺漏。我甚至對 fetchSize 和 pageSize 進行測試,設了好幾組不同的數字來跑,發現有幾次的結果都不太一樣,這又使我更困惑。

最後經過前輩的一個提點,才發現問題竟然出現在「排序」。

問題發生原因

由於 JDBC Item Reader 會依據 fetchSize、pageSize 和 sortKeys 來自動生成分頁查詢語句,所以只要這三個屬性設定不正確,就有可能造成資料遺失。分頁時,Spring Batch 並不是真的記住上一筆資料在哪,而是記錄上一次查詢的最後一筆資料的 sortKeys 值,在下一次查詢時,用大於或小於該值的方式來找出這次的起始行

舉例來說,假設 Spring Batch 使用數字欄位 ACCOUNT_NO 進行 ASC 冪排序。每次撈取 500 筆資料(fetchSize = 500)。先依 ACCOUNT_NO 排序完後,Spring Batch 撈取 1~500 筆資料,並記下第 500 筆資料的 ACCOUNT_NO,假設為 113500,下一次的 501~1000,它會找出 ACCOUNT_NO 大於 113500 的資料,並撈取 500 筆,以此類推。

序號ACCOUNT_NO
1111001
2111002
500113500 ⬅️ 記下最後一筆的 ACCOUNT_NO
依 ACCOUNT_NO 升覓排序後,第一次撈取 500 筆資料,並記下最後一筆的 ACCOUNT_NO: 113500
序號ACCOUNT_NO
500113500 ➡️ 利用記起來 ACCOUNT_NO 找到上次查詢的最後一筆資料
501113501 ⬅️ 這次從這裡開始獲取 500 筆資料
502113502
1000114000
依 ACCOUNT_NO 升覓排序後,從 ACCOUNT_NO > 113500 的資料行,開始第二次撈取

看出問題了嗎?如果 sortKeys 欄位不是唯一值,Spring Batch 有可能找錯上一批次結尾的資料位置!而這也是 fetchSize 和 pageSize 會影響查詢總數的原因,因為不是每次都會剛好分頁斷在會重複的資料上。

解決方法

發現問題後,解決方法就簡單了,就是使 sortKeys 的欄位值唯一!在一般情況執行 SQL 時,可能不需要讓排序欄位一定要是唯一的值(一般來說會有默認排序輔助),但在 Spring Batch 的 JdbcPagingItemReader 這種完全由開發者設定 JDBC 的方式就有可能需要了。

以我當時的例子來說,由於我有 LEFT JOIN 很多 TABLE,而我多查出了最深層 TABLE 的 PK 欄位(此為 ID),並將該欄位多加入了排序中:

    Map<String, Order> sortKeys = new LinkedHashMap<>();

    sortKeys.put("ID", Order.ASCENDING); // --> LEFT JOIN 中最深層 TABLE 的 PK 欄位
    sortKeys.put("ACCOUNT_NO", Order.ASCENDING);
    sortKeys.put("SEQ_NO", Order.ASCENDING);

LEFT JOIN 中最深層 TABLE,其 Primary Key 自然可以確保資料的唯一性。最後 ID + ACCOUNT_NO + SEQ_NO 的組合終於查出了正確的資料數量。

p.s. 如果是像我一樣使用 put 的方式加入 key-value 的話,需使用 LinkedHashMap,才能確保排序的優先權(ID > ACCOUNT_NO > SEQ_NO)。

結語

這問題困擾我一整天。我是 JAVA 新手,這種批次處理對我來說更是陌生。而且每次重跑批次其實滿花時間的。最後發現解決方法其實很簡單,只是對於第一次使用 JdbcPagingItemReader 的我來說,如果沒有去理解它背後的處理邏輯,就會很難發現問題點。現在回想起來,這是個很有趣的經驗,也才能讓我馬上寫下這篇筆記 。

5 1 vote
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments