如何做分页、搜索和筛选

当你的 AI 产品从几十个测试用户扩展到几千个活跃用户,数据库里的记录从百级增长到百万级,「能跑」和「好用」之间的差距会集中暴露在三个功能上:分页、搜索和筛选。它们不是锦上添花的高级特性,而是用户每天都在用的基础能力。分页决定了用户能否快速找到目标数据,搜索决定了用户能否用自然语言定位内容,筛选决定了用户能否从海量结果中精确缩小范围。

这篇文章会从方案选型、实现细节、性能优化和前后端协作四个维度,系统梳理分页、搜索和筛选的设计思路与工程实践。

分页方案对比

分页是最基础的数据组织能力。当一个查询返回 10 万条结果时,没有任何前端能一次性渲染这些数据,分页就是解决「怎么把大量数据分批展示」的问题。

Offset 分页

Offset 分页是最直觉的方案,SQL 层面用 LIMITOFFSET 实现:

SELECT * FROM comics ORDER BY created_at DESC LIMIT 20 OFFSET 40;

前端传入 pagepageSize,后端计算 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;

游标通常是上一页最后一条记录的某个唯一字段值(如 idcreated_at),前端只需把它传给后端。由于查询条件直接定位到起始位置,无论翻到多深的页,查询性能都是恒定的。同时,因为定位到具体的记录而非偏移量,新增或删除数据不会导致跳页或重复。

Cursor 分页的代价是不支持「跳转到第 N 页」——用户只能顺序地加载上一页或下一页。这在信息流(Feed)、无限滚动场景下不是问题,但在需要页码导航的管理后台就不太合适。

无限滚动

无限滚动不是一个独立的分页技术,而是 Cursor 分页在前端的呈现方式。用户滚动到页面底部时自动加载下一批数据,没有传统的「页码」概念。它适合内容消费型场景(浏览 AI 生成的漫画列表、查看创作历史),但不适合需要定位和对比的场景(后台管理、数据审核)。

分页方案对比表

维度Offset 分页Cursor 分页无限滚动
实现复杂度低,LIMIT + OFFSET中,需要稳定的排序字段和游标编码中,需要前端滚动监听
大数据量性能差,offset 越大越慢好,性能恒定好,性能恒定
跳页支持支持任意页码跳转不支持,只能顺序翻页不支持
数据一致性插入/删除会导致跳页或重复不受中间数据变化影响不受中间数据变化影响
适用场景后台管理、小数据量列表Feed 流、大数据量 API移动端浏览、内容消费
典型用户GitHub Issues 列表Twitter/X 时间线Instagram、小红书

搜索实现

搜索让用户用关键词或自然语言从数据中找到目标内容,是比筛选更灵活的定位方式。

数据库全文搜索

PostgreSQL 内置了 Full-text Search(FTS)能力,基于 tsvectortsquery 两个数据类型实现。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,直接在现有数据库上添加索引即可。对于中小规模数据(几十万条记录以内),性能完全够用。它还支持中文分词(需要 zhparserpg_jieba 扩展)和权重分配(可以给标题、正文等不同字段设置不同权重)。

Elasticsearch 与专业搜索服务

当数据量增长到百万级以上,或者需要复杂的搜索能力(拼音搜索、同义词扩展、自定义相关性调优、多语言混合搜索),Elasticsearch 是更成熟的选择。它基于 Lucene 构建,底层使用倒排索引,在搜索延迟和吞吐量上比 PostgreSQL FTS 有 3-10 倍的优势。

Algolia 和 Meilisearch 则走的是 SaaS 路线——前者提供托管服务,按搜索请求数计费;后者是开源的轻量替代,适合中小项目。它们的共同优势是开箱即用:分面搜索、拼写纠错、高亮显示等功能无需从零搭建。

搜索方案对比表

