当前位置: 首页 > news >正文

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

文章转载自dbaplus社群

作者介绍

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

我们在使用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命令:

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

最终QPS:

PING_INLINE: 92902.27 requests per second

PING_BULK: 93580.38 requests per second

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

2)短连接测试

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

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

最终QPS:

PING_INLINE: 15187.18 requests per second

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。

void freeClient(client *c) {

    listNode *ln;

    /* .........*/

    /* Free the query buffer */

    sdsfree(c->querybuf);

    sdsfree(c->pending_querybuf);

    c->querybuf = NULL;

    /* Deallocate structures used to block on blocking ops. */

    if (c->flags & CLIENT_BLOCKED) unblockClient(c);

    dictRelease(c->bpop.keys);

    /* UNWATCH all the keys */

    unwatchAllKeys(c);

    listRelease(c->watched_keys);

    /* Unsubscribe from all the pubsub channels */

    pubsubUnsubscribeAllChannels(c,0);

    pubsubUnsubscribeAllPatterns(c,0);

    dictRelease(c->pubsub_channels);

    listRelease(c->pubsub_patterns);

    /* Free data structures. */

    listRelease(c->reply);

    freeClientArgv(c);

    /* Unlink the client: this will close the socket, remove the I/O

     * handlers, and remove references of the client from different

     * places where active clients may be referenced. */

    /*  redis-server维护了一个server.clients链表,当用户端建立连接后,新建一个client对象并追加到server.clients上,

        当连接释放时,需求从server.clients上删除client对象 */

    unlinkClient(c);

   /* ...........*/

}

