Java短链接生成服务实战指南
1. 短链接服务的实际应用
1.1 为什么需要短链接
短链接在现代互联网应用中扮演着重要角色。
想象一下,你在发短信推广时,一个几十个字符的长链接会占用大量字符空间。
而短链接只需要几个字符就能搞定,既节省成本又提升用户体验。
在社交媒体分享时,短链接让内容看起来更简洁。
微博的140字限制下,每个字符都很珍贵。
短链接还能帮你追踪点击数据,了解推广效果。
1.2 短链接的核心原理
短链接的工作机制其实很简单:建立一个映射关系。
长链接通过某种算法生成一个短标识,存储在数据库中。
用户访问短链接时,系统根据短标识找到原始链接,然后重定向过去。
这个过程就像给每个长链接发了一张身份证,凭证就能找到本人。
2. 两种生成策略对比
2.1 随机生成策略
随机生成就是用算法随机产生短链接标识。
这种方式实现简单,但有个问题:可能会撞车。
所以需要额外检查生成的标识是否已经存在。
2.2 自增生成策略
自增方式使用一个递增的数字作为基础。
每次生成新的短链接,数字就加1,然后转换成短标识。
这样能保证不重复,但在分布式环境下需要考虑并发问题。
3. 随机生成方式实现
3.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-61int randomIndex = rand.nextInt(62);// 根据索引从字符集中取出对应字符sb.append(BASE62.charAt(randomIndex));}return sb.toString();}
}
3.2 代码解析
这个实现方案有几个关键设计点需要理解:
字符集选择原理:
BASE62
字符集包含62个字符,这样6位字符串就能产生62^6种组合。
理论上能生成568亿个不同的短链接,对大多数应用来说够用了。
选择62进制而不是64进制,是为了避免URL中的特殊字符如’+‘和’/'。
重复检测机制:
createKey()
方法每次随机选择6个字符组成标识。
虽然有重复的可能,但概率很小,通过while循环能确保唯一性。
在实际生产中,这个检测应该放在数据库层面,而不是内存HashMap。
4. 自增生成方式实现
4.1 主控制器代码
自增方式的优势在于能够保证生成的短链接绝对不重复。
我们从一个较大的数字开始(比如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;}
}
4.2 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();}
}
4.3 编码原理说明
Base62编码就是把十进制数字转换成62进制表示。
这个转换过程可以用一个具体例子来理解:
转换示例:
数字1000000的转换过程:
- 1000000 ÷ 62 = 16129,余数 8 → 字符’8’
- 16129 ÷ 62 = 260,余数 9 → 字符’9’
- 260 ÷ 62 = 4,余数 12 → 字符’c’
- 4 ÷ 62 = 0,余数 4 → 字符’4’
- 反转后得到:“4c98”
这样就把7位数字压缩成了4位字符串,压缩率非常高。
每次num自增,确保生成的短链接都不重复。
5. 接口使用示例
5.1 生成短链接
访问接口:GET /shortUrl/getShortUrl?longUrl=https://www.example.com
返回结果:http://a.cn/aBc123
5.2 获取原始链接
访问接口:GET /shortUrl/getLongUrl?shortUrl=http://a.cn/aBc123
返回结果:https://www.example.com
6. 生产环境优化策略
6.1 分布式ID生成
在真实的生产环境中,单机自增肯定不够用。
多台服务器同时生成ID会导致冲突,这时候就需要分布式ID生成方案。
雪花算法是Twitter开源的分布式ID生成算法,它能保证在分布式环境下生成全局唯一的ID。
雪花算法的组成结构:
- 1位符号位(固定为0)
- 41位时间戳(毫秒级,可用69年)
- 10位机器标识(5位数据中心ID + 5位工作机器ID)
- 12位序列号(同一毫秒内的递增序列)
@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 {// 新的毫秒,序列号重置为0sequence = 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;}
}
6.2 Redis缓存优化
数据库查询是短链接服务的性能瓶颈,特别是在高并发场景下。
Redis作为内存数据库,查询速度比MySQL快几个数量级。
我们可以将热点数据缓存到Redis中,大幅提升响应速度。
缓存策略设计:
- 设置合理的过期时间,避免缓存无限增长
- 使用统一的key前缀,便于管理和清理
- 提供exists方法,快速判断短链接是否存在
@Service
public class ShortUrlService {@Autowiredprivate 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) {// 根据短链接标识查询原始URLString 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));}
}
6.3 布隆过滤器防重复
当数据量达到千万级别时,即使是Redis查询也会成为瓶颈。
布隆过滤器是一种空间效率极高的概率型数据结构,用于快速判断元素是否存在。
它的特点是:如果说不存在,那一定不存在;如果说存在,可能存在误判。
布隆过滤器的优势:
- 空间占用极小,100万元素只需要几MB内存
- 查询速度极快,时间复杂度O(1)
- 适合作为第一层过滤,减少数据库查询
@Component
public class BloomFilterService {private BloomFilter<String> bloomFilter;@PostConstructpublic 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);}
}
6.4 三层架构应用
在实际生产环境中,我们采用"布隆过滤器 + Redis + 数据库"的三层架构来优化查询性能:
@Service
public class ShortUrlService {@Autowiredprivate BloomFilterService bloomFilterService;@Autowiredprivate RedisTemplate<String, String> redisTemplate;@Autowiredprivate 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;}
}
这种三层架构的优势:
- 布隆过滤器:过滤99%以上的无效请求,保护后端系统
- Redis缓存:处理热点数据访问,响应时间在毫秒级
- 数据库:作为最终数据源,保证数据一致性
7. 数据库设计
7.1 短链接表结构
-- 短链接数据表设计,考虑了性能和扩展性
CREATE TABLE short_url (id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 主键,自增IDshort_key VARCHAR(10) NOT NULL UNIQUE COMMENT '短链接标识', -- 短链接标识,唯一索引long_url TEXT NOT NULL COMMENT '原始长链接', -- 原始URL,使用TEXT支持长URLcreate_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', -- 创建时间,自动填充expire_time TIMESTAMP NULL COMMENT '过期时间', -- 过期时间,可为空表示永不过期click_count INT DEFAULT 0 COMMENT '点击次数', -- 点击统计,默认为0INDEX idx_short_key (short_key), -- 短链接查询索引(最重要)INDEX idx_create_time (create_time) -- 时间范围查询索引
);
7.2 数据访问层实现
数据访问层负责与数据库的交互操作。
这里使用JdbcTemplate而不是JPA,是为了更好地控制SQL语句,提升查询性能。
特别注意expire_time的处理,确保过期的短链接不会被查询到:
@Repository
public class ShortUrlRepository {@Autowiredprivate 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);}
}
8. 性能监控与统计
8.1 点击统计功能
短链接服务不仅要提供跳转功能,还要统计访问数据。
这对于营销推广和数据分析非常重要。
我们使用异步更新来避免统计操作影响跳转性能,用户体验优先:
@RestController
public class ShortUrlRedirectController {@Autowiredprivate ShortUrlService shortUrlService;@Autowiredprivate 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);}
}
8.2 访问日志记录
详细的访问日志对于分析用户行为和排查问题非常重要。
通过拦截器的方式,我们可以统一记录所有短链接的访问情况。
特别要注意获取真实IP地址,考虑代理和负载均衡的情况:
@Component
public class AccessLogInterceptor implements HandlerInterceptor {private static final Logger logger = LoggerFactory.getLogger(AccessLogInterceptor.class);@Overridepublic 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是代理服务器添加的头部,包含真实客户端IPString xForwardedFor = request.getHeader("X-Forwarded-For");if (xForwardedFor != null && !xForwardedFor.isEmpty()) {// 可能包含多个IP,第一个是真实客户端IPreturn xForwardedFor.split(",")[0].trim();}// 如果没有代理,直接获取远程地址return request.getRemoteAddr();}private String extractShortKey(String uri) {return uri.substring(uri.lastIndexOf('/') + 1);}
}
9. 总结
小型应用(日访问量 < 10万):
- 使用随机生成策略,简单易实现
- 数据存储用MySQL即可
- 不需要复杂的缓存和分布式方案
中型应用(日访问量 10万-100万):
- 推荐自增生成策略,避免重复检测开销
- 引入Redis缓存热点数据
- 考虑数据库读写分离
大型应用(日访问量 > 100万):
- 必须使用分布式ID生成(雪花算法)
- 多级缓存架构(本地缓存 + Redis)
- 布隆过滤器预过滤
- 数据库分库分表
这套短链接服务涵盖了从基础实现到生产优化的完整方案。
在实际使用中,可以根据业务需求选择合适的生成策略和优化方案。
简单的方案往往是最好的方案,不要过度设计。
参考:https://blog.csdn.net/java_zhangshuai/article/details/106942758