Zum Inhalt springen

Einen URL-Shortener von Grund auf selbst bauen

Schritt-für-Schritt zum URL-Shortener mit Kurzcode-Generierung, Redirect-Handling, Klick-Analytik und Rate-Limiting — vom Datenbankschema bis zur API.

5 Min. Lesezeit
Architekturdiagramm eines URL-Shorteners mit dem Encode-, Speicher- und Redirect-Ablauf

Ein URL-Shortener ist eine der besten Systemdesign-Übungen, weil er simpel aussieht — eine lange URL rein, eine kurze raus —, die Implementierung aber Hashing, Datenbankdesign, Caching, Analytik und Rate-Limiting berührt. Wer einen von Grund auf baut, lernt mehr über Backend-Engineering als in den meisten Tutorials.

Wir bauen einen URL-Shortener, der Kurzcodes generiert, Redirects verarbeitet, Klick-Analytik erfasst und Rate-Limits durchsetzt. Der Stack ist Node.js mit TypeScript, aber die Muster gelten für jede Sprache.

Datenbankschema

Das zentrale Datenmodell ist unkompliziert: ein Mapping von Kurzcode zur Original-URL, plus Metadaten für Analytik und Ablaufdatum.

sqlsql
-- Core URL mapping table
CREATE TABLE urls (
  id            BIGSERIAL PRIMARY KEY,
  short_code    VARCHAR(10) UNIQUE NOT NULL,
  original_url  TEXT NOT NULL,
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  expires_at    TIMESTAMPTZ,
  click_count   BIGINT DEFAULT 0,
  creator_ip    INET,
  is_active     BOOLEAN DEFAULT TRUE
);
 
-- Index for fast lookups by short code (the hot path)
CREATE INDEX idx_urls_short_code ON urls (short_code)
  WHERE is_active = TRUE;
 
-- Click analytics table
CREATE TABLE clicks (
  id          BIGSERIAL PRIMARY KEY,
  url_id      BIGINT REFERENCES urls(id),
  clicked_at  TIMESTAMPTZ DEFAULT NOW(),
  referrer    TEXT,
  user_agent  TEXT,
  country     VARCHAR(2)
);
 
-- Index for analytics queries
CREATE INDEX idx_clicks_url_id ON clicks (url_id);
CREATE INDEX idx_clicks_clicked_at ON clicks (clicked_at);

Generierung der Kurzcodes

Der Kurzcode ist der Kern des Systems. Er muss kurz (6-8 Zeichen), eindeutig und URL-sicher sein. Es gibt zwei Hauptansätze: zählerbasierte Kodierung und Zufallsgenerierung.

tstypescript
import crypto from 'crypto';
 
const BASE62_CHARS =
  '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
 
// Approach 1: Counter-based (deterministic, no collisions)
function encodeBase62(num: number): string {
  if (num === 0) return BASE62_CHARS[0];
 
  let result = '';
  let value = num;
 
  while (value > 0) {
    result = BASE62_CHARS[value % 62] + result;
    value = Math.floor(value / 62);
  }
 
  return result;
}
 
// Approach 2: Random generation (needs collision check)
function generateRandomCode(length: number = 7): string {
  const bytes = crypto.randomBytes(length);
  let result = '';
 
  for (let i = 0; i < length; i++) {
    result += BASE62_CHARS[bytes[i] % 62];
  }
 
  return result;
}
tstypescript
// ❌ Using MD5/SHA hash of the URL — wasteful and collision-prone
function badShortCode(url: string): string {
  const hash = crypto.createHash('md5').update(url).digest('hex');
  return hash.substring(0, 7); // Truncating a hash increases collision risk
}
 
// ✅ Using counter-based encoding — guaranteed unique, predictable
async function generateShortCode(db: Database): Promise<string> {
  // Use database sequence for guaranteed uniqueness
  const result = await db.query(
    "SELECT nextval('url_id_seq') AS id"
  );
  const id = Number(result.rows[0].id);
 
  // Offset to avoid short codes like "0", "1", "a"
  // Starting at 100_000_000 gives 7-character codes
  return encodeBase62(id + 100_000_000);
}

