皮皮舟印岁月

A JOURNAL OF TRAVEL · CODE · MARKETS
第三十七期 · 第 37 号 二〇二六年十月 · 秋分之后

一次分布式刷量攻击的完整复盘:取证、脏数据清理与防御验证

2026-10-06 20 阅读

本文完整记录一次个人博客遭遇分布式代理池刷量的全过程:从发现访问量异常暴涨 → 日志取证定位脏数据 → 编写 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 条/天的垃圾记录带来三个问题:

  1. 统计数据全部失真——PV/UV、来源分析、热门文章排行全被污染;
  2. 表体积膨胀——有效数据被稀释到不足 20%;
  3. 拖慢查询——后台统计页越来越慢。

所以本次要解决的是两件事:止血(挡住后续刷量)和清创(清理已入库的脏数据)。顺序上先止血、后清创——否则一边清一边灌,永远清不完。


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 写成任意值。正确做法是:

  1. Nginx 侧强制覆盖该头:proxy_set_header X-Forwarded-For $remote_addr;
  2. 后端优先读 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 则立即放行、超限即拒,对爬虫的杀伤力更大。

正则里的两个坑:

  1. 静态资源判断用 $uri(已去掉查询串),且必须允许结尾带 ;jsessionid=... 后缀——因为无 Cookie 客户端渲染出的链接形如 /css/style.css;jsessionid=XXX?v=1.0.8;
  2. 正则里含 ; 必须用引号包起来,否则 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 时:

  1. PostgreSQL 不立刻物理删除这行,而是把它标记为死元组(dead tuple);
  2. 原因:可能还有别的事务正在读这个行的旧版本(快照隔离),不能直接抹掉;
  3. 死元组占用的空间不会自动回收,表文件(和索引)会持续膨胀。

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. 注意事项与误区

  1. 别把"看起来可疑"当判据。要选因果上不可能的特征(真人物理上产生不了),而不是"凌晨访问""Referer 为空"这类真人也会产生的特征。否则必然误伤。
  2. 删前必备份、必须可回滚。清理是破坏性操作,CREATE TABLE AS 备份成本极低,没有理由不做。
  3. 删完必须核对,核对要有标尺。用"清理前总数 − 待删量 = 清理后总数"对账,用"每日量是否回落到基线"验证,别凭感觉。
  4. VACUUM 不是可选项。删了 82% 不 VACUUM,空间不回收、统计不更新,性能不会改善。
  5. 别只封单个 IP。代理池 IP 一直轮换,封单个没用。要针对"共性"下手——行为特征、伪造 Referer、UA 特征。
  6. IP 段封禁有误伤风险。住宅 IP 段里可能混有真实用户,优先用"限流 / 特征拦截"而不是直接封整段。
  7. 别封搜索引擎。Googlebot / Bingbot / 百度蜘蛛封了会掉收录。给它们开免限流白名单,但只豁免限流,不要豁免特征拦截(官方爬虫本来也不会命中那些特征)。
  8. 数据库看不到被挡的请求。评估防护效果必须看 Nginx 日志,这是最容易误解的一点。
  9. limit_req_zone 改 key 必须 restart。nginx -t / nginx -T 都验证不了运行中的配置,别被它们骗了。
  10. 新增真实子域名要加白名单。fake-referer.conf 白名单只有主域和 www,开了新子域必须同步追加,否则站内跳转全 403。
  11. robots.txt 只约束守规矩的。搜索引擎和声明身份的 AI 爬虫会遵守,恶意爬虫根本不读它,不能作为唯一防线。
  12. 清理是治标,防御才是治本。脏数据清理是一次性的;只要 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 日志 / 数据库回落)

三条最值得记住的经验:

  1. IP 限流防不住代理池——攻击维度不同,武器不能混用,必须补行为特征拦截;
  2. 判据要选"因果上不可能"的——用正常态基线当标尺,才能既删干净又不误伤;
  3. DELETE 之后必须 VACUUM——理解 MVCC 死元组,才知道删完为什么表还是那么大。

以上。

分享此文

评论 (0)

还没有评论,来抢沙发。

发表评论