博客跑在一台 3.6GB 内存的小鸡上,Apache + PHP-FPM + MySQL 8.0 + Typecho(Handsome 主题)。平时访问没啥感觉,但一上来并发就原形毕露——50 个并发请求响应时间直接飙到 5 秒,300 并发直接卡死,98% 的请求失败。
折腾了一下午,最终用 Varnish 做全页缓存,把 300 并发从 5 秒干到了 20 毫秒。记录一下过程。
先说结论#
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 300 并发吞吐 | ~4.65 req/s(50并发就卡死) | 13903 req/s |
| 平均响应 | 5283ms | 21.6ms |
| P99 响应 | 9207ms | 34ms |
| 500 并发 | 必崩 | 12801 req/s,P99=48ms |
| 失败率 | 98%(50并发) | 0%(5000请求) |
问题在哪#
先压测摸个底:
ab -n 100 -c 50 -H "Host: wanghaoyu.com.cn" http://127.0.0.1/Requests per second: 4.65 [#/sec]
Time per request: 5283 [ms] (mean)
Failed requests: 98
99% 9207ms50 并发就 5 秒,300 并发根本不用想。看下单次 PHP 请求要多久:
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" -H "Host: wanghaoyu.com.cn" http://localhost/
# TTFB: 0.215922s单次渲染就要 215ms。拆一下时间:
| 环节 | 耗时 |
|---|---|
| MySQL 28 次查询总计 | 6ms |
| PHP 代码执行 + 模板渲染 | 209ms |
SQL 不是瓶颈,PHP 执行本身才是问题。
300 并发下每个 PHP-FPM worker 要占 200ms,至少需要 60 个 worker 才能不排队。但这台机器只有 3.6GB 内存,每个 PHP-FPM 进程吃 40MB,内存最多撑 35 个 worker。
堆进程走不通。只有一个办法:别让它跑 PHP。
架构调整#
优化前:客户端 → Apache(80/443) → PHP-FPM → MySQL
优化后:客户端 → Nginx(443/TLS) → Varnish(80) → Apache(8080) → PHP-FPM → MySQL上游已经有 Nginx 做 TLS 终止了(CDN 那边),所以本机不需要处理 SSL,简单很多。
核心思路:Varnish 做全页缓存,匿名访客的请求直接从内存返回,只有缓存 MISS、登录用户、POST 请求才穿透到 PHP。
装 Varnish#
yum install -y varnish写 VCL 配置 /etc/varnish/default.vcl:
vcl 4.1;
backend default {
.host = "127.0.0.1";
.port = "8080";
.connect_timeout = 3s;
.first_byte_timeout = 30s;
.between_bytes_timeout = 10s;
.max_connections = 200;
}
acl purge {
"localhost";
"127.0.0.1";
}
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge) { return (synth(405, "Not allowed")); }
return (purge);
}
# POST/登录/后台/API → 穿透不缓存
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
if (req.url ~ "^/bb/") { return (pass); }
if (req.url ~ "^/(action/|feed|sitemap|search|api/|install)") { return (pass); }
# 登录用户 → 穿透(Typecho 用 __typecho_uid cookie 标识登录)
if (req.http.cookie ~ "__typecho_uid") { return (pass); }
# 匿名 GET → 剥离 cookie,走缓存
unset req.http.Cookie;
set req.http.X-Forwarded-Proto = "https";
return (hash);
}
sub vcl_backend_response {
if (beresp.status >= 400 || beresp.status == 302) {
set beresp.uncacheable = true;
return (deliver);
}
# HTML 缓存 5 分钟
set beresp.ttl = 5m;
# 静态资源缓存 1 天
if (bereq.url ~ "\.(jpg|jpeg|png|gif|css|js|woff2?|svg)$") {
set beresp.ttl = 24h;
unset beresp.http.Set-Cookie;
}
# HTML 页面移除 Set-Cookie,避免缓存污染
if (beresp.http.Content-Type ~ "text/html") {
unset beresp.http.Set-Cookie;
unset beresp.http.Pragma;
set beresp.http.Cache-Control = "public, max-age=300";
}
return (deliver);
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
unset resp.http.Server;
unset resp.http.X-Powered-By;
return (deliver);
}踩坑提醒:__typecho_uid 是 Typecho 登录后写入的认证 cookie,通过它识别登录用户并绕过缓存。如果不清除匿名用户的 cookie,会导致每个用户缓存一份(因为 cookie 不同),命中率极低。所以匿名请求必须 unset req.http.Cookie。
配置 Varnish 监听 80 端口,分配 512MB 内存缓存:
mkdir -p /etc/systemd/system/varnish.service.d
cat > /etc/systemd/system/varnish.service.d/custom.conf <<EOF
[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd -a :80 -f /etc/varnish/default.vcl -s malloc,512m -p default_ttl=300
EOF
systemctl daemon-reload
systemctl enable --now varnishApache 退到 8080#
Varnish 接管了 80 端口,Apache 得让出来。
改 /etc/httpd/conf/httpd.conf:
Listen 8080
Timeout 60
KeepAlive On
MaxKeepAliveRequests 100
KeepAliveTimeout 5
ServerLimit 16
ThreadsPerChild 25
MaxRequestWorkers 400
MinSpareThreads 25
MaxSpareThreads 150改 /etc/httpd/conf.d/blog.conf,虚拟主机改为 8080:
<VirtualHost *:8080>
ServerName wanghaoyu.com.cn
ServerAlias www.wanghaoyu.com.cn
DocumentRoot /var/www/wanghaoyu.com.cn
DirectoryIndex index.php
LogFormat "%{X-Forwarded-For}i %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" varnish_combined
ErrorLog /var/log/httpd/wanghaoyu.com.cn-error.log
CustomLog /var/log/httpd/wanghaoyu.com.cn-access.log varnish_combined
<Directory "/var/www/wanghaoyu.com.cn">
AllowOverride All
Require all granted
</Directory>
SetEnvIf X-Forwarded-Proto "https" HTTPS=on
</VirtualHost>踩坑提醒:之前 Apache 还在监听 443,这端口已经由上游 Nginx 处理了。记得在 /etc/httpd/conf.d/ssl.conf 里注释掉 Listen 443 https,不然会冲突。
阅读量怎么办#
这是缓存方案最容易被忽略的坑。
Typecho Handsome 主题的阅读量是在 PHP 渲染时直接 UPDATE 数据库的。Varnish 缓存后,页面直接从内存返回,PHP 根本不执行,阅读量自然不会增长。如果缓存 5 分钟,那这 5 分钟内不管多少人访问,阅读量只 +1(第一个穿透的人)。
解决方案:AJAX 异步统计#
思路很简单:
- PHP 模板的
get_post_view()改为只读(不再 +1) - 新建
/api/views.php接口,负责查询 + 增量(带 cookie 防重复刷新) - VCL 里
/api/views.php强制穿透(永不缓存) - 页面加载后 JS 异步调用接口,拿到最新阅读数填入页面
/api/views.php 核心逻辑:
<?php
header("Content-Type: application/json; charset=utf-8");
header("Cache-Control: no-store, no-cache");
$cid = (int)($_GET["cid"] ?? 0);
if ($cid <= 0) { http_response_code(400); exit; }
// 引入 Typecho
define("__TYPECHO_ROOT_DIR__", dirname(__DIR__));
require_once __TYPECHO_ROOT_DIR__ . "/config.inc.php";
\Typecho\Common::init();
$db = \Typecho\Db::get();
$row = $db->fetchRow($db->select("views")->from("table.contents")->where("cid = ?", $cid));
$views = (int)$row["views"];
// cookie 防重复刷新
$visited = isset($_COOKIE["extend_contents_views"]) ? explode(",", $_COOKIE["extend_contents_views"]) : [];
if (!in_array($cid, $visited)) {
$views++;
$db->query($db->update("table.contents")->rows(["views" => $views])->where("cid = ?", $cid));
$visited[] = $cid;
setcookie("extend_contents_views", implode(",", $visited), [
"expires" => time() + 86400 * 30,
"path" => "/",
"httponly" => true,
]);
}
echo json_encode(["cid" => $cid, "views" => $views]);主题 post.php 里,阅读量显示改成:
<span id="post-views-count" data-cid="<?php echo $this->cid; ?>">
<?php echo get_post_view($this) ?> 次浏览
</span>footer.php 的 </body> 前加一段 JS:
<script>
(function(){
var el = document.getElementById("post-views-count");
if (!el) return;
fetch("/api/views.php?cid=" + el.dataset.cid, {credentials:"same-origin"})
.then(function(r){ return r.json(); })
.then(function(d){
if (d && typeof d.views !== "undefined")
el.innerHTML = d.views + " 次浏览";
})
.catch(function(){});
})();
</script>functions_mine.php 里的 get_post_view() 简化为只读:
function get_post_view($archive)
{
$cid = $archive->cid;
$db = Typecho_Db::get();
Database::createFiledInTable("views",Database::$type_int_10,"contents");
$row = $db->fetchRow($db->select("views")->from("table.contents")->where("cid = ?", $cid));
return $row["views"];
}这样缓存页面时 PHP 只读一次(被缓存),阅读量增量全走 AJAX 接口,缓存期间阅读量准确增长。
其他顺手优化的#
既然都改了,顺手把几个明显的问题也一起处理了。
PHP-FPM#
原来是 max_children = 50,max_spare_servers = 35,35 个空闲进程常驻,白白吃掉 1.4GB 内存。
pm.max_children = 30
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 12
pm.max_requests = 500加了 pm.max_requests = 500,每个进程处理 500 次请求后重启,防止内存泄漏。
MySQL#
Buffer Pool 从 128MB 翻倍到 256MB。加了几条复合索引:
ALTER TABLE blog_contents ADD INDEX idx_type_status_created (type, status, created);
ALTER TABLE blog_contents ADD INDEX idx_type_status_views (type, status, views);
ALTER TABLE blog_metas ADD INDEX idx_type (type);开了慢查询日志(阈值 2 秒)方便以后排查。
OPcache#
opcache.memory_consumption=128
opcache.revalidate_freq=60revalidate_freq 原来默认 2 秒,每次请求都 stat 文件看有没有变。改成 60 秒,减少磁盘 IO。发布文章后手动清理 Varnish 缓存就行。
验证效果#
先预热缓存,再压测:
# 预热
curl -s -o /dev/null -H "Host: wanghaoyu.com.cn" http://127.0.0.1/
# 看 X-Cache 头
curl -s -I -H "Host: wanghaoyu.com.cn" http://127.0.0.1/ | grep X-Cache
# X-Cache: HIT
# 压测 300 并发
ab -n 2000 -c 300 -H "Host: wanghaoyu.com.cn" http://127.0.0.1/Requests per second: 13903.08 [#/sec]
Time per request: 21.578 [ms] (mean)
Complete requests: 2000
Failed requests: 0
50% 21ms
95% 26ms
99% 34ms再试试 500 并发:
ab -n 5000 -c 500 -H "Host: wanghaoyu.com.cn" http://127.0.0.1/Requests per second: 12801.80 [#/sec]
Time per request: 39.057 [ms] (mean)
99% 48ms看 Varnish 缓存命中率:
varnishstat -1 | grep cacheMAIN.cache_hit 6999 99.99%
MAIN.cache_miss 1 0.01%缓存清理#
发布新文章后需要清理缓存,不然访客看到的是旧内容。写了个脚本放到 /root/:
# 全部清理
/root/clean_varnish_cache.sh
# 清理指定页面
/root/clean_varnish_cache.sh /archives/200.html也可以等 5 分钟自动过期,看需求取舍。
一点感受#
单次 PHP 渲染 215ms 对静态博客来说太奢侈了。Typecho 首页就 28 次 SQL 查询,MySQL 只要 6ms,剩下 209ms 全花在 PHP 代码和模板上。对于大部分访客来说,看到的内容在缓存期内不会变,没必要每次都跑一遍 PHP。
Varnish 的缓存命中响应是 0.2ms,比 PHP 快了 1000 倍。这就意味着 Varnish 面对并发时几乎不消耗资源,所有压力都绕过了。
阅读量异步化这个设计值得单独说。很多缓存方案直接忽略这个问题,但这在博客场景里很影响体验。用 AJAX 接口做穿透处理,VCL 里加一行
pass就搞定了,复杂度不高。3.6GB 跑 Typecho 博客完全够用,关键是用对缓存。优化前 PHP-FPM 空闲驻留 35 个进程吃掉 1.4GB,优化后只有 4 个 worker,可用内存从 1.9GB 涨到 2.2GB。
上游有 Nginx 做反代的情况下,加 Varnish 特别简单。不用处理 TLS,不用改 DNS,Varnish 只监听 80 端口做 HTTP 缓存就行。
相关链接: