多级栏目分页需规避preload爆炸、count不准和游标落地难问题:树形结构应预加载全量栏目后内存构树;count须用独立db会话防条件污染;游标需组合(created_at, id)并建联合索引;大偏移量应转向条件筛选或自动降级游标。

多级栏目分页不是加个 Limit 和 Offset 就能跑通的事——栏目树结构 + 分页 + 关联数据加载,三者叠加后,Preload 会爆炸,Count 会不准,游标分页还难落地。
为什么 Preload 在多级栏目分页里必出问题
栏目表通常有 parent_id 字段,查某一级栏目下的文章时,你可能这样写:
db.Preload("Category").Where("category_id IN ?", categoryIDs).Offset(...).Limit(...).Find(&articles)
这会导致 GORM 先查出 20 篇文章,再为每篇发起一次 SELECT * FROM categories WHERE id IN (?,?,...) ——但 IN 列表只含这 20 篇的 category_id,漏掉父级/子级栏目信息。更糟的是,如果文章还 Preload("Tags"),GORM 会为每个 article 单独查 tags,N+1 变成 N×M。
- 别在分页主查询里用
Preload加载树形关联(如 Category.Parent、Category.Children) - 树形结构应提前查好:用
db.Where("id IN ?", allCategoryIDs).Find(&categories)一次性拉全,再在内存里构树 - 文章列表只
Select("id, title, category_id, ..."),避免把栏目字段冗余进结果集
Count 查询如何不被栏目条件污染
假设你要查「栏目 A 及其所有子栏目下的文章总数」,SQL 本质是先查出所有子栏目 ID(递归 CTE 或多次 JOIN),再 COUNT 文章。但 GORM 没原生递归支持,硬写 Count 容易漏条件:
错例:db.Joins("JOIN categories ON articles.category_id = categories.id").Where("categories.path LIKE ?", "A/%").Count(&total) —— 这个链式调用若之前用了 Limit,Count 也会带上 Limit,返回永远 ≤20。
- 必须用独立 DB 实例:
db.Session(&gorm.Session{NewDB: true}).Model(&Article{}).Joins(...).Where(...).Count(&total) - 路径匹配推荐用前缀索引:MySQL 中
path字段建INDEX(path(191)),避免LIKE "A/%"全表扫 - 如果栏目层级深、子栏目多,COUNT 建议走缓存(如 Redis 记录每个栏目的文章数,增删文章时原子更新)
游标分页在栏目场景下怎么设 last_id
栏目内容常按 created_at DESC, id DESC 排序,但用户翻页时,如果某篇文章被编辑导致 created_at 更新,它可能在下一页重复出现。单纯用 id 当游标又无法保证时间序。
安全做法是组合字段做游标:
WHERE (created_at, id) <p>前端传 <code>last_created_at</code> 和 <code>last_id</code> 两个值,后端拼成元组比较。注意:</p>
- MySQL 5.7+、PostgreSQL 支持行构造器比较,SQLite 需拆成
created_at -
created_at字段必须有索引,且和id组成联合索引:INDEX(created_at, id)(降序需显式声明INDEX(created_at DESC, id DESC)) - 栏目筛选条件(如
category_id IN (?))必须写在 WHERE 子句最外层,不能塞进游标逻辑里
大偏移量下栏目分页的兜底策略
后台 CMS 常要跳转到第 500 页(比如运营导出数据),这时 OFFSET 9999 在百万级文章表上基本卡死。游标分页又不支持随机跳页。
务实解法是:允许跳页,但强制走条件过滤。
- 禁用纯数字页码输入,改用「按日期筛选 + 分页」:/articles?date_from=2026-01-01&date_to=2026-06-30&page=1
- 对栏目维度加聚合统计:首页展示「栏目 A:1240 篇,最新更新 2 小时前」,点进去才查具体内容
- 超过
OFFSET 5000时,自动 fallback 到游标模式,并返回next_cursor而非next_page,前端切换无限滚动
栏目树本身别参与分页计算——它的层级关系应单独缓存(如 Redis JSON),查文章时只拿扁平的 category_ids 列表去过滤,否则每次分页都触发树遍历,IO 直接拉满。











