iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 15 篇

Day 15|分頁查詢:資料變動時,分頁可能位移

  • 分享至 

  • xImage
  •  

按照使用者的使用流程,團購的第一步是「挑店家」,使用者登入後,使用者會看到一份店家清單,依低消(min_order_amount,滿多少錢店家才接單)由低到高排列,可以用店名搜尋,一頁 10 筆往下翻。

乍看之下,分頁只需要:

LIMIT size OFFSET (page - 1) * size

但「能查出第 1 頁、第 2 頁」只是最基本的功能。一個可以放心使用的列表 API,還得先定義幾個 LIMIT 和 OFFSET 決定不了的規則:

  1. 翻頁時,同一家店會不會出現兩次,或某家店被跳過?
  2. client 一次最多能要幾筆?
  3. 搜尋框清空,或輸入特殊字元時怎麼處理?
  4. 查不到任何店家,算不算錯誤?

翻頁時,資料順序必須固定

最直覺的寫法是照需求直接排序。以一頁 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也要訂規則,避免回傳的資料無上限

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 分頁。


上一篇
Day 14|測試變綠了,真的代表規則被守住了嗎?
下一篇
Day 16|店家改價之後,已經下的單要扣多少?
系列文
做一個團購後端,順便搞懂那些事 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言