一個無競爭的快取

charlieroro發表於2024-04-28

一個無競爭的快取

目錄
  • 一個無競爭的快取
    • Cache定義
    • 資料節點的建立
    • hashmap
    • s3-FIFO
      • Dqueue
    • readBuffers
    • writebuffer
    • Node 過期策略
      • 可變過期策略
        • Variable的初始化
        • 刪除過期資料
        • 新增資料
    • Cache的Set & Get
      • Set
      • Get
    • 事件和過期資料的處理
      • 事件處理
      • 清理過期資料
    • Issues

otter是一個無競爭的快取,在相關的效能測試中表項突出。otter的原理基於如下論文:

  • BP-Wrapper: A Framework Making Any Replacement Algorithms (Almost) Lock Contention Free
  • FIFO queues are all you need for cache eviction
  • Bucket-Based Expiration Algorithm: Improving Eviction Efficiency for In-Memory Key-Value Database
  • A large scale analysis of hundreds of in-memory cache clusters at Twitter

Cache定義

Cache的定義如下,其主要的元件包括:

  • hashmap:儲存全部快取資料
  • policy(s3-FIFO):這是一個驅逐策略。當在hashmap中新增一個資料時,會同時將該資料新增到s3-FIFO中,若此時s3-FIFO驅逐出了老的資料,則需要同時刪除hashmap中的對應資料。因此hashmap中的資料內容受限於s3-FIFO,hashmap和s3-FIFO中的資料是以最終一致的方式呈現的。
  • readBuffers:是一個快取之上的快取,其資料空間是較小且固定。用於找出熱點資料,並增加熱點資料的使用頻率(freq),以輔助實現s3-FIFO驅逐策略。
  • expiryPolicy:資料的快取策略,支援固定TTL、可變TTL以及無過期方式。透過一個名為的cleanup 的goroutine來定期清理過期資料。
  • writeBuffer:這是一個事件佇列,haspmap的增刪改操作會將資料變更事件push到writeBuffer中,再由單獨的goroutine非同步處理這些事件,以保證hashmap、s3-FIFO和expiryPolicy的資料一致性。

otter將大部分儲存的大小都設定為2的冪,這樣實現的好處有兩點:

  • 在進行儲存大小調整時,方便透過移位操作進行擴縮容

  • 透過位與操作可以方便找到ring buffer中的資料位置:

    func RoundUpPowerOf2(v uint32) uint32 {
    	if v == 0 {
    		return 1
    	}
    	v--
    	v |= v >> 1
    	v |= v >> 2
    	v |= v >> 4
    	v |= v >> 8
    	v |= v >> 16
    	v++
    	return v
    }
    func main() {
      var capacity uint32 = 5 //定義buffer容量
    	var bufferHead uint32
    	t := RoundUpPowerOf2(capacity) //將buffer容量轉換為向上取2的冪
    	mask := t - 1 //獲取掩碼
    	buffer := make([]int, t)
    
    	head := atomic.LoadUint32(&bufferHead)
    	buffer[head&mask] = 100 //獲取下一個資料位置,並儲存資料
    	atomic.AddUint32(&bufferHead, 1) //下一個資料位置+1
    }
    

在Cache中有一個鎖evictionMutex,併發訪問競爭中,僅用於變更從readBuffers中返回的熱點資料的freq,因此對併發訪問競爭的影響很小。

type Cache[K comparable, V any] struct {
   nodeManager      *node.Manager[K, V]
   hashmap          *hashtable.Map[K, V] //hashmap
   policy           *s3fifo.Policy[K, V] //s3-FIFO
   expiryPolicy     expiryPolicy[K, V] //expiryPolicy
   stats            *stats.Stats
   readBuffers      []*lossy.Buffer[K, V] //readBuffers
   writeBuffer      *queue.Growable[task[K, V]] //writeBuffer
   evictionMutex    sync.Mutex
   closeOnce        sync.Once
   doneClear        chan struct{}
   costFunc         func(key K, value V) uint32
   deletionListener func(key K, value V, cause DeletionCause)
   capacity         int
   mask             uint32
   ttl              uint32
   withExpiration   bool
   isClosed         bool
}

資料節點的建立

Otter中的資料單位為node,一個node表示一個[k,v]。使用Manager來建立node,根據使用的過期策略和Cost,可以建立becbcbeb四種型別的節點:

  • b -->Base:基本型別

  • e -->Expiration:使用過期策略

  • c -->Cost:大部分場景下的node的cost設定為1即可,但在如某個node的資料較大的情況下,可以透過cost來限制s3-FIFO中的資料量,以此來控制快取佔用的記憶體大小。

type Manager[K comparable, V any] struct {
	create      func(key K, value V, expiration, cost uint32) Node[K, V]
	fromPointer func(ptr unsafe.Pointer) Node[K, V]
}

NewManager可以根據配置建立不同型別的node:

func NewManager[K comparable, V any](c Config) *Manager[K, V] {
	var sb strings.Builder
	sb.WriteString("b")
	if c.WithExpiration {
		sb.WriteString("e")
	}
	if c.WithCost {
		sb.WriteString("c")
	}
	nodeType := sb.String()
	m := &Manager[K, V]{}

	switch nodeType {
	case "bec":
		m.create = NewBEC[K, V]
		m.fromPointer = CastPointerToBEC[K, V]
	case "bc":
		m.create = NewBC[K, V]
		m.fromPointer = CastPointerToBC[K, V]
	case "be":
		m.create = NewBE[K, V]
		m.fromPointer = CastPointerToBE[K, V]
	case "b":
		m.create = NewB[K, V]
		m.fromPointer = CastPointerToB[K, V]
	default:
		panic("not valid nodeType")
	}
	return m
}

