第 11 章
Day 11:AOF Fsync 與設定
關閉時,AOF 尚未真正寫入磁碟,有可能遺失資料嗎?
會。
呼叫檔案的 Write() 後,作業系統可能先將資料放在 page buffer,而不立即寫入磁碟。若在 flush 前發生斷電等非預期終止,資料就可能遺失。
MySQL、Redis 等資料庫會使用 fsync() syscall,要求將緩衝資料寫入磁碟。
AOF Sync
Redis 提供三種 AOF sync 策略,可透過 redis.conf 設定:
no:不主動 sync,交給作業系統。always:每次 AOF 寫入都 sync。everysec:背景工作每秒 sync 一次。
Redis configuration
Redis 使用文字檔 redis.conf 設定服務,見 Redis configuration。
先建立簡單的 parser:
package data
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
)
var (
ErrCommand = errors.New("invalid command")
)
const (
AOFFsyncNo = iota + 1
AOFFsyncAlways
AOFFsyncEverySec
)
type Config struct {
AOFEnabled bool
AOFFsync int
}
func NewConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
cfg := Config{}
rd := bufio.NewReader(f)
ln := 0
for {
ln++
line, err := rd.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return &cfg, nil
}
return nil, err
}
line = strings.Trim(line, " \n")
if len(line) == 0 || line[0] == '#' {
continue
}
parsed := strings.SplitN(line, " ", 2)
if len(parsed) != 2 {
return nil, fmt.Errorf("%w: (line: %d): %+v", ErrCommand, ln, parsed)
}
cmd, arg := parsed[0], parsed[1]
switch cmd {
case "appendonly":
if arg == "yes" {
cfg.AOFEnabled = true
}
case "appendfsync":
switch arg {
case "no":
cfg.AOFFsync = AOFFsyncNo
case "always":
cfg.AOFFsync = AOFFsyncAlways
case "everysec":
cfg.AOFFsync = AOFFsyncEverySec
}
}
}
}
注意:這不是完整的 Redis configuration parser。
套用設定
在 main.go 初始化:
func main() {
cfg, err := data.NewConfig("redis.conf")
if err != nil {
slog.Error("failed to load config", slog.Any("error", err))
return
}
在 app.go 使用:
func NewApp(cfg *data.Config) (*App, error) {
var handler server.Handler
var restorer server.Restorer
if cfg.AOFEnabled {
aof, err := service.NewAOF(AOFPath)
if err != nil {
return nil, err
}
handler = server.NewHandler(cmd.NewCommands(repo.NewStorage()), server.NewAOFMiddleware(aof))
restorer = server.NewRestorer(aof, handler)
} else {
handler = server.NewHandler(cmd.NewCommands(repo.NewStorage()))
}
return &App{
cfg: cfg,
srv: server.NewServer(handler, restorer),
}, nil
}
restorer 為 nil 時略過還原:
func (s *simpleSrv) RestoreFromDisk() error {
if s.restorer == nil {
return nil
}
return s.restorer.LoadFromDisk()
}
每次寫入都 Sync
設定 appendfsync always 時,寫入後執行 sync:
func (aof *file) Write(v *resp.Command) error {
aof.mu.Lock()
defer aof.mu.Unlock()
if !v.Dirty() {
return nil
}
marshaled := v.Marshal()
n, err := aof.f.Write(marshaled)
if err != nil {
return err
}
if n != len(marshaled) {
return fmt.Errorf("wrote length mismatch: got %d want %d", n, len(marshaled))
}
if aof.cfg.AOFFsync == data.AOFFsyncAlways {
return aof.f.Sync()
}
return nil
}
n, err := aof.f.Write(marshaled)
if err != nil {
return err
}
if n != len(marshaled) {
return fmt.Errorf("wrote length mismatch: got %d want %d", n, len(marshaled))
}
+ if aof.cfg.AOFFsync == data.AOFFsyncAlways {
+ return aof.f.Sync()
+ }
return nil
每秒 Sync
設定 appendfsync everysec 時,在 cmd/olivine/app.go 加入 goroutine,每秒呼叫 sync:
if app.cfg.AOFEnabled && app.cfg.AOFFsync == data.AOFFsyncEverySec {
go func() {
for {
time.Sleep(time.Second)
if err := app.aof.Sync(); err != nil {
errch <- err
}
}
}()
}
別忘了在 service.AOF 加入 Sync():
// file: internal/service/aof.go
type AOF interface {
Read() (*resp.Command, error)
Write(*resp.Command) error
+ Sync() error
Close() error
}
+func (aof *file) Sync() error {
+ aof.mu.Lock()
+ defer aof.mu.Unlock()
+
+ return aof.f.Sync()
+}
Redis Config Parser
前面的簡易 parser 有幾個問題:
- 檔案沒有結尾的
\n時,rd.ReadString('\n')會回傳 EOF。 strings.Trim(line, " \n")與strings.SplitN(line, " ", 2)的處理不容易閱讀。
我請 Codex(GPT-5.5 medium)修正,得到以下結果:
func NewConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
cfg := Config{}
rd := bufio.NewReader(f)
ln := 0
for {
ln++
line, err := rd.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) && len(line) == 0 {
return &cfg, nil
}
if !errors.Is(err, io.EOF) {
return nil, err
}
}
if err := parse(&cfg, ln, line); err != nil {
return nil, err
}
if errors.Is(err, io.EOF) {
return &cfg, nil
}
}
}
func parse(cfg *Config, ln int, line string) error {
parsed := strings.Fields(line)
if len(parsed) == 0 || strings.HasPrefix(parsed[0], "#") {
return nil
}
if len(parsed) != 2 {
return fmt.Errorf("%w: (line: %d): %+v", ErrCommand, ln, parsed)
}
cmd, arg := parsed[0], parsed[1]
switch cmd {
case "appendonly":
if arg == "yes" {
cfg.AOFEnabled = true
}
case "appendfsync":
switch arg {
case "no":
cfg.AOFFsync = AOFFsyncNo
case "always":
cfg.AOFFsync = AOFFsyncAlways
case "everysec":
cfg.AOFFsync = AOFFsyncEverySec
}
}
return nil
}
結果到處都在檢查 io.EOF,反而更難閱讀。
這裡有個重要前提:Redis 設定由 server 管理者提供,通常不會很大。
因此可以先將整個檔案讀入記憶體,再直接解析。結尾的 \n 也能在讀入後補上,不必每個迴圈都處理。
這是我的版本:
func NewConfigFromBytes(content []byte) (*Config, error) {
cfg := Config{}
rd := bytes.NewBuffer(append(content, '\n'))
ln := 0
for {
ln++
line, err := rd.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return &cfg, nil
}
return &cfg, fmt.Errorf("%w: (line: %d): %w", ErrCommand, ln, err)
}
if err := parse(&cfg, line); err != nil {
return &cfg, fmt.Errorf("%w: (line: %d): %w", ErrCommand, ln, err)
}
}
}
常數 Enum
AOFFsync enum 有一個重要設計:
const (
AOFFsyncNo = iota + 1
AOFFsyncAlways
AOFFsyncEverySec
)
使用 iota+1,讓零值保留給「尚未設定」。若直接以 iota 開始,就無法區分 AOFFsyncNo 與預設零值。
Ticker
原本在 cmd/olivine/app.go 使用 time.Sleep(time.Second) 做 AOF sync:
go func() {
for {
time.Sleep(time.Second)
if err := app.aof.Sync(); err != nil {
errch <- err
}
}
}()
這個 goroutine 不會在 sleep 時回應關閉訊號。雖然程式即將退出,暫時可以接受這個做法,但寫法不夠理想。
改用 time.Ticker:
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := app.aof.Sync(); err != nil {
errch <- err
return
}
}
}
}()