Redis 的 Set (集合) 是一個無序且**成員唯一(Unique)**的字串容器。平常開發拿來存標籤(Tags)、IP 白名單或共同好友很方便。
今天來做 Set,先把 SADD、SREM 與 SMEMBERS 這幾個核心命令補起來。
map[string]struct{}?Go 標準庫並沒有提供原生的 Set 資料結構,所以我用 map 來模擬集合。
底層的 Set 結構定義為:
map[string]struct{}
struct{}(空結構體)作為 value?在 Go 裡,struct{} 有個很適合拿來做 Set 的特性:它本身不佔用記憶體空間(0 位元組):
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // 輸出 0
其實我一開始圖方便直接用了 map[string]bool,後來稍微算了一下,發現 bool 雖然很小,但資料量大起來還是會累積成成本。既然 value 根本不需要存資訊,用 struct{} 會更乾淨。
我在 code/db/set.go 中實作了 Set 的方法。
func (db *DB) getOrInitSet(key string) (map[string]struct{}, error) {
e, exists := db.getEntryWithoutLock(key)
if !exists {
s := make(map[string]struct{})
db.data[key] = &entry{
dataType: TypeSet,
val: s,
}
return s, nil
}
if e.dataType != TypeSet {
return nil, ErrWrongType
}
return e.val.(map[string]struct{}), nil
}
SADDSADD 接收多個成員,並將它們加入集合。回傳值為成功加入的新成員數量。若某個成員已存在,則會被忽略,不計入回傳值中:
func (db *DB) SAdd(key string, members ...string) (int, error) {
db.mu.Lock()
defer db.mu.Unlock()
s, err := db.getOrInitSet(key)
if err != nil {
return 0, err
}
added := 0
for _, member := range members {
if _, exists := s[member]; !exists {
s[member] = struct{}{} // 插入空結構體
added++
}
}
return added, nil
}
SREMSREM 從集合中移除一或多個成員,回傳成功移除的成員數量。
與先前相同,當集合的成員數量歸零(len(s) == 0)後,我們必須將該 Key 從資料庫的 map 中刪除,以釋放記憶體:
func (db *DB) SRem(key string, members ...string) (int, error) {
db.mu.Lock()
defer db.mu.Unlock()
e, exists := db.getEntryWithoutLock(key)
if !exists {
return 0, nil
}
if e.dataType != TypeSet {
return 0, ErrWrongType
}
s := e.val.(map[string]struct{})
removed := 0
for _, member := range members {
if _, exists := s[member]; exists {
delete(s, member) // 從集合中刪除
removed++
}
}
// 記憶體自動清理
if len(s) == 0 {
delete(db.data, key)
}
return removed, nil
}
SMEMBERSSMEMBERS 會回傳集合中的所有成員。因為 map 的鍵是無序的,回傳的 slice 順序不固定:
func (db *DB) SMembers(key string) ([]string, error) {
db.mu.Lock() // 需要 Lock,因內部會處理外層 Key 的惰性刪除
defer db.mu.Unlock()
e, exists := db.getEntryWithoutLock(key)
if !exists {
return []string{}, nil
}
if e.dataType != TypeSet {
return nil, ErrWrongType
}
s := e.val.(map[string]struct{})
res := make([]string, 0, len(s))
for member := range s {
res = append(res, member)
}
return res, nil
}
先把 Server 跑起來:
go run ./code/main.go
接著開另一個終端機,用 printf + nc 送 RESP 命令來測試我們的 Set:
# SADD tags go
printf "*3\r\n\$4\r\nSADD\r\n\$4\r\ntags\r\n\$2\r\ngo\r\n" | nc localhost 6379
# 預期回覆::1
# SADD tags java rust
printf "*4\r\n\$4\r\nSADD\r\n\$4\r\ntags\r\n\$4\r\njava\r\n\$4\r\nrust\r\n" | nc localhost 6379
# 預期回覆::2
# SMEMBERS tags
printf "*2\r\n\$8\r\nSMEMBERS\r\n\$4\r\ntags\r\n" | nc localhost 6379
# 預期回覆:*3 接著是 go, java, rust 的 RESP 字串 (回傳順序可能不同)
# SREM tags java
printf "*3\r\n\$4\r\nSREM\r\n\$4\r\ntags\r\n\$4\r\njava\r\n" | nc localhost 6379
# 預期回覆::1
今天把 Set 做完,也順便把 map[string]struct{} 這個 Go 裡常見的 Set 寫法實際用了一次。
明天要搞 Sorted Set,也就是 ZSet。看起來得手寫 Skip List,感覺會掉不少頭髮。