前言
redis 在4.0之后就推出了异步删除,其中相关的最直接的命令就是unlink, 那如何去用unlink,它是否能够取代del 命令,我们从源码层面,来好好的剖析一下,阅读下面文章之前,我们先怀着以下几个疑问去看。
- 异步删除会存在并发问题吗?
- 它是怎么解决并发问题的?
- 为什么要异步删除?
unlink 一个拯救性能的英雄?
我们话不多说,先来直接上最核心的代码
unlink 入口代码
void unlinkCommand(client *c) {
//传入lazy 为1
delGenericCommand(c,1);
}
del 和 lazy del都会走到这里,这里有个意思的点就是如果被访问到的key 如果已经是一个过期key,那么直接会走过期流程,那么这个删除键可能就不是异步而是由expire 的流程来决定。
/* This command implements DEL and LAZYDEL. */
void delGenericCommand(client *c, int lazy) {
int numdel = 0, j;
//获取客户端传过来的变量
for (j = 1; j < c->argc; j++) {
//当前键是否过期,如果过期要走
expireIfNeeded(c->db,c->argv[j]);
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
dbSyncDelete(c->db,c->argv[j]);
if (deleted) {
//通知是否被watched key
signalModifiedKey(c,c->db,c->argv[j]);
//提供一个通知事件入口,为后续代码做扩展
notifyKeyspaceEvent(NOTIFY_GENERIC,
"del",c->argv[j],c->db->id);
server.dirty++;
numdel++;
}
}
//客户端返回参数
addReplyLongLong(c,numdel);
}
下面的代码有几个关键的信息点
- expired map(过期键的集合,看我上篇的文章应该知道是怎么回事),过期键的集合不参与会直接被回收内存。
- 不是调用了unlink 就会异步删除,而是对应的value 超过了一定的空间长度(空间长度不是字节数)这里要注意
- unlink 不是所有行为都是异步,而是只针对big value,且异步空间回收也是只针对bigvalue,其它的空间回收还是在同步代码块里面
/*
* Delete a key, value, and associated expiration entry if any, from the DB.
* If there are enough allocations to free the value object 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.
* */
/**
* 因为每个删除的key 其实对应的是一个entry,如果这个entry的value的空间长度超过我们设置的阈值,那么我们会
* 将value 放入 一个list 里面去,如果通过bio.c 里面提供的子线程进行空间的回收
*/
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
/* Deleting an entry from the expires dict will not free the sds of
* the key, because it is shared with the main dictionary. */
//首先expire 这个集合是不支持异步的所以会立即删除掉集合里面的数据
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. */
//先在map 总执行unlink 操作,保证元素在map 中移除掉,并得到对应Entry Map
dictEntry *de = dictUnlink(db->dict,key->ptr);
if (de) {
//从entry找到val
robj *val = dictGetVal(de);
//根据不同的类型得出val 所占用得字节数
//size_t 是c语言常用来表示空间长度的类型,是可以跟long 类型互转
size_t free_effort = lazyfreeGetFreeEffort(val);
/* If releasing the object is too much work, do it in the background
* by adding the object to the lazy free list.
* Note that if the object is shared, to reclaim it now it is not
* possible. This rarely happens, however sometimes the implementation
* of parts of the Redis core may call incrRefCount() to protect
* objects, and then call dbDelete(). In this case we'll fall
* through and reach the dictFreeUnlinkedEntry() call, that will be
* equivalent to just calling decrRefCount(). */
//如果val长度超过64个字节 则将val放入异步线程,并将这个entry的value 设置为null,
if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
atomicIncr(lazyfree_objects,1);
bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
dictSetVal(db->dict,de,NULL);
}
}
/* Release the key-val pair, or just the key if we set the val
* field to NULL in order to lazy free it later. */
if (de) {
//下面的流程和同步流程基本差不多,
dictFreeUnlinkedEntry(db->dict,de);
//如果是集群情况下,则通知集群下面键删除
if (server.cluster_enabled) slotToKeyDel(key->ptr);
return 1;
} else {
return 0;
}
}
下面的代码能够解释如果解决并发问题,unlink就是把我们db对应的map,利用链表的指向功能。将该元素从map移除掉
/* Search and remove an element. This is an helper function for
* dictDelete() and dictUnlink(), please check the top comment
* of those functions.
* 这个方法unlink 和普通delete 都会找到这里,unlink的意思主要指在链表中去掉链接,即如果A->B->C, 如果要删除B,则变成A->C
* */
static dictEntry *dictGenericDelete(dict *d, const void *key, int nofree) {
uint64_t h, idx;
dictEntry *he, *prevHe;
int table;
if (d->ht[0].used == 0 && d->ht[1].used == 0) return NULL;
//是否正在rehash
if (dictIsRehashing(d)) _dictRehashStep(d);
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
//记录一个pre的结点,来用于跳过需要跳过的元素
prevHe = NULL;
while(he) {
//如果匹配到key,则执行 unlink 操作,如果nofree 等于0 则释放内存
if (key==he->key || dictCompareKeys(d, key, he->key)) {
/* Unlink the element from the list */
if (prevHe)
prevHe->next = he->next;
else
d->ht[table].table[idx] = he->next;
//如果是异步删除,则会执行下面一步释放空间
if (!nofree) {
dictFreeKey(d, he);
dictFreeVal(d, he);
zfree(he);
}
d->ht[table].used--;
return he;
}
prevHe = he;
he = he->next;
}
//如果不是正在rehash , 则不需要遍历table[1] ,遍历table[0] 即可
if (!dictIsRehashing(d)) break;
}
return NULL; /* not found */
}
小结
回到开始提出的三个问题
1, 异步删除不会存在并发问题,是一个让人安心,没有坑的方法。因为他是将会造成多线程并发问题的部分置为同步,而将空间回收的部分变为异步处理。
2, 通过将map 数据结构进行unlink 操作首先保证,元素不能被其它线程同步访问到,然后将big value交给异步线程回收
3, 显然对于大的空间回收是一个体力活动,至于他为什么是一个体力活动,咱们将单独说,其实空间回收就是一个大的内存遍历过程。涉及到cpu与内存打交道的一些知识只要知道这个点就行了。
使用建议
通过以上源码的分析,大多数场景都是可以用unlink来取代del操作的,原因如下
- redis 普遍删除操作都不会超过我们的默认阈值64的空间长度。因为一般规范我们都要避免大key的存在
- 即使我们删除大key,也一般都会处在空间足够的情况下面,所以这个时候空间回收我们都可以为了提高性能,让大的value 内存回收来做异步处理,利用多核的优势提高整个reids的负载能力
相关redis 异步删除配置建议
# 1) On eviction, because of the maxmemory and maxmemory policy configurations,
# in order to make room for new data, without going over the specified
# memory limit.
# 2) Because of expire: when a key with an associated time to live (see the
# EXPIRE command) must be deleted from memory.
# 3) Because of a side effect of a command that stores data on a key that may
# already exist. For example the RENAME command may delete the old key
# content when it is replaced with another one. Similarly SUNIONSTORE
# or SORT with STORE option may delete existing keys. The SET command
# itself removes any old content of the specified key in order to replace
# it with the specified string.
# 4) During replication, when a replica performs a full resynchronization with
# its master, the content of the whole database is removed in order to
# load the RDB file just transferred.
#
# In all the above cases the default is to delete objects in a blocking way,
# like if DEL was called. However you can configure each case specifically
# in order to instead release memory in a non-blocking way like if UNLINK
# was called, using the following configuration directives.
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
replica-lazy-flush no
lazyfree-lazy-eviction
针对redis内存使用达到maxmeory,并设置有淘汰策略时;在被动淘汰键时,是否采用lazy free机制;在lru,lfu场景里面已经阻塞了流程,来释放空间,但是如果在cpu使用率比较足的情况下,建议还是开启(有条件开启),具体我们在lru lfu章节继续分析。
lazyfree-lazy-expire
从expred场景我们本来就是在针对超过10%的过期的时候就会提高速度键的淘汰循环(具体可见expire key 章节),但是此时并不代表我们的整个redis 空间已经不足,所以异步删除来提高整个expire的吞吐速度。
此场景建议开启。
lazyfree-lazy-server-del
针对有些指令在处理已存在的键时,会带有一个隐式的DEL键的操作。如rename命令,当目标键已存在,redis会先删除目标键,如果这些目标键是一个big key,那就会引入阻塞删除的性能问题。 此参数设置就是解决这类问题,建议可开启。
slave-lazy-flush
针对slave进行全量数据同步,slave在加载master的RDB文件前,会运行flushall来清理自己的数据场景,这个场景我的建议是有条件开启,因为异步删除能够加快这一场景的执行速度,当然这里面有个风险点再与异步清理的时候可能会导致空间未完全释放,但是要加载的新数据。这个时候可能会出现等待清理的过程,具体我们会再其它章节讨论
性能测试
测试代码:
import redis.clients.jedis.Jedis;
import java.util.HashMap;
import java.util.Map;
/**
* @date 2020/09/21
**/
public class RedisTest {
public static void main(String[] args){
//连接本地redis 服务器
Jedis jedis = new Jedis("127.0.0.1");
Map<String,String> dataMap = new HashMap<>();
for(int i=0;i<10000;i++){
dataMap.put("test"+i,"adkfjksjdfsjf"+i);
}
setTestData(dataMap,jedis);
Long beforeDelDataTime= System.currentTimeMillis();
delData(jedis);
Long afterDelDataTime = System.currentTimeMillis();
System.out.println("del 100 keys bigcost time:"+(afterDelDataTime-beforeDelDataTime));
setTestData(dataMap,jedis);
Long beforeUnLInkDataTime= System.currentTimeMillis();
unlinkData(jedis);
Long afterUnlinkDataTime = System.currentTimeMillis();
System.out.println("unlink 100 bigkeys cost time:"+(afterUnlinkDataTime-beforeUnLInkDataTime));
}
private static void setTestData(Map map,Jedis jedis){
for(int i=0;i<100;i++){
jedis.hset("test"+i,map);
}
}
private static void delData(Jedis jedis){
String[] keys= new String[100];
for(int i=0;i<100;i++){
keys[i]="test"+i;
}
jedis.del(keys);
}
private static void unlinkData(Jedis jedis){
String[] keys= new String[100];
for(int i=0;i<100;i++){
keys[i]="test"+i;
}
jedis.unlink(keys);
}
可以看到从测试角度来看unlink 在大键删除的优势,看文章的朋友有兴趣的也可以自测一下
总结:
这个章节我们从各个角度分析了异步删除的原理,性能和使用场景,但是这个章节里面我也埋下了几个坑,包括内存如何回收,异步线程又是具体如何处理的,lru场景又是怎么样的情况,这些坑我将会再后面的文章一一讲解到, 尽请关注。