Memcached 应用层缓存实战:安装调参、PHP 接入、命中率观测与和 Redis 的分工边界

Memcached 没有过时:给个人站点做一层纯内存缓存的正确姿势

很多人一提缓存就想到 Redis,觉得 Memcached 是"上一代的东西"。这个判断在功能层面没错——Memcached 确实没有持久化、没有主从、没有数据结构、没有 Lua。但在单个个人站的场景里,这些"缺失"恰恰是它的优点:它只做一件事,就是把 key 映射到一段字节,放在内存里,取的时候快到接近内存寻址。没有 AOF 重写、没有 RDB fork、没有复杂的淘汰策略配置,运维心智负担几乎为零。如果你的缓存需求就是"把数据库查出来的那坨结果原样存一会儿",Memcached 比 Redis 更省心。

Memcached 和 Redis 的分工可以这样看:Redis 适合做有结构、需要原子操作、需要持久化或需要发布订阅的东西(队列、排行榜、分布式锁、会话共享);Memcached 适合做纯粹的"键值结果缓存",尤其是多实例、纯读、不需要落地的场景。WordPress 的 object-cache.php、Discuz 的表缓存、Typecho 的插件缓存,基本都是这个模式。

安装与最小可用配置

Memcached 本身极简,Debian/Ubuntu 直接装:

apt update
apt install -y memcached libmemcached-tools

# 查看服务状态
systemctl status memcached

# 用 nc 或自带的 stats 工具查看运行指标
echo -e "stats\nquit" | nc -q1 127.0.0.1 11211

默认配置里几个参数必须改,否则不是性能问题就是安全问题。配置文件在 /etc/memcached.conf:

# 只监听本地回环,绝对不要 0.0.0.0(Memcached 无认证,暴露公网等于把内存交出去)
-l 127.0.0.1

# 内存上限,按站点数据量与机器剩余内存给(默认 64M 通常不够)
-m 256

# 单条 value 上限,默认 1M,缓存大对象(如整页 HTML)会超
-I 4m

# 最大连接数,默认 1024,高并发小站够用
-c 2048

# 运行用户,默认 memcache,保持非 root
-u memcache

# 线程数,与 CPU 核心数匹配即可
-t 4

改完 systemctl restart memcached。-l 127.0.0.1 这条是安全底线——Memcached 走的是无认证的文本协议,一旦监听公网,任何能扫到 11211 端口的人都可读写你的全部缓存,历史上多次因为这个配置导致的数据库泄露事件都是这么来的。如果确实要跨机共享缓存(比如多台 Web 服务器共用一个缓存节点),务必用防火墙只放行 Web 服务器的内网 IP,或者用 stunnel/SSH 隧道加密。

核心参数:先把 maxconns 和内存淘汰弄明白

Memcached 的行为由几个关键参数决定,搞错会导致"缓存看起来在跑,实际命中率极低"。

参数含义常见误区
-m分配给缓存的总内存(MB)设太小会频繁淘汰,设太大会挤占 PHP/MySQL 内存
-I单条 value 最大字节数默认 1M,缓存整页 HTML 或大数组时超限,写入静默失败
-c最大并发连接数按 QPS×平均响应时间估算,默认 1024 对高并发站偏小
slab内存分块,按 value 大小归类不同大小的 value 分属不同 slab,容易「碎片化」浪费内存
LRU内存满时淘汰策略默认近似 LRU,不是严格的最近最少使用

其中 slab 机制最容易被忽视。Memcached 启动时把内存切成若干个 slab class,每个 class 管一种大小区间的 chunk。你存一个 100 字节的 key,它会分给你一个该 class 的整块(比如 96 或 120 字节),剩下的浪费掉。如果站点的缓存对象大小分布很散,就会看到 stats slabs 输出里某些 class 用满了、另一些几乎空着,总内存却报"用尽"。解决办法是把缓存对象的序列化尺寸尽量统一,或者接受一部分浪费,把 -m 给足。

用 PHP 接入:两行代码看到差别

Memcached 的 PHP 扩展是 memcached(不是老旧的 memcache)。安装:

apt install -y php-memcached
systemctl restart php8.2-fpm   # 按你的 PHP 版本调整

基础用法就是 get / set:

<?php
$mc = new Memcached();
$mc->addServer('127.0.0.1', 11211);

// 设置选项:序列化器与压缩阈值
$mc->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY);
$mc->setOption(Memcached::OPT_COMPRESSION, true);
$mc->setOption(Memcached::OPT_COMPRESSION_THRESHOLD, 2048);

$key = 'home_latest_posts';
$data = $mc->get($key);

if ($data === false) {
    // 缓存未命中,回源查库
    $data = $pdo->query("SELECT id,title FROM posts ORDER BY id DESC LIMIT 20")->fetchAll();
    // 缓存 300 秒
    $mc->set($key, $data, 300);
} else {
    // 命中
}

foreach ($data as $row) {
    echo htmlspecialchars($row['title']), "\n";
}

