Туториали

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

Совладување со PHP кеширање: Стратегии за перформанси, конзистентност и спречување на кеш-напливи

Кешот лесно се додава, но изненадувачки е тешко да се управува со него. Тешките прашања почнуваат по првиот успешен GET: Што се случува кога клучот истекува при голем сообраќај? Колку брзо записите мора да станат видливи? Може ли исфрлањето тивко да ја поништи инвалидизацијата? Што треба да направи барањето кога друго барање ја поседува бравата за освежување?

Овој туторијал гради PHP 8.3 услуга наменета за продукциска употреба што експлицитно одговара на тие прашања. Таа опслужува производи од PostgreSQL, ги кешира во Redis, обединува истовремени промашувања со брава заштитена со токен, сервира застарени податоци за време на освежувањата и инвалидизира цел именски простор на производи по записи.

Предуслови и цел за конзистентност

Потребни ви се Docker со приклучокот Compose и curl. Контејнерите користат PHP 8.3 со PECL екстензијата redis 6.1.0, PostgreSQL 16 и Redis 7.2. API-то phpredis 6.1.x е синхроно, што одговара на моделот на Apache со едно барање по работник.

Услугата намерно обезбедува ограничена евентуална конзистентност наместо линеаризирани читања. Кеширан производ е свеж 15 секунди и може да се сервира застарен уште 120 секунди додека едно барање го освежува. Успешните записи ја зголемуваат генерацијата на именскиот простор, па следните барања веднаш користат нови клучеви. Барањата што веќе се во тек сè уште може да ја вратат претходната вредност.

Тој договор дава предност на достапноста и предвидливото оптоварување на базата на податоци. Ако секое читање мора да го види последниот потврден запис, кеш на ниво на апликација обично е погрешен авторитет, освен ако читањата и записите учествуваат во посилен протокол за координација.

Архитектура и распоред на проектот

Секој кеш-запис го содржи производот плус две временски ознаки: fresh_until и stale_until. Кеш-клучот содржи генерација како products:3:item:1. Зголемувањето на products:version го инвалидизира именскиот простор без скенирање или бришење клучеви.

Кога свежите податоци не се достапни, едно барање добива кратка Redis брава. Барањата што веќе имаат застарени податоци ги враќаат веднаш; барањата без податоци кратко чекаат на сопственикот. Ако не се појави резултат, тие добиваат контролиран одговор за преоптоварување наместо сите да го прашуваат PostgreSQL.

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

Изградете го извршното опкружување

Создадете ја следнава Compose-датотека. Redis користи append-only перзистентност и политика noeviction. Таа политика е важна затоа што исфрлен генерациски клуч може повторно да направи стар именски простор тековен. Исцрпувањето на меморијата наместо тоа ќе ги отфрли записите, создавајќи видлив неуспех што може да се надгледува.

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:

Домаќинот го објавува само веб-портот, и тоа само на loopback. PostgreSQL и Redis остануваат на приватната Compose-мрежа.

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>

Создадете ја базата на податоци

Шемата намерно е мала, но патеката за ажурирање сепак користи заклучување на редови и ограничени операции со базата на податоци. Скриптите за иницијализација се извршуваат само кога PostgreSQL создава празен волумен со податоци.

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);

Имплементирајте заклучување stale-while-revalidate

Вредноста на бравата е случаен токен за сопственост. Ослободувањето со обичен DEL би било небезбедно: ако бравата истече и друго барање ја добие, првобитниот сопственик би можел да ја избрише замената. Lua-скриптата го споредува токенот и атомски ја брише.

<?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;
        }
    }
}

Бравата трае две секунди, додека временското ограничување за PostgreSQL-изразот подолу е една секунда. Тој редослед е намерен: деловната работа треба да заврши или да не успее пред да истече нејзиниот закуп. Чекањето при промашување без податоци е пократко од бравата, спречувајќи секој што чека да стане втор вчитувач.

Изложете крајни точки за читање, запишување и здравствена состојба

Предниот контролер конфигурира одделни временски ограничувања за Redis-врска и читање. Временското ограничување за врска не ги ограничува последователните читања, исто како што connect_timeout на PostgreSQL не ги ограничува барањата. Затоа PostgreSQL добива и временски ограничувања за изрази и брави на ниво на сесија.

<?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']);
}

Стартувајте го и проверете го системот

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

Првото читање треба да прикаже X-Cache-Status: miss, второто hit, а читањето по ажурирањето повторно miss. За да го тестирате обединувањето, отстранете еден генериран клуч за ставка во средина за еднократна употреба и испратете истовремени барања. Само едно барање треба да ја вчита базата на податоци, додека другите пријавуваат coalesced.

