You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

11 KiB

11 | MySQL如何应对高并发使用缓存保护MySQL

你好,我是李玥。

通过前面几节课的学习相信你对MySQL这类关系型数据库的能力已经有了定量的认知。

我们知道大部分面向公众用户的互联网系统它的并发请求数量是和在线用户数量正相关的而MySQL能承担的并发读写的量是有上限的当系统的在线用户超过几万到几十万这个量级的时候单台MySQL就很难应付了。

绝大多数互联网系统都使用MySQL加上Redis这对儿经典的组合来解决这个问题。Redis作为MySQL的前置缓存可以替MySQL挡住绝大部分查询请求很大程度上缓解了MySQL并发请求的压力。

Redis之所以能这么流行非常重要的一个原因是它的API非常简单几乎没有太多的学习成本。但是要想在生产系统中用好Redis和MySQL这对儿经典组合并不是一件很简单的事儿。我在《08 | 一个几乎每个系统必踩的坑儿:访问数据库超时》举的社交电商数据库超时故障的案例,其中一个重要的原因就是,对缓存使用不当引发了缓存穿透,最终导致数据库被大量查询请求打死。

今天这节课我们就来说一下在电商的交易类系统中如何正确地使用Redis这样的缓存系统以及如何正确应对使用缓存过程中遇到的一些常见的问题。

更新缓存的最佳方式

要正确地使用好任何一个数据库你都需要先了解它的能力和弱点扬长避短。Redis是一个使用内存保存数据的高性能KV数据库它的高性能主要来自于

  1. 简单的数据结构;
  2. 使用内存存储数据。

上节课我们讲到过数据库可以分为执行器和存储引擎两部分Redis的执行器这一层非常的薄所以Redis只能支持有限的几个API几乎没有聚合查询的能力也不支持SQL。它的存储引擎也非常简单直接在内存中用最简单的数据结构来保存数据你从它的API中的数据类型基本就可以猜出存储引擎中数据结构。

比如Redis的LIST在存储引擎的内存中的数据结构就是一个双向链表。内存是一种易失性存储所以使用内存保存数据的Redis不能保证数据可靠存储。从设计上来说Redis牺牲了大部分功能牺牲了数据可靠性换取了高性能。但也正是这些特性使得Redis特别适合用来做MySQL的前置缓存。

虽然说Redis支持将数据持久化到磁盘中并且还支持主从复制但你需要知道Redis仍然是一个不可靠的存储它在设计上天然就不保证数据的可靠性所以一般我们都使用Redis做缓存很少使用它作为唯一的数据存储。

即使只是把Redis作为缓存来使用我们在设计Redis缓存的时候也必须要考虑Redis的这种“数据不可靠性”或者换句话说我们的程序在使用Redis的时候要能兼容Redis丢数据的情况做到即使Redis发生了丢数据的情况也不影响系统的数据准确性。

我们仍然用电商的订单系统来作为例子说明一下如何正确地使用Redis做缓存。在缓存MySQL的一张表的时候通常直接选用主键来作为Redis中的Key比如缓存订单表那就直接用订单表的主键订单号来作为Redis中的key。

如果说Redis的实例不是给订单表专用的还需要给订单的Key加一个统一的前缀比如“orders:888888”。Value用来保存序列化后的整条订单记录你可以选择可读性比较好的JSON作为序列化方式也可以选择性能更好并且更节省内存的二进制序列化方式都是可以的。

然后我们来说,缓存中的数据要怎么来更新的问题。我见过很多同学都是这么用缓存的:在查询订单数据的时候,先去缓存中查询,如果命中缓存那就直接返回订单数据。如果没有命中,那就去数据库中查询,得到查询结果之后把订单数据写入缓存,然后返回。在更新订单数据的时候,先去更新数据库中的订单表,如果更新成功,再去更新缓存中的数据。

这其实是一种经典的缓存更新策略: Read/Write Through。这样使用缓存的方式有没有问题?绝大多数情况下可能都没问题。但是,在并发的情况下,有一定的概率会出现“脏数据”问题,缓存中的数据可能会被错误地更新成了旧数据。

比如,对同一条订单记录,同时产生了一个读请求和一个写请求,这两个请求被分配到两个不同的线程并行执行,读线程尝试读缓存没命中,去数据库读到了订单数据,这时候可能另外一个读线程抢先更新了缓存,在处理写请求的线程中,先后更新了数据和缓存,然后,拿着订单旧数据的第一个读线程又把缓存更新成了旧数据。

这是一种情况,还有比如两个线程对同一个条订单数据并发写,也有可能造成缓存中的“脏数据”,具体流程类似于我在之前“如何保证订单数据准确无误?”这节课中讲到的ABA问题。你不要觉得发生这种情况的概率比较小出现“脏数据”的概率是和系统的数据量以及并发数量正相关的当系统的数据量足够大并且并发足够多的情况下这种脏数据几乎是必然会出现的。

