前言Doris query_timeout 超时时间调整——一次“小事“背后的风险与思考最近在处理一个 Doris 数据库的查询超时问题。第三方系统同步我们的数据查询经常跑超时要求我们把query_timeout从 600 秒加到 1800 秒。他们说用分页了我怀疑他们用的深分页然后给他们提建议用游标分页优化查询结果对方说我们的 ID 不是自增的用不了游标分页。说实话这个回复让我有点意外。因为游标分页的核心从来就不是自增 ID而是能不能找到一个可排序的列来做定位。借此机会把游标分页的适用场景、常见误区和替代方案整理一下。一、游标分页到底是什么先看看传统 OFFSET 分页的问题大多数人的分页习惯是这样的-- 第1页 SELECT * FROM orders LIMIT 10000 OFFSET 0; -- 第2页 SELECT * FROM orders LIMIT 10000 OFFSET 10000; -- 第100页 SELECT * FROM orders LIMIT 10000 OFFSET 990000;看起来没问题但 Doris以及 MySQL在执行OFFSET的时候是先把前面所有行扫一遍再丢掉的OFFSET 0 扫描 1 万行返回 1 万行 → 很快 OFFSET 10 万 扫描 10 万行丢掉前 10 万行 → 还行 OFFSET 1000 万 扫描 1000 万行全部丢掉只返回 1 万行 → 可能超时你让数据库从 1000 万行里翻到第 1000 万零 1 行再取 1 万条数据它不超时谁超时游标分页的核心思路换个思路不跳页只记住上一页最后一条的位置从那个位置继续往后取。-- 第一批 SELECT * FROM orders ORDER BY id LIMIT 10000; -- 假设返回的最后一条 id 50000 -- 第二批直接从 50000 后面开始 SELECT * FROM orders WHERE id 50000 ORDER BY id LIMIT 10000; -- 假设返回的最后一条 id 100000 -- 第三批 SELECT * FROM orders WHERE id 100000 ORDER BY id LIMIT 10000;不管当前翻到第几页每次查询都只需要从上一页的末尾位置往后扫描 1 万行耗时基本恒定。关键点游标分页用的不是自增 ID而是一个可以排序 唯一定位记录位置的列或列组合二、六种场景逐个击破场景 1有自增 ID最简单的情况直接用-- 第一批 SELECT * FROM table_name ORDER BY id LIMIT 10000; -- 最后一条 id 50000 -- 第二批 SELECT * FROM table_name WHERE id 50000 ORDER BY id LIMIT 10000;没任何问题。但现实业务中自增 ID 越来越少了。场景 2有雪花 ID 或其他有序 UUID很多系统用雪花算法生成 ID比如1826735489123456789。看着像随机数字但雪花 ID 的前 41 位是毫秒时间戳天然有序-- 第一批 SELECT * FROM table_name ORDER BY snowflake_id LIMIT 10000; -- 最后一条 snowflake_id 1826735489123456789 -- 第二批 SELECT * FROM table_name WHERE snowflake_id 1826735489123456789 ORDER BY snowflake_id LIMIT 10000;结论雪花 ID 虽然无意义但可以当游标用。场景 3有业务编码且有序比如订单号ORD20250701001、流水号TXN20250701143025001这类编码通常是按时间顺序生成的SELECT * FROM orders WHERE order_no ORD20250701010000 ORDER BY order_no LIMIT 10000;只要业务编码的生成规则是随时间递增的就可以用。场景 4没有有序 ID但有时间戳字段重点讲这是最常见的ID 不自增场景。很多表用 UUID 做主键但大概率有create_time或update_time。问题来了同一秒内有多条记录怎么办假设表里有这些数据create_timeid (UUID)name2025-07-01 10:30:00uuid_aaa张三2025-07-01 10:30:00uuid_bbb李四2025-07-01 10:30:00uuid_ccc王五第一批取了 uuid_aaa第二批要从uuid_abb开始。写法一只用时间WHERE create_time 2025-07-01 10:30:00问题不包含等于同一秒的另外两条数据直接丢了。写法二用时间 大于等于WHERE create_time 2025-07-01 10:30:00问题uuid_aaa又被查出来一次数据重复了。写法三元组比较正确写法WHERE (create_time, id) (2025-07-01 10:30:00, uuid_aaa)元组比较的原理很多人没见过(a, b) (x, y)这种写法其实它是 SQL 标准语法MySQL 和 Doris 都支持。比较规则像字典排序从左到右逐字段比较第一步比较 create_time create_time 10:30:00 → 为真直接成立不用看 id create_time 10:30:00 → 为假直接不成立 create_time 10:30:00 → 继续下一步 第二步比较 id id uuid_aaa → 为真 id uuid_aaa → 为假回到上面的数据WHERE (create_time, id) (2025-07-01 10:30:00, uuid_aaa)uuid_aaacreate_time 相等但 id 不大于 uuid_aaa →跳过不重复uuid_bbbcreate_time 相等id uuid_aaa →命中uuid_ccccreate_time 相等id uuid_aaa →命中精准定位不丢不重。排序的 ORDER BY 也要对应改SELECT * FROM table_name WHERE (create_time, id) (2025-07-01 10:30:00, uuid_aaa) ORDER BY create_time, id LIMIT 10000;排序和过滤条件保持一致都是(create_time, id)的顺序。场景 5只有业务字段且有序如状态码有时候排序键不一定是时间或 ID可能是一个业务字段比如create_date日期精确到天-- 第一批 SELECT * FROM table_name ORDER BY create_date, id LIMIT 10000; -- 最后一条 create_date 2025-07-01, id uuid_xyz -- 第二批 SELECT * FROM table_name WHERE (create_date, id) (2025-07-01, uuid_xyz) ORDER BY create_date, id LIMIT 10000;逻辑和场景 4 一模一样只是字段从时间戳变成了日期。场景 6完全没有有序字段真正用不了的情况如果表里既没有自增 ID、也没有时间戳、也没有任何有序字段——这种情况确实用不了游标分页。但是我想反问一句一张业务表怎么可能没有时间戳如果真的没有这张表的建表方案本身就有问题【我们表是有的所以没问题是第三方不想优化】应该推动建表方加上create_time、update_time字段。方案 A加字段ALTER TABLE table_name ADD COLUMN sync_cursor INT DEFAULT 0; UPDATE table_name SET sync_cursor id; -- 或用 ROW_NUMBER() 生成方案 B退回到 Offset 分页 性能兜底如果短期内改不了退而求其次用 OFFSET 分页。但要注意性能问题后面会讲。-- 华东片区内分页最大 OFFSET 1200 万 SELECT * FROM table WHERE region 华东 ORDER BY id LIMIT 10000 OFFSET 0; SELECT * FROM table WHERE region 华东 ORDER BY id LIMIT 10000 OFFSET 10000; -- ... -- 华北片区内分页 SELECT * FROM table WHERE region 华北 ORDER BY id LIMIT 10000 OFFSET 0; -- ...三、OFFSET 分页的性能兜底方案当你真的只能用 OFFSET 的时候不能放任大 OFFSET直接跑要想办法把一次大扫描拆成多次小扫描。原理用业务条件切片假设表有 5000 万条数据没有任何有序字段但有一个region区域字段SELECT region, COUNT(*) FROM table GROUP BY region;结果regioncount华东1200 万华北1800 万华南2000 万原来整张表 OFFSET 分页最大 OFFSET 5000 万到后面必超时。现在按 region切片每片内单独分页-- 华东片区内分页最大 OFFSET 1200 万 SELECT * FROM table WHERE region 华东 ORDER BY id LIMIT 10000 OFFSET 0; SELECT * FROM table WHERE region 华东 ORDER BY id LIMIT 10000 OFFSET 10000; -- ... -- 华北片区内分页 SELECT * FROM table WHERE region 华北 ORDER BY id LIMIT 10000 OFFSET 0; -- ...每个片区内的数据量小了OFFSET 不会太大单次查询的扫描量可控。比喻你要从一本5000 页的书里找特定内容传统做法是从第 1 页开始翻。翻到第 4000 页的时候前面 4000 页的内容虽然不看但你每次翻页都要经过它们。 性能兜底的思路是先按章节把书拆开每个章节内部最多 1000 页翻起来就不会那么痛苦了。适用场景按日期切片如果哪怕只有精确到天的时间字段按枚举字段切片区域、状态、类型等按首字母/拼音首字母切片实在不行按 ID 的范围粗切比如id % 10 0四、两种分页方式对比对比维度OFFSET 分页游标分页写法LIMIT 10000 OFFSET 50000WHERE id 50000 LIMIT 10000深分页性能越往后越慢扫描量线性增长基本恒定每次只扫描一页数据一致性中间有增删会导致跳页或重复不受中间数据变动影响能否跳页可以跳到任意页只能往后翻不能跳页适用条件任何场景需要有可排序的列适合全量同步不适合后期超时适合五、给第三方开发的建议如果你是做数据同步的开发不管用什么技术栈以下几点值得参考1. 分页拉数据一次不要拿太多-- 别这么干 SELECT * FROM big_table; -- 要这么干 SELECT * FROM big_table ORDER BY xxx LIMIT 10000;2. 优先用游标分页检查你的表有没有这些字段自增 ID → 直接用雪花 ID → 直接用业务编码订单号之类→ 看看是不是有序的create_time / update_time → 配合主键做元组比较3. 别只盯着超时时间超时时间从 600 秒加到 1800 秒只是让问题晚暴露 20 分钟。数据量翻倍之后呢再加到 3600加到 7200超时时间有上限但数据量没有。4. 增量同步 全量同步如果是定时任务拉数据优先用增量方式用WHERE update_time 上次同步时间只拉变更数据能接 Kafka CDC 的就接 CDC实时性好、对源库压力小六、总结你的情况推荐方案有自增 ID / 雪花 ID直接用 ID 做游标分页有时间戳字段最常见用(时间戳, 主键)元组比较有有序业务编码用业务编码做游标没有任何有序字段推动加时间字段或者用 OFFSET 业务切片兜底游标分页不是什么高深的东西核心就是一句话记住上一条在哪从它后面继续找。用好它不需要多牛的技术只需要你对表结构多看一眼大概率就能找到可以做游标的东西。 别再遇到慢查询就说加超时时间了。加超时是最简单的操作也是最危险的操作。