• Redis开发与运维:SDS


    STRING

    我们会经常打交道的string类型,在redis中拥有广泛的使用。也是开启redis数据类型的基础。

    在我最最开始接触的redis的时候,总是以为字符串类型就是值的类型是字符串。

    比如:SET key value

    我的理解是value数据类型是stirng类型,现在来看呢,这句话说得不够具体全面。

    • 所有的键都是字符串类型

    • 字符串类型的值可以是字符串、数字、二进制

    这里也就引出了,另一个概念:外部类型和内部类型

    外部类型 vs 内部类型

    这里的外部类型,就是我们所熟知的:字符串(string)、哈希(hash)、列表(list)、集合(set)、有序结合(zset)等 
    

    Q1:那么什么是内部类型呢?

    Q2:外部类型和内部类型是什么时候出现的?

    Q3:为什么要这样设计?

    我们先来看问题1,可以这样理解,对外数据结构就像是我们的API,对外提供着一定组织结构的数据。

    对内来说,我们可以更换里面的逻辑算法,甚至更换数据存储方式,比如将Mysql换成Redis.

    内部类型其实就是数据存储的形式。举现在我们所讨论的stirng来说。

    string的外部类型就是string,而它对应的数据内部存储结构分为三种。

    int:8个字节的长整形
    embstr:<=39个字节的字符串(3.2 版本变成了44)
    raw:>39个字节的字符串(3.2 版本变成了44)
    

    所以,string类型会根据当前字符串的长度来决定到底使用哪种内部数据结构。

    现在我们再回到问题上:什么是内部类型?

    就是数据真正存储在内存上的数据结构。
    

    其实第二个问题:外部类型和内部类型是什么时候出现的?

    这里也算是有答案了,外部类型就是对外公开的数据类型也可以说是API,内部类型根据长度判断哪种内部结构。
    

    第三个问题:为什么这样设计?

    前后分离,如果有更好地内部数据类型,我们可以替换后面的数据类型,但不影响前面的Api.
    
    还有一点也是根据不同情况,选择更好地数据结构,节省内存。毕竟是内存数据库,资源珍贵。
    

    如何查看外部类型和内部类型

    查看外部类型:type

    127.0.0.1:6999[1]> SET sc sunchong   // 对外类型:string
    OK
    127.0.0.1:6999[1]> type sc
    string
    
    
    127.0.0.1:6999[1]> HSET hsc sun chong    // 对外类型:hash
    (integer) 1
    127.0.0.1:6999[1]> type hsc
    hash
    
    
    127.0.0.1:6999> RPUSH rsc s un ch hong
    (integer) 4
    127.0.0.1:6999> TYPE rsc
    list
    
    

    查看内部类型:object

    int

    127.0.0.1:6999[1]> set sc 1234567890123456789   // 对内类型:int
    OK
    127.0.0.1:6999[1]> STRLEN sc
    (integer) 19
    127.0.0.1:6999[1]> OBJECT encoding sc
    "int"
    

    int -> embstr

    (int 8位的长整形,最大存储十进制位数为19位)

    127.0.0.1:6999[1]> set sc 12345678901234567890   // 对内类型:embstr
    OK
    127.0.0.1:6999[1]> STRLEN sc
    (integer) 20
    127.0.0.1:6999[1]> OBJECT encoding sc   
    "embstr"
    

    embstr -> raw

    127.0.0.1:6999[1]> set sc 123456789012345678901234567890123456789  
    OK
    127.0.0.1:6999[1]> STRLEN sc
    (integer) 39
    127.0.0.1:6999[1]> OBJECT encoding sc
    "embstr"
    
    
    127.0.0.1:6999[1]> set sc 12345678901234567890123456789012345678901  
    OK
    127.0.0.1:6999[1]> STRLEN sc
    (integer) 41
    127.0.0.1:6999[1]> OBJECT encoding sc
    "embstr"
    
    

    额,这里我看《Redis 开发与运维》一书

    39字节,embstr 转raw。写错了?

    我的本机redis版本是5.0+,这本书是3.0,中间肯定是有了版本更新。

    试试看看源码和提交记录 (https://github.com/antirez/redis/commit/f15df8ba5db09bdf4be58c53930799d82120cc34#diff-43278b647ec38f9faf284496e22a97d5)

    继续尝试 embstr -> raw

    127.0.0.1:6999[1]> set sc 12345678901234567890123456789012345678901234
    OK
    127.0.0.1:6999[1]> STRLEN sc
    (integer) 44
    127.0.0.1:6999[1]> OBJECT encoding sc
    "embstr"
    
    127.0.0.1:6999[1]> set sc 123456789012345678901234567890123456789012345  // 对内类型:raw
    OK
    127.0.0.1:6999[1]> STRLEN sc
    (integer) 45
    127.0.0.1:6999[1]> OBJECT encoding sc
    "raw"
    

    常用命令

    set key value [EX seconds] [PX milliseconds] [NX|XX]

    -- ex 秒级过期时间

    -- px 毫秒级过期时间

    -- nx 不存在才能执行成功,类似添加

    -- xx 必须存在才能执行成功,类似修改

    nx

    127.0.0.1:6999[1]> EXISTS bus
    (integer) 0
    127.0.0.1:6999[1]> SET bus Q xx
    (nil)
    127.0.0.1:6999[1]> SET bus Q nx
    OK
    
    

    xx

    127.0.0.1:6999[1]> EXISTS car
    (integer) 0
    127.0.0.1:6999[1]> SET car B
    OK
    127.0.0.1:6999[1]> SET car C nx
    (nil)
    127.0.0.1:6999[1]> SET car C xx
    OK
    127.0.0.1:6999[1]> GET car
    "C"
    
    

    setnx / setxx

    这两个命令会逐步弃用

    String类型源码分析

    SDS 数据结构

    为什么Redis要自己实现一套简单的动态字符串?

    1. 效率
    2. 安全(二进制安全:C语言中的字符串已 “” 为结束标志。)
    3. 扩容
    

    如果说,有一辆车,到站前提前告知车站乘客,本次列车还有多少余座。

    此时,如果有个计数器可以计算一下当前坐了多少乘客,同时还有多少空位就好了。

    这样司机师傅就不必每次停车上客前,数数还有多少座位可以坐。可以专心开车。

    同样,Redis SDS 也使用了这样一些小小的记录,

    使用时候获取这个记录,时间复杂度是O(1),效率是很高的。不用每次都去统计。

    redis做了这样的设计:

    struct sdshdr {
        unsigned int len;
        unsigned int free;
        char buf[];
    };
    
    
    len 已用字节数
    
    free 未用字节数
    
    buf[]  字符数组
    

    这样设计有什么好处?
    1. 方便统计当前长度等,时间复杂度是O(1)
    2. 有了长度这些关键属性,可以不依赖“” 终止符。二进制安全。
    3. 指针返回的是buf[],这样可以复用C字符串相关的函数。避免重复造轮子,兼容C字符串操作
    4. 前面的len和free以及数组指针buf,内存分配上地址是连续的。所以很容易使用buf地址找到len和free.

    我们先来看看,这个数据结构:

    问题来了,是否还有优化的空间呢?

    这样问比较笼统。我们思考一种场景:是不是所有的字符串存储都需要这样的结构?

    到这里,有经验的你已经想到,所有的情况用没问题,但是Redis是内存数据库,

    内存是资源,如何在资源上斤斤计较是Redis必须权衡的问题。

    现在我们坐下来仔细分析一下:

    unsigned int len 可以存的数据范围是:0 ~ 4294967295 (4 Bytes)

    Redis中的字符串长度往往不需要这么大,多大合适呢?

    1字节(Byte)? 这样?
    
    struct sdshdr {
        char len;
        char free;
        char buf[];
    };
    
    

    呀, 1字节是0~255,一般长度的字符串足够用。

    如果真的存储了1个字节的字符串,len和free加起来也占了两个字节。

    本来数据就1字节大,我为了存数据,额外信息都占2字节。

    再优化,只能使用位来存储长度
    

    假设,我们从全局来看,将字符串长度(小于1KB,1KB,2KB,4KB,8KB)来表示。

    对于1字节,至少要拿出3个位,才能覆盖这5种情况( 2^3=8),那么剩下的5位才能存储长度。

    现在我们已经进入到了Redis5.0 数据结构时代:

    struct __attribute__ ((__packed__)) sdshdr5 {
        unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
        char buf[];
    };
    
    

    3个低位标识类型,5个高位存长度(2^5=32)

    说到这,长度大于31(''结束符)的字符串,1个字节是存不下的。

    我们还是按照之前的逻辑 len和free再结合刚才的按位看长度类型,来看看大于1字节的数据结构:

    struct __attribute__ ((__packed__)) sdshdr8 {
        uint8_t len; /* used */
        uint8_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    
    
    使用了多少(len)、分配了多大(alloc)、长度类型标识(flags) ---- 这些表头= 1字节+1字节+1字节 ,共3字节
    所以Redis对:字符串大小的界限就有了对应的宏定义
    #define SDS_TYPE_5  0     // 小于1KB
    #define SDS_TYPE_8  1     
    #define SDS_TYPE_16 2    
    #define SDS_TYPE_32 3    
    #define SDS_TYPE_64 4    
    
    
    对应的数据结构就是:
    struct __attribute__ ((__packed__)) sdshdr5 {
        unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr8 {
        uint8_t len; /* used */
        uint8_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr16 {
        uint16_t len; /* used */
        uint16_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr32 {
        uint32_t len; /* used */
        uint32_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr64 {
        uint64_t len; /* used */
        uint64_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    

    创建字符串

    sds sdsnewlen(const void *init, size_t initlen);
    

    看看注释,非常明白:

    /* Create a new sds string with the content specified by the 'init' pointer
     * and 'initlen'.
     * If NULL is used for 'init' the string is initialized with zero bytes.
     * If SDS_NOINIT is used, the buffer is left uninitialized;
     *
     * The string is always null-termined (all the sds strings are, always) so
     * even if you create an sds string with:
     *
     * mystring = sdsnewlen("abc",3);
     *
     * You can print the string with printf() as there is an implicit  at the
     * end of the string. However the string is binary safe and can contain
     *  characters in the middle, as the length is stored in the sds header. */
    
    

    获取SDS类型

    char type = sdsReqType(initlen);
    

    SDS_TYPE_5 一般用于字符串追加,所以还是用8这个。

    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    

    获取头长度

    int hdrlen = sdsHdrSize(type);
    

    申请内存(头+数据体+终止符)

    sh = s_malloc(hdrlen+initlen+1);
    

    s=数据体buf[]指针

    s = (char*)sh+hdrlen;
    

    buf[]指针-1,就找到了长度类型flag

    fp = ((unsigned char*)s)-1;
    

    最后缀上结束符,然后返回的是buf[]指针,兼容C语言字符串

        s[initlen] = '';
        return s;
    

    追加字符串

    sds sdscatlen(sds s, const void *t, size_t len) {
        size_t curlen = sdslen(s);  // 原字符串长度
    
        s = sdsMakeRoomFor(s,len); // 调整空间
    
        if (s == NULL) return NULL; // 内存不足
    
        memcpy(s+curlen, t, len); // 追加
    
        sdssetlen(s, curlen+len);  // 设置新的长度
    
        s[curlen+len] = ''; // 结束标志,兼容C字符串
    
        return s;  
    }
    

    释放字符串

    void sdsfree(sds s) 
    

    直接释放内存

    if (s == NULL) return;
      s_free((char*)s-sdsHdrSize(s[-1]));
    }
    

    为了避免频繁申请关释放内存, 把使用量len重置为0,同时清空数据

    void sdsclear(sds s) {
      sdssetlen(s, 0); 
      s[0] = '';
    }
    

    好处数据可以复用,避免重新申请内存

    应用场景

    用户信息

    最近用户中心的访问压力极大,数据库已经扛不住。

    我们使用比家里快而且成熟的技术,就是再加一层缓存。

    比如:

    uid:ui01

    username: sunchong

    nickname:二中

    roletype:01

    level:0

    需求是:用户中心的用户数据,可以用uid拿到,也可以根据username拿到(uid和username 都是唯一不重复的)

    我根据uid可以获取查询到用户,也可以根据username获取到用户。

    首先,使用哈希进行数据的缓存 — HSET user:ui01 key1 value1 key2 value2 key3 value3 ...

    127.0.0.1:6999> HSET user:ui01 username sunchong nickname 二中 roletype 01 level 0
    (integer) 4
    127.0.0.1:6999> HKEYS user:ui01
    1) "username"
    2) "nickname"
    3) "roletype"
    4) "level"
    
    

    然后创建映射关系:

    127.0.0.1:6999> SET user:sunchong ui01
    OK
    127.0.0.1:6999> GET user:sunchong
    "ui01"
    
    

    通过 username 找到主键uid,然后根据主键获取用户信息。

    数据量较多时,过期时间设置为一定区间内的随机数。避免缓存穿透。

    接口请求次数

    当前我们有对用户开放的API,用户充值后使用,使用次数累加,剩余次数递减。

    127.0.0.1:6999> SET user-ui01:times 1000
    OK
    
    127.0.0.1:6999> INCR user-ui01:times
    (integer) 1001
    
    127.0.0.1:6999> GET user-ui01:times
    "1001"
    127.0.0.1:6999> DECR user-ui01:times
    (integer) 1000
    
    

    短信验证码

    就在前几天,我们刚刚对接了阿里云短信码服务。

    起初,我自己认为短信验证码为了实时性不需要进行实际的缓存处理。

    但是完全可以根据实际情况进行设计魂村策略。

    为了防止接口的频繁调用,我们可以像网关一样进行设置。

    现在就有这样一个需求:1个手机号,1分钟最多获取10次验证码

    SET Catch:Limit:13355222226 1 ex 60 nx 
    

    初始化手机号,起始次数是1,默认过期时间60秒

    再剩下的就是代码判断次数即可。

                string redisConn = "127.0.0.1:6378,defaultDatabase=0,prefix=";
                using (var redis = new CSRedis.CSRedisClient(redisConn))
                {
                    string key = "Catch:13355222222";
                    bool inserted = redis.SetAsync(key, 1, 60, CSRedis.RedisExistence.Nx).Result;
    
    
                    if (inserted || redis.IncrByAsync(key).Result <= 10)
                    {
                        // 新增或未达上限--发送验证码
                    }
                    else
                    {
                        // 受限
                    }
                }
    

    总结

    字符串类型结合命令有很多的应用场景,这个有待去收集和发现。

    Redis 比较容易上手,文档全,代码整洁高效。

    当然更需要我们去深入其运行原理,来更好使用这个工具来服务我们的业务。

  • 相关阅读:
    error PRJ0019的一个解决心得
    3月3日工作日志88250
    IBM、BEA和JBoss应用服务器采用OSGi
    四级再次挂了
    迁移应用进入基于Annotation MVC的spring 2.5
    如何在VC6.0中设置条件断点
    3月4日工作日志88250
    KMP字符串模式匹配详解
    KMP字符串模式匹配详解
    C/C++之SQLite常用函数
  • 原文地址:https://www.cnblogs.com/sunchong/p/11754048.html
Copyright © 2020-2023  润新知