DEV Community

ahmet gedik
ahmet gedik

Posted on

Redis Caching Strategies for Video Content Platforms

TrendVidStream serves trending video content from 8 regions: US, GB, CH, DK, AE, BE, CZ, FI. The Nordic and Central European regions trend quietly, while UAE can spike hard when regional events drive traffic. A flat TTL for all regions wastes quota on quiet regions and serves stale data to spiking ones.

Redis lets us handle that heterogeneity cleanly.

Current Stack vs Redis

The platform currently uses a 3-tier PHP file cache. Redis fits in as the middle tier:

Request
  → Redis (sub-millisecond, in-memory)
  → PHP file cache (milliseconds, disk)
  → SQLite (single-digit ms, WAL mode)
Enter fullscreen mode Exit fullscreen mode

For a multi-worker PHP-FPM setup, Redis solves the shared-state problem: file-based caches are per-server. Redis is shared across all workers automatically.

Trending Lists: Redis Lists

<?php

class TrendingCache
{
    private Redis $r;

    // Region-specific TTLs — Nordic/Central EU trends slowly,
    // UAE and GB can spike fast
    private const REGION_TTL = [
        'AE' => 5400,   // 1.5h — UAE news/events spike
        'GB' => 7200,   // 2h
        'US' => 7200,   // 2h
        'CH' => 10800,  // 3h — stable Swiss market
        'DK' => 10800,  // 3h — stable Nordic
        'BE' => 10800,  // 3h
        'CZ' => 14400,  // 4h — Central EU slower cycle
        'FI' => 14400,  // 4h — Finland quietest
    ];

    public function set(string $region, array $videos): void
    {
        $key = "tvs:trending:{$region}";
        $ttl = self::REGION_TTL[$region] ?? 10800;

        $pipe = $this->r->pipeline();
        $pipe->del($key);

        foreach ($videos as $video) {
            $pipe->rPush($key, json_encode([
                'id'        => $video['video_id'],
                'title'     => $video['title'],
                'thumb'     => $video['thumbnail'],
                'channel'   => $video['channel_title'],
                'views'     => $video['view_count'],
                'category'  => $video['category_id'],
            ]));
        }

        $pipe->expire($key, $ttl);
        $pipe->execute();
    }

    public function get(string $region, int $page = 1, int $perPage = 20): ?array
    {
        $key   = "tvs:trending:{$region}";
        $start = ($page - 1) * $perPage;
        $end   = $start + $perPage - 1;

        $items = $this->r->lRange($key, $start, $end);
        if (empty($items)) {
            return null;
        }

        return array_map(
            fn($v) => json_decode($v, true),
            $items
        );
    }

