Go singleflight:防缓存击穿利器

作者:陈明勇

个人网站:https://chenmingyong.cn

文章持续更新,如果本文能让您有所收获,欢迎点赞收藏加关注本掘金号。 微信阅读可搜《程序员陈明勇》。 这篇文章已被收录于 GitHub https://github.com/chenmingyong0423/blog,欢迎大家 Star 催更并持续关注。

前言

在构建高性能的服务时,缓存是优化数据库压力和提高响应速度的关键技术。使用缓存也会带来一些问题,其中就包括 缓存击穿,它不仅会导致数据库压力剧增,引起数据库性能的下降,严重时甚至会击垮数据库,导致数据库不可用。

Go 语言中,golang.org/x/sync/singleflight 包提供了一种机制,确保对于任何特定 key 的并发请求在同一时刻只执行一次。这个机制有效地防止了缓存击穿问题。

本文将深入探讨 Go 语言中 singleflight 包的使用。从缓存击穿问题的基础知识开始,进而详细介绍 singleflight 包的使用,展示如何利用它来避免缓存击穿。

准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。

在这里插入图片描述

缓存击穿

缓存击穿 是指在高并发的情况下,某个热点的 key 突然过期,导致大量的请求直接访问数据库,造成数据库的压力过大,甚至宕机的现象。

在这里插入图片描述

常见的解决方案:

  • 设置热点数据永不过期:对于一些确定的热点数据,可以将其设置为 永不过期,这样就可以确保不会因为缓存失效而导致请求直接访问数据库。
  • 设置互斥锁:为了防止缓存失效时所有请求同时查询数据库,可以采用锁机制确保仅有一个请求查询数据库并更新缓存,而其他请求则在缓存更新后再进行访问。
  • 提前更新:后台监控缓存的使用情况,当缓存即将过期时,异步更新缓存,延长过期时间。

singleflight 包

Package singleflight provides a duplicate function call suppression mechanism.

这段英文来自官方文档的介绍,直译过来的意思是:singleflight 包提供了一种“重复函数调用抑制机制”。

换句话说,当多个 goroutine 同时尝试调用同一个函数(基于某个给定的 key)时,singleflight 会确保该函数只会被第一个到达的 goroutine 调用,其他 goroutine 会等待这次调用的结果,然后共享这个结果,而不是同时发起多个调用。

一句话概括就是 singleflight 将多个请求合并成一个请求,多个请求共享同一个结果。

组成部分

  • Group:这是 singleflight 包的核心结构体。它管理着所有的请求,确保同一时刻,对同一资源的请求只会被执行一次。Group 对象不需要显式创建,直接声明后即可使用。

  • Do 方法:Group 结构体提供了 Do 方法,这是实现合并请求的主要方法,该方法接收两个参数:一个是字符串 key(用于标识请求资源),另一个是函数 fn,用来执行实际的任务。在调用 Do 方法时,如果已经有一个相同 key 的请求正在执行,那么 Do 方法会等待这个请求完成并共享结果,否则执行 fn 函数,然后返回结果。

    Do 方法有三个返回值,前两个返回值是 fn 函数的返回值,类型分别为 interface{}error,最后一个返回值是一个 bool 类型,表示 Do 方法的返回结果是否被多个调用共享。

  • DoChan:该方法与 Do 方法类似,但它返回的是一个通道,通道在操作完成时接收到结果。返回值是通道,意味着我们能以非阻塞的方式等待结果。

  • Forget:该方法用于从 Group 中删除一个 key 以及相关的请求记录,确保下次用同一 key 调用 Do 时,将立即执行新请求,而不是复用之前的结果。

  • Result:这是 DoChan 方法返回结果时所使用的结构体类型,用于封装请求的结果。这个结构体包含三个字段,具体如下:

    • Valinterface{} 类型):请求返回的结果。
    • Errerror 类型):请求过程中发生的错误信息。
    • Sharedbool 类型):表示这个结果是否被当前请求以外的其他请求共享。

安装

通过以下命令,在 go 应用中安装 singleflight 依赖:

go get golang.org/x/sync/singleflight

使用示例

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/singleflight/usage/main.go
package main

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

var errRedisKeyNotFound = errors.New("redis: key not found")

func fetchDataFromCache() (any, error) {
    fmt.Println("fetch data from cache")
    return nil, errRedisKeyNotFound
}

func fetchDataFromDataBase() (any, error) {
    fmt.Println("fetch data from database")
    return "程序员陈明勇", nil
}

func fetchData() (any, error) {
    cache, err := fetchDataFromCache()
    if err != nil && errors.Is(err, errRedisKeyNotFound) {
        fmt.Println(errRedisKeyNotFound.Error())
        return fetchDataFromDataBase()
    }
    return cache, err
}

func main() {
    var (
        sg singleflight.Group
        wg sync.WaitGroup
    )

    for range 5 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            v, err, shared := sg.Do("key", fetchData)
            if err != nil {
                panic(err)
            }
            fmt.Printf("v: %v, shared: %v\n", v, shared)
        }()
    }
    wg.Wait()
}

在这里插入图片描述

这段代码模拟了一个典型的并发访问场景:从缓存获取数据,若缓存未命中,则从数据库检索。在此过程中,singleflight 库起到了至关重要的作用。它确保在多个并发请求尝试同时获取相同数据时,实际的获取操作(不论是访问缓存还是查询数据库)只会执行一次。这样不仅减轻了数据库的压力,还有效防止了高并发环境下可能发生的缓存击穿问题。

代码运行结果如下所示:

fetch data from cache
redis: key not found         
fetch data from database     
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true

根据运行结果可知,当 5goroutine 并发获取相同数据时,数据获取操作实际上只由一个goroutine执行了一次。此外,由于所有返回的 shared 值均为 true,这表明返回的结果被其他 4goroutine共享。

最佳实践

key 的设计

在生成 key 的时候,我们应该保证它的唯一性与一致性。

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

超时控制

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

以下是一个实现超时控制的简单示例:

// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/singleflight/timeout_control/main.go
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(4 * time.Second)
        return "程序员陈明勇", nil
    })
    select {
    case <-doChan:
        fmt.Println("done")
    case <-time.After(2 * time.Second):
        fmt.Println("timeout")
        // 采用其他降级策略
    }
}

小结

本文首先介绍了 缓存击穿 的含义及其常见的解决方案。

然后深入探讨了 singleflight 包,从基础概念、组成部分到具体的安装和使用示例。

接着通过模拟一个典型的并发访问场景来演示如何利用 singleflight 来防止在高并发场景下可能发生的缓存击穿问题。

最后,探讨在实践中设计 key 和控制请求超时的最佳策略,以便更好地理解和应用 singleflight,从而优化并发处理逻辑。

singleflight 包的源码解析文章即将发布,如果你对 singleflight 的源码感兴趣,别忘了点个关注,以免错过精彩内容。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员陈_明勇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值