Memcached命中率低的六个原因与内存碎片排查

Memcached 是个很奇怪的东西:它几乎是最简单的一种缓存——纯内存、无持久化、只认 key-value、不支持复杂查询。正因为简单,很多人装上就以为完事,结果上线半年后才在某个凌晨发现「缓存命中率只有 30%,还老是被打满」。这篇讲的是 Memcached 在个人站长场景下的真实用法和踩过的坑,尤其是命中率低的六种原因以及它和 Redis、APCu 该谁干什么活。

一、先分清它和 Redis 的活不是同一件事

现在一提到缓存就上 Redis,但对一台单机的小站来说,Memcached 在某些方面依然更合适:

  • 纯内存、多线程:Memcached 用 libevent 多线程模型,能真正吃满多核,缓存读写这种超短操作上延迟极低。Redis 是单线程命令处理,虽然也够快,但在多核机器上压满单核后就没法再涨。
  • LRU 淘汰是内建的:内存满了自动按 LRU 踢掉旧数据,不会像 Redis 那样在 maxmemory-policy noeviction 时直接报错。
  • 没有持久化负担:不用操心 RDB/AOF 的写盘抖动,重启就是清空——对纯缓存场景这正是你要的语义。

反过来,如果你需要队列、排行榜、分布式锁、数据要能重启后还在,那 Redis 是唯一选择。**判断标准很简单:这份数据丢了能不能从数据库重算出来?能,就用 Memcached;不能,就别用缓存当存储。**

二、安装与最小可用配置

apt install memcached libmemcached-tools -y

# 关键参数(Debian 12 在 /etc/memcached.conf)
-d                  # 守护进程
-m 512              # 内存上限 MB,按机器总内存 10%~25% 给
-c 1024             # 最大连接数,默认只有 1024
-l 127.0.0.1        # 只监听本机!默认可能是 0.0.0.0
-p 11211
-t 4                # 线程数,一般等于核数
-I 2m               # 单个 item 大小上限,默认 1m
-o modern           # 启用新版 LRU 与 slab 重平衡
-o slab_reassign,slab_automove=1

这里有两个必须改的默认值。-l 127.0.0.1:Memcached 完全没有认证机制,暴露在公网等于把内存送给别人用,我见过被打成 DDoS 反射放大的案例(UDP 端口开着,放大倍数上千)。-c 1024:默认连接数很低,PHP-FPM 每开一个 worker 就可能占一条连接,200 个 worker 加上 CLI 脚本很容易撞上限,表现为 Too many open connections。

验证是否只监听本地:

ss -lntup | grep 11211
# 应该是 127.0.0.1:11211,不是 0.0.0.0:11211 或 *:11211

# 若开了 UDP,确认关闭(-U 0)
systemctl cat memcached | grep ExecStart

三、命中率排查:stats 里到底该看哪几个数

memcached-tool 127.0.0.1:11211 stats 会吐出一大堆指标,实际只需要盯六个数:

memcached-tool 127.0.0.1:11211 stats | grep -E \
'cmd_get|cmd_set|get_hits|get_misses|evictions|curr_items|limit_maxbytes|bytes'

# 命中率 = get_hits / (get_hits + get_misses)
# 淘汰数 = evictions
# 内存占用 = bytes / limit_maxbytes

一个健康的缓存应该是:命中率 85% 以上,evictions 增长缓慢(或者为 0),bytes/limit_maxbytes 在 70% 以下。三条判据凑在一起才能定位问题:

  1. 命中率低 + evictions 为 0:缓存根本没装下东西,或者 key 一直在变。这是代码问题,不是容量问题。
  2. 命中率低 + evictions 疯涨:内存不够,LRU 一直在踢。要么加内存,要么缩短 TTL,要么减少缓存内容。
  3. 命中率高 + 内存打满:看着好,其实随时可能雪崩。要提前扩容或者做分级。

四、命中率低的六个真实原因

原因 1:key 里带了时间戳或随机数

这是最蠢也最常见的一种。新手写缓存喜欢拼 md5(time()) 或者把 $_SERVER['REQUEST_TIME'] 带进去,结果是每次都 set 一个新 key、每次都 miss。检查方法:正常跑十分钟,看 cmd_set 与 cmd_get 的比例,如果 set 接近 get,几乎可以确定 key 在变。

# 观察 30 秒内 set/get 的增量
a1=$(memcached-tool 127.0.0.1:11211 stats | awk '/cmd_set/{print $2}')
sleep 30
a2=$(memcached-tool 127.0.0.1:11211 stats | awk '/cmd_set/{print $2}')
echo "30 秒内新增 set: $((a2-a1))"

原因 2:TTL 设得太短

很多人出于「怕数据旧」的心理,把 TTL 设成 60 甚至 10 秒。结果缓存刚热起来就过期了,每次都要回数据库。合理做法是按数据变化频率倒推 TTL:首页 5 分钟、栏目页 10 分钟、文章详情页 30 分钟到 2 小时、几乎不变的内容(站点配置、友链)设 1 天。

更稳的做法是「逻辑过期」:TTL 设得很长(比如 24 小时),但在 value 里存一个软过期时间,读到时超过软过期就返回旧值同时异步刷新。这样永远不会出现「过期瞬间大量请求打到数据库」的雪崩。

原因 3:单个对象超过 1MB 被静默丢弃

