缓存--参考go-cache自己实现缓存

目录

链接地址

思考

  • 如何控制缓存过期时间
  • 如何控制缓存内存大小
  • 如何提高内存的利用率

1.前言

缓存在业务中是不可避免的一个模块,大体上分成两类

  • 本地缓存,如前端的localstorage
  • 分布式缓存,如redise/memcache

同时,通常我们为了避免业务层直接操作redis,会引入一个缓存模块,即再次封装,最典型的例子就是go-cache

2.go-cache走读

2.1API概览与快速上手

image.png

go-cache的API设计主要分成两部分

  • 单个操作:Set/Get
  • 针对数字的加减: Increment
func Test_go_cache(t *testing.T) {
    c := cache.New(time.Minute, 10*time.Minute)
    c.Set("name", "zjb", cache.DefaultExpiration)
    res, ok := c.Get("name")
    if !ok {
        t.Fatal("not found")
    }
    name := res.(string)
    t.Log(name)
}

2.2主要数据结构

New方法返回Cache结构体

func New(defaultExpiration, cleanupInterval time.Duration) *Cache {
    items := make(map[string]Item)
    return newCacheWithJanitor(defaultExpiration, cleanupInterval, items)
}

type Item struct {
    Object     interface{}
    Expiration int64 // 过期时间:设置时间+缓存时长
}

我们可以大致猜到这个Item就是实际存储KV的本地缓存,实际上就是如此,后续的Set/get等就是并发操作map;同时我们也意识到map属于hash一类的数据结构,内存利用率并不高(但从这角度讲,替换成数组是否更好?)

那问题来了,为什么我们不直接使用map还要套一层娃呢?因为map并发会冲突,扩展性也差

type Cache struct {
    *cache
    // If this is confusing, see the comment at the bottom of New()
}

type cache struct {
    defaultExpiration time.Duration
    items             map[string]Item
    mu                sync.RWMutex
    onEvicted         func(string, interface{})
    janitor           *janitor
}

type janitor struct {
    Interval time.Duration // 多长时间扫描一次缓存
    stop     chan bool // 是否需要停止
}
  • onEvicted 是删除Key时执行的回调函数
  • mu 内部使用锁来处理并发
  • defaultxxxx 默认的超时时间New函数传入
  • janitor 负责定时清空缓存

2.3清空缓存操作

newCacheWithJanitor方法初始化janitor

    C := &Cache{c}
    if ci > 0 {
        runJanitor(c, ci)
        runtime.SetFinalizer(C, stopJanitor)
    }

内部启动一个协程,监听计时器和信号通道(cache的删除操作是 for range 遍历全部)

func runJanitor(c *cache, ci time.Duration) {
    j := &janitor{
        Interval: ci,
        stop:     make(chan bool),
    }
    c.janitor = j
    go j.Run(c)
}

func (j *janitor) Run(c *cache) {
    ticker := time.NewTicker(j.Interval)
    for {
        select {
        case <-ticker.C:
            c.DeleteExpired()
        case <-j.stop:
            ticker.Stop()
            return
        }
    }
}

那么问题来了这个终止的信号量由谁输入?runtime.SetFinalizer

runtime.SetFinalizer 是Go提供对象被GC回收时的一个注册函数,可以在对象被回收的时候回掉函数

2.4扩展思考

  • go-cache是锁住全部map,锁的粒度是否可以更小?
  • 使用sync.Map代替map?
  • SetFinalizer函数使用有什么需要注意点?

其他缓存库参考:

  • freecache
  • groupcache
  • bigcache

3.尝试自己实现一个高可用的缓存

假设我们自己的redis缓存API设计如下

type Cache interface {
    Get(ctx context.Context, key string) (any, error)
    Set(ctx context.Context, key string, value any, t time.Duration) error
    Delete(ctx context.Context, key string) error
}

实现结构体为

type RedisCache struct {
    t   time.Duration
    cmd redis.Cmdable
}

set和get就不贴出了,具体就是操作cmd

3.1缓存的过期机制如何设计

