🔥 本文专栏:Redis
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录 :
真正把你送到远处的,不是某一天突然爆发,而是在没人看见的时候,你依然没有停止积累。
思维导图

引入
在进入本篇正文之前,我们先对此前学习过的相关内容做一个简单的回顾,以便更好地承接后续要讨论的问题。
在此前的学习中,我们已经认识到,Redis 是一个采用键值对形式组织数据的客户端---服务器网络服务程序。相比于 MySQL,Redis 的一个突出特点就是数据访问速度非常快。
Redis 访问速度快的第一个核心原因,是其主要数据集直接存储在内存中。内存的访问延迟通常处于纳秒级别,而磁盘访问延迟则往往处于毫秒级别,两者在硬件访问速度上并不处于同一个数量级。
其次,对于 MySQL 的 InnoDB 存储引擎来说,其通常采用 B+ 树结构组织索引数据。B+ 树中的一个节点一般对应一个页,而 InnoDB 页的默认大小为 16KB。对于 B+ 树的内部节点来说,其不会保存完整的行记录,而是保存索引键值以及指向下层子页的页指针。正因如此,一个内部节点中可以容纳大量的分支信息,从而显著降低整棵树的高度。
因此,即使在数据规模较大的情况下,B+ 树的高度通常也不会非常高。通过索引查询数据时,从根页逐层向下定位到目标叶子页,所需要经过的层级并不多。所以,对于 MySQL 来说,真正明显的访问成本并不在于"遍历 B+ 树"这一过程本身。
MySQL 查询过程中较大的开销,首先可能来自页面读取。虽然 InnoDB 内部维护了 Buffer Pool,用于在内存中缓存已经访问过的数据页和索引页,但是当本次查询所需要的目标页没有命中 Buffer Pool 时,MySQL 就需要从磁盘中读取对应页面。相比于内存访问,磁盘 I/O 的延迟要高得多,因此一旦发生缓存未命中,就可能显著增加一次查询的执行时间。
其次, MySQL并不是一个只负责根据索引查找数据的简单程序,而是一个支持多客户端连接、事务处理和并发访问的数据库服务程序。在默认的一连接一线程模型下,不同客户端连接产生的请求可以由不同的执行线程并发处理。当多个执行线程所处理的请求需要修改同一条索引记录,或者访问彼此冲突的索引范围时, lnooDB就需要通过锁机制协调访问。此时,虽然真正尝试修改记录的是执行线程,但锁的持有与释放仍然以事务为边界:如果当前事务所需要的锁已经被其他事务持有,那么执行该请求的线程就需要等待,直到相关事务提交或回滚并释放锁。
对于普通的快照读来说,其通常不会直接与写操作竞争行锁,而是借助 MVCC机制,根据当前事务的可见性规则读取合适的数据版本。如果目标记录已经被其他事务修改,那么查询过程中还可能需要沿着 undo log 形成的版本链,找到当前事务能够看到的历史版本。虽然这种机制避免了大量读写操作之间的直接阻塞,但版本可见性判断和历史版本读取本身同样会带来一定的处理成本。
因此,MySQL 的一次数据访问,并不只是简单地从 B+ 树根页逐层向下定位到叶子页。根据具体场景不同,它还可能涉及 Buffer Pool命中判断、缓存未命中后的磁盘 I/O,以及事务机制带来的额外处理过程,例如普通快照读中的 MVCC 可见性判断与历史版本读取,修改操作或加锁读中的索引记录锁竞争
相比之下,Redis 的普通键值访问路径要更加轻量。Redis 的主要数据直接存储在内存中,当客户端发送查询命令之后,Redis 服务端可以根据 key 在内存中的键空间结构中快速定位对应的数据。
同时Redis 的普通命令通常由主线程按照顺序执行。不会出现多个命令执行线程同时修改同一份数据而产生复杂锁竞争的问题。同时,Redis 还通过 I/O 多路复用机制管理大量客户端连接:一个线程就可以监听多个客户端 socket 上的就绪事件,而不需要为每一个连接都创建一个专门的处理线程。
这种模型减少了核心数据访问路径上的锁竞争和线程切换成本,但它同样有一个重要前提:单条命令的执行过程必须足够轻量。如果某条命令需要扫描大量数据,或者执行耗时很长,那么它就会长时间占用主线程,导致后续其他客户端请求无法及时得到处理。例如,之前学习过的 KEYS 命令,在键数量非常大的情况下就可能带来明显的阻塞风险。
因此,Redis 访问速度快,并不能简单归结为"它把数据放在内存中"。更完整地说,Redis 的高性能主要来源于以下几个方面:
- 主要数据集直接驻留在内存中,数据访问延迟较低;
- 通过适合键值查询的数据结构,根据
key快速定位目标数据; - 核心命令执行路径主要采用串行处理模型,减少了复杂锁竞争和线程切换带来的成本;
- 通过 I/O 多路复用机制管理大量客户端连接,提高网络请求处理效率。
虽然 Redis 的访问速度通常比 MySQL 更快,但是 Redis 和 MySQL 之间并不是互相替代的关系,而是协同配合的关系。
MySQL 更适合存储完整、持久化并且需要事务一致性保障的业务数据;Redis 则更适合保存访问频繁的热点数据,用于降低 MySQL 的查询压力,提高服务器处理请求的效率。
在典型的缓存场景中,服务器接收到客户端请求之后,会先查询 Redis:
text
客户端发送请求
↓
服务器查询 Redis
↓
缓存命中:直接返回数据
↓
缓存未命中:继续查询 MySQL
↓
MySQL 返回查询结果
↓
服务器将结果返回给客户端
↓
根据业务需要,将热点数据写入 Redis,并设置过期时间
例如,在查询用户信息、商品详情、热点文章内容等场景下,如果这些数据被大量用户频繁访问,那么就可以将其缓存到 Redis 中。这样,大量重复查询请求就可以直接由 Redis 处理,而不必每一次都访问 MySQL,从而有效降低数据库压力。
但是,需要注意的是,并不是 MySQL 中查询出的所有数据都适合缓存到 Redis 中。通常只有那些访问频繁、具有热点价值,并且能够容忍 Redis 缓存数据与 MySQL 真实数据在短时间内存在不一致的内容,才适合作为缓存数据存储在 Redis 中。
例如,在商品详情查询场景中,商品介绍信息通常会被大量用户频繁访问,因此可以将其缓存到 Redis 中。假设最初 MySQL 和 Redis 中保存的商品介绍都为"基础款白色 T 恤",之后商家在后台将商品介绍修改为"纯棉基础款白色 T 恤"。此时,后端服务器已经将新的商品介绍写入 MySQL,但是 Redis 中原有的缓存数据还没有被删除或者更新,那么 Redis 中保存的仍然是修改前的旧内容。
当用户再次查询该商品详情时,服务器会优先访问 Redis。由于 Redis 中仍然存在对应缓存,因此本次查询会直接命中缓存并返回旧的商品介绍,而不会继续查询 MySQL 中已经更新后的最新数据。这样,就出现了 Redis 缓存数据与 MySQL 真实数据在短时间内不一致,并导致上层业务读取到旧数据的情况。
对于商品介绍、展示图片等展示性质的数据来说,短时间内读取到旧内容通常不会造成严重业务影响,因此比较适合使用 Redis 进行缓存。但是,对于那些无法容忍读取旧数据的业务内容,就需要设计更加严格的缓存更新与一致性保障方案。
另外,Redis 的数据存储在内存中,因此其可使用的存储空间会受到机器内存容量的限制。如果服务器不断向 Redis 中写入新的数据,而不对内存使用进行控制,那么 Redis 同样可能占用越来越多的内存,最终影响服务器正常运行。
为了解决这一问题,Redis 支持配置最大可用内存,并在内存达到上限后,根据指定策略淘汰部分已有数据。例如,可以按照近似 LRU 的方式优先淘汰长时间没有被访问的数据。当然,Redis 的内存淘汰策略并不只有这一种,关于最大内存配置以及具体淘汰策略,后续会单独展开介绍。
最后,由于 Redis 本质上是一个客户端---服务器架构的网络服务程序,因此客户端不能像访问进程内部普通变量一样直接操作 Redis 中保存的数据,而是需要通过 Redis 命令向服务端发送操作请求。
例如,使用 redis-cli 输入如下命令:
redis
SET name wangzhe
GET name
其本质过程是:客户端将命令按照 Redis 通信协议编码为请求数据,通过网络发送给 Redis 服务端;Redis 服务端接收并解析请求,执行对应命令之后,再将执行结果返回给客户端。
在实际开发中,向 Redis 发送命令的客户端并不一定是我们手动使用的 redis-cli,也可以是后端服务器程序中集成的 Redis 客户端库。服务器程序正是通过这些客户端库,与 Redis 建立连接并发送命令,从而完成缓存数据的读取、写入以及更新操作。
在此前的学习中,我们已经认识了一部分与 key 相关的基础命令,例如键值的设置与查询、过期时间的设置与查看,以及 Redis 对过期键的处理策略。接下来,我们将继续深入 Redis 的命令体系,进一步学习不同数据类型所支持的核心操作。
Redis 数据类型与内部编码:区分逻辑语义与底层存储实现
上文中,我们对此前学习过的 Redis 基础知识进行了简单回顾。我们知道,Redis 采用键值对形式组织数据。其中,key 统一以字符串形式表示,而 value 则支持多种不同的数据类型,例如字符串 String、列表 List、集合 Set 以及哈希 Hash 等。
这里需要注意一个非常容易产生的误区:Redis 对外提供的数据类型 ,并不等同于底层唯一固定的存储实现方式。
例如,当我们提到 String 类型时,很多读者可能会下意识地认为:既然保存的是字符串数据,那么 Redis 底层就一定通过同一种字符串结构进行存储。也就是说,无论其中保存的是整数、较短的字符串,还是长度较大的字符串,只要它们都属于 String 类型,那么底层实现方式就应该完全相同。
同样,当我们提到 Hash 类型时,很多读者可能会直接将其理解为一个普通哈希表:底层维护一个桶数组,数组中的每一个位置保存一个链表头指针;当多个字段经过哈希计算后映射到同一个桶位置时,新插入的节点就可以通过头插的方式挂接到该桶对应的链表中。
但是,Redis 中的数据类型并不能简单等同于某一种固定的数据结构实现。String、List、Set、Hash 等名称,描述的是 Redis 对外提供的逻辑数据类型,也就是客户端可以按照什么样的命令语义来操作数据;而在 Redis 底层,同一种逻辑数据类型可能根据数据内容、数据规模等条件,选择不同的编码方式进行存储。
那么,Redis 为什么要为同一种数据类型提供不同的底层实现方式呢?
这就需要结合 Redis 的核心特点来理解。Redis 的主要数据存储在内存中,而内存资源本身是有限的。虽然内存访问速度非常快,但是相比于磁盘,内存容量更有限,单位存储成本也更高。因此,对于 Redis 来说,仅仅保证访问速度快是不够的,它还需要尽可能提高内存的使用效率,在有限的内存空间中保存更多数据。
例如,同样是 String 类型的数据,其中保存的内容可能存在明显差异:
text
"18"
"alice"
"用户最近发布的一篇较长的博客内容......"
对于客户端来说,这些数据都可以通过 String 类型进行操作。但是从底层存储角度来看,它们的数据特点并不完全相同:有的数据本质上可以表示为整数,有的数据内容较短,有的数据长度较大。
如果 Redis 不区分这些数据特点,而是统一采用同一种固定方式存储所有字符串数据,那么在某些场景下就可能产生不必要的空间开销。相反,如果 Redis 能够根据字符串保存的具体内容和长度,选择更加合适的底层编码方式,那么就可以在保持 String 操作语义不变的前提下,进一步提高内存使用效率。
也就是说,Redis 中需要区分两个概念:
text
逻辑数据类型:
客户端能够看到和操作的数据类型,
例如 String。
底层编码方式:
Redis 内部真正保存该数据时采用的具体实现方式。
同一个 String 类型,根据数据内容和长度不同,
可能采用不同的底层编码。
上文中,我们提到了 Redis 中"编码"这一概念。不过,在此之前,读者可能已经接触过另一个同样被称为"编码"的概念,也就是字符编码。因此,在继续学习 Redis 的底层编码方式之前,我们需要先区分这两个概念。
我们知道,计算机底层能够保存的本质上都是二进制数据。对于字符串来说,其中的每一个字符都需要按照某种字符编码规则,被转换为对应的字节序列之后,才能够存储到计算机中。
例如,对于同一个字符串来说,如果采用 UTF-8、GBK 等不同的字符编码方式,那么最终映射得到的字节序列就可能不同。因此,字符编码解决的是这样一个问题:
text
一个字符应当按照什么规则,映射为底层存储的字节序列。
而 Redis 中所说的"编码",并不是指字符如何转换为字节序列,而是指某一个 value 对象在 Redis 底层采用什么样的内部存储实现方式。
也就是说,这两个概念关注的问题并不相同:
text
字符编码:
解决字符如何映射为字节序列的问题。
Redis 内部编码:
解决某一个 value 对象在底层采用什么实现方式进行存储的问题。
二者之间还有一个非常重要的区别。
对于字符编码来说,一旦确定了所采用的编码规则,那么字符串中的字符就会按照这一套固定规则转换为对应的字节序列。例如,当程序采用 UTF-8 编码保存字符串时,无论字符串内容是 "100"、"wangzhe",还是一段更长的文本,它们都会按照 UTF-8 的规则转换为对应的字节数据。
但是,对于 Redis 的内部编码来说,其并不是为某一种数据类型固定选择一种底层实现方式。Redis 会针对某一个具体 key 所对应的 value 对象,根据该对象保存的数据内容、数据长度等特点,选择更加合适的内部编码方式。
以接下来要学习的 String 类型为例,对于客户端来说,下面这些 value 都属于字符串类型的数据:
text
"100"
"wz"
"这是一段长度更大的字符串内容......"
客户端都可以通过相同的 SET、GET 等命令对它们进行操作。但是,从 Redis 底层存储的角度来看,这些 value 的数据特点并不完全相同:有的数据内容本身可以表示为整数,有的数据内容较短,有的数据内容长度较大。
因此,Redis 没有必要对所有 String 类型的 value 都采用完全相同的底层实现方式。相反,Redis 可以根据每一个 value 自身的特点,选择更加适合它的内部编码,从而在内存占用与访问效率之间进行权衡。
这里还需要注意,Redis 对编码方式的选择是针对某一个具体 value 对象独立完成的,而不是针对整个数据类型统一完成的。
例如,在同一个 Redis 实例中,我们可以存储多个 String 类型的数据:
redis
SET num 100
SET name wangz
SET content "这是一段长度更大的字符串内容......"
从客户端的角度来看,num、name 和 content 对应的 value 都属于 String 类型。但是,Redis 可以根据它们各自保存的数据特点,为它们选择不同的内部编码方式。
也就是说,并不是因为 num 对应的 value 采用了某一种内部编码,其他所有 String 类型的 value 就必须采用相同的编码方式。每一个 value 的内部编码选择,主要取决于它自身保存的数据内容和数据规模。因此,在同一个 Redis 中,多个相同数据类型的 value 采用不同的内部编码方式,是非常正常的现象。
那么,既然同一种数据类型在底层可能采用不同的编码方式,程序员在操作数据时是否需要关心这些区别呢?
答案是不需要。
虽然不同的内部编码在底层可能采用不同的存储实现,并且可能影响对象占用的内存空间以及部分操作的执行成本,但是 Redis 会对上层客户端提供统一的数据类型操作接口。
例如,无论一个 String 类型的 value 在底层采用哪一种内部编码,程序员都仍然可以通过相同的命令对其进行操作:
redis
SET name wangz
GET name
对于客户端来说,它只需要知道当前操作的数据属于 String 类型,并按照字符串类型提供的命令完成数据访问即可,而不需要根据 Redis 底层采用的具体编码方式重新选择不同的操作接口。
这也意味着,Redis 的内部编码方式对于程序员来说是透明的。这里所谓的透明,并不是说程序员完全无法知道底层采用了什么编码方式,而是说:即使底层编码方式发生变化,程序员也不需要修改上层操作数据的方式。
当然,我们仍然可以通过 Redis 提供的命令,查看某一个 key 所对应 value 的逻辑数据类型以及当前内部编码方式:
redis
TYPE key
OBJECT ENCODING key
其中:
text
TYPE key:
用于查看该 key 对应 value 的逻辑数据类型。
OBJECT ENCODING key:
用于查看该 value 当前采用的内部编码方式。
例如:
redis
SET num 100
TYPE num
OBJECT ENCODING num

