news 2026/9/15 2:24:14

分页与排序的工程实践:从深翻页到游标分页的平滑迁移

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
分页与排序的工程实践:从深翻页到游标分页的平滑迁移

先说一件我自己踩过的事。前两年我接手一个订单查询服务,数据量到了百万级之后,运营那边陆续反馈“列表页越来越慢”。我最初以为是服务器带宽问题,结果打开慢查询日志发现,有一条SELECT * FROM orders ORDER BY user_id DESC LIMIT 10 OFFSET 200000的语句,居然跑了将近 7 秒。这个user_id恰好没有索引,MySQL 每次都要执行全表排序,再从前 20 万行里把 10 行挑出来。更让我后背发凉的是,这个排序字段是前端直接传的,任何调用方都可以用同一个接口按照数据库表的任意列做排序,等于把一个内部数据结构裸露在了公网 API 上。后来我花了整整两周时间,把分页方案从 offset 切到了游标,把所有排序字段改成白名单映射,数据库 CPU 占用才从 90% 掉回 15%。

这篇文章不打算讲教科书式的理论,只讲两件事:分页怎么做才能扛得住大表,排序怎么设计才安全、不翻车。适合后端开发、API 设计者,也适合正在被慢查询和深翻页折磨的人。我会把方案对比、索引原理、安全边界、框架坑位一起摊开来说。

1. 先别写接口,把分页模型选对

1.1 三种主流分页模型,分别解决什么问题

现实中真正会被大规模使用的分页模型,其实只有三种,剩下的都是它们的变体:

  • 页码分页(page / pageSize):最常见的 REST API 形式,例如GET /api/orders?page=3&size=20,返回第 3 页的数据。
  • 偏移分页(offset / limit):与页码分页本质相同,只是把页码换成偏移量,例如GET /api/orders?offset=40&limit=20
  • 游标分页(cursor / limit):前端每次从返回结果中拿到一个不透明的 cursor,下次请求把它带上,例如GET /api/orders?cursor=eyJ2IjogIjIwMjQt...&limit=20

从数据库执行的角度看,页码分页和偏移分页没有本质区别,LIMIT 20 OFFSET 40无非是把page-1乘上size得到的结果。它们都依赖同一个逻辑:先把满足 WHERE 条件的所有数据排好序,再从排序结果里跳过前 N 行,取接下来的 M 行。问题恰恰出在这个“所有数据”上,对数据库来说这是一笔不小的开销。

游标分页的思路完全不同:它不跳过任何数据,而是拿“上一批最后一条记录”作为起点,直接告诉数据库“从这里往后取”。它不需要知道之前有多少条,自然也就不存在“跳过的代价”。这个差异在小数据量下看不出来,一旦数据量过了十万、百万,性能差距会被拉大到几个数量级。

1.2 数据量级和访问模式决定了你的分页上限

很多团队选分页方案的时候,习惯从“用什么参数风格”开始讨论,这是顺序搞错了。正确的出发点应该是:你的数据量级预期是多少?用户访问模式是“随手翻几页”还是“一直往下翻”?

我自己的划分标准大致是这样:

数据量级首选方案原因
万级以内页码分页实现最简单,后端管理端完全够用,深翻页概率极低
十万到百万级偏移分页 + 严格上限索引配合下去大部分场景能撑住,但必须限制 offset 深度
百万级以上游标分页offset 深翻页的成本已经无法忽视,必须换思路
动态排序 + 超大表游标分页 + 排序列白名单索引排序字段和过滤条件要提前建组合索引,否则再好的分页方案也白搭

这里要特别提醒一件事:数据量不是静态的。很多接口上线时只有几千条数据,看着什么都行,等业务跑两年之后到了百万级,再想从 offset 迁移到 cursor 就是一次不小的重构。所以我的做法是:新接口如果预判一年内会超过 10 万条,直接按游标分页设计,宁可在前端做一层兼容封装,也不要等性能事故来了再还债。

1.3 业务特性对分页方案的硬约束

分页方案除了性能,还要照顾业务逻辑。最常见的约束有三个:数据是否频繁变动、是否需要跳页、是否存在大数据量导出场景。

