35-41节 集群、数据倾斜、通信开销、Redis6.0新特性、NVM

35-Codis VS RedisCluster:我该选择哪一个集群方案?

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是如何处理请求的。

首先,为了让集群能接收并处理请求,我们要先使用codis dashboard 设置codis server和codis proxy的访问地址,完成设置后,codis server和codis proxy才会开始接收连接。

然后,当客户端要读写数据时,客户端直接和codis proxy建立连接。你可能会担心,既然客户端连接的是proxy,是不是需要修改客户端,才能访问proxy?其实,你不用担心,codis proxy本身支持Redis的RESP交互协议,所以,客户端访问codis proxy时,和访问原生的Redis实例没有什么区别,这样一来,原本连接单实例的客户端就可以轻松地和Codis集群建立起连接了。

最后,codis proxy接收到请求,就会查询请求数据和codis server的映射关系,并把请求转发给相应的codis server进行处理。当codis server处理完请求后,会把结果返回给codis proxy,proxy再把数据返回给客户端。

了解了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上了。

数据key和Slot的映射关系是客户端在【读写数据前】直接通过CRC32计算得到的,而Slot和codis server的映射关系是通过分配完成的,所以就需要用一个存储系统保存下来,否则,如果集群有故障了,映射关系就会丢失。

我们把Slot和codis server的映射关系称为数据路由表(简称路由表)。我们在codis dashboard上分配好路由表后,dashboard会把路由表发送给codis proxy,同时,dashboard也会把路由表保存在Zookeeper中。codis-proxy会把路由表缓存在本地,当它接收到客户端请求后,直接查询本地的路由表,就可以完成正确的请求转发了。
你可以看下这张图,它显示了路由表的分配和使用过程。

在数据分布的实现方法上,Codis和Redis Cluster很相似,都采用了key映射到Slot、Slot再分配到实例上的机制。

但是,这里有一个明显的区别,我来解释一下。

Codis中的路由表是我们通过codis dashboard分配和修改的,并被保存在Zookeeper集群中。一旦数据位置发生变化(例如有实例增减),路由表被修改了,codis dashbaord就会把修改后的路由表发送给codis proxy,proxy就可以根据最新的路由信息转发请求了。

在Redis Cluster中,数据路由表是通过每个实例相互间的通信传递的,最后会在每个实例上保存一份。当数据路由信息发生变化时,就需要在所有实例间通过网络消息进行传递。所以,如果实例数量较多的话,就会消耗较多的集群网络资源。

数据分布解决了新数据写入时该保存在哪个server的问题,但是,当业务数据增加后,如果集群中的现有实例不足以保存所有数据,我们就需要对集群进行扩容。接下来,我们再来学习下Codis针对集群扩容的关键技术设计。

集群扩容和数据迁移如何进行?

Codis集群扩容包括了两方面:增加codis server和增加codis proxy。

我们先来看增加codis server,这个过程主要涉及到两步操作:

  • 启动新的codis server,将它加入集群;
  • 把部分数据迁移到新的server。
  • 需要注意的是,这里的数据迁移是一个重要的机制,接下来我来重点介绍下。

Codis集群按照Slot的粒度进行数据迁移,我们来看下迁移的基本流程。

  • 在源server上,Codis从要迁移的Slot中随机选择一个数据,发送给目的server。
  • 目的server确认收到数据后,会给源server返回确认消息。这时,源server会在本地将刚才迁移的数据删除。
  • 第一步和第二步就是单个数据的迁移过程。Codis会不断重复这个迁移过程,直到要迁移的Slot中的数据全部迁移完成。

我画了下面这张图,显示了数据迁移的流程,你可以看下加深理解。

针对刚才介绍的单个数据的迁移过程,Codis实现了两种迁移模式,分别是同步迁移和异步迁移,我们来具体看下。

同步迁移是指,在数据从源server发送给目的server的过程中,源server是阻塞的,无法处理新的请求操作。这种模式很容易实现,但是迁移过程中会涉及多个操作(包括数据在源server序列化、网络传输、在目的server反序列化,以及在源server删除),如果迁移的数据是一个bigkey,源server就会阻塞较长时间,无法及时处理用户请求。

为了避免数据迁移阻塞源server,Codis实现的第二种迁移模式就是异步迁移。异步迁移的关键特点有两个。

第一个特点是,当源server把数据发送给目的server后,就可以处理其他请求操作了,不用等到目的server的命令执行完。而目的server会在收到数据并反序列化保存到本地后,给源server发送一个ACK消息,表明迁移完成。此时,源server在本地把刚才迁移的数据删除。

在这个过程中,迁移的数据会被设置为只读,所以,源server上的数据步会被修改,自然也就不会出现“和目的server上的数据不一致”问题了。

第二个特点是,对于bigkey,异步迁移采用了拆分指令的方式进行迁移。具体来说就是,对bigkey中每个元素,用一条指令进行迁移,而不是把整个bigkey进行序列化后再整体传输。这种化整为零的方式,就避免了bigkey迁移时,因为要序列化大量数据而阻塞源server的问题。

外,当bigkey迁移了一部分数据后,如果Codis发生故障,就会导致bigkey的一部分元素在源server,而另一部分元素在目的server,这就破坏了迁移的原子性。

所以,Codis会在目标server上,给bigkey的元素设置一个临时过期时间。如果迁移过程中发生故障,那么,目标server上的key会在过期后被删除,不会影响迁移的原子性。当正常完成迁移后,bigkey元素的临时过期时间会被删除。

我给你举个例子,假如我们要迁移一个有1万个元素的List类型数据,当使用异步迁移时,源server就会给目的server传输1万条RPUSH命令,每条命令对应了List中一个元素的插入。在目的server上,这1万条命令再被依次执行,就可以完成数据迁移。

