商品列表接口设计:分页、筛选、排序背后的工程权衡

6 阅读12分钟

上一篇讲了商品详情接口——它的难点在「各平台字段不一样」。

列表接口是它的姊妹,但难点完全不同。详情接口是「点查」:给一个确定的 ID,取一条确定的记录;列表接口是「扫描」:在一堆数据里,按用户给的任意条件找出一页结果。

这个区别决定了一切:点查的性能是恒定的,而扫描的性能取决于「用户提了什么条件」——于是列表接口成了最容易被用户拖垮的那个接口。

这篇文章讲清楚列表接口该怎么设计:四个参数维度、三种分页方案、三个必踩的陷阱。

一、列表接口要表达的四件事

一个列表接口,本质上就是让调用方用参数表达四件事:找出哪些、按什么顺序、要第几段、字段要多少。

维度参数示例设计要点
① 筛选 Filter?status=on_sale&minPrice=1000字段白名单、类型校验、明确区分「未设置」与「为空」
② 排序 Sort?sort=price_asc白名单 + 默认排序 + 排序键必须唯一
③ 分页 Pagination?cursor=xxx&limit=20三种方案按场景选,别默认用 offset
④ 字段投影 Fields列表只返回决策字段列表不该像详情一样返回全量字段

其中第 ④ 项最常被忽略,但影响最直接。

列表接口返回的字段,应该只包含「用户决策所需的信息」——主图、标题、价格、销量、店铺,就够了。

很多团队为了省事,让列表接口直接复用详情接口的返回结构,结果:

  • 响应体巨大:一页 20 条,每条都带详情长图和描述,响应体几 MB;
  • 数据库压力大:本来只需要查一张主表,现在要 join 一堆关联表;
  • 传输时间成为主要耗时:接口变慢的原因不是查询慢,而是序列化和网络传输慢。

「列表用列表的字段,详情用详情的字段」,这条边界要守住。

一个电商列表特有的细节:多 SKU 商品的列表价格要返回「区间」。

列表页不需要知道每个 SKU 的价格,但需要展示「¥12.00 - ¥30.00」。如果列表只返回一个价格,用户点进详情发现价格不同,就是一次体验落差。

二、三种分页方案:性能和体验的取舍

分页是列表接口最容易做错的地方。三种方案没有绝对优劣,关键是别用错场景。

1. offset / limit

{
  "id": "10000000000000001",
  "title": "……",
  "priceMin": 1200,
  "priceMax": 3000,
  "currency": "CNY",
  "skuCount": 6
}

优点:可以任意跳页,实现最直观。

致命缺点:深分页性能崩塌。

这条 SQL 的执行方式是:先按排序取前 10020 条,然后丢弃前 10000 条,返回最后 20 条。  也就是说,翻到第 500 页时,数据库要处理 10020 条记录才能给你 20 条。

页数越深越慢,最后一页最慢。  一个能正常跑一整天、只在有人翻到深处时才爆的接口,排查起来非常折磨人。

适用场景:后台管理页面、总页数有限(比如几百条数据)的内部列表。

2. page / pageSize

SELECT * FROM products ORDER BY sales DESC LIMIT 20 OFFSET 10000;

语义最直观,是对外 API 里最常见的写法。但它的本质仍然是 offset(OFFSET = (page-1) * pageSize),所以深分页的问题一模一样。

它还有一个独有成本:total 需要额外执行一次 COUNT(*)。  在大表上,COUNT(*) 可能比查询本身还慢,而且往往只为了在前端显示一句「共 137 条」。

优化建议:

  • 如果只是要显示「有没有下一页」,就不要算 total,用 hasMore 布尔值代替;
  • 如果一定要 total,考虑用近似值或异步统计(比如从统计表读,或定时任务预计算);
  • 超大结果集不要提供精确 total,因为「共 12,345,678 条」对用户毫无意义,还会拖慢接口。

3. 游标(Cursor)分页——滚动场景的首选

GET /v1/products?page=2&pageSize=20

原理:不记录「第几页」,而是记录「上一页最后一条的位置」。  服务端把「排序字段值 + 唯一 ID」编码成游标,下一页从「大于这个游标」的位置继续取。

GET /v1/products?cursor=eyJzYWxlcyI6MTIzLCJpZCI6NDU2fQ&limit=20

