登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

热点 Key 不扩容也能缓解吗:拆分、复制与本地缓存

来源:17golang原创

时间:2026-10-08 10:10:00 397浏览 收藏

可以缓解,但前提是先弄清热点来自读还是写。Redis Cluster 中一个 Key 只属于一个哈希槽,稳定状态下这个槽由一个主节点负责,所以单纯把集群空闲资源留在旁边,并不会自动分担这个 Key 的压力。我的经验是:写热点优先拆分,读多写少优先复制或本地缓存,读写都很频繁则先改数据模型和访问路径。

不扩容不是“零成本”。拆分会增加聚合开销,复制会增加一致性复杂度,本地缓存会引入失效与内存管理。真正目标是把单点压力换成业务可接受的代价。

官方文档:https://redis.io/docs/latest/

先确认问题真的是热点 Key

热点 Key 的典型现象不是所有分片一起忙,而是某个分片 CPU、命令量或延迟明显高于其他分片。Redis 官方监控文档把热点 Key 定义为被极高频访问的 Key,并指出一个 Key 只属于一个分片,因此它可能把负载集中到单个分片。

排查时先看分片级 CPU、延迟、网络流量和命令分布,再使用热点采样。官方文档提示,redis-cli --hotkeys 依赖 LFU 统计;MONITOR 可能产生较高影响,只适合谨慎、短时、二次确认,不能当成常驻观测手段。

# 先确认实例当前的内存淘汰策略,避免误判 --hotkeys 的可用性
redis-cli CONFIG GET maxmemory-policy

# 在具备 LFU 统计的前提下采样热点 Key
redis-cli --hotkeys

# 检查拆分后的 Key 是否落到不同哈希槽
redis-cli CLUSTER KEYSLOT 'article:42:part:0'
redis-cli CLUSTER KEYSLOT 'article:42:part:1'

这里最重要的检查点是:热点是否持续、是否集中在同一个业务对象、读写比例怎样、值多大、更新频率多高。如果只是一次流量尖峰,限流和请求合并可能比改数据模型更合适。

三条路线解决的是不同压力

方案更适合主要收益主要代价
拆分 Key计数、集合、可分区聚合的写热点把写入分散到多个槽和分片读取需要聚合,跨槽操作受限
复制 Key读多写少、允许短暂最终一致把读取路由到多个物理 Key更新扇出、版本切换和过期管理更复杂
本地缓存高频读取、变化不频繁的小对象直接减少网络请求与 Redis 回源失效通知、断线清空和进程内存成本

我不建议先拍脑袋选择“最强”的方案。把热点类型与代价对上,通常比一次性叠加三层缓存更容易上线,也更容易回滚。

热点请求、拆分 Key、副本 Key、本地缓存与 Redis 分片之间的静态关系图
图1:三种缓解方案与请求入口、Redis Key 和分片之间的静态关系说明图,不是监控截图或运行结果。

写热点先拆成可合并的分片 Key

如果热点是全局计数器、点赞数、曝光数或可分区集合,可以把一个逻辑对象映射成多个物理 Key。请求依据用户 ID、请求 ID 或业务分区选择一个桶,例如 article:42:part:0 到 article:42:part:63。读取总数时再聚合这些桶,或者异步把桶汇总到一个只读快照。

Redis Cluster 有 16384 个哈希槽,Key 默认按完整键名计算槽位。拆分时不要给所有桶使用相同的哈希标签,否则花括号中的相同内容会强制它们回到同一个槽,等于把热点重新集中起来。跨槽的多 Key 命令、事务和 Lua 脚本也有约束,所以聚合通常要放在应用层,或由后台任务生成快照。

package hotkey

import (
    "fmt"
    "hash/fnv"
)

// BucketKey 根据稳定标识选择写入桶,避免同一请求重复路由到不同桶。
func BucketKey(base, stableID string, buckets uint32) string {
    h := fnv.New32a()
    _, _ = h.Write([]byte(stableID))

    // 键名不使用相同哈希标签,让不同桶有机会分布到不同槽。
    bucket := h.Sum32() % buckets
    return fmt.Sprintf("%s:part:%02d", base, bucket)
}

拆分的验收不是“生成了 64 个 Key”,而是这些 Key 的槽位和请求量确实分散,同时聚合延迟仍在预算内。桶太少无法摊平,桶太多则会放大读取、扫描和过期成本。

读多写少可以复制,但要接受一致性成本

对热门配置、商品基础信息或公共字典,可以维护多个内容相同的物理 Key,例如 product:42:copy:0 到 product:42:copy:7,请求按稳定散列读取其中一个。与写拆分类似,这些副本不能被相同哈希标签重新绑到同一个槽。

