MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis字符串对象的内部实现与操作

2024-05-227.7k 阅读

Redis字符串对象概述

在Redis中,字符串对象是最基础也是最常用的数据类型之一。Redis的字符串对象不仅可以存储普通的字符串,还能存储整数和浮点数,其内部实现的灵活性使得它在各种场景下都能高效地工作。

Redis使用对象系统来管理所有的数据类型,字符串对象也不例外。每个对象都有一个redisObject结构作为其头部,该结构包含了对象的类型、编码方式以及其他一些元信息。对于字符串对象,其类型为REDIS_STRING

字符串对象的编码方式

Redis字符串对象有三种主要的编码方式:intembstrraw

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字节,或者字符串对象在创建时没有满足intembstr编码的条件,Redis会使用raw编码。raw编码下,redisObjectptr字段指向一个独立分配的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编码,直接从redisObjectlval字段获取整数值并转换为字符串返回;对于embstrraw编码,则从对应的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字段进行加一操作;如果是embstrraw编码且值为数字字符串,会先将其转换为整数,然后加一,再根据结果决定是否需要改变编码方式(例如,如果原来是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编码,然后使用SDSsdscat函数将新字符串追加到原字符串末尾。如果是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编码将redisObjectSDS结构连续分配在一块内存中,在创建时只需要一次内存分配操作,释放时也只需要一次内存释放操作。这种方式减少了内存碎片,提高了内存利用率。

raw编码下,redisObjectptr指向一个独立分配的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的内存预分配策略。如果知道字符串会持续增长,可以在初始化时适当分配较大的空间,减少后续内存重分配的次数。

同时,合理使用INCRDECR等原子操作。这些操作在Redis内部是原子性的,在多线程或多进程环境下可以保证数据的一致性,避免使用复杂的锁机制来实现类似的功能。

另外,在批量操作字符串对象时,可以使用MSETMGET命令。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,充分发挥其性能优势,同时合理管理内存,避免潜在的性能瓶颈和内存问题。无论是小型应用还是大规模的分布式系统,掌握字符串对象的这些知识都是非常关键的。