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.