iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

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

Day 10:實作核心資料結構 Hash 與欄位操作命令

  • 分享至 

  • xImage
  •  

Redis 的 Hash 很適合存物件資料,不用每次都把整包 JSON 存進去;只改某個欄位時,直接動那個 field 就好。它其實就是 field 到 value 的映射表。

今天就來搞 Hash 的核心指令:HSETHGETHDEL


Go 原生 Map 在 Hash 中的應用

Go 本來就有 map,所以做 Hash 時底層結構很直覺,先用 map[string][]byte 就能把 field/value 存起來。

我將底層的 Hash 結構定義為:

map[string][]byte

這其中:

  • 外層鍵(Key)是 Redis 中的資料庫鍵(例如 "user:1000")。
  • 內層鍵(Field)是 Hash 物件中的欄位名稱(例如 "name")。
  • 值(Value)是欄位對應的二進位安全資料(例如 []byte("Alice"))。

核心設計與實作

我在 code/db/hash.go 中實作了所有的 Hash 操作方法。

1. 延遲初始化與型別檢查

與 List 的設計一致,如果寫入時 Key 不存在,自動初始化一個 map[string][]byte;若類型衝突則回傳 ErrWrongType 錯誤:

func (db *DB) getOrInitHash(key string) (map[string][]byte, error) {
	e, exists := db.getEntryWithoutLock(key)
	if !exists {
		h := make(map[string][]byte)
		db.data[key] = &entry{
			dataType: TypeHash,
			val:      h,
		}
		return h, nil
	}

	if e.dataType != TypeHash {
		return nil, ErrWrongType
	}

	return e.val.(map[string][]byte), nil
}

2. 設定欄位值:HSET

依據 Redis 規範,HSET 的回傳值包含狀態資訊:

  • 1:代表這是一個新建立的欄位。
  • 0:代表該欄位本來就存在,這次操作僅是**更新(Overwrite)**它的值。
func (db *DB) HSet(key, field string, val []byte) (int, error) {
	db.mu.Lock()
	defer db.mu.Unlock()

	h, err := db.getOrInitHash(key)
	if err != nil {
		return 0, err
	}

	_, fieldExists := h[field]
	h[field] = val // 寫入或更新

	if !fieldExists {
		return 1, nil // 新建欄位
	}
	return 0, nil // 更新欄位
}

說實在,一開始 HSET 回傳值我直接回 0,想說反正寫成功就好,後來才發現新建跟更新的回傳值不一樣,只好乖乖加個判斷。

3. 獲取欄位值:HGET

獲取指定欄位的值,回傳資料以及該欄位是否存在的布林值:

func (db *DB) HGet(key, field string) ([]byte, bool, error) {
	db.mu.Lock() // 需要 Lock,因內部會處理外層 Key 的惰性刪除
	defer db.mu.Unlock()

	e, exists := db.getEntryWithoutLock(key)
	if !exists {
		return nil, false, nil // 鍵不存在
	}

	if e.dataType != TypeHash {
		return nil, false, ErrWrongType
	}

	h := e.val.(map[string][]byte)
	val, fieldExists := h[field]
	return val, fieldExists, nil
}

4. 刪除欄位:HDEL

HDEL 允許同時刪除一個 Hash 中的多個欄位,並回傳成功刪除的欄位個數。

這裡也要注意空容器清理:當一個 Hash 裡的欄位都刪光(len(h) == 0)後,這個 Key 就應該一起從資料庫 map 裡拿掉。

func (db *DB) HDel(key string, fields ...string) (int, error) {
	db.mu.Lock()
	defer db.mu.Unlock()

	e, exists := db.getEntryWithoutLock(key)
	if !exists {
		return 0, nil
	}

	if e.dataType != TypeHash {
		return 0, ErrWrongType
	}

	h := e.val.(map[string][]byte)
	deleted := 0

	for _, field := range fields {
		if _, fieldExists := h[field]; fieldExists {
			delete(h, field) // 從 map 中移除欄位
			deleted++
		}
	}

	// 記憶體清理:若 Hash 為空,清除此鍵
	if len(h) == 0 {
		delete(db.data, key)
	}

	return deleted, nil
}

跑起來看看

最後來測試 Hash 的三個主要操作,看它們是不是真的能管好裡面的 key-value。

$ go run ./code/main.go

printf 操作 HSET/HGET/HDEL:

# HSET 設定 user:1 的 name 為 john
$ printf "*4\r\n\$4\r\nHSET\r\n\$6\r\nuser:1\r\n\$4\r\nname\r\n\$4\r\njohn\r\n" | nc localhost 6379
# 預期回覆::1

# HGET 取得 user:1 的 name
$ printf "*3\r\n\$4\r\nHGET\r\n\$6\r\nuser:1\r\n\$4\r\nname\r\n" | nc localhost 6379
# 預期回覆:$4\r\njohn

# HDEL 刪除 user:1 的 name
$ printf "*3\r\n\$4\r\nHDEL\r\n\$6\r\nuser:1\r\n\$4\r\nname\r\n" | nc localhost 6379
# 預期回覆::1

# 再次 HGET 看是不是真的刪了
$ printf "*3\r\n\$4\r\nHGET\r\n\$6\r\nuser:1\r\n\$4\r\nname\r\n" | nc localhost 6379
# 預期回覆:$-1 (Null Bulk String)

看到回傳 Null Bulk String ($-1),這 Hash 算是有模有樣了。明天再來挑戰更複雜的結構!

總結

今天把 Hash 的 HSETHGETHDEL 做起來。比較容易忽略的是 HSET 回傳值,新欄位和更新欄位不能混在一起。

明天換無序集合 Set 上場,這個引擎的資料結構總算越來越齊了,明天見!


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

尚未有邦友留言

立即登入留言