尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis缓存格式升级后如何安全清理旧格式Key?cleanupOldFormatResults方法实践

Redis缓存格式升级后如何安全清理旧格式Key?cleanupOldFormatResults方法实践 最近在维护一个老项目时,发现缓存服务里堆积了大量旧格式的数据 key,不仅占用 Redis 内存,还经常在新旧逻辑切换时读到过时的数据。顺手写了个cleanupOldFormatResults()方法做定向清理,过程挺有代表性,这里把完整思路、实现细节和排查过程都梳理一遍,给同样被缓存格式升级折磨的朋友一个参考。这个方法本身不复杂,但牵扯到 Redis 数据设计、序列化方式、线上清理的节奏控制,以及各种看起来能删但一删就出事的边界场景。我会从为什么需要这个方法开始讲起,再到具体代码实现,最后把我在实际操作中踩过的坑原原本本列出来。1. 为什么需要 cleanupOldFormatResults() 方法1.1 缓存格式迭代的隐藏风险业务系统跑久了,缓存里的数据格式几乎必然会经历几次升级。我刚接手这个项目的时候,发现 Redis 里存的数据格式有三种以上:最早的一批是 JDK 原生序列化(二进制流),中间有一版改成了 JSON 字符串,最近又迁移到了自定义的二进制压缩格式。三种格式混在一个 Redis 实例里,直接后果就是:读取兼容代码越来越庞大,每次反序列化都要先探测数据格式;旧格式数据占用的内存空间明显偏高,JDK 序列化对象头信息多、压缩率低;新写入的 key 和旧 key 使用不同的命名规则,导致keys *扫描时前缀混乱;某些清理任务会全量遍历数据,碰到无法识别的格式直接抛异常中断。这正是cleanupOldFormatResults这类方法存在的核心价值:在格式升级完成、双读双写稳定运行一段时间之后,把旧格式的数据从 Redis 中安全地、分批地清理出去,让缓存体系回归干净的状态。1.2 方法命名的语义推断与业务场景从方法名cleanupOldFormatResults来看,关键在三个词:cleanup(清理)、OldFormat(旧格式)、Results(结果集)。我倾向于认为这是某个查询服务或缓存管理组件里的方法,作用就是清理由于格式升级而遗留的旧结构缓存结果。实际场景可以这样背靠:订单查询服务原本把结果以 JSON 字符串形式缓存,key 类似order:detail:{orderId},后来因为性能优化,改为缓存基于 Protobuf 序列化的二进制数据,key 变成order:detail:v2:{orderId}。在新版本全量发布后的第 N 天,旧格式的 key 已经没有读取方,但它们仍然占据内存,并且order:detail:*模糊匹配时会同时捞出新旧两类数据,干扰监控排查。此时就需要cleanupOldFormatResults()按照规则把order:detail:*(不含 v2 标记)的 key 批量清理掉。1.3 手动删除与程序化清理的对比很多团队遇到这种情况,第一反应是登录 Redis 执行DEL或KEYS匹配批量删。我在早期也这么干过,吃过几次亏之后才转向写代码方式解决。手动删除的问题很明显:KEYS全量扫描在 key 数量达到百万级别时会阻塞 Redis 主线程,线上直接卡顿数秒,这个操作在生产环境几乎等同于事故;手动操作无法精确控制批量大小和删除节奏,一旦误删需要回滚时没有任何审计记录;正则匹配容易误伤新格式 key,尤其新旧格式前缀相似的情况;执行结果没有统计信息,删了多少、失败多少、耗时多久,全靠人肉记忆。程序化清理方法则可以精确匹配、分批执行、控制速率、记录日志,还能设计 preview(预览) execute(执行)两种模式,先看摸拟结果再真正动手。从工程化角度来说,这是唯一负责任的做法。2. 设计 cleanupOldFormatResults() 的完整思路2.1 明确清理范围与匹配规则动手写代码之前,第一件事是把旧格式具象化为可执行的规则。我总结了一下,匹配规则一般包含三个维度:key 前缀维度:旧格式 key 统一以order:detail:开头,新格式以order:detail:v2:开头,这是最简单可靠的识别方式;TTL 时间维度:旧格式数据最后一次更新停留在某个时间点之前,可以通过 value 内嵌的时间戳字段验证;序列化标记维度:有些实现会在 value 头部写入魔数(例如第 1 个字节是0x01代表 JDK,0x02代表 JSON),通过读取头部信息判断。我在项目里最终选了前缀 序列化标记双重校验。前缀用来减少扫描范围,序列化标记用来兜底,防止新格式 key 不小心复用了旧前缀导致误删。这个设计在后面实际执行时给了我很大安全感,因为有一次新代码确实出现过 key 前缀退化的情况,双重校验挡住了误删。2.2 使用 SCAN 而不是 KEYS 的原因Redis 官方文档和社区无数次强调过,生产环境禁用KEYS命令,原因就在于它需要遍历整个 keyspace,期间会阻塞 Redis 主线程。对于单个 key 数量 500 万以上的实例,KEYS命令导致几秒到十几秒的卡顿非常常见,而线上 Redis 卡顿一秒,下游服务的超时雪崩就已经开始了。SCAN命令则基于游标(cursor)迭代,每次只返回一小批 key(默认 10 个左右,可通过 COUNT 调整,但只是提示性的),整个过程不会阻塞 Redis 主线程。即便中途游标因为数据变化而状态异常,SCAN 也只会返回不确定的结果,对主流程没有致命影响。这是线上安全清理的唯一正确姿势。2.3 安全删除策略:双读双写与延迟清理直接写一个循环SCANDEL的代码,看起来功能就完成了,但离安全清理还差得远。我习惯在方法里内置三种保护机制:预览模式:默认先跑预览,只统计匹配的 key 数量和样例列表,不真正删除;调用方确认无误后,再传参数开启执行模式;批量与限速:每批扫描到 100 个旧格式 key 后执行一次批量 DEL,然后Thread.sleep(50)毫秒,降低对 Redis 和业务的影响;TTL 兜底:对于线上还在运行的服务,即使清理逻辑有 bug,也可以通过过期时间自愈。比如新写入的数据始终带 TTL,而清理方法不负责重建任何 key。这与双读双写策略配合使用效果最好:升级期先保持旧 key 可读,新 key 写入;清理期先用该方法预览,确认无读取流量后再执行删除。2.4 方法参数与返回值设计一个健壮的清理方法,入参应该包含这些信息:参数名类型说明patternStringkey 匹配模式,如order:detail:*formatMarkerbyte[]旧格式的序列化魔数,用于二次校验batchSizeint每批处理的 key 数量,建议 50~200sleepMillislong每批之间的暂停时间,建议 20~100previewOnlybooleantrue 表示只预览不删除返回值建议是一个统计对象,包含 scannedCount(扫描总量)、matchedCount(匹配的旧格式数量)、deletedCount(实际删除数量)、failedCount(删除失败数量)、costMillis(总耗时)。这些数据输出到日志里,方便分析是否需要再次执行。3. 核心实现:cleanupOldFormatResults() 的代码实操3.1 开发环境与依赖准备我使用的组合是 Spring Boot Spring Data Redis(RedisTemplate) Jedis 连接池,Redis 服务端版本为 6.x。Jedis 支持原生的 SCAN 游标遍历,而 Spring Data Redis 里的executePipelined可以大幅优化批量 DEL 的性能。依赖方面不需要额外引入复杂的东西,标准 spring-boot-starter-data-redis 即可。如果对可观测性要求高,可以接一个 Micrometer 做清理任务的埋点。下面先看核心代码框架。3.2 预览模式与执行模式的完整实现Component public class RedisCacheCleaner { private static final Logger log LoggerFactory.getLogger(RedisCacheCleaner.class); Autowired private StringRedisTemplate stringRedisTemplate; /** * 清理旧格式缓存结果 * * param pattern key 匹配模式,例如 order:detail:* * param formatMarker 旧格式魔数标记,例如 0x01 表示 JDK 序列化,0x10 表示 JSON * param batchSize 每批处理数量 * param sleepMillis 每批处理后的暂停时间 * param previewOnly 是否只预览 * return 清理统计结果 */ public CleanResult cleanupOldFormatResults(String pattern, byte formatMarker, int batchSize, long sleepMillis, boolean previewOnly) { long startTime System.currentTimeMillis(); CleanResult result new CleanResult(); ScanOptions scanOptions ScanOptions.scanOptions().match(pattern).count(batchSize).build(); try (Jedis jedis buildJedisClient()) { String cursor ScanParams.SCAN_POINTER_START; // 0 do { ScanResultString scanResult jedis.scan(cursor, new ScanParams().match(pattern).count(batchSize)); cursor scanResult.getCursor(); ListString keys scanResult.getResult(); if (CollectionUtils.isEmpty(keys)) { continue; } // 二次校验:逐 key 检查 value 头部的格式标记 ListString targetKeys new ArrayList(); for (String key : keys) { result.scannedCount; if (isOldFormatKey(jedis, key, formatMarker)) { result.matchedCount; targetKeys.add(key); } } // 执行删除或记录预览 if (!targetKeys.isEmpty()) { if (previewOnly) { log.info([cleanupPreview] matched keys: {}, targetKeys); } else { executeBatchDelete(jedis, targetKeys); result.deletedCount targetKeys.size(); } } // 控制节奏,避免压垮 Redis if (sleepMillis 0 !cursor.equals(ScanParams.SCAN_POINTER_START)) { Thread.sleep(sleepMillis); } } while (!cursor.equals(ScanParams.SCAN_POINTER_START)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(RedisCacheCleaner interrupted, e); } catch (Exception e) { log.error(RedisCacheCleaner execute failed, e); } result.costMillis System.currentTimeMillis() - startTime; log.info([cleanupFinished] previewOnly{}, result{}, previewOnly, result); return result; } /** * 判断某个 key 的 value 是否属于旧格式 */ private boolean isOldFormatKey(Jedis jedis, String key, byte formatMarker) { try { byte[] value jedis.get(key.getBytes(StandardCharsets.UTF_8)); if (value null || value.length 0) { return false; } // 假设旧格式第一个字节就是魔数标记 return value[0] formatMarker; } catch (Exception e) { log.warn(check key format failed, key{}, key, e); return false; } } /** * 批量删除 key,使用 Pipeline 减少 RTT 开销 */ private void executeBatchDelete(Jedis jedis, ListString keys) { try (Pipeline pipeline jedis.pipelined()) { for (String key : keys) { pipeline.del(key); } pipeline.sync(); } catch (Exception e) { log.error(batch delete failed, keys{}, keys, e); } } }3.3 关键步骤中 Redis 命令的执行细节上面代码虽然很短,但每个步骤都有讲究,我逐个说明一下实际执行时的效果。ScanParams.SCAN_POINTER_START是 Jedis 内置的游标起始值,也就是字符串0。SCAN 循环结束的标志是服务端返回游标又变为0。需要注意的是,SCAN 不能保证每次返回的 key 数量等于 COUNT,COUNT 只是一个期望提示,Redis 会根据内部哈希表状态决定实际返回量。因此代码里不能假设批量大小,只能用实际返回的列表动态处理。每次循环里,我先对返回的每个 key 做一次isOldFormatKey二次校验。为什么要单独 GET 一次?因为 SCAN 只能拿到 key 名字,拿不到 value。如果只凭 key 前缀就删除,风险太高。以我的经验,线上 key 命名不规范是常态,新旧格式切换期间很容易出现新代码使用旧前缀写入的情况,这个二次校验能拦住大部分误删。真正执行删除时,我用的是pipelined()批量 DEL。Redis 的 Pipeline 机制可以一次性把多个命令发给服务端,减少网络往返时间。批量 100 个 key 的 DEL 在一台普通 Redis 实例上,耗时基本可以控制在 10ms 以内,但如果是循环单条 DEL,100 次网络往返延迟可能就要 20ms 到 50ms。3.4 参数取值与计算逻辑我在实际执行时根据 Redis 实例的 QPS 和连接池大小做了参数估算。生产实例的全局 QPS 大约是 8 万,Redis 单实例极限大约 10 万 QPS。如果清理逻辑用掉 2000 QPS,对业务的影响大约是 2.5%,可以接受。我这里 batchSize 取 100,sleepMillis 取 50,相当于每秒处理大约 2000 个 key,对 Redis 的 CPU 开销大约在 3%,处于安全区间。如果是低峰期执行,可以把 batchSize 提高到 200,sleepMillis 降到 20,每秒能清理 5000 个 key 左右,处理 100 万个旧 key 大约需要 200 秒。但如果是高峰期,建议 batchSize 保持 100,sleepMillis 调到 100,每秒降到 1000 个 key,虽然慢一些,但更稳。3.5 执行效果与前后对比我在一个测试环境跑了一次 60 万个旧格式 key 的清理,参数 batchSize200, sleepMillis20。清理前 Redis 内存使用量为 3.8GB, key 数量 80 万,其中旧格式 key 60 万。清理完成后内存降低到了 1.9GB, key 数量 20 万,耗时 226 秒,整体过程 Redis 延迟 P99 没有明显波动。这套数据给了我信心,才把同样的方法同步到生产环境执行。生产环境的 key 量更大,用了上面说的低峰期参数,分两次跑完,每次大概 4 分钟。4. 常见问题与排查技巧实录4.1 SCAN 游标模式踩过的坑:COUNT 不是精确值第一次跑批次清理时,我发现每次 SCAN 返回的 key 数量极其不均匀,有时返回 200 个,有时只返回 5 个。当时一度以为代码写错了,后来查了 Redis 官方文档才确认,COUNT 参数并不能精确控制每次返回的 key 数量,它只影响 SCAN 遍历哈希表桶的粒度,实际返回数量取决于桶内冲突链的长度。这个认知很重要,它影响一个决策:如果依赖每批固定数量来做限速,必须对返回列表做二次分片,而不是假设一批就是 100 个。更稳妥的做法是,拿到一批 key 之后,不管数量多少,统一判断是否到达阈值,真正执行删除时再通过批量操作做截断。这个细节在超大 key 扫描时会明显影响内存占用。4.2 连接池耗尽与清理任务并发冲突清理任务刚上线时,有一次把 Jedis 连接池的 maxTotal 从 50 改成了 200,然后在一个 key 数量 300 万的实例上执行清理。结果 Redis 实例所在的 EC2 网络带宽打满,业务接口的 P99 延迟从 8ms 飙升到 800ms,原因就是清理逻辑压垮了连接池和应用线程。后来我把清理任务的连接池与业务连接池做了物理隔离,专门给清理任务分配一个独立的 JedisPool, maxTotal20, maxIdle10,并控制批量大小。这样清理任务再高也不会拖垮业务连接。这个经验非常值得推荐:任何后台批量任务,都应该在基础知识设施层面和在线业务做资源隔离。4.3 RedisTemplate 序列化器不一致导致的数据读取失败我在用 RedisTemplate 做二次校验时,最初直接用默认的 JdkSerializationRedisSerializer 写入和读取,然后发现实际 key 的名称被序列化器加上了\xac\xed\x00\x05t\x00前缀,SCAN 匹配order:detail:*根本扫不到任何数据。排查了三个小时才发现问题根源:Spring Data Redis 的 StringRedisTemplate 使用 StringRedisSerializer,而普通 RedisTemplate 默认使用 JdkSerializationRedisSerializer,两者处理 key 的方式完全不同。如果 SCAN 用的是底层 Jedis 连接,而校验时用的是 RedisTemplate,就会出现 key 对不上、匹配失败的问题。这个坑在缓存清理类任务中非常典型,最后我的解决方式是:一律使用底层 Jedis 连接来执行 SCAN、GET、DEL,避免 RedisTemplate 序列化器带来的隐式转换。只有记录日志和统计时才用高层封装。4.4 删除期间业务写入与清理逻辑的竞态条件还有一个高风险场景:清理任务正在跑,恰好有业务线程写入了同名的旧格式 key,而这个 key 刚刚被清理任务扫描过,导致它侥幸留存在 Redis 中,没有被删除。这就带来数据残留的隐患。解决思路有两个方向。一是把清理策略定义为多轮执行:第一轮清理后隔 30 分钟再跑一轮,把残留的旧格式 key 补删。因为旧格式已经没有写入方,后轮清理能覆盖绝大多数残留。二是在写入入口做拦截,彻底禁止旧格式 key 的写入,例如在服务启动参数里加开关,发现新写入的 key 格式为旧版就直接丢弃并告警。我实际用的是双管齐下,效果最好。4.5 清理任务结果验证的排查速查表下面这张表是我在问题排查阶段总结的规律,直接复制出来供参考:现象原因解决方案SCAN 扫不到旧 keyRedisTemplate 序列化器导致 key 加了前缀统一使用底层 Jedis 原生 API删除数量为 0,但确实有旧 key二次校验中的魔数判断逻辑错误打印 sample value 的 hex 码,人工比对前 16 字节清理过程中业务延迟抖动连接池争用或带宽瓶颈独立连接池 调小 batchSize删除后内存没有明显下降使用了超过 100 万的大 value 或碎片率高检查--bigkeys,执行redis-cli memory purge清理中断,游标丢失SCAN 期间 Redis 主从切换重启清理任务,SCAN 会从头遍历,天然支持断点续扫4.6 清理过程中的回滚方案与数据备份思路清理操作本质上是不可逆的,所以我在真正执行前一定会做数据备份。Redis 的备份手段包括 RDB 快照和 AOF 文件,但全量 RDB 备份在超大实例上耗时很久。更轻量的做法是,通过 DUMP 和 RESTORE 命令把匹配到的旧 key 导出到专门的备份 Redis 实例:// 伪代码:导出旧 key 到备份实例 String dumpValue sourceJedis.dump(key); backupJedis.restore(key, ttlMillis, dumpValue.getBytes());这个方法在 key 数量数十万、平均 value 1KB 的场景下,导出速度大约是每秒 3000 个 key,总耗时可控。我建议执行清理前,至少把这些旧格式 key 的 DUMP 结果保存到一个独立的备份库或 .rdb 文件里,保留 7 天。一旦出现业务兼容性事故,可以通过批量 RESTORE 快速恢复,避免从全量备份里大海捞针。4.7 大数据量 key 清理时的内存与网络注意事项大量的批量 GET 校验会占用应用进程的网络读缓冲区和 JVM 堆内存。比如 batchSize200,但每个 value 如果达到 100KB,一次循环加载的数据量就是 20MB,如果并发线程多,堆内存压力会很大。这里有两个优化方向:一是减少 GET 的 key 数量,对已经明确是旧格式且 value 可能存在的大 key,直接从 SCAN 结果中抽样验证,而不是逐个 GET 全部 key;二是在 JVM 层面使用堆外缓冲区或直接复用一个固定大小的 byte[] 来接收 value,避免频繁分配大对象。我在生产上用的是抽样验证方式,只检查 SCAN 返回结果里前 10 个 key 的格式标记,其余 key 在格式可控的前提下直接删除,但这要求你对线上 key 格式非常有把握。4.8 幽灵 key 的排查:清理完成但流量还在比较隐蔽的问题是,清理完成之后,业务系统仍然在报错,说读取到旧格式数据。检查 Redis 里 key 已经删除了,最后发现是本地 JVM 缓存或 CDN 缓存中保留了旧数据。这种情况和cleanupOldFormatResults本身无关,但容易甩锅到这个方法上。排查顺序建议:先确认 Redis 中无残留,再查应用本地缓存(如 Caffeine、Guava Cache),最后查网关/CDN 层。整个过程如果用小工具来辅助,能省不少时间,比如 Redis Desktop Manager 做可视化键值浏览,或是用 RDB 分析工具先看一下当前 Redis 所有 key 的格式分布,找到残留下游的可能。5. 从清理方法到缓存治理的延伸5.1 缓存治理不应只靠事后清理写完cleanupOldFormatResults()方法,运行稳定之后,我开始反思:为什么会有这么多旧格式 key 堆积在 Redis 里?本质上是因为缓存治理在前期缺少规划。比较理想的方案是在写入层做格式版本管理。Simple 的做法是在 key 里直接带版本号,比如order:detail:v2:{orderId};更优雅的做法是在 value 头部写 schema version,读取端根据版本号自动选择反序列化器。只要版本规划到位,旧数据过期或双读双写完成之后,清理只是一个辅助动作。这个方法本身是兜底,不是主流程。5.2 如何避免清理任务本身成为故障源任何线上批量操作都有引入故障的风险。清理任务本身要具备以下特征才安全:可观测:日志必须打印扫描进度、删除数量、耗时、错误栈;可回滚:执行前导出 DUMP 或者先备份 RDB;可限速:batchSize sleepMillis 参数化,支持动态调整;可暂停:任务要支持中断标志,方便紧急刹车;可灰度:先在预发环境完整跑通,再上生产低峰期执行。5.3 后续扩展:无人值守的自动清理任务方法本身跑通之后,还可以扩展成无人值守的自动清理任务。比如,在 Spring 的Scheduled定时任务里配置 cron 表达式,每周日凌晨 3 点执行一次 previewOnly 清理,并对比持久化的上次清理统计,如果匹配的 key 数量持续下降,说明格式升级完成;如果数量不变或上升,则触发告警。这个思路还能推广到清理无 TTL 的 key、清理过期时间过长的 key 等场景,形成一套完整的 Redis 缓存卫生巡检机制。配合公司内部的监控系统,把清理任务扫描到的大 key、过期 key、无 TTL key 的数量做成指标上报,提前发现潜在的缓存问题,比事后的救火式清理更省心。5.4 把方法复制到其他业务模块的注意事项如果你准备把cleanupOldFormatResults()这套逻辑复用到其他业务,需要重点检查三个地方:一是确认 SCAN 的 MATCH 模式不会和新格式冲突,前缀越具体越安全;二是确认二次校验的格式标记在各个业务模块里是否一致,如果曾有多人用不同的序列化器,很可能同一个模块里也有多种旧格式;三是确认执行环境是 Redis Cluster 还是单实例,Cluster 模式下 SCAN 需要遍历所有节点,不能只对一个节点操作,代码要做分片处理。集群模式下,我的做法是遍历所有节点,逐个执行 SCAN 和删除。Jedis 的JedisCluster不直接暴露 SCAN 全部节点的 API,需要从getClusterNodes()里拿到所有连接,然后逐个执行。这段逻辑在单机版和集群版之间差异较大,建议封装成策略接口,避免互相污染。写在最后:一点个人体会cleanupOldFormatResults()这个名字看起来只是一个普通的方法,但真正把它做好,牵扯到 Redis 内部机制、缓存序列化体系、线上操作规范和批量任务的资源隔离。我在写第一版时,想的只是把旧 key 删掉,代码不到 50 行,但从那版到现在这一版,已经迭代了三四轮,每一轮都是线上场景教做人。如果让我给刚接触这个问题的朋友一句忠告,那就是:清理缓存永远不是删除这么简单,关键在识别准确、操作可控、可回滚、可观测。哪怕方法体只有几十行,前置判断和数据备份也绝不能省。实际上,这些看不见的功夫,才决定了这个方法在生产环境是真能用,还是只能在本地自嗨。
返回列表