Redis大key判定标准与多key拆分方案
凌晨两点被电话叫起来,原因是一条 Redis 慢查询告警:某条 HGETALL 命令耗时 800ms,把接口 P99 从 120ms 顶到了 900ms。登上机器一看,那个 Hash 里塞了 260 万个 field,整个 value 接近 300MB。这类事故在有一定体量的业务里几乎避不开,所以我常拿“Redis 大key的判定标准与多key拆分方案”这个话题去面候选人——不是考背诵,而是看这个人有没有真的在生产上救过火。这篇文章就把我在实际项目里踩过的、验证过的、以及面试时听到的好答案烂答案,一次性摊开讲清楚。
大key这件事,说穿了不复杂:单个 key 承载的数据量或访问量超出了 Redis 单线程模型能从容处理的范围。但它牵扯出的东西很多——内存分布、网络带宽、持久化、主从复制、集群槽位倾斜,每一个都能独立把服务打挂。多key拆分是治理手段里最主流的一种,但它不是万能药,拆错了反而把简单问题搞复杂。下面这些内容适合三类人:写业务代码时习惯hset一把梭的初中级开发、正在准备面试想搞明白原理的求职者,以及线上已经出现慢查询需要紧急止血的运维同学。我会从判定标准讲到拆分设计,再落到四类数据结构的可直接抄的代码,最后把迁移和踩坑都过一遍。
1. 大key的判定标准与被放大的三重危害
1.1 多大算大:三条不同口径的判定线
很多人面试时被问“多大算大key”,条件反射答“超过 10KB”。这个答案太单薄,因为 String 和 Hash 的“大”根本不是一回事。我一般按三个口径分:
| 口径 | 类型 | 经验阈值 | 典型触发的问题 |
|---|---|---|---|
| 字节体积 | String | value > 10KB(高频访问场景收紧到 1KB) | 网络往返耗时、带宽占用 |
| 元素数量 | Hash / List / Set / ZSet | 元素数 > 5000,或总体积 > 1MB | HGETALL、SMEMBERS、LRANGE 全量读阻塞 |
| 访问热度 | 任意类型 | 单 key QPS > 5000(单节点) | 集群槽位热点、单核 CPU 打满 |
这三条线要分开看。一条 100KB 的 String 如果每秒读十万次,它比一个 5MB 但一天只读一次的 Hash 危险得多;反过来,一个 200 万元素的 Set 哪怕 QPS 只有 10,一次SMEMBERS也能让整个实例卡住半秒以上,因为那 200 万个元素的序列化和网络发送全在 Redis 的单线程里做。
字节线也不是拍脑袋定的。Redis 处理命令时,value 需要走“读取-序列化-写入 socket 缓冲区”这条链,10KB 大概对应一次 0.1ms 量级的处理,而 1MB 就是 10ms 量级。业务侧通常要求 Redis 单次操作控制在 1ms 以内,所以 10KB 是个比较务实的界。元素数量线的 5000 也是类似逻辑——5000 个短 field 拼起来差不多 100KB 到 300KB,再大就该警惕。
需要强调一点:判定阈值必须跟业务场景绑定,不能跨团队照抄。缓存型业务(比如商品详情)容忍度可以高一些,因为读放大有 CDN 和本地缓存顶着;而计数型、会话型的场景,1KB 都算大。我在某个内容平台项目里定的规则就是“String > 8KB 或集合类 > 2000 元素,一律进周巡检名单”,跑了一年多,误报率很低。
1.2 危害拆解:一次大key阻塞的完整链路
光说“会阻塞”太笼统,我把它拆成五条链路,每一条都能对上真实现象。
第一条是命令执行阻塞。Redis 6.0 之后虽然引入了 IO 多线程处理网络读写,但命令执行始终是单线程的。一个HGETALL返回 2MB 数据,这 2MB 的遍历、内存拷贝、resp 协议编码全在那一个线程里完成,期间所有其他客户端的命令都在排队。2MB 大概 10~20ms,如果这个 key 被高频调用,就变成持续性的排队雪崩。
第二条是网络带宽挤占。假设一个 200 万元素的 Hash 被HGETALL每秒调用 50 次,那就是 300MB × 50 = 15GB/s 的出流量。这个数字已经超出绝大多数实例的网卡上限,结果是所有正常的请求都跟着超时。带宽打满的表面现象往往是“偶发超时”“连接池被打穿”,跟大key好像没关系,排查时特别容易走偏。
第三条是删除阻塞,这条最容易被忽略。你用DEL删一个 200 万元素的 Hash,Redis 需要先释放这 200 万个对象的引用、回收内存,整个过程同步执行,实测能卡住两三秒。正确做法是UNLINK,它把内存回收丢到后台线程异步做,主线程只做一次 key 摘除。Redis 4.0 才有 UNLINK,老版本只能用SCAN+ 分批HDEL慢慢删。
第四条是持久化放大。RDB 落盘时,大key会导致fork之后的写时复制(COW)内存膨胀,因为父进程在 fork 期间只要有写入,被改动的内存页就要复制一份。一个 300MB 的 Hash 如果整体被改写,可能直接多出几百 MB 的额外内存,极端情况下触发 OOM。AOF 也是同理,大key的一次写入会产生一条巨大的 AOF 记录,重写和回放都会被拖慢。
第五条是集群槽位倾斜。在集群模式下,一个大key整体落在某一个槽上,而槽又落到某一个节点上。结果是那个节点的内存和 CPU 使用率远高于其他节点,扩容也没用——因为热点还是那一个槽。这种现象在监控图上非常典型:所有节点的内存都在 40%,就一个节点 85%。
1.3 面试时真正被考察的思维链
我面人的时候,问“你怎么处理大key”,最想听到的是一条完整的链:怎么发现、怎么判定、怎么止血、怎么根治、怎么做长期防护。只答“拆成多个小key”的候选人,我会追问“拆多少片、怎么算的、拆完原子性怎么办”,多半就答不上来了。
比较理想的回答层次是这样的:先说发现手段(--bigkeys扫描、MEMORY USAGE、慢日志、RDB 离线分析),再说危害(阻塞、带宽、持久化、倾斜),然后给治理方案(拆分、压缩、换存储),最后补一句预防(写入拦截、阈值告警、编码规范)。这条链能完整走下来的人,基本上手上是真管过 Redis 的。
2. 拆分方案的整体设计:先定维度再定粒度
2.1 三条拆分主线及其适用边界
拆分不是只有“按哈希取模”一条路。我把它归成三条主线,选哪条取决于数据结构和读写模式。
第一条是业务维度拆分。这是最自然的方式,比如按用户 ID 拆、按租户拆、按业务线拆。典型例子是用户会话:session:{uid}本身就是按用户分开的,天然不构成大key。真正需要业务拆分的场景是“一个大对象里塞了多个业务域的数据”,比如商品详情里既有基础属性又有营销信息又有库存快照,正确做法是把它们拆成独立 key,各自设不同的过期时间。
第二条是哈希分桶拆分。适合单个业务对象的内部结构无法再细分的情况,比如一个用户的画像标签有 5000 个字段。做法是对 field 名或元素本身做哈希,取模落到 N 个桶里,每个桶是一个独立的 key。这种方式拆分均匀度高,代价是失去整体的原子性和一次性读取能力。
第三条是 value 内部切片。主要针对超大 String,比如一个 8MB 的 JSON 文档或一张 Base64 编码的图片。做法是按固定字节数切成若干片,每片一个 key,读取时重新拼装。
还有一条补充思路值得单独提:冷热分离。一个 5000 字段的对象,实际高频访问的可能只有 20 个字段,剩下的都是长尾。那就把热字段拎出来单独存一个小 Hash,冷字段留在原地或者挪到冷存储。这个方案对读性能提升最明显,改造量也最小,但前提是你得先摸清楚访问分布——MONITOR采样或者客户端埋点都能拿到。
2.2 桶数量的计算:两个约束取最大值
“拆几个桶”是最容易被敷衍过去的环节。我见过有人随手拆 10 个,结果单桶还是 20 万 field,等于没拆。正确的算法是同时满足两个约束:
- 约束一:单桶元素数 ≤ 上限(比如 1000)
- 约束二:单桶字节数 ≤ 上限(比如 1MB)
- 桶数 N = max(ceil(总元素数 / 元素上限), ceil(总体积 / 字节上限)),然后向上取到 2 的幂
举个具体例子。某项目有个用户标签 Hash,200 万个 field,平均每个 field 加 value 约 100 字节,总体积约 200MB。代入公式:
- 按元素数:2,000,000 / 1000 = 2000 个桶
- 按字节数:200MB / 1MB = 200 个桶
- 取最大值 2000,向上取 2 的幂得到 2048
拆完平均每桶约 977 个 field、97KB 左右,完全在安全范围内。选 2 的幂是为了把取模换成位与运算(hash & 2047),比hash % 2048快一截,虽然 Redis 侧其实不差这点,但客户端侧调用量大的时候还是有点意义。
注意:桶数量一旦上线就尽量别再改。因为桶号是靠取模算出来的,桶数从 2048 改成 1024,所有历史数据的桶号全变,等于要做一次全量迁移。所以初期宁可多拆一点,留出增长空间。
2.3 拆分前必须先回答的四个问题
我每次做拆分方案评审,都会强制要求把这四个问题写进文档里,答不上来就不许动手。
问题一:读模式是单字段读还是整体读?如果业务 90% 是整体读(比如渲染整个详情页),那拆成多个 key 之后需要 N 次往返或者一次 pipeline,端到端延迟不一定下降,这时候冷热分离比硬拆更合适。如果主要是单字段读,拆分收益极大,因为读一个字段只需要访问一个桶。
问题二:需不需要跨桶原子性?如果需要,就必须在 key 里加 hash tag(比如profile:{12345}:0),保证同一个对象的多个桶落在同一个槽上,这样才能用 Lua 或者 MULTI 包起来。但加了 hash tag 就意味着所有桶压在同一个节点上,失去了分散压力的意义。这是个明确的权衡点,必须显式决策。
问题三:有没有顺序性要求?List 拆片时尤其要注意。如果按时间窗口切片(feed:{uid}:202506这种),LPUSH的顺序天然保持,新数据进新片,没问题。但如果按成员哈希切片,LRANGE出来的顺序就完全乱了。所以 List 一般不按哈希拆,只按时间或按序号区间拆。
问题四:有没有全量遍历需求?如果业务存在“遍历所有元素”的操作(比如统计、导出、对账),拆分之后就要遍历 N 个桶,SCAN的复杂度上升。这时候要么维护一个索引 key 记录所有桶号,要么接受遍历成本上升。
3. 四类数据结构的可直接落地的拆分实践
3.1 String 超大 value:切片存储与拼装读取
某项目的商品详情缓存是一个 8MB 的 JSON。直接GET的耗时接近 100ms,而且每次大促前预热时会持续压满带宽。改造方案是按 64KB 切片。
切片大小的选择有讲究。太小会导致 key 数量爆炸、pipeline 命令数过多;太大则单片仍然偏重。64KB 到 256KB 是比较舒服的区间,8MB 切成 64KB 是 128 片。
public class ChunkedStringStore { private static final int CHUNK_SIZE = 64 * 1024; private static final String PREFIX = "prod:detail:"; private final StringRedisTemplate redis; public ChunkedStringStore(StringRedisTemplate redis) { this.redis = redis; } public void put(String id, byte[] payload, Duration ttl) { int total = (payload.length + CHUNK_SIZE - 1) / CHUNK_SIZE; Map<String, String> batch = new HashMap<>(total + 1); for (int i = 0; i < total; i++) { int from = i * CHUNK_SIZE; int to = Math.min(from + CHUNK_SIZE, payload.length); byte[] slice = Arrays.copyOfRange(payload, from, to); batch.put(chunkKey(id, i), Base64.getEncoder().encodeToString(slice)); } batch.put(metaKey(id), String.valueOf(total)); redis.executePipelined((RedisCallback<Object>) conn -> { batch.forEach((k, v) -> { byte[] kb = k.getBytes(StandardCharsets.UTF_8); byte[] vb = v.getBytes(StandardCharsets.UTF_8); conn.stringCommands().setEx(kb, ttl.getSeconds(), vb); }); return null; }); } public byte[] get(String id) { String totalStr = redis.opsForValue().get(metaKey(id)); if (totalStr == null) { return null; } int total = Integer.parseInt(totalStr); List<String> keys = new ArrayList<>(total); for (int i = 0; i < total; i++) { keys.add(chunkKey(id, i)); } List<String> slices = redis.opsForValue().multiGet(keys); ByteArrayOutputStream out = new ByteArrayOutputStream(total * CHUNK_SIZE); for (String s : slices) { if (s == null) { return null; } out.writeBytes(Base64.getDecoder().decode(s)); } return out.toByteArray(); } private String chunkKey(String id, int idx) { return PREFIX + id + ":seg:" + idx; } private String metaKey(String id) { return PREFIX + id + ":meta"; } }几个实操细节必须说明。第一,切片是按字节切的,绝对不能在中途把每一片单独按 UTF-8 解码——一个中文字符占 3 个字节,很可能被切成两半,解码就出乱码。正确的做法是全程保持byte[],全部拼完再统一 decode。上面这段代码用 Base64 编码后存 String,是为了兼容StringRedisTemplate,如果直接用RedisTemplate<String, byte[]>可以省掉编解码开销。
第二,片数要单独存一个 meta key,否则读的时候根本不知道有多少片。meta key 的 TTL 要跟数据片一致,但不能反过来——如果 meta 先过期了,残留的数据片就变成孤儿 key,只能靠定期扫描清理。
第三,读的时候用multiGet一次发出所有片请求,StringRedisTemplate底层会走 pipeline,128 次往返压缩成 1 次。如果不走集群模式,这是最省事的方案。
3.2 Hash 巨型对象:二次哈希分桶
用户画像、购物车、配置表这类数据,基本都用 Hash 存。当 field 数量涨到几十万,HGETALL就成了定时炸弹。分桶方案如下:
public class ShardedHash { private static final int BUCKETS = 2048; private static final int MASK = BUCKETS - 1; private static final String PREFIX = "profile:"; private final StringRedisTemplate redis; public ShardedHash(StringRedisTemplate redis) { this.redis = redis; } private int bucketOf(String field) { int h = field.hashCode(); h ^= (h >>> 16); return h & MASK; } private String bucketKey(String uid, int bucket) { return PREFIX + uid + ":" + bucket; } public void put(String uid, String field, String value) { redis.opsForHash().put(bucketKey(uid, bucketOf(field)), field, value); } public String get(String uid, String field) { Object v = redis.opsForHash().get(bucketKey(uid, bucketOf(field)), field); return v == null ? null : v.toString(); } public Map<String, String> getAll(String uid) { List<String> keys = new ArrayList<>(BUCKETS); for (int i = 0; i < BUCKETS; i++) { keys.add(bucketKey(uid, i)); } List<Object> results = redis.executePipelined((RedisCallback<Object>) conn -> { for (String k : keys) { conn.hashCommands().hGetAll(k.getBytes(StandardCharsets.UTF_8)); } return null; }); Map<String, String> merged = new HashMap<>(); for (Object r : results) { if (r instanceof Map) { ((Map<?, ?>) r).forEach((k, v) -> merged.put(String.valueOf(k), String.valueOf(v))); } } return merged; } }这里最关键的实现点是哈希函数的选择。String.hashCode()对短字符串分布还行,但对形如tag_0001、tag_0002这种有规律的 field 名,低位容易聚集。所以我在上面做了h ^= (h >>> 16)的高低位混淆,跟 HashMap 里那套扰动函数是一个思路。如果 field 名规律性特别强,建议直接上 CRC32 或者 MurmurHash,分布更均匀。
第二个点是空桶的处理。2048 个桶里,如果这个用户只有 30 个字段,那 2018 个桶都是不存在的空 key。HGETALL一个不存在的 key 返回空 Map,代价很小,但 2048 次 pipeline 调用仍然是实打实的开销。所以实际项目里我一般会做一个自适应:桶数不是固定的 2048,而是根据对象的实际字段数量动态算。字段少于 1000 的直接单 key 存,超过才分桶,桶数按 2.2 节的公式算出来存到 meta 里。
第三个点是HGETALL的替代。很多业务其实只需要读部分字段,那就用HMGET,但HMGET要求所有 field 在同一个 key 里。分桶之后只能按桶分组,每个桶发一次HMGET,然后 pipeline 打包。写起来啰嗦,但比全量读便宜得多。
3.3 List / ZSet / Set:按时间片和成员哈希切片
这三类结构各有各的拆法,不能混用一套模板。
List 最合适按时间切片。消息流、操作日志、评论列表这类数据天然有时间维度,key 设计成feed:{uid}:{yyyyMM},每个月一个 List,LPUSH新数据进当前月,读的时候按需拼最近几个月。这样单 key 的元素数天然被时间窗口限制住了,而且历史数据可以按需设置更长的 TTL 甚至归档。要注意的是跨片的范围查询:想读最近 100 条,可能需要读 2 到 3 个片然后合并,好在LRANGE支持负数索引,实现起来不复杂。
ZSet 按周期切分最自然。排行榜类需求,日榜rank:{yyyyMMdd}、周榜rank:{yyyyWW}、月榜rank:{yyyyMM},各存各的,互不干扰。缺点是跨周期的聚合排序需要应用层归并。如果业务确实需要“全站总榜”,那就得用定时任务把周期榜的结果汇总到一个总榜里,而不是让客户端去实时归并。
Set 只能按成员哈希拆。标签集合、好友关系这类数据没有天然的时间序,只能对成员本身做哈希。tags:{id}:{bucket},判断是否包含某成员时,先算出桶号再SISMEMBER。这里有个必须接受的代价:SINTER、SUNION、SDIFF这些集合运算彻底没法用了,因为参与运算的两个集合分片规则不同。如果业务强依赖集合运算,那就老老实实别拆,改用别的方案(比如用 BitMap 或者换到支持集合运算的存储)。
3.4 实在拆不动的场景:压缩、换存储与协议优化
不是所有大key都能拆。有三种情况我建议放弃拆分,换思路。
第一种是强事务场景。比如库存扣减、账户余额,它们的正确性依赖单 key 的原子操作。拆了之后跨 key 的原子性只能靠 Lua 或者分布式锁,复杂度暴涨,出问题的概率远高于收益。这类 key 本来就该小——余额只有一个数字,怎么会大?如果大了,说明设计有问题,该拆的是业务模型不是存储结构。
第二种是超大二进制对象。视频、图片、安装包这类东西根本不该进 Redis。正确的做法是存到对象存储,Redis 里只放一个指针(URL 或 ID)。我见过有人把 PDF 文件 Base64 之后塞进 Redis,一个 key 十几 MB,这种属于用错了工具。
第三种是必须整体读的复杂聚合。如果一个 5MB 的 JSON 每次都要整体反序列化成对象,那切片和拼装只是把一次大传输变成多次小传输,总字节数没变,CPU 侧的序列化开销反而更高。这时候更该做的是压缩——开启 value 压缩(比如用 Snappy、LZ4 在应用层压一遍再存),通常能压到原来的 20% 到 30%,比拆分省事得多。或者考虑把这份数据从缓存层挪到搜索层、文档层。
4. 迁移落地:双写、灰度与一致性校验三板斧
4.1 双写期的读写路由设计
拆分方案想得再漂亮,上线才是真正的考验。历史数据不能停机迁移,所以必须要有一段双写期。路由逻辑我用一个统一的组件收口:
public class SplitRouter { private final StringRedisTemplate redis; private final SplitSwitch splitSwitch; public void write(String uid, String field, String value) { if (splitSwitch.isDualWrite(uid)) { redis.opsForHash().put(legacyKey(uid), field, value); } if (splitSwitch.isSplitRead(uid)) { shardedHash.put(uid, field, value); } } public String read(String uid, String field) { if (splitSwitch.isSplitRead(uid)) { String v = shardedHash.get(uid, field); if (v != null) { return v; } return legacyRead(uid, field); } return legacyRead(uid, field); } }设计要点有三个。第一,写路径以新结构为准,旧结构只做兼容,即新结构写成功、旧结构写失败可以容忍,反过来不行。第二,读路径先读新结构,miss 才回源旧结构,这样迁移过程中不会出现读到空数据的情况。第三,旧 key 的 TTL 不能直接删掉,要持续续期,否则双写期间一部分用户的数据会因为旧 key 过期而重新走回源逻辑,读放大严重。
双写期的长度取决于数据规模和访问频率。一般我建议至少覆盖一个完整的访问周期(比如一周),确保所有活跃用户的数据都被自然迁移过一遍。对于长期不活跃的用户,可以在双写结束时跑一次全量迁移脚本兜底。
4.2 灰度切流与回滚预案
灰度必须按用户 ID 取模来切,不能按流量随机切。原因是同一个用户的数据必须始终走同一套逻辑,否则会出现“这次读新结构、下次读旧结构”的抖动,数据对不上。
灰度节奏我一般这么排:5% 跑一天,观察错误率和延迟;没问题放到 20% 跑一天;然后 50%、100%。每一档都要盯三个指标:新结构的HGET延迟分布、旧结构的读命中率(应该逐步下降)、以及双写的失败计数。
回滚预案必须在上线前就准备好,而不是出事再想。具体就是配置中心里放一个开关,把isSplitRead全部置为 false,读流量瞬间全部回落到旧结构。这时候新结构还留着,不影响数据,等排查清楚再重新灰度。这个开关必须做到秒级生效,不能依赖重启。
注意:回滚开关只管读,不管写。双写逻辑一旦开启就不建议关掉,因为关掉之后新结构就停止更新,再想恢复又得重新补数据。所以开关要设计成“读写分离的两个开关”,而不是一个总开关。
4.3 一致性校验的脚本实现思路
双写期间最怕的是“以为写进去了,其实没有”。所以要跑对账。校验的核心思路是:遍历旧结构的所有用户,把新结构的多个桶拼起来,跟旧结构做摘要比对。
public class ConsistencyChecker { public void check(String uid) { Map<String, String> legacy = redis.opsForHash().entries("profile:" + uid); Map<String, String> split = shardedHash.getAll(uid); if (!legacy.keySet().equals(split.keySet())) { log.warn("field set mismatch, uid={}, legacy={}, split={}", uid, legacy.size(), split.size()); return; } for (Map.Entry<String, String> e : legacy.entrySet()) { String other = split.get(e.getKey()); if (!Objects.equals(e.getValue(), other)) { log.warn("value mismatch, uid={}, field={}", uid, e.getKey()); } } } }对账脚本要注意两点。第一,不能一次跑全量,要用SCAN分批,每批 500 个 key,跑完 sleep 一下,避免把实例压垮。第二,比对结果的输出要做聚合,不能每条差异都打一行日志——线上跑的时候差异可能是几十万条,日志直接把磁盘写满。做法是每个 uid 只记录差异数量和前三条样例。
发现差异之后的修复策略,我倾向于“以新结构为准,重写旧结构”——因为新结构是未来的权威数据源。但如果差异集中在某个时间段,那说明双写逻辑有 bug,这时候应该先修代码再修复数据。
5. 大key的发现手段与长期防护体系
5.1 五种发现手段的适用场景对比
治理的前提是能发现。我把常用的发现手段列一下,各有各的适用范围。
| 手段 | 命令/方式 | 优点 | 局限 |
|---|---|---|---|
| 内置扫描 | redis-cli --bigkeys | 开箱即用,按类型给出最大 key | 基于 SCAN 采样,可能漏掉;只报最大不报全部 |
| 内存查询 | MEMORY USAGE key SAMPLES 0 | 精确到单 key 字节数 | 需要先知道 key 名,全量扫代价高 |
| 慢日志 | SLOWLOG GET | 直接反映线上真实耗时 | 只能抓到慢的命令,抓不到“快但频繁”的大key |
| 离线分析 | 解析 RDB 文件 | 全量精确,不影响线上 | 需要额外机器,有延迟 |
| 客户端埋点 | 统计命令参数长度 | 能抓写入侧,提前拦截 | 需要改代码接入 |
实际生产里我是组合用的。日常巡检跑--bigkeys,一周一次,出个 Top 50 名单;慢日志实时采集,超过 50ms 的命令全部上报;RDB 离线分析每月一次,做精确摸底。三者交叉验证,基本不会漏。
MEMORY USAGE那个命令要多说一句,它默认会采样估算,加SAMPLES 0才是全量精确计算,但代价是可能阻塞。所以线上千万别对着几百万元素的 key 执行,我吃过这个亏——一次MEMORY USAGE卡了 1.2 秒,直接触发了主从切换。
5.2 写入侧的硬拦截与阈值告警
发现是被动的,防护要主动。我在编码层加了两道闸。
第一道是序列化层的体积检查。所有写 Redis 的操作都走一个统一的RedisWriter组件,写入前检查 value 的字节数,超过配置的阈值就直接抛异常并打点,让问题在测试环境就暴露,而不是等到线上。阈值按业务配置,缓存类给 16KB,会话类给 4KB。
public void setEx(String key, String value, long seconds) { int bytes = value.getBytes(StandardCharsets.UTF_8).length; if (bytes > maxValueBytes) { metrics.counter("redis.write.reject").increment(); throw new IllegalStateException( "value too large: " + bytes + " bytes, key=" + key); } redis.opsForValue().set(key, value, seconds, TimeUnit.SECONDS); }第二道是元素数量的软告警。集合类写入时无法预知最终大小,所以改成写后检查:每写入 1000 次采样一次HLEN,超过 5000 就发告警,超过 50000 就自动触发迁移任务。这个逻辑听起来粗糙,但实测非常有效——大部分大key都是慢慢长起来的,早期发现处理成本极低。
还有一条容易被忽略的:给所有集合类 key 强制设置 TTL。我见过太多“临时数据”因为忘了设过期时间,跑了两年变成几百万条。在封装层里把 TTL 做成必填参数,比任何事后治理都管用。
6. 高频追问与踩坑实录
6.1 拆分之后的批量操作陷阱
最容易踩的坑是MGET和集群的CROSSSLOT报错。把一个大 String 拆成 128 片之后,如果直接redisTemplate.opsForValue().multiGet(keys),在集群模式下会直接抛异常,因为这 128 个 key 分布在不同的槽上。
解决方案有两条。方案 A 是给所有分片 key 加 hash tag,比如prod:{12345}:seg:0,这样同一个商品的 128 片全部落在同一个槽上,MGET可以正常执行,也支持 Lua 原子操作。代价是这个商品的所有分片压在同一个节点,如果这个商品是爆款,节点就成了热点。
方案 B 是不加 tag,改用 pipeline 逐个 GET。这样分片自然分散到不同节点,压力均匀,但拿不到原子性,而且 pipeline 的往返次数虽然被压缩成 1 次,命令处理仍然是 N 次。
我的选择标准是这样的:单次读取的分片数小于 50,走方案 A 加 tag,因为延迟更低;分片数超过 50 或者是热点数据,走方案 B 分散压力。这个取舍没有标准答案,必须结合具体数据量判断。
6.2 原子性、Lua 与事务的边界
拆分之后,原本一条HSET能搞定的事可能要写 2 个 key。如果业务要求要么全成功要么全失败,就得用 Lua 或者MULTI/EXEC。但这两个机制在集群模式下都要求所有 key 在同一个槽,所以又回到了 hash tag 的老问题。
有个更隐蔽的坑:Lua 脚本里的 key 数量是有上限的,虽然官方没明确说死,但脚本执行期间会阻塞整个实例,脚本里操作几十个 key 会让阻塞时间线性增长。所以我的经验是,Lua 只用于 2 到 5 个 key 的小规模原子操作,超过这个数就该重新设计数据模型,而不是硬用 Lua 兜。
还有一个反直觉的结论:大部分所谓的“原子性需求”其实是伪需求。用户画像的多个字段之间本来就没有强一致要求,晚几百毫秒更新完全可接受。真正需要原子的只有计数、库存这类场景,而这类数据本来就不该大。所以在设计拆分方案时,先质疑一下“是否真的需要原子”,往往能省掉一半复杂度。
6.3 拆分过度反而更慢的真实案例
这个坑我必须单独讲,因为它是我自己踩的。早期做用户标签拆分时,我把一个只有 200 个字段的 Hash 也套用了分桶方案,拆成了 8 个桶。结果上线后发现接口 P99 反而涨了 15ms——原因很简单,原本一次HGETALL能拿到的数据,现在要发 8 次 pipeline 请求,虽然网络往返被合并了,但 Redis 侧的命令解析、槽位路由、结果聚合全都翻了 8 倍。
后来复盘定了一条铁律:只有当 key 真的超过阈值才拆,且拆分收益必须大于拆分成本。200 个字段的 Hash 完全没必要拆,5000 个字段以上才进拆分流程。这个阈值后来写进了团队的编码规范,避免新人无脑照搬。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 处置动作 |
|---|---|---|---|
| 接口偶发超时,无明显规律 | 大key 的HGETALL阻塞主线程 | 查慢日志,看是否有大 value 命令 | 拆分或改为HMGET按需读 |
| 集群单节点内存异常高 | 大key 导致槽位倾斜 | --bigkeys扫一遍,对比各节点内存 | 拆分打散或加 hash tag 后定向迁移 |
| 删除 key 时服务卡顿 | DEL同步释放大量内存 | 观察删除时段的主线程阻塞指标 | 改用UNLINK,老版本用分批HDEL |
| 主从延迟突然增大 | 大key 写入产生巨大复制包 | 查主从offset差值 | 拆分写入、降低单次写入体积 |
| 双写后数据对不上 | 路由逻辑或桶号计算不一致 | 跑一致性校验脚本,抽样比对 | 修代码后以新结构为准重刷旧数据 |
| 分片后读延迟反而升高 | 拆分过度,往返次数增加 | 统计单次请求的分片数量 | 合并小桶或撤销不必要的拆分 |
| RDB 落盘时内存暴涨 | fork 期间大key 的 COW 复制 | 观察fork前后的内存曲线 | 拆分、开启压缩、调整save策略 |
最后再分享一个我在实际项目中的体会:大key治理这件事,技术方案只占三成,剩下七成靠的是规范约束和早期发现。我见过太多团队把拆分方案写得非常漂亮,但因为没有写入侧的硬拦截,半年之后新的大key 又长出来了,等于白干。所以真正有效的做法是把阈值检查、TTL 强制、巡检告警这三件事固化到基础组件里,让业务方想写大key 都写不进去,这比事后救火省心一百倍。