Redis字符串对象的内部实现与操作
Redis字符串对象概述
在Redis中,字符串对象是最基础也是最常用的数据类型之一。Redis的字符串对象不仅可以存储普通的字符串,还能存储整数和浮点数,其内部实现的灵活性使得它在各种场景下都能高效地工作。
Redis使用对象系统来管理所有的数据类型,字符串对象也不例外。每个对象都有一个redisObject
结构作为其头部,该结构包含了对象的类型、编码方式以及其他一些元信息。对于字符串对象,其类型为REDIS_STRING
。
字符串对象的编码方式
Redis字符串对象有三种主要的编码方式:int
、embstr
和raw
。
int编码
当字符串对象保存的是整数值,且这个整数值可以用long
类型(在64位系统上为8字节)表示时,Redis会使用int
编码来存储这个字符串对象。这种编码方式直接将整数值保存在redisObject
结构的ptr
字段中(虽然ptr
通常是用来保存指针的,但在这里被巧妙地用来保存整数值)。
以下是一个使用int
编码的示例:
// 假设我们在Redis中执行 SET num 1234567890
// 在Redis内部,这个字符串对象会以int编码存储
// redisObject结构简化示意
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
// 其他字段省略
union {
long lval;
void *ptr;
// 其他联合成员省略
} u;
} robj;
// 当设置 num 1234567890 时
robj *obj = createStringObject("1234567890", 10);
if (isInteger(obj)) {
obj->encoding = REDIS_ENCODING_INT;
obj->u.lval = 1234567890;
}
embstr编码
embstr
编码是专门为短字符串设计的。当字符串对象保存的是长度小于等于39字节的字符串时,Redis会使用embstr
编码。embstr
编码将redisObject
结构和保存字符串的SDS
(Simple Dynamic String,简单动态字符串)结构连续分配在一块内存中,这样可以减少内存碎片,提高内存利用率,同时在读取短字符串时只需要一次内存查找,提升了访问效率。
SDS
是Redis自定义的字符串表示方式,它克服了传统C字符串在长度获取、内存重分配等方面的不足。SDS
结构如下:
struct sdshdr {
int len;
int free;
char buf[];
};
其中,len
表示已使用的长度,free
表示剩余可用的长度,buf
是实际存储字符串的数组。
以下是embstr
编码的示例:
// 假设我们在Redis中执行 SET short_str "hello"
// 在Redis内部,这个字符串对象会以embstr编码存储
// 创建redisObject和SDS结构连续的内存块
size_t totalLen = sizeof(robj) + sizeof(struct sdshdr) + 6; // 6 为 "hello\0" 的长度
void *mem = zmalloc(totalLen);
robj *obj = (robj*)mem;
obj->type = REDIS_STRING;
obj->encoding = REDIS_ENCODING_EMBSTR;
// 初始化SDS部分
struct sdshdr *sdsHdr = (struct sdshdr*)(obj + 1);
sdsHdr->len = 5;
sdsHdr->free = 0;
memcpy(sdsHdr->buf, "hello", 6);
raw编码
当字符串对象保存的字符串长度大于39字节,或者字符串对象在创建时没有满足int
或embstr
编码的条件,Redis会使用raw
编码。raw
编码下,redisObject
的ptr
字段指向一个独立分配的SDS
结构,这个SDS
结构用于存储字符串内容。
示例如下:
// 假设我们在Redis中执行 SET long_str "a very very long string that exceeds 39 bytes"
// 在Redis内部,这个字符串对象会以raw编码存储
struct sdshdr *sdsHdr = sdsnew("a very very long string that exceeds 39 bytes");
robj *obj = createStringObjectFromSDS(sdsHdr);
obj->encoding = REDIS_ENCODING_RAW;
字符串对象的操作
Redis提供了丰富的命令来操作字符串对象,下面我们将详细介绍一些常见的操作及其内部实现原理。
SET操作
SET
命令用于设置一个键值对,如果键已经存在,则会覆盖旧值。
在内部实现上,首先会检查键是否存在。如果存在,会根据新值的类型和长度来决定是否需要改变当前字符串对象的编码方式。例如,如果原来的字符串对象是int
编码,而新值是一个长字符串,就需要将编码方式转换为raw
。
// 简化的SET命令实现
void setCommand(client *c) {
robj *key = c->argv[1];
robj *val = c->argv[2];
robj *oldVal = lookupKeyWrite(c->db, key);
if (oldVal != NULL) {
// 处理旧值,释放内存等
decrRefCount(oldVal);
}
if (isInteger(val)) {
// 处理整数类型,可能采用int编码
createOrModifyStringObjectAsInt(c->db, key, getLongFromObject(val));
} else if (sdslen(val->ptr) <= 39) {
// 处理短字符串,可能采用embstr编码
createOrModifyStringObjectAsEmbstr(c->db, key, val->ptr);
} else {
// 处理长字符串,采用raw编码
createOrModifyStringObjectAsRaw(c->db, key, val->ptr);
}
}
GET操作
GET
命令用于获取指定键的值。在内部,首先会根据键查找对应的字符串对象。如果找到,根据对象的编码方式来获取值。对于int
编码,直接从redisObject
的lval
字段获取整数值并转换为字符串返回;对于embstr
和raw
编码,则从对应的SDS
结构中获取字符串内容返回。
// 简化的GET命令实现
void getCommand(client *c) {
robj *key = c->argv[1];
robj *val = lookupKeyRead(c->db, key);
if (val == NULL) {
addReply(c, shared.nullbulk);
} else {
if (val->encoding == REDIS_ENCODING_INT) {
char buf[32];
snprintf(buf, 32, "%ld", val->u.lval);
addReplyBulkCString(c, buf);
} else {
addReplyBulk(c, val);
}
}
}
INCR操作
INCR
命令用于将存储在指定键的数字值增一。如果键不存在,会先将其初始化为0,然后再执行增一操作。
在内部实现上,首先会检查键是否存在以及对应的值是否为数字类型。如果是int
编码,直接对lval
字段进行加一操作;如果是embstr
或raw
编码且值为数字字符串,会先将其转换为整数,然后加一,再根据结果决定是否需要改变编码方式(例如,如果原来是embstr
编码,加一后的值超出了embstr
编码的长度限制,则转换为raw
编码)。
// 简化的INCR命令实现
void incrCommand(client *c) {
robj *key = c->argv[1];
robj *val = lookupKeyWrite(c->db, key);
if (val == NULL) {
// 键不存在,初始化为0
val = createStringObject("0", 1);
dbAdd(c->db, key, val);
}
long long num;
if (getLongLongFromObject(val, &num) != C_OK) {
addReplyError(c, "value is not an integer or out of range");
return;
}
num++;
if (val->encoding == REDIS_ENCODING_INT) {
val->u.lval = num;
} else {
char buf[32];
snprintf(buf, 32, "%lld", num);
robj *newVal = createStringObject(buf, strlen(buf));
replaceStringObject(c->db, key, newVal);
}
addReplyLongLong(c, num);
}
APPEND操作
APPEND
命令用于将指定的字符串追加到键对应的值的末尾。如果键不存在,会先创建一个新的键值对。
在内部实现上,首先会查找键是否存在。如果存在且是embstr
编码,由于embstr
编码的内存是连续分配且固定大小的,需要将其转换为raw
编码,然后使用SDS
的sdscat
函数将新字符串追加到原字符串末尾。如果是raw
编码,则直接使用sdscat
函数进行追加操作。
// 简化的APPEND命令实现
void appendCommand(client *c) {
robj *key = c->argv[1];
robj *val = lookupKeyWrite(c->db, key);
if (val == NULL) {
// 键不存在,创建新的键值对
val = createStringObject(c->argv[2]->ptr, sdslen(c->argv[2]->ptr));
dbAdd(c->db, key, val);
addReplyLongLong(c, sdslen(val->ptr));
return;
}
if (val->encoding == REDIS_ENCODING_EMBSTR) {
// 转换为raw编码
robj *newVal = convertToRawStringObject(val);
replaceStringObject(c->db, key, newVal);
val = newVal;
}
sds s = (sds)val->ptr;
s = sdscat(s, c->argv[2]->ptr);
setStringObject(val, s);
addReplyLongLong(c, sdslen(val->ptr));
}
字符串对象的内存管理
字符串对象的内存管理与它的编码方式紧密相关。
对于int
编码,由于整数值直接存储在redisObject
结构的lval
字段中,不需要额外的内存分配来存储值本身,内存开销主要就是redisObject
结构的大小。
embstr
编码将redisObject
和SDS
结构连续分配在一块内存中,在创建时只需要一次内存分配操作,释放时也只需要一次内存释放操作。这种方式减少了内存碎片,提高了内存利用率。
raw
编码下,redisObject
的ptr
指向一个独立分配的SDS
结构。当字符串长度发生变化时,SDS
会根据需要重新分配内存。SDS
采用了一种预分配策略,在扩展字符串时,如果剩余空间足够,则直接使用剩余空间;如果不足,则会分配比需要的空间更大的内存,以减少频繁的内存重分配。例如,当SDS
需要扩展时,会预分配与当前已使用长度相同的额外空间(如果扩展后长度小于1MB),这样在后续的追加操作中,如果追加的内容长度不超过预分配的空间,就不需要再次进行内存重分配。
// SDS内存重分配示例
sds sdsMakeRoomFor(sds s, size_t addlen) {
struct sdshdr *sh, *newsh;
size_t free = sdsavail(s);
size_t len, newlen;
if (free >= addlen) return s;
len = sdslen(s);
sh = (void*) (s-(sizeof(struct sdshdr)));
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
newsh = zmalloc(sizeof(struct sdshdr)+newlen+1);
if (newsh == NULL) return NULL;
memcpy(newsh->buf, sh->buf, len+1);
newsh->len = len;
newsh->free = newlen - len;
zfree(sh);
return newsh->buf;
}
字符串对象在实际应用中的优化
在实际应用中,合理使用字符串对象的编码方式和操作可以显著提升Redis的性能和内存使用效率。
例如,在存储大量小整数时,尽量保持int
编码。可以通过在应用层进行数据类型检查和转换,确保传入Redis的值能以int
编码存储,这样可以减少内存开销,提高运算效率。
对于短字符串,利用embstr
编码的优势。避免对短字符串进行频繁的修改操作,因为一旦修改导致字符串长度超过39字节,就会触发编码转换,从embstr
转换为raw
,这会带来额外的内存分配和拷贝开销。
在处理长字符串时,注意SDS
的内存预分配策略。如果知道字符串会持续增长,可以在初始化时适当分配较大的空间,减少后续内存重分配的次数。
同时,合理使用INCR
、DECR
等原子操作。这些操作在Redis内部是原子性的,在多线程或多进程环境下可以保证数据的一致性,避免使用复杂的锁机制来实现类似的功能。
另外,在批量操作字符串对象时,可以使用MSET
和MGET
命令。MSET
一次设置多个键值对,MGET
一次获取多个键的值,这样可以减少客户端与服务器之间的网络交互次数,提高整体性能。
import redis
# 使用Python的redis-py库示例
r = redis.Redis(host='localhost', port=6379, db = 0)
# MSET操作
r.mset({'key1': 'value1', 'key2': 'value2'})
# MGET操作
result = r.mget(['key1', 'key2'])
print(result)
通过对Redis字符串对象的内部实现和操作的深入理解,我们可以在实际开发中更加高效地使用Redis,充分发挥其性能优势,同时合理管理内存,避免潜在的性能瓶颈和内存问题。无论是小型应用还是大规模的分布式系统,掌握字符串对象的这些知识都是非常关键的。