redis rdb持久化

系列

redis數(shù)據(jù)淘汰原理
redis過期數(shù)據(jù)刪除策略
redis server事件模型
redis cluster mget 引發(fā)的討論
redis 3.x windows 集群搭建
redis 命令執(zhí)行過程
redis string底層數(shù)據(jù)結構
redis list底層數(shù)據(jù)結構
redis hash底層數(shù)據(jù)結構
redis set底層數(shù)據(jù)結構
redis zset底層數(shù)據(jù)結構
redis 客戶端管理
redis 主從同步-slave端
redis 主從同步-master端
redis 主從超時檢測
redis aof持久化
redis rdb持久化
redis 數(shù)據(jù)恢復過程
redis TTL實現(xiàn)原理
redis cluster集群建立
redis cluster集群選主

redis rdb和aof持久化的區(qū)別

?關于這兩者的區(qū)別敬尺,網(wǎng)上有很多資料枚尼,這里我只想補充下自己理解的兩個比較核心的點:

  • 持久化過程是否異步,rdb持久化是后臺異步進程執(zhí)行砂吞,aof是同步執(zhí)行
  • 持久化內容格式署恍,rdb是直接存儲實際內存存儲數(shù)據(jù),aof是轉為redis執(zhí)行命令行存儲


redis rdb持久化過程

?分析redis的rdb持久化過程直接從bgsaveCommand命令的執(zhí)行過程開始分析

  • 首先不能同時執(zhí)行多個bgsave命令或同時執(zhí)行bgrewriteaof命令
  • 其次進入后臺fork線程生成rdb文件過程rdbSaveBackground
void bgsaveCommand(redisClient *c) {

    // 不能重復執(zhí)行 BGSAVE
    if (server.rdb_child_pid != -1) {
        addReplyError(c,"Background save already in progress");

    // 不能在 BGREWRITEAOF 正在運行時執(zhí)行
    } else if (server.aof_child_pid != -1) {
        addReplyError(c,"Can't BGSAVE while AOF log rewriting is in progress");

    // 執(zhí)行 BGSAVE
    } else if (rdbSaveBackground(server.rdb_filename) == REDIS_OK) {
        addReplyStatus(c,"Background saving started");

    } else {
        addReply(c,shared.err);
    }
}



?在rdbSaveBackground內部執(zhí)行了fork子進程開始進行rdb的持久化操作蜻直,核心邏輯在執(zhí)行rdbSave(filename)的命令盯质。

int rdbSaveBackground(char *filename) {
    pid_t childpid;
    long long start;

    // 如果 BGSAVE 已經(jīng)在執(zhí)行袁串,那么出錯
    if (server.rdb_child_pid != -1) return REDIS_ERR;

    // 記錄 BGSAVE 執(zhí)行前的數(shù)據(jù)庫被修改次數(shù)
    server.dirty_before_bgsave = server.dirty;

    // 最近一次嘗試執(zhí)行 BGSAVE 的時間
    server.lastbgsave_try = time(NULL);

    // fork() 開始前的時間,記錄 fork() 返回耗時用
    start = ustime();

    if ((childpid = fork()) == 0) {
        int retval;

        /* Child */

        // 關閉網(wǎng)絡連接 fd
        closeListeningSockets(0);

        // 設置進程的標題唤殴,方便識別
        redisSetProcTitle("redis-rdb-bgsave");

        // 執(zhí)行保存操作
        retval = rdbSave(filename);

        // 打印 copy-on-write 時使用的內存數(shù)
        if (retval == REDIS_OK) {
            size_t private_dirty = zmalloc_get_private_dirty();

            if (private_dirty) {
                redisLog(REDIS_NOTICE,
                    "RDB: %zu MB of memory used by copy-on-write",
                    private_dirty/(1024*1024));
            }
        }

        // 向父進程發(fā)送信號
        exitFromChild((retval == REDIS_OK) ? 0 : 1);

    } 

    // 省略非核心的邏輯
    return REDIS_OK;
}



?整個生成rdb文件的核心般婆,整體邏輯如下

  • 創(chuàng)建rdb磁盤文件
  • 遍歷redis的所有db進行寫入

整個寫入數(shù)據(jù)是將redis內存中的數(shù)據(jù)原封不動的寫入到rdb文件當中,整個寫入過程按照以下順序進行執(zhí)行:

  • 通過rdbSaveType方法寫入type
  • 通過rdbSaveLen寫入數(shù)據(jù)(或者是下面的集中替代)
  • 通過rdbSaveObjectType存儲redis value的數(shù)據(jù)類型
  • 通過rdbSaveStringObject存儲redis key的數(shù)據(jù)
  • 通過rdbSaveObject存儲redis value的數(shù)據(jù)

整個過程中我們發(fā)現(xiàn)redis就是把實際內存數(shù)據(jù)庫的數(shù)據(jù)dump到rdb文件當中

/* 
 * 將數(shù)據(jù)庫保存到磁盤上朵逝。
 *
 * 保存成功返回 REDIS_OK ,出錯/失敗返回 REDIS_ERR 乡范。
 */
int rdbSave(char *filename) {
    dictIterator *di = NULL;
    dictEntry *de;
    char tmpfile[256];
    char magic[10];
    int j;
    long long now = mstime();
    FILE *fp;
    rio rdb;
    uint64_t cksum;

    // 創(chuàng)建臨時文件
    snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
    fp = fopen(tmpfile,"w");
    if (!fp) {
        redisLog(REDIS_WARNING, "Failed opening .rdb for saving: %s",
            strerror(errno));
        return REDIS_ERR;
    }

    // 初始化 I/O
    rioInitWithFile(&rdb,fp);

    // 設置校驗和函數(shù)
    if (server.rdb_checksum)
        rdb.update_cksum = rioGenericUpdateChecksum;

    // 寫入 RDB 版本號
    snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);
    if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;

    // 遍歷所有數(shù)據(jù)庫
    for (j = 0; j < server.dbnum; j++) {

        // 指向數(shù)據(jù)庫
        redisDb *db = server.db+j;

        // 指向數(shù)據(jù)庫鍵空間
        dict *d = db->dict;

        // 跳過空數(shù)據(jù)庫
        if (dictSize(d) == 0) continue;

        // 創(chuàng)建鍵空間迭代器
        di = dictGetSafeIterator(d);
        if (!di) {
            fclose(fp);
            return REDIS_ERR;
        }

        /* Write the SELECT DB opcode 
         *
         * 寫入 DB 選擇器
         */
        if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_SELECTDB) == -1) goto werr;
        if (rdbSaveLen(&rdb,j) == -1) goto werr;

        /* Iterate this DB writing every entry 
         *
         * 遍歷數(shù)據(jù)庫配名,并寫入每個鍵值對的數(shù)據(jù)
         */
        while((de = dictNext(di)) != NULL) {
            sds keystr = dictGetKey(de);
            robj key, *o = dictGetVal(de);
            long long expire;
            
            // 根據(jù) keystr ,在棧中創(chuàng)建一個 key 對象
            initStaticStringObject(key,keystr);

            // 獲取鍵的過期時間
            expire = getExpire(db,&key);

            // 保存鍵值對數(shù)據(jù)
            if (rdbSaveKeyValuePair(&rdb,&key,o,expire,now) == -1) goto werr;
        }
        dictReleaseIterator(di);
    }
    di = NULL; /* So that we don't release it again on error. */

    /* EOF opcode 
     *
     * 寫入 EOF 代碼
     */
    if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_EOF) == -1) goto werr;

    /* 
     * CRC64 校驗和晋辆。
     *
     * 如果校驗和功能已關閉渠脉,那么 rdb.cksum 將為 0 ,
     * 在這種情況下瓶佳, RDB 載入時會跳過校驗和檢查芋膘。
     */
    cksum = rdb.cksum;
    memrev64ifbe(&cksum);
    rioWrite(&rdb,&cksum,8);

    /* Make sure data will not remain on the OS's output buffers */
    // 沖洗緩存,確保數(shù)據(jù)已寫入磁盤
    if (fflush(fp) == EOF) goto werr;
    if (fsync(fileno(fp)) == -1) goto werr;
    if (fclose(fp) == EOF) goto werr;

    /* 
     * 使用 RENAME 霸饲,原子性地對臨時文件進行改名为朋,覆蓋原來的 RDB 文件。
     */
    if (rename(tmpfile,filename) == -1) {
        redisLog(REDIS_WARNING,"Error moving temp DB file on the final destination: %s", strerror(errno));
        unlink(tmpfile);
        return REDIS_ERR;
    }

    // 寫入完成厚脉,打印日志
    redisLog(REDIS_NOTICE,"DB saved on disk");

    // 清零數(shù)據(jù)庫臟狀態(tài)
    server.dirty = 0;

    // 記錄最后一次完成 SAVE 的時間
    server.lastsave = time(NULL);

    // 記錄最后一次執(zhí)行 SAVE 的狀態(tài)
    server.lastbgsave_status = REDIS_OK;

    return REDIS_OK;

werr:
    // 關閉文件
    fclose(fp);
    // 刪除文件
    unlink(tmpfile);

    redisLog(REDIS_WARNING,"Write error saving DB on disk: %s", strerror(errno));

    if (di) dictReleaseIterator(di);

    return REDIS_ERR;
}



?將鍵值對的鍵习寸、值、過期時間和類型寫入到 RDB 中

/* 
 * 將鍵值對的鍵傻工、值霞溪、過期時間和類型寫入到 RDB 中。
 *
 * 出錯返回 -1 中捆。
 *
 * On success if the key was actually saved 1 is returned, otherwise 0
 * is returned (the key was already expired). 
 *
 * 成功保存返回 1 鸯匹,當鍵已經(jīng)過期時,返回 0 泄伪。
 */
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
                        long long expiretime, long long now)
{
    /* Save the expire time 
     *
     * 保存鍵的過期時間
     */
    if (expiretime != -1) {
        /* If this key is already expired skip it 
         *
         * 不寫入已經(jīng)過期的鍵
         */
        if (expiretime < now) return 0;

        if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
        if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
    }

    /* Save type, key, value 
     *
     * 保存類型殴蓬,鍵,值
     */
    if (rdbSaveObjectType(rdb,val) == -1) return -1;
    if (rdbSaveStringObject(rdb,key) == -1) return -1;
    if (rdbSaveObject(rdb,val) == -1) return -1;

    return 1;
}



?將鍵值對的值類型寫入到 rdb 中

/* 
 * 將對象 o 的類型寫入到 rdb 中
 */
int rdbSaveObjectType(rio *rdb, robj *o) {

    switch (o->type) {

    case REDIS_STRING:
        return rdbSaveType(rdb,REDIS_RDB_TYPE_STRING);

    case REDIS_LIST:
        if (o->encoding == REDIS_ENCODING_ZIPLIST)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_LIST_ZIPLIST);
        else if (o->encoding == REDIS_ENCODING_LINKEDLIST)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_LIST);
        else
            redisPanic("Unknown list encoding");

    case REDIS_SET:
        if (o->encoding == REDIS_ENCODING_INTSET)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_SET_INTSET);
        else if (o->encoding == REDIS_ENCODING_HT)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_SET);
        else
            redisPanic("Unknown set encoding");

    case REDIS_ZSET:
        if (o->encoding == REDIS_ENCODING_ZIPLIST)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_ZSET_ZIPLIST);
        else if (o->encoding == REDIS_ENCODING_SKIPLIST)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_ZSET);
        else
            redisPanic("Unknown sorted set encoding");

    case REDIS_HASH:
        if (o->encoding == REDIS_ENCODING_ZIPLIST)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_HASH_ZIPLIST);
        else if (o->encoding == REDIS_ENCODING_HT)
            return rdbSaveType(rdb,REDIS_RDB_TYPE_HASH);
        else
            redisPanic("Unknown hash encoding");

    default:
        redisPanic("Unknown object type");
    }

    return -1; /* avoid warning */
}



?將給定的字符串對象 obj 保存到 rdb 中臂容,我們的key就是通過這個方法保存的

/*
 * 將給定的字符串對象 obj 保存到 rdb 中科雳。
 *
 * 函數(shù)返回 rdb 保存字符串對象所需的字節(jié)數(shù)。
 *
 * p.s. 代碼原本的注釋 rdbSaveStringObjectRaw() 函數(shù)已經(jīng)不存在了脓杉。
 */
int rdbSaveStringObject(rio *rdb, robj *obj) {

    /* Avoid to decode the object, then encode it again, if the
     * object is already integer encoded. */
    // 嘗試對 INT 編碼的字符串進行特殊編碼
    if (obj->encoding == REDIS_ENCODING_INT) {
        return rdbSaveLongLongAsStringObject(rdb,(long)obj->ptr);

    // 保存 STRING 編碼的字符串
    } else {
        redisAssertWithInfo(NULL,obj,sdsEncodedObject(obj));
        return rdbSaveRawString(rdb,obj->ptr,sdslen(obj->ptr));
    }
}



?將給定對象 o 保存到 rdb 中糟秘。

/* 
 * 將給定對象 o 保存到 rdb 中。
 *
 * 保存成功返回 rdb 保存該對象所需的字節(jié)數(shù) 球散,失敗返回 0 尿赚。
 *
 * p.s.上面原文注釋所說的返回值是不正確的
 */