三大优势:

  1. 性能恒定——无论翻到多深,都只扫描 20 条,不会随页数增长;
  1. 数据变动时不会重复或漏掉——游标锚定在具体记录上,不是「第 N 条」这个抽象位置;
  1. 天然适配无限滚动——移动端和前端信息流的标准做法。

唯一缺点:不能跳页。  用户无法直接跳到「第 5 页」。所以游标分页适合「往下刷」的场景,不适合「有页码导航」的场景。

选型速查

场景推荐方案
后台管理、数据量小、需要页码导航page / pageSize
对外 API、需要跳页page / pageSize + 可选 total
移动端无限滚动、信息流游标 Cursor
搜索结果(结果集大且要滚动)游标 Cursor

三、三个最容易被忽略的陷阱

陷阱一:排序键不唯一 —— 翻页会漏数据

这是分页问题里最常见、也最难发现的一个。

-- 游标分页:从上次结束的位置继续
SELECT * FROM products
WHERE (sales, id) < (123, 456)      -- 上一页最后一条的排序键
ORDER BY sales DESC, id DESC
LIMIT 20;

假设有 100 个商品销量都是 0。数据库对这 100 条的返回顺序是不保证稳定的——两次查询可能给出不同的顺序。

结果就是:

  • 同一件商品在第 1 页和第 2 页都出现(用户觉得很奇怪);
  • 某件商品在任何一页都没出现(用户投诉「我明明看到过,怎么找不到了」)。

解法:排序键必须补一个唯一字段兜底。

-- ✗ 危险:按销量排序,销量相同的商品顺序不确定
SELECT * FROM products ORDER BY sales DESC LIMIT 20 OFFSET 0;
SELECT * FROM products ORDER BY sales DESC LIMIT 20 OFFSET 20;

这条规则应该写进团队的接口规范,因为它不会报错、测试也很难发现(测试数据往往销量各不相同)。

陷阱二:翻页过程中数据变了

用户在第 1 页看到商品 A 排第 5。此时后台新上架了 3 个爆款商品,都排到了前面。用户翻到第 2 页时,offset 语义下的窗口整体向后位移了 3 位,于是第 1 页末尾的 3 个商品又出现在了第 2 页。

这不是 bug,是 offset 分页的固有特性:它描述的是「第 N 到第 M 条」,而数据集合在变。

解法:滚动场景用游标分页。  游标锚定在「某条具体记录」上,后续结果永远是「排在这条之后的内容」,不会因为前面插入了新数据而错位。

陷阱三:列表缓存的 key 爆炸

列表接口看起来很适合缓存,但实际很危险。

因为参数组合太多了:

-- ✓ 正确:销量相同时用 ID 打破平局,顺序完全确定
ORDER BY sales DESC, id DESC

结果就是缓存命中率极低,缓存反而成了纯粹的负担和内存浪费。

三个可行的缓存策略:

1. 只缓存「热门组合」——默认排序 + 无筛选的第一页,通常占总请求量的很大比例,缓存它收益最高。

2. 缓存「ID 列表」而不是完整结果——把「符合条件的商品 ID 序列」缓存起来,然后再按 ID 批量取详情。这样即使筛选组合变了,商品本身的数据可以复用。

3. 搜索类查询交给专门的搜索引擎,而不是在关系数据库上硬扛。搜索引擎的优势不只是全文检索,还包括对复杂筛选 + 多维排序 + 聚合统计的天然支持。

还有一条要注意:列表缓存绝不能返回过期的价格和库存。  列表页展示的价格如果和详情页不一致,用户会觉得系统不可信。价格和库存字段在缓存列表里要特别处理(短 TTL,或者干脆标记为「仅供参考」并在点击后实时校验)。

四、筛选:白名单是底线

筛选参数绝对不能直接拼进查询。

筛选(状态 × 价格区间 × 类目 × 店铺…)× 排序(5 种)× 分页(N 页)
= 几乎无穷的 key 组合

这不只是注入问题,还包括性能风险:如果用户可以按任意字段筛选排序,那么他们就能筛到没有索引的字段上,触发全表扫描——一个人就能把整个数据库拖慢。

规则:所有筛选和排序字段必须来自服务端定义的白名单。

✗ GET /products?sort=price; DROP TABLE products--
✗ GET /products?category=<用户输入的任意字段名>