此时,TYPE num 用于查看 num 对应的 value 是否属于 String 类型,而 OBJECT ENCODING num 则用于查看 Redis 当前采用什么内部编码来保存该 value。
但是,需要注意的是,对于 String 类型来说,程序员只能查看 Redis 最终选择的内部编码方式,而不能在存储数据时直接指定某一个 value 必须采用哪一种具体编码。例如,我们不能在执行 SET 命令时要求 Redis 必须使用某一种指定编码保存 "100"。具体采用哪一种内部编码,是 Redis 根据该 value 自身特点自动决定的。
这个过程可以类比为一家制作草莓蛋糕的商店。对于尺寸较小、结构比较简单的草莓蛋糕,商家可以采用更加紧凑、更加节省包装空间的制作与包装方式;而对于尺寸更大、结构更加复杂的草莓蛋糕,商家则需要采用更加完整、更加适合保存和运输的处理方式。
虽然不同规模的草莓蛋糕在制作和包装过程中采用的处理方案不同,但是对于顾客来说,最终购买到的仍然是草莓蛋糕。顾客并不需要因为商家内部处理方式的不同,就改变自己购买和食用草莓蛋糕的方式。
Redis 中的内部编码也是类似的。对于同一个 String 类型的 value,Redis 会根据其具体内容和数据长度,选择更加合适的底层存储实现。虽然不同 value 在 Redis 底层可能采用不同的编码方式,但是对于客户端来说,它们仍然都属于 String 类型,程序员依然通过统一的字符串命令对其进行操作。
因此,在学习 Redis 数据类型时,我们需要明确区分下面两个概念:
text
逻辑数据类型:
Redis 对客户端暴露的数据操作语义,
例如 String、List、Set、Hash 等。
内部编码方式:
Redis 在底层真正保存某一个 value 对象时,
根据其数据特点所选择的具体存储实现方式。
在明确了逻辑数据类型与底层编码方式之间的关系之后,接下来我们就可以正式从 String 类型入手,进一步分析 Redis 会如何根据字符串 value 的不同特点,选择相应的内部编码方式进行存储。

