iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

在某些參數下,SET 這個 Redis 指令會需要當前已經儲存的值:

  • NX:只有當 Redis 中不存在該 Key 時才會設置成功
  • XX:只有當 Redis 中存在該 Key 時才會設置成功

以下我們可以觀察一個例子:

> SET foo bar NX # 因為 "foo" 並不存在,所以 SET 會成功
OK
> GET foo
"bar"
> SET foo baz NX # 因為 "foo" 已經存在了,所以 SET 會失敗
(nil)
> GET foo
"bar"
  • IFEQ {val}:只有當目前儲存的值等於 {val} 時,才會設置成功
  • IFNEQ {val}:只有當目前儲存的值不等於 {val} 時,才會設置成功
> SET foo bar
OK
> SET foo baz IFEQ bar # 因為 {"foo":"bar"} 存在,所以設置成功
OK
> SET foo bax IFEQ bar # 因為 {"foo":"bar"} 不存在,所以設置失敗
(nil)

程式碼重構

在之前,我已經先實作了一些功能的原型,但並未完成:

// file: ./internal/service/cmd/set.go

const (
	nx    = iota + 1 // set value only not exists
	xx               // set value only exists
	ifeq             // set only value == cond.Val
	ifne             // set only value != cond.Val
	ifdeq            // set only XXH3(value) == cond.Val
	ifdne            // set only XXH3(value) != cond.Val
)

type parseSetCond struct {
	Typ int
	Val string
}

type parsedSet struct {
	K    string
	V    string
	Cond parseSetCond
	Get  bool
	Exp  *time.Time
}

var _ repo.SetParam = parsedSet{}

func (parsed parsedSet) Obj() object.Object {
	return object.NewString(parsed.K, parsed.V, parsed.Exp)
}

首先,它會解析 condition options(條件選項)NX, XX, IFEQ, IFNE 等),但並未使用。

再者,這些常數與實作定義在 cmd 中,但它們會在 repo 中被使用,這會導致循環依賴。

另一方面,repo.(SetParam) 會接收所有型態的輸入,包括但不限於 object.(String)object.(Hash) (或許未來還會實現 object.(List)object.(OrderedSet)),然而它們在 storage 中是以 s.storage[param.Obj().Key()] = param.Obj() 的方式被直接存入。

綜合以上因素,該是時候來重構程式。

repo.(SetStringParam) 介面

首先,定義一個 repo.(SetStringParam) 介面,其中內嵌了 repo.(SetParam)

// file: ./internal/repo/stoage.go
const (
	CondNX    Cond = iota + 1 // set value only not exists
	CondXX                    // set value only exists
	CondIFEQ                  // set only value == cond.Val
	CondIFNE                  // set only value != cond.Val
	CondIFDEQ                 // set only XXH3(value) == cond.Val
	CondIFDNE                 // set only XXH3(value) != cond.Val
)

type SetStringParam interface {
	SetParam

	CondType() Cond
	CondValue() string
	ExpiresAt() *time.Time
	KeepTTL() bool

	GetCurrent() bool
	SetCurrent(*object.String)
}

並且將 cmd.(parsedSet) 改為 cmd.(setparams),同時實作 repo.(SetStringParam) 介面:

// file: ./internal/service/cmd/set.go
type setparams struct {
	key string
	val string

	// NX|XX|IFEQ|IFNE|IFDEQ|IFDNE
	condType  repo.Cond
	condValue string

	// GET
	get bool
	cur *object.String

	// EX|PX|EXAT|PXAT
	exp     *time.Time
	keepTTL bool
}

var _ repo.SetStringParam = &setparams{}

func (p *setparams) Obj() object.Object {
	return object.NewString(p.key, p.val, p.exp)
}

func (p *setparams) CondType() repo.Cond {
	return p.condType
}

func (p *setparams) CondValue() string {
	return p.condValue
}

func (p *setparams) ExpiresAt() *time.Time {
	return p.exp
}

func (p *setparams) KeepTTL() bool {
	return p.keepTTL
}

func (p *setparams) GetCurrent() bool {
	return p.get
}

func (p *setparams) SetCurrent(cur *object.String) {
	p.cur = cur
}

mapStorage.Set() 處理 repo.(SetStringParam)

// file: ./internal/repo/storage.go
func (s *mapStorage) Set(_ context.Context, param SetParam) error {
	if strparam, ok := param.(SetStringParam); ok {
		return s.setString(strparam)
	}

	return errors.New("unimplemented")
}

func (s *mapStorage) setString(param SetStringParam) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.storage[param.Obj().Key()] = param.Obj()

	return nil
}

repo.(*mapStorage).setString() 的實作

repo.(*mapStorage).setString() 具有一些特性:

  • 取得資料時,如果發現資料已經過期就刪除它(被動刪除)
  • 如果有 GET 選項的話,需要把目前 Redis 儲存的值放回 object.(*String)
  • 檢查條件選項(NX, XX…等),如果不符合條件的話就中止
  • 如果有 KEEPTTL 選項的話,用 object.SetExpiresAt() 將當前 Redis 中儲存的值的過期時間放到新值上
// file: ./internal/repo/storage.go
func (s *mapStorage) setString(param SetStringParam) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	obj := param.Obj()

	cur, exists := s.storage[obj.Key()]
	if cur != nil && cur.Expired() {
		delete(s.storage, obj.Key())
		cur = nil
		exists = false
	}

	if param.GetCurrent() {
		if !exists {
			param.SetCurrent(nil)
		} else {
			if curstr, ok := cur.(*object.String); !ok {
				return ErrTypeMismatch
			} else {
				param.SetCurrent(curstr)
			}
		}
	}
	if err := s.checkStringCond(param, cur, exists); err != nil {
		return fmt.Errorf("%w: %w", ErrCondMismatch, err)
	}
	if param.KeepTTL() && cur != nil {
		obj.SetExpiresAt(cur.ExpiresAt())
	}

	s.storage[obj.Key()] = obj

	return nil
}

func (s *mapStorage) checkStringCond(param SetStringParam, current object.Object, exists bool) error {
	switch param.CondType() {
	case 0:
		return nil
	case CondNX:
		if exists {
			return errors.New("data found")
		}
	case CondXX:
		if !exists {
			return errors.New("data not found")
		}
	case CondIFEQ:
		if !exists {
			return errors.New("data not found")
		}
		if str, ok := current.(*object.String); !ok {
			return ErrTypeMismatch
		} else if !str.Equals(param.CondValue()) {
			return errors.New("data mismatch")
		}
	case CondIFNE:
		if !exists {
			return errors.New("data not found")
		}
		if str, ok := current.(*object.String); !ok {
			return ErrTypeMismatch
		} else if str.Equals(param.CondValue()) {
			return errors.New("data match")
		}
	case CondIFDEQ:
		if !exists {
			return errors.New("data not found")
		}
		if str, ok := current.(*object.String); !ok {
			return ErrTypeMismatch
		} else if !str.EqualsDigest(param.CondValue()) {
			return errors.New("data match")
		}
	case CondIFDNE:
		if !exists {
			return errors.New("data not found")
		}
		if str, ok := current.(*object.String); !ok {
			return ErrTypeMismatch
		} else if str.EqualsDigest(param.CondValue()) {
			return errors.New("data match")
		}
	default:
		panic(fmt.Sprintf("unknow condition type: %d", param.CondType()))
	}

	return nil
}

關於 KEEPTTL 與 AOF

要讓 AOF 支援 KEEPTTL 選項是一個滿大的挑戰。

在 Redis 指令解析的過程中,我們已經會將 EX PX 這樣的選項統一改為 PXAT,然而 KEEPTTL 必須要在讀取過值之後才能確定要改成什麼值。

我們將在後續(如果有機會的話)來想辦法解決這個問題。

IFDEQIFDNE

在 Redis 中儲存的值可能非常大(雖然不建議這麼做,但 Redis 最大支援單一鍵值分別可以佔 512MB),如果使用 IFEQIFNE 的話會有 $O(n)$ 的比對成本,其中 $n$ 表示值的大小。

Redis 支援用 IFDEQIFDNE 選項來比對摘要,Redis 目前是使用 XXH3 這個摘要演算法。

object.(*String) 中加入 digest uint64

// file: internal/repo/object/string.go
type String struct {
	*base

	val    string
	digest uint64
}

// ...

func NewString(k, v string, expiresAt *time.Time) *String {
	return &String{
		val:    v,
		digest: xxh3.HashString(v),
		base:   &base{key: k, expiresAt: expiresAt},
	}
}


func (str *String) EqualsDigest(v string) bool {
	n, err := strconv.ParseUint(v, 10, 0)
	if err != nil {
		return false
	}

	return n == str.digest
}

註:我使用了 zeebo/xxh3 這個套件,這是一個充份利用現代 cpu 特性進行特化的 XXH3 實作。


上一篇
[Day 24] 過期處理(下):主動刪除與 AOF 處理
下一篇
[Day 26] 用 pprof 進行性能剖析
系列文
從零開始打造 Redis:以 Go 建立 Production Ready 應用程式 29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言