作者 | 景同学

来源 | https://juejin.cn/post/6928407842009546766

Redis作为一个基于内存的缓存系统,一直以高性能著称,因没有上下文切换以及无锁操作,即使在单线程处理情况下,读速度仍可达到11万次/s,写速度达到8.1万次/s。但是,单线程的设计也给Redis带来一些问题:

  • 只能使用CPU一个核;

  • 如果删除的键过大(比如Set类型中有上百万个对象),会导致服务端阻塞好几秒;

  • QPS难再提高。

针对上面问题,Redis在4.0版本以及6.0版本分别引入了Lazy Free以及多线程IO,逐步向多线程过渡,下面将会做详细介绍。

单线程原理

都说Redis是单线程的,那么单线程是如何体现的?如何支持客户端并发请求的?为了搞清这些问题,首先来了解下Redis是如何工作的。

Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

  • 文件事件:Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象;服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作,比如连接acceptreadwriteclose等;

  • 时间事件:Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象,比如过期键清理,服务状态统计等。

别再抱怨缺乏算法实践场景,不妨来挑战这场百万奖池的比赛!

如上图,Redis将文件事件和时间事件进行抽象,时间轮训器会监听I/O事件表,一旦有文件事件就绪,Redis就会优先处理文件事件,接着处理时间事件。在上述所有事件处理上,Redis都是以单线程形式处理,所以说Redis是单线程的。此外,如下图,Redis基于Reactor模式开发了自己的I/O事件处理器,也就是文件事件处理器,Redis在I/O事件处理上,采用了I/O多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数,通过一个线程实现了多客户端并发处理。

别再抱怨缺乏算法实践场景,不妨来挑战这场百万奖池的比赛!

正因为这样的设计,在数据处理上避免了加锁操作,既使得实现上足够简洁,也保证了其高性能。当然,Redis单线程只是指其在事件处理上,实际上,Redis也并不是单线程的,比如生成RDB文件,就会fork一个子进程来实现,当然,这不是本文要讨论的内容。

Lazy Free机制

如上所知,Redis在处理客户端命令时是以单线程形式运行,而且处理速度很快,期间不会响应其他客户端请求,但若客户端向Redis发送一条耗时较长的命令,比如删除一个含有上百万对象的Set键,或者执行flushdb,flushall操作,Redis服务器需要回收大量的内存空间,导致服务器卡住好几秒,对负载较高的缓存系统而言将会是个灾难。为了解决这个问题,在Redis 4.0版本引入了Lazy Free,将慢操作异步化,这也是在事件处理上向多线程迈进了一步。

如作者在其博客中所述,要解决慢操作,可以采用渐进式处理,即增加一个时间事件,比如在删除一个具有上百万个对象的Set键时,每次只删除大键中的一部分数据,最终实现大键的删除。但是,该方案可能会导致回收速度赶不上创建速度,最终导致内存耗尽。因此,Redis最终实现上是将大键的删除操作异步化,采用非阻塞删除(对应命令UNLINK),大键的空间回收交由单独线程实现,主线程只做关系解除,可以快速返回,继续处理其他事件,避免服务器长时间阻塞。

以删除(DEL命令)为例,看看Redis是如何实现的,下面就是删除函数的入口,其中,lazyfree_lazy_user_del是是否修改DEL命令的默认行为,一旦开启,执行DEL时将会以UNLINK形式执行。

