Go 开源说第四期(下):go-zero缓存管理最佳实践

GoCN

共 4694字,需浏览 10分钟

 ·

2021-02-09 05:24

本文有『Go开源说』第四期 go-zero 直播内容修改整理而成,视频内容较长,拆分成上下篇,本文内容有所删减和重构。

大家好,很高兴来到“GO开源说” 跟大家分享开源项目背后的一些故事、设计思想以及使用方法,今天分享的项目是 go-zero,一个集成了各种工程实践的 web 和 rpc 框架。我是Kevin,go-zero 作者,我的 github id 是 kev wan。

go-zero 概览

go-zero 虽然是20年8月7号才开源,但是已经经过线上大规模检验了,也是我近20年工程经验的积累,开源后得到社区的积极反馈,在5个多月的时间里,获得了5.9k star。多次登顶github Go语言日榜、周榜、月榜榜首,并获得了gitee最有价值项目(GVP),开源中国年度最佳人气项目。同时微信社区极为活跃,3000+人的社区群,go-zero爱好者们一起交流go-zero使用心得和讨论使用过程中的问题。

star

go-zero 如何自动管理缓存?

缓存设计原理

我们对缓存是只删除,不做更新,一旦DB里数据出现修改,我们就会直接删除对应的缓存,而不是去更新。

我们看看删除缓存的顺序怎样才是正确的。

  • 先删除缓存,再更新DB

我们看两个并发请求的情况,A请求需要更新数据,先删除了缓存,然后B请求来读取数据,此时缓存没有数据,就会从DB加载数据并写回缓存,然后A更新了DB,那么此时缓存内的数据就会一直是脏数据,知道缓存过期或者有新的更新数据的请求。如图

  • 先更新DB,再删除缓存

A请求先更新DB,然后B请求来读取数据,此时返回的是老数据,此时可以认为是A请求还没更新完,最终一致性,可以接受,然后A删除了缓存,后续请求都会拿到最新数据,如图

让我们再来看一下正常的请求流程:

  1. 第一个请求更新DB,并删除了缓存
  2. 第二个请求读取缓存,没有数据,就从DB读取数据,并回写到缓存里
  3. 后续读请求都可以直接从缓存读取

我们再看一下DB查询有哪些情况,假设行记录里有ABCDEFG七列数据:

  1. 只查询部分列数据的请求,比如请求其中的ABC,CDE或者EFG等,如图

  2. 查询单条完整行记录,如图

  1. 查询多条行记录的部分或全部列,如图

对于上面三种情况,首先,我们不用部分查询,因为部分查询没法缓存,一旦缓存了,数据有更新,没法定位到有哪些数据需要删除;其次,对于多行的查询,根据实际场景和需要,我们会在业务层建立对应的从查询条件到主键的映射;而对于单行完整记录的查询,go-zero 内置了完整的缓存管理方式。所以核心原则是:go-zero 缓存的一定是完整的行记录

下面我们来详细介绍 go-zero 内置的三种场景的缓存处理方式:

  1. 基于主键的缓存

    PRIMARY KEY (`id`)

    这种相对来讲是最容易处理的缓存,只需要在 redis 里用 primary key 作为 key 来缓存行记录即可。

  2. 基于唯一索引的缓存

    img

    在做基于索引的缓存设计的时候我借鉴了 database 索引的设计方法,在 database 设计里,如果通过索引去查数据时,引擎会先在 索引->主键tree 里面查找到主键,然后再通过主键去查询行记录,就是引入了一个间接层去解决索引到行记录的对应问题。在 go-zero 的缓存设计里也是同样的原理。

    基于索引的缓存又分为单列唯一索引和多列唯一索引:

    但是对于 go-zero 来说,单列和多列只是生成缓存 key 的方式不同而已,背后的控制逻辑是一样的。然后 go-zero 内置的缓存管理就比较好的控制了数据一致性问题,同时也内置防止了缓存的击穿、穿透、雪崩问题(这些在 gopherchina 大会上分享的时候仔细讲过,见后续 gopherchina 分享视频)。

    另外,go-zero 内置了缓存访问量、访问命中率统计,如下所示:

    dbcache(sqlc) - qpm: 5057, hit_ratio: 99.7%, hit: 5044, miss: 13, db_fails: 0

    可以看到比较详细的统计信息,便于我们来分析缓存的使用情况,对于缓存命中率极低或者请求量极小的情况,我们就可以去掉缓存了,这样也可以降低成本。

    • 单列唯一索引如下:

      UNIQUE KEY `product_idx` (`product`)
    • 多列唯一索引如下:

      UNIQUE KEY `vendor_product_idx` (`vendor`, `product`)

缓存代码解读

1. 基于主键的缓存逻辑

