Olivine:以 Go 建構與 Redis 相容的服務 · 章節目錄

第 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 有幾個問題:

  1. 檔案沒有結尾的 \n 時,rd.ReadString('\n') 會回傳 EOF。
  2. 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
					}
				}
			}
		}()