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

第 13 章

Day 13:Key 到期機制

目前使用 map[string]string 儲存資料:

type mapStorage struct {
	storage map[string]string
	mu      sync.RWMutex
}

這個簡單結構無法直接處理資料到期。

如何實作?

根據 Redis 的到期機制說明,key 會以被動與主動兩種方式到期:被動方式在 client 存取已到期的 key 時刪除;但有些 key 到期後不再被存取,因此還需要定期抽查帶有到期時間的 keys,刪除其中已到期的資料。

因此 value 除了字串,還需要記錄 expiration。

Object

定義 Object 型別,取代原本儲存的 string,把資料與到期資訊放在一起:

package object

import "time"

type Object interface {
	Key() string
	ExpiresAt() *time.Time
}

type base struct {
	key       string
	expiresAt *time.Time
}

func (obj base) Key() string {
	return obj.key
}

func (obj base) ExpiresAt() *time.Time {
	return obj.expiresAt
}

String 是可儲存的 Object 型別之一:

package object

import "time"

type String struct {
	base

	val string
}

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

func (str *String) String() string {
	return str.val
}

更新 mapStorage:

// file: internal/repo/storage.go
func NewStorage() Storage {
	s := mapStorage{}
-	s.storage = make(map[string]string)
+	s.storage = make(map[string]object.Object)

	return &s
}

type mapStorage struct {
-	storage map[string]string
+	storage map[string]object.Object
	mu      sync.Mutex
}
func (s *mapStorage) Set(_ context.Context, obj object.Object) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.storage[obj.Key()] = obj
	return nil
}

func (s *mapStorage) Get(_ context.Context, k string) (v object.Object, err error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	var ok bool
	if v, ok = s.storage[k]; !ok {
		return nil, fmt.Errorf("%w: miss", ErrNotFound)
	}
	if v.ExpiresAt() != nil && time.Now().After(*v.ExpiresAt()) {
		delete(s.storage, k) // remove key from storage when expired
		return nil, fmt.Errorf("%w: expired", ErrNotFound)
	}

	return
}

讀取時刪除已到期的 key,就是被動刪除。

讓 SET 支援到期時間

SET 可以使用以下選項:

  • EX {sec}:在指定秒數後到期。
  • PX {msec}:在指定毫秒數後到期。
  • EXAT {usec}:在指定 Unix timestamp 到期,單位為秒。
  • PXAT {umsec}:在指定 Unix timestamp 到期,單位為毫秒。

完整語法如下:

SET key value [NX | XX | IFEQ ifeq-value | IFNE ifne-value |
  IFDEQ ifdeq-digest | IFDNE ifdne-digest] [GET] [EX seconds |
  PX milliseconds | EXAT unix-time-seconds |
  PXAT unix-time-milliseconds | KEEPTTL]

為 Set 加入 parse(),回傳 (parsedSet, error):

