# 我的博客，被 47 万次请求刷慢了


![我的博客，被 47 万次请求刷慢了](https://img.lixueduan.com/blog/cover/caddy-rate-limit.jpg)

事情是这样的。

前几天有读者跟我反馈，说博客变慢了。

不是偶尔卡一下，是点开文章以后，文字出来了，图片还要在原地转上好几秒。

我自己试了几次，确实不对劲。更奇怪的是，前不久我才压缩过图片。

一个纯静态博客，为什么会突然慢成这样？

<!--more-->

## 1. CPU 没忙，3M 带宽却满了

我第一反应是服务器扛不住了。

毕竟这就是一台很普通的小服务器，2C2G。真遇上请求高峰，被打得喘不过气也不奇怪。

结果我打开监控一看，CPU 使用率还不到 10%。

机器闲得很。

真正出问题的，是带宽。

我这台服务器的公网出口只有 3 Mbps。正常情况下，机器的流出带宽很低，只有 `70.793 Kbit/s`。

![正常情况下，公网流出带宽只有几十 Kbit/s](https://img.lixueduan.com/blog/caddy-rate-limit/bandwidth-1d.png)

但被大量请求刷起来以后，曲线完全变了。把粒度缩到 1 分钟，公网流出带宽直接冲到 `3.289 Mbit/s`，贴着 3M 上限跑。

![大量请求出现后，公网流出带宽达到 3.289 Mbit/s](https://img.lixueduan.com/blog/caddy-rate-limit/bandwidth-1h.png)

从 `70.793 Kbit/s` 到 `3.289 Mbit/s`，相差超过 46 倍。

Caddy 返回静态页面几乎不费 CPU，所以没有收到报警信息。但出口一旦被占满，正常读者再打开一张图片，就只能在后面排队。

顺着带宽曲线往下查，我发现 Caddy Access Log 最近轮转得比平时快了不少。

我一开始还没太当回事。博客嘛，被搜索引擎和 RSS 阅读器抓一抓很正常。

直到我把 27 天的轮转日志全部合到一起，才发现里面有一批访问频率异常的请求。

## 2. 27 天日志里，到底藏了什么

这份日志快照实际覆盖 27.46 天，统计结果如下：

| 指标 | 数值 |
| --- | ---: |
| 全站请求 | 1,132,456 |
| 异常请求 | 477,314 |
| 异常请求占比 | 42.15% |
| 异常响应流量 | 9.00 GB |
| 异常 IP | 75 个 |
| 返回 200 | 475,806 次 |

47 万是累计总量，短时间内的请求频率更值得看。

| 行为 | 观测结果 |
| --- | ---: |
| 最高一天 | 25,936 次 |
| 最高一小时 | 6,864 次，占该小时全站请求 85.98% |
| 最高一分钟 | 627 次，全部来自同一个 IP |
| 最快 100 次 | 6.281 秒 |
| 最快 500 次 | 43.412 秒 |
| 最长连续突发 | 7 分 28 秒内请求 3,539 次 |

那段最长突发里，相邻请求没有一次停顿超过 1 秒。这段时间共切换了 45 种 User-Agent，成功请求文章页面 3,308 次。

从文章覆盖范围看，这些请求也不是偶尔访问几篇。

当前 `sitemap.xml` 中有 278 篇文章。这批 IP 访问了全部 278 篇，仅对这些文章就请求了 394,112 次。按篇数粗略折算，相当于把整个文章库读取了 1,418 遍。
> 这就离谱😂

单篇文章被读取的中位数是 1,163 次，152 篇文章超过 1,000 次，最高一篇达到 5,551 次。

不过，请求多并不能直接说明这些访问不正常。

搜索引擎、RSS 阅读器，甚至某个读者连续打开很多文章，都可能制造高峰。还需要继续看这些请求的具体特征。

## 3. 从日志识别异常抓取

先看 IP。

27 天里一共出现了 75 个相关地址，单个 IP 通常只活跃 1～6 天，随后换成同一地址空间里的另一个。

再看 User-Agent。同一个 IP 在不到一秒内留下过这样的记录：

```text
10:36:19.436  Chrome/127  /posts/kubernetes/58-dra-p4-my-dra-driver/
10:36:19.955  Chrome/110  /index.xml
10:36:20.029  Chrome/115  /tags/rtk/
10:36:20.075  Chrome/133  /posts/kubernetes/59-kubeclipper-release-1.6.0/
10:36:20.148  Chrome/120  /posts/cloudnative/01-metallb/
10:36:20.321  Chrome/109  /posts/kubernetes/57-dra-p3-workflow/
```

Chrome 100～144 一共 45 个版本全部出现，每个版本的请求量都在 9,441～10,045 次之间。排除每个 IP 的第一条记录后，97.77% 的相邻请求都会更换 User-Agent。

正常浏览器不会请求一次就换一个 Chrome 版本。

再看请求目标。这批地址主要请求 RSS 和文章 HTML，几乎不加载文章图片。

## 4. 从 IP 黑名单转向请求频率限流

我第一反应就是封 IP。

但对方 27 天换了 75 个 IP，逐个封禁只能短期止血，批量封禁又容易误伤正常用户。

继续判断请求来自谁没有太大意义，我只需要限制访问频率。

问题变成了，每分钟允许多少次，既能拦住抓取，又不影响正常读者？

为了避免阈值设得太紧，误伤正常访问，我先拿最近 24 小时的日志做了一次固定自然分钟回放：

| 阈值 | 回放结果 |
| --- | --- |
| 全部请求 60 次/分钟 | 命中 3 个主要异常 IP 和 1 个额外 IP |
| 全部请求 120 次/分钟 | 只命中 3 个主要异常 IP |
| 页面请求 30 次/分钟 | 命中 3 个主要异常 IP 和 1 个额外 IP |
| 页面请求 60 次/分钟 | 只命中 3 个主要异常 IP |

结合回放结果，我设置了两个独立限流区：

```text
全部请求：120 次/分钟/IP
页面请求： 60 次/分钟/IP
```

页面请求同时计入两个区。HTML、分类、标签和 RSS 每分钟最多 60 次，CSS、JavaScript 等其他资源只受每分钟 120 次的兜底限制。

日志回放按固定自然分钟统计，后面使用的模块是滑动窗口，两者不会完全一致。这组阈值只对应当时的访问情况，后续还要根据 429 和误伤情况调整。

## 5. 在 Caddy 中启用 Rate Limit

Caddy v2.11.4 的官方标准二进制不包含 `rate_limit`，需要通过 `xcaddy` 构建自定义版本。本文使用的是第三方模块 [mholt/caddy-ratelimit](https://github.com/mholt/caddy-ratelimit)。

为了以后换服务器还能复现，我固定了 Caddy、xcaddy 和插件提交：

```bash
go install github.com/caddyserver/xcaddy/cmd/xcaddy@v0.4.6

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
  xcaddy build v2.11.4 \
  --with github.com/mholt/caddy-ratelimit@5625512f24f6f59d6f64fb3aafe5eecff0b286db
```

构建完成后确认模块已经包含在二进制中：

```bash
./caddy version
./caddy list-modules | grep -Fx http.handlers.rate_limit
```

限流配置如下：

```caddyfile
rate_limit {
  zone blog_all {
    key {remote_host}
    events 120
    window 1m
    ipv6_prefix 64
  }

  zone blog_pages {
    match {
      method GET HEAD
      path / /posts/* /tags/* /categories/* /about/* /index.xml
    }
    key {remote_host}
    events 60
    window 1m
    ipv6_prefix 64
  }

  log_key
}
```

将配置写入实际使用的 Caddyfile 后，先用新二进制校验配置，再替换当前版本并重启服务：

```bash
install -m 0755 ./caddy /usr/local/bin/caddy.new

/usr/local/bin/caddy.new validate \
  --config /etc/caddy/Caddyfile

cp /usr/local/bin/caddy \
  /usr/local/bin/caddy.$(date +%Y%m%d%H%M%S).bak

mv /usr/local/bin/caddy.new /usr/local/bin/caddy
systemctl restart caddyd
systemctl status caddyd --no-pager
```

只替换磁盘上的二进制还不够，正在运行的旧进程不会自动加载新模块，因此这里需要重启 Caddy。

配置生效后，我从同一出口 IP 并发请求页面：

```bash
seq 1 65 | xargs -P 10 -I {} \
  curl --http1.1 -s -o /dev/null -w '%{http_code}\n' \
  https://www.lixueduan.com/posts/ \
  | sort | uniq -c
```

超过窗口额度后，客户端开始收到 429：

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 58
Server: Caddy
Content-Length: 0
```

看到 429 后，可以确认限流配置已经生效。

## 6. 限流上线后的同一时段

限流在 `2026-07-16 10:55:50` 随新 Caddy 进程正式生效。

前面的云监控截图中，带宽打满发生在 `2026-07-15 20:29～21:28`。我把这 1 小时作为上线前窗口，再取限流上线后相同钟点的 `2026-07-16 20:29～21:28` 做对比。

上线后的同一小时内，异常 IP 成功获取的 HTML 减少了 4,478 次，Caddy 少返回了 89.46 MB 响应数据，还有 1,287 次请求被直接挡在 429。

| 指标 | 上线前 | 上线后 | 实际变化 |
| --- | ---: | ---: | ---: |
| 异常地址成功返回 HTML | 5,297 | 819 | 减少 4,478（-84.5%） |
| 异常地址响应流量 | 112.03 MB | 22.57 MB | 减少 89.46 MB（-79.9%） |
| 异常地址返回 200 | 5,664 | 932 | 减少 4,732（-83.5%） |
| 异常地址返回 429 | 0 | 1,287 | 新增 1,287 次拦截 |
| 异常地址请求 | 5,684 | 2,223 | 减少 3,461（-60.9%） |
| 全站请求 | 6,637 | 3,071 | 减少 3,566（-53.7%） |

成功返回的 HTML 和异常响应流量分别下降 84.5% 和 79.9%，限流生效后有 1,287 次请求返回 429。全站请求量也下降了 53.7%，不过里面混着正常访问，只能作为参考。

单个小时还不能代表长期效果，后续仍要观察 429 来源以及搜索引擎是否被误伤。

## 7. 总结

文章发出来，本来就是给大家看的。有人搜索、订阅，甚至批量抓取文章，我其实都无所谓。我介意的是，持续的高频请求已经影响到正常读者打开文章和图片。

`120 次/分钟` 和 `60 次/分钟` 不是通用答案，只是我根据这次日志回放选出的起点，后面还要继续观察是否存在误伤。

限流只是控制访问节奏，让真正来看文章的人能够顺畅打开页面。


---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/blog/04-caddy-rate-limit/  

