从零开始实现一个Redis(二)——缓存过期策略

1,313 阅读5分钟

前言

上篇文章从零开始实现一个Redis(一)我们实现了简单的string类型的setget方法,我们在set一个key的同时,可以通过set key value px 100这种方式来为key设置过期时间,但是我们并没有实现Redis的过期策略,这篇文章主要就是实现Redis的过期策略。

Redis过期策略原理

我们先看一下官方文档的定义:

Redis keys are expired in two ways: a passive way, and an active way.

A key is passively expired simply when some client tries to access it, and the key is found to be timed out.

Of course this is not enough as there are expired keys that will never be accessed again. These keys should be expired anyway, so periodically Redis tests a few keys at random among keys with an expire set. All the keys that are already expired are deleted from the keyspace.

Specifically this is what Redis does 10 times per second:

  1. Test 20 random keys from the set of keys with an associated expire.
  2. Delete all the keys found expired.
  3. If more than 25% of keys were expired, start again from step 1.

This is a trivial probabilistic algorithm, basically the assumption is that our sample is representative of the whole key space, and we continue to expire until the percentage of keys that are likely to be expired is under 25%

This means that at any given moment the maximum amount of keys already expired that are using memory is at max equal to max amount of write operations per second divided by 4.

简单概括一下: Redis的key有两种失效方式: 被动和主动。 被动失效主要是key在被访问的时候会看这个key是否过期,如果过期则会处理它。
主动的方式则会通过一个后台定时任务(也是在主线程中,通过事件模型中的定时器事件触发),来定时清理那些过期的key(一秒种执行10次)主要逻辑如下:

  1. 随机找20个设置了过期时间的key
  2. 删除其中过期的key
  3. 如果过期的key超过了25%,则从step1再开始循环。

这其实就是简单的概率算法,这意味着在任意时刻,Redis中已过期的key的最大数量也只会等于每秒写入数量的4分之1。

Redis的整个key过期策略就是这样,看起来也比较简单,那我们下面就开始实现~

Redis过期策略实现

主动过期

上面说到,主动过期其实就是在key被访问的时候过期,这个实现起来很简单:

  1. 在访问的时候先查询一个key是否在过期字典中,如果不在,则直接返回
  2. 在过期字典中,是否已过期,没有过期,直接返回
  3. 已过期,则删除key

在我们查找key前增加key过期检查:

db.go

func (r *redisDb) lookupKey(key *robj) *robj {
	//检查key是否过期,如果过期则删除
	r.expireIfNeeded(key)

	return r.doLookupKey(key)
}

func (r *redisDb) expireIfNeeded(key *robj) int {
	when := r.getExpire(key)

	if when < 0 {
		return 0
	}
	now := mstime()

	if now <= when {
		return 0
	}

	return r.dbDelete(key)
}

被动过期

被动过期Redis是通过它的ae事件模型中的定时器事件来触发的,我们使用的gnet中有一个Tick事件刚好可以实现同样的功能(Golang是使用协程来实现的,但是不妨碍我们理解Redis实现原理)。

我们重点说一下执行频率:上面说到Redis执行过期定时任务是是以每秒钟10次的频率来执行的,这个频率是如何得来的? 在redisServer结构体中,有一个字段hz,这个就是用来控制后台任务的执行频率,每个任务的执行间隔时间为1000/hz ms,hz的默认值为10,也就是1秒钟执行10次,每次100ms。

redis.c image.png

除了执行频率,在处理过期key时,Redis还针对每个DB的处理时长做了限制,如果处理时间超过了最大时长则会退出,这是为了防止过期任务执行时间过长,影响正常命令的执行。

redis.c
image.png

ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC值为25,也就是每次执行的最大时间为25%的CPU时间,比如定时任务时间为100ms,那么每个DB的最大执行时间为25ms

这里可以看到其实server.hztimelimit是成反比的,如果hz设置得越大,那么timelimit也就越小,也就是执行频率越高,那么每次运行的时间就会越短。

我们的具体实现如下:
先注册定时事件,并设置时间间隔

redis.go

//初始化server
func initServer() {
        ...

	//初始化事件处理器
	server.events.react = dataHandler
	server.events.accept = acceptHandler
        //注册定时器事件
	server.events.tick = func()(delay time.Duration, action gnet.Action) {
		return serverCron(), action
	}
        ...
}

func serverCron() time.Duration {
	databasesCron()
	return time.Millisecond * time.Duration(1000 / server.hz)
}



