跳过正文

Varnish 缓存优化 Typecho 博客:300 并发从 5 秒降到 20 毫秒

·1002 字·5 分钟
作者
王浩宇
记录服务器、虚拟化、网络和日常折腾。

博客跑在一台 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
平均响应5283ms21.6ms
P99 响应9207ms34ms
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%   9207ms

50 并发就 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 varnish

Apache 退到 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 异步统计
#

思路很简单:

  1. PHP 模板的 get_post_view() 改为只读(不再 +1)
  2. 新建 /api/views.php 接口,负责查询 + 增量(带 cookie 防重复刷新)
  3. VCL 里 /api/views.php 强制穿透(永不缓存)
  4. 页面加载后 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) ?>&nbsp;次浏览
</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 + "&nbsp;次浏览";
        })
        .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 = 50max_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=60

revalidate_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 cache
MAIN.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 分钟自动过期,看需求取舍。

一点感受
#

  1. 单次 PHP 渲染 215ms 对静态博客来说太奢侈了。Typecho 首页就 28 次 SQL 查询,MySQL 只要 6ms,剩下 209ms 全花在 PHP 代码和模板上。对于大部分访客来说,看到的内容在缓存期内不会变,没必要每次都跑一遍 PHP。

  2. Varnish 的缓存命中响应是 0.2ms,比 PHP 快了 1000 倍。这就意味着 Varnish 面对并发时几乎不消耗资源,所有压力都绕过了。

  3. 阅读量异步化这个设计值得单独说。很多缓存方案直接忽略这个问题,但这在博客场景里很影响体验。用 AJAX 接口做穿透处理,VCL 里加一行 pass 就搞定了,复杂度不高。

  4. 3.6GB 跑 Typecho 博客完全够用,关键是用对缓存。优化前 PHP-FPM 空闲驻留 35 个进程吃掉 1.4GB,优化后只有 4 个 worker,可用内存从 1.9GB 涨到 2.2GB。

  5. 上游有 Nginx 做反代的情况下,加 Varnish 特别简单。不用处理 TLS,不用改 DNS,Varnish 只监听 80 端口做 HTTP 缓存就行。


相关链接