AOF 持久化是追加模式,也就是說系統跑越久,AOF 檔案就會越大。如果同一個 Key 被改了一萬次,AOF 就老老實實記下一萬條指令。
但實際上,歷史紀錄不重要,只有最後一次修改的當前狀態才是我們要的。
今天來看 Redis 怎麼處理 AOF 膨脹,順便自己實作一版 AOF 重寫(AOF Rewrite)。
AOF 重寫不是把舊 AOF 檔一行一行拿來分析,而是直接看記憶體裡目前最新的狀態。每個還活著的 Key 重新產生一條可以重建它的寫指令,例如 List 就輸出一條包含目前所有元素的 RPUSH,再寫進新的臨時 AOF 檔。
寫完後,用臨時檔案原子替換舊的 AOF 檔案。
舊 Aof 檔案 (內含 100 萬次修改日誌) --- 丟棄
記憶體 DB (當前狀態) ---> [遍歷快照] ---> 生成新臨時 Aof ---> 原子替換
AOF 重寫是個需要遍歷整個資料庫的重型磁碟 I/O 操作。如果直接在main thread 中執行,會導致資料庫卡死(阻塞數秒至數分鐘),這對高併發的資料庫是不可接受的。
Redis 在 Linux 上使用了 fork 系統呼叫 生成子進程,利用作業系統的 Copy-on-Write (寫時複製,COW) 技術:
fork 後,子進程與主進程共享同一塊記憶體物理頁。fork 那個瞬間的靜態快照。在 Go 語言中,因為 goroutine 的運行架構,我們無法直接像single-thread 的 C 程式那樣呼叫 fork(這會導致多個背景線程狀態混亂)。說實在的,一開始我還想硬幹找個套件來 fork,結果發現 Go 遇到 fork 後面根本一團糟,最後還是得乖乖走其他路。
Go 這裡沒辦法照 Redis 那樣直接走 fork,所以我改用 快照複製(Shallow Copy) 的方式,盡量縮短鎖住 DB 的時間:
*entry 指標。這段很短,至少不會在寫檔期間一直卡住 DB。SET、RPUSH、HSET、SADD、ZADD 命令寫入臨時檔案。os.Rename 替換舊 AOF。我們在 code/db/aof.go 中實作了 Rewrite() 方法:
func (a *AofLogger) Rewrite() error {
tempFilename := a.filename + ".tmp"
tempFile, err := os.OpenFile(tempFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil { return err }
defer tempFile.Close()
// 1. 複製記憶體快照(極短時間鎖定)
a.dbEngine.mu.RLock()
snapshot := make(map[string]*entry, len(a.dbEngine.data))
for k, v := range a.dbEngine.data {
if !v.isExpired() {
snapshot[k] = v // 拷貝 entry 指標
}
}
a.dbEngine.mu.RUnlock() // 立即解鎖
writer := bufio.NewWriter(tempFile)
// 2. 遍歷快照,寫入重建命令
for key, entry := range snapshot {
var cmd resp.Value
// ... 寫入 EXPIRE 恢復指令 ...
switch entry.dataType {
case TypeString:
cmd = resp.NewArray([]resp.Value{
resp.NewBulkString([]byte("SET")),
resp.NewBulkString([]byte(key)),
resp.NewBulkString(entry.val.([]byte)),
})
_, _ = writer.Write(cmd.Marshal())
case TypeList:
// 遍歷雙向鏈結,轉換成 RPUSH 命令
// ...
case TypeHash:
// 遍歷 map,轉換成 HSET 命令
// ...
}
// ...
}
_ = writer.Flush()
_ = tempFile.Sync()
// 3. 替換舊檔案
a.mu.Lock()
defer a.mu.Unlock()
_ = a.file.Close()
err = os.Rename(tempFilename, a.filename) // 原子更名
// ... 重新開啟 a.file ...
return nil
}
這樣做的重點是把慢的 I/O 和 DB 鎖分開。說真的,只要鎖加對地方,效能其實不會太差。
我們來試試看 AOF 重寫到底有沒有瘦身效果:
# 1. 啟動伺服器,瘋狂寫入同一個 Key
$ redis-cli
> SET mykey "a"
> SET mykey "b"
... 狂敲 100 次
> BGREWRITEAOF
# 預期回覆:Background append only file rewriting started
看看背景重寫前後的 AOF 檔案大小差別:
$ du -sh appendonly.aof*
12K appendonly.aof # 原本一堆冗餘的歷史紀錄
4K appendonly.aof.tmp # 重寫完只剩最後一條 SET mykey 的乾淨快照
重寫完後,舊檔就被 .tmp 替換掉了,這就是背景快照發揮功效的時候啦!
今天把 AOF 檔案膨脹的問題拆開,也用「快照拷貝 + 背景 goroutine」做了一版非同步 AOF 重寫。
明天來處理 RDB(二進位快照持久化),聽說要用 gob 來壓資料,感覺有點意思。