iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

從零開始打造 Redis:以 Go 建立 Production Ready 應用程式 系列 第 28

[Day 28] 對記憶體性能進行剖析(上)

  • 分享至 

  • xImage
  •  

在經過 Day 26 與 Day 27 之後,我們可以得到如下的火焰圖:

https://ithelp.ithome.com.tw/upload/images/20260828/20183331T0EwFvItw5.png

可以見到 runtime.gcDrain 佔用了 25.14 秒(約 13.9%)的 CPU 時間,這代表服務花了很多成本在建立與銷毀生命周期較短的物件。

註:此處的 object 物件 指的是會有一定時間佔用記憶體的資料,與 C99 規格書 中所提到的 object 具有類似意義,與 Object-Oriented Programming, OOP 物件導向程式設計 所指涉的物件意義不同。

記憶體剖析

我們可以從 localhost:6060/debug/pprof/allocslocalhost:6060/debug/pprof/heap 取得記憶體分配的火焰圖。

註:不要忘記在 redis-benchmark 中使用較大的 -n-r 參數。

以下是生成的結果,圖一是 allocs、圖二是 heap

https://ithelp.ithome.com.tw/upload/images/20260828/20183331TESSWhZIJd.png

https://ithelp.ithome.com.tw/upload/images/20260828/20183331VSwhFmmAWJ.png

  • allocs:記憶體分配的歷史,包括正在使用的與已經被銷毀的物件
  • heap:當前的記憶體狀態,只有沒被 GC 所銷毀的(註:如果物件長期不使用但因為各種因素沒有被 GC 回收也會顯示,是檢測 Memory Leak 的利器)

因為 runtime.gcDrain 花了很長的時間,我們先專注於 allocs 的火焰圖。

使用 bufio.(*Reader).ReadSlice()

目前的 resp.(*Reader).readLine() 實作如下:

// file: pkg/resp/io.go
func (r *Reader) readLine() ([]byte, error) { 
	var buf bytes.Buffer 
  
	for { 
		data, err := r.rd.ReadBytes('\r') 
		if err != nil { 
			return nil, err 
		} 
  
		buf.Write(data) 
		b, err := r.rd.ReadByte() 
		if err != nil { 
			return nil, err 
		} 
		if b == '\n' { 
			return buf.Bytes()[:buf.Len()-1], nil 
		} 
		buf.WriteByte(b) 
	} 
} 

可以見到,當多次讀取之後會漸漸使 var buf bytes.Buffer 增長;我們使用 r.rd.ReadSlice('\n') 來重複利用 bufio.(*Reader) 中的緩衝區:

// file: ./pkg/resp/io.go
func (r *Reader) readLine() ([]byte, error) { 
	var fragments []byte 
  
	for { 
		data, err := r.rd.ReadSlice('\n') 
		if errors.Is(err, bufio.ErrBufferFull) { 
			fragments = append(fragments, data...) 
			continue 
		} 
		if err != nil { 
			return nil, err 
		} 
  
		if len(data) == 1 && data[0] == '\n' && len(fragments) > 0 && fragments[len(fragments)-1] == '\r' { 
			return fragments[:len(fragments)-1], nil 
		} 
  
		if len(data) >= 2 && data[len(data)-2] == '\r' { 
			data = data[:len(data)-2] 
			if len(fragments) == 0 { 
				return data, nil 
			} 
  
			return append(fragments, data...), nil 
		} 
  
		fragments = append(fragments, data...) 
	} 
} 

使用 go tool pprof -http=:8000 -base ./scripts/out_archive/1-original.allocs ./scripts/out_archive/2-readslice.allocs,我們可以比對修改前後的記憶體分配情況:

https://ithelp.ithome.com.tw/upload/images/20260828/20183331Cx2NUtif6Q.png

介面的型別轉換

介面是一個非常有用的特性,它可以幫助我們寫下清晰、可讀的程式碼。然而這並不是免費的,介面在運行期有其代價:Go 會在 heap 上分配記憶體給介面,作為後續的型別轉換備使用(因為 Go 編譯器不能確定實作介面的那些結構體所佔用的記憶體大小,因此只能分配在 heap 上)

註:所謂「在 heap 上分配記憶體」可以簡單理解成在 C 用 malloc(3) 或在 C++ 用 new() 的方式分配一個記憶體空間給即將到來的物件,相較於變數宣告是在 stack 上分配記憶體這種做法會有較高的成本但也具有較高的彈性。

resp.ReadCommand() 中:

  • rd.Read() 回傳 resp.Value 介面
  • arr, ok := v.(Array)resp.Value 轉換為 resp.Array
  • cmd, ok := arr.data[0].(BulkString)arr.data[0] 的資料從 resp.Value 轉換為 resp.(BulkString)
  • args 會將 arr.data[1:] 剩餘的參數都一併轉換為 resp.(BulkString)
