上一篇讲了商品详情接口——它的难点在「各平台字段不一样」。
列表接口是它的姊妹,但难点完全不同。详情接口是「点查」:给一个确定的 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
三大优势:
- 性能恒定——无论翻到多深,都只扫描 20 条,不会随页数增长;
- 数据变动时不会重复或漏掉——游标锚定在具体记录上,不是「第 N 条」这个抽象位置;
- 天然适配无限滚动——移动端和前端信息流的标准做法。
唯一缺点:不能跳页。 用户无法直接跳到「第 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 列表而非完整结果
- 列表缓存中的价格与库存标记了时效,点击后实时校验
- 搜索类需求评估了是否该用搜索引擎而非关系数据库
一句话总结: 列表接口设计的核心,是**「让用户表达查询意图」和「让数据库不被打垮」之间的平衡**。所以判断一个列表接口做得好不好,看三条:排序键稳定(翻页不漏)、参数走白名单(性能可控)、查询次数恒定(不随页数增长) 。这三条守住,列表接口就不会成为系统里那个「平时没事、一有压力就先倒」的短板。
本文为通用接口设计方法总结,具体分页方案与缓存策略需结合数据规模与访问特征决定。
相关参考: 跨境电商平台接口与数据服务 方案