需要注意的是NewBECNewBCNewBENewB返回的都是node指標,後續可能會將該指標儲存到hashmap、s3-FIFO、readBuffers等元件中,因此在可以保證各元件操作的是同一個node,但同時也需要注意node指標的回收,防止記憶體洩露。

hashmap

hashmap是一個支援併發訪問的資料結構,它儲存了所有快取資料。這裡參考了puzpuzpuz/xsyncmapof實現。

一個table包含一個bucket陣列,每個bucket為一個連結串列,每個連結串列節點包含一個長度為3的node陣列:

type Map[K comparable, V any] struct {
	table unsafe.Pointer //指向一個table結構體,用於儲存快取資料

	nodeManager *node.Manager[K, V] //用於管理node
	// only used along with resizeCond
	resizeMutex sync.Mutex
	// used to wake up resize waiters (concurrent modifications)
	resizeCond sync.Cond
	// resize in progress flag; updated atomically
	resizing atomic.Int64 //用於表示該map正處於resizing階段,resizing可能會生成新的table,導致set失效,該值作為一個條件判斷使用
}
type table[K comparable] struct {
	buckets []paddedBucket //其長度為2的冪
	// sharded counter for number of table entries;
	// used to determine if a table shrinking is needed
	// occupies min(buckets_memory/1024, 64KB) of memory
	size   []paddedCounter//用於統計table中的node個數,使用多個counter分散統計的目的是為了降低訪問衝突
  mask   uint64 //為len(buckets)-1, 用於和node的雜湊值作位於運算,計算node所在的bucket位置
	hasher maphash.Hasher[K] //雜湊方法,計算node的雜湊值
}

bucket是一個單向連結串列:

type bucket struct {
   hashes [bucketSize]uint64 //儲存node的雜湊值,bucketSize為3
   nodes  [bucketSize]unsafe.Pointer //儲存node指標,node指標和node的雜湊值所在的索引位置相同
   next   unsafe.Pointer//指向下一個bucket
   mutex  sync.Mutex //用於操作本bucket的鎖
}

table的結構如下

image

下面是map的初始化方法,為了增加檢索效率並降低連結串列長度,table中的buckets數目(size)不宜過小

func newMap[K comparable, V any](nodeManager *node.Manager[K, V], size int) *Map[K, V] {
	m := &Map[K, V]{
		nodeManager: nodeManager,
	}
	m.resizeCond = *sync.NewCond(&m.resizeMutex)
	var t *table[K]
	if size <= minNodeCount {
		t = newTable(minBucketCount, maphash.NewHasher[K]()) //minBucketCount=32
	} else {
		bucketCount := xmath.RoundUpPowerOf2(uint32(size / bucketSize))
		t = newTable(int(bucketCount), maphash.NewHasher[K]())
	}
	atomic.StorePointer(&m.table, unsafe.Pointer(t))
	return m
}

下面是向map新增資料的方式,注意它支援並行新增資料。set操作的是一個table中的某個bucket。如果table中的元素大於某個閾值,就會觸發hashmap擴容(resize),此時會建立一個新的table,並將老的table中的資料複製到新建的table中。

setresize都會變更相同的table,為了防止衝突,下面使用了bucket鎖以及一些判斷來防止此類情況:

  • 每個bucket都有一個鎖,resize在調整table大小時會新建一個table,然後呼叫copyBuckets將原table的buckets中的資料複製到新的table的buckets中。透過bucket鎖可以保證resizeset不會同時操作相同的bucket

  • 由於resize會建立新的table,有可能導致setresize操作不同的table,進而導致set到無效的table中。

    • 如果resize發生在set之前,則透過if m.resizeInProgress() 來保證二則操作不同的table

    • 如果同時發生resizeset,則可以透過bucket鎖+if m.newerTableExists(t)來保證操作的是最新的table。

      由於copyBuckets時也會用到bucket鎖,如果此時正在執行set,則copyBuckets會等待set操作完成後再將資料複製到新的table中。copyBuckets之後會將新的table儲存到hashmap中,因此需要保證bucket和table的一致性,在set時獲取到bucket鎖之後需要進一步驗證table是否一致。

