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 visited bit 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.

text
   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.

cpp
// 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:

cpp
// 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:

cpp
// 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 to current_protected_size, splice it 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:

cpp
// 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.
cpp
// 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:

cpp
// 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.

cpp
// 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_protected set 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:

cpp
// 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:

cpp
// 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

cpp
// 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:

cpp
// 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

cpp
// 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:

cpp
// 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).

text
   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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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

sql
-- What is the current policy?
SELECT name, value, default
FROM system.server_settings
WHERE name LIKE '%_cache_policy';
text
┌─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:

PolicyData structureHit costScan resistance
LRUone list, recency-orderedone splicenone
SLRUtwo lists + is_protected bitone splice (maybe two)strong: second-touch promotion
SIEVEone FIFO list + visited bit + handone bit storegood: 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:

cpp
// 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.

ClickhouseInternalsCachingPerformanceAlgorithms