info.list参数这四个字,第一次看到大概会以为是某份接口文档里被随手截下来的一行路径,实际上它几乎是所有列表类接口都绕不开的一个结构:外层一个info装元数据,内层一个list装数据行。看起来简单,但它牵扯到的东西一点都不少——分页语义、字段命名、空值约定、类型精度、序列化行为、前后端契约对齐,任何一环没想清楚,上线之后就是无穷无尽的“为什么这里少一条”“为什么总数对不上”。我做过不少列表接口,也接手过不少别人留下的info.list,说实话,出问题的地方往往不是业务逻辑,而是这个结构本身的约定没定死。下面我把这套东西从选型到落地完整拆一遍,包括参数怎么算、校验怎么写、坑在哪里,尽量给到可以直接抄的程度。
1. 先把info.list讲明白:它是什么、卡在哪儿、谁需要关心
1.1 一个被复制粘贴了无数次的字段路径
info.list本质上是一条取值路径:在一个 JSON 响应体里,先进入info这个对象,再取它下面的list数组。它通常长这样:最外层是状态码和提示信息,中间一层info放分页相关的元数据,比如总条数、当前页、每页条数、总页数,最里面list放当前页的实际数据行。这种三层结构在列表查询、搜索结果、消息流、日志检索、订单列表这类场景里出现的频率极高。
之所以高频,是因为它同时满足了两类读者的需求:机器读的时候,list直接就是可遍历的数组,不需要再剥一层;人读的时候,info里的元数据一眼就能看出这次查询的边界在哪。如果只有一个裸数组返回,前端想做个分页器就得另外再发一次计数请求,白白多一次往返。所以这个结构不是谁拍脑袋想出来的,是被真实需求逼出来的。
我见过一些团队嫌它啰嗦,把分页信息塞进响应头,或者干脆只返回数组让前端自己数长度,结果就是列表页永远显示“共 ? 条”。这种省事是假省事,后面补起来更贵。
1.2 为什么这个结构会长成这样
要理解info.list的形态,得回到列表查询的本质。一次列表查询其实包含两个完全不同性质的信息:一个是“这次给我哪一段”,一个是“一共有多少段”。前者是数据,后者是元数据。数据可能几十上百条,元数据通常只有四五个数字。把性质不同的东西混在一个层级里,字段就会变得又长又乱,list里每一行都重复带一遍total,纯属浪费带宽。
分层之后,职责就清楚了:info负责描述这次查询的上下文,list负责承载业务实体。这个划分还有个隐性好处——list里的每一行可以保持纯净,直接映射到业务模型,序列化和反序列化都不用做特殊处理。你在服务端把实体列表丢进去就行,不需要给每个实体塞分页字段。
另外一个原因是向下兼容。info这个对象是可以扩展的:今天加个hasNext,明天加个serverTime,后天加个聚合统计,都不会影响list的结构,也不会让已经对接的前端崩掉。这种“元数据可生长、数据层稳定”的特性,是它能在大量项目里活下来的根本原因。
1.3 谁该认真读这篇
这篇东西不是写给纯新手的入门科普,也不是写给架构师的宏大叙事。它最适合三类人:第一类是正在设计列表接口的后端,需要把info.list的字段和语义一次定对,避免后期反复改契约;第二类是要消费这个结构的前端,需要知道哪些字段可以信、哪些字段有坑、空数组和null到底怎么处理;第三类是接手遗留项目的人,看到一堆info.list但搞不清分页到底怎么算的,需要一份能对照排查的清单。
如果你只是想知道“这个字段是干嘛的”,看完第 1 节就够了。如果你想把它落地到项目里、并且不想在三个月后被人追着问“为什么第二页少了两条”,那 2 到 5 节建议逐段看。下面所有涉及具体数值的地方,我都会给出计算过程,涉及代码的地方都会给可运行的片段,涉及坑的地方都是我或者身边人真实踩过的。
2. 结构选型:info套list和平铺返回,到底该选哪个
2.1 三种主流返回结构的横向对比
在动手写字段之前,先得把结构定下来。常见的返回形态大概有三种:完全平铺、info嵌套list、以及data里再套一层。它们没有绝对优劣,只有适不适合。
| 结构形态 | 典型样子 | 优点 | 明显短板 | 适合场景 |
|---|---|---|---|---|
| 完全平铺 | {total, page, list:[...]} | 层级浅,取值路径短 | 元数据和数据混在一起,扩展时容易撞名 | 字段极少的小接口 |
info嵌套list | {code, info:{...}, list:[...]} | 职责清晰,元数据可自由扩展 | 取值要多跳一层 | 分页列表、搜索结果 |
data再套一层 | {code, data:{info:{...}, list:[...]}} | 统一入口,便于加通用拦截 | 层级最深,调试时容易看花眼 | 大型系统统一网关 |
我个人的倾向是第二种。第一种在字段少的时候确实清爽,但只要业务一长,total、page、pageSize、hasNext、aggregations全挤在顶层,和业务字段混在一起,读代码的人得先猜哪个是元数据。第三种在需要统一做响应包装、统一加签解签、统一埋点的系统里很合理,但如果你的项目规模还没到那个程度,多一层就是纯负担,尤其在排查问题时,日志里打出来的 JSON 一层套一层,肉眼找一个字段要找半天。
选型的判断标准其实就一句话:元数据会不会持续增长。会,就用info分层;不会,平铺也无妨。绝大多数列表接口的元数据都是会长的,所以info.list成为了默认答案。
2.2info与list的职责边界怎么划
结构定了之后,最容易出问题的是边界。我见过把list里第一条记录的某个字段提到info里的,也见过把total塞进list每一行的。这两种都是边界失守。
我的划分原则很粗暴:能被list.length直接影响或者被它推导出来的,放info;描述单条业务实体属性的,放list。按这个原则,total、page、pageSize、pages、hasNext、hasPrev这些统统归info,因为它们描述的是“这一段”和“整体”的关系。而商品名、订单号、创建时间这些,无论多少条都只属于某一行,归list。
有一个灰色地带值得单独说:聚合统计。比如搜索结果里要显示“品牌:A 12 件,B 8 件”。这类信息既不属于单行,也不完全属于分页。我的做法是放info下面单独开一个aggregations对象,而不是硬塞进list或者平铺在顶层。理由是它和分页元数据一样,属于“这次查询的上下文”,生命周期和info一致。
还有一类经常被误放的东西:当前登录用户的信息。有些人图省事,把userId、userName塞进info。这在多用户共享缓存的场景下是灾难,一旦缓存键没带用户维度,A 用户就会看到 B 用户的名字。用户信息该走独立的会话接口或者顶层字段,不要往info里塞。
2.3 命名上的几个高频分歧
字段命名看着是小事,实际是团队协作里最容易吵架的地方。围绕info.list这套结构,分歧主要集中在几个点上,我把自己踩过的结论列出来。
list还是rows还是items?这三个都有人用。list的好处是语义中性、不绑定具体业务;rows带着明显的表格隐喻,适合管理后台;items更偏集合语义,适合购物车、消息流。同一个系统里最好只选一个,别这个接口用list、那个接口用items,前端写适配器写到吐。
total还是count?total表达的是“满足条件的总记录数”,count容易和“当前页条数”混淆。建议total专指总数,当前页条数用list.length现算,不额外给字段,避免两个数字不一致时不知道该信谁。
page从 0 还是从 1 开始?这是跨团队协作的重灾区。我的建议是接口层统一从 1 开始,因为这是人直觉上的“第一页”;如果底层存储用的是从 0 开始的偏移量,在服务层做一次转换就行,别把这个差异透出去。一旦透出去,前端传 0 后端当第一页、前端传 1 后端当第二页的事故一定会发生。
pageSize还是size还是limit?pageSize语义最明确,limit更偏数据库层。接口层用pageSize,SQL 层用limit,中间做映射,各司其职。
2.4 什么场景下不该用info.list
不是所有列表都该套这一层。有两种情况我会明确避开。
第一种是流式或游标分页。当数据量大到深度分页会拖垮数据库时,通常会改用游标方式,靠上一页最后一条的排序键往下取。这时候page、pages、total这些字段要么算不出来,要么算出来代价极高。硬套info.list只会让人误以为可以随便跳页。这种场景下info里应该放的是nextCursor、hasMore,而不是传统分页字段。字段名一样,语义完全不同,不如换个结构避免误导。
第二种是一次性返回全量的配置类接口。比如字典表、枚举列表,总共就几十条,不会增长。这种直接返回数组最省事,套上info反而让调用方多写一层解包。判断标准是:数据量是否有稳定的上界、是否需要翻页。两个答案都是否,就别套。
选型这件事没有标准答案,但有一个通用原则:结构的复杂度应该和业务的复杂度匹配。列表要翻页、要统计、要聚合,那就值得分层;列表就是个小数组,那就别折腾。
3. 字段级拆解:list装什么、info算什么
3.1list里每一行的字段设计
list里装的是业务实体,但直接暴露数据库字段是很常见的错误。我见过把表结构原样吐出去的接口,字段名是c_time、is_del、f_status这种,前端拿到手一脸懵,还得反过来问后端每个字段什么意思。接口是对外的契约,字段名应该按业务语义重新组织。
具体做法是给每行定义一个明确的视图对象,只放这个场景真正需要的字段。列表页通常不需要详情页的全量字段,把大文本、富文本、二进制内容排除掉,能显著降低响应体积。我做过一个对比,同一个商品实体,详情接口返回 3.2KB,列表接口裁剪到 12 个字段后只有 400 字节,一千条数据差了将近 3MB。这个差距在移动端网络下非常明显。
每行还需要一个稳定的唯一标识字段,通常叫id。这个字段的用途不只是渲染 key,还包括后续的详情跳转、批量操作、去重。没有稳定id的列表在交互上会很难做,尤其是做无限滚动时,没有 key 就无法判断哪些是新数据。
时间字段的处理也值得说一句。数据库里存的是时间戳或者带时区的时间,直接吐给前端往往会出问题。我的做法是统一格式化成固定格式的字符串,同时在info里给出服务端的当前时间,前端据此做相对时间展示(“3 分钟前”)。如果只给绝对时间不给基准,客户端时钟一偏,就会显示“来自未来”的诡异结果。
3.2info里分页元数据怎么算
这部分是info.list的核心,也是最容易算错的地方。假设当前请求是第page页、每页pageSize条,数据库里满足条件的总记录数是total,那么几个关键值是这样推出来的。
偏移量offset = (page - 1) × pageSize。这是所有分页的基础,页从 1 开始,第一页偏移 0。总页数pages = ceil(total / pageSize),也就是向上取整。举个例子,total = 1000、pageSize = 20,那么pages = 1000 / 20 = 50,正好整除。如果total = 1001、pageSize = 20,1001 / 20 = 50.05,向上取整得 51 页,最后一页只有 1 条。
是否还有下一页的判断有两种写法。一种是hasNext = page < pages,依赖pages计算正确;另一种是hasNext = offset + list.length < total。我倾向后者,因为它用的是实际返回的行数,即使total因为并发写入发生了变化,判断也不会失真。曾经遇到过一个案例:查询total和查询list是两条独立 SQL,中间隔了几十毫秒,期间有人插入了几条数据,导致total比实际能取到的多,按pages判断就会多出一个空页,用户点下一页看到空白。改用offset + list.length < total之后,这种边界情况就自然消失了。
还有一个细节:当page超出pages时怎么办。有的实现直接返回空数组,有的实现自动纠正到最后一页。我建议返回空数组加正常状态码,同时在info里如实给出pages,让前端自己判断。自动纠正看起来贴心,实际上会让前端的分页状态和实际数据错位,用户点第 99 页却看到第 50 页的内容,体验更糟。
3.3 参数校验与边界值约定
page和pageSize来自客户端,必须校验,而且要在最外层校验,不能等到拼 SQL 的时候才发现。校验规则我一般定这么几条。
page必须是正整数,小于 1 的值直接归一到 1,而不是报错。因为用户手抖传个 0 或者负数,报错对体验没好处,归一更友好。但要注意,归一是要记日志的,如果某个客户端长期传 0,说明它的分页逻辑有问题,得推动它改。
pageSize必须设上下限。下限取 1,上限取多少要看业务和数据量。管理后台通常 20 到 50 够用,移动端列表 10 到 20 比较合适,导出场景可能到几百上千。上限的意义在于防止有人传pageSize = 1000000把数据库打穿。我见过真实案例:某个内部工具传了超大pageSize,一次查询扫了全表,把数据库连接池占满,整个服务雪崩。
默认值也要定。客户端不传pageSize时给什么?我一般给 20,并且在info里回显实际使用的pageSize,让客户端知道服务端到底用了多少。这个回显很重要,否则客户端以为是 10,服务端用了 20,渲染出来的分页器就是错的。
还有一个容易被忽略的点:total为 0 时的处理。这时候pages应该返回 0 还是 1?数学上ceil(0 / 20) = 0,但前端分页器通常至少要显示一页,传 0 可能导致组件渲染异常。我的做法是pages返回 0,但在前端做兜底,Math.max(pages, 1)。接口保持数学正确,展示逻辑交给展示层,这个分工更清晰。
3.4 类型、空值与序列化约定
类型问题是info.list里最隐蔽的雷。最常见的是大整数精度丢失。后端返回的id如果是 64 位整数,比如订单号,数值可能超过2^53 - 1,也就是 9007199254740991,JavaScript 的 Number 类型存不下,末几位会被截断。表现出来就是列表里两条不同的订单显示了同一个 ID,点进去详情对不上。解决办法是让这类字段以字符串形式序列化,在序列化层统一配置,不要指望每个字段手动转。
第二类是布尔值的形态。有些序列化框架会把true输出成1,前端拿到1做判断,if (item.enabled)结果是真,看起来没问题;但如果输出成字符串"1",if ("0")也是真,这就出大问题了。空字符串在 JS 里是假值,但字符串"0"是真值。所以布尔字段要么统一输出真正的true/false,要么在契约里写清楚是数值,前端做显式转换,别靠隐式判断。
第三类是空值。list是空的时候返回[]还是null?我强烈建议返回[]。null意味着“未知”,空数组意味着“查了,确实没有”,语义不同。更重要的是,前端拿到null直接.map会抛异常,每个调用点都得写判空,成本很高。约定成[]之后,前端可以放心遍历。同理,list里单行对象的字符串字段,空值统一给""还是null也得定死,我倾向null,因为空字符串和“真的没有”在业务上通常要区分。
数字类型的小数也要注意。金额字段如果以浮点数返回,累加会丢精度,0.1 + 0.2不等于0.3这种事在金额展示上会直接变成投诉。我的做法是金额统一用最小单位的整数返回,比如分,前端展示时再除以 100 并格式化,这样加减乘除都不会出问题。
4. 落地实操:从契约定义到前后端跑通
4.1 先把接口契约钉死
动手写代码之前,先写一份能看懂的契约。契约不需要多正式的文档,但必须包含请求参数、响应结构、字段类型、是否必填、边界说明。下面是一份典型的列表查询契约,我用注释标了关键点。
{ "code": 0, "message": "ok", "info": { "page": 1, "pageSize": 20, "total": 1001, "pages": 51, "hasNext": true, "hasPrev": false, "serverTime": "2024-05-20T10:30:00+08:00" }, "list": [ { "id": "9007199254740993", "title": "示例条目", "status": 1, "createdAt": "2024-05-19T08:12:33+08:00" } ] }几个要点值得单独强调。id用字符串,避开精度问题;status用数值并在契约里附上枚举含义,不要只给数字不解释;时间是带时区的 ISO 格式,前端解析不会歧义;info里把实际生效的page和pageSize回显出来,哪怕客户端没传;serverTime用于相对时间计算。
契约定好之后,最好能生成一个 mock,前端可以并行开发,不用等后端联调。这一步能省掉大量“你什么时候能给我接口”的沟通成本。
4.2 服务端组装info.list的过程
服务端的核心流程是四步:校验参数、查总数、查当前页数据、组装结构。很多人图省事把查总数和查数据合成一步,用结果条数当总数,这只有在数据总量小于一页的时候才对。正确做法是两条独立查询,或者用一次查询加窗口函数拿总数,具体看数据库支持程度。
下面是一段 Node 风格的伪代码,逻辑是通用的。
async function queryList(params) { // 第一步:参数归一与校验 const page = Math.max(1, parseInt(params.page, 10) || 1); const rawSize = parseInt(params.pageSize, 10) || 20; const pageSize = Math.min(Math.max(rawSize, 1), 100); // 上限 100 // 第二步:先查总数(条件与查数据保持一致) const total = await db.count('orders', { status: params.status }); // 第三步:计算偏移量,查当前页 const offset = (page - 1) * pageSize; const rows = total === 0 ? [] : await db.select('orders', { where: { status: params.status }, orderBy: 'id desc', limit: pageSize, offset }); // 第四步:组装 info 与 list return { code: 0, message: 'ok', info: { page, pageSize, total, pages: Math.ceil(total / pageSize), hasNext: offset + rows.length < total, hasPrev: page > 1, serverTime: new Date().toISOString() }, list: rows.map(toListView) }; }有两个细节值得展开。pageSize的钳制必须在服务端做,不能只在前端限制,因为请求可以被随意构造。offset的计算要注意整数溢出,虽然一般业务量到不了,但page传一个极大值乘以pageSize可能超出安全整数范围,稳妥的做法是先判断page > pages直接返回空列表,跳过查询。
toListView这一步是字段裁剪和转换,别省略。它把数据库行映射成对外契约,同时也是加脱敏逻辑的地方。手机号、身份证这类敏感字段在列表里通常要打码,放在这一层统一处理,比在每个查询里各写一遍可靠得多。
4.3 前端消费info.list的几种写法
前端拿到响应之后,第一件事是做防御性解构。不能假设info一定存在,也不能假设list一定是数组,虽然契约上规定了,但网络异常、网关拦截、后端降级都可能返回非预期结构。
function normalize(resp) { const info = resp?.info ?? {}; const list = Array.isArray(resp?.list) ? resp.list : []; return { list, page: Number(info.page) || 1, pageSize: Number(info.pageSize) || 20, total: Number(info.total) || 0, pages: Number(info.pages) || 0, hasNext: Boolean(info.hasNext) }; }这段代码看起来啰嗦,但能挡掉大量线上问题。我遇到过网关超时返回一个 HTML 错误页,前端resp.list.map直接白屏的情况,加了这层之后至少页面还能渲染出空状态。
另一个需要处理的点是翻页时的竞态。用户快速点击下一页,会连续发出多个请求,如果第 2 页的响应比第 3 页晚到,页面就会显示第 2 页的数据但分页器停在第 3 页。解决办法是给每次请求打一个递增的序号,只接受序号最新的响应,或者在发新请求时取消上一个。这个细节不做,测试时不容易发现,线上用户手快一点就复现。
分页器本身的渲染也有讲究。pages很大时不能把所有页码都渲染出来,一般只显示当前页附近的几个,加上首尾页和省略号。判断当前页是否高亮要用page而不是数组下标,否则数据为空时会错位。
4.4 大列表下的性能与内存处理
数据量到一定规模,info.list的实现方式就得变。最典型的问题是深度分页:offset越大,数据库要跳过的行越多,查询越慢。offset = 100000时,即使只取 20 条,数据库也可能要扫描十万行。我实测过一个场景,前 100 页查询都在 20 毫秒内,到第 5000 页时飙到 4 秒以上。
应对方式有几种。一是限制最大可翻页数,比如只允许翻到前 1000 页,超出提示用户用更精确的筛选条件。这个方案简单粗暴但有效,很多搜索引擎就是这么做的。二是改用游标分页,用上一页最后一条的排序键作为起点,where id < lastId order by id desc limit 20,这种方式无论翻多深都是常量时间,代价是不能随机跳页。三是做覆盖索引,把排序和筛选涉及的字段建联合索引,减少回表。
内存方面要注意的是别在服务端把全量数据查出来再切片。有些人为了省一次查询,先把所有数据查出来放内存,然后用数组的slice分页。数据量小的时候看不出问题,一旦到几万条,单次请求就能吃掉几十 MB 内存,并发一高就 OOM。分页必须在数据库层做,这是底线。
还有序列化开销。list里如果有一千条、每条二十个字段,JSON 序列化本身也要花时间。这时候字段裁剪就不只是省带宽,也是省 CPU。我做过对比,同一批数据,裁剪前序列化耗时 18 毫秒,裁剪后 6 毫秒,降了三分之二。
5. 排障实录:info.list最常见的几类翻车
5.1 问题速查表
先把高频问题汇总成表,方便对照排查。这张表里的每一条我都至少遇到过一次。
| 现象 | 大概率原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 列表条数和总数对不上 | 总数查询与数据查询条件不一致 | 对比两条 SQL 的 where 子句 | 抽公共条件构造器 |
| 最后一页多出空白页 | pages用浮点除法未取整 | 检查total / pageSize是否ceil | 统一用向上取整函数 |
| 前端遍历报错 | list返回了null | 看响应体原始 JSON | 强制返回空数组 |
| 两条数据 ID 相同 | 大整数精度丢失 | 检查 ID 是否超过2^53-1 | 序列化为字符串 |
| 翻页后数据重复 | 排序字段不唯一 | 检查 order by 是否有并列值 | 追加唯一键做次级排序 |
| 布尔字段判断失效 | 序列化成"0"/"1"字符串 | 看原始响应类型 | 统一输出真布尔值 |
| 第 N 页加载极慢 | 深度分页 offset 过大 | 看执行计划 | 限页或改游标 |
5.2 空数组与null的拉锯战
这是发生频率最高、修复成本最低、但偏偏最多人犯的问题。某个接口在无数据时返回了null,前端某个组件忘了判空,直接白屏。修的时候只改那一个组件,等下一个页面又用同一个接口,又白屏一次。正确的做法是在接口层一次性解决,服务端保证list永远是数组。
服务端改造起来很简单,查询结果为空的时候用空数组初始化,而不是默认null。但要注意序列化框架的行为,有些框架会配置成忽略空集合,导致字段直接不出现。这种情况比返回null更隐蔽,前端取不到字段得到undefined,同样会炸。所以除了保证返回空数组,还要确认序列化配置没有把这个字段吃掉。
前端这边也应该做一层兜底,不能完全依赖契约。契约是给人看的,代码是给机器跑的,两者之间总有缝隙。加上Array.isArray判断之后再操作,成本很低,收益很高。
5.3 分页错位与总数对不上
这类问题的根源通常是“总数”和“数据”用了不同的查询条件。举个典型例子:数据查询加了权限过滤,只查当前用户可见的记录,但总数查询忘了加,用的是全表计数。结果就是total显示 100,实际只能翻出 30 条,翻到第 4 页全是空白。
排查方法很直接,把两条查询的 SQL 打印出来逐字对比。我一般会在代码里把条件构造抽成一个函数,两条查询共用,从结构上杜绝不一致。这个重构看起来麻烦,但做过一次之后,同类问题基本绝迹。
另一种错位是排序不稳定导致的。按createTime倒序排,如果多条记录的createTime完全相同,数据库返回的顺序是不确定的,翻页时同一条数据可能在第 1 页出现,也在第 2 页出现。解决办法是在排序字段后面追加一个唯一字段,比如order by createTime desc, id desc,保证排序是全序的。
5.4 大整数精度与类型漂移
这个问题在前面提过,但值得单独展开,因为它的表现形式特别有迷惑性。接口返回的 JSON 里id明明是9007199254740993,前端打印出来变成9007199254740992,差 1。单看一条数据根本发现不了,只有对比之后才会察觉。等发现的时候,可能已经有用户反馈“点进去是别人的订单”了。
排查手段是直接在浏览器控制台或者抓包工具里看原始响应文本,而不是看打印出来的对象。原始文本里是准确的,对象里是丢失精度的,一对比就清楚。修复方式是在序列化层对 Long 类型统一转字符串,用一个全局配置解决,不要每个字段手动.toString(),容易漏。
类型漂移还体现在日期上。有的接口返回时间戳数字,有的返回格式化字符串,同一个项目里两种混用。前端做时间格式化时得写两套逻辑,维护成本很高。定契约的时候就要统一,我倾向于字符串加时区,可读性和兼容性都更好。
5.5 我给自己定的几条硬规矩
做久了之后,我给自己总结了几条不做就会出事的规矩,写在这里供参考。
第一条,list永远不返回null,info永远不为空对象。这两条保证了前端取值路径永远可用,不需要到处判空。
第二条,page和pageSize必须在响应里回显实际生效值。客户端传了什么和服务端用了什么是两回事,不回显就必然出现前端分页器算错的情况。
第三条,任何涉及金额、ID、时间戳的字段,在契约里必须标注精度要求和类型。标注不是形式主义,它是给下一个接手的人看的,也是给自己三个月后看的。
第四条,总数查询和数据查询共用条件构造器。这条能消灭掉大概一半的分页相关 bug。
第五条,翻页请求必须做竞态处理。宁可多做一步,也不要让用户看到数据和页码对不上的画面。
第六条,上线前用真实数据量压一次。几百条数据和几十万条数据是完全不同的两个系统,很多问题只有量上去了才会暴露。
这几条不复杂,难的是每次都坚持。我自己也有偷懒的时候,图快跳过回显、跳过竞态处理,结果就是在某个深夜被叫起来排查一个“翻页数据乱跳”的问题,最后发现就是没做序号校验。那之后我就把这些检查项放进了提交前的清单里,一条一条过,比事后救火省事得多。
6. 关于info.list我还想补充的几点
写到这里,结构、字段、落地、排障都覆盖了,最后补几个零散但实用的点。
接口版本的演进。info是可以扩展的,但扩展要有节制。我见过一个info里塞了三十多个字段的接口,其中一半是历史遗留、早已没人用。新增字段的时候顺手清理废弃字段,比一直往后堆要健康。清理前先确认没有调用方在用,可以通过日志统计字段的访问情况。
统一响应包装和info.list的关系。很多团队会在info.list外面再包一层data,形成data.info.list。这层包装如果是为了统一加签、统一埋点,那有价值;如果只是历史习惯,建议评估一下是否真的需要。层级越深,取值越容易写错,尤其是手写路径的时候。
文档和代码的一致性。契约文档写完就放在那里不动,代码改了三版文档还是第一版,这种情况太常见了。我的做法是把契约做成可执行的,用 schema 校验响应结构,测试跑的时候自动比对,不一致就报错。这样文档想落后都难。
给新人的一句话。info.list这种结构,看懂了觉得平平无奇,但真正把它每个字段的语义、边界、异常情况都想清楚的人并不多。多数线上事故不是因为用了多复杂的技术,而是因为这些看起来太简单、以至于没人愿意停下来想一想的细节。我自己踩过的坑,十有八九都在这类“太简单”的地方。