上一篇讲了商品详情接口——它的难点在「各平台字段不一样」。列表接口是它的姊妹但难点完全不同。详情接口是「点查」给一个确定的 ID取一条确定的记录列表接口是「扫描」在一堆数据里按用户给的任意条件找出一页结果。这个区别决定了一切点查的性能是恒定的而扫描的性能取决于「用户提了什么条件」——于是列表接口成了最容易被用户拖垮的那个接口。这篇文章讲清楚列表接口该怎么设计四个参数维度、三种分页方案、三个必踩的陷阱。一、列表接口要表达的四件事一个列表接口本质上就是让调用方用参数表达四件事找出哪些、按什么顺序、要第几段、字段要多少。维度参数示例设计要点① 筛选 Filter?statuson_saleminPrice1000字段白名单、类型校验、明确区分「未设置」与「为空」② 排序 Sort?sortprice_asc白名单 默认排序 排序键必须唯一③ 分页 Pagination?cursorxxxlimit20三种方案按场景选别默认用 offset④ 字段投影 Fields列表只返回决策字段列表不该像详情一样返回全量字段其中第 ④ 项最常被忽略但影响最直接。列表接口返回的字段应该只包含「用户决策所需的信息」——主图、标题、价格、销量、店铺就够了。很多团队为了省事让列表接口直接复用详情接口的返回结构结果响应体巨大一页 20 条每条都带详情长图和描述响应体几 MB数据库压力大本来只需要查一张主表现在要 join 一堆关联表传输时间成为主要耗时接口变慢的原因不是查询慢而是序列化和网络传输慢。「列表用列表的字段详情用详情的字段」这条边界要守住。一个电商列表特有的细节多 SKU 商品的列表价格要返回「区间」。{ id: 10000000000000001, title: ……, priceMin: 1200, priceMax: 3000, currency: CNY, skuCount: 6 }列表页不需要知道每个 SKU 的价格但需要展示「¥12.00 - ¥30.00」。如果列表只返回一个价格用户点进详情发现价格不同就是一次体验落差。二、三种分页方案性能和体验的取舍分页是列表接口最容易做错的地方。三种方案没有绝对优劣关键是别用错场景。1.offset / limitSELECT * FROM products ORDER BY sales DESC LIMIT 20 OFFSET 10000;优点可以任意跳页实现最直观。致命缺点深分页性能崩塌。这条 SQL 的执行方式是先按排序取前 10020 条然后丢弃前 10000 条返回最后 20 条。也就是说翻到第 500 页时数据库要处理 10020 条记录才能给你 20 条。页数越深越慢最后一页最慢。一个能正常跑一整天、只在有人翻到深处时才爆的接口排查起来非常折磨人。适用场景后台管理页面、总页数有限比如几百条数据的内部列表。2.page / pageSizeGET /v1/products?page2pageSize20语义最直观是对外 API 里最常见的写法。但它的本质仍然是 offsetOFFSET (page-1) * pageSize所以深分页的问题一模一样。它还有一个独有成本total需要额外执行一次COUNT(*)。在大表上COUNT(*)可能比查询本身还慢而且往往只为了在前端显示一句「共 137 条」。优化建议如果只是要显示「有没有下一页」就不要算 total用hasMore布尔值代替如果一定要 total考虑用近似值或异步统计比如从统计表读或定时任务预计算超大结果集不要提供精确 total因为「共 12,345,678 条」对用户毫无意义还会拖慢接口。3. 游标Cursor分页——滚动场景的首选GET /v1/products?cursoreyJzYWxlcyI6MTIzLCJpZCI6NDU2fQlimit20原理不记录「第几页」而是记录「上一页最后一条的位置」。服务端把「排序字段值 唯一 ID」编码成游标下一页从「大于这个游标」的位置继续取。-- 游标分页从上次结束的位置继续 SELECT * FROM products WHERE (sales, id) (123, 456) -- 上一页最后一条的排序键 ORDER BY sales DESC, id DESC LIMIT 20;三大优势性能恒定——无论翻到多深都只扫描 20 条不会随页数增长数据变动时不会重复或漏掉——游标锚定在具体记录上不是「第 N 条」这个抽象位置天然适配无限滚动——移动端和前端信息流的标准做法。唯一缺点不能跳页。用户无法直接跳到「第 5 页」。所以游标分页适合「往下刷」的场景不适合「有页码导航」的场景。选型速查场景推荐方案后台管理、数据量小、需要页码导航page / pageSize对外 API、需要跳页page / pageSize 可选 total移动端无限滚动、信息流游标 Cursor搜索结果结果集大且要滚动游标 Cursor三、三个最容易被忽略的陷阱陷阱一排序键不唯一 —— 翻页会漏数据这是分页问题里最常见、也最难发现的一个。-- ✗ 危险按销量排序销量相同的商品顺序不确定 SELECT * FROM products ORDER BY sales DESC LIMIT 20 OFFSET 0; SELECT * FROM products ORDER BY sales DESC LIMIT 20 OFFSET 20;假设有 100 个商品销量都是 0。数据库对这 100 条的返回顺序是不保证稳定的——两次查询可能给出不同的顺序。结果就是同一件商品在第 1 页和第 2 页都出现用户觉得很奇怪某件商品在任何一页都没出现用户投诉「我明明看到过怎么找不到了」。解法排序键必须补一个唯一字段兜底。-- ✓ 正确销量相同时用 ID 打破平局顺序完全确定 ORDER BY sales DESC, id DESC这条规则应该写进团队的接口规范因为它不会报错、测试也很难发现测试数据往往销量各不相同。陷阱二翻页过程中数据变了用户在第 1 页看到商品 A 排第 5。此时后台新上架了 3 个爆款商品都排到了前面。用户翻到第 2 页时offset 语义下的窗口整体向后位移了 3 位于是第 1 页末尾的 3 个商品又出现在了第 2 页。这不是 bug是 offset 分页的固有特性它描述的是「第 N 到第 M 条」而数据集合在变。解法滚动场景用游标分页。游标锚定在「某条具体记录」上后续结果永远是「排在这条之后的内容」不会因为前面插入了新数据而错位。陷阱三列表缓存的 key 爆炸列表接口看起来很适合缓存但实际很危险。因为参数组合太多了筛选状态 × 价格区间 × 类目 × 店铺…× 排序5 种× 分页N 页 几乎无穷的 key 组合结果就是缓存命中率极低缓存反而成了纯粹的负担和内存浪费。三个可行的缓存策略1. 只缓存「热门组合」——默认排序 无筛选的第一页通常占总请求量的很大比例缓存它收益最高。2. 缓存「ID 列表」而不是完整结果——把「符合条件的商品 ID 序列」缓存起来然后再按 ID 批量取详情。这样即使筛选组合变了商品本身的数据可以复用。3. 搜索类查询交给专门的搜索引擎而不是在关系数据库上硬扛。搜索引擎的优势不只是全文检索还包括对复杂筛选 多维排序 聚合统计的天然支持。还有一条要注意列表缓存绝不能返回过期的价格和库存。列表页展示的价格如果和详情页不一致用户会觉得系统不可信。价格和库存字段在缓存列表里要特别处理短 TTL或者干脆标记为「仅供参考」并在点击后实时校验。四、筛选白名单是底线筛选参数绝对不能直接拼进查询。✗ GET /products?sortprice; DROP TABLE products-- ✗ GET /products?category用户输入的任意字段名这不只是注入问题还包括性能风险如果用户可以按任意字段筛选排序那么他们就能筛到没有索引的字段上触发全表扫描——一个人就能把整个数据库拖慢。规则所有筛选和排序字段必须来自服务端定义的白名单。允许筛选category, status, minPrice, maxPrice, shopId, hasStock 允许排序default, price_asc, price_desc, sales_desc, newest 默认排序default通常是综合权重不是简单的创建时间倒序白名单之外的参数要么忽略、要么明确报错——但绝不要透传给数据库。「未设置」和「为空」是两回事筛选里有一个语义细节经常被搞混?weight → 用户传了空的 weight 参数可能是误操作 ?weightnull → 想筛选「weight 为空的商品」 不传 weight → 不按 weight 筛选返回全部这三种情况必须能区分。很多实现把它们混为一谈导致「想筛未设置的商品」这个需求根本无法表达。统一约定不传参数 不筛选传特定值 筛选该值筛「空」用专门的表达方式如weightIS_NULL。然后写进文档。五、N1列表接口最常见的性能杀手列表接口的性能问题很大一部分不是查询本身慢而是「为了一页 20 条数据额外发了 20 次查询」。① 查出 20 个商品 ② 为每个商品查一次店铺信息 → 20 次查询 ③ 为每个商品查一次库存 → 20 次查询 ④ 为每个商品查一次价格 → 20 次查询 总计1 60 61 次查询列表接口的 N1 比详情接口危害更大因为详情接口只有 1 条记录而列表有 N 条N1 会被放大 N 倍。解法只有一个批量查询。-- ✗ N1 SELECT * FROM shops WHERE id ?; -- 循环 20 次 -- ✓ 批量 SELECT * FROM shops WHERE id IN (?, ?, ..., ?); -- 一次查 20 个配套要求关联字段店铺 ID、类目 ID要有索引否则IN查询本身也会慢批量查询结果要在内存里按 ID 建立映射再回填到列表项上如果关联数据本身可以缓存比如店铺信息变化很少加一层缓存比批量查询更划算。判断标准很简单一个列表接口的数据库查询次数应该是一个常数不应该随pageSize增长。六、七个高频反模式反模式后果排序键不唯一翻页出现重复或漏项且不报错无限滚动还用 offset深分页越来越慢体验也随之错乱允许按任意字段筛选排序无索引字段被扫一个人拖垮数据库筛选参数直接拼查询注入风险 性能不可控列表返回详情全量字段响应体几 MB慢在传输不在查询列表接口存在 N1查询次数随 pageSize 线性增长缓存列表里的价格库存列表与详情价格不一致用户失去信任七、商品列表接口检查清单参数设计筛选字段有白名单且都建了索引排序字段有白名单且有合理默认排序明确区分「不传参数 / 传空值 / 筛空值」三种语义并写入文档筛选支持多值与区间价格、时间列表返回字段是「决策所需」最小集合不是详情字段全集多 SKU 商品返回价格区间priceMin / priceMax分页分页方案与场景匹配滚动用游标页码导航用 page排序键唯一可能重复的字段后面补 ID 兜底total 按需返回或换用 hasMore大表不算精确 count游标内容不可伪造、不可被客户端篡改签名或加密越界页超出总页数有明确返回不报错性能数据库查询次数为常数不随 pageSize 增长已消除 N1关联字段有索引批量查询用IN而非循环列表查询有合理的行数上限防止 pageSize 被传成 10000慢查询有监控能定位到具体的参数组合缓存只缓存热门组合默认排序首页等不做全组合缓存或缓存 ID 列表而非完整结果列表缓存中的价格与库存标记了时效点击后实时校验搜索类需求评估了是否该用搜索引擎而非关系数据库一句话总结列表接口设计的核心是「让用户表达查询意图」和「让数据库不被打垮」之间的平衡。所以判断一个列表接口做得好不好看三条排序键稳定翻页不漏、参数走白名单性能可控、查询次数恒定不随页数增长。这三条守住列表接口就不会成为系统里那个「平时没事、一有压力就先倒」的短板。本文为通用接口设计方法总结具体分页方案与缓存策略需结合数据规模与访问特征决定。相关参考跨境电商平台接口与数据服务 方案