
简介这是一套基于Java实现的短链接生成工具完整源码面向具备一定Java与前端基础的开发者可用于学习链接管理、访问统计与AB测试等典型业务场景。项目融合Java、Vue、JavaScript、CSS与HTML等技术栈核心能力包括将原始网页链接压缩为简洁短链、记录每次访问的详细信息并生成地区分布与设备信息等统计图表同时支持随时修改跳转目标、批量创建短链兼顾管理效率与灵活性。资源包共280个文件约1.44MB其中187个Java源文件构成后端主体19个Vue组件与11个JavaScript文件负责前端交互另有XML、YAML配置及PNG、SVG等静态资源目录结构清晰便于按模块阅读与二次开发。目前已有329人学习下载适合希望掌握短链系统设计思路、统计埋点与前后端协作方式的读者参考借鉴。1. 短链接生成工具从长网址到短码Java 方案到底怎么落地一条带 UTM 参数的电商活动链接动辄两三百个字符丢进短信里直接截断贴在海报上得用放大镜看。短链接生成工具要解决的就是这件事把任意长网址压缩成https://域名/abc123这种短码用户点击后 302 跳转到原始地址。听起来简单但真正上线要考虑的东西不少——短码怎么生成才不撞车、并发写入怎么保证唯一、跳转用 301 还是 302、缓存怎么扛住热点链接、数据量大到千万级之后索引还走不走得动。这套基于 Java 的实现方案核心就是围绕「发号 → 映射 → 跳转 → 统计」这条链路来设计。适合有 Java 基础、想做一个能真正跑起来的后端小项目的开发者也适合拿它来练手 Spring Boot、Redis、分库分表这些常见技术栈的组合使用。下面从表结构设计开始一步步把可运行的代码和踩过的坑讲清楚。2. 短码生成算法选型自增发号、哈希与 Base62 的取舍短链接工具最核心的问题就是给你一个长网址怎么产出一个短码这个短码要满足三个条件——全局唯一、尽量短、生成速度够快。常见的做法有三类各有各的适用场景选错了后面改起来很痛苦。2.1 三种主流短码生成策略对比自增 ID Base62 编码是最稳妥的方案。数据库维护一个自增主键拿到 ID 之后转成 62 进制字符串。比如 ID 为 1000Base62 编码后是gi只有两位。这种方案的好处是绝对不会重复因为自增 ID 本身就不重复短码长度随 ID 增长而缓慢增长6 位 Base62 能表示 568 亿个组合够用很久。缺点是短码可被枚举——别人从a开始递增就能遍历你的所有链接。如果业务对这一点敏感可以在 Base62 之前做一次混淆比如把 ID 和一个固定大质数做异或。哈希取模是另一种思路。对长网址做 MD5 或 MurmurHash取哈希值的前 N 位作为短码。问题是哈希必然存在碰撞两个不同的长网址可能映射到同一个短码。解决碰撞的方式是检测到冲突后加盐重新哈希但这样每次生成都要查一次库并发高了性能会受影响。预生成短码池适合对写入性能要求极高的场景。后台批量生成一批短码存进队列前端请求来了直接从队列里取取完再补。好处是写入路径极短坏处是要维护一个额外的短码池服务复杂度上去了。方案唯一性保证短码长度写入性能可枚举风险适用场景自增IDBase62强保证4-7位高有大多数业务哈希取模需冲突检测6-8位中低对不可枚举有要求预生成池强保证可控极高有超高并发写入我一般会选自增 ID Base62再叠加一层轻量混淆。理由很简单唯一性由数据库保证不需要在应用层做冲突检测代码路径最短出问题的概率最低。2.2 Base62 编码的 Java 实现Base62 就是用0-9、a-z、A-Z共 62 个字符来表示数字。核心逻辑是不断对 62 取余余数查字符表商继续下一轮直到商为 0。public class Base62Encoder { // 62个字符顺序可以自定义打乱后短码更不可预测 private static final String CHARS 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ; private static final int BASE CHARS.length(); /** * 将十进制数字编码为 Base62 字符串 * param num 自增ID必须为非负数 * return Base62 短码 */ public static String encode(long num) { if (num 0) { throw new IllegalArgumentException(num must be non-negative); } if (num 0) { return String.valueOf(CHARS.charAt(0)); } StringBuilder sb new StringBuilder(); while (num 0) { int remainder (int) (num % BASE); sb.append(CHARS.charAt(remainder)); num / BASE; } // 反转得到正确顺序 return sb.reverse().toString(); } /** * 将 Base62 字符串解码回十进制数字 * param code 短码 * return 原始数字 */ public static long decode(String code) { long result 0; for (int i 0; i code.length(); i) { int digit CHARS.indexOf(code.charAt(i)); if (digit 0) { throw new IllegalArgumentException(invalid char: code.charAt(i)); } result result * BASE digit; } return result; } }这段代码有两个关键点。第一CHARS的顺序可以打乱打乱后即使别人知道你是自增 ID也没法直接猜出短码序列。第二encode里先 append 再 reverse是因为取余得到的字符是从低位到高位的必须反转才是正确顺序。decode方法在排查问题时很有用——拿到一个短码反解出 ID就能直接去数据库定位记录。2.3 混淆策略让短码不可枚举如果直接用自增 ID 做 Base62短码就是1、2、3……这种顺序。别人写个循环就能遍历你所有链接。常见的混淆方式有两种一种是异或混淆。选一个足够大的质数作为密钥把 ID 和密钥做异或后再 Base62。这样短码看起来就是乱序的但解码时再异或一次就能还原。注意异或密钥要足够大否则短码长度会不够。另一种是Feistel 网络。把 ID 拆成两半做多轮可逆变换每轮用不同的轮函数。这种方式更安全但实现复杂度也更高。对于大多数业务场景异或混淆已经够用了。// 异或混淆示例密钥取一个较大的质数 private static final long XOR_KEY 0x5DEECE66DL; public static String encodeWithObfuscation(long id) { long obfuscated id ^ XOR_KEY; return Base62Encoder.encode(obfuscated); } public static long decodeWithObfuscation(String code) { long obfuscated Base62Encoder.decode(code); return obfuscated ^ XOR_KEY; }注意异或混淆只能防住「顺手遍历」不能防住有心人。如果业务对安全性要求高应该在短码之外再加一层签名校验或者直接用随机短码 唯一索引冲突重试的方案。3. 存储层设计MySQL 表结构与 Redis 缓存的配合短链接系统的读写比极度倾斜——写入一次可能被点击几万次。所以存储层要分两块MySQL 负责持久化和唯一性保证Redis 负责扛住高频读取。3.1 MySQL 表结构设计与索引选择核心表就一张字段不多但每个字段的类型和索引都要想清楚。CREATE TABLE short_link ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 自增主键, short_code VARCHAR(10) NOT NULL COMMENT Base62短码, original_url VARCHAR(2048) NOT NULL COMMENT 原始长网址, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, expire_at DATETIME DEFAULT NULL COMMENT 过期时间NULL表示永不过期, click_count BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 点击次数, PRIMARY KEY (id), UNIQUE KEY uk_short_code (short_code), KEY idx_expire_at (expire_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短链接映射表;几个设计决策值得展开说。original_url用VARCHAR(2048)而不是TEXT是因为 VARCHAR 在 InnoDB 里可以走覆盖索引而且 2048 字节足够覆盖绝大多数 URL 长度。short_code加了唯一索引这是防止短码冲突的最后一道防线——即使应用层出了问题数据库也不会让重复短码写进去。expire_at加普通索引是为了定时清理过期链接时能快速定位。click_count字段放在主表里其实有争议。如果点击量很大每次跳转都更新这个字段会造成频繁的行锁竞争。更常见的做法是把点击事件写到 Redis 或消息队列异步批量更新。如果只是做个 demo直接更新问题不大如果要上线建议拆出去。3.2 Redis 缓存策略与过期时间设置跳转接口的 QPS 可能是写入的几百倍每次跳转都查 MySQL 肯定扛不住。Redis 缓存是必须的但缓存策略有几个细节要注意。缓存 Key 用short_link:{short_code}Value 存原始 URL。过期时间设置有两种思路如果链接本身有过期时间Redis 的 TTL 就设成和链接过期时间一致如果链接永不过期Redis TTL 可以设成 24 小时配合懒加载——缓存没命中就查 MySQL 并回写。Service public class ShortLinkService { Autowired private StringRedisTemplate redisTemplate; Autowired private ShortLinkMapper shortLinkMapper; private static final String CACHE_KEY_PREFIX short_link:; private static final long CACHE_TTL_HOURS 24; /** * 根据短码获取原始URL优先走Redis缓存 */ public String getOriginalUrl(String shortCode) { String cacheKey CACHE_KEY_PREFIX shortCode; // 1. 先查Redis String cachedUrl redisTemplate.opsForValue().get(cacheKey); if (cachedUrl ! null) { return cachedUrl; } // 2. 缓存未命中查MySQL ShortLink link shortLinkMapper.selectByShortCode(shortCode); if (link null) { return null; } // 3. 检查是否过期 if (link.getExpireAt() ! null link.getExpireAt().before(new Date())) { return null; } // 4. 回写Redis redisTemplate.opsForValue().set( cacheKey, link.getOriginalUrl(), CACHE_TTL_HOURS, TimeUnit.HOURS); return link.getOriginalUrl(); } }这段代码里有个容易忽略的点缓存穿透。如果有人拿一个不存在的短码疯狂请求每次都会穿透 Redis 打到 MySQL。解决办法是在 Redis 里缓存一个空值TTL 设短一点比如 5 分钟。这样短时间内重复请求同一个不存在的短码就不会反复查库了。另一个问题是缓存雪崩。如果大量链接的缓存同时过期请求会瞬间全打到 MySQL。解决办法是在 TTL 上加一个随机偏移量比如24小时 random(0, 3600)秒让过期时间分散开。3.3 分库分表的时机判断单表数据量在 500 万以内MySQL 的 B 树索引还能保持不错的查询性能。超过这个量级就需要考虑分库分表了。短链接表的分片键很自然就是short_code因为查询几乎都是按短码查。常见的分片算法是对短码做哈希取模或者用一致性哈希。但分库分表会带来一个麻烦自增 ID 不再全局唯一。这时候要么用分布式 ID 生成器雪花算法等要么每个分片独立自增但加一个分片前缀。我一般会建议如果单表还没到 500 万行先别急着分。过早分库分表带来的复杂度往往比性能收益更大。4. 跳转服务实现Spring Boot 接口与 301/302 的选择存储层搞定之后跳转服务就是对外暴露的入口。这部分逻辑不复杂但 HTTP 状态码的选择、参数校验、防刷策略这些细节决定了服务的质量。4.1 跳转接口的完整实现RestController public class RedirectController { Autowired private ShortLinkService shortLinkService; /** * 短链接跳转接口 * param shortCode 路径中的短码 * return 302重定向到原始URL */ GetMapping(/{shortCode}) public ResponseEntityVoid redirect( PathVariable String shortCode, HttpServletRequest request) { // 1. 参数校验短码只允许字母和数字长度限制 if (shortCode null || !shortCode.matches(^[0-9a-zA-Z]{1,10}$)) { return ResponseEntity.badRequest().build(); } // 2. 查询原始URL String originalUrl shortLinkService.getOriginalUrl(shortCode); if (originalUrl null) { return ResponseEntity.notFound().build(); } // 3. 异步记录点击事件 shortLinkService.recordClick(shortCode, request); // 4. 302临时重定向 HttpHeaders headers new HttpHeaders(); headers.setLocation(URI.create(originalUrl)); return new ResponseEntity(headers, HttpStatus.FOUND); } }参数校验这一步不能省。短码只允许字母和数字长度限制在 10 位以内这样可以直接挡掉一批恶意请求。recordClick方法建议做成异步的用Async注解或者丢到线程池里执行不要阻塞跳转主流程。4.2 301 与 302 的取舍这是短链接系统里最常被讨论的问题。301 是永久重定向浏览器会缓存这个映射关系下次再访问同一个短码就直接跳转不再请求你的服务器。302 是临时重定向每次都会请求服务器。选 301 的好处是省服务器资源坏处是你丢失了后续的点击数据——浏览器不请求了你就统计不到。选 302 的好处是每次跳转都经过服务器点击统计准确而且你可以随时修改短码指向的原始 URL。坏处是服务器压力大。对于短链接服务我一般选302。原因很直接短链接的核心价值之一就是可统计、可修改。如果用了 301用户浏览器缓存了跳转关系你后面想改目标地址都改不了点击数据也丢了。除非是纯公益的、不需要统计的短链接服务才考虑 301。4.3 防刷与限流短链接跳转接口是公开的很容易被刷。常见的防护手段有两层IP 限流和短码黑名单。IP 限流可以用 Redis 做滑动窗口。每个 IP 每分钟最多请求 60 次跳转超过就返回 429。实现方式是用INCR命令配合EXPIRE或者用 Redis 的 Sorted Set 做精确的滑动窗口。/** * 基于Redis的简单IP限流 * return true表示允许通过false表示被限流 */ public boolean allowRequest(String ip) { String key rate_limit: ip; Long count redisTemplate.opsForValue().increment(key); if (count 1) { // 第一次请求设置过期时间 redisTemplate.expire(key, 1, TimeUnit.MINUTES); } return count 60; }这段代码有个小问题increment和expire不是原子操作。如果第一次increment之后、expire之前服务挂了这个 key 就永远不会过期。更稳妥的做法是用 Lua 脚本把两个命令包在一起执行。短码黑名单则是针对已经确认的恶意链接把短码加入 Redis Set跳转前先检查一下。这个功能在内容审核场景下很有用。5. 避坑与排查短链接系统上线后最容易翻车的 5 个点前面把主流程讲完了但真正上线之后出问题的地方往往不在主流程而在一些边角细节上。下面这 5 个坑都是我或者身边同事实际踩过的按「现象 → 原因 → 解决」整理出来。5.1 短码冲突导致写入失败现象高并发写入时偶尔出现Duplicate entry for key uk_short_code异常用户看到 500 错误。原因如果短码生成逻辑不是严格依赖数据库自增 ID而是用了哈希或者随机数并发场景下就可能生成相同的短码。即使概率很低量大了也会撞上。解决最彻底的办法是用数据库自增 ID 做 Base62从根上杜绝冲突。如果必须用随机短码写入时捕获唯一索引冲突异常重新生成一次再试重试 3 次还失败就返回错误。不要用「先查再插」的方式并发下查和插之间有窗口期照样会冲突。5.2 Redis 缓存与 MySQL 数据不一致现象修改了某个短链接的目标 URL但跳转还是跳到旧地址过了好一会儿才更新。原因更新 MySQL 之后没有删除 Redis 缓存或者删除失败了。缓存里还是旧数据直到 TTL 过期才回源。解决更新流程必须是「先更新 MySQL再删除 Redis」而不是「先删 Redis 再更新 MySQL」。删除失败时要重试或者把删除操作丢到消息队列里保证最终一致。更简单的做法是给缓存设一个较短的 TTL比如 5 分钟即使删除失败最多 5 分钟后也会自动纠正。5.3 长网址超长导致插入失败现象某些带大量参数的 URL 插入 MySQL 时报Data too long for column original_url。原因VARCHAR(2048)看起来够用但有些电商平台的 URL 带一堆追踪参数轻松超过 2048 字符。解决两个方案。一是把字段改成TEXT类型但会失去覆盖索引的能力。二是插入前先判断长度超过阈值就拒绝并返回明确错误。我一般选后者因为超长 URL 本身就不适合做短链接——短链接的价值在于简洁原始 URL 太长说明它本身就需要被清理。5.4 过期链接清理任务锁表现象定时清理任务执行时数据库 CPU 飙升正常跳转请求变慢。原因清理任务用DELETE FROM short_link WHERE expire_at NOW()一次性删除大量数据长事务锁住了大量行。解决分批删除每次删 1000 行删完 sleep 100 毫秒再删下一批。或者用LIMIT子句控制单次删除量。另外清理任务尽量放在低峰期执行比如凌晨 3 点。5.5 短码大小写敏感导致 404现象用户手动输入短码时把abc输成了ABC结果 404。原因Base62 的字符集里同时包含大小写字母abc和ABC是两个不同的短码。解决如果业务允许可以在生成短码时只用小写字母和数字把字符集从 62 个缩减到 36 个。代价是同样长度能表示的组合数变少了但 6 位 36 进制仍然有 21 亿种组合够用。另一种方案是在跳转接口里对短码做大小写不敏感处理但这样需要额外维护一个映射关系复杂度更高。6. 进阶技巧用布隆过滤器挡住无效短码请求前面在缓存策略里提到了缓存穿透的问题——大量请求不存在的短码每次都打到 MySQL。用 Redis 缓存空值能缓解但如果攻击者每次都用不同的随机短码空值缓存也会被撑爆。这时候布隆过滤器就是更优雅的方案。布隆过滤器的原理不复杂用一个位数组和多个哈希函数把存在的短码映射到若干个位上。查询时如果所有对应的位都是 1说明短码可能存在如果有任何一位是 0说明短码一定不存在。注意「可能存在」这个词——布隆过滤器有假阳性但没有假阴性。也就是说它说「不存在」的时候一定准说「存在」的时候可能误判。在短链接系统里这个特性刚好够用请求进来先过布隆过滤器如果它说不存在直接返回 404连 Redis 都不用查。如果它说存在再走正常的缓存和数据库查询流程。即使有少量假阳性也只是多查一次 Redis不影响正确性。Component public class ShortCodeBloomFilter { private final BloomFilterString bloomFilter; // 预计插入1000万个短码误判率设为0.01% public ShortCodeBloomFilter() { this.bloomFilter BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 10_000_000, 0.0001 ); } /** * 新增短码时加入布隆过滤器 */ public void add(String shortCode) { bloomFilter.put(shortCode); } /** * 判断短码是否可能存在 * return false表示一定不存在true表示可能存在 */ public boolean mightExist(String shortCode) { return bloomFilter.mightContain(shortCode); } }这里用的是 Guava 的BloomFilter参数10_000_000是预计插入的元素数量0.0001是期望的误判率。误判率越低需要的位数组越大内存占用越高。1000 万元素、0.01% 误判率的情况下大约需要 24 MB 内存完全可以接受。有一个关键点要注意布隆过滤器不支持删除。如果短链接过期了你没法把它从布隆过滤器里移除。所以布隆过滤器只适合做「新增时加入」不适合做「删除时移除」。过期链接的处理还是得靠 Redis 和 MySQL 的过期机制。如果业务中删除操作很频繁可以考虑用布谷鸟过滤器它支持删除但实现复杂度更高。另一个实践中的问题是布隆过滤器的预热。服务重启后布隆过滤器是空的所有请求都会穿透到 Redis 和 MySQL。解决办法是启动时从 MySQL 批量加载所有短码到布隆过滤器。如果数据量太大加载太慢可以只加载最近 N 天的短码更早的短码走正常的缓存查询流程。验证布隆过滤器是否生效可以写一个简单的测试先加入一个短码然后查询一个明显不存在的短码看是否直接返回 404 而没有查库。如果日志里没有对应的 SQL 查询说明布隆过滤器起作用了。我自己的习惯是任何对外暴露的查询接口只要数据量超过百万级都会先加一层布隆过滤器。它就像一道免费的保安虽然偶尔会拦错人但能挡住绝大多数无效请求。希望帮到你。本文还有配套的精品资源点击获取