Golang - 并发原语SingleFlight

引入

如下图所示,可能存在来自桌面端和移动端的用户有 1000 的并发请求,同一时刻来访问的获取文章列表的接口,获取前 20 条信息,如果这时服务直接去访问 redis 出现 cache miss, 那就会去请求 1000 次数据库,这时可能会给数据库带来较大的压力(这里的1000 只是一个例子,实际上可能远大于这个值)导致服务异常或者超时。

 这时就可以使用singleflight库了,直译过来就是单飞

实际开发中常见的做法是在查数据库前先去查缓存,如果缓存Miss(未命中)就去数据库中查到数据并放到缓存里。这是正常情况,然而缓存击穿则是指在高并发系统中,大量请求同时查询一个缓存的key,假如这个key刚好过期就会导致大量的请求都打到数据库上。在绝大多数情况下,可以考虑使用singleflight来抑制重复函数调用。

SingleFlight的作用是在处理多个goroutine同时调用同一个函数的时候,只让一个goroutine去实际调用这个函数,等到这个goroutine返回结果的时,再把结果返回给其他几个同时调用了相同函数的goroutine,这样可以减少并发调用的数量。

缓存击穿:在高并发系统中,会出现大量的请求同时查询一个key的情况,假如此时这个热key刚好失效了,就会导致大量的请求都打到数据库
缓存雪崩:大面积key的缓存失效,打崩了DB

解决“缓存击穿”的问题,即一个热点key突然失效,导致大量请求直接访问数据库,给数据库带来巨大压力的情况,可以采用以下几种方式(包含单飞):

1)缓存空对象
原理:对于不存在的数据,也在缓存中存储一个空对象(如null或特定标记),并设置较短的过期时间。当请求查询该数据时,如果缓存中存在空对象,则直接返回空对象,避免查询数据库。
优势:减少了不必要的数据库查询,但需要注意空对象缓存的过期时间和存储空间。
2) 预先加载
原理:在缓存即将失效之前,提前加载数据并更新缓存,以避免缓存失效后瞬间大量请求打到数据库。
优势:通过预测和预加载,降低了缓存失效时的数据库压力。
挑战:需要准确预测缓存的失效时间,并且实现预加载的逻辑。(可以设置一个后台任务,定期检查即将过期的缓存数据)
3)设置随机过期时间
原理:为热点数据设置一个随机的过期时间,避免因为所有请求同时过期而导致缓存击穿。
优势:分散了缓存失效的时间点,减少了缓存失效时的数据库压力。
实现:在缓存数据时,为每个key设置一个随机但合理的过期时间。(设置一个随机的过期时间,如60秒到120秒之间的一个随机数)
4)使用互斥锁
原理:当查询缓存未命中时,尝试获取一个分布式锁。如果获取到锁,则查询数据库并更新缓存;如果未获取到锁,则等待一段时间后重试或返回默认值。
优势:通过锁机制控制了并发查询数据库的请求数量。
挑战:锁的粒度、获取和释放锁的开销需要仔细考虑。(使用Redis的SETNX)
5)逻辑过期与异步重建
原理:在缓存中存储数据的逻辑过期时间,而不是依赖缓存的自动过期机制。当数据逻辑过期后,可以异步地重建缓存数据,而不是立即删除缓存。
优势:避免了缓存失效时的瞬间压力,同时保证了数据的最终一致性。
实现:需要设计合理的逻辑过期机制和异步重建策略。
6)使用布隆过滤器(提前拦截)
过滤无效请求:在缓存之前使用布隆过滤器判断请求的键是否存在,减少对数据库的无效访问。
实现示例:将所有可能存在的数据key添加到布隆过滤器中。当客户端请求到来时,先通过布隆过滤器判断该key是否存在。如果布隆过滤器判断不存在,则直接拒绝请求;如果判断存在,则继续查询缓存或数据库。
7)双缓存策略
长短缓存结合:使用两个缓存,一个缓存的过期时间较短,用于应对大部分请求;另一个缓存的过期时间较长,用于应对缓存击穿的情况。
实现示例:当短缓存过期时,先从长缓存中获取数据,然后再去查询数据库并更新两个缓存。这样可以减少直接查询数据库的次数,降低数据库压力。
8)单飞(Singleflight)
原理:单飞模式通过合并对同一资源的并发请求,确保只有一个请求去查询数据库或执行其他耗时操作,并将结果缓存起来供后续请求共享。
优势:显著减少了对数据库的并发访问,降低了数据库压力。
实现:使用像Go语言的singleflight包这样的工具,可以轻松实现请求的合并和结果的共享。
9)监控与告警
原理:通过监控缓存的命中率和数据库的查询量等指标,及时发现并应对缓存击穿问题。
优势:能够主动发现和解决问题,减少了对用户的影响。
实现:需要搭建监控系统和配置相应的告警规则。

