iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

先從一個核心問題開始:如果在 SET 指令執行之後、AOF 寫入之前關閉服務,是否有可能造成資料遺失?

--答案是肯定的。

我們雖然會在每次 SET 指令之後呼叫 os.(*File).Write(),但是作業系統(尤其是 POSIX 標準相容的)的 write(2) 系統調用並不一定會立即直接把資料寫進硬碟中,而是會先寫入 page buffer(分頁緩衝區),然後再分批寫入硬碟。

如果在資料被從 page buffer 寫入磁碟之前發生了意外中止的情況(例如斷電),這將會遺失這個 page buffer 中的資料(因為 page buffer 的位置絕大多數情況還是在記憶體中)。

對於大部份的資料庫服務(MySQL、Redis 等)都會在合適的時機調用 fsync(2) 把 page buffer 強制刷進磁碟中。

AOF 同步

Redis 提供了三種不同的 AOF 同步政策:

  • no:全部依照作業系統決定
  • always:每次寫入指令之後都調用 fsync()
  • everysec:建立一個執行緒,每秒調用一次 fsync()

這個可以在 redis.conf 中被設定。

註:一般來說,官方建議使用 everysec,這樣頂多會喪失一秒內的資料而且對性能的影響並不會太大,算是相對平衡的選擇。

Redis 設定檔解析器

Redis 可以被 redis.conf 設定,這是一個純文字檔案,從這裡可以獲得更多資訊。

我們可以寫一個簡單的解析器:

// file: ./internal/data/config.go
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 設定檔解析器,而且這是一個有 bug 的解析器,我會在後續進行分析。

應用設定檔

首先在 main.main() 中初始化這個設定檔:

// file: ./cmd/olivine/main.go
func main() { 
	cfg, err := data.NewConfig("redis.conf") 
	if err != nil { 
		slog.Error("failed to load config", slog.Any("error", err)) 
		return 
	} 
	// ...
}

並且在 main.NewApp() 中應用這個設定檔:

// file: ./app/olivine/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
}

不要忘記修改 server.(*simpleSrv).RestoreFromDisk(),加入 s.restorer == nil 的保護:

// file: ./internal/server/server.go
func (s *simpleSrv) RestoreFromDisk() error {
	if s.restorer == nil {
		return nil
	}

	return s.restorer.LoadFromDisk()
}

AOF 同步實作

always

appendfsync always 的實作相當容易:只要在 aof.Write() 之後調用 aof.f.Sync() 即可:

// file: ./internal/service/aof.go
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
}

everysec

我們可以在 cmd/olivine/app.go 加入一個 goroutine 來處理每秒同步:

// file: ./cmd/olivine/app.go
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 設定檔的解析

在前面我們實作了一個未完成的設定檔解析器,但它存在一些問題:

  • 因為 rd.ReadString('\n'),如果設定檔的行末不包含 \n 它就會回傳未預期的 io.EOF
  • strings.Trim(line, " \n")strings.SplitN(line, " ", 2) 並不容易理解

我把這兩個問題發給了 Codex(GPT-5.5 medium),並試圖讓他來協助我解決,以下是它的解法:

// file: ./internal/data/config.go
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
}

我的老天,它讓整個 code 更難讀了:它在檔案裡判斷了一堆 if errors.Is(err, io.EOF),就像在檔案裡拉屎。

此處有一個很重要前提:Redis 設定檔通常是由伺服器管理者提供,它通常不會是一個巨大到無法處理的檔案;因此,我們完全可以將整個檔案載入記憶體而非一行行解析。至於那個遺失檔尾 \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)
		}
	}
}

其實這也從側面印證了有的時候 AI 工具還尚有進步空間,而這也是人類工程師仍有一席之地的主因--不要完全依賴 AI,你仍應該對程式碼保有自己的品味。

常數枚舉

或許你有注意到,我在設計 AOFFsync 常數的巧思:

// file: ./internal/data/config.go
const (
	AOFFsyncNo = iota + 1
	AOFFsyncAlways
	AOFFsyncEverySec
)

使用 iota+1 而不是 iota,這是因為 Go 在設定初值的時候會將值設為 Zero Value(零值),如果僅使用 iota 的話,我們就不曉得它是被明確設定為 AOFFsyncNo 還是因為 iota 而被設定為零值。

在設定常數枚舉時,這是一個滿重要的技巧:零值有的時候應該被設為非法的。

Ticker

在上面的程式中,我用了 time.Sleep(time.Second)

go func() {
	for {
		time.Sleep(time.Second)
		if err := app.aof.Sync(); err != nil {
			errch <- err
		}
	}
}()

這種做法會忽略 server.(*simpleSrv).Shutdown() 的信號,雖然它是安全的但不夠優雅:使用 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
			}
		}
	}
}()

上一篇
[Day 20] Graceful Shutdown(下):超時處理
下一篇
[Day 22] 依賴注入
系列文
從零開始打造 Redis:以 Go 建立 Production Ready 應用程式 29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言