具体的key过期逻辑:

redis.go

func activeExpireCycle(){
	//目前我们只有一个DB,就不需要循环处理DB了
	//多DB的情况下最外层应该还有一层循环: for db in server.dbs {}

	//记录开始时间
	start := ustime()

	//每个DB的最长执行时间限制
	timelimit := int64(1000000 * activeExpireCycleSlowTimeperc / server.hz / 100)

	if timelimit <= 0 {
		timelimit = 1
	}
	db := server.db
	for {
		nums := 0
		expired := 0
		now := mstime()
		//过期字典的大小
		nums = db.expires.used()
		if nums == 0 {
			break
		}
                
                //TODO 如果过期字典的大小小于容量的1%,则不处理。
                
		//最大为20
		if nums > activeExpireCycleLookupsPerLoop {
			nums = activeExpireCycleLookupsPerLoop
		}

		for ;nums > 0; nums = nums - 1 {
			
			//随机获取一个key
			de := db.expires.getRandomKey()
			if de == nil {
				break
			}
			
			//将key过期
			if activeExpireCycleTryExpire(db, de, now) {
				expired++
			}
		}
		log.Printf("expired %v keys", expired)

		//执行时长
		elapsed := ustime() - start

		if elapsed > timelimit {
			log.Printf("expired cost too long. elapsed: %v, timelimit: %v", elapsed, timelimit)
			break
		}
		if expired < activeExpireCycleLookupsPerLoop / 4 {
			log.Printf("expired count %v less than %v. ending loop", expired, activeExpireCycleLookupsPerLoop / 4)
			break
		}
	}
}

上面的逻辑很简单:

  1. 循环所有DB(我们的只实现了一个DB,所以不需要这层循环)
  2. 每次从DB中随机抽取min(20, 过期字典容量)个key,并将其中已过期的删除
  3. 只要执行时间没有超过最大执行时间,并且被过期的key还大于抽样数据的25%(min(20, 过期字典容量)/4个),那么就一直循环处理,否则退出这个DB的循环

到这里我们的Redis过期实现就完成了,其实逻辑是挺简单的。

相关命令实现

实现了过期策略,那我们就可以实现相关的命令了

EXPIRE命令实现

expire命令很简单,就是在过期字典中添加对应的key和过期时间即可:

redis.go

func expireGenericCommand(client *redisClient, basetime int64, unit int) {
	key := client.argv[1]
	param := client.argv[2]
	w, _ := strconv.Atoi(string(param.ptr.(sds)))
	when := int64(w)
	if unit == unitSeconds {
		when *= 1000
	}
	when += basetime

	if client.db.lookupKey(key) == nil {
		addReply(client, shared.czero)
	}

	client.db.setExpire(key, when)
	addReply(client, shared.cone)
}

TTL命令实现

ttl也不多说,就是根据key在过期字典中查询对应的过期时间:

  1. 先查询字典中有没有这个key,没有的话直接返回-2
  2. 再看过期字典中有没有这个key,如果没有意味着不会过期,返回-1
  3. 返回过期字典中的value(过期时间)

redis.go

func ttlGenericCommand(client *redisClient, outputMs bool) {

	var ttl int64 = -1

	if client.db.lookupKey(client.argv[1]) == nil {
		addReplyLongLong(client, -2)
		return
	}

	expire := client.db.getExpire(client.argv[1])

	if expire != -1 {
		ttl = expire - mstime()
		if ttl < 0 {
			ttl = 0
		}
	}
	if ttl == -1 {
		addReplyLongLong(client, -1)
	}else {
		if !outputMs {
			ttl = (ttl + 500) / 1000
		}
		addReplyLongLong(client, ttl)
	}
}

总结

到这里我们的Redis的key过期相关的逻辑就已经基本实现完成了,最后总结一下:

  • Redis的过期策略为主动过期+被动过期
  • 被动过期就是在查询key时判断,如果过期则删除
  • 主动过期则是通过Redis的后台定时任务(也是在主线程执行),默认一般是每100ms执行一次,遍历所有的DB,每次随机从过期字典中抽取20个来进行过期检测,如果过期则删除。

Redis通过这两种过期策略一起来达到内存的利用率与性能的平衡, 那么在Redis的内存使用达到上限的时候,它又是如何来选择淘汰哪些key的? 接下来准备实现Redis的缓存淘汰策略。

完整代码:github.com/cadeeper/my…