Back to blog

Build a URL Shortener: Caching với Redis

typescriptnodejsrediscachingbackend
Build a URL Shortener: Caching với Redis

Redirect endpoint đã hoạt động. User truy cập short.ly/abc123, server query PostgreSQL, tìm thấy original URL, gửi redirect. Xong trong ~5ms. Ổn.

Giờ tưởng tượng 10,000 redirects mỗi giây. Mỗi request đều đập vào database. PostgreSQL xử lý được một lúc, nhưng mỗi lần đều tốn một network round-trip và disk read cho data gần như không bao giờ thay đổi. Cùng một short code luôn resolve về cùng một URL — đây là textbook case cho caching.

Trong bài này, chúng ta sẽ thêm Redis làm caching layer cho URL lookups và build một sliding window rate limiter để chống abuse. Kết thúc bài, redirects sẽ giảm từ ~5ms xuống dưới 1ms, và API được bảo vệ khỏi traffic spikes.

Thời gian: 1–2 giờ
Yêu cầu: Phase 4: Redirect Engine & Click Analytics

Những gì chúng ta sẽ build:
✅ Redis thêm vào Docker Compose cùng PostgreSQL
✅ Redis client với connection management và health checks
✅ Cache-aside pattern cho URL lookups
✅ Cache invalidation khi URL được update hoặc delete
✅ TTL-based expiration cho cached entries
✅ Sliding window rate limiter dùng Redis sorted sets
✅ Rate limiting middleware cho API endpoints
✅ So sánh performance: cached vs uncached redirects


Tại sao dùng Redis?

Trước khi viết code, hãy hiểu tại sao Redis là tool phù hợp:

FeaturePostgreSQLRedis
StorageDisk-based (persistent)Memory-based (fast)
Latency~1-5ms mỗi query~0.1-0.5ms mỗi query
Throughput~10K queries/sec~100K+ queries/sec
Data modelRelational tablesKey-value + data structures
Dùng choSource of truthCaching, rate limiting, sessions

Redis không thay thế PostgreSQL — nó đứng trước PostgreSQL. PostgreSQL vẫn là source of truth; Redis là fast read-through cache ngăn những database hits không cần thiết.


Thêm Redis vào Docker Compose

Chúng ta đã có PostgreSQL chạy trong Docker Compose từ bài database. Giờ thêm Redis:

# docker-compose.yml
version: '3.8'
 
services:
  postgres:
    image: postgres:16-alpine
    container_name: url-shortener-db
    environment:
      POSTGRES_USER: urlshortener
      POSTGRES_PASSWORD: urlshortener
      POSTGRES_DB: urlshortener
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
 
  redis:
    image: redis:7-alpine
    container_name: url-shortener-redis
    ports:
      - '6379:6379'
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 5s
      retries: 3
 
volumes:
  postgres_data:
  redis_data:

Giải thích các config chính:

  • redis:7-alpine — Redis stable mới nhất trên minimal image
  • --maxmemory 128mb — giới hạn memory cho development (chỉnh lại cho production)
  • --maxmemory-policy allkeys-lru — khi hết memory, evict key ít dùng nhất. Hoàn hảo cho cache — stale entries tự bị xóa
  • healthcheck — Docker monitor Redis health và restart nếu cần

Khởi động stack:

docker compose up -d
docker compose ps  # Kiểm tra cả hai services đang chạy

Test Redis hoạt động:

docker exec -it url-shortener-redis redis-cli ping
# PONG

Cập nhật Environment Variables

Thêm Redis config vào .env:

# .env
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
 
# Database
DATABASE_URL=postgresql://urlshortener:urlshortener@localhost:5432/urlshortener
 
# Redis
REDIS_URL=redis://localhost:6379
REDIS_KEY_PREFIX=urlshort:
CACHE_TTL_SECONDS=3600
RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_MAX_REQUESTS=20

Cập nhật environment config:

// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
 
export const env = {
  port: parseInt(process.env.PORT || '3000', 10),
  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
  nodeEnv: process.env.NODE_ENV || 'development',
  databaseUrl: process.env.DATABASE_URL!,
 
  // Redis
  redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
  redisKeyPrefix: process.env.REDIS_KEY_PREFIX || 'urlshort:',
  cacheTtlSeconds: parseInt(process.env.CACHE_TTL_SECONDS || '3600', 10),
 
  // Rate limiting
  rateLimitWindowSeconds: parseInt(process.env.RATE_LIMIT_WINDOW_SECONDS || '60', 10),
  rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '20', 10),
};

Redis Client Setup

Install Redis client:

npm install ioredis
npm install -D @types/ioredis

Dùng ioredis thay vì package redis chính thức vì TypeScript support tốt hơn, có sẵn cluster support, automatic reconnection, và Lua scripting helpers.

