iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 8

Day 08:過期時間(TTL)與惰性刪除機制深度解析

  • 分享至 

  • xImage
  •  

用過 Redis 的人都知道 EXPIRE 很好用,時間一到資料就不見了。這在快取、驗證碼與臨時鎖等場景中是很重要的特性。

今天就來把 TTL 和惰性刪除(Lazy Expiration)補上,讓資料不只是能存,也能在時間到之後自己失效。


鍵的過期刪除策略

過期鍵清理其實是在 CPU 和記憶體之間取捨。Redis 不是只靠單一策略,而是把兩種做法搭在一起:

過期鍵清理策略
├── 惰性刪除 (Lazy Expiration)
│   ├── 原理:只有在存取 Key 時才檢查是否過期
│   ├── 優點:CPU 友善,不會做無謂的掃描
│   └── 缺點:記憶體不友善,未被存取的過期鍵會永久殘留
└── 定期刪除 (Active Expiration)
    ├── 原理:每隔一段時間隨機抽取部分 Key 檢查並刪除
    ├── 優點:記憶體友善,能有效釋放記憶體
    └── 缺點:佔用 CPU 時間,需控制掃描頻率避免阻塞

1. 惰性刪除 (Lazy Expiration)

  • 運作方式:當客戶端試圖存取一個 Key 時,資料庫先檢查它是否已經到期。如果過期,就將它從資料庫中刪除,並向客戶端回傳空值;如果沒過期,正常回傳。
  • 優缺點:這是對 CPU 最友善的策略,因為只在必要時才進行過期檢查,不會浪費 CPU 去掃描那些不常被存取的 Key。但缺點是,如果某些 Key 過期後再也沒有被存取,它們就會一直殘留在記憶體中,造成記憶體洩漏。

2. 定期刪除 (Active Expiration / Periodic Expiration)

  • 運作方式:Redis 每隔一段時間(例如每秒 10 次),會隨機測試部分設有過期時間的 Key。如果過期了就將其刪除。若過期的 Key 佔抽樣比例的 25% 以上,則重複此過程,直到過期比例降低或達到時間限制。
  • 優缺點:這能主動釋放記憶體,避免大量過期鍵殘留。但需要精細調整掃描頻率,避免阻塞 main thread。

程式碼實作:TTL 與 Expire

我在 code/db/db.go 中實作了 TTLExpire 命令:

1. 獲取剩餘生存時間:TTL

當查詢一個 Key 的 TTL 時,依據 Redis 官方規範回傳特定的整數值:

  • -1:代表該 Key 存在,但沒有設定過期時間(永久保存)。
  • -2:代表該 Key 不存在,或者它已經過期被刪除了。
func (db *DB) TTL(key string) int64 {
	db.mu.Lock() // 因為 getEntryWithoutLock 會觸發惰性刪除(寫操作),故使用 Lock
	defer db.mu.Unlock()

	e, exists := db.getEntryWithoutLock(key)
	if !exists {
		return -2 // 鍵不存在或已過期
	}

	if e.expireAt.IsZero() {
		return -1 // 永久保存
	}

	remain := time.Until(e.expireAt)
	if remain <= 0 {
		return -2 // 已過期但尚未被惰性刪除
	}

	return int64(remain.Seconds())
}

原本寫 TTL 的時候我直接回傳剩餘秒數,後來查文件才發現不存在要回 -2,沒過期要回 -1,還好有先看文件不然又要 bug 了。

2. 主動設定過期時間:Expire

Expire 用來設定或更新一個已存在 Key 的過期時間(單位為秒)。如果設定的秒數小於等於 0,表示該鍵應該立即失效(等同於刪除):

func (db *DB) Expire(key string, seconds int64) int {
	db.mu.Lock()
	defer db.mu.Unlock()

	e, exists := db.getEntryWithoutLock(key)
	if !exists {
		return 0 // 鍵不存在或已過期,設定失敗
	}

	if seconds <= 0 {
		delete(db.data, key) // 立即刪除
		return 1
	}

	e.expireAt = time.Now().Add(time.Duration(seconds) * time.Second)
	return 1 // 設定成功
}

單元測試與驗證

為了驗證過期時間的正確性,在 code/db/db_test.go 中編寫了 TestDBExpiration

func TestDBExpiration(t *testing.T) {
	db := NewDB()

	// 設定一個生存時間只有 50 毫秒的 Key
	db.Set("key1", []byte("value1"), 50*time.Millisecond)

	// 立即讀取,應該要存在
	if _, exists := db.GetString("key1"); !exists {
		t.Error("設定 TTL 後立即讀取,發現 Key 不存在")
	}

	// Sleep 100 毫秒,等 key 過期
	time.Sleep(100 * time.Millisecond)

	// 再次讀取,應該會觸發 getEntryWithoutLock 的惰性刪除並回傳不存在
	if _, exists := db.GetString("key1"); exists {
		t.Error("Key 應該要已過期,但依然存在")
	}
}

這個測試至少能確認一件事:時間真的過了之後,讀取時會把過期 key 清掉。


跑起來看看

我們來驗證一下剛寫好的 TTL 和 EXPIRE 機制!啟動伺服器:

$ go run ./code/main.go

一樣用 printf + nc 來玩玩看:

# 先設定一筆資料
$ printf "*3\r\n\$3\r\nSET\r\n\$5\r\nmykey\r\n\$5\r\nhello\r\n" | nc localhost 6379
# 預期回覆:+OK

# 設定過期時間 2 秒
$ printf "*3\r\n\$6\r\nEXPIRE\r\n\$5\r\nmykey\r\n\$1\r\n2\r\n" | nc localhost 6379
# 預期回覆::1

# 馬上查一下剩餘秒數
$ printf "*2\r\n\$3\r\nTTL\r\n\$5\r\nmykey\r\n" | nc localhost 6379
# 預期回覆::2 (或 :1,取決於你手速)

# 故意等個 3 秒鐘... 然後再查一次 TTL
# 等待中...
$ printf "*2\r\n\$3\r\nTTL\r\n\$5\r\nmykey\r\n" | nc localhost 6379
# 預期回覆::-2

看到回傳 -2,就代表資料真的被我們清理掉了。時間控制有時候很容易出 bug,能在這裡正確抓到過期,後面就可以安心用了。

總結

今天先把 TTL / EXPIRE 和惰性刪除跑通。這版只在讀取時清過期 key,所以如果某些過期資料再也沒被查詢,還是會留在記憶體裡。

後面需要再補一個背景 worker,定期抽樣清理過期 key,這樣才比較接近 Redis 真正的做法。明天來寫 List,這才開始有點資料庫的感覺了,明天見!


上一篇
Day 07:實作記憶體引擎基礎命令:SET, GET, DEL 與 EXISTS
下一篇
Day 09:實作核心資料結構 List 與雙端操作命令
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言