关于redis:挑战大型系统的缓存设计应对一致性问题

39次阅读

共计 3511 个字符,预计需要花费 9 分钟才能阅读完成。

在实在的业务场景中,咱们业务的数据——例如订单、会员、领取等——都是长久化到数据库中的,因为数据库能有很好的事务保障、长久化保障。然而,正因为数据库要可能满足这么多优良的性能个性,使得数据库在设计上通常难以兼顾到性能,因而往往不能满足大型流量下的性能要求,像是 MySQL 数据库只能承当“千”这个级别的 QPS,否则很可能会不稳固,进而导致整个零碎的故障。

然而主观上,咱们的业务规模很可能要求着更高的 QPS,有些业务的规模自身就十分大,也有些业务会遇到一些流量顶峰,比方电商会遇到大促的状况。

而这时候大部分的流量实际上都是读申请,而且大部分数据也是没有那么多变动的,如热门商品信息、微博的内容等常见数据就是如此。此时,缓存就是咱们应答此类场景的利器。

缓存的意义

所谓缓存,实际上就是用空间换工夫,精确地说是用更高速的空间来换工夫,从而整体上晋升读的性能。

何为更高速的空间呢?

1、更快的存储介质。通常状况下,如果说数据库的速度慢,就得用更快的存储介质去代替它,目前最常见的就是 Redis。Redis 单实例的读 QPS 能够高达 10w/s,90% 的场景下只须要正确应用 Redis 就能应答。
2、就近应用本地内存。就像 CPU 也有高速缓存一样,缓存也能够分为一级缓存、二级缓存。即使 Redis 自身性能曾经足够高了,但拜访一次 Redis 毕竟也须要一次网络 IO,而应用本地内存无疑有更快的速度。不过单机的内存是非常无限的,所以这种一级缓存只能存储十分大量的数据,通常是最热点的那些 key 对应的数据。这就相当于额定耗费贵重的服务内存去换取高速的读取性能。

引入缓存后的一致性挑战

用空间换工夫,意味着数据同时存在于多个空间。最常见的场景就是数据同时存在于 Redis 与 MySQL 上(为了问题的普适性,前面举例中若没有特地阐明,缓存均指 Redis 缓存)。

实际上,最权威最全的数据还是在 MySQL 里的,只有 Redis 数据没有失去及时的更新而导致最新数据没有同步到 Redis 中,就呈现了数据不统一。

大部分状况下,只有应用了缓存,就必然会有不统一的状况呈现,只是说这个不统一的工夫窗口是否能做到足够的小。有些不合理的设计可能会导致数据继续不统一,这是咱们须要改善设计去防止的。

缓存不一致性无奈主观地齐全毁灭

为什么咱们简直没方法做到缓存和数据库之间的强统一呢?

失常状况下,咱们须要在数据库更新完后,把对应的最新数据同步到缓存中,以便在读申请的时候,能读到新的数据而不是旧的数据(脏数据)。然而很惋惜,因为数据库和 Redis 之间是没有事务保障的,所以咱们无奈确保写入数据库胜利后,写入 Redis 也是肯定胜利的;即使 Redis 写入能胜利,在数据库写入胜利后到 Redis 写入胜利前的这段时间里,Redis 数据也必定是和 MySQL 不统一的。如下图:

所以说这个工夫窗口是没方法齐全毁灭的,除非咱们付出极大的代价,应用分布式事务等各种伎俩去维持强统一,然而这样会使得零碎的整体性能大幅度降落,甚至比不必缓存还慢,这样不就与咱们应用缓存的指标南辕北辙了吗?

不过尽管无奈做到强统一,然而咱们能做到的是缓存与数据库达到最终统一,而且不统一的工夫窗口咱们能做到尽可能短,依照教训来说,如果能将工夫优化到 1ms 之内,这个一致性问题带来的影响咱们就能够忽略不计。

更新缓存的伎俩

data = queryDataRedis(key);
if (data ==null) {data = queryDataMySQL(key); // 缓存查问不到,从 MySQL 做查问
     if (data!=null) {updateRedis(key, data);// 查问完数据后更新到 MySQL
     }
}

也就是说优先查问缓存,查问不到才查询数据库。如果这时候数据库查到数据了,就将缓存的数据进行更新。这样的逻辑是正确的,而一致性的问题个别不来源于此,而是呈现在解决写申请的时候。所以咱们简化成最简略的写申请的逻辑,此时你可能会面临多个抉择,到底是间接更新缓存,还是生效缓存?而无论是更新缓存还是生效缓存,都能够抉择在更新数据库之前,还是之后操作。

这样就演变出 4 个策略:
1、更新数据库后更新缓存
2、更新数据库前更新缓存
3、更新数据库后删除缓存
4、更新数据库前删除缓存。

