Redis 基础数据结构 —— 字符串对象

405 阅读7分钟

字符串对象是Reids最基本的数据类型,一个键最大能存储512MB。Redis的字符串对象是二进制安全的字节流,可以包含任何数据,如图片数据或者序列化的对象。

字符串对象的编码有int、embstr、raw三种。

  • 一个字符串对象保存的是整数值,使用int编码实现;
  • 一个字符串对象保存的是字符串值且字符串值的长度小于45个字节,使用embstr编码实现
  • 一个字符串对象保存的是字符串值且字符串值的长度大于等于45个字节,使用raw编码实现。

    需要注意的是,浮点数以及不可用long表示的大整数,编码方式采用embstr或raw。

embstr 编码是 raw 编码的一种优化方式,其底层数据结构都是简单动态字符串(SDS)。所以 embstr编码和 raw 编码实现的字符串对象,都包含两部分的结构:redisObject 和 sdshdr。不同的是,raw 编码方式会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构;而 embstr 编码方式只调用一次内存分配函数为 redisObject 结构和 sdshdr 结构同时分配内存空间。使用 embstr 编码的字符串对象,相对raw编码方式有以下优点:

  • 创建对象时,内存分配函数从两次降为一次
  • 销毁对象时,内存释放函数从两次降为一次
  • embstr编码的字符串,redisObject 结构和 sdshdr 结构处于连续的内存空间中,能更好地利用缓存带来的优势

编码转换

int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码。 int编码的字符串对象,如果执行某个命令后会使对象值保存的不再是整数而是字符串,会将编码转换为raw。Redis中没有embstr编码的字符串的修改API(embstr是一个只读的结构),当要修改embstr编码的字符串时,总是将编码转换为raw,再执行修改命令。

简单动态字符串(SDS)

raw编码的字符串对象和embstr编码的字符串对象,对象值保存的不是C语言字符串,而是简单动态字符串结构。

struct __attribute__ ((__packed__)) sdshdr8 {
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    //字节数组,用于保存字符串,以'\0'字符结尾,结尾空字符不计算在len中,以空字符结尾的好处是可以直接重用一部分C字符串函数库里的函数。
    char buf[];
};

添加字符(扩容)

添加字符串,sdscat输入参数为sds和字符串t,首先调用sdsMakeRoomFor扩容方法,再追加新的字符串,最后添加上结尾符'\0'。我们来看下扩容方法里面是如何实现的?第一步先调用常见方法中的sdsavail方法,获取还剩多少空闲空间。如果空闲空间大于要添加的字符串t的长度,则直接返回,不想要扩容。如果空闲空间不够,则想要扩容。第二步判断想要扩容多大,这边有分情况,如果目前的字符串小于1M,则直接扩容双倍,如果目前的字符串大于1M,则直接添加1M。第三个判断添加字符串之后的数据类型还是否和原来的一致,如果一致,则没啥事。如果不一致,则想要新建一个sdshdr,把现有的数据都挪过去。

举个例子,现在str的字符串为hello,目前是sdshdr8,总长度50,已用6,空闲44。现在想要添加长度为50的字符t,第一步想要看下是否要扩容,50明显大于44,需要扩容。第二步扩容多少,str的长度小于1M,所以扩容双倍,新的长度为50*2=100。第三步50+50所对应sdshdr类型还是sdshdr8吗?明显还是sdshdr8,所以不要数据结构的变化,还在原来的基础上添加t即可。

删除

String类型的删除并不是直接回收内存,而是修改字符,让其为空字符,这其实是惰性释放,等待将来使用。在调用sdsempty方法时,再次调用上面的sdsnewlen方法。

/*
 * 就地修改 sds 字符串以使其为空(零长度)。
 * 然而,所有现有的缓冲区不会被丢弃而是被设置为可用空间
 * 以便下一个追加操作不需要分配到以前可用的字节数。
 */
 void sdsclear(sds s) {
    sdssetlen(s, 0);
    s[0] = '\0';
 }

sds sdsempty(void) {
    return sdsnewlen("",0);
}

SDS与C语言字符串的区别

  • 常数复杂度获取字符串长度
    • 因为C字符串并不记录自身的长度信息,获取C字符串的长度必须遍历整个字符串,复杂度为O(N)。
    • SDS中使用len属性记录字符串的长度,获取字符串长度复杂度为O(1)。通过额外的空间记录字符串长度信息,确保了获取字符串长度的工作不会成为Redis的性能瓶颈。
  • 杜绝缓冲区溢出
    • 因为C字符串不记录自身的长度,在操作字符串时容易产生缓冲区溢出。SDS的空间分配策略则完全杜绝了发生缓冲区溢出的可能性:当对SDS进行修改时,会检查free的大小,并自动进行空间扩展。
  • 减少修改字符串时带来的内存重新分配次数
    • C字符串不记录自身长度,每次增长或缩短一个C字符串,程序总要对保存字符串的数组进行一次内存重新分配操作。
    • Redis作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果频繁重新分配内存,会对新能造成影响。SDS中,存在已使用的内存空间和未使用的空间,通过使用空间预分配和惰性空间释放两种优化策略,减少内存重新分配次数。
    • SDS空间预分配:如果对SDS进行修改之后,SDS的长度小于1M,则分配和len属性同样大小的未使用空间。如果对SDS进行修改之后,SDS的长度将大于等于1M,则分配1M的未使用空间。
    • 惰性空间释放:对SDS字符串缩短操作时,不会立即回收多出来的字节,而是把多出来的字节长度记录到free属性中,待增长字符串时使用。同时,SDS也提供相应的API释放未使用的空间,保证惰性空间释放策略不会造成内存浪费。
  • 二进制安全
    • C字符串必须符合某种编码,并且字符串中间不能存在空字符,否则最先被程序读入的空字符会被误认为是字符串结尾。
    • SDS通过len属性判断字符串结尾,保留最后一个字符为'\0'并不是用于识别字符串结尾,而是用于兼容部分C库函数。

位图(bitmap) value 非0即1

  • 有用户系统,统计用户登录天数且窗口随机

统计用户登陆天数.png

setbit zhangsan 1 1
setbit zhangsan 7 1
setbit zhangsan 364 1
strlen zhangsan
bitcount zhangsan 0 -1

一年10亿内存占用: 1,000,000,000 * (365 / 8) 约等于 46GB

  • 618做活动:送礼物大库备货多少礼物,假设有2亿用户(僵尸用户/冷热用户/活跃用户)。统计活跃用户统计且窗口随机。 比如说 1号~3号连续登录要去重

统计活跃用户.png

setbit 20210617  1  1
setbit 20210618  1  1
setbit 20210619  7  1
bitop or destkey 20210617  20210618
bitcount destkey  0 -1 

一年10亿内存占用: 365 * (1,000,000,000 / 8) 约等于 46GB