目录
1. Redis 命令行客户端
1.1 与 Redis 服务器交互
根据上篇博客已经安装并启动了 Redis 服务,下面将介绍如何使用 redis-cli 连接、操作 Redis 服务。客户端和服务端的交互过程如下图所示。(Redis命令不区分大小写)
redis-cli 可以使用以下两种方式连接 Redis 服务器:
-
交互式方式
通过 redis-cli -h { host } -p { port } 的方式连接到 Redis 服务,后续所有的操作都是通过交互式的方式实现,不需要再执行 redis-cli 了,例如:
-
命令方式
用 redis-cli -h { host } -p { port } { command } 就可以直接得到命令的返回结果。
由于我们连接的 Redis 服务位于 127.0.0.1,端口也使用的是默认的 6379 端口,所以可以省略 -h { host } -p { port }。
Redis 客户端与服务端的交互过程:
1.2 set 和 get 命令
Redis 是按照键值对的方式存储数据的。
- get:根据 key 来取 value
- set:把 key 和 value 存储进去
注意:这里的 key 和 value 本质上都是字符串。
对于上面的 key value,不需要加上引号就是代表字符串的类型(加上也是可以的,单引号或双引号都行)直接按下 Tab,可以发现:系统会为我们自动补全命令(大写,Redis 中的命令不区分大小写)。
get 命令直接输入 key,就能得到 value。如果当前的 key 不存在,会返回 nil(nil 和 null / NULL 是一个意思)。
在学习 5 种数据结构之前,了解一下 Redis 的一些全局命令、数据结构和内部编码、单线程命令处理机制是十分必要的,它们能为后面内容的学习打下一个良好的基础。
主要体现在两个方面:
- Redis 的命令有上百个,如果纯靠死记硬背比较困难,但是如果理解 Redis 的一些机制,会发现这些命令有很强的通用性。
- Redis 不是万金油,有些数据结构和命令必须在特定场景下使用,一旦使用不当可能对 Redis 本身或者应用本身造成致命伤害。
2. 基本全局命令
Redis 有 5 种数据结构,它们都是键值对中的值,对于键来说有一些通用的命令(能够搭配任意一个数据结构来使用的命令),叫作全局命令。
2.1 keys
功能:用来查询当前服务器上匹配的 key。通过一些特殊符号(通配符)来描述 key 的模样,匹配上述模样的 key 就能被查询出来。
返回所有满足样式(pattern)的 key。支持如下统配样式:
- h?llo 匹配 hello , hallo 和 hxllo(? 匹配任意一个字符)
- h*llo 匹配 hllo 和 heeeello(* 匹配 0 个或者多个任意字符)
- h[ae]llo 匹配 hello 和 hallo 但不匹配 hillo(只能匹配到a、e,其它的不行,相当于给出固定选项)
- h[^e]llo 匹配 hallo , hbllo , ... 但不匹配 hello(排除 e,只有 e 匹配不了,其它的都能匹配)
- h[a-b]llo 匹配 hallo 和 hbllo(匹配 a~b 这个范围内的字符,包含两侧边界)
语法:
keys pattern
pattern 表示包含特殊符号的字符串。
命令有效版本:1.0.0 之后
时间复杂度:O(N)
需要把 Redis 里的所有 key 都遍历一遍,依次去看每一个 key 是否符合 pattern,符合就留下,不符合就跳过。
在生产环境上,一般都会禁止使用 keys 命令,尤其是 keys *(生产环境上的 key 可能非常多,而 Redis 是一个单线程的服务器,那么执行 keys * 的时间非常长,就会使 Redis 服务器被阻塞了,而无法给其他客户端提供服务)。
返回值:匹配 pattern 的所有 key。
示例:
2.2 exists
功能:判断某个 key 是否存在(也可以一次判断多个)。
Redis 支持很多数据结构,指的是一个 value 可以是一些复杂的数据结构。Redis 自身的这些键值对是通过哈希表的方式来组织的。Redis 具体的某个值又可以是一些数据结构。
语法:
exists key [key ...]
命令有效版本:1.0.0 之后.
时间复杂度:O(1)
Redis 组织这些 key 是按照哈希表的方式组织的,哈希表查询的复杂度就是 O(1),严谨来说,应该是查询 N 个 key 就是 O(N)。
返回值:key 存在的个数。
Redis 是一个客户端服务器结构的程序,客户端和服务器之间通过网络来进行通信。
两种写法的区别:
蓝色框(一次请求和一次响应):
红色框(一次请求和一次响应 + 一次请求和一次响应,四次网络通信,也就是两个轮次):
分开的写法会产生更多轮次的网络通信(效率低、成本高,和直接操作内存比)。
2.3 del
功能:删除指定的 key。
Redis 的主要应用场景就是作为缓存。此时,Redis 里存的只是一个热点数据,全量数据是在 MySQL 数据库中的。此时,如果删除了 Redis 中的几个 key,一般来说问题不大。但是,如果把 Redis 中一大半数据甚至是全部数据全删了,那么影响就很大(Redis 本来是帮 MySQL 负重前行的,而现在 Redis 数据没了,那么大部分的请求就会直接打给 MySQL,然后就容易把 MySQL 搞挂)。所以在相比之下,如果是 MySQL 中误删了一个数据,都可能影响很大。
如果把 Redis 作为数据库,此时误删数据的影响也是很大。
如果是把 Redis 作为消息队列(mq),此时误删数据的影响就应该根据具体问题来具体分析了。
语法:del key [key ...]
命令有效版本:1.0.0 之后
时间复杂度:O(1)
返回值:删除掉的 key 的个数。
示例:
2.4 expire
功能:为指定的 key(key 已存在,否则设置失败)添加秒级的过期时间(Time To Live TTL)。
pexpire(毫秒级)
key 的存活时间超出这个指定值就会被自动删除。业务场景举例:手机发送验证码(60s)、外卖优惠券(7天)、基于 Redis 实现的分布式锁(给 Redis 里写一个特殊的 key value,删除就是解锁。为了避免出现不能正确解锁的情况,通常都会在加锁的时候设置过期时间)。
语法:expire key seconds
命令有效版本:1.0.0 之后
时间复杂度:O(1)
返回值:1 表示设置成功,0 表示设置失败。
示例:
2.5 ttl
功能:获取指定 key 的过期时间,秒级。
IP 协议报头中有一个字段:ttl,它不是用时间来衡量过期的,而是用次数。
语法:ttl key
命令有效版本:1.0.0 之后
时间复杂度:O(1)
返回值:剩余过期时间。-1 表示没有关联过期时间,-2 表示 key 不存在。
示例:
键的过期机制:
expire 和 ttl 命令都有对应的⽀持毫秒为单位的版本:pexpire 和 pttl。
Redis 的 key 的过期策略是如何实现的呢?(一个 Redis 中可能同时存在很多 key,这些 key 中可能有很大一部分都有过期时间,那么此时 Redis 如何知道哪些 key 已经过期要被删除,哪些 key 还没过期呢?)
如果直接遍历所有的 key 显然是不行的,效率非常低。Redis 整体的策略是:
- 定期删除(每次抽取一部分进行验证过期时间,保证这个抽取检查的过程足够快)
- 惰性删除(假设这个 key 已经到过期时间了,但是暂时还没删除,key 还存在,紧接着后面又有一次访问,正好用到了这个 key,于是这次访问就会让 Redis 服务器触发删除 key 的操作,同时再返回一个 nil)
为什么这里对于定期删除的时间有明确的要求呢?
因为 Redis 是单线程的程序,它的主要任务有:处理每个命令的任务、扫描过期的 key 等等,如果扫描过期 key 消耗的时间太多,那么正常处理请求命令就被阻塞了(产生了类似于执行 keys* 这样的效果。
虽然有上面讲到的两种策略结合,但整体的结果一般,仍然可能会有很多过期的 key 被残留,没有及时删除掉。Redis 为了对上述进行补充,还提供了一系列的内存淘汰策略。
如果有多个 key 过期,也可以通过一个定时器(基于优先级队列或者时间轮都可以实现比较高效的定时器)来高效 / 节省 CPU 的前提下来处理多个 key。但 Redis 并没有采取定时器的方式来实现过期 key 删除。(猜测:基于定时器实现,就需要引入多线程,但 Redis 的早起版本就奠定了单线程的基调,如果引入多线程就打破了初衷)。
定时器:在某个时间到达之后,执行指定的任务,它是基于优先级队列 / 堆的(一般的队列是先进先出,而优先级队列则是按照指定的优先级(自定义)先出)。在 Redis 过期 key 的场景中,就可以通过 “过期时间越早,就是优先级越高”。此时定时器只需要分配一个线程,不需要遍历所有的 key,只需要让这个线程去检查队首元素,看是否过期即可。如果队首元素还没过期,那么后续元素一定没过期。另外,在扫描线程检查队首元素过期时间时,也不能检查的太频繁,此时可以根据时刻和队首元素的过期时间设置一个等待,当时间差不多到了,系统再唤醒这个线程(可以节省 CPU 的开销)。
万一在线程休眠时,来了一个新的任务呢?可以在新任务添加时,唤醒刚才的线程,重新检查一下队首元素,再根据时间差距重新调整阻塞时间即可。
基于时间轮实现的定时器(把时间划分成很多小段,具体划分的粒度看实际需求):
每个小段都挂着一个链表,每个链表都代表一个要执行的任务(相当于一个函数指针以及对应的参数)。假设需要添加一个 key,这个 key 在 300ms 之后过期。此时这个指针就会每隔固定的时间间隔(此处约定时 100ms)往后走一个,每次走到一个格子就会把这个格子上链表的任务尝试执行一下。
对于时间轮来说,每个格子是多少时间,一共有多少个格子都是需要根据实际场景来灵活调配的。
2.6 type
功能:返回 key 对应的数据类型。
此处 Redis 所有的 key 都是 string,key 对应的 value 可能会存在多种类型。
语法:type key
命令有效版本:1.0.0 之后
时间复杂度:O(1)
返回值:none,string,list,set,zset,hash,stream
Redis 作为消息队列时,使用 stream 作为返回值类型。
在 Redis 中,上述几种类型的操作方式差别很大,使用的命令都是完全不同的。
示例:
3. 数据结构和内部编码
type 命令实际返回的就是当前键的数据结构类型,它们分别是:string(字符串)、list(列表)、hash(哈希)、set(集合)、zset(有序集合),但这些只是 Redis 对外的数据结构,如下图所示:
Redis 的 5 种主要的数据类型:
Redis 底层在实现上述数据结构时,会在源码底层针对上述实现进行特定的优化(内部具体实现的数据结构(编码方式)还会有变数),来达到节省时间 / 空间的效果。
实际上 Redis 针对每种数据结构都有自己的底层内部编码实现,而且是多种实现,这样 Redis 会在合适的场景选择合适的内部编码,如下表所示:
Redis 数据结构和内部编码:
从 Redis 3.2 开始,list 引入了新的实现方式:quicklist,它同时兼顾了 linkedlist 和 ziplist 的优点。quicklist 就是一个链表,每个元素又是一个 ziplist(空间和效率都折中兼顾到),类似于 C++ 中的 std::deque。
可以看到每种数据结构都有至少两种以上的内部编码实现,例如 list 数据结构包含了 linkedlist 和 ziplist 两种内部编码。同时有些内部编码,例如 ziplist,可以作为多种数据结构的内部实现,可以通过 object encoding 命令查询内部编码:
Redis 这样设计有两个好处:
- 可以改进内部编码,而对外的数据结构和命令没有任何影响,这样一旦开发出更优秀的内部编码,无需改动外部数据结构和命令。例如 Redis 3.2 提供了 quicklist,结合了 ziplist 和 linkedlist 两者的优势,为列表类型提供了一种更为优秀的内部编码实现,而对用户来说基本无感知。
- 多种内部编码实现可以在不同场景下发挥各自的优势,例如 ziplist 比较节省内存,但是在列表元素比较多的情况下,性能会下降,这时候 Redis 会根据配置选项将列表类型的内部实现转换为 linkedlist,整个过程用户同样无感知。
4. 单线程架构
Redis 使用了单线程架构来实现高性能的内存数据库服务。
Redis 只使用一个线程处理所有的命令请求,并不是说一个 Redis 服务器进程内部真的就只有一个线程,其实也有多个线程,但这多个线程是在处理网络 IO。
下面将先通过多个客户端命令调用的例子说明 Redis 单线程命令处理机制,接着分析 Redis 单线程模型为什么性能如此之高,最终给出为什么理解单线程模型是使用和运维 Redis 的关键。
现在开启了两个 redis-cli 客户端同时执行命令。
客户端 1 对 counter 做自增操作:
127.0.0.1:6379> incr counter
客户端 2 对 counter 做自增操作:
127.0.0.1:6379> incr counter
宏观上,2 个客户端是同时请求 Redis 服务的:
incr 就是 increase 自增,作用是把 key 的 value 进行 +1 操作。
线程安全问题:在多线程中,针对类似于这样的场景,两个线程尝试同时对同一个变量进行自增,表面上看是自增两次,实际上可能只自增了一次。
从客户端发送的命令经历:发送命令、执行命令、返回结果三个阶段,其中重点关注第 2 步。所谓的 Redis 是采用单线程模型执行命令的是指:虽然两个客户端看起来是同时要求 Redis 去执行命令的,也相当于 “并发” 的发起了上述的请求。但从微观角度来看,Redis 是串行 / 顺序执行这多个命令的,这些命令还是采用线性方式去执行的,只是原则上命令的执行顺序是不确定的,但一定不会有两条命令被同步执行,如下图(Redis 的单线程模型)所示,可以想象 Redis 内部只有一个服务窗口,多个客户端按照它们达到的先后顺序被排队在窗口前,依次接受 Redis 的服务,所以两条 incr 命令无论执行顺序,结果一定是 2,不会发生并发问题,这个就是 Redis 的单线程执行模型,保证了当前收到的这多个请求是串行执行的,所以不会发生上述类似的线程安全问题。多个请求同时到达 Redis 服务器,也是要先在队列中排队,再等待 Redis 服务器一个个的取出里面的命令再执行。
微观上,客户端发送命令的时间有先后次序的:
Redis 的单线程模型:
Redis 虽然是单线程模型,但为什么效率这么高,速度还能这么快呢?(参照物:MySQL、Oracle、Sql Server)
通常来讲,单线程处理能力要比多线程差,例如有 10000 公斤货物,每辆车的运载能力是每次 200 公斤,那么要 50 次才能完成;但是如果有 50 辆车,只要安排合理,只需要依次就可以完成任务。那么为什么 Redis 使用单线程模型会达到每秒万级别的处理能力呢?可以将其归结为三点:
- Redis 是纯内存访问,而数据库是访问硬盘。Redis 将所有数据放在内存中,内存的响应时长大约为 100ns,这是 Redis 达到每秒万级别访问的重要基础。Redis 的核心功能比数据库的核心功能更简单。(数据库对于数据的插入删除查询... 都有更复杂的功能支持,这样的功能势必要花费更多的开销。比如,针对插入删除,数据库中的各种约束都会使数据库做额外的工作)
- Redis 是单线程模型,避免了线程切换和竞态产生的消耗。Redis 的每个基本操作都是 “短平快” 的,就是简单操作一下内存数据,不是特别消耗 CPU 的操作。就算搞多个线程,提升也不大。单线程可以简化数据结构和算法的实现,让程序模型更简单;其次多线程避免了在线程竞争同一份共享数据时带来的切换和等待消耗。
- 非阻塞 IO。Redis 使用 epoll 作为 I/O 多路复用技术的实现,再加上 Redis 自身的事件处理模型将 epoll 中的连接、读写、关闭都转换为事件,不在网络 I/O 上浪费过多的时间,如下图所示。(本质上就是一个线程可以管理多个 socket。针对 TCP 来说,服务器这边每次要服务一个客户端都需要给这个客户端安排一个 socket。假设一个服务器服务多个客户端,同时就会有很多个 socket,但这些 socket 上并不是无时不刻都在传输数据。很多情况下,每个客户端和服务器之间的通信并没有那么频繁,此时这么多的 socket 大部分时间都是静默的,上面是没有数据需要传输的。也就是说,同一时刻只有少数 socket 是活跃的)。
Redis 使用 I/O 多路复用模型:
虽然单线程给 Redis 带来很多好处,但还是有一个致命的问题:对于单个命令的执行时间都是有要求的。如果某个命令执行过长,会导致其他命令全部处于等待队列中,迟迟等不到响应,造成客户端的阻塞,对于 Redis 这种高性能的服务来说是非常严重的,所以 Redis 是面向快速执行场景的数据库。
本篇完。
本专栏下一阶段将逐步讲解 Redis 五大主要数据结构的知识和应用。