Redis 最佳设计 Top 9
in redis with 0 comment

Redis 最佳设计 Top 9

in redis with 0 comment

文章是作者在学习开源组件原理时找出来的一些比较优秀(有趣)的设计。通过了解这些设计以及实现,提升自己的编码水平。希望这能成为一个系列,通过不断完善文章来学习并且熟悉开源框架。

本文排名分先后,文章极具主观性,也可以当作是作者最喜欢的设计排名,可能会因为作者水平不够导致漏掉些更加优秀的设计。

本文属于广而不精的类型,如果需要深入了解,建议阅读下参考文章或者去阅读相关源码(参考文章比本文章价值大多了)。

No.9 线程模型

Redis 4.0 前

为了避免多线程对代码带来的复杂性,Redis 一开始设计采用了单线程模型。为了避免阻塞主线程太久,Redis 通过各种渐进式操作来减少对主线程的阻塞。

Redis 4.0

但是由于删除一些比较大的 Key 操作仍然会阻塞线程,所以 Redis 引入了 UNLINKFLUSHALL ASYNCFLUSHDB ASYNC 这些异步操作来避免删除阻塞。(放心用 UNLINK,不会去直接开新线程的,会先判断一下这个 key 是否为 bigkey,再开新的线程)。这个版本去掉了共享,对 Redis 进行了巨大重构。

Redis 6.0

随着单机需要承受的流量越来越高,网络 I/O 瓶颈已经越来越明显。具体是哪块呢?我们来看一眼 I/O 多路复用。

我一开始完全没有理解到为什么 IO 是瓶颈。直到我看到了一篇美团的文章:https://tech.meituan.com/2016/11/04/nio.html

emma_1

IO 分为两步,第一步是从网卡拷贝到内核中,这步是 IO 密集型的。
第二步是从内核态拷贝到用户态,这一步是 CPU 密集型的,而且这个过程非常快,属于memory copy,带宽通常在1GB/s级别以上,Redis 单线程模型就是在这一步遇到了瓶颈。

Redis 6.0 只把 IO 线程改成了多线程的,也就是图上将数据从内核拷贝到用户空间改为多线程。具体操作数据结构仍然是单线程的,大概率是因为原有的数据结构并不支持多线程操作。

相当于 netty 中的 workpool 的线程数是 1,worker 线程数从 1 到了 n。

No.8 伪客户端

Redis 为每一个客户端都维护了一个 RedisClient 结构,这些结构用链表连接,通过维护客户端结构,很方便的实现了 pipeline,事务,顺序返回,AOF 载入,LUA 等功能特性。

RedisClient 结构中包括:socket,客户端名字,标志,输入输出缓冲区,状态,阻塞命令缓冲区,watch 的键,发布与订阅用到的数据结构等等。

伪客户端:加载 AOF 文件或者执行 LUA 脚本,本质上也能看作是一个本地的客户端执行命令,Redis 非常巧妙的实现了这两个功能。通过服务端内部维护两个伪客户端,分别用来加载 AOF 文件和执行 LUA 脚本。

No.7 渐进式处理

由于 Redis 对数据结构的操作是单线程的,为了避免服务端长时间阻塞,采用了许多渐进式操作,包括:

字典渐进式 rehash

hash 内部存着两个 hashtable,通常情况下只有一个 hashtable 有值,当扩容和缩容的时候会生成一个新的 hashtable,然后往新的 hashtable 上搬数据。在 rehash 过程中,所有的查找操作会在两个 hashtable 上进行,添加操作只在新的 Hash 中进行。

渐进式 rehash + 定时搬迁
_dictRehashStep方法在每次插入查找删除操作里都会去执行一次,这个函数把第一个不为空的桶搬到新的 hashtable 上。

scan

Redis 包含了 SCANSSCAN HSCAN ZSCAN 命令去迭代数据库中的键,可以每次迭代一小部分,以免长时间阻塞住服务器。scan 采用了高位加法来做迭代,避免了在扩容或者缩容的时候漏掉数据。

遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;

过期键的删除

Redis 并没有主动扫描全部 Key 来淘汰过期的 key,Redis 使用主动和被动的方式删除过期的 key,当访问 key 时会去判断 key 是否过期,如果过期则直接删除。

Redis 会把全部设置了过期时间的 key 放到一张大 map 中,并且定期扫描。默认行为是每秒钟进行 10 次扫描,随机取出 20 个 key,如果超过 1/4 个 key 过期了,则继续重复上面的操作,扫描 20 个 key 查看过期个数,否则证明过期的key占比比较少,等下一次扫描。

如果有大批量的 key 过期要给过期时间设置随机范围,否则在同一时间大量 key 删除会导致服务不可用

当然目前 Redis 4.0 以上版本可以开启 lazyfree-lazy-expire 配置把删除放在后台线程执行。

从节点不会主动扫描,主节点过期后会往 AOF 中写入一条 del 同步到从节点,会导致数据暂时的不一致。

No.6 环形缓存解决主从同步问题

在 Redis 2.8 以前,当从服务器断开与主服务器的连接后,需要重新从主服务器同步一遍完整的 RDB 文件。
在这版本之后,使用了环形缓存来解决重复传输的问题,如果从服务器断开一段时间。

当 Redis 收到写命令时,它不仅会把命令传播给从服务器,还会把写命令复制到一个环形缓存中。

当从服务器断开重新连接上主服务器时,会通过 PSYNC 命令把自己的偏移量发送给主服务器,如果偏移量仍在这个缓存的范围中,则从缓存中把写命令同步给从服务器。相反,如果断线时间过长,请求命令已经不在缓存中,则需要全量同步。

如果某个从服务器从主服务器同步完 RDB 文件后,发现由于全量同步时间过长,接下来需要同步的命令已经不在缓存中,则需要再次重新同步。所以这个环形缓存大小参数非常重要,可以适当调大一些。

如果想要更进一步了解使用环形缓存的优点可以看一下这篇文章。

http://ifeve.com/dissecting-disruptor-whats-so-special/

No.5 近似淘汰算法

为了减少内存消耗,Redis 使用了近似 LRU 与近似 LFU 算法。

LRU

近似 LRU 算法在现有数据结构的基础上采用随机采样的方式来淘汰元素,它为每个 key 增加了一个最后一次被访问的时间戳,当内存不足时,就执行一次近似 LRU 算法,具体步骤是随机采样 n 个 key,这个采样个数默认为 5,然后根据时间戳淘汰掉最旧的那个 key,如果淘汰后内存还是不足,就继续随机采样来淘汰。这里的淘汰策略如果设置的是 allkeys,就从所有 key 中随机采样,如果设置的是 volatile,就从设有过期时间的 key 中随机采样,采样值越大,效果就越接近传统的 LRU 算法。
Redis 3.0 在算法中增加了淘汰池,进一步提升了近似 LRU 算法的效果。具体原理是构建一个淘汰池数组,在每一次淘汰循环中,新随机采样的 key 会和淘汰池中的 key 进行融合,淘汰掉最旧的那个 key 后,保留剩余的 key 放入淘汰池中等待下一次循环。

LFU

Redis 后续推出了 LFU 算法,最近最少使用算法,在 key 上维护的最后访问时间变成了两部分,前面16位仍然是时钟,时钟进度是分钟,后8位是访问频率。由于如果按照访问时间 8 位不够表示,取了对数进行计算。

  uint8_t LFULogIncr(uint8_t counter) {
      if (counter == 255) return 255;
      double r = (double)rand()/RAND_MAX;
      double baseval = counter - LFU_INIT_VAL;
      if (baseval < 0) baseval = 0;
      double p = 1.0/(baseval*server.lfu_log_factor+1);
      if (r < p) counter++;
      return counter;
  }
  unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

新分配的 key 会给一定初始值,默认为 5 ,以免直接被淘汰了。时钟更新也会反过来影响频率的计算结果。

如果对更多缓存淘汰算法感兴趣可以看下这篇文章的淘汰策略部分(主要看文章的引用):
http://icyfenix.cn/architect-perspective/general-architecture/diversion-system/cache-middleware.html

No.4 对象节省空间

Redis 为了节省空间使用了许多非常有意思的操作,因为当 hash,zet 数据量比较小的时候整个遍历可能会比查询还快一点,借助紧凑的 ziplist 能节省不少空间。

具体 ziplist 啥的随便搜一篇文章都能看懂,我这里只挑我最喜欢的 string 多讲点,剩下的就随便过下吧。

string

string 有三种表示方式,当可以用 long 表示的时候是 long,比较短的时候是 emb,比较长的时候是 raw。
想要知道怎么在string里节省空间首先我们需要知道结构体长什么样子。

struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes
    void *ptr; // 因为是空指针可以存任意类型数据
} robj;
struct SDS<T> {
  T capacity; // 数组容量
  T len; // 数组长度
  byte flags; // 特殊标识位,不理睬它
  byte[] content; // 数组内容
}

当字符串比较短的时候, * ptr 会直接保存一个 SDS。当字符串比较长的时候,会保存一个指向 SDS 的指针。这样当字符串较短的时候就少保存了一个指针。

内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等等,为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。

hash
压缩链表紧凑的表示 HASH:
key|value|key|value

set
set 只会用 intset 来压缩空间,intset 的存储布局:
1|2|3|4|5

注意 intset 是含有顺序的,有序可以降低去重以及查找的时间复杂度。每个数字占的内存大小相同,可以简单的理解成数组。

zset
压缩链表表示 zset

key|score|key|socre|key|socre

No.3 Pipeline

pipeline 本质上是客户端上的功能,通过批量打包命令,一次性发送给 Redis 服务器来减少网络开销。

使用缓冲区打包命令然后一次性发送来减少网络开销的思路可以有很多运用。比如说 kafka producer 通过发送线程来一次性写入多条数据,TCP 下的 nagle 算法等等。

以下摘自:https://tech.meituan.com/2016/11/04/nio.html

还比如说我们也可以手动配置一个缓存:可以使用单线程+队列,把请求数据缓冲。然后pipeline 发送,返回 future,然后 channel 可读时,直接在队列中把 future 取回来,done()就可以了。

class RedisClient implements ChannelHandler {
    private BlockingQueue CmdQueue;
    private EventLoop eventLoop;
    private Channel channel;

    class Cmd {
        String cmd;
        Future result;
    }

    public Future get(String key) {
        Cmd cmd = new Cmd(key);
        queue.offer(cmd);
        eventLoop.submit(new Runnable() {
            List list = new ArrayList();
        queue.drainTo(list);
        if(channel.isWritable()) {
            channel.writeAndFlush(list);
        }
        });
    }

    public void ChannelReadFinish(Channel channel,Buffer Buffer) {
        List result = handleBuffer();//处理数据
        //从cmdQueue取出future,并设值,future.done();
    }

    public void ChannelWritable(Channel channel) {
        channel.flush();
    }
}

  1. 使用管道技术可以显著提升Redis处理命令的速度,其原理就是将多条命令打包,只需要一次网络开销,在服务器端和客户端各一次 read()write() 系统调用,以此来节约时间。
  2. 管道中的命令数量要适当,当命令数量提升到一定程度后,不仅性能不会再提升而且可能还会阻塞其他客户端命令的执行
  3. pipeline 适合一组没有关联的命令。

No.2 持久化

AOF 没有上榜,因为如果要给数据库设计持久化方式,我相信绝大部分人都能想到这个。

BGSAVE

BGSAVE 充分利用了操作系统的特性。Copy On Write(注意这个和 Java CopyOnWriteArrayList 的不是一个概念)。

当我们执行 BGSAVE 后 Redis 会 fork 一个子进程,子进程负责把内存中的数据保存到磁盘。父子进程的内存在 fork 时是完全相同的,在 fork 之后进行写入和修改也不会相互影响。当客户端出现更改命令,需要修改父进程的数据时,才会以页为单位进行拷贝并且修改。

假如没有用到 COW,会导致子进程需要大量拷贝内存,并且 Redis 内存利用率不能超过一半,这很明显是不可接受的。

重写 AOF

重写这个词非常有迷惑性,让人以为是去读 AOF 文件然后对文件进行优化,比如说某个 key 有多个操作,只保留最后的结果。但是这样实现起来成本太高了,真正的实现不是这样的。

具体实现其实与 BGSAVE 差不太多,只不过生成的文件是 AOF 文件。

  1. 主进程fork出子进程,主进程 fork 完子进程继续接受客户端请求,并且此时主进程接收到的写请求会同步一份到 aof_rewrite_buf 缓冲区中

  2. 子进程把内存中的 key 全部读一遍,然后向文件中写入 set xxx 命令,注意这里一般一条命令会写入多调数据

  3. 子进程写完新的 AOF 文件后,向主进程发信号,主进程把aof_rewrite_buf中的数据写入到新的AOF文件。

  4. 覆盖旧的 AOF 文件,完成重写操作

但是这个设计有一个不好的地方,在重写时,需要一块 aof_rewrite_buf 内存来保存写入数据,假如写入数据较多时会占用较多内存,并且在步骤 3 也会导致大量 CPU 消耗

在 Redis 7.0 中,使用增量 AOF 取代了 aof_rewrite_buf 缓冲区,原本写到缓冲区的数据变成了写到了增量 AOF 中,解决了内存占用,并且最后一步处理只需要简单的合并文件即可。

PS:这边重写 AOF 和 BGSAVE 不能同时进行,主要是考虑到资源占用的问题。

混合持久化

重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。

如果使用 AOF 日志重放,性能则相对 RDB 来说要慢很多,这样在 Redis 实例很大的情况下,启动的时候需要花费很长的时间。

由于重写 AOF 生成的文件还是比较大,重写 AOF 的过程其实和 BGSAVE 差不多,而且 BGSAVE 生成的 RDB 文件比 AOF 小得多,那么为什么不直接重写的时候生成 BGSAVE 文件呢?

根据这点 Redis 4.0 推出了混合持久化,混合持久化同样也是通过 bgrewriteaof 完成的,与上面不同的是当开启混合持久化时,fork出的子进程先将共享的内存副本以 RDB 方式写入 aof 文件,然后在将 aof_rewrite_buf 重写缓冲区的增量命令以 AOF 方式写入到文件。新的AOF文件前半段是RDB格式的全量数据后半段是AOF格式的增量数据。

No.1 lua

能执行脚本 lua 是作者在 Redis 中最喜欢的设计,在 Redis 中引入了 lua 脚本极大的提升了整个数据库的灵活性,通过自定义 lua 脚本,Redis 可以扩展出许多的用途,限流,分布式锁,原子操作等,在工作上帮助作者解决了不少难题。

大概讲一下几个使用注意事项

具体实现原理有很多文章都介绍过了这里就不重复一次了,有兴趣可以看下参考文章

参考