Redis存儲老是內心沒底?你大概漏了這些數據結構原理

1、Redis內存數據結構與編碼

想要弄清楚Redis內部如何支持5種數據類型,也就是要弄清Redis究竟是使用什麼樣的數據結構來存儲、查找咱們設置在內存中的數據。java

雖然咱們使用5種數據類型來緩存數據,可是Redis會根據咱們存儲數據的不一樣而選用不一樣的數據結構和編碼。node

這些數據類型在內存中的數據結構和編碼有不少種,隨着咱們存儲的數據類型的不一樣、數據量的大小不一樣都會引發內存數據結構的動態調整。redis

本文只是作數據結構和編碼的通常性介紹,不作過多細節討論,一方面是關於Redis源碼分析的資料網上有不少,還有一個緣由就是Redis每個版本的實現有很大差別,一旦展開細節討論,每個點每個數據結構都會很複雜,因此咱們這裏就不展開討論這些,只是起到拋磚引玉做用。算法

一、OBJECT encoding key、DEBUG OBJECT key

咱們知道使用type命令能夠查看某個key是不是5種數據類型之一,可是當咱們想查看某個key底層是使用哪一種數據結構和編碼來存儲的時候,可使用OBJECT encoding命令。sql

一樣一個key,因爲咱們設置的值不一樣,Redis選用了不一樣的內存數據結構和編碼。雖然Redis提供String數據類型,可是Redis會自動識別咱們cache的數據類型是int仍是String。數據庫

若是咱們設置的是字符串,且這個字符串長度不大於39字節,那麼將使用embstr來編碼;若是大於39字節將使用raw來編碼。Redis4.0將這個閥值擴大了45個字節。數組

除了使用OBJECT encoding命令外,咱們還可使用DEBUG OBJECT命令來查看更多詳細信息。緩存

DEBUG OBJECT能看到這個對象的refcount引用計數、serializedlength長度、lru_seconds_idle時間,這些信息決定了這個key緩存清除策略。服務器

二、簡單動態字符串(simple dynamic String)

簡單動態字符串簡稱SDS,在Redis中全部涉及到字符串的地方都是使用SDS實現,固然這裏不包括字面量。SDS與傳統C字符串的區別就是SDS是結構化的,它能夠高效的處理分配、回收、長度計算等問題。數據結構

struct sdshdr { 
    unsigned int len; 
    unsigned int free; 
    char buf[]; 
}; 

這是Redis3.0版本的sds.h頭文件定義,3.0.0以後變化比較大。len表示字符串長度,free表示空間長度,buf數組表示字符串。

SDS有不少優勢,好比,獲取長度的時間複雜度O(1),不須要遍歷全部charbuf[]組數,直接返回len值。

static inline size_t sdslen(const sds s) { 
    struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr))); 
    return sh->len; 
} 

固然還有空間分配檢查、空間預分配、空間惰性釋放等,這些都是SDS結構化字符串帶來的強大的擴展能力。

三、鏈表(linked list)

鏈表數據結構咱們是比較熟悉的,最大的特色就是節點的增、刪很是靈活。Redis List數據類型底層就是基於鏈表來實現。這是Redis3.0實現。

typedef struct list { 
    listNode *head; 
    listNode *tail; 
    void *(*dup)(void *ptr); 
    void (*free)(void *ptr); 
    int (*match)(void *ptr, void *key); 
    unsigned long len; 
} list; 
 
typedef struct listNode { 
    struct listNode *prev; 
    struct listNode *next; 
    void *value; 
} listNode; 

在Redis3.2.0版本的時候引入了quicklist鏈表結構,結合了linkedlist和ziplist的優點。

typedef struct quicklist { 
    quicklistNode *head; 
    quicklistNode *tail; 
    unsigned long count;        /* total count of all entries in all ziplists */ 
    unsigned int len;           /* number of quicklistNodes */ 
    int fill : 16;              /* fill factor for individual nodes */ 
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */ 
} quicklist; 
 