如果列表数据是高频新增的,比如消息流、评论流,页码分页会出现经典的“翻页重复和遗漏”问题:你在看第 2 页时,第 1 页新增了一条数据,所有记录整体往后移,你看到的第 2 页实际上会插入一条本该在第 1 页的数据,而第 1 页底部可能被挤掉一条。用户会感觉列表“跳了一下”,体验很差。游标分页以最后一条为锚点,新增数据不会影响已经返回过的位置,所以在动态数据场景里几乎是唯一正确的选择。

如果业务必须允许用户跳转到任意页,比如后台管理系统的“去第 50 页”,游标分页就无能为力了,因为它是线性前进的,不知道“第 50 页”对应哪个游标。此时要么接受 offset 方案并限制最大深度,要么做混合方案:浅层用 offset,深层改用游标,同时前端把“跳页”功能限制在浅层范围内。

还有一个经常被忽略的场景是导出。很多列表页都提供“导出当前筛选结果”的按钮,实现时容易直接复制分页逻辑然后循环拉取。分页方案如果限定了 offset 上限,导出功能就必须单独设计,比如用游标循环或者按主键分段拉取,否则导出到一半会被自己的接口拦住。这个我在后面的实战部分会展开讲。

2. 深翻页的性能瓶颈:从索引到内存缓冲

2.1 LIMIT/OFFSET 到底慢在哪

先说结论:LIMIT/OFFSET的慢,不是“跳过”这个动作慢,而是数据库为了完成跳过动作,付出了一整套排序和扫表的代价。

拿 MySQL 举例,执行SELECT * FROM orders ORDER BY create_time DESC LIMIT 10 OFFSET 100000时,优化器如果找不到能直接满足ORDER BY create_time DESC的索引,就会走 filesort,把满足 WHERE 条件的整张结果集都拉出来排一遍序,然后才能开始数偏移量。而即便有索引,只要查询字段带了*,仍然需要回表去读每一行的完整数据,100010 行数据一行都省不了。

假设每行数据平均 500 字节,offset 到 10 万时,数据库至少要扫描并丢弃 10 万行的排序结果,再把第 100001 到 100010 行返回给应用。这 10 万行如果排序字段和 WHERE 条件不能完全命中索引,还需要临时表参与。数据量越大,磁盘临时表出现的概率越高,性能会从毫秒级直接掉到秒级。

所以很多文章里说的“不要用 offset 深翻页”,本质原因就在这里。它不是一个参数习惯问题,而是深 offset 强制数据库做了大量无用功,这根本无法靠调 buffer 大小来根治。

2.2 COUNT(*) 是被忽略的第二根稻草

分页接口往往会顺手返回一个total,前端要显示“共多少条”。这个total在很多 ORM 框架里是自动COUNT(*)出来的,它才是深翻页场景里比重更隐蔽的成本。

InnoDB 的行数统计不像 MyISAM 那样直接记录在表头,每次COUNT(*)都意味着要扫描满足 WHERE 条件的所有索引页或数据页。如果 WHERE 条件里只有普通二级索引,count 需要走整个二级索引,在几十 GB 的表上跑一次就是灾难。更麻烦的是,分页接口通常每次请求都要 count 一次,用户点第 1 页、第 2 页,每点一次都是全量扫描。

我的建议是:能砍就砍。如果前端只需要“是否有下一页”,用limit+1的策略就够了——多查一条,能返回就说明还有下一页。如果产品必须显示总数,可以加一个“总数封顶”逻辑:count超过 10000 后直接显示10000+,或者用同步计数表的方式维护。总之,不要让一个列表页的基础请求承担全表COUNT(*)的代价。

2.3 排序操作对内存缓冲区的挤压

这是我曾经栽过跟头的地方。当时的现象是数据库主机监控里内存相关的一个指标长期报警,一开始我们怀疑是缓存命中率问题,最后定位到源头是大量排序请求把内存缓冲区的空间挤占了。

排序操作需要一个工作区。在 MySQL 中,单次排序的可用内存由sort_buffer_size控制,排序数据如果超过这个值就会落到磁盘临时表;大量并发排序请求同时进来时,每个连接都在申请自己的排序缓冲区,内存压力会迅速上升。如果使用 SQL Server 这类数据库,类似的现象可能表现为内存池里大量空间被排序操作占用的告警,很多人一看到“非分页缓冲池占用过高”就以为是内存泄漏,其实先去查一下 tempdb 是不是被排序塞满了,通常会有惊喜。

