true 到底在說什麼
Ristretto 以「入場政策」聞名:它不是所有東西都收,會拿新來的項目跟現有的比被存取的次數,比不過就不收。這個 demo 給的容量是 1,000,工作集是 50,000 個 key——絕大多數 Set 注定被拒。(那個 1,000 的單位是什麼,後面會發現不是我以為的。)
問題是呼叫端看不出來。cache.Set(...) 回傳 bool,而這一輪 527,268 次呼叫,527,268 次拿到 true。
這不是 bug。是這個回傳值本來就不在回答那個問題——SetWithTTL 的最後一段長這樣:
select {
case c.setBuf <- i:
return true
default:
if i.flag == itemUpdate { return true }
c.Metrics.add(dropSets, keyHash, 1)
return false
}
true 的意思是「排進 setBuf 這個 channel 了」。 唯一會拿到 false 的情況,是這個 buffer 滿了而且這是一次新增——而那會去加一個叫 dropSets 的計數器。
所以「全部 true」在程式自己的帳本裡是對得起來的:那一行印的 dropped=0,正是 dropSets。佇列一次都沒滿,所以一次 false 都不可能出現。 回傳值忠實地回答了它被設計來回答的問題,只是那個問題是「收下了嗎」的前一步。
真正的取捨發生在 processItems(cache.go:557):它在 ristretto 自己的 goroutine 上把佇列裡的項目取出來,一個一個送進 cachePolicy.Add。那個時候,呼叫端早就拿著 true 走了。
四個點就是照這條分界鋪的:workload 裡的「快取沒中」(計數與計時各一個)、「送出寫入」(抓寫回去的 key),以及分界另一邊的 defaultPolicy.Add——「決定收留或拒收」。
圖上是兩座島,而那正是答案
一座島是呼叫端:快取沒中 99,252 次,一條邊連到 送出寫入。另一座島只有一個節點——97,829 次的 決定收留或拒收,沒有任何一條邊連進來,也沒有一條連出去。它的堆疊上找不到呼叫端的任何一格,所以圖把它畫成第二個 root。
不是圖漏畫了。Set 把項目排進佇列就返回了;收不收是稍後在另一條 goroutine 上決定的。兩件事之間沒有任何一條堆疊連得起來,所以圖上也不該有那條邊。那個 true 回的不是「收下了」,是「排進去了」。
政策拿什麼在比
「決定收留或拒收」掛在 policy.go:122,而那一行是:
incHits := p.admit.Estimate(key)
新來這個 key 被存取過幾次的估計值。 Add 拿到它之後,隨機抽 5 個現有項目當淘汰候選(lfuSample = 5),用同一個估計器問出這 5 個裡最低的那個,然後比一次:
if incHits < minHits {
p.metrics.add(rejectSets, key, 1)
return victims, false
}
比輸就拒收,而那個 rejectSets 就是帳本上的 rejected。這個次數不是精確計數,是 TinyLFU 的估計值(4-bit 計數器的 Count-Min Sketch)——所以這是一場拿估計值對估計值的比較。
比的是次數,不是時間。 沒有「最近多久沒用到」這回事進到這個判斷裡;SetWithTTL 那個 ttl 參數確實管過期,但這個 demo 傳的是 Set(key, value, 1),ttl 是 0——不過期,所以時間在這篇裡從頭到尾沒有上場。
同一個 key 再送一次也不會走到這裡:Set 會先在 map 上把值換掉,而 Add 在更前面就用 updateIfHas 返回了。入場競爭只發生在「新的 key,而且空間不夠」的時候。
比較的對象是抽出來的 5 個,不是全快取最冷的那個。 所以一個很冷的 key 可能因為沒被抽到而活下來,一個沒那麼冷的可能倒楣被抽中換掉。這不是瑕疵——掃描整個快取找最小值,成本會落在每一次寫入上。
而帳本說出了這個政策真正在做的事:
rejected 475,921 ← 在門口就被拒
evicted 46,927 ← 進去之後才被踢
被擋在門口的,是被踢出去的十倍。 對一個工作集遠大於容量的快取,入場政策的主要作用不是換進換出,是不讓東西進來。
每次命中帶回來的值長這樣:
key=15413959104418642000 | cost=57
而 cost 這一欄,是這篇最讓我意外的東西。
demo 呼叫的是 cache.Set(key, "value-"+key, 1)——我傳的成本是 1。政策手上拿到的是 57。中間差的 56 在這裡:
if !c.ignoreInternalCost {
i.Cost += itemSize // itemSize = unsafe.Sizeof(storeItem[any]{})
}
processItems 在把項目交給政策之前,先把每一筆的簿記開銷加了上去。你可以用 IgnoreInternalCost 關掉它,預設是開的。
所以 MaxCost: 1000 不是「一千個項目」,是「一千個成本單位」。 而這個工作集的每個項目要 57 個單位:
1000 ÷ 57 ≈ 17
這個快取實際上只裝得下十七個東西。
而帳本自己把這個數字說了出來——收下 46,944 個、踢掉 46,927 個:
46,944 − 46,927 = 17
我傳的 1、探針讀到的 57、原始碼那一行、1000 ÷ 57、帳本的差——五個地方講同一件事。
而程式自己的帳本說了另一半:
mine: sets=527268 set_true=527268 | ristretto: added=46944 rejected=475921 dropped=0 evicted=46927
收下 46,944 個、拒收 475,921 次、又踢掉 46,927 個。 收下與踢掉的差,就是上面那 17。
延遲那一欄要打折看
快取沒中 的計時區間 p50 是 0.887ms。但這個區間裡平均跑過 3.09 個探針(probesPerSample),而跑在區間裡的東西都算進區間的時間。
0.887ms 裡有多少是 ristretto、有多少是我,這張圖分不出來。而同一天在另一個 demo 上,我試著用前後對照去補這個空格,兩次做出了相反的答案——所以這裡不猜,只說「這個數字含觀測成本,比例未知」。
結果
站在呼叫端,這個快取在「幾乎全收」和「幾乎全拒」的時候長得一模一樣:同一個回傳值、同一個型別、同樣沒有錯誤。分辨這兩者需要的不是更仔細地讀回傳值,是站到另一條 goroutine 上去看。
而那條 goroutine 不在你的呼叫堆疊上——這就是為什麼它必須是動態掛上去的一個點,而不是一段你可以在呼叫端加的 log。
📊 本文的遠端觀測面板(實測數據,可線上檢視):https://codebull-service-b7lha2dtrq-uc.a.run.app/public/ristretto-admission-20260905/ Behavior Graph 分頁是文章開頭那張圖的互動版,兩座島的分離在上面看得最清楚;Logs 分頁是入場政策每一次比較的 key 與 cost。(面板時間軸是本地時間 UTC+8,對應內文的 UTC 時間。)