typedef struct quicklistNode { 
    struct quicklistNode *prev; 
    struct quicklistNode *next; 
    unsigned char *zl; 
    unsigned int sz;             /* ziplist size in bytes */ 
    unsigned int count : 16;     /* count of items in ziplist */ 
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */ 
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */ 
    unsigned int recompress : 1; /* was this node previous compressed? */ 
    unsigned int attempted_compress : 1; /* node can't compress; too small */ 
    unsigned int extra : 10; /* more bits to steal for future usage */ 
} quicklistNode; 

quicklist提供了靈活性同時也兼顧了ziplist的壓縮能力,quicklist->encoding指定了兩種壓縮算法。quickList->compress表示咱們能夠進行quicklist node的深度壓縮能力。Redis提供了兩個有關於壓縮的配置:

  • List-max-zipList-size:zipList長度控制;
  • List-compress-depth:控制鏈表兩端節點的壓縮個數,越是靠近兩端的節點被訪問的機率越大,因此能夠將訪問機率大的節點不壓縮,其餘節點進行壓縮。

對比Redis3.2的quicklist與Redis3.0,很明顯quicklist提供了更加豐富的壓縮功能。Redis3.0的版本是每一個listnode直接緩存值,而quicklistnode還有強大的有關於壓縮能力。

LPUSH list:products:mall 100 200 300 
(integer) 3 
 
OBJECT encoding list:products:mall 
"quicklist" 

四、字典(dict)

dict字典是基於Hash算法來實現,是Hash數據類型的底層存儲數據結構。咱們來看下Redis3.0.0版本的dict.h頭文件定義:

typedef struct dict { 
    dictType *type; 
    void *privdata; 
    dictht ht[2]; 
    long rehashidx; 
    int iterators;  
} dict; 
 
typedef struct dictht { 
    dictEntry **table; 
    unsigned long size; 
    unsigned long sizemask; 
    unsigned long used; 
} dictht; 
 
typedef struct dictEntry { 
    void *key; 
    union { 
        void *val; 
        uint64_t u64; 
        int64_t s64; 
        double d; 
    } v; 
    struct dictEntry *next; 
} dictEntry; 

說到Hash table有兩個東西是咱們常常會碰到的,首先就是Hash碰撞問題,Redis dict是採用鏈地址法來解決,dictEntry->next就是指向下個衝突key的節點。

還有一個常常碰到的就是rehash的問題,提到rehash咱們仍是有點擔憂性能的。那麼Redis實現是很是巧妙的,採用惰性漸進式rehash算法。

在dict struct裏有一個ht[2]組數,還有一個rehashidx索引。Redis進行rehash的大體算法是這樣的:

首先會開闢一個新的dictht空間,放在ht[2]索引上,此時將rehashidx設置爲0,表示開始進入rehash階段,這個階段可能會持續很長時間,rehashidx表示dictEntry個數。每次當有對某個ht[1]索引中的key進行訪問時,獲取、刪除、更新,Redis都會將當前dictEntry索引中的全部key rehash到ht[2]字典中。一旦rehashidx=-1表示rehash結束。

五、跳錶(skip list)

skip list是Zset的底層數據結構,有着高性能的查找排序能力。

咱們都知道通常用來實現帶有排序的查找都是用Tree實現,無論是各類變體的B Tree仍是B+Tree,本質都是用來作順序查找。

skip list實現起來簡單,性能也與B Tree相接近。

typedef struct zskiplistNode { 
    robj *obj; 
    double score; 
    struct zskiplistNode *backward; 
    struct zskiplistLevel { 
        struct zskiplistNode *forward; 
        unsigned int span; 
    } level[]; 
} zskiplistNode; 
 
 
typedef struct zskiplist { 
    struct zskiplistNode *header, *tail; 
    unsigned long length; 
    int level; 
} zskiplist; 

zskiplistNode->zskiplistLevel->span這個值記錄了當前節點距離下個節點的跨度。每個節點會有最大不超過zskiplist->level節點個數,分別用來表示不一樣跨度與節點的距離。

每一個節點會有多個forward向前指針,只有一個backward指針。每一個節點會有對象__*obj__和score分值,每一個分值都會按照順序排列。

六、整數集合(int set)

int set整數集合是set數據類型的底層實現數據結構,它的特色和使用場景很明顯,只要咱們使用的集合都是整數且在必定的範圍以內,都會使用整數集合編碼。

