Caching Database Queries in PHP With Redis: Speed Up Reads 300x

Santaji GadePHPDevelopment14 hours ago11 Views

caching database queries

A practical guide to caching database queries in PHP with Redis, covering cache aside, TTL strategy, invalidation on write, and stampede protection.

Development PHP Caching Redis

A database query that takes fifty milliseconds feels instant once. It feels very different once a thousand visitors trigger it every minute. Caching database queries in PHP with Redis is how that same query starts answering in under a millisecond on every repeat.

01

Why Caching Database Queries in PHP With Redis Cuts Read Latency

Every database query pays for a network round trip, a query plan, and disk or buffer pool access, even for data that barely changes.

Redis keeps that same data in memory on a server built for one job: answering key lookups as fast as the network allows. Memcached is the other well known option here, though Redis's richer data structures make it the more common default for new projects today.

The pattern below, cache aside, is the simplest way to put that speed in front of an existing MySQL or PostgreSQL query without rewriting how the application talks to its database.

02

Connecting to Redis and Deciding What Belongs in Cache

The connection setup is the same phpredis extension and pattern covered earlier in this series for rate limiting, just pointed at a different set of keys. Predis is a pure PHP alternative worth knowing about when the phpredis C extension cannot be installed on a shared host.

Not every query is worth caching. A value read often and written rarely, like a user profile or a product listing, is a good candidate. A value that changes on every request is not.

Redis's own reference for SETEX documents exactly how the expiry argument used throughout this article behaves at the command level.

redis_connect.php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setOption(Redis::OPT_PREFIX, 'cache:');
03

Implementing the Cache Aside Pattern

Cache aside checks Redis first. On a miss, it runs the real query, stores the result, and returns it. On a hit, the database is never touched.

get_user_cached.php
function get_user_cached(Redis $redis, PDO $pdo, int $userId): array {
    $key = "user:$userId";
    $cached = $redis->get($key);

    if ($cached !== false) {
        return json_decode($cached, true);
    }

    $stmt = $pdo->prepare('SELECT id, name, plan FROM users WHERE id = ?');
    $stmt->execute([$userId]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    $redis->setex($key, 300, json_encode($user));
    return $user;
}

PHP's own PDO prepared statements documentation covers the parameter binding used above, which stays exactly the same whether or not a cache sits in front of it.

Running this twice in a row, once cold and once warm, against a query deliberately slowed down to simulate real database latency, shows exactly what that cache hit is worth.

Real terminal output of bench.php showing a 49.18 millisecond cold cache read versus a 0.16 millisecond warm cache read, a 307.4x speedup

Actual timing output from running the cache aside function cold and then warm against the same key.

That gap only grows under real traffic, since every one of those repeat reads that used to hit the database now costs Redis a fraction of a millisecond instead.

StrategyWhen It Writes to CacheBest For
Cache asideOn a cache miss, lazilyRead heavy data with occasional writes
Read throughAutomatically inside the cache layerApps that want caching hidden from query code
Write throughOn every write, immediatelyData that must never serve stale on a read
04

Setting TTLs Based on How Stale Data Can Be

A single global expiry treats a rarely changing setting the same as a price that updates hourly, which is rarely the right tradeoff for either one.

ttl_by_type.php
$ttlSeconds = [
    'user_profile' => 3600,
    'product_price' => 60,
    'site_setting'  => 86400,
];

$redis->setex($key, $ttlSeconds[$dataType], $payload);

A shorter TTL means more database reads but fresher data. A longer TTL means fewer reads but a longer window where a change is not yet visible. AWS's own caching best practices guide walks through this exact tradeoff in more depth, independent of which cache server is running behind it.

05

Invalidating Cache the Moment Data Changes

A TTL alone means every update waits out the clock before the cache catches up. Explicit invalidation on write removes that wait entirely.

update_user_plan.php
function update_user_plan(Redis $redis, PDO $pdo, int $userId, string $newPlan): void {
    $stmt = $pdo->prepare('UPDATE users SET plan = ? WHERE id = ?');
    $stmt->execute([$newPlan, $userId]);

    $redis->del("user:$userId");
}

The next read after that delete finds nothing in Redis, falls back to the database automatically, and quietly repopulates the cache with the fresh value.

Real terminal output showing a cache key present after the first read, removed immediately after a plan update, and automatically repopulated after the next read

Actual output confirming a write invalidates the stale key and the following read repopulates it.

06

Preventing Cache Stampede on Popular Keys

When a hot key expires, every request arriving in that same instant can miss the cache together and hit the database all at once.

A short lived lock lets one request rebuild the value while the rest wait briefly and then read the freshly cached result instead of repeating the same query.

rebuild_with_lock.php
$lockKey = "lock:$key";

if ($redis->set($lockKey, 1, ['nx', 'ex' => 5])) {
    $fresh = run_expensive_query();
    $redis->setex($key, 300, json_encode($fresh));
    $redis->del($lockKey);
} else {
    usleep(50000);
    return get_user_cached($redis, $pdo, $userId);
}

Only the request that wins the lock ever reaches the database. Every other request briefly waits instead of piling on with a duplicate query. Cloudflare's overview of caching covers this same stampede problem at the edge network layer, where the same lock and wait idea shows up under a different name.

  • Cache reads, invalidate writes: a stale read is usually cheap to fix, a stale write is not.
  • Vary TTL by data type: a setting that rarely changes can sit far longer than a price.
  • Delete on update, do not wait for expiry: explicit invalidation removes an entire class of staleness.
  • Guard hot keys with a short lock: stops a stampede from hitting the database all at once.
  • Prefix cache keys clearly: keeps cache data separate from anything else sharing the same Redis instance.
07

Measuring Whether Caching Is Actually Helping

Redis tracks its own hit and miss counts, which is the fastest way to confirm a cache is doing real work rather than sitting mostly empty.

terminal
# check the running hit ratio directly from Redis
redis-cli info stats | grep keyspace

A hit ratio that stays low after normal traffic usually means the TTL is too short, the wrong queries were chosen for caching, or invalidation is firing more often than it should.

Request Check Redis hit Serve From Cache miss Query Database Store in Redis

Frequently Asked Questions

Cache aside is the simplest starting point. Check Redis first, run the real query only on a miss, then store the result before returning it.

It depends entirely on how stale the data can safely be. A rarely changing setting can sit for a day, while a price should expire in minutes.

A TTL alone leaves a window where an update is not yet visible. Deleting the key on write removes that window immediately.

A popular key expiring lets many requests miss at once. A short lived lock lets one request rebuild it while the rest wait briefly instead of piling on.

Check whether the TTL is too short, whether the wrong queries were chosen for caching, or whether invalidation is firing more often than the data actually changes.

What We Learn Today

1

Cache aside is the simplest start

Check Redis first, query the database only on a miss.

2

Not every query deserves caching

Read heavy, rarely changing data benefits the most.

3

TTL should match data volatility

A setting and a price should never share one expiry.

4

Invalidate on write, not just on expiry

A delete on update removes stale reads immediately.

5

Stampedes need a lock

One request rebuilds a hot key while others wait briefly.

6

Hit ratio tells the real story

A low ratio after normal traffic signals a tuning problem.

Ready to Speed Up Your Slowest Endpoints?

Caching database queries in PHP with Redis pairs naturally with the rate limiting and validation work covered elsewhere in this series.

0 Votes: 0 Upvotes, 0 Downvotes (0 Points)

Leave a reply

Loading Next Post...
Search
Popular Now
Loading

Signing-in 3 seconds...

Signing-up 3 seconds...