分页不是链接列表:URL 形态、爬虫深度、COUNT 与深 OFFSET 的六处代价

分页大概是内容站里最不起眼的一块代码。它在一个循环里吐三十条链接,样式上是一排数字按钮,测试的时候点两下能翻页就过去了。但我这两年里遇到的收录问题、爬虫预算问题、服务器被拖慢的问题,至少有三分之一最后追到了分页上:有的分页让爬虫掉进了无限的参数组合里,有的分页把同一批文章在二十个 URL 上各输出一遍,有的分页页每一页都去调用一次 COUNT(*),还有的分页干脆让第 2 页开始的所有页面都变成了「没有入口的死路」。

这篇文章不写模板代码,写的是判定顺序:先搞清楚分页的 URL 形态怎么影响收录,再看爬虫实际是怎么消费分页的,然后是索引与 TTFB 上的代价,最后是后台列表、站内搜索、评论分页这几个容易出事的角落。每一节都附上我自己站上跑过的数字,供你对照。

一、先把分页的 URL 形态定下来,它决定了收录的上限

Typecho 默认是 /page/2/ 这种路径式分页,WordPress 是 /page/2/ 或 ?paged=2,很多自建站是 ?page=2、?p=2、?start=30。三种形态在爬虫眼里是完全不同的东西。

路径式分页(/page/2/)算独立的静态路径,可以被收录,可以被 sitemap 收录,也有自己的一份权重传递。参数式分页(?page=2)属于「同一路径的不同参数」,主流搜索引擎会做参数折叠,但不折叠的时候问题就来了:如果你的列表页除了 page 还有排序参数(&sort=hot)、筛选参数(&tag=nginx)、时间参数(&from=2026-01),那么地址空间是 page × sort × tag × from 的笛卡尔积。我给你算个数:30 页 × 3 种排序 × 40 个标签 × 12 个月,等于 43200 个理论上可以访问的 URL,而里面真正有内容的可能只有几百个。

最直接的办法是分清「该被收录的分页」和「不该被收录的分页」:

# 该被收录:栏目/分类/标签的第一页与后续页(内容不重复的部分)
/category/tech/           → index, follow
/category/tech/page/2/    → index, follow

# 不该被收录:排序、筛选、搜索、时间归档的组合
/list/?sort=hot           → noindex, follow   (或直接 301 回默认排序)
/list/?page=2&sort=hot    → noindex, follow
/search?q=xxx&page=3      → noindex, follow

判断标准其实很简单:这个 URL 是不是「用户会想直接分享给我」的地址。分类第 3 页,有人会发到群里;热门排序第 3 页,没人会发。凡是没人会主动分享的,都不该让爬虫把预算花在上面。

还有一类分页是必须挡掉的:搜索结果页的分页。搜索页本身就是无限组合(?q= 后面的每个词都能生成一个新 URL),如果搜索结果的第 2、3 页再允许抓取,那你的站会给爬虫提供无穷多个入口。这一条我在 2135 篇里提过抓取预算,这里补一句操作层面的话:搜索页统一 noindex, follow,并且要确认真的是「follow」而不是「nofollow」。nofollow 会让搜索页里链接到的文章也拿不到信号,这是一个很常见的反向错误。

二、爬虫是怎么消费分页的:不是「一路翻到底」

很多人以为蜘蛛会顺着「下一页」一直翻到最后一页,所以担心第 50 页没人点到。实际行为不是这样。我用访问日志观察了三个月的 Googlebot 和 Bingbot 抓取行为,结论是这样的:

# 从访问日志里看爬虫的分页消费深度(按 deep 分层统计)
grep -E 'category/tech/page/' /var/log/nginx/access.log \
  | grep -Ei 'googlebot|bingbot' \
  | awk '{print $7}' \
  | sed -E 's#.*/page/([0-9]+)/.*#\1#' \
  | sort -n | uniq -c | sort -k2 -n | head -20

