我们在使用Redis的时候,总会遇到一些redis-server端CPU和内存占用高的问题。下面以几个实际案例为例,讨论一下使用Redis时容易忽略的几种情况。1.短连接导致CPU高。某用户反映QPS不高。从监控来看,CPU确实偏高。由于QPS不高,很可能是redis-server本身在做一些清理工作或者用户在执行复杂的命令。经查,没有进行key过期删除操作,也没有执行过复杂的命令。对机器上的redis-server进行perf分析,发现函数listSearchKey占用CPU比较高,分析调用栈发现listSearchKey在释放连接时被频繁调用,用户反馈说使用的是短连接,所以推断CPU被频繁释放连接增加占用。1.对比实验下面使用redis-benchmark工具分别使用长连接和短连接做对比实验。redis-server是社区版本4.0.10。1)长连接测试使用10000个长连接发送500000个ping到redis-server命令:./redis-benchmark-hhost-pport-tping-c10000-n500000-k1(k=1表示使用长连接,k=0表示使用短连接)FinalQPS:PING_INLINE:92902.27requestspersecondPING_BULK:93580.38requestspersecondtoredis-server分析发现readQueryFromClient占用的CPU最高,也就是主要处理来自客户端的请求。2)短连接测试使用10000个短连接发送500000个ping命令到redis-server:./redis-benchmark-hhost-pport-tping-c10000-n500000-k0FinalQPS:PING_INLINE:15187.18requestspersecondPING_BULK:16471.75requestspersecondtoredis-根据服务器分析,发现listSearchKey占用CPU最多,readQueryFromClient占用CPU的比例远低于listSearchKey。也就是说,CPU有点“不务正业”了,处理用户请求成了副业,而搜索列表成了正职。所以在相同的业务请求量下,使用短连接会增加CPU的负担。从QPS来看,短连接和长连接有很大的差距。原因来自两个方面:每次连接重新建立引入的网络开销。当连接被释放时,redis-server需要消耗额外的CPU周期来清理。(这一点可以从redis-server端优化)2.Redis连接释放我们从代码层面看一下客户端发起连接释放后redis-server会做什么。redis-server收到客户端断开连接的请求时,会直接转到freeClient。voidfreeClient(client*c){listNode*ln;/*.........*//*Freethequerybuffer*/sdsfree(c->querybuf);sdsfree(c->pending_querybuf);c->querybuf=NULL;/*解除分配用于阻止阻塞操作的结构。*/if(c->flags&CLIENT_BLOCKED)unblockClient(c);dictRelease(c->bpop.keys);/*UNWATCHallthekeys*/unwatchAllKeys(c);listRelease(c)->watched_keys);/*取消订阅所有pubsubchannels*/pubsubUnsubscribeAllChannels(c,0);pubsubUnsubscribeAllPatterns(c,0);dictRelease(c->pubsub_channels);listRelease(c->pubsub_patterns);/*Freedatastructures.*/listRelease(c->reply);free(ClientArg);/*Unlinktheclient:thiswillclosethesocket,removetheI/O*handlers,andremovereferencesoftheclientfromdifferent*placeswhereactiveclientsmaybereferenced.*//*redis-server维护一个server.clients链表,当客户端建立一个connection,新建一个client对象,附加到server.clients上面,当释放连接时,需要从server.clients中删除client对象*/unlinkClient(c);/*...........*/}voidunlinkClient(client*c){listNode*ln;/*Ifthisismarkedascurrentclienttunsetit.*/if(server.current_client==c)server.current_client=NULL;/*某些操作必须仅在客户端为活动套接字时才执行。*如果客户端已准备好未链接或“假客户端”*fdisalreadysetto-1.*/if(c->fd!=-1){/*查找server.clients链表,然后删除client节点对象,这里的复杂度为O(N)*/ln=listSearchKey(server.clients,c);serverAssert(ln!=NULL);listDelNode(server.clients,ln);/*UnregisterasyncI/Ohandlers并关闭套接字。*/aeDeleteFileEvent(server.el,c->fd,AE_READABLE);aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE);close(c->fd);c->fd=-1;}/*.......*/所以每次断开连接,都有一个O(N)的操作。对于像redis这样的内存数据库,我们应该尽量避免O(N))的操作,尤其是在连接数比较大的场景下,对性能影响比较大。虽然用户只要不使用短连接就可以避免,但在实际场景中,用户也可能在客户端连接池满后建立一些短连接。3.优化从上面的分析来看,每释放一个连接都会进行O(N)次操作。复杂度可以降低到O(1)吗?这个问题很简单,server.clients是一个双向链表,只要client对象在创建的时候记住自己的内存地址,释放的时候不需要遍历server.clients。接下来试试优化下:client*createClient(intfd){client*c=zmalloc(sizeof(client));/*.......*/listSetFreeMethod(c->pubsub_patterns,decrRefCountVoid);listSetMatchMethod(c->pubsub_patterns,listMatchObjects);if(fd!=-1){/*client记录自己所在list的listNode地址*/c->client_list_node=listAddNodeTailEx(server.clients,c);}initClientMultiState(c);returnc;}voidunlinkClient(client*c){listNode*ln;/*Ifthisismarkedascurrentclientunsetit.*/if(server.current_client==c)server.current_client=NULL;/*Certainoperationsmustbedonlyiftheclienthasanactivesocket.*Iftheclientwasalreadyunlinkedorfit's"fakeclient"the*fdisalreadysetto-1.*/if(c->fd!=-1){/*此时不再需要搜索server.clients链接表*///ln=listSearchKey(server.clients,c);//serverAssert(ln!=NULL);//listDelNode(server.clients,ln);listDelNode(server.clients,c->client_list_node);/*UnregisterasyncI/Ohandlersandclosethesocket.*/aeDeleteFileEvent(server.el,c->fd,AE_READABLE);aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE);关闭(c->fd);c->fd=-1;}/*.......*/优化短连接测试使用10000个短连接向redis-server:发送50w次ping命令。/redis-benchmark-hhost-pport-tping-c10000-n500000-k0FinalQPS:PING_INLINE:21884.23requestspersecondPING_BULK:21454.62requestspersecond与优化前相比短连接性能提升30+%,因此可以保证有短连接,性能还算不错2.info命令导致CPU高有些用户通过周期性执行info命令来监控redis的状态,一定程度上会导致CPU占用率高。info频繁执行时,通过perf分析发现getClientsMaxBuffers、getClientOutputBufferMemoryUsage、getMemoryOverheadData函数占用CPU比较高。通过Info命令可以在redis-server端拉取如下状态信息(未列出):clientconnected_clients:1client_longest_output_list:0//redis-server端最长outputbuffer列表的长度client_biggest_input_buf:0.//longestoutputbufferlistontheredis-serverside长的inputbuffer字节长度blocked_clients:0Memoryused_memory:848392used_memory_human:828.51Kused_memory_rss:3620864used_memory_rss_human:3.45Mused_memory_peak:619108296used_memory_peak_human:590.43Mused_memory_peak_perc:0.14%used_memory_overhead:836182//除dataset外,redis-server为维护自身结构所占外部使用的内存量used_memory_startup:786552used_memory_dataset:12210used_memory_dataset_perc:19.74%为了得到client_longest_output_list和client_longest_output_list的状态,需要遍历所有的clients在redis-serverget端,你可能会看到Bufferget端的Maxx这里也有相同的O(N)操作。voidgetClientsMaxBuffers(unsignedlong*longest_output_list,unsignedlong*biggest_input_buffer){client*c;listNode*ln;listIterli;unsignedlonglol=0,bib=0;/*遍历所有客户端,复杂度O(N)*/listRewind(server.clients,&li);while((ln=listNext(&li))!=NULL){c=listNodeValue(ln);if(listLength(c->reply)>lol)lol=listLength(c->reply);if(sdslen(c->querybuf)>bib)bib=sdslen(c->querybuf);}*longest_output_list=lol;*biggest_input_buffer=bib;}为了得到used_memory_overhead状态,还需要遍历所有client计算内存占用由所有clients的outputBuffer总量,如getMemoryOverheadData所示:repl_backlog);mh->repl_backlog=mem;mem_total+=mem;/*.........*/mem=0;if(listLength(server.clients)){listIterli;listNode*ln;/*遍历所有client,计算所有clientoutputBuffers占用内存的总和,复杂度isO(N)*/listRewind(server.clients,&li);while((ln=listNext(&li))){client*c=listNodeValue(ln);if(c->flags&CLIENT_SLAVE)continue;mem+=getClientOutputBufferMemoryUsage(c);mem+=sdsAllocSize(c->querybuf);mem+=sizeof(client);}}mh->clients_normal=mem;mem_total+=mem;mem=0;if(server.aof_state!=AOF_OFF){mem+=sdslen(server.aof_buf);mem+=aofRewriteBufferSize();}mh->aof_buffer=mem;mem_total+=mem;/*.........*/returnmh;}实验来自上面的分析表明,当连接数很高时(NofO(N)很大),如果频繁执行info命令,会占用更多的CPU1)建立连接,不断执行info命令funcmain(){c,err:=redis.Dial("tcp",addr)iferr!=nil{fmt.Println("Connecttorediserror:",err)return}for{c.Do("info")}return}实验结果表明CPU使用率只有20%左右。2)建立9999个空闲连接,1个连接执行infofuncmain(){clients:=[]redis.Conn{}fori:=0;i<9999;i++{c,err:=redis.Dial("tcp",addr)iferr!=nil{fmt.Println("Connecttorediserror:",err)return}clients=append(clients,c)}c,err:=redis.Dial("tcp",addr)iferr!=nil{fmt.println("Connecttorediserror:",err)return}for{_,err=c.Do("info")iferr!=nil{panic(err)}}return}实验结果表明CPU可以达到80%,所以当连接数高的时候,尽量避免使用info命令。3)pipeline导致内存占用高有用户发现,使用pipeline进行只读操作时,redis-server的内存容量偶尔会明显增加,这是pipeline使用不当造成的。下面用一个简单的例子来说明Redis的管道逻辑。下面使用golang语言以流水线的方式从redis-server端读取key1、key2、key3。import("fmt""github.com/garyburd/redigo/redis")funcmain(){c,err:=redis.Dial("tcp","127.0.0.1:6379")iferr!=nil{panic(err)}c.Send("get","key1")//缓存到客户端缓冲区c.Send("get","key2")//缓存到客户端缓冲区c.Send("get","key3")//缓存到client端的buffer中c.Flush()//将buffer中的内容以特定的协议格式发送到redis-server端fmt.Println(redis.String(c.Receive()))fmt.Println(redis.String(c.Receive()))fmt.Println(redis.String(c.Receive()))}服务端收到的内容为:*2\r\n$3\r\nget\r\n$4\r\nkey1\r\n*2\r\n$3\r\nget\r\n$4\r\nkey2\r\n*2\r\n$3\r\nget\r\n$4\r\nkey3\r\n以下是redis-server端非正式的代码处理逻辑。redis-server端从接收到的内容中解析出命令,执行命令,并将执行结果缓存到replyBuffer中,标记客户端有东西要写出来。replyBuffer中的内容是在调度下一个事件时通过socket发送给客户端的,所以处理完一个命令后并不会返回结果给客户端。readQueryFromClient(client*c){read(c->querybuf)//c->query="*2\r\n$3\r\nget\r\n$4\r\nkey1\r\n*2\r\n$3\r\nget\r\n$4\r\nkey2\r\n*2\r\n$3\r\nget\r\n$4\r\nkey3\r\n"cmdsNum=parseCmdNum(c->querybuf)//cmdNum=3while(cmsNum--){cmd=parseCmd(c->querybuf)//cmd:getkey1,getkey2,getkey3reply=execCmd(cmd)appendReplyBuffer(reply)markClientPendingWrite(c)}}考虑这个一种情况:如果客户端程序处理速度慢,未能及时通过c.Receive()从TCP接收缓冲区中读取内容或者由于某些BUG导致c.Receive()没有执行,当接收缓冲区full时,server端的TCP滑动窗口为0,导致server端无法发送replyBuffer中的内容,replyBuffer由于延迟释放占用了额外的内存。当pipeline一次打包的命令过多,并且包含mget、hgetall、lrange等操作多个对象的命令时,问题会更加突出。综上所述,以上几种情况都是非常简单的问题,没有复杂的逻辑,在大多数场景下都不是问题。但是,为了在一些极端场景下用好Redis,开发者还是需要注意这些细节。建议:尽量不要使用短连接;连接数比较多的场景尽量不要频繁使用info;使用管道时,及时接收请求处理结果,管道不要一次打包太多请求。作者介绍张鹏毅,腾讯云数据库高级工程师,曾参与华为Taurus分布式数据和腾讯CynosDBforpg的研发,现从事腾讯云Redis数据库的研发工作。我们在使用Redis的时候,总会遇到一些redis-server端CPU和内存占用率高的问题。下面以几个实际案例为例,讨论一下使用Redis时容易忽略的几种情况。
