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)
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}");
}
}
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);
}
}
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)
);
}
}
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);
}
}
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);
}
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)