按照使用者的使用流程,團購的第一步是「挑店家」,使用者登入後,使用者會看到一份店家清單,依低消(min_order_amount,滿多少錢店家才接單)由低到高排列,可以用店名搜尋,一頁 10 筆往下翻。
乍看之下,分頁只需要:
LIMIT size OFFSET (page - 1) * size
但「能查出第 1 頁、第 2 頁」只是最基本的功能。一個可以放心使用的列表 API,還得先定義幾個 LIMIT 和 OFFSET 決定不了的規則:
最直覺的寫法是照需求直接排序。以一頁 2 筆為例,查第 1 頁:
SELECT * FROM stores
ORDER BY min_order_amount
LIMIT 2 OFFSET 0; -- 不跳過,取 2 筆
查第 2 頁時,是另外送出一次查詢:
SELECT * FROM stores
ORDER BY min_order_amount
LIMIT 2 OFFSET 2; -- 跳過前 2 筆,再取 2 筆
看起來完全合理,需求就是「依低消排序」。問題在於 min_order_amount 預設是 0,店家沒設低消時,大量資料可能都是同一個值:
store_id min_order_amount
pg-test-1 0
pg-test-2 0
pg-test-3 0
pg-test-4 0
ORDER BY min_order_amount 只保證低消小的排在前面,四家都是 0 時,彼此之間的順序並沒有被定義。而第 1 頁和第 2 頁是兩次獨立的查詢,因此可能出現:
查第 1 頁:[1, 2, 3, 4] → 取前兩筆 → [1, 2]
查第 2 頁:[1, 3, 2, 4] → 跳過兩筆 → [2, 4]
結果 pg-test-2 出現兩次,pg-test-3 則被跳過。
因此排序條件不能只依賴可能重複的欄位,而要讓它唯一決定每一筆資料的位置。例如低消相同時,再用不重複的主鍵 store_id 排序:
// OrderService.getAllShops()
.orderByAsc(Store::getMinOrderAmount)
.orderByAsc(Store::getStoreId);
這樣先按照低消排序,低消相同時再按照 store_id 排序。每筆資料的相對位置就被明確定義,OFFSET 2 才能穩定地代表「跳過前兩筆」。
排序規則已經定義為「先比低消,低消相同再比 store_id」,因此每筆資料的先後順序是確定的。測試要驗證的,就是分頁後把每一頁串起來,結果仍然符合這個排序規則。
因此建立 5 家低消都是 0 的店,並且故意不按照 store_id 順序寫入:
List<String> inserted = List.of(
"pg-test-3",
"pg-test-1",
"pg-test-5",
"pg-test-2",
"pg-test-4"
);
inserted.forEach(id ->
seedStore(id, PAGING_STORE_PREFIX + id, 0)
);
接著用 size=2 把三頁全部查完,把每一頁的 store_id 按查詢結果的順序串起來:
List<String> collected = new ArrayList<>();
for (int page = 1; page <= 3; page++) {
JsonNode res = queryShops(Map.of(
"storeName", PAGING_STORE_PREFIX,
"page", String.valueOf(page),
"size", "2"
));
assertThat(res.get("total").asLong()).isEqualTo(5);
assertThat(res.get("totalPages").asLong()).isEqualTo(3);
collected.addAll(storeIds(res));
}
因為低消全部相同,所以完整結果應該按照 store_id 排列:
第 1 頁 → [pg-test-1, pg-test-2]
第 2 頁 → [pg-test-3, pg-test-4]
第 3 頁 → [pg-test-5]
最後直接驗證三頁串起來的結果:
assertThat(collected)
.containsExactly(
"pg-test-1",
"pg-test-2",
"pg-test-3",
"pg-test-4",
"pg-test-5"
);
這裡故意打亂寫入順序,是為了確認結果不是單純沿用資料的寫入順序,而是真的按照 API 定義的排序規則回傳。
不過,這個測試有一個重要限制:它不一定能重現「沒有 store_id 排序」時的問題。
如果把:
.orderByAsc(Store::getStoreId);
拿掉,SQL 會變成:
ORDER BY min_order_amount
這時低消都是 0 的資料彼此之間沒有被 SQL 定義順序。資料庫這次可能剛好回傳:
[1, 2, 3, 4, 5]
測試就會通過;但這不代表這個順序是 SQL 保證的,之後換了索引、執行計畫或資料狀態,都可能得到不同順序。
所以要分清楚兩件事:
store_id: ORDER BY min_order_amount, store_id 已經明確定義完整排序,這組排序本身是穩定的。store_id: 低消相同的資料沒有明確的先後順序,只是資料庫有時候可能「剛好排對」。因此,這個測試目前主要是驗證跨頁查詢後,資料沒有重複或遺漏,結果也符合目前定義的排序。至於 store_id 是否一定要作為 tie-breaker,單靠這個測試還無法完全保證。若未來這項規則變得更重要,可以再補更完整的測試,讓排序規則本身也能被明確驗證。
size 是 client 自己帶的參數:
GET /order/get_all_shops?size=999999
如果不設上限,一個請求就可能把大量資料一次撈回來,分頁控制查詢成本的作用也就消失了。因此專案把上限集中在 PageLimits,直接在 Controller 參數上用 Bean Validation 擋掉:
@RequestParam(defaultValue = "1")
@Min(value = 1, message = PageLimits.PAGE_MESSAGE)
int page,
@RequestParam(defaultValue = "10")
@Min(value = 1, message = PageLimits.SIZE_MESSAGE)
@Max(value = PageLimits.MAX_SIZE, message = PageLimits.SIZE_MESSAGE)
int size
MAX_SIZE 是 20,因此 size=21 會直接回 400,page < 1 也一樣被擋掉,這些規則在 API 層就先定義清楚,而不是讓錯誤的參數一路傳到資料存取層,最後才由 MyBatis-Plus 或資料庫處理。
例如:
GET /order/get_all_shops?storeName=不存在的店名xyz
回傳 200:
{
"records": [],
"page": 1,
"size": 10,
"total": 0,
"totalPages": 0
}
請求本身成功,只是這個集合裡沒有符合條件的店家。翻到超過最後一頁也是同樣的處理:200 加上空的 records。
這和指定單一資源不同。例如開團時,request 要指定一家店:
POST /order/create_order
{ "storeId": "store999", "orderName": "...", "deadline": "..." }
如果 store999 這家店不存在,才會回 404,createOrder() 會丟出:
ResourceNotFoundException("店家不存在")
以上幾個規則確定後,目前的設計仍存在某些限制。
第一,同低消時用 store_id 排序只是為了讓順序穩定,如果未來需求變成「同低消的店家再按照店名排序」,最後仍然需要補上主鍵;第二,size 上限 20,代表前端若要一次顯示更多店家,就必須多發幾次請求;最後,穩定排序只能解決「同一次資料狀態下,分頁順序不一致」的問題,無法處理資料本身在翻頁期間發生變動,例如:使用者看完第 1 頁後,新店家插入了前面的位置,第 2 頁仍可能因為 OFFSET 位移而重複看到一筆資料。
因為 OFFSET 記住的是「第幾筆」,不是「從哪一筆之後」。
另一種做法是使用游標分頁(Cursor Pagination),把上一頁最後一筆的排序欄位當作下一頁的查詢起點,例如目前的 min_order_amount + store_id。這樣即使前面新增資料,下一頁仍會從上一頁最後一筆之後繼續查,不會因為前面的資料數量改變而產生整體位移的情況。
不過,游標分頁也需要穩定且完整的排序鍵,並不能單純換掉 OFFSET 就解決所有資料異動問題。目前專案資料量與需求尚未需要這種做法,因此仍採用 OFFSET 分頁。