上个月有个读者的站彻底掉了,不是慢,是没流量。他给我看 Search Console,整片红:抓取异常里 4xx 数量从每天 20 条涨到了 9000 多条。他自己点开站内链接都正常,用浏览器访问随便哪个页面都能打开,所以一开始完全不信是死链问题。
最后查出来的原因是他半年前把 URL 结构从 /archives/{cid}/ 改成了 /{cid}.html,老链接没有做跳转,而是直接返回了首页内容——HTTP 状态码 200。搜索引擎看到的是「同一个首页、九千个不同地址」,于是把大量重复内容判成低质量,整站权重被拖垮。
这件事让我意识到,死链处理是个人站最容易忽略、又最容易被判罚的一块。这篇文章把我自己的死链排查、404 页面设计和恢复流程写下来。
先把三种「打不开」分开
很多站长把所有「打不开」都叫死链,但搜索引擎对这三种情况的处理完全不同,修法也完全不同。分开是排查的第一步。
第一种:真死链,服务器明确说不存在
返回 404 Not Found 或 410 Gone。这是最干净的状态。爬虫看到 404 会把这个 URL 从索引里移出去,站点不会因此受罚——404 本身不是错误,是正常的互联网状态。
410 比 404 更明确一点:它表示「这个资源曾经存在,现在永久删除了,别再来」。Google 的文档里说过 410 的处理速度可能比 404 快一些,因为不需要再观察。但实践中差别不大,我一般只在明确删除了大批内容时用 410。
第二种:软 404,返回 200 但内容说「没有」
这是最危险的。服务器返回 200 OK,页面上写着「文章不存在」或者干脆重定向到首页却保留 200。爬虫会把它当成一个正常的、内容很少的页面收录进去。
为什么危险?因为它制造了三种伤害:一是浪费抓取预算,一个软 404 要花掉跟正常页面一样的抓取资源;二是制造重复内容(如果我有一万条老链接都返回主页内容,那就是一万个重复页面);三是让「已发现未编入索引」的计数暴涨,你去看 GSC 会看到一大堆深链接卡在那里不动。
判断软 404 有个很实用的技巧:看字节数和内容特征。同一批「不存在」的 URL 返回的内容长度往往完全一致(都是那个默认的「暂无内容」模板),而且多个不同 URL 的响应体完全相同。用 curl 抓几个对比 md5 就能确认。
第三种:重定向链,一跳变五跳
每多一跳重定向,抓取预算就多消耗一份,而且链路上任何一跳出问题,整条链断掉。我见过最夸张的一条:http:// → https:// → http://www. → https://www. → https://www./archives/123/ → https://www./123.html,五次跳转。
规范的站应该做到任一 URL 最多一跳到达最终地址。这需要把 http→https、裸域→www、旧结构→新结构 三条规则合并成一条直接跳转到最终地址,而不是串成链。
怎么把死链批量找出来
靠人工点击这辈子都找不完。我用三套办法,覆盖不同的来源。
办法一:翻访问日志里的 404
这是最快、最真实的一份清单,因为它是真实访客和爬虫刚刚踩过的坑:
awk '$9 == 404 {print $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -50注意两个细节:一是 $9 的位置取决于你的 log_format,combined 格式里状态码是第 9 个字段,但如果自己加过字段就变了,先用 head -1 看一眼真实的一行再决定;二是要连轮转过的 access.log.1 一起查,只看当天会漏掉昨天的那批。
如果站上装了 CDN,源站日志里看到的 404 会少很多,因为大部分请求被 CDN 挡了。这时候要么去 CDN 控制台看日志,要么在 CDN 上开一个「未命中回源」的采样。
办法二:抓页面里的每一个链接,逐个验证
日志只覆盖「有人访问过」的链接,页面里那些从来没人点的死链抓不到。所以还需要主动扫:把站内所有文章页抓一遍,抽出所有 <a href>,去重后逐个请求。
import re, urllib.request, urllib.parse
from concurrent.futures import ThreadPoolExecutor
seen, broken = set(), []
def check(url):
try:
req = urllib.request.Request(url, method='HEAD',
headers={'User-Agent': 'Mozilla/5.0 (compatible; LinkCheck/1.0)'})
code = urllib.request.urlopen(req, timeout=10).status
if code >= 400:
broken.append((code, url))
except urllib.error.HTTPError as e:
broken.append((e.code, url))
except Exception as e:
broken.append(('ERR', url))
with ThreadPoolExecutor(max_workers=8) as pool:
pool.map(check, sorted(seen))千万不要用 -c 50 这种高并发去扫自己的站,那跟 DDoS 没区别,云厂商的 WAF 会直接把你 IP 封掉,某些情况下还会触发安全告警。八个并发、每请求间隔几百毫秒,一万个链接大概跑十几分钟,完全可以接受。
另外注意 HEAD 请求的问题:有些老站或者带 WAF 的站对 HEAD 返回 405,看起来像死链其实是误报。我的做法是 HEAD 拿到 4xx 之后再用 GET 复核一次,只有 GET 也失败才算真死链。
办法三:Search Console 的 404 报告
GSC 的「网页」报告里有一个「未找到 (404)」分类,列出的是Google 曾经抓取过、现在 404 的 URL。这份清单的价值在于:它包含外部链接指向的地址,也就是别人的文章里链接到你的、但现在打不开的页面。这类链接是外链资产,处理优先级最高——因为它本来能带来流量和权重。
这份报告导出有上限(一次 1000 行),站大了要分几次导。我一般每月导一次,跟本地日志比对,看有没有新的批次出现。
404 页面本身该怎么设计
找到死链只是第一步,404 页面怎么呈现,其实直接影响到损失多少。
必须返回真正的 404 状态码
这是最容易出错的地方。Nginx 里如果你写了:
location / {
try_files $uri $uri/ /index.php;
}那么一个不存在的 /abc.html 会走到 /index.php,PHP 返回 200,页面上显示「文章不存在」——这就是软 404。正确的写法是让 PHP 在判断文章不存在时显式发 header('HTTP/1.1 404 Not Found');,或者对静态站直接写:
location / {
try_files $uri $uri/ =404;
}
error_page 404 /404.html;注意 error_page 404 /404.html 这一行,/404.html 本身必须能访问,否则会形成「404 的 404」,Nginx 会返回一个内部错误。而且这一行不会改变返回的状态码,仍然是 404,这是对的。
页面上要放什么
一个好的 404 页面应该做三件事,顺序很重要:
- 明确告诉用户「找不到」,不要用「页面正在建设中」这种含糊话术,用户会以为是网站坏了。
- 给出搜索框和最新文章列表——大部分踩到 404 的人其实想找的是某个主题,给他一个入口比让他返回首页更有用。
- 给出首页和分类页链接,让用户有台阶下。
但有一件事绝对不能做:不要在 404 页面上堆几百个内链,或者把全站文章列表都铺出来。有些 SEO 教程说「404 页面要充分利用权重」,这是过时的思路。大量的、无关的内链会稀释页面权重,而且会让爬虫在 404 页上继续抓取,浪费预算。
noindex 与 nofollow
404 页面本身返回 404 状态码,搜索引擎自然不会收录它,不需要再加 noindex。但如果因为某些原因你的站只能返回 200(比如用了静态托管服务不支持自定义状态码),那就必须加 <meta name="robots" content="noindex">,至少避免被收录成重复内容。
另外 404 页面上的链接建议加 rel="nofollow",避免爬虫顺着这些链接继续在死链区打转。
死链出现了,该怎么处理
找到死链之后有三种处理方式,怎么选取决于这个 URL 有没有价值。
有价值的:做 301 跳到最相关的页面
判断标准是「有没有外链指向它」「有没有历史流量」。如果一条老链接被别的站引用过,或者过去三个月有过点击,那它有价值,应该 301 跳到内容最接近的现存页面。
关键点:跳到「最相关」而不是「首页」。把所有死链都 301 到首页,是 SEO 里最糟糕的做法之一——搜索引擎会把它识别为软 404 的变体,而且用户点进去发现不是他要的内容会立刻离开,跳出率暴涨。
批量做 301 我用 Nginx 的 map,比一条条写 location 干净得多:
map $uri $redirect_to {
default "";
~^/archives/(\d+)/$ /$1.html;
/old-category/ /tech/;
/about-me.html /about/;
}
server {
if ($redirect_to != "") {
return 301 $redirect_to;
}
}这里 if 在 server 块里配合 return 是 Nginx 官方文档明确认可的安全用法("if is evil" 那篇文章里专门列出了这个例外),不要因为看到「if is evil」就不敢用。
没价值的:让它 404 或 410
如果一条 URL 从来没有外链、也没有流量,那就让它老老实实 404。不用跳转,不用保留。搜索引擎会在一两个月内把它从索引里清掉,对站点没有任何负面影响。
这里我要纠正一个很流行的说法:「大量 404 会拉低站点质量分」。这是错的。Google 的 John Mueller 多次公开说过,站点有 404 是正常的,不影响排名。真正影响排名的是软 404和死链导致的抓取预算浪费,而不是 404 这个状态码本身。
内容还在但换了地址:301,别删
这是最常见的一种。文章还在,只是 URL 改了。这种情况必须 301,而且要在改 URL 的当天就配好,不要等搜索引擎把老 URL 从索引里踢掉之后才想起来。老 URL 的 301 至少要保留一年,因为外链和用户收藏不会更新得那么快。
一次真实的恢复过程
回到开头那位读者。我们的处理顺序是这样的:
第一步,把九千条返回 200 的「假首页」全部改成返回真正的 404。这一步做完,GSC 的 4xx 计数立刻涨到九千多——看着吓人,但这是正确状态。
第二步,按访问日志里的点击量排序,把有流量的前 300 条老 URL 做了 301,映射到对应的新 URL。规则用上面的 map 写法,正则一条搞定 /archives/(\d+)/ 到 /$1.html。
第三步,剩下的八千多条保持 404,不跳转。
第四步,重新提交 sitemap,把里面所有已删除的 URL 清掉,让 sitemap 只包含 200 状态的有效地址。
恢复的节奏是这样的:改完第二周,抓取异常从 9000 降到 2000 左右;第四周,索引量开始回升;第八周,首页展示量恢复到原来的七成。整个过程大概两个月,比预想的慢,但这已经算快的了——SEO 类问题的修复周期都是以周为单位的,如果有人告诉你三天见效,那是在卖药。
每周十分钟的例行检查
死链不会一次性清完,它是个持续的过程——文章会删、URL 会改、外链会失效。我给自己定了一个十分钟的例行清单:
- 看当天日志里 404 的 top 10,有没有突然出现的新批次(突发的一批通常意味着某个模板或规则改错了)
- 看 GSC 的 404 报告有没有新增,特别是有外链的那些
- 抽查三条自己站的随机内链,确认能打开
- 确认 404 页面本身返回的是 404 而不是 200
这四件事加起来不到十分钟,但能提前发现大部分问题。最开始那个读者的站之所以能烂半年,就是因为从来没有看过日志里的状态码分布——流量是慢慢流失的,等他发现的时候,损失已经积累了半年。