
我参与过日均处理千亿次请求的短链系统重构。今天,我将从 0 到 1 带你剖析短链系统的设计奥秘,不仅告诉你 "怎么做",更要讲透 "为什么这么做"。无论你是刚入行的开发者,还是想优化现有系统的架构师,这篇文章都将为你提供可落地的实战指南。
短链系统(URL Shortener)是一种能将长 URL 转换为短 URL 的服务。当用户访问短 URL 时,系统会将其重定向到原始的长 URL。例如:
https://www.google.com/search?q=url+shortener&rlz=1C1GCEU_zh-CN___CN1002&oq=url+shortener&aqs=chrome..69i57j0i512l9.3848j0j7&sourceid=chrome&ie=UTF-8https://goo.gl/abc123短链系统的核心流程如下:

一个工业级短链系统需要应对以下挑战:
一个完整的短链系统架构如下:

基于 Java 生态,我们选择以下技术栈(均为最新稳定版本):
短码(Short Code)是短链系统的核心,它决定了短 URL 的长度、唯一性和安全性。
推荐使用 Base62 字符集(62 个字符):
算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
自增 ID+Base62 | 实现简单、唯一性高 | 可预测性强 | 非敏感场景、需要顺序生成 |
哈希 + 截取 | 不可预测、分布均匀 | 可能冲突、需解决碰撞 | 敏感场景、对安全性要求高 |
UUID 压缩 | 全球唯一、无需中心节点 | 长度较长(通常需 12 字符) | 分布式系统、无中心 ID 生成器 |
import org.apache.commons.lang3.StringUtils;
import java.util.Objects;
/**
* Base62编码工具类
* 用于将数字ID转换为短码,支持62进制与10进制互转
*
* @author 果酱
*/
public class Base62Utils {
/**
* Base62字符集(不含易混淆字符)
*/
private static final String BASE62_CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz123456789";
/**
* 基数
*/
private static final int BASE = BASE62_CHARACTERS.length();
/**
* 将长整型数字转换为Base62字符串
*
* @param number 待转换的数字,必须大于等于0
* @return Base62字符串
* @throws IllegalArgumentException 如果数字为负数
*/
public static String encode(long number) {
if (number < 0) {
throw new IllegalArgumentException("数字必须是非负数: " + number);
}
if (number == 0) {
return String.valueOf(BASE62_CHARACTERS.charAt(0));
}
StringBuilder sb = new StringBuilder();
while (number > 0) {
int remainder = (int) (number % BASE);
sb.append(BASE62_CHARACTERS.charAt(remainder));
number = number / BASE;
}
return sb.reverse().toString();
}
/**
* 将Base62字符串转换为长整型数字
*
* @param base62String Base62字符串
* @return 对应的长整型数字
* @throws IllegalArgumentException 如果输入字符串包含无效字符
*/
public static long decode(String base62String) {
Objects.requireNonNull(base62String, "Base62字符串不能为空");
if (base62String.isEmpty()) {
throw new IllegalArgumentException("Base62字符串不能为空");
}
long number = 0;
for (char c : base62String.toCharArray()) {
int index = BASE62_CHARACTERS.indexOf(c);
if (index == -1) {
throw new IllegalArgumentException("无效的Base62字符: " + c);
}
number = number * BASE + index;
}
return number;
}
}
基于雪花算法(Snowflake)实现分布式 ID 生成,确保 ID 全局唯一:
import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
/**
* 分布式ID生成器(基于雪花算法)
* 生成64位唯一ID,格式:[1位符号位][41位时间戳][10位机器ID][12位序列号]
*
* @author 果酱
*/
@Slf4j
public class SnowflakeIdGenerator {
/**
* 起始时间戳(2024-01-01 00:00:00)
*/
private static final long START_TIMESTAMP = 1704067200000L;
/**
* 机器ID所占位数
*/
private static final int WORKER_ID_BITS = 10;
/**
* 序列号所占位数
*/
private static final int SEQUENCE_BITS = 12;
/**
* 机器ID最大值(2^10 - 1)
*/
private static final long MAX_WORKER_ID = (1L << WORKER_ID_BITS) - 1;
/**
* 序列号最大值(2^12 - 1)
*/
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;
/**
* 机器ID左移位数
*/
private static final int WORKER_ID_SHIFT = SEQUENCE_BITS;
/**
* 时间戳左移位数
*/
private static final int TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
/**
* 机器ID
*/
private final long workerId;
/**
* 序列号,用于同一毫秒内生成多个ID
*/
private long sequence = 0L;
/**
* 上一次生成ID的时间戳
*/
private long lastTimestamp = -1L;
/**
* 构造函数
*
* @param workerId 机器ID,范围0~1023
* @throws IllegalArgumentException 如果机器ID超出范围
*/
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("机器ID超出范围,必须在0~" + MAX_WORKER_ID + "之间: " + workerId);
}
this.workerId = workerId;
log.info("雪花ID生成器初始化完成,机器ID:{}", workerId);
}
/**
* 生成下一个唯一ID
*
* @return 64位唯一ID
*/
public synchronized long nextId() {
long currentTimestamp = getCurrentTimestamp();
// 如果当前时间戳小于上一次生成ID的时间戳,说明系统时钟回拨
if (currentTimestamp < lastTimestamp) {
log.error("系统时钟回拨,拒绝生成ID,上一次时间戳:{}, 当前时间戳:{}", lastTimestamp, currentTimestamp);
throw new RuntimeException("系统时钟回拨,无法生成ID");
}
// 如果是同一毫秒,序列号加1
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
// 如果序列号超出最大值,等待下一毫秒
if (sequence == 0) {
currentTimestamp = waitUntilNextMillis(lastTimestamp);
}
} else {
// 不同毫秒,序列号重置为0
sequence = 0L;
}
lastTimestamp = currentTimestamp;
// 组合ID:时间戳部分 + 机器ID部分 + 序列号部分
return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
/**
* 获取当前时间戳(毫秒)
*
* @return 当前时间戳
*/
private long getCurrentTimestamp() {
return Instant.now().toEpochMilli();
}
/**
* 等待到下一毫秒
*
* @param lastTimestamp 上一次的时间戳
* @return 新的时间戳
*/
private long waitUntilNextMillis(long lastTimestamp) {
long timestamp = getCurrentTimestamp();
while (timestamp <= lastTimestamp) {
timestamp = getCurrentTimestamp();
}
return timestamp;
}
}
结合 ID 生成器和 Base62 编码,实现短码生成服务:
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Objects;
/**
* 短码生成服务
* 负责将长URL转换为唯一的短码
*
* @author 果酱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ShortCodeService {
/**
* 短码最小长度
*/
private static final int MIN_CODE_LENGTH = 6;
/**
* 分布式ID生成器
*/
private final SnowflakeIdGenerator snowflakeIdGenerator;
/**
* 短链映射DAO
*/
private final ShortUrlMapper shortUrlMapper;
/**
* 生成短码
*
* @param longUrl 长URL
* @return 生成的短码
*/
public String generateShortCode(String longUrl) {
Objects.requireNonNull(longUrl, "长URL不能为空");
// 检查是否已存在映射(避免重复生成)
String existingCode = shortUrlMapper.selectShortCodeByLongUrl(longUrl);
if (StringUtils.hasText(existingCode)) {
log.info("长URL已存在短码映射,长URL:{}, 短码:{}", maskUrl(longUrl), existingCode);
return existingCode;
}
// 生成唯一ID
long id = snowflakeIdGenerator.nextId();
// 转换为Base62短码
String shortCode = Base62Utils.encode(id);
// 确保短码长度不小于最小值
if (shortCode.length() < MIN_CODE_LENGTH) {
shortCode = StringUtils.leftPad(shortCode, MIN_CODE_LENGTH, BASE62_FILL_CHAR);
}
log.info("生成短码成功,长URL:{}, 短码:{}", maskUrl(longUrl), shortCode);
return shortCode;
}
/**
* 对URL进行脱敏处理(只保留前10和后10个字符)
*
* @param url 原始URL
* @return 脱敏后的URL
*/
private String maskUrl(String url) {
if (url.length() <= 20) {
return url;
}
return url.substring(0, 10) + "..." + url.substring(url.length() - 10);
}
/**
* 用于填充短码的字符
*/
private static final char BASE62_FILL_CHAR = 'A';
}
短链系统核心数据模型包括:
CREATE TABLE `short_url` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`short_code` varchar(20) NOT NULL COMMENT '短码',
`long_url` text NOT NULL COMMENT '原始长URL',
`domain` varchar(50) NOT NULL DEFAULT 's.example.com' COMMENT '短链域名',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间,NULL表示永久有效',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`create_user` varchar(50) DEFAULT NULL COMMENT '创建用户',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-正常',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_short_code` (`short_code`,`domain`) COMMENT '短码+域名唯一索引',
KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引',
KEY `idx_expire_time` (`expire_time`) COMMENT '过期时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';
CREATE TABLE `short_url_access` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`short_code` varchar(20) NOT NULL COMMENT '短码',
`domain` varchar(50) NOT NULL COMMENT '短链域名',
`access_time` datetime NOT NULL COMMENT '访问时间',
`ip` varchar(50) DEFAULT NULL COMMENT '访问IP',
`user_agent` text COMMENT '用户代理信息',
`referrer` text COMMENT '来源页面',
`country` varchar(50) DEFAULT NULL COMMENT '国家',
`region` varchar(50) DEFAULT NULL COMMENT '地区',
`city` varchar(50) DEFAULT NULL COMMENT '城市',
`browser` varchar(50) DEFAULT NULL COMMENT '浏览器',
`os` varchar(50) DEFAULT NULL COMMENT '操作系统',
`device` varchar(50) DEFAULT NULL COMMENT '设备类型',
PRIMARY KEY (`id`),
KEY `idx_short_code_time` (`short_code`,`domain`,`access_time`) COMMENT '短码+域名+访问时间索引',
KEY `idx_access_time` (`access_time`) COMMENT '访问时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链访问统计表';
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 短链映射实体
*
* @author 果酱
*/
@Data
@TableName("short_url")
@Schema(description = "短链映射实体")
public class ShortUrl {
@TableId(type = IdType.AUTO)
@Schema(description = "主键ID")
private Long id;
@TableField("short_code")
@Schema(description = "短码", required = true)
private String shortCode;
@TableField("long_url")
@Schema(description = "原始长URL", required = true)
private String longUrl;
@TableField("domain")
@Schema(description = "短链域名", defaultValue = "s.example.com")
private String domain;
@TableField("expire_time")
@Schema(description = "过期时间,null表示永久有效")
private LocalDateTime expireTime;
@TableField(value = "create_time", fill = FieldFill.INSERT)
@Schema(description = "创建时间", accessMode = Schema.AccessMode.READ_ONLY)
private LocalDateTime createTime;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
@Schema(description = "更新时间", accessMode = Schema.AccessMode.READ_ONLY)
private LocalDateTime updateTime;
@TableField("create_user")
@Schema(description = "创建用户")
private String createUser;
@TableField("status")
@Schema(description = "状态:0-禁用,1-正常", defaultValue = "1")
private Integer status;
}
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 短链映射Mapper
*
* @author 果酱
*/
public interface ShortUrlMapper extends BaseMapper<ShortUrl> {
/**
* 根据长URL查询短码
*
* @param longUrl 长URL
* @return 短码,如果不存在则返回null
*/
String selectShortCodeByLongUrl(@Param("longUrl") String longUrl);
/**
* 根据短码和域名查询长URL
*
* @param shortCode 短码
* @param domain 域名
* @return 长URL,如果不存在或已过期则返回null
*/
String selectLongUrlByCodeAndDomain(
@Param("shortCode") String shortCode,
@Param("domain") String domain);
/**
* 分页查询短链列表
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return 分页结果
*/
IPage<ShortUrl> selectPage(
IPage<ShortUrl> page,
@Param(Constants.WRAPPER) Wrapper<ShortUrl> queryWrapper);
/**
* 批量插入短链记录
*
* @param shortUrls 短链列表
* @return 插入成功的数量
*/
int batchInsert(@Param("list") List<ShortUrl> shortUrls);
}
当短链数量达到亿级规模时,需要进行分库分表:
short_code为分表键分表配置(MyBatis-Plus 分片策略):
import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import org.apache.commons.codec.digest.Md5Crypt;
import java.util.Objects;
/**
* 短链表分表处理器
*
* @author 果酱
*/
public class ShortUrlTableShardingHandler implements TableNameHandler {
/**
* 基础表名
*/
private static final String BASE_TABLE_NAME = "short_url_";
/**
* 分表数量
*/
private static final int TABLE_COUNT = 16;
@Override
public String dynamicTableName(String sql, String tableName) {
// 从SQL中提取short_code参数(实际实现需更复杂的SQL解析)
// 这里简化处理,假设通过ThreadLocal传递shortCode
String shortCode = ShortCodeHolder.get();
if (StringUtils.isBlank(shortCode)) {
throw new IllegalArgumentException("短码不能为空,无法确定分表");
}
// 计算分表索引
int tableIndex = calculateTableIndex(shortCode);
// 返回实际表名
return BASE_TABLE_NAME + tableIndex;
}
/**
* 计算分表索引
*
* @param shortCode 短码
* @return 分表索引
*/
private int calculateTableIndex(String shortCode) {
Objects.requireNonNull(shortCode, "短码不能为空");
// 使用MD5哈希获取哈希值
String md5 = Md5Crypt.md5Crypt(shortCode.getBytes());
// 取哈希值的最后8位作为整数
long hashValue = Long.parseLong(md5.substring(md5.length() - 8), 16);
// 取模计算分表索引
return (int) (hashValue % TABLE_COUNT);
}
}
为提升读取性能,采用多级缓存策略:
缓存实现:
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 短链缓存管理器
* 实现多级缓存(本地缓存+Redis)
*
* @author 果酱
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShortUrlCacheManager {
/**
* 缓存键前缀
*/
private static final String CACHE_KEY_PREFIX = "short_url:";
/**
* Redis缓存过期时间(分钟)
*/
private static final int REDIS_TTL_MINUTES = 30;
/**
* 本地缓存过期时间(分钟)
*/
private static final int LOCAL_TTL_MINUTES = 5;
/**
* 本地缓存最大数量
*/
private static final int LOCAL_CACHE_MAX_SIZE = 10_0000;
private final ShortUrlMapper shortUrlMapper;
private final StringRedisTemplate stringRedisTemplate;
/**
* 本地缓存
*/
private final LoadingCache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(LOCAL_CACHE_MAX_SIZE)
.expireAfterWrite(LOCAL_TTL_MINUTES, TimeUnit.MINUTES)
.recordStats()
.build(this::loadLongUrlFromDb);
/**
* 获取长URL(优先从缓存获取)
*
* @param shortCode 短码
* @param domain 域名
* @return 长URL,如果不存在则返回null
*/
public String getLongUrl(String shortCode, String domain) {
Objects.requireNonNull(shortCode, "短码不能为空");
String actualDomain = StringUtils.defaultIfBlank(domain, "s.example.com");
String cacheKey = buildCacheKey(shortCode, actualDomain);
// 1. 从本地缓存获取
try {
return localCache.get(cacheKey);
} catch (Exception e) {
log.warn("从本地缓存获取长URL失败,key:{}", cacheKey, e);
}
// 2. 从Redis获取
String longUrl = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.hasText(longUrl)) {
log.debug("从Redis获取长URL成功,key:{}", cacheKey);
// 同步到本地缓存
localCache.put(cacheKey, longUrl);
return longUrl;
}
// 3. 从数据库获取并更新缓存
longUrl = loadLongUrlFromDb(cacheKey);
if (StringUtils.hasText(longUrl)) {
// 更新Redis缓存
stringRedisTemplate.opsForValue().set(
cacheKey,
longUrl,
REDIS_TTL_MINUTES,
TimeUnit.MINUTES
);
// 更新本地缓存
localCache.put(cacheKey, longUrl);
}
return longUrl;
}
/**
* 从数据库加载长URL
*
* @param cacheKey 缓存键
* @return 长URL,如果不存在则返回null
*/
private String loadLongUrlFromDb(String cacheKey) {
// 解析缓存键,获取shortCode和domain
String[] parts = cacheKey.split(":", 3);
if (parts.length < 3) {
log.error("缓存键格式错误,key:{}", cacheKey);
return null;
}
String shortCode = parts[1];
String domain = parts[2];
log.debug("从数据库加载长URL,shortCode:{}, domain:{}", shortCode, domain);
return shortUrlMapper.selectLongUrlByCodeAndDomain(shortCode, domain);
}
/**
* 构建缓存键
*
* @param shortCode 短码
* @param domain 域名
* @return 缓存键
*/
private String buildCacheKey(String shortCode, String domain) {
return CACHE_KEY_PREFIX + shortCode + ":" + domain;
}
/**
* 清除缓存
*
* @param shortCode 短码
* @param domain 域名
*/
public void clearCache(String shortCode, String domain) {
Objects.requireNonNull(shortCode, "短码不能为空");
String actualDomain = StringUtils.defaultIfBlank(domain, "s.example.com");
String cacheKey = buildCacheKey(shortCode, actualDomain);
// 清除本地缓存
localCache.invalidate(cacheKey);
// 清除Redis缓存
stringRedisTemplate.delete(cacheKey);
log.info("清除缓存成功,key:{}", cacheKey);
}
}
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* 短链服务接口
*
* @author 果酱
*/
@Tag(name = "短链服务", description = "提供短链的创建、查询、跳转等功能")
public interface ShortUrlService extends IService<ShortUrl> {
/**
* 创建短链
*
* @param longUrl 长URL
* @param domain 短链域名,为空则使用默认域名
* @param expireTime 过期时间,为空则永久有效
* @param createUser 创建用户
* @return 创建的短链信息
*/
@Operation(summary = "创建短链", description = "将长URL转换为短URL")
@ApiResponse(responseCode = "200", description = "创建成功",
content = @Content(schema = @Schema(implementation = ShortUrl.class)))
ShortUrl createShortUrl(
@Parameter(description = "长URL", required = true) @RequestParam String longUrl,
@Parameter(description = "短链域名") @RequestParam(required = false) String domain,
@Parameter(description = "过期时间") @RequestParam(required = false) LocalDateTime expireTime,
@Parameter(description = "创建用户") @RequestParam(required = false) String createUser);
/**
* 根据短码和域名查询短链信息
*
* @param shortCode 短码
* @param domain 域名
* @return 短链信息,如果不存在则返回null
*/
@Operation(summary = "查询短链信息", description = "根据短码和域名查询短链详细信息")
ShortUrl getShortUrlByCodeAndDomain(
@Parameter(description = "短码", required = true) @RequestParam String shortCode,
@Parameter(description = "域名") @RequestParam(required = false) String domain);
/**
* 短链跳转
*
* @param shortCode 短码
* @param domain 域名
* @param response HTTP响应对象
* @throws IOException 如果跳转失败
*/
@Operation(summary = "短链跳转", description = "将短URL重定向到原始长URL")
void redirect(
@Parameter(description = "短码", required = true) @PathVariable String shortCode,
@Parameter(description = "域名") @RequestParam(required = false) String domain,
HttpServletResponse response) throws IOException;
/**
* 分页查询短链列表
*
* @param pageNum 页码
* @param pageSize 每页大小
* @param keyword 搜索关键词(长URL或短码)
* @return 分页查询结果
*/
@Operation(summary = "分页查询短链列表", description = "根据关键词分页查询短链列表")
IPage<ShortUrl> queryShortUrls(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页大小") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword);
/**
* 更新短链状态
*
* @param id 短链ID
* @param status 状态:0-禁用,1-正常
* @return 是否更新成功
*/
@Operation(summary = "更新短链状态", description = "启用或禁用短链")
boolean updateStatus(
@Parameter(description = "短链ID", required = true) @RequestParam Long id,
@Parameter(description = "状态:0-禁用,1-正常", required = true) @RequestParam Integer status);
}
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 短链服务实现类
*
* @author 果酱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ShortUrlServiceImpl extends ServiceImpl<ShortUrlMapper, ShortUrl> implements ShortUrlService {
/**
* 默认短链域名
*/
private static final String DEFAULT_DOMAIN = "s.example.com";
/**
* 正常状态
*/
private static final int STATUS_NORMAL = 1;
/**
* 禁用状态
*/
private static final int STATUS_DISABLED = 0;
private final ShortCodeService shortCodeService;
private final ShortUrlCacheManager cacheManager;
private final ShortUrlAccessService accessService;
@Override
@Transactional(rollbackFor = Exception.class)
public ShortUrl createShortUrl(String longUrl, String domain, LocalDateTime expireTime, String createUser) {
// 参数校验
StringUtils.hasText(longUrl, "长URL不能为空");
String actualDomain = StringUtils.defaultIfBlank(domain, DEFAULT_DOMAIN);
log.info("开始创建短链,长URL:{}, 域名:{}", shortCodeService.maskUrl(longUrl), actualDomain);
// 生成短码
String shortCode = shortCodeService.generateShortCode(longUrl);
// 检查短码是否已存在(防止并发问题)
ShortUrl existing = getShortUrlByCodeAndDomain(shortCode, actualDomain);
if (Objects.nonNull(existing)) {
log.info("短码已存在,直接返回,短码:{}", shortCode);
return existing;
}
// 创建短链记录
ShortUrl shortUrl = new ShortUrl();
shortUrl.setShortCode(shortCode);
shortUrl.setLongUrl(longUrl);
shortUrl.setDomain(actualDomain);
shortUrl.setExpireTime(expireTime);
shortUrl.setCreateUser(createUser);
shortUrl.setStatus(STATUS_NORMAL);
// 保存到数据库
save(shortUrl);
log.info("短链创建成功,短码:{}, 短URL:https://{}/{}", shortCode, actualDomain, shortCode);
return shortUrl;
}
@Override
public ShortUrl getShortUrlByCodeAndDomain(String shortCode, String domain) {
StringUtils.hasText(shortCode, "短码不能为空");
String actualDomain = StringUtils.defaultIfBlank(domain, DEFAULT_DOMAIN);
log.debug("查询短链信息,短码:{}, 域名:{}", shortCode, actualDomain);
// 查询数据库
return getOne(new LambdaQueryWrapper<ShortUrl>()
.eq(ShortUrl::getShortCode, shortCode)
.eq(ShortUrl::getDomain, actualDomain)
.eq(ShortUrl::getStatus, STATUS_NORMAL)
.and(wrapper -> wrapper
.isNull(ShortUrl::getExpireTime)
.or()
.gt(ShortUrl::getExpireTime, LocalDateTime.now())));
}
@Override
public void redirect(String shortCode, String domain, HttpServletResponse response) throws IOException {
StringUtils.hasText(shortCode, "短码不能为空");
String actualDomain = StringUtils.defaultIfBlank(domain, DEFAULT_DOMAIN);
log.info("处理短链跳转,短码:{}, 域名:{}", shortCode, actualDomain);
// 从缓存获取长URL
String longUrl = cacheManager.getLongUrl(shortCode, actualDomain);
if (StringUtils.isBlank(longUrl)) {
log.warn("短链不存在或已过期,短码:{}, 域名:{}", shortCode, actualDomain);
response.sendError(HttpServletResponse.SC_NOT_FOUND, "短链不存在或已过期");
return;
}
// 记录访问日志(异步)
accessService.recordAccessAsync(shortCode, actualDomain, response);
// 302重定向(临时重定向,便于统计点击量)
response.sendRedirect(longUrl);
log.debug("短链跳转成功,短码:{}, 目标URL:{}", shortCode, shortCodeService.maskUrl(longUrl));
}
@Override
public IPage<ShortUrl> queryShortUrls(int pageNum, int pageSize, String keyword) {
log.info("分页查询短链列表,页码:{}, 每页大小:{}, 关键词:{}", pageNum, pageSize, keyword);
// 构建查询条件
LambdaQueryWrapper<ShortUrl> queryWrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(keyword)) {
queryWrapper.and(wrapper -> wrapper
.like(ShortUrl::getLongUrl, keyword)
.or()
.like(ShortUrl::getShortCode, keyword));
}
// 按创建时间倒序
queryWrapper.orderByDesc(ShortUrl::getCreateTime);
// 分页查询
IPage<ShortUrl> page = new Page<>(pageNum, pageSize);
return page(page, queryWrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateStatus(Long id, Integer status) {
Objects.requireNonNull(id, "短链ID不能为空");
if (status != STATUS_NORMAL && status != STATUS_DISABLED) {
throw new IllegalArgumentException("状态必须是0或1");
}
log.info("更新短链状态,ID:{}, 新状态:{}", id, status);
// 查询短链信息
ShortUrl shortUrl = getById(id);
if (Objects.isNull(shortUrl)) {
log.error("短链不存在,ID:{}", id);
return false;
}
// 如果状态没有变化,直接返回成功
if (shortUrl.getStatus().equals(status)) {
log.info("短链状态未变化,ID:{}", id);
return true;
}
// 更新状态
shortUrl.setStatus(status);
boolean result = updateById(shortUrl);
// 如果禁用短链,清除缓存
if (status == STATUS_DISABLED) {
cacheManager.clearCache(shortUrl.getShortCode(), shortUrl.getDomain());
}
log.info("更新短链状态完成,ID:{}, 结果:{}", id, result);
return result;
}
}
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 短链访问统计服务
*
* @author 果酱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ShortUrlAccessService {
/**
* Kafka主题
*/
private static final String KAFKA_TOPIC = "short_url_access";
private final KafkaTemplate<String, ShortUrlAccess> kafkaTemplate;
private final UserAgentParser userAgentParser;
private final IpLocationResolver ipLocationResolver;
/**
* 异步记录访问日志
*
* @param shortCode 短码
* @param domain 域名
* @param response HTTP响应对象
*/
@Async("accessLogExecutor")
public void recordAccessAsync(String shortCode, String domain, HttpServletResponse response) {
try {
recordAccess(shortCode, domain, response);
} catch (Exception e) {
log.error("记录访问日志失败,短码:{}, 域名:{}", shortCode, domain, e);
}
}
/**
* 记录访问日志
*
* @param shortCode 短码
* @param domain 域名
* @param response HTTP响应对象
*/
private void recordAccess(String shortCode, String domain, HttpServletResponse response) {
Objects.requireNonNull(shortCode, "短码不能为空");
// 创建访问记录
ShortUrlAccess access = new ShortUrlAccess();
access.setShortCode(shortCode);
access.setDomain(domain);
access.setAccessTime(LocalDateTime.now());
// 获取客户端IP
String ip = getClientIp(response);
access.setIp(ip);
// 解析IP地理位置
if (StringUtils.hasText(ip)) {
IpLocation location = ipLocationResolver.resolve(ip);
if (Objects.nonNull(location)) {
access.setCountry(location.getCountry());
access.setRegion(location.getRegion());
access.setCity(location.getCity());
}
}
// 获取并解析User-Agent
String userAgent = response.getHeader("User-Agent");
access.setUserAgent(userAgent);
if (StringUtils.hasText(userAgent)) {
UserAgentInfo info = userAgentParser.parse(userAgent);
if (Objects.nonNull(info)) {
access.setBrowser(info.getBrowser());
access.setOs(info.getOs());
access.setDevice(info.getDevice());
}
}
// 获取来源页面
String referrer = response.getHeader("Referer");
access.setReferrer(referrer);
// 发送到Kafka
kafkaTemplate.send(KAFKA_TOPIC, shortCode, access);
log.debug("访问日志记录成功,短码:{}, IP:{}", shortCode, ip);
}
/**
* 获取客户端真实IP
*
* @param response HTTP响应对象
* @return 客户端IP
*/
private String getClientIp(HttpServletResponse response) {
// 实际实现需要从请求头中获取,如X-Forwarded-For、X-Real-IP等
// 这里简化处理
return "127.0.0.1";
}
}
使用 Resilience4j 实现限流和熔断,保护系统不被流量峰值击垮:
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 短链控制器(带限流和熔断)
*
* @author 果酱
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class ShortUrlController {
private final ShortUrlService shortUrlService;
/**
* 短链跳转接口(带限流和熔断)
* 限流:每秒最多处理1000个请求
* 熔断:失败率超过50%时触发熔断,持续10秒
*/
@RateLimiter(name = "shortUrlRedirect", fallbackMethod = "redirectFallback")
@CircuitBreaker(name = "shortUrlRedirect", fallbackMethod = "redirectFallback")
public void redirect(
@PathVariable String shortCode,
@RequestParam(required = false) String domain,
HttpServletResponse response) throws IOException {
shortUrlService.redirect(shortCode, domain, response);
}
/**
* 跳转接口降级方法
*/
public void redirectFallback(
String shortCode,
String domain,
HttpServletResponse response,
Exception e) throws IOException {
log.error("短链跳转降级,短码:{}, 域名:{}", shortCode, domain, e);
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "服务繁忙,请稍后再试");
}
}
Resilience4j 配置:
resilience4j:
ratelimiter:
instances:
shortUrlRedirect:
limitRefreshPeriod: 1s
limitForPeriod: 1000
timeoutDuration: 0
circuitbreaker:
instances:
shortUrlRedirect:
slidingWindowSize: 100
failureRateThreshold: 50
waitDurationInOpenState: 10000
permittedNumberOfCallsInHalfOpenState: 10
registerHealthIndicator: true
使用 Spring 的 @Async 注解实现异步处理,提升系统吞吐量:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步线程池配置
*
* @author 果酱
*/
@Configuration
@EnableAsync
public class AsyncConfig {
/**
* 访问日志处理线程池
*/
@Bean(name = "accessLogExecutor")
public Executor accessLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(1000);
// 线程名称前缀
executor.setThreadNamePrefix("access-log-");
// 线程空闲时间
executor.setKeepAliveSeconds(60);
// 拒绝策略:由提交任务的线程处理
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 初始化
executor.initialize();
return executor;
}
}
MyBatis-Plus 分页插件配置:
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus配置
*
* @author 果酱
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
/**
* 短链系统监控指标收集器
*
* @author 果酱
*/
@Component
public class ShortUrlMetrics {
private final MeterRegistry meterRegistry;
// 创建短链指标
private Counter createSuccessCounter;
private Counter createFailCounter;
private Timer createTimer;
// 跳转指标
private Counter redirectSuccessCounter;
private Counter redirectFailCounter;
private Timer redirectTimer;
// 缓存指标
private Counter cacheHitCounter;
private Counter cacheMissCounter;
public ShortUrlMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@PostConstruct
public void init() {
// 初始化创建短链指标
createSuccessCounter = meterRegistry.counter("short_url.create.success");
createFailCounter = meterRegistry.counter("short_url.create.fail");
createTimer = Timer.builder("short_url.create.duration")
.description("创建短链耗时")
.register(meterRegistry);
// 初始化跳转指标
redirectSuccessCounter = meterRegistry.counter("short_url.redirect.success");
redirectFailCounter = meterRegistry.counter("short_url.redirect.fail");
redirectTimer = Timer.builder("short_url.redirect.duration")
.description("短链跳转耗时")
.register(meterRegistry);
// 初始化缓存指标
cacheHitCounter = meterRegistry.counter("short_url.cache.hit");
cacheMissCounter = meterRegistry.counter("short_url.cache.miss");
}
// 创建短链指标记录方法
public void recordCreateSuccess(long durationMs) {
createSuccessCounter.increment();
createTimer.record(durationMs, TimeUnit.MILLISECONDS);
}
public void recordCreateFail() {
createFailCounter.increment();
}
// 跳转指标记录方法
public void recordRedirectSuccess(long durationMs) {
redirectSuccessCounter.increment();
redirectTimer.record(durationMs, TimeUnit.MILLISECONDS);
}
public void recordRedirectFail() {
redirectFailCounter.increment();
}
// 缓存指标记录方法
public void recordCacheHit() {
cacheHitCounter.increment();
}
public void recordCacheMiss() {
cacheMissCounter.increment();
}
}
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
* 短链系统健康检查指示器
*
* @author 果酱
*/
@Component("shortUrlSystem")
public class ShortUrlHealthIndicator implements HealthIndicator {
private final StringRedisTemplate redisTemplate;
private final ShortUrlMapper shortUrlMapper;
public ShortUrlHealthIndicator(StringRedisTemplate redisTemplate, ShortUrlMapper shortUrlMapper) {
this.redisTemplate = redisTemplate;
this.shortUrlMapper = shortUrlMapper;
}
@Override
public Health health() {
// 检查Redis健康状态
boolean redisHealthy = checkRedisHealth();
// 检查数据库健康状态
boolean dbHealthy = checkDbHealth();
if (redisHealthy && dbHealthy) {
return Health.up()
.withDetail("redis", "健康")
.withDetail("database", "健康")
.build();
} else {
return Health.down()
.withDetail("redis", redisHealthy ? "健康" : "不健康")
.withDetail("database", dbHealthy ? "健康" : "不健康")
.build();
}
}
/**
* 检查Redis健康状态
*/
private boolean checkRedisHealth() {
try {
return redisTemplate.getConnectionFactory().getConnection().ping().equals("PONG");
} catch (Exception e) {
return false;
}
}
/**
* 检查数据库健康状态
*/
private boolean checkDbHealth() {
try {
shortUrlMapper.selectCount(null);
return true;
} catch (Exception e) {
return false;
}
}
}
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Objects;
import java.util.Set;
/**
* URL安全检测器
* 防止恶意URL被转换为短链
*
* @author 果酱
*/
@Slf4j
@Component
public class UrlSafetyChecker {
/**
* 禁止的URL前缀
*/
private static final Set<String> FORBIDDEN_PREFIXES = Set.of(
"javascript:", "vbscript:", "data:", "mailto:", "tel:"
);
/**
* 禁止的域名
*/
private static final Set<String> FORBIDDEN_DOMAINS = Set.of(
"malicious.com", "phishing.com", "spam.com"
);
/**
* 检查URL是否安全
*
* @param url 待检查的URL
* @return true-安全,false-不安全
*/
public boolean isSafeUrl(String url) {
Objects.requireNonNull(url, "URL不能为空");
// 检查URL前缀
String lowerUrl = url.toLowerCase();
for (String prefix : FORBIDDEN_PREFIXES) {
if (lowerUrl.startsWith(prefix)) {
log.warn("URL包含禁止的前缀,URL:{},前缀:{}", url, prefix);
return false;
}
}
// 解析并检查域名(实际实现需要更复杂的URL解析)
String domain = extractDomain(url);
if (FORBIDDEN_DOMAINS.contains(domain)) {
log.warn("URL包含禁止的域名,URL:{},域名:{}", url, domain);
return false;
}
// 其他安全检查(如长度限制、特殊字符检查等)
if (url.length() > 1000) {
log.warn("URL过长,可能存在安全风险,长度:{}", url.length());
return false;
}
return true;
}
/**
* 从URL中提取域名(简化实现)
*/
private String extractDomain(String url) {
// 实际实现应使用java.net.URL或专门的URL解析库
try {
java.net.URL urlObj = new java.net.URL(url);
return urlObj.getHost();
} catch (Exception e) {
log.error("解析URL失败,URL:{}", url, e);
return "";
}
}
}
通过本文,我们学习了如何设计一个工业级短链系统,包括:
短链系统可以进一步扩展以下功能: