多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

【金仓数据库征文】百万级订单深分页实战:索引已经命中,为什么第 50000 页还是慢?

【金仓数据库征文】百万级订单深分页实战:索引已经命中,为什么第 50000 页还是慢? 一开始我只准备在现有的小表上跑几条分页 SQL。执行完才发现数据量太少无论首页还是后面的页码都很快这组结果没有多少分析价值。于是我在 Windows 上重新安装 KingbaseES建立独立实验库生成 200 万条订单。第 1、100、1000、10000 和 50000 页各测 5 次原始结果都保留截图。真正让我继续查下去的是第 50000 页。复合索引已经出现在执行计划里首页也降到 0.047ms可深页仍接近 1.9 秒。我先怀疑计划是不是看错了重新核对节点后才注意到索引仍输出了 100 万行。后面的游标、插入和删除实验都是在这一轮复查后增加的。一、先把环境跑起来KingbaseES V9 直接安装在 Windows 上。创建实例时我填写的实例名是contest_lab选择 PG 兼容模式端口改为 54321。端口没有沿用常见默认值主要是避免与电脑里原有的数据库服务冲突。实例向导执行完成后管控工具中的状态变成“运行中”。我先截下这一页再继续排查端口和连接。PowerShell 中可以看到 54321 已经监听IPv4、IPv6 都有记录。随后在 KStudio 2.0.0 中填写127.0.0.1:54321测试连接成功服务端返回 KingbaseES V009R001C010。到这里后面的 SQL 才有了可重复使用的运行环境。实验数据库名为contest_db。测试时记录到的参数是server_version12.1、shared_buffers128MB、work_mem4MB、effective_cache_size4GB、max_connections100。这些值只用于说明本次实验环境并不是一套推荐配置。二、场景不是“查20条”而是“跳过多少条”我模拟的是运营后台中的商家订单列表固定一个商家按创建时间倒序每页20条。第1页的查询很普通第50000页换算后却是OFFSET 999980。最终虽然仍只返回20条但数据库要先越过前面的999980条记录。本次使用的表结构如下CREATETABLEpagination_lab.orders(idBIGINTPRIMARYKEY,order_noVARCHAR(24)NOTNULL,merchant_idINTEGERNOTNULL,user_idBIGINTNOTNULL,statusVARCHAR(16)NOTNULL,amountNUMERIC(12,2)NOTNULL,created_atTIMESTAMPNOTNULL,paid_atTIMESTAMP,region_codeVARCHAR(8)NOTNULL,remarkVARCHAR(64));排序没有只写created_at DESC而是增加了id DESCORDERBYcreated_atDESC,idDESC排序字段中加入id是一次中途修正。只用created_at DESC时同一秒产生的订单没有唯一先后关系游标也无法只靠时间戳确定位置。因此最终查询统一使用created_at DESC, id DESC后面的游标锚点同样由这两个值组成。第一次造数只插入20万行用来检查字段、状态分布和样例记录。检查没有发现问题我执行TRUNCATE后重新生成200万行。造数规则把偶数ID分给商家42因此这个商家会占据约一半数据可以覆盖需要测试的深页位置。INSERTINTOpagination_lab.orders(id,order_no,merchant_id,user_id,status,amount,created_at,paid_at,region_code,remark)SELECTg::BIGINT,ORD||lpad(g::TEXT,16,0),CASEWHENg%20THEN42ELSE1(g%500)END,100000(g%200000),CASEWHENg%2012THENPAIDWHENg%2016THENSHIPPEDWHENg%2019THENCANCELLEDELSEREFUNDEDEND,((g%200000)100)::NUMERIC/100,TIMESTAMP2023-01-01 00:00:00(((g*47)%94608000)*INTERVAL1 second),CASEWHENg%2016THENTIMESTAMP2023-01-01 00:00:00((((g*47)%94608000)300)*INTERVAL1 second)ELSENULLEND,R||lpad((g%32)::TEXT,2,0),md5(g::TEXT)FROMgenerate_series(1,2000000)ASg;ANALYZEpagination_lab.orders;正式插入结束后全表计数为200万商家42共有100.4万条时间范围从2023-01-01到2025-12-30。表主体约274MB包含索引等对象后约317MB。我把总数和测试ID查询保留下来实验结束清理临时记录时又执行了一遍。三、没有专用索引时LIMIT 20 也救不了深分页基线组只保留建表时的主键不增加与业务查询有关的索引。第50000页使用下面的SQLEXPLAIN(ANALYZE,BUFFERS)SELECTid,order_no,status,amount,created_atFROMpagination_lab.ordersWHEREmerchant_id42ORDERBYcreated_atDESC,idDESCOFFSET999980LIMIT20;我分别记录第1、100、1000、10000和50000页。每档预热一次再执行5次表中取中位数。页码OFFSET中位耗时上层处理/输出行数主要计划特征101579.900ms20Parallel Seq Scan、top-N heapsort10019801684.015ms2000Parallel Seq Scan、top-N heapsort1000199801718.007ms20000部分 external merge出现临时I/O100001999801956.558ms200000external merge500009999802474.909ms1000000external merge、Gather Merge第50000页耗时中位数为2474.909ms。计划从并行顺序扫描开始过滤商家42后进行排序和合并约100万行进入Limit节点最后留下20行。这里的LIMIT 20只约束最终结果并没有把前面节点的处理量限制为20行。四、单列索引建成了执行计划却没有使用第一次尝试针对过滤条件给merchant_id建索引CREATEINDEXidx_orders_merchantONpagination_lab.orders(merchant_id);创建成功后执行ANALYZE再跑原来的分页 SQL。计划没有出现idx_orders_merchant仍是并行顺序扫描和排序。商家42有100.4万条记录接近全表一半这个字段的区分度并不高而且单列索引无法提供created_at DESC, id DESC的顺序。首页中位数为1543.470ms第50000页为2331.461ms。随后删除这个索引改为测试created_atCREATEINDEXidx_orders_createdONpagination_lab.orders(created_atDESC);对象列表中可以查到新索引分页计划中却仍然没有它。该查询还要处理merchant_id42排序中也缺少id一个时间字段没有覆盖完整访问路径。这轮首页中位数是1590.261ms第50000页是2315.793ms节点结构与基线相同。2315.793ms和2331.461ms都小于基线的2474.909ms但我没有把这点差值记作优化效果。原因是新索引根本没有进入计划扫描行数和临时I/O也没有明显变化。结合5轮数据来看它们仍在正常波动范围内。五、复合索引把首页降到0.047ms但还没解决深OFFSET第三次才把过滤列和两个排序列放到同一个索引里CREATEINDEXidx_orders_merchant_created_idONpagination_lab.orders(merchant_id,created_atDESC,idDESC);ANALYZEpagination_lab.orders;这次计划终于变了首页只剩Limit → Index Scan原先的并行扫表、排序和合并节点都不见了。5 次结果为 0.093、0.040、0.047、0.054、0.041ms中位数 0.047ms缓冲区命中数是 4。首页已经足够快我又按原来的页码全部跑了一遍结果如下页码OFFSET中位耗时Index Scan实际输出行数100.047ms2010019800.803ms20001000199806.889ms200001000019998041.467ms200000500009999801894.843ms1000000第 10000 页是 41.467ms到第 50000 页却重新变成 1894.843ms。看到这里我先检查有没有退回顺序扫描答案是否定的计划仍使用刚建立的复合索引。问题在索引节点的实际输出行数它为了满足OFFSET 999980还是交出了 100 万行。复合索引没有失效。它省掉了扫表和排序却省不掉 OFFSET 从开头计数的过程。六、换成游标后深页只读20行游标分页不再告诉数据库“跳过多少行”而是告诉它“从上一页最后一条之后继续”。本次排序键有两个因此游标也必须包含created_at和idSELECTid,order_no,status,amount,created_atFROMpagination_lab.ordersWHEREmerchant_id42AND(created_at,id)(TIMESTAMP2023-01-05 08:34:30,8010)ORDERBYcreated_atDESC,idDESCLIMIT20;我没有手写一个假锚点而是先从第 50000 页前取到真实的末尾位置(2023-01-05 08:34:30, 8010)。把它代入游标 SQL 后连续执行 5 次分别是 0.071、0.059、0.052、0.048 和 0.040ms中位数为 0.052ms。再看计划索引条件里已经带上商家、时间和 ID 边界节点输出 20 行只命中 5 个共享缓冲块。同一位置OFFSET 查询是 1894.843ms游标查询是 0.052ms。用这两次结果相除约为 36439。两条 SQL 使用的是同一个复合索引区别只在读取起点OFFSET 从头走并丢掉前面的行游标从保存的时间和 ID 后面接着读。快并不代表结果一定对。我把同一位置的 OFFSET 结果和游标结果各保存 20 行再分别做一次差集。两边独有的记录数都是 0也就是offset_only0、cursor_only0。七、性能之外我更在意翻页是否稳定1. 相同时间必须有第二排序键核对正式数据的时候我发现200 万行里的created_at居然没有重复值这样反而测不出同时间戳下的排序边界。于是我临时插了 3 条时间完全一致的订单ID 特意按 101、103、102 的顺序写入。查询结果稳定是 103、102、101说明排序确实是按id DESC来的不是碰巧沿用了插入顺序。这步还踩了个小坑。测试完我单独执行了ROLLBACK本以为 3 条记录已经回滚掉了结果一查还在。后来才反应过来KStudio 这个连接开了自动提交单独跑ROLLBACK根本没用。最后我按预留的测试 ID 逐条删掉再核对才清零。也正是因为这次失误后面每次清理数据我都以实际查询结果为准不再光看命令有没有执行成功。2. 顶部插入新订单OFFSET出现重复和漏数插入测试先保存原始第一页、第二页再向列表顶部加入一条订单。重新读取第二页时OFFSET结果与已经读过的第一页重复1条并且缺少原第二页中的1条使用原第一页末尾锚点继续查询游标结果的重复数、漏数均为0。新增记录改变了后续数据的绝对位置OFFSET 20仍按变化后的行号切分。原游标保存的是上一页末尾记录的时间和ID这个排序边界没有随顶部插入发生变化。3. 第一页删除记录OFFSET漏一条又多带一条删除实验使用单独的测试商家和41条隔离记录。保存前两页后从第一页删除1条。OFFSET重新取得的第二页相对原结果少1条又从后续位置带入1条旧锚点游标没有出现这两种差异。最后一步是清理。我按预留ID删除临时订单再删除隔离测试商家的记录。查询结果回到总表200万行、商家42为100.4万行测试残留为0。索引也重新核对过只保留主键和最终复合索引。八、我的落地选择不要把游标当成OFFSET的无条件替代品方案浅页表现深页表现任意跳页数据变化时更适合的场景无专用索引OFFSET慢更慢支持容易漂移不建议用于本场景复合索引OFFSET很快随OFFSET线性退化支持仍可能重复或漏数页数受控的管理后台复合索引游标很快基本不受逻辑页码影响不直接支持连续翻页更稳定信息流、API、移动端列表混合方案浅页体验好深页改游标或导出有限支持依实现而定同时存在跳页和连续浏览的系统做完这些测试我不会把OFFSET全部替换为游标。后台列表需要输入页码时OFFSET使用简单浅页性能也可以接受不过要限制最大跳转深度。连续加载的列表和API没有任意跳页需求可以返回上一页末尾的时间、ID作为下一次游标。游标参数还要处理编码、防篡改和筛选条件绑定并不是零成本方案。再深的历史检索则更适合做成异步导出。结合本次场景具体处理如下商家过滤并按时间、ID倒序的查询使用(merchant_id, created_at DESC, id DESC)后台浅页保留 OFFSET同时限制可跳转的最大页数连续下拉、API 和深页浏览传递created_at id游标大范围历史检索走异步导出上线前重新使用生产数据分布和并发量压测不照搬本机毫秒数。九、结语这次测试改变了我看执行计划的顺序。看到索引命中以后检查并没有结束还要继续看节点实际输出行数、排序和临时I/O。第50000页使用了正确的复合索引却仍输出100万行耗时重新升高的原因就在这里。两次单列索引没有进入计划KStudio自动提交还造成了一次无效回滚。这些过程提醒我建完索引要重新读取计划清理测试数据也要查询确认。比较分页方案时耗时和结果一致性需要放在一起看。全部数据来自同一台Windows电脑上的KingbaseES V009R001C010实例。硬件、缓存、并发和数据分布改变后绝对耗时也会改变因此文中的毫秒数不能直接作为生产指标执行计划中的扫描量、排序方式和OFFSET处理行数才是更值得复查的部分。
返回列表