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));
}
}
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();
}
}
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;
}
}
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'];
}
}
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
];
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;
}
}
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;
}
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)