软件架构场景之—— 读缓存:如何减少数据库读操作压力?

业务场景

负责的一个电商系统中,存放了 50000 多条商品数据,每次用户浏览商品详情页时,需要先从数据库中读取数据,再进行数据拼装和计算,耗费的时间有时长达 1 秒

这就导致用户每次点击商品详情页时,页面打开速度慢,此时该如何减少数据库读操作压力呢?

此时我们采取的方案也很通用,把所有的商品数据缓存起来就行

关于缓存问题,最简单的实现方法是使用本地缓存。在 Google Guava 中有一个 cache 内存缓存模块,它把所有商品的 ID 与商品详情信息一对一缓存至 JVM 内存中,用户获取商品详情数据时,系统会根据商品 ID 直接从缓存中读取数据,大大提升了用户页面访问速度

不过,通过简单换算后,我们发现这个方法明显不合理,先来举个例子

1 条商品数据中,往往包含品牌、分类、参数、规格、服务、描述等字段,光存储这些商品数据就得占用 500K 左右内存,再将这些数据缓存到本地的话,差不多还需占用 500 K*50000=25G 内存。此时,假设商品服务有 30 个服务器节点,光缓存商品数据就需要额外准备 750G 内存空间,这种方法显然不可取

为此,想到了另外一个解决办法——分布式缓存,先将所有的缓存数据集中存储在同一个地方,而并非保存到各个服务器节点中,然后所有的服务器节点从这个地方读取数据

缓存中间件技术选型

市面上比较流行的缓存中间件(Memcached、MongoDB、Redis)进行简单对比,这样你就不必再深入进行选型调研了

  Memcached MongoDB Redis
数据结构 简单 Key - Value 非常全面,文档性数据库 String、List、Set、Hash、Bitmap、Sorted set 等
持久化 不支持 支持 支持
集群 客户端自己控制 支持 支持
性能 中等

以上三种技术中,目前市面上通用的缓存中间件技术是 Redis,使用 MongoDB 的公司最少,因为它只是一个数据库,由于它的读写速度与其他数据库相比较快,所以人们才把它当作类似缓存的存储

总结了下 Redis 之所以比 Memcached 流行的三种原因

(1)数据结构

举个例子,在使用 Memcached 保存 List 缓存对象的过程中,如果我们往 List 增加一条数据,首先需要读取整个 List ,再反序列化塞入数据,接着再序列化存储回 Memcached。而对于 Redis 而言,它仅仅是一个 Redis 请求,会直接帮我们塞入数据并存储,简单快捷

(2)持久化

对于 Memcached 来说,一旦系统宕机数据就会丢失。通过 Memcached 的官方文档得知,1.5.18 以后 Memcached 支持 restartable cache,其实现原理是重启时 CLI 先发信号给守护进程,然后守护进程将内存持久化至一个文件中,系统重启时再从那个文件恢复数据。不过,这个设计仅在正常重启情况下使用,意外情况还是不处理

(3)集群(这点尤为重要)

Memcached 的集群设计非常简单,客户端根据 Hash 值直接判断存取的 Memcached 节点。而 Redis 的集群因在高可用、主从、冗余、failover 等方面都有所考虑,所以集群设计相对复杂些,属于较常规的分布式高可用架构

因此,经过一番“慎重”的思考,最终决定使用 Redis 作为缓存的中间件。技术选型完,开始考虑缓存的一些具体问题,先从缓存何时存储数据入手

 

缓存何时存储数据

使用缓存的逻辑

  1. 先尝试从缓存中读取数据;
  2. 缓存中没有数据或者数据过期,再从数据库中读取数据保存到缓存中;
  3. 最终把缓存数据返回给调用方

这种逻辑唯一麻烦的地方:当用户发来大量的并发请求,且所有请求同时挤在上面第 2 步,此时如果这些请求全部从数据库读取数据,会直接挤爆数据库

挤爆可以分为三种情况,单独展开说明一下

1. 单一数据过期或者不存在,这种情况称为缓存击穿

此时解决方案:第一个线程如果发现 key 不存在,先给 key 加锁,再从数据库读取数据保存到缓存中,最后释放锁。如果其他线程正在读取同一个 key 值,它必须等到锁释放后才行

2. 数据大面积过期或者 Redis 宕机,这种情况称为缓存雪崩

此时,设置缓存的过期时间随机分布或永不过期即可

3. 一个恶意请求获取的 key 不在数据库中,这种情况称为缓存穿透

这种情况如果不作处理,恶意请求每次都会查询数据库,无疑给数据库增加了压力

这里分享 2 种解决办法

① 在业务逻辑上直接校验,在数据库不被访问的前提下过滤掉不存在的 key;

② 将恶意请求的 key 存放一个空值在缓存中,防止恶意请求骚扰数据库

关于缓存预热说明下:在深夜无人或访问量小的时候,我们可以考虑将预热的热数据保存到缓存中,这样流量大的时候,用户查询无须再从数据库读取数据,大大减少了数据读压力

如何更新缓存?

更新缓存的步骤特别简单,总共就两步:更新数据库和更新缓存。但就这么简单的两步,这里需要考虑好几个问题

  1. 先更新数据库还是先更新缓存?更新缓存时先删除还是直接更新?

  2. 假设第一步成功了,第二步失败了怎么办?

  3. 假设 2 个线程同时更新同一个数据,A 线程先完成第一步,B 线程先完成第二步,此时该怎么办?

其中,第一个问题就存在 4 种组合问题,先针对第 1 种组合问题给出对应的解决方案

组合一:先更新缓存,再更新数据库

对于这个组合,会遇到这种情况:假设第 2 步数据库更新失败了,要求回滚缓存的更新,这时该怎么办呢?

我们知道 Redis 不支持事务回滚,除非我们采用手工回滚的方式,先保存原有数据,然后再将缓存更新回原来的数据,这种解决方案就有点尴尬了

这里简单举个例子,比如

  1. 原来缓存中的值是 a,两个线程同时更新库存;

  2. 线程 A 将缓存中的值更新成 b,且保存了原来的值 a,然后更新数据库;

  3. 线程 B 将缓存中的值更新成 c,且保存了原来的值 b,然后更新数据库;

  4. 线程 A 更新数据库时失败了,它必须回滚了,那现在缓存中的值更新回什么呢?

在 A 线程更新缓存与数据库的整个过程中,先把缓存及数据库都锁上,确保别人不能更新,这种方法可不可行呢?当然是可行的,但是别人能不能读呢?假设 A 更新数据库失败回滚缓存时,线程 C 也来参一腿,它需要先读取缓存中的值,这时又返回什么值呢?

这就是典型的事务隔离级别场景。这里只是使用一下缓存而已,如果自己实现事务隔离级别,这个要求是有点高的

组合二:先删除缓存,再更新数据库

使用这种方案,就算我们更新数据库失败了也不需要回滚缓存。这种做法虽然巧妙规避了失败回滚的问题,却引来了 2 个更大的问题

  • 假设 A 线程先删除缓存,再更新数据库。在 A 线程完成更新数据库之前,后执行的 B 线程反而超前完成了操作,读取 key 发现没数据后,将数据库中的旧值存放到了缓存中。A 线程在 B 线程都完成后再更新数据库,这样就会出现缓存(旧值)与数据库的值(新值)不一致的问题

  • 为了解决一致性问题,我们可以让 A 线程给 key 加锁,因为写操作特别耗时,这种处理方法会导致大量的读请求卡在锁中

以上描述的是典型的高可用和一致性难以两全的问题,要再加上分区容错就是 CAP 了

组合三:先更新数据库,再更新缓存

对于组合三,同样需要考虑 2 个问题

  • 假设第一步成功,第二步失败了怎么办?因为缓存不是主流程,数据库才是,所以我们不会因为更新缓存失败而回滚第一步对数据库的更新。此时,我们一般采取的做法是做重试机制,但重试机制如果存在延时还是会出现数据库与缓存不一致的情况,非常不好处理

  • 假设 2 个线程同时更新同一个数据,A 线程先完成了第一步,B 线程先完成了第二步怎么办?接着推演整个过程:A 线程把值更新成 a,B 线程把值更新成 b,此时数据库中的最新值是 b,因为 A 线程先完成了第一步,后完成第二步,所以缓存中的最新值是 a,数据库与缓存的值还是不一致,还是不好处理

因此,不建议采用以上这个方案

组合四:先更新数据库,再删除缓存

针对组合四,看看到底会存在哪些问题

  • 假设第一步成功了,第二步失败了怎么办?这种情况的出现概率与上个组合相比明显少不少,因为删除比更新容易多了。此时虽然它不完美,但出现一致性的问题概率少

  • 假设 2 个线程同时更新同一个数据,A 线程先完成第一步,B 线程先完成第二步怎么办?接着推演整个过程:A 线程把值更新成 a,B 线程把值更新成 b,此时数据库中的最新值是 b,因为 A 线程先完成第一步,至于第二步谁先完成已经无所谓了,反正是直接删除缓存数据

看到这,发现组合四完美地解决了以上难题,所以我建议更新缓存时,先更新数据库再删除缓存

不过,这个解决方案也会引发另外 3个问题

  • 删除缓存数据后变相出现缓存击穿,此时该怎么办?此问题在前面已经给出了方案

  • 删除缓存失败如何重试?你可以参考之前的查询分离使用重试的方案解决

  • 删除缓存失败,重试成功前出现脏数据。这个需要与业务商量,毕竟这种情况还是少见,我们可以根据实际业务情况判断是否需要解决这个瑕疵。毕竟任何一个方案都不是完美的,但如果剩下 1% 的问题需要我们花好几倍的代价去解决,从技术上来讲得不偿失,这就要求架构师去说服业务方。毕竟作为一名好的架构师,需要具备极强的说服力

缓存的高可用设计

设计高可用方案时,需要考虑 5 个要点

  • 负载均衡: 是否可以通过加节点的方式水平分担读请求压力

  • 分片: 是否可以通过划分到不同的节点的方式水平分担写压力

  • 数据冗余: 一个节点的数据如果挂掉了,其他节点的数据是否可以直接备份挂掉节点的职责

  • Fail-over: 任何节点挂掉后,集群的职责是否可以重新分配,以此保障集群正常工作

  • 一致性保证: 在数据冗余、failover、分片机制的数据转移过程中,如果某个地方出幺蛾子,能否保证所有的节点数据或节点与数据库之间数据的一致性。(依靠 Redis 本身是不行的。)

 

缓存的监控

缓存上线以后,我们还需要定时查看缓存使用情况,再判断业务逻辑是否需要优化,也就是所谓的缓存的监控

在查看缓存使用情况时,一般我们会监控缓存命中率、内存利用率、慢日志、延迟、客户端连接数等数据。当然,随着问题的深入我们还增加了其他的指标

至于最终使用哪种监控工具,需要根据你们的实际情况而定。市面上也有很多开源的监控工具,比如 RedisLive、Redis-monitor 等

此方案的价值和不足

以上方案可以顺利解决读数据请求压垮数据库的问题,目前互联网架构也基本是采取这个方案。但这个方案还在一个不足,无法解决写数据请求量大的问题,也就是说写请求多时,数据库还是会扛不住

猜你喜欢

转载自blog.csdn.net/vincent_wen0766/article/details/112464414