极客时间《Redis核心技术与实战》阅读笔记


数据结构

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。

哈希桶中的元素保存的并不是值本身,而是指向具体值的指针

因为用了哈希表,所以我们必须考虑hash冲突和 key越来越多的hash扩容过程(rehash)。

  • hash冲突的解决:拉链法
  • rehash:双hash表+渐进式hash:在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。本质上是通过将hash的过程拆分到每次entity的读取写入来避免阻塞。 如下图所示:


集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。

这部分后面可以考虑在小林里面再补充补充。

详细参考:Redis数据结构总结[1]


单线程模型

Redis 是单线程,是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

由于”主流程是单线程“,为了避免阻塞,Redis采用的是事件驱动回调 + Io多路复用(Epoll)的架构进行处理。

具体来说,事件回调指的是:select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。 这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。

Redis持久化:AOF日志、RDB

Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。

AOF日志

AOF是先实际执行操作,然后再记录日志,这点与WAL相反(WAL是先写日志,再执行操作)。好处是对于不需要单独的机制检查语句是否可执行。 且AOF是记录操作语句,而Mysql的WAL(Mysql的Redolog)记录的是更新后的值。

这点应该也和Redis没有Mysql那样复杂的:预处理器、优化器 各种架构有关。


AOF刷盘时机:AOF 配置项 appendfsync 的三个可选值:

  • Always​,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;

  • Everysec​,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;

  • No​,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。


AOF记录原始操作语句,随着语句增多,日志文件会越来越大。

为了控制体积,使用AOF重写机制:AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。

在AOF重写过程中,我们必须要考虑两个方面的问题:

  • 重写过程的阻塞问题:和 AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。
  • 重写过程中新的命令如何执行:“一个fork,两处日志”。fork指的是复制进程,两处日志是指每次写入会同时记录AOF日志和AOF重写日志,见下图:

AOF重写不复用AOF本身的日志

一个原因是父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。

二是如果AOF重写过程中失败了,那么原本的AOF文件相当于被污染了,无法做恢复使用。所以Redis AOF重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的AOF文件产生影响。等重写完成之后,直接替换旧文件即可。

需要注意:

fork进程是比较耗时的,虽然操作系统提供了写时复制的能力,但是同样会产生阻塞。同样,在重写AOF过程中,如果内存修改了,那么操作系统需要拷贝内存(写时复制),那么会阻塞,尤其是在操作大key、操作系统开启了内存大页机制(Huge Page,页面大小2M) 的情况下更加需要注意!

fork进程是会消耗内存的,在一个极端场景下:bgrewriteaof的进程内存占用等于原进程,所以Redis的最大内存设置也是需要考虑的,可能会导致服务器内存占用太大导致频繁swap(性能低,武功被废除),甚至导致OOM。

RDB

RDB执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

  • save​:在主线程中执行,会导致阻塞;

  • bgsave​:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

在bgsave执行过程中,类似于AOF重写,我们必须要考虑:阻塞、新命令 执行的问题。

  • 阻塞:后台进程执行,理论上不会阻塞,但是同AOF,需要注意fork和写时复制带来的阻塞。

  • 新命令执行:新命令执行并不会记录到RDB中。

bgsave​执行过程中,新的命令并不会记录到RDB中。所以bgsave​的执行时间间隔值得考量,针对这个问题,Redis4.0提出了混合持久化

混合持久化

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。

AOF 优点是丢失数据少,但是数据恢复不快。

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

混合持久化优点:

  • 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点:

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
  • 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

​​

高可用:集群

主从数据同步机制

总结在主从同步流程[2]

哨兵机制

详细亦可见哨兵机制的高可用设计 | 哨兵集群[3]

哨兵是一个运行在特殊状态(哨兵状态)的Redis。

哨兵的三大职能:监控、选主、通知。

监控

在判断主从库下线的问题上,哨兵是通过不间断地PING所有节点来检测的。既然是通过网络,那么我们就必须注意网络本身就是不可靠的

为了处理网络不可靠的问题:

  • 哨兵本身也是集群的形式
  • 引入”主观下线“、”客观下线“的概念:对于从节点,只要有一个哨兵判断其下线,其就”客观下线“;对于主节点,哨兵判断其下线只能将其标记为”主观下线“,需要半数以上的哨兵判断其”主观下线“后才”客观下线“。

对于主节点“客观下线”的数量,半数以上是一个比较推荐的值,实际上可以由管理员自行指定。

客观下线的流程:

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为“客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

哨兵集群

因为哨兵本身也是集群,可以看成其负责调控整个Redis集群的运作。集群就涉及了分布式相关的问题,因此需要先了解哨兵集群的运作机制。

  • 哨兵的集群的节点之间相互有连接,哨兵集群的节点与Redis集群的节点之间相互有连接:

  • 在标记节点“客观下线”之后, 哨兵集群中执行内部“投标仲裁”后才会开始选主,“投标仲裁”会选出一个主持这次选主的 leader 哨兵主持后续的选主过程。[4]

哨兵集群节点之间通过Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制来建立连接。具体来说他们都订阅了主库的一个频道。

哨兵集群与从库通过 与主库的INFO命令 建立连接。

选主

选主机制[5]

通知

通知指的是哨兵通知客户端新主的过程,具体见:通知机制[6]

分片集群

分片集群是为了解决单机Redis容量限制的问题。

只要是分片集群,我们就必须要考虑请求路由和数据迁移的问题。

  • 请求路由:客户端自行路由 + Redis集群的主要思路是slot槽。
  • 数据迁移:在迁移过程中,使用moved(永久)、asking(单个命令,临时)迁移来引导客户端迁移

业界也出现了一些第三方的分片集群管理方案,一般是做一层中心化的proxy来让客户端对集群无感知,比如:Codis​、Twemproxy​。

如何应对Redis的数据增长

横向增长和纵向增长。

Redis官方集群的管理方案|redis cluster

具体来说,Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。 具体的映射过程分为两大步:首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

我们在部署 Redis Cluster 方案时,可以使用 cluster create​ 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。 当然, 我们也可以使用 cluster meet​ 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。

哈希槽数和存放在这个实例上的数据百分比理论上是正比。

在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。

如何请求路由

Redis cluster由客户端维护一个路由表来请求录友。

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?这是因为,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散,这个过程也被称为hash槽扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。 当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

数据迁移|slot槽迁移

当数据发生迁移(新增节点、删除节点)时,slot槽的映射关系也会发生变化。在这个过程中Redis采用重定向的思想引导客户端定位向新的节点:

MOVED:永久重定向。

ASKING:单次重定向。

MOVED

当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。

GET hello:key
(error) MOVED 13320 172.16.19.5:6379

MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。

ASKING

如果slot正在迁移过程中,那么就不能直接MOVED(永久重定向了),就需要使用ASKING来表明单次的重定向:

在实际应用时,如果 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 命令,以读取数据。

稍微复杂,理一下也不复杂。

ASK 命令表示两层含义:第一,表明 Slot 数据还在迁移中;第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令。

为什么客户端需要向实例3先发送ASKING命令,GPT的解释:

数据一致性和透明性。

需要注意的是:和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改本地缓存,让后续所有命令都发往新实例。

REDIS集群管理方案补充:Codis 与REdis cluster对比

相比于REDIS CLUSTER,CODIS是第三方开源的中心化REDIS集群管理方案(REDIS CLUSTER是无中心化的管理方案)。

架构:

CODIS的架构如下图:主要由codis fe、codis dashboard、codis proxy、zookeeper组成。

  • 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 proxy处理,而数据迁移等由codis dashboard来控制,因此对于客户端来说,操作codis集群就和单机的REDIS没有区别。

对于路由表的保存:codis:保存在codis proxy和zk中;REDIS CLUSTER:保存在客户端中,详见如何请求路由一节。

数据分布和集群扩容(数据迁移):

数据分布:Codis的数据分布同样拥有slot的概念,Codis 集群一共有 1024 个 Slot,编号依次是 0 到 1023,同样也可以支持自动分配和手动分配;在客户端读写的时候,会进行\(CRC32(KEY) \% 1024\)的运算来计算出对应的key位于哪一个节点,如图所示:

对比REDIS CLUSTER,由于REDIS CLUSTER的路由表保存在客户端中,因此由客户端来计算出key位于的节点,详见:如何请求路由

集群扩容(数据迁移):相比于REDIS CLUSTER,CODIS的集群有两方面的扩容:proxy和CODIS SERVER(REDIS节点):

  • proxy​:proxy本身是无状态的,其只缓存路由表,因此扩容的话就将其加入proxy集群即可。
  • CODIS SERVER​:集群扩容就会涉及数据迁移。而CODIS的数据迁移对客户端来说也是无感的,和redis CLUSTER一样,其也是按照slot为维度进行数据迁移,大概步骤如下:。在每个key的迁移过程中,还分为异步和同步两种模式,大概特点:
- codis集群迁移
- 同步迁移(阻塞迁移)
- 在迁移key的时候,源server是阻塞的
- 异步迁移(非阻塞迁移)
- 非阻塞:在迁移key的时候,源server是非阻塞的
- 只读:迁移key过程中,源server中key只读,不可修改
- 拆分bigkey迁移:对于 bigkey,异步迁移对 bigkey 中每个元素,都用一条指令进行迁移

此外,对于异步迁移,在迁移过程中还会对目标server设置过期时间:当 bigkey 迁移了一部分数据后,如果 Codis 发生故障,就会导致 bigkey 的一部分元素在源 server,而另一部分元素在目的 server,这就破坏了迁移的原子性。 所以,Codis 会在目标 server 上,给 bigkey 的元素设置一个临时过期时间。如果迁移过程中发生故障,那么,目标 server 上的 key 会在过期后被删除,不会影响迁移的原子性。当正常完成迁移后,bigkey 元素的临时过期时间会被删除。

对于REDIS CLUSTER,在CLUSTER中,数据迁移是由REDIS节点的MOVED​和ASKING​相关指令来控制,对客户端来说是有感知的,详见:数据迁移|slot槽迁移

对比总结

数据分布、集群扩容和数据迁移、客户端兼容性、可靠性保证。

但是需要注意的是:在Codis很早就没有维护了,因此对于新版本的命令(BITOP、MULTI等)并不支持,在https://github.com/CodisLabs/codis/blob/release3.2/doc/unsupported_cmds.md 可以看到详细列表。

其它补充

Redis Cluster不采用把key直接映射到实例的方式,而采用哈希槽的方式原因:

1、整个集群存储key的数量是无法预估的,key的数量非常多时,直接记录每个key对应的实例映射关系,这个映射表会非常庞大,这个映射表无论是存储在服务端还是客户端都占用了非常大的内存空间。

2、Redis Cluster采用无中心化的模式(无proxy,客户端与服务端直连),客户端在某个节点访问一个key,如果这个key不在这个节点上,这个节点需要有纠正客户端路由到正确节点的能力(MOVED响应),这就需要节点之间互相交换路由表,每个节点拥有整个集群完整的路由关系。如果存储的都是key与实例的对应关系,节点之间交换信息也会变得非常庞大,消耗过多的网络资源,而且就算交换完成,相当于每个节点都需要额外存储其他节点的路由表,内存占用过大造成资源浪费。

3、当集群在扩容、缩容、数据均衡时,节点之间会发生数据迁移,迁移时需要修改每个key的映射关系,维护成本高。

4、而在中间增加一层哈希槽,可以把数据和节点解耦,key通过Hash计算,只需要关心映射到了哪个哈希槽,然后再通过哈希槽和节点的映射表找到节点,相当于消耗了很少的CPU资源,不但让数据分布更均匀,还可以让这个映射表变得很小,利于客户端和服务端保存,节点之间交换信息时也变得轻量。

5、当集群在扩容、缩容、数据均衡时,节点之间的操作例如数据迁移,都以哈希槽为基本单位进行操作,简化了节点扩容、缩容的难度,便于集群的维护和管理。

另外,我想补充一下Redis集群相关的知识,以及我的理解: Redis使用集群方案就是为了解决单个节点数据量大、写入量大产生的性能瓶颈的问题。多个节点组成一个集群,可以提高集群的性能和可靠性,但随之而来的就是集群的管理问题,最核心问题有2个:请求路由、数据迁移(扩容/缩容/数据平衡)。

1、请求路由:一般都是采用哈希槽的映射关系表找到指定节点,然后在这个节点上操作的方案。 Redis Cluster在每个节点记录完整的映射关系(便于纠正客户端的错误路由请求),同时也发给客户端让客户端缓存一份,便于客户端直接找到指定节点,客户端与服务端配合完成数据的路由,这需要业务在使用Redis Cluster时,必须升级为集群版的SDK才支持客户端和服务端的协议交互。 其他Redis集群化方案例如Twemproxy、Codis都是中心化模式(增加Proxy层),客户端通过Proxy对整个集群进行操作,Proxy后面可以挂N多个Redis实例,Proxy层维护了路由的转发逻辑。操作Proxy就像是操作一个普通Redis一样,客户端也不需要更换SDK,而Redis Cluster是把这些路由逻辑做在了SDK中。当然,增加一层Proxy也会带来一定的性能损耗。