func (c *Set) parse(cmd *resp.Command) (p parsedSet, err error) {
	const (
		awaitingKey = iota
		awaitingValue
		awaitingOption
		awaitingConditionValue
		awaitingPostConditionOption
		awaitingPostGetOption
		awaitingExpirationValue
		done
		invalid
	)

	state := awaitingKey
	var conditionType int
	var expirationOption string
	var expirationOptionIndex int

	for i, arg := range cmd.Args() {
		switch state {
		case awaitingKey:
			p.K = arg.String()
			state = awaitingValue
		case awaitingValue:
			p.V = arg.String()
			state = awaitingOption
		case awaitingOption:
			switch strings.ToUpper(arg.String()) {
			case "NX":
				p.Cond.Typ = nx
				state = awaitingPostConditionOption
			case "XX":
				p.Cond.Typ = xx
				state = awaitingPostConditionOption
			case "IFEQ":
				conditionType = ifeq
				state = awaitingConditionValue
			case "IFNE":
				conditionType = ifne
				state = awaitingConditionValue
			case "IFDEQ":
				conditionType = ifdeq
				state = awaitingConditionValue
			case "IFDNE":
				conditionType = ifdne
				state = awaitingConditionValue
			case "GET":
				p.Get = true
				state = awaitingPostGetOption
			case "EX", "PX", "EXAT", "PXAT":
				expirationOption = strings.ToUpper(arg.String())
				expirationOptionIndex = i
				state = awaitingExpirationValue
			case "KEEPTTL":
				p.Exp = new(time.Time)
				state = done
			default:
				state = invalid
			}
		case awaitingConditionValue:
			p.Cond = parseSetCond{Typ: conditionType, Val: arg.String()}
			state = awaitingPostConditionOption
		case awaitingPostConditionOption:
			switch strings.ToUpper(arg.String()) {
			case "GET":
				p.Get = true
				state = awaitingPostGetOption
			case "EX", "PX", "EXAT", "PXAT":
				expirationOption = strings.ToUpper(arg.String())
				expirationOptionIndex = i
				state = awaitingExpirationValue
			case "KEEPTTL":
				p.Exp = new(time.Time)
				state = done
			default:
				state = invalid
			}
		case awaitingPostGetOption:
			switch strings.ToUpper(arg.String()) {
			case "EX", "PX", "EXAT", "PXAT":
				expirationOption = strings.ToUpper(arg.String())
				expirationOptionIndex = i
				state = awaitingExpirationValue
			case "KEEPTTL":
				p.Exp = new(time.Time)
				state = done
			default:
				state = invalid
			}
		case awaitingExpirationValue:
			p.Exp, err = parseExpiration(expirationOption, arg.String())
			if err != nil {
				return parsedSet{}, fmt.Errorf("%w: %w", ErrSyntax, err)
			}
			state = done
		case done:
			state = invalid
		}
	}

	if state != awaitingOption && state != awaitingPostConditionOption && state != awaitingPostGetOption && state != done {
		return parsedSet{}, fmt.Errorf("%w: invalid SET syntax", ErrSyntax)
	}

	if p.Exp != nil && !p.Exp.IsZero() {
		cmd.UpdateAOF(expirationOptionIndex+1, resp.NewBulkString("PXAT"))
		cmd.UpdateAOF(expirationOptionIndex+2, resp.NewBulkString(strconv.FormatInt(p.Exp.UnixMilli(), 10)))
	}

	return p, nil
}

func parseExpiration(option, value string) (*time.Time, error) {
	switch option {
	case "EX", "PX":
		unit := "s"
		if option == "PX" {
			unit = "ms"
		}

		duration, err := parseDuration(value, unit)
		if err != nil {
			return nil, err
		}

		return new(time.Now().Add(duration)), nil
	case "EXAT", "PXAT":
		timestamp, err := strconv.Atoi(value)
		if err != nil {
			return nil, err
		}
		if timestamp <= 0 {
			return nil, errors.New("invalid expire time")
		}
		if option == "EXAT" {
			return new(time.Unix(int64(timestamp), 0)), nil
		}

		return new(time.UnixMilli(int64(timestamp))), nil
	}

	return nil, fmt.Errorf("unknown expiration option %q", option)
}

func parseDuration(s, unit string) (time.Duration, error) {
	if _, err := strconv.Atoi(s); err != nil {
		return 0, fmt.Errorf("invalid expire time: %w", err)
	}

	var sb strings.Builder
	sb.WriteString(s)
	sb.WriteString(unit)

	if d, err := time.ParseDuration(sb.String()); err != nil {
		return d, err
	} else if d <= 0 {
		return d, errors.New("invalid expire time")
	} else {
		return d, nil
	}
}

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
}

注意:這裡以 finite state machine 建立 parser。程式較長,但狀態轉移清楚。

SetParam interface

Set.parse() 回傳 parsedSet,而不是直接將 object.Object 傳給 repo.Storage.Set()。因此讓 storage 接受 repo.SetParam interface,再由 parsedSet 實作它:

type SetParam interface {
	Obj() object.Object
}
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)
}

var _ repo.SetParam = parsedSet{} 能在編譯時確認型別符合介面。

更新 repo.Storage.Set():

func (s *mapStorage) Set(_ context.Context, param SetParam) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.storage[param.Obj().Key()] = param.Obj()
	return nil
}

這讓未來的 storage 除了 object.String,也能處理 HSET 所需的 object.Hash 等型別。

主動到期

前面完成的是存取時刪除已到期資料。主動方式則定期抽查並清除到期 keys。Redis 每 100 毫秒執行這類工作。

使用 Worker 重構

我們已經用 goroutine 實作過背景工作:依設定每秒同步 AOF。

建立 service.Worker 統一管理這些 goroutines,讓 Application.Run() 流程更清楚:

package service

import (
	"context"
	"errors"
	"time"

	"golang.org/x/sync/errgroup"

	"olivine/internal/data"
	"olivine/internal/repo"
)

type Worker interface {
	Start(context.Context) error
}

func NewWorker(cfg *data.Config, aof AOF, storage repo.Storage) Worker {
	return &worker{
		cfg: cfg,

		aof:     aof,
		storage: storage,
	}
}

type worker struct {
	cfg *data.Config

	storage repo.Storage
	aof     AOF
}

func (w *worker) Start(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	if w.cfg.AOFEnabled && w.cfg.AOFFsync == data.AOFFsyncEverySec {
		g.Go(w.aofSyncer(ctx))
	}

	g.Go(w.storagePruner(ctx))

	return g.Wait()
}

func (w *worker) aofSyncer(ctx context.Context) func() error {
	return func() error {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return nil
			case <-ticker.C:
				if err := w.aof.Sync(); err != nil {
					return err
				}
			}
		}
	}
}

再於應用程式的 errgroup goroutine 呼叫 Worker.Start()。Worker 的實作如下:

package service

import (
	"context"
	"time"

	"golang.org/x/sync/errgroup"

	"olivine/internal/data"
)

type Worker interface {
	Start(context.Context) error
}

func NewWorker(cfg *data.Config, aof AOF) Worker {
	return &worker{
		cfg: cfg,
		aof: aof,
	}
}

type worker struct {
	cfg *data.Config

	aof AOF
}

func (w *worker) Start(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	if w.cfg.AOFEnabled && w.cfg.AOFFsync == data.AOFFsyncAlways {
		g.Go(w.aofSyncer(ctx))
	}

	return g.Wait()
}

func (w *worker) aofSyncer(ctx context.Context) func() error {
	return func() error {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return nil
			case <-ticker.C:
				if err := w.aof.Sync(); err != nil {
					return err
				}
			}
		}
	}
}

Storage.Prune() 與 Worker 註冊

func (s *mapStorage) Prune(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			if stop := s.tryPrune(); stop {
				return nil
			}
		}
	}
}

func (s *mapStorage) tryPrune() bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	if len(s.storage) == 0 {
		return true
	}

	const sampleSize = 10

	sampled := 0
	expired := 0
	now := time.Now()

	for k, v := range s.storage {
		if sampled == sampleSize {
			break
		}

		sampled++
		if v.ExpiresAt() != nil && now.After(*v.ExpiresAt()) {
			delete(s.storage, k)
			expired++
		}
	}

	return expired*4 < sampled
}
// file: internal/service/worker.go
func (w *worker) Start(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	if w.cfg.AOFEnabled && w.cfg.AOFFsync == data.AOFFsyncEverySec {
		g.Go(w.aofSyncer(ctx))
	}

+	g.Go(w.storagePruner(ctx))

	return g.Wait()
}

+func (w *worker) storagePruner(ctx context.Context) func() error {
+	return func() error {
+		ticker := time.NewTicker(time.Millisecond * 100)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-ctx.Done():
+				return nil
+			case <-ticker.C:
+				if err := w.storage.Prune(ctx); err != nil {
+					if ctx.Err() != nil && errors.Is(err, ctx.Err()) {
+						return nil
+					}
+					return err
+				}
+			}
+		}
+	}
+}

到期資料與 AOF

直接把原始 SET 寫入 AOF,會在還原時產生意料外的結果:

  1. 00:00:00:執行 SET foo bar EX 10,同時寫入 storage 與 AOF。
  2. 00:00:05:GET foo 回傳 bar。
  3. 00:00:11:key 到期,GET foo 回傳 (nil)。
  4. 00:01:00:關閉服務。
  5. 01:00:00:啟動服務,重播 AOF。
  6. 01:00:05:GET foo 又回傳 bar,不符合預期。

解法是將 AOF 中的相對到期時間改為絕對 Unix timestamp。

在 resp.Command 加入 aof

帶有到期選項的 SET 需要寫入不同版本的指令,所以在 resp.Command 加入供 AOF writer 使用的 aof:

