Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache 通识 #55

Open
zhongdeming428 opened this issue Jul 2, 2022 · 0 comments
Open

Cache 通识 #55

zhongdeming428 opened this issue Jul 2, 2022 · 0 comments

Comments

@zhongdeming428
Copy link
Owner

1 Cache 定义

缓存通常是介于应用层和存储层之间的一种数据冗余,具有存取速度快,吞吐量高的特征。

相比于存储层的 DB,缓存通常将数据存储在内存中以满足高并发需求,也有部分 Cache 提供持久化能力,但是一般来说,更多的是利用缓存的高速存取能力。

2 Cache 作用

使用 Cache 可以给我们的系统带来以下 好处

  1. 加速读写,Cache 一般是基于内存保存数据,存取速度比 DB 快很多,可以提高系统的响应速度,优化用户体验。
  2. 降低后端的负载,可以将很多应用内的缓存放到分布式 Cache 中,降低应用容器内的 mem 使用率,让其能够专注于提供计算能力,做到无状态化。同时,Cache 可以降低 DB 的载荷,保护 DB 免于受到大量请求的冲击,让其可以专注于数据持久化。

但是使用 Cache 不只是会给我们带来好处,也可能会引入一些 成本

  1. 数据不一致性问题:数据存放在 Cache 和 DB 中,某些场景下两处的数据可能会不一致,这时候系统会出现数据不一致的 bug。
  2. 维护成本升高:引入了 Cache 就多了一些要维护的成本,需要 devops 考虑数据备份、并发能力等问题。
  3. 代码成本升高:应用内会新增很多 Cache 相关的代码,并且在设计 Cache 使用策略时还要针对不同场景精心考量。

所以在引入 Cache 到系统中时,需要考虑当前系统是否真的需要 Cache,如果没有必要的话,或许可以不引入。

目前来看,适合使用 Cache 的场景有以下这些:

  1. 需要快速响应客户端请求的场景,某些数据需要经过长时间的运算之后才能返回,这时候可以将数据缓存在 Cache 中以提高系统的响应能力。
  2. 并发量很大,这时候需要引入 Cache 作为 DB 的保护层,避免 DB 受到大量请求的冲击而不可用。
  3. 一致性要求不高的场景会表示适合使用 Cache 以提高响应速度或者提高处理能力。
  4. 热点数据场景适合使用 Cache,比如直播间点赞、评论,这种数据对一致性要求不是很高(甚至允许小部分数据丢失),而且是短时间内突然爆发,之后可能不会再用到(直播评论不需要持久化),这种场景非常适合使用 Cache。

不适合使用 Cache 的场景

  1. 没有并发要求,比如内部使用的系统,直接访问 DB 也没有压力。
  2. 高一致性要求的场景,比如银行转账汇款之类的。
  3. 与热点数据相对的是冷门数据,比如 APP 内的设置功能,这种属于非常冷门的数据,数据只有极少用户访问到,且访问时间占总体使用时间的比例极低。
  4. 数据量太大,成本非常高(比如缓存大文件),这时候使用 Cache 得不偿失,因为 Cache 的价格是比硬盘要贵很多的。

3 Cache 类型

Cache 可以分为本地缓存和分布式缓存,本地缓存基于应用容器内的 mem 进行缓存,读取速率比使用分布式缓存要高很多(没有网络开销);但是分布式缓存耦合性更低,多个应用实例可以共享缓存;应用容器出现问题下线之后不受影响,可以通过新增一个容器解决问题(做到无状态化服务)。

4 Cache 策略

参考:Caching Strategies and How to Choose the Right One?

Cache 策略指的是我们读写 Cache 和 DB 的方式,不同的读写方式和顺序构成了多种多样的 Cache 策略,这一节会记述 5 种常用的 Cache 策略。

Cache 策略的使用取决于不同的应用场景,在学习和了解了这几种常用的 Cache 策略之后还要能够做到根据实际的应用场景采用合适的 Cache 策略,不同的场景采用不同的策略会有截然不同的效果。

4.1 Cache Aside

cache-aside

Cache Aside 策略是使用最广的缓存策略,它需要应用来操作 Cache 和 DB。

Cache Aside 策略指的是在读取数据时:

  1. 访问 Cache,如果有数据则直接返回(Cache Hit)。
  2. 如果没有缓存数据(Cache Miss),则从 DB 进行读取,读取之后将数据写回到 Cache 中,之后返回。

Cache Aside 策略在 Cache 不可用时,服务仍然可以基于 DB 正常工作,当然如果请求量太大把 DB 冲垮了,那服务仍然是不可用的。

Cache Aside 策略的缺点在于每一个缓存数据在第一次访问时都需要进入 DB 进行查找,所以在 Cache 上线之初可能会导致系统体验不佳的问题。

Cache Aside 策略适用于读多写少的场景,比如用户资料。

4.2 Read/Write Through

read-throughwrite-through

Read/Write Through 策略基于缓存进行数据的存取,数据库的操作由缓存层实现,所以用户无需关心 Cache 和 DB 的数据一致性。

这里的 Read Through 策略和 Cache Aside 策略的读操作十分类似,区别在于这里的读写操作都通过 Cache 中间层实现,用户不关心 DB。

缺点跟 Cache Aside 相同,Cache 上线之初缓存命中率非常低,如果需要获得较好的用户体验,需要开发者人为进行预热,将数据加载到缓存中。

4.3 Write Around

Write Around 策略,是指在写入数据时不直接写入 Cache,而是将数据写入到 DB 之后删除 Cache 中的已缓存数据。

在写入数据时:

  1. 将数据写入 DB。
  2. 将 Cache 中的数据删除(或者也可以什么都不做,让数据自动过期)。

这么做的好处是没有并发写入 Cache 导致的数据不一致问题,缓存数据的删除操作是幂等的。