2、数据迁移:当集群节点不足以支撑业务需求时,就需要扩容节点,扩容就意味着节点之间的数据需要做迁移,而迁移过程中是否会影响到业务,这也是判定一个集群方案是否成熟的标准。 Twemproxy不支持在线扩容,它只解决了请求路由的问题,扩容时需要停机做数据重新分配。而Redis Cluster和Codis都做到了在线扩容(不影响业务或对业务的影响非常小),重点就是在数据迁移过程中,客户端对于正在迁移的key进行操作时,集群如何处理?还要保证响应正确的结果? Redis Cluster和Codis都需要服务端和客户端/Proxy层互相配合,迁移过程中,服务端针对正在迁移的key,需要让客户端或Proxy去新节点访问(重定向),这个过程就是为了保证业务在访问这些key时依旧不受影响,而且可以得到正确的结果。由于重定向的存在,所以这个期间的访问延迟会变大。等迁移完成之后,Redis Cluster每个节点会更新路由映射表,同时也会让客户端感知到,更新客户端缓存。Codis会在Proxy层更新路由表,客户端在整个过程中无感知。 除了访问正确的节点之外,数据迁移过程中还需要解决异常情况(迁移超时、迁移失败)、性能问题(如何让数据迁移更快、bigkey如何处理),这个过程中的细节也很多。 Redis Cluster的数据迁移是同步的,迁移一个key会同时阻塞源节点和目标节点,迁移过程中会有性能问题。而Codis提供了异步迁移数据的方案,迁移速度更快,对性能影响最小,当然,实现方案也比较复杂。

补充

数据结构:整数数组和压缩列表作为底层数据结构的优势是什么?

一言以蔽之:节省内存。

在Set、Zset和List中,如果元素个数比较少,且每个元素的大小小于一定值的情况下,就会使用压缩列表和整数数组。

原因主要在于:

  1. 节省内存:相比于hash表、跳表,不用再通过额外的指针把元素串接起来,这就避免了额外指针带来的空间开销 。(在大量小对象的时候差距还是很大的)
  2. 内存连续:内存连续隐形会带来一定优势,比如说cpu缓存,遍历时快等等。

既然用了压缩列表、整数数组,为什么不一直用,还要迁移?

因为在元素数量比较多、元素个数大的时候,其性能衰减。

使用的优势比不上其带来的劣势。

Redis的重hash|rehash

参考:rehash[7]

其它补充:

采用渐进式 hash 时,如果实例暂时没有收到新请求,是不是就不做 rehash 了?

Redis 会执行定时任务,定时任务中就包含了 rehash​ 操作。所谓的定时任务,就是按照一定频率(例如每 100ms/ 次)执行的任务。 在 rehash 被触发后,即使没有收到新请求,Redis 也会定时执行一次 rehash 操作,

而且,每次执行时长不会超过 1ms,以免对其他任务造成影响。

AOF 重写过程中有没有其他潜在的阻塞风险?

  1. fork带来的风险
  2. 写时复制仍然有风险,而且会因为bigkey和操作系统大页的设置而加剧
  3. 内存占用的极限情况下翻倍带来的风险。

主从复制

主从复制为什么用RDB而不用AOF

主要是效率。

答案:有两个原因。

RDB 文件是二进制文件,无论是要把 RDB 写入磁盘,还是要通过网络传输 RDB,IO 效率都比记录和传输 AOF 的高。

在从库端进行恢复时,用 RDB 的恢复效率要高于用 AOF。

replication buffer 和 repl_backlog_buffer 的区别

  1. replication buffer用于第一次主从同步;repl_backlog_buffer用于后续增量同步。
  2. 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)发给主库,主库就可以和它独立同步。

主从切换时,客户端能否正常请求

主从集群一般是采用读写分离模式,当主库故障后,客户端仍然可以把读请求发送给从库,让从库服务。但是,对于写请求操作,客户端就无法执行了。

如果想要应用程序不感知服务的中断,还需要哨兵或客户端再做些什么吗?

客户端缓存写请求:一方面,客户端需要能缓存应用发送的写请求。只要不是同步写操作(Redis 应用场景一般也没有同步写),写请求通常不会在应用程序的关键路径上,所以,客户端缓存写请求后,给应用程序返回一个确认就行。

客户端切换主库:另一方面,主从切换完成后,客户端要能和新主库重新建立连接,哨兵需要提供订阅频道,让客户端能够订阅到新主库的信息。同时,客户端也需要能主动和哨兵通信,询问新主库的信息。

哨兵集群

哨兵集群故障对主从切换的影响:5 个哨兵实例的集群,quorum 值设为 2。在运行过程中,如果有 3 个哨兵实例都发生故障了,此时,Redis 主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?

2个哨兵存活≥quorum,因此可以“客观下线”。

但是因为无法获取半数以上的投票,因此无法投标仲裁成功,无法选出 执行主从切换的哨兵leader,自然无法判断 主从自动切换。

投标仲裁条件参考:投标仲裁[8]半数以上的票和quorum是的关系。

哨兵实例是不是越多越好呢?如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处?

哨兵实例越多,误判率会越低,但是在判定主库下线和选举 Leader 时,实例需要拿到的赞成票数也越多,等待所有哨兵投完票的时间可能也会相应增加,从而导致主从库切换的时间也会变长,客户端容易堆积较多的请求操作,可能会导致客户端请求溢出,从而造成请求丢失。

down-after-milliseconds​是控制哨兵对主节点主观下线的时间阈值。

  • 调大 down-after-milliseconds​ 后,可能会导致这样的情况:主库实际已经发生故障了,但是哨兵过了很长时间才判断出来,这就会影响到 Redis 对业务的可用性。

  • 调小down-after-milliseconds​后,可能会导致网络稍有波动,主节点就会下线,导致频繁主从切换。

主从切换的时候只能读,无法写入。

集群

为什么 Redis 不直接用一个表,把键值对和实例的对应关系记录下来?而要使用slot+hash的方式

一言以蔽之:

  • 节省内存,直接记录key的内存占用很大,直接记录slot与集群节点的对应关系内存占用就很小了。
  • 数据迁移:延续上一条,如果出现集群变更,那么记录slot变更节点带来的成本显然比记录key变更节点小得多。

如果使用表记录键值对和实例的对应关系,一旦键值对和实例的对应关系发生了变化(例如实例有增减或者数据重新分布),就要修改表。如果是单线程操作表,那么所有操作都要串行执行,性能慢;如果是多线程操作表,就涉及到加锁开销。此外,如果数据量非常大,使用表记录键值对和实例的对应关系,需要的额外存储空间也会增加。 基于哈希槽计算时,虽然也要记录哈希槽和实例的对应关系,但是哈希槽的个数要比键值对的个数少很多,无论是修改哈希槽和实例的对应关系,还是使用额外空间存储哈希槽和实例的对应关系,都比直接记录键值对和实例的关系的开销小得多。

REDIS数据分布:数据倾斜优化思路

对于其中 热点数据采用不同key前缀的多副本方法的补充:

方法具体是指对于热key,可以在key上加上前缀形成多副本,访问时随机访问一个副本来打散QPS的方式减少热key的影响。

这样的方式带来的弊端就是更新的时候也要同时进行多副本的更新。

当然,对于数据量比较小的热key,实际上业界更常见的使用方式应该是本地缓存。

对于其中Hash Tag的补充

Hash Tag 是指加在键值对 key 中的一对花括号{}​。这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的 key 内容进行计算。如果没用 Hash Tag 的话,客户端计算整个 key 的 CRC16 的值。

Hash Tag 一般用在什么场景呢?其实,它主要是用在 Redis Cluster 和 Codis 中,支持事务操作和范围查询。因为 Redis Cluster 和 Codis 本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。 这样操作起来非常麻烦,所以,我们可以使用 Hash Tag 把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。

Redis 的很多性能问题,例如导致 Redis 阻塞的场景:bigkey、集中过期、大实例 RDB 等等,这些场景都与数据倾斜类似,都是因为数据集中、处理逻辑集中导致的耗时变长

其解决思路也类似,都是把集中变分散,例如 bigkey 拆分为小 key、单个大实例拆分为切片集群等。

从软件架构演进过程来看,从单机到分布式,再到后来出现的消息队列、负载均衡等技术,也都是为了将请求压力分散开,避免数据集中、请求集中的问题,这样既可以让系统承载更大的请求量,同时还保证了系统的稳定性。

String细节追踪之其是否节省内存

在SDS[9]和SDS 结构设计[10]中,我们了解到Redis为了减少内存占用设计了一下机制(flag,关闭内存对齐等),看起来对于小key来说String是节省内存的。

但是实际上String的key-value占用是overload(额外的内存占用)是很高的。

除了SDS本身之外,主要来自于两点:

  1. 无论什么数据结构,在Redis中都是一个Redis Object,Object需要一些元数据,带来了内存开销:

  2. Redis 保存对象是用的全局hash中的dictEntry结构体,dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节。其中有3个指针的开销:

    而且,这三个指针只有 24 字节,为什么会占用了 32 字节呢?这就要提到 Redis 使用的内存分配库 jemalloc 了。 jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。

因此如果是大量的及小的对象的存储,在极致考虑内存占用的情况下,我们可以用 底层实现为压缩列表的zset来避免上述的overload。

当然,zset的底层中压缩列表(或者新版的listpack)的内存占用是好于 hash实现的内存占用的,如果想节约,需要控制其底层为压缩列表。可以下面两个参数来控制:

hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。

hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。

当然,压缩列表实现查询和插入本质上是遍历,性能上有损失。此外,需要担心级联扩容的问题。同时,过期时间管理也会有一些问题。

Redis应用场景

keys统计相关

Redis一般可以完成四种统计:

  • 聚合统计:使用Set,方便计算差集、交集、并集等,但大量数据处理耗时高。

  • 排序统计:使用Sorted Set。不适用list的原因是list非队头插入取出耗时为O(n)。

  • 二值状态统计:使用Bitmap,常用于签到等只有是非状态的场景。

  • 基数统计:HyperLogLog,大数据量时节省内存(每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数),但是有统计偏差(标准误算率是 0.81%)。

HyperLogLog原理可见: HyperLogLog

上面提到了Bitmap和HyperLogLog,实际上这两者都是Redis提供的拓展的数据结构。

实际上,在Redis中提供了五种基础的数据结构:String、List、Set、Zset、Hash,剩下还提供了三种拓展的数据结构:Bitmap​、HyperLogLog​和GEO​。

GEO数据结构

GEO是Redis提供的一种拓展数据结构,与经纬度相关。常用于计算一定经纬度范围内的东西,比如说地标性建筑与xxx的距离等等。

常用的方法分别是 GEOADD 和 GEORADIUS。

GEOADD 命令:用于把一组经纬度信息和相对应的一个 ID 记录到 GEO 类型集合中;

GEORADIUS 命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。当然,我们可以自己定义这个范围。

GEO实现原理

实际上GEO底层使用的就是ZSET数据结构,对于范围内的查询GEORADIUS,实际上就是利用的ZSET的查询能力。

这其中最关键的就是 怎么把经纬度信息编码成ZSET中支持的浮点数。

这里Redis 采用了业界广泛使用的 GeoHash 编码方法,这个方法的基本原理就是“先二分区间,再区间编码”。 在具体了解之前,需要明白这样编码之后的结果是:经纬度越近,编码之后的浮点数差距越小。

二分区间

二分区间指的是就是将经纬度分别编码成一个数字的行为,这个过程就是将区间一直二分,因此称为二分区间。

如果我需要将经度116.37编码成五位的数字,那么大概是如下的一个过程:

假设我们要编码的经度值是 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。

对于纬度也是类似的操作,不过注意纬度是[-90,90],要对应替换即可。

区间编码

从高到低,从经度到纬度挨个组装经纬度编码后的值即可。

官话版本:最终编码值的偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值,其中,偶数位从 0 开始,奇数位从 1 开始。

例子过程如下,最终的编码结果为1110011101。

通过上述的:“先二分区间,再区间编码”的过程,最终实现了 二维的经纬度编码成一维的浮点数。且经纬度越近,编码之后的浮点数差距越小的目的。

需要注意的是这种GEO编码方式不是完全符合经纬度越近,编码之后浮点数差距越小的,是大多数符合,比如下方的0111​和1000​差距只有1,但是经纬度差距却比较大。

13讲中还讲述了如何在Redis中自己设计自己的数据存储结构,这里只是浅浅的看了一下,未深入学习。

Redis保存时间序列

个人觉得这章含金量不高。时间序列的特点是大量写入和常见聚合计算。

Redis基于内存,不适合大量写入。Redis是单线程,因此也不太适合聚合计算(CPU密集任务)。

有价值的列在这里:

  1. Redis 中保证原子性操作:这里就涉及到了 Redis 用来实现简单的事务的 MULTI​ 和 EXEC​ 命令。当多个命令及其参数本身无误时,MULTI​ 和 EXEC​ 命令可以保证执行这些命令时的原子性(出错的情况在后面会继续讨论)。

    • MULTI​ 命令:表示一系列原子性操作的开始。收到这个命令后,Redis 就知道,接下来再收到的命令需要放到一个内部队列中,后续一起执行,保证原子性。

    • EXEC​ 命令:表示一系列原子性操作的结束。一旦 Redis 收到了这个命令,就表示所有要保证原子性的命令操作都已经发送完成了。此时,Redis 开始执行刚才放到内部队列中的所有命令操作。

      下面这张示意图,命令 1 到命令 N 是在 MULTI 命令后、EXEC 命令前发送的,它们会被一起执行,保证原子性。

  2. 在使用MULTI和EXEC命令时,建议客户端使用pipeline,当使用pipeline时,客户端会把命令一次性批量发送给服务端,然后让服务端执行,这样可以减少客户端和服务端的来回网络IO次数,提升访问性能。

本章具体见《14 | 如何在Redis中保存时间序列数据?》

对于时间序列,可以考虑使用Zset,但是

Zset类型有个短板:它并不支持对数据进行范围查询。

RedisTimeSeries

RedisTimeSeries 是 Redis 的一个扩展模块。它专门面向时间序列数据提供了数据类型和访问接口,并且支持在 Redis 实例上直接对数据进行按时间范围的聚合计算。

loadmodule redistimeseries.so