func (m *Map[K, V]) set(n node.Node[K, V], onlyIfAbsent bool) node.Node[K, V] {
   for {
   RETRY:
      var (
         emptyBucket *paddedBucket
         emptyIdx    int
      )
      //獲取map的table
      t := (*table[K])(atomic.LoadPointer(&m.table))
      tableLen := len(t.buckets)
      hash := t.calcShiftHash(n.Key())//獲取node的雜湊值
      bucketIdx := hash & t.mask //獲取node在table中的bucket位置
      //獲取node所在的bucket位置
      rootBucket := &t.buckets[bucketIdx]
      //獲取所操作的bucket鎖,在resize時,會建立一個新的table,然後將原table中的資料複製到新建立的table中。
      //resize的copyBuckets是以bucket為單位進行複製的,且在複製時,也會對bucket加鎖。這樣就保證了,如果同時發生set和resize,
      //resize的copyBuckets也會等操作相同bucket的set結束之後才會進行複製。
      rootBucket.mutex.Lock()
      // the following two checks must go in reverse to what's
      // in the resize method.
      //如果正在調整map大小,則可能會生成一個新的table,為了防止出現無效操作,此時不允許繼續新增資料
      if m.resizeInProgress() {
         // resize is in progress. wait, then go for another attempt.
         rootBucket.mutex.Unlock()
         m.waitForResize()
         goto RETRY
      }
      //如果當前操作的是一個新的table,需要重新選擇table
      if m.newerTableExists(t) {
         // someone resized the table, go for another attempt.
         rootBucket.mutex.Unlock()
         goto RETRY
      }
      b := rootBucket
      //set node的邏輯是首先在bucket連結串列中搜尋是否已經存在該node,如果存在則直接更新,如果不存在再找一個空位將其set進去
      for {
         //本迴圈用於在單個bucket中查詢是否已經存在需要set的node。如果找到則根據是否設定onlyIfAbsent來選擇
         //是否原地更新。如果沒有在當前bucket中找到所需的node,則需要繼續查詢下一個bucket
         for i := 0; i < bucketSize; i++ {
            h := b.hashes[i]
            if h == uint64(0) {
               if emptyBucket == nil {
                  emptyBucket = b //找到一個最近的空位,如果後續沒有在bucket連結串列中找到已存在的node,則將node新增到該位置
                  emptyIdx = i
               }
               continue
            }
            if h != hash { //查詢與node雜湊值相同的node
               continue
            }
            prev := m.nodeManager.FromPointer(b.nodes[i])
            if n.Key() != prev.Key() { //為了避免雜湊碰撞,進一步比較node的key
               continue
            }
            if onlyIfAbsent { //onlyIfAbsent用於表示,如果node已存在,則不會再更新
               // found node, drop set
               rootBucket.mutex.Unlock()
               return n
            }
            // in-place update.
            // We get a copy of the value via an interface{} on each call,
            // thus the live value pointers are unique. Otherwise atomic
            // snapshot won't be correct in case of multiple Store calls
            // using the same value.
            atomic.StorePointer(&b.nodes[i], n.AsPointer())//node原地更新,儲存node指標即可
            rootBucket.mutex.Unlock()
            return prev
         }
        //b.next == nil說明已經查詢到最後一個bucket,如果整個bucket連結串列中都沒有找到所需的node,則表示這是新的node,需要將node
        //新增到bucket中。如果bucket空間不足,則需要進行擴容
         if b.next == nil {
            //如果已有空位,直接新增node即可
            if emptyBucket != nil {
               // insertion into an existing bucket.
               // first we update the hash, then the entry.
               atomic.StoreUint64(&emptyBucket.hashes[emptyIdx], hash)
               atomic.StorePointer(&emptyBucket.nodes[emptyIdx], n.AsPointer())
               rootBucket.mutex.Unlock()
               t.addSize(bucketIdx, 1)
               return nil
            }
           //這裡判斷map中的元素總數是不是已經達到擴容閾值growThreshold,即當前元素總數大於容量的0.75倍時就執行擴容
           //其實growThreshold計算的是table中的buckets連結串列的數目,而t.sumSize()計算的是tables中的node總數,即
           //所有連結串列中的節點總數。這麼比較的原因是為了降低計算的時間複雜度,當tables中的nodes較多時,能夠及時擴容
           //buckets數目,而不是一味地增加連結串列長度。
           //參見:https://github.com/maypok86/otter/issues/79
            growThreshold := float64(tableLen) * bucketSize * loadFactor
            if t.sumSize() > int64(growThreshold) {
               // need to grow the table then go for another attempt.
               rootBucket.mutex.Unlock()
              //擴容,然後重新在該bucket中查詢空位。需要注意的是擴容會給map生成一個新的table,
              //並將原table的資料複製過來,由於table變了,因此需要重新set(goto RETRY)
               m.resize(t, growHint)
               goto RETRY
            }
            // insertion into a new bucket.
            // create and append the bucket.
           //如果前面bucket中沒有空位,且沒達到擴容要求,則需要新建一個bucket,並將其新增到bucket連結串列中
            newBucket := &paddedBucket{}
            newBucket.hashes[0] = hash
            newBucket.nodes[0] = n.AsPointer()
            atomic.StorePointer(&b.next, unsafe.Pointer(newBucket))//儲存node
            rootBucket.mutex.Unlock()
            t.addSize(bucketIdx, 1)
            return nil
         }
        //如果沒有在當前bucket中找到所需的node,則需要繼續查詢下一個bucket
         b = (*paddedBucket)(b.next)
      }
   }
}
func (m *Map[K, V]) copyBuckets(b *paddedBucket, dest *table[K]) (copied int) {
   rootBucket := b
   //使用bucket鎖
   rootBucket.mutex.Lock()
   for {
      for i := 0; i < bucketSize; i++ {
         if b.nodes[i] == nil {
            continue
         }
         n := m.nodeManager.FromPointer(b.nodes[i])
         hash := dest.calcShiftHash(n.Key())
         bucketIdx := hash & dest.mask
         dest.buckets[bucketIdx].add(hash, b.nodes[i])
         copied++
      }
      if b.next == nil {
         rootBucket.mutex.Unlock()
         return copied
      }
      b = (*paddedBucket)(b.next)
   }
}

Get的邏輯和set的邏輯類似,但get時無需關心是否會操作老的table,原因是如果產生了新的table,其也會複製老的資料。

s3-FIFO