    public function count(string $region): int
    {
        return (int)$this->r->lLen("tvs:trending:{$region}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Sorted Sets for Cross-Region Trending

TrendVidStream's home page surfaces videos trending in the most regions simultaneously — a pan-European trending feed:

<?php

class CrossRegionCache
{
    /**
     * Score = number of regions a video is trending in.
     * ZUNIONSTORES the region lists into a single cross-region feed.
     */
    public function rebuild(array $regions): void
    {
        $tempKeys = [];

        foreach ($regions as $region) {
            $srcKey  = "tvs:trending:{$region}";
            $tempKey = "tvs:temp:scores:{$region}";

            // Fetch all video IDs from this region's list
            $videos = $this->r->lRange($srcKey, 0, -1);
            if (empty($videos)) {
                continue;
            }

            $pipe = $this->r->pipeline();
            $pipe->del($tempKey);
            foreach ($videos as $raw) {
                $v = json_decode($raw, true);
                $pipe->zAdd($tempKey, 1, $v['id']);
            }
            $pipe->execute();

            $tempKeys[] = $tempKey;
        }

        if (empty($tempKeys)) {
            return;
        }

        // Union all region scores — video trending in 4 regions scores 4
        $this->r->zUnionStore(
            'tvs:global:trending',
            $tempKeys,
            array_fill(0, count($tempKeys), 1),
            'SUM'
        );
        $this->r->expire('tvs:global:trending', 10800);

        // Cleanup temp keys
        $this->r->del($tempKeys);
    }

    public function getTopGlobal(int $n = 20): array
    {
        // Highest score = most regions trending
        return $this->r->zRevRange('tvs:global:trending', 0, $n - 1, true);
    }
}
Enter fullscreen mode Exit fullscreen mode

Hash Sets for Video Metadata

<?php

class VideoMetaCache
{
    private const TTL = 21600; // 6h — matches watch page HTTP cache

    public function set(string $videoId, array $meta): void
    {
        $key = "tvs:video:{$videoId}";
        $this->r->hMSet($key, [
            'title'       => $meta['title'],
            'description' => mb_substr($meta['description'], 0, 500),
            'channel'     => $meta['channel_title'],
            'thumb_high'  => $meta['thumbnail_high'],
            'view_count'  => $meta['view_count'],
            'like_count'  => $meta['like_count'],
            'category_id' => $meta['category_id'],
            'region'      => $meta['region'],
            'cached_at'   => time(),
        ]);
        $this->r->expire($key, self::TTL);
    }

    public function get(string $videoId): ?array
    {
        $data = $this->r->hGetAll("tvs:video:{$videoId}");
        return empty($data) ? null : $data;
    }

    public function mGet(array $videoIds): array
    {
        $pipe = $this->r->pipeline();
        foreach ($videoIds as $id) {
            $pipe->hGetAll("tvs:video:{$id}");
        }
        $results = $pipe->execute();

        return array_combine(
            $videoIds,
            array_map(fn($r) => empty($r) ? null : $r, $results)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Cache Invalidation Patterns

Write-Through on Cron Fetch

<?php

class CronFetcher
{
    public function fetchRegion(string $region): void
    {
        $videos = $this->youtube->fetchTrending($region);

        // 1. Persist to SQLite
        $this->db->upsertVideos($videos, $region);

        // 2. Write-through: update Redis immediately
        $this->trendingCache->set($region, $videos);

        // 3. Invalidate search results for this region
        $this->invalidateSearchCache($region);

        // 4. Invalidate PHP page cache for affected pages
        $this->clearPageCache($region);
    }

    private function invalidateSearchCache(string $region): void
    {
        // Use SCAN — never KEYS in production
        $cursor = null;
        do {
            [$cursor, $keys] = $this->r->scan(
                $cursor,
                ['match' => "tvs:search:{$region}:*", 'count' => 200]
            );
            if ($keys) {
                $this->r->del($keys);
            }
        } while ($cursor !== 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Stale-While-Revalidate in PHP

<?php

function getTrendingWithSWR(string $region, TrendingCache $cache, Database $db): array
{
    $data = $cache->get($region);

    if ($data !== null) {
        $ttl = $cache->getTTL($region);
        // If less than 20% of TTL remains, trigger async refresh
        if ($ttl < ($cache::REGION_TTL[$region] ?? 10800) * 0.2) {
            // Schedule background refresh without blocking the request
            if (function_exists('fastcgi_finish_request')) {
                register_shutdown_function(function() use ($region, $db, $cache) {
                    fastcgi_finish_request();
                    $fresh = $db->getTrending($region, 50);
                    $cache->set($region, $fresh);
                });
            }
        }
        return $data;
    }

    // Cache miss — fetch from SQLite
    $videos = $db->getTrending($region, 50);
    $cache->set($region, $videos);
    return array_slice($videos, 0, 20);
}
Enter fullscreen mode Exit fullscreen mode

Memory Budget

For 8 regions at TrendVidStream:

Key type Count Avg size Total
Trending lists 8 × 50 items ~600B/item ~240KB
Video metadata hashes ~400 videos ~900B ~360KB
Search results ~300 cached ~2KB ~600KB
Global trending sorted set 1 ~15KB ~15KB
Total ~1.2MB

A 32MB Redis instance comfortably serves TrendVidStream with room for 25× growth.


This is part of the "Building TrendVidStream" series, documenting the architecture behind a global video directory covering Nordic, Middle Eastern, and Central European regions.

Top comments (0)