10)设置热点key永不过期

这可算作是一种解决方案,即对于某些特别热的key,可以让它们永不过期,这样就避免了过期带来的问题。但这需要谨慎使用,因为可能会导致缓存数据与实际数据长时间不一致,可能导致其他问题,如缓存数据长时间不更新、内存占用过多等。

用法

Do:执行一个函数,并返回函数执行的结果。需要提供一个 key,对于同一个 key,在同一时间只有一个在执行,同一个 key 并发的请求会等待。第一个执行的请求返回的结果,就是它的返回结果。函数 fn 是一个无参的函数,返回一个结果或者 error,而 Do 方法会返回函数执行的结果或者是 error,shared 会指示 v 是否返回给多个请求。【会阻塞直到fn执行完成】
DoChan:类似 Do 方法,只不过是返回一个 chan(大小为 1 的缓冲通道),等 fn 函数执行完,产生了结果以后,就能从这个 chan 中接收这个结果。【将执行结果返回到通道中,可通过监听通道结果获取方法执行值,这个方法相较于Do来说的区别是执行DoChan后不会阻塞到其中一个协程完成任务,而是异步执行任务,最后需要结果时直接从通道中获取,避免长时间等待】
Forget:告诉 Group 忘记这个 key(手动移除某个 key)。这样一来,之后这个 key 请求会执行 f,而不是等待前一个未完成的 fn 函数的结果。【如果在某些场景下允许第一个调用失败后再次尝试调用该函数,而不希望同一时间内的多次请求都因第一个调用返回失败而失败,那么可以通过调用该方法来忘记这个key。】

源码剖析

Group 是 singleflight 的核心,代表一个组,用于执行具有重复抑制的工作单元。

type Group struct {
    mu sync.Mutex       
    m  map[string]*call
}

SingleFlight 是使用互斥锁 Mutex 和 Map 来实现的。互斥锁 Mutex 提供并发时的读写保护,而 Map 用于保存同一个 key 正在处理的请求。

最佳实践

1)key 的设计

  • 唯一性:确保传递给 Do 方法的 key 具有唯一性,以便 Group 区分不同请求。推荐使用结构化的命名方式来保证 key 的唯一性,例如,可以遵循类似 {类型}):{标识} 的规范来构建 key。以获取用户信息为例,相应的 key 可以是 user:123,其中 user 标识数据类型,而 123 则是具体的用户标识。
  • 一致性:对于相同的请求,无论何时调用,生成的 key 应该保持一致,以便 Group 正确地合并相同的请求,防止非预期的错误。

 2)超时控制

在调用 Group.Do 方法时,第一个到达的 goroutine 可以成功执行 fn 函数,而其他随后到达的 goroutine 将进入阻塞状态。如果阻塞状态持续过长,可能需要采取降级策略以保证系统的响应性,这时可以利用 Group.DoChan 方法和结合 select 语句实现超时控制。
以下是一个实现超时控制的简单示例:

package main

import (
    "fmt"
    "golang.org/x/sync/singleflight"
    "time"
)

func main() {
    var sg singleflight.Group
    doChan := sg.DoChan("key", func() (interface{}, error) {
        time.Sleep(6 * time.Second)
        return "test", nil
    })
    select {
    case <-doChan:
        fmt.Println("done")
    case <-time.After(5 * time.Second):
        fmt.Println("timeout")
        // 采用其他降级策略
    }
}

特点

总结

  • Do和DoChan 一个用于同步阻塞调用传入的函数,一个用于异步调用传入的参数并通过 Channel 接收函数的返回值;
  • Forget可以通知 Group 在持有的映射表中删除某个键,接下来对该键的调用就不会等待前面的函数返回了;
  • 一旦调用的函数返回了错误,所有在等待的 Goroutine 也都会接收到同样的错误;
  • 主要作用是合并并发请求的场景,针对于同一时刻相同的读请求。而对于并发写请求的场景,如果是多次写只需要一次的情况,那么也是满足的。例如:每个 http 请求都会携带 token,每次请求都需要把 token 存入缓存或者写入数据库,如果多次并发请求同时进来,只需要写一次即可。
posted @ 2024-08-12 20:26  李若盛开  阅读(293)  评论(0)    收藏  举报