一、第一现场:缓存服务变“蜗牛”

线上环境出了问题,很多人第一反应是去查代码逻辑,可是有时候代码很简单,就是从Memcached里取一段数据,却莫名其妙地慢到了让人抓狂的地步。一台实例,CPU使用率明明不高,内存也没满,但请求延迟就是忽高忽低,甚至让后端的数据库都跟着遭殃。这时候,如果你是个有经验的工程师,会立刻把目光从代码上移开,去看看操作系统、硬件虚拟化这些“更底层”的家伙。因为有些事情,光靠业务代码是解释不了的。

本文就想用大白话,把我在云环境里部署Memcached时遇到的一个坑,以及最后怎么通过NUMA架构和内存分配策略的调优爬出来,完整地讲一讲。整个过程不涉及特别高深的理论,但很实用。

二、先说说“超卖”是什么鬼

2.1 超卖的基本概念

“超卖”这个词,最早是从机票酒店那里来的,意思是你买了票,但可能根本没座位。把这个概念放到云上,就特别形象。云厂商手里有一台物理机,配置了32核CPU,128G内存,然后把这台物理机切成好多台虚拟机器卖给你。为了保证利润,他们往往不会严格地按比例配额来,而是让虚拟机的规格总和超过物理机的实际容量。这样一来,你买到的所谓“4核8G”,实际上可能只是“有机会用到那么多资源”,而不是“独占那么多资源”。

2.2 超卖对Memcached意味着什么

Memcached是一个极度依赖内存和CPU的服务。每一条缓存数据,都得在内存里住着,每次读写,都得消耗一点CPU时间。而在超卖严重的云环境里,你的虚拟CPU时间片可能会被邻居抢占,内存页也可能被换入换出。更头疼的是,如今的主流云服务器几乎都是多路CPU加NUMA架构,而虚拟化层和操作系统的默认调度,并不会特意为Memcached考虑物理内存的远近问题。于是,性能瓶颈就自然而然地冒出来了。

三、NUMA架构:内存也有“远近亲疏”

3.1 从SMP到NUMA

好久以前的服务器,CPU和内存的关系很简单,大家共享一条总线,所有CPU访问内存都一样快,这种叫SMP(对称多处理器)。后来CPU核数越来越多,共享总线就成了瓶颈。于是硬件工程师想出了NUMA(非均匀内存访问)这样的招数:把CPU和内存分成好几个“小组”,每个小组里有自己的CPU核心和一块本地内存。CPU访问自己小组里的本地内存,速度飞快;访问别的组的内存,就要跨过一条互联通道,慢上一截。这个“本地”和“远端”的差别,就叫NUMA距离。

3.2 查看你机器的NUMA拓扑

要想知道你的机器有没有NUMA,可以使用Linux下的命令。我以Shell环境为例,手把手演示一下。

技术栈:Linux Shell


# 查看NUMA节点的数量以及CPU和内存的分布情况
numactl --hardware

# 输出示例(实际机器可能不同):
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 4 5 6 7
# node 0 size: 32768 MB
# node 0 free: 21568 MB
# node 1 cpus: 8 9 10 11 12 13 14 15
# node 1 size: 32768 MB
# node 1 free: 21000 MB
# node distances:
# node   0   1
#   0:  10  21
#   1:  21  10

可以看到,这里有两个NUMA节点,每个节点各有8个CPU和32G内存。节点0访问节点1的内存,距离是21,而访问自己的内存是10,快了将近一倍。如果你的Memcached进程被调度到了节点0的CPU上,但内存却大部分在节点1上,那就相当于你住酒店,床在20楼,厕所却在一楼,每次上厕所都得跑断腿。

四、Memcached的内存分配策略

4.1 slab机制简介

Memcached不像普通程序那样频繁malloc/free,它一开始就申请大块内存(默认64MB),然后切成若干个小块,这叫slab。每个slab又分成很多固定大小的chunk,不同slab class的chunk大小不一样,从96字节到几百K都有。当你存一条数据时,Memcached会挑选一个大小最合适的slab class,把数据放进去。这样的好处是内存碎片少,分配速度快,坏处是如果你存储的数据大小分布很随意,容易出现浪费。比如一条100字节的数据,可能被扔进128字节的chunk里,白白浪费28字节。这个问题不大,但要知道。

4.2 启动Memcached时常用的内存参数

部署Memcached时,我们一般通过启动参数来控制内存大小、连接数、最大对象大小等等。下面给出一个常用的启动命令示例:

技术栈:Linux Shell


# 启动Memcached,指定监听端口11211,分配1024MB内存
# -m 指定内存大小,单位MB
# -p 指定端口
# -l 绑定监听地址
# -u 指定运行用户
# -c 最大并发连接数
# -I 指定单个item的最大字节数,这里设置为4MB
memcached -d -m 1024 -p 11211 -l 127.0.0.1 -u nobody -c 65535 -I 4m

