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

可以見到 runtime.gcDrain 佔用了 25.14 秒(約 13.9%)的 CPU 時間,這代表服務花了很多成本在建立與銷毀生命周期較短的物件。
註:此處的 object 物件 指的是會有一定時間佔用記憶體的資料,與 C99 規格書 中所提到的 object 具有類似意義,與 Object-Oriented Programming, OOP 物件導向程式設計 所指涉的物件意義不同。
我們可以從 localhost:6060/debug/pprof/allocs 與 localhost:6060/debug/pprof/heap 取得記憶體分配的火焰圖。
註:不要忘記在
redis-benchmark中使用較大的-n與-r參數。
以下是生成的結果,圖一是 allocs、圖二是 heap:


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,我們可以比對修改前後的記憶體分配情況:

介面是一個非常有用的特性,它可以幫助我們寫下清晰、可讀的程式碼。然而這並不是免費的,介面在運行期有其代價: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()
}
其結果如下:

Copy-on-Write CoW 寫時複製 是一個常見的效能加速小技巧,其核心就是「如果一個東西沒有用到,就不要在它身上浪費資源」
目前 resp.(*Commands).aof 是 resp.(*Commands).raw 的 deep 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)
}
其結果如下:

resp.(*Commands)以下是目前的 resp.(*Commands) 的定義
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
}
然後用 resp.(*Commands).Command() 與 resp.(*Commands).Args() 取而代之:
