Redis的实现,很详细的笔记整合 | 青训营

129 阅读8分钟
  • 为什么需要Redis?

    冷热分离。缓解数据量增长下,读写数据的压力

  • Redis如何运作?

    数据从内存中读写,(手动)保存至硬盘以持久化。其中AOF文件存增量数据,RDB文件存全量数据。

    Redis采用一个全局的hashtable(数组)保存所有的KV对。数组的每个位置都是一个独立的entry,保存着key(s)和value(s)。key为string,value为string/list/hash/set/zset中的任意一种。

    image.png

  • Redis特点:

    • 高性能(基于内存,单线程处理所有网络I/O和KV读写,多路I/O复用,Gossip协议同步,数据结构高效)
    • 高可用(主从复制、哨兵集群)
    • 高拓展(Cluster分片集群)

    对于~8000以内的连接数,QPS可达100k/s。

案例

  • 连续签到

    (set expiration as 后天0点,如果明天未签到,一旦到了后天0点,连续签到数据就会消失)

  • 消息通知

    用list作消息队列,用于如将更新后的文章推送到ES

  • 计数,用户信息,购物车信息等

    用hash(表)存一个用户的多项计数需求

  • 去重、踩、赞、共同好友

    用set

  • 排行榜

    用zset

  • 限流

    以时间戳+username作为key,对此key调用incr,超过限制N则禁止访问

  • 分布式锁

    用setnx实现


底层实现

1. 维护/管理内存资源

  • 命中率统计

  • LRU:每次读取一个key后,服务器会更新其lru时间

  • dirty标识:如果有客户端使用WATCH命令监视了该键,服务器会将这个键标记为dirty,让事务程序注意到这个键已经被修改过。每次修改都会对dirty加一,用于触发持久化和复制

  • 数据过期:结合2种策略

    • 惰性删除:如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作。缺点是如果不访问,过期key就会一直存在内存中

    • 定期删除:启用一个定时器定时监视所有key,删除过期的key。缺点是每次都遍历内存中所有数据,很耗cpu

    二者结合,定期删除只随机抽取一部分key检查,剩下的靠惰性删除解决。两者都未cover到的,交给内存淘汰机制

  • 内存淘汰

    • noeviction:redis默认。内存不足时新写入操作直接报错
    • allkeys-lru:内存不足时移除最近最少使用的key
    • allkeys-random:随机移除某个key
    • volatile-lru:在设置了过期时间的key中,移除最近最少使用的key。(一般是把redis同时当缓存和持久化存储时才用)
    • volatile-random
    • volatile-ttl:在设置了过期时间的key中,移除过期时间更早的key

    这里的lru采用采样概率,不存双向链表(?)

2. 网络I/O多路复用

  • 阻塞I/O下,app调用OS的recvfrom(),这个函数会阻塞线程直至数据回来。于是若要同时处理多个网络请求,就需要配合大量线程使用,每接收一个连接都要创造一个新线程。创建、上下文切换的开销很大,还有锁竞争的潜在风险(?)。

  • 而非阻塞I/O下,多个网络连接用同一个线程。recvfrom()不断轮询看数据是否准备好。若数据未准备好,kernel不会阻塞在一个连接上,会直接返回错误并将错误码设为EAGAIN或EWOULDBLOCK。缺点是比较耗费CPU。

I/O复用模型就是为解决CPU占用而生。Linux提供poll/select/epoll三种方式,Mac:kqueue,Windows:select

当管理的连接数过少时,这种模型会退化成阻塞I/O,而且还多了一次对poll/select/epoll的系统调用。

此外,poll/select/epoll“本质仍是同步I/O”。I/O multiplexing解决的是数据准备阶段的问题,事件就绪后,从kernel复制数据到user space仍需由它们自己来完成,而这个读写过程还是阻塞的。

  • select(O(n) time): 需要将fd集(包括readfds/writefds/exceptfds)从user space整个拷贝到kernel space。当有fd就绪,select调用者可以感知到(i.e. select会返回),但不知道具体是哪些个fd,必须轮询
  • poll(O(n) time):几乎等同于select,但fd是基于链表存储,并不像select一样有上限
  • epoll(O(1) time):user/kernel共享内存。感知基于event callback方式,仅感知活跃连接

3. 自实现的网络事件处理器(基于reactor模式)

在Redis中,I/O多路复用程序监听多个socket(每个socket连接都与程序的一个fd对应,I/O multiplexing实际是监视当前程序的fd)。被监听的socket准备好执行accept/close/read/write时,相应的事件会发出,进到多路复用程序的队列,然后被文件事件分派器转发给不同的事件处理器。

感谢各位大佬制图:

image.png

image.png

image.png


4. 不同数据类型的底层实现

(首先明确,所有Key均为string,以下数据类型为Value可用的一些数据类型)

1. String: sdshdr

struct sdshdr {
    int len; // length of buf, used
    int free; // length of buf, free
    char buf[];  // length = len + free + 1 (for the ending '\0')
}
  • len帮助O(1)获取string的长度,同时允许string中出现'\0'(二进制数据中会出现)而不会影响string的边界判断
  • free帮助直接判断执行strcat时是否需要扩容;释放空间时,也未必需要回收内存,只需把释放出的大小存在free中,这样一来,下次要是要append或许就不用额外地扩容了

此外,每次sds被修改,Redis还会额外分配一些空间,以减少总的内存重分配次数


2. List: 双端linkedlist + ziplist --> quicklist

  • 双端linkedlist:是多态的(用void*指针),可以保存不同类型的value
  • ziplist:压缩,节省了linkedlist中pointers的空间。查首尾是O(1),查别的元素是O(n)
    struct ziplist<T> { 
        int32 zlbytes;   // total number of bytes occupied
        int32 zltail_offset;  // for jumping to the last element in the list
        int16 zllength;  // number of entries in the ziplist
        T[] entries;     // an entry: [ previous_entry_length | encoding | content ]
        int8 zlend;      // the constant 0xFF. mark the end of ziplist. 
    }
    

linkedlist的空间成本很高(光prev/next指针就去掉了16bytes,且linkedlist节点在内存中单独分配,会加剧内存的碎片化),所以后来Redis用quicklist代替了ziplist + linkedlist,将linkedlist 按段切分,每一段使用 ziplist 来紧凑存储:

image.png

3. Hash: dictht

struct dictht {
    dictEntry **ht;
    int size;
    int sizemask;
}
  • hash(key) & sizemask = key所在槽位。注意一个槽位里可能有多个entry(key冲突的情况)

渐进式Rehash(同样适用于Redis的全局哈希表!):

假设要把ht[0]中的数据全部迁移到ht[1]中,然后释放ht[0](比如对hashtable扩容以解决冲突时)。数据量大的场景下,迁移过程会明显阻塞用户请求。

解决方案:渐进式rehash -- 将整个迁移过程分摊到多次处理请求的过程中,每次用户请求时只迁移一点点


4. Zset: zskiplist + dictht

  • 跳表:增加多层级索引以加速链表的查询,平均复杂度达到O(log n)。长这样:

    image.png

    (新节点的层高是按概率随机生成的)

  • dict中key为元素的值,对应skiplistNode的obj属性;value为元素的分值,对应skiplistNode的score属性

struct zset {
    zskiplist *zsl;
    dictht *dict;
}

struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length; // number of nodes in the list
    int level;  // deepest level
}

struct zskiplistNode {
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;  // distance (nodes spanned) from current node to *forward
    } level[];
    
    struct zskiplistNode *backward;
    robj *obj;
    double score;
}

zskiplist以score为基准来排列

二者结合,dict使得查找为O(1),skiplist使得范围操作时无需再对dict进行排序


使用注意

1. 大key

string value字节数大于10K,或其他数据类型元素个数大于5000/总字节数大于10M,容易导致慢查询

  • 读取成本高,读写耗时变长
  • 主从复制异常,服务阻塞,不能正常响应请求

解决办法:

  • 拆分value(实操比较复杂)
  • 压缩value,读取时再解压
  • 区分冷热(如榜单只缓存前10页,后续走db)

2. 热key

某key的QPS特别高,导致server实例出现CPU负载突增/不均。

解决办法:

  • 在业务服务侧设置local cache,local cache里没有再去redis拉数据(e.g. Java的Guava,Go的BigCache)
  • 拆分:复制这个热key(key1:value, key2:value)。访问时走不同的key,以此将QPS分散到不同的redis实例上。代价是更新时需要更新多个key,存在短暂的数据不一致风险

3. 慢查询

避免这些操作:

  • 批量操作一次性传入过多的key/value,如mset/hmset/sadd/zadd等O(n)操作。建议单批次不要超过100,超过100之后性能下降明显
  • zset大部分命令都是O(log(n)),当大小超过5k以上时,简单的zadd/zrem也可能导致慢查询
  • 操作的单个value过大,超过10KB。也即,避免使用大Key
  • 对大key的delete/expire操作也可能导致慢查询,Redis4.0之前不支持异步删除unlink,大key删除会阻塞Redis

4. 缓存穿透/缓存雪崩

缓存穿透:热点数据绕过缓存,直接查询数据库

  • 比如大量查询某个不存在的数据(攻击)。这种数据通常不会缓存,查询会全部打到DB
    • 解决办法:缓存空值,或使用bloom filter来存储合法的key
  • 高并发场景下,如果一个热key过期,大量请求会同时击穿至DB

缓存雪崩:大量缓存同时过期

  • 将缓存失效时间分散开,比如在原有的失效时间上增加一个随机值;热点数据设置尽量长的过期时间
  • 使用缓存集群,避免单机宕机造成的缓存雪崩

Ref:

字节redis课件

www.cnblogs.com/wzh2010/p/1…

zhuanlan.zhihu.com/p/367591714

zhuanlan.zhihu.com/p/64746509

www.jianshu.com/p/360627bd0…

redisbook.com/preview/ski…

blog.csdn.net/CSDN2497242…

blog.csdn.net/weixin_4300…