Redis源碼剖析數(shù)據(jù)過期(expire)

      網(wǎng)友投稿 856 2022-05-30

      我之前統(tǒng)計過我們線上某redis數(shù)據(jù)被訪問的時間分布,大概90%的請求只會訪問最新15分鐘的數(shù)據(jù),99%的請求訪問最新1小時的數(shù)據(jù),只有不到千分之一的請求會訪問超過1天的數(shù)據(jù)。我們之前這份數(shù)據(jù)存了兩天(近500g內(nèi)存數(shù)據(jù)),如果算上主備的話用掉了120多個Redis實例(一個實例8g內(nèi)存),光把過期時間從2天改成1天就能省下60多個redis實例,而且對原業(yè)務也沒有啥太大影響。

      當然Redis已經(jīng)實現(xiàn)了數(shù)據(jù)過期的自動清理機制,我所要做的只是改下數(shù)據(jù)寫入時的過期時間而已。假設Redis沒有數(shù)據(jù)過期的機制,我們要怎么辦? 大概一想就知道很麻煩,仔細想的話還得考慮很多的細節(jié)。所以我覺得過期數(shù)據(jù)在緩存系統(tǒng)中是不起眼但非常重要的功能,除了省事外,它也能幫我們節(jié)省很多成本。接下來我們看下Redis中是如何實現(xiàn)數(shù)據(jù)過期的。

      實時清理

      眾所周知,Redis核心流程是單線程執(zhí)行的,它基本上是處理完一條請求再出處理另外一條請求,處理請求的過程并不僅僅是響應用戶發(fā)起的請求,Redis也會做好多其他的工作,當前其中就包括數(shù)據(jù)的過期。

      Redis在讀寫某個key的時候,它就會調用expireIfNeeded()先判斷這個key是否已經(jīng)過期了,如果已過期,就會執(zhí)行刪除。

      int expireIfNeeded(redisDb *db, robj *key) { if (!keyIsExpired(db,key)) return 0; /* 如果是在slave上下文中運行,直接返回1,因為slave的key過期是由master控制的, * master會給slave發(fā)送數(shù)據(jù)刪除命令。 * * 如果返回0表示數(shù)據(jù)不需要清理,返回1表示數(shù)據(jù)這次標記為過期 */ if (server.masterhost != NULL) return 1; if (checkClientPauseTimeoutAndReturnIfPaused()) return 1; /* 刪除key */ server.stat_expiredkeys++; propagateExpire(db,key,server.lazyfree_lazy_expire); notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired",key,db->id); int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key); if (retval) signalModifiedKey(NULL,db,key); return retval; }

      判斷是否過期也很簡單,Redis在dictEntry中存儲了上次更新的時間戳,只需要判斷當前時間戳和上次更新時間戳之間的gap是否超過設定的過期時間即可。

      我們重點來關注下這行代碼。

      int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key);

      lazyfree_lazy_expire 是Redis的配置項之一,它的作用是是否開啟惰性刪除(默認不開啟),很顯然如果開啟就會執(zhí)行異步刪除,接下來我們詳細說下Redis的惰性刪除。

      惰性刪除

      何為惰性刪除,從本質上講惰性刪除就是新開一個線程異步處理數(shù)據(jù)刪除的任務。為什么要有惰性刪除?眾所周知,Redis核心流程是單線程執(zhí)行,如果某個一步執(zhí)行特別耗時,會直接影響到Redis的性能,比如刪除一個幾個G的hash key,那這個實例不直接原地升天。。 針對這種情況,需要新開啟一個線程去異步刪除,防止阻塞出Redis的主線程,當然Redis實際實現(xiàn)的時候dbAsyncDelete()并不完全是異步,我們直接看代碼。

      #define LAZYFREE_THRESHOLD 64 int dbAsyncDelete(redisDb *db, robj *key) { /* 從db->expires中刪除key,只是刪除其指針而已,并沒有刪除實際值 */ if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr); /* If the value is composed of a few allocations, to free in a lazy way * is actually just slower... So under a certain limit we just free * the object synchronously. */ /* * 在字典中摘除這個key(沒有真正刪除,只是查不到而已),如果被摘除的dictEntry不為 * 空就去執(zhí)行下面的釋放邏輯 */ dictEntry *de = dictUnlink(db->dict,key->ptr); if (de) { robj *val = dictGetVal(de); /* Tells the module that the key has been unlinked from the database. */ moduleNotifyKeyUnlink(key,val); /* lazy_free并不是完全異步的,而是先評估釋放操作所需工作量,如果影響較小就直接在主線程中刪除了 */ size_t free_effort = lazyfreeGetFreeEffort(key,val); /* 如果釋放這個對象需要做大量的工作,就把他放到異步線程里做 * 但如果這個對象是共享對象(refcount > 1)就不能直接釋放了,當然這很少發(fā)送,但有可能redis * 核心會調用incrRefCount來保護對象,然后調用dbDelete。這我只需要直接調用dictFreeUnlinkedEntry, * 等價于調用decrRefCount */ if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) { atomicIncr(lazyfree_objects,1); bioCreateLazyFreeJob(lazyfreeFreeObject,1, val); dictSetVal(db->dict,de,NULL); } } /* 釋放鍵值對所占用的內(nèi)存,如果是lazyFree,val已經(jīng)是null了,只需要釋放key的內(nèi)存即可 */ if (de) { dictFreeUnlinkedEntry(db->dict,de); if (server.cluster_enabled) slotToKeyDel(key->ptr); return 1; } else { return 0; } }

      首先在db->expires中把這個key給刪除掉,(db->expires保存了所有帶有過期時間數(shù)據(jù)的key,方便做數(shù)據(jù)過期)

      然后把這個數(shù)據(jù)節(jié)點從db中摘掉,數(shù)據(jù)實際還在內(nèi)存里,只是查不到而已。

      接下來就是要清理數(shù)據(jù)了,redis并不是直接把清理工作放到異步線程里做,而是調用lazyfreeGetFreeEffort()來評估清理工作對性能的影響,如果影響較小,就直接在主線程里做了。反之影響太大才會將刪除的任務提交到異步線程里。

      釋放key和val占用的內(nèi)存空間,如果是異步刪除,val已經(jīng)是null,這里只需要釋放key占用的空間即可。

      這里第三步中為什么異步刪除不完全是異步? 我覺得還是得從異步任務提交bioCreateLazyFreeJob()中一窺端倪。

      void bioCreateLazyFreeJob(lazy_free_fn free_fn, int arg_count, ...) { va_list valist; /* Allocate memory for the job structure and all required * arguments */ struct bio_job *job = zmalloc(sizeof(*job) + sizeof(void *) * (arg_count)); job->free_fn = free_fn; va_start(valist, arg_count); for (int i = 0; i < arg_count; i++) { job->free_args[i] = va_arg(valist, void *); } va_end(valist); bioSubmitJob(BIO_LAZY_FREE, job); } void bioSubmitJob(int type, struct bio_job *job) { job->time = time(NULL); // 多線程需要加鎖,把待處理的job添加到隊列末尾 pthread_mutex_lock(&bio_mutex[type]); listAddNodeTail(bio_jobs[type],job); bio_pending[type]++; pthread_cond_signal(&bio_newjob_cond[type]); pthread_mutex_unlock(&bio_mutex[type]); }

      我理解,在異步刪除的時候需要加鎖將異步任務提交到隊列里,如果加鎖和任務提交所帶來的性能影響大于直接刪除的影響,那么異步刪除還不如同步呢。

      定期抽樣刪除

      這里思考下另外一個問題,如果數(shù)據(jù)寫入后就再也沒有讀寫了,是不是實時清理的功能就無法觸及到這些數(shù)據(jù),然后這些數(shù)據(jù)就永遠都會占用空間。針對這種情況,Redis也實現(xiàn)了定期刪除的策略。眾所周知,Redis核心流程是單線程執(zhí)行,所以注定它不能長時間停下來去干某個特定的工作,所以Redis定期清理也是每次只做一點點。

      /* 有兩種清理模式,快速清理和慢速清理 */ void activeExpireCycle(int type) { /* Adjust the running parameters according to the configured expire * effort. The default effort is 1, and the maximum configurable effort * is 10. */ unsigned long effort = server.active_expire_effort-1, /* Rescale from 0 to 9. */ config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP + ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP/4*effort, // 每次抽樣的數(shù)據(jù)量大小 config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION + ACTIVE_EXPIRE_CYCLE_FAST_DURATION/4*effort, // 每次清理的持續(xù)時間 config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC + 2*effort, // 最大CPU周期使用率 config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE- effort; // 可接受的過期數(shù)據(jù)占比 /* This function has some global state in order to continue the work * incrementally across calls. */ static unsigned int current_db = 0; /* Last DB tested. */ static int timelimit_exit = 0; /* Time limit hit in previous call? */ static long long last_fast_cycle = 0; /* When last fast cycle ran. */ int j, iteration = 0; int dbs_per_call = CRON_DBS_PER_CALL; long long start = ustime(), timelimit, elapsed; /* When clients are paused the dataset should be static not just from the * POV of clients not being able to write, but also from the POV of * expires and evictions of keys not being performed. */ if (checkClientPauseTimeoutAndReturnIfPaused()) return; // 快速清理 if (type == ACTIVE_EXPIRE_CYCLE_FAST) { /* Don't start a fast cycle if the previous cycle did not exit * for time limit, unless the percentage of estimated stale keys is * too high. Also never repeat a fast cycle for the same period * as the fast cycle total duration itself. */ // 如果上次執(zhí)行沒有觸發(fā)timelimit_exit, 跳過執(zhí)行 if (!timelimit_exit && server.stat_expired_stale_perc < config_cycle_acceptable_stale) return; // 兩個快速清理周期內(nèi)不執(zhí)行快速清理 if (start < last_fast_cycle + (long long)config_cycle_fast_duration*2) return; last_fast_cycle = start; } /* We usually should test CRON_DBS_PER_CALL per iteration, with * two exceptions: * * 1) Don't test more DBs than we have. * 2) If last time we hit the time limit, we want to scan all DBs * in this iteration, as there is work to do in some DB and we don't want * expired keys to use memory for too much time. */ if (dbs_per_call > server.dbnum || timelimit_exit) dbs_per_call = server.dbnum; /* We can use at max 'config_cycle_slow_time_perc' percentage of CPU * time per iteration. Since this function gets called with a frequency of * server.hz times per second, the following is the max amount of * microseconds we can spend in this function. * config_cycle_slow_time_perc是清理所能占用的CPU周期數(shù)配置,這里將周期數(shù)轉化為具體的時間 */ timelimit = config_cycle_slow_time_perc*1000000/server.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = config_cycle_fast_duration; /* in microseconds. */ /* Accumulate some global stats as we expire keys, to have some idea * about the number of keys that are already logically expired, but still * existing inside the database. */ long total_sampled = 0; long total_expired = 0; for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) { /* Expired and checked in a single loop. */ unsigned long expired, sampled; redisDb *db = server.db+(current_db % server.dbnum); /* Increment the DB now so we are sure if we run out of time * in the current DB we'll restart from the next. This allows to * distribute the time evenly across DBs. */ current_db++; /* Continue to expire if at the end of the cycle there are still * a big percentage of keys to expire, compared to the number of keys * we scanned. The percentage, stored in config_cycle_acceptable_stale * is not fixed, but depends on the Redis configured "expire effort". */ do { unsigned long num, slots; long long now, ttl_sum; int ttl_samples; iteration++; /* 如果沒有可清理的,直接結束 */ if ((num = dictSize(db->expires)) == 0) { db->avg_ttl = 0; break; } slots = dictSlots(db->expires); now = mstime(); /* 如果slot的填充率小于1%,采樣的成本太高,跳過執(zhí)行,等待下次合適的機會。*/ if (num && slots > DICT_HT_INITIAL_SIZE && (num*100/slots < 1)) break; /* 記錄本次采樣的數(shù)據(jù)和其中過期的數(shù)量 */ expired = 0; sampled = 0; ttl_sum = 0; ttl_samples = 0; // 每次最多抽樣num個 if (num > config_keys_per_loop) num = config_keys_per_loop; /* 這里因為性能考量,我們訪問了hashcode的的底層實現(xiàn),代碼和dict.c有些類型, * 但十幾年內(nèi)很難改變。 * * 注意:hashtable很多特定的地方是空的,所以我們的終止條件需要考慮到已掃描的bucket * 數(shù)量。 但實際上掃描空bucket是很快的,因為都是在cpu 緩存行里線性掃描,所以可以多 * 掃一些bucket */ long max_buckets = num*20; long checked_buckets = 0; // 這里有采樣數(shù)據(jù)和bucket數(shù)量的限制。 while (sampled < num && checked_buckets < max_buckets) { for (int table = 0; table < 2; table++) { if (table == 1 && !dictIsRehashing(db->expires)) break; unsigned long idx = db->expires_cursor; idx &= db->expires->ht[table].sizemask; dictEntry *de = db->expires->ht[table].table[idx]; long long ttl; /* 遍歷當前bucket中的所有entry*/ checked_buckets++; while(de) { /* Get the next entry now since this entry may get * deleted. */ dictEntry *e = de; de = de->next; ttl = dictGetSignedIntegerVal(e)-now; if (activeExpireCycleTryExpire(db,e,now)) expired++; if (ttl > 0) { /* We want the average TTL of keys yet * not expired. */ ttl_sum += ttl; ttl_samples++; } sampled++; } } db->expires_cursor++; } total_expired += expired; total_sampled += sampled; /* 更新ttl統(tǒng)計信息 */ if (ttl_samples) { long long avg_ttl = ttl_sum/ttl_samples; /* Do a simple running average with a few samples. * We just use the current estimate with a weight of 2% * and the previous estimate with a weight of 98%. */ if (db->avg_ttl == 0) db->avg_ttl = avg_ttl; db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50); } /* We can't block forever here even if there are many keys to * expire. So after a given amount of milliseconds return to the * caller waiting for the other active expire cycle. * 不能一直阻塞在這里做清理工作,如果超時了要結束清理循環(huán)*/ if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */ elapsed = ustime()-start; if (elapsed > timelimit) { timelimit_exit = 1; server.stat_expired_time_cap_reached_count++; break; } } /* * 如果過期key數(shù)量超過采樣數(shù)的10%+effort,說明過期測數(shù)量較多,要多清理下,所以 * 繼續(xù)循環(huán)做一次采樣清理。 */ } while (sampled == 0 || (expired*100/sampled) > config_cycle_acceptable_stale); } elapsed = ustime()-start; server.stat_expire_cycle_time_used += elapsed; latencyAddSampleIfNeeded("expire-cycle",elapsed/1000); /* Update our estimate of keys existing but yet to be expired. * Running average with this sample accounting for 5%. */ double current_perc; if (total_sampled) { current_perc = (double)total_expired/total_sampled; } else current_perc = 0; server.stat_expired_stale_perc = (current_perc*0.05)+ (server.stat_expired_stale_perc*0.95); }

      代碼有些長,大致總結下其執(zhí)行流程,細節(jié)見代碼注釋。

      首先通過配置或者默認值計算出幾個參數(shù),這幾個參數(shù)直接或間接決定了這些執(zhí)行的終止條件,分別如下。

      config_keys_per_loop: 每次循環(huán)抽樣的數(shù)據(jù)量

      config_cycle_fast_duration: 快速清理模式下每次清理的持續(xù)時間

      config_cycle_slow_time_perc: 慢速清理模式下每次清理最大消耗CPU周期數(shù)(cpu最大使用率)

      config_cycle_acceptable_stale: 可接受的過期數(shù)據(jù)量占比,如果本次采樣中過期數(shù)量小于這個閾值就結束本次清理。

      根據(jù)上述參數(shù)計算出終止條件的具體值(最大采樣數(shù)量和超時限制)。

      遍歷清理所有的db。

      針對每個db在終止條件的限制下循環(huán)清理。

      總結

      Redis的數(shù)據(jù)過期策略比較簡單,代碼也不是特別多,但一如既然處處貫穿者性能的考慮。當然Redis只是提供了這樣一個功能,如果想用好的話還得根據(jù)具體的業(yè)務需求和實際的數(shù)據(jù)調整過期時間的配置,就好比我在文章開頭舉的那個例子。

      本文是Redis源碼剖析系列博文,同時也有與之對應的Redis中文注釋版,有想深入學習Redis的同學,歡迎star和關注。

      Redis中文注解版?zhèn)}庫:https://github.com/xindoo/Redis

      Redis源碼剖析專欄:https://zxs.io/s/1h

      如果覺得本文對你有用,歡迎一鍵三連。

      Redis源碼剖析之數(shù)據(jù)過期(expire)

      Redis 數(shù)據(jù)庫

      版權聲明:本文內(nèi)容由網(wǎng)絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發(fā)現(xiàn)本站中有涉嫌抄襲或描述失實的內(nèi)容,請聯(lián)系我們jiasou666@gmail.com 處理,核實后本網(wǎng)站將在24小時內(nèi)刪除侵權內(nèi)容。

      上一篇:Python語言課程設計名片管理系統(tǒng)
      下一篇:基于華為ABC應用構建云快速開發(fā)企業(yè)應用
      相關文章
      亚洲黄页网在线观看| 亚洲乱码日产精品BD在线观看| 精品国产日韩久久亚洲| 亚洲网站视频在线观看| 老司机亚洲精品影院无码| 亚洲久本草在线中文字幕| 亚洲AV第一页国产精品| 亚洲av综合色区| 久久精品7亚洲午夜a| 亚洲视频中文字幕| 久久丫精品国产亚洲av| 亚洲美女免费视频| 亚洲欧洲尹人香蕉综合| 亚洲AV无码乱码在线观看代蜜桃| 亚洲国产日韩在线成人蜜芽 | 亚洲字幕在线观看| 亚洲欧洲久久精品| 亚洲国产成人精品无码区在线秒播 | 国产亚洲精品AAAA片APP| 国产精品成人亚洲| 亚洲中久无码不卡永久在线观看| 亚洲人妻av伦理| 国产成人综合亚洲亚洲国产第一页 | 亚洲中文字幕精品久久| 久久久久亚洲精品无码网址色欲 | 久久久久亚洲精品无码网址色欲| 全亚洲最新黄色特级网站 | 亚洲一欧洲中文字幕在线| 亚洲中文字幕无码中文字| 亚洲AV色欲色欲WWW| 亚洲av成人一区二区三区在线观看| 亚洲日韩中文在线精品第一 | 亚洲午夜福利717| 亚洲AV日韩精品久久久久久| 亚洲美女视频一区二区三区| 亚洲高清中文字幕免费| 精品亚洲福利一区二区| 国产亚洲精品成人AA片新蒲金 | 色婷婷六月亚洲综合香蕉| 亚洲中文字幕无码爆乳av中文| 亚洲欧洲日产国码av系列天堂|