慢 SQL 优化别急着加索引:从订单分页查询聊聊 KES 执行计划
订单列表第一页打开很快,翻到后面却越来越慢。接口每次只返回二十条记录,表上也建了索引,为什么还会出现这种情况?
遇到这类问题,很容易先想到"再加一个索引"。但索引解决的是数据访问路径问题,并不能自动消除查询本身要求数据库完成的工作。一个带有大偏移量的分页查询,即使走了索引,也可能需要经过大量记录,才能拿到真正要返回的那二十条。
这篇文章以电科金仓数据库 KingbaseES(以下简称 KES)中的订单分页为例,讨论如何从 SQL、执行计划和业务交互三个层面定位问题。示例采用教学用表结构,不引用虚构的生产压测数据;具体执行计划需要在自己的 KES 版本、兼容模式和数据分布下验证。 
一、先确认慢的是哪条 SQL
后台页面转了三秒,不代表订单查询就在数据库里执行了三秒。
一次分页接口可能先查询总条数,再查询当前页,随后逐条补充客户名称、商品信息。如果只拿分页 SQL 去优化,很可能改了半天,接口耗时仍然没有明显变化。真正拖慢页面的,可能是那条每次都重新计算的 COUNT(*),也可能是代码里循环执行的几十次查询。
因此,开始分析前,至少要拿到三样东西:接口实际执行的 SQL、对应的参数,以及各条 SQL 的耗时。若应用还要等待数据库连接,连接池等待时间也应单独记录。
这里特别容易漏掉参数。
同一条 SQL,查询一个只有几百笔订单的租户,与查询一个拥有几百万笔订单的租户,执行情况可能差很多。时间范围从一天扩大到一年,也不能继续沿用原来的判断。如果应用采用预编译语句,排查时还要保留参数类型和调用方式,不能只把参数替换成字面量,就认定测试环境完整复现了现场。
我的习惯是先把问题描述写具体:
"订单查询慢"还不能指导优化;"某租户查询整月已支付订单,第一页正常,偏移十万条后明显变慢"就已经提供了很有价值的线索。
二、把查询条件摆在一起看
假设订单列表只需要以下字段:
sql
CREATE TABLE demo_order_page (
order_id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
order_status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL,
pay_amount NUMERIC(12, 2) NOT NULL
);
这张表省略了订单明细、收货信息等内容,目的是把讨论集中在筛选与分页上。验证性能时,需要准备具有代表性的测试数据;空表或者几十条记录只能帮助检查语法,无法说明真实访问成本。
业务查询如下:
sql
SELECT order_id, created_at, pay_amount
FROM demo_order_page
WHERE tenant_id = 42
AND order_status = 'PAID'
AND created_at >= TIMESTAMP '2026-09-01 00:00:00'
AND created_at < TIMESTAMP '2026-10-01 00:00:00'
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 100000;
这条 SQL 实际上提出了几项要求:找到指定租户的已支付订单,限制创建时间,按时间和订单编号倒序排列,再跳过前十万条结果,返回二十条。
其中,LIMIT 20 只限制最终返回数量,并不意味着数据库只需要处理二十条记录。KINGBASE 研究院的查询计划分析文章就展示过这种差别:带有偏移量的查询,底层节点产生的记录数可能明显多于顶层返回数。博客园
还有两个细节值得保留。
首先,时间条件使用左闭右开的区间。查询九月份数据时,将上界写成十月一日,比拼接"九月三十日最后一秒"更清楚,也不会遗漏带有更细时间精度的记录。
其次,排序中保留了 order_id。多笔订单完全可能拥有相同的创建时间,只按 created_at 排序,无法确定这些记录之间的先后关系。把唯一订单编号作为第二排序字段,才能给分页建立明确的顺序。
不过,确定排序顺序并不等于固定了整个结果集。如果翻页过程中发生新增或修改,数据集合仍然可能变化,后面还会讨论这个问题。
三、执行计划应该回答什么
KES 提供 EXPLAIN 查看执行计划,也支持通过 EXPLAIN ANALYZE 获取实际执行信息。分析时可以先看估算计划,再决定是否在合适的环境中执行带统计信息的查询。官方手册明确说明,ANALYZE 选项会实际执行语句,而不是只生成一份说明。KingbaseES产品手册
对于上面的只读查询,可以这样观察:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, created_at, pay_amount
FROM demo_order_page
WHERE tenant_id = 42
AND order_status = 'PAID'
AND created_at >= TIMESTAMP '2026-09-01 00:00:00'
AND created_at < TIMESTAMP '2026-10-01 00:00:00'
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 100000;
这条命令本身会执行查询,因此一条原本耗时较长的 SQL,带上执行分析后也不会变得轻量。不要为了收集计划,在繁忙时段反复运行已经确认开销很大的查询。
第一次阅读计划,不必急着研究每个字段。先围绕几个问题寻找证据:
| 观察位置 | 要回答的问题 |
|---|---|
| 扫描节点 | 从哪里读取数据,实际产生了多少行? |
| 筛选条件 | 条件用于缩小索引访问范围,还是读出后才过滤? |
| 排序节点 | 是否需要额外排序,是否出现磁盘排序? |
| 估算与实际行数 | 优化器对数据规模的判断是否接近实际? |
BUFFERS |
访问了多少缓冲块,是否存在大量读取或临时块访问? |
计划中的 cost 是优化器使用的成本估算值,不能直接当作毫秒。实际执行时间则需要结合节点的层级和循环次数理解,不能简单地把父子节点时间全部相加。KES 官方文档将执行计划描述为一棵节点树,子节点向父节点提供结果,这也解释了为什么只盯着最上方的 Limit 节点不够。KingbaseES产品手册
BUFFERS 也需要谨慎解读。缓冲命中和读取信息有助于理解数据访问量,但数据库缓冲区发生读取,并不能直接推断底层存储设备完成了同等次数的物理读操作,中间还可能经过操作系统缓存。KES 文档提供了共享块、本地块和临时块等统计说明,排查时应区分这些指标。KingbaseES产品手册
针对这条分页 SQL,我更关心的是:为了最后返回二十条,底层究竟经过了多少条记录?这些记录是否还要排序?答案往往比"有没有用索引"更能解释问题。
四、联合索引要对应完整的访问方式
对于当前这组固定条件,可以考虑以下联合索引:
sql
CREATE INDEX idx_demo_order_page_query
ON demo_order_page (
tenant_id,
order_status,
created_at DESC,
order_id DESC
);
这里的排列有明确目的。
tenant_id 和 order_status 都是等值条件,放在前面用于限定访问范围;后面的创建时间和订单编号对应查询的排序顺序。KES 的索引管理文档介绍了多列索引以及列排序方向,这些能力可以用于设计与查询条件相匹配的访问路径。KingbaseES产品手册
但这个设计有前提:查询始终限定单个租户和单个状态。
如果另一种页面允许同时查询多个状态,甚至不传状态条件,那么这条索引对排序的帮助就可能发生变化。不能因为两个页面都查询订单表,就认定它们适合共用同一种索引设计。
pay_amount 没有包含在这条索引里,因此也不能把它描述成覆盖了本次查询的所有字段。执行时是否需要进一步访问表数据,应以实际计划为准。
数据准备过程也会影响判断。比如,刚批量导入大量测试订单后,应考虑更新统计信息:
sql
ANALYZE demo_order_page;
这样做是为了让优化器获得更接近当前数据分布的统计信息,并不是要求每次查询前都执行一次。KES 的执行计划分析文档将统计信息列为影响计划选择的重要因素。KingbaseES产品手册
加完索引后,应当重新观察执行计划。可能出现的改善包括减少无关记录访问、避免额外排序,但不能预先承诺一定选择某个节点,更不能保证某个固定的加速倍数。
索引也会占用空间,并增加写入维护成本。订单状态频繁变化时,包含状态字段的索引同样需要维护。是否值得增加这一条索引,要结合查询收益与写入负担判断。
五、索引补上了,深分页为什么仍然慢
假设索引已经帮助查询按需要的顺序读取记录,额外排序也消失了,OFFSET 100000 仍然存在。
数据库依旧需要越过前面的匹配结果,才能返回后面的二十条。索引改善了访问路径,却没有改变这条 SQL 的分页要求。
如果页面交互只是"继续加载"或者"下一页",可以改为记录上一页最后一条订单的位置,再从这个位置之后继续查询。
例如,上一页最后一条记录为:
text
created_at = 2026-09-18 10:20:30
order_id = 987654
下一页查询可以表达为:
sql
SELECT order_id, created_at, pay_amount
FROM demo_order_page
WHERE tenant_id = 42
AND order_status = 'PAID'
AND created_at >= TIMESTAMP '2026-09-01 00:00:00'
AND created_at < TIMESTAMP '2026-10-01 00:00:00'
AND (
created_at < TIMESTAMP '2026-09-18 10:20:30'
OR (
created_at = TIMESTAMP '2026-09-18 10:20:30'
AND order_id < 987654
)
)
ORDER BY created_at DESC, order_id DESC
LIMIT 20;
这个条件处理了两种情况:创建时间更早的订单,以及创建时间相同、编号更小的订单。因为使用倒序排列,这两类记录才位于上一页最后一条记录之后。
只写 created_at < 上一页时间 会漏掉同一时间下尚未返回的订单,这也是分页改写中很常见的错误。
还要留意执行层面的边界:上面使用 OR 清楚地表达了分页逻辑,但不同环境下,优化器如何利用这个条件,需要查看实际计划。删除 OFFSET 并不自动保证数据库可以从游标位置高效开始扫描。 如果游标条件主要表现为扫描后的过滤,仍可能访问很多记录,需要进一步检查索引条件和实际扫描量。
应用侧则应将上一页返回的完整时间精度和订单编号原样保存,下一页通过绑定参数传入。若前端将时间截断到秒,而数据库保存了更细的精度,分页边界就可能失真。
这种方式适合顺序翻页,但不直接支持任意跳转到"第 5000 页"。如果业务确实要求随机跳页,就需要讨论页码范围、检索条件、历史归档或其他定位方式,不能只修改 SQL 而忽略使用习惯。
另外,固定排序也无法保证跨多次请求的数据快照一致。翻页期间订单状态发生改变,记录可能进入或退出结果集。对于要求完整一致的数据导出,应单独设计快照或一致性方案,不能把普通列表分页直接当成导出机制。
六、验证时,别让一次变快掩盖了问题
优化完成后,仅记录一次查询耗时远远不够。
第一次执行可能涉及较多读取,第二次执行可能已经命中缓存。若把优化前的首次执行与优化后的重复执行直接比较,结果容易夸大收益。测试应说明缓存状态,进行多次观察,并保持数据范围、返回字段和测试负载尽量一致。
比较偏移分页与游标分页时,还要保证两者获取的是同一段结果。游标应来自目标位置之前的记录,不能拿原来的第几千页与改写后的第一页比较,然后据此宣布性能提高了多少倍。
我会至少检查以下几组情况:
- 小租户与大租户,确认数据倾斜是否影响计划。
- 短时间范围与长时间范围,观察扫描量如何变化。
- 第一页与深页,区分基础查询成本和偏移成本。
- 单次查询与业务并发,确认改善能否在实际负载下保持。
- 查询性能与写入开销,评估新增索引的整体影响。
正确性也必须一起验证。可以专门准备多条创建时间相同的订单,在固定数据集上连续翻页,检查是否遗漏或重复。分页查询快了,却丢掉了边界上的几条记录,显然不能算优化完成。
最后别忘了回到接口。如果列表查询已经变快,页面仍然迟迟不返回,就继续检查总数统计、关联查询和结果处理。数据库执行计划能解释一条 SQL 的工作,却不能替代对整个请求链路的分析。
对于这类订单分页,我更愿意把优化结果写成具体变化:减少了多少扫描记录、是否去掉了额外排序、深页是否还要越过大量结果,以及写入开销是否可以接受。KES 提供的执行计划和缓冲统计,正好可以帮助我们逐项验证这些判断。
一条 SQL 最终只返回二十行,它背后可能已经处理了十万行。先把这部分工作看清楚,再决定补索引还是调整分页方式,优化才有明确的落点。