这里,有个地方需要你注意下,为了提升迁移的效率,Codis在异步迁移Slot时,允许每次迁移多个key。你可以通过异步迁移命令SLOTSMGRTTAGSLOT-ASYNC的参数numkeys设置每次迁移的key数量

刚刚我们学习的是codis server的扩容和数据迁移机制,其实,在Codis集群中,除了增加codis server,有时还需要增加codis proxy。

因为在Codis集群中,客户端是和codis proxy直接连接的,所以,当客户端增加时,一个proxy无法支撑大量的请求操作,此时,我们就需要增加proxy。

增加proxy比较容易,我们直接启动proxy,再通过codis dashboard把proxy加入集群就行。

此时,codis proxy的访问连接信息都会保存在Zookeeper上。所以,当新增了proxy后,Zookeeper上会有最新的访问列表,客户端也就可以从Zookeeper上读取proxy访问列表,把请求发送给新增的proxy。这样一来,客户端的访问压力就可以在多个proxy上分担处理了,如下图所示:

好了,到这里,我们就了解了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的主从切换,提升集群可靠性。

因为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这两种切片集群方案我们就学完了,我把它们的区别总结在了一张表里,你可以对比看下。

最后,在实际应用的时候,对于这两种方案,我们该怎么选择呢?我再给你提4条建议。

  1. 从稳定性和成熟度来看,Codis应用得比较早,在业界已经有了成熟的生产部署。虽然Codis引入了proxy和Zookeeper,增加了集群复杂度,但是,proxy的无状态设计和Zookeeper自身的稳定性,也给Codis的稳定使用提供了保证。而Redis Cluster的推出时间晚于Codis,相对来说,成熟度要弱于Codis,如果你想选择一个成熟稳定的方案,Codis更加合适些。
  2. 从业务应用客户端兼容性来看,连接单实例的客户端可以直接连接codis proxy,而原本连接单实例的客户端要想连接Redis Cluster的话,就需要开发新功能。所以,如果你的业务应用中大量使用了单实例的客户端,而现在想应用切片集群的话,建议你选择Codis,这样可以避免修改业务应用中的客户端。
  3. 从使用Redis新命令和新特性来看,Codis server是基于开源的Redis 3.2.8开发的,所以,Codis并不支持Redis后续的开源版本中的新增命令和数据类型。另外,Codis并没有实现开源Redis版本的所有命令,比如BITOP、BLPOP、BRPOP,以及和与事务相关的MUTLI、EXEC等命令。Codis官网上列出了不被支持的命令列表,你在使用时记得去核查一下。所以,如果你想使用开源Redis 版本的新特性,Redis Cluster是一个合适的选择。
  4. 从数据迁移性能维度来看,Codis能支持异步迁移,异步迁移对集群处理正常请求的性能影响要比使用同步迁移的小。所以,如果你在应用集群时,数据迁移比较频繁的话,Codis是个更合适的选择。

小结

这节课,我们学习了Redis切片集群的Codis方案。Codis集群包含codis server、codis proxy、Zookeeper、codis dashboard和codis fe这四大类组件。我们再来回顾下它们的主要功能。

  • codis proxy和codis server负责处理数据读写请求,其中,codis proxy和客户端连接,接收请求,并转发请求给codis server,而codis server负责具体处理请求。
  • codis dashboard和codis fe负责集群管理,其中,codis dashboard执行管理操作,而codis fe提供Web管理界面。
  • Zookeeper集群负责保存集群的所有元数据信息,包括路由表、proxy实例信息等。这里,有个地方需要你注意,除了使用Zookeeper,Codis还可以使用etcd或本地文件系统保存元数据信息。

关于Codis和Redis Cluster的选型考虑,我从稳定性成熟度、客户端兼容性、Redis新特性使用以及数据迁移性能四个方面给你提供了建议,希望能帮助到你。

最后,我再给你提供一个Codis使用上的小建议:当你有多条业务线要使用Codis时,可以启动多个codis dashboard,每个dashboard管理一部分codis server,同时,再用一个dashboard对应负责一个业务线的集群管理,这样,就可以做到用一个Codis集群实现多条业务线的隔离管理了。

36-Redis支撑秒杀场景的关键技术和实践都有哪些?

秒杀场景的负载特征对支撑系统的要求

秒杀活动售卖的商品通常价格非常优惠,会吸引大量用户进行抢购。但是,商品库存量却远远小于购买该商品的用户数,而且会限定用户只能在一定的时间段内购买。这就给秒杀系统带来两个明显的负载特征,相应的,也对支撑系统提出了要求,我们来分析下。

第一个特征是瞬时并发访问量非常高。

一般数据库每秒只能支撑千级别的并发请求,而Redis的并发处理能力(每秒处理请求数)能达到万级别,甚至更高。所以,当有大量并发请求涌入秒杀系统时,我们就需要使用Redis先拦截大部分请求,避免大量请求直接发送给数据库,把数据库压垮。

第二个特征是读多写少,而且读操作是简单的查询操作。

当然,实际秒杀场景通常有多个环节,刚才介绍的用户查验库存只是其中的一个环节。那么,Redis具体可以在整个秒杀场景中哪些环节发挥作用呢?这就要说到秒杀活动的整体流程了,我们来分析下。

Redis可以在秒杀场景的哪些环节发挥作用?

