大規模負荷対応URL短縮サービス設計

1. 概要

URL短縮は、なじみのあるシステム設計問題です。ロジックは単純で、短いコードを長いURLにマップするだけです。難しいのはスケール面です:毎秒数万件のリダイレクト、突発的に「バイラル」化するリンク、そして決してコードが重複しないという要件です。

この記事では全体的なプロセスを取り上げます:要件の確定、負荷見積もり、データモデル、API、コード生成方法、キャッシング、そして難しい点です。読み終わったら、自分で構築するか面接に持っていくことができるSpring Boot 3を使ったフレームワークと実装コードが手に入ります。

2. 問題要件

2.1. 機能要件

  • 長いURLから短いリンクを作成します。ユーザーはエイリアスを自分で設定できます、例えば /sale-2026。
  • 短いリンクにアクセスすると、元のURLにリダイレクトされます。
  • リンクは有効期限(expires_at)を持つことができ、所有者によって無効化できます。
  • リンク別のクリック数を統計します。統計は数秒の遅延を許容します。

2.2. スケール

毎月1億個の新しいリンクを想定し、読み取り/書き込み比率は100:1です。

書き込み:   100M / (30 * 86400s)  ≈ 40 リンク/秒   (ピーク ×10 ≈ 400/秒)
読み取り:   40 × 100              ≈ 4,000 rps  (ピーク ×10 ≈ 40,000 rps)
5年間のストレージ:60億リンク × ~500バイト ≈ 3 TB
7文字base62コード空間:62^7 ≈ 3.5兆  (60億には十分)

これらの数字から2つの結論が出ます。まず、システムは読み取り中心なので、キャッシュが設計の中心です。次に、書き込み負荷が低いので、1つのPostgreSQL primaryで十分で、まだシャーディングは不要です。

2.3. 制約

  • リダイレクトのp99 < 20 ms。
  • 短いコードは一意で、順序から予測できません。これにより、他の人がプライベートなリンクを推測することは難しくなります。
  • Analyticsまたはkafkaに障害が発生した場合でも、システムはリダイレクトを続行できます。

3. データモデルとAPI

必要な依存関係(Spring Bootが管理するバージョン):

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>
</dependencies>

PostgreSQLスキーマ:

CREATE TABLE links (
    code        VARCHAR(16) PRIMARY KEY,
    long_url    TEXT        NOT NULL,
    owner_id    BIGINT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ,
    disabled    BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_links_owner ON links (owner_id, created_at DESC);

CREATE SEQUENCE link_id_seq START 1 INCREMENT BY 1000;

主な設計上の決定の説明:

  • codeは主キーです。最もホットなクエリは WHERE code = ? なので、これはprimary keyのインデックスに直接入ります。
  • idx_links_ownerは「私のリンク」画面に対応します。
  • link_id_seqは毎回1000ずつ増加します。nextvalを呼び出すたびに、アプリケーションは複数のID(セクション4.2)を受け取ります。

2つのメインAPI:

POST /api/links          {"url": "https://shop.vn/...", "alias": null}
  -> 201 {"code": "0bX3kQz", "shortUrl": "https://nonla.ly/0bX3kQz"}
  -> 409 エイリアスが既に存在する場合

GET  /{code}
  -> 302 Location: https://shop.vn/...
  -> 存在しない/有効期限切れ/無効化されている場合404

注意: 302を使用してください、301は使用しないでください。ブラウザは301を永続的にキャッシュします。その場合、クリック数を失い、広く流通してしまったリンクを無効化することもできません。

4. 短いコード生成

4.1. 戦略の選択

方法 利点 欠点
URLをハッシュ(MD5を7文字切り取り) 状態不要 重複の可能性、チェックと再試行が必要
ランダムな7文字 シンプル リンク数が多いほど重複しやすい、毎回DBをチェック
カウンター + base62 重複しない 順序が露出、一元化されたID供給源が必要

この記事は3番目の方法を選択し、2つの欠点に対処します。順序を露出させないためには、全単射(bijection)でIDをシャッフルします。一元化されたID供給源への依存を減らすために、IDをブロック単位で割り当てます。

import java.math.BigInteger;

public final class ShortCode {
    private static final String ALPHABET =
            "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final int LENGTH = 7;
    private static final BigInteger SPACE = BigInteger.valueOf(62).pow(LENGTH);
    // 奇数で31で割り切れない => 62^7と互いに素
    private static final BigInteger MULTIPLIER = BigInteger.valueOf(1_580_030_173L);

    private ShortCode() {}

    public static String of(long id) {
        if (id < 0 || id >= SPACE.longValue()) {
            throw new IllegalArgumentException("コード空間外のid:" + id);
        }
        long mixed = BigInteger.valueOf(id).multiply(MULTIPLIER).mod(SPACE).longValue();
        char[] out = new char[LENGTH];
        for (int i = LENGTH - 1; i >= 0; i--) {
            out[i] = ALPHABET.charAt((int) (mixed % 62));
            mixed /= 62;
        }
        return new String(out);
    }
}

動作方法:

  • モジュロ乗算 id × MULTIPLIER mod 62^7 は全単射です。MULTIPLIERは62^7と互いに素だからです。各IDは正確に1つのコードを生成し、2つの異なるIDが同じコードを生成することはありません。
  • ループは数値をbase62に変換し、左側をパディングするため、コードは常に正確に7文字です。
  • ID 1とID 2は2番目の文字から異なるコードを生成するため、他の人が次のリンクを推測することは困難です。
  • BigIntegerは乗算時にlongのオーバーフローを回避します。400書き込み/秒の負荷では、このコストは無視できます。

この方法はコードを予測しにくくするだけで、暗号化ではありません。MULTIPLIERを知っている人なら逆算できます。本当にプライベートなリンクには、追加の認証メカニズムが必要です。

4.2. ブロック単位のID割り当て

毎回DBからIDを取得するとprimaryがボトルネックになります。代わりに、各インスタンスは1000のIDのブロックを取得し、メモリ内で徐々に配布します:

import java.util.concurrent.locks.ReentrantLock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

@Component
public class IdBlockAllocator {
    private static final int BLOCK_SIZE = 1000; // INCREMENT BYと一致する必要あり
    private final JdbcTemplate jdbc;
    private final ReentrantLock lock = new ReentrantLock();
    private long next;
    private long end;

    public IdBlockAllocator(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public long nextId() {
        lock.lock();
        try {
            if (next >= end) {
                Long start = jdbc.queryForObject("SELECT nextval('link_id_seq')", Long.class);
                next = start;
                end = start + BLOCK_SIZE;
            }
            return next++;
        } finally {
            lock.unlock();
        }
    }
}

注意すべき点:

  • nextvalはアトミックな操作です。2つのインスタンスが同じブロックを取得することはないため、分散ロックは不要です。
  • 400書き込み/秒では、各インスタンスは数秒ごとにDBに触れるだけです。
  • コードはsynchronizedの代わりにReentrantLockを使用します。Java 21では、virtual threadはI/O待機中にsynchronizedブロック内でcarrier threadに「ピン止め」されます。
  • インスタンスのリスタートはブロックの残りを無駄にします。コード空間には3.5兆の値があるため、これは許容できます。

5. リンク作成フロー

import java.net.URI;
import java.time.Duration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class LinkWriteService {
    static final String KEY = "link:";
    private final JdbcTemplate jdbc;
    private final StringRedisTemplate redis;
    private final IdBlockAllocator allocator;

    public LinkWriteService(JdbcTemplate jdbc, StringRedisTemplate redis, IdBlockAllocator allocator) {
        this.jdbc = jdbc;
        this.redis = redis;
        this.allocator = allocator;
    }

    public String create(String longUrl, String alias, Long ownerId) {
        String scheme = URI.create(longUrl).getScheme();
        if (!"http".equals(scheme) && !"https".equals(scheme)) {
            throw new IllegalArgumentException("http/httpsのみ受け付けます");
        }
        String code = (alias != null) ? alias : ShortCode.of(allocator.nextId());
        try {
            jdbc.update("INSERT INTO links(code, long_url, owner_id) VALUES (?, ?, ?)",
                    code, longUrl, ownerId);
        } catch (DuplicateKeyException e) {
            throw new AliasTakenException(code);
        }
        redis.opsForValue().set(KEY + code, longUrl, Duration.ofHours(24));
        return code;
    }
}

class AliasTakenException extends RuntimeException {
    AliasTakenException(String code) { super("エイリアスが既に存在します:" + code); }
}

処理フローは4つのステップで構成されています:

  1. schemeをチェックしてjavascript:またはfile:をブロックします。このステップをスキップすると、サービスが有害なリンクを配布するツールになります。
  2. コードを取得します:ユーザーが設定したエイリアス、または自動生成されたコード。
  3. INSERT。主キー制約は重複コードに対する最後の防御層です。
  4. Redisにも書き込みます(write-through)。これにより、最初のリダイレクトはDBに行く必要がありません。

本番環境では、ユーザーが設定したエイリアスは、自動生成されたコードと異なる文字またはより長い長さを使用する必要があります。例えば、-を必須にするか、長さを最低8文字にします。そうしないと、ユーザーのエイリアスが、後で生成ツールが作成するコードを「奪う」可能性があります。その場合、生成ツールはDuplicateKeyExceptionに遭遇しますが、その目的は決して重複しないことです。

6. リダイレクトフローと多層キャッシング

ピーク時の40,000 rpsでは、ほとんどのリクエストがDBに触れずに処理される必要があります。3つの層を使用します:JVM内のCaffeine、共有Redis、その後PostgreSQL。

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class LinkReadService {
    private static final String NOT_FOUND = ""; // 見つからないキャッシュのsentinel
    private final Cache<String, String> local = Caffeine.newBuilder()
            .maximumSize(100_000)
            .expireAfterWrite(Duration.ofSeconds(30))
            .build();
    private final JdbcTemplate jdbc;
    private final StringRedisTemplate redis;

    public LinkReadService(JdbcTemplate jdbc, StringRedisTemplate redis) {
        this.jdbc = jdbc;
        this.redis = redis;
    }

    public Optional<String> resolve(String code) {
        String url = local.get(code, this::loadFromRedisOrDb);
        return url.isEmpty() ? Optional.empty() : Optional.of(url);
    }

    private String loadFromRedisOrDb(String code) {
        String key = LinkWriteService.KEY + code;
        String cached = redis.opsForValue().get(key);
        if (cached != null) return cached;

        List<String> rows = jdbc.queryForList("""
                SELECT long_url FROM links
                WHERE code = ? AND NOT disabled
                  AND (expires_at IS NULL OR expires_at > now())""", String.class, code);
        String url = rows.isEmpty() ? NOT_FOUND : rows.getFirst();
        redis.opsForValue().set(key, url, url.isEmpty() ? Duration.ofMinutes(5) : Duration.ofHours(24));
        return url;
    }
}

重要なポイント:

  • Caffeineのlocal.get(code, loader)は同じキーに対する同時ロードをマージします。5,000リクエストが同じキャッシュされていないコードに対して問い合わせても、1つのスレッドだけがRedis/DBに降りていきます。他のスレッドはその結果を待ちます。これはインスタンス内でのcache stampede対策です。
  • Negative caching:存在しないコードも5分間、空の文字列としてキャッシュされます。これにより、ランダムなコードをスキャンするボットはDBを圧倒できません。
  • Caffeineはnull値をキャッシュしないため、コードはsentinel ``をnullの代わりに使用します。

Controllerは302を返し、クリックイベントをKafkaに送信します:

import java.net.URI;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.web.bind.annotation.*;

@RestController
public class LinkController {
    private final LinkWriteService writer;
    private final LinkReadService reader;
    private final KafkaTemplate<String, String> kafka;

    public LinkController(LinkWriteService writer, LinkReadService reader,
                          KafkaTemplate<String, String> kafka) {
        this.writer = writer;
        this.reader = reader;
        this.kafka = kafka;
    }

    public record CreateLinkRequest(String url, String alias) {}

    @PostMapping("/api/links")
    public ResponseEntity<Map<String, String>> create(@RequestBody CreateLinkRequest req) {
        String code = writer.create(req.url(), req.alias(), null);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("code", code, "shortUrl", "https://nonla.ly/" + code));
    }

    @GetMapping("/{code:[0-9a-zA-Z-]{1,16}}")
    public ResponseEntity<Void> redirect(@PathVariable String code) {
        return reader.resolve(code)
                .map(url -> {
                    kafka.send("link-clicks", code, String.valueOf(System.currentTimeMillis()));
                    return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).<Void>build();
                })
                .orElse(ResponseEntity.notFound().build());
    }

    @ExceptionHandler(AliasTakenException.class)
    ResponseEntity<String> conflict(AliasTakenException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
    }
}

