mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
81
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(一)| 经典的Redis学习资料有哪些?.md
Normal file
81
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(一)| 经典的Redis学习资料有哪些?.md
Normal file
@@ -0,0 +1,81 @@
|
||||
<audio id="audio" title="加餐(一)| 经典的Redis学习资料有哪些?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/58/a0/588ef87ce0f83d7da8e48d2da3715fa0.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
咱们课程的“基础篇”已经结束了。在这个模块,我们学习了Redis的系统架构、数据结构、线程模型、持久化、主从复制和切片集群这些核心知识点,相信你已经初步构建了自己的一套基础知识框架。
|
||||
|
||||
不过,如果想要持续提升自己的技术能力,还需要不断丰富自己的知识体系,那么,阅读就是一个很好的方式。所以,这节课,我就给你推荐几本优秀的书籍,以及一些拓展知识面的其他资料,希望能够帮助你全面掌握Redis。
|
||||
|
||||
## 经典书籍
|
||||
|
||||
在学习Redis时,最常见的需求有三个方面。
|
||||
|
||||
- 日常使用操作:比如常见命令和配置,集群搭建等;
|
||||
- 关键技术原理:比如我们介绍过的IO模型、AOF和RDB机制等;
|
||||
- 在实际使用时的经验教训,比如,Redis响应变慢了怎么办?Redis主从库数据不一致怎么办?等等。
|
||||
|
||||
接下来,我就根据这些需求,把参考资料分成工具类、原理类、实战类三种。我们先来看工具类参考资料。
|
||||
|
||||
### 工具书:《Redis使用手册》
|
||||
|
||||
一本好的工具书,可以帮助我们快速地了解或查询Redis的日常使用命令和操作方法。我要推荐的《Redis使用手册》,就是一本非常好用的工具书。
|
||||
|
||||
在这本书中,作者把Redis的内容分成了三大部分,分别是“数据结构与应用”“附加功能”和“多机功能”。其中,我认为最有用的就是“数据结构与应用”的内容,因为它提供了丰富的操作命令介绍,不仅涵盖了Redis的5大基本数据类型的主要操作命令,还介绍了4种扩展数据类型的命令操作,包括位图、地址坐标、HyperLogLog和流。只要这本书在手边,我们就能很轻松地了解和正确使用Redis的大部分操作命令了。
|
||||
|
||||
不过,如果你想要了解最全、最新的Redis命令操作,我建议你把Redis的命令参考网站收录到你的浏览器书签中,随用随查。目前,Redis官方提供的所有命令操作参考肯定是最全、最新的,建议你优先使用这个[官方网站](https://redis.io/commands/)。在这个网页上查找命令操作非常方便,我们既可以通过命令操作的名称直接查找,也可以根据Redis的功能,分类查找对应功能下的操作,例如和集群相关的操作,和发布订阅相关的操作。考虑到有些同学可能想看中文版,我再给你提供一个[翻译版的命令参考](http://redisdoc.com/)。
|
||||
|
||||
除了提供Redis的命令操作介绍外,《Redis使用手册》还提供了“附加功能”部分,介绍了Redis数据库的管理操作和过期key的操作,这对我们进行Redis数据库运维(例如迁移数据、清空数据库、淘汰数据等)提供了操作上的指导。
|
||||
|
||||
有了工具手册,我们就能很轻松地掌握不同命令操作的输入参数、返回结果和复杂度了。接下来,就是进一步了解各种机制背后的原理了,我再跟你分享一本原理书。
|
||||
|
||||
### 原理书:《Redis设计与实现》
|
||||
|
||||
虽然《Redis设计与实现》和《Redis使用手册》是同一个作者写的,但是它们的侧重点不一样,这本书更加关注Redis关键机制的实现原理。
|
||||
|
||||
介绍Redis原理的资料有很多,但我认为,这本书讲解得非常透彻,尤其是在Redis底层数据结构、RDB和AOF持久化机制,以及哨兵机制和切片集群的介绍上,非常容易理解,我建议你重点学习下这些部分的内容。
|
||||
|
||||
除了文字讲解,这本书还针对一些难点问题,例如数据结构的组成、哨兵实例间的交互过程、切片集群实例的交互过程等,都使用了非常清晰的插图来表示,可以最大程度地降低学习难度。
|
||||
|
||||
其实,这本书也是我自己读的第一本Redis参考书,可以说,是它把我领进了Redis原理的大门。当时在学习时,正是因为有了这些插图的帮助,我才能快速地搞懂核心原理。直到今天,我都还记得这本书中的一些插图,真是受益匪浅。
|
||||
|
||||
虽然这本书的出版日期比较早(它针对的是Redis 3.0),但是里面讲的很多原理现在依然是适用的,它可以帮助你在从入门Redis到精通的道路上,迈进一大步。
|
||||
|
||||
### 实战书:《Redis开发与运维》
|
||||
|
||||
在实战方面,《Redis开发与运维》是一本不错的参考书。
|
||||
|
||||
首先,它介绍了Redis的Java和Python客户端,以及Redis用于缓存设计的关键技术和注意事项,这些内容在其他参考书中不太常见,你可以重点学习下。
|
||||
|
||||
其次,它围绕客户端、持久化、主从复制、哨兵、切片集群等几个方面,着重介绍了在日常的开发运维过程中遇到的问题和“坑”,都是经验之谈,可以帮助你提前做规避。
|
||||
|
||||
另外,这本书还针对Redis阻塞、优化内存使用、处理bigkey这几个经典问题,提供了解决方案,非常值得一读。在阅读的时候,你可以把目录里的问题整理一下,做成列表,这样,在遇到问题的时候,就可以对照着这个列表,快速地找出原因,并且利用书中的方案去解决问题了。
|
||||
|
||||
当然,要想真正提升实战能力,光读书是远远不够的,毕竟,“纸上得来终觉浅”。所以,我还想再给你分享两条建议。
|
||||
|
||||
第一个建议是阅读源码。读源码其实也是一种实战锻炼,可以帮助你从代码逻辑中彻底理解Redis系统的实际运行机制,**当遇到问题时,可以直接从代码层面进行定位、分析和解决问题**。阅读Redis源码,最直接的材料就是Redis在GitHub上的[源码库](https://github.com/redis/redis)。另外,有一个[网站](https://github.com/huangz1990/redis-3.0-annotated)提供了Redis 3.0源码的部分中文注释,你也可以参考一下。
|
||||
|
||||
另外,我们还需要亲自动手实践。在课程的留言中,我看到有同学说“没有服务器无法实践”,其实,Redis运行后本身就是一个进程,我们是可以直接使用自己的电脑进行部署的。只要不是性能测试,在功能测试或者场景模拟上,自己电脑的环境一般都是可以胜任的。比如说,要想部署主从集群或者切片集群,模拟主库故障,我们完全可以在自己电脑上起多个Redis实例来完成,只要保证它们的端口号不同,就可以了。
|
||||
|
||||
好了,关于Redis本身的书籍的推荐,就先告一段落了,接下来,我想再给你分享一些扩展内容。
|
||||
|
||||
## 扩展阅读方向
|
||||
|
||||
通过前面几节课的学习,我相信你一定已经发现了,Redis的很多关键功能,其实和操作系统底层的实现机制是相关的,比如说,非阻塞的网络框架、RDB生成和AOF重写时涉及到的fork和写时复制机制,等等。另外,Redis主从集群中的哨兵机制,以及切片集群的数据分布还涉及到一些分布式系统的内容。
|
||||
|
||||
我用一张图片,展示一下Redis的关键机制和操作系统、分布式系统的对应知识点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/2c/a0f558fbf9105817744ee2c44230c62c.jpg" alt="">
|
||||
|
||||
AOF日志的刷盘时机和操作系统的fsync机制、高速页缓存的刷回有关,而网络框架跟epoll有关,RDB生成和AOF重写与fork、写时复制有关(我在前面第3、4、5讲上讲过它们的关联)。
|
||||
|
||||
此外,我在[第8讲](https://time.geekbang.org/column/article/275337)介绍的哨兵选主过程,其实是分布式系统中的经典的Raft协议的执行过程,如果你比较了解Raft协议,就能很轻松地掌握哨兵选主的运行机制了。在[第9讲](https://time.geekbang.org/column/article/276545),我们学习了实现切片集群的Redis Cluster方案,其实,业界还有一种实现方案,就是ShardedJedis,而它就用到了分布式系统中经典的一致性哈希机制。
|
||||
|
||||
所以,如果说你希望自己的实战能力能够更强,我建议你**读一读操作系统和分布式系统方面的经典教材,**比如《操作系统导论》。尤其是这本书里对进程、线程的定义,对进程API、线程API以及对文件系统fsync操作、缓存和缓冲的介绍,都是和Redis直接相关的;再比如,《大规模分布式存储系统:原理解析与架构实战》中的分布式系统章节,可以让你掌握Redis主从集群、切片集群涉及到的设计规范。了解下操作系统和分布式系统的基础知识,既能帮你厘清容易混淆的概念(例如Redis主线程、子进程),也可以帮助你将一些通用的设计方法(例如一致性哈希)应用到日常的实践中,做到融会贯通,举一反三。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我给你推荐了三本参考书,分别对应了Redis的命令操作使用、关键机制的实现原理,以及实战经验,还介绍了Redis操作命令快速查询的两个网站,这可是我们日常使用Redis的必备工具,可以提升你使用操作Redis的效率。另外,对于Redis关键机制涉及到的扩展知识点,我从操作系统和分布式系统两个方面进行了补充。
|
||||
|
||||
Redis的源码阅读是成为Redis专家的必经之路,你可以阅读一下Redis在GitHub上的源码库,如果觉得有难度,也可以从带有中文注释的源码阅读网站入手。
|
||||
|
||||
最后,我也想请你聊一聊,你的Redis学习资料都有哪些呢?欢迎在留言区分享一下,我们一起进步。另外,如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。
|
||||
130
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(七) | 从微博的Redis实践中,我们可以学到哪些经验?.md
Normal file
130
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(七) | 从微博的Redis实践中,我们可以学到哪些经验?.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="加餐(七) | 从微博的Redis实践中,我们可以学到哪些经验?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/39/0f110c648cf9081257b821781cab4339.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们知道,微博内部的业务场景中广泛使用了Redis,积累了大量的应用和优化经验。微博有位专家曾有过一个[分享](https://mp.weixin.qq.com/s?__biz=MzI4NTA1MDEwNg==&mid=2650782429&idx=1&sn=7f2df520a7295a002c4a59f6aea9e7f3&chksm=f3f90f48c48e865e478d936d76c5303663c98da506f221ede85f0f9250e5f897f24896147cfb&scene=27#wechat_redirect),介绍了Redis在微博的优化之路,其中有很多的优秀经验。
|
||||
|
||||
俗话说“他山之石,可以攻玉”,学习掌握这些经验,可以帮助我们在自己的业务场景中更好地应用Redis。今天这节课,我就结合微博技术专家的分享,以及我和他们内部专家的交流,和你聊聊微博对Redis的优化以及我总结的经验。
|
||||
|
||||
首先,我们来看下微博业务场景对Redis的需求。这些业务需求也就是微博优化和改进Redis的出发点。
|
||||
|
||||
微博的业务有很多,例如让红包飞活动,粉丝数、用户数、阅读数统计,信息流聚合,音乐榜单等,同时,这些业务面临的用户体量非常大,业务使用Redis存取的数据量经常会达到TB级别。
|
||||
|
||||
作为直接面向终端用户的应用,微博用户的业务体验至关重要,这些都需要技术的支持。我们来总结下微博对Redis的技术需求:
|
||||
|
||||
- 能够提供高性能、高并发的读写访问,保证读写延迟低;
|
||||
- 能够支持大容量存储;
|
||||
- 可以灵活扩展,对于不同业务能进行快速扩容。
|
||||
|
||||
为了满足这些需求,微博对Redis做了大量的改进优化,概括来说,既有对Redis本身数据结构、工作机制的改进,也基于Redis自行研发了新功能组件,包括支持大容量存储的RedRock和实现服务化的RedisService。
|
||||
|
||||
接下来,我们就来具体了解下微博对Redis自身做的一些改进。
|
||||
|
||||
## 微博对Redis的基本改进
|
||||
|
||||
根据微博技术专家的分享,我们可以发现,微博对Redis的基本改进可以分成两类:避免阻塞和节省内存。
|
||||
|
||||
首先,针对持久化需求,他们使用了全量RDB加增量AOF复制结合的机制,这就避免了数据可靠性或性能降低的问题。当然,Redis在官方4.0版本之后,也增加了混合使用RDB和AOF的机制。
|
||||
|
||||
其次,在AOF日志写入刷盘时,用额外的BIO线程负责实际的刷盘工作,这可以避免AOF日志慢速刷盘阻塞主线程的问题。
|
||||
|
||||
再次,增加了aofnumber配置项。这个配置项可以用来设置AOF文件的数量,控制AOF写盘时的总文件量,避免了写入过多的AOF日志文件导致的磁盘写满问题。
|
||||
|
||||
最后,在主从库复制机制上,使用独立的复制线程进行主从库同步,避免对主线程的阻塞影响。
|
||||
|
||||
在节省内存方面,微博有一个典型的优化,就是定制化数据结构。
|
||||
|
||||
在使用Redis缓存用户的关注列表时,针对关注列表的存储,他们定制化设计了LongSet数据类型。这个数据类型是一个存储Long类型元素的集合,它的底层数据结构是一个Hash数组。在设计LongSet类型之前,微博是用Hash集合类型来保存用户关注列表,但是,Hash集合类型在保存大量数据时,内存空间消耗较大。
|
||||
|
||||
而且,当缓存的关注列表被从Redis中淘汰时,缓存实例需要从后台数据库中读取用户关注列表,再用HMSET写入Hash集合,在并发请求压力大的场景下,这个过程会降低缓存性能。跟Hash集合相比,LongSet类型底层使用Hash数组保存数据,既避免了Hash表较多的指针开销,节省内存空间,也可以实现快速存取。
|
||||
|
||||
从刚才介绍的改进工作,你可以看到,微博对Redis进行优化的出发点,和我们在前面课程中反复强调的Redis优化目标是一致的。我自己也总结了两个经验。
|
||||
|
||||
第一个经验是:高性能和省内存始终都是应用Redis要关注的重点,这和Redis在整个业务系统中的位置是密切相关的。
|
||||
|
||||
Redis通常是作为缓存在数据库层前端部署,就需要能够快速地返回结果。另外,Redis使用内存保存数据,一方面带来了访问速度快的优势,另一方面,也让我们在运维时需要特别关注内存优化。我在前面的课程里介绍了很多和性能优化、节省内存相关的内容(比如说第18~20讲),你可以重点回顾下,并且真正地在实践中应用起来。
|
||||
|
||||
第二个经验是,在实际应用中需要基于Redis做定制化工作或二次开发,来满足一些特殊场景的需求,就像微博定制化数据结构。不过,如果要进行定制化或二次开发,就需要了解和掌握Redis源码。所以,我建议你在掌握了Redis的基本原理和关键技术后,把阅读Redis源码作为下一个目标。这样一来,你既可以结合原理来加强对源码的理解,还可以在掌握源码后,开展新增功能或数据类型的开发工作。对于如何在Redis中新增数据类型,我在[第13讲](https://time.geekbang.org/column/article/281745)中向你介绍过,你可以再复习下。
|
||||
|
||||
除了这些改进工作,为了满足大容量存储需求,微博专家还在技术分享中提到,他们把RocksDB和硬盘结合使用,以扩大单实例的容量,我们来了解下。
|
||||
|
||||
## 微博如何应对大容量数据存储需求?
|
||||
|
||||
微博业务层要保存的数据经常会达到TB级别,这就需要扩大Redis实例的存储容量了。
|
||||
|
||||
针对这个需求,微博对数据区分冷热度,把热数据保留在Redis中,而把冷数据通过RocksDB写入底层的硬盘。
|
||||
|
||||
在微博的业务场景中,冷热数据是比较常见的。比如说,有些微博话题刚发生时,热度非常高,会有海量的用户访问这些话题,使用Redis服务用户请求就非常有必要。
|
||||
|
||||
但是,等到话题热度过了之后,访问人数就会急剧下降,这些数据就变为冷数据了。这个时候,冷数据就可以从Redis迁移到RocksDB,保存在硬盘中。这样一来,Redis实例的内存就可以节省下来保存热数据,同时,单个实例能保存的数据量就由整个硬盘的大小来决定了。
|
||||
|
||||
根据微博的技术分享,我画了一张他们使用RocksDB辅助Redis实现扩容的架构图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/b0/c0fdb8248a3362afd29d3efe8b6b21b0.jpg" alt="">
|
||||
|
||||
从图中可以看到,Redis是用异步线程在RocksDB中读写数据。
|
||||
|
||||
读写RocksDB的延迟毕竟比不上Redis的内存访问延迟,这样做也是为了避免读写冷数据时,阻塞Redis主线程。至于冷数据在SSD上的布局和管理,都交给RocksDB负责。RocksDB目前已经比较成熟和稳定了,可以胜任Redis冷数据管理这个工作。
|
||||
|
||||
关于微博使用RocksDB和SSD进行扩容的优化工作,我也总结了两条经验,想和你分享一下。
|
||||
|
||||
首先,**实现大容量的单实例在某些业务场景下还是有需求的。**虽然我们可以使用切片集群的多实例分散保存数据,但是这种方式也会带来集群运维的开销,涉及到分布式系统的管理和维护。而且,切片集群的规模会受限,如果能增加单个实例的存储容量,那么,即使在使用较小规模的集群时,集群也能保存更多的数据。
|
||||
|
||||
第二个经验是,如果想实现大容量的Redis实例,**借助于SSD和RocksDB来实现是一个不错的方案**。我们在[第28讲](https://time.geekbang.org/column/article/298205)中学习的360开源的Pika,还有微博的做法,都是非常好的参考。
|
||||
|
||||
RocksDB可以实现快速写入数据,同时使用内存缓存部分数据,也可以提供万级别的数据读取性能。而且,当前SSD的性能提升很快,单块SSD的盘级IOPS可以达到几十万级别。这些技术结合起来,Redis就能够在提供大容量数据存储的同时,保持一定的读写性能。当你有相同的需求时,也可以把基于SSD的RocksDB应用起来保存大容量数据。
|
||||
|
||||
## 面向多业务线,微博如何将Redis服务化?
|
||||
|
||||
微博的不同业务对Redis容量的需求不一样,而且可能会随着业务的变化出现扩容和缩容的需求。
|
||||
|
||||
为了能够灵活地支持这些业务需求,微博对Redis进行了服务化改造(RedisService)。所谓服务化,就是指,使用Redis集群来服务不同的业务场景需求,每一个业务拥有独立的资源,相互不干扰。
|
||||
|
||||
同时,所有的Redis实例形成一个资源池,资源池本身也能轻松地扩容。如果有新业务上线或是旧业务下线,就可以从资源池中申请资源,或者是把不用的资源归还到资源池中。
|
||||
|
||||
形成了Redis服务之后,不同业务线在使用Redis时就非常方便了。不用业务部门再去独立部署和运维,只要让业务应用客户端访问Redis服务集群就可以。即使业务应用的数据量增加了,也不用担心实例容量问题,服务集群本身可以自动在线扩容,来支撑业务的发展。
|
||||
|
||||
在Redis服务化的过程中,微博采用了类似Codis的方案,通过集群代理层来连接客户端和服务器端。从微博的公开技术资料中,可以看到,他们在代理层中实现了丰富的服务化功能支持。
|
||||
|
||||
- 客户端连接监听和端口自动增删。
|
||||
- Redis协议解析:确定需要路由的请求,如果是非法和不支持的请求,直接返回错误。
|
||||
- 请求路由:根据数据和后端实例间的映射规则,将请求路由到对应的后端实例进行处理,并将结果返回给客户端。
|
||||
- 指标采集监控:采集集群运行的状态,并发送到专门的可视化组件,由这些组件进行监控处理。
|
||||
|
||||
此外,在服务化集群中,还有一个配置中心,它用来管理整个集群的元数据。同时,实例会按照主从模式运行,保证数据的可靠性。不同业务的数据部署到不同的实例上,相互之间保持隔离。
|
||||
|
||||
按照我的理解,画了一张示意图,显示了微博Redis服务化集群的架构,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/ff/58dc7b26b8b0a1df4fd1faeee24618ff.jpg" alt="">
|
||||
|
||||
从Redis服务化的实践中,我们可以知道,当多个业务线有共同的Redis使用需求时,提供平台级服务是一种通用做法,也就是服务化。
|
||||
|
||||
当把一个通用功能做成平台服务时,我们需要重点考虑的问题,包括**平台平滑扩容、多租户支持和业务数据隔离、灵活的路由规则、丰富的监控功能**等。
|
||||
|
||||
如果要进行平台扩容,我们可以借助Codis或是Redis Cluster的方法来实现。多租户支持和业务隔离的需求是一致,我们需要通过资源隔离来实现这两个需求,也就是把不同租户或不同业务的数据分开部署,避免混用资源。对于路由规则和监控功能来说,微博目前的方案是不错的,也就是在代理层proxy中来完成这两个功能。
|
||||
|
||||
只有很好地实现了这些功能,一个平台服务才能高效地支撑不同业务线的需求。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这节课,我们学习了微博的Redis实践,从中总结了许多经验。总结来说,微博对Redis的技术需求可以概括为3点,分别是高性能、大容量和易扩展。
|
||||
|
||||
为了满足这些需求,除了对Redis进行优化,微博也在自研扩展系统,包括基于RocksDB的容量扩展机制,以及服务化的RedisService集群。
|
||||
|
||||
最后,我还想再跟你分享一下我自己的两个感受。
|
||||
|
||||
第一个是关于微博做的RedisService集群,这个优化方向是大厂平台部门同学的主要工作方向。
|
||||
|
||||
业务纵切、平台横切是当前构建大规模系统的基本思路。所谓业务纵切,是指把不同的业务数据单独部署,这样可以避免相互之间的干扰。而平台横切是指,当不同业务线对运行平台具有相同需求时,可以统一起来,通过构建平台级集群服务来进行支撑。Redis就是典型的多个业务线都需要的基础性服务,所以将其以集群方式服务化,有助于提升业务的整体效率。
|
||||
|
||||
第二个是代码实践在我们成长为Redis高手过程中的重要作用。
|
||||
|
||||
我发现,对Redis的二次改造或开发,是大厂的一个必经之路,这和大厂业务多、需求广有密切关系。
|
||||
|
||||
微博做的定制化数据结构、RedRock和RedisService都是非常典型的例子。所以,如果我们想要成为Redis高手,成为大厂中的一员,那么,先原理后代码,边学习边实践,就是一个不错的方法。原理用来指导代码阅读的聚焦点,而动手实践至关重要,需要我们同时开展部署操作实践和阅读代码实践。纸上得来终觉浅,绝知此事要躬行,希望你不仅重视学习原理,还要真正地用原理来指导实践,提升自己的实战能力。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,你在实际应用Redis时,有没有一些经典的优化改进或二次开发经验?
|
||||
|
||||
欢迎你在留言区聊一聊你的经验,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
99
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(三)| 用户Kaito:我希望成为在压力中成长的人.md
Normal file
99
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(三)| 用户Kaito:我希望成为在压力中成长的人.md
Normal file
@@ -0,0 +1,99 @@
|
||||
<audio id="audio" title="加餐(三)| 用户Kaito:我希望成为在压力中成长的人" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/30/75349a12b6a1d218ba9e8c5301f90d30.mp3"></audio>
|
||||
|
||||
你好,我是Kaito。
|
||||
|
||||
上一次,我分享了我总结的Redis学习路径,在留言区的交流和互动中,我有了很多新的收获。今天,我想再分享一下我对学习这件事儿的认识以及我的学习方法,包括领先一步的心理建设、事半功倍的学习方法以及提升效率的小技巧。
|
||||
|
||||
## 领先一步:保持好奇+不设限
|
||||
|
||||
我认为,任何领域的学习,在研究具体的方法之前,我们都需要先在心理上领先别人一步。什么意思呢?其实就是要建立并保持好奇心,并且不给自己设限。
|
||||
|
||||
我发现,很多人是缺乏好奇心的,突出表现在只知其然,不知其所以然,不善于思考和挖掘问题。
|
||||
|
||||
给你举个小例子。刚开始接触Redis时,你肯定听说过一句话,**Redis是单线程,高性能**。很多人听完也就过去了,但是有好奇心的人,会进一步思考:“单线程如何处理多个客户端的网络请求呢?采用单线程的话,只能用到一个CPU核心,怎么达到高性能呢?”
|
||||
|
||||
顺着这个思路去学习的话,你就会发现,Redis虽然采用了单线程,但是它使用了多路复用技术,可以处理多个客户端的网络请求。而且,它的数据都存储在内存中,再加上高效的数据结构,所以处理每个请求的速度极快。
|
||||
|
||||
你看,带着好奇心去看问题,最终我们得到的远远超出想象。所以,我们要**永远保持好奇心和深入探究的精神**,它是我们不断进步的核心驱动力。
|
||||
|
||||
我要说的第二点,就是**不要给自己设限**。
|
||||
|
||||
**不要没有做任何尝试,就先去说“我做不到”**。如果你这样做,就相当于提前放弃了自己的成长机会。我特别喜欢的一个心态是:“**我现在虽然不会,但是只要给我时间,我就能学会它。**”
|
||||
|
||||
说到这儿,我想给你分享一个我的小故事。
|
||||
|
||||
之前我在业务部门做开发时,大部分时间都在写业务代码,对Redis也只停留在“会用”的层面,并不了解它的原理,更别说分析和定位性能问题了。
|
||||
|
||||
后来一个偶然的机会,我可以去公司的基础架构部门做数据库中间件相关的工作。我当时非常犹豫:一方面,我知道,这个工作要求熟练掌握Redis的方方面面,难度非常高,我觉得我可能无法胜任;但另一方面,我也非常想踏出舒适区,突破一下自己。最终,我还是选择了接受挑战。
|
||||
|
||||
刚开始时,我确实遭遇了难以想象的困难,比如说不熟悉Redis的运行原理、看Redis源码时一头雾水、在系统发生问题时不知所措等等。还好,面对压力,我的斗志被激发了,于是就疯狂地恶补数据结构、网络协议、操作系统等知识,一行行地去啃源码……
|
||||
|
||||
真正走出舒适区之后,我看到了自己的飞速成长和进步,不仅很快就胜任了新工作,而且,我越来越自信了。之后,每次遇到新问题的时候,我再也不会害怕了,因为我知道,只要花时间去研究,就可以搞定一切。
|
||||
|
||||
所以,我真的想和你说,面对任何可以让自己成长的机会,都不要轻易错过,一定不要给自己设限。你要相信,你的潜能会随着你面临的压力而被激发出来,而且它的威力巨大!
|
||||
|
||||
## 事半功倍:行之有效的学习方法
|
||||
|
||||
有了强烈的学习意愿还不够,我们还要快速地找到科学有效的学习方法,这样才能事半功倍。接下来,我就聊聊我的学习方法。
|
||||
|
||||
首先,我们要学会快速地搜集自己需要的资料。在搜索的时候,我们要尽量简化检索的内容,避免无用的关键词,例如,如果想要搜索“Redis哨兵集群在选举时是如何达成共识的”这个问题,我一般会搜索“Redis sentinel raft”,这样只搜索重点词汇,得到的结果会更多,也更符合我们想要的结果。
|
||||
|
||||
如果在查资料时,遇到了细节问题,找不到答案,不要犹豫,**一定要去看源码**。源码是客观的,是最细节的表现,**不要只会从别人那里获取东西,要学着自己动手觅食**,而源码,往往能够给我们提供清晰易懂的答案。
|
||||
|
||||
比如说,Redis的String数据类型底层是由SDS实现的,当要修改的value长度发生变更时,如果原来的内存空间不足以存储新内容,SDS就需要重新申请内存空间,进行扩容,那么,每次扩容时,会申请多大的内存呢?
|
||||
|
||||
如果你看到了`sds.c`中的sdsMakeRoomFor函数,就会知道,当需要申请的内存空间小于1MB时,SDS会申请1倍的内存空间,这样就可以避免后面频繁申请内存而导致的性能开销;当需要申请的内存空间大于1MB时,为了避免内存浪费,每次扩容时就只申请1MB的内存空间。类似于这样的问题,我们都能很快地从源码中找到答案。
|
||||
|
||||
很多人都觉得看源码很难,不愿意走出这一步,刚开始我也是这样的,但是后来有一天,我突然想到了“二八定律”。我所理解的“二八定律”,就是80%的人甘于平庸,遇到稍微难一点的问题就会停下脚步;而另外20%的人,一直不愿意停留在舒适区,只要确定了目标,就会一直向前走。我当然希望自己是那20%的人。所以,每次我觉得有压力、有难度的时候,我就会告诉自己,得坚持下去,这样才能超越80%的人。不得不说,这招儿还挺有用的。
|
||||
|
||||
另外,我还想说,掌握新知识最好的方式,就是把它讲给别人听,或者是写成文章。
|
||||
|
||||
尤其是在写文章的时候,我们需要确定文章的结构,梳理知识点的脉络,还要组织语言,在这个过程中,我们会把一些零碎的内容转化为体系化、结构化的知识。那些散乱的点,会形成一棵“知识树”,这不仅方便我们记忆,而且,在复习的时候,只需要找到“树干”,就能延伸到“枝叶”,举一反三。
|
||||
|
||||
而且,在梳理的过程中,我们往往还能发现自己的知识漏洞,或者是对某些内容有了新的认识和见解。
|
||||
|
||||
例如,我在写《Redis如何持久化数据》这篇文章的时候,就已经知道了RDB+AOF两种方式,但在写的过程中,我发现自己并不清楚具体的细节,比如,为什么生成的RDB文件这么小,这是如何做到的?RDB和AOF分别适合用在什么场景中呢?
|
||||
|
||||
翻阅源码之后,我才发现,RDB文件之所以很小,一方面是因为它存储的是二进制数据,另一方面,Redis针对不同的数据类型做了进一步的压缩处理(例如数字采用int编码存储),这就进一步节省了存储空间。所以,RDB更适合做定时的快照备份和主从全量数据同步,而AOF是实时记录的每个变更操作,更适合用在对数据完整性和安全性要求更高的业务场景中。
|
||||
|
||||
这种用输出反哺输入的方式,也是强化收获的一种有效手段,我真心建议你也试一试。
|
||||
|
||||
## 持续精进:做好精力管理
|
||||
|
||||
拥有了好奇心,也找到了合适的方法,也并不是万事大吉了。我们可能还会面临一个问题:“我非常想把某个技术学好,但是我总是被一些事情打断,而且有的时候总想犯懒,这该怎么办呢?”
|
||||
|
||||
其实这是一个效率问题。人天生是有惰性的,所以我们需要借助一些东西去督促我们前进。想一下,工作时,什么时候效率最高?是不是接近deadline的时候呢?
|
||||
|
||||
这就说明,当我们有目标、有压力的时候,才会有动力、有效率地去执行。所以,我常用的一个方法是,在学习某个领域的知识时,我会先按照从易到难的顺序,把它拆解成一个个大的模块,确定大框架的学习目标;接着,我会继续细化每个模块,细化到一看到这个任务就知道立马应该做什么的程度。同时,我还会给每项任务制定一个deadline。
|
||||
|
||||
简单举个例子。我在学习Redis的基础数据类型时,首先确定了String、List、Hash、Set、Sorted Set这五大模块。接着,我又对每个模块继续进行拆分,例如,Hash类型底层实现可以拆分成“压缩列表+哈希表”这两种数据结构实现,接下来,我就继续细化这两个模块的内容,最终确定了一个个小目标。
|
||||
|
||||
怎么完成这些小目标呢?我采用的方式是用**番茄工作法**。
|
||||
|
||||
我会把这些细化的目标加入到番茄任务中,并且排列好优先级。随后,我会在工作日晚上或者周末,抽出一整块的时间去完成这些小目标。在开启番茄钟时,我会迅速集中精力去完成这些任务。同时,我会把手机静音,放在自己够不到的地方。等一个番茄钟(25分钟)结束后,休息5分钟,调整下状态,然后再投入到一个番茄任务中。
|
||||
|
||||
在实施的过程中,我们可能会遇到一些阻碍,比如说某个任务比想象中的难。这个时候,我会尝试多用几个番茄钟去攻克它,或者是把它的优先级向后放,先完成其他的番茄任务,最后再花时间去解决比较难的问题。
|
||||
|
||||
长时间使用这种方法,我发现,我的效率非常高。而且,把番茄任务一个个划掉之后,也会有一些小小的成就感,这种成就感会激励我持续学习。
|
||||
|
||||
最后,我还想再说一点,就是要**投入足够多的时间**。不要总是抱怨想要的得不到,在抱怨之前,你要先想一想,有没有远超出他人的投入和付出。想要走在别人的前面,就要准备好投入足够多的时间。
|
||||
|
||||
有时候,可能你会觉得,学习某一个领域的技术感觉很枯燥,因为细节很多、很繁琐,但这都是很正常的。现在我们所看到的每一项技术,都是开发者多年的总结和提炼的成果,它本身就是严肃的,你必须花足够多的时间去分析、研究、思考,没有捷径。
|
||||
|
||||
千万不要指望着借助碎片化学习,搞懂某个领域的知识。我所说的碎片化有两层含义:一是指内容碎片化,二是指时间碎片化。
|
||||
|
||||
不知道你没有遇到这种情况,当你看完一篇技术文章时,可能以为自己已经掌握了这些知识点,但是,如果别人稍微问一下相关的知识点,你可能就答不上来了。这就是因为,你学到的东西是碎片化的,并没有形成知识体系。
|
||||
|
||||
但是,只有系统化学习,你才能看到这项技术的全貌,会更清晰边界,知道它适合做什么,不适合做什么,为什么会这样去设计。
|
||||
|
||||
另一方面,不要幻想着只在地铁上学一会儿,就能把它学会,这样就有点太高估自己了。因为在很短的时间内,我们没有办法深入地去思考,去深入了解这个知识点的前因后果。你必须在晚上或者周末抽出一整块时间,去理清每个知识点之间的关系和边界,必要时还需要动手实践。
|
||||
|
||||
因此,如果真正想去要握某项技术,就必须需要付出整块的时间去学习,而且,必须是系统化的学习。
|
||||
|
||||
## 总结
|
||||
|
||||
今天,我跟你分享了我的一些学习总结,包括领先别人一步的心理建设,事半功倍的学习方法,以及持续精进的精力管理方法。
|
||||
|
||||
**这些道理其实很简单,也很容易理解,但是能真正做到的,也只有20%的人,甚至是更少。所以,希望我们都能真正地行动起来,进步的路很长,我们一定要让自己在路上。**
|
||||
|
||||
最后,希望这些内容对你有所帮助,我也很期待你在留言区聊一聊你的学习方法或习惯,我们一起交流和进步。
|
||||
138
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(二)| 用户Kaito:我是如何学习Redis的?.md
Normal file
138
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(二)| 用户Kaito:我是如何学习Redis的?.md
Normal file
@@ -0,0 +1,138 @@
|
||||
<audio id="audio" title="加餐(二)| 用户Kaito:我是如何学习Redis的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/dd/0bc4d7f759b3a5ff6debdc3f25a1c2dd.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在看课程留言的时候,我发现,Kaito同学的总结常常特别精彩,所以就请编辑帮我联系了Kaito,想请他来聊一聊具体是怎么学习Redis的。
|
||||
|
||||
接下来,我就把Kaito的学习经验分享给你。
|
||||
|
||||
** **
|
||||
|
||||
你好,我是Kaito。
|
||||
|
||||
很荣幸受到极客时间编辑的邀请,来和你分享一下我学习Redis的方法,希望可以帮助你更加高效地学习Redis。
|
||||
|
||||
我先做个自我介绍。
|
||||
|
||||
从毕业到现在,我已经工作7年了,目前是北京的一家移动互联网公司的资深研发工程师。我之前主导设计过垂直爬虫采集平台,后来开发面向用户的后端服务系统,现在在从事基础架构和数据库中间件方面的研发工作,具体是做跨数据中心的存储层灾备与多活领域的研发,主要技术栈是Golang。
|
||||
|
||||
我们公司采用的Redis集群方案是Codis,所以我也主要负责公司内的Codis定制化开发工作。在最近的一年多时间里,我的很多工作都是围绕Redis展开的。在这期间,我遇到了很多Redis相关的问题,例如访问延迟变大、部署运维参数配置不合理,等等,也狠狠地恶补了Redis方面的知识,看过书,读过源码,出过Bug,踩过坑,一路走来,我逐渐梳理出了一套高效的学习路径,我把它分为三大模块:
|
||||
|
||||
1. 掌握数据结构和缓存的基本使用方法;
|
||||
1. 掌握支撑Redis实现高可靠、高性能的技术;
|
||||
1. 精通Redis底层实现原理。
|
||||
|
||||
今天的这次分享,我想先和你聊聊“如何高效学习Redis”,后面我会再跟你分享我的一些学习心得和总结。
|
||||
|
||||
## 掌握数据结构和缓存的基本使用方法
|
||||
|
||||
要想会用一种系统,我们首先要会一些基本操作。我们平时在开发业务系统时,或多或少地会把Redis当作数据库或缓存使用。Redis也提供了非常丰富的数据结构,这也给我们的开发提供了极大的便利。
|
||||
|
||||
所以,要想快速地上手Redis,我建议你从三个步骤入手:
|
||||
|
||||
1. 学会基础数据类型的用法;
|
||||
1. 掌握扩展数据类型的用法;
|
||||
1. 积累一些Redis用作缓存的方法以及典型问题的解决方案。
|
||||
|
||||
在刚接触Redis时,第一步就是要学习它的基础数据结构,也就是String、List、Hash、Set、Sorted Set。毕竟,Redis之所以这么受欢迎,跟它丰富的数据类型是分不开的,它的数据都存储在内存中,访问速度极快,而且非常贴合我们常见的业务场景。我举几个例子:
|
||||
|
||||
- 如果你只需要存储简单的键值对,或者是对数字进行递增递减操作,就可以使用String存储;
|
||||
- 如果需要一个简单的分布式队列服务,List就可以满足你的需求;
|
||||
- 如果除了需要存储键值数据,还想单独对某个字段进行操作,使用Hash就非常方便;
|
||||
- 如果想得到一个不重复的集合,就可以使用Set,而且它还可以做并集、差集和交集运算;
|
||||
- 如果想实现一个带权重的评论、排行榜列表,那么,Sorted Set就能满足你。
|
||||
|
||||
当我们能够熟练地使用这些基础的数据类型时,就说明我们已经入门了Redis。此时,如果你的业务体量不是很大,那么,在使用过程中并不会遇到很大的问题。但是,现在已经进入了大数据时代,我们不可避免地会遇到数据请求量巨大的业务场景,对于这种情况,基础数据类型已经无法应对了。
|
||||
|
||||
举个最简单的例子,当数据量很小时,我们想要计算App里某一天的用户UV数,只需要使用一个Set存储这一天的访问用户,再使用SCARD,就可以计算出结果了。但是,假如一天的访问用户量达到了亿级,就不能这样存储了,因为这会消耗非常大的内存空间。而且,这么大的key在过期时会引发阻塞风险。这个时候,我们就需要学习Redis的数据结构的高阶用法了。
|
||||
|
||||
Redis提供了三种扩展数据类型,就是咱们前面学到的HyperLogLog、Bitmap和GEO。
|
||||
|
||||
HyperLogLog就非常适合存储UV这样的业务数据,而且它占用的内存非常小。同样地,当需要计算大量用户的签到情况时,你会发现,使用String、Set、Sorted Set都会占用非常多的内存空间,而Redis提供的位运算就派上用场了。如果你遇到了缓存穿透问题,就可以使用位运算的布隆过滤器,这种方法能够在占用内存很少的情况下解决我们的问题。
|
||||
|
||||
基于这个思路,你会发现,有很多巧妙地使用Redis的方法。在这个阶段,基于Redis提供的数据类型,你可以尽可能地去挖掘它们的使用方法,去实现你的业务模型。
|
||||
|
||||
除了借助数据类型实现业务模型之外,我们在使用Redis时,还会经常把它当作缓存使用。
|
||||
|
||||
因为Redis的速度极快,非常适合把数据库中的数据缓存一份在Redis中,这样可以提高我们应用的访问速度。但是,由于Redis把数据都存储在内存中,而一台机器的内存是有上限的,是无法存储无限数据的。所以,我们还需要思考“Redis如何做缓存”的问题。
|
||||
|
||||
你可能也听说过,Redis在用作缓存时,有很多典型的问题,比如说数据库和Redis缓存的数据一致性问题、缓存穿透问题、缓存雪崩问题。这些问题会涉及到缓存策略、缓存如何设置过期时间、应用与缓存如何配合,等等。所以,我们在前期学习的时候,还要知道一些应对策略。
|
||||
|
||||
学会了这些,我们就能简单地操作Redis了。接下来,我们就可以学习一些高阶的用法。
|
||||
|
||||
## 掌握支撑Redis实现高性能、高可靠的技术点
|
||||
|
||||
如果你看过软件架构设计相关的文章,应该就会知道,一个优秀的软件,必须符合三个条件:高可靠、高性能、易扩展。作为一个非常优秀的数据库软件,Redis也是符合这些条件的。不过,易扩展是针对深度参与Redis开发来说的,我们接触得比较少,暂时可以忽略。我们需要关注另外两个:高可靠、高性能。
|
||||
|
||||
Redis之所以可以实现高可靠、高性能,和它的持久化机制、主从复制机制、哨兵、故障自动恢复、切片集群等密不可分。所以,我们还要掌握这一系列机制。这样的话, 在出现问题时,我们就可以快速地定位和解决问题了。而且,我们还可以从Redis身上学习一个优秀软件的设计思想,这也会给我们学习其他数据库提供非常大的帮助。
|
||||
|
||||
我先从一个最简单的单机版Redis说起,和你聊一聊我的理解。
|
||||
|
||||
假设我们只部署一个Redis实例,然后把业务数据都存储在这个实例中,而Redis只把数据存储在内存中,那么,如果此时,这个Redis实例故障宕机了,就意味着,我们的业务数据就全部丢失了,这显然是不能接受的。那该如何处理呢?
|
||||
|
||||
这就需要Redis有持久化数据的能力。具体来说,就是可以把内存中的数据持久化到磁盘,当实例宕机时,我们可以从磁盘中恢复数据。所以,Redis提供了两种持久化方式:RDB和AOF,分别对应数据快照和实时的命令持久化,它们相互补充,实现了Redis的持久化功能。
|
||||
|
||||
有了数据的持久化,是不是就可以高枕无忧了?
|
||||
|
||||
不是的。当实例宕机后,如果我们需要从磁盘恢复数据,还会面临一个问题:恢复也是需要时间的,而且实例越大,恢复的时间越长,对业务的影响就越大。
|
||||
|
||||
针对这个问题,解决方案就是:采用多个副本。我们需要Redis可以实时保持多个副本的同步,也就是我们说的主从复制。这样,当一个实例宕机时,我们还有其他完整的副本可以使用。这时,只需要把一个副本提升为主节点,继续提供服务就可以了,这就避免了数据恢复过程中的一些影响。
|
||||
|
||||
但是,进一步再想一下,当主节点宕机后,我们把从节点提升上来,这个过程是手动的。手动触发就意味着,当故障发生时,需要人的反应时间和操作时间,这个过程也需要消耗时间。晚操作一会儿,就会对业务产生持续的影响,这怎么办呢?我们很容易会想到,当故障发生时,是不是可以让程序自动切换主从呢?
|
||||
|
||||
要实现主从自动切换,就需要能够保证高可用的组件:哨兵。哨兵可以实时检测主节点的健康情况。当主节点故障时,它会立即把一个从节点提升为主节点,实现自动故障转移,整个过程无需人工干预,程序自动完成,大大地减少了故障带来的影响。
|
||||
|
||||
所以你看,经过刚刚的分析,我们知道,为了保证可靠性,一个数据库软件必然需要做到数据持久化、主从副本和故障自动恢复。其他的数据库软件也遵循这样的原则,你可以留意观察一下。
|
||||
|
||||
到这里,我们说的都是针对单个Redis实例的功能,如果我们业务的读写请求不大,使用单个实例没有问题,但是当业务写入量很大时,单个Redis实例就无法承担这么大的写入量了。
|
||||
|
||||
这个时候,我们就需要引入切片集群了,也就是把多个Redis实例组织起来,形成一个集群,对外提供服务。同时,这个集群还要具有水平扩展的能力,当业务量再增长时,可以通过增加机器部署新实例的方法,承担更大的请求量,这样一来,我们的集群性能也可以变得很高。
|
||||
|
||||
所以,就有了Redis Cluster、Twemproxy、Codis这些集群解决方案。其中,Redis Cluster是官方提供的集群方案,而Twemproxy和Codis是早期Redis Cluster不够完善时开发者设计的。
|
||||
|
||||
既然是多个节点存储数据,而且还要在节点不足时能够增加新的节点扩容集群,这也对应着切片集群的核心问题:**数据路由和数据迁移**。
|
||||
|
||||
数据路由用于解决把数据写到哪个节点的问题,而数据迁移用于解决在节点发生变更时,集群数据重新分布的问题。
|
||||
|
||||
当我们从单机版Redis进入到切片集群化的领域时,就打开了另一个世界的大门。
|
||||
|
||||
不知道你有没有思考过这样一个问题:当我们的系统需要承担更大体量的请求时,从应用层到数据层,容易引发性能问题的地方在哪儿?
|
||||
|
||||
其实,最终都会落到数据库层面。因为我们的应用层是无状态的,如果性能达到了瓶颈,就可以增加机器的横向扩展能力,部署多个实例,非常容易。但是,应用层水平扩容后,数据库还是单体的,大量请求还是只有一个机器的数据库在支撑,这必然会产生性能瓶颈。所以,最好的方案是,数据库层也可以做成分布式的,这也就是说,数据也可以分布在不同的机器上,并且拥有横向扩展的能力,这样,在业务层和数据库层,都可以根据业务的体量进行弹性伸缩,非常灵活。
|
||||
|
||||
切片集群虽然更可靠,性能更好,但是因为涉及到多个机器的部署,所以就会引入新的问题,比如说,多个节点如何组织?多个节点的状态如何保持一致?跨机器的故障如何检测?网络延迟时集群是否还能正常工作?这些就涉及到分布式系统领域相关的知识了。
|
||||
|
||||
上面这些都是跟可靠性相关的知识,下面我们再来看看高性能。
|
||||
|
||||
Redis的数据都存储在内存中,再加上使用IO多路复用机制,所以,Redis的性能非常高。如果配合切片集群的使用,性能就会再上一个台阶。但是,这也意味着,如果发生操作延迟变大的情况,就会跟我们的预期不符。所以,如何使用和运维好Redis也是需要我们重点关注的,只有这样,才可以让Redis持续稳定地发挥其高性能。
|
||||
|
||||
而性能问题,就贯穿了刚刚我们说到的所有方面,业务使用不当,高可靠、切片集群运维不当,都会产生性能问题。
|
||||
|
||||
例如,在业务使用层面,使用复杂度过高的命令、使用O(N)命令而且N很大、大量数据集中过期、实例内存达到上限等,都会导致操作延迟变大;在运维层面,持久化策略选择不当、主从复制参数配置不合理、部署和监控不到位、机器资源饱和,等等,也会产生性能问题。
|
||||
|
||||
Redis性能涉及到了CPU、内存、网络甚至磁盘的方方面面,一旦某个环节出现问题,都会影响到性能。所以,在第二个阶段,我们就需要掌握跟高可靠、高性能相关的一系列机制。
|
||||
|
||||
这个时候,我们的Redis使用能力就超过了很多人,不过还达不到精通的程度。要想成为Redis大神,我们还必须具备能够随时解决棘手问题的能力。这个时候,我们就要去学习Redis的底层原理了。
|
||||
|
||||
## 精通Redis底层实现原理
|
||||
|
||||
我们要知道各种数据类型的底层原理。这个时候,可以去看下源码。例如,`t_string.c`、`t_list.c`、`t_hash.c`、`t_set.c`、`t_zset.c`。
|
||||
|
||||
在阅读源码的时候,我们就会了解每种数据结构的具体实现,例如List在底层是一个链表,在List中查找元素时就会比较慢,而Hash和Set底层都是哈希表实现的,所以定位元素的速度非常快,而Sorted Set是把哈希表和跳表结合起来使用,查找元素和遍历元素都比较快。如果你不了解这些数据结构的实现,就无法选择最佳的方案。
|
||||
|
||||
如果你看得比较仔细的话,还会发现,每种数据结构对应了不同的实现,例如List、Hash、Sorted Set为了减少内存的使用,在数据量比较少时,都采用压缩列表(ziplist)存储,这样可以节省内存。而String和Set在存储数据时,也尽量选择使用int编码存储,这也是为了节省内存占用。这些都是Redis针对数据结构做的优化。只有了解了这些底层原理,我们在使用Redis时才能更加游刃有余,把它的优势真正发挥出来。
|
||||
|
||||
另外,我们还需要掌握跟高性能、高可靠相关的一系列原理,主要就是持久化、主从同步、故障转移、切片集群是如何做的,比如说:
|
||||
|
||||
- RDB和AOF重写都使用了操作系统提供的"fork"机制进行数据持久化,这涉及到了操作系统层面的知识;
|
||||
- 故障转移使用哨兵集群实现,而哨兵集群的维护就涉及到了分布式系统的选举问题和共识问题;
|
||||
- 切片集群是操作多个机器上的节点,如何对多个节点进行管理、调度和维护,也涉及到分布式系统的很多问题,例如CAP原理、分布式事务、架构设计;
|
||||
- ……
|
||||
|
||||
掌握了原理,就可以以不变应万变,无论遇到什么问题,我们都可以轻松地进行分析和定位了。到了这个阶段,我们的Redis应用能力就已经远超很多人了。
|
||||
|
||||
好了,这些就是我总结的Redis学习路径了,基本上是按照从易到难逐渐递进的。在学习的过程中,可以有针对性地看一些书籍,以及相关的课程,比如咱们的专栏,这些内容可以帮助你快速地提升实战能力。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/3c/0e7b8c42d1daf631b19c7164ac4bdf3c.jpg" alt="">
|
||||
|
||||
最后,我也想请你聊一聊,你是怎么学习Redis的呢?希望你能在留言区聊聊你的学习方法,我们一起交流。
|
||||
125
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(五) | Redis有哪些好用的运维工具?.md
Normal file
125
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(五) | Redis有哪些好用的运维工具?.md
Normal file
@@ -0,0 +1,125 @@
|
||||
<audio id="audio" title="加餐(五) | Redis有哪些好用的运维工具?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/6a/e97c6221e55eb47fe68bd89bb9yy086a.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
今天的加餐,我来给你分享一些好用的Redis运维工具。
|
||||
|
||||
我们在应用Redis时,经常会面临的运维工作,包括Redis的运行状态监控,数据迁移,主从集群、切片集群的部署和运维。接下来,我就从这三个方面,给你介绍一些工具。我们先来学习下监控Redis实时运行状态的工具,这些工具都用到了Redis提供的一个监控命令:INFO。
|
||||
|
||||
## 最基本的监控命令:INFO命令
|
||||
|
||||
**Redis本身提供的INFO命令会返回丰富的实例运行监控信息,这个命令是Redis监控工具的基础**。
|
||||
|
||||
INFO命令在使用时,可以带一个参数section,这个参数的取值有好几种,相应的,INFO命令也会返回不同类型的监控信息。我把INFO命令的返回信息分成5大类,其中,有的类别当中又包含了不同的监控内容,如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/a8/8fb2ef487fd9b7073fd062d480b220a8.jpg" alt="">
|
||||
|
||||
在监控Redis运行状态时,INFO命令返回的结果非常有用。如果你想了解INFO命令的所有参数返回结果的详细含义,可以查看Redis[官网](https://redis.io/commands/info)的介绍。这里,我给你提几个运维时需要重点关注的参数以及它们的重要返回结果。
|
||||
|
||||
首先,**无论你是运行单实例或是集群,我建议你重点关注一下stat、commandstat、cpu和memory这四个参数的返回结果**,这里面包含了命令的执行情况(比如命令的执行次数和执行时间、命令使用的CPU资源),内存资源的使用情况(比如内存已使用量、内存碎片率),CPU资源使用情况等,这可以帮助我们判断实例的运行状态和资源消耗情况。
|
||||
|
||||
另外,当你启用RDB或AOF功能时,你就需要重点关注下persistence参数的返回结果,你可以通过它查看到RDB或者AOF的执行情况。
|
||||
|
||||
如果你在使用主从集群,就要重点关注下replication参数的返回结果,这里面包含了主从同步的实时状态。
|
||||
|
||||
不过,INFO命令只是提供了文本形式的监控结果,并没有可视化,所以,在实际应用中,我们还可以使用一些第三方开源工具,将INFO命令的返回结果可视化。接下来,我要讲的Prometheus,就可以通过插件将Redis的统计结果可视化。
|
||||
|
||||
## 面向Prometheus的Redis-exporter监控
|
||||
|
||||
[Prometheus](https://prometheus.io/)是一套开源的系统监控报警框架。它的核心功能是从被监控系统中拉取监控数据,结合[Grafana](https://grafana.com/)工具,进行可视化展示。而且,监控数据可以保存到时序数据库中,以便运维人员进行历史查询。同时,Prometheus会检测系统的监控指标是否超过了预设的阈值,一旦超过阈值,Prometheus就会触发报警。
|
||||
|
||||
对于系统的日常运维管理来说,这些功能是非常重要的。而Prometheus已经实现了使用这些功能的工具框架。我们只要能从被监控系统中获取到监控数据,就可以用Prometheus来实现运维监控。
|
||||
|
||||
Prometheus正好提供了插件功能来实现对一个系统的监控,我们把插件称为exporter,每一个exporter实际是一个采集监控数据的组件。exporter采集的数据格式符合Prometheus的要求,Prometheus获取这些数据后,就可以进行展示和保存了。
|
||||
|
||||
[Redis-exporter](https://github.com/oliver006/redis_exporter)就是用来监控Redis的,它将INFO命令监控到的运行状态和各种统计信息提供给Prometheus,从而进行可视化展示和报警设置。目前,Redis-exporter可以支持Redis 2.0至6.0版本,适用范围比较广。
|
||||
|
||||
除了获取Redis实例的运行状态,Redis-exporter还可以监控键值对的大小和集合类型数据的元素个数,这个可以在运行Redis-exporter时,使用check-keys的命令行选项来实现。
|
||||
|
||||
此外,我们可以开发一个Lua脚本,定制化采集所需监控的数据。然后,我们使用scripts命令行选项,让Redis-exporter运行这个特定的脚本,从而可以满足业务层的多样化监控需求。
|
||||
|
||||
最后,我还想再给你分享两个小工具:[redis-stat](https://github.com/junegunn/redis-stat)和[Redis Live](https://github.com/snakeliwei/RedisLive)。跟Redis-exporter相比,这两个都是轻量级的监控工具。它们分别是用Ruby和Python开发的,也是将INFO命令提供的实例运行状态信息可视化展示。虽然这两个工具目前已经很少更新了,不过,如果你想自行开发Redis监控工具,它们都是不错的参考。
|
||||
|
||||
除了监控Redis的运行状态,还有一个常见的运维任务就是数据迁移。接下来,我们再来学习下数据迁移的工具。
|
||||
|
||||
## 数据迁移工具Redis-shake
|
||||
|
||||
有时候,我们需要在不同的实例间迁移数据。目前,比较常用的一个数据迁移工具是[Redis-shake](https://github.com/aliyun/redis-shake),这是阿里云Redis和MongoDB团队开发的一个用于Redis数据同步的工具。
|
||||
|
||||
Redis-shake的基本运行原理,是先启动Redis-shake进程,这个进程模拟了一个Redis实例。然后,Redis-shake进程和数据迁出的源实例进行数据的全量同步。
|
||||
|
||||
这个过程和Redis主从实例的全量同步是类似的。
|
||||
|
||||
源实例相当于主库,Redis-shake相当于从库,源实例先把RDB文件传输给Redis-shake,Redis-shake会把RDB文件发送给目的实例。接着,源实例会再把增量命令发送给Redis-shake,Redis-shake负责把这些增量命令再同步给目的实例。
|
||||
|
||||
下面这张图展示了Redis-shake进行数据迁移的过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/5b/027f6ae0276d483650ee4d5179f19c5b.jpg" alt="">
|
||||
|
||||
**Redis-shake的一大优势,就是支持多种类型的迁移。**
|
||||
|
||||
**首先,它既支持单个实例间的数据迁移,也支持集群到集群间的数据迁移**。
|
||||
|
||||
**其次**,有的Redis切片集群(例如Codis)会使用proxy接收请求操作,Redis-shake也同样支持和proxy进行数据迁移。
|
||||
|
||||
**另外**,因为Redis-shake是阿里云团队开发的,所以,除了支持开源的Redis版本以外,Redis-shake还支持云下的Redis实例和云上的Redis实例进行迁移,可以帮助我们实现Redis服务上云的目标。
|
||||
|
||||
**在数据迁移后,我们通常需要对比源实例和目的实例中的数据是否一致**。如果有不一致的数据,我们需要把它们找出来,从目的实例中剔除,或者是再次迁移这些不一致的数据。
|
||||
|
||||
这里,我就要再给你介绍一个数据一致性比对的工具了,就是阿里云团队开发的[Redis-full-check](https://github.com/aliyun/redis-full-check)。
|
||||
|
||||
Redis-full-check的工作原理很简单,就是对源实例和目的实例中的数据进行全量比对,从而完成数据校验。不过,为了降低数据校验的比对开销,Redis-full-check采用了多轮比较的方法。
|
||||
|
||||
在第一轮校验时,Redis-full-check会找出在源实例上的所有key,然后从源实例和目的实例中把相应的值也都查找出来,进行比对。第一次比对后,redis-full-check会把目的实例中和源实例不一致的数据,记录到sqlite数据库中。
|
||||
|
||||
从第二轮校验开始,Redis-full-check只比较上一轮结束后记录在数据库中的不一致的数据。
|
||||
|
||||
为了避免对实例的正常请求处理造成影响,Redis-full-check在每一轮比对结束后,会暂停一段时间。随着Redis-shake增量同步的进行,源实例和目的实例中的不一致数据也会逐步减少,所以,我们校验比对的轮数不用很多。
|
||||
|
||||
我们可以自己设置比对的轮数。具体的方法是,在运行redis-full-check命令时,把参数comparetimes的值设置为我们想要比对的轮数。
|
||||
|
||||
等到所有轮数都比对完成后,数据库中记录的数据就是源实例和目的实例最终的差异结果了。
|
||||
|
||||
这里有个地方需要注意下,Redis-full-check提供了三种比对模式,我们可以通过comparemode参数进行设置。comparemode参数有三种取值,含义如下:
|
||||
|
||||
- KeyOutline,只对比key值是否相等;
|
||||
- ValueOutline,只对比value值的长度是否相等;
|
||||
- FullValue,对比key值、value长度、value值是否相等。
|
||||
|
||||
我们在应用Redis-full-check时,可以根据业务对数据一致性程度的要求,选择相应的比对模式。如果一致性要求高,就把comparemode参数设置为FullValue。
|
||||
|
||||
好了,最后,我再向你介绍一个用于Redis集群运维管理的工具CacheCloud。
|
||||
|
||||
## 集群管理工具CacheCloud
|
||||
|
||||
[CacheCloud](https://github.com/sohutv/cachecloud)是搜狐开发的一个面向Redis运维管理的云平台,它**实现了主从集群、哨兵集群和Redis Cluster的自动部署和管理**,用户可以直接在平台的管理界面上进行操作。
|
||||
|
||||
针对常见的集群运维需求,CacheCloud提供了5个运维操作。
|
||||
|
||||
- 下线实例:关闭实例以及实例相关的监控任务。
|
||||
- 上线实例:重新启动已下线的实例,并进行监控。
|
||||
- 添加从节点:在主从集群中给主节点添加一个从节点。
|
||||
- 故障切换:手动完成Redis Cluster主从节点的故障转移。
|
||||
- 配置管理:用户提交配置修改的工单后,管理员进行审核,并完成配置修改。
|
||||
|
||||
当然,作为运维管理平台,CacheCloud除了提供运维操作以外,还提供了丰富的监控信息。
|
||||
|
||||
CacheCloud不仅会收集INFO命令提供的实例实时运行状态信息,进行可视化展示,而且还会把实例运行状态信息保存下来,例如内存使用情况、客户端连接数、键值对数据量。这样一来,当Redis运行发生问题时,运维人员可以查询保存的历史记录,并结合当时的运行状态信息进行分析。
|
||||
|
||||
如果你希望有一个统一平台,把Redis实例管理相关的任务集中托管起来,CacheCloud是一个不错的工具。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我给你介绍了几种Redis的运维工具。
|
||||
|
||||
我们先了解了Redis的INFO命令,这个命令是监控工具的基础,监控工具都会基于INFO命令提供的信息进行二次加工。我们还学习了3种用来监控Redis实时运行状态的运维工具,分别是Redis-exporter、redis-stat和Redis Live。
|
||||
|
||||
关于数据迁移,我们既可以使用Redis-shake工具,也可以通过RDB文件或是AOF文件进行迁移。
|
||||
|
||||
在运维Redis时,刚刚讲到的多款开源工具,已经可以满足我们的不少需求了。但是,有时候,不同业务线对Redis运维的需求可能并不一样,直接使用现成的开源工具可能无法满足全部需求,在这种情况下,建议你基于开源工具进行二次开发或是自研,从而更好地满足业务使用需求。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题:你在实际应用中还使用过什么好的运维工具吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
177
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(六)| Redis的使用规范小建议.md
Normal file
177
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(六)| Redis的使用规范小建议.md
Normal file
@@ -0,0 +1,177 @@
|
||||
<audio id="audio" title="加餐(六)| Redis的使用规范小建议" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/a4/bff585ayy164fd5c3a454f1bfbe32ba4.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
今天的加餐,我们来聊一个轻松点儿的话题,我来给你介绍一下Redis的使用规范,包括键值对使用、业务数据保存和命令使用规范。
|
||||
|
||||
毕竟,高性能和节省内存,是我们的两个目标,只有规范地使用Redis,才能真正实现这两个目标。如果说之前的内容教会了你怎么用,那么今天的内容,就是帮助你用好Redis,尽量不出错。
|
||||
|
||||
好了,话不多说,我们来看下键值对的使用规范。
|
||||
|
||||
## 键值对使用规范
|
||||
|
||||
关于键值对的使用规范,我主要想和你说两个方面:
|
||||
|
||||
1. key的命名规范,只有命名规范,才能提供可读性强、可维护性好的key,方便日常管理;
|
||||
1. value的设计规范,包括避免bigkey、选择高效序列化方法和压缩方法、使用整数对象共享池、数据类型选择。
|
||||
|
||||
### 规范一:key的命名规范
|
||||
|
||||
一个Redis实例默认可以支持16个数据库,我们可以把不同的业务数据分散保存到不同的数据库中。
|
||||
|
||||
但是,在使用不同数据库时,客户端需要使用SELECT命令进行数据库切换,相当于增加了一个额外的操作。
|
||||
|
||||
其实,我们可以通过合理命名key,减少这个操作。具体的做法是,把业务名作为前缀,然后用冒号分隔,再加上具体的业务数据名。这样一来,我们可以通过key的前缀区分不同的业务数据,就不用在多个数据库间来回切换了。
|
||||
|
||||
我给你举个简单的小例子,看看具体怎么命名key。
|
||||
|
||||
比如说,如果我们要统计网页的独立访客量,就可以用下面的代码设置key,这就表示,这个数据对应的业务是统计unique visitor(独立访客量),而且对应的页面编号是1024。
|
||||
|
||||
```
|
||||
uv:page:1024
|
||||
|
||||
```
|
||||
|
||||
这里有一个地方需要注意一下。key本身是字符串,底层的数据结构是SDS。SDS结构中会包含字符串长度、分配空间大小等元数据信息。从Redis 3.2版本开始,**当key字符串的长度增加时,SDS中的元数据也会占用更多内存空间**。
|
||||
|
||||
所以,**我们在设置key的名称时,要注意控制key的长度**。否则,如果key很长的话,就会消耗较多内存空间,而且,SDS元数据也会额外消耗一定的内存空间。
|
||||
|
||||
SDS结构中的字符串长度和元数据大小的对应关系如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/0c/5afd9d57af8edd1ae69e62b1c998050c.jpg" alt="">
|
||||
|
||||
为了减少key占用的内存空间,我给你一个小建议:对于业务名或业务数据名,可以使用相应的英文单词的首字母表示,(比如user用u表示,message用m),或者是用缩写表示(例如unique visitor使用uv)。
|
||||
|
||||
### 规范二:避免使用bigkey
|
||||
|
||||
Redis是使用单线程读写数据,bigkey的读写操作会阻塞线程,降低Redis的处理效率。所以,在应用Redis时,关于value的设计规范,非常重要的一点就是避免bigkey。
|
||||
|
||||
bigkey通常有两种情况。
|
||||
|
||||
- 情况一:键值对的值大小本身就很大,例如value为1MB的String类型数据。为了避免String类型的bigkey,在业务层,我们要尽量把String类型的数据大小控制在10KB以下。
|
||||
- 情况二:键值对的值是集合类型,集合元素个数非常多,例如包含100万个元素的Hash集合类型数据。为了避免集合类型的bigkey,我给你的设计规范建议是,**尽量把集合类型的元素个数控制在1万以下**。
|
||||
|
||||
当然,这些建议只是为了尽量避免bigkey,如果业务层的String类型数据确实很大,我们还可以通过数据压缩来减小数据大小;如果集合类型的元素的确很多,我们可以将一个大集合拆分成多个小集合来保存。
|
||||
|
||||
这里,还有个地方需要注意下,Redis的4种集合类型List、Hash、Set和Sorted Set,在集合元素个数小于一定的阈值时,会使用内存紧凑型的底层数据结构进行保存,从而节省内存。例如,假设Hash集合的hash-max-ziplist-entries配置项是1000,如果Hash集合元素个数不超过1000,就会使用ziplist保存数据。
|
||||
|
||||
紧凑型数据结构虽然可以节省内存,但是会在一定程度上导致数据的读写性能下降。所以,如果业务应用更加需要保持高性能访问,而不是节省内存的话,在不会导致bigkey的前提下,你就不用刻意控制集合元素个数了。
|
||||
|
||||
### 规范三:使用高效序列化方法和压缩方法
|
||||
|
||||
为了节省内存,除了采用紧凑型数据结构以外,我们还可以遵循两个使用规范,分别是使用高效的序列化方法和压缩方法,这样可以减少value的大小。
|
||||
|
||||
Redis中的字符串都是使用二进制安全的字节数组来保存的,所以,我们可以把业务数据序列化成二进制数据写入到Redis中。
|
||||
|
||||
但是,**不同的序列化方法,在序列化速度和数据序列化后的占用内存空间这两个方面,效果是不一样的**。比如说,protostuff和kryo这两种序列化方法,就要比Java内置的序列化方法(java-build-in-serializer)效率更高。
|
||||
|
||||
此外,业务应用有时会使用字符串形式的XML和JSON格式保存数据。
|
||||
|
||||
这样做的好处是,这两种格式的可读性好,便于调试,不同的开发语言都支持这两种格式的解析。
|
||||
|
||||
缺点在于,XML和JSON格式的数据占用的内存空间比较大。为了避免数据占用过大的内存空间,我建议使用压缩工具(例如snappy或gzip),把数据压缩后再写入Redis,这样就可以节省内存空间了。
|
||||
|
||||
### 规范四:使用整数对象共享池
|
||||
|
||||
整数是常用的数据类型,Redis内部维护了0到9999这1万个整数对象,并把这些整数作为一个共享池使用。
|
||||
|
||||
换句话说,如果一个键值对中有0到9999范围的整数,Redis就不会为这个键值对专门创建整数对象了,而是会复用共享池中的整数对象。
|
||||
|
||||
这样一来,即使大量键值对保存了0到9999范围内的整数,在Redis实例中,其实只保存了一份整数对象,可以节省内存空间。
|
||||
|
||||
基于这个特点,我建议你,在满足业务数据需求的前提下,能用整数时就尽量用整数,这样可以节省实例内存。
|
||||
|
||||
那什么时候不能用整数对象共享池呢?主要有两种情况。
|
||||
|
||||
第一种情况是,**如果Redis中设置了maxmemory,而且启用了LRU策略(allkeys-lru或volatile-lru策略),那么,整数对象共享池就无法使用了**。这是因为,LRU策略需要统计每个键值对的使用时间,如果不同的键值对都共享使用一个整数对象,LRU策略就无法进行统计了。
|
||||
|
||||
第二种情况是,如果集合类型数据采用ziplist编码,而集合元素是整数,这个时候,也不能使用共享池。因为ziplist使用了紧凑型内存结构,判断整数对象的共享情况效率低。
|
||||
|
||||
好了,到这里,我们了解了和键值对使用相关的四种规范,遵循这四种规范,最直接的好处就是可以节省内存空间。接下来,我们再来了解下,在实际保存数据时,该遵循哪些规范。
|
||||
|
||||
## 数据保存规范
|
||||
|
||||
### 规范一:使用Redis保存热数据
|
||||
|
||||
为了提供高性能访问,Redis是把所有数据保存到内存中的。
|
||||
|
||||
虽然Redis支持使用RDB快照和AOF日志持久化保存数据,但是,这两个机制都是用来提供数据可靠性保证的,并不是用来扩充数据容量的。而且,内存成本本身就比较高,如果把业务数据都保存在Redis中,会带来较大的内存成本压力。
|
||||
|
||||
所以,一般来说,在实际应用Redis时,我们会更多地把它作为缓存保存热数据,这样既可以充分利用Redis的高性能特性,还可以把宝贵的内存资源用在服务热数据上,就是俗话说的“好钢用在刀刃上”。
|
||||
|
||||
### 规范二:不同的业务数据分实例存储
|
||||
|
||||
虽然我们可以使用key的前缀把不同业务的数据区分开,但是,如果所有业务的数据量都很大,而且访问特征也不一样,我们把这些数据保存在同一个实例上时,这些数据的操作就会相互干扰。
|
||||
|
||||
你可以想象这样一个场景:假如数据采集业务使用Redis保存数据时,以写操作为主,而用户统计业务使用Redis时,是以读查询为主,如果这两个业务数据混在一起保存,读写操作相互干扰,肯定会导致业务响应变慢。
|
||||
|
||||
那么,**我建议你把不同的业务数据放到不同的 Redis 实例中**。这样一来,既可以避免单实例的内存使用量过大,也可以避免不同业务的操作相互干扰。
|
||||
|
||||
### 规范三:在数据保存时,要设置过期时间
|
||||
|
||||
对于Redis来说,内存是非常宝贵的资源,而且,Redis通常用于保存热数据。热数据一般都有使用的时效性。
|
||||
|
||||
所以,在数据保存时,我建议你根据业务使用数据的时长,设置数据的过期时间。不然的话,写入Redis的数据会一直占用内存,如果数据持续增多,就可能达到机器的内存上限,造成内存溢出,导致服务崩溃。
|
||||
|
||||
### 规范四:控制Redis实例的容量
|
||||
|
||||
Redis单实例的内存大小都不要太大,根据我自己的经验值,建议你设置在 2~6GB 。这样一来,无论是RDB快照,还是主从集群进行数据同步,都能很快完成,不会阻塞正常请求的处理。
|
||||
|
||||
## 命令使用规范
|
||||
|
||||
最后,我们再来看下在使用Redis命令时要遵守什么规范。
|
||||
|
||||
### 规范一:线上禁用部分命令
|
||||
|
||||
Redis 是单线程处理请求操作,如果我们执行一些涉及大量操作、耗时长的命令,就会严重阻塞主线程,导致其它请求无法得到正常处理,这类命令主要有3种。
|
||||
|
||||
- **KEYS**,按照键值对的key内容进行匹配,返回符合匹配条件的键值对,该命令需要对Redis的全局哈希表进行全表扫描,严重阻塞Redis主线程;
|
||||
- **FLUSHALL**,删除Redis实例上的所有数据,如果数据量很大,会严重阻塞Redis主线程;
|
||||
- **FLUSHDB**,删除当前数据库中的数据,如果数据量很大,同样会阻塞Redis主线程。
|
||||
|
||||
所以,我们在线上应用Redis时,就需要禁用这些命令。**具体的做法是,管理员用rename-command命令在配置文件中对这些命令进行重命名,让客户端无法使用这些命令**。
|
||||
|
||||
当然,你还可以使用其它命令替代这3个命令。
|
||||
|
||||
- 对于KEYS命令来说,你可以用SCAN命令代替KEYS命令,分批返回符合条件的键值对,避免造成主线程阻塞;
|
||||
- 对于FLUSHALL、FLUSHDB命令来说,你可以加上ASYNC选项,让这两个命令使用后台线程异步删除数据,可以避免阻塞主线程。
|
||||
|
||||
### 规范二:慎用MONITOR命令
|
||||
|
||||
Redis的MONITOR命令在执行后,会持续输出监测到的各个命令操作,所以,我们通常会用MONITOR命令返回的结果,检查命令的执行情况。
|
||||
|
||||
但是,MONITOR命令会把监控到的内容持续写入输出缓冲区。如果线上命令的操作很多,输出缓冲区很快就会溢出了,这就会对Redis性能造成影响,甚至引起服务崩溃。
|
||||
|
||||
所以,除非十分需要监测某些命令的执行(例如,Redis性能突然变慢,我们想查看下客户端执行了哪些命令),你可以偶尔在短时间内使用下MONITOR命令,否则,我建议你不要使用MONITOR命令。
|
||||
|
||||
### 规范三:慎用全量操作命令
|
||||
|
||||
对于集合类型的数据来说,如果想要获得集合中的所有元素,一般不建议使用全量操作的命令(例如Hash类型的HGETALL、Set类型的SMEMBERS)。这些操作会对Hash和Set类型的底层数据结构进行全量扫描,如果集合类型数据较多的话,就会阻塞Redis主线程。
|
||||
|
||||
如果想要获得集合类型的全量数据,我给你三个小建议。
|
||||
|
||||
- 第一个建议是,你可以使用SSCAN、HSCAN命令分批返回集合中的数据,减少对主线程的阻塞。
|
||||
- 第二个建议是,你可以化整为零,把一个大的Hash集合拆分成多个小的Hash集合。这个操作对应到业务层,就是对业务数据进行拆分,按照时间、地域、用户ID等属性把一个大集合的业务数据拆分成多个小集合数据。例如,当你统计用户的访问情况时,就可以按照天的粒度,把每天的数据作为一个Hash集合。
|
||||
- 最后一个建议是,如果集合类型保存的是业务数据的多个属性,而每次查询时,也需要返回这些属性,那么,你可以使用String类型,将这些属性序列化后保存,每次直接返回String数据就行,不用再对集合类型做全量扫描了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我围绕Redis应用时的高性能访问和节省内存空间这两个目标,分别在键值对使用、命令使用和数据保存三方面向你介绍了11个规范。
|
||||
|
||||
我按照强制、推荐、建议这三个类别,把这些规范分了下类,如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/fd/f2513c69d7757830e7f3e3c831fcdcfd.jpg" alt="">
|
||||
|
||||
我来解释一下这3个类别的规范。
|
||||
|
||||
- 强制类别的规范:这表示,如果不按照规范内容来执行,就会给Redis的应用带来极大的负面影响,例如性能受损。
|
||||
- 推荐类别的规范:这个规范的内容能有效提升性能、节省内存空间,或者是增加开发和运维的便捷性,你可以直接应用到实践中。
|
||||
- 建议类别的规范:这类规范内容和实际业务应用相关,我只是从我的经历或经验给你一个建议,你需要结合自己的业务场景参考使用。
|
||||
|
||||
我再多说一句,你一定要熟练掌握这些使用规范,并且真正地把它们应用到你的Redis使用场景中,提高Redis的使用效率。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,你在日常应用Redis时,有遵循过什么好的使用规范吗?
|
||||
|
||||
欢迎在留言区分享一下你常用的使用规范,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
213
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(四) | Redis客户端如何与服务器端交换命令和数据?.md
Normal file
213
极客时间专栏/Redis核心技术与实战/加餐篇/加餐(四) | Redis客户端如何与服务器端交换命令和数据?.md
Normal file
@@ -0,0 +1,213 @@
|
||||
<audio id="audio" title="加餐(四) | Redis客户端如何与服务器端交换命令和数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/34/f35b889aaba2673aedec5379yyf6d434.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在前面的课程中,我们主要学习了Redis服务器端的机制和关键技术,很少涉及到客户端的问题。但是,Redis采用的是典型的client-server(服务器端-客户端)架构,客户端会发送请求给服务器端,服务器端会返回响应给客户端。
|
||||
|
||||
如果要对Redis客户端进行二次开发(比如增加新的命令),我们就需要了解请求和响应涉及的命令、数据在客户端和服务器之间传输时,是如何编码的。否则,我们在客户端新增的命令就无法被服务器端识别和处理。
|
||||
|
||||
Redis使用RESP(REdis Serialization Protocol)协议定义了客户端和服务器端交互的命令、数据的编码格式。在Redis 2.0版本中,RESP协议正式成为客户端和服务器端的标准通信协议。从Redis 2.0 到Redis 5.0,RESP协议都称为RESP 2协议,从Redis 6.0开始,Redis就采用RESP 3协议了。不过,6.0版本是在今年5月刚推出的,所以,目前我们广泛使用的还是RESP 2协议。
|
||||
|
||||
这节课,我就向你重点介绍下RESP 2协议的规范要求,以及RESP 3相对RESP 2的改进之处。
|
||||
|
||||
首先,我们先来看下客户端和服务器端交互的内容包括哪些,毕竟,交互内容不同,编码形式也不一样。
|
||||
|
||||
## 客户端和服务器端交互的内容有哪些?
|
||||
|
||||
为了方便你更加清晰地理解,RESP 2协议是如何对命令和数据进行格式编码的,我们可以把交互内容,分成客户端请求和服务器端响应两类:
|
||||
|
||||
- 在客户端请求中,客户端会给Redis发送命令,以及要写入的键和值;
|
||||
- 而在服务器端响应中,Redis实例会返回读取的值、OK标识、成功写入的元素个数、错误信息,以及命令(例如Redis Cluster中的MOVE命令)。
|
||||
|
||||
其实,这些交互内容还可以再进一步细分成七类,我们再来了解下它们。
|
||||
|
||||
1. **命令**:这就是针对不同数据类型的操作命令。例如对String类型的SET、GET操作,对Hash类型的HSET、HGET等,这些命令就是代表操作语义的字符串。
|
||||
1. **键**:键值对中的键,可以直接用字符串表示。
|
||||
1. **单个值**:对应String类型的数据,数据本身可以是字符串、数值(整数或浮点数),布尔值(True或是False)等。
|
||||
1. **集合值**:对应List、Hash、Set、Sorted Set类型的数据,不仅包含多个值,而且每个值也可以是字符串、数值或布尔值等。
|
||||
1. **OK回复**:对应命令操作成功的结果,就是一个字符串的“OK”。
|
||||
1. **整数回复**:这里有两种情况。一种是,命令操作返回的结果是整数,例如LLEN命令返回列表的长度;另一种是,集合命令成功操作时,实际操作的元素个数,例如SADD命令返回成功添加的元素个数。
|
||||
1. **错误信息**:命令操作出错时的返回结果,包括“error”标识,以及具体的错误信息。
|
||||
|
||||
了解了这7类内容都是什么,下面我再结合三个具体的例子,帮助你进一步地掌握这些交互内容。
|
||||
|
||||
先看第一个例子,来看看下面的命令:
|
||||
|
||||
```
|
||||
#成功写入String类型数据,返回OK
|
||||
127.0.0.1:6379> SET testkey testvalue
|
||||
OK
|
||||
|
||||
```
|
||||
|
||||
这里的交互内容就包括了**命令**(SET命令)、**键(<strong>String类型的键testkey)和**单个值</strong>(String类型的值testvalue),而服务器端则直接返回一个**OK回复**。
|
||||
|
||||
第二个例子是执行HSET命令:
|
||||
|
||||
```
|
||||
#成功写入Hash类型数据,返回实际写入的集合元素个数
|
||||
127.0.0.1:6379>HSET testhash a 1 b 2 c 3
|
||||
(integer) 3
|
||||
|
||||
```
|
||||
|
||||
这里的交互内容包括三个key-value的Hash**集合值**(a 1 b 2 c 3),而服务器端返回**整数回复**(3),表示操作成功写入的元素个数。
|
||||
|
||||
最后一个例子是执行PUT命令,如下所示:
|
||||
|
||||
```
|
||||
#发送的命令不对,报错,并返回错误信息
|
||||
127.0.0.1:6379>PUT testkey2 testvalue
|
||||
(error) ERR unknown command 'PUT', with args beginning with: 'testkey', 'testvalue'
|
||||
|
||||
```
|
||||
|
||||
可以看到,这里的交互内容包括**错误信息,**这是因为,Redis实例本身不支持PUT命令,所以服务器端报错“error”,并返回具体的错误信息,也就是未知的命令“put”。
|
||||
|
||||
好了,到这里,你了解了,Redis客户端和服务器端交互的内容。接下来,我们就来看下,RESP 2是按照什么样的格式规范来对这些内容进行编码的。
|
||||
|
||||
## RESP 2的编码格式规范
|
||||
|
||||
RESP 2协议的设计目标是,希望Redis开发人员实现客户端时简单方便,这样就可以减少客户端开发时出现的Bug。而且,当客户端和服务器端交互出现问题时,希望开发人员可以通过查看协议交互过程,能快速定位问题,方便调试。为了实现这一目标,RESP 2协议采用了可读性很好的文本形式进行编码,也就是通过一系列的字符串,来表示各种命令和数据。
|
||||
|
||||
不过,交互内容有多种,而且,实际传输的命令或数据也会有很多个。针对这两种情况,RESP 2协议在编码时设计了两个基本规范。
|
||||
|
||||
1. 为了对不同类型的交互内容进行编码,RESP 2协议实现了5种编码格式类型。同时,为了区分这5种编码类型,RESP 2使用一个专门的字符,作为每种编码类型的开头字符。这样一来,客户端或服务器端在对编码后的数据进行解析时,就可以直接通过开头字符知道当前解析的编码类型。
|
||||
1. RESP 2进行编码时,会按照单个命令或单个数据的粒度进行编码,并在每个编码结果后面增加一个换行符“\r\n”(有时也表示成CRLF),表示一次编码结束。
|
||||
|
||||
接下来,我就来分别介绍下这5种编码类型。
|
||||
|
||||
**1.简单字符串类型(RESP Simple Strings)**
|
||||
|
||||
这种类型就是用一个字符串来进行编码,比如,请求操作在服务器端成功执行后的OK标识回复,就是用这种类型进行编码的。
|
||||
|
||||
当服务器端成功执行一个操作后,返回的OK标识就可以编码如下:
|
||||
|
||||
```
|
||||
+OK\r\n
|
||||
|
||||
```
|
||||
|
||||
**2.长字符串类型(RESP Bulk String)**
|
||||
|
||||
这种类型是用一个二进制安全的字符串来进行编码。这里的二进制安全,其实是相对于C语言中对字符串的处理方式来说的。我来具体解释一下。
|
||||
|
||||
Redis在解析字符串时,不会像C语言那样,使用“`\0`”判定一个字符串的结尾,Redis会把 “`\0`”解析成正常的0字符,并使用额外的属性值表示字符串的长度。
|
||||
|
||||
举个例子,对于“Redis\0Cluster\0”这个字符串来说,C语言会解析为“Redis”,而Redis会解析为“Redis Cluster”,并用len属性表示字符串的真实长度是14字节,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/7a/b4e98e2ecf00b42098a790cec363fc7a.jpg" alt="">
|
||||
|
||||
这样一来,字符串中即使存储了“`\0`”字符,也不会导致Redis解析到“`\0`”时,就认为字符串结束了从而停止解析,这就保证了数据的安全性。和长字符串类型相比,简单字符串就是非二进制安全的。
|
||||
|
||||
长字符串类型最大可以达到512MB,所以可以对很大的数据量进行编码,正好可以满足键值对本身的数据量需求,所以,RESP 2就用这种类型对交互内容中的键或值进行编码,并且使用“`$`”字符作为开头字符,`$`字符后面会紧跟着一个数字,这个数字表示字符串的实际长度。
|
||||
|
||||
例如,我们使用GET命令读取一个键(假设键为testkey)的值(假设值为testvalue)时,服务端返回的String值编码结果如下,其中,`$`字符后的9,表示数据长度为9个字符。
|
||||
|
||||
```
|
||||
$9 testvalue\r\n
|
||||
|
||||
```
|
||||
|
||||
**3.整数类型(RESP Integer)**
|
||||
|
||||
这种类型也还是一个字符串,但是表示的是一个有符号64位整数。为了和包含数字的简单字符串类型区分开,整数类型使用“`:`”字符作为开头字符,可以用于对服务器端返回的整数回复进行编码。
|
||||
|
||||
例如,在刚才介绍的例子中,我们使用HSET命令设置了testhash的三个元素,服务器端实际返回的编码结果如下:
|
||||
|
||||
```
|
||||
:3\r\n
|
||||
|
||||
```
|
||||
|
||||
**4.错误类型(RESP Errors)**
|
||||
|
||||
它是一个字符串,包括了错误类型和具体的错误信息。Redis服务器端报错响应就是用这种类型进行编码的。RESP 2使用“`-`”字符作为它的开头字符。
|
||||
|
||||
例如,在刚才的例子中,我们在redis-cli执行PUT testkey2 testvalue命令报错,服务器端实际返回给客户端的报错编码结果如下:
|
||||
|
||||
```
|
||||
-ERR unknown command `PUT`, with args beginning with: `testkey`, `testvalue`
|
||||
|
||||
```
|
||||
|
||||
其中,ERR就是报错类型,表示是一个通用错误,ERR后面的文字内容就是具体的报错信息。
|
||||
|
||||
**5.数组编码类型(RESP Arrays)**
|
||||
|
||||
这是一个包含多个元素的数组,其中,元素的类型可以是刚才介绍的这4种编码类型。
|
||||
|
||||
在客户端发送请求和服务器端返回结果时,数组编码类型都能用得上。客户端在发送请求操作时,一般会同时包括命令和要操作的数据。而数组类型包含了多个元素,所以,就适合用来对发送的命令和数据进行编码。为了和其他类型区分,RESP 2使用“`*`”字符作为开头字符。
|
||||
|
||||
例如,我们执行命令GET testkey,此时,客户端发送给服务器端的命令的编码结果就是使用数组类型编码的,如下所示:
|
||||
|
||||
```
|
||||
*2\r\n$3\r\nGET\r\n$7\r\ntestkey\r\n
|
||||
|
||||
```
|
||||
|
||||
其中,**第一个`*`字符标识当前是数组类型的编码结果**,2表示该数组有2个元素,分别对应命令GET和键testkey。命令GET和键testkey,都是使用长字符串类型编码的,所以用`$`字符加字符串长度来表示。
|
||||
|
||||
类似地,当服务器端返回包含多个元素的集合类型数据时,也会用`*`字符和元素个数作为标识,并用长字符串类型对返回的集合元素进行编码。
|
||||
|
||||
好了,到这里,你了解了RESP 2协议的5种编码类型和相应的开头字符,我在下面的表格里做了小结,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/ce/4658d36cdb64a846fe1732a29c45b3ce.jpg" alt="">
|
||||
|
||||
Redis 6.0中使用了RESP 3协议,对RESP 2.0做了改进,我们来学习下具体都有哪些改进。
|
||||
|
||||
## RESP 2的不足和RESP 3的改进
|
||||
|
||||
虽然我们刚刚说RESP 2协议提供了5种编码类型,看起来很丰富,其实是不够的。毕竟,基本数据类型还包括很多种,例如浮点数、布尔值等。编码类型偏少,会带来两个问题。
|
||||
|
||||
一方面,在值的基本数据类型方面,RESP 2只能区分字符串和整数,对于其他的数据类型,客户端使用RESP 2协议时,就需要进行额外的转换操作。例如,当一个浮点数用字符串表示时,客户端需要将字符串中的值和实际数字值比较,判断是否为数字值,然后再将字符串转换成实际的浮点数。
|
||||
|
||||
另一方面,RESP 2用数组类别编码表示所有的集合类型,但是,Redis的集合类型包括了List、Hash、Set和Sorted Set。当客户端接收到数组类型编码的结果时,还需要根据调用的命令操作接口,来判断返回的数组究竟是哪一种集合类型。
|
||||
|
||||
我来举个例子。假设有一个Hash类型的键是testhash,集合元素分别为a:1、b:2、c:3。同时,有一个Sorted Set类型的键是testzset,集合元素分别是a、b、c,它们的分数分别是1、2、3。我们在redis-cli客户端中读取它们的结果时,返回的形式都是一个数组,如下所示:
|
||||
|
||||
```
|
||||
127.0.0.1:6379>HGETALL testhash
|
||||
1) "a"
|
||||
2) "1"
|
||||
3) "b"
|
||||
4) "2"
|
||||
5) "c"
|
||||
6) "3"
|
||||
|
||||
127.0.0.1:6379>ZRANGE testzset 0 3 withscores
|
||||
1) "a"
|
||||
2) "1"
|
||||
3) "b"
|
||||
4) "2"
|
||||
5) "c"
|
||||
6) "3"
|
||||
|
||||
```
|
||||
|
||||
为了在客户端按照Hash和Sorted Set两种类型处理代码中返回的数据,客户端还需要根据发送的命令操作HGETALL和ZRANGE,来把这两个编码的数组结果转换成相应的Hash集合和有序集合,增加了客户端额外的开销。
|
||||
|
||||
从Redis 6.0版本开始,RESP 3协议增加了对多种数据类型的支持,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。RESP 3也是通过不同的开头字符来区分不同的数据类型,例如,当开头第一个字符是“`,`”,就表示接下来的编码结果是浮点数。这样一来,客户端就不用再通过额外的字符串比对,来实现数据转换操作了,提升了客户端的效率。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了RESP 2协议。这个协议定义了Redis客户端和服务器端进行命令和数据交互时的编码格式。RESP 2提供了5种类型的编码格式,包括简单字符串类型、长字符串类型、整数类型、错误类型和数组类型。为了区分这5种类型,RESP 2协议使用了5种不同的字符作为这5种类型编码结果的第一个字符,分别是`+`、 `$`、:、-和*。
|
||||
|
||||
RESP 2协议是文本形式的协议,实现简单,可以减少客户端开发出现的Bug,而且可读性强,便于开发调试。当你需要开发定制化的Redis客户端时,就需要了解和掌握RESP 2协议。
|
||||
|
||||
RESP 2协议的一个不足就是支持的类型偏少,所以,Redis 6.0版本使用了RESP 3协议。和RESP 2协议相比,RESP 3协议增加了对浮点数、布尔类型、有序字典集合、无序集合等多种类型数据的支持。不过,这里,有个地方需要你注意,Redis 6.0只支持RESP 3,对RESP 2协议不兼容,所以,如果你使用Redis 6.0版本,需要确认客户端已经支持了RESP 3协议,否则,将无法使用Redis 6.0。
|
||||
|
||||
最后,我也给你提供一个小工具。如果你想查看服务器端返回数据的RESP 2编码结果,就可以使用telnet命令和redis实例连接,执行如下命令就行:
|
||||
|
||||
```
|
||||
telnet 实例IP 实例端口
|
||||
|
||||
```
|
||||
|
||||
接着,你可以给实例发送命令,这样就能看到用RESP 2协议编码后的返回结果了。当然,你也可以在telnet中,向Redis实例发送用RESP 2协议编写的命令操作,实例同样能处理,你可以课后试试看。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,假设Redis实例中有一个List类型的数据,key为mylist,value是使用LPUSH命令写入List集合的5个元素,依次是1、2、3.3、4、hello,当执行LRANGE mylist 0 4命令时,实例返回给客户端的编码结果是怎样的?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
157
极客时间专栏/Redis核心技术与实战/基础篇/01 | 基本架构:一个键值数据库包含什么?.md
Normal file
157
极客时间专栏/Redis核心技术与实战/基础篇/01 | 基本架构:一个键值数据库包含什么?.md
Normal file
@@ -0,0 +1,157 @@
|
||||
<audio id="audio" title="01 | 基本架构:一个键值数据库包含什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/7e/7da7fdf657935e079f566210e99f7d7e.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们知道,Redis是典型的键值数据库,所以今天,我准备手把手地带你构建一个简单的键值数据库。为啥要这么做呢?
|
||||
|
||||
还记得我在开篇词说过吗?Redis本身比较复杂,如果我们一上来就直接研究一个个具体的技术点,比如“单线程”“缓存”等,虽然可以直接学习到具体的内容,甚至立马就能解决一些小问题,但是这样学,很容易迷失在细枝末节里。
|
||||
|
||||
从我自己的经验来看,更好的学习方式就是先建立起“**系统观**”。这也就是说,如果我们想要深入理解和优化Redis,就必须要对它的总体架构和关键模块有一个全局的认知,然后再深入到具体的技术点。这也是我们这门课坚持的一种讲课方式。
|
||||
|
||||
我相信,经过这样一个过程,我们在实践中定位和解决问题时,就会轻松很多,而且你还可以把这个学习方式迁移到其他的学习活动上。我希望你能彻底掌握这个学习思路,让自己的学习、工作效率更高。
|
||||
|
||||
说远了,还是回到我们今天的课程主题上。今天,在构造这个简单的键值数据库时,我们只需要关注整体架构和核心模块。这就相当于医学上在正式解剖人体之前,会先解剖一只小白鼠。我们通过剖析这个最简单的键值数据库,来迅速抓住学习和调优Redis的关键。
|
||||
|
||||
我把这个简单的键值数据库称为SimpleKV。需要注意的是,GitHub上也有一个名为SimpleKV的项目,这跟我说的SimpleKV不是一回事,我说的只是一个具有关键组件的键值数据库架构。
|
||||
|
||||
好了,你是不是已经准备好了,那我们就一起来构造SimpleKV吧。
|
||||
|
||||
开始构造SimpleKV时,首先就要考虑里面可以存什么样的数据,对数据可以做什么样的操作,也就是数据模型和操作接口。它们看似简单,实际上却是我们理解Redis经常被用于缓存、秒杀、分布式锁等场景的重要基础。
|
||||
|
||||
理解了数据模型,你就会明白,为什么在有些场景下,原先使用关系型数据库保存的数据,也可以用键值数据库保存。例如,用户信息(用户ID、姓名、年龄、性别等)通常用关系型数据库保存,在这个场景下,一个用户ID对应一个用户信息集合,这就是键值数据库的一种数据模型,它同样能完成这一存储需求。
|
||||
|
||||
但是,如果你只知道数据模型,而不了解操作接口的话,可能就无法理解,为什么在有些场景中,使用键值数据库又不合适了。例如,同样是在上面的场景中,如果你要对多个用户的年龄计算均值,键值数据库就无法完成了。因为它只提供简单的操作接口,无法支持复杂的聚合计算。
|
||||
|
||||
那么,对于Redis来说,它到底能做什么,不能做什么呢?只有先搞懂它的数据模型和操作接口,我们才能真正把“这块好钢用在刀刃上”。
|
||||
|
||||
接下来,我们就先来看可以存哪些数据。
|
||||
|
||||
## 可以存哪些数据?
|
||||
|
||||
对于键值数据库而言,基本的数据模型是key-value模型。 例如,“hello”: “world”就是一个基本的KV对,其中,“hello”是key,“world”是value。SimpleKV也不例外。在SimpleKV中,key是String类型,而value是基本数据类型,例如String、整型等。
|
||||
|
||||
但是,SimpleKV毕竟是一个简单的键值数据库,对于实际生产环境中的键值数据库来说,value类型还可以是复杂类型。
|
||||
|
||||
不同键值数据库支持的key类型一般差异不大,而value类型则有较大差别。我们在对键值数据库进行选型时,一个重要的考虑因素是**它支持的value类型**。例如,Memcached支持的value类型仅为String类型,而Redis支持的value类型包括了String、哈希表、列表、集合等。**Redis能够在实际业务场景中得到广泛的应用,就是得益于支持多样化类型的value**。
|
||||
|
||||
从使用的角度来说,不同value类型的实现,不仅可以支撑不同业务的数据需求,而且也隐含着不同数据结构在性能、空间效率等方面的差异,从而导致不同的value操作之间存在着差异。
|
||||
|
||||
只有深入地理解了这背后的原理,我们才能在选择Redis value类型和优化Redis性能时,做到游刃有余。
|
||||
|
||||
## 可以对数据做什么操作?
|
||||
|
||||
知道了数据模型,接下来,我们就要看它对数据的基本操作了。SimpleKV是一个简单的键值数据库,因此,基本操作无外乎增删改查。
|
||||
|
||||
我们先来了解下SimpleKV需要支持的3种基本操作,即PUT、GET和DELETE。
|
||||
|
||||
- PUT:新写入或更新一个key-value对;
|
||||
- GET:根据一个key读取相应的value值;
|
||||
- DELETE:根据一个key删除整个key-value对。
|
||||
|
||||
需要注意的是,**有些键值数据库的新写/更新操作叫SET**。新写入和更新虽然是用一个操作接口,但在实际执行时,会根据key是否存在而执行相应的新写或更新流程。
|
||||
|
||||
在实际的业务场景中,我们经常会碰到这种情况:查询一个用户在一段时间内的访问记录。这种操作在键值数据库中属于SCAN操作,即**根据一段key的范围返回相应的value值**。因此,**PUT/GET/DELETE/SCAN是一个键值数据库的基本操作集合**。
|
||||
|
||||
此外,实际业务场景通常还有更加丰富的需求,例如,在黑白名单应用中,需要判断某个用户是否存在。如果将该用户的ID作为key,那么,可以增加EXISTS操作接口,用于判断某个key是否存在。对于一个具体的键值数据库而言,你可以通过查看操作文档,了解其详细的操作接口。
|
||||
|
||||
当然,当一个键值数据库的value类型多样化时,就需要包含相应的操作接口。例如,Redis的value有列表类型,因此它的接口就要包括对列表value的操作。后面我也会具体介绍,不同操作对Redis访问效率的影响。
|
||||
|
||||
说到这儿呢,数据模型和操作接口我们就构造完成了,这是我们的基础工作。接下来呢,我们就要更进一步,考虑一个非常重要的设计问题:**键值对保存在内存还是外存?**
|
||||
|
||||
保存在内存的好处是读写很快,毕竟内存的访问速度一般都在百ns级别。但是,潜在的风险是一旦掉电,所有的数据都会丢失。
|
||||
|
||||
保存在外存,虽然可以避免数据丢失,但是受限于磁盘的慢速读写(通常在几ms级别),键值数据库的整体性能会被拉低。
|
||||
|
||||
因此,**如何进行设计选择,我们通常需要考虑键值数据库的主要应用场景**。比如,缓存场景下的数据需要能快速访问但允许丢失,那么,用于此场景的键值数据库通常采用内存保存键值数据。Memcached和Redis都是属于内存键值数据库。对于Redis而言,缓存是非常重要的一个应用场景。后面我会重点介绍Redis作为缓存使用的关键机制、优势,以及常见的优化方法。
|
||||
|
||||
为了和Redis保持一致,我们的SimpleKV就采用内存保存键值数据。接下来,我们来了解下SimpleKV的基本组件。
|
||||
|
||||
大体来说,一个键值数据库包括了**访问框架、索引模块、操作模块和存储模块**四部分(见下图)。接下来,我们就从这四个部分入手,继续构建我们的SimpleKV。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/d5/ec18bf4b8afef2fa8b99af252d95a2d5.jpg" alt="">
|
||||
|
||||
## 采用什么访问模式?
|
||||
|
||||
访问模式通常有两种:一种是**通过函数库调用的方式供外部应用使用**,比如,上图中的libsimplekv.so,就是以动态链接库的形式链接到我们自己的程序中,提供键值存储功能;另一种是**通过网络框架以Socket通信的形式对外提供键值对操作**,这种形式可以提供广泛的键值存储服务。在上图中,我们可以看到,网络框架中包括Socket Server和协议解析。
|
||||
|
||||
不同的键值数据库服务器和客户端交互的协议并不相同,我们在对键值数据库进行二次开发、新增功能时,必须要了解和掌握键值数据库的通信协议,这样才能开发出兼容的客户端。
|
||||
|
||||
实际的键值数据库也基本采用上述两种方式,例如,RocksDB以动态链接库的形式使用,而Memcached和Redis则是通过网络框架访问。后面我还会给你介绍Redis现有的客户端和通信协议。
|
||||
|
||||
通过网络框架提供键值存储服务,一方面扩大了键值数据库的受用面,但另一方面,也给键值数据库的性能、运行模型提供了不同的设计选择,带来了一些潜在的问题。
|
||||
|
||||
举个例子,当客户端发送一个如下的命令后,该命令会被封装在网络包中发送给键值数据库:
|
||||
|
||||
```
|
||||
PUT hello world
|
||||
|
||||
```
|
||||
|
||||
键值数据库网络框架接收到网络包,并按照相应的协议进行解析之后,就可以知道,客户端想写入一个键值对,并开始实际的写入流程。此时,我们会遇到一个系统设计上的问题,简单来说,就是网络连接的处理、网络请求的解析,以及数据存取的处理,是用一个线程、多个线程,还是多个进程来交互处理呢?该如何进行设计和取舍呢?我们一般把这个问题称为I/O模型设计。不同的I/O模型对键值数据库的性能和可扩展性会有不同的影响。
|
||||
|
||||
举个例子,如果一个线程既要处理网络连接、解析请求,又要完成数据存取,一旦某一步操作发生阻塞,整个线程就会阻塞住,这就降低了系统响应速度。如果我们采用不同线程处理不同操作,那么,某个线程被阻塞时,其他线程还能正常运行。但是,不同线程间如果需要访问共享资源,那又会产生线程竞争,也会影响系统效率,这又该怎么办呢?所以,这的确是个“两难”选择,需要我们进行精心的设计。
|
||||
|
||||
你可能经常听说Redis是单线程,那么,Redis又是如何做到“单线程,高性能”的呢?后面我再和你好好聊一聊。
|
||||
|
||||
## 如何定位键值对的位置?
|
||||
|
||||
当SimpleKV解析了客户端发来的请求,知道了要进行的键值对操作,此时,SimpleKV需要查找所要操作的键值对是否存在,这依赖于键值数据库的索引模块。**索引的作用是让键值数据库根据key找到相应value的存储位置,进而执行操作**。
|
||||
|
||||
索引的类型有很多,常见的有哈希表、B+树、字典树等。不同的索引结构在性能、空间消耗、并发控制等方面具有不同的特征。如果你看过其他键值数据库,就会发现,不同键值数据库采用的索引并不相同,例如,Memcached和Redis采用哈希表作为key-value索引,而RocksDB则采用跳表作为内存中key-value的索引。
|
||||
|
||||
一般而言,内存键值数据库(例如Redis)采用哈希表作为索引,很大一部分原因在于,其键值数据基本都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表O(1)的操作复杂度相匹配。
|
||||
|
||||
SimpleKV的索引根据key找到value的存储位置即可。但是,和SimpleKV不同,对于Redis而言,很有意思的一点是,它的value支持多种类型,当我们通过索引找到一个key所对应的value后,仍然需要从value的复杂结构(例如集合和列表)中进一步找到我们实际需要的数据,这个操作的效率本身就依赖于它们的实现结构。
|
||||
|
||||
Redis采用一些常见的高效索引结构作为某些value类型的底层数据结构,这一技术路线为Redis实现高性能访问提供了良好的支撑。
|
||||
|
||||
## 不同操作的具体逻辑是怎样的?
|
||||
|
||||
SimpleKV的索引模块负责根据key找到相应的value的存储位置。对于不同的操作来说,找到存储位置之后,需要进一步执行的操作的具体逻辑会有所差异。SimpleKV的操作模块就实现了不同操作的具体逻辑:
|
||||
|
||||
- 对于GET/SCAN操作而言,此时根据value的存储位置返回value值即可;
|
||||
- 对于PUT一个新的键值对数据而言,SimpleKV需要为该键值对分配内存空间;
|
||||
- 对于DELETE操作,SimpleKV需要删除键值对,并释放相应的内存空间,这个过程由分配器完成。
|
||||
|
||||
不知道你注意到没有,对于PUT和DELETE两种操作来说,除了新写入和删除键值对,还需要分配和释放内存。这就不得不提SimpleKV的存储模块了。
|
||||
|
||||
## 如何实现重启后快速提供服务?
|
||||
|
||||
SimpleKV采用了常用的内存分配器glibc的malloc和free,因此,SimpleKV并不需要特别考虑内存空间的管理问题。但是,键值数据库的键值对通常大小不一,glibc的分配器在处理随机的大小内存块分配时,表现并不好。一旦保存的键值对数据规模过大,就可能会造成较严重的内存碎片问题。
|
||||
|
||||
因此,分配器是键值数据库中的一个关键因素。对于以内存存储为主的Redis而言,这点尤为重要。Redis的内存分配器提供了多种选择,分配效率也不一样,后面我会具体讲一讲这个问题。
|
||||
|
||||
SimpleKV虽然依赖于内存保存数据,提供快速访问,但是,我也希望SimpleKV重启后能快速重新提供服务,所以,我在SimpleKV的存储模块中增加了持久化功能。
|
||||
|
||||
不过,鉴于磁盘管理要比内存管理复杂,SimpleKV就直接采用了文件形式,将键值数据通过调用本地文件系统的操作接口保存在磁盘上。此时,SimpleKV只需要考虑何时将内存中的键值数据保存到文件中,就可以了。
|
||||
|
||||
一种方式是,对于每一个键值对,SimpleKV都对其进行落盘保存,这虽然让SimpleKV的数据更加可靠,但是,因为每次都要写盘,SimpleKV的性能会受到很大影响。
|
||||
|
||||
另一种方式是,SimpleKV只是周期性地把内存中的键值数据保存到文件中,这样可以避免频繁写盘操作的性能影响。但是,一个潜在的代价是SimpleKV的数据仍然有丢失的风险。
|
||||
|
||||
和SimpleKV一样,Redis也提供了持久化功能。不过,为了适应不同的业务场景,Redis为持久化提供了诸多的执行机制和优化改进,后面我会和你逐一介绍Redis在持久化机制中的关键设计考虑。
|
||||
|
||||
## 小结
|
||||
|
||||
至此,我们构造了一个简单的键值数据库SimpleKV。可以看到,前面两步我们是从应用的角度进行设计的,也就是应用视角;后面四步其实就是SimpleKV完整的内部构造,可谓是麻雀虽小,五脏俱全。
|
||||
|
||||
SimpleKV包含了一个键值数据库的基本组件,对这些组件有了了解之后,后面在学习Redis这个丰富版的SimpleKV时,就会轻松很多。
|
||||
|
||||
为了支持更加丰富的业务场景,Redis对这些组件或者功能进行了扩展,或者说是进行了精细优化,从而满足了功能和性能等方面的要求。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/44/30e0e0eb0b475e6082dd14e63c13ed44.jpg" alt="">
|
||||
|
||||
从这张对比图中,我们可以看到,从SimpleKV演进到Redis,有以下几个重要变化:
|
||||
|
||||
- Redis主要通过网络框架进行访问,而不再是动态库了,这也使得Redis可以作为一个基础性的网络服务进行访问,扩大了Redis的应用范围。
|
||||
- Redis数据模型中的value类型很丰富,因此也带来了更多的操作接口,例如面向列表的LPUSH/LPOP,面向集合的SADD/SREM等。在下节课,我将和你聊聊这些value模型背后的数据结构和操作效率,以及它们对Redis性能的影响。
|
||||
- Redis的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到Redis的访问性能和可靠性。
|
||||
- SimpleKV是个简单的单机键值数据库,但是,Redis支持高可靠集群和高可扩展集群,因此,Redis中包含了相应的集群功能支撑模块。
|
||||
|
||||
通过这节课SimpleKV的构建,我相信你已经对键值数据库的基本结构和重要模块有了整体认知和深刻理解,这其实也是Redis单机版的核心基础。针对刚刚提到的几点Redis的重大演进,在接下来的课程中,我会依次进行重点讲解。与此同时,我还会结合实战场景,让你不仅能够理解原理,还能真正学以致用,提升实战能力。
|
||||
|
||||
## 每课一问
|
||||
|
||||
给你留个小问题:和你了解的Redis相比,你觉得,SimpleKV里面还缺少什么功能组件或模块吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
158
极客时间专栏/Redis核心技术与实战/基础篇/02 | 数据结构:快速的Redis有哪些慢操作?.md
Normal file
158
极客时间专栏/Redis核心技术与实战/基础篇/02 | 数据结构:快速的Redis有哪些慢操作?.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="02 | 数据结构:快速的Redis有哪些慢操作?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/64/a3/64793ee06a6fe2cdc1023189f5f538a3.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
一提到Redis,我们的脑子里马上就会出现一个词:“快。”但是你有没有想过,Redis的快,到底是快在哪里呢?实际上,这里有一个重要的表现:它接收到一个键值对操作后,能以**微秒级别**的速度找到数据,并快速完成操作。
|
||||
|
||||
数据库这么多,为啥Redis能有这么突出的表现呢?一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。另一方面,这要归功于它的数据结构。这是因为,键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是Redis快速处理数据的基础。这节课,我就来和你聊聊数据结构。
|
||||
|
||||
说到这儿,你肯定会说:“这个我知道,不就是String(字符串)、List(列表)、Hash(哈希)、Set(集合)和Sorted Set(有序集合)吗?”其实,这些只是Redis键值对中值的数据类型,也就是数据的保存形式。而这里,我们说的数据结构,是要去看看它们的底层实现。
|
||||
|
||||
简单来说,底层数据结构一共有6种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/01/8219f7yy651e566d47cc9f661b399f01.jpg" alt="">
|
||||
|
||||
可以看到,String类型的底层实现只有一种数据结构,也就是简单动态字符串。而List、Hash、Set和Sorted Set这四种数据类型,都有两种底层实现结构。通常情况下,我们会把这四种类型称为集合类型,它们的特点是**一个键对应了一个集合的数据**。
|
||||
|
||||
看到这里,其实有些问题已经值得我们去考虑了:
|
||||
|
||||
- 这些数据结构都是值的底层实现,键和值本身之间用什么结构组织?
|
||||
- 为什么集合类型有那么多的底层结构,它们都是怎么组织数据的,都很快吗?
|
||||
- 什么是简单动态字符串,和常用的字符串是一回事吗?
|
||||
|
||||
接下来,我就和你聊聊前两个问题。这样,你不仅可以知道Redis“快”的基本原理,还可以借此理解Redis中有哪些潜在的“慢操作”,最大化Redis的性能优势。而关于简单动态字符串,我会在后面的课程中再和你讨论。
|
||||
|
||||
我们先来看看键和值之间是用什么结构组织的。
|
||||
|
||||
## 键和值用什么结构组织?
|
||||
|
||||
为了实现从键到值的快速访问,Redis使用了一个哈希表来保存所有键值对。
|
||||
|
||||
一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
|
||||
|
||||
看到这里,你可能会问了:“如果值是集合类型的话,作为数组元素的哈希桶怎么来保存呢?”其实,哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是String,还是集合类型,哈希桶中的元素都是指向它们的指针。
|
||||
|
||||
在下图中,可以看到,哈希桶中的entry元素中保存了`*key`和`*value`指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过`*value`指针被查找到。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/5f/1cc8eaed5d1ca4e3cdbaa5a3d48dfb5f.jpg" alt="">
|
||||
|
||||
因为这个哈希表保存了所有的键值对,所以,我也把它称为**全局哈希表**。哈希表的最大好处很明显,就是让我们可以用O(1)的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的entry元素。
|
||||
|
||||
你看,这个查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说,不管哈希表里有10万个键还是100万个键,我们只需要一次计算就能找到相应的键。
|
||||
|
||||
但是,如果你只是了解了哈希表的O(1)复杂度和快速查找特性,那么,当你往Redis中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是**哈希表的冲突问题和rehash可能带来的操作阻塞。**
|
||||
|
||||
### 为什么哈希表操作变慢了?
|
||||
|
||||
当你往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,也就是指,两个key的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。
|
||||
|
||||
毕竟,哈希桶的个数通常要少于key的数量,这也就是说,难免会有一些key的哈希值对应到了同一个哈希桶中。
|
||||
|
||||
Redis解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指**同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接**。
|
||||
|
||||
如下图所示:entry1、entry2和entry3都需要保存在哈希桶3中,导致了哈希冲突。此时,entry1元素会通过一个`*next`指针指向entry2,同样,entry2也会通过`*next`指针指向entry3。这样一来,即使哈希桶3中的元素有100个,我们也可以通过entry元素中的指针,把它们连起来。这就形成了一个链表,也叫作哈希冲突链。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/28/8ac4cc6cf94968a502161f85d072e428.jpg" alt="">
|
||||
|
||||
但是,这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。对于追求“快”的Redis来说,这是不太能接受的。
|
||||
|
||||
所以,Redis会对哈希表做rehash操作。rehash也就是增加现有的哈希桶数量,让逐渐增多的entry元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。那具体怎么做呢?
|
||||
|
||||
其实,为了使rehash操作更高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据逐步增多,Redis开始执行rehash,这个过程分为三步:
|
||||
|
||||
1. 给哈希表2分配更大的空间,例如是当前哈希表1大小的两倍;
|
||||
1. 把哈希表1中的数据重新映射并拷贝到哈希表2中;
|
||||
1. 释放哈希表1的空间。
|
||||
|
||||
到此,我们就可以从哈希表1切换到哈希表2,用增大的哈希表2保存更多数据,而原来的哈希表1留作下一次rehash扩容备用。
|
||||
|
||||
这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表1中的数据都迁移完,会造成Redis线程阻塞,无法服务其他请求。此时,Redis就无法快速访问数据了。
|
||||
|
||||
为了避免这个问题,Redis采用了**渐进式rehash**。
|
||||
|
||||
简单来说就是在第二步拷贝数据时,Redis仍然正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有entries拷贝到哈希表2中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的entries。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/0c/73fb212d0b0928d96a0d7d6ayy76da0c.jpg" alt="" title="渐进式rehash">
|
||||
|
||||
这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
|
||||
|
||||
好了,到这里,你应该就能理解,Redis的键和值是怎么通过哈希表组织的了。对于String类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的O(1)操作复杂度也就是它的复杂度了。
|
||||
|
||||
但是,对于集合类型来说,即使找到哈希桶了,还要在集合中再进一步操作。接下来,我们来看集合类型的操作效率又是怎样的。
|
||||
|
||||
## 集合数据操作效率
|
||||
|
||||
和String类型不同,一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。那么,集合的操作效率和哪些因素相关呢?
|
||||
|
||||
首先,与集合的底层数据结构有关。例如,使用哈希表实现的集合,要比使用链表实现的集合访问效率更高。其次,操作效率和这些操作本身的执行特点有关,比如读写一个元素的操作要比读写所有元素的效率高。
|
||||
|
||||
接下来,我们就分别聊聊集合类型的底层数据结构和操作复杂度。
|
||||
|
||||
### 有哪些底层数据结构?
|
||||
|
||||
刚才,我也和你介绍过,集合类型的底层数据结构主要有5种:整数数组、双向链表、哈希表、压缩列表和跳表。
|
||||
|
||||
其中,哈希表的操作特点我们刚刚已经学过了;整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是O(N),操作效率比较低;压缩列表和跳表我们平时接触得可能不多,但它们也是Redis重要的数据结构,所以我来重点解释一下。
|
||||
|
||||
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量和列表中的entry个数;压缩列表在表尾还有一个zlend,表示列表结束。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/a0/9587e483f6ea82f560ff10484aaca4a0.jpg" alt="">
|
||||
|
||||
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是O(N)了。
|
||||
|
||||
我们再来看下跳表。
|
||||
|
||||
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,**增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位**,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/b4/1eca7135d38de2yy16681c2bbc4f3fb4.jpg" alt="" title="跳表的快速查找过程">
|
||||
|
||||
如果我们要在链表中查找33这个元素,只能从头开始遍历链表,查找6次,直到找到33为止。此时,复杂度是O(N),查找效率很低。
|
||||
|
||||
为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素1作为一级索引,从第三、四个元素中抽取元素11作为一级索引。此时,我们只需要4次查找就能定位到元素33了。
|
||||
|
||||
如果我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取1、27、100作为二级索引,二级索引指向一级索引。这样,我们只需要3次查找,就能定位到元素33了。
|
||||
|
||||
可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是O(logN)。
|
||||
|
||||
好了,我们现在可以按照查找的时间复杂度给这些数据结构分下类了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/f0/fb7e3612ddee8a0ea49b7c40673a0cf0.jpg" alt="">
|
||||
|
||||
### 不同操作的复杂度
|
||||
|
||||
集合类型的操作类型很多,有读写单个集合元素的,例如HGET、HSET,也有操作多个元素的,例如SADD,还有对整个集合进行遍历操作的,例如SMEMBERS。这么多操作,它们的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。
|
||||
|
||||
我总结了一个“四句口诀”,希望能帮助你快速记住集合常见操作的复杂度。这样你在使用过程中,就可以提前规避高复杂度操作了。
|
||||
|
||||
- 单元素操作是基础;
|
||||
- 范围操作非常耗时;
|
||||
- 统计操作通常高效;
|
||||
- 例外情况只有几个。
|
||||
|
||||
第一,**单元素操作,是指每一种集合类型对单个数据实现的增删改查操作**。例如,Hash类型的HGET、HSET和HDEL,Set类型的SADD、SREM、SRANDMEMBER等。这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET和HDEL是对哈希表做操作,所以它们的复杂度都是O(1);Set类型用哈希表作为底层数据结构时,它的SADD、SREM、SRANDMEMBER复杂度也是O(1)。
|
||||
|
||||
这里,有个地方你需要注意一下,集合类型支持同时对多个元素进行增删改查,例如Hash类型的HMGET和HMSET,Set类型的SADD也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET增加M个元素时,复杂度就从O(1)变成O(M)了。
|
||||
|
||||
第二,**范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据**,比如Hash类型的HGETALL和Set类型的SMEMBERS,或者返回一个范围内的部分数据,比如List类型的LRANGE和ZSet类型的ZRANGE。**这类操作的复杂度一般是O(N),比较耗时,我们应该尽量避免**。
|
||||
|
||||
不过,Redis从2.8版本开始提供了SCAN系列操作(包括HSCAN,SSCAN和ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于HGETALL、SMEMBERS这类操作来说,就避免了一次性返回所有元素而导致的Redis阻塞。
|
||||
|
||||
第三,统计操作,是指**集合类型对集合中所有元素个数的记录**,例如LLEN和SCARD。这类操作复杂度只有O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。
|
||||
|
||||
第四,例外情况,是指某些数据结构的特殊记录,例如**压缩列表和双向链表都会记录表头和表尾的偏移量**。这样一来,对于List类型的LPOP、RPOP、LPUSH、RPUSH这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有O(1),可以实现快速操作。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis的底层数据结构,这既包括了Redis中用来保存每个键和值的全局哈希表结构,也包括了支持集合类型实现的双向链表、压缩列表、整数数组、哈希表和跳表这五大底层结构。
|
||||
|
||||
Redis之所以能快速操作键值对,一方面是因为O(1)复杂度的哈希表被广泛使用,包括String、Hash和Set,它们的操作复杂度基本由哈希表决定,另一方面,Sorted Set也采用了O(logN)复杂度的跳表。不过,集合类型的范围操作,因为要遍历底层数据结构,复杂度通常是O(N)。这里,我的建议是:**用其他命令来替代**,例如可以用SCAN来代替,避免在Redis内部产生费时的全集合遍历操作。
|
||||
|
||||
当然,我们不能忘了复杂度较高的List类型,它的两种底层实现结构:双向链表和压缩列表的操作复杂度都是O(N)。因此,我的建议是:**因地制宜地使用List类型**。例如,既然它的POP/PUSH效率很高,那么就将它主要用于FIFO队列场景,而不是作为一个可以随机读写的集合。
|
||||
|
||||
Redis数据类型丰富,每个类型的操作繁多,我们通常无法一下子记住所有操作的复杂度。所以,最好的办法就是**掌握原理,以不变应万变**。这里,你可以看到,一旦掌握了数据结构基本原理,你可以从原理上推断不同操作的复杂度,即使这个操作你不一定熟悉。这样一来,你不用死记硬背,也能快速合理地做出选择了。
|
||||
|
||||
## 每课一问
|
||||
|
||||
整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么Redis还会把它们作为底层数据结构呢?
|
||||
|
||||
数据结构是了解Redis性能的必修课,如果你身边还有不太清楚数据结构的朋友,欢迎你把今天的内容分享给他/她,期待你在留言区和我交流讨论。
|
||||
113
极客时间专栏/Redis核心技术与实战/基础篇/03 | 高性能IO模型:为什么单线程Redis能那么快?.md
Normal file
113
极客时间专栏/Redis核心技术与实战/基础篇/03 | 高性能IO模型:为什么单线程Redis能那么快?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="03 | 高性能IO模型:为什么单线程Redis能那么快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/08/43/080cc05798a394b3d1f6e1fc764dc843.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
今天,我们来探讨一个很多人都很关心的问题:“为什么单线程的Redis能那么快?”
|
||||
|
||||
首先,我要和你厘清一个事实,我们通常说,Redis是单线程,主要是指**Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储服务的主要流程**。但Redis的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
|
||||
|
||||
所以,严格来说,Redis并不是单线程,但是我们一般把Redis称为单线程高性能,这样显得“酷”些。接下来,我也会把Redis称为单线程模式。而且,这也会促使你紧接着提问:“为什么用单线程?为什么单线程能这么快?”
|
||||
|
||||
要弄明白这个问题,我们就要深入地学习下Redis的单线程设计机制以及多路复用机制。之后你在调优Redis性能时,也能更有针对性地避免会导致Redis单线程阻塞的操作,例如执行复杂度高的命令。
|
||||
|
||||
好了,话不多说,接下来,我们就先来学习下Redis采用单线程的原因。
|
||||
|
||||
## Redis为什么用单线程?
|
||||
|
||||
要更好地理解Redis为什么用单线程,我们就要先了解多线程的开销。
|
||||
|
||||
### 多线程的开销
|
||||
|
||||
日常写程序时,我们经常会听到一种说法:“使用多线程,可以增加系统吞吐率,或是可以增加系统扩展性。”的确,对于一个多线程的系统来说,在有合理的资源分配的情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率。下面的左图是我们采用多线程时所期待的结果。
|
||||
|
||||
但是,请你注意,通常情况下,在我们采用多线程后,如果没有良好的系统设计,实际得到的结果,其实是右图所展示的那样。我们刚开始增加线程数时,系统吞吐率会增加,但是,再进一步增加线程时,系统吞吐率就增长迟缓了,有时甚至还会出现下降的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/33/cbd394e62219cc5a6d9ae64035e51733.jpg" alt="" title="线程数与系统吞吐率">
|
||||
|
||||
为什么会出现这种情况呢?一个关键的瓶颈在于,系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。
|
||||
|
||||
拿Redis来说,在上节课中,我提到过,Redis有List的数据类型,并提供出队(LPOP)和入队(LPUSH)操作。假设Redis采用多线程设计,如下图所示,现在有两个线程A和B,线程A对一个List做LPUSH操作,并对队列长度加1。同时,线程B对该List执行LPOP操作,并对队列长度减1。为了保证队列长度的正确性,Redis需要让线程A和B的LPUSH和LPOP串行执行,这样一来,Redis可以无误地记录它们对List长度的修改。否则,我们可能就会得到错误的长度结果。这就是**多线程编程模式面临的共享资源的并发访问控制问题**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/08/303255dcce6d0837bf7e2440df0f8e08.jpg" alt="" title="多线程并发访问Redis">
|
||||
|
||||
并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。
|
||||
|
||||
而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis直接采用了单线程模式。
|
||||
|
||||
讲到这里,你应该已经明白了“Redis为什么用单线程”,那么,接下来,我们就来看看,为什么单线程Redis能获得高性能。
|
||||
|
||||
## 单线程Redis为什么那么快?
|
||||
|
||||
通常来说,单线程的处理能力要比多线程差很多,但是Redis却能使用单线程模型达到每秒数十万级别的处理能力,这是为什么呢?其实,这是Redis多方面设计选择的一个综合结果。
|
||||
|
||||
一方面,Redis的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是Redis采用了**多路复用机制**,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。接下来,我们就重点学习下多路复用机制。
|
||||
|
||||
首先,我们要弄明白网络操作的基本IO模型和潜在的阻塞点。毕竟,Redis采用单线程进行IO,如果线程被阻塞了,就无法进行多路复用了。
|
||||
|
||||
### 基本IO模型与阻塞点
|
||||
|
||||
你还记得我在[第一节课](https://time.geekbang.org/column/article/268262)介绍的具有网络框架的SimpleKV吗?
|
||||
|
||||
以Get请求为例,SimpleKV为了处理一个Get请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从socket中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向socket中写回数据(send)。
|
||||
|
||||
下图显示了这一过程,其中,bind/listen、accept、recv、parse和send属于网络IO处理,而get属于键值数据操作。既然Redis是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/c9/e18499ab244e4428a0e60b4da6575bc9.jpg" alt="" title="Redis基本IO模型">
|
||||
|
||||
但是,在这里的网络IO操作中,有潜在的阻塞点,分别是accept()和recv()。当Redis监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在accept()函数这里,导致其他客户端无法和Redis建立连接。类似的,当Redis通过recv()从一个客户端读取数据时,如果数据一直没有到达,Redis也会一直阻塞在recv()。
|
||||
|
||||
这就导致Redis整个线程阻塞,无法处理其他客户端请求,效率很低。不过,幸运的是,socket网络模型本身支持非阻塞模式。
|
||||
|
||||
### 非阻塞模式
|
||||
|
||||
Socket网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用socket非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。接下来,我们就重点学习下它们。
|
||||
|
||||
在socket模型中,不同操作调用后会返回不同的套接字类型。socket()方法会返回主动套接字,然后调用listen()方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用accept()方法接收到达的客户端连接,并返回已连接套接字。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/4a/1ccc62ab3eb2a63c4965027b4248f34a.jpg" alt="" title="Redis套接字类型与非阻塞设置">
|
||||
|
||||
针对监听套接字,我们可以设置非阻塞模式:当Redis调用accept()但一直未有连接请求到达时,Redis线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用accept()时,已经存在监听套接字了。
|
||||
|
||||
虽然Redis线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知Redis。
|
||||
|
||||
类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis调用recv()后,如果已连接套接字上一直没有数据到达,Redis线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知Redis。
|
||||
|
||||
这样才能保证Redis线程,既不会像基本IO模型中一直在阻塞点等待,也不会导致Redis无法处理实际到达的连接请求或数据。
|
||||
|
||||
到此,Linux中的IO多路复用机制就要登场了。
|
||||
|
||||
### 基于多路复用的高性能I/O模型
|
||||
|
||||
Linux中的IO多路复用机制是指一个线程处理多个IO流,就是我们经常听到的select/epoll机制。简单来说,在Redis只运行单线程的情况下,**该机制允许内核中,同时存在多个监听套接字和已连接套接字**。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。
|
||||
|
||||
下图就是基于多路复用的Redis IO模型。图中的多个FD就是刚才所说的多个套接字。Redis网络框架调用epoll机制,让内核监听这些套接字。此时,Redis线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis可以同时和多个客户端连接并处理请求,从而提升并发性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/ea/00ff790d4f6225aaeeebba34a71d8bea.jpg" alt="" title="基于多路复用的Redis高性能IO模型">
|
||||
|
||||
为了在请求到达时能通知到Redis线程,select/epoll提供了**基于事件的回调机制**,即**针对不同事件的发生,调用相应的处理函数**。
|
||||
|
||||
那么,回调机制是怎么工作的呢?其实,select/epoll一旦监测到FD上有请求到达时,就会触发相应的事件。
|
||||
|
||||
这些事件会被放进一个事件队列,Redis单线程对该事件队列不断进行处理。这样一来,Redis无需一直轮询是否有请求实际发生,这就可以避免造成CPU资源浪费。同时,Redis在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为Redis一直在对事件队列进行处理,所以能及时响应客户端请求,提升Redis的响应性能。
|
||||
|
||||
为了方便你理解,我再以连接请求和读数据请求为例,具体解释一下。
|
||||
|
||||
这两个请求分别对应Accept事件和Read事件,Redis分别对这两个事件注册accept和get回调函数。当Linux内核监听到有连接请求或读数据请求时,就会触发Accept事件和Read事件,此时,内核就会回调Redis相应的accept和get函数进行处理。
|
||||
|
||||
这就像病人去医院瞧病。在医生实际诊断前,每个病人(等同于请求)都需要先分诊、测体温、登记等。如果这些工作都由医生来完成,医生的工作效率就会很低。所以,医院都设置了分诊台,分诊台会一直处理这些诊断前的工作(类似于Linux内核监听请求),然后再转交给医生做实际诊断。这样即使一个医生(相当于Redis单线程),效率也能提升。
|
||||
|
||||
不过,需要注意的是,即使你的应用场景中部署了不同的操作系统,多路复用机制也是适用的。因为这个机制的实现有很多种,既有基于Linux系统下的select和epoll实现,也有基于FreeBSD的kqueue实现,以及基于Solaris的evport实现,这样,你可以根据Redis实际运行的操作系统,选择相应的多路复用实现。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们重点学习了Redis线程的三个问题:“Redis真的只有单线程吗?”“为什么用单线程?”“单线程为什么这么快?”
|
||||
|
||||
现在,我们知道了,Redis单线程是指它对网络IO和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的Redis也能获得高性能,跟多路复用的IO模型密切相关,因为这避免了accept()和send()/recv()潜在的网络IO操作阻塞点。
|
||||
|
||||
搞懂了这些,你就走在了很多人的前面。如果你身边还有不清楚这几个问题的朋友,欢迎你分享给他/她,解决他们的困惑。
|
||||
|
||||
另外,我也剧透下,可能你也注意到了,2020年5月,Redis 6.0的稳定版发布了,Redis 6.0中提出了多线程模型。那么,这个多线程模型和这节课所说的IO模型有什么关联?会引入复杂的并发控制问题吗?会给Redis 6.0带来多大提升?关于这些问题,我会在后面的课程中和你具体介绍。
|
||||
|
||||
## 每课一问
|
||||
|
||||
这节课,我给你提个小问题,在“Redis基本IO模型”图中,你觉得还有哪些潜在的性能瓶颈吗?欢迎在留言区写下你的思考和答案,我们一起交流讨论。
|
||||
120
极客时间专栏/Redis核心技术与实战/基础篇/04 | AOF日志:宕机了,Redis如何避免数据丢失?.md
Normal file
120
极客时间专栏/Redis核心技术与实战/基础篇/04 | AOF日志:宕机了,Redis如何避免数据丢失?.md
Normal file
@@ -0,0 +1,120 @@
|
||||
<audio id="audio" title="04 | AOF日志:宕机了,Redis如何避免数据丢失?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/5c/8ea8a96310fb035d222657e917cc2b5c.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
如果有人问你:“你会把Redis用在什么业务场景下?”我想你大概率会说:“我会把它当作缓存使用,因为它把后端数据库中的数据存储在内存中,然后直接从内存中读取数据,响应速度会非常快。”没错,这确实是Redis的一个普遍使用场景,但是,这里也有一个绝对不能忽略的问题:**一旦服务器宕机,内存中的数据将全部丢失。**
|
||||
|
||||
我们很容易想到的一个解决方案是,从后端数据库恢复这些数据,但这种方式存在两个问题:一是,需要频繁访问数据库,会给数据库带来巨大的压力;二是,这些数据是从慢速数据库中读取出来的,性能肯定比不上从Redis中读取,导致使用这些数据的应用程序响应变慢。所以,对Redis来说,实现数据的持久化,避免从后端数据库中进行恢复,是至关重要的。
|
||||
|
||||
目前,Redis的持久化主要有两大机制,即AOF(Append Only File)日志和RDB快照。在接下来的两节课里,我们就分别学习一下吧。这节课,我们先重点学习下AOF日志。
|
||||
|
||||
## AOF日志是如何实现的?
|
||||
|
||||
说到日志,我们比较熟悉的是数据库的写前日志(Write Ahead Log, WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF日志正好相反,它是写后日志,“写后”的意思是Redis是先执行命令,把数据写入内存,然后才记录日志,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/1f/407f2686083afc37351cfd9107319a1f.jpg" alt="" title="Redis AOF操作过程">
|
||||
|
||||
那AOF为什么要先执行命令再记日志呢?要回答这个问题,我们要先知道AOF里记录了什么内容。
|
||||
|
||||
传统数据库的日志,例如redo log(重做日志),记录的是修改后的数据,而AOF里记录的是Redis收到的每一条命令,这些命令是以文本形式保存的。
|
||||
|
||||
我们以Redis收到“set testkey testvalue”命令后记录的日志为例,看看AOF日志的内容。其中,“`*3`”表示当前命令有三个部分,每部分都是由“`$+数字`”开头,后面紧跟着具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例如,“`$3 set`”表示这部分有3个字节,也就是“set”命令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/9f/4d120bee623642e75fdf1c0700623a9f.jpg" alt="" title="Redis AOF日志内容">
|
||||
|
||||
但是,为了避免额外的检查开销,Redis在向AOF里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis在使用日志恢复数据时,就可能会出错。
|
||||
|
||||
而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。
|
||||
|
||||
除此之外,AOF还有一个好处:它是在命令执行后才记录日志,所以**不会阻塞当前的写操作**。
|
||||
|
||||
不过,AOF也有两个潜在的风险。
|
||||
|
||||
首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时Redis是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果Redis是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。
|
||||
|
||||
其次,AOF虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。
|
||||
|
||||
仔细分析的话,你就会发现,这两个风险都是和AOF写回磁盘的时机相关的。这也就意味着,如果我们能够控制一个写命令执行完后AOF日志写回磁盘的时机,这两个风险就解除了。
|
||||
|
||||
## 三种写回策略
|
||||
|
||||
其实,对于这个问题,AOF机制给我们提供了三个选择,也就是AOF配置项appendfsync的三个可选值。
|
||||
|
||||
- **Always**,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
|
||||
- **Everysec**,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
|
||||
- **No**,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
|
||||
|
||||
针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美。我们来分析下其中的原因。
|
||||
|
||||
- “同步写回”可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能;
|
||||
- 虽然“操作系统控制的写回”在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在Redis手中了,只要AOF记录没有写回磁盘,一旦宕机对应的数据就丢失了;
|
||||
- “每秒写回”采用一秒写回一次的频率,避免了“同步写回”的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中。
|
||||
|
||||
我把这三种策略的写回时机,以及优缺点汇总在了一张表格里,以方便你随时查看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/f8/72f547f18dbac788c7d11yy167d7ebf8.jpg" alt="">
|
||||
|
||||
到这里,我们就可以根据系统对高性能和高可靠性的要求,来选择使用哪种写回策略了。总结一下就是:想要获得高性能,就选择No策略;如果想要得到高可靠性保证,就选择Always策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择Everysec策略。
|
||||
|
||||
但是,按照系统的性能需求选定了写回策略,并不是“高枕无忧”了。毕竟,AOF是以文件的形式在记录接收到的所有写命令。随着接收的写命令越来越多,AOF文件会越来越大。这也就意味着,我们一定要小心AOF文件过大带来的性能问题。
|
||||
|
||||
这里的“性能问题”,主要在于以下三个方面:一是,文件系统本身对文件大小有限制,无法保存过大的文件;二是,如果文件太大,之后再往里面追加命令记录的话,效率也会变低;三是,如果发生宕机,AOF中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到Redis的正常使用。
|
||||
|
||||
所以,我们就要采取一定的控制手段,这个时候,**AOF重写机制**就登场了。
|
||||
|
||||
## 日志文件太大了怎么办?
|
||||
|
||||
简单来说,AOF重写机制就是在重写时,Redis根据数据库的现状创建一个新的AOF文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。比如说,当读取了键值对“testkey”: “testvalue”之后,重写机制会记录set testkey testvalue这条命令。这样,当需要恢复时,可以重新执行该命令,实现“testkey”: “testvalue”的写入。
|
||||
|
||||
为什么重写机制可以把日志文件变小呢? 实际上,重写机制具有“多变一”功能。所谓的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令。
|
||||
|
||||
我们知道,AOF文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条写命令反复修改时,AOF文件会记录相应的多条命令。但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。
|
||||
|
||||
下面这张图就是一个例子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/08/6528c699fdcf40b404af57040bb8d208.jpg" alt="" title="AOF重写减少日志大小">
|
||||
|
||||
当我们对一个列表先后做了6次修改操作后,列表的最后状态是[“D”, “C”, “N”],此时,只用LPUSH u:list “N”, “C”, "D"这一条命令就能实现该数据的恢复,这就节省了五条命令的空间。对于被修改过成百上千次的键值对来说,重写能节省的空间当然就更大了。
|
||||
|
||||
不过,虽然AOF重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志都写回磁盘,仍然是一个非常耗时的过程。这时,我们就要继续关注另一个问题了:重写会不会阻塞主线程?
|
||||
|
||||
## AOF重写会阻塞吗?
|
||||
|
||||
和AOF日志由主线程写回不同,重写过程是由后台子进程bgrewriteaof来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。
|
||||
|
||||
我把重写的过程总结为“**一个拷贝,两处日志**”。
|
||||
|
||||
“一个拷贝”就是指,每次执行重写时,主线程fork出后台的bgrewriteaof子进程。此时,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。
|
||||
|
||||
“两处日志”又是什么呢?
|
||||
|
||||
因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指正在使用的AOF日志,Redis会把这个操作写到它的缓冲区。这样一来,即使宕机了,这个AOF日志的操作仍然是齐全的,可以用于恢复。
|
||||
|
||||
而第二处日志,就是指新的AOF重写日志。这个操作也会被写到重写日志的缓冲区。这样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会写入新的AOF文件,以保证数据库最新状态的记录。此时,我们就可以用新的AOF文件替代旧文件了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/e8/6b054eb1aed0734bd81ddab9a31d0be8.jpg" alt="" title="AOF非阻塞的重写过程">
|
||||
|
||||
总结来说,每次AOF重写时,Redis会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为Redis采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我向你介绍了Redis用于避免数据丢失的AOF方法。这个方法通过逐一记录操作命令,在恢复时再逐一执行命令的方式,保证了数据的可靠性。
|
||||
|
||||
这个方法看似“简单”,但也是充分考虑了对Redis性能的影响。总结来说,它提供了AOF日志的三种写回策略,分别是Always、Everysec和No,这三种策略在可靠性上是从高到低,而在性能上则是从低到高。
|
||||
|
||||
此外,为了避免日志文件过大,Redis还提供了AOF重写机制,直接根据数据库里数据的最新状态,生成这些数据的插入命令,作为新日志。这个过程通过后台线程完成,避免了对主线程的阻塞。
|
||||
|
||||
其中,三种写回策略体现了系统设计中的一个重要原则 ,即trade-off,或者称为“取舍”,指的就是在性能和可靠性保证之间做取舍。我认为,这是做系统设计和开发的一个关键哲学,我也非常希望,你能充分地理解这个原则,并在日常开发中加以应用。
|
||||
|
||||
不过,你可能也注意到了,落盘时机和重写机制都是在“记日志”这一过程中发挥作用的。例如,落盘时机的选择可以避免记日志时阻塞主线程,重写可以避免日志文件过大。但是,在“用日志”的过程中,也就是使用AOF进行故障恢复时,我们仍然需要把所有的操作记录都运行一遍。再加上Redis的单线程设计,这些命令操作只能一条一条按顺序执行,这个“重放”的过程就会很慢了。
|
||||
|
||||
那么,有没有既能避免数据丢失,又能更快地恢复的方法呢?当然有,那就是RDB快照了。下节课,我们就一起学习一下,敬请期待。
|
||||
|
||||
## 每课一问
|
||||
|
||||
这节课,我给你提两个小问题:
|
||||
|
||||
1. AOF日志重写的时候,是由bgrewriteaof子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?
|
||||
1. AOF重写也有一个重写日志,为什么它不共享使用AOF本身的日志呢?
|
||||
|
||||
希望你能好好思考一下这两个问题,欢迎在留言区分享你的答案。另外,也欢迎你把这节课的内容转发出去,和更多的人一起交流讨论。
|
||||
120
极客时间专栏/Redis核心技术与实战/基础篇/05 | 内存快照:宕机后,Redis如何实现快速恢复?.md
Normal file
120
极客时间专栏/Redis核心技术与实战/基础篇/05 | 内存快照:宕机后,Redis如何实现快速恢复?.md
Normal file
@@ -0,0 +1,120 @@
|
||||
<audio id="audio" title="05 | 内存快照:宕机后,Redis如何实现快速恢复?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/b5/d01c043fa6903cf91efea4e974e3a8b5.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
上节课,我们学习了Redis避免数据丢失的AOF方法。这个方法的好处,是每次执行只需要记录操作命令,需要持久化的数据量不大。一般而言,只要你采用的不是always的持久化策略,就不会对性能造成太大影响。
|
||||
|
||||
但是,也正因为记录的是操作命令,而不是实际的数据,所以,用AOF方法进行故障恢复的时候,需要逐一把操作日志都执行一遍。如果操作日志非常多,Redis就会恢复得很缓慢,影响到正常使用。这当然不是理想的结果。那么,还有没有既可以保证可靠性,还能在宕机时实现快速恢复的其他方法呢?
|
||||
|
||||
当然有了,这就是我们今天要一起学习的另一种持久化方法:**内存快照**。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记下来。
|
||||
|
||||
对Redis来说,它实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为RDB文件,其中,RDB就是Redis DataBase的缩写。
|
||||
|
||||
和AOF相比,RDB记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把RDB文件读入内存,很快地完成恢复。听起来好像很不错,但内存快照也并不是最优选项。为什么这么说呢?
|
||||
|
||||
我们还要考虑两个关键问题:
|
||||
|
||||
- 对哪些数据做快照?这关系到快照的执行效率问题;
|
||||
- 做快照时,数据还能被增删改吗?这关系到Redis是否被阻塞,能否同时正常处理请求。
|
||||
|
||||
这么说可能你还不太好理解,我还是拿拍照片来举例子。我们在拍照时,通常要关注两个问题:
|
||||
|
||||
- 如何取景?也就是说,我们打算把哪些人、哪些物拍到照片中;
|
||||
- 在按快门前,要记着提醒朋友不要乱动,否则拍出来的照片就模糊了。
|
||||
|
||||
你看,这两个问题是不是非常重要呢?那么,接下来,我们就来具体地聊一聊。先说“取景”问题,也就是我们对哪些数据做快照。
|
||||
|
||||
## 给哪些内存数据做快照?
|
||||
|
||||
Redis的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是**全量快照**,也就是说,把内存中的所有数据都记录到磁盘中,这就类似于给100个人拍合影,把每一个人都拍进照片里。这样做的好处是,一次性记录了所有数据,一个都不少。
|
||||
|
||||
当你给一个人拍照时,只用协调一个人就够了,但是,拍100人的大合影,却需要协调100个人的位置、状态,等等,这当然会更费时费力。同样,给内存的全量数据做快照,把它们全部写入磁盘也会花费很多时间。而且,全量数据越多,RDB文件就越大,往磁盘上写数据的时间开销就越大。
|
||||
|
||||
对于Redis而言,它的单线程模型就决定了,我们要尽量避免所有会阻塞主线程的操作,所以,针对任何操作,我们都会提一个灵魂之问:“它会阻塞主线程吗?”RDB文件的生成是否会阻塞主线程,这就关系到是否会降低Redis的性能。
|
||||
|
||||
Redis提供了两个命令来生成RDB文件,分别是save和bgsave。
|
||||
|
||||
- save:在主线程中执行,会导致阻塞;
|
||||
- bgsave:创建一个子进程,专门用于写入RDB文件,避免了主线程的阻塞,这也是Redis RDB文件生成的默认配置。
|
||||
|
||||
好了,这个时候,我们就可以通过bgsave命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对Redis的性能影响。
|
||||
|
||||
接下来,我们要关注的问题就是,在对内存数据做快照时,这些数据还能“动”吗? 也就是说,这些数据还能被修改吗? 这个问题非常重要,这是因为,如果数据能被修改,那就意味着Redis还能正常处理写操作。否则,所有写操作都得等到快照完了才能执行,性能一下子就降低了。
|
||||
|
||||
## 快照时数据能修改吗?
|
||||
|
||||
在给别人拍照时,一旦对方动了,那么这张照片就拍糊了,我们就需要重拍,所以我们当然希望对方保持不动。对于内存快照而言,我们也不希望数据“动”。
|
||||
|
||||
举个例子。我们在时刻t给内存做快照,假设内存数据量是4GB,磁盘的写入带宽是0.2GB/s,简单来说,至少需要20s(4/0.2 = 20)才能做完。如果在时刻t+5s时,一个还没有被写入磁盘的内存数据A,被修改成了A’,那么就会破坏快照的完整性,因为A’不是时刻t时的状态。因此,和拍照类似,我们在做快照时也不希望数据“动”,也就是不能被修改。
|
||||
|
||||
但是,如果快照执行期间数据不能被修改,是会有潜在问题的。对于刚刚的例子来说,在做快照的20s时间里,如果这4GB的数据都不能被修改,Redis就不能处理对这些数据的写操作,那无疑就会给业务服务造成巨大的影响。
|
||||
|
||||
你可能会想到,可以用bgsave避免阻塞啊。这里我就要说到一个常见的误区了,**避免阻塞和正常处理写操作并不是一回事**。此时,主线程的确没有阻塞,可以正常接收请求,但是,为了保证快照完整性,它只能处理读操作,因为不能修改正在执行快照的数据。
|
||||
|
||||
为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
|
||||
|
||||
简单来说,bgsave子进程是由主线程fork生成的,可以共享主线程的所有内存数据。bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入RDB文件。
|
||||
|
||||
此时,如果主线程对这些数据也都是读操作(例如图中的键值对A),那么,主线程和bgsave子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对C),那么,这块数据就会被复制一份,生成该数据的副本(键值对C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave子进程可以继续把原来的数据(键值对C)写入RDB文件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/58/a2e5a3571e200cb771ed8a1cd14d5558.jpg" alt="" title="写时复制机制保证快照期间数据可修改">
|
||||
|
||||
这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。
|
||||
|
||||
到这里,我们就解决了对“哪些数据做快照”以及“做快照时数据能否修改”这两大问题:Redis会使用bgsave对当前内存中的所有数据做快照,这个操作是子进程在后台完成的,这就允许主线程同时可以修改数据。
|
||||
|
||||
现在,我们再来看另一个问题:多久做一次快照?我们在拍照的时候,还有项技术叫“连拍”,可以记录人或物连续多个瞬间的状态。那么,快照也适合“连拍”吗?
|
||||
|
||||
## 可以每秒做一次快照吗?
|
||||
|
||||
对于快照来说,所谓“连拍”就是指连续地做快照。这样一来,快照的间隔时间变得很短,即使某一时刻发生宕机了,因为上一时刻快照刚执行,丢失的数据也不会太多。但是,这其中的快照间隔时间就很关键了。
|
||||
|
||||
如下图所示,我们先在T0时刻做了一次快照,然后又在T0+t时刻做了一次快照,在这期间,数据块5和9被修改了。如果在t这段时间内,机器宕机了,那么,只能按照T0时刻的快照进行恢复。此时,数据块5和9的修改值因为没有快照记录,就无法恢复了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/ab/711c873a61bafde79b25c110735289ab.jpg" alt="" title="快照机制下的数据丢失">
|
||||
|
||||
所以,要想尽可能恢复数据,t值就要尽可能小,t越小,就越像“连拍”。那么,t值可以小到什么程度呢,比如说是不是可以每秒做一次快照?毕竟,每次快照都是由bgsave子进程在后台执行,也不会阻塞主线程。
|
||||
|
||||
这种想法其实是错误的。虽然bgsave执行时不阻塞主线程,但是,**如果频繁地执行全量快照,也会带来两方面的开销**。
|
||||
|
||||
一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
|
||||
|
||||
另一方面,bgsave子进程需要通过fork操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁fork出bgsave子进程,这就会频繁阻塞主线程了(所以,在Redis中如果有一个bgsave在运行,就不会再启动第二个bgsave子进程)。那么,有什么其他好方法吗?
|
||||
|
||||
此时,我们可以做增量快照,所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。
|
||||
|
||||
在第一次做完全量快照后,T1和T2时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。但是,这么做的前提是,**我们需要记住哪些数据被修改了**。你可不要小瞧这个“记住”功能,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/a5/8a1d515269cd23595ee1813e8dff28a5.jpg" alt="" title="增量快照示意图">
|
||||
|
||||
如果我们对每一个键值对的修改,都做个记录,那么,如果有1万个被修改的键值对,我们就需要有1万条额外的记录。而且,有的时候,键值对非常小,比如只有32字节,而记录它被修改的元数据信息,可能就需要8字节,这样的画,为了“记住”修改,引入的额外空间开销比较大。这对于内存资源宝贵的Redis来说,有些得不偿失。
|
||||
|
||||
到这里,你可以发现,虽然跟AOF相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销,那么,还有什么方法既能利用RDB的快速恢复,又能以较小的开销做到尽量少丢数据呢?
|
||||
|
||||
Redis 4.0中提出了一个**混合使用AOF日志和内存快照**的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用AOF日志记录这期间的所有命令操作。
|
||||
|
||||
这样一来,快照不用很频繁地执行,这就避免了频繁fork对主线程的影响。而且,AOF日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
|
||||
|
||||
如下图所示,T1和T2时刻的修改,用AOF日志记录,等到第二次做全量快照时,就可以清空AOF日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/20/e4c5846616c19fe03dbf528437beb320.jpg" alt="" title="内存快照和AOF混合使用">
|
||||
|
||||
这个方法既能享受到RDB文件快速恢复的好处,又能享受到AOF只记录操作命令的简单优势,颇有点“鱼和熊掌可以兼得”的感觉,建议你在实践中用起来。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis用于避免数据丢失的内存快照方法。这个方法的优势在于,可以快速恢复数据库,也就是只需要把RDB文件直接读入内存,这就避免了AOF需要顺序、逐一重新执行操作命令带来的低效性能问题。
|
||||
|
||||
不过,内存快照也有它的局限性。它拍的是一张内存的“大合影”,不可避免地会耗时耗力。虽然,Redis设计了bgsave和写时复制方式,尽可能减少了内存快照对正常读写的影响,但是,频繁快照仍然是不太能接受的。而混合使用RDB和AOF,正好可以取两者之长,避两者之短,以较小的性能开销保证数据可靠性和性能。
|
||||
|
||||
最后,关于AOF和RDB的选择问题,我想再给你提三点建议:
|
||||
|
||||
- 数据不能丢失时,内存快照和AOF的混合使用是一个很好的选择;
|
||||
- 如果允许分钟级别的数据丢失,可以只使用RDB;
|
||||
- 如果只用AOF,优先使用everysec的配置选项,因为它在可靠性和性能之间取了一个平衡。
|
||||
|
||||
## 每课一问
|
||||
|
||||
我曾碰到过这么一个场景:我们使用一个2核CPU、4GB内存、500GB磁盘的云主机运行Redis,Redis数据库的数据量大小差不多是2GB,我们使用了RDB做持久化保证。当时Redis的运行负载以修改操作为主,写读比例差不多在8:2左右,也就是说,如果有100个请求,80个请求执行的是修改操作。你觉得,在这个场景下,用RDB做持久化有什么风险吗?你能帮着一起分析分析吗?
|
||||
|
||||
到这里,关于持久化我们就讲完了,这块儿内容是熟练掌握Redis的基础,建议你一定好好学习下这两节课。如果你觉得有收获,希望你能帮我分享给更多的人,帮助更多人解决持久化的问题。
|
||||
145
极客时间专栏/Redis核心技术与实战/基础篇/06 | 数据同步:主从库如何实现数据一致?.md
Normal file
145
极客时间专栏/Redis核心技术与实战/基础篇/06 | 数据同步:主从库如何实现数据一致?.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<audio id="audio" title="06 | 数据同步:主从库如何实现数据一致?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/6d/4a300dec58f09215c1c8798a45f5b56d.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
前两节课,我们学习了AOF和RDB,如果Redis发生了宕机,它们可以分别通过回放日志和重新读入RDB文件的方式恢复数据,从而保证尽量少丢失数据,提升可靠性。
|
||||
|
||||
不过,即使用了这两种方法,也依然存在服务不可用的问题。比如说,我们在实际使用时只运行了一个Redis实例,那么,如果这个实例宕机了,它在恢复期间,是无法服务新来的数据存取请求的。
|
||||
|
||||
那我们总说的Redis具有高可靠性,又是什么意思呢?其实,这里有两层含义:一是**数据尽量少丢失**,二是**服务尽量少中断**。AOF和RDB保证了前者,而对于后者,Redis的做法就是**增加副本冗余量**,将一份数据同时保存在多个实例上。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。
|
||||
|
||||
多实例保存同一份数据,听起来好像很不错,但是,我们必须要考虑一个问题:这么多副本,它们之间的数据如何保持一致呢?数据读写操作可以发给所有的实例吗?
|
||||
|
||||
实际上,Redis提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。
|
||||
|
||||
- **读操作**:主库、从库都可以接收;
|
||||
- **写操作**:首先到主库执行,然后,主库将写操作同步给从库。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/2f/809d6707404731f7e493b832aa573a2f.jpg" alt="" title="Redis主从库和读写分离">
|
||||
|
||||
那么,为什么要采用读写分离的方式呢?
|
||||
|
||||
你可以设想一下,如果在上图中,不管是主库还是从库,都能接收客户端的写操作,那么,一个直接的问题就是:如果客户端对同一个数据(例如k1)前后修改了三次,每一次的修改请求都发送到不同的实例上,在不同的实例上执行,那么,这个数据在这三个实例上的副本就不一致了(分别是v1、v2和v3)。在读取这个数据的时候,就可能读取到旧的值。
|
||||
|
||||
如果我们非要保持这个数据在三个实例上一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销,当然是不太能接受的。
|
||||
|
||||
而主从库模式一旦采用了读写分离,所有数据的修改只会在主库上进行,不用协调三个实例。主库有了最新的数据后,会同步给从库,这样,主从库的数据就是一致的。
|
||||
|
||||
那么,主从库同步是如何完成的呢?主库数据是一次性传给从库,还是分批同步?要是主从库间的网络断连了,数据还能保持一致吗?这节课,我就和你聊聊主从库同步的原理,以及应对网络断连风险的方案。
|
||||
|
||||
好了,我们先来看看主从库间的第一次同步是如何进行的,这也是Redis实例建立主从库模式后的规定动作。
|
||||
|
||||
## 主从库间如何进行第一次同步?
|
||||
|
||||
当我们启动多个Redis实例的时候,它们相互之间就可以通过replicaof(Redis 5.0之前使用slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。
|
||||
|
||||
例如,现在有实例1(ip:172.16.19.3)和实例2(ip:172.16.19.5),我们在实例2上执行以下这个命令后,实例2就变成了实例1的从库,并从实例1上复制数据:
|
||||
|
||||
```
|
||||
replicaof 172.16.19.3 6379
|
||||
|
||||
```
|
||||
|
||||
接下来,我们就要学习主从库间数据第一次同步的三个阶段了。你可以先看一下下面这张图,有个整体感知,接下来我再具体介绍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/a1/63d18fd41efc9635e7e9105ce1c33da1.jpg" alt="" title="主从库第一次同步的流程">
|
||||
|
||||
第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,**从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了**。
|
||||
|
||||
具体来说,从库给主库发送psync命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync命令包含了**主库的runID**和**复制进度offset**两个参数。
|
||||
|
||||
- runID,是每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的runID,所以将runID设为“?”。
|
||||
- offset,此时设为-1,表示第一次复制。
|
||||
|
||||
主库收到psync命令后,会用FULLRESYNC响应命令带上两个参数:主库runID和主库目前的复制进度offset,返回给从库。从库收到响应后,会记录下这两个参数。
|
||||
|
||||
这里有个地方需要注意,**FULLRESYNC响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库**。
|
||||
|
||||
在第二阶段,**主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载**。这个过程依赖于内存快照生成的RDB文件。
|
||||
|
||||
具体来说,主库执行bgsave命令,生成RDB文件,接着将文件发给从库。从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件。这是因为从库在通过replicaof命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。
|
||||
|
||||
在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的RDB文件中。为了保证主从库的数据一致性,主库会在内存中用专门的replication buffer,记录RDB文件生成后收到的所有写操作。
|
||||
|
||||
最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成RDB文件发送后,就会把此时replication buffer中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。
|
||||
|
||||
## 主从级联模式分担全量复制时的主库压力
|
||||
|
||||
通过分析主从库间第一次数据同步的过程,你可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成RDB文件和传输RDB文件。
|
||||
|
||||
如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于fork子进程生成RDB文件,进行数据全量同步。fork这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输RDB文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。那么,有没有好的解决方法可以分担主库压力呢?
|
||||
|
||||
其实是有的,这就是“主-从-从”模式。
|
||||
|
||||
在刚才介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以**通过“主-从-从”模式将主库生成RDB和传输RDB的压力,以级联的方式分散到从库上**。
|
||||
|
||||
简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行如下命令,让它们和刚才所选的从库,建立起主从关系。
|
||||
|
||||
```
|
||||
replicaof 所选从库的IP 6379
|
||||
|
||||
```
|
||||
|
||||
这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/45/403c2ab725dca8d44439f8994959af45.jpg" alt="" title="级联的“主-从-从”模式">
|
||||
|
||||
好了,到这里,我们了解了主从库间通过全量复制实现数据同步的过程,以及通过“主-从-从”模式分担主库压力的方式。那么,一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为**基于长连接的命令传播**,可以避免频繁建立连接的开销。
|
||||
|
||||
听上去好像很简单,但不可忽视的是,这个过程中存在着风险点,最常见的就是**网络断连或阻塞**。如果网络断连,主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致了,客户端就可能从从库读到旧数据。
|
||||
|
||||
接下来,我们就来聊聊网络断连后的解决办法。
|
||||
|
||||
## 主从库间网络断了怎么办?
|
||||
|
||||
在Redis 2.8之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。
|
||||
|
||||
从Redis 2.8开始,网络断了之后,主从库会采用增量复制的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。
|
||||
|
||||
那么,增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于repl_backlog_buffer这个缓冲区。我们先来看下它是如何用于增量命令的同步的。
|
||||
|
||||
当主从库断连后,主库会把断连期间收到的写操作命令,写入replication buffer,同时也会把这些操作命令也写入repl_backlog_buffer这个缓冲区。
|
||||
|
||||
repl_backlog_buffer是一个环形缓冲区,**主库会记录自己写到的位置,从库则会记录自己已经读到的位置**。
|
||||
|
||||
刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是master_repl_offset。主库接收的新写操作越多,这个值就会越大。
|
||||
|
||||
同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量slave_repl_offset也在不断增加。正常情况下,这两个偏移量基本相等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/37/13f26570a1b90549e6171ea24554b737.jpg" alt="" title="Redis repl_backlog_buffer的使用">
|
||||
|
||||
主从库的连接恢复之后,从库首先会给主库发送psync命令,并把自己当前的slave_repl_offset发给主库,主库会判断自己的master_repl_offset和slave_repl_offset之间的差距。
|
||||
|
||||
在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset会大于slave_repl_offset。此时,主库只用把master_repl_offset和slave_repl_offset之间的命令操作同步给从库就行。
|
||||
|
||||
就像刚刚示意图的中间部分,主库和从库之间相差了put d e和put d f两个操作,在增量复制时,主库只需要把它们同步给从库,就行了。
|
||||
|
||||
说到这里,我们再借助一张图,回顾下增量复制的流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/16/20e233bd30c3dacb0221yy0c77780b16.jpg" alt="" title="Redis增量复制流程">
|
||||
|
||||
不过,有一个地方我要强调一下,因为repl_backlog_buffer是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。**如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致**。
|
||||
|
||||
因此,我们要想办法避免这一情况,一般而言,我们可以调整**repl_backlog_size**这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 * 2,这也就是repl_backlog_size的最终值。
|
||||
|
||||
举个例子,如果主库每秒写入2000个操作,每个操作的大小为2KB,网络每秒能传输1000个操作,那么,有1000个操作需要缓冲起来,这就至少需要2MB的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压力,我们最终把repl_backlog_size设为4MB。
|
||||
|
||||
这样一来,增量复制时主从库的数据不一致风险就降低了。不过,如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时,主从库数据仍然可能不一致。
|
||||
|
||||
针对这种情况,一方面,你可以根据Redis所在服务器的内存资源再适当增加repl_backlog_size值,比如说设置成缓冲空间大小的4倍,另一方面,你可以考虑使用切片集群来分担单个主库的请求压力。关于切片集群,我会在第9讲具体介绍。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们一起学习了Redis的主从库同步的基本原理,总结来说,有三种模式:全量复制、基于长连接的命令传播,以及增量复制。
|
||||
|
||||
全量复制虽然耗时,但是对于从库来说,如果是第一次同步,全量复制是无法避免的,所以,我给你一个小建议:**一个Redis实例的数据库不要太大**,一个实例大小在几GB级别比较合适,这样可以减少RDB文件生成、传输和重新加载的开销。另外,为了避免多个从库同时和主库进行全量复制,给主库过大的同步压力,我们也可以采用“主-从-从”这一级联模式,来缓解主库的压力。
|
||||
|
||||
长连接复制是主从库正常运行后的常规同步阶段。在这个阶段中,主从库之间通过命令传播实现同步。不过,这期间如果遇到了网络断连,增量复制就派上用场了。我特别建议你留意一下repl_backlog_size这个配置参数。如果它配置得过小,在增量复制阶段,可能会导致从库的复制进度赶不上主库,进而导致从库重新进行全量复制。所以,通过调大这个参数,可以减少从库在网络断连时全量复制的风险。
|
||||
|
||||
不过,主从库模式使用读写分离虽然避免了同时写多个实例带来的数据不一致问题,但是还面临主库故障的潜在风险。主库故障了从库该怎么办,数据还能保持一致吗,Redis还能正常提供服务吗?在接下来的两节课里,我会和你具体聊聊主库故障后,保证服务可靠性的解决方案。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提一个小问题。这节课,我提到,主从库间的数据复制同步使用的是RDB文件,前面我们学习过,AOF记录的操作命令更全,相比于RDB丢失的数据更少。那么,为什么主从库间的复制不使用AOF呢?
|
||||
|
||||
好了,这节课就到这里,如果你觉得有收获,欢迎你帮我把今天的内容分享给你的朋友。
|
||||
151
极客时间专栏/Redis核心技术与实战/基础篇/07 | 哨兵机制:主库挂了,如何不间断服务?.md
Normal file
151
极客时间专栏/Redis核心技术与实战/基础篇/07 | 哨兵机制:主库挂了,如何不间断服务?.md
Normal file
@@ -0,0 +1,151 @@
|
||||
<audio id="audio" title="07 | 哨兵机制:主库挂了,如何不间断服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/e6/945ca5b0f9dfe89efdbb6e730f9535e6.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
上节课,我们学习了主从库集群模式。在这个模式下,如果从库发生故障了,客户端可以继续向主库或其他从库发送请求,进行相关的操作,但是如果主库发生故障了,那就直接会影响到从库的同步,因为从库没有相应的主库可以进行数据复制操作了。
|
||||
|
||||
而且,如果客户端发送的都是读操作请求,那还可以由从库继续提供服务,这在纯读的业务场景下还能被接受。但是,一旦有写操作请求了,按照主从库模式下的读写分离要求,需要由主库来完成写操作。此时,也没有实例可以来服务客户端的写操作请求了,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/20/d828d7eee133cec690dc140e99e26f20.jpg" alt="" title="主库故障后从库无法服务写操作">
|
||||
|
||||
无论是写服务中断,还是从库无法进行数据同步,都是不能接受的。所以,如果主库挂了,我们就需要运行一个新主库,比如说把一个从库切换为主库,把它当成主库。这就涉及到三个问题:
|
||||
|
||||
1. 主库真的挂了吗?
|
||||
1. 该选择哪个从库作为主库?
|
||||
1. 怎么把新主库的相关信息通知给从库和客户端呢?
|
||||
|
||||
这就要提到哨兵机制了。在Redis主从集群中,哨兵机制是实现主从库自动切换的关键机制,它有效地解决了主从复制模式下故障转移的这三个问题。
|
||||
|
||||
接下来,我们就一起学习下哨兵机制。
|
||||
|
||||
## 哨兵机制的基本流程
|
||||
|
||||
哨兵其实就是一个运行在特殊模式下的Redis进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。
|
||||
|
||||
我们先看监控。监控是指哨兵进程在运行时,周期性地给所有的主从库发送PING命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的PING命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的PING命令,哨兵就会判定主库下线,然后开始**自动切换主库**的流程。
|
||||
|
||||
这个流程首先是执行哨兵的第二个任务,选主。主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。
|
||||
|
||||
然后,哨兵会执行最后一个任务:通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行replicaof命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。
|
||||
|
||||
我画了一张图片,展示了这三个任务以及它们各自的目标。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/a1/efcfa517d0f09d057be7da32a84cf2a1.jpg" alt="" title="哨兵机制的三项任务与目标">
|
||||
|
||||
在这三个任务中,通知任务相对来说比较简单,哨兵只需要把新主库信息发给从库和客户端,让它们和新主库建立连接就行,并不涉及决策的逻辑。但是,在监控和选主这两个任务中,哨兵需要做出两个决策:
|
||||
|
||||
- 在监控任务中,哨兵需要判断主库是否处于下线状态;
|
||||
- 在选主任务中,哨兵也要决定选择哪个从库实例作为主库。
|
||||
|
||||
接下来,我们就先说说如何判断主库的下线状态。
|
||||
|
||||
你首先要知道的是,哨兵对主库的下线判断有“主观下线”和“客观下线”两种。那么,为什么会存在两种判断呢?它们的区别和联系是什么呢?
|
||||
|
||||
## 主观下线和客观下线
|
||||
|
||||
我先解释下什么是“主观下线”。
|
||||
|
||||
**哨兵进程会使用PING命令检测它自己和主、从库的网络连接情况,用来判断实例的状态**。如果哨兵发现主库或从库对PING命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。
|
||||
|
||||
如果检测的是从库,那么,哨兵简单地把它标记为“主观下线”就行了,因为从库的下线影响一般不太大,集群的对外服务不会间断。
|
||||
|
||||
但是,如果检测的是主库,那么,哨兵还不能简单地把它标记为“主观下线”,开启主从切换。因为很有可能存在这么一个情况:那就是哨兵误判了,其实主库并没有故障。可是,一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。
|
||||
|
||||
为了避免这些不必要的开销,我们要特别注意误判的情况。
|
||||
|
||||
首先,我们要知道啥叫误判。很简单,就是主库实际并没有下线,但是哨兵误以为它下线了。误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下。
|
||||
|
||||
一旦哨兵判断主库下线了,就会开始选择新主库,并让从库和新主库进行数据同步,这个过程本身就会有开销,例如,哨兵要花时间选出新主库,从库也需要花时间和新主库同步。而在误判的情况下,主库本身根本就不需要进行切换的,所以这个过程的开销是没有价值的。正因为这样,我们需要判断是否有误判,以及减少误判。
|
||||
|
||||
那怎么减少误判呢?在日常生活中,当我们要对一些重要的事情做判断的时候,经常会和家人或朋友一起商量一下,然后再做决定。
|
||||
|
||||
哨兵机制也是类似的,它**通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群**。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。
|
||||
|
||||
这节课,你只需要先理解哨兵集群在减少误判方面的作用,就行了。至于具体的运行机制,下节课我们再重点学习。
|
||||
|
||||
在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。
|
||||
|
||||
为了方便你理解,我再画一张图展示一下这里的逻辑。
|
||||
|
||||
如下图所示,Redis主从集群有一个主库、三个从库,还有三个哨兵实例。在图片的左边,哨兵2判断主库为“主观下线”,但哨兵1和3却判定主库是上线状态,此时,主库仍然被判断为处于上线状态。在图片的右边,哨兵1和2都判断主库为“主观下线”,此时,即使哨兵3仍然判断主库为上线状态,主库也被标记为“客观下线”了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/0d/1945703abf16ee14e2f7559873e4e60d.jpg" alt="" title="客观下线的判断">
|
||||
|
||||
简单来说,“客观下线”的标准就是,当有N个哨兵实例时,最好要有N/2 + 1个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。这样一来,就可以减少误判的概率,也能避免误判带来的无谓的主从库切换。(当然,有多少个实例做出“主观下线”的判断才可以,可以由Redis管理员自行设定)。
|
||||
|
||||
好了,到这里,你可以看到,借助于多个哨兵实例的共同判断机制,我们就可以更准确地判断出主库是否处于下线状态。如果主库的确下线了,哨兵就要开始下一个决策过程了,即从许多从库中,选出一个从库来做新主库。
|
||||
|
||||
## 如何选定新主库?
|
||||
|
||||
一般来说,我把哨兵选择新主库的过程称为“筛选+打分”。简单来说,我们在多个从库中,先按照**一定的筛选条件**,把不符合条件的从库去掉。然后,我们再按照**一定的规则**,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/4c/f2e9b8830db46d959daa6a39fbf4a14c.jpg" alt="" title="新主库的选择过程">
|
||||
|
||||
在刚刚的这段话里,需要注意的是两个“一定”,现在,我们要考虑这里的“一定”具体是指什么。
|
||||
|
||||
首先来看筛选的条件。
|
||||
|
||||
一般情况下,我们肯定要先保证所选的从库仍然在线运行。不过,在选主时从库正常在线,这只能表示从库的现状良好,并不代表它就是最适合做主库的。
|
||||
|
||||
设想一下,如果在选主时,一个从库正常运行,我们把它选为新主库开始使用了。可是,很快它的网络出了故障,此时,我们就得重新选主了。这显然不是我们期望的结果。
|
||||
|
||||
所以,在选主时,**除了要检查从库的当前在线状态,还要判断它之前的网络连接状态**。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。
|
||||
|
||||
具体怎么判断呢?你使用配置项down-after-milliseconds * 10。其中,down-after-milliseconds是我们认定主从库断连的最大连接超时时间。如果在down-after-milliseconds毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了10次,就说明这个从库的网络状况不好,不适合作为新主库。
|
||||
|
||||
好了,这样我们就过滤掉了不适合做主库的从库,完成了筛选工作。
|
||||
|
||||
接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是**从库优先级、从库复制进度以及从库ID号**。只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。
|
||||
|
||||
**第一轮:优先级最高的从库得分高。**
|
||||
|
||||
用户可以通过slave-priority配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。
|
||||
|
||||
**第二轮:和旧主库同步程度最接近的从库得分高。**
|
||||
|
||||
这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。
|
||||
|
||||
如何判断从库和旧主库间的同步进度呢?
|
||||
|
||||
上节课我向你介绍过,主从库同步时有个命令传播的过程。在这个过程中,主库会用master_repl_offset记录当前的最新写操作在repl_backlog_buffer中的位置,而从库会用slave_repl_offset这个值记录当前的复制进度。
|
||||
|
||||
此时,我们想要找的从库,它的slave_repl_offset需要最接近master_repl_offset。如果在所有从库中,有从库的slave_repl_offset最接近master_repl_offset,那么它的得分就最高,可以作为新主库。
|
||||
|
||||
就像下图所示,旧主库的master_repl_offset是1000,从库1、2和3的slave_repl_offset分别是950、990和900,那么,从库2就应该被选为新主库。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/62/df/626yy88853a2d15b5196b922367140df.jpg" alt="" title="基于复制进度的新主库选主原则">
|
||||
|
||||
当然,如果有两个从库的slave_repl_offset值大小是一样的(例如,从库1和从库2的slave_repl_offset值都是990),我们就需要给它们进行第三轮打分了。
|
||||
|
||||
**第三轮:ID号小的从库得分高。**
|
||||
|
||||
每个实例都会有一个ID,这个ID就类似于这里的从库的编号。目前,Redis在选主库时,有一个默认的规定:**在优先级和复制进度都相同的情况下,ID号最小的从库得分最高,会被选为新主库**。
|
||||
|
||||
到这里,新主库就被选出来了,“选主”这个过程就完成了。
|
||||
|
||||
我们再回顾下这个流程。首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID号大小再对剩余的从库进行打分,只要有得分最高的从库出现,就把它选为新主库。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们一起学习了哨兵机制,它是实现Redis不间断服务的重要保证。具体来说,主从集群的数据同步,是数据可靠的基础保证;而在主库发生故障时,自动的主从切换是服务不间断的关键支撑。
|
||||
|
||||
Redis的哨兵机制自动完成了以下三大功能,从而实现了主从库的自动切换,可以降低Redis集群的运维开销:
|
||||
|
||||
- 监控主库运行状态,并判断主库是否客观下线;
|
||||
- 在主库客观下线后,选取新主库;
|
||||
- 选出新主库后,通知从库和客户端。
|
||||
|
||||
为了降低误判率,在实际应用时,哨兵机制通常采用多实例的方式进行部署,多个哨兵实例通过“少数服从多数”的原则,来判断主库是否客观下线。一般来说,我们可以部署三个哨兵,如果有两个哨兵认定主库“主观下线”,就可以开始切换过程。当然,如果你希望进一步提升判断准确率,也可以再适当增加哨兵个数,比如说使用五个哨兵。
|
||||
|
||||
但是,使用多个哨兵实例来降低误判率,其实相当于组成了一个哨兵集群,我们会因此面临着一些新的挑战,例如:
|
||||
|
||||
- 哨兵集群中有实例挂了,怎么办,会影响主库状态判断和选主吗?
|
||||
- 哨兵集群多数实例达成共识,判断出主库“客观下线”后,由哪个实例来执行主从切换呢?
|
||||
|
||||
要搞懂这些问题,就不得不提哨兵集群了,下节课,我们来具体聊聊哨兵集群的机制和问题。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。这节课,我提到,通过哨兵机制,可以实现主从库的自动切换,这是实现服务不间断的关键支撑,同时,我也提到了主从库切换是需要一定时间的。所以,请你考虑下,在这个切换过程中,客户端能否正常地进行请求操作呢?如果想要应用程序不感知服务的中断,还需要哨兵或需要客户端再做些什么吗?
|
||||
|
||||
欢迎你在留言区跟我交流讨论,也欢迎你能帮我把今天的内容分享给更多人,帮助他们一起解决问题。我们下节课见。
|
||||
141
极客时间专栏/Redis核心技术与实战/基础篇/08 | 哨兵集群:哨兵挂了,主从库还能切换吗?.md
Normal file
141
极客时间专栏/Redis核心技术与实战/基础篇/08 | 哨兵集群:哨兵挂了,主从库还能切换吗?.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="08 | 哨兵集群:哨兵挂了,主从库还能切换吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/20/2846c84668a66a7ce46c9051c3880220.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
上节课,我们学习了哨兵机制,它可以实现主从库的自动切换。通过部署多个实例,就形成了一个哨兵集群。哨兵集群中的多个实例共同判断,可以降低对主库下线的误判率。
|
||||
|
||||
但是,我们还是要考虑一个问题:如果有哨兵实例在运行时发生了故障,主从库还能正常切换吗?
|
||||
|
||||
实际上,一旦多个实例组成了**哨兵集群**,即使有哨兵实例出现故障挂掉了,其他哨兵还能继续协作完成主从库切换的工作,包括判定主库是不是处于下线状态,选择新主库,以及通知从库和客户端。
|
||||
|
||||
如果你部署过哨兵集群的话就会知道,在配置哨兵的信息时,我们只需要用到下面的这个配置项,设置**主库的IP**和**端口**,并没有配置其他哨兵的连接信息。
|
||||
|
||||
```
|
||||
sentinel monitor <master-name> <ip> <redis-port> <quorum>
|
||||
|
||||
```
|
||||
|
||||
这些哨兵实例既然都不知道彼此的地址,又是怎么组成集群的呢?要弄明白这个问题,我们就需要学习一下哨兵集群的组成和运行机制了。
|
||||
|
||||
## 基于pub/sub机制的哨兵集群组成
|
||||
|
||||
哨兵实例之间可以相互发现,要归功于Redis提供的pub/sub机制,也就是发布/订阅机制。
|
||||
|
||||
哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP和端口)。同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的IP地址和端口。
|
||||
|
||||
除了哨兵实例,我们自己编写的应用程序也可以通过Redis进行消息的发布和订阅。所以,为了区分不同应用的消息,Redis会以频道的形式,对这些消息进行分门别类的管理。所谓的频道,实际上就是消息的类别。当消息类别相同时,它们就属于同一个频道。反之,就属于不同的频道。**只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换**。
|
||||
|
||||
在主从集群中,主库上有一个名为“`__sentinel__:hello`”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
|
||||
|
||||
我来举个例子,具体说明一下。在下图中,哨兵1把自己的IP(172.16.19.3)和端口(26579)发布到“`__sentinel__:hello`”频道上,哨兵2和3订阅了该频道。那么此时,哨兵2和3就可以从这个频道直接获取哨兵1的IP地址和端口号。
|
||||
|
||||
然后,哨兵2、3可以和哨兵1建立网络连接。通过这个方式,哨兵2和3也可以建立网络连接,这样一来,哨兵集群就形成了。它们相互间可以通过网络连接进行通信,比如说对主库有没有下线这件事儿进行判断和协商。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/b1/ca42698128aa4c8a374efbc575ea22b1.jpg" alt="">
|
||||
|
||||
哨兵除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在哨兵的监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知从库,让它们和新主库进行同步。
|
||||
|
||||
那么,**哨兵是如何知道从库的IP地址和端口的呢?**
|
||||
|
||||
这是由哨兵向主库发送INFO命令来完成的。就像下图所示,哨兵2给主库发送INFO命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵1和3可以通过相同的方法和从库建立连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/e0/88fdc68eb94c44efbdf7357260091de0.jpg" alt="">
|
||||
|
||||
你看,通过pub/sub机制,哨兵之间可以组成集群,同时,哨兵又通过INFO命令,获得了从库连接信息,也能和从库建立连接,并进行监控了。
|
||||
|
||||
但是,哨兵不能只和主、从库连接。因为,主从库切换后,客户端也需要知道新主库的连接信息,才能向新主库发送请求操作。所以,哨兵还需要完成把新主库的信息告诉客户端这个任务。
|
||||
|
||||
而且,在实际使用哨兵时,我们有时会遇到这样的问题:如何在客户端通过监控了解哨兵进行主从切换的过程呢?比如说,主从切换进行到哪一步了?这其实就是要求,客户端能够获取到哨兵集群在监控、选主、切换这个过程中发生的各种事件。
|
||||
|
||||
此时,我们仍然可以依赖pub/sub机制,来帮助我们完成哨兵和客户端间的信息同步。
|
||||
|
||||
## 基于pub/sub机制的客户端事件通知
|
||||
|
||||
从本质上说,哨兵就是一个运行在特定模式下的Redis实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供pub/sub机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。
|
||||
|
||||
频道有这么多,一下子全部学习容易丢失重点。为了减轻你的学习压力,我把重要的频道汇总在了一起,涉及几个关键事件,包括主库下线判断、新主库选定、从库重新配置。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/25/4e9665694a9565abbce1a63cf111f725.jpg" alt="">
|
||||
|
||||
知道了这些频道之后,你就可以**让客户端从哨兵这里订阅消息**了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。
|
||||
|
||||
举个例子,你可以执行如下命令,来订阅“所有实例进入客观下线状态的事件”:
|
||||
|
||||
```
|
||||
SUBSCRIBE +odown
|
||||
|
||||
```
|
||||
|
||||
当然,你也可以执行如下命令,订阅所有的事件:
|
||||
|
||||
```
|
||||
PSUBSCRIBE *
|
||||
|
||||
```
|
||||
|
||||
当哨兵把新主库选择出来后,客户端就会看到下面的switch-master事件。这个事件表示主库已经切换了,新主库的IP地址和端口信息已经有了。这个时候,客户端就可以用这里面的新主库地址和端口进行通信了。
|
||||
|
||||
```
|
||||
switch-master <master name> <oldip> <oldport> <newip> <newport>
|
||||
|
||||
```
|
||||
|
||||
有了这些事件通知,客户端不仅可以在主从切换后得到新主库的连接信息,还可以监控到主从库切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度。
|
||||
|
||||
好了,有了pub/sub机制,哨兵和哨兵之间、哨兵和从库之间、哨兵和客户端之间就都能建立起连接了,再加上我们上节课介绍主库下线判断和选主依据,哨兵集群的监控、选主和通知三个任务就基本可以正常工作了。不过,我们还需要考虑一个问题:主库故障以后,哨兵集群有多个实例,那怎么确定由哪个哨兵来进行实际的主从切换呢?
|
||||
|
||||
## 由哪个哨兵执行主从切换?
|
||||
|
||||
确定由哪个哨兵执行主从切换的过程,和主库“客观下线”的判断过程类似,也是一个“投票仲裁”的过程。在具体了解这个过程前,我们再来看下,判断“客观下线”的仲裁过程。
|
||||
|
||||
哨兵集群要判定主库“客观下线”,需要有一定数量的实例都认为该主库已经“主观下线”了。我在上节课向你介绍了判断“客观下线”的原则,接下来,我介绍下具体的判断过程。
|
||||
|
||||
任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送is-master-down-by-addr命令。接着,其他实例会根据自己和主库的连接情况,做出Y或N的响应,Y相当于赞成票,N相当于反对票。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/84/e0832d432c14c98066a94e0ef86af384.jpg" alt="">
|
||||
|
||||
一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的quorum配置项设定的。例如,现在有5个哨兵,quorum配置的是3,那么,一个哨兵需要3张赞成票,就可以标记主库为“客观下线”了。这3张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。
|
||||
|
||||
此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader选举”。因为最终执行主从切换的哨兵称为Leader,投票过程就是确定Leader。
|
||||
|
||||
在投票过程中,任何一个想成为Leader的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的quorum值。以3个哨兵为例,假设此时的quorum设置为2,那么,任何一个想成为Leader的哨兵只要拿到2张赞成票,就可以了。
|
||||
|
||||
这么说你可能还不太好理解,我再画一张图片,展示一下3个哨兵、quorum为2的选举过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/d9/5f6ceeb9337e158cc759e23c0f375fd9.jpg" alt="">
|
||||
|
||||
在T1时刻,S1判断主库为“客观下线”,它想成为Leader,就先给自己投一张赞成票,然后分别向S2和S3发送命令,表示要成为Leader。
|
||||
|
||||
在T2时刻,S3判断主库为“客观下线”,它也想成为Leader,所以也先给自己投一张赞成票,再分别向S1和S2发送命令,表示要成为Leader。
|
||||
|
||||
在T3时刻,S1收到了S3的Leader投票请求。因为S1已经给自己投了一票Y,所以它不能再给其他哨兵投赞成票了,所以S1回复N表示不同意。同时,S2收到了T2时S3发送的Leader投票请求。因为S2之前没有投过票,它会给第一个向它发送投票请求的哨兵回复Y,给后续再发送投票请求的哨兵回复N,所以,在T3时,S2回复S3,同意S3成为Leader。
|
||||
|
||||
在T4时刻,S2才收到T1时S1发送的投票命令。因为S2已经在T3时同意了S3的投票请求,此时,S2给S1回复N,表示不同意S1成为Leader。发生这种情况,是因为S3和S2之间的网络传输正常,而S1和S2之间的网络传输可能正好拥塞了,导致投票请求传输慢了。
|
||||
|
||||
最后,在T5时刻,S1得到的票数是来自它自己的一票Y和来自S2的一票N。而S3除了自己的赞成票Y以外,还收到了来自S2的一票Y。此时,S3不仅获得了半数以上的Leader赞成票,也达到预设的quorum值(quorum为2),所以它最终成为了Leader。接着,S3会开始执行选主操作,而且在选定新主库后,会给其他从库和客户端通知新主库的信息。
|
||||
|
||||
如果S3没有拿到2票Y,那么这轮投票就不会产生Leader。哨兵集群会等待一段时间(也就是哨兵故障转移超时时间的2倍),再重新选举。这是因为,哨兵集群能够进行成功投票,很大程度上依赖于选举命令的正常网络传播。如果网络压力较大或有短时堵塞,就可能导致没有一个哨兵能拿到半数以上的赞成票。所以,等到网络拥塞好转之后,再进行投票选举,成功的概率就会增加。
|
||||
|
||||
需要注意的是,如果哨兵集群只有2个实例,此时,一个哨兵要想成为Leader,必须获得2票,而不是1票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置3个哨兵实例。这一点很重要,你在实际应用时可不能忽略了。
|
||||
|
||||
## 小结
|
||||
|
||||
通常,我们在解决一个系统问题的时候,会引入一个新机制,或者设计一层新功能,就像我们在这两节课学习的内容:为了实现主从切换,我们引入了哨兵;为了避免单个哨兵故障后无法进行主从切换,以及为了减少误判率,又引入了哨兵集群;哨兵集群又需要有一些机制来支撑它的正常运行。
|
||||
|
||||
这节课上,我就向你介绍了支持哨兵集群的这些关键机制,包括:
|
||||
|
||||
- 基于pub/sub机制的哨兵集群组成过程;
|
||||
- 基于INFO命令的从库列表,这可以帮助哨兵和从库建立连接;
|
||||
- 基于哨兵自身的pub/sub功能,这实现了客户端和哨兵之间的事件通知。
|
||||
|
||||
对于主从切换,当然不是哪个哨兵想执行就可以执行的,否则就乱套了。所以,这就需要哨兵集群在判断了主库“客观下线”后,经过投票仲裁,选举一个Leader出来,由它负责实际的主从切换,即由它来完成新主库的选择以及通知从库与客户端。
|
||||
|
||||
最后,我想再给你分享一个经验:**要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值down-after-milliseconds**。我们曾经就踩过一个“坑”。当时,在我们的项目中,因为这个值在不同的哨兵实例上配置不一致,导致哨兵集群一直没有对有故障的主库形成共识,也就没有及时切换主库,最终的结果就是集群服务不稳定。所以,你一定不要忽略这条看似简单的经验。
|
||||
|
||||
## 每课一问
|
||||
|
||||
这节课上,我给你提一个小问题。
|
||||
|
||||
假设有一个Redis集群,是“一主四从”,同时配置了包含5个哨兵实例的集群,quorum值设为2。在运行过程中,如果有3个哨兵实例都发生故障了,此时,Redis主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?此外,哨兵实例是不是越多越好呢,如果同时调大down-after-milliseconds值,对减少误判是不是也有好处呢?
|
||||
|
||||
欢迎你在留言区跟我交流讨论。如果你身边也有要学习哨兵集群相关知识点的朋友,也欢迎你能帮我把今天的内容分享给他们,帮助他们一起解决问题。我们下节课见。
|
||||
153
极客时间专栏/Redis核心技术与实战/基础篇/09 | 切片集群:数据增多了,是该加内存还是加实例?.md
Normal file
153
极客时间专栏/Redis核心技术与实战/基础篇/09 | 切片集群:数据增多了,是该加内存还是加实例?.md
Normal file
@@ -0,0 +1,153 @@
|
||||
<audio id="audio" title="09 | 切片集群:数据增多了,是该加内存还是加实例?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/82/cc/82543a522cee4c257e68dc470551e8cc.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。今天我们来学习切片集群。
|
||||
|
||||
我曾遇到过这么一个需求:要用Redis保存5000万个键值对,每个键值对大约是512B,为了能快速部署并对外提供服务,我们采用云主机来运行Redis实例,那么,该如何选择云主机的内存容量呢?
|
||||
|
||||
我粗略地计算了一下,这些键值对所占的内存空间大约是25GB(5000万*512B)。所以,当时,我想到的第一个方案就是:选择一台32GB内存的云主机来部署Redis。因为32GB的内存能保存所有数据,而且还留有7GB,可以保证系统的正常运行。同时,我还采用RDB对数据做持久化,以确保Redis实例故障后,还能从RDB恢复数据。
|
||||
|
||||
但是,在使用的过程中,我发现,Redis的响应有时会非常慢。后来,我们使用INFO命令查看Redis的latest_fork_usec指标值(表示最近一次fork的耗时),结果显示这个指标值特别高,快到秒级别了。
|
||||
|
||||
这跟Redis的持久化机制有关系。在使用RDB进行持久化时,Redis会fork子进程来完成,fork操作的用时和Redis的数据量是正相关的,而fork在执行时会阻塞主线程。数据量越大,fork操作造成的主线程阻塞的时间越长。所以,在使用RDB对25GB的数据进行持久化时,数据量较大,后台运行的子进程在fork创建时阻塞了主线程,于是就导致Redis响应变慢了。
|
||||
|
||||
看来,第一个方案显然是不可行的,我们必须要寻找其他的方案。这个时候,我们注意到了Redis的切片集群。虽然组建切片集群比较麻烦,但是它可以保存大量数据,而且对Redis主线程的阻塞影响较小。
|
||||
|
||||
切片集群,也叫分片集群,就是指启动多个Redis实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把25GB的数据平均分成5份(当然,也可以不做均分),使用5个实例来保存,每个实例只需要保存5GB数据。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/26/793251ca784yyf6ac37fe46389094b26.jpg" alt="" title="切片集群架构图">
|
||||
|
||||
那么,在切片集群中,实例在为5GB数据生成RDB时,数据量就小了很多,fork子进程一般不会给主线程带来较长时间的阻塞。采用多个实例保存数据切片后,我们既能保存25GB数据,又避免了fork子进程阻塞主线程而导致的响应突然变慢。
|
||||
|
||||
在实际应用Redis时,随着用户或业务规模的扩展,保存大量数据的情况通常是无法避免的。而切片集群,就是一个非常好的解决方案。这节课,我们就来学习一下。
|
||||
|
||||
## 如何保存更多数据?
|
||||
|
||||
在刚刚的案例里,为了保存大量数据,我们使用了大内存云主机和切片集群两种方法。实际上,这两种方法分别对应着Redis应对数据量增多的两种方案:纵向扩展(scale up)和横向扩展(scale out)。
|
||||
|
||||
- **纵向扩展**:升级单个Redis实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的CPU。就像下图中,原来的实例内存是8GB,硬盘是50GB,纵向扩展后,内存增加到24GB,磁盘增加到150GB。
|
||||
- **横向扩展**:横向增加当前Redis实例的个数,就像下图中,原来使用1个8GB内存、50GB磁盘的实例,现在使用三个相同配置的实例。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/1a/7a512fec7eba789c6d098b834929701a.jpg" alt="" title="纵向扩展和横向扩展对比图">
|
||||
|
||||
那么,这两种方式的优缺点分别是什么呢?
|
||||
|
||||
首先,纵向扩展的好处是,**实施起来简单、直接**。不过,这个方案也面临两个潜在的问题。
|
||||
|
||||
第一个问题是,当使用RDB对数据进行持久化时,如果数据量增加,需要的内存也会增加,主线程fork子进程时就可能会阻塞(比如刚刚的例子中的情况)。不过,如果你不要求持久化保存Redis数据,那么,纵向扩展会是一个不错的选择。
|
||||
|
||||
不过,这时,你还要面对第二个问题:**纵向扩展会受到硬件和成本的限制**。这很容易理解,毕竟,把内存从32GB扩展到64GB还算容易,但是,要想扩充到1TB,就会面临硬件容量和成本上的限制了。
|
||||
|
||||
与纵向扩展相比,横向扩展是一个扩展性更好的方案。这是因为,要想保存更多的数据,采用这种方案的话,只用增加Redis的实例个数就行了,不用担心单个实例的硬件和成本限制。**在面向百万、千万级别的用户规模时,横向扩展的Redis切片集群会是一个非常好的选择**。
|
||||
|
||||
不过,在只使用单个实例的时候,数据存在哪儿,客户端访问哪儿,都是非常明确的,但是,切片集群不可避免地涉及到多个实例的分布式管理问题。要想把切片集群用起来,我们就需要解决两大问题:
|
||||
|
||||
- 数据切片后,在多个实例之间如何分布?
|
||||
- 客户端怎么确定想要访问的数据在哪个实例上?
|
||||
|
||||
接下来,我们就一个个地解决。
|
||||
|
||||
## 数据切片和实例的对应分布关系
|
||||
|
||||
在切片集群中,数据需要分布在不同实例上,那么,数据和实例之间如何对应呢?这就和接下来我要讲的Redis Cluster方案有关了。不过,我们要先弄明白切片集群和Redis Cluster的联系与区别。
|
||||
|
||||
实际上,切片集群是一种保存大量数据的通用机制,这个机制可以有不同的实现方案。在Redis 3.0之前,官方并没有针对切片集群提供具体的方案。从3.0开始,官方提供了一个名为Redis Cluster的方案,用于实现切片集群。Redis Cluster方案中就规定了数据和实例的对应规则。
|
||||
|
||||
具体来说,Redis Cluster方案采用哈希槽(Hash Slot,接下来我会直接称之为Slot),来处理数据和实例之间的映射关系。在Redis Cluster方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到一个哈希槽中。
|
||||
|
||||
具体的映射过程分为两大步:首先根据键值对的key,按照[CRC16算法](https://en.wikipedia.org/wiki/Cyclic_redundancy_check)计算一个16 bit的值;然后,再用这个16bit值对16384取模,得到0~16383范围内的模数,每个模数代表一个相应编号的哈希槽。关于CRC16算法,不是这节课的重点,你简单看下链接中的资料就可以了。
|
||||
|
||||
那么,这些哈希槽又是如何被映射到具体的Redis实例上的呢?
|
||||
|
||||
我们在部署Redis Cluster方案时,可以使用cluster create命令创建集群,此时,Redis会自动把这些槽平均分布在集群实例上。例如,如果集群中有N个实例,那么,每个实例上的槽个数为16384/N个。
|
||||
|
||||
当然, 我们也可以使用cluster meet命令手动建立实例间的连接,形成集群,再使用cluster addslots命令,指定每个实例上的哈希槽个数。
|
||||
|
||||
举个例子,假设集群中不同Redis实例的内存大小配置不一,如果把哈希槽均分在各个实例上,在保存相同数量的键值对时,和内存大的实例相比,内存小的实例就会有更大的容量压力。遇到这种情况时,你可以根据不同实例的资源配置情况,使用cluster addslots命令手动分配哈希槽。
|
||||
|
||||
为了便于你理解,我画一张示意图来解释一下,数据、哈希槽、实例这三者的映射分布情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/ab/7d070c8b19730b308bfaabbe82c2f1ab.jpg" alt="">
|
||||
|
||||
示意图中的切片集群一共有3个实例,同时假设有5个哈希槽,我们首先可以通过下面的命令手动分配哈希槽:实例1保存哈希槽0和1,实例2保存哈希槽2和3,实例3保存哈希槽4。
|
||||
|
||||
```
|
||||
redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1
|
||||
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
|
||||
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4
|
||||
|
||||
```
|
||||
|
||||
在集群运行的过程中,key1和key2计算完CRC16值后,对哈希槽总个数5取模,再根据各自的模数结果,就可以被映射到对应的实例1和实例3上了。
|
||||
|
||||
另外,我再给你一个小提醒,**在手动分配哈希槽时,需要把16384个槽都分配完,否则Redis集群无法正常工作**。
|
||||
|
||||
好了,通过哈希槽,切片集群就实现了数据到哈希槽、哈希槽再到实例的分配。但是,即使实例有了哈希槽的映射信息,客户端又是怎么知道要访问的数据在哪个实例上呢?接下来,我就来和你聊聊。
|
||||
|
||||
## 客户端如何定位数据?
|
||||
|
||||
在定位键值对数据时,它所处的哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时来执行。但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上。
|
||||
|
||||
一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。
|
||||
|
||||
那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。
|
||||
|
||||
客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
|
||||
|
||||
但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
|
||||
|
||||
- 在集群中,实例有新增或删除,Redis需要重新分配哈希槽;
|
||||
- 为了负载均衡,Redis需要把哈希槽在所有实例上重新分布一遍。
|
||||
|
||||
此时,实例之间还可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致了,那该怎么办呢?
|
||||
|
||||
Redis Cluster方案提供了一种**重定向机制,**所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。
|
||||
|
||||
那客户端又是怎么知道重定向时的新实例的访问地址呢?当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的MOVED命令响应结果,这个结果中就包含了新实例的访问地址。
|
||||
|
||||
```
|
||||
GET hello:key
|
||||
(error) MOVED 13320 172.16.19.5:6379
|
||||
|
||||
```
|
||||
|
||||
其中,MOVED命令表示,客户端请求的键值对所在的哈希槽13320,实际是在172.16.19.5这个实例上。通过返回的MOVED命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和172.16.19.5连接,并发送操作请求了。
|
||||
|
||||
我画一张图来说明一下,MOVED重定向命令的使用方法。可以看到,由于负载均衡,Slot 2中的数据已经从实例2迁移到了实例3,但是,客户端缓存仍然记录着“Slot 2在实例2”的信息,所以会给实例2发送命令。实例2给客户端返回一条MOVED命令,把Slot 2的最新位置(也就是在实例3上),返回给客户端,客户端就会再次向实例3发送请求,同时还会更新本地缓存,把Slot 2与实例的对应关系更新过来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/35/09/350abedefcdbc39d6a8a8f1874eb0809.jpg" alt="" title="客户端MOVED重定向命令">
|
||||
|
||||
需要注意的是,在上图中,当客户端给实例2发送命令时,Slot 2中的数据已经全部迁移到了实例3。在实际应用时,如果Slot 2中的数据比较多,就可能会出现一种情况:客户端向实例2发送请求,但此时,Slot 2中的数据只有一部分迁移到了实例3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条ASK报错信息,如下所示:
|
||||
|
||||
```
|
||||
GET hello:key
|
||||
(error) ASK 13320 172.16.19.5:6379
|
||||
|
||||
```
|
||||
|
||||
这个结果中的ASK命令就表示,客户端请求的键值对所在的哈希槽13320,在172.16.19.5这个实例上,但是这个哈希槽正在迁移。此时,客户端需要先给172.16.19.5这个实例发送一个ASKING命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送GET命令,以读取数据。
|
||||
|
||||
看起来好像有点复杂,我再借助图片来解释一下。
|
||||
|
||||
在下图中,Slot 2正在从实例2往实例3迁移,key1和key2已经迁移过去,key3和key4还在实例2。客户端向实例2请求key2后,就会收到实例2返回的ASK命令。
|
||||
|
||||
ASK命令表示两层含义:第一,表明Slot数据还在迁移中;第二,ASK命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例3发送ASKING命令,然后再发送操作命令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/b0/e93ae7f4edf30724d58bf68yy714eeb0.jpg" alt="" title="客户端ASK重定向命令">
|
||||
|
||||
和MOVED命令不同,**ASK命令并不会更新客户端缓存的哈希槽分配信息**。所以,在上图中,如果客户端再次请求Slot 2中的数据,它还是会给实例2发送请求。这也就是说,ASK命令的作用只是让客户端能给新实例发送一次请求,而不像MOVED命令那样,会更改本地缓存,让后续所有命令都发往新实例。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了切片集群在保存大量数据方面的优势,以及基于哈希槽的数据分布机制和客户端定位键值对的方法。
|
||||
|
||||
在应对数据量扩容时,虽然增加内存这种纵向扩展的方法简单直接,但是会造成数据库的内存过大,导致性能变慢。Redis切片集群提供了横向扩展的模式,也就是使用多个实例,并给每个实例配置一定数量的哈希槽,数据可以通过键的哈希值映射到哈希槽,再通过哈希槽分散保存到不同的实例上。这样做的好处是扩展性好,不管有多少数据,切片集群都能应对。
|
||||
|
||||
另外,集群的实例增减,或者是为了实现负载均衡而进行的数据重新分布,会导致哈希槽和实例的映射关系发生变化,客户端发送请求时,会收到命令执行报错信息。了解了MOVED和ASK命令,你就不会为这类报错而头疼了。
|
||||
|
||||
我刚刚说过,在Redis 3.0 之前,Redis官方并没有提供切片集群方案,但是,其实当时业界已经有了一些切片集群的方案,例如基于客户端分区的ShardedJedis,基于代理的Codis、Twemproxy等。这些方案的应用早于Redis Cluster方案,在支撑的集群实例规模、集群稳定性、客户端友好性方面也都有着各自的优势,我会在后面的课程中,专门和你聊聊这些方案的实现机制,以及实践经验。这样一来,当你再碰到业务发展带来的数据量巨大的难题时,就可以根据这些方案的特点,选择合适的方案实现切片集群,以应对业务需求了。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,给你提一个小问题:Redis Cluster方案通过哈希槽的方式把键值对分配到不同的实例上,这个过程需要对键值对的key做CRC计算,然后再和哈希槽做映射,这样做有什么好处吗?如果用一个表直接把键值对和实例的对应关系记录下来(例如键值对1在实例2上,键值对2在实例1上),这样就不用计算key和哈希槽的对应关系了,只用查表就行了,Redis为什么不这么做呢?
|
||||
|
||||
欢迎你在留言区畅所欲言,如果你觉得有收获,也希望你能帮我把今天的内容分享给你的朋友,帮助更多人解决切片集群的问题。
|
||||
224
极客时间专栏/Redis核心技术与实战/基础篇/10 | 第1~9讲课后思考题答案及常见问题答疑.md
Normal file
224
极客时间专栏/Redis核心技术与实战/基础篇/10 | 第1~9讲课后思考题答案及常见问题答疑.md
Normal file
@@ -0,0 +1,224 @@
|
||||
<audio id="audio" title="10 | 第1~9讲课后思考题答案及常见问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/72/bef4caa459a3abff34e8077c6af94672.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
咱们的课程已经更新9讲了,这段时间,我收到了很多留言。很多同学都认真地回答了课后思考题,有些回答甚至可以说是标准答案。另外,还有很多同学针对Redis的基本原理和关键机制,提出了非常好的问题,值得好好讨论一下。
|
||||
|
||||
今天,我就和你聊一聊课后题答案,并且挑选一些典型问题,集中进行一次讲解,希望可以解决你的困惑。
|
||||
|
||||
## 课后思考题答案
|
||||
|
||||
### [第1讲](https://time.geekbang.org/column/article/268262)
|
||||
|
||||
**问题:和跟Redis相比,SimpleKV还缺少什么?**
|
||||
|
||||
@曾轼麟、@Kaito 同学给出的答案都非常棒。他们从数据结构到功能扩展,从内存效率到事务性,从高可用集群再到高可扩展集群,对SimpleKV和Redis进行了详细的对比。而且,他们还从运维使用的角度进行了分析。我先分享一下两位同学的答案。
|
||||
|
||||
@曾轼麟同学:
|
||||
|
||||
>
|
||||
<ol>
|
||||
- 数据结构:缺乏广泛的数据结构支持,比如支持范围查询的SkipList和Stream等数据结构。
|
||||
- 高可用:缺乏哨兵或者master-slave模式的高可用设计;
|
||||
- 横向扩展:缺乏集群和分片功能;
|
||||
- 内存安全性:缺乏内存过载时的key淘汰算法的支持;
|
||||
- 内存利用率:没有充分对数据结构进行优化,提高内存利用率,例如使用压缩性的数据结构;
|
||||
- 功能扩展:需要具备后续功能的拓展;
|
||||
- 不具备事务性:无法保证多个操作的原子性。
|
||||
</ol>
|
||||
|
||||
|
||||
@Kaito同学:
|
||||
|
||||
>
|
||||
SimpleKV所缺少的有:丰富的数据类型、支持数据压缩、过期机制、数据淘汰策略、主从复制、集群化、高可用集群等,另外,还可以增加统计模块、通知模块、调试模块、元数据查询等辅助功能。
|
||||
|
||||
|
||||
我也给个答案总结。还记得我在[开篇词](https://time.geekbang.org/column/article/268247)讲过的“两大维度”“三大主线”吗?这里我们也可以借助这个框架进行分析,如下表所示。此外,在表格最后,我还从键值数据库开发和运维的辅助工具上,对SimpleKV和Redis做了对比。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/36/67e77bea2568a4f0997c1853d9c60036.jpg" alt="">
|
||||
|
||||
### [第2讲](https://time.geekbang.org/column/article/268253)
|
||||
|
||||
**问题:整数数组和压缩列表作为底层数据结构的优势是什么?**
|
||||
|
||||
整数数组和压缩列表的设计,充分体现了Redis“又快又省”特点中的“省”,也就是节省内存空间。整数数组和压缩列表都是在内存中分配一块地址连续的空间,然后把集合中的元素一个接一个地放在这块空间内,非常紧凑。因为元素是挨个连续放置的,我们不用再通过额外的指针把元素串接起来,这就避免了额外指针带来的空间开销。
|
||||
|
||||
我画一张图,展示下这两个结构的内存布局。整数数组和压缩列表中的entry都是实际的集合元素,它们一个挨一个保存,非常节省内存空间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/2a/2c57cc1c548a0733bd1bf09f397f342a.jpg" alt="">
|
||||
|
||||
Redis之所以采用不同的数据结构,其实是在性能和内存使用效率之间进行的平衡。
|
||||
|
||||
### [第3讲](https://time.geekbang.org/column/article/270474)
|
||||
|
||||
**问题:Redis基本IO模型中还有哪些潜在的性能瓶颈?**
|
||||
|
||||
这个问题是希望你能进一步理解阻塞操作对Redis单线程性能的影响。在Redis基本IO模型中,主要是主线程在执行操作,任何耗时的操作,例如bigkey、全量返回等操作,都是潜在的性能瓶颈。
|
||||
|
||||
### [第4讲](https://time.geekbang.org/column/article/271754)
|
||||
|
||||
**问题1:AOF重写过程中有没有其他潜在的阻塞风险?**
|
||||
|
||||
这里有两个风险。
|
||||
|
||||
风险一:Redis主线程fork创建bgrewriteaof子进程时,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作进程控制块(Process Control Block,简称为PCB)。内核要把主线程的PCB内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。而且,在拷贝过程中,子进程要拷贝父进程的页表,这个过程的耗时和Redis实例的内存大小有关。如果Redis实例内存大,页表就会大,fork执行时间就会长,这就会给主线程带来阻塞风险。
|
||||
|
||||
风险二:bgrewriteaof子进程会和主线程共享内存。当主线程收到新写或修改的操作时,主线程会申请新的内存空间,用来保存新写或修改的数据,如果操作的是bigkey,也就是数据量大的集合类型数据,那么,主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。
|
||||
|
||||
**问题2:AOF 重写为什么不共享使用 AOF 本身的日志?**
|
||||
|
||||
如果都用AOF日志的话,主线程要写,bgrewriteaof子进程也要写,这两者会竞争文件系统的锁,这就会对Redis主线程的性能造成影响。
|
||||
|
||||
### [第5讲](https://time.geekbang.org/column/article/271839)
|
||||
|
||||
问题:使用一个 2 核 CPU、4GB 内存、500GB 磁盘的云主机运行 Redis,Redis 数据库的数据量大小差不多是 2GB。当时 Redis主要以修改操作为主,写读比例差不多在 8:2 左右,也就是说,如果有 100 个请求,80 个请求执行的是修改操作。在这个场景下,用 RDB 做持久化有什么风险吗?
|
||||
|
||||
@Kaito同学的回答从内存资源和CPU资源两方面分析了风险,非常棒。我稍微做了些完善和精简,你可以参考一下。
|
||||
|
||||
**内存不足的风险**:Redis fork一个bgsave子进程进行RDB写入,如果主线程再接收到写操作,就会采用写时复制。写时复制需要给写操作的数据分配新的内存空间。本问题中写的比例为80%,那么,在持久化过程中,为了保存80%写操作涉及的数据,写时复制机制会在实例内存中,为这些数据再分配新内存空间,分配的内存量相当于整个实例数据量的80%,大约是1.6GB,这样一来,整个系统内存的使用量就接近饱和了。此时,如果实例还有大量的新key写入或key修改,云主机内存很快就会被吃光。如果云主机开启了Swap机制,就会有一部分数据被换到磁盘上,当访问磁盘上的这部分数据时,性能会急剧下降。如果云主机没有开启Swap,会直接触发OOM,整个Redis实例会面临被系统kill掉的风险。
|
||||
|
||||
**主线程和子进程竞争使用CPU的风险**:生成RDB的子进程需要CPU核运行,主线程本身也需要CPU核运行,而且,如果Redis还启用了后台线程,此时,主线程、子进程和后台线程都会竞争CPU资源。由于云主机只有2核CPU,这就会影响到主线程处理请求的速度。
|
||||
|
||||
### [第6讲](https://time.geekbang.org/column/article/272852)
|
||||
|
||||
**问题:为什么主从库间的复制不使用 AOF?**
|
||||
|
||||
答案:有两个原因。
|
||||
|
||||
1. RDB文件是二进制文件,无论是要把RDB写入磁盘,还是要通过网络传输RDB,IO效率都比记录和传输AOF的高。
|
||||
1. 在从库端进行恢复时,用RDB的恢复效率要高于用AOF。
|
||||
|
||||
### [第7讲](https://time.geekbang.org/column/article/274483)
|
||||
|
||||
**问题1:在主从切换过程中,客户端能否正常地进行请求操作呢?**
|
||||
|
||||
主从集群一般是采用读写分离模式,当主库故障后,客户端仍然可以把读请求发送给从库,让从库服务。但是,对于写请求操作,客户端就无法执行了。
|
||||
|
||||
**问题2:如果想要应用程序不感知服务的中断,还需要哨兵或客户端再做些什么吗?**
|
||||
|
||||
一方面,客户端需要能缓存应用发送的写请求。只要不是同步写操作(Redis应用场景一般也没有同步写),写请求通常不会在应用程序的关键路径上,所以,客户端缓存写请求后,给应用程序返回一个确认就行。
|
||||
|
||||
另一方面,主从切换完成后,客户端要能和新主库重新建立连接,哨兵需要提供订阅频道,让客户端能够订阅到新主库的信息。同时,客户端也需要能主动和哨兵通信,询问新主库的信息。
|
||||
|
||||
### [第8讲](https://time.geekbang.org/column/article/275337)
|
||||
|
||||
**问题1:5个哨兵实例的集群,quorum值设为2。在运行过程中,如果有3个哨兵实例都发生故障了,此时,Redis主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?**
|
||||
|
||||
因为判定主库“客观下线”的依据是,认为主库“主观下线”的哨兵个数要大于等于quorum值,现在还剩2个哨兵实例,个数正好等于quorum值,所以还能正常判断主库是否处于“客观下线”状态。如果一个哨兵想要执行主从切换,就要获到半数以上的哨兵投票赞成,也就是至少需要3个哨兵投票赞成。但是,现在只有2个哨兵了,所以就无法进行主从切换了。
|
||||
|
||||
**问题2:哨兵实例是不是越多越好呢?如果同时调大down-after-milliseconds值,对减少误判是不是也有好处?**
|
||||
|
||||
哨兵实例越多,误判率会越低,但是在判定主库下线和选举Leader时,实例需要拿到的赞成票数也越多,等待所有哨兵投完票的时间可能也会相应增加,主从库切换的时间也会变长,客户端容易堆积较多的请求操作,可能会导致客户端请求溢出,从而造成请求丢失。如果业务层对Redis的操作有响应时间要求,就可能会因为新主库一直没有选定,新操作无法执行而发生超时报警。
|
||||
|
||||
调大down-after-milliseconds后,可能会导致这样的情况:主库实际已经发生故障了,但是哨兵过了很长时间才判断出来,这就会影响到Redis对业务的可用性。
|
||||
|
||||
### [第9讲](https://time.geekbang.org/column/article/276545)
|
||||
|
||||
问题:为什么Redis不直接用一个表,把键值对和实例的对应关系记录下来?
|
||||
|
||||
如果使用表记录键值对和实例的对应关系,一旦键值对和实例的对应关系发生了变化(例如实例有增减或者数据重新分布),就要修改表。如果是单线程操作表,那么所有操作都要串行执行,性能慢;如果是多线程操作表,就涉及到加锁开销。此外,如果数据量非常大,使用表记录键值对和实例的对应关系,需要的额外存储空间也会增加。
|
||||
|
||||
基于哈希槽计算时,虽然也要记录哈希槽和实例的对应关系,但是哈希槽的个数要比键值对的个数少很多,无论是修改哈希槽和实例的对应关系,还是使用额外空间存储哈希槽和实例的对应关系,都比直接记录键值对和实例的关系的开销小得多。
|
||||
|
||||
好了,这些问题你都回答上来了吗?如果你还有其他想法,也欢迎多多留言,跟我和其他同学进行交流讨论。
|
||||
|
||||
## 典型问题讲解
|
||||
|
||||
接下来,我再讲一些代表性问题,包括Redis rehash的时机和执行机制,主线程、子进程和后台线程的联系和区别,写时复制的底层实现原理,以及replication buffer和repl_backlog_buffer的区别。
|
||||
|
||||
### 问题1:rehash的触发时机和渐进式执行机制
|
||||
|
||||
我发现,很多同学对Redis的哈希表数据结构都很感兴趣,尤其是哈希表的rehash操作,所以,我再集中回答两个问题。
|
||||
|
||||
**1.Redis什么时候做rehash?**
|
||||
|
||||
Redis会使用装载因子(load factor)来判断是否需要做rehash。装载因子的计算方式是,哈希表中所有entry的个数除以哈希表的哈希桶个数。Redis会根据装载因子的两种情况,来触发rehash操作:
|
||||
|
||||
- 装载因子≥1,同时,哈希表被允许进行rehash;
|
||||
- 装载因子≥5。
|
||||
|
||||
在第一种情况下,如果装载因子等于1,同时我们假设,所有键值对是平均分布在哈希表的各个桶中的,那么,此时,哈希表可以不用链式哈希,因为一个哈希桶正好保存了一个键值对。
|
||||
|
||||
但是,如果此时再有新的数据写入,哈希表就要使用链式哈希了,这会对查询性能产生影响。在进行RDB生成和AOF重写时,哈希表的rehash是被禁止的,这是为了避免对RDB和AOF重写造成影响。如果此时,Redis没有在生成RDB和重写AOF,那么,就可以进行rehash。否则的话,再有数据写入时,哈希表就要开始使用查询较慢的链式哈希了。
|
||||
|
||||
在第二种情况下,也就是装载因子大于等于5时,就表明当前保存的数据量已经远远大于哈希桶的个数,哈希桶里会有大量的链式哈希存在,性能会受到严重影响,此时,就立马开始做rehash。
|
||||
|
||||
刚刚说的是触发rehash的情况,如果装载因子小于1,或者装载因子大于1但是小于5,同时哈希表暂时不被允许进行rehash(例如,实例正在生成RDB或者重写AOF),此时,哈希表是不会进行rehash操作的。
|
||||
|
||||
**2.采用渐进式hash时,如果实例暂时没有收到新请求,是不是就不做rehash了?**
|
||||
|
||||
其实不是的。Redis会执行定时任务,定时任务中就包含了rehash操作。所谓的定时任务,就是按照一定频率(例如每100ms/次)执行的任务。
|
||||
|
||||
在rehash被触发后,即使没有收到新请求,Redis也会定时执行一次rehash操作,而且,每次执行时长不会超过1ms,以免对其他任务造成影响。
|
||||
|
||||
### 问题2:主线程、子进程和后台线程的联系与区别
|
||||
|
||||
我在课程中提到了主线程、主进程、子进程、子线程和后台线程这几个词,有些同学可能会有疑惑,我再帮你总结下它们的区别。
|
||||
|
||||
首先,我来解释一下进程和线程的区别。
|
||||
|
||||
从操作系统的角度来看,进程一般是指资源分配单元,例如一个进程拥有自己的堆、栈、虚存空间(页表)、文件描述符等;而线程一般是指CPU进行调度和执行的实体。
|
||||
|
||||
了解了进程和线程的区别后,我们再来看下什么是主进程和主线程。
|
||||
|
||||
如果一个进程启动后,没有再创建额外的线程,那么,这样的进程一般称为主进程或主线程。
|
||||
|
||||
举个例子,下面是我写的一个C程序片段,main函数会直接调用一个worker函数,函数worker就是执行一个for循环计算。下面这个程序运行后,它自己就是一个主进程,同时也是个主线程。
|
||||
|
||||
```
|
||||
int counter = 0;
|
||||
void *worker() {
|
||||
for (int i=0;i<10;i++) {
|
||||
counter++;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
worker();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
和这段代码类似,Redis启动以后,本身就是一个进程,它会接收客户端发送的请求,并处理读写操作请求。而且,接收请求和处理请求操作是Redis的主要工作,Redis没有再依赖于其他线程,所以,我一般把完成这个主要工作的Redis进程,称为主进程或主线程。
|
||||
|
||||
在主线程中,我们还可以使用fork创建子进程,或是使用pthread_create创建线程。下面我先介绍下Redis中用fork创建的子进程有哪些。
|
||||
|
||||
- 创建RDB的后台子进程,同时由它负责在主从同步时传输RDB给从库;
|
||||
- 通过无盘复制方式传输RDB的子进程;
|
||||
- bgrewriteaof子进程。
|
||||
|
||||
然后,我们再看下Redis使用的线程。从4.0版本开始,Redis也开始使用pthread_create创建线程,这些线程在创建后,一般会自行执行一些任务,例如执行异步删除任务。相对于完成主要工作的主线程来说,我们一般可以称这些线程为后台线程。关于Redis后台线程的具体执行机制,我会在第16讲具体介绍。
|
||||
|
||||
为了帮助你更好地理解,我画了一张图,展示了它们的区别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/51/c2c5bd3a66921b1b0cc1d377dfabd451.jpg" alt="">
|
||||
|
||||
### 问题3:写时复制的底层实现机制
|
||||
|
||||
Redis在使用RDB方式进行持久化时,会用到写时复制机制。我在第5节课讲写时复制的时候,着重介绍了写时复制的效果:bgsave子进程相当于复制了原始数据,而主线程仍然可以修改原来的数据。
|
||||
|
||||
今天,我再具体讲一讲写时复制的底层实现机制。
|
||||
|
||||
对Redis来说,主线程fork出bgsave子进程后,bgsave子进程实际是复制了主线程的页表。这些页表中,就保存了在执行bgsave命令时,主线程的所有数据块在内存中的物理地址。这样一来,bgsave子进程生成RDB时,就可以根据页表读取这些数据,再写入磁盘中。如果此时,主线程接收到了新写或修改操作,那么,主线程会使用写时复制机制。具体来说,写时复制就是指,主线程在有写操作时,才会把这个新写或修改后的数据写入到一个新的物理地址中,并修改自己的页表映射。
|
||||
|
||||
我来借助下图中的例子,具体展示一下写时复制的底层机制。
|
||||
|
||||
bgsave子进程复制主线程的页表以后,假如主线程需要修改虚页7里的数据,那么,主线程就需要新分配一个物理页(假设是物理页53),然后把修改后的虚页7里的数据写到物理页53上,而虚页7里原来的数据仍然保存在物理页33上。这个时候,虚页7到物理页33的映射关系,仍然保留在bgsave子进程中。所以,bgsave子进程可以无误地把虚页7的原始数据写入RDB文件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/eb/cc98dc9f65a1079f3638158aacf81aeb.jpg" alt="">
|
||||
|
||||
### 问题4:replication buffer和repl_backlog_buffer的区别
|
||||
|
||||
在进行主从复制时,Redis会使用replication buffer和repl_backlog_buffer,有些同学可能不太清楚它们的区别,我再解释下。
|
||||
|
||||
总的来说,replication buffer是主从库在进行全量复制时,主库上用于和从库连接的客户端的buffer,而repl_backlog_buffer是为了支持从库增量复制,主库上用于持续保存写操作的一块专用buffer。
|
||||
|
||||
Redis主从库在进行复制时,当主库要把全量复制期间的写操作命令发给从库时,主库会先创建一个客户端,用来连接从库,然后通过这个客户端,把写操作命令发给从库。在内存中,主库上的客户端就会对应一个buffer,这个buffer就被称为replication buffer。Redis通过client_buffer配置项来控制这个buffer的大小。主库会给每个从库建立一个客户端,所以replication buffer不是共享的,而是每个从库都有一个对应的客户端。
|
||||
|
||||
repl_backlog_buffer是一块专用buffer,在Redis服务器启动后,开始一直接收写操作命令,这是所有从库共享的。主库和从库会各自记录自己的复制进度,所以,不同的从库在进行恢复时,会把自己的复制进度(slave_repl_offset)发给主库,主库就可以和它独立同步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/a8/7a1795yy4f6dc064f0d34ef1231203a8.jpg" alt="">
|
||||
|
||||
好了,这节课就到这里。非常感谢你的仔细思考和提问,每个问题都很精彩,在看留言的过程中,我自己也受益匪浅。另外,我希望我们可以组建起一个Redis学习团,在接下来的课程中,欢迎你继续在留言区畅所欲言,我们一起进步,希望每个人都能成为Redis达人!
|
||||
175
极客时间专栏/Redis核心技术与实战/实践篇/11 | “万金油”的String,为什么不好用了?.md
Normal file
175
极客时间专栏/Redis核心技术与实战/实践篇/11 | “万金油”的String,为什么不好用了?.md
Normal file
@@ -0,0 +1,175 @@
|
||||
<audio id="audio" title="11 | “万金油”的String,为什么不好用了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/3b/9a7e6698a3d102e66ac5fd92f3f4b33b.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
从今天开始,我们就要进入“实践篇”了。接下来,我们会用5节课的时间学习“数据结构”。我会介绍节省内存开销以及保存和统计海量数据的数据类型及其底层数据结构,还会围绕典型的应用场景(例如地址位置查询、时间序列数据库读写和消息队列存取),跟你分享使用Redis的数据类型和module扩展功能来满足需求的具体方案。
|
||||
|
||||
今天,我们先了解下String类型的内存空间消耗问题,以及选择节省内存开销的数据类型的解决方案。
|
||||
|
||||
先跟你分享一个我曾经遇到的需求。
|
||||
|
||||
当时,我们要开发一个图片存储系统,要求这个系统能快速地记录图片ID和图片在存储系统中保存时的ID(可以直接叫作图片存储对象ID)。同时,还要能够根据图片ID快速查找到图片存储对象ID。
|
||||
|
||||
因为图片数量巨大,所以我们就用10位数来表示图片ID和图片存储对象ID,例如,图片ID为1101000051,它在存储系统中对应的ID号是3301000051。
|
||||
|
||||
```
|
||||
photo_id: 1101000051
|
||||
photo_obj_id: 3301000051
|
||||
|
||||
```
|
||||
|
||||
可以看到,图片ID和图片存储对象ID正好一一对应,是典型的“键-单值”模式。所谓的“单值”,就是指键值对中的值就是一个值,而不是一个集合,这和String类型提供的“一个键对应一个值的数据”的保存形式刚好契合。
|
||||
|
||||
而且,String类型可以保存二进制字节流,就像“万金油”一样,只要把数据转成二进制字节数组,就可以保存了。
|
||||
|
||||
所以,我们的第一个方案就是用String保存数据。我们把图片ID和图片存储对象ID分别作为键值对的key和value来保存,其中,图片存储对象ID用了String类型。
|
||||
|
||||
刚开始,我们保存了1亿张图片,大约用了6.4GB的内存。但是,随着图片数据量的不断增加,我们的Redis内存使用量也在增加,结果就遇到了大内存Redis实例因为生成RDB而响应变慢的问题。很显然,String类型并不是一种好的选择,我们还需要进一步寻找能节省内存开销的数据类型方案。
|
||||
|
||||
在这个过程中,我深入地研究了String类型的底层结构,找到了它内存开销大的原因,对“万金油”的String类型有了全新的认知:String类型并不是适用于所有场合的,它有一个明显的短板,就是它保存数据时所消耗的内存空间较多。
|
||||
|
||||
同时,我还仔细研究了集合类型的数据结构。我发现,集合类型有非常节省内存空间的底层实现结构,但是,集合类型保存的数据模式,是一个键对应一系列值,并不适合直接保存单值的键值对。所以,我们就使用二级编码的方法,实现了用集合类型保存单值键值对,Redis实例的内存空间消耗明显下降了。
|
||||
|
||||
这节课,我就把在解决这个问题时学到的经验和方法分享给你,包括String类型的内存空间消耗在哪儿了、用什么数据结构可以节省内存,以及如何用集合类型保存单值键值对。如果你在使用String类型时也遇到了内存空间消耗较多的问题,就可以尝试下今天的解决方案了。
|
||||
|
||||
接下来,我们先来看看String类型的内存都消耗在哪里了。
|
||||
|
||||
## 为什么String类型内存开销大?
|
||||
|
||||
在刚才的案例中,我们保存了1亿张图片的信息,用了约6.4GB的内存,一个图片ID和图片存储对象ID的记录平均用了64字节。
|
||||
|
||||
但问题是,一组图片ID及其存储对象ID的记录,实际只需要16字节就可以了。
|
||||
|
||||
我们来分析一下。图片ID和图片存储对象ID都是10位数,我们可以用两个8字节的Long类型表示这两个ID。因为8字节的Long类型最大可以表示2的64次方的数值,所以肯定可以表示10位数。但是,为什么String类型却用了64字节呢?
|
||||
|
||||
其实,除了记录实际数据,String类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。
|
||||
|
||||
那么,String类型具体是怎么保存数据的呢?我来解释一下。
|
||||
|
||||
当你保存64位有符号整数时,String类型会把它保存为一个8字节的Long类型整数,这种保存方式通常也叫作int编码方式。
|
||||
|
||||
但是,当你保存的数据中包含字符时,String类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/57/37c6a8d5abd65906368e7c4a6b938657.jpg" alt="">
|
||||
|
||||
- **buf**:字节数组,保存实际数据。为了表示字节数组的结束,Redis会自动在数组最后加一个“\0”,这就会额外占用1个字节的开销。
|
||||
- **len**:占4个字节,表示buf的已用长度。
|
||||
- **alloc**:也占个4字节,表示buf的实际分配长度,一般大于len。
|
||||
|
||||
可以看到,在SDS中,buf保存实际数据,而len和alloc本身其实是SDS结构体的额外开销。
|
||||
|
||||
另外,对于String类型来说,除了SDS的额外开销,还有一个来自于RedisObject结构体的开销。
|
||||
|
||||
因为Redis的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis会用一个RedisObject结构体来统一记录这些元数据,同时指向实际数据。
|
||||
|
||||
一个RedisObject包含了8字节的元数据和一个8字节指针,这个指针再进一步指向具体数据类型的实际数据所在,例如指向String类型的SDS结构所在的内存地址,可以看一下下面的示意图。关于RedisObject的具体结构细节,我会在后面的课程中详细介绍,现在你只要了解它的基本结构和元数据开销就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/57/3409948e9d3e8aa5cd7cafb9b66c2857.jpg" alt="">
|
||||
|
||||
为了节省内存空间,Redis还对Long类型整数和SDS的内存布局做了专门的设计。
|
||||
|
||||
一方面,当保存的是Long类型整数时,RedisObject中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。
|
||||
|
||||
另一方面,当保存的是字符串数据,并且字符串小于等于44字节时,RedisObject中的元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为embstr编码方式。
|
||||
|
||||
当然,当字符串大于44字节时,SDS的数据量就开始变多了,Redis就不再把SDS和RedisObject布局在一起了,而是会给SDS分配独立的空间,并用指针指向SDS结构。这种布局方式被称为raw编码模式。
|
||||
|
||||
为了帮助你理解int、embstr和raw这三种编码模式,我画了一张示意图,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/e3/ce83d1346c9642fdbbf5ffbe701bfbe3.jpg" alt="">
|
||||
|
||||
好了,知道了RedisObject所包含的额外元数据开销,现在,我们就可以计算String类型的内存使用量了。
|
||||
|
||||
因为10位数的图片ID和图片存储对象ID是Long类型整数,所以可以直接用int编码的RedisObject保存。每个int编码的RedisObject元数据部分占8字节,指针部分被直接赋值为8字节的整数了。此时,每个ID会使用16字节,加起来一共是32字节。但是,另外的32字节去哪儿了呢?
|
||||
|
||||
我在[第2讲](https://time.geekbang.org/column/article/268253)中说过,Redis会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个dictEntry的结构体,用来指向一个键值对。dictEntry结构中有三个8字节的指针,分别指向key、value以及下一个dictEntry,三个指针共24字节,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/e7/b6cbc5161388fdf4c9b49f3802ef53e7.jpg" alt="">
|
||||
|
||||
但是,这三个指针只有24字节,为什么会占用了32字节呢?这就要提到Redis使用的内存分配库jemalloc了。
|
||||
|
||||
jemalloc在分配内存时,会根据我们申请的字节数N,找一个比N大,但是最接近N的2的幂次数作为分配的空间,这样可以减少频繁分配的次数。
|
||||
|
||||
举个例子。如果你申请6字节空间,jemalloc实际会分配8字节空间;如果你申请24字节空间,jemalloc则会分配32字节。所以,在我们刚刚说的场景里,dictEntry结构就占用了32字节。
|
||||
|
||||
好了,到这儿,你应该就能理解,为什么用String类型保存图片ID和图片存储对象ID时需要用64个字节了。
|
||||
|
||||
你看,明明有效信息只有16字节,使用String类型保存时,却需要64字节的内存空间,有48字节都没有用于保存实际的数据。我们来换算下,如果要保存的图片有1亿张,那么1亿条的图片ID记录就需要6.4GB内存空间,其中有4.8GB的内存空间都用来保存元数据了,额外的内存空间开销很大。那么,有没有更加节省内存的方法呢?
|
||||
|
||||
## 用什么数据结构可以节省内存?
|
||||
|
||||
Redis有一种底层数据结构,叫压缩列表(ziplist),这是一种非常节省内存的结构。
|
||||
|
||||
我们先回顾下压缩列表的构成。表头有三个字段zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量,以及列表中的entry个数。压缩列表尾还有一个zlend,表示列表结束。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/9f/f6d4df5f7d6e80de29e2c6446b02429f.jpg" alt="">
|
||||
|
||||
压缩列表之所以能节省内存,就在于它是用一系列连续的entry保存数据。每个entry的元数据包括下面几部分。
|
||||
|
||||
- **prev_len**,表示前一个entry的长度。prev_len有两种取值情况:1字节或5字节。取值1字节时,表示上一个entry的长度小于254字节。虽然1字节的值能表示的数值范围是0到255,但是压缩列表中zlend的取值默认是255,因此,就默认用255表示整个压缩列表的结束,其他表示长度的地方就不能再用255这个值了。所以,当上一个entry长度小于254字节时,prev_len取值为1字节,否则,就取值为5字节。
|
||||
- **len**:表示自身长度,4字节;
|
||||
- **encoding**:表示编码方式,1字节;
|
||||
- **content**:保存实际数据。
|
||||
|
||||
这些entry会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。
|
||||
|
||||
我们以保存图片存储对象ID为例,来分析一下压缩列表是如何节省内存空间的。
|
||||
|
||||
每个entry保存一个图片存储对象ID(8字节),此时,每个entry的prev_len只需要1个字节就行,因为每个entry的前一个entry长度都只有8字节,小于254字节。这样一来,一个图片的存储对象ID所占用的内存大小是14字节(1+4+1+8=14),实际分配16字节。
|
||||
|
||||
Redis基于压缩列表实现了List、Hash和Sorted Set这样的集合类型,这样做的最大好处就是节省了dictEntry的开销。当你用String类型时,一个键值对就有一个dictEntry,要用32字节空间。但采用集合类型时,一个key就对应一个集合的数据,能保存的数据多了很多,但也只用了一个dictEntry,这样就节省了内存。
|
||||
|
||||
这个方案听起来很好,但还存在一个问题:在用集合类型保存键值对时,一个键对应了一个集合的数据,但是在我们的场景中,一个图片ID只对应一个图片的存储对象ID,我们该怎么用集合类型呢?换句话说,在一个键对应一个值(也就是单值键值对)的情况下,我们该怎么用集合类型来保存这种单值键值对呢?
|
||||
|
||||
## 如何用集合类型保存单值的键值对?
|
||||
|
||||
在保存单值的键值对时,可以采用基于Hash类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为Hash集合的key,后一部分作为Hash集合的value,这样一来,我们就可以把单值数据保存到Hash集合中了。
|
||||
|
||||
以图片ID 1101000060和图片存储对象ID 3302000080为例,我们可以把图片ID的前7位(1101000)作为Hash类型的键,把图片ID的最后3位(060)和图片存储对象ID分别作为Hash类型值中的key和value。
|
||||
|
||||
按照这种设计方法,我在Redis中插入了一组图片ID及其存储对象ID的记录,并且用info命令查看了内存开销,我发现,增加一条记录后,内存占用只增加了16字节,如下所示:
|
||||
|
||||
```
|
||||
127.0.0.1:6379> info memory
|
||||
# Memory
|
||||
used_memory:1039120
|
||||
127.0.0.1:6379> hset 1101000 060 3302000080
|
||||
(integer) 1
|
||||
127.0.0.1:6379> info memory
|
||||
# Memory
|
||||
used_memory:1039136
|
||||
|
||||
```
|
||||
|
||||
在使用String类型时,每个记录需要消耗64字节,这种方式却只用了16字节,所使用的内存空间是原来的1/4,满足了我们节省内存空间的需求。
|
||||
|
||||
不过,你可能也会有疑惑:“二级编码一定要把图片ID的前7位作为Hash类型的键,把最后3位作为Hash类型值中的key吗?”**其实,二级编码方法中采用的ID长度是有讲究的**。
|
||||
|
||||
在[第2讲](https://time.geekbang.org/column/article/268253)中,我介绍过Redis Hash类型的两种底层实现结构,分别是压缩列表和哈希表。
|
||||
|
||||
那么,Hash类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢?其实,Hash类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash类型就会用哈希表来保存数据了。
|
||||
|
||||
这两个阈值分别对应以下两个配置项:
|
||||
|
||||
- hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
|
||||
- hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
|
||||
|
||||
如果我们往Hash集合中写入的元素个数超过了hash-max-ziplist-entries,或者写入的单个元素大小超过了hash-max-ziplist-value,Redis就会自动把Hash类型的实现结构由压缩列表转为哈希表。
|
||||
|
||||
一旦从压缩列表转为了哈希表,Hash类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。
|
||||
|
||||
**为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在Hash集合中的元素个数**。所以,在刚才的二级编码中,我们只用图片ID最后3位作为Hash集合的key,也就保证了Hash集合的元素个数不超过1000,同时,我们把hash-max-ziplist-entries设置为1000,这样一来,Hash集合就可以一直使用压缩列表来节省内存空间了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们打破了对String的认知误区,以前,我们认为String是“万金油”,什么场合都适用,但是,在保存的键值对本身占用的内存空间不大时(例如这节课里提到的的图片ID和图片存储对象ID),String类型的元数据开销就占据主导了,这里面包括了RedisObject结构、SDS结构、dictEntry结构的内存开销。
|
||||
|
||||
针对这种情况,我们可以使用压缩列表保存数据。当然,使用Hash这种集合类型保存单值键值对的数据时,我们需要将单值数据拆分成两部分,分别作为Hash集合的键和值,就像刚才案例中用二级编码来表示图片ID,希望你能把这个方法用到自己的场景中。
|
||||
|
||||
最后,我还想再给你提供一个小方法:如果你想知道键值对采用不同类型保存时的内存开销,可以在[这个网址](http://www.redis.cn/redis_memory/)里输入你的键值对长度和使用的数据类型,这样就能知道实际消耗的内存大小了。建议你把这个小工具用起来,它可以帮助你充分地节省内存。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,给你提个小问题:除了String类型和Hash类型,你觉得,还有其他合适的类型可以应用在这节课所说的保存图片的例子吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论,也欢迎你把今天的内容分享给你的朋友。
|
||||
265
极客时间专栏/Redis核心技术与实战/实践篇/12 | 有一亿个keys要统计,应该用哪种集合?.md
Normal file
265
极客时间专栏/Redis核心技术与实战/实践篇/12 | 有一亿个keys要统计,应该用哪种集合?.md
Normal file
@@ -0,0 +1,265 @@
|
||||
<audio id="audio" title="12 | 有一亿个keys要统计,应该用哪种集合?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/92/697b9d7ce3152b5636450e5a571e9c92.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在Web和移动应用的业务场景中,我们经常需要保存这样一种信息:一个key对应了一个数据集合。我举几个例子。
|
||||
|
||||
- 手机App中的每天的用户登录信息:一天对应一系列用户ID或移动设备ID;
|
||||
- 电商网站上商品的用户评论列表:一个商品对应了一系列的评论;
|
||||
- 用户在手机App上的签到打卡信息:一天对应一系列用户的签到记录;
|
||||
- 应用网站上的网页访问信息:一个网页对应一系列的访问点击。
|
||||
|
||||
我们知道,Redis集合类型的特点就是一个键对应一系列的数据,所以非常适合用来存取这些数据。但是,在这些场景中,除了记录信息,我们往往还需要对集合中的数据进行统计,例如:
|
||||
|
||||
- 在移动应用中,需要统计每天的新增用户数和第二天的留存用户数;
|
||||
- 在电商网站的商品评论中,需要统计评论列表中的最新评论;
|
||||
- 在签到打卡中,需要统计一个月内连续打卡的用户数;
|
||||
- 在网页访问记录中,需要统计独立访客(Unique Visitor,UV)量。
|
||||
|
||||
通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。所以,我们必须要选择能够非常高效地统计大量数据(例如亿级)的集合类型。
|
||||
|
||||
**要想选择合适的集合,我们就得了解常用的集合统计模式。**这节课,我就给你介绍集合类型常见的四种统计模式,包括聚合统计、排序统计、二值状态统计和基数统计。我会以刚刚提到的这四个场景为例,和你聊聊在这些统计模式下,什么集合类型能够更快速地完成统计,而且还节省内存空间。掌握了今天的内容,之后再遇到集合元素统计问题时,你就能很快地选出合适的集合类型了。
|
||||
|
||||
## 聚合统计
|
||||
|
||||
我们先来看集合元素统计的第一个场景:聚合统计。
|
||||
|
||||
所谓的聚合统计,就是指统计多个集合元素的聚合结果,包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)。
|
||||
|
||||
在刚才提到的场景中,统计手机App每天的新增用户数和第二天的留存用户数,正好对应了聚合统计。
|
||||
|
||||
要完成这个统计任务,我们可以用一个集合记录所有登录过App的用户ID,同时,用另一个集合记录每一天登录过App的用户ID。然后,再对这两个集合做聚合统计。我们来看下具体的操作。
|
||||
|
||||
记录所有登录过App的用户ID还是比较简单的,我们可以直接使用Set类型,把key设置为user:id,表示记录的是用户ID,value就是一个Set集合,里面是所有登录过App的用户ID,我们可以把这个Set叫作累计用户Set,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/ca/990e56babf199d9a7fa4c7343167ecca.jpg" alt="">
|
||||
|
||||
需要注意的是,累计用户Set中没有日期信息,我们是不能直接统计每天的新增用户的。所以,我们还需要把每一天登录的用户ID,记录到一个新集合中,我们把这个集合叫作每日用户Set,它有两个特点:
|
||||
|
||||
1. key是 user:id 以及当天日期,例如 user:id:20200803;
|
||||
1. value是Set集合,记录当天登录的用户ID。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/9e/a63dd95d5e44bf538fe960e67761b59e.jpg" alt="">
|
||||
|
||||
在统计每天的新增用户时,我们只用计算每日用户Set和累计用户Set的差集就行。
|
||||
|
||||
我借助一个具体的例子来解释一下。
|
||||
|
||||
假设我们的手机App在2020年8月3日上线,那么,8月3日前是没有用户的。此时,累计用户Set是空集,当天登录的用户ID会被记录到 key为user:id:20200803的Set中。所以,user:id:20200803这个Set中的用户就是当天的新增用户。
|
||||
|
||||
然后,我们计算累计用户Set和user:id:20200803 Set的并集结果,结果保存在user:id这个累计用户Set中,如下所示:
|
||||
|
||||
```
|
||||
SUNIONSTORE user:id user:id user:id:20200803
|
||||
|
||||
```
|
||||
|
||||
此时,user:id这个累计用户Set中就有了8月3日的用户ID。等到8月4日再统计时,我们把8月4日登录的用户ID记录到user:id:20200804 的Set中。接下来,我们执行SDIFFSTORE命令计算累计用户Set和user:id:20200804 Set的差集,结果保存在key为user:new的Set中,如下所示:
|
||||
|
||||
```
|
||||
SDIFFSTORE user:new user:id:20200804 user:id
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个差集中的用户ID在user:id:20200804 的Set中存在,但是不在累计用户Set中。所以,user:new这个Set中记录的就是8月4日的新增用户。
|
||||
|
||||
当要计算8月4日的留存用户时,我们只需要再计算user:id:20200803 和 user:id:20200804两个Set的交集,就可以得到同时在这两个集合中的用户ID了,这些就是在8月3日登录,并且在8月4日留存的用户。执行的命令如下:
|
||||
|
||||
```
|
||||
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804
|
||||
|
||||
```
|
||||
|
||||
当你需要对多个集合进行聚合计算时,Set类型会是一个非常不错的选择。不过,我要提醒你一下,这里有一个潜在的风险。
|
||||
|
||||
Set的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致Redis实例阻塞。所以,我给你分享一个小建议:**你可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计**,这样就可以规避阻塞主库实例和其他从库实例的风险了。
|
||||
|
||||
## 排序统计
|
||||
|
||||
接下来,我们再来聊一聊应对集合元素排序需求的方法。我以在电商网站上提供最新评论列表的场景为例,进行讲解。
|
||||
|
||||
最新评论列表包含了所有评论中的最新留言,**这就要求集合类型能对元素保序**,也就是说,集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。
|
||||
|
||||
在Redis常用的4个集合类型中(List、Hash、Set、Sorted Set),List和Sorted Set就属于有序集合。
|
||||
|
||||
**List是按照元素进入List的顺序进行排序的,而Sorted Set可以根据元素的权重来排序**,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入Sorted Set的时间确定权重值,先插入的元素权重小,后插入的元素权重大。
|
||||
|
||||
看起来好像都可以满足需求,我们该怎么选择呢?
|
||||
|
||||
我先说说用List的情况。每个商品对应一个List,这个List包含了对这个商品的所有评论,而且会按照评论时间保存这些评论,每来一个新评论,就用LPUSH命令把它插入List的队头。
|
||||
|
||||
在只有一页评论的时候,我们可以很清晰地看到最新的评论,但是,在实际应用中,网站一般会分页显示最新的评论列表,一旦涉及到分页操作,List就可能会出现问题了。
|
||||
|
||||
假设当前的评论List是{A, B, C, D, E, F}(其中,A是最新的评论,以此类推,F是最早的评论),在展示第一页的3个评论时,我们可以用下面的命令,得到最新的三条评论A、B、C:
|
||||
|
||||
```
|
||||
LRANGE product1 0 2
|
||||
1) "A"
|
||||
2) "B"
|
||||
3) "C"
|
||||
|
||||
```
|
||||
|
||||
然后,再用下面的命令获取第二页的3个评论,也就是D、E、F。
|
||||
|
||||
```
|
||||
LRANGE product1 3 5
|
||||
1) "D"
|
||||
2) "E"
|
||||
3) "F"
|
||||
|
||||
```
|
||||
|
||||
但是,如果在展示第二页前,又产生了一个新评论G,评论G就会被LPUSH命令插入到评论List的队头,评论List就变成了{G, A, B, C, D, E, F}。此时,再用刚才的命令获取第二页评论时,就会发现,评论C又被展示出来了,也就是C、D、E。
|
||||
|
||||
```
|
||||
LRANGE product1 3 5
|
||||
1) "C"
|
||||
2) "D"
|
||||
3) "E"
|
||||
|
||||
```
|
||||
|
||||
之所以会这样,关键原因就在于,List是通过元素在List中的位置来排序的,当有一个新元素插入时,原先的元素在List中的位置都后移了一位,比如说原来在第1位的元素现在排在了第2位。所以,对比新元素插入前后,List相同位置上的元素就会发生变化,用LRANGE读取时,就会读到旧元素。
|
||||
|
||||
和List相比,Sorted Set就不存在这个问题,因为它是根据元素的实际权重来排序和获取数据的。
|
||||
|
||||
我们可以按评论时间的先后给每条评论设置一个权重值,然后再把评论保存到Sorted Set中。Sorted Set的ZRANGEBYSCORE命令就可以按权重排序后返回元素。这样的话,即使集合中的元素频繁更新,Sorted Set也能通过ZRANGEBYSCORE命令准确地获取到按序排列的数据。
|
||||
|
||||
假设越新的评论权重越大,目前最新评论的权重是N,我们执行下面的命令时,就可以获得最新的10条评论:
|
||||
|
||||
```
|
||||
ZRANGEBYSCORE comments N-9 N
|
||||
|
||||
```
|
||||
|
||||
所以,在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议你优先考虑使用Sorted Set。
|
||||
|
||||
## 二值状态统计
|
||||
|
||||
现在,我们再来分析下第三个场景:二值状态统计。这里的二值状态就是指集合元素的取值就只有0和1两种。在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态,
|
||||
|
||||
在签到统计时,每个用户一天的签到用1个bit位就能表示,一个月(假设是31天)的签到情况用31个bit位就可以,而一年的签到也只需要用365个bit位,根本不用太复杂的集合类型。这个时候,我们就可以选择Bitmap。这是Redis提供的扩展数据类型。我来给你解释一下它的实现原理。
|
||||
|
||||
Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。String类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态。你可以把Bitmap看作是一个bit数组。
|
||||
|
||||
Bitmap提供了GETBIT/SETBIT操作,使用一个偏移值offset对bit数组的某一个bit位进行读和写。不过,需要注意的是,Bitmap的偏移量是从0开始算的,也就是说offset的最小值是0。当使用SETBIT对一个bit位进行写操作时,这个bit位会被设置为1。Bitmap还提供了BITCOUNT操作,用来统计这个bit数组中所有“1”的个数。
|
||||
|
||||
那么,具体该怎么用Bitmap进行签到统计呢?我还是借助一个具体的例子来说明。
|
||||
|
||||
假设我们要统计ID 3000的用户在2020年8月份的签到情况,就可以按照下面的步骤进行操作。
|
||||
|
||||
第一步,执行下面的命令,记录该用户8月3号已签到。
|
||||
|
||||
```
|
||||
SETBIT uid:sign:3000:202008 2 1
|
||||
|
||||
```
|
||||
|
||||
第二步,检查该用户8月3日是否签到。
|
||||
|
||||
```
|
||||
GETBIT uid:sign:3000:202008 2
|
||||
|
||||
```
|
||||
|
||||
第三步,统计该用户在8月份的签到次数。
|
||||
|
||||
```
|
||||
BITCOUNT uid:sign:3000:202008
|
||||
|
||||
```
|
||||
|
||||
这样,我们就知道该用户在8月份的签到情况了,是不是很简单呢?接下来,你可以再思考一个问题:如果记录了1亿个用户10天的签到情况,你有办法统计出这10天连续签到的用户总数吗?
|
||||
|
||||
在介绍具体的方法之前,我们要先知道,Bitmap支持用BITOP命令对多个Bitmap按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的Bitmap中。
|
||||
|
||||
我以按位“与”操作为例来具体解释一下。从下图中,可以看到,三个Bitmap bm1、bm2和bm3,对应bit位做“与”操作,结果保存到了一个新的Bitmap中(示例中,这个结果Bitmap的key被设为“resmap”)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/7a/4151af42513cf5f7996fe86c6064f97a.jpg" alt="">
|
||||
|
||||
回到刚刚的问题,在统计1亿个用户连续10天的签到情况时,你可以把每天的日期作为key,每个key对应一个1亿位的Bitmap,每一个bit对应一个用户当天的签到情况。
|
||||
|
||||
接下来,我们对10个Bitmap做“与”操作,得到的结果也是一个Bitmap。在这个Bitmap中,只有10天都签到的用户对应的bit位上的值才会是1。最后,我们可以用BITCOUNT统计下Bitmap中的1的个数,这就是连续签到10天的用户总数了。
|
||||
|
||||
现在,我们可以计算一下记录了10天签到情况后的内存开销。每天使用1个1亿位的Bitmap,大约占12MB的内存(10^8/8/1024/1024),10天的Bitmap的内存开销约为120MB,内存压力不算太大。不过,在实际应用时,最好对Bitmap设置过期时间,让Redis自动删除不再需要的签到记录,以节省内存开销。
|
||||
|
||||
所以,如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用Bitmap,因为它只用一个bit位就能表示0或1。在记录海量数据时,Bitmap能够有效地节省内存空间。
|
||||
|
||||
## 基数统计
|
||||
|
||||
最后,我们再来看一个统计场景:基数统计。基数统计就是指统计一个集合中不重复的元素个数。对应到我们刚才介绍的场景中,就是统计网页的UV。
|
||||
|
||||
网页UV的统计有个独特的地方,就是需要去重,一个用户一天内的多次访问只能算作一次。在Redis的集合类型中,Set类型默认支持去重,所以看到有去重需求时,我们可能第一时间就会想到用Set类型。
|
||||
|
||||
我们来结合一个例子看一看用Set的情况。
|
||||
|
||||
有一个用户user1访问page1时,你把这个信息加到Set中:
|
||||
|
||||
```
|
||||
SADD page1:uv user1
|
||||
|
||||
```
|
||||
|
||||
用户1再来访问时,Set的去重功能就保证了不会重复记录用户1的访问次数,这样,用户1就算是一个独立访客。当你需要统计UV时,可以直接用SCARD命令,这个命令会返回一个集合中的元素个数。
|
||||
|
||||
但是,如果page1非常火爆,UV达到了千万,这个时候,一个Set就要记录千万个用户ID。对于一个搞大促的电商网站而言,这样的页面可能有成千上万个,如果每个页面都用这样的一个Set,就会消耗很大的内存空间。
|
||||
|
||||
当然,你也可以用Hash类型记录UV。
|
||||
|
||||
例如,你可以把用户ID作为Hash集合的key,当用户访问页面时,就用HSET命令(用于设置Hash集合元素的值),对这个用户ID记录一个值“1”,表示一个独立访客,用户1访问page1后,我们就记录为1个独立访客,如下所示:
|
||||
|
||||
```
|
||||
HSET page1:uv user1 1
|
||||
|
||||
```
|
||||
|
||||
即使用户1多次访问页面,重复执行这个HSET命令,也只会把user1的值设置为1,仍然只记为1个独立访客。当要统计UV时,我们可以用HLEN命令统计Hash集合中的所有元素个数。
|
||||
|
||||
但是,和Set类型相似,当页面很多时,Hash类型也会消耗很大的内存空间。那么,有什么办法既能完成统计,还能节省内存吗?
|
||||
|
||||
这时候,就要用到Redis提供的HyperLogLog了。
|
||||
|
||||
HyperLogLog是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。
|
||||
|
||||
在Redis中,每个 HyperLogLog只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。你看,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。
|
||||
|
||||
在统计UV时,你可以用PFADD命令(用于向HyperLogLog中添加新元素)把访问页面的每个用户都添加到HyperLogLog中。
|
||||
|
||||
```
|
||||
PFADD page1:uv user1 user2 user3 user4 user5
|
||||
|
||||
```
|
||||
|
||||
接下来,就可以用PFCOUNT命令直接获得page1的UV值了,这个命令的作用就是返回HyperLogLog的统计结果。
|
||||
|
||||
```
|
||||
PFCOUNT page1:uv
|
||||
|
||||
```
|
||||
|
||||
关于HyperLogLog的具体实现原理,你不需要重点掌握,不会影响到你的日常使用,我就不多讲了。如果你想了解一下,课下可以看看[这条链接](http://en.wikipedia.org/wiki/HyperLogLog)。
|
||||
|
||||
不过,有一点需要你注意一下,HyperLogLog的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是0.81%。这也就意味着,你使用HyperLogLog统计的UV是100万,但实际的UV可能是101万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用Set或Hash类型。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们结合统计新增用户数和留存用户数、最新评论列表、用户签到数以及网页独立访客量这4种典型场景,学习了集合类型的4种统计模式,分别是聚合统计、排序统计、二值状态统计和基数统计。为了方便你掌握,我把Set、Sorted Set、Hash、List、Bitmap、HyperLogLog的支持情况和优缺点汇总在了下面的表格里,希望你把这张表格保存下来,时不时地复习一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/6e/c0bb35d0d91a62ef4ca1bd939a9b136e.jpg" alt="">
|
||||
|
||||
可以看到,Set和Sorted Set都支持多种聚合统计,不过,对于差集计算来说,只有Set支持。Bitmap也能做多个Bitmap间的聚合计算,包括与、或和异或操作。
|
||||
|
||||
当需要进行排序统计时,List中的元素虽然有序,但是一旦有新元素插入,原来的元素在List中的位置就会移动,那么,按位置读取的排序结果可能就不准确了。而Sorted Set本身是按照集合元素的权重排序,可以准确地按序获取结果,所以建议你优先使用它。
|
||||
|
||||
如果我们记录的数据只有0和1两个值的状态,Bitmap会是一个很好的选择,这主要归功于Bitmap对于一个数据只用1个bit记录,可以节省内存。
|
||||
|
||||
对于基数统计来说,如果集合元素量达到亿级别而且不需要精确统计时,我建议你使用HyperLogLog。
|
||||
|
||||
当然,Redis的应用场景非常多,这张表中的总结不一定能覆盖到所有场景。我建议你也试着自己画一张表,把你遇到的其他场景添加进去。长久积累下来,你一定能够更加灵活地把集合类型应用到合适的实践项目中。
|
||||
|
||||
## 每课一问
|
||||
|
||||
依照惯例,我给你留个小问题。这节课,我们学习了4种典型的统计模式,以及各种集合类型的支持情况和优缺点,我想请你聊一聊,你还遇到过其他的统计场景吗?用的是怎样的集合类型呢?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,和我交流讨论。如果你身边还有需要解决这些统计问题的朋友或同事,也欢迎你把今天的内容分享给他/她,我们下节课见。
|
||||
297
极客时间专栏/Redis核心技术与实战/实践篇/13 | GEO是什么?还可以定义新的数据类型吗?.md
Normal file
297
极客时间专栏/Redis核心技术与实战/实践篇/13 | GEO是什么?还可以定义新的数据类型吗?.md
Normal file
@@ -0,0 +1,297 @@
|
||||
<audio id="audio" title="13 | GEO是什么?还可以定义新的数据类型吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/2a/93f468d9cc61a67ba5459e8c1784c92a.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在[第2讲](https://time.geekbang.org/column/article/268253)中,我们学习了Redis的5大基本数据类型:String、List、Hash、Set和Sorted Set,它们可以满足大多数的数据存储需求,但是在面对海量数据统计时,它们的内存开销很大,而且对于一些特殊的场景,它们是无法支持的。所以,Redis还提供了3种扩展数据类型,分别是Bitmap、HyperLogLog和GEO。前两种我在上节课已经重点介绍过了,今天,我再具体讲一讲GEO。
|
||||
|
||||
另外,我还会给你介绍开发自定义的新数据类型的基本步骤。掌握了自定义数据类型的开发方法,当你面临一些复杂的场景时,就不用受基本数据类型的限制,可以直接在Redis中增加定制化的数据类型,来满足你的特殊需求。
|
||||
|
||||
接下来,我们就先来了解下扩展数据类型GEO的实现原理和使用方法。
|
||||
|
||||
## 面向LBS应用的GEO数据类型
|
||||
|
||||
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO就非常适合应用在LBS服务的场景中,我们来看一下它的底层结构。
|
||||
|
||||
### GEO的底层结构
|
||||
|
||||
**一般来说,在设计一个数据类型的底层结构时,我们首先需要知道,要处理的数据有什么访问特点**。所以,我们需要先搞清楚位置信息到底是怎么被存取的。
|
||||
|
||||
我以叫车服务为例,来分析下LBS应用中经纬度的存取特点。
|
||||
|
||||
1. 每一辆网约车都有一个编号(例如33),网约车需要将自己的经度信息(例如116.034579)和纬度信息(例如39.000452 )发给叫车应用。
|
||||
1. 用户在叫车的时候,叫车应用会根据用户的经纬度位置(例如经度116.054579,纬度39.030452),查找用户的附近车辆,并进行匹配。
|
||||
1. 等把位置相近的用户和车辆匹配上以后,叫车应用就会根据车辆的编号,获取车辆的信息,并返回给用户。
|
||||
|
||||
可以看到,一辆车(或一个用户)对应一组经纬度,并且随着车(或用户)的位置移动,相应的经纬度也会变化。
|
||||
|
||||
这种数据记录模式属于一个key(例如车ID)对应一个value(一组经纬度)。当有很多车辆信息要保存时,就需要有一个集合来保存一系列的key和value。Hash集合类型可以快速存取一系列的key和value,正好可以用来记录一系列车辆ID和经纬度的对应关系,所以,我们可以把不同车辆的ID和它们对应的经纬度信息存在Hash集合中,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/0e/c8d3f1951874da0d916ed51ccdce9e0e.jpg" alt="">
|
||||
|
||||
同时,Hash类型的HSET操作命令,会根据key来设置相应的value值,所以,我们可以用它来快速地更新车辆变化的经纬度信息。
|
||||
|
||||
到这里,Hash类型看起来是一个不错的选择。但问题是,对于一个LBS应用来说,除了记录经纬度信息,还需要根据用户的经纬度信息在车辆的Hash集合中进行范围查询。一旦涉及到范围查询,就意味着集合中的元素需要有序,但Hash类型的元素是无序的,显然不能满足我们的要求。
|
||||
|
||||
我们再来看看使用**Sorted Set类型**是不是合适。
|
||||
|
||||
Sorted Set类型也支持一个key对应一个value的记录模式,其中,key就是Sorted Set中的元素,而value则是元素的权重分数。更重要的是,Sorted Set可以根据元素的权重分数排序,支持范围查询。这就能满足LBS服务中查找相邻位置的需求了。
|
||||
|
||||
实际上,GEO类型的底层数据结构就是用Sorted Set来实现的。咱们还是借着叫车应用的例子来加深下理解。
|
||||
|
||||
用Sorted Set来保存车辆的经纬度信息时,Sorted Set的元素是车辆ID,元素的权重分数是经纬度信息,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/4e/a9a6bc78ea3bb652ef1404020dd2934e.jpg" alt="">
|
||||
|
||||
这时问题来了,Sorted Set元素的权重分数是一个浮点数(float类型),而一组经纬度包含的是经度和纬度两个值,是没法直接保存为一个浮点数的,那具体该怎么进行保存呢?
|
||||
|
||||
这就要用到GEO类型中的GeoHash编码了。
|
||||
|
||||
### GeoHash的编码方法
|
||||
|
||||
为了能高效地对经纬度进行比较,Redis采用了业界广泛使用的GeoHash编码方法,这个方法的基本原理就是“二分区间,区间编码”。
|
||||
|
||||
当我们要对一组经纬度进行GeoHash编码时,我们要先对经度和纬度分别编码,然后再把经纬度各自的编码组合成一个最终编码。
|
||||
|
||||
首先,我们来看下经度和纬度的单独编码过程。
|
||||
|
||||
对于一个地理位置信息来说,它的经度范围是[-180,180]。GeoHash编码会把一个经度值编码成一个N位的二进制值,我们来对经度范围[-180,180]做N次的二分区操作,其中N可以自定义。
|
||||
|
||||
在进行第一次二分区时,经度范围[-180,180]会被分成两个子区间:[-180,0)和[0,180](我称之为左、右分区)。此时,我们可以查看一下要编码的经度值落在了左分区还是右分区。如果是落在左分区,我们就用0表示;如果落在右分区,就用1表示。这样一来,每做完一次二分区,我们就可以得到1位编码值。
|
||||
|
||||
然后,我们再对经度值所属的分区再做一次二分区,同时再次查看经度值落在了二分区后的左分区还是右分区,按照刚才的规则再做1位编码。当做完N次的二分区后,经度值就可以用一个N bit的数来表示了。
|
||||
|
||||
举个例子,假设我们要编码的经度值是116.37,我们用5位编码值(也就是N=5,做5次分区)。
|
||||
|
||||
我们先做第一次二分区操作,把经度区间[-180,180]分成了左分区[-180,0)和右分区[0,180],此时,经度值116.37是属于右分区[0,180],所以,我们用1表示第一次二分区后的编码值。
|
||||
|
||||
接下来,我们做第二次二分区:把经度值116.37所属的[0,180]区间,分成[0,90)和[90, 180]。此时,经度值116.37还是属于右分区[90,180],所以,第二次分区后的编码值仍然为1。等到第三次对[90,180]进行二分区,经度值116.37落在了分区后的左分区[90, 135)中,所以,第三次分区后的编码值就是0。
|
||||
|
||||
按照这种方法,做完5次分区后,我们把经度值116.37定位在[112.5, 123.75]这个区间,并且得到了经度值的5位编码值,即11010。这个编码过程如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/f2/3cb007yy63c820d6dd2e4999608683f2.jpg" alt="">
|
||||
|
||||
对纬度的编码方式,和对经度的一样,只是纬度的范围是[-90,90],下面这张表显示了对纬度值39.86的编码过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/6d/65f41469866cb94963b4c9afbf2b016d.jpg" alt="">
|
||||
|
||||
当一组经纬度值都编完码后,我们再把它们的各自编码值组合在一起,组合的规则是:最终编码值的偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值,其中,偶数位从0开始,奇数位从1开始。
|
||||
|
||||
我们刚刚计算的经纬度(116.37,39.86)的各自编码值是11010和10111,组合之后,第0位是经度的第0位1,第1位是纬度的第0位1,第2位是经度的第1位1,第3位是纬度的第1位0,以此类推,就能得到最终编码值1110011101,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/87/4a8296e841f18ed4f3a554703ebd5887.jpg" alt="">
|
||||
|
||||
用了GeoHash编码后,原来无法用一个权重分数表示的一组经纬度(116.37,39.86)就可以用1110011101这一个值来表示,就可以保存为Sorted Set的权重分数了。
|
||||
|
||||
当然,使用GeoHash编码后,我们相当于把整个地理空间划分成了一个个方格,每个方格对应了GeoHash中的一个分区。
|
||||
|
||||
举个例子。我们把经度区间[-180,180]做一次二分区,把纬度区间[-90,90]做一次二分区,就会得到4个分区。我们来看下它们的经度和纬度范围以及对应的GeoHash组合编码。
|
||||
|
||||
- 分区一:[-180,0)和[-90,0),编码00;
|
||||
- 分区二:[-180,0)和[0,90],编码01;
|
||||
- 分区三:[0,180]和[-90,0),编码10;
|
||||
- 分区四:[0,180]和[0,90],编码11。
|
||||
|
||||
这4个分区对应了4个方格,每个方格覆盖了一定范围内的经纬度值,分区越多,每个方格能覆盖到的地理空间就越小,也就越精准。我们把所有方格的编码值映射到一维空间时,相邻方格的GeoHash编码值基本也是接近的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/74/2a2a650086acf9700c0603a4be8ceb74.jpg" alt="">
|
||||
|
||||
所以,我们使用Sorted Set范围查询得到的相近编码值,在实际的地理空间上,也是相邻的方格,这就可以实现LBS应用“搜索附近的人或物”的功能了。
|
||||
|
||||
不过,我要提醒你一句,有的编码值虽然在大小上接近,但实际对应的方格却距离比较远。例如,我们用4位来做GeoHash编码,把经度区间[-180,180]和纬度区间[-90,90]各分成了4个分区,一共16个分区,对应了16个方格。编码值为0111和1000的两个方格就离得比较远,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/ba/0d64c9765ab72a50abef16a0275bc0ba.jpg" alt="">
|
||||
|
||||
所以,为了避免查询不准确问题,我们可以同时查询给定经纬度所在的方格周围的4个或8个方格。
|
||||
|
||||
好了,到这里,我们就知道了,GEO类型是把经纬度所在的区间编码作为Sorted Set中元素的权重分数,把和经纬度相关的车辆ID作为Sorted Set中元素本身的值保存下来,这样相邻经纬度的查询就可以通过编码值的大小范围查询来实现了。接下来,我们再来聊聊具体如何操作GEO类型。
|
||||
|
||||
### 如何操作GEO类型?
|
||||
|
||||
在使用GEO类型时,我们经常会用到两个命令,分别是GEOADD和GEORADIUS。
|
||||
|
||||
- GEOADD命令:用于把一组经纬度信息和相对应的一个ID记录到GEO类型集合中;
|
||||
- GEORADIUS命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。当然,我们可以自己定义这个范围。
|
||||
|
||||
我还是以叫车应用的车辆匹配场景为例,介绍下具体如何使用这两个命令。
|
||||
|
||||
假设车辆ID是33,经纬度位置是(116.034579,39.030452),我们可以用一个GEO集合保存所有车辆的经纬度,集合key是cars:locations。执行下面的这个命令,就可以把ID号为33的车辆的当前经纬度位置存入GEO集合中:
|
||||
|
||||
```
|
||||
GEOADD cars:locations 116.034579 39.030452 33
|
||||
|
||||
```
|
||||
|
||||
当用户想要寻找自己附近的网约车时,LBS应用就可以使用GEORADIUS命令。
|
||||
|
||||
例如,LBS应用执行下面的命令时,Redis会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的5公里内的车辆信息,并返回给LBS应用。当然, 你可以修改“5”这个参数,来返回更大或更小范围内的车辆信息。
|
||||
|
||||
```
|
||||
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
|
||||
|
||||
```
|
||||
|
||||
另外,我们还可以进一步限定返回的车辆信息。
|
||||
|
||||
比如,我们可以使用ASC选项,让返回的车辆信息按照距离这个中心位置从近到远的方式来排序,以方便选择最近的车辆;还可以使用COUNT选项,指定返回的车辆信息的数量。毕竟,5公里范围内的车辆可能有很多,如果返回全部信息,会占用比较多的数据带宽,这个选项可以帮助控制返回的数据量,节省带宽。
|
||||
|
||||
可以看到,使用GEO数据类型可以非常轻松地操作经纬度这种信息。
|
||||
|
||||
虽然我们有了5种基本类型和3种扩展数据类型,但是有些场景下,我们对数据类型会有特殊需求,例如,我们需要一个数据类型既能像Hash那样支持快速的单键查询,又能像Sorted Set那样支持范围查询,此时,我们之前学习的这些数据类型就无法满足需求了。那么,接下来,我就再向你介绍下Redis扩展数据类型的终极版——自定义的数据类型。这样,你就可以定制符合自己需求的数据类型了,不管你的应用场景怎么变化,你都不用担心没有合适的数据类型。
|
||||
|
||||
## 如何自定义数据类型?
|
||||
|
||||
为了实现自定义数据类型,首先,我们需要了解Redis的基本对象结构RedisObject,因为Redis键值对中的每一个值都是用RedisObject保存的。
|
||||
|
||||
我在[第11讲](https://time.geekbang.org/column/article/279649)中说过,RedisObject包括元数据和指针。其中,元数据的一个功能就是用来区分不同的数据类型,指针用来指向具体的数据类型的值。所以,要想开发新数据类型,我们就先来了解下RedisObject的元数据和指针。
|
||||
|
||||
### Redis的基本对象结构
|
||||
|
||||
RedisObject的内部组成包括了type、encoding、lru和refcount 4个元数据,以及1个`*ptr`指针。
|
||||
|
||||
- type:表示值的类型,涵盖了我们前面学习的五大基本类型;
|
||||
- encoding:是值的编码方式,用来表示Redis中实现各个基本类型的底层数据结构,例如SDS、压缩列表、哈希表、跳表等;
|
||||
- lru:记录了这个对象最后一次被访问的时间,用于淘汰过期的键值对;
|
||||
- refcount:记录了对象的引用计数;
|
||||
- *ptr:是指向数据的指针。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/af/05c2d546e507d8a863c002e2173c71af.jpg" alt="">
|
||||
|
||||
RedisObject结构借助`*ptr`指针,就可以指向不同的数据类型,例如,`*ptr`指向一个SDS或一个跳表,就表示键值对中的值是String类型或Sorted Set类型。所以,我们在定义了新的数据类型后,也只要在RedisObject中设置好新类型的type和encoding,再用`*ptr`指向新类型的实现,就行了。
|
||||
|
||||
### 开发一个新的数据类型
|
||||
|
||||
了解了RedisObject结构后,定义一个新的数据类型也就不难了。首先,我们需要为新数据类型定义好它的底层结构、type和encoding属性值,然后再实现新数据类型的创建、释放函数和基本命令。
|
||||
|
||||
接下来,我以开发一个名字叫作NewTypeObject的新数据类型为例,来解释下具体的4个操作步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/99/88702464f8bc80ea11b26ab157926199.jpg" alt="">
|
||||
|
||||
**第一步:定义新数据类型的底层结构**
|
||||
|
||||
我们用newtype.h文件来保存这个新类型的定义,具体定义的代码如下所示:
|
||||
|
||||
```
|
||||
struct NewTypeObject {
|
||||
struct NewTypeNode *head;
|
||||
size_t len;
|
||||
}NewTypeObject;
|
||||
|
||||
```
|
||||
|
||||
其中,NewTypeNode结构就是我们自定义的新类型的底层结构。我们为底层结构设计两个成员变量:一个是Long类型的value值,用来保存实际数据;一个是`*next`指针,指向下一个NewTypeNode结构。
|
||||
|
||||
```
|
||||
struct NewTypeNode {
|
||||
long value;
|
||||
struct NewTypeNode *next;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
从代码中可以看到,NewTypeObject类型的底层结构其实就是一个Long类型的单向链表。当然,你还可以根据自己的需求,把NewTypeObject的底层结构定义为其他类型。例如,如果我们想要NewTypeObject的查询效率比链表高,就可以把它的底层结构设计成一颗B+树。
|
||||
|
||||
**第二步:在RedisObject的type属性中,增加这个新类型的定义**
|
||||
|
||||
这个定义是在Redis的server.h文件中。比如,我们增加一个叫作OBJ_NEWTYPE的宏定义,用来在代码中指代NewTypeObject这个新类型。
|
||||
|
||||
```
|
||||
#define OBJ_STRING 0 /* String object. */
|
||||
#define OBJ_LIST 1 /* List object. */
|
||||
#define OBJ_SET 2 /* Set object. */
|
||||
#define OBJ_ZSET 3 /* Sorted set object. */
|
||||
…
|
||||
#define OBJ_NEWTYPE 7
|
||||
|
||||
```
|
||||
|
||||
**第三步:开发新类型的创建和释放函数**
|
||||
|
||||
Redis把数据类型的创建和释放函数都定义在了object.c文件中。所以,我们可以在这个文件中增加NewTypeObject的创建函数createNewTypeObject,如下所示:
|
||||
|
||||
```
|
||||
robj *createNewTypeObject(void){
|
||||
NewTypeObject *h = newtypeNew();
|
||||
robj *o = createObject(OBJ_NEWTYPE,h);
|
||||
return o;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
createNewTypeObject分别调用了newtypeNew和createObject两个函数,我分别来介绍下。
|
||||
|
||||
先说newtypeNew函数。它是用来为新数据类型初始化内存结构的。这个初始化过程主要是用zmalloc做底层结构分配空间,以便写入数据。
|
||||
|
||||
```
|
||||
NewTypeObject *newtypeNew(void){
|
||||
NewTypeObject *n = zmalloc(sizeof(*n));
|
||||
n->head = NULL;
|
||||
n->len = 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
newtypeNew函数涉及到新数据类型的具体创建,而Redis默认会为每个数据类型定义一个单独文件,实现这个类型的创建和命令操作,例如,t_string.c和t_list.c分别对应String和List类型。按照Redis的惯例,我们就把newtypeNew函数定义在名为t_newtype.c的文件中。
|
||||
|
||||
createObject是Redis本身提供的RedisObject创建函数,它的参数是数据类型的type和指向数据类型实现的指针`*ptr`。
|
||||
|
||||
我们给createObject函数中传入了两个参数,分别是新类型的type值OBJ_NEWTYPE,以及指向一个初始化过的NewTypeObjec的指针。这样一来,创建的RedisObject就能指向我们自定义的新数据类型了。
|
||||
|
||||
```
|
||||
robj *createObject(int type, void *ptr) {
|
||||
robj *o = zmalloc(sizeof(*o));
|
||||
o->type = type;
|
||||
o->ptr = ptr;
|
||||
...
|
||||
return o;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于释放函数来说,它是创建函数的反过程,是用zfree命令把新结构的内存空间释放掉。
|
||||
|
||||
**第四步:开发新类型的命令操作**
|
||||
|
||||
简单来说,增加相应的命令操作的过程可以分成三小步:
|
||||
|
||||
1.在t_newtype.c文件中增加命令操作的实现。比如说,我们定义ntinsertCommand函数,由它实现对NewTypeObject单向链表的插入操作:
|
||||
|
||||
```
|
||||
void ntinsertCommand(client *c){
|
||||
//基于客户端传递的参数,实现在NewTypeObject链表头插入元素
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
2.在server.h文件中,声明我们已经实现的命令,以便在server.c文件引用这个命令,例如:
|
||||
|
||||
```
|
||||
void ntinsertCommand(client *c)
|
||||
|
||||
```
|
||||
|
||||
3.在server.c文件中的redisCommandTable里面,把新增命令和实现函数关联起来。例如,新增的ntinsert命令由ntinsertCommand函数实现,我们就可以用ntinsert命令给NewTypeObject数据类型插入元素了。
|
||||
|
||||
```
|
||||
struct redisCommand redisCommandTable[] = {
|
||||
...
|
||||
{"ntinsert",ntinsertCommand,2,"m",...}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
此时,我们就完成了一个自定义的NewTypeObject数据类型,可以实现基本的命令操作了。当然,如果你还希望新的数据类型能被持久化保存,我们还需要在Redis的RDB和AOF模块中增加对新数据类型进行持久化保存的代码,我会在后面的加餐中再和你分享。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis的扩展数据类型GEO。GEO可以记录经纬度形式的地理位置信息,被广泛地应用在LBS服务中。GEO本身并没有设计新的底层数据结构,而是直接使用了Sorted Set集合类型。
|
||||
|
||||
GEO类型使用GeoHash编码方法实现了经纬度到Sorted Set中元素权重分数的转换,这其中的两个关键机制就是对二维地图做区间划分,以及对区间进行编码。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为Sorted Set元素的权重分数。这样一来,我们就可以把经纬度保存到Sorted Set中,利用Sorted Set提供的“按权重进行有序范围查找”的特性,实现LBS服务中频繁使用的“搜索附近”的需求。
|
||||
|
||||
GEO属于Redis提供的扩展数据类型。扩展数据类型有两种实现途径:一种是基于现有的数据类型,通过数据编码或是实现新的操作的方式,来实现扩展数据类型,例如基于Sorted Set和GeoHash编码实现GEO,以及基于String和位操作实现Bitmap;另一种就是开发自定义的数据类型,具体的操作是增加新数据类型的定义,实现创建和释放函数,实现新数据类型支持的命令操作,建议你尝试着把今天学到的内容灵活地应用到你的工作场景中。
|
||||
|
||||
## 每课一问
|
||||
|
||||
到今天为止,我们已经学习Redis的5大基本数据类型和3个扩展数据类型,我想请你来聊一聊,你在日常的实践过程中,还用过Redis的其他数据类型吗?
|
||||
|
||||
欢迎在留言区分享你使用过的其他数据类型,我们一起来交流学习。如果你身边还有想要自己开发Redis的新数据类型的朋友,也希望你帮我把今天的内容分享给他/她。我们下节课见。
|
||||
261
极客时间专栏/Redis核心技术与实战/实践篇/14 | 如何在Redis中保存时间序列数据?.md
Normal file
261
极客时间专栏/Redis核心技术与实战/实践篇/14 | 如何在Redis中保存时间序列数据?.md
Normal file
@@ -0,0 +1,261 @@
|
||||
<audio id="audio" title="14 | 如何在Redis中保存时间序列数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/de/9b2bd52da8e40203cab8bd933e4588de.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们现在做互联网产品的时候,都有这么一个需求:记录用户在网站或者App上的点击行为数据,来分析用户行为。这里的数据一般包括用户ID、行为类型(例如浏览、登录、下单等)、行为发生的时间戳:
|
||||
|
||||
```
|
||||
UserID, Type, TimeStamp
|
||||
|
||||
```
|
||||
|
||||
我之前做过的一个物联网项目的数据存取需求,和这个很相似。我们需要周期性地统计近万台设备的实时状态,包括设备ID、压力、温度、湿度,以及对应的时间戳:
|
||||
|
||||
```
|
||||
DeviceID, Pressure, Temperature, Humidity, TimeStamp
|
||||
|
||||
```
|
||||
|
||||
这些与发生时间相关的一组数据,就是时间序列数据。**这些数据的特点是没有严格的关系模型,记录的信息可以表示成键和值的关系**(例如,一个设备ID对应一条记录),所以,并不需要专门用关系型数据库(例如MySQL)来保存。而Redis的键值数据模型,正好可以满足这里的数据存取需求。Redis基于自身数据结构以及扩展模块,提供了两种解决方案。
|
||||
|
||||
这节课,我就以物联网场景中统计设备状态指标值为例,和你聊聊不同解决方案的做法和优缺点。
|
||||
|
||||
俗话说,“知己知彼,百战百胜”,我们就先从时间序列数据的读写特点开始,看看到底应该采用什么样的数据类型来保存吧。
|
||||
|
||||
## 时间序列数据的读写特点
|
||||
|
||||
在实际应用中,时间序列数据通常是持续高并发写入的,例如,需要连续记录数万个设备的实时状态值。同时,时间序列数据的写入主要就是插入新数据,而不是更新一个已存在的数据,也就是说,一个时间序列数据被记录后通常就不会变了,因为它就代表了一个设备在某个时刻的状态值(例如,一个设备在某个时刻的温度测量值,一旦记录下来,这个值本身就不会再变了)。
|
||||
|
||||
所以,**这种数据的写入特点很简单,就是插入数据快,这就要求我们选择的数据类型,在进行数据插入时,复杂度要低,尽量不要阻塞**。看到这儿,你可能第一时间会想到用Redis的String、Hash类型来保存,因为它们的插入复杂度都是O(1),是个不错的选择。但是,我在[第11讲](https://time.geekbang.org/column/article/279649)中说过,String类型在记录小数据时(例如刚才例子中的设备温度值),元数据的内存开销比较大,不太适合保存大量数据。
|
||||
|
||||
那我们再看看,时间序列数据的“读”操作有什么特点。
|
||||
|
||||
我们在查询时间序列数据时,既有对单条记录的查询(例如查询某个设备在某一个时刻的运行状态信息,对应的就是这个设备的一条记录),也有对某个时间范围内的数据的查询(例如每天早上8点到10点的所有设备的状态信息)。
|
||||
|
||||
除此之外,还有一些更复杂的查询,比如对某个时间范围内的数据做聚合计算。这里的聚合计算,就是对符合查询条件的所有数据做计算,包括计算均值、最大/最小值、求和等。例如,我们要计算某个时间段内的设备压力的最大值,来判断是否有故障发生。
|
||||
|
||||
那用一个词概括时间序列数据的“读”,就是查询模式多。
|
||||
|
||||
弄清楚了时间序列数据的读写特点,接下来我们就看看如何在Redis中保存这些数据。我们来分析下:针对时间序列数据的“写要快”,Redis的高性能写特性直接就可以满足了;而针对“查询模式多”,也就是要支持单点查询、范围查询和聚合计算,Redis提供了保存时间序列数据的两种方案,分别可以基于Hash和Sorted Set实现,以及基于RedisTimeSeries模块实现。
|
||||
|
||||
接下来,我们先学习下第一种方案。
|
||||
|
||||
## 基于Hash和Sorted Set保存时间序列数据
|
||||
|
||||
Hash和Sorted Set组合的方式有一个明显的好处:它们是Redis内在的数据类型,代码成熟和性能稳定。所以,基于这两个数据类型保存时间序列数据,系统稳定性是可以预期的。
|
||||
|
||||
不过,在前面学习的场景中,我们都是使用一个数据类型来存取数据,那么,**为什么保存时间序列数据,要同时使用这两种类型?这是我们要回答的第一个问题。**
|
||||
|
||||
关于Hash类型,我们都知道,它有一个特点是,可以实现对单键的快速查询。这就满足了时间序列数据的单键查询需求。我们可以把时间戳作为Hash集合的key,把记录的设备状态值作为Hash集合的value。
|
||||
|
||||
可以看下用Hash集合记录设备的温度值的示意图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/be/f2e7bc4586be59aa5e7e78a5599830be.jpg" alt="">
|
||||
|
||||
当我们想要查询某个时间点或者是多个时间点上的温度数据时,直接使用HGET命令或者HMGET命令,就可以分别获得Hash集合中的一个key和多个key的value值了。
|
||||
|
||||
举个例子。我们用HGET命令查询202008030905这个时刻的温度值,使用HMGET查询202008030905、202008030907、202008030908这三个时刻的温度值,如下所示:
|
||||
|
||||
```
|
||||
HGET device:temperature 202008030905
|
||||
"25.1"
|
||||
|
||||
HMGET device:temperature 202008030905 202008030907 202008030908
|
||||
1) "25.1"
|
||||
2) "25.9"
|
||||
3) "24.9"
|
||||
|
||||
```
|
||||
|
||||
你看,用Hash类型来实现单键的查询很简单。但是,**Hash类型有个短板:它并不支持对数据进行范围查询。**
|
||||
|
||||
虽然时间序列数据是按时间递增顺序插入Hash集合中的,但Hash类型的底层结构是哈希表,并没有对数据进行有序索引。所以,如果要对Hash类型进行范围查询的话,就需要扫描Hash集合中的所有数据,再把这些数据取回到客户端进行排序,然后,才能在客户端得到所查询范围内的数据。显然,查询效率很低。
|
||||
|
||||
为了能同时支持按时间戳范围的查询,可以用Sorted Set来保存时间序列数据,因为它能够根据元素的权重分数来排序。我们可以把时间戳作为Sorted Set集合的元素分数,把时间点上记录的数据作为元素本身。
|
||||
|
||||
我还是以保存设备温度的时间序列数据为例,进行解释。下图显示了用Sorted Set集合保存的结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/7a/9e1214dbd5b42c5b3452ea73efc8c67a.jpg" alt="">
|
||||
|
||||
使用Sorted Set保存数据后,我们就可以使用ZRANGEBYSCORE命令,按照输入的最大时间戳和最小时间戳来查询这个时间范围内的温度值了。如下所示,我们来查询一下在2020年8月3日9点7分到9点10分间的所有温度值:
|
||||
|
||||
```
|
||||
ZRANGEBYSCORE device:temperature 202008030907 202008030910
|
||||
1) "25.9"
|
||||
2) "24.9"
|
||||
3) "25.3"
|
||||
4) "25.2"
|
||||
|
||||
```
|
||||
|
||||
现在我们知道了,同时使用Hash和Sorted Set,可以满足单个时间点和一个时间范围内的数据查询需求了,但是我们又会面临一个新的问题,**也就是我们要解答的第二个问题:如何保证写入Hash和Sorted Set是一个原子性的操作呢?**
|
||||
|
||||
所谓“原子性的操作”,就是指我们执行多个写命令操作时(例如用HSET命令和ZADD命令分别把数据写入Hash和Sorted Set),这些命令操作要么全部完成,要么都不完成。
|
||||
|
||||
只有保证了写操作的原子性,才能保证同一个时间序列数据,在Hash和Sorted Set中,要么都保存了,要么都没保存。否则,就可能出现Hash集合中有时间序列数据,而Sorted Set中没有,那么,在进行范围查询时,就没有办法满足查询需求了。
|
||||
|
||||
那Redis是怎么保证原子性操作的呢?这里就涉及到了Redis用来实现简单的事务的MULTI和EXEC命令。当多个命令及其参数本身无误时,MULTI和EXEC命令可以保证执行这些命令时的原子性。关于Redis的事务支持和原子性保证的异常情况,我会在第30讲中向你介绍,这节课,我们只要了解一下MULTI和EXEC这两个命令的使用方法就行了。
|
||||
|
||||
- MULTI命令:表示一系列原子性操作的开始。收到这个命令后,Redis就知道,接下来再收到的命令需要放到一个内部队列中,后续一起执行,保证原子性。
|
||||
- EXEC命令:表示一系列原子性操作的结束。一旦Redis收到了这个命令,就表示所有要保证原子性的命令操作都已经发送完成了。此时,Redis开始执行刚才放到内部队列中的所有命令操作。
|
||||
|
||||
你可以看下下面这张示意图,命令1到命令N是在MULTI命令后、EXEC命令前发送的,它们会被一起执行,保证原子性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/62/c0e2fd5834113cef92f2f68e7462a262.jpg" alt="">
|
||||
|
||||
以保存设备状态信息的需求为例,我们执行下面的代码,把设备在2020年8月3日9时5分的温度,分别用HSET命令和ZADD命令写入Hash集合和Sorted Set集合。
|
||||
|
||||
```
|
||||
127.0.0.1:6379> MULTI
|
||||
OK
|
||||
|
||||
127.0.0.1:6379> HSET device:temperature 202008030911 26.8
|
||||
QUEUED
|
||||
|
||||
127.0.0.1:6379> ZADD device:temperature 202008030911 26.8
|
||||
QUEUED
|
||||
|
||||
127.0.0.1:6379> EXEC
|
||||
1) (integer) 1
|
||||
2) (integer) 1
|
||||
|
||||
```
|
||||
|
||||
可以看到,首先,Redis收到了客户端执行的MULTI命令。然后,客户端再执行HSET和ZADD命令后,Redis返回的结果为“QUEUED”,表示这两个命令暂时入队,先不执行;执行了EXEC命令后,HSET命令和ZADD命令才真正执行,并返回成功结果(结果值为1)。
|
||||
|
||||
到这里,我们就解决了时间序列数据的单点查询、范围查询问题,并使用MUTLI和EXEC命令保证了Redis能原子性地把数据保存到Hash和Sorted Set中。**接下来,我们需要继续解决第三个问题:如何对时间序列数据进行聚合计算?**
|
||||
|
||||
聚合计算一般被用来周期性地统计时间窗口内的数据汇总状态,在实时监控与预警等场景下会频繁执行。
|
||||
|
||||
因为Sorted Set只支持范围查询,无法直接进行聚合计算,所以,我们只能先把时间范围内的数据取回到客户端,然后在客户端自行完成聚合计算。这个方法虽然能完成聚合计算,但是会带来一定的潜在风险,也就是**大量数据在Redis实例和客户端间频繁传输,这会和其他操作命令竞争网络资源,导致其他操作变慢。**
|
||||
|
||||
在我们这个物联网项目中,就需要每3分钟统计一下各个设备的温度状态,一旦设备温度超出了设定的阈值,就要进行报警。这是一个典型的聚合计算场景,我们可以来看看这个过程中的数据体量。
|
||||
|
||||
假设我们需要每3分钟计算一次的所有设备各指标的最大值,每个设备每15秒记录一个指标值,1分钟就会记录4个值,3分钟就会有12个值。我们要统计的设备指标数量有33个,所以,单个设备每3分钟记录的指标数据有将近400个(33 * 12 = 396),而设备总数量有1万台,这样一来,每3分钟就有将近400万条(396 * 1万 = 396万)数据需要在客户端和Redis实例间进行传输。
|
||||
|
||||
为了避免客户端和Redis实例间频繁的大量数据传输,我们可以使用RedisTimeSeries来保存时间序列数据。
|
||||
|
||||
RedisTimeSeries支持直接在Redis实例上进行聚合计算。还是以刚才每3分钟算一次最大值为例。在Redis实例上直接聚合计算,那么,对于单个设备的一个指标值来说,每3分钟记录的12条数据可以聚合计算成一个值,单个设备每3分钟也就只有33个聚合值需要传输,1万台设备也只有33万条数据。数据量大约是在客户端做聚合计算的十分之一,很显然,可以减少大量数据传输对Redis实例网络的性能影响。
|
||||
|
||||
所以,如果我们只需要进行单个时间点查询或是对某个时间范围查询的话,适合使用Hash和Sorted Set的组合,它们都是Redis的内在数据结构,性能好,稳定性高。但是,如果我们需要进行大量的聚合计算,同时网络带宽条件不是太好时,Hash和Sorted Set的组合就不太适合了。此时,使用RedisTimeSeries就更加合适一些。
|
||||
|
||||
好了,接下来,我们就来具体学习下RedisTimeSeries。
|
||||
|
||||
## 基于RedisTimeSeries模块保存时间序列数据
|
||||
|
||||
RedisTimeSeries是Redis的一个扩展模块。它专门面向时间序列数据提供了数据类型和访问接口,并且支持在Redis实例上直接对数据进行按时间范围的聚合计算。
|
||||
|
||||
因为RedisTimeSeries不属于Redis的内建功能模块,在使用时,我们需要先把它的源码单独编译成动态链接库redistimeseries.so,再使用loadmodule命令进行加载,如下所示:
|
||||
|
||||
```
|
||||
loadmodule redistimeseries.so
|
||||
|
||||
```
|
||||
|
||||
当用于时间序列数据存取时,RedisTimeSeries的操作主要有5个:
|
||||
|
||||
- 用TS.CREATE命令创建时间序列数据集合;
|
||||
- 用TS.ADD命令插入数据;
|
||||
- 用TS.GET命令读取最新数据;
|
||||
- 用TS.MGET命令按标签过滤查询数据集合;
|
||||
- 用TS.RANGE支持聚合计算的范围查询。
|
||||
|
||||
下面,我来介绍一下如何使用这5个操作。
|
||||
|
||||
**1.用TS.CREATE命令创建一个时间序列数据集合**
|
||||
|
||||
在TS.CREATE命令中,我们需要设置时间序列数据集合的key和数据的过期时间(以毫秒为单位)。此外,我们还可以为数据集合设置标签,来表示数据集合的属性。
|
||||
|
||||
例如,我们执行下面的命令,创建一个key为device:temperature、数据有效期为600s的时间序列数据集合。也就是说,这个集合中的数据创建了600s后,就会被自动删除。最后,我们给这个集合设置了一个标签属性{device_id:1},表明这个数据集合中记录的是属于设备ID号为1的数据。
|
||||
|
||||
```
|
||||
TS.CREATE device:temperature RETENTION 600000 LABELS device_id 1
|
||||
OK
|
||||
|
||||
```
|
||||
|
||||
**2.用TS.ADD命令插入数据,用TS.GET命令读取最新数据**
|
||||
|
||||
我们可以用TS.ADD命令往时间序列集合中插入数据,包括时间戳和具体的数值,并使用TS.GET命令读取数据集合中的最新一条数据。
|
||||
|
||||
例如,我们执行下列TS.ADD命令时,就往device:temperature集合中插入了一条数据,记录的是设备在2020年8月3日9时5分的设备温度;再执行TS.GET命令时,就会把刚刚插入的最新数据读取出来。
|
||||
|
||||
```
|
||||
TS.ADD device:temperature 1596416700 25.1
|
||||
1596416700
|
||||
|
||||
TS.GET device:temperature
|
||||
25.1
|
||||
|
||||
```
|
||||
|
||||
**3.用TS.MGET命令按标签过滤查询数据集合**
|
||||
|
||||
在保存多个设备的时间序列数据时,我们通常会把不同设备的数据保存到不同集合中。此时,我们就可以使用TS.MGET命令,按照标签查询部分集合中的最新数据。在使用TS.CREATE创建数据集合时,我们可以给集合设置标签属性。当我们进行查询时,就可以在查询条件中对集合标签属性进行匹配,最后的查询结果里只返回匹配上的集合中的最新数据。
|
||||
|
||||
举个例子。假设我们一共用4个集合为4个设备保存时间序列数据,设备的ID号是1、2、3、4,我们在创建数据集合时,把device_id设置为每个集合的标签。此时,我们就可以使用下列TS.MGET命令,以及FILTER设置(这个配置项用来设置集合标签的过滤条件),查询device_id不等于2的所有其他设备的数据集合,并返回各自集合中的最新的一条数据。
|
||||
|
||||
```
|
||||
TS.MGET FILTER device_id!=2
|
||||
1) 1) "device:temperature:1"
|
||||
2) (empty list or set)
|
||||
3) 1) (integer) 1596417000
|
||||
2) "25.3"
|
||||
2) 1) "device:temperature:3"
|
||||
2) (empty list or set)
|
||||
3) 1) (integer) 1596417000
|
||||
2) "29.5"
|
||||
3) 1) "device:temperature:4"
|
||||
2) (empty list or set)
|
||||
3) 1) (integer) 1596417000
|
||||
2) "30.1"
|
||||
|
||||
```
|
||||
|
||||
**4.用TS.RANGE支持需要聚合计算的范围查询**
|
||||
|
||||
最后,在对时间序列数据进行聚合计算时,我们可以使用TS.RANGE命令指定要查询的数据的时间范围,同时用AGGREGATION参数指定要执行的聚合计算类型。RedisTimeSeries支持的聚合计算类型很丰富,包括求均值(avg)、求最大/最小值(max/min),求和(sum)等。
|
||||
|
||||
例如,在执行下列命令时,我们就可以按照每180s的时间窗口,对2020年8月3日9时5分和2020年8月3日9时12分这段时间内的数据进行均值计算了。
|
||||
|
||||
```
|
||||
TS.RANGE device:temperature 1596416700 1596417120 AGGREGATION avg 180000
|
||||
1) 1) (integer) 1596416700
|
||||
2) "25.6"
|
||||
2) 1) (integer) 1596416880
|
||||
2) "25.8"
|
||||
3) 1) (integer) 1596417060
|
||||
2) "26.1"
|
||||
|
||||
```
|
||||
|
||||
与使用Hash和Sorted Set来保存时间序列数据相比,RedisTimeSeries是专门为时间序列数据访问设计的扩展模块,能支持在Redis实例上直接进行聚合计算,以及按标签属性过滤查询数据集合,当我们需要频繁进行聚合计算,以及从大量集合中筛选出特定设备或用户的数据集合时,RedisTimeSeries就可以发挥优势了。
|
||||
|
||||
## 小结
|
||||
|
||||
在这节课,我们一起学习了如何用Redis保存时间序列数据。时间序列数据的写入特点是要能快速写入,而查询的特点有三个:
|
||||
|
||||
- 点查询,根据一个时间戳,查询相应时间的数据;
|
||||
- 范围查询,查询起始和截止时间戳范围内的数据;
|
||||
- 聚合计算,针对起始和截止时间戳范围内的所有数据进行计算,例如求最大/最小值,求均值等。
|
||||
|
||||
关于快速写入的要求,Redis的高性能写特性足以应对了;而针对多样化的查询需求,Redis提供了两种方案。
|
||||
|
||||
第一种方案是,组合使用Redis内置的Hash和Sorted Set类型,把数据同时保存在Hash集合和Sorted Set集合中。这种方案既可以利用Hash类型实现对单键的快速查询,还能利用Sorted Set实现对范围查询的高效支持,一下子满足了时间序列数据的两大查询需求。
|
||||
|
||||
不过,第一种方案也有两个不足:一个是,在执行聚合计算时,我们需要把数据读取到客户端再进行聚合,当有大量数据要聚合时,数据传输开销大;另一个是,所有的数据会在两个数据类型中各保存一份,内存开销不小。不过,我们可以通过设置适当的数据过期时间,释放内存,减小内存压力。
|
||||
|
||||
我们学习的第二种实现方案是使用RedisTimeSeries模块。这是专门为存取时间序列数据而设计的扩展模块。和第一种方案相比,RedisTimeSeries能支持直接在Redis实例上进行多种数据聚合计算,避免了大量数据在实例和客户端间传输。不过,RedisTimeSeries的底层数据结构使用了链表,它的范围查询的复杂度是O(N)级别的,同时,它的TS.GET查询只能返回最新的数据,没有办法像第一种方案的Hash类型一样,可以返回任一时间点的数据。
|
||||
|
||||
所以,组合使用Hash和Sorted Set,或者使用RedisTimeSeries,在支持时间序列数据存取上各有优劣势。我给你的建议是:
|
||||
|
||||
- 如果你的部署环境中网络带宽高、Redis实例内存大,可以优先考虑第一种方案;
|
||||
- 如果你的部署环境中网络、内存资源有限,而且数据量大,聚合计算频繁,需要按数据集合属性查询,可以优先考虑第二种方案。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。
|
||||
|
||||
在这节课上,我提到,我们可以使用Sorted Set保存时间序列数据,把时间戳作为score,把实际的数据作为member,你觉得这样保存数据有没有潜在的风险?另外,如果你是Redis的开发维护者,你会把聚合计算也设计为Sorted Set的一个内在功能吗?
|
||||
|
||||
好了,这节课就到这里,如果你觉得有所收获,欢迎你把今天的内容分享给你的朋友或同事,我们下节课见。
|
||||
292
极客时间专栏/Redis核心技术与实战/实践篇/15 | 消息队列的考验:Redis有哪些解决方案?.md
Normal file
292
极客时间专栏/Redis核心技术与实战/实践篇/15 | 消息队列的考验:Redis有哪些解决方案?.md
Normal file
@@ -0,0 +1,292 @@
|
||||
<audio id="audio" title="15 | 消息队列的考验:Redis有哪些解决方案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ce/8c/ce703b9yy58ff12b214e59624070c68c.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
现在的互联网应用基本上都是采用分布式系统架构进行设计的,而很多分布式系统必备的一个基础软件就是消息队列。
|
||||
|
||||
消息队列要能支持组件通信消息的快速读写,而Redis本身支持数据的高速访问,正好可以满足消息队列的读写性能需求。不过,除了性能,消息队列还有其他的要求,所以,很多人都很关心一个问题:“Redis适合做消息队列吗?”
|
||||
|
||||
其实,这个问题的背后,隐含着两方面的核心问题:
|
||||
|
||||
- 消息队列的消息存取需求是什么?
|
||||
- Redis如何实现消息队列的需求?
|
||||
|
||||
这节课,我们就来聊一聊消息队列的特征和Redis提供的消息队列方案。只有把这两方面的知识和实践经验串连起来,才能彻底理解基于Redis实现消息队列的技术实践。以后当你需要为分布式系统组件做消息队列选型时,就可以根据组件通信量和消息通信速度的要求,选择出适合的Redis消息队列方案了。
|
||||
|
||||
我们先来看下第一个问题:消息队列的消息读取有什么样的需求?
|
||||
|
||||
## 消息队列的消息存取需求
|
||||
|
||||
我先介绍一下消息队列存取消息的过程。在分布式系统中,当两个组件要基于消息队列进行通信时,一个组件会把要处理的数据以消息的形式传递给消息队列,然后,这个组件就可以继续执行其他操作了;远端的另一个组件从消息队列中把消息读取出来,再在本地进行处理。
|
||||
|
||||
为了方便你理解,我还是借助一个例子来解释一下。
|
||||
|
||||
假设组件1需要对采集到的数据进行求和计算,并写入数据库,但是,消息到达的速度很快,组件1没有办法及时地既做采集,又做计算,并且写入数据库。所以,我们可以使用基于消息队列的通信,让组件1把数据x和y保存为JSON格式的消息,再发到消息队列,这样它就可以继续接收新的数据了。组件2则异步地从消息队列中把数据读取出来,在服务器2上进行求和计算后,再写入数据库。这个过程如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/bc/d79d46ec4aa22bf46fde3ae1a99fc2bc.jpg" alt="">
|
||||
|
||||
我们一般把消息队列中发送消息的组件称为生产者(例子中的组件1),把接收消息的组件称为消费者(例子中的组件2),下图展示了一个通用的消息队列的架构模型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/62/f470bb957c1faff674c08b1fa65a3a62.jpg" alt="">
|
||||
|
||||
在使用消息队列时,消费者可以异步读取生产者消息,然后再进行处理。这样一来,即使生产者发送消息的速度远远超过了消费者处理消息的速度,生产者已经发送的消息也可以缓存在消息队列中,避免阻塞生产者,这是消息队列作为分布式组件通信的一大优势。
|
||||
|
||||
**不过,消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。**
|
||||
|
||||
### 需求一:消息保序
|
||||
|
||||
虽然消费者是异步处理消息,但是,消费者仍然需要按照生产者发送消息的顺序来处理消息,避免后发送的消息被先处理了。对于要求消息保序的场景来说,一旦出现这种消息被乱序处理的情况,就可能会导致业务逻辑被错误执行,从而给业务方造成损失。
|
||||
|
||||
我们来看一个更新商品库存的场景。
|
||||
|
||||
假设生产者负责接收库存更新请求,消费者负责实际更新库存,现有库存量是10。生产者先后发送了消息1和消息2,消息1要把商品X的库存记录更新为5,消息2是把商品X库存更新为3。如果消息1和2在消息队列中无法保序,出现消息2早于消息1被处理的情况,那么,很显然,库存更新就出错了。这是业务应用无法接受的。
|
||||
|
||||
面对这种情况,你可能会想到一种解决方案:不要把更新后的库存量作为生产者发送的消息,而是**把库存扣除值作为消息的内容**。这样一来,消息1是扣减库存量5,消息2是扣减库存量2。如果消息1和消息2之间没有库存查询请求的话,即使消费者先处理消息2,再处理消息1,这个方案也能够保证最终的库存量是正确的,也就是库存量为3。
|
||||
|
||||
但是,我们还需要考虑这样一种情况:假如消费者收到了这样三条消息:消息1是扣减库存量5,消息2是读取库存量,消息3是扣减库存量2,此时,如果消费者先处理了消息3(把库存量扣减2),那么库存量就变成了8。然后,消费者处理了消息2,读取当前的库存量是8,这就会出现库存量查询不正确的情况。从业务应用层面看,消息1、2、3应该是顺序执行的,所以,消息2查询到的应该是扣减了5以后的库存量,而不是扣减了2以后的库存量。所以,用库存扣除值作为消息的方案,在消息中同时包含读写操作的场景下,会带来数据读取错误的问题。而且,这个方案还会面临一个问题,那就是重复消息处理。
|
||||
|
||||
### 需求二:重复消息处理
|
||||
|
||||
消费者从消息队列读取消息时,有时会因为网络堵塞而出现消息重传的情况。此时,消费者可能会收到多条重复的消息。对于重复的消息,消费者如果多次处理的话,就可能造成一个业务逻辑被多次执行,如果业务逻辑正好是要修改数据,那就会出现数据被多次修改的问题了。
|
||||
|
||||
还是以库存更新为例,假设消费者收到了一次消息1,要扣减库存量5,然后又收到了一次消息1,那么,如果消费者无法识别这两条消息实际是一条相同消息的话,就会执行两次扣减库存量5的操作,此时,库存量就不对了。这当然也是无法接受的。
|
||||
|
||||
### 需求三:消息可靠性保证
|
||||
|
||||
另外,消费者在处理消息的时候,还可能出现因为故障或宕机导致消息没有处理完成的情况。此时,消息队列需要能提供消息可靠性的保证,也就是说,当消费者重启后,可以重新读取消息再次进行处理,否则,就会出现消息漏处理的问题了。
|
||||
|
||||
Redis的List和Streams两种数据类型,就可以满足消息队列的这三个需求。我们先来了解下基于List的消息队列实现方法。
|
||||
|
||||
## 基于List的消息队列解决方案
|
||||
|
||||
List本身就是按先进先出的顺序对数据进行存取的,所以,如果使用List作为消息队列保存消息的话,就已经能满足消息保序的需求了。
|
||||
|
||||
具体来说,生产者可以使用LPUSH命令把要发送的消息依次写入List,而消费者则可以使用RPOP命令,从List的另一端按照消息的写入顺序,依次读取消息并进行处理。
|
||||
|
||||
如下图所示,生产者先用LPUSH写入了两条库存消息,分别是5和3,表示要把库存更新为5和3;消费者则用RPOP把两条消息依次读出,然后进行相应的处理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/7c/b0959216cbce7ac383ce206b8884777c.jpg" alt="">
|
||||
|
||||
不过,在消费者读取数据时,有一个潜在的性能风险点。
|
||||
|
||||
在生产者往List中写入数据时,List并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用RPOP命令(比如使用一个while(1)循环)。如果有新消息写入,RPOP命令就会返回结果,否则,RPOP命令返回空值,再继续循环。
|
||||
|
||||
所以,即使没有新消息写入List,消费者也要不停地调用RPOP命令,这就会导致消费者程序的CPU一直消耗在执行RPOP命令上,带来不必要的性能损失。
|
||||
|
||||
为了解决这个问题,Redis提供了BRPOP命令。**BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据**。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。
|
||||
|
||||
消息保序的问题解决了,接下来,我们还需要考虑解决重复消息处理的问题,这里其实有一个要求:**消费者程序本身能对重复消息进行判断。**
|
||||
|
||||
一方面,消息队列要能给每一个消息提供全局唯一的ID号;另一方面,消费者程序要把已经处理过的消息的ID号记录下来。
|
||||
|
||||
当收到一条消息后,消费者程序就可以对比收到的消息ID和记录的已处理过的消息ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。这种处理特性也称为幂等性,幂等性就是指,对于同一条消息,消费者收到一次的处理结果和收到多次的处理结果是一致的。
|
||||
|
||||
不过,List本身是不会为每个消息生成ID号的,所以,消息的全局唯一ID号就需要生产者程序在发送消息前自行生成。生成之后,我们在用LPUSH命令把消息插入List时,需要在消息中包含这个全局唯一ID。
|
||||
|
||||
例如,我们执行以下命令,就把一条全局ID为101030001、库存量为5的消息插入了消息队列:
|
||||
|
||||
```
|
||||
LPUSH mq "101030001:stock:5"
|
||||
(integer) 1
|
||||
|
||||
```
|
||||
|
||||
最后,我们再来看下,List类型是如何保证消息可靠性的。
|
||||
|
||||
当消费者程序从List中读取一条消息后,List就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从List中读取消息了。
|
||||
|
||||
为了留存消息,List类型提供了BRPOPLPUSH命令,这个命令的作用是让消费者程序从一个List中读取消息,同时,Redis会把这个消息再插入到另一个List(可以叫作备份List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份List中重新读取消息并进行处理了。
|
||||
|
||||
我画了一张示意图,展示了使用BRPOPLPUSH命令留存消息,以及消费者再次读取消息的过程,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/3d/5045395da08317b546aab7eb698d013d.jpg" alt="">
|
||||
|
||||
生产者先用LPUSH把消息“5”“3”插入到消息队列mq中。消费者程序使用BRPOPLPUSH命令读取消息“5”,同时,消息“5”还会被Redis插入到mqback队列中。如果消费者程序处理消息“5”时宕机了,等它重启后,可以从mqback中再次读取消息“5”,继续处理。
|
||||
|
||||
好了,到这里,你可以看到,基于List类型,我们可以满足分布式组件对消息队列的三大需求。但是,在用List做消息队列时,我们还可能遇到过一个问题:**生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致List中的消息越积越多,给Redis的内存带来很大压力**。
|
||||
|
||||
这个时候,我们希望启动多个消费者程序组成一个消费组,一起分担处理List中的消息。但是,List类型并不支持消费组的实现。那么,还有没有更合适的解决方案呢?这就要说到Redis从5.0版本开始提供的Streams数据类型了。
|
||||
|
||||
和List相比,Streams同样能够满足消息队列的三大需求。而且,它还支持消费组形式的消息读取。接下来,我们就来了解下Streams的使用方法。
|
||||
|
||||
## 基于Streams的消息队列解决方案
|
||||
|
||||
Streams是Redis专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。
|
||||
|
||||
- XADD:插入消息,保证有序,可以自动生成全局唯一ID;
|
||||
- XREAD:用于读取消息,可以按ID读取数据;
|
||||
- XREADGROUP:按消费组形式读取消息;
|
||||
- XPENDING和XACK:XPENDING命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而XACK命令用于向消息队列确认消息处理已完成。
|
||||
|
||||
首先,我们来学习下Streams类型存取消息的操作XADD。
|
||||
|
||||
XADD命令可以往消息队列中插入新消息,消息的格式是键-值对形式。对于插入的每一条消息,Streams可以自动为其生成一个全局唯一的ID。
|
||||
|
||||
比如说,我们执行下面的命令,就可以往名称为mqstream的消息队列中插入一条消息,消息的键是repo,值是5。其中,消息队列名称后面的`*`,表示让Redis为插入的数据自动生成一个全局唯一的ID,例如“1599203861727-0”。当然,我们也可以不用`*`,直接在消息队列名称后自行设定一个ID号,只要保证这个ID号是全局唯一的就行。不过,相比自行设定ID号,使用`*`会更加方便高效。
|
||||
|
||||
```
|
||||
XADD mqstream * repo 5
|
||||
"1599203861727-0"
|
||||
|
||||
```
|
||||
|
||||
可以看到,消息的全局唯一ID由两部分组成,第一部分“1599203861727”是数据插入时,以毫秒为单位计算的当前服务器时间,第二部分表示插入消息在当前毫秒内的消息序号,这是从0开始编号的。例如,“1599203861727-0”就表示在“1599203861727”毫秒内的第1条消息。
|
||||
|
||||
当消费者需要读取消息时,可以直接使用XREAD命令从消息队列中读取。
|
||||
|
||||
XREAD在读取消息时,可以指定一个消息ID,并从这个消息ID的下一条消息开始进行读取。
|
||||
|
||||
例如,我们可以执行下面的命令,从ID号为1599203861727-0的消息开始,读取后续的所有消息(示例中一共3条)。
|
||||
|
||||
```
|
||||
XREAD BLOCK 100 STREAMS mqstream 1599203861727-0
|
||||
1) 1) "mqstream"
|
||||
2) 1) 1) "1599274912765-0"
|
||||
2) 1) "repo"
|
||||
2) "3"
|
||||
2) 1) "1599274925823-0"
|
||||
2) 1) "repo"
|
||||
2) "2"
|
||||
3) 1) "1599274927910-0"
|
||||
2) 1) "repo"
|
||||
2) "1"
|
||||
|
||||
```
|
||||
|
||||
另外,消费者也可以在调用XRAED时设定block配置项,实现类似于BRPOP的阻塞读取操作。当消息队列中没有消息时,一旦设置了block配置项,XREAD就会阻塞,阻塞的时长可以在block配置项进行设置。
|
||||
|
||||
举个例子,我们来看一下下面的命令,其中,命令最后的“$”符号表示读取最新的消息,同时,我们设置了block 10000的配置项,10000的单位是毫秒,表明XREAD在读取最新消息时,如果没有消息到来,XREAD将阻塞10000毫秒(即10秒),然后再返回。下面命令中的XREAD执行后,消息队列mqstream中一直没有消息,所以,XREAD在10秒后返回空值(nil)。
|
||||
|
||||
```
|
||||
XREAD block 10000 streams mqstream $
|
||||
(nil)
|
||||
(10.00s)
|
||||
|
||||
```
|
||||
|
||||
刚刚讲到的这些操作是List也支持的,接下来,我们再来学习下Streams特有的功能。
|
||||
|
||||
Streams本身可以使用XGROUP创建消费组,创建消费组之后,Streams可以使用XREADGROUP命令让消费组内的消费者读取消息,
|
||||
|
||||
例如,我们执行下面的命令,创建一个名为group1的消费组,这个消费组消费的消息队列是mqstream。
|
||||
|
||||
```
|
||||
XGROUP create mqstream group1 0
|
||||
OK
|
||||
|
||||
```
|
||||
|
||||
然后,我们再执行一段命令,让group1消费组里的消费者consumer1从mqstream中读取所有消息,其中,命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。因为在consumer1读取消息前,group1中没有其他消费者读取过消息,所以,consumer1就得到mqstream消息队列中的所有消息了(一共4条)。
|
||||
|
||||
```
|
||||
XREADGROUP group group1 consumer1 streams mqstream >
|
||||
1) 1) "mqstream"
|
||||
2) 1) 1) "1599203861727-0"
|
||||
2) 1) "repo"
|
||||
2) "5"
|
||||
2) 1) "1599274912765-0"
|
||||
2) 1) "repo"
|
||||
2) "3"
|
||||
3) 1) "1599274925823-0"
|
||||
2) 1) "repo"
|
||||
2) "2"
|
||||
4) 1) "1599274927910-0"
|
||||
2) 1) "repo"
|
||||
2) "1"
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了。比如说,我们执行完刚才的XREADGROUP命令后,再执行下面的命令,让group1内的consumer2读取消息时,consumer2读到的就是空值,因为消息已经被consumer1读取完了,如下所示:
|
||||
|
||||
```
|
||||
XREADGROUP group group1 consumer2 streams mqstream 0
|
||||
1) 1) "mqstream"
|
||||
2) (empty list or set)
|
||||
|
||||
```
|
||||
|
||||
使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。例如,我们执行下列命令,让group2中的consumer1、2、3各自读取一条消息。
|
||||
|
||||
```
|
||||
XREADGROUP group group2 consumer1 count 1 streams mqstream >
|
||||
1) 1) "mqstream"
|
||||
2) 1) 1) "1599203861727-0"
|
||||
2) 1) "repo"
|
||||
2) "5"
|
||||
|
||||
XREADGROUP group group2 consumer2 count 1 streams mqstream >
|
||||
1) 1) "mqstream"
|
||||
2) 1) 1) "1599274912765-0"
|
||||
2) 1) "repo"
|
||||
2) "3"
|
||||
|
||||
XREADGROUP group group2 consumer3 count 1 streams mqstream >
|
||||
1) 1) "mqstream"
|
||||
2) 1) 1) "1599274925823-0"
|
||||
2) 1) "repo"
|
||||
2) "2"
|
||||
|
||||
```
|
||||
|
||||
为了保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息,Streams会自动使用内部队列(也称为PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用XACK命令通知Streams“消息已经处理完成”。如果消费者没有成功处理消息,它就不会给Streams发送XACK命令,消息仍然会留存。此时,消费者可以在重启后,用XPENDING命令查看已读取、但尚未确认处理完成的消息。
|
||||
|
||||
例如,我们来查看一下group2中各个消费者已读取、但尚未确认的消息个数。其中,XPENDING返回结果的第二、三行分别表示group2中所有消费者读取的消息最小ID和最大ID。
|
||||
|
||||
```
|
||||
XPENDING mqstream group2
|
||||
1) (integer) 3
|
||||
2) "1599203861727-0"
|
||||
3) "1599274925823-0"
|
||||
4) 1) 1) "consumer1"
|
||||
2) "1"
|
||||
2) 1) "consumer2"
|
||||
2) "1"
|
||||
3) 1) "consumer3"
|
||||
2) "1"
|
||||
|
||||
```
|
||||
|
||||
如果我们还需要进一步查看某个消费者具体读取了哪些数据,可以执行下面的命令:
|
||||
|
||||
```
|
||||
XPENDING mqstream group2 - + 10 consumer2
|
||||
1) 1) "1599274912765-0"
|
||||
2) "consumer2"
|
||||
3) (integer) 513336
|
||||
4) (integer) 1
|
||||
|
||||
```
|
||||
|
||||
可以看到,consumer2已读取的消息的ID是1599274912765-0。
|
||||
|
||||
一旦消息1599274912765-0被consumer2处理了,consumer2就可以使用XACK命令通知Streams,然后这条消息就会被删除。当我们再使用XPENDING命令查看时,就可以看到,consumer2已经没有已读取、但尚未确认处理的消息了。
|
||||
|
||||
```
|
||||
XACK mqstream group2 1599274912765-0
|
||||
(integer) 1
|
||||
XPENDING mqstream group2 - + 10 consumer2
|
||||
(empty list or set)
|
||||
|
||||
```
|
||||
|
||||
现在,我们就知道了用Streams实现消息队列的方法,我还想再强调下,Streams是Redis 5.0专门针对消息队列场景设计的数据类型,如果你的Redis是5.0及5.0以后的版本,就可以考虑把Streams用作消息队列了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了分布式系统组件使用消息队列时的三大需求:消息保序、重复消息处理和消息可靠性保证,这三大需求可以进一步转换为对消息队列的三大要求:消息数据有序存取,消息数据具有全局唯一编号,以及消息数据在消费完成后被删除。
|
||||
|
||||
我画了一张表格,汇总了用List和Streams实现消息队列的特点和区别。当然,在实践的过程中,你也可以根据新的积累,进一步补充和完善这张表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/14/b2d6581e43f573da6218e790bb8c6814.jpg" alt="">
|
||||
|
||||
其实,关于Redis是否适合做消息队列,业界一直是有争论的。很多人认为,要使用消息队列,就应该采用Kafka、RabbitMQ这些专门面向消息队列场景的软件,而Redis更加适合做缓存。
|
||||
|
||||
根据这些年做Redis研发工作的经验,我的看法是:Redis是一个非常轻量级的键值数据库,部署一个Redis实例就是启动一个进程,部署Redis集群,也就是部署多个Redis实例。而Kafka、RabbitMQ部署时,涉及额外的组件,例如Kafka的运行就需要再部署ZooKeeper。相比Redis来说,Kafka和RabbitMQ一般被认为是重量级的消息队列。
|
||||
|
||||
所以,关于是否用Redis做消息队列的问题,不能一概而论,我们需要考虑业务层面的数据体量,以及对性能、可靠性、可扩展性的需求。如果分布式系统中的组件消息通信量不大,那么,Redis只需要使用有限的内存空间就能满足消息存储的需求,而且,Redis的高性能特性能支持快速的消息读写,不失为消息队列的一个好的解决方案。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。如果一个生产者发送给消息队列的消息,需要被多个消费者进行读取和处理(例如,一个消息是一条从业务系统采集的数据,既要被消费者1读取进行实时计算,也要被消费者2读取并留存到分布式文件系统HDFS中,以便后续进行历史查询),你会使用Redis的什么数据类型来解决这个问题呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎你帮我分享给更多人。我们下节课见。
|
||||
166
极客时间专栏/Redis核心技术与实战/实践篇/16 | 异步机制:如何避免单线程模型的阻塞?.md
Normal file
166
极客时间专栏/Redis核心技术与实战/实践篇/16 | 异步机制:如何避免单线程模型的阻塞?.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="16 | 异步机制:如何避免单线程模型的阻塞?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/c8/e61d9584a316b261e77a8a1403406dc8.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
Redis之所以被广泛应用,很重要的一个原因就是它支持高性能访问。也正因为这样,我们必须要重视所有可能影响Redis性能的因素(例如命令操作、系统配置、关键机制、硬件配置等),不仅要知道具体的机制,尽可能避免性能异常的情况出现,还要提前准备好应对异常的方案。
|
||||
|
||||
所以,从这节课开始,我会用6节课的时间介绍影响Redis性能的5大方面的潜在因素,分别是:
|
||||
|
||||
- Redis内部的阻塞式操作;
|
||||
- CPU核和NUMA架构的影响;
|
||||
- Redis关键系统配置;
|
||||
- Redis内存碎片;
|
||||
- Redis缓冲区。
|
||||
|
||||
这节课,我们就先学习了解下Redis内部的阻塞式操作以及应对的方法。
|
||||
|
||||
在[第3讲](https://time.geekbang.org/column/article/270474)中,我们学习过,Redis的网络IO和键值对读写是由主线程完成的。那么,如果在主线程上执行的操作消耗的时间太长,就会引起主线程阻塞。但是,Redis既有服务客户端请求的键值对增删改查操作,也有保证可靠性的持久化操作,还有进行主从复制时的数据同步操作,等等。操作这么多,究竟哪些会引起阻塞呢?
|
||||
|
||||
别着急,接下来,我就带你分门别类地梳理下这些操作,并且找出阻塞式操作。
|
||||
|
||||
## Redis实例有哪些阻塞点?
|
||||
|
||||
Redis实例在运行时,要和许多对象进行交互,这些不同的交互就会涉及不同的操作,下面我们来看看和Redis实例交互的对象,以及交互时会发生的操作。
|
||||
|
||||
- **客户端**:网络IO,键值对增删改查操作,数据库操作;
|
||||
- **磁盘**:生成RDB快照,记录AOF日志,AOF日志重写;
|
||||
- **主从节点**:主库生成、传输RDB文件,从库接收RDB文件、清空数据库、加载RDB文件;
|
||||
- **切片集群实例**:向其他实例传输哈希槽信息,数据迁移。
|
||||
|
||||
为了帮助你理解,我再画一张图来展示下这4类交互对象和具体的操作之间的关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/22/6ce8abb76b3464afe1c4cb3bbe426922.jpg" alt="">
|
||||
|
||||
接下来,我们来逐个分析下在这些交互对象中,有哪些操作会引起阻塞。
|
||||
|
||||
**1.和客户端交互时的阻塞点**
|
||||
|
||||
网络IO有时候会比较慢,但是Redis使用了IO多路复用机制,避免了主线程一直处在等待网络连接或请求到来的状态,所以,网络IO不是导致Redis阻塞的因素。
|
||||
|
||||
键值对的增删改查操作是Redis和客户端交互的主要部分,也是Redis主线程执行的主要任务。所以,复杂度高的增删改查操作肯定会阻塞Redis。
|
||||
|
||||
那么,怎么判断操作复杂度是不是高呢?这里有一个最基本的标准,就是看操作的复杂度是否为O(N)。
|
||||
|
||||
Redis中涉及集合的操作复杂度通常为O(N),我们要在使用时重视起来。例如集合元素全量查询操作HGETALL、SMEMBERS,以及集合的聚合统计操作,例如求交、并和差集。这些操作可以作为Redis的**第一个阻塞点:集合全量查询和聚合操作**。
|
||||
|
||||
除此之外,集合自身的删除操作同样也有潜在的阻塞风险。你可能会认为,删除操作很简单,直接把数据删除就好了,为什么还会阻塞主线程呢?
|
||||
|
||||
其实,删除操作的本质是要释放键值对占用的内存空间。你可不要小瞧内存的释放过程。释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序,所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成Redis主线程的阻塞。
|
||||
|
||||
那么,什么时候会释放大量内存呢?其实就是在删除大量键值对数据的时候,最典型的就是删除包含了大量元素的集合,也称为bigkey删除。为了让你对bigkey的删除性能有一个直观的印象,我测试了不同元素数量的集合在进行删除操作时所消耗的时间,如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/53/94bc8cf9yy5c34a6445434a15b1e9653.jpg" alt="">
|
||||
|
||||
从这张表里,我们可以得出三个结论:
|
||||
|
||||
1. 当元素数量从10万增加到100万时,4大集合类型的删除时间的增长幅度从5倍上升到了近20倍;
|
||||
1. 集合元素越大,删除所花费的时间就越长;
|
||||
1. 当删除有100万个元素的集合时,最大的删除时间绝对值已经达到了1.98s(Hash类型)。Redis的响应时间一般在微秒级别,所以,一个操作达到了近2s,不可避免地会阻塞主线程。
|
||||
|
||||
经过刚刚的分析,很显然,**bigkey删除操作就是Redis的第二个阻塞点**。删除操作对Redis实例性能的负面影响很大,而且在实际业务开发时容易被忽略,所以一定要重视它。
|
||||
|
||||
既然频繁删除键值对都是潜在的阻塞点了,那么,在Redis的数据库级别操作中,清空数据库(例如FLUSHDB和FLUSHALL操作)必然也是一个潜在的阻塞风险,因为它涉及到删除和释放所有的键值对。所以,这就是**Redis的第三个阻塞点:清空数据库**。
|
||||
|
||||
**2.和磁盘交互时的阻塞点**
|
||||
|
||||
我之所以把Redis与磁盘的交互单独列为一类,主要是因为磁盘IO一般都是比较费时费力的,需要重点关注。
|
||||
|
||||
幸运的是,Redis开发者早已认识到磁盘IO会带来阻塞,所以就把Redis进一步设计为采用子进程的方式生成RDB快照文件,以及执行AOF日志重写操作。这样一来,这两个操作由子进程负责执行,慢速的磁盘IO就不会阻塞主线程了。
|
||||
|
||||
但是,Redis直接记录AOF日志时,会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是1~2ms,如果有大量的写操作需要记录在AOF日志中,并同步写回的话,就会阻塞主线程了。这就得到了Redis的**第四个阻塞点了:AOF日志同步写**。
|
||||
|
||||
**3.主从节点交互时的阻塞点**
|
||||
|
||||
在主从集群中,主库需要生成RDB文件,并传输给从库。主库在复制的过程中,创建和传输RDB文件都是由子进程来完成的,不会阻塞主线程。但是,对于从库来说,它在接收了RDB文件后,需要使用FLUSHDB命令清空当前数据库,这就正好撞上了刚才我们分析的**第三个阻塞点。**
|
||||
|
||||
此外,从库在清空当前数据库后,还需要把RDB文件加载到内存,这个过程的快慢和RDB文件的大小密切相关,RDB文件越大,加载过程越慢,所以,**加载RDB文件就成为了Redis的第五个阻塞点**。
|
||||
|
||||
**4.切片集群实例交互时的阻塞点**
|
||||
|
||||
最后,当我们部署Redis切片集群时,每个Redis实例上分配的哈希槽信息需要在不同实例间进行传递,同时,当需要进行负载均衡或者有实例增删时,数据会在不同的实例间进行迁移。不过,哈希槽的信息量不大,而数据迁移是渐进式执行的,所以,一般来说,这两类操作对Redis主线程的阻塞风险不大。
|
||||
|
||||
不过,如果你使用了Redis Cluster方案,而且同时正好迁移的是bigkey的话,就会造成主线程的阻塞,因为Redis Cluster使用了同步迁移。我将在第33讲中向你介绍不同切片集群方案对数据迁移造成的阻塞的解决方法,这里你只需要知道,当没有bigkey时,切片集群的各实例在进行交互时不会阻塞主线程,就可以了。
|
||||
|
||||
好了,你现在已经了解了Redis的各种关键操作,以及其中的阻塞式操作,我们来总结下刚刚找到的五个阻塞点:
|
||||
|
||||
- 集合全量查询和聚合操作;
|
||||
- bigkey删除;
|
||||
- 清空数据库;
|
||||
- AOF日志同步写;
|
||||
- 从库加载RDB文件。
|
||||
|
||||
如果在主线程中执行这些操作,必然会导致主线程长时间无法服务其他请求。为了避免阻塞式操作,Redis提供了异步线程机制。所谓的异步线程机制,就是指,Redis会启动一些子线程,然后把一些任务交给这些子线程,让它们在后台完成,而不再由主线程来执行这些任务。使用异步线程机制执行操作,可以避免阻塞主线程。
|
||||
|
||||
不过,这个时候,问题来了:这五大阻塞式操作都可以被异步执行吗?
|
||||
|
||||
## 哪些阻塞点可以异步执行?
|
||||
|
||||
在分析阻塞式操作的异步执行的可行性之前,我们先来了解下异步执行对操作的要求。
|
||||
|
||||
如果一个操作能被异步执行,就意味着,它并不是Redis主线程的关键路径上的操作。我再解释下关键路径上的操作是啥。这就是说,客户端把请求发送给Redis后,等着Redis返回数据结果的操作。
|
||||
|
||||
这么说可能有点抽象,我画一张图片来解释下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/61/f196035e3d2ba65257b211ed436b0b61.jpg" alt="">
|
||||
|
||||
主线程接收到操作1后,因为操作1并不用给客户端返回具体的数据,所以,主线程可以把它交给后台子线程来完成,同时只要给客户端返回一个“OK”结果就行。在子线程执行操作1的时候,客户端又向Redis实例发送了操作2,而此时,客户端是需要使用操作2返回的数据结果的,如果操作2不返回结果,那么,客户端将一直处于等待状态。
|
||||
|
||||
在这个例子中,操作1就不算关键路径上的操作,因为它不用给客户端返回具体数据,所以可以由后台子线程异步执行。而操作2需要把结果返回给客户端,它就是关键路径上的操作,所以主线程必须立即把这个操作执行完。
|
||||
|
||||
对于Redis来说,**读操作是典型的关键路径操作**,因为客户端发送了读操作之后,就会等待读取的数据返回,以便进行后续的数据处理。而Redis的第一个阻塞点“集合全量查询和聚合操作”都涉及到了读操作,所以,它们是不能进行异步操作了。
|
||||
|
||||
我们再来看看删除操作。删除操作并不需要给客户端返回具体的数据结果,所以不算是关键路径操作。而我们刚才总结的第二个阻塞点“bigkey删除”,和第三个阻塞点“清空数据库”,都是对数据做删除,并不在关键路径上。因此,我们可以使用后台子线程来异步执行删除操作。
|
||||
|
||||
对于第四个阻塞点“AOF日志同步写”来说,为了保证数据可靠性,Redis实例需要保证AOF日志中的操作记录已经落盘,这个操作虽然需要实例等待,但它并不会返回具体的数据结果给实例。所以,我们也可以启动一个子线程来执行AOF日志的同步写,而不用让主线程等待AOF日志的写完成。
|
||||
|
||||
最后,我们再来看下“从库加载RDB文件”这个阻塞点。从库要想对客户端提供数据存取服务,就必须把RDB文件加载完成。所以,这个操作也属于关键路径上的操作,我们必须让从库的主线程来执行。
|
||||
|
||||
对于Redis的五大阻塞点来说,除了“集合全量查询和聚合操作”和“从库加载RDB文件”,其他三个阻塞点涉及的操作都不在关键路径上,所以,我们可以使用Redis的异步子线程机制来实现bigkey删除,清空数据库,以及AOF日志同步写。
|
||||
|
||||
那么,Redis实现的异步子线程机制具体是怎么执行呢?
|
||||
|
||||
## 异步的子线程机制
|
||||
|
||||
Redis主线程启动后,会使用操作系统提供的pthread_create函数创建3个子线程,分别由它们负责AOF日志写操作、键值对删除以及文件关闭的异步执行。
|
||||
|
||||
主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。
|
||||
|
||||
但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。
|
||||
|
||||
和惰性删除类似,当AOF日志配置成everysec选项后,主线程会把AOF写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入AOF日志,这样主线程就不用一直等待AOF日志写完了。
|
||||
|
||||
下面这张图展示了Redis中的异步子线程执行机制,你可以再看下,加深印象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/69/ae004728bfe6d3771c7424e4161e7969.jpg" alt="">
|
||||
|
||||
这里有个地方需要你注意一下,异步的键值对删除和数据库清空操作是Redis 4.0后提供的功能,Redis也提供了新的命令来执行这两个操作。
|
||||
|
||||
- 键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用UNLINK命令。
|
||||
- 清空数据库:可以在FLUSHDB和FLUSHALL命令后加上ASYNC选项,这样就可以让后台子线程异步地清空数据库,如下所示:
|
||||
|
||||
```
|
||||
FLUSHDB ASYNC
|
||||
FLUSHALL AYSNC
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis实例运行时的4大类交互对象:客户端、磁盘、主从库实例、切片集群实例。基于这4大类交互对象,我们梳理了会导致Redis性能受损的5大阻塞点,包括集合全量查询和聚合操作、bigkey删除、清空数据库、AOF日志同步写,以及从库加载RDB文件。
|
||||
|
||||
在这5大阻塞点中,bigkey删除、清空数据库、AOF日志同步写不属于关键路径操作,可以使用异步子线程机制来完成。Redis在运行时会创建三个子线程,主线程会通过一个任务队列和三个子线程进行交互。子线程会根据任务的具体类型,来执行相应的异步操作。
|
||||
|
||||
不过,异步删除操作是Redis 4.0以后才有的功能,如果你使用的是4.0之前的版本,当你遇到bigkey删除时,我给你个小建议:先使用集合类型提供的SCAN命令读取数据,然后再进行删除。因为用SCAN命令可以每次只读取一部分数据并进行删除,这样可以避免一次性删除大量key给主线程带来的阻塞。
|
||||
|
||||
例如,对于Hash类型的bigkey删除,你可以使用HSCAN命令,每次从Hash集合中获取一部分键值对(例如200个),再使用HDEL删除这些键值对,这样就可以把删除压力分摊到多次操作中,那么,每次删除操作的耗时就不会太长,也就不会阻塞主线程了。
|
||||
|
||||
最后,我想再提一下,集合全量查询和聚合操作、从库加载RDB文件是在关键路径上,无法使用异步操作来完成。对于这两个阻塞点,我也给你两个小建议。
|
||||
|
||||
- 集合全量查询和聚合操作:可以使用SCAN命令,分批读取数据,再在客户端进行聚合计算;
|
||||
- 从库加载RDB文件:把主库的数据量大小控制在2~4GB左右,以保证RDB文件能以较快的速度加载。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提一个小问题:我们今天学习了关键路径上的操作,你觉得,Redis的写操作(例如SET、HSET、SADD等)是在关键路径上吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你帮我分享给更多人,我们下节课见。
|
||||
272
极客时间专栏/Redis核心技术与实战/实践篇/17 | 为什么CPU结构也会影响Redis的性能?.md
Normal file
272
极客时间专栏/Redis核心技术与实战/实践篇/17 | 为什么CPU结构也会影响Redis的性能?.md
Normal file
@@ -0,0 +1,272 @@
|
||||
<audio id="audio" title="17 | 为什么CPU结构也会影响Redis的性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/6c/1e6bc30079078d1598c077262d1a3b6c.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
很多人都认为Redis和CPU的关系很简单,就是Redis的线程在CPU上运行,CPU快,Redis处理请求的速度也很快。
|
||||
|
||||
这种认知其实是片面的。CPU的多核架构以及多CPU架构,也会影响到Redis的性能。如果不了解CPU对Redis的影响,在对Redis的性能进行调优时,就可能会遗漏一些调优方法,不能把Redis的性能发挥到极限。
|
||||
|
||||
今天,我们就来学习下目前主流服务器的CPU架构,以及基于CPU多核架构和多CPU架构优化Redis性能的方法。
|
||||
|
||||
## 主流的CPU架构
|
||||
|
||||
要了解CPU对Redis具体有什么影响,我们得先了解一下CPU架构。
|
||||
|
||||
一个CPU处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(Level 1 cache,简称L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称L2 cache)。
|
||||
|
||||
这里提到了一个概念,就是物理核的私有缓存。它其实是指缓存空间只能被当前的这个物理核使用,其他的物理核无法对这个核的缓存空间进行数据存取。我们来看一下CPU物理核的架构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/3a/c2d620c012a82e825570df631a7fbc3a.jpg" alt="">
|
||||
|
||||
因为L1和L2缓存是每个物理核私有的,所以,当数据或指令保存在L1、L2缓存时,物理核访问它们的延迟不超过10纳秒,速度非常快。那么,如果Redis把要运行的指令或存取的数据保存在L1和L2缓存的话,就能高速地访问这些指令和数据。
|
||||
|
||||
但是,这些L1和L2缓存的大小受限于处理器的制造技术,一般只有KB级别,存不下太多的数据。如果L1、L2缓存中没有所需的数据,应用程序就需要访问内存来获取数据。而应用程序的访存延迟一般在百纳秒级别,是访问L1、L2缓存的延迟的近10倍,不可避免地会对性能造成影响。
|
||||
|
||||
所以,不同的物理核还会共享一个共同的三级缓存(Level 3 cache,简称为L3 cache)。L3缓存能够使用的存储资源比较多,所以一般比较大,能达到几MB到几十MB,这就能让应用程序缓存更多的数据。当L1、L2缓存中没有数据缓存时,可以访问L3,尽可能避免访问内存。
|
||||
|
||||
另外,现在主流的CPU处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。同一个物理核的逻辑核会共享使用L1、L2缓存。
|
||||
|
||||
为了方便你理解,我用一张图展示一下物理核和逻辑核,以及一级、二级缓存的关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/09/d9689a38cbe67c3008d8ba99663c2f09.jpg" alt="">
|
||||
|
||||
在主流的服务器上,一个CPU处理器会有10到20多个物理核。同时,为了提升服务器的处理能力,服务器上通常还会有多个CPU处理器(也称为多CPU Socket),每个处理器有自己的物理核(包括L1、L2缓存),L3缓存,以及连接的内存,同时,不同处理器间通过总线连接。
|
||||
|
||||
下图显示的就是多CPU Socket的架构,图中有两个Socket,每个Socket有两个物理核。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/3d/5ceb2ab6f61c064284c8f8811431bc3d.jpg" alt="">
|
||||
|
||||
**在多CPU架构上,应用程序可以在不同的处理器上运行**。在刚才的图中,Redis可以先在Socket 1上运行一段时间,然后再被调度到Socket 2上运行。
|
||||
|
||||
但是,有个地方需要你注意一下:如果应用程序先在一个Socket上运行,并且把数据保存到了内存,然后被调度到另一个Socket上运行,此时,应用程序再进行内存访问时,就需要访问之前Socket上连接的内存,这种访问属于**远端内存访问**。**和访问Socket直接连接的内存相比,远端内存访问会增加应用程序的延迟。**
|
||||
|
||||
在多CPU架构下,一个应用程序访问所在Socket的本地内存和访问远端内存的延迟并不一致,所以,我们也把这个架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA架构)。
|
||||
|
||||
到这里,我们就知道了主流的CPU多核架构和多CPU架构,我们来简单总结下CPU架构对应用程序运行的影响。
|
||||
|
||||
- L1、L2缓存中的指令和数据的访问速度很快,所以,充分利用L1、L2缓存,可以有效缩短应用程序的执行时间;
|
||||
- 在NUMA架构下,如果应用程序从一个Socket上调度到另一个Socket上,就可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间。
|
||||
|
||||
接下来,我们就先来了解下CPU多核是如何影响Redis性能的。
|
||||
|
||||
## CPU多核对Redis性能的影响
|
||||
|
||||
在一个CPU核上运行时,应用程序需要记录自身使用的软硬件资源信息(例如栈指针、CPU核的寄存器值等),我们把这些信息称为**运行时信息**。同时,应用程序访问最频繁的指令和数据还会被缓存到L1、L2缓存上,以便提升执行速度。
|
||||
|
||||
但是,在多核CPU的场景下,一旦应用程序需要在一个新的CPU核上运行,那么,运行时信息就需要重新加载到新的CPU核上。而且,新的CPU核的L1、L2缓存也需要重新加载数据和指令,这会导致程序的运行时间增加。
|
||||
|
||||
说到这儿,我想跟你分享一个我曾经在多核CPU环境下对Redis性能进行调优的案例。希望借助这个案例,帮你全方位地了解到多核CPU对Redis的性能的影响。
|
||||
|
||||
当时,我们的项目需求是要对Redis的99%尾延迟进行优化,要求GET尾延迟小于300微秒,PUT尾延迟小于500微秒。
|
||||
|
||||
可能有同学不太清楚99%尾延迟是啥,我先解释一下。我们把所有请求的处理延迟从小到大排个序,**99%的请求延迟小于的值就是99%尾延迟**。比如说,我们有1000个请求,假设按请求延迟从小到大排序后,第991个请求的延迟实测值是1ms,而前990个请求的延迟都小于1ms,所以,这里的99%尾延迟就是1ms。
|
||||
|
||||
刚开始的时候,我们使用GET/PUT复杂度为O(1)的String类型进行数据存取,同时关闭了RDB和AOF,而且,Redis实例中没有保存集合类型的其他数据,也就没有bigkey操作,避免了可能导致延迟增加的许多情况。
|
||||
|
||||
但是,即使这样,我们在一台有24个CPU核的服务器上运行Redis实例,GET和PUT的99%尾延迟分别是504微秒和1175微秒,明显大于我们设定的目标。
|
||||
|
||||
后来,我们仔细检测了Redis实例运行时的服务器CPU的状态指标值,这才发现,CPU的context switch次数比较多。
|
||||
|
||||
context switch是指线程的上下文切换,这里的上下文就是线程的运行时信息。在CPU多核的环境中,一个线程先在一个CPU核上运行,之后又切换到另一个CPU核上运行,这时就会发生context switch。
|
||||
|
||||
当context switch发生后,Redis主线程的运行时信息需要被重新加载到另一个CPU核上,而且,此时,另一个CPU核上的L1、L2缓存中,并没有Redis实例之前运行时频繁访问的指令和数据,所以,这些指令和数据都需要重新从L3缓存,甚至是内存中加载。这个重新加载的过程是需要花费一定时间的。而且,Redis实例需要等待这个重新加载的过程完成后,才能开始处理请求,所以,这也会导致一些请求的处理时间增加。
|
||||
|
||||
如果在CPU多核场景下,Redis实例被频繁调度到不同CPU核上运行的话,那么,对Redis实例的请求处理时间影响就更大了。**每调度一次,一些请求就会受到运行时信息、指令和数据重新加载过程的影响,这就会导致某些请求的延迟明显高于其他请求**。分析到这里,我们就知道了刚刚的例子中99%尾延迟的值始终降不下来的原因。
|
||||
|
||||
所以,我们要避免Redis总是在不同CPU核上来回调度执行。于是,我们尝试着把Redis实例和CPU核绑定了,让一个Redis实例固定运行在一个CPU核上。我们可以使用**taskset命令**把一个程序绑定在一个核上运行。
|
||||
|
||||
比如说,我们执行下面的命令,就把Redis实例绑在了0号核上,其中,“-c”选项用于设置要绑定的核编号。
|
||||
|
||||
```
|
||||
taskset -c 0 ./redis-server
|
||||
|
||||
```
|
||||
|
||||
绑定以后,我们进行了测试。我们发现,Redis实例的GET和PUT的99%尾延迟一下子就分别降到了260微秒和482微秒,达到了我们期望的目标。
|
||||
|
||||
我们来看一下绑核前后的Redis的99%尾延迟。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/57/eb72b9f58052d6a6023d3e1dac522157.jpg" alt="">
|
||||
|
||||
可以看到,在CPU多核的环境下,通过绑定Redis实例和CPU核,可以有效降低Redis的尾延迟。当然,绑核不仅对降低尾延迟有好处,同样也能降低平均延迟、提升吞吐率,进而提升Redis性能。
|
||||
|
||||
接下来,我们再来看看多CPU架构,也就是NUMA架构,对Redis性能的影响。
|
||||
|
||||
## CPU的NUMA架构对Redis性能的影响
|
||||
|
||||
在实际应用Redis时,我经常看到一种做法,为了提升Redis的网络性能,把操作系统的网络中断处理程序和CPU核绑定。这个做法可以避免网络中断处理程序在不同核上来回调度执行,的确能有效提升Redis的网络处理性能。
|
||||
|
||||
但是,网络中断程序是要和Redis实例进行网络数据交互的,一旦把网络中断程序绑核后,我们就需要注意Redis实例是绑在哪个核上了,这会关系到Redis访问网络数据的效率高低。
|
||||
|
||||
我们先来看下Redis实例和网络中断程序的数据交互:网络中断处理程序从网卡硬件中读取数据,并把数据写入到操作系统内核维护的一块内存缓冲区。内核会通过epoll机制触发事件,通知Redis实例,Redis实例再把数据从内核的内存缓冲区拷贝到自己的内存空间,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/d2/8753ce6985fd08bb9cf9a3813c8b2cd2.jpg" alt="">
|
||||
|
||||
那么,在CPU的NUMA架构下,当网络中断处理程序、Redis实例分别和CPU核绑定后,就会有一个潜在的风险:**如果网络中断处理程序和Redis实例各自所绑的CPU核不在同一个CPU Socket上,那么,Redis实例读取网络数据时,就需要跨CPU Socket访问内存,这个过程会花费较多时间。**
|
||||
|
||||
这么说可能有点抽象,我再借助一张图来解释下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/b0/30cd42yy86debc0eb6e7c5b069533ab0.jpg" alt="">
|
||||
|
||||
可以看到,图中的网络中断处理程序被绑在了CPU Socket 1的某个核上,而Redis实例则被绑在了CPU Socket 2上。此时,网络中断处理程序读取到的网络数据,被保存在CPU Socket 1的本地内存中,当Redis实例要访问网络数据时,就需要Socket 2通过总线把内存访问命令发送到 Socket 1上,进行远程访问,时间开销比较大。
|
||||
|
||||
我们曾经做过测试,和访问CPU Socket本地内存相比,跨CPU Socket的内存访问延迟增加了18%,这自然会导致Redis处理请求的延迟增加。
|
||||
|
||||
所以,为了避免Redis跨CPU Socket访问网络数据,我们最好把网络中断程序和Redis实例绑在同一个CPU Socket上,这样一来,Redis实例就可以直接从本地内存读取网络数据了,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/79/41f02b2afb08ec54249680e8cac30179.jpg" alt="">
|
||||
|
||||
不过,需要注意的是,**在CPU的NUMA架构下,对CPU核的编号规则,并不是先把一个CPU Socket中的所有逻辑核编完,再对下一个CPU Socket中的逻辑核编码,而是先给每个CPU Socket中每个物理核的第一个逻辑核依次编号,再给每个CPU Socket中的物理核的第二个逻辑核依次编号。**
|
||||
|
||||
我给你举个例子。假设有2个CPU Socket,每个Socket上有6个物理核,每个物理核又有2个逻辑核,总共24个逻辑核。我们可以执行**lscpu命令**,查看到这些核的编号:
|
||||
|
||||
```
|
||||
lscpu
|
||||
|
||||
Architecture: x86_64
|
||||
...
|
||||
NUMA node0 CPU(s): 0-5,12-17
|
||||
NUMA node1 CPU(s): 6-11,18-23
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
可以看到,NUMA node0的CPU核编号是0到5、12到17。其中,0到5是node0上的6个物理核中的第一个逻辑核的编号,12到17是相应物理核中的第二个逻辑核编号。NUMA node1的CPU核编号规则和node0一样。
|
||||
|
||||
所以,在绑核时,我们一定要注意,不能想当然地认为第一个Socket上的12个逻辑核的编号就是0到11。否则,网络中断程序和Redis实例就可能绑在了不同的CPU Socket上。
|
||||
|
||||
比如说,如果我们把网络中断程序和Redis实例分别绑到编号为1和7的CPU核上,此时,它们仍然是在2个CPU Socket上,Redis实例仍然需要跨Socket读取网络数据。
|
||||
|
||||
**所以,你一定要注意NUMA架构下CPU核的编号方法,这样才不会绑错核。**
|
||||
|
||||
我们先简单地总结下刚刚学习的内容。在CPU多核的场景下,用taskset命令把Redis实例和一个核绑定,可以减少Redis实例在不同核上被来回调度执行的开销,避免较高的尾延迟;在多CPU的NUMA架构下,如果你对网络中断程序做了绑核操作,建议你同时把Redis实例和网络中断程序绑在同一个CPU Socket的不同核上,这样可以避免Redis跨Socket访问内存中的网络数据的时间开销。
|
||||
|
||||
不过,“硬币都是有两面的”,绑核也存在一定的风险。接下来,我们就来了解下它的潜在风险点和解决方案。
|
||||
|
||||
## 绑核的风险和解决方案
|
||||
|
||||
Redis除了主线程以外,还有用于RDB生成和AOF重写的子进程(可以回顾看下[第4讲](https://time.geekbang.org/column/article/271754)和[第5讲](https://time.geekbang.org/column/article/271839))。此外,我们还在[第16讲](https://time.geekbang.org/column/article/285000)学习了Redis的后台线程。
|
||||
|
||||
当我们把Redis实例绑到一个CPU逻辑核上时,就会导致子进程、后台线程和Redis主线程竞争CPU资源,一旦子进程或后台线程占用CPU时,主线程就会被阻塞,导致Redis请求延迟增加。
|
||||
|
||||
针对这种情况,我来给你介绍两种解决方案,分别是**一个Redis实例对应绑一个物理核和优化Redis源码。**
|
||||
|
||||
**方案一:一个Redis实例对应绑一个物理核**
|
||||
|
||||
在给Redis实例绑核时,我们不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的2个逻辑核都用上。
|
||||
|
||||
我们还是以刚才的NUMA架构为例,NUMA node0的CPU核编号是0到5、12到17。其中,编号0和12、1和13、2和14等都是表示一个物理核的2个逻辑核。所以,在绑核时,我们使用属于同一个物理核的2个逻辑核进行绑核操作。例如,我们执行下面的命令,就把Redis实例绑定到了逻辑核0和12上,而这两个核正好都属于物理核1。
|
||||
|
||||
```
|
||||
taskset -c 0,12 ./redis-server
|
||||
|
||||
```
|
||||
|
||||
和只绑一个逻辑核相比,把Redis实例和物理核绑定,可以让主线程、子进程、后台线程共享使用2个逻辑核,可以在一定程度上缓解CPU资源竞争。但是,因为只用了2个逻辑核,它们相互之间的CPU竞争仍然还会存在。如果你还想进一步减少CPU竞争,我再给你介绍一种方案。
|
||||
|
||||
**方案二:优化Redis源码**
|
||||
|
||||
这个方案就是通过修改Redis源码,把子进程和后台线程绑到不同的CPU核上。
|
||||
|
||||
如果你对Redis的源码不太熟悉,也没关系,因为这是通过编程实现绑核的一个通用做法。学会了这个方案,你可以在熟悉了源码之后把它用上,也可以应用在其他需要绑核的场景中。
|
||||
|
||||
接下来,我先介绍一下通用的做法,然后,再具体说说可以把这个做法对应到Redis的哪部分源码中。
|
||||
|
||||
通过编程实现绑核时,要用到操作系统提供的1个数据结构cpu_set_t和3个函数CPU_ZERO、CPU_SET和sched_setaffinity,我先来解释下它们。
|
||||
|
||||
- cpu_set_t数据结构:是一个位图,每一位用来表示服务器上的一个CPU逻辑核。
|
||||
- CPU_ZERO函数:以cpu_set_t结构的位图为输入参数,把位图中所有的位设置为0。
|
||||
- CPU_SET函数:以CPU逻辑核编号和cpu_set_t位图为参数,把位图中和输入的逻辑核编号对应的位设置为1。
|
||||
- sched_setaffinity函数:以进程/线程ID号和cpu_set_t为参数,检查cpu_set_t中哪一位为1,就把输入的ID号所代表的进程/线程绑在对应的逻辑核上。
|
||||
|
||||
那么,怎么在编程时把这三个函数结合起来实现绑核呢?很简单,我们分四步走就行。
|
||||
|
||||
- 第一步:创建一个cpu_set_t结构的位图变量;
|
||||
- 第二步:使用CPU_ZERO函数,把cpu_set_t结构的位图所有的位都设置为0;
|
||||
- 第三步:根据要绑定的逻辑核编号,使用CPU_SET函数,把cpu_set_t结构的位图相应位设置为1;
|
||||
- 第四步:使用sched_setaffinity函数,把程序绑定在cpu_set_t结构位图中为1的逻辑核上。
|
||||
|
||||
下面,我就具体介绍下,分别把后台线程、子进程绑到不同的核上的做法。
|
||||
|
||||
先说后台线程。为了让你更好地理解编程实现绑核,你可以看下这段示例代码,它实现了为线程绑核的操作:
|
||||
|
||||
```
|
||||
//线程函数
|
||||
void worker(int bind_cpu){
|
||||
cpu_set_t cpuset; //创建位图变量
|
||||
CPU_ZERO(&cpu_set); //位图变量所有位设置0
|
||||
CPU_SET(bind_cpu, &cpuset); //根据输入的bind_cpu编号,把位图对应为设置为1
|
||||
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在cpu_set_t结构位图中为1的逻辑核
|
||||
|
||||
//实际线程函数工作
|
||||
}
|
||||
|
||||
int main(){
|
||||
pthread_t pthread1
|
||||
//把创建的pthread1绑在编号为3的逻辑核上
|
||||
pthread_create(&pthread1, NULL, (void *)worker, 3);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于Redis来说,它是在bio.c文件中的bioProcessBackgroundJobs函数中创建了后台线程。bioProcessBackgroundJobs函数类似于刚刚的例子中的worker函数,在这个函数中实现绑核四步操作,就可以把后台线程绑到和主线程不同的核上了。
|
||||
|
||||
和给线程绑核类似,当我们使用fork创建子进程时,也可以把刚刚说的四步操作实现在fork后的子进程代码中,示例代码如下:
|
||||
|
||||
```
|
||||
int main(){
|
||||
//用fork创建一个子进程
|
||||
pid_t p = fork();
|
||||
if(p < 0){
|
||||
printf(" fork error\n");
|
||||
}
|
||||
//子进程代码部分
|
||||
else if(!p){
|
||||
cpu_set_t cpuset; //创建位图变量
|
||||
CPU_ZERO(&cpu_set); //位图变量所有位设置0
|
||||
CPU_SET(3, &cpuset); //把位图的第3位设置为1
|
||||
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在3号逻辑核
|
||||
//实际子进程工作
|
||||
exit(0);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于Redis来说,生成RDB和AOF日志重写的子进程分别是下面两个文件的函数中实现的。
|
||||
|
||||
- rdb.c文件:rdbSaveBackground函数;
|
||||
- aof.c文件:rewriteAppendOnlyFileBackground函数。
|
||||
|
||||
这两个函数中都调用了fork创建子进程,所以,我们可以在子进程代码部分加上绑核的四步操作。
|
||||
|
||||
使用源码优化方案,我们既可以实现Redis实例绑核,避免切换核带来的性能影响,还可以让子进程、后台线程和主线程不在同一个核上运行,避免了它们之间的CPU资源竞争。相比使用taskset绑核来说,这个方案可以进一步降低绑核的风险。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了CPU架构对Redis性能的影响。首先,我们了解了目前主流的多核CPU架构,以及NUMA架构。
|
||||
|
||||
在多核CPU架构下,Redis如果在不同的核上运行,就需要频繁地进行上下文切换,这个过程会增加Redis的执行时间,客户端也会观察到较高的尾延迟了。所以,建议你在Redis运行时,把实例和某个核绑定,这样,就能重复利用核上的L1、L2缓存,可以降低响应延迟。
|
||||
|
||||
为了提升Redis的网络性能,我们有时还会把网络中断处理程序和CPU核绑定。在这种情况下,如果服务器使用的是NUMA架构,Redis实例一旦被调度到和中断处理程序不在同一个CPU Socket,就要跨CPU Socket访问网络数据,这就会降低Redis的性能。所以,我建议你把Redis实例和网络中断处理程序绑在同一个CPU Socket下的不同核上,这样可以提升Redis的运行性能。
|
||||
|
||||
虽然绑核可以帮助Redis降低请求执行时间,但是,除了主线程,Redis还有用于RDB和AOF重写的子进程,以及4.0版本之后提供的用于惰性删除的后台线程。当Redis实例和一个逻辑核绑定后,这些子进程和后台线程会和主线程竞争CPU资源,也会对Redis性能造成影响。所以,我给了你两个建议:
|
||||
|
||||
- 如果你不想修改Redis代码,可以把按一个Redis实例一个物理核方式进行绑定,这样,Redis的主线程、子进程和后台线程可以共享使用一个物理核上的两个逻辑核。
|
||||
- 如果你很熟悉Redis的源码,就可以在源码中增加绑核操作,把子进程和后台线程绑到不同的核上,这样可以避免对主线程的CPU资源竞争。不过,如果你不熟悉Redis源码,也不用太担心,Redis 6.0出来后,可以支持CPU核绑定的配置操作了,我将在第38讲中向你介绍Redis 6.0的最新特性。
|
||||
|
||||
Redis的低延迟是我们永恒的追求目标,而多核CPU和NUMA架构已经成为了目前服务器的主流配置,所以,希望你能掌握绑核优化方案,并把它应用到实践中。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。
|
||||
|
||||
在一台有2个CPU Socket(每个Socket 8个物理核)的服务器上,我们部署了有8个实例的Redis切片集群(8个实例都为主节点,没有主备关系),现在有两个方案:
|
||||
|
||||
1. 在同一个CPU Socket上运行8个实例,并和8个CPU核绑定;
|
||||
1. 在2个CPU Socket上各运行4个实例,并和相应Socket上的核绑定。
|
||||
|
||||
如果不考虑网络数据读取的影响,你会选择哪个方案呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,如果你觉得有所收获,也欢迎你帮我把今天的内容分享给你的朋友。我们下节课见。
|
||||
162
极客时间专栏/Redis核心技术与实战/实践篇/18 | 波动的响应延迟:如何应对变慢的Redis?(上).md
Normal file
162
极客时间专栏/Redis核心技术与实战/实践篇/18 | 波动的响应延迟:如何应对变慢的Redis?(上).md
Normal file
@@ -0,0 +1,162 @@
|
||||
<audio id="audio" title="18 | 波动的响应延迟:如何应对变慢的Redis?(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/51/b09fe89d84d83719596a2ab088477a51.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在Redis的实际部署应用中,有一个非常严重的问题,那就是Redis突然变慢了。一旦出现这个问题,不仅会直接影响用户的使用体验,还可能会影响到“旁人”,也就是和Redis在同一个业务系统中的其他系统,比如说数据库。
|
||||
|
||||
举个小例子,在秒杀场景下,一旦Redis变慢了,大量的用户下单请求就会被拖慢,也就是说,用户提交了下单申请,却没有收到任何响应,这会给用户带来非常糟糕的使用体验,甚至可能会导致用户流失。
|
||||
|
||||
而且,在实际生产环境中,Redis往往是业务系统中的一个环节(例如作为缓存或是作为数据库)。一旦Redis上的请求延迟增加,就可能引起业务系统中的一串儿“连锁反应”。
|
||||
|
||||
我借助一个包含了Redis的业务逻辑的小例子,简单地给你解释一下。
|
||||
|
||||
应用服务器(App Server)要完成一个事务性操作,包括在MySQL上执行一个写事务,在Redis上插入一个标记位,并通过一个第三方服务给用户发送一条完成消息。
|
||||
|
||||
这三个操作都需要保证事务原子性,所以,如果此时Redis的延迟增加,就会拖累App Server端整个事务的执行。这个事务一直完成不了,又会导致MySQL上写事务占用的资源无法释放,进而导致访问MySQL的其他请求被阻塞。很明显,Redis变慢会带来严重的连锁反应。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/64/58555bc098b518e992136f1128430c64.jpg" alt="">
|
||||
|
||||
我相信,不少人遇到过这个问题,那具体该怎么解决呢?
|
||||
|
||||
这个时候,切忌“病急乱投医”。如果没有一套行之有效的应对方案,大多数时候我们只能各种尝试,做无用功。在前面的[第16讲](https://time.geekbang.org/column/article/285000)、[第17讲](https://time.geekbang.org/column/article/286082)中,我们学习了会导致Redis变慢的潜在阻塞点以及相应的解决方案,即异步线程机制和CPU绑核。除此之外,还有一些因素会导致Redis变慢。
|
||||
|
||||
接下来的两节课,我再向你介绍一下如何系统性地应对Redis变慢这个问题。我会从问题认定、系统性排查和应对方案这3个方面给你具体讲解。学完这两节课以后,你一定能够有章法地解决Redis变慢的问题。
|
||||
|
||||
## Redis真的变慢了吗?
|
||||
|
||||
在实际解决问题之前,我们首先要弄清楚,如何判断Redis是不是真的变慢了。
|
||||
|
||||
一个最直接的方法,就是**查看Redis的响应延迟**。
|
||||
|
||||
大部分时候,Redis延迟很低,但是在某些时刻,有些Redis实例会出现很高的响应延迟,甚至能达到几秒到十几秒,不过持续时间不长,这也叫延迟“毛刺”。当你发现Redis命令的执行时间突然就增长到了几秒,基本就可以认定Redis变慢了。
|
||||
|
||||
这种方法是看Redis延迟的绝对值,但是,在不同的软硬件环境下,Redis本身的绝对性能并不相同。比如,在我的环境中,当延迟为1ms时,我判定Redis变慢了,但是你的硬件配置高,那么,在你的运行环境下,可能延迟是0.2ms的时候,你就可以认定Redis变慢了。
|
||||
|
||||
所以,这里我就要说第二个方法了,也就是基于**当前环境下的Redis基线性能**做判断。所谓的基线性能呢,也就是一个系统在低压力、无干扰下的基本性能,这个性能只由当前的软硬件配置决定。
|
||||
|
||||
你可能会问,具体怎么确定基线性能呢?有什么好方法吗?
|
||||
|
||||
实际上,从2.8.7版本开始,redis-cli命令提供了–intrinsic-latency选项,可以用来监测和统计测试期间内的最大延迟,这个延迟可以作为Redis的基线性能。其中,测试时长可以用–intrinsic-latency选项的参数来指定。
|
||||
|
||||
举个例子,比如说,我们运行下面的命令,该命令会打印120秒内监测到的最大延迟。可以看到,这里的最大延迟是119微秒,也就是基线性能为119微秒。一般情况下,运行120秒就足够监测到最大延迟了,所以,我们可以把参数设置为120。
|
||||
|
||||
```
|
||||
./redis-cli --intrinsic-latency 120
|
||||
Max latency so far: 17 microseconds.
|
||||
Max latency so far: 44 microseconds.
|
||||
Max latency so far: 94 microseconds.
|
||||
Max latency so far: 110 microseconds.
|
||||
Max latency so far: 119 microseconds.
|
||||
|
||||
36481658 total runs (avg latency: 3.2893 microseconds / 3289.32 nanoseconds per run).
|
||||
Worst run took 36x longer than the average latency.
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,基线性能和当前的操作系统、硬件配置相关。因此,我们可以把它和Redis运行时的延迟结合起来,再进一步判断Redis性能是否变慢了。
|
||||
|
||||
一般来说,你要把运行时延迟和基线性能进行对比,如果你观察到的Redis运行时延迟是其基线性能的2倍及以上,就可以认定Redis变慢了。
|
||||
|
||||
判断基线性能这一点,对于在虚拟化环境下运行的Redis来说,非常重要。这是因为,在虚拟化环境(例如虚拟机或容器)中,由于增加了虚拟化软件层,与物理机相比,虚拟机或容器本身就会引入一定的性能开销,所以基线性能会高一些。下面的测试结果,显示的就是某一个虚拟机上运行Redis时测的基线性能。
|
||||
|
||||
```
|
||||
$ ./redis-cli --intrinsic-latency 120
|
||||
Max latency so far: 692 microseconds.
|
||||
Max latency so far: 915 microseconds.
|
||||
Max latency so far: 2193 microseconds.
|
||||
Max latency so far: 9343 microseconds.
|
||||
Max latency so far: 9871 microseconds.
|
||||
|
||||
```
|
||||
|
||||
可以看到,由于虚拟化软件本身的开销,此时的基线性能已经达到了9.871ms。如果该Redis实例的运行时延迟为10ms,这并不能算作性能变慢,因为此时,运行时延迟只比基线性能增加了1.3%。如果你不了解基线性能,一看到较高的运行时延迟,就很有可能误判Redis变慢了。
|
||||
|
||||
不过,我们通常是通过客户端和网络访问Redis服务,为了避免网络对基线性能的影响,刚刚说的这个命令需要在服务器端直接运行,这也就是说,**我们只考虑服务器端软硬件环境的影响**。
|
||||
|
||||
如果你想了解网络对Redis性能的影响,一个简单的方法是用iPerf这样的工具,测量从Redis客户端到服务器端的网络延迟。如果这个延迟有几十毫秒甚至是几百毫秒,就说明,Redis运行的网络环境中很可能有大流量的其他应用程序在运行,导致网络拥塞了。这个时候,你就需要协调网络运维,调整网络的流量分配了。
|
||||
|
||||
## 如何应对Redis变慢?
|
||||
|
||||
经过了上一步之后,你已经能够确定Redis是否变慢了。一旦发现变慢了,接下来,就要开始查找原因并解决这个问题了,这其实是一个很有意思的诊断过程。
|
||||
|
||||
此时的你就像一名医生,而Redis则是一位病人。在给病人看病时,你要知道人体的机制,还要知道可能对身体造成影响的外部因素,比如不健康的食物、不好的情绪等,然后要拍CT、心电图等找出病因,最后再确定治疗方案。
|
||||
|
||||
在诊断“Redis变慢”这个病症时,同样也是这样。你要基于自己对Redis本身的工作原理的理解,并且结合和它交互的操作系统、存储以及网络等外部系统关键机制,再借助一些辅助工具来定位原因,并制定行之有效的解决方案。
|
||||
|
||||
医生诊断一般都是有章可循的。同样,Redis的性能诊断也有章可依,这就是影响Redis的关键因素。下面这张图你应该有印象,这是我们在[第一节课](https://time.geekbang.org/column/article/268262)画的Redis架构图。你可以重点关注下我在图上新增的红色模块,也就是Redis自身的操作特性、文件系统和操作系统,它们是影响Redis性能的三大要素。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/06/cd026801924e197f5c79828c368cd706.jpg" alt="">
|
||||
|
||||
接下来,我将从这三大要素入手,结合实际的应用场景,依次给你介绍从不同要素出发排查和解决问题的实践经验。这节课我先给你介绍Redis的自身操作特性的影响,下节课我们再重点研究操作系统和文件系统的影响。
|
||||
|
||||
### Redis自身操作特性的影响
|
||||
|
||||
首先,我们来学习下Redis提供的键值对命令操作对延迟性能的影响。我重点介绍两类关键操作:慢查询命令和过期key操作。
|
||||
|
||||
**1.慢查询命令**
|
||||
|
||||
慢查询命令,就是指在Redis中执行速度慢的命令,这会导致Redis延迟增加。Redis提供的命令操作很多,并不是所有命令都慢,这和命令操作的复杂度有关。所以,我们必须要知道Redis的不同命令的复杂度。
|
||||
|
||||
比如说,Value类型为String时,GET/SET操作主要就是操作Redis的哈希表索引。这个操作复杂度基本是固定的,即O(1)。但是,当Value类型为Set时,SORT、SUNION/SMEMBERS操作复杂度分别为O(N+M*log(M))和O(N)。其中,N为Set中的元素个数,M为SORT操作返回的元素个数。这个复杂度就增加了很多。[Redis官方文档](https://redis.io/commands/)中对每个命令的复杂度都有介绍,当你需要了解某个命令的复杂度时,可以直接查询。
|
||||
|
||||
那该怎么应对这个问题呢?在这儿,我就要给你排查建议和解决方法了,这也是今天的第一个方法。
|
||||
|
||||
当你发现Redis性能变慢时,可以通过Redis日志,或者是latency monitor工具,查询变慢的请求,根据请求对应的具体命令以及官方文档,确认下是否采用了复杂度高的慢查询命令。
|
||||
|
||||
如果的确有大量的慢查询命令,有两种处理方式:
|
||||
|
||||
1. **用其他高效命令代替**。比如说,如果你需要返回一个SET中的所有成员时,不要使用SMEMBERS命令,而是要使用SSCAN多次迭代返回,避免一次返回大量数据,造成线程阻塞。
|
||||
1. **当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用SORT、SUNION、SINTER这些命令,以免拖慢Redis实例**。
|
||||
|
||||
当然,如果业务逻辑就是要求使用慢查询命令,那你得考虑采用性能更好的CPU,更快地完成查询命令,避免慢查询的影响。
|
||||
|
||||
还有一个比较容易忽略的慢查询命令,就是KEYS。它用于返回和输入模式匹配的所有key,例如,以下命令返回所有包含“name”字符串的keys。
|
||||
|
||||
```
|
||||
redis> KEYS *name*
|
||||
1) "lastname"
|
||||
2) "firstname"
|
||||
|
||||
```
|
||||
|
||||
**因为KEYS命令需要遍历存储的键值对,所以操作延时高**。如果你不了解它的实现而使用了它,就会导致Redis性能变慢。所以,**KEYS命令一般不被建议用于生产环境中**。
|
||||
|
||||
**2.过期key操作**
|
||||
|
||||
接下来,我们来看过期key的自动删除机制。它是Redis用来回收内存空间的常用机制,应用广泛,本身就会引起Redis操作阻塞,导致性能变慢,所以,你必须要知道该机制对性能的影响。
|
||||
|
||||
Redis键值对的key可以设置过期时间。默认情况下,Redis每100毫秒会删除一些过期key,具体的算法如下:
|
||||
|
||||
1. 采样ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP个数的key,并将其中过期的key全部删除;
|
||||
1. 如果超过25%的key过期了,则重复删除的过程,直到过期key的比例降至25%以下。
|
||||
|
||||
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP是Redis的一个参数,默认是20,那么,一秒内基本有200个过期key会被删除。这一策略对清除过期key、释放内存空间很有帮助。如果每秒钟删除200个过期key,并不会对Redis造成太大影响。
|
||||
|
||||
但是,如果触发了上面这个算法的第二条,Redis就会一直删除以释放内存空间。注意,**删除操作是阻塞的**(Redis 4.0后可以用异步线程机制来减少阻塞影响)。所以,一旦该条件触发,Redis的线程就会一直执行删除,这样一来,就没办法正常服务其他的键值操作了,就会进一步引起其他键值操作的延迟增加,Redis就会变慢。
|
||||
|
||||
那么,算法的第二条是怎么被触发的呢?其中一个重要来源,就是**频繁使用带有相同时间参数的EXPIREAT命令设置过期key**,这就会导致,在同一秒内有大量的key同时过期。
|
||||
|
||||
现在,我就要给出第二条排查建议和解决方法了。
|
||||
|
||||
你要检查业务代码在使用EXPIREAT命令设置key过期时间时,是否使用了相同的UNIX时间戳,有没有使用EXPIRE命令给批量的key设置相同的过期秒数。因为,这都会造成大量key在同一时间过期,导致性能变慢。
|
||||
|
||||
遇到这种情况时,千万不要嫌麻烦,你首先要根据实际业务的使用需求,决定EXPIREAT和EXPIRE的过期时间参数。其次,如果一批key的确是同时过期,你还可以在EXPIREAT和EXPIRE的过期时间参数上,加上一个一定大小范围内的随机数,这样,既保证了key在一个邻近时间范围内被删除,又避免了同时过期造成的压力。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我首先给你介绍了Redis性能变慢带来的重要影响,希望你能充分重视这个问题。我重点介绍了判断Redis变慢的方法,一个是看响应延迟,一个是看基线性能。同时,我还给了你两种排查和解决Redis变慢这个问题的方法:
|
||||
|
||||
1. 从慢查询命令开始排查,并且根据业务需求替换慢查询命令;
|
||||
1. 排查过期key的时间设置,并根据实际使用需求,设置不同的过期时间。
|
||||
|
||||
性能诊断通常是一件困难的事,所以我们一定不能毫无目标地“乱找”。这节课给你介绍的内容,就是排查和解决Redis性能变慢的章法,你一定要按照章法逐一排查,这样才可能尽快地找出原因。
|
||||
|
||||
当然,要真正把Redis用好,除了要了解Redis本身的原理,还要了解和Redis交互的各底层系统的关键机制,包括操作系统和文件系统。通常情况下,一些难以排查的问题是Redis的用法或设置和底层系统的工作机制不协调导致的。下节课,我会着重给你介绍文件系统、操作系统对Redis性能的影响,以及相应的排查方法和解决方案。
|
||||
|
||||
## 每课一问
|
||||
|
||||
这节课,我提到了KEYS命令,因为它的复杂度很高,容易引起Redis线程操作阻塞,不适用于生产环境。但是,KEYS命令本身提供的功能是上层业务应用经常需要的,即返回与输入模式匹配的keys。
|
||||
|
||||
请思考一下,在Redis中,还有哪些其他命令可以代替KEYS命令,实现同样的功能呢?这些命令的复杂度会导致Redis变慢吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得有所收获,欢迎你把今天的内容分享给你的朋友。
|
||||
193
极客时间专栏/Redis核心技术与实战/实践篇/19 | 波动的响应延迟:如何应对变慢的Redis?(下).md
Normal file
193
极客时间专栏/Redis核心技术与实战/实践篇/19 | 波动的响应延迟:如何应对变慢的Redis?(下).md
Normal file
@@ -0,0 +1,193 @@
|
||||
<audio id="audio" title="19 | 波动的响应延迟:如何应对变慢的Redis?(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/f6/ed576a29yyf497c4d213c3c630b935f6.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
上节课,我介绍了判断Redis变慢的两种方法,分别是响应延迟和基线性能。除此之外,我还给你分享了从Redis的自身命令操作层面排查和解决问题的两种方案。
|
||||
|
||||
但是,如果在排查时,你发现Redis没有执行大量的慢查询命令,也没有同时删除大量过期keys,那么,我们是不是就束手无策了呢?
|
||||
|
||||
当然不是!我还有很多“锦囊妙计”,准备在这节课分享给你呢!
|
||||
|
||||
如果上节课的方法不管用,那就说明,你要关注影响性能的其他机制了,也就是文件系统和操作系统。
|
||||
|
||||
Redis会持久化保存数据到磁盘,这个过程要依赖文件系统来完成,所以,文件系统将数据写回磁盘的机制,会直接影响到Redis持久化的效率。而且,在持久化的过程中,Redis也还在接收其他请求,持久化的效率高低又会影响到Redis处理请求的性能。
|
||||
|
||||
另一方面,Redis是内存数据库,内存操作非常频繁,所以,操作系统的内存机制会直接影响到Redis的处理效率。比如说,如果Redis的内存不够用了,操作系统会启动swap机制,这就会直接拖慢Redis。
|
||||
|
||||
那么,接下来,我再从这两个层面,继续给你介绍,如何进一步解决Redis变慢的问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/06/cd026801924e197f5c79828c368cd706.jpg" alt="">
|
||||
|
||||
## 文件系统:AOF模式
|
||||
|
||||
你可能会问,Redis是个内存数据库,为什么它的性能还和文件系统有关呢?
|
||||
|
||||
我在前面讲过,为了保证数据可靠性,Redis会采用AOF日志或RDB快照。其中,AOF日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是write和fsync。
|
||||
|
||||
write只要把日志记录写到内核缓冲区,就可以返回了,并不需要等待日志实际写回到磁盘;而fsync需要把日志记录写回到磁盘后才能返回,时间较长。下面这张表展示了三种写回策略所执行的系统调用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/a4/9f1316094001ca64c8dfca37c2c49ea4.jpg" alt="">
|
||||
|
||||
当写回策略配置为everysec和always时,Redis需要调用fsync把日志写回磁盘。但是,这两种写回策略的具体执行情况还不太一样。
|
||||
|
||||
在使用everysec时,Redis允许丢失一秒的操作记录,所以,Redis主线程并不需要确保每个操作记录日志都写回磁盘。而且,fsync的执行时间很长,如果是在Redis主线程中执行fsync,就容易阻塞主线程。所以,当写回策略配置为everysec时,Redis会使用后台的子线程异步完成fsync的操作。
|
||||
|
||||
而对于always策略来说,Redis需要确保每个操作记录日志都写回磁盘,如果用后台子线程异步完成,主线程就无法及时地知道每个操作是否已经完成了,这就不符合always策略的要求了。所以,always策略并不使用后台子线程来执行。
|
||||
|
||||
另外,在使用AOF日志时,为了避免日志文件不断增大,Redis会执行AOF重写,生成体量缩小的新的AOF日志文件。AOF重写本身需要的时间很长,也容易阻塞Redis主线程,所以,Redis使用子进程来进行AOF重写。
|
||||
|
||||
但是,这里有一个潜在的风险点:AOF重写会对磁盘进行大量IO操作,同时,fsync又需要等到数据写到磁盘后才能返回,所以,当AOF重写的压力比较大时,就会导致fsync被阻塞。虽然fsync是由后台子线程负责执行的,但是,主线程会监控fsync的执行进度。
|
||||
|
||||
当主线程使用后台子线程执行了一次fsync,需要再次把新接收的操作记录写回磁盘时,如果主线程发现上一次的fsync还没有执行完,那么它就会阻塞。所以,如果后台子线程执行的fsync频繁阻塞的话(比如AOF重写占用了大量的磁盘IO带宽),主线程也会阻塞,导致Redis性能变慢。
|
||||
|
||||
为了帮助你理解,我再画一张图来展示下在磁盘压力小和压力大的时候,fsync后台子线程和主线程受到的影响。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/a6/2a47b3f6fd7beaf466a675777ebd28a6.jpg" alt="">
|
||||
|
||||
好了,说到这里,你已经了解了,由于fsync后台子线程和AOF重写子进程的存在,主IO线程一般不会被阻塞。但是,如果在重写日志时,AOF重写子进程的写入量比较大,fsync线程也会被阻塞,进而阻塞主线程,导致延迟增加。现在,我来给出排查和解决建议。
|
||||
|
||||
首先,你可以检查下Redis配置文件中的appendfsync配置项,该配置项的取值表明了Redis实例使用的是哪种AOF日志写回策略,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/e9/ba770d1f25ffae79a101c13b9f8aa9e9.jpg" alt="">
|
||||
|
||||
如果AOF写回策略使用了everysec或always配置,请先确认下业务方对数据可靠性的要求,明确是否需要每一秒或每一个操作都记日志。有的业务方不了解Redis AOF机制,很可能就直接使用数据可靠性最高等级的always配置了。其实,在有些场景中(例如Redis用于缓存),数据丢了还可以从后端数据库中获取,并不需要很高的数据可靠性。
|
||||
|
||||
如果业务应用对延迟非常敏感,但同时允许一定量的数据丢失,那么,可以把配置项no-appendfsync-on-rewrite设置为yes,如下所示:
|
||||
|
||||
```
|
||||
no-appendfsync-on-rewrite yes
|
||||
|
||||
```
|
||||
|
||||
这个配置项设置为yes时,表示在AOF重写时,不进行fsync操作。也就是说,Redis实例把写命令写到内存后,不调用后台线程进行fsync操作,就可以直接返回了。当然,如果此时实例发生宕机,就会导致数据丢失。反之,如果这个配置项设置为no(也是默认配置),在AOF重写时,Redis实例仍然会调用后台线程进行fsync操作,这就会给实例带来阻塞。
|
||||
|
||||
如果的确需要高性能,同时也需要高可靠数据保证,我建议你考虑**采用高速的固态硬盘作为AOF日志的写入设备。**
|
||||
|
||||
高速固态盘的带宽和并发度比传统的机械硬盘的要高出10倍及以上。在AOF重写和fsync后台线程同时执行时,固态硬盘可以提供较为充足的磁盘IO资源,让AOF重写和fsync后台线程的磁盘IO资源竞争减少,从而降低对Redis的性能影响。
|
||||
|
||||
## 操作系统:swap
|
||||
|
||||
如果Redis的AOF日志配置只是no,或者就没有采用AOF模式,那么,还会有什么问题导致性能变慢吗?
|
||||
|
||||
接下来,我就再说一个潜在的瓶颈:**操作系统的内存swap**。
|
||||
|
||||
内存swap是操作系统里将内存数据在内存和磁盘间来回换入和换出的机制,涉及到磁盘的读写,所以,一旦触发swap,无论是被换入数据的进程,还是被换出数据的进程,其性能都会受到慢速磁盘读写的影响。
|
||||
|
||||
Redis是内存数据库,内存使用量大,如果没有控制好内存的使用量,或者和其他内存需求大的应用一起运行了,就可能受到swap的影响,而导致性能变慢。
|
||||
|
||||
这一点对于Redis内存数据库而言,显得更为重要:正常情况下,Redis的操作是直接通过访问内存就能完成,一旦swap被触发了,Redis的请求操作需要等到磁盘数据读写完成才行。而且,和我刚才说的AOF日志文件读写使用fsync线程不同,swap触发后影响的是Redis主IO线程,这会极大地增加Redis的响应时间。
|
||||
|
||||
说到这儿,我想给你分享一个我曾经遇到过的因为swap而导致性能降低的例子。
|
||||
|
||||
在正常情况下,我们运行的一个实例完成5000万个GET请求时需要300s,但是,有一次,这个实例完成5000万GET请求,花了将近4个小时的时间。经过问题复现,我们发现,当时Redis处理请求用了近4小时的情况下,该实例所在的机器已经发生了swap。从300s到4个小时,延迟增加了将近48倍,可以看到swap对性能造成的严重影响。
|
||||
|
||||
那么,什么时候会触发swap呢?
|
||||
|
||||
通常,触发swap的原因主要是**物理机器内存不足**,对于Redis而言,有两种常见的情况:
|
||||
|
||||
- Redis实例自身使用了大量的内存,导致物理机器的可用内存不足;
|
||||
- 和Redis实例在同一台机器上运行的其他进程,在进行大量的文件读写操作。文件读写本身会占用系统内存,这会导致分配给Redis实例的内存量变少,进而触发Redis发生swap。
|
||||
|
||||
针对这个问题,我也给你提供一个解决思路:**增加机器的内存或者使用Redis集群**。
|
||||
|
||||
操作系统本身会在后台记录每个进程的swap使用情况,即有多少数据量发生了swap。你可以先通过下面的命令查看Redis的进程号,这里是5332。
|
||||
|
||||
```
|
||||
$ redis-cli info | grep process_id
|
||||
process_id: 5332
|
||||
|
||||
```
|
||||
|
||||
然后,进入Redis所在机器的/proc目录下的该进程目录中:
|
||||
|
||||
```
|
||||
$ cd /proc/5332
|
||||
|
||||
```
|
||||
|
||||
最后,运行下面的命令,查看该Redis进程的使用情况。在这儿,我只截取了部分结果:
|
||||
|
||||
```
|
||||
$cat smaps | egrep '^(Swap|Size)'
|
||||
Size: 584 kB
|
||||
Swap: 0 kB
|
||||
Size: 4 kB
|
||||
Swap: 4 kB
|
||||
Size: 4 kB
|
||||
Swap: 0 kB
|
||||
Size: 462044 kB
|
||||
Swap: 462008 kB
|
||||
Size: 21392 kB
|
||||
Swap: 0 kB
|
||||
|
||||
```
|
||||
|
||||
每一行Size表示的是Redis实例所用的一块内存大小,而Size下方的Swap和它相对应,表示这块Size大小的内存区域有多少已经被换出到磁盘上了。如果这两个值相等,就表示这块内存区域已经完全被换出到磁盘了。
|
||||
|
||||
作为内存数据库,Redis本身会使用很多大小不一的内存块,所以,你可以看到有很多Size行,有的很小,就是4KB,而有的很大,例如462044KB。**不同内存块被换出到磁盘上的大小也不一样**,例如刚刚的结果中的第一个4KB内存块,它下方的Swap也是4KB,这表示这个内存块已经被换出了;另外,462044KB这个内存块也被换出了462008KB,差不多有462MB。
|
||||
|
||||
这里有个重要的地方,我得提醒你一下,当出现百MB,甚至GB级别的swap大小时,就表明,此时,Redis实例的内存压力很大,很有可能会变慢。所以,swap的大小是排查Redis性能变慢是否由swap引起的重要指标。
|
||||
|
||||
一旦发生内存swap,最直接的解决方法就是**增加机器内存**。如果该实例在一个Redis切片集群中,可以增加Redis集群的实例个数,来分摊每个实例服务的数据量,进而减少每个实例所需的内存量。
|
||||
|
||||
当然,如果Redis实例和其他操作大量文件的程序(例如数据分析程序)共享机器,你可以将Redis实例迁移到单独的机器上运行,以满足它的内存需求量。如果该实例正好是Redis主从集群中的主库,而从库的内存很大,也可以考虑进行主从切换,把大内存的从库变成主库,由它来处理客户端请求。
|
||||
|
||||
## 操作系统:内存大页
|
||||
|
||||
除了内存swap,还有一个和内存相关的因素,即内存大页机制(Transparent Huge Page, THP),也会影响Redis性能。
|
||||
|
||||
Linux内核从2.6.38开始支持内存大页机制,该机制支持2MB大小的内存页分配,而常规的内存页分配是按4KB的粒度来执行的。
|
||||
|
||||
很多人都觉得:“Redis是内存数据库,内存大页不正好可以满足Redis的需求吗?而且在分配相同的内存量时,内存大页还能减少分配次数,不也是对Redis友好吗?”
|
||||
|
||||
其实,系统的设计通常是一个取舍过程,我们称之为trade-off。很多机制通常都是优势和劣势并存的。Redis使用内存大页就是一个典型的例子。
|
||||
|
||||
虽然内存大页可以给Redis带来内存分配方面的收益,但是,不要忘了,Redis为了提供数据可靠性保证,需要将数据做持久化保存。这个写入过程由额外的线程执行,所以,此时,Redis主线程仍然可以接收客户端写请求。客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis就会采用写时复制机制,也就是说,一旦有数据要被修改,Redis并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。
|
||||
|
||||
如果采用了内存大页,那么,即使客户端请求只修改100B的数据,Redis也需要拷贝2MB的大页。相反,如果是常规内存页机制,只用拷贝4KB。两者相比,你可以看到,当客户端请求修改或新写入数据较多时,内存大页机制将导致大量的拷贝,这就会影响Redis正常的访存操作,最终导致性能变慢。
|
||||
|
||||
那该怎么办呢?很简单,关闭内存大页,就行了。
|
||||
|
||||
首先,我们要先排查下内存大页。方法是:在Redis实例运行的机器上执行如下命令:
|
||||
|
||||
```
|
||||
cat /sys/kernel/mm/transparent_hugepage/enabled
|
||||
|
||||
```
|
||||
|
||||
如果执行结果是always,就表明内存大页机制被启动了;如果是never,就表示,内存大页机制被禁止。
|
||||
|
||||
在实际生产环境中部署时,我建议你不要使用内存大页机制,操作也很简单,只需要执行下面的命令就可以了:
|
||||
|
||||
```
|
||||
echo never /sys/kernel/mm/transparent_hugepage/enabled
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我从文件系统和操作系统两个维度,给你介绍了应对Redis变慢的方法。
|
||||
|
||||
为了方便你应用,我给你梳理了一个包含9个检查点的Checklist,希望你在遇到Redis性能变慢时,按照这些步骤逐一检查,高效地解决问题。
|
||||
|
||||
1. 获取Redis实例在当前环境下的基线性能。
|
||||
1. 是否用了慢查询命令?如果是的话,就使用其他命令替代慢查询命令,或者把聚合计算命令放在客户端做。
|
||||
1. 是否对过期key设置了相同的过期时间?对于批量删除的key,可以在每个key的过期时间上加一个随机数,避免同时删除。
|
||||
1. 是否存在bigkey? 对于bigkey的删除操作,如果你的Redis是4.0及以上的版本,可以直接利用异步线程机制减少主线程阻塞;如果是Redis 4.0以前的版本,可以使用SCAN命令迭代删除;对于bigkey的集合查询和聚合操作,可以使用SCAN命令在客户端完成。
|
||||
1. Redis AOF配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我们需要高性能,同时也允许数据丢失,可以将配置项no-appendfsync-on-rewrite设置为yes,避免AOF重写和fsync竞争磁盘IO资源,导致Redis延迟增加。当然, 如果既需要高性能又需要高可靠性,最好使用高速固态盘作为AOF日志的写入盘。
|
||||
1. Redis实例的内存使用是否过大?发生swap了吗?如果是的话,就增加机器内存,或者是使用Redis集群,分摊单机Redis的键值对数量和内存压力。同时,要避免出现Redis和其他内存需求大的应用共享机器的情况。
|
||||
1. 在Redis实例的运行环境中,是否启用了透明大页机制?如果是的话,直接关闭内存大页机制就行了。
|
||||
1. 是否运行了Redis主从集群?如果是的话,把主库实例的数据量大小控制在2~4GB,以免主从复制时,从库因加载大的RDB文件而阻塞。
|
||||
1. 是否使用了多核CPU或NUMA架构的机器运行Redis实例?使用多核CPU时,可以给Redis实例绑定物理核;使用NUMA架构时,注意把Redis实例和网络中断处理程序运行在同一个CPU Socket上。
|
||||
|
||||
实际上,影响系统性能的因素还有很多,这两节课给你讲的都是应对最常见问题的解决方案。
|
||||
|
||||
如果你遇到了一些特殊情况,也不要慌,我再给你分享一个小技巧:仔细检查下有没有恼人的“邻居”,具体点说,就是Redis所在的机器上有没有一些其他占内存、磁盘IO和网络IO的程序,比如说数据库程序或者数据采集程序。如果有的话,我建议你将这些程序迁移到其他机器上运行。
|
||||
|
||||
为了保证Redis高性能,我们需要给Redis充足的计算、内存和IO资源,给它提供一个“安静”的环境。
|
||||
|
||||
## 每课一问
|
||||
|
||||
这两节课,我向你介绍了系统性定位、排查和解决Redis变慢的方法。所以,我想请你聊一聊,你遇到过Redis变慢的情况吗?如果有的话,你是怎么解决的呢?
|
||||
|
||||
欢迎你在留言区分享一下自己的经验,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友或同事,我们下节课见。
|
||||
161
极客时间专栏/Redis核心技术与实战/实践篇/20 | 删除数据后,为什么内存占用率还是很高?.md
Normal file
161
极客时间专栏/Redis核心技术与实战/实践篇/20 | 删除数据后,为什么内存占用率还是很高?.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<audio id="audio" title="20 | 删除数据后,为什么内存占用率还是很高?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/49/19e0ba2853d5f977393a831a9e783d49.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在使用Redis时,我们经常会遇到这样一个问题:明明做了数据删除,数据量已经不大了,为什么使用top命令查看时,还会发现Redis占用了很多内存呢?
|
||||
|
||||
实际上,这是因为,当数据删除后,Redis释放的内存空间会由内存分配器管理,并不会立即返回给操作系统。所以,操作系统仍然会记录着给Redis分配了大量内存。
|
||||
|
||||
但是,这往往会伴随一个潜在的风险点:Redis释放的内存空间可能并不是连续的,那么,这些不连续的内存空间很有可能处于一种闲置的状态。这就会导致一个问题:虽然有空闲空间,Redis却无法用来保存数据,不仅会减少Redis能够实际保存的数据量,还会降低Redis运行机器的成本回报率。
|
||||
|
||||
打个形象的比喻。我们可以把Redis的内存空间比作高铁上的车厢座位数。如果高铁的车厢座位数很多,但运送的乘客数很少,那么,高铁运行一次的效率低,成本高,性价比就会降低,Redis也是一样。如果你正好租用了一台16GB内存的云主机运行Redis,但是却只保存了8GB的数据,那么,你租用这台云主机的成本回报率也会降低一半,这个结果肯定不是你想要的。
|
||||
|
||||
所以,这节课,我就和你聊聊Redis的内存空间存储效率问题,探索一下,为什么数据已经删除了,但内存却闲置着没有用,以及相应的解决方案。
|
||||
|
||||
## 什么是内存碎片?
|
||||
|
||||
通常情况下,内存空间闲置,往往是因为操作系统发生了较为严重的内存碎片。那么,什么是内存碎片呢?
|
||||
|
||||
为了方便你理解,我还是借助高铁的车厢座位来进行解释。假设一个车厢的座位总共有60个,现在已经卖了57张票,你和2个小伙伴要乘坐高铁出门旅行,刚好需要三张票。不过,你们想要坐在一起,这样可以在路上聊天。但是,在选座位时,你们却发现,已经买不到连续的座位了。于是,你们只好换了一趟车。这样一来,你们需要改变出行时间,而且这趟车就空置了三个座位。
|
||||
|
||||
其实,这趟车的空座位是和你们的人数相匹配的,只是这些空座位是分散的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/df/23ebc99ff968f2c7edd0f8ddf7def8df.jpg" alt="">
|
||||
|
||||
我们可以把这些分散的空座位叫作“车厢座位碎片”,知道了这一点,操作系统的内存碎片就很容易理解了。虽然操作系统的剩余内存空间总量足够,但是,应用申请的是一块连续地址空间的N字节,但在剩余的内存空间中,没有大小为N字节的连续空间了,那么,这些剩余空间就是内存碎片(比如上图中的“空闲2字节”和“空闲1字节”,就是这样的碎片)。
|
||||
|
||||
那么,Redis中的内存碎片是什么原因导致的呢?接下来,我带你来具体看一看。我们只有了解了内存碎片的成因,才能对症下药,把Redis占用的内存空间充分利用起来,增加存储的数据量。
|
||||
|
||||
## 内存碎片是如何形成的?
|
||||
|
||||
其实,内存碎片的形成有内因和外因两个层面的原因。简单来说,内因是操作系统的内存分配机制,外因是Redis的负载特征。
|
||||
|
||||
### 内因:内存分配器的分配策略
|
||||
|
||||
内存分配器的分配策略就决定了操作系统无法做到“按需分配”。这是因为,内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配。
|
||||
|
||||
Redis可以使用libc、jemalloc、tcmalloc多种内存分配器来分配内存,默认使用jemalloc。接下来,我就以jemalloc为例,来具体解释一下。其他分配器也存在类似的问题。
|
||||
|
||||
jemalloc的分配策略之一,是按照一系列固定的大小划分内存空间,例如8字节、16字节、32字节、48字节,…, 2KB、4KB、8KB等。当程序申请的内存最接近某个固定值时,jemalloc会给它分配相应大小的空间。
|
||||
|
||||
这样的分配方式本身是为了减少分配次数。例如,Redis申请一个20字节的空间保存数据,jemalloc就会分配32字节,此时,如果应用还要写入10字节的数据,Redis就不用再向操作系统申请空间了,因为刚才分配的32字节已经够用了,这就避免了一次分配操作。
|
||||
|
||||
但是,如果Redis每次向分配器申请的内存空间大小不一样,这种分配方式就会有形成碎片的风险,而这正好来源于Redis的外因了。
|
||||
|
||||
### 外因:键值对大小不一样和删改操作
|
||||
|
||||
Redis通常作为共用的缓存系统或键值数据库对外提供服务,所以,不同业务应用的数据都可能保存在Redis中,这就会带来不同大小的键值对。这样一来,Redis申请内存空间分配时,本身就会有大小不一的空间需求。这是第一个外因。
|
||||
|
||||
但是咱们刚刚讲过,内存分配器只能按固定大小分配内存,所以,分配的内存空间一般都会比申请的空间大一些,不会完全一致,这本身就会造成一定的碎片,降低内存空间存储效率。
|
||||
|
||||
比如说,应用A保存6字节数据,jemalloc按分配策略分配8字节。如果应用A不再保存新数据,那么,这里多出来的2字节空间就是内存碎片了,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/a5/46d93f2ef50a7f6f91812d0c21ebd6a5.jpg" alt="">
|
||||
|
||||
第二个外因是,这些键值对会被修改和删除,这会导致空间的扩容和释放。具体来说,一方面,如果修改后的键值对变大或变小了,就需要占用额外的空间或者释放不用的空间。另一方面,删除的键值对就不再需要内存空间了,此时,就会把空间释放出来,形成空闲空间。
|
||||
|
||||
我画了下面这张图来帮助你理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/b8/4d5265c6a38d1839bf4943918f6b6db8.jpg" alt="">
|
||||
|
||||
一开始,应用A、B、C、D分别保存了3、1、2、4字节的数据,并占据了相应的内存空间。然后,应用D删除了1个字节,这个1字节的内存空间就空出来了。紧接着,应用A修改了数据,从3字节变成了4字节。为了保持A数据的空间连续性,操作系统就需要把B的数据拷贝到别的空间,比如拷贝到D刚刚释放的空间中。此时,应用C和D也分别删除了2字节和1字节的数据,整个内存空间上就分别出现了2字节和1字节的空闲碎片。如果应用E想要一个3字节的连续空间,显然是不能得到满足的。因为,虽然空间总量够,但却是碎片空间,并不是连续的。
|
||||
|
||||
好了,到这里,我们就知道了造成内存碎片的内外因素,其中,内存分配器策略是内因,而Redis的负载属于外因,包括了大小不一的键值对和键值对修改删除带来的内存空间变化。
|
||||
|
||||
大量内存碎片的存在,会造成Redis的内存实际利用率变低,接下来,我们就要来解决这个问题了。不过,在解决问题前,我们要先判断Redis运行过程中是否存在内存碎片。
|
||||
|
||||
## 如何判断是否有内存碎片?
|
||||
|
||||
Redis是内存数据库,内存利用率的高低直接关系到Redis运行效率的高低。为了让用户能监控到实时的内存使用情况,Redis自身提供了INFO命令,可以用来查询内存使用的详细信息,命令如下:
|
||||
|
||||
```
|
||||
INFO memory
|
||||
# Memory
|
||||
used_memory:1073741736
|
||||
used_memory_human:1024.00M
|
||||
used_memory_rss:1997159792
|
||||
used_memory_rss_human:1.86G
|
||||
…
|
||||
mem_fragmentation_ratio:1.86
|
||||
|
||||
```
|
||||
|
||||
这里有一个mem_fragmentation_ratio的指标,它表示的就是Redis当前的内存碎片率。那么,这个碎片率是怎么计算的呢?其实,就是上面的命令中的两个指标used_memory_rss和used_memory相除的结果。
|
||||
|
||||
```
|
||||
mem_fragmentation_ratio = used_memory_rss/ used_memory
|
||||
|
||||
```
|
||||
|
||||
used_memory_rss是操作系统实际分配给Redis的物理内存空间,里面就包含了碎片;而used_memory是Redis为了保存数据实际申请使用的空间。
|
||||
|
||||
我简单举个例子。例如,Redis申请使用了100字节(used_memory),操作系统实际分配了128字节(used_memory_rss),此时,mem_fragmentation_ratio就是1.28。
|
||||
|
||||
那么,知道了这个指标,我们该如何使用呢?在这儿,我提供一些经验阈值:
|
||||
|
||||
- **mem_fragmentation_ratio 大于1但小于1.5**。这种情况是合理的。这是因为,刚才我介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由Redis负载决定,也无法限制。所以,存在内存碎片也是正常的。
|
||||
- **mem_fragmentation_ratio 大于 1.5** 。这表明内存碎片率已经超过了50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。
|
||||
|
||||
## 如何清理内存碎片?
|
||||
|
||||
当Redis发生内存碎片后,一个“简单粗暴”的方法就是**重启Redis实例**。当然,这并不是一个“优雅”的方法,毕竟,重启Redis会带来两个后果:
|
||||
|
||||
- 如果Redis中的数据没有持久化,那么,数据就会丢失;
|
||||
- 即使Redis数据持久化了,我们还需要通过AOF或RDB进行恢复,恢复时长取决于AOF或RDB的大小,如果只有一个Redis实例,恢复阶段无法提供服务。
|
||||
|
||||
所以,还有什么其他好办法吗?
|
||||
|
||||
幸运的是,从4.0-RC3版本以后,Redis自身提供了一种内存碎片自动清理的方法,我们先来看这个方法的基本机制。
|
||||
|
||||
内存碎片清理,简单来说,就是“搬家让位,合并空间”。
|
||||
|
||||
我还以刚才的高铁车厢选座为例,来解释一下。你和小伙伴不想耽误时间,所以直接买了座位不在一起的三张票。但是,上车后,你和小伙伴通过和别人调换座位,又坐到了一起。
|
||||
|
||||
这么一说,碎片清理的机制就很容易理解了。当有数据把一块连续的内存空间分割成好几块不连续的空间时,操作系统就会把数据拷贝到别处。此时,数据拷贝需要能把这些数据原来占用的空间都空出来,把原本不连续的内存空间变成连续的空间。否则,如果数据拷贝后,并没有形成连续的内存空间,这就不能算是清理了。
|
||||
|
||||
我画一张图来解释一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/42/6480b6af5b2423b271ef3fb59f555842.jpg" alt="">
|
||||
|
||||
在进行碎片清理前,这段10字节的空间中分别有1个2字节和1个1字节的空闲空间,只是这两个空间并不连续。操作系统在清理碎片时,会先把应用D的数据拷贝到2字节的空闲空间中,并释放D原先所占的空间。然后,再把B的数据拷贝到D原来的空间中。这样一来,这段10字节空间的最后三个字节就是一块连续空间了。到这里,碎片清理结束。
|
||||
|
||||
不过,需要注意的是:**碎片清理是有代价的**,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。因为Redis是单线程,在数据拷贝时,Redis只能等着,这就导致Redis无法及时处理请求,性能就会降低。而且,有的时候,数据拷贝还需要注意顺序,就像刚刚说的清理内存碎片的例子,操作系统需要先拷贝D,并释放D的空间后,才能拷贝B。这种对顺序性的要求,会进一步增加Redis的等待时间,导致性能降低。
|
||||
|
||||
那么,有什么办法可以尽量缓解这个问题吗?这就要提到,Redis专门为自动内存碎片清理功机制设置的参数了。我们可以通过设置参数,来控制碎片清理的开始和结束时机,以及占用的CPU比例,从而减少碎片清理对Redis本身请求处理的性能影响。
|
||||
|
||||
首先,Redis需要启用自动内存碎片清理,可以把activedefrag配置项设置为yes,命令如下:
|
||||
|
||||
```
|
||||
config set activedefrag yes
|
||||
|
||||
```
|
||||
|
||||
这个命令只是启用了自动清理功能,但是,具体什么时候清理,会受到下面这两个参数的控制。这两个参数分别设置了触发内存清理的一个条件,如果同时满足这两个条件,就开始清理。在清理的过程中,只要有一个条件不满足了,就停止自动清理。
|
||||
|
||||
- **active-defrag-ignore-bytes 100mb**:表示内存碎片的字节数达到100MB时,开始清理;
|
||||
- **active-defrag-threshold-lower 10**:表示内存碎片空间占操作系统分配给Redis的总空间比例达到10%时,开始清理。
|
||||
|
||||
为了尽可能减少碎片清理对Redis正常请求处理的影响,自动内存碎片清理功能在执行时,还会监控清理操作占用的CPU时间,而且还设置了两个参数,分别用于控制清理操作占用的CPU时间比例的上、下限,既保证清理工作能正常进行,又避免了降低Redis性能。这两个参数具体如下:
|
||||
|
||||
- **active-defrag-cycle-min 25**: 表示自动清理过程所用CPU时间的比例不低于25%,保证清理能正常开展;
|
||||
- **active-defrag-cycle-max 75**:表示自动清理过程所用CPU时间的比例不高于75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞Redis,导致响应延迟升高。
|
||||
|
||||
自动内存碎片清理机制在控制碎片清理启停的时机上,既考虑了碎片的空间占比、对Redis内存使用效率的影响,还考虑了清理机制本身的CPU时间占比、对Redis性能的影响。而且,清理机制还提供了4个参数,让我们可以根据实际应用中的数据量需求和性能要求灵活使用,建议你在实践中好好地把这个机制用起来。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我和你一起了解了Redis的内存空间效率问题,这里面的一个关键技术点就是要识别和处理内存碎片。简单来说,就是“三个一”:
|
||||
|
||||
- info memory命令是一个**好工具**,可以帮助你查看碎片率的情况;
|
||||
- 碎片率阈值是一个**好经验**,可以帮忙你有效地判断是否要进行碎片清理了;
|
||||
- 内存碎片自动清理是一个**好方法**,可以避免因为碎片导致Redis的内存实际利用率降低,提升成本收益率。
|
||||
|
||||
内存碎片并不可怕,我们要做的就是了解它,重视它,并借用高效的方法解决它。
|
||||
|
||||
最后,我再给你提供一个小贴士:内存碎片自动清理涉及内存拷贝,这对Redis而言,是个潜在的风险。如果你在实践过程中遇到Redis性能变慢,记得通过日志看下是否正在进行碎片清理。如果Redis的确正在清理碎片,那么,我建议你调小active-defrag-cycle-max的值,以减轻对正常请求处理的影响。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提一个小问题。在这节课中,我提到,可以使用mem_fragmentation_ratio来判断Redis当前的内存碎片率是否严重,我给出的经验阈值都是大于1的。那么,我想请你来聊一聊,如果mem_fragmentation_ratio小于1了,Redis的内存使用是什么情况呢?会对Redis的性能和内存空间利用率造成什么影响呢?
|
||||
|
||||
欢迎你在留言区写下你的思考和答案,和我一起交流讨论,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友或同事,我们下节课见。
|
||||
209
极客时间专栏/Redis核心技术与实战/实践篇/21 | 缓冲区:一个可能引发“惨案”的地方.md
Normal file
209
极客时间专栏/Redis核心技术与实战/实践篇/21 | 缓冲区:一个可能引发“惨案”的地方.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="21 | 缓冲区:一个可能引发“惨案”的地方" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/e3/2240508bea644ca5e76281f82e4738e3.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。今天,我们一起来学习下Redis中缓冲区的用法。
|
||||
|
||||
缓冲区的功能其实很简单,主要就是用一块内存空间来暂时存放命令数据,以免出现因为数据和命令的处理速度慢于发送速度而导致的数据丢失和性能问题。但因为缓冲区的内存空间有限,如果往里面写入数据的速度持续地大于从里面读取数据的速度,就会导致缓冲区需要越来越多的内存来暂存数据。当缓冲区占用的内存超出了设定的上限阈值时,就会出现缓冲区溢出。
|
||||
|
||||
如果发生了溢出,就会丢数据了。那是不是不给缓冲区的大小设置上限,就可以了呢?显然不是,随着累积的数据越来越多,缓冲区占用内存空间越来越大,一旦耗尽了Redis实例所在机器的可用内存,就会导致Redis实例崩溃。
|
||||
|
||||
所以毫不夸张地说,缓冲区是用来避免请求或数据丢失的惨案的,但也只有用对了,才能真正起到“避免”的作用。
|
||||
|
||||
我们知道,Redis是典型的client-server架构,所有的操作命令都需要通过客户端发送给服务器端。所以,缓冲区在Redis中的一个主要应用场景,就是在客户端和服务器端之间进行通信时,用来暂存客户端发送的命令数据,或者是服务器端返回给客户端的数据结果。此外,缓冲区的另一个主要应用场景,是在主从节点间进行数据同步时,用来暂存主节点接收的写命令和数据。
|
||||
|
||||
这节课,我们就分别聊聊服务器端和客户端、主从集群间的缓冲区溢出问题,以及应对方案。
|
||||
|
||||
## 客户端输入和输出缓冲区
|
||||
|
||||
我们先来看看服务器端和客户端之间的缓冲区。
|
||||
|
||||
为了避免客户端和服务器端的请求发送和处理速度不匹配,服务器端给每个连接的客户端都设置了一个输入缓冲区和输出缓冲区,我们称之为客户端输入缓冲区和输出缓冲区。
|
||||
|
||||
输入缓冲区会先把客户端发送过来的命令暂存起来,Redis主线程再从输入缓冲区中读取命令,进行处理。当Redis主线程处理完数据后,会把结果写入到输出缓冲区,再通过输出缓冲区返回给客户端,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/e4/b86be61e91bd7ca207989c220991fce4.jpg" alt="">
|
||||
|
||||
下面,我们就分别学习下输入缓冲区和输出缓冲区发生溢出的情况,以及相应的应对方案。
|
||||
|
||||
### 如何应对输入缓冲区溢出?
|
||||
|
||||
我们前面已经分析过了,输入缓冲区就是用来暂存客户端发送的请求命令的,所以可能导致溢出的情况主要是下面两种:
|
||||
|
||||
- 写入了bigkey,比如一下子写入了多个百万级别的集合类型数据;
|
||||
- 服务器端处理请求的速度过慢,例如,Redis主线程出现了间歇性阻塞,无法及时处理正常发送的请求,导致客户端发送的请求在缓冲区越积越多。
|
||||
|
||||
接下来,我们就从**如何查看输入缓冲区的内存使用情况,<strong>以及**如何避免溢出</strong>这两个问题出发,来继续学习吧。
|
||||
|
||||
要查看和服务器端相连的每个客户端对输入缓冲区的使用情况,我们可以**使用CLIENT LIST命令**:
|
||||
|
||||
```
|
||||
CLIENT LIST
|
||||
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client
|
||||
|
||||
```
|
||||
|
||||
CLIENT命令返回的信息虽然很多,但我们只需要重点关注两类信息就可以了。
|
||||
|
||||
一类是与服务器端连接的客户端的信息。这个案例展示的是一个客户端的输入缓冲区情况,如果有多个客户端,输出结果中的addr会显示不同客户端的IP和端口号。
|
||||
|
||||
另一类是与输入缓冲区相关的三个参数:
|
||||
|
||||
- cmd,表示客户端最新执行的命令。这个例子中执行的是CLIENT命令。
|
||||
- qbuf,表示输入缓冲区已经使用的大小。这个例子中的CLIENT命令已使用了26字节大小的缓冲区。
|
||||
- qbuf-free,表示输入缓冲区尚未使用的大小。这个例子中的CLIENT命令还可以使用32742字节的缓冲区。qbuf和qbuf-free的总和就是,Redis服务器端当前为已连接的这个客户端分配的缓冲区总大小。这个例子中总共分配了 26 + 32742 = 32768字节,也就是32KB的缓冲区。
|
||||
|
||||
有了CLIENT LIST命令,我们就可以通过输出结果来判断客户端输入缓冲区的内存占用情况了。如果qbuf很大,而同时qbuf-free很小,就要引起注意了,因为这时候输入缓冲区已经占用了很多内存,而且没有什么空闲空间了。此时,客户端再写入大量命令的话,就会引起客户端输入缓冲区溢出,Redis的处理办法就是把客户端连接关闭,结果就是业务程序无法进行数据存取了。
|
||||
|
||||
通常情况下,Redis服务器端不止服务一个客户端,当多个客户端连接占用的内存总量,超过了Redis的maxmemory配置项时(例如4GB),就会触发Redis进行数据淘汰。一旦数据被淘汰出Redis,再要访问这部分数据,就需要去后端数据库读取,这就降低了业务应用的访问性能。此外,更糟糕的是,如果使用多个客户端,导致Redis内存占用过大,也会导致内存溢出(out-of-memory)问题,进而会引起Redis崩溃,给业务应用造成严重影响。
|
||||
|
||||
所以,我们必须得想办法避免输入缓冲区溢出。**我们可以从两个角度去考虑如何避免,一是把缓冲区调大,二是从数据命令的发送和处理速度入手。**
|
||||
|
||||
我们先看看,到底有没有办法通过参数调整输入缓冲区的大小呢?答案是没有。
|
||||
|
||||
Redis的客户端输入缓冲区大小的上限阈值,在代码中就设定为了1GB。也就是说,Redis服务器端允许为每个客户端最多暂存1GB的命令和数据。1GB的大小,对于一般的生产环境已经是比较合适的了。一方面,这个大小对于处理绝大部分客户端的请求已经够用了;另一方面,如果再大的话,Redis就有可能因为客户端占用了过多的内存资源而崩溃。
|
||||
|
||||
所以,Redis并没有提供参数让我们调节客户端输入缓冲区的大小。如果要避免输入缓冲区溢出,那我们就只能从数据命令的发送和处理速度入手,也就是前面提到的避免客户端写入bigkey,以及避免Redis主线程阻塞。
|
||||
|
||||
接下来,我们再来看看输出缓冲区的溢出问题。
|
||||
|
||||
### 如何应对输出缓冲区溢出?
|
||||
|
||||
Redis的输出缓冲区暂存的是Redis主线程要返回给客户端的数据。一般来说,主线程返回给客户端的数据,既有简单且大小固定的OK响应(例如,执行SET命令)或报错信息,也有大小不固定的、包含具体数据的执行结果(例如,执行HGET命令)。
|
||||
|
||||
因此,Redis为每个客户端设置的输出缓冲区也包括两部分:一部分,是一个大小为16KB的固定缓冲空间,用来暂存OK响应和出错信息;另一部分,是一个可以动态增加的缓冲空间,用来暂存大小可变的响应结果。
|
||||
|
||||
**那什么情况下会发生输出缓冲区溢出呢?** 我为你总结了三种:
|
||||
|
||||
- 服务器端返回bigkey的大量结果;
|
||||
- 执行了MONITOR命令;
|
||||
- 缓冲区大小设置得不合理。
|
||||
|
||||
其中,bigkey原本就会占用大量的内存空间,所以服务器端返回的结果包含bigkey,必然会影响输出缓冲区。接下来,我们就重点看下,执行MONITOR命令和设置缓冲区大小这两种情况吧。
|
||||
|
||||
MONITOR命令是用来监测Redis执行的。执行这个命令之后,就会持续输出监测到的各个命令操作,如下所示:
|
||||
|
||||
```
|
||||
MONITOR
|
||||
OK
|
||||
1600617456.437129 [0 127.0.0.1:50487] "COMMAND"
|
||||
1600617477.289667 [0 127.0.0.1:50487] "info" "memory"
|
||||
|
||||
```
|
||||
|
||||
到这里,你有没有看出什么问题呢?MONITOR的输出结果会持续占用输出缓冲区,并越占越多,最后的结果就是发生溢出。所以,我要给你一个小建议:**MONITOR命令主要用在调试环境中,不要在线上生产环境中持续使用MONITOR**。当然,如果在线上环境中偶尔使用MONITOR检查Redis的命令执行情况,是没问题的。
|
||||
|
||||
**接下来,我们看下输出缓冲区大小设置的问题**。和输入缓冲区不同,我们可以通过client-output-buffer-limit配置项,来设置缓冲区的大小。具体设置的内容包括两方面:
|
||||
|
||||
- 设置缓冲区大小的上限阈值;
|
||||
- 设置输出缓冲区持续写入数据的数量上限阈值,和持续写入数据的时间的上限阈值。
|
||||
|
||||
在具体使用client-output-buffer-limit来设置缓冲区大小的时候,我们需要先区分下客户端的类型。
|
||||
|
||||
对于和Redis实例进行交互的应用程序来说,主要使用两类客户端和Redis服务器端交互,分别是常规和Redis服务器端进行读写命令交互的普通客户端,以及订阅了Redis频道的订阅客户端。此外,在Redis主从集群中,主节点上也有一类客户端(从节点客户端)用来和从节点进行数据同步,我会在介绍主从集群中的缓冲区时,向你具体介绍。
|
||||
|
||||
当我们给普通客户端设置缓冲区大小时,通常可以在Redis配置文件中进行这样的设置:
|
||||
|
||||
```
|
||||
client-output-buffer-limit normal 0 0 0
|
||||
|
||||
```
|
||||
|
||||
其中,normal表示当前设置的是普通客户端,第1个0设置的是缓冲区大小限制,第2个0和第3个0分别表示缓冲区持续写入量限制和持续写入时间限制。
|
||||
|
||||
对于普通客户端来说,它每发送完一个请求,会等到请求结果返回后,再发送下一个请求,这种发送方式称为阻塞式发送。在这种情况下,如果不是读取体量特别大的bigkey,服务器端的输出缓冲区一般不会被阻塞的。
|
||||
|
||||
所以,我们通常把普通客户端的缓冲区大小限制,以及持续写入量限制、持续写入时间限制都设置为0,也就是不做限制。
|
||||
|
||||
对于订阅客户端来说,一旦订阅的Redis频道有消息了,服务器端都会通过输出缓冲区把消息发给客户端。所以,订阅客户端和服务器间的消息发送方式,不属于阻塞式发送。不过,如果频道消息较多的话,也会占用较多的输出缓冲区空间。
|
||||
|
||||
因此,我们会给订阅客户端设置缓冲区大小限制、缓冲区持续写入量限制,以及持续写入时间限制,可以在Redis配置文件中这样设置:
|
||||
|
||||
```
|
||||
client-output-buffer-limit pubsub 8mb 2mb 60
|
||||
|
||||
```
|
||||
|
||||
其中,pubsub参数表示当前是对订阅客户端进行设置;8mb表示输出缓冲区的大小上限为8MB,一旦实际占用的缓冲区大小要超过8MB,服务器端就会直接关闭客户端的连接;2mb和60表示,如果连续60秒内对输出缓冲区的写入量超过2MB的话,服务器端也会关闭客户端连接。
|
||||
|
||||
好了,我们来总结下如何应对输出缓冲区溢出:
|
||||
|
||||
- 避免bigkey操作返回大量数据结果;
|
||||
- 避免在线上环境中持续使用MONITOR命令。
|
||||
- 使用client-output-buffer-limit设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限。
|
||||
|
||||
以上就是关于客户端缓冲区,我们要重点掌握的内容了。我们继续看看在主从集群间使用缓冲区,需要注意什么问题。
|
||||
|
||||
## 主从集群中的缓冲区
|
||||
|
||||
主从集群间的数据复制包括全量复制和增量复制两种。全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。无论在哪种形式的复制中,为了保证主从节点的数据一致,都会用到缓冲区。但是,这两种复制场景下的缓冲区,在溢出影响和大小设置方面并不一样。所以,我们分别来学习下吧。
|
||||
|
||||
### 复制缓冲区的溢出问题
|
||||
|
||||
在全量复制过程中,主节点在向从节点传输RDB文件的同时,会继续接收客户端发送的写命令请求。这些写命令就会先保存在复制缓冲区中,等RDB文件传输完成后,再发送给从节点去执行。主节点上会为每个从节点都维护一个复制缓冲区,来保证主从节点间的数据同步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/7a/a39cd9a9f62c547e2069e6977239de7a.jpg" alt="">
|
||||
|
||||
所以,如果在全量复制时,从节点接收和加载RDB较慢,同时主节点接收到了大量的写命令,写命令在复制缓冲区中就会越积越多,最终导致溢出。
|
||||
|
||||
其实,主节点上的复制缓冲区,本质上也是一个用于和从节点连接的客户端(我们称之为从节点客户端),使用的输出缓冲区。复制缓冲区一旦发生溢出,主节点也会直接关闭和从节点进行复制操作的连接,导致全量复制失败。那如何避免复制缓冲区发生溢出呢?
|
||||
|
||||
一方面,我们可以控制主节点保存的数据量大小。按通常的使用经验,我们会把主节点的数据量控制在2~4GB,这样可以让全量同步执行得更快些,避免复制缓冲区累积过多命令。
|
||||
|
||||
另一方面,我们可以使用client-output-buffer-limit配置项,来设置合理的复制缓冲区大小。设置的依据,就是主节点的数据量大小、主节点的写负载压力和主节点本身的内存大小。
|
||||
|
||||
我们通过一个具体的例子,来学习下具体怎么设置。在主节点执行如下命令:
|
||||
|
||||
```
|
||||
config set client-output-buffer-limit slave 512mb 128mb 60
|
||||
|
||||
```
|
||||
|
||||
其中,slave参数表明该配置项是针对复制缓冲区的。512mb代表将缓冲区大小的上限设置为512MB;128mb和60代表的设置是,如果连续60秒内的写入量超过128MB的话,也会触发缓冲区溢出。
|
||||
|
||||
我们再继续看看这个设置对我们有啥用。假设一条写命令数据是1KB,那么,复制缓冲区可以累积512K条(512MB/1KB = 512K)写命令。同时,主节点在全量复制期间,可以承受的写命令速率上限是2000条/s(128MB/1KB/60 约等于2000)。
|
||||
|
||||
这样一来,我们就得到了一种方法:在实际应用中设置复制缓冲区的大小时,可以根据写命令数据的大小和应用的实际负载情况(也就是写命令速率),来粗略估计缓冲区中会累积的写命令数据量;然后,再和所设置的复制缓冲区大小进行比较,判断设置的缓冲区大小是否足够支撑累积的写命令数据量。
|
||||
|
||||
关于复制缓冲区,我们还会遇到一个问题。主节点上复制缓冲区的内存开销,会是每个从节点客户端输出缓冲区占用内存的总和。如果集群中的从节点数非常多的话,主节点的内存开销就会非常大。所以,我们还必须得控制和主节点连接的从节点个数,不要使用大规模的主从集群。
|
||||
|
||||
好了,我们先总结一下这部分的内容。为了避免复制缓冲区累积过多命令造成溢出,引发全量复制失败,我们可以控制主节点保存的数据量大小,并设置合理的复制缓冲区大小。同时,我们需要控制从节点的数量,来避免主节点中复制缓冲区占用过多内存的问题。
|
||||
|
||||
### 复制积压缓冲区的溢出问题
|
||||
|
||||
接下来,我们再来看下增量复制时使用的缓冲区,这个缓冲区称为复制积压缓冲区。
|
||||
|
||||
主节点在把接收到的写命令同步给从节点时,同时会把这些写命令写入复制积压缓冲区。一旦从节点发生网络闪断,再次和主节点恢复连接后,从节点就会从复制积压缓冲区中,读取断连期间主节点接收到的写命令,进而进行增量同步,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/8f/aedc9b41b31860e283c5d140bdb3318f.jpg" alt="">
|
||||
|
||||
看到这里你是不是感觉有些熟悉?没错,我们在[第6讲](https://time.geekbang.org/column/article/272852)时已经学过复制积压缓冲区了,只不过我当时告诉你的是它的英文名字repl_backlog_buffer。所以这一讲,我们从缓冲区溢出的角度再来回顾下两个重点:复制积压缓冲区溢出的影响,以及如何应对复制积压缓冲区的溢出问题。
|
||||
|
||||
首先,复制积压缓冲区是一个大小有限的环形缓冲区。当主节点把复制积压缓冲区写满后,会覆盖缓冲区中的旧命令数据。如果从节点还没有同步这些旧命令数据,就会造成主从节点间重新开始执行全量复制。
|
||||
|
||||
其次,为了应对复制积压缓冲区的溢出问题,我们可以调整复制积压缓冲区的大小,也就是设置repl_backlog_size这个参数的值。具体的调整依据,你可以再看下[第6讲](https://time.geekbang.org/column/article/272852)中提供的repl_backlog_size大小的计算依据。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们一起学习了Redis中使用的缓冲区。使用缓冲区以后,当命令数据的接收方处理速度跟不上发送方的发送速度时,缓冲区可以避免命令数据的丢失。
|
||||
|
||||
按照缓冲区的用途,例如是用于客户端通信还是用于主从节点复制,我把缓冲区分成了客户端的输入和输出缓冲区,以及主从集群中主节点上的复制缓冲区和复制积压缓冲区。这样学习的好处是,你可以很清楚Redis中到底有哪些地方使用了缓冲区,那么在排查问题的时候,就可以快速找到方向——从客户端和服务器端的通信过程以及主从节点的复制过程中分析原因。
|
||||
|
||||
现在,从缓冲区溢出对Redis的影响的角度,我再把这四个缓冲区分成两类做个总结。
|
||||
|
||||
- 缓冲区溢出导致网络连接关闭:普通客户端、订阅客户端,以及从节点客户端,它们使用的缓冲区,本质上都是Redis客户端和服务器端之间,或是主从节点之间为了传输命令数据而维护的。这些缓冲区一旦发生溢出,处理机制都是直接把客户端和服务器端的连接,或是主从节点间的连接关闭。网络连接关闭造成的直接影响,就是业务程序无法读写Redis,或者是主从节点全量同步失败,需要重新执行。
|
||||
- 缓冲区溢出导致命令数据丢失:主节点上的复制积压缓冲区属于环形缓冲区,一旦发生溢出,新写入的命令数据就会覆盖旧的命令数据,导致旧命令数据的丢失,进而导致主从节点重新进行全量复制。
|
||||
|
||||
从本质上看,缓冲区溢出,无非就是三个原因:命令数据发送过快过大;命令数据处理较慢;缓冲区空间过小。明白了这个,我们就可以有针对性地拿出应对策略了。
|
||||
|
||||
- 针对命令数据发送过快过大的问题,对于普通客户端来说可以避免bigkey,而对于复制缓冲区来说,就是避免过大的RDB文件。
|
||||
- 针对命令数据处理较慢的问题,解决方案就是减少Redis主线程上的阻塞操作,例如使用异步的删除操作。
|
||||
- 针对缓冲区空间过小的问题,解决方案就是使用client-output-buffer-limit配置项设置合理的输出缓冲区、复制缓冲区和复制积压缓冲区大小。当然,我们不要忘了,输入缓冲区的大小默认是固定的,我们无法通过配置来修改它,除非直接去修改Redis源码。
|
||||
|
||||
有了上面这些应对方法,我相信你在实际应用时,就可以避免缓冲区溢出带来的命令数据丢失、Redis崩溃的这些“惨案”了。
|
||||
|
||||
## 每课一问
|
||||
|
||||
最后,我给你提个小问题吧。
|
||||
|
||||
在这节课上,我们提到Redis采用了client-server架构,服务器端会为每个客户端维护输入、输出缓冲区。那么,应用程序和Redis实例交互时,应用程序中使用的客户端需要使用缓冲区吗?如果使用的话,对Redis的性能和内存使用会有影响吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事,我们下节课见。
|
||||
261
极客时间专栏/Redis核心技术与实战/实践篇/22 | 第11~21讲课后思考题答案及常见问题答疑.md
Normal file
261
极客时间专栏/Redis核心技术与实战/实践篇/22 | 第11~21讲课后思考题答案及常见问题答疑.md
Normal file
@@ -0,0 +1,261 @@
|
||||
<audio id="audio" title="22 | 第11~21讲课后思考题答案及常见问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/ce/6df7cb9e10af08157c6bf095c3ba45ce.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
咱们的课程已经更新到第21讲了,今天,我们来进行一场答疑。
|
||||
|
||||
前半部分,我会给你讲解第11~21讲的课后思考题。在学习这部分内容时,可以和你的答案进行对照,看看还有哪里没有考虑到。当然,有些问题不一定有标准答案,我们还可以继续讨论。
|
||||
|
||||
后半部分,我会围绕着许多同学都很关注的如何排查慢查询命令和bigkey的问题,重点解释一下,希望可以解答你的困惑。
|
||||
|
||||
好了,我们现在开始。
|
||||
|
||||
## 课后思考题答案
|
||||
|
||||
### [第11讲](https://time.geekbang.org/column/article/279649)
|
||||
|
||||
**问题:除了String类型和Hash类型,还有什么类型适合保存第11讲中所说的图片吗?**
|
||||
|
||||
答案:除了String和Hash,我们还可以使用Sorted Set类型进行保存。Sorted Set的元素有member值和score值,可以像Hash那样,使用二级编码进行保存。具体做法是,把图片ID的前7位作为Sorted Set的key,把图片ID的后3位作为member值,图片存储对象ID作为score值。
|
||||
|
||||
Sorted Set中元素较少时,Redis会使用压缩列表进行存储,可以节省内存空间。不过,和Hash不一样,Sorted Set插入数据时,需要按score值的大小排序。当底层结构是压缩列表时,Sorted Set的插入性能就比不上Hash。所以,在我们这节课描述的场景中,Sorted Set类型虽然可以用来保存,但并不是最优选项。
|
||||
|
||||
### [第12讲](https://time.geekbang.org/column/article/280680)
|
||||
|
||||
问题:我在第12讲中介绍了4种典型的统计模式,分别是聚合统计、排序统计、二值状态统计和基数统计,以及它们各自适合的集合类型。你还遇到过其他的统计场景吗?用的是什么集合类型呢?
|
||||
|
||||
答案:@海拉鲁同学在留言中提供了一种场景:他们曾使用List+Lua统计最近200个客户的触达率。具体做法是,每个List元素表示一个客户,元素值为0,代表触达;元素值为1,就代表未触达。在进行统计时,应用程序会把代表客户的元素写入队列中。当需要统计触达率时,就使用LRANGE key 0 -1 取出全部元素,计算0的比例,这个比例就是触达率。
|
||||
|
||||
这个例子需要获取全部元素,不过数据量只有200个,不算大,所以,使用List,在实际应用中也是可以接受的。但是,如果数据量很大,又有其他查询需求的话(例如查询单个元素的触达情况),List的操作复杂度较高,就不合适了,可以考虑使用Hash类型。
|
||||
|
||||
### [第13讲](https://time.geekbang.org/column/article/281745)
|
||||
|
||||
问题:你在日常的实践过程中,还用过Redis的其他数据类型吗?
|
||||
|
||||
答案:除了我们课程上介绍的5大基本数据类型,以及HyperLogLog、Bitmap、GEO,Redis还有一种数据类型,叫作布隆过滤器。它的查询效率很高,经常会用在缓存场景中,可以用来判断数据是否存在缓存中。我会在后面(第25讲)具体地介绍一下它。
|
||||
|
||||
### [第14讲](https://time.geekbang.org/column/article/282478)
|
||||
|
||||
问题:在用Sorted Set保存时间序列数据时,如果把时间戳作为score,把实际的数据作为member,这样保存数据有没有潜在的风险?另外,如果你是Redis的开发维护者,你会把聚合计算也设计为Sorted Set的一个内在功能吗?
|
||||
|
||||
答案:Sorted Set和Set一样,都会对集合中的元素进行去重,也就是说,如果我们往集合中插入的member值,和之前已经存在的member值一样,那么,原来member的score就会被新写入的member的score覆盖。相同member的值,在Sorted Set中只会保留一个。
|
||||
|
||||
对于时间序列数据来说,这种去重的特性是会带来数据丢失风险的。毕竟,某一时间段内的多个时间序列数据的值可能是相同的。如果我们往Sorted Set中写入的数据是在不同时刻产生的,但是写入的时刻不同,Sorted Set中只会保存一份最近时刻的数据。这样一来,其他时刻的数据就都没有保存下来。
|
||||
|
||||
举个例子,在记录物联网设备的温度时,一个设备一个上午的温度值可能都是26。在Sorted Set中,我们把温度值作为member,把时间戳作为score。我们用ZADD命令把上午不同时刻的温度值写入Sorted Set。由于member值一样,所以只会把score更新为最新时间戳,最后只有一个最新时间戳(例如上午12点)下的温度值。这肯定是无法满足保存多个时刻数据的需求的。
|
||||
|
||||
关于是否把聚合计算作为Sorted Set的内在功能,考虑到Redis的读写功能是由单线程执行,在进行数据读写时,本身就会消耗较多的CPU资源,如果再在Sorted Set中实现聚合计算,就会进一步增加CPU的资源消耗,影响到Redis的正常数据读取。所以,如果我是Redis的开发维护者,除非对Redis的线程模型做修改,比如说在Redis中使用额外的线程池做聚合计算,否则,我不会把聚合计算作为Redis的内在功能实现的。
|
||||
|
||||
### [第15讲](https://time.geekbang.org/column/article/284291)
|
||||
|
||||
问题:如果一个生产者发送给消息队列的消息,需要被多个消费者进行读取和处理(例如,一个消息是一条从业务系统采集的数据,既要被消费者1读取并进行实时计算,也要被消费者2读取并留存到分布式文件系统HDFS中,以便后续进行历史查询),你会使用Redis的什么数据类型来解决这个问题呢?
|
||||
|
||||
答案:有同学提到,可以使用Streams数据类型的消费组,同时消费生产者的数据,这是可以的。但是,有个地方需要注意,如果只是使用一个消费组的话,消费组内的多个消费者在消费消息时是互斥的,换句话说,在一个消费组内,一个消息只能被一个消费者消费。我们希望消息既要被消费者1读取,也要被消费者2读取,是一个多消费者的需求。所以,如果使用消费组模式,需要让消费者1和消费者2属于不同的消费组,这样它们就能同时消费了。
|
||||
|
||||
另外,Redis基于字典和链表数据结构,实现了发布和订阅功能,这个功能可以实现一个消息被多个消费者消费使用,可以满足问题中的场景需求。
|
||||
|
||||
### [第16讲](https://time.geekbang.org/column/article/285000)
|
||||
|
||||
问题:Redis的写操作(例如SET、HSET、SADD等)是在关键路径上吗?
|
||||
|
||||
答案:Redis本身是内存数据库,所以,写操作都需要在内存上完成执行后才能返回,这就意味着,如果这些写操作处理的是大数据集,例如1万个数据,那么,主线程需要等这1万个数据都写完,才能继续执行后面的命令。所以说,Redis的写操作也是在关键路径上的。
|
||||
|
||||
这个问题是希望你把面向内存和面向磁盘的写操作区分开。当一个写操作需要把数据写到磁盘时,一般来说,写操作只要把数据写到操作系统的内核缓冲区就行。不过,如果我们执行了同步写操作,那就必须要等到数据写回磁盘。所以,面向磁盘的写操作一般不会在关键路径上。
|
||||
|
||||
我看到有同学说,根据写操作命令的返回值来决定是否在关键路径上,如果返回值是OK,或者客户端不关心是否写成功,那么,此时的写操作就不算在关键路径上。
|
||||
|
||||
这个思路不错,不过,需要注意的是,客户端经常会阻塞等待发送的命令返回结果,在上一个命令还没有返回结果前,客户端会一直等待,直到返回结果后,才会发送下一个命令。此时,即使我们不关心返回结果,客户端也要等到写操作执行完成才行。所以,在不关心写操作返回结果的场景下,可以对Redis客户端做异步改造。具体点说,就是使用异步线程发送这些不关心返回结果的命令,而不是在Redis客户端中等待这些命令的结果。
|
||||
|
||||
### [第17讲](https://time.geekbang.org/column/article/286082)
|
||||
|
||||
问题:在一台有两个CPU Socket(每个Socket 8个物理核)的服务器上,我们部署了一个有着8个实例的Redis切片集群(8个实例都为主节点,没有主备关系),现在有两个方案:
|
||||
|
||||
1. 在同一个CPU Socket上运行8个实例,并和8个CPU核绑定;
|
||||
1. 在两个CPU Socket上各运行4个实例,并和相应Socket上的核绑定。
|
||||
|
||||
如果不考虑网络数据读取的影响,你会选择哪个方案呢?
|
||||
|
||||
答案:建议使用第二个方案,主要有两方面的原因。
|
||||
|
||||
<li>
|
||||
同一个CPU Socket上的进程,会共享L3缓存。如果把8个实例都部署在同一个Socket上,它们会竞争L3缓存,这就会导致它们的L3缓存命中率降低,影响访问性能。
|
||||
</li>
|
||||
<li>
|
||||
同一个CPU Socket上的进程,会使用同一个Socket上的内存空间。8个实例共享同一个Socket上的内存空间,肯定会竞争内存资源。如果有实例保存的数据量大,其他实例能用到的内存空间可能就不够了,此时,其他实例就会跨Socket申请内存,进而造成跨Socket访问内存,造成实例的性能降低。
|
||||
</li>
|
||||
|
||||
另外,在切片集群中,不同实例间通过网络进行消息通信和数据迁移,并不会使用共享内存空间进行跨实例的数据访问。所以,即使把不同的实例部署到不同的Socket上,它们之间也不会发生跨Socket内存的访问,不会受跨Socket内存访问的负面影响。
|
||||
|
||||
### [第18讲](https://time.geekbang.org/column/article/286549)
|
||||
|
||||
问题:在Redis中,还有哪些命令可以代替KEYS命令,实现对键值对的key的模糊查询呢?这些命令的复杂度会导致Redis变慢吗?
|
||||
|
||||
答案:Redis提供的SCAN命令,以及针对集合类型数据提供的SSCAN、HSCAN等,可以根据执行时设定的数量参数,返回指定数量的数据,这就可以避免像KEYS命令一样同时返回所有匹配的数据,不会导致Redis变慢。以HSCAN为例,我们可以执行下面的命令,从user这个Hash集合中返回key前缀以103开头的100个键值对。
|
||||
|
||||
```
|
||||
HSCAN user 0 match "103*" 100
|
||||
|
||||
```
|
||||
|
||||
### [第19讲](https://time.geekbang.org/column/article/287819)
|
||||
|
||||
问题:你遇到过Redis变慢的情况吗?如果有的话,你是怎么解决的呢?
|
||||
|
||||
答案:@Kaito同学在留言区分享了他排查Redis变慢问题的Checklist,而且还提供了解决方案,非常好,我把Kaito同学给出的导致Redis变慢的原因汇总并完善一下,分享给你:
|
||||
|
||||
1. 使用复杂度过高的命令或一次查询全量数据;
|
||||
1. 操作bigkey;
|
||||
1. 大量key集中过期;
|
||||
1. 内存达到maxmemory;
|
||||
1. 客户端使用短连接和Redis相连;
|
||||
1. 当Redis实例的数据量大时,无论是生成RDB,还是AOF重写,都会导致fork耗时严重;
|
||||
1. AOF的写回策略为always,导致每个操作都要同步刷回磁盘;
|
||||
1. Redis实例运行机器的内存不足,导致swap发生,Redis需要到swap分区读取数据;
|
||||
1. 进程绑定CPU不合理;
|
||||
1. Redis实例运行机器上开启了透明内存大页机制;
|
||||
1. 网卡压力过大。
|
||||
|
||||
### [第20讲](https://time.geekbang.org/column/article/289140)
|
||||
|
||||
问题:我们可以使用mem_fragmentation_ratio来判断Redis当前的内存碎片率是否严重,我给出的经验阈值都是大于1的。我想请你思考一下,如果mem_fragmentation_ratio小于1,Redis的内存使用是什么情况呢?会对Redis的性能和内存空间利用率造成什么影响呢?
|
||||
|
||||
答案:如果mem_fragmentation_ratio小于1,就表明,操作系统分配给Redis的内存空间已经小于Redis所申请的空间大小了,此时,运行Redis实例的服务器上的内存已经不够用了,可能已经发生swap了。这样一来,Redis的读写性能也会受到影响,因为Redis实例需要在磁盘上的swap分区中读写数据,速度较慢。
|
||||
|
||||
### [第21讲](https://time.geekbang.org/column/article/291277)
|
||||
|
||||
问题:在和Redis实例交互时,应用程序中使用的客户端需要使用缓冲区吗?如果使用的话,对Redis的性能和内存使用会有影响吗?
|
||||
|
||||
答案:应用程序中使用的Redis客户端,需要把要发送的请求暂存在缓冲区。这有两方面的好处。
|
||||
|
||||
一方面,可以在客户端控制发送速率,避免把过多的请求一下子全部发到Redis实例,导致实例因压力过大而性能下降。不过,客户端缓冲区不会太大,所以,对Redis实例的内存使用没有什么影响。
|
||||
|
||||
另一方面,在应用Redis主从集群时,主从节点进行故障切换是需要一定时间的,此时,主节点无法服务外来请求。如果客户端有缓冲区暂存请求,那么,客户端仍然可以正常接收业务应用的请求,这就可以避免直接给应用返回无法服务的错误。
|
||||
|
||||
## 代表性问题
|
||||
|
||||
在前面的课程中,我重点介绍了避免Redis变慢的方法。慢查询命令的执行时间和bigkey操作的耗时都很长,会阻塞Redis。很多同学学完之后,知道了要尽量避免Redis阻塞,但是还不太清楚,具体应该如何排查阻塞的命令和bigkey呢。
|
||||
|
||||
所以,接下来,我就再重点解释一下,如何排查慢查询命令,以及如何排查bigkey。
|
||||
|
||||
**问题1:如何使用慢查询日志和latency monitor排查执行慢的操作?**
|
||||
|
||||
在第18讲中,我提到,可以使用Redis日志(慢查询日志)和latency monitor来排查执行较慢的命令操作,那么,我们该如何使用慢查询日志和latency monitor呢?
|
||||
|
||||
Redis的慢查询日志记录了执行时间超过一定阈值的命令操作。当我们发现Redis响应变慢、请求延迟增加时,就可以在慢查询日志中进行查找,确定究竟是哪些命令执行时间很长。
|
||||
|
||||
在使用慢查询日志前,我们需要设置两个参数。
|
||||
|
||||
- **slowlog-log-slower-than**:这个参数表示,慢查询日志对执行时间大于多少微秒的命令进行记录。
|
||||
- **slowlog-max-len**:这个参数表示,慢查询日志最多能记录多少条命令记录。慢查询日志的底层实现是一个具有预定大小的先进先出队列,一旦记录的命令数量超过了队列长度,最先记录的命令操作就会被删除。这个值默认是128。但是,如果慢查询命令较多的话,日志里就存不下了;如果这个值太大了,又会占用一定的内存空间。所以,一般建议设置为1000左右,这样既可以多记录些慢查询命令,方便排查,也可以避免内存开销。
|
||||
|
||||
设置好参数后,慢查询日志就会把执行时间超过slowlog-log-slower-than阈值的命令操作记录在日志中。
|
||||
|
||||
我们可以使用SLOWLOG GET命令,来查看慢查询日志中记录的命令操作,例如,我们执行如下命令,可以查看最近的一条慢查询的日志信息。
|
||||
|
||||
```
|
||||
SLOWLOG GET 1
|
||||
1) 1) (integer) 33 //每条日志的唯一ID编号
|
||||
2) (integer) 1600990583 //命令执行时的时间戳
|
||||
3) (integer) 20906 //命令执行的时长,单位是微秒
|
||||
4) 1) "keys" //具体的执行命令和参数
|
||||
2) "abc*"
|
||||
5) "127.0.0.1:54793" //客户端的IP和端口号
|
||||
6) "" //客户端的名称,此处为空
|
||||
|
||||
```
|
||||
|
||||
可以看到,KEYS "abc*"这条命令的执行时间是20906微秒,大约20毫秒,的确是一条执行较慢的命令操作。如果我们想查看更多的慢日志,只要把SLOWLOG GET后面的数字参数改为想查看的日志条数,就可以了。
|
||||
|
||||
好了,有了慢查询日志后,我们就可以快速确认,究竟是哪些命令的执行时间比较长,然后可以反馈给业务部门,让业务开发人员避免在应用Redis的过程中使用这些命令,或是减少操作的数据量,从而降低命令的执行复杂度。
|
||||
|
||||
除了慢查询日志以外,Redis从2.8.13版本开始,还提供了latency monitor监控工具,这个工具可以用来监控Redis运行过程中的峰值延迟情况。
|
||||
|
||||
和慢查询日志的设置相类似,要使用latency monitor,首先要设置命令执行时长的阈值。当一个命令的实际执行时长超过该阈值时,就会被latency monitor监控到。比如,我们可以把latency monitor监控的命令执行时长阈值设为1000微秒,如下所示:
|
||||
|
||||
```
|
||||
config set latency-monitor-threshold 1000
|
||||
|
||||
```
|
||||
|
||||
设置好了latency monitor的参数后,我们可以使用latency latest命令,查看最新和最大的超过阈值的延迟情况,如下所示:
|
||||
|
||||
```
|
||||
latency latest
|
||||
1) 1) "command"
|
||||
2) (integer) 1600991500 //命令执行的时间戳
|
||||
3) (integer) 2500 //最近的超过阈值的延迟
|
||||
4) (integer) 10100 //最大的超过阈值的延迟
|
||||
|
||||
```
|
||||
|
||||
**问题2:如何排查Redis的bigkey?**
|
||||
|
||||
在应用Redis时,我们要尽量避免bigkey的使用,这是因为,Redis主线程在操作bigkey时,会被阻塞。那么,一旦业务应用中使用了bigkey,我们该如何进行排查呢?
|
||||
|
||||
Redis可以在执行redis-cli命令时带上–bigkeys选项,进而对整个数据库中的键值对大小情况进行统计分析,比如说,统计每种数据类型的键值对个数以及平均大小。此外,这个命令执行后,会输出每种数据类型中最大的bigkey的信息,对于String类型来说,会输出最大bigkey的字节长度,对于集合类型来说,会输出最大bigkey的元素个数,如下所示:
|
||||
|
||||
```
|
||||
./redis-cli --bigkeys
|
||||
|
||||
-------- summary -------
|
||||
Sampled 32 keys in the keyspace!
|
||||
Total key length in bytes is 184 (avg len 5.75)
|
||||
|
||||
//统计每种数据类型中元素个数最多的bigkey
|
||||
Biggest list found 'product1' has 8 items
|
||||
Biggest hash found 'dtemp' has 5 fields
|
||||
Biggest string found 'page2' has 28 bytes
|
||||
Biggest stream found 'mqstream' has 4 entries
|
||||
Biggest set found 'userid' has 5 members
|
||||
Biggest zset found 'device:temperature' has 6 members
|
||||
|
||||
//统计每种数据类型的总键值个数,占所有键值个数的比例,以及平均大小
|
||||
4 lists with 15 items (12.50% of keys, avg size 3.75)
|
||||
5 hashs with 14 fields (15.62% of keys, avg size 2.80)
|
||||
10 strings with 68 bytes (31.25% of keys, avg size 6.80)
|
||||
1 streams with 4 entries (03.12% of keys, avg size 4.00)
|
||||
7 sets with 19 members (21.88% of keys, avg size 2.71)
|
||||
5 zsets with 17 members (15.62% of keys, avg size 3.40)
|
||||
|
||||
```
|
||||
|
||||
不过,在使用–bigkeys选项时,有一个地方需要注意一下。这个工具是通过扫描数据库来查找bigkey的,所以,在执行的过程中,会对Redis实例的性能产生影响。如果你在使用主从集群,我建议你在从节点上执行该命令。因为主节点上执行时,会阻塞主节点。如果没有从节点,那么,我给你两个小建议:第一个建议是,在Redis实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;第二个建议是,可以使用-i参数控制扫描间隔,避免长时间扫描降低Redis实例的性能。例如,我们执行如下命令时,redis-cli会每扫描100次暂停100毫秒(0.1秒)。
|
||||
|
||||
```
|
||||
./redis-cli --bigkeys -i 0.1
|
||||
|
||||
```
|
||||
|
||||
当然,使用Redis自带的–bigkeys选项排查bigkey,有两个不足的地方:
|
||||
|
||||
1. 这个方法只能返回每种类型中最大的那个bigkey,无法得到大小排在前N位的bigkey;
|
||||
1. 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大。
|
||||
|
||||
所以,如果我们想统计每个数据类型中占用内存最多的前N个bigkey,可以自己开发一个程序,来进行统计。
|
||||
|
||||
我给你提供一个基本的开发思路:使用SCAN命令对数据库扫描,然后用TYPE命令获取返回的每一个key的类型。接下来,对于String类型,可以直接使用STRLEN命令获取字符串的长度,也就是占用的内存空间字节数。
|
||||
|
||||
对于集合类型来说,有两种方法可以获得它占用的内存大小。
|
||||
|
||||
如果你能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。
|
||||
|
||||
- List类型:LLEN命令;
|
||||
- Hash类型:HLEN命令;
|
||||
- Set类型:SCARD命令;
|
||||
- Sorted Set类型:ZCARD命令;
|
||||
|
||||
如果你不能提前知道写入集合的元素大小,可以使用MEMORY USAGE命令(需要Redis 4.0及以上版本),查询一个键值对占用的内存空间。例如,执行以下命令,可以获得key为user:info这个集合类型占用的内存空间大小。
|
||||
|
||||
```
|
||||
MEMORY USAGE user:info
|
||||
(integer) 315663239
|
||||
|
||||
```
|
||||
|
||||
这样一来,你就可以在开发的程序中,把每一种数据类型中的占用内存空间大小排在前 N 位的key统计出来,这也就是每个数据类型中的前N个bigkey。
|
||||
|
||||
## 总结
|
||||
|
||||
从第11讲到第21讲,我们重点介绍的知识点比较多,也比较细。其实,我们可以分成两大部分来掌握:一个是多种多样的数据结构,另一个是如何避免Redis性能变慢。
|
||||
|
||||
希望这节课的答疑,能帮助你更加深入地理解前面学过的内容。通过这节课,你应该也看到了,课后思考题是一种很好地梳理重点内容、拓展思路的方式,所以,在接下来的课程里,希望你能多留言聊一聊你的想法,这样可以进一步巩固你所学的知识。而且,还能在和其他同学的交流中,收获更多东西。好了,这节课就到这里,我们下节课见。
|
||||
168
极客时间专栏/Redis核心技术与实战/实践篇/23 | 旁路缓存:Redis是如何工作的?.md
Normal file
168
极客时间专栏/Redis核心技术与实战/实践篇/23 | 旁路缓存:Redis是如何工作的?.md
Normal file
@@ -0,0 +1,168 @@
|
||||
<audio id="audio" title="23 | 旁路缓存:Redis是如何工作的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/d2/df0e7ce5a5f742d1ec349e4e361ca8d2.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们知道,Redis提供了高性能的数据存取功能,所以广泛应用在缓存场景中,既能有效地提升业务应用的响应速度,还可以避免把高并发大压力的请求发送到数据库层。
|
||||
|
||||
但是,如果Redis做缓存时出现了问题,比如说缓存失效,那么,大量请求就会直接积压到数据库层,必然会给数据库带来巨大的压力,很可能会导致数据库宕机或是故障,那么,业务应用就没有办法存取数据、响应用户请求了。这种生产事故,肯定不是我们希望看到的。
|
||||
|
||||
正因为Redis用作缓存的普遍性以及它在业务应用中的重要作用,所以,我们需要系统地掌握缓存的一系列内容,包括工作原理、替换策略、异常处理和扩展机制。具体来说,我们需要解决四个关键问题:
|
||||
|
||||
- Redis缓存具体是怎么工作的?
|
||||
- Redis缓存如果满了,该怎么办?
|
||||
- 为什么会有缓存一致性、缓存穿透、缓存雪崩、缓存击穿等异常,该如何应对?
|
||||
- Redis的内存毕竟有限,如果用快速的固态硬盘来保存数据,可以增加缓存的数据量,那么,Redis缓存可以使用快速固态硬盘吗?
|
||||
|
||||
这节课,我们来了解下缓存的特征和Redis适用于缓存的天然优势,以及Redis缓存的具体工作机制。
|
||||
|
||||
## 缓存的特征
|
||||
|
||||
要想弄明白Redis为什么适合用作缓存,我们得清楚缓存都有什么特征。
|
||||
|
||||
首先,你要知道,一个系统中的不同层之间的访问速度不一样,所以我们才需要缓存,这样就可以把一些需要频繁访问的数据放在缓存中,以加快它们的访问速度。
|
||||
|
||||
为了让你能更好地理解,我以计算机系统为例,来解释一下。下图是计算机系统中的三层存储结构,以及它们各自的常用容量和访问性能。最上面是处理器,中间是内存,最下面是磁盘。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/9c/ac80f6e1714f3e1e8eabcfd8da3d689c.jpg" alt="">
|
||||
|
||||
从图上可以看到,CPU、内存和磁盘这三层的访问速度从几十ns到100ns,再到几ms,性能的差异很大。
|
||||
|
||||
想象一下,如果每次CPU处理数据时,都要从ms级别的慢速磁盘中读取数据,然后再进行处理,那么,CPU只能等磁盘的数据传输完成。这样一来,高速的CPU就被慢速的磁盘拖累了,整个计算机系统的运行速度会变得非常慢。
|
||||
|
||||
所以,计算机系统中,默认有两种缓存:
|
||||
|
||||
- CPU里面的末级缓存,即LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
|
||||
- 内存中的高速页缓存,即page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/44/7dyycf727f9396eb9788644474855a44.jpg" alt="">
|
||||
|
||||
跟内存相比,LLC的访问速度更快,而跟磁盘相比,内存的访问是更快的。所以,我们可以看出来缓存的**第一个特征**:在一个层次化的系统中,缓存一定是一个快速子系统,数据存在缓存中时,能避免每次从慢速子系统中存取数据。对应到互联网应用来说,Redis就是快速子系统,而数据库就是慢速子系统了。
|
||||
|
||||
知道了这一点,你就能理解,为什么我们必须想尽办法让Redis提供高性能的访问,因为,如果访问速度很慢,Redis作为缓存的价值就不大了。
|
||||
|
||||
我们再看一下刚才的计算机分层结构。LLC的大小是MB级别,page cache的大小是GB级别,而磁盘的大小是TB级别。这其实包含了缓存的**第二个特征:缓存系统的容量大小总是小于后端慢速系统的,我们不可能把所有数据都放在缓存系统中**。
|
||||
|
||||
这个很有意思,它表明,缓存的容量终究是有限的,缓存中的数据量也是有限的,肯定是没法时刻都满足访问需求的。所以,缓存和后端慢速系统之间,必然存在数据写回和再读取的交互过程。简单来说,缓存中的数据需要按一定规则淘汰出去,写回后端系统,而新的数据又要从后端系统中读取进来,写入缓存。
|
||||
|
||||
说到这儿,你肯定会想到,Redis本身是支持按一定规则淘汰数据的,相当于实现了缓存的数据淘汰,其实,这也是Redis适合用作缓存的一个重要原因。
|
||||
|
||||
好了,我们现在了解了缓存的两个重要特征,那么,接下来,我们就来学习下,缓存是怎么处理请求的。实际上,业务应用在访问Redis缓存中的数据时,数据不一定存在,因此,处理的方式也不同。
|
||||
|
||||
## Redis缓存处理请求的两种情况
|
||||
|
||||
把Redis用作缓存时,我们会把Redis部署在数据库的前端,业务应用在访问数据时,会先查询Redis中是否保存了相应的数据。此时,根据数据是否存在缓存中,会有两种情况。
|
||||
|
||||
- **缓存命中**:Redis中有相应数据,就直接读取Redis,性能非常快。
|
||||
- **缓存缺失**:Redis中没有保存相应数据,就从后端数据库中读取数据,性能就会变慢。而且,一旦发生缓存缺失,为了让后续请求能从缓存中读取到数据,我们需要把缺失的数据写入Redis,这个过程叫作缓存更新。缓存更新操作会涉及到保证缓存和数据库之间的数据一致性问题,关于这一点,我会在第25讲中再具体介绍。
|
||||
|
||||
我画了一张图,清晰地展示了发生缓存命中或缺失时,应用读取数据的情况,你可以看下这张图片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/3d/6b0b489ec0c1c5049c8df84d77fa243d.jpg" alt="">
|
||||
|
||||
假设我们在一个Web应用中,使用Redis作为缓存。用户请求发送给Tomcat,Tomcat负责处理业务逻辑。如果要访问数据,就需要从MySQL中读写数据。那么,我们可以把Redis部署在MySQL前端。如果访问的数据在Redis中,此时缓存命中,Tomcat可以直接从Redis中读取数据,加速应用的访问。否则,Tomcat就需要从慢速的数据库中读取数据了。
|
||||
|
||||
到这里,你可能已经发现了,使用Redis缓存时,我们基本有三个操作:
|
||||
|
||||
- 应用读取数据时,需要先读取Redis;
|
||||
- 发生缓存缺失时,需要从数据库读取数据;
|
||||
- 发生缓存缺失时,还需要更新缓存。
|
||||
|
||||
那么,这些操作具体是由谁来做的呢?这和Redis缓存的使用方式相关。接下来,我就来和你聊聊Redis作为旁路缓存的使用操作方式。
|
||||
|
||||
## Redis作为旁路缓存的使用操作
|
||||
|
||||
Redis是一个独立的系统软件,和业务应用程序是两个软件,当我们部署了Redis实例后,它只会被动地等待客户端发送请求,然后再进行处理。所以,如果应用程序想要使用Redis缓存,我们就要在程序中增加相应的缓存操作代码。所以,我们也把Redis称为旁路缓存,也就是说,读取缓存、读取数据库和更新缓存的操作都需要在应用程序中来完成。
|
||||
|
||||
这和我刚才讲的计算机系统中的LLC和page cache不一样。你可以回想下,平时在开发程序时,我们是没有专门在代码中显式地创建LLC或page cache的实例的,也没有显式调用过它们的GET接口。这是因为,我们在构建计算机硬件系统时,已经把LLC和page cache放在了应用程序的数据访问路径上,应用程序访问数据时直接就能用上缓存。
|
||||
|
||||
那么,使用Redis缓存时,具体来说,我们需要在应用程序中增加三方面的代码:
|
||||
|
||||
- 当应用程序需要读取数据时,我们需要在代码中显式调用Redis的GET操作接口,进行查询;
|
||||
- 如果缓存缺失了,应用程序需要再和数据库连接,从数据库中读取数据;
|
||||
- 当缓存中的数据需要更新时,我们也需要在应用程序中显式地调用SET操作接口,把更新的数据写入缓存。
|
||||
|
||||
那么,代码应该怎么加呢?我给你展示一段Web应用中使用Redis缓存的伪代码示例。
|
||||
|
||||
```
|
||||
String cacheKey = “productid_11010003”;
|
||||
String cacheValue = redisCache.get(cacheKey);
|
||||
//缓存命中
|
||||
if ( cacheValue != NULL)
|
||||
return cacheValue;
|
||||
//缓存缺失
|
||||
else
|
||||
cacheValue = getProductFromDB();
|
||||
redisCache.put(cacheValue) //缓存更新
|
||||
|
||||
```
|
||||
|
||||
可以看到,为了使用缓存,Web应用程序需要有一个表示缓存系统的实例对象redisCache,还需要主动调用Redis的GET接口,并且要处理缓存命中和缓存缺失时的逻辑,例如在缓存缺失时,需要更新缓存。
|
||||
|
||||
了解了这一点,我们在使用Redis缓存时,有一个地方就需要注意了:因为需要新增程序代码来使用缓存,所以,Redis并不适用于那些无法获得源码的应用,例如一些很早之前开发的应用程序,它们的源码已经没有再维护了,或者是第三方供应商开发的应用,没有提供源码,所以,我们就没有办法在这些应用中进行缓存操作。
|
||||
|
||||
在使用旁路缓存时,我们需要在应用程序中增加操作代码,增加了使用Redis缓存的额外工作量,但是,也正因为Redis是旁路缓存,是一个独立的系统,我们可以单独对Redis缓存进行扩容或性能优化。而且,只要保持操作接口不变,我们在应用程序中增加的代码就不用再修改了。
|
||||
|
||||
好了,到这里,我们知道了,通过在应用程序中加入Redis的操作代码,我们可以让应用程序使用Redis缓存数据了。不过,除了从Redis缓存中查询、读取数据以外,应用程序还可能会对数据进行修改,这时,我们既可以在缓存中修改,也可以在后端数据库中进行修改,我们该怎么选择呢?
|
||||
|
||||
其实,这就涉及到了Redis缓存的两种类型:只读缓存和读写缓存。只读缓存能加速读请求,而读写缓存可以同时加速读写请求。而且,读写缓存又有两种数据写回策略,可以让我们根据业务需求,在保证性能和保证数据可靠性之间进行选择。所以,接下来,我们来具体了解下Redis的缓存类型和相应的写回策略。
|
||||
|
||||
## 缓存的类型
|
||||
|
||||
按照Redis缓存是否接受写请求,我们可以把它分成只读缓存和读写缓存。先来了解下只读缓存。
|
||||
|
||||
### 只读缓存
|
||||
|
||||
当Redis用作只读缓存时,应用要读取数据的话,会先调用Redis GET接口,查询数据是否存在。而所有的数据写请求,会直接发往后端的数据库,在数据库中增删改。对于删改的数据来说,如果Redis已经缓存了相应的数据,应用需要把这些缓存的数据删除,Redis中就没有这些数据了。
|
||||
|
||||
当应用再次读取这些数据时,会发生缓存缺失,应用会把这些数据从数据库中读出来,并写到缓存中。这样一来,这些数据后续再被读取时,就可以直接从缓存中获取了,能起到加速访问的效果。
|
||||
|
||||
我给你举个例子。假设业务应用要修改数据A,此时,数据A在Redis中也缓存了,那么,应用会先直接在数据库里修改A,并把Redis中的A删除。等到应用需要读取数据A时,会发生缓存缺失,此时,应用从数据库中读取A,并写入Redis,以便后续请求从缓存中直接读取,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/cd/464ea24a098c87b9d292cf61a2b2fecd.jpg" alt="">
|
||||
|
||||
只读缓存直接在数据库中更新数据的好处是,所有最新的数据都在数据库中,而数据库是提供数据可靠性保障的,这些数据不会有丢失的风险。当我们需要缓存图片、短视频这些用户只读的数据时,就可以使用只读缓存这个类型了。
|
||||
|
||||
### 读写缓存
|
||||
|
||||
知道了只读缓存,读写缓存也就很容易理解了。
|
||||
|
||||
对于读写缓存来说,除了读请求会发送到缓存进行处理(直接在缓存中查询数据是否存在),所有的写请求也会发送到缓存,在缓存中直接对数据进行增删改操作。此时,得益于Redis的高性能访问特性,数据的增删改操作可以在缓存中快速完成,处理结果也会快速返回给业务应用,这就可以提升业务应用的响应速度。
|
||||
|
||||
但是,和只读缓存不一样的是,在使用读写缓存时,最新的数据是在Redis中,而Redis是内存数据库,一旦出现掉电或宕机,内存中的数据就会丢失。这也就是说,应用的最新数据可能会丢失,给应用业务带来风险。
|
||||
|
||||
所以,根据业务应用对数据可靠性和缓存性能的不同要求,我们会有同步直写和异步写回两种策略。其中,同步直写策略优先保证数据可靠性,而异步写回策略优先提供快速响应。学习了解这两种策略,可以帮助我们根据业务需求,做出正确的设计选择。
|
||||
|
||||
接下来,我们来具体看下这两种策略。
|
||||
|
||||
同步直写是指,写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都写完数据,才给客户端返回。这样,即使缓存宕机或发生故障,最新的数据仍然保存在数据库中,这就提供了数据可靠性保证。
|
||||
|
||||
不过,同步直写会降低缓存的访问性能。这是因为缓存中处理写请求的速度是很快的,而数据库处理写请求的速度较慢。即使缓存很快地处理了写请求,也需要等待数据库处理完所有的写请求,才能给应用返回结果,这就增加了缓存的响应延迟。
|
||||
|
||||
而异步写回策略,则是优先考虑了响应延迟。此时,所有写请求都先在缓存中处理。等到这些增改的数据要被从缓存中淘汰出来时,缓存将它们写回后端数据库。这样一来,处理这些数据的操作是在缓存中进行的,很快就能完成。只不过,如果发生了掉电,而它们还没有被写回数据库,就会有丢失的风险了。
|
||||
|
||||
为了便于你理解,我也画了下面这张图,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/66/009d055bb91d42c28b9316c649f87f66.jpg" alt="">
|
||||
|
||||
关于是选择只读缓存,还是读写缓存,主要看我们对写请求是否有加速的需求。
|
||||
|
||||
- 如果需要对写请求进行加速,我们选择读写缓存;
|
||||
- 如果写请求很少,或者是只需要提升读请求的响应速度的话,我们选择只读缓存。
|
||||
|
||||
举个例子,在商品大促的场景中,商品的库存信息会一直被修改。如果每次修改都需到数据库中处理,就会拖慢整个应用,此时,我们通常会选择读写缓存的模式。而在短视频App的场景中,虽然视频的属性有很多,但是,一般确定后,修改并不频繁,此时,在数据库中进行修改对缓存影响不大,所以只读缓存模式是一个合适的选择。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们学习了缓存的两个特征,分别是在分层系统中,数据暂存在快速子系统中有助于加速访问;缓存容量有限,缓存写满时,数据需要被淘汰。而Redis天然就具有高性能访问和数据淘汰机制,正好符合缓存的这两个特征的要求,所以非常适合用作缓存。
|
||||
|
||||
另外,我们还学习了Redis作为旁路缓存的特性,旁路缓存就意味着需要在应用程序中新增缓存逻辑处理的代码。当然,如果是无法修改源码的应用场景,就不能使用Redis做缓存了。
|
||||
|
||||
Redis做缓存时,还有两种模式,分别是只读缓存和读写缓存。其中,读写缓存还提供了同步直写和异步写回这两种模式,同步直写模式侧重于保证数据可靠性,而异步写回模式则侧重于提供低延迟访问,我们要根据实际的业务场景需求来进行选择。
|
||||
|
||||
这节课,虽然我提到了Redis有数据淘汰机制,但是并没有展开讲具体的淘汰策略。那么,Redis究竟是怎么淘汰数据的呢?我会在下节课给你具体介绍。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提一个小问题。这节课,我提到了Redis只读缓存和使用直写策略的读写缓存,这两种缓存都会把数据同步写到后端数据库中,你觉得,它们有什么区别吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
167
极客时间专栏/Redis核心技术与实战/实践篇/24 | 替换策略:缓存满了怎么办?.md
Normal file
167
极客时间专栏/Redis核心技术与实战/实践篇/24 | 替换策略:缓存满了怎么办?.md
Normal file
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="24 | 替换策略:缓存满了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/8e/a0da09fda3bea841a2871b47709fb18e.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
Redis缓存使用内存来保存数据,避免业务应用从后端数据库中读取数据,可以提升应用的响应速度。那么,如果我们把所有要访问的数据都放入缓存,是不是一个很好的设计选择呢?其实,这样做的性价比反而不高。
|
||||
|
||||
举个例子吧。MySQL中有1TB的数据,如果我们使用Redis把这1TB的数据都缓存起来,虽然应用都能在内存中访问数据了,但是,这样配置并不合理,因为性价比很低。一方面,1TB内存的价格大约是3.5万元,而1TB磁盘的价格大约是1000元。另一方面,数据访问都是有局部性的,也就是我们通常所说的“八二原理”,80%的请求实际只访问了20%的数据。所以,用1TB的内存做缓存,并没有必要。
|
||||
|
||||
为了保证较高的性价比,缓存的空间容量必然要小于后端数据库的数据总量。不过,内存大小毕竟有限,随着要缓存的数据量越来越大,有限的缓存空间不可避免地会被写满。此时,该怎么办呢?
|
||||
|
||||
解决这个问题就涉及到缓存系统的一个重要机制,即**缓存数据的淘汰机制**。简单来说,数据淘汰机制包括两步:第一,根据一定的策略,筛选出对应用访问来说“不重要”的数据;第二,将这些数据从缓存中删除,为新来的数据腾出空间,
|
||||
|
||||
这节课上,我就来和你聊聊缓存满了之后的数据淘汰机制。通常,我们也把它叫作缓存替换机制,同时还会讲到一系列选择淘汰数据的具体策略。了解了数据淘汰机制和相应策略,我们才可以选择合理的Redis配置,提高缓存命中率,提升应用的访问性能。
|
||||
|
||||
不过,在学习淘汰策略之前,我们首先要知道设置缓存容量的依据和方法。毕竟,在实际使用缓存时,我们需要决定用多大的空间来缓存数据。
|
||||
|
||||
## 设置多大的缓存容量合适?
|
||||
|
||||
缓存容量设置得是否合理,会直接影响到使用缓存的性价比。我们通常希望以最小的代价去获得最大的收益,所以,把昂贵的内存资源用在关键地方就非常重要了。
|
||||
|
||||
就像我刚才说的,实际应用中的数据访问是具有局部性的。下面有一张图,图里有红、蓝两条线,显示了不同比例数据贡献的访问量情况。蓝线代表了“八二原理”表示的数据局部性,而红线则表示在当前应用负载下,数据局部性的变化。
|
||||
|
||||
我们先看看蓝线。它表示的就是“八二原理”,有20%的数据贡献了80%的访问了,而剩余的数据虽然体量很大,但只贡献了20%的访问量。这80%的数据在访问量上就形成了一条长长的尾巴,我们也称为“长尾效应”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/e4/986ed247a4353524f387f0bbf76586e4.jpg" alt="">
|
||||
|
||||
所以,如果按照“八二原理”来设置缓存空间容量,也就是把缓存空间容量设置为总数据量的20%的话,就有可能拦截到80%的访问。
|
||||
|
||||
为什么说是“有可能”呢?这是因为,“八二原理”是对大量实际应用的数据访问情况做了统计后,得出的一个统计学意义上的数据量和访问量的比例。具体到某一个应用来说,数据访问的规律会和具体的业务场景有关。对于最常被访问的20%的数据来说,它们贡献的访问量,既有可能超过80%,也有可能不到80%。
|
||||
|
||||
我们再通过一个电商商品的场景,来说明下“有可能”这件事儿。一方面,在商品促销时,热门商品的信息可能只占到总商品数据信息量的5%,而这些商品信息承载的可能是超过90%的访问请求。这时,我们只要缓存这5%的数据,就能获得很好的性能收益。另一方面,如果业务应用要对所有商品信息进行查询统计,这时候,即使按照“八二原理”缓存了20%的商品数据,也不能获得很好的访问性能,因为80%的数据仍然需要从后端数据库中获取。
|
||||
|
||||
接下来,我们再看看数据访问局部性示意图中的红线。近年来,有些研究人员专门对互联网应用(例如视频播放网站)中,用户请求访问内容的分布情况做过分析,得到了这张图中的红线。
|
||||
|
||||
在这条红线上,80%的数据贡献的访问量,超过了传统的长尾效应中80%数据能贡献的访问量。原因在于,用户的个性化需求越来越多,在一个业务应用中,不同用户访问的内容可能差别很大,所以,用户请求的数据和它们贡献的访问量比例,不再具备长尾效应中的“八二原理”分布特征了。也就是说,20%的数据可能贡献不了80%的访问,而剩余的80%数据反而贡献了更多的访问量,我们称之为重尾效应。
|
||||
|
||||
正是因为20%的数据不一定能贡献80%的访问量,我们不能简单地按照“总数据量的20%”来设置缓存最大空间容量。在实践过程中,我看到过的缓存容量占总数据量的比例,从5%到40%的都有。这个容量规划不能一概而论,是需要结合**应用数据实际访问特征**和**成本开销**来综合考虑的。
|
||||
|
||||
这其实也是我一直在和你分享的经验,系统的设计选择是一个权衡的过程:大容量缓存是能带来性能加速的收益,但是成本也会更高,而小容量缓存不一定就起不到加速访问的效果。一般来说,**我会建议把缓存容量设置为总数据量的15%到30%,兼顾访问性能和内存空间开销**。
|
||||
|
||||
对于Redis来说,一旦确定了缓存最大容量,比如4GB,你就可以使用下面这个命令来设定缓存的大小了:
|
||||
|
||||
```
|
||||
CONFIG SET maxmemory 4gb
|
||||
|
||||
```
|
||||
|
||||
不过,**缓存被写满是不可避免的**。即使你精挑细选,确定了缓存容量,还是要面对缓存写满时的替换操作。缓存替换需要解决两个问题:决定淘汰哪些数据,如何处理那些被淘汰的数据。
|
||||
|
||||
接下来,我们就来学习下,Redis中的数据淘汰策略。
|
||||
|
||||
## Redis缓存有哪些淘汰策略?
|
||||
|
||||
Redis 4.0之前一共实现了6种内存淘汰策略,在4.0之后,又增加了2种策略。我们可以按照是否会进行数据淘汰把它们分成两类:
|
||||
|
||||
- 不进行数据淘汰的策略,只有noeviction这一种。
|
||||
- 会进行淘汰的7种其他策略。
|
||||
|
||||
会进行淘汰的7种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:
|
||||
|
||||
- 在设置了过期时间的数据中进行淘汰,包括volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0后新增)四种。
|
||||
- 在所有数据范围内进行淘汰,包括allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0后新增)三种。
|
||||
|
||||
我把这8种策略的分类,画到了一张图里:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/f6/04bdd13b760016ec3b30f4b02e133df6.jpg" alt="">
|
||||
|
||||
下面我就来具体解释下各个策略。
|
||||
|
||||
默认情况下,Redis在使用的内存空间超过maxmemory值时,并不会淘汰数据,也就是设定的**noeviction策略**。对应到Redis缓存,也就是指,一旦缓存被写满了,再有写请求来时,Redis不再提供服务,而是直接返回错误。Redis用作缓存时,实际的数据集通常都是大于缓存容量的,总会有新的数据要写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,我们不把它用在Redis缓存中。
|
||||
|
||||
我们再分析下volatile-random、volatile-ttl、volatile-lru和volatile-lfu这四种淘汰策略。它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。
|
||||
|
||||
例如,我们使用EXPIRE命令对一批键值对设置了过期时间后,无论是这些键值对的过期时间是快到了,还是Redis的内存使用量达到了maxmemory阈值,Redis都会进一步按照volatile-ttl、volatile-random、volatile-lru、volatile-lfu这四种策略的具体筛选规则进行淘汰。
|
||||
|
||||
- volatile-ttl在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
|
||||
- volatile-random就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
|
||||
- volatile-lru会使用LRU算法筛选设置了过期时间的键值对。
|
||||
- volatile-lfu会使用LFU算法选择设置了过期时间的键值对。
|
||||
|
||||
可以看到,volatile-ttl和volatile-random筛选规则比较简单,而volatile-lru因为涉及了LRU算法,所以我会在分析allkeys-lru策略时再详细解释。volatile-lfu使用了LFU算法,我会在第27讲中具体解释,现在你只需要知道,它是在LRU算法的基础上,同时考虑了数据的访问时效性和数据的访问次数,可以看作是对淘汰策略的优化。
|
||||
|
||||
相对于volatile-ttl、volatile-random、volatile-lru、volatile-lfu这四种策略淘汰的是设置了过期时间的数据,allkeys-lru、allkeys-random、allkeys-lfu这三种淘汰策略的备选淘汰数据范围,就扩大到了所有键值对,无论这些键值对是否设置了过期时间。它们筛选数据进行淘汰的规则是:
|
||||
|
||||
- allkeys-random策略,从所有键值对中随机选择并删除数据;
|
||||
- allkeys-lru策略,使用LRU算法在所有数据中进行筛选。
|
||||
- allkeys-lfu策略,使用LFU算法在所有数据中进行筛选。
|
||||
|
||||
这也就是说,如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。当然,如果它的过期时间到了但未被策略选中,同样也会被删除。
|
||||
|
||||
接下来,我们就看看volatile-lru和allkeys-lru策略都用到的LRU算法吧。LRU算法工作机制并不复杂,我们一起学习下。
|
||||
|
||||
LRU算法的全称是Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。
|
||||
|
||||
那具体是怎么筛选的呢?LRU会把所有的数据组织成一个链表,链表的头和尾分别表示MRU端和LRU端,分别代表最近最常使用的数据和最近最不常用的数据。我们看一个例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/y5/0201f85c84203300ae4085c60e955yy5.jpg" alt="">
|
||||
|
||||
我们现在有数据6、3、9、20、5。如果数据20和3被先后访问,它们都会从现有的链表位置移到MRU端,而链表中在它们之前的数据则相应地往后移一位。因为,LRU算法选择删除数据时,都是从LRU端开始,所以把刚刚被访问的数据移到MRU端,就可以让它们尽可能地留在缓存中。
|
||||
|
||||
如果有一个新数据15要被写入缓存,但此时已经没有缓存空间了,也就是链表没有空余位置了,那么,LRU算法做两件事:
|
||||
|
||||
1. 数据15是刚被访问的,所以它会被放到MRU端;
|
||||
1. 算法把LRU端的数据5从缓存中删除,相应的链表中就没有数据5的记录了。
|
||||
|
||||
其实,LRU算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在MRU端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到LRU端,在缓存满时,就优先删除它。
|
||||
|
||||
不过,LRU算法在实际实现时,需要用链表管理所有的缓存数据,这会**带来额外的空间开销**。而且,当有数据被访问时,需要在链表上把该数据移动到MRU端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低Redis缓存性能。
|
||||
|
||||
所以,在Redis中,LRU算法被做了简化,以减轻数据淘汰对缓存性能的影响。具体来说,Redis默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构RedisObject中的lru字段记录)。然后,Redis在决定淘汰的数据时,第一次会随机选出N个数据,把它们作为一个候选集合。接下来,Redis会比较这N个数据的lru字段,把lru字段值最小的数据从缓存中淘汰出去。
|
||||
|
||||
Redis提供了一个配置参数maxmemory-samples,这个参数就是Redis选出的数据个数N。例如,我们执行如下命令,可以让Redis选出100个数据作为候选数据集:
|
||||
|
||||
```
|
||||
CONFIG SET maxmemory-samples 100
|
||||
|
||||
```
|
||||
|
||||
当需要再次淘汰数据时,Redis需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:**能进入候选集合的数据的lru字段值必须小于候选集合中最小的lru值**。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了maxmemory-samples,Redis就把候选数据集中lru字段值最小的数据淘汰出去。
|
||||
|
||||
这样一来,Redis缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。
|
||||
|
||||
好了,到这里,我们就学完了除了使用LFU算法以外的5种缓存淘汰策略,我再给你三个使用建议。
|
||||
|
||||
- **优先使用allkeys-lru策略**。这样,可以充分利用LRU这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果你的业务数据中有明显的冷热数据区分,我建议你使用allkeys-lru策略。
|
||||
- 如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用allkeys-random策略,随机选择淘汰的数据就行。
|
||||
- **如果你的业务中有置顶的需求**,比如置顶新闻、置顶视频,那么,可以使用volatile-lru策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据LRU规则进行筛选。
|
||||
|
||||
一旦被淘汰的数据被选定后,Redis怎么处理这些数据呢?这就要说到缓存替换时的具体操作了。
|
||||
|
||||
## 如何处理被淘汰的数据?
|
||||
|
||||
一般来说,一旦被淘汰的数据选定后,如果这个数据是干净数据,那么我们就直接删除;如果这个数据是脏数据,我们需要把它写回数据库,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/5e/953e48912yy9515abf9db588d447cc5e.jpg" alt="">
|
||||
|
||||
那怎么判断一个数据到底是干净的还是脏的呢?
|
||||
|
||||
干净数据和脏数据的区别就在于,和最初从后端数据库里读取时的值相比,有没有被修改过。干净数据一直没有被修改,所以后端数据库里的数据也是最新值。在替换时,它可以被直接删除。
|
||||
|
||||
而脏数据就是曾经被修改过的,已经和后端数据库中保存的数据不一致了。此时,如果不把脏数据写回到数据库中,这个数据的最新值就丢失了,就会影响应用的正常使用。
|
||||
|
||||
这么一来,缓存替换既腾出了缓存空间,用来缓存新的数据,同时,将脏数据写回数据库,也保证了最新数据不会丢失。
|
||||
|
||||
不过,对于Redis来说,它决定了被淘汰的数据后,会把它们删除。即使淘汰的数据是脏数据,Redis也不会把它们写回数据库。所以,我们在使用Redis缓存时,如果数据被修改了,需要在数据修改时就将它写回数据库。否则,这个脏数据被淘汰时,会被Redis删除,而数据库里也没有最新的数据了。
|
||||
|
||||
## 小结
|
||||
|
||||
在这节课上,我围绕着“缓存满了该怎么办”这一问题,向你介绍了缓存替换时的数据淘汰策略,以及被淘汰数据的处理方法。
|
||||
|
||||
Redis 4.0版本以后一共提供了8种数据淘汰策略,从淘汰数据的候选集范围来看,我们有两种候选范围:一种是所有数据都是候选集,一种是设置了过期时间的数据是候选集。另外,无论是面向哪种候选数据集进行淘汰数据选择,我们都有三种策略,分别是随机选择,根据LRU算法选择,以及根据LFU算法选择。当然,当面向设置了过期时间的数据集选择淘汰数据时,我们还可以根据数据离过期时间的远近来决定。
|
||||
|
||||
一般来说,缓存系统对于选定的被淘汰数据,会根据其是干净数据还是脏数据,选择直接删除还是写回数据库。但是,在Redis中,被淘汰数据无论干净与否都会被删除,所以,这是我们在使用Redis缓存时要特别注意的:当数据修改成为脏数据时,需要在数据库中也把数据修改过来。
|
||||
|
||||
选择哪种缓存策略是值得我们多加琢磨的,它在筛选数据方面是否能筛选出可能被再次访问的数据,直接决定了缓存效率的高与低。
|
||||
|
||||
很简单的一个对比,如果我们使用随机策略,刚筛选出来的要被删除的数据可能正好又被访问了,此时应用就只能花费几毫秒从数据库中读取数据了。而如果使用LRU策略,被筛选出来的数据往往是经过时间验证了,如果在一段时间内一直没有访问,本身被再次访问的概率也很低了。
|
||||
|
||||
所以,我给你的建议是,先根据是否有始终会被频繁访问的数据(例如置顶消息),来选择淘汰数据的候选集,也就是决定是针对所有数据进行淘汰,还是针对设置了过期时间的数据进行淘汰。候选数据集范围选定后,建议优先使用LRU算法,也就是,allkeys-lru或volatile-lru策略。
|
||||
|
||||
当然,设置缓存容量的大小也很重要,我的建议是:结合实际应用的数据总量、热数据的体量,以及成本预算,把缓存空间大小设置在总数据量的15%到30%这个区间就可以。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提一个小问题。这节课,我向你介绍了Redis缓存在应对脏数据时,需要在数据修改的同时,也把它写回数据库,针对我们上节课介绍的缓存读写模式:只读缓存,以及读写缓存中的两种写回策略,请你思考下,Redis缓存对应哪一种或哪几种模式?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或/同事。我们下节课见。
|
||||
152
极客时间专栏/Redis核心技术与实战/实践篇/25 | 缓存异常(上):如何解决缓存和数据库的数据不一致问题?.md
Normal file
152
极客时间专栏/Redis核心技术与实战/实践篇/25 | 缓存异常(上):如何解决缓存和数据库的数据不一致问题?.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="25 | 缓存异常(上):如何解决缓存和数据库的数据不一致问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/d5/7d6ef8d85c4ec58e95db460d685a45d5.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在实际应用Redis缓存时,我们经常会遇到一些异常问题,概括来说有4个方面:缓存中的数据和数据库中的不一致;缓存雪崩;缓存击穿和缓存穿透。
|
||||
|
||||
只要我们使用Redis缓存,就必然会面对缓存和数据库间的一致性保证问题,这也算是Redis缓存应用中的“必答题”了。最重要的是,如果数据不一致,那么业务应用从缓存中读取的数据就不是最新数据,这会导致严重的错误。比如说,我们把电商商品的库存信息缓存在Redis中,如果库存信息不对,那么业务层下单操作就可能出错,这当然是不能接受的。所以,这节课我就重点和你聊聊这个问题。关于缓存雪崩、穿透和击穿等问题,我会在下一节课向你介绍。
|
||||
|
||||
接下来,我们就来看看,缓存和数据库之间的数据不一致是怎么引起的。
|
||||
|
||||
## 缓存和数据库的数据不一致是如何发生的?
|
||||
|
||||
首先,我们得清楚“数据的一致性”具体是啥意思。其实,这里的“一致性”包含了两种情况:
|
||||
|
||||
- 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
|
||||
- 缓存中本身没有数据,那么,数据库中的值必须是最新值。
|
||||
|
||||
不符合这两种情况的,就属于缓存和数据库的数据不一致问题了。不过,当缓存的读写模式不同时,缓存数据不一致的发生情况不一样,我们的应对方法也会有所不同,所以,我们先按照缓存读写模式,来分别了解下不同模式下的缓存不一致情况。我在[第23讲](https://time.geekbang.org/column/article/293929)中讲过,根据是否接收写请求,我们可以把缓存分成读写缓存和只读缓存。
|
||||
|
||||
对于读写缓存来说,如果要对数据进行增删改,就需要在缓存中进行,同时还要根据采取的写回策略,决定是否同步写回到数据库中。
|
||||
|
||||
- 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;
|
||||
- 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。
|
||||
|
||||
所以,对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。不过,需要注意的是,如果采用这种策略,就需要同时更新缓存和数据库。所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,我们就无法实现同步直写。
|
||||
|
||||
当然,在有些场景下,我们对数据一致性的要求可能不是那么高,比如说缓存的是电商商品的非关键属性或者短视频的创建或修改时间等,那么,我们可以使用异步写回策略。
|
||||
|
||||
下面我们再来说说只读缓存。对于只读缓存来说,如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。
|
||||
|
||||
接下来,我以Tomcat向MySQL中写入和删改数据为例,来给你解释一下,数据的增删改操作具体是如何进行的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/dc/15ae0147459ecc46436f35a0f3e5yydc.jpg" alt="">
|
||||
|
||||
从图中可以看到,Tomcat上运行的应用,无论是新增(Insert操作)、修改(Update操作)、还是删除(Delete操作)数据X,都会直接在数据库中增改删。当然,如果应用执行的是修改或删除操作,还会删除缓存的数据X。
|
||||
|
||||
那么,这个过程中会不会出现数据不一致的情况呢?考虑到新增数据和删改数据的情况不一样,所以我们分开来看。
|
||||
|
||||
**1.新增数据**
|
||||
|
||||
如果是新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时,缓存中本身就没有新增数据,而数据库中是最新值,这种情况符合我们刚刚所说的一致性的第2种情况,所以,此时,缓存和数据库的数据是一致的。
|
||||
|
||||
**2.删改数据**
|
||||
|
||||
如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,也就是说,要不都完成,要不都没完成,此时,就会出现数据不一致问题了。这个问题比较复杂,我们来分析一下。
|
||||
|
||||
我们假设应用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。
|
||||
|
||||
我来举个例子说明一下,可以先看看下面的图片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/ae/b305a6355c9da145e4d1f86d23f4f0ae.jpg" alt="">
|
||||
|
||||
应用要把数据X的值从10更新为3,先在Redis缓存中删除了X的缓存值,但是更新数据库却失败了。如果此时有其他并发的请求访问X,会发现Redis中缓存缺失,紧接着,请求就会访问数据库,读到的却是旧值10。
|
||||
|
||||
你可能会问,如果我们先更新数据库,再删除缓存中的值,是不是就可以解决这个问题呢?我们再来分析下。
|
||||
|
||||
如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。
|
||||
|
||||
我还是借助一个例子来说明一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/77/767b4b2b1bafffd9a4b6368f05930a77.jpg" alt="">
|
||||
|
||||
应用要把数据X的值从10更新为3,先成功更新了数据库,然后在Redis缓存中删除X的缓存,但是这个操作却失败了,这个时候,数据库中X的新值为3,Redis中的X的缓存值为10,这肯定是不一致的。如果刚好此时有其他客户端也发送请求访问X,会先在Redis中查询,该客户端会发现缓存命中,但是读到的却是旧值10。
|
||||
|
||||
好了,到这里,我们可以看到,在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值。我画了下面这张表,总结了刚刚所说的这两种情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/ac/2c376b536aff9d14d8606499f401cdac.jpg" alt="">
|
||||
|
||||
问题发生的原因我们知道了,那该怎么解决呢?
|
||||
|
||||
## 如何解决数据不一致问题?
|
||||
|
||||
首先,我给你介绍一种方法:重试机制。
|
||||
|
||||
具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
|
||||
|
||||
如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
|
||||
|
||||
下图显示了先更新数据库,再删除缓存值时,如果缓存删除失败,再次重试后删除成功的情况,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/ab/74a66b9ce185d7c5b53986fc522dfcab.jpg" alt="">
|
||||
|
||||
刚刚说的是在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作第一次执行时都没有失败,当有大量并发请求时,应用还是有可能读到不一致的数据。
|
||||
|
||||
同样,我们按照不同的删除和更新顺序,分成两种情况来看。在这两种情况下,我们的解决方法也有所不同。
|
||||
|
||||
**情况一:先删除缓存,再更新数据库。**
|
||||
|
||||
假设线程A删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程B就开始读取数据了,那么这个时候,线程B会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
|
||||
|
||||
1. 线程B读取到了旧值;
|
||||
1. 线程B是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。
|
||||
|
||||
等到线程B从数据库读取完数据、更新了缓存后,线程A才开始更新数据库,此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。
|
||||
|
||||
我用一张表来汇总下这种情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/12/857c2b5449d9a04de6fe93yy1e355c12.jpg" alt="">
|
||||
|
||||
这该怎么办呢?我来给你提供一种解决方案。
|
||||
|
||||
**在线程A更新完数据库值以后,我们可以让它先sleep一小段时间,再进行一次缓存删除操作。**
|
||||
|
||||
之所以要加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程A再进行删除。所以,线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。这个时间怎么确定呢?建议你在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进行估算。
|
||||
|
||||
这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。
|
||||
|
||||
下面的这段伪代码就是“延迟双删”方案的示例,你可以看下。
|
||||
|
||||
```
|
||||
redis.delKey(X)
|
||||
db.update(X)
|
||||
Thread.sleep(N)
|
||||
redis.delKey(X)
|
||||
|
||||
```
|
||||
|
||||
**情况二:先更新数据库值,再删除缓存值。**
|
||||
|
||||
如果线程A删除了数据库中的值,但还没来得及删除缓存值,线程B就开始读取数据了,那么此时,线程B查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程A一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
|
||||
|
||||
我再画一张表,带你总结下先更新数据库、再删除缓存值的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/0b/a1c66ee114yyc9f37f2a35f21b46010b.jpg" alt="">
|
||||
|
||||
好了,到这里,我们了解到了,缓存和数据库的数据不一致一般是由两个原因导致的,我给你提供了相应的解决方案。
|
||||
|
||||
- 删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
|
||||
- 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。
|
||||
|
||||
## 小结
|
||||
|
||||
在这节课,我们学习了在使用Redis缓存时,最常遇见的一个问题,也就是缓存和数据库不一致的问题。针对这个问题,我们可以分成读写缓存和只读缓存两种情况进行分析。
|
||||
|
||||
对于读写缓存来说,如果我们采用同步写回策略,那么可以保证缓存和数据库中的数据一致。只读缓存的情况比较复杂,我总结了一张表,以便于你更加清晰地了解数据不一致的问题原因、现象和应对方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/6f/11ae5e620c63de76448bc658fe6a496f.jpg" alt="">
|
||||
|
||||
希望你能把我总结的这张表格放入到你的学习笔记中,时不时复习一下。
|
||||
|
||||
最后,我还想再多说几句。在大多数业务场景下,我们会把Redis作为只读缓存使用。针对只读缓存来说,我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。我的建议是,优先使用先更新数据库再删除缓存的方法,原因主要有两个:
|
||||
|
||||
1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
|
||||
1. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
|
||||
|
||||
不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,如果业务层要求必须读取一致的数据,那么,我们就需要在更新数据库时,先在Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。这节课,我提到,在只读缓存中进行数据的删改操作时,需要在缓存中删除相应的缓存值。我想请你思考一下,如果在这个过程中,我们不是删除缓存值,而是直接更新缓存的值,你觉得和删除缓存值相比,有什么好处和不足吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
141
极客时间专栏/Redis核心技术与实战/实践篇/26 | 缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?.md
Normal file
141
极客时间专栏/Redis核心技术与实战/实践篇/26 | 缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="26 | 缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/49/1ce12daae07f20b3e09de04de660a749.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
上节课,我们学习了缓存和数据库的数据不一致问题和应对方法。除了数据不一致问题,我们常常还会面临缓存异常的三个问题,分别是缓存雪崩、缓存击穿和缓存穿透。这三个问题一旦发生,会导致大量的请求积压到数据库层。如果请求的并发量很大,就会导致数据库宕机或是故障,这就是很严重的生产事故了。
|
||||
|
||||
这节课,我就来和你聊聊这三个问题的表现、诱发原因以及解决方法。俗话说,知己知彼,百战不殆。了解了问题的成因,我们就能够在应用Redis缓存时,进行合理的缓存设置,以及相应的业务应用前端设置,提前做好准备。
|
||||
|
||||
接下来,我们就先看下缓存雪崩的问题和应对方案。
|
||||
|
||||
## 缓存雪崩
|
||||
|
||||
缓存雪崩是指大量的应用请求无法在Redis缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。
|
||||
|
||||
缓存雪崩一般是由两个原因导致的,应对方案也有所不同,我们一个个来看。
|
||||
|
||||
第一个原因是:缓存中有大量数据同时过期,导致大量请求无法得到处理。
|
||||
|
||||
具体来说,当数据保存在缓存中,并且设置了过期时间时,如果在某一个时刻,大量数据同时过期,此时,应用再访问这些数据的话,就会发生缓存缺失。紧接着,应用就会把请求发送给数据库,从数据库中读取数据。如果应用的并发请求量很大,那么数据库的压力也就很大,这会进一步影响到数据库的其他正常业务请求处理。我们来看一个简单的例子,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/2e/74bb1aa4b2213e3ff29e2ee701e8f72e.jpg" alt="">
|
||||
|
||||
针对大量数据同时失效带来的缓存雪崩问题,我给你提供两种解决方案。
|
||||
|
||||
首先,我们可以避免给大量的数据设置相同的过期时间。如果业务层的确要求有些数据同时失效,你可以在用EXPIRE命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加1~3分钟),这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。
|
||||
|
||||
除了微调过期时间,我们还可以通过服务降级,来应对缓存雪崩。
|
||||
|
||||
所谓的服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式。
|
||||
|
||||
- 当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
|
||||
- 当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
|
||||
|
||||
这样一来,只有部分过期数据的请求会发送到数据库,数据库的压力就没有那么大了。下面这张图显示的是服务降级时数据请求的执行情况,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/a8/4ab3be5ba24cf172879e6b2cff649ca8.jpg" alt="">
|
||||
|
||||
**除了大量数据同时失效会导致缓存雪崩,还有一种情况也会发生缓存雪崩,那就是,Redis缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。**
|
||||
|
||||
一般来说,一个Redis实例可以支持数万级别的请求处理吞吐量,而单个数据库可能只能支持数千级别的请求处理吞吐量,它们两个的处理能力可能相差了近十倍。由于缓存雪崩,Redis缓存失效,所以,数据库就可能要承受近十倍的请求压力,从而因为压力过大而崩溃。
|
||||
|
||||
此时,因为Redis实例发生了宕机,我们需要通过其他方法来应对缓存雪崩了。我给你提供两个建议。
|
||||
|
||||
**第一个建议,是在业务系统中实现服务熔断或请求限流机制。**
|
||||
|
||||
所谓的服务熔断,是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问。再具体点说,就是业务应用调用缓存接口时,缓存客户端并不把请求发给Redis缓存实例,而是直接返回,等到Redis缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。
|
||||
|
||||
这样一来,我们就避免了大量请求因缓存缺失,而积压到数据库系统,保证了数据库系统的正常运行。
|
||||
|
||||
在业务系统运行时,我们可以监测Redis缓存所在机器和数据库所在机器的负载指标,例如每秒请求数、CPU利用率、内存利用率等。如果我们发现Redis缓存实例宕机了,而数据库所在机器的负载压力突然增加(例如每秒请求数激增),此时,就发生缓存雪崩了。大量请求被发送到数据库进行处理。我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,从而降低对数据库的访问压力,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/b5/17d39f6233c3332161c588b42eccaeb5.jpg" alt="">
|
||||
|
||||
服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围大。为了尽可能减少这种影响,我们也可以进行请求限流。这里说的请求限流,就是指,我们在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请求被发送到数据库。
|
||||
|
||||
我给你举个例子。假设业务系统正常运行时,请求入口前端允许每秒进入系统的请求是1万个,其中,9000个请求都能在缓存系统中进行处理,只有1000个请求会被应用发送到数据库进行处理。
|
||||
|
||||
一旦发生了缓存雪崩,数据库的每秒请求数突然增加到每秒1万个,此时,我们就可以启动请求限流机制,在请求入口前端只允许每秒进入系统的请求数为1000个,再多的请求就会在入口前端被直接拒绝服务。所以,使用了请求限流,就可以避免大量并发请求压力传递到数据库层。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/54/d5a0928e1d97cae2f4a4fb5b93e5c854.jpg" alt="">
|
||||
|
||||
使用服务熔断或是请求限流机制,来应对Redis实例宕机导致的缓存雪崩问题,是属于“事后诸葛亮”,也就是已经发生缓存雪崩了,我们使用这两个机制,来降低雪崩对数据库和整个业务系统的影响。
|
||||
|
||||
**我给你的第二个建议就是事前预防。**
|
||||
|
||||
通过主从节点的方式构建Redis缓存高可靠集群。如果Redis缓存的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。
|
||||
|
||||
缓存雪崩是发生在大量数据同时失效的场景下,而接下来我要向你介绍的缓存击穿,是发生在某个热点数据失效的场景下。和缓存雪崩相比,缓存击穿失效的数据数量要小很多,应对方法也不一样,我们来看下。
|
||||
|
||||
## 缓存击穿
|
||||
|
||||
缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。缓存击穿的情况,经常发生在热点数据过期失效时,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d4/4b/d4c77da4yy7d6e34aca460642923ab4b.jpg" alt="">
|
||||
|
||||
为了避免缓存击穿给数据库带来的激增压力,我们的解决方法也比较直接,对于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而Redis数万级别的高吞吐量可以很好地应对大量的并发请求访问。
|
||||
|
||||
好了,到这里,你了解了缓存雪崩和缓存击穿问题,以及它们的应对方案。当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据。接下来,我向你介绍的缓存穿透问题,和雪崩、击穿问题不一样,缓存穿透发生时,数据也不在数据库中,这会同时给缓存和数据库带来访问压力,那该怎么办呢?我们来具体看下。
|
||||
|
||||
## 缓存穿透
|
||||
|
||||
缓存穿透是指要访问的数据既不在Redis缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求,这样一来,缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/2e/46c49dd155665579c5204a66da8ffc2e.jpg" alt="">
|
||||
|
||||
那么,缓存穿透会发生在什么时候呢?一般来说,有两种情况。
|
||||
|
||||
- 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
|
||||
- 恶意攻击:专门访问数据库中没有的数据。
|
||||
|
||||
为了避免缓存穿透的影响,我来给你提供三种应对方案。
|
||||
|
||||
**第一种方案是,缓存空值或缺省值。**
|
||||
|
||||
一旦发生缓存穿透,我们就可以针对查询的数据,在Redis中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为0)。紧接着,应用发送的后续请求再进行查询时,就可以直接从Redis中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。
|
||||
|
||||
**第二种方案是,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。**
|
||||
|
||||
我们先来看下,布隆过滤器是如何工作的。
|
||||
|
||||
布隆过滤器由一个初值都为0的bit数组和N个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
|
||||
|
||||
- 首先,使用N个哈希函数,分别计算这个数据的哈希值,得到N个哈希值。
|
||||
- 然后,我们把这N个哈希值对bit数组的长度取模,得到每个哈希值在数组中的对应位置。
|
||||
- 最后,我们把对应位置的bit位设置为1,这就完成了在布隆过滤器中标记数据的操作。
|
||||
|
||||
如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit数组对应bit位的值仍然为0。
|
||||
|
||||
当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在bit数组中对应的N个位置。紧接着,我们查看bit数组中这N个位置上的bit值。只要这N个bit值有一个不为1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。为了便于你理解,我画了一张图,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/68/98f7d32499e4386b40aebc3622aa7268.jpg" alt="">
|
||||
|
||||
图中布隆过滤器是一个包含10个bit位的数组,使用了3个哈希函数,当在布隆过滤器中标记数据X时,X会被计算3次哈希值,并对10取模,取模结果分别是1、3、7。所以,bit数组的第1、3、7位被设置为1。当应用想要查询X时,只要查看数组的第1、3、7位是否为1,只要有一个为0,那么,X就肯定不在数据库中。
|
||||
|
||||
正是基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样一来,即使发生缓存穿透了,大量请求只会查询Redis和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用Redis实现,本身就能承担较大的并发访问压力。
|
||||
|
||||
最后一种方案是,在请求入口的**前端进行请求检测。**缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以,一个有效的应对方案是在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。
|
||||
|
||||
跟缓存雪崩、缓存击穿这两类问题相比,缓存穿透的影响更大一些,希望你能重点关注一下。从预防的角度来说,我们需要避免误删除数据库和缓存中的数据;从应对角度来说,我们可以在业务系统中使用缓存空值或缺省值、使用布隆过滤器,以及进行恶意请求检测等方法。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了缓存雪崩、击穿和穿透这三类异常问题。从问题成因来看,缓存雪崩和击穿主要是因为数据不在缓存中了,而缓存穿透则是因为数据既不在缓存中,也不在数据库中。所以,缓存雪崩或击穿时,一旦数据库中的数据被再次写入到缓存后,应用又可以在缓存中快速访问数据了,数据库的压力也会相应地降低下来,而缓存穿透发生时,Redis缓存和数据库会同时持续承受请求压力。
|
||||
|
||||
为了方便你掌握,我把这三大问题的原因和应对方案总结到了一张表格,你可以再复习一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b5/e1/b5bd931239be18bef24b2ef36c70e9e1.jpg" alt="">
|
||||
|
||||
最后,我想强调一下,服务熔断、服务降级、请求限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响。例如使用服务降级时,有部分数据的请求就只能得到错误返回信息,无法正常处理。如果使用了服务熔断,那么,整个缓存系统的服务都被暂停了,影响的业务范围更大。而使用了请求限流机制后,整个业务系统的吞吐率会降低,能并发处理的用户请求会减少,会影响到用户体验。
|
||||
|
||||
所以,我给你的建议是,尽量使用预防式方案:
|
||||
|
||||
- 针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
|
||||
- 针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
|
||||
- 针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。在讲到缓存雪崩时,我提到,可以采用服务熔断、服务降级、请求限流的方法来应对。请你思考下,这三个机制可以用来应对缓存穿透问题吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
145
极客时间专栏/Redis核心技术与实战/实践篇/27 | 缓存被污染了,该怎么办?.md
Normal file
145
极客时间专栏/Redis核心技术与实战/实践篇/27 | 缓存被污染了,该怎么办?.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<audio id="audio" title="27 | 缓存被污染了,该怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/5c/175a89769f3998c204802abce60a1d5c.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们应用Redis缓存时,如果能缓存会被反复访问的数据,那就能加速业务应用的访问。但是,如果发生了缓存污染,那么,缓存对业务应用的加速作用就减少了。
|
||||
|
||||
那什么是缓存污染呢?在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
|
||||
|
||||
当缓存污染不严重时,只有少量数据占据缓存空间,此时,对缓存系统的影响不大。但是,缓存污染一旦变得严重后,就会有大量不再访问的数据滞留在缓存中。如果这时数据占满了缓存空间,我们再往缓存中写入新数据时,就需要先把这些数据逐步淘汰出缓存,这就会引入额外的操作时间开销,进而会影响应用的性能。
|
||||
|
||||
今天,我们就来看看如何解决缓存污染问题。
|
||||
|
||||
## 如何解决缓存污染问题?
|
||||
|
||||
要解决缓存污染,我们也能很容易想到解决方案,那就是得把不会再被访问的数据筛选出来并淘汰掉。这样就不用等到缓存被写满以后,再逐一淘汰旧数据之后,才能写入新数据了。而哪些数据能留存在缓存中,是由缓存的淘汰策略决定的。
|
||||
|
||||
到这里,你还记得咱们在[第24讲](https://time.geekbang.org/column/article/294640)一起学习的8种数据淘汰策略吗?它们分别是noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random和allkeys-lfu策略。
|
||||
|
||||
在这8种策略中,noeviction策略是不会进行数据淘汰的。所以,它肯定不能用来解决缓存污染问题。其他的7种策略,都会按照一定的规则来淘汰数据。这里有个关键词是“一定的规则”,那么问题来了,不同的规则对于解决缓存污染问题,是否都有效呢?接下来,我们就一一分析下。
|
||||
|
||||
因为LRU算法是我们在缓存数据淘汰策略中广泛应用的算法,所以我们先分析其他策略,然后单独分析淘汰策略使用LRU算法的情况,最后再学习下LFU算法用于淘汰策略时,对缓存污染的应对措施。使用LRU算法和LFU算法的策略各有两种(volatile-lru和allkeys-lru,以及volatile-lfu和allkeys-lfu),为了便于理解,接下来我会统一把它们叫作LRU策略和LFU策略。
|
||||
|
||||
首先,我们看下**volatile-random和allkeys-random**这两种策略。它们都是采用随机挑选数据的方式,来筛选即将被淘汰的数据。
|
||||
|
||||
既然是随机挑选,那么Redis就不会根据数据的访问情况来筛选数据。如果被淘汰的数据又被访问了,就会发生缓存缺失。也就是说,应用需要到后端数据库中访问这些数据,降低了应用的请求响应速度。所以,volatile-random和allkeys-random策略,在避免缓存污染这个问题上的效果非常有限。
|
||||
|
||||
我给你举个例子吧。如下图所示,假设我们配置Redis缓存使用allkeys-random淘汰策略,当缓存写满时,allkeys-random策略随机选择了数据20进行淘汰。不巧的是,数据20紧接着又被访问了,此时,Redis就会发生了缓存缺失。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/c8/d8e81168d83b411524a91c2f5554e3c8.jpg" alt="">
|
||||
|
||||
我们继续看**volatile-ttl**策略是否能有效应对缓存污染。volatile-ttl针对的是设置了过期时间的数据,把这些数据中剩余存活时间最短的筛选出来并淘汰掉。
|
||||
|
||||
虽然volatile-ttl策略不再是随机选择淘汰数据了,但是剩余存活时间并不能直接反映数据再次访问的情况。所以,按照volatile-ttl策略淘汰数据,和按随机方式淘汰数据类似,也可能出现数据被淘汰后,被再次访问导致的缓存缺失问题。
|
||||
|
||||
这时,你可能会想到一种例外的情况:业务应用在给数据设置过期时间的时候,就明确知道数据被再次访问的情况,并根据访问情况设置过期时间。此时,Redis按照数据的剩余最短存活时间进行筛选,是可以把不会再被访问的数据筛选出来的,进而避免缓存污染。例如,业务部门知道数据被访问的时长就是一个小时,并把数据的过期时间设置为一个小时后。这样一来,被淘汰的数据的确是不会再被访问了。
|
||||
|
||||
讲到这里,我们先小结下。除了在明确知道数据被再次访问的情况下,volatile-ttl可以有效避免缓存污染。在其他情况下,volatile-random、allkeys-random、volatile-ttl这三种策略并不能应对缓存污染问题。
|
||||
|
||||
接下来,我们再分别分析下LRU策略,以及Redis 4.0后实现的LFU策略。LRU策略会按照数据访问的时效性,来筛选即将被淘汰的数据,应用非常广泛。在第24讲,我们已经学习了Redis是如何实现LRU策略的,所以接下来我们就重点看下它在解决缓存污染问题上的效果。
|
||||
|
||||
## LRU缓存策略
|
||||
|
||||
我们先复习下LRU策略的核心思想:如果一个数据刚刚被访问,那么这个数据肯定是热数据,还会被再次访问。
|
||||
|
||||
按照这个核心思想,Redis中的LRU策略,会在每个数据对应的RedisObject结构体中设置一个lru字段,用来记录数据的访问时间戳。在进行数据淘汰时,LRU策略会在候选数据集中淘汰掉lru字段值最小的数据(也就是访问时间最久的数据)。
|
||||
|
||||
所以,在数据被频繁访问的业务场景中,LRU策略的确能有效留存访问时间最近的数据。而且,因为留存的这些数据还会被再次访问,所以又可以提升业务应用的访问速度。
|
||||
|
||||
但是,也正是**因为只看数据的访问时间,使用LRU策略在处理扫描式单次查询操作时,无法解决缓存污染**。所谓的扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以lru字段值都很大。
|
||||
|
||||
在使用LRU策略淘汰数据时,这些数据会留存在缓存中很长一段时间,造成缓存污染。如果查询的数据量很大,这些数据占满了缓存空间,却又不会服务新的缓存请求,此时,再有新数据要写入缓存的话,还是需要先把这些旧数据替换出缓存才行,这会影响缓存的性能。
|
||||
|
||||
为了方便你理解,我给你举个例子。如下图所示,数据6被访问后,被写入Redis缓存。但是,在此之后,数据6一直没有被再次访问,这就导致数据6滞留在缓存中,造成了污染。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/75/76909482d30097da81273f7bda18b275.jpg" alt="">
|
||||
|
||||
所以,对于采用了LRU策略的Redis缓存来说,扫描式单次查询会造成缓存污染。为了应对这类缓存污染问题,Redis从4.0版本开始增加了LFU淘汰策略。
|
||||
|
||||
与LRU策略相比,LFU策略中会从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数。
|
||||
|
||||
那Redis的LFU策略是怎么实现的,又是如何解决缓存污染问题的呢?我们来看一下。
|
||||
|
||||
## LFU缓存策略的优化
|
||||
|
||||
LFU缓存策略是在LRU策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用LFU策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
|
||||
|
||||
和那些被频繁访问的数据相比,扫描式单次查询的数据因为不会被再次访问,所以它们的访问次数不会再增加。因此,LFU策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU策略就可以避免这些数据对缓存造成污染了。
|
||||
|
||||
那么,LFU策略具体又是如何实现的呢?既然LFU策略是在LRU策略上做的优化,那它们的实现必定有些关系。所以,我们就再复习下第24讲学习过的LRU策略的实现。
|
||||
|
||||
为了避免操作链表的开销,Redis在实现LRU策略时使用了两个近似方法:
|
||||
|
||||
- Redis是用RedisObject结构来保存数据的,RedisObject结构中设置了一个lru字段,用来记录数据的访问时间戳;
|
||||
- Redis并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如10个)的数据放入候选集合,后续在候选集合中根据lru字段值的大小进行筛选。
|
||||
|
||||
在此基础上,**Redis在实现LFU策略的时候,只是把原来24bit大小的lru字段,又进一步拆分成了两部分**。
|
||||
|
||||
1. ldt值:lru字段的前16bit,表示数据的访问时间戳;
|
||||
1. counter值:lru字段的后8bit,表示数据的访问次数。
|
||||
|
||||
总结一下:当LFU策略筛选数据时,Redis会在候选集合中,根据数据lru字段的后8bit选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据lru字段的前16bit值大小,选择访问时间最久远的数据进行淘汰。
|
||||
|
||||
到这里,还没结束,**Redis只使用了8bit记录数据的访问次数,而8bit记录的最大值是255**,这样可以吗?
|
||||
|
||||
在实际应用中,一个数据可能会被访问成千上万次。如果每被访问一次,counter值就加1的话,那么,只要访问次数超过了255,数据的counter值就一样了。在进行数据淘汰时,LFU策略就无法很好地区分并筛选这些数据,反而还可能会把不怎么访问的数据留存在了缓存中。
|
||||
|
||||
我们一起来看个例子。
|
||||
|
||||
假设第一个数据A的累计访问次数是256,访问时间戳是202010010909,所以它的counter值为255,而第二个数据B的累计访问次数是1024,访问时间戳是202010010810。如果counter值只能记录到255,那么数据B的counter值也是255。
|
||||
|
||||
此时,缓存写满了,Redis使用LFU策略进行淘汰。数据A和B的counter值都是255,LFU策略再比较A和B的访问时间戳,发现数据B的上一次访问时间早于A,就会把B淘汰掉。但其实数据B的访问次数远大于数据A,很可能会被再次访问。这样一来,使用LFU策略来淘汰数据就不合适了。
|
||||
|
||||
的确,Redis也注意到了这个问题。因此,**在实现LFU策略时,Redis并没有采用数据每被访问一次,就给对应的counter值加1的计数规则,而是采用了一个更优化的计数规则**。
|
||||
|
||||
简单来说,LFU策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项lfu_log_factor再加1,再取其倒数,得到一个p值;然后,把这个p值和一个取值范围在(0,1)间的随机数r值比大小,只有p值大于r值时,计数器才加1。
|
||||
|
||||
下面这段Redis的部分源码,显示了LFU策略增加计数器值的计算逻辑。其中,baseval是计数器当前的值。计数器的初始值默认是5(由代码中的LFU_INIT_VAL常量设置),而不是0,这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。
|
||||
|
||||
```
|
||||
double r = (double)rand()/RAND_MAX;
|
||||
...
|
||||
double p = 1.0/(baseval*server.lfu_log_factor+1);
|
||||
if (r < p) counter++;
|
||||
|
||||
```
|
||||
|
||||
使用了这种计算规则后,我们可以通过设置不同的lfu_log_factor配置项,来控制计数器值增加的速度,避免counter值很快就到255了。
|
||||
|
||||
为了更进一步说明LFU策略计数器递增的效果,你可以看下下面这张表。这是Redis[官网](https://redis.io/topics/lru-cache)上提供的一张表,它记录了当lfu_log_factor取不同值时,在不同的实际访问次数情况下,计数器的值是如何变化的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/3e/8eafa57112b01ba0yyf93034ca109f3e.jpg" alt="">
|
||||
|
||||
可以看到,当lfu_log_factor取值为1时,实际访问次数为100K后,counter值就达到255了,无法再区分实际访问次数更多的数据了。而当lfu_log_factor取值为100时,当实际访问次数为10M时,counter值才达到255,此时,实际访问次数小于10M的不同数据都可以通过counter值区分出来。
|
||||
|
||||
正是因为使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选。从刚才的表中,我们可以看到,当lfu_log_factor取值为10时,百、千、十万级别的访问次数对应的counter值已经有明显的区分了,所以,我们在应用LFU策略时,一般可以将lfu_log_factor取值为10。
|
||||
|
||||
前面我们也提到了,应用负载的情况是很复杂的。在一些场景下,有些数据在短时间内被大量访问后就不会再被访问了。那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。为此,Redis在实现LFU策略时,还设计了一个counter值的衰减机制。
|
||||
|
||||
简单来说,LFU策略使用衰减因子配置项lfu_decay_time来控制访问次数的衰减。LFU策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU策略再把这个差值除以lfu_decay_time值,所得的结果就是数据counter要衰减的值。
|
||||
|
||||
简单举个例子,假设lfu_decay_time取值为1,如果数据在N分钟内没有被访问,那么它的访问次数就要减N。如果lfu_decay_time取值更大,那么相应的衰减值会变小,衰减效果也会减弱。所以,如果业务应用中有短时高频访问的数据的话,建议把lfu_decay_time值设置为1,这样一来,LFU策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这节课,我们学习的是“如何解决缓存污染”这个问题。
|
||||
|
||||
缓存污染问题指的是留存在缓存中的数据,实际不会被再次访问了,但是又占据了缓存空间。如果这样的数据体量很大,甚至占满了缓存,每次有新数据写入缓存时,还需要把这些数据逐步淘汰出缓存,就会增加缓存操作的时间开销。
|
||||
|
||||
因此,要解决缓存污染问题,最关键的技术点就是能识别出这些只访问一次或是访问次数很少的数据,在淘汰数据时,优先把它们筛选出来并淘汰掉。因为noviction策略不涉及数据淘汰,所以这节课,我们就从能否有效解决缓存污染这个维度,分析了Redis的其他7种数据淘汰策略。
|
||||
|
||||
volatile-random和allkeys-random是随机选择数据进行淘汰,无法把不再访问的数据筛选出来,可能会造成缓存污染。如果业务层明确知道数据的访问时长,可以给数据设置合理的过期时间,再设置Redis缓存使用volatile-ttl策略。当缓存写满时,剩余存活时间最短的数据就会被淘汰出缓存,避免滞留在缓存中,造成污染。
|
||||
|
||||
当我们使用LRU策略时,由于LRU策略只考虑数据的访问时效,对于只访问一次的数据来说,LRU策略无法很快将其筛选出来。而LFU策略在LRU策略基础上进行了优化,在筛选数据时,首先会筛选并淘汰访问次数少的数据,然后针对访问次数相同的数据,再筛选并淘汰访问时间最久远的数据。
|
||||
|
||||
在具体实现上,相对于LRU策略,Redis只是把原来24bit大小的lru字段,又进一步拆分成了16bit的ldt和8bit的counter,分别用来表示数据的访问时间戳和访问次数。为了避开8bit最大只能记录255的限制,LFU策略设计使用非线性增长的计数器来表示数据的访问次数。
|
||||
|
||||
在实际业务应用中,LRU和LFU两个策略都有应用。LRU和LFU两个策略关注的数据访问特征各有侧重,LRU策略更加关注数据的时效性,而LFU策略更加关注数据的访问频次。通常情况下,实际应用的负载具有较好的时间局部性,所以LRU策略的应用会更加广泛。但是,在扫描式查询的应用场景中,LFU策略就可以很好地应对缓存污染问题了,建议你优先使用。
|
||||
|
||||
此外,如果业务应用中有短时高频访问的数据,除了LFU策略本身会对数据的访问次数进行自动衰减以外,我再给你个小建议:你可以优先使用volatile-lfu策略,并根据这些数据的访问时限设置它们的过期时间,以免它们留存在缓存中造成污染。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。使用了LFU策略后,你觉得缓存还会被污染吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
168
极客时间专栏/Redis核心技术与实战/实践篇/28 | Pika:如何基于SSD实现大容量Redis?.md
Normal file
168
极客时间专栏/Redis核心技术与实战/实践篇/28 | Pika:如何基于SSD实现大容量Redis?.md
Normal file
@@ -0,0 +1,168 @@
|
||||
<audio id="audio" title="28 | Pika:如何基于SSD实现大容量Redis?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/7c/2eafb25d314553776cc36d5c4212787c.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们在应用Redis时,随着业务数据的增加(比如说电商业务中,随着用户规模和商品数量的增加),就需要Redis能保存更多的数据。你可能会想到使用Redis切片集群,把数据分散保存到多个实例上。但是这样做的话,会有一个问题,如果要保存的数据总量很大,但是每个实例保存的数据量较小的话,就会导致集群的实例规模增加,这会让集群的运维管理变得复杂,增加开销。
|
||||
|
||||
你可能又会说,我们可以通过增加Redis单实例的内存容量,形成大内存实例,每个实例可以保存更多的数据,这样一来,在保存相同的数据总量时,所需要的大内存实例的个数就会减少,就可以节省开销。
|
||||
|
||||
这是一个好主意,但这也并不是完美的方案:基于大内存的大容量实例在实例恢复、主从同步过程中会引起一系列潜在问题,例如恢复时间增长、主从切换开销大、缓冲区易溢出。
|
||||
|
||||
那怎么办呢?我推荐你使用固态硬盘(Solid State Drive,SSD)。它的成本很低(每GB的成本约是内存的十分之一),而且容量大,读写速度快,我们可以基于SSD来实现大容量的Redis实例。360公司DBA和基础架构组联合开发的Pika[键值数据库](https://github.com/Qihoo360/pika),正好实现了这一需求。
|
||||
|
||||
Pika在刚开始设计的时候,就有两个目标:一是,单实例可以保存大容量数据,同时避免了实例恢复和主从同步时的潜在问题;二是,和Redis数据类型保持兼容,可以支持使用Redis的应用平滑地迁移到Pika上。所以,如果你一直在使用Redis,并且想使用SSD来扩展单实例容量,Pika就是一个很好的选择。
|
||||
|
||||
这节课,我就和你聊聊Pika。在介绍Pika前,我先给你具体解释下基于大内存实现大容量Redis实例的潜在问题。只有知道了这些问题,我们才能选择更合适的方案。另外呢,我还会带你一步步分析下Pika是如何实现刚刚我们所说的两个设计目标,解决这些问题的。
|
||||
|
||||
## 大内存Redis实例的潜在问题
|
||||
|
||||
Redis使用内存保存数据,内存容量增加后,就会带来两方面的潜在问题,分别是,内存快照RDB生成和恢复效率低,以及主从节点全量同步时长增加、缓冲区易溢出。我来一一解释下,
|
||||
|
||||
我们先看内存快照RDB受到的影响。内存大小和内存快照RDB的关系是非常直接的:实例内存容量大,RDB文件也会相应增大,那么,RDB文件生成时的fork时长就会增加,这就会导致Redis实例阻塞。而且,RDB文件增大后,使用RDB进行恢复的时长也会增加,会导致Redis较长时间无法对外提供服务。
|
||||
|
||||
接下来我们再来看下主从同步受到的影响,
|
||||
|
||||
主从节点间的同步的第一步就是要做全量同步。全量同步是主节点生成RDB文件,并传给从节点,从节点再进行加载。试想一下,如果RDB文件很大,肯定会导致全量同步的时长增加,效率不高,而且还可能会导致复制缓冲区溢出。一旦缓冲区溢出了,主从节点间就会又开始全量同步,影响业务应用的正常使用。如果我们增加复制缓冲区的容量,这又会消耗宝贵的内存资源。
|
||||
|
||||
此外,如果主库发生了故障,进行主从切换后,其他从库都需要和新主库进行一次全量同步。如果RDB文件很大,也会导致主从切换的过程耗时增加,同样会影响业务的可用性。
|
||||
|
||||
那么,Pika是如何解决这两方面的问题呢?这就要提到Pika中的关键模块RocksDB、binlog机制和Nemo了,这些模块都是Pika架构中的重要组成部分。所以,接下来,我们就来先看下Pika的整体架构。
|
||||
|
||||
## Pika的整体架构
|
||||
|
||||
Pika键值数据库的整体架构中包括了五部分,分别是网络框架、Pika线程模块、Nemo存储模块、RocksDB和binlog机制,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/e7/a1421b8dbca6bb1ee9b6c1be7a929ae7.jpg" alt="">
|
||||
|
||||
这五个部分分别实现了不同的功能,下面我一个个来介绍下。
|
||||
|
||||
首先,网络框架主要负责底层网络请求的接收和发送。Pika的网络框架是对操作系统底层的网络函数进行了封装。Pika在进行网络通信时,可以直接调用网络框架封装好的函数。
|
||||
|
||||
其次,Pika线程模块采用了多线程模型来具体处理客户端请求,包括一个请求分发线程(DispatchThread)、一组工作线程(WorkerThread)以及一个线程池(ThreadPool)。
|
||||
|
||||
请求分发线程专门监听网络端口,一旦接收到客户端的连接请求后,就和客户端建立连接,并把连接交由工作线程处理。工作线程负责接收客户端连接上发送的具体命令请求,并把命令请求封装成Task,再交给线程池中的线程,由这些线程进行实际的数据存取处理,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/06/4627f13848167cdaa3b30370d9b80a06.jpg" alt="">
|
||||
|
||||
在实际应用Pika的时候,我们可以通过增加工作线程数和线程池中的线程数,来提升Pika的请求处理吞吐率,进而满足业务层对数据处理性能的需求。
|
||||
|
||||
Nemo模块很容易理解,它实现了Pika和Redis的数据类型兼容。这样一来,当我们把Redis服务迁移到Pika时,不用修改业务应用中操作Redis的代码,而且还可以继续应用运维Redis的经验,这使得Pika的学习成本就较低。Nemo模块对数据类型的具体转换机制是我们要重点关心的,下面我会具体介绍。
|
||||
|
||||
最后,我们再来看看RocksDB提供的基于SSD保存数据的功能。它使得Pika可以不用大容量的内存,就能保存更多数据,还避免了使用内存快照。而且,Pika使用binlog机制记录写命令,用于主从节点的命令同步,避免了刚刚所说的大内存实例在主从同步过程中的潜在问题。
|
||||
|
||||
接下来,我们就来具体了解下,Pika是如何使用RocksDB和binlog机制的。
|
||||
|
||||
## Pika如何基于SSD保存更多数据?
|
||||
|
||||
为了把数据保存到SSD,Pika使用了业界广泛应用的持久化键值数据库[RocksDB](https://rocksdb.org/)。RocksDB本身的实现机制较为复杂,你不需要全部弄明白,你只要记住RocksDB的基本数据读写机制,对于学习了解Pika来说,就已经足够了。下面我来解释下这个基本读写机制。
|
||||
|
||||
下面我结合一张图片,来给你具体介绍下RocksDB写入数据的基本流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/1d/95d97d3cf0f1555b65b47fb256b7b81d.jpg" alt="">
|
||||
|
||||
当Pika需要保存数据时,RocksDB会使用两小块内存空间(Memtable1和Memtable2)来交替缓存写入的数据。Memtable的大小可以设置,一个Memtable的大小一般为几MB或几十MB。当有数据要写入RocksDB时,RocksDB会先把数据写入到Memtable1。等到Memtable1写满后,RocksDB再把数据以文件的形式,快速写入底层的SSD。同时,RocksDB会使用Memtable2来代替Memtable1,缓存新写入的数据。等到Memtable1的数据都写入SSD了,RocksDB会在Memtable2写满后,再用Memtable1缓存新写入的数据。
|
||||
|
||||
这么一分析你就知道了,RocksDB会先用Memtable缓存数据,再将数据快速写入SSD,即使数据量再大,所有数据也都能保存到SSD中。而且,Memtable本身容量不大,即使RocksDB使用了两个Memtable,也不会占用过多的内存,这样一来,Pika在保存大容量数据时,也不用占据太大的内存空间了。
|
||||
|
||||
当Pika需要读取数据的时候,RocksDB会先在Memtable中查询是否有要读取的数据。这是因为,最新的数据都是先写入到Memtable中的。如果Memtable中没有要读取的数据,RocksDB会再查询保存在SSD上的数据文件,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/3b/aa70655efbb767af499a83bd6521ee3b.jpg" alt="">
|
||||
|
||||
到这里,你就了解了,当使用了RocksDB保存数据后,Pika就可以把大量数据保存到大容量的SSD上了,实现了大容量实例。不过,我刚才向你介绍过,当使用大内存实例保存大量数据时,Redis会面临RDB生成和恢复的效率问题,以及主从同步时的效率和缓冲区溢出问题。那么,当Pika保存大量数据时,还会面临相同的问题吗?
|
||||
|
||||
其实不会了,我们来分析一下。
|
||||
|
||||
一方面,Pika基于RocksDB保存了数据文件,直接读取数据文件就能恢复,不需要再通过内存快照进行恢复了。而且,Pika从库在进行全量同步时,可以直接从主库拷贝数据文件,不需要使用内存快照,这样一来,Pika就避免了大内存快照生成效率低的问题。
|
||||
|
||||
另一方面,Pika使用了binlog机制实现增量命令同步,既节省了内存,还避免了缓冲区溢出的问题。binlog是保存在SSD上的文件,Pika接收到写命令后,在把数据写入Memtable时,也会把命令操作写到binlog文件中。和Redis类似,当全量同步结束后,从库会从binlog中把尚未同步的命令读取过来,这样就可以和主库的数据保持一致。当进行增量同步时,从库也是把自己已经复制的偏移量发给主库,主库把尚未同步的命令发给从库,来保持主从库的数据一致。
|
||||
|
||||
不过,和Redis使用缓冲区相比,使用binlog好处是非常明显的:binlog是保存在SSD上的文件,文件大小不像缓冲区,会受到内存容量的较多限制。而且,当binlog文件增大后,还可以通过轮替操作,生成新的binlog文件,再把旧的binlog文件独立保存。这样一来,即使Pika实例保存了大量的数据,在同步过程中也不会出现缓冲区溢出的问题了。
|
||||
|
||||
现在,我们先简单小结下。Pika使用RocksDB把大量数据保存到了SSD,同时避免了内存快照的生成和恢复问题。而且,Pika使用binlog机制进行主从同步,避免大内存时的影响,Pika的第一个设计目标就实现了。
|
||||
|
||||
接下来,我们再来看Pika是如何实现第二个设计目标的,也就是如何和Redis兼容。毕竟,如果不兼容的话,原来使用Redis的业务就无法平滑迁移到Pika上使用了,也就没办法利用Pika保存大容量数据的优势了。
|
||||
|
||||
## Pika如何实现Redis数据类型兼容?
|
||||
|
||||
Pika的底层存储使用了RocksDB来保存数据,但是,RocksDB只提供了单值的键值对类型,RocksDB键值对中的值就是单个值,而Redis键值对中的值还可以是集合类型。
|
||||
|
||||
对于Redis的String类型来说,它本身就是单值的键值对,我们直接用RocksDB保存就行。但是,对于集合类型来说,我们就无法直接把集合保存为单值的键值对,而是需要进行转换操作。
|
||||
|
||||
为了保持和Redis的兼容性,Pika的Nemo模块就负责把Redis的集合类型转换成单值的键值对。简单来说,我们可以把Redis的集合类型分成两类:
|
||||
|
||||
- 一类是List和Set类型,它们的集合中也只有单值;
|
||||
- 另一类是Hash和Sorted Set类型,它们的集合中的元素是成对的,其中,Hash集合元素是field-value类型,而Sorted Set集合元素是member-score类型。
|
||||
|
||||
Nemo模块通过转换操作,把这4种集合类型的元素表示为单值的键值对。具体怎么转换呢?下面我们来分别看下每种类型的转换。
|
||||
|
||||
首先我们来看List类型。在Pika中,List集合的key被嵌入到了单值键值对的键当中,用key字段表示;而List集合的元素值,则被嵌入到单值键值对的值当中,用value字段表示。因为List集合中的元素是有序的,所以,Nemo模块还在单值键值对的key后面增加了sequence字段,表示当前元素在List中的顺序,同时,还在value的前面增加了previous sequence和next sequence这两个字段,分别表示当前元素的前一个元素和后一个元素。
|
||||
|
||||
此外,在单值键值对的key前面,Nemo模块还增加了一个值“l”,表示当前数据是List类型,以及增加了一个1字节的size字段,表示List集合key的大小。在单值键值对的value后面,Nemo模块还增加了version和ttl字段,分别表示当前数据的版本号和剩余存活时间(用来支持过期key功能),如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/05/066465f1a28b6f14a42c1fc3a3f73105.jpg" alt="">
|
||||
|
||||
我们再来看看Set集合。
|
||||
|
||||
Set集合的key和元素member值,都被嵌入到了Pika单值键值对的键当中,分别用key和member字段表示。同时,和List集合类似,单值键值对的key前面有值“s”,用来表示数据是Set类型,同时还有size字段,用来表示key的大小。Pika单值键值对的值只保存了数据的版本信息和剩余存活时间,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/71/aa20c1456526dbf3f7d30f9d865f0f71.jpg" alt="">
|
||||
|
||||
对于Hash类型来说,Hash集合的key被嵌入到单值键值对的键当中,用key字段表示,而Hash集合元素的field也被嵌入到单值键值对的键当中,紧接着key字段,用field字段表示。Hash集合元素的value则是嵌入到单值键值对的值当中,并且也带有版本信息和剩余存活时间,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/b9/6378f7045393ae342632189a4ab601b9.jpg" alt="">
|
||||
|
||||
最后,对于Sorted Set类型来说,该类型是需要能够按照集合元素的score值排序的,而RocksDB只支持按照单值键值对的键来排序。所以,Nemo模块在转换数据时,就把Sorted Set集合key、元素的score和member值都嵌入到了单值键值对的键当中,此时,单值键值对中的值只保存了数据的版本信息和剩余存活时间,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/a8/a0bc4d00a5d95e7fd2699945ff7a56a8.jpg" alt="">
|
||||
|
||||
采用了上面的转换方式之后,Pika不仅能兼容支持Redis的数据类型,而且还保留了这些数据类型的特征,例如List的元素保序、Sorted Set的元素按score排序。了解了Pika的转换机制后,你就会明白,如果你有业务应用计划从使用Redis切换到使用Pika,就不用担心面临因为操作接口不兼容而要修改业务应用的问题了。
|
||||
|
||||
经过刚刚的分析,我们可以知道,Pika能够基于SSD保存大容量数据,而且和Redis兼容,这是它的两个优势。接下来,我们再来看看,跟Redis相比,Pika的其他优势,以及潜在的不足。当在实际应用Pika时,Pika的不足之处是你需要特别注意的地方,这些可能都需要你进行系统配置或参数上的调优。
|
||||
|
||||
## Pika的其他优势与不足
|
||||
|
||||
跟Redis相比,Pika最大的特点就是使用了SSD来保存数据,这个特点能带来的最直接好处就是,Pika单实例能保存更多的数据了,实现了实例数据扩容。
|
||||
|
||||
除此之外,Pika使用SSD来保存数据,还有额外的两个优势。
|
||||
|
||||
首先,**实例重启快**。Pika的数据在写入数据库时,是会保存到SSD上的。当Pika实例重启时,可以直接从SSD上的数据文件中读取数据,不需要像Redis一样,从RDB文件全部重新加载数据或是从AOF文件中全部回放操作,这极大地提高了Pika实例的重启速度,可以快速处理业务应用请求。
|
||||
|
||||
另外,主从库重新执行全量同步的风险低。Pika通过binlog机制实现写命令的增量同步,不再受内存缓冲区大小的限制,所以,即使在数据量很大导致主从库同步耗时很长的情况下,Pika也不用担心缓冲区溢出而触发的主从库重新全量同步。
|
||||
|
||||
但是,就像我在前面的课程中和你说的,“硬币都是有正反两面的”,Pika也有自身的一些不足。
|
||||
|
||||
虽然它保持了Redis操作接口,也能实现数据库扩容,但是,当把数据保存到SSD上后,会降低数据的访问性能。这是因为,数据操作毕竟不能在内存中直接执行了,而是要在底层的SSD中进行存取,这肯定会影响,Pika的性能。而且,我们还需要把binlog机制记录的写命令同步到SSD上,这会降低Pika的写性能。
|
||||
|
||||
不过,Pika的多线程模型,可以同时使用多个线程进行数据读写,这在一定程度上弥补了从SSD存取数据造成的性能损失。当然,你也可以使用高配的SSD来提升访问性能,进而减少读写SSD对Pika性能的影响。
|
||||
|
||||
为了帮助你更直观地了解Pika的性能情况,我再给你提供一张表,这是Pika[官网](https://github.com/Qihoo360/pika/wiki/3.2.x-Performance)上提供的测试数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/c5/6fed4a269a79325efd6fa4fb17fc44c5.jpg" alt="">
|
||||
|
||||
这些数据是在Pika 3.2版本中,String和Hash类型在多线程情况下的基本操作性能结果。从表中可以看到,在不写binlog时,Pika的SET/GET、HSET/HGET的性能都能达到200K OPS以上,而一旦增加了写binlog操作,SET和HSET操作性能大约下降了41%,只有约120K OPS。
|
||||
|
||||
所以,我们在使用Pika时,需要在单实例扩容的必要性和可能的性能损失间做个权衡。如果保存大容量数据是我们的首要需求,那么,Pika是一个不错的解决方案。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了基于SSD给Redis单实例进行扩容的技术方案Pika。跟Redis相比,Pika的好处非常明显:既支持Redis操作接口,又能支持保存大容量的数据。如果你原来就在应用Redis,现在想进行扩容,那么,Pika无疑是一个很好的选择,无论是代码迁移还是运维管理,Pika基本不需要额外的工作量。
|
||||
|
||||
不过,Pika毕竟是把数据保存到了SSD上,数据访问要读写SSD,所以,读写性能要弱于Redis。针对这一点,我给你提供两个降低读写SSD对Pika的性能影响的小建议:
|
||||
|
||||
1. 利用Pika的多线程模型,增加线程数量,提升Pika的并发请求处理能力;
|
||||
1. 为Pika配置高配的SSD,提升SSD自身的访问性能。
|
||||
|
||||
最后,我想再给你一个小提示。Pika本身提供了很多工具,可以帮助我们把Redis数据迁移到Pika,或者是把Redis请求转发给Pika。比如说,我们使用aof_to_pika命令,并且指定Redis的AOF文件以及Pika的连接信息,就可以把Redis数据迁移到Pika中了,如下所示:
|
||||
|
||||
```
|
||||
aof_to_pika -i [Redis AOF文件] -h [Pika IP] -p [Pika port] -a [认证信息]
|
||||
|
||||
```
|
||||
|
||||
关于这些工具的信息,你都可以直接在Pika的[GitHub](https://github.com/Qihoo360/pika/wiki)上找到。而且,Pika本身也还在迭代开发中,我也建议你多去看看GitHub,进一步地了解它。这样,你就可以获得Pika的最新进展,也能更好地把它应用到你的业务实践中。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。这节课,我向你介绍的是使用SSD作为内存容量的扩展,增加Redis实例的数据保存量,我想请你来聊一聊,我们可以使用机械硬盘来作为实例容量扩展吗,有什么好处或不足吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
167
极客时间专栏/Redis核心技术与实战/实践篇/29 | 无锁的原子操作:Redis如何应对并发访问?.md
Normal file
167
极客时间专栏/Redis核心技术与实战/实践篇/29 | 无锁的原子操作:Redis如何应对并发访问?.md
Normal file
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="29 | 无锁的原子操作:Redis如何应对并发访问?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/e3/846079205efc98381146183fa72df4e3.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
我们在使用Redis时,不可避免地会遇到并发访问的问题,比如说如果多个用户同时下单,就会对缓存在Redis中的商品库存并发更新。一旦有了并发写操作,数据就会被修改,如果我们没有对并发写请求做好控制,就可能导致数据被改错,影响到业务的正常使用(例如库存数据错误,导致下单异常)。
|
||||
|
||||
为了保证并发访问的正确性,Redis提供了两种方法,分别是加锁和原子操作。
|
||||
|
||||
加锁是一种常用的方法,在读取数据前,客户端需要先获得锁,否则就无法进行操作。当一个客户端获得锁后,就会一直持有这把锁,直到客户端完成数据更新,才释放这把锁。
|
||||
|
||||
看上去好像是一种很好的方案,但是,其实这里会有两个问题:一个是,如果加锁操作多,会降低系统的并发访问性能;第二个是,Redis客户端要加锁时,需要用到分布式锁,而分布式锁实现复杂,需要用额外的存储系统来提供加解锁操作,我会在下节课向你介绍。
|
||||
|
||||
**原子操作是另一种提供并发访问控制的方法**。原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响。
|
||||
|
||||
这节课,我就来和你聊聊Redis中的原子操作。原子操作的目标是实现并发访问控制,那么当有并发访问请求时,我们具体需要控制什么呢?接下来,我就先向你介绍下并发控制的内容。
|
||||
|
||||
## 并发访问中需要对什么进行控制?
|
||||
|
||||
我们说的并发访问控制,是指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在Redis实例上执行时具有互斥性。例如,客户端A的访问操作在执行时,客户端B的操作不能执行,需要等到A的操作结束后,才能执行。
|
||||
|
||||
并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:
|
||||
|
||||
1. 客户端先把数据读取到本地,在本地进行修改;
|
||||
1. 客户端修改完数据后,再写回Redis。
|
||||
|
||||
我们把这个流程叫做“读取-修改-写回”操作(Read-Modify-Write,简称为RMW操作)。当有多个客户端对同一份数据执行RMW操作的话,我们就需要让RMW操作涉及的代码以原子性方式执行。访问同一份数据的RMW操作代码,就叫做临界区代码。
|
||||
|
||||
不过,当有多个客户端并发执行临界区代码时,就会存在一些潜在问题,接下来,我用一个多客户端更新商品库存的例子来解释一下。
|
||||
|
||||
我们先看下临界区代码。假设客户端要对商品库存执行扣减1的操作,伪代码如下所示:
|
||||
|
||||
```
|
||||
current = GET(id)
|
||||
current--
|
||||
SET(id, current)
|
||||
|
||||
```
|
||||
|
||||
可以看到,客户端首先会根据商品id,从Redis中读取商品当前的库存值current(对应Read),然后,客户端对库存值减1(对应Modify),再把库存值写回Redis(对应Write)。当有多个客户端执行这段代码时,这就是一份临界区代码。
|
||||
|
||||
如果我们对临界区代码的执行没有控制机制,就会出现数据更新错误。在刚才的例子中,假设现在有两个客户端A和B,同时执行刚才的临界区代码,就会出现错误,你可以看下下面这张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/5c/dce821cd00c1937b4aab1f130424335c.jpg" alt="">
|
||||
|
||||
可以看到,客户端A在t1时读取库存值10并扣减1,在t2时,客户端A还没有把扣减后的库存值9写回Redis,而在此时,客户端B读到库存值10,也扣减了1,B记录的库存值也为9了。等到t3时,A往Redis写回了库存值9,而到t4时,B也写回了库存值9。
|
||||
|
||||
如果按正确的逻辑处理,客户端A和B对库存值各做了一次扣减,库存值应该为8。所以,这里的库存值明显更新错了。
|
||||
|
||||
出现这个现象的原因是,临界区代码中的客户端读取数据、更新数据、再写回数据涉及了三个操作,而这三个操作在执行时并不具有互斥性,多个客户端基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改。
|
||||
|
||||
为了保证数据并发修改的正确性,我们可以用锁把并行操作变成串行操作,串行操作就具有互斥性。一个客户端持有锁后,其他客户端只能等到锁释放,才能拿锁再进行修改。
|
||||
|
||||
下面的伪代码显示了使用锁来控制临界区代码的执行情况,你可以看下。
|
||||
|
||||
```
|
||||
LOCK()
|
||||
current = GET(id)
|
||||
current--
|
||||
SET(id, current)
|
||||
UNLOCK()
|
||||
|
||||
```
|
||||
|
||||
虽然加锁保证了互斥性,但是**加锁也会导致系统并发性能降低**。
|
||||
|
||||
如下图所示,当客户端A加锁执行操作时,客户端B、C就需要等待。A释放锁后,假设B拿到锁,那么C还需要继续等待,所以,t1时段内只有A能访问共享数据,t2时段内只有B能访问共享数据,系统的并发性能当然就下降了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/25/845b4694700264482d64a3dbb7a36525.jpg" alt="">
|
||||
|
||||
和加锁类似,原子操作也能实现并发控制,但是原子操作对系统并发性能的影响较小,接下来,我们就来了解下Redis中的原子操作。
|
||||
|
||||
## Redis的两种原子操作方法
|
||||
|
||||
为了实现并发控制要求的临界区代码互斥执行,Redis的原子操作采用了两种方法:
|
||||
|
||||
1. 把多个操作在Redis中实现成一个操作,也就是单命令操作;
|
||||
1. 把多个操作写到一个Lua脚本中,以原子性方式执行单个Lua脚本。
|
||||
|
||||
我们先来看下Redis本身的单命令操作。
|
||||
|
||||
Redis是使用单线程来串行处理客户端的请求操作命令的,所以,当Redis执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。当然,Redis的快照生成、AOF重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。
|
||||
|
||||
你可能也注意到了,虽然Redis的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了,那该怎么办呢?
|
||||
|
||||
别担心,Redis提供了INCR/DECR命令,把这三个操作转变为一个原子操作了。INCR/DECR命令可以对数据进行**增值/减值**操作,而且它们本身就是单个命令操作,Redis在执行它们时,本身就具有互斥性。
|
||||
|
||||
比如说,在刚才的库存扣减例子中,客户端可以使用下面的代码,直接完成对商品id的库存值减1操作。即使有多个客户端执行下面的代码,也不用担心出现库存值扣减错误的问题。
|
||||
|
||||
```
|
||||
DECR id
|
||||
|
||||
```
|
||||
|
||||
所以,如果我们执行的RMW操作是对数据进行增减值的话,Redis提供的原子操作INCR和DECR可以直接帮助我们进行并发控制。
|
||||
|
||||
但是,如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis的单命令操作已经无法保证多个操作的互斥执行了。所以,这个时候,我们需要使用第二个方法,也就是Lua脚本。
|
||||
|
||||
Redis会把整个Lua脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了Lua脚本中操作的原子性。如果我们有多个操作要执行,但是又无法用INCR/DECR这种命令操作来实现,就可以把这些要执行的操作编写到一个Lua脚本中。然后,我们可以使用Redis的EVAL命令来执行脚本。这样一来,这些操作在执行时就具有了互斥性。
|
||||
|
||||
我再给你举个例子,来具体解释下Lua的使用。
|
||||
|
||||
当一个业务应用的访问用户增加时,我们有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。
|
||||
|
||||
那该怎么限制呢?我们可以把客户端IP作为key,把客户端的访问次数作为value,保存到Redis中。客户端每访问一次后,我们就用INCR增加访问次数。
|
||||
|
||||
不过,在这种场景下,客户端限流其实同时包含了对访问次数和时间范围的限制,例如每分钟的访问次数不能超过20。所以,我们可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为60s后过期。同时,在客户端每次访问时,我们读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。你可以看下下面的这段代码,它实现了对客户端每分钟访问次数不超过20次的限制。
|
||||
|
||||
```
|
||||
//获取ip对应的访问次数
|
||||
current = GET(ip)
|
||||
//如果超过访问次数超过20次,则报错
|
||||
IF current != NULL AND current > 20 THEN
|
||||
ERROR "exceed 20 accesses per second"
|
||||
ELSE
|
||||
//如果访问次数不足20次,增加一次访问计数
|
||||
value = INCR(ip)
|
||||
//如果是第一次访问,将键值对的过期时间设置为60s后
|
||||
IF value == 1 THEN
|
||||
EXPIRE(ip,60)
|
||||
END
|
||||
//执行其他操作
|
||||
DO THINGS
|
||||
END
|
||||
|
||||
```
|
||||
|
||||
可以看到,在这个例子中,我们已经使用了INCR来原子性地增加计数。但是,客户端限流的逻辑不只有计数,还包括**访问次数判断和过期时间设置**。
|
||||
|
||||
对于这些操作,我们同样需要保证它们的原子性。否则,如果客户端使用多线程访问,访问次数初始值为0,第一个线程执行了INCR(ip)操作后,第二个线程紧接着也执行了INCR(ip),此时,ip对应的访问次数就被增加到了2,我们就无法再对这个ip设置过期时间了。这样就会导致,这个ip对应的客户端访问次数达到20次之后,就无法再进行访问了。即使过了60s,也不能再继续访问,显然不符合业务要求。
|
||||
|
||||
所以,这个例子中的操作无法用Redis单个命令来实现,此时,我们就可以使用Lua脚本来保证并发控制。我们可以把访问次数加1、判断访问次数是否为1,以及设置过期时间这三个操作写入一个Lua脚本,如下所示:
|
||||
|
||||
```
|
||||
local current
|
||||
current = redis.call("incr",KEYS[1])
|
||||
if tonumber(current) == 1 then
|
||||
redis.call("expire",KEYS[1],60)
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
假设我们编写的脚本名称为lua.script,我们接着就可以使用Redis客户端,带上eval选项,来执行该脚本。脚本所需的参数将通过以下命令中的keys和args进行传递。
|
||||
|
||||
```
|
||||
redis-cli --eval lua.script keys , args
|
||||
|
||||
```
|
||||
|
||||
这样一来,访问次数加1、判断访问次数是否为1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis也会依次串行执行脚本代码,避免了并发操作带来的数据错误。
|
||||
|
||||
## 小结
|
||||
|
||||
在并发访问时,并发的RMW操作会导致数据错误,所以需要进行并发控制。所谓并发控制,就是要保证临界区代码的互斥执行。
|
||||
|
||||
Redis提供了两种原子操作的方法来实现并发控制,分别是单命令操作和Lua脚本。因为原子操作本身不会对太多的资源限制访问,可以维持较高的系统并发性能。
|
||||
|
||||
但是,单命令原子操作的适用范围较小,并不是所有的RMW操作都能转变成单命令的原子操作(例如INCR/DECR命令只能在读取数据后做原子增减),当我们需要对读取的数据做更多判断,或者是我们对数据的修改不是简单的增减时,单命令操作就不适用了。
|
||||
|
||||
而Redis的Lua脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。不过,如果把很多操作都放在Lua脚本中原子执行,会导致Redis执行脚本的时间增加,同样也会降低Redis的并发性能。所以,我给你一个小建议:**在编写Lua脚本时,你要避免把不<strong><strong>需要**</strong>做并发控制的操作写入脚本中</strong>。
|
||||
|
||||
当然,加锁也能实现临界区代码的互斥执行,只是如果有多个客户端加锁时,就需要分布式锁的支持了。所以,下节课,我就来和你聊聊分布式锁的实现。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我向你提个小问题,Redis在执行Lua脚本时,是可以保证原子性的,那么,在我举的Lua脚本例子(lua.script)中,你觉得是否需要把读取客户端ip的访问次数,也就是GET(ip),以及判断访问次数是否超过20的判断逻辑,也加到Lua脚本中吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
232
极客时间专栏/Redis核心技术与实战/实践篇/30 | 如何使用Redis实现分布式锁?.md
Normal file
232
极客时间专栏/Redis核心技术与实战/实践篇/30 | 如何使用Redis实现分布式锁?.md
Normal file
@@ -0,0 +1,232 @@
|
||||
<audio id="audio" title="30 | 如何使用Redis实现分布式锁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0e/69/0e517f6ef22d893534yyee6dc72da269.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
上节课,我提到,在应对并发问题时,除了原子操作,Redis客户端还可以通过加锁的方式,来控制并发写操作对共享数据的修改,从而保证数据的正确性。
|
||||
|
||||
但是,Redis属于分布式系统,当有多个客户端需要争抢锁时,我们必须要保证,**这把锁不能是某个客户端本地的锁**。否则的话,其它客户端是无法访问这把锁的,当然也就不能获取这把锁了。
|
||||
|
||||
所以,在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。
|
||||
|
||||
Redis本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁。而且Redis的读写性能高,可以应对高并发的锁操作场景。所以,这节课,我就来和你聊聊如何基于Redis实现分布式锁。
|
||||
|
||||
我们日常在写程序的时候,经常会用到单机上的锁,你应该也比较熟悉了。而分布式锁和单机上的锁既有相似性,但也因为分布式锁是用在分布式场景中,所以又具有一些特殊的要求。
|
||||
|
||||
所以,接下来,我就先带你对比下分布式锁和单机上的锁,找出它们的联系与区别,这样就可以加深你对分布式锁的概念和实现要求的理解。
|
||||
|
||||
## 单机上的锁和分布式锁的联系与区别
|
||||
|
||||
我们先来看下单机上的锁。
|
||||
|
||||
对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。
|
||||
|
||||
- 变量值为0时,表示没有线程获取锁;
|
||||
- 变量值为1时,表示已经有线程获取到锁了。
|
||||
|
||||
我们通常说的线程调用加锁和释放锁的操作,到底是啥意思呢?我来解释一下。实际上,一个线程调用加锁操作,其实就是检查锁变量值是否为0。如果是0,就把锁的变量值设置为1,表示获取到锁,如果不是0,就返回错误信息,表示加锁失败,已经有别的线程获取到锁了。而一个线程调用释放锁操作,其实就是将锁变量的值置为0,以便其它线程可以来获取锁。
|
||||
|
||||
我用一段代码来展示下加锁和释放锁的操作,其中,lock为锁变量。
|
||||
|
||||
```
|
||||
acquire_lock(){
|
||||
if lock == 0
|
||||
lock = 1
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
}
|
||||
|
||||
release_lock(){
|
||||
lock = 0
|
||||
return 1
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
和单机上的锁类似,分布式锁同样可以**用一个变量来实现**。客户端加锁和释放锁的操作逻辑,也和单机上的加锁和释放锁操作逻辑一致:**加锁时同样需要判断锁变量的值,根据锁变量值来判断能否加锁成功;释放锁时需要把锁变量值设置为0,表明客户端不再持有锁**。
|
||||
|
||||
但是,和线程在单机上操作锁不同的是,在分布式场景下,**锁变量需要由一个共享存储系统来维护**,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,**加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值**。
|
||||
|
||||
这样一来,我们就可以得出实现分布式锁的两个要求。
|
||||
|
||||
- 要求一:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;
|
||||
- 要求二:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。
|
||||
|
||||
好了,知道了具体的要求,接下来,我们就来学习下Redis是怎么实现分布式锁的。
|
||||
|
||||
其实,我们既可以基于单个Redis节点来实现,也可以使用多个Redis节点实现。在这两种情况下,锁的可靠性是不一样的。我们先来看基于单个Redis节点的实现方法。
|
||||
|
||||
## 基于单个Redis节点实现分布式锁
|
||||
|
||||
作为分布式锁实现过程中的共享存储系统,Redis可以使用键值对来保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求。那么,键值对的键和值具体是怎么定的呢?
|
||||
|
||||
我们要赋予锁变量一个变量名,把这个变量名作为键值对的键,而锁变量的值,则是键值对的值,这样一来,Redis就能保存锁变量了,客户端也就可以通过Redis的命令操作来实现锁操作。
|
||||
|
||||
为了帮助你理解,我画了一张图片,它展示Redis使用键值对保存锁变量,以及两个客户端同时请求加锁的操作过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/45/1d18742c1e5fc88835ec27f1becfc145.jpg" alt="">
|
||||
|
||||
可以看到,Redis可以使用一个键值对lock_key:0来保存锁变量,其中,键是lock_key,也是锁变量的名称,锁变量的初始值是0。
|
||||
|
||||
我们再来分析下加锁操作。
|
||||
|
||||
在图中,客户端A和C同时请求加锁。因为Redis使用单线程处理请求,所以,即使客户端A和C同时把加锁请求发给了Redis,Redis也会串行处理它们的请求。
|
||||
|
||||
我们假设Redis先处理客户端A的请求,读取lock_key的值,发现lock_key为0,所以,Redis就把lock_key的value置为1,表示已经加锁了。紧接着,Redis处理客户端C的请求,此时,Redis会发现lock_key的值已经为1了,所以就返回加锁失败的信息。
|
||||
|
||||
刚刚说的是加锁的操作,那释放锁该怎么操作呢?其实,释放锁就是直接把锁变量值设置为0。
|
||||
|
||||
我还是借助一张图片来解释一下。这张图片展示了客户端A请求释放锁的过程。当客户端A持有锁时,锁变量lock_key的值为1。客户端A执行释放锁操作后,Redis将lock_key的值置为0,表明已经没有客户端持有锁了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/82/c7c413b47d42f06f08fce92404f31e82.jpg" alt="">
|
||||
|
||||
因为加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为1),而这三个操作在执行时需要保证原子性。那怎么保证原子性呢?
|
||||
|
||||
上节课,我们学过,要想保证操作的原子性,有两种通用的方法,分别是使用Redis的单命令操作和使用Lua脚本。那么,在分布式加锁场景下,该怎么应用这两个方法呢?
|
||||
|
||||
我们先来看下,Redis可以用哪些单命令操作实现加锁操作。
|
||||
|
||||
首先是SETNX命令,它用于设置键值对的值。具体来说,就是这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。
|
||||
|
||||
举个例子,如果执行下面的命令时,key不存在,那么key会被创建,并且值会被设置为value;如果key已经存在,SETNX不做任何赋值操作。
|
||||
|
||||
```
|
||||
SETNX key value
|
||||
|
||||
```
|
||||
|
||||
对于释放锁操作来说,我们可以在执行完业务逻辑后,使用DEL命令删除锁变量。不过,你不用担心锁变量被删除后,其他客户端无法请求加锁了。因为SETNX命令在执行时,如果要设置的键值对(也就是锁变量)不存在,SETNX命令会先创建键值对,然后设置它的值。所以,释放锁之后,再有客户端请求加锁时,SETNX命令会创建保存锁变量的键值对,并设置锁变量的值,完成加锁。
|
||||
|
||||
总结来说,我们就可以用SETNX和DEL命令组合来实现加锁和释放锁操作。下面的伪代码示例显示了锁操作的过程,你可以看下。
|
||||
|
||||
```
|
||||
// 加锁
|
||||
SETNX lock_key 1
|
||||
// 业务逻辑
|
||||
DO THINGS
|
||||
// 释放锁
|
||||
DEL lock_key
|
||||
|
||||
```
|
||||
|
||||
不过,使用SETNX和DEL命令组合实现分布锁,存在两个潜在的风险。
|
||||
|
||||
第一个风险是,假如某个客户端在执行了SETNX命令、加锁之后,紧接着却在操作共享数据时发生了异常,结果一直没有执行最后的DEL命令释放锁。因此,锁就一直被这个客户端持有,其它客户端无法拿到锁,也无法访问共享数据和执行后续操作,这会给业务应用带来影响。
|
||||
|
||||
针对这个问题,一个有效的解决方法是,**给锁变量设置一个过期时间**。这样一来,即使持有锁的客户端发生了异常,无法主动地释放锁,Redis也会根据锁变量的过期时间,在锁变量过期后,把它删除。其它客户端在锁变量过期后,就可以重新请求加锁,这就不会出现无法加锁的问题了。
|
||||
|
||||
我们再来看第二个风险。如果客户端A执行了SETNX命令加锁后,假设客户端B执行了DEL命令释放锁,此时,客户端A的锁就被误释放了。如果客户端C正好也在申请加锁,就可以成功获得锁,进而开始操作共享数据。这样一来,客户端A和C同时在对共享数据进行操作,数据就会被修改错误,这也是业务层不能接受的。
|
||||
|
||||
为了应对这个问题,我们需要**能区分来自不同客户端的锁操作**,具体咋做呢?其实,我们可以在锁变量的值上想想办法。
|
||||
|
||||
在使用SETNX命令进行加锁的方法中,我们通过把锁变量值设置为1或0,表示是否加锁成功。1和0只有两种状态,无法表示究竟是哪个客户端进行的锁操作。所以,我们在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。
|
||||
|
||||
知道了解决方案,那么,在Redis中,具体是怎么实现的呢?我们再来了解下。
|
||||
|
||||
在查看具体的代码前,我要先带你学习下Redis的SET命令。
|
||||
|
||||
我们刚刚在说SETNX命令的时候提到,对于不存在的键值对,它会先创建再设置值(也就是“不存在即设置”),为了能达到和SETNX命令一样的效果,Redis给SET命令提供了类似的选项NX,用来实现“不存在即设置”。如果使用了NX选项,SET命令只有在键值对不存在时,才会进行设置,否则不做赋值操作。此外,SET命令在执行时还可以带上EX或PX选项,用来设置键值对的过期时间。
|
||||
|
||||
举个例子,执行下面的命令时,只有key不存在时,SET才会创建key,并对key进行赋值。另外,**key的存活时间由seconds或者milliseconds选项值来决定**。
|
||||
|
||||
```
|
||||
SET key value [EX seconds | PX milliseconds] [NX]
|
||||
|
||||
```
|
||||
|
||||
有了SET命令的NX和EX/PX选项后,我们就可以用下面的命令来实现加锁操作了。
|
||||
|
||||
```
|
||||
// 加锁, unique_value作为客户端唯一性的标识
|
||||
SET lock_key unique_value NX PX 10000
|
||||
|
||||
```
|
||||
|
||||
其中,unique_value是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000则表示lock_key会在10s后过期,以免客户端在这期间发生异常而无法释放锁。
|
||||
|
||||
因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识,如下所示:
|
||||
|
||||
```
|
||||
//释放锁 比较unique_value是否相等,避免误释放
|
||||
if redis.call("get",KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del",KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
这是使用Lua脚本(unlock.script)实现的释放锁操作的伪代码,其中,KEYS[1]表示lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行Lua脚本时作为参数传入的。
|
||||
|
||||
最后,我们执行下面的命令,就可以完成锁释放操作了。
|
||||
|
||||
```
|
||||
redis-cli --eval unlock.script lock_key , unique_value
|
||||
|
||||
```
|
||||
|
||||
你可能也注意到了,在释放锁操作中,我们使用了Lua脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而Redis在执行Lua脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
|
||||
|
||||
好了,到这里,你了解了如何使用SET命令和Lua脚本在Redis单节点上实现分布式锁。但是,我们现在只用了一个Redis实例来保存锁变量,如果这个Redis实例发生故障宕机了,那么锁变量就没有了。此时,客户端也无法进行锁操作了,这就会影响到业务的正常执行。所以,我们在实现分布式锁时,还需要保证锁的可靠性。那怎么提高呢?这就要提到基于多个Redis节点实现分布式锁的方式了。
|
||||
|
||||
## 基于多个Redis节点实现高可靠的分布式锁
|
||||
|
||||
当我们要实现高可靠的分布式锁时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。“一定的步骤和规则”是指啥呢?其实就是分布式锁的算法。
|
||||
|
||||
为了避免Redis实例故障而导致的锁无法工作的问题,Redis的开发者Antirez提出了分布式锁算法Redlock。
|
||||
|
||||
Redlock算法的基本思路,是让客户端和多个独立的Redis实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个Redis实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
|
||||
|
||||
我们来具体看下Redlock算法的执行步骤。Redlock算法的实现需要有N个独立的Redis实例。接下来,我们可以分成3步来完成加锁操作。
|
||||
|
||||
**第一步是,客户端获取当前时间。**
|
||||
|
||||
**第二步是,客户端按顺序依次向N个Redis实例执行加锁操作。**
|
||||
|
||||
这里的加锁操作和在单实例上执行的加锁操作一样,使用SET命令,带上NX,EX/PX选项,以及带上客户端的唯一标识。当然,如果某个Redis实例发生故障了,为了保证在这种情况下,Redlock算法能够继续运行,我们需要给加锁操作设置一个超时时间。
|
||||
|
||||
如果客户端在和一个Redis实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个Redis实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
|
||||
|
||||
**第三步是,一旦客户端完成了和所有Redis实例的加锁操作,客户端就要计算整个加锁过程的总耗时。**
|
||||
|
||||
客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
|
||||
|
||||
- 条件一:客户端从超过半数(大于等于 N/2+1)的Redis实例上成功获取到了锁;
|
||||
- 条件二:客户端获取锁的总耗时没有超过锁的有效时间。
|
||||
|
||||
在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
|
||||
|
||||
当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有Redis节点发起释放锁的操作。
|
||||
|
||||
在Redlock算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的Lua脚本就可以了。这样一来,只要N个Redis实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。
|
||||
|
||||
所以,在实际的业务应用中,如果你想要提升分布式锁的可靠性,就可以通过Redlock算法来实现。
|
||||
|
||||
## 小结
|
||||
|
||||
分布式锁是由共享存储系统维护的变量,多个客户端可以向共享存储系统发送命令进行加锁或释放锁操作。Redis作为一个共享存储系统,可以用来实现分布式锁。
|
||||
|
||||
在基于单个Redis实例实现分布式锁时,对于加锁操作,我们需要满足三个条件。
|
||||
|
||||
1. 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用SET命令带上NX选项来实现加锁;
|
||||
1. 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在SET命令执行时加上EX/PX选项,设置其过期时间;
|
||||
1. 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用SET命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端。
|
||||
|
||||
和加锁类似,释放锁也包含了读取锁变量值、判断锁变量值和删除锁变量三个操作,不过,我们无法使用单个命令来实现,所以,我们可以采用Lua脚本执行释放锁操作,通过Redis原子性地执行Lua脚本,来保证释放锁操作的原子性。
|
||||
|
||||
不过,基于单个Redis实例实现分布式锁时,会面临实例异常或崩溃的情况,这会导致实例无法提供锁操作,正因为此,Redis也提供了Redlock算法,用来实现基于多个实例的分布式锁。这样一来,锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。Redlock算法是实现高可靠分布式锁的一种有效解决方案,你可以在实际应用中把它用起来。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题。这节课,我提到,我们可以使用SET命令带上NX和EX/PX选项进行加锁操作,那么,我想请你再思考一下,我们是否可以用下面的方式来实现加锁操作呢?
|
||||
|
||||
```
|
||||
// 加锁
|
||||
SETNX lock_key unique_value
|
||||
EXPIRE lock_key 10S
|
||||
// 业务逻辑
|
||||
DO THINGS
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
245
极客时间专栏/Redis核心技术与实战/实践篇/31 | 事务机制:Redis能实现ACID属性吗?.md
Normal file
245
极客时间专栏/Redis核心技术与实战/实践篇/31 | 事务机制:Redis能实现ACID属性吗?.md
Normal file
@@ -0,0 +1,245 @@
|
||||
<audio id="audio" title="31 | 事务机制:Redis能实现ACID属性吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/eb/3053a049b7df4e99db44167310569eeb.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
事务是数据库的一个重要功能。所谓的事务,就是指对数据进行读写的一系列操作。事务在执行时,会提供专门的属性保证,包括原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是ACID属性。这些属性既包括了对事务执行结果的要求,也有对数据库在事务执行前后的数据状态变化的要求。
|
||||
|
||||
那么,Redis可以完全保证ACID属性吗?毕竟,如果有些属性在一些场景下不能保证的话,很可能会导致数据出错,所以,我们必须要掌握Redis对这些属性的支持情况,并且提前准备应对策略。
|
||||
|
||||
接下来,我们就先了解ACID属性对事务执行的具体要求,有了这个知识基础后,我们才能准确地判断Redis的事务机制能否保证ACID属性。
|
||||
|
||||
## 事务ACID属性的要求
|
||||
|
||||
首先来看原子性。原子性的要求很明确,就是一个事务中的多个操作必须都完成,或者都不完成。业务应用使用事务时,原子性也是最被看重的一个属性。
|
||||
|
||||
我给你举个例子。假如用户在一个订单中购买了两个商品A和B,那么,数据库就需要把这两个商品的库存都进行扣减。如果只扣减了一个商品的库存,那么,这个订单完成后,另一个商品的库存肯定就错了。
|
||||
|
||||
第二个属性是一致性。这个很容易理解,就是指数据库中的数据在事务执行前后是一致的。
|
||||
|
||||
第三个属性是隔离性。它要求数据库在执行一个事务时,其它操作无法存取到正在执行事务访问的数据。
|
||||
|
||||
我还是借助用户下单的例子给你解释下。假设商品A和B的现有库存分别是5和10,用户X对A、B下单的数量分别是3、6。如果事务不具备隔离性,在用户X下单事务执行的过程中,用户Y一下子也购买了5件B,这和X购买的6件B累加后,就超过B的总库存值了,这就不符合业务要求了。
|
||||
|
||||
最后一个属性是持久性。数据库执行事务后,数据的修改要被持久化保存下来。当数据库重启后,数据的值需要是被修改后的值。
|
||||
|
||||
了解了ACID属性的具体要求后,我们再来看下Redis是如何实现事务机制的。
|
||||
|
||||
## Redis如何实现事务?
|
||||
|
||||
事务的执行过程包含三个步骤,Redis提供了MULTI、EXEC两个命令来完成这三个步骤。下面我们来分析下。
|
||||
|
||||
第一步,客户端要使用一个命令显式地表示一个事务的开启。在Redis中,这个命令就是MULTI。
|
||||
|
||||
第二步,客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端。这些操作就是Redis本身提供的数据读写命令,例如GET、SET等。不过,这些命令虽然被客户端发送到了服务器端,但Redis实例只是把这些命令暂存到一个命令队列中,并不会立即执行。
|
||||
|
||||
第三步,客户端向服务器端发送提交事务的命令,让数据库实际执行第二步中发送的具体操作。Redis提供的**EXEC命令**就是执行事务提交的。当服务器端收到EXEC命令后,才会实际执行命令队列中的所有命令。
|
||||
|
||||
下面的代码就显示了使用MULTI和EXEC执行一个事务的过程,你可以看下。
|
||||
|
||||
```
|
||||
#开启事务
|
||||
127.0.0.1:6379> MULTI
|
||||
OK
|
||||
#将a:stock减1,
|
||||
127.0.0.1:6379> DECR a:stock
|
||||
QUEUED
|
||||
#将b:stock减1
|
||||
127.0.0.1:6379> DECR b:stock
|
||||
QUEUED
|
||||
#实际执行事务
|
||||
127.0.0.1:6379> EXEC
|
||||
1) (integer) 4
|
||||
2) (integer) 9
|
||||
|
||||
```
|
||||
|
||||
我们假设a:stock、b:stock两个键的初始值是5和10。在MULTI命令后执行的两个DECR命令,是把a:stock、b:stock两个键的值分别减1,它们执行后的返回结果都是QUEUED,这就表示,这些操作都被暂存到了命令队列,还没有实际执行。等到执行了EXEC命令后,可以看到返回了4、9,这就表明,两个DECR命令已经成功地执行了。
|
||||
|
||||
好了,通过使用MULTI和EXEC命令,我们可以实现多个操作的共同执行,但是这符合事务要求的ACID属性吗?接下来,我们就来具体分析下。
|
||||
|
||||
## Redis的事务机制能保证哪些属性?
|
||||
|
||||
原子性是事务操作最重要的一个属性,所以,我们先来分析下Redis事务机制能否保证原子性。
|
||||
|
||||
### 原子性
|
||||
|
||||
如果事务正常执行,没有发生任何错误,那么,MULTI和EXEC配合使用,就可以保证多个操作都完成。但是,如果事务执行发生错误了,原子性还能保证吗?我们需要分三种情况来看。
|
||||
|
||||
第一种情况是,**在执行EXEC命令前,客户端发送的操作命令本身就有错误**(比如语法错误,使用了不存在的命令),在命令入队时就被Redis实例判断出来了。
|
||||
|
||||
对于这种情况,在命令入队时,Redis就会报错并且记录下这个错误。此时,我们还能继续提交命令操作。等到执行了EXEC命令之后,Redis就会拒绝执行所有提交的命令操作,返回事务失败的结果。这样一来,事务中的所有命令都不会再被执行了,保证了原子性。
|
||||
|
||||
我们来看一个因为事务操作入队时发生错误,而导致事务失败的小例子。
|
||||
|
||||
```
|
||||
#开启事务
|
||||
127.0.0.1:6379> MULTI
|
||||
OK
|
||||
#发送事务中的第一个操作,但是Redis不支持该命令,返回报错信息
|
||||
127.0.0.1:6379> PUT a:stock 5
|
||||
(error) ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`,
|
||||
#发送事务中的第二个操作,这个操作是正确的命令,Redis把该命令入队
|
||||
127.0.0.1:6379> DECR b:stock
|
||||
QUEUED
|
||||
#实际执行事务,但是之前命令有错误,所以Redis拒绝执行
|
||||
127.0.0.1:6379> EXEC
|
||||
(error) EXECABORT Transaction discarded because of previous errors.
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,事务里包含了一个Redis本身就不支持的PUT命令,所以,在PUT命令入队时,Redis就报错了。虽然,事务里还有一个正确的DECR命令,但是,在最后执行EXEC命令后,整个事务被放弃执行了。
|
||||
|
||||
我们再来看第二种情况。
|
||||
|
||||
和第一种情况不同的是,**事务操作入队时,命令和操作的数据类型不匹配,但Redis实例没有检查出错误**。但是,在执行完EXEC命令以后,Redis实际执行这些事务操作时,就会报错。不过,需要注意的是,虽然Redis会对错误命令报错,但还是会把正确的命令执行完。在这种情况下,事务的原子性就无法得到保证了。
|
||||
|
||||
举个小例子。事务中的LPOP命令对String类型数据进行操作,入队时没有报错,但是,在EXEC执行时报错了。LPOP命令本身没有执行成功,但是事务中的DECR命令却成功执行了。
|
||||
|
||||
```
|
||||
#开启事务
|
||||
127.0.0.1:6379> MULTI
|
||||
OK
|
||||
#发送事务中的第一个操作,LPOP命令操作的数据类型不匹配,此时并不报错
|
||||
127.0.0.1:6379> LPOP a:stock
|
||||
QUEUED
|
||||
#发送事务中的第二个操作
|
||||
127.0.0.1:6379> DECR b:stock
|
||||
QUEUED
|
||||
#实际执行事务,事务第一个操作执行报错
|
||||
127.0.0.1:6379> EXEC
|
||||
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
|
||||
2) (integer) 8
|
||||
|
||||
```
|
||||
|
||||
看到这里,你可能有个疑问,传统数据库(例如MySQL)在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态,那么,在刚才的例子中,如果命令实际执行时报错了,是不是可以用回滚机制恢复原来的数据呢?
|
||||
|
||||
其实,Redis中并没有提供回滚机制。虽然Redis提供了DISCARD命令,但是,这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
|
||||
|
||||
DISCARD命令具体怎么用呢?我们来看下下面的代码。
|
||||
|
||||
```
|
||||
#读取a:stock的值4
|
||||
127.0.0.1:6379> GET a:stock
|
||||
"4"
|
||||
#开启事务
|
||||
127.0.0.1:6379> MULTI
|
||||
OK
|
||||
#发送事务的第一个操作,对a:stock减1
|
||||
127.0.0.1:6379> DECR a:stock
|
||||
QUEUED
|
||||
#执行DISCARD命令,主动放弃事务
|
||||
127.0.0.1:6379> DISCARD
|
||||
OK
|
||||
#再次读取a:stock的值,值没有被修改
|
||||
127.0.0.1:6379> GET a:stock
|
||||
"4"
|
||||
|
||||
```
|
||||
|
||||
这个例子中,a:stock键的值一开始为4,然后,我们执行一个事务,想对a:stock的值减1。但是,在事务的最后,我们执行的是DISCARD命令,所以事务就被放弃了。我们再次查看a:stock的值,会发现仍然为4。
|
||||
|
||||
最后,我们再来看下第三种情况:**在执行事务的EXEC命令时,Redis实例发生了故障,导致事务执行失败**。
|
||||
|
||||
在这种情况下,如果Redis开启了AOF日志,那么,只会有部分的事务操作被记录到AOF日志中。我们需要使用redis-check-aof工具检查AOF日志文件,这个工具可以把未完成的事务操作从AOF文件中去除。这样一来,我们使用AOF恢复实例后,事务操作不会再被执行,从而保证了原子性。
|
||||
|
||||
当然,如果AOF日志并没有开启,那么实例重启后,数据也都没法恢复了,此时,也就谈不上原子性了。
|
||||
|
||||
好了,到这里,你了解了Redis对事务原子性属性的保证情况,我们来简单小结下:
|
||||
|
||||
- 命令入队时就报错,会放弃事务执行,保证原子性;
|
||||
- 命令入队时没报错,实际执行时报错,不保证原子性;
|
||||
- EXEC命令执行时实例故障,如果开启了AOF日志,可以保证原子性。
|
||||
|
||||
接下来,我们再来学习下一致性属性的保证情况。
|
||||
|
||||
### 一致性
|
||||
|
||||
事务的一致性保证会受到错误命令、实例故障的影响。所以,我们按照命令出错和实例故障的发生时机,分成三种情况来看。
|
||||
|
||||
**情况一:命令入队时就报错**
|
||||
|
||||
在这种情况下,事务本身就会被放弃执行,所以可以保证数据库的一致性。
|
||||
|
||||
**情况二:命令入队时没报错,实际执行时报错**
|
||||
|
||||
在这种情况下,有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性。
|
||||
|
||||
**情况三:EXEC命令执行时实例发生故障**
|
||||
|
||||
在这种情况下,实例故障后会进行重启,这就和数据恢复的方式有关了,我们要根据实例是否开启了RDB或AOF来分情况讨论下。
|
||||
|
||||
如果我们没有开启RDB或AOF,那么,实例故障重启后,数据都没有了,数据库是一致的。
|
||||
|
||||
如果我们使用了RDB快照,因为RDB快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到RDB快照中,使用RDB快照进行恢复时,数据库里的数据也是一致的。
|
||||
|
||||
如果我们使用了AOF日志,而事务操作还没有被记录到AOF日志时,实例就发生了故障,那么,使用AOF日志恢复的数据库数据是一致的。如果只有部分操作被记录到了AOF日志,我们可以使用redis-check-aof清除事务中已经完成的操作,数据库恢复后也是一致的。
|
||||
|
||||
所以,总结来说,在命令执行错误或Redis发生故障的情况下,Redis事务机制对一致性属性是有保证的。接下来,我们再继续分析下隔离性。
|
||||
|
||||
### 隔离性
|
||||
|
||||
事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC命令执行前)和命令实际执行(EXEC命令执行后)两个阶段,所以,我们就针对这两个阶段,分成两种情况来分析:
|
||||
|
||||
1. 并发操作在EXEC命令前执行,此时,隔离性的保证要使用WATCH机制来实现,否则隔离性无法保证;
|
||||
1. 并发操作在EXEC命令后执行,此时,隔离性可以保证。
|
||||
|
||||
我们先来看第一种情况。一个事务的EXEC命令还没有执行时,事务的命令操作是暂存在命令队列中的。此时,如果有其它的并发操作,我们就需要看事务是否使用了WATCH机制。
|
||||
|
||||
WATCH机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用EXEC命令执行时,WATCH机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。
|
||||
|
||||
WATCH机制的具体实现是由WATCH命令实现的,我给你举个例子,你可以看下下面的图,进一步理解下WATCH命令的使用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/73/4f8589410f77df16311dd29131676373.jpg" alt="">
|
||||
|
||||
我来给你具体解释下图中的内容。
|
||||
|
||||
在t1时,客户端X向实例发送了WATCH命令。实例收到WATCH命令后,开始监测a:stock的值的变化情况。
|
||||
|
||||
紧接着,在t2时,客户端X把MULTI命令和DECR命令发送给实例,实例把DECR命令暂存入命令队列。
|
||||
|
||||
在t3时,客户端Y也给实例发送了一个DECR命令,要修改a:stock的值,实例收到命令后就直接执行了。
|
||||
|
||||
等到t4时,实例收到客户端X发送的EXEC命令,但是,实例的WATCH机制发现a:stock已经被修改了,就会放弃事务执行。这样一来,事务的隔离性就可以得到保证了。
|
||||
|
||||
当然,如果没有使用WATCH机制,在EXEC命令前执行的并发操作是会对数据进行读写的。而且,在执行EXEC命令的时候,事务要操作的数据已经改变了,在这种情况下,Redis并没有做到让事务对其它操作隔离,隔离性也就没有得到保障。下面这张图显示了没有WATCH机制时的情况,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/57/8ca37debfff91282b9c62a25fd7e9a57.jpg" alt="">
|
||||
|
||||
在t2时刻,客户端X发送的EXEC命令还没有执行,但是客户端Y的DECR命令就执行了,此时,a:stock的值会被修改,这就无法保证X发起的事务的隔离性了。
|
||||
|
||||
刚刚说的是并发操作在EXEC命令前执行的情况,下面我再来说一说第二种情况:**并发操作在EXEC命令之后被服务器端接收并执行**。
|
||||
|
||||
因为Redis是用单线程执行命令,而且,EXEC命令执行后,Redis会保证先把命令队列中的所有命令执行完。所以,在这种情况下,并发操作不会破坏事务的隔离性,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/ae/11a1eff930920a0b423a6e46c23f44ae.jpg" alt="">
|
||||
|
||||
最后,我们来分析一下Redis事务的持久性属性保证情况。
|
||||
|
||||
### 持久性
|
||||
|
||||
因为Redis是内存数据库,所以,数据是否持久化保存完全取决于Redis的持久化配置模式。
|
||||
|
||||
如果Redis没有使用RDB或AOF,那么事务的持久化属性肯定得不到保证。如果Redis使用了RDB模式,那么,在一个事务执行后,而下一次的RDB快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。
|
||||
|
||||
如果Redis采用了AOF模式,因为AOF模式的三种配置选项no、everysec和always都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。
|
||||
|
||||
所以,不管Redis采用什么持久化模式,事务的持久性属性是得不到保证的。
|
||||
|
||||
## 小结
|
||||
|
||||
在这节课上,我们学习了Redis中的事务实现。Redis通过MULTI、EXEC、DISCARD和WATCH四个命令来支持事务机制,这4个命令的作用,我总结在下面的表中,你可以再看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/50/9571308df0620214d7ccb2f2cc73a250.jpg" alt="">
|
||||
|
||||
事务的ACID属性是我们使用事务进行正确操作的基本要求。通过这节课的分析,我们了解到了,Redis的事务机制可以保证一致性和隔离性,但是无法保证持久性。不过,因为Redis本身是内存数据库,持久性并不是一个必须的属性,我们更加关注的还是原子性、一致性和隔离性这三个属性。
|
||||
|
||||
原子性的情况比较复杂,只有当事务中使用的命令语法有误时,原子性得不到保证,在其它情况下,事务都可以原子性执行。
|
||||
|
||||
所以,我给你一个小建议:**严格按照Redis的命令规范进行程序开发,并且通过code review确保命令的正确性**。这样一来,Redis的事务机制就能被应用在实践中,保证多操作的正确执行。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,在执行事务时,如果Redis实例发生故障,而Redis使用了RDB机制,那么,事务的原子性还能得到保证吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
161
极客时间专栏/Redis核心技术与实战/实践篇/32 | Redis主从同步与故障切换,有哪些坑?.md
Normal file
161
极客时间专栏/Redis核心技术与实战/实践篇/32 | Redis主从同步与故障切换,有哪些坑?.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<audio id="audio" title="32 | Redis主从同步与故障切换,有哪些坑?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/de/375630900d9ef3ce58c9b7072e2256de.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
Redis的主从同步机制不仅可以让从库服务更多的读请求,分担主库的压力,而且还能在主库发生故障时,进行主从库切换,提供高可靠服务。
|
||||
|
||||
不过,在实际使用主从机制的时候,我们很容易踩到一些坑。这节课,我就向你介绍3个坑,分别是主从数据不一致、读到过期数据,以及配置项设置得不合理从而导致服务挂掉。
|
||||
|
||||
一旦踩到这些坑,业务应用不仅会读到错误数据,而且很可能会导致Redis无法正常使用,我们必须要全面地掌握这些坑的成因,提前准备一套规避方案。不过,即使不小心掉进了陷阱里,也不要担心,我还会给你介绍相应的解决方案。
|
||||
|
||||
好了,话不多说,下面我们先来看看第一个坑:主从数据不一致。
|
||||
|
||||
## 主从数据不一致
|
||||
|
||||
主从数据不一致,就是指客户端从从库中读取到的值和主库中的最新值并不一致。
|
||||
|
||||
举个例子,假设主从库之前保存的用户年龄值是19,但是主库接收到了修改命令,已经把这个数据更新为20了,但是,从库中的值仍然是19。那么,如果客户端从从库中读取用户年龄值,就会读到旧值。
|
||||
|
||||
那为啥会出现这个坑呢?其实这是因为**主从库间的命令复制是异步进行的**。
|
||||
|
||||
具体来说,在主从库命令传播阶段,主库收到新的写命令后,会发送给从库。但是,主库并不会等到从库实际执行完命令后,再把结果返回给客户端,而是主库自己在本地执行完命令后,就会向客户端返回结果了。如果从库还没有执行主库同步过来的命令,主从库间的数据就不一致了。
|
||||
|
||||
那在什么情况下,从库会滞后执行同步命令呢?其实,这里主要有两个原因。
|
||||
|
||||
一方面,主从库间的网络可能会有传输延迟,所以从库不能及时地收到主库发送的命令,从库上执行同步命令的时间就会被延后。
|
||||
|
||||
另一方面,即使从库及时收到了主库的命令,但是,也可能会因为正在处理其它复杂度高的命令(例如集合操作命令)而阻塞。此时,从库需要处理完当前的命令,才能执行主库发送的命令操作,这就会造成主从数据不一致。而在主库命令被滞后处理的这段时间内,主库本身可能又执行了新的写操作。这样一来,主从库间的数据不一致程度就会进一步加剧。
|
||||
|
||||
那么,我们该怎么应对呢?我给你提供两种方法。
|
||||
|
||||
首先,**在硬件环境配置方面,我们要尽量保证主从库间的网络连接状况良好**。例如,我们要避免把主从库部署在不同的机房,或者是避免把网络通信密集的应用(例如数据分析应用)和Redis主从库部署在一起。
|
||||
|
||||
另外,**我们还可以开发一个外部程序来监控主从库间的复制进度**。
|
||||
|
||||
因为Redis的INFO replication命令可以查看主库接收写命令的进度信息(master_repl_offset)和从库复制写命令的进度信息(slave_repl_offset),所以,我们就可以开发一个监控程序,先用INFO replication命令查到主、从库的进度,然后,我们用master_repl_offset减去slave_repl_offset,这样就能得到从库和主库间的复制进度差值了。
|
||||
|
||||
如果某个从库的进度差值大于我们预设的阈值,我们可以让客户端不再和这个从库连接进行数据读取,这样就可以减少读到不一致数据的情况。不过,为了避免出现客户端和所有从库都不能连接的情况,我们需要把复制进度差值的阈值设置得大一些。
|
||||
|
||||
我们在应用Redis时,可以周期性地运行这个流程来监测主从库间的不一致情况。为了帮助你更好地理解这个方法,我画了一张流程图,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/05/3a89935297fb5b76bfc4808128aaf905.jpg" alt="">
|
||||
|
||||
当然,监控程序可以一直监控着从库的复制进度,当从库的复制进度又赶上主库时,我们就允许客户端再次跟这些从库连接。
|
||||
|
||||
除了主从数据不一致以外,我们有时还会在从库中读到过期的数据,这是怎么回事呢?接下来,我们就来详细分析一下。
|
||||
|
||||
## 读取过期数据
|
||||
|
||||
我们在使用Redis主从集群时,有时会读到过期数据。例如,数据X的过期时间是202010240900,但是客户端在202010240910时,仍然可以从从库中读到数据X。一个数据过期后,应该是被删除的,客户端不能再读取到该数据,但是,Redis为什么还能在从库中读到过期的数据呢?
|
||||
|
||||
其实,这是由Redis的过期数据删除策略引起的。我来给你具体解释下。
|
||||
|
||||
**Redis同时使用了两种策略来删除过期的数据,分别是惰性删除策略和定期删除策略**。
|
||||
|
||||
先说惰性删除策略。当一个数据的过期时间到了以后,并不会立即删除数据,而是等到再有请求来读写这个数据时,对数据进行检查,如果发现数据已经过期了,再删除这个数据。
|
||||
|
||||
这个策略的好处是尽量减少删除操作对CPU资源的使用,对于用不到的数据,就不再浪费时间进行检查和删除了。但是,这个策略会导致大量已经过期的数据留存在内存中,占用较多的内存资源。所以,Redis在使用这个策略的同时,还使用了第二种策略:定期删除策略。
|
||||
|
||||
定期删除策略是指,Redis每隔一段时间(默认100ms),就会随机选出一定数量的数据,检查它们是否过期,并把其中过期的数据删除,这样就可以及时释放一些内存。
|
||||
|
||||
清楚了这两个删除策略,我们再来看看它们为什么会导致读取到过期数据。
|
||||
|
||||
首先,虽然定期删除策略可以释放一些内存,但是,Redis为了避免过多删除操作对性能产生影响,每次随机检查数据的数量并不多。如果过期数据很多,并且一直没有再被访问的话,这些数据就会留存在Redis实例中。业务应用之所以会读到过期数据,这些留存数据就是一个重要因素。
|
||||
|
||||
其次,惰性删除策略实现后,数据只有被再次访问时,才会被实际删除。如果客户端从主库上读取留存的过期数据,主库会触发删除操作,此时,客户端并不会读到过期数据。但是,从库本身不会执行删除操作,如果客户端在从库中访问留存的过期数据,从库并不会触发数据删除。那么,从库会给客户端返回过期数据吗?
|
||||
|
||||
这就和你使用的Redis版本有关了。如果你使用的是Redis 3.2之前的版本,那么,从库在服务读请求时,并不会判断数据是否过期,而是会返回过期数据。在3.2版本后,Redis做了改进,如果读取的数据已经过期了,从库虽然不会删除,但是会返回空值,这就避免了客户端读到过期数据。所以,**在应用主从集群时,尽量使用Redis 3.2及以上版本**。
|
||||
|
||||
你可能会问,只要使用了Redis 3.2后的版本,就不会读到过期数据了吗?其实还是会的。
|
||||
|
||||
为啥会这样呢?这跟Redis用于设置过期时间的命令有关系,有些命令给数据设置的过期时间在从库上可能会被延后,导致应该过期的数据又在从库上被读取到了,我来给你具体解释下。
|
||||
|
||||
我先给你介绍下这些命令。设置数据过期时间的命令一共有4个,我们可以把它们分成两类:
|
||||
|
||||
- EXPIRE和PEXPIRE:它们给数据设置的是**从命令执行时开始计算的存活时间**;
|
||||
- EXPIREAT和PEXPIREAT:**它们会直接把数据的过期时间设置为具体的一个时间点**。
|
||||
|
||||
这4个命令的参数和含义如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/e1/06e8cb2f1af320d450a29326a876f4e1.jpg" alt="">
|
||||
|
||||
为了方便你理解,我给你举两个例子。
|
||||
|
||||
第一个例子是使用EXPIRE命令,当执行下面的命令时,我们就把testkey的过期时间设置为60s后。
|
||||
|
||||
```
|
||||
EXPIRE testkey 60
|
||||
|
||||
```
|
||||
|
||||
第二个例子是使用EXPIREAT命令,例如,我们执行下面的命令,就可以让testkey在2020年10月24日上午9点过期,命令中的1603501200就是以秒数时间戳表示的10月24日上午9点。
|
||||
|
||||
```
|
||||
EXPIREAT testkey 1603501200
|
||||
|
||||
```
|
||||
|
||||
好了,知道了这些命令,下面我们来看看这些命令如何导致读到过期数据。
|
||||
|
||||
当主从库全量同步时,如果主库接收到了一条EXPIRE命令,那么,主库会直接执行这条命令。这条命令会在全量同步完成后,发给从库执行。而从库在执行时,就会在当前时间的基础上加上数据的存活时间,这样一来,从库上数据的过期时间就会比主库上延后了。
|
||||
|
||||
这么说可能不太好理解,我再给你举个例子。
|
||||
|
||||
假设当前时间是2020年10月24日上午9点,主从库正在同步,主库收到了一条命令:EXPIRE testkey 60,这就表示,testkey的过期时间就是24日上午9点1分,主库直接执行了这条命令。
|
||||
|
||||
但是,主从库全量同步花费了2分钟才完成。等从库开始执行这条命令时,时间已经是9点2分了。而EXPIRE命令是把testkey的过期时间设置为当前时间的60s后,也就是9点3分。如果客户端在9点2分30秒时在从库上读取testkey,仍然可以读到testkey的值。但是,testkey实际上已经过期了。
|
||||
|
||||
为了避免这种情况,我给你的建议是,**在业务应用中使用EXPIREAT/PEXPIREAT命令,把数据的过期时间设置为具体的时间点,避免读到过期数据。**
|
||||
|
||||
好了,我们先简单地总结下刚刚学过的这两个典型的坑。
|
||||
|
||||
- 主从数据不一致。Redis采用的是异步复制,所以无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。我给你提供了应对方法:保证良好网络环境,以及使用程序监控从库复制进度,一旦从库复制进度超过阈值,不让客户端连接从库。
|
||||
- 对于读到过期数据,这是可以提前规避的,一个方法是,使用Redis 3.2及以上版本;另外,你也可以使用EXPIREAT/PEXPIREAT命令设置过期时间,避免从库上的数据过期时间滞后。不过,这里有个地方需要注意下,**因为EXPIREAT/PEXPIREAT设置的是时间点,所以,主从节点上的时钟要保持一致,具体的做法是,让主从节点和相同的NTP服务器(时间服务器)进行时钟同步**。
|
||||
|
||||
除了同步过程中有坑以外,主从故障切换时,也会因为配置不合理而踩坑。接下来,我向你介绍两个服务挂掉的情况,都是由不合理配置项引起的。
|
||||
|
||||
## 不合理配置项导致的服务挂掉
|
||||
|
||||
这里涉及到的配置项有两个,分别是**protected-mode和cluster-node-timeout。**
|
||||
|
||||
**1.protected-mode 配置项**
|
||||
|
||||
这个配置项的作用是限定哨兵实例能否被其他服务器访问。当这个配置项设置为yes时,哨兵实例只能在部署的服务器本地进行访问。当设置为no时,其他服务器也可以访问这个哨兵实例。
|
||||
|
||||
正因为这样,如果protected-mode被设置为yes,而其余哨兵实例部署在其它服务器,那么,这些哨兵实例间就无法通信。当主库故障时,哨兵无法判断主库下线,也无法进行主从切换,最终Redis服务不可用。
|
||||
|
||||
所以,我们在应用主从集群时,要注意将protected-mode 配置项设置为no,并且将bind配置项设置为其它哨兵实例的IP地址。这样一来,只有在bind中设置了IP地址的哨兵,才可以访问当前实例,既保证了实例间能够通信进行主从切换,也保证了哨兵的安全性。
|
||||
|
||||
我们来看一个简单的小例子。如果设置了下面的配置项,那么,部署在192.168.10.3/4/5这三台服务器上的哨兵实例就可以相互通信,执行主从切换。
|
||||
|
||||
```
|
||||
protected-mode no
|
||||
bind 192.168.10.3 192.168.10.4 192.168.10.5
|
||||
|
||||
```
|
||||
|
||||
**2.cluster-node-timeout配置项**
|
||||
|
||||
**这个配置项设置了Redis Cluster中实例响应心跳消息的超时时间**。
|
||||
|
||||
当我们在Redis Cluster集群中为每个实例配置了“一主一从”模式时,如果主实例发生故障,从实例会切换为主实例,受网络延迟和切换操作执行的影响,切换时间可能较长,就会导致实例的心跳超时(超出cluster-node-timeout)。实例超时后,就会被Redis Cluster判断为异常。而Redis Cluster正常运行的条件就是,有半数以上的实例都能正常运行。
|
||||
|
||||
所以,如果执行主从切换的实例超过半数,而主从切换时间又过长的话,就可能有半数以上的实例心跳超时,从而可能导致整个集群挂掉。所以,**我建议你将cluster-node-timeout调大些(例如10到20秒)**。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis主从库同步时可能出现的3个坑,分别是主从数据不一致、读取到过期数据和不合理配置项导致服务挂掉。
|
||||
|
||||
为了方便你掌握,我把这些坑的成因和解决方法汇总在下面的这张表中,你可以再回顾下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/93/9fb7a033987c7b5edc661f4de58ef093.jpg" alt="">
|
||||
|
||||
最后,关于主从库数据不一致的问题,我还想再给你提一个小建议:Redis中的slave-serve-stale-data配置项设置了从库能否处理数据读写命令,你可以把它设置为no。这样一来,从库只能服务INFO、SLAVEOF命令,这就可以避免在从库中读到不一致的数据了。
|
||||
|
||||
不过,你要注意下这个配置项和slave-read-only的区别,slave-read-only是设置从库能否处理写命令,slave-read-only设置为yes时,从库只能处理读请求,无法处理写请求,你可不要搞混了。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,我们把slave-read-only设置为no,让从库也能直接删除数据,以此来避免读到过期数据,你觉得,这是一个好方法吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
115
极客时间专栏/Redis核心技术与实战/实践篇/33 | 脑裂:一次奇怪的数据丢失.md
Normal file
115
极客时间专栏/Redis核心技术与实战/实践篇/33 | 脑裂:一次奇怪的数据丢失.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="33 | 脑裂:一次奇怪的数据丢失" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/e1/1c58b31b9b4cb3c3aa1bccd5dc83d6e1.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在使用主从集群时,我曾遇到过这样一个问题:我们的主从集群有1个主库、5个从库和3个哨兵实例,在使用的过程中,我们发现客户端发送的一些数据丢失了,这直接影响到了业务层的数据可靠性。
|
||||
|
||||
通过一系列的问题排查,我们才知道,这其实是主从集群中的脑裂问题导致的。
|
||||
|
||||
所谓的脑裂,就是指在主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。
|
||||
|
||||
那么,主从集群中为什么会发生脑裂?脑裂为什么又会导致数据丢失呢?我们该如何避免脑裂的发生呢?这节课,我就结合我遇见的这个真实问题,带你一起分析和定位问题,帮助你掌握脑裂的成因、后果和应对方法。
|
||||
|
||||
## 为什么会发生脑裂?
|
||||
|
||||
刚才我提到,我最初发现的问题是,在主从集群中,客户端发送的数据丢失了。所以,我们首先要弄明白,为什么数据会丢失?是不是数据同步出了问题?
|
||||
|
||||
### 第一步:确认是不是数据同步出现了问题
|
||||
|
||||
在主从集群中发生数据丢失,最常见的原因就是**主库的数据还没有同步到从库,结果主库发生了故障,等从库升级为主库后,未同步的数据就丢失了。**
|
||||
|
||||
如下图所示,新写入主库的数据a:1、b:3,就因为在主库故障前未同步到从库而丢失了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/a4/46a7bef9a7074b6a46978c2524f92ea4.jpg" alt="">
|
||||
|
||||
如果是这种情况的数据丢失,我们可以通过比对主从库上的复制进度差值来进行判断,也就是计算master_repl_offset和slave_repl_offset的差值。如果从库上的slave_repl_offset小于原主库的master_repl_offset,那么,我们就可以认定数据丢失是由数据同步未完成导致的。
|
||||
|
||||
我们在部署主从集群时,也监测了主库上的master_repl_offset,以及从库上的slave_repl_offset。但是,当我们发现数据丢失后,我们检查了新主库升级前的slave_repl_offset,以及原主库的master_repl_offset,它们是一致的,也就是说,这个升级为新主库的从库,在升级时已经和原主库的数据保持一致了。那么,为什么还会出现客户端发送的数据丢失呢?
|
||||
|
||||
分析到这里,我们的第一个设想就被推翻了。这时,我们想到,所有的数据操作都是从客户端发送给Redis实例的,那么,是不是可以从客户端操作日志中发现问题呢?紧接着,我们就把目光转到了客户端。
|
||||
|
||||
### 第二步:排查客户端的操作日志,发现脑裂现象
|
||||
|
||||
在排查客户端的操作日志时,我们发现,在主从切换后的一段时间内,有一个客户端仍然在和原主库通信,并没有和升级的新主库进行交互。这就相当于主从集群中同时有了两个主库。根据这个迹象,我们就想到了在分布式主从集群发生故障时会出现的一个问题:脑裂。
|
||||
|
||||
但是,不同客户端给两个主库发送数据写操作,按道理来说,只会导致新数据会分布在不同的主库上,并不会造成数据丢失。那么,为什么我们的数据仍然丢失了呢?
|
||||
|
||||
到这里,我们的排查思路又一次中断了。不过,在分析问题时,我们一直认为“从原理出发是追本溯源的好方法”。脑裂是发生在主从切换的过程中,我们猜测,肯定是漏掉了主从集群切换过程中的某个环节,所以,我们把研究的焦点投向了主从切换的执行过程。
|
||||
|
||||
### 第三步:发现是原主库假故障导致的脑裂
|
||||
|
||||
我们是采用哨兵机制进行主从切换的,当主从切换发生时,一定是有超过预设数量(quorum配置项)的哨兵实例和主库的心跳都超时了,才会把主库判断为客观下线,然后,哨兵开始执行切换操作。哨兵切换完成后,客户端会和新主库进行通信,发送请求操作。
|
||||
|
||||
但是,在切换过程中,既然客户端仍然和原主库通信,这就表明,**原主库并没有真的发生故障**(例如主库进程挂掉)。我们猜测,主库是由于某些原因无法处理请求,也没有响应哨兵的心跳,才被哨兵错误地判断为客观下线的。结果,在被判断下线之后,原主库又重新开始处理请求了,而此时,哨兵还没有完成主从切换,客户端仍然可以和原主库通信,客户端发送的写操作就会在原主库上写入数据了。
|
||||
|
||||
为了验证原主库只是“假故障”,我们也查看了原主库所在服务器的资源使用监控记录。
|
||||
|
||||
的确,我们看到原主库所在的机器有一段时间的CPU利用率突然特别高,这是我们在机器上部署的一个数据采集程序导致的。因为这个程序基本把机器的CPU都用满了,导致Redis主库无法响应心跳了,在这个期间内,哨兵就把主库判断为客观下线,开始主从切换了。不过,这个数据采集程序很快恢复正常,CPU的使用率也降下来了。此时,原主库又开始正常服务请求了。
|
||||
|
||||
正因为原主库并没有真的发生故障,我们在客户端操作日志中就看到了和原主库的通信记录。等到从库被升级为新主库后,主从集群里就有两个主库了,到这里,我们就把脑裂发生的原因摸清楚了。
|
||||
|
||||
为了帮助你加深理解,我再画一张图,展示一下脑裂的发生过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/72/1339e1bfe6d07da8477342ba5fyy9872.jpg" alt="">
|
||||
|
||||
弄清楚了脑裂发生的原因后,我们又结合主从切换的原理过程进行了分析,很快就找到数据丢失的原因了。
|
||||
|
||||
## 为什么脑裂会导致数据丢失?
|
||||
|
||||
主从切换后,从库一旦升级为新主库,哨兵就会让原主库执行slave of命令,和新主库重新进行全量同步。而在全量同步执行的最后阶段,原主库需要清空本地的数据,加载新主库发送的RDB文件,这样一来,原主库在主从切换期间保存的新写数据就丢失了。
|
||||
|
||||
下面这张图直观地展示了原主库数据丢失的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/66/959240fa59c2bb9f5ddb7df4b318af66.jpg" alt="">
|
||||
|
||||
到这里,我们就完全弄明白了这个问题的发生过程和原因。
|
||||
|
||||
在主从切换的过程中,如果原主库只是“假故障”,它会触发哨兵启动主从切换,一旦等它从假故障中恢复后,又开始处理请求,这样一来,就会和新主库同时存在,形成脑裂。等到哨兵让原主库和新主库做全量同步后,原主库在切换期间保存的数据就丢失了。
|
||||
|
||||
看到这里,你肯定会很关心,我们该怎么应对脑裂造成的数据丢失问题呢?
|
||||
|
||||
## 如何应对脑裂问题?
|
||||
|
||||
刚刚说了,主从集群中的数据丢失事件,归根结底是因为发生了脑裂。所以,我们必须要找到应对脑裂问题的策略。
|
||||
|
||||
既然问题是出在原主库发生假故障后仍然能接收请求上,我们就开始在主从集群机制的配置项中查找是否有限制主库接收请求的设置。
|
||||
|
||||
通过查找,我们发现,Redis已经提供了两个配置项来限制主库的请求处理,分别是min-slaves-to-write和min-slaves-max-lag。
|
||||
|
||||
- min-slaves-to-write:这个配置项设置了主库能进行数据同步的最少从库数量;
|
||||
- min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送ACK消息的最大延迟(以秒为单位)。
|
||||
|
||||
有了这两个配置项后,我们就可以轻松地应对脑裂问题了。具体咋做呢?
|
||||
|
||||
我们可以把min-slaves-to-write和min-slaves-max-lag这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为N和T。这两个配置项组合后的要求是,主库连接的从库中至少有N个从库,和主库进行数据复制时的ACK消息延迟不能超过T秒,否则,主库就不会再接收客户端的请求了。
|
||||
|
||||
即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行ACK确认了。这样一来,min-slaves-to-write和min-slaves-max-lag的组合要求就无法得到满足,原主库就会被限制接收客户端请求,客户端也就不能在原主库中写入新数据了。
|
||||
|
||||
等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
|
||||
|
||||
我再来给你举个例子。
|
||||
|
||||
假设我们将min-slaves-to-write设置为1,把min-slaves-max-lag设置为12s,把哨兵的down-after-milliseconds设置为10s,主库因为某些原因卡住了15s,导致哨兵判断主库客观下线,开始进行主从切换。同时,因为原主库卡住了15s,没有一个从库能和原主库在12s内进行数据复制,原主库也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了主从切换时可能遇到的脑裂问题。脑裂是指在主从集群中,同时有两个主库都能接收写请求。在Redis的主从切换过程中,如果发生了脑裂,客户端数据就会写入到原主库,如果原主库被降为从库,这些新写入的数据就丢失了。
|
||||
|
||||
脑裂发生的原因主要是原主库发生了假故障,我们来总结下假故障的两个原因。
|
||||
|
||||
1. 和主库部署在同一台服务器上的其他程序临时占用了大量资源(例如CPU资源),导致主库资源使用受限,短时间内无法响应心跳。其它程序不再使用资源时,主库又恢复正常。
|
||||
1. 主库自身遇到了阻塞的情况,例如,处理bigkey或是发生内存swap(你可以复习下[第19讲](https://time.geekbang.org/column/article/287819)中总结的导致实例阻塞的原因),短时间内无法响应心跳,等主库阻塞解除后,又恢复正常的请求处理了。
|
||||
|
||||
为了应对脑裂,你可以在主从集群部署时,通过合理地配置参数min-slaves-to-write和min-slaves-max-lag,来预防脑裂的发生。
|
||||
|
||||
在实际应用中,可能会因为网络暂时拥塞导致从库暂时和主库的ACK消息超时。在这种情况下,并不是主库假故障,我们也不用禁止主库接收请求。
|
||||
|
||||
所以,我给你的建议是,假设从库有K个,可以将min-slaves-to-write设置为K/2+1(如果K等于1,就设为1),将min-slaves-max-lag设置为十几秒(例如10~20s),在这个配置下,如果有一半以上的从库和主库进行的ACK消息延迟超过十几秒,我们就禁止主库接收客户端写请求。
|
||||
|
||||
这样一来,我们可以避免脑裂带来数据丢失的情况,而且,也不会因为只有少数几个从库因为网络阻塞连不上主库,就禁止主库接收请求,增加了系统的鲁棒性。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,假设我们将min-slaves-to-write设置为1,min-slaves-max-lag设置为15s,哨兵的down-after-milliseconds设置为10s,哨兵主从切换需要5s。主库因为某些原因卡住了12s,此时,还会发生脑裂吗?主从切换完成后,数据会丢失吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
173
极客时间专栏/Redis核心技术与实战/实践篇/34 | 第23~33讲课后思考题答案及常见问题答疑.md
Normal file
173
极客时间专栏/Redis核心技术与实战/实践篇/34 | 第23~33讲课后思考题答案及常见问题答疑.md
Normal file
@@ -0,0 +1,173 @@
|
||||
<audio id="audio" title="34 | 第23~33讲课后思考题答案及常见问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/61/3400e8d02ce337b78fe0845yy9548b61.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
今天,又到了我们的答疑时间,我们一起来学习下第23~33讲的课后思考题。同时,我还会给你讲解两道典型问题。
|
||||
|
||||
## 课后思考题答案
|
||||
|
||||
### [第23讲](https://time.geekbang.org/column/article/293929)
|
||||
|
||||
问题:Redis的只读缓存和使用直写策略的读写缓存,都会把数据同步写到后端数据库中,你觉得它们有什么区别吗?
|
||||
|
||||
答案:主要的区别在于,当有缓存数据被修改时,在只读缓存中,业务应用会直接修改数据库,并把缓存中的数据标记为无效;而在读写缓存中,业务应用需要同时修改缓存和数据库。
|
||||
|
||||
我把这两类缓存的优劣势汇总在一张表中,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/51/84ed48ebccd3443f29cba150b5c1a951.jpg" alt="">
|
||||
|
||||
### [第24讲](https://time.geekbang.org/column/article/294640)
|
||||
|
||||
问题:Redis缓存在处理脏数据时,不仅会修改数据,还会把它写回数据库。我们在前面学过Redis的只读缓存模式和两种读写缓存模式(带同步直写的读写模式,带异步写回的读写模式)),请你思考下,Redis缓存对应哪一种或哪几种模式?
|
||||
|
||||
答案:如果我们在使用Redis缓存时,需要把脏数据写回数据库,这就意味着,Redis中缓存的数据可以直接被修改,这就对应了读写缓存模式。更进一步分析的话,脏数据是在被替换出缓存时写回后端数据库的,这就对应了带有异步写回策略的读写缓存模式。
|
||||
|
||||
### [第25讲](https://time.geekbang.org/column/article/295812)
|
||||
|
||||
问题:在只读缓存中对数据进行删改时,需要在缓存中删除相应的缓存值。如果在这个过程中,我们不是删除缓存值,而是直接更新缓存的值,你觉得,和删除缓存值相比,直接更新缓存值有什么好处和不足吗?
|
||||
|
||||
答案:如果我们直接在缓存中更新缓存值,等到下次数据再被访问时,业务应用可以直接从缓存中读取数据,这是它的一大好处。
|
||||
|
||||
不足之处在于,当有数据更新操作时,我们要保证缓存和数据库中的数据是一致的,这就可以采用我在第25讲中介绍的重试或延时双删方法。不过,这样就需要在业务应用中增加额外代码,有一定的开销。
|
||||
|
||||
### [第26讲](https://time.geekbang.org/column/article/296586)
|
||||
|
||||
问题:在讲到缓存雪崩时,我提到,可以采用服务熔断、服务降级、请求限流三种方法来应对。请你思考下,这三个方法可以用来应对缓存穿透问题吗?
|
||||
|
||||
答案:关于这个问题,@徐培同学回答得特别好,他看到了缓存穿透的本质,也理解了穿透和缓存雪崩、击穿场景的区别,我再来回答一下这个问题。
|
||||
|
||||
缓存穿透这个问题的本质是查询了Redis和数据库中没有的数据,而服务熔断、服务降级和请求限流的方法,本质上是为了解决Redis实例没有起到缓存层作用的问题,缓存雪崩和缓存击穿都属于这类问题。
|
||||
|
||||
在缓存穿透的场景下,业务应用是要从Redis和数据库中读取不存在的数据,此时,如果没有人工介入,Redis是无法发挥缓存作用的。
|
||||
|
||||
一个可行的办法就是**事前拦截**,不让这种查询Redis和数据库中都没有的数据的请求发送到数据库层。
|
||||
|
||||
使用布隆过滤器也是一个方法,布隆过滤器在判别数据不存在时,是不会误判的,而且判断速度非常快,一旦判断数据不存在,就立即给客户端返回结果。使用布隆过滤器的好处是既降低了对Redis的查询压力,也避免了对数据库的无效访问。
|
||||
|
||||
另外,这里,有个地方需要注意下,对于缓存雪崩和击穿问题来说,服务熔断、服务降级和请求限流这三种方法属于有损方法,会降低业务吞吐量、拖慢系统响应、降低用户体验。不过,采用这些方法后,随着数据慢慢地重新填充回Redis,Redis还是可以逐步恢复缓存层作用的。
|
||||
|
||||
### [第27讲](https://time.geekbang.org/column/article/297270)
|
||||
|
||||
问题:使用了LFU策略后,缓存还会被污染吗?
|
||||
|
||||
答案:在Redis中,我们使用了LFU策略后,还是有可能发生缓存污染的。@yeek回答得不错,我给你分享下他的答案。
|
||||
|
||||
在一些极端情况下,LFU策略使用的计数器可能会在短时间内达到一个很大值,而计数器的衰减配置项设置得较大,导致计数器值衰减很慢,在这种情况下,数据就可能在缓存中长期驻留。例如,一个数据在短时间内被高频访问,即使我们使用了LFU策略,这个数据也有可能滞留在缓存中,造成污染。
|
||||
|
||||
### [第28讲](https://time.geekbang.org/column/article/298205)
|
||||
|
||||
问题:这节课,我向你介绍的是使用SSD作为内存容量的扩展,增加Redis实例的数据保存量,我想请你来聊一聊,我们可以使用机械硬盘来作为实例容量扩展吗?有什么好处或不足吗?
|
||||
|
||||
答案:这道题有不少同学(例如@Lemon、@Kaito)都分析得不错,我再来总结下使用机械硬盘的优劣势。
|
||||
|
||||
从容量维度来看,机械硬盘的性价比更高,机械硬盘每GB的成本大约在0.1元左右,而SSD每GB的成本大约是0.4~0.6元左右。
|
||||
|
||||
从性能角度来看,机械硬盘(例如SAS盘)的延迟大约在3~5ms,而企业级SSD的读延迟大约是60~80us,写延迟在20us。缓存的负载特征一般是小粒度数据、高并发请求,要求访问延迟低。所以,如果使用机械硬盘作为Pika底层存储设备的话,缓存的访问性能就会降低。
|
||||
|
||||
所以,我的建议是,如果业务应用需要缓存大容量数据,但是对缓存的性能要求不高,就可以使用机械硬盘,否则最好是用SSD。
|
||||
|
||||
### [第29讲](https://time.geekbang.org/column/article/299806)
|
||||
|
||||
问题:Redis在执行Lua脚本时,是可以保证原子性的,那么,在课程里举的Lua脚本例子(lua.script)中,你觉得是否需要把读取客户端ip的访问次数,也就是GET(ip),以及判断访问次数是否超过20的判断逻辑,也加到Lua脚本中吗?代码如下所示:
|
||||
|
||||
```
|
||||
local current
|
||||
current = redis.call("incr",KEYS[1])
|
||||
if tonumber(current) == 1 then
|
||||
redis.call("expire",KEYS[1],60)
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
答案:在这个例子中,要保证原子性的操作有三个,分别是INCR、判断访问次数是否为1和设置过期时间。而对于获取IP以及判断访问次数是否超过20这两个操作来说,它们只是读操作,即使客户端有多个线程并发执行这两个操作,也不会改变任何值,所以并不需要保证原子性,我们也就不用把它们放到Lua脚本中了。
|
||||
|
||||
### [第30讲](https://time.geekbang.org/column/article/301092)
|
||||
|
||||
问题:在课程里,我提到,我们可以使用SET命令带上NX和EX/PX选项进行加锁操作,那么,我们是否可以用下面的方式来实现加锁操作呢?
|
||||
|
||||
```
|
||||
// 加锁
|
||||
SETNX lock_key unique_value
|
||||
EXPIRE lock_key 10S
|
||||
// 业务逻辑
|
||||
DO THINGS
|
||||
|
||||
```
|
||||
|
||||
答案:如果使用这个方法实现加锁的话,SETNX和EXPIRE两个命令虽然分别完成了对锁变量进行原子判断和值设置,以及设置锁变量的过期时间的操作,但是这两个操作一起执行时,并没有保证原子性。
|
||||
|
||||
如果在执行了SETNX命令后,客户端发生了故障,但锁变量还没有设置过期时间,就无法在实例上释放了,这就会导致别的客户端无法执行加锁操作。所以,我们不能使用这个方法进行加锁。
|
||||
|
||||
### [第31讲](https://time.geekbang.org/column/article/301491)
|
||||
|
||||
问题:在执行事务时,如果Redis实例发生故障,而Redis使用的是RDB机制,那么,事务的原子性还能得到保证吗?
|
||||
|
||||
答案:当Redis采用RDB机制保证数据可靠性时,Redis会按照一定的周期执行内存快照。
|
||||
|
||||
一个事务在执行过程中,事务操作对数据所做的修改并不会实时地记录到RDB中,而且,Redis也不会创建RDB快照。我们可以根据故障发生的时机以及RDB是否生成,分成三种情况来讨论事务的原子性保证。
|
||||
|
||||
<li>
|
||||
假设事务在执行到一半时,实例发生了故障,在这种情况下,上一次RDB快照中不会包含事务所做的修改,而下一次RDB快照还没有执行。所以,实例恢复后,事务修改的数据会丢失,事务的原子性能得到保证。
|
||||
</li>
|
||||
<li>
|
||||
假设事务执行完成后,RDB快照已经生成了,如果实例发生了故障,事务修改的数据可以从RDB中恢复,事务的原子性也就得到了保证。
|
||||
</li>
|
||||
<li>
|
||||
假设事务执行已经完成,但是RDB快照还没有生成,如果实例发生了故障,那么,事务修改的数据就会全部丢失,也就谈不上原子性了。
|
||||
</li>
|
||||
|
||||
### [第32讲](https://time.geekbang.org/column/article/303247)
|
||||
|
||||
问题:在主从集群中,我们把slave-read-only设置为no,让从库也能直接删除数据,以此来避免读到过期数据。你觉得,这是一个好方法吗?
|
||||
|
||||
答案:这道题目的重点是,假设从库也能直接删除过期数据的话(也就是执行写操作),是不是一个好方法?其实,我是想借助这道题目提醒你,主从复制中的增删改操作都需要在主库执行,即使从库能做删除,也不要在从库删除,否则会导致数据不一致。
|
||||
|
||||
例如,主从库上都有a:stock的键,客户端A给主库发送一个SET命令,修改a:stock的值,客户端B给从库发送了一个SET命令,也修改a:stock的值,此时,相同键的值就不一样了。所以,如果从库具备执行写操作的功能,就会导致主从数据不一致。
|
||||
|
||||
@Kaito同学在留言区对这道题做了分析,回答得很好,我稍微整理下,给你分享下他的留言。
|
||||
|
||||
即使从库可以删除过期数据,也还会有不一致的风险,有两种情况。
|
||||
|
||||
第一种情况是,对于已经设置了过期时间的key,主库在key快要过期时,使用expire命令重置了过期时间,例如,一个key原本设置为10s后过期,在还剩1s就要过期时,主库又用expire命令将key的过期时间设置为60s后。但是,expire命令从主库传输到从库时,由于网络延迟导致从库没有及时收到expire命令(比如延后了3s从库才收到expire命令),所以,从库按照原定的过期时间删除了过期key,这就导致主从数据不一致了。
|
||||
|
||||
第二种情况是,主从库的时钟不同步,导致主从库删除时间不一致。
|
||||
|
||||
另外,当slave-read-only设置为no时,如果在从库上写入的数据设置了过期时间,Redis 4.0前的版本不会删除过期数据,而Redis 4.0及以上版本会在数据过期后删除。但是,对于主库同步过来的带有过期时间的数据,从库仍然不会主动进行删除。
|
||||
|
||||
### [第33讲](https://time.geekbang.org/column/article/303568)
|
||||
|
||||
问题:假设我们将min-slaves-to-write设置为1,min-slaves-max-lag设置为15s,哨兵的down-after-milliseconds设置为10s,哨兵主从切换需要5s,而主库因为某些原因卡住了12s。此时,还会发生脑裂吗?主从切换完成后,数据会丢失吗?
|
||||
|
||||
答案:主库卡住了12s,超过了哨兵的down-after-milliseconds 10s阈值,所以,哨兵会把主库判断为客观下线,开始进行主从切换。因为主从切换需要5s,在主从切换过程中,原主库恢复正常。min-slaves-max-lag设置的是15s,而原主库在卡住12s后就恢复正常了,所以没有被禁止接收请求,客户端在原主库恢复后,又可以发送请求给原主库。一旦在主从切换之后有新主库上线,就会出现脑裂。如果原主库在恢复正常后到降级为从库前的这段时间内,接收了写操作请求,那么,这些数据就会丢失了。
|
||||
|
||||
## 典型问题答疑
|
||||
|
||||
在第23讲中,我们学习了Redis缓存的工作原理,我提到了Redis是旁路缓存,而且可以分成只读模式和读写模式。我看到留言区有一些共性问题:如何理解Redis属于旁路缓存?Redis通常会使用哪种模式?现在,我来解释下这两个问题。
|
||||
|
||||
### 如何理解把Redis称为旁路缓存?
|
||||
|
||||
有同学提到,平时看到的旁路缓存是指,写请求的处理方式是直接更新数据库,并删除缓存数据;而读请求的处理方式是查询缓存,如果缓存缺失,就读取数据库,并把数据写入缓存。那么,课程中说的“Redis属于旁路缓存”是这个意思吗?
|
||||
|
||||
其实,这位同学说的是典型的只读缓存的特点。而我把Redis称为旁路缓存,更多的是从“业务应用程序如何使用Redis缓存”这个角度来说的。**业务应用在使用Redis缓存时,需要在业务代码中显式地增加缓存的操作逻辑**。
|
||||
|
||||
例如,一个基本的缓存操作就是,一旦发生缓存缺失,业务应用需要自行去读取数据库,而不是缓存自身去从数据库中读取数据再返回。
|
||||
|
||||
为了便于你理解,我们再来看下和旁路缓存相对应的、计算机系统中的CPU缓存和page cache。这两种缓存默认就在应用程序访问内存和磁盘的路径上,我们写的应用程序都能直接使用这两种缓存。
|
||||
|
||||
我之所以强调Redis是一个旁路缓存,也是希望你能够记住,在使用Redis缓存时,我们需要修改业务代码。
|
||||
|
||||
### 使用Redis缓存时,应该用哪种模式?
|
||||
|
||||
我提到,通用的缓存模式有三种:**只读缓存模式、采用同步直写策略的读写缓存模式、采用异步写回策略的读写缓存模式**。
|
||||
|
||||
一般情况下,我们会把Redis缓存用作只读缓存。只读缓存涉及的操作,包括查询缓存、缓存缺失时读数据库和回填,数据更新时删除缓存数据,这些操作都可以加到业务应用中。而且,当数据更新时,缓存直接删除数据,缓存和数据库的数据一致性较为容易保证。
|
||||
|
||||
当然,有时我们也会把Redis用作读写缓存,同时采用同步直写策略。在这种情况下,缓存涉及的操作也都可以加到业务应用中。而且,和只读缓存相比有一个好处,就是数据修改后的最新值可以直接从缓存中读取。
|
||||
|
||||
对于采用异步写回策略的读写缓存模式来说,缓存系统需要能在脏数据被淘汰时,自行把数据写回数据库,但是,Redis是无法实现这一点的,所以我们使用Redis缓存时,并不采用这个模式。
|
||||
|
||||
## 小结
|
||||
|
||||
好了,这次的答疑就到这里。如果你在学习的过程中遇到了什么问题,欢迎随时给我留言。
|
||||
|
||||
最后,我想说,“学而不思则罔,思而不学则殆”。你平时在使用Redis的时候,不要局限于你眼下的问题,你要多思考问题背后的原理,积累相应的解决方案。当然,在学习课程里的相关操作和配置时,也要有意识地亲自动手去实践。只有学思结合,才能真正提升你的Redis实战能力。
|
||||
@@ -0,0 +1,200 @@
|
||||
<audio id="audio" title="35 | Codis VS Redis Cluster:我该选择哪一个集群方案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/a2/5966ae8f66fb6c856071b47fb43c96a2.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
Redis的切片集群使用多个实例保存数据,能够很好地应对大数据量的场景。在[第8讲](https://time.geekbang.org/column/article/275337)中,我们学习了Redis官方提供的切片集群方案Redis Cluster,这为你掌握切片集群打下了基础。今天,我再来带你进阶一下,我们来学习下Redis Cluster方案正式发布前,业界已经广泛使用的Codis。
|
||||
|
||||
我会具体讲解Codis的关键技术实现原理,同时将Codis和Redis Cluster进行对比,帮你选出最佳的集群方案。
|
||||
|
||||
好了,话不多说,我们先来学习下Codis的整体架构和流程。
|
||||
|
||||
## Codis的整体架构和基本流程
|
||||
|
||||
Codis集群中包含了4类关键组件。
|
||||
|
||||
- codis server:这是进行了二次开发的Redis实例,其中增加了额外的数据结构,支持数据迁移操作,主要负责处理具体的数据读写请求。
|
||||
- codis proxy:接收客户端请求,并把请求转发给codis server。
|
||||
- Zookeeper集群:保存集群元数据,例如数据位置信息和codis proxy信息。
|
||||
- codis dashboard和codis fe:共同组成了集群管理工具。其中,codis dashboard负责执行集群管理工作,包括增删codis server、codis proxy和进行数据迁移。而codis fe负责提供dashboard的Web操作界面,便于我们直接在Web界面上进行集群管理。
|
||||
|
||||
我用一张图来展示下Codis集群的架构和关键组件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/a5/c726e3c5477558fa1dba13c6ae8a77a5.jpg" alt="">
|
||||
|
||||
我来给你具体解释一下Codis是如何处理请求的。
|
||||
|
||||
首先,为了让集群能接收并处理请求,我们要先使用codis dashboard 设置codis server和codis proxy的访问地址,完成设置后,codis server和codis proxy才会开始接收连接。
|
||||
|
||||
然后,当客户端要读写数据时,客户端直接和codis proxy建立连接。你可能会担心,既然客户端连接的是proxy,是不是需要修改客户端,才能访问proxy?其实,你不用担心,codis proxy本身支持Redis的RESP交互协议,所以,客户端访问codis proxy时,和访问原生的Redis实例没有什么区别,这样一来,原本连接单实例的客户端就可以轻松地和Codis集群建立起连接了。
|
||||
|
||||
最后,codis proxy接收到请求,就会查询请求数据和codis server的映射关系,并把请求转发给相应的codis server进行处理。当codis server处理完请求后,会把结果返回给codis proxy,proxy再把数据返回给客户端。
|
||||
|
||||
我来用一张图展示这个处理流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/e5/f76df33a4eba1ebddfd5450745yy83e5.jpg" alt="">
|
||||
|
||||
好了,了解了Codis集群架构和基本流程后,接下来,我就围绕影响切片集群使用效果的4方面技术因素:数据分布、集群扩容和数据迁移、客户端兼容性、可靠性保证,来和你聊聊它们的具体设计选择和原理,帮你掌握Codis的具体用法。
|
||||
|
||||
## Codis的关键技术原理
|
||||
|
||||
一旦我们使用了切片集群,面临的第一个问题就是,**数据是怎么在多个实例上分布的**。
|
||||
|
||||
### 数据如何在集群里分布?
|
||||
|
||||
在Codis集群中,一个数据应该保存在哪个codis server上,这是通过逻辑槽(Slot)映射来完成的,具体来说,总共分成两步。
|
||||
|
||||
第一步,Codis集群一共有1024个Slot,编号依次是0到1023。我们可以把这些Slot手动分配给codis server,每个server上包含一部分Slot。当然,我们也可以让codis dashboard进行自动分配,例如,dashboard把1024个Slot在所有server上均分。
|
||||
|
||||
第二步,当客户端要读写数据时,会使用CRC32算法计算数据key的哈希值,并把这个哈希值对1024取模。而取模后的值,则对应Slot的编号。此时,根据第一步分配的Slot和server对应关系,我们就可以知道数据保存在哪个server上了。
|
||||
|
||||
我来举个例子。下图显示的就是数据、Slot和codis server的映射保存关系。其中,Slot 0和1被分配到了server1,Slot 2分配到server2,Slot 1022和1023被分配到server8。当客户端访问key 1和key 2时,这两个数据的CRC32值对1024取模后,分别是1和1022。因此,它们会被保存在Slot 1和Slot 1022上,而Slot 1和Slot 1022已经被分配到codis server 1和8上了。这样一来,key 1和key 2的保存位置就很清楚了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/yy/77cb1b860cfa5aac9f0a0f7b780fbeyy.jpg" alt="">
|
||||
|
||||
数据key和Slot的映射关系是客户端在读写数据前直接通过CRC32计算得到的,而Slot和codis server的映射关系是通过分配完成的,所以就需要用一个存储系统保存下来,否则,如果集群有故障了,映射关系就会丢失。
|
||||
|
||||
我们把Slot和codis server的映射关系称为数据路由表(简称路由表)。我们在codis dashboard上分配好路由表后,dashboard会把路由表发送给codis proxy,同时,dashboard也会把路由表保存在Zookeeper中。codis-proxy会把路由表缓存在本地,当它接收到客户端请求后,直接查询本地的路由表,就可以完成正确的请求转发了。
|
||||
|
||||
你可以看下这张图,它显示了路由表的分配和使用过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/b1/d1a53f8b23d410f320ef145fd47c97b1.jpg" alt="">
|
||||
|
||||
在数据分布的实现方法上,Codis和Redis Cluster很相似,都采用了key映射到Slot、Slot再分配到实例上的机制。
|
||||
|
||||
但是,这里有一个明显的区别,我来解释一下。
|
||||
|
||||
Codis中的路由表是我们通过codis dashboard分配和修改的,并被保存在Zookeeper集群中。一旦数据位置发生变化(例如有实例增减),路由表被修改了,codis dashbaord就会把修改后的路由表发送给codis proxy,proxy就可以根据最新的路由信息转发请求了。
|
||||
|
||||
在Redis Cluster中,数据路由表是通过每个实例相互间的通信传递的,最后会在每个实例上保存一份。当数据路由信息发生变化时,就需要在所有实例间通过网络消息进行传递。所以,如果实例数量较多的话,就会消耗较多的集群网络资源。
|
||||
|
||||
数据分布解决了新数据写入时该保存在哪个server的问题,但是,当业务数据增加后,如果集群中的现有实例不足以保存所有数据,我们就需要对集群进行扩容。接下来,我们再来学习下Codis针对集群扩容的关键技术设计。
|
||||
|
||||
### 集群扩容和数据迁移如何进行?
|
||||
|
||||
Codis集群扩容包括了两方面:增加codis server和增加codis proxy。
|
||||
|
||||
我们先来看增加codis server,这个过程主要涉及到两步操作:
|
||||
|
||||
1. 启动新的codis server,将它加入集群;
|
||||
1. 把部分数据迁移到新的server。
|
||||
|
||||
需要注意的是,这里的数据迁移是一个重要的机制,接下来我来重点介绍下。
|
||||
|
||||
Codis集群按照Slot的粒度进行数据迁移,我们来看下迁移的基本流程。
|
||||
|
||||
1. 在源server上,Codis从要迁移的Slot中随机选择一个数据,发送给目的server。
|
||||
1. 目的server确认收到数据后,会给源server返回确认消息。这时,源server会在本地将刚才迁移的数据删除。
|
||||
1. 第一步和第二步就是单个数据的迁移过程。Codis会不断重复这个迁移过程,直到要迁移的Slot中的数据全部迁移完成。
|
||||
|
||||
我画了下面这张图,显示了数据迁移的流程,你可以看下加深理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/6b/e01c7806b51b196097c393a079436d6b.jpg" alt="">
|
||||
|
||||
针对刚才介绍的单个数据的迁移过程,Codis实现了两种迁移模式,分别是同步迁移和异步迁移,我们来具体看下。
|
||||
|
||||
同步迁移是指,在数据从源server发送给目的server的过程中,源server是阻塞的,无法处理新的请求操作。这种模式很容易实现,但是迁移过程中会涉及多个操作(包括数据在源server序列化、网络传输、在目的server反序列化,以及在源server删除),如果迁移的数据是一个bigkey,源server就会阻塞较长时间,无法及时处理用户请求。
|
||||
|
||||
为了避免数据迁移阻塞源server,Codis实现的第二种迁移模式就是异步迁移。异步迁移的关键特点有两个。
|
||||
|
||||
第一个特点是,当源server把数据发送给目的server后,就可以处理其他请求操作了,不用等到目的server的命令执行完。而目的server会在收到数据并反序列化保存到本地后,给源server发送一个ACK消息,表明迁移完成。此时,源server在本地把刚才迁移的数据删除。
|
||||
|
||||
在这个过程中,迁移的数据会被设置为只读,所以,源server上的数据不会被修改,自然也就不会出现“和目的server上的数据不一致”的问题了。
|
||||
|
||||
第二个特点是,对于bigkey,异步迁移采用了拆分指令的方式进行迁移。具体来说就是,对bigkey中每个元素,用一条指令进行迁移,而不是把整个bigkey进行序列化后再整体传输。这种化整为零的方式,就避免了bigkey迁移时,因为要序列化大量数据而阻塞源server的问题。
|
||||
|
||||
此外,当bigkey迁移了一部分数据后,如果Codis发生故障,就会导致bigkey的一部分元素在源server,而另一部分元素在目的server,这就破坏了迁移的原子性。
|
||||
|
||||
所以,Codis会在目标server上,给bigkey的元素设置一个临时过期时间。如果迁移过程中发生故障,那么,目标server上的key会在过期后被删除,不会影响迁移的原子性。当正常完成迁移后,bigkey元素的临时过期时间会被删除。
|
||||
|
||||
我给你举个例子,假如我们要迁移一个有1万个元素的List类型数据,当使用异步迁移时,源server就会给目的server传输1万条RPUSH命令,每条命令对应了List中一个元素的插入。在目的server上,这1万条命令再被依次执行,就可以完成数据迁移。
|
||||
|
||||
这里,有个地方需要你注意下,为了提升迁移的效率,Codis在异步迁移Slot时,允许每次迁移多个key。**你可以通过异步迁移命令SLOTSMGRTTAGSLOT-ASYNC的参数numkeys设置每次迁移的key数量**。
|
||||
|
||||
刚刚我们学习的是codis server的扩容和数据迁移机制,其实,在Codis集群中,除了增加codis server,有时还需要增加codis proxy。
|
||||
|
||||
因为在Codis集群中,客户端是和codis proxy直接连接的,所以,当客户端增加时,一个proxy无法支撑大量的请求操作,此时,我们就需要增加proxy。
|
||||
|
||||
增加proxy比较容易,我们直接启动proxy,再通过codis dashboard把proxy加入集群就行。
|
||||
|
||||
此时,codis proxy的访问连接信息都会保存在Zookeeper上。所以,当新增了proxy后,Zookeeper上会有最新的访问列表,客户端也就可以从Zookeeper上读取proxy访问列表,把请求发送给新增的proxy。这样一来,客户端的访问压力就可以在多个proxy上分担处理了,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/23/707767936a6fb2d7686c84d81c048423.jpg" alt="">
|
||||
|
||||
好了,到这里,我们就了解了Codis集群中的数据分布、集群扩容和数据迁移的方法,这都是切片集群中的关键机制。
|
||||
|
||||
不过,因为集群提供的功能和单实例提供的功能不同,所以,我们在应用集群时,不仅要关注切片集群中的关键机制,还需要关注客户端的使用。这里就有一个问题了:业务应用采用的客户端能否直接和集群交互呢?接下来,我们就来聊下这个问题。
|
||||
|
||||
### 集群客户端需要重新开发吗?
|
||||
|
||||
使用Redis单实例时,客户端只要符合RESP协议,就可以和实例进行交互和读写数据。但是,在使用切片集群时,有些功能是和单实例不一样的,比如集群中的数据迁移操作,在单实例上是没有的,而且迁移过程中,数据访问请求可能要被重定向(例如Redis Cluster中的MOVE命令)。
|
||||
|
||||
所以,客户端需要增加和集群功能相关的命令操作的支持。如果原来使用单实例客户端,想要扩容使用集群,就需要使用新客户端,这对于业务应用的兼容性来说,并不是特别友好。
|
||||
|
||||
Codis集群在设计时,就充分考虑了对现有单实例客户端的兼容性。
|
||||
|
||||
Codis使用codis proxy直接和客户端连接,codis proxy是和单实例客户端兼容的。而和集群相关的管理工作(例如请求转发、数据迁移等),都由codis proxy、codis dashboard这些组件来完成,不需要客户端参与。
|
||||
|
||||
这样一来,业务应用使用Codis集群时,就不用修改客户端了,可以复用和单实例连接的客户端,既能利用集群读写大容量数据,又避免了修改客户端增加复杂的操作逻辑,保证了业务代码的稳定性和兼容性。
|
||||
|
||||
最后,我们再来看下集群可靠性的问题。可靠性是实际业务应用的一个核心要求。**对于一个分布式系统来说,它的可靠性和系统中的组件个数有关:组件越多,潜在的风险点也就越多**。和Redis Cluster只包含Redis实例不一样,Codis集群包含的组件有4类。那你就会问了,这么多组件会降低Codis集群的可靠性吗?
|
||||
|
||||
### 怎么保证集群可靠性?
|
||||
|
||||
我们来分别看下Codis不同组件的可靠性保证方法。
|
||||
|
||||
首先是codis server。
|
||||
|
||||
codis server其实就是Redis实例,只不过增加了和集群操作相关的命令。Redis的主从复制机制和哨兵机制在codis server上都是可以使用的,所以,Codis就使用主从集群来保证codis server的可靠性。简单来说就是,Codis给每个server配置从库,并使用哨兵机制进行监控,当发生故障时,主从库可以进行切换,从而保证了server的可靠性。
|
||||
|
||||
在这种配置情况下,每个server就成为了一个server group,每个group中是一主多从的server。数据分布使用的Slot,也是按照group的粒度进行分配的。同时,codis proxy在转发请求时,也是按照数据所在的Slot和group的对应关系,把写请求发到相应group的主库,读请求发到group中的主库或从库上。
|
||||
|
||||
下图展示的是配置了server group的Codis集群架构。在Codis集群中,我们通过部署server group和哨兵集群,实现codis server的主从切换,提升集群可靠性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/4a/0282beb10f5c42c1f12c89afbe03af4a.jpg" alt="">
|
||||
|
||||
因为codis proxy和Zookeeper这两个组件是搭配在一起使用的,所以,接下来,我们再来看下这两个组件的可靠性。
|
||||
|
||||
在Codis集群设计时,proxy上的信息源头都是来自Zookeeper(例如路由表)。而Zookeeper集群使用多个实例来保存数据,只要有超过半数的Zookeeper实例可以正常工作, Zookeeper集群就可以提供服务,也可以保证这些数据的可靠性。
|
||||
|
||||
所以,codis proxy使用Zookeeper集群保存路由表,可以充分利用Zookeeper的高可靠性保证来确保codis proxy的可靠性,不用再做额外的工作了。当codis proxy发生故障后,直接重启proxy就行。重启后的proxy,可以通过codis dashboard从Zookeeper集群上获取路由表,然后,就可以接收客户端请求进行转发了。这样的设计,也降低了Codis集群本身的开发复杂度。
|
||||
|
||||
对于codis dashboard和codis fe来说,它们主要提供配置管理和管理员手工操作,负载压力不大,所以,它们的可靠性可以不用额外进行保证了。
|
||||
|
||||
## 切片集群方案选择建议
|
||||
|
||||
到这里,Codis和Redis Cluster这两种切片集群方案我们就学完了,我把它们的区别总结在了一张表里,你可以对比看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/b8/8fec8c2f76e32647d055ae6ed8cfbab8.jpg" alt="">
|
||||
|
||||
最后,在实际应用的时候,对于这两种方案,我们该怎么选择呢?我再给你提4条建议。
|
||||
|
||||
<li>
|
||||
从稳定性和成熟度来看,Codis应用得比较早,在业界已经有了成熟的生产部署。虽然Codis引入了proxy和Zookeeper,增加了集群复杂度,但是,proxy的无状态设计和Zookeeper自身的稳定性,也给Codis的稳定使用提供了保证。而Redis Cluster的推出时间晚于Codis,相对来说,成熟度要弱于Codis,如果你想选择一个成熟稳定的方案,Codis更加合适些。
|
||||
</li>
|
||||
<li>
|
||||
从业务应用客户端兼容性来看,连接单实例的客户端可以直接连接codis proxy,而原本连接单实例的客户端要想连接Redis Cluster的话,就需要开发新功能。所以,如果你的业务应用中大量使用了单实例的客户端,而现在想应用切片集群的话,建议你选择Codis,这样可以避免修改业务应用中的客户端。
|
||||
</li>
|
||||
<li>
|
||||
从使用Redis新命令和新特性来看,Codis server是基于开源的Redis 3.2.8开发的,所以,Codis并不支持Redis后续的开源版本中的新增命令和数据类型。另外,Codis并没有实现开源Redis版本的所有命令,比如BITOP、BLPOP、BRPOP,以及和与事务相关的MUTLI、EXEC等命令。[Codis官网](https://github.com/CodisLabs/codis/blob/release3.2/doc/unsupported_cmds.md)上列出了不被支持的命令列表,你在使用时记得去核查一下。所以,如果你想使用开源Redis 版本的新特性,Redis Cluster是一个合适的选择。
|
||||
</li>
|
||||
<li>
|
||||
从数据迁移性能维度来看,Codis能支持异步迁移,异步迁移对集群处理正常请求的性能影响要比使用同步迁移的小。所以,如果你在应用集群时,数据迁移比较频繁的话,Codis是个更合适的选择。
|
||||
</li>
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis切片集群的Codis方案。Codis集群包含codis server、codis proxy、Zookeeper、codis dashboard和codis fe这四大类组件。我们再来回顾下它们的主要功能。
|
||||
|
||||
- codis proxy和codis server负责处理数据读写请求,其中,codis proxy和客户端连接,接收请求,并转发请求给codis server,而codis server负责具体处理请求。
|
||||
- codis dashboard和codis fe负责集群管理,其中,codis dashboard执行管理操作,而codis fe提供Web管理界面。
|
||||
- Zookeeper集群负责保存集群的所有元数据信息,包括路由表、proxy实例信息等。这里,有个地方需要你注意,除了使用Zookeeper,Codis还可以使用etcd或本地文件系统保存元数据信息。
|
||||
|
||||
关于Codis和Redis Cluster的选型考虑,我从稳定性成熟度、客户端兼容性、Redis新特性使用以及数据迁移性能四个方面给你提供了建议,希望能帮助到你。
|
||||
|
||||
最后,我再给你提供一个Codis使用上的小建议:当你有多条业务线要使用Codis时,可以启动多个codis dashboard,每个dashboard管理一部分codis server,同时,再用一个dashboard对应负责一个业务线的集群管理,这样,就可以做到用一个Codis集群实现多条业务线的隔离管理了。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我会给你提个小问题。假设Codis集群中保存的80%的键值对都是Hash类型,每个Hash集合的元素数量在10万~20万个,每个集合元素的大小是2KB。你觉得,迁移一个这样的Hash集合数据,会对Codis的性能造成影响吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
184
极客时间专栏/Redis核心技术与实战/实践篇/36 | Redis支撑秒杀场景的关键技术和实践都有哪些?.md
Normal file
184
极客时间专栏/Redis核心技术与实战/实践篇/36 | Redis支撑秒杀场景的关键技术和实践都有哪些?.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<audio id="audio" title="36 | Redis支撑秒杀场景的关键技术和实践都有哪些?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/10/b3/107116ef311346a103cbf1c45dc6a4b3.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
秒杀是一个非常典型的活动场景,比如,在双11、618等电商促销活动中,都会有秒杀场景。秒杀场景的业务特点是**限时限量**,业务系统要处理瞬时的大量高并发请求,而Redis就经常被用来支撑秒杀活动。
|
||||
|
||||
不过,秒杀场景包含了多个环节,可以分成秒杀前、秒杀中和秒杀后三个阶段,每个阶段的请求处理需求并不相同,Redis并不能支撑秒杀场景的每一个环节。
|
||||
|
||||
那么,Redis具体是在秒杀场景的哪个环节起到支撑作用的呢?又是如何支持的呢?清楚了这个问题,我们才能知道在秒杀场景中,如何使用Redis来支撑高并发压力,并且做好秒杀场景的应对方案。
|
||||
|
||||
接下来,我们先来了解下秒杀场景的负载特征。
|
||||
|
||||
## 秒杀场景的负载特征对支撑系统的要求
|
||||
|
||||
秒杀活动售卖的商品通常价格非常优惠,会吸引大量用户进行抢购。但是,商品库存量却远远小于购买该商品的用户数,而且会限定用户只能在一定的时间段内购买。这就给秒杀系统带来两个明显的负载特征,相应的,也对支撑系统提出了要求,我们来分析下。
|
||||
|
||||
**第一个特征是瞬时并发访问量非常高**。
|
||||
|
||||
一般数据库每秒只能支撑千级别的并发请求,而Redis的并发处理能力(每秒处理请求数)能达到万级别,甚至更高。所以,**当有大量并发请求涌入秒杀系统时,我们就需要使用Redis先拦截大部分请求,避免大量请求直接发送给数据库,把数据库压垮**。
|
||||
|
||||
**第二个特征是读多写少,而且读操作是简单的查询操作**。
|
||||
|
||||
在秒杀场景下,用户需要先查验商品是否还有库存(也就是根据商品ID查询该商品的库存还有多少),只有库存有余量时,秒杀系统才能进行库存扣减和下单操作。
|
||||
|
||||
库存查验操作是典型的键值对查询,而Redis对键值对查询的高效支持,正好和这个操作的要求相匹配。
|
||||
|
||||
不过,秒杀活动中只有少部分用户能成功下单,所以,商品库存查询操作(读操作)要远多于库存扣减和下单操作(写操作)。
|
||||
|
||||
当然,实际秒杀场景通常有多个环节,刚才介绍的用户查验库存只是其中的一个环节。那么,Redis具体可以在整个秒杀场景中哪些环节发挥作用呢?这就要说到秒杀活动的整体流程了,我们来分析下。
|
||||
|
||||
## Redis可以在秒杀场景的哪些环节发挥作用?
|
||||
|
||||
我们一般可以把秒杀活动分成三个阶段。在每一个阶段,Redis所发挥的作用也不一样。
|
||||
|
||||
第一阶段是秒杀活动前。
|
||||
|
||||
在这个阶段,用户会不断刷新商品详情页,这会导致详情页的瞬时请求量剧增。这个阶段的应对方案,一般是尽量**把商品详情页的页面元素静态化,然后使用CDN或是浏览器把这些静态化的元素缓存起来**。这样一来,秒杀前的大量请求可以直接由CDN或是浏览器缓存服务,不会到达服务器端了,这就减轻了服务器端的压力。
|
||||
|
||||
在这个阶段,有CDN和浏览器缓存服务请求就足够了,我们还不需要使用Redis。
|
||||
|
||||
第二阶段是秒杀活动开始。
|
||||
|
||||
此时,大量用户点击商品详情页上的秒杀按钮,会产生大量的并发请求查询库存。一旦某个请求查询到有库存,紧接着系统就会进行库存扣减。然后,系统会生成实际订单,并进行后续处理,例如订单支付和物流服务。如果请求查不到库存,就会返回。用户通常会继续点击秒杀按钮,继续查询库存。
|
||||
|
||||
简单来说,这个阶段的操作就是三个:库存查验、库存扣减和订单处理。因为每个秒杀请求都会查询库存,而请求只有查到有库存余量后,后续的库存扣减和订单处理才会被执行。所以,这个阶段中最大的并发压力都在库存查验操作上。
|
||||
|
||||
为了支撑大量高并发的库存查验请求,我们需要在这个环节使用Redis保存库存量,这样一来,请求可以直接从Redis中读取库存并进行查验。
|
||||
|
||||
那么,库存扣减和订单处理是否都可以交给后端的数据库来执行呢?
|
||||
|
||||
其实,订单处理可以在数据库中执行,但库存扣减操作,不能交给后端数据库处理。
|
||||
|
||||
在数据库中处理订单的原因比较简单,我先说下。
|
||||
|
||||
订单处理会涉及支付、商品出库、物流等多个关联操作,这些操作本身涉及数据库中的多张数据表,要保证处理的事务性,需要在数据库中完成。而且,订单处理时的请求压力已经不大了,数据库可以支撑这些订单处理请求。
|
||||
|
||||
那为啥库存扣减操作不能在数据库执行呢?这是因为,一旦请求查到有库存,就意味着发送该请求的用户获得了商品的购买资格,用户就会下单了。同时,商品的库存余量也需要减少一个。如果我们把库存扣减的操作放到数据库执行,会带来两个问题。
|
||||
|
||||
1. **额外的开销**。Redis中保存了库存量,而库存量的最新值又是数据库在维护,所以数据库更新后,还需要和Redis进行同步,这个过程增加了额外的操作逻辑,也带来了额外的开销。
|
||||
1. **下单量超过实际库存量,出现超售**。由于数据库的处理速度较慢,不能及时更新库存余量,这就会导致大量库存查验的请求读取到旧的库存值,并进行下单。此时,就会出现下单数量大于实际的库存量,导致出现超售,这就不符合业务层的要求了。
|
||||
|
||||
所以,我们就需要直接在Redis中进行库存扣减。具体的操作是,当库存查验完成后,一旦库存有余量,我们就立即在Redis中扣减库存。而且,为了避免请求查询到旧的库存值,库存查验和库存扣减这两个操作需要保证原子性。
|
||||
|
||||
第三阶段就是秒杀活动结束后。
|
||||
|
||||
在这个阶段,可能还会有部分用户刷新商品详情页,尝试等待有其他用户退单。而已经成功下单的用户会刷新订单详情,跟踪订单的进展。不过,这个阶段中的用户请求量已经下降很多了,服务器端一般都能支撑,我们就不重点讨论了。
|
||||
|
||||
好了,我们先来总结下秒杀场景对Redis的需求。
|
||||
|
||||
秒杀场景分成秒杀前、秒杀中和秒杀后三个阶段。秒杀开始前后,高并发压力没有那么大,我们不需要使用Redis,但在秒杀进行中,需要查验和扣减商品库存,库存查验面临大量的高并发请求,而库存扣减又需要和库存查验一起执行,以保证原子性。这就是秒杀对Redis的需求。
|
||||
|
||||
下图显示了在秒杀场景中需要Redis参与的两个环节:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/1b/7c3e5def912d7c8c45bca00f955d751b.jpg" alt="">
|
||||
|
||||
了解需求后,我们使用Redis来支撑秒杀场景的方法就比较清晰了。接下来,我向你介绍两种方法。
|
||||
|
||||
## Redis的哪些方法可以支撑秒杀场景?
|
||||
|
||||
秒杀场景对Redis操作的根本要求有两个。
|
||||
|
||||
1. **支持高并发****。**这个很简单,Redis本身高速处理请求的特性就可以支持高并发。而且,如果有多个秒杀商品,我们也可以使用切片集群,用不同的实例保存不同商品的库存,这样就避免,使用单个实例导致所有的秒杀请求都集中在一个实例上的问题了。不过,需要注意的是,当使用切片集群时,我们要先用CRC算法计算不同秒杀商品key对应的Slot,然后,我们在分配Slot和实例对应关系时,才能把不同秒杀商品对应的Slot分配到不同实例上保存。
|
||||
1. **保证库存查验和库存扣减原子性执行**。针对这条要求,我们就可以使用Redis的原子操作或是分布式锁这两个功能特性来支撑了。
|
||||
|
||||
我们先来看下Redis是如何基于原子操作来支撑秒杀场景的。
|
||||
|
||||
### 基于原子操作支撑秒杀场景
|
||||
|
||||
在秒杀场景中,一个商品的库存对应了两个信息,分别是总库存量和已秒杀量。这种数据模型正好是一个key(商品ID)对应了两个属性(总库存量和已秒杀量),所以,我们可以使用一个Hash类型的键值对来保存库存的这两个信息,如下所示:
|
||||
|
||||
```
|
||||
key: itemID
|
||||
value: {total: N, ordered: M}
|
||||
|
||||
```
|
||||
|
||||
其中,itemID是商品的编号,total是总库存量,ordered是已秒杀量。
|
||||
|
||||
因为库存查验和库存扣减这两个操作要保证一起执行,**一个直接的方法就是使用Redis的原子操作**。
|
||||
|
||||
我们在[第29讲](https://time.geekbang.org/column/article/299806)中学习过,原子操作可以是Redis自身提供的原子命令,也可以是Lua脚本。因为库存查验和库存扣减是两个操作,无法用一条命令来完成,所以,我们就需要使用Lua脚本原子性地执行这两个操作。
|
||||
|
||||
那怎么在Lua脚本中实现这两个操作呢?我给你提供一段Lua脚本写的伪代码,它显示了这两个操作的实现。
|
||||
|
||||
```
|
||||
#获取商品库存信息
|
||||
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
|
||||
#将总库存转换为数值
|
||||
local total = tonumber(counts[1])
|
||||
#将已被秒杀的库存转换为数值
|
||||
local ordered = tonumber(counts[2])
|
||||
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存
|
||||
if ordered + k <= total then
|
||||
#更新已秒杀的库存量
|
||||
redis.call("HINCRBY",KEYS[1],"ordered",k) return k;
|
||||
end
|
||||
return 0
|
||||
|
||||
```
|
||||
|
||||
有了Lua脚本后,我们就可以在Redis客户端,使用EVAL命令来执行这个脚本了。
|
||||
|
||||
最后,客户端会根据脚本的返回值,来确定秒杀是成功还是失败了。如果返回值是k,就是成功了;如果是0,就是失败。
|
||||
|
||||
到这里,我们学习了如何使用原子性的Lua脚本来实现库存查验和库存扣减。其实,要想保证库存查验和扣减这两个操作的原子性,我们还有另一种方法,就是**使用分布式锁来保证多个客户端能互斥执行这两个操作**。接下来,我们就来看下如何使用分布式锁来支撑秒杀场景。
|
||||
|
||||
### 基于分布式锁来支撑秒杀场景
|
||||
|
||||
**使用分布式锁来支撑秒杀场景的具体做法是,先让客户端向Redis申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减**。这样一来,大量的秒杀请求就会在争夺分布式锁时被过滤掉。而且,库存查验和扣减也不用使用原子操作了,因为多个并发客户端只有一个客户端能够拿到锁,已经保证了客户端并发访问的互斥性。
|
||||
|
||||
你可以看下下面的伪代码,它显示了使用分布式锁来执行库存查验和扣减的过程。
|
||||
|
||||
```
|
||||
//使用商品ID作为key
|
||||
key = itemID
|
||||
//使用客户端唯一标识作为value
|
||||
val = clientUniqueID
|
||||
//申请分布式锁,Timeout是超时时间
|
||||
lock =acquireLock(key, val, Timeout)
|
||||
//当拿到锁后,才能进行库存查验和扣减
|
||||
if(lock == True) {
|
||||
//库存查验和扣减
|
||||
availStock = DECR(key, k)
|
||||
//库存已经扣减完了,释放锁,返回秒杀失败
|
||||
if (availStock < 0) {
|
||||
releaseLock(key, val)
|
||||
return error
|
||||
}
|
||||
//库存扣减成功,释放锁
|
||||
else{
|
||||
releaseLock(key, val)
|
||||
//订单处理
|
||||
}
|
||||
}
|
||||
//没有拿到锁,直接返回
|
||||
else
|
||||
return
|
||||
|
||||
```
|
||||
|
||||
需要提醒你的是,在使用分布式锁时,客户端需要先向Redis请求锁,只有请求到了锁,才能进行库存查验等操作,这样一来,客户端在争抢分布式锁时,大部分秒杀请求本身就会因为抢不到锁而被拦截。
|
||||
|
||||
所以,我给你一个小建议,**我们可以使用切片集群中的不同实例来分别保存分布式锁和商品库存信息**。使用这种保存方式后,秒杀请求会首先访问保存分布式锁的实例。如果客户端没有拿到锁,这些客户端就不会查询商品库存,这就可以减轻保存库存信息的实例的压力了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我们学习了Redis在秒杀场景中的具体应用。秒杀场景有2个负载特征,分别是瞬时高并发请求和读多写少。Redis良好的高并发处理能力,以及高效的键值对读写特性,正好可以满足秒杀场景的需求。
|
||||
|
||||
在秒杀场景中,我们可以通过前端CDN和浏览器缓存拦截大量秒杀前的请求。在实际秒杀活动进行时,库存查验和库存扣减是承受巨大并发请求压力的两个操作,同时,这两个操作的执行需要保证原子性。Redis的原子操作、分布式锁这两个功能特性可以有效地来支撑秒杀场景的需求。
|
||||
|
||||
当然,对于秒杀场景来说,只用Redis是不够的。秒杀系统是一个系统性工程,Redis实现了对库存查验和扣减这个环节的支撑,除此之外,还有4个环节需要我们处理好。
|
||||
|
||||
1. **前端静态页面的设计**。秒杀页面上能静态化处理的页面元素,我们都要尽量静态化,这样可以充分利用CDN或浏览器缓存服务秒杀开始前的请求。
|
||||
1. **请求拦截和流控**。在秒杀系统的接入层,对恶意请求进行拦截,避免对系统的恶意攻击,例如使用黑名单禁止恶意IP进行访问。如果Redis实例的访问压力过大,为了避免实例崩溃,我们也需要在接入层进行限流,控制进入秒杀系统的请求数量。
|
||||
1. **库存信息过期时间处理**。Redis中保存的库存信息其实是数据库的缓存,为了避免缓存击穿问题,我们不要给库存信息设置过期时间。
|
||||
1. **数据库订单异常处理**。如果数据库没能成功处理订单,可以增加订单重试功能,保证订单最终能被成功处理。
|
||||
|
||||
最后,我也再给你一个小建议:秒杀活动带来的请求流量巨大,我们需要把秒杀商品的库存信息用单独的实例保存,而不要和日常业务系统的数据保存在同一个实例上,这样可以避免干扰业务系统的正常运行。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,假设一个商品的库存量是800,我们使用一个包含了4个实例的切片集群来服务秒杀请求。我们让每个实例各自维护库存量200,然后,客户端的秒杀请求可以分发到不同的实例上进行处理,你觉得这是一个好方法吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
199
极客时间专栏/Redis核心技术与实战/实践篇/37 | 数据分布优化:如何应对数据倾斜?.md
Normal file
199
极客时间专栏/Redis核心技术与实战/实践篇/37 | 数据分布优化:如何应对数据倾斜?.md
Normal file
@@ -0,0 +1,199 @@
|
||||
<audio id="audio" title="37 | 数据分布优化:如何应对数据倾斜?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/a5/abf072cc4b5a334ea497a7aab23d5aa5.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在切片集群中,数据会按照一定的分布规则分散到不同的实例上保存。比如,在使用Redis Cluster或Codis时,数据都会先按照CRC算法的计算值对Slot(逻辑槽)取模,同时,所有的Slot又会由运维管理员分配到不同的实例上。这样,数据就被保存到相应的实例上了。
|
||||
|
||||
虽然这种方法实现起来比较简单,但是很容易导致一个问题:数据倾斜。
|
||||
|
||||
数据倾斜有两类。
|
||||
|
||||
- **数据量倾斜**:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。
|
||||
- **数据访问倾斜**:虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。
|
||||
|
||||
如果发生了数据倾斜,那么保存了大量数据,或者是保存了热点数据的实例的处理压力就会增大,速度变慢,甚至还可能会引起这个实例的内存资源耗尽,从而崩溃。这是我们在应用切片集群时要避免的。
|
||||
|
||||
今天这节课,我就来和你聊聊,这两种数据倾斜是怎么发生的,我们又该怎么应对。
|
||||
|
||||
## 数据量倾斜的成因和应对方法
|
||||
|
||||
首先,我们来看数据量倾斜的成因和应对方案。
|
||||
|
||||
当数据量倾斜发生时,数据在切片集群的多个实例上分布不均衡,大量数据集中到了一个或几个实例上,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/85/2cb89b2d1b319fb43a5d1b94d7929685.jpg" alt="">
|
||||
|
||||
那么,数据量倾斜是怎么产生的呢?这主要有三个原因,分别是某个实例上保存了bigkey、Slot分配不均衡以及Hash Tag。接下来,我们就一个一个来分析,同时我还会给你讲解相应的解决方案。
|
||||
|
||||
#### bigkey导致倾斜
|
||||
|
||||
第一个原因是,某个实例上正好保存了bigkey。bigkey的value值很大(String类型),或者是bigkey保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。
|
||||
|
||||
而且,bigkey的操作一般都会造成实例IO线程阻塞,如果bigkey的访问量比较大,就会影响到这个实例上的其它请求被处理的速度。
|
||||
|
||||
其实,bigkey已经是我们课程中反复提到的一个关键点了。为了避免bigkey造成的数据倾斜,一个根本的应对方法是,**我们在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中**。
|
||||
|
||||
此外,**如果bigkey正好是集合类型,我们还有一个方法,就是把bigkey拆分成很多个小的集合类型数据,分散保存在不同的实例上**。
|
||||
|
||||
我给你举个例子。假设Hash类型集合user:info保存了100万个用户的信息,是一个bigkey。那么,我们就可以按照用户ID的范围,把这个集合拆分成10个小集合,每个小集合只保存10万个用户的信息(例如小集合1保存的是ID从1到10万的用户信息,小集合2保存的是ID从10万零1到20万的用户)。这样一来,我们就可以把一个bigkey化整为零、分散保存了,避免了bigkey给单个切片实例带来的访问压力。
|
||||
|
||||
需要注意的是,当bigkey访问量较大时,也会造成数据访问倾斜,我一会儿再给你讲具体怎么应对。
|
||||
|
||||
接下来,我们再来看导致数据量倾斜的第二个原因:Slot分配不均衡。
|
||||
|
||||
#### Slot分配不均衡导致倾斜
|
||||
|
||||
如果集群运维人员没有均衡地分配Slot,就会有大量的数据被分配到同一个Slot中,而同一个Slot只会在一个实例上分布,这就会导致,大量数据被集中到一个实例上,造成数据倾斜。
|
||||
|
||||
我以Redis Cluster为例,来介绍下Slot分配不均衡的情况。
|
||||
|
||||
Redis Cluster一共有16384个Slot,假设集群一共有5个实例,其中,实例1的硬件配置较高,运维人员在给实例分配Slot时,就可能会给实例1多分配些Slot,把实例1的资源充分利用起来。
|
||||
|
||||
但是,我们其实并不知道数据和Slot的对应关系,这种做法就可能会导致大量数据正好被映射到实例1上的Slot,造成数据倾斜,给实例1带来访问压力。
|
||||
|
||||
为了应对这个问题,我们可以通过运维规范,在分配之前,我们就要避免把过多的Slot分配到同一个实例。如果是已经分配好Slot的集群,我们可以先查看Slot和实例的具体分配关系,从而判断是否有过多的Slot集中到了同一个实例。如果有的话,就将部分Slot迁移到其它实例,从而避免数据倾斜。
|
||||
|
||||
不同集群上查看Slot分配情况的方式不同:如果是Redis Cluster,就用CLUSTER SLOTS命令;如果是Codis,就可以在codis dashboard上查看。
|
||||
|
||||
比如说,我们执行CLUSTER SLOTS命令查看Slot分配情况。命令返回结果显示,Slot 0 到Slot 4095被分配到了实例192.168.10.3上,而Slot 12288到Slot 16383被分配到了实例192.168.10.5上。
|
||||
|
||||
```
|
||||
127.0.0.1:6379> cluster slots
|
||||
1) 1) (integer) 0
|
||||
2) (integer) 4095
|
||||
3) 1) "192.168.10.3"
|
||||
2) (integer) 6379
|
||||
2) 1) (integer) 12288
|
||||
2) (integer) 16383
|
||||
3) 1) "192.168.10.5"
|
||||
2) (integer) 6379
|
||||
|
||||
```
|
||||
|
||||
如果某一个实例上有太多的Slot,我们就可以使用迁移命令把这些Slot迁移到其它实例上。在Redis Cluster中,我们可以使用3个命令完成Slot迁移。
|
||||
|
||||
1. CLUSTER SETSLOT:使用不同的选项进行三种设置,分别是设置Slot要迁入的目标实例,Slot要迁出的源实例,以及Slot所属的实例。
|
||||
1. CLUSTER GETKEYSINSLOT:获取某个Slot中一定数量的key。
|
||||
1. MIGRATE:把一个key从源实例实际迁移到目标实例。
|
||||
|
||||
我来借助一个例子,带你了解下这三个命令怎么用。
|
||||
|
||||
假设我们要把Slot 300从源实例(ID为3)迁移到目标实例(ID为5),那要怎么做呢?
|
||||
|
||||
实际上,我们可以分成5步。
|
||||
|
||||
第1步,我们先在目标实例5上执行下面的命令,将Slot 300的源实例设置为实例3,表示要从实例3上迁入Slot 300。
|
||||
|
||||
```
|
||||
CLUSTER SETSLOT 300 IMPORTING 3
|
||||
|
||||
```
|
||||
|
||||
第2步,在源实例3上,我们把Slot 300的目标实例设置为5,这表示,Slot 300要迁出到实例5上,如下所示:
|
||||
|
||||
```
|
||||
CLUSTER SETSLOT 300 MIGRATING 5
|
||||
|
||||
```
|
||||
|
||||
第3步,从Slot 300中获取100 个key。因为Slot中的key数量可能很多,所以我们需要在客户端上多次执行下面的这条命令,分批次获得并迁移key。
|
||||
|
||||
```
|
||||
CLUSTER GETKEYSINSLOT 300 100
|
||||
|
||||
```
|
||||
|
||||
第4步,我们把刚才获取的100个key中的key1迁移到目标实例5上(IP为192.168.10.5),同时把要迁入的数据库设置为0号数据库,把迁移的超时时间设置为timeout。我们重复执行MIGRATE命令,把100个key都迁移完。
|
||||
|
||||
```
|
||||
MIGRATE 192.168.10.5 6379 key1 0 timeout
|
||||
|
||||
```
|
||||
|
||||
最后,我们重复执行第3和第4步,直到Slot中的所有key都迁移完成。
|
||||
|
||||
从Redis 3.0.6开始,你也可以使用KEYS选项,一次迁移多个key(key1、2、3),这样可以提升迁移效率。
|
||||
|
||||
```
|
||||
MIGRATE 192.168.10.5 6379 "" 0 timeout KEYS key1 key2 key3
|
||||
|
||||
```
|
||||
|
||||
对于Codis来说,我们可以执行下面的命令进行数据迁移。其中,我们把dashboard组件的连接地址设置为ADDR,并且把Slot 300迁移到编号为6的codis server group上。
|
||||
|
||||
```
|
||||
codis-admin --dashboard=ADDR -slot-action --create --sid=300 --gid=6
|
||||
|
||||
```
|
||||
|
||||
除了bigkey和Slot分配不均衡会导致数据量倾斜,还有一个导致倾斜的原因,就是使用了Hash Tag进行数据切片。
|
||||
|
||||
#### Hash Tag导致倾斜
|
||||
|
||||
Hash Tag是指加在键值对key中的一对花括号{}。这对括号会把key的一部分括起来,客户端在计算key的CRC16值时,只对Hash Tag花括号中的key内容进行计算。如果没用Hash Tag的话,客户端计算整个key的CRC16的值。
|
||||
|
||||
举个例子,假设key是user:profile:3231,我们把其中的3231作为Hash Tag,此时,key就变成了user:profile:{3231}。当客户端计算这个key的CRC16值时,就只会计算3231的CRC16值。否则,客户端会计算整个“user:profile:3231”的CRC16值。
|
||||
|
||||
使用Hash Tag的好处是,如果不同key的Hash Tag内容都是一样的,那么,这些key对应的数据会被映射到同一个Slot中,同时会被分配到同一个实例上。
|
||||
|
||||
下面这张表就显示了使用Hash Tag后,数据被映射到相同Slot的情况,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/36/d560b0161f1f786328dbd8a1df66d036.jpg" alt="">
|
||||
|
||||
其中,user:profile:{3231}和user:order:{3231}的Hash Tag一样,都是3231,它们的CRC16计算值对16384取模后的值也是一样的,所以就对应映射到了相同的Slot 1024中。user:profile:{5328}和user:order:{5328}也是相同的映射结果。
|
||||
|
||||
那么,Hash Tag一般用在什么场景呢?其实,它主要是用在Redis Cluster和Codis中,支持事务操作和范围查询。因为Redis Cluster和Codis本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。
|
||||
|
||||
这样操作起来非常麻烦,所以,我们可以使用Hash Tag把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。
|
||||
|
||||
但是,使用Hash Tag的潜在问题,就是大量的数据可能被集中到一个实例上,导致数据倾斜,集群中的负载不均衡。那么,该怎么应对这种问题呢?我们就需要在范围查询、事务执行的需求和数据倾斜带来的访问压力之间,进行取舍了。
|
||||
|
||||
我的建议是,如果使用Hash Tag进行切片的数据会带来较大的访问压力,就优先考虑避免数据倾斜,最好不要使用Hash Tag进行数据切片。因为事务和范围查询都还可以放在客户端来执行,而数据倾斜会导致实例不稳定,造成服务不可用。
|
||||
|
||||
好了,到这里,我们完整地了解了数据量倾斜的原因以及应对方法。接下来,我们再来看数据访问倾斜的原因和应对方法。
|
||||
|
||||
## 数据访问倾斜的成因和应对方法
|
||||
|
||||
发生数据访问倾斜的根本原因,就是实例上存在热点数据(比如新闻应用中的热点新闻内容、电商促销活动中的热门商品信息,等等)。
|
||||
|
||||
一旦热点数据被存在了某个实例中,那么,这个实例的请求访问量就会远高于其它实例,面临巨大的访问压力,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/20/94b1ca50143db1d09c60475fa7b41820.jpg" alt="">
|
||||
|
||||
那么,我们该如何应对呢?
|
||||
|
||||
和数据量倾斜不同,热点数据通常是一个或几个数据,所以,直接重新分配Slot并不能解决热点数据的问题。
|
||||
|
||||
通常来说,热点数据以服务读操作为主,在这种情况下,我们可以采用**热点数据多副本**的方法来应对。
|
||||
|
||||
这个方法的具体做法是,我们把热点数据复制多份,在每一个数据副本的key中增加一个随机前缀,让它和其它副本数据不会被映射到同一个Slot中。这样一来,热点数据既有多个副本可以同时服务请求,同时,这些副本数据的key又不一样,会被映射到不同的Slot中。在给这些Slot分配实例时,我们也要注意把它们分配到不同的实例上,那么,热点数据的访问压力就被分散到不同的实例上了。
|
||||
|
||||
这里,有个地方需要注意下,**热点数据多副本方法只能针对只读的热点数据**。如果热点数据是有读有写的话,就不适合采用多副本方法了,因为要保证多副本间的数据一致性,会带来额外的开销。
|
||||
|
||||
对于有读有写的热点数据,我们就要给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我向你介绍了数据倾斜的两种情况:数据量倾斜和数据访问倾斜。
|
||||
|
||||
造成数据量倾斜的原因主要有三个:
|
||||
|
||||
1. 数据中有bigkey,导致某个实例的数据量增加;
|
||||
1. Slot手工分配不均,导致某个或某些实例上有大量数据;
|
||||
1. 使用了Hash Tag,导致数据集中到某些实例上。
|
||||
|
||||
而数据访问倾斜的主要原因就是有热点数据存在,导致大量访问请求集中到了热点数据所在的实例上。
|
||||
|
||||
为了应对数据倾斜问题,我给你介绍了四个方法,也分别对应了造成数据倾斜的四个原因。我把它们总结在下表中,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/6f/092da1ee7425d20b1af4900ec8e9926f.jpg" alt="">
|
||||
|
||||
当然,如果已经发生了数据倾斜,我们可以通过数据迁移来缓解数据倾斜的影响。Redis Cluster和Codis集群都提供了查看Slot分配和手工迁移Slot的命令,你可以把它们应用起来。
|
||||
|
||||
最后,关于集群的实例资源配置,我再给你一个小建议:在构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的Slot。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,在有数据访问倾斜时,如果热点数据突然过期了,而Redis中的数据是缓存,数据的最终值保存在后端数据库,此时会发生什么问题?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
139
极客时间专栏/Redis核心技术与实战/实践篇/38 | 通信开销:限制Redis Cluster规模的关键因素.md
Normal file
139
极客时间专栏/Redis核心技术与实战/实践篇/38 | 通信开销:限制Redis Cluster规模的关键因素.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="38 | 通信开销:限制Redis Cluster规模的关键因素" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/dd/659052fe681a6fed9bd80702d75fcddd.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
Redis Cluster能保存的数据量以及支撑的吞吐量,跟集群的实例规模密切相关。Redis官方给出了Redis Cluster的规模上限,就是一个集群运行1000个实例。
|
||||
|
||||
那么,你可能会问,为什么要限定集群规模呢?其实,这里的一个关键因素就是,**实例间的通信开销会随着实例规模增加而增大**,在集群超过一定规模时(比如800节点),集群吞吐量反而会下降。所以,集群的实际规模会受到限制。
|
||||
|
||||
今天这节课,我们就来聊聊,集群实例间的通信开销是如何影响Redis Cluster规模的,以及如何降低实例间的通信开销。掌握了今天的内容,你就可以通过合理的配置来扩大Redis Cluster的规模,同时保持高吞吐量。
|
||||
|
||||
## 实例通信方法和对集群规模的影响
|
||||
|
||||
Redis Cluster在运行时,每个实例上都会保存Slot和实例的对应关系(也就是Slot映射表),以及自身的状态信息。
|
||||
|
||||
为了让集群中的每个实例都知道其它所有实例的状态信息,实例之间会按照一定的规则进行通信。这个规则就是Gossip协议。
|
||||
|
||||
Gossip协议的工作原理可以概括成两点。
|
||||
|
||||
一是,每个实例之间会按照一定的频率,从集群中随机挑选一些实例,把PING消息发送给挑选出来的实例,用来检测这些实例是否在线,并交换彼此的状态信息。PING消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及Slot映射表。
|
||||
|
||||
二是,一个实例在接收到PING消息后,会给发送PING消息的实例,发送一个PONG消息。PONG消息包含的内容和PING消息一样。
|
||||
|
||||
下图显示了两个实例间进行PING、PONG消息传递的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/86/5eacfc36c4233ae7c99f80b1511yyb86.jpg" alt="">
|
||||
|
||||
Gossip协议可以保证在一段时间后,集群中的每一个实例都能获得其它所有实例的状态信息。
|
||||
|
||||
这样一来,即使有新节点加入、节点故障、Slot变更等事件发生,实例间也可以通过PING、PONG消息的传递,完成集群状态在每个实例上的同步。
|
||||
|
||||
经过刚刚的分析,我们可以很直观地看到,实例间使用Gossip协议进行通信时,通信开销受到**通信消息大小**和**通信频率**这两方面的影响,
|
||||
|
||||
消息越大、频率越高,相应的通信开销也就越大。如果想要实现高效的通信,可以从这两方面入手去调优。接下来,我们就来具体分析下这两方面的实际情况。
|
||||
|
||||
首先,我们来看实例通信的消息大小。
|
||||
|
||||
### Gossip消息大小
|
||||
|
||||
Redis实例发送的PING消息的消息体是由clusterMsgDataGossip结构体组成的,这个结构体的定义如下所示:
|
||||
|
||||
```
|
||||
typedef struct {
|
||||
char nodename[CLUSTER_NAMELEN]; //40字节
|
||||
uint32_t ping_sent; //4字节
|
||||
uint32_t pong_received; //4字节
|
||||
char ip[NET_IP_STR_LEN]; //46字节
|
||||
uint16_t port; //2字节
|
||||
uint16_t cport; //2字节
|
||||
uint16_t flags; //2字节
|
||||
uint32_t notused1; //4字节
|
||||
} clusterMsgDataGossip;
|
||||
|
||||
```
|
||||
|
||||
其中,CLUSTER_NAMELEN和NET_IP_STR_LEN的值分别是40和46,分别表示,nodename和ip这两个字节数组的长度是40字节和46字节,我们再把结构体中其它信息的大小加起来,就可以得到一个Gossip消息的大小了,即104字节。
|
||||
|
||||
每个实例在发送一个Gossip消息时,除了会传递自身的状态信息,默认还会传递集群十分之一实例的状态信息。
|
||||
|
||||
所以,对于一个包含了1000个实例的集群来说,每个实例发送一个PING消息时,会包含100个实例的状态信息,总的数据量是 10400字节,再加上发送实例自身的信息,一个Gossip消息大约是10KB。
|
||||
|
||||
此外,为了让Slot映射表能够在不同实例间传播,PING消息中还带有一个长度为 16,384 bit 的 Bitmap,这个Bitmap的每一位对应了一个Slot,如果某一位为1,就表示这个Slot属于当前实例。这个Bitmap大小换算成字节后,是2KB。我们把实例状态信息和Slot分配信息相加,就可以得到一个PING消息的大小了,大约是12KB。
|
||||
|
||||
PONG消息和PING消息的内容一样,所以,它的大小大约是12KB。每个实例发送了PING消息后,还会收到返回的PONG消息,两个消息加起来有24KB。
|
||||
|
||||
虽然从绝对值上来看,24KB并不算很大,但是,如果实例正常处理的单个请求只有几KB的话,那么,实例为了维护集群状态一致传输的PING/PONG消息,就要比单个业务请求大了。而且,每个实例都会给其它实例发送PING/PONG消息。随着集群规模增加,这些心跳消息的数量也会越多,会占据一部分集群的网络通信带宽,进而会降低集群服务正常客户端请求的吞吐量。
|
||||
|
||||
除了心跳消息大小会影响到通信开销,如果实例间通信非常频繁,也会导致集群网络带宽被频繁占用。那么,Redis Cluster中实例的通信频率是什么样的呢?
|
||||
|
||||
### 实例间通信频率
|
||||
|
||||
Redis Cluster的实例启动后,默认会每秒从本地的实例列表中随机选出5个实例,再从这5个实例中找出一个最久没有通信的实例,把PING消息发送给该实例。这是实例周期性发送PING消息的基本做法。
|
||||
|
||||
但是,这里有一个问题:实例选出来的这个最久没有通信的实例,毕竟是从随机选出的5个实例中挑选的,这并不能保证这个实例就一定是整个集群中最久没有通信的实例。
|
||||
|
||||
所以,这有可能会出现,**有些实例一直没有被发送PING消息,导致它们维护的集群状态已经过期了**。
|
||||
|
||||
为了避免这种情况,Redis Cluster的实例会按照每100ms一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG消息的时间,已经大于配置项 cluster-node-timeout的一半了(cluster-node-timeout/2),就会立刻给该实例发送 PING消息,更新这个实例上的集群状态信息。
|
||||
|
||||
当集群规模扩大之后,因为网络拥塞或是不同服务器间的流量竞争,会导致实例间的网络通信延迟增加。如果有部分实例无法收到其它实例发送的PONG消息,就会引起实例之间频繁地发送PING消息,这又会对集群网络通信带来额外的开销了。
|
||||
|
||||
我们来总结下单实例每秒会发送的PING消息数量,如下所示:
|
||||
|
||||
>
|
||||
PING消息发送数量 = 1 + 10 * 实例数(最近一次接收PONG消息的时间超出cluster-node-timeout/2)
|
||||
|
||||
|
||||
其中,1是指单实例常规按照每1秒发送一个PING消息,10是指每1秒内实例会执行10次检查,每次检查后会给PONG消息超时的实例发送消息。
|
||||
|
||||
我来借助一个例子,带你分析一下在这种通信频率下,PING消息占用集群带宽的情况。
|
||||
|
||||
假设单个实例检测发现,每100毫秒有10个实例的PONG消息接收超时,那么,这个实例每秒就会发送101个PING消息,约占1.2MB/s带宽。如果集群中有30个实例按照这种频率发送消息,就会占用36MB/s带宽,这就会挤占集群中用于服务正常请求的带宽。
|
||||
|
||||
所以,我们要想办法降低实例间的通信开销,那该怎么做呢?
|
||||
|
||||
## 如何降低实例间的通信开销?
|
||||
|
||||
为了降低实例间的通信开销,从原理上说,我们可以减小实例传输的消息大小(PING/PONG消息、Slot分配信息),但是,因为集群实例依赖PING、PONG消息和Slot分配信息,来维持集群状态的统一,一旦减小了传递的消息大小,就会导致实例间的通信信息减少,不利于集群维护,所以,我们不能采用这种方式。
|
||||
|
||||
那么,我们能不能降低实例间发送消息的频率呢?我们先来分析一下。
|
||||
|
||||
经过刚才的学习,我们现在知道,实例间发送消息的频率有两个。
|
||||
|
||||
- 每个实例每1秒发送一条PING消息。这个频率不算高,如果再降低该频率的话,集群中各实例的状态可能就没办法及时传播了。
|
||||
- 每个实例每100毫秒会做一次检测,给PONG消息接收超过cluster-node-timeout/2的节点发送PING消息。实例按照每100毫秒进行检测的频率,是Redis实例默认的周期性检查任务的统一频率,我们一般不需要修改它。
|
||||
|
||||
那么,就只有cluster-node-timeout这个配置项可以修改了。
|
||||
|
||||
配置项cluster-node-timeout定义了集群实例被判断为故障的心跳超时时间,默认是15秒。如果cluster-node-timeout值比较小,那么,在大规模集群中,就会比较频繁地出现PONG消息接收超时的情况,从而导致实例每秒要执行10次“给PONG消息超时的实例发送PING消息”这个操作。
|
||||
|
||||
所以,为了避免过多的心跳消息挤占集群带宽,我们可以调大cluster-node-timeout值,比如说调大到20秒或25秒。这样一来, PONG消息接收超时的情况就会有所缓解,单实例也不用频繁地每秒执行10次心跳发送操作了。
|
||||
|
||||
当然,我们也不要把cluster-node-timeout调得太大,否则,如果实例真的发生了故障,我们就需要等待cluster-node-timeout时长后,才能检测出这个故障,这又会导致实际的故障恢复时间被延长,会影响到集群服务的正常使用。
|
||||
|
||||
为了验证调整cluster-node-timeout值后,是否能减少心跳消息占用的集群网络带宽,我给你提个小建议:**你可以在调整cluster-node-timeout值的前后,使用tcpdump命令抓取实例发送心跳信息网络包的情况**。
|
||||
|
||||
例如,执行下面的命令后,我们可以抓取到192.168.10.3机器上的实例从16379端口发送的心跳网络包,并把网络包的内容保存到r1.cap文件中:
|
||||
|
||||
```
|
||||
tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap
|
||||
|
||||
```
|
||||
|
||||
通过分析网络包的数量和大小,就可以判断调整cluster-node-timeout值前后,心跳消息占用的带宽情况了。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我向你介绍了Redis Cluster实例间以Gossip协议进行通信的机制。Redis Cluster运行时,各实例间需要通过PING、PONG消息进行信息交换,这些心跳消息包含了当前实例和部分其它实例的状态信息,以及Slot分配信息。这种通信机制有助于Redis Cluster中的所有实例都拥有完整的集群状态信息。
|
||||
|
||||
但是,随着集群规模的增加,实例间的通信量也会增加。如果我们盲目地对Redis Cluster进行扩容,就可能会遇到集群性能变慢的情况。这是因为,集群中大规模的实例间心跳消息会挤占集群处理正常请求的带宽。而且,有些实例可能因为网络拥塞导致无法及时收到PONG消息,每个实例在运行时会周期性地(每秒10次)检测是否有这种情况发生,一旦发生,就会立即给这些PONG消息超时的实例发送心跳消息。集群规模越大,网络拥塞的概率就越高,相应的,PONG消息超时的发生概率就越高,这就会导致集群中有大量的心跳消息,影响集群服务正常请求。
|
||||
|
||||
最后,我也给你一个小建议,虽然我们可以通过调整cluster-node-timeout配置项减少心跳消息的占用带宽情况,但是,在实际应用中,如果不是特别需要大容量集群,我建议你把Redis Cluster 的规模控制在400~500个实例。
|
||||
|
||||
假设单个实例每秒能支撑8万请求操作(8万QPS),每个主实例配置1个从实例,那么,400~ 500个实例可支持 1600万~2000万QPS(200/250个主实例*8万QPS=1600/2000万QPS),这个吞吐量性能可以满足不少业务应用的需求。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,如果我们采用跟Codis保存Slot分配信息相类似的方法,把集群实例状态信息和Slot分配信息保存在第三方的存储系统上(例如Zookeeper),这种方法会对集群规模产生什么影响吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
108
极客时间专栏/Redis核心技术与实战/开篇词/开篇词 | 这样学Redis,才能技高一筹.md
Normal file
108
极客时间专栏/Redis核心技术与实战/开篇词/开篇词 | 这样学Redis,才能技高一筹.md
Normal file
@@ -0,0 +1,108 @@
|
||||
<audio id="audio" title="开篇词 | 这样学Redis,才能技高一筹" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/cb/0714352c6e016e7e68yy853813542fcb.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧,欢迎和我一起学习Redis。
|
||||
|
||||
我博士毕业后,就一直在中科院计算所工作,现在的职位是副研究员。在过去的14年时间里,我一直从事互联网底层基础设施方面的研究工作,主要的研究方向为新型存储介质、键值数据库、存储系统和操作系统。
|
||||
|
||||
2015年的时候,我和我的团队接到了一个高难度任务,目标是设计一个单机性能达到千万级吞吐量的键值数据库。为了实现这个目标,我们就开始重点研究Redis,从此,我就和这个数据库结缘了。
|
||||
|
||||
作为键值数据库,Redis的应用非常广泛,如果你是后端工程师,我猜你出去面试,八成都会被问到与它相关的性能问题。比如说,为了保证数据的可靠性,Redis需要在磁盘上读写AOF和RDB,但在高并发场景里,这就会直接带来两个新问题:一个是写AOF和RDB会造成Redis性能抖动,另一个是Redis集群数据同步和实例恢复时,读RDB比较慢,限制了同步和恢复速度。
|
||||
|
||||
那这个问题有没有好的解决方法呢?哈哈,这里我卖了个关子。其实,一个可行的解决方案就是使用非易失内存NVM,因为它既能保证高速的读写,又能快速持久化数据。我和团队就在NVM的键值数据库上开展了诸多深入研究,先后申请了二十余项专利,也在顶级学术会议上发表了学术论文。
|
||||
|
||||
当然,这些研究最后都是为了完成一开始说的那个大目标:设计一个单机千万级吞吐量的键值数据库。在这个过程中,我也深入、透彻地研究了Redis,包括它的源代码、架构设计以及核心控制点。
|
||||
|
||||
另外,因为各大互联网公司在Redis应用方面比较超前,场景比较丰富,他们会遇到各种各样的棘手问题,所以这几年,我和蚂蚁金服、百度、华为、中兴等公司都有合作,致力于和他们一起解决生产环境的各种疑难杂症。
|
||||
|
||||
最后,对标Redis,我们团队也研发了高性能键值数据库[HiKV](https://www.usenix.org/conference/atc17/technical-sessions/presentation/xia)等,你感兴趣的话,可以点开链接看看整体的设计。
|
||||
|
||||
正是因为有这样的研究和项目经历,让我目睹了同样是使用Redis,但是不同公司的“玩法”却不太一样,比如说,有做缓存的,有做数据库的,也有用做分布式锁的。不过,他们遇见的“坑”,总体来说集中在四个方面:
|
||||
|
||||
- CPU使用上的“坑”,例如数据结构的复杂度、跨CPU核的访问;
|
||||
- 内存使用上的“坑”,例如主从同步和AOF的内存竞争;
|
||||
- 存储持久化上的“坑”,例如在SSD上做快照的性能抖动;
|
||||
- 网络通信上的“坑”,例如多实例时的异常网络丢包。
|
||||
|
||||
随着这些深入的研究、实战操作、案例积累,我拥有了一套从原理到实战的Redis知识总结。这一次,我想把我多年积累的经验分享给你。
|
||||
|
||||
## 为什么懂得了一个个技术点,却依然用不好Redis?
|
||||
|
||||
我知道,很多同学都是带着一个个具体的问题来学这门课的,比如说,Redis数据怎么做持久化?集群方案应该怎么做?这些问题当然很重要,但是,如果你只是急于解决这些细微的问题,你的Redis使用能力就很难得到质的提升。
|
||||
|
||||
这些年,在和国内大厂的合作过程中,我发现,很多技术人都有一个误区,那就是,**只关注零散的技术点,没有建立起一套完整的知识框架,缺乏系统观,但是,系统观其实是至关重要的。**从某种程度上说,在解决问题时,拥有了系统观,就意味着你能有依据、有章法地定位和解决问题。
|
||||
|
||||
说到这儿,我想跟你分享一个小案例。
|
||||
|
||||
现在,很多大厂的Redis服务面临的请求规模很大,因此,在评估性能时,仅仅看平均延迟已经不够了。我简单举个例子,假设Redis处理了100个请求,99个请求的响应时间都是1s,而有一个请求的响应时间是100s。那么,如果看平均延迟,这100个请求的平均延迟是1.99s,但是对于这个响应时间是100s的请求而言,它对应的用户体验将是非常糟糕的。如果有100万个请求,哪怕只有1%的请求是100s,这也对应了1万个糟糕的用户体验。这1%的请求延迟就属于长尾延迟。
|
||||
|
||||
我之前在做一个项目的时候,要把Redis的长尾延迟维持在一定阈值以下。你可以想一下,如果是你,你会怎么做呢?
|
||||
|
||||
刚开始的时候,我有些无从下手,因为那个时候,我并不清楚跟长尾延迟相关的东西都有哪些,只能一个个摸索。
|
||||
|
||||
首先,我对Redis的线程模型做了分析,我发现,对于单线程的Redis而言,任何阻塞性操作都会导致长尾延迟的产生。接着,我又开始寻找可能导致阻塞的关键因素,一开始想到的是网络阻塞,但随着对Redis网络框架的理解,我知道Redis网络IO使用了IO复用机制,并不会阻塞在单个客户端上。
|
||||
|
||||
再后来,我又把目光转向了键值对数据结构、持久化机制下的fork调用、主从库同步时的AOF重写,以及缓冲区溢出等多个方面。绕了一大圈子之后,这条影响长尾延迟的“证据链”才得以形成。这样一来,我也系统地掌握了影响Redis性能的关键因素路径,之后再碰到类似的问题时,我就可以轻松解决了。
|
||||
|
||||
那么,如何高效地形成系统观呢?我们做事情一般都希望“多快好省”,说白了,就是希望花很少的时间掌握更丰富的知识和经验,解决更多的问题。听起来好像很难,但实际上,只要你能抓住主线,在自己的脑海中绘制一幅Redis全景知识图,这完全是可以实现的。而这,也是我在设计这门课时,所遵循的思路。
|
||||
|
||||
那么,所谓的Redis知识全景图都包括什么呢?简单来说,就是“两大维度,三大主线”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/e7/79da7093ed998a99d9abe91e610b74e7.jpg" alt="">
|
||||
|
||||
“两大维度”就是指系统维度和应用维度,“三大主线”也就是指高性能、高可靠和高可扩展(可以简称为“三高”)。
|
||||
|
||||
首先,从系统维度上说,你需要了解Redis的各项关键技术的设计原理,这些能够为你判断和推理问题打下坚实的基础,而且,你还能从中掌握一些优雅的系统设计规范,例如run-to-complete模型、epoll网络模型,这些可以应用到你后续的系统开发实践中。
|
||||
|
||||
这里有一个问题是,Redis作为庞大的键值数据库,可以说遍地都是知识,一抓一大把,我们怎么能快速地知道该学哪些呢?别急,接下来就要看“三大主线”的魔力了。
|
||||
|
||||
别看技术点是零碎的,其实你完全可以按照这三大主线,给它们分下类,就像图片中展示的那样,具体如下:
|
||||
|
||||
- **高性能主线**,包括线程模型、数据结构、持久化、网络框架;
|
||||
- **高可靠主线**,包括主从复制、哨兵机制;
|
||||
- **高可扩展主线**,包括数据分片、负载均衡。
|
||||
|
||||
你看,这样,你就有了一个结构化的知识体系。当你遇见这些问题时,就可以按图索骥,快速找到影响这些问题的关键因素,这是不是非常省时省力呢?
|
||||
|
||||
其次,在应用维度上,我建议你按照两种方式学习: “**应用场景驱动**”和“**典型案例驱动**”,一个是“面”的梳理,一个是“点”的掌握。
|
||||
|
||||
我们知道,缓存和集群是Redis的两大广泛的应用场景。在这些场景中,本身就具有一条显式的技术链。比如说,提到缓存场景,你肯定会想到缓存机制、缓存替换、缓存异常等一连串的问题。
|
||||
|
||||
不过,并不是所有的东西都适合采用这种方式,比如说Redis丰富的数据模型,就导致它有很多零碎的应用场景,很多很杂。而且,还有一些问题隐藏得比较深,只有特定的业务场景下(比如亿级访问压力场景)才会出现,并不是普遍现象,所以,我们也比较难于梳理出结构化的体系。
|
||||
|
||||
这个时候,你就可以用“典型案例驱动”的方式学习了。我们可以重点解读一些对Redis的“三高”特性影响较大的使用案例,例如,多家大厂在万亿级访问量和万亿级数据量的情况下对Redis的深度优化,解读这些优化实践,非常有助于你透彻地理解Redis。而且,你还可以梳理一些方法论,做成Checklist,就像是一个个锦囊,之后当你遇到问题的时候,就可以随时拿出自己的“锦囊妙计”解决问题了。
|
||||
|
||||
最后,我还想跟你分享一个非常好用的技巧。我梳理了一下这些年遇到的、看到的Redis各大典型问题,同时结合相关的技术点,手绘了一张Redis的问题画像图。无论你遇见什么问题,都可以拿出来这张图,这样你就能快速地按照问题来查找对应的Redis主线模块了,然后再进一步定位到相应的技术点上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/b4/70a5bc1ddc9e3579a2fcb8a5d44118b4.jpeg" alt="">
|
||||
|
||||
举个例子,如果你遇到了Redis的响应变慢问题,对照着这张图,你就可以发现,这个问题和Redis的性能主线相关,而性能主线又和数据结构、异步机制、RDB、AOF重写相关。找到了影响的因素,解决起来也就很容易了。
|
||||
|
||||
另外,在学习和使用的过程中,你完全可以根据你自己的方式,完善这张画像图,把你自己实践或掌握到的新知识点,按照“问题 --> 主线 --> 技术点”的方式梳理出来,放到这张图上。这样一来,你的积累越来越多,画像也会越来越丰富。以后在遇到问题的时候,就很容易解决了。
|
||||
|
||||
## 课程是如何设计的?
|
||||
|
||||
刚刚说的这些,其实也正是咱们这个课程的核心设计理念。接下来,我就说说这门课具体是怎么设计的。
|
||||
|
||||
**基础篇:打破技术点之间的壁垒,带你建立网状知识结构**
|
||||
|
||||
我会先从构造一个简单的键值数据库入手,带你庖丁解牛。这有点像是建房子,只有顶梁柱确定了,房子有形了,你才能去想“怎么设计更美、更实用”的问题。因此,在“基础篇”,我会具体讲解数据结构、线程模型、持久化等几根“顶梁柱”,让你不仅能抓住重点,还能明白它们在整体框架中的地位和作用,以及它们之间的相互联系。明白了这些,也就打好了基础。
|
||||
|
||||
**实践篇:场景和案例驱动,取人之长,梳理出一套属于你自己的“武林秘籍”**
|
||||
|
||||
前面说过,从应用的维度来说,在学习时,我们需要以“场景”和“案例”作为驱动。因此,在“实践篇”,我也会从这两大层面来进行讲解。
|
||||
|
||||
在“案例”层面,我会介绍数据结构的合理使用、避免请求阻塞和抖动、避免内存竞争和提升内存使用效率的关键技巧;在“场景”层面,我会重点介绍缓存和集群两大场景。
|
||||
|
||||
对于缓存而言,我会重点讲解缓存基本原理及淘汰策略,还有雪崩、穿透、污染等异常情况;对于集群来说,我会围绕集群方案优化、数据一致性、高并发访问等问题,和你聊聊可行的解决方案。
|
||||
|
||||
**未来篇:具有前瞻性,解锁新特性**
|
||||
|
||||
Redis 6.0刚刚推出,增加了万众瞩目的多线程等新特性,因此,我会向你介绍这些新特性,以及当前业界对Redis的最新探索,这会让你拥有前瞻性视角,了解Redis的发展路线图,为未来的发展提前做好准备。凡事预则立,这样一来,你就可以走在很多人的前面。
|
||||
|
||||
除此之外,我还会不定期进行加餐,跟你分享一些好的运维工具、定制化客户端开发的方法、经典的学习资料,等等,还会策划一些答疑,及时解决你的疑惑。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/7e/13946f7543f9eea58c9bd2b877826b7e.jpg" alt="">
|
||||
|
||||
最后,我想说,Redis是一个非常优秀的系统,它在CPU使用、内存组织、存储持久化和网络通信这四大方面的设计非常经典,而这些,基本涵盖了一个优秀的后端系统工程师需要掌握的核心知识和关键技术。希望你通过这个课程的学习,成长为一名优秀的系统工程师。
|
||||
|
||||
不过,一个人单枪匹马地去学习,往往很难坚持下去。如果你身边也有在使用Redis的同学,我希望你能帮忙把这个课程分享给他/她,你们可以一起学习,互相鼓励。欢迎多多给我留言,你们的鼓励是我持续产出好内容的动力。
|
||||
50
极客时间专栏/Redis核心技术与实战/期中测试/期中测试题 | 一套习题,测出你的掌握程度.md
Normal file
50
极客时间专栏/Redis核心技术与实战/期中测试/期中测试题 | 一套习题,测出你的掌握程度.md
Normal file
@@ -0,0 +1,50 @@
|
||||
<audio id="audio" title="期中测试题 | 一套习题,测出你的掌握程度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/79/38f6acb1de1d1e6c28fbe8f54293f579.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
咱们的课程已经更新过半了,我看到很多同学一直在坚持学习,课程每次更新后,都会认真回答课后题,而且还会分享自己的思考和经验。但是,我也发现,最近有不少同学都掉队了,积累了很多节课都没有学习过。
|
||||
|
||||
从今天开始,我们就进入期中周了。我知道,很多同学平时确实比较忙,想等到有了大块的时间再来学习。所以,在刚开始设计课程时,我就特意设置了期中周。巧的是,我们的期中周刚好和国庆黄金周重合了。那么,现在,就是你赶上进度的好机会。
|
||||
|
||||
在开始做题之前,我想多说几句。
|
||||
|
||||
Redis的知识点比较多,而且一旦涉及到性能优化、可靠性保证等需求时,我们就需要和进程、线程、内存管理、磁盘IO、网络连接等计算机底层系统知识打交道。如果你不熟悉底层系统的知识,在学习Redis时,就需要进一步查资料。但是我们平时都很忙,可能会来不及查资料,过一段时间可能就忘了,再想学习时,就需要重新了解,学习成本比较高。
|
||||
|
||||
针对这个问题,我想给你分享一下我自己的学习方法。我会用一个word文档或者其他的笔记软件,把涉及到的知识点先记录下来。对于那些我没搞清楚的知识点,我会把它们标记为红色,表明这是一个to-do项。等我有空的时候,我就会把这个文档拿出来,挨个儿去查看那些标红的知识点,查找相关的资料,补上知识漏洞。
|
||||
|
||||
你可以不要小瞧这个文档,日积月累下来,这就是你的知识宝库。你已经了解的知识点以及还需要进一步学习的知识点,在文档中一目了然。而且,咱们常说“温故而知新”,这个文档就是一个“温故知新”的好材料。
|
||||
|
||||
另外,我知道很多同学还有一个疑惑:在学习的时候感觉自己都明白了,但是,真正应用的时候,发现自己又说不清楚或者是想不明白。其实,一个潜在的原因就是,我们对技术点的掌握还不牢固,没有形成自己内在的一套知识体系。
|
||||
|
||||
所以,我再给你推荐一个非常有用的学习方法,那就是“**转述**”。每学完一节课之后,就找一个小伙伴,把你学到的内容讲给他/她听。如果对方能听明白,就表示你理解了这些内容。我们自己在讲述内容的时候,潜意识会自动梳理知识点以及它们之间的逻辑关系。当然,你也可以写成一篇文章,如果你发现自己讲不清楚,或者是写不出来,那就代表,你对这些内容的理解有偏差,或者是没有把它们纳入你自己的知识体系。这个时候,你一定要找出来知识盲区,及时在留言区提出来,和我或者是其他小伙伴一起交流讨论。
|
||||
|
||||
好了,那话不多说,接下来就准备来自测一下吧。我给你出了一套测试题,包括一套选择题和一套问答题。
|
||||
|
||||
- 选择题:满分共100分,包含15道单选题和5道多选题。提交试卷之后,系统自动评分。
|
||||
- 问答题:包括3道题目,不计入分数,但我希望你能认真回答这些问题,可以把你的答案写在留言区。在10月7日这一天,我会公布答案。
|
||||
|
||||
## 选择题
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=215&exam_id=662)
|
||||
|
||||
## 问答题
|
||||
|
||||
### 第一题
|
||||
|
||||
Redis在接收多个网络客户端发送的请求操作时,如果有一个客户端和Redis的网络连接断开了,Redis会一直等待该客户端恢复连接吗?为什么?
|
||||
|
||||
### 第二题
|
||||
|
||||
Redis的主从集群可以提升数据可靠性,主节点在和从节点进行数据同步时,会使用两个缓冲区:复制缓冲区和复制积压缓冲区,这两个缓冲区的作用各是什么?会对Redis主从同步产生什么影响吗?
|
||||
|
||||
### 第三题
|
||||
|
||||
假设在业务场景中,我们有20GB的短视频属性信息(包括短视频ID、短视频基本信息,例如短视频作者、创建时间等)要持久化保存,并且线上负载以读为主,需要能快速查询到这些短视频信息。
|
||||
|
||||
现在,我们想使用Redis来实现这个需求,请你来设计一个解决方案。我来提几个问题,你可以思考下。
|
||||
|
||||
首先,你会用Redis的什么数据类型来保存数据?如果我们只用单个实例来运行的话,你会采用什么样的持久化方案来保证数据的可靠性?
|
||||
|
||||
其次,如果不使用单实例运行,我们有两个备选方案:一个是用两台32GB内存的云主机来运行主从两个Redis实例;另一个是用10台8GB的云主机来运行Redis Cluster,每两台云主机分别运行一个Redis实例主库和从库,分别保存4GB数据,你会用哪种方案呢?请聊一聊你的想法。
|
||||
|
||||
好了,这节课就到这里。希望你能抓住期中周的机会,查漏补缺,快速地提升Redis实战能力。我们10月7日见!
|
||||
60
极客时间专栏/Redis核心技术与实战/期中测试/期中测试题答案 | 这些问题,你都答对了吗?.md
Normal file
60
极客时间专栏/Redis核心技术与实战/期中测试/期中测试题答案 | 这些问题,你都答对了吗?.md
Normal file
@@ -0,0 +1,60 @@
|
||||
<audio id="audio" title="期中测试题答案 | 这些问题,你都答对了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/y0/fa0637a18da931665fde2yy77da61yy0.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。今天,我来公布一下主观题的答案。
|
||||
|
||||
### 第一题
|
||||
|
||||
Redis在接收多个网络客户端发送的请求操作时,如果有一个客户端和Redis的网络连接断开了,Redis会一直等待该客户端恢复连接吗?为什么?
|
||||
|
||||
答案:
|
||||
|
||||
Redis不会等待客户端恢复连接。<br>
|
||||
原因是,Redis的网络连接是由操作系统进行处理的,操作系统内核负责监听网络连接套接字上的连接请求或数据请求,而Redis采用了IO多路复用机制epoll,不会阻塞在某一个特定的套接字上。epoll机制监测到套接字上有请求到达时,就会触发相应的事件,并把事件放到一个队列中,Redis就会对这个事件队列中的事件进行处理。这样一来,Redis只用查看和处理事件队列,就可以了。当客户端网络连接断开或恢复时,操作系统会进行处理,并且在客户端能再次发送请求时,把接收到的请求以事件形式通知Redis。
|
||||
|
||||
### 第二题
|
||||
|
||||
Redis的主从集群可以提升数据可靠性,主节点在和从节点进行数据同步时,会使用两个缓冲区:复制缓冲区和复制积压缓冲区。这两个缓冲区的作用各是什么?会对Redis主从同步产生什么影响吗?
|
||||
|
||||
答案:
|
||||
|
||||
首先来说一下复制缓冲区。
|
||||
|
||||
**作用:**主节点开始和一个从节点进行全量同步时,会为从节点创建一个输出缓冲区,这个缓冲区就是复制缓冲区。当主节点向从节点发送RDB文件时,如果又接收到了写命令操作,就会把它们暂存在复制缓冲区中。等RDB文件传输完成,并且在从节点加载完成后,主节点再把复制缓冲区中的写命令发给从节点,进行同步。
|
||||
|
||||
**对主从同步的影响:**如果主库传输RDB文件以及从库加载RDB文件耗时长,同时主库接收的写命令操作较多,就会导致复制缓冲区被写满而溢出。一旦溢出,主库就会关闭和从库的网络连接,重新开始全量同步。所以,我们可以通过调整client-output-buffer-limit slave这个配置项,来增加复制缓冲区的大小,以免复制缓冲区溢出。
|
||||
|
||||
再来看看复制积压缓冲区。
|
||||
|
||||
**作用:**主节点和从节点进行常规同步时,会把写命令也暂存在复制积压缓冲区中。如果从节点和主节点间发生了网络断连,等从节点再次连接后,可以从复制积压缓冲区中同步尚未复制的命令操作。
|
||||
|
||||
**对主从同步的影响:**如果从节点和主节点间的网络断连时间过长,复制积压缓冲区可能被新写入的命令覆盖。此时,从节点就没有办法和主节点进行增量复制了,而是只能进行全量复制。针对这个问题,应对的方法是调大复制积压缓冲区的大小(可以参考[第6讲](https://time.geekbang.org/column/article/272852)中对repl_backlog_size的设置)。
|
||||
|
||||
### 第三题
|
||||
|
||||
假设在业务场景中,我们有20GB的短视频属性信息(包括短视频ID、短视频基本信息,例如短视频作者、创建时间等)要持久化保存,并且线上负载以读为主,需要能快速查询到这些短视频信息。
|
||||
|
||||
现在,针对这个需求,我们想使用Redis来解决,请你来设计一个解决方案。我来提几个问题,你可以思考下。
|
||||
|
||||
首先,你会用Redis的什么数据类型来保存数据?如果我们只用单个实例来运行的话,你会采用什么样的持久化方案来保证数据的可靠性?
|
||||
|
||||
另外,如果不使用单实例运行,我们有两个备选方案:一个是用两台32GB内存的云主机来运行主从两个Redis实例;另一个是用10台8GB的云主机来运行Redis Cluster,每两台云主机分别运行一个Redis实例主库和从库,分别保存4GB数据,你会用哪种方案呢?请聊一聊你的想法。
|
||||
|
||||
答案:
|
||||
|
||||
Redis的Hash类型属于典型的集合类型,可以保存key-value形式的数据。而且,当Hash类型中保存较多数据时,它的底层是由哈希表实现的。哈希表的存取复杂度是O(1),所以可以实现快速访问。在这道题中,短视频属性信息属于典型key-value形式,所以,我们可以使用Hash类型保存短视频信息。具体来说,就是将一个短视频ID作为Hash集合的key,将短视频的其他属性信息作为Hash集合内部的键值对,例如“作者”:“实际姓名”,“创建时间”:“实际时间”。这样既满足了保存数据的需求,也可以利用Hash快速查询的特点,快速查到相应的信息。
|
||||
|
||||
Redis的AOF日志会记录客户端发送给实例的每一次写操作命令,在Redis实例恢复时,可以通过重新运行AOF文件中的命令,实现恢复数据的目的。在这道题的业务场景中,负载以读为主,因此,写命令不会太多,AOF日志文件的体量不会太大,即使实例故障了,也可以快速完成恢复。所以,当使用单实例运行时,我们可以使用AOF日志来做持久化方案。
|
||||
|
||||
关于使用多实例的运行方案:两种方案各有优势,我们来分析一下。
|
||||
|
||||
#### 方案一
|
||||
|
||||
优势:可以节省云主机数量和成本。虽然主从节点进行第一次全量同步时,RDB文件较大,耗时会长些,但是因为写请求少,所以复制缓冲区的压力不大。<br>
|
||||
不足:如果网络环境不好,需要频繁地进行全量同步的话,这种方案的优势就小了,每次全量同步时的RDB生成和传输压力都很大。
|
||||
|
||||
#### 方案二
|
||||
|
||||
优势:每个实例只用保存4GB数据,和从库同步时的压力较小。而且,这种方案的可扩展性更好,如果有新增数据,可以更好地应对。<br>
|
||||
不足:需要较多的云主机,运维和资源成本较高。
|
||||
|
||||
好了,这节课就到这里。假期很快就要结束了,希望你抓住最后的几天时间,好好地巩固一下所学的内容。我们下节课见。
|
||||
187
极客时间专栏/Redis核心技术与实战/未来篇/39 | Redis 6.0的新特性:多线程、客户端缓存与安全.md
Normal file
187
极客时间专栏/Redis核心技术与实战/未来篇/39 | Redis 6.0的新特性:多线程、客户端缓存与安全.md
Normal file
@@ -0,0 +1,187 @@
|
||||
<audio id="audio" title="39 | Redis 6.0的新特性:多线程、客户端缓存与安全" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/7b/a9f6891ca274e7155b4826beceebc57b.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
Redis官方在今年5月份正式推出了6.0版本,这个版本中有很多的新特性。所以,6.0刚刚推出,就受到了业界的广泛关注。
|
||||
|
||||
所以,在课程的最后,我特意安排了这节课,想来和你聊聊Redis 6.0中的几个关键新特性,分别是面向网络处理的多IO线程、客户端缓存、细粒度的权限控制,以及RESP 3协议的使用。
|
||||
|
||||
其中,面向网络处理的多IO线程可以提高网络请求处理的速度,而客户端缓存可以让应用直接在客户端本地读取数据,这两个特性可以提升Redis的性能。除此之外,细粒度权限控制让Redis可以按照命令粒度控制不同用户的访问权限,加强了Redis的安全保护。RESP 3协议则增强客户端的功能,可以让应用更加方便地使用Redis的不同数据类型。
|
||||
|
||||
只有详细掌握了这些特性的原理,你才能更好地判断是否使用6.0版本。如果你已经在使用6.0了,也可以看看怎么才能用得更好,少踩坑。
|
||||
|
||||
首先,我们来了解下6.0版本中新出的多线程特性。
|
||||
|
||||
## 从单线程处理网络请求到多线程处理
|
||||
|
||||
**在Redis 6.0中,非常受关注的第一个新特性就是多线程**。这是因为,Redis一直被大家熟知的就是它的单线程架构,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF重写),但是,从网络IO处理到实际的读写命令处理,都是由单个线程完成的。
|
||||
|
||||
随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络IO的处理上,也就是说,**单个主线程处理网络请求的速度跟不上底层网络硬件的速度**。
|
||||
|
||||
为了应对这个问题,一般有两种方法。
|
||||
|
||||
第一种方法是,用用户态网络协议栈(例如DPDK)取代内核网络协议栈,让网络请求的处理不用在内核里执行,直接在用户态完成处理就行。
|
||||
|
||||
对于高性能的Redis来说,避免频繁让内核进行网络请求处理,可以很好地提升请求处理效率。但是,这个方法要求在Redis的整体架构中,添加对用户态网络协议栈的支持,需要修改Redis源码中和网络相关的部分(例如修改所有的网络收发请求函数),这会带来很多开发工作量。而且新增代码还可能引入新Bug,导致系统不稳定。所以,Redis 6.0中并没有采用这个方法。
|
||||
|
||||
第二种方法就是采用多个IO线程来处理网络请求,提高网络请求处理的并行度。Redis 6.0就是采用的这种方法。
|
||||
|
||||
但是,Redis的多IO线程只是用来处理网络请求的,对于读写命令,Redis仍然使用单线程来处理。这是因为,Redis处理请求时,网络处理经常是瓶颈,通过多个IO线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证Lua脚本、事务的原子性,额外开发多线程互斥机制了。这样一来,Redis线程模型实现就简单了。
|
||||
|
||||
我们来看下,在Redis 6.0中,主线程和IO线程具体是怎么协作完成请求处理的。掌握了具体原理,你才能真正地会用多线程。为了方便你理解,我们可以把主线程和多IO线程的协作分成四个阶段。
|
||||
|
||||
**阶段一:服务端和客户端建立Socket连接,并分配处理线程**
|
||||
|
||||
首先,主线程负责接收建立连接请求。当有客户端请求和实例建立Socket连接时,主线程会创建和客户端的连接,并把 Socket 放入全局等待队列中。紧接着,主线程通过轮询方法把Socket连接分配给IO线程。
|
||||
|
||||
**阶段二:IO线程读取并解析请求**
|
||||
|
||||
主线程一旦把Socket分配给IO线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析。因为有多个IO线程在并行处理,所以,这个过程很快就可以完成。
|
||||
|
||||
**阶段三:主线程执行请求操作**
|
||||
|
||||
等到IO线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。下面这张图显示了刚才介绍的这三个阶段,你可以看下,加深理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/cd/5817b7e2085e7c00e63534a07c4182cd.jpg" alt="">
|
||||
|
||||
**阶段四:IO线程回写Socket和主线程清空全局队列**
|
||||
|
||||
当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待IO线程把这些结果回写到Socket中,并返回给客户端。
|
||||
|
||||
和IO线程读取和解析请求一样,IO线程回写Socket时,也是有多个线程在并发执行,所以回写Socket的速度也很快。等到IO线程回写Socket完毕,主线程会清空全局队列,等待客户端的后续请求。
|
||||
|
||||
我也画了一张图,展示了这个阶段主线程和IO线程的操作,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/1b/2e1f3a5bafc43880e935aaa4796d131b.jpg" alt="">
|
||||
|
||||
了解了Redis主线程和多线程的协作方式,我们该怎么启用多线程呢?在Redis 6.0中,多线程机制默认是关闭的,如果需要使用多线程功能,需要在redis.conf中完成两个设置。
|
||||
|
||||
**1.设置io-thread-do-reads配置项为yes,表示启用多线程。**
|
||||
|
||||
```
|
||||
io-threads-do-reads yes
|
||||
|
||||
```
|
||||
|
||||
2.设置线程个数。一般来说,**线程个数要小于Redis实例所在机器的CPU核个数**,例如,对于一个8核的机器来说,Redis官方建议配置6个IO线程。
|
||||
|
||||
```
|
||||
io-threads 6
|
||||
|
||||
```
|
||||
|
||||
如果你在实际应用中,发现Redis实例的CPU开销不大,吞吐量却没有提升,可以考虑使用Redis 6.0的多线程机制,加速网络处理,进而提升实例的吞吐量。
|
||||
|
||||
## 实现服务端协助的客户端缓存
|
||||
|
||||
和之前的版本相比,Redis 6.0新增了一个重要的特性,就是实现了服务端协助的客户端缓存功能,也称为跟踪(Tracking)功能。有了这个功能,业务应用中的Redis客户端就可以把读取的数据缓存在业务应用本地了,应用就可以直接在本地快速读取数据了。
|
||||
|
||||
不过,当把数据缓存在客户端本地时,我们会面临一个问题:**如果数据被修改了或是失效了,如何通知客户端对缓存的数据做失效处理?**
|
||||
|
||||
6.0实现的Tracking功能实现了两种模式,来解决这个问题。
|
||||
|
||||
**第一种模式是普通模式**。在这个模式下,实例会在服务端记录客户端读取过的key,并监测key是否有修改。一旦key的值发生变化,服务端会给客户端发送invalidate消息,通知客户端缓存失效了。
|
||||
|
||||
在使用普通模式时,有一点你需要注意一下,服务端对于记录的key只会报告一次invalidate消息,也就是说,服务端在给客户端发送过一次invalidate消息后,如果key再被修改,此时,服务端就不会再次给客户端发送invalidate消息。
|
||||
|
||||
只有当客户端再次执行读命令时,服务端才会再次监测被读取的key,并在key修改时发送invalidate消息。这样设计的考虑是节省有限的内存空间。毕竟,如果客户端不再访问这个key了,而服务端仍然记录key的修改情况,就会浪费内存资源。
|
||||
|
||||
我们可以通过执行下面的命令,打开或关闭普通模式下的Tracking功能。
|
||||
|
||||
```
|
||||
CLIENT TRACKING ON|OFF
|
||||
|
||||
```
|
||||
|
||||
**第二种模式是广播模式**。在这个模式下,服务端会给客户端广播所有key的失效情况,不过,这样做了之后,如果key 被频繁修改,服务端会发送大量的失效广播消息,这就会消耗大量的网络带宽资源。
|
||||
|
||||
所以,在实际应用时,我们会让客户端注册希望跟踪的key的前缀,当带有注册前缀的key被修改时,服务端会把失效消息广播给所有注册的客户端。**和普通模式不同,在广播模式下,即使客户端还没有读取过key,但只要它注册了要跟踪的key,服务端都会把key失效消息通知给这个客户端**。
|
||||
|
||||
我给你举个例子,带你看一下客户端如何使用广播模式接收key失效消息。当我们在客户端执行下面的命令后,如果服务端更新了user:id:1003这个key,那么,客户端就会收到invalidate消息。
|
||||
|
||||
```
|
||||
CLIENT TRACKING ON BCAST PREFIX user
|
||||
|
||||
```
|
||||
|
||||
这种监测带有前缀的key的广播模式,和我们对key的命名规范非常匹配。我们在实际应用时,会给同一业务下的key设置相同的业务名前缀,所以,我们就可以非常方便地使用广播模式。
|
||||
|
||||
不过,刚才介绍的普通模式和广播模式,需要客户端使用RESP 3协议,RESP 3协议是6.0新启用的通信协议,一会儿我会给你具体介绍。
|
||||
|
||||
对于使用RESP 2协议的客户端来说,就需要使用另一种模式,也就是重定向模式(redirect)。在重定向模式下,想要获得失效消息通知的客户端,就需要执行订阅命令SUBSCRIBE,专门订阅用于发送失效消息的频道_redis_:invalidate。同时,再使用另外一个客户端,执行CLIENT TRACKING命令,设置服务端将失效消息转发给使用RESP 2协议的客户端。
|
||||
|
||||
我再给你举个例子,带你了解下如何让使用RESP 2协议的客户端也能接受失效消息。假设客户端B想要获取失效消息,但是客户端B只支持RESP 2协议,客户端A支持RESP 3协议。我们可以分别在客户端B和A上执行SUBSCRIBE和CLIENT TRACKING,如下所示:
|
||||
|
||||
```
|
||||
//客户端B执行,客户端B的ID号是303
|
||||
SUBSCRIBE _redis_:invalidate
|
||||
|
||||
//客户端A执行
|
||||
CLIENT TRACKING ON BCAST REDIRECT 303
|
||||
|
||||
```
|
||||
|
||||
这样设置以后,如果有键值对被修改了,客户端B就可以通过_redis_:invalidate频道,获得失效消息了。
|
||||
|
||||
好了,了解了6.0 版本中的客户端缓存特性后,我们再来了解下第三个关键特性,也就是实例的访问权限控制列表功能(Access Control List,ACL),这个特性可以有效地提升Redis的使用安全性。
|
||||
|
||||
## 从简单的基于密码访问到细粒度的权限控制
|
||||
|
||||
在Redis 6.0 版本之前,要想实现实例的安全访问,只能通过设置密码来控制,例如,客户端连接实例前需要输入密码。
|
||||
|
||||
此外,对于一些高风险的命令(例如KEYS、FLUSHDB、FLUSHALL等),在Redis 6.0 之前,我们也只能通过rename-command来重新命名这些命令,避免客户端直接调用。
|
||||
|
||||
Redis 6.0 提供了更加细粒度的访问权限控制,这主要有两方面的体现。
|
||||
|
||||
**首先,6.0版本支持创建不同用户来使用Redis**。在6.0版本前,所有客户端可以使用同一个密码进行登录使用,但是没有用户的概念,而在6.0中,我们可以使用ACL SETUSER命令创建用户。例如,我们可以执行下面的命令,创建并启用一个用户normaluser,把它的密码设置为“abc”:
|
||||
|
||||
```
|
||||
ACL SETUSER normaluser on > abc
|
||||
|
||||
```
|
||||
|
||||
**另外,6.0版本还支持以用户为粒度设置命令操作的访问权限**。我把具体操作列在了下表中,你可以看下,其中,加号(+)和减号(-)就分别表示给用户赋予或撤销命令的调用权限。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/c8/d1bd6891934cfa879ee080de1c5455c8.jpg" alt="">
|
||||
|
||||
为了便于你理解,我给你举个例子。假设我们要设置用户normaluser只能调用Hash类型的命令操作,而不能调用String类型的命令操作,我们可以执行如下命令:
|
||||
|
||||
```
|
||||
ACL SETUSER normaluser +@hash -@string
|
||||
|
||||
```
|
||||
|
||||
除了设置某个命令或某类命令的访问控制权限,6.0版本还支持以key为粒度设置访问权限。
|
||||
|
||||
具体的做法是使用波浪号“~”和key的前缀来表示控制访问的key。例如,我们执行下面命令,就可以设置用户normaluser只能对以“user:”为前缀的key进行命令操作:
|
||||
|
||||
```
|
||||
ACL SETUSER normaluser ~user:* +@all
|
||||
|
||||
```
|
||||
|
||||
好了,到这里,你了解了,Redis 6.0可以设置不同用户来访问实例,而且可以基于用户和key的粒度,设置某个用户对某些key允许或禁止执行的命令操作。
|
||||
|
||||
这样一来,我们在有多用户的Redis应用场景下,就可以非常方便和灵活地为不同用户设置不同级别的命令操作权限了,这对于提供安全的Redis访问非常有帮助。
|
||||
|
||||
## 启用RESP 3协议
|
||||
|
||||
Redis 6.0实现了RESP 3通信协议,而之前都是使用的RESP 2。在RESP 2中,客户端和服务器端的通信内容都是以字节数组形式进行编码的,客户端需要根据操作的命令或是数据类型自行对传输的数据进行解码,增加了客户端开发复杂度。
|
||||
|
||||
而RESP 3直接支持多种数据类型的区分编码,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。
|
||||
|
||||
所谓区分编码,就是指直接通过不同的开头字符,区分不同的数据类型,这样一来,客户端就可以直接通过判断传递消息的开头字符,来实现数据转换操作了,提升了客户端的效率。除此之外,RESP 3协议还可以支持客户端以普通模式和广播模式实现客户端缓存。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课,我向你介绍了Redis 6.0的新特性,我把这些新特性总结在了一张表里,你可以再回顾巩固下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/f0/2155c01bf3129d5d58fcb98aefd402f0.jpg" alt="">
|
||||
|
||||
最后,我也再给你一个小建议:因为Redis 6.0是刚刚推出的,新的功能特性还需要在实际应用中进行部署和验证,所以,如果你想试用Redis 6.0,可以尝试先在非核心业务上使用Redis 6.0,一方面可以验证新特性带来的性能或功能优势,另一方面,也可以避免因为新特性不稳定而导致核心业务受到影响。
|
||||
|
||||
## 每课一问
|
||||
|
||||
你觉得,Redis 6.0的哪个或哪些新特性会对你有帮助呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。
|
||||
124
极客时间专栏/Redis核心技术与实战/未来篇/40 | Redis的下一步:基于NVM内存的实践.md
Normal file
124
极客时间专栏/Redis核心技术与实战/未来篇/40 | Redis的下一步:基于NVM内存的实践.md
Normal file
@@ -0,0 +1,124 @@
|
||||
<audio id="audio" title="40 | Redis的下一步:基于NVM内存的实践" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/29/e1cdc414c24a5f14c47006a774702129.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
今天这节课是咱们课程的最后一节课了,我们来聊聊Redis的下一步发展。
|
||||
|
||||
这几年呢,新型非易失存储(Non-Volatile Memory,NVM)器件发展得非常快。NVM器件具有容量大、性能快、能持久化保存数据的特性,这些刚好就是Redis追求的目标。同时,NVM器件像DRAM一样,可以让软件以字节粒度进行寻址访问,所以,在实际应用中,NVM可以作为内存来使用,我们称为NVM内存。
|
||||
|
||||
你肯定会想到,Redis作为内存键值数据库,如果能和NVM内存结合起来使用,就可以充分享受到这些特性。我认为,Redis发展的下一步,就可以基于NVM内存来实现大容量实例,或者是实现快速持久化数据和恢复。这节课,我就带你了解下这个新趋势。
|
||||
|
||||
接下来,我们先来学习下NVM内存的特性,以及软件使用NVM内存的两种模式。在不同的使用模式下,软件能用到的NVM特性是不一样的,所以,掌握这部分知识,可以帮助我们更好地根据业务需求选择适合的模式。
|
||||
|
||||
## NVM内存的特性与使用模式
|
||||
|
||||
Redis是基于DRAM内存的键值数据库,而跟传统的DRAM内存相比,NVM有三个显著的特点。
|
||||
|
||||
首先,**NVM内存最大的优势是可以直接持久化保存数据**。也就是说,数据保存在NVM内存上后,即使发生了宕机或是掉电,数据仍然存在NVM内存上。但如果数据是保存在DRAM上,那么,掉电后数据就会丢失。
|
||||
|
||||
其次,**NVM内存的访问速度接近DRAM的速度**。我实际测试过NVM内存的访问速度,结果显示,它的读延迟大约是200~300ns,而写延迟大约是100ns。在读写带宽方面,单根NVM内存条的写带宽大约是1~2GB/s,而读带宽约是5~6GB/s。当软件系统把数据保存在NVM内存上时,系统仍然可以快速地存取数据。
|
||||
|
||||
最后,**NVM内存的容量很大**。这是因为,NVM器件的密度大,单个NVM的存储单元可以保存更多数据。例如,单根NVM内存条就能达到128GB的容量,最大可以达到512GB,而单根DRAM内存条通常是16GB或32GB。所以,我们可以很轻松地用NVM内存构建TB级别的内存。
|
||||
|
||||
总结来说,NVM内存的特点可以用三句话概括:
|
||||
|
||||
- 能持久化保存数据;
|
||||
- 读写速度和DRAM接近;
|
||||
- 容量大。
|
||||
|
||||
现在,业界已经有了实际的NVM内存产品,就是Intel在2019年4月份时推出的Optane AEP内存条(简称AEP内存)。我们在应用AEP内存时,需要注意的是,AEP内存给软件提供了两种使用模式,分别对应着使用了NVM的容量大和持久化保存数据两个特性,我们来学习下这两种模式。
|
||||
|
||||
第一种是Memory模式。
|
||||
|
||||
这种模式是把NVM内存作为大容量内存来使用的,也就是说,只使用NVM容量大和性能高的特性,没有启用数据持久化的功能。
|
||||
|
||||
例如,我们可以在一台服务器上安装6根NVM内存条,每根512GB,这样我们就可以在单台服务器上获得3TB的内存容量了。
|
||||
|
||||
在Memory模式下,服务器上仍然需要配置DRAM内存,但是,DRAM内存是被CPU用作AEP内存的缓存,DRAM的空间对应用软件不可见。换句话说,**软件系统能使用到的内存空间,就是AEP内存条的空间容量**。
|
||||
|
||||
第二种是App Direct模式。
|
||||
|
||||
这种模式启用了NVM持久化数据的功能。在这种模式下,应用软件把数据写到AEP内存上时,数据就直接持久化保存下来了。所以,使用了App Direct模式的AEP内存,也叫做持久化内存(Persistent Memory,PM)。
|
||||
|
||||
现在呢,我们知道了AEP内存的两种使用模式,那Redis是怎么用的呢?我来给你具体解释一下。
|
||||
|
||||
## 基于NVM内存的Redis实践
|
||||
|
||||
当AEP内存使用Memory模式时,应用软件就可以利用它的大容量特性来保存大量数据,Redis也就可以给上层业务应用提供大容量的实例了。而且,在Memory模式下,Redis可以像在DRAM内存上运行一样,直接在AEP内存上运行,不用修改代码。
|
||||
|
||||
不过,有个地方需要注意下:在Memory模式下,AEP内存的访问延迟会比DRAM高一点。我刚刚提到过,NVM的读延迟大约是200~300ns,而写延迟大约是100ns。所以,在Memory模式下运行Redis实例,实例读性能会有所降低,我们就需要在保存大量数据和读性能较慢两者之间做个取舍。
|
||||
|
||||
那么,当我们使用App Direct模式,把AEP内存用作PM时,Redis又该如何利用PM快速持久化数据的特性呢?这就和Redis的数据可靠性保证需求和现有机制有关了,我们来具体分析下。
|
||||
|
||||
为了保证数据可靠性,Redis设计了RDB和AOF两种机制,把数据持久化保存到硬盘上。
|
||||
|
||||
但是,无论是RDB还是AOF,都需要把数据或命令操作以文件的形式写到硬盘上。对于RDB来说,虽然Redis实例可以通过子进程生成RDB文件,但是,实例主线程fork子进程时,仍然会阻塞主线程。而且,RDB文件的生成需要经过文件系统,文件本身会有一定的操作开销。
|
||||
|
||||
对于AOF日志来说,虽然Redis提供了always、everysec和no三个选项,其中,always选项以fsync的方式落盘保存数据,虽然保证了数据的可靠性,但是面临性能损失的风险。everysec选项避免了每个操作都要实时落盘,改为后台每秒定期落盘。在这种情况下,Redis的写性能得到了改善,但是,应用会面临秒级数据丢失的风险。
|
||||
|
||||
此外,当我们使用RDB文件或AOF文件对Redis进行恢复时,需要把RDB文件加载到内存中,或者是回放AOF中的日志操作。这个恢复过程的效率受到RDB文件大小和AOF文件中的日志操作多少的影响。
|
||||
|
||||
所以,在前面的课程里,我也经常提醒你,不要让单个Redis实例过大,否则会导致RDB文件过大。在主从集群应用中,过大的RDB文件就会导致低效的主从同步。
|
||||
|
||||
我们先简单小结下现在Redis在涉及持久化操作时的问题:
|
||||
|
||||
- RDB文件创建时的fork操作会阻塞主线程;
|
||||
- AOF文件记录日志时,需要在数据可靠性和写性能之间取得平衡;
|
||||
- 使用RDB或AOF恢复数据时,恢复效率受RDB和AOF大小的限制。
|
||||
|
||||
但是,如果我们使用持久化内存,就可以充分利用PM快速持久化的特点,来避免RDB和AOF的操作。因为PM支持内存访问,而Redis的操作都是内存操作,那么,我们就可以把Redis直接运行在PM上。同时,数据本身就可以在PM上持久化保存了,我们就不再需要额外的RDB或AOF日志机制来保证数据可靠性了。
|
||||
|
||||
那么,当使用PM来支持Redis的持久化操作时,我们具体该如何实现呢?
|
||||
|
||||
我先介绍下PM的使用方法。
|
||||
|
||||
当服务器中部署了PM后,我们可以在操作系统的/dev目录下看到一个PM设备,如下所示:
|
||||
|
||||
```
|
||||
/dev/pmem0
|
||||
|
||||
```
|
||||
|
||||
然后,我们需要使用ext4-dax文件系统来格式化这个设备:
|
||||
|
||||
```
|
||||
mkfs.ext4 /dev/pmem0
|
||||
|
||||
```
|
||||
|
||||
接着,我们把这个格式化好的设备,挂载到服务器上的一个目录下:
|
||||
|
||||
```
|
||||
mount -o dax /dev/pmem0 /mnt/pmem0
|
||||
|
||||
```
|
||||
|
||||
此时,我们就可以在这个目录下创建文件了。创建好了以后,再把这些文件通过内存映射(mmap)的方式映射到Redis的进程空间。这样一来,我们就可以把Redis接收到的数据直接保存到映射的内存空间上了,而这块内存空间是由PM提供的。所以,数据写入这块空间时,就可以直接被持久化保存了。
|
||||
|
||||
而且,如果要修改或删除数据,PM本身也支持以字节粒度进行数据访问,所以,Redis可以直接在PM上修改或删除数据。
|
||||
|
||||
如果发生了实例故障,Redis宕机了,因为数据本身已经持久化保存在PM上了,所以我们可以直接使用PM上的数据进行实例恢复,而不用再像现在的Redis那样,通过加载RDB文件或是重放AOF日志操作来恢复了,可以实现快速的故障恢复。
|
||||
|
||||
当然,因为PM的读写速度比DRAM慢,所以,**如果使用PM来运行Redis,需要评估下PM提供的访问延迟和访问带宽,是否能满足业务层的需求**。
|
||||
|
||||
我给你举个例子,带你看下如何评估PM带宽对Redis业务的支撑。
|
||||
|
||||
假设业务层需要支持1百万QPS,平均每个请求的大小是2KB,那么,就需要机器能支持2GB/s的带宽(1百万请求操作每秒 * 2KB每请求 = 2GB/s)。如果这些请求正好是写操作的话,那么,单根PM的写带宽可能不太够用了。
|
||||
|
||||
这个时候,我们就可以在一台服务器上使用多根PM内存条,来支撑高带宽的需求。当然,我们也可以使用切片集群,把数据分散保存到多个实例,分担访问压力。
|
||||
|
||||
好了,到这里,我们就掌握了用PM将Redis数据直接持久化保存在内存上的方法。现在,我们既可以在单个实例上使用大容量的PM保存更多的业务数据了,同时,也可以在实例故障后,直接使用PM上保存的数据进行故障恢复。
|
||||
|
||||
## 小结
|
||||
|
||||
这节课我向你介绍了NVM的三大特点:性能高、容量大、数据可以持久化保存。软件系统可以像访问传统DRAM内存一样,访问NVM内存。目前,Intel已经推出了NVM内存产品Optane AEP。
|
||||
|
||||
这款NVM内存产品给软件提供了两种使用模式,分别是Memory模式和App Direct模式。在Memory模式时,Redis可以利用NVM容量大的特点,实现大容量实例,保存更多数据。在使用App Direct模式时,Redis可以直接在持久化内存上进行数据读写,在这种情况下,Redis不用再使用RDB或AOF文件了,数据在机器掉电后也不会丢失。而且,实例可以直接使用持久化内存上的数据进行恢复,恢复速度特别快。
|
||||
|
||||
NVM内存是近年来存储设备领域中一个非常大的变化,它既能持久化保存数据,还能像内存一样快速访问,这必然会给当前基于DRAM和硬盘的系统软件优化带来新的机遇。现在,很多互联网大厂已经开始使用NVM内存了,希望你能够关注这个重要趋势,为未来的发展做好准备。
|
||||
|
||||
## 每课一问
|
||||
|
||||
按照惯例,我给你提个小问题,你觉得有了持久化内存后,还需要Redis主从集群吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。
|
||||
134
极客时间专栏/Redis核心技术与实战/未来篇/41 | 第35~40讲课后思考题答案及常见问题答疑.md
Normal file
134
极客时间专栏/Redis核心技术与实战/未来篇/41 | 第35~40讲课后思考题答案及常见问题答疑.md
Normal file
@@ -0,0 +1,134 @@
|
||||
<audio id="audio" title="41 | 第35~40讲课后思考题答案及常见问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/22/5953267ce8bf69ac50e74488b55f5222.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
今天是我们最后一节答疑课,我会带你一起分析一下第35~40讲的课后思考题。同时,我还会讲解两个典型问题,分别是原子操作使用问题,以及Redis和其他键值数据库的对比情况。
|
||||
|
||||
## [第35讲](https://time.geekbang.org/column/article/306548)
|
||||
|
||||
问题:假设Codis集群中保存的80%的键值对都是Hash类型,每个Hash集合的元素数量在10万~20万个,每个集合元素的大小是2KB。你觉得,迁移这样的Hash集合数据,会对Codis的性能造成影响吗?
|
||||
|
||||
答案:其实影响不大。虽然一个Hash集合数据的总数据量有200MB ~ 400MB(2KB * 0.1M ≈ 200MB到 2KB * 0.2M ≈ 400MB),但是Codis支持异步、分批迁移数据,所以,Codis可以把集合中的元素分多个批次进行迁移,每批次迁移的数据量不大,所以,不会给源实例造成太大影响。
|
||||
|
||||
## [第36讲](https://time.geekbang.org/column/article/307421)
|
||||
|
||||
问题:假设一个商品的库存量是800,我们使用一个包含了4个实例的切片集群来服务秒杀请求,我们让每个实例各自维护库存量200,把客户端的秒杀请求分发到不同的实例上进行处理,你觉得这是一个好方法吗?
|
||||
|
||||
答案:这个方法是不是能达到一个好的效果,主要取决于,**客户端请求能不能均匀地分发到每个实例上**。如果可以的话,那么,每个实例都可以帮着分担一部分压力,避免压垮单个实例。
|
||||
|
||||
在保存商品库存时,key一般就是商品的ID,所以,客户端在秒杀场景中查询同一个商品的库存时,会向集群请求相同的key,集群就需要把客户端对同一个key的请求均匀地分发到多个实例上。
|
||||
|
||||
为了解决这个问题,客户端和实例间就需要有代理层来完成请求的转发。例如,在Codis中,codis proxy负责转发请求,那么,如果我们让codis proxy收到请求后,按轮询的方式把请求分发到不同实例上(可以对Codis进行修改,增加转发规则),就可以利用多实例来分担请求压力了。
|
||||
|
||||
如果没有代理层的话,客户端会根据key和Slot的映射关系,以及Slot和实例的分配关系,直接把请求发给保存key的唯一实例了。在这种情况下,请求压力就无法由多个实例进行分担了。题目中描述的这个方法也就不能达到好的效果了。
|
||||
|
||||
## [第37讲](https://time.geekbang.org/column/article/308393)
|
||||
|
||||
问题:当有数据访问倾斜时,如果热点数据突然过期了,假设Redis中的数据是缓存,数据的最终值是保存在后端数据库中的,这样会发生什么问题吗?
|
||||
|
||||
答案:在这种情况下,会发生缓存击穿的问题,也就是热点数据突然失效,导致大量访问请求被发送到数据库,给数据库带来巨大压力。
|
||||
|
||||
我们可以采用[第26讲](https://time.geekbang.org/column/article/296586)中介绍的方法,不给热点数据设置过期时间,这样可以避免过期带来的击穿问题。
|
||||
|
||||
除此之外,我们最好在数据库的接入层增加流控机制,一旦监测到有大流量请求访问数据库,立刻开启限流,这样做也是为了避免数据库被大流量压力压垮。因为数据库一旦宕机,就会对整个业务应用带来严重影响。所以,我们宁可在请求接入数据库时,就直接拒接请求访问。
|
||||
|
||||
## [第38讲](https://time.geekbang.org/column/article/310347)
|
||||
|
||||
问题:如果我们采用跟Codis保存Slot分配信息相类似的方法,把集群实例状态信息和Slot分配信息保存在第三方的存储系统上(例如Zookeeper),这种方法会对集群规模产生什么影响吗?
|
||||
|
||||
答案:假设我们将Zookeeper作为第三方存储系统,保存集群实例状态信息和Slot分配信息,那么,实例只需要和Zookeeper通信交互信息,实例之间就不需要发送大量的心跳消息来同步集群状态了。这种做法可以减少实例之间用于心跳的网络通信量,有助于实现大规模集群。而且,网络带宽可以集中用在服务客户端请求上。
|
||||
|
||||
不过,在这种情况下,实例获取或更新集群状态信息时,都需要和Zookeeper交互,Zookeeper的网络通信带宽需求会增加。所以,采用这种方法的时候,需要给Zookeeper保证一定的网络带宽,避免Zookeeper受限于带宽而无法和实例快速通信。
|
||||
|
||||
## [第39讲](https://time.geekbang.org/column/article/310838)
|
||||
|
||||
问题:你觉得,Redis 6.0的哪个或哪些新特性会对你有帮助呢?
|
||||
|
||||
答案:这个要根据你们的具体需求来定。从提升性能的角度上来说,Redis 6.0中的多IO线程特性可以缓解Redis的网络请求处理压力。通过多线程增加处理网络请求的能力,可以进一步提升实例的整体性能。业界已经有人评测过,跟6.0之前的单线程Redis相比,6.0的多线程性能的确有提升。所以,这个特性对业务应用会有比较大的帮助。
|
||||
|
||||
另外,基于用户的命令粒度ACL控制机制也非常有用。当Redis以云化的方式对外提供服务时,就会面临多租户(比如多用户或多个微服务)的应用场景。有了ACL新特性,我们就可以安全地支持多租户共享访问Redis服务了。
|
||||
|
||||
## [第40讲](https://time.geekbang.org/column/article/312568)
|
||||
|
||||
问题:你觉得,有了持久化内存后,还需要Redis主从集群吗?
|
||||
|
||||
答案:持久化内存虽然可以快速恢复数据,但是,除了提供主从故障切换以外,主从集群还可以实现读写分离。所以,我们可以通过增加从实例,让多个从实例共同分担大量的读请求,这样可以提升Redis的读性能。而提升读性能并不是持久化内存能提供的,所以,如果业务层对读性能有高要求时,我们还是需要主从集群的。
|
||||
|
||||
## 常见问题答疑
|
||||
|
||||
好了,关于思考题的讨论,到这里就告一段落了。接下来,我结合最近收到的一些问题,来和你聊一聊,在进行原子操作开发时,局部变量和全局共享变量导致的差异问题,以及Redis和另外两种常见的键值数据库Memcached、RocksDB的优劣势对比。
|
||||
|
||||
### 关于原子操作的使用疑问
|
||||
|
||||
在[第29讲](https://time.geekbang.org/column/article/299806)中,我在介绍原子操作时,提到了一个多线程限流的例子,借助它来解释如何使用原子操作。我们再来回顾下这个例子的代码:
|
||||
|
||||
```
|
||||
//获取ip对应的访问次数
|
||||
current = GET(ip)
|
||||
//如果超过访问次数超过20次,则报错
|
||||
IF current != NULL AND current > 20 THEN
|
||||
ERROR "exceed 20 accesses per second"
|
||||
ELSE
|
||||
//如果访问次数不足20次,增加一次访问计数
|
||||
value = INCR(ip)
|
||||
//如果是第一次访问,将键值对的过期时间设置为60s后
|
||||
IF value == 1 THEN
|
||||
EXPIRE(ip,60)
|
||||
END
|
||||
//执行其他操作
|
||||
DO THINGS
|
||||
END
|
||||
|
||||
```
|
||||
|
||||
在分析这个例子的时候,我提到:“第一个线程执行了INCR(ip)操作后,第二个线程紧接着也执行了INCR(ip),此时,ip对应的访问次数就被增加到了2,我们就不能再对这个ip设置过期时间了。”
|
||||
|
||||
有同学认为,value是线程中的局部变量,所以两个线程在执行时,每个线程会各自判断value是否等于1。判断完value值后,就可以设置ip的过期时间了。因为Redis本身执行INCR可以保证原子性,所以,客户端线程使用局部变量获取ip次数并进行判断时,是可以实现原子性保证的。
|
||||
|
||||
我再进一步解释下这个例子中使用Lua脚本保证原子性的原因。
|
||||
|
||||
在这个例子中,value其实是一个在多线程之间共享的全局变量,所以,多线程在访问这个变量时,就可能会出现一种情况:一个线程执行了INCR(ip)后,第二个线程也执行了INCR(ip),等到第一个线程再继续执行时,就会发生ip对应的访问次数变成2的情况。而设置过期时间的条件是ip访问次数等于1,这就无法设置过期时间了。在这种情况下,我们就需要用Lua脚本保证计数增加和计数判断操作的原子性。
|
||||
|
||||
### Redis和Memcached、RocksDB的对比
|
||||
|
||||
Memcached和RocksDB分别是典型的内存键值数据库和硬盘键值数据库,应用得也非常广泛。和Redis相比,它们有什么优势和不足呢?是否可以替代Redis呢?我们来聊一聊这个问题。
|
||||
|
||||
#### Redis和Memcached的比较
|
||||
|
||||
和Redis相似,Memcached也经常被当做缓存来使用。不过,Memcached有一个明显的优势,**就是它的集群规模可以很大**。Memcached集群并不是像Redis Cluster或Codis那样,使用Slot映射来分配数据和实例的对应保存关系,而是使用一致性哈希算法把数据分散保存到多个实例上,而一致性哈希的优势就是可以支持大规模的集群。所以,如果我们需要部署大规模缓存集群,Memcached会是一个不错的选择。
|
||||
|
||||
不过,在使用Memcached时,有个地方需要注意,Memcached支持的数据类型比Redis少很多。Memcached只支持String类型的键值对,而Redis可以支持包括String在内的多种数据类型,当业务应用有丰富的数据类型要保存的话,使用Memcached作为替换方案的优势就没有了。
|
||||
|
||||
如果你既需要保存多种数据类型,又希望有一定的集群规模保存大量数据,那么,Redis仍然是一个不错的方案。
|
||||
|
||||
我把Redis和Memcached的对比情况总结在了一张表里,你可以看下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/29/9eb06cfea8a3ec6fced6e736e4e9ec29.jpg" alt="">
|
||||
|
||||
#### Redis和RocksDB的比较
|
||||
|
||||
和Redis不同,RocksDB可以把数据直接保存到硬盘上。这样一来,单个RocksDB可以保存的数据量要比Redis多很多,而且数据都能持久化保存下来。
|
||||
|
||||
除此之外,RocksDB还能支持表结构(即列族结构),而Redis的基本数据模型就是键值对。所以,如果你需要一个大容量的持久化键值数据库,并且能按照一定表结构保存数据,RocksDB是一个不错的替代方案。
|
||||
|
||||
不过,RocksDB毕竟是要把数据写入底层硬盘进行保存的,而且在进行数据查询时,如果RocksDB要读取的数据没有在内存中缓存,那么,RocksDB就需要到硬盘上的文件中进行查找,这会拖慢RocksDB的读写延迟,降低带宽。
|
||||
|
||||
在性能方面,RocksDB是比不上Redis的。而且,RocksDB只是一个动态链接库,并没有像Redis那样提供了客户端-服务器端访问模式,以及主从集群和切片集群的功能。所以,我们在使用RocksDB替代Redis时,需要结合业务需求重点考虑替换的可行性。
|
||||
|
||||
我把Redis和RocksDB的对比情况总结了下,如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/82/7c0a225636f4983cb56a5b7265cf5982.jpg" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
集群是实际业务应用中很重要的一个需求,在课程的最后,我还想再给你提一个小建议。
|
||||
|
||||
集群部署和运维涉及的工作量非常大,所以,我们一定要重视集群方案的选择。
|
||||
|
||||
**集群的可扩展性是我们评估集群方案的一个重要维度**,你一定要关注,集群中元数据是用Slot映射表,还是一致性哈希维护的。如果是Slot映射表,那么,是用中心化的第三方存储系统来保存,还是由各个实例来扩散保存,这也是需要考虑清楚的。Redis Cluster、Codis和Memcached采用的方式各不相同。
|
||||
|
||||
- Redis Cluster:使用Slot映射表并由实例扩散保存。
|
||||
- Codis:使用Slot映射表并由第三方存储系统保存。
|
||||
- Memcached:使用一致性哈希。
|
||||
|
||||
从可扩展性来看,Memcached优于Codis,Codis优于Redis Cluster。所以,如果实际业务需要大规模集群,建议你优先选择Codis或者是基于一致性哈希的Redis切片集群方案。
|
||||
6
极客时间专栏/Redis核心技术与实战/未来篇/期末测试 | 这些Redis核心知识,你都掌握了吗?.md
Normal file
6
极客时间专栏/Redis核心技术与实战/未来篇/期末测试 | 这些Redis核心知识,你都掌握了吗?.md
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
在课程的最后,我给你出了一份结课测试题,满分100分,10道单选题,10道多选题,快来测试下你的掌握程度吧。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=236&exam_id=815)
|
||||
77
极客时间专栏/Redis核心技术与实战/结束语/结束语 | 从学习Redis到向Redis学习.md
Normal file
77
极客时间专栏/Redis核心技术与实战/结束语/结束语 | 从学习Redis到向Redis学习.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<audio id="audio" title="结束语 | 从学习Redis到向Redis学习" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/15/d01e3e64a02118809121f46916b31715.mp3"></audio>
|
||||
|
||||
你好,我是蒋德钧。
|
||||
|
||||
这么快就到课程的尾声了,到了和你说再见的时候了。
|
||||
|
||||
在过去的4个多月时间里,我们掌握了Redis的各种关键技术和核心知识。在课程的最后,我想带你切换一个视角:**如果说我们之前一直在学习Redis本身,那么今天,我们来看看能向Redis学到什么。**
|
||||
|
||||
在聊这个“视角”之前,我想先问你一个问题:你有没有想过,学习技术究竟意味着什么呢?
|
||||
|
||||
大多数人人都会觉得,就是掌握具体的原理,进行实战,并且学习别人的经验,解决自己在实际工作中的问题。比如说,学习Redis时,我们会把它用在缓存、分布式锁、数据集群等业务场景中,这就需要我们掌握关键实践技巧、常见问题和应对方法,这也是我们课程的聚焦点。
|
||||
|
||||
但是,我认为,这只是学习技术的第一个层面。当我们对技术的认识和积累达到一定程度后,我们就应该“**向技术致敬**”。所谓的致敬,就是向技术学习,来解决我们在生活中遇到的问题。这是第二个层面。
|
||||
|
||||
这背后的道理其实非常朴素:每一项优秀技术都是一些精华思想的沉淀成果,**向技术学习,其实就是向优秀的思想学习**。
|
||||
|
||||
**我一直很崇尚一个理念:一个优秀的计算机系统设计本身就包含了不少人生哲学**。所以,接下来,我们就再往前迈一步,从Redis设计中总结一些做事方法。
|
||||
|
||||
## 向Redis单线程模式学习,专心致志做重要的事
|
||||
|
||||
Redis的最大特点是快,这是Redis在设计之初就设立的目标。而能成为某项技术的高手、某个技术方向的大牛,通常是我们给自己设立的目标。Redis实现“快”这个目标的关键机制就是单线程架构。单线程架构就给我们提供了一个很好的做事方式:**专心致志做一件事,把事情做到极致,是达到目标的核心要素。**
|
||||
|
||||
在Redis的设计中,主线程专门负责处理请求,而且会以最快的速度完成。对于其他会阻碍这个目标的事情(例如生成快照、删除、AOF重写等),就想办法用异步的方式,或者是用后台线程来完成。在给你介绍6.0版本时,我还提到,Redis特意把请求网络包读写和解析也从主线程中剥离出来了,这样主线程就可以更加“专注”地做请求处理了。
|
||||
|
||||
我认为,“单线程”思想是非常值得我们品味的。在确定目标以后,我们也可以采用“单线程模式”,把精力集中在核心目标上,竭尽全力做好这件事,同时合理安排自己的时间,主动避开干扰因素。
|
||||
|
||||
当我们沉浸在一件事上,并且做到极致时,距离成为大牛,也就不远了。
|
||||
|
||||
当然,我们说在一件事上做到极致,并不是说只盯着某一个知识点或某一项技术,而是指在一个技术方向上做到极致。
|
||||
|
||||
比如说,Redis属于键值数据库,我们就可以给自己定个目标:精通主要的键值数据库。因此,我们不仅要扎实地掌握现有技术,还要持续关注最新的技术发展。这就要提到我们可以向Redis学习的第二点了:具备可扩展能力。
|
||||
|
||||
## 向Redis集群学习可扩展能力
|
||||
|
||||
在应用Redis时,我们会遇到数据量增长、负载压力增大的情况,但Redis都能轻松应对,这就是得益于它的可扩展集群机制:当数据容量增加时,Redis会增加实例实现扩容;当读压力增加时,Redis会增加从库,来分担压力。
|
||||
|
||||
Redis的新特性在持续推出,新的存储硬件也在快速地发展,这些最新技术的发展,很可能就会改变Redis的关键机制和使用方法。**所以,想要应对复杂的场景变化,我们也要像Redis集群一样,具备可扩展能力。**毕竟,技术的迭代速度如此之快,各种需求也越来越复杂。如果只是专注于学习现有的技术知识,或者是基于目前的场景去苦心钻研,很可能会被时代快速地抛弃。
|
||||
|
||||
只有紧跟技术发展的步伐,具备解决各种突发问题的能力,才能成为真正的技术大牛。
|
||||
|
||||
怎么培养可扩展能力呢?很简单,随时随地记录新鲜的东西。这里的“新鲜”未必是指最新的内容,而是指你不了解的内容。当你的认知范围越来越大,你的可扩展能力自然就会越来越强。
|
||||
|
||||
说到这儿,我想跟你分享一个我的小习惯。我有一个小笔记本,会随身携带着,在看文章、参加技术会议,或是和别人聊天时,只要学到了新东西,我就会赶紧记下来,之后再专门找时间去搜索相关的资料,时不时地拿出来回顾一下。这个习惯,让我能够及时地掌握最新的技术,轻松地应对各种变化。
|
||||
|
||||
我们做技术的同学,通常习惯于脚踏实地地把事情做好,但是,也千万别忘了,脚踏实地的同时,也是需要“仰望星空”的。要把学习变成一种习惯,从为了应对问题的被动学习,到为了增强自己的可扩展性而主动学习,这个转变绝对可以让你的技术能力远超过其他人。
|
||||
|
||||
当然,Redis的优秀设计思想还有很多,你还可以自己提炼总结下。我还想再跟你探讨的话题是,我们该怎么把向Redis学到的思想真正落地到实践中呢?
|
||||
|
||||
其实,道理也很简单:**从做成一件事开始**。在竭尽全力做成事情的过程当中,磨炼自己的专注力,锻炼自己的可扩展能力。
|
||||
|
||||
## 从做成一件事开始
|
||||
|
||||
我们常说“不积跬步,无以至千里”,这句话中的“跬步”,我把它解释为做成一件事。我们总是会做很多事,但是,很多时候,能够让我们真正得到提升的是把事做成。
|
||||
|
||||
对我来说,创作这门课完全是一次全新的尝试。在写作时,无论是思考内容的结构,确认具体的细节,还是连夜赶稿以保证按时更新,我都感受到了不少压力。但是,现在我回过头来看过去的半年,感到很欣慰,因为这事儿我做成了,而且有很多额外的收获。
|
||||
|
||||
其实,做成一件事的目标不分大小。它可以很小,比如学完两节课,也可以很大,比如花3个月时间把Redis源码读完。
|
||||
|
||||
最重要的是,一旦定好目标,我们就要尽全力把这件事做成。我们不可避免地会遇到各种困难,比如临时有其他的工作安排,抽不出时间,或者是遇到了不理解的内容,很难再学进去。但是,这就像爬山,爬到半山腰的时候,往往也是我们最累的时候。
|
||||
|
||||
我再跟你分享一下我自己的小故事。
|
||||
|
||||
在看Redis数据结构的源码时,我觉得非常困难。Redis的数据类型非常多,每种数据类型还有不同的底层结构实现,而有的数据结构本身就设计得很复杂。
|
||||
|
||||
当时我差一点就决定放弃了,但是,我后来憋着一口气,说我一定要把事情做成。冷静下来之后,我进一步细分目标,每周搞定一个结构,先从原理上理解结构的设计,自己在白纸上推演一遍。然后,把每个结构的代码看一遍,同时自己也把关键部分编写一遍。毕竟,我们在看代码的时候,很容易想当然地跳过一些地方,只有自己一行行地去编写时,才会思考得更细致,理解得也更透彻。
|
||||
|
||||
攻克了“数据结构”这个难关之后,我发现,后面的就简单多了。甚至在遇到其他困难时,我也不再害怕了。
|
||||
|
||||
因为每一次把一件事做成,都会增强我们的自信心,提升我们的能力。随着我们做成的事越来越多,我们也就越来越接近山顶了,这时,你会真正地体会到“会当凌绝顶,一览众山小”的感觉。
|
||||
|
||||
好了,到这里,真的要和你说再见了。“此地一为别,孤蓬万里征”,这是李白送别友人时说的,比较忧伤。古代的通讯和交通没有那么便利,分别之后,好友只能是自己独自奋斗了。
|
||||
|
||||
但咱们不是。虽然课程结束了,但是这些内容会持续存在,你可以时不时地复习一下。如果你遇见了什么问题,也欢迎继续给我留言。
|
||||
|
||||
最后,我给你准备了一份结课问卷,希望你花1分钟时间填写一下,聊一聊你对这门课的看法和反馈,就有机会获得“Redis 快捷口令超大鼠标垫”和价值99元的极客时间课程阅码。期待你的畅所欲言。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/7f/de/7f21e7e0fabb48347d59c1e0e1dddcde.jpg" alt="">](https://jinshuju.net/f/deBEiK)
|
||||
Reference in New Issue
Block a user