s3-FIFO可以看作是hashmap的資料過濾器,使用s3-FIFO來淘汰hashmap中的資料。

Dqueue

S3-FIFO的ghost使用了Dqueue。

Dqueue就是一個ring buffer,支援PopFront/PushFront和PushBack/PopBack,其中buffer size為2的冪。其快於golang的container/list庫。

image

由於是ring buffer,隨著push和pop操作,其back和front的位置會發生變化,因此可能會出現back push的資料到了Front前面的情況。

image

用法如下:

package main

import (
    "fmt"
    "github.com/gammazero/deque"
)

func main() {
    var q deque.Deque[string]
    q.PushBack("foo")
    q.PushBack("bar")
    q.PushBack("baz")

    fmt.Println(q.Len())   // Prints: 3
    fmt.Println(q.Front()) // Prints: foo
    fmt.Println(q.Back())  // Prints: baz

    q.PopFront() // remove "foo"
    q.PopBack()  // remove "baz"

    q.PushFront("hello")
    q.PushBack("world")

    // Consume deque and print elements.
    for q.Len() != 0 {
        fmt.Println(q.PopFront())
    }
}

readBuffers

在讀取資料時,會將獲取的資料也儲存到readBuffers中,readBuffers的空間比較小,其中的資料可以看作是熱點資料。當某個readBuffers[i]陣列滿了之後,會將readBuffers[i]中的所有nodes返回出來,並增加各個node的freq(給s3-FIFO使用),然後清空readBuffers[i]

image

readBuffers是由4倍最大goroutines併發數的lossy.Buffer構成的陣列,lossy.Buffer為固定大小的ring buffer 結構,包括用於建立node的nodeManager以及存放node陣列的policyBuffers,容量大小為capacity(16)。

parallelism := xruntime.Parallelism()
roundedParallelism := int(xmath.RoundUpPowerOf2(parallelism))
readBuffersCount := 4 * roundedParallelism
readBuffers := make([]*lossy.Buffer[K, V], 0, readBuffersCount)

使用nodeManager來初始化lossy.Buffer

for i := 0; i < readBuffersCount; i++ {
  readBuffers = append(readBuffers, lossy.New[K, V](nodeManager))
}

下面是lossy.New的實現,Buffer長度為2的冪。

type Buffer[K comparable, V any] struct {
	head                 atomic.Uint64 //指向buffer的head
	headPadding          [xruntime.CacheLineSize - unsafe.Sizeof(atomic.Uint64{})]byte
	tail                 atomic.Uint64 //指向buffer的tail
	tailPadding          [xruntime.CacheLineSize - unsafe.Sizeof(atomic.Uint64{})]byte
	nodeManager          *node.Manager[K, V] //用於管理node
  returned             unsafe.Pointer //可以看做是一個條件鎖,和hashmap的resizing作用類似,防止在buffer變更(add/free)的同時新增node
	returnedPadding      [xruntime.CacheLineSize - 2*8]byte
  policyBuffers        unsafe.Pointer //指向一個容量為16的PolicyBuffers,用於複製讀快取(buffer)中的熱點資料
	returnedSlicePadding [xruntime.CacheLineSize - 8]byte
	buffer               [capacity]unsafe.Pointer //儲存讀快取的資料
}
type PolicyBuffers[K comparable, V any] struct {
	Returned []node.Node[K, V]
}
func New[K comparable, V any](nodeManager *node.Manager[K, V]) *Buffer[K, V] {
	pb := &PolicyBuffers[K, V]{
		Returned: make([]node.Node[K, V], 0, capacity),
	}
	b := &Buffer[K, V]{
		nodeManager:   nodeManager,
		policyBuffers: unsafe.Pointer(pb),
	}
	b.returned = b.policyBuffers
	return b
}

下面是向readBuffers中新增資料的方式:

// Add lazily publishes the item to the consumer.
//
// item may be lost due to contention.
func (b *Buffer[K, V]) Add(n node.Node[K, V]) *PolicyBuffers[K, V] {
	head := b.head.Load()
	tail := b.tail.Load()
	size := tail - head
  //併發訪問可能會導致這種情況,buffer滿了就無法再新增元素,需要由其他操作透過返回熱點資料來釋放buffer空間
	if size >= capacity {
		// full buffer
		return nil
	}
  // 新增開始,將tail往後移一位
	if b.tail.CompareAndSwap(tail, tail+1) {
		// tail中儲存的是下一個元素的位置。使用mask位與是為了獲取當前ring buffer中的tail位置。
		index := int(tail & mask)
    // 將node的指標儲存到buffer的第index位,這樣就完成了資料儲存
		atomic.StorePointer(&b.buffer[index], n.AsPointer())
     // buffer滿了,此時需要清理快取,即將讀快取buffer中的熱點資料資料存放到policyBuffers中,後續給s3-FIFO使用
		if size == capacity-1 {
			// 這裡可以看做是一個條件鎖,如果有其他執行緒正在處理熱點資料,則退出。
			if !atomic.CompareAndSwapPointer(&b.returned, b.policyBuffers, nil) {
				// somebody already get buffer
				return nil
			}

      //將整個buffer中的資料儲存到policyBuffers中,並清空buffer。
			pb := (*PolicyBuffers[K, V])(b.policyBuffers)
			for i := 0; i < capacity; i++ {
        // 獲取head的索引
				index := int(head & mask)
				v := atomic.LoadPointer(&b.buffer[index])
				if v != nil {
					// published
					pb.Returned = append(pb.Returned, b.nodeManager.FromPointer(v))
					// 清空buffer的資料
					atomic.StorePointer(&b.buffer[index], nil)
				}
				head++
			}

			b.head.Store(head)
			return pb
		}
	}

	// failed
	return nil
}

