Vodiči

Mastering PHP Caching: Strategies for Performance, Consistency, and Stampede Prevention

Savladavanje PHP predmemoriranja: Strategije za performanse, dosljednost i sprječavanje navale zahtjeva

Predmemoriju je lako dodati, a iznenađujuće teško održavati. Teška pitanja počinju nakon prvog uspješnog GET zahtjeva: Što se događa kada ključ istekne pod velikim prometom? Koliko brzo zapisi moraju postati vidljivi? Može li izbacivanje tiho onemogućiti invalidaciju? Što bi zahtjev trebao učiniti kada drugi zahtjev posjeduje zaključavanje osvježavanja?

Ovaj vodič izrađuje produkcijski orijentiranu PHP 8.3 uslugu koja izričito odgovara na ta pitanja. Ona poslužuje proizvode iz PostgreSQL-a, predmemorira ih u Redisu, objedinjuje istodobne promašaje pomoću zaključavanja zaštićenog tokenom, poslužuje zastarjele podatke tijekom osvježavanja i invalidira cijeli imenski prostor proizvoda nakon zapisa.

Preduvjeti i cilj konzistentnosti

Potrebni su vam Docker s dodatkom Compose i curl. Spremnici koriste PHP 8.3 s PECL proširenjem redis 6.1.0, PostgreSQL 16 i Redis 7.2. API phpredis 6.1.x je sinkron, što odgovara Apacheovu modelu izvršavanja jedan zahtjev po radniku.

Usluga namjerno pruža ograničenu eventualnu konzistentnost umjesto linearizabilnih čitanja. Predmemorirani proizvod svjež je 15 sekundi i može se poslužiti kao zastario još 120 sekundi dok ga jedan zahtjev osvježava. Uspješni zapisi povećavaju generaciju imenskog prostora, tako da sljedeći zahtjevi odmah koriste nove ključeve. Zahtjevi koji su već u tijeku i dalje mogu vratiti prethodnu vrijednost.

Takav ugovor daje prednost dostupnosti i predvidivom opterećenju baze podataka. Ako svako čitanje mora vidjeti najnoviji potvrđeni zapis, predmemorija na razini aplikacije obično je pogrešan autoritet, osim ako čitanja i zapisi sudjeluju u snažnijem koordinacijskom protokolu.

Arhitektura i raspored projekta

Svaki unos predmemorije sadrži proizvod i dvije vremenske oznake: fresh_until i stale_until. Ključ predmemorije uključuje generaciju poput products:3:item:1. Povećavanje products:version invalidira imenski prostor bez skeniranja ili brisanja ključeva.

Kada svježi podaci nisu dostupni, jedan zahtjev dobiva kratko Redis zaključavanje. Zahtjevi koji već imaju zastarjele podatke odmah ih vraćaju; hladni zahtjevi kratko čekaju vlasnika. Ako se rezultat ne pojavi, dobivaju kontrolirani odgovor na preopterećenje umjesto da svi upitaju PostgreSQL.

php-cache/
├── compose.yaml
├── Dockerfile
├── docker/
│   └── apache-vhost.conf
├── db/
│   └── init.sql
├── src/
│   └── ProductCache.php
└── public/
    └── index.php

Izgradite izvršno okruženje

Stvorite sljedeću Compose datoteku. Redis koristi postojanost samo s dodavanjem i pravilo noeviction. To je pravilo važno zato što bi izbačeni ključ generacije mogao stari imenski prostor ponovno učiniti trenutačnim. Iscrpljivanje memorije umjesto toga odbit će zapise, stvarajući vidljiv kvar koji se može nadzirati.

services:
  web:
    build: .
    ports:
      - "127.0.0.1:8080:80"
    environment:
      DB_DSN: "pgsql:host=postgres;port=5432;dbname=app;connect_timeout=1"
      DB_USER: "app"
      DB_PASSWORD: "development-only"
      REDIS_HOST: "redis"
      REDIS_PORT: "6379"
      ADMIN_TOKEN: "replace-this-before-deployment"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: "app"
      POSTGRES_USER: "app"
      POSTGRES_PASSWORD: "development-only"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20

  redis:
    image: redis:7.2-alpine
    command:
      - redis-server
      - --appendonly
      - "yes"
      - --maxmemory
      - 256mb
      - --maxmemory-policy
      - noeviction
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  pgdata:
  redisdata:

Glavno računalo objavljuje samo web priključak, i to samo na povratnoj petlji. PostgreSQL i Redis ostaju na privatnoj Compose mreži.

