ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Memcached平滑升级实战:一致性哈希与缓存预热详解

Memcached平滑升级实战:一致性哈希与缓存预热详解

1. 为什么“平滑”二字才是升级memcached的真正难点

做过线上服务的人大概都有这种体会:装一个memcached很简单,改个配置重启也不难,难的是在业务不中断、缓存不雪崩、连接不报错的前提下,把一整套缓存层从旧版本换到新版本。这就是“memcached平滑升级”这个项目标题背后真正要解决的问题。

我先把这个事情说清楚:memcached是一个内存键值缓存服务,通常部署在应用和数据库之间,扛住大量读请求。它的特点是进程重启后内存数据全部丢失,因为它本身不做持久化。这就意味着,任何一次简单粗暴的重启,都会让缓存命中率瞬间掉到零,所有请求直接打到后端数据库,轻则响应变慢,重则数据库被打挂。所以“平滑升级”的核心诉求不是升级本身,而是在升级过程中控制缓存失效带来的冲击。

这套方案适合谁参考?我总结下来是三类人:一是手里维护着几十到上百个memcached节点、需要做版本迭代的运维或SRE;二是应用层用了一致性哈希做缓存分片的后端开发;三是正在做缓存架构治理、想把老版本memcached替换掉的技术负责人。不管你用的是哪种客户端(常见的有基于文本协议的、二进制协议的,或者各种语言封装的客户端库),核心思路是相通的。

接下来我会从整体设计思路、关键细节、完整实操流程、问题排查四个大块展开,把我自己踩过的坑和验证过的做法都摊开讲。文章里涉及的具体参数和命令,我会说明为什么这么选,而不是甩一堆配置让你照抄。

2. 整体升级方案设计与思路拆解

2.1 先搞清楚“平滑”到底要平滑什么

很多人一上来就想“怎么不停机重启”,但真正该问的是:升级过程中,哪些东西会断,哪些东西不能断。我把影响面拆成三层来看。

第一层是连接层。客户端和服务端之间维持着TCP长连接,进程一停,连接就断。如果客户端没有重连和故障转移机制,那这一瞬间的请求全部失败。第二层是数据层。memcached重启后内存清空,原本缓存的热点数据没了,命中率归零。第三层是容量层。如果升级时节点数发生变化,一致性哈希的环会重新分布,大量key被映射到新节点,同样导致命中率暴跌。

所以“平滑”的本质是:连接要能自动切换,数据要能逐步预热,容量要尽量保持不变。理解了这三点,方案设计就有了方向。

2.2 三种主流升级路径的取舍

实际生产中,memcached升级大致有三条路,我逐个分析它们的适用场景和代价。

路径一:原地重启。就是停掉旧进程,启动新进程。优点是简单,缺点是缓存全丢、连接全断。这条路只适合那些缓存可以随时丢弃、后端数据库扛得住瞬时压力的场景。如果你的缓存是“可选加速层”,丢了也不影响正确性,那原地重启配合限流是可以接受的。但如果缓存是“关键路径”,这条路直接排除。

路径二:双写过渡。让应用同时往新旧两套memcached写数据,读的时候优先读新的,等新集群预热充分后再切流量、下线旧的。优点是切换过程命中率平滑,缺点是应用要改造,双写期间资源翻倍。这条路适合对可用性要求极高、且应用层可控的场景。

路径三:滚动替换 + 一致性哈希。这是我最推荐、也是用得最多的一种。核心做法是逐个节点替换,每次只动一个节点,利用客户端的一致性哈希把该节点的流量自动转移到其他节点,替换完成后再把流量引回来。整个过程对业务几乎无感。下面我重点讲这条路。

2.3 为什么滚动替换要绑定一致性哈希

这里必须解释清楚一个原理。假设你有4个memcached节点,客户端用取模(key % 4)做分片。现在你把其中1个节点换掉,节点数还是4,但那个节点的IP变了,取模结果不变,看起来没问题。可问题是:被替换节点的数据是空的,原本落在它上面的key现在读不到,全部穿透到数据库。

