PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

LRU Cache – O(1) implementacja z HashMap + lista dwukierunkowa, per-request cache

by Henryk Tews / wtorek, 17 grudnia 2024 / Opublikowano w Algorytmy

LRU (Least Recently Used) Cache to jeden z klasycznych problemów algorytmicznych z rozmów rekrutacyjnych, ale też realna struktura danych używana w systemach produkcyjnych. Redis domyślnie używa wariantu LRU do eksmisji kluczy. PHP OPcache stosuje LRU. Pokazuję jak zaimplementować LRU Cache w O(1) dla get i put, używając hashmapy i listy dwukierunkowej – i gdzie to ma zastosowanie w e-commerce.

Problem który rozwiązuje LRU Cache

Masz ograniczoną pamięć i chcesz cachować wyniki kosztownych operacji. Gdy cache jest pełny i musisz dodać nowy element – który usunąć? LRU usuwa element który był najdawniej używany. Intuicja: jeśli czegoś nie użyłeś od dawna, prawdopodobnie nie użyjesz wkrótce.

Naiwna implementacja – O(n) dla get

<?php

declare(strict_types=1);

// Naiwna implementacja - prosta ale wolna O(n) przy eviction
class SimpleLruCache
{
    private array $cache = []; // [key => value]
    private array $order = []; // klucze w kolejności użycia

    public function __construct(private int $capacity) {}

    public function get(string $key): mixed
    {
        if (!isset($this->cache[$key])) {
            return null;
        }

        // Przenieś na koniec (najnowszy) - O(n) przez array_search
        $this->moveToEnd($key);
        return $this->cache[$key];
    }

    public function put(string $key, mixed $value): void
    {
        if (isset($this->cache[$key])) {
            $this->cache[$key] = $value;
            $this->moveToEnd($key);
            return;
        }

        if (count($this->cache) >= $this->capacity) {
            // Usuń najstarszy (pierwszy w kolejności) - O(1)
            $oldest = array_shift($this->order);
            unset($this->cache[$oldest]);
        }

        $this->cache[$key]  = $value;
        $this->order[] = $key;
    }

    private function moveToEnd(string $key): void
    {
        // O(n) - wyszukanie i przesunięcie elementu w tablicy
        $pos = array_search($key, $this->order, true);
        if ($pos !== false) {
            array_splice($this->order, $pos, 1);
            $this->order[] = $key;
        }
    }
}

Optymalna implementacja – O(1) dla get i put

Kluczowa obserwacja: HashMap daje O(1) lookup, lista dwukierunkowa daje O(1) insert i delete przy znajomości węzła. Połączone: HashMap przechowuje wartość + wskaźnik do węzła listy, lista utrzymuje kolejność LRU.

<?php

declare(strict_types=1);

// Węzeł listy dwukierunkowej
class LruNode
{
    public ?LruNode $prev = null;
    public ?LruNode $next = null;

    public function __construct(
        public string $key,
        public mixed $value
    ) {}
}

// LRU Cache O(1) dla get i put
class LruCache
{
    /** @var array<string, LruNode> HashMap: key -> node */
    private array $map = [];

    // Sentinel nodes - eliminują sprawdzenie null przy operacjach na liście
    private LruNode $head; // najstarszy (LRU) - tuż za head
    private LruNode $tail; // najnowszy (MRU) - tuż przed tail

    public function __construct(private int $capacity)
    {
        // Sentinel nodes nie przechowują danych - upraszczają insert/delete
        $this->head = new LruNode('__head__', null);
        $this->tail = new LruNode('__tail__', null);

        $this->head->next = $this->tail;
        $this->tail->prev = $this->head;
    }

    // GET - O(1)
    public function get(string $key): mixed
    {
        if (!isset($this->map[$key])) {
            return null; // cache miss
        }

        $node = $this->map[$key];
        $this->moveToFront($node); // oznacz jako najnowiej używany
        return $node->value;
    }

    // PUT - O(1)
    public function put(string $key, mixed $value): void
    {
        if (isset($this->map[$key])) {
            // Aktualizuj istniejący
            $node = $this->map[$key];
            $node->value = $value;
            $this->moveToFront($node);
            return;
        }

        if (count($this->map) >= $this->capacity) {
            // Evict LRU - węzeł za head sentinel
            $lru = $this->head->next;
            $this->removeNode($lru);
            unset($this->map[$lru->key]);
        }

        // Dodaj nowy węzeł przed tail sentinel (najnowszy)
        $node = new LruNode($key, $value);
        $this->addBeforeTail($node);
        $this->map[$key] = $node;
    }

    public function has(string $key): bool
    {
        return isset($this->map[$key]);
    }

    public function delete(string $key): bool
    {
        if (!isset($this->map[$key])) {
            return false;
        }

        $node = $this->map[$key];
        $this->removeNode($node);
        unset($this->map[$key]);
        return true;
    }

    public function size(): int { return count($this->map); }

    // Przenieś węzeł na pozycję "najnowszy" (tuż przed tail)
    private function moveToFront(LruNode $node): void
    {
        $this->removeNode($node);
        $this->addBeforeTail($node);
    }

    // Odłącz węzeł z listy
    private function removeNode(LruNode $node): void
    {
        $node->prev->next = $node->next;
        $node->next->prev = $node->prev;
    }

