2024 年 6 月,我们把一套 Redis 5.0 主从架构(3 主 3 从)迁到 Redis 7.2 Cluster(6 主 6 从),用了 4 周时间。期间踩了 5 个大坑:hash tag 分布不均、Lua 脚本跨槽、pipeline 报 MOVED、客户端集群感知不全、scan 漏 key。本文复盘整个迁移流程 + 真实数据 + 修法。
迁移背景
原架构:Redis 5.0 主从
- 3 个独立实例:cache-1 (用户 session)、cache-2 (商品)、cache-3 (订单)
- 每个 1 主 1 从,sentinel 选主
- 单实例 32GB,总容量 96GB
- QPS 峰值 8w/s(集群级)
痛点:
1. 容量碰天花板:cache-2 用了 30GB,扩容只能纵向(更大内存机器)
2. 单点 QPS 瓶颈:cache-1 session 写入 4w/s,主节点 CPU 80%
3. 主从切换还是慢(sentinel 30s 才完成 failover)
4. 业务读写分离逻辑乱(三个独立实例,key 怎么分散三处)
目标:
- 升 Redis 7.2(开启 CLUSTER 模式)
- 6 主 6 从,水平扩展
- 自动 sharding,业务无感
- 故障自动转移 < 5s
Redis Cluster 原理
Redis Cluster:
- 16384 个 slot,key 经过 CRC16(key) mod 16384 决定落哪个 slot
- 每个 master 负责一段连续 slot
- slave 复制 master,master 挂 slave 自动接管
- 客户端直连任意节点,节点返回 MOVED 重定向
数据分布(6 master):
- node-1: slot 0-2730
- node-2: slot 2731-5460
- node-3: slot 5461-8191
- node-4: slot 8192-10922
- node-5: slot 10923-13652
- node-6: slot 13653-16383
要点:
- 同一 hash slot 的 key 才能 MULTI / Lua / pipeline 批量
- 用 hash tag 强制聚合:user:{12345}:profile 和 user:{12345}:cart 都按 12345 算槽
- 跨槽操作:报 CROSSSLOT 错误
部署 Redis Cluster
# 6 节点(3 master + 3 slave 简化版,生产用 6+6)
# 每节点配置
$ cat /etc/redis/redis.conf
port 6379
cluster-enabled yes
cluster-config-file /var/lib/redis/nodes.conf
cluster-node-timeout 5000
cluster-require-full-coverage no # 部分槽不可用时,其他槽继续服务
cluster-allow-reads-when-down no
appendonly yes
appendfsync everysec
maxmemory 28gb
maxmemory-policy allkeys-lru
maxmemory-samples 10
tcp-keepalive 60
# 创建集群
$ redis-cli --cluster create \
10.0.1.1:6379 10.0.1.2:6379 10.0.1.3:6379 \
10.0.1.4:6379 10.0.1.5:6379 10.0.1.6:6379 \
--cluster-replicas 1
# >>> Performing hash slots allocation on 6 nodes...
# Master[0] -> Slots 0 - 5460
# Master[1] -> Slots 5461 - 10922
# Master[2] -> Slots 10923 - 16383
# Adding replica 10.0.1.5:6379 to 10.0.1.1:6379
# ...
# [OK] All 16384 slots covered.
# 验证
$ redis-cli -h 10.0.1.1 cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_known_nodes:6
cluster_size:3
$ redis-cli -h 10.0.1.1 cluster nodes
数据迁移方案
不能直接停旧 Redis 再启新的,要"在线迁移"
方案对比:
方案 1:redis-shake(阿里开源)
- 从旧 Redis 拉 RDB + 同步增量
- 支持单机 → cluster 转换
- 我们用的这个
方案 2:双写
- 业务代码同时写新旧 Redis
- 老 Redis 数据迁移完后切读
- 改业务代码,适合长期迁移
方案 3:RIOT(Redis 迁移工具)
- 类似 redis-shake,但更现代
最终选 redis-shake,因为业务代码不动
redis-shake 配置
# shake.toml
[sync_reader]
address = "10.0.0.10:6379" # 旧 Redis 主
username = ""
password = "oldpass"
tls = false
sync_rdb = true
sync_aof = true
prefer_replica = false # 直接读主,避免延迟
try_diskless = true
[redis_writer]
address = "10.0.1.1:6379" # 集群入口
username = ""
password = "newpass"
tls = false
cluster = true # 集群模式!
target_db = 0
target_redis_proto_max_bulk_len = 512mb
[filter]
allow_db = [0]
allow_keys = [] # 空 = 全量
block_keys = ["__sentinel__*"]
allow_key_regex = []
block_key_regex = []
function = "" # Lua 自定义过滤
[advanced]
log_file = "shake.log"
log_level = "info"
log_interval = 5
rdb_restore_command_behavior = "rewrite" # 已存在的 key 覆盖
pipeline_count_limit = 1024
target_redis_client_max_querybuf_len = 1gb
target_redis_proto_max_bulk_len = 512mb
aws_psync = ""
empty_db_before_sync = false # 不要清空目标!
[module]
target_mbbloom_version = 20603
# 启动
$ ./redis-shake shake.toml
# 监控
$ tail -f shake.log
2024-06-10 14:23:01 [INFO] sync rdb 100% (8.2GB / 8.2GB)
2024-06-10 14:23:15 [INFO] aof sync: lag=2ms, qps=4500
2024-06-10 14:23:20 [INFO] aof sync: lag=1ms, qps=4800
# lag 持续 < 100ms 才算追平,可以切流量
客户端改造
// 旧代码:单机连接
import "github.com/redis/go-redis/v9"
rdb := redis.NewClient(&redis.Options{
Addr: "cache-1:6379",
Password: "oldpass",
})
// 新代码:Cluster Client
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"10.0.1.1:6379",
"10.0.1.2:6379",
"10.0.1.3:6379",
},
Password: "newpass",
// 关键参数
MaxRedirects: 3, // MOVED 重定向最多 3 次
ReadOnly: false,
RouteByLatency: false, // 不按延迟路由
RouteRandomly: false,
PoolSize: 100, // 每节点连接池
MinIdleConns: 10,
PoolTimeout: 5 * time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
DialTimeout: 2 * time.Second,
})
// 验证
ctx := context.Background()
err := rdb.Ping(ctx).Err()
if err != nil {
log.Fatalf("connect failed: %v", err)
}
// 集群所有节点 ping
err = rdb.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
return shard.Ping(ctx).Err()
})
坑 1:hash 分布不均
迁移完发现:
- node-1: 28GB(满了!)
- node-2: 12GB
- node-3: 15GB
原因:业务 key 大量带 {user_session},全部聚到一个 slot
排查:
$ redis-cli --cluster check 10.0.1.1:6379
[WARNING] Node 10.0.1.1:6379 has 19283482 keys in 5461 slots, but slot 1234 alone has 8923012 keys
# 找出热点 slot
$ redis-cli -h 10.0.1.1 --hotkeys
# 找出 slot 中 key 数最多的
$ redis-cli -h 10.0.1.1 cluster countkeysinslot 1234
8923012
# 看具体 key 模式
$ redis-cli -h 10.0.1.1 cluster getkeysinslot 1234 100
1) "user:{session_v2}:abc..."
2) "user:{session_v2}:def..."
# 所有 session 都用了 {session_v2} hash tag,全部聚到 1234 slot!
# 修复:业务改用 {user_id} 做 hash tag
# 原 key: user:{session_v2}:abc → user:{12345}:session
# 这样按 user_id 分散到各 slot
坑 2:Lua 脚本跨槽报错
业务有库存扣减脚本:
local stock = tonumber(redis.call('GET', KEYS[1]))
local order_id = ARGV[1]
if stock and stock > 0 then
redis.call('DECR', KEYS[1])
redis.call('SET', KEYS[2], order_id)
return 1
end
return 0
调用:eval LUA 2 stock:1001 order:99999 user-001
报错:CROSSSLOT Keys in request don't hash to the same slot
原因:stock:1001 和 order:99999 不在同一 slot
# 修法:用 hash tag 强制同槽
# stock:{1001}:goods 和 order:{1001}:99999 → 按 1001 算槽
# 改业务代码,KEYS 都带 {goods_id}:
eval LUA 2 stock:{1001}:value order:{1001}:99999 user-001
# 也可以用 redis-cli 验证 slot
$ redis-cli cluster keyslot 'stock:{1001}:value'
6788
$ redis-cli cluster keyslot 'order:{1001}:99999'
6788 # 同槽,Lua 可执行
坑 3:pipeline 频繁 MOVED
// 旧代码:pipeline 100 个 key 一批
pipe := rdb.Pipeline()
for _, key := range keys {
pipe.Get(ctx, key)
}
results, err := pipe.Exec(ctx)
// 集群下:100 个 key 可能落 6 个不同节点
// pipeline 串行执行每个 key,每个不在本节点的都 MOVED 重定向
// 延迟从 1ms 飙到 30ms
// 修法 1:按 slot 分批
import "github.com/redis/go-redis/v9"
func batchGet(keys []string) (map[string]string, error) {
// 按 slot 分组
slotKeys := make(map[int][]string)
for _, k := range keys {
slot := rdb.PoolStats().Hits // 实际用 CRC16
slotKeys[crc16Slot(k)] = append(slotKeys[crc16Slot(k)], k)
}
result := make(map[string]string)
for _, ks := range slotKeys {
pipe := rdb.Pipeline()
cmds := make([]*redis.StringCmd, 0, len(ks))
for _, k := range ks {
cmds = append(cmds, pipe.Get(ctx, k))
}
pipe.Exec(ctx)
for i, k := range ks {
v, _ := cmds[i].Result()
result[k] = v
}
}
return result, nil
}
// 修法 2:用 mset/mget 时强制同 slot
// keys 必须带相同 hash tag
rdb.MGet(ctx, "user:{123}:name", "user:{123}:email")
坑 4:scan 漏 key
业务用 SCAN 0 MATCH 'session:*' COUNT 1000 遍历所有 session
集群下:SCAN 只扫当前连接的节点,需要遍历所有 master
修法:用客户端的 cluster scan
// 客户端实现 cluster 范围 scan
func clusterScan(pattern string) ([]string, error) {
var allKeys []string
err := rdb.ForEachMaster(ctx, func(ctx context.Context, master *redis.Client) error {
var cursor uint64
for {
keys, c, err := master.Scan(ctx, cursor, pattern, 1000).Result()
if err != nil {
return err
}
allKeys = append(allKeys, keys...)
cursor = c
if cursor == 0 {
break
}
}
return nil
})
return allKeys, err
}
// 注意:scan 不能保证一致性视图(节点间数据有迁移)
// 关键业务不要用 scan,改用业务字典(MySQL/独立索引)
坑 5:慢查询阻塞集群
某天突然集群整体超时
排查发现一个节点 KEYS * 把整个 Redis 卡住
KEYS / SMEMBERS 大集合 / SORT / LRANGE 大列表都是危险操作
防御:
1. ACL 禁用 KEYS / FLUSHALL / FLUSHDB(普通业务账号)
2. 慢查询告警:slowlog-log-slower-than 10ms
3. 大 key 监控:redis-cli --bigkeys
# 创建受限账号
$ redis-cli -h 10.0.1.1
> ACL SETUSER appuser \
on \
>appsecret \
~* &* \
+@all -@dangerous -KEYS -FLUSHALL -FLUSHDB -DEBUG -SHUTDOWN -CONFIG -CLUSTER \
+CLUSTER|NODES +CLUSTER|INFO
# 慢查询配置
> CONFIG SET slowlog-log-slower-than 10000 # 10ms (单位 us)
> CONFIG SET slowlog-max-len 1024
# 查慢查询
> SLOWLOG GET 10
# 大 key 扫描
$ redis-cli -h 10.0.1.1 --bigkeys -i 0.01
# Biggest string found 'session:large:user:8888' has 12345 bytes
# Biggest hash found 'user_profile:12345' has 8231 fields
# Biggest list found 'event_log' has 1283910 items ← 超大,要拆!
# Biggest set found 'online_users' has 920000 members
# Biggest zset found 'leaderboard' has 50000 members
故障转移测试
# 主动 kill 一个 master,观察 failover
$ kill -9 $(pgrep -f 'redis-server.*6379' | head -1)
# 监控
$ while true; do
redis-cli -h 10.0.1.1 cluster info | grep cluster_state
redis-cli -h 10.0.1.1 cluster nodes | grep -E 'master|slave' | head
sleep 1
done
# 时间线:
# t=0 : kill master 1
# t=1s : 其他节点 PFAIL master 1
# t=3s : quorum 达成 FAIL
# t=4s : slave 发起选主投票
# t=5s : slave 升 master,接管 slot
# t=6s : 客户端缓存的 MOVED 路由更新
# 实际业务影响:5-6 秒部分 key 不可读写
监控告警
# prometheus rules
groups:
- name: redis_cluster
rules:
# 节点离线
- alert: RedisClusterNodeDown
expr: redis_up{job="redis-cluster"} == 0
for: 30s
labels: { severity: critical }
annotations:
summary: "Redis node {{ $labels.instance }} down"
# 集群状态异常
- alert: RedisClusterNotOK
expr: redis_cluster_enabled == 1 and redis_cluster_state != 1
for: 1m
labels: { severity: critical }
# 内存使用率高
- alert: RedisMemoryHigh
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.85
for: 5m
labels: { severity: warning }
# 主从复制延迟大
- alert: RedisReplicationLag
expr: redis_connected_slaves > 0
and redis_replication_lag_seconds > 10
for: 5m
labels: { severity: warning }
# 命中率低
- alert: RedisHitRatioLow
expr: redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.7
for: 10m
labels: { severity: info }
# slot 不全
- alert: RedisClusterSlotsNotCovered
expr: redis_cluster_slots_assigned < 16384
for: 30s
labels: { severity: critical }
容量规划
单 master 推荐配置:
- 内存:25-30GB(超过 30GB BGSAVE fork 慢)
- 集群规模:< 1000 master(gossip 流量 O(N²))
- 单节点 QPS:5w-8w(混合读写)
- 网络:万兆(主从复制 + cluster bus)
我们当前:
- 6 master × 28GB = 168GB
- 6 slave × 28GB
- 集群总 QPS:50w(读 80% slave 分担)
- 单节点峰值 12w QPS
下次扩容:
1. 添加 3 个新 master 节点
2. redis-cli --cluster reshard 重分片 slot
3. 监控搬迁进度
4. 全程业务不中断
迁移后效果
指标 迁移前(单机) 迁移后(Cluster) 变化
==========================================================
总容量 96GB 168GB +75%
峰值 QPS 8w 50w +5x
故障切换 30s (sentinel) 5s (cluster) -83%
单 key 延迟 p99 4ms 2ms -50%
扩容方式 纵向(换机器) 横向(加节点)
业务影响:
- 大促期间 Redis 不再是瓶颈
- 新业务直接用 Redis,容量不愁
- 旧业务 hash tag 改造一周完成
避坑清单
- 业务 key 设计要考虑 hash tag,避免热点 slot
- Lua / pipeline / MULTI 必须同 slot,用 {} 强制
- 慢操作(KEYS / FLUSHALL / 大集合操作)用 ACL 禁掉
- 客户端要用集群感知 client(go-redis / lettuce 等)
- scan 不要用于关键业务,集群下不保证一致性
- cluster-node-timeout 不要太短(< 5s 容易抖动)
- 主从复制延迟监控,> 10s 告警
- 大 key(string > 10KB, list/set > 5000)拆分
- maxmemory-policy 用 allkeys-lru 而非 noeviction
- 定期 redis-cli --cluster check 验证集群健康
总结
Redis Cluster 是水平扩容的最优解,但不是"换上就完事":业务代码、key 设计、客户端选型都要配合。我们这次迁移花了 4 周,实际改业务代码 1 周,剩下 3 周在调 hash tag + 验证 + 灰度。最重要的认知:Redis Cluster 不是"自动 sharding 黑盒",业务必须理解 slot/hash tag 才能用好。如果业务 key 模式不适合分片(大量跨 key 事务),先评估能否改造,再考虑迁移 — 不要为了 cluster 而 cluster。
—— 别看了 · 2026