问题:
目前悬赏贴悬赏有效期为5天,余额只存储在redis中,集群为status集群,key:st:offer:balance:{statusId},缓存有效期为6天。当进行分配赏金时,会查询该贴的余额。有问题的悬赏贴,相应的redis key被删除。 |
分析:
首先,大致看了下业务代码和 redis 组件相关的实现,以及 24/25 号的日志,没有发现值得怀疑的点;然后,根据上述的时间范围,去监控面板找异常,还真找到了。。。 24/25号前后 redis 内存占用不正常(具体原因需要业务排查),下图是过去 10 天的一个占用走势
对比下图的 redis 最大内存配置,会发现 8 个分片的主库内存在 2020.06.24 12:00 - 2020.06.25 03:00 期间都触达了最大值
内存淘汰策略相当于清除掉那些占用内存并且使用不太频繁的数据,淘汰掉这些不活跃数据来清理内存
我们知道,redis设置配置文件的maxmemory参数,可以控制其最大可用内存大小(字节)。
那么当所需内存,超过maxmemory怎么办?
这个时候就该配置文件中的maxmemory-policy出场了。
其默认值是noeviction。
下面我将列出当可用内存不足时,删除redis键具有的淘汰规则。
就会根据配置的清理策略,redis开始干活了
- volatile-lru
使用LRU算法,从设置了过期时间的key中选择删除
- allkeys-lru
使用LRU算法,从所有key中选择删除
- volatile-random
从设置了过期时间的key中随机删除
- allkeys-random
从所有的key中随机删除
- volatile-ttl
从设置了过期时间的key中选择最先过期的删除
- noeviction
不处理,当有写操作时,直接返回错误
LRU算法,least RecentlyUsed,最近最少使用算法。也就是说默认删除最近最少使用的键。
但是一定要注意一点!redis中并不会准确的删除所有键中最近最少使用的键,而是随机抽取5个键,删除这五个键中最近最少使用的键。
那么5这个数字也是可以设置的,对应位置是配置文件中的maxmeory-samples.
redis 统计还提供了另一个数据,叫 evicted_keys,可以看到分片强制淘汰了多少个 key,其他分片大致类似
改进
redis的内存占用本来就是件应当关注的事情
1.增加redis内存监控报警
2.加强使用人员对redis使用的方式了解,结合自身场景选择合适的配置
相关总结
DEL 和 UNLINK区别?同步还是异步?
使用DEL命令会触发「同步删除」,如果Key是一个有很多元素的复杂类型,这个过程可能会堵塞一下Redis服务自身,从而影响用户的访问。
使用UNLINK命令,Redis服务会先计算删除Key的成本,从而更智能地做出「同步删除」或「异步删除」的选择。
成本计算:
对于list,hash,set,zset的对象类型,如果长度大于64(由宏LAZYFREE_THRESHOLD定义),才会采用异步删除的手段,从当前db先释放该key,再由另外一个线程做异步删除。对于长度不大于64的复杂类型,异步删除比同步删除还多了一些函数调用与多线程同步的代价,所以同步删除更好。对于string对象,底层的数据结构sds是一份连续的内存,内存分配器回收这块内存的复杂度是O(1),所以采用同步删除也不会堵塞服务。
总的来说,我们作为用户,都能用UNLINK替代DEL。
驱逐策略
Redis通过参数maxmemory来选择不同的驱逐策略:
- volatile-random 从已设置过期时间的数据集(server.db[i].expires)中任意选择数据驱逐;volatile-lru 从数据集(server.db[i].dict)中挑选最近最少使用的数据驱逐(2.8默认);
- volatile-ttl 从已设置过期时间的数据集(server.db[i].expires)中寻找最近即将过期(ttl最小)的key来驱逐;
- allkeys-random 从数据集(server.db[i].dict)中任意选择数据驱逐;
- allkeys-lru 从数据集(server.db[i].dict)中挑选最近最少使用的数据驱逐;
- noeviction 禁止驱逐数据,永远不驱逐,仅对写操作返回一个错误(4.0默认);
在4.0版本后,还增加了以下两种驱逐策略。
- volatile-lfu在过期集合中使用LFU链来驱逐数据;
- allkeys-lfu 从数据集(server.db[i].dict)使用LFU算法来驱逐数据;
4.0后,在返回写入失败前,还会先检测lazyfree线程是否还有待删除的Key,没有才会给用户返回写入失败。
在4.0或以上的版本,Key的驱逐会基于参数lazyfree_lazy_eviction,来决定采用unlink还是del。在2.8版本,则只会用del。
Key的访问淘汰
对于Slave节点,访问到了已过期的Key,Slave节点会返回该Key不存在,但不会主动删除该Key。删除的动作,还是会从Master上同步过来。
对于Master节点,在4.0或以上的版本,会根据参数lazyfree-lazy-expire,来决定用DEL还是UNLINK。对于2.8版本,则只能用DEL了。这些删除的动作,都会同步到Slave与AOF文件中。
Key的定时淘汰
多久会执行一次定时调度呢?
redis服务的参数hz能控制定时淘汰的频率,hz默认是10,即每秒能调度100次。
定时淘汰一定是在master上发生的吗?
有些时候,用户会把Slave节点设置成可写,那么Slave上写的带有过期时间的Key,因为Master是不知道的,就一直不会淘汰掉。所以在版本4.0以后,Redis增加了单独的逻辑,在定时淘汰中删除这些在slave节点上写入的过期Key。
“FAST淘汰”和“SLOW淘汰”?
前者每次淘汰只能花1毫秒,不能花更多了,后者是以hz=10为例,每次调度的总时间是100ms,这里调度不会25%的cpu时间,即25ms。
如果每淘汰1个Key就检测一次,无疑代价太大。从源码上看,定时淘汰会尝试遍历每个db,遍历完了或者时间到了就退出循环。第一层循环是遍历各个db,第二层循环是遍历db里面的一批批key,一批key是20个,如果第三层循环结束后有大于5个key是成功淘汰的(说明这个db很多淘汰key),那么二层就继续循环,如果小于等于5个key,说明这个db没有很多key需要淘汰,则退出二层循环,第三层循环是一批key里面逐个key进行淘汰。即最多320个key进行判断后,就会看看是否已经超过cpu占用时间。
在4.0或以上的版本,会根据参数lazyfree-lazy-expire(默认no)来做DEL还是UNLINK。
总结
1.驱逐策略的选择,往往与业务特点、使用场景紧密相关。不当的选择,可能会让用户丢失不想丢失的数据,或者导致较差的驱逐效率;
2.已过期的Key往往不会立刻被删除,用户在导出快照与建立主从时,会疑惑主从之间的Key数量不一致,我们都需要了解这一点;
3.驱逐与淘汰都有可能影响服务,在新版本下,最好都开启unlink代替del。
扩展unlink和异步线程
单线程为什么还能这么快?
1 基于内存操作:Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高;
2 数据结构简单:Redis 的数据结构比较简单,是为 Redis 专门设计的,而这些简单的数据结构的查找和操作的时间复杂度都是 O(1),因此性能比较高;
3 多路复用和非阻塞 I/O:Redis 使用 I/O 多路复用功能来监听多个 socket 连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作,从而大大提高了 Redis 的性能;
4 避免上下文切换:因为是单线程模型,因此就避免了不必要的上下文切换和多线程竞争,这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生。
redis是单进程单线程模式吗?
在不同的 Redis 版本下答案是不同的,在 Redis 4.0 之前,Redis 是单线程运行的,但单线程并不意味着性能低,类似单线程的程序还有 Nginx 和 NodeJs 他们都是单线程程序,但是效率并不低。 Redis 的 FAQ(Frequently Asked Questions,常见问题)也回到过这个问题
参见:https://redis.io/topics/faq 大体意思是说 Redis 是基于内存操作的,因此他的瓶颈可能是机器的内存或者网络带宽而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了,况且使用多线程比较麻烦。但是在 Redis 4.0 中开始支持多线程了,例如后台删除等功能。
简单来说 Redis 之所以在 4.0 之前一直采用单线程的模式是因为以下三个原因:
1 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
2 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是多路复用(详见本文下半部分);
3 对于 Redis 系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。
Redis 6.0 多线程
1 单线程的机制导致 Redis 的 QPS(Query Per Second,每秒查询率)很难得到有效的提高。
2 Redis 4.0 版本中虽然引入了多线程,但此版本中的多线程只能用于大数据量的异步删除,然而对于非删除操作的意义并不是很大。
feature:使用多线程就可以分摊 Redis 同步读写 I/O 的压力,以及充分的利用多核 CPU 的资源,并且可以有效的提升 Redis 的 QPS。在 Redis 中虽然使用了 I/O 多路复用,并且是基于非阻塞 I/O 进行操作的,但 I/O 的读和写本身是堵塞的,比如当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给 Redis 调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。
fix:因此在 Redis 6.0 中新增了多线程的功能来提高 I/O 的读写性能,他的主要实现思路是将主线程的 IO 读写任务拆分给一组独立的线程去执行,这样就可以使多个 socket 的读写可以并行化了,但 Redis 的命令依旧是由主线程串行执行的。
Redis 6.0 默认是禁用多线程的,可以通过修改 Redis 的配置文件 redis.conf 中的 io-threads-do-reads 等于 true 来开启多线程,完整配置为 io-threads-do-reads true,除此之外我们还需要设置线程的数量才能正确的开启多线程的功能,同样是修改 Redis 的配置,例如设置 io-threads 4 表示开启 4 个线程。
antirez 在 RedisConf 2019 分享时曾提到,Redis 6 引入的多线程 I/O 特性对性能提升至少是一倍以上。
redis访问过期key删除时在主线程里吗?
redis增加了意不删除命令unlink,防止删除大key阻塞主线程:原理是执行unlink时会将需要删除的数据挂到一个链表中,由专门的线程负责删除
通常情况下使用 del 指令可以很快的删除数据,而当被删除的 key 是一个非常大的对象时,例如时包含了成千上万个元素的 hash 集合时,那么 del 指令就会造成 Redis 主线程卡顿,因此使用惰性删除可以有效的避免 Redis 卡顿的问题。
例如 unlink key / flushdb async / flushall async 等命令
redis渐进式删除
参见:http://antirez.com/news/93 大意是:把要删除的对象放到一个链表中,起一个定期任务(作者设计一种自适应的删除,通过判断内存是增加还是减少,来动态调整删除任务执行频率),每次只删除其中一部分
redis异步线程
首先消灭redis之前版本中的共享对象(解决这种问题修改了保存结构,详细看源码),这种共享数据越多多线程的竞争越大,越影响性性能
多线程在内存分配上的冲突
的意思是redis在内存分配上使用的时间极少,可以忽略
redis key过期源码分析
redis的所有数据结构都可以设置过期时间,当key过期后再查询该key返回null
redis4.0版本引入了异步删除的机制,对于删除对象大小大于64字节的key,先通过Unlink方法软删除后放入回收队列中,由其他线程异步回收内存空间,减少主线程的在内存回收上消耗的时间。
主动删除
对key删改查操作时先判断该key是否已过期,如过期则删除回收该key
在redis主从架构下,从节点不处理过期机制,通过等待主节点的指令直接删除对应的key。因此当网络延迟较大时,存在主节点中已过期的key能在从节点查询出来的问题。
robj *lookupKeyRead(redisDb *db, robj *key) {
// 查询操作
return lookupKeyReadWithFlags(db,key,LOOKUP_NONE);
}
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
robj *val;
// 查询key的值前先检查该key是否已过期
// 如果key过期则删除回收该key
if (expireIfNeeded(db,key) == 1) {
if (server.masterhost == NULL) {
server.stat_keyspace_misses++;
notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
return NULL;
}
if (server.current_client &&
server.current_client != server.master &&
server.current_client->cmd &&
server.current_client->cmd->flags & CMD_READONLY)
{
server.stat_keyspace_misses++;
notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
return NULL;
}
}
// key未过期,查询key对应的值
val = lookupKey(db,key,flags);
if (val == NULL) {
server.stat_keyspace_misses++;
notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
}
else
server.stat_keyspace_hits++;
return val;
}
int expireIfNeeded(redisDb *db, robj *key) {
// 查询redisDb.expires判断该key是否过期
if (!keyIsExpired(db,key)) return 0;
// 从节点不处理过期机制,等待主节点的指令直接删除对应的key
// 因此存在网络延迟大时,已过期的key能在从节点查询出来的问题
if (server.masterhost != NULL) return 1;
server.stat_expiredkeys++;
propagateExpire(db,key,server.lazyfree_lazy_expire);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
// 默认使用dbAsyncDelete异步删除回收
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
定时删除
主线程每100毫秒从redisDb.expires中随机选择20个key,删除其中已过期的key。如果过期的key的比例超过1/4则重复该操作直到达到时间上限(默认25毫秒)
databasesCron方法由主线程每100毫秒调用一次,其中的activeExpireCycle方法则是删除回收过期key的关键方法,其入参有两个值:ACTIVE_EXPIRE_CYCLE_FAST和ACTIVE_EXPIRE_CYCLE_SLOW:
1 ACTIVE_EXPIRE_CYCLE_FAST:表示快速删除回收过期key,该场景下每次删除回收的时间上限为1毫秒,当主线程处理完外部请求后等待新请求前阻塞时会使用该参数;
2 ACTIVE_EXPIRE_CYCLE_SLOW:表示慢删除回收过期key,该场景下每次删除回收的时间上限为25毫秒,当主线程每100毫秒执行定时任务时使用该参数;
由于主线程每100毫秒会调用一次activeExpireCycle方法回收过期key,因此存在极端情况下同一时刻redis中大量数据同时过期,会导致每100毫秒一次的定时任务activeExpireCycle需要花费25毫秒的时间删除回收过期key,从而出现客户端请求等待阻塞的情况。
void databasesCron(void) {
if (server.active_expire_enabled) {
if (server.masterhost == NULL) {
// 定时任务使用慢扫描回收,扫描回收的时间上限为25毫秒
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
} else {
expireSlaveKeys();
}
}
...
}
#define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20
void activeExpireCycle(int type) {
...
// timelimit = 1000000*25/10/100 = 25_000微妙 = 25毫秒
timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
timelimit_exit = 0;
if (timelimit <= 0) timelimit = 1;
// 快速扫描回收,扫描回收时间上限为1毫秒
// 慢扫描回收,扫描回收时间上限默认timelimit = 25毫秒
if (type == ACTIVE_EXPIRE_CYCLE_FAST)
timelimit = ACTIVE_EXPIRE_CYCLE_FASTf_DURATION; /* in microseconds. */
long total_sampled = 0;
long total_expired = 0;
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
int expired;
redisDb *db = server.db+(current_db % server.dbnum);
current_db++;
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();
if (num && slots > DICT_HT_INITIAL_SIZE &&
(num*100/slots < 1)) break;
expired = 0;
ttl_sum = 0;
ttl_samples = 0;
// 每次从redisDb.expires随机获取20个key
if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
while (num--) {
dictEntry *de;
long long ttl;
// 从redisDb.expires随机获取一个key
if ((de = dictGetRandomKey(db->expires)) == NULL) break;
ttl = dictGetSignedIntegerVal(de)-now;
// 如果key已过期,默认调用dbAsyncDelete异步删除回收,并累计过期的key数量
if (activeExpireCycleTryExpire(db,de,now)) expired++;
if (ttl > 0) {
ttl_sum += ttl;
ttl_samples++;
}
total_sampled++;
}
total_expired += expired;
if (ttl_samples) {
long long avg_ttl = ttl_sum/ttl_samples;
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
}
// 如果过期key的扫描回收时间达到了上限,则结束此次扫描回收操作
if ((iteration & 0xf) == 0) {
elapsed = ustime()-start;
if (elapsed > timelimit) {
timelimit_exit = 1;
server.stat_expired_time_cap_reached_count++;
break;
}
}
// 每次随机获取的20个key如果超过1/4已经过期,则重复操作删除回收过期的key
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
...
}
异步删除
在redis4.0版本默认调用dbAsyncDelete方法删除回收key,但实际上dbAsyncDelete会判断该key的对象大小,如果key的对象大小超过64字节时才会真正使用异步删除逻辑,将该key放入BIO队列由其他线程删除回收内存空间;否则仍然使用同步删除逻辑直接回收内存空间。
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
dictEntry *de = dictUnlink(db->dict,key->ptr);
if (de) {
robj *val = dictGetVal(de);
size_t free_effort = lazyfreeGetFreeEffort(val);
// 如果待删除key的长度超过64字节,异步删除
if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
atomicIncr(lazyfree_objects,1);
// 放入BIO队列中由其他进程回收内存空间
bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
dictSetVal(db->dict,de,NULL);
}
}
// 待删除key长度不超过64字节,依然使用同步删除
if (de) {
dictFreeUnlinkedEntry(db->dict,de); // 同步回收内存空间
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
return 0;
}
}