过期与淘汰
过期与淘汰 源码分析(惰性+定期删除 / performEvictions / lazyfree)
过期键不会在到点的一瞬间被删掉,Redis 解决的问题是:单线程如何在不卡住命令处理的前提下,把过期键和超内存时的牺牲品清理掉。
答案是三板斧——访问时顺手删(惰性删除 expireIfNeeded)、后台按时间预算抽样删(activeExpireCycle)、内存超限时按近似 LRU/LFU 挑最差的删(performEvictions),大对象的释放再甩给 lazyfree 线程,主线程只做「摘引用」这一步 O(1) 操作。
定期删除:采样 + 时间预算
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/expire.c
#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */ // 每轮每库最多采样 20 个带 TTL 的键
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */ // fast 周期总预算 1ms
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */ // slow 周期最多吃 25% CPU
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
we do extra efforts. */ // 过期键占比 >10% 才继续加班
// 定期删除的最小动作:检查一个 expires 表里的键,到期就删并传播 DEL
int activeExpireCycleTryExpire(redisDb *db, kvobj *kv, long long now) {
if (now < kvobjGetExpire(kv))
return 0;
enterExecutionUnit(1, 0);
sds key = kvobjGetKey(kv);
robj *keyobj = createStringObject(key,sdslen(key));
deleteExpiredKeyAndPropagate(db,keyobj); // 删除 + 向 AOF/副本传播 DEL(或 UNLINK),副本自己不主动过期
server.stat_expiredkeys_active++;
decrRefCount(keyobj);
exitExecutionUnit();
/* Propagate the DEL command */
postExecutionUnitOperations();
return 1;
}
void activeExpireCycle(int type) {
// effort 是 active-expire-effort 配置(1~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,
config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
ACTIVE_EXPIRE_CYCLE_FAST_DURATION/4*effort,
config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
2*effort,
config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE-
effort;
/* This function has some global state in order to continue the work
* incrementally across calls. */
static unsigned int current_db = 0; /* Next DB to test. */ // static 断点续扫:这次没扫完,下次从下一个库接着来
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;
int dbs_performed = 0;
long long start = ustime(), timelimit, elapsed;
// ... 省略 pause 检查
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
// fast 周期(每次事件循环 beforeSleep 都会试)有两个闸门:
// 1) 上次 slow 没超时且估算的过期残留低于阈值 -> 没必要跑; 2) 距上次 fast 不足 2 倍时长 -> 防止空转
if (!timelimit_exit &&
server.stat_expired_stale_perc < config_cycle_acceptable_stale)
return;
if (start < last_fast_cycle + (long long)config_cycle_fast_duration*2)
return;
last_fast_cycle = start;
}
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum; // 上次超时说明积压多,这次一口气看所有库
// 时间预算的核心公式:hz=10 时 slow 周期预算 = 25%*1s/10 = 25ms,绝不无限扫
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. */ // fast 只给 1ms
long total_sampled = 0;
long total_expired = 0;
// ... 省略断言
for (j = 0; dbs_performed < dbs_per_call && timelimit_exit == 0 && j < server.dbnum; j++) {
expireScanData data;
// ... 省略 data 初始化与 activeSubexpiresCycle(hash field TTL 的主动过期)
current_db++;
do {
unsigned long num;
iteration++;
/* If there is nothing to expire try next DB ASAP. */
if ((num = kvstoreSize(db->expires)) == 0) { // 只扫 expires 表(设过 TTL 的键),不碰主表
db->avg_ttl = 0;
break;
}
data.now = mstime();
data.sampled = 0;
data.expired = 0;
if (num > config_keys_per_loop)
num = config_keys_per_loop; // 一轮最多采样 20*effort 个键
long max_buckets = num*20; // 空桶扫描便宜(顺序读 NULL 数组),但也设上限防止全空表白扫
long checked_buckets = 0;
// ... 省略 avg_ttl 采样变量
while (data.sampled < num && checked_buckets < max_buckets) {
db->expires_cursor = kvstoreScan(db->expires, db->expires_cursor, -1, expireScanCallback, expirySamplingShouldSkipDict, &data);
if (db->expires_cursor == 0) { // cursor 归零 = 本库扫完一整圈
db_done = 1;
break;
}
checked_buckets++;
}
total_expired += data.expired;
total_sampled += data.sampled;
// ... 省略 avg_ttl 统计(0.98 加权滑动平均, 见 avg_ttl_factor 常量表)
// 自适应的关键:本轮采样中过期比例 >10% 就再来一轮,说明该库脏得厉害
repeat = db_done ? 0 : (data.sampled == 0 || (data.expired * 100 / data.sampled) > config_cycle_acceptable_stale);
if ((iteration & 0xf) == 0 || !repeat) { /* Update the average TTL stats every 16 iterations or about to exit. */
// ... 省略 avg_ttl 写回
if ((iteration & 0xf) == 0) { /* check time limit every 16 iterations. */
elapsed = ustime()-start; // 每 16 轮才查一次表,ustime 是系统调用,不能每轮都查
if (elapsed > timelimit) {
timelimit_exit = 1; // 记下"我是被时间预算掐断的",影响下次 fast/slow 的行为
server.stat_expired_time_cap_reached_count++;
break;
}
}
}
} while (repeat);
}
// ... 省略 latency 统计
/* 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)+ // 全局过期残留估计值,fast 周期用它决定跑不跑
(server.stat_expired_stale_perc*0.95);
}淘汰:evictionPool 采样 + performEvictions
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/evict.c
#define EVPOOL_SIZE 16
#define EVPOOL_CACHED_SDS_SIZE 255
struct evictionPoolEntry {
unsigned long long idle; /* Object idle time (inverse frequency for LFU) */ // 统一叫 idle,其实是"越大越该死"的分数
sds key; /* Key name. */
sds cached; /* Cached SDS object for key name. */ // 预分配 255 字节复用,短 key 免 malloc/free
int dbid; /* Key DB number. */
int slot; /* Slot. */
};
// 近似 LRU 的空闲时间:robj 只有 24 位 lru 字段(object.h: unsigned lru:LRU_BITS),
// 存的是秒级时钟的低 24 位,约 194 天回绕一次,else 分支处理回绕
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
/* Logarithmically increment a counter. The greater is the current counter value
* the less likely is that it gets really incremented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) { // LFU 复用同一个 24 位字段:高 16 位=分钟级访问时间, 低 8 位=对数计数器
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL; // 新键从 LFU_INIT_VAL=5 起步,避免刚写入就被当"从没访问过"淘汰
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++; // 概率递增:counter 越大越难加。8 位就能表示百万级访问量
return counter;
}
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8; // 高 16 位:上次访问时间(分钟, 会回绕)
unsigned long counter = o->lru & 255; // 低 8 位:对数频率
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods; // 每过 decay_time 分钟衰减 1,昔日热键会慢慢凉
return counter;
}
int evictionPoolPopulate(redisDb *db, kvstore *samplekvs, struct evictionPoolEntry *pool) {
int j, k, count;
dictEntry *samples[server.maxmemory_samples]; // 默认 5 个:不做全局排序,随机抓几个跟池子里的比
/* Don't retry, since we will call evictionPoolPopulate multiple times if needed. */
int slot = kvstoreGetFairRandomDictIndex(samplekvs, randomEvictionShouldSkipDictIndex, 1, 0);
if (slot == -1) return 0;
count = kvstoreDictGetSomeKeys(samplekvs,slot,samples,server.maxmemory_samples);
for (j = 0; j < count; j++) {
unsigned long long idle;
// ... 省略取 kv/key
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LRM)) {
idle = estimateObjectIdleTime(kv);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
idle = 255-LFUDecrAndReturn(kv); // LFU 取反装进同一个池:频率越低分数越高
} else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
/* In this case the sooner the expire the better. */
idle = ULLONG_MAX - kvobjGetExpire(kv); // TTL 策略也走池:到期越早分数越高
} else {
serverPanic("Unknown eviction policy in evictionPoolPopulate()");
}
/* Insert the element inside the pool. */
k = 0;
while (k < EVPOOL_SIZE &&
pool[k].key &&
pool[k].idle < idle) k++; // 池按 idle 升序,右边最该淘汰
if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) {
continue; // 比池里最差的还"新",进不了池
}
// ... 省略 memmove 插入/挤掉最左元素、cached sds 复用的细节
}
return count;
}
int performEvictions(void) {
if (!isSafeToPerformEvictions()) return EVICT_OK; // loading/长脚本/副本忽略 maxmemory 时直接跳过
// ... 省略局部变量
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK) {
result = EVICT_OK; // 没超 maxmemory(计算时扣掉了副本输出缓冲和 AOF buffer),什么都不用做
goto update_metrics;
}
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION) {
result = EVICT_FAIL; /* We need to free memory, but policy forbids. */ // 之后写命令会收到 OOM 错误
goto update_metrics;
}
unsigned long eviction_time_limit_us = evictionTimeLimitUs(); // tenacity 配置换算出的单次时间预算
// ... 省略计时器初始化
while (mem_freed < (long long)mem_tofree) {
// ... 省略局部变量
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU|MAXMEMORY_FLAG_LRM) ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
{
struct evictionPoolEntry *pool = EvictionPoolLRU;
while (bestkey == NULL) {
// ... 省略:对每个 db,按策略选 db->keys(allkeys-*) 或 db->expires(volatile-*) 做采样
sampled_keys += evictionPoolPopulate(db, kvs, pool);
// ... 省略采样上限判断
/* Go backward from best to worst element to evict. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) { // 从池的右端(最该淘汰)往回找
if (pool[k].key == NULL) continue;
// ... 省略取 kvs
de = kvstoreDictFind(kvs, pool[k].slot, pool[k].key);
/* Remove the entry from the pool. */
if (pool[k].key != pool[k].cached)
sdsfree(pool[k].key);
pool[k].key = NULL;
pool[k].idle = 0;
/* If the key exists, is our pick. Otherwise it is
* a ghost and we need to try the next element. */
if (de) { // 池是跨调用持久的,键可能早被删了(ghost),所以要验证存在性
bestkey = kvobjGetKey(dictGetKV(de));
break;
}
}
}
}
/* volatile-random and allkeys-random policy */
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
{
// ... 省略:轮转 next_db,随机取一个键,不经过池
}
/* Finally remove the selected key. */
if (bestkey) {
// ... 省略 createStringObject
deleteEvictedKeyAndPropagate(db, keyobj, &key_mem_freed); // 按 lazyfree-lazy-eviction 决定同步删还是丢给 lazyfree
// ... 省略计数
if (keys_freed % 16 == 0) { // 每删 16 个键歇口气做三件事:
if (slaves) flushSlavesOutputBuffers(); // 1. 把积压的 DEL 推给副本,防止淘汰风暴撑爆输出缓冲
if (server.lazyfree_lazy_eviction) {
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
break; // 2. 异步删时 mem_freed 统计不到后台释放,直接重新问一次内存够不够
}
}
if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
startEvictionTimeProc(); // 3. 超预算就注册 0ms 定时事件,下一轮事件循环接着淘汰,别卡当前命令
break;
}
}
} else {
goto cant_free; /* nothing to free... */
}
}
result = (isEvictionProcRunning) ? EVICT_RUNNING : EVICT_OK;
cant_free:
if (result == EVICT_FAIL) {
// 没东西可删了,但 lazyfree 线程可能还在释放:短暂等它一下,说不定内存就够了
while (bioPendingJobsOfType(BIO_LAZY_FREE) &&
elapsedUs(evictionTimer) < eviction_time_limit_us) {
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
result = EVICT_OK;
break;
}
usleep(eviction_time_limit_us < 1000 ? eviction_time_limit_us : 1000);
}
// ... 省略 latency 统计
}
// ... 省略 update_metrics
return result;
}lazyfree:主线程摘引用,后台线程慢慢拆
代码块收起展开
// 基于本地 Redis 仓 (unstable), src/lazyfree.c
/* Release objects from the lazyfree thread. It's just decrRefCount()
* updating the count of objects to release. */
void lazyfreeFreeObject(void *args[]) { // 跑在 bio 后台线程里,真正的 free 在这
robj *o = (robj *) args[0];
decrRefCount(o);
atomicDecr(lazyfree_objects,1);
atomicIncr(lazyfreed_objects,1);
}
// 释放代价估算:不是字节数,是"要 free 多少次"的量级
size_t lazyfreeGetFreeEffort(robj *key, robj *obj, int dbid) {
if (obj->type == OBJ_LIST && obj->encoding == OBJ_ENCODING_QUICKLIST) {
quicklist *ql = obj->ptr;
return ql->len; // quicklist 节点数,不是元素数
} else if (obj->type == OBJ_SET && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
return dictSize(ht);
} else if (obj->type == OBJ_ZSET && obj->encoding == OBJ_ENCODING_SKIPLIST){
zset *zs = obj->ptr;
return zs->zsl->length;
} else if (obj->type == OBJ_HASH && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
return dictSize(ht);
// ... 省略 stream/module 的估算
} else {
return 1; /* Everything else is a single allocation. */ // 关键:listpack/intset/embstr 都是一整块内存,free 一次就完
}
}
/* If there are enough allocations to free the value object asynchronously, it
* may be put into a lazy free list instead of being freed synchronously. The
* lazy free list will be reclaimed in a different bio.c thread. 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. */
#define LAZYFREE_THRESHOLD 64
/* Free an object, if the object is huge enough, free it in async way. */
void freeObjAsync(robj *key, robj *obj, int dbid) {
size_t free_effort = lazyfreeGetFreeEffort(key,obj,dbid);
if (free_effort > LAZYFREE_THRESHOLD && obj->refcount == 1) { // refcount 必须为 1:引用计数不是线程安全的,
atomicIncr(lazyfree_objects,1); // 共享中的对象不能交给别的线程去 decr
bioCreateLazyFreeJob(lazyfreeFreeObject,1,obj);
} else {
decrRefCount(obj); // 小对象同步删更快:投递任务+线程唤醒的开销比 free 64 次还贵
}
}原理串讲
先看惰性删除。所有读写都经过 lookupKey*() 家族,它们统一调 db.c 里的 expireIfNeeded(db, key, kv, flags):
代码块收起展开
先用 `keyIsExpired()` 拿 `getExpire()` 的到期毫秒数跟 `commandTimeSnapshot()` 比(同一条命令内时间快照固定,保证 Lua 脚本里多次读同一个键结果一致),没过期返回 KEY_VALID;
过期了但自己是副本,只返回 KEY_EXPIRED 让上层"装作键不存在",绝不真删——删除权在主库,主库删了会传播 DEL 过来,这样主从的数据集才逐字节一致;主库则调 deleteExpiredKeyAndPropagate() 真删并向 AOF/副本传播,返回 KEY_DELETED。
为什么读命令也可能触发写传播?因为过期删除本质是一次状态变更,不传播的话 AOF 重放和副本上这个键就还魂了。
惰性删除的死角是”再也没人访问的过期键”,它们会永远占着内存,所以需要定期删除兜底。serverCron 每秒 hz 次调 activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW),每次事件循环的 beforeSleep 再补一个 FAST 周期。
它不遍历全库——那是 O(N) 的事件循环杀手——而是对每个库的 expires 表用 kvstoreScan 游标式采样约 20 个键,调 activeExpireCycleTryExpire 删掉到期的;如果本轮过期比例超过 10%,说明这个库很脏,do...while(repeat) 继续采样。
为什么用”过期比例”当停止条件而设计成自适应?因为它是个无偏估计:随机样本里 10% 过期,大致说明整个表 10% 过期,Redis 用这一个数字就把”内存浪费上限约 10%(可用 effort 调)“变成了可承诺的指标,同时把 CPU 花销钉死在 timelimit = 25%/hz 的预算内——宁可留一点垃圾,也不让清理拖慢命令,这和 G1 设 pause target 是同一种哲学。
static 的 current_db、timelimit_exit 让工作跨调用断点续传,超时中断的信息还会反过来触发下一次 FAST 周期,形成闭环。
淘汰走另一条路。processCommand 在执行可能增加内存的命令前调 performEvictions():getMaxmemoryState 算出超了多少(mem_tofree),然后循环挑键删除直到够数。
八种 maxmemory 策略在这里分成三类:
- noeviction 直接返回 EVICT_FAIL,写命令报 OOM;
- volatile-random/allkeys-random 随机抓键不排序;
- 其余(volatile-lru/lfu/ttl、allkeys-lru/lfu,这个 unstable 仓还新增了 volatile-lrm/allkeys-lrm 按最近修改时间淘汰)全部复用同一个 evictionPool 框架——volatile 系列从
db->expires采样,allkeys 系列从db->keys采样,差别仅此而已。
为什么是”池采样”而不是全局排序?精确 LRU 需要像 Java LinkedHashMap(accessOrder=true) 那样维护一条按访问序的双向链表,每次 GET 都要摘链插链,还要给每个 robj 加两个 8 字节指针——几千万个键就是上 GB 的额外内存。
Redis 的方案是每个 robj 只留 24 位的 lru 字段(LRU 模式存秒级时钟低 24 位,LFU 模式拆成 16 位分钟时间戳 + 8 位对数计数器),淘汰时 evictionPoolPopulate 随机采 maxmemory_samples(默认 5)个键,跟一个大小 16、按 idle 升序的池子比,够格才插入;performEvictions 从池的右端取最老的键删。
池是跨调用持久的,相当于把历史采样的”最差候选”记忆下来,所以实际效果接近采样 16+5 个——官方实测 10 个样本的近似 LRU 已经和精确 LRU 的命中率曲线基本重合,这就是用 3 字节换掉 16 字节链表指针的交易。
取键时还要 kvstoreDictFind 验证一次,因为池里可能躺着早已被删的 ghost。
LFU 为什么不用真实计数器?8 位最多存 255,于是 LFULogIncr 做概率递增:counter 越大,加一的概率越小(p = 1/(baseval*lfu_log_factor+1)),对数尺度下 255 能表达百万级访问;LFUDecrAndReturn 再按 lfu_decay_time 让计数随时间衰减,否则历史热键会永远霸占高频段,算法就无法适应访问模式的变化。
注意衰减是读时惰性计算的,不需要后台线程去扫全库减数。
最后是 lazyfree。同步 decrRefCount 一个几百万字段的 hash 意味着几百万次 free,主线程会卡几百毫秒——单线程模型里这等于所有客户端一起卡。
freeObjAsync 先用 lazyfreeGetFreeEffort 估算释放代价(按”free 的次数”而非字节数:一个 100MB 的 embstr 字符串 effort 是 1,同步 free 反而是 O(1),根本不需要异步),超过 64 且 refcount==1 才投给 bio 的 BIO_LAZY_FREE 线程。
为什么必须单独线程而不是主线程分片释放?因为 free 本身无法像 scan 那样天然断点续传,而 dict 一旦从 keyspace 摘除(emptyDbAsync 里是直接换一套新的空 kvstore),旧结构就再没有并发访问,后台线程可以毫无锁地慢慢拆——这正是 UNLINK 与 DEL、FLUSHALL ASYNC 与 FLUSHALL 的区别:主线程只付出”摘引用”的 O(1) 成本,O(N) 的拆解在别的核上进行。
refcount==1 的限制则是因为引用计数的增减不是原子操作+对象可能仍被主线程共享持有,交出去就会产生数据竞争。
淘汰路径与 lazyfree 还有一处配合:performEvictions 每删 16 个键就重查内存水位(异步释放的内存不计入 mem_freed),实在无键可删时也会短暂等待 BIO_LAZY_FREE 队列,给后台线程一个”追上来”的机会。
设计取舍
- 过期删除的两策略是”CPU 换内存”的两端:惰性删除零后台开销但可能长期漏删,定期删除补漏但要花 CPU;Redis 用时间预算(25%/hz)+ 残留比例目标(约 10%)把两头都钉死,而非追求”到期即删”。
- 近似 LRU 用 24 位字段 + 16 元素池换掉了全局链表:省掉每对象 16 字节指针和每次访问的链表操作,代价是”删掉的不保证是全局最老的”,只保证”是采样见过的里面最老的”。
- volatile-lru 常见误区:它只在
db->expires里采样,如果没有键设置 TTL,它和 noeviction 一样删不出东西,performEvictions 直接走到 cant_free 返回 EVICT_FAIL。 - LFU 的 8 位计数器是对数刻度,counter=255 不代表 255 次访问而可能是百万次;比较两个键的冷热可以,还原访问次数不行。新键从 5 起步 + 按分钟衰减,分别防”新键秒死”和”僵尸热键”。
- lazyfree 不是越大越该异步:effort 按 free 次数算,整块内存的大对象(embstr/listpack/intset)同步释放就是 O(1);阈值 64 以下同步删反而更快,因为投递 bio 任务本身有固定开销。