1. replace命令是什么,为什么值得单独讲
1.1 一个容易被当成set简化版的命令
Memcached的存储命令里,set、add、replace这三兄弟经常被放在一起讲,但大多数教程只给一张对比表格,列一列“存在时是否覆盖”,然后就结束了。实际干活的时候,replace的语义比表格里那一行字要微妙得多:它只会在key已经存在的情况下更新值,key不存在就什么都不做,直接告诉客户端“NOT_STORED”。
这个“什么都不做”恰恰是它最值钱的地方。很多人写缓存更新习惯性直接用set,因为set无论key存不存在都会写入,看起来更方便。但当你接手一个需要保证“只有已存在的key才允许更新”的业务时,set就会变成危险的万能覆盖器。举个例子,一个分布式任务系统里用Memcached保存“当前正在执行的任务标识”,任务不存在时该标识就不应该出现,这时候只能靠replace来更新,set反而会把一个不存在的标识硬写进去,让下游误以为有任务在跑。
所以replace不是一个“set的备胎”,它是一把有条件的钥匙。理解这一点,才能理解后面所有协议、场景和源码层面的细节。
1.2 在九个存储命令里的位置
Memcached常用的数据更新指令一共就几个:set、add、replace、append、prepend、cas,外加读取用的get/gets,计数用的incr/decr。它们的核心区别就看一件事:操作一个key的时候,它“存不存在”是否影响结果。
- set:不管key存不存在,直接覆盖,不存在就新建,存在就替换。
- add:只在key不存在时写入,已存在就返回NOT_STORED。
- replace:只在key已存在时更新,不存在就返回NOT_STORED。
这三者正好构成一个完整的存在性逻辑矩阵。append/prepend是在已有值的基础上追加或前插文本内容,cas是带版本号的乐观锁更新。replace跟cas经常被混为一谈,后面我会单独展开,这里先记住一句话:replace只检查“key是否活着”,不检查“旧值是否是我上次看到的样子”。
从语义上看,replace解决的是“更新一个确定存在的对象”,add解决的是“创建且不允许覆盖”,set解决的是“管它存不存在,我就是要这个最终状态”。很多缓存回填、缓存预热场景里,add和replace往往是一对组合拳。
2. 协议格式与参数,看这份就明白
2.1 文本协议下replace长什么样
Memcached的文本协议非常直白,一行命令加一个数据块。replace的完整请求格式是:
replace <key> <flags> <exptime> <bytes> [noreply]\r\n <data>\r\n服务端处理完后,会返回一行状态:
STORED\r\n:更新成功。NOT_STORED\r\n:key不存在,或更新被拒绝。ERROR\r\n:命令格式有问题。CLIENT_ERROR bad command line format\r\n:参数解析失败。SERVER_ERROR out of memory\r\n:内存分配失败。
这里最容易踩的坑是\r\n。文本协议要求每一条命令和数据块都以回车换行结束,尤其用nc或者自研client测试时,漏掉\r只发\n,Memcached会一直等,看起来就像卡死一样。后面排障部分我会专门讲。
2.2 flags、exptime、bytes到底怎么填
flags是一个32位无符号整数,Memcached本身不解析它,只负责存和原样返回。它通常是客户端用来标记序列化方式的,比如0表示原始字符串、1表示JSON、2表示gzip压缩数据。使用replace更新一个旧key时,新flags会覆盖旧flags,所以如果你原来存的是gzip压缩数据,更新时忘了传压缩标记,客户端读回来就傻眼了。
exptime是过期时间,单位是秒。它的规则有点反直觉:0表示永不过期;1到2592000(30天)之间的值,表示从现在起多少秒后过期;超过2592000的值,会被当作Unix时间戳处理,也就是那个绝对时间点过期。有人把过期时间填成3600000,以为是一小时,结果1小时只对应3600秒,3600000大约41天后过期,实际已经把时间戳语义触发了。写代码时最好统一用相对秒数,并且封装过期时间工具函数,否则很容易出这种低级事故。
bytes是数据块的字节数,只算data部分,不算命令行的回车换行,也不算数据块末尾的CRLF。bytes填多了或少了都有问题:填少了,Memcached只读取前几个字节,剩下的数据会留在socket缓冲区,导致后续命令错位;填多了,服务端读完bytes个字节后,根本等不到结束标志,直接卡死或报错。所以正确姿势是把数据真实长度算准,不能按字符数算,要按字节数算,中文、表情符号尤其要注意UTF-8编码后的长度。
noreply是可选的,加上以后服务端不返回任何状态,适合批量写入、对结果不关心的场景。但它也意味着错误被静默吞掉,排障时不要轻易加。
2.3 影响范围:三种状态下的返回码差异
用一个不存在key、一个存在但未过期key、一个存在但已过期key来做测试,replace返回结果完全不同:
| 当前key状态 | replace结果 | 原因 |
|---|---|---|
| key不存在 | NOT_STORED | 不存在就不更新 |
| key存在且未过期 | STORED | 正常替换 |
| key存在但已过期 | NOT_STORED | Memcached逻辑过期,get时会忽略 |
第三种情况很多人会忽略。Memcached里过期的key不会立刻从内存删除,但在逻辑上已经不可见了。replace在执行时会先查这个key是否有效,过期key会被当成不存在处理,所以返回NOT_STORED。这不算bug,而是设计如此,使用replace做缓存刷新时要注意:如果旧值刚好在更新前几毫秒过期,这次更新就无效了,需要补一次set兜底。
3. 从telnet到编程客户端,完整体验一次
3.1 本地环境搭一个最小实验台
没有Memcached环境时,最快上手的方式是直接在当前机器跑一个实例。Ubuntu/Debian系可以用apt install memcached,CentOS/RHEL系用yum install memcached,macOS用brew install memcached。装完后前台启动,方便看日志:
memcached -p 11211 -u nobody -m 64 -vv-p指定监听端口,-u指定运行用户,-m指定最大内存,单位是MB,-vv表示把每个进来的命令打印到终端。这一步很重要,排障时能看到memcached实际收到了哪条命令,有没有解析报错。
然后用telnet验证连通性:
telnet 127.0.0.1 11211如果提示Connected to 127.0.0.1.,说明端口通,服务正常。很多人问“telnet ip 端口 命令怎么看通不通”,其实不用敲什么特殊命令,能连上就通;连不上会直接提示Connection refused或超时。连接后哪怕什么都不输入,和服务器保持连接本身就已经证明端口可访问了。
3.2 用telnet亲手做几次replace
连接上后,先故意对一个不存在的key执行replace:
replace user:1001 0 300 5 hello服务端返回NOT_STORED,因为key不存在。接着先用set创建这个key:
set user:1001 0 300 5 hello返回STORED。再用replace更新:
replace user:1001 0 300 8 world123返回STORED,然后get看一下:
get user:1001 VALUE user:1001 0 8 world123 END整个过程非常直观。这里要注意,输入数据块时我故意让bytes和实际字符串长度严格一致,都是8个字节,否则后面get结果就会错乱。真实业务里往往通过客户端库自动计算长度,手写协议的机会不多,但理解这个过程能帮你以后排查诡异问题。
3.3 Python客户端里的replace怎么用
Python里最常用的库是python-memcached和pymemcache。以python-memcached为例,replace的调用方式和set几乎一样:
import memcache mc = memcache.Client(['127.0.0.1:11211']) # 先写入一个key mc.set('session:9527', 'alive', time=300) # replace存在key result = mc.replace('session:9527', 'renewed', time=600) print(result) # 1表示成功,0表示key不存在 # replace不存在key result = mc.replace('ghost:key', 'nothing', time=600) print(result) # 0表示失败python-memcached的replace返回值是1或0,正好对应文本协议的STORED和NOT_STORED,便于直接判断。pymemcache的接口稍微不同,replace在key不存在时返回False,参数名是expire而不是time。用哪个库不重要,重要的是记住返回值能区分“更新成功”和“key不存在”。
很多新手会用replace后不判断返回值,以为像set一样一定成功,结果数据没更新也不报错,线上查半天。这里建议所有replace调用都先判断返回值,至少在日志里打个warn。
3.4 PHP等客户端里可以直接抄的模板
PHP如果用的是memcached扩展(注意是带d的memcached,不是memcache),replace方法签名如下:
$m = new Memcached(); $m->addServer('127.0.0.1', 11211); $result = $m->replace('user:1001', 'new-value', 300); if ($m->getResultCode() === Memcached::RES_SUCCESS) { echo "更新成功"; } elseif ($m->getResultCode() === Memcached::RES_NOTSTORED) { echo "key不存在,无法更新"; } else { echo "其他错误: " . $m->getResultCode(); }这里不需要再传flags参数,扩展内部会自己管理序列化方式。但要注意,Memcached扩展默认对复杂数组做序列化,读取时也会自动反序列化,如果你在别的客户端里直接get这个key,看到的可能是一长串序列化后的字符串,别把它当数据异常。
Go的gomemcache库写法也类似,mc.Replace(context.Background(), key, value, expiration)返回error,如果key不存在会返回memcache.ErrNotStored,这是一个标志性错误,处理逻辑可以参考PHP的做法。
4. 典型业务场景与踩坑经验
4.1 刷新缓存时的三个分支
缓存更新的经典场景是:查数据库,然后把结果写回缓存。很多人直接set,一步到位。但有些场景必须区分三种情况:
- key原先有值,只是内容过期,需要整体刷新。
- key原先就没建起来,比如数据库里查不到,缓存里也不该有。
- key正在被其他请求锁定,我不该去动它。
replace适合第一个场景,add适合第二个场景,set适合“我才是最终发言人”的第三个场景。如果数据库查询结果本身为空,你用set硬塞一个空值进缓存,等于告诉整个系统“这个key是存在的”,后续所有依赖这个key的查询都会命中一个假值。合理做法是查询为空时直接用delete清理缓存,不让空值污染缓存。
我之前在一个订单查询接口里见过一种反模式:每次请求过来都set一次,不管数据库有没有结果。结果一条被删除的订单ID在缓存里能存活30分钟,客户端一直显示“订单处理中”。改成“先查库,有结果才set,查到空就delete”之后,问题立刻消失。replace在这里也能派上用场:当你有把握key应该存在时,用replace更新,至少不会误创建一个本不该出现的缓存条目。
4.2 会话、锁和计数的续期
replace最经典的应用是“续期”。会话缓存里通常会存一个key,比如session:<token>,表示登录态。用户每次访问都刷新过期时间,旧会话才能一直有效。这时候replace就很合适:只有会话key还存在时才续期,如果会话已经被踢掉或过期,replace返回NOT_STORED,业务就能及时把用户踢回登录页。
分布式锁也能用同样思路。Memcached做分布式锁的基本方案是add一个锁key,设置过期时间防止死锁。持有锁的线程如果处理时间太长,需要续租:
# 续租:只有锁还存在才延长时间 renewed = mc.replace('lock:order:1001', 'holder', time=60) if not renewed: # 锁已丢失,可能要放弃操作或重新抢锁 print('锁已过期,续租失败')这里有个坑:replace只检查key是否存在,不检查value是否为当前持有者。如果锁被其他线程用set暴力覆盖了,replace还是会续租成功,而你不知道锁已经换主人。所以续租时最好配合gets/cas,或者至少在value里带上持有者标识,续租前先get一次确认身份。
计数类的场景同理。比如用incr做计数器,值本身存在才能自增;如果你想重置计数器,应该用replace把初始值写入已存在的计数器,而不是用set,这样至少保留了“计数器是否初始化”的判定能力。
4.3 与CAS的边界:replace不是条件更新,别搞混了
很多人以为replace就是“有条件的更新”,于是拿它当乐观锁用,这是我对这个命令最想纠正的一点。replace的条件只有一个:key是否存在。它不关心旧值是什么,不关心数据有没有被别的线程改过。两个线程同时replace同一个key,后写者覆盖先写者,毫无阻碍。
真正的条件更新是gets配合cas。gets返回一个CAS token,它表示当前value的版本号;cas提交时必须带上这个token,只有版本号一致才允许更新。这样才叫“只在值没变的情况下更新”,防止并发覆盖。
什么时候用replace,什么时候用cas?只管存在性,用replace;管版本一致性,用cas。更新用户资料缓存这种覆盖型操作,replace就行;更新库存、余额这种一致性敏感的数据,必须用cas,甚至应该直接走数据库,不要纯依赖缓存。memcached终究是个缓存系统,不是事务型数据库,别让它承担不该承担的职责。
5. 源码视角和性能细节
5.1 memcached源码里replace到底做了什么
看源码之前先说结论:replace不是一个简单的“原地覆盖内存”,它内部要经历查找、分配、替换、回收四个阶段。以memcached 1.6.x为例,文本协议解析在proto_text.c里,处理更新类命令的核心函数是process_update_command。它会根据命令关键字区分SET、ADD、REPLACE,然后把参数解析到item结构体里。
接着走存储逻辑。REPLACE会先调用item_get,根据key找到对应的item。如果找不到,直接返回NOT_STORED;找得到,就继续走更新流程。更新时如果新数据的字节数和旧item分属不同的slab class,已有的内存块不能继续使用,需要重新分配一块新内存,把旧item从哈希表和LRU链表中摘掉,再把新item挂上去。
源码里对应的函数大致是do_item_replace,它内部会item_remove(old_it)并do_item_link(new_it)。近几年代码细节可能有调整,但核心思路没变。这也是为什么replace大value时,内存碎片和LRU抖动会明显:不是简单把新字符串塞进旧盒子里,而是在内存池里重新找坑位。
5.2 为什么replace不总是原地更新
Memcached使用slab allocator管理内存,它把内存按大小划分成不同class,比如96字节、120字节、152字节……每个class内部有多个空闲块。add/set一个key时,根据数据长度选择class并分配一块内存。replace如果新数据长度没超出旧item所在class的容量,理论上可以原地复用;但很多实现里,为了保持数据和哈希表的一致性,还是会走“新item替换旧item”的完整流程,尤其是新旧长度跨class时,必然要换内存块。
这带来两个实际影响:
- replace超大值会比set更耗时,因为它要执行一次查找加一次分配,再执行一次哈希表更新。
- 频繁replace不同长度的value,可能加速内存碎片化,极端情况下触发LRU淘汰,把并不该淘汰的热key挤出内存。
所以在设计缓存value时,尽量让同key的value大小保持稳定。如果value长度会剧烈变化,比如一个order可能从几十字节涨到几十KB,建议拆分成多个key,或者加一层压缩再写入,减少slab class跨越。
5.3 性能、网络交互与批量操作
replace是一个完整网络往返。如果循环里一个个replace,一次更新100个key就要100个RTT。在延迟为5ms的网络环境里,不算处理时间光等往返就500ms了,肉眼可见的慢。
减少往返有两条路:
- 对不关心返回结果的更新加
noreply参数,客户端不会傻等响应。 - 使用pipeline,把多条命令批量发给服务端再统一收结果。Memcached文本协议天然支持pipeline,你可以在一个连接里连续发送多条replace,不必一条条等返回。
但noreply也有副作用:Memcached在处理完命令后不返回结果,如果某条replace因为key不存在而失败,你根本不知道。对“必须成功的更新”,别用noreply;对“更新失败也无所谓的异步刷新”,用noreply能明显提升吞吐。实际项目里我一般把这两类操作分开:核心链路全部要求返回码,旁路刷盘、预热类操作才开noreply。
6. 实战排查:常见问题速查与避坑
6.1 六个出了事才发现的坑
| 症状 | 真实原因 | 处理方法 |
|---|---|---|
| replace返回NOT_STORED,但key明明在 | key已过期,逻辑上不存在 | get一下确认,必要时改用set |
| replace返回STORED,get却得到旧数据 | 写到了不同Memcached实例或不同key | 检查客户端server列表、key是否带前缀 |
| 一直CLIENT_ERROR bad command line | bytes长度和实际数据不匹配 | 用字节数计算,不是字符数 |
| 中文数据replace后get乱码 | exptime没传或序列化方式不一致 | 统一flags和编码,UTF-8存储 |
| 批量replace很慢 | 循环等待每个响应 | 加noreply或pipeline |
| 并发replace互相覆盖 | replace没有版本校验,不是cas | 换gets/cas做乐观锁 |
第一行的坑最容易骗人。Memcached对过期key采用惰性删除,key在内存里可能还在,但逻辑上已经不可见。你直接replace它,内部判断为不存在,所以NOT_STORED。排查的时候别只看key有没有,还要看它的剩余过期时间。
6.2 telnet排障的几个小命令
telnet进入Memcached之后,不只是能发replace。几个实用命令先备着:
stats stats items stats slabsstats看整体状态,比如get_hits、get_misses、curr_items,能快速判断缓存命中情况。stats items可以查看每个slab class里的item数量,辅助定位内存占用异常。stats slabs看每个slab class分配了多少内存页。
还有一个冷门但实用的方式:用stats cachedump <slab_id> <limit>把一个slab里的key列出来。比如stats cachedump 1 500,能看前500个key。配合前面说的“key逻辑过期”问题,你可以先列出key,再去get确认状态。
6.3 一个能完整跑通的Python示例
下面这段代码可以直接复制运行,覆盖了replace的核心行为,包括成功、失败、过期三种情况:
import memcache import time mc = memcache.Client(['127.0.0.1:11211']) # 情况1:key不存在,replace返回0 print('不存在key的replace结果:', mc.replace('demo:001', 'hello', time=30)) # 情况2:先set,再replace mc.set('demo:002', 'old-value', time=30) print('存在key的replace结果:', mc.replace('demo:002', 'new-value', time=30)) print('replace后get结果:', mc.get('demo:002')) # 情况3:过期后replace mc.set('demo:003', 'will-expire', time=1) time.sleep(2) print('过期key的replace结果:', mc.replace('demo:003', 'too-late', time=30)) print('过期后get结果:', mc.get('demo:003')) # 情况4:用gets/cas做真正意义上的条件更新 mc.set('demo:004', 'base') value, cas_token = mc.gets('demo:004') print('获取到的旧值和cas版本:', value, cas_token) mc.cas('demo:004', 'updated-with-cas', cas_token, time=30) print('cas更新后的值:', mc.get('demo:004'))运行结果应该依次是:第一个replace返回0,第二个replace返回1,get输出new-value,第三个replace因为过期返回0,get输出None,第四个cas输出updated-with-cas。这能帮你快速建立一个直觉:replace跟存在性强相关,跟数据是否新鲜无关;想要新鲜度校验,只能靠cas。
6.4 我个人的几条经验
用Memcached这几年,我最后悔的就是早先没把replace和set分清楚,导致过好几次线上事故。有几条经验写在这里供参考:
第一,所有缓存写入操作,先问自己一句“这个key现在该不该存在”。该存在才更新,用replace;可能不存在且应该新建,用add;无论怎样都要覆盖,才用set。强行用set,就是把设计约束删掉。
第二,别把replace和cas混为一谈。在代码评审里见到“用replace实现乐观锁”,可以直接打回去。Memcached并不复杂,但正因为简单,很多人误以为它可以做复杂的原子操作。做技术方案时,明确每个命令的原子性边界。
第三,所有用到replace的地方,至少有一个日志分支能看见NOT_STORED。这个返回值不是错误,是业务状态信号。看见NOT_STORED时,说明key已经不存在了,你要问的不是“为什么写不进去”,而是“它什么时候没的,是否合理”。
最后再分享一个末尾的小技巧:如果担心replace因为key过期而静默失败,可以在replace前先get一次并记录旧的exist状态,配合gets拿到的版本号做对照。这样既能发现意外消失的key,又能在需要时快速降级到set策略。分布式环境下排障的效率,往往就取决于你多记录了这几行日志。