关于 HotKey 的思考
1. 问题描述
在 Redis 中,Hotkey(热点键)是指在一段时间内,被大量客户端频繁访问的键。例如,在一个电商系统的秒杀活动中,代表秒杀商品库存的 Redis 键就可能是一个 Hotkey,因为众多用户会同时频繁地查询和更新这个键的值。
HotKey存在两个问题。第一个是缓存过期导致的穿透问题,突发的大流量可能会压垮数据库,进而拖累整个服务。另一个问题是,突发的大流量可能会给缓存带来风险,虽然Redis的性能很好,也是有上限的。当然,HotKey还有一些其他的问题,像是数据不一致啊,分片流量过高等。
HotKey问题的解决方案还是比较简单的,它的本质就是一个穿透问题。使用多级缓存,或者单飞都可以解决。HotKey的真正难点在于发现哪个Key是HotKey,只要发现的足够及时,就有多种办法解决问题。由HotKey引发的事故,多数情况下都是发现不及时,引发的。
重要
HotKey 的本质是存在缓存击穿问题
2. 难点分析
关于HotKey问题的解决方案,我们可以具体场景具体分析:
如果是一个已知的热点活动,比如超卖,跨年活动等。那么这个Key是一个已知的在某段时间内的热点,这个时候可以对数据进行异步更新的缓存策略,避免因为缓存失效导致的穿透问题。另外,可以使用多级缓存的思路,在本地设置一个短时间的缓存,进一步减少流量传递给Redis。
如果是一个突发的热点活动,比如说突然火了一个热搜,或者明星同款之类的,这种问题比较麻烦。首先,可以在网关或者Redis的网络层设计一个热点探测器,发现某个Key在一段时间内的请求了猛增,就将其判断为HotKey,一方面发布业务告警,另一方面做一些简单的缓存优化,防止突发事故。然后,就按照上面的方法执行。
另外,如果缓存时一个分布式的缓存,可能会出现流量倾斜的情况,比如说热点在分片1,那么它的流量就特别多,其他的分片流量很少,这样其实就没有达到分布式缓存的要求。这种情况的话,可以在Key后面加个随机数,将同一份数据保存在多个分片上,这样子就可以把流量均匀分散到了每个分片上。
最后,单飞也是个应对HotKey的好方法。
3 已知热点问题
相关信息
前提:我们的业务场景是典型的 读多写少的场景。
3.1. 现有技术方案
对于已知的热点缓存,可以使用我们之前的技术方案,比如说:
异步更新、基于 Binlog 更新或者租约都可以,不过最常用的方案还是多级缓存机制和单飞也都可以解决。
因为热点是已知的数据,因此可以使用一些脚本的方式,人为的来预热一波数据。
3.2. 缓存预热方案
3.2.1. 简单预热方式
package appv9
import (
"fmt"
"net/http"
"net/url"
"time"
)
func Run() {
t := time.NewTicker(1 * time.Minute)
for _ = range t.C {
go func() {
getUrl()
}()
}
}
func getUrl() {
client := &http.Client{}
u, _ := url.Parse("http://127.0.0.1:8080/")
q := u.Query()
q.Add("id", "1")
q.Add("cache_flag", "true")
u.RawQuery = q.Encode()
res, err := client.Get(u.String())
if err != nil {
fmt.Println(err)
}
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
fmt.Println(res.StatusCode)
}
}相关信息
这种方式一般需要我们的接口带有一定的后门。
这种方式的预热,一般用在临时活动时,它对公司基建要求非常低,对技术人员的也几乎没有要求,完全可以写一个简单脚本,在活动周期内定时运行,活动结束后,再停止即可。这个方案,简单易懂好实现,效果也不差,只是粗糙了一些,处处透着一股子草台班子的感觉。对于一个工作经验丰富的人,从开发到调试好,估计半小时都用不了。
警告
写脚本,是作为一个技术人员必须要掌握的技能之一。
这种简单方法也有一些不好的地方,就是需要修改数据的时候,千万要小心;另外千万要小心权限和范围,中国互联网知名事故里,有不少都是因为脚本写的有问题导致的。
3.2.2. 预热多级缓存
多级缓存的预热相对麻烦一些,一般情况下公司内部会有专门的缓存管理平台,可以事先提交一些 Key,会有专门的运维团队帮忙配置热点缓存。如果没有,那也可以用上面的简单方法,稍加修改,就可以针对多级缓存进行预热。 虽然这个方法非常草台班子,但是:
提示
请不要嘲笑任何一个草台班子。
3.2.3. 预防流量倾斜
如果我们用的是分布式缓存,那么根据一致性哈希原理,一个固定的 KEY 总是会被分配到一个固定的分片上。比如我们 Id 为 1 的 ,可能固定会分配到分片 1 上面。这种情况会出现流量倾斜的现象:
假如我们有 10 片分片,热点数据在第一片,流量分配是普通流量 1K,热点流量是 1W,分片的性能阈值是 5K,那么此时分片 1,上的流量是 11K,其他分片还是 1K。分片 1 会出现问题,进而影响到整个分布式缓存。解决这个问题的办法,最简单的就是给 KEY 加后缀,让他们分散在不同的分片里:
func GetBookV1(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "只支持 GET 请求", http.StatusMethodNotAllowed)
return
}
id := r.URL.Query().Get("id")
//先获取redis
d := rand.Intn(5)
key := fmt.Sprintf("book_%s_%d", id, d)
cache, err := db.Rdb.Get(context.Background(), key).Result()
if err != nil && !errors.Is(err, redis.Nil) {
_, _ = fmt.Fprintf(w, "缓存获取失败:"+fmt.Sprint(cache))
return
}
//缓存的Key 不存在,走回溯逻辑
if errors.Is(err, redis.Nil) {
//redis 不存在就查询数据库
info := db.Info{}
data := info.Get(1)
//无论数据存在不存在 都将数据缓存
cache = fmt.Sprint(data)
_ = db.Rdb.Set(context.Background(), key, cache, time.Second*5).Err()
}
// 向客户端发送响应
_, _ = fmt.Fprintf(w, "这是查数据库的结果:"+cache)
}相关信息
如果使用加后缀的方式,还要设计的特别复杂,那就脱离了这种方案的优势。
这是一个典型的用空间换时间的分流方式,优点是实现简单,可以很快搞定,缺点是数据多处保存,加大了缓存不一致的概率。另外还有一些其他的办法,比如说多备份方案。这个类似于 Mysql 主备,多台机器共同提供服务。但是无论怎样,都是在存储层面实现的解决方案,终究有自己的局限。
重要
这里的流量倾斜需要和分片不均区分开。一般面试问的分片不均,需要我们回答的是虚拟节点。热点问题引发的流量倾斜,虚拟节点其实很难搞定。可以思考下为什么
上述方案,无法有效解决多个热点 Key 的问题,也无法有效解决突发热点。所以类似的问题,除了在存储层面做好优化,也要在服务层尽可能的做好过滤:多级缓存+单飞。
4. 突发热点问题
4.1. 业务特征
针对自己的业务进行排查,摸底,总结业务场景的特点,对自己负责的业务模块有一个明确的认识。我这边可以分享几个有趣的业务特征:
- 绝大多数 APP 的高峰期都是中午 11.30-14.00 和晚上 8.00-10.00 这段时间。
- 一场赛事往往只有热门队伍的比赛流量较高,差值可能有几十倍。
。。。
4.2. 热点监控
热点监控是解决突发热点的核心中的核心,毫不夸张的说,只要能发现问题,就能解决问题。热点监控一般会用在微服务里,我们这边给个例子,这是 go-zero 里的 Cache 模块的
type cacheStat struct {
name string //名称,最后打印日志记录是要用到
hit uint64 //命中缓存次数
miss uint64 //未命中次数
sizeCallback func() int //自定义回调函数,会在打印结果的时候用到
}
func newCacheStat(name string, sizeCallback func() int) *cacheStat {...}
func (cs *cacheStat) IncrementHit() {
atomic.AddUint64(&cs.hit, 1) //记录命中次数
//用原子操作的其主要原因是,原子操作由底层硬件支持,而锁则由操作系统提供的API实现。若实现相同的功能,前者通常会更有效率。
}
func (cs *cacheStat) IncrementMiss() {
atomic.AddUint64(&cs.miss, 1)
}
func (cs *cacheStat) statLoop() {
...
}提示
热点监控实际上,是缓存命中率的副产品。缓存命中率是缓存服务需要重点监控的指标之一,在统计的同时,可以捎带手做热点监控。
4.3. 流量预警
一般情况下,需要在监控上搭建两个必要的监控指标:一个是流量阈值,一个是流量的与最近一周的平均值,也就是环比。这两个指标监控,可以有效反应实时流量的变化,以及当前流量突增是正常的还是不正常的。
如果环比数据出现问题,就相当于今天这个点的流量比平时多了一些,肯定有问题出现,然后顺藤摸瓜,排查下缓存的命中还有热门 Key 列表,就可以大概知道当前的情况了。
提示
日常工作中,一定要做好监控。出现事故不可怕,可怕的是出现事故还没有感知。
4.4. 预案
这一块,我就不多展开了,大家可以看下之前关于预案的内容。
5. 总结
大明王朝吕公公有句名言,叫做人要三思。
我教你两句话你记住!一句是文官们说的"做官要三思",什么叫三思 三思就是思危、思退、思变 ,知道了危险就能躲开危险,这就叫思危;躲到人家都不注意到你的地方,这就叫思退;退了下来就有了机会,再慢慢看、慢慢想,自己以前哪儿错了,往后该怎么做,这就叫思变。
重要
思危、思退、思变
我们在做业务的时候,也要保持”多思考“的习惯,尽可能想清楚业务场景里存在的一系列问题。计算机架构里没有银弹,没有万金油,没有一劳永逸的方案,需要根据具体的场景选择合适的方案。因此,我总是在分享里反复强调,我们的学习是为了丰富我们的工具箱,以便于在遇到不同问题时,能掏出趁手的武器。