Connection Management

// src/config/redis.ts
import Redis from 'ioredis';
import { env } from './env';
 
let redisClient: Redis | null = null;
 
export function getRedisClient(): Redis {
  if (!redisClient) {
    redisClient = new Redis(env.redisUrl, {
      maxRetriesPerRequest: 3,
      retryStrategy(times: number) {
        if (times > 10) {
          console.error('Redis: max retry attempts reached, giving up');
          return null; // Ngừng retry
        }
        const delay = Math.min(times * 200, 2000);
        console.warn(`Redis: retrying connection in ${delay}ms (attempt ${times})`);
        return delay;
      },
      lazyConnect: false,
      enableReadyCheck: true,
    });
 
    redisClient.on('connect', () => {
      console.log('Redis: connected');
    });
 
    redisClient.on('ready', () => {
      console.log('Redis: ready to accept commands');
    });
 
    redisClient.on('error', (err) => {
      console.error('Redis: connection error', err.message);
    });
 
    redisClient.on('close', () => {
      console.warn('Redis: connection closed');
    });
  }
 
  return redisClient;
}
 
export async function closeRedisConnection(): Promise<void> {
  if (redisClient) {
    await redisClient.quit();
    redisClient = null;
    console.log('Redis: connection closed gracefully');
  }
}
 
export async function isRedisHealthy(): Promise<boolean> {
  try {
    const client = getRedisClient();
    const result = await client.ping();
    return result === 'PONG';
  } catch {
    return false;
  }
}

Tại sao thiết kế như vậy:

  • Singleton pattern — một connection dùng chung cho cả app. Redis connections là multiplexed, nên không cần pool như PostgreSQL
  • Retry strategy — exponential backoff tối đa 2 giây, bỏ cuộc sau 10 lần
  • Event listeners — log connection state changes để debug
  • Graceful shutdown — closeRedisConnection() ngắt kết nối sạch sẽ khi server tắt
  • Health check — isRedisHealthy() cho readiness probes

Graceful Shutdown

Cập nhật server entry point để đóng Redis khi shutdown:

// src/index.ts (thêm vào shutdown handler hiện có)
import { closeRedisConnection } from './config/redis';
 
async function gracefulShutdown() {
  console.log('Shutting down gracefully...');
  await closeRedisConnection();
  // ... đóng database, HTTP server, v.v.
  process.exit(0);
}
 
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

Cache-Aside Pattern cho URL Lookups

Cache-aside (hay "lazy-loading") là caching strategy phổ biến nhất. Application tự quản lý cache:

  1. Kiểm tra Redis xem có data không
  2. Nếu có (cache hit), trả về luôn
  3. Nếu không (cache miss), query PostgreSQL
  4. Lưu kết quả vào Redis cho lần sau
  5. Trả về data

Cache Key Design

Key design tốt rất quan trọng. Keys cần:

  • Namespaced — prefix tránh collision với app khác dùng chung Redis
  • Descriptive — dễ debug bằng redis-cli KEYS *
  • Consistent — cùng cấu trúc ở mọi nơi
// src/cache/keys.ts
import { env } from '../config/env';
 
const prefix = env.redisKeyPrefix;
 
export const CacheKeys = {
  /** Cache key cho URL record theo short code */
  url: (shortCode: string) => `${prefix}url:${shortCode}`,
 
  /** Cache key cho URL stats */
  urlStats: (shortCode: string) => `${prefix}stats:${shortCode}`,
 
  /** Rate limiter key theo IP address */
  rateLimit: (ip: string) => `${prefix}ratelimit:${ip}`,
 
  /** Rate limiter key theo IP + endpoint */
  rateLimitEndpoint: (ip: string, endpoint: string) =>
    `${prefix}ratelimit:${ip}:${endpoint}`,
};

Ví dụ keys trong Redis:

urlshort:url:abc123          → cached URL record
urlshort:stats:abc123        → cached click stats
urlshort:ratelimit:192.168.1.1  → rate limit counter

URL Cache Service

Giờ build caching layer. Service này wrap Redis operations và xử lý serialization/deserialization:

// src/cache/urlCache.ts
import { getRedisClient } from '../config/redis';
import { env } from '../config/env';
import { CacheKeys } from './keys';
 
export interface CachedUrl {
  id: string;
  shortCode: string;
  originalUrl: string;
  expiresAt: string | null;
}
 
export class UrlCacheService {
  private redis = getRedisClient();
  private ttl = env.cacheTtlSeconds;
 