    // Wstaw węzeł tuż przed tail (pozycja "najnowszy")
    private function addBeforeTail(LruNode $node): void
    {
        $node->prev = $this->tail->prev;
        $node->next = $this->tail;

        $this->tail->prev->next = $node;
        $this->tail->prev = $node;
    }

    // Debug: zwróć kolejność kluczy od LRU do MRU
    public function getKeysInOrder(): array
    {
        $keys = [];
        $current = $this->head->next;

        while ($current !== $this->tail) {
            $keys[] = $current->key;
            $current = $current->next;
        }

        return $keys;
    }
}

Testy i wizualizacja zachowania

<?php

// Demonstracja działania LRU Cache z capacity = 3
$cache = new LruCache(3);

$cache->put('a', 1);
echo implode(', ', $cache->getKeysInOrder()) . "\n"; // a

$cache->put('b', 2);
echo implode(', ', $cache->getKeysInOrder()) . "\n"; // a, b

$cache->put('c', 3);
echo implode(', ', $cache->getKeysInOrder()) . "\n"; // a, b, c

// Dostęp do 'a' - przesuwa na koniec (MRU)
$cache->get('a');
echo implode(', ', $cache->getKeysInOrder()) . "\n"; // b, c, a

// Dodaj 'd' - pojemność przekroczona, usuwa 'b' (LRU)
$cache->put('d', 4);
echo implode(', ', $cache->getKeysInOrder()) . "\n"; // c, a, d

var_dump($cache->get('b')); // NULL - wyeksmitowany
var_dump($cache->get('a')); // int(1) - nadal w cache

Praktyczne zastosowanie – cache wyników zapytań produktów

<?php

declare(strict_types=1);

// In-process cache dla wyników Repository - eliminuje powtarzające się zapytania
// w obrębie jednego requestu HTTP
class CachedProductRepository
{
    // Cache na poziomie procesu PHP - zniknie po zakończeniu requestu
    // Pojemność: max 500 produktów w pamięci jednocześnie
    private LruCache $requestCache;

    public function __construct(
        private \Magento\Catalog\Api\ProductRepositoryInterface $repository
    ) {
        $this->requestCache = new LruCache(500);
    }

    public function getById(int $id): \Magento\Catalog\Api\Data\ProductInterface
    {
        $cacheKey = "product_id_{$id}";

        $cached = $this->requestCache->get($cacheKey);
        if ($cached !== null) {
            return $cached; // hit - zero zapytań do bazy
        }

        $product = $this->repository->getById($id);
        $this->requestCache->put($cacheKey, $product);

        return $product;
    }

    public function getBySku(string $sku): \Magento\Catalog\Api\Data\ProductInterface
    {
        $cacheKey = "product_sku_{$sku}";

        $cached = $this->requestCache->get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        $product = $this->repository->get($sku);

        // Cache pod oboma kluczami
        $this->requestCache->put($cacheKey, $product);
        $this->requestCache->put("product_id_{$product->getId()}", $product);

        return $product;
    }
}

// Scenariusz: renderowanie strony kategorii z 100 produktami
// Bez cache: 100 wywołań getById = 100 zapytań SQL
// Z LRU: pierwsze 100 trafień to miss, kolejne wywołania (np. related products)
// dla tych samych produktów = hit, zero dodatkowych zapytań

Benchmark – LRU vs SplFixedArray vs Magento ObjectCache

<?php

declare(strict_types=1);

$iterations = 100_000;
$capacity   = 1000;
$cache      = new LruCache($capacity);

// Wypełnij cache
for ($i = 0; $i < $capacity; $i++) {
    $cache->put("key_{$i}", "value_{$i}");
}

// Benchmark get (mixed hit/miss pattern)
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $key = "key_" . ($i % ($capacity * 2)); // 50% hit rate
    $cache->get($key);
}
$getTime = (microtime(true) - $start) * 1000;

// Benchmark put z eviction
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
    $cache->put("new_key_{$i}", "value_{$i}"); // zawsze eviction
}
$putTime = (microtime(true) - $start) * 1000;

echo "LRU get (100k ops): {$getTime}ms\n";   // ~20-30ms
echo "LRU put (100k ops): {$putTime}ms\n";   // ~25-35ms
echo "Średni get: " . round($getTime / $iterations * 1000, 2) . "μs\n"; // ~0.2μs

Podsumowanie

LRU Cache to algorytm który elegancko łączy dwie struktury danych – HashMap i listę dwukierunkową – w rozwiązanie O(1) dla obu operacji. Implementacja od zera jest klasycznym ćwiczeniem algorytmicznym, ale w praktyce PHP dla per-request cache wystarczy prosta tablica asocjacyjna z array_shift(). LRU wchodzi do gry gdy masz tysiące różnych kluczy w jednym procesie (CLI importer, długo działający worker) i chcesz kontrolować zużycie pamięci bez ręcznego zarządzania. Dla cache między requestami – Redis z polityką allkeys-lru robi to za Ciebie na poziomie infrastruktury.

About Henryk Tews

Co możesz przeczytać następne

Dijkstra – najkrótsza ścieżka z wagami, SplMinHeap, zastosowania w e-commerce
Sortowanie w PHP – bubble sort, merge sort, usort() i operator <=>
Lista dwukierunkowa – implementacja od zera, SplDoublyLinkedList, tabela porównawcza
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}