Olivine:以 Go 建構與 Redis 相容的服務 · 章節目錄

第 14 章

Day 14:依既有 Key 條件執行 SET

Redis 的 SET 支援依既有資料決定是否更新的選項:

  • NX:只有 key 不存在時才設定。
  • XX:只有 key 存在時才設定。
# because key "foo" doesn't exist, SET successful.
(redis-cli) > SET foo bar NX
OK
(redis-cli) > GET foo
"bar"
# because key "foo" exists, SET failed.
(redis-cli) > SET foo baz NX
(nil)
(redis-cli) > GET foo
"bar"
  • IFEQ {val}:只有目前值等於 {val} 才設定。
  • IFNE {val}:只有目前值不等於 {val} 才設定。
(redis-cli) > SET foo bar
OK
# SET successful
(redis-cli) > SET foo baz IFEQ bar
OK
# SET failed
(redis-cli) > SET foo bax IFEQ bar

Code Review 與重構

先前只完成部分雛形:

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)
}

它解析 NX、XX、IFEQ、IFNE 等選項,卻還沒有套用條件;條件定義在 cmd,實際上應交由 repo 使用。

此外,repo.SetParam 原本打算處理 object.String、object.Hash,未來可能還有 object.List、object.OrderedSet,但目前直接以 s.storage[param.Obj().Key()] = param.Obj() 寫入,無法處理各型別的特殊行為。

因此需要重構。

SetStringParam interface

定義組合 SetParam 的 SetStringParam:

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,並讓它實作 SetStringParam:

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
}

也要更新 parse() 回傳的型別。

讓 mapStorage.Set() 處理 SetStringParam

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
}

setString() 實作

setString() 需要:

  • 讀取目前值時刪除已到期的 key。
  • 指定 GET 且目前值為 *object.String 時,帶回舊值。
  • 寫入前檢查 NX、XX、IFEQ、IFNE 等條件。
  • 指定 KEEPTTL 時,以目前值的到期時間呼叫 object.SetExpiresAt()。
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
}

注意:目前的 setString() 是過渡方案。未來可以用 factory method pattern 處理 SET、HSET、ZSET 等操作。

AOF 與 KEEPTTL

先前已將 EX、PX 改寫成適合 AOF 的指令,但 KEEPTTL 更難處理:解析指令時無法確定 storage 中現有的到期時間。

因此,目前同時使用 KEEPTTL 與 AOF 仍有非預期行為,留待未來解決。

IFDEQ 與 IFDNE

儲存的字串可能很大,IFEQ、IFNE 的比較成本為 O(n)O(n),其中 nn 為字串長度。

Redis 的 IFDEQ、IFDNE 透過 digest 比較,使用 XXH3。

在 *object.String 加入 digest uint64:

type String struct {
	*base

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

	return n == str.digest
}

寫入時產生 digest:

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

注意:這裡使用 github.com/zeebo/xxh3 計算字串的 XXH3 hash,透過 SIMD 提升速度。