This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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学习资料都有哪些呢欢迎在留言区分享一下我们一起进步。另外如果你觉得今天的内容对你有所帮助也欢迎你分享给你的朋友或同事。

View 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==&amp;mid=2650782429&amp;idx=1&amp;sn=7f2df520a7295a002c4a59f6aea9e7f3&amp;chksm=f3f90f48c48e865e478d936d76c5303663c98da506f221ede85f0f9250e5f897f24896147cfb&amp;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使用内存保存数据一方面带来了访问速度快的优势另一方面也让我们在运维时需要特别关注内存优化。我在前面的课程里介绍了很多和性能优化、节省内存相关的内容比如说第1820讲你可以重点回顾下并且真正地在实践中应用起来。
第二个经验是在实际应用中需要基于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时有没有一些经典的优化改进或二次开发经验
欢迎你在留言区聊一聊你的经验,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你把今天的内容分享给你的朋友或同事。

View 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%的人,甚至是更少。所以,希望我们都能真正地行动起来,进步的路很长,我们一定要让自己在路上。**
最后,希望这些内容对你有所帮助,我也很期待你在留言区聊一聊你的学习方法或习惯,我们一起交流和进步。

View 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的呢希望你能在留言区聊聊你的学习方法我们一起交流。

View 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-shakeRedis-shake会把RDB文件发送给目的实例。接着源实例会再把增量命令发送给Redis-shakeRedis-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运维的需求可能并不一样直接使用现成的开源工具可能无法满足全部需求在这种情况下建议你基于开源工具进行二次开发或是自研从而更好地满足业务使用需求。
## 每课一问
按照惯例,我给你提个小问题:你在实际应用中还使用过什么好的运维工具吗?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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时有遵循过什么好的使用规范吗
欢迎在留言区分享一下你常用的使用规范,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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使用RESPREdis Serialization Protocol协议定义了客户端和服务器端交互的命令、数据的编码格式。在Redis 2.0版本中RESP协议正式成为客户端和服务器端的标准通信协议。从Redis 2.0 到Redis 5.0RESP协议都称为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&gt; SET testkey testvalue
OK
```
这里的交互内容就包括了**命令**SET命令、**键(<strong>String类型的键testkey和**单个值</strong>String类型的值testvalue而服务器端则直接返回一个**OK回复**。
第二个例子是执行HSET命令
```
#成功写入Hash类型数据返回实际写入的集合元素个数
127.0.0.1:6379&gt;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&gt;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&gt;HGETALL testhash
1) &quot;a&quot;
2) &quot;1&quot;
3) &quot;b&quot;
4) &quot;2&quot;
5) &quot;c&quot;
6) &quot;3&quot;
127.0.0.1:6379&gt;ZRANGE testzset 0 3 withscores
1) &quot;a&quot;
2) &quot;1&quot;
3) &quot;b&quot;
4) &quot;2&quot;
5) &quot;c&quot;
6) &quot;3&quot;
```
为了在客户端按照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为mylistvalue是使用LPUSH命令写入List集合的5个元素依次是1、2、3.3、4、hello当执行LRANGE mylist 0 4命令时实例返回给客户端的编码结果是怎样的
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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里面还缺少什么功能组件或模块吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论,也欢迎你把今天的内容分享给你的朋友。

View 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和HDELSet类型的SADD、SREM、SRANDMEMBER等。这些操作的复杂度由集合采用的数据结构决定例如HGET、HSET和HDEL是对哈希表做操作所以它们的复杂度都是O(1)Set类型用哈希表作为底层数据结构时它的SADD、SREM、SRANDMEMBER复杂度也是O(1)。
这里有个地方你需要注意一下集合类型支持同时对多个元素进行增删改查例如Hash类型的HMGET和HMSETSet类型的SADD也支持同时增加多个元素。此时这些操作的复杂度就是由单个元素操作复杂度和元素个数决定的。例如HMSET增加M个元素时复杂度就从O(1)变成O(M)了。
第二,**范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据**比如Hash类型的HGETALL和Set类型的SMEMBERS或者返回一个范围内的部分数据比如List类型的LRANGE和ZSet类型的ZRANGE。**这类操作的复杂度一般是O(N),比较耗时,我们应该尽量避免**。
不过Redis从2.8版本开始提供了SCAN系列操作包括HSCANSSCAN和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性能的必修课如果你身边还有不太清楚数据结构的朋友欢迎你把今天的内容分享给他/她,期待你在留言区和我交流讨论。

View 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模型”图中你觉得还有哪些潜在的性能瓶颈吗欢迎在留言区写下你的思考和答案我们一起交流讨论。

View 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的持久化主要有两大机制即AOFAppend 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本身的日志呢
希望你能好好思考一下这两个问题,欢迎在留言区分享你的答案。另外,也欢迎你把这节课的内容转发出去,和更多的人一起交流讨论。

View 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简单来说至少需要20s4/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磁盘的云主机运行RedisRedis数据库的数据量大小差不多是2GB我们使用了RDB做持久化保证。当时Redis的运行负载以修改操作为主写读比例差不多在8:2左右也就是说如果有100个请求80个请求执行的是修改操作。你觉得在这个场景下用RDB做持久化有什么风险吗你能帮着一起分析分析吗
到这里关于持久化我们就讲完了这块儿内容是熟练掌握Redis的基础建议你一定好好学习下这两节课。如果你觉得有收获希望你能帮我分享给更多的人帮助更多人解决持久化的问题。

View 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实例的时候它们相互之间就可以通过replicaofRedis 5.0之前使用slaveof命令形成主库和从库的关系之后会按照三个阶段完成数据的第一次同步。
例如现在有实例1ip172.16.19.3和实例2ip172.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呢
好了,这节课就到这里,如果你觉得有收获,欢迎你帮我把今天的内容分享给你的朋友。

View 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集群的运维开销
- 监控主库运行状态,并判断主库是否客观下线;
- 在主库客观下线后,选取新主库;
- 选出新主库后,通知从库和客户端。
为了降低误判率,在实际应用时,哨兵机制通常采用多实例的方式进行部署,多个哨兵实例通过“少数服从多数”的原则,来判断主库是否客观下线。一般来说,我们可以部署三个哨兵,如果有两个哨兵认定主库“主观下线”,就可以开始切换过程。当然,如果你希望进一步提升判断准确率,也可以再适当增加哨兵个数,比如说使用五个哨兵。
但是,使用多个哨兵实例来降低误判率,其实相当于组成了一个哨兵集群,我们会因此面临着一些新的挑战,例如:
- 哨兵集群中有实例挂了,怎么办,会影响主库状态判断和选主吗?
- 哨兵集群多数实例达成共识,判断出主库“客观下线”后,由哪个实例来执行主从切换呢?
要搞懂这些问题,就不得不提哨兵集群了,下节课,我们来具体聊聊哨兵集群的机制和问题。
## 每课一问
按照惯例,我给你提个小问题。这节课,我提到,通过哨兵机制,可以实现主从库的自动切换,这是实现服务不间断的关键支撑,同时,我也提到了主从库切换是需要一定时间的。所以,请你考虑下,在这个切换过程中,客户端能否正常地进行请求操作呢?如果想要应用程序不感知服务的中断,还需要哨兵或需要客户端再做些什么吗?
欢迎你在留言区跟我交流讨论,也欢迎你能帮我把今天的内容分享给更多人,帮助他们一起解决问题。我们下节课见。

View 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 &lt;master-name&gt; &lt;ip&gt; &lt;redis-port&gt; &lt;quorum&gt;
```
这些哨兵实例既然都不知道彼此的地址,又是怎么组成集群的呢?要弄明白这个问题,我们就需要学习一下哨兵集群的组成和运行机制了。
## 基于pub/sub机制的哨兵集群组成
哨兵实例之间可以相互发现要归功于Redis提供的pub/sub机制也就是发布/订阅机制。
哨兵只要和主库建立起了连接就可以在主库上发布消息了比如说发布它自己的连接信息IP和端口。同时它也可以从主库上订阅消息获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后它们之间就能知道彼此的IP地址和端口。
除了哨兵实例我们自己编写的应用程序也可以通过Redis进行消息的发布和订阅。所以为了区分不同应用的消息Redis会以频道的形式对这些消息进行分门别类的管理。所谓的频道实际上就是消息的类别。当消息类别相同时它们就属于同一个频道。反之就属于不同的频道。**只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换**。
在主从集群中,主库上有一个名为“`__sentinel__:hello`”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
我来举个例子具体说明一下。在下图中哨兵1把自己的IP172.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 &lt;master name&gt; &lt;oldip&gt; &lt;oldport&gt; &lt;newip&gt; &lt;newport&gt;
```
有了这些事件通知,客户端不仅可以在主从切换后得到新主库的连接信息,还可以监控到主从库切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度。
好了有了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值对减少误判是不是也有好处呢
欢迎你在留言区跟我交流讨论。如果你身边也有要学习哨兵集群相关知识点的朋友,也欢迎你能帮我把今天的内容分享给他们,帮助他们一起解决问题。我们下节课见。

View 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实例那么该如何选择云主机的内存容量呢
我粗略地计算了一下这些键值对所占的内存空间大约是25GB5000万*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为什么不这么做呢
欢迎你在留言区畅所欲言,如果你觉得有收获,也希望你能帮我把今天的内容分享给你的朋友,帮助更多人解决切片集群的问题。

View File

@@ -0,0 +1,224 @@
<audio id="audio" title="10 | 第19讲课后思考题答案及常见问题答疑" 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)
**问题1AOF重写过程中有没有其他潜在的阻塞风险**
这里有两个风险。
风险一Redis主线程fork创建bgrewriteaof子进程时内核需要创建用于管理子进程的相关数据结构这些数据结构在操作系统中通常叫作进程控制块Process Control Block简称为PCB。内核要把主线程的PCB内容拷贝给子进程。这个创建和拷贝过程由内核执行是会阻塞主线程的。而且在拷贝过程中子进程要拷贝父进程的页表这个过程的耗时和Redis实例的内存大小有关。如果Redis实例内存大页表就会大fork执行时间就会长这就会给主线程带来阻塞风险。
风险二bgrewriteaof子进程会和主线程共享内存。当主线程收到新写或修改的操作时主线程会申请新的内存空间用来保存新写或修改的数据如果操作的是bigkey也就是数据量大的集合类型数据那么主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时有查找和锁的开销这就会导致阻塞。
**问题2AOF 重写为什么不共享使用 AOF 本身的日志?**
如果都用AOF日志的话主线程要写bgrewriteaof子进程也要写这两者会竞争文件系统的锁这就会对Redis主线程的性能造成影响。
### [第5讲](https://time.geekbang.org/column/article/271839)
问题:使用一个 2 核 CPU、4GB 内存、500GB 磁盘的云主机运行 RedisRedis 数据库的数据量大小差不多是 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写入磁盘还是要通过网络传输RDBIO效率都比记录和传输AOF的高。
1. 在从库端进行恢复时用RDB的恢复效率要高于用AOF。
### [第7讲](https://time.geekbang.org/column/article/274483)
**问题1在主从切换过程中客户端能否正常地进行请求操作呢**
主从集群一般是采用读写分离模式,当主库故障后,客户端仍然可以把读请求发送给从库,让从库服务。但是,对于写请求操作,客户端就无法执行了。
**问题2如果想要应用程序不感知服务的中断还需要哨兵或客户端再做些什么吗**
一方面客户端需要能缓存应用发送的写请求。只要不是同步写操作Redis应用场景一般也没有同步写写请求通常不会在应用程序的关键路径上所以客户端缓存写请求后给应用程序返回一个确认就行。
另一方面,主从切换完成后,客户端要能和新主库重新建立连接,哨兵需要提供订阅频道,让客户端能够订阅到新主库的信息。同时,客户端也需要能主动和哨兵通信,询问新主库的信息。
### [第8讲](https://time.geekbang.org/column/article/275337)
**问题15个哨兵实例的集群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的区别。
### 问题1rehash的触发时机和渐进式执行机制
我发现很多同学对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&lt;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="">
### 问题4replication 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达人

View 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 StringSDS结构体来保存如下图所示
<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保存一个图片存储对象ID8字节此时每个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&gt; info memory
# Memory
used_memory:1039120
127.0.0.1:6379&gt; hset 1101000 060 3302000080
(integer) 1
127.0.0.1:6379&gt; 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-valueRedis就会自动把Hash类型的实现结构由压缩列表转为哈希表。
一旦从压缩列表转为了哈希表Hash类型就会一直用哈希表进行保存而不会再转回压缩列表了。在节省内存空间方面哈希表就没有压缩列表那么高效了。
**为了能充分使用压缩列表的精简内存布局我们一般要控制保存在Hash集合中的元素个数**。所以在刚才的二级编码中我们只用图片ID最后3位作为Hash集合的key也就保证了Hash集合的元素个数不超过1000同时我们把hash-max-ziplist-entries设置为1000这样一来Hash集合就可以一直使用压缩列表来节省内存空间了。
## 小结
这节课我们打破了对String的认知误区以前我们认为String是“万金油”什么场合都适用但是在保存的键值对本身占用的内存空间不大时例如这节课里提到的的图片ID和图片存储对象IDString类型的元数据开销就占据主导了这里面包括了RedisObject结构、SDS结构、dictEntry结构的内存开销。
针对这种情况我们可以使用压缩列表保存数据。当然使用Hash这种集合类型保存单值键值对的数据时我们需要将单值数据拆分成两部分分别作为Hash集合的键和值就像刚才案例中用二级编码来表示图片ID希望你能把这个方法用到自己的场景中。
最后,我还想再给你提供一个小方法:如果你想知道键值对采用不同类型保存时的内存开销,可以在[这个网址](http://www.redis.cn/redis_memory/)里输入你的键值对长度和使用的数据类型,这样就能知道实际消耗的内存大小了。建议你把这个小工具用起来,它可以帮助你充分地节省内存。
## 每课一问
按照惯例给你提个小问题除了String类型和Hash类型你觉得还有其他合适的类型可以应用在这节课所说的保存图片的例子吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论,也欢迎你把今天的内容分享给你的朋友。

View 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 VisitorUV量。
通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。所以,我们必须要选择能够非常高效地统计大量数据(例如亿级)的集合类型。
**要想选择合适的集合,我们就得了解常用的集合统计模式。**这节课,我就给你介绍集合类型常见的四种统计模式,包括聚合统计、排序统计、二值状态统计和基数统计。我会以刚刚提到的这四个场景为例,和你聊聊在这些统计模式下,什么集合类型能够更快速地完成统计,而且还节省内存空间。掌握了今天的内容,之后再遇到集合元素统计问题时,你就能很快地选出合适的集合类型了。
## 聚合统计
我们先来看集合元素统计的第一个场景:聚合统计。
所谓的聚合统计,就是指统计多个集合元素的聚合结果,包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)。
在刚才提到的场景中统计手机App每天的新增用户数和第二天的留存用户数正好对应了聚合统计。
要完成这个统计任务我们可以用一个集合记录所有登录过App的用户ID同时用另一个集合记录每一天登录过App的用户ID。然后再对这两个集合做聚合统计。我们来看下具体的操作。
记录所有登录过App的用户ID还是比较简单的我们可以直接使用Set类型把key设置为user:id表示记录的是用户IDvalue就是一个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 SetList和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) &quot;A&quot;
2) &quot;B&quot;
3) &quot;C&quot;
```
然后再用下面的命令获取第二页的3个评论也就是D、E、F。
```
LRANGE product1 3 5
1) &quot;D&quot;
2) &quot;E&quot;
3) &quot;F&quot;
```
但是如果在展示第二页前又产生了一个新评论G评论G就会被LPUSH命令插入到评论List的队头评论List就变成了{G, A, B, C, D, E, F}。此时再用刚才的命令获取第二页评论时就会发现评论C又被展示出来了也就是C、D、E。
```
LRANGE product1 3 5
1) &quot;C&quot;
2) &quot;D&quot;
3) &quot;E&quot;
```
之所以会这样关键原因就在于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/102410天的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种典型的统计模式以及各种集合类型的支持情况和优缺点我想请你聊一聊你还遇到过其他的统计场景吗用的是怎样的集合类型呢
欢迎你在留言区写下你的思考和答案,和我交流讨论。如果你身边还有需要解决这些统计问题的朋友或同事,也欢迎你把今天的内容分享给他/她,我们下节课见。