规避手段有两个方向:一是从 SQL 层面尽量减少排序数据集,比如让WHEREORDER BY尽量命中同一个复合索引,让索引天然有序,消除 filesort;二是控制并发和单次数据量,比如限制limit最大值、封装统一的查询入口,避免有人写一次取五万条还要排序的调用。后端 API 层加一道护栏,比天天去调数据库参数要靠谱得多。

3. 排序接口的安全边界:字段白名单与兼容性

3.1 排序字段注入为什么是安全问题

大部分开发者在做排序接口时,下意识只会考虑“用字符串拼 SQL 会引来 SQL 注入”,然后加一层参数化查询以为就结束了。但我想强调,排序字段引入的安全问题远不止 SQL 注入这么直接。

关键在于:排序字段会暴露数据库表的内部结构信息。如果接口允许调用方传任意列名,攻击者就可以通过观察排序结果的变化,探测表里是否存在某个内部字段,比如deleted_atinternal_scoreagent_id。他不需要看到值,只需要构造两个不同排序参数的请求,对比返回顺序,就能确认字段存在,以及大致的数据分布。这种信息泄露在用户画像、风控、反作弊系统里非常危险,等于把你数据模型的底牌亮给了对手。

除此之外,如果字段名处理不严谨,还可能引发类型转换开销,甚至间接造成慢查询。比如让一个未建索引的超长 VARCHAR 列参与排序,代价会非常大;如果传入的列类型是 TEXT,排序时更是雪上加霜。所以排序字段从请求入口就必须被当作不可信输入,和查询参数一样做严格校验。

3.2 排序白名单的工程实现:别直接拼接

安全第一步是无条件信任一份白名单。这里说的白名单不是简单的“允许这个字段”,而是要在代码里把外部字段名映射到数据库列名和排序方向。为什么强调映射?因为这样可以顺带隐藏真实列名,同时避免把任何用户输入直接拼进 ORDER BY。

下面是一个 Java 风格的实现示例,核心是两层校验:字段名必须存在于映射表里,排序方向只允许 asc 或 desc:

private static final Map<String, String> SORT_COLUMN_MAP = Map.ofEntries( Map.entry("createTime", "create_time"), Map.entry("updateTime", "update_time"), Map.entry("amount", "amount"), Map.entry("status", "status"), Map.entry("userName", "u.name") ); private static final Set<String> DIRECTION_SET = Set.of("asc", "desc"); public String buildOrderBy(String sortField, String direction) { String column = SORT_COLUMN_MAP.get(sortField); if (column == null) { throw new ApiException(400, "invalid sort field"); } String dir = DIRECTION_SET.contains(direction) ? direction : "asc"; return column + " " + dir; }

注意Map.entry("userName", "u.name")这种写法:白名单的 value 是程序员预先写死在代码里的,即便带表别名也不会被用户污染。到这里可能有人会问:用字符串拼接 ORDER BY 安全吗?在列名来自白名单的前提下,安全;但如果你写的是String orderBy = sortField + " " + direction;,即使 sortField 做了校验,direction 的拼接也要小心。最稳妥的方案是把asc/desc也做成白名单映射,不要依赖任何正则和黑名单过滤——白名单比对永远比黑名单过滤可靠。

3.3 字符串排序的编码、大小写和中文陷阱

字符串排序看起来最容易,实际上坑最多。最典型的一个场景是版本号排序:数据库里存的是"9.0""10.0""9.10"这种字符串,直接ORDER BY version DESC会得到"9.0"排在"10.0"前面,因为字符串比较是从左到右逐字符比较,'1' < '9'就决定了"10.0"永远排在"9.0"后面。所以很多系统里的版本号字段要单独拆成 major/minor/patch 三列,或者写入时做一次转整数处理,都是为了规避这个问题。

另一个常见问题是大小写。MySQL 默认的utf8mb4_general_ci不区分大小写,所以ORDER BY name会把Zebraapple混在一起排序,Zebra反而排在apple前面,因为au的顺序在比较时决定了结果。如果业务要求区分大小写、让大写排在小写前面,就必须显式指定COLLATE utf8mb4_bin,或者在应用层做字段转换。这里最容易犯的错误是:开发环境用 SQLite 或 PostgreSQL 调试没问题,上线到 MySQL 后排序结果突然不一样,因为不同数据库的默认排序规则完全不同。