  /**
   * Lấy URL từ cache theo short code.
   * Trả về null nếu cache miss hoặc entry đã expire.
   */
  async get(shortCode: string): Promise<CachedUrl | null> {
    try {
      const key = CacheKeys.url(shortCode);
      const cached = await this.redis.get(key);
 
      if (!cached) {
        return null; // Cache miss
      }
 
      const parsed: CachedUrl = JSON.parse(cached);
 
      // Kiểm tra URL đã expire chưa (application-level expiry)
      if (parsed.expiresAt && new Date(parsed.expiresAt) < new Date()) {
        await this.delete(shortCode);
        return null;
      }
 
      return parsed;
    } catch (error) {
      // Cache errors không được phá app
      console.error('Cache get error:', error);
      return null;
    }
  }
 
  /**
   * Lưu URL vào cache với TTL.
   * Nếu URL có expiration riêng, dùng TTL ngắn hơn.
   */
  async set(url: CachedUrl): Promise<void> {
    try {
      const key = CacheKeys.url(url.shortCode);
      const value = JSON.stringify(url);
 
      let effectiveTtl = this.ttl;
 
      // Nếu URL expire sớm hơn cache TTL, dùng URL's expiry
      if (url.expiresAt) {
        const urlTtl = Math.floor(
          (new Date(url.expiresAt).getTime() - Date.now()) / 1000
        );
        if (urlTtl > 0) {
          effectiveTtl = Math.min(effectiveTtl, urlTtl);
        } else {
          return; // URL đã expire, không cache
        }
      }
 
      await this.redis.setex(key, effectiveTtl, value);
    } catch (error) {
      console.error('Cache set error:', error);
      // Không throw — caching là best-effort
    }
  }
 
  /**
   * Xóa URL khỏi cache (dùng cho invalidation).
   */
  async delete(shortCode: string): Promise<void> {
    try {
      const key = CacheKeys.url(shortCode);
      await this.redis.del(key);
    } catch (error) {
      console.error('Cache delete error:', error);
    }
  }
 
  /**
   * Xóa nhiều URLs khỏi cache cùng lúc.
   */
  async deleteMany(shortCodes: string[]): Promise<void> {
    if (shortCodes.length === 0) return;
 
    try {
      const keys = shortCodes.map((code) => CacheKeys.url(code));
      await this.redis.del(...keys);
    } catch (error) {
      console.error('Cache deleteMany error:', error);
    }
  }
}

Ba quyết định design quan trọng ở đây:

  1. Errors không bao giờ lan ra ngoài — mọi method đều catch exceptions và log. Nếu Redis chết, app vẫn hoạt động (chỉ chậm hơn, đập thẳng vào PostgreSQL). Đây là cache-aside golden rule: cache là optimization, không phải dependency.

  2. Smart TTL calculation — nếu short URL expire trong 30 phút nhưng cache TTL là 1 giờ, ta cache 30 phút. Điều này tránh serve stale data cho expired URLs.

  3. Application-level expiry check — dù đã có TTL, ta vẫn double-check expiresAt khi đọc. Xử lý edge case khi URL được update sau khi cache.


Tích hợp Cache vào Redirect Flow

Giờ wire cache vào redirect service. Thay đổi chính: kiểm tra Redis trước khi đập vào PostgreSQL.

// src/services/redirectService.ts
import { UrlCacheService, CachedUrl } from '../cache/urlCache';
import { prisma } from '../config/database';
 
const urlCache = new UrlCacheService();
 
export async function resolveShortCode(shortCode: string) {
  // Bước 1: Kiểm tra cache
  const cached = await urlCache.get(shortCode);
 
  if (cached) {
    console.log(`Cache HIT for ${shortCode}`);
    return {
      id: cached.id,
      originalUrl: cached.originalUrl,
      shortCode: cached.shortCode,
      source: 'cache' as const,
    };
  }
 
  console.log(`Cache MISS for ${shortCode}`);
 
  // Bước 2: Query database
  const url = await prisma.url.findUnique({
    where: { shortCode },
    select: {
      id: true,
      shortCode: true,
      originalUrl: true,
      expiresAt: true,
      isActive: true,
    },
  });
 
  if (!url || !url.isActive) {
    return null;
  }
 
  // Kiểm tra expiration
  if (url.expiresAt && url.expiresAt < new Date()) {
    return null;
  }
 
  // Bước 3: Populate cache cho request tiếp theo
  const cacheEntry: CachedUrl = {
    id: url.id,
    shortCode: url.shortCode,
    originalUrl: url.originalUrl,
    expiresAt: url.expiresAt?.toISOString() ?? null,
  };
 
  await urlCache.set(cacheEntry);
 
  return {
    id: url.id,
    originalUrl: url.originalUrl,
    shortCode: url.shortCode,
    source: 'database' as const,
  };
}

Updated Redirect Route

// src/routes/redirectRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { resolveShortCode } from '../services/redirectService';
import { recordClick } from '../services/analyticsService';
 
const router = Router();
 
