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:
| Feature | PostgreSQL | Redis |
|---|---|---|
| Storage | Disk-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 model | Relational tables | Key-value + data structures |
| Dùng cho | Source of truth | Caching, 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ạyTest Redis hoạt động:
docker exec -it url-shortener-redis redis-cli ping
# PONGCậ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=20Cậ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/ioredisDù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:
- Kiểm tra Redis xem có data không
- Nếu có (cache hit), trả về luôn
- Nếu không (cache miss), query PostgreSQL
- Lưu kết quả vào Redis cho lần sau
- 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 counterURL 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:
-
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.
-
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.
-
Application-level expiry check — dù đã có TTL, ta vẫn double-check
expiresAtkhi đọ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
| Event | Action | Lý do |
|---|---|---|
| URL updated | Xóa khỏi cache | Request tiếp theo fetch data mới |
| URL deleted | Xóa khỏi cache | Tránh stale redirects |
| URL deactivated | Xóa khỏi cache | Ngừng serve inactive URLs |
| URL expired | Tự độ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:
- Đơ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.
- 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 Case | TTL | Lý do |
|---|---|---|
| URL lookups (redirect) | 1 giờ | URLs ít khi thay đổi, đọc nhiều |
| URL stats/analytics | 5 phút | Stats thay đổi mỗi click |
| Rate limit windows | 1 phút | Windows ngắn cho limiting chính xác |
| User sessions | 24 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:
| Algorithm | Cách hoạt động | Nhượ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 bucket | Tokens refill theo tốc độ cố định; mỗi request tốn một token | Phức tạp để implement đúng |
| Sliding window | Track timestamp mỗi request; đếm requests trong rolling window | Tố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:
- Xóa tất cả entries cũ hơn
now - windowSize - Đếm entries còn lại
- Nếu count < maxRequests, thêm request mới và cho phép
- 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:
- Global — 100 requests/phút mỗi IP cho tất cả endpoints. Ngăn general abuse.
- 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 Structure | Dùng cho | Commands | Tại sao |
|---|---|---|---|
String (GET/SET) | URL cache | GET, SETEX, DEL | Simple key-value; hoàn hảo cho caching JSON objects |
Sorted Set (ZADD/ZCARD) | Rate limiting | ZADD, ZCARD, ZREMRANGEBYSCORE | Score = 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:abc123Sorted 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 60Health 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
| Metric | Không có Redis | Có Redis | Cải thiện |
|---|---|---|---|
| Avg redirect latency | 3-8ms | 0.2-0.8ms | 5-10x nhanh hơn |
| P99 latency | 15-25ms | 1-2ms | 10-15x nhanh hơn |
| DB queries/sec | 10,000 | ~100 (chỉ cache misses) | Giảm 99% |
| Max throughput | ~5K req/sec | ~50K req/sec | 10x 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.jsonThêm mới:
src/cache/— cache key management và URL cache servicesrc/config/redis.ts— Redis client với connection managementsrc/middleware/rateLimiter.ts— sliding window rate limitersrc/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 WITHSCORESTest 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"}')"
doneKế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/abc123Tiế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.