type Command struct {
	raw  Value
+	aof  Array
	cmd  BulkString
	args []BulkString
}

aof 是 raw 的 deep clone;resp.ReadCommand() 已確認 raw 為 resp.Array。

func ReadCommand(rd *Reader) (*Command, error) {
	v, err := rd.Read()
	if err != nil {
		return nil, err
	}

	arr, ok := v.(Array)
	if !ok {
		return nil, fmt.Errorf("%w: expected array, got %T(%+v)", ErrProtocol, v, v)
	}
	if arr.null || len(arr.data) == 0 {
		return nil, fmt.Errorf("%w: empty array", ErrProtocol)
	}

	cmd, ok := arr.data[0].(BulkString)
	if !ok {
		return nil, fmt.Errorf("%w: command expected bulk string, got %T(%+v)", ErrProtocol, arr.data[0], arr.data[0])
	}

	args := make([]BulkString, 0, len(arr.data)-1)
	for i := range arr.data[1:] {
		arg, ok := arr.data[i+1].(BulkString)
		if !ok {
			return nil, fmt.Errorf("%w: argument [%d] expected bulk string, got %T(%+v)", ErrProtocol, i, arr.data[i+1], arr.data[i+1])
		}
		args = append(args, arg)
	}

	return &Command{
		raw:  v,
		aof:  arr.Clone(),
		cmd:  cmd,
		args: args,
	}, nil
}

加入 UpdateAOF():

func (cmd *Command) UpdateAOF(i int, v Value) {
	cmd.aof.data[i] = v
}

在 SET 呼叫 UpdateAOF()

	if p.Exp != nil && !p.Exp.IsZero() {
		cmd.UpdateAOF(expirationOptionIndex+1, resp.NewBulkString("PXAT"))
		cmd.UpdateAOF(expirationOptionIndex+2, resp.NewBulkString(strconv.FormatInt(p.Exp.UnixMilli(), 10)))
	}

將 EX、PX 改寫為 PXAT 後再寫入 AOF。

TTL 指令

實作並註冊 TTL:

package cmd

import (
	"context"
	"errors"
	"fmt"
	"time"

	"olivine/internal/repo"
	"olivine/pkg/resp"
)

type TTL struct {
	storage repo.Storage
}

func NewTTL(storage repo.Storage) *TTL {
	return &TTL{
		storage: storage,
	}
}

func (c *TTL) Command() string {
	return "TTL"
}

func (c *TTL) Exec(ctx context.Context, cmd *resp.Command) (resp.Value, error) {
	if err := c.parse(cmd); err != nil {
		return nil, err
	}

	args := cmd.Args()

	k := args[0]
	v, err := c.storage.Get(ctx, k.String())
	if err != nil {
		if errors.Is(err, repo.ErrNotFound) {
			return resp.Integer(-2), nil
		}
		return nil, fmt.Errorf("%w: %w", ErrStorage, err)
	}
	if v.ExpiresAt() == nil {
		return resp.Integer(-1), nil
	}

	return resp.Integer(time.Until(*v.ExpiresAt()) / time.Second), nil
}

func (c *TTL) parse(cmd *resp.Command) error {
	const (
		awaitingKey = iota
		keyReceived
		tooManyKeys
	)

	state := awaitingKey
	for range cmd.Args() {
		switch state {
		case awaitingKey:
			state = keyReceived
		case keyReceived:
			state = tooManyKeys
		}
	}

	if state != keyReceived {
		return fmt.Errorf("%w: argument count mismatch: expect %d got %d", ErrSyntax, 1, len(cmd.Args()))
	}

	return nil
}
// file: internal/service/cmd/cmd.go
func NewCommands(storage repo.Storage) []Command {
	return []Command{
		&Ping{},

		NewSet(storage),
		NewGet(storage),

+		NewTTL(storage),
	}
}

補充

這個主題很難寫。到期機制涉及許多電腦科學知識,但全部展開會讓文章過長。

我想解釋為什麼 Redis 同時採用主動與被動到期,而不使用以 expiration *time.Time 排序的 min-heap(priority queue)。

也想說明 Redis 如何在 redisObject 使用 bit fields 儲存到期資訊,以及這裡為什麼自行建立 finite state machine,而放棄 regular expression。

這一階段包含不少其他修改,但無法在本章逐一說明。