乐趣区

关于golang:缓存设计的好服务基本不会倒

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

大家好,很快乐来到“GO 开源说”跟大家分享开源我的项目背地的一些故事、设计思维以及应用办法,明天分享的我的项目是 go-zero,一个集成了各种工程实际的 web 和 rpc 框架。我是 Kevin,go-zero 作者,我的 github id 是 kevwan。

go-zero 概览

go-zero 尽管是 20 年 8 月 7 号才开源,然而曾经通过线上大规模测验了,也是我近 20 年工程教训的积攒,开源后失去社区的踊跃反馈,在 5 个多月的工夫里,取得了 6k stars。屡次登顶 github Go 语言日榜、周榜、月榜榜首,并取得了 gitee 最有价值我的项目(GVP),开源中国年度最佳人气我的项目。同时微信社区极为沉闷,3000+ 人的社区群,go-zero 爱好者们一起交换 go-zero 应用心得和探讨应用过程中的问题。

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 等,如图

  1. 查问单条残缺行记录,如图

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

对于下面三种状况,首先,咱们不必局部查问,因为局部查问没法缓存,一旦缓存了,数据有更新,没法定位到有哪些数据须要删除;其次,对于多行的查问,依据理论场景和须要,咱们会在业务层建设对应的从查问条件到主键的映射;而对于单行残缺记录的查问,go-zero 内置了残缺的缓存治理形式。所以外围准则是:go-zero 缓存的肯定是残缺的行记录

上面咱们来具体介绍 go-zero 内置的三种场景的缓存解决形式:

  1. 基于主键的缓存

    PRIMARY KEY (`id`)

    这种绝对来讲是最容易解决的缓存,只须要在 redis 里用 primary key 作为 key 来缓存行记录即可。

  2. 基于惟一索引的缓存

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

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

    • 单列惟一索引如下:

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

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

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

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

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

    能够看到比拟具体的统计信息,便于咱们来剖析缓存的应用状况,对于缓存命中率极低或者申请量极小的状况,咱们就能够去掉缓存了,这样也能够降低成本。

缓存代码解读

1. 基于主键的缓存逻辑

具体实现代码如下:

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{}) error) error {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{}) error) error {
  // 用 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
    • 返回所需的行记录数据
    // v - 须要读取的数据对象
    // key - 通过索引生成的缓存 key
    // keyer - 用主键生成基于主键缓存的 key 的办法
    // indexQuery - 用索引从 DB 读取残缺数据的办法,须要返回主键
    // primaryQuery - 用主键从 DB 获取残缺数据的办法
    func (cc CachedConn) QueryRowIndex(v interface{}, key string, keyer func(primary interface{}) string,
      indexQuery IndexQueryFn, primaryQuery PrimaryQueryFn) error {var primaryKey interface{}
      var found bool
    
      // 先通过索引查问缓存,看是否有索引到主键的缓存
      if err := cc.cache.TakeWithExpire(&primaryKey, key, func(val interface{}, expire time.Duration) (err error) {
        // 如果没有索引到主键的缓存,那么就通过索引查问残缺数据
        primaryKey, err = indexQuery(cc.db, v)
        if err != nil {return}
    
        // 通过索引查问到了残缺数据,设置 found,前面间接应用,不须要再从缓存读取数据了
        found = true
        // 将主键到残缺数据的映射保留到缓存里,TakeWithExpire 办法曾经将索引到主键的映射保留到缓存了
        return cc.cache.SetCacheWithExpire(keyer(primaryKey), v, expire+cacheSafeGapBetweenIndexAndPrimary)
      }); err != nil {return err}
    
      // 曾经通过索引找到了数据,间接返回即可
      if found {return nil}
    
      // 通过主键从缓存读取数据,如果缓存没有,通过 primaryQuery 办法从 DB 读取并回写缓存再返回数据
      return cc.cache.Take(v, keyer(primaryKey), func(v interface{}) error {return primaryQuery(cc.db, v, primaryKey)
      })
    }

    咱们来看一个理论的例子

    func (m *defaultUserModel) FindOneByUser(user string) (*User, error) {
      var resp User
      // 生成基于索引的 key
      indexKey := fmt.Sprintf("%s%v", cacheUserPrefix, user)
      
      err := m.QueryRowIndex(&resp, indexKey,
        // 基于主键生成残缺数据缓存的 key
        func(primary interface{}) string {return fmt.Sprintf("user#%v", primary)
        },
        // 基于索引的 DB 查询方法
        func(conn sqlx.SqlConn, v interface{}) (i interface{}, e error) {query := fmt.Sprintf("select %s from %s where user = ? limit 1", userRows, m.table)
          if err := conn.QueryRow(&resp, query, user); err != nil {return nil, err}
          return resp.Id, nil
        },
        // 基于主键的 DB 查询方法
        func(conn sqlx.SqlConn, v, primary interface{}) error {query := fmt.Sprintf("select %s from %s where id = ?", userRows, m.table)
          return conn.QueryRow(&resp, query, primary)
        })
      
      // 错误处理,须要判断是否返回的是 sqlc.ErrNotFound,如果是,咱们用本 package 定义的 ErrNotFound 返回
      // 防止使用者感知到有没有应用缓存,同时也是对底层依赖的隔离
      switch err {
        case nil:
          return &resp, nil
        case sqlc.ErrNotFound:
          return nil, ErrNotFound
        default:
          return nil, err
      }
    }

所有下面这些缓存的主动治理代码都是能够通过 goctl 主动生成的,咱们团队外部 CRUD 和缓存根本都是通过 goctl 主动生成的,能够节俭大量开发工夫,并且缓存代码自身也是非常容易出错的,即便有很好的代码教训,也很难每次齐全写对,所以咱们举荐尽可能应用主动的缓存代码生成工具去防止谬误。

Need more?

如果你想要更好的理解 go-zero 我的项目,欢送返回官方网站上学习具体的示例。

视频回放地址

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

我的项目地址

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

欢送应用 go-zero 并 star 反对咱们!

go-zero 系列文章见『微服务实际』公众号

退出移动版