SADD set:userid 100 200 300 
(integer) 3 
 
OBJECT encoding set:userid 
"intset" 

int set使用一塊連續的內存來存儲集合數據,它是數組結構不是鏈表結構。

typedef struct intset { 
    uint32_t encoding; 
    uint32_t length; 
    int8_t contents[]; 
} intset; 

intset->encoding用來肯定contents[]是什麼類型的整數編碼,如下三種值之一:

#define INTSET_ENC_INT16 (sizeof(int16_t)) 
#define INTSET_ENC_INT32 (sizeof(int32_t)) 
#define INTSET_ENC_INT64 (sizeof(int64_t)) 

Redis會根據咱們設置的值類型動態sizeof出一個對應的空間大小。若是咱們集合原來是int16,而後往集合裏添加了int32整數將觸發升級,一旦升級成功不會觸發降級操做。

七、壓縮表(zip list)

zip list壓縮表是List、Zset、Hash數據類型的底層數據結構之一。它是爲了節省內存經過壓縮數據存儲在一塊連續的內存空間中。

typedef struct zlentry { 
    unsigned int prevrawlensize, prevrawlen; 
    unsigned int lensize, len; 
    unsigned int headersize; 
    unsigned char encoding; 
    unsigned char *p; 
} zlentry; 

它最大的優勢就是壓縮空間,空間利用率很高。缺點就是一旦出現更新可能就是連鎖更新,由於數據在內容空間中都是連續的,最極端狀況下就是可能出現順序連鎖擴張。

壓縮列表會由多個zlentry節點組成,每個zlentry記錄上一個節點長度和大小,當前節點長度lensize和大小len包括編碼encoding。

這取決於業務場景,Redis提供了一組配置,專門用來針對不一樣的場景進行閾值控制:

hash-max-ziplist-entries 512 
hash-max-ziplist-value 64 
list-max-ziplist-entries 512 
list-max-ziplist-value 64 
zset-max-ziplist-entries 128 
zset-max-ziplist-value 64 

上述配置分別用來配置ziplist做爲Hash、List、Zset數據類型的底層壓縮閾值控制。

八、Redis Object類型與映射

Redis內部每一種數據類型都是對象化的,也就是咱們所說的5種數據類型其實內部都會對應到Redis Object對象,而後再由Redis Object來包裝具體的存儲數據結構和編碼。

typedef struct redisObject { 
    unsigned type:4; 
    unsigned encoding:4; 
    unsigned lru:REDIS_LRU_BITS;  
    int refcount; 
    void *ptr; 
} robj; 

這是一個很OO的設計,redisObject->type是5種數據類型之一,redisObject->encoding是這個數據類型所使用的數據結構和編碼。

咱們看下Redis提供的5種數據類型與每一種數據類型對應的存儲數據結構和編碼。

/* Object types */ 
#define REDIS_STRING 0 
#define REDIS_LIST 1 
#define REDIS_SET 2 
#define REDIS_ZSET 3 
#define REDIS_HASH 4 
 
#define REDIS_ENCODING_RAW 0      
#define REDIS_ENCODING_INT 1     
#define REDIS_ENCODING_HT 2 
#define REDIS_ENCODING_ZIPMAP 3 
#define REDIS_ENCODING_LINKEDLIST 4 
#define REDIS_ENCODING_ZIPLIST 5  
#define REDIS_ENCODING_INTSET 6   
#define REDIS_ENCODING_SKIPLIST 7   
#define REDIS_ENCODING_EMBSTR 8  

REDIS_ENCODING_ZIPMAP 3這個編碼能夠忽略了,在特定的狀況下有性能問題,在Redis 2.6版本以後已經廢棄,爲了兼容性保留。

圖是Redis 5種數據類型與底層數據結構和編碼的對應關係:

可是這種對應關係在每個版本中都會有可能發生變化,這也是Redis Object的靈活性所在,有着OO的這種多態性。

  • redisObject->refcount表示當前對象的引用計數,在Redis內部爲了節省內存採用了共享對象的方法,當某個對象被引用的時候這個refcount會加1,釋放的時候會減1。
  • redisObject->lru表示當前對象的空轉時長,也就是idle time,這個時間會是Redis lru算法用來釋放對象的時間依據。能夠經過OBJECT idletime命令查看某個key的空轉時長lru時間。