// file: ./pkg/resp/cmd.go
func ReadCommand(rd *Reader) (*Command, error) { 
	v, err := rd.Read() 
	if err != nil { 
		return nil, fmt.Errorf("%w: %w", ErrProtocol, err) 
	} 
 
	arr, ok := v.(Array) 
	if !ok { 
		return nil, fmt.Errorf("%w: expected array, got %T(%+v)", ErrProtocol, v, v) 
	} 
	if arr.null || len(arr.data) == 0 { 
		return nil, fmt.Errorf("%w: empty array", ErrProtocol) 
	} 
  
	cmd, ok := arr.data[0].(BulkString) 
	if !ok { 
		return nil, fmt.Errorf("%w: command expected bulk string, got %T(%+v)", ErrProtocol, arr.data[0], arr.data[0]) 
	} 
  
	args := make([]BulkString, 0, len(arr.data)-1) 
	for i := range arr.data[1:] { 
		arg, ok := arr.data[i+1].(BulkString) 
		if !ok { 
			return nil, fmt.Errorf("%w: argument [%d] expected bulk string, got %T(%+v)", ErrProtocol, i, arr.data[i+1], arr.data[i+1]) 
		} 
		args = append(args, arg) 
	} 
  
	return &Command{ 
		raw:  v, 
		aof:  arr.Clone(), 
		cmd:  cmd, 
		args: args, 
	}, nil 
} 

為此,我另外做了一個快速路徑 resp.(*Reader).ReadCommand(),它會直接將 Redis Command 讀取成 []resp.(BulkString)

// file: ./pkg/resp/io.go
func (r *Reader) ReadCommand() ([]BulkString, error) { 
	t, err := r.rd.ReadByte() 
	if err != nil { 
		return nil, err 
	} 
	if t != MAGIC_ARRAY { 
		return nil, fmt.Errorf("expected array, got %c", t) 
	} 
  
	sz, err := r.readInt() 
	if err != nil { 
		return nil, err 
	} 
	if sz < 0 { 
		return nil, nil 
	} 
  
	values := make([]BulkString, sz) 
	for i := range values { 
		t, err := r.rd.ReadByte() 
		if err != nil { 
			return nil, err 
		} 
		if t != MAGIC_BULK_STRING { 
			return nil, fmt.Errorf("element [%d] expected bulk string, got %c", i, t) 
		} 
 
		values[i], err = r.readBulkString() 
		if err != nil { 
			return nil, err 
		} 
	} 
  
	return values, nil 
} 
  
func (r *Reader) Buffered() int { 
	return r.rd.Buffered() 
} 

其結果如下:

https://ithelp.ithome.com.tw/upload/images/20260828/20183331Wf5nmnfMh7.png

AOF 寫時複製

Copy-on-Write CoW 寫時複製 是一個常見的效能加速小技巧,其核心就是「如果一個東西沒有用到,就不要在它身上浪費資源」

目前 resp.(*Commands).aofresp.(*Commands).rawdeep copy 深拷貝,因為有些 Redis 指令在寫入 AOF 時會被變更(例如 SET ... EX ... 需要被更新成 SET ... PX ...),即便 AOF 功能關閉這個複製也會執行。

誠如你所知的,resp.(*Commands).UpdateAOF()resp.(*Commands).MarshalAOF() 只有在 AOF 功能被打開的時候才會執行,我們完全可以在執行這兩個函式的時候才初始化 resp.(*Commands).aof

註:這種技巧其實有點極端,因為以工程的角度來看如果在某些時候才初始化某個結構中的欄位,可能會讓其它開發者在使用這個欄位時心存疑慮,建議只有在有明確證據顯示具有足夠效益時再使用這個手段。

// file: ./pkg/resp/cmd.go
func (cmd *Command) UpdateAOF(i int, v BulkString) { 
	if cmd.aof == nil { 
		cmd.aof = slices.Clone(cmd.raw) 
	} 
 
	if i < len(cmd.aof) { 
		cmd.aof[i] = v 
	} else { 
		cmd.aof = append(cmd.aof, v) 
	} 
} 
  
func (cmd *Command) MarshalAOF() []byte { 
	if cmd.aof == nil { 
		return marshalCommand(cmd.raw) 
	} 
	return marshalCommand(cmd.aof) 
} 

其結果如下:

https://ithelp.ithome.com.tw/upload/images/20260828/20183331FG7T1hcFYU.png

重構 resp.(*Commands)

以下是目前的 resp.(*Commands) 的定義

type Command struct { 
	raw  []BulkString 
	aof  []BulkString 
	cmd  BulkString 
	args []BulkString 
} 

resp.ReadCommand() 的重構之後,我們完全可以把 cmdargs 拿掉:

type Command struct {
	raw  []BulkString
	aof  []BulkString
-	cmd  BulkString
-	args []BulkString
}

然後用 resp.(*Commands).Command()resp.(*Commands).Args() 取而代之:

https://ithelp.ithome.com.tw/upload/images/20260828/20183331IDMyJ7R2BV.png


上一篇
[Day 27] 對互斥鎖的性能剖析
下一篇
[Day 29] 對記憶體進行性能剖析(下)
系列文
從零開始打造 Redis:以 Go 建立 Production Ready 應用程式 29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言