Savladavanje PHP predmemoriranja: Strategije za performanse, dosljednost i sprječavanje navale zahtjeva
A cache is easy to add and surprisingly hard to operate. The difficult questions begin after the first successful GET: What happens when the key expires under heavy traffic? How quickly must writes become visible? Can eviction silently defeat invalidation? What should a request do when another request owns the refresh lock?
This tutorial builds a production-oriented PHP 8.3 service that answers those questions explicitly. It serves products from PostgreSQL, caches them in Redis, coalesces concurrent misses with a token-protected lock, serves stale data during refreshes, and invalidates an entire product namespace after writes.
Prerequisites and consistency target
You need Docker with the Compose plugin and curl. The containers use PHP 8.3 with the PECL redis extension 6.1.0, PostgreSQL 16, and Redis 7.2. The phpredis 6.1.x API is synchronous, matching Apache's request-per-worker execution model.
The service deliberately provides bounded eventual consistency rather than linearizable reads. A cached product is fresh for 15 seconds and may be served stale for another 120 seconds while one request refreshes it. Successful writes increment a namespace generation, so subsequent requests use new keys immediately. Requests already in flight may still return the previous value.
That contract favors availability and predictable database load. If every read must observe the latest committed write, an application-level cache is usually the wrong authority unless reads and writes participate in a stronger coordination protocol.
Architecture and project layout
Each cache entry contains the product plus two timestamps: fresh_until and stale_until. The cache key includes a generation such as products:3:item:1. Incrementing products:version invalidates the namespace without scanning or deleting keys.
When fresh data is unavailable, one request obtains a short Redis lock. Requests that already have stale data return it immediately; cold requests wait briefly for the owner. If no result appears, they receive a controlled overload response instead of all querying PostgreSQL.
php-cache/
├── compose.yaml
├── Dockerfile
├── docker/
│ └── apache-vhost.conf
├── db/
│ └── init.sql
├── src/
│ └── ProductCache.php
└── public/
└── index.php
Build the runtime
Create the following Compose file. Redis uses append-only persistence and a noeviction policy. That policy matters because an evicted generation key could make an old namespace current again. Memory exhaustion will reject writes instead, producing a visible failure that can be monitored.
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:
The host publishes only the web port, and only on loopback. PostgreSQL and Redis remain on the private Compose network.
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>
Create the database
The schema is intentionally small, but the update path still uses row locking and bounded database operations. Initialization scripts run only when PostgreSQL creates an empty data volume.
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);
Implement stale-while-revalidate locking
The lock value is a random ownership token. Releasing it with a plain DEL would be unsafe: if the lock expired and another request acquired it, the original owner could delete the replacement. The Lua script compares the token and deletes atomically.
<?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;
}
}
}
The lock lasts two seconds, while the PostgreSQL statement timeout below is one second. That ordering is intentional: business work should finish or fail before its lease expires. The cold-miss wait is shorter than the lock, preventing each waiter from becoming a second loader.
Expose read, write, and health endpoints
The front controller configures separate Redis connection and read timeouts. A connection timeout does not limit subsequent reads, just as PostgreSQL's connect_timeout does not limit queries. PostgreSQL therefore also receives session-level statement and lock timeouts.
<?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']);
}
Start and verify the system
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
The first read should report X-Cache-Status: miss, the second hit, and the read after the update another miss. To test coalescing, remove one generated item key in a disposable environment and issue concurrent requests. Only one request should load the database while the others report coalesced.
Failure modes and consistency edges
A loader exceeds the lock lifetime
Another request may acquire the lock and perform duplicate work. Keep every loader operation bounded below the lock duration, and measure actual tail latency. If loaders require multiple remote calls, the lock budget must include all of them or use an ownership-renewal design.
Redis fails after a database write
The update may commit while generation invalidation fails. This implementation returns 503, but the write is not rolled back, and old entries can remain visible until their physical TTL expires. The PUT is naturally repeatable with the same representation.
For a stricter guarantee, write an invalidation event to a PostgreSQL outbox in the same transaction as the product update. A worker can publish it to Redis with at-least-once delivery, short claim transactions, expiring leases, and idempotent generation advancement. That adds machinery but closes the database-to-cache dual-write gap.
Redis reaches its memory limit
With noeviction, cache writes fail instead of silently discarding generation state. Alert on rejected commands and memory headroom. Random eviction policies are especially dangerous when correctness depends on coordination keys.
A popular key expires
Freshness expiration does not remove the entry. One request refreshes it while other callers receive stale data, converting a sharp database spike into one bounded refresh. Cold misses have no stale fallback, so waiters eventually receive 503; that is deliberate backpressure.
Security, observability, and performance
Replace development credentials with managed secrets, terminate TLS at a trusted reverse proxy, restrict the administrative route, and rate-limit both reads and writes. Do not expose Redis or PostgreSQL publicly. In multi-host deployments, use authenticated encrypted connections and network policy rather than relying on container DNS isolation.
Record cache status as a low-cardinality metric: hit, miss, stale, coalesced, and error. Also track refresh duration, lock acquisition failures, Redis latency, PostgreSQL query latency, generation increments, rejected Redis writes, and stale-on-error responses. Never place raw product IDs in metric labels; use logs or traces for per-request detail.
Performance tuning begins with evidence. A longer fresh window improves hit rate but delays passive refreshes. A longer stale window improves resilience but permits older reads during failure. Increasing lock duration reduces duplicate loaders but makes abandoned locks linger. These values form one reliability budget and should be tuned together.
Deployment checklist
- Use immutable image tags or digests and run the application image through normal vulnerability scanning.
- Replace all sample credentials and keep databases off public interfaces.
- Set PHP worker limits according to memory usage and database connection capacity.
- Verify database statement and lock timeouts remain below the refresh-lock budget.
- Alert on Redis memory pressure, rejected writes, refresh failures, and elevated stale responses.
- Test warm hits, cold concurrent misses, stale fallback, Redis interruption, database timeout, and post-write invalidation.
- Confirm old namespace keys expire and the generation key survives restarts.
- Document the bounded-staleness contract for API consumers.
High-performance caching is not primarily about making retrieval fast. It is about deciding who may perform expensive work, how long old information remains acceptable, and how failure is exposed. Once those decisions are encoded as explicit timeouts, ownership rules, and consistency boundaries, the cache stops being an optimistic shortcut and becomes a dependable part of the system.