Linux下面做性能优化、扛高并发,memcached早晚绕不开。这篇博文是我在实际运维和项目开发中反复使用memcached之后整理出来的入门实战笔记,从原理、安装到命令行操作、项目接入、调优排错,一条线讲清楚,新手看完能直接上手干活,老手也能查漏补缺。
1. memcached到底是什么,为什么要学它
1.1 先说清楚它是干什么的
memcached是一个开源的高性能分布式内存对象缓存系统,本质就是一个住在内存里的键值对数据库。你在项目里把热点数据提前塞进去,等真正要读的时候直接从内存拿,不再每次打数据库,这样数据库压力小一大截,接口响应速度也快得多。
它最早是LiveJournal这个网站搞出来的,后来被Facebook、Wikipedia这些流量大户大规模使用。Linux服务器上部署memcached几乎是后端架构的标配技能,跟Nginx、Redis一样属于基础中间件。
我见过很多刚入门的同学把memcached想得太玄乎。其实它的核心就三件事:存、取、过期。存就是set,取就是get,过期就是你设一个存活时间,到了之后自动淘汰。所有高级用法都是从这三件事延伸出来的。你把这个底层逻辑吃透,后面不管接什么语言的项目心里都有底。
1.2 它跟Redis的区别,怎么选
网上讨论最多的就是memcached和Redis的区别,我直接用一张表说明,方便新手上路时做选择:
| 对比项 | memcached | Redis |
|---|---|---|
| 数据类型 | 仅支持字符串键值 | String、Hash、List、Set、ZSet等 |
| 持久化 | 无持久化,重启即丢 | 支持RDB和AOF持久化 |
| 集群模式 | 客户端一致性哈希,服务端无集群 | 官方Cluster模式 |
| 多线程 | 多线程处理网络IO | 默认单线程事件驱动 |
| 内存管理 | Slab Allocator+LRU | 多种内存淘汰策略 |
| 适用场景 | 单纯缓存热点数据 | 缓存+队列+分布式锁等 |
并不是Redis一定比memcached好。如果你的业务就是纯缓存、对数据结构要求简单、希望多核CPU把吞吐量压到极致,memcached依然是很务实的选择。很多老牌高并发系统跑了好多年还是memcached,稳定得很。
1.3 学它之前需要哪些Linux基础
想顺利走完这篇入门,你最好已经掌握几个Linux基本功:会用终端敲命令、会用vim编辑文件、会用systemctl管理服务,懂一点网络端口的基本概念。如果这些还不熟,建议先去把Linux常用命令过一遍,比如grep、ps、netstat、systemctl、firewall-cmd这些,否则后面操作会卡壳。
此外最好知道一点简单的TCP/UDP概念,因为memcached默认走TCP端口11211,后面我们排查连接问题时会用telnet、nc这类工具去手动模拟协议交互,不懂端口和套接字的基础知识,排查起来会像无头苍蝇。
2. 环境准备与安装部署
2.1 CentOS/RHEL系安装memcached
在CentOS或者Rocky Linux这类RHEL系发行版上,安装memcached直接走yum/dnf仓库最省事:
sudo dnf install -y memcached libmemcached装完之后先看一眼版本,确认装上了:
memcached -version我之前遇到过一种情况,部分精简安装的Linux服务器默认仓库里没有memcached包,这时候先执行sudo dnf makecache刷新元数据,再安装一般就能找到。如果还是不行,可以用EPEL源,先sudo dnf install epel-release再装。
libmemcached这个库强烈建议一起装,它是一个C语言客户端库,后面我们用memcstat、memccat这些工具排查数据时要用到,省得自己封装协议。
2.2 Ubuntu/Debian系安装
Ubuntu系用apt安装同样简单:
sudo apt update sudo apt install -y memcached libmemcached-tools装完Debian系之后默认服务是自动启动的,你可以直接检查一下端口:
sudo systemctl status memcached ss -lntp | grep 11211看到11211端口在监听了,说明已经跑起来了。我建议不管哪个发行版,装完都顺手执行一次memcached -h,把帮助信息扫一遍,里面有不少默认参数,对理解配置很有帮助。
2.3 用源码编译安装的方式
如果生产环境对版本有强制要求,或者官方仓库版本太老,就得走源码编译。我从实践角度说,编译安装最核心的依赖是libevent,memcached是基于libevent做的事件驱动模型,这个库必须先搞定。
# 安装编译需要的工具和依赖 sudo dnf install -y gcc make libevent libevent-devel # 下载源码包,这里假设版本号例如1.6.21 wget https://memcached.org/files/memcached-1.6.21.tar.gz tar zxvf memcached-1.6.21.tar.gz cd memcached-1.6.21 # 配置、编译、安装 ./configure --prefix=/usr/local/memcached make -j4 sudo make install这里有个坑,如果./configure时报找不到libevent,说明devel包没装全。RHEL系是libevent-devel,Ubuntu系是libevent-dev,别搞混。
2.4 配置文件详解与建议参数
rpm或apt方式安装的memcached,配置文件一般在/etc/sysconfig/memcached(CentOS系)或者/etc/memcached.conf(Debian系)。CentOS系默认内容大致如下:
PORT="11211" USER="memcached" MAXCONN="1024" CACHESIZE="64" OPTIONS="-l 127.0.0.1,::1"这几个参数我用大白话解释一下:
- PORT:监听端口,一般默认11211不用改。
- USER:以哪个系统用户身份运行,绝不建议用root,权限最小化原则。
- MAXCONN:最大连接数。连接数满了之后新的连接直接拒绝,表现为客户端报连接超时。
- CACHESIZE:单位是MB,这个就是memcached最多能吃掉多少内存。默认64MB太小了,生产环境一般按机器内存的1/4到1/3分配,绝不是越大越好,要留内存给系统本身和应用程序。
- OPTIONS:额外的启动参数,
-l指定监听地址。如果只跑本机,监听127.0.0.1就够了;需要给其他服务器提供缓存服务,就监听内网IP。
我这里放一个生产环境常用的配置参考:
PORT="11211" USER="memcached" MAXCONN="4096" CACHESIZE="1024" OPTIONS="-l 0.0.0.0 -U 0 -m 1024 -t 4"其中-U 0表示关闭UDP端口。memcached默认同时开UDP 11211端口,这个端口历史上出过放大攻击漏洞,非必要直接关掉。-t 4是启用4个工作线程处理网络请求。
2.5 systemd管理memcached服务
改完配置后需要重启服务让配置生效:
sudo systemctl restart memcached sudo systemctl enable memcached检查服务是否正常运行,我习惯用三步确认法:先看进程状态,再看端口监听,最后实际set一个键验证:
systemctl status memcached ss -lntup | grep 11211 printf 'set health 0 0 2\r\nok\r\n' | nc 127.0.0.1 11211nc命令这里如果没装,可以用telnet 127.0.0.1 11211手动连上去做同样的操作。最后一步能在命令行里跟memcached直接对话,非常直观,这也是我强烈建议新手掌握的调试技巧。
防火墙方面,如果开启了firewalld,需要放行11211端口:
sudo firewall-cmd --permanent --add-port=11211/tcp sudo firewall-cmd --reload但这里我多提醒一句:除非业务架构真的需要跨服务器访问,否则别把memcached暴露在非必要的位置。缓存里可能有用户会话、商品数据,一旦裸奔被扫到,数据泄露或者被刷流量都是很麻烦的事。
3. 核心操作:命令行与文本协议实战
3.1 手动用telnet/nc对话memcached
memcached有一个非常朴素的文本协议,直接用telnet或者nc就能模拟客户端操作。我先把最常用的一组命令列出来:
| 命令 | 作用 | 示例 |
|---|---|---|
| set | 新增或覆盖一个键值对 | set name 0 60 5 |
| add | 仅当键不存在时新增 | add name 0 60 5 |
| replace | 仅当键已存在时覆盖 | replace name 0 60 5 |
| get | 读取一个或多个键 | get name age |
| delete | 删除一个键 | delete name |
| incr/decr | 对数值类型的值做原子自增/自减 | incr num 1 |
| stats | 查看统计信息 | stats |
| flush_all | 清空所有数据 | flush_all |
set命令的具体格式要注意,它实际上有四行协议交互。假设我要存一个name,值为Tom:
telnet 127.0.0.1 11211 Trying 127.0.0.1... Connected to 127.0.0.1. Escape character is '^]'. set name 0 60 3 Tom STOREDset name 0 60 3这四个字段拆开看:name是键名,0是标志位(客户端用来区分数据类型,memcached本身不关心),60是存活时间(单位秒),3是value的字节数,必须跟实际字节数一致,多一个少一个都会报错。下一行直接输入Tom,回车,返回STORED就说明存成功了。
这地方新手最容易栽跟头的是字节数数错。字符串Tom刚好3个字节没问题,但如果内容是中文,比如张三,在UTF-8编码下是6个字节,这时候写set user 0 60 6然后输入张三,才会返回STORED。我见过有人在这个细节上卡了半小时,一直报CLIENT_ERROR bad data chunk。
3.2 get查询和delete删除
在telnet里执行:
get name VALUE name 0 3 Tom END返回结果里VALUE name 0 3就是告诉你有这个键,标志位是0,数据长度是3字节。紧接着下一行是真正的数据,然后以END结束。如果get一个不存在的键,直接返回END,不会有VALUE行。
删除操作更简单:
delete name DELETED如果键不存在,会返回NOT_FOUND。实际项目中,delete常用于清除缓存,比如后台改了文章标题,就要立刻把前台对应的缓存删掉,防止用户读到脏数据。
3.3 incr/decr做原子计数
memcached的incr/decr命令在秒杀、限流、统计这类场景非常实用。存一个数值需要先set,注意存活时间设为0表示永不过期:
set visitor_count 0 0 1 5 STORED incr visitor_count 1 6 get visitor_count VALUE visitor_count 0 1 6 END你可能会问,为什么不用get然后程序里+1再set回去?因为那两步操作之间有间隙,高并发下两个请求同时读到旧值,就会丢失一次自增。incr是服务端原子操作,不管多少请求同时打过来,最后的结果一定是准确的。这个特性在做计数器功能时比读改写稳妥得多。
3.4 过期时间的几种写法
set命令第三个参数是过期时间,写法上有两种方式:
- 0到2592000之间的数字,表示相对时间,单位秒,意思是从现在开始多少秒后过期。
- 超过2592000的数字,会当作Unix时间戳处理,表示到某个绝对时间点过期。
为什么是2592000这个数?因为memcached内部约定30天以内的当作相对秒数,超过30天当作绝对时间戳。你不小心把过期时间设成3600000这种大数字,本意是1小时,结果它被解释成了时间戳,缓存瞬间就过期了。我专门在代码评审时抓出过这种bug,新手值得留意。
另外,过期时间不一定被立即物理清除。memcached用的是惰性过期,就是说一个键过期了,但占用的内存并不会马上释放,而是等到下次访问这个键时发现过期,才会标记为可复用。还有一种情况是内存不够时由LRU算法被动淘汰。理解这个机制,就能明白为什么有时stats里的curr_items不会立刻降下来。
3.5 stats命令快速判断缓存健康度
对运维来说,stats信息是判断缓存系统是否健康的仪表盘。我在排查问题时的第一件事就是敲stats:
stats STAT pid 12345 STAT uptime 3600 STAT curr_items 1024 STAT total_items 2048 STAT get_hits 5000 STAT get_misses 1000 STAT delete_misses 100 STAT bytes 1048576 STAT curr_connections 5 STAT total_connections 100 STAT cmd_get 6000 STAT cmd_set 2048 STAT evictions 10 STAT limit_maxbytes 1073741824 END这里重点关注两个指标:命中率(get_hits/cmd_get)和驱逐数(evictions)。命中率太低说明缓存利用率不行,可能key设计有问题或者缓存预热不够。evictions大于0说明内存不够装了,旧的key被强制踢出去,这时候该考虑扩容缓存内存或者优化缓存策略了。计算命中率的公式是get_hits/(get_hits+get_misses)*100%,我一般以90%为健康分界线。
还有一个stats cachedump 1 100命令可以用来枚举某个slab类里的key,但这个命令在生产环境要慎用,它会遍历整个类别的数据,量大时对性能有影响,调试环境用用就好,生产我基本不用它。
4. 实际项目中的使用套路
4.1 缓存读写的经典流程
到了真实项目中,我们不会直接拿命令行操作memcached,而是通过PHP、Python、Java这些语言的客户端库去调用。但不管用什么语言,代码里的读写逻辑长成一个套路,也就是面试喜欢考的缓存范式:
第一步,先查缓存。第二步,缓存没有命中,就查数据库。第三步,数据库查到结果之后,回写一份到缓存,同时设置过期时间。第四步,返回数据给调用方。
我用伪代码描述一下这个流程:
def get_user_profile(user_id): cache_key = "user:profile:%s" % user_id data = mc.get(cache_key) if data: return data data = db.query("select * from user where id=%s", user_id) if data: mc.set(cache_key, data, time=300) return data这段代码看起来简单,但里面藏了好几个细节。第一个是缓存key的设计必须有统一规范,:分隔的业务前缀很有讲究,user:profile:123比裸写123好一万倍,既能避免键冲突,又方便出问题时用工具精确删除某类缓存。第二个细节是设置过期时间时要加一点随机扰动,比如300秒你写成300 + random.randint(0, 60),防止大量key在同一时刻集中过期,导致缓存雪崩。
4.2 缓存穿透、击穿、雪崩的应对思路
使用缓存必然要考虑三个经典问题,我把它们和memcached场景对应起来:
缓存穿透是指查一个必然不存在的数据,比如查一个已被删除的文章ID。因为缓存和数据库里都没有,每次请求都会穿透到数据库,高并发下数据库可能被压垮。应对方案有用布隆过滤器提前拦截,或者在查到空结果时也往缓存里写一个占位符,比如把空值缓存一分钟,而这个空值的过期时间要设置得比正常数据短。
缓存击穿是指某一刻某个热点key恰好过期,同时大量需要这个key的请求涌入,结果全部打到了数据库。解决办法一般有两种思路:一是用互斥锁,只允许一个请求去重建缓存;二是把热点key的过期时间设成永不过期,然后由后台任务主动更新值,这个方案在集中式缓存系统里更稳。
缓存雪崩是指大量key在同一时间段失效,导致一批请求全部到达数据库。原因是前面说的过期时间太整齐,解决办法也很简单,初始化缓存时把过期时间打散,加一个随机偏移量。
这三个问题面试常问,工作中也一定会遇到,我的建议是不要死记概念,画一下请求流向图,搞清楚数据在哪个环节断档,自然会知道在哪个环节补措施。
4.3 PHP项目接入memcached
PHP接入memcached用得最多的是php-memcached扩展,它基于libmemcached,功能比较全。安装方式在CentOS上:
sudo dnf install -y php-pecl-memcached代码示例,这是我在实际项目里常用的写法:
$mc = new Memcached(); $mc->addServer('127.0.0.1', 11211); $key = 'product:detail:1001'; $detail = $mc->get($key); if ($detail === false) { // 从数据库读取 $detail = getProductDetailFromDb(1001); $mc->set($key, $detail, 300); }这里有个注意点,$mc->get($key)返回false有两种情况,一种是缓存不存在,另一种是缓存的值恰好就是false。所以如果你要缓存的值本身就是布尔型false,建议在外面包一层数组或者用字符串'0'避免歧义。另一种更稳妥的写法是先用$mc->getResultCode()判断是否命中:
$detail = $mc->get($key); if ($mc->getResultCode() === Memcached::RES_NOTFOUND) { // 确实没有缓存,读数据库 }PHP的Memcached类还支持设置多个服务器,做一致性哈希分布:
$mc->addServers([ ['192.168.1.10', 11211], ['192.168.1.11', 11211], ['192.168.1.12', 11211], ]); $mc->setOption(Memcached::OPT_DISTRIBUTION, Memcached::DISTRIBUTION_CONSISTENT);多服务器时一致性哈希能尽量减少增删节点时key重新映射的数量,不然某台缓存服务器宕机,会导致大量缓存key失效,数据库压力瞬间飙升。
4.4 Python和Java接入简例
Python生态里最常用的客户端是pymemcache和python-memcached。我推荐pymemcache,纯Python实现,维护状态好,而且支持连接池:
from pymemcache.client.base import PooledClient client = PooledClient('127.0.0.1', 11211, max_pool_size=10) client.set('foo', 'bar', expire=60) value = client.get('foo')pymemcache的set过期时间参数是expire,单位秒。还有一点要注意,pymemcache序列化时默认不处理Python对象,你要自己传字符串或者字节,实际项目中我一般会配合json做序列化,用serializer参数指定,目前pymemcache提供pickle和json的序列化模块,需要显式声明。
Java接入则最常用spymemcached或xmemcached。我举一个spymemcached的例子:
MemcachedClient client = new MemcachedClient( AddrUtil.getAddresses("127.0.0.1:11211") ); client.set("foo", 60, "bar"); Object value = client.get("foo");Java客户端相对重一些,但功能完善,内置了连接池和失败重连机制。这里提醒一下,Java里使用set时存活时间是秒,但有些客户端版本接口签名不同,有的是int秒,有的是long毫秒,建议写代码前先看一眼客户端接口定义,别想当然。
4.5 缓存与数据库的一致性处理
使用缓存绕不开一个问题:数据库更新了,缓存怎么办。常见的策略是Cache Aside模式,也就是更新数据库时直接删缓存,再等下次读请求来时回填。
也许你会想,为什么不更新数据库的同时也更新缓存?因为更新缓存的时机很难把握,高并发下两个线程同时更新同一个key,可能出现数据库先更新的值后写进缓存,造成新旧数据互相覆盖。删缓存虽然也是在数据库操作后执行,但它不怕覆盖问题,因为下次读时缓存不存在,自然会把最新数据写进去。
更稳妥的做法是延迟双删,先删一次缓存,再更新数据库,最后休眠几百毫秒再删一次。这个方案主要针对极端情况:第一个删缓存后,有旧请求把旧数据回填了缓存,然后数据库更新,第二次删除就清掉了这份旧数据。实际生产里,如果业务允许极短时间的不一致,简单的一次删缓存加expire兜底也够用。
5. 性能监控与参数调优
5.1 内存管理机制:Slab Allocator
memcached的内存管理不同于常规的malloc直接分配,它用了一套叫Slab Allocator的机制。刚运行时,memcached把分配的内存划分成一系列slab类别,每个类别里又分出固定大小的chunk。比如slab类1的chunk是96字节,slab类2是120字节,按倍数递增,最到最大chunk上限。
存入一个100字节的数据时,memcached会把它放进chunk大小能容下它的最小slab类,比如120字节的chunk。这个机制的优点是避免了频繁分配和释放内存碎片,缺点是会造成内存浪费。如果你大量存入的是1KB大小的数据,但memcached的slab增长因子是1.25,它可能被分配到1.5KB或者2KB的chunk里,浪费率就比较明显。
在实际项目中,如果数据大小差异特别大,比如既有几十字节的小key又有几百KB的大value,可以考虑调整-f增长因子,或者干脆拆分业务,小key和大文件各自用独立的memcached实例,内存利用率会高不少。
5.2 evictions与LRU
当memcached内存满了之后,新的写入不会报错,而是通过LRU算法把最久没用的数据淘汰掉。stats里evictions字段就是被淘汰的数据条数,它和过期不一样,过期是时间到了,淘汰是内存不够被强制挤掉。
我的经验是,当evictions开始稳定增长,第一反应不是加内存,而是检查是不是有些缓存key永远不会再被读取了,比如脏数据或者无效会话。把无用key清掉,比无脑加内存更理性。如果你确认所有缓存数据都是高价值热点,再考虑调大-m参数。
memcached还提供了LRU爬虫机制,默认情况下过期数据会等被访问时才清除,但你可以开启-o lru_crawler参数让后台线程主动扫描并回收过期key占用的空间。开启后能一定程度减少过期数据积压,但会带来一点额外CPU消耗,机器资源紧的服务器要权衡。
5.3 线程模型与连接数调优
memcached虽然是多线程处理网络事件,但要注意,它的线程模型是多个线程分别处理不同的连接,不是多线程处理同一个连接上的命令。所以调整-t参数时,socket连接会被分发到不同线程,单个连接的命令还是串行执行的。
stats里的curr_connections如果长期靠近MAXCONN上限,先检查是不是有客户端忘了关连接。很多语言里每次请求如果都new一个client而不是复用连接,很容易把连接数打满。这时候优化方向是使用连接池,而不是盲目调大MAXCONN。连接数开得过大,每个连接都有接收缓冲和发送缓冲,内存会被无谓吃掉。
5.4 命中率优化实战
命中率是缓存系统最核心的指标。我手头一个真实case,原先某个查询接口的命中率只有70%,数据库压力很大。分析后发现问题出在缓存key的设计上,接口用的是user:list作为key,根本没有把分页参数带进去。也就是说,用户翻第1页和第100页,缓存里都是同一份数据,而这份数据只能展示默认首页的内容。
把key调整成user:list:page:1:size:20之后,每个分页都有自己的缓存,命中率直接升到95%。类似的情况还有很多,比如查询条件里不同排序维度、不同省份、不同时间段,都应该体现在缓存key里。
5.5 用memcstat和memcslap做体检
安装了libmemcached-tools之后,可以用memcstat快速查看运行状态:
memcstat --servers=127.0.0.1:11211它会把stats信息整理成可读格式,比自己在telnet里看舒服。还有一个memcslap工具,可以用来自带压测:
memcslap --servers=127.0.0.1:11211 --concurrency=100 --execute-number=10000它会模拟并发请求打set和get,输出QPS和响应时间统计。这是我在上线缓存服务之前必做的基础压测,确认机器承受得起预期流量。压测结果如果QPS远低于预期,优先检查网络协议栈、连接数和实例本身的内存分配,不要一开始就怀疑memcached性能差,很多瓶颈其实在客户端侧。
6. 常见问题与排查技巧实录
6.1 问题速查表
我在这个部分把实际踩过的坑整理成一张表,遇到类似问题可以先对照:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 客户端连接被拒绝 | 监听地址不是客户端能访问到的地址;防火墙未放行 | 检查-l参数和firewalld规则,确认11211端口可达 |
| 能ping通但连不上11211 | UDP关闭后TCP未监听;服务未启动 | ss -lntp看监听列表,systemctl status看服务状态 |
| set返回SERVER_ERROR out of memory | 内存配置过小或者数据量超出 | 查看stats的limit_maxbytes和bytes,评估是否调大-m |
| set返回SERVER_ERROR object too large for cache | value超过单条最大限制(默认1MB) | 压缩大value,或者调大-I参数,一般建议压缩而不是调大 |
| 缓存get不到数据但数据库有 | key拼写不一致;过期时间太短 | 用命令行手工get验证key,检查代码里key的拼接逻辑 |
| 缓存数据不更新 | 代码只写缓存不删缓存;没有过期时间 | 采用Cache Aside模式,更新数据库后主动delete缓存key |
| 高并发时数据不准 | 用了get后修改再set,非原子操作 | 改用incr/decr做原子计数 |
| stats中evictions持续增长 | 内存不足,LRU被迫淘汰 | 清理无效key,调整过期时间,再考虑扩容 |
| 重启服务后缓存全部丢失 | 这是memcached正常行为,不持久化 | 前置做缓存预热,或者把需要长存的切到Redis |
| get返回false但代码判定不存在 | PHP里值本身就是false导致歧义 | 用getResultCode判断是否RES_NOTFOUND |
6.2 排查实录一:连接超时问题
有一回线上某个接口突然变慢,日志里全是Connection timed out。我先看了memcached的stats,发现curr_connections已经顶到1024上限了,而total_connections增长异常快,说明有客户端在不断建立新连接。
后来定位到是某个Java服务升级了连接池配置,默认把超时时间从1000ms改成了100ms,导致在访问高峰时连接池里的连接还没来得及返回就直接超时报错,于是服务频繁重建连接,连接数越堆越多,最终把memcached连接数打满。解决办法是把超时时间调回合理值,同时给连接池设置足够的最大空闲连接。这个案例让我养成了一个习惯:每次改缓存客户端的参数,都要看一遍curr_connections和total_connections的变化趋势。
6.3 排查实录二:缓存穿透把数据库打挂
另一个case是商品详情页在秒杀活动开始瞬间数据库CPU飙升到100%。现象是缓存里根本没有那些活动商品的key,每次请求都直接打到MySQL。
排查后发现,这批活动商品在管理员后台批量上架时,代码没有做缓存预热。活动开始后大量用户瞬间访问新商品,缓存里没有数据,于是全部穿透到数据库。处理方案分为两步:第一步在后台商品上架时主动把商品详情写入缓存,提前预热;第二步对查询不到的key,在缓存里也写一个短暂的空值占位,防止恶意遍历或哈希攻击穿透。这两个措施加上之后,数据库CPU马上就下来了。
6.4 排查实录三:内存碎片与Slab分配失衡
有一台memcached服务器内存配置了2GB,但每次压测到一定程度时set就开始报错。我用stats slabs查看各slab类的chunk使用情况,发现某个slab类的chunk分配了大量内存但命中率极低,而另一个slab类内存不足导致大量evictions。
原因是我们把几百万个小session和一个大JSON混在同一个memcached实例里,小数据占的slab类别和大数据占的slab类别内存隔离,大JSON的slab类因为chunk大,数量不多就吃光了内存。解决方案是把不同大小的数据拆成两个memcached实例,分别配置不同的-f增长因子,session把-f调到1.15,大JSON用默认1.25。拆分后两个实例的内存利用率都健康了,set错误也消失了。
6.5 安全加固建议
前面说了UDP端口默认关闭,这里再补充几条安全加固措施。第一,监听地址尽量不对外网公开,如果业务方必须跨网络访问,限制源IP为已知服务网段,在云安全组层面做访问控制。第二,禁止在公网环境下无认证部署memcached,它本身没有内置认证机制,暴露出去等于给全网开放了读写权限。第三,如果希望加认证,可以考虑SASL机制,配置比较繁琐但能挡住大部分未授权访问需求。第四,定期检查stats里的连接来源,如有异常连续建立连接的来源IP要及时封禁。
7. 从一个memcached到缓存架构演进
7.1 什么时候该引入缓存代理层
业务体量小的时候,客户端直连memcached服务器没什么问题。但当服务器数量变多,客户端需要感知所有缓存节点的变化时,管理成本就会显著上升。我见过一个项目里,业务方在代码里维护了十几台缓存节点,每上下线一台都要改配置发版本,非常痛苦。
这个阶段可以考虑引入一层代理,比如twemproxy或者更现代的方案。代理层负责把key分布到不同缓存节点的路由逻辑,客户端只跟代理通信,节点变动时只需要在代理层配置热加载,业务代码零改动。代价是代理层本身成为新的故障点,需要部署多台冗余。
7.2 egg缓存抽取与复用
再往后走一步,缓存架构优化不只能靠堆机器,还可以把缓存key的生成规则和失效策略统一成一个单独的模块。我经常建议团队做一层通用的缓存封装,对外暴露get(key, expire, loaderFunction),内部逻辑固定:先查缓存,没有就执行loaderFunction读数据库,拿到结果写缓存。这样业务代码里不再散落set/get/delete逻辑,长此以往维护成本会低很多,排查问题时也只需要看封装层。
7.3 与Redis共存的场景拆解
很多团队最终会同时使用memcached和Redis,而不是二选一。我目前维护的系统就是这么做的:memcached负责承载高吞吐的纯KV热点数据,比如用户session、查询结果缓存;Redis负责需要数据结构的场景,如排行榜、队列、分布式锁、布隆过滤器。
这种分工的原因很实际:memcached在纯KV读写场景下延迟极低且多线程扩展性好,代码简单不容易出错;而Redis的数据结构丰富,可以替代更多手工编码逻辑。运维上两类中间件分别监控,memcached关注命中率和驱逐数,Redis关注内存使用率和持久化策略,故障定位起来反而更清晰。
7.4 Quit命令与连接优雅关闭
最后提一个容易被忽略的细节,文本协议里还有一个quit命令。客户端在不需要连接时应该发送quit,或者直接关闭socket,而不是让连接挂着等服务端超时。不礼貌的客户端连接会占用curr_connections,最终影响其他正常业务的连接。
用telnet测试完我习惯输入quit退出,生产代码里客户端连接池会自动管理空闲连接,但如果你写自定义脚本去连memcached,记得在结束前关闭连接。这些都是细节,但细节才见经验。
7.5 从入门到进阶的路径建议
如果看完这篇你打算深入下去,我的建议路线是这样的:先用stats里的各项指标把内存计算清楚,会看slabs的增长情况,再试着给现有缓存写一个命中率统计脚本,然后模拟缓存穿透和雪崩场景做一次应急预案演练。等这套跑通了,可以说你已经具备日常运维和调优memcached的基本功了。另一步可以继续研究内存分配器源码实现,比如LRU队列、slab划分逻辑、crawler机制,这些都是高性能缓存的经典设计,值得反复琢磨。
我这个操作过程中最大的体会是,别急着上多复杂的架构,先把一台memcached的安装、命令行、统计数据吃透,再去谈分布式、集群、代理层。缓存系统的问题往往不在性能,而在使用姿势和数据设计,把这两件事做对了,绝大多数问题都不会主动找上门。