赞
踩
Redis 提供了 5 种数据结构,理解每种数据结构的特点对于 Redis 开发运维非常重要,同时掌握每种数据结构的常见命令,会在使用 Redis 的时候做到游刃有余。
官方文档:Commands | Docs (redis.io)
Redis 是按照键值对的方式存储数据的。
注意:这里的 key 和 value 本质上都是字符串。
对于上面的 key value,不需要加上引号就是代表字符串的类型(加上也是可以的,单引号或双引号都行)
直接按下 Tab,可以发现:系统会为我们自动补全命令(大写,Redis 中的命令不区分大小写)。
get 命令直接输入 key,就能得到 value。如果当前的 key 不存在,会返回 nil(nil 和 null / NULL 是一个意思)。
在学习 5 种数据结构之前,了解一下 Redis 的一些全局命令、数据结构和内部编码、单线程命令处理机制是十分必要的,它们能为后面内容的学习打下一个良好的基础。
主要体现在两个方面:
Redis 的命令有上百个,如果纯靠死记硬背比较困难,但是如果理解 Redis 的一些机制,会发现这些命令有很强的通用性。
Redis 不是万金油,有些数据结构和命令必须在特定场景下使用,一旦使用不当可能对 Redis 本身或者应用本身造成致命伤害。
Redis 有 5 种数据结构,它们都是键值对中的值,对于键来说有一些通用的命令(能够搭配任意一个数据结构来使用的命令),叫作全局命令。
用来查询当前服务器上匹配的 key。通过一些特殊符号(通配符)来描述 key 的模样,匹配上述模样的 key 就能被查询出来。
返回所有满足样式(pattern)的 key。支持如下统配样式:
语法:
KEYS patternpattern 表示包含特殊符号的字符串。
命令有效版本:
1.0.0 之后
时间复杂度:
O(N)
需要把 Redis 里的所有 key 都遍历一遍,依次去看每一个 key 是否符合 pattern,符合就留下,不符合就跳过。
在生产环境上,一般都会禁止使用 keys 命令,尤其是 keys *(生产环境上的 key 可能非常多,而 Redis 是一个单线程的服务器,那么执行 keys * 的时间非常长,就会使 Redis 服务器被阻塞了,而无法给其他客户端提供服务)。
返回值:
匹配 pattern 的所有 key。
示例:
判断某个 key 是否存在(也可以一次判断多个)。
Redis 支持很多数据结构,指的是一个 value 可以是一些复杂的数据结构。Redis 自身的这些键值对是通过哈希表的方式来组织的。Redis 具体的某个值又可以是一些数据结构。
语法:
EXISTS key [key ...]
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)Redis 组织这些 key 是按照哈希表的方式组织的,哈希表查询的复杂度就是 O(1),严谨来说,应该是查询 N 个 key 就是 O(N)。
返回值:
key 存在的个数。
示例:
Redis 是一个客户端服务器结构的程序,客户端和服务器之间通过网络来进行通信。
两种写法的区别:
蓝色框(一次请求和一次响应):
红色框(一次请求和一次响应 + 一次请求和一次响应,四次网络通信,也就是两个轮次):
分开的写法会产生更多轮次的网络通信(效率低、成本高,和直接操作内存比)。
删除指定的 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 的个数。
示例:
为指定的 key(key 已存在,否则设置失败)添加秒级的过期时间(Time To Live TTL)。
PEXPIRE(毫秒级)
key 的存活时间超出这个指定值就会被自动删除。业务场景举例:手机发送验证码(60s)、外卖优惠券(7天)、基于 Redis 实现的分布式锁(给 Redis 里写一个特殊的 key value,删除就是解锁。为了避免出现不能正确解锁的情况,通常都会在加锁的时候设置过期时间)。
语法:
EXPIRE key seconds
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
1 表示设置成功,0 表示设置失败。
示例:
获取指定 key 的过期时间,秒级。
IP 协议报头中有一个字段:TTL,它不是用时间来衡量过期的,而是用次数。
语法:
TTL key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
剩余过期时间。-1 表示没有关联过期时间,-2 表示 key 不存在。
示例:
键的过期机制:
tips :EXPIRE 和 TTL 命令都有对应的⽀持毫秒为单位的版本:PEXPIRE 和 PTTL。
如果直接遍历所有的 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)往后走一个,每次走到一个格子就会把这个格子上链表的任务尝试执行一下。
对于时间轮来说,每个格子是多少时间,一共有多少个格子都是需要根据实际场景来灵活调配的。
返回 key 对应的数据类型。
此处 Redis 所有的 key 都是 string,key 对应的 value 可能会存在多种类型。
语法:
TYPE key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
none,string,list,set,zset,hash,stream
Redis 作为消息队列时,使用 stream 作为返回值类型。
在 Redis 中,上述几种类型的操作方式差别很大,使用的命令都是完全不同的。
示例:
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,整个过程用户同样无感知。
Redis 使用了单线程架构来实现高性能的内存数据库服务。
Redis 只使用一个线程处理所有的命令请求,并不是说一个 Redis 服务器进程内部真的就只有一个线程,其实也有多个线程,但这多个线程是在处理网络 IO。
下面将先通过多个客户端命令调用的例子说明 Redis 单线程命令处理机制,接着分析 Redis 单线程模型为什么性能如此之高,最终给出为什么理解单线程模型是使用和运维 Redis 的关键。
现在开启了两个 redis-cli 客户端同时执行命令。
客户端 1 对 counter 做自增操作:
127.0.0.1:6379> incr counter
客户端 1 对 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 的单线程模型:
通常来讲,单线程处理能力要比多线程差,例如有 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 最基础的数据类型,关于字符串需要特别注意:
由于 Redis 内部存储字符串完全是按照二进制流的形式保存的,所以 Redis 是不处理字符集编码问题的,客户端传入的命令中使用的是什么字符集编码,就存储什么字符集编码。
字符串数据类型:
将 string 类型的 value 设置到 key 中。如果 key 之前存在,则覆盖,无论原来的数据类型是什么。之前关于此 key 的 TTL 也全部失效。
语法:
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
选项:
SET 命令支持多种选项来影响它的行为:
注意:由于带选项的 SET 命令可以被 SETNX 、 SETEX 、 PSETEX 等命令代替,所以之后的版本中,Redis 可能进行合并。
返回值:
- 如果设置成功,返回 OK。
- 如果由于 SET 指定了 NX 或者 XX 但条件不满足,SET 不会执行,并返回 (nil)。
FLUSHALL:表示清空所有数据(类似于 MySQL 里的 drop database)。
示例:
获取 key 对应的 value。如果 key 不存在,返回 nil。如果 value 的数据类型不是 string,会报错。
对于 GET 来说,只是支持字符串类型的 value,如果 value 是其他类型,那么使用 GET 获取就会出错。
语法:
GET key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
key 对应的 value,或者 nil 当 key 不存在。
示例:
一次性获取多个 key 的值。如果对应的 key 不存在或者对应的数据类型不是 string,返回 nil。
语法:
MGET key [key ...]
命令有效版本:
1.0.0 之后
时间复杂度:
O(N) N 是 key 数量
返回值:
对应 value 的列表。
示例:
一次性设置多个 key 的值。
语法:
MSET key value [key value ...]
命令有效版本:
1.0.1 之后
时间复杂度:
O(N) N 是 key 数量
返回值:
永远是 OK
示例:
多次 get VS 单次 mget:
使用 mget / mset 由于可以有效地减少了网络时间,所以性能相较更高。假设网络耗时 1 毫秒,命令执行时间耗时 0.1 毫秒,则执行时间如下表所示:
学会使用批量操作,可以有效提高业务处理效率,但是要注意,每次批量操作所发送的键的数量也不是无节制的,否则可能造成单一命令执行时间过长,导致 Redis 阻塞。
设置 key-value 但只允许在 key 之前不存在的情况下。
语法:
SETNX key value
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
1 表示设置成功,0 表示没有设置。
示例:
SET、SET NX 和 SET XX 执行流程:
将 key 对应的 string 表示的数字加一。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型(相当于 C++ 中的 long long),则报错。
语法:
INCR key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
integer 类型的加完后的数值。
示例:
incr 操作的 key 如果不存在,就会把这个 key 的 value 当作 0 来使用。
将 key 对应的 string 表示的数字加上对应的值。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型,则报错。
语法:
INCRBY key decrement
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
integer 类型的加完后的数值。
示例:
将 key 对应的 string 表示的数字减一。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型,则报错。运算结果也是计算之后的值。
语法:
DECR key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
integer 类型的减完后的数值。
示例:
将 key 对应的 string 表示的数字减去对应的值。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的 string 不是一个整型或者范围超过了 64 位有符号整型,则报错。
语法:
DECRBY key decrement
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
integer 类型的减完后的数值。
示例:
将 key 对应的 string 表示的浮点数加上对应的值。如果对应的值是负数,则视为减去对应的值。如果 key 不存在,则视为 key 对应的 value 是 0。如果 key 对应的不是 string,或者不是一个浮点数,则报错。允许采用科学计数法表示浮点数。
语法:
INCRBYFLOAT key increment
命令有效版本:
2.6.0 之后
时间复杂度:
O(1)
返回值:
加 / 减完后的数值。
示例:
很多存储系统和编程语言内部使用 CAS 机制实现计数功能,会有一定的 CPU 开销,但在 Redis 中完全不存在这个问题,因为 Redis 是单线程架构,任何命令到了 Redis 服务端都要顺序执行。
如果 key 已经存在并且是⼀个 string,命令会将 value 追加到原有 string 的后边。如果 key 不存在,则效果等同于 SET 命令。
语法:
APPEND KEY VALUE
命令有效版本:
2.0.0 之后
时间复杂度:
O(1) 追加的字符串一般长度较短,可以视为 O(1)
返回值:
追加完成之后 string 的长度。
append 的返回值长度的单位是字节,Redis 的字符串不会对字符编码做任何处理。
示例:
当前 XShell 终端默认的字符编码是 utf-8,在终端中输入汉字之后,也就是按照 utf8 编码。一个汉字在 utf8 字符集中通常是 3 个字节的。
在启动 Redis 客户端时,加上一个 --raw 这样的选项,就可以使 Redis 客户端能够自动的把二进制数据尝试翻译。
返回 key 对应的 string 的子串,由 start 和 end 确定(左闭右闭,是闭区间)。可以使用负数表示倒数,-1 代表倒数第一个字符(下标为 len - 1 的元素),-2 代表倒数第二个,其他的与此类似。超过范围的偏移量会根据 string 的长度调整成正确的值。
语法:
GETRANGE key start end
命令有效版本:
2.4.0 之后
时间复杂度:
O(N) N 为 [start, end] 区间的长度,由于 string 通常比较短,可以视为是 O(1)
返回值:
string 类型的子串
示例:
如果字符串中保存的是汉字,此时进行子串切分很可能切出来的就不是完整的汉字了。上述的代码是强行切出了中间的四个字节,这么一切,切出的结果在 utf8 码表上就不知道能查出什么了。上述问题在 C++ 中也同样存在(C++ 字符串中的基本单位是字节),需要我们手动处理。但 Java 就不会(Java 中字符串的基本单位是字符,占 2 个字节的字符),Java 中相当于 String 帮我们把汉字的编码转换都处理好了。
覆盖字符串的一部分,从指定的偏移开始。
语法:
SETRANGE key offset value
命令有效版本:
2.2.0 之后
时间复杂度:
O(N) N 为 value 的长度,由于一般给的 value 比较短,通常视为 O(1)。
返回值:
替换后的 string 的长度。
示例:
如果 value 是一个中文字符串,进行 setrange 时是可能会出问题的。
这里凭空生成了一个字节,这个字节里的内容就是 "0x00",aaa 就被追加到 "0x00" 后面了。setange 针对不存在的 key 也是可以操作的,不过会把 offset 之前的内容填充成 "0x00"。
获取 key 对应的 string 的长度。当 key 存放的类型不是 string 时,报错。
语法:
STRLEN key
命令有效版本:
2.2.0 之后
时间复杂度:
O(1)
返回值:
string 的长度。或者当 key 不存在时,返回 0。
单位是字节。(在 C++ 中,字符串的长度本身就是用字节为单位的)
在 MySQL 中,varchar(N) 的 N 的单位就是字符,MySQL 中的字符也是完整的汉字,这样的一个字符也可能是多个字节。
示例:
下表是字符串类型命令的效果、时间复杂度:
字符串类型的内部编码有 3 种:
Redis 会根据当前值的类型和长度动态决定使用哪种内部编码实现。
整型类型示例如下:
短字符串示例如下:
Redis 存储小数,本质上还是当作字符串来存储,这就和整数相比差别很大了。整数直接使用 int 来存储(准确来说是一个 long long(C++)),比较方便进行算术运算。小数则是使用字符串来存储,意味着每次进行算术运算都需要把字符串转成小数来进行运算,结果再转回字符串保存。
长字符串示例如下:
下图是比较典型的缓存使用场景,其中 Redis 作为缓冲层,MySQL 作为存储层,绝大部分请求的数据都是从 Redis 中获取。由于 Redis 具有支撑高并发的特性,所以缓存通常能起到加速读写和降低后端压力的作用。
Redis + MySQL 组成的缓存存储架构:
整体思路:应用服务器访问数据时,先查询 Redis。如果 Redis 上数据存在,就直接从 Redis 中取出数据交给应用服务器,不继续访问数据库了。如果 Redis 上数据不存在,此时再读取 MySQL,把读到的结果返回给应用服务器,同时把这个数据也写入到 Redis 中。
- 在把数据写给 Redis 的同时,给这个 key 设置一个过期时间。
- Redis 也在内存不足时,提供了淘汰策略。
下面的伪代码模拟了上图的业务数据访问过程:
- UserInfo getUserInfo(long uid) {
- ...
- }
- // 根据 uid 得到 Redis 的键
- String key = "user:info:" + uid;
-
- // 尝试从 Redis 中获取对应的值
- String value = Redis 执⾏命令:get key;
-
- // 如果缓存命中(hit)
- if (value != null) {
- // 假设我们的⽤⼾信息按照 JSON 格式存储
- UserInfo userInfo = JSON 反序列化(value);
- return userInfo;
- }
- // 如果缓存未命中(miss)
- if (value == null) {
- // 从数据库中,根据 uid 获取⽤⼾信息
- UserInfo userInfo = MySQL 执⾏ SQL:select * from user_info where uid = <uid>
-
- // 如果表中没有 uid 对应的⽤⼾信息
- if (userInfo == null) {
- 响应 404
- return null;
- }
-
- // 将⽤⼾信息序列化成 JSON 格式
- String value = JSON 序列化(userInfo);
-
- // 写⼊缓存,为了防⽌数据腐烂(rot),设置过期时间为 1 ⼩时(3600 秒)
- Redis 执⾏命令:set key value ex 3600
-
- // 返回⽤⼾信息
- return userInfo;
- }
通过增加缓存功能,在理想情况下,每个用户信息,一个小时期间只会有一次 MySQL 查询,极大地提升了查询效率,也降低了 MySQL 的访问数。
与 MySQL 等关系型数据库不同的是,Redis 没有表、字段这种命名空间,而且也没有对键名有强制要求(除了不能使用一些特殊字符)。但设计合理的键名,有利于防止键冲突和项目的可维护性,比较推荐的方式是使用 “业务名:对象名:唯一标识:属性” 作为键名。例如:MySQL 的数据库名为 vs,用户表名为 user_info,那么对应的键可以使用 "vs:user_info:6379"、"vs:user_info:6379:name" 来表示,如果当前 Redis 只会被一个业务使用,可以省略业务名 "vs:"。如果键名过程,则可以使用团队内部都认同的缩写替代,例如:"user:6379:friends:messages:5217" 可以被 "u:6379:fr:m:5217" 代替。毕竟键名过长,还是会导致 Redis 的性能明显下降的。
许多应用都会使用 Redis 作为计数的基础工具,它可以实现快速计数、查询缓存的功能,同时数据可以异步处理或者落地到其他数据源。如下图所示,例如视频网站的视频播放次数可以使用 Redis 来完成:用户每播放⼀次视频,相应的视频播放数就会自增 1。
记录视频播放次数:
这里写入统计数据仓库(可能是 MySQL,也可能是 HDFS)的步骤往往是异步的,所以并不是说来一个播放请求,这里就必须立即马上写一个数据。
- // 在 Redis 中统计某视频的播放次数
- long incrVideoCounter(long vid) {
- key = "video:" + vid;
- long count = Redis 执⾏命令:incr key
- return counter;
- }
实际中要开发一个成熟、稳定的真实计数系统,要面临的挑战远不止如此简单:防作弊、按照不同维度计数、避免单点问题、数据持久化到底层数据源等。
如下图所示,一个分布式 Web 服务将用户的 Session 信息(例如用户登录信息)保存在各自的服务器中,但这样会造成一个问题:出于负载均衡的考虑,分布式服务会将用户的访问请求均衡到不同的服务器上,并且通常无法保证用户每次请求都会被均衡到同一台服务器上,这样当用户刷新一次访问是可能会发现需要重新登录,这个问题是用户无法容忍的。
Session 分散存储:
为了解决这个问题,可以使用 Redis 将用户的 Session 信息进行集中管理,如下图所示,在这种模式下,只要保证 Redis 是高可用和可扩展性的,无论用户被均衡到哪台 Web 服务器上,都集中从 Redis 中查询、更新 Session 信息。
Redis 集中管理 Session:
很多应用出于安全考虑,会在每次进行登录时,让用户输入手机号并且配合给手机发送验证码,然后让用户再次输入收到的验证码并进行验证,从而确定是否是用户本人。为了短信接口不会频繁访问,会限制用户每分钟获取验证码的频率,例如一分钟不能超过 5 次,如下图所示:
短信验证码:
此功能可以用以下伪代码说明基本实现思路:
- String 发送验证码(phoneNumber) {
- key = "shortMsg:limit:" + phoneNumber;
- // 设置过期时间为 1 分钟(60 秒)
- // 使⽤ NX,只在不存在 key 时才能设置成功
- bool r = Redis 执⾏命令:set key 1 ex 60 nx
- if (r == false) {
- // 说明之前设置过该手机的验证码了
- long c = Redis 执⾏命令:incr key
- if (c > 5) {
- // 说明超过了⼀分钟 5 次的限制了
- // 限制发送
- return null;
- }
- }
-
- // 说明要么之前没有设置过手机的验证码;要么次数没有超过 5 次
- String validationCode = ⽣成随机的 6 位数的验证码();
-
- validationKey = "validation:" + phoneNumber;
- // 验证码 5 分钟(300 秒)内有效
- Redis 执⾏命令:set validationKey validationCode ex 300;
-
- // 返回验证码,随后通过手机短信发送给用户
- return validationCode ;
- }
-
- // 验证用户输入的验证码是否正确
- bool 验证验证码(phoneNumber, validationCode) {
- validationKey = "validation:" + phoneNumber;
-
- String value = Redis 执⾏命令:get validationKey;
- if (value == null) {
- // 说明没有这个手机的验证码记录,验证失败
- return false;
- }
-
- if (value == validationCode) {
- return true;
- } else {
- return false;
- }
- }
以上介绍了使用 Redis 的字符串数据类型可以使用的几个场景,但其适用场景远不止于此,开发人员可以结合字符串类型的特点以及提供的命令,充分发挥自己的想象力,在自己的业务中去找到合适的场景去使用 Redis 的字符串类型。
几乎所有的主流编程语言都提供了哈希(hash)类型,它们的叫法可能是哈希、字典、关联数组、映射。在 Redis 中,哈希类型是指值本身又是⼀个键值对结构,形如 key = "key",value = { { field1, value1 }, ..., { fieldN, valueN } },Redis 键值对和哈希类型二者的关系可以用下图来表示。
字符串和哈希类型对比:
哈希类型中的映射关系通常称为 field-value,用于区分 Redis 整体的键值对(key-value),注意这里的 value 是指 field 对应的值,不是键(key)对应的值,请注意 value 在不同上下文的作用。
设置 hash 中指定的字段(field)的值(value)。
语法:
HSET key field value [field value ...]
命令有效版本:
2.0.0 之后
时间复杂度:
插⼊一组 field 为 O(1),插⼊ N 组 field 为 O(N)
返回值:
添加的字段的个数,也就是设置成功的键值对的个数。
示例:
获取 hash 中指定字段的值。
语法:
HGET key field
命令有效版本:
2.0.0 之后
时间复杂度:
O(1)
返回值:
字段对应的值或者 nil。
示例:
判断 hash 中是否有指定的字段。
语法:
HEXISTS key field
命令有效版本:
2.0.0 之后
时间复杂度:
O(1)
返回值:
1 表示存在,0 表示不存在。
示例:
删除 hash 中指定的字段。
语法:
HDEL key field [field ...]
命令有效版本:
2.0.0 之后
时间复杂度:
删除一个元素为 O(1),删除 N 个元素为 O(N)。
返回值:
本次操作删除的字段个数。
示例:
获取 hash 中的所有字段。
语法:
HKEYS key
命令有效版本:
2.0.0 之后
时间复杂度:
O(N) N 为 field 的个数,当前的 O(N) 可以说成是 O(1)。
返回值:
字段列表。
示例:
获取 hash 中的所有的值。
语法:
HVALS key
命令有效版本:
2.0.0 之后
时间复杂度:
O(N) N 为 field 的个数。
如果 field(哈希)非常大,那么这个操作就可能导致 Redis 服务器被阻塞住。
返回值:
所有的值。
示例:
获取 hash 中的所有字段以及对应的值。
这个操作的风险比较大,但多数情况下,我们不需要查询所有的 field,可能只查其中几个 field。
语法:
HGETALL key
命令有效版本:
2.0.0 之后
时间复杂度:
O(N) N 为 field 的个数。
返回值:
字段和对应的值。
示例:
此处前面的序号仅仅是标识下返回元素的顺序,和下标无关,hash 类型没有下标的概念。
一次获取 hash 中多个字段的值。
语法:
HMGET key field [field ...]
命令有效版本:
2.0.0 之后
时间复杂度:
只查询⼀个元素为 O(1),查询多个元素为 O(N) N 为查询元素个数。
返回值:
字段对应的值或者 nil。
示例:
注意:多个 value 的顺序和 field 的顺序是匹配的。
在使用命令 HKEYS,HVALS,HGETALL 完成所有的遍历操作时,都是存在一定风险的,如果 hash 的元素个数太多,执行的耗时就比较长,那么就会存在阻塞 Redis 的可能。
如果开发人员只需要获取部分 field,可以使用 HMGET,如果一定要获取全部 field,可以尝试使用 HSCAN 命令,该命令采用渐进式遍历哈希类型(敲一次命令,遍历一小部分,时间是可控的,连续执行多次就可以完成整个遍历过程)。
有的,但是并不需要使用,因为 hset 已经支持一次设置多个 field 和 value 了。
获取 hash 中的所有字段的个数。
语法:
HLEN key
命令有效版本:
2.0.0 之后
时间复杂度:
O(1)
返回值:
字段个数。
示例:
在字段不存在的情况下,设置 hash 中的字段和值。
语法:
HSETNX key field value
命令有效版本:
2.0.0 之后
时间复杂度:
O(1)
返回值:
1 表示设置成功,0 表示失败。
示例:
将 hash 中字段对应的数值添加指定的值。
语法:
HINCRBY key field increment
命令有效版本:
2.0.0 之后
时间复杂度:
O(1)
返回值:
该字段变化之后的值。
示例:
HINCRBY 的浮点数版本。
语法:
HINCRBYFLOAT key field increment
命令有效版本:
2.6.0 之后
时间复杂度:
O(1)
返回值:
该字段变化之后的值。
示例:
下表是哈希类型命令的效果、时间复杂度:
哈希的内部编码有两种:
- ziplist(压缩列表):当哈希类型元素个数小于 hash-max-ziplist-entries 配置(默认 512 个)、同时所有值都小于 hash-max-ziplist-value 配置(默认 64 字节)时(这两个配置项是可以写到 redis.conf 文件中的),Redis 会使用 ziplist 作为哈希的内部实现,ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀。
- hashtable(哈希表):当哈希类型无法满足 ziplist 的条件时,Redis 会使用 hashtable 作为哈希的内部实现,因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O(1)。
下面的示例演示了哈希类型的内部编码,以及响应的变化。
下图为关系型数据表记录的两条用户信息,用户的属性表现为表的列,每条用户信息表现为行。
关系型数据表保存用户信息:
如果映射关系表示这两个用户信息,则下图所示:
映射关系表示用户信息:
不存储这个 uid 也可以。但是在工程实践中,一般都会把 uid 在 value 中再存一份,后续写到相关的代码时,使用起来会比较方便。
如果使用 string(JSON)的格式来表示 UserInfo,万一只想要获取其中的某个 field 或者修改某个 field,就需要把整个 JSON 都读出来,解析成对象,操作 field,再重写转成 JSON 字符串,再写回去。
相比于使用 JSON 格式的字符串缓存用户信息,哈希类型变得更加直观,并且在更新操作上变得更灵活,可以使用 field 表示对象的每个属性(数据表的每个列),此时就可以很方便的修改 / 获取任何一个属性的值了。可以将每个用户的 id 定义为键后缀,多对 field-value 对应用户的各个属性,类似如下伪代码:
- UserInfo getUserInfo(long uid) {
- // 根据 uid 得到 Redis 的键
- String key = "user:" + uid;
-
- // 尝试从 Redis 中获取对应的值
- userInfoMap = Redis 执⾏命令:hgetall key;
-
- // 如果缓存命中(hit)
- if (value != null) {
- // 将映射关系还原为对象形式
- UserInfo userInfo = 利⽤映射关系构建对象(userInfoMap);
- return userInfo;
- }
-
- // 如果缓存未命中(miss)
- // 从数据库中,根据 uid 获取⽤⼾信息
- UserInfo userInfo = MySQL 执⾏ SQL:select * from user_info where uid = <uid>
-
- // 如果表中没有 uid 对应的⽤⼾信息
- if (userInfo == null) {
- 响应 404
- return null;
- }
-
- // 将缓存以哈希类型保存
- Redis 执⾏命令:hmset key name userInfo.name age userInfo.age city userInfo.city
-
- // 写⼊缓存,为了防⽌数据腐烂(rot),设置过期时间为 1 ⼩时(3600 秒)
- Redis 执⾏命令:expire key 3600
-
- // 返回⽤⼾信息
- return userInfo;
- }
关系型数据库稀疏性:
截至目前为止,我们已经能够用三种方法缓存用户信息,下面给出三种方案的实现方法和优缺点分析。
- set user:1:name James
- set user:1:age 23
- set user:1:city Beijing
set user:1 经过序列化后的⽤⼾对象字符串
hmset user:1 name James age 23 city Beijing
列表两端插入和弹出操作:
列表类型是用来存储多个有序的字符串,如上图所示,a、b、c、d、e 五个元素从左到右组成了一个有序的列表,列表中的每个字符串称为元素(element),一个列表最多可以存储个元素。
在 Redis 中,可以对列表两端插入(push)和弹出(pop),还可以获取指定范围的元素列表、获取指定索引下标的元素等,如下图所示。
列表的获取、删除等操作:
列表是一种比较灵活的数据结构,它可以充当栈和队列的角色,在实际开发上有很多应用场景。
列表类型的特点:
列表中允许有重复元素:
将一个或者多个元素从左侧放入(头插)到 list 中。
语法:
LPUSH key element [element ...]
命令有效版本:
1.0.0 之后
时间复杂度:
只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数。
返回值:
插入后 list 的长度。
示例:
前面的序号时专门给结果集使用的序号,和 list 下标无关。
在 key 存在时,将一个或者多个元素从左侧放入(头插)到 list 中。不存在,直接返回。
LPUSHX 指的是:left push exists
语法:
LPUSHX key element [element ...]
命令有效版本:
2.0.0 之后
时间复杂度:
只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数。
返回值:
插入后 list 的长度。
示例:
将一个或者多个元素从右侧放入(尾插)到 list 中。
语法:
RPUSH key element [element ...]
命令有效版本:
1.0.0 之后
时间复杂度:
只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数。
返回值:
插入后 list 的长度。
示例:
在 key 存在时,将一个或者多个元素从右侧放入(尾插)到 list 中。
语法:
RPUSHX key element [element ...]
命令有效版本:
2.0.0 之后
时间复杂度:
只插入一个元素为 O(1),插入多个元素为 O(N),N 为插入元素个数。
返回值:
插入后 list 的长度。
示例:
获取从 start 到 end 区间的所有元素,左闭右闭(闭区间),下标支持负数。
LRANGE 指的是:list range
语法:
LRANGE key start stop
命令有效版本:
1.0.0 之后
时间复杂度:
O(N)
返回值:
指定区间的元素。
示例:
Redis 的做法是直接尽可能的获取到给定区间范围内的元素,如果给定区间非法,比如超出下标,就会尽可能的获取对应的内容。
从 list 左侧取出元素(即头删)。
语法:
LPOP key
Redis 5 版本中在这后面是没有 [count] 参数的,从 Redis 6.2 版本开始,新增了一个 count 参数,用来描述此次要删除几个元素。
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
取出的元素或者 nil。
示例:
从 list 右侧取出元素(即尾删)。
语法:
RPOP key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
取出的元素或者 nil。
示例:
获取从左数第 index 位置的元素。
LINDEX 指的是:list index
语法:
LINDEX key index
命令有效版本:
1.0.0 之后
时间复杂度:
O(N)
返回值:
取出的元素或者 nil。
示例:
在特定位置插入元素。
语法:
LINSERT key <BEFORE | AFTER> pivot element
命令有效版本:
2.2.0 之后
时间复杂度:
O(N),N 表示列表长度。
返回值:
插入后的 list 长度。
示例:
insert 进行插入时,要根据基准值找到对应的位置,从左往右找,找到第一个符合基准值的位置即可。
获取 list 长度。
语法:
LLEN key
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
list 的长度。
示例:
根据参数 count 的值,移除列表中与参数 element 相等的元素。
语法:
LREM key count element
命令有效版本:
1.0.0 之后
时间复杂度:
O(N)
返回值:
被移除元素的数量。 列表不存在时返回 0 。
示例:
Redis Ltrim 对一个列表进行修剪(trim),也就是说,让列表只保留 start 和 stop 区间内(闭区间)的元素,不在区间之内的元素都将被直接删除。
语法:
LTRIM key start stop
命令有效版本:
1.0.0 之后
时间复杂度:
O(N)
返回值:
命令执行成功时,返回 OK。
示例:
通过索引来设置元素的值。当索引参数超出范围,或对一个空列表进行 LSET 时,返回一个错误。
语法:
LSET key index element
命令有效版本:
1.0.0 之后
时间复杂度:
O(N)
返回值:
操作成功返回 OK,否则返回错误信息。
示例:
blpop 和 brpop 是 lpop 和 rpop 的阻塞版本,和对应非阻塞版本的作用基本一致,除了:
阻塞版本的 blpop 和非阻塞版本 lpop 的区别:
LPOP 的阻塞版本。
语法:
BLPOP key [key ...] timeout
此处还可以指定超时时间,单位是秒(Redis 6 中,超时时间允许设定成小数,Redis 5 得是整数)。
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
取出的元素或者 nil。
示例:
(2)BRPOP
RPOP 的阻塞版本。
效果和 BLPOP 类似,只不过这里是头删。
语法:
BRPOP key [key ...] timeout
命令有效版本:
1.0.0 之后
时间复杂度:
O(1)
返回值:
取出的元素或者 nil。
BLPOP 和 BRPOP 这两个阻塞命令的用途主要就是用来作为 “消息队列”。虽然这两个命令可以在一定程度上满足 “消息队列” 这样的需求,但整体来说,功能还是比较有限。
下表是这些命令的作用和时间复杂度:
列表命令:
列表类型的内部编码有两种(旧版本,现在已经不再使用,了解即可):
现在采用的内部编码都是 quicklist。quicklist 相当于是链表和压缩列表的结合,整体还是一个链表,链表的每个节点是一个压缩列表。每个压缩列表都不让它太大,同时再把多个压缩列表通过链式结构连起来。
如下图所示,Redis 可以使用 lpush + brpop 命令组合实现经典的阻塞式生产者-消费者模型队列,生产者客户端使用 lpush 从列表左侧插入元素,多个消费者客户端使用 brpop 命令阻塞式地从队列中 “争抢” 队首元素。通过多个客户端来保证消费的负载均衡和高可用性。
阻塞消息队列模型:
brpop 是阻塞操作,当列表为空时,brpop 就会阻塞等待,一直等到其他客户端 push 了元素为止。当新元素到达之后,首先是第一个消费者拿到元素(按照执行 brpop 命令的先后顺序来决定是谁获取到)。第一个消费者拿到元素之后,也就从 brpop 中返回了(相当于这个命令执行完了)。如果第一个消费者还想继续消费,就需要重新执行 brpop,排在最后。此时,再来一个新的元素过来,就是第二个消费者拿到该元素,以此类推。
如下图所示,Redis 同样使用 lpush + brpop 命令,但通过不同的键模拟频道的概念,不同的消费者可以通过 brpop 不同的键值,实现订阅不同频道的理念。
Redis 分频道阻塞消息队列模型:
多个列表(channel)/ 频道(topic),这种场景很常见,日常使用的一些程序,比如抖音。有一个通道用来传输短视频数据,还可以有一个通道来传输弹幕,一个通道来传输点赞、转发、收藏数据,一个通道来传输评论数据... ... 弄成多个频道就可以在某种数据发生问题时,不会对其他数据造成影响(解耦合)。
每个用户都有属于自己的 Timeline(微博列表),现需要分页展示文章列表。此时可以考虑使用列表,因为列表不但是有序的,同时支持按照索引范围获取元素。
- hmset mblog:1 title xx timestamp 1476536196 content xxxxx
- ...
- hmset mblog:n title xx timestamp 1476536196 content xxxxx
- lpush user:1:mblogs mblog:1 mblog:3
- ...
- lpush user:k:mblogs mblog:9
- keylist = lrange user:1:mblogs 0 9
- for key in keylist {
- hgetall key
- }
此方案在实际中可能存在两个问题:
选择列表类型时,请参考:
- 同侧存取(lpush + lpop 或者 rpush + rpop)为栈。
- 异侧存取(lpush + rpop 或者 rpush + lpop)为队列。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。