Redis源码剖析–字符串t_string实现
目录
介绍完Redis的底层数据结构之后, 介绍我们平时使用Redis的时候可以直接看到五种数据结构:字符串、哈希、链表、集合和有序集合。
首先介绍字符串t_string的实现。
字符串的结构
上一篇文章讲到过字符串的底层实现其实有三种编码:
类型 | 编码 | 对象 |
---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象。 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用 embstr 编码的简单动态字符串实现的字符串对象。 |
REDIS_STRING | REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象。 |
这三种类型分别对应的底层数据结构为int,embstr, sds。
字符串的定义代码如下:
typedef struct redisObject {
//对象的数据类型,字符串对象应该为 OBJ_STRING
unsigned type:4;
//对象的编码类型,分别为OBJ_STRING、OBJ_ENCODING_INT或OBJ_ENCODING_EMBSTR
unsigned encoding:4;
//LRU_BITS为24位,最近一次的访问时间
unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
//引用计数
int refcount;
//指向底层数据实现的指针
void *ptr;
} robj;
那么字符串具体用哪种编码实现呢?
如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int 。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw 。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。
embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象, 但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构
这边采用int和embstr编码的原因是:相较于raw格式的2次分配内存, 这两种格式只需要一次分配内存空间就可以;并且在回收的时候,也只需要调用一次内存释放函数。在存储小的字符串的时候更有速度优势。
字符串命令
字符串支持的命令如下:
命令 | 命令描述 |
---|---|
SET key value [ex 秒数][px 毫秒数][nx/xx] | 设置指定key的值 |
GET key | 获取指定key的值 |
APPEND key value | 将value追加到指定key的值末尾 |
INCRBY key increment | 将指定key的值加上增量increment |
DECRBY key decrement | 将指定key的值减去增量decrement |
STRLEN key | 返回指定key的值长度 |
SETRANGE key offset value | 将value覆写到指定key的值上,从offset位开始 |
GETRANGE key start end | 获取指定key中字符串的子串[start,end] |
MSET key value [key value …] | 一次设定多个key的值 |
MGET key1 [key2..] | 一次获取多个key的值 |
字符串命令实现
set命令的实现
set命令用于设置指定的值,其具体命令格式如下:
set key value [ex 秒数] [px 毫秒数] [nx/xx]
其中,各个选项的含义如下:
- ex 设置指定的到期时间,单位为秒
- px 设置指定的到期时间,单位为毫秒
- nx 只有在key不存在的时候,才设置key的值
- xx 只有key存在时,才对key进行设置操作
set命令是调用setCommand实现的:
// 关于set命令的操作有三种宏定义
#define OBJ_SET_NO_FLAGS 0 // 没有设定参数
#define OBJ_SET_NX (1<<0) // 只有键不存在时才设定其值
#define OBJ_SET_XX (1<<1) // 只有键存在时才设定其值
#define OBJ_SET_EX (1<<2) // ex属性,到期时间单位为秒
#define OBJ_SET_PX (1<<3) // px属性,到期时间单位为毫秒
/* set命令实现函数 */
void setCommand(client *c) {
int j;
robj *expire = NULL;
int unit = UNIT_SECONDS;
// 用于标记ex/px和nx/xx命令参数
int flags = OBJ_SET_NO_FLAGS;
// 从命令串的第四个参数开始,查看其是否设定了ex/px和nx/xx
for (j = 3; j < c->argc; j++) {
char *a = c->argv[j]->ptr;
robj *next = (j == c->argc-1) ? NULL : c->argv[j+1];
if ((a[0] == 'n' || a[0] == 'N') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_XX)) // 标记
{
flags |= OBJ_SET_NX;
} else if ((a[0] == 'x' || a[0] == 'X') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_NX))
{
flags |= OBJ_SET_XX;
} else if ((a[0] == 'e' || a[0] == 'E') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_PX) && next)
{
flags |= OBJ_SET_EX;
unit = UNIT_SECONDS;
expire = next;
j++;
} else if ((a[0] == 'p' || a[0] == 'P') &&
(a[1] == 'x' || a[1] == 'X') && a[2] == '\0' &&
!(flags & OBJ_SET_EX) && next)
{
flags |= OBJ_SET_PX;
unit = UNIT_MILLISECONDS;
expire = next;
j++;
} else {
// 如果不是上述参数,则需要报错,命令错误
addReply(c,shared.syntaxerr);
return;
}
}
// 判断value是否可以编码成整数,如果能则编码;反之不做处理
c->argv[2] = tryObjectEncoding(c->argv[2]);
// 调用底层函数进行键值对设定
setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}
函数的一开始先检查传入的参数,看是否设定了过期时间以及过期时间的精度等,然后设置对应的flag, 最后根据flag调用setGenericCommand实现具体的操作。
接下去看一下setGenericCommand函数的实现:
//setGenericCommand()函数是以下命令: SET, SETEX, PSETEX, SETNX.的最底层实现
//flags 可以是NX或XX,由上面的宏提供
//expire 定义key的过期时间,格式由unit指定
//ok_reply和abort_reply保存着回复client的内容,NX和XX也会改变回复
//如果ok_reply为空,则使用 "+OK"
//如果abort_reply为空,则使用 "$-1"
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
long long milliseconds = 0; /* initialized to avoid any harmness warning */ //初始化,避免错误
//如果定义了key的过期时间
if (expire) {
//从expire对象中取出值,保存在milliseconds中,如果出错发送默认的信息给client
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
return;
// 如果过期时间小于等于0,则发送错误信息给client
if (milliseconds <= 0) {
addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
return;
}
//如果unit的单位是秒,则需要转换为毫秒保存
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}
//lookupKeyWrite函数是为执行写操作而取出key的值对象
//如果设置了NX(不存在),并且在数据库中 找到 该key,或者
//设置了XX(存在),并且在数据库中 没有找到 该key
//回复abort_reply给client
if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
(flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
{
addReply(c, abort_reply ? abort_reply : shared.nullbulk);
return;
}
//在当前db设置键为key的值为val
setKey(c->db,key,val);
//设置数据库为脏(dirty),服务器每次修改一个key后,都会对脏键(dirty)增1
server.dirty++;
//设置key的过期时间
//mstime()返回毫秒为单位的格林威治时间
if (expire) setExpire(c->db,key,mstime()+milliseconds);
//发送"set"事件的通知,用于发布订阅模式,通知客户端接受发生的事件
notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
//发送"expire"事件通知
if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
"expire",key,c->db->id);
//设置成功,则向客户端发送ok_reply
addReply(c, ok_reply ? ok_reply : shared.ok);
}
get命令的实现
类似于set命令,get命令也是最终调用一个getGenericcommand的函数实现:
//GET 命令的底层实现
int getGenericCommand(client *c) {
robj *o;
//lookupKeyReadOrReply函数是为执行读操作而返回key的值对象,找到返回该对象,找不到会发送信息给client
//如果key不存在直接,返回0表示GET命令执行成功
if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk)) == NULL)
return C_OK;
//如果key的值的编码类型不是字符串对象
if (o->type != OBJ_STRING) {
addReply(c,shared.wrongtypeerr); //返回类型错误的信息给client,返回-1表示GET命令执行失败
return C_ERR;
} else {
addReplyBulk(c,o); //返回之前找到的对象作为回复给client,返回0表示GET命令执行成功
return C_OK;
}
}
编码的转换
上边说到字符串类型底层其实有三种数据类型,这三种数据类型在特定的情况下也会互相转换。
int编码转换为raw编码
对于 int 编码的字符串对象来说, 如果我们向对象执行了一些命令, 使得这个对象保存的不再是整数值, 而是一个字符串值, 比如在最开始的数字类型后边执行了一个append的操作,加上了一串字符串,那么字符串对象的编码将从 int 变为 raw 。
embstr编码转换为raw编码
因为 Redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序 (只有 int 编码的字符串对象和 raw 编码的字符串对象有这些程序), 所以 embstr 编码的字符串对象实际上是只读的: 当我们对 embstr 编码的字符串对象执行任何修改命令时, 程序会先将对象的编码从 embstr 转换成 raw , 然后再执行修改命令; 因为这个原因, embstr 编码的字符串对象在执行修改命令之后, 总会变成一个 raw 编码的字符串对象。