kafka.sendは非同期なので、リダイレクトはbrokerを待つ必要がありません。別のconsumerがコードごとのイベントを数秒ごとにまとめて、統計テーブルにバッチ書き込みします。producerの設定は低遅延を優先します:

spring.kafka.producer.acks=1
spring.kafka.producer.properties.linger.ms=20
spring.kafka.producer.properties.max.block.ms=50

max.block.ms=50はbrokerが障害を起こした場合、リダイレクトスレッドが長時間凍結するのを防ぎます。これは「analyticsが失敗してもリダイレクトが動く」という要件と一致します。その場合、クリックイベントを少し失うことを許容します。

7. 難しい点と解決策

7.1. 競合

  • 2人が同じエイリアスを設定: SELECTしてからINSERTしないでください。2つのリクエストは「まだ存在しない」と同時に見える可能性があり、その後両方が書き込みます。DBの一意制約に決定させ、セクション5のようにDuplicateKeyExceptionをキャッチしてください。
  • 複数のインスタンスが同じコードを生成: sequenceは各インスタンスにブロックを割り当てるため、競合はありません。

7.2. 整合性

  • 作成直後に読み取り: readレプリカから読み取ると、レプリケーション遅延により新しく作成されたリンクが404を返すことがあります。作成時にRedisに即座に書き込む(write-through)はこの遅延を隠します。
  • リンクの無効化: DBを更新してからRedisキーを削除します。Caffeineは各インスタンスで最大30秒間古いバージョンを保持します。詐欺的なリンク例など、即座に遮断する必要がある場合は、Redis Pub/Subまたはkafkaで無効化イベントを発行して、各インスタンスのローカルキャッシュをクリアしてください。
  • 有効期限切れ: キャッシュのTTLはmin(24h, expires_at - now)であるべきです。そうでないと、有効期限切れのリンクが最大24時間追加でリダイレクトします。

7.3. トラフィック急増

  • ホットキー: バイラル化したリンクはRedisの1つのシャードに全トラフィックを集約します。ローカルCaffeineはこの負荷のほとんどを吸収します。各インスタンスは各キーに対してRedisに1回、30秒ごとにしか問い合わせません。
  • コールドスタート: 新しいインスタンスはCaffeineに何も持っていませんが、セクション6のロードマージ機能により、Redisが同時リクエストの波で圧倒されることはありません。
  • リンク作成スパム: APIキーまたはIPあたりのレート制限、例えばRedis上のtoken bucketを使用してからLinkWriteServiceを呼び出してください。

8. コード生成動作テスト

システムの最も重要な特性は、コードが一意で、固定長で、順序を露出させないことです:

import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.Set;
import java.util.stream.LongStream;
import org.junit.jupiter.api.Test;

class ShortCodeTest {

    @Test
    void generateCodeWithCorrectLengthAndAlphabet() {
        assertThat(ShortCode.of(1)).hasSize(7).matches("[0-9a-zA-Z]{7}");
    }

    @Test
    void noCollisionAmong100kConsecutiveIds() {
        Set<String> codes = LongStream.range(0, 100_000).mapToObj(ShortCode::of).collect(toSet());
        assertThat(codes).hasSize(100_000);
    }

    @Test
    void consecutiveIdsDontProduceConsecutiveCodes() {
        assertThat(ShortCode.of(1).substring(0, 5)).isNotEqualTo(ShortCode.of(2).substring(0, 5));
    }

    @Test
    void rejectIdOutsideCodeSpace() {
        assertThatThrownBy(() -> ShortCode.of(-1)).isInstanceOf(IllegalArgumentException.class);
    }
}

2番目のテストはサンプルで検証します。全単射性を証明することはできません。全単射性は数学から来ます(gcdが1)。テストは、誰かが誤ってMULTIPLIERを偶数に変更するのを保護するだけです。

9. よくあるミスとシンプルにしておくべき時

注意: リリース後にMULTIPLIERまたはALPHABETを変更すると、新しく生成されたIDが古いコードと重複するコードを生成します。これらを永久定数と見なしてください。

  • クリックを直接linksテーブルに書き込む(UPDATE ... SET clicks = clicks + 1)とホットキーでrow lockが発生します。Kafkaを経由で集約してください。
  • Caffeineのサイズを制限しないと、ランダムなコードをスキャンされる場合にOutOfMemoryErrorが発生します。
  • 1日に数百のリンクのみを持つ社内製品の場合、1つのPostgreSQLテーブルと1つのcontrollerで十分です。Redis、Kafka、ブロック単位のID割り当ては、実際の負荷が要求する場合のみ投資する価値があります。

10. 結論

大規模負荷対応URL短縮サービスの設計は3つの決定を中心に回ります。ブロック単位で割り当てられたカウンターと全単射を組み合わせたコード生成により、コードが一意で推測困難です。見つからないキャッシングとロードマージを含む多層キャッシングにより、ホットキーと無差別スキャンボットに対応します。クリック統計など不可欠でない作業は、Kafkaを通じてリダイレクト処理の外に出します。

スケール見積もりから始めてください。セクション2の数字が、最初の日からすべてを構築する代わりに、各層をいつ追加するかを教えてくれます。

system-designspring-bootrediskafkapostgresql