我在“商品系统的存储该如何设计”这节课中在讲解如何缓存商品数据的时候曾经简单提到过缓存策略。其中提到的Cache Aside模式可以很好地解决这个问题在大多数情况下是使用缓存的最佳方式。

Cache Aside模式和上面的Read/Write Through模式非常像它们处理读请求的逻辑是完全一样的唯一的一个小差别就是Cache Aside模式在更新数据的时候并不去尝试更新缓存而是去删除缓存。

订单服务收到更新数据请求之后先更新数据库如果更新成功了再尝试去删除缓存中订单如果缓存中存在这条订单就删除它如果不存在就什么都不做然后返回更新成功。这条更新后的订单数据将在下次被访问的时候加载到缓存中。使用Cache Aside模式来更新缓存可以非常有效地避免并发读写导致的脏数据问题。

注意缓存穿透引起雪崩

如果我们的缓存命中率比较低,就会出现大量“缓存穿透”的情况。缓存穿透指的是,在读数据的时候,没有命中缓存,请求“穿透”了缓存,直接访问后端数据库的情况。

少量的缓存穿透是正常的,我们需要预防的是,短时间内大量的请求无法命中缓存,请求穿透到数据库,导致数据库繁忙,请求超时。大量的请求超时还会引发更多的重试请求,更多的重试请求让数据库更加繁忙,这样恶性循环导致系统雪崩。

当系统初始化的时候,比如说系统升级重启或者是缓存刚上线,这个时候缓存是空的,如果大量的请求直接打过来,很容易引发大量缓存穿透导致雪崩。为了避免这种情况,可以采用灰度发布的方式,先接入少量请求,再逐步增加系统的请求数量,直到全部请求都切换完成。

如果系统不能采用灰度发布的方式,那就需要在系统启动的时候对缓存进行预热。所谓的缓存预热就是在系统初始化阶段,接收外部请求之前,先把最经常访问的数据填充到缓存里面,这样大量请求打过来的时候,就不会出现大量的缓存穿透了。

还有一种常见的缓存穿透引起雪崩的情况是,当发生缓存穿透时,如果从数据库中读取数据的时间比较长,也容易引起数据库雪崩。

这种情况我在《08 | 一个几乎每个系统必踩的坑儿:访问数据库超时》这节课中也曾经提到过。比如说我们缓存的数据是一个复杂的数据库联查结果如果在数据库执行这个查询需要10秒钟那当缓存中这条数据过期之后最少10秒内缓存中都不会有数据。

如果这10秒内有大量的请求都需要读取这个缓存数据这些请求都会穿透缓存打到数据库上这样很容易导致数据库繁忙当请求量比较大的时候就会引起雪崩。

所以如果说构建缓存数据需要的查询时间太长或者并发量特别大的时候Cache Aside或者是Read/Write Through这两种缓存模式都可能出现大量缓存穿透。

对于这种情况并没有一种方法能应对所有的场景你需要针对业务场景来选择合适解决方案。比如说可以牺牲缓存的时效性和利用率缓存所有的数据放弃Read Through策略所有的请求只读缓存不读数据库用后台线程来定时更新缓存数据。

小结

使用Redis作为MySQL的前置缓存可以非常有效地提升系统的并发上限降低请求响应时延。绝大多数情况下使用Cache Aside模式来更新缓存都是最佳的选择相比Read/Write Through模式更简单还能大幅降低脏数据的可能性。

使用Redis的时候还需要特别注意大量缓存穿透引起雪崩的问题在系统初始化阶段需要使用灰度发布或者其他方式来对缓存进行预热。如果说构建缓存数据需要的查询时间过长或者并发量特别大这两种情况下使用Cache Aside模式更新缓存会出现大量缓存穿透有可能会引发雪崩。

顺便说一句我们今天这节课中讲到的这些缓存策略都是非常经典的理论早在互联网大规模应用之前这些缓存策略就已经非常成熟了在操作系统中CPU Cache的缓存、磁盘文件的内存缓存它们也都应用了我们今天讲到的这些策略。

所以无论技术发展的多快,计算机的很多基础的理论的知识都是相通的,你绞尽脑汁想出的解决工程问题的方法,很可能早都写在几十年前出版的书里。学习算法、数据结构、设计模式等等这些基础的知识,并不只是为了应付面试。

思考题

课后请你想一下具体什么情况下使用Cache Aside模式更新缓存会产生脏数据欢迎你在评论区留言通过一个例子来说明情况。

感谢阅读,如果你觉得今天的内容对你有帮助,也欢迎把它分享给你的朋友。