2、Redis內存管理策略

Redis在服務端分別爲不一樣的db index維護一個dict,這個dict稱爲key space鍵空間。每個RedisClient只能屬於一個db index,在Redis服務端會維護每個連接的RedisClient。

typedef struct redisClient { 
    uint64_t id; 
    int fd; 
    redisDb *db; 
} redisClient; 

在服務端每個Redis客戶端都會有一個指向redisDb的指針。

typedef struct redisDb { 
    dict *dict; 
    dict *expires; 
    dict *blocking_keys; 
    dict *ready_keys; 
    dict *watched_keys; 
    struct evictionPoolEntry *eviction_pool; 
    int id; 
    long long avg_ttl; 
} redisDb; 

key space鍵空間就是這裏的redisDb->dict。redisDb->expires是維護全部鍵空間的每個key的過時時間。

一、鍵過時時間、生存時間

對於一個key咱們能夠設置它多少秒、毫秒以後過時,也能夠設置它在某個具體的時間點過時,後者是一個時間戳。例如:

  • EXPIRE命令能夠設置某個key多少秒以後過時;
  • PEXPIRE命令能夠設置某個key多少毫秒以後過時;
  • EXPIREAT命令能夠設置某個key在多少秒時間戳以後過時;
  • PEXPIREAT命令能夠設置某個key在多少毫秒時間戳以後過時;
  • PERSIST命令能夠移除鍵的過時時間。

其實上述命令最終都會被轉換成對PEXPIREAT命令。在redisDb->expires指向的key字典中維護着一個到期的毫秒時間戳。

TTL、PTTL能夠經過這兩個命令查看某個key的過時秒、毫秒數。

Redis內部有一個事件循環,這個事件循環會檢查鍵的過時時間是否小於當前時間,若是小於則會刪除這個鍵。

二、過時鍵刪除策略

在使用Redis的時候咱們最關心的就是鍵是如何被刪除的,如何高效準時地刪除某個鍵。其實Redis提供了兩個方案來完成這件事情:惰性刪除、按期刪除雙重刪除策略。

惰性刪除:當咱們訪問某個key的時候,Redis會檢查它是否過時。

robj *lookupKeyRead(redisDb *db, robj *key) { 
    robj *val; 
 
    expireIfNeeded(db,key); 
    val = lookupKey(db,key); 
    if (val == NULL) 
        server.stat_keyspace_misses++; 
    else 
        server.stat_keyspace_hits++; 
    return val; 
} 
 
int expireIfNeeded(redisDb *db, robj *key) { 
    mstime_t when = getExpire(db,key); 
    mstime_t now; 
 
    if (when < 0) return 0; /* No expire for this key */ 
 
    if (server.loading) return 0; 
 
    now = server.lua_caller ? server.lua_time_start : mstime(); 
    if (server.masterhost != NULL) return now > when; 
 
    /* Return when this key has not expired */ 
    if (now <= when) return 0; 
 
    /* Delete the key */ 
    server.stat_expiredkeys++; 
    propagateExpire(db,key); 
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,"expired",key,db->id); 
    return dbDelete(db,key); 
} 

按期刪除:Redis經過事件循環,週期性地執行key的過時刪除動做。

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { 
    /* Handle background operations on Redis databases. */ 
    databasesCron(); 
} 
 
void databasesCron(void) { 
    /* Expire keys by random sampling. Not required for slaves 
     * as master will synthesize DELs for us. */ 
    if (server.active_expire_enabled && server.masterhost == NULL) 
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW); 
} 

要注意的是:

  • 惰性刪除是每次只要有讀取、寫入都會觸發惰性刪除代碼;
  • 按期刪除是由Redis EventLoop來觸發的。Redis內部不少維護性工做都是基於EventLoop。

三、AOF、RDB處理過時鍵策略

既然鍵會隨時存在過時問題,那麼涉及到持久化Redis是如何幫咱們處理的?

當Redis使用RDB方式持久化時,每次持久化的時候就會檢查這些即將被持久化的key是否已通過期,若是過時將直接忽略,持久化那些沒有過時的鍵。

