尝试实现了一个多线程版 redis,分享一下实践笔记

2019-04-04 17:37:40 +08:00
 oncewosiwo

由来

  前几个星期在知乎看到阿里云的多线程版 Redis 实现,感觉是个不错的简化方案,而且之前实践过自己的 gedis 项目,积累了一定的经验,所以就决定自己也薅一个多线程 redis 出来

有兴趣可以移步 github 看具体的项目: https://github.com/wosiwo/redis-multithreading

Redis 内部实现

工欲善其事必先利其器,动手改造之前先要梳理好 redis 本身的设计思路

Redis 的各个逻辑链

  一直觉得接触一个大项目需要抓好切入点(一个是提高效率,一个是容易建立正反馈),虽然代码可读性一直都是大家的追求,不过程序语言到底还是处在人的语言和机器语言中间,通读代码来理出逻辑肯定不是一个高效的办法。
  基于之前的实践,感觉从逻辑链入手,是一个比较好的办法,一开始不要把自己困在一个个逻辑实现的细节总,人大脑的并行能力其实很有限,不同层次的逻辑混在一起看,难度就大了很多

//一次 Redis 请求的执行流程
main();                                    //入口函数
listenToPort();                           //端口监听
aeCreateFileEvent();                        //事件触发绑定
aeMain();                                   //主事件循环
acceptTcpHandler();acceptUnixHandler();     //创建 tcp/本地 连接
createClient();                           //创建一个新客户端
readQueryFromClient();                    //读取客户端的查询缓冲区内容
processInputBuffer();                        //处理客户端输入的命令内容
processCommand();                         //执行命令
addReply();                               //将客户端连接描述符的写事件,绑定到指定的事件循环中
sendReplyToClient();                      //reactor 线程中将内容输出给客户端

更详细的调用链梳理:CallChain.md

几种多线程模型的对比

了解了 Redis 本身的逻辑链,下面就可以思考应该应用那种多线程模型

多 reactor 单 worker 线程模型

目前实现的是第一个方案,这里做一个详细的介绍,先借用阿里云 Redis 的模型图

void dispatch2Reactor(int connfd,redisClient *c){
    int reactor_id = connfd%server.reactorNum; //连接 fd 对 REACTOR_NUM 取余,决定抛给哪个 reactor 线程
    //将 connfd 加入到指定 reactor 线程的事件循环中
    //reactor 线程的事件驱动器被触发后,AE_READABLE 类型的事件会被分发到 reactorReadHandle 函数
    aeCreateFileEvent(server.reactors[reactor_id].el,connfd,AE_READABLE,reactorReadHandle, c);
}
void reactorReadHandle(aeEventLoop *el,int connfd, void *privdata, int mask){
    int ret = readQueryFromClient(el, connfd, privdata, mask);
    //通过管道通知 worker 线程
    int pipeWriteFd = server.worker[0].pipMasterFd;
     //将客户端信息添加到当前 reactor 线程的队列中
    atomListAddNodeTail(server.reactors[c->reactor_id].clients,c);
    //worker 线程循环读取队列,可以判断 worker 线程状态来决定是否通过管道通知 worker 线程
    //避免大量的管道读写带来的开销
    if(0==server.worker[0].loopStatus){
        ret = write(pipeWriteFd, str, 5);
        redisLog(REDIS_NOTICE,"reactorReadHandle reactor_id %s write %d connfd %d",str,ret,connfd);
    }
}
void workerPipeReadHandle(aeEventLoop *el,int pipfd, void *privdata, int mask){
      void *node;int nullNodes = 0;
      do{     //轮询各个线程的队列,循环弹出所有节点
          reactor_id = i%(server.reactorNum);
          //从无锁队列从取出 client 信息
          redisClient *c = atomListPop(server.reactors[reactor_id].clients);
          if(NULL==node) nullNodes++;

          processInputBuffer(c);  //执行客户端操作命令
      }while(nullNodes<server.reactorNum); //循环取队列     
}

Redis 拆分多线程后线程安全问题梳理

先就第一个方案来梳理拆分后遇到的线程安全问题

客户端对象

redisClient *c ,在主线程,reactor 线程,woker 线程都有可能被操作

字典扩容

在数据库字典 dict 满的时候,会对字典进行扩容,这个时候会有线程安全问题

频道订阅

对 channel 订阅,与取消订阅的操作,需要改造为无锁队列(pubsubUnsubscribeChannel())

server 全局变量
事务

TODO 验证多线程对事务是否有影响

集群

多线程下的集群 也是 TODO 状态

性能优化措施

对线程模型进行优化,以充分利用多核,以及尽可能减少线程间的互斥

EnQueue(list,x) //进队列改良版
{
    node = new record();
    node->value = x;
    node->next = NULL;
 
    p = list->tail;
    oldp = p

    //判断是否空表
    if(CAS(&list->tail, NULL, node) == true){ //表尾指针为空
        //这里有个需要特别注意的地方,不能直接把 node 节点赋值给 list->head
        //因为当出队速度快于入队速速,是会把队列取空的,一旦队列取空,是无法原子的同时吧 list->head 和 list->tail 原子的置空的
        //所以就需要给原队列留下一个种子,保证队列不会完全被置空
        //无锁队列的设计很巧妙的通过哨兵节点,在初始化队列时,给 head 指针赋值一个空节点,这个空节点的 next 指针再指向真正的当前节点
        //这样即使在取空队列的时候,仍然会有一个节点被留下来
        headNode = new record();
        headNode->next = NULL;
        headNode->prev = NULL;
        list->head = headNode;
        if(CAS(&list->head->next, NULL, node)!=true){
            //printf("list->head shoud be null except\n");
        }
    }else{
      do {
          while (p->next != NULL) //加这个 while 循环是因为 tail 节点是不能保证一定指向最后一个节点的
              p = p->next;
      } while( CAS(&p.next, NULL, node) != TRUE); //如果没有把结点链在尾上,再试
      //p.next 表示当前节点已经是事实上的最后一个节点

      CAS(&list->tail, oldp, node); //置尾结点
      //如果 tail 指针指向的仍然是循环之前的节点,则将其指向新加入的节点
      //tail 指针没有变化,说明这中间没有其他线程对链表进行入队操作
      //不论是否发生了变化,其实都不能保证 tail 指针指向的是最后的节点,只是能够在执行这段代码时没有其他线程插入的情况下将 tail 指针更新到较新的节点

    }
    
}
  出队列操作
  DeQueue(list) //出队列
  {
      do{
          p = list->head;
          if (p->next == NULL){
              return ERR_EMPTY_QUEUE;
          }
      while( CAS(&list->head, p, p->next) != TRUE );
      return p->next->value;  //返回的是下一个节点的内容
      //TODO head 指针应该避免跑到 tail 指针后面
  }

压测对比

对各种线程模型的压测对比(后面的两种方案只是实验性质的拉了分支,并没有处理线程安全问题,所以只能对 get 命令压测)

机器环境

|cpu | 内存 | 操作系统 | | ------ | ------ | ------| | Intel(R) Xeon(R) CPU E5-1650 0 @ 3.20GHz (6 核 12 线程) | DDR3 32G|Ubuntu 16.04.4 LTS|

压测结果

| | 原版 redis3.0 | 多 reactor 单 worker |多 reactor ( io 线程直接执行命令)|多 reactor 多 worker(*)| | ------ | ------ | ------ | ------ | ------ | | GET | 110533/107540 | 304697/208500 | 427069.456| 350202.9| | SET | 104119.06 | 217941.196 | | | | | HSET | 101584.73 | 181488.2 | | | | | HGET | 98058.45 | 180018 | | | | | ZADD | 87382.03 | 132996.41 | | | |

//这里直接使用了 vire 的多线程压测客户端
//e.g get 命令的压测  -T 表示压测程序开启的线程数
./tests/vire-benchmark -p 6381 -c 600 -T 12 -t get -n 1000000
GET 命令的压测会有两个值,斜杆"/"前是直接对空数据库的 GET 请求,另一个是有数据情况下的 GET 请求  

  从压测对比上看,目前实现的多 reactor 单 worker 线程模型(虽然只在空数据集的情况下进行压测),在大部分命令中性能大概是原版 redis 是两倍,不过还是直接多个工作线程的方案三性能最好

TODO
引用

https://coolshell.cn/articles/8239.html
https://zhuanlan.zhihu.com/p/43422624

2340 次点击
所在节点    程序员
14 条回复
wayslog
2019-04-04 18:32:06 +08:00
C10M 不是你想优化就能优化的出来的少年,业界早有结论,C10M 的重点在于 bypass 内核。DPDK 用起来。。。
kiddult
2019-04-04 19:26:20 +08:00
Redis 在实现内部结构的时候,基本上没考虑线程安全,多 worker 的话,线程安全的损耗应该没法忽略吧?或者简化点,采用 MQ 的方案,每个 DB 对应至多一个 worker,一个 worker 可以操作多个 DB,不过对于 Redis 集群意义不大。

个人还是更倾向于多进程,如果想充分利用内存的话,启动多个实例就是了

磁盘也没多大意义,redis 曾经也想把虚拟内存加进来,但是对性能影响太大了
atonku
2019-04-04 19:41:25 +08:00
emmmmmmmmmmmmmmmmmmmm
oncewosiwo
2019-04-04 20:10:57 +08:00
@kiddult 多线程操作同一个 DB 也可以尝试控制损耗,对已经存在的 key,可以在 key 这一层加读写锁,冲突会比较小,新增 key 的话可以研究下能不能用上无锁哈希表
多进程的话我后面开多个实例压测下看看瓶颈是在哪里
kiddult
2019-04-04 20:26:14 +08:00
@oncewosiwo 可以是可以,不过总起来说,感觉付出和回报不成比例,DDR4 的带宽应该能到 20GB 那个数量级,这个速度对于网卡的性能应该是足够了,除非服务器多 CPU ?那块就不清楚具体有多少性能差距了
ihacku
2019-04-04 22:59:39 +08:00
Mirana
2019-04-05 01:06:39 +08:00
redis 多线程最蛋疼的是怎么解决数据分布的问题

如果是多线程共享同一份数据的话,访问同一个 key 要加锁

如果每个线程负责一部分独立的数据的话,热点 key 和大 key 又会造成数据分布不均和流量不均
yayoyamasaki
2019-04-05 01:35:19 +08:00
@Mirana 大 key 热点不改数据存储方式我觉得无解
热点 key Redis 本身的 hash 算法是不是已经满足需求了?不然 worker 线程负责某几个不连续的 hashslot 块
9hills
2019-04-05 07:28:03 +08:00
最稳妥的方式是每个 cpu 核绑一个 redis 实例,充分利用
oncewosiwo
2019-04-05 09:32:19 +08:00
@Mirana 如果 key 这一层的锁也不能满足需求的话,目前想到的办法是让 get/set 之外的复杂数据结构支持线程安全,将锁的粒度放的更小
zeromake
2019-04-05 10:22:18 +08:00
话说为啥是用 redis3.0 实现,最近在看的 《 redis 设计与实现》也是 3.0 ……,已 star
oncewosiwo
2019-04-05 11:35:32 +08:00
@zeromake 就是在这本书的作者加了中文注释的版本上改的~
orafy
2019-04-05 13:30:54 +08:00
pedis 了解下,嘿嘿
https://github.com/fastio/pedis/
ye939647181
2019-04-05 19:43:35 +08:00
基于内存的 Redis 为什么需要多线程,会比单线程快?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/552150

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX