在一线互联网大厂的系统架构面试中,Redis BigKey的在线优化是一个高频且极具挑战性的问题。面试官通常会抛出一个具体场景:线上存在一个承载核心业务的亿级数据BigKey(例如Hash类型),要求在不影响业务、不阻塞Redis、且流量不穿透数据库的前提下,对其进行拆分与优化。 这无疑是一道“飞行中换引擎”式的架构设计考题。本文将深入拆解这一难题,从设计思路到具体实现,提供一套教科书级的解决方案。
核心挑战分析
动手设计前,必须清晰理解三个核心约束条件:
- 业务无感知:不能停机,服务需平滑过渡,无明显性能抖动。
- 不阻塞Redis:禁止使用
DEL、HGETALL等O(N)复杂度的命令,必须采用分治策略。
- 保护数据库:迁移过程中缓存不能大面积失效,避免缓存击穿导致数据库被瞬时高QPS压垮(缓存雪崩)。
核心结论:必须采用 “双写 + 渐进式迁移 + 动态路由 + 异步删除” 的组合策略。
总体架构方案
假设待优化的BigKey是一个存储用户详情的Hash,Key为user:info:all,内部包含数千万个字段(field为userId,value为JSON格式的用户信息)。目标是将其拆分为100个小Hash,例如user:info:0 到 user:info:99。
核心步骤:
- 双写阶段:改造应用写逻辑,新数据同时写入新、老两个Key。
- 迁移阶段:启动后台任务,使用
HSCAN命令渐进式地将老Key存量数据迁移到新Key。
- 切读阶段:借助配置中心进行灰度发布,逐步将读流量从老Key切换至新Key。
- 清理阶段:验证无误后,异步、非阻塞地删除老Key。
详细实施步骤
Step 1:数据分片设计
首先确定分片策略。最常用的是基于用户ID的取模算法。
分片公式:shard_id = hash(userId) % 100
新Key命名规则:user:info:{shard_id}
通过此设计,原先的亿级大Hash被拆分为100个百万级的小Hash,有效分散了数据热点与操作压力。
Step 2:同步双写
这是保证平滑迁移的基石。在应用层修改所有写操作,确保新老数据实时同步。
public void updateUserInfo(Long uid, UserInfo info) {
String value = JSON.toJSONString(info);
// 1. 【新逻辑】写入分片后的新Key
int shardId = Math.abs(uid.hashCode() % 100);
String newKey = "user:info:" + shardId;
redis.hset(newKey, uid.toString(), value);
// 2. 【旧逻辑】同时写入老Key,保证迁移期间读请求有数据兜底
String oldKey = "user:info:all";
redis.hset(oldKey, uid.toString(), value);
}
注意:此阶段,所有读请求仍完全访问老Keyuser:info:all,业务侧无任何感知。
Step 3:渐进式数据迁移
这是最关键的步骤,需要启动一个独立的后台迁移程序(Worker)。
绝对禁忌:
- ❌ 使用
HGETALL一次性拉取全部数据(会长时间阻塞Redis主线程)。
- ❌ 迁移后立即删除老数据(会导致读请求击穿至数据库)。
正确实践:使用HSCAN命令进行游标式扫描。
# 后台迁移脚本伪代码
cursor = 0
old_key = "user:info:all"
while True:
# 1. 使用HSCAN分批拉取,每次1000条,避免阻塞
cursor, data = redis.hscan(old_key, cursor=cursor, count=1000)
if not data:
break
# 2. 在内存中计算分片并组装写入命令
pipeline = redis.pipeline()
for uid, info_json in data.items():
shard_id = hash(uid) % 100
new_key = f"user:info:{shard_id}"
# 3. 批量写入新Key(HSET操作是幂等的)
pipeline.hset(new_key, uid, info_json)
pipeline.execute()
# 4. 短暂休眠,控制迁移速率,避免对Redis造成压力
time.sleep(0.05)
if cursor == 0:
break # 游标归零,表示全量数据扫描完毕
这类后台数据处理任务,在大数据平台中也有类似的分治思想应用。
Step 4:灰度切读与多级兜底
存量数据迁移完成后,新Key已拥有全量数据。但切换读流量必须谨慎,采用灰度策略并设计兜底逻辑是保障“请求不穿透DB”的核心。
public UserInfo getUserInfo(Long uid) {
// 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() % 100);
String newKey = "user:info:" + shardId;
String value = redis.hget(newKey, uid.toString());
if (value != null) {
return JSON.parseObject(value, UserInfo.class);
}
} catch (Exception e) {
// 记录日志并降级,不抛异常
log.error("Read new key failed, fallback to old key.", e);
}
}
// 3. 【一级兜底】查老Key(只要老Key未删,请求绝不会击穿到DB)
String oldValue = redis.hget("user:info:all", uid.toString());
if (oldValue != null) {
return JSON.parseObject(oldValue, UserInfo.class);
}
// 4. 【二级兜底】查数据库(最终防线)
return userMapper.selectById(uid);
}
操作流程:
- 初始:灰度比例设为0%,全部流量读取老Key。
- 观察:将比例调至1%,监控错误日志、Redis命中率及业务指标。
- 放量:逐步提升比例至10%、50%,最后到100%。
- 稳固:全量切换后,持续观察一段时间,确保数据一致性与服务稳定。
Step 5:非阻塞清理
当读写流量全部切换至新Key并稳定运行一段时间后,可着手清理老Key。
绝对禁忌:
- ❌ 直接使用
DEL user:info:all。删除一个巨大的Key会导致Redis主线程阻塞,引发线上故障。
正确实践:
总结与防坑指南
本方案通过一套组合拳,完美应对了三大挑战:
| 挑战 |
解决方案 |
| 不影响业务 |
双写机制保证数据实时同步;灰度切读支持快速回滚。 |
| 不阻塞Redis |
HSCAN迁移实现化整为零;UNLINK删除实现异步回收。 |
| 不穿透数据库 |
多级兜底策略确保即使新Key无数据,也会回查老Key(老Key持续存在),彻底保护数据库。 |
关键注意事项:
- 幂等性:迁移脚本必须支持断点续传和重试,
HSET操作本身是幂等的,这有利于实现。
- 过期时间:如果老Key设置了过期时间,需要在新Key上设置相同或更长的TTL。
- Hash Tag:若在Redis Cluster模式且需在Lua脚本中操作多个新Key,需在Key中使用
{user:info}:1这样的Hash Tag来保证它们落在同一Slot。纯分片场景通常不需要。
掌握这套 “分片+双写+迁移+兜底+异步删” 的方法论,不仅能够解决BigKey问题,也能为其他复杂的数据迁移与架构演进场景提供坚实的设计思路。