前几天排查一个线上慢查询,发现罪魁祸首居然是一条看起来很普通的分页 SQL。两张表关联查询,数据量不过百万级,用 Row_Number() 分页翻到后半段时,接口响应直接飙到 8 秒多,数据库 CPU 被打到 70% 以上。这让我重新审视了一遍 SQL Server 的分页优化问题,也把 Row_Number() 分页存在的几个坑彻底理清楚了。这篇文章就围绕这次优化过程展开,既讲原理,也给出可落地的替代方案,希望给正在被分页性能折磨的朋友一些参考。
1. 问题现场:一次慢查询引发的分页反思
1.1 业务背景与表象
这是一个典型的后台管理系统列表页,主表存放订单,子表存放订单明细,前端按创建时间倒序分页展示。初期数据量只有十几万的时候,Row_Number() 分页跑得飞快,基本感觉不到延迟。后来业务推广,数据量涨到接近两百万,问题就暴露了:翻到第 50 页之后,页面加载明显变慢,再往后翻基本要等八九秒才能出数据。
起初我以为是数据库服务器负载过高,看了监控才发现 CPU 和内存都正常,但这条分页 SQL 的逻辑读非常高,单次执行达到十几万次。问题定位到 SQL 本身,而不是硬件资源不够。这其实很典型——分页性能的瓶颈通常不是机器,而是写法和索引之间的匹配度出了问题。
1.2 最初的 Row_Number() 分页写法
当时的 SQL 长这样:
WITH OrderPage AS ( SELECT ROW_NUMBER() OVER (ORDER BY o.CreateTime DESC) AS RowNum, o.OrderId, o.OrderNo, o.CustomerName, o.CreateTime, d.DetailInfo FROM dbo.Orders o INNER JOIN dbo.OrderDetails d ON o.OrderId = d.OrderId ) SELECT * FROM OrderPage WHERE RowNum BETWEEN 10001 AND 10020 ORDER BY RowNum;这段 SQL 在逻辑上没有任何问题:先给结果集编号,然后取第 10001 到 10020 条。但问题恰恰出在"先编号"这一步——它要把满足条件的所有数据都排好序、编好号,然后才去截取这 20 条。数据量小的时候无所谓,数据量一大,前面的排序和编号操作就变成了巨大的浪费。
1.3 性能瓶颈初现
我用 SET STATISTICS IO ON 和 SET STATISTICS TIME ON 实测了一下,这一条查询的逻辑读接近 18 万,CPU 耗时 6.8 秒。执行计划里出现了两个明显的代价点:排序操作占了 47%,表连接操作占了 31%。更关键的是,整个执行计划显示,优化器为了算 Row_Number(),必须把两张表关联后的所有行全部物化排序,即使最终只取 20 行,也逃不掉全量计算的命运。
我尝试给 Orders 表的 CreateTime 建了索引,但效果有限。原因在于:Inner Join 加上排序键不在驱动表上,索引无法直接满足排序和筛选的双重需求。这就是 Row_Number() 分页的第一个隐患——它对执行计划的要求很高,一旦排序键和过滤条件无法完全匹配索引,优化器就可能选择显式排序,性能瞬间崩塌。
2. Row_Number() 分页为什么慢?核心原理拆解
2.1 Row_Number() 分页的执行逻辑
Row_Number() 是 SQL Server 2005 引入的窗口函数,它的作用是在结果集内按指定顺序生成行号。分页时我们通常这样用:外层查询通过 RowNum BETWEEN 条件截取目标区间的数据。
问题在于,Row_Number() 是在结果集生成之后才进行计算的。什么意思呢?SQL Server 需要先执行 FROM、JOIN、WHERE 这些子句,得到完整的结果集,然后根据 ORDER BY 子句对结果集排序,排序完成后才逐行赋予行号,最后外层 WHERE 过滤掉不需要的行。
这个过程里,你翻到第 100 页和翻到第 1 页,工作量几乎一样。第 1 页虽然只需要前 20 行,但仍然要把所有行排序、编号,然后从 100 万行里取前 20 行。唯一的差别可能在 TOP 优化上——如果你用 TOP (20) 配合 ORDER BY,优化器可能提前终止排序;但 Row_Number() 的写法通常是一个 CTE 或子查询,外层再过滤,优化器一般不会把这个逻辑下推,导致全量排序不可避免。
2.2 大偏移量下的"无用功"
可以这样理解:Row_Number() 分页就像一本没有目录、没有书签的纸质书。你想看第 100 页的内容,只能从第 1 页开始一页一页翻过去,翻到第 100 页才能停。虽然最终你只看了第 100 页那一页的内容,但前面 99 页你虽然没有细看,却必须做"翻页"这个动作。
在数据库层面,那个"翻页"动作就是对前 10000 行数据完成排序和编号。更糟糕的是,排序不是免费的,尤其是指定 CreateTime DESC 这种非聚集索引排序键时,排序操作可能还要回表查询其他字段。每多翻一页,工作量线性增长,最终导致接口延迟越来越高。
2.3 排序不稳定带来的隐蔽 Bug
这是 Row_Number() 分页最容易被忽视的问题——如果 ORDER BY 的字段不是唯一键,那么数据分布不均匀时,分页结果可能出现重复或缺失。
举个例子:列表按 CreateTime 倒序排列,同一秒内可能插入多条数据。ROW_NUMBER() 在排序时如果遇到 CreateTime 相等的行,SQL Server 会按物理顺序随便给个先后。当你执行第一页查询时,某条记录可能排在第 19 位;再执行第二页查询时,由于数据变化或执行计划差异,同样的记录可能变成第 21 位。于是你会在第一页看到它,第二页也看到它,或者两页都看不到它。
这种问题在测试环境很难复现,因为数据量小、并发低、物理顺序稳定。但生产环境数据持续写入,加上并行执行计划可能造成不同的数据分布,翻页到后面时很容易出现"重复记录"反馈。用户可能不一定会立刻发现,但一旦被提 bug,排查起来非常痛苦。
2.4 索引使用上的陷阱
有人会说,那我给排序键建个索引不就行了?实际上,对于 Row_Number() 分页,这里有个常见误区:给 CreateTime 建了索引,但结果集需要返回 * 或大量列,而索引里没有这些列,SQL Server 必须对每一行执行 Bookmark Lookup(键查找)。当筛选范围很大时,键查找次数也会很多,整体性能反而比全表扫描更差。
另外,如果分页 SQL 里带了 JOIN,驱动顺序也会影响索引选择。订单表按 CreateTime 建了索引,优化器可以选择先扫订单表、再去关联明细表;但如果优化器统计信息不够新,它可能会先扫描明细表,再回订单表查找,此时 CreateTime 索引根本用不上。这个问题我在排查时遇到过多次,最后都是靠更新统计信息或者加查询提示解决的。
3. 优化方案一:OFFSET ... FETCH 的改进与局限
3.1 从 Row_Number 切换到 OFFSET ... FETCH
SQL Server 2012 引入了 OFFSET ... FETCH,这是标准 SQL 的分页语法。把原来的 CTE 写法改成这样:
SELECT o.OrderId, o.OrderNo, o.CustomerName, o.CreateTime, d.DetailInfo FROM dbo.Orders o INNER JOIN dbo.OrderDetails d ON o.OrderId = d.OrderId ORDER BY o.CreateTime DESC OFFSET 10000 ROWS FETCH NEXT 20 ROWS ONLY;相比 Row_Number(),这种写法更简洁,执行计划里不再有专门的 ROW_NUMBER 计算算子,而是通过表 Spool 或直接定位到偏移位置后读取目标行。在某些场景下,OFFSET ... FETCH 能更好地利用索引顺序,避免显式排序。比如 ORDER BY 字段正好是索引键,优化器可以直接从索引的指定位置开始扫描,扫描到目标行数即停止。
我做了个简单对比:同样的表和数据,OFFSET ... FETCH 翻第 501 页时,逻辑读从 18 万降到了 9 万,耗时从 6.8 秒降到了 3.2 秒。性能提升明显,但代价仍然不小。
3.2 为什么 OFFSET 也不够完美
很多人以为把 Row_Number() 换成 OFFSET ... FETCH 就万事大吉,实际不然。OFFSET ... FETCH 的问题在于:它仍然需要先跳过 10000 行,而跳过这些行的过程中,SQL Server 依然会读取它们的索引条目或堆数据。
换句话说,OFFSET 10000 并不是直接"跳"过去,而是"数"过去。虽然不需要把所有行的所有列都取出来,但索引扫描仍然要从第一行开始一行一行地往后数,数到 10000 后再取 20 行。这个"数行数"的操作在大偏移下仍会消耗大量 IO。
在 SQL Server 2012 之后的版本里,如果 ORDER BY 字段有合适的索引,OFFSET ... FETCH 的执行计划会显示一个"Index Scan + Top N Sort"或"Index Seek + Key Lookup"的组合,但本质上索引扫描还是要遍历偏移量之前的所有索引行。数据量越大、偏移量越大,耗时依然线性增长。
3.3 实测对比:Row_Number vs OFFSET
我把两种写法在 100 万行数据下做了多轮测试,结果如下表(数据为本地环境实测,单位毫秒):
| 分页位置 | Row_Number() 耗时 | OFFSET ... FETCH 耗时 | 逻辑读(Row_Number) | 逻辑读(OFFSET) |
|---|---|---|---|---|
| 第 1 页(前 20 行) | 180 | 35 | 2100 | 180 |
| 第 50 页(995-1015行) | 420 | 190 | 8100 | 3400 |
| 第 500 页(9980-10000行) | 6800 | 3200 | 182000 | 91000 |
| 第 5000 页(99800-99820行) | 超出了测试耐心 | 26800 | 跑不完 | 852000 |
可以看到,OFFSET ... FETCH 虽然在每一档都比 Row_Number() 快一倍左右,但依然存在大偏移量导致的性能衰减。翻到第 5000 页时,2.68 秒的延迟仍然无法接受。这时候我才意识到,要根治分页性能问题,不能只在"取数方式"上打补丁,而是要改变分页模型本身。
4. 优化方案二:键集分页(Keyset/Seek Method)的真正落地
4.1 键集分页的核心思想
所谓键集分页,英文叫 Keyset Pagination 或 Seek Method,核心思想一句话:不要告诉数据库"我要跳过多少行",而是告诉它"我要从哪一行之后开始取"。
传统分页的语义是"给我第 N 页",键集分页的语义是"给我上次看到的那条记录之后的 20 条"。这样数据库可以利用索引直接定位到目标位置,避免扫描之前所有的行。整个过程跟翻书完全不同,更像查字典——你知道某个字在哪个页码附近,直接翻过去,而不是从第一页开始数。
对应到 SQL 写法,你要把上一页最后一条记录的排序键值作为查询条件传进来。比如上一页最后一条记录的 CreateTime 是 '2024-06-15 14:30:20',OrderId 是 12345,那么下一页查询就可以写成:
SELECT TOP (20) o.OrderId, o.OrderNo, o.CustomerName, o.CreateTime, d.DetailInfo FROM dbo.Orders o INNER JOIN dbo.OrderDetails d ON o.OrderId = d.OrderId WHERE (o.CreateTime < '2024-06-15 14:30:20') OR (o.CreateTime = '2024-06-15 14:30:20' AND o.OrderId < 12345) ORDER BY o.CreateTime DESC, o.OrderId DESC;注意,这里 WHERE 条件的写法不是为了简单过滤,而是为了构造一个符合排序键顺序的 SARG 条件,让 SQL Server 能够用上索引 Seek,直接定位到目标位置往下读 20 行。这样查询成本永远不会随着页码增加而增加,永远只扫描 20 行左右。
4.2 具体 SQL 写法与参数化
实际开发中我们不会在 SQL 里硬编码值,而是使用参数。上面的写法可以改造成如下形式:
DECLARE @LastCreateTime DATETIME = '2024-06-15 14:30:20'; DECLARE @LastOrderId INT = 12345; SELECT TOP (20) o.OrderId, o.OrderNo, o.CustomerName, o.CreateTime, d.DetailInfo FROM dbo.Orders o INNER JOIN dbo.OrderDetails d ON o.OrderId = d.OrderId WHERE o.CreateTime < @LastCreateTime OR (o.CreateTime = @LastCreateTime AND o.OrderId < @LastOrderId) ORDER BY o.CreateTime DESC, o.OrderId DESC;这段 SQL 对索引的要求是:需要在 Orders 表上建立 (CreateTime, OrderId) 的复合索引。为什么必须有 OrderId 作为第二键?因为 CreateTime 可能重复,需要唯一键来保证排序的确定性。如果 OrderId 是主键且是递增的,那它天然适合作为第二排序键。
这里有个细节需要注意:如果你用了 OR 条件,优化器有可能把 OR 展开成两个分支再合并,这仍然可以走索引,但更推荐的写法是用 UNION ALL 明确区分两个分支:
SELECT TOP (20) o.OrderId, o.OrderNo, o.CustomerName, o.CreateTime, d.DetailInfo FROM dbo.Orders o INNER JOIN dbo.OrderDetails d ON o.OrderId = d.OrderId WHERE o.CreateTime < @LastCreateTime UNION ALL SELECT TOP (20) o.OrderId, o.OrderNo, o.CustomerName, o.CreateTime, d.DetailInfo FROM dbo.Orders o INNER JOIN dbo.OrderDetails d ON o.OrderId = d.OrderId WHERE o.CreateTime = @LastCreateTime AND o.OrderId < @LastOrderId ORDER BY CreateTime DESC, OrderId DESC;但这样会多出一次连接和排序,实际测试下来,如果复合索引设计合理,OR 写法反而能生成更紧凑的执行计划。这一点我建议你根据实际执行计划来选择,而不是盲目迷信网上教程。我最终采用了 OR 写法,配合 OPTION (RECOMPILE) 避免参数嗅探,实测执行计划里看到了清晰的 Index Seek。
4.3 索引设计与排序键的选取
键集分页对索引的依赖比传统分页高得多。没有正确的索引,键集分页的性能不一定比 OFFSET 好,甚至可能更差。我的经验是,排序键必须满足以下几个条件:
第一,排序键至少包含一个唯一列。主键是最直接的选择,如果主键是自增列,那么业务时间字段加主键的组合基本够用。第二,排序键的顺序必须与 ORDER BY 完全一致,包括 DESC/ASC。第三,WHERE 条件里的列必须出现在索引中,并且最好是索引的前导列。
比如上面那个例子,索引应该建在 Orders 表上:
CREATE NONCLUSTERED INDEX IX_Orders_CreateTime_OrderId ON dbo.Orders (CreateTime DESC, OrderId DESC);建立这个索引后,执行计划会非常漂亮:索引 Seek 定位到 (CreateTime, OrderId) 小于参数值的第一个位置,然后顺序读取 20 行,每行回到聚集索引取其他字段。由于只读 20 行,键查找次数也就是 20 次左右,逻辑读从几万瞬间降到几十。
不过要注意,创建 DESC 还是 ASC,取决于你的排序方向。如果列表经常用正序排,那就建 ASC;如果经常倒序排,就建 DESC。SQL Server 的索引键方向对性能的影响不容忽视,方向不一致时优化器可能放弃 Seek,改用反向扫描,反向扫描的效率通常不如正向扫描。
4.4 与前端交互的接口设计
键集分页没法直接兼容传统"翻页"的页码 UI,因为它不提供"跳到第 5 页"的能力。所以接口层面需要调整。常见做法有两种。
一种是"上一页/下一页"模式:后端返回当前页最后一条记录的排序键值,前端把它作为参数随下一次请求带上。这种模式最经典,也最容易被接受。另一种是"无限滚动"模式:移动端不断下拉加载,后端每次返回下一页数据,同时返回 nextKey,前端拿到 nextKey 作为下次请求的游标。这两种模式的本质一样,都是把"位置"交给业务缓存,而不是每次去数据库计算。
如果你的产品经理坚持要做页码跳转,那键集分页就无能为力了。这种情况我建议用混合方案:前几页用 OFFSET ... FETCH 支持页码跳转,数据量超过一定阈值后自动切到键集分页的"上一页/下一页"模式。甚至可以在页码控件上做文章,比如只显示最近几页和位置指示符,跳转按钮实际上只是滚动加载的变种。
5. 实操记录与踩坑实录
5.1 一个容易被忽略的排序字段陷阱
我在改造时遇到了一个特别隐蔽的问题:业务需求里要求的排序顺序是CreateTime 倒序,但 CreateTime 是 DATETIME 类型,精度只到 3 毫秒。在高并发下,同一批次导入的数据可能在毫秒级内有大量重复值。单靠 CreateTime 排序,哪怕加了 OrderId 作为第二键也还是会出现问题——因为 OrderId 是自增主键,但它只记录插入顺序,如果某些历史数据是通过 ETL 工具批量导入的,OrderId 的顺序可能与 CreateTime 的顺序不一致。
举个例子:A 记录 CreateTime 为 12:00:00.100,OrderId 为 1000;B 记录 CreateTime 为 12:00:00.100,OrderId 为 1001。按 CreateTime DESC, OrderId DESC 排序后,B 在 A 前面。这个顺序在数据导入后是固定的,但如果后续有补数操作,给 A 的 CreateTime 改成了 12:00:00.200,那这两条的相对顺序就变了。这种变化如果发生在用户翻页过程中,就可能造成重复或遗漏。
解决办法是,把排序键从业务时间字段完全切换到一个不可变且唯一的字段上。比如使用 OrderId 作为唯一的排序键,不要跟着 CreateTime 走。如果业务上必须按创建时间倒序,那就要保证 CreateTime 的写入顺序和自增序列一致,并且不允许业务修改 CreateTime。我们把表结构从"可修改 CreateTime"调整成了"通过触发器禁止更新 CreateTime",才算彻底稳定了排序。
5.2 当数据发生变更时键集分页如何处理
键集分页的另一个常见疑虑是:如果上一页最后一条记录在用户浏览期间被删除了,下一页查询把它作为条件时怎么办?
答案很直接:如果是删除,查询条件会找不到对应的 CreateTime 和 OrderId 组合,但 SQL Server 不会报错。它会在索引里寻找下一个符合条件的记录并继续返回,相当于自动跳过被删除的那条。如果你拿到的游标是上一页最后一条的完整排序键,那么即使这一条刚被删除,下一页查询依然可以正常定位,因为查询条件是"小于",不是"等于"。
如果是更新,问题会复杂一些。比如上一页最后一条记录的 CreateTime 被更新了,导致它排到了别的页。这时下一页查询携带的旧 CreateTime 可能找不到任何记录或者位置偏移。解决思路是:尽量用不可变字段作为排序键,避免更新导致游标失效。如果排序键是 OrderId 这种自增且永不更新的字段,就根本不用担心这个问题。
我在项目里的做法是:将 OrderId 作为游标主键,CreateTime 只作为展示字段,查询排序也只用 OrderId DESC。虽然业务上看起来"最新创建的订单排在前面"变成了"最新插入的订单排在前面",但由于订单创建的基本都是即时插入,这个偏差不大。如果遇到历史数据迁移导致 OrderId 顺序与实际创建时间不一致,那就需要单独维护一个业务序号字段,比如 Version 或 Sequence,保证它的逻辑顺序和业务顺序一致。
5.3 单条查询优化到毫秒级后的整体效果
改完键集分页后,我重新跑了之前那个慢查询。同样的位置,翻到第 5000 页,SQL 耗时稳定在 30 毫秒左右,逻辑读只有 48 次。这个效果可以说是质的变化,而且页码越深,优势越大。
更重要的是,这个优化不仅拯救了分页接口,还降低了整个数据库的负载。原来高峰期数据库 CPU 达到 70%,大量 IO 被分页查询拖垮;优化后,同样的业务流量下,CPU 峰值只有 20% 出头。后台管理页面的响应时间从 8 秒降到了 200 毫秒以内,产品经理都忍不住来问做了什么优化。
6. 不同场景下的分页选型建议
6.1 数据量小或管理后台等场景
如果你的表数据量在十万以下,或者用户规模不大,Row_Number() 分页和 OFFSET ... FETCH 的差距完全可以忽略。毕竟一百行数据的排序几乎不耗时间,用键集分页反而会增加开发复杂度。这种情况我不建议你强行改造,保持原有代码风格就好。
管理后台这类场景通常有页码跳转需求,产品不可能快速改成游标模式。那就在现有写法基础上做点简单优化:比如给排序键建立覆盖索引、减少返回列、用 TOP + ORDER BY 代替 Row_Number()。实测下来,覆盖索引配合 OFFSET ... FETCH,在十万级数据量下,翻到最后一页也基本能控制在 100 毫秒以内,足够用了。
6.2 大数据量或高并发互联网场景
数据量超过百万、并发量高、用户频繁翻页的场景,我强烈建议直接用键集分页。尤其是 C 端列表、信息流、日志查询这些场景,用户根本不需要精确页码,只需要"加载更多"按钮或者无限滚动。这类需求与键集分页天然契合。
在设计时,一定要把游标字段的索引设计好。索引如果没建对,键集分页可能在第一次查询就比 OFFSET 还慢。比如前面那个例子,索引必须是 (CreateTime DESC, OrderId DESC),而不是单列索引。另外,游标字段最好返回给前端之后做一次 URL 编码,防止特殊字符导致参数传递出错。
6.3 移动端或无限滚动场景
移动端无限滚动是键集分页最典型的应用场景。每次滚动到底部,客户端拿到下一页的游标,然后请求后续数据。这里有一个容易踩的坑:客户端的滚动加载请求如果用 GET 方式,游标会暴露在 URL 里,可能被日志记录。所以如果有敏感信息,建议用 POST 请求或者对游标做签名校验。
还有一个细节:无限滚动加载时,用户会快速连续触发多次请求。如果服务端不一致,可能导致数据跳过或重复。我的习惯是在接口层加一个"游标防重"机制:如果收到的游标比上一车返回的游标还旧,就直接返回空数据,不做数据库查询,减轻无用压力。
6.4 使用 ORM 框架时怎么落地
很多朋友用的是 EF Core、MyBatis-Plus 这类 ORM 框架,它们默认提供的分页方法都是基于 Skip/Take 或 Limit/Offset 的。以 MyBatis-Plus 为例,它的 Page 对象底层用的是 Row_Number() 或者 LIMIT 偏移量,数据量一大一样会遇到性能问题。
如果你想在 ORM 里落地键集分页,不要期望框架直接支持,通常需要自己写 SQL。MyBatis 里可以用 XML 自定义查询,EF Core 可以通过 FromSqlRaw 写原生查询。我建议把游标分页封装成一个公共组件:输入上一页游标和页大小,输出结果和下一页游标。这样虽然失去了 ORM 的强类型便利,但性能收益是实打实的。
如果实在要在 EF Core 里用键集分页,可以借助 Z.EntityFramework.Plus 或手工构建 Where 子句。不过这些第三方库不一定支持所有 SQL Server 版本,生产环境还是得先做兼容性测试。
7. 常见问题排查速查表
7.1 Row_Number() 分页结果重复或缺失
先检查排序列是否唯一。如果 ORDER BY 的字段没有唯一性约束,就在 ORDER BY 末尾加上主键,保证排序稳定。其次,检查外层查询是否有 JOIN,多张表 JOIN 后如果排序列来自被驱动表,可能会导致每行编号的物理顺序不固定。第三,看看查询执行计划里有没有并行运算符,并行扫描可能打乱输出顺序。可以加 OPTION (MAXDOP 1) 临时验证,如果问题消失,说明并行导致的顺序不稳定。
7.2 使用 OFFSET ... FETCH 后无法与旧版本兼容
OFFSET ... FETCH 是 SQL Server 2012 才引入的语法。如果你要兼容 SQL Server 2008 R2 及更早版本,就只能用 Row_Number()。另外,OFFSET ... FETCH 不能直接在视图或派生表中使用 ORDER BY,这一点和 Row_Number() 不同,容易踩到语法错误。遇到这种情况,可以在子查询里先用 Row_Number() 生成序号,再在外面用 BETWEEN 过滤,或者升级数据库版本。
7.3 参数嗅探导致分页查询不稳定
这个问题在键集分页里也会遇到。SQL Server 会缓存第一次执行时的执行计划,如果第一次传入的游标值选择性很好,之后传入一个选择性很差的值,可能仍然沿用第一次的计划,导致性能下降。我的做法是,在键集分页的查询里加上 OPTION (RECOMPILE)。虽然这会让每次查询重新编译,带来少量 CPU 开销,但对于分页这种每次参数都不同的查询来说,性价比非常高。
另外,如果游标字段是 DATETIME 类型,传入的值如果是字符串,可能需要显式转换。注意转换的方式,最好使用参数而不是直接拼字符串,否则容易造成隐式转换,让索引失效。我在一个项目里发现游标查询很慢,检查执行计划后发现 CreateTime 字段出现了 Convert 算子,就是因为前端传过来的是字符串,SQL 里直接 @LastCreateTime = '2024-06-15 14:30:20',导致 CreateTime 索引无法 Seek。改成参数化查询后性能立刻恢复。
7.4 非分页缓冲池占用过高怎么办
虽然这个热搜词和分页优化本身关系不大,但很多朋友在排查分页性能时可能会同时发现内存计数器异常。如果出现非分页缓冲池(Non-Paged Pool)占用很高,通常与驱动、网卡或某些第三方软件有关。SQL Server 本身并不是非分页缓冲池的主要消费者。这时候可以先通过任务管理器或池监控工具看看哪个进程的 NP Pool 持续增长,再逐步排查。不要急着去调整 SQL Server 内存配置,那是南辕北辙的做法。
7.5 分页 SQL 在宝塔环境中无法识别
有些朋友用宝塔面板部署 SQL Server,安装后面板识别不了数据库。这通常是因为宝塔的数据库管理插件没有正确注册 SQL Server 服务。解决思路是:确保 SQL Server 服务已启动,并且 TCP/IP 协议已启用;在 SQL Server 配置管理器中把监听端口设为 1433;然后在宝塔中添加数据库时,不要用默认的 localhost,而是填 127.0.0.1。如果还不行,检查防火墙是否放行 1433 端口。这个问题本质上与分页优化无关,但出现频率很高,顺手记录一下。
8. 写在最后:一点个人经验
这次优化让我切实体会到,分页从来不是一个孤立的功能点,它牵扯到索引设计、排序稳定性、接口语义甚至产品交互。Row_Number() 不是不能用,但它更适合那些不需要深层翻页、数据量可控的管理类页面。真正面向海量数据的分页,键集分页才是终极方案。
如果你的系统已经跑了好几年,大量分页代码都是 Row_Number() 写法,我建议你不要一次性全改,而是挑一个数据量最大、性能瓶颈最明显的接口做试点。先用键集分页把"下一页"接口做出来,保留原有页码接口,观察线上效果。实测稳定后再逐步推广。我这次就是这么做的,前后花了三个工作日,收益却覆盖了后续一整年的分页类问题工单。
最后再分享一个小技巧:如果你不方便改前端交互,但又要解决深层分页性能问题,可以在数据库层做一个"伪键集"方案——把页码转换成游标。比如每页的 lastId 都缓存进 Redis,页码请求时先从 Redis 拿到 lastId,再走键集分页 SQL。这样既能保留页码输入的方式,又能享受键集分页的性能优势。代价是要缓存好每页的游标,并且数据变动时要及时更新缓存。这个方案我实际验证过,对于后台管理系统是个不错的折中做法。