启动之后,我们可以用memcached-tool命令查看slab的分布情况,这个工具是Memcached官方仓库里自带的,很方便:

技术栈:Linux Shell


# 显示memcached的slab统计信息,包括每个class的chunk大小、使用量等
memcached-tool 127.0.0.1:11211 stats

4.3 内存锁和预分配

为了不让操作系统把Memcached的内存页给“换”到后端,我们通常会加上锁内存的参数。还有一种做法是配置进程的内存锁定限制,让Memcached启动时一次性把所有内存锁在物理内存中。不过这个操作需要root权限,而且锁内存失败会导致启动失败,所以生产环境中要根据实际内存余量谨慎使用。

五、最头疼的现场:超卖+NUMA双重夹击

5.1 故障现象实例

当时我管理的容器平台上面,一台云主机里跑着好几个服务,其中就有一个Memcached。那台宿主物理机的CPU和内存都被其他租户超卖得很厉害。我检查进程时发现,Memcached的CPU使用率只有百分之三十,内存也足够,但延迟飙到了几百毫秒。用系统自带的numastat一测,发现Memcached被调度到了node 0的CPU上,但它申请的内存却有将近八成分配到了node 1。这就是典型的“CPU和内存离得远”问题。

技术栈:Linux Shell


# 查看Memcached进程的NUMA内存分配情况,从中可以看出本地和远端的比例
numastat -p $(pgrep memcached)

# 输出示例:
# Per-node process memory usage (in MBs)
# PID  Node 0 Node 1
# 1234  1200  4800

1200MB在node0,4800MB在node1,本地命中率只有20%,大量访问跨NUMA节点,再加上虚拟化层的超卖,不慢才有鬼。

5.2 用numactl强制绑定

既然知道是NUMA惹的祸,最直接的办法就是把Memcached的CPU和内存都绑定到同一个NUMA节点上。如果你的Memcached所需的全部内存远小于单个节点的内存容量,那这个方案最简单有效。比如上面那个场景,单节点有32G,Memcached只有6G,完全可以绑在node0上。命令如下:

技术栈:Linux Shell


# 使用numactl将Memcached进程绑定到node0的CPU上,并且优先在node0上分配内存
# --cpunodebind=0 表示CPU绑定到node0
# --preferred=0  表示优先在node0上分配内存
numactl --cpunodebind=0 --preferred=0 \
  memcached -d -m 6144 -p 11211 -l 127.0.0.1 -u nobody -c 65535 -I 4m

注意,硬性绑定内存的方式虽然彻底,但有个风险:如果node0内存不够,它宁可申请失败也不去node1,这样可能直接导致Memcached启动失败或者在运行期间OOM。所以更稳妥一点是采用优先分配内存的方式,先让内存尽量落在node0上,实在不够了才允许跨节点使用,这样既照顾了速度,又保证了可用性。

5.3 调整进程的NUMA策略

除了启动时绑定,还可以在Memcached已经运行的情况下,用numactl修改内存分配策略。不过改起来比较麻烦,而且会影响性能,一般不建议。我更推荐把绑定参数写到systemd服务文件里,这样每次开机都生效。下面用一段Shell脚本来演示如何生成一个带NUMA绑定的systemd服务配置:

技术栈:Linux Shell


# 使用cat把配置写入systemd服务文件
cat > /etc/systemd/system/memcached.service <<'EOF'
[Service]
ExecStart=/usr/bin/numactl --cpunodebind=0 --preferred=0 /usr/bin/memcached -m 6144 -p 11211 -l 127.0.0.1 -u nobody -c 65535 -I 4m
EOF

# 重新加载systemd配置并启动服务
systemctl daemon-reload
systemctl restart memcached

5.4 调整slab大小来缓解浪费

有时候Memcached的缓存数据大小跨度很大,比如登录状态的session可能只有几十字节,但某些序列化后的对象有几百K。如果slab class设置不合理,大对象占用大chunk,导致小对象浪费内存,内存利用率低,从而在超卖环境下更容易触发内存不足。我们可以通过调整最大对象大小参数和最小chunk参数来更精细地管理内存。下面命令演示如何启动一个更精细配置的Memcached:

技术栈:Linux Shell


# -n 48 表示slab最小chunk大小为48字节,适合存放大量小数据
# -I 2m 设置单个item最大为2MB,防止超大对象占用过多内存
# -t 4 使用4个处理线程,配合NUMA绑定效果更好
numactl --cpunodebind=0 --preferred=0 \
  memcached -d -m 6144 -p 11211 -l 127.0.0.1 -u nobody -c 65535 -t 4 -n 48 -I 2m

另外,Memcached支持slab内存页的自动重分配。当某个slab class用完了内存,而其他class还有空闲页时,会自动从其他class那里“借”内存。这个行为在官方叫slab reassign。我们可以通过启动参数开启,让内存使用比例更贴近实际的数据分布:

技术栈:Linux Shell


# 开启slab自动重分配和自动移动
numactl --cpunodebind=0 --preferred=0 \
  memcached -d -m 6144 -p 11211 -l 127.0.0.1 -u nobody -c 65535 \
  -o slab_reassign,slab_automove

六、其他辅助调优策略

6.1 调整内核参数

有些云主机默认的内核配置并不适合Memcached这种延迟敏感型服务。比如我们可以关掉THP(透明大页),减少内存分配时的停顿;也可以调低swap的使用倾向,让内存页尽量留在物理内存里。通过sysctl可以做到:

技术栈:Linux Shell


# 关闭THP的启用,减少后台内存碎片整理带来的卡顿
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 降低swap使用倾向,数值范围0-100,越小越不爱用swap,这里设为10
sysctl -w vm.swappiness=10

# 增大虚拟内存映射数量限制,有的系统默认值可能导致memcached锁定内存失败
sysctl -w vm.max_map_count=655300

需要注意的是,修改这些参数需要root权限,而且最好写入/etc/sysctl.conf让它永久生效,不过我这里为了演示直接用sysctl命令。

6.2 验证优化效果

调优不能靠感觉,必须拿出数据。启动完Memcached之后,我们可以再用numastat看看本地内存命中率。如果本地命中率从20%跑到了接近百分之百,那说明绑定生效了。同时,可以使用memcached-tool来看各项统计指标,比如get命中次数、换出次数,来确认缓存质量。

技术栈:Linux Shell


# 查看memcached的统计信息,包括命中率、当前连接数、换出次数等
memcached-tool 127.0.0.1:11211 stats

# 如果没有安装,可以用包管理器安装
# yum install -y memcached-tools

七、这个方案的应用场景和优缺点

7.1 适用场景

如果你是在物理机或者有CPU绑定的裸金属云主机上部署Memcached,NUMA绑定会让性能提升非常明显。尤其是你的业务对缓存访问延迟很敏感,比如做商品库存、秒杀活动、用户会话,这些场景都要求毫秒级甚至亚毫秒级的响应。

另外,如果服务本身内存占用量很大,超过了单个NUMA节点的容量,那就不能简单绑定一个节点了。这时候可以考虑把Memcached拆分成多个实例,每个实例绑定一个不同的NUMA节点,让每个节点各自管理自己的一部分缓存。否则硬绑在一个节点会OOM。这是一个很重要的取舍。

7.2 优点和缺点

这个方案的好处显而易见:降低跨节点内存访问的延迟、减少CPU和内存之间的总线争用、提升缓存请求的稳定性;在超卖严重的云环境里,相当于给Memcached划了一小块“私人领地”。缺点是,它牺牲了云端资源池的弹性。绑定之后,你就不能灵活地使用其他节点上的空闲内存了。如果单个节点内存吃紧,Memcached就会提前OOM,不如不绑的时候能挪一挪。所以要结合业务情况来权衡。

八、注意事项

第一,不要盲目使用硬性绑定内存的模式。如果绑定的节点内存不够,进程可能直接崩溃。优先采用“优先分配”这种柔性策略。

第二,要确保numactl命令存在。基础系统里有时没装,需要用包管理器安装。比如在CentOS上:

技术栈:Linux Shell


# CentOS/RHEL系统安装numactl
yum install -y numactl

Ubuntu/Debian系统则是:

技术栈:Linux Shell


# Ubuntu/Debian系统安装numactl
apt-get install -y numactl

第三,Memcached的线程数不要超过绑定节点的CPU核数,否则线程切换会带来额外开销。使用线程数参数来限制。

第四,不要忘记在云监控里关注宿主机层面的CPU stolen时间。如果CPU stolen很高,说明超卖很严重,这时候光调NUMA也没用,最好申请有专用核的实例。

第五,调整内核参数前先备份,别一上来就在生产机器上乱改。可以先在测试环境跑一轮压测,确认没有负面效果再全量应用。

九、总结

说到底,云环境部署Memcached遇到性能瓶颈,不能只盯着应用本身。超卖是云厂商的常规操作,NUMA是物理硬件的底层现实,这两者叠加在一起,就可能导致你的缓存服务明明看起来资源充足,实际却慢得要命。通过把Memcached的CPU和内存绑定在同一个NUMA节点上,再配合启动参数、slab自动调整、内核参数等辅助手段,可以很大程度上把性能捞回来。整体思路其实很简单:让数据离CPU近一点,让内存别到处乱跑。希望这一次的分享,能让你在下次遇到“魔幻”的缓存延迟时,多几个排查方向。