當Redis做爲master主服務器啓動的時候,載入rdb持久化鍵時也會檢查這些鍵是否過時,將忽略過時的鍵,只載入沒過時的鍵。

當Redis使用AOF方式持久化時,每次遇到過時的key Redis會追加一條DEL命令到AOF文件,也就是說只要咱們順序載入執行AOF命令文件就會刪除過時的鍵。

若是Redis做爲從服務器啓動的話,它一旦與master主服務器創建連接就會清空全部數據進行完整同步。固然新版本的Redis支持SYCN2的半同步,若是是已經創建了master/slave主從同步以後,主服務器會發送DEL命令給全部從服務器執行刪除操做。

四、Redis LRU算法

在使用Redis的時候咱們會設置maxmemory選項,64位的默認是0不限制。線上的服務器必需要設置的,要否則頗有可能致使Redis宿主服務器直接內存耗盡,最後連接都上不去。

因此基本要設置兩個配置:

  • maxmemory最大內存閾值;
  • maxmemory-policy到達閾值的執行策略。

能夠經過CONFIG GET maxmemory/maxmemory-policy分別查看這兩個配置值,也能夠經過CONFIG SET去分別配置。

  • maxmemory-policy有一組配置,能夠用在不少場景下:
  • noeviction:客戶端嘗試執行會讓更多內存被使用的命令直接報錯;
  • allkeys-lru:在全部key裏執行lru算法;
  • volatile-lru:在全部已通過期的key裏執行lru算法;
  • allkeys-random:在全部key裏隨機回收;
  • volatile-random:在已通過期的key裏隨機回收;
  • volatile-ttl:回收已通過期的key,而且優先回收存活時間(TTL)較短的鍵。

關於cache的命中率能夠經過info命令查看鍵空間的命中率和未命中率。

# Stats 
keyspace_hits:33 
keyspace_misses:5 

maxmemory在到達閾值的時候會採用必定的策略去釋放內存,這些策略咱們能夠根據本身的業務場景來選擇,默認是noeviction 。

Redis LRU算法有一個取樣的優化機制,能夠經過必定的取樣因子來增強回收的key的準確度。CONFIG GET maxmemory-samples查看取樣配置,具體能夠參考更加詳細的文章。

3、Redis持久化方式

Redis自己提供持久化功能,有兩種持久化機制,一種是數據持久化RDB,一種是命令持久化AOF,這兩種持久化方式各有優缺點,也能夠組合使用。一旦組合使用,Redis在載入數據的時候會優先載入aof文件,只有當AOF持久化關閉的時候纔會載入rdb文件。

一、RDB(Redis DataBase)

RDB是Redis數據庫,Redis會根據一個配置來觸發持久化:

#save <seconds> <changes> 
 
save 900 1 
save 300 10 
save 60 10000 
 
CONFIG GET save 
1) "save" 
2) "3600 1 300 100 60 10000" 

表示在多少秒之類的變化次數,一旦達到這個觸發條件Redis將觸發持久化動做。

Redis在執行持久化的時候有兩種模式BGSAVE、SAVE:

  • BGSAVE是後臺保存,Redis會fork出一個子進程來處理持久化,不會block用戶的執行請求;
  • SAVE則會block用戶執行請求。
struct redisServer { 
long long dirty;/* Changes to DB from the last save */ 
time_t lastsave; /* Unix time of last successful save */ 
long long dirty_before_bgsave; 
pid_t rdb_child_pid;/* PID of RDB saving child */ 
struct saveparam *saveparams; /* Save points array for RDB */ 
} 
 
struct saveparam { 
    time_t seconds; 
    int changes; 
}; 

RedisServer包含的信息不少,其中就包含了有關於RDB持久化的信息。

redisServer->dirty至上次save到目前爲止的change數。redisServer->lastsave上次save時間。

saveparam struct保存了咱們經過save命令設置的參數,time_t是個long時間戳。

typedef __darwin_time_t     time_t; 
 
typedef long    __darwin_time_t;    /* time() */ 
 
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { 
         for (j = 0; j < server.saveparamslen; j++) { 
            struct saveparam *sp = server.saveparams+j; 
            if (server.dirty >= sp->changes && 
                server.unixtime-server.lastsave > sp->seconds && 
                (server.unixtime-server.lastbgsave_try > 
                 REDIS_BGSAVE_RETRY_DELAY || 
                 server.lastbgsave_status == REDIS_OK)) 
            { 
                redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...", 
                    sp->changes, (int)sp->seconds); 
                rdbSaveBackground(server.rdb_filename); 
                break; 
            } 
         } 
} 

Redis事件循環會週期性的執行serverCron方法,這段代碼會循環遍歷server.saveparams參數鏈表。

若是server.dirty大於等於咱們參數裏配置的變化而且server.unixtime-server.lastsave大於參數裏配置的時間,而且server.unixtime-server.lastbgsave_try減去bgsave重試延遲時間,或者當前server.lastbgsave_status== REDIS_OK則執行rdbSaveBackground方法。

二、AOF(Append-only file)

AOF持久化是採用對文件進行追加對方式進行,每次追加都是Redis處理的命令。有點相似command sourcing命令溯源的模式,只要咱們能夠將全部的命令按照執行順序在重放一遍就能夠還原最終的Redis內存狀態。

AOF持久化最大的優點是能夠縮短數據丟失的間隔,作到秒級的丟失率。RDB會丟失上一個保存週期到目前的全部數據,只要沒有觸發save命令設置的save seconds changes閾值數據就會一直不被持久化。

struct redisServer { 
 /* AOF buffer, written before entering the event loop */ 
 sds aof_buf; 
 } 
 
struct sdshdr { 
    unsigned int len; 
    unsigned int free; 
    char buf[]; 
}; 

aof_buf是命令緩存區,採用sds結構緩存,每次當有命令被執行當時候都會寫一次到aof_buf中。有幾個配置用來控制AOF持久化的機制。

appendonly no  
appendfilename "appendonly.aof" 

appendonly用來控制是否開啓AOF持久化,appendfilename用來設置aof文件名。

appendfsync always 
appendfsync everysec 
appendfsync no 

appendfsync用來控制命令刷盤機制。如今操做系統都有文件cache/buffer的概念,全部的寫入和讀取都會走cache/buffer,並不會每次都同步刷盤,由於這樣性能必定會受影響。因此Redis也提供了這個選項讓咱們來本身根據業務場景控制。

  • always:每次將aof_buf命令寫入aof文件而且執行實時刷盤。
  • everysec:每次將aof_buf命令寫入aof文件,可是每隔一秒執行一次刷盤。
  • no:每次將aof_buf命令寫入aof文件不執行刷盤,由操做系統來自行控制。

AOF也是採用後臺子進程的方式進行,與主進程共享數據空間也就是aof_buf,可是隻要開始了AOF子進程以後Redis事件循環文件事件處理器會將以後的命令寫入另一個aof_buf,這樣就能夠作到平滑的切換。

AOF會不斷的追加命令進aof文件,隨着時間和併發量的加大aof文件會極速膨脹,因此有必要對這個文件大小進行優化。Redis基於rewrite重寫對文件進行壓縮。

no-appendfsync-on-rewrite no/yes 
auto-aof-rewrite-percentage 100 
auto-aof-rewrite-min-size 64mb 

no-appendfsync-on-rewrite控制是否在bgrewriteaof的時候還須要進行命令追加,若是追加可能會出現磁盤IO跑高現象。

上面說過,當AOF進程在執行的時候原來的事件循環還會正常的追加命令進aof文件,同時還會追加命令進另一個aof_buf,用來作新aof文件的重寫。這是兩條並行的動做,若是咱們設置成yes就不追加原來的aof_buf 由於新的aof文件已經包含了以後進來的命令。

auto-aof-rewrite-percentage和auto-aof-rewrite-min -size64mb這兩個配置前者是文件增加百分比來進行rewrite,後者是按照文件大小增加進行rewrite

歡迎歡迎學Java的朋友們加入java架構交流: 855835163 羣內提供免費的Java架構學習資料(裏面有高可用、高併發、高性能及分佈式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用本身每一分每一秒的時間來學習提高本身,不要再用"沒有時間「來掩飾本身思想上的懶惰!趁年輕,使勁拼,給將來的本身一個交代!

相關文章
相關標籤/搜索