本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
【专栏简介】
随着数据需求的迅猛增长,持久化和数据查询技术的重要性日益凸显。关系型数据库已不再是唯一选择,数据的处理方式正变得日益多样化。在众多新兴的解决方案与工具中,Redis凭借其独特的优势脱颖而出。
【技术大纲】
为何Redis备受瞩目?原因在于其学习曲线平缓,短时间内便能对Redis有初步了解。同时,Redis在处理特定问题时展现出卓越的通用性,专注于其擅长的领域。深入了解Redis后,您将能够明确哪些任务适合由Redis承担,哪些则不适宜。这一经验对开发人员来说是一笔宝贵的财富。
在这个专栏中,我们将专注于Redis的6.2版本进行深入分析和介绍。Redis 6.2不仅是我个人特别偏爱的一个版本,而且在实际应用中也被广泛认为是稳定性和性能表现都相当出色的版本。
【专栏目标】
本专栏深入浅出地传授Redis的基础知识,旨在助力读者掌握其核心概念与技能。深入剖析了Redis的大多数功能以及全部多机功能的实现原理,详细展示了这些功能的核心数据结构和关键算法思想。读者将能够快速且有效地理解Redis的内部构造和运作机制,这些知识将助力读者更好地运用Redis,提升其使用效率。
将聚焦于Redis的五大数据结构,深入剖析各种数据建模方法,并分享关键的管理细节与调试技巧。
【目标人群】
Redis技术进阶之路专栏:目标人群与受众对象,对于希望深入了解Redis实现原理底层细节的人群。
1. Redis爱好者与社区成员
Redis技术有浓厚兴趣,经常参与社区讨论,希望深入研究Redis内部机制、性能优化和扩展性的读者。
2. 后端开发和系统架构师
在日常工作中经常使用Redis作为数据存储和缓存工具,他们在项目中需要利用Redis进行数据存储、缓存、消息队列等操作时,此专栏将为他们提供有力的技术支撑。
3. 计算机专业的本科生及研究生
对于学习计算机科学、软件工程、数据分析等相关专业的在校学生,以及对Redis技术感兴趣的教育工作者,此专栏可以作为他们的学习资料和教学参考。
无论是初学者还是资深专家,无论是从业者还是学生,只要对Redis技术感兴趣并希望深入了解其原理和实践,都是此专栏的目标人群和受众对象。
让我们携手踏上学习Redis的旅程,探索其无尽的可能性!
本篇文章主要是深入剖析Redis服务器在数据库层面的实现机制。首先,我们将探讨Redis如何高效地存储和管理数据库,详细解释服务器保存数据库的策略和客户端在不同数据库间切换的技巧。紧接着,我们将聚焦于键值对的存储机制,阐明Redis如何巧妙地存储和检索这些基本数据单元。
Redis数据库存储与通知机制
除了基本的数据操作,Redis还提供了键的过期时间管理机制。这一功能允许用户为存储的键设置生存时间,当键的存活时间达到预设阈值时,Redis服务器将自动删除这些键,有效地管理了内存资源。我们将详细阐述这一机制的实现原理和效果。数据库通知。这一功能为Redis赋予了实时响应数据变化的能力,使得客户端能够即时获知数据的增删改查等操作。
Redis切换数据库
Redis服务器采用了一种独特的方式来存储其所有数据库:它们被保存在服务器状态redisServer
结构的db
数组中。这个数组的每个元素都是一个redisDb
结构,而每一个redisDb
结构都代表了一个独立的数据库。
Redis服务端
通过这种设计,Redis能够高效地管理和访问各个数据库,源码位置:redis/src/server.h,相关的源码如下所示:
c
/* Redis database representation. There are multiple databases identified
* by integers from 0 (the default database) up to the max configured
* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
kvstore *keys; /* The keyspace for this DB */
kvstore *expires; /* Timeout of keys with a timeout set */
ebuckets hexpires; /* Hash expiration DS. Single TTL per hash (of next min field to expire) */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *blocking_keys_unblock_on_nokey; /* Keys with clients waiting for
* data, and should be unblocked if key is deleted (XREADEDGROUP).
* This is a subset of blocking_keys*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
/* The following structure represents a node in the server.ready_keys list,
* where we accumulate all the keys that had clients blocked with a blocking
* operation such as B[LR]POP, but received new data in the context of the
* last executed command.
*
* After the execution of every command or script, we iterate over this list to check
* if as a result we should serve data to clients blocked, unblocking them.
* Note that server.ready_keys will not have duplicates as there dictionary
* also called ready_keys in every structure representing a Redis database,
* where we make sure to remember if a given key was already added in the
* server.ready_keys list. */
typedef struct readyList {
redisDb *db;
robj *key;
} readyList;
从上面的源码中可以看出readyList
中的redisDb *db
数组负责存储服务器中的所有数据库。当服务器进行初始化时,程序会参考服务器状态的dbnum
属性,以确定需要创建的数据库数量。这种机制确保了数据库的灵活配置与高效管理。
Redis服务器中的dbnum
属性值是依据服务器配置的database
选项来设定的。默认情况下,database
选项的值为16,因此Redis服务器在启动时会默认创建16个数据库实例,以满足大多数应用场景的需求。这一默认配置如下图所示,展示了Redis服务器数据库结构的基本布局。
Redis客户端
在服务器内部,客户端的状态由redisClient
结构体来表示,其中包含一个db
属性。这个db
属性是一个指向redisDb
结构体的指针,它记录了当前客户端所选择的目标数据库。通过这个指针,服务器能够确定当客户端执行数据库命令时,应当针对哪个具体的数据库进行操作。这种设计使得Redis能够支持多个数据库,并允许客户端在它们之间灵活切换。
切换数据库
在Redis系统中,每个客户端实例都关联有其独立的目标数据库。这些目标数据库是客户端执行数据库写命令(如SET、LPUSH等)或读命令(如GET、LRANGE等)时的直接操作对象。
python
# 假设我们有一个Redis客户端实例,命名为redis_client
# 在0号数据库中设置键msg的值
redis_client.set('msg', 'Hello, Redis!')
# 在0号数据库中读取键msg的值
value_in_db0 = redis_client.get('msg')
print(f"Value of 'msg' in DB 0: {value_in_db0.decode('utf-8') if value_in_db0 else 'None'}")
# 切换到2号数据库
redis_client.select(2)
# 在2号数据库中设置键msg的值(注意,这不会影响0号数据库中的msg)
redis_client.set('msg', 'Hello from DB 2!')
# 在2号数据库中读取键msg的值
value_in_db2 = redis_client.get('msg')
print(f"Value of 'msg' in DB 2: {value_in_db2.decode('utf-8') if value_in_db2 else 'None'}")
注意,实际使用的Redis客户端库可能会有不同的API调用方式,但基本的概念和流程是相同的。客户端能够选择性地与不同的数据库进行交互,这是Redis多数据库特性的一个关键优势。
通常,当一个新的Redis客户端连接建立时,它默认会使用0号数据库作为其目标数据库。然而,客户端拥有灵活性,可以通过发送SELECT
命令来动态地更改其当前的目标数据库。
客户端结构模型
Redis服务器内部,每个客户端的状态都由redisClient
结构来表示。其中,db
属性扮演了关键角色,它不仅记录了客户端当前所选的目标数据库,而且实际上是一个指向redisDb
结构的指针。这样的设计使得服务器能够精确地追踪和定位每个客户端正在操作的数据库。
c
typedef struct redisclient
//记录客户增当前正在使用的数据库
redisDb* db;
} redisclient;
具体而言,Redis客户端内部包含一个指针,它直接指向redisServer
的db
数组中的某个特定元素。这个被指向的元素正是客户端当前的目标数据库。以1号数据库为例,当一个客户端选定它作为操作对象时,其内部指针便会指向redisServer.db[1]
,从而建立起客户端状态与服务器状态之间直接而明确的关联。
select命令切换数据库
Redis通过一种称为SELECT
命令的方式,允许用户动态地改变redisClient.db
指针的指向,从而实现对不同数据库的访问。具体来说,这个过程涉及到对Redis客户端连接对象(通常表示为redisClient
)内部数据库指针(db
)的重新定向。
当客户端执行SELECT 2
命令时,它实际上是在修改其内部状态以指定新的目标数据库。这一操作会导致客户端状态中的db
指针重新指向redisServer
的db
数组中对应的2号数据库元素。因此,客户端状态与服务器状态之间的关系会随之更新,以反映新的目标数据库选择。 当客户端执行SELECT
命令并指定一个数据库索引时,Redis服务器会检查该索引是否有效(即确保它不超过服务器配置的最大数据库数),然后更新redisClient.db
指针,使其指向与该索引相对应的内部数据库结构。
数据库存储键空问(key space)
在Redis内部,每一个数据库都是由精心设计的server.h/redisDb
结构体来表征的。这个结构体中,尤为关键的是其内部的一个dict
字典,它承载着数据库中所有的键值对数据,我们习惯性地将其称为"键空间"(key space)。
它负责存储并管理所有的键值对信息。通过精细的哈希表实现,Redis能够高效地处理大量的键值对数据,实现快速的查询、插入和删除操作。每个数据库都有自己独立的键空间,就上上面说的可以通过SELECT
命令,客户端可以灵活地切换不同的数据库进行操作,从而实现了多数据库的管理和隔离。
c
/* Redis database representation. There are multiple databases identified
* by integers from 0 (the default database) up to the max configured
* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
kvstore *keys; /* The keyspace for this DB */
kvstore *expires; /* Timeout of keys with a timeout set */
ebuckets hexpires; /* Hash expiration DS. Single TTL per hash (of next min field to expire) */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *blocking_keys_unblock_on_nokey; /* Keys with clients waiting for
* data, and should be unblocked if key is deleted (XREADEDGROUP).
* This is a subset of blocking_keys*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
从上述源码的深入解析中,我们可以清晰地看到数据的整体组织是以dict
对象模型为基石的,这种设计确保了数据的高效管理和访问。具体而言,这个数据模型涵盖了多个关键的组成部分:
-
blocking_keys :这部分存储了那些由于执行了如
BLPOP
等命令而被阻塞的客户端所关注的键(key)。这些键的存在是为了确保在数据到达或满足特定条件时,能够及时唤醒等待的客户端。 -
blocking_keys_unblock_on_nokey:这个部分包含了不需要额外加锁即可在找不到对应键时解除阻塞的键。这种设计提高了系统的灵活性和响应速度。
-
ready_keys:此部分记录了那些已经接收到推送消息或满足其他条件,准备解除阻塞的键。一旦这些键的状态发生变化,系统可以迅速做出响应,减少客户端的等待时间。
-
watched_keys:该部分与事务处理紧密相关,存储了当前事务正在监视的键。当这些键的值发生变化时,系统能够识别并采取相应的处理措施,以确保事务的一致性和完整性。
在数据存储类型方面,整个模型以键值对(key-value, kvstore)为基础,这种简洁而高效的数据结构使得Redis能够快速地处理各种数据操作。下面便是redis服务基于dbnum,进行创建每一个keyspace的逻辑循环处理方式。
c
for (j = 0; j < server.dbnum; j++) {
server.db[j].keys = kvstoreCreate(&dbDictType, slot_count_bits, flags);
server.db[j].expires = kvstoreCreate(&dbExpiresDictType, slot_count_bits, flags);
server.db[j].hexpires = ebCreate();
server.db[j].expires_cursor = 0;
server.db[j].blocking_keys = dictCreate(&keylistDictType);
server.db[j].blocking_keys_unblock_on_nokey = dictCreate(&objectKeyPointerValueDictType);
server.db[j].ready_keys = dictCreate(&objectKeyPointerValueDictType);
server.db[j].watched_keys = dictCreate(&keylistDictType);
server.db[j].id = j;
server.db[j].avg_ttl = 0;
server.db[j].defrag_later = listCreate();
listSetFreeMethod(server.db[j].defrag_later,(void (*)(void*))sdsfree);
}
此外,expires
字段记录了设置了超时属性的关键点的超时信息,这为数据的自动清理和过期处理提供了便利。
键空间与用户操作的数据库是紧密耦合
键空间中的键直接映射到数据库的键,这些键无一例外地以字符串对象的形式存在,确保了数据检索的准确性和高效性。键空间中的值则是数据库实际存储的数据内容,它们以Redis对象的形式存在,这些对象类型丰富多样,包括但不限于字符串对象、列表对象、哈希表对象、集合对象和有序集合对象,具体结构关系如下图所示。 总结一下,数据库的键空间本质上是一个精心设计的字典结构,因此,执行任何与数据库相关的操作------无论是插入新的键值对、移除已存在的键值对,还是检索特定的键值对------实质上都是对这个键空间字典进行精细的操控。
添加新键
添加一个新键值对到数据库,实质上等同于在数据库的键空间字典中插入一个新的条目。在这个过程中,键始终是以字符串对象的形式存在,而与之对应的值则可以是Redis支持的任意一种数据类型对象,如字符串、列表、哈希表、集合或有序集合。
bash
redis>SET aaa "bbb"
OK
键空间将添加一个新的键值对,这个新键值对的键是一个包含字符串"aaa"的字符申对象,而键值对的值则是一个包含字符串"bbb"的字符串对象,如下图所示。
删除旧键
删除数据库中的一个键,实际上是在键空间字典中移除该键及其对应的值对象。假设当前数据库的键空间状态如图所示,当执行以下命令时:
c
redis>DEL aaa
(integer)1
除了上述命令外,Redis 还提供了多个与键空间操作相关的命令,如 EXISTS
(检查键是否存在)、RENAME
(重命名键)、KEYS
(查找所有匹配给定模式的键)等。这些命令通过对键空间进行精确或模糊查询、修改操作,为用户提供了丰富的数据库管理功能。通过灵活使用这些命令,用户可以轻松地管理 Redis 数据库中的数据。
读写键空间的分析说明
INFO stats
Redis 服务器会检查所请求的键是否存在于键空间中。如果键存在,服务器会增加键空间命中(hit)次数的计数器;如果键不存在,则会增加键空间不命中(miss)次数的计数器。这两个统计值可以通过执行 INFO stats 命令查看,其中 keyspace_hits 和 keyspace_misses 属性分别对应着命中和不命中的次数。
OBJECT IDLETIME
在读取一个键之后,Redis 还会更新该键的 LRU(Least Recently Used,最近最少使用)时间戳。这个值记录了键最后一次被访问的时间,用于后续计算键的闲置时间。通过使用 OBJECT IDLETIME 命令,用户可以查询特定键 的闲置时间,即从上一次访问到现在经过的时间。
数据删除策略的实现
如果服务器在读取一个键时发现该键已经过期(即键的过期时间戳小于当前时间),那么服务器会立即删除这个过期键,并从键空间中移除其键值对。这样做可以确保过期的数据不会被错误地读取,同时释放了相关的内存资源。过期键的删除是 Redis 内存管理策略的一部分,有助于维护数据库的健康状态和数据的一致性。
数据过期策略实现
为了确保数据的时效性和资源的高效利用,客户端可以利用EXPIRE或PEXPIRE命令为特定的键(key)设置生存时间(TTL,Time To Live)。这两个命令允许用户以秒或毫秒的精度来指定键的过期时间。一 旦到达设定的时间阈值,服务器将自动识别并删除那些生存时间已经耗尽(即TTL变为0)的键。
bash
redis>SET key value
OK
redis>EXPIRE key 5
(integer)1
redis>
GET key//5秒之内
"value"
redis>GET key//5秒之后
(ni1)
与EXPIRE和PEXPIRE命令相类似,客户端还可以使用EXPIREAT或PEXPIREAT命令来为Redis数据库中的特定键设定过期时间。这些命令允许用户以秒或毫秒的精度来指定一个UNIX时间戳作为键的过期时间点。当到达这个预设的UNIX时间戳时,服务器会自动识别并从数据库中移除那些已过期的键。
bash
redis>SET key value
OK
redis>EXPIREAT key xxxxxxxx
(integer)1
redis>TIME
设置过期时间
Redis提供了四种不同的命令,这些命令用于精确地管理键的生存时间(即键在数据库中能够持续存在的时长)以及设置键的过期时间(即键将在何时从数据库中自动移除)。这些功能为Redis用户提供了高度的灵活性和控制权,确保数据按照预设的规则和条件进行管理。
EXPIRE <key><ttl>
:此命令允许用户以秒为单位设置键的过期时间。当达到指定的秒数后,该键将被自动从Redis数据库中移除。PEXPIRE <key><ttl>
:与EXPIRE
相似,但PEXPIRE
允许用户以毫秒为单位设置键的过期时间,提供了更为精确的过期时间控制。EXPIREAT <key><timestamp>
:该命令要求用户提供一个具体的UNIX时间戳,当到达这个时间戳时,相应的键将从Redis数据库中删除。这种方式使得用户能够精确地规划键的过期时间。PEXPIREAT <key><timestamp>
:与EXPIREAT
类似,但允许用户以毫秒级的时间戳来指定键的过期时间,为用户提供了更为精细的时间管理选项。
尽管有多种具有不同时间单位和形式的命令来设置键的过期时间,如EXPIRE
、PEXPIRE
和EXPIREAT
,但实质上,这些命令在Redis内部都是基于PEXPIREAT
命令的核心机制来实现的。
不论客户端选择执行上述四个命令中的哪一个,Redis服务器都会进行相应的转换和处理,以确保最终的执行效果与直接使用PEXPIREAT
命令是一致的。
过期键的判定
当涉及到查询Redis中键的剩余生存时间时,TTL(Time To Live)命令和PTTL(Precision Time To Live)命令提供了不同的精度选项。具体来说,TTL命令返回的是键的剩余生存时间,其时间单位为秒,而PTTL命令则提供了更高的精度,它以毫秒为单位返回键的剩余生存时间。
bash
redis>TTL alphabet
(integer)8549007
redis>PTTL alphabet
(integer)8549001011
Redis通过维护一个过期字典,程序能够高效地判断一个给定键是否已过期。以下是优化和降重后的检查流程:
- 查询过期字典:程序首先会检查给定的键是否存在于过期字典中。如果存在,它会检索出该键对应的过期时间戳。
- 比对当前时间与过期时间 :
- 随后,程序会比较当前的UNIX时间戳(即当前系统时间)与键的过期时间戳。
- 如果当前时间戳大于或等于键的过期时间戳,则表明该键已经过期。
- 反之,如果当前时间戳小于键的过期时间戳,则该键尚未过期。
通过这种机制,系统能够精确控制键的生存周期,并在需要时自动清理过期的数据,从而确保存储资源的有效利用和数据的时效性。此外,过期字典的设计也允许系统根据业务逻辑灵活地设置键的过期时间,以满足不同应用场景的需求。
过期键删除策略
在深入了解了数据库键的过期机制后,我们已明确过期字典用于存储键的过期时间。接下来,一个自然而然的疑问是:当一个键达到其过期时间后,它会在何时被系统从数据库中移除呢?
常见的策略
对于过期键的清理,数据库系统通常不会立即删除它们,因为频繁地检查和删除过期键可能会对性能产生负面影响。相反,系统会采用一种更为高效和策略性的方法来进行过期键的删除。
-
被动删除:当客户端尝试访问一个过期键时,系统会在此时检查其过期时间,并如果发现已经过期,则将其删除。这种方法确保了只有在真正需要时才进行清理,从而降低了不必要的开销。
-
主动删除:系统定期(如每隔一段时间或当内存使用达到某个阈值时)运行一个任务来查找并删除过期键。这种策略可以在客户端未主动访问过期键时也能保持数据库的清洁,但可能会增加一定的系统负担。
-
混合策略:结合被动删除和主动删除的优点,系统可以根据实际负载和性能需求动态调整删除策略,以实现最佳的过期键管理效果。
Redis的过期键删除策略
Redis服务器在内存管理策略上巧妙地结合了惰性删除(Lazy Deletion)和定期删除(Active Deletion)两种机制,以实现CPU使用效率与内存空间利用率的最佳平衡。Redis通过精心设计的这两种删除策略的组合,实现了对系统资源的精细控制。它既能确保在需要时快速响应数据请求,又能有效避免内存空间的无效占用,从而达到了CPU使用与内存管理的理想平衡状态。
惰性删除策略的实现
Redis采用了一种称为惰性删除的策略来处理过期键,这一策略主要由db.c/expireIfNeeded函数负责实现。每当Redis执行任何涉及数据库的读写命令时,都会在命令执行之前调用expireIfNeeded函数对输入的键进行检查。 具体来说,expireIfNeeded函数会检查被操作键的过期时间戳(如果存在)。如果键的过期时间戳小于当前时间,即表示该键已过期,那么函数会立即删除该键,并返回相应的过期信息。如果键未过期或不存在过期时间戳,则函数将不做任何操作,继续执行原命令。
注意,通过这种机制,Redis能够在不增加额外负担的情况下,有效地处理过期键,确保数据的实时性和准确性。同时,这种惰性删除的方式也减少了不必要的CPU资源消耗,提高了Redis的整体性能 。 expireIfNeeded函数犹如一个前置的"哨兵"或"筛选器",在Redis命令实际执行之前,它会迅速而准确地识别并排除那些已经过期的输入键。
过期键的定期删除策略是由server.c/activeExpireCycle
函数来实现的,该函数作为Redis服务器周期性任务server.c/serverCron
的一部分被调用。每当Redis服务器按照预定的时间间隔执行serverCron
函数时,它便会触发activeExpireCycle
函数来执行过期键的清理工作。
定期删除策略的实现
在特定的时间间隔内,Redis服务器执行一种循环扫描机制,这种机制被称为"定期过期键清理"。该机制会依次遍历服务器中的各个数据库,针对每个数据库,它并不检查所有键的过期时间,而是从每个数据库的expires字典中随机选择一部分键进行过期检查
总体核心流程
在activeExpireCycle函数的每次迭代中,它都会从服务器中的多个数据库中精心挑选出一定数量的随机键,并对这些键的过期时间进行检查。一旦发现键已过期,便会立即将其从数据库中移除。
为了确保检查的连续性和效率,全局变量current_db被用作一个指针,记录着activeExpireCycle函数当前遍历的数据库位置。当函数因某种原因中断或完成当前数据库的遍历后,current_db的值会被更新,以便在下一次函数调用时,能够从上一次中断的位置继续开始检查。
例如,如果activeExpireCycle函数在遍历到第10号数据库时因某种原因返回,那么在下一次函数调用时,它将直接从第11号数据库开始继续其过期键的查找和删除工作。
随着activeExpireCycle函数的持续运行,服务器中的所有数据库都将被逐一检查。一旦所有数据库都完成了一轮遍历,current_db变量将被重置为0,标志着新一轮的过期键检查周期的开始。这种设计确保了Redis服务器能够高效地管理和维护其内部数据,同时确保内存空间的有效利用。