而一致性哈希不一样。它把节点和key都映射到一个哈希环上,每个key顺时针找到第一个节点。当你下线一个节点时,原本属于它的key会自动顺延到下一个节点,而不是全部落空。虽然下一个节点也没有这些key(因为缓存本来就是各存各的),但至少流量是均匀分散的,不会集中打垮某一个节点。这就是滚动替换能“平滑”的底层原因。

注意:一致性哈希只能保证流量分散,不能保证数据不丢。所以滚动替换必须配合缓存预热或延迟下线策略,否则命中率还是会掉,只是掉得没那么惨。

2.4 版本兼容性要先确认

在动手之前,有一件事必须先做:确认新旧版本的协议兼容性。memcached的文本协议和二进制协议在不同版本间基本稳定,但有些新版本会调整默认行为,比如slab分配策略、最大连接数、LRU淘汰逻辑等。如果你的客户端用了某些特定命令(比如stats的某些字段、touch、gat等),要确认新版本是否支持。

我的做法是:先在测试环境用相同版本的客户端连新版本服务端,跑一遍完整的读写和统计命令,确认没有报错。这一步花不了多少时间,但能避免上线后才发现客户端不兼容的尴尬。

3. 核心细节解析与实操要点

3.1 节点规划:升级前后节点数尽量不变

滚动替换最怕的就是节点数变化。因为一致性哈希虽然比取模稳定,但节点数一变,环上的分布还是会变,导致部分key重新映射。所以我的原则是:升级前后保持节点数量一致,只替换节点本身,不增不减。

具体操作上,我会准备一批新节点,数量和旧节点相同。然后一个一个替换:新节点上线,旧节点下线,环上的节点总数始终不变。这样key的映射关系变化最小,命中率波动也最小。

如果因为硬件换代必须改变节点数,那就不能叫“平滑升级”了,那叫“缓存集群扩容/缩容”,需要单独设计迁移方案,比如用双写或者代理层做key迁移。这两件事不要混在一起做。

3.2 客户端配置:重试和故障转移是生命线

服务端做得再好,如果客户端没有容错,一样会出问题。我见过太多事故是因为客户端连不上就抛异常,直接把错误透传给用户。所以升级前必须检查客户端的这几个配置。

配置项推荐值作用说明
连接超时100-500ms避免连接卡死拖垮线程
读写超时200-1000ms缓存超时要快速失败,不能等
失败重试次数1-2次重试太多会放大故障
故障节点剔除开启连续失败后临时踢出节点
节点恢复探测30-60s定期探测被剔除节点是否恢复

这里重点说故障节点剔除。当某个memcached节点被下线时,客户端应该能快速感知并把它从哈希环里摘掉,把流量分到其他节点。不同客户端实现不一样,有的叫failover,有的叫ejection,有的需要自己实现。如果你的客户端不支持,那就要考虑在升级窗口内手动调整节点列表,或者引入一个代理层(比如twemproxy这类)来统一管理。

提示:故障剔除的阈值不要设得太敏感。网络抖动是常态,如果一两次超时就剔除节点,会导致环频繁变动,反而更不稳定。我一般设成连续失败3次或5次才剔除。

3.3 缓存预热:别让冷启动变成雪崩

新节点上线后是空的,这是最大的风险点。如果直接把它加回哈希环,所有落到它上面的请求都会miss,然后穿透到数据库。所以必须预热。

预热有两种做法。一种是被动预热:新节点先不加回环,而是用工具把热点key批量写进去,等写入量达到一定程度再加回。另一种是主动预热:从旧节点的stats cachedump或者业务侧的访问日志里提取热点key,提前灌入新节点。

我一般用被动预热为主、主动预热为辅。具体来说,新节点启动后,先用一个脚本从旧节点导出热点key列表(可以通过stats items和stats cachedump拿到,但注意这个命令在高版本里可能被限制),然后批量set到新节点。等预热到旧节点数据量的60%以上,再把新节点加回哈希环。

这里有个细节:预热时不要用set覆盖已有数据,因为新节点是空的,直接set就行。但如果你的预热脚本是幂等的,用add更安全,避免覆盖掉预热期间新写入的数据。

3.4 监控指标:升级期间盯紧这几个数

升级过程中,光靠感觉判断“有没有问题”是不行的,必须看指标。我重点盯这几个:

  • 缓存命中率:这是最直接的指标。正常应该在90%以上,如果掉到70%以下就要警惕。
  • 后端数据库QPS:缓存miss会直接转化为数据库查询,这个数飙升说明缓存层出问题了。
  • 客户端连接数:新节点上线后连接数应该逐步上升,如果一直上不去,说明客户端没识别到新节点。
  • 节点响应时间:新节点如果响应慢,会拖累整个环的性能。
  • 淘汰率(eviction):如果新节点内存不够,会频繁淘汰数据,命中率上不去。

这些指标最好在升级前就配好告警,升级时盯着看。我习惯把命中率和数据库QPS放在同一个面板上,因为它们的相关性最直观。

4. 完整实操流程与核心环节实现

4.1 升级前的准备工作清单

动手之前,我会按这个清单逐项确认,缺一项都不开始。

  1. 确认新版本:明确要升级到哪个版本,查清楚该版本的变更日志,特别是行为变更和已知问题。
  2. 测试环境验证:在测试环境完整跑一遍升级流程,包括客户端兼容性、预热脚本、监控指标。
  3. 备份配置:把旧节点的启动参数、配置文件、启动脚本全部备份。memcached的启动参数很关键,比如-m内存大小、-c最大连接数、-t线程数,这些在新节点上要保持一致或按需调整。
  4. 准备新节点:新节点的硬件配置、网络位置尽量和旧节点一致,避免因为硬件差异导致性能波动。
  5. 通知相关方:升级窗口要提前通知业务方和值班同学,万一出问题能快速响应。
  6. 回滚方案:准备好回滚脚本,一旦新节点有问题,能快速把旧节点重新拉起来。

注意:memcached的启动参数里,-m(内存上限)和-I(单item最大大小)这两个要特别小心。如果新版本默认值变了,而你没显式指定,可能导致行为不一致。我习惯把所有关键参数都显式写出来,不依赖默认值。

4.2 单节点滚动替换的详细步骤

假设你有4个旧节点(old1到old4),准备了4个新节点(new1到new4)。整个替换过程分四轮,每轮替换一个节点。下面是单轮的详细操作。

第一步:新节点启动并预热。在new1上启动新版本memcached,参数和old1保持一致。然后运行预热脚本,从old1导出热点key并写入new1。预热脚本的核心逻辑大概是这样:

# 伪代码示意,实际根据你的环境和客户端调整 # 1. 从旧节点获取key列表 # 2. 批量读取旧节点的值 # 3. 批量写入新节点 for key in $(get_hot_keys_from old1); do value=$(echo -e "get $key\r" | nc old1 11211 | extract_value) if [ -n "$value" ]; then echo -e "set $key 0 0 ${#value}\r\n$value\r" | nc new1 11211 fi done

实际生产中我不会用nc这么粗糙的方式,而是用支持批量操作的客户端库,比如Python的pymemcache或者Go的gomemcache,效率高很多。预热的时候要注意控制速率,别把新节点打满。

第二步:把new1加入哈希环。修改客户端的节点列表,把old1替换成new1。这一步要确保客户端能平滑地重新加载配置。有些客户端支持动态更新节点列表,有些需要重启应用。如果是后者,那就要考虑用代理层来屏蔽这个变化。

第三步:观察指标。加入new1后,盯紧命中率和数据库QPS。因为new1已经预热过,命中率应该不会有太大波动。如果命中率明显下降,说明预热不充分或者哈希分布有问题,这时候可以考虑把new1暂时摘掉,继续预热。

第四步:下线old1。确认new1稳定运行一段时间(我一般观察15-30分钟)后,把old1停掉。停之前先确认没有客户端还在连它,可以通过stats看连接数,或者直接看客户端的节点列表。

