AI 建议用 `LIMIT + OFFSET` 做增量同步,为什么数据一变就可能漏记录
刚开始写数据同步、批量导出或历史回放任务时很多人都会用一段很熟悉的分页逻辑SELECTid,user_id,status,updated_atFROMuser_eventWHEREupdated_at:startTimeANDupdated_at:endTimeORDERBYupdated_atLIMIT:pageSizeOFFSET:offset;代码侧不断增加offsetintpageSize500;intoffset0;while(true){ListUserEventeventsrepository.findByPage(startTime,endTime,pageSize,offset);if(events.isEmpty()){break;}syncService.process(events);offsetpageSize;}这段代码很容易被 AI 写出来。它看起来也完全合理有时间范围有排序有分页有终止条件每次处理固定数量的数据。如果表里的数据完全不变它通常能正常跑完。但真实系统里数据往往不会在同步期间静止。只要有新的事件写入、旧数据状态更新、同一时间戳的记录顺序变化LIMIT OFFSET就可能让你遇到两种最麻烦的问题某些记录被重复处理某些记录被直接跳过。更糟的是任务大概率不会报错。它会显示“已同步完成”但最终数据并不完整。一、OFFSET 为什么会在数据变化时失效假设你要同步一段时间内的事件数据当前按updated_at排序。第一次查询OFFSET 0, LIMIT 3 A B C此时刚好有一条更早排序的数据X被更新进入了当前查询范围X A B C D E F第二次查询仍然执行OFFSET 3, LIMIT 3得到的可能是C D E注意C被重复拿到了。因为X插入到前面后原本的分页位置整体后移了。再看另一种情况。第一次查询已经处理A B C处理期间A被归档、删除或者状态改变后不再满足条件数据集变成B C D E F第二次依旧从OFFSET 3开始E F那么D就被跳过了。这就是 OFFSET 分页的核心问题OFFSET 不是“从上次处理到的位置继续”而是“从当前结果集的第 N 行开始”。只要结果集在两次查询之间发生变化N的含义就变了。二、一个错误但常见的补救方式加大分页大小发现同步慢或者数据有遗漏时有人会尝试intpageSize5000;或者干脆一次查更多LIMIT10000OFFSET:offset;这可能让问题“看起来少发生一些”但不能从根本上解决。分页大小变大后风险只是从“更多页之间的数据变化”变成“单页查询更重、锁更久、失败重跑代价更高”。它还可能带来新的问题做法看起来的好处实际风险增大pageSize查询次数减少单次内存占用更高增大pageSize同步更快失败后重试范围更大增大pageSize少一些 OFFSET仍然可能重复或漏数据一次查全量逻辑简单容易拖垮数据库或任务节点不排序直接分页SQL 更短返回顺序不稳定问题更严重真正需要修复的不是页大小而是“下一页从哪里开始”的定义。三、正确思路用稳定游标代替 OFFSET对于持续变化的数据集更可靠的方式是使用游标分页也叫 Keyset Pagination。核心思想是不要记“已经跳过了多少行”而要记“上一次处理到哪条记录”。例如按updated_at和id组成稳定排序SELECTid,user_id,status,updated_atFROMuser_eventWHEREupdated_at:startTimeANDupdated_at:endTimeAND(updated_at:lastUpdatedAtOR(updated_at:lastUpdatedAtANDid:lastId))ORDERBYupdated_atASC,idASCLIMIT:pageSize;这里为什么要用两个字段因为单独用updated_at不一定唯一。多个事件可能在同一秒、同一毫秒写入。如果只记录时间WHEREupdated_at:lastUpdatedAt那么和上次最后一条记录时间相同的数据会被直接跳过。因此需要一个稳定、唯一的次级排序字段例如主键id。游标状态可以这样定义publicrecordSyncCursor(LocalDateTimelastUpdatedAt,LonglastId){}处理完一页后不是增加offset而是保存最后一条记录的位置publicSyncCursorprocessPage(ListUserEventevents,SyncCursorcurrentCursor){for(UserEventevent:events){syncService.process(event);}UserEventlastevents.get(events.size()-1);returnnewSyncCursor(last.getUpdatedAt(),last.getId());}下一页从这个游标之后继续。这样即使前面插入或删除了其他记录已经处理过的位置仍然是确定的。四、排序字段不稳定游标分页也会出问题很多人看到游标分页后会立刻写成WHEREid:lastIdORDERBYid这个方案在某些场景里没有问题例如数据只追加、不更新主键和业务写入顺序强相关任务只处理创建后的新记录不需要捕获历史记录的后续状态变化。但如果你的同步依据是“数据最近被修改过”只按id就不够。举个例子idstatusupdated_at100PENDING10:00:00101PENDING10:01:00102PENDING10:02:00任务已经同步到id 102。此时id 100的状态被修改为SUCCESSupdated_at变成10:05:00。如果后续只使用WHEREid102这条更新永远不会再次被同步到。所以先要确定业务事实你是在同步“新增记录” 还是在同步“最近发生变化的记录”前者可以考虑按递增 ID 游标。后者通常需要按updated_at id并且要处理时间窗口重叠。五、不要把时间窗口切得刚刚好假设任务每 5 分钟跑一次10:00:00 - 10:05:00 10:05:00 - 10:10:00如果某条数据在 10:04:59 发生写入但数据库提交、事件写入、时钟差异或延迟让它在 10:05:01 才可见就可能落在两个窗口之间。因此实际增量同步常会使用“重叠窗口”本次查询窗口 上次成功时间 - 2 分钟 到 当前安全水位时间例如WHEREupdated_at:lastSuccessTimeMinusOverlapANDupdated_at:safeWatermark这会带来一定重复但重复并不一定是坏事。关键是消费侧要具备可验证的去重或幂等策略。这里要区分查询层允许少量重叠避免漏数据处理层防止重复副作用避免重复写入或重复通知。新人很容易只盯着“不要重复”。但在同步任务里通常比重复更危险的是漏数据。六、让 AI 先帮你画数据边界不要直接让它补分页代码如果你只问帮我给同步任务加分页。AI 很可能会生成LIMIT OFFSET因为这是最常见、最容易理解的答案。更有效的提问方式是你是后端数据同步方案评审助手。 场景 我要同步 user_event 表中最近发生变化的记录。 同步过程中可能有新数据写入、旧记录状态更新、记录删除和任务重跑。 请不要直接使用 LIMIT OFFSET。 请输出 1. 应使用哪些稳定排序字段 2. 游标应该保存哪些状态 3. 相同 updated_at 的记录如何避免遗漏 4. 时间窗口是否需要重叠 5. 任务中断后如何断点续跑 6. 哪些情况下可能重复处理 7. 至少 6 个并发和异常测试场景 8. 哪些判断需要由业务方确认。这类 Prompt 的价值在于把“分页”从一个 SQL 语法问题变成一个数据完整性问题。对刚开始用 ChatGPT Plus 做代码解释、查询设计和异常排查的开发者来说工具接入准备不只是让 AI 帮你补循环代码还包括明确数据边界、保存过程状态、记录异常原因和验证最终结果。第一次把 AI 工具纳入开发工作流时建议把使用说明、异常处理和信息留存方式一起整理相关准备项可按实际需要参考gpt328七、至少要测试这些场景分页同步不是“查到数据就处理”的简单循环。建议至少覆盖下面这些场景测试场景预期结果数据完全静止全部记录恰好处理一次同步期间有新记录插入不影响已处理游标同步期间旧记录被更新后续窗口能捕获更新多条记录updated_at相同不因时间相同而遗漏任务处理中断从保存的游标继续不从头重复某页处理失败游标不能提前推进时间窗口重叠可重复读取但处理结果不重复删除或归档前序记录后续游标不会跳过未处理数据例如可以验证“相同更新时间的记录不会漏掉”TestvoidshouldNotSkipRecordsWithSameUpdatedAt(){LocalDateTimesameTimeLocalDateTime.of(2026,6,24,10,0,0);repository.saveAll(List.of(event(101L,sameTime),event(102L,sameTime),event(103L,sameTime)));SyncCursorcursornewSyncCursor(sameTime,101L);ListUserEventresultrepository.findAfter(cursor,100);assertEquals(List.of(102L,103L),result.stream().map(UserEvent::getId).toList());}再验证“处理失败时游标不能被错误推进”TestvoidshouldNotAdvanceCursorWhenPageProcessingFails(){ListUserEventeventsList.of(event(101L),event(102L),event(103L));doThrow(newSyncException(downstream unavailable)).when(syncService).process(events.get(1));assertThrows(SyncException.class,()-syncTask.process(events));assertEquals(originalCursor,cursorRepository.load(user-event-sync));}这里的原则很简单游标代表“已经确认处理完成的位置”不是“已经读取到的位置”。八、上线后要观察什么分页同步如果出了问题通常不会直接抛出明显异常。更常见的是处理数量看似正常但下游少了一部分数据某些记录不断被重复同步游标卡住不动任务一直成功但延迟越来越大某个时间段的数据永远没有进入下游系统。因此建议至少记录sync_cursor_updated_at sync_cursor_last_id sync_page_size sync_processed_count sync_duplicate_count sync_failed_count sync_lag_seconds sync_last_success_time最关键的是当前游标停在哪里距离当前数据最新时间差多少每页实际处理了多少条重复数量是否突然升高是否存在连续成功但游标不前进的情况。不要只看“任务执行成功”。任务成功只说明代码没有抛异常不说明数据真的完整到达了目标系统。九、结语LIMIT OFFSET适合静态列表翻页也适合一些后台页面展示。但它并不天然适合持续变化的数据同步。当数据会新增、更新、删除或者任务需要中断恢复时更重要的是排序是否稳定游标是否能够准确表达处理进度相同时间戳的数据是否会漏时间窗口是否需要重叠失败时游标会不会被提前推进重复与遗漏分别由哪一层负责处理。AI 可以很快帮你写出分页循环。但同步任务真正难的部分从来不是while循环而是如何定义“这批数据已经被完整、可靠地处理过”。最危险的不是任务失败。而是任务显示成功却悄悄漏掉了你最需要的数据。