我们一般可以把秒杀活动分成三个阶段。在每一个阶段,Redis所发挥的作用也不一样。

  • 第一阶段是秒杀活动前。
    • 在这个阶段,用户会不断刷新商品详情页,这会导致详情页的瞬时请求量剧增。这个阶段的应对方案,一般是尽量把商品详情页的页面元素静态化,然后使用CDN或是浏览器把这些静态化的元素缓存起来。这样一来,秒杀前的大量请求可以直接由CDN或是浏览器缓存服务,不会到达服务器端了,这就减轻了服务器端的压力。
    • 在这个阶段,有CDN和浏览器缓存服务请求就足够了,我们还不需要使用Redis。
  • 第二阶段是秒杀活动开始。
    • 此时,大量用户点击商品详情页上的秒杀按钮,会产生大量的并发请求查询库存。这个阶段的操作简单说就是三个:库存查验、库存扣减和订单处理。因为每个秒杀请求都会查询库存,而请求只有查到有库存余量后,后续的库存扣减和订单处理才会被执行。所以,这个阶段中最大的并发压力都在库存查验操作上。
    • 为了支撑大量高并发的库存查验请求,我们需要在这个环节使用Redis保存库存量
    • 订单处理会涉及支付、商品出库、物流等多个关联操作,这些操作本身涉及数据库中的多张数据表,要保证处理的事务性,需要在数据库中完成。而且,订单处理时的请求压力已经不大了,数据库可以支撑这些订单处理请求。
      • 需要直接在Redis中进行库存扣减,如果放到数据库执行,会带来两个问题。
        • 额外的开销。Redis中保存了库存量,而库存量的最新值又是数据库在维护,所以数据库更新后,还需要和Redis进行同步,这个过程增加了额外的操作逻辑,也带来了额外的开销。
        • 下单量超过实际库存量,出现超售。由于数据库的处理速度较慢,不能及时更新库存余量,这就会导致大量库存查验的请求读取到旧的库存值,并进行下单。此时,就会出现下单数量大于实际的库存量,导致出现超售,这就不符合业务层的要求了。
      • 具体的操作是,当库存查验完成后,一旦库存有余量,我们就立即在Redis中扣减库存。而且,为了避免请求查询到旧的库存值,库存查验和库存扣减这两个操作需要保证原子性。
  • 第三阶段就是秒杀活动结束后。
    • 在这个阶段,可能还会有部分用户刷新商品详情页,尝试等待有其他用户退单。而已经成功下单的用户会刷新订单详情,跟踪订单的进展。不过,这个阶段中的用户请求量已经下降很多了,服务器端一般都能支撑,我们就不重点讨论了。

下图显示了在秒杀场景中需要Redis参与的两个环节:

Redis的哪些方法可以支撑秒杀场景?

秒杀场景对Redis操作的根本要求有两个。

  • 支持高并发。这个很简单,Redis本身高速处理请求的特性就可以支持高并发。而且,如果有多个秒杀商品,我们也可以使用切片集群,用不同的实例保存不同商品的库存,这样就避免,使用单个实例导致所有的秒杀请求都集中在一个实例上的问题了。不过,需要注意的是,当使用切片集群时,我们要先用CRC算法计算不同秒杀商品key对应的Slot,然后,我们在分配Slot和实例对应关系时,才能把不同秒杀商品对应的Slot分配到不同实例上保存。
  • 保证库存查验和库存扣减原子性执行。针对这条要求,我们就可以使用Redis的原子操作或是分布式锁这两个功能特性来支撑了。

基于原子操作支撑秒杀场景

在秒杀场景中,一个商品的库存对应了两个信息,分别是总库存量和已秒杀量。这种数据模型正好是一个key(商品ID)对应了两个属性(总库存量和已秒杀量),所以,我们可以使用一个Hash类型的键值对来保存库存的这两个信息,如下所示:

1
2
3
key: itemID
value: {total: N, ordered: M}
其中,itemID是商品的编号,total是总库存量,ordered是已秒杀量。

因为库存查验和库存扣减这两个操作要保证一起执行,一个直接的方法就是使用Redis的原子操作。

原子操作可以是Redis自身提供的原子命令,也可以是Lua脚本。因为库存查验和库存扣减是两个操作,无法用一条命令来完成,所以,我们就需要使用Lua脚本原子性地执行这两个操作。下面是一段伪代码

1
2
3
4
5
6
7
8
9
10
11
12
#获取商品库存信息            
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
#将总库存转换为数值
local total = tonumber(counts[1])
#将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存
if ordered + k <= total then
#更新已秒杀的库存量
redis.call("HINCRBY",KEYS[1],"ordered",k) return k;
end
return 0

有了Lua脚本后,我们就可以在Redis客户端,使用EVAL命令来执行这个脚本了。

最后,客户端会根据脚本的返回值,来确定秒杀是成功还是失败了。如果返回值是k,就是成功了;如果是0,就是失败。

要想保证库存查验和扣减这两个操作的原子性,我们还有另一种方法,就是使用分布式锁来保证多个客户端能互斥执行这两个操作

基于分布式锁来支撑秒杀场景

使用分布式锁来支撑秒杀场景的具体做法是,先让客户端向Redis申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减。 这样一来,大量的秒杀请求就会在争夺分布式锁时被过滤掉。而且,库存查验和扣减也不用使用原子操作了,因为多个并发客户端只有一个客户端能够拿到锁,已经保证了客户端并发访问的互斥性。

你可以看下下面的伪代码,它显示了使用分布式锁来执行库存查验和扣减的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//使用商品ID作为key
key = itemID
//使用客户端唯一标识作为value
val = clientUniqueID
//申请分布式锁,Timeout是超时时间
lock =acquireLock(key, val, Timeout)
//当拿到锁后,才能进行库存查验和扣减
if(lock == True) {
//库存查验和扣减
availStock = DECR(key, k)
//库存已经扣减完了,释放锁,返回秒杀失败
if (availStock < 0) {
releaseLock(key, val)
return error
}
//库存扣减成功,释放锁
else{
releaseLock(key, val)
//订单处理
}
}
//没有拿到锁,直接返回
else
return