int rdbSaveObject(rio *rdb, robj *o) {
    int n, nwritten = 0;

    // 保存字符串對象
    if (o->type == REDIS_STRING) {
        /* Save a string value */
        if ((n = rdbSaveStringObject(rdb,o)) == -1) return -1;
        nwritten += n;

    // 保存列表對象
    } else if (o->type == REDIS_LIST) {
        /* Save a list value */
        if (o->encoding == REDIS_ENCODING_ZIPLIST) {
            size_t l = ziplistBlobLen((unsigned char*)o->ptr);

            // 以字符串對象的形式保存整個 ZIPLIST 列表
            if ((n = rdbSaveRawString(rdb,o->ptr,l)) == -1) return -1;
            nwritten += n;
        } else if (o->encoding == REDIS_ENCODING_LINKEDLIST) {
            list *list = o->ptr;
            listIter li;
            listNode *ln;

            if ((n = rdbSaveLen(rdb,listLength(list))) == -1) return -1;
            nwritten += n;

            // 遍歷所有列表項
            listRewind(list,&li);
            while((ln = listNext(&li))) {
                robj *eleobj = listNodeValue(ln);
                // 以字符串對象的形式保存列表項
                if ((n = rdbSaveStringObject(rdb,eleobj)) == -1) return -1;
                nwritten += n;
            }
        } else {
            redisPanic("Unknown list encoding");
        }

    // 保存集合對象
    } else if (o->type == REDIS_SET) {
        /* Save a set value */
        if (o->encoding == REDIS_ENCODING_HT) {
            dict *set = o->ptr;
            dictIterator *di = dictGetIterator(set);
            dictEntry *de;

            if ((n = rdbSaveLen(rdb,dictSize(set))) == -1) return -1;
            nwritten += n;

            // 遍歷集合成員
            while((de = dictNext(di)) != NULL) {
                robj *eleobj = dictGetKey(de);
                // 以字符串對象的方式保存成員
                if ((n = rdbSaveStringObject(rdb,eleobj)) == -1) return -1;
                nwritten += n;
            }
            dictReleaseIterator(di);
        } else if (o->encoding == REDIS_ENCODING_INTSET) {
            size_t l = intsetBlobLen((intset*)o->ptr);

            // 以字符串對象的方式保存整個 INTSET 集合
            if ((n = rdbSaveRawString(rdb,o->ptr,l)) == -1) return -1;
            nwritten += n;
        } else {
            redisPanic("Unknown set encoding");
        }

    // 保存有序集對象
    } else if (o->type == REDIS_ZSET) {
        /* Save a sorted set value */
        if (o->encoding == REDIS_ENCODING_ZIPLIST) {
            size_t l = ziplistBlobLen((unsigned char*)o->ptr);

            // 以字符串對象的形式保存整個 ZIPLIST 有序集
            if ((n = rdbSaveRawString(rdb,o->ptr,l)) == -1) return -1;
            nwritten += n;
        } else if (o->encoding == REDIS_ENCODING_SKIPLIST) {
            zset *zs = o->ptr;
            dictIterator *di = dictGetIterator(zs->dict);
            dictEntry *de;

            if ((n = rdbSaveLen(rdb,dictSize(zs->dict))) == -1) return -1;
            nwritten += n;

            // 遍歷有序集
            while((de = dictNext(di)) != NULL) {
                robj *eleobj = dictGetKey(de);
                double *score = dictGetVal(de);

                // 以字符串對象的形式保存集合成員
                if ((n = rdbSaveStringObject(rdb,eleobj)) == -1) return -1;
                nwritten += n;

                // 成員分值(一個雙精度浮點數(shù))會被轉換成字符串
                // 然后保存到 rdb 中
                if ((n = rdbSaveDoubleValue(rdb,*score)) == -1) return -1;
                nwritten += n;
            }
            dictReleaseIterator(di);
        } else {
            redisPanic("Unknown sorted set encoding");
        }

    // 保存哈希表
    } else if (o->type == REDIS_HASH) {

        /* Save a hash value */
        if (o->encoding == REDIS_ENCODING_ZIPLIST) {
            size_t l = ziplistBlobLen((unsigned char*)o->ptr);

            // 以字符串對象的形式保存整個 ZIPLIST 哈希表
            if ((n = rdbSaveRawString(rdb,o->ptr,l)) == -1) return -1;
            nwritten += n;

        } else if (o->encoding == REDIS_ENCODING_HT) {
            dictIterator *di = dictGetIterator(o->ptr);
            dictEntry *de;

            if ((n = rdbSaveLen(rdb,dictSize((dict*)o->ptr))) == -1) return -1;
            nwritten += n;

            // 迭代字典
            while((de = dictNext(di)) != NULL) {
                robj *key = dictGetKey(de);
                robj *val = dictGetVal(de);

                // 鍵和值都以字符串對象的形式來保存
                if ((n = rdbSaveStringObject(rdb,key)) == -1) return -1;
                nwritten += n;
                if ((n = rdbSaveStringObject(rdb,val)) == -1) return -1;
                nwritten += n;
            }
            dictReleaseIterator(di);

        } else {
            redisPanic("Unknown hash encoding");
        }

    } else {
        redisPanic("Unknown object type");
    }

    return nwritten;
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子凌净,更是在濱河造成了極大的恐慌悲龟,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,084評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件冰寻,死亡現(xiàn)場離奇詭異须教,居然都是意外死亡,警方通過查閱死者的電腦和手機斩芭,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,623評論 3 392
  • 文/潘曉璐 我一進店門轻腺,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人划乖,你說我怎么就攤上這事贬养。” “怎么了琴庵?”我有些...
    開封第一講書人閱讀 163,450評論 0 353
  • 文/不壞的土叔 我叫張陵误算,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么递鹉? 我笑而不...
    開封第一講書人閱讀 58,322評論 1 293
  • 正文 為了忘掉前任幢哨,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好这橙,可當我...
    茶點故事閱讀 67,370評論 6 390
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著导披,像睡著了一般屈扎。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上撩匕,一...
    開封第一講書人閱讀 51,274評論 1 300
  • 那天鹰晨,我揣著相機與錄音,去河邊找鬼止毕。 笑死模蜡,一個胖子當著我的面吹牛,可吹牛的內容都是我干的扁凛。 我是一名探鬼主播忍疾,決...
    沈念sama閱讀 40,126評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼谨朝!你這毒婦竟也來了卤妒?” 一聲冷哼從身側響起甥绿,我...
    開封第一講書人閱讀 38,980評論 0 275
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎则披,沒想到半個月后共缕,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,414評論 1 313
  • 正文 獨居荒郊野嶺守林人離奇死亡士复,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,599評論 3 334
  • 正文 我和宋清朗相戀三年图谷,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片阱洪。...
    茶點故事閱讀 39,773評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡蜓萄,死狀恐怖,靈堂內的尸體忽然破棺而出澄峰,到底是詐尸還是另有隱情,我是刑警寧澤辟犀,帶...
    沈念sama閱讀 35,470評論 5 344
  • 正文 年R本政府宣布俏竞,位于F島的核電站,受9級特大地震影響堂竟,放射性物質發(fā)生泄漏魂毁。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,080評論 3 327
  • 文/蒙蒙 一出嘹、第九天 我趴在偏房一處隱蔽的房頂上張望席楚。 院中可真熱鬧,春花似錦税稼、人聲如沸烦秩。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,713評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽只祠。三九已至,卻和暖如春扰肌,著一層夾襖步出監(jiān)牢的瞬間抛寝,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,852評論 1 269
  • 我被黑心中介騙來泰國打工曙旭, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留盗舰,地道東北人。 一個月前我還...
    沈念sama閱讀 47,865評論 2 370
  • 正文 我出身青樓桂躏,卻偏偏與公主長得像钻趋,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子沼头,可洞房花燭夜當晚...
    茶點故事閱讀 44,689評論 2 354

推薦閱讀更多精彩內容

  • 前言 在上一篇文章中爷绘,介紹了Redis內存模型书劝,從這篇文章開始,將依次介紹Redis高可用相關的知識——持久化土至、復...
    Java架構閱讀 2,314評論 3 21
  • 本文翻譯自官方文檔http://redis.io/topics/persistence 购对。 Redis 持久化 R...
    六尺帳篷閱讀 1,631評論 1 15
  • RDB持久化 Redis是內存數(shù)據(jù)庫,數(shù)據(jù)庫狀態(tài)都在內存里邊陶因,需要RDB持久化功能將內存中的數(shù)據(jù)庫狀態(tài)保存到磁盤里...
    涵仔睡覺閱讀 290評論 0 1
  • 文章已經(jīng)放到github上 骡苞,如果對您有幫助 請給個star[https://github.com/qqxuanl...
    尼爾君閱讀 2,286評論 0 22
  • 開始記錄我的 2019-Read-Record 記錄一些有意思的知識點和疑難雜癥。 1 整理字符工作 有這么一幫人...
    NinthDay閱讀 2,474評論 2 13