Otter中的AddFree是成對使用的,只有在Free中才會重置Add中變更的Buffer.returned。因此如果沒有執行Free,則對相同Buffer的其他Add操作也無法返回熱點資料。

idx := c.getReadBufferIdx()
pb := c.readBuffers[idx].Add(got) //獲取熱點資料
if pb != nil {
  c.evictionMutex.Lock()
  c.policy.Read(pb.Returned) //增加熱點資料的freq
  c.evictionMutex.Unlock()

  c.readBuffers[idx].Free() //清空熱點資料存放空間
}

Free方法如下:

// 在add返回熱點資料,並在增加熱點資料的freq之後,會呼叫Free方法釋放熱點資料的存放空間
func (b *Buffer[K, V]) Free() {
	pb := (*PolicyBuffers[K, V])(b.policyBuffers)
	for i := 0; i < len(pb.Returned); i++ {
		pb.Returned[i] = nil //清空熱點資料
	}
	pb.Returned = pb.Returned[:0]
	atomic.StorePointer(&b.returned, b.policyBuffers)
}

writebuffer

writebuffer佇列用於儲存node的增刪改事件,並由另外一個goroutine非同步處理這些事件。事件型別如下:

const (
	addReason reason = iota + 1
	deleteReason
	updateReason
	clearReason //執行cache.Clear
	closeReason //執行cache.Close
)

writebuffer的初始大小是最大併發goroutines數目的128倍:

queue.NewGrowable[task[K, V]](minWriteBufferCapacity, maxWriteBufferCapacity),

Growable是一個可擴充套件的ring buffer,從尾部push,從頭部pop。在otter中作為儲存node變動事件的快取,類似kubernetes中的workqueue。

type Growable[T any] struct {
	mutex    sync.Mutex
	notEmpty sync.Cond //用於透過push來喚醒由於佇列中由於沒有資料而等待的Pop操作
	notFull  sync.Cond //用於透過pop來喚醒由於資料量達到上限maxCap而等待的Push操作
	buf      []T //儲存事件
	head     int //指向buf中下一個可以pop資料的索引
	tail     int //指向buf中下一個可以push資料的索引
	count    int //統計buf中的資料總數
	minCap   int //定義了buf的初始容量
	maxCap   int //定義了buf的最大容量,當count數目達到該值之後就不能再對buf進行擴容,需要等待pop操作來釋放空間
}

image

writebuffer的佇列長度同樣是2的冪,包括minCapmaxCap也是是2的冪:

func NewGrowable[T any](minCap, maxCap uint32) *Growable[T] {
	minCap = xmath.RoundUpPowerOf2(minCap)
	maxCap = xmath.RoundUpPowerOf2(maxCap)

	g := &Growable[T]{
		buf:    make([]T, minCap),
		minCap: int(minCap),
		maxCap: int(maxCap),
	}

	g.notEmpty = *sync.NewCond(&g.mutex)
	g.notFull = *sync.NewCond(&g.mutex)

	return g
}

下面是擴充套件writebuffer的方法:

func (g *Growable[T]) resize() {
	newBuf := make([]T, g.count<<1) //新的buf是原來的2倍
	if g.tail > g.head {
		copy(newBuf, g.buf[g.head:g.tail]) //將事件複製到新的buf
	} else {
		n := copy(newBuf, g.buf[g.head:]) //pop和push操作導致head和tail位置變動,且tail位於head之前,需要作兩次copy
		copy(newBuf[n:], g.buf[:g.tail])
	}

	g.head = 0
	g.tail = g.count
	g.buf = newBuf
}

Node 過期策略

支援的過期策略有:

  • 固定TTL:所有node的過期時間都一樣。將node儲存到佇列中,因此最早入佇列的node最有可能過期,按照FIFO的方式獲取佇列中的node,判斷其是否過期即可。
  • 可變過期策略:這裡參考了Bucket-Based Expiration Algorithm: Improving Eviction Efficiency for In-Memory Key-Value Database,該演算法的要點是將時間轉換為空間位置
  • 無過期策略:即不配置過期時間,在呼叫RemoveExpired獲取過期的nodes時,認為所有nodes都是過期的。

可變過期策略

下面介紹可變過期策略的實現:

