高性能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); }
}
处理流程包括四个步骤:
- 检查scheme来阻止
javascript:或file:。跳过这步,你的服务就成了恶意链接传播工具。 - 获取码:使用自定义别名或从ID生成。
INSERT。主键约束是防止码重复的最后一道防线。- 立即写入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节的数字会告诉你何时需要添加每一层,而不是从一开始就构建完整架构。