絕大多數的 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 中是同步的,但這可能會留下隱患。
其結果如下:

在 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{}
在某些 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
環境:
結果:
可以見到,吞吐量提升了 20 倍,甚至比 Redis 官方版本還高。
註:相較於官方的 Redis,目前的 Olivine 其實缺少了很多東西,所以不能一概而論地說我的實作就是比官方版要來得好,只能說在經過剖析與最佳化之後我們可以明顯看到性能有著巨大提升。
順帶一提,其實前面在實作的時候有些坑我是刻意埋的,這樣才可以在這個單元中提供更高的情緒價值(?),事實上我們平常在寫程式時如果有遵循比較優秀的實踐的話並不會有這麼高的提升。