大綱 📖
- 1、場景 🪵
- 2、原因 🔥
- 3、解決方式:游標分頁 📏
- 4、一點思考💡
- 5、全表查詢的優化思路 🍅
記錄一個分頁不準的問題
1、場景 🪵
調用一個第三方List接口(帶分頁),然后遍歷分頁后的每一批數據做處理,結果發現代碼會偶現最終處理數據不全的bug
2、原因 🔥
對于需要獲取全量數據的場景,常見的有兩種實現途徑:
- 全量查表,不推薦,因為數據量很大時,數據庫壓力和服務內存壓力都很大
select * from table;
- 分頁查表,一批批的拿數據,最終拿到全量數據
對于一個普通的分頁接口,底層常規的都是類似:
select * from table limit 10 offset 0;
這樣,在我們一批批拿數據的過程中,如果有數據插入或者刪除,就會導致漏數據或者一些數據被重復獲取,比如下面這個分頁,
-- 第一頁
SELECT * FROM users ORDER BY id LIMIT 0,10;
-- 第二頁
SELECT * FROM users ORDER BY id LIMIT 10,10;
-
數據刪除:當第一頁查詢后,有人刪除了第8條數據。此時再查第二頁,原第11條變成第10條,導致第11條數據被跳過(實際返回原第12~21條)
-
數據插入:當第一頁查詢后,有人插入5條新數據。此時再查第二頁,會重復顯示原第6~15條(因為新插入數據導致原數據位置后移)
因此,如果List遍歷所有分頁獲取全量數據的過程中,有數據增刪,就會導致分頁數據不準,特別是數據增刪頻繁的情況下,這個情況基本就是必現
3、解決方式:游標分頁 📏
以一個主鍵自增的表為例:
id | name |
---|---|
1 | tom |
2 | cat |
3 | dog |
我們可以考慮給原本的select語句加一個where條件過濾,再取limit行的數據,offset這個起始位置值,容易受數據增刪的影響,但這個where條件,就像一個游標卡尺的左臂,明確記錄了每次取值的位置:
-- 第一頁
SELECT * FROM users
WHERE id > 0 -- 初始游標
ORDER BY id LIMIT 5;-- 返回最后一條ID=5,作為下一頁游標-- 第二頁(不受中間變更影響)
SELECT * FROM users
WHERE id > 5 -- 使用上一頁最后ID
ORDER BY id LIMIT 5;
此時,如果id = 2、id = 3、id = 4這條數據被刪了,常規的offset和limit下,id = 6、id = 7、id = 8這三條數據就會被漏掉,但有游標id > 5,可以精準定位到后面的數據,然后limit 5取5條,就不會漏數據,且主鍵ID自帶索引,性能也好
4、一點思考💡
你可能會想到數據庫的事務隔離級別,但這個其實沒用:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public List<User> getUsers(int page) {// 同一事務內所有查詢看到相同數據快照return userMappere.findByPage(PageRequest.of(page, 5));
}
可重復讀,是一次事務沒結束的時候,或者說同一個事務里,每次讀到的結果都一樣,但我執行一次limit 和offset查詢,方法執行一次,就是一個完整事務,所以,我多次傳不同limit和offset獲取一批批的數據時,就不是一個事務,還是會漏數據,因此,這地方修改事務隔離級別也不行
5、全表查詢的優化思路 🍅
全表查詢改分頁后,如果拉一頁數據,處理完再拉一頁數據,過程長,容易出現增刪,導致分頁不準。當表結構不支持游標分頁時,可以考慮下:循環分批,查詢全量數據到內存后,再慢慢處理,這種方式,雖然數據庫壓力不大,但還是得考慮你服務自己內存的壓力,加載太多對象存Jvm內存,其實并不是最優解,實現:
// 一次取limit900,讓掃描行數 < 1000
private static final int BATCH_SIZE = 900;
// 最大安全頁數限制,防止意外無限循環
private static final int MAX_SAFE_PAGES = 1000;/*** 統一拉取,避免源數據增刪頻繁,分頁不準* 當前最大數據量 < 2w*/
private static <T> List<T> fetchAllByBatch(int batchSize, BiFunction<Integer, Integer, List<T>> batchQueryFunction) {List<T> result = new ArrayList<>();int offset = 0;int pageCount = 0;while (pageCount < MAX_SAFE_PAGES) {pageCount++;List<T> batch = batchQueryFunction.apply(offset, batchSize);if (batch == null || batch.isEmpty()) {break;}result.addAll(batch);offset += batchSize;// 不夠一批了,那后面肯定也沒數據了,跳出循環即可if (batch.size() < batchSize) {break;}}return result;
}
上面通過一個BiFunction函數式接口,傳入offset和limit,調用apply方法,拉取到分頁的數據,然后存下來,接著立馬去拉下一批,直到全表數據拉完
@FunctionalInterface
public interface BiFunction<T, U, R> {/*** Applies this function to the given arguments.** @param t the first function argument* @param u the second function argument* @return the function result*/R apply(T t, U u);
}
此時,之前的全表select,就可以改成:
@Repository
public interface UserMapper {@Select("select `uid`, `uname`, `age` from user_table limit #{limit} offset #{offset}")List<User> getByBatch(int offset, int limit);}
// Service層
List<User> allUserData = fetchAllByBatch(BATCH_SIZE, UserMapper::getByBatch);
這并不是最優解,因為一來要考慮Jvm內存壓力,二來并不是100%不漏數據,只是緩解,這里記錄下,主要是對BiFunction接口的使用,有一定的抽象