|
|
51CTO旗下网站
|
|
移动端

解码Redis最易被忽视的CPU和内存占用高问题

我们在使用Redis时,总会碰到一些redis-server端CPU及内存占用比较高的问题。下面以几个实际案例为例,来讨论一下在使用Redis时容易忽视的几种情形。

作者:张鹏义来源:DBAplus社群|2019-09-24 09:00

【大咖·来了 第7期】10月24日晚8点观看《智能导购对话机器人实践》

我们在使用Redis时,总会碰到一些redis-server端CPU及内存占用比较高的问题。下面以几个实际案例为例,来讨论一下在使用Redis时容易忽视的几种情形。

一、短连接导致CPU高

某用户反映QPS不高,从监控看CPU确实偏高。既然QPS不高,那么redis-server自身很可能在做某些清理工作或者用户在执行复杂度较高的命令,经排查无没有进行key过期删除操作,没有执行复杂度高的命令。

上机器对redis-server进行perf分析,发现函数listSearchKey占用CPU比较高,分析调用栈发现在释放连接时会频繁调用listSearchKey,且用户反馈说是使用的短连接,所以推断是频繁释放连接导致CPU占用有所升高。

1、对比实验

下面使用redis-benchmark工具分别使用长连接和短连接做一个对比实验,redis-server为社区版4.0.10。

1)长连接测试

使用10000个长连接向redis-server发送50w次ping命令:

  1. ./redis-benchmark -h host -p port -t ping -c 10000 -n 500000 -k 1(k=1表示使用长连接,k=0表示使用短连接) 

最终QPS:

  1. PING_INLINE: 92902.27 requests per second 
  2. PING_BULK: 93580.38 requests per second 

对redis-server分析,发现占用CPU最高的是readQueryFromClient,即主要是在处理来自用户端的请求。

2)短连接测试

使用10000个短连接向redis-server发送50w次ping命令:

  1. ./redis-benchmark -h host -p port -t ping -c 10000 -n 500000 -k 0 

最终QPS:

  1. PING_INLINE: 15187.18 requests per second 
  2. PING_BULK: 16471.75 requests per second 

对redis-server分析,发现占用CPU最高的确实是listSearchKey,而readQueryFromClient所占CPU的比例比listSearchKey要低得多,也就是说CPU有点“不务正业”了,处理用户请求变成了副业,而搜索list却成为了主业。所以在同样的业务请求量下,使用短连接会增加CPU的负担。

从QPS上看,短连接与长连接差距比较大,原因来自两方面:

  • 每次重新建连接引入的网络开销。
  • 释放连接时,redis-server需消耗额外的CPU周期做清理工作。(这一点可以尝试从redis-server端做优化)

2、Redis连接释放

