微服务缓存原理与最佳实践

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