Redis String 类型底层实现:SDS、三种编码方式与动态转换机制
根据上文,我们已经认识了 Redis 中内部编码的概念。接下来,我们就可以进一步进入 String 类型的学习。
这一部分将采用一种自底向上的讲解思路:首先认识 String 类型在 Redis 底层可能采用的内部编码方式,理解字符串数据在底层是如何被组织和保存的;在此基础上,再回到上层,继续学习围绕 String 类型提供的相关命令。
即使不了解底层编码,我们同样可以使用 SET、GET 等命令操作字符串数据。但是,在认识了 String 的底层实现之后,我们就能够进一步理解这些命令背后的存储方式、空间开销以及部分操作效率。
对于 Redis 的 String 类型来说,其底层主要存在三种内部编码方式:
text
int:
用于保存能够以整数形式表示的字符串数据。
embstr:
用于保存较短的字符串数据。
raw:
用于保存较长的字符串数据,或者适合采用可修改字符串表示的数据。
需要注意的是,int、embstr 和 raw 描述的是 Redis 底层保存数据时采用的内部编码方式。从客户端角度来看,无论一个 value 最终采用哪一种编码,它都仍然属于 String 类型,程序员依旧通过字符串类型提供的命令对其进行操作。
传统 C 字符串存在的问题
在理解 Redis 如何保存字符串数据之前,我们可以先回顾一种最熟悉的字符串存储方式:使用 char 类型的字符数组保存字符串,并在有效内容末尾添加 \0,以此标记字符串的结束位置。
例如,字符串 "hello" 可以表示为:
text
'h' 'e' 'l' 'l' 'o' '\0'
这种方式实现简单,并且与 C 语言提供的字符串处理函数相配合。但是,对于 Redis 这种需要保存任意字节数据的服务程序来说,如果完全依赖传统 C 字符串形式处理数据,就会存在一些明显问题。
首先,传统 C 字符串通过第一个 \0 判断有效内容的结束位置,因此无法安全地表示中间包含 \0 的任意二进制数据。
这里需要注意,char 数组本身当然可以保存任意二进制字节,包括图片、音视频数据或者序列化后的字节流。真正的问题在于:如果我们使用依赖 \0 判断结尾的字符串处理方式,那么一旦有效数据中间本身出现了 \0,程序就会错误地认为数据已经结束。
例如,某段二进制数据在内存中的内容如下:
text
'A' 'B' '\0' 'C' 'D'
对于字节数组来说,这五个字节都可以正常保存。但是,如果按照传统 C 字符串方式处理,那么程序在遇到中间的第一个 \0 时,就会认为有效内容只有:
text
"AB"
后面的 "CD" 将无法被继续作为有效数据处理。
因此,如果 Redis 的 String 仅仅依赖传统 C 字符串形式进行存储和解析,那么它就无法安全保存中间可能包含 \0 的任意二进制字节序列。
其次,传统 C 字符串无法直接知道当前有效数据长度。如果需要获取一个字符串的长度,就必须从数组起始位置开始不断向后遍历,直到遇到末尾的 \0 为止。
假设字符串长度为 N,那么获取字符串长度所需要的时间复杂度就是:
text
O(N)
同样,对于字符串拼接操作来说,如果要将一个长度为 M 的新字符串追加到一个长度为 N 的原字符串末尾,首先需要遍历原字符串,找到其末尾位置,然后再将新的内容拷贝到后方:
text
查找原字符串末尾:O(N)
拷贝新增字符串内容:O(M)
总时间复杂度:O(N + M)
对于 Redis 这种需要频繁处理字符串数据的系统来说,如果每一次获取长度或者追加内容都需要重新扫描已有数据,就会带来不必要的执行开销。
SDS:Redis 保存字符串数据的基础结构
为了解决传统 C 字符串存在的问题,Redis 并不是单纯依赖一个以 \0 结尾的字符数组保存字符串内容,而是设计了一种用于管理字节序列的结构,也就是SDS(Simple Dynamic String,简单动态字符串)
从逻辑上来看,SDS 除了保存实际的字节数据之外,还会额外维护与当前字符串有关的元信息,例如:
text
有效数据长度:
当前真正保存了多少字节内容。
已分配容量:
当前为字节缓冲区分配了多少可用空间。
字节缓冲区:
真正用于保存字符串或二进制数据的区域。
这里暂时不需要深入 SDS 在源码中的具体结构体布局。当前阶段,只需要建立这样一个理解:SDS 不再只依赖字节缓冲区中的 \0 判断数据长度,而是会额外记录当前有效数据的长度和空间容量。
需要注意的是,SDS 的字节缓冲区末尾仍然会保留一个 \0。这样做可以让 SDS 在部分场景下兼容传统 C 字符串接口。但是,Redis 在处理 SDS 数据时,并不是依赖这个 \0 来判断有效内容的结束位置,而是依据 SDS 中记录的有效数据长度进行处理。
例如,SDS 中可以保存如下内容:
text
'A' 'B' '\0' 'C' 'D' '\0'
其中,前五个字节都属于真正的有效数据:
text
有效数据长度 = 5
最后一个 \0 只是额外保留的结束符。由于 Redis 根据记录的有效长度读取数据,因此即使有效内容中间存在 \0,也不会错误地将数据截断。
这就是 Redis String 类型具有二进制安全性 的原因。对于 Redis 来说,String 并不只能保存我们平时理解的文本字符串,它本质上可以保存一段字节序列,因此也可以保存序列化结果、图片内容或者其他二进制数据。
SDS 对字符串操作效率的优化
除了支持二进制安全之外,SDS 还能够优化部分字符串操作的执行效率。
首先,对于获取字符串长度的操作来说,由于 SDS 内部已经记录了当前有效数据长度,因此 Redis 不需要再从头遍历整个字符缓冲区,而是可以直接读取保存的长度信息。
text
传统 C 字符串获取长度:
需要从头遍历到 '\0',时间复杂度为 O(N)。
SDS 获取长度:
直接读取有效数据长度,时间复杂度为 O(1)。
其次,对于字符串追加操作来说,SDS 同样可以避免为了寻找原字符串末尾而产生的线性扫描。
假设当前 SDS 中已经保存了长度为 N 的字符串,现在需要在其末尾追加长度为 M 的新内容。由于 SDS 已经记录了当前有效长度,因此 Redis 可以直接定位到追加位置,而不需要重新扫描前面的 N 个字节。
如果当前 SDS 的剩余容量足够容纳新增内容,那么本次追加主要只需要完成新增数据的拷贝:
text
直接定位追加位置:O(1)
拷贝新增内容:O(M)
追加成本:O(M)
但是,如果当前缓冲区的剩余空间不足,追加过程中就可能需要重新申请更大的连续内存空间,并将原有内容拷贝到新的空间中。此时,本次追加仍然可能涉及原有 N 个字节与新增 M 个字节的处理,因此成本可能达到:
text
O(N + M)
所以,更准确地说,SDS 对字符串追加操作的优化在于:它避免了传统 C 字符串为了寻找末尾而产生的额外线性扫描;当剩余容量足够时,追加操作主要只需要拷贝新增内容。
Redis 对象:记录数据类型与内部编码
认识 SDS 之后,我们还需要继续解决一个问题:Redis 在获取某一个 key 对应的 value 之后,如何知道当前 value 应该按照什么方式解析和操作?
如果 Redis 中所有 String 类型的数据都固定通过 SDS 保存,那么 Redis 只需要按照 SDS 的方式访问数据即可。但是,根据前文我们已经知道,String 类型在底层可能采用 int、embstr 和 raw 三种不同的内部编码方式。
因此,Redis 在真正读取或者修改一个 value 之前,首先需要知道当前 value 属于什么逻辑数据类型,以及它在底层采用什么内部编码方式。为了解决这个问题,Redis 会使用对象结构来描述 value。
对于理解本节 String 编码机制来说,我们重点关注 Redis 对象中的以下三个字段:
text
type:
表示当前 value 的逻辑数据类型。
例如,对于本节所学习的字符串数据来说,
type 表示其属于 String 类型。
encoding:
表示当前 value 采用的内部编码方式。
对于 String 类型来说,
可能是 int、embstr 或 raw。
ptr:
用于保存或定位当前 value 对应的实际数据。
cpp
// 逻辑数据类型
#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
#define OBJ_HASH 4
// String 类型可能采用的内部编码
#define OBJ_ENCODING_RAW 0
#define OBJ_ENCODING_INT 1
#define OBJ_ENCODING_EMBSTR 8
// 实际 Redis 源码中还包含引用计数、LRU/LFU 等管理信息。
struct redisObject
{
unsigned type;
// 逻辑数据类型,例如:
// OBJ_STRING、OBJ_LIST、OBJ_SET、OBJ_HASH 等
unsigned encoding;
// 当前对象采用的内部编码方式。
// 对于 String 类型来说,可能是:
// OBJ_ENCODING_INT、OBJ_ENCODING_EMBSTR、OBJ_ENCODING_RAW
void* ptr;
// 用于指向或保存当前 value 对应的实际数据:
// raw 编码:指向独立分配的 SDS
// embstr 编码:指向与 redisObject 连续存储的 SDS
// int 编码:直接保存整数值本身
};
其中,ptr 字段在不同编码方式下承担的作用并不完全相同。
对于 raw 编码来说,Redis 对象与 SDS 分开申请内存,因此 ptr 指向独立存在的 SDS:
text
Redis 对象 SDS
┌──────────────────┐ ┌──────────────────────┐
│ type = String │ │ len │
│ encoding = raw │ ──────────→ │ alloc │
│ ptr │ │ buf[] │
└──────────────────┘ └──────────────────────┘
对于 embstr 编码来说,Redis 对象与 SDS 通过一次内存分配,存储在同一块连续内存空间中。此时,ptr 仍然用于定位 SDS,只不过该 SDS 位于 Redis 对象后方的连续空间中:
text
一块连续内存空间:
┌──────────────────┬──────────────────────┐
│ Redis 对象 │ SDS 头部 + 字节数据 │
│ encoding = embstr │ │
│ ptr ──────────────┼───────────────→ │
└──────────────────┴──────────────────────┘
而对于 int 编码来说,由于当前 String 类型保存的数据本身能够直接表示为整数,Redis 就不需要再额外申请一个 SDS 来保存对应的字节内容。此时,ptr 字段会被直接用于保存整数值本身,而不是指向一个单独的字符串数据对象。
可以简单理解为:
text
String 类型的 value
raw:
Redis 对象中的 ptr → 独立分配的 SDS
embstr:
Redis 对象中的 ptr → 与对象连续存储的 SDS
int:
Redis 对象中的 ptr 直接保存整数值
需要注意的是,type、encoding 和 ptr 只是当前理解字符串编码机制时最关键的字段。Redis 对象内部还会维护引用计数、访问淘汰相关信息等内容,这些内容当前暂时不展开。
这样一来,Redis 在操作一个 value 时,就可以先通过 type 判断它属于哪一种逻辑数据类型,再通过 encoding 判断它当前采用哪一种底层表示方式,最后根据 ptr 的具体含义正确读取或者修改其中的数据。
从 C++ std::string 理解短字符串优化
在正式进入 Redis 的 embstr 与 raw 编码之前,我们可以先借助 C++ 中常见的 std::string 短字符串优化思想,理解为什么较短字符串与较长字符串可能采用不同的内存组织方式。
在常见的 std::string 实现中,字符串对象内部会预留一小块用于保存短字符串内容的缓冲区。当字符串内容较短时,其字符数据可以直接保存在 std::string 对象内部,而不需要额外在堆区申请一块动态内存空间;只有当字符串长度超过内部缓冲区能够容纳的范围时,才需要在堆区申请更大的空间保存实际内容。
可以将其简单理解为:
text
较短字符串:
字符内容直接保存在 string 对象内部的小型缓冲区中,
不需要额外申请堆内存。
较长字符串:
对象内部空间不足以保存完整内容,
需要额外申请动态内存,并由 string 对象定位该空间。
之所以要对短字符串进行特殊处理,首先是因为堆内存的申请与释放本身存在成本。对于内容很短的字符串来说,如果仍然为每一个字符串单独申请一块堆内存,那么实际保存的数据可能只有几个字节,而动态内存分配、指针管理以及后续释放所带来的额外成本反而并不划算。
其次,短字符串在很多场景下创建完成之后,可能主要用于读取、比较或者传递,而不会继续增长到很大的规模。在这种情况下,将短字符串内容直接保存在对象内部,就可以在整个使用过程中避免额外的堆内存申请与释放开销。
当然,即使一个字符串最初属于短字符串,后续仍然可能因为追加内容而增长。当其内容超过对象内部缓冲区能够容纳的范围之后,std::string 就需要转为动态存储方式,为更长的字符串内容申请堆内存空间。
但是,这并不意味着前面的短字符串优化没有意义。因为在字符串尚未增长之前,它仍然避免了原本没有必要发生的动态内存申请。也就是说,短字符串优化的意义并不是保证字符串永远不发生动态分配,而是在数据规模较小时,优先使用更加轻量的存储方式;只有当字符串规模真正增长到需要更大空间时,再承担动态分配的成本。
可以将这个过程理解为:
text
字符串内容较短:
先使用对象内部缓冲区保存数据,
避免过早发生堆内存申请。
后续字符串没有明显增长:
一直享受短字符串存储带来的低分配成本。
后续字符串增长并超过容量:
再转换为动态存储方式,
为真正变大的数据申请堆空间。
因此,短字符串优化本质上是一种根据当前数据规模选择更合适存储方式的策略:在数据较小时避免不必要的资源开销;当数据确实变大时,再切换到更适合大规模内容的组织方式。
需要注意的是,C++ std::string 的短字符串优化与 Redis 的 embstr 编码并不是完全相同的实现。这里借助 std::string 只是为了帮助理解二者相似的优化动机:对于较短的字符串,如果能够减少额外的内存分配,就可以降低对象创建和释放时的成本,并改善数据访问时的局部性。
将视角重新切换回 Redis 后,就可以理解为什么 Redis 会为短字符串设计 embstr 编码,而为较长或者需要进一步修改的字符串采用 raw 编码。
embstr:短字符串的紧凑编码方式
对于不能直接编码为整数的较短字符串,Redis 可以采用 embstr 编码。
在经典的 Redis String 对象模型中,embstr 的核心特点是:Redis 对象与保存字符串数据的 SDS 会通过一次内存分配,存放在同一块连续的内存空间中。
可以将其抽象理解为:
text
一块连续内存空间:
┌──────────────────┬──────────────────────┐
│ Redis 对象相关信息 │ SDS 头部 + 字节数据 │
└──────────────────┴──────────────────────┘
由于 Redis 对象与 SDS 位于同一块连续内存空间中,因此,创建一个 embstr 字符串对象时,只需要进行一次内存分配;释放该对象时,也只需要释放这一块内存空间。
相比于对象和 SDS 分开申请内存,这种方式可以减少一次内存分配与释放的开销,同时,由于对象信息和实际字符串内容在内存中彼此相邻,也有利于提高访问时的局部性。
按照 Redis 官方文档给出的经典编码规则,对于长度不超过 44 字节、并且不能采用整数编码保存的字符串,Redis 可以使用 embstr 编码。
这里需要注意,判断依据是字节长度,而不是我们看到的字符数量。例如,对于 UTF-8 编码保存的中文字符串来说,一个中文字符通常会占用多个字节,因此不能简单按照字符个数判断它是否满足短字符串条件。
raw:较长字符串的编码方式
当字符串内容较长时,Redis 则会采用 raw 编码。
在经典对象模型下,raw 与 embstr 的主要区别在于:raw 编码中,Redis 对象与 SDS 并不是通过一次连续内存分配绑定在一起,而是分别组织。Redis 对象中会保留用于定位实际字符串数据的信息,而真正的字符串内容则由独立的 SDS 进行管理。
可以抽象理解为:
text
Redis 对象 SDS 字符串数据
┌──────────────────┐ ┌──────────────────────┐
│ type / encoding │ ─────────→ │ SDS 头部 + 字节数据 │
└──────────────────┘ └──────────────────────┘
这种方式虽然相较 embstr 需要维护更加分离的内存组织关系,但是它更适合保存较长的字符串数据,也更适合后续可能发生修改、追加和扩容的字符串对象。
这是因为,当字符串内容需要被修改或者扩容时,SDS 所对应的数据空间可能需要重新分配。如果对象信息与 SDS 数据被紧凑地绑定在同一块连续空间中,那么字符串内容变化时,整个连续内存布局都会受到影响。而采用 raw 编码后,字符串数据可以由 SDS 单独管理,修改和扩容时更加灵活。
需要注意的是,embstr 适合用于创建后直接读取的短字符串对象。当对采用 embstr 编码的字符串执行追加等修改操作时,Redis 会将其转换为更适合修改的表示,再继续完成后续操作。因此,不能简单理解为:只要修改后的字符串仍然没有超过短字符串阈值,它就一定会继续保持 embstr 编码。
int:整数形式字符串的编码方式
认识了 embstr 与 raw 这两种字符串编码方式之后,接下来还需要认识 String 类型的第三种内部编码,也就是 int 编码。
前面介绍的 embstr 与 raw,本质上都是针对普通字符串内容所采用的存储方式:字符串数据最终会由 SDS 负责保存,只不过二者在 Redis 对象与 SDS 的内存组织方式上存在差异。
但是,对于某些 String 类型的 value 来说,其保存的内容虽然从客户端角度看属于字符串,但内容本身实际上可以被解释为一个整数。例如:
redis
SET num 100
对于客户端来说,num 对应的 value 仍然属于 String 类型:
redis
TYPE num
执行结果为:
text
string
但是,从底层存储角度来看,"100" 这段内容本身可以直接表示为一个整数。因此,Redis 没有必要一定为其创建 SDS,再将字符 '1'、'0'、'0' 以字节序列的形式保存下来。
如果一个 String 类型的 value 能够被 Redis 识别为处于有符号 64 位整数范围内的整数形式,那么 Redis 就可能为其选择 int 编码,直接按照整数形式保存该 value。
例如:
redis
SET num 100
OBJECT ENCODING num
此时可能得到:
text
int
这说明:
text
从客户端角度看:
num 对应的 value 属于 String 类型。
从 Redis 底层角度看:
num 当前采用 int 编码,
直接按照整数形式保存数值 100。
int 编码的底层组织方式
对于 raw 和 embstr 编码来说,Redis 对象中的 ptr 字段会用于定位保存字符串内容的 SDS:
text
raw:
ptr 指向独立分配的 SDS。
embstr:
ptr 指向与 Redis 对象位于同一块连续空间中的 SDS。
而对于 int 编码来说,由于 value 本身可以直接表示为整数,因此 Redis 不需要再额外创建 SDS 来保存对应的字符串字节内容。此时,可以将 Redis 对象中的 ptr 字段理解为直接保存整数值本身。
例如,执行:
redis
SET num 100
在底层可以抽象理解为:
text
Redis 对象
┌──────────────────────────┐
│ type = OBJ_STRING │
│ encoding = OBJ_ENCODING_INT│
│ ptr = 100 │
└──────────────────────────┘
而如果采用普通字符串形式保存 "100",则需要额外维护 SDS:
text
Redis 对象 SDS
┌──────────────────┐ ┌──────────────────────┐
│ type = STRING │ │ len = 3 │
│ encoding = RAW / │ ──────────→ │ alloc │
│ EMBSTR │ │ buf = "100" │
│ ptr │ └──────────────────────┘
└──────────────────┘
因此,int 编码的核心特点就是:
text
对于本质上可以表示为整数的 String value,
Redis 不再额外使用 SDS 保存其字符形式,
而是直接按照整数形式组织该数据。
int 编码为什么能够节省空间
Redis 为整数形式的字符串提供 int 编码,首先是因为:同一个整数按照字符串形式存储时,其空间占用会随着数字位数增加而不断增长;而按照整数形式存储时,只要数值仍处于有符号 64 位整数范围内,其数值本身占用的空间就是固定的。
例如,对于下面这些数字内容来说:
text
"10"
"100000"
"1000000000000000000"
如果按照字符串形式保存,那么 Redis 需要保存每一个数字字符:
text
"10" → 需要保存 2 个数字字符
"100000" → 需要保存 6 个数字字符
"1000000000000000000" → 需要保存 19 个数字字符
也就是说,数字越大,其十进制字符串表示通常就越长,需要保存的字符字节数量也会随之增加。
但是,如果这些数字仍然能够落在有符号 64 位整数范围内,那么 Redis 就可以直接按照整数形式保存它们。此时,无论该整数的十进制表示包含多少位数字,整数值本身所需要的存储空间都不会随着数字位数增加而继续膨胀。
可以简单理解为:
text
字符串形式保存数字:
数字位数越多,需要保存的字符字节越多。
整数形式保存数字:
只要数值仍处于 64 位整数范围内,
数值本身始终按照固定大小的整数形式保存。
不过,对于 Redis 来说,int 编码节省的空间还不只是数字内容本身所占用的字节数量。
如果 Redis 按照普通字符串方式保存一个整数形式的 value,那么它不仅需要保存数字对应的字符内容,还需要额外维护 SDS 结构。例如,保存字符串 "100" 时,底层需要维护:
text
Redis 对象
↓
SDS 元信息:
有效数据长度
已分配容量
SDS 数据区域:
'1' '0' '0' '\0'
而当 Redis 识别出当前 value 可以直接表示为整数之后,就可以采用 int 编码,不再额外创建 SDS 保存数字对应的字符串内容,而是直接利用 Redis 对象中的 ptr 字段保存整数值本身:
text
Redis 对象
┌──────────────────────────┐
│ type = OBJ_STRING │
│ encoding = OBJ_ENCODING_INT│
│ ptr = 100 │
└──────────────────────────┘
因此,即使对于 "10"、"100" 这样字符数量较少的整数内容,虽然其有效字符本身未必比一个 64 位整数占用更多空间,但是采用 int 编码之后,Redis 仍然可以省去额外维护 SDS 元信息以及字符串缓冲区的开销。
而对于位数较多的整数文本来说,int 编码的空间优势则会更加明显:它不仅能够省去 SDS 带来的额外结构开销,还能够避免十进制字符串表示随着数字位数增加而产生的数据体积膨胀。
因此,int 编码能够节省空间,主要体现在两个方面:
text
第一点:
整数采用字符串形式保存时,
字符数据长度会随着十进制位数增加而增长;
而整数形式在 64 位范围内占用固定空间。
第二点:
Redis 采用 int 编码后,
不需要再额外创建 SDS 保存数字字符串,
从而省去 SDS 元信息和字符缓冲区带来的额外开销。
这就是 Redis 会对整数形式的 String value 进行特殊编码处理的原因。
int 编码对整数运算的帮助
int 编码带来的收益不仅体现在空间占用方面,还体现在整数运算场景中。
虽然 Redis 对外提供的是 String 类型,但是它仍然支持围绕整数值进行计算的命令,例如:
redis
SET count 100
INCR count
INCRBY count 10
DECR count
这些命令操作的对象,从客户端角度来看仍然属于 String 类型。但是,如果当前 value 保存的是整数内容,并且底层已经采用 int 编码,那么 Redis 在执行递增、递减等整数运算时,就可以直接基于整数形式进行处理,而不必每次都先从 SDS 保存的字符字节序列中解析出原有整数值。
例如:
redis
SET count 100
INCR count
可以抽象理解为:
text
执行之前:
type = String
encoding = int
ptr = 100
执行 INCR:
直接基于整数值完成加一操作
执行之后:
type = String
encoding = int
ptr = 101
因此,对于需要频繁执行计数操作的数据,例如访问次数、点赞数量、在线人数统计等,整数形式的字符串不仅能够保持 Redis String 类型统一的操作语义,还能够更加适合整数计算场景。
int、embstr 与 raw 的关系
至此,我们就认识了 Redis String 类型底层可能采用的三种主要编码方式:
text
String 类型
│
├── int
│ └── 保存能够表示为有符号 64 位整数的字符串内容
│ 不需要额外创建 SDS
│ 可以直接按照整数形式组织 value
│
├── embstr
│ └── 保存较短的普通字符串
│ Redis 对象与 SDS 位于同一块连续内存空间中
│ 减少内存分配与释放次数
│
└── raw
└── 保存较长的普通字符串,
或者需要采用可修改形式处理的字符串
Redis 对象与 SDS 分离组织
更便于字符串内容的修改和扩容
例如,下面三个 value 从客户端角度来看都属于 String 类型:
redis
SET num 100
SET name wangzhe
SET article "这是一段长度较大的字符串内容......"
TYPE num
TYPE name
TYPE article
它们的逻辑类型都可能显示为:
text
string
但是,在 Redis 底层,它们可能根据各自保存的数据特点采用不同的内部编码方式:
text
num:
保存整数形式的内容,可能采用 int 编码。
name:
保存较短的普通字符串,可能采用 embstr 编码。
article:
保存较长的普通字符串,可能采用 raw 编码。
这正体现了 Redis 内部编码机制的意义:在不改变客户端所看到的数据类型语义以及操作接口的前提下,根据不同 value 自身的数据特点,选择更加合适的底层存储实现,从而降低内存开销,并提升特定场景下的数据处理效率。
String 内部编码的动态转换
根据上文,我们已经认识了 Redis String 类型可能采用的三种内部编码方式:int、embstr 和 raw。
需要注意的是,Redis 会根据写入 value 自身的数据内容和数据规模,为其选择合适的内部编码方式。但是,这并不意味着某一个 value 在创建之后,其内部编码就永远不会发生变化。
以普通短字符串为例,当我们首次通过 SET 命令写入一个长度较短、并且不能表示为整数的字符串时,Redis 可能会为其选择 embstr 编码:
redis
SET name wangz
OBJECT ENCODING name
此时,name 对应的 value 可能采用:
text
embstr
在经典的 Redis String 对象模型中,embstr 编码的核心特点是:Redis 对象与保存字符串内容的 SDS 通过一次内存分配,存储在同一块连续内存空间中。
text
一块连续内存空间:
┌─────────────────────┬────────────────────────┐
│ Redis 对象 │ SDS │
│ encoding = embstr │ len / alloc / buf[] │
│ ptr ─────────────────┼──────────────→ │
└─────────────────────┴────────────────────────┘
这种内存组织方式可以减少一次内存申请与释放的开销,并提高数据访问时的局部性,因此适合保存创建之后主要用于读取的短字符串对象。
但是,embstr 编码对应的 SDS 与 Redis 对象被组织在同一块连续内存空间中,并不适合直接在原有对象上继续修改字符串内容。因此,如果后续对该 value 执行 APPEND 追加操作,或者执行 SETRANGE 局部覆盖操作,那么 Redis 会将原有的 embstr 编码转换为更适合修改的 raw 编码,再完成后续操作。
例如:
redis
SET name wangz
OBJECT ENCODING name
APPEND name "!"
OBJECT ENCODING name

这里需要特别注意:即使执行 APPEND 之后,最终字符串的长度仍然没有超过短字符串对应的阈值,其编码也会由 embstr 转换为 raw。
例如,假设 "wangz" 本身采用 embstr 编码,在其末尾只追加一个 "!":
redis
SET name wangz
APPEND name "!"
修改后的字符串内容仅仅是:
text
"wangz!"
虽然它仍然是一个长度很短的字符串,但是由于该 value 已经发生了内容修改,Redis 会将其转换为更适合后续修改的 raw 编码。
之所以需要进行这种转换,是因为 raw 编码更加适合字符串内容的修改和扩容。在 raw 编码下,Redis 对象与 SDS 分开组织:
text
Redis 对象 SDS
┌─────────────────────┐ ┌────────────────────────┐
│ type = OBJ_STRING │ │ len │
│ encoding = raw │ ───────→ │ alloc │
│ ptr │ │ buf[] │
└─────────────────────┘ └────────────────────────┘
假设当前字符串对应的 SDS 缓冲区剩余空间已经不足,而后续又需要追加新的内容,那么 Redis 就可能需要扩展 SDS 所使用的数据空间。
在 raw 编码下,由于 Redis 对象与 SDS 并不绑定在同一块连续内存中,因此,字符串扩容时可以主要围绕 SDS 进行处理:有效数据长度和已分配容量等信息由 SDS 自身负责更新;如果 SDS 扩容后所在的内存地址发生变化,那么 Redis 只需要进一步修改 Redis 对象中的 ptr 字段,使其重新指向扩容后的 SDS,而不需要将 Redis 对象本身也随着字符串数据一起重新组织。
text
扩容之前:
Redis 对象 原 SDS
┌─────────────────────┐ ┌────────────────┐
│ encoding = raw │ ───────→ │ "hello" │
│ ptr │ └────────────────┘
└─────────────────────┘
扩容之后:
Redis 对象 扩容后的 SDS
┌─────────────────────┐ ┌──────────────────────────┐
│ encoding = raw │ ───────→ │ "hello redis string..." │
│ ptr │ └──────────────────────────┘
└─────────────────────┘
相比之下,如果 Redis 对象与 SDS 像 embstr 一样紧密存储在同一块连续内存空间中,那么 SDS 所需空间发生变化时,包含 Redis 对象与字符串数据在内的整块连续布局都会受到影响。因此,embstr 更适合保存创建之后主要用于读取的短字符串,而 raw 更适合保存需要继续修改或者扩容的字符串数据。
这里还需要进一步区分两类操作。
第一类操作,是在已有 value 的基础上修改其内容,例如:
redis
APPEND name "!"
SETRANGE name 0 "W"
其中,APPEND 会在原字符串末尾追加内容,因此字符串长度只会增加;SETRANGE 会从指定偏移位置开始覆盖原有内容,如果覆盖范围没有超过原字符串长度,那么字符串总长度保持不变,如果覆盖范围超过原字符串长度,那么字符串还会继续变长。也就是说,在当前讨论的这些内容修改操作中,字符串不会因为修改而被截短。
因此,对于一个已经因为 APPEND 或 SETRANGE 等操作而转换为 raw 编码的 value 来说,后续继续执行这些内容修改操作时,它会继续按照适合修改的 raw 表示进行处理。
第二类操作,是通过 SET 命令为某一个 key 重新写入新的 value:
redis
SET name hi
SET 并不是在原有字符串对象内部继续修改内容,而是使用一个新的 value 覆盖当前 key 对应的旧 value。因此,当执行 SET 时,Redis 会针对重新写入的新 value,再次根据其内容与长度进行编码判断。Redis 文档也明确说明,SET 会替换 key 中原本保存的 value。
例如,假设 name 当前已经因为发生过追加操作而采用 raw 编码:
redis
SET name wangz
APPEND name "_redis"
OBJECT ENCODING name
# raw
此时,如果重新执行:
redis
SET name hi
OBJECT ENCODING name
由于 "hi" 是一个重新写入的短字符串,Redis 会重新判断该新 value 的编码方式,因此它仍然可能采用 embstr 编码。
同样,如果重新写入的是能够表示为整数的字符串:
redis
SET name 100
OBJECT ENCODING name
那么新的 value 也可能采用 int 编码。
因此,对于 String 类型的编码变化,可以总结为:
text
首次写入一个新的 value:
Redis 根据 value 的内容和长度选择编码。
可能采用 int、embstr 或 raw。
对已有短字符串执行内容修改:
例如 APPEND、SETRANGE。
原有 embstr 会转换为 raw。
即使修改后的字符串长度仍然较短,也不会继续保持 embstr。
通过 SET 覆盖为新的 value:
Redis 将新写入的数据重新作为判断对象。
新 value 可能重新采用 int、embstr 或 raw。
由此可见,Redis 的内部编码会随着 value 的写入方式与后续操作发生变化。但是,这种变化并不是简单意义上的"字符串变长就升级、字符串变短就降级",而是 Redis 根据当前操作是否需要修改已有字符串对象,以及新写入 value 的数据特点,选择更加适合的底层表示方式。
Redis String 类型命令详解:基础读写、范围操作与数值运算
String 类型的基础操作命令:SET 与 GET
认识了 String 类型底层可能采用的 int、embstr 和 raw 三种内部编码方式之后,接下来我们就可以重新回到上层,继续学习用于操作 String 类型 value 的相关命令。
Redis 会针对不同的逻辑数据类型提供相应的操作命令。对于同一种逻辑数据类型来说,客户端所使用的命令接口是统一的,并不会因为某个 value 当前采用了不同的内部编码方式而发生改变。
例如,下面两个 value 从底层来看可能采用不同的编码方式:
redis
SET num 100
SET name wangz
其中,num 保存的是能够表示为整数的字符串内容,因此底层可能采用 int 编码;而 name 保存的是较短的普通字符串,因此底层可能采用 embstr 编码。
但是,对于客户端来说,它们都属于 String 类型,因此程序员仍然可以统一通过 GET 命令查询对应数据:
redis
GET num
GET name
也就是说,底层内部编码对于客户端来说是透明的。程序员只需要关注当前 value 属于哪一种逻辑数据类型,并使用该类型对应的命令进行操作即可;至于 Redis 底层究竟采用 int、embstr 还是 raw 编码,则由 Redis 自身负责判断和处理。
在 Redis 服务端内部,当客户端发送操作命令之后,Redis 会根据 key 找到对应的 Redis 对象,并结合对象中记录的逻辑数据类型 type 与内部编码方式 encoding,选择相应的底层处理路径完成操作。
SET 与 GET 命令
对于 String 类型来说,最基础的两个命令就是此前已经接触过的 SET 与 GET。
其中,SET 命令用于写入或覆盖某一个 key 对应的字符串 value:
redis
SET name wangz
执行成功后,Redis 中就会保存一个键值对:
text
key:name
value:wangz
而 GET 命令则用于根据 key 查询其对应的字符串 value:
redis
GET name
执行结果为:
text
"wangz"
其基本使用形式可以表示为:
redis
SET key value
GET key
如果执行 SET 时,指定的 key 原本不存在,那么 Redis 会创建新的键值对;如果该 key 原本已经存在,那么新的 value 会覆盖旧 value。
例如:
redis
SET name wangz
GET name
此时查询结果为:
text
"wangz"
随后重新执行:
redis
SET name WangZ
GET name
此时,原有的 value 会被新的内容覆盖,查询结果变为:
text
"WangZhe"
需要注意的是,SET 重新写入新的 value 时,Redis 会根据新 value 自身的数据特点重新判断其内部编码方式。也就是说,原 value 采用什么编码,并不会限制新 value 的编码选择。
例如:
redis
SET name wangz
OBJECT ENCODING name
# 为 embstr
SET name 100
OBJECT ENCODING name
# 为 int
这里第二次执行 SET 时,Redis 面对的是重新写入的新 value "100"。由于该内容能够表示为整数,因此 Redis 可以为新的 value 重新选择 int 编码。
使用 SET 命令同时设置过期时间
在此前的学习中,我们已经认识了 EXPIRE 命令。该命令可以为一个已经存在的 key 设置过期时间:
redis
SET code 123456
EXPIRE code 60
上述命令表示:首先保存验证码 123456,随后为 code 设置 60 秒的过期时间。
但是,需要注意,Redis 是一个客户端---服务器架构的网络服务程序。客户端发送的每一条命令都需要经过网络传输到达 Redis 服务端,再由服务端解析并执行。因此,如果原本可以在一条命令中完成的操作被拆分为多条命令,就会增加额外的网络交互开销。
更重要的是,将写入数据与设置过期时间拆分为两条命令,还可能产生中间状态:
text
SET code 123456 执行成功
↓
EXPIRE code 60 尚未执行
如果在两条命令之间,客户端程序发生异常,或者第二条命令没有成功发送到 Redis 服务端,那么 code 虽然已经被成功写入,但却没有对应的过期时间。对于验证码、临时会话等本应自动失效的数据来说,这显然不符合业务预期。
因此,SET 命令本身就支持在写入 value 的同时设置过期时间:
redis
SET code 123456 EX 60
其中,EX 表示以秒为单位设置过期时间。上述命令表示:
text
写入 key:code
写入 value:123456
设置过期时间:60 秒
除了 EX 之外,SET 还支持使用 PX 以毫秒为单位设置过期时间:
redis
SET code 123456 PX 60000
上述命令同样表示验证码在 60000 毫秒,也就是 60 秒之后过期。
因此:
text
EX:
以秒为单位设置过期时间。
PX:
以毫秒为单位设置过期时间。
例如,在保存短信验证码时,可以直接写成:
redis
SET phone:13800000000:code 123456 EX 60
这样,验证码的写入与过期时间设置就可以在同一条命令中完成,既减少了一次网络交互,也避免了验证码写入成功但没有正确设置过期时间的问题。
使用 NX 与 XX 控制写入条件
除了设置过期时间之外,SET 命令还支持控制本次写入操作是否允许执行。
默认情况下,SET 命令既可以创建新的 key,也可以覆盖已经存在的 key:
redis
SET name wangz
SET name WangZ
第二条命令会直接将原有的 value 覆盖为新的 value。
但是,在一些业务场景中,我们可能并不希望无条件执行覆盖操作。例如:
- 只有当某个 key 不存在时,才允许第一次写入;
- 只有当某个 key 已经存在时,才允许对其进行更新。
为此,SET 命令提供了 NX 与 XX 两个选项。
NX:仅当 key 不存在时写入
NX 可以理解为 Not Exists ,表示只有当指定 key 当前不存在时,SET 才会成功执行。
例如:
redis
SET name wangz NX
如果 name 当前不存在,那么写入成功:
text
name = "wangz"
但是,如果 name 已经存在,再次执行:
redis
SET name WangZ NX
本次写入就不会成功,原有数据也不会被覆盖。
因此,NX 的语义可以表示为:
text
key 不存在:
允许写入。
key 已经存在:
拒绝写入。
这类操作适合用于"首次创建"语义较强的场景。
XX:仅当 key 已存在时写入
XX 表示只有当指定 key 当前已经存在时,SET 才会成功执行。
例如:
redis
SET name WangZ XX
如果 name 原本已经存在,那么 Redis 会将其 value 更新为 "WangZ"。
但是,如果当前根本不存在 name 这个 key,那么执行:
redis
SET name WangZ XX
就不会创建新的键值对。
因此,XX 的语义可以表示为:
text
key 已经存在:
允许更新。
key 不存在:
拒绝写入。
这类操作适合用于"只允许修改已有数据,而不允许创建新数据"的场景。
需要注意的是,NX 与 XX 判断的是 key 当前是否存在,与该 value 属于什么数据类型、底层采用什么内部编码方式没有关系。
组合使用过期时间与写入条件
SET 命令中的过期时间选项与条件写入选项还可以组合使用。
例如:
redis
SET code 123456 EX 60 NX
该命令表示:
text
只有当 code 当前不存在时,
才写入 value = 123456,
并同时设置 60 秒过期时间。
这意味着:
text
code 不存在:
写入验证码,并设置过期时间。
code 已经存在:
本次写入失败,不覆盖原有验证码。
这种写法可以用于限制某些临时数据在有效期内被重复创建。例如,在验证码发送场景中,如果某个手机号对应的验证码 key 仍然存在,就可以说明该验证码尚未过期,此时可以拒绝短时间内重复生成新的验证码。
同样,也可以写成:
redis
SET session:new_data EX 1800 XX
其含义是:
text
只有当 session 这个 key 已经存在时,
才更新其中保存的数据,
并重新设置 1800 秒过期时间。
因此,通过 SET 命令组合 EX、PX、NX、XX 等选项,客户端可以在一次请求中同时表达写入内容、设置有效期以及限制写入条件等多个操作需求。
使用 KEEPTTL 保留原有过期时间
这里还需要注意一个容易忽略的问题:当一个 key 原本已经设置了过期时间,而我们再次使用普通 SET 命令覆盖其 value 时,原有的过期时间默认会被清除。
例如:
redis
SET code 123456 EX 60
TTL code
此时,code 具有剩余过期时间。
但是,如果随后执行:
redis
SET code 654321
新的 value 会覆盖原有 value,并且原来设置的过期时间默认也会失效。此时再次查询:
redis
TTL code
会发现该 key 已经不再按照原来的时间自动过期。
如果我们希望在修改 value 的同时,继续保留该 key 原本已经存在的过期时间,就可以使用 KEEPTTL 选项:
redis
SET code 654321 KEEPTTL
该命令表示:
text
更新 code 对应的 value,
但是保留其原有的过期时间。
因此:
text
普通 SET 覆盖已有 value:
默认清除原有过期时间。
SET ... KEEPTTL:
更新 value 的同时保留原有过期时间。
这在某些临时状态数据的更新场景中非常有用。例如,一个验证码或者临时会话已经设置了固定有效期,后续虽然需要更新其中保存的内容,但并不希望因为更新 value 而重新延长或取消其原有生命周期,此时就可以使用 KEEPTTL 保留剩余过期时间。
SET 命令常用选项总结
至此,我们可以对 SET 命令当前接触到的常用选项做一个总结:
text
SET key value
写入或覆盖一个 String 类型的 value。
SET key value EX seconds
写入 value,并设置以秒为单位的过期时间。
SET key value PX milliseconds
写入 value,并设置以毫秒为单位的过期时间。
SET key value NX
只有当 key 不存在时,才允许写入。
SET key value XX
只有当 key 已经存在时,才允许写入。
SET key value KEEPTTL
覆盖 value 时,保留该 key 原有的过期时间。
需要注意的是,这些选项描述的是客户端希望 Redis 如何完成本次写入操作,而 value 最终采用 int、embstr 还是 raw 编码,则依旧由 Redis 根据写入内容自身的特点自动选择。
也就是说:
text
客户端负责:
通过 SET / GET 等命令表达业务操作需求。
Redis 服务端负责:
根据 value 的内容和操作特点,
自动选择合适的内部编码方式完成底层存储。
在认识了 SET 与 GET 命令以及 SET 支持的常用选项之后,接下来我们就可以继续学习 String 类型提供的其他操作命令。
APPEND:字符串追加命令
认识了 SET 与 GET 命令之后,接下来继续来看 String 类型中用于追加字符串内容的命令,也就是 APPEND 命令。
APPEND 命令的基本语法结构非常简单:
redis
APPEND key value
该命令的作用是:将指定的 value 追加到当前 key 所对应的字符串内容末尾,并返回追加完成之后字符串的总长度。
例如:
redis
SET name wangz
APPEND name "_redis"
GET name
执行完成后,name 对应的 value 就会由原来的:
text
"wangz"
变为:
text
"wangz_redis"
需要注意的是,如果执行 APPEND 时,指定的 key 原本不存在,那么 Redis 会将其视为空字符串,并直接创建该 key,再将本次追加的内容作为其 value。
例如:
redis
APPEND name wangz
GET name
如果此前并不存在 name 这个 key,那么执行完成后,查询结果就是:
text
"wangz"
APPEND 对内部编码的影响
根据前文对 String 底层编码方式的分析,我们知道,一个字符串 value 在创建时,Redis 可能会根据其内容和长度,选择 int、embstr 或 raw 编码。
而 APPEND 命令并不是使用一个新的 value 直接覆盖旧数据,而是在已有字符串内容的基础上继续追加新的字节。因此,执行 APPEND 时,可能会导致原有 value 的内部编码发生变化。
embstr 执行追加后转换为 raw
如果一个字符串最初采用 embstr 编码,那么对其执行 APPEND 之后,Redis 会将其转换为 raw 编码。
例如:
redis
SET name wangz
OBJECT ENCODING name
# 为 embstr
APPEND name "!"
OBJECT ENCODING name
# raw
这里需要特别注意:即使追加之后得到的字符串 "wangz!" 长度仍然很短,并没有超过短字符串对应的阈值,其内部编码依然会由 embstr 转换为 raw。
这是因为,在 embstr 编码下,Redis 对象与用于保存字符串内容的 SDS 位于同一块连续内存空间中。这种方式适合保存创建之后主要用于读取的短字符串对象,但是并不适合继续修改已有字符串内容。
而在 raw 编码下,Redis 对象与 SDS 分开组织,后续字符串内容的追加和扩容都可以主要围绕 SDS 进行处理。因此,当一个 embstr 字符串需要发生内容修改时,Redis 会将其转换为更适合修改的 raw 编码。
int 执行追加后同样转换为 raw
如果一个 String 类型的 value 最初保存的是整数形式的内容,那么 Redis 可能会采用 int 编码。

例如:
redis
SET num 100
OBJECT ENCODING num
# int
但是,如果随后对其执行追加操作:
redis
APPEND num "0"
GET num
# "1000"
OBJECT ENCODING num
# raw
此时,即使追加后的结果 "1000" 仍然能够表示为整数,Redis 也不会继续将其保持为 int 编码,而是会转换为 raw 编码。
原因在于,APPEND 表达的语义是:在原有字符串内容的末尾追加新的字节。它是在修改一个已有的字符串对象,而不是像 SET 一样重新写入一个新的 value 并重新选择最适合的初始编码。因此,当 int 编码的 value 需要按照字符串方式进行追加时,Redis 会将其转换为能够支持字符串修改的 raw 编码。
因此,对于 APPEND 命令来说,其对已有 value 的编码影响可以总结为:
text
原有编码为 embstr:
执行 APPEND 后转换为 raw。
原有编码为 int:
执行 APPEND 后转换为 raw。
原有编码为 raw:
执行 APPEND 后继续保持 raw。
APPEND 与 SDS 扩容
当一个字符串采用 raw 编码之后,其实际字符串内容会通过 SDS 进行管理。根据前文,我们知道 SDS 除了保存实际字节数据之外,还会维护当前有效数据长度以及已经分配的容量。
可以简单理解为:
text
len:
当前字符串实际保存的有效字节数量。
alloc:
当前 SDS 缓冲区已经分配的可用容量。
当执行 APPEND 命令时,Redis 首先需要判断当前 SDS 已经分配的空间是否足够容纳本次新增内容。
例如,假设当前 SDS 的状态为:
text
len = 5
alloc = 10
这表示当前字符串中已经保存了 5 个有效字节,但是底层缓冲区总共可以容纳 10 个字节。
如果此时追加 3 个字节,那么追加完成后的有效长度为:
text
新长度 = 5 + 3 = 8
由于:
text
8 <= 10
当前已经分配的空间仍然足够,因此 Redis 可以直接将新增内容写入现有 SDS 缓冲区中,而不需要重新扩容。
但是,如果此时需要追加 8 个字节,那么追加完成后的有效长度为:
text
新长度 = 5 + 8 = 13
由于:
text
13 > 10
当前 SDS 已经分配的容量不足以容纳追加后的完整内容,因此 Redis 就需要扩展 SDS 所使用的数据空间。
可以抽象理解为:
text
扩容之前:
Redis 对象 原 SDS
┌─────────────────────┐ ┌────────────────────┐
│ encoding = raw │ ───────→ │ len = 5 │
│ ptr │ │ alloc = 10 │
└─────────────────────┘ │ buf = "hello" │
└────────────────────┘
追加内容并触发扩容之后:
Redis 对象 扩容后的 SDS
┌─────────────────────┐ ┌──────────────────────────┐
│ encoding = raw │ ───────→ │ len = 13 │
│ ptr │ │ alloc >= 13 │
└─────────────────────┘ │ buf = "hello........" │
└──────────────────────────┘
在扩容过程中,字符串的有效数据长度和已分配容量等信息都由 SDS 自身负责更新。如果扩容后的 SDS 所处内存地址发生变化,那么 Redis 只需要进一步更新 Redis 对象中的 ptr 字段,使其重新指向扩容后的 SDS 即可,而不需要重新创建整个 Redis 对象。
这里与 C++ 中 std::string 的动态扩容思想具有一定相似性:当当前已有容量足够时,字符串可以直接在原有空间后方追加内容;而当当前容量不足时,就需要重新申请更大的存储空间,以保存追加后的完整数据。
因此,APPEND 命令虽然在上层只是一个简单的字符串追加操作,但是结合底层编码方式来看,其背后可能涉及两个重要过程:
text
第一步:
如果原 value 采用 int 或 embstr 编码,
Redis 会先将其转换为适合修改的 raw 编码。
第二步:
Redis 基于 raw 编码下的 SDS 追加数据;
如果现有容量不足,则进一步触发 SDS 扩容。
由此可见,在认识 String 类型的内部编码方式之后,我们不仅能够知道 APPEND 命令如何使用,还能够理解该命令执行过程中可能发生的编码转换与底层空间调整。
SETRANGE:从指定位置覆盖字符串内容
认识了 APPEND 命令之后,接下来继续来看另一个能够修改 String 类型 value 的命令,也就是 SETRANGE 命令。
SETRANGE 的基本语法结构如下:
redis
SETRANGE key offset value
其中:
text
key:
需要修改的目标键。
offset:
开始写入数据的位置,单位是字节。
value:
从指定位置开始写入的新内容。
例如,当前保存了一个字符串:
redis
SET msg "Hello World"
现在希望将其中的 "World" 修改为 "Redis",就可以执行:
redis
SETRANGE msg 6 "Redis"
GET msg
执行结果为:
text
"Hello Redis"
这里原字符串的内容可以简单表示为:
text
'H' 'e' 'l' 'l' 'o' ' ' 'W' 'o' 'r' 'l' 'd'
0 1 2 3 4 5 6 7 8 9 10
偏移量 6 正好对应字符 "W" 所在的位置,因此 Redis 会从该位置开始,将新的字节内容 "Redis" 逐个写入原字符串中。
SETRANGE 的本质是覆盖字节,而不是删除原有子串
虽然我们可以将 SETRANGE 理解为一种"替换字符串内容"的命令,但是需要注意,它的底层行为并不是先找到某一个完整子串,再将该子串整体删除并替换为新的内容。
SETRANGE 的真实行为是:
从指定的字节偏移量开始,将新的字节内容逐个覆盖到原字符串中。
例如:
redis
SET text "abcdef"
SETRANGE text 2 "X"
GET text
原字符串为:
text
'a' 'b' 'c' 'd' 'e' 'f'
0 1 2 3 4 5
执行 SETRANGE text 2 "X" 之后,Redis 只会从偏移量 2 开始,用 "X" 覆盖原来的 "c":
text
'a' 'b' 'X' 'd' 'e' 'f'
最终结果为:
text
"abXdef"
可以看到,原字符串后方未被覆盖的 "def" 并不会被自动删除。
因此,SETRANGE 的语义应当理解为:
text
从指定偏移量开始写入新的字节内容;
新内容覆盖到哪里,原内容就被替换到哪里;
没有被覆盖到的后续内容仍然继续保留。
写入内容超过原字符串末尾时,会使字符串长度增加
如果新写入的内容超过了原字符串已有的末尾位置,那么 SETRANGE 不仅会覆盖原有内容,还会继续向后写入,从而使字符串长度增加。
例如:
redis
SET text "hello"
SETRANGE text 3 "redis"
GET text
原字符串为:
text
'h' 'e' 'l' 'l' 'o'
0 1 2 3 4
现在从偏移量 3 开始写入 "redis":
text
原有内容:
'h' 'e' 'l' 'l' 'o'
覆盖并继续写入之后:
'h' 'e' 'l' 'r' 'e' 'd' 'i' 's'
因此,最终查询结果为:
text
"helredis"
可以将这种情况总结为:
text
如果新写入内容没有超过原字符串末尾:
只覆盖已有字节,字符串总长度保持不变。
如果新写入内容超过原字符串末尾:
在覆盖已有字节之后继续向后写入,
字符串总长度增加。
SETRANGE 的偏移量单位是字节
这里还需要注意一个非常重要的问题:SETRANGE 中的 offset 表示的是字节偏移量,而不是字符偏移量。
Redis 的 String 本质上保存的是字节序列。对于 Redis 来说,它只负责按照字节位置读取或者修改数据,并不会主动识别某几个字节共同组成了一个中文字符。
对于普通英文字母来说,在 ASCII 编码中,一个英文字母只需要一个字节表示;在 UTF-8 编码中,ASCII 范围内的英文字母同样只占用一个字节。因此,对于纯英文字符串来说,我们通常可以直接将字符位置近似理解为字节偏移位置。
例如:
text
"hello"
'h' 'e' 'l' 'l' 'o'
0 1 2 3 4
但是,对于中文字符串来说,情况就不同了。在 UTF-8 编码下,一个中文字符通常需要三个字节表示。
例如,字符串:
text
"你好"
从字符角度来看,它包含两个中文字符;但是从底层字节序列角度来看,它通常包含六个字节:
text
"你" "好"
3 个字节 3 个字节
如果我们使用 SETRANGE 修改中文字符串时,指定的偏移量刚好落在某一个中文字符对应的多个字节中间,那么就可能只覆盖该字符的一部分字节,从而破坏原有完整的 UTF-8 编码序列。
例如,原本一个中文字符需要三个字节表示:
text
第 0 字节 第 1 字节 第 2 字节
└──────── 一个完整中文字符 ────────┘
如果只修改了其中第 1 个字节,那么原本完整的字符编码就会被破坏。之后客户端再按照 UTF-8 方式解析这段数据时,就可能出现乱码或者无法正常显示的问题。
因此,在使用 SETRANGE 修改包含中文等多字节字符的字符串时,需要特别注意:
text
Redis 只按照字节偏移量修改数据;
程序员必须自行保证修改范围不会破坏一个完整字符对应的字节序列。
key 不存在时,SETRANGE 会创建新的字符串
SETRANGE 不仅可以修改已经存在的字符串 value,还可以在指定 key 不存在时创建新的字符串。
例如:
redis
SETRANGE msg 0 "redis"
GET msg
如果此前并不存在 msg 这个 key,那么 Redis 会创建一个新的 String 类型 value,并从偏移量 0 开始写入 "redis"。
最终结果为:
text
"redis"
但是,如果指定的偏移量并不是 0,那么在真正写入新内容之前,Redis 会将偏移量之前的空缺区域补充为值为 0 的字节,也就是 \0。
例如:
redis
SETRANGE msg 5 "redis"
由于 msg 原本不存在,而本次写入需要从偏移量 5 开始,因此 Redis 会先补充前面的五个空缺字节:
text
偏移位置:
0 1 2 3 4 5 6 7 8 9
内容:
'\0' '\0' '\0' '\0' '\0' 'r' 'e' 'd' 'i' 's'
这里也能够进一步体现 Redis String 类型具备二进制安全性。
如果按照传统 C 字符串的处理方式,数据开头存在 \0 就可能被直接认为字符串已经结束。但是,Redis 底层通过 SDS 保存字符串数据,并通过有效数据长度判断完整内容,因此即使字符串前方存在多个 \0,Redis 仍然能够完整保存和处理后续的 "redis" 数据。
SETRANGE 对内部编码的影响
结合此前学习过的 String 内部编码方式,我们还可以进一步分析 SETRANGE 命令执行过程中可能发生的编码变化。
SETRANGE 与 APPEND 类似,都属于在已有字符串 value 基础上修改内容的操作。因此,如果原有 value 采用的是 embstr 或 int 编码,那么在执行 SETRANGE 之后,Redis 会将其转换为更加适合修改字符串内容的 raw 编码。
SETRANGE 命令总结
至此,我们可以对 SETRANGE 命令做一个简单总结:
text
SETRANGE key offset value:
从指定字节偏移量开始,
使用新的字节内容覆盖原有字符串。
核心特点:
1. offset 的单位是字节,而不是字符;
2. 只覆盖指定范围,不会主动删除后方未被覆盖的数据;
3. 如果写入内容超过原字符串末尾,会使字符串长度增加;
4. 如果 key 不存在,会创建新的字符串;
5. 如果 key 不存在且 offset 大于 0,
前方空缺区域会使用 '\0' 补齐;
6. 修改 UTF-8 中文等多字节字符时,
需要避免破坏完整字符对应的字节序列;
7. 对已有 int 或 embstr 编码的 value 执行修改后,
Redis 会将其转换为 raw 编码。
由此可见,SETRANGE 表面上只是一个从指定位置修改字符串内容的命令,但结合 Redis String 类型的底层实现来看,它同时体现了字节序列操作、SDS 二进制安全性以及内部编码转换等多个知识点。
GETRANGE:获取字符串指定范围内的内容
认识了用于覆盖字符串内容的 SETRANGE 命令之后,接下来再来看一个与其名称比较相似,但作用完全不同的命令,也就是 GETRANGE。
SETRANGE 用于修改字符串内容,而 GETRANGE 则用于读取字符串中指定范围内的部分内容,并不会对原有 value 产生修改。
text
SETRANGE:
从指定偏移量开始,使用新的内容覆盖原字符串。
GETRANGE:
根据指定范围,读取原字符串中的部分内容。
GETRANGE 的基本语法结构如下:
redis
GETRANGE key start end
其中:
text
key:
需要读取的目标键。
start:
截取内容的起始索引。
end:
截取内容的结束索引。
这里可以将 GETRANGE 类比为 C++ 标准库中 std::string 提供的 substr() 操作,它们都可以用于从原字符串中获取一段子串。
例如,在 C++ 中:
cpp
std::string str = "Hello Redis";
std::string sub = str.substr(6, 5);
其中,substr() 的第一个参数表示起始位置,第二个参数表示需要截取的字符数量。因此,上述代码表示:从索引 6 开始,向后截取 5 个字符,最终得到:
text
"Redis"
而在 Redis 中,GETRANGE 的参数形式并不是"起始位置 + 截取长度",而是"起始索引 + 结束索引"。
例如:
redis
SET msg "Hello Redis"
GETRANGE msg 6 10
执行结果为:
text
"Redis"
这里原字符串对应的索引关系如下:
text
'H' 'e' 'l' 'l' 'o' ' ' 'R' 'e' 'd' 'i' 's'
0 1 2 3 4 5 6 7 8 9 10
GETRANGE msg 6 10 表示从索引 6 开始读取,一直读取到索引 10 为止,并且起始位置与结束位置对应的内容都会包含在最终结果中。
因此,GETRANGE 的读取区间属于:
text
[start, end]
也就是左闭右闭区间。
这与 C++ 中 std::string::substr(pos, count) 的参数语义存在明显区别:
text
std::string::substr(pos, count):
第一个参数是起始位置;
第二个参数是截取长度;
范围可以理解为 [pos, pos + count)。
GETRANGE key start end:
第二、三个参数分别是起始索引和结束索引;
范围为 [start, end]。
由于在程序设计中,我们通常更熟悉左闭右开的区间表达方式,因此第一次使用 GETRANGE 时,需要特别注意其结束索引对应的数据同样会被包含在返回结果中。
GETRANGE 的索引单位是字节
与前面介绍的 SETRANGE 一样,GETRANGE 中的 start 与 end 表示的同样是字节索引,而不是从人类阅读角度理解的字符索引。
Redis 的 String 类型本质上保存的是一段字节序列。Redis 在执行 GETRANGE 时,只会按照指定的字节位置截取内容,并不会主动识别某几个字节共同组成了一个完整字符。
对于纯英文字符串来说,这一点通常不会带来明显问题。因为在 ASCII 编码中,一个英文字母占用一个字节;而在 UTF-8 编码中,ASCII 范围内的英文字母同样只需要一个字节表示。
例如:
redis
SET msg "Hello Redis"
GETRANGE msg 0 4
执行结果为:
text
"Hello"
这里字符串中的每一个英文字母都对应一个字节,因此字节索引与我们看到的字符位置基本一致。
但是,对于中文字符串来说,情况就不同了。在 UTF-8 编码下,一个中文字符通常需要三个字节表示。
例如,字符串:
text
"你好"
从字符角度来看,其中只包含两个中文字符;但是从底层字节序列角度来看,它通常需要六个字节进行表示:
text
"你" "好"
3 个字节 3 个字节
如果使用 GETRANGE 截取中文字符串时,指定的起始位置或者结束位置落在某一个中文字符对应的字节序列中间,那么最终返回的数据就只包含该字符的一部分字节。此时,客户端再次按照 UTF-8 方式解析这段字节内容时,就可能出现乱码或者无法正确显示的问题。
因此,在使用 GETRANGE 读取包含中文等多字节字符的字符串时,需要自行保证截取范围落在完整字符对应的字节边界上。
可以简单理解为:
text
Redis 负责:
按照字节索引截取数据。
程序员负责:
保证截取范围不会破坏完整字符对应的字节序列。
GETRANGE 支持负数索引
除了使用从 0 开始的正数索引之外,GETRANGE 还支持使用负数索引从字符串末尾开始定位。
其中:
text
-1:
表示字符串中的最后一个字节。
-2:
表示字符串中的倒数第二个字节。
-3:
表示字符串中的倒数第三个字节。
例如:
redis
SET msg "Hello Redis"
GETRANGE msg -5 -1
执行结果为:
text
"Redis"
这是因为 "Redis" 正好对应原字符串末尾的五个字节。
同样,如果希望读取某一个字符串的完整内容,也可以写成:
redis
GETRANGE msg 0 -1
其中:
text
0:
表示从字符串第一个字节开始读取。
-1:
表示一直读取到字符串最后一个字节。
因此,最终会返回完整的字符串内容:
text
"Hello Redis"
索引超出范围时的处理方式
GETRANGE 在读取字符串时,即使指定的索引超过了字符串当前的实际范围,也不会直接报错。
例如:
redis
SET msg "Hello"
GETRANGE msg 0 100
虽然结束索引 100 已经超过了字符串本身的长度,但是 Redis 会自动将读取范围限制在字符串实际存在的内容范围内,因此最终仍然会返回:
text
"Hello"
而如果起始索引本身已经超过了字符串的有效范围:
redis
GETRANGE msg 100 200
由于已经没有任何可以读取的内容,因此 Redis 会返回空字符串:
text
""
因此,可以将超出范围时的处理方式理解为:
text
结束索引超过字符串末尾:
自动读取到字符串真实末尾为止。
起始索引已经超过字符串末尾:
没有可读取内容,返回空字符串。
key 不存在时的处理方式
这里还需要注意,GETRANGE 并不要求目标 key 必须已经存在。
如果执行 GETRANGE 时,指定的 key 当前不存在,那么 Redis 不会报错,也不会创建新的键值对,而是直接返回空字符串。
例如:
redis
GETRANGE not_exists 0 10
执行结果为:
text
""
这是因为 GETRANGE 本质上属于读取操作。当目标 key 不存在时,可以理解为当前没有任何字符串内容可以被截取,因此返回空字符串即可。
这一点与前面介绍的 SETRANGE 存在明显区别:
text
SETRANGE:
属于写操作;
如果 key 不存在,会创建新的 String value;
如果 offset 大于 0,还会使用 '\0' 补齐前方空缺区域。
GETRANGE:
属于读操作;
如果 key 不存在,不会创建任何数据;
只会返回空字符串。
GETRANGE 命令总结
至此,我们可以对 GETRANGE 命令做一个简单总结:
text
GETRANGE key start end:
用于读取 String 类型 value 中指定范围内的内容。
核心特点:
1. start 与 end 表示起始索引和结束索引;
2. 读取区间为左闭右闭区间,即 [start, end];
3. 索引单位是字节,而不是字符;
4. 支持负数索引,-1 表示最后一个字节;
5. 结束索引超过实际范围时,会自动读取到字符串末尾;
6. 起始索引超过实际范围时,会返回空字符串;
7. key 不存在时,不会报错,也不会创建数据,而是返回空字符串;
8. 读取中文等多字节字符内容时,需要避免截取到某个字符的部分字节。
与 SETRANGE 不同,GETRANGE 并不会修改原有 value,因此也不会导致字符串对象发生内部编码转换。它只是根据指定的字节范围,从已有字符串数据中读取并返回对应内容。
String 类型的数值运算命令
在前文中,我们认识了 String 类型可能采用的 int 编码。对于能够表示为有符号 64 位整数的字符串内容,Redis 可以直接按照整数形式组织该 value,而不必额外创建 SDS 保存数字对应的字符序列。
正因如此,Redis 也为保存整数形式内容的 String value 提供了一组数值运算命令,例如 INCR、DECR、INCRBY 和 DECRBY。
不过,这里需要特别注意:这些命令能否执行,并不取决于当前 value 的底层编码是否一定为 int,而是取决于该 value 当前保存的内容是否能够被正确解析为整数。
也就是说,int 只是 Redis 针对整数形式字符串采用的一种底层存储优化;而整数运算命令面向的,仍然是逻辑类型为 String、并且内容满足整数语义的 value。
INCR 与 DECR:整数自增与自减
INCR 命令用于将指定 key 对应的整数形式字符串增加 1,其语法结构如下:
redis
INCR key
例如:
redis
SET age 19
INCR age
GET age
执行结果为:
text
(integer) 20
"20"
这里可以将 INCR 的语义理解为程序设计语言中的自增操作:
cpp
age++;
与之对应,DECR 命令用于将当前整数值减少 1:
redis
DECR key
例如:
redis
SET age 20
DECR age
GET age
执行结果为:
text
(integer) 19
"19"
其语义可以类比为:
cpp
age--;
需要注意的是,如果执行 INCR 或 DECR 时,指定的 key 原本不存在,那么 Redis 会将其初始值视为 0,然后再执行对应的运算。
例如:
redis
INCR count
GET count
执行结果为:
text
(integer) 1
"1"
而:
redis
DECR stock
GET stock
执行结果为:
text
(integer) -1
"-1"
INCRBY 与 DECRBY:按照指定数值增减
如果希望一次增加的数值不只是 1,就可以使用 INCRBY 命令。
其语法结构如下:
redis
INCRBY key increment
例如:
redis
SET score 100
INCRBY score 20
GET score
执行结果为:
text
(integer) 120
"120"
INCRBY 的第二个参数表示本次需要增加的整数值。由于加上一个负数在数学意义上等价于减法,因此下面的命令同样可以让 score 减少 20:
redis
INCRBY score -20
不过,为了让命令语义更加直观,Redis 还提供了专门用于减少指定数值的 DECRBY 命令:
redis
DECRBY key decrement
例如:
redis
SET score 100
DECRBY score 20
GET score
执行结果为:
text
(integer) 80
"80"
因此,这四个命令的基本作用可以总结为:
text
INCR key:
将当前整数值增加 1。
DECR key:
将当前整数值减少 1。
INCRBY key increment:
将当前整数值增加指定数值。
DECRBY key decrement:
将当前整数值减少指定数值。
整数运算命令关注的是内容,而不是当前编码
这里是理解这组命令时最容易产生误区的地方。
由于前文介绍过 int 编码,读者可能会认为:只有当前内部编码为 int 的 String value,才能够执行 INCR、DECR 等整数运算命令。
但实际上并非如此。
例如,首先写入一个整数形式的字符串:
redis
SET age 19
OBJECT ENCODING age
此时,age 可能采用 int 编码:
text
"int"
随后,在其末尾追加一个数字字符:
redis
APPEND age 0
GET age
OBJECT ENCODING age
执行结果为:
text
"190"
"raw"
根据前文对 APPEND 的分析,我们知道:由于 APPEND 修改了原有字符串内容,因此 value 会转换为更适合修改的 raw 编码。
但是,虽然此时 age 的内部编码已经变为 raw,其保存的内容 "190" 仍然能够被正确解析为整数。因此,继续执行:
redis
INCR age
仍然可以成功:
text
(integer) 191
这说明:
text
encoding = raw:
并不意味着不能执行整数运算。
value 内容仍然是合法整数形式:
就仍然可以执行 INCR、DECR、INCRBY、DECRBY。
相反,如果追加的内容破坏了原有整数形式,例如:
redis
SET age 19
INCR age
APPEND age "?"
GET age
此时得到:
text
"20?"
虽然 age 仍然属于 String 类型,但是 "20?" 已经不能被解析为合法整数。因此,再次执行:
redis
INCR age
Redis 就会返回错误:
text
(error) ERR value is not an integer or out of range
这里报错的根本原因并不是内部编码变成了 raw,而是当前 value 保存的内容已经不再满足整数语义。
因此,整数运算命令的执行条件可以总结为:
text
1. 当前 key 对应的 value 必须属于 String 类型;
2. 当前字符串内容必须能够被解析为有符号 64 位整数;
3. 执行运算后的结果不能超出有符号 64 位整数范围。
也就是说:
text
内部编码:
决定 Redis 底层如何保存 value。
value 内容:
决定整数运算命令能否正确执行。

整数响应与 String 类型之间的关系
在执行 INCR、DECR、INCRBY 或 DECRBY 时,redis-cli 会将命令执行结果显示为整数响应:
redis
SET count 100
INCR count
返回结果为:
text
(integer) 101
但是,这并不意味着 Redis 中的 count 被转换成了一种独立的整数逻辑类型。
继续执行:
redis
TYPE count
其结果仍然为:
text
string
也就是说:
text
命令返回结果:
Redis 以整数响应的形式返回本次计算结果。
Redis 中保存的 value:
对客户端来说,逻辑类型仍然属于 String。
而 int 编码只是 Redis 在底层保存整数形式字符串时可能采用的一种内部实现方式。
INCRBYFLOAT:浮点数增量运算
除了整数运算之外,Redis 还支持对浮点数形式的字符串进行增量计算,对应的命令是 INCRBYFLOAT。
其语法结构如下:
redis
INCRBYFLOAT key increment
例如:
redis
SET price 10.5
INCRBYFLOAT price 0.2
GET price
执行结果为:
text
"10.7"
"10.7"
这里需要注意,虽然 Redis 支持对浮点数形式的字符串进行计算,但是 String 类型的内部编码中并不存在类似 int 一样专门用于长期保存浮点数的浮点编码。
因此,对于下面这样的 value:
redis
SET price 10.5
Redis 在存储状态下仍然会按照普通字符串形式保存 "10.5"。
当执行:
redis
INCRBYFLOAT price 0.2
Redis 会完成如下处理过程:
text
读取当前字符串内容 "10.5"
↓
将其解析为浮点数
↓
与增量 0.2 进行浮点运算
↓
将计算结果重新转换为字符串保存
↓
新的 value 为 "10.7"
因此,整数运算与浮点数运算的底层处理方式存在区别:
text
整数形式的 String value:
Redis 可能采用 int 编码直接保存整数;
执行整数运算时,可以利用整数形式完成处理。
浮点形式的 String value:
Redis 不提供专门的浮点内部编码;
存储状态下仍然以字符串形式保存;
执行 INCRBYFLOAT 时,临时解析为浮点数完成计算,
再将结果转换为字符串保存。
数值运算命令总结
至此,我们可以对 String 类型相关的数值运算命令做一个总结:
text
INCR key:
将整数形式的 String value 增加 1。
DECR key:
将整数形式的 String value 减少 1。
INCRBY key increment:
将整数形式的 String value 增加指定整数。
DECRBY key decrement:
将整数形式的 String value 减少指定整数。
INCRBYFLOAT key increment:
将浮点形式的 String value 增加指定浮点数。
其中最需要注意的一点是:
text
INCR、DECR、INCRBY、DECRBY 能否执行,
取决于 String value 当前保存的内容能否被解析为合法整数,
而不是取决于其底层编码是否一定为 int。
int 编码解决的是整数形式字符串如何在底层更加高效地保存和处理的问题;而整数运算命令解决的是客户端如何对满足整数语义的 String value 进行数值修改的问题。两者相关,但并不能简单等同。
STRLEN:获取字符串长度
最后,再来看一个用于获取字符串长度的命令,也就是 STRLEN。
STRLEN 的语法结构如下:
redis
STRLEN key
该命令用于返回指定 key 对应的 String value 的长度。
例如:
redis
SET name wangzhe
STRLEN name
执行结果为:
text
(integer) 8
这是因为字符串 "wangzhe" 由 8 个字节组成。
这里需要注意,STRLEN 返回的是字符串内容所占用的字节长度,而不是我们从阅读角度看到的字符数量。
对于纯英文字符串来说,在 UTF-8 编码下,一个英文字母通常占用一个字节,因此字节长度与字符数量通常一致:
redis
SET name wangzhe
STRLEN name
# (integer) 8
但是,对于中文字符串来说,一个中文字符在 UTF-8 编码下通常占用三个字节。例如:
redis
SET message "你好"
STRLEN message
执行结果通常为:
text
(integer) 6
虽然 "你好" 从阅读角度来看只包含两个中文字符,但是其 UTF-8 字节序列通常占用六个字节。因此,STRLEN 返回的是 6,而不是 2。
这一点与前面学习过的 SETRANGE 和 GETRANGE 是一致的:Redis 的 String 类型本质上保存的是字节序列,因此字符串长度、读取范围以及覆盖位置等操作,都是基于字节进行处理的。
STRLEN 与 SDS 的关系
在前文分析 String 类型的底层实现时,我们已经认识了 SDS。与传统 C 字符串依赖 \0 判断字符串结尾不同,SDS 会额外记录当前字符串的有效数据长度。
因此,当 Redis 执行 STRLEN 命令时,并不需要从字符串起始位置开始,逐字节向后遍历直到找到末尾位置,而是可以直接读取 SDS 中已经维护的有效数据长度。
可以简单理解为:
text
传统 C 字符串获取长度:
从头扫描字节数据,直到遇到 '\0'
时间复杂度为 O(N)
Redis SDS 获取长度:
直接读取已经记录的有效数据长度
时间复杂度为 O(1)
这也体现了 Redis 采用 SDS 保存普通字符串数据的一个重要优势:通过额外维护长度信息,能够提高获取字符串长度这类操作的执行效率。Redis 官方文档中,STRLEN 命令的时间复杂度即为 O(1)。
这里还可以结合此前学习过的 int 编码进一步理解。即使某个 String value 当前采用的是 int 编码,例如:
redis
SET age 100
OBJECT ENCODING age
# 可能为 int
STRLEN age
STRLEN age 返回的仍然是该 value 按照字符串语义表示后的字节长度:
text
(integer) 3
而对于采用 int 编码的 value 来说,其底层直接保存的是整数值本身,并不存在 SDS 中记录的字符串长度。此时,Redis 会根据该整数以十进制字符串形式展示时所需要的字符数量,计算其对应的长度。例如,整数 100 对应字符串 "100",长度为 3;整数 -100 对应字符串 "-100",长度为 4。这一过程只需要计算十进制表示所占用的字符数量,并不需要完整生成一个新的字符串对象。
key 不存在时的处理方式
如果执行 STRLEN 时,指定的 key 当前不存在,那么 Redis 不会返回 nil,也不会报错,而是直接返回整数 0。
例如:
redis
STRLEN not_exists
执行结果为:
text
(integer) 0
这是因为当前不存在对应的字符串内容,因此其长度可以直接视为 0。Redis 官方文档规定,STRLEN 返回指定字符串 value 的长度;当 key 不存在时,返回 0。
这里可以与此前学习过的 GET 和 GETRANGE 做一个简单区分:
text
GET key:
key 不存在时,返回 nil。
GETRANGE key start end:
key 不存在时,返回空字符串。
STRLEN key:
key 不存在时,返回整数 0。
value 不是 String 类型时的处理方式
STRLEN 是用于操作 String 类型 value 的命令。因此,如果指定 key 对应的 value 属于其他逻辑数据类型,例如 List、Set 或 Hash,那么执行 STRLEN 就会返回类型错误。
可以简单理解为:
text
key 对应 String value:
返回字符串的字节长度。
key 不存在:
返回 0。
key 对应的 value 不是 String 类型:
返回类型错误。
STRLEN 命令总结
至此,我们可以对 STRLEN 命令做一个简单总结:
text
STRLEN key:
用于获取 String 类型 value 的字节长度。
核心特点:
1. 返回的是字节数量,而不是字符数量;
2. 对于 UTF-8 中文字符串,一个中文字符通常对应多个字节;
3. key 不存在时,返回整数 0,而不是 nil;
4. key 对应的 value 不是 String 类型时,会返回类型错误;
5. 由于 Redis 可以直接获取字符串长度信息,
STRLEN 的时间复杂度为 O(1)。
通过 STRLEN 命令,我们也能够进一步看到前文学习底层编码的意义:对于上层来说,程序员只需要通过统一命令获取字符串长度;而在底层,Redis 会根据 value 当前采用的表示方式,完成相应的处理,并对客户端保持一致的 String 操作语义。
String 类型的典型应用场景:短信验证码
认识了 String 类型的底层编码方式以及相关操作命令之后,文章的最后,我们可以通过一个常见的业务场景,将前面学习过的知识串联起来。String 类型一个非常典型的应用场景,就是保存手机短信验证码以及统计验证码获取次数。
当用户在登录或者注册页面中输入手机号,并点击"获取验证码"按钮时,客户端会向后端服务器发送请求。后端服务器接收到手机号之后,可以调用一个用于生成验证码的业务函数,例如:
cpp
getCode(phoneNumber);
该函数的参数就是用户输入的手机号。
由于 Redis 中所有 key 都位于同一个键空间中,因此,在设计 key 时,通常不会直接将手机号本身作为 key,而是会在手机号前面拼接具有业务含义的前缀。
例如:
text
sms:code:13800000000
sms:count:13800000000
其中:
text
sms:code:<手机号>:
用于保存真正发送给用户的短信验证码。
sms:count:<手机号>:
用于保存该手机号在某一个时间窗口内已经获取验证码的次数。
之所以需要将验证码内容与获取次数分别保存到两个 key 中,是因为二者表达的业务含义不同。
例如,假设用户获得的验证码为:
text
482913
那么 Redis 中可以保存:
text
sms:code:13800000000 = "482913"
而用户当前已经获取验证码的次数则可以保存为:
text
sms:count:13800000000 = "1"
如果将验证码和获取次数混在同一个 key 中,那么后续为了统计获取次数而执行 INCR 命令时,就会错误地修改真正用于校验的验证码内容,导致用户收到的验证码与 Redis 中保存的验证码不一致。因此,验证码内容与获取次数必须分别使用不同的 key 进行保存。
保存验证码并设置有效期
短信验证码通常只在较短时间内有效。例如,系统可以规定验证码在生成后的 60 秒内有效,并且在这 60 秒内不允许用户重复生成新的验证码。
对于这种需求,可以使用前面学习过的 SET 命令,并结合 EX 与 NX 选项完成:
redis
SET sms:code:13800000000 482913 EX 60 NX
该命令中:
text
sms:code:13800000000:
保存验证码内容的 key。
482913:
本次生成的随机验证码。
EX 60:
设置该验证码在 60 秒后自动过期。
NX:
只有当该 key 当前不存在时,才允许写入成功。
因此,这条命令表达的完整语义就是:
text
只有当该手机号当前没有仍在有效期内的验证码时,
才保存新的验证码 482913,
并设置该验证码在 60 秒后自动失效。
第一次获取验证码时,sms:code:13800000000 这个 key 尚不存在,因此写入可以成功:
text
写入成功:
sms:code:13800000000 = "482913"
过期时间 = 60 秒
随后,服务器就可以将生成的验证码通过短信服务发送给用户。
但是,如果用户在验证码尚未过期之前再次点击"获取验证码",那么该 key 仍然存在。由于命令中设置了 NX 选项,本次写入就不会成功:
redis
SET sms:code:13800000000 739125 EX 60 NX
此时 Redis 会拒绝覆盖原有验证码,服务器就可以根据执行结果向用户返回提示:
text
验证码仍在有效期内,请稍后再试。
这里使用 SET ... EX ... NX 的好处在于,验证码的写入、过期时间设置以及短时间防重复生成这几个动作,可以通过一条命令同时表达。
如果将写入验证码和设置过期时间拆分为两条命令:
redis
SET sms:code:13800000000 482913
EXPIRE sms:code:13800000000 60
那么不仅会增加一次客户端与 Redis 服务端之间的网络交互,还可能出现验证码已经写入成功,但过期时间没有成功设置的中间状态。而通过:
redis
SET sms:code:13800000000 482913 EX 60 NX
就可以避免这一问题。
使用整数形式的 String 统计获取次数
仅仅限制用户在 60 秒内不能重复生成验证码通常还不够。为了避免某个手机号在较长时间范围内频繁请求验证码,业务中还可以限制同一个手机号在一天内最多获取若干次验证码。
例如,可以规定:
text
同一个手机号一天内最多成功获取 5 次验证码。
这时,就可以使用另一个 key 专门保存验证码获取次数:
text
sms:count:13800000000
由于获取次数本质上是一个整数,因此该 value 仍然可以使用 String 类型保存,并通过 INCR 命令完成递增操作:
redis
INCR sms:count:13800000000
如果该 key 原本不存在,那么 Redis 会将其初始值视为 0,随后完成加一操作:
text
第一次成功获取验证码:
sms:count:13800000000 = "1"
之后,每当该手机号成功获得一次新的验证码,就可以将获取次数继续递增:
redis
INCR sms:count:13800000000
例如:
text
第一次成功发送验证码:count = 1
第二次成功发送验证码:count = 2
第三次成功发送验证码:count = 3
......
第五次成功发送验证码:count = 5
当获取次数已经达到业务限制之后,服务器就可以拒绝继续向该手机号发送新的验证码。
由于这里统计的是"一天内"的获取次数,因此计数 key 同样需要设置过期时间。例如,可以为其设置一天的生命周期:
redis
EXPIRE sms:count:13800000000 86400
其中:
text
86400 秒 = 24 小时
这样,当一天时间过去之后,该手机号对应的获取次数记录就会自动失效,下一时间窗口内又可以重新开始统计。
需要注意的是,sms:count:<手机号> 保存的是获取次数,而 sms:code:<手机号> 保存的是真正用于用户验证的验证码。二者虽然都可以使用 String 类型保存,但承担的业务职责完全不同:
text
sms:code:<手机号>:
保存验证码本身;
生命周期较短,例如 60 秒;
用于后续校验用户输入的验证码是否正确。
sms:count:<手机号>:
保存获取次数;
生命周期较长,例如 24 小时;
用于限制验证码发送频率。
验证码获取流程
结合前面的分析,一个简化的验证码获取流程可以理解为:
text
用户输入手机号并点击获取验证码
↓
后端服务器接收手机号
↓
构造验证码 key:
sms:code:<手机号>
构造次数 key:
sms:count:<手机号>
↓
判断该手机号在当前时间窗口内
是否已经超过最大获取次数
↓
未超过限制:
生成随机验证码
↓
尝试执行:
SET sms:code:<手机号> <验证码> EX 60 NX
↓
写入成功:
说明当前不存在有效验证码
向用户发送短信验证码
并更新成功获取次数
↓
写入失败:
说明当前仍存在有效验证码
提示用户稍后再试
cpp
string getCode(string phone)
{
string codeKey = "sms:code:" + phone;
string countKey = "sms:count:" + phone;
string command = "GET " + countKey;
string count = sendToRedis(command);
if (count != "nil" && stoi(count) >= 5)
{
return "今日验证码获取次数已达到上限";
}
string code = getRandomCode();
command = "SET " + codeKey + " " + code + " EX 60 NX";
string result = sendToRedis(command);
if (result == "nil")
{
return "验证码仍在有效期内,请稍后再试";
}
command = "INCR " + countKey;
string newCount = sendToRedis(command);
if (newCount == "1")
{
command = "EXPIRE " + countKey + " 86400";//86400秒也就是24小时
sendToRedis(command);
}
sendMessage(phone, code);
return "验证码发送成功";
}
在上述代码中,服务器首先根据手机号构造两个不同语义的 key:sms:code:<手机号> 用于保存真正发送给用户的验证码,sms:count:<手机号> 用于保存该手机号在一天内成功获取验证码的次数。随后,服务器通过动态拼接 Redis 命令字符串,并将命令发送给 Redis 服务端执行。
其中,SET sms:code:<手机号> <验证码> EX 60 NX 用于保存验证码:EX 60 表示验证码在 60 秒后自动过期,NX 表示只有当前不存在有效验证码时,新的验证码才允许写入成功。验证码成功写入之后,再通过 INCR sms:count:<手机号> 对成功获取次数进行递增统计;如果这是第一次计数,则通过 EXPIRE 为次数记录设置一天的过期时间。
这样,String 类型提供的 SET、NX、EX、INCR 与 EXPIRE 等命令,就能够共同完成验证码内容保存、有效期控制、短时间防止重复生成以及获取次数统计等业务需求。
这样,前面学习过的多个 String 类型命令就可以在同一个业务场景中体现出来:
text
SET:
保存验证码内容。
EX:
设置验证码有效期。
NX:
防止验证码仍在有效期内时被重复生成。
INCR:
统计某个手机号成功获取验证码的次数。
EXPIRE:
设置获取次数统计的时间窗口。

结语
那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!
