iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 11

Day 11:實作核心資料結構 Set 與集合操作命令

  • 分享至 

  • xImage
  •  

Redis 的 Set (集合) 是一個無序且**成員唯一(Unique)**的字串容器。平常開發拿來存標籤(Tags)、IP 白名單或共同好友很方便。

今天來做 Set,先把 SADDSREMSMEMBERS 這幾個核心命令補起來。


為什麼在 Go 中使用 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 的方法。

1. 初始化與類型檢查

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
}

2. 添加成員:SADD

SADD 接收多個成員,並將它們加入集合。回傳值為成功加入的新成員數量。若某個成員已存在,則會被忽略,不計入回傳值中:

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
}

3. 移除成員:SREM

SREM 從集合中移除一或多個成員,回傳成功移除的成員數量。

與先前相同,當集合的成員數量歸零(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
}

4. 獲取所有成員:SMEMBERS

SMEMBERS 會回傳集合中的所有成員。因為 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,感覺會掉不少頭髮。


上一篇
Day 10:實作核心資料結構 Hash 與欄位操作命令
下一篇
Day 12:實作跳躍表(Skip List)與有序集合 Sorted Set 命令
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言