FROM php:8.3-apache

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq-dev \
 && docker-php-ext-install pdo_pgsql \
 && pecl install redis-6.1.0 \
 && docker-php-ext-enable redis \
 && rm -rf /var/lib/apt/lists/*

COPY docker/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
COPY public/ /var/www/html/public/
COPY src/ /var/www/html/src/
<VirtualHost *:80>
    DocumentRoot /var/www/html/public

    <Directory /var/www/html/public>
        AllowOverride None
        Require all granted
        FallbackResource /index.php
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

Stvorite bazu podataka

Shema je namjerno mala, ali put ažuriranja i dalje koristi zaključavanje redaka i ograničene operacije baze podataka. Skripte za inicijalizaciju pokreću se samo kada PostgreSQL stvori prazan podatkovni volumen.

CREATE TABLE products (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
    price_cents integer NOT NULL CHECK (price_cents >= 0),
    updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

INSERT INTO products (name, price_cents)
VALUES ('Mechanical Keyboard', 12900),
       ('USB-C Dock', 8900);

Implementirajte zaključavanje stale-while-revalidate

Vrijednost zaključavanja nasumični je token vlasništva. Oslobađanje običnim DEL naredbom bilo bi nesigurno: ako je zaključavanje isteklo i drugi ga je zahtjev stekao, izvorni vlasnik mogao bi izbrisati zamjenu. Lua skripta uspoređuje token i briše ga atomski.

<?php
declare(strict_types=1);

final class ProductCache
{
    private const RELEASE_LOCK = <<<'LUA'
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
end
return 0
LUA;

    public function __construct(
        private Redis $redis,
        private int $freshSeconds = 15,
        private int $staleSeconds = 120,
        private int $lockMilliseconds = 2000,
        private int $waitMilliseconds = 1200,
    ) {
        $this->redis->set('products:version', '1', ['nx']);
    }

    public function get(int $id, callable $loader): array
    {
        $version = (string) $this->redis->get('products:version');
        $key = "products:{$version}:item:{$id}";
        $lockKey = "{$key}:lock";
        $entry = $this->decode($this->redis->get($key));
        $now = microtime(true);

        if ($entry !== null && $entry['fresh_until'] > $now) {
            return ['status' => 'hit', 'value' => $entry['value']];
        }

        $token = bin2hex(random_bytes(16));
        $locked = $this->redis->set(
            $lockKey,
            $token,
            ['nx', 'px' => $this->lockMilliseconds]
        );

        if ($locked) {
            try {
                $again = $this->decode($this->redis->get($key));
                if ($again !== null && $again['fresh_until'] > microtime(true)) {
                    return ['status' => 'hit-after-lock', 'value' => $again['value']];
                }

                try {
                    $value = $loader();
                } catch (Throwable $error) {
                    if ($entry !== null && $entry['stale_until'] > microtime(true)) {
                        return ['status' => 'stale-on-error', 'value' => $entry['value']];
                    }
                    throw $error;
                }

                $created = microtime(true);
                $payload = json_encode([
                    'value' => $value,
                    'fresh_until' => $created + $this->freshSeconds,
                    'stale_until' => $created + $this->staleSeconds,
                ], JSON_THROW_ON_ERROR);

                $this->redis->setEx(
                    $key,
                    $this->staleSeconds + 5,
                    $payload
                );

                return ['status' => 'miss', 'value' => $value];
            } finally {
                $this->redis->eval(
                    self::RELEASE_LOCK,
                    [$lockKey, $token],
                    1
                );
            }
        }

        if ($entry !== null && $entry['stale_until'] > $now) {
            return ['status' => 'stale', 'value' => $entry['value']];
        }

        $deadline = microtime(true) + ($this->waitMilliseconds / 1000);
        do {
            usleep(25_000);
            $filled = $this->decode($this->redis->get($key));
            if ($filled !== null && $filled['stale_until'] > microtime(true)) {
                return ['status' => 'coalesced', 'value' => $filled['value']];
            }
        } while (microtime(true) < $deadline);

        throw new RuntimeException('Cache refresh is still in progress');
    }

    public function invalidateProducts(): int
    {
        return $this->redis->incr('products:version');
    }

    private function decode(string|false $json): ?array
    {
        if ($json === false) {
            return null;
        }

        try {
            $entry = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
            return isset(
                $entry['value'],
                $entry['fresh_until'],
                $entry['stale_until']
            ) ? $entry : null;
        } catch (JsonException) {
            return null;
        }
    }
}

Zaključavanje traje dvije sekunde, dok je donje ograničenje vremena izvršavanja PostgreSQL naredbe jedna sekunda. Taj je redoslijed namjeran: poslovni rad mora završiti ili ne uspjeti prije isteka zakupa. Čekanje pri hladnom promašaju kraće je od zaključavanja, što sprječava da svaki čekatelj postane drugi učitavač.

Izložite krajnje točke za čitanje, zapis i provjeru stanja

Prednji kontroler konfigurira zasebna vremenska ograničenja za Redis vezu i čitanje. Vremensko ograničenje veze ne ograničava naknadna čitanja, baš kao što PostgreSQL-ov connect_timeout ne ograničava upite. PostgreSQL stoga također prima vremenska ograničenja naredbi i zaključavanja na razini sesije.

<?php
declare(strict_types=1);

require '/var/www/html/src/ProductCache.php';

header('Content-Type: application/json');

function respond(int $status, array $body): never
{
    http_response_code($status);
    echo json_encode($body, JSON_THROW_ON_ERROR);
    exit;
}

try {
    $redis = new Redis();
    $redis->connect(
        getenv('REDIS_HOST') ?: 'redis',
        (int) (getenv('REDIS_PORT') ?: 6379),
        0.25
    );
    $redis->setOption(Redis::OPT_READ_TIMEOUT, 0.25);

    $pdo = new PDO(
        getenv('DB_DSN'),
        getenv('DB_USER'),
        getenv('DB_PASSWORD'),
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_PERSISTENT => false,
        ]
    );
    $pdo->exec("SET statement_timeout = '1000ms'");
    $pdo->exec("SET lock_timeout = '250ms'");

    $cache = new ProductCache($redis);
    $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    $method = $_SERVER['REQUEST_METHOD'];

    if ($method === 'GET' && $path === '/health') {
        $pdo->query('SELECT 1');
        if ($redis->ping() !== true) {
            throw new RuntimeException('Redis health check failed');
        }
        respond(200, ['status' => 'ok']);
    }

    if (!preg_match('#^/products/([1-9][0-9]*)$#', $path, $matches)) {
        respond(404, ['error' => 'not_found']);
    }
    $id = (int) $matches[1];

    if ($method === 'GET') {
        $result = $cache->get($id, function () use ($pdo, $id): array {
            $statement = $pdo->prepare(
                'SELECT id, name, price_cents, updated_at
                 FROM products WHERE id = :id'
            );
            $statement->execute(['id' => $id]);
            $product = $statement->fetch();
            if ($product === false) {
                throw new OutOfBoundsException('Product not found');
            }
            return $product;
        });

        header('X-Cache-Status: ' . $result['status']);
        respond(200, $result['value']);
    }

    if ($method === 'PUT') {
        $provided = $_SERVER['HTTP_X_ADMIN_TOKEN'] ?? '';
        $expected = getenv('ADMIN_TOKEN') ?: '';
        if ($expected === '' || !hash_equals($expected, $provided)) {
            respond(401, ['error' => 'unauthorized']);
        }

        $input = json_decode(file_get_contents('php://input'), true);
        $name = is_array($input) ? ($input['name'] ?? null) : null;
        $price = is_array($input) ? ($input['price_cents'] ?? null) : null;
        if (!is_string($name) || trim($name) === '' ||
            mb_strlen($name) > 200 || !is_int($price) || $price < 0) {
            respond(422, ['error' => 'invalid_product']);
        }

        $statement = $pdo->prepare(
            'UPDATE products
             SET name = :name, price_cents = :price,
                 updated_at = clock_timestamp()
             WHERE id = :id'
        );
        $statement->execute([
            'name' => trim($name),
            'price' => $price,
            'id' => $id,
        ]);
        if ($statement->rowCount() !== 1) {
            respond(404, ['error' => 'not_found']);
        }

        $cache->invalidateProducts();
        respond(200, ['status' => 'updated']);
    }

    respond(405, ['error' => 'method_not_allowed']);
} catch (OutOfBoundsException) {
    respond(404, ['error' => 'not_found']);
} catch (Throwable $error) {
    error_log($error::class . ': ' . $error->getMessage());
    respond(503, ['error' => 'temporarily_unavailable']);
}

Pokrenite i provjerite sustav

docker compose build
docker compose up -d
docker compose ps

curl -i http://127.0.0.1:8080/health
curl -i http://127.0.0.1:8080/products/1
curl -i http://127.0.0.1:8080/products/1

curl -i -X PUT \
  -H 'Content-Type: application/json' \
  -H 'X-Admin-Token: replace-this-before-deployment' \
  --data '{"name":"Mechanical Keyboard","price_cents":11900}' \
  http://127.0.0.1:8080/products/1

curl -i http://127.0.0.1:8080/products/1

Prvo čitanje trebalo bi prijaviti X-Cache-Status: miss, drugo hit, a čitanje nakon ažuriranja ponovno miss. Za testiranje objedinjavanja uklonite jedan generirani ključ stavke u jednokratnom okruženju i pošaljite istodobne zahtjeve. Samo jedan zahtjev trebao bi učitati bazu podataka, dok ostali prijavljuju coalesced.

Načini kvara i rubovi konzistentnosti

Učitavač premašuje trajanje zaključavanja

Drugi zahtjev može steći zaključavanje i obaviti dvostruki posao. Neka svaka operacija učitavača bude ograničena ispod trajanja zaključavanja i mjerite stvarnu repnu latenciju. Ako učitavači zahtijevaju više udaljenih poziva, proračun zaključavanja mora uključivati sve njih ili koristiti dizajn obnove vlasništva.

Redis ne uspije nakon zapisa u bazu podataka

Ažuriranje se može potvrditi dok invalidacija generacije ne uspije. Ova implementacija vraća 503, ali zapis se ne vraća unatrag, a stari unosi mogu ostati vidljivi dok ne istekne njihov fizički TTL. PUT se prirodno može ponoviti s istim prikazom.

Za strože jamstvo upišite događaj invalidacije u PostgreSQL outbox unutar iste transakcije kao i ažuriranje proizvoda. Radnik ga može objaviti u Redis uz isporuku barem jednom, kratke transakcije preuzimanja, zakupe s istekom i idempotentno napredovanje generacije. To dodaje mehanizme, ali zatvara jaz dvostrukog zapisa između baze podataka i predmemorije.

Redis doseže ograničenje memorije

Uz noeviction, zapisi u predmemoriju ne uspijevaju umjesto da se stanje generacije tiho odbaci. Postavite upozorenja za odbijene naredbe i raspoloživu memoriju. Pravila nasumičnog izbacivanja osobito su opasna kada ispravnost ovisi o koordinacijskim ključevima.

Popularan ključ istječe

Istek svježine ne uklanja unos. Jedan zahtjev ga osvježava dok drugi pozivatelji primaju zastarjele podatke, pretvarajući nagli skok opterećenja baze podataka u jedno ograničeno osvježavanje. Hladni promašaji nemaju zastarjelu zamjenu, pa čekatelji na kraju primaju 503; to je namjerni povratni pritisak.

Sigurnost, vidljivost i performanse

Zamijenite razvojne vjerodajnice upravljanim tajnama, završite TLS na pouzdanom obrnutom proxyju, ograničite administrativnu rutu i ograničite stopu čitanja i zapisa. Nemojte javno izlagati Redis ni PostgreSQL. U implementacijama s više hostova koristite autentificirane šifrirane veze i mrežna pravila umjesto oslanjanja na DNS izolaciju spremnika.

Bilježite status predmemorije kao metriku s malim kardinalitetom: hit, miss, stale, coalesced i error. Također pratite trajanje osvježavanja, neuspjehe stjecanja zaključavanja, latenciju Redisa, latenciju PostgreSQL upita, povećanja generacije, odbijene Redis zapise i odgovore stale-on-error. Nikada ne stavljajte sirove ID-jeve proizvoda u oznake metrika; za pojedinosti po zahtjevu koristite zapisnike ili tragove.

Prilagodba performansi počinje dokazima. Dulji prozor svježine poboljšava stopu pogodaka, ali odgađa pasivna osvježavanja. Dulji prozor zastarjelosti poboljšava otpornost, ali dopušta starija čitanja tijekom kvara. Povećanje trajanja zaključavanja smanjuje dvostruke učitavače, ali napuštena zaključavanja čini dugotrajnijima. Te vrijednosti čine jedan proračun pouzdanosti i trebale bi se prilagođavati zajedno.

Kontrolni popis za implementaciju

  • Koristite nepromjenjive oznake ili sažetke slika i pokrenite sliku aplikacije kroz uobičajeno skeniranje ranjivosti.
  • Zamijenite sve ogledne vjerodajnice i držite baze podataka izvan javnih sučelja.
  • Postavite ograničenja PHP radnika prema upotrebi memorije i kapacitetu veza baze podataka.
  • Provjerite ostaju li vremenska ograničenja naredbi i zaključavanja baze podataka ispod proračuna zaključavanja osvježavanja.
  • Postavite upozorenja za pritisak memorije Redisa, odbijene zapise, neuspjehe osvježavanja i povećane odgovore sa zastarjelim podacima.
  • Testirajte tople pogotke, hladne istodobne promašaje, zastarjelu zamjenu, prekid Redisa, vremensko ograničenje baze podataka i invalidaciju nakon zapisa.
  • Potvrdite da stari ključevi imenskog prostora istječu i da ključ generacije preživljava ponovna pokretanja.
  • Dokumentirajte ugovor o ograničenoj zastarjelosti za korisnike API-ja.

Predmemoriranje visokih performansi prvenstveno nije stvar brzog dohvaćanja. Riječ je o odlučivanju tko smije obavljati skup posao, koliko dugo stare informacije ostaju prihvatljive i kako se kvar izlaže. Kada su te odluke kodirane kao izričita vremenska ograničenja, pravila vlasništva i granice konzistentnosti, predmemorija prestaje biti optimističan prečac i postaje pouzdan dio sustava.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.