Режими на неуспех и граници на конзистентност

Вчитувачот го надминува животниот век на бравата

Друго барање може да ја добие бравата и да изврши дупликат работа. Одржувајте ја секоја операција на вчитувачот ограничена под траењето на бравата и мерете ја реалната латентност на опашката. Ако вчитувачите бараат повеќе далечински повици, буџетот за бравата мора да ги вклучува сите нив или да користи дизајн за обновување на сопственоста.

Redis откажува по запис во базата на податоци

Ажурирањето може да се потврди додека генерациската инвалидизација не успее. Оваа имплементација враќа 503, но записот не се поништува и старите записи може да останат видливи додека не истече нивниот физички TTL. PUT природно може да се повтори со истата репрезентација.

За построга гаранција, запишете настан за инвалидизација во PostgreSQL outbox во истата трансакција како ажурирањето на производот. Работник може да го објави во Redis со испорака барем еднаш, кратки трансакции за преземање, закупи со истекување и идемпотентно напредување на генерацијата. Тоа додава механизми, но го затвора јазот на двојниот запис од базата на податоци до кешот.

Redis ја достигнува границата на меморијата

Со noeviction, кеш-записите не успеваат наместо тивко да ја отфрлат генерациската состојба. Поставете предупредувања за отфрлени команди и расположлива меморија. Политиките за случајно исфрлање се особено опасни кога исправноста зависи од клучеви за координација.

Популарен клуч истекува

Истекувањето на свежината не го отстранува записот. Едно барање го освежува додека другите повикувачи добиваат застарени податоци, претворајќи остар скок на базата на податоци во едно ограничено освежување. Промашувањата без податоци немаат застарена резервна опција, па оние што чекаат на крај добиваат 503; тоа е намерен повратен притисок.

Безбедност, набљудливост и перформанси

Заменете ги развојните акредитиви со управувани тајни, завршете TLS на доверлив обратен прокси, ограничете ја административната рута и ограничете ја стапката и на читања и на записи. Не изложувајте ги Redis или PostgreSQL јавно. Во распоредувања со повеќе домаќини, користете автентицирани шифрирани врски и мрежна политика наместо да се потпирате на изолација со DNS на контејнери.

Запишувајте го статусот на кешот како метрика со мала кардиналност: hit, miss, stale, coalesced и error. Следете и траење на освежувањето, неуспеси при добивање брава, латентност на Redis, латентност на PostgreSQL-барања, зголемувања на генерацијата, отфрлени Redis-записи и одговори stale-on-error. Никогаш не ставајте сурови ID на производи во ознаки на метрики; користете логови или траги за детали по барање.

Подесувањето на перформансите почнува со докази. Подолг прозорец на свежина ја подобрува стапката на погодоци, но ги одложува пасивните освежувања. Подолг прозорец на застареност ја подобрува отпорноста, но дозволува постари читања при неуспех. Зголемувањето на траењето на бравата ги намалува дупликат-вчитувачите, но ги задржува напуштените брави подолго. Овие вредности сочинуваат еден буџет за доверливост и треба да се подесуваат заедно.

Контролна листа за распоредување

  • Користете непроменливи ознаки или дигести на слики и пуштете ја сликата на апликацијата низ вообичаеното скенирање за ранливости.
  • Заменете ги сите примерни акредитиви и држете ги базите на податоци надвор од јавни интерфејси.
  • Поставете PHP-ограничувања за работници според употребата на меморија и капацитетот на врски со базата на податоци.
  • Проверете дали временските ограничувања за изрази и брави во базата на податоци остануваат под буџетот на бравата за освежување.
  • Поставете предупредувања за мемориски притисок во Redis, отфрлени записи, неуспеси на освежување и покачени застарени одговори.
  • Тестирајте топли погодоци, истовремени промашувања без податоци, застарена резервна опција, прекин на Redis, истекување на базата на податоци и инвалидизација по запис.
  • Потврдете дека старите клучеви на именски простор истекуваат и дека генерацискиот клуч преживува рестартирања.
  • Документирајте го договорот за ограничена застареност за потрошувачите на API.

Кеширањето со високи перформанси првенствено не е за тоа пребарувањето да биде брзо. Станува збор за одлучување кој смее да извршува скапа работа, колку долго старите информации остануваат прифатливи и како се изложува неуспехот. Штом тие одлуки се кодирани како експлицитни временски ограничувања, правила за сопственост и граници на конзистентност, кешот престанува да биде оптимистичка кратенка и станува сигурен дел од системот.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.