中文排序更是经典大坑。MySQL 里ORDER BY name按字符集排序规则比较,一般按编码顺序或者偏旁部首排,而不是按拼音排。如果产品要求“按拼音排序”,最可靠的做法不是临时转换 collation,而是在业务数据里冗余一个拼音列,写入时用分词工具生成,排序时直接排这个拼音列,原文当展示字段。临时转换 collation 的性能和准确度都很难保证,尤其是百万级以上的表。

3.4 NULL 值位置与多字段排序的稳定性

最后一个是容易被忽略的 NULL 排序位置。不同数据库的行为不一样:MySQL 中升序时 NULL 排在结果集最前面,降序时排在最后;而 Oracle、PostgreSQL 可以显式指定NULLS FIRST/NULLS LAST。如果分页接口的排序字段允许 NULL,用户的直观感受是“明明想按时间从新到旧看,为什么最上面全是没有时间的记录”。

三个处理方案供选择:一是写入时兜底,给排序列设置默认值,比如create_time DEFAULT CURRENT_TIMESTAMP;二是查询时用ORDER BY field IS NULL, field DESC把 NULL 强制放到尾部;三是在业务层面约定“排序字段必须非空”,前端对应筛选条件里把这些数据过滤掉。方案一最干净,但改动数据表;方案二不破坏数据,但要注意这种写法在部分数据库里可能影响索引使用;方案三适合字段本来就允许为空的场景。

排序稳定性则直接关系到分页体验的核心问题——翻页不重不漏。设想ORDER BY create_time DESC但同一秒内创建了 300 条记录,create_time完全不唯一,此时数据库返回顺序是不确定的,第一页和第二页之间就可能出现重复或漏掉的数据。解决思路很朴素:排序字段最后必须追加一个绝对唯一的字段,通常是主键。ORDER BY create_time DESC, id DESC就是一个足够稳定的排序。同一时间戳的记录顺序,数据库内部确实没有保证,所以这不是选择题,是必选项。

4. 游标分页的完整落地:编码、查询与返回

4.1 游标里装什么:编码、签名与防篡改

游标分页落地时,很多人第一反应是“直接把上一页最后一条记录的 create_time 和 id 传回来”,这样确实简单,但一旦传参被用户修改,查询条件就不可控了。更专业的做法是把游标编码成不透明字符串,并且做签名校验,确保用户不能伪造。

游标至少需要包含两个信息:排序字段的值,比如 create_time;主键值 id。为什么需要主键 id?因为排序字段可能不唯一,必须用主键兜底,否则游标指向的那一行可能对应多条数据。以 Python 为例,一个带 HMAC 签名的游标编码可以这样实现:

import base64 import hashlib import hmac import json SECRET = b"replace-with-your-secret" def encode_cursor(value, primary_id): if hasattr(value, "isoformat"): value = value.isoformat() payload = base64.urlsafe_b64encode( json.dumps({"v": value, "i": primary_id}).encode() ).rstrip(b"=") signature = hmac.new(SECRET, payload, hashlib.sha256).digest() sig_b64 = base64.urlsafe_b64encode(signature).rstrip(b"=").decode() return payload.decode() + "." + sig_b64 def decode_cursor(cursor): try: payload_b64, sig_b64 = cursor.split(".") signature = base64.urlsafe_b64decode(sig_b64 + "=" * (-len(sig_b64) % 4)) expected = hmac.new(SECRET, payload_b64.encode(), hashlib.sha256).digest() if not hmac.compare_digest(signature, expected): raise ValueError("bad cursor") payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4))) return payload["v"], payload["i"] except Exception: raise ApiException(400, "invalid cursor")

这段代码里有一个细节要多说一句:签名密钥不能放在前端或公共配置文件里,也不能出现在会被打包进客户端的 SDK 中。如果攻击者拿到了密钥,他就可以随意构造游标,把接口变成任意的扫描器。所以游标的签名密钥和 API 鉴权密钥一样,属于服务端机密。

加密和签名是两回事。签名保证“不可篡改”,加密保证“不可见”。游标里如果不打算放敏感数据,只放排序值和主键,那签名就够了。千万不要为了让游标看起来更神秘,把用户 ID、内部标记这类敏感信息塞进去,因为一旦签名密钥泄露,这套东西就全暴露了。把游标当作公开展示的请求参数来设计,是最稳妥的心态。

4.2 服务端怎么用游标写查询