var (
	buckets = []uint32{64, 64, 32, 4, 1}
  //注意spans中的元素值都是2的冪,分別為1(span[0]),64(span[1]),4096(span[2]),131072(span[3]),524288(span[4])。
  //上面的buckets定義也很有講究,spans[i]表示該buckets[i]的超時單位,buckets[i][j]的過期時間為j個spans[i],即過期時間為j*spans[i]。
  //buckets之所以為{64, 64, 32, 4, 1},是因為buckets[1]的超時單位為64s,因此如果過期時間大於64s就需要使用buckets[1]的超時單位spans[1],
  //反之則使用buckets[0]的超時單位spans[0],因此buckets[0]長度為64(64/1=64);
  //以此類推,buckets[2]的超時單位為4096s,如果過期時間大於4096s就需要使用buckets[2]的超時單位spans[2],反之則使用buckets[1]的超時單位spans[1],
  //因此buckets[1]長度為64(4096/64=64);buckets[3]的超時單位為131072s,如果過期時間大於131072s就需要使用buckets[3]的超時單位spans[3],
  //反之則使用buckets[2]的超時單位spans[2],因此buckets[2]長度為32(131072/4096=32)...
  //spass[4]作為最大超時時間單位,超時時間大於該spans[4]時,都按照spans[4]計算
  //buckets[i]的長度隨過期時間的增加而減少,這也符合常用場景,因為大部分場景中的過期時間都較短,像1.52d這種級別的過期時間比較少見
	spans   = []uint32{
		xmath.RoundUpPowerOf2(uint32((1 * time.Second).Seconds())),             // 1s--2^0
		xmath.RoundUpPowerOf2(uint32((1 * time.Minute).Seconds())),             // 1.07m --64s--2^6
		xmath.RoundUpPowerOf2(uint32((1 * time.Hour).Seconds())),               // 1.13h --4096s--2^12
		xmath.RoundUpPowerOf2(uint32((24 * time.Hour).Seconds())),              // 1.52d --131072s--2^17
		buckets[3] * xmath.RoundUpPowerOf2(uint32((24 * time.Hour).Seconds())), // 6.07d --524288s--2^19
		buckets[3] * xmath.RoundUpPowerOf2(uint32((24 * time.Hour).Seconds())), // 6.07d --524288s--2^19
	}
	shift = []uint32{
		uint32(bits.TrailingZeros32(spans[0])),
		uint32(bits.TrailingZeros32(spans[1])),
		uint32(bits.TrailingZeros32(spans[2])),
		uint32(bits.TrailingZeros32(spans[3])),
		uint32(bits.TrailingZeros32(spans[4])),
	}
)

下面是快取資料使用的資料結構。

type Variable[K comparable, V any] struct {
	wheel [][]node.Node[K, V]
  time  uint32
}
  • Variable.wheel的資料結構如下,Variable.wheel[i][]的陣列長度等於buckets[i]buckets[i]的超時單位為spans[i]Variable.wheel[i][j]表示過期時間為j*spans[i]的資料所在的位置。

    但由於超時單位跨度比較大,因此即使Variable.wheel[i][j]所在的nodes被認為是過期的,也需要進一步確認node是否真正過期。以64s的超時單位為例,過期時間為65s的node和過期時間為100s的node會放到相同的wheel[1][0]連結串列中,若當前時間為80s,則只有過期時間為65s的node才是真正過期的。因此需要進一步比較具體的node過期時間。

    image

  • Variable.time是一個重要的成員:其表示上一次執行清理操作(移除過期資料或清除所有資料)的時間,並作為各個wheel[i]陣列中的有效資料的起點。該值在執行清理操作之後會被重置,表示新的有效資料起點。要理解該成員的用法,應該將Variable.wheel[i]的陣列看做是一個個時間塊(而非位置點),每個時間塊表示一個超時單位。

Variable的初始化

Variable的初始化方式如下,主要就是初始化一個二維陣列:

func NewVariable[K comparable, V any](nodeManager *node.Manager[K, V]) *Variable[K, V] {
	wheel := make([][]node.Node[K, V], len(buckets))
	for i := 0; i < len(wheel); i++ {
		wheel[i] = make([]node.Node[K, V], buckets[i])
		for j := 0; j < len(wheel[i]); j++ {
			var k K
			var v V
			fn := nodeManager.Create(k, v, math.MaxUint32, 1) //預設過期時間為math.MaxUint32,相當於沒有過期時間
			fn.SetPrevExp(fn)
			fn.SetNextExp(fn)
			wheel[i][j] = fn
		}
	}
	return &Variable[K, V]{
		wheel: wheel,
	}
}
刪除過期資料
func (v *Variable[K, V]) RemoveExpired(expired []node.Node[K, V]) []node.Node[K, V] {
	currentTime := unixtime.Now()//獲取到目前為止,系統啟動的秒數,以此作為當前時間
	prevTime := v.time //獲取上一次執行清理的時間,在使用時會將其轉換為以spans[i]為單位的數值,作為各個wheel[i]的起始清理位置
	v.time = currentTime //重置v.time,本次清理之後的有效資料的起始位置,也可以作為下一次清理時的起始位置

  //在清理資料時會將時間轉換以spans[i]為單位的數值。delta表示上一次清理之後到當前的時間差。
  //在清理時需要遍歷清理各個wheel[i],如果delta大於buckets[i],則認為整個wheel[i]都可能出現過期資料,
  //反之,則認為wheel[i]的部分割槽間資料可能過期。
	for i := 0; i < len(shift); i++ {
    //在prevTime和currentTime都小於shift[i]或二者非常接近的情況下delta可能為0,但delte為0時無需執行清理動作
		previousTicks := prevTime >> shift[i]
		currentTicks := currentTime >> shift[i]
		delta := currentTicks - previousTicks
		if delta == 0 {	
			break
		}

		expired = v.removeExpiredFromBucket(expired, i, previousTicks, delta)
	}

	return expired
}

下面用於清理wheel[i]下的過期資料:

