Redis 的 Hash 很適合存物件資料,不用每次都把整包 JSON 存進去;只改某個欄位時,直接動那個 field 就好。它其實就是 field 到 value 的映射表。
今天就來搞 Hash 的核心指令:HSET、HGET 和 HDEL。
Go 本來就有 map,所以做 Hash 時底層結構很直覺,先用 map[string][]byte 就能把 field/value 存起來。
我將底層的 Hash 結構定義為:
map[string][]byte
這其中:
"user:1000")。"name")。[]byte("Alice"))。我在 code/db/hash.go 中實作了所有的 Hash 操作方法。
與 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
}
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,想說反正寫成功就好,後來才發現新建跟更新的回傳值不一樣,只好乖乖加個判斷。
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
}
HDELHDEL 允許同時刪除一個 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 的 HSET、HGET、HDEL 做起來。比較容易忽略的是 HSET 回傳值,新欄位和更新欄位不能混在一起。
明天換無序集合 Set 上場,這個引擎的資料結構總算越來越齊了,明天見!