如何做分页、搜索和筛选
当你的 AI 产品从几十个测试用户扩展到几千个活跃用户,数据库里的记录从百级增长到百万级,「能跑」和「好用」之间的差距会集中暴露在三个功能上:分页、搜索和筛选。它们不是锦上添花的高级特性,而是用户每天都在用的基础能力。分页决定了用户能否快速找到目标数据,搜索决定了用户能否用自然语言定位内容,筛选决定了用户能否从海量结果中精确缩小范围。
这篇文章会从方案选型、实现细节、性能优化和前后端协作四个维度,系统梳理分页、搜索和筛选的设计思路与工程实践。
分页方案对比
分页是最基础的数据组织能力。当一个查询返回 10 万条结果时,没有任何前端能一次性渲染这些数据,分页就是解决「怎么把大量数据分批展示」的问题。
Offset 分页
Offset 分页是最直觉的方案,SQL 层面用 LIMIT 和 OFFSET 实现:
SELECT * FROM comics ORDER BY created_at DESC LIMIT 20 OFFSET 40;前端传入 page 和 pageSize,后端计算 offset = (page - 1) * pageSize。这种方式的优点是实现简单、支持跳转到任意页码,对于后台管理系统中的小数据量列表完全够用。
但 Offset 分页有一个根本性的性能问题:数据库必须先查出 offset + limit 条记录,再丢弃前 offset 条。当 offset 值很大时(比如翻到第 1000 页),数据库要扫描并丢弃前 20000 条记录,查询时间线性增长。另一个容易忽略的问题是数据一致性——在用户翻页的过程中,如果有新数据插入或旧数据删除,页面上会出现重复或遗漏的记录。
Cursor 分页
Cursor 分页用「上一页最后一条记录的位置」作为游标,避免了 offset 的计算开销:
SELECT * FROM comics
WHERE created_at < '2026-06-30T12:00:00Z'
ORDER BY created_at DESC
LIMIT 20;游标通常是上一页最后一条记录的某个唯一字段值(如 id 或 created_at),前端只需把它传给后端。由于查询条件直接定位到起始位置,无论翻到多深的页,查询性能都是恒定的。同时,因为定位到具体的记录而非偏移量,新增或删除数据不会导致跳页或重复。
Cursor 分页的代价是不支持「跳转到第 N 页」——用户只能顺序地加载上一页或下一页。这在信息流(Feed)、无限滚动场景下不是问题,但在需要页码导航的管理后台就不太合适。
无限滚动
无限滚动不是一个独立的分页技术,而是 Cursor 分页在前端的呈现方式。用户滚动到页面底部时自动加载下一批数据,没有传统的「页码」概念。它适合内容消费型场景(浏览 AI 生成的漫画列表、查看创作历史),但不适合需要定位和对比的场景(后台管理、数据审核)。
分页方案对比表
| 维度 | Offset 分页 | Cursor 分页 | 无限滚动 |
|---|---|---|---|
| 实现复杂度 | 低,LIMIT + OFFSET | 中,需要稳定的排序字段和游标编码 | 中,需要前端滚动监听 |
| 大数据量性能 | 差,offset 越大越慢 | 好,性能恒定 | 好,性能恒定 |
| 跳页支持 | 支持任意页码跳转 | 不支持,只能顺序翻页 | 不支持 |
| 数据一致性 | 插入/删除会导致跳页或重复 | 不受中间数据变化影响 | 不受中间数据变化影响 |
| 适用场景 | 后台管理、小数据量列表 | Feed 流、大数据量 API | 移动端浏览、内容消费 |
| 典型用户 | GitHub Issues 列表 | Twitter/X 时间线 | Instagram、小红书 |
搜索实现
搜索让用户用关键词或自然语言从数据中找到目标内容,是比筛选更灵活的定位方式。
数据库全文搜索
PostgreSQL 内置了 Full-text Search(FTS)能力,基于 tsvector 和 tsquery 两个数据类型实现。tsvector 将文本拆分为词元(lexeme)并记录位置,tsquery 表示搜索条件(支持 &、|、!、<->` 等操作符)。
SELECT id, title, ts_rank(to_tsvector('english', content), query) AS rank
FROM comics
WHERE to_tsvector('english', title || ' ' || content) @@ query
ORDER BY rank DESC;PostgreSQL FTS 的优势在于零额外依赖——不需要部署 Elasticsearch 或 Meilisearch,直接在现有数据库上添加索引即可。对于中小规模数据(几十万条记录以内),性能完全够用。它还支持中文分词(需要 zhparser 或 pg_jieba 扩展)和权重分配(可以给标题、正文等不同字段设置不同权重)。
Elasticsearch 与专业搜索服务
当数据量增长到百万级以上,或者需要复杂的搜索能力(拼音搜索、同义词扩展、自定义相关性调优、多语言混合搜索),Elasticsearch 是更成熟的选择。它基于 Lucene 构建,底层使用倒排索引,在搜索延迟和吞吐量上比 PostgreSQL FTS 有 3-10 倍的优势。
Algolia 和 Meilisearch 则走的是 SaaS 路线——前者提供托管服务,按搜索请求数计费;后者是开源的轻量替代,适合中小项目。它们的共同优势是开箱即用:分面搜索、拼写纠错、高亮显示等功能无需从零搭建。
搜索方案对比表
| 维度 | PostgreSQL FTS | Elasticsearch | Algolia / Meilisearch |
|---|---|---|---|
| 部署复杂度 | 低,利用现有数据库 | 高,独立集群 + JVM | 低,SaaS 或单二进制 |
| 搜索性能 | 中等,P95 约 89ms | 快,大数据量下优势明显 | 快,通常 < 50ms |
| 中文支持 | 需扩展(zhparser) | 需分词插件(IK、HanLP) | 内置多语言支持 |
| 相关性调优 | 有限,ts_rank 基础配置 | 丰富,BM25 + 自定义 boost | 丰富,可视化调优 |
| 运维成本 | 低,随数据库备份 | 高,需监控集群健康 | 低(SaaS)或中(自建) |
| 适用规模 | 几十万条以内 | 百万到亿级 | 按方案和预算决定 |
| 典型场景 | 内部工具、MVP 阶段 | 大规模内容平台 | 电商搜索、文档搜索 |
选择建议:产品 MVP 阶段或内部工具,PostgreSQL FTS 足够。当搜索成为核心体验、数据量超过百万、或者需要精细的相关性调优时,再引入 Elasticsearch 或 SaaS 搜索服务。过早引入搜索中间件只会增加架构复杂度,而收益有限。
筛选设计
筛选让用户从结果集中精确缩小范围,是比搜索更结构化的定位方式。一个设计良好的筛选系统应该同时满足「灵活组合」和「易于理解」两个目标。
多条件组合筛选
最常见的筛选方式是多个条件的组合,每个条件对应数据的一个字段。前端通过 URL 查询参数传递筛选条件:
GET /api/comics?status=published&genre=fantasy&min_rating=4&page=1
后端收到参数后,动态构建查询条件。这里有一个重要的设计决策:多个条件之间是 AND 还是 OR?大多数场景下默认 AND(缩小范围),但也要为用户提供切换的能力。
对于更复杂的筛选需求,可以引入筛选表达式语法。例如 GitHub 的 Issues 搜索支持 is:open label:bug assignee:@me 这样的语法,虽然不是所有产品都需要这种灵活度,但它展示了一种思路——当筛选维度超过 5 个时,传统的下拉框 + 复选框就不够用了。
动态筛选与筛选状态管理
动态筛选指的是筛选条件本身根据数据动态生成。例如,「分类」筛选项不应该硬编码,而应该查询数据库中实际存在的分类值。这样可以避免展示没有数据的空选项,也减少了前后端的同步维护成本。
筛选状态管理是前端需要解决的核心问题。推荐的做法是将筛选条件同步到 URL 上,这样用户可以通过复制 URL 分享筛选结果、浏览器的前进后退也能正常工作。React 生态中可以用 useSearchParams 或 nuqs 库来管理,服务端渲染框架(Next.js)天然支持从 URL 读取查询参数。
筛选设计对比表
| 设计维度 | 简单筛选 | 中级筛选 | 高级筛选 |
|---|---|---|---|
| 条件数量 | 1-3 个固定字段 | 5-8 个可选字段 | 不限,表达式语法 |
| UI 形态 | 下拉框、单选按钮 | 多选标签 + 范围滑块 | 搜索框 + 表达式输入 |
| 条件关系 | 固定 AND | 支持 AND/OR 切换 | 自由组合(括号、嵌套) |
| 后端实现 | 固定 SQL WHERE | 动态拼接条件 + 参数校验 | 条件解析器 + 安全沙箱 |
| URL 同步 | 简单 query string | 编码后的 JSON 或结构化参数 | 自定义表达式解析 |
| 典型产品 | Todo List | 电商商品列表 | GitHub Issues、Jira |
性能优化
分页、搜索和筛选的性能问题往往在数据量增长后才暴露,但等到用户投诉再处理就来不及了。以下策略应该在设计阶段就纳入考量。
索引策略
索引是查询性能的基础。对于分页排序字段(如 created_at),建立 B-Tree 索引;对于筛选字段(如 status、genre),如果基数不高可以用 B-Tree,如果基数高或需要多值匹配可以考虑 GIN 索引。对于全文搜索,PostgreSQL 的 tsvector 字段需要 GIN 索引:
CREATE INDEX idx_comics_content_fts ON comics USING GIN (to_tsvector('english', content));组合索引(Composite Index)在筛选 + 排序的场景下很有用。例如用户经常按 status 筛选并按 created_at 排序,那么 (status, created_at) 的复合索引比两个单独索引更高效。但要注意,复合索引的列顺序很重要——把筛选字段放在前面,排序字段放在后面。
缓存策略
对于读多写少的列表接口,缓存可以大幅降低数据库压力。常见的缓存层级包括:
- 查询缓存:对相同的查询参数 + 分页条件缓存结果,设置合理的 TTL(如 30 秒到 5 分钟)。
- 筛选选项缓存:动态筛选的可选值(如所有分类列表)变化不频繁,可以缓存更长时间。
- 搜索结果缓存:对于热门搜索关键词,缓存搜索结果。
缓存失效策略需要配合数据变更来做——新增、修改、删除记录时清除相关缓存。在 Redis 中可以用 KEYS 模式匹配或 pub/sub 机制来做精准失效。
查询优化
一些通用的查询优化技巧:
- 只查需要的字段:避免
SELECT *,只返回前端需要的字段,减少数据传输量。 - 使用覆盖索引:如果查询的字段都在索引中,数据库可以直接从索引返回数据,不需要回表查询。
- 避免在 WHERE 中对字段做函数运算:
WHERE LOWER(name) = 'xxx'会导致索引失效,应该改为存储时就统一小写,或使用函数索引。 - 控制 JOIN 的数量:过多的 JOIN 会显著增加查询复杂度,可以考虑冗余存储常用关联数据。
性能优化策略对比表
| 优化策略 | 适用场景 | 实施难度 | 效果预期 |
|---|---|---|---|
| B-Tree 索引 | 排序、范围查询、等值筛选 | 低 | 查询提速 10-100x |
| GIN 索引 | 全文搜索、数组/JSON 字段查询 | 低 | 全文搜索提速 50-200x |
| 复合索引 | 筛选 + 排序组合查询 | 低 | 组合查询提速 5-20x |
| 查询缓存 | 读多写少的列表接口 | 中 | 响应时间降至 1-5ms |
| 只查需要的字段 | 所有查询 | 低 | 减少 30-70% 数据传输 |
| 冗余存储 | 频繁 JOIN 的关联查询 | 中 | 消除 JOIN,提速 2-10x |
| 读写分离 | 高并发读取场景 | 高 | 主库写、从库读,互不阻塞 |
前后端协作
分页、搜索和筛选的实现离不开前后端的紧密配合。API 的参数设计和响应格式直接决定了前端的开发体验。
API 参数设计
推荐的参数风格是扁平化的查询参数,直观且易于调试:
GET /api/comics?status=published&genre=fantasy&q=dragon&page=1&pageSize=20&sort=-createdAt
参数规范建议:
page和pageSize:Offset 分页用;cursor和limit:Cursor 分页用。两者选其一,不要混用。q或search:搜索关键词。sort:排序字段,前缀-表示降序,+或不带前缀表示升序。- 筛选字段直接用字段名:
status=published、min_rating=4。
对于复杂筛选,也可以用一个 filter 参数传递 JSON 结构的筛选条件:
GET /api/comics?filter={"status":"published","rating":{"$gte":4},"tags":{"$in":["fantasy","action"]}}
这种方式更灵活,但调试起来不如扁平参数直观。
响应格式
API 响应应该包含足够的分页元信息,让前端能够正确展示分页 UI:
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 1523,
"totalPages": 77,
"hasNext": true,
"hasPrev": false
}
}对于 Cursor 分页,响应中用 nextCursor 和 prevCursor 替代页码信息:
{
"data": [...],
"pagination": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTMwIn0=",
"hasMore": true
}
}游标值建议做 Base64 编码——这不是为了安全,而是为了隐藏内部实现细节(比如游标基于哪个字段),方便后续修改游标策略而不影响前端。
错误处理与边界情况
几个容易出错的边界情况需要提前约定:
- 空结果:返回空数组和
total: 0,不要返回 404。 - 页码超出范围:返回空数组而非报错,或者返回 400 并提示有效范围。
- 搜索关键词为空:忽略搜索条件,返回全量分页结果。
- 筛选条件无效:返回 422 并明确说明哪个参数有问题。
- pageSize 过大:设置上限(如 100),超出则截断或返回 400。
实现流程
下面用一个 Mermaid 流程图展示分页搜索筛选的完整实现流程:
案例
案例一:AI 漫画创作平台的作品列表
一个 AI 漫画平台需要展示用户创建的作品列表,支持按状态筛选(草稿、已发布、已归档)、按类型搜索(奇幻、科幻、日常)、按关键词搜索标题和描述。
分页方案选择:由于作品列表是管理后台页面,用户需要跳页和排序,选择 Offset 分页。数据量预计在万级,Offset 的性能问题暂时可接受。
搜索实现:标题和描述的搜索使用 PostgreSQL FTS,为 title 和 description 字段建立 tsvector 列,并创建 GIN 索引。标题权重设为 A,描述权重设为 B:
ALTER TABLE comics ADD COLUMN search_vector tsvector;
CREATE INDEX idx_comics_search ON comics USING GIN (search_vector);
-- 触发器自动更新 search_vector
CREATE TRIGGER comics_search_update
BEFORE INSERT OR UPDATE ON comics
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_vector, 'pg_catalog.english', title, description);筛选设计:状态和类型使用固定筛选,前端用下拉框展示。排序支持「最新创建」「最近更新」「名称字母序」。所有筛选条件同步到 URL,方便用户分享和收藏。
案例二:AI 对话历史的大数据量检索
一个 AI 聊天应用需要支持用户检索历史对话,数据量可能达到百万级。对话内容包含多语言文本(英文、中文、日文混合)。
分页方案选择:对话历史是时间线式的数据流,用户通常只看最近的对话,偶尔向前翻阅。选择 Cursor 分页,以对话的 id(UUID)作为游标,性能恒定。
搜索实现:由于数据量大且需要多语言支持,引入 Elasticsearch。数据通过 CDC(Change Data Capture)从 PostgreSQL 同步到 ES。ES 中配置 IK 分词器处理中文,kuromoji 处理日文,standard 处理英文。搜索结果返回后,用对话 id 回查 PostgreSQL 获取完整数据。
筛选设计:支持按时间范围(最近 7 天 / 30 天 / 自定义)、按对话类型(文本 / 图片 / 代码)、按标签筛选。筛选条件作为 filter 参数的 JSON 结构传递给 ES 的 bool 查询。
检查清单
在实现分页、搜索和筛选功能之前,逐项确认以下要点:
分页相关
- 选择合适的分页方案:后台管理用 Offset,Feed 流用 Cursor,不要在大数据量场景用 Offset 分页。
- 设置 pageSize 上限:限制单次请求的最大返回条数(建议 50-100),防止恶意请求拉取全量数据。
- 排序字段必须有索引:分页依赖排序,排序字段没有索引意味着全表扫描。
- Cursor 值做编码:Base64 编码游标值,隐藏内部实现,方便后续调整。
搜索相关
- 从简单方案开始:先用数据库自带的全文搜索,不够用再引入 Elasticsearch。
- 分词器选型:中文搜索必须配置分词器,默认的空白分词对中文无效。
- 搜索结果高亮:返回高亮的匹配片段,让用户快速确认搜索结果的相关性。
筛选相关
- 筛选条件同步到 URL:让用户能分享筛选结果,也方便调试。
- 动态生成筛选项:筛选选项从数据库实时查询,不要硬编码。
- 无效筛选条件要报错:不要静默忽略无效参数,返回明确的错误信息。
性能相关
- 查询前先看索引:确认排序字段、筛选字段、搜索字段都有合适的索引。
- **避免 SELECT ***:只返回前端需要的字段。
- 大数据量分页考虑读写分离:
COUNT(*)查询很耗时,可以走从库或缓存总数。
协作相关
- API 文档覆盖所有参数:分页、搜索、筛选的每个参数都要在 API 文档中说明类型、默认值和取值范围。
- 前后端约定错误码:空结果返回 200 还是 404、参数错误返回 400 还是 422,提前统一。
小结
分页、搜索和筛选看起来是基础功能,但它们的方案选型直接影响产品的可扩展性和用户体验。分页的核心是在「跳页能力」和「大数据量性能」之间做权衡;搜索的核心是在「方案复杂度」和「搜索质量」之间找平衡;筛选的核心是在「灵活性」和「易用性」之间取舍。
没有一劳永逸的方案。MVP 阶段用 Offset 分页 + 数据库 FTS + 简单筛选就够了,随着数据量增长和用户需求演进,再逐步引入 Cursor 分页、Elasticsearch、高级筛选表达式。关键是在每一步都做好性能基准测试和监控,用数据驱动决策,而不是凭直觉提前过度设计。
参考资料
- API Pagination Guide: Cursor vs Offset vs Keyset for High-Scale — Design Gurus,2026
- Comparing Native Postgres, ElasticSearch, and pg_search for Full Text Search — Neon Blog,2025
- REST API Design: Filtering, Sorting, and Pagination — Moesif Blog,2022
- PostgreSQL Full Text Search: Why We Replaced Elasticsearch — msezer.dev,2026
- Offset vs Cursor vs Keyset Pagination: Best Practices for Scalable APIs — Faiz Ahmed,2025
- How to Implement Filtering and Sorting in REST APIs — OneUptime,2026
- Cursor-Based Pagination vs Offset Pagination: Preserving User Experience — Keyhole Software,2026
- REST API Design Best Practices for Query Parameters — Moesif Blog,2024