img

具体实现代码如下:

func (cc CachedConn) QueryRow(v interface{}, key string, query QueryFn) error {
 return cc.cache.Take(v, key, func(v interface{}) error {
  return query(cc.db, v)
 })
}

这里的 Take 方法是先从缓存里去通过 key 拿数据,如果拿到就直接返回,如果拿不到,那么就通过 query 方法去 DB 读取完整行记录并写回缓存,然后再返回数据。整个逻辑还是比较简单易懂的。

我们详细看看 Take 的实现:

func (c cacheNode) Take(v interface{}, key string, query func(v interface{}) errorerror {
 return c.doTake(v, key, query, func(v interface{}) error {
  return c.SetCache(key, v)
 })
}

Take 的逻辑如下:

  • key 从缓存里查找数据
  • 如果找到,则返回数据
  • 如果找不到,用 query 方法去读取数据
  • 读到后调用 c.SetCache(key, v) 设置缓存

其中的 doTake 代码和解释如下:

// v - 需要读取的数据对象
// key - 缓存key
// query - 用来从DB读取完整数据的方法
// cacheVal - 用来写缓存的方法
func (c cacheNode) doTake(v interface{}, key string, query func(v interface{}) error,
 cacheVal func(v interface{}) errorerror {
  // 用barrier来防止缓存击穿,确保一个进程内只有一个请求去加载key对应的数据
 val, fresh, err := c.barrier.DoEx(key, func() (interface{}, error) {
    // 从cache里读取数据
  if err := c.doGetCache(key, v); err != nil {
      // 如果是预先放进来的placeholder(用来防止缓存穿透)的,那么就返回预设的errNotFound
      // 如果是未知错误,那么就直接返回,因为我们不能放弃缓存出错而直接把所有请求去请求DB,
      // 这样在高并发的场景下会把DB打挂掉的
   if err == errPlaceholder {
    return nil, c.errNotFound
   } else if err != c.errNotFound {
    // why we just return the error instead of query from db,
    // because we don't allow the disaster pass to the DBs.
    // fail fast, in case we bring down the dbs.
    return nil, err
   }

      // 请求DB
      // 如果返回的error是errNotFound,那么我们就需要在缓存里设置placeholder,防止缓存穿透
   if err = query(v); err == c.errNotFound {
    if err = c.setCacheWithNotFound(key); err != nil {
     logx.Error(err)
    }

    return nil, c.errNotFound
   } else if err != nil {
        // 统计DB失败
    c.stat.IncrementDbFails()
    return nil, err
   }

      // 把数据写入缓存
   if err = cacheVal(v); err != nil {
    logx.Error(err)
   }
  }
    
    // 返回json序列化的数据
  return jsonx.Marshal(v)
 })
 if err != nil {
  return err
 }
 if fresh {
  return nil
 }

 // got the result from previous ongoing query
 c.stat.IncrementTotal()
 c.stat.IncrementHit()

  // 把数据写入到传入的v对象里
 return jsonx.Unmarshal(val.([]byte), v)
}

2. 基于唯一索引的缓存逻辑

因为这块比较复杂,所以我用不同颜色标识出来了响应的代码块和逻辑,block 2 其实跟基于主键的缓存是一样的,这里主要讲 block 1 的逻辑。

代码块的 block 1 部分分为两种情况:

  1. 通过索引能够从缓存里找到主键

    此时就直接用主键走 block 2 的逻辑了,后续同上面基于主键的缓存逻辑

  2. 通过索引无法从缓存里找到主键

    • 通过索引从DB里查询完整行记录,如有 error,返回
    • 查到完整行记录后,会把主键到完整行记录的缓存和索引到主键的缓存同时写到 redis
    • 返回所需的行记录数据
    • 右侧代码是个实际使用索引缓存的示例
img

所有上面这些缓存的自动管理代码都是可以通过 goctl 自动生成的,我们团队内部 CRUD 和缓存基本都是通过 goctl 自动生成的,可以节省大量开发时间,并且缓存代码本身也是非常容易出错的,即使有很好的代码经验,也很难每次完全写对,所以我们推荐尽可能使用自动的缓存代码生成工具去避免错误。

go-zero 服务治理 + go-zero 最佳实践(待续)

如果你想要更好的了解 go-zero 项目,欢迎前往官方网站上学习具体的示例。

也可以扫码加入微信群,我们有3000+人的活跃社区共同成长!

视频回放地址

https://www.bilibili.com/video/BV1Jy4y127Xu

项目地址

https://github.com/tal-tech/go-zero

演讲PPT:

https://github.com/gocn/opentalk/tree/main/PhaseFour_go-zero

欢迎使用 go-zero 并 star 支持我们!


浏览 23
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报