游标解码之后,真正要执行的查询反而简单了。以排序ORDER BY create_time DESC, id DESC为例,上一批最后一条记录的create_time = T, id = ID,那么取下一页的 SQL 是:

SELECT * FROM orders WHERE create_time < ? OR (create_time = ? AND id < ?) ORDER BY create_time DESC, id DESC LIMIT 21;

这里LIMIT 21是“取 N+1”的策略:多取一条用来判断has_more,实际返回给用户的只有前 20 条。为什么要用(create_time = ? AND id < ?)这个条件?因为如果只写create_time < ?,恰好同一时间戳生成的多条数据就被漏掉了——这是一开始设计排序稳定性的自然延续。同样地,如果是正序排序ORDER BY create_time ASC, id ASC,条件就反过来:create_time > ? OR (create_time = ? AND id > ?)

为了让这个查询快,必须建一个和排序完全对齐的复合索引,比如(create_time, id)。这样 WHERE 条件和 ORDER BY 都能走索引,数据库可以从索引定位到游标位置,直接顺序读取下一页,不需要全表扫描,也不需要 filesort。很多人测试游标分页时发现没有变快,绝大多数原因都是索引没建对。记住一个原则:游标分页的排序字段、WHERE 条件和 ORDER BY 需要形成同一个有序的索引结构,任何一步偏了,性能都会回到全排水平。

4.3 返回协议里必须带着的下一页信息

游标分页的返回协议和 offset 分页差异不小。如果前端想无缝对接,建议统一返回这样的结构:

{ "data": [...], "next_cursor": "eyJ2IjogIjIwMjQtMDEt...", "has_more": true, "filters": { "sort_by": "create_time", "sort_order": "desc" } }

has_more的作用是帮前端省一次请求:它直接告诉调用方还有没有下一页,前端不用每次都盲目地发请求试探。next_cursor为空就代表没有下一页。前端拿到next_cursor后,把它作为下一个请求的cursor参数传回来,整个过程无状态,接口不必在服务端维护任何分页会话。

这里有一个容易忽略的细节:filters字段。为什么返回协议里要带排序方式?因为游标编码只和“当前这一批的排序值”绑定,如果调用方在翻页过程中偷偷改了sort_by,服务端可能无法察觉排序已经变化,从而返回错乱的数据。我的做法是:把当前查询的排序方式放进返回体,前端翻页时要么原样带上,要么清晰提示用户“切换排序会重置分页”。很多公司的列表页在排序切换后仍然保留旧游标,结果翻出大量重复数据,就是这里没接好。

4.4 导出场景怎么复用游标逻辑

导出是分页方案里最容易出问题的角落,因为它的循环次数不是用户手动控制的,而是代码自动执行的。如果导出实现直接写for page in range(1, N): fetch(page),遇到 offset 方案深翻页直接性能崩溃;遇到游标方案,至少需要保证每次请求返回的next_cursor能被正确传递。

我的推荐做法是用“按主键批次扫描”替代“按页扫描”:每次取id > last_id ORDER BY id ASC LIMIT 1000,处理完这一批后再把最后一条的 id 作为下一次的起点。因为主键唯一且单调,这种扫描天然支持增量导出,中途断了重启也能从上次位置继续。如果业务要求按业务时间字段排序导出,那就退化为游标循环,把上一批末尾的排序值和主键带上下一次请求。

5. 主流框架与缓存场景里的分页坑

5.1 MyBatis-Plus 分页失效的常见排查链路

关于 MyBatis-Plus 分页失效的讨论很多,但实际场景里大部分原因翻来覆去就那么几个。我整理成一套排查链路,照着走基本能定位。

最常见的是配置问题:PaginationInnerInterceptor没有被注册到MybatisPlusInterceptor里,或者注册顺序不对。MyBatis-Plus 要求把所有拦截器放在同一个MybatisPlusInterceptor中,分页拦截器要正常插入,否则 SQL 不会被改写。检查配置里是否有类似下面这段:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

注意DbType.MYSQL要和你实际的数据库一致。多数据源环境下,这个拦截器必须注入到所有使用分页的数据源中,很多人只配了主数据源,分页在从库上自然就失效了。

第二类原因是调用方式的问题:Page参数没有作为第一个参数传给 mapper 方法,或者返回类型写成了List<T>而不是IPage<T>。MyBatis-Plus 只有在方法签名里显式包含Page参数时才会触发分页改写,返回IPage<T>才能拿到总数。排查时先打开 SQL 日志,如果看到日志里根本没有出现 LIMIT,或者出现的 count 语句是原样直查而不是被改写的版本,基本就是前面这些原因。

