先從一個核心問題開始:如果在 SET 指令執行之後、AOF 寫入之前關閉服務,是否有可能造成資料遺失?
--答案是肯定的。
我們雖然會在每次 SET 指令之後呼叫 os.(*File).Write(),但是作業系統(尤其是 POSIX 標準相容的)的 write(2) 系統調用並不一定會立即直接把資料寫進硬碟中,而是會先寫入 page buffer(分頁緩衝區),然後再分批寫入硬碟。
如果在資料被從 page buffer 寫入磁碟之前發生了意外中止的情況(例如斷電),這將會遺失這個 page buffer 中的資料(因為 page buffer 的位置絕大多數情況還是在記憶體中)。
對於大部份的資料庫服務(MySQL、Redis 等)都會在合適的時機調用 fsync(2) 把 page buffer 強制刷進磁碟中。
Redis 提供了三種不同的 AOF 同步政策:
no:全部依照作業系統決定always:每次寫入指令之後都調用 fsync()
everysec:建立一個執行緒,每秒調用一次 fsync()
這個可以在 redis.conf 中被設定。
註:一般來說,官方建議使用
everysec,這樣頂多會喪失一秒內的資料而且對性能的影響並不會太大,算是相對平衡的選擇。
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()
}
alwaysappendfsync 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()
+}
在前面我們實作了一個未完成的設定檔解析器,但它存在一些問題:
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 而被設定為零值。
在設定常數枚舉時,這是一個滿重要的技巧:零值有的時候應該被設為非法的。
在上面的程式中,我用了 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
}
}
}
}()