func (v *Variable[K, V]) removeExpiredFromBucket(expired []node.Node[K, V], index int, prevTicks, delta uint32) []node.Node[K, V] {
	mask := buckets[index] - 1
  //獲取buckets[index]對應的陣列長度
	steps := buckets[index]
  //如果delta小於buckets[index]的大小,則[start,start+delta]之間的資料可能是過期的
  //如果delta大於buckets[index]的大小,則整個buckets[i]都可能是過期的
	if delta < steps {
		steps = delta
	}
  //取上一次清理的時間作為起始位置,[start,end]之間的資料都認為可能是過期的
	start := prevTicks & mask
	end := start + steps
	timerWheel := v.wheel[index]
	for i := start; i < end; i++ {
    //遍歷wheel[index][i]中的連結串列
		root := timerWheel[i&mask]
		n := root.NextExp()
		root.SetPrevExp(root)
		root.SetNextExp(root)

		for !node.Equals(n, root) {
			next := n.NextExp()
			n.SetPrevExp(nil)
			n.SetNextExp(nil)
      //注意此時v.time已經被重置為當前時間。進一步比較具體的node過期時間。
			if n.Expiration() <= v.time {
				expired = append(expired, n)
			} else {
				v.Add(n)
			}

			n = next
		}
	}

	return expired
}

下圖展示了刪除過期資料的方式

  1. v.time中儲存了上一次清理的時間,進而轉換為本次wheel[i]的清理起始位置
    image
  2. 在下一次清理時,會在此讀取上一次清理的時間,並作為本次wheel[i]的清理起始位置
    ![image-20240418154846844](/Users/charlie.liu/Library/Application Support/typora-user-images/image-20240418154846844.png)
新增資料

新增資料時首先需要找到該資料在Variable.wheel中的位置Variable.wheel[i][j],然後新增到該位置的連結串列中即可。

在新增資料時需要避免將資料新增到上一次清理點之前

// findBucket determines the bucket that the timer event should be added to.
func (v *Variable[K, V]) findBucket(expiration uint32) node.Node[K, V] {
  //expiration是絕對時間。獲取距離上一次清理過期資料(包括清理所有資料)所過去的時間,或看做是和起始有效資料的距離。
  duration := expiration - v.time
	length := len(v.wheel) - 1
	for i := 0; i < length; i++ {
    //找到duration的最佳超時單位spans[i]
		if duration < spans[i+1] {
      //計算expiration包含多少個超時單位,並以此作為其在wheel[i]中的位置index。
      //expiration >> shift[i]等價於(duration + v.time)>> shift[i],即和起始有效資料的距離
			ticks := expiration >> shift[i]
			index := ticks & (buckets[i] - 1)
			return v.wheel[i][index]
		}
	}
	return v.wheel[length][0] //buckets[4]的長度為1,因此二維索引只有一個值0。
}

Cache的Set & Get

image

Set

新增node時需要同時處理node add/update事件。

func (c *Cache[K, V]) set(key K, value V, expiration uint32, onlyIfAbsent bool) bool {
  //限制node的cost大小,過大會佔用更多的快取空間
	cost := c.costFunc(key, value)
	if int(cost) > c.policy.MaxAvailableCost() {
		c.stats.IncRejectedSets()
		return false
	}

	n := c.nodeManager.Create(key, value, expiration, cost)
  //只新增不存在的節點
	if onlyIfAbsent {
    //res == nil說明是新增的node
		res := c.hashmap.SetIfAbsent(n)
		if res == nil {
			// 將node新增事件新增到writeBuffer中
			c.writeBuffer.Push(newAddTask(n))
			return true
		}
		c.stats.IncRejectedSets() //如果node存在,則不作任何處理,增加rejected統計
		return false
	}

  //evicted != nil表示對已有node進行了更新,反之則表示新加的node
	evicted := c.hashmap.Set(n)
	if evicted != nil {
		// update,將老節點evicted設定為無效狀態,並將node更新事件新增到writeBuffer中
		evicted.Die()
		c.writeBuffer.Push(newUpdateTask(n, evicted))
	} else {
		// 將node新增事件新增到writeBuffer中
		c.writeBuffer.Push(newAddTask(n))
	}

	return true
}

Get

Get需要處理刪除過期node事件。

// GetNode returns the node associated with the key in this cache.
func (c *Cache[K, V]) GetNode(key K) (node.Node[K, V], bool) {
	n, ok := c.hashmap.Get(key)
	if !ok || !n.IsAlive() { //不返回非active狀態的node
		c.stats.IncMisses()
		return nil, false
	}

  //如果node過期,需要將node刪除事件新增到writeBuffer中,後續由其他goroutine執行資料刪除
	if n.HasExpired() {
		c.writeBuffer.Push(newDeleteTask(n))
		c.stats.IncMisses()
		return nil, false
	}

  //在讀取node之後的動作,獲取熱點node,並增加s3-FIFO node的freq
	c.afterGet(n)
  //增加命中統計
	c.stats.IncHits()

	return n, true
}

在成功讀取node之後,需要處理熱點nodes:

func (c *Cache[K, V]) afterGet(got node.Node[K, V]) {
	idx := c.getReadBufferIdx()
  //獲取熱點nodes
	pb := c.readBuffers[idx].Add(got)
	if pb != nil {
		c.evictionMutex.Lock()
    //增加nodes的freq
		c.policy.Read(pb.Returned)
		c.evictionMutex.Unlock()
    //已經處理完熱點資料,清理存放熱點資料的buffer
		c.readBuffers[idx].Free()
	}
}

另外還有一種獲取方法,此方法中不會觸發驅逐策略,即不會用到readBufferss3-FIFO

func (c *Cache[K, V]) GetNodeQuietly(key K) (node.Node[K, V], bool) {
	n, ok := c.hashmap.Get(key)
	if !ok || !n.IsAlive() || n.HasExpired() {
		return nil, false
	}

	return n, true
}

