用過 Redis 的人都知道 EXPIRE 很好用,時間一到資料就不見了。這在快取、驗證碼與臨時鎖等場景中是很重要的特性。
今天就來把 TTL 和惰性刪除(Lazy Expiration)補上,讓資料不只是能存,也能在時間到之後自己失效。
過期鍵清理其實是在 CPU 和記憶體之間取捨。Redis 不是只靠單一策略,而是把兩種做法搭在一起:
過期鍵清理策略
├── 惰性刪除 (Lazy Expiration)
│ ├── 原理:只有在存取 Key 時才檢查是否過期
│ ├── 優點:CPU 友善,不會做無謂的掃描
│ └── 缺點:記憶體不友善,未被存取的過期鍵會永久殘留
└── 定期刪除 (Active Expiration)
├── 原理:每隔一段時間隨機抽取部分 Key 檢查並刪除
├── 優點:記憶體友善,能有效釋放記憶體
└── 缺點:佔用 CPU 時間,需控制掃描頻率避免阻塞
我在 code/db/db.go 中實作了 TTL 和 Expire 命令:
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 了。
ExpireExpire 用來設定或更新一個已存在 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,這才開始有點資料庫的感覺了,明天見!