void unlinkClient(client *c) {

    listNode *ln;

    /* If this is marked as current client unset it. */

    if (server.current_client == c) server.current_client = NULL;

    /* Certain operations must be done only if the client has an active socket.

     * If the client was already unlinked or if it's a "fake client" the

     * fd is already set to -1. */

    if (c->fd != -1) {

        /* 搜索server.clients链表,然后删除client节点对象,这里复杂为O(N) */

        ln = listSearchKey(server.clients,c);

        serverAssert(ln != NULL);

        listDelNode(server.clients,ln);

        /* Unregister async I/O handlers and close the socket. */

        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(int fd) {

    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);

    return c;

}

void unlinkClient(client *c) {

    listNode *ln;

    /* If this is marked as current client unset it. */

    if (server.current_client == c) server.current_client = NULL;

    /* Certain operations must be done only if the client has an active socket.

     * If the client was already unlinked or if it's a "fake client" the

     * fd is already set to -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);

        /* Unregister async I/O handlers and close the socket. */

        aeDeleteFileEvent(server.el,c->fd,AE_READABLE);

        aeDeleteFileEvent(server.el,c->fd,AE_WRITABLE);

        close(c->fd);

        c->fd = -1;

    }

   /*   ......... */

优化后短连接测试

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

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

最终QPS:

PING_INLINE: 21884.23 requests per second

PING_BULK: 21454.62 requests per second

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

二、info命令导致CPU高

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

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

client

connected_clients:1

client_longest_output_list:0 // redis-server端最长的outputbuffer列表长度

client_biggest_input_buf:0. // redis-server端最长的inputbuffer字节长度

blocked_clients:0

Memory

used_memory:848392

used_memory_human:828.51K

used_memory_rss:3620864

used_memory_rss_human:3.45M

used_memory_peak:619108296

used_memory_peak_human:590.43M

used_memory_peak_perc:0.14%

used_memory_overhead:836182 // 除dataset外,redis-server为维护自身结构所额外占用的内存量

used_memory_startup:786552

used_memory_dataset:12210

used_memory_dataset_perc:19.74%

为了得到client_longest_output_list、client_longest_output_list状态,需要遍历redis-server端所有的client, 如getClientsMaxBuffers所示,可能看到这里也是存在同样的O(N)运算。

void getClientsMaxBuffers(unsigned long *longest_output_list,

                          unsigned long *biggest_input_buffer) {

    client *c;

    listNode *ln;

    listIter li;

    unsigned long lol = 0, bib = 0;

    /* 遍历所有client, 复杂度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计算所有client的outputBuffer所占用的内存总量,如getMemoryOverheadData所示:

struct redisMemOverhead *getMemoryOverheadData(void) {

    /* ......... */

    mem = 0;

    if (server.repl_backlog)

        mem += zmalloc_size(server.repl_backlog);

    mh->repl_backlog = mem;

    mem_total += mem;

   /* ...............*/

    mem = 0;

    if (listLength(server.clients)) {

        listIter li;

        listNode *ln;

        /*  遍历所有的client, 计算所有client outputBuffer占用的内存总和,复杂度为O(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;

  /* ......... */

    return mh;

}

实验

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

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

func main() {                                                                                                                                             

     c, err := redis.Dial("tcp", addr)                                                                                                             

     if err != nil {                                                                                                        

        fmt.Println("Connect to redis error:", err)                                                          

        return                                                                                                               

     }                                                                                                                                                                                                                                                   

     for {                                                                                                                     

        c.Do("info")                                                                                                     

     }                                                                                                                                                                                                                                              

     return                                                                                                                  

}

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

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

func main() {                                                                  

     clients := []redis.Conn{}                                     

     for i := 0; i < 9999; i++ {                                    

        c, err := redis.Dial("tcp", addr)                      

        if err != nil {                                                      

           fmt.Println("Connect to redis error:", err) 

           return                                                             

        }                                                                         

        clients = append(clients, c)                           

     }                                                                            

     c, err := redis.Dial("tcp", addr)                         

     if err != nil {                                                         

        fmt.Println("Connect to redis error:", err)    

        return                                                                

     }                                                                                                                                                          

     for {                                                                        

        _, err = c.Do("info")                                                              

        if err != nil {                                                       

           panic(err)                                                                     

        }                                                                          

     }                                                                               

     return                                                                             

}

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

3)pipeline导致内存占用高

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

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

import (

    "fmt"

    "github.com/garyburd/redigo/redis"

)

func main(){

    c, err := redis.Dial("tcp", "127.0.0.1:6379")

    if err != nil {

        panic(err)

    }

    c.Send("get", "key1")       //缓存到client端的buffer中

    c.Send("get", "key2")       //缓存到client端的buffer中

    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()))

}

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

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

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 = 3

    while(cmsNum--) {

        cmd = parseCmd(c->querybuf)    // cmd:  get key1、get key2、get key3

        reply = execCmd(cmd)

        appendReplyBuffer(reply)

        markClientPendingWrite(c)

    }

}

考虑这样一种情况:

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

小结

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

  • 尽量不要使用短连接;

  • 尽量不要在连接数比较高的场景下频繁使用info;

  • 使用pipeline时,要及时接收请求处理结果,且pipeline不宜一次打包太多请求。

   

QQ群号:763628645

QQ群二维码如下, 添加请注明:姓名+地区+职位,否则不予通过

订阅我的微信公众号“杨建荣的学习笔记”,第一时间免费收到文章更新。别忘了加星标,以免错过新推送提示。

   

近期热文

你可能也会对以下话题感兴趣。点击链接就可以查看。

  • 一条看似不合理SQL和10个合理的解释

  • MySQL的主键命名挺任性,就这么定了

  • 回答:我不小心把公司的数据库给删了,该不该离职?

  • 迁移到MySQL的业务架构演进实战

  • 数据库修改密码风险高,如何保证业务持续,这几种密码双活方案可以参考

  • MySQL业务双活的初步设计方案

  • 如何优化MySQL千万级大表,我写了6000字的解读

  • 一道经典的MySQL面试题,答案出现三次反转

  • 业务双活的数据切换思路设计(下)

  • 业务双活的数据切换思路设计(一)

  • MySQL中的主键和rowid,看似简单,其实有一些使用陷阱需要注意

  • 小白学MySQL要多久?我整理了10多个问题的答案

   