我们从代码层面来看下redis-server在用户端发起连接释放后都会做哪些事情,redis-server在收到用户端的断连请求时会直接进入到freeClient。

  1. void freeClient(client *c) { 
  2.     listNode *ln; 
  3.  
  4.     /* .........*/ 
  5.  
  6.     /* Free the query buffer */ 
  7.     sdsfree(c->querybuf); 
  8.     sdsfree(c->pending_querybuf); 
  9.     c->querybuf = NULL
  10.  
  11.     /* Deallocate structures used to block on blocking ops. */ 
  12.     if (c->flags & CLIENT_BLOCKED) unblockClient(c); 
  13.     dictRelease(c->bpop.keys); 
  14.  
  15.     /* UNWATCH all the keys */ 
  16.     unwatchAllKeys(c); 
  17.     listRelease(c->watched_keys); 
  18.  
  19.     /* Unsubscribe from all the pubsub channels */ 
  20.     pubsubUnsubscribeAllChannels(c,0); 
  21.     pubsubUnsubscribeAllPatterns(c,0); 
  22.     dictRelease(c->pubsub_channels); 
  23.     listRelease(c->pubsub_patterns); 
  24.  
  25.     /* Free data structures. */ 
  26.     listRelease(c->reply); 
  27.     freeClientArgv(c); 
  28.  
  29.     /* Unlink the client: this will close the socket, remove the I/O 
  30.      * handlers, and remove references of the client from different 
  31.      * places where active clients may be referenced. */ 
  32.     /*  redis-server维护了一个server.clients链表,当用户端建立连接后,新建一个client对象并追加到server.clients上, 
  33.         当连接释放时,需求从server.clients上删除client对象 */ 
  34.     unlinkClient(c); 
  35.  
  36.    /* ...........*/ 
  37. void unlinkClient(client *c) { 
  38.     listNode *ln; 
  39.  
  40.     /* If this is marked as current client unset it. */ 
  41.     if (server.current_client == c) server.current_client = NULL
  42.  
  43.     /* Certain operations must be done only if the client has an active socket. 
  44.      * If the client was already unlinked or if it's a "fake client" the 
  45.      * fd is already set to -1. */ 
  46.     if (c->fd != -1) { 
  47.         /* 搜索server.clients链表,然后删除client节点对象,这里复杂为O(N) */ 
  48.         ln = listSearchKey(server.clients,c); 
  49.         serverAssert(ln != NULL); 
  50.         listDelNode(server.clients,ln); 
  51.  
  52.         /* Unregister async I/O handlers and close the socket. */ 
  53.         aeDeleteFileEvent(server.el,c->fd,AE_READABLE); 
  54.         aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE); 
  55.         close(c->fd); 
  56.         c->fd = -1; 
  57.     } 
  58.  
  59.    /*   ......... */ 

所以在每次连接断开时,都存在一个O(N)的运算。对于redis这样的内存数据库,我们应该尽量避开O(N)运算,特别是在连接数比较大的场景下,对性能影响比较明显。虽然用户只要不使用短连接就能避免,但在实际的场景中,用户端连接池被打满后,用户也可能会建立一些短连接。

3、优化

从上面的分析看,每次连接释放时都会进行O(N)的运算,那能不能降复杂度降到O(1)呢?

这个问题非常简单,server.clients是个双向链表,只要当client对象在创建时记住自己的内存地址,释放时就不需要遍历server.clients。接下来尝试优化下:

  1. client *createClient(int fd) { 
  2.     client *c = zmalloc(sizeof(client)); 
  3.    /*  ........  */ 
  4.     listSetFreeMethod(c->pubsub_patterns,decrRefCountVoid); 
  5.     listSetMatchMethod(c->pubsub_patterns,listMatchObjects); 
  6.     if (fd != -1) { 
  7.         /*  client记录自身所在list的listNode地址 */ 
  8.         c->client_list_node = listAddNodeTailEx(server.clients,c); 
  9.     }  
  10.     initClientMultiState(c); 
  11.     return c; 
  12. void unlinkClient(client *c) { 
  13.     listNode *ln; 
  14.  
  15.     /* If this is marked as current client unset it. */ 
  16.     if (server.current_client == c) server.current_client = NULL
  17.  
  18.     /* Certain operations must be done only if the client has an active socket. 
  19.      * If the client was already unlinked or if it's a "fake client" the 
  20.      * fd is already set to -1. */ 
  21.     if (c->fd != -1) { 
  22.         /* 这时不再需求搜索server.clients链表 */ 
  23.         //ln = listSearchKey(server.clients,c); 
  24.         //serverAssert(ln != NULL); 
  25.         //listDelNode(server.clients,ln); 
  26.         listDelNode(server.clients, c->client_list_node); 
  27.  
  28.         /* Unregister async I/O handlers and close the socket. */ 
  29.         aeDeleteFileEvent(server.el,c->fd,AE_READABLE); 
  30.         aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE); 
  31.         close(c->fd); 
  32.         c->fd = -1; 
  33.     } 
  34.  
  35.    /*   ......... */ 

优化后短连接测试

使用10000个短连接向redis-server发送50w次ping命令:

  1. ./redis-benchmark -h host -p port -t ping -c 10000 -n 500000 -k 0 

最终QPS:

  1. PING_INLINE: 21884.23 requests per second 
  2. PING_BULK: 21454.62 requests per second 

与优化前相比,短连接性能能够提升30+%,所以能够保证存在短连接的情况下,性能不至于太差。

二、info命令导致CPU高

有用户通过定期执行info命令监视redis的状态,这会在一定程度上导致CPU占用偏高。频繁执行info时通过perf分析发现getClientsMaxBuffers、getClientOutputBufferMemoryUsage及getMemoryOverheadData这几个函数占用CPU比较高。

通过Info命令,可以拉取到redis-server端的如下一些状态信息(未列全):

  1. client 
  2. connected_clients:1 
  3. client_longest_output_list:0 // redis-server端最长的outputbuffer列表长度 
  4. client_biggest_input_buf:0. // redis-server端最长的inputbuffer字节长度 
  5. blocked_clients:0 
  6. Memory 
  7. used_memory:848392 
  8. used_memory_human:828.51K 
  9. used_memory_rss:3620864 
  10. used_memory_rss_human:3.45M 
  11. used_memory_peak:619108296 
  12. used_memory_peak_human:590.43M 
  13. used_memory_peak_perc:0.14% 
  14. used_memory_overhead:836182 // 除dataset外,redis-server为维护自身结构所额外占用的内存量 
  15. used_memory_startup:786552 
  16. used_memory_dataset:12210 
  17. used_memory_dataset_perc:19.74% 
  18. 为了得到client_longest_output_list、client_longest_output_list状态,需要遍历redis-server端所有的client, 如getClientsMaxBuffers所示,可能看到这里也是存在同样的O(N)运算。 
  19. void getClientsMaxBuffers(unsigned long *longest_output_list, 
  20.                           unsigned long *biggest_input_buffer) { 
  21.     client *c; 
  22.     listNode *ln; 
  23.     listIter li; 
  24.     unsigned long lol = 0, bib = 0; 
  25.     /* 遍历所有client, 复杂度O(N) */ 
  26.     listRewind(server.clients,&li); 
  27.     while ((ln = listNext(&li)) != NULL) { 
  28.         c = listNodeValue(ln); 
  29.  
  30.         if (listLength(c->reply) > lol) lol = listLength(c->reply); 
  31.         if (sdslen(c->querybuf) > bib) bib = sdslen(c->querybuf); 
  32.     } 
  33.     *longest_output_list = lol; 
  34.     *biggest_input_buffer = bib; 
  35. 为了得到used_memory_overhead状态,同样也需要遍历所有client计算所有client的outputBuffer所占用的内存总量,如getMemoryOverheadData所示: 
  36. struct redisMemOverhead *getMemoryOverheadData(void) { 
  37.  
  38.     /* ......... */ 
  39.     mem = 0; 
  40.     if (server.repl_backlog) 
  41.         mem += zmalloc_size(server.repl_backlog); 
  42.     mh->repl_backlog = mem; 
  43.     mem_total += mem; 
  44.    /* ...............*/ 
  45.     mem = 0; 
  46.     if (listLength(server.clients)) { 
  47.         listIter li; 
  48.         listNode *ln; 
  49.         /*  遍历所有的client, 计算所有client outputBuffer占用的内存总和,复杂度为O(N)  */ 
  50.         listRewind(server.clients,&li); 
  51.         while((ln = listNext(&li))) { 
  52.             client *c = listNodeValue(ln); 
  53.             if (c->flags & CLIENT_SLAVE) 
  54.                 continue
  55.             mem += getClientOutputBufferMemoryUsage(c); 
  56.             mem += sdsAllocSize(c->querybuf); 
  57.             mem += sizeof(client); 
  58.         } 
  59.     } 
  60.     mh->clients_normal = mem; 
  61.     mem_total+=mem; 
  62.  
  63.     mem = 0; 
  64.     if (server.aof_state != AOF_OFF) { 
  65.         mem += sdslen(server.aof_buf); 
  66.         mem += aofRewriteBufferSize(); 
  67.     } 
  68.     mh->aof_buffer = mem; 
  69.     mem_total+=mem; 
  70.  
  71.   /* ......... */ 
  72.  
  73.     return mh; 

实验

从上面的分析知道,当连接数较高时(O(N)的N大),如果频率执行info命令,会占用较多CPU。

1)建立一个连接,不断执行info命令

  1. func main() {                                                                                                                                              
  2.      c, err := redis.Dial("tcp", addr)                                                                                                              
  3.      if err != nil {                                                                                                         
  4.         fmt.Println("Connect to redis error:", err)                                                           
  5.         return                                                                                                                
  6.      }                                                                                                                                                                                                                                                    
  7.      for {                                                                                                                      
  8.         c.Do("info")                                                                                                      
  9.      }                                                                                                                                                                                                                                               
  10.      return                                                                                                                   

实验结果表明,CPU占用仅为20%左右。

2)建立9999个空闲连接,及一个连接不断执行info

  1. func main() {                                                                   
  2.      clients := []redis.Conn{}                                      
  3.      for i := 0; i < 9999; i++ {                                     
  4.         c, err := redis.Dial("tcp", addr)                       
  5.         if err != nil {                                                       
  6.            fmt.Println("Connect to redis error:", err)  
  7.            return                                                              
  8.         }                                                                          
  9.         clients = append(clients, c)                            
  10.      }                                                                             
  11.      c, err := redis.Dial("tcp", addr)                          
  12.      if err != nil {                                                          
  13.         fmt.Println("Connect to redis error:", err)     
  14.         return                                                                 
  15.      }                                                                                                                                                           
  16.      for {                                                                         
  17.         _, err = c.Do("info")                                                               
  18.         if err != nil {                                                        
  19.            panic(err)                                                                      
  20.         }                                                                           
  21.      }                                                                                
  22.      return                                                                              

实验结果表明CPU能够达到80%,所以在连接数较高时,尽量避免使用info命令。

3)pipeline导致内存占用高

有用户发现在使用pipeline做只读操作时,redis-server的内存容量偶尔也会出现明显的上涨, 这是对pipeline的使不当造成的。下面先以一个简单的例子来说明Redis的pipeline逻辑是怎样的。

下面通过golang语言实现以pipeline的方式从redis-server端读取key1、key2、key3。

  1. import ( 
  2.     "fmt" 
  3.     "github.com/garyburd/redigo/redis" 
  4.  
  5. func main(){ 
  6.     c, err := redis.Dial("tcp""127.0.0.1:6379"
  7.     if err != nil { 
  8.         panic(err) 
  9.     } 
  10.     c.Send("get""key1")       //缓存到client端的buffer中 
  11.     c.Send("get""key2")       //缓存到client端的buffer中 
  12.     c.Send("get""key3")       //缓存到client端的buffer中 
  13.     c.Flush()                   //将buffer中的内容以一特定的协议格式发送到redis-server端 
  14.     fmt.Println(redis.String(c.Receive())) 
  15.     fmt.Println(redis.String(c.Receive())) 
  16.     fmt.Println(redis.String(c.Receive())) 

而此时server端收到的内容为:

  1. *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发送到client,所以并不是处理完一条命令就将结果返回用户端。

  1. readQueryFromClient(client* c) { 
  2.     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" 
  3.     cmdsNum = parseCmdNum(c->querybuf)  // cmdNum = 3 
  4.     while(cmsNum--) { 
  5.         cmd = parseCmd(c->querybuf)    // cmd:  get key1、get key2、get key3 
  6.         reply = execCmd(cmd) 
  7.         appendReplyBuffer(reply) 
  8.         markClientPendingWrite(c) 
  9.     } 

考虑这样一种情况:

如果用户端程序处理比较慢,未能及时通过c.Receive()从TCP的接收buffer中读取内容或者因为某些BUG导致没有执行c.Receive(),当接收buffer满了后,server端的TCP滑动窗口为0,导致server端无法发送replyBuffer中的内容,所以replyBuffer由于迟迟得不到释放而占用额外的内存。当pipeline一次打包的命令数太多,以及包含如mget、hgetall、lrange等操作多个对象的命令时,问题会更突出。

小结

上面几种情况,都是非常简单的问题,没有复杂的逻辑,在大部分场景下都不算问题,但是在一些极端场景下要把Redis用好,开发者还是需要关注这些细节。建议:

  • 尽量不要使用短连接;
  • 尽量不要在连接数比较高的场景下频繁使用info;
  • 使用pipeline时,要及时接收请求处理结果,且pipeline不宜一次打包太多请求。

作者介绍

张鹏义,腾讯云数据库高级工程师,曾参与华为Taurus分布式数据研发及腾讯CynosDB for pg研发工作,现从事腾讯云Redis数据库研发工作。

我们在使用Redis时,总会碰到一些redis-server端CPU及内存占用比较高的问题。下面以几个实际案例为例,来讨论一下在使用Redis时容易忽视的几种情形。

【编辑推荐】

  1. 中国自主研发内存即将问世 内存垄断格局要变天
  2. Redis中主从、哨兵、分片集群入门篇
  3. 如何用慢查询找到 Redis 的性能瓶颈?
  4. Redis集群模式搭建与原理详解
  5. 如何避免内存溢出?—— Redis内存使用和管理知识总结
【责任编辑:武晓燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

订阅专栏+更多

用Python玩转excel

用Python玩转excel

让重复操作傻瓜化
共3章 | DE8UG

187人订阅学习

AI入门级算法

AI入门级算法

算法常识
共22章 | 周萝卜123

164人订阅学习

这就是5G

这就是5G

5G那些事儿
共15章 | armmay

132人订阅学习

读 书 +更多

鸟哥的Linux私房菜——服务器架设篇(第二版)

本书是对连续三年蝉联畅销书排行榜前10名的《Linux鸟哥私房菜——服务器架设篇》的升级版,新版本根据目前服务器与网络环境做了大幅度修订...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊

51CTO服务号

51CTO官微