router.get('/:code', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { code } = req.params;
    const result = await resolveShortCode(code);
 
    if (!result) {
      return res.status(404).json({ error: 'Short URL not found or expired' });
    }
 
    // Record click bất đồng bộ — không block redirect
    recordClick(result.id, req).catch((err) =>
      console.error('Failed to record click:', err)
    );
 
    // Log cache performance (bỏ trong production hoặc dùng proper metrics)
    if (result.source === 'cache') {
      console.log(`Redirect ${code} served from cache`);
    }
 
    return res.redirect(302, result.originalUrl);
  } catch (error) {
    next(error);
  }
});
 
export default router;

Chú ý: recordClick() chạy bất đồng bộ với .catch() — ta không muốn analytics làm chậm redirect. User nhận redirect ngay lập tức; click được record trong background.


Cache Invalidation

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

Cache invalidation là chỗ mà hầu hết caching implementations sai. Nếu bạn update URL destination nhưng destination cũ vẫn nằm trong cache, users bị redirect sai chỗ. Đây là strategy của chúng ta:

Khi nào Invalidate

EventActionLý do
URL updatedXóa khỏi cacheRequest tiếp theo fetch data mới
URL deletedXóa khỏi cacheTránh stale redirects
URL deactivatedXóa khỏi cacheNgừng serve inactive URLs
URL expiredTự động (TTL)Redis evict expired keys

Invalidation trong URL Service

// src/services/urlService.ts
import { UrlCacheService } from '../cache/urlCache';
 
const urlCache = new UrlCacheService();
 
export async function updateUrl(
  shortCode: string,
  updates: { originalUrl?: string; expiresAt?: Date; isActive?: boolean }
) {
  // Update trong database (source of truth)
  const updated = await prisma.url.update({
    where: { shortCode },
    data: updates,
  });
 
  // Invalidate cache — request tiếp theo sẽ re-populate
  await urlCache.delete(shortCode);
 
  return updated;
}
 
export async function deleteUrl(shortCode: string) {
  await prisma.url.delete({
    where: { shortCode },
  });
 
  // Xóa khỏi cache
  await urlCache.delete(shortCode);
}
 
export async function deactivateUrl(shortCode: string) {
  await prisma.url.update({
    where: { shortCode },
    data: { isActive: false },
  });
 
  // Xóa khỏi cache — deactivated URLs không nên resolve
  await urlCache.delete(shortCode);
}

Pattern rất đơn giản: ghi vào database trước, rồi invalidate cache. Thứ tự này quan trọng. Nếu invalidate cache trước rồi database write fail, một request mới có thể re-populate cache với stale data trước khi write retry.

Tại sao Delete, không phải Update?

Bạn có thể thắc mắc: sao không update cache thay vì xóa? Hai lý do:

  1. Đơn giản — delete là một operation. Update cần đọc full record, serialize, rồi ghi. Nhiều code hơn, nhiều bug hơn.
  2. Chính xác — nếu database write thành công nhưng cache update fail, data bị inconsistent. Với delete-on-write, worst case chỉ là một cache miss (chấp nhận được).

Đây gọi là write-invalidate strategy, và là approach an toàn nhất cho hầu hết applications.


TTL Management

TTL (Time-To-Live) quyết định data cached bao lâu trong Redis. Quá ngắn thì nhiều cache misses (nhiều database hits). Quá dài thì serve stale data lâu hơn.

TTL Strategy

TTL Values khuyến nghị

Use CaseTTLLý do
URL lookups (redirect)1 giờURLs ít khi thay đổi, đọc nhiều
URL stats/analytics5 phútStats thay đổi mỗi click
Rate limit windows1 phútWindows ngắn cho limiting chính xác
User sessions24 giờCân bằng UX và security

Dynamic TTL theo Popularity

Approach nâng cao hơn: điều chỉnh TTL dựa trên popularity của URL. Hot URLs cache lâu hơn:

// src/cache/urlCache.ts (enhanced set method)
 
async setWithDynamicTtl(url: CachedUrl, clickCount: number): Promise<void> {
  try {
    const key = CacheKeys.url(url.shortCode);
    const value = JSON.stringify(url);
 
    // Nhiều clicks hơn = TTL dài hơn (tối đa 24 giờ)
    let ttl: number;
    if (clickCount > 10000) {
      ttl = 86400;  // 24 giờ — viral URL
    } else if (clickCount > 1000) {
      ttl = 14400;  // 4 giờ — popular URL
    } else if (clickCount > 100) {
      ttl = 3600;   // 1 giờ — traffic vừa phải
    } else {
      ttl = 900;    // 15 phút — traffic thấp
    }
 
    // Vẫn tôn trọng URL-level expiration
    if (url.expiresAt) {
      const urlTtl = Math.floor(
        (new Date(url.expiresAt).getTime() - Date.now()) / 1000
      );
      if (urlTtl > 0) {
        ttl = Math.min(ttl, urlTtl);
      } else {
        return;
      }
    }
 
    await this.redis.setex(key, ttl, value);
  } catch (error) {
    console.error('Cache set error:', error);
  }
}

Đây là optional — fixed TTL đơn giản hoạt động tốt cho hầu hết cases. Chỉ thêm dynamic TTL khi bạn có bằng chứng traffic patterns lệch mạnh (vài URLs nhận 90% requests).


Sliding Window Rate Limiter

Rate limiting ngăn abuse — một client không nên gửi 1,000 requests/giây và overwhelm server. Chúng ta sẽ implement sliding window algorithm dùng Redis sorted sets.

Tại sao Sliding Window?

Có ba approaches phổ biến:

AlgorithmCách hoạt độngNhược điểm
Fixed windowĐếm requests trong time slots cố định (VD: phút 0:00-1:00)Burst ở ranh giới window (200 req giây cuối + 200 giây đầu = 400 trong 2 giây)
Token bucketTokens refill theo tốc độ cố định; mỗi request tốn một tokenPhức tạp để implement đúng
Sliding windowTrack timestamp mỗi request; đếm requests trong rolling windowTốn memory hơn chút (lưu timestamps)

Sliding window là balance tốt nhất giữa accuracy và simplicity. Không bị boundary bursts, dễ hiểu, và Redis sorted sets khiến nó efficient.

Cách hoạt động

Mỗi request timestamp được lưu trong Redis sorted set. "Score" là timestamp. Để check rate:

  1. Xóa tất cả entries cũ hơn now - windowSize
  2. Đếm entries còn lại
  3. Nếu count < maxRequests, thêm request mới và cho phép
  4. Nếu count >= maxRequests, reject với 429

Implementation

// src/middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express';
import { getRedisClient } from '../config/redis';
import { CacheKeys } from '../cache/keys';
import { env } from '../config/env';
 
interface RateLimitConfig {
  windowSeconds: number;
  maxRequests: number;
  keyGenerator?: (req: Request) => string;
  message?: string;
}
 
const defaultConfig: RateLimitConfig = {
  windowSeconds: env.rateLimitWindowSeconds,
  maxRequests: env.rateLimitMaxRequests,
  message: 'Too many requests. Please try again later.',
};
 
export function rateLimiter(config: Partial<RateLimitConfig> = {}) {
  const {
    windowSeconds,
    maxRequests,
    keyGenerator,
    message,
  } = { ...defaultConfig, ...config };
 
  return async (req: Request, res: Response, next: NextFunction) => {
    const redis = getRedisClient();
 
    // Tạo unique key cho client này
    const clientId = keyGenerator
      ? keyGenerator(req)
      : getClientIp(req);
 
    const key = CacheKeys.rateLimit(clientId);
    const now = Date.now();
    const windowStart = now - windowSeconds * 1000;
 
    try {
      // Dùng Redis pipeline cho atomicity và performance
      const pipeline = redis.pipeline();
 
      // Bước 1: Xóa expired entries (cũ hơn window start)
      pipeline.zremrangebyscore(key, 0, windowStart);
 
      // Bước 2: Đếm entries hiện tại trong window
      pipeline.zcard(key);
 
      // Bước 3: Thêm request hiện tại
      pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
      // Bước 4: Set expiry trên key (cleanup)
      pipeline.expire(key, windowSeconds);
 
      const results = await pipeline.exec();
 
      if (!results) {
        // Pipeline fail — cho phép request (fail open)
        return next();
      }
 
      // Result index 1 là ZCARD result (count trước khi thêm)
      const currentCount = results[1][1] as number;
 
      if (currentCount >= maxRequests) {
        // Xóa request vừa thêm (không nên tính)
        await redis.zremrangebyscore(key, now, now);
 
        // Tính retry-after
        const oldestEntry = await redis.zrange(key, 0, 0, 'WITHSCORES');
        const retryAfter = oldestEntry.length >= 2
          ? Math.ceil((parseInt(oldestEntry[1]) + windowSeconds * 1000 - now) / 1000)
          : windowSeconds;
 
        res.set({
          'X-RateLimit-Limit': maxRequests.toString(),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': Math.ceil((now + retryAfter * 1000) / 1000).toString(),
          'Retry-After': retryAfter.toString(),
        });
 
        return res.status(429).json({
          error: message,
          retryAfter,
        });
      }
 
      // Set rate limit headers cho successful requests
      res.set({
        'X-RateLimit-Limit': maxRequests.toString(),
        'X-RateLimit-Remaining': (maxRequests - currentCount - 1).toString(),
        'X-RateLimit-Reset': Math.ceil((now + windowSeconds * 1000) / 1000).toString(),
      });
 
      next();
    } catch (error) {
      console.error('Rate limiter error:', error);
      // Fail open — nếu Redis chết, vẫn cho phép request
      next();
    }
  };
}
 