白名单之外的参数,要么忽略、要么明确报错——但绝不要透传给数据库。

「未设置」和「为空」是两回事

筛选里有一个语义细节经常被搞混:

允许筛选:category, status, minPrice, maxPrice, shopId, hasStock
允许排序:default, price_asc, price_desc, sales_desc, newest
默认排序:default(通常是综合权重,不是简单的创建时间倒序)

这三种情况必须能区分。  很多实现把它们混为一谈,导致「想筛未设置的商品」这个需求根本无法表达。

统一约定:不传参数 = 不筛选;传特定值 = 筛选该值;筛「空」用专门的表达方式(如 weight=IS_NULL)。  然后写进文档。

五、N+1:列表接口最常见的性能杀手

列表接口的性能问题,很大一部分不是查询本身慢,而是**「为了一页 20 条数据,额外发了 20 次查询」**。

?weight=          → 用户传了空的 weight 参数(可能是误操作)
?weight=null      → 想筛选「weight 为空的商品」
(不传 weight)    → 不按 weight 筛选,返回全部

列表接口的 N+1 比详情接口危害更大,因为详情接口只有 1 条记录,而列表有 N 条,N+1 会被放大 N 倍。

解法只有一个:批量查询。

① 查出 20 个商品
② 为每个商品查一次店铺信息     → 20 次查询
③ 为每个商品查一次库存         → 20 次查询
④ 为每个商品查一次价格         → 20 次查询
总计:1 + 60 = 61 次查询

配套要求:

  • 关联字段(店铺 ID、类目 ID)要有索引,否则 IN 查询本身也会慢;
  • 批量查询结果要在内存里按 ID 建立映射,再回填到列表项上;
  • 如果关联数据本身可以缓存(比如店铺信息变化很少),加一层缓存比批量查询更划算。

判断标准很简单:一个列表接口的数据库查询次数,应该是一个常数,不应该随 pageSize 增长。

六、七个高频反模式

反模式后果
排序键不唯一翻页出现重复或漏项,且不报错
无限滚动还用 offset深分页越来越慢,体验也随之错乱
允许按任意字段筛选排序无索引字段被扫,一个人拖垮数据库
筛选参数直接拼查询注入风险 + 性能不可控
列表返回详情全量字段响应体几 MB,慢在传输不在查询
列表接口存在 N+1查询次数随 pageSize 线性增长
缓存列表里的价格库存列表与详情价格不一致,用户失去信任

七、商品列表接口检查清单

参数设计

  • 筛选字段有白名单,且都建了索引
  • 排序字段有白名单,且有合理默认排序
  • 明确区分「不传参数 / 传空值 / 筛空值」三种语义,并写入文档
  • 筛选支持多值与区间(价格、时间)
  • 列表返回字段是「决策所需」最小集合,不是详情字段全集
  • 多 SKU 商品返回价格区间(priceMin / priceMax)

分页

  • 分页方案与场景匹配(滚动用游标,页码导航用 page)
  • 排序键唯一(可能重复的字段后面补 ID 兜底)
  • total 按需返回,或换用 hasMore;大表不算精确 count
  • 游标内容不可伪造、不可被客户端篡改(签名或加密)
  • 越界页(超出总页数)有明确返回,不报错

性能

  • 数据库查询次数为常数,不随 pageSize 增长(已消除 N+1)
  • 关联字段有索引,批量查询用 IN 而非循环
  • 列表查询有合理的行数上限(防止 pageSize 被传成 10000)
  • 慢查询有监控,能定位到具体的参数组合

缓存

  • 只缓存热门组合(默认排序首页等),不做全组合缓存
  • 或缓存 ID 列表而非完整结果
  • 列表缓存中的价格与库存标记了时效,点击后实时校验
  • 搜索类需求评估了是否该用搜索引擎而非关系数据库

一句话总结:  列表接口设计的核心,是**「让用户表达查询意图」和「让数据库不被打垮」之间的平衡**。所以判断一个列表接口做得好不好,看三条:排序键稳定(翻页不漏)、参数走白名单(性能可控)、查询次数恒定(不随页数增长) 。这三条守住,列表接口就不会成为系统里那个「平时没事、一有压力就先倒」的短板。

本文为通用接口设计方法总结,具体分页方案与缓存策略需结合数据规模与访问特征决定。

相关参考:  跨境电商平台接口与数据服务 方案