需要提醒你的是,在使用分布式锁时,客户端需要先向Redis请求锁,只有请求到了锁,才能进行库存查验等操作,这样一来,客户端在争抢分布式锁时,大部分秒杀请求本身就会因为抢不到锁而被拦截。

所以,我给你一个小建议,我们可以使用切片集群中的不同实例来分别保存分布式锁和商品库存信息。使用这种保存方式后,秒杀请求会首先访问保存分布式锁的实例。如果客户端没有拿到锁,这些客户端就不会查询商品库存,这就可以减轻保存库存信息的实例的压力了。

秒杀系统是一个系统性工程,Redis实现了对库存查验和扣减这个环节的支撑,除此之外,还有4个环节需要我们处理好。

  1. 前端静态页面的设计。秒杀页面上能静态化处理的页面元素,我们都要尽量静态化,这样可以充分利用CDN或浏览器缓存服务秒杀开始前的请求。
  2. 请求拦截和流控。在秒杀系统的接入层,对恶意请求进行拦截,避免对系统的恶意攻击,例如使用黑名单禁止恶意IP进行访问。如果Redis实例的访问压力过大,为了避免实例崩溃,我们也需要在接入层进行限流,控制进入秒杀系统的请求数量。
  3. 库存信息过期时间处理。Redis中保存的库存信息其实是数据库的缓存,为了避免缓存击穿问题,我们不要给库存信息设置过期时间。
  4. 数据库订单异常处理。如果数据库没能成功处理订单,可以增加订单重试功能,保证订单最终能被成功处理。

最后,我也再给你一个小建议:秒杀活动带来的请求流量巨大,我们需要把秒杀商品的库存信息用单独的实例保存,而不要和日常业务系统的数据保存在同一个实例上,这样可以避免干扰业务系统的正常运行。

课后问题

37-数据分布优化:如何应对数据倾斜?

数据倾斜有两类。

  • 数据量倾斜:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。
  • 数据访问倾斜:虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。

数据量倾斜的成因和应对方法

首先,我们来看数据量倾斜的成因和应对方案。

当数据量倾斜发生时,数据在切片集群的多个实例上分布不均衡,大量数据集中到了一个或几个实例上,如下图所示:

数据量倾斜是怎么产生的呢?这主要有三个原因,分别是某个实例上保存了bigkey、Slot分配不均衡以及Hash Tag。接下来,我们就一个一个来分析,同时我还会给你讲解相应的解决方案。

bigkey导致倾斜

第一个原因是,某个实例上正好保存了bigkey。bigkey的value值很大(String类型),或者是bigkey保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。

而且,bigkey的操作一般都会造成实例IO线程阻塞,如果bigkey的访问量比较大,就会影响到这个实例上的其它请求被处理的速度。

其实,bigkey已经是我们课程中反复提到的一个关键点了。为了避免bigkey造成的数据倾斜,一个根本的应对方法是,我们在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中

此外 ,如果bigkey正好是集合类型,我们还有一个方法,就是把bigkey拆分成很多个小的集合类型数据,分散保存在不同的实例上。

Slot分配不均衡导致倾斜

如果集群运维人员没有均衡地分配Slot,就会有大量的数据被分配到同一个Slot中,而同一个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上。

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> cluster slots
1) 1) (integer) 0
2) (integer) 4095
3) 1) "192.168.10.3"
2) (integer) 6379
2) 1) (integer) 12288
2) (integer) 16383
3) 1) "192.168.10.5"
2) (integer) 6379

如果某一个实例上有太多的Slot,我们就可以使用迁移命令把这些Slot迁移到其它实例上。在Redis Cluster中,我们可以使用3个命令完成Slot迁移。

  1. CLUSTER SETSLOT:使用不同的选项进行三种设置,分别是设置Slot要迁入的目标实例,Slot要迁出的源实例,以及Slot所属的实例。
  2. CLUSTER GETKEYSINSLOT:获取某个Slot中一定数量的key。
  3. MIGRATE:把一个key从源实例实际迁移到目标实例

我来借助一个例子,带你了解下这三个命令怎么用。

假设我们要把Slot 300从源实例(ID为3)迁移到目标实例(ID为5),那要怎么做呢?

实际上,我们可以分成5步。

第1步,我们先在目标实例5上执行下面的命令,将Slot 300的源实例设置为实例3,表示要从实例3上迁入Slot 300。

1
CLUSTER SETSLOT 300 IMPORTING 3

第2步,在源实例3上,我们把Slot 300的目标实例设置为5,这表示,Slot 300要迁出到实例5上,如下所示:

1
CLUSTER SETSLOT 300 MIGRATING 5

第3步,从Slot 300中获取100 个key。因为Slot中的key数量可能很多,所以我们需要在客户端上多次执行下面的这条命令,分批次获得并迁移key。

1
CLUSTER GETKEYSINSLOT 300 100

第4步,我们把刚才获取的100个key中的key1迁移到目标实例5上(IP为192.168.10.5),同时把要迁入的数据库设置为0号数据库,把迁移的超时时间设置为timeout。我们重复执行MIGRATE命令,把100个key都迁移完。

1
MIGRATE 192.168.10.5 6379 key1 0 timeout

最后,我们重复执行第3和第4步,直到Slot中的所有key都迁移完成。

从Redis 3.0.6开始,你也可以使用KEYS选项,一次迁移多个key(key1、2、3),这样可以提升迁移效率。

1
MIGRATE 192.168.10.5 6379 "" 0 timeout KEYS key1 key2 key3

对于Codis来说,我们可以执行下面的命令进行数据迁移。其中,我们把dashboard组件的连接地址设置为ADDR,并且把Slot 300迁移到编号为6的codis server group上。

1
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的值。

使用Hash Tag的好处是,如果不同key的Hash Tag内容都是一样的,那么,这些key对应的数据会被映射到同一个Slot中,同时会被分配到同一个实例上。

下面这张表就显示了使用Hash Tag后,数据被映射到相同Slot的情况,你可以看下。

Hash Tag一般用在什么场景呢?其实,它主要是用在Redis Cluster和Codis中,支持事务操作和范围查询。因为Redis Cluster和Codis本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。

这样操作起来非常麻烦,所以,我们可以使用Hash Tag把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。

但是,使用Hash Tag的潜在问题,就是大量的数据可能被集中到一个实例上,导致数据倾斜,集群中的负载不均衡。那么,该怎么应对这种问题呢?我们就需要在范围查询、事务执行的需求和数据倾斜带来的访问压力之间,进行取舍了。

我的建议是,如果使用Hash Tag进行切片的数据会带来较大的访问压力,就优先考虑避免数据倾斜,最好不要使用Hash Tag进行数据切片。因为事务和范围查询都还可以放在客户端来执行,而数据倾斜会导致实例不稳定,造成服务不可用

数据访问倾斜的成因和应对方法

发生数据访问倾斜的根本原因,就是实例上存在热点数据(比如新闻应用中的热点新闻内容、电商促销活动中的热门商品信息,等等)。

和数据量倾斜不同,热点数据通常是一个或几个数据,所以,直接重新分配Slot并不能解决热点数据的问题。

通常来说,热点数据以服务读操作为主,在这种情况下,我们可以采用热点数据多副本的方法来应对。

这个方法的具体做法是,我们把热点数据复制多份,在每一个数据副本的key中增加一个随机前缀,让它和其它副本数据不会被映射到同一个Slot中。这样一来,热点数据既有多个副本可以同时服务请求,同时,这些副本数据的key又不一样,会被映射到不同的Slot中。在给这些Slot分配实例时,我们也要注意把它们分配到不同的实例上,那么,热点数据的访问压力就被分散到不同的实例上了。

这里,有个地方需要注意下,【热点数据多副本方法只能针对只读的热点数据】。如果热点数据是有读有写的话,就不适合采用多副本方法了,因为要保证多副本间的数据一致性,会带来额外的开销。

对于有读有写的热点数据,我们就要给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。

小结

当然,如果已经发生了数据倾斜,我们可以通过数据迁移来缓解数据倾斜的影响。Redis Cluster和Codis集群都提供了查看Slot分配和手工迁移Slot的命令,你可以把它们应用起来。

最后,关于集群的实例资源配置,我再给你一个小建议:在构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的Slot。

课后问题


缓存击穿后:不设置热点 Key 的过期时间,并以采用热点数据多副本的方法减少单实例压力。

38-通信开销:限制RedisCluster规模的关键因素

Redis官方给出了Redis Cluster的规模上限,就是一个集群运行1000个实例。

那么,你可能会问,为什么要限定集群规模呢?其实,这里的一个关键因素就是,实例间的通信开销会随着实例规模增加而增大,在集群超过一定规模时(比如800节点),集群吞吐量反而会下降。所以,集群的实际规模会受到限制。

实例通信方法和对集群规模的影响

Redis Cluster在运行时,每个实例上都会保存Slot和实例的对应关系(也就是Slot映射表),以及自身的状态信息。

为了让集群中的每个实例都知道其它所有实例的状态信息,实例之间会按照一定的规则进行通信。这个规则就是Gossip协议。

Gossip协议的工作原理可以概括成两点。

  • 一是,每个实例之间会按照一定的频率,从集群中随机挑选一些实例,把PING消息发送给挑选出来的实例,用来检测这些实例是否在线,并交换彼此的状态信息。PING消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及Slot映射表。
  • 二是,一个实例在接收到PING消息后,会给发送PING消息的实例,发送一个PONG消息。PONG消息包含的内容和PING消息一样。

下图显示了两个实例间进行PING、PONG消息传递的情况。

Gossip协议可以保证在一段时间后,集群中的每一个实例都能获得其它所有实例的状态信息。

这样一来,即使有新节点加入、节点故障、Slot变更等事件发生,实例间也可以通过PING、PONG消息的传递,完成集群状态在每个实例上的同步。

经过刚刚的分析,我们可以很直观地看到,实例间使用Gossip协议进行通信时,通信开销受到通信消息大小和通信频率这两方面的影响,

消息越大、频率越高,相应的通信开销也就越大。如果想要实现高效的通信,可以从这两方面入手去调优。接下来,我们就来具体分析下这两方面的实际情况。

Gossip消息大小

Redis实例发送的PING消息的消息体是由clusterMsgDataGossip结构体组成的,这个结构体的定义如下所示:

1
2
3
4
5
6
7
8
9
10
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文件中:

1
tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap

通过分析网络包的数量和大小,就可以判断调整cluster-node-timeout值前后,心跳消息占用的带宽情况

小结

这节课,我向你介绍了Redis Cluster实例间以Gossip协议进行通信的机制。Redis Cluster运行时,各实例间需要通过PING、PONG消息进行信息交换,这些心跳消息包含了当前实例和部分其它实例的状态信息,以及Slot分配信息。这种通信机制有助于Redis Cluster中的所有实例都拥有完整的集群状态信息。

但是,随着集群规模的增加,实例间的通信量也会增加。如果我们盲目地对Redis Cluster进行扩容,就可能会遇到集群性能变慢的情况。这是因为,集群中大规模的实例间心跳消息会挤占集群处理正常请求的带宽。而且,有些实例可能因为网络拥塞导致无法及时收到PONG消息,每个实例在运行时会周期性地(每秒10次)检测是否有这种情况发生,一旦发生,就会立即给这些PONG消息超时的实例发送心跳消息。集群规模越大,网络拥塞的概率就越高,相应的,PONG消息超时的发生概率就越高,这就会导致集群中有大量的心跳消息,影响集群服务正常请求。

最后,我也给你一个小建议,虽然我们可以通过调整cluster-node-timeout配置项减少心跳消息的占用带宽情况,但是,在实际应用中,如果不是特别需要大容量集群,我建议你把Redis Cluster 的规模控制在400~500个实例

假设单个实例每秒能支撑8万请求操作(8万QPS),每个主实例配置1个从实例,那么,400~ 500个实例可支持 1600万~2000万QPS(200/250个主实例*8万QPS=1600/2000万QPS),这个吞吐量性能可以满足不少业务应用的需求。

课后问题

39-Redis6.0的新特性:多线程、客户端缓存与安全

想来和你聊聊Redis 6.0中的几个关键新特性

  • 面向网络处理的多IO线程:可以提高网络请求处理的速度,
  • 客户端缓存:可以让应用直接在客户端本地读取数据,这两个特性可以提升Redis的性能。
  • 细粒度权限控制:让Redis可以按照命令粒度控制不同用户的访问权限,加强了Redis的安全保护。
  • RESP 3协议:增强客户端的功能,可以让应用更加方便地使用Redis的不同数据类型。

从单线程处理网络请求到多线程处理

在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线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。下面这张图显示了刚才介绍的这三个阶段,你可以看下,加深理解。

阶段四:IO线程回写Socket和主线程清空全局队列

当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待IO线程把这些结果回写到Socket中,并返回给客户端。

和IO线程读取和解析请求一样,IO线程回写Socket时,也是有多个线程在并发执行,所以回写Socket的速度也很快。等到IO线程回写Socket完毕,主线程会清空全局队列,等待客户端的后续请求。

我也画了一张图,展示了这个阶段主线程和IO线程的操作,你可以看下。
在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功能。

1
CLIENT TRACKING ON|OFF

第二种模式是广播模式。 在这个模式下,服务端会给客户端广播所有key的失效情况,不过,这样做了之后,如果key 被频繁修改,服务端会发送大量的失效广播消息,这就会消耗大量的网络带宽资源。

所以,在实际应用时,我们会让客户端注册希望跟踪的key的前缀,当带有注册前缀的key被修改时,服务端会把失效消息广播给所有注册的客户端。和普通模式不同,在广播模式下,即使客户端还没有读取过key,但只要它注册了要跟踪的key,服务端都会把key失效消息通知给这个客户端。

我给你举个例子,带你看一下客户端如何使用广播模式接收key失效消息。当我们在客户端执行下面的命令后,如果服务端更新了user:id:1003这个key,那么,客户端就会收到invalidate消息。

1
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,如下所示:

1
2
3
4
5
//客户端B执行,客户端B的ID号是303
SUBSCRIBE _redis_:invalidate

//客户端A执行
CLIENT TRACKING ON BCAST REDIRECT 303

这样设置以后,如果有键值对被修改了,客户端B就可以通过_redis_:invalidate频道,获得失效消息了。

从简单的基于密码访问到细粒度的权限控制

在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”:

1
ACL SETUSER normaluser on > abc

另外,6.0版本还支持以用户为粒度设置命令操作的访问权限。我把具体操作列在了下表中,你可以看下,其中,加号(+)和减号(-)就分别表示给用户赋予或撤销命令的调用权限。

为了便于你理解,我给你举个例子。假设我们要设置用户normaluser只能调用Hash类型的命令操作,而不能调用String类型的命令操作,我们可以执行如下命令:

1
ACL SETUSER normaluser +@hash -@string

除了设置某个命令或某类命令的访问控制权限,6.0版本还支持以key为粒度设置访问权限。

具体的做法是使用波浪号“~”和key的前缀来表示控制访问的key。例如,我们执行下面命令,就可以设置用户normaluser只能对以“user:”为前缀的key进行命令操作:

1
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,可以尝试先在非核心业务上使用Redis 6.0,一方面可以验证新特性带来的性能或功能优势,另一方面,也可以避免因为新特性不稳定而导致核心业务受到影响。

40-Redis的下一步:基于NVM内存的实践

这几年呢,新型非易失存储(Non-Volatile Memory,NVM)器件发展得非常快。NVM器件具有容量大、性能快、能持久化保存数据的特性,这些刚好就是Redis追求的目标。同时,NVM器件像DRAM一样,可以让软件以字节粒度进行寻址访问,所以,在实际应用中,NVM可以作为内存来使用,我们称为NVM内存。

你肯定会想到,Redis作为内存键值数据库,如果能和NVM内存结合起来使用,就可以充分享受到这些特性。我认为,Redis发展的下一步,就可以基于NVM内存来实现大容量实例,或者是实现快速持久化数据和恢复。这节课,我就带你了解下这个新趋势。

接下来,我们先来学习下NVM内存的特性,以及软件使用NVM内存的两种模式。在不同的使用模式下,软件能用到的NVM特性是不一样的,所以,掌握这部分知识,可以帮助我们更好地根据业务需求选择适合的模式。

NVM内存的特性与使用模式

Redis是基于DRAM内存的键值数据库,而跟传统的DRAM内存相比,NVM有三个显著的特点。

首先,NVM内存最大的优势是可以直接持久化保存数据。也就是说,数据保存在NVM内存上后,即使发生了宕机或是掉电,数据仍然存在NVM内存上。但如果数据是保存在DRAM上,那么,掉电后数据就会丢失。

其次,NVM内存的访问速度接近DRAM的速度。我实际测试过NVM内存的访问速度,结果显示,它的读延迟大约是200300ns,而写延迟大约是100ns。在读写带宽方面,单根NVM内存条的写带宽大约是12GB/s,而读带宽约是5~6GB/s。当软件系统把数据保存在NVM内存上时,系统仍然可以快速地存取数据。

最后,NVM内存的容量很大。这是因为,NVM器件的密度大,单个NVM的存储单元可以保存更多数据。例如,单根NVM内存条就能达到128GB的容量,最大可以达到512GB,而单根DRAM内存条通常是16GB或32GB。所以,我们可以很轻松地用NVM内存构建TB级别的内存。

现在,业界已经有了实际的NVM内存产品,就是Intel在2019年4月份时推出的Optane AEP内存条(简称AEP内存)。我们在应用AEP内存时,需要注意的是,AEP内存给软件提供了两种使用模式,分别对应着使用了NVM的容量大和持久化保存数据两个特性,我们来学习下这两种模式。

  • 第一种是Memory模式。

    • 这种模式是把NVM内存作为大容量内存来使用的,也就是说,只使用NVM容量大和性能高的特性,没有启用数据持久化的功能。

    • 例如,我们可以在一台服务器上安装6根NVM内存条,每根512GB,这样我们就可以在单台服务器上获得3TB的内存容量了。

    • 在Memory模式下,服务器上仍然需要配置DRAM内存,但是,DRAM内存是被CPU用作AEP内存的缓存,DRAM的空间对应用软件不可见。换句话说,软件系统能使用到的内存空间,就是AEP内存条的空间容量

  • 第二种是App Direct模式。

    • 这种模式启用了NVM持久化数据的功能。在这种模式下,应用软件把数据写到AEP内存上时,数据就直接持久化保存下来了。所以,使用了App Direct模式的AEP内存,也叫做持久化内存(Persistent Memory,PM)。

基于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设备,如下所示:

1
/dev/pmem0

然后,我们需要使用ext4-dax文件系统来格式化这个设备:

1
mkfs.ext4 /dev/pmem0

接着,我们把这个格式化好的设备,挂载到服务器上的一个目录下:

1
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内存了,希望你能够关注这个重要趋势,为未来的发展做好准备。

课后问题

image.png

41-第35~40讲课后思考题答案及常见问题答疑

第35讲

问题:假设Codis集群中保存的80%的键值对都是Hash类型,每个Hash集合的元素数量在10万~20万个,每个集合元素的大小是2KB。你觉得,迁移这样的Hash集合数据,会对Codis的性能造成影响吗?

答案:其实影响不大。虽然一个Hash集合数据的总数据量有200MB ~ 400MB(2KB * 0.1M ≈ 200MB到 2KB * 0.2M ≈ 400MB),但是Codis支持异步、分批迁移数据,所以,Codis可以把集合中的元素分多个批次进行迁移,每批次迁移的数据量不大,所以,不会给源实例造成太大影响。

第36讲

问题:假设一个商品的库存量是800,我们使用一个包含了4个实例的切片集群来服务秒杀请求,我们让每个实例各自维护库存量200,把客户端的秒杀请求分发到不同的实例上进行处理,你觉得这是一个好方法吗?

答案:这个方法是不是能达到一个好的效果,主要取决于,客户端请求能不能均匀地分发到每个实例上。如果可以的话,那么,每个实例都可以帮着分担一部分压力,避免压垮单个实例。

在保存商品库存时,key一般就是商品的ID,所以,客户端在秒杀场景中查询同一个商品的库存时,会向集群请求相同的key,集群就需要把客户端对同一个key的请求均匀地分发到多个实例上。

为了解决这个问题,客户端和实例间就需要有代理层来完成请求的转发。例如,在Codis中,codis proxy负责转发请求,那么,如果我们让codis proxy收到请求后,按轮询的方式把请求分发到不同实例上(可以对Codis进行修改,增加转发规则),就可以利用多实例来分担请求压力了。

如果没有代理层的话,客户端会根据key和Slot的映射关系,以及Slot和实例的分配关系,直接把请求发给保存key的唯一实例了。在这种情况下,请求压力就无法由多个实例进行分担了。题目中描述的这个方法也就不能达到好的效果了。

第37讲

问题:当有数据访问倾斜时,如果热点数据突然过期了,假设Redis中的数据是缓存,数据的最终值是保存在后端数据库中的,这样会发生什么问题吗?

答案:在这种情况下,会发生缓存击穿的问题,也就是热点数据突然失效,导致大量访问请求被发送到数据库,给数据库带来巨大压力。

我们可以采用第26讲中介绍的方法,不给热点数据设置过期时间,这样可以避免过期带来的击穿问题。

除此之外,我们最好在数据库的接入层增加流控机制,一旦监测到有大流量请求访问数据库,立刻开启限流,这样做也是为了避免数据库被大流量压力压垮。因为数据库一旦宕机,就会对整个业务应用带来严重影响。所以,我们宁可在请求接入数据库时,就直接拒接请求访问

第38讲

问题:如果我们采用跟Codis保存Slot分配信息相类似的方法,把集群实例状态信息和Slot分配信息保存在第三方的存储系统上(例如Zookeeper),这种方法会对集群规模产生什么影响吗?

答案:假设我们将Zookeeper作为第三方存储系统,保存集群实例状态信息和Slot分配信息,那么,实例只需要和Zookeeper通信交互信息,实例之间就不需要发送大量的心跳消息来同步集群状态了。这种做法可以减少实例之间用于心跳的网络通信量,有助于实现大规模集群。而且,网络带宽可以集中用在服务客户端请求上。

不过,在这种情况下,实例获取或更新集群状态信息时,都需要和Zookeeper交互,Zookeeper的网络通信带宽需求会增加。所以,采用这种方法的时候,需要给Zookeeper保证一定的网络带宽,避免Zookeeper受限于带宽而无法和实例快速通信。

第39讲

问题:你觉得,Redis 6.0的哪个或哪些新特性会对你有帮助呢?

答案:这个要根据你们的具体需求来定。从提升性能的角度上来说,Redis 6.0中的多IO线程特性可以缓解Redis的网络请求处理压力。通过多线程增加处理网络请求的能力,可以进一步提升实例的整体性能。业界已经有人评测过,跟6.0之前的单线程Redis相比,6.0的多线程性能的确有提升。所以,这个特性对业务应用会有比较大的帮助。

另外,基于用户的命令粒度ACL控制机制也非常有用。当Redis以云化的方式对外提供服务时,就会面临多租户(比如多用户或多个微服务)的应用场景。有了ACL新特性,我们就可以安全地支持多租户共享访问Redis服务了。

第40讲

问题:你觉得,有了持久化内存后,还需要Redis主从集群吗?

答案:持久化内存虽然可以快速恢复数据,但是,除了提供主从故障切换以外,主从集群还可以实现读写分离。所以,我们可以通过增加从实例,让多个从实例共同分担大量的读请求,这样可以提升Redis的读性能。而提升读性能并不是持久化内存能提供的,所以,如果业务层对读性能有高要求时,我们还是需要主从集群的。

关于原子操作的使用疑问

在第29讲中,我在介绍原子操作时,提到了一个多线程限流的例子,借助它来解释如何使用原子操作。我们再来回顾下这个例子的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次,则报错
IF current != NULL AND current > 20 THEN
ERROR "exceed 20 accesses per second"
ELSE
//如果访问次数不足20次,增加一次访问计数
value = INCR(ip)
//如果是第一次访问,将键值对的过期时间设置为60s后
IF value == 1 THEN
EXPIRE(ip,60)
END
//执行其他操作
DO THINGS
END

在分析这个例子的时候,我提到:“第一个线程执行了INCR(ip)操作后,第二个线程紧接着也执行了INCR(ip),此时,ip对应的访问次数就被增加到了2,我们就不能再对这个ip设置过期时间了。”

有同学认为,value是线程中的局部变量,所以两个线程在执行时,每个线程会各自判断value是否等于1。判断完value值后,就可以设置ip的过期时间了。因为Redis本身执行INCR可以保证原子性,所以,客户端线程使用局部变量获取ip次数并进行判断时,是可以实现原子性保证的。

我再进一步解释下这个例子中使用Lua脚本保证原子性的原因。

在这个例子中,value其实是一个在多线程之间共享的全局变量,所以,多线程在访问这个变量时,就可能会出现一种情况:一个线程执行了INCR(ip)后,第二个线程也执行了INCR(ip),等到第一个线程再继续执行时,就会发生ip对应的访问次数变成2的情况。而设置过期时间的条件是ip访问次数等于1,这就无法设置过期时间了。在这种情况下,我们就需要用Lua脚本保证计数增加和计数判断操作的原子性。

Redis和Memcached、RocksDB的对比

Redis和Memcached的比较

和Redis相似,Memcached也经常被当做缓存来使用。不过,Memcached有一个明显的优势,就是它的集群规模可以很大。Memcached集群并不是像Redis Cluster或Codis那样,使用Slot映射来分配数据和实例的对应保存关系,而是使用一致性哈希算法把数据分散保存到多个实例上,而一致性哈希的优势就是可以支持大规模的集群。所以,如果我们需要部署大规模缓存集群,Memcached会是一个不错的选择。

Memcached支持的数据类型比Redis少很多。Memcached只支持String类型的键值对,而Redis可以支持包括String在内的多种数据类型,当业务应用有丰富的数据类型要保存的话,使用Memcached作为替换方案的优势就没有了。

如果你既需要保存多种数据类型,又希望有一定的集群规模保存大量数据,那么,Redis仍然是一个不错的方案。
image.png

Redis和RocksDB的比较

和Redis不同,RocksDB可以把数据直接保存到硬盘上。这样一来,单个RocksDB可以保存的数据量要比Redis多很多,而且数据都能持久化保存下来。

除此之外,RocksDB还能支持表结构(即列族结构),而Redis的基本数据模型就是键值对。所以,如果你需要一个大容量的持久化键值数据库,并且能按照一定表结构保存数据,RocksDB是一个不错的替代方案。

不过,RocksDB毕竟是要把数据写入底层硬盘进行保存的,而且在进行数据查询时,如果RocksDB要读取的数据没有在内存中缓存,那么,RocksDB就需要到硬盘上的文件中进行查找,这会拖慢RocksDB的读写延迟,降低带宽。

在性能方面,RocksDB是比不上Redis的。而且,RocksDB只是一个动态链接库,并没有像Redis那样提供了客户端-服务器端访问模式,以及主从集群和切片集群的功能。所以,我们在使用RocksDB替代Redis时,需要结合业务需求重点考虑替换的可行性。
image.png

总结

集群是实际业务应用中很重要的一个需求,在课程的最后,我还想再给你提一个小建议。

集群部署和运维涉及的工作量非常大,所以,我们一定要重视集群方案的选择。

集群的可扩展性是我们评估集群方案的一个重要维度,你一定要关注,集群中元数据是用Slot映射表,还是一致性哈希维护的。如果是Slot映射表,那么,是用中心化的第三方存储系统来保存,还是由各个实例来扩散保存,这也是需要考虑清楚的。Redis Cluster、Codis和Memcached采用的方式各不相同。

  • Redis Cluster:使用Slot映射表并由实例扩散保存。
  • Codis:使用Slot映射表并由第三方存储系统保存。
  • Memcached:使用一致性哈希。

从可扩展性来看,Memcached优于Codis,Codis优于Redis Cluster。所以,如果实际业务需要大规模集群,建议你优先选择Codis或者是基于一致性哈希的Redis切片集群方案。