当用于时间序列数据存取时,RedisTimeSeries 的操作主要有 5 个:

用 TS.CREATE 命令创建时间序列数据集合;

用 TS.ADD 命令插入数据;

用 TS.GET 命令读取最新数据;

用 TS.MGET 命令按标签过滤查询数据集合;

用 TS.RANGE 支持聚合计算的范围查询。

Redis消息队列

本部分基于《15 | 消息队列的考验:Redis有哪些解决方案?》

一般使用Redis做消息队列可以考虑两种数据结构:

  • Streams比List还多了区分消费者组Group的能力
  • Redis实现消息队列不能保证消息不丢失(因为AOF落盘一般来说会开启1s一次刷盘的策略,不会开启每次都落盘;主从切换的时候也可能会导致消息丢失
  • Redis基于内存,无法应对大量消息的情况。

补充:

个人感觉:专业的事情还是需要专业的来做,虽然容忍丢失的场景可以使用Redis,毕竟看起来更轻量。但是集团统一都会配置专业的消息队列,为啥不用呢?何况内存远远比磁盘成本高。

REDIS支持秒杀场景

秒杀场景其实说的是一类通用的场景,其有如下特点:

1.并发量高

2.读多写少

3.需要保证数据扣减正确性,即不能超卖

对于秒杀场景,用户点击下单按钮的时候需要做如下校验:1.查验是否还有库存;2.如果有库存才真正的发起下单并扣减库存。 因此会对库存信息并发的大量读和 部分写入。

因此REDIS在秒杀场景的应用主要为对库存的扣减,作为缓存,其可以校验商品的库存信息,拦截住大量打往数据库的并发。

REDIS实现获取商品信息并扣减的伪代码:

#获取商品库存信息
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

补充:

  • 当然我们支持秒杀场景的另一种思路是使用分布式锁的方案,只有拿到锁的客户端才能对库存进行操作,通过这样的方案减少对数据库的并发。
  • 可以考虑拆分库存以减少访问压力。比如一个商品库存为800,可以拆成4个200的“虚拟商品”,再拆分用户访问不同的虚拟商品,这样的话来减少并发压力。这种方案的缺点:1.公平性需要考虑,只有保证用户访问虚拟商品的概率是相同的才行,不过说起来就算没“那么公平”,用户也是无感知的。2.展示库存需要遍历多个虚拟商品。感觉这个也无关紧要,因为对于秒杀场景,一般都不支持库存的展示。
  • 另一种思考:不保证REDIS原子性,让REDIS部分超卖:一般秒杀场景 用redis 扣减库存,不去保证原子性,扣减后有可能超卖,再用数据库去保证最终不超卖,因为超卖的不会多,能够打到mysql的操作就是超卖+实际库存,所以mysql压力也会比较少
  • 现实中超卖场景还可以有更多方案,比如说:网关层对超大的并发进行访问限流;网关对恶意的攻击和灰产进行拦截。

感觉这一节讲的不是很全面,待再了解了解TODO :REDIS如何支持秒杀场景[11]

Redis性能场景

异步机制|阻塞

影响 Redis 性能的 5 大方面的潜在因素

  • Redis 内部的阻塞式操作;
  • CPU 核和 NUMA 架构的影响;
  • Redis 关键系统配置;
  • Redis 内存碎片;
  • Redis 缓冲区

Redis内部阻塞

Redis内部阻塞点可以分成四个大类:

  • 客户端
  • 磁盘(日志)
  • 主从集群
  • 切片集群

客户端

网络IO:对于网络的处理,Redis这边采用了epoll+缓冲区的设计,因此在网络方面不会产生Redis阻塞的情况。

虽然对于客户端来说,网络耗时往往是大头。

增删改查:这显然是我们平时需要考虑的点,很容易产生阻塞。

首先是第一个阻塞点:集合全量查询和聚合操作。对于集合的操作,其操作复杂度往往是O(N),比如:HGETALL、SMEMBERS、以及交并集的计算等。一方面这会带来很大的内存读取,另一方面可能会导致CPU大量消耗 两方面共同作用带来一些阻塞。

其次是第二个阻塞点:删除。两种操作:bigkey的删除 和 清空数据库(例如 FLUSHDB​ 和 FLUSHALL​ 操作)。

关键词为:集合操作、删除(bigkey、清空)。

对于删除,通常来说认为在Redis支持unlink​操作之后,就不会有阻塞风险了,实际上是有一定误区的,仍然有一定风险,具体可见下文。

磁盘交互

Redis与磁盘的交互其实就是AOF和RDB。

  • 对于AOF,在同步写回磁盘的时候一般有1~2ms的延迟阻塞。
  • 对于RDB,其是异步的,因此不会阻塞。

需要注意的是:这里只说明了磁盘方面的阻塞,实际上AOF和RDB是可能会引起阻塞,已经多次讨论过了,不在这里再重复。

主从集群

主从集群交互可以从主节点和从节点分别来看。

主节点阻塞:当从节点第一次连上,主节点会生成RDB文件,虽然创建和传输RDB都是由子进程完成,但可能会因此导致一些阻塞。

从节点阻塞:从节点连上之后,在接收到RDB文件后需要执行:FLUSHDB​清空数据库,加载RDB文件。在这个过程中是阻塞的。

切片集群

切片集群可能会阻塞的点是:哈希槽扩散和数据迁移。

  • 哈希槽扩散:每个 Redis 实例上分配的哈希槽信息需要在不同实例间进行传递,同时,当需要进行负载均衡或者有实例增删时,数据会在不同的实例间进行迁移。不过,哈希槽的信息量不大,一般不会有阻塞风险。
  • 数据迁移:数据迁移是渐进式的,一般来说不会阻塞。(Redis集群用的重定向的实现,具体见上方数据迁移|slot槽迁移部分)

因此当没有 bigkey 时,切片集群的各实例在进行交互时不会阻塞主线程。如果存在bigkey的话,会在后续的课程中讲解。

总结及优化

Redis可能导致阻塞的四大方向下主要会导致阻塞的点:

  • 客户端交互:删除键值对,集合操作,清空数据库(FLUSHDB​、FLUSHALL​)
  • 磁盘交互:AOF同步写回。
  • 主从同步:从节点清空自己(FLUSHDB​)和加载RDB文件。
  • 主从切片:迁移slot的时候遇到bigkey。

目前来说,Redis已经在部分地方有了多线程,主要包括:1.后台关闭文件的线程;2.后台写回AOF的线程;3.异步释放内存的线程(unlink)。

注意:异步的键值对删除和数据库清空操作是 Redis 4.0 后提供的功能,Redis 也提供了新的命令来执行这两个操作。

键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用 UNLINK​ 命令。

清空数据库:可以在 FLUSHDB​ 和 FLUSHALL​ 命令后加上 ASYNC​ 选项,这样就可以让后台子线程异步地清空数据库,如下所示:

FLUSHDB ASYNC
FLUSHALL AYSNC

关于LAZYFREE异步删除的补充细节:

lazy-free相关的源码,发现有很多细节需要补充下: 1、lazy-free是4.0新增的功能,但是默认是关闭的,需要手动开启。 2、手动开启lazy-free时,有4个选项可以控制,分别对应不同场景下,要不要开启异步释放内存机制: a) lazyfree-lazy-expire:key在过期删除时尝试异步释放内存 b) lazyfree-lazy-eviction:内存达到maxmemory并设置了淘汰策略时尝试异步释放内存 c) lazyfree-lazy-server-del:执行RENAME/MOVE等命令或需要覆盖一个key时,删除旧key尝试异步释放内存 d) replica-lazy-flush:主从全量同步,从库清空数据库时异步释放内存 3、即使开启了lazy-free,如果直接使用DEL命令还是会同步删除key,只有使用UNLINK命令才会可能异步删除key。 4、这也是最关键的一点,上面提到开启lazy-free的场景,除了replica-lazy-flush之外,其他情况都只是可能去异步释放key的内存,并不是每次必定异步释放内存的。 开启lazy-free后,Redis在释放一个key的内存时,首先会评估代价,如果释放内存的代价很小,那么就直接在主线程中操作了,没必要放到异步线程中执行(不同线程传递数据也会有性能消耗)。 什么情况才会真正异步释放内存?这和key的类型、编码方式、元素数量都有关系(详细可参考源码中的lazyfreeGetFreeEffort函数): a) 当Hash/Set底层采用哈希表存储(非ziplist/int编码存储)时,并且元素数量超过64个 b) 当ZSet底层采用跳表存储(非ziplist编码存储)时,并且元素数量超过64个 c) 当List链表节点数量超过64个(注意,不是元素数量,而是链表节点的数量,List的实现是在每个节点包含了若干个元素的数据,这些元素采用ziplist存储) 只有以上这些情况,在删除key释放内存时,才会真正放到异步线程中执行,其他情况一律还是在主线程操作。 也就是说String(不管内存占用多大)、List(少量元素)、Set(int编码存储)、Hash/ZSet(ziplist编码存储)这些情况下的key在释放内存时,依旧在主线程中操作。 可见,即使开启了lazy-free,String类型的bigkey,在删除时依旧有阻塞主线程的风险。所以,即便Redis提供了lazy-free,我建议还是尽量不要在Redis中存储bigkey。 个人理解Redis在设计评估释放内存的代价时,不是看key的内存占用有多少,而是关注释放内存时的工作量有多大。从上面分析基本能看出,如果需要释放的内存是连续的,Redis作者认为释放内存的代价比较低,就放在主线程做。如果释放的内存不连续(大量指针类型的数据),这个代价就比较高,所以才会放在异步线程中去执行。

Redis全面的集群方案可见:https://cloud.tencent.com/developer/article/1992856

Redis性能与CPU的关系

这里是第17讲,主要讲解了NUWA远端取内存对程序性能的影响,细节见原文。

总结一下,CPU架构可能对Redis性能造成影响的点:

  • NUWA架构远端取地址,所以可以用绑核心来解决
  • REDIS没有绑定核心,如果绑核心REDIS子进程又与主进程争抢
  • REDIS程序与网卡程序不在同一个物理核心上导致NUWA远端取地址

Redis耗时波动分析

分析思路关键点:

  1. 命令本身
  2. 过期删除
  3. 文件系统

命令本身

使用建议:

  1. 使用SCAN相关函数代替批量获取:如果是SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。
  2. 当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。
  3. 容易忽略的慢查询:KEYS 匹配。这个操作会遍历所有的键值对。可以用SCAN命令来替代。

SCAN命令代替KEYS命令做匹配的时候,可能在REDIS缩容的场景遇到重复KEY的问题,具体可见:Redis中好玩的算法-高位进位法

当然,现在很多的集群中都禁用了KEYS和SCAN命令。此外,在切片集群中KEYS和SCAN命令能否跨集群也值得探讨。(SCAN命令在本节课18讲评论区作者回答了表示需要遍历每个节点,分别进行SCAN操作,然后在客户端合并结果。)

过期删除

Redis过期删除: Redis的过期清理机制[12]

本章过期删除的时候表明:如果25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。并没有说有25ms的时间限制,与小林中的有点不一样,有时间可以确认下。todo

使用建议:

  1. 不要频繁使用带有相同时间参数的 EXPIREAT​ 命令设置过期 key。考虑在 EXPIREAT 和 EXPIRE 的过期时间参数上,加上一个一定大小范围内的随机数。

补充:

当你发现 Redis 性能变慢时,可以通过 Redis 日志,或者是 latency monitor​工具,查询变慢的请求。

redis基线性能判断命令:运行下面的命令,该命令会打印 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.

文件系统

需要注意:

  1. 异步AOF重写过慢:对于AOF,通常来说会开启后台一秒一次异步刷盘的配置,此时是由后台子线程来完成刷盘,但是来了:当遇到磁盘及其慢的场景(比如在AOF重写期间),主线程又需要把数据写到文件内存中(write​ 系统调用),但此时的后台子线程由于磁盘负载过高,导致 fsync发生阻塞,迟迟不能返回,那主线程在执行 write 系统调用时,也会被阻塞住,直到后台线程 fsync​ 执行完成后,主线程执行 write​ 才能成功返回。此时可以通过关闭AOF重写期间的刷盘来避免:配置项 no-appendfsync-on-rewrite​ 设置为 yes。

  2. 触发操作系统SWAP机制:当REDIS的内存达到了操作系统设定的上限(或者是其他应用也占用内存 或者 REDIS在AOF重写或者RDB期间需要fork),这时候可能会触发操作系统的SWAP换页。此时性能自然会骤降。

  3. 操作系统的大页机制:开启大页后,系统分页可能由4KB-》2MB,此时写时复制的时候由于页大,可能会出现波动。生产环境一般不建议开启大页机制。

REDIS波动的排查思路总结:

下面的总结抛开原来的思路之外,主要额外包含了:bigkey、CPU架构 。

梳理了一个包含 9 个检查点的 Checklist,希望你在遇到 Redis 性能变慢时,按照这些步骤逐一检查,高效地解决问题。

获取 Redis 实例在当前环境下的基线性能。

是否用了慢查询命令?如果是的话,就使用其他命令替代慢查询命令,或者把聚合计算命令放在客户端做。

是否对过期 key 设置了相同的过期时间?对于批量删除的 key,可以在每个 key 的过期时间上加一个随机数,避免同时删除。

是否存在 bigkey? 对于 bigkey 的删除操作,如果你的 Redis 是 4.0 及以上的版本,可以直接利用异步线程机制减少主线程阻塞;如果是 Redis 4.0 以前的版本,可以使用 SCAN 命令迭代删除;对于 bigkey 的集合查询和聚合操作,可以使用 SCAN 命令在客户端完成。