维度PostgreSQL FTSElasticsearchAlgolia / 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 生态中可以用 useSearchParamsnuqs 库来管理,服务端渲染框架(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 索引;对于筛选字段(如 statusgenre),如果基数不高可以用 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 机制来做精准失效。

查询优化

一些通用的查询优化技巧:

  1. 只查需要的字段:避免 SELECT *,只返回前端需要的字段,减少数据传输量。
  2. 使用覆盖索引:如果查询的字段都在索引中,数据库可以直接从索引返回数据,不需要回表查询。
  3. 避免在 WHERE 中对字段做函数运算WHERE LOWER(name) = 'xxx' 会导致索引失效,应该改为存储时就统一小写,或使用函数索引。
  4. 控制 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

参数规范建议:

  • pagepageSize:Offset 分页用;cursorlimit:Cursor 分页用。两者选其一,不要混用。
  • qsearch:搜索关键词。
  • sort:排序字段,前缀 - 表示降序,+ 或不带前缀表示升序。
  • 筛选字段直接用字段名:status=publishedmin_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 分页,响应中用 nextCursorprevCursor 替代页码信息:

{
  "data": [...],
  "pagination": {
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTMwIn0=",
    "hasMore": true
  }
}

游标值建议做 Base64 编码——这不是为了安全,而是为了隐藏内部实现细节(比如游标基于哪个字段),方便后续修改游标策略而不影响前端。

错误处理与边界情况

几个容易出错的边界情况需要提前约定:

  • 空结果:返回空数组和 total: 0,不要返回 404。
  • 页码超出范围:返回空数组而非报错,或者返回 400 并提示有效范围。
  • 搜索关键词为空:忽略搜索条件,返回全量分页结果。
  • 筛选条件无效:返回 422 并明确说明哪个参数有问题。
  • pageSize 过大:设置上限(如 100),超出则截断或返回 400。

实现流程

下面用一个 Mermaid 流程图展示分页搜索筛选的完整实现流程:

流程图画布 · 115%
Mermaid 流程图加载中...

案例

案例一:AI 漫画创作平台的作品列表

一个 AI 漫画平台需要展示用户创建的作品列表,支持按状态筛选(草稿、已发布、已归档)、按类型搜索(奇幻、科幻、日常)、按关键词搜索标题和描述。

分页方案选择:由于作品列表是管理后台页面,用户需要跳页和排序,选择 Offset 分页。数据量预计在万级,Offset 的性能问题暂时可接受。

搜索实现:标题和描述的搜索使用 PostgreSQL FTS,为 titledescription 字段建立 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 查询。

检查清单

在实现分页、搜索和筛选功能之前,逐项确认以下要点:

分页相关

  1. 选择合适的分页方案:后台管理用 Offset,Feed 流用 Cursor,不要在大数据量场景用 Offset 分页。
  2. 设置 pageSize 上限:限制单次请求的最大返回条数(建议 50-100),防止恶意请求拉取全量数据。
  3. 排序字段必须有索引:分页依赖排序,排序字段没有索引意味着全表扫描。
  4. Cursor 值做编码:Base64 编码游标值,隐藏内部实现,方便后续调整。

搜索相关

  1. 从简单方案开始:先用数据库自带的全文搜索,不够用再引入 Elasticsearch。
  2. 分词器选型:中文搜索必须配置分词器,默认的空白分词对中文无效。
  3. 搜索结果高亮:返回高亮的匹配片段,让用户快速确认搜索结果的相关性。

筛选相关

  1. 筛选条件同步到 URL:让用户能分享筛选结果,也方便调试。
  2. 动态生成筛选项:筛选选项从数据库实时查询,不要硬编码。
  3. 无效筛选条件要报错:不要静默忽略无效参数,返回明确的错误信息。

性能相关

  1. 查询前先看索引:确认排序字段、筛选字段、搜索字段都有合适的索引。
  2. **避免 SELECT ***:只返回前端需要的字段。
  3. 大数据量分页考虑读写分离COUNT(*) 查询很耗时,可以走从库或缓存总数。

协作相关

  1. API 文档覆盖所有参数:分页、搜索、筛选的每个参数都要在 API 文档中说明类型、默认值和取值范围。
  2. 前后端约定错误码:空结果返回 200 还是 404、参数错误返回 400 还是 422,提前统一。

小结

分页、搜索和筛选看起来是基础功能,但它们的方案选型直接影响产品的可扩展性和用户体验。分页的核心是在「跳页能力」和「大数据量性能」之间做权衡;搜索的核心是在「方案复杂度」和「搜索质量」之间找平衡;筛选的核心是在「灵活性」和「易用性」之间取舍。

没有一劳永逸的方案。MVP 阶段用 Offset 分页 + 数据库 FTS + 简单筛选就够了,随着数据量增长和用户需求演进,再逐步引入 Cursor 分页、Elasticsearch、高级筛选表达式。关键是在每一步都做好性能基准测试和监控,用数据驱动决策,而不是凭直觉提前过度设计。

参考资料

  1. API Pagination Guide: Cursor vs Offset vs Keyset for High-Scale — Design Gurus,2026
  2. Comparing Native Postgres, ElasticSearch, and pg_search for Full Text Search — Neon Blog,2025
  3. REST API Design: Filtering, Sorting, and Pagination — Moesif Blog,2022
  4. PostgreSQL Full Text Search: Why We Replaced Elasticsearch — msezer.dev,2026
  5. Offset vs Cursor vs Keyset Pagination: Best Practices for Scalable APIs — Faiz Ahmed,2025
  6. How to Implement Filtering and Sorting in REST APIs — OneUptime,2026
  7. Cursor-Based Pagination vs Offset Pagination: Preserving User Experience — Keyhole Software,2026
  8. REST API Design Best Practices for Query Parameters — Moesif Blog,2024