DEV Community

ahmet gedik
ahmet gedik

Posted on

Redis Caching Strategies for Video Content Platforms

Trending video data is read-heavy: thousands of page views for every one database write. TopVideoHub covers 9 Asia-Pacific regions, and each region's trending list changes only every few hours. Redis turns that pattern into a performance advantage.

This article covers the cache layers, Redis data structures, and invalidation strategies we use.

Why Redis on Top of PHP File Cache?

The current stack uses a PHP file-based cache (data/pagecache/*.html). That works fine for a single server. Redis becomes valuable when:

  • Multiple PHP-FPM workers need shared cache state
  • You want sub-millisecond reads with O(1) lookup
  • You need pub/sub for cache invalidation events
  • You want TTL management without file system overhead

The architecture targets a Redis instance as a middle tier between PHP and SQLite.

Data Structure Choices

Trending Video Lists → Redis Lists

<?php

class VideoCache
{
    private Redis $redis;
    private const TTL_TRENDING = 10800; // 3 hours
    private const MAX_LIST_SIZE = 50;

    public function setTrending(string $region, array $videos): void
    {
        $key = "trending:{$region}";
        $pipe = $this->redis->pipeline();

        // Delete existing list
        $pipe->del($key);

        // Push all videos as JSON strings
        foreach ($videos as $video) {
            $pipe->rPush($key, json_encode($video));
        }

        // Set expiry
        $pipe->expire($key, self::TTL_TRENDING);
        $pipe->execute();
    }

    public function getTrending(string $region, int $limit = 20): ?array
    {
        $key = "trending:{$region}";

        // LRANGE is O(S+N) — fast for small lists
        $items = $this->redis->lRange($key, 0, $limit - 1);
        if (empty($items)) {
            return null; // Cache miss
        }

        return array_map('json_decode', $items, array_fill(0, count($items), true));
    }
}
Enter fullscreen mode Exit fullscreen mode

Category Metadata → Redis Hashes

<?php

class CategoryCache
{
    private const TTL_CATEGORIES = 86400; // 24 hours

    public function setAll(array $categories): void
    {
        $pipe = $this->redis->pipeline();

        foreach ($categories as $cat) {
            // Hash per category — individual field reads are O(1)
            $key = "category:{$cat['slug']}";
            $pipe->hMSet($key, [
                'id'          => $cat['id'],
                'name'        => $cat['name'],
                'slug'        => $cat['slug'],
                'video_count' => $cat['video_count'],
                'updated_at'  => $cat['updated_at'],
            ]);
            $pipe->expire($key, self::TTL_CATEGORIES);
        }

        // Also store the sorted category index
        $pipe->del('categories:index');
        foreach ($categories as $rank => $cat) {
            $pipe->zAdd('categories:index', $cat['video_count'], $cat['slug']);
        }
        $pipe->expire('categories:index', self::TTL_CATEGORIES);

        $pipe->execute();
    }

    public function getTopN(int $n = 15): array
    {
        // ZREVRANGE: top N by video count
        $slugs = $this->redis->zRevRange('categories:index', 0, $n - 1);
        if (empty($slugs)) {
            return [];
        }

        $pipe = $this->redis->pipeline();
        foreach ($slugs as $slug) {
            $pipe->hGetAll("category:{$slug}");
        }
        return $pipe->execute();
    }
}
Enter fullscreen mode Exit fullscreen mode

Search Results → Redis Strings with Prefix Keys

<?php

class SearchCache
{
    private const TTL_SEARCH = 21600; // 6 hours

    public function get(string $query, string $region): ?array
    {
        $key = $this->key($query, $region);
        $data = $this->redis->get($key);
        return $data ? json_decode($data, true) : null;
    }

    public function set(string $query, string $region, array $results): void
    {
        $key = $this->key($query, $region);
        $this->redis->setEx($key, self::TTL_SEARCH, json_encode($results));
    }

    private function key(string $query, string $region): string
    {
        // Normalize: lowercase, trim, collapse spaces
        $normalized = strtolower(trim(preg_replace('/\s+/', ' ', $query)));
        return 'search:' . $region . ':' . md5($normalized);
    }

    public function invalidateRegion(string $region): int
    {
        // Use SCAN to find and delete all search keys for a region
        $deleted = 0;
        $cursor = null;
        do {
            [$cursor, $keys] = $this->redis->scan(
                $cursor,
                ['match' => "search:{$region}:*", 'count' => 100]
            );
            if ($keys) {
                $deleted += $this->redis->del($keys);
            }
        } while ($cursor !== 0);

        return $deleted;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cache Invalidation on Cron Fetch

The cron job runs every 4 hours for TopVideoHub. After fetching, invalidation is surgical, not a full flush:

<?php

class CronFetcher
{
    public function run(): void
    {
        foreach (FETCH_REGIONS as $region) {
            $videos = $this->fetchTrending($region);

            if ($this->hasChanged($region, $videos)) {
                // Update SQLite
                $this->db->upsertVideos($videos, $region);

                // Invalidate only this region's cache
                $this->videoCache->invalidateRegion($region);
                $this->searchCache->invalidateRegion($region);

                // Repopulate immediately so next request hits cache
                $this->videoCache->setTrending($region, $videos);

                echo "[{$region}] refreshed: " . count($videos) . " videos\n";
            } else {
                // Extend TTL without repopulating
                $this->videoCache->touchTrending($region);
                echo "[{$region}] unchanged, TTL extended\n";
            }
        }

        // Category counts change after any region update
        $this->categoryCache->rebuild();
    }

    private function hasChanged(string $region, array $newVideos): bool
    {
        $existing = $this->videoCache->getTrending($region, 1);
        if ($existing === null) {
            return true; // Cache miss → assume changed
        }
        return $existing[0]['id'] !== $newVideos[0]['id'];
    }
}
Enter fullscreen mode Exit fullscreen mode

Regional TTL Strategy

Different Asia-Pacific regions update at different rates. Tailor TTLs accordingly:

<?php

const REGION_TTL = [
    // High-frequency regions (major markets)
    'JP' => 7200,   // 2h — Japan trends fast
    'KR' => 7200,   // 2h — Korea K-pop events
    'US' => 10800,  // 3h — standard
    'GB' => 10800,  // 3h — standard

    // Medium-frequency regions
    'TW' => 10800,  // 3h
    'SG' => 14400,  // 4h
    'HK' => 14400,  // 4h

    // Lower-frequency regions
    'VN' => 21600,  // 6h
    'TH' => 21600,  // 6h
];
Enter fullscreen mode Exit fullscreen mode

Connection Pooling

<?php

class RedisPool
{
    private static ?Redis $instance = null;

    public static function get(): Redis
    {
        if (self::$instance === null) {
            $redis = new Redis();
            $redis->pconnect(
                host: getenv('REDIS_HOST') ?: '127.0.0.1',
                port: (int)(getenv('REDIS_PORT') ?: 6379),
                timeout: 1.0,
            );
            $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);
            $redis->setOption(Redis::OPT_PREFIX, 'tvh:');
            self::$instance = $redis;
        }
        return self::$instance;
    }
}
Enter fullscreen mode Exit fullscreen mode

Memory Sizing

For 9 regions × 50 videos, with each JSON video object ~800 bytes:

  • Trending lists: 9 × 50 × 800B ≈ 360KB
  • Category hashes: ~5KB
  • Search cache (peak): ~500 entries × 3KB ≈ 1.5MB

A 32MB Redis instance handles TopVideoHub traffic comfortably with room to grow.

Read-Through Pattern

<?php

function getTrending(string $region): array
{
    // 1. Redis (sub-millisecond)
    $cached = RedisPool::get()->lRange("tvh:trending:{$region}", 0, 19);
    if (!empty($cached)) {
        return array_map(fn($v) => json_decode($v, true), $cached);
    }

    // 2. SQLite (single-digit milliseconds with WAL mode)
    $videos = Database::getInstance()->getTrending($region, 20);

    // 3. Populate Redis for next request
    if (!empty($videos)) {
        $pipe = RedisPool::get()->pipeline();
        $pipe->del("tvh:trending:{$region}");
        foreach ($videos as $v) {
            $pipe->rPush("tvh:trending:{$region}", json_encode($v));
        }
        $pipe->expire("tvh:trending:{$region}", REGION_TTL[$region] ?? 10800);
        $pipe->execute();
    }

    return $videos;
}
Enter fullscreen mode Exit fullscreen mode

This three-layer approach keeps TopVideoHub responsive for K-pop trend spikes from Korea and gaming surges from Japan without hammering SQLite on every request.


This is part of the "Building TopVideoHub" series, documenting the architecture behind a video discovery platform covering 9 Asia-Pacific regions.

Top comments (0)