抛开业务谈技术都是在耍流氓。—— Kevin Wangit
先从一个老生常谈的问题开始谈起:咱们的程序是如何运行起来的?github
disk
中RAM
之中,也就是咱们所说的 main memory
CPU
中执行来看一个最简单的例子:a = a + 1
golang
load x:
x0 = x0 + 1
load x0 -> RAM
上面提到了3种存储介质。咱们都知道,三类的读写速度和成本成反比,因此咱们在克服速度问题上须要引入一个 中间层。这个中间层,须要高速存取的速度,可是成本可接受。因而乎,Cache
被引入redis
而在计算机系统中,有两种默认缓存:sql
LLC
。缓存内存中的数据page cache
。缓存磁盘中的数据引入 Cache
以后,咱们继续来看看操做缓存会发生什么。由于存在存取速度的差别「并且差别很大」,从而在操做数据时,延迟或程序失败等都会致使缓存和实际存储层数据不一致。数据库
咱们就以标准的 Cache+DB
来看看经典读写策略和应用场景。后端
先来考虑一种最简单的业务场景,好比用户表:userId:用户id, phone:用户电话token,avtoar:用户头像url
,缓存中咱们用 phone
做为key存储用户头像。当用户修改头像url该如何作?api
DB
数据,再更新Cache
数据DB
数据,再删除 Cache
数据首先 变动数据库 和 变动缓存 是两个独立的操做,而咱们并无对操做作任何的并发控制。那么当两个线程并发更新它们的时候,就会由于写入顺序的不一样形成数据不一致。缓存
因此更好的方案是 2
:网络
DB
,并将结果 load cache
这个策略就是咱们使用缓存最多见的策略:Cache Aside
。这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的,分为读策略和写策略。
可是可见的问题也就出现了:频繁的读写操做会致使 Cache
反复地替换,缓存命中率下降。固然若是在业务中对命中率有监控报警时,能够考虑如下方案:
TTL
。固然除了这个策略,在计算机体系还有其余几种经典的缓存策略,它们也有各自适用的使用场景。
先查询写入数据key是否击中缓存,若是在 -> 更新缓存,同时缓存组件同步数据至DB;不存在,则触发 Write Miss
。
而通常 Write Miss
有两种方式:
Write Allocate
:写时直接分配 Cache line
No-write allocate
:写时不写入缓存,直接写入DB,return在 Write Through
中,通常采起 No-write allocate
。由于其实不管哪一种,最终数据都会持久化到DB中,省去一步缓存的写入,提高写性能。而缓存由 Read Through
写入缓存。
这个策略的核心原则:用户只与缓存打交道,由缓存组件和DB通讯,写入或者读取数据。在一些本地进程缓存组件能够考虑这种策略。
相信你也看出上述方案的缺陷:写数据时缓存和数据库同步,可是咱们知道这两块存储介质的速度差几个数量级,对写入性能是有很大影响。那咱们是否异步更新数据库?
Write back
就是在写数据时只更新该 Cache Line
对应的数据,并把该行标记为 Dirty
。在读数据时或是在缓存满时换出「缓存替换策略」时,将 Dirty
写入存储。
须要注意的是:在 Write Miss
状况下,采起的是 Write Allocate
,即写入存储同时写入缓存,这样咱们在以后的写请求只须要更新缓存。
async purge
此类概念其实存在计算机体系中。Mysql
中刷脏页,本质都是尽量防止随机写,统一写磁盘时机。
Redis
是一个独立的系统软件,和咱们写的业务程序是两个软件。当咱们部署了Redis
实例后,它只会被动地等待客户端发送请求,而后再进行处理。因此,若是应用程序想要使用 Redis
缓存,咱们就要在程序中增长相应的缓存操做代码。因此咱们也把 Redis
称为 旁路缓存,也就是说:读取缓存、读取数据库和更新缓存的操做都须要在应用程序中来完成。
而做为缓存的 Redis
,一样须要面临常见的问题:
通常来讲,缓存对于选定的被淘汰数据,会根据其是干净数据仍是脏数据,选择直接删除仍是写回数据库。可是,在 Redis 中,被淘汰数据不管干净与否都会被删除,因此,这是咱们在使用 Redis 缓存时要特别注意的:当数据修改为为脏数据时,须要在数据库中也把数据修改过来。
因此无论替换策略是什么,脏数据有可能在换入换出中丢失。那咱们在产生脏数据就应该删除缓存,而不是更新缓存,一切数据应该以数据库为准。这也很好理解,缓存写入应该交给读请求来完成;写请求尽量保证数据一致性。
至于替换策略有哪些,网上已经有不少文章概括之间的优劣,这里就再也不赘述。
并发场景下,可能会有多个线程(协程)同时请求同一份资源,若是每一个请求都要走一遍资源的请求过程,除了比较低效以外,还会对资源服务形成并发的压力。
go-zero
中的 ShardCalls
可使得同时多个请求只须要发起一次拿结果的调用,其余请求"不劳而获",这种设计有效减小了资源服务的并发压力,能够有效防止缓存击穿。
对于防止暴增的接口请求对下游服务形成瞬时高负载,能够在你的函数包裹:
fn = func() (interface{}, error) { // 业务查询 } data, err = g.Do(apiKey, fn) // 就得到到data,以后的方法或者逻辑就可使用这个data
其实原理也很简单:
func (g *sharedGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) { // done: false,才会去执行下面的业务逻辑;为 true,直接返回以前获取的data c, done := g.createCall(key) if done { return c.val, c.err } // 执行调用者传入的业务逻辑 g.makeCall(c, key, fn) return c.val, c.err } func (g *sharedGroup) createCall(key string) (c *call, done bool) { // 只让一个请求进来进行操做 g.lock.Lock() // 若是携带标示一系列请求的key在 calls 这个map中已经存在, // 则解锁并同时等待以前请求获取数据,返回 if c, ok := g.calls[key]; ok { g.lock.Unlock() c.wg.Wait() return c, true } // 说明本次请求是首次请求 c = new(call) c.wg.Add(1) // 标注请求,由于持有锁,不用担忧并发问题 g.calls[key] = c g.lock.Unlock() return c, false }
这种 map+lock
存储并限制请求操做,和groupcache中的 singleflight
相似,都是防止缓存击穿的利器
源码地址:sharedcalls.go
这是开发中常见纠结问题:究竟是先删除缓存仍是先更新存储?
状况一:先删除缓存,再更新存储;
A
删除缓存,更新存储时网络延迟B
读请求,发现缓存缺失,读存储 -> 此时读到旧数据
这样会产生两个问题:
B
读取旧值B
同时读请求会把旧值写入缓存,致使后续读请求读到旧值既然是缓存多是旧值,那就无论删除。有一个并不优雅的解决方案:在写请求更新完存储值之后,sleep()
一小段时间,再进行一次缓存删除操做。
sleep
是为了确保读请求结束,写请求能够删除读请求形成的缓存脏数据,固然也要考虑到 redis 主从同步的耗时。不过仍是要根据实际业务而定。
这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,被称为:延迟双删。
状况二:先更新数据库值,再删除缓存值:
A
删除存储值,可是删除缓存网络延迟B
读请求时,缓存击中,就直接返回旧值
这种状况对业务的影响较小,而绝大多数缓存组件都是采起此种更新顺序,知足最终一致性要求。
状况三:新用户注册,直接写入数据库,同时缓存中确定没有。若是程序此时读从库,因为主从延迟,致使读取不到用户数据。
这种状况就须要针对 Insert
这种操做:插入新数据入数据库同时写缓存。使得后续读请求能够直接读缓存,同时由于是刚插入的新数据,在一段时间修改的可能性不大。
以上方案在复杂的状况或多或少都有潜在问题,须要贴合业务作具体的修改。
上面说了这么多,回到咱们开发角度,若是咱们须要考虑这么多问题,显然太麻烦了。因此如何把这些缓存策略和替换策略封装起来,简化开发过程?
明确几点:
咱们从读和写两个角度去聊聊 go-zero
是如何封装。
// res: query result // cacheKey: redis key err := m.QueryRow(&res, cacheKey, func(conn sqlx.SqlConn, v interface{}) error { querySQL := `select * from your_table where campus_id = ? and student_id = ?` return conn.QueryRow(v, querySQL, campusId, studentId) })
咱们将开发查询业务逻辑用 func(conn sqlx.SqlConn, v interface{})
封装。用户无需考虑缓存写入,只须要传入须要写入的 cacheKey
。同时把查询结果 res
返回。
那缓存操做是如何被封装在内部呢?来看看函数内部:
func (c cacheNode) QueryRow(v interface{}, key string, query func(conn sqlx.SqlConn, v interface{}) error) error { cacheVal := func(v interface{}) error { return c.SetCache(key, v) } // 1. cache hit -> return // 2. cache miss -> err if err := c.doGetCache(key, v); err != nil { // 2.1 err defalut val {*} if err == errPlaceholder { return c.errNotFound } else if err != c.errNotFound { return err } // 2.2 cache miss -> query db // 2.2.1 query db return err {NotFound} -> return err defalut val「see 2.1」 if err = query(c.db, v); err == c.errNotFound { if err = c.setCacheWithNotFound(key); err != nil { logx.Error(err) } return c.errNotFound } else if err != nil { c.stat.IncrementDbFails() return err } // 2.3 query db success -> set val to cache if err = cacheVal(v); err != nil { logx.Error(err) return err } } // 1.1 cache hit -> IncrementHit c.stat.IncrementHit() return nil }
从流程上刚好对应缓存策略中的:Read Through
。
源码地址:cachedsql.go
而写请求,使用的就是以前缓存策略中的 Cache Aside
-> 先写数据库,再删除缓存。
_, err := m.Exec(func(conn sqlx.SqlConn) (result sql.Result, err error) { execSQL := fmt.Sprintf("update your_table set %s where 1=1", m.table, AuthRows) return conn.Exec(execSQL, data.RangeId, data.AuthContentId) }, keys...) func (cc CachedConn) Exec(exec ExecFn, keys ...string) (sql.Result, error) { res, err := exec(cc.db) if err != nil { return nil, err } if err := cc.DelCache(keys...); err != nil { return nil, err } return res, nil }
和 QueryRow
同样,调用者只须要负责业务逻辑,缓存写入和删除对调用透明。
源码地址:cachedsql.go
开篇第一句话:脱离业务将技术都是耍流氓。以上都是在对缓存模式分析,可是实际业务中缓存是否起到应有的加速做用?最直观就是缓存击中率,而如何观测到服务的缓存击中?这就涉及到监控。
下图是咱们线上环境的某个服务的缓存记录状况:
还记得上面 QueryRow
中:查询缓存击中,会调用 c.stat.IncrementHit()
。其中的 stat
就是做为监控指标,不断在计算击中率和失败率。
源码地址:cachestat.go
在其余的业务场景中:好比首页信息浏览业务中,大量请求不可避免。因此缓存首页的信息在用户体验上尤为重要。可是又不像以前提到的一些单一的key,这里可能涉及大量消息,这个时候就须要其余缓存类型加入:
消息id
-> 由 消息id
查询消息,并缓存插入消息list
中。这里也就是涉及缓存的最佳实践:
本文从缓存的引入,常见缓存读写策略,如何保证数据的最终一致性,如何封装一个好用的缓存操做层,也展现了线上缓存的状况以及监控。全部上面谈到的这些缓存细节均可以参考 go-zero
源码实现,见 go-zero
源码的 core/stores
。
https://github.com/tal-tech/go-zero
欢迎使用 go-zero 并 star 鼓励咱们!👏🏻