当前位置: 首页 > 科技观察

高性能服务器设计中的缓存系统一致性

时间:2023-03-22 16:29:12 科技观察

缓存系统交互缓存系统设计是后端开发人员必备的技能,是实现高并发的重要武器。对于读多写少的场景,我们通常采用内存数据库作为缓存,关系数据库作为主存,形成两层相互依存的存储体系。共识:我们将使用Redis和MySQL作为缓存和主要存储实体来开始今天的话题。缓存系统需要处理读取和更新的场景:只要读取前MySQL和Redis中的数据一致,以后只要没有更新操作就没有问题,内存读取速度可以提高并发能力。这也是我们缓存系统设计的初衷。简单阅读的案例不多。即使在读多写少的业务模型中,依然会有更新操作。由于MySQL和Redis的操作不是天然的原子操作,我们需要特殊处理。读流程展示:读流程:读请求先从缓存中获取数据,获取后返回,完成交互;如果缓存中没有数据,则从主存中取出数据,更新数据并写回缓存中。为后续的读请求铺平道路。更新过程中数据不一致的原因主要有两个:内部原因:Redis和MySQL的更新不是天然的原子操作,是非事务性的组合。外部原因:实际读写请求并发且乱序,可预测性差,完全不可控。数据不一致的感知下面通过一个实际的例子来进一步理解缓存系统中数据不一致的问题。我们平时上下班挤地铁的时候听听网易云。比如我喜欢听民歌,所以会去关注一些官方的民歌榜单,如图:这是一个很典型的读多写少的场景,因为songs只是配置了网易云运维学员。作为用户,我们不能修改播放列表的内容。所以如果我是网易云的后端同学,我肯定会将歌单信息存储在Redis中,缓存起来,以提高性能。大概可以是这样的:如果因为版权问题操作删除了一首歌曲,此时会进行更新。安装了MySQL,但是如果Redis中的数据没有及时更新,部分用户会在播放列表中看到已经删除的歌曲,点击可能无法播放。画外音:这是缓存和主存数据不一致。当然,具体网易云是如何实现的,我们不得而知。上面的场景纯粹是作者编造的不一致问题的一个直观例子。理性看待不一致性问题数据一致性可以说是分布式系统中不可避免的问题。数据一致性可以分为:强一致性:始终保持一致。最终一致性:暂时的不一致是允许的,但最终是一致的。要实现缓存和主存之间的强一致性,需要复杂的分布式一致性协议。最好不要使用缓存。毕竟缓存的优势在于读多写少的场景。画外音:缓存不是万能的。可能不适合写多读少的场景。奉劝大家不要只考虑缓存。在大多数工程场景下,最终一致性就足够了,于是我们将问题转化为:如何在保证数据最终一致性的前提下,将数据不一致的影响降低到可接受的范围内。问题是更新还是删除当MySQL更新时,我们如何处理Redis中的旧数据?江湖有两种常见的做法,一起来看看:删除操作:直接删除key,是否再次加载由后面的读请求决定,这次只负责删除,kill掉不就行了不要埋葬它。更新操作:直接更新变化的key,相当于为后续请求做加载操作,杀戮和埋葬。可以明确的是,删除操作可以直接进行,但是更新操作可能涉及到更多的处理步骤,即更新比删除更复杂。还有一点就是我们要尽量保证Redis里面的数据是热数据。每次update都会让数据驻留在redis中。也许这是不必要的,因为这些可能是冷数据。至于加载哪些数据,还是留待以后请求比较合适。综上所述,我们更倾向于使用删除操作作为一般的选择,所以文章的后续都是以删除缓存的策略为主。如何解决不一致的问题Redis和MySQL数据不一致的根本原因是:业务的update/write操作。先操作Redis还是先操作MySQL是个问题,不同操作时机的影响也不同。尺短寸长。归根结底,这是一种取舍。无论哪种组合对业务的负面影响最小,都首选哪种解决方案。缓存系统中数据不一致的问题是一个经典的问题,那么解决这个问题的方法肯定有很多种,那么下面我们就带着分析和思考来看看每一种方案的优劣。思路一:设置缓存过期时间在往Redis写入一条数据时,同时设置过期时间x秒,不同业务过期时间不一样。到了过期时间,Redis会删除这条数据,后续对Redis的读请求会显示CacheMiss,然后读MySQL,再把数据写入Redis。如果发生更新操作时只操作MySQL,那么Redis中的数据更新只靠过期时间来保证底线。换句话说:如果某个key的数据当前在缓存中,更新数据时,只写mysql,不写redis。在数据更新后到缓存失效前的这段时间,读取的数据不一致。画外音:这个解决方案是最简单的。如果业务不关心短期的不一致,设置过期时间的方案就够了,没必要弄得太复杂。思路二:先清除缓存,再更新主存。为了防止其他线程读取缓存中的旧数据,简单的将其剔除,然后更新数据到主存。后续请求再次读取的时候会触发CacheMiss,从而读取MySQL然后更新新的数据到Redis。T1时刻:Redis和MySQL的age值相同,都是18,相同;T2时刻:有更新请求设置age=20,此时Redis中没有age数据;Redis淘汰后,执行MySQL数据更新age=20;这个方案听起来不错,但是读写请求都是并发的,顺序完全不可预测。稍后发送的请求先被处理也很常见。因此造成了一个明显的漏洞:Redis数据淘汰完成后,MySQL更新完成前,如果在此期间有新的读请求过来,发现CacheMiss,则将旧数据重写到Redis,又造成了Inconsistency,后续读取的都是旧数据而没有意识到。画外音:其实这个方案不能说完全没用,但至少不完美。您可以考虑其他解决方案。思路三:先更新主存&后清除缓存先UpdateMySQL,成功后再清除缓存,后续有读请求时触发CacheMiss,再将新数据写回Redis。这种模式下,在更新MySQL和淘汰Redis之间的这段时间,仍然会请求读取Redis的旧数据,但是在MySQL更新完成后,可以立即恢复一致性,影响比较小。但是,如果T0时刻读取的数据不在缓存中,则触发CacheMiss后会发生回写。如果回写动作在T4时刻完成,那么旧的数据还是会被写入,如图:这种情况确实存在问题,但真的是巧合:事件A:更新MySQL前发生读请求,且缓存中没有数据,发生缓存未命中事件B:回写Redis的操作在T3完成,之前的T2清空缓存出现问题的概率为P(A)*P(二).从实际的角度来看,发生这种综合事件的概率是很低的,因为写操作比读操作慢很多。也就是说,在实际场景中,上图中更新MySQL&清除缓存的操作耗时较长,可以清除写回Redis的旧数据。画外音:先更新MySQL再淘汰Redis的方案,虽然有小概率不一致,但总的来说在工程上是可以用的。比如非要说MySQL写完就挂了,Redis是不会被淘汰的。这种情况只能说是真的有问题。思路四:延迟双删策略上面提到的思路二和思路三都只有一次Redis的删除操作。这里延迟双删本质上是思路2和3的结合:说实话,我个人觉得这个方案感觉有点像堆操作,设置延迟的目的是为了避免小概率的问题第三个想法。确定延迟设置多长时间并不容易。其次,延迟降低了并发性能。同时,预删除缓存操作的作用不大。这个方案揭示了一种思路:如果删除的次数多一些,一致性可能更有保证,确实如此。画外音:这个方案不是说不可以,但其实有点麻烦,在复杂高并发场景下会影响性能。它也可以用于一般场景。思路五:异步更新缓存由于直接操作MySQL和Redis存在一些问题,是否可以引入中间层来解决问题?MySQL的更新操作完成后,并不直接操作Redis,而是将操作命令(消息)抛给一个中间层,然后Redis自己消费更新后??的数据。这是一个解耦的异步解决方案。仅仅为了更新缓存而引入中间件确实很复杂,但是MySQL提供了binlog同步机制。此时Redis作为slave进行主从同步更新数据,成本可以接受。画外音:引入中间层的思路真是万能的!综上所述,本文主要介绍以下重点内容:缓存系统适用场景:多读少写。缓存系统读写的基本交互流程,读很简单,写有点复杂。缓存系统写入时的不一致性有内外两方面的因素:外部读写的并发无序和内部操作的非原子性。使用缓存系统需要接受最终一致性的前提,否则不推荐使用缓存。解决缓存数据不一致的思路很多,或多或少都有不足之处。使用哪一种取决于实际业务场景,没有一种方案是放之四海而皆准的。