第五步:清理和记录。old1下线后,把它的配置归档,记录这次替换的时间、版本、观察到的指标变化。然后开始下一轮,替换old2。

四轮下来,整个集群就完成了升级。每轮之间留出足够的观察时间,不要图快。我见过有人为了赶窗口,四个节点一起换,结果缓存全空,数据库直接被打挂。

4.3 预热脚本的参数计算

预热不是把所有key都灌进去,那样太慢也没必要。关键是灌热点key。怎么判断哪些是热点?我的做法是从旧节点的stats items里看每个slab的number_hot和number_warm,或者从业务侧的访问日志里统计频率。

预热的数据量目标,我一般定在旧节点数据量的50%-70%。为什么不是100%?因为新节点加回环后,新的写入会逐渐填充剩余空间,而且热点key本身会随着访问不断刷新。灌太多反而浪费时间,还可能因为内存淘汰把刚灌的热点挤掉。

预热的并发度也要控制。我一般用4-8个并发线程,每个线程每秒写入几百到几千个key。具体数值取决于新节点的处理能力和网络带宽。可以用memcached-tool或者自己写脚本压测一下,找到不导致新节点响应时间明显上升的速率。

4.4 一致性哈希环的调整细节

如果你的客户端用的是标准的一致性哈希(比如ketama算法),那节点替换时要注意虚拟节点(vnodes)的数量。虚拟节点越多,key分布越均匀,但节点变动时受影响的key比例也越接近理论值(1/n)。我一般用100-200个虚拟节点,这个范围在均匀性和变动影响之间比较平衡。

另外,替换节点时,新节点的权重要和旧节点一致。如果新节点硬件更好,可以适当调高权重,让更多key落到新节点上。但升级期间我建议保持权重不变,等升级完成后再单独调整,避免引入额外变量。

还有一个容易忽略的点:哈希环的更新要原子化。如果客户端在更新节点列表时出现中间状态(比如新旧节点同时存在或都不存在),会导致部分key映射错误。所以更新操作要么全量替换,要么用版本号控制,确保所有客户端看到的是同一个环。

5. 常见问题与排查技巧实录

5.1 升级后命中率不升反降

这是最常见的问题。原因通常有三个:一是预热不充分,新节点数据太少;二是哈希环分布不均,某些节点负载过高;三是客户端没有正确识别新节点,流量还是打到旧节点(但旧节点已经下线,导致大量失败)。

排查思路:先看客户端连接数,确认流量是否真的打到了新节点。然后看新节点的stats,确认cmd_get和get_hits的比例。如果get_hits很低,说明预热不够,继续灌数据。如果某个节点curr_connections特别高,说明哈希分布不均,检查虚拟节点配置。

5.2 客户端报连接超时或拒绝连接

升级期间连接报错,通常是客户端还在尝试连已经下线的旧节点。这时候要看客户端的故障剔除机制是否生效。如果客户端不支持自动剔除,就要手动更新节点列表,或者重启客户端。

还有一种可能是新节点的-c(最大连接数)设得太小,扛不住原有连接数。memcached默认是1024,生产环境一般要调到几万。检查新节点的stats里curr_connections和max_connections,如果接近上限就要调大。

5.3 新节点内存淘汰严重

如果新节点的evictions指标很高,说明内存不够用。可能是-m参数设小了,也可能是预热灌了太多数据。先确认-m和旧节点一致,然后看stats items里各slab的使用情况。如果某个slab淘汰特别多,说明这类数据量太大,可以考虑调大-m或者调整slab增长因子(-f参数)。

提示:memcached的slab分配是预分配的,不同大小的item落在不同的slab class里。如果数据大小分布和旧节点差异大,可能需要调整-f(增长因子)和-n(最小chunk大小)。这两个参数在升级时容易被忽略,但影响很大。

5.4 升级过程中数据库压力飙升