function getClientIp(req: Request): string {
  const forwarded = req.headers['x-forwarded-for'];
  if (typeof forwarded === 'string') {
    return forwarded.split(',')[0].trim();
  }
  return req.ip || req.socket.remoteAddress || 'unknown';
}

Phân tích các phần quan trọng:

Pipeline — gộp bốn Redis commands vào một network round-trip duy nhất. Không có pipeline, đây sẽ là bốn round-trips riêng biệt.

Math.random() trong member — sorted set members phải unique. Nếu hai requests đến cùng millisecond, ${now}:${Math.random()} đảm bảo chúng được lưu thành entries riêng.

Fail open — nếu Redis chết, ta vẫn cho phép request. Cách ngược lại (fail closed) sẽ block toàn bộ traffic khi Redis có vấn đề. Với hầu hết applications, fail open là lựa chọn đúng.

Rate limit headers — standard X-RateLimit-* headers cho clients biết quota và khi nào reset. Header Retry-After cho clients biết chính xác cần đợi bao lâu.

Apply Rate Limiting vào Routes

// src/app.ts
import express from 'express';
import { rateLimiter } from './middleware/rateLimiter';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
 
const app = express();
app.use(express.json());
 
// Global rate limit: 100 requests/phút/IP
app.use(rateLimiter({
  windowSeconds: 60,
  maxRequests: 100,
}));
 
// Rate limit chặt hơn cho URL creation
app.use('/api/shorten', rateLimiter({
  windowSeconds: 60,
  maxRequests: 10,
  keyGenerator: (req) => `create:${getClientIp(req)}`,
  message: 'URL creation rate limit exceeded. Max 10 URLs per minute.',
}));
 
// API routes
app.use('/api', urlRoutes);
 
// Redirect route (ít strict hơn — cần nhanh)
app.use('/', redirectRoutes);
 
export default app;

Hai tầng rate limiting:

  1. Global — 100 requests/phút mỗi IP cho tất cả endpoints. Ngăn general abuse.
  2. Endpoint-specific — 10 URL creations/phút. Tạo URL tốn resources hơn redirect, nên limit chặt hơn.

Redirects cố tình có limit rộng rãi — mục đích của URL shortener là xử lý nhiều redirects.


Redis Data Structures Reference

Tóm tắt mọi Redis data structure ta dùng và lý do:

Data StructureDùng choCommandsTại sao
String (GET/SET)URL cacheGET, SETEX, DELSimple key-value; hoàn hảo cho caching JSON objects
Sorted Set (ZADD/ZCARD)Rate limitingZADD, ZCARD, ZREMRANGEBYSCOREScore = timestamp; efficient range queries cho sliding window

String Operations (URL Cache)

# Lưu URL với 1-hour TTL
SET urlshort:url:abc123 '{"id":"1","shortCode":"abc123","originalUrl":"https://example.com"}' EX 3600
 
# Lấy ra
GET urlshort:url:abc123
 
# Xóa (invalidation)
DEL urlshort:url:abc123
 
# Kiểm tra TTL còn lại
TTL urlshort:url:abc123

Sorted Set Operations (Rate Limiting)

# Thêm request timestamp (score = timestamp, member = unique ID)
ZADD urlshort:ratelimit:192.168.1.1 1711036800000 "1711036800000:0.42"
 
# Xóa expired entries (cũ hơn 60 giây)
ZREMRANGEBYSCORE urlshort:ratelimit:192.168.1.1 0 1711036740000
 
# Đếm requests trong window hiện tại
ZCARD urlshort:ratelimit:192.168.1.1
 
# Set key expiry để cleanup
EXPIRE urlshort:ratelimit:192.168.1.1 60

Health Check Endpoint

Thêm health check báo cáo trạng thái Redis cùng PostgreSQL:

// src/routes/healthRoutes.ts
import { Router, Request, Response } from 'express';
import { isRedisHealthy } from '../config/redis';
import { prisma } from '../config/database';
 
const router = Router();
 
router.get('/health', async (_req: Request, res: Response) => {
  const [redisOk, dbOk] = await Promise.all([
    isRedisHealthy(),
    prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false),
  ]);
 
  const status = redisOk && dbOk ? 'healthy' : 'degraded';
 
  res.status(redisOk && dbOk ? 200 : 503).json({
    status,
    services: {
      redis: redisOk ? 'connected' : 'disconnected',
      database: dbOk ? 'connected' : 'disconnected',
    },
    timestamp: new Date().toISOString(),
  });
});
 
