Back to blog

Build a URL Shortener: Database Design & Storage

typescriptnodejspostgresqlprismabackend
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?

FeaturePostgreSQLMySQLSQLite
ACID complianceĐầy đủĐầy đủĐầy đủ
JSON supportNative jsonb typeHạn chếKhông có
Full-text searchBuilt-in tsvectorCần pluginCơ bản
Concurrent writesMVCC (tuyệt vời)Lock-basedSingle writer
AnalyticsWindow functions, CTEsHạn chếCơ bản
ScalabilityXử lý hàng triệu row dễ dàngTốtDataset 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 volume postgres_data giữ data qua các lần container restart. Không có cái này thì data bay hết khi container dừng
  • healthcheck — 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 -d

Kiểm tra nó đang chạy:

docker compose ps
NAME                 STATUS              PORTS
url-shortener-db     Up (healthy)        0.0.0.0:5432->5432/tcp

Bạ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/urlshortener

Và update .env.example:

PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/urlshortener

Lưu ý bảo mật: Đừng bao giờ commit .env vào git. File .env.example dù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/pg

Bố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ới pg driver
  • @types/pg (dev dependency) — TypeScript types cho pg driver

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 init

Lệnh này tạo:

  • prisma/schema.prisma — file định nghĩa database schema
  • prisma.config.ts — file cấu hình Prisma (mới trong Prisma 7)
  • Update .env với DATABASE_URL placeholder (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:

  1. 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)
  2. 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_case

Cho 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:

IndexTăng tốc choDùng khi
shortCode (unique)WHERE short_code = ?Mỗi lần redirect (GET /:code)
idx_original_urlWHERE original_url = ?Check trùng lặp khi shorten
idx_created_atORDER BY created_at DESCLiệt kê URL mới nhất
idx_active_short_codeWHERE 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 BY và 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 init

Lệnh này làm ba việc:

  1. Generate SQL — tạo migration file trong prisma/migrations/
  2. Chạy SQL — apply migration vào database
  3. 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/client

Xem 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 đọc DATABASE_URL lú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 click

count 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 -d

Start dev server:

npm run dev

Test 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 .clickCount
1

Test 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   |           1

Connection 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

ParameterDefaultMô tả
max10Số connection tối đa trong pool
idleTimeoutMillis10000 (10s)Idle connection giữ mở bao lâu
connectionTimeoutMillis0 (không timeout)Chờ bao lâu để có connection trống
min0Số 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 option pool của adapter — URL parameter bị pg driver 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 + 1 là đ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 studio

Mở 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 validate

Update .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 deploy

Trong 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 deploy

db 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.json

Thay Đổi Gì So Với Post #2

FileThay đổiLý do
docker-compose.ymlMớiChạy PostgreSQL local
prisma/schema.prismaMớiĐịnh nghĩa database schema
prisma.config.tsMớiCấu hình Prisma 7 (auto-generated)
src/lib/prisma.tsMớiPrisma client singleton với pg adapter
src/store/prismaStore.tsMớiDatabase-backed store
src/services/urlService.tsCập nhậtDùng prismaStore thay memoryStore
src/app.tsCập nhậtHealth check ping database
src/index.tsCập nhậtGraceful Prisma disconnect
.envCập nhậtThêm DATABASE_URL
package.jsonCập nhậtThê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.