短链系统设计详解
本章导读
短链服务是互联网基础设施之一,广泛应用于短信营销、社交媒体、二维码等场景。本章将从短链生成算法、存储方案、高并发优化、防刷机制等维度,系统性地剖析短链系统的设计要点,帮助你掌握高性能URL服务的设计方法。
学习目标:
- 目标1:掌握多种短链生成算法及其优缺点
- 目标2:理解短链系统的存储方案与缓存策略
- 目标3:能够设计高并发、高可用的短链服务
前置知识:熟悉分布式系统基础,了解Redis缓存、数据库设计
阅读时长:约 50 分钟
一、知识概述
短链服务(Short URL Service)是一种将长URL转换为短URL的服务,广泛应用于短信营销、社交媒体、二维码等场景。短链服务不仅能够节省字符空间,还能提供访问统计、流量分析、安全检测等增值功能。
本文将深入分析短链系统的设计要点,包括短链生成算法、存储方案、高并发优化、防刷机制等核心问题,并提供完整的Java实现方案。
短链系统的核心价值
- 节省空间:短信、社交媒体等平台有字数限制
- 统计分析:追踪点击量、用户来源、设备类型等
- 安全防护:过滤恶意链接、防止钓鱼攻击
- 品牌形象:自定义短链提升品牌认知
二、知识点详细讲解
2.1 短链生成算法
方案一:自增ID + 进制转换
原理:
- 维护一个全局自增ID
- 将10进制ID转换为62进制(0-9, a-z, A-Z)
- 得到短链后缀
优点:
- 短链有序递增
- 长度可控
- 无冲突
缺点:
- 依赖数据库自增ID
- 可被遍历(安全问题)
/**
* 62进制编码器
*/
public class Base62Encoder {
private static final String BASE62_CHARS =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
/**
* 将10进制数转换为62进制字符串
* @param num 10进制数字
* @return 62进制字符串
*/
public static String encode(long num) {
if (num < 0) {
throw new IllegalArgumentException("Number must be non-negative");
}
StringBuilder sb = new StringBuilder();
while (num > 0) {
int remainder = (int) (num % 62);
sb.append(BASE62_CHARS.charAt(remainder));
num /= 62;
}
return sb.length() == 0 ? "0" : sb.reverse().toString();
}
/**
* 将62进制字符串转换为10进制数
* @param str 62进制字符串
* @return 10进制数字
*/
public static long decode(String str) {
if (str == null || str.isEmpty()) {
throw new IllegalArgumentException("String cannot be null or empty");
}
long result = 0;
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
int index = BASE62_CHARS.indexOf(c);
if (index == -1) {
throw new IllegalArgumentException("Invalid character: " + c);
}
result = result * 62 + index;
}
return result;
}
/**
* 生成指定长度的短链(通过填充前缀)
*/
public static String encodeWithLength(long num, int minLength) {
String encoded = encode(num);
if (encoded.length() >= minLength) {
return encoded;
}
// 前面补0
StringBuilder sb = new StringBuilder();
for (int i = encoded.length(); i < minLength; i++) {
sb.append('0');
}
sb.append(encoded);
return sb.toString();
}
public static void main(String[] args) {
// 测试示例
System.out.println("编码测试:");
System.out.println("1 -> " + encode(1)); // 1
System.out.println("61 -> " + encode(61)); // Z
System.out.println("62 -> " + encode(62)); // 10
System.out.println("1000000 -> " + encode(1000000)); // 4c91
// 解码测试
System.out.println("\n解码测试:");
System.out.println("1 -> " + decode("1"));
System.out.println("Z -> " + decode("Z"));
System.out.println("10 -> " + decode("10"));
System.out.println("4c91 -> " + decode("4c91"));
// 长度测试
System.out.println("\n指定长度编码:");
System.out.println("固定6位: " + encodeWithLength(12345, 6));
}
}
方案二:哈希算法 + 冲突处理
原理:
- 对长URL进行哈希(如MurmurHash、MD5)
- 取哈希值的一部分作为短链
- 冲突时重新哈希或追加随机盐
优点:
- 不依赖数据库
- 分布式友好
缺点:
- 存在冲突概率
- 短链长度不可控
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 哈希短链生成器
*/
public class HashShortUrlGenerator {
private static final String BASE62_CHARS =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
/**
* 使用MD5生成短链
* @param longUrl 原始长链接
* @param length 短链长度(建议6-8位)
* @return 短链后缀
*/
public static String generateByMD5(String longUrl, int length) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hashBytes = md.digest(longUrl.getBytes(StandardCharsets.UTF_8));
// 转换为16进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
// 取前length个字符
return hexString.substring(0, Math.min(length, hexString.length()));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 algorithm not found", e);
}
}
/**
* 使用MurmurHash生成短链(性能更优)
* @param longUrl 原始长链接
* @return 短链后缀
*/
public static String generateByMurmurHash(String longUrl) {
// 使用Guava的MurmurHash(这里简化实现)
int hash = murmurHash3(longUrl.getBytes(StandardCharsets.UTF_8), 0);
return base62Encode(Math.abs(hash));
}
/**
* 简化版MurmurHash3实现
*/
private static int murmurHash3(byte[] data, int seed) {
int h1 = seed;
int len = data.length;
for (int i = 0; i < len; i++) {
h1 ^= data[i];
h1 *= 0x5bd1e995;
h1 ^= h1 >>> 15;
}
return h1;
}
/**
* 10进制转62进制
*/
private static String base62Encode(long num) {
if (num == 0) {
return "0";
}
StringBuilder sb = new StringBuilder();
while (num > 0) {
int remainder = (int) (num % 62);
sb.append(BASE62_CHARS.charAt(remainder));
num /= 62;
}
return sb.reverse().toString();
}
/**
* 冲突检测与重试
* @param longUrl 原始长链接
* @param maxLength 短链最大长度
* @param maxRetries 最大重试次数
* @return 短链候选列表
*/
public static List<String> generateWithRetry(String longUrl, int maxLength, int maxRetries) {
List<String> candidates = new ArrayList<>();
String currentUrl = longUrl;
for (int i = 0; i < maxRetries; i++) {
String shortUrl = generateByMurmurHash(currentUrl);
if (shortUrl.length() <= maxLength) {
candidates.add(shortUrl);
}
// 追加盐值重试
currentUrl = longUrl + "#" + i;
}
return candidates;
}
public static void main(String[] args) {
String longUrl = "https://www.example.com/products/12345?category=electronics&sort=price";
System.out.println("MD5短链: " + generateByMD5(longUrl, 6));
System.out.println("MurmurHash短链: " + generateByMurmurHash(longUrl));
// 批量测试冲突率
Set<String> shorts = new HashSet<>();
int collisions = 0;
for (int i = 0; i < 10000; i++) {
String url = "https://example.com/" + i;
String shortUrl = generateByMurmurHash(url);
if (shorts.contains(shortUrl)) {
collisions++;
}
shorts.add(shortUrl);
}
System.out.println("\n冲突测试(10000个URL):");
System.out.println("冲突次数: " + collisions);
System.out.println("冲突率: " + (collisions * 100.0 / 10000) + "%");
}
}
方案三:雪花算法变种
结合时间戳和序列号,保证全局唯一性和有序性。
/**
* 雪花算法变种 - 短链ID生成器
* 结构:时间戳(41位) + 数据中心ID(5位) + 机器ID(5位) + 序列号(13位)
*/
public class SnowflakeShortUrlGenerator {
// 起始时间戳(2024-01-01)
private final long twepoch = 1704038400000L;
// 各部分占用的位数
private final long datacenterIdBits = 5L;
private final long workerIdBits = 5L;
private final long sequenceBits = 13L;
// 各部分最大值
private final long maxDatacenterId = ~(-1L << datacenterIdBits);
private final long maxWorkerId = ~(-1L << workerIdBits);
private final long sequenceMask = ~(-1L << sequenceBits);
// 各部分左移位数
private final long workerIdShift = sequenceBits;
private final long datacenterIdShift = sequenceBits + workerIdBits;
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private final long datacenterId;
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeShortUrlGenerator(long datacenterId, long workerId) {
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException("Datacenter ID out of range");
}
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("Worker ID out of range");
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
/**
* 生成下一个ID
*/
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
/**
* 等待到下一毫秒
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
/**
* 生成短链后缀
*/
public String nextShortUrl() {
long id = nextId();
return Base62Encoder.encode(id);
}
public static void main(String[] args) {
SnowflakeShortUrlGenerator generator = new SnowflakeShortUrlGenerator(1, 1);
System.out.println("生成10个短链:");
for (int i = 0; i < 10; i++) {
String shortUrl = generator.nextShortUrl();
System.out.println((i + 1) + ". " + shortUrl);
}
// 性能测试
long start = System.currentTimeMillis();
int count = 100000;
for (int i = 0; i < count; i++) {
generator.nextShortUrl();
}
long end = System.currentTimeMillis();
System.out.println("\n性能测试:");
System.out.println("生成 " + count + " 个短链耗时: " + (end - start) + "ms");
System.out.println("QPS: " + (count * 1000L / (end - start)));
}
}
2.2 存储方案设计
数据库表结构
-- 短链映射表
CREATE TABLE `short_url` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`short_code` varchar(10) NOT NULL COMMENT '短链后缀',
`long_url` varchar(2048) NOT NULL COMMENT '原始长链接',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1-正常 0-禁用',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_short_code` (`short_code`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';
-- 访问日志表
CREATE TABLE `access_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`short_code` varchar(10) NOT NULL COMMENT '短链后缀',
`long_url` varchar(2048) NOT NULL COMMENT '原始长链接',
`user_agent` varchar(512) DEFAULT NULL COMMENT '用户代理',
`ip` varchar(64) DEFAULT NULL COMMENT '访问IP',
`referer` varchar(512) DEFAULT NULL COMMENT '来源页面',
`device_type` varchar(20) DEFAULT NULL COMMENT '设备类型',
`access_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '访问时间',
PRIMARY KEY (`id`),
KEY `idx_short_code` (`short_code`),
KEY `idx_access_time` (`access_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访问日志表';
-- 统计汇总表
CREATE TABLE `url_statistics` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`short_code` varchar(10) NOT NULL COMMENT '短链后缀',
`date` date NOT NULL COMMENT '统计日期',
`pv` int(11) NOT NULL DEFAULT '0' COMMENT '页面浏览量',
`uv` int(11) NOT NULL DEFAULT '0' COMMENT '独立访客数',
`ip_count` int(11) NOT NULL DEFAULT '0' COMMENT '独立IP数',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code_date` (`short_code`, `date`),
KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统计汇总表';
实体类设计
import lombok.Data;
import java.time.LocalDateTime;
/**
* 短链实体类
*/
@Data
public class ShortUrl {
private Long id;
/**
* 短链后缀
*/
private String shortCode;
/**
* 原始长链接
*/
private String longUrl;
/**
* 状态:1-正常 0-禁用
*/
private Integer status;
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 判断是否已过期
*/
public boolean isExpired() {
return expireTime != null && expireTime.isBefore(LocalDateTime.now());
}
/**
* 判断是否可用
*/
public boolean isAvailable() {
return status == 1 && !isExpired();
}
}
/**
* 访问日志实体
*/
@Data
public class AccessLog {
private Long id;
private String shortCode;
private String longUrl;
private String userAgent;
private String ip;
private String referer;
private String deviceType;
private LocalDateTime accessTime;
}
/**
* 统计数据实体
*/
@Data
public class UrlStatistics {
private Long id;
private String shortCode;
private LocalDate date;
private Integer pv;
private Integer uv;
private Integer ipCount;
}
2.3 缓存策略
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 短链缓存管理器
*/
@Component
public class ShortUrlCacheManager {
private final StringRedisTemplate redisTemplate;
// 缓存key前缀
private static final String CACHE_PREFIX = "short_url:";
// 布隆过滤器key
private static final String BLOOM_KEY = "short_url_bloom";
// 缓存过期时间(秒)
private static final long CACHE_EXPIRE_SECONDS = 3600;
public ShortUrlCacheManager(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 缓存短链映射
*/
public void cacheShortUrl(String shortCode, String longUrl) {
String key = CACHE_PREFIX + shortCode;
redisTemplate.opsForValue().set(key, longUrl, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
}
/**
* 获取缓存的长链接
*/
public String getLongUrl(String shortCode) {
String key = CACHE_PREFIX + shortCode;
return redisTemplate.opsForValue().get(key);
}
/**
* 删除缓存
*/
public void evictCache(String shortCode) {
String key = CACHE_PREFIX + shortCode;
redisTemplate.delete(key);
}
/**
* 检查短链是否存在(使用布隆过滤器)
*/
public boolean mightContain(String shortCode) {
// 使用Redis的布隆过滤器(需要RedisBloom模块)
// 这里简化实现,使用Set模拟
return Boolean.TRUE.equals(
redisTemplate.opsForSet().isMember(BLOOM_KEY, shortCode)
);
}
/**
* 添加到布隆过滤器
*/
public void addToBloom(String shortCode) {
redisTemplate.opsForSet().add(BLOOM_KEY, shortCode);
}
/**
* 缓存预热
*/
public void warmUp(List<ShortUrl> hotUrls) {
hotUrls.forEach(url -> {
if (url.isAvailable()) {
cacheShortUrl(url.getShortCode(), url.getLongUrl());
addToBloom(url.getShortCode());
}
});
}
}
2.4 高并发优化方案
方案一:本地缓存 + Redis二级缓存
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 二级缓存管理器
* L1: Caffeine本地缓存(毫秒级)
* L2: Redis分布式缓存(秒级)
*/
@Component
public class TwoLevelCacheManager {
private final Cache<String, String> localCache;
private final StringRedisTemplate redisTemplate;
public TwoLevelCacheManager(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
// 本地缓存配置
this.localCache = Caffeine.newBuilder()
.maximumSize(10000) // 最大缓存数量
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期
.recordStats() // 记录统计信息
.build();
}
/**
* 获取长链接(二级缓存)
*/
public String getLongUrl(String shortCode) {
// 1. 查询本地缓存
String longUrl = localCache.getIfPresent(shortCode);
if (longUrl != null) {
return longUrl;
}
// 2. 查询Redis缓存
String key = "short_url:" + shortCode;
longUrl = redisTemplate.opsForValue().get(key);
if (longUrl != null) {
// 回填本地缓存
localCache.put(shortCode, longUrl);
return longUrl;
}
// 3. 返回null,需要查询数据库
return null;
}
/**
* 缓存短链映射
*/
public void cacheUrl(String shortCode, String longUrl) {
// 写入本地缓存
localCache.put(shortCode, longUrl);
// 写入Redis
String key = "short_url:" + shortCode;
redisTemplate.opsForValue().set(key, longUrl, 1, TimeUnit.HOURS);
}
/**
* 获取缓存统计信息
*/
public void printStats() {
var stats = localCache.stats();
System.out.println("本地缓存统计:");
System.out.println("命中率: " + stats.hitRate());
System.out.println("命中次数: " + stats.hitCount());
System.out.println("未命中次数: " + stats.missCount());
System.out.println("缓存大小: " + localCache.estimatedSize());
}
}
方案二:读写分离 + 分库分表
/**
* 短链服务实现(支持分库分表)
*/
@Service
public class ShortUrlServiceImpl implements ShortUrlService {
private final ShortUrlRepository repository;
private final TwoLevelCacheManager cacheManager;
private final AsyncLogService asyncLogService;
public ShortUrlServiceImpl(ShortUrlRepository repository,
TwoLevelCacheManager cacheManager,
AsyncLogService asyncLogService) {
this.repository = repository;
this.cacheManager = cacheManager;
this.asyncLogService = asyncLogService;
}
/**
* 创建短链
*/
@Override
@Transactional
public String createShortUrl(String longUrl) {
// 参数校验
validateUrl(longUrl);
// 检查是否已存在
ShortUrl existing = repository.findByLongUrl(longUrl);
if (existing != null && existing.isAvailable()) {
return existing.getShortCode();
}
// 生成短链
String shortCode = generateShortCode();
// 保存到数据库
ShortUrl shortUrl = new ShortUrl();
shortUrl.setShortCode(shortCode);
shortUrl.setLongUrl(longUrl);
shortUrl.setStatus(1);
repository.save(shortUrl);
// 写入缓存
cacheManager.cacheUrl(shortCode, longUrl);
return shortCode;
}
/**
* 获取长链接(高并发优化)
*/
@Override
public String getLongUrl(String shortCode) {
// 1. 查询二级缓存
String longUrl = cacheManager.getLongUrl(shortCode);
if (longUrl != null) {
// 异步记录访问日志
asyncLogService.logAccess(shortCode, longUrl);
return longUrl;
}
// 2. 查询数据库(加分布式锁防止缓存穿透)
String lockKey = "lock:" + shortCode;
try {
// 使用Redis分布式锁
boolean locked = acquireLock(lockKey, 3, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查
longUrl = cacheManager.getLongUrl(shortCode);
if (longUrl != null) {
return longUrl;
}
// 查询数据库
ShortUrl shortUrl = repository.findByShortCode(shortCode);
if (shortUrl != null && shortUrl.isAvailable()) {
longUrl = shortUrl.getLongUrl();
// 写入缓存
cacheManager.cacheUrl(shortCode, longUrl);
// 异步记录访问日志
asyncLogService.logAccess(shortCode, longUrl);
return longUrl;
}
// 短链不存在或已过期
return null;
} finally {
releaseLock(lockKey);
}
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(50);
return getLongUrl(shortCode);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
/**
* 生成短链代码
*/
private String generateShortCode() {
// 使用雪花算法生成ID,然后转62进制
SnowflakeShortUrlGenerator generator = new SnowflakeShortUrlGenerator(1, 1);
return generator.nextShortUrl();
}
/**
* 参数校验
*/
private void validateUrl(String url) {
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("URL cannot be null or empty");
}
if (url.length() > 2048) {
throw new IllegalArgumentException("URL too long");
}
// 更多校验规则...
}
// 分布式锁相关方法...
}
2.5 防刷与安全机制
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 防刷限流器
*/
@Component
public class RateLimiter {
private final StringRedisTemplate redisTemplate;
public RateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 基于IP的限流(滑动窗口算法)
* @param ip 客户端IP
* @param limit 时间窗口内最大访问次数
* @param windowSeconds 时间窗口(秒)
* @return 是否允许访问
*/
public boolean allowAccess(String ip, int limit, int windowSeconds) {
String key = "rate_limit:ip:" + ip;
long currentTime = System.currentTimeMillis();
long windowStart = currentTime - windowSeconds * 1000L;
// 1. 移除窗口外的记录
redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
// 2. 统计当前窗口内的访问次数
Long count = redisTemplate.opsForZSet().zCard(key);
if (count != null && count >= limit) {
return false;
}
// 3. 添加本次访问记录
redisTemplate.opsForZSet().add(key, String.valueOf(currentTime), currentTime);
// 4. 设置过期时间
redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
return true;
}
/**
* 基于令牌桶算法的限流
* @param key 限流key
* @param capacity 桶容量
* @param rate 令牌生成速率(个/秒)
* @return 是否允许访问
*/
public boolean allowByTokenBucket(String key, int capacity, int rate) {
String tokensKey = key + ":tokens";
String lastRefillKey = key + ":last_refill";
long now = System.currentTimeMillis();
// 获取当前令牌数和上次填充时间
String tokensStr = redisTemplate.opsForValue().get(tokensKey);
String lastRefillStr = redisTemplate.opsForValue().get(lastRefillKey);
int tokens = tokensStr == null ? capacity : Integer.parseInt(tokensStr);
long lastRefill = lastRefillStr == null ? now : Long.parseLong(lastRefillStr);
// 计算需要补充的令牌数
long elapsed = now - lastRefill;
int tokensToAdd = (int) (elapsed * rate / 1000);
// 补充令牌
tokens = Math.min(capacity, tokens + tokensToAdd);
// 尝试获取令牌
if (tokens > 0) {
tokens--;
redisTemplate.opsForValue().set(tokensKey, String.valueOf(tokens));
redisTemplate.opsForValue().set(lastRefillKey, String.valueOf(now));
return true;
}
return false;
}
/**
* 黑名单检查
*/
public boolean isBlacklisted(String ip) {
String key = "blacklist:ip:" + ip;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 添加到黑名单
*/
public void addToBlacklist(String ip, long expireSeconds) {
String key = "blacklist:ip:" + ip;
redisTemplate.opsForValue().set(key, "1", expireSeconds, TimeUnit.SECONDS);
}
}
2.6 异步日志处理
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 异步日志服务
* 使用生产者-消费者模式批量写入日志
*/
@Service
public class AsyncLogService {
private final AccessLogRepository logRepository;
private final BlockingQueue<AccessLog> logQueue;
private final AtomicInteger queueSize;
private final int batchSize = 100;
public AsyncLogService(AccessLogRepository logRepository) {
this.logRepository = logRepository;
this.logQueue = new LinkedBlockingQueue<>(10000);
this.queueSize = new AtomicInteger(0);
// 启动消费者线程
startConsumer();
}
/**
* 异步记录访问日志
*/
@Async
public void logAccess(String shortCode, String longUrl) {
AccessLog log = new AccessLog();
log.setShortCode(shortCode);
log.setLongUrl(longUrl);
log.setAccessTime(LocalDateTime.now());
// 非阻塞写入队列
if (!logQueue.offer(log)) {
// 队列满,丢弃或记录到备用存储
System.err.println("Log queue is full, dropping log");
}
}
/**
* 启动消费者线程
*/
private void startConsumer() {
Thread consumer = new Thread(() -> {
List<AccessLog> batch = new ArrayList<>(batchSize);
while (true) {
try {
// 从队列中取出日志
AccessLog log = logQueue.poll(100, TimeUnit.MILLISECONDS);
if (log != null) {
batch.add(log);
queueSize.decrementAndGet();
}
// 批量写入
if (batch.size() >= batchSize ||
(log == null && !batch.isEmpty())) {
logRepository.batchInsert(batch);
batch.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
System.err.println("Error processing logs: " + e.getMessage());
}
}
}, "log-consumer");
consumer.setDaemon(true);
consumer.start();
}
/**
* 获取队列大小
*/
public int getQueueSize() {
return queueSize.get();
}
}
三、可运行Java代码示例
完整的短链服务实现
package com.example.shorturl;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* 短链服务启动类
*/
@SpringBootApplication
@EnableCaching
@EnableAsync
public class ShortUrlApplication {
public static void main(String[] args) {
SpringApplication.run(ShortUrlApplication.class, args);
}
}
package com.example.shorturl.controller;
import com.example.shorturl.dto.ShortUrlRequest;
import com.example.shorturl.dto.ShortUrlResponse;
import com.example.shorturl.service.ShortUrlService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
/**
* 短链控制器
*/
@RestController
public class ShortUrlController {
private final ShortUrlService shortUrlService;
private final String domain = "https://s.example.com/";
public ShortUrlController(ShortUrlService shortUrlService) {
this.shortUrlService = shortUrlService;
}
/**
* 创建短链
*/
@PostMapping("/api/shorten")
public ResponseEntity<ShortUrlResponse> createShortUrl(@RequestBody ShortUrlRequest request) {
String shortCode = shortUrlService.createShortUrl(request.getLongUrl());
ShortUrlResponse response = new ShortUrlResponse();
response.setShortUrl(domain + shortCode);
response.setShortCode(shortCode);
response.setLongUrl(request.getLongUrl());
return ResponseEntity.ok(response);
}
/**
* 短链跳转
*/
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode,
@RequestHeader(value = "User-Agent", required = false) String userAgent,
@RequestHeader(value = "Referer", required = false) String referer,
@RequestHeader(value = "X-Real-IP", required = false) String ip) {
String longUrl = shortUrlService.getLongUrl(shortCode);
if (longUrl == null) {
return ResponseEntity.notFound().build();
}
// 记录访问日志(异步)
shortUrlService.recordAccess(shortCode, userAgent, referer, ip);
// 302重定向
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(longUrl));
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
/**
* 获取统计数据
*/
@GetMapping("/api/stats/{shortCode}")
public ResponseEntity<UrlStatistics> getStatistics(@PathVariable String shortCode,
@RequestParam(required = false) String date) {
UrlStatistics stats = shortUrlService.getStatistics(shortCode, date);
return ResponseEntity.ok(stats);
}
}
package com.example.shorturl.dto;
import lombok.Data;
/**
* 创建短链请求DTO
*/
@Data
public class ShortUrlRequest {
/**
* 原始长链接
*/
private String longUrl;
/**
* 过期时间(可选)
*/
private String expireTime;
/**
* 自定义短链后缀(可选)
*/
private String customCode;
}
/**
* 短链响应DTO
*/
@Data
public class ShortUrlResponse {
private String shortUrl;
private String shortCode;
private String longUrl;
}
package com.example.shorturl.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* 缓存配置
*/
@Configuration
public class CacheConfig {
@Bean
public Caffeine<Object, Object> caffeineConfig() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats();
}
@Bean
public CacheManager cacheManager(Caffeine<Object, Object> caffeine) {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(caffeine);
return cacheManager;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
package com.example.shorturl.interceptor;
import com.example.shorturl.component.RateLimiter;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 限流拦截器
*/
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiter rateLimiter;
public RateLimitInterceptor(RateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String ip = getClientIp(request);
// 检查黑名单
if (rateLimiter.isBlacklisted(ip)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("Access denied");
return false;
}
// 限流检查(每个IP每分钟最多100次访问)
if (!rateLimiter.allowAccess(ip, 100, 60)) {
response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
response.getWriter().write("Rate limit exceeded");
return false;
}
return true;
}
/**
* 获取客户端真实IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Real-IP");
if (ip == null || ip.isEmpty()) {
ip = request.getHeader("X-Forwarded-For");
}
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
return ip;
}
}
四、实战应用场景
场景一:短信营销短链
需求特点:
- 高并发创建(批量导入)
- 高并发访问(短信群发)
- 需要详细统计(点击率、转化率)
解决方案:
/**
* 批量短链创建服务
*/
@Service
public class BatchShortUrlService {
private final ShortUrlRepository repository;
private final SnowflakeShortUrlGenerator generator;
private final StringRedisTemplate redisTemplate;
/**
* 批量创建短链
*/
@Transactional
public List<String> batchCreate(List<String> longUrls) {
List<String> shortCodes = new ArrayList<>();
// 使用批量插入优化性能
List<ShortUrl> entities = new ArrayList<>();
for (String longUrl : longUrls) {
String shortCode = generator.nextShortUrl();
ShortUrl entity = new ShortUrl();
entity.setShortCode(shortCode);
entity.setLongUrl(longUrl);
entity.setStatus(1);
entities.add(entity);
shortCodes.add(shortCode);
}
// 批量插入数据库
repository.batchInsert(entities);
// 批量写入Redis管道
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (int i = 0; i < entities.size(); i++) {
ShortUrl entity = entities.get(i);
String key = "short_url:" + entity.getShortCode();
connection.setEx(
key.getBytes(),
3600,
entity.getLongUrl().getBytes()
);
}
return null;
});
return shortCodes;
}
}
场景二:二维码短链
需求特点:
- 短链需永久有效
- 支持自定义短链后缀
- 需要防止恶意跳转
解决方案:
/**
* 自定义短链服务
*/
@Service
public class CustomShortUrlService {
private final ShortUrlRepository repository;
/**
* 创建自定义短链
*/
@Transactional
public String createCustomShortUrl(String longUrl, String customCode) {
// 检查自定义后缀是否合法
validateCustomCode(customCode);
// 检查是否已被占用
if (repository.existsByShortCode(customCode)) {
throw new BusinessException("短链后缀已被占用: " + customCode);
}
// 创建永久有效的短链
ShortUrl shortUrl = new ShortUrl();
shortUrl.setShortCode(customCode);
shortUrl.setLongUrl(longUrl);
shortUrl.setStatus(1);
shortUrl.setExpireTime(null); // 永久有效
repository.save(shortUrl);
return customCode;
}
/**
* 校验自定义后缀
*/
private void validateCustomCode(String code) {
if (code == null || code.isEmpty()) {
throw new IllegalArgumentException("自定义后缀不能为空");
}
if (code.length() < 3 || code.length() > 20) {
throw new IllegalArgumentException("后缀长度应在3-20位之间");
}
if (!code.matches("^[a-zA-Z0-9_-]+$")) {
throw new IllegalArgumentException("后缀只能包含字母、数字、下划线和连字符");
}
// 黑名单检查
Set<String> blacklist = Set.of("admin", "api", "static", "assets");
if (blacklist.contains(code.toLowerCase())) {
throw new IllegalArgumentException("该后缀不可用");
}
}
}
场景三:实时统计大屏
需求特点:
- 实时展示访问数据
- 支持多维度分析
- 高性能聚合查询
解决方案:
/**
* 实时统计服务
*/
@Service
public class RealTimeStatisticsService {
private final StringRedisTemplate redisTemplate;
/**
* 记录实时访问数据
*/
public void recordAccess(String shortCode, String ip) {
String today = LocalDate.now().toString();
// PV计数
String pvKey = "stats:pv:" + today;
redisTemplate.opsForValue().increment(pvKey);
// UV计数(使用HyperLogLog)
String uvKey = "stats:uv:" + today;
redisTemplate.opsForHyperLogLog().add(uvKey, ip);
// 独立IP计数(使用Set)
String ipKey = "stats:ip:" + today;
redisTemplate.opsForSet().add(ipKey, ip);
// 设置过期时间
long expireSeconds = 7 * 24 * 3600; // 保留7天
redisTemplate.expire(pvKey, expireSeconds, TimeUnit.SECONDS);
redisTemplate.expire(uvKey, expireSeconds, TimeUnit.SECONDS);
redisTemplate.expire(ipKey, expireSeconds, TimeUnit.SECONDS);
}
/**
* 获取今日统计数据
*/
public Map<String, Object> getTodayStats() {
String today = LocalDate.now().toString();
Map<String, Object> stats = new HashMap<>();
// PV
String pvKey = "stats:pv:" + today;
String pv = redisTemplate.opsForValue().get(pvKey);
stats.put("pv", pv == null ? 0 : Long.parseLong(pv));
// UV
String uvKey = "stats:uv:" + today;
Long uv = redisTemplate.opsForHyperLogLog().size(uvKey);
stats.put("uv", uv == null ? 0 : uv);
// 独立IP
String ipKey = "stats:ip:" + today;
Long ipCount = redisTemplate.opsForSet().size(ipKey);
stats.put("ipCount", ipCount == null ? 0 : ipCount);
return stats;
}
/**
* 获取访问趋势(最近7天)
*/
public List<Map<String, Object>> getTrend(int days) {
List<Map<String, Object>> trend = new ArrayList<>();
for (int i = days - 1; i >= 0; i--) {
LocalDate date = LocalDate.now().minusDays(i);
String dateStr = date.toString();
Map<String, Object> dayStats = new HashMap<>();
dayStats.put("date", dateStr);
String pvKey = "stats:pv:" + dateStr;
String pv = redisTemplate.opsForValue().get(pvKey);
dayStats.put("pv", pv == null ? 0 : Long.parseLong(pv));
String uvKey = "stats:uv:" + dateStr;
Long uv = redisTemplate.opsForHyperLogLog().size(uvKey);
dayStats.put("uv", uv == null ? 0 : uv);
trend.add(dayStats);
}
return trend;
}
}
五、总结与最佳实践
5.1 架构设计要点
| 设计维度 | 推荐方案 | 说明 |
|---|---|---|
| 短链生成 | 雪花算法 + 62进制 | 全局唯一、有序递增、性能高 |
| 存储方案 | MySQL + 分库分表 | 支持海量数据、水平扩展 |
| 缓存策略 | Caffeine + Redis二级缓存 | 本地缓存抗热点、Redis保一致 |
| 高并发优化 | 异步化、批量操作 | 日志异步写入、批量创建 |
| 防刷机制 | 滑动窗口 + 黑名单 | IP限流、异常检测 |
5.2 性能优化建议
- 缓存预热:启动时加载热门短链到缓存
- 布隆过滤器:防止缓存穿透
- 连接池优化:数据库、Redis连接池合理配置
- 批量操作:批量创建、批量写入日志
- 异步处理:访问日志异步写入、统计异步计算
5.3 可靠性保障
/**
* 短链服务降级策略
*/
@Component
public class ShortUrlFallback {
private final Map<String, String> fallbackCache = new ConcurrentHashMap<>();
/**
* 降级:从本地缓存获取
*/
public String getFromFallback(String shortCode) {
return fallbackCache.get(shortCode);
}
/**
* 更新降级缓存
*/
public void updateFallback(String shortCode, String longUrl) {
if (fallbackCache.size() < 10000) {
fallbackCache.put(shortCode, longUrl);
}
}
}
5.4 监控指标
/**
* 监控指标收集
*/
@Component
public class ShortUrlMetrics {
private final MeterRegistry meterRegistry;
public ShortUrlMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
/**
* 记录创建短链
*/
public void recordCreate() {
meterRegistry.counter("short_url.create.count").increment();
}
/**
* 记录访问
*/
public void recordAccess(String shortCode) {
meterRegistry.counter("short_url.access.count",
"code", shortCode).increment();
}
/**
* 记录缓存命中率
*/
public void recordCacheHit(boolean hit) {
meterRegistry.counter("short_url.cache",
"result", hit ? "hit" : "miss").increment();
}
/**
* 记录响应时间
*/
public void recordLatency(long milliseconds) {
meterRegistry.timer("short_url.latency")
.record(Duration.ofMillis(milliseconds));
}
}
5.5 扩展性考虑
-
国际化支持:
- 支持多语言错误提示
- 不同地区使用不同域名
-
业务扩展:
- 链接安全检测
- 自定义跳转页面
- A/B测试支持
-
存储扩展:
- 冷热数据分离
- 历史数据归档
- 多机房部署
5.6 常见问题与解决方案
Q1: 如何处理短链冲突?
- 使用唯一索引 + 重试机制
- 布隆过滤器预判
Q2: 如何防止恶意短链?
- URL安全检测(第三方API)
- 域名白名单机制
- 人工审核机制
Q3: 如何支持海量数据?
- 分库分表(按短链hash取模)
- 冷热数据分离
- 时序数据库存储访问日志
Q4: 如何保证高可用?
- 多机房部署
- 数据库主从复制
- Redis哨兵/集群
- 服务降级与熔断
六、思考与练习
思考题
- 基础题:自增ID+进制转换和哈希算法两种短链生成方案各有什么优缺点?如何选择?
- 进阶题:在高并发场景下,如何防止短链生成时的冲突?布隆过滤器在短链系统中有什么作用?
- 实战题:设计一个支持自定义短链、永久有效、访问统计的企业级短链服务。
编程练习
练习:实现一个完整的短链服务,要求:
- 支持短链创建、跳转、统计功能
- 使用雪花算法生成短链ID
- 实现二级缓存(本地缓存+Redis)
- 支持IP限流和黑名单
章节关联
- 前置章节:《数据同步方案详解》
- 后续章节:《消息推送系统详解》
- 扩展阅读:Twitter Snowflake论文、Google短链服务架构
📝 下一章预告
下一章将学习消息推送系统的设计,探讨长连接管理、消息路由、可靠性保证等核心技术,掌握即时通讯和推送通知系统的设计方法。
本章完