export default router;

Chú ý response là degraded, không phải down. App vẫn hoạt động không có Redis — chỉ fallback về database-only lookups. Đây là lợi ích của cache-aside pattern: Redis là optimization, không phải hard dependency.


So sánh Performance

Hãy đo impact thực tế. Đây là benchmark đơn giản bạn có thể chạy locally:

// scripts/benchmark-cache.ts
import { resolveShortCode } from '../src/services/redirectService';
import { getRedisClient } from '../src/config/redis';
 
async function benchmark(shortCode: string, iterations: number) {
  const redis = getRedisClient();
 
  // Warm up — request đầu tiên populate cache
  await resolveShortCode(shortCode);
 
  // Benchmark có cache
  const cacheStart = performance.now();
  for (let i = 0; i < iterations; i++) {
    await resolveShortCode(shortCode);
  }
  const cacheTime = performance.now() - cacheStart;
 
  // Xóa cache
  await redis.flushdb();
 
  // Benchmark không cache (mọi request đều hit DB)
  const dbStart = performance.now();
  for (let i = 0; i < iterations; i++) {
    await resolveShortCode(shortCode);
    await redis.flushdb(); // Xóa sau mỗi lần để force DB hit
  }
  const dbTime = performance.now() - dbStart;
 
  console.log(`\n--- Benchmark Results (${iterations} iterations) ---`);
  console.log(`Có cache:      ${(cacheTime / iterations).toFixed(2)}ms avg`);
  console.log(`Không cache:   ${(dbTime / iterations).toFixed(2)}ms avg`);
  console.log(`Speedup:       ${(dbTime / cacheTime).toFixed(1)}x nhanh hơn`);
  console.log(`Cache hit rate: ~${((1 - 1/iterations) * 100).toFixed(0)}%`);
}
 
benchmark('abc123', 1000);

Kết quả kỳ vọng

MetricKhông có RedisCó RedisCải thiện
Avg redirect latency3-8ms0.2-0.8ms5-10x nhanh hơn
P99 latency15-25ms1-2ms10-15x nhanh hơn
DB queries/sec10,000~100 (chỉ cache misses)Giảm 99%
Max throughput~5K req/sec~50K req/sec10x nhiều hơn

Thắng lớn nhất không phải tốc độ từng request — mà là giảm tải database. Với 95% cache hit rate, PostgreSQL xử lý ít hơn 20 lần queries. Nghĩa là bạn scale xa hơn nhiều trước khi cần database replicas hoặc sharding.


Những Sai lầm Phổ biến và Cách Tránh

1. Cache Stampede

Vấn đề: Khi một cached key phổ biến expire, hàng trăm requests đồng thời đều thấy cache miss và tất cả query database cùng lúc. Database bị dập tơi tả với identical queries.

Giải pháp: Locking (mutex) pattern

// src/cache/urlCache.ts — thêm vào UrlCacheService
 
async getWithLock(shortCode: string): Promise<CachedUrl | null> {
  // Thử cache trước
  const cached = await this.get(shortCode);
  if (cached) return cached;
 
  // Thử acquire lock
  const lockKey = `${CacheKeys.url(shortCode)}:lock`;
  const acquired = await this.redis.set(lockKey, '1', 'EX', 5, 'NX');
 
  if (acquired) {
    // Có lock — fetch từ DB và populate cache
    // (caller xử lý DB query và gọi this.set())
    return null; // Signal: "bạn cần fetch từ DB"
  }
 
  // Ai đó đang giữ lock — chờ chút rồi retry từ cache
  await new Promise((resolve) => setTimeout(resolve, 50));
  return this.get(shortCode);
}

Với hầu hết URL shorteners, approach đơn giản không có locking là đủ. Cache stampede chỉ thành problem thực sự ở traffic rất cao (100K+ requests/giây trên cùng key).

2. Stale Data sau Updates

Vấn đề: Bạn update URL destination, nhưng users vẫn bị redirect đến URL cũ.

Giải pháp: Luôn invalidate khi write (ta đã làm):

// ĐÚNG: invalidate sau update
await prisma.url.update({ where: { shortCode }, data: { originalUrl: newUrl } });
await urlCache.delete(shortCode);
 
// SAI: update cache trực tiếp (risk inconsistency)
await urlCache.set({ ...existing, originalUrl: newUrl });
await prisma.url.update({ where: { shortCode }, data: { originalUrl: newUrl } });

3. Không Set Key Expiry cho Rate Limit Keys

Vấn đề: Nếu quên set EXPIRE trên rate limit sorted set keys, chúng tích lũy mãi mãi. Sau vài tháng, Redis memory đầy với stale rate limit data.

Giải pháp: Luôn set EXPIRE trên rate limit keys:

