ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

分页与排序的工程实践:从深翻页到游标分页的平滑迁移 先说一件我自己踩过的事。前两年我接手一个订单查询服务数据量到了百万级之后运营那边陆续反馈“列表页越来越慢”。我最初以为是服务器带宽问题结果打开慢查询日志发现有一条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?page3size20返回第 3 页的数据。偏移分页offset / limit与页码分页本质相同只是把页码换成偏移量例如GET /api/orders?offset40limit20。游标分页cursor / limit前端每次从返回结果中拿到一个不透明的 cursor下次请求把它带上例如GET /api/orders?cursoreyJ2IjogIjIwMjQt...limit20。从数据库执行的角度看页码分页和偏移分页没有本质区别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 页每点一次都是全量扫描。我的建议是能砍就砍。如果前端只需要“是否有下一页”用limit1的策略就够了——多查一条能返回就说明还有下一页。如果产品必须显示总数可以加一个“总数封顶”逻辑count超过 10000 后直接显示10000或者用同步计数表的方式维护。总之不要让一个列表页的基础请求承担全表COUNT(*)的代价。2.3 排序操作对内存缓冲区的挤压这是我曾经栽过跟头的地方。当时的现象是数据库主机监控里内存相关的一个指标长期报警一开始我们怀疑是缓存命中率问题最后定位到源头是大量排序请求把内存缓冲区的空间挤占了。排序操作需要一个工作区。在 MySQL 中单次排序的可用内存由sort_buffer_size控制排序数据如果超过这个值就会落到磁盘临时表大量并发排序请求同时进来时每个连接都在申请自己的排序缓冲区内存压力会迅速上升。如果使用 SQL Server 这类数据库类似的现象可能表现为内存池里大量空间被排序操作占用的告警很多人一看到“非分页缓冲池占用过高”就以为是内存泄漏其实先去查一下 tempdb 是不是被排序塞满了通常会有惊喜。规避手段有两个方向一是从 SQL 层面尽量减少排序数据集比如让WHERE和ORDER BY尽量命中同一个复合索引让索引天然有序消除 filesort二是控制并发和单次数据量比如限制limit最大值、封装统一的查询入口避免有人写一次取五万条还要排序的调用。后端 API 层加一道护栏比天天去调数据库参数要靠谱得多。3. 排序接口的安全边界字段白名单与兼容性3.1 排序字段注入为什么是安全问题大部分开发者在做排序接口时下意识只会考虑“用字符串拼 SQL 会引来 SQL 注入”然后加一层参数化查询以为就结束了。但我想强调排序字段引入的安全问题远不止 SQL 注入这么直接。关键在于排序字段会暴露数据库表的内部结构信息。如果接口允许调用方传任意列名攻击者就可以通过观察排序结果的变化探测表里是否存在某个内部字段比如deleted_at、internal_score、agent_id。他不需要看到值只需要构造两个不同排序参数的请求对比返回顺序就能确认字段存在以及大致的数据分布。这种信息泄露在用户画像、风控、反作弊系统里非常危险等于把你数据模型的底牌亮给了对手。除此之外如果字段名处理不严谨还可能引发类型转换开销甚至间接造成慢查询。比如让一个未建索引的超长 VARCHAR 列参与排序代价会非常大如果传入的列类型是 TEXT排序时更是雪上加霜。所以排序字段从请求入口就必须被当作不可信输入和查询参数一样做严格校验。3.2 排序白名单的工程实现别直接拼接安全第一步是无条件信任一份白名单。这里说的白名单不是简单的“允许这个字段”而是要在代码里把外部字段名映射到数据库列名和排序方向。为什么强调映射因为这样可以顺带隐藏真实列名同时避免把任何用户输入直接拼进 ORDER BY。下面是一个 Java 风格的实现示例核心是两层校验字段名必须存在于映射表里排序方向只允许 asc 或 descprivate static final MapString, 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 SetString 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会把Zebra和apple混在一起排序Zebra反而排在apple前面因为a和u的顺序在比较时决定了结果。如果业务要求区分大小写、让大写排在小写前面就必须显式指定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 breplace-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是“取 N1”的策略多取一条用来判断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 方法或者返回类型写成了ListT而不是IPageT。MyBatis-Plus 只有在方法签名里显式包含Page参数时才会触发分页改写返回IPageT才能拿到总数。排查时先打开 SQL 日志如果看到日志里根本没有出现 LIMIT或者出现的 count 语句是原样直查而不是被改写的版本基本就是前面这些原因。第三类原因是复杂 SQL 场景。比如 mapper XML 里写了连表查询分页插件在自动生成 count 语句时可能解析失败导致总数不对。我的经验是把复杂查询拆成两步先用分页查询拿主键 id 集合再WHERE id IN (...)去查完整数据分页和明细完全解耦逻辑也更清晰。5.2 ORM 分页在复杂查询上的性能陷阱除了 MyBatis-PlusJPA、Entity Framework 这类 ORM 在分页上最容易踩的坑是“把分页写在外层却把排序和过滤逻辑全压给子查询”。比如 JPA 的PageRequest配合Query查询时生成的 SQL 经常是先把所有符合条件的记录查出来再在外面套一层 limit。数据量不大时看不出来一旦过滤条件多、表数据量大这个子查询的代价会成倍放大。另一种典型陷阱是“分页 关联集合的 N1 查询”。列表页先查 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显示实际使用的索引不能是 NULLtype不能是ALL也就是全表扫描最好是range或refExtra如果出现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不超过 20max_limit不超过 100超出直接返回 400。浅层数据用 offset/page深层数据和动态列表一律游标分页。排序字段全部走白名单映射方向只允许asc/desc。排序语句最后必须追加主键兜底保证排序稳定。游标必须带签名不能接受用户自由构造的游标。大列表接口不做无条件COUNT(*)用limit1或总数封顶替代。复杂列表查询先查主键再查明细避免大型 JOIN 后代分页。每个接口上线前提交 EXPLAIN 执行计划检查记录。关于排序接口的安全边界我个人的体会是真正出问题的往往不是 SQL 注入这种“大事件”而是排序字段被恶意探测、NULL 排序导致数据错位、深翻页把数据库打挂这类“小问题”。它们藏得很深不是发版那一刻能发现的需要在一开始就把设计原则刻进去。分页和排序看似是所有 API 里最简单的部分恰恰是上线后最容易出事故的部分——认真对待它比多写十个业务接口都值。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进