geektutu / blog

极客兔兔的博客,Coding Coding 创建有趣的开源项目。
https://geektutu.com
Apache License 2.0
167 stars 21 forks source link

动手写分布式缓存 - GeeCache第六天 防止缓存击穿 | 极客兔兔 #67

Open geektutu opened 4 years ago

geektutu commented 4 years ago

https://geektutu.com/post/geecache-day6.html

7天用 Go语言/golang 从零实现分布式缓存 GeeCache 教程(7 days implement golang distributed cache from scratch tutorial),动手写分布式缓存,参照 groupcache 的实现。本文介绍了缓存雪崩、缓存击穿与缓存穿透的概念,使用 singleflight 防止缓存击穿,实现与测试。

xiaoxfan commented 4 years ago

我有点蒙了

xiaoxfan commented 4 years ago

singleflight这样理解对不对: 在一瞬间有大量请求get(key),而且key未被缓存或者未被缓存在当前节点 如果不用singleflight,那么这些请求都会发送远端节点或者从本地数据库读取,会造成远端节点或本地数据库压力猛增。使用singleflight,第一个get(key)请求到来时,singleflight会记录当前key正在被处理,后续的请求只需要等待第一个请求处理完成,取返回值即可。

geektutu commented 4 years ago

@xiaoxfan 你的理解是正确的,并发场景下如果 GeeCache 已经向其他节点/源获取数据了,那么就加锁阻塞其他相同的请求,等待请求结果,防止其他节点/源压力猛增被击穿。

walkmiao commented 4 years ago

当有一个key请求发起后 fn处理函数处理过程中 如果有其他相同key的请求过来 那么就一直阻塞等待第一个fn处理完成 拿到完成后的值。这个singleflight的作用期间也就是这个期间吧

201732110125 commented 4 years ago
g.mu.Lock()
delete(g.m,key) //更新 g.m
g.mu.Unlock()

请教博主: 能否在请求结束后 不删除g.m映射关系中的key?


我个人认为 需要删除的原因:

  1. 不删除,如果key对应的值变化,所得的值还是旧值
  2. 占用内存
  3. 因为这个singleflight机制相当于是一个请求的缓冲器,不需要有储存功能。

    • 在少量访问时,正常使用。
    • 在大量并发访问时,对于并发的信息,共享第一个请求的返回值,大幅减少请求次数
geektutu commented 4 years ago

@201732110125 你的理解是对的,缓存值的存储都放在 LRU 中,其他地方不保存数据。如果不删除,占用内存,且不会淘汰。

ppd0705 commented 4 years ago

第一次见到这种提高并发的方式,多谢分享

hhyvs111 commented 4 years ago

不同客户端访问相同的key会受到影响吗?比如10个用户同一时间访问一个key,只有第一个能收到结果?

waruto210 commented 4 years ago

您好,问什么要使用waitgroup呢,实际上c.wg中同时最多只会有一个任务,使用group是不是太多了。

HuntSweet commented 4 years ago

请问一下这样加锁,影响性能大不大呢

wilgx0 commented 3 years ago

又被大佬鬼斧神刀般的技术震撼住了,此时的我内心久久不能平静

geektutu commented 3 years ago

@hhyvs111 所有用户都能收到结果,请求是在服务端阻塞的,等待某一个查询返回结果的,其余请求直接复用这个结果了。singleflight 是这个目的。

@Jason210314 如果用信道,接受和发送需要一一对应, waitgroup 有 Add(1) 和 Done() 是一一对应的,但是可以有多个请求同时调用 Wait(),同时等待该任务结束, 一般锁和信道是做不到这一点的。

@HuntSweet 这样做的目的是提升性能,加锁的时间与访问数据源相比,可以忽略,

geektutu commented 3 years ago

@wilgx0,哈哈,可以慢慢地理解下,singleflight 这个机制在缓存领域使用是非常广泛的,geecache 里实现的是一个比较简单的版本。感兴趣可以再看一看标准库里面的实现 x/sync/singleflight

a-long-way commented 3 years ago

这变量命名看的脑壳痛 a,b,c,d,e,f,g

geektutu commented 3 years ago

@lire277 这个是 golang 非常著名的开源项目 groupcache singleflight的实现,,有点不容易看懂,但是看懂之后,会觉得非常地精巧和优雅,另外 golang 变量命名不会像 java 那么冗长。

shiluoye commented 3 years ago

这块代码真的太精巧了,看完后大呼过瘾的感觉。wg.Wait()用来阻塞当前的gorotinue,等待第一个调用返回的思想太精巧了。但其实这个还是会有问题,就是分布式情况下可能还是会产生并发请求,也就是说如果我们的GeeCache部署在集群上,落到不同pod的请求还是会并发访问数据库。但总得来说可以应付绝大多数场景了。

