高性能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 链接/s   (峰值 ×10 ≈ 400/s)
读:   40 × 100              ≈ 4,000 rps  (峰值 ×10 ≈ 40,000 rps)
存储5年:60亿条链接 × ~500字节 ≈ 3TB
7字符base62空间:62^7 ≈ 3.5万亿  (足够60亿条)

从这些数字得出两个结论。第一,系统是读多写少的,所以缓存是设计的核心。第二,写入负载低,一个PostgreSQL主节点就够了,暂时不需要分片。

2.3. 约束条件

  • 重定向的p99延迟 < 20ms。
  • 短码不能重复、不能通过顺序猜测,防止他人枚举私有链接。
  • 分析系统或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 schema:

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 = ?,由主键索引直接支撑。
  • idx_links_owner 用于“我的链接”页面。
  • link_id_seq 每次增长1000。每次调用 nextval,应用获得整块ID(见第4.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源

本文采用第三种方法,并克服它的两个缺点。为了不暴露顺序,用双射(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映射到唯一的码,两个不同ID永不产生相同的码。
  • 循环将数转换为base62并左填充,确保码长度为7。
  • ID 1和ID 2从第二个字符就产生不同的码,所以外人很难猜测下一个链接。
  • BigInteger 避免乘法时 long 溢出。在400写/s的负载下,这个开销可以忽略。
  • 这种方法只是让码难以预测,不是真正的加密。任何知道 MULTIPLIER 的人都能反推。真正私密的链接需要额外的认证机制。

4.2. 分块分配ID

每个ID都查DB会让主节点成为瓶颈。相反,每个实例从数据库取一块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 是原子操作。两个实例永不获得同一块ID,所以不需要分布式锁。
  • 在400写/s下,每个实例每隔几秒才访问一次DB。
  • 代码使用 ReentrantLock 而不是 synchronized。在Java 21上,虚拟线程在 synchronized 块内等待I/O时会被“钉住”到carrier thread。
  • 实例重启会浪费块中的剩余ID。编码空间有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); }
}

处理流程包括四个步骤:

  1. 检查scheme来阻止 javascript: 或 file:。跳过这步,你的服务就成了恶意链接传播工具。
  2. 获取码:使用自定义别名或从ID生成。
  3. INSERT。主键约束是防止码重复的最后一道防线。
  4. 立即写入Redis(write-through),让第一次重定向不需要访问DB。

在生产环境中,自定义别名应该使用不同的字符或长度与自动生成的码区别,例如强制包含 - 或长度至少8个字符。否则,用户别名可能会“抢占”生成器之后会创建的码。这样生成器会遇到 DuplicateKeyException,违反了它永不重复的目标。

6. 重定向流程与多层缓存

在峰值40,000 rps下,大多数请求都要不访问DB直接返回。使用三层: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 = ""; // 负缓存的哨兵值
    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) 会合并对同一key的并发加载。如果5,000个请求同时查询一个不在缓存中的码,只有一个线程会访问Redis/DB。其他线程等待那个结果。这就是在实例内防止缓存雪崩的方法。
  • 负缓存:不存在的码也被缓存为空字符串5分钟。这样随机枚举的bot就无法压垮DB。
  • Caffeine不缓存 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按 code 聚合事件,每隔几秒批量写入统计表。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故障时重定向线程长时间阻塞,符合“分析系统故障时重定向仍能工作”的约束。在这种情况下接受丢失部分点击事件。

7. 难点与解决方案

7.1. 竞争条件

  • 两个人同时设置同一个别名: 不要先 SELECT 再 INSERT。两个请求可能都看到“不存在”然后都尝试写。让DB的唯一约束决定胜负,捕获 DuplicateKeyException(如第5节)。
  • 多个实例同时生成码: sequence为每个实例分配独立块,所以没有竞争。

7.2. 一致性

  • 创建后立即读取: 如果从read replica读取,复制延迟可能导致新创建的链接返回404。创建时立即写入Redis(write-through)掩盖了这个延迟。
  • 禁用链接: 更新DB然后删除Redis key。Caffeine仍会在每个实例上保留旧值最多30秒。如果需要立即阻止(如欺诈链接),通过Redis Pub/Sub或Kafka发布失效事件,让各实例清除本地缓存。
  • 过期: 缓存TTL应该是 min(24h, expires_at - now)。否则,已过期的链接还会再重定向最多24小时。

7.3. 突发流量

  • 热key: 一个热点链接会让所有流量集中到一个Redis shard。本地Caffeine吸收了大部分流量。每个实例每30秒左右才查Redis一次同一个key。
  • 冷启动: 新实例Caffeine里什么都没有,但第6节的合并加载功能防止了Redis被请求淹没。
  • 链接创建滥用: 按API key或IP限流,例如使用Redis上的令牌桶,在调用 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 生成正确长度和字母表的码() {
        assertThat(ShortCode.of(1)).hasSize(7).matches("[0-9a-zA-Z]{7}");
    }

    @Test
    void 十万连续ID中无重复() {
        Set<String> codes = LongStream.range(0, 100_000).mapToObj(ShortCode::of).collect(toSet());
        assertThat(codes).hasSize(100_000);
    }

    @Test
    void 连续ID不产生连续码() {
        assertThat(ShortCode.of(1).substring(0, 5)).isNotEqualTo(ShortCode.of(2).substring(0, 5));
    }

    @Test
    void 拒绝超出编码空间的ID() {
        assertThatThrownBy(() -> ShortCode.of(-1)).isInstanceOf(IllegalArgumentException.class);
    }
}

第二个测试只是采样检查,无法证明双射性。双射性来自数学(gcd为1)。测试只是防止有人不小心把 MULTIPLIER 改成偶数。

9. 常见错误与何时不该过度设计

注意: 发布后修改 MULTIPLIER 或 ALPHABET 会导致新ID生成的码与旧码重复。把它们视为永久常数。

  • 每次点击直接写入 links 表(UPDATE ... SET clicks = clicks + 1)会在热点链接处产生行锁。应该通过Kafka聚合。
  • Caffeine没有大小限制会在遭遇大规模随机枚举时导致 OutOfMemoryError。
  • 对于日均仅几百条链接的内部产品,一个PostgreSQL表和一个controller就够了。Redis、Kafka和ID分块仅当实际负载需要时才值得投入。

10. 总结

高性能URL缩短服务的设计围绕三个关键决策。通过分块分配的计数器加双射生成短码,既不重复又难以预测。使用多层缓存加负缓存和合并加载,抵御热key和码枚举bot。通过Kafka将非关键操作(如点击统计)推出重定向路径。

从规模评估开始。第2节的数字会告诉你何时需要添加每一层,而不是从一开始就构建完整架构。

system-designspring-bootrediskafkapostgresql