Endpoint zur URL-Erstellung

Der Erstellungs-Endpoint validiert die URL, generiert einen Kurzcode, speichert das Mapping und gibt die gekürzte URL zurück.

tstypescript
import { z } from 'zod';
 
const CreateUrlSchema = z.object({
  url: z.string().url().max(2048),
  expiresIn: z.number().min(3600).max(31536000).optional(), // 1h to 1yr in seconds
  customCode: z
    .string()
    .regex(/^[a-zA-Z0-9_-]+$/)
    .min(4)
    .max(20)
    .optional(),
});
 
async function createShortUrl(
  req: Request,
  db: Database,
  config: AppConfig
): Promise<Response> {
  const body = CreateUrlSchema.parse(await req.json());
 
  // Check if custom code is already taken
  if (body.customCode) {
    const existing = await db.query(
      'SELECT id FROM urls WHERE short_code = $1',
      [body.customCode]
    );
    if (existing.rows.length > 0) {
      return new Response(
        JSON.stringify({ error: 'Custom code already in use' }),
        { status: 409 }
      );
    }
  }
 
  const shortCode = body.customCode ?? await generateShortCode(db);
  const expiresAt = body.expiresIn
    ? new Date(Date.now() + body.expiresIn * 1000)
    : null;
 
  await db.query(
    `INSERT INTO urls (short_code, original_url, expires_at, creator_ip)
     VALUES ($1, $2, $3, $4)`,
    [shortCode, body.url, expiresAt, getClientIp(req)]
  );
 
  return new Response(
    JSON.stringify({
      shortUrl: `${config.baseUrl}/${shortCode}`,
      shortCode,
      expiresAt,
    }),
    { status: 201 }
  );
}

Redirect-Handler

Der Redirect-Endpoint ist der Hot Path — jeder Klick läuft hier durch. Er muss schnell sein. Kurzcode nachschlagen, den Klick asynchron erfassen und weiterleiten.

tstypescript
async function handleRedirect(
  req: Request,
  shortCode: string,
  db: Database,
  cache: RedisClient
): Promise<Response> {
  // Check cache first (most short URLs are accessed repeatedly)
  let originalUrl = await cache.get(`url:${shortCode}`);
 
  if (!originalUrl) {
    // Cache miss — query the database
    const result = await db.query(
      `SELECT original_url, expires_at, is_active FROM urls
       WHERE short_code = $1`,
      [shortCode]
    );
 
    if (result.rows.length === 0) {
      return new Response('Not Found', { status: 404 });
    }
 
    const row = result.rows[0];
 
    if (!row.is_active) {
      return new Response('This link has been deactivated', { status: 410 });
    }
 
    if (row.expires_at && new Date(row.expires_at) < new Date()) {
      return new Response('This link has expired', { status: 410 });
    }
 
    originalUrl = row.original_url;
 
    // Cache for 1 hour
    await cache.set(`url:${shortCode}`, originalUrl, 'EX', 3600);
  }
 
  // Record click asynchronously — do not block the redirect
  recordClick(shortCode, req, db).catch((err) =>
    console.error('Failed to record click:', err)
  );
 
  return new Response(null, {
    status: 302,
    headers: { Location: originalUrl },
  });
}
 
async function recordClick(
  shortCode: string,
  req: Request,
  db: Database
): Promise<void> {
  const userAgent = req.headers.get('user-agent') ?? '';
  const referrer = req.headers.get('referer') ?? '';
 
  await db.query(
    `INSERT INTO clicks (url_id, user_agent, referrer)
     SELECT id, $2, $3 FROM urls WHERE short_code = $1`,
    [shortCode, userAgent, referrer]
  );
 
  // Increment the denormalized counter
  await db.query(
    'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
    [shortCode]
  );
}

Rate-Limiting

Ohne Rate-Limiting kann jeder den Erstellungs-Endpoint fluten und deinen Kurzcode-Raum erschöpfen. Ein Sliding-Window-Rate-Limiter mit Redis ist einfach und effektiv.

