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

第 17 章

Day 17:記憶體效能分析

完成 Day 15 與 Day 16 後,我取得以下 flame graph:

Image

runtime.gcDrain 佔用 25.14 秒(13.9%)CPU 時間,因此我開始關注頻繁建立、回收的短生命週期物件。

Memory Profiling

Benchmark 後,可以分別從 localhost:6060/debug/pprof/allocs 與 localhost:6060/debug/pprof/heap 取得 allocation 與 heap 資料。

注意:這章使用 redis-benchmark -p 16379 -t SET -P 16 -n 10000000 -r 1000000。

Allocation flame graph:

Image

Heap flame graph:

Image

兩者的差異如下:

  • allocs:記憶體配置歷史,包含仍存活與已回收的物件。
  • heap:目前仍存活、或尚未被 GC 回收的記憶體。

因為 runtime.gcDrain 佔用較多時間,先關注 allocs。

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

原本的 resp.(*Reader).readLine():

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)
	}
}

讀取過程可能多次擴充 bytes.Buffer。改用 r.rd.ReadSlice('\n'),重用 bufio.Reader 的 buffer:

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 比較修改前後:

Image

Interface 與型別轉換

Interface 有助於建立清楚易讀的程式碼,但這裡的 interface 與型別轉換也帶來部分 heap allocation。

resp.ReadCommand() 的流程如下:

  • rd.Read() 回傳 resp.Value interface。
  • arr, ok := v.(Array) 將其轉為 resp.Array。
  • cmd, ok := arr.data[0].(BulkString) 將第一個元素轉為 BulkString。
  • 其餘元素也逐一轉成 BulkString,形成 args。
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() 建立 fast path,直接讀取由 Bulk Strings 組成的 Array:

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()
}

resp.ReadCommand() 改為直接呼叫它:

func ReadCommand(rd *Reader) (*Command, error) {
	values, err := rd.ReadCommand()
	if err != nil {
		return nil, fmt.Errorf("%w: %w", ErrProtocol, err)
	}
	if len(values) == 0 {
		return nil, fmt.Errorf("%w: empty array", ErrProtocol)
	}

	return &Command{
		raw:  values,
		aof:  slices.Clone(values),
		cmd:  values[0],
		args: values[1:],
	}, nil
}

結果

Image

AOF Copy-on-Write

原本 Command.aof 是 Command.raw 的副本,因為寫入 AOF 時,SET ... EX ... 可能改成 SET ... PXAT ...。但即使 AOF 關閉,也會建立這份副本。

UpdateAOF() 與 MarshalAOF() 只在啟用 AOF 時使用,因此可以將初始化延後到這些 methods,而不在 resp.ReadCommand() 先做:

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)
}

結果

Image

重構 Command 欄位

原本的定義:

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

重構 resp.ReadCommand() 後,不再需要獨立的 cmd 與 args 欄位:

type Command struct {
	raw  []BulkString
	aof  []BulkString
-	cmd  BulkString
-	args []BulkString
}
func (cmd *Command) Command() string {
	return strings.ToUpper(string(cmd.raw[0].data))
}

func (cmd *Command) Args() []BulkString {
	return cmd.raw[1:]
}

結果

Image

共用 Buffer

多數 BulkString 都很短,但每次讀取前仍執行 buf := make([]byte, sz):

func (r *Reader) readBulkString() (BulkString, error) {
	sz, err := r.readInt()
	if err != nil {
		return BulkString{}, err
	}
	if sz < 0 { // Ref: https://redis.io/docs/latest/develop/reference/protocol-spec/#null-bulk-strings
		return BulkString{null: true}, nil
	}

	buf := make([]byte, sz)
	if _, err := io.ReadFull(r.rd, buf); err != nil {
		return BulkString{}, err
	}

	// Drop tailing "\r\n" from reader.
	if cr, err := r.rd.ReadByte(); err != nil || cr != '\r' {
		return BulkString{}, errors.New("unexpected sentinel")
	}
	if nl, err := r.rd.ReadByte(); err != nil || nl != '\n' {
		return BulkString{}, errors.New("unexpected sentinel")
	}

	return BulkString{data: buf}, nil
}

可以在 resp.Reader 加入共用 buffer:

type Reader struct {
	rd       *bufio.Reader
+	bytesbuf [1 << 12]byte
}
func (r *Reader) readBulkString() (BulkString, error) {
	sz, err := r.readInt()
	if err != nil {
		return BulkString{}, err
	}
	if sz < 0 { // Ref: https://redis.io/docs/latest/develop/reference/protocol-spec/#null-bulk-strings
		return BulkString{null: true}, nil
	}

	var buf []byte
	if sz <= len(r.bytesbuf) {
		buf = r.bytesbuf[:sz:sz]
	} else {
		buf = make([]byte, sz)
	}
	if _, err := io.ReadFull(r.rd, buf); err != nil {
		return BulkString{}, err
	}

	// Drop tailing "\r\n" from reader.
	if cr, err := r.rd.ReadByte(); err != nil || cr != '\r' {
		return BulkString{}, errors.New("unexpected sentinel")
	}
	if nl, err := r.rd.ReadByte(); err != nil || nl != '\n' {
		return BulkString{}, errors.New("unexpected sentinel")
	}

	return BulkString{data: string(buf)}, nil
}

這個 4096-byte 的 bytesbuf 在同一條連線的不同請求間重用。

但若 BulkString.data 直接參照它,下一個請求就會覆寫資料。因此將 BulkString.data 從 []byte 改成 string,建立 BulkString 時複製字串。

valuebuf

同樣可以在 resp.Reader 為 ReadCommand() 加入共用 valuebuf:

type Reader struct {
	rd       *bufio.Reader
+	valuebuf [8]BulkString
	bytesbuf [1 << 12]byte
}
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
	}

	var values []BulkString
	if sz <= len(r.valuebuf) {
		clear(r.valuebuf[:])
		values = r.valuebuf[:sz:sz]
	} else {
		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
}

sz <= 8 時使用 r.valuebuf,否則才配置新的 slice:

	var values []BulkString
	if sz <= len(r.valuebuf) {
		clear(r.valuebuf[:])
		values = r.valuebuf[:sz:sz]
	} else {
		values = make([]BulkString, sz)
	}

重新指派前要清空 r.valuebuf,避免保留前一個請求的大字串參照。

注意:ReadCommand() 可能將共用 valuebuf 交給 Command.raw。這裡的 command pipeline 依序處理,所以我接受這個設計;若跨 goroutines 分享或保留資料,仍有風險。

結果

Image

其他調整

OK 回應

SET 成功時回傳 +OK\r\n,原本每次都建立新的 resp.SimpleString:

	return resp.SimpleString("OK"), nil

可以改用預先定義的 resp.OKValue:

var OKValue = ok{}

type ok struct{}

func (ok) Marshal() []byte {
	return []byte("+OK\r\n")
}

Debug Logging

Debug logging 會建立 slog.Any("error", err),消耗 CPU 與配置記憶體。可以移除或停用。

測量結果

我以以下參數比較 Redis、Day 14 的 Olivine 與 profiling 後的版本:

$ redis-benchmark -t SET -P 16 -n 10000000 -r 1000000

測試環境:

  • MacBook Pro 2023,14 吋。
  • CPU:Apple M2 Pro。
  • RAM:32GB。
  • SSD:512GB。

測試結果:

  • Redis 8.8:1090512.50 req/s。
  • Olivine Day 14:70773.91 req/s。
  • Olivine profiling 後:1397038.25 req/s。

在上述環境與測試參數下,Olivine 的吞吐量提升接近 20 倍,並在這組測量中高於 Redis。