缓存如何处理过期Key?

  • 定期删除:学go-cache开一个协程定时轮询,time.Ticker时间一到就检测一遍。那么时间间隔多久合适,或者直接让用户传入?遍历数量是全部还是100个,1000个?(时间不精准,对象过期只有等到下一个甚至更久的循环才能被删除)
  • 定时删除:每个key开一个协程盯着(time.Afterfunc)?虽然可行但很离谱,这些协程大部分时间在阻塞,浪费资源(定时器本身开销就很大,时间重置要先取消原本定时器,再重启)
  • 懒惰删除:学mysql懒删除,即用户调用Get访问key的时候检查过期,通常会与定期删除一起使用,比如redis(对象过期后究竟什么时候删除不确定,内存浪费)
  • 延迟队列:把对象扔到一个延迟队列里面,当从队列里取出时表示已经过期(需要额外的内存开销,而且过期时间被重置时需要调整在队列中的位置,计算的时间复杂度增加)

3.1.1 参考redis的过期删除策略

redis是追求高性能的中间件,因此不会选择使用延迟队列和定时删除的方式,选择懒加载和定期的方式
同时redis的定期删除会在每一个循环中遍历DB,如果当次定期没有遍历全部DB,那么下一次循环从下一个DB开始遍历,对于每个DB:

  • 如果DB的key都没设置过期时间就遍历下一个DB
  • 设置了就抽一批调查,默认25个,检查过期,执行删除
  • 每遍历16个key就检测执行时间,如果超过阈值就中断本次循环
  • 如果这批过期的key比例超过一个阈值,就继续抽取下一批(比例太低就直接去遍历下一个DB)

redis通过hz和dynamic-hz的值来控制定期删除的频率

特变注意redis还有一个主从分布的问题,3.2之前若key过期主库会执行删除操作,从库不会,因此仍可以从从库中获取key

补充一点redis的持久化文件RDB在生成时会忽略已经过期的key,AOF是无论是定期删除还是懒删除都会记录DEL命令

3.1.2 过期时间如何确定和优化

最完美的情况就是容量无限大,设置永不过期,但实际怎么可能,基本都是通过缓存命中率来确定过期时间

  • 从缓存命中率角度出发(命中缓存的次数/过期时间),可以选择调大过期时间,但代价就是缓存了更多key,占用更多内存,类似的业务就是登录的状态(缓存命中率越高,就越需要更多的缓存容量,越长的过期时间)
  • 减少过期时间,这是根据业务特征出发的,如果有些业务根本用不了一定的时间,可以选择降低过期时间,比如当某个数据被查出来后,大概率在三十分钟内再次使用这个对象,就可以减少过期时间到30分钟

问题又来了如何确定缓存命中率,这个实际要看用户体验确定,或者公司规定的平均响应时间来推算,比如要求平均响应时间是300ms,实际命中缓存时间100ms,没命中1000ms,假设命中率=p 则p需要满足
命中的时间p+没命中的时间(1-p)=300

100 x p + 1000 x (1-p) = 300
p=0.78
  • 从数据特征出发,热点数据越热,过期时间越长,这也就是动态确定过期时间,比如根据请求特征、计算时间、重要性、优先级等

3.1.3 扩展-缓存的预加载和超短过期时间

简单来说就是预料用户的行为--空间换响应时间,在访问a数据时,大概率会访问B,获取A数据时提前缓存B数据,此时这个用户不管看不看B数据,别人都用不上,所以可以把B数据的过期时间设置很短,典型的就是B站的视频下方的视频推荐列表,朋友圈的九宫格图片,点开第一张后半预加载第二张等,总结就是预期用户行为

3.2缓存的内存如何控制

为了防止内存无止尽的扩展通常需要我们做缓存控制,go-cache里没实现改功能

  • 控制键值对数量
  • 控制内存大小

思路都是在set时检测是否超过阈值,在删除时恢复当前余量

3.2.1扩展阅读-淘汰策略

  • LRU最近最少策略,缓存容量不足时从所有key挑一个最近最长未使用的key淘汰(经典的实现方式是额外使用一个队列,最近使用过的放到队首,队尾就是长时间未使用过的)
  • LFU最不经常使用,根据使用频率删除(可以在队列实现的基础上,增加一个表示访问次数的字段,每次访问+1)