void delCommand(client *c) {delGenericCommand(c,server.lazyfree_lazy_user_del);
}/* This command implements DEL and LAZYDEL. */
void delGenericCommand(client *c, int lazy) {int numdel = 0, j;for (j = 1; j < c->argc; j++) {expireIfNeeded(c->db,c->argv[j]);// 根据配置确定DEL在执行时是否以lazy形式执行int deleted  = lazy ? dbAsyncDelete(c->db,c->argv[j]) :dbSyncDelete(c->db,c->argv[j]);if (deleted) {signalModifiedKey(c,c->db,c->argv[j]);notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[j],c->db->id);server.dirty++;numdel++;}}addReplyLongLong(c,numdel);
}

同步删除很简单,只要把key和value删除,如果有内层引用,则进行递归删除,这里不做介绍。下面看下异步删除,Redis在回收对象时,会先计算回收收益,只有回收收益在超过一定值时,采用封装成Job加入到异步处理队列中,否则直接同步回收,这样效率更高。回收收益计算也很简单,比如String类型,回收收益值就是1,而Set类型,回收收益就是集合中元素个数。

/* Delete a key, value, and associated expiration entry if any, from the DB.* If there are enough allocations to free the value object may be put into* a lazy free list instead of being freed synchronously. The lazy free list* will be reclaimed in a different bio.c thread. */
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {/* Deleting an entry from the expires dict will not free the sds of* the key, because it is shared with the main dictionary. */if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);/* If the value is composed of a few allocations, to free in a lazy way* is actually just slower... So under a certain limit we just free* the object synchronously. */dictEntry *de = dictUnlink(db->dict,key->ptr);if (de) {robj *val = dictGetVal(de);// 计算value的回收收益size_t free_effort = lazyfreeGetFreeEffort(val);/* If releasing the object is too much work, do it in the background* by adding the object to the lazy free list.* Note that if the object is shared, to reclaim it now it is not* possible. This rarely happens, however sometimes the implementation* of parts of the Redis core may call incrRefCount() to protect* objects, and then call dbDelete(). In this case we'll fall* through and reach the dictFreeUnlinkedEntry() call, that will be* equivalent to just calling decrRefCount(). */// 只有回收收益超过一定值,才会执行异步删除,否则还是会退化到同步删除if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {atomicIncr(lazyfree_objects,1);bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);dictSetVal(db->dict,de,NULL);}}/* Release the key-val pair, or just the key if we set the val* field to NULL in order to lazy free it later. */if (de) {dictFreeUnlinkedEntry(db->dict,de);if (server.cluster_enabled) slotToKeyDel(key->ptr);return 1;} else {return 0;}
}

通过引入a threaded lazy free,Redis实现了对于Slow OperationLazy操作,避免了在大键删除,FLUSHALLFLUSHDB时导致服务器阻塞。当然,在实现该功能时,不仅引入了lazy free线程,也对Redis聚合类型在存储结构上进行改进。因为Redis内部使用了很多共享对象,比如客户端输出缓存。当然,Redis并未使用加锁来避免线程冲突,锁竞争会导致性能下降,而是去掉了共享对象,直接采用数据拷贝,如下,在3.x和6.x中ZSet节点value的不同实现。

// 3.2.5版本ZSet节点实现,value定义robj *obj
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {robj *obj;double score;struct zskiplistNode *backward;struct zskiplistLevel {struct zskiplistNode *forward;unsigned int span;} level[];
} zskiplistNode;// 6.0.10版本ZSet节点实现,value定义为sds ele
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {sds ele;double score;struct zskiplistNode *backward;struct zskiplistLevel {struct zskiplistNode *forward;unsigned long span;} level[];
} zskiplistNode;

去掉共享对象,不但实现了lazy free功能,也为Redis向多线程跨进带来了可能,正如作者所述:

Now that values of aggregated data types are fully unshared, and client output buffers don’t contain shared objects as well, there is a lot to exploit. For example it is finally possible to implement threaded I/O in Redis, so that different clients are served by different threads. This means that we’ll have a global lock only when accessing the database, but the clients read/write syscalls and even the parsing of the command the client is sending, can happen in different threads.

多线程I/O及其局限性

Redis在4.0版本引入了Lazy Free,自此Redis有了一个Lazy Free线程专门用于大键的回收,同时,也去掉了聚合类型的共享对象,这为多线程带来可能,Redis也不负众望,在6.0版本实现了多线程I/O

实现原理

正如官方以前的回复,Redis的性能瓶颈并不在CPU上,而是在内存和网络上。因此6.0发布的多线程并未将事件处理改成多线程,而是在I/O上,此外,如果把事件处理改成多线程,不但会导致锁竞争,而且会有频繁的上下文切换,即使用分段锁来减少竞争,对Redis内核也会有较大改动,性能也不一定有明显提升。

别再抱怨缺乏算法实践场景,不妨来挑战这场百万奖池的比赛!

如上图红色部分,就是Redis实现的多线程部分,利用多核来分担I/O读写负荷。在事件处理线程每次获取到可读事件时,会将所有就绪的读事件分配给I/O线程,并进行等待,在所有I/O线程完成读操作后,事件处理线程开始执行任务处理,在处理结束后,同样将写事件分配给I/O线程,等待所有I/O线程完成写操作。

以读事件处理为例,看下事件处理线程任务分配流程:

int handleClientsWithPendingReadsUsingThreads(void) {.../* Distribute the clients across N different lists. */listIter li;listNode *ln;listRewind(server.clients_pending_read,&li);int item_id = 0;// 将等待处理的客户端分配给I/O线程while((ln = listNext(&li))) {client *c = listNodeValue(ln);int target_id = item_id % server.io_threads_num;listAddNodeTail(io_threads_list[target_id],c);item_id++;}.../* Wait for all the other threads to end their work. */// 轮训等待所有I/O线程处理完while(1) {unsigned long pending = 0;for (int j = 1; j < server.io_threads_num; j++)pending += io_threads_pending[j];if (pending == 0) break;}...return processed;
}

I/O线程处理流程:

void *IOThreadMain(void *myid) {...while(1) {...// I/O线程执行读写操作while((ln = listNext(&li))) {client *c = listNodeValue(ln);// io_threads_op判断是读还是写事件if (io_threads_op == IO_THREADS_OP_WRITE) {writeToClient(c,0);} else if (io_threads_op == IO_THREADS_OP_READ) {readQueryFromClient(c->conn);} else {serverPanic("io_threads_op value is unknown");}}listEmpty(io_threads_list[id]);io_threads_pending[id] = 0;if (tio_debug) printf("[%ld] Done\n", id);}
}

局限性

从上面实现上看,6.0版本的多线程并非彻底的多线程,I/O线程只能同时执行读或者同时执行写操作,期间事件处理线程一直处于等待状态,并非流水线模型,有很多轮训等待开销。

Tair多线程实现原理

相较于6.0版本的多线程,Tair的多线程实现更加优雅。如下图,Tair的Main Thread负责客户端连接建立等,IO Thread负责请求读取、响应发送、命令解析等,Worker Thread线程专门用于事件处理。IO Thread读取用户的请求并进行解析,之后将解析结果以命令的形式放在队列中发送给Worker Thread处理。Worker Thread将命令处理完成后生成响应,通过另一条队列发送给IO Thread。为了提高线程的并行度,IO ThreadWorker Thread之间采用无锁队列管道进行数据交换,整体性能会更好。

别再抱怨缺乏算法实践场景,不妨来挑战这场百万奖池的比赛!

小结

Redis 4.0引入Lazy Free线程,解决了诸如大键删除导致服务器阻塞问题,在6.0版本引入了I/O Thread线程,正式实现了多线程,但相较于Tair,并不太优雅,而且性能提升上并不多,压测看,多线程版本性能是单线程版本的2倍,Tair多线程版本则是单线程版本的3倍。在作者看来,Redis多线程无非两种思路,I/O threadingSlow commands threading,正如作者在其博客中所说:

I/O threading is not going to happen in Redis AFAIK, because after much consideration I think it’s a lot of complexity without a good reason. Many Redis setups are network or memory bound actually. Additionally I really believe in a share-nothing setup, so the way I want to scale Redis is by improving the support for multiple Redis instances to be executed in the same host, especially via Redis Cluster.

What instead I really want a lot is slow operations threading, and with the Redis modules system we already are in the right direction. However in the future (not sure if in Redis 6 or 7) we’ll get key-level locking in the module system so that threads can completely acquire control of a key to process slow operations. Now modules can implement commands and can create a reply for the client in a completely separated way, but still to access the shared data set a global lock is needed: this will go away.

Redis作者更倾向于采用集群方式来解决I/O threading,尤其是在6.0版本发布的原生Redis Cluster Proxy背景下,使得集群更加易用。此外,作者更倾向于slow operations threading(比如4.0版本发布的Lazy Free)来解决多线程问题。后续版本,是否会将IO Thread实现的更加完善,采用Module实现对慢操作的优化,着实值得期待。

往期推荐

别再抱怨缺乏算法实践场景,不妨来挑战这场百万奖池的比赛!

坑你没商量!盘点Java中最常见的事故现场,你都中过哪些招?

你真的了解 OpenJDK 吗?

坚决不给中国人发Offer的GitLab成立中国公司!

紧随Java 16,Spring Framework 5.3.5 发布:涵盖JDK 16的支持!

推荐一个专注后端面试的公众号

点击关注,每天学习,一起进大厂!

喜欢的这里报道

↘↘↘

面试:Redis新版本开始引入多线程,谈谈你的看法?相关推荐

  1. 面试官:Redis新版本开始引入多线程,谈谈你的看法?

    来源:https://juejin.cn/post/6928407842009546766 Redis作为一个基于内存的缓存系统,一直以高性能著称,因没有上下文切换以及无锁操作,即使在单线程处理情况下 ...

  2. 面试官:Redis 新版本开始引入多线程,你怎么看?

    Redis 作为一个基于内存的缓存系统,一直以高性能著称,因没有上下文切换以及无锁操作,即使在单线程处理情况下,读速度仍可达到11万次/s,写速度达到8.1万次/s.但是,单线程的设计也给Redis带 ...

  3. Redis6.0新版本开始引入多线程,到底改善了什么

    Redis的性能瓶颈并不在CPU上,而是在内存和网络上.因此6.0发布的多线程并未将事件处理改成多线程,而是在I/O上,此外,如果把事件处理改成多线程,不但会导致锁竞争,而且会有频繁的上下文切换,即使 ...

  4. 面试面经|Java面试Redis面试题

    序言 凡事预则立,不预则废.能读到这里的人,我相信都是这个世界上的"有心人",还是那句老话:上天不负有心人!我相信你的每一步努力,都会收获意想不到的回报. 1.Redis 为何这么 ...

  5. Redis为什么又引入了多线程?单线程不香了?

    ​ 相信你一定不止一次见过Redis是单线程模式,不过说实话那只是个老版本,这个问题是一位老哥的大厂面试题,跟我分享了一下.想着自己就知道redis6.0以前一直都是单线程,到了6的版本才加入了多线程 ...

  6. Redis 6.0 为什么要引入多线程呢?

    查看 Redis 版本 redis-cli -v redis-cli 3.2.1 Reactor 模式 Redis 是基于 Reactor 模式开发了网络事件处理器,这个处理器称为文件事件处理器.组成 ...

  7. 程序员过关斩将--请不要误会redis 6.0 的多线程

    " 你对redis的单线程是不是有点误会? " 你对redis 6.0的多线程是不是也有点误会? " redis多线程一定可以提高性能吗? redis官方刚刚发布的6.0 ...

  8. Redis 属于单线程还是多线程?不同的版本有什么区别?

    Redis 是普及率最高的技术之一,同时也是面试中必问的一个技术模块,所以从今天开始我们将从最热门的 Redis 面试题入手,更加深入的学习和了解一下 Redis. 我们本文的面试题是 Redis 属 ...

  9. Redis新版本发布,你还认为Redis是单线程?

    Redis从单线程到多线程的转变 Redis简介 Redis单线程时代 `"单线程"`的Redis为什么会这么快? Redis的瓶颈 6.0版本后的Redis线程问题 redis的 ...

最新文章

  1. pytorch eval
  2. 一个初级程序员学习新技术的策略
  3. Android listview与adapter用法
  4. 【JZOJ3636】【BOI2012】Mobile(mobile)
  5. 直播预告 | 小米人工智能部崔世起:小爱同学全双工技术实践
  6. java中断线程_Java中断线程的方法
  7. 技术解析:一文看懂 Anolis OS 国密生态 | 龙蜥专场
  8. mb_internal_encoding php,PHP字符串mbstring处理中文字符串的具体方法解析
  9. C#开发笔记之04-如何用C#优雅的计算个人所得税?
  10. Unicode – CSS中文字体转编码
  11. idea怎么找到路径下面的js_怎么找到Win7桌面存储路径?怎么把Win7桌面转到D盘?...
  12. 【问题解决方案】git clone失败的分析和解决
  13. 从tensorflow的summary中提取数据,并进行平滑操作与显示
  14. 访问不了firefox附加组件页面怎么办
  15. luogu4360 锯木厂选址 (斜率优化dp)
  16. 格兰杰检验的基本步骤_Toda-Yamamoto 格兰杰因果检验 TY-Granger方法
  17. Matlab下的整数规划(CVX)
  18. 因果推断理论框架 Potenial Outcomes Framework
  19. linux驱动之输入子系统
  20. rhel6.5搭建mantis时报错

热门文章

  1. MySQL INNODB Plugin 测试(二)
  2. ASP.NET4.0中客户端ID的生成
  3. redis 主从复制功能 原理
  4. linux centos7 docker 安装 oracle
  5. golang 协程 通道channel阻塞
  6. 对国内云计算三个现象的思考
  7. Makefile文件和shell脚本
  8. 你的计算机无法启动一键还原,教你电脑怎么一键还原
  9. NeHe教程Qt实现——lesson10
  10. 剑指 Offer 59 - I. 滑动窗口的最大值