在一个日更 18 篇、大概有 120 个分类页的站上,我看到的分布是:第 1 页的抓取次数是第 2 页的 4 到 6 倍,第 2 到第 5 页是主力,第 6 页以后骤降到每天个位数,第 15 页以后基本没人抓。这不是爬虫偷懒,而是它已经通过 sitemap 和文章页拿到了全部文章的地址,分类页对它来说只是「验证入口还有效」,不是「发现新文章的唯一路径」。

由此推导出三条实践结论。第一,不要把 sitemap 只交给首页和分页去表达,文章列表一定要有独立的 sitemap(我站上是按分类拆,每 5000 条一片),分页对收录的贡献是辅助性的。第二,分页深度不是越长越好。如果你的分类有 200 页,前 5 页之后的内容几乎不会因为分页被收录,那就老老实实把每页条数从 10 提高到 30,把页数压到 70 页以内,反而更有利于第 2、3 页的权重集中。第三,「下一页」按钮必须是可抓取的真链接,不能是 JavaScript 点击事件。这一条看起来废话,但我在三个模板里见过 <a href="javascript:;"> 的写法,等于告诉爬虫这条路断了。

顺带说一个反直觉的发现:给分页加上 rel="next" 和 rel="prev",在今天的 Google 上已经没有明显的收录收益了(Google 2019 年就宣布不再使用这两个提示),但 Bing 和部分国内引擎仍然会参考。成本是几行模板代码,我还是留着,只是不再把它当成「收录优化」的卖点。

三、分页的第一个性能陷阱:每一页都去 COUNT(*)

分页最常见的实现是「总条数 ÷ 每页条数 = 总页数」,而这个总条数往往是一个 SELECT COUNT(*)。在一张十万行的文章表上,这条 SQL 单独跑 20 毫秒,看起来无所谓;但它在每一次分页请求里都要跑一次,而且和主查询是串行的。当有人(或者爬虫)连着翻 10 页,就是 10 次 COUNT 加上 10 次带 OFFSET 的主查询。

-- 慢日志里最典型的一对:COUNT 与 深 OFFSET
# Query_time: 0.412  Lock_time: 0.000  Rows_sent: 1
SELECT COUNT(*) FROM typecho_contents WHERE type='post' AND status='publish';

# Query_time: 1.180  Lock_time: 0.000  Rows_sent: 30
SELECT cid,title,created FROM typecho_contents
WHERE type='post' AND status='publish'
ORDER BY created DESC LIMIT 30 OFFSET 9000;

两条 SQL 各有一个问题。COUNT 那条在 InnoDB 上没有缓存,每次都走一遍二级索引,十万行大概 20~40 毫秒,可以接受但没必要每次都做。深 OFFSET 那条才是真正的杀手:LIMIT 30 OFFSET 9000 意味着 MySQL 要先把 9030 行读出来、再丢掉前 9000 行,行数越多越慢,而且这个成本随页码线性增长。

两个改法,按代价从低到高。第一个是把 COUNT 的结果缓存住,比如按分类缓存 5 分钟:

-- 用一张小表当计数缓存,定时任务每 5 分钟刷一次
CREATE TABLE page_counts (
  cache_key VARCHAR(120) PRIMARY KEY,
  total INT UNSIGNED NOT NULL,
  updated_at INT UNSIGNED NOT NULL
);
-- 读的时候先查缓存表,没有就回源并写回

第二个是把深分页从 OFFSET 改成游标(keyset pagination):记住上一页最后一条的 created 和 cid,下一页用 WHERE (created, cid) < (?, ?)。这个改法对「下一页」按钮完全成立,对「直接跳到第 300 页」不成立,所以有一种混合策略:前 20 页用 OFFSET(数据量小,成本可接受),第 20 页以后的「跳转」只允许跳到最近的几页,或者干脆不提供数字跳转。

实测数据:我站上的一个栏目有 4000 篇文章、每页 30 条共 134 页。改之前翻到最后一页的 TTFB 是 1.9 秒,改之后(前 20 页 OFFSET + 之后 keyset)最慢的一页 TTFB 是 220 毫秒。这个差距不是配置能调出来的,是查询形态决定的。

四、深分页为什么会被爬虫「意外地」大量访问

有一次我发现 MySQL 的 QPS 在凌晨三点有一个规律的尖峰,查下来是 Googlebot 在翻一个标签的深分页,一直翻到第 180 页。原因很蠢:那个标签下有 5400 篇文章,模板里把分页按钮全部输出了,180 个链接全在页面上,爬虫顺着就翻。而且那个页面的第 3 页之后,每一页都是「标题 + 摘要」,和文章页高度重复。

处理顺序应该是这样:

  1. 先确认分页是不是被 sitemap 收录了。如果是,先把它从 sitemap 里去掉,这一条往往就能把抓取量砍掉一半。
  2. 再看分页模板里有没有输出全部页码。改成「首尾 + 当前页前后两页 + 省略号」,把页面上可见的分页链接从 180 个压到 9 个。
  3. 然后判断内容重复度。如果分页页和文章页只是列表与详情的关系,那没问题;如果分页页里塞了和文章页一样的整段正文(很多模板的「摘要」其实是前 200 字全文),那第 3 页以后的相似度会高到被判定为重复,这时候应该在分页页上加 noindex,或者只保留前 3 页可索引。
  4. 最后才是限流。把爬虫对分页路径的访问做速率限制,但这一步是兜底,不是解决方案。

顺序很重要。我见过有人第一步就去 Nginx 里给爬虫限速,结果 Googlebot 抓取速度降下来,文章页的收录也跟着变慢,问题反而更难看了。

五、canonical、noindex 与 rel 的正确组合,别互相打架

分页最容易配错的是信号冲突。举三个我实际见过的错误配置:

# 错误 1:分页页 canonical 指回第 1 页
# 后果:第 2 页的内容永远不被单独收录,且爬虫认为第 2 页是重复页
<link rel="canonical" href="https://example.com/category/tech/">

# 错误 2:分页页 noindex 但 canonical 指向自己
# 后果:noindex 会赢,canonical 白写,还算「自我矛盾」
<meta name="robots" content="noindex, follow">
<link rel="canonical" href="https://example.com/category/tech/page/2/">

# 错误 3:分页页 noindex + rel=next 指向第 3 页
# 后果:告诉爬虫「别收录我」又「继续往下翻」,可以,但要清楚这是有意的
<meta name="robots" content="noindex, follow">
<link rel="next" href="/category/tech/page/3/">

我的做法是分两类处理。对「分类、标签的前 N 页」:自指 canonical,允许索引,保留 rel=next/prev,同时进 sitemap。对「排序、筛选、搜索、深分页」:noindex, follow,不给 canonical(或者给了也只指自己),不进 sitemap,但保留可抓取的链接让爬虫能顺着走到文章页。

有一个例外要记住:noindex 和 canonical 同时出现时,如果 canonical 指向别的 URL,两个信号会被算法判定为冲突,这时候 Google 大概率选择 noindex,但也可能因为 canonical 而把信号并给目标页,结果不稳定。所以最稳的是「要 noindex 就别写跨页 canonical」,一句话记:一个页面只发一种主要信号。

六、后台列表分页:真正把服务器拖垮的地方

前台分页再差,访客是有限的;后台列表分页一旦做错,就是自己给自己制造慢查询。后台列表通常比前台复杂得多:要多表 JOIN(文章表 + 分类表 + 用户表 + 评论计数),要支持任意字段排序(?order=comment_count),要支持多条件筛选,而且常常没有索引。

-- 后台列表最典型的一条:ORDER BY 一个没有索引的派生列
SELECT c.cid, c.title, u.name, COUNT(cm.coid) AS cnt
FROM typecho_contents c
LEFT JOIN typecho_users u ON u.uid = c.authorId
LEFT JOIN typecho_comments cm ON cm.cid = c.cid
WHERE c.type = 'post'
GROUP BY c.cid
ORDER BY cnt DESC
LIMIT 20 OFFSET 200;

这条 SQL 在 4000 篇文章、2 万条评论的表上跑了 6 秒。改法不是加索引,而是把「评论数」冗余成一个字段,在评论写入时用触发器或应用层维护,然后给这个字段加索引。冗余计数字段这件事很多人不愿意做,觉得「反范式」,但在列表排序这个场景下,它的收益远大于一致性上的那点风险,只要在评论删除、审核状态变更这两个点上补上更新逻辑就够了。

另一个常见的坑是后台列表的默认排序用 ORDER BY created DESC,但 created 上和 status、type 的组合没有联合索引,导致每次都要 filesort。给 (type, status, created) 建一个联合索引,注意列顺序:等值条件在前,范围/排序在后。

七、站内搜索的分页,是最容易被忽略的重复内容来源

站内搜索结果的分页,是「同一批内容在多个 URL 上重复输出」的典型。用户搜索「nginx」,第 1 页有 10 条,第 2 页有 10 条,第 3 页又是 10 条。如果这几个页面允许索引,那么站上就多出了几十个内容重复度极高的 URL。

更糟的是搜索词是无限的。有人用工具枚举了 5000 个搜索词,你的站就多出 5000 × 页数 个可访问 URL。我处理过的站里,有一条是被这种 URL 拖慢了整站:MySQL 对每个搜索请求都做了一次 LIKE '%nginx%' 全表扫描,每秒几十个请求就把 CPU 打满了。

处理方案按优先级:搜索页统一 noindex, follow(这是第一位的,也是成本最低的);搜索接口加限流(按 IP 每分钟 30 次);搜索只走索引不走全表(要么用全文索引,要么限制只搜标题);搜索结果的 URL 不进 sitemap、不出现在任何可抓取的链接里(搜索页的链接用表单 GET 提交,或者用 JS 提交,不要用真链接)。

八、分页与渲染:别用 JS 拼出来的列表

有一类站的前台分页是 AJAX 加载的:点击第 2 页,JS 去请求 /api/list?page=2,把 JSON 拼成 HTML 插进 DOM。用户体验很好,但对爬虫是个死胡同:蜘蛛拿到的第 1 页源码里只有第一页的 10 条,第 2 页以后的链接不在 HTML 里。

如果一定要用 AJAX,最低成本的补救是「渐进增强」:服务器端照样输出完整的第 2、3 页链接(只是默认隐藏),JS 接管后拦截点击。这样蜘蛛看到的是真链接,用户看到的是无刷新。另外,AJAX 接口返回的内容不要和页面 HTML 里的内容不一致,否则容易触发「渲染后内容与原始 HTML 不符」的判定。

九、一份可以照着做的分页核对清单

  • 分页 URL 是否统一?同一页是否存在 ?page=2 和 /page/2/ 两种入口,如果有,用 301 收敛到一种。
  • 分页页是否有自指 canonical?不要指回第 1 页。
  • 页面上可见的分页链接数量是否超过 15 个?超过就改成「首尾 + 当前 ±2」。
  • 「下一页」是真链接还是 javascript:;?
  • 排序/筛选/搜索的分页是否 noindex, follow?
  • 深分页的 SQL 是否还在用大 OFFSET?有没有 keyset 或页码上限?
  • 每条分页请求是否都跑一次 COUNT(*)?有没有缓存?
  • 后台列表的排序字段是否有索引?有没有派生列排序?
  • 分页页的摘要是否是整段正文?和文章页的相似度是否超过阈值?
  • 分页是否进了 sitemap?深分页进 sitemap 是最常见的抓取预算浪费。

最后说个判断标准。如果一个月之后,你在日志里看到爬虫的第 3 页以后抓取量明显下降、文章页抓取量上升、MySQL 的 QPS 曲线变平,那就是做对了。分页不是要「做得更漂亮」,而是要「让重要的路更短」。

Last modification:November 12th, 2026 at 01:23 pm

Leave a Comment