geektutu commented 3 years ago

@shiluoye 一般 groupcache 之上,还会再增加一层封装,作为 API 层,上面这一层还会做一定的负载均衡的措施。singleflight 用来应对相同的并发请求是非常有效的。这里实现的是简单版本的。官方也实现了一个版本 x/sync/singleflight,更强大,感兴趣可以看一看~

651619114 commented 3 years ago

跟了大佬的web和cache深感震撼 也受益匪浅 感觉这两年的php就是在纯搬砖 多谢多谢

fy403 commented 3 years ago

一种基于channel的并发实现

package singleflight

type result struct {
    val interface{}
    err error
}

type entry struct {
    res   result
    ready chan struct{}
}

type request struct {
    key      string
    fn       func() (interface{}, error)
    response chan result
}

type Group struct{ requests chan request }

func New() *Group {
    g := &Group{make(chan request)}
    go g.serve()
    return g
}

func (g *Group) Close() { close(g.requests) }

func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
    // Create request for each key
    req := request{key, fn, make(chan result)}
    // Send to g.serve handle
    g.requests <- req
    // Wait for response
    ret := <-req.response
    return ret.val, ret.err
}

func (g *Group) serve() {
    // Cache the results of each key
    cache := make(map[string]*entry)
    // handle each request
    for r := range g.requests {
        if e, ok := cache[r.key]; !ok {
            e := &entry{
                ready: make(chan struct{}),
            }
            cache[r.key] = e
            go e.call(r)
        } else {
            go e.deliver(r.response)
        }
        //I didn't implement a good way to delete the cache
    }
}

func (e *entry) call(req request) {
    e.res.val, e.res.err = req.fn()
    req.response <- e.res
    close(e.ready)
}

func (e *entry) deliver(resp chan<- result) {
    <-e.ready
    resp <- e.res
}

使用与大佬的基本一致

func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
    // ...
    g := &Group{
        // ...
        loader:    singleflight.New(), // different
    }
    return g
}
runmark commented 3 years ago

@fy403 当有多个请求请求同一个key值时,如何将多个阻塞的请求同时唤醒?你的代码应该只能唤醒其中一个阻塞的请求吧?

xiaoheng14 commented 3 years ago

@runmark @fy403 当有多个请求请求同一个key值时,如何将多个阻塞的请求同时唤醒?你的代码应该只能唤醒其中一个阻塞的请求吧?

deliver可以唤醒其他相同请求了,但是这个没把缓存的结果删除。

iiiuwioajdks commented 3 years ago

太妙了我的天,感谢大佬

zmf173417897 commented 2 years ago

有个问题,group没有等待协程,只是等待函数调用结束也可以吗?感谢

018429 commented 2 years ago
ningzero commented 2 years ago

假设有a,b两个协程是请求同一个key的,a走到了c.wg.Add(1),还没来得及g.m[key] = c,而b也走到了c.wg.Add(1),那还是会发起两次相同请求吧,这种情况能避免么?

ppd0705 commented 2 years ago

假设有a,b两个协程是请求同一个key的,a走到了c.wg.Add(1),还没来得及g.m[key] = c,而b也走到了c.wg.Add(1),那还是会发起两次相同请求吧,这种情况能避免么?

不会出现这种情况,Do函数第一行就是加锁,只有一个协程可以拿到锁

ningzero commented 2 years ago

@ppd0705

假设有a,b两个协程是请求同一个key的,a走到了c.wg.Add(1),还没来得及g.m[key] = c,而b也走到了c.wg.Add(1),那还是会发起两次相同请求吧,这种情况能避免么?

不会出现这种情况,Do函数第一行就是加锁,只有一个协程可以拿到锁

哦,你说得对,我只看到上面文章里面去掉g.mu锁的代码,忘了原来还有个g.mu锁了

wiyan commented 2 years ago

func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() 请问这里就是无论谁进来,都会在这等来同一个锁,这样高并发下是否会影响性能呢

ppd0705 commented 2 years ago

func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() 请问这里就是无论谁进来,都会在这等来同一个锁,这样高并发下是否会影响性能呢

我理解这样正好把同类请求合并了, 后来者只需要等结果就好了,这样不反而提高性能了吗?

BetterZhuang commented 2 years ago

@wiyan func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() 请问这里就是无论谁进来,都会在这等来同一个锁,这样高并发下是否会影响性能呢

面试就问到这个问题~

ts-cao commented 2 years ago

@cqzzg

@wiyan func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() 请问这里就是无论谁进来,都会在这等来同一个锁,这样高并发下是否会影响性能呢

