iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

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

[Day 29] 對記憶體進行性能剖析(下)

  • 分享至 

  • xImage
  •  

共享緩衝

絕大多數的 resp.(BulkString) 都很短,但我們卻在讀取時用 buf := make([]byte, sz),這其實相當浪費。

// file: ./pkg/resp/io.go
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) 中定義一個共享的 []byte 作為緩衝區:

// file: ./pkg/resp/io.go
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 
} 

我在 resp.(*Reader).bytesbuf 中定義了一個 4KB 的共享緩衝區,它會在連線之間共享。

註:4KB 的緩衝區其實是經驗法則,如果真的要追求極端性能的話可以考慮對齊 CPU Cacheline 或 Go 在做記憶體分配時的小分頁。

因為 resp.(BulkString).data 會指向 bytesbuf,但它可能會被其它連線寫入時蓋掉,讓我們讀取時讀到非預期的資料;因此我在此處將 resp.(BulkString).data 的型別改成 string,如此一來它就會建立一個不可變的 string copy。

resp.Value 緩衝

同理,我們可以在 resp.(*Reader) 中加入 valuebuf,這是一個在 resp.(*Reader).ReadCommand() 時使用的共享緩衝。

// file: ./pkg/resp/io.go
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 
}

其實最核心的變更就只有 valuebuf 的來源:

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

值得注意的是,要在 values = r.valuebuf[:sz:sz] 之前用 clear(v.valuebuf[:]) 清空,否則可能會遺留上一次的資料(如果上一次傳入的是一個佔用記憶體較多的資料,可能會造成額外開銷)。

註:resp.(*Reader).ReadCommand() 會回傳 valuebuf,這個值是被指向 resp.(*Command).raw 並取得其所有權;在這個案例中這是可接受的,因為 Redis 指令在同一個 pipeline 中是同步的,但這可能會留下隱患。

其結果如下:

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

一些其它事項

ok response

SET 指令成功之後,會回傳 resp.SimpleString("OK")

// file: ./internal/service/cmd/set.go
return resp.SimpleString("OK"), nil

我們可以定義一個 resp.OKValue 來避免這個問題:

var OKValue = ok{}

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

註:其實也可以建立一個共享的 var OKValue = SimpleString("OK"),但是因為 resp.SimpleString 的底層是一個 []byte,如果有外部修改了 OKValue 的值會造成不可預期的情況,因此這邊直接定義一個 type ok struct{}

Logging

在某些 Debugging Log 中會使用 slog.Any("error", err) 來建立 slot.Attr,但如果該 Debug 其實不會顯示(例如線上只顯示 Warn 與 Error,但 Info Log 中就算不會顯示,它的 slog.Attr 也會被建立)

結論

以上就是對當前服務進行記憶體剖析的結果,我們可以比對一下它與先前的效能差異:

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

環境:

  • Macbook Pro 2023 14"
  • CPU: Apple M2 Pro
  • RAM: 32GB

結果:

  • Redis 8.8: 1090512.50 req/s
  • Olivine 在 Day25 (剖析前)的時候:70773.91 req/s
  • Olivine 在 Day29 (剖析後)的時候:1397038.25 req/s

可以見到,吞吐量提升了 20 倍,甚至比 Redis 官方版本還高。

註:相較於官方的 Redis,目前的 Olivine 其實缺少了很多東西,所以不能一概而論地說我的實作就是比官方版要來得好,只能說在經過剖析與最佳化之後我們可以明顯看到性能有著巨大提升。

順帶一提,其實前面在實作的時候有些坑我是刻意埋的,這樣才可以在這個單元中提供更高的情緒價值(?),事實上我們平常在寫程式時如果有遵循比較優秀的實踐的話並不會有這麼高的提升。


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

尚未有邦友留言

立即登入留言