一個無競爭的緩存
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,可以創建bec
、bc
、be
、b
四種類型的節點:
-
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
}
需要注意的是
NewBEC
、NewBC
、NewBE
、NewB
返回的都是node指針,後續可能會將該指針保存到hashmap、s3-FIFO、readBuffers等組件中,因此在可以保證各組件操作的是同一個node,但同時也需要注意node指針的回收,防止內存泄露。
hashmap
hashmap是一個支持併發訪問的數據結構,它保存了所有緩存數據。這裏參考了puzpuzpuz/xsync的mapof實現。
一個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的結構如下
下面是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中。
set
和resize
都會變更相同的table,爲了防止衝突,下面使用了bucket鎖以及一些判斷來防止此類情況:
-
每個bucket都有一個鎖,
resize
在調整table大小時會新建一個table,然後調用copyBuckets
將原table的buckets中的數據拷貝到新的table的buckets中。通過bucket鎖可以保證resize
和set
不會同時操作相同的bucket -
由於
resize
會創建新的table,有可能導致set
和resize
操作不同的table,進而導致set到無效的table中。-
如果
resize
發生在set
之前,則通過if m.resizeInProgress()
來保證二則操作不同的table -
如果同時發生
resize
和set
,則可以通過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
庫。
由於是ring buffer,隨着push和pop操作,其back和front的位置會發生變化,因此可能會出現back push的數據到了Front前面的情況。
用法如下:
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]。
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中的Add
和Free
是成對使用的,只有在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操作來釋放空間
}
writebuffer的隊列長度同樣是2的冪,包括minCap
和maxCap
也是是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過期時間。 -
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
}
下圖展示了刪除過期數據的方式
v.time
中保存了上一次清理的時間,進而轉換爲本次wheel[i]
的清理起始位置
- 在下一次清理時,會在此讀取上一次清理的時間,並作爲本次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
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()
}
}
另外還有一種獲取方法,此方法中不會觸發驅逐策略,即不會用到readBuffers
和s3-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)
}
}
}
}
清理過期數據
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
這裏還有一些跟作者的互動: