Implementing Redis‑Style Key Expiration in Go with Lazy Deletion and Time Wheel

This article walks through building an EasyRedis service in Go, explaining Redis's lazy and timed expiration strategies, showing concrete code for on‑read expiration checks, and detailing a time‑wheel implementation that efficiently schedules key deletions.

Nullbody Notes
Nullbody Notes
Nullbody Notes
Implementing Redis‑Style Key Expiration in Go with Lazy Deletion and Time Wheel

EasyRedis source code: https://github.com/gofish2020/easyredis

Expiration Strategies

Lazy deletion : a key is removed only when it is accessed and found to be expired.

Timed deletion : a background task removes the key as soon as its TTL expires.

Lazy Deletion Implementation

Read operations invoke an active expiration check. The retrieval path is engine/database.go. When GetEntity(key) is called, the key is looked up in the in‑memory dictionary and then db.IsExpire(key) determines whether the key has timed out.

func (db *DB) GetEntity(key string) (*payload.DataEntity, bool) {
    val, exist := db.dataDict.Get(key)
    if !exist {
        return nil, false
    }
    // active expiration check
    if db.IsExpire(key) {
        return nil, false
    }
    dataEntity, ok := val.(*payload.DataEntity)
    if !ok {
        return nil, false
    }
    return dataEntity, true
}

The expiration check reads the deadline from ttlDict. If no entry exists, the key has no TTL. If a deadline exists and the current time is past it, the key is removed and the function reports expiration.

func (db *DB) IsExpire(key string) bool {
    val, result := db.ttlDict.Get(key)
    if !result {
        return false
    }
    expireTime, _ := val.(time.Time)
    isExpire := time.Now().After(expireTime)
    if isExpire { // proactive removal
        db.Remove(key)
    }
    return isExpire
}

Timed Deletion with a Time Wheel

A fixed‑interval timer (e.g., checking every 3 seconds) is inefficient for keys that expire at finer granularity. The time‑wheel algorithm simulates a clock face: a circular slot array where each slot holds a linked list of tasks scheduled for that slot. The wheel advances at a constant interval; tasks that require multiple rotations carry a circle counter.

Time wheel illustration
Time wheel illustration

When a task with a 3‑second delay is added, it is placed at pos + 3. A 5‑second task goes to pos + 5. If the wheel’s pointer reaches a slot, all tasks in that slot’s list are executed. Tasks spanning more than one full rotation have a non‑zero circle value indicating how many additional cycles must pass.

Task placement on wheel
Task placement on wheel

The implementation resides in tool/timewheel. The wheel struct contains interval, ticker, current slot position, slot count, a slice of linked‑list slots, a map for quick key lookup, and channels for adding, canceling, and stopping tasks.

type TimeWheel struct {
    interval    time.Duration
    ticker      *time.Ticker
    curSlotPos  int
    slotNum     int
    slots       []*list.List
    m           map[string]*taskPos
    addChannel  chan *task
    cacelChannel chan string
    stopChannel chan struct{}
}

Adding a task computes the target slot and the number of full rotations using posAndCircle. The task is appended to the appropriate slot’s list, and the key‑to‑position map is updated to allow cancellation of duplicate keys.

func (tw *TimeWheel) posAndCircle(d time.Duration) (pos, circle int) {
    delaySecond := int(d.Seconds())
    intervalSecond := int(tw.interval.Seconds())
    pos = (tw.curSlotPos + delaySecond/intervalSecond) % tw.slotNum
    circle = (delaySecond / intervalSecond) / tw.slotNum
    return
}

func (tw *TimeWheel) addTask(t *task) {
    pos, circle := tw.posAndCircle(t.delay)
    t.circle = circle
    ele := tw.slots[pos].PushBack(t)
    if t.key != "" {
        if _, ok := tw.m[t.key]; ok {
            tw.cancelTask(t.key)
        }
        tw.m[t.key] = &taskPos{pos: pos, ele: ele}
    }
}

Handling Re‑Setting of Keys

If a key is set with an expiration (e.g., SET key value EX 3) and later overwritten without a TTL ( SET key value), the expiration entry is removed so the key no longer expires. The logic resides in engine/string.go within the cmdSet handler, where the tail of the function deletes the key from ttlDict when the command lacks an expiration argument.

Set command handling
Set command handling

Combined, EasyRedis demonstrates Redis‑style TTL management by performing lazy checks on reads and using a time‑wheel scheduler for proactive expiration.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Backend DevelopmentRedisGoTime WheelLazy DeletionKey Expiration
Nullbody Notes
Written by

Nullbody Notes

Go backend development, learning open-source project source code together, focusing on simplicity and practicality.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.