前言
在 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 |
---|---|
1 | 111001 |
2 | 111002 |
… | … |
500 | 113500 ⬅️ 記下最後一筆的 ACCOUNT_NO |
序號 | ACCOUNT_NO |
---|---|
500 | 113500 ➡️ 利用記起來 ACCOUNT_NO 找到上次查詢的最後一筆資料 |
501 | 113501 ⬅️ 這次從這裡開始獲取 500 筆資料 |
502 | 113502 |
… | … |
1000 | 114000 |
看出問題了嗎?如果 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 的我來說,如果沒有去理解它背後的處理邏輯,就會很難發現問題點。現在回想起來,這是個很有趣的經驗,也才能讓我馬上寫下這篇筆記 。