Web服务中的缓存设计(允许出错、不允许出错)

在Web Server中使用的Cache,基本上可以分为两大类,允许出错的和不允许出错的。本文的主体也会按照这两部分展开。

另一方面,数据区分为了 Source 和 Cache 。Source 和 Cache 通常是不同的存储介质、数据结构、软件,比如 Cache 通常保存在内存中,使用Redis、Mencache、ES、甚至MongoDB等软件。Source通常是MySQL、PostgreSQL、MongoDB等等软件。

缓存,通常意味着更加高效的查询,因此其通常使用 K/V 这种简单的数据模型(区别于SQL这种复杂的关系型模型)。同时,Cache 的数据和 Source 的数据有时会有着不同的格式,最常见的场景:Cache中保存着SQL的SELECT COUNT(1) FROM TABLE;的结果,Cache 中的数据是一个数字,而 Source 中的数据是N条记录。

缓存流程 - 允许出错

允许错的缓存方案,一般会有缓存过期时间。

基本流程

image-20220626224200068.png

为何不同时更新数据库和缓存?

这样容易错 - 分布式事务角度

首先,更新缓存和更新数据库是两个独立的操作(或者说两个事务),也没办法同时提交。(2PC、3PC可以,但是麻烦呀)所以两个操作有任何一个执行失败了,都会导致数据不一致。

先更新数据源,再更新缓存

image-20220626224226383.png

先更新缓存,再更新数据源

image-20220626230112761.png

这样容易错 - 并发写角度

有人说,在常规的情况下,数据库、redis的连接一般都挺稳定的,很少会操作失败。那么我们来看另一个并发场景:

扫描二维码关注公众号,回复: 14382202 查看本文章

先更新缓存,再更新数据库 写A写B同时发生,先更新缓存的写A却后更新数据库。

image-20220626224312650.png

先更新数据库,再更新缓存 写A写B同时发生,先更新数据库的写A却后更新缓存。

image-20220626224333972.png

其他原因

首先,数据被更新,不代表数据会被立马被读到(视具体的场景)(数据懒加载原则)。

其次,数据的缓存,与数据的本源结构不一定一致(比如缓存是一个count(*)操作的缓存)。

最后,因为数据库本身有一些逻辑(比如默认值、on update current_timestamp(6)、递增值、随机值等等),因此,只有读出来的数据,才是真正的数据。

Twitter的选择:trade off

Twitter选择先更新数据库,再让缓存失效这种方式。就如这张图:

image-20220626224355598.png

虽然这种方式不保证100%正确,但是却可以避免我们上面提到的并发写问题。(Twitter认为操作缓存失败的概率很小,一定程度上忽略了分布式事务问题)。这种方式自有的问题,和并发写问题相似但不同:

image-20220626224415337.png

解释一下1中缓不存在的原因,可能是因为没有缓存,可能是缓存超时失效了,也可能是在1前紧跟着一个写请求,将缓存删除了。

虽然可能会发生图中所示的错误,但是出错的概率很小,其必须满足两个条件:

  1. 缓存提前失效了(由于超时或写操作主动删除缓存)。
  2. 一般而言读请求会更快,写请求更慢,图中的时序很难发生。 PS:只是说概率小,不是不会发生。线程调度/GC/读没优化好而导致读很慢/读本身是个耗时操作 都有可能让图示时序发生。

综上,虽然这个事情可能发生,但是概率很小。如果系统允许一个缓存过期周期的错误数据,则这可能是最好的缓存方案。

缓存流程 - 不允许错

在不允许错的情况下,我们通常对脏数据非常敏感,一个过期周期的脏数据通常不能忍受。

串行化

我们上述聊到的最复杂的问题,都是并发资源操作带来的。如果能将对单个资源的操作串行化,则事情简单起来。

串行化通常有两种方案,加锁和异步更新+顺序消费。

加锁

image-20220626224441558.png

我们假设一个极度简单业务场景:以用户id为key,用户基础数据为value,进行缓存。总共三个接口:增加用户、查询用户数据、更新用户数据。

  • 增加用户不操作缓存;
  • 查询用户数据时,如果如果缓存不命中,先加锁,再查数据库,再设置缓存;
  • 更新用户数据时,先加锁,再更新数据库,再更新缓存。

锁也有多种方案,比如利用数据库自带的锁;用redis获得锁;专门建立一个锁发布中心;弄一套租约机制等等。各种锁方案各有利弊,这里暂不讨论。

异步更新 顺序消费

如果说担心加锁导致请求变更慢,且不太在意更新的即时性,可以将更新作为一个异步的任务,投递到消息队列中,然后消费者顺序消费,更新数据源和缓存。

不过顺序消费有比较多的坑和限制,这个方案就不进一步展开了。如果真的要实施,需要比较多额外的工作和一些容忍度。

追加更新

假设Web应用设置每个请求必须在10s内完成,否则强行终止请求;或我们能假设99.9999%的请求都会在3s内完成。则我们可以添加一个3s后执行的 删除 / 更新缓存的异步任务。这样这个 删除 / 更新 缓存的操作必然会在请求结束后执行,使缓存变成最新的状态。

缓存的坑

雪崩

大量热点key同时失效,请求全部打到数据库。

原因

通常是因为缓存自动过期,或者更新数据导致缓存失效。

也有可能是因为冷启动,导致这些热点key不存在。这个问题的对策在“冷启动”一章中细说。

对策

有一个比较简单的方案,可以避免这种问题的出现:过期时间设置为固定值+随机数,比如固定5分钟过期,随机1分钟前后过期。这样即使所有的数据在同一时刻建立了缓存,也会在4-6分钟之内慢慢过期,而不会同时过期。

如果这个简单方案解决不了,那么就要一些复杂的方案了~

  1. 如果是静态资源的话(或者相对静态,改动很少)且数据量不大,直接缓存全量数据,通过加锁来同步维护数据库和缓存(见上“缓存流程 - 不允许错”)。
  2. 如果数据量不大,设计新旧两个缓存,新缓存5分钟一过期,旧缓存1天一过期。每次缓存刷新,都同时刷新 新旧 两个数据。这样即使新缓存失效,也不会有大量请求打到数据库之上。
  3. 识别热点数据,对于热点数据,后台刷新缓存与过期时间。
  4. 设计二级过期时间。比如一级过期时间5分钟,二级过期时间1h。让请求在读缓存的同时,更新缓存的一级过期时间。原本5分钟的缓存,可以1h内永不过期,但是1h后必须过期一次,防止数据不一致。

击穿

和雪崩很像,雪崩是大量热点key同时过期,击穿是一个超高热点key过期。策略上差不多。

穿透

用不存在的数据去做大量查询,或者用无法缓存的查询去做大量查询。

对策

一般这种情况都是有人在搞你,一般可以通过nginx等限流限制住,再或者在代码层面做检查,比如id一定要在某个区间内,或者在某个集合内,或者符合一定的格式。如果以上两种限流还搞不定,则可以去添加一些特殊的缓存:对“有”和“没有”两种情况都去做缓存。

比如对于实例ID,先做正则匹配,来确保格式正确;再把ID里的时间读出来,看一看是否属于过去生成的ID;再经过一个NotExisted的布尔过滤器,如果存在于布尔过滤器,则直接返回空,否则去查数据库;如果数据库里也不存在,则将这个ID加入到NotExisted的布尔过滤器。

缓存淘汰策略

如果我们缓存了太多的数据,那么就不得不考虑一下缓存系统是否能存放下这么多数据,如果存放不下,怎么淘汰数据?如果淘汰策略选择的不好,那么我们之前所有精心设计的缓存策略都可能被击溃。

对于所有的淘汰算法,都要同时考虑其实现的目标和实现的手段,这两点综合组成了效果。比如LRU,其目标是将最后一个被使用的数据淘汰,其最佳的实现手段是HashMap+双向链表。

在不同的情况下,LRU、LFU、FIFO都可能成为最佳的算法,而其不同的实现或变种又可能有各自的场景,如果可以展开讲非常多的内容。这里跳过不谈。

冷启动

