大規模負荷対応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つのステップで構成されています:
- schemeをチェックして
javascript:またはfile:をブロックします。このステップをスキップすると、サービスが有害なリンクを配布するツールになります。 - コードを取得します:ユーザーが設定したエイリアス、または自動生成されたコード。
INSERT。主キー制約は重複コードに対する最後の防御層です。- 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の数字が、最初の日からすべてを構築する代わりに、各層をいつ追加するかを教えてくれます。