经验总结

做个简略总结(能够查看原文,有具体的剖析逻辑),足以适应绝大部分的互联网开发场景的决策:
1、针对大部分读多写少场景,倡议抉择更新数据库后删除缓存的策略。

2、针对读写相当或者写多读少的场景,倡议抉择更新数据库后更新缓存的策略。

最终一致性如何保障?

缓存设置过期工夫

第一个办法便是咱们下面提到的,当咱们无奈确定 MySQL 更新实现后,缓存的更新 / 删除肯定能胜利,例如 Redis 挂了导致写入失败了,或者过后网络呈现故障,更常见的是服务过后刚好产生重启了,没有执行这一步的代码。

这些时候 MySQL 的数据就无奈刷到 Redis 了。为了防止这种不一致性永恒存在,应用缓存的时候,咱们必须要给缓存设置一个过期工夫,例如 1 分钟,这样即便呈现了更新 Redis 失败的极其场景,不统一的工夫窗口最多也只是 1 分钟。

这是咱们最终一致性的兜底计划,万一呈现任何状况的不统一问题,最初都能通过缓存生效后从新查询数据库,而后回写到缓存,来做到缓存与数据库的最终统一。

如何缩小缓存删除 / 更新的失败?

万一删除缓存这一步因为服务重启没有执行,或者 Redis 长期不可用导致删除缓存失败了,就会有一个较长的工夫(缓存的残余过期工夫)是数据不统一的。

那咱们有没有什么伎俩来缩小这种不统一的状况呈现呢?这时候借助一个牢靠的消息中间件就是一个不错的抉择。

因为消息中间件有 ATLEAST-ONCE 的机制,如下图所示。

咱们把删除 Redis 的申请以生产 MQ 音讯的伎俩去生效对应的 Key 值,如果 Redis 真的存在异样导致无奈删除胜利,咱们仍旧能够依附 MQ 的重试机制来让最终 Redis 对应的 Key 生效。

而你们或者会问,极其场景下,是否存在更新数据库后 MQ 音讯没发送胜利,或者没机会发送进来机器就重启的状况?

这个场景确实比拟麻烦,如果 MQ 应用的是 RocketMQ,咱们能够借助 RocketMQ 的事务音讯,来让删除缓存的音讯最终肯定发送进来。而如果你没有应用 RocketMQ,或者你应用的消息中间件并没有事务音讯的个性,则能够采取音讯表的形式让更新数据库和发送音讯一起胜利。事实上这个话题比拟大了,咱们不在这里开展。

如何解决简单的多缓存场景?

有些时候,实在的缓存场景并不是数据库中的一个记录对应一个 Key 这么简略,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能须要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。

咱们以一个数据库记录对应多个 Key 的场景来举例。

如果零碎设计上咱们缓存了一个粉丝的主页信息、主播打赏榜 TOP10 的粉丝、单日 TOP 100 的粉丝等多个信息。如果这个粉丝登记了,或者这个粉丝触发了打赏的行为,下面多个 Key 可能都须要更新。只是一个打赏的记录,你可能就要做:

updateMySQL();// 更新数据库一条记录
deleteRedisKey1();// 生效主页信息的缓存
updateRedisKey2();// 更新打赏榜 TOP10
deleteRedisKey3();// 更新单日打赏榜 TOP100

这就波及多个 Redis 的操作,每一步都可能失败,影响到前面的更新。甚至从零碎设计上,更新数据库可能是独自的一个服务,而这几个不同的 Key 的缓存保护却在不同的 3 个微服务中,这就大大增加了零碎的复杂度和进步了缓存操作失败的可能性。最可怕的是,操作更新记录的中央很大概率不只在一个业务逻辑中,而是散发在零碎各个零散的地位。针对这个场景,解决方案和上文提到的保障最终一致性的操作一样,就是把更新缓存的操作以 MQ 音讯的形式发送进来,由不同的零碎或者专门的一个零碎进行订阅,而做聚合的操作。如下图:

通过订阅 MySQL binlog 的形式解决缓存

下面讲到的 MQ 解决形式须要业务代码外面显式地发送 MQ 音讯。还有一种优雅的形式便是订阅 MySQL 的 binlog,监听数据的实在变动状况以解决相干的缓存。

例如刚刚提到的例子中,如果粉丝又触发打赏了,这时候咱们利用 binlog 表监听是能及时发现的,发现后就能集中处理了,而且无论是在什么零碎什么地位去更新数据,都能做到集中处理。

目前业界相似的产品有 Canal,具体的操作图如下:

到这里,针对大型零碎缓存设计如何保障最终一致性,咱们曾经从策略、场景、操作计划等角度进行了粗疏的讲述,这些是我依据多年开发教训进行总结的,心愿能对你起到帮忙。

正文完
 0