Redis AOF 配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我们需要高性能,同时也允许数据丢失,可以将配置项 no-appendfsync-on-rewrite 设置为 yes,避免 AOF 重写和 fsync 竞争磁盘 IO 资源,导致 Redis 延迟增加。当然, 如果既需要高性能又需要高可靠性,最好使用高速固态盘作为 AOF 日志的写入盘。

Redis 实例的内存使用是否过大?发生 swap 了吗?如果是的话,就增加机器内存,或者是使用 Redis 集群,分摊单机 Redis 的键值对数量和内存压力。同时,要避免出现 Redis 和其他内存需求大的应用共享机器的情况。

在 Redis 实例的运行环境中,是否启用了透明大页机制?如果是的话,直接关闭内存大页机制就行了。 是否运行了 Redis 主从集群?如果是的话,把主库实例的数据量大小控制在 2~4GB,以免主从复制时,从库因加载大的 RDB 文件而阻塞。

是否使用了多核 CPU 或 NUMA 架构的机器运行 Redis 实例?使用多核 CPU 时,可以给 Redis 实例绑定物理核;使用 NUMA 架构时,注意把 Redis 实例和网络中断处理程序运行在同一个 CPU Socket 上。


另一种排查思路(核心是差不多的):

1、使用复杂度过高的命令(例如SORT/SUION/ZUNIONSTORE/KEYS),或一次查询全量数据(例如LRANGE key 0 N,但N很大)

分析:a) 查看slowlog是否存在这些命令 b) Redis进程CPU使用率是否飙升(聚合运算命令导致)

解决:a) 不使用复杂度过高的命令,或用其他方式代替实现(放在客户端做) b) 数据尽量分批查询(LRANGE key 0 N,建议N<=100,查询全量数据建议使用HSCAN/SSCAN/ZSCAN)

2、操作bigkey

分析:a) slowlog出现很多SET/DELETE变慢命令(bigkey分配内存和释放内存变慢) b) 使用`redis-cli -h $host -p $port --bigkeys`​扫描出很多bigkey

解决:a) 优化业务,避免存储bigkey b) Redis 4.0+可开启lazy-free机制

3、大量key集中过期

分析:a) 业务使用EXPIREAT/PEXPIREAT命令 b) Redis info中的expired_keys指标短期突增

解决:a) 优化业务,过期增加随机时间,把时间打散,减轻删除过期key的压力 b) 运维层面,监控expired_keys指标,有短期突增及时报警排查

4、Redis内存达到maxmemory

分析:a) 实例内存达到maxmemory,且写入量大,淘汰key压力变大 b) Redis info中的evicted_keys指标短期突增

解决:a) 业务层面,根据情况调整淘汰策略(随机比LRU快) b) 运维层面,监控evicted_keys指标,有短期突增及时报警 c) 集群扩容,多个实例减轻淘汰key的压力

5、大量短连接请求 分析:Redis处理大量短连接请求,TCP三次握手和四次挥手也会增加耗时 解决:使用长连接操作Redis

6、生成RDB和AOF重写fork耗时严重 分析:a) Redis变慢只发生在生成RDB和AOF重写期间 b) 实例占用内存越大,fork拷贝内存页表越久 c) Redis info中latest_fork_usec耗时变长 解决:a) 实例尽量小 b) Redis尽量部署在物理机上 c) 优化备份策略(例如低峰期备份) d) 合理配置repl-backlog和slave client-output-buffer-limit,避免主从全量同步 e) 视情况考虑关闭AOF f) 监控latest_fork_usec耗时是否变长 7、AOF使用awalys机制 分析:磁盘IO负载变高 解决:a) 使用everysec机制 b) 丢失数据不敏感的业务不开启AOF 8、使用Swap 分析:a) 所有请求全部开始变慢 b) slowlog大量慢日志 c) 查看Redis进程是否使用到了Swap 解决:a) 增加机器内存 b) 集群扩容 c) Swap使用时监控报警 9、进程绑定CPU不合理 分析:a) Redis进程只绑定一个CPU逻辑核 b) NUMA架构下,网络中断处理程序和Redis进程没有绑定在同一个Socket下 解决:a) Redis进程绑定多个CPU逻辑核 b) 网络中断处理程序和Redis进程绑定在同一个Socket下 10、开启透明大页机制 分析:生成RDB和AOF重写期间,主线程处理写请求耗时变长(拷贝内存副本耗时变长) 解决:关闭透明大页机制 11、网卡负载过高 分析:a) TCP/IP层延迟变大,丢包重传变多 b) 是否存在流量过大的实例占满带宽 解决:a) 机器网络资源监控,负载过高及时报警 b) 提前规划部署策略,访问量大的实例隔离部署 总之,Redis的性能与CPU、内存、网络、磁盘都息息相关,任何一处发生问题,都会影响到Redis的性能。

REDIS性能耗时排查可以参考:https://mp.weixin.qq.com/s/Qc4t_-_pL4w8VlSoJhRDcg 。又大又全,硬核好文。

关于REDIS性能问题:在实际项目使用REDIS的过程中,也发现了REDIS由于争抢而导致的一些问题,可以考虑总结总结。todo

其它

这里放置的内容是个人觉得没有那么重要或者没那么多内容写成一章,并不代表原文中就是其它。

REDIS使用之bigkey相关|大key

bigkey是什么: bigkey是指占用内存很大的key,没有特别严格的定义,但是可以参考的标准:1.val的内容大于1MB的键值对;2.元素个数超过1w的zset、set等集合。

bigkey会导致什么问题: 最主要的是阻塞问题,比如大key阻塞太久导致并发数量降低、处理bigkey期间主线程停止对外响应而导致脑裂、占满客户端输入输出缓冲区(虽然每个client有1g大小)、数据迁移的时候阻塞等。 此外,处理bigkey的耗时还可能导致客户端与服务器处理超时。

bigkey如何排查: 需要命令:redis-cli --bigkeys

Redis 可以在执行 redis-cli 命令时带上 –bigkeys 选项,进而对整个数据库中的键值对大小情况进行统计分析,比如说,统计每种数据类型的键值对个数以及平均大小。

# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed). [00.00%] Biggest string found so far 'a' with 3 bytes
[05.14%] Biggest list found so far 'b' with 100004 items
[35.77%] Biggest string found so far 'c' with 6 bytes
[73.91%] Biggest hash found so far 'd' with 3 fields -------- summary ------- Sampled 506 keys in the keyspace!
Total key length in bytes is 3452 (avg len 6.82) Biggest string found 'c' has 6 bytes
Biggest list found 'b' has 100004 items
Biggest hash found 'd' has 3 fields 504 strings with 1403 bytes (99.60% of keys, avg size 2.78)
1 lists with 100004 items (00.20% of keys, avg size 100004.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
1 hashs with 3 fields (00.20% of keys, avg size 3.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)

对下面命令的使用注意

这个工具是通过扫描数据库来查找 bigkey 的,所以,在执行的过程中,会对 Redis 实例有阻塞风险

这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey; 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大。

如果想详细的话需要第三方的工具了。

bigkey如何避免和解决: bigkey问题本质上是业务应该去考虑避免的。

  • 业务上直接避免使用bigkey,考虑更加合理使用缓存的方式
  • 自行将bigkey拆分成多个key,比如我们业务上就会自己实现拆分的小工具

REDIS内存占用分析之内存碎片

在之前的章节中,以 STRING内存占用为例分析了设置多个对象之后REDIS的内存占用情况,分析REDIS内存占用的具体情况(REDIS OBJECT、指针等)

在这一部分,告诉的是 内存碎片 对REDIS内存占用同样有一定影响:由于REDIS的内存分配器默认是JEMALLOC​,对于JEMALLOC​分配器,其分配策略之一是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、48 字节,…, 2KB、4KB、8KB 等。当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间。

在减少内存分配次数以及一些带来一些其它优点的同时,其会导致一些 内存碎片。

这些内存碎片会对REDIS的内存占用有一定影响,尤其是当键值对频繁的变动的情况下。

解决办法:利用INFO相关命令INFO memory来查询内存使用的情况,查看其mem_fragmentation_ratio指标。

  • mem_fragmentation_ratio​ 小于1。表明由于物理内存不够,已经使用到了操作系统的SWAP机制。

  • mem_fragmentation_ratio​ 大于 1 但小于 1.5。这种情况是合理的。这是因为,刚才我介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由 Redis 负载决定,也无法限制。所以,存在内存碎片也是正常的。

  • mem_fragmentation_ratio​ 大于 1.5 。这表明内存碎片率已经超过了 50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。

那么如何清理呢:

  • 重启REDIS:会导致REDIS停止服务

  • 使用REDIS提供的内存清理能力(4.0版本之后):

    • 命令手动开启清理:memory purge​。主线程进行的有阻塞风险。
    • 自动配置清理:config set activedefrag yes​。主线程进行的有阻塞风险。

更具体可见:《20 | 删除数据后,为什么内存占用率还是很高?》

REDIS的各种缓冲区及其使用注意

客户端与服务器之间:输入输出缓冲区。

主从节点之间:主从复制缓冲区(每个从节点在主节点都有一个),复制挤压缓冲区(所有从节点共享一个)

使用注意:

此外,一般来说REDIS的客户端也会设置一定的缓冲区,当然作用就只是为了提高用户体验了,具体如下:应用程序中使用的 Redis 客户端,需要把要发送的请求暂存在缓冲区。这有两方面的好处。

一方面,可以在客户端控制发送速率,避免把过多的请求一下子全部发到 Redis 实例,导致实例因压力过大而性能下降。不过,客户端缓冲区不会太大,所以,对 Redis 实例的内存使用没有什么影响。

另一方面,在应用 Redis 主从集群时,主从节点进行故障切换是需要一定时间的,此时,主节点无法服务外来请求。如果客户端有缓冲区暂存请求,那么,客户端仍然可以正常接收业务应用的请求,这就可以避免直接给应用返回无法服务的错误。

REDIS的内存淘汰策略

划分就如下图中的几种:不淘汰,淘汰(只对设置了过期时间的数据淘汰,在所有数据中淘汰):random、lru、lfu。

其中几种都很好理解,就是volatile-ttl不好理解,解释一下:

  • volatile-ttl ​在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除

对于LRU的淘汰算法,REDIS做了一些很有意思的设计,主要包含下面两点:

  • 空间考虑:由于正经LRU维护需要一个链表,带来额外的空间开销(双向链表一个节点两个指针需要\(2*8=16B\)的内存空间开销和移动元素的时间开销。 因此REDIS采用记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)的方式(\(4B\))
  • “正确性”考虑:为了适配LRU实现,REDIS采用的做法是在决定淘汰的数据时,第一次随机选出 N 个数据,作为一个候选集合。比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。有意思的来了:这个可以保证LRU的数值越来越小。

todo:极客时间的说法和小林中略有出入:包括每次搜集的数量。小林中也没有“正确性”考虑,待再学习学习。

而且上面的“正确性”保证看起来是有点问题的,评论区中也有讨论: 图片中的网址是要csdn付费的。。。

缓存应用

数据一致性

Redis作为旁路缓存,在每次读写操作的时候除了修改数据库中的数据之外,需要额外维护Redis中的数据,在这个过程中就可能出现数据不一致的情况。

主要维护的大分类来说就两种:删、改,具体来说有四种。

  • 先删后改:先删除Redis,后修改数据库。

  • 先改后删:先修改数据库,后删除Redis。

  • 双改:

    • 先改Redis,再改数据库
    • 先改数据库,再改Redis

对于不同的方案,我们绕不开的问题的是:

1.方案都分为两步,如果只成功了一步怎么帮?因此要考虑使用mq之类的补偿机制。

2.并发的时候会不会出现一致性问题?对业务影响如何?

第一个问题这里不讨论,讨论一下第二个问题的结论:

这几种方案在极限并发的情况下都可能出现数据不一致的问题,但是按照概率来说最佳的方案是:

先改后删 , 因为只会在 改完到删除这个时间片段 中出现不一致的问题,并发修改同样如此。同时无论采用哪一种方案,为了保险兜底,我们最好增加的措施是:

  • 给key设置一个较短的过期时间,保证最长的不一致时间,作一个兜底。

下面是极客时间中的总结,没有总结双改的情况。关于这一章,小林(链接)中相关讨论比较全面。

除了不一致的问题之外,我们还可以考虑下以下的东西:对缓存命中率的影响、删除缓存是否会导致数据库压力过大。

缓存雪崩、缓存击穿、缓存穿透如何应对

这一章同样在小林中有讨论了,结果和这里一样,因此忽略了。

下面的图中,针对缓存击穿,还可以增加备用热key的做法来避免击穿。

对于缓存击穿中的热key不过期,在上面数据一致性一章中提到了数据最好设置一个过期时间作为兜底。因此个人感觉哟个备用key不过期+主key过期这样的方案显然更加合理。

如何基于磁盘实现REDIS之PIKA的介绍

对于PIKA,其是用磁盘模拟了REIDS的相关命令,其也很有价值。

这个产品也很有意思,跟我也有些缘分,但是跟REIDS使用之类的关系就不是很大了。

有机会再来总结学习。todo

REDIS的锁操作

REDIS自身的锁操作和原子操作

我们常见的业务操作都是RMW(READ MODIFY WRITE)形态的,like:

read
xxxx 业务操作
write

如果是MySQL,我们可以通过事务等方式来避免并发竞争的问题,即操作的“原子性”。

如果是REDIS,我们可以使用两种方式来保证操作的“原子性”:

  1. REDIS保证单个命令是原子的:这点其实是由于REDIS的主线程是单线程+REDIS是一个命令一个命令执行 这样的机制来保证的。
  2. 使用lua脚本:Redis 的 Lua 脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。使用lua脚本的弊端:执行时间长的话降低REDIS并发量(REDIS主线程为单线程);

REDIS中的LUA脚本使用建议:

REDIS实现分布式锁

REDIS实现分布式锁的原理和操作很简单,如下命令即可,不过我们需要额外注意两个问题:

// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

带上NX​标志是因为这个标识此次加锁是否成功。

除此之外,的命令中有两个点值得注意:

  • value的值为当前客户端的唯一标识
  • 设置过期时间

上面的两个细节分别是为了解决两个问题:

  1. 保证解锁的客户端和加锁的客户端是同一个。(保证一个客户端加的锁不会被其他客户端解掉)
  2. 在客户端宕机的情况下,无法解锁,设置过期时间避免死锁。

在上面的情况下,如果REDIS不出现问题,那么分布式锁看起来可以正常运行了,但是问题是REDIS集群本身也可以出现问题。

试想:如果出现了主从切换 + 主从延迟的情况,这样的情况下可能会出现a客户端加上锁,但是主从没同步上,发生主从切换后,b客户端也可以加上锁了。

为了保证分布式锁的可靠性,大概有两种方案:1.REDIS官方的REDLOCK ;2.其它中间件(etcd、zk等)。

REDLOCK方案需要REDIS集群,看起来也不能完全保证可靠性,最主要是业界使用的很少,因此个人更推荐基于其它中间件的实现方案。

  1. REDIS官方的REDLOCK​:第一步是,客户端获取当前时间。第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

    客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

    条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;

    条件二:客户端获取锁的总耗时没有超过锁的有效时间

    需要注意的是:释放锁的时候需要向所有实例尝试解锁而不只像加锁成功的实例解锁,因为可能加锁成功,但是因为网络问题导致没有返回值。

  2. 其它中间件(etcd、zk等):基于RAFT的方案让人感觉可靠。

可以看到REDLOCK还是比较重的,如果极端情况下允许出现一些不一致,可以考虑单机的REDIS分布式锁方案。 毕竟这样的情况很少,如果出现了,那么开发手动去修数据也不是不可以。

一个稳健的系统需要考虑每一步出错(宕机)的可能,而分布式由于网络等原因,大大增加了出错的概率。

REDIS之事务

结论:REDIS无法完全支持事务所需要的ACID。

REDIS中支持事务的方式是:使用MULTI​(准备执行事务)、EXEC​(开始执行事务)、DISCARD​(放弃执行本次事务)命令。

其大概原理是MULTI​开启事务之后,当前客户端发送的所有命令都会放在一个请求队列中,接收到EXEC​之后,开始执行。

如果出现错误怎么办:

  1. 命令本身不支持:比如PUT什么REDIS不支持的命令,那么REDIS会对当前命令报错,并且当EXEC开始执行的时候,REDIS也会因为有错误命令而全盘拒绝执行。

    #开启事务
    127.0.0.1:6379> MULTI
    OK
    #发送事务中的第一个操作,但是Redis不支持该命令,返回报错信息
    127.0.0.1:6379> PUT a:stock 5
    (error) ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`,
    #发送事务中的第二个操作,这个操作是正确的命令,Redis把该命令入队
    127.0.0.1:6379> DECR b:stock
    QUEUED
    #实际执行事务,但是之前命令有错误,所以Redis拒绝执行
    127.0.0.1:6379> EXEC
    (error) EXECABORT Transaction discarded because of previous errors.
  2. 命令支持,但是类型不对:事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例并没法检查出错误。在执行EXEC执行的过程中才会报错。需要注意的是:虽然 Redis 会对错误命令报错,但还是会把正确的命令执行完。在这种情况下,事务的原子性就无法得到保证了

    #开启事务
    127.0.0.1:6379> MULTI
    OK
    #发送事务中的第一个操作,LPOP命令操作的数据类型不匹配,此时并不报错
    127.0.0.1:6379> LPOP a:stock
    QUEUED
    #发送事务中的第二个操作
    127.0.0.1:6379> DECR b:stock
    QUEUED
    #实际执行事务,事务第一个操作执行报错
    127.0.0.1:6379> EXEC
    1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
    2) (integer) 8

可以看到无论REDIS这么设计的考虑是什么,现状就是这么设计之后,REDIS并没有提供回滚之类的机制,自然谈不上支持事务了。

其它补充:

1.同一个“REDIS”事务里面的操作是连续的,不会被其它命令打断。

2.我们可以使用WATCH的方式来监听某个某键是否发生变化,从而选择是否要EXEC“事务”:

DISCARD使用范例:

#读取a:stock的值4
127.0.0.1:6379> GET a:stock
"4"
#开启事务
127.0.0.1:6379> MULTI
OK
#发送事务的第一个操作,对a:stock减1
127.0.0.1:6379> DECR a:stock
QUEUED
#执行DISCARD命令,主动放弃事务
127.0.0.1:6379> DISCARD
OK
#再次读取a:stock的值,值没有被修改
127.0.0.1:6379> GET a:stock
"4"

REDIS主从异常场景

主从延迟 与 过期键读取

在REDIS主从集群的场景下,数据的异常场景最突出的是两个原因是两个:

  1. 主从延迟:表现出来就是刚set进去的值,get并不能读取到(因为Redis主从集群的设置一般是主写从读)。
  2. 过期键读取:表现出来就是到了expireat相关参数,且到了过期时间,但是仍然能读取到这个key。

对于过期键的读取,其本质上也是主从延迟的一种表现,不过因为其比较特殊,所以单独拿出来唠唠。

对于上面两种情况,有一些有意思的补充:

  1. 主从延迟:我们可以通过相关指标来监控主从延迟情况,设置一些告警之类的措施master_repl_offset​、slave_repl_offset
  2. 过期键读取:由于主从,设置过期更建议使用EXPIREAT设置过期时间点的方式代替EXPIRE设置过期时间的方式(保证时钟同步的前提下)。补充:REDIS的主从删除的方式是主节点驱动删除,具体可见:Redis过期键读取相关思考[13]

数据丢失:脑裂 和 其它场景

导致脑裂产生的原因:主从切换时,原主实际上并没有宕机(假故障),只是由于某些原因临时和哨兵通信出现问题,此时原主可以正常处理客户端的写入,但是对整个集群来说已经换主了,从而导致出现“双主”。

脑裂的后果:数据丢失。发生脑裂的情况下,在原主恢复后(或者运维人员发现脑裂手动下线),其会感知到自己已经是从节点了,其会同步新主的进度,因此原主在脑裂之后原主写入的数据就会丢失了。

脑裂主要发生的场景:

1.REDIS阻塞:比如:其他程序占据CPU导致REDIS处理过慢无法和哨兵ping通,大key阻塞太久等等。

2.网络问题:REDIS(原主)与哨兵的网络出现问题,但是与客户端的网络正常。

在讲解脑裂的缓解措施之前我们需要意识到:实际上脑裂并不能完全避免,同样数据丢失同样不能完全避免,这些措施只能减少脑裂期间的数据丢失。

REDIS集群减少脑裂期间数据丢失的措施:禁止从节点过少的情况下限制主库进行写入。

  • min-slaves-to-write​:这个配置项设置了主库能进行数据同步的最少从库数量;
  • min-slaves-max-lag​:这个配置项设置了主从库间进行数据复制时,从库给主库发送 ACK 消息的最大延迟(以秒为单位)。

结合CAP的理论:REDIS的集群并不能保证数据的一致性,实际上是CAP中的AP。其减少脑裂数据丢失的措施,实际上权衡了CAP中的CP。


  1. Redis数据结构总结

    首先祭出关键图片:

    下面将对每种数据结构展开描述:

    SDS

    一言以蔽之:Redis中的SDS​(simple dynamic string,简单动态字符串)是对C语言字符串的一种改进结构,解决了C语言字符串的一些固有问题,并尽可能的减少解决C语言字符串的固有问题带来的一些额外的开销。

    C 语言字符串的缺陷

    C语言字符串是什么?有什么缺陷?

    C语言字符串就是char类型的数组,其使用有很多不方便的地方:

    • 结尾是以\0​来判断,很别扭,意味着不能存在\0​这个字符,即不能保存图片、音视频等文件。
    • 很容易数组越界,因为不知道有多长
    • 获取长度很不方便,需要遍历来获取长度。

    SDS 结构设计

    len​字段和alloc​字段

    既然上面提出了问题,那么SDS肯定是要对这三个问题进行改进的。

    对于结尾为\0​和获取长度不方便很简单:添加了len字段表示字符串长度,避免以\0​为数组结尾。

    对于容易数组越界的问题,添加了alloc字段表示当前分配的空间长度(而且既然添加了len字段避免以\0​结尾,那总要有个结尾标识符吧)。

    那么Redis的SDS的样子解密:,我们发现其样子和我们想的差不多,有真正的buf字节数组(这是肯定的,要不东西放哪里),添加有len字段表示当前数组长度,添加有alloc表示分配的空间长度。

    上面都是符合的,但是却多了一个flags字段来表示SDS的类型,这个是干嘛的?

    flags​字段

    flags​字段的作用一言以蔽之:为了省len​字段和alloc​字段的空间。

    上面不是添加了两个字段:len​和alloc​,那么这两个字段是什么类型呢?如果是简单的认为是uint32_t​或者是uint64_t​那就太简单了或者说毫无道理。 这个flags字段恰恰体现了Redis的极致省空间。

    flags 表示的是 SDS 类型,一共设计了 5 种类型,分别是 sdshdr5​、sdshdr8​、sdshdr16​、sdshdr32​ 和 sdshdr64​。

    他们的区别主要在于len​和alloc​的类型不同!

    比如 sdshdr16​ 和 sdshdr32​ 这两个类型,它们的定义分别如下:

    struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc;
    unsigned char flags;
    char buf[];
    }; struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;
    uint32_t alloc;
    unsigned char flags;
    char buf[];
    };

    会根据字符串的不同设定不同的len​和alloc​的类型,真的是极致的节省。

    而且这还没完,注意到__attribute__​,这代表取消了内存对齐!!!

    Redis​对SDS的flags参数和取消内存对齐就可以看出Redis特别看重内存的占用。

    这个也可以从Redis​其他数据结构的xx设计(todo:待总结)和Redis对LRU页面置换算法的改造 可以看出来。

    PS:你看,这不就可以扯到页面置换算法了吗。

    SDS相对于C语言字符串的优势

    总结一下其优势:

    • 二进制安全:可以储存音视频,照片等。
    • O(1)​时间获取字符串长度
    • 不会发生缓存区溢出
    • 节省空间,当然这是指Redis精细(变态的)设计,尽可能节约空间,因为添加了额外字段,空间占用肯定比C语言字符串更多。

    不会发生缓存区溢出进行解释:

    C语言字符串库提供的函数很多是有溢出风险的(比如 strcat 追加字符串函数),其内部没有判断缓存区大小是否满足空间,把判断满足的工作交给了程序员。而Redis则会判断空间够不够,不够则会进行扩容。

    hisds hi_sdsMakeRoomFor(hisds s, size_t addlen)
    {
    ... ...
    // s目前的剩余空间已足够,无需扩展,直接返回
    if (avail >= addlen)
    return s;
    //获取目前s的长度
    len = hi_sdslen(s);
    sh = (char *)s - hi_sdsHdrSize(oldtype);
    //扩展之后 s 至少需要的长度
    newlen = (len + addlen);
    //根据新长度,为s分配新空间所需要的大小
    if (newlen < HI_SDS_MAX_PREALLOC)
    //新长度<HI_SDS_MAX_PREALLOC 则分配所需空间*2的空间
    newlen *= 2;
    else
    //否则,分配长度为目前长度+1MB
    newlen += HI_SDS_MAX_PREALLOC;
    ...
    }

    其中HI_SDS_MAX_PREALLOC​是一个常量,默认值为1 << 20,即1MB。

    因此扩容机制为:

    @startuml
    
    start
    
    :扩容机制;
    
    if (判断当前剩余空间是否需要扩容) then (false)
    :不扩容; else (true)
    :扩容;
    if(所需的的newlen < HI_SDS_MAX_PREALLOC) then(true)
    :翻倍扩容(扩容到2倍的newlen);
    else(false)
    :扩容长到 newlen + HI_SDS_MAX_PREALLOC;
    endif stop @enduml

    ​​

    PS:这个文本绘图咋这么不对劲hhh

    链表

    链表的设计

    Redis的链表设计还是比较正常的,只是比正常双链表多了一些len​(标记链表长度),dup​(节点复制函数),free​(节点释放函数),match​(节点比较函数)。

    要注意的是为了保持任意类型,listNode​中的value​应该是一个void *​类型

    链表的优势与缺陷

    优势与缺陷其实和普通链表类似:

    优点:

    • 有长度字段,获取长度时间复杂度为O(1)
    • 双向链表且有tail字段,可以从后开始遍历
    • value类型为void *,可以保存任意类型的数据

    缺点:

    • 内存不连续,无法利用CPU高效缓存
    • 链表固有要保存上一个节点和下一个节点的指针,因此指针开销大。

    缺点和优点都有些空泛,都是双向链表这个数据结构特有的。

    除了value为void *。

    为了缓解链表的缺点:内存不连续、指针开销大,因此List的底层会在数据量小的时候使用压缩列表,在数据量大之后再换用链表。

    当然现在List的底层只有一个实现了:quicklist。quicklist融合了链表和压缩列表,具体可见下文。

    哈希表

    哈希表是hash的底层数据结构之一。

    hash的底层数据结构为:哈希表或压缩列表(现在使用listpack​替代了压缩列表)。

    哈希表是一种保存键值对(key-value​)的数据结构。优点在于,它能以 O(1) 的复杂度快速查询数据

    针对哈希表中的哈希冲突问题:Redis中采用拉链法来解决哈希冲突。

    哈希表的结构设计

    首先看下哈希表的设计:

    图里面没有展示完全,主要少了另一张哈希表,后面会对这个进行说明,这里不展示另一张哈希表是为了理解简单。

    typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希表已有的节点数量---用于后续rehash的计算
    unsigned long used;
    } dictht;

    首先看一下哈希表的结构,主要字段为:table​-指向哈希表数组、size​-哈希表大小、sizemask​-计算索引值、used​-哈希表已有节点的数量,用于后续rehash的计算。

    哈希表的整体设计和普通的没有太大的区别,主要的区别可能在于这里除了记录哈希表本身大小之外,还记录了哈希表已有的节点数量。

    再来看看哈希表节点的结构,如下:

    typedef struct dictEntry {
    //键值对中的键
    void *key; //键值对中的值
    union {
    void *val;
    uint64_t u64;
    int64_t s64;
    double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
    } dictEntry;

    这里面是有惊喜的,key​字段和next​字段没有太大的意外,一个是指向key​的指针,一个是指向下一个哈希表节点的指针(用拉链法解决哈希冲突)。

    需要注意的就是v是一个【联合体】,这里又体现了Redis​极致的节省内存了。因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry​ 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。

    真的是妙。

    rehash

    Redis的渐进式rehash过程

    这里不讲解【哈希冲突】、【拉链法解决哈希冲突】。

    当哈希冲突越来越严重,需要拉的链子就会越来越长,极端情况下可能会使哈希表查找的时间复杂度由O(1)退化到O(n),这种情况下就需要将哈希数组的长度进行增加,这个增加哈希数组长度的过程就成为rehash。

    一个朴素的想法是当要rehash的过程的时候就新建一个扩容后的哈希数组,然后将元素全部迁移过去即可。

    朴素想法中需要另一个哈希表(扩容后的哈希表)的想法没有问题,实际上Redis中的哈希数据结构本来就会定义两张表:

    typedef struct dict {

    //两个Hash表,交替使用,用于rehash操作
    dictht ht[2];

    } dict;

    但是朴素想法的问题在于当哈希表元素很多的时候,大量迁移会导致Redis的阻塞,这个成本太高了。

    因此Redis采用的是渐进式的rehash,将一次性的迁移拆分到了每一次执行命令的过程中,分摊了时间成本,具体过程为:

    • 给「哈希表 2」 分配空间;
    • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上
    • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

    这样就完成了rehash,需要注意两点:

    1. 在rehash过程中许多操作都需要执行两遍,比如查找,在【哈希表1】中查找后如果没有还需要去【哈希表2】中查找。
    2. 新增元素只新增在【哈希表2】中,这样保证【哈希表1】中的元素会慢慢减少。

    Redis的rehash触发条件

    rehash​ 的触发条件跟负载因子(load factor) 有关系。

    负载因子可以通过下面这个公式计算:

    触发 rehash 操作的条件,主要有两个:

    • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
    • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

    实际上rehash不只是扩容,还会缩容量,具体可见:rehash的时机:包括缩容和扩容[14]

    哈希表的优势与缺陷

    优势:

    • 节省内存:dictEntry​中的v存放值使用联合体
    • 渐进式rehash​过程

    缺陷:

    压缩列表

    我们在开始的图中知道Redis中的list​、hash​、zset​都会在节点少的时候使用压缩列表来代替原本的链表、哈希表、跳表等结构。说明压缩列表在节点少的时候比原本的结构有很强的性能,但是在节点数量上来之后性能就没有那么大的优势了(实际上节点数量上来了性能是有很大的劣势,下面会具体讲解)。

    当然较新版的Redis​中list​已经使用了quicklist​实现,而压缩列表​也有了替代品listpack​,因此上文可以为:【Redis中的hash、zset都会在节点少的时候使用listpack来代替原本的哈希表、跳表等结构。】

    虽然压缩列表已经被替换,但是还是可以学习其优秀思想。(面试要考多嘛)

    既然压缩列表有如此厉害的特性,那我们来看看其设计到底有什么独到之处。

    压缩列表的结构设计

    压缩列表最大的特点是内存紧凑的数据结构,占用一块连续的内存空间,不仅可以利用 CPU缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

    本身是由连续的内存块构成,有点类似于数组的结构。

    可以看到其记录了一些压缩链表的信息字段,比如:

    • zlbytes​:占用内存字节数
    • zltail​:尾部距离开头多少字节,即尾部偏移
    • zllen​:整个压缩链表有多少个元素
    • zlend​:标记压缩链表的结束点,固定值0xFF​(十进制255)。

    这些字段还是比较常规的,无法体现上面说的压缩列表的正对不同长度的数据进行相应编码的特点,这个秘密常在entry的结构里:

    压缩列表节点包含三部分内容:

    • prevlen​,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
    • encoding​,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
    • data​,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

    没错,就是你想的那样,压缩链表“节约内存”的这一秘密就藏在这三个字段里面。和SDS数据结构类似,prevlenencoding都是会随着data的大小和类型来进行不同的空间大小分配,具体来说:

    • prevlenprevlen​字段记录的是前一个entry占用的字节数,如果前一个节点的长度小于 254 字节,那么 prevlen​ 属性需要用 1 字节的空间;如果前一个节点的长度大于等于 254 字节,那么 prevlen​ 属性需要用 5 字节的空间;
    • encoding 如下图所示。可以看到,在极限的情况下,甚至不需要data​(图中的content)。

    别问一字节2^8 = 256,为啥prevlen的跳变值不是256字节而是254字节,问就是我也不清楚,看的小林网站上的。

    压缩列表的缺陷

    上面说了压缩链表的好处:连续的内存空间、节省内存。也提及了在数据多的时候压缩列表并不好,那么到底为啥不好呢?主要有两点:1.查找元素需要遍历 2.连锁更新。

    查找元素需要遍历很好理解,下面主要分析连锁更新的问题。

    连锁更新这个问题的核心正是因为prevlen​和encoding​字段的变长,真是成也风云、败也风云。

    一句话总结就是如果某一个entry​的更新突破了prevlen​的临界值(或者插入一个特别大的entry​),那么下一个entry的prevlen占用字节数就会增加(从一字节变成五字节),prevlen占用变多,如果正好又突破到了临界值,那么又会导致下一个entry的prevlen占用字节数增加。。。。

    如此往复,极端情况下可能导致整个压缩链表所有的元素都进行空间扩展的操作。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」 ,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下....

    压缩列表的优势与缺陷

    • 优势:内存连续的结构;节省内存的设计(变长);
    • 缺陷:只能顺序遍历;连锁更新(内存重新分配最严重时);

    整数集合

    整数集合是 Set​ 对象的底层实现之一。当一个 Set对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。

    整数集合的结构设计

    整数集合本质上是一块连续内存空间,它的结构定义如下:

    typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
    } intset;

    其中元素数量length​字段就是字面含义,还有字段是encoding​(这个字段体现了Redis极致节省内存),其含义决定了contents 数组的真正类型。

    即content数组虽然是以int8_t的类型声明的,但是其保存内容的格式并不是按照int8_t​,而是取决于encoding​字段。

    • 如果 encoding​ 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
    • 如果 encoding​ 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
    • 如果 encoding​ 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;

    由这个字段可以看到在保存数字大小不同的时候整数集合会选择不同的encoding,那么突然插入一个很大的数(只要新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要大时就算),那么整数集合就会“升级”。

    整数集合的升级操作相关

    整数集合升级是什么?为什么要升级?

    如果现在整数集合中都是int16_t​类型的数字,现在插入了一个int32_t​类型的数字,那么Redis中的整数集合就会将encoding升级为INTSET_ENC_INT32​,并且对content数组进行扩容,扩容后按照int32_t​ 的大小重新安排原来的元素,然后再插入新元素。

    比如:

    原来集合中右三个int16_t​类型的元素。

    当插入一个int32_t​类型的元素时,由于新插入的元素利用int16_t​无法保存,那么整数集合就会涉及升级操作:

    1. content数组首先会扩容,在原本空间的大小之上再扩容多 80 位(4x32-3x16=80),这样就能保存下 4 个类型为 int32_t的元素
    2. 将之前的元素转为int32_t​,并且将元素班搬移到正确的位置。整个过程如图所示:

    先搬移后面再搬移前面,就可以不用申请临时的空间了,算法 的巧妙设计呀。

    1. 插入新元素(65535),已经演示在上图中了。

    整数集合什么时候会升级?

    在插入元素的类型大于当前整数集合的类型的时候。

    整数集合会“降级”吗?

    不会降级,即如果只有一个int32_t​类型的元素,整个整数集合也会是int32_t​的类型,就算这个元素删除,也不会降级。比如上图中的65535,就算删除,整数集合也不会降级。

    整数集合的优势与缺陷

    优势:占用内存小 + 内存连续。 占用内存小是不需要链表那样的指向上一个元素下一个元素的指针;内存连续指的是连续的元素内存地址连续,便于CPU缓存访问加速。

    缺陷:访问实际上是O(N)的时间复杂度。

    优缺点实际上与压缩列表[15]的优缺点是相同的。

    通过encoding​字段决定data​数组中元素的实际长度,节约了内存。但是如果插入了一个比较大的元素,就会发生【整数集合的升级操作】涉及了data​数组的扩容和元素的搬移。

    联想:【整数集合的升级操作】和【压缩列表的级联更新】有一定的相似地方,也有一定的不同点。

    • 相似:都涉及了大量元素的搬移。
    • 不同点:整数集合搬移的原因是因为整数集合每个元素占用相同大小的空间,当发生升级操作的时候每个元素占用的空间都会增大;压缩列表搬移的原因是因为其entry​的prevlen​和encoding​字段是动态变化的,当前一个元素变大可能会导致当前元素prevlen​字段和encoding​字段变长,然后再不断影响到后面。

    跳表

    Redis只有 Zset对象的底层实现用到了跳表跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

    Zset​ 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。

    typedef struct zset {
    dict *dict;
    zskiplist *zsl;
    } zset;

    Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。

    Zset对象能支持范围查询(如 ZRANGEBYSCORE操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE操作),这是因为它同时采用了哈希表进行索引。

    可能很多人会奇怪,为什么我开头说 Zset 对象的底层数据结构是「压缩列表」或者「跳表」,而没有说哈希表呢?

    Zset​ 对象在使用跳表作为数据结构的时候,是使用由「哈希表+跳表」组成的 struct zset,但是我们讨论的时候,都会说跳表是 Zset对象的底层数据结构,而不会提及哈希表,是因为 struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。

    跳表的结构设计

    跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表。相比于链表,能快读定位数据。

    这个【有序】是指什么有序?后面查找过程会看见。

    下面展示了链表的基础结构:

    我们不妨以一个查找过程为介入来介绍链表的结构:

    在正式开始之前需要先弄清楚跳表中查找元素的规则:

    • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
    • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。

    查看过程是先看权重,再看SDS数据类型。

    这也说明了跳表中元素的排列顺序是先按权重排,再按SDS元素类型排。

    比如要在上图的结构中查找【元素:abcd;权重:4】的节点。

    从头结点的最高层L2出发,到【元素:abc;权重3】的节点,发现“当前节点权重比要查找的元素更小”,于是继续向后遍历,但是【元素:abc;权重3】的L2层的下一个为空,于是跳到下一层L1,再到后一个节点【元素:abcd;权重4】,发现【元素:abcd;权重4】的“权重等于目标权重,但是SDS值abcd大于目标节点”,于是返回元素元素:abc;权重3】并到下一层L1,往后一个元素【元素:abcd;权重:4】,发现就是目标元素,查找到了,跳出。


    从查找过程可以感受到跳表至少需要的数据结构有:

    不同层的下一个元素的指针,很易想到理应由一个数组保存;

    当前节点的元素值,权重;

    其实跳表中的节点数据结构为:

    typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward; //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
    struct zskiplistNode *forward;
    unsigned long span;
    } level[];
    } zskiplistNode;

    可以看到比我们推测的多了:

    • unsigned long span : 跨度,实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。与查找过程是没有关系的。
    • struct zskiplistNode *backward; 后向指针,指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

    另外,图中的头节点其实也是 zskiplistNode 跳表节点,只不过头节点的后向指针、权重、元素值都没有用到,所以图中省略了这部分。

    问题来了,由谁定义哪个跳表节点是头节点呢?这就介绍「跳表」结构体了,如下所示:

    typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
    } zskiplist;

    跳表结构里包含了:

    • 跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
    • 跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
    • 跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量;

    跳表节点层数设置

    理想状态

    跳表的相邻两层的节点数量的比例会影响跳表的查询性能,因为每次跳过的越多,那么需要遍历的就越少。

    那么每次跳过的最多就是当前数量的一半,和二分查找类似,因此如果每次能排除掉一半,查找的时间复杂度最低能到log(n)。这要求相邻level之间的节点数量为2:1,这样的节点数量才能保证每次跳过一半的节点。

    Redis是怎么做的

    如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。

    Redis并没有严格维持这个顺序,采用的方式是跳表在创建节点的时候,随机生成每个节点的层数

    除了第一层 外,生成后面层的概率都是25%,如果生成了一层,那么继续随机25%的概率生成下一层。

    虽然概率很小,但是如果没有限制的话层的高度理论上是无穷的,对此Redis的解决方案是限制最高高度为64,因此头节点的高度就为64。

    为什么使用跳表而不使用平衡树

    一言以蔽之,跳表写起来更加简单,而且性能也不是很差,而且省内存一些。

    • 简单:平衡树在插入删除的时候会涉及子树结构的调整,逻辑复杂。而且在范围查找的遍历的时候,跳表的范围遍历很简单,而平衡树要使用中序遍历,相对复杂。
    • 性能:跳表性能没有那么差,而且经常需要执行ZRANGE命令,即范围遍历,在范围遍历的时候跳表时间复杂度并不比平衡树差。
    • 省内存:节省指针内存,平衡树每个节点要保存左右儿子的地址,有两个指针。而跳表的节点指点的期望值为 1/(1-p),就Redis而言为1/0.75 = 4/3 = 1.33个,比平衡树占用更少。

    为什么跳表的节点指点的期望值为 1/(1-p)。

    节点期望值为:1+p +p*p+p*p*p+ ... 。就是个等差数列求和

    跳表的优势与缺陷

    如前节【为什么使用跳表而不使用平衡树】分析。

    quicklist

    quicklist是链表压缩列表的结合。

    list数据结构底层以前是压缩列表或链表,现在是quicklist了。

    quicklist把两者融合了,结合了两者的优点,那么自然就用quicklist了。

    quicklist的结构设计

    quicklist是压缩列表和链表的结合,那么一个很自然的问题就是为什么要结合?肯定是压缩列表和链表分开使用有一些缺陷,然后用结合的方式来改进了。

    因此先回顾一下压缩列表和链表各自的优缺点把:

    压缩列表:优:内存连续;缺点:连锁更新;

    链表:优:没有连锁更新问题;缺:内存不连续;

    quicklist采用的就是将链表的node换成了压缩列表:

    压缩列表的连锁更新问题在节点数量大时后果很严重,因此压缩列表不适合保存大量的节点,换句话说压缩列表在保存少量的节点效率还是不错的。因此quicklist​会限制压缩列表中的元素个数或者大小来缓解大量连锁更新的缺陷,但是连锁更新的问题肯定还是存在的。

    quicklist的优势与缺陷

    其实 quicklist就是「双向链表 + 压缩列表」组合,因为一个 quicklist就是一个链表,而链表中的每个元素又是一个压缩列表。

    在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。

    以前List由双向链表或者压缩列表实现,既然quicklist融合了两者优点,那么自然List换用quicklist实现了。

    listpack

    一言:listpack是在保留压缩列表优点的基础上对压缩列表的连锁更新问题进行改进

    压缩列表的优缺点先简单回顾一下:

    • 优:内存连续;节省内存(可变长字段)
    • 缺:只能遍历;连锁更新

    listpack​因为不再使用压缩列表中prevlen​字段保存前一个节点的长度,而是使用len​字段保存当前节点的长度达到保留压缩列表优点的同时规避了压缩列表连锁更新的问题。

    listpack的结构设计

    最大特点是 listpack中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

    • encoding​,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
    • data​,实际存放的数据;
    • len​,encoding+data的总长度;

    为了方便对比,这里放上压缩列表的结构:

    其中listpack​相比于压缩列表最大的该表就是将prevlen​字段(保存前一个节点的长度)改为了len​(保存当前节点的长度),从而避免了压缩链表级联更新长度的问题。


    补充:压缩列表中prevlen​字段可以用来实现倒序查找元素,那么改为len只保存当前节点的长度后怎么实现倒序查找元素 呢?

    element-tot-len记录了这个Entry的长度(encoding + data),注意并不包括 element-tot-len​ 自身的长度,占用的字节数小于等于5。

    • element-tot-len 所占用的每个字节的第一个 bit 用于标识;0代表结束,1代表尚未结束,每个字节只有7 bit 有效。
    • 值得一提的是,element-tot-len 主要用于从后向前遍历,当我们需要找到当前元素的上一个元素时,我们可以从后向前依次查找每个字节,找到上一个Entry​的 element-tot-len 字段的结束标识,进而可以计算出上一个元素的长度。
    • 例如 element-tot-len 为00000001 10001000​,代表该元素的长度为00000010001000​ (注意这里是14位),即136字节。通过计算即可算出上一个元素的首地址(entry的首地址)。

    链接:https://juejin.cn/post/7093530299866284045

    listpack的优势与缺陷

    Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,使用了len保存当前节点的长度(不包括len本身)代替压缩列表中的prevlen字段

    quicklist​融合了链表和压缩列表,然后listpack​改进了压缩列表,那么为什么quicklist不直接使用链表+listpack呢?

    针对这个问题目前还没有解答

    面试总结:

    极值省内存的体现

    Redis中有有很多优秀的设计用于极致节省内存,主要包括:使用encoding字段指明data类型,当数据很小的时候直接使用encoding字段本身保存数据;字段占用字节数根据数据量是变长的。

    下面对此概要总结一下,详细见上方解读:

    1. SDS中的flag字段

    SDS中的flag决定了len字段和allocate字段的类型,就节省len和allocate类型变化占用的字节数,这样可以在SDS很短的时候节省空间。

    2. 哈希表

    哈希表中的哈希entry中的value​结构为:

        //键值对中的值
    union {
    void *val;
    uint64_t u64;
    int64_t s64;
    double d;
    } v;

    是一个联合体(union),当value为uint64_t、int64_t、double类型的时候就可以节省一个指针的开销了。

    如果用都用void *​,那么就是value​指向保存的诸如uint64_t​类型,会多一个指针的空间。

    3. 压缩列表和listpack

    压缩列表和listpack极致节省内存都是一样的,因此放在一起来说。

    两者都是因为其entry的prevlen(listpack对应为len)和encoding字段也是变长的。

    4. 整数集合

    整数集合本身的升级操作,比如:如果最大元素在u_int16范围内,那么整数集合就会用u_int16来保存数据,而不是u_in32。只有在最大元素为u_int32的时候,整数集合才会用u_int32来保存数据。


    参考:

    Redis 数据结构

    深入分析redis之listpack,取代ziplist? - 掘金

    ziplist、quicklist、listpack源码设计解读_listpack和ziplist_柯南二号的博客-CSDN博客

  2. 主从同步流程

    从库执行slaveof​命令:

    第一步,主从库建立连接,协商同步。

    1. 从库发送psync​命令,表示进行数据同步。其中run ID​表示主库ID,第一次不知道主库的run ID​,就设置为"?"
    2. 主库收到psync命令后,用FULLRESYNC​响应,返回run ID​(主库ID)和offset​(主库目前的复制进度)。
    3. 从库收到响应后,记录这两个参数。

    FULLRESYNC​ 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

    第二步:主库同步数据给从库。

    从库收到数据后,在本地完成数据加载。这过程依赖于RDB快照。

    1. 主库执行bgsave​命令,生成RDB文件,再把文件发强从库。
    2. 从库收到RDB文件后,先清空当前数据库,然后加载RDB文件。

    第三步,主库发送新写命令给从库

    主库在数据同步过程中,会记录所有写操作,避免丢失同步过程接收的新的写命令。

    1. 主库使用replication buffer​来记录新的​命令(读命令自然没有必要记录)
    2. 当从库加载RDB文件完成后,主库再把replication buffer​的内容发送给从库,从库再执行这些操作实现同步。

    拓展:

    1.主从集群之后,Redis集群是读写分离的。

    1.主从集群可以运行主从级联(主-从-主)减小主库同步压力:第一次同步的时候FULLSYNC主库会执行bgsave命令来生成RDB,bgsave过程中是有风险的,再加上如果从库多的话,主库可能会严重阻塞。

    网络断开处理

    • 2.8之前,全量重新复制
    • 2.8之后:增量复制。

    增量复制的关键就在于:repl_backlog_buffer,主库的写命令,除了传给从库后,还会写入repl_backlog_buffer,当主从断开后,重新建立连接,从库会发送之前的那个命令:

    psync $master_runid $slave_offset

    这个时候,主库就会在repl_backlog_buffer中找到offset的位置,把之后的写命令写入replication buffer同步给从库。

    连接断开后,根据 repl_backlog_buffer 增量缓冲区 增量同步的示意图:

    参考:

    https://www.cnblogs.com/liang24/p/14189679.html 图较多

    https://mp.weixin.qq.com/s/Zb-k8WGluLYWXHpuOUuvGQ

  3. 哨兵机制的高可用设计 | 哨兵集群

    在上一节的分析过程已经设涉及了哨兵集群的具体操作,这里稍微总结一下哨兵集群的作用:

    哨兵的作用是:监控、选主、通知。哨兵集群在其中的作用可以总结为: 避免单个哨兵错误判断主库宕机而导致发生主切换而导致服务短暂不可用。具体发生的环节:选主的“投标仲裁”。

  4. 哨兵集群中执行内部“投标仲裁”后才会开始选主,“投标仲裁”会选出一个主持这次选主的 leader 哨兵主持后续的选主过程

  5. 选主机制

    哨兵集群中执行内部“投标仲裁”后才会开始选主,“投标仲裁”会选出一个主持这次选主的 leader 哨兵主持后续的选主过程

    投标仲裁

    任何一个实例判断主库“主观下线”,就会给其他实例发送is-master-down-by-addr命令。

    其他实例会根据自己和主库的连接情况,做出Y或N响应。

    一个哨兵获得仲裁所需的赞同票数后,就可以标记主库为“客观下线”。这个票数可以通过哨兵配置文件中的quorum​配置项来设定。

    再给其他哨兵发送命令,表示希望由自己来执行主从切换,并让其他哨兵进行投票。“投标仲裁”这个也叫“Leader选举”,同时满足以下两个条件才能当Leader:

    • 拿到半数以上的票数
    • 拿到的票数>=quorum

    从这里也可以发现,“选出 leader 哨兵”与 开始选新主、标记主库“客观下线” 逻辑上是同时进行的,或者说 投标仲裁的目的就是为了执行 客观下线之后的 选新主 的过程。

    选主的过程总结是:“筛选+打分”,先筛选出可以当主库的从库,再在这些从库中选出得分最高的选为 leader。

    • 筛选:首先从库必须在线运行,其次从库网络状态良好。(从库网络状态关键词down-after-milliseconds​)。

    • 打分:三轮打分,在过程中只要选出得分高的就不用下一轮了

      1. 配置的优先级,优先级高的得分高。(配置关键词:slave-priority​)。
      2. 从库与主库同步进度,同步进度越高的丢失的数据越少,得分越高。(同步进度关键词:主从库是通过repl_backlog_buffer​保持同步的,所以slave_repl_offset​最接近master_repl_offset​,得分高。)
      3. 从库 ID 小的得分高。个人理解这个相当于兜底,保证一定可以选出一个 新主库。

  6. 通知机制

    通知机制,就是让客户端从哨兵订阅消息,当有新主库切换的消息时就切换即可。

    因为哨兵本质上是一个运行在特殊模式的 Redis 节点,因此其支持pub/sub功能。下面是哨兵提供的一些重要的频道。

    知道这些频道后,可以让客户端从哨兵这里订阅消息了。

  7. rehash

    Redis的渐进式rehash过程

    这里不讲解【哈希冲突】、【拉链法解决哈希冲突】。

    当哈希冲突越来越严重,需要拉的链子就会越来越长,极端情况下可能会使哈希表查找的时间复杂度由O(1)退化到O(n),这种情况下就需要将哈希数组的长度进行增加,这个增加哈希数组长度的过程就成为rehash。

    一个朴素的想法是当要rehash的过程的时候就新建一个扩容后的哈希数组,然后将元素全部迁移过去即可。

    朴素想法中需要另一个哈希表(扩容后的哈希表)的想法没有问题,实际上Redis中的哈希数据结构本来就会定义两张表:

    typedef struct dict {

    //两个Hash表,交替使用,用于rehash操作
    dictht ht[2];

    } dict;

    但是朴素想法的问题在于当哈希表元素很多的时候,大量迁移会导致Redis的阻塞,这个成本太高了。

    因此Redis采用的是渐进式的rehash,将一次性的迁移拆分到了每一次执行命令的过程中,分摊了时间成本,具体过程为:

    • 给「哈希表 2」 分配空间;
    • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上
    • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

    这样就完成了rehash,需要注意两点:

    1. 在rehash过程中许多操作都需要执行两遍,比如查找,在【哈希表1】中查找后如果没有还需要去【哈希表2】中查找。
    2. 新增元素只新增在【哈希表2】中,这样保证【哈希表1】中的元素会慢慢减少。

    Redis的rehash触发条件

    rehash​ 的触发条件跟负载因子(load factor) 有关系。

    负载因子可以通过下面这个公式计算:

    触发 rehash 操作的条件,主要有两个:

    • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
    • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

    实际上rehash不只是扩容,还会缩容量,具体可见:rehash的时机:包括缩容和扩容[14:1]

  8. 投标仲裁

    任何一个实例判断主库“主观下线”,就会给其他实例发送is-master-down-by-addr命令。

    其他实例会根据自己和主库的连接情况,做出Y或N响应。

    一个哨兵获得仲裁所需的赞同票数后,就可以标记主库为“客观下线”。这个票数可以通过哨兵配置文件中的quorum​配置项来设定。

    再给其他哨兵发送命令,表示希望由自己来执行主从切换,并让其他哨兵进行投票。“投标仲裁”这个也叫“Leader选举”,同时满足以下两个条件才能当Leader:

    • 拿到半数以上的票数
    • 拿到的票数>=quorum

    从这里也可以发现,“选出 leader 哨兵”与 开始选新主、标记主库“客观下线” 逻辑上是同时进行的,或者说 投标仲裁的目的就是为了执行 客观下线之后的 选新主 的过程。

  9. SDS


    书是不是有点老了






  10. SDS 结构设计

    len​字段和alloc​字段

    既然上面提出了问题,那么SDS肯定是要对这三个问题进行改进的。

    对于结尾为\0​和获取长度不方便很简单:添加了len字段表示字符串长度,避免以\0​为数组结尾。

    对于容易数组越界的问题,添加了alloc字段表示当前分配的空间长度(而且既然添加了len字段避免以\0​结尾,那总要有个结尾标识符吧)。

    那么Redis的SDS的样子解密:,我们发现其样子和我们想的差不多,有真正的buf字节数组(这是肯定的,要不东西放哪里),添加有len字段表示当前数组长度,添加有alloc表示分配的空间长度。

    上面都是符合的,但是却多了一个flags字段来表示SDS的类型,这个是干嘛的?

    flags​字段

    flags​字段的作用一言以蔽之:为了省len​字段和alloc​字段的空间。

    上面不是添加了两个字段:len​和alloc​,那么这两个字段是什么类型呢?如果是简单的认为是uint32_t​或者是uint64_t​那就太简单了或者说毫无道理。 这个flags字段恰恰体现了Redis的极致省空间。

    flags 表示的是 SDS 类型,一共设计了 5 种类型,分别是 sdshdr5​、sdshdr8​、sdshdr16​、sdshdr32​ 和 sdshdr64​。

    他们的区别主要在于len​和alloc​的类型不同!

    比如 sdshdr16​ 和 sdshdr32​ 这两个类型,它们的定义分别如下:

    struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc;
    unsigned char flags;
    char buf[];
    }; struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;
    uint32_t alloc;
    unsigned char flags;
    char buf[];
    };

    会根据字符串的不同设定不同的len​和alloc​的类型,真的是极致的节省。

    而且这还没完,注意到__attribute__​,这代表取消了内存对齐!!!

    Redis​对SDS的flags参数和取消内存对齐就可以看出Redis特别看重内存的占用。

    这个也可以从Redis​其他数据结构的xx设计(todo:待总结)和Redis对LRU页面置换算法的改造 可以看出来。

    PS:你看,这不就可以扯到页面置换算法了吗。

  11. TODO :REDIS如何支持秒杀场景

  12. Redis的过期清理机制

    Redis中有一个过期字典,里面保存了所有设置了过期时间的key的过期时间。

    在查询key的时候会查询其是否存在过期字典expires和是否过期再返回。

    typedef struct redisDb {
    dict *dict; /* 数据库键空间,存放着所有的键值对 */
    dict *expires; /* 键的过期时间 */
    ....
    } redisDb;

    Redis并不会在一个key过期的瞬间去删除它,而是采用的:【惰性删除+定期删除的方式】

    惰性删除:在找到key,发现其过期的时候才删除。

    好处:惰性删除节约时间。

    坏处:一直用不到的key一直不会删除。

    定期删除:每隔一段时间(默认10s)「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key,根据情况判断是否再次检查。

    好处与坏处和 “惰性删除” 是相反的。

    注意区别“清理”key时不同的措施:主动删除:DELETE​+UNLINK​;过期清理:惰性删除+定期扫描;缓存:TTL|LRU|LFU|不删除。

    来源:Redis 过期删除策略和内存淘汰策略有什么区别?

  13. Redis过期键读取相关思考

    Redis过期键读取是属于主从延迟的一个特殊场景,原因在于:REDIS可以通过EXPIRE相关命令设置键值对自动删除,所以在主从同步的时候相对于其他命令必须有一些额外考虑。

    REDIS对于主从同步删除对象的原则是:谁生产谁删除。即对于设置了EXPIRE相关参数的对象,从节点也不会删除,而是等待主节点同步DEL命令之后再删除(包括LRU等淘汰算法的删除)。对于已经到达EXPIRE时间的对象,从节点会有一个特殊标记,让其对客户端返回空白而不是真正的删除(老版本甚至没有这个标记)。

    所以REDIS的从节点删除可以总结成:主节点驱动(driven)的删除机制。

    一个必须要这么的场景是:主节点设置了过期删除时间,然后设置了刷新这个删除时间的操作。但是后面的刷新命令延迟了。如果从节点主动删除了对象,那么就会导致异常。

    此外:设置过期时间有两种方式:

    1. EXPIRE设置过期时间长度
    2. EXPIREAT设置在某个时间点过期

    由于主从延迟,更建议使用EXPIREAT​设置时间点过期的方式来控制过期时间(此时需要保证时钟同步)。使用EXPIRE​可能由于主从延迟带来更大的延迟(比如主从延迟1秒,设置EXPIRE​ 2秒,那么可能会导致3秒后的时候从节点中这个key依然可见)。

    参考:

    https://blog.csdn.net/qq_39380192/article/details/110005067

  14. rehash的时机:包括缩容和扩容

  15. 压缩列表

    我们在开始的图中知道Redis中的list​、hash​、zset​都会在节点少的时候使用压缩列表来代替原本的链表、哈希表、跳表等结构。说明压缩列表在节点少的时候比原本的结构有很强的性能,但是在节点数量上来之后性能就没有那么大的优势了(实际上节点数量上来了性能是有很大的劣势,下面会具体讲解)。

    当然较新版的Redis​中list​已经使用了quicklist​实现,而压缩列表​也有了替代品listpack​,因此上文可以为:【Redis中的hash、zset都会在节点少的时候使用listpack来代替原本的哈希表、跳表等结构。】

    虽然压缩列表已经被替换,但是还是可以学习其优秀思想。(面试要考多嘛)

    既然压缩列表有如此厉害的特性,那我们来看看其设计到底有什么独到之处。

    压缩列表的结构设计

    压缩列表最大的特点是内存紧凑的数据结构,占用一块连续的内存空间,不仅可以利用 CPU缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

    本身是由连续的内存块构成,有点类似于数组的结构。

    可以看到其记录了一些压缩链表的信息字段,比如:

    • zlbytes​:占用内存字节数
    • zltail​:尾部距离开头多少字节,即尾部偏移
    • zllen​:整个压缩链表有多少个元素
    • zlend​:标记压缩链表的结束点,固定值0xFF​(十进制255)。

    这些字段还是比较常规的,无法体现上面说的压缩列表的正对不同长度的数据进行相应编码的特点,这个秘密常在entry的结构里:

    压缩列表节点包含三部分内容:

    • prevlen​,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
    • encoding​,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
    • data​,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

    没错,就是你想的那样,压缩链表“节约内存”的这一秘密就藏在这三个字段里面。和SDS数据结构类似,prevlenencoding都是会随着data的大小和类型来进行不同的空间大小分配,具体来说:

    • prevlenprevlen​字段记录的是前一个entry占用的字节数,如果前一个节点的长度小于 254 字节,那么 prevlen​ 属性需要用 1 字节的空间;如果前一个节点的长度大于等于 254 字节,那么 prevlen​ 属性需要用 5 字节的空间;
    • encoding 如下图所示。可以看到,在极限的情况下,甚至不需要data​(图中的content)。

    别问一字节2^8 = 256,为啥prevlen的跳变值不是256字节而是254字节,问就是我也不清楚,看的小林网站上的。

    压缩列表的缺陷

    上面说了压缩链表的好处:连续的内存空间、节省内存。也提及了在数据多的时候压缩列表并不好,那么到底为啥不好呢?主要有两点:1.查找元素需要遍历 2.连锁更新。

    查找元素需要遍历很好理解,下面主要分析连锁更新的问题。

    连锁更新这个问题的核心正是因为prevlen​和encoding​字段的变长,真是成也风云、败也风云。

    一句话总结就是如果某一个entry​的更新突破了prevlen​的临界值(或者插入一个特别大的entry​),那么下一个entry的prevlen占用字节数就会增加(从一字节变成五字节),prevlen占用变多,如果正好又突破到了临界值,那么又会导致下一个entry的prevlen占用字节数增加。。。。

    如此往复,极端情况下可能导致整个压缩链表所有的元素都进行空间扩展的操作。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」 ,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下....

    压缩列表的优势与缺陷

    • 优势:内存连续的结构;节省内存的设计(变长);
    • 缺陷:只能顺序遍历;连锁更新(内存重新分配最严重时);

极客时间《Redis核心技术与实战》阅读笔记的更多相关文章

  1. 极客时间 Mysql实战45讲 07讲行锁功过:怎么减少行锁对性能的影响笔记 极客时间

    极客时间 Mysql实战45讲 07讲行锁功过:怎么减少行锁对性能的影响笔记 极客时间极客时间 Mysql实战45讲 07讲行锁功过:怎么减少行锁对性能的影响笔记 极客时间 笔记体会: 方案一,事务相 ...

  2. Mysql实战45讲 06讲全局锁和表锁:给表加个字段怎么有这么多阻碍 极客时间 读书笔记

    Mysql实战45讲 极客时间 读书笔记 Mysql实战45讲 极客时间 读书笔记 笔记体会: 根据加锁范围:MySQL里面的锁可以分为:全局锁.表级锁.行级锁 一.全局锁:对整个数据库实例加锁.My ...

  3. Mysql实战45讲 05讲深入浅出索引(下)极客时间 读书笔记

    极客时间 Mysql实战45讲 04讲深入浅出索引(下)极客时间 笔记体会: 回表:回到主键索引树搜索的过程,称为回表覆盖索引:某索引已经覆盖了查询需求,称为覆盖索引,例如:select ID fro ...

  4. Mysql实战45讲 04讲深入浅出索引(上)读书笔记 极客时间

    极客时间 Mysql实战45讲 04讲深入浅出索引 极客时间(上)读书笔记  笔记体悟 1.索引的作用:提高数据查询效率2.常见索引模型:哈希表.有序数组.搜索树3.哈希表:键 - 值(key - v ...

  5. 【视频合集】极客时间 react实战进阶45讲 【更新中】

    https://up2.v.sharedaka.com/video/ochvq0AVfpa71A24bmugS5EewhFM1553702519936.mp4 01 React出现的历史背景及特性介绍 ...

  6. 极客时间_Vue开发实战_汇总贴

    视频地址: https://time.geekbang.org/course/intro/163 https://github.com/tangjinzhou/geektime-vue-1 电脑dem ...

  7. "做中学"之“极客时间”课程学习指导

    目录 "做中学"之"极客时间"课程学习指导 所有课程都可以选的课程 Java程序设计 移动平台开发 网络攻防实践 信息安全系统设计基础 信息安全专业导论 极客时 ...

  8. 左耳朵耗子:我为什么要在极客时间 App 开设独家专栏?

    参考链接:https://www.infoq.cn/article/2018/01/why-geektime 不少朋友都知道我在极客时间App 上开了一个收费专栏<左耳听风>,这个专栏会开 ...

  9. java爬虫系列第四讲-采集"极客时间"专栏文章、视频专辑

    1.概述 极客时间(https://time.geekbang.org/),想必大家都知道的,上面有很多值得大家学习的课程,如下图: 本文主要内容 使用webmagic采集极客时间中某个专栏课程生成h ...

  10. 如何将极客时间课程制作成kindle电子书

    订阅了几个极客时间的专栏,一直没有时间去看. 最近,想着如果把内容制作成电子书,利用上下班时间学习一下,岂不是很方便? 在网上搜到一个很好用的开源软件,几分钟就可以把极客时间的专栏做成电子书,简直太棒 ...

随机推荐

  1. 2024-2025, 四大翻译工具加AI翻译的深度对比

    前言 在过去两年中,人工智能技术的迅猛发展对翻译工具产生了深远的影响. 本期特意挑选了四款翻译工具以及一个AI翻译工具, 对其性能进行评测,看看在AI技术的加持下,它们的质量提升如何. 以下是参赛选手 ...

  2. highcharts中的环形图

    环形图如下效果: 代码: that.options = { chart: { type: 'pie', backgroundColor: 'transparent', color: '#fff', / ...

  3. Codeforces Round 887 (Div. 2)

    C. Ntarsis' Set ​ (\(1 \leq n,k \leq 2 \cdot 10^5\)) 题解:思维 + 二分 我们不妨反向考虑 由于答案最后一次一定在第一个位置 所以答案上一轮一定在 ...

  4. Vue.js 事件绑定

    1.事件监听 v-on:eventName可以简写成@eventName 事件对象:在HTML中,事件参数为$event,但是即使不传递,在回调函数中也可以直接使用event读取 <div id ...

  5. scikit-learn中的Pipeline:构建高效、可维护的机器学习流程

    我们使用scikit-learn进行机器学习的模型训练时,用到的数据和算法参数会根据具体的情况相应调整变化, 但是,整个模型训练的流程其实大同小异,一般都是加载数据,数据预处理,特征选择,模型训练等几 ...

  6. 零基础学习人工智能—Python—Pytorch学习(十)

    前言 本文的内容是来自教程视频的第十五集,个人感觉,这个教程是有点虎头蛇尾,就是前面开始的教程,是非常惊人的好,但到这里,就有点水了,可以说就是把代码一铺,然后简单介绍一遍,很多细节都没有讲,所以,我 ...

  7. 对 .NET 开发者来说,Azure AD 改名为 Microsoft Entra ID 意味着什么?

    对 .NET 开发者来说,Azure AD 改名为 Microsoft Entra ID 意味着什么? 原文地址:https://devblogs.microsoft.com/dotnet/azure ...

  8. ng-alain 创建页面

    https://ng-alain.com/cli/generate/zh https://ng-alain.com/docs/new-page/zh 默认情况下,创建模块 trade,创建在目录 sr ...

  9. [solon]Solon开发实战之权限认证

    本项目采用权限认证框架sa-token(sa-token-solon-plugin) pom.xml <!-- 鉴权--> <dependency> <groupId&g ...

  10. 【C#】【平时作业】习题-9-接口

    1.什么是接口 为派生类提供因该遵守的标准结构,而本身只包含成员声明,不包含成员的定义 2.接口与抽象类有什么区别 3.设计IBluetooth. public interface IBluetoot ...