解碼Redis最易被忽視的CPU和內(nèi)存占用高問題
我們在使用Redis時,總會碰到一些redis-server端CPU及內(nèi)存占用比較高的問題。下面以幾個實(shí)際案例為例,來討論一下在使用Redis時容易忽視的幾種情形。
一、短連接導(dǎo)致CPU高
某用戶反映QPS不高,從監(jiān)控看CPU確實(shí)偏高。既然QPS不高,那么redis-server自身很可能在做某些清理工作或者用戶在執(zhí)行復(fù)雜度較高的命令,經(jīng)排查無沒有進(jìn)行key過期刪除操作,沒有執(zhí)行復(fù)雜度高的命令。
上機(jī)器對redis-server進(jìn)行perf分析,發(fā)現(xiàn)函數(shù)listSearchKey占用CPU比較高,分析調(diào)用棧發(fā)現(xiàn)在釋放連接時會頻繁調(diào)用listSearchKey,且用戶反饋說是使用的短連接,所以推斷是頻繁釋放連接導(dǎo)致CPU占用有所升高。
1、對比實(shí)驗(yàn)
下面使用redis-benchmark工具分別使用長連接和短連接做一個對比實(shí)驗(yàn),redis-server為社區(qū)版4.0.10。
1)長連接測試
使用10000個長連接向redis-server發(fā)送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分析,發(fā)現(xiàn)占用CPU最高的是readQueryFromClient,即主要是在處理來自用戶端的請求。
2)短連接測試
使用10000個短連接向redis-server發(fā)送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分析,發(fā)現(xiàn)占用CPU最高的確實(shí)是listSearchKey,而readQueryFromClient所占CPU的比例比listSearchKey要低得多,也就是說CPU有點(diǎn)“不務(wù)正業(yè)”了,處理用戶請求變成了副業(yè),而搜索list卻成為了主業(yè)。所以在同樣的業(yè)務(wù)請求量下,使用短連接會增加CPU的負(fù)擔(dān)。
從QPS上看,短連接與長連接差距比較大,原因來自兩方面:
- 每次重新建連接引入的網(wǎng)絡(luò)開銷。
- 釋放連接時,redis-server需消耗額外的CPU周期做清理工作。(這一點(diǎn)可以嘗試從redis-server端做優(yōu)化)
2、Redis連接釋放
我們從代碼層面來看下redis-server在用戶端發(fā)起連接釋放后都會做哪些事情,redis-server在收到用戶端的斷連請求時會直接進(jìn)入到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維護(hù)了一個server.clients鏈表,當(dāng)用戶端建立連接后,新建一個client對象并追加到server.clients上,
- 當(dāng)連接釋放時,需求從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節(jié)點(diǎn)對象,這里復(fù)雜為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)的運(yùn)算。對于redis這樣的內(nèi)存數(shù)據(jù)庫,我們應(yīng)該盡量避開O(N)運(yùn)算,特別是在連接數(shù)比較大的場景下,對性能影響比較明顯。雖然用戶只要不使用短連接就能避免,但在實(shí)際的場景中,用戶端連接池被打滿后,用戶也可能會建立一些短連接。
3、優(yōu)化
從上面的分析看,每次連接釋放時都會進(jìn)行O(N)的運(yùn)算,那能不能降復(fù)雜度降到O(1)呢?
這個問題非常簡單,server.clients是個雙向鏈表,只要當(dāng)client對象在創(chuàng)建時記住自己的內(nèi)存地址,釋放時就不需要遍歷server.clients。接下來嘗試優(yōu)化下:
- 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;
- }
- /* ......... */
優(yōu)化后短連接測試
使用10000個短連接向redis-server發(fā)送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
與優(yōu)化前相比,短連接性能能夠提升30+%,所以能夠保證存在短連接的情況下,性能不至于太差。
二、info命令導(dǎo)致CPU高
有用戶通過定期執(zhí)行info命令監(jiān)視redis的狀態(tài),這會在一定程度上導(dǎo)致CPU占用偏高。頻繁執(zhí)行info時通過perf分析發(fā)現(xiàn)getClientsMaxBuffers、getClientOutputBufferMemoryUsage及getMemoryOverheadData這幾個函數(shù)占用CPU比較高。
通過Info命令,可以拉取到redis-server端的如下一些狀態(tài)信息(未列全):
- client
- connected_clients:1
- client_longest_output_list:0 // redis-server端最長的outputbuffer列表長度
- client_biggest_input_buf:0. // redis-server端最長的inputbuffer字節(jié)長度
- 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為維護(hù)自身結(jié)構(gòu)所額外占用的內(nèi)存量
- used_memory_startup:786552
- used_memory_dataset:12210
- used_memory_dataset_perc:19.74%
- 為了得到client_longest_output_list、client_longest_output_list狀態(tài),需要遍歷redis-server端所有的client, 如getClientsMaxBuffers所示,可能看到這里也是存在同樣的O(N)運(yù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, 復(fù)雜度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狀態(tài),同樣也需要遍歷所有client計算所有client的outputBuffer所占用的內(nèi)存總量,如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占用的內(nèi)存總和,復(fù)雜度為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;
- }
實(shí)驗(yàn)
從上面的分析知道,當(dāng)連接數(shù)較高時(O(N)的N大),如果頻率執(zhí)行info命令,會占用較多CPU。
1)建立一個連接,不斷執(zhí)行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
- }
實(shí)驗(yàn)結(jié)果表明,CPU占用僅為20%左右。
2)建立9999個空閑連接,及一個連接不斷執(zhí)行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
- }
實(shí)驗(yàn)結(jié)果表明CPU能夠達(dá)到80%,所以在連接數(shù)較高時,盡量避免使用info命令。
3)pipeline導(dǎo)致內(nèi)存占用高
有用戶發(fā)現(xiàn)在使用pipeline做只讀操作時,redis-server的內(nèi)存容量偶爾也會出現(xiàn)明顯的上漲, 這是對pipeline的使不當(dāng)造成的。下面先以一個簡單的例子來說明Redis的pipeline邏輯是怎樣的。
下面通過golang語言實(shí)現(xiàn)以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中的內(nèi)容以一特定的協(xié)議格式發(fā)送到redis-server端
- fmt.Println(redis.String(c.Receive()))
- fmt.Println(redis.String(c.Receive()))
- fmt.Println(redis.String(c.Receive()))
- }
而此時server端收到的內(nèi)容為:
- *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端從接收到的內(nèi)容依次解析出命令、執(zhí)行命令、將執(zhí)行結(jié)果緩存到replyBuffer中,并將用戶端標(biāo)記為有內(nèi)容需要寫出。等到下次事件調(diào)度時再將replyBuffer中的內(nèi)容通過socket發(fā)送到client,所以并不是處理完一條命令就將結(jié)果返回用戶端。
- 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中讀取內(nèi)容或者因?yàn)槟承〣UG導(dǎo)致沒有執(zhí)行c.Receive(),當(dāng)接收buffer滿了后,server端的TCP滑動窗口為0,導(dǎo)致server端無法發(fā)送replyBuffer中的內(nèi)容,所以replyBuffer由于遲遲得不到釋放而占用額外的內(nèi)存。當(dāng)pipeline一次打包的命令數(shù)太多,以及包含如mget、hgetall、lrange等操作多個對象的命令時,問題會更突出。
小結(jié)
上面幾種情況,都是非常簡單的問題,沒有復(fù)雜的邏輯,在大部分場景下都不算問題,但是在一些極端場景下要把Redis用好,開發(fā)者還是需要關(guān)注這些細(xì)節(jié)。建議:
- 盡量不要使用短連接;
- 盡量不要在連接數(shù)比較高的場景下頻繁使用info;
- 使用pipeline時,要及時接收請求處理結(jié)果,且pipeline不宜一次打包太多請求。
作者介紹
張鵬義,騰訊云數(shù)據(jù)庫高級工程師,曾參與華為Taurus分布式數(shù)據(jù)研發(fā)及騰訊CynosDB for pg研發(fā)工作,現(xiàn)從事騰訊云Redis數(shù)據(jù)庫研發(fā)工作。
我們在使用Redis時,總會碰到一些redis-server端CPU及內(nèi)存占用比較高的問題。下面以幾個實(shí)際案例為例,來討論一下在使用Redis時容易忽視的幾種情形。