本文完整记录一次个人博客遭遇分布式代理池刷量的全过程:从发现访问量异常暴涨 → 日志取证定位脏数据 → 编写 SQL 安全清理 → Nginx 三层防御止血 → 多维度验证效果。 重点不只是"怎么做",而是每一步背后的原理——为什么这个特征能识别爬虫、为什么删完必须 VACUUM、为什么 IP 限流对代理池无效、为什么改限流 key 必须重启 Nginx。 文中域名、IP、库名等均以占位符表示,可直接套用到你自己的站点。 最后更新:2026-10-06
1. 事件概述:74 倍的访问量暴涨
1.1 发现
后台访问统计里,日访问量曲线出现了一个极不自然的"垂直拉升":
| 日期 | 访问量 | 备注 |
|---|---|---|
| 09-16 ~ 09-30 | 200 ~ 400 / 天 | 正常基线(约 300) |
| 10-01 | 549 | 开始爬升 |
| 10-02 | 795 | 继续爬升 |
| 10-03 | 3,816 | 失控 |
| 10-04 | 22,847 | 爆发 |
| 10-05 | 24,589 | 爆发(当日 16:20 导出,未满一天) |
峰值相比基线放大约 74 倍。对于一个人博客,这个数字本身就是最强的异常信号——没有任何运营动作(没被大 V 转发、没上热搜、没投广告)能解释 74 倍的增长。
1.2 特征画像
把暴涨期的记录拉出来看,特征非常统一:
- IP 极度分散:几万个不同 IP,绝大多数每个 IP 只访问 1~2 次;
- UA 五花八门但都是伪造的:Chrome 各版本、Safari、Firefox 随机组合;
- Referer 高度可疑:大量记录的来源页带着
;jsessionid=这种会话跟踪参数; - 访问时段异常:凌晨 0~2 点、中午 11~13 点出现尖峰,与真人作息不符。
1.3 为什么常规防护没挡住
站点此前已经部署了按 IP 限流(每 IP 每秒 3 次,突发 10)。但这次完全失效,原因很直接:
每个 IP 只请求 1~2 次,谁都没有"超速"。
IP 限流防的是"单个 IP 高频",而代理池的战术恰恰是"海量 IP 低频"。这是两种不同维度的攻击,用错了武器。 这个教训是本次事件最核心的一条,后面第 5.5 节会展开。
1.4 附带损伤:数据库被灌脏数据
刷量请求穿透到了应用层,每条都被写进了访问日志表。24,589 条/天的垃圾记录带来三个问题:
- 统计数据全部失真——PV/UV、来源分析、热门文章排行全被污染;
- 表体积膨胀——有效数据被稀释到不足 20%;
- 拖慢查询——后台统计页越来越慢。
所以本次要解决的是两件事:止血(挡住后续刷量)和清创(清理已入库的脏数据)。顺序上先止血、后清创——否则一边清一边灌,永远清不完。
2. 数据源:访问日志表
2.1 数据怎么来的
每次访问文章详情页,后端写入一条记录。字段包括:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
自增主键 | 记录 ID |
article_id |
长整型 | 访问的文章 ID |
ip |
变长字符串 | 访客 IP(优先取 Nginx 设置的 X-Real-IP 头,防止客户端伪造) |
user_agent |
变长字符串 | 浏览器 UA 字符串,爬虫可伪造 |
referer |
变长字符串 | 来源页地址,爬虫可伪造 |
device_type |
变长字符串 | 设备类型(mobile / pc / tablet) |
browser |
变长字符串 | 解析出的浏览器名 |
os |
变长字符串 | 解析出的操作系统 |
created_at |
时间戳 | 访问时间 |
2.2 一条关键限制(务必理解)
访问日志表只记录"穿透到应用层"的请求。 被 Nginx 限流(429)或拦截(403)的请求根本到不了应用,不会进这张表。
这意味着两件事:
- 这张表看到的是"漏过来的爬虫",看不到"被挡掉的爬虫";
- 评估防护效果要看 Nginx 日志的 429/403 数量,而不是看数据库里爬虫少了没有(详见第 6 节)。
2.3 一个容易踩的坑:ip 字段的可信度
如果后端直接读 X-Forwarded-For,攻击者可以随便伪造这个头,把 IP 写成任意值。正确做法是:
- Nginx 侧强制覆盖该头:
proxy_set_header X-Forwarded-For $remote_addr; - 后端优先读
X-Real-IP(由 Nginx 设置为真实客户端 IP),而不是X-Forwarded-For。
否则 IP 统计、限流、封禁全部形同虚设。
3. 取证:如何证明一条记录是脏数据
这是本次最重要的部分。清理脏数据的难点不在 DELETE 语句,而在"如何证明你要删的确实是垃圾"——删错了就是数据事故。
3.1 第一性原理:真人和程序在日志上的本质区别
所有取证都围绕一个思路:找"真人物理上不可能产生"的特征。
不是找"看起来可疑"的特征(比如"凌晨访问"——夜猫子也会凌晨访问),而是找因果上不可能的特征。这样的特征才经得起推敲,才不会误伤。
本次找到两个这样的特征。
3.2 判据一:Referer 里的 ;jsessionid=
3.2.1 现象
大量记录的 referer 形如:
http://x1.example.com/article/some-post;jsessionid=9EFC823AC4F6C9A0B715919FDB0B2EDA
3.2.2 原理:Servlet 的 URL 重写
要理解这个特征,得先理解 Servlet 容器(Tomcat / Jetty)的会话跟踪机制。
HTTP 是无状态协议,服务端靠 JSESSIONID 这个 Cookie 来识别"这是同一个人的第几次请求"。但如果客户端不接受 Cookie,会话就断了。于是 Servlet 规范规定了一个兜底方案——URL 重写(URL Rewriting):
- 页面里所有链接在渲染时,通过
response.encodeURL()处理; - 如果容器判断"这个客户端不支持 Cookie",就在 URL 后面追加
;jsessionid=XXXX,形如/article/foo;jsessionid=ABC123; - 客户端点这个链接访问时,容器从 URL 里把 session id 抠出来,会话得以延续。
那容器怎么判断"客户端支不支持 Cookie"?看本次请求有没有带 JSESSIONID Cookie。
3.2.3 关键推理链
把上面机制套到真实浏览器和爬虫身上:
真实浏览器:
第 1 次请求 → 不带 Cookie → 容器认为"不支持 Cookie" → 页面链接带上 ;jsessionid=XXX
← 同时下发 Set-Cookie: JSESSIONID=XXX
第 2 次请求 → 浏览器自动带上 Cookie → 容器发现带了 Cookie
→ 从此不再往 URL 里塞 ;jsessionid
真实用户从第二次请求起,页面里的链接就再也不会带 ;jsessionid= 了。
不保存 Cookie 的爬虫:
第 1 次请求 → 不带 Cookie → 页面链接带 ;jsessionid=XXX
第 2 次请求 → 依然不带 Cookie(不保存)→ 页面链接还是带 ;jsessionid=XXX
第 N 次请求 → 永远都是"第一次" → 永远带 ;jsessionid=XXX
于是:爬虫顺着页面里的链接继续爬,它发出去的下一个请求,Referer 就是这个带 ;jsessionid= 的 URL。
结论:
referer字段里出现;jsessionid=,等价于"这个请求的上一个页面,是被一个不保存 Cookie 的客户端渲染出来的"。真人浏览器物理上不会产生这种 Referer。
这就是"因果上不可能"的特征。
3.2.4 用历史数据验证这个判据
光有推理不够,还得用数据佐证。查暴涨之前的历史数据:
-- 统计暴涨前,Referer 含 ;jsessionid= 的记录数
SELECT DATE(created_at) AS 日期, COUNT(*) AS 记录数
FROM visit_logs
WHERE referer LIKE '%;jsessionid=%'
AND created_at < '2026-10-01'
GROUP BY DATE(created_at)
ORDER BY 日期;
结果:08-23 ~ 09-30 共 39 天,全表只有 72 条命中,且逐条检查 UA,100% 是爬虫(GPTBot、旧版 Chrome 采集器)——没有一条真人记录。
而进入 10 月后:10-01 是 48 条、10-02 是 462 条、10-03 之后彻底失控。
结论:这个特征平时极稀有(39 天 72 条),且历史命中 100% 是爬虫。用它当判据是安全的。
3.3 判据二:不可能存在的浏览器版本号
3.3.1 现象
一部分记录的 UA 是:
Mozilla/5.0 (Linux; Android 9; ASUS_X00TD; Flow) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/359.0.0.288 Mobile Safari/537.36
注意 Chrome/359。
3.3.2 原理:版本号的物理边界
Chrome 的主版本号大约每 4 周 +1,也就是每年涨 12~13 个主版本:
| 年份 | Chrome 主版本(约) |
|---|---|
| 2020 | 80+ |
| 2022 | 100+ |
| 2024 | 120+ |
| 2026 | 140+ |
Chrome/359 对应的是几十年后的版本号,不可能存在。这是爬虫作者用随机数生成 UA 时忘了设上限的典型痕迹。
3.3.3 为什么这个特征也安全
真人浏览器上报的版本号一定是当时真实存在的版本。"不存在的版本号"同样是因果上不可能的特征。
-- 查看伪造版本号的记录量
SELECT COUNT(*) AS 伪造UA记录数
FROM visit_logs
WHERE user_agent LIKE '%Chrome/359.%';
3.4 基线对比法:给"脏"和"净"找一把标尺
有了两个判据还不够,还得回答一个关键问题:"删完之后,剩下的量对不对?"
方法是用正常态做标尺:
基线(09-16 ~ 09-30 日均) ≈ 300 条/天
暴涨期(10-03 ~ 10-05)总量 = 51,252 条
两判据命中 = 50,347 条
未被命中的剩余 = 905 条
905 条按天拆分:10-03 316 条
10-04 360 条
10-05 229 条
看最后一行:剩余量(316 / 360 / 229)恰好回落到 300 左右的基线水平。
这说明:
- 判据把"多出来的"全部抓住了(没有漏);
- 判据没有多删(否则剩余量会低于基线)。
再抽样看这 905 条是什么——里面能看到 bingbot、Baiduspider 等真实搜索引擎爬虫,属于正常流量,不能删。
这一步是数据清理里最有价值的思路:用已知的正常态当标尺,而不是凭感觉判断"删够了没有"。
3.5 判据的边界(诚实说明局限)
任何判据都有边界,说清楚比藏着好:
| 判据 | 能抓到 | 抓不到 |
|---|---|---|
;jsessionid= |
不保存 Cookie 的爬虫 | 用无头浏览器(带 Cookie)的爬虫 |
| 伪造版本号 | 随机生成 UA 且没设上限的爬虫 | UA 伪装得完全合理的爬虫 |
本次刷量恰好属于"不保存 Cookie + UA 随机生成"这一类,所以两判据覆盖了 98%(50,347 / 51,252)。换一种更"专业"的爬虫,这两个判据可能就失效了——所以清理之外,必须配套第 5 节的实时防御。
4. 清理:SQL 六步法(含备份与回滚)
4.1 总体思路
清理脏数据最怕两件事:删错和删了没法恢复。所以流程设计成六步,每一步都为下一步兜底:
① 看现状 → ② 备份 → ③ 删除 → ④ 核对 → ⑤ 回收空间 → ⑥ 回滚预案
原则:先查后改、先备份后删除、删完必核对。
4.2 第 0 步:先看现状
动任何数据之前,先把"要动多少"看清楚。
-- 清理前每日访问量(最近 12 天)
SELECT DATE(created_at) AS 日期, COUNT(*) AS 记录数
FROM visit_logs
WHERE created_at >= '2026-09-24'
GROUP BY DATE(created_at)
ORDER BY 日期;
-- 待删除量核对:两个判据分别多少、合计多少、全表多少
SELECT
COUNT(*) FILTER (WHERE referer LIKE '%;jsessionid=%') AS 判据一_jsessionid,
COUNT(*) FILTER (WHERE user_agent LIKE '%Chrome/359.%') AS 判据二_假UA,
COUNT(*) FILTER (WHERE referer LIKE '%;jsessionid=%'
OR user_agent LIKE '%Chrome/359.%') AS 合计待删,
COUNT(*) AS 清理前总数
FROM visit_logs;
这一步的作用:拿到"合计待删"和"清理前总数",后面备份和核对都要拿这两个数来对账。
4.3 第 1 步:备份(可回滚的关键)
-- 只备份"待删除"的行,不是整表
DROP TABLE IF EXISTS visit_logs_bak_20261006;
CREATE TABLE visit_logs_bak_20261006 AS
SELECT * FROM visit_logs
WHERE referer LIKE '%;jsessionid=%'
OR user_agent LIKE '%Chrome/359.%';
-- 核对:备份行数应等于上一步的「合计待删」
SELECT COUNT(*) AS 备份行数 FROM visit_logs_bak_20261006;
为什么用 CREATE TABLE ... AS SELECT?
- 它把查询结果物化成一张新表,一行 SQL 完成"建表 + 灌数据",不需要事先定义表结构;
- 只备份待删行,体积远小于整表备份(本例待删 82%,但换成删 5% 的场景优势就很明显了);
- 备份表是普通表,可直接
INSERT INTO ... SELECT原样还原,回滚成本极低。
⚠️ 注意:
CREATE TABLE AS不复制原表的约束、索引、默认值。作为一次性回滚快照完全够用,但如果要长期保留,需要另行补索引。
4.4 第 2 步:删除
两个判据分开写,便于出问题时单独排查是哪一条出了问题:
-- 判据一:Referer 含 ;jsessionid= 的刷量流量
DELETE FROM visit_logs
WHERE referer LIKE '%;jsessionid=%';
-- 判据二:伪造版本号 Chrome/359 的爬虫
DELETE FROM visit_logs
WHERE user_agent LIKE '%Chrome/359.%';
为什么两条分开跑? 如果合并成一条 OR,一旦行数对不上,你无法判断是哪条判据多删/少删了。分开跑,每条的影响行数都能和 4.2 步的核对数字对账。
4.5 第 3 步:核对结果
-- 清理后每日访问量(应回落到约 250~500/天 的基线)
SELECT DATE(created_at) AS 日期, COUNT(*) AS 记录数
FROM visit_logs
WHERE created_at >= '2026-09-24'
GROUP BY DATE(created_at)
ORDER BY 日期;
SELECT COUNT(*) AS 清理后总数 FROM visit_logs;
核对标准:清理后总数 = 清理前总数 − 合计待删。对不上就说明删多了或删少了,立刻从备份表回滚。
本例:64,306 − 52,832 = 11,474,且每日曲线回落到 300 上下,符合预期。
4.6 第 4 步:回收空间(必须做,且必须单独执行)
-- ⚠️ 不能在事务块里执行,Navicat 中单独开一个查询窗口跑
VACUUM (ANALYZE) visit_logs;
这一步不是可选项,原因见第 7.5 节(MVCC 与死元组)。简单说:DELETE 只是把行标记为"死元组",磁盘空间并不会自动回收,表和索引文件还是原来的大小,查询计划器也还拿着旧的统计信息。删了 82% 的行却不 VACUUM,性能一点不会变好。
4.7 第 5 步:回滚预案(万一删多了)
-- 完整还原(备份表结构与被删行一致)
INSERT INTO visit_logs SELECT * FROM visit_logs_bak_20261006;
-- 确认业务无误后,再删掉备份表
DROP TABLE visit_logs_bak_20261006;
建议:备份表至少保留 1~2 周再删。清理是破坏性操作,留一条后路成本极低。
5. 止血:Nginx 三层防御
清理是"清创",防御是"止血"。本次在 Nginx 层做了三层规则 + 一层豁免。
5.1 第一层:按 IP 限流(防单点高频)
# /etc/nginx/conf.d/rate-limit.conf
# 后台与静态资源不参与限流(key 置空),其余按客户端 IP
map $uri $pz_limit_key {
"~^/admin/" "";
"~*\.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|map)(;jsessionid=[0-9a-f]+)?$" "";
default $binary_remote_addr;
}
limit_req_zone $pz_limit_key zone=pz_dyn:10m rate=3r/s;
limit_req_status 429;
在站点配置里挂上限流:
server {
# ……
location / {
limit_req zone=pz_dyn burst=10 nodelay;
# ……原有配置……
}
}
参数原理:
| 参数 | 含义 |
|---|---|
rate=3r/s |
每个 key(这里是 IP)平均允许 3 请求/秒 |
burst=10 |
漏桶容量:允许瞬时多来 10 个请求 |
nodelay |
桶内的突发请求立即放行(不排队延迟),超出桶的直接 429 |
limit_req 用的是漏桶算法:请求以固定速率被处理,多余的先装进桶里(burst),桶满了就拒绝。不加 nodelay 时,桶里的请求会被"延迟放行"(平滑成匀速);加了 nodelay 则立即放行、超限即拒,对爬虫的杀伤力更大。
正则里的两个坑:
- 静态资源判断用
$uri(已去掉查询串),且必须允许结尾带;jsessionid=...后缀——因为无 Cookie 客户端渲染出的链接形如/css/style.css;jsessionid=XXX?v=1.0.8; - 正则里含
;必须用引号包起来,否则 Nginx 会把分号当成指令结束符,报invalid number of the map parameters。
5.2 第二层:特征拦截(防代理池,本次的关键一层)
IP 限流对代理池无效(每个 IP 只来一两次,都没超速)。必须换一个不依赖 IP 的维度——用行为特征。 这就用上了第 3.2 节那个 ;jsessionid= 特征。
# /etc/nginx/conf.d/bot-block.conf
# ① 请求是否带 JSESSIONID Cookie
map $http_cookie $pz_has_cookie {
default 1;
"" 0; # 完全没有 Cookie
}
# ② Referer 是否含 ;jsessionid=
map $http_referer $pz_ref_jsid {
default 0;
"~;jsessionid=" 1;
}
# ③ 是否静态资源(静态资源一律放行,避免误伤无 Cookie 渲染出的样式/图片链接)
# 注意:用 $uri(已去掉查询串),并允许结尾带 ;jsessionid=... 后缀;
# 正则含 ";" 必须加引号,否则分号会被当成指令结束符
map $uri $pz_is_static {
default 0;
"~*\.(css|js|png|jpe?g|gif|svg|webp|avif|bmp|ico|woff2?|ttf|eot|map)(;jsessionid=[0-9a-f]+)?$" 1;
}
# ④ 三条件同时满足才拦:Referer 带 jsessionid + 无 Cookie + 非静态资源
map "$pz_ref_jsid:$pz_has_cookie:$pz_is_static" $pz_bot_block {
default 0;
"~^1:0:0" 1;
}
在 server 块中应用:
server {
# ……
if ($pz_bot_block = 1) {
return 403;
}
# ……原有 location 配置……
}
为什么设了三层保险(静态资源放行 / 带 Cookie 放行 / 两条件同时满足才拦)?
因为这是"宁可放过、不可错杀"的规则——目标是零误伤真人。真人要么带 Cookie(第二次请求起),要么访问的是静态资源,都不会命中。
效果:暴涨期 98% 的请求命中该规则,真实用户误伤 0。
5.3 第三层:伪造子域名 Referer 拦截
代理池还会把 Referer 伪造成站内子域名(如 x1.example.com),伪装成"站内跳转"来绕过防盗链。
它为什么能绕过防盗链?因为防盗链的 valid_referers *.example.com 是字符串通配——只要 Referer 以 example.com 结尾就放行,根本不校验这个子域名是否真实存在。
修法是改成显式白名单:
# /etc/nginx/conf.d/fake-referer.conf
map $http_referer $fake_ref {
default 0;
# —— 白名单:实际拥有的域名(新增子域名在此追加)——
"~(?i)^https?://example\.com(/|:|$)" 0;
"~(?i)^https?://www\.example\.com(/|:|$)" 0;
# —— 其余 example.com 子域名一律视为伪造 ——
"~(?i)^https?://[a-z0-9.-]+\.example\.com(/|:|$)" 1;
}
server {
# ……
if ($fake_ref = 1) {
return 403;
}
# ……
}
map 按书写顺序匹配(先白名单后黑名单)。Referer 为空 / 主域 / 外站 → default 0 放行;是 *.example.com 但子域名不在白名单 → 1 → 403。正则用 (?i) 忽略大小写,(/|:|$) 兼容路径 / 端口 / 结尾三种情况。
⚠️ 维护提醒:将来新增真实子域名(如
blog.example.com),必须先在白名单里追加,否则新站点的站内跳转会全部 403。
5.4 豁免层:别把搜索引擎一起拦了
三层规则里,fake-referer 和 bot-block 都不会误伤官方搜索引擎爬虫(它们从第二次请求起就带 Cookie,也不会伪造站内子域名 Referer)。唯一可能误伤的是限流——Googlebot 等批量抓取时会撞到 3r/s,返回 429,影响收录。
给搜索引擎开个"免限流"白名单:
# ============ 1) 识别搜索引擎爬虫 UA ============
map $http_user_agent $pz_se {
default 0;
"~*googlebot" 1;
"~*bingbot" 1;
"~*msnbot" 1;
"~*baiduspider" 1;
"~*yandexbot" 1;
"~*duckduckbot" 1;
"~*slurp" 1; # Yahoo
"~*sogou web spider" 1;
"~*360spider" 1;
"~*shenmaspider" 1; # 神马
"~*bytespider" 1; # 字节
"~*applebot" 1;
"~*petalbot" 1; # 华为
}
# ============ 2) 在原限流 key 外层再包一层 ============
# 搜索引擎 -> 空 key,其余沿用原 key
map "$pz_se:$pz_limit_key" $pz_limit_key_se {
default $pz_limit_key;
"~^1:" "";
}
# ============ 3) 把 limit_req_zone 的 key 换掉 ============
limit_req_zone $pz_limit_key_se zone=pz_dyn:10m rate=3r/s;
原理(关键知识点):Nginx 对"空字符串 key"不做计数与限流。 这是官方行为,也正是上面 map 里把 /admin/ 和静态资源的 key 置空就能"免限流"的原因。把搜索引擎的 key 也置空,等于完全豁免限流,同时不影响其他任何拦截规则。
两个
map必须写在limit_req_zone之前,否则 Nginx 报unknown variable(变量必须先定义后引用)。
5.5 踩坑实录:改限流 key 必须 restart,不能 reload
这一节是本次排查最绕的一段弯路,值得单独记下来。
现象:配置改完,sudo nginx -t 通过,sudo nginx -T 也能读到新内容,但行为完全没变——Googlebot 还是被 429。
根因:limit_req_zone 的 key 表达式变更无法通过 reload 生效。
Nginx 的 limit_req_zone 背后是一块共享内存。reload 时,新 worker 进程要继承老进程的这块共享内存。如果新配置里 zone 的 key 表达式变了(本例从 $pz_limit_key` 改成 `$pz_limit_key_se),Nginx 认为"这不是同一个 zone",于是放弃新配置,让旧配置继续运行。
为什么极具迷惑性?
| 命令 | 表现 | 为什么骗人 |
|---|---|---|
nginx -t |
通过 | 只校验语法,不校验运行中配置 |
nginx -T |
能读到新内容 | 它是重新解析文件并打印,不等于运行中的配置 |
| 实际行为 | 完全没变 | 旧 zone 还在跑 |
结论(记牢):
- 改
limit_req_zone/limit_conn_zone的 key → 必须systemctl restart nginx; - 只改
rate或 zone 大小 →reload即可。
# 改 key 用 restart
sudo nginx -t && sudo systemctl restart nginx
6. 验证:怎么确认真的生效了
配置改完不算完,必须验证。本次从三个维度验证。
6.1 状态码验证(配置正确性)
# ① 搜索引擎 UA 连发 20 次 → 应全部 200,无 429
for i in $(seq 20); do
curl -s -o /dev/null -w '%{http_code} ' \
-A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
https://example.com/
done; echo
# ② 普通 UA 连发 20 次 → 应出现 429(限流生效)
for i in $(seq 20); do
curl -s -o /dev/null -w '%{http_code} ' https://example.com/
done; echo
# ③ 爬虫特征:Referer 带 jsessionid 且不带 Cookie → 应返回 403
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Referer: http://x1.example.com/article/x;jsessionid=ABC123' \
https://example.com/article/x
# ④ 同样 Referer 但带上 Cookie → 应返回 200(不误伤)
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Referer: http://x1.example.com/article/x;jsessionid=ABC123' \
-H 'Cookie: JSESSIONID=ABC123' \
https://example.com/article/x
预期:① 20×200、② 出现 429、③ 403、④ 200。
6.2 效果验证(看 Nginx 日志,不是看数据库)
再次强调:数据库看不到被挡掉的请求,评估防护效果唯一可靠的数据源是 Nginx 日志。
# 各状态码数量分布
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
# 403 / 429 按 IP 排行(看是谁在被拦)
awk '$9==403{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
awk '$9==429{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
本次部署后的实测:403 拦截量显著上升(92.5% 的请求被拦),且拦截对象中零误伤搜索引擎爬虫。
6.3 数据验证(每日量回落)
-- 数据库每日访问量应回落到基线水平
SELECT DATE(created_at) AS 日期, COUNT(*) AS 记录数
FROM visit_logs
WHERE created_at >= '2026-10-01'
GROUP BY DATE(created_at)
ORDER BY 日期;
判定标准:Nginx 403 持续上升 + 数据库新增记录回落到 300 上下 = 防御生效。
7. 原理扩展:把这次踩到的知识点讲透
7.1 HTTP Referer 是什么,能信吗
Referer 是浏览器自动带上的"上一页地址"请求头。要点:
- 它由客户端发送,因此可以被伪造。 爬虫可以随便写一个 Referer;
- 但它默认行为是浏览器自动填的,所以"真人产生不了的值"反而是可信的判据(如
;jsessionid=); - 微信 / QQ / 邮件客户端内置浏览器、隐私插件、
Referrer-Policy设置都会剥离或裁剪 Referer,所以"Referer 为空"很常见,不能简单等同于爬虫。
7.2 User-Agent 的两面性
- 空 UA → 几乎必然是程序(浏览器一定会带);
- 脚本 UA(含
curl/python/Go-http/Scrapy/okhttp等)→ 未伪装的程序; - 伪 UA → 看起来正常,需结合其他特征(版本号是否合理、是否被多个 IP 共用、频率等)判断。
"版本号是否合理"是一个被低估的强判据——伪造者往往忽略版本号的物理边界。
7.3 代理池为什么能绕过 IP 限流
代理池 = 一个由成千上万台机器(或住宅宽带)组成的出口 IP 池,每次请求随机换一个 IP。
| 攻击方式 | 特征 | 有效防御 |
|---|---|---|
| 单点高频 | 一个 IP 短时间大量请求 | IP 限流(limit_req) |
| 分布式低频 | 海量 IP 各请求 1~2 次 | 行为特征拦截(本例的 ;jsessionid= 规则) |
这是两个不同维度,武器不能混用。 本次事件的核心教训就在这里:站点原本只部署了 IP 限流,面对代理池等于没设防。
7.4 Servlet 会话跟踪与 URL 重写(补充)
JSESSIONID是 Servlet 容器的会话 Cookie,默认是会话级 Cookie(关浏览器即失效);- 客户端禁用 Cookie 时,容器通过 URL 重写(
;jsessionid=)维持会话,这是 Servlet 规范的兼容设计; - 对本博客这类"没有登录态也要记会话"的站点,这个兼容设计反而成了爬虫指纹——这是一个很有意思的副作用;
- 想彻底关掉这个行为,可以在 web.xml / 配置里关闭 URL 重写(
<tracking-mode>COOKIE</tracking-mode>),但会牺牲禁用 Cookie 用户的会话保持。
7.5 PostgreSQL 的 DELETE 为什么不释放空间
这是理解"删完必须 VACUUM"的关键。
MVCC(多版本并发控制):PostgreSQL 靠多版本实现"读写不互相阻塞"。当你执行 DELETE 时:
- PostgreSQL 不立刻物理删除这行,而是把它标记为死元组(dead tuple);
- 原因:可能还有别的事务正在读这个行的旧版本(快照隔离),不能直接抹掉;
- 死元组占用的空间不会自动回收,表文件(和索引)会持续膨胀。
VACUUM 做什么:
| 命令 | 作用 | 是否归还磁盘给操作系统 |
|---|---|---|
VACUUM |
把死元组占用的空间标记为"可复用" | ❌ 不归还,但可被后续 INSERT 复用 |
VACUUM FULL |
重写整张表,彻底整理 | ✅ 归还,但会加排他锁(阻塞读写) |
VACUUM (ANALYZE) |
VACUUM + 重新收集统计信息 | ❌ 同上 |
为什么加 ANALYZE:DELETE 掉 82% 的行后,查询计划器手里的统计信息还是旧的。它可能仍按"表很大"来选择执行计划(比如走索引扫描),实际已经不合适。ANALYZE 重新采样,让计划器知道行数变了。
为什么不能在事务块里:VACUUM 需要在事务之外运行(PG 会报 VACUUM cannot run inside a transaction block)。所以在 Navicat 里要单独开一个查询窗口跑,不要和前面的语句一起提交。
7.6 CREATE TABLE AS 做备份的取舍
| 优点 | 缺点 |
|---|---|
| 一行 SQL 完成"建表 + 灌数据" | 不复制约束、索引、默认值、触发器 |
| 只备份目标行,体积小 | 备份表是"死"快照,不会跟原表同步 |
回滚只需一条 INSERT ... SELECT |
大表备份会占用可观的磁盘空间 |
结论:作为"一次性回滚快照",它是性价比最高的方案;若要长期留档,建议改用 pg_dump 导出,或建表后手动补索引。
8. 注意事项与误区
- 别把"看起来可疑"当判据。要选因果上不可能的特征(真人物理上产生不了),而不是"凌晨访问""Referer 为空"这类真人也会产生的特征。否则必然误伤。
- 删前必备份、必须可回滚。清理是破坏性操作,
CREATE TABLE AS备份成本极低,没有理由不做。 - 删完必须核对,核对要有标尺。用"清理前总数 − 待删量 = 清理后总数"对账,用"每日量是否回落到基线"验证,别凭感觉。
VACUUM不是可选项。删了 82% 不 VACUUM,空间不回收、统计不更新,性能不会改善。- 别只封单个 IP。代理池 IP 一直轮换,封单个没用。要针对"共性"下手——行为特征、伪造 Referer、UA 特征。
- IP 段封禁有误伤风险。住宅 IP 段里可能混有真实用户,优先用"限流 / 特征拦截"而不是直接封整段。
- 别封搜索引擎。Googlebot / Bingbot / 百度蜘蛛封了会掉收录。给它们开免限流白名单,但只豁免限流,不要豁免特征拦截(官方爬虫本来也不会命中那些特征)。
- 数据库看不到被挡的请求。评估防护效果必须看 Nginx 日志,这是最容易误解的一点。
limit_req_zone改 key 必须 restart。nginx -t/nginx -T都验证不了运行中的配置,别被它们骗了。- 新增真实子域名要加白名单。
fake-referer.conf白名单只有主域和 www,开了新子域必须同步追加,否则站内跳转全 403。 robots.txt只约束守规矩的。搜索引擎和声明身份的 AI 爬虫会遵守,恶意爬虫根本不读它,不能作为唯一防线。- 清理是治标,防御才是治本。脏数据清理是一次性的;只要 Nginx 层的规则在,新刷量就进不来。反过来,只清不防等于白清。
9. 附录:完整清理脚本
可直接复制到 Navicat / psql 执行。执行顺序:0 → 1 → 2 → 3 → 4,第 4 步(VACUUM)单独开窗口。
第 0 步:看现状
-- 清理前每日访问量
SELECT DATE(created_at) AS 日期, COUNT(*) AS 记录数
FROM visit_logs
WHERE created_at >= '2026-09-24'
GROUP BY DATE(created_at)
ORDER BY 日期;
-- 待删除量核对
SELECT
COUNT(*) FILTER (WHERE referer LIKE '%;jsessionid=%') AS 判据一_jsessionid,
COUNT(*) FILTER (WHERE user_agent LIKE '%Chrome/359.%') AS 判据二_假UA,
COUNT(*) FILTER (WHERE referer LIKE '%;jsessionid=%'
OR user_agent LIKE '%Chrome/359.%') AS 合计待删,
COUNT(*) AS 清理前总数
FROM visit_logs;
第 1 步:备份
DROP TABLE IF EXISTS visit_logs_bak_20261006;
CREATE TABLE visit_logs_bak_20261006 AS
SELECT * FROM visit_logs
WHERE referer LIKE '%;jsessionid=%'
OR user_agent LIKE '%Chrome/359.%';
SELECT COUNT(*) AS 备份行数 FROM visit_logs_bak_20261006;
第 2 步:删除
-- 判据一
DELETE FROM visit_logs
WHERE referer LIKE '%;jsessionid=%';
-- 判据二
DELETE FROM visit_logs
WHERE user_agent LIKE '%Chrome/359.%';
第 3 步:核对
SELECT DATE(created_at) AS 日期, COUNT(*) AS 记录数
FROM visit_logs
WHERE created_at >= '2026-09-24'
GROUP BY DATE(created_at)
ORDER BY 日期;
SELECT COUNT(*) AS 清理后总数 FROM visit_logs;
第 4 步:回收空间(单独窗口执行)
VACUUM (ANALYZE) visit_logs;
回滚
INSERT INTO visit_logs SELECT * FROM visit_logs_bak_20261006;
-- 确认无误后再删备份表:
-- DROP TABLE visit_logs_bak_20261006;
结语
这次事件的完整链路是:
异常发现(量级 74 倍)
↓
日志取证(找"真人物理上不可能"的特征)
↓
数据清理(先查 → 备份 → 删 → 核对 → VACUUM → 可回滚)
↓
实时防御(IP 限流 + 特征拦截 + 伪造 Referer 拦截 + 搜索引擎豁免)
↓
多维度验证(状态码 / Nginx 日志 / 数据库回落)
三条最值得记住的经验:
- IP 限流防不住代理池——攻击维度不同,武器不能混用,必须补行为特征拦截;
- 判据要选"因果上不可能"的——用正常态基线当标尺,才能既删干净又不误伤;
DELETE之后必须VACUUM——理解 MVCC 死元组,才知道删完为什么表还是那么大。
以上。
评论 (0)
还没有评论,来抢沙发。