ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入理解Memcached replace命令:语义、协议与实战避坑指南

深入理解Memcached replace命令:语义、协议与实战避坑指南 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\nkey不存在或更新被拒绝。ERROR\r\n命令格式有问题。CLIENT_ERROR bad command line format\r\n参数解析失败。SERVER_ERROR out of memory\r\n内存分配失败。这里最容易踩的坑是\r\n。文本协议要求每一条命令和数据块都以回车换行结束尤其用nc或者自研client测试时漏掉\r只发\nMemcached会一直等看起来就像卡死一样。后面排障部分我会专门讲。2.2 flags、exptime、bytes到底怎么填flags是一个32位无符号整数Memcached本身不解析它只负责存和原样返回。它通常是客户端用来标记序列化方式的比如0表示原始字符串、1表示JSON、2表示gzip压缩数据。使用replace更新一个旧key时新flags会覆盖旧flags所以如果你原来存的是gzip压缩数据更新时忘了传压缩标记客户端读回来就傻眼了。exptime是过期时间单位是秒。它的规则有点反直觉0表示永不过期1到259200030天之间的值表示从现在起多少秒后过期超过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_STOREDMemcached逻辑过期get时会忽略第三种情况很多人会忽略。Memcached里过期的key不会立刻从内存删除但在逻辑上已经不可见了。replace在执行时会先查这个key是否有效过期key会被当成不存在处理所以返回NOT_STORED。这不算bug而是设计如此使用replace做缓存刷新时要注意如果旧值刚好在更新前几毫秒过期这次更新就无效了需要补一次set兜底。3. 从telnet到编程客户端完整体验一次3.1 本地环境搭一个最小实验台没有Memcached环境时最快上手的方式是直接在当前机器跑一个实例。Ubuntu/Debian系可以用apt install memcachedCentOS/RHEL系用yum install memcachedmacOS用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执行replacereplace user:1001 0 300 5 hello服务端返回NOT_STORED因为key不存在。接着先用set创建这个keyset 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, time300) # replace存在key result mc.replace(session:9527, renewed, time600) print(result) # 1表示成功0表示key不存在 # replace不存在key result mc.replace(ghost:key, nothing, time600) 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不是memcachereplace方法签名如下$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, time60) 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一下确认必要时改用setreplace返回STOREDget却得到旧数据写到了不同Memcached实例或不同key检查客户端server列表、key是否带前缀一直CLIENT_ERROR bad command linebytes长度和实际数据不匹配用字节数计算不是字符数中文数据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]) # 情况1key不存在replace返回0 print(不存在key的replace结果:, mc.replace(demo:001, hello, time30)) # 情况2先set再replace mc.set(demo:002, old-value, time30) print(存在key的replace结果:, mc.replace(demo:002, new-value, time30)) print(replace后get结果:, mc.get(demo:002)) # 情况3过期后replace mc.set(demo:003, will-expire, time1) time.sleep(2) print(过期key的replace结果:, mc.replace(demo:003, too-late, time30)) 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, time30) print(cas更新后的值:, mc.get(demo:004))运行结果应该依次是第一个replace返回0第二个replace返回1get输出new-value第三个replace因为过期返回0get输出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策略。分布式环境下排障的效率往往就取决于你多记录了这几行日志。
返回列表