这里有三个必须理解的坑。第一,get 未命中返回 false,但如果你缓存的值本身就是 false 或空数组,就无法区分"未命中"和"命中但值为空"。正确做法是用 Memcached::getResultCode() 判断到底是 RES_NOTFOUND 还是真的取到了值。

$data = $mc->get($key);
if ($data === false && $mc->getResultCode() === Memcached::RES_NOTFOUND) {
    // 确实是未命中
}

第二,key 的设计要带版本号或前缀,方便整体失效。比如 posts:v3:latest,当数据结构变化时把 v3 改成 v4,旧缓存自然作废,不需要遍历删除。

第三,缓存过期时间要留随机抖动。如果所有缓存都在同一秒写入、同一秒过期,会造成"缓存雪崩"——同一时刻大量请求同时回源,数据库瞬间被压垮。给 TTL 加个 ±10% 的随机量即可:

$ttl = 300 + random_int(-30, 30);
$mc->set($key, $data, $ttl);

命中率才是唯一值得看的指标

缓存上线后不要凭感觉说"好像快了",要看数字。stats 里的 get_hits 和 get_misses 是核心:

echo -e "stats\nquit" | nc -q1 127.0.0.1 11211 | grep -E "get_hits|get_misses|curr_items|evictions|limit_maxbytes|bytes"

# 关键几行:
# STAT curr_items 1823          当前缓存的 key 数量
# STAT get_hits 92831           命中次数
# STAT get_misses 7104          未命中次数
# STAT evictions 0              因内存满被淘汰的 key 数
# STAT bytes 67108864           实际使用字节
# STAT limit_maxbytes 268435456 配置的上限

命中率 = hits / (hits + misses)。个人站的内容型缓存,命中率 85% 以上才算正常,90% 以上是好。如果偏低,通常是这几个原因:TTL 设得太短(写一次读一次就过期)、key 里带了随机或用户相关成分(每个人 key 都不一样,等于没缓存)、或者缓存容量太小频繁 eviction。

evictions 这个数字特别重要。只要它持续增长,就说明内存不够、老 key 被不断踢掉,你看到的命中率数字是被"内存不足"扭曲过的。这时第一动作是加大 -m,而不是去调 TTL。

与 Redis 并存不冲突:一套现实的分工

不少站点最后是 Memcached 和 Redis 一起用,这并不矛盾,关键是分工清晰:

  • Memcached 管纯读结果缓存:首页文章列表、分类页、热门标签、单篇渲染结果。特点是只读、可随时丢弃、丢了回源代价可控。
  • Redis 管有状态的东西:用户会话(session)、限流计数器、消息队列、需要原子增减的统计数。特点是需要操作、需要持久化或需要跨请求共享可变状态。

如果只有一台内存小的 VPS,二选一时,做内容站优先 Memcached(省内存、无 fork 抖动),做有交互功能的应用优先 Redis。两者都装但只用一个也常见——不要为了"齐全"把两个都跑起来徒增维护面。

那些容易翻车的地方

第一,缓存穿透:查询一个数据库里根本不存在的 key(比如被爬虫遍历不存在的文章 ID),每次都穿透缓存打到数据库。对策是对"查不到"的结果也做短时缓存(比如缓存一个空值 60 秒),或者用布隆过滤器挡在前面。

第二,雪崩:前面说过 TTL 加抖动。更彻底的做法是热点数据永不过期,改用后台定时任务主动刷新。

第三,重启即全丢:Memcached 是纯内存,重启后所有缓存消失,瞬间全部回源。不要在流量高峰期重启它。如果站点扛不住冷启动的全量回源,要么错峰重启,要么在重启前预热热点 key。

第四,不要把 Memcached 当数据库用:它不持久化,也没有事务。任何"丢了就麻烦"的数据都不能只放 Memcached。它的定位永远是"加速层",真相源在数据库。

第五,-I 调大的代价:把单条 value 上限从 1M 调到 4M,意味着 slab 的最大块也变大,配合 -m 太小时会出现"存得下但一两条就吃满一个 slab"的尴尬。调 -I 之前先估算最大的缓存对象到底多大(用 strlen(serialize($data)) 量一下)。

结语:选工具看的是匹配度,不是新旧

Memcached 诞生于 2003 年,它没有跟上后来 Redis 那种"什么都能做"的路线,这正是它在"纯缓存"这个细分定位上依然好用的原因。对个人站长来说,评估一个缓存方案的标准很朴素:能不能让数据库压力降下来、配置和维护是否简单、出问题时是否容易看懂。Memcached 在这三点上的表现,二十年后依然合格。

如果你的站点读多写少、缓存对象是整段结果、且不需要任何额外的数据结构,那用 Memcached 就对了,不必因为"Redis 更流行"而多背一层复杂度。工具的价值在于合适,不在于声量。

Last modification:October 1st, 2026 at 09:25 pm

Leave a Comment