第三类原因是复杂 SQL 场景。比如 mapper XML 里写了连表查询,分页插件在自动生成 count 语句时可能解析失败,导致总数不对。我的经验是把复杂查询拆成两步:先用分页查询拿主键 id 集合,再WHERE id IN (...)去查完整数据,分页和明细完全解耦,逻辑也更清晰。

5.2 ORM 分页在复杂查询上的性能陷阱

除了 MyBatis-Plus,JPA、Entity Framework 这类 ORM 在分页上最容易踩的坑是“把分页写在外层,却把排序和过滤逻辑全压给子查询”。比如 JPA 的PageRequest配合@Query查询时,生成的 SQL 经常是先把所有符合条件的记录查出来,再在外面套一层 limit。数据量不大时看不出来,一旦过滤条件多、表数据量大,这个子查询的代价会成倍放大。

另一种典型陷阱是“分页 + 关联集合的 N+1 查询”。列表页先查 20 条订单,再对每条订单去查它的明细,20 次额外查询如果明细表没有索引,或者分页场景下要循环拉 1000 条,问题就大了。处理方式是在分页查询阶段只取主表字段,明细数据用批量查询合并:一次性WHERE order_id IN (...)查出所有明细,再在应用内存里按 orderId 分组。这个模式几乎适用于所有联表列表页。

5.3 Redis 缓存列表时的分页一致性问题

缓存列表是另一个容易分页“翻车”的地方。很多人习惯用 Redis 的 ZSet 缓存列表数据,因为 ZSet 天然按 score 排序,可以用ZREVRANGEBYSCORE实现类似索引的正反排序分页。比如商品列表按创建时间倒序,score 存时间戳,member 存商品 ID,取第一页就是ZREVRANGEBYSCORE product:list +inf -inf LIMIT 0 20

但这个方案有两个大坑。第一是内存成本高:ZSet 的每个 member 都占内存,列表有几百万条就要存几百万条 ZSet 成员,非常吃内存,所以线上往往只能缓存前几百页,后面的请求直接走数据库。第二个坑是缓存和数据库的排序一致性:如果数据库中排序字段发生了更新,比如商品价格排序,price 变了,缓存里的 score 不会自动同步,就会出现排序错乱。常规做法是给缓存加基于业务事件的失效机制:任何影响排序字段的写操作,都要删除对应列表缓存或更新 score,而且更新 score 要小心并发——先删缓存再回源数据库重建是相对安全的策略。

如果业务允许,我更推荐折中方案:列表页浅层用 Redis 加速,深层一律走数据库游标。也就是查询时先用一个很短的缓存窗口判断前两页数据是否存在缓存,后面的页数直接绕过缓存。这样既省内存,又不用为极端并发维护复杂的缓存一致性。

6. 性能验证和回归:上线前必须盯住的信号

6.1 用 EXPLAIN 看你的分页查询到底走没走索引

分页方案再完美,最后都要靠 SQL 执行计划说话。MySQL 上只需要一条:

EXPLAIN SELECT * FROM orders WHERE create_time < '2024-01-01 00:00:00' OR (create_time = '2024-01-01 00:00:00' AND id < 100) ORDER BY create_time DESC, id DESC LIMIT 21;

看几个关键位:key显示实际使用的索引,不能是 NULL;type不能是ALL,也就是全表扫描,最好是rangerefExtra如果出现Using filesort,说明 ORDER BY 没有走索引,性能随时会恶化。游标分页在正确建索引的情况下,Extra应该是干净的,或者只出现Using index condition这类信息。

过滤条件发生变化时,要重新检查执行计划。同一个接口支持不同筛选条件时,每个条件对应的执行计划可能完全不一样;WHERE里的字段如果参与了排序组合索引,可以走到range;如果前端多加了一个没有索引的筛选字段,整个执行计划可能瞬间退化。我在团队里定的规矩是:每个列表接口在发版前,必须贴出最常用三四个筛选场景的 EXPLAIN 截图。这不是形式主义,是防止真实环境里索引被意外绕过的最有效手段。

6.2 压测要覆盖“最坏情况”,不能只测首页

分页接口的压测最容易犯的错误是只压第一页。第一页的 offset 很小,排序集也小,数据库轻松扛住;但真实用户可能翻到第 100 页,或者某个脚本直接从第 10000 条开始取。压测用例里必须包含这种“深页”场景。如果采用 offset 方案,建议在代码里加一个硬上限,比如offset + limit <= 10000,超过就直接拒绝;如果采用游标方案,压测时模拟连续翻页请求,重点看每页耗时是否稳定,而不是第一页很快、后面越来越慢。

压测前还可以顺手做两件事:开启慢查询日志,把阈值调到 1 秒,压测后去看慢查询列表有没有排名靠前的分页 SQL;再用数据库自带的监控面板观察 filesort 和临时表的频率。如果发现排序相关临时表频繁出现,优先回头检查索引和 SQL 结构,而不是盲目扩大sort_buffer_size——那只能掩盖问题,不能解决扫描量本身。

6.3 一套可以照抄的分页接口设计清单

最后把经验收敛成一份清单,设计分页和排序接口时逐条打勾,能省掉大量线上返工:

  • 默认limit不超过 20,max_limit不超过 100,超出直接返回 400。
  • 浅层数据用 offset/page,深层数据和动态列表一律游标分页。
  • 排序字段全部走白名单映射,方向只允许asc/desc
  • 排序语句最后必须追加主键兜底,保证排序稳定。
  • 游标必须带签名,不能接受用户自由构造的游标。
  • 大列表接口不做无条件COUNT(*),用limit+1或总数封顶替代。
  • 复杂列表查询先查主键再查明细,避免大型 JOIN 后代分页。
  • 每个接口上线前提交 EXPLAIN 执行计划检查记录。

关于排序接口的安全边界,我个人的体会是:真正出问题的往往不是 SQL 注入这种“大事件”,而是排序字段被恶意探测、NULL 排序导致数据错位、深翻页把数据库打挂这类“小问题”。它们藏得很深,不是发版那一刻能发现的,需要在一开始就把设计原则刻进去。分页和排序看似是所有 API 里最简单的部分,恰恰是上线后最容易出事故的部分——认真对待它,比多写十个业务接口都值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 2:23:28

Simulink中DEKF双扩展卡尔曼滤波建模与参数辨识实战

1. 为什么是DEKF&#xff1a;状态和参数捆在一起估计时&#xff0c;单滤波器根本玩不转1.1 联合EKF的维度灾难与耦合问题做状态估计的工程朋友应该都有过这种体验&#xff1a;系统模型里有几个参数拿不准&#xff0c;于是顺手把它们塞进状态向量&#xff0c;搞一个联合EKF&…

作者头像 李华
网站建设 2026/9/15 2:22:35

降AI率实战指南:十大工具与从检测原理到人味重铸的完整操作路径

这两年&#xff0c;身边越来越多朋友开始讨论“降AI率”&#xff1a;有学生写课程论文&#xff0c;有自媒体编辑做选题&#xff0c;也有企业部门做行业报告。大家遇到的问题惊人地一致——明明自己先写了思路&#xff0c;再用AI润色&#xff0c;结果稿子送到AIGC检测系统里一跑…

作者头像 李华
网站建设 2026/9/15 2:22:14

教育类网站HTML+CSS静态页面:解压、本地部署与样式修改

简介&#xff1a;面向网页设计初学者的一套教育类静态网站源码包&#xff0c;以“趣学网”为演示案例&#xff0c;完整呈现超文本标记语言&#xff08;HTML&#xff09;与层叠样式表&#xff08;CSS&#xff09;搭建信息型网页的过程&#xff0c;适合用来攻克页面结构组织、导航…

作者头像 李华
网站建设 2026/9/15 2:22:12

基于Truffle的投票DApp实战:从合约设计到前后端联调

简介&#xff1a;这套基于 Truffle 框架的区块链投票系统源码&#xff0c;是面向区块链初学者的毕业设计项目&#xff0c;内置两个递进式子项目&#xff1a;简单投票 DApp 与基于 Token 的投票 DApp。项目以 Ganache 作为本地私有链&#xff0c;配合 MetaMask 钱包完成交互&…

作者头像 李华
网站建设 2026/9/15 2:20:19

Profinet转Modbus RTU网关实战:新旧设备混接的工业通讯解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 2:18:19

LS-DYNA材料模型实战解析:从选型到参数标定

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华