有两种冷启动,一种是缓存组件挂了,重新启动导致缓存为空。另一种是整个Web服务器(包括Web应用,数据库,缓存)从0开始运行。对于前者的情况,可以做缓存的持久化,或者做主从,来避免缓存丢失。对于后一种情况,可以随机/按一定策略(甚至包括运营的配置)将一些数据塞入缓存。

非预期超热点数据

这种情况日常开发比较少见,多出现在微博 。一般而言一台单机redis服务器的并发量完全可以满足一般的网站开发,如果不行那就再上集群。如果某个Key的访问量特别大,超过了单台redis的承受能力,那么可能会打崩掉某个redis。

从运维的角度来讲,我们引入一个“组”的概念。每个组负责一个redis切片。而每个组内设置多个redis实例。运维动态监控redis的状态,并动态扩容组内的redis实例。

从开发的角度来讲,由于Web应用可以无限横向扩展(因为是纯逻辑、纯过程的)。我们可以设计一个热点发现系统,将热点数据推送到每个Web应用的本地内存进行缓存。

实战

B站的点赞功能

  1. 对于B站的每个视频,每个用户可以点赞,或取消点赞。以上两个操作可以重复无数次。
  2. 每个视频要显示点赞的数量
  3. 每个用户要能查看是否对每个视频点赞过

原始数据表

 create table Zan (
      id       bigint primary key,
      user_id  varcher(255),
      video_id varcher(255),
      cancel   bool,  # 是否取消点赞
 );
 ​
 create index scan_index on Zan (user_id, video_id);

两个缓存:

  1. 视频点赞数的缓存
  2. 用户是否点赞的缓存

对于视频点赞数的缓存:一个后台程序,每隔5分钟,找出一小时内被播放过的视频,然后更新缓存中的点赞数,并更新缓存的过期时间。这样热点视频的点赞数缓存永不过期。

对于用户是否点赞的缓存:用户id+视频id成为唯一key,存一个bool值。对于这个值的读取与更新,可以用锁或者Twitter的策略去实现。

最佳实践

Cache 缓存

One More Things

Cache的四种范式 / 分类

缓存的设计模式一般分为四种 Cache aside, Read through, Write through, Write behind caching。

今天是Web Cache的分享,即ache Aside Pattern。

Cache Aside Pattern

Cache aside pattern是最常用的方式,其基本概念如下:

  1. 数据区分为了source和cache。cache和source通常是不同的存储介质、数据结构、软件,比如cache通常是redis、mencache、ES、甚至mongoDB。source通常是MySQL、PostgreSQL、mongoDB等等。
  2. cache中的数据基本以键值对的形式存在,key对应着一个cache中的数据,也对应着一个source的查询。
  3. cache中的数据通常有过期时间,cache也通常有容量限制。

Read/Write Through Pattern

我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

比如MySQL5.6里,会把一些热点数据放在内存中;CPU上的高速缓存,也会缓存一些内存页,大体思路都比较像。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

这里给一个Wikipedia的Cache词条,其他就不多赘述了。

Write Behind Cache Pattern

这种模式是当数据更新的时候直接更新缓存数据,然后建立异步任务去更新数据库。这种异步方式请求响应会很快,系统的吞吐量会明显提升。

比如MySQL的WAL,磁盘系统,都有类似的设计。这个设计利用了磁盘顺序存储的速度优势来进行优化。但是在一般的Web服务器之后,我们一般不需要考虑这种方式。

Go 的 LocalCache 如何实现 ZERO GC?

www.komu.engineer

如何提高并发下的性能?

实现Raft时 对于加锁和避免死锁的思考(1-5更新)

如何提高并发下的正确性?

[论文翻译] 从错误中学习 - 对工程中的并发bug的特性的综合性研究

如何高效获取时间差?

go中使用单调时钟获得准确的时间间隔

参考资料

缓存解决方案

阅读笔记:Scaling Memcache at Facebook

缓存的设计与使用

oschina 上的一种双缓存思路

后端应用缓存最佳实践www.jianshu.com/p/c596e1050…)

猜你喜欢

转载自juejin.im/post/7121928612378312718
今日推荐