事件和過期資料的處理

otter有兩種途徑來處理快取中的資料,一種是透過處理writeBuffer中的事件來對快取資料進行增刪改,另一種是定期清理過期資料。

事件處理

writeBuffer中儲存了快取讀寫過程中的事件。

需要注意的是hashmap中的資料會按照add/delete操作實時更新,只有涉及到s3-FIFO驅逐的資料才會透過writeBuffer非同步更新。

func (c *Cache[K, V]) process() {
	bufferCapacity := 64
	buffer := make([]task[K, V], 0, bufferCapacity)
	deleted := make([]node.Node[K, V], 0, bufferCapacity)
	i := 0
	for {
    //從writeBuffer中獲取一個事件
		t := c.writeBuffer.Pop()

    //呼叫Cache.Clear()或Cache.Close()時會清理cache。Cache.Clear()和Cache.Close()中都會清理hashmap和readBuffers
    //這裡清理writebuffer和s3-FIFO
		if t.isClear() || t.isClose() {
			buffer = clearBuffer(buffer)
			c.writeBuffer.Clear()

			c.evictionMutex.Lock()
			c.policy.Clear()
			c.expiryPolicy.Clear()
			if t.isClose() {
				c.isClosed = true
			}
			c.evictionMutex.Unlock()
      //清理完成
			c.doneClear <- struct{}{}
      //如果是close則直接退出,否則(clear)會繼續處理writeBuffer中的事件
			if t.isClose() {
				break
			}
			continue
		}

    //這裡使用了批次處理事件的方式
		buffer = append(buffer, t)
		i++
		if i >= bufferCapacity {
			i -= bufferCapacity

			c.evictionMutex.Lock()

			for _, t := range buffer {
				n := t.node()
				switch {
				case t.isDelete()://刪除事件,發生在直接刪除資料或資料過期的情況下。刪除expiryPolicy,和s3-FIFO中的資料
					c.expiryPolicy.Delete(n)
					c.policy.Delete(n)
				case t.isAdd()://新增事件,傳送在新增資料的情況下,將資料新增到expiryPolicy和s3-FIFO中
					if n.IsAlive() {
						c.expiryPolicy.Add(n)
						deleted = c.policy.Add(deleted, n) //新增驅逐資料
					}
				case t.isUpdate()://更新事件,發生在新增相同key的資料的情況下,此時需刪除老資料,並新增活動狀態的新資料
					oldNode := t.oldNode()
					c.expiryPolicy.Delete(oldNode)
					c.policy.Delete(oldNode)
					if n.IsAlive() {
						c.expiryPolicy.Add(n)
						deleted = c.policy.Add(deleted, n) //新增驅逐資料
					}
				}
			}

      //從expiryPolicy中刪除s3-FIFO驅逐的資料
			for _, n := range deleted {
				c.expiryPolicy.Delete(n)
			}

			c.evictionMutex.Unlock()

			for _, t := range buffer {
				switch {
				case t.isDelete():
					n := t.node()
					c.notifyDeletion(n.Key(), n.Value(), Explicit)
				case t.isUpdate():
					n := t.oldNode()
					c.notifyDeletion(n.Key(), n.Value(), Replaced)
				}
			}

      //從hashmap中刪除s3-FIFO驅逐的資料
			for _, n := range deleted {
				c.hashmap.DeleteNode(n)
				n.Die()
				c.notifyDeletion(n.Key(), n.Value(), Size)
				c.stats.IncEvictedCount()
				c.stats.AddEvictedCost(n.Cost())
			}

			buffer = clearBuffer(buffer)
			deleted = clearBuffer(deleted)
			if cap(deleted) > 3*bufferCapacity {
				deleted = make([]node.Node[K, V], 0, bufferCapacity)
			}
		}
	}
}

清理過期資料

image

cleanup是一個單獨的goroutine,用於定期處理Cache.hashmap中的過期資料。在呼叫Cache.Get時會判斷並刪除(透過向writeBuffer中寫入deleteReason事件,由process goroutine非同步刪除)s3-FIFO(Cache.policy)中的過期資料。

另外無需處理readbuffers中的過期資料,因為從readbuffers讀取到熱點資料之後,只會增加這些資料的freq,隨後會清空存放熱點資料的空間,不會對其他元件的資料造成影響。

func (c *Cache[K, V]) cleanup() {
	bufferCapacity := 64
	expired := make([]node.Node[K, V], 0, bufferCapacity)
	for {
		time.Sleep(time.Second) //每秒嘗試清理一次過期資料

		c.evictionMutex.Lock()
		if c.isClosed {
			return
		}

    //刪除expiryPolicy、policy和hashmap中的過期資料
		expired = c.expiryPolicy.RemoveExpired(expired)
		for _, n := range expired {
			c.policy.Delete(n)
		}

		c.evictionMutex.Unlock()

		for _, n := range expired {
			c.hashmap.DeleteNode(n)
			n.Die()
			c.notifyDeletion(n.Key(), n.Value(), Expired)
		}

		expired = clearBuffer(expired)
		if cap(expired) > 3*bufferCapacity {
			expired = make([]node.Node[K, V], 0, bufferCapacity)
		}
	}
}

Issues

這裡還有一些跟作者的互動:

  • Question about the hashtable.Map growThreshold
  • Question about add node to Variable
  • How the cost works?
  • concurrent access slice
  • Set to invalid hashmap in concurrent situation

相關文章