转载热文

你可能也会对以下话题感兴趣,文章来源于转载,点击链接就可以查看。

  • 去IOE or Not?

  • 拉里·佩奇(Larry Page)的伟大归来

  • 《吊打面试官》系列-Redis基础

  • 唯一ID生成算法剖析,看看这篇就够了

  • 关于大数据运维能力的一些思考

  • DBA菜鸟的进化简史:不忘初心,记工作中踩过的三个坑

  • 美女主持直播,被突发意外打断!湾区网友却高喊: 我懂!超甜

相关文章:

  • 还能这样?把 Python 自动翻译成 C++
  • 华裔教授发现二次方程极简解法,我默默的做了下验算
  • 运维系统性能优化后思考,除了避免懒惰的麻木,还需要了解系统的“脾性”...
  • 用python重新定义【2019十大网络流行语】
  • 那些对我来说没有价值和有价值的事情
  • 关于MySQL GTID的一次深刻学习
  • 这才是真正的Git——Git内部原理揭秘!
  • MySQL 8.0与MariaDB 10.4,谁更易于填坑补锅?
  • 那些难忘的维护之夜
  • 《宣州谢眺楼饯别校书叔云》赏析
  • 无意中测试了下MySQL里面的join操作,发现还是存在理解偏差
  • 说几点关于数据库的见解
  • Oracle和MySQL的数据导入,差别为什么这么大
  • 使用Python分析北京积分落户数据,分析完我陷入了深思
  • 私有云MySQL多租户权限的初版设计
  • [PHP内核探索]PHP中的哈希表
  • - C#编程大幅提高OUTLOOK的邮件搜索能力!
  • flutter的key在widget list的作用以及必要性
  • JAVA并发编程--1.基础概念
  • Java教程_软件开发基础
  • log4j2输出到kafka
  • react 代码优化(一) ——事件处理
  • React 快速上手 - 06 容器组件、展示组件、操作组件
  • Spring技术内幕笔记(2):Spring MVC 与 Web
  • Swift 中的尾递归和蹦床
  • 高度不固定时垂直居中
  • 记录一下第一次使用npm
  • 开源地图数据可视化库——mapnik
  • 微信如何实现自动跳转到用其他浏览器打开指定页面下载APP
  • 一道闭包题引发的思考
  • 责任链模式的两种实现
  • 1.Ext JS 建立web开发工程
  • #FPGA(基础知识)
  • #Linux杂记--将Python3的源码编译为.so文件方法与Linux环境下的交叉编译方法
  • #我与虚拟机的故事#连载20:周志明虚拟机第 3 版:到底值不值得买?
  • (2/2) 为了理解 UWP 的启动流程,我从零开始创建了一个 UWP 程序
  • (20050108)又读《平凡的世界》
  • (3)llvm ir转换过程
  • (C++17) std算法之执行策略 execution
  • (附源码)计算机毕业设计SSM教师教学质量评价系统
  • (三)Honghu Cloud云架构一定时调度平台
  • (循环依赖问题)学习spring的第九天
  • (一一四)第九章编程练习
  • (原+转)Ubuntu16.04软件中心闪退及wifi消失
  • (转) ns2/nam与nam实现相关的文件
  • (转)Oracle存储过程编写经验和优化措施
  • (转)视频码率,帧率和分辨率的联系与区别
  • (转)一些感悟
  • * CIL library *(* CIL module *) : error LNK2005: _DllMain@12 already defined in mfcs120u.lib(dllmodu
  • ***微信公众号支付+微信H5支付+微信扫码支付+小程序支付+APP微信支付解决方案总结...
  • ..回顾17,展望18
  • .bat批处理(二):%0 %1——给批处理脚本传递参数
  • .bat批处理(十一):替换字符串中包含百分号%的子串
  • .NET 8 编写 LiteDB vs SQLite 数据库 CRUD 接口性能测试(准备篇)
  • .net core 6 使用注解自动注入实例,无需构造注入 autowrite4net