Write Around 策略经常和 Cache Aside 策略搭配使用,它们都适合于读多写少的场景,数据更新后再次访问数据时总是需要访问 DB,这会带来额外的开销,尤其是当写入次数很多的时候。

4.4 Write Back

回写

Write Back 策略在更新数据时会先更新 Cache,然后在之后的一段时间内更新 DB,这会导致某些时间段内 DB 和 Cache 的数据不一致,但是可以很好的减轻 DB 的写入负载。

Write Back 策略有两种实现方式,一种是通过定时任务定时同步 Cache 中的数据到 DB 中;另一种是通过 MQ + Event Handler 异步地将数据写入 DB 中。

这种策略可以很好的削减高峰期的访问峰值,减轻 DB 压力(甚至允许 DB 短时间内不可用),但是也存在数据不一致性以及数据丢失(Cache 不可用)的风险。

这种策略非常适合写入次数很多的场景。

在实际应用中,要根据不同的场景选择合适的 Cache 策略。

  1. 直播间点赞数据,特点是高并发,允许部分丢失,点赞数实时展示;属于是读写均为高并发的场景。这种场景适合完全使用 Cache 作为临时数据存储,在后台定时将数据同步到 DB 持久化。所以合适的策略为 Write Back ,读策略依然考虑 Cache Aside,因为这里的 Cache 数据不过期,所以缓存命中率可以认为是有保证的。
  2. 类似于抖音的视频流页面,特点是读是高并发,写是低频率的,所以适合的策略是 Cache Aside + Write Around
  3. 购物 APP 首页的部分运营可配置内容,特点是读是超高并发,写是极少量的写,所以适合的策略是 Cache Aside + Write Around 的变种,区别就是不设置数据过期时间以及在写入 DB 之后需要将数据写入 Cache,因为这里的 Cache 数据写入频率极低,所以不用担心写入并发导致的数据不一致问题,可以使用 写 DB + 写 Cache 的策略。
  4. 直播间的评论数据,特点类似第一点中的点赞数据,所以采用的策略也十分类似,但是异步写入 DB 采用的是 MQ 来削峰,而不是定时任务。其实我认为这里完全没有必要使用到 DB 和 Cache?因为数据并不需要持久化,只需要进行适当的削峰,所以应该采用 MQ + 消费数据的消费者(从 MQ 中取出来数据发送给观众)就好了。

5 Cache 过期

Cache 的存储空间是有限的,当存储空间不够的时候,Cache 可能会针对数据进行清理。清理的策略大致有以下三种:

  1. FIFO(First In First Out):先进先出策略,最早存储的数据就最早清理,适合数据实时性要求较高的场景,优先淘汰老旧数据。
  2. LFU(Less Frequently Used):最少命中的缓存优先淘汰。
  3. LRU(Least Recently Used):最近最近最少使用策略,按照数据最后一次被命中的时间戳清理数据,适合于热点数据缓存的场景。

6 Cache 问题

6.1 缓存雪崩

缓存雪崩指的是大量 key 同时过期(或者就是 Cache 不可用)导致短时间内大量请求透过 Cache 直接访问 DB,从而导致 DB 不可用。

解决的办法是:

  1. 在设置 key 过期时间时不要同时对大量 key 设置相同过期时间,设置的过期时间可以在一定范围内零散分布,这样短时间内同时过期的 key 就没有那么多了。
  2. 缓存系统要做好自动切换工作,一台下线了之后另一台要快速上线。
  3. 如果 key 过期了,需要从 DB 获取数据,这时候可以进行加锁,只有首先发现 key 失效的请求可以访问 DB,其他请求都需要进行等待;这样做可能会导致大量请求阻塞从而加重系统负载,这时候的解决办法是拿旧版本的数据返回,这个数据应该是存储在其他地方的。

6.2 缓存击穿

缓存击穿指的是热点 key 过期导致的短时间内大量请求涌入 DB 导致 DB 不可用。

解决办法是:

  1. 给访问 DB 的操作进行加锁,降低 DB 的并发访问量。
  2. 设置 key 不过期。
  3. 针对热点 key 设置多级缓存(甚至从客户端开始就进行缓存)。

6.3 缓存穿透

缓存穿透指的是访问一些不存在的数据时,请求每次都会透过 Cache 访问 DB,从而给 DB 造成较大的压力。

解决办法是:

  1. 针对请求参数进行检查,对于不合理的请求参数(比如负数 id)直接拒绝请求。
  2. 缓存空对象,请求拿到缓存数据之后就知道当前数据不存在,可以直接返回而不用访问 DB。这种做法不适合大量空数据的场景,会浪费 Cache 资源。
  3. 使用 Bloom Filter(布隆过滤器),将已知存在的数据 id 进行缓存,减轻 DB 压力。参考 go 的 bloom filter 实现。

针对三种场景,在大量缓存数据失效导致增加 DB 压力的情况下,都有一种解决方案叫做针对 DB 查询进行加锁。

这里加锁的方式也分为两种,一种是进程内的线程(或者协程)级别锁,另一种是分布式锁。

前者锁的实现比较简单,但是只在进程维度生效。如果部署了多个服务实例,那个每个实例都可能有一个协程访问到 DB。

后者是整个应用维度的加锁,整个应用不管维护了多少个实例,最终只会有一个请求去访问 DB,效果较好。

本地锁的好处在于简单易实现,分布式锁的好处在于锁是全局锁。但是分布式锁会引入额外的 IO 开销,而且投入可能会和收益不匹配,即使一个应用部署了一百个实例,针对一条缓存数据同时有一百个请求访问 DB 进行查询,这压力也不会很大。除非有非常多的缓存数据需要访问 DB 进行查询,这种情况就需要用到分布式锁了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant