在某些參數下,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 必須要在讀取過值之後才能確定要改成什麼值。
我們將在後續(如果有機會的話)來想辦法解決這個問題。
IFDEQ 與 IFDNE在 Redis 中儲存的值可能非常大(雖然不建議這麼做,但 Redis 最大支援單一鍵值分別可以佔 512MB),如果使用 IFEQ 或 IFNE 的話會有 $O(n)$ 的比對成本,其中 $n$ 表示值的大小。
Redis 支援用 IFDEQ 與 IFDNE 選項來比對摘要,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 實作。