面试就问到这个问题~

这些项目可以写到简历里面吗?(还没实习过的菜鸟)

BetterZhuang commented 2 years ago

实在没项目就写了这个,面试官都觉得简单,没怎么问

在 2022-05-03 11:42:11,"ts-cao" @.***> 写道:

@cqzzg

@wiyan func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() 请问这里就是无论谁进来,都会在这等来同一个锁,这样高并发下是否会影响性能呢

面试就问到这个问题~

这些项目可以写到简历里面吗?(还没实习过的菜鸟)

— Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you were mentioned.Message ID: @.***>

Yuxinkun commented 2 years ago

想问一下,有个细节没理清楚。我们现在已知的是,当我们在访问服务端的数据的时候,这个过程的其他进程如果也想访问会被阻塞,然后都会用到正在访问服务端的数据的返回的结果。但是,我们最后更新的delete时候,我们是不是就是说在这个过程中,如果需要再一次访问服务端的数据,又要重新访问服务端?我知道call的返回值回存入LRU中,但是我们delete后,势必会进行一次新的判断?

LLoyou00 commented 2 years ago

@wiyan func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() 请问这里就是无论谁进来,都会在这等来同一个锁,这样高并发下是否会影响性能呢

这个 Do 只有在缓存没有命中的时候才会执行,并不会影响缓存的本身的性能,缓存没命中的时候必然要等待获取数据,要么等上游返回,要么等锁。

lristar commented 2 years ago

在这里改用读写锁会不会省去这一步呢 func (c *cache)Get(key string)(val ByteView,ok bool){ // 使用读锁 c.mx.RLock() defer c.mx.RUnlock() if v,ok:=c.lru.Get(key);ok{ val = v.(ByteView) return val,ok } return }

func (c *cache)Set(key string,v ByteView){ // 使用写锁 c.mx.Lock() defer c.mx.Unlock() if c.lru == nil{ c.lru = NewCache(c.cacheBytes,nil) } c.lru.Set(key,v) }

jfld commented 1 year ago

对 m 加锁是为了达到懒汉单例 +控制并发读 的效果?

1mnobody commented 1 year ago

singleflight在目前的逻辑下,出现缓存失效时,要达到完完全全的“只从DB查询一次数据”这个目的应该是还存在一些问题的。 比如如下场景: 假设两个请求查询同一个key,对应的两个协程执行情况如下,并且先由协程2获取到了锁的话(这里协程2已经加载过一次数据了),那么协程1也会调用 fn 再进行一次数据加载

func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
    g.mu.Lock()     // <---- 协程1执行到这里
    if g.m == nil {
        g.m = make(map[string]*call)
    }
    if c, ok := g.m[key]; ok {
        g.mu.Unlock()
        c.wg.Wait()
        return c.val, c.err
    }
    c := new(call)
    c.wg.Add(1)
    g.m[key] = c
    g.mu.Unlock()

    c.val, c.err = fn()
    c.wg.Done()

    g.mu.Lock()    // <---- 协程2执行到这里
    delete(g.m, key)
    g.mu.Unlock()

    return c.val, c.err
}

不过作为使用singleflight的示例还是很有收获的,感谢大佬分享~~

GuiQuQu commented 1 year ago

@1mnobody singleflight在目前的逻辑下,出现缓存失效时,要达到完完全全的“只从DB查询一次数据”这个目的应该是还存在一些问题的。 比如如下场景: 假设两个请求查询同一个key,对应的两个协程执行情况如下,并且先由协程2获取到了锁的话(这里协程2已经加载过一次数据了),那么协程1也会调用 fn 再进行一次数据加载

func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
  g.mu.Lock()     // <---- 协程1执行到这里
  if g.m == nil {
      g.m = make(map[string]*call)
  }
  if c, ok := g.m[key]; ok {
      g.mu.Unlock()
      c.wg.Wait()
      return c.val, c.err
  }
  c := new(call)
  c.wg.Add(1)
  g.m[key] = c
  g.mu.Unlock()

  c.val, c.err = fn()
  c.wg.Done()

  g.mu.Lock()    // <---- 协程2执行到这里
  delete(g.m, key)
  g.mu.Unlock()

  return c.val, c.err
}

不过作为使用singleflight的示例还是很有收获的,感谢大佬分享~~

这个是正常情况吧,singlefilght的目的是保证了当多次同时对fn发起调用的时候(这个时候一个fn还没有执行完成),只实际上调用一次fn,其他请求都等待fn的执行结果。你说的情况协程2执行到的地方对于fn的调用已经结束了,实际上协程1和协程2不是同时调用