View 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 ServiceLBS的应用。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="">
对纬度的编码方式,和对经度的一样,只是纬度的范围是[-9090]下面这张表显示了对纬度值39.86的编码过程。
<img src="https://static001.geekbang.org/resource/image/65/6d/65f41469866cb94963b4c9afbf2b016d.jpg" alt="">
当一组经纬度值都编完码后我们再把它们的各自编码值组合在一起组合的规则是最终编码值的偶数位上依次是经度的编码值奇数位上依次是纬度的编码值其中偶数位从0开始奇数位从1开始。
我们刚刚计算的经纬度116.3739.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.3739.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.03457939.030452我们可以用一个GEO集合保存所有车辆的经纬度集合key是cars:locations。执行下面的这个命令就可以把ID号为33的车辆的当前经纬度位置存入GEO集合中
```
GEOADD cars:locations 116.034579 39.030452 33
```
当用户想要寻找自己附近的网约车时LBS应用就可以使用GEORADIUS命令。
例如LBS应用执行下面的命令时Redis会根据输入的用户的经纬度信息116.05457939.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-&gt;head = NULL;
n-&gt;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-&gt;type = type;
o-&gt;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[] = {
...
{&quot;ntinsert&quot;,ntinsertCommand,2,&quot;m&quot;,...}
}
```
此时我们就完成了一个自定义的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的新数据类型的朋友也希望你帮我把今天的内容分享给他/她。我们下节课见。

View 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
&quot;25.1&quot;
HMGET device:temperature 202008030905 202008030907 202008030908
1) &quot;25.1&quot;
2) &quot;25.9&quot;
3) &quot;24.9&quot;
```
你看用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) &quot;25.9&quot;
2) &quot;24.9&quot;
3) &quot;25.3&quot;
4) &quot;25.2&quot;
```
现在我们知道了同时使用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&gt; MULTI
OK
127.0.0.1:6379&gt; HSET device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379&gt; ZADD device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379&gt; 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) &quot;device:temperature:1&quot;
2) (empty list or set)
3) 1) (integer) 1596417000
2) &quot;25.3&quot;
2) 1) &quot;device:temperature:3&quot;
2) (empty list or set)
3) 1) (integer) 1596417000
2) &quot;29.5&quot;
3) 1) &quot;device:temperature:4&quot;
2) (empty list or set)
3) 1) (integer) 1596417000
2) &quot;30.1&quot;
```
**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) &quot;25.6&quot;
2) 1) (integer) 1596416880
2) &quot;25.8&quot;
3) 1) (integer) 1596417060
2) &quot;26.1&quot;
```
与使用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的一个内在功能吗
好了,这节课就到这里,如果你觉得有所收获,欢迎你把今天的内容分享给你的朋友或同事,我们下节课见。

View 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 &quot;101030001:stock:5&quot;
(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和XACKXPENDING命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息而XACK命令用于向消息队列确认消息处理已完成。
首先我们来学习下Streams类型存取消息的操作XADD。
XADD命令可以往消息队列中插入新消息消息的格式是键-值对形式。对于插入的每一条消息Streams可以自动为其生成一个全局唯一的ID。
比如说我们执行下面的命令就可以往名称为mqstream的消息队列中插入一条消息消息的键是repo值是5。其中消息队列名称后面的`*`表示让Redis为插入的数据自动生成一个全局唯一的ID例如“1599203861727-0”。当然我们也可以不用`*`直接在消息队列名称后自行设定一个ID号只要保证这个ID号是全局唯一的就行。不过相比自行设定ID号使用`*`会更加方便高效。
```
XADD mqstream * repo 5
&quot;1599203861727-0&quot;
```
可以看到消息的全局唯一ID由两部分组成第一部分“1599203861727”是数据插入时以毫秒为单位计算的当前服务器时间第二部分表示插入消息在当前毫秒内的消息序号这是从0开始编号的。例如“1599203861727-0”就表示在“1599203861727”毫秒内的第1条消息。
当消费者需要读取消息时可以直接使用XREAD命令从消息队列中读取。
XREAD在读取消息时可以指定一个消息ID并从这个消息ID的下一条消息开始进行读取。
例如我们可以执行下面的命令从ID号为1599203861727-0的消息开始读取后续的所有消息示例中一共3条
```
XREAD BLOCK 100 STREAMS mqstream 1599203861727-0
1) 1) &quot;mqstream&quot;
2) 1) 1) &quot;1599274912765-0&quot;
2) 1) &quot;repo&quot;
2) &quot;3&quot;
2) 1) &quot;1599274925823-0&quot;
2) 1) &quot;repo&quot;
2) &quot;2&quot;
3) 1) &quot;1599274927910-0&quot;
2) 1) &quot;repo&quot;
2) &quot;1&quot;
```
另外消费者也可以在调用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中读取所有消息其中命令最后的参数“&gt;表示从第一条尚未被消费的消息开始读取。因为在consumer1读取消息前group1中没有其他消费者读取过消息所以consumer1就得到mqstream消息队列中的所有消息了一共4条
```
XREADGROUP group group1 consumer1 streams mqstream &gt;
1) 1) &quot;mqstream&quot;
2) 1) 1) &quot;1599203861727-0&quot;
2) 1) &quot;repo&quot;
2) &quot;5&quot;
2) 1) &quot;1599274912765-0&quot;
2) 1) &quot;repo&quot;
2) &quot;3&quot;
3) 1) &quot;1599274925823-0&quot;
2) 1) &quot;repo&quot;
2) &quot;2&quot;
4) 1) &quot;1599274927910-0&quot;
2) 1) &quot;repo&quot;
2) &quot;1&quot;
```
需要注意的是消息队列中的消息一旦被消费组里的一个消费者读取了就不能再被该消费组内的其他消费者读取了。比如说我们执行完刚才的XREADGROUP命令后再执行下面的命令让group1内的consumer2读取消息时consumer2读到的就是空值因为消息已经被consumer1读取完了如下所示
```
XREADGROUP group group1 consumer2 streams mqstream 0
1) 1) &quot;mqstream&quot;
2) (empty list or set)
```
使用消费组的目的是让组内的多个消费者共同分担读取消息所以我们通常会让每个消费者读取部分消息从而实现消息读取负载在多个消费者间是均衡分布的。例如我们执行下列命令让group2中的consumer1、2、3各自读取一条消息。
```
XREADGROUP group group2 consumer1 count 1 streams mqstream &gt;
1) 1) &quot;mqstream&quot;
2) 1) 1) &quot;1599203861727-0&quot;
2) 1) &quot;repo&quot;
2) &quot;5&quot;
XREADGROUP group group2 consumer2 count 1 streams mqstream &gt;
1) 1) &quot;mqstream&quot;
2) 1) 1) &quot;1599274912765-0&quot;
2) 1) &quot;repo&quot;
2) &quot;3&quot;
XREADGROUP group group2 consumer3 count 1 streams mqstream &gt;
1) 1) &quot;mqstream&quot;
2) 1) 1) &quot;1599274925823-0&quot;
2) 1) &quot;repo&quot;
2) &quot;2&quot;
```
为了保证消费者在发生故障或宕机再次重启后仍然可以读取未处理完的消息Streams会自动使用内部队列也称为PENDING List留存消费组里每个消费者读取的消息直到消费者使用XACK命令通知Streams“消息已经处理完成”。如果消费者没有成功处理消息它就不会给Streams发送XACK命令消息仍然会留存。此时消费者可以在重启后用XPENDING命令查看已读取、但尚未确认处理完成的消息。
例如我们来查看一下group2中各个消费者已读取、但尚未确认的消息个数。其中XPENDING返回结果的第二、三行分别表示group2中所有消费者读取的消息最小ID和最大ID。
```
XPENDING mqstream group2
1) (integer) 3
2) &quot;1599203861727-0&quot;
3) &quot;1599274925823-0&quot;
4) 1) 1) &quot;consumer1&quot;
2) &quot;1&quot;
2) 1) &quot;consumer2&quot;
2) &quot;1&quot;
3) 1) &quot;consumer3&quot;
2) &quot;1&quot;
```
如果我们还需要进一步查看某个消费者具体读取了哪些数据,可以执行下面的命令:
```
XPENDING mqstream group2 - + 10 consumer2
1) 1) &quot;1599274912765-0&quot;
2) &quot;consumer2&quot;
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的什么数据类型来解决这个问题呢
欢迎在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎你帮我分享给更多人。我们下节课见。

View 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.98sHash类型。Redis的响应时间一般在微秒级别所以一个操作达到了近2s不可避免地会阻塞主线程。
经过刚刚的分析,很显然,**bigkey删除操作就是Redis的第二个阻塞点**。删除操作对Redis实例性能的负面影响很大而且在实际业务开发时容易被忽略所以一定要重视它。
既然频繁删除键值对都是潜在的阻塞点了那么在Redis的数据库级别操作中清空数据库例如FLUSHDB和FLUSHALL操作必然也是一个潜在的阻塞风险因为它涉及到删除和释放所有的键值对。所以这就是**Redis的第三个阻塞点清空数据库**。
**2.和磁盘交互时的阻塞点**
我之所以把Redis与磁盘的交互单独列为一类主要是因为磁盘IO一般都是比较费时费力的需要重点关注。
幸运的是Redis开发者早已认识到磁盘IO会带来阻塞所以就把Redis进一步设计为采用子进程的方式生成RDB快照文件以及执行AOF日志重写操作。这样一来这两个操作由子进程负责执行慢速的磁盘IO就不会阻塞主线程了。
但是Redis直接记录AOF日志时会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是12ms如果有大量的写操作需要记录在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等是在关键路径上吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你帮我分享给更多人,我们下节课见。

View 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 AccessNUMA架构
到这里我们就知道了主流的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(&amp;cpu_set); //位图变量所有位设置0
CPU_SET(bind_cpu, &amp;cpuset); //根据输入的bind_cpu编号把位图对应为设置为1
sched_setaffinity(0, sizeof(cpuset), &amp;cpuset); //把程序绑定在cpu_set_t结构位图中为1的逻辑核
//实际线程函数工作
}
int main(){
pthread_t pthread1
//把创建的pthread1绑在编号为3的逻辑核上
pthread_create(&amp;pthread1, NULL, (void *)worker, 3);
}
```
对于Redis来说它是在bio.c文件中的bioProcessBackgroundJobs函数中创建了后台线程。bioProcessBackgroundJobs函数类似于刚刚的例子中的worker函数在这个函数中实现绑核四步操作就可以把后台线程绑到和主线程不同的核上了。
和给线程绑核类似当我们使用fork创建子进程时也可以把刚刚说的四步操作实现在fork后的子进程代码中示例代码如下
```
int main(){
//用fork创建一个子进程
pid_t p = fork();
if(p &lt; 0){
printf(&quot; fork error\n&quot;);
}
//子进程代码部分
else if(!p){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&amp;cpu_set); //位图变量所有位设置0
CPU_SET(3, &amp;cpuset); //把位图的第3位设置为1
sched_setaffinity(0, sizeof(cpuset), &amp;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上的核绑定。
如果不考虑网络数据读取的影响,你会选择哪个方案呢?
欢迎在留言区写下你的思考和答案,如果你觉得有所收获,也欢迎你帮我把今天的内容分享给你的朋友。我们下节课见。

View 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&gt; KEYS *name*
1) &quot;lastname&quot;
2) &quot;firstname&quot;
```
**因为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变慢吗
欢迎在留言区写下你的思考和答案,我们一起讨论,共同学习进步。如果你觉得有所收获,欢迎你把今天的内容分享给你的朋友。

View 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变慢的情况吗如果有的话你是怎么解决的呢
欢迎你在留言区分享一下自己的经验,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友或同事,我们下节课见。

View 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的性能和内存空间利用率造成什么影响呢
欢迎你在留言区写下你的思考和答案,和我一起交流讨论,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友或同事,我们下节课见。

View 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] &quot;COMMAND&quot;
1600617477.289667 [0 127.0.0.1:50487] &quot;info&quot; &quot;memory&quot;
```
到这里你有没有看出什么问题呢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代表将缓冲区大小的上限设置为512MB128mb和60代表的设置是如果连续60秒内的写入量超过128MB的话也会触发缓冲区溢出。
我们再继续看看这个设置对我们有啥用。假设一条写命令数据是1KB那么复制缓冲区可以累积512K条512MB/1KB = 512K写命令。同时主节点在全量复制期间可以承受的写命令速率上限是2000条/s128MB/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的性能和内存使用会有影响吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事,我们下节课见。

View File

@@ -0,0 +1,261 @@
<audio id="audio" title="22 | 第1121讲课后思考题答案及常见问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/ce/6df7cb9e10af08157c6bf095c3ba45ce.mp3"></audio>
你好,我是蒋德钧。
咱们的课程已经更新到第21讲了今天我们来进行一场答疑。
前半部分我会给你讲解第1121讲的课后思考题。在学习这部分内容时可以和你的答案进行对照看看还有哪里没有考虑到。当然有些问题不一定有标准答案我们还可以继续讨论。
后半部分我会围绕着许多同学都很关注的如何排查慢查询命令和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、GEORedis还有一种数据类型叫作布隆过滤器。它的查询效率很高经常会用在缓存场景中可以用来判断数据是否存在缓存中。我会在后面第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 &quot;103*&quot; 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小于1Redis的内存使用是什么情况呢会对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) &quot;keys&quot; //具体的执行命令和参数
2) &quot;abc*&quot;
5) &quot;127.0.0.1:54793&quot; //客户端的IP和端口号
6) &quot;&quot; //客户端的名称,此处为空
```
可以看到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) &quot;command&quot;
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性能变慢。
希望这节课的答疑,能帮助你更加深入地理解前面学过的内容。通过这节课,你应该也看到了,课后思考题是一种很好地梳理重点内容、拓展思路的方式,所以,在接下来的课程里,希望你能多留言聊一聊你的想法,这样可以进一步巩固你所学的知识。而且,还能在和其他同学的交流中,收获更多东西。好了,这节课就到这里,我们下节课见。

View 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作为缓存。用户请求发送给TomcatTomcat负责处理业务逻辑。如果要访问数据就需要从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只读缓存和使用直写策略的读写缓存这两种缓存都会把数据同步写到后端数据库中你觉得它们有什么区别吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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-lfuRedis 4.0后新增)四种。
- 在所有数据范围内进行淘汰包括allkeys-lru、allkeys-random、allkeys-lfuRedis 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-samplesRedis就把候选数据集中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缓存对应哪一种或哪几种模式
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或/同事。我们下节课见。

View 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的新值为3Redis中的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缓存客户端暂存并发读请求等数据库更新完、缓存值删除后再读取数据从而保证数据一致性。
## 每课一问
按照惯例,我给你提个小问题。这节课,我提到,在只读缓存中进行数据的删改操作时,需要在缓存中删除相应的缓存值。我想请你思考一下,如果在这个过程中,我们不是删除缓存值,而是直接更新缓存的值,你觉得和删除缓存值相比,有什么好处和不足吗?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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="">
最后,我想强调一下,服务熔断、服务降级、请求限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响。例如使用服务降级时,有部分数据的请求就只能得到错误返回信息,无法正常处理。如果使用了服务熔断,那么,整个缓存系统的服务都被暂停了,影响的业务范围更大。而使用了请求限流机制后,整个业务系统的吞吐率会降低,能并发处理的用户请求会减少,会影响到用户体验。
所以,我给你的建议是,尽量使用预防式方案:
- 针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
- 针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
- 针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。
## 每课一问
按照惯例,我给你提个小问题。在讲到缓存雪崩时,我提到,可以采用服务熔断、服务降级、请求限流的方法来应对。请你思考下,这三个机制可以用来应对缓存穿透问题吗?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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值都是255LFU策略再比较A和B的访问时间戳发现数据B的上一次访问时间早于A就会把B淘汰掉。但其实数据B的访问次数远大于数据A很可能会被再次访问。这样一来使用LFU策略来淘汰数据就不合适了。
的确Redis也注意到了这个问题。因此**在实现LFU策略时Redis并没有采用数据每被访问一次就给对应的counter值加1的计数规则而是采用了一个更优化的计数规则**。
简单来说LFU策略实现的计数规则是每当数据被访问一次时首先用计数器当前的值乘以配置项lfu_log_factor再加1再取其倒数得到一个p值然后把这个p值和一个取值范围在01间的随机数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 &lt; 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策略后你觉得缓存还会被污染吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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 DriveSSD。它的成本很低每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保存更多数据
为了把数据保存到SSDPika使用了业界广泛应用的持久化键值数据库[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实例的数据保存量我想请你来聊一聊我们可以使用机械硬盘来作为实例容量扩展吗有什么好处或不足吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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也扣减了1B记录的库存值也为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 &gt; 20 THEN
ERROR &quot;exceed 20 accesses per second&quot;
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(&quot;incr&quot;,KEYS[1])
if tonumber(current) == 1 then
redis.call(&quot;expire&quot;,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脚本中吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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同时把加锁请求发给了RedisRedis也会串行处理它们的请求。
我们假设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(&quot;get&quot;,KEYS[1]) == ARGV[1] then
return redis.call(&quot;del&quot;,KEYS[1])
else
return 0
end
```
这是使用Lua脚本unlock.script实现的释放锁操作的伪代码其中KEYS[1]表示lock_keyARGV[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命令带上NXEX/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
```
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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&gt; MULTI
OK
#将a:stock减1
127.0.0.1:6379&gt; DECR a:stock
QUEUED
#将b:stock减1
127.0.0.1:6379&gt; DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379&gt; 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&gt; MULTI
OK
#发送事务中的第一个操作但是Redis不支持该命令返回报错信息
127.0.0.1:6379&gt; PUT a:stock 5
(error) ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`,
#发送事务中的第二个操作这个操作是正确的命令Redis把该命令入队
127.0.0.1:6379&gt; DECR b:stock
QUEUED
#实际执行事务但是之前命令有错误所以Redis拒绝执行
127.0.0.1:6379&gt; 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&gt; MULTI
OK
#发送事务中的第一个操作LPOP命令操作的数据类型不匹配此时并不报错
127.0.0.1:6379&gt; LPOP a:stock
QUEUED
#发送事务中的第二个操作
127.0.0.1:6379&gt; DECR b:stock
QUEUED
#实际执行事务,事务第一个操作执行报错
127.0.0.1:6379&gt; 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&gt; GET a:stock
&quot;4&quot;
#开启事务
127.0.0.1:6379&gt; MULTI
OK
#发送事务的第一个操作对a:stock减1
127.0.0.1:6379&gt; DECR a:stock
QUEUED
#执行DISCARD命令主动放弃事务
127.0.0.1:6379&gt; DISCARD
OK
#再次读取a:stock的值值没有被修改
127.0.0.1:6379&gt; GET a:stock
&quot;4&quot;
```
这个例子中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机制那么事务的原子性还能得到保证吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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让从库也能直接删除数据以此来避免读到过期数据你觉得这是一个好方法吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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设置为十几秒例如1020s在这个配置下如果有一半以上的从库和主库进行的ACK消息延迟超过十几秒我们就禁止主库接收客户端写请求。
这样一来,我们可以避免脑裂带来数据丢失的情况,而且,也不会因为只有少数几个从库因为网络阻塞连不上主库,就禁止主库接收请求,增加了系统的鲁棒性。
## 每课一问
按照惯例我给你提个小问题假设我们将min-slaves-to-write设置为1min-slaves-max-lag设置为15s哨兵的down-after-milliseconds设置为10s哨兵主从切换需要5s。主库因为某些原因卡住了12s此时还会发生脑裂吗主从切换完成后数据会丢失吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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>
你好,我是蒋德钧。
今天又到了我们的答疑时间我们一起来学习下第2333讲的课后思考题。同时我还会给你讲解两道典型问题。
## 课后思考题答案
### [第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的查询压力也避免了对数据库的无效访问。
另外这里有个地方需要注意下对于缓存雪崩和击穿问题来说服务熔断、服务降级和请求限流这三种方法属于有损方法会降低业务吞吐量、拖慢系统响应、降低用户体验。不过采用这些方法后随着数据慢慢地重新填充回RedisRedis还是可以逐步恢复缓存层作用的。
### [第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(&quot;incr&quot;,KEYS[1])
if tonumber(current) == 1 then
redis.call(&quot;expire&quot;,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设置为1min-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实战能力。

View File

@@ -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 proxyproxy再把数据返回给客户端。
我来用一张图展示这个处理流程:
<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被分配到了server1Slot 2分配到server2Slot 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 proxyproxy就可以根据最新的路由信息转发请求了。
在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就会阻塞较长时间无法及时处理用户请求。
为了避免数据迁移阻塞源serverCodis实现的第二种迁移模式就是异步迁移。异步迁移的关键特点有两个。
第一个特点是当源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实例信息等。这里有个地方需要你注意除了使用ZookeeperCodis还可以使用etcd或本地文件系统保存元数据信息。
关于Codis和Redis Cluster的选型考虑我从稳定性成熟度、客户端兼容性、Redis新特性使用以及数据迁移性能四个方面给你提供了建议希望能帮助到你。
最后我再给你提供一个Codis使用上的小建议当你有多条业务线要使用Codis时可以启动多个codis dashboard每个dashboard管理一部分codis server同时再用一个dashboard对应负责一个业务线的集群管理这样就可以做到用一个Codis集群实现多条业务线的隔离管理了。
## 每课一问
按照惯例我会给你提个小问题。假设Codis集群中保存的80%的键值对都是Hash类型每个Hash集合的元素数量在10万20万个每个集合元素的大小是2KB。你觉得迁移一个这样的Hash集合数据会对Codis的性能造成影响吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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(&quot;HMGET&quot;, KEYS[1], &quot;total&quot;, &quot;ordered&quot;);
#将总库存转换为数值
local total = tonumber(counts[1])
#将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存
if ordered + k &lt;= total then
#更新已秒杀的库存量
redis.call(&quot;HINCRBY&quot;,KEYS[1],&quot;ordered&quot;,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 &lt; 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然后客户端的秒杀请求可以分发到不同的实例上进行处理你觉得这是一个好方法吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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&gt; cluster slots
1) 1) (integer) 0
2) (integer) 4095
3) 1) &quot;192.168.10.3&quot;
2) (integer) 6379
2) 1) (integer) 12288
2) (integer) 16383
3) 1) &quot;192.168.10.5&quot;
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选项一次迁移多个keykey1、2、3这样可以提升迁移效率。
```
MIGRATE 192.168.10.5 6379 &quot;&quot; 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中的数据是缓存数据的最终值保存在后端数据库此时会发生什么问题
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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万QPS200/250个主实例*8万QPS=1600/2000万QPS这个吞吐量性能可以满足不少业务应用的需求。
## 每课一问
按照惯例我给你提个小问题如果我们采用跟Codis保存Slot分配信息相类似的方法把集群实例状态信息和Slot分配信息保存在第三方的存储系统上例如Zookeeper这种方法会对集群规模产生什么影响吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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重写相关。找到了影响的因素解决起来也就很容易了。
另外,在学习和使用的过程中,你完全可以根据你自己的方式,完善这张画像图,把你自己实践或掌握到的新知识点,按照“问题 --&gt; 主线 --&gt; 技术点”的方式梳理出来,放到这张图上。这样一来,你的积累越来越多,画像也会越来越丰富。以后在遇到问题的时候,就很容易解决了。
## 课程是如何设计的?
刚刚说的这些,其实也正是咱们这个课程的核心设计理念。接下来,我就说说这门课具体是怎么设计的。
**基础篇:打破技术点之间的壁垒,带你建立网状知识结构**
我会先从构造一个简单的键值数据库入手,带你庖丁解牛。这有点像是建房子,只有顶梁柱确定了,房子有形了,你才能去想“怎么设计更美、更实用”的问题。因此,在“基础篇”,我会具体讲解数据结构、线程模型、持久化等几根“顶梁柱”,让你不仅能抓住重点,还能明白它们在整体框架中的地位和作用,以及它们之间的相互联系。明白了这些,也就打好了基础。
**实践篇:场景和案例驱动,取人之长,梳理出一套属于你自己的“武林秘籍”**
前面说过,从应用的维度来说,在学习时,我们需要以“场景”和“案例”作为驱动。因此,在“实践篇”,我也会从这两大层面来进行讲解。
在“案例”层面,我会介绍数据结构的合理使用、避免请求阻塞和抖动、避免内存竞争和提升内存使用效率的关键技巧;在“场景”层面,我会重点介绍缓存和集群两大场景。
对于缓存而言,我会重点讲解缓存基本原理及淘汰策略,还有雪崩、穿透、污染等异常情况;对于集群来说,我会围绕集群方案优化、数据一致性、高并发访问等问题,和你聊聊可行的解决方案。
**未来篇:具有前瞻性,解锁新特性**
Redis 6.0刚刚推出增加了万众瞩目的多线程等新特性因此我会向你介绍这些新特性以及当前业界对Redis的最新探索这会让你拥有前瞻性视角了解Redis的发展路线图为未来的发展提前做好准备。凡事预则立这样一来你就可以走在很多人的前面。
除此之外,我还会不定期进行加餐,跟你分享一些好的运维工具、定制化客户端开发的方法、经典的学习资料,等等,还会策划一些答疑,及时解决你的疑惑。
<img src="https://static001.geekbang.org/resource/image/13/7e/13946f7543f9eea58c9bd2b877826b7e.jpg" alt="">
最后我想说Redis是一个非常优秀的系统它在CPU使用、内存组织、存储持久化和网络通信这四大方面的设计非常经典而这些基本涵盖了一个优秀的后端系统工程师需要掌握的核心知识和关键技术。希望你通过这个课程的学习成长为一名优秀的系统工程师。
不过一个人单枪匹马地去学习往往很难坚持下去。如果你身边也有在使用Redis的同学我希望你能帮忙把这个课程分享给他/她,你们可以一起学习,互相鼓励。欢迎多多给我留言,你们的鼓励是我持续产出好内容的动力。

View 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&amp;exam_id=662)
## 问答题
### 第一题
Redis在接收多个网络客户端发送的请求操作时如果有一个客户端和Redis的网络连接断开了Redis会一直等待该客户端恢复连接吗为什么
### 第二题
Redis的主从集群可以提升数据可靠性主节点在和从节点进行数据同步时会使用两个缓冲区复制缓冲区和复制积压缓冲区这两个缓冲区的作用各是什么会对Redis主从同步产生什么影响吗
### 第三题
假设在业务场景中我们有20GB的短视频属性信息包括短视频ID、短视频基本信息例如短视频作者、创建时间等要持久化保存并且线上负载以读为主需要能快速查询到这些短视频信息。
现在我们想使用Redis来实现这个需求请你来设计一个解决方案。我来提几个问题你可以思考下。
首先你会用Redis的什么数据类型来保存数据如果我们只用单个实例来运行的话你会采用什么样的持久化方案来保证数据的可靠性
其次如果不使用单实例运行我们有两个备选方案一个是用两台32GB内存的云主机来运行主从两个Redis实例另一个是用10台8GB的云主机来运行Redis Cluster每两台云主机分别运行一个Redis实例主库和从库分别保存4GB数据你会用哪种方案呢请聊一聊你的想法。
好了这节课就到这里。希望你能抓住期中周的机会查漏补缺快速地提升Redis实战能力。我们10月7日见

View 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>
不足:需要较多的云主机,运维和资源成本较高。
好了,这节课就到这里。假期很快就要结束了,希望你抓住最后的几天时间,好好地巩固一下所学的内容。我们下节课见。

View 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 ListACL这个特性可以有效地提升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 &gt; 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的哪个或哪些新特性会对你有帮助呢?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。我们下节课见。

View 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 MemoryNVM器件发展得非常快。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 MemoryPM
现在呢我们知道了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主从集群吗?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得今天的内容对你有所帮助,也欢迎你分享给你的朋友或同事。

View File

@@ -0,0 +1,134 @@
<audio id="audio" title="41 | 第3540讲课后思考题答案及常见问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/22/5953267ce8bf69ac50e74488b55f5222.mp3"></audio>
你好,我是蒋德钧。
今天是我们最后一节答疑课我会带你一起分析一下第3540讲的课后思考题。同时我还会讲解两个典型问题分别是原子操作使用问题以及Redis和其他键值数据库的对比情况。
## [第35讲](https://time.geekbang.org/column/article/306548)
问题假设Codis集群中保存的80%的键值对都是Hash类型每个Hash集合的元素数量在10万~20万个每个集合元素的大小是2KB。你觉得迁移这样的Hash集合数据会对Codis的性能造成影响吗
答案其实影响不大。虽然一个Hash集合数据的总数据量有200MB ~ 400MB2KB * 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 &gt; 20 THEN
ERROR &quot;exceed 20 accesses per second&quot;
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优于CodisCodis优于Redis Cluster。所以如果实际业务需要大规模集群建议你优先选择Codis或者是基于一致性哈希的Redis切片集群方案。

View 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&amp;exam_id=815)

View 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)