8 缓存穿透问题
1. 什么是缓存穿透
缓存穿透指查询一个一定不存在的数据,由于缓存是不命中时被动写,并且处于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,失去了缓存的意义。
重要
简而言之,就是有人有意无意的去访问一个眼下不存在的数据。
2. 解决方案
2.1. 缓存空值
这个思路简单粗暴,别管有没有数据都直接存到缓存里。
package logic
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"net/http"
"time"
"training/cache/appv0/db"
)
func GetBook(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
key := fmt.Sprintf("book_%s", id)
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)
}
这个方法足够简单,而且当这个 key 如果突然新增了,那么只需要按照写删除逻辑,清除掉这一块缓存,就可以在下次读取时生效。但缺点也很明显,需要额外的缓存来存储数据,如果有人恶意攻击,会占用大量的内存空间,进而影响到正常业务的使用。
2.2. 布隆过滤器
2.2.1. 原理
它的原理是利用了哈希冲突+bitmap 或者 bitarray 来存储已经存在的数据,进而判断一个新来的数据是否存在。它用了一个很简单的数学,或者说逻辑思维:布隆过滤器不能保证一定存在,但能够保证一定不存在。
BloomFilter 的算法是,首先分配一块内存空间做 bit 数组,数组的 bit 位初始值全部设为 0。加入元素时,采用 k 个相互独立的 Hash 函数计算,然后将元素 Hash 映射的 K 个位置全部设置为 1。检测 key 是否存在,仍然用这 k 个 Hash 函数计算出 k 个位置,如果位置全部为 1,则表明 key 存在,否则不存在。
2.2.2. 代码实现
使用 bits-and-blooms 这个包,地址为:https://github.com/bits-and-blooms/bloom
先在命令行里输入:
go get github.com/bits-and-blooms/bloom
然后在 tools 文件夹下:
package tools
import (
"fmt"
"github.com/bits-and-blooms/bloom"
"strconv"
"sync"
"time"
"training/cache/appv7/db"
)
const (
bfSize = 1000 * 1000 //容量
bfFalsePositive = 0.01 //误判率
refreshInterval = time.Hour //刷新间隔
)
var bf *bloom.BloomFilter
var mux sync.Mutex
func Run() {
newBf()
go func() {
schedule()
}()
}
func Get(key []byte) bool {
mux.Lock()
defer mux.Unlock()
return bf.Test(key)
}
func newBf() {
mux.Lock()
defer mux.Unlock()
bf = loadData2BF()
}
func schedule() {
ticker := time.NewTicker(refreshInterval)
defer ticker.Stop()
for _ = range ticker.C {
//更新布隆过滤器
fmt.Println("定时器启动,正在初始化过滤器")
newBf()
}
}
func loadData2BF() *bloom.BloomFilter {
info := db.Info{}
keys := info.AllKey()
tmp := bloom.NewWithEstimates(bfSize, bfFalsePositive)
for _, key := range keys {
//int64 直接转字节 很麻烦,这里先转Str 再转字节
str := strconv.FormatInt(key, 10)
tmp.Add([]byte(str))
}
return tmp
}
最后只需要在获取数据的地方调用:
//先去bloom里确认下 是否有数据
if exists := tools.Get([]byte(id)); !exists {
_, _ = fmt.Fprintf(w, "数据不存在:")
return
}
2.2.3. 一些问题
警告
问题 1:过滤器的空间满了怎么办?
可以用切片或者 Map 扩容的思想,当数据量超过一定阈值,就对bfSize 进行扩容,翻倍扩容或者 1.25 循环扩容,然后重建一下过滤器。这一步可以利用配置文件+viper 的 Watch 机制实现不停机扩容。
警告
问题 2:新增或者删除数据怎么办?
新增数据比较好办,我们只需要新增一个 Put 方法,在每次数据写入数据库成功后,调用一下:
func Put(key []byte) {
mux.Lock()
defer mux.Unlock()
bf.Add(key)
}
删除数据?那不好意思,布隆过滤器对于删除数据这一块支持的并不好,因此不建议删除数据。如果出现大量数据失效,建议重建过滤器
另外,我们的定时器任务本身也是对数据时效性的一种兜底策略。
警告
问题 3:bloom 过滤器支持并发读写嘛?
过滤器的底层是一本 Bitset,你可以简单认为是个 map 或者 数组,并不支持并发。因此我们使用的时候需要加上互斥锁,后续升级也可以改为读写锁,提高效率。
2.2.4. 优势
实现原理足够简单,性能也足够好,性能和误判率挂钩,需要的误判率越低,则性能越低,因为需要更多的计算。只需要保持阈值在 70%-80%以下,误判率(也叫假阳率)很低。非常适合缓存穿透的场景。
相关信息
引申:计数布隆过滤器
2.3. 空队列
这种方法很鸡肋,用到的地方很少很少,因此我们不再这里展开。
3. 更好的优化
3.1. 布谷鸟过滤器
如果,我们需要一个更低误判率,并且要求能够删除数据,那么此时可以使用布谷鸟过滤器。
论文:
https://www.cs.cmu.edu/~dga/papers/cuckoo-conext2014.pdf
3.1.1. 原理
重要
鸠占鹊巢
布谷鸟过滤器(Cuckoo Filter)是一种高效的概率型数据结构,用于判断一个元素是否存在于集合中。它在布隆过滤器(Bloom Filter)的基础上进行了改进,支持元素删除操作,并在相同空间下提供更低的假阳性率,特别适合需要动态更新的大规模数据场景。
它的底层像一个 Map,有很多的数据桶,桶里有多个槽。写入一个值时,会算出这个 Key 的指纹(通常时哈希值 4-8 位),和两个候选位置。然后去对应的位置查看,如果两个中有至少一个为空,就写入。如果两个都满了,就踢出去一个,然后递归处理被踢出去的元素。(最多 N 次,避免无限循环)
3.1.2. 代码实现
先下载一下依赖包:
go get github.com/seiflotfy/cuckoofilter
然后构造一下函数:
package tools
import (
cuckoo "github.com/seiflotfy/cuckoofilter"
"strconv"
"sync"
"training/cache/appv7/db"
)
var CuckooFilter Cuckoo
type Cuckoo struct {
Filter *cuckoo.Filter
mu sync.RWMutex
}
func (c *Cuckoo) New() {
if c.Filter == nil {
c.Filter = cuckoo.NewFilter(1000 * 1000)
}
info := db.Info{}
keys := info.AllKey()
c.mu.Lock()
defer c.mu.Unlock()
for _, key := range keys {
//int64 直接转字节 很麻烦,这里先转Str 再转字节
str := strconv.FormatInt(key, 10)
c.Filter.Insert([]byte(str))
}
}
func (c *Cuckoo) Get(key []byte) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.Filter.Lookup(key)
}
func (c *Cuckoo) Put(key []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.Filter.Insert(key)
}
func (c *Cuckoo) Delete(key []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.Filter.Delete(key)
}
在使用的时候,和 布隆过滤器类似,可以直接使用。
3.1.3. 优势
最明显的优势,就是支持删除操作,在相同空间下,拥有更低的假阳率。
3.2. 单飞
单飞是一个好方案,但是这个方案只适合于大量请求访问同一个 Key 的情况,这个场景其实就是缓存击穿的场景,因此我们会在击穿场景里展开介绍下。
4. 真正的破局思路
4.1. 扁鹊的故事
相关信息
魏文王问扁鹊曰:“子昆弟三人其孰最善为医?”扁鹊曰:“长兄最善,中兄次之,扁鹊最为下。”魏文侯曰:“可得闻邪?”扁鹊曰:“长兄于病视神,未有形而除之,故名不出于家。中兄治病,其在毫毛,故名不出于闾。若扁鹊者,镵血脉,投毒药,副肌肤,闲而名出闻于诸侯。”
译文:
魏文王问名医扁鹊:“你们家三兄弟都精通医术,谁的医术最好呢?”
扁鹊回答:“长兄医术最好,中兄次之,我最差。”
魏文王好奇地追问:“可以详细说说吗?”
扁鹊解释道:
- 长兄:治病于病情未发作之前(“未有形而除之”)。他能通过观察气色、神态,在疾病尚未显露征兆时就将其化解,所以他的名声只限于家中(普通人看不出他的医术有多高明)。
- 中兄:治病于病情初起之时(“其在毫毛”)。他能在病症刚出现轻微迹象时就及时治疗,很快治愈,所以他的名声只限于乡里(乡里人认为他只能治小病)。
- 扁鹊:只能在病情严重时(“镵血脉,投毒药,副肌肤”),通过针刺、用药、手术等方法治疗,因此名声远播诸侯(世人误以为他医术最高明)。
4.2. 防范于未然
就像我们最前面说的,缓存穿透****
4.2.1. 接口入参管理
在接口处增加详细的入参管理,以我们的接口为例:
func validID(key string) error {
//判断是否为空
if key == "" {
return errors.New("Key 不能为空")
}
//判断是否过长
if len(key) > 25 {
return errors.New("key 长度超过了25")
}
//判断是否为纯数字
match, _ := regexp.MatchString(`^\d+$`, key)
if !match {
return errors.New("key 不是纯数字")
}
//判断是否含有特殊字符
match, _ = regexp.MatchString(`[^a-zA-Z0-9_]`, key)
if !match {
return errors.New("key 包含特殊字符")
}
return nil
}
4.2.2. 增加业务限制
这个就比较好理解了,我举几个例子:
- 对于热度较高的数据页面,需要按照时间查询的,只允许查询最近一周或者一个月的数据。超过一个月以上的数据,从其他冷数据接口异步获取,不要让历史数据影响当前接口。典型场景:银行 APP的转账记录(数据冷热分离)
- 历史记录等数据,可以通过业务 ID 查询的,限制 ID 的范围区间,对明显不符合业务场景的入参进行过滤。设置一个兜底数据,对不满足要求的直接返回兜底数据。B站查一个不存在的视频等。
- 有比较复杂查询条件的,可以通过 对关键条件的过滤,来优化查询。
4.2.3. 在网关增加安全限制
警告
如果有人有意的去访问不存在的数据怎么办?
如果遇到这种情况,对面要么是水平不高的爬虫,要么是动机不纯的沙雕。这个时候,可以联系公司的网关组,让他们提高一下针对个别接口的安全侧率,给他们一点小小的 503 震撼,或者干脆给他生成一些 MOCK 数据,让他自娱自乐。
4.2.4. 做好预案
总是有突发情况发生的,真出了穿透问题,不要慌,按照预案来。至于什么是预案,那我建议你看看
谁制定了美国劫匪的行业标准?【硬核狠人06】_哔哩哔哩_bilibili
4.3. 面对不公平
按照我们的方法,侧重点在与防范于未然,把问题消灭在萌芽阶段。这样子会出现一种情况:善战者无赫赫之功。言外之意是,没出过事故怎么显得你技术牛逼?其实这是不对的想法,我把这种情况分成三类:
- 第一类,自己负责的业务出的错,自己修复。这是你的职责,没有什么好聊的,修不好,那你自己要背锅。
- 第二类,别人负责的业务出错,别人搞不定,你来帮忙搞定。这是最好的情况,修好了,你有功,修不好,最多人家说你瞎你吗掺和。
- 第三类,别人负责的业务出错,别人修好了,你负责的业务抗住了,没问题。最后公司领导表扬了别人,没表扬你。
只有第三类需要我们品一下,如果一次两次这样,那可能是领导不懂事,或者有其他深意。如果次次都这样,那就趁早走人。至少说明两点,第一你不是他认为的嫡系,第二能力确实不行。
早点跑。