Memcached 默认单 item 上限 1MB(-I 2m 改后是 2MB)。超过上限的 set 会失败,返回 SERIALIZED 或者干脆 false,取决于客户端。如果你缓存的是整个页面 HTML 或者大数组,很容易踩到。

# slab 页大小决定单 item 上限
memcached-tool 127.0.0.1:11211 display
# 注意 item_size_max 一行

# 检查是否有 put 失败
memcached-tool 127.0.0.1:11211 stats | grep -E 'store_no_memory|store_too_large'

store_too_large 不为 0 就是有对象超限。别急着调到 8MB——大对象会占掉整个 slab class,造成内存碎片和淘汰不均。正确做法是把大对象拆成小块,或者干脆别缓存它。

原因 4:内存被不同大小的对象挤成碎片

Memcached 用 slab allocator,内存按固定大小的 chunk 分组成若干 slab class(96 字节、120 字节、152 字节……)。如果你缓存的对象大小分布极不均匀,可能出现「总内存还有 200MB,但 128KB 那个 class 已经满了」的情况。

memcached-tool 127.0.0.1:11211 display
# slab  class   size  chunk  used  chunks
#    1      1     96      4    47     ...
#   10     10   1184    128   500     ...
# evicted 列不为 0 的那个 class 就是热点

# 开自动重平衡(启动参数)
-o slab_reassign,slab_automove=1

开了 slab_automove=1 后,Memcached 会自动把空闲 slab page 分给吃紧的 class。slab_automove=2 更激进(每秒检查一次),小机器上反而会占用 CPU。如果你不想调,那就统一 key 的大小分布:页面缓存和列表页缓存分到不同的 Memcached 实例,各自的内存池不会互相抢。

原因 5:多台机器各存各的,命中率被摊薄

两台 Web 服务器各跑一个 Memcached,PHP 配置里只写了一台,或者客户端哈希算法不一致,就会出现「这台机器缓存的东西那台读不到」。要么用一致性哈希把 key 分到所有节点(这样每台都只存一部分,命中率是全局的),要么干脆各存各的(局部命中率更低但简单)。最怕的是混用:有些代码读 A、有些读 B,命中率永远上不去。

原因 6:缓冲被外部条件挡住

最容易被忽略的一种:缓存本身工作良好,但上层的页面已经被 fastcgi_cache 或 CDN 挡住了,PHP 根本没执行,cmd_get 自然不涨。这不算坏事——说明缓存命中了更外层,但会让你看着指标困惑。判断方法:对比 cmd_get 与站点访问量的比例,如果相差十倍以上,说明大部分请求在更外层就被拦下了。

五、PHP 侧的关键配置

; php.ini 或 pool 配置
extension=memcached.so
; 注意 memcache 和 memcached 是两个不同的扩展,别搞混

; 连接池与超时(重要)
memcached.sess_lock_wait_min = 150
memcached.sess_prefix = "sess_"

; 代码里必须显式设置超时
$mc = new Memcached();
$mc->setOption(Memcached::OPT_CONNECT_TIMEOUT, 100);   // 毫秒
$mc->setOption(Memcached::OPT_SEND_TIMEOUT, 100);
$mc->setOption(Memcached::OPT_RECV_TIMEOUT, 100);
$mc->setOption(Memcached::OPT_POLL_TIMEOUT, 100);
$mc->setOption(Memcached::OPT_RETRY_TIMEOUT, 2);
$mc->addServer('127.0.0.1', 11211);

超时是必须设置的。默认超时是好几秒,一旦 Memcached 卡住(比如重启中、内存 swap),PHP 会跟着卡好几秒,然后 Nginx 502。缓存挂了应该是「变慢」,不能是「挂掉」——所有缓存操作都要 try/catch 包住,读失败就当 miss 走数据库。

另外注意 OPT_BINARY_PROTOCOL:开了它可以用更高效的二进制协议,但某些老客户端混用会读不出数据。如果你在同一个实例上跑两种语言的应用,先确认都用二进制模式再开。

六、会话(session)放 Memcached 的选择

把 PHP session 放到 Memcached 是最常见的用法之一,但它有个致命弱点:Memcached 重启,所有用户掉线。如果你能接受「重启时用户重新登录」,那它是非常合适的——session 读写频繁、体积小、允许丢。

; php.ini
session.save_handler = memcached
session.save_path = "127.0.0.1:11211"
session.gc_probability = 0   ; 由 Memcached 的 TTL 负责过期

注意这里的 session.save_path 写法和文件模式完全不同——不要写成 tcp:// 前缀,也不要写路径。写错的表现是「登录后立刻掉登录」,非常浪费排查时间。

七、什么时候该放弃 Memcached

最后说个不讨好但真实的判断:**如果你的站日 PV 不到 5 万,且只有一台机器,Memcached 往往是过度设计。** APCu 比它更近一层(进程内共享内存,零网络开销),对接 PHP 的数组缓存场景命中率更高、延迟更低(微秒级对毫秒级)。等到进程间不共享成了瓶颈(多台机器、或者要跨语言共享),再引入 Memcached。

我自己的一个内容站就是这么演进的:单机时期用 APCu,加了第二台 Web 之后换成 Memcached 存列表和会话,数据库前面再加一层由 MySQL 自动维护的 InnoDB buffer pool。三层缓存各管一段,不互相越界——这才是稳定的做法,而不是「一个 Redis 解决所有问题」。

Last modification:October 22nd, 2026 at 09:26 pm

Leave a Comment