
字节笔记本
2026年10月7日 · 约 8 分钟读完
FastAPI 列表分页:总数和偏移一起改
列表接口原来一次返回全部申请记录,前端类型是 Application[]。数据一多,这一刀就该改成翻页。服务端要同时给出这一页的记录、总数和页码,前端不能再把整个响应当成数组来渲染。只改 SQL 的 LIMIT,类型对不上,页面会在读取字段时直接空掉。

响应不再是一个数组
分页响应单独做一个模型,不要往原来的申请模型上塞页码字段。一页里有这些数:
class PaginatedResponse(BaseModel):
items: list[Application]
total: int
page: int
page_size: int
total_pages: intitems 是这一页的记录。total 是符合条件的全部行数,不是这一页的长度。total_pages 用整数除法向上取整:(total + page_size - 1) // page_size。总数是 0 时,页数也是 0,不要再除一次变成异常。
查询参数用 Query 收。page 从 1 开始,page_size 给一个默认值,并加上限,避免有人传一个极大的页长把数据库打满。非法页码直接 422,不要静默改成第一页还让调用方以为自己要的那页是空的。
先数总数,再按偏移取一页
偏移是 (page - 1) * page_size。第一页偏移是 0。过滤条件要同时用在计数和取数上,否则总数和列表对的不是同一批行。
SELECT * FROM applications
WHERE ...
ORDER BY id DESC
LIMIT %s OFFSET %s计数是一条 SELECT COUNT(*),取数是第二条带 LIMIT 和 OFFSET 的查询。参数用占位符拼进 params,过滤值和分页值都走参数,不要把页码格式化进 SQL 字符串。排序固定 id DESC,翻页才稳定。没有 ORDER BY 时,两次请求的同一页可能不是同一批行。
样本连的是 MySQL,游标用字典,所以 cursor.fetchone()["total"] 能直接拿到数字。连接信息从环境变量读,主机、库名、端口都有本地默认值。密码也写了本地默认。这个默认值不该进入仓库或镜像。缺了密码就启动失败,比用一个写死的口令连上库更安全。
返回体是字典,键和 PaginatedResponse 对齐:items、total、page、page_size、total_pages。FastAPI 会按响应模型校验。少一个键,文档和运行时都会露出来。
跨域在样本里只放了本地前端的源。分页接口和旧接口挂在同一个应用上,中间件不用为分页再加一条。前端如果换了端口,改的是允许的源,不是分页公式。
前端类型要跟着变
原来的客户端是:
export const getApplications = async () => {
const { data } = await api.get<Application[]>("/applications")
return data
}改完以后 data 不再是数组。表格要渲染 data.items,分页器要读 total 和 page_size。谁还把 data.map 当成列表,谁就会在运行时发现对象没有 map。请求要带上 page 和 page_size。基地址在样本里指向本机的 8000 端口,生产环境换成配置,不要把这个地址写死在组件里。
空页是合法结果:items 为空数组,total 为 0。这和接口报错不同。界面上的空状态看 total,不要看 HTTP 状态码是不是 200。200 加上空数组,表示这一页没有行。
页码、总数、列表是一份契约
分页不是只在 SQL 末尾加 LIMIT。总数用同一套过滤条件先算出来,偏移用页码和页长推出,响应里同时给记录和页数。前端把返回值从数组改成带 items 的对象。这三处有一处还是旧形状,翻页按钮和表格就会各说各的。

过滤条件和两页之间的空档
where 子句一旦加上状态或类型,计数 SQL 和列表 SQL 必须共用同一段 where 和同一组参数。只在列表上过滤,总数会大于用户能翻到的行,最后一页点进去是空的,分页器却还显示有下一页。只在计数上过滤,则会出现总数很小、列表却很长。
偏移翻页有一个已知的代价:页码很深时,数据库仍要跳过前面的所有行。样本用的是 LIMIT 加 OFFSET,对管理后台的前几十页够用。不要在第一版就改成游标,除非已经测出深页变慢。真要改,契约里的 page 就不再够用,前端要传上一页最后一条的 id。那是另一次接口变更,不要和这次的 items、total 混在一个提交里。
排序用 id 降序,新建的记录出现在第一页。如果同时允许改更新时间、又按更新时间排序,正在被编辑的行会在两页之间跳动。后台列表先固定主键排序,等产品明确要求“最近更新”再换字段,并且把这个字段做成索引。
page_size 的上限要写在 Query 的约束里,而不是写在注释里。有人会把页长传成 0。除法算总页数时,页长为 0 会出问题。默认 20、最大 100 这一类边界,用参数声明拒绝,而不是在函数中间 if 一下再默默改掉。调用方收到 422,才知道自己传错了。
CORS 样本只允许一个本地源,并且允许携带凭据。分页接口如果以后要给别的前端用,源列表从环境变量读。不要为了省事改成星号,又同时允许凭据,浏览器会直接拒绝。这和分页公式无关,但会让人误以为接口没部署。
前端除了改类型,还要改加载状态。请求第 2 页时不要清空第 1 页的表格,除非设计就是整表替换。替换的话,用 items 整份替换。追加的话,那是无限滚动,不是这个响应模型的页码语义。两套交互不要共用一个“把 data 拼进数组”的函数。 接口文档里把旧的数组响应标成废弃,给一段迁移时间,或者直接换路径。同一个路径既可能返回数组又可能返回对象,生成的客户端会随机用错类型。样本是直接改了列表接口。前端必须同一次发布。只发后端时,旧页面会在 data.map 上抛错,看起来像分页算错,其实是形状变了。
总数查询在没有索引的过滤字段上会先成为慢的那一条。列表有 LIMIT,计数没有。上线后如果只慢在第一页,先看计数语句,不要先怪 OFFSET。OFFSET 的代价在深页才明显。
返回的 page 应回显请求的页码,而不是服务器悄悄改过的值。用户要第 9 页、总共 3 页时,可以返回空 items 且 page 仍是 9,也可以返回 400。不要返回第 3 页的数据却把 page 写成 9。调用方无法区分自己在看哪一页。 联调时先用页长 2 请求三次,看 items 的 id 是否按降序衔接、有没有重复。重复说明偏移和页长不是同一套,或者排序不稳定。总数不变的前提下,三页的 items 长度除了最后一页都应该等于页长。最后一页可以更短。这个检查不依赖前端,用一次接口调用就能做完。
字典游标让计数行能用名字取值。如果换回默认元组游标,fetchone()["total"] 会失败。那是驱动用法变了,不是总数算错。样本选择字典,是为了响应字段和列名对齐。换驱动时这一层要一起看。
页长和页码都从 1 和正整数理解。页码传 0 时不要让偏移变成负数。负数偏移会让数据库报错或扫到意外的行。参数约束把 page 限制为大于等于 1,偏移公式才成立。



