外观
从单分片 92% 到均摊:Redis Cluster 大 key 打散铁律
问题背景:大 key 为什么会压垮一个分片
Redis Cluster 使用 16384 个哈希槽做水平扩展,键通过 CRC16(key) mod 16384 决定所属槽。多数业务以为“键多了自然均匀”,但真正压垮集群的往往不是键数量,而是单个超大 key 承载了过多数据与请求。例如一个 5GB 的用户会话 Hash、一个每秒被数万次更新的计数器、一个包含百万成员的 ZSet,都会固定落在同一个主节点上:CPU、网络、内存和持久化 fork 成本无法被其他分片分担。
更隐蔽的是热点倾斜。即使大 key 被拆成很多小 key,如果拆分键命名没有规划,请求可能仍集中在少数槽;反过来,如果业务需要多键原子操作,又必须让相关键落在同一槽。此时 hash tag 就成为集群规划的核心工具:花括号中的子串才参与槽计算。
bash
# 以下两个键实际都使用 user:1001 计算哈希槽,因此落在同一槽
CLUSTER KEYSLOT {user:1001}:profile
CLUSTER KEYSLOT {user:1001}:orders
# 没有 hash tag 时,两个键通常分布在不同槽
CLUSTER KEYSLOT user:1001:profile
CLUSTER KEYSLOT user:1001:orders铁律不是“所有 key 都加 hash tag”,而是:事务相关键用同一 tag 聚合,超大实体按稳定路由因子拆 tag,绝不能让一个无界大 key 随业务自然增长。
方案对比:三种大 key 治理路径
方案一:单 Hash 分字段
把对象字段放入一个 Hash,适合字段总量可控、读写集中的小实体。优点是读取单个字段高效;缺点是字段数量无上限时,单个 key 仍会变热、变大,迁移和删除成本高。
方案二:业务后缀随机打散
将 cart:1001 拆为 cart:1001:0 到 cart:1001:15,由应用层选择分片。该方式能均摊容量和流量,但多键操作需要明确路由;若直接使用随机后缀,后续无法知道数据写到了哪个分片。
方案三:hash tag + 固定分片
使用 {cart:1001:shardN}:items 形式,既把大实体拆成多个小 key,又保留同片内多结构原子操作能力。分片号由稳定标识计算,例如 userId % 16、skuId % 32。这是热点大 key 最常用的长期方案。
集群规划:先定分片数,再设计 hash tag
分片数不是越大越好。分片过少,热点仍明显;分片过多,管理成本和跨片查询增加。建议按峰值 QPS、单键内存上限和未来 6 到 12 个月增长估算。普通业务可从 16 或 32 片开始;分片数一旦写入 key 规则,后续扩容虽然可以倍增,但旧数据迁移需要额外任务,因此初期要预留空间。
规划时遵循四点:
- 路由因子稳定:优先使用用户 ID、商户 ID、商品 ID,不要使用会变化的状态字段。
- 分片值可重复计算:读、写、删除使用同一个函数,禁止写入时随机。
- 单片设置容量边界:例如每个 Hash 不超过 5 千字段、每个 ZSet 不超过 1 万成员,超过则增加片数。
- tag 粒度匹配事务边界:需要原子操作的数据放同一 tag;无事务关系的数据不要为了“整齐”共用 tag。
实操步骤:用 hash tag 拆分购物车大 Hash
假设原 key 为 cart:1001,保存所有店铺商品。随着用户行为增加,单个 Hash 达到数百 MB。我们将其拆为 16 个分片,分片号用商品 SKU 计算;同一店铺的统计、明细和去重集合使用相同 tag,保证 Lua 可原子更新。
第一步:验证 hash tag 落槽规则
bash
# 启动 redis-cli 后执行;-c 表示集群模式,会自动跟随 MOVED 重定向
# redis-cli -c -p 7001
# 花括号内相同,即使外部后缀不同,也会落在同一槽
CLUSTER KEYSLOT {cart:1001:3}:item
CLUSTER KEYSLOT {cart:1001:3}:shop:stat
CLUSTER KEYSLOT {cart:1001:3}:dedup
# 不同分片号通常分布到不同槽,从而打散容量与流量
CLUSTER KEYSLOT {cart:1001:0}:item
CLUSTER KEYSLOT {cart:1001:8}:item第二步:写入分片 Hash 与 ZSet
bash
# 应用侧先计算 shard = crc32(sku_id) % 16,示例 sku=5008,5008 % 16 = 0
# Hash:保存商品数量、勾选状态和加入时间
HSET {cart:1001:0}:item sku:5008 "count=2,checked=1,added_at=1710000000"
# ZSet:同分片内按更新时间排序,便于分页查询最近加入商品
ZADD {cart:1001:0}:recent 1710000000 sku:5008
# String:分片级价格汇总,可由 Lua 与明细一起更新
SET {cart:1001:0}:amount "199.00"
# 查看分片内存与成员规模,作为监控基线
MEMORY USAGE {cart:1001:0}:item
HLEN {cart:1001:0}:item
ZCARD {cart:1001:0}:recent关键数据结构职责要单一:Hash 存放字段明细,ZSet 负责排序分页,String 存放聚合值。不要把列表、JSON 字符串和计数器混成一个不可分析的大对象。
第三步:使用 Lua 保证同 tag 多键原子更新
bash
# KEYS 必须位于同一槽;三个 key 都包含 {cart:1001:0}
# ARGV[1]=sku ARGV[2]=数量 ARGV[3]=时间戳 ARGV[4]=单价
EVAL "
local itemKey = KEYS[1]
local recentKey = KEYS[2]
local amountKey = KEYS[3]
local sku = ARGV[1]
local qty = tonumber(ARGV[2])
local ts = tonumber(ARGV[3])
local price = tonumber(ARGV[4])
if qty <= 0 then
redis.call('HDEL', itemKey, sku)
redis.call('ZREM', recentKey, sku)
else
redis.call('HSET', itemKey, sku, qty)
redis.call('ZADD', recentKey, ts, sku)
end
local old = tonumber(redis.call('GET', amountKey) or '0')
redis.call('SET', amountKey, old + qty * price)
return redis.call('HLEN', itemKey)
" 3 {cart:1001:0}:item {cart:1001:0}:recent {cart:1001:0}:amount sku:5008 2 1710000000 99.50如果把三个 key 写成不带相同 tag 的形式,集群会返回 CROSSSLOT Keys in request don't hash to the same slot。这不是 Redis 能力不足,而是在提醒事务边界与分片规则不一致。
第四步:扫描与聚合要按分片路由
bash
# 查询某个商品时,应用先算 shard,再直接 HGET,避免扫描全集群
HGET {cart:1001:0}:item sku:5008
# 分页只访问目标分片
ZREVRANGE {cart:1001:0}:recent 0 19 WITHSCORES
# 需要用户购物车总价时,对 16 个分片 amount 做 MGET;
# 不同 tag 可能跨槽,不能在原生集群模式下直接对所有 key 使用 MGET。
MGET {cart:1001:0}:amount {cart:1001:1}:amount {cart:1001:2}:amount注意上面的 MGET 仍可能跨槽,生产中应使用并行单 key GET,或在客户端按槽分组后批量提交。不要为了一次 MGET 把所有分片强行改成相同 tag,否则打散设计会失效。
避坑总结:热点治理中的高频错误
坑一:把随机后缀当分片,读请求找不到数据
错误现象:写入时使用 Math.random() 生成 cart:1001:7,读取时只能遍历所有分片;节点扩容后还可能出现重复数据。修正方法是使用稳定标识取模,例如 shard = hash(skuId) % 16,并把分片数写入配置或迁移元数据。
坑二:hash tag 粒度过大,打散变重新倾斜
错误现象:所有订单 key 都写成 {shop:1}:order:*,表面拆了很多 key,实际全部落在同一个槽,热门商户把单节点打到 92% CPU。修正方法是按更细路由因子拆分,如 {shop:1:uid:80023:4};只有必须同事务操作的 key 才共享 tag。
坑三:不同 tag 的 key 使用事务或 Lua
错误现象:业务执行 MULTI、MGET 或 Lua 时出现 CROSSSLOT。修正方法是先画事务边界,把同一原子操作涉及的 key 设计为同一 hash tag;若业务天然跨实体聚合,就改为异步汇总、应用层并发查询或独立统计 key。
坑四:一次性删除大 key 造成阻塞
错误现象:直接 DEL my:bighash 导致主线程短暂阻塞,慢查询飙升,故障切换被触发。修正方法是使用 HSCAN 分批 HDEL、ZSCAN 分批 ZREM,或 Redis 版本支持时使用 UNLINK 异步释放;迁移期间采用双写和灰度读,避免上线即全量搬迁。
坑五:只看内存均衡,不看请求热度
错误现象:集群各节点内存接近,但某个节点 QPS 和网络带宽明显过高。原因可能是热门用户、热门直播间或热门商品使用固定 tag。修正方法是结合 redis-cli --hotkeys、慢日志、代理层 key 指标和节点 CPU 做双重评估;对极端热点再做二级副本读、本地缓存或请求合并。
结语
Redis Cluster 的 hash tag 本质是把“数据放置权”显式交给业务。好的集群规划不是追求 key 数量平均,而是让容量、请求和事务边界同时均衡:无关数据大胆打散,相关数据谨慎聚合。把分片规则前置到设计阶段,并配合容量阈值、热点监控和渐进式迁移,才能避免大 key 从存储问题演变为高可用事故。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。