redis支持多种淘汰算法,可以根据maxmemory设置内存用量和maxmemory_policy设置淘汰算法

  • 还有从业务角度出发,区分VIP用户和普通用户的数据,优先淘汰普通用户
  • 从数据角度出发,可以选择先淘汰大对象或小对象,热点数据或冷点数据

3.3缓存模式

缓存常与数据库DB一起使用(最经典的场景就是redis查不到,再去DB里面查),这也涉及到我们常用的一些缓存模式

3.3.1 Cache Aside

就是Cache和DB的更新策略全部由开发者自己决定(把缓存看作是一个独立的数据源),即上面的业务层(写在业务代码里)查cache查不到,去db里查再去更新cache

image.png

cache-aside常在并发场景下的数据不一致问题
image.png

扩展阅读

3.3.2 读穿透read-through/写穿透write-through

cache可以做决策
read-through:

  • 业务代码只需要从cache中读取数据,cache会在缓存不命中的时候读取数据(我们可以主动给结构体设置一个接收读DB的方法成员)
  • 写数据的时候,业务代码需要自己写DB和cache

write-through反之,当然这两者也不能解决并发情况下,对同一数据使用的数据一致性问题

3.3.3 write back

  • 写操作直接写缓存,不会更新DB,读也是一样
  • 缓存过期了再写回数据库
    image.png

类似go-cache里的onEvicted函数,缺点就是缓存奔溃时,会造成数据丢失的情况,优点就是缓解了数据一致性的问题
image.png

(使用redis的Setnx回写命令能解决)

3.3.4 refresh ahead

即在DB和Redis中间再加一层,监听DB变更自动更新cache
image.png

这种模式也有缓存一致性问题,出现在缓存未命中时(使用redis的Setnx回写命令能解决)

3.3.5删除缓存

顾名思义,在写操作时,直接写数据库删除缓存,类似的就是延迟双删(在第一次删除后设置一个定时器再次删除,为防止删除时再来一个写操作,实际就是二次检查)

3.3.6 缓存一致性问题

实际就是并发下的数据一致性问题(操作部分失败也会造成不一致我问题),

  • 业务层面:常用的解决思路就是二次检查double-check:在获取数据时加锁做一次检查,在执行操作前再加锁做一次检查

  • 使用消息队列,确保同一时刻只有一个线程在更新数据

  • 引入数据版本号,每一次更新版本号+1,低版本数据不能覆盖高版本数据,缺点就是难以维护

  • 多级缓存:本地+redis+mysql结合

  • 分布式锁方案

3.4缓存异常

3.4.1缓存穿透

请求的数据根本不在(DB和Cache都没有),在cache-aside模式下就会造成多次请求全部打到数据库上(典型如黑客非法攻击)

那么我们是否可以在数据库都没有的情况下,往缓存里回写一个特殊值,标记数据不存在,那么下次查询过来不就防止了(但是如果攻击者每次都用不同key就会造成资源浪费)

3.4.2 缓存击穿

缓存没有对应的key,但是在某个时间该key并发访问量巨大,请求就会都落在数据库上

3.4.3 缓存雪崩

同一时刻大量key过期

3.4.4 解决思路-singleflight

  • 穿透:同一ip的大量请求落在数据库上
  • 击穿:不同ip的大量请求落在数据库上
  • 雪崩:大量key同时过期导致大量请求落在数据库上

综上,解决思路就是如何在缓存出问题时如何让请求不落在数据库上?或者说我们在规定时间内,多个协程访问同一个key时,只允许一个通过,

singleflight就是上面的思路,击穿都能解决,但是穿透效果不好,因为数据库里面也是没有,那么我们可以使用布隆过滤器或者bit array,cache未命中时再问一下这些数据结构

image.png
;最后缓存雪崩,最好的办法就是给每个key再加一个随机的时间偏移量防止同时过期

最后别忘了最关键的限流

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容