这是缓存miss的直接后果。如果数据库扛不住,必须立即采取措施:一是暂停升级,把流量切回旧节点(如果还没下线);二是临时限流,减少打到数据库的请求;三是加快预热,尽快提升命中率。

我的经验是,升级窗口尽量选在业务低峰期,并且提前和数据库团队打好招呼,让他们准备好应对突发流量。如果数据库本身没有余量,那就要考虑用双写方案,而不是滚动替换。

5.5 常见问题速查表

现象可能原因排查方法解决措施
命中率下降预热不足/分布不均看新节点get_hits继续预热/调整虚拟节点
连接超时旧节点未剔除看客户端连接日志更新节点列表/开启故障剔除
内存淘汰高内存不足/数据量大看evictions和slab调大-m/调整-f
数据库QPS飙升缓存miss看命中率暂停升级/限流/加速预热
新节点无流量客户端未识别看新节点连接数检查节点列表更新机制

5.6 几个我踩过的坑

第一个坑是预热脚本把新节点打满。有一次我用多线程全速灌数据,结果新节点CPU跑满,响应时间飙升,反而拖累了整个环。后来我加了速率限制,并且用stats实时监控,一旦响应时间超过阈值就自动降速。

第二个坑是客户端缓存了旧的节点列表。有些客户端为了性能,会把节点列表缓存在本地,更新后不会立即生效。结果旧节点下线后,客户端还在往旧节点发请求。解决办法是确认客户端的配置刷新机制,必要时通过重启或发信号强制刷新。

第三个坑是忽略了stats命令的权限变化。新版本可能默认禁用了某些stats子命令,导致监控脚本拿不到数据。升级前一定要确认监控依赖的命令在新版本上可用。

第四个坑是升级窗口选得太短。有一次为了赶在业务高峰前完成,四个节点替换只用了半小时,结果预热不充分,命中率掉了20个百分点。后来我调整策略,每个节点之间至少留30分钟观察期,整个升级拉长到2-3小时,虽然慢但稳。

6. 升级后的收尾与长期优化

6.1 确认升级完成的标准

四个节点全部替换完,不代表升级就结束了。我会确认这几件事:所有旧节点已下线且不再有连接;所有新节点运行稳定,命中率恢复到升级前水平;监控指标正常,没有持续告警;客户端节点列表已全部更新,没有残留旧节点。

确认无误后,把旧节点的资源释放掉,更新文档和资产记录。如果旧节点还要保留一段时间作为回滚备份,那就先别释放,等观察一周后再处理。

6.2 把升级流程脚本化

手动操作一次两次可以,但如果你的集群规模大、升级频繁,就一定要脚本化。我会把整个流程拆成几个脚本:节点启动脚本、预热脚本、节点替换脚本、指标检查脚本。每个脚本都有明确的输入输出和错误处理,这样下次升级时只需要改几个参数就能跑。

脚本化的另一个好处是可回滚。如果某一步出问题,可以快速执行反向操作。比如节点替换脚本要支持“把new1换回old1”的功能,预热脚本要支持“清空新节点数据”的功能。

6.3 长期优化:让下次升级更轻松

升级完成后,我会复盘整个过程,看看哪些地方可以优化。比如预热能不能更快、监控能不能更细、客户端容错能不能更强。这些优化不一定马上做,但记录下来,下次升级前就能派上用场。

还有一个长期建议:尽量保持客户端和服务端版本的同步演进。不要等到服务端版本落后太多才想起来升级,那样跨度大、风险高。我一般每隔几个小版本就跟一次,每次升级的改动小,风险也可控。

我个人在实际操作中的体会是,memcached平滑升级这件事,技术难度其实不高,难的是对业务影响面的判断和对细节的把控。你把连接、数据、容量这三层都想清楚了,把预热和监控做到位,剩下的就是耐心执行。最怕的就是图快,觉得“重启一下而已”,结果缓存雪崩,数据库被打挂,那就得不偿失了。最后再分享一个小技巧:升级前先用一个小流量业务做试点,验证整个流程,确认没问题再推广到核心业务。这个试点成本很低,但能帮你提前发现大部分坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进