如何设计一个高并发短链接服务(如 bit.ly)?
引言:
在社交媒体营销、短信推广等场景中,短链接服务已成为互联网基础设施的关键组件。全球每天有数十亿短链接被创建,如Bitly、TinyURL等服务每天处理数十亿请求。
作为一名拥有 8年经验的Java架构师 ,我曾主导设计过日处理千万级短链接的系统。今天我将从原理到实现,深度解析如何构建一个高性能、高可用、可扩展的短链接服务。
一、业务场景与技术挑战
1.1 核心业务需求
- 短链接生成 :将长URL转换为短码(如
bit.ly/3xY8zK1
) - 短链接跳转:通过短码重定向到原始URL
- 访问统计:记录点击量、来源、设备等信息
- 链接管理:设置有效期、访问密码、API接入等
1.2 技术挑战
挑战维度 | 具体问题 |
---|---|
高并发 | 瞬时生成/跳转请求峰值可达10万QPS |
低延迟 | 跳转操作需在50ms内完成 |
海量数据存储 | 百亿级URL关系存储与快速检索 |
短码碰撞 | 避免不同长URL生成相同短码 |
防恶意攻击 | 防止短码遍历、刷量等攻击行为 |
二、架构设计核心思路
2.1 整体架构
markdown
客户端 → API网关 → 生成服务 → Redis缓存 → MySQL分库
↓ ↓
→ 跳转服务 → 埋点统计 → Kafka → 数仓
2.2 核心组件设计
✅ 短码生成方案对比
方案 | 优点 | 缺点 |
---|---|---|
哈希算法 | 生成速度快 | 存在碰撞风险 |
自增ID+进制转换 | 无碰撞、长度可控 | 需分布式ID生成器 |
预生成号池 | 高性能、零延迟 | 需提前分配、浪费风险 |
推荐方案:分布式ID生成器(Snowflake变体)+ Base62编码
✅ 存储方案选型
arduino
// 短码-长URL映射存储
public class UrlMapping {
private String shortCode; // 短码(主键)
private String originUrl; // 原始URL
private long createTime; // 创建时间
private int expireDays; // 过期天数
private int clickCount; // 点击统计
}
存储策略:
- Redis:存储热点映射(TTL自动过期)
- MySQL:持久化全量数据(分库分表)
- 布隆过滤器:快速判断短码是否存在
三、核心实现细节
3.1 短码生成算法
arduino
/**
* 基于Snowflake的分布式ID生成器
*/
public class ShortCodeGenerator {
// 组成:时间戳(41bit) + 机器ID(10bit) + 序列号(12bit)
private static final long EPOCH = 1672531200000L; // 2023-01-01
public String generate(String longUrl) {
long id = snowflake.nextId(); // 获取分布式ID
return base62Encode(id);
}
// Base62编码(0-9a-zA-Z)
private String base62Encode(long num) {
char[] map = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
StringBuilder sb = new StringBuilder();
while (num > 0) {
sb.append(map[(int)(num % 62)]);
num /= 62;
}
return sb.reverse().toString(); // 如 "3xY8zK1"
}
}
3.2 跳转服务核心逻辑
scss
@RestController
public class RedirectController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UrlMappingRepository repository;
/**
* 短链接跳转(千万级QPS核心路径)
*/
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(
@PathVariable String shortCode,
HttpServletRequest request) {
// 1. 从Redis查询缓存
String originUrl = redisTemplate.opsForValue().get(shortCode);
// 2. 缓存未命中查询数据库
if (originUrl == null) {
UrlMapping mapping = repository.findByShortCode(shortCode);
if (mapping == null) return ResponseEntity.notFound().build();
originUrl = mapping.getOriginUrl();
// 写入缓存(设置TTL)
redisTemplate.opsForValue().set(shortCode, originUrl, 24, TimeUnit.HOURS);
}
// 3. 异步记录访问日志(非阻塞)
CompletableFuture.runAsync(() ->
trackAccess(shortCode, request)
);
// 4. 返回302重定向
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(originUrl))
.build();
}
// 访问统计埋点
private void trackAccess(String shortCode, HttpServletRequest request) {
AccessLog log = new AccessLog();
log.setShortCode(shortCode);
log.setIp(request.getRemoteAddr());
log.setUserAgent(request.getHeader("User-Agent"));
log.setReferer(request.getHeader("Referer"));
log.setAccessTime(System.currentTimeMillis());
// 发送到Kafka
kafkaTemplate.send("access_log", log);
}
}
3.3 防攻击策略
arduino
/**
* 短链安全防护
*/
@Service
public class SecurityService {
// 布隆过滤器(10亿数据,误判率0.1%)
private BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), 1_000_000_000, 0.001);
/**
* 校验短码合法性
*/
public boolean isValidCode(String shortCode) {
// 1. 长度校验(6-8字符)
if (shortCode.length() < 6 || shortCode.length() > 8)
return false;
// 2. 布隆过滤器校验
if (!bloomFilter.mightContain(shortCode))
return false;
// 3. 频率限制(Redis计数器)
String key = "rate_limit:" + shortCode;
Long count = redisTemplate.opsForValue().increment(key, 1);
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
return count <= 100; // 每分钟最多100次访问
}
}
四、性能优化关键点
4.1 缓存策略优化
缓存技巧:
- 热点数据:二级缓存(Caffeine+Redis)
- 缓存穿透:布隆过滤器+空值缓存
- 缓存雪崩:随机TTL(24h ± 2h)
4.2 MySQL分库分表
sql
-- 按短码哈希分表(1024张表)
CREATE TABLE url_mapping_1023 (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
short_code VARCHAR(8) NOT NULL COMMENT '短码',
origin_url VARCHAR(2048) NOT NULL COMMENT '原始URL',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_short_code (short_code) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分片策略:
shard = CRC32(short_code) % 1024
4.3 跳转服务优化
- 零拷贝响应:直接返回HTTP 302避免序列化开销
- 异步日志:访问日志通过Disruptor队列异步处理
- 连接池优化:使用HikariCP配置千级数据库连接池
- CDN边缘计算:全球部署跳转节点减少延迟
五、扩展功能实现
5.1 访问统计看板
typescript
// 基于Flink的实时统计
public class AccessStatsJob {
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.addSource(new FlinkKafkaConsumer<>("access_log", new AccessLogDeserializer(), props))
.keyBy(AccessLog::getShortCode)
.window(TumblingProcessingTimeWindows.of(Time.minutes(5)))
.aggregate(new StatsAggregator())
.addSink(new RedisSink());
}
// 聚合统计
private static class StatsAggregator implements AggregateFunction<AccessLog, StatsAcc, StatsResult> {
public StatsAcc createAccumulator() {
return new StatsAcc();
}
public StatsAcc add(AccessLog log, StatsAcc acc) {
acc.count++;
// 按设备/地区/来源统计
return acc;
}
public StatsResult getResult(StatsAcc acc) {
return new StatsResult(acc);
}
}
}
5.2 管理功能实现
API示例:
less
@PostMapping("/create")
public ApiResponse createUrl(@RequestBody CreateRequest request) {
// 1. URL规范化处理
String normalizedUrl = UrlUtils.normalize(request.getUrl());
// 2. 检查是否已存在(防重复生成)
String existCode = repository.findByOriginUrl(normalizedUrl);
if (existCode != null) {
return ApiResponse.success(existCode);
}
// 3. 生成短码并存储
String shortCode = generator.generate(normalizedUrl);
repository.save(new UrlMapping(shortCode, normalizedUrl));
return ApiResponse.success(shortCode);
}
六、生产环境最佳实践
6.1 监控指标
指标类型 | 监控项 | 报警阈值 |
---|---|---|
性能 | 跳转P99延迟 | >100ms |
可用性 | 5xx错误率 | >0.1% |
数据一致性 | 缓存-数据库不一致率 | >0.01% |
6.2 灾难恢复方案
-
多级备份:
- Redis:AOF持久化+跨机房复制
- MySQL:每日全量备份+Binlog增量
-
降级策略:
- 缓存故障时直接读数据库
- 数据库故障时返回静态页
-
流量调度:DNS故障转移+多AZ部署
架构师思考:
短链接服务看似简单,实则处处暗藏玄机:
算法设计:如何在碰撞概率与性能间取得平衡?
存储优化:百亿级数据如何做到毫秒级查询?
高并发挑战:如何设计无状态服务应对流量洪峰?