tstypescript
async function checkRateLimit(
  clientIp: string,
  cache: RedisClient,
  config: { maxRequests: number; windowSeconds: number }
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const key = `ratelimit:${clientIp}`;
  const now = Date.now();
  const windowStart = now - config.windowSeconds * 1000;
 
  // Use a sorted set with timestamps as scores
  const pipeline = cache.pipeline();
  pipeline.zremrangebyscore(key, 0, windowStart); // Remove old entries
  pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`);
  pipeline.zcard(key); // Count entries in window
  pipeline.expire(key, config.windowSeconds); // Auto-cleanup
 
  const results = await pipeline.exec();
  const requestCount = results?.[2]?.[1] as number;
 
  return {
    allowed: requestCount <= config.maxRequests,
    remaining: Math.max(0, config.maxRequests - requestCount),
    resetAt: Math.ceil((windowStart + config.windowSeconds * 1000) / 1000),
  };
}
tstypescript
// ❌ No rate limiting — vulnerable to abuse
app.post('/api/shorten', async (req, res) => {
  const result = await createShortUrl(req, db, config);
  return res.json(result);
});
 
// ✅ Rate limited — 100 URLs per hour per IP
app.post('/api/shorten', async (req, res) => {
  const clientIp = getClientIp(req);
  const limit = await checkRateLimit(clientIp, redis, {
    maxRequests: 100,
    windowSeconds: 3600,
  });
 
  if (!limit.allowed) {
    res.set('Retry-After', String(limit.resetAt - Math.floor(Date.now() / 1000)));
    return res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: limit.resetAt,
    });
  }
 
  res.set('X-RateLimit-Remaining', String(limit.remaining));
  const result = await createShortUrl(req, db, config);
  return res.json(result);
});

Endpoint für Klick-Analytik

Basis-Analytik gibt Nutzern Einblick in die Performance ihrer Links. Aggregiere die Daten nach Zeiträumen, damit die Abfragen schnell bleiben.

tstypescript
async function getUrlAnalytics(
  shortCode: string,
  db: Database
): Promise<Analytics> {
  const [urlResult, dailyClicks, topReferrers] = await Promise.all([
    db.query(
      'SELECT click_count, created_at FROM urls WHERE short_code = $1',
      [shortCode]
    ),
    db.query(
      `SELECT DATE(clicked_at) AS day, COUNT(*) AS clicks
       FROM clicks
       JOIN urls ON urls.id = clicks.url_id
       WHERE urls.short_code = $1
         AND clicked_at > NOW() - INTERVAL '30 days'
       GROUP BY DATE(clicked_at)
       ORDER BY day DESC`,
      [shortCode]
    ),
    db.query(
      `SELECT referrer, COUNT(*) AS clicks
       FROM clicks
       JOIN urls ON urls.id = clicks.url_id
       WHERE urls.short_code = $1
         AND referrer != ''
       GROUP BY referrer
       ORDER BY clicks DESC
       LIMIT 10`,
      [shortCode]
    ),
  ]);
 
  return {
    totalClicks: urlResult.rows[0]?.click_count ?? 0,
    createdAt: urlResult.rows[0]?.created_at,
    dailyClicks: dailyClicks.rows,
    topReferrers: topReferrers.rows,
  };
}

Die wichtigsten Erkenntnisse

  1. Zählerbasierte Kurzcodes sind einfacher als Zufallscodes — nutze eine Datenbank-Sequenz, um Eindeutigkeit ohne Kollisionsbehandlung zu garantieren
  2. Cache den Redirect-Pfad aggressiv — das ist der heißeste Pfad im System; jede Millisekunde zählt
  3. Erfasse Analytik asynchron — blockiere niemals einen Redirect, um einen Klick-Eintrag zu schreiben
  4. Rate-Limite den Erstellungs-Endpoint — ohne Limits sind dein Kurzcode-Raum und deine Datenbank anfällig für Missbrauch
  5. Validiere und bereinige URLs — weise offensichtlich bösartige oder fehlerhafte Eingaben an der Systemgrenze ab
  6. Verwende parametrisierte Queries für alle Datenbankoperationen — URL-Shortener verarbeiten nutzerseitige URLs, die alles Mögliche enthalten können
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX