使用 LIMIT offset, size 進行大表分頁時,若並發存在數據刪除,會導致**數據漏讀**(丟失)或**重複讀取**。本文通過具體案例剖析根因,並提供基於**游標/記憶最大值**的穩健解決方案。
system_user 約 400 萬行userid,分頁大小 20,000for (curPage = 1; ; curPage++) {
offset = (curPage - 1) * pageSize;
sql = "SELECT userid FROM system_user ORDER BY userid ASC LIMIT " + offset + "," + pageSize;
rows = fetchAll(sql);
if (empty(rows)) break;
foreach (rows as row) process(row['userid']);
}
假設初始數據:u1, u2, u3, u4, u5, u6, u7, u8, u9, u10(10行),每頁 5 行。
| 階段 | 操作 | 數據狀態 | 查詢結果 | 問題 |
|---|---|---|---|---|
| 第1頁 | LIMIT 0,5 | u1~u10 | u1,u2,u3,u4,u5 | 正常 |
| 間隙 | 併發刪除 u2,u3 | u1,u4,u5,u6,u7,u8,u9,u10 | - | 數據漂移 |
| 第2頁 | LIMIT 5,5 | u1,u4,u5,u6,u7,u8,u9,u10 | u8,u9,u10 | 丟失 u6,u7! |
本質:OFFSET 是「跳過前 N 行」。當前 N 行發生刪除,原本屬於第 2 頁的數據(u6,u7)前移至第 1 頁位置,被跳過導致丟失。
核心思想:**記住上一頁最大值,下一頁查「大於該值」的數據**,而非「跳過固定行數」。
$pageSize = 20000;
$pageMaxId = 0; // 記錄上一頁最大 userid
while (true) {
// 關鍵:WHERE userid > 上一頁最大值
$sql = "SELECT userid FROM system_user WHERE userid > {$pageMaxId} ORDER BY userid ASC LIMIT {$pageSize}";
$rows = $systemUserTable->fetchAll($sql);
if (empty($rows)) break;
foreach ($rows as $row) {
$userid = $row['userid'];
// 更新最大值
if ($userid > $pageMaxId) $pageMaxId = $userid;
echo $userid . "\r\n";
}
}
| 階段 | SQL | 數據狀態 | 結果 | pageMaxId |
|---|---|---|---|---|
| 第1頁 | WHERE userid > 0 LIMIT 5 | u1~u10 | u1,u2,u3,u4,u5 | u5 |
| 間隙 | - | 刪除 u2,u3 → u1,u4,u5,u6,u7,u8,u9,u10 | - | u5 |
| 第2頁 | WHERE userid > u5 LIMIT 5 | u1,u4,u5,u6,u7,u8,u9,u10 | u6,u7,u8,u9,u10 | u10 |
結果:零丟失、零重複,完美解決並發變動導致的分頁異常。
| 條件 | 說明 |
|---|---|
| 排序列唯一且有序 | userid 為主鍵/唯一索引,ORDER BY userid ASC 確定性順序 |
| 單向遍歷 | 適用於導出、同步、批量處理;不適用於用戶翻頁(需跳轉任意頁) |
| 無大量插入干擾 | 若並發大量插入userid < pageMaxId的數據,會被遺漏(極少見,ID通常遞增) |
SELECT * FROM tbl WHERE id IN (SELECT id FROM tbl ORDER BY id LIMIT offset, size)SELECT * FROM tbl WHERE id >= (SELECT id FROM tbl ORDER BY id LIMIT offset, 1) ORDER BY id LIMIT sizeLIMIT offset, size 在並發寫入環境下**非安全分頁**,會導致數據丟失/重複WHERE id > last_max_id LIMIT size)是**流式處理、數據同步、全量導出**的黃金標準