背景: 在一线互联网大厂(阿里、字节等)的面试中,Redis 的 BigKey 优化是必考题。 但面试官通常不会只问“什么是 BigKey”,而是会抛出一个极具挑战性的场景: “线上有一个亿级数据的 BigKey(如 Hash 类型),正在承载核心业务。现在要求你对其进行优化(拆分),要求:1. 业务全程无感知;2. 绝对不能阻塞 Redis;3. 流量不能穿透到数据库。你怎么做?”
这是一道典型的“飞行中换引擎”的架构题。本文将从设计到落地,手把手教你设计一套教科书级的解决方案。
一、 核心挑战分析
在动手写代码之前,我们必须先拆解面试官给出的三个“紧箍咒”:
- 不能影响现有业务:意味着不能停机,不能有明显的抖动,必须平滑过渡。
- 不能阻塞 Redis:意味着不能使用
DEL、HGETALL等 复杂度的命令,必须利用分治思想。 - 请求不能大量到库:这是最关键的。在数据迁移过程中,缓存不能失效。如果直接删除老 Key 等待重建,数据库瞬间就会被百万 QPS 打死(缓存雪崩)。
结论:我们必须采用 “双写 + 渐进式迁移 + 动态路由 + 异步删除” 的组合拳。
二、 总体架构方案
假设我们的 BigKey 是一个存储用户详情的 Hash,Key 为user:info:all,内部包含 1000 万个字段(field 为 userId,value 为 JSON)。
我们的目标是将其拆分为 100 个小 Hash:user:info:0到user:info:99。
核心步骤:
-
双写阶段:修改代码,对写操作同时写入“新 Key”和“老 Key”。
-
迁移阶段:启动后台程序,利用
HSCAN渐进式地把“老 Key”的数据搬运到“新 Key”。 -
清理阶段:确认无误后,异步删除“老 Key”。
三、 详细实施步骤
Step 1:数据分片设计 (Sharding)
首先确定分片策略。最常用的是取模算法。
分片公式:shard_id = hash(userId) % 100
Key 命名规则:user:info:{shard_id}
这样,原本 1000 万的大 Hash 就变成了 100 个 10 万级的小 Hash,彻底解决了单 Key 热点和阻塞问题。
Step 2:同步双写 (Double Write)
这是“平滑过渡”的基石。在应用层修改写逻辑,新老数据同时更新。
publicvoidupdateUserInfo(Longuid,UserInfoinfo) { Stringvalue =JSON.toJSONString(info); // 1. 【新逻辑】写入分片后的新 Key int shardId =Math.abs(uid.hashCode() 0); StringnewKey ="user:info:"+ shardId; redis.hset(newKey, uid.toString(), value); // 2. 【旧逻辑】同时写入老 Key(保持老数据最新,供读取和兜底) StringoldKey ="user:info:all"; redis.hset(oldKey, uid.toString(), value);}
注意:此时的读操作依然完全读取user:info:all,业务完全无感知。
Step 3:渐进式数据迁移 (The Migration)
这是最考验技术细节的一步。我们需要一个后台任务(Worker),将老数据搬运到新 Key 中。
绝对禁忌:
- ❌禁止使用
HGETALL一次性拉取所有数据(会阻塞 Redis 主线程,导致故障)。 - ❌禁止在迁移后立即删除老数据(会导致读请求击穿到 DB)。
正确姿势:使用HSCAN命令。
# 伪代码:后台迁移脚本cursor =0old_key ="user:info:all" whileTrue: # 1. 使用 HSCAN 每次只拉取 1000 条,避免阻塞 # cursor 是游标,每次返回新的游标和数据 cursor, data = redis.hscan(old_key, cursor=cursor, count=1000) ifnotdata: break# 数据为空,结束 # 2. 在内存中进行分片计算 pipeline = redis.pipeline() foruid, info_jsonindata.items(): shard_id =hash(uid) 0 new_key =f"user:info:{shard_id}" # 3. 批量写入新 Key pipeline.hset(new_key, uid, info_json) pipeline.execute() # 4. 稍微休眠一下,给 Redis 喘息机会(控制迁移速率) time.sleep(0.05) ifcursor ==0: break# 游标归零,全量扫描结束
Step 4:灰度切读与多级兜底 (Gray Switch)
数据迁移完成后,新 Key 中已经有了全量数据。但为了保险,我们不能“一刀切”。
我们需要引入灰度开关(Switch Ratio),并设计多级兜底策略,这是满足“请求不穿透到 DB”的核心。
publicUserInfogetUserInfo(Longuid) { // 1. 获取灰度比例 (例如 10 代表 10% 的流量走新逻辑) int switchRatio = configService.getInt("bigkey.switch.ratio",0); // 2. 流量路由 if(ThreadLocalRandom.current().nextInt(100) < switchRatio) { try{ // --- 尝试读新 Key --- int shardId =Math.abs(uid.hashCode() 0); StringnewKey ="user:info:"+ shardId; Stringvalue = redis.hget(newKey, uid.toString()); if(value !=null) { returnJSON.parseObject(value,UserInfo.class); } }catch(Exceptione) { // 记录日志,不要抛出,降级到老逻辑 log.error("Read new key failed", e); } } // 3. 【一级兜底】如果没命中新 Key,或者不在灰度范围内,查老 Key // 只要老 Key 还在,请求就绝对不会击穿到数据库! StringoldValue = redis.hget("user:info:all", uid.toString()); if(oldValue !=null) { returnJSON.parseObject(oldValue,UserInfo.class); } // 4. 【二级兜底】查数据库(最后防线) returnuserMapper.selectById(uid);}
操作流程:
- 初始状态:比例 0%,全读老 Key。
- 观察期:调至 1%,观察日志、Redis 命中率、业务报错。
- 放量期:逐步调至 10% -> 50% -> 100%。
- 全量后:保持运行一段时间,确保新 Key 数据完全正确。
Step 5:非阻塞清理 (Async Delete)
当读写流量全部切换到新 Key,且稳定运行一周后,可以下线“双写逻辑”中的老 Key 写入,并删除老 Key。
绝对禁忌:
- ❌禁止直接使用
DEL user:info:all。删除一个 5GB 的 Key 会导致 Redis 主线程阻塞数秒甚至数分钟,引发线上故障。
正确姿势:
- Redis 4.0+ :使用
UNLINK命令。
UNLINKuser:info:all
原理:Redis 会将 Key 从元数据中卸载,真正的内存回收由后台线程(Lazy Free)异步执行,不阻塞主线程。
- Redis 4.0 以下:使用
HSCAN+HDEL。写一个脚本,每次 scan 1000 个字段,然后 delete 这 1000 个字段,循环执行,直到删空。
四、 总结与防坑指南
回顾我们的方案,是如何完美解决面试官的三个难题的:
最后的防坑 Tips:
- 迁移脚本的幂等性:迁移脚本可能会中断重启,代码必须设计为可重入的(Set 操作本身就是幂等的,这很好)。
- 过期时间:如果老 Key 有过期时间,新 Key 必须继承(甚至设置得稍微长一点)。
- Hash Tag:如果你使用的是 Redis Cluster,且需要在 Lua 脚本中同时操作多个新 Key,记得在 Key 设计时加上 Hash Tag,例如
{user:info}:1,但在纯分片场景下通常不需要。
掌握了这套“分片+双写+迁移+兜底+异步删”的组合拳,你不仅能搞定 BigKey,还能解决绝大多数数据迁移类的架构难题。