// ĐÚNG
pipeline.expire(key, windowSeconds);
 
// SAI — key tồn tại vĩnh viễn
// (không gọi EXPIRE)

4. Caching Errors

Vấn đề: Database query fail, và bạn cache error response. Giờ mọi request sau đều nhận cached error.

Giải pháp: Chỉ cache successful lookups:

// ĐÚNG
const url = await prisma.url.findUnique({ where: { shortCode } });
if (url) {
  await urlCache.set(toCachedUrl(url)); // Chỉ cache nếu tìm thấy
}
 
// SAI
try {
  const url = await prisma.url.findUnique({ where: { shortCode } });
  await urlCache.set(url); // Cache null khi not-found!
} catch (error) {
  await urlCache.set(null); // Cache errors!
}

5. Dùng Redis làm Primary Storage

Vấn đề: Coi Redis là source of truth. Redis data có thể mất khi restart (trừ khi cấu hình persistence), và eviction policies có thể xóa keys bất ngờ.

Giải pháp: PostgreSQL luôn là source of truth. Redis là cache — data có thể rebuild từ database bất cứ lúc nào.


Updated Project Structure

Sau bài này, cấu trúc project mở rộng:

url-shortener/
├── src/
│   ├── cache/
│   │   ├── keys.ts              # Cache key generators
│   │   └── urlCache.ts          # URL cache service
│   ├── config/
│   │   ├── env.ts               # Environment variables
│   │   ├── database.ts          # Prisma client
│   │   └── redis.ts             # Redis client + connection mgmt
│   ├── middleware/
│   │   ├── errorHandler.ts      # Error handling
│   │   ├── rateLimiter.ts       # Sliding window rate limiter
│   │   └── validateRequest.ts   # Request validation
│   ├── routes/
│   │   ├── healthRoutes.ts      # Health check endpoint
│   │   ├── redirectRoutes.ts    # Redirect có cache
│   │   └── urlRoutes.ts         # URL CRUD endpoints
│   ├── services/
│   │   ├── analyticsService.ts  # Click tracking
│   │   ├── redirectService.ts   # Resolve short codes (cache-aside)
│   │   └── urlService.ts        # URL CRUD với invalidation
│   ├── types/
│   │   └── url.ts
│   ├── app.ts
│   └── index.ts
├── docker-compose.yml           # PostgreSQL + Redis
├── .env
└── package.json

Thêm mới:

  • src/cache/ — cache key management và URL cache service
  • src/config/redis.ts — Redis client với connection management
  • src/middleware/rateLimiter.ts — sliding window rate limiter
  • src/routes/healthRoutes.ts — health check với Redis status
  • Updated docker-compose.yml — Redis service thêm vào

Testing Cache

Manual Testing với redis-cli

# Kết nối Redis
docker exec -it url-shortener-redis redis-cli
 
# Xem tất cả keys
KEYS urlshort:*
 
# Kiểm tra cached URL
GET urlshort:url:abc123
 
# Kiểm tra TTL còn lại
TTL urlshort:url:abc123
 
# Monitor tất cả Redis commands real-time (hữu ích cho debugging)
MONITOR
 
# Kiểm tra memory usage
INFO memory
 
# Kiểm tra rate limit entries cho một IP
ZRANGE urlshort:ratelimit:127.0.0.1 0 -1 WITHSCORES

Test Rate Limiting

# Gửi 25 requests nhanh — sẽ nhận 429 sau request 20
for i in $(seq 1 25); do
  echo "Request $i: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/shorten -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com"}')"
done

Kết quả mong đợi:

Request 1: 201
Request 2: 201
...
Request 20: 201
Request 21: 429
Request 22: 429
...

Test Cache Performance

# Request đầu tiên — cache miss (chậm hơn)
time curl -o /dev/null -s http://localhost:3000/abc123
 
# Request thứ hai — cache hit (nhanh hơn)
time curl -o /dev/null -s http://localhost:3000/abc123

Tiếp theo

Với Redis đã sẵn sàng, URL shortener giờ xử lý được traffic lớn hơn đáng kể. Redirects dưới millisecond, database được bảo vệ khỏi read storms, và abusive clients bị rate-limited tự động.

Bài tiếp theo, chúng ta sẽ thêm user authentication — registration, login với JWT tokens, và user-scoped URL management. Users sẽ xem được URLs của mình, edit chúng, và xem analytics cho links của họ.

Nội dung chính Phase 6:

  • JWT authentication với refresh tokens
  • User registration và login endpoints
  • Password hashing với bcrypt
  • Auth middleware cho protected routes
  • User-scoped URL ownership

Series: Build a URL Shortener
Previous: Phase 4: Redirect Engine & Click Analytics
Next: Phase 6: User Authentication & URL Management

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.