ClickHouse's SLRU Cache Gives Hot Entries a Second Queue
ClickHouse's default cache policy is SLRU, not LRU: entries hit twice are promoted to a protected queue that a sequential scan cannot flush, and SIEVE offers a hand-based alternative.
TL;DR: Plain LRU is destroyed by a sequential scan. SLRU splits the cache in two queues — probationary and protected — and promotes an entry to the protected queue the second time it is touched. SIEVE is the alternative: one FIFO queue, one
visitedbit per entry, and a “hand” that walks the queue clearing bits until it finds one to evict.
The previous article described ClickHouse’s LRU cache: a hash map of cells, a
linked list of keys in recency order, a splice on every hit. It is a clean
design and it is optimal for a stationary workload.
It is also catastrophically bad for the workload ClickHouse actually has.
The problem: one query eats your cache
Picture a ClickHouse server with a 5 GiB mark cache. A dashboard polls the same table every 30 seconds and relies on the marks being cached. Now a backfill job starts scanning a year of partitions. That scan touches tens of thousands of mark files, each exactly once, in a burst.
Under LRU, every one of those one-hit-wonders is more recently used than anything the dashboard cached. Each one is inserted at the back of the list and every one of them pushes the dashboard’s marks out of the front. By the time the backfill moves on, the mark cache contains nothing but backfill marks, and the dashboard’s next query is a cold cache again.
This is the classic cache pollution problem, and LRU is the wrong answer to it because LRU has no memory of how often something was used. It only knows when.
The fix, in the literature and in ClickHouse, is to distinguish between entries
that have been used once and entries that have been used more than once, and to
give the second group more protection. ClickHouse implements two such policies,
SLRU and SIEVE, and makes SLRU the default for every cache in the tree.
SLRU: two queues
The idea is one sentence: the first time you touch an entry it is a probationary candidate; the second time it is proven to be worth keeping.
insert(key) get(key) on a probationary entry
│ │
▼ ▼
┌───────────────┐ get() (2nd touch) ┌───────────────┐
│ probationary │ ────────────────────► │ protected │
│ queue │ │ queue │
└───────────────┘ └───────────────┘
│ │
│ evicted when the cache is full │ demoted back to
▼ ▼ probationary
GONE ┌───────────────┐
│ probationary │
└───────────────┘Two std::list<Key> queues, one unordered_map of cells, and a boolean per
cell recording which queue it is in.
// src/Common/SLRUCachePolicy.h:256
private:
using SLRUQueue = std::list<Key>;
using SLRUQueueIterator = typename SLRUQueue::iterator;
SLRUQueue probationary_queue;
SLRUQueue protected_queue;
struct Cell
{
bool is_protected = false;
MappedPtr value;
size_t size{};
SLRUQueueIterator queue_iterator;
};Compare with the LRU cell, which had value, size, queue_iterator. The
SLRU cell adds exactly one bit: is_protected. That single bit is the entire
difference in behaviour, and it is why SLRU needs two lists instead of one.
The class documentation says what it is for:
// src/Common/SLRUCachePolicy.h:12
/// Cache policy SLRU evicts entries which were used only once and are not used for a long time,
/// this policy protects entries which were used more then once from a sequential scan. Also see cache policy LRU for reference.
/// WeightFunction is a functor that takes Mapped as a parameter and returns "weight" (approximate size) of that value.
/// Cache starts to evict entries when their total weight exceeds max_size_in_bytes.
/// Value weight should not change after insertion.
“protects entries which were used more than once from a sequential scan” — that is the entire pitch, stated in the header comment.
The promotion: one bool, one splice
Here is the whole hit path:
// src/Common/SLRUCachePolicy.h:153
MappedPtr get(const Key & key) override
{
auto it = cells.find(key);
if (it == cells.end())
return {};
Cell & cell = it->second;
if (cell.is_protected)
protected_queue.splice(protected_queue.end(), protected_queue, cell.queue_iterator);
else
{
cell.is_protected = true;
current_protected_size += cell.size;
protected_queue.splice(protected_queue.end(), probationary_queue, cell.queue_iterator);
removeOverflow(protected_queue, max_protected_size, current_protected_size, /*is_protected=*/true);
}
return cell.value;
}Two cases:
- Already protected: just move it to the back of the protected queue. It
stays protected, and its weight is not recounted (it was already counted in
current_protected_size). - Probationary: this is the second touch, so promote it. Set
is_protected = true, add its weight tocurrent_protected_size,spliceit from the probationary queue into the protected queue, and then trim the protected queue if the promotion pushed it over its budget.
The splice here is the clever part. protected_queue.splice(protected_queue.end(), probationary_queue, cell.queue_iterator) moves the node from one list to
the other in O(1), with no allocation and no copying of the key. The cell’s
stored iterator still points at that node afterwards — it just points at a node
that now lives in a different list.
The one thing that is easy to miss: the promotion is triggered by get(),
not by an explicit “mark as hot” call. There is no separate API. The second
lookup is the promotion.
Note also that get() does not update the metrics, even though it can change
current_protected_size. The comment on updateMetrics explains:
// src/Common/SLRUCachePolicy.h:366
/// Adds to the metrics how much the size and the count of the cache changed since they were
/// `old_size_in_bytes` and `old_count`. All threads that use caches of the same kind update the same
/// metrics, so each method updates them once, after all its changes, and skips a metric that did not
/// change. A lookup only moves a cell between the queues, so it does not update the metrics at all.
Only the total size metric matters to an operator, and a lookup does not change the total. So the metrics are updated once per mutating operation, not once per queue move. This is a small optimisation with a real justification: the metrics are global counters shared by every cache of the same kind, and hammering them on every lookup would be a contention point.
The two budgets
SLRU has two limits, not one:
max_size_in_bytes— the total. The probationary queue is bounded by this.max_protected_size— the protected queue’s own budget, derived from the total by a ratio.
// src/Common/SLRUCachePolicy.h:289
/// Applies size_ratio to a cache limit, in bytes or in entries.
/// static_cast<double>(std::numeric_limits<size_t>::max()) rounds up to 2^64, which is not representable
/// in size_t, so the product is compared against the limit before it is converted back.
static size_t calculateMaxProtectedValue(size_t max_value, double size_ratio)
{
const double max_protected_value = static_cast<double>(max_value) * std::max(0.0, std::min(1.0, size_ratio));
if (max_protected_value >= static_cast<double>(max_value))
return max_value;
return static_cast<size_t>(max_protected_value);
}The size_ratio defaults to 0.5, so half the cache is protected and half is
probationary. The ratio is a server setting, per cache:
// src/Core/ServerSettings.cpp:661
DECLARE(Double, mark_cache_size_ratio, DEFAULT_MARK_CACHE_SIZE_RATIO, R"(The size of the protected queue (in case of SLRU policy) in the mark cache relative to the cache's total size.)", 0) \
The floating-point dance in calculateMaxProtectedValue is worth a look: if
max_value is SIZE_MAX, then static_cast<double>(SIZE_MAX) rounds up to
2^64, which is not representable in size_t. Multiplying by the ratio and
casting back would be undefined. So the code compares the double against the
original limit before converting, and returns the original limit if the
product would overflow. A cache configured with “unlimited” entries and a
ratio of 1.0 must not blow up.
Demotion: how protected entries lose their protection
A protected entry that has not been touched for a long time should stop being protected — otherwise the protected queue becomes a place where entries go to die, and the probationary queue never gets to try anything new. So when the protected queue exceeds its budget, its oldest entries are demoted back to probationary rather than evicted.
// src/Common/SLRUCachePolicy.h:300
/// While `queue` exceeds its limits, moves its oldest cells out of it: a protected cell moves to the
/// probationary queue, and a probationary cell leaves the cache. The callers update the metrics.
void removeOverflow(SLRUQueue & queue, size_t max_weight_size, size_t & current_weight_size, bool is_protected)
{
size_t current_weight_lost = 0;
size_t queue_size = queue.size();
std::function<bool()> need_remove;
if (is_protected)
{
/// Check if after remove all elements from probationary part there will be no more than max elements
/// in protected queue and weight of all protected elements will be less then max protected weight.
/// It's not possible to check only cells.size() > max_count
/// because protected elements move to probationary part and still remain in cache.
need_remove = [&]()
{
return ((max_count != 0 && cells.size() - probationary_queue.size() > max_protected_count)
|| (current_weight_size > max_weight_size)) && (queue_size > 0);
};
}
else
{
need_remove = [&]()
{
return ((max_count != 0 && cells.size() > max_count) || (current_weight_size > max_weight_size)) && (queue_size > 0);
};
}
while (need_remove())
{
const Key & key = queue.front();
auto it = cells.find(key);
if (it == cells.end())
std::terminate(); // Queue became inconsistent
auto & cell = it->second;
current_weight_size -= cell.size;
if (cell.is_protected)
{
cell.is_protected = false;
probationary_queue.splice(probationary_queue.end(), queue, cell.queue_iterator);
}
else
{
current_weight_lost += cell.size;
/// We cannot have protected cells in non-protected queue
chassert(!is_protected);
/// Update cache-specific metrics.
if (on_remove_entry_function)
on_remove_entry_function(cell.size, cell.value);
cells.erase(it);
queue.pop_front();
}
--queue_size;
}
if (current_size_in_bytes > (1ull << 63))
std::terminate(); // Queue became inconsistent
}One function handles both queues, parameterised by which queue is being drained and whether it is the protected one. The two behaviours are:
- Draining the protected queue: the front entry is demoted —
is_protectedset to false, and the node spliced into the back of the probationary queue. Note the splice target:probationary_queue.end(), so the demoted entry goes to the back of probationary, i.e. it becomes the most recently used probationary entry. It gets one more chance before it can be evicted. - Draining the probationary queue: the front entry is evicted — removed from the map, the entry-removal callback fires, and the node is popped.
The subtlety is that the same function is called with the protected queue as the argument while a protected entry is being demoted into the probationary queue. So the code has to handle “the cell says it is protected but we are draining the probationary queue”. That case cannot happen, and there is an assertion for it:
// src/Common/SLRUCachePolicy.h:349
/// We cannot have protected cells in non-protected queue
chassert(!is_protected);The count-limit check for the protected queue is subtle:
// src/Common/SLRUCachePolicy.h:316
return ((max_count != 0 && cells.size() - probationary_queue.size() > max_protected_count)cells.size() - probationary_queue.size() is the number of protected cells.
The comment explains why you cannot just check cells.size() > max_count:
protected cells that get demoted stay in the cache (they move to probationary),
so the total count does not shrink when the protected queue is trimmed. The
protected queue’s own count limit has to be checked against the protected count
specifically.
The insert path
// src/Common/SLRUCachePolicy.h:200
void set(const Key & key, const MappedPtr & mapped) override
{
const size_t old_size_in_bytes = current_size_in_bytes;
const size_t old_count = cells.size();
auto [it, inserted] = cells.emplace(std::piecewise_construct,
std::forward_as_tuple(key),
std::forward_as_tuple());
Cell & cell = it->second;
if (inserted)
{
try
{
cell.queue_iterator = probationary_queue.insert(probationary_queue.end(), key);
}
catch (...)
{
cells.erase(it);
throw;
}
}
else
{
current_size_in_bytes -= cell.size;
if (cell.is_protected)
{
current_protected_size -= cell.size;
protected_queue.splice(protected_queue.end(), protected_queue, cell.queue_iterator);
}
else
{
cell.is_protected = true;
protected_queue.splice(protected_queue.end(), probationary_queue, cell.queue_iterator);
}
}
cell.value = mapped;
cell.size = cell.value ? weight_function(*cell.value) : 0;
current_size_in_bytes += cell.size;
current_protected_size += cell.is_protected ? cell.size : 0;
removeOverflow(protected_queue, max_protected_size, current_protected_size, /*is_protected=*/true);
removeOverflow(probationary_queue, max_size_in_bytes, current_size_in_bytes, /*is_protected=*/false);
updateMetrics(old_size_in_bytes, old_count);
}New entries always land in the probationary queue, at the back. They have not proved anything yet.
But an update to an existing probationary entry promotes it to protected:
// src/Common/SLRUCachePolicy.h:231
else
{
cell.is_protected = true;
protected_queue.splice(protected_queue.end(), probationary_queue, cell.queue_iterator);
}So overwriting an entry counts as a use. That is a deliberate choice: the caller had to produce the value, which means it cared about it.
Two overflow calls, in a specific order: protected first, then probationary. The order matters. If the probationary queue is trimmed first, a demotion from the protected queue could push the probationary queue over its limit, requiring another pass. Draining protected first means the demotions land in a probationary queue that is then trimmed in the same call. One pass, consistent state.
Also note the weight accounting on update: current_size_in_bytes -= cell.size
before the new weight is known, and current_protected_size is adjusted to
match whether the cell was protected before the update. The line
// src/Common/SLRUCachePolicy.h:241
current_protected_size += cell.is_protected ? cell.size : 0;adds the new weight, and the earlier subtraction removed the old weight. Since an update can change the weight arbitrarily, both queues’ totals are recomputed from the cell’s new size.
SIEVE: the hand
SLRU needs two lists and a bit per cell. SIEVE needs one list, one bit per cell, and a cursor. It is the algorithm from the NSDI 2024 paper, and the implementation names the reference:
// src/Common/SIEVECachePolicy.h:12
/// Cache policy SIEVE evicts entries using the SIEVE algorithm: a single "hand" walks through a FIFO
/// queue of entries and inspects each entry's `visited` flag. If the flag is set, it is cleared and the
/// hand advances; if the flag is clear, the entry is evicted. Newly inserted entries start with the flag
/// cleared and are appended to the back of the queue. This is a simple, scan-resistant alternative to LRU.
/// Also see cache policy LRU for reference.
/// WeightFunction is a functor that takes Mapped as a parameter and returns "weight" (approximate size) of that value.
/// Cache starts to evict entries when their total weight exceeds max_size_in_bytes.
/// Value weight should not change after insertion.
/// To work with the thread-safe implementation of this class use a class "CacheBase" with first parameter "SIEVE"
/// and next parameters in the same order as in the constructor of the current class.
/// For more details, see https://junchengyang.com/publication/nsdi24-SIEVE.pdf
The mechanism, in one paragraph: the queue is a FIFO, not a recency list.
A lookup does not move the entry. It only sets a visited bit. When the
cache needs to evict, a “hand” walks the queue from the front: if the entry it
points at has visited == true, it clears the bit and advances (giving the
entry one more chance, but not moving it); if visited == false, it evicts the
entry and the hand stays put (the next node is now at that position).
queue (FIFO, insertion order) hand
┌────┬────┬────┬────┬────┬────┬────┐
│ A │ B │ C │ D │ E │ F │ G │
└────┴────┴────┴────┴────┴────┴────┘
▲
hand
hand at A, A.visited == true:
clear A.visited, advance hand to B. (A survives this round)
hand at A, A.visited == false:
evict A, hand stays at what is now A (= old B).The data structure is barely different from LRU’s:
// src/Common/SIEVECachePolicy.h:231
private:
using SIEVEQueue = std::list<Key>;
using SIEVEQueueIterator = typename SIEVEQueue::iterator;
struct Cell
{
MappedPtr value;
size_t size = 0;
bool visited = false;
SIEVEQueueIterator queue_iterator;
};
using Cells = std::unordered_map<Key, Cell, HashFunction>;
SIEVEQueue queue;
SIEVEQueueIterator hand;
Cells cells;Compare with SLRU: bool is_protected becomes bool visited, and there is one
queue instead of two, plus a hand iterator.
And the hit path is simpler than SLRU’s — it is a bit store:
// src/Common/SIEVECachePolicy.h:125
MappedPtr get(const Key & key) override
{
auto it = cells.find(key);
if (it == cells.end())
return {};
Cell & cell = it->second;
cell.visited = true;
return cell.value;
}One hash lookup, one bit store. No list surgery at all. That is cheaper than both LRU and SLRU on the hit path, and it is why SIEVE is interesting: it achieves scan resistance without touching the queue on reads.
The eviction loop:
// src/Common/SIEVECachePolicy.h:259
void removeOverflow(bool ignore_last_element)
{
/// SIEVE algorithm:
/// `hand` walks through the queue.
/// If it points to a cell with visited == true, it sets visited = false and advances.
/// If it points to a cell with visited == false, it evicts this cell.
const size_t old_size_in_bytes = current_size_in_bytes;
const size_t old_size = cells.size();
while (!queue.empty()
&& (current_size_in_bytes > max_size_in_bytes || (max_count != 0 && queue.size() > max_count)))
{
if (hand == queue.end()
|| (ignore_last_element && queue.size() > 1 && hand == std::prev(queue.end())))
{
/// Do not evict the just-inserted element and wrap the hand back to the start.
hand = queue.begin();
}
const Key & key = *hand;
auto it = cells.find(key);
if (it == cells.end())
std::terminate(); // Queue became inconsistent
auto & cell = it->second;
if (!cell.visited)
{
current_size_in_bytes -= cell.size;
if (on_remove_entry_function)
on_remove_entry_function(cell.size, cell.value);
hand = queue.erase(hand);
cells.erase(it);
}
else
{
cell.visited = false;
++hand;
}
}
if (hand == queue.end())
hand = queue.begin();
CurrentMetrics::sub(current_size_in_bytes_metric, old_size_in_bytes - current_size_in_bytes);
CurrentMetrics::sub(count_metric, old_size - cells.size());
if (current_size_in_bytes > (1ull << 63))
std::terminate(); // Queue became inconsistent
}queue.erase(hand) returns an iterator to the next element, so the hand
automatically lands on the successor of the evicted entry — no manual
adjustment needed. And ++hand on the “give it another chance” branch does the
same thing.
The ignore_last_element subtlety
The bool ignore_last_element parameter looks like a small optimisation but it
is guarding a real bug. The rule in the SIEVE paper is that a newly inserted
entry should not be evicted immediately — it goes to the back of the queue, and
the hand is usually somewhere in the middle, so the hand will reach it only
after a full lap. But if the hand happens to be at the back already, the
newly inserted entry would be the first thing evicted.
The fix is to wrap the hand back to the front when it is about to consider the
tail, skipping the just-inserted element. But that exemption must apply only
to a real insertion. On an update, no new tail is created, so an unrelated
entry at the tail must not get the exemption. The code distinguishes the two by
passing inserted:
// src/Common/SIEVECachePolicy.h:190
/// Protect the just-inserted tail entry from immediate eviction only when a new entry was
/// actually appended. On an update no new tail is created, so the hand must walk normally;
/// otherwise an unrelated tail entry would wrongly receive the new-entry exemption.
removeOverflow(/*ignore_last_element=*/ inserted);There is a unit test that pins this behaviour down, which is worth reading because it documents the bug in the test’s own comment:
// src/Common/tests/gtest_sieve_cache.cpp:244
/// An update that overflows the cache must not protect the entry the hand currently points at as if it
/// were a freshly inserted tail entry: only a real insertion appends a new tail to protect. Here the hand
/// points at the tail (key 11) and an update of an unrelated key (key 1) overflows. The canonical SIEVE
/// hand inspects and evicts key 11; the buggy behaviour skipped key 11 and evicted key 3 instead.
TEST(SIEVECache, UpdateDoesNotProtectUnrelatedTail)The SIEVE policy also has to handle the hand pointing at an entry that is removed out from under it. That is handled explicitly:
// src/Common/SIEVECachePolicy.h:89
const bool hand_points_to_removed = hand == cell.queue_iterator;
auto next = queue.erase(cell.queue_iterator);
if (hand_points_to_removed)
hand = next == queue.end() ? queue.begin() : next;Compare with the eviction path, where queue.erase(hand) returns the
successor directly. Two different call sites, two different idioms, because in
one case the hand is the erased iterator and in the other it is not.
Observing SIEVE from a test
The SIEVE policy exposes its internals for testing — the queue order, the
visited bits, and the hand’s position:
// src/Common/SIEVECachePolicy.h:204
/// Returns the keys in their queue order. For unit tests.
std::vector<Key> dumpQueue() const
{
std::vector<Key> res;
for (const auto & key : queue)
res.push_back(key);
return res;
}
/// Returns the `visited` flag of an entry, or std::nullopt if the key is absent. For unit tests.
std::optional<bool> isVisited(const Key & key) const
{
auto it = cells.find(key);
if (it == cells.end())
return std::nullopt;
const Cell & cell = it->second;
return cell.visited;
}
/// Returns the key the hand currently points to, or std::nullopt if the queue is empty. For unit tests.
std::optional<Key> getHand() const
{
if (hand == queue.end())
return std::nullopt;
return *hand;
}And the test walks through a complete eviction sequence, asserting the queue contents and hand position at every step:
// src/Common/tests/gtest_sieve_cache.cpp:161
sieve_cache.set(8, std::make_shared<size_t>(2));
ASSERT_EQ(sieve_cache.count(), 7);
assertQueue(sieve_cache_policy.dumpQueue(), {1, 3, 4, 5, 6, 7, 8});
ASSERT_EQ(sieve_cache_policy.getHand().value(), 3);
ASSERT_TRUE(sieve_cache_policy.isVisited(1).value() == 0);
ASSERT_TRUE(sieve_cache_policy.isVisited(3).value() == 1);Reading that trace: entries 1 and 3 were touched (so they are visited), then entry 8 was inserted. The cache overflowed, the hand was at 1 (unvisited), so 1 was evicted. The hand moved to 3, which is visited, so its bit was cleared and the hand advanced to 4. Entry 8 went to the back of the queue.
Choosing between them
-- What is the current policy?
SELECT name, value, default
FROM system.server_settings
WHERE name LIKE '%_cache_policy';┌─name──────────────────────┬─value─┬─default─┐
│ mark_cache_policy │ SLRU │ SLRU │
│ primary_index_cache_policy│ SLRU │ SLRU │
│ statistics_cache_policy │ SLRU │ SLRU │
└───────────────────────────┴───────┴─────────┘The three options and what they are for:
| Policy | Data structure | Hit cost | Scan resistance |
|---|---|---|---|
LRU | one list, recency-ordered | one splice | none |
SLRU | two lists + is_protected bit | one splice (maybe two) | strong: second-touch promotion |
SIEVE | one FIFO list + visited bit + hand | one bit store | good: hand gives a second chance |
The trade-offs:
LRU is optimal for a stationary workload and cheapest to reason about. It is the right choice when the access pattern is uniform and there are no sequential scans — for instance a cache whose keyspace is small enough that everything fits anyway.
SLRU is the default because a scan is the common case in an OLAP server.
It costs one extra std::list and one bit per cell, and it needs the
demotion path to keep the protected queue from becoming a graveyard. It also
means a genuinely cold entry has to be hit twice before it is protected, so
the first hit of a cold entry does not make it immune to eviction.
SIEVE has the cheapest hit path of the three — a bit store, no list surgery — and it is the only one whose queue order is insertion order rather than access order, which makes its behaviour easier to reason about and cheaper to maintain. Its weakness relative to SLRU is that the protection is implicit: an entry gets one more chance from the hand, but it is not promoted to a separate region, so a long-lived hot entry and a just-touched-but-rarely-used entry are treated more similarly than SLRU treats them.
What all three share: the same Cell layout, the same weight function,
the same byte-based budget, the same CacheBase wrapper with its mutex and
insert tokens, and the same on_remove_entry_function callback. Swapping
policies is a one-line change at construction.
Trade-offs
What SLRU buys you. A sequential scan cannot flush the cache. Entries that have been used at least twice live in a region of the cache that a scan never touches, because a scan’s entries are all new and land in probationary. The cost: one extra list, one bit per cell, and a demotion path that has to keep two weight counters straight.
What SIEVE buys you. The cheapest hit path of the three and a
scan-resistant eviction order with a single queue. The cost: the eviction
behaviour is genuinely subtle — the ignore_last_element distinction between
an insert and an update is the kind of thing that produces a bug which only
manifests under concurrent load, which is why it has a dedicated regression
test.
What they all cost. The cache is a single mutex. Under heavy concurrency the lock, not the algorithm, is the bottleneck. And the weight is a snapshot taken at insert time, so a value that changes size corrupts the accounting.
The part people get wrong. size_ratio = 1.0 hands the entire cache to
the protected queue, and nothing can ever stay probationary. That is a
misconfiguration, not a case to protect against, and the tests say so
explicitly:
// src/Common/tests/gtest_slru_cache.cpp:255
/// A ratio of 1.0 hands the whole cache to the protected queue, so nothing can stay
/// probationary. That is a misconfiguration rather than a case to protect against.
What to go do about it. If a cache’s hit rate collapses during a large scan and recovers afterwards, that is the cache pollution problem and SLRU is already the answer. If the hit rate is low all the time, the problem is different: the cache is too small, the workload has no reuse, or the keyspace is too large. Change the policy first, and only then the size.
Wrapping up
The one thing to remember: ClickHouse’s default cache policy is not LRU. It is SLRU, and the reason is that a query server’s dominant access pattern is a burst of one-hit-wonders, which plain LRU handles catastrophically.
The part people get wrong: SLRU protects an entry only from the second touch onward. A cold entry hit once is still a probationary entry and can still be evicted by the next scan.
What to go do about it: read
SELECT metric, value FROM system.metrics WHERE metric LIKE '%Cache%' next to
system.events for the *CacheEvicted* counters. If the evicted count spikes
during a large scan while the hit rate stays high, the policy is doing its
job. If the hit rate is flat and low, no eviction policy will save you.