短链接在现代互联网应用中扮演着重要角色。
想象一下,你在发短信推广时,一个几十个字符的长链接会占用大量字符空间。
而短链接只需要几个字符就能搞定,既节省成本又提升用户体验。
在社交媒体分享时,短链接让内容看起来更简洁。
微博的140字限制下,每个字符都很珍贵。
短链接还能帮你追踪点击数据,了解推广效果。
短链接的工作机制其实很简单:建立一个映射关系。
长链接通过某种算法生成一个短标识,存储在数据库中。
用户访问短链接时,系统根据短标识找到原始链接,然后重定向过去。
这个过程就像给每个长链接发了一张身份证,凭证就能找到本人。
随机生成就是用算法随机产生短链接标识。
这种方式实现简单,但有个问题:可能会撞车。
所以需要额外检查生成的标识是否已经存在。
自增方式使用一个递增的数字作为基础。
每次生成新的短链接,数字就加1,然后转换成短标识。
这样能保证不重复,但在分布式环境下需要考虑并发问题。
随机生成方式的核心思路是使用62进制字符集来构造短标识。
我们选择62进制是因为它包含了数字、小写字母和大写字母,既保证了足够的组合数量,又避免了特殊字符带来的URL编码问题。
下面的代码展示了完整的随机生成实现:
@RestController
@RequestMapping("/shortUrl")
public class ShortUrlController {
// 定义62进制字符集:数字+小写字母+大写字母
private String BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
// 短链接域名前缀
private String shortUrlPrefix = "http://a.cn/";
// 存储短链接和长链接的映射关系
private HashMap<String, String> map = new HashMap<>();
@RequestMapping("getShortUrl")
public String getShortUrl(String longUrl) {
// 参数校验:确保传入的URL不为空
if (longUrl == null || longUrl.trim().isEmpty()) {
throw new IllegalArgumentException("URL不能为空");
}
String key = createKey();
// 如果生成的key已存在,重新生成直到唯一
// 注意:在生产环境中应该限制重试次数,避免无限循环
int retryCount = 0;
while (map.containsKey(key) && retryCount < 10) {
key = createKey();
retryCount++;
}
if (retryCount >= 10) {
throw new RuntimeException("生成短链接失败,请重试");
}
map.put(key, longUrl);
return shortUrlPrefix + key;
}
@RequestMapping("getLongUrl")
public String getLongUrl(String shortUrl) {
// 参数校验:确保短链接格式正确
if (shortUrl == null || !shortUrl.startsWith(shortUrlPrefix)) {
throw new IllegalArgumentException("短链接格式不正确");
}
// 提取短链接标识,去掉域名前缀
String shortKey = shortUrl.replace(shortUrlPrefix, "");
String longUrl = map.get(shortKey);
// 如果找不到对应的长链接,返回null或抛出异常
if (longUrl == null) {
throw new RuntimeException("短链接不存在或已过期");
}
return longUrl;
}
private String createKey() {
Random rand = new Random();
StringBuilder sb = new StringBuilder();
// 生成6位随机字符串,每位从62个字符中随机选择
for (int i = 0; i < 6; i++) {
// 随机选择一个索引位置,范围是0-61
int randomIndex = rand.nextInt(62);
// 根据索引从字符集中取出对应字符
sb.append(BASE62.charAt(randomIndex));
}
return sb.toString();
}
}
这个实现方案有几个关键设计点需要理解:
字符集选择原理:
BASE62
字符集包含62个字符,这样6位字符串就能产生62^6种组合。
理论上能生成568亿个不同的短链接,对大多数应用来说够用了。
选择62进制而不是64进制,是为了避免URL中的特殊字符如'+'和'/'。
重复检测机制:
createKey()
方法每次随机选择6个字符组成标识。
虽然有重复的可能,但概率很小,通过while循环能确保唯一性。
在实际生产中,这个检测应该放在数据库层面,而不是内存HashMap。
自增方式的优势在于能够保证生成的短链接绝对不重复。
我们从一个较大的数字开始(比如100万),这样生成的短链接不会太短,看起来更专业。
每次生成新链接时,数字递增1,然后转换为Base62编码:
@RestController
@RequestMapping("/shortUrl2")
public class ShortUrl2Controller {
private String shortUrlPrefix = "http://a.cn/";
private HashMap<String, String> map = new HashMap<>();
// 从100万开始,避免短链接太短
private Long num = 1000000L;
@RequestMapping("getShortUrl")
public String getShortUrl(String longUrl) {
String key = createKey();
map.put(key, longUrl);
return shortUrlPrefix + key;
}
@RequestMapping("getLongUrl")
public String getLongUrl(String shortUrl) {
return map.get(shortUrl.replace(shortUrlPrefix, ""));
}
private String createKey() {
String base62 = Base62Util.base62Encode(num);
num++;
return base62;
}
}
Base62编码是整个自增方案的核心算法。
它的作用是将十进制数字转换成62进制字符串,从而大大缩短标识的长度。
这个转换过程类似于我们熟悉的十进制转二进制,只是进制基数不同:
public class Base62Util {
private static final String BASE62_CHARACTERS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static String base62Encode(long num) {
List<Character> base62Digits = new ArrayList<>();
// 进制转换的核心算法:不断除以62取余数
do {
// 取余数,得到当前位的数字(0-61)
int remainder = (int) (num % 62);
// 将数字转换为对应的字符并存储
base62Digits.add(BASE62_CHARACTERS.charAt(remainder));
// 继续处理商,相当于右移一位
num /= 62;
} while (num > 0);
StringBuilder sb = new StringBuilder();
// 反转数字顺序,得到正确的Base62编码
// 因为我们是从低位到高位计算的,需要反转才是正确顺序
for (int i = base62Digits.size() - 1; i >= 0; i--) {
sb.append(base62Digits.get(i));
}
return sb.toString();
}
}
Base62编码就是把十进制数字转换成62进制表示。
这个转换过程可以用一个具体例子来理解:
转换示例:
数字1000000的转换过程:
这样就把7位数字压缩成了4位字符串,压缩率非常高。
每次num自增,确保生成的短链接都不重复。
访问接口:GET /shortUrl/getShortUrl?longUrl=https://www.example.com
返回结果:http://a.cn/aBc123
访问接口:GET /shortUrl/getLongUrl?shortUrl=http://a.cn/aBc123
返回结果:https://www.example.com
在真实的生产环境中,单机自增肯定不够用。
多台服务器同时生成ID会导致冲突,这时候就需要分布式ID生成方案。
雪花算法是Twitter开源的分布式ID生成算法,它能保证在分布式环境下生成全局唯一的ID。
雪花算法的组成结构:
@Component
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 检查时钟回拨,这在分布式环境中可能发生
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨异常");
}
// 如果是同一毫秒内的请求,序列号递增
if (lastTimestamp == timestamp) {
// 序列号递增,使用位运算确保不超过12位(4095)
sequence = (sequence + 1) & 4095; // 4095 = 2^12 - 1
// 如果序列号溢出,等待下一毫秒
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// 新的毫秒,序列号重置为0
sequence = 0L;
}
lastTimestamp = timestamp;
// 组装64位ID:时间戳(41位) + 数据中心ID(5位) + 工作机器ID(5位) + 序列号(12位)
return ((timestamp - 1609459200000L) << 22) // 时间戳左移22位(减去起始时间戳)
| (datacenterId << 17) // 数据中心ID左移17位
| (workerId << 12) // 工作机器ID左移12位
| sequence; // 序列号占最低12位
}
private long waitNextMillis(long lastTimestamp) {
// 等待下一毫秒,确保时间戳递增
// 这个方法在高并发场景下可能会消耗CPU,但保证了ID的唯一性
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
数据库查询是短链接服务的性能瓶颈,特别是在高并发场景下。
Redis作为内存数据库,查询速度比MySQL快几个数量级。
我们可以将热点数据缓存到Redis中,大幅提升响应速度。
缓存策略设计:
@Service
public class ShortUrlService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String SHORT_URL_PREFIX = "short_url:";
private static final int EXPIRE_TIME = 7 * 24 * 3600; // 7天过期
public void saveMapping(String shortKey, String longUrl) {
// 构造Redis键名,使用前缀避免键冲突
String redisKey = SHORT_URL_PREFIX + shortKey;
// 设置键值对,同时设置过期时间防止内存泄漏
redisTemplate.opsForValue().set(redisKey, longUrl, EXPIRE_TIME, TimeUnit.SECONDS);
}
public String getLongUrl(String shortKey) {
// 根据短链接标识查询原始URL
String redisKey = SHORT_URL_PREFIX + shortKey;
return redisTemplate.opsForValue().get(redisKey);
}
public boolean exists(String shortKey) {
// 快速判断短链接是否存在,避免无效查询
String redisKey = SHORT_URL_PREFIX + shortKey;
return Boolean.TRUE.equals(redisTemplate.hasKey(redisKey));
}
}
当数据量达到千万级别时,即使是Redis查询也会成为瓶颈。
布隆过滤器是一种空间效率极高的概率型数据结构,用于快速判断元素是否存在。
它的特点是:如果说不存在,那一定不存在;如果说存在,可能存在误判。
布隆过滤器的优势:
@Component
public class BloomFilterService {
private BloomFilter<String> bloomFilter;
@PostConstruct
public void init() {
// 初始化布隆过滤器,需要预估数据量和可接受的误判率
// 预估100万个元素,误判率0.01%(万分之一)
// 误判率越低,占用内存越大,需要根据实际情况平衡
bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()), // 字符串哈希函数
1000000, // 预期元素数量
0.0001 // 误判率(0.01%)
);
}
public boolean mightContain(String url) {
return bloomFilter.mightContain(url);
}
public void put(String url) {
bloomFilter.put(url);
}
}
在实际生产环境中,我们采用"布隆过滤器 + Redis + 数据库"的三层架构来优化查询性能:
@Service
public class ShortUrlService {
@Autowired
private BloomFilterService bloomFilterService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ShortUrlMapper shortUrlMapper;
public String getLongUrl(String shortKey) {
// 第一层:布隆过滤器快速过滤不存在的数据
// 如果布隆过滤器说不存在,那一定不存在,直接返回
if (!bloomFilterService.mightContain(shortKey)) {
return null; // 避免无效的Redis和数据库查询
}
// 第二层:Redis缓存查询
String redisKey = "short_url:" + shortKey;
String longUrl = redisTemplate.opsForValue().get(redisKey);
if (longUrl != null) {
return longUrl; // 缓存命中,直接返回
}
// 第三层:数据库查询(布隆过滤器可能误判)
ShortUrl shortUrl = shortUrlMapper.selectByShortKey(shortKey);
if (shortUrl != null) {
// 查询到数据,回写Redis缓存
redisTemplate.opsForValue().set(redisKey, shortUrl.getLongUrl(),
Duration.ofHours(24)); // 缓存24小时
return shortUrl.getLongUrl();
}
return null; // 确实不存在
}
public String createShortUrl(String longUrl) {
String shortKey = generateShortKey();
// 保存到数据库
ShortUrl shortUrl = new ShortUrl();
shortUrl.setShortKey(shortKey);
shortUrl.setLongUrl(longUrl);
shortUrlMapper.insert(shortUrl);
// 同步更新布隆过滤器和Redis缓存
bloomFilterService.put(shortKey);
String redisKey = "short_url:" + shortKey;
redisTemplate.opsForValue().set(redisKey, longUrl, Duration.ofHours(24));
return shortKey;
}
}
这种三层架构的优势:
-- 短链接数据表设计,考虑了性能和扩展性
CREATE TABLE short_url (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 主键,自增ID
short_key VARCHAR(10) NOT NULL UNIQUE COMMENT '短链接标识', -- 短链接标识,唯一索引
long_url TEXT NOT NULL COMMENT '原始长链接', -- 原始URL,使用TEXT支持长URL
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 创建时间,自动填充
expire_time TIMESTAMP NULL COMMENT '过期时间', -- 过期时间,可为空表示永不过期
click_count INT DEFAULT 0 COMMENT '点击次数', -- 点击统计,默认为0
INDEX idx_short_key (short_key), -- 短链接查询索引(最重要)
INDEX idx_create_time (create_time) -- 时间范围查询索引
);
数据访问层负责与数据库的交互操作。
这里使用JdbcTemplate而不是JPA,是为了更好地控制SQL语句,提升查询性能。
特别注意expire_time的处理,确保过期的短链接不会被查询到:
@Repository
public class ShortUrlRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
public void save(String shortKey, String longUrl) {
String sql = "INSERT INTO short_url (short_key, long_url) VALUES (?, ?)";
jdbcTemplate.update(sql, shortKey, longUrl);
}
public String findLongUrl(String shortKey) {
// 查询SQL包含过期时间检查,确保不返回过期的链接
// expire_time IS NULL 表示永不过期
// expire_time > NOW() 表示还未过期
String sql = "SELECT long_url FROM short_url WHERE short_key = ? AND (expire_time IS NULL OR expire_time > NOW())";
try {
return jdbcTemplate.queryForObject(sql, String.class, shortKey);
} catch (EmptyResultDataAccessException e) {
// 没有找到记录时返回null,而不是抛出异常
return null;
}
}
public void incrementClickCount(String shortKey) {
String sql = "UPDATE short_url SET click_count = click_count + 1 WHERE short_key = ?";
jdbcTemplate.update(sql, shortKey);
}
}
短链接服务不仅要提供跳转功能,还要统计访问数据。
这对于营销推广和数据分析非常重要。
我们使用异步更新来避免统计操作影响跳转性能,用户体验优先:
@RestController
public class ShortUrlRedirectController {
@Autowired
private ShortUrlService shortUrlService;
@Autowired
private ShortUrlRepository repository;
@GetMapping("/s/{shortKey}")
public ResponseEntity<Void> redirect(@PathVariable String shortKey) {
String longUrl = shortUrlService.getLongUrl(shortKey);
if (longUrl == null) {
return ResponseEntity.notFound().build();
}
// 异步更新点击统计,不阻塞用户跳转
// 使用CompletableFuture确保统计操作在后台执行
CompletableFuture.runAsync(() -> {
try {
repository.incrementClickCount(shortKey);
} catch (Exception e) {
// 统计失败不影响用户体验,只记录日志
logger.error("更新点击统计失败: shortKey={}", shortKey, e);
}
});
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(longUrl));
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
}
详细的访问日志对于分析用户行为和排查问题非常重要。
通过拦截器的方式,我们可以统一记录所有短链接的访问情况。
特别要注意获取真实IP地址,考虑代理和负载均衡的情况:
@Component
public class AccessLogInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(AccessLogInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String shortKey = extractShortKey(request.getRequestURI());
String userAgent = request.getHeader("User-Agent");
String clientIp = getClientIp(request);
// 记录访问日志
logger.info("短链接访问: shortKey={}, ip={}, userAgent={}", shortKey, clientIp, userAgent);
return true;
}
private String getClientIp(HttpServletRequest request) {
// 获取真实客户端IP,考虑代理和负载均衡的情况
// X-Forwarded-For是代理服务器添加的头部,包含真实客户端IP
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
// 可能包含多个IP,第一个是真实客户端IP
return xForwardedFor.split(",")[0].trim();
}
// 如果没有代理,直接获取远程地址
return request.getRemoteAddr();
}
private String extractShortKey(String uri) {
return uri.substring(uri.lastIndexOf('/') + 1);
}
}
小型应用(日访问量 < 10万):
中型应用(日访问量 10万-100万):
大型应用(日访问量 > 100万):
这套短链接服务涵盖了从基础实现到生产优化的完整方案。
在实际使用中,可以根据业务需求选择合适的生成策略和优化方案。
简单的方案往往是最好的方案,不要过度设计。
参考:https://blog.csdn.net/java_zhangshuai/article/details/106942758
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。