复制不是 Redis 自动替你完成的数据一致性协议。更新时需要应用或发布任务写入全部副本,并用版本号、校验值或独立的发布状态判断是否完成。如果业务不能接受几秒钟的版本不一致,就不要把应用级副本当成默认方案;强一致读取应保留单一权威路径,或使用经过验证的读副本能力并明确陈旧读取是否可接受。

我更喜欢“新版本 Key + 旧版本延迟过期”的方式:先写一组带版本的新副本,确认数量和版本一致,再切换读取版本;出现异常时把读取版本切回去。它牺牲一些内存,却比原地逐个覆盖更容易回退。

本地缓存最省 Redis 请求,也最容易藏住陈旧数据

Redis 官方的 server-assisted client-side caching 使用 Tracking:服务器记录连接读过哪些 Key,当 Key 被修改、淘汰或过期时发送失效消息,客户端收到后移除本地副本。这样应用命中本地缓存时不再访问 Redis,网络和服务器负载都会下降。

它尤其适合“读得多、改得少”的小对象。对持续 INCR 的计数器、频繁变化的排行榜或大对象,本地缓存可能被失效消息反复击穿,管理成本反而高于收益。官方文档还要求连接丢失时清空本地缓存,避免应用在失去失效通道后继续返回旧数据。

  • 只缓存明确的热点前缀或白名单,不要默认把所有读取都塞进进程内存。
  • 设置本地容量上限、短 TTL 与随机抖动,避免大量进程同时回源。
  • 把缓存友好和高频更新的数据分开连接,减少无效的跟踪与失效消息。
  • 监控断线清空次数、失效消息量、本地命中率和 Redis 回源比例。

推荐的上线顺序与回滚开关

我通常先加一个只读的热点观察面板,再为候选 Key 做灰度开关。第一阶段只记录命中和路由结果;第二阶段让少量请求使用拆分、副本或本地缓存;第三阶段才扩大比例。每个方案都要保留直接访问原 Key 的回退路径。

上线后至少同时观察四组指标:热点分片 CPU 是否下降、P95/P99 延迟是否改善、新增聚合或失效开销有多大、错误和陈旧读取是否增加。只看平均延迟很容易掩盖单分片仍在抖动的事实。

读写信号、路由规则、版本控制、失效通知和回滚开关之间的静态关系图
图2:热点治理的信号、控制与验证边界说明图;这些节点用于组织上线检查,不代表实际执行时间线。

容易踩的四个坑

  1. 拆了 Key 却保留相同哈希标签。物理 Key 变多了,槽位仍然相同,单分片压力没有改变。
  2. 复制后没有版本字段。无法判断读到的是哪一批数据,也无法安全回滚。
  3. 本地缓存只设 TTL,不处理失效通道断线。连接异常后可能长时间返回旧值。
  4. 只盯 Redis 命中率。命中率高不代表尾延迟、分片倾斜和一致性错误都已改善。

快速选择清单

  • 高频累加、允许最终聚合:优先拆分 Key。
  • 内容稳定、读远多于写、允许短暂不一致:考虑多个副本 Key。
  • 对象小、读频繁、客户端支持可靠失效:优先本地缓存。
  • 写入频繁且要求强一致:不要先复制,优先重构数据模型、限流或合并请求。
  • 单分片仍有真实容量瓶颈:这些方法只能缓解,最终仍可能需要扩容或重新分片。

所以,热点 Key 在不扩容时确实有治理空间,但要把“分散压力”和“复制数据”看成不同工具。先用最小改动验证一个方向,再依据分片 CPU、尾延迟和一致性指标决定是否保留,通常比一次性堆满缓存层更稳。

常见问题

把一个大 Hash 拆成多个 Hash 一定有效吗?

不一定。只有拆分后的键落到不同槽并且请求也被分散,才可能降低单分片压力;如果仍使用相同哈希标签,效果很有限。

可以直接从 Redis Cluster 副本读吗?

集群规范允许在接受陈旧数据的场景使用副本扩展读取,但客户端、路由与一致性语义必须明确。它与应用自行复制多个 Key 不是同一方案。

本地缓存失效消息丢了怎么办?

连接或失效通道异常时应清空相关本地缓存并回源 Redis,恢复后再重新建立跟踪,不能继续信任旧副本。

热点消失后需要恢复单 Key 吗?

不必立刻恢复。先比较聚合成本和运维复杂度;如果热点已经消失且拆分长期增加维护成本,可以通过灰度开关回到单 Key。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>