Build a URL Shortener: Database Design & Storage

URL shortener của chúng ta hoạt động rồi — nhưng nó quên sạch mọi thứ khi server restart. Mọi URL đã shorten, mọi click count, bay hết. Prototype thì ổn, nhưng production thì vô dụng hoàn toàn.
Trong bài này, chúng ta sẽ thêm PostgreSQL làm database và Prisma làm ORM. Cuối bài, URL sẽ sống sót qua server restart, query sẽ type-safe, và schema được version-control qua migration. Điều hay nhất? Nhờ kiến trúc phân tầng từ Post #2, chúng ta chỉ cần thay đúng một file.
Thời gian: 1–2 giờ
Prerequisites: Phase 1: Setup Project & URL Shortening API
Những gì chúng ta sẽ build trong bài này:
✅ PostgreSQL chạy trong Docker với Docker Compose
✅ Prisma ORM với type-safe database client
✅ Schema design với proper index cho fast lookup
✅ Database migration cho version-controlled schema change
✅ Thay thế memoryStore bằng Prisma-based store
✅ Connection pooling configuration cho production readiness
Tại Sao PostgreSQL + Prisma?
Trước khi bắt tay vào làm, hãy nói rõ tại sao chọn hai tools này thay vì hàng tá lựa chọn khác.
Tại Sao PostgreSQL?
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| ACID compliance | Đầy đủ | Đầy đủ | Đầy đủ |
| JSON support | Native jsonb type | Hạn chế | Không có |
| Full-text search | Built-in tsvector | Cần plugin | Cơ bản |
| Concurrent writes | MVCC (tuyệt vời) | Lock-based | Single writer |
| Analytics | Window functions, CTEs | Hạn chế | Cơ bản |
| Scalability | Xử lý hàng triệu row dễ dàng | Tốt | Dataset nhỏ |
Với URL shortener, PostgreSQL cho chúng ta:
- Fast indexed lookup trên short code (quan trọng cho redirect speed)
- JSONB column cho flexible analytics data ở các bài sau
- Window functions cho click analytics và reporting
- Battle-tested concurrency khi nhiều user shorten URL cùng lúc
Tại Sao Prisma?
Prisma là TypeScript-first ORM, generate ra fully typed database client từ schema:
// Type-safe — TypeScript biết chính xác shape của result
const url = await prisma.url.findUnique({
where: { shortCode: 'aBc1X9z' }
});
// url có type là Url | null — không phải `any`So với raw SQL hay query builder:
- Type safety — bắt lỗi query lúc compile time, không phải runtime
- Auto-generated client — không cần viết tay type rồi nó drift khỏi schema
- Migration system — version-controlled schema change với rollback
- Introspection — generate schema từ database có sẵn
Setup PostgreSQL với Docker
Chúng ta dùng Docker Compose để chạy PostgreSQL local. Máy sạch sẽ, setup reproducible.
Tạo Docker Compose File
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
container_name: url-shortener-db
restart: unless-stopped
environment:
POSTGRES_USER: urlshortener
POSTGRES_PASSWORD: urlshortener123
POSTGRES_DB: urlshortener
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urlshortener"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:Giải thích từng phần:
postgres:16-alpine— Alpine-based image (nhỏ hơn nhiều so với image mặc định, ~80MB vs ~400MB)restart: unless-stopped— auto-restart nếu container crash (nhưng không restart nếu bạn dừng thủ công)volumes— named volumepostgres_datagiữ data qua các lần container restart. Không có cái này thì data bay hết khi container dừnghealthcheck— Docker biết khi nào PostgreSQL thật sự sẵn sàng nhận connection, không chỉ khi container start. Quan trọng cho dependent service
Start PostgreSQL
docker compose up -dKiểm tra nó đang chạy:
docker compose psNAME STATUS PORTS
url-shortener-db Up (healthy) 0.0.0.0:5432->5432/tcpBạn cũng có thể connect trực tiếp để check:
docker exec -it url-shortener-db psql -U urlshortener -d urlshortener -c "SELECT version();"Update Environment Variables
Thêm database connection string vào .env:
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
DATABASE_URL=postgresql://urlshortener:urlshortener123@localhost:5432/urlshortenerVà update .env.example:
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/urlshortenerLưu ý bảo mật: Đừng bao giờ commit
.envvào git. File.env.exampledùng placeholder value và commit an toàn.
Install và Khởi Tạo Prisma
Install Dependencies
# Runtime dependencies
npm install @prisma/client @prisma/adapter-pg
# Development dependencies
npm install -D prisma @types/pgBốn package:
prisma(dev dependency) — CLI tool cho migration, schema management và client generation@prisma/client(runtime dependency) — generated client bạn dùng trong code@prisma/adapter-pg(runtime dependency) — Prisma 7 driver adapter cho PostgreSQL, kết nối Prisma vớipgdriver@types/pg(dev dependency) — TypeScript types chopgdriver
Tại sao cần adapter? Prisma 7 dùng "client" engine mới, delegate database communication cho JavaScript driver (trong trường hợp này là
pg) thay vì nhúng binary query engine riêng. Điều này làm Prisma Client nhỏ hơn và start nhanh hơn, đồng thời cho bạn kiểm soát trực tiếp connection pool.
Khởi Tạo Prisma
npx prisma initLệnh này tạo:
prisma/schema.prisma— file định nghĩa database schemaprisma.config.ts— file cấu hình Prisma (mới trong Prisma 7)- Update
.envvớiDATABASE_URLplaceholder (chúng ta đã set rồi)
Project structure giờ trông như thế này:
url-shortener/
├── prisma/
│ └── schema.prisma # MỚI — database schema
├── src/
│ ├── config/env.ts
│ ├── middleware/
│ ├── routes/
│ ├── services/
│ ├── store/
│ │ └── memoryStore.ts # Sẽ được thay thế
│ ├── types/
│ ├── app.ts
│ └── index.ts
├── docker-compose.yml # MỚI — PostgreSQL
├── prisma.config.ts # MỚI — cấu hình Prisma
├── .env # ĐÃ CẬP NHẬT — thêm DATABASE_URL
└── ...Thiết Kế Schema
Đây là phần quan trọng nhất của bài. Schema design tốt giúp tránh migration đau đầu sau này và làm query nhanh hơn.
Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Url {
id String @id @default(uuid())
shortCode String @unique @map("short_code")
originalUrl String @map("original_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
clickCount Int @default(0) @map("click_count")
isActive Boolean @default(true) @map("is_active")
expiresAt DateTime? @map("expires_at")
@@index([originalUrl], name: "idx_original_url")
@@index([createdAt], name: "idx_created_at")
@@index([isActive, shortCode], name: "idx_active_short_code")
@@map("urls")
}Hãy xem xét từng quyết định chi tiết.
Phân Tích Từng Field
id — UUID primary key
id String @id @default(uuid())Dùng UUID thay vì auto-increment integer vì hai lý do:
- Security — sequential ID lộ thông tin (user có thể đoán
id=1,id=2,id=3để duyệt qua tất cả URL) - Distributed systems — UUID generate được mà không cần đụng database, quan trọng khi scale ra nhiều server
shortCode — URL slug
shortCode String @unique @map("short_code")Constraint @unique cực kỳ quan trọng. Nó:
- Tự động tạo unique index (fast lookup)
- Ngăn duplicate short code ở database level (kể cả khi application logic có bug)
- Là field lookup chính cho redirect
originalUrl — URL đích
originalUrl String @map("original_url")Không có length constraint trong Prisma — PostgreSQL TEXT type không có giới hạn thực tế. URL có thể dài khủng khiếp (browser hỗ trợ đến ~2000 ký tự, nhưng một số server-generated URL còn dài hơn).
createdAt / updatedAt — timestamp
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")@default(now()) set createdAt lúc insert. @updatedAt là feature của Prisma tự động set timestamp mỗi khi record được update.
clickCount — analytics counter
clickCount Int @default(0) @map("click_count")Bắt đầu từ 0. Tăng lên mỗi lần redirect. Trong hệ thống high-traffic, bạn sẽ dùng bảng analytics riêng hoặc Redis cho việc này — nhưng bây giờ, atomic increment operation là đủ.
isActive — soft delete
isActive Boolean @default(true) @map("is_active")Thay vì xóa row, chúng ta set isActive thành false. Điều này cho phép:
- Giữ analytics data cho URL đã deactivate
- Khôi phục URL bị xóa nhầm
- Ngăn tái sử dụng short code (code đã xóa không nên gán lại)
expiresAt — link expiration
expiresAt DateTime? @map("expires_at")Nullable — không phải URL nào cũng expire. Khi set, redirect cần check trước khi chuyển hướng. Logic expiration sẽ được implement ở bài sau.
Quy Ước Đặt Tên
Chú ý decorator @map():
shortCode String @map("short_code") // TypeScript: camelCase
// Database: snake_caseCho chúng ta cả hai thế giới:
- TypeScript code dùng
camelCase(JavaScript convention) - Database column dùng
snake_case(SQL convention) - Table name
@@map("urls")— số nhiều, chữ thường (SQL convention)
Indexing Strategy
Index là thứ làm database query nhanh. Không có index, PostgreSQL phải scan từng row trong bảng (gọi là "sequential scan"). Có index, nó nhảy thẳng đến row khớp.
@@index([originalUrl], name: "idx_original_url")
@@index([createdAt], name: "idx_created_at")
@@index([isActive, shortCode], name: "idx_active_short_code")Tại sao mỗi index tồn tại:
| Index | Tăng tốc cho | Dùng khi |
|---|---|---|
shortCode (unique) | WHERE short_code = ? | Mỗi lần redirect (GET /:code) |
idx_original_url | WHERE original_url = ? | Check trùng lặp khi shorten |
idx_created_at | ORDER BY created_at DESC | Liệt kê URL mới nhất |
idx_active_short_code | WHERE is_active = true AND short_code = ? | Lookup URL đang active |
Composite index idx_active_short_code rất quan trọng. Khi check short code có active không trong lúc redirect, PostgreSQL có thể thỏa mãn query hoàn toàn từ index — không cần truy cập table. Đây gọi là "index-only scan" và nhanh hơn đáng kể.
Nguyên tắc chung: Tạo index cho column xuất hiện trong
WHERE,ORDER BYvàJOIN. Nhưng đừng index quá nhiều — mỗi index làm chậm write vì PostgreSQL phải update index mỗi khi INSERT/UPDATE.
Chạy Migration
Prisma migration là các file SQL được version-control, theo dõi schema change theo thời gian.
Tạo Migration Đầu Tiên
npx prisma migrate dev --name initLệnh này làm ba việc:
- Generate SQL — tạo migration file trong
prisma/migrations/ - Chạy SQL — apply migration vào database
- Generate client — tạo type-safe Prisma Client
Output:
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "urlshortener", schema "public" at "localhost:5432"
Applying migration `20260321100000_init`
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20260321100000_init/
└─ migration.sql
✔ Generated Prisma Client to ./node_modules/.prisma/clientXem SQL Được Generate
-- prisma/migrations/20260321100000_init/migration.sql
CREATE TABLE "urls" (
"id" TEXT NOT NULL,
"short_code" TEXT NOT NULL,
"original_url" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"click_count" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"expires_at" TIMESTAMP(3),
CONSTRAINT "urls_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "urls_short_code_key" ON "urls"("short_code");
CREATE INDEX "idx_original_url" ON "urls"("original_url");
CREATE INDEX "idx_created_at" ON "urls"("created_at");
CREATE INDEX "idx_active_short_code" ON "urls"("is_active", "short_code");Đây là SQL thực tế mà Prisma chạy trên database. Nó được commit vào git, nên team có thể thấy chính xác cái gì thay đổi và khi nào. Nếu có vấn đề, bạn có thể viết down migration để revert.
Project Structure Cập Nhật
url-shortener/
├── prisma/
│ ├── migrations/
│ │ └── 20260321100000_init/
│ │ └── migration.sql # SQL được generate
│ └── schema.prisma # Định nghĩa schema
├── src/
│ ├── config/env.ts
│ ├── lib/
│ │ └── prisma.ts # MỚI — Prisma client singleton
│ ├── store/
│ │ ├── memoryStore.ts # CŨ — sẽ được thay thế
│ │ └── prismaStore.ts # MỚI — database-backed store
│ └── ...
├── docker-compose.yml
├── prisma.config.ts # Cấu hình Prisma 7
└── ...Prisma Client Singleton
Trong Node.js application, bạn muốn đúng một Prisma Client instance. Tạo nhiều instance lãng phí database connection và có thể gây connection pool exhaustion.
Prisma 7 dùng pattern driver adapter — thay vì nhúng binary query engine riêng, nó delegate cho JavaScript PostgreSQL driver (pg). Nghĩa là chúng ta kiểm soát trực tiếp connection pool.
// src/lib/prisma.ts
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development'
? ['query', 'warn', 'error']
: ['warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}Prisma 7 Khác Gì?
Trong Prisma version cũ (5/6), new PrismaClient() là đủ — nó dùng embedded binary query engine tự handle database connection. Trong Prisma 7, query engine chạy bằng JavaScript, và bạn cung cấp driver adapter wrap pg driver:
// Prisma 5/6 — embedded query engine (cách cũ)
const prisma = new PrismaClient();
// Prisma 7 — driver adapter (cách mới)
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });PrismaPg adapter tạo và quản lý pg.Pool bên trong. Bạn cũng có thể truyền pool configuration:
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
pool: { max: 10 }, // max connection trong pool
});Tại sao
import 'dotenv/config'? Adapter đọcDATABASE_URLlúc import time, trước khi Express startup code chạy. Dòng này đảm bảo environment variable được load trước khi tạo adapter.
Tại Sao Dùng Trick globalThis?
Trong development, tool như nodemon và tsx hot-reload code bằng cách re-import module. Mỗi lần re-import tạo instance PrismaClient mới, mở connection pool mới. Sau vài lần reload, bạn đã xài hết connection limit của database.
Lưu instance trên globalThis đảm bảo nó sống sót qua module reload. Trong production chỉ có một lần load — nên guard này chỉ cần thiết khi develop.
Logging Configuration
log: process.env.NODE_ENV === 'development'
? ['query', 'warn', 'error']
: ['warn', 'error'],Trong development, chúng ta log mọi SQL query. Điều này giúp bạn:
- Xem Prisma generate query gì
- Phát hiện N+1 query problem
- Kiểm tra index có được dùng không
Trong production, chỉ log warning và error để giữ log sạch.
Build Prisma Store
Giờ đến phần chính — thay thế memoryStore.ts bằng implementation dùng Prisma với cùng interface.
// src/store/prismaStore.ts
import { prisma } from '../lib/prisma';
import { UrlRecord } from '../types/url';
export const prismaStore = {
async save(record: UrlRecord): Promise<UrlRecord> {
const url = await prisma.url.create({
data: {
id: record.id,
shortCode: record.shortCode,
originalUrl: record.originalUrl,
createdAt: record.createdAt,
clickCount: record.clickCount,
},
});
return this.toUrlRecord(url);
},
async findByShortCode(shortCode: string): Promise<UrlRecord | null> {
const url = await prisma.url.findUnique({
where: { shortCode },
});
return url ? this.toUrlRecord(url) : null;
},
async findByOriginalUrl(originalUrl: string): Promise<UrlRecord | null> {
const url = await prisma.url.findFirst({
where: {
originalUrl,
isActive: true,
},
});
return url ? this.toUrlRecord(url) : null;
},
async incrementClickCount(shortCode: string): Promise<void> {
await prisma.url.update({
where: { shortCode },
data: {
clickCount: { increment: 1 },
},
});
},
async exists(shortCode: string): Promise<boolean> {
const count = await prisma.url.count({
where: { shortCode },
});
return count > 0;
},
toUrlRecord(url: {
id: string;
shortCode: string;
originalUrl: string;
createdAt: Date;
clickCount: number;
}): UrlRecord {
return {
id: url.id,
shortCode: url.shortCode,
originalUrl: url.originalUrl,
createdAt: url.createdAt,
clickCount: url.clickCount,
};
},
};Khác Biệt Chính So Với Memory Store
Tất cả method giờ là async
Memory store thì synchronous — Map.get() trả kết quả ngay. Database query là I/O operation trả về Promise. Đây là lý do chúng ta làm service layer async từ đầu.
findUnique vs findFirst
// findUnique — cho column có @unique constraint
await prisma.url.findUnique({ where: { shortCode } });
// findFirst — cho column không unique, trả kết quả đầu tiên
await prisma.url.findFirst({ where: { originalUrl } });findUnique nhanh hơn vì PostgreSQL biết chỉ có tối đa một row khớp (nhờ unique index) và dừng scan ngay.
Atomic increment
await prisma.url.update({
where: { shortCode },
data: {
clickCount: { increment: 1 },
},
});Cái này dịch ra UPDATE urls SET click_count = click_count + 1 WHERE short_code = ?. Increment xảy ra atomically trong database — không có race condition khi nhiều request click cùng URL.
So sánh với cách memory store:
// MEMORY STORE — có thể race condition
const record = urls.get(shortCode);
if (record) {
record.clickCount += 1; // Hai request đọc cùng lúc đều thấy clickCount=5
} // Cả hai ghi 6, mất một clickcount cho existence check
async exists(shortCode: string): Promise<boolean> {
const count = await prisma.url.count({
where: { shortCode },
});
return count > 0;
}Dùng count() thay vì findUnique() hiệu quả hơn một chút cho existence check — không cần transfer toàn bộ row từ database, chỉ cần một số.
Update URL Service
Giờ swap import từ memoryStore sang prismaStore. Vì cả hai store implement cùng interface, thay đổi rất ít:
// src/services/urlService.ts
import { prismaStore } from '../store/prismaStore';
import { generateShortCode, isValidCustomAlias } from '../utils/shortCode';
import { env } from '../config/env';
import { CreateUrlResponse, UrlStatsResponse } from '../types/url';
export class UrlService {
async shortenUrl(originalUrl: string, customAlias?: string): Promise<CreateUrlResponse> {
// Check URL đã được shorten chưa
const existing = await prismaStore.findByOriginalUrl(originalUrl);
if (existing && !customAlias) {
return this.toCreateResponse(existing.shortCode, existing.originalUrl, existing.createdAt);
}
// Dùng custom alias hoặc generate short code
let shortCode: string;
if (customAlias) {
if (!isValidCustomAlias(customAlias)) {
throw new AppError('Invalid custom alias. Use 3-30 alphanumeric characters, hyphens, or underscores.', 400);
}
if (await prismaStore.exists(customAlias)) {
throw new AppError('Custom alias is already taken', 409);
}
shortCode = customAlias;
} else {
shortCode = await this.generateUniqueCode();
}
// Lưu record
const record = await prismaStore.save({
id: crypto.randomUUID(),
shortCode,
originalUrl,
createdAt: new Date(),
clickCount: 0,
});
return this.toCreateResponse(record.shortCode, record.originalUrl, record.createdAt);
}
async resolveAndTrack(shortCode: string): Promise<string> {
const record = await prismaStore.findByShortCode(shortCode);
if (!record) {
throw new AppError('Short URL not found', 404);
}
// Increment click count (fire and forget — không làm chậm redirect)
prismaStore.incrementClickCount(shortCode).catch((err) => {
console.error('Failed to increment click count:', err);
});
return record.originalUrl;
}
async getUrlStats(shortCode: string): Promise<UrlStatsResponse> {
const record = await prismaStore.findByShortCode(shortCode);
if (!record) {
throw new AppError('Short URL not found', 404);
}
return {
shortCode: record.shortCode,
shortUrl: `${env.baseUrl}/${record.shortCode}`,
originalUrl: record.originalUrl,
createdAt: record.createdAt,
clickCount: record.clickCount,
};
}
private async generateUniqueCode(): Promise<string> {
const MAX_RETRIES = 5;
for (let i = 0; i < MAX_RETRIES; i++) {
const code = generateShortCode();
if (!(await prismaStore.exists(code))) {
return code;
}
}
throw new AppError('Failed to generate unique short code. Please try again.', 500);
}
private toCreateResponse(shortCode: string, originalUrl: string, createdAt: Date): CreateUrlResponse {
return {
shortCode,
shortUrl: `${env.baseUrl}/${shortCode}`,
originalUrl,
createdAt,
};
}
}
export class AppError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = 'AppError';
}
}Thay Đổi Gì?
Cùng diff các thay đổi chính:
1. Swap import
// Trước
import { memoryStore } from '../store/memoryStore';
// Sau
import { prismaStore } from '../store/prismaStore';2. Tất cả store call giờ dùng await
// Trước (synchronous)
const existing = memoryStore.findByOriginalUrl(originalUrl);
// Sau (asynchronous)
const existing = await prismaStore.findByOriginalUrl(originalUrl);3. Fire-and-forget click tracking
// Không await increment click count — user không nên chờ analytics
prismaStore.incrementClickCount(shortCode).catch((err) => {
console.error('Failed to increment click count:', err);
});Đây là optimization có chủ đích. Redirect rất nhạy thời gian — user mong nó xong trong millisecond. Click counting có thể chạy background. Nếu fail thì log lại nhưng không làm chậm redirect.
Graceful Shutdown với Prisma
Cần disconnect database khi server shutdown. Update entry point:
// src/index.ts
import app from './app';
import { env } from './config/env';
import { prisma } from './lib/prisma';
const server = app.listen(env.port, () => {
console.log(`🚀 URL Shortener running at ${env.baseUrl}`);
console.log(` Environment: ${env.nodeEnv}`);
console.log(` Health check: ${env.baseUrl}/health`);
});
// Graceful shutdown
async function shutdown() {
console.log('Shutting down gracefully...');
server.close(async () => {
await prisma.$disconnect();
console.log('Database disconnected');
process.exit(0);
});
// Force shutdown sau 10 giây
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10_000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);Không có prisma.$disconnect(), process có thể thoát với database connection đang mở. PostgreSQL giới hạn số connection (mặc định: 100), và connection bị leak có thể xài hết limit theo thời gian.
Update Health Check
Health check cần kiểm tra cả database connection:
// src/app.ts (health check đã update)
import express from 'express';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
import { errorHandler } from './middleware/errorHandler';
import { prisma } from './lib/prisma';
const app = express();
// Middleware
app.use(express.json({ limit: '10kb' }));
// Health check — bao gồm database connectivity
app.get('/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
database: 'connected',
});
} catch {
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString(),
database: 'disconnected',
});
}
});
// API routes (phải trước redirect catch-all)
app.use('/api', urlRoutes);
// Redirect catch-all (phải cuối cùng)
app.use(redirectRoutes);
// Error handler (phải sau tất cả routes)
app.use(errorHandler);
export default app;Health check luôn trả ok thì vô dụng. Bằng cách ping database, load balancer và monitoring system có thể phát hiện khi database down và chuyển traffic đi chỗ khác.
Test Mọi Thứ
Đảm bảo PostgreSQL đang chạy:
docker compose up -dStart dev server:
npm run devTest 1: Health Check với Database
curl http://localhost:3000/health{
"status": "ok",
"timestamp": "2026-03-21T10:00:00.000Z",
"database": "connected"
}Test 2: Shorten URL (Đã Persist!)
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/prisma/prisma"}'{
"shortCode": "xK9mP2q",
"shortUrl": "http://localhost:3000/xK9mP2q",
"originalUrl": "https://github.com/prisma/prisma",
"createdAt": "2026-03-21T10:00:05.000Z"
}Test 3: Kiểm Tra Persistence
Restart server (Ctrl+C, rồi npm run dev), URL vẫn phải hoạt động:
curl http://localhost:3000/api/urls/xK9mP2q{
"shortCode": "xK9mP2q",
"shortUrl": "http://localhost:3000/xK9mP2q",
"originalUrl": "https://github.com/prisma/prisma",
"createdAt": "2026-03-21T10:00:05.000Z",
"clickCount": 0
}Data sống sót qua restart — đó là toàn bộ ý nghĩa của database.
Test 4: Redirect và Click Counting
# Redirect (dùng -L để thấy destination cuối)
curl -v http://localhost:3000/xK9mP2q 2>&1 | grep "Location:"< Location: https://github.com/prisma/prisma# Check click count đã tăng
curl http://localhost:3000/api/urls/xK9mP2q | jq .clickCount1Test 5: Deduplication URL Trùng
# Shorten cùng URL lần nữa — trả về short code đã có
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/prisma/prisma"}'{
"shortCode": "xK9mP2q",
"shortUrl": "http://localhost:3000/xK9mP2q",
"originalUrl": "https://github.com/prisma/prisma",
"createdAt": "2026-03-21T10:00:05.000Z"
}Cùng shortCode — không tạo duplicate.
Test 6: Kiểm Tra Data Trực Tiếp Trong PostgreSQL
docker exec -it url-shortener-db psql -U urlshortener -d urlshortener \
-c "SELECT short_code, original_url, click_count FROM urls;" short_code | original_url | click_count
------------+------------------------------------+-------------
xK9mP2q | https://github.com/prisma/prisma | 1Connection Pooling
Mỗi database query cần một connection. Mở connection tốn thời gian (TCP handshake, authentication, TLS negotiation). Connection pool giữ sẵn một bộ connection và tái sử dụng chúng qua các query.
Prisma 7 Connection Pooling Hoạt Động Thế Nào
Trong Prisma 7, PrismaPg adapter quản lý pg.Pool bên trong. Bạn configure pool qua adapter, không phải qua URL parameter:
// src/lib/prisma.ts
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
pool: {
max: 10, // Max connection (default: 10)
idleTimeoutMillis: 30000, // Đóng idle connection sau 30s
connectionTimeoutMillis: 5000, // Timeout chờ connection
},
});Tham Số Pool Configuration
| Parameter | Default | Mô tả |
|---|---|---|
max | 10 | Số connection tối đa trong pool |
idleTimeoutMillis | 10000 (10s) | Idle connection giữ mở bao lâu |
connectionTimeoutMillis | 0 (không timeout) | Chờ bao lâu để có connection trống |
min | 0 | Số connection tối thiểu giữ mở |
Lưu ý: Trong Prisma version cũ (5/6), bạn configure pool qua URL parameter như
?connection_limit=10&pool_timeout=10. Trong Prisma 7, dùng optionpoolcủa adapter — URL parameter bịpgdriver bỏ qua cho pool setting.
Cần Bao Nhiêu Connection?
Sai lầm phổ biến là set pool quá lớn. Nhiều connection hơn không có nghĩa performance tốt hơn. Mỗi connection tiêu tốn memory trên database server (~10MB mỗi connection trong PostgreSQL).
Nguyên tắc:
- Development: 2-5 connection là đủ
- Production (single server):
num_cpus * 2 + 1là điểm bắt đầu tốt - Production (nhiều server): Chia tổng pool cho tất cả server. Nếu PostgreSQL cho phép 100 connection và bạn có 4 server, mỗi server được 25
// Cho production với 4 CPU core:
// Pool = 4 * 2 + 1 = 9 connections
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
pool: { max: 9 },
});Monitor Connection Usage
Check active connection trực tiếp trong PostgreSQL:
docker exec -it url-shortener-db psql -U urlshortener -d urlshortener \
-c "SELECT count(*) FROM pg_stat_activity WHERE datname = 'urlshortener';"Prisma Studio — Visual Database Browser
Prisma có sẵn database GUI:
npx prisma studioMở web interface tại http://localhost:5555 nơi bạn có thể:
- Duyệt tất cả record trong table
- Sửa data trực tiếp
- Filter và sort record
- Xem relationship giữa các table
Cực kỳ hữu ích cho debug lúc develop.
Thêm Prisma Vào Workflow
Các Lệnh Prisma Hữu Ích
# Generate client sau khi thay đổi schema (không migration)
npx prisma generate
# Tạo và apply migration
npx prisma migrate dev --name describe_your_change
# Apply migration trong production (không prompt)
npx prisma migrate deploy
# Reset database (xóa hết data — chỉ dùng khi develop!)
npx prisma migrate reset
# Mở database GUI
npx prisma studio
# Format schema file
npx prisma format
# Validate schema
npx prisma validateUpdate .gitignore
.gitignore từ Post #2 đã cover đủ những gì cần thiết (node_modules/, dist/, .env, *.log). Không cần thay đổi gì.
Thư mục prisma/migrations/ nên được commit — đó là database version history. Generated client trong node_modules/.prisma/client được tạo lại khi npm install qua postinstall hook.
Update Package Scripts
{
"scripts": {
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "tsc --noEmit",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset",
"postinstall": "prisma generate"
}
}Script postinstall đảm bảo Prisma Client được generate mỗi khi ai đó chạy npm install — thiết yếu cho CI/CD pipeline và team member mới.
Hiểu Data Flow
Trace toàn bộ flow shorten → redirect với database:
Sai Lầm Thường Gặp
1. Tạo Nhiều Prisma Client Instance
// SAI — adapter + client mới mỗi lần import
export function getDb() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
return new PrismaClient({ adapter });
}
// ĐÚNG — singleton pattern
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
export { prisma };Mỗi instance PrismaClient tạo connection pool riêng qua adapter. Năm instance = năm pool = gấp năm lần connection. Dùng singleton pattern từ lib/prisma.ts.
2. Không Await Prisma Query
// SAI — trả về Promise, không phải result
const url = prismaStore.findByShortCode(code);
console.log(url.shortCode); // Error: shortCode không tồn tại trên Promise
// ĐÚNG
const url = await prismaStore.findByShortCode(code);Đây là lý do TypeScript có giá trị — nó bắt lỗi này lúc compile time nếu type đúng.
3. Quên Chạy Migration Trong Production
# Development — interactive, generate migration file
npx prisma migrate dev --name add_expires_at
# Production — non-interactive, apply pending migration
npx prisma migrate deployTrong production, luôn dùng migrate deploy. Nó apply pending migration mà không generate file mới hay prompt xác nhận.
4. Thiếu Index Trên Column Query Nhiều
// CHẬM — không có index trên originalUrl nghĩa là full table scan
model Url {
originalUrl String
}
// NHANH — index cho phép O(log n) lookup
model Url {
originalUrl String
@@index([originalUrl])
}Check query pattern và thêm index tương ứng. Dùng EXPLAIN ANALYZE trong PostgreSQL để xác nhận query dùng index:
EXPLAIN ANALYZE SELECT * FROM urls WHERE short_code = 'xK9mP2q';Tìm "Index Scan" trong output — nếu thấy "Seq Scan", index chưa được dùng.
5. Không Handle Connection Failure
import { Prisma } from '@prisma/client';
// SAI — crash khi database lỗi
const record = await prisma.url.findUnique({ where: { shortCode } });
// ĐÚNG — handle gracefully
try {
const record = await prisma.url.findUnique({ where: { shortCode } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Handle specific Prisma error (ví dụ: unique constraint violation P2002)
}
throw error; // Re-throw unknown error
}6. Dùng db push Trong Production
# Chỉ dùng khi develop — sync schema không tạo migration file
npx prisma db push
# Production — luôn dùng migration cho auditable, reversible change
npx prisma migrate deploydb push tốt cho rapid prototyping nhưng không tạo migration file. Trong production, bạn cần audit trail mà migration cung cấp.
Trạng Thái Project Sau Bài Này
url-shortener/
├── prisma/
│ ├── migrations/
│ │ └── 20260321100000_init/
│ │ └── migration.sql
│ └── schema.prisma
├── src/
│ ├── config/
│ │ └── env.ts
│ ├── lib/
│ │ └── prisma.ts # MỚI — Prisma client singleton
│ ├── middleware/
│ │ ├── errorHandler.ts
│ │ └── validateRequest.ts
│ ├── routes/
│ │ ├── urlRoutes.ts
│ │ └── redirectRoutes.ts
│ ├── services/
│ │ └── urlService.ts # CẬP NHẬT — dùng prismaStore
│ ├── store/
│ │ ├── memoryStore.ts # Giữ lại để tham khảo (không dùng)
│ │ └── prismaStore.ts # MỚI — database-backed store
│ ├── types/
│ │ └── url.ts
│ ├── utils/
│ │ └── shortCode.ts
│ ├── app.ts # CẬP NHẬT — health check với DB
│ └── index.ts # CẬP NHẬT — graceful shutdown
├── docker-compose.yml # MỚI — PostgreSQL
├── prisma.config.ts # MỚI — cấu hình Prisma 7
├── .env # CẬP NHẬT — DATABASE_URL
├── .env.example
├── .gitignore
├── package.json # CẬP NHẬT — db script + dependencies
└── tsconfig.jsonThay Đổi Gì So Với Post #2
| File | Thay đổi | Lý do |
|---|---|---|
docker-compose.yml | Mới | Chạy PostgreSQL local |
prisma/schema.prisma | Mới | Định nghĩa database schema |
prisma.config.ts | Mới | Cấu hình Prisma 7 (auto-generated) |
src/lib/prisma.ts | Mới | Prisma client singleton với pg adapter |
src/store/prismaStore.ts | Mới | Database-backed store |
src/services/urlService.ts | Cập nhật | Dùng prismaStore thay memoryStore |
src/app.ts | Cập nhật | Health check ping database |
src/index.ts | Cập nhật | Graceful Prisma disconnect |
.env | Cập nhật | Thêm DATABASE_URL |
package.json | Cập nhật | Thêm db script, adapter-pg và postinstall |
Tiếp Theo?
Chúng ta đã có persistent storage — URL sống sót qua restart, click được đếm atomically, và schema được version-control. Nhưng short code generation vẫn còn cơ bản. Trong Post #4, chúng ta sẽ xử lý:
- Base62 encoding vs nanoid — approach nào dùng khi nào
- Collision detection và resolution strategy
- Custom alias validation và reserved word
- Guaranteed uniqueness với database constraint
Short code generator là trái tim của URL shortener, và làm đúng nó rất quan trọng cho scale.
Series: Build a URL Shortener
Previous: Phase 1: Setup Project & URL Shortening API
Next: Phase 3: Short Code Generation
📬 Đă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.