☰
Java实现短链系统:发号器、Base62与重定向实战
2026/9/24 23:50:53 网站建设 项目流程

短链这个东西,听起来不复杂,但真要在项目里落地,还是有不少门道。很多刚接触后端的同学会觉得“不就是把一个长网址变短嘛”,但实际做起来会牵扯到发号策略、存储设计、缓存穿透、重定向状态码选择、甚至还有安全风控。尤其“短链跳第三方是不是真的”这类问题现在经常被拿出来讨论,背后其实就是一个完整的重定向链路设计。这篇文章我打算用Java生态,从零到一讲清楚长链、短链、以及短链转长链时重定向的完整实现方案。不扯虚的,直接讲能用的代码和踩过的坑,适合正在做课程设计、简历项目,或者想系统理解短链原理的同学。

1. 长链与短链:一个映射表就把事情说清楚

1.1 长链、短链的本质区别

所谓长链,就是我们日常见到的标准URL,比如商品详情页、文章链接、活动页面,动辄几十上百个字符。短链则是把长链映射成一段很短的可读字符串,比如https://s.xxx.com/Ab3dX9。本质上,短链系统做的事情非常朴素:保存一张“短码到长链”的映射表,当用户访问短链时,根据短码找到对应的长链,然后告诉浏览器“你实际上要去的是这个地址”。

这里有个容易混淆的点:短码本身并不携带长链的任何信息。它不是加密,不是压缩,只是一个唯一的索引。这就好比你去餐馆点了一份“宫保鸡丁”,服务员在厨房看到的是“桌号12”,这个“12”和鸡丁没有任何语义关系,但通过映射能找到正确的菜。短链做的就是这张“点菜表”。

1.2 为什么需要短链,它解决的业务痛点

第一个痛点是字符数限制。很多场景下链接越长越容易出问题,比如短信推广,一条短信有字数上限,长链接会额外占用宝贵的字符预算;再比如微信生态、微博这类平台,长链接不仅不美观,还会因为特殊符号被截断。

第二个痛点是可追踪性。业务方希望知道一个活动链接被点击了多少次,来源于哪个渠道。长链本身无法快捷打标,而短链天生带唯一代码,可以在跳转前记录下这次点击的来源、时间、IP、设备信息,再做301或302跳转。这就是为什么电商平台、广告投放系统几乎都内置了短链模块。

第三个痛点是安全与管控。有些泛域名、参数特别复杂的链接,肉眼根本看不出是否安全。短链系统相当于一道管理闸口,可以加上白名单校验、恶意链接拦截、违规内容风控,“淘宝短链跳第三方是真的吗”这类疑问指向的其实就是渠道方有没有在短链中间层做安全控制。

1.3 技术选型:自建短链系统还是用第三方

市面上有现成的短链服务,长链贴进去就出短链,确实方便。但如果你有两个需求,就必须自己动手:一是完全掌控自己的数据,比如点击日志、转化分析、短链归属;二是更高的可控性和定制能力,比如自定义短码、指定有效期、私有化部署。

自己实现一个短链系统,技术难度并不高。技术栈只需要Java + Spring Boot + MySQL + Redis,就能应对大部分场景。我讲的自建方案重点解决三个问题:短码如何生成且尽量短、如何避免碰撞、以及如何让跳转在高并发下依然稳定。

2. 短链生成核心算法:自增ID + Base62编码

2.1 备选方案对比:哈希截取、随机字符串、发号器

我把生成短码的主流方案都过一遍,先说结论,我最推荐的是“发号器 + Base62换算”。

哈希截取方案,一般做法是把长链用MD5或SHA256算出哈希值,然后取前几位作为短码。优点是同一长链可以稳定生成相同短码,天然做到去重。缺点也致命:哈希值截取后碰撞概率会随数据量增大而上升,一旦碰撞就需要额外的查重和再哈希流程,而且无法控制短码长度和字符集的可读性。

随机字符串方案,直接生成6-10位随机字符。优点是实现简单、几乎不用考虑分布式问题。缺点是随着记录变多,冲突几乎必然发生,每次插入前都要查库确认是否存在,插入越频繁,碰撞概率越高,性能越不稳定。

发号器方案,用数据库自增ID或者独立发号服务,先拿到一个全局唯一的数值ID,再把这个十进制ID换算成更高进制,用更少的字符表示这个数值。比如十进制ID 1000000,转成62进制短码就短得多。这个方案稳定、有序、可预判,是业内主流做法,像雪花算法本质上也属于发号器的思路。

2.2 为什么选择Base62而不是Base64/32

进制换算的关键在于字符集的选择。Base62用的是数字0-9、大小写字母a-zA-Z,总共62个字符。Base64在62个字符之外还有+和/,这两个符号在URL里是有特殊含义的,要么需要转义,要么可能被网关、中间件截断,所以短链场景下直接用Base64是给自己找麻烦。

Base32虽然全部使用字母和数字0-6,规避了易混淆字符,但32进制意味着同样数值需要更多位来表达,6位短码能表示的ID个数远小于62进制。综合可读性、URL安全和长度,Base62是最佳折中。

这里顺带算一笔账,很多人会问“6位短码到底够不够用?”62的6次方大约是568亿,也就是说理论上能支持568亿条短链记录。对绝大多数业务来说,6位短码够用很久;但如果你预计日增以亿计,就可以考虑7位或8位,完全没有性能损失。

2.3 10进制转62进制:Base62工具类实现

代码很直接,循环取模、映射字符。我来写一个我自己在项目里用的版本:

public class Base62 { private static final String BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; private static final char[] CHARS = BASE62_CHARS.toCharArray(); private static final int BASE = 62; // 十进制ID转62进制短码 public static String encode(long num) { if (num == 0) { return String.valueOf(CHARS[0]); } StringBuilder sb = new StringBuilder(); while (num > 0) { int remainder = (int) (num % BASE); sb.append(CHARS[remainder]); num = num / BASE; } return sb.reverse().toString(); } // 62进制短码转回十进制ID public static long decode(String shortCode) { long result = 0L; for (char c : shortCode.toCharArray()) { result = result * BASE + BASE62_CHARS.indexOf(c); } return result; } public static void main(String[] args) { long id = 1000000L; String code = encode(id); System.out.println("ID: " + id + " -> 短码: " + code); System.out.println("短码: " + code + " -> ID: " + decode(code)); } }

这里有个细节:字符表不是按字母顺序排列的,而是把数字排在最前面,小写字母居中,大写字母靠后。这样设计没有性能差异,但生成的短码在排序、展示时更符合人的习惯。另外,短码前缀如果以0开头,某些日志系统或者工具可能误判为数字类型,这个不是大问题,但影响观感。如果你想让短码完全避免0开头,可以在生成时判断首位是否包含保留字符,不够优雅但能解决特殊平台的兼容问题。

2.4 发号器实现:数据库号段模式

短码由ID换算而来,那ID从哪来?最简单的做法是每次插入短链记录时,利用MySQL的自增ID拿到主键,然后再回写短码。这个方案在低并发下没问题,但在高并发下有一个明显瓶颈:插入完成后才能拿到ID,而短码又需要ID来生成,整个流程是串行的。而且数据库自增主键一旦被业务拿到,在分布式多库场景下会出现重复。

我更推荐使用号段模式:单独维护一张发号表,每次从数据库取一批号码段的起始值和结束值,应用内在内存里逐个分配。比如取到[1000000, 1000999]这段范围,应用层就能连续生成1000个短码,期间完全不需要再访问数据库。

发号表结构大致如下:

CREATE TABLE id_allocator ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_tag VARCHAR(64) NOT NULL UNIQUE, max_id BIGINT NOT NULL, step INT NOT NULL DEFAULT 1000, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB;

获取号段的SQL用一条乐观更新完成:

UPDATE id_allocator SET max_id = max_id + step WHERE biz_tag = 'short_link'; SELECT max_id, step FROM id_allocator WHERE biz_tag = 'short_link';

先更新再查询,保证同一时刻只有一个应用实例能拿到同一段号码空间,天然避免了并发冲突。每次取号段后,应用内自行用原子类AtomicLong分配,用完再取下一批。这样一个发号模块能支撑的QPS已经不低了,至少是几千级别。

3. 请求链路落地:短链转长链重定向的完整流程

3.1 存储设计:短链接表与点击日志表

短链系统核心表就是一张映射表。我一般这样建表:

CREATE TABLE short_link ( id BIGINT PRIMARY KEY, short_code VARCHAR(16) NOT NULL UNIQUE, long_url VARCHAR(2048) NOT NULL, biz_type VARCHAR(32) DEFAULT 'default', expire_time DATETIME NULL, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_short_code (short_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意几点:short_code必须加唯一索引,这是防重复的最后防线;long_url建议用VARCHAR(2048),虽然日常场景很多长链也就几百字符,但广告系统里偶尔会有很长的拼接参数,留足余量省得后续搬迁;status字段虽然是小小的TINYINT,但关键时刻可以用来做软删除、封禁违规链接,避免直接物理删除影响统计分析。

点击日志表则单独拆分,因为写入频率高、查询维度多,混在主表会让主表索引膨胀。日志表至少包含:短链ID、来源IP、User-Agent、Referrer、创建时间。如果业务需要统计每个渠道的转化,还可以加channel字段,但这块通常结合消息队列异步写入,不在本次核心流程内。

3.2 重定向接口实现:从短码解析到302跳转

重定向接口是整个系统的门户。用户访问短链域名下的/go/{shortCode},接口要做的事情相当纯粹:接收短码,查长链,跳转。我贴一个精简版的Controller代码:

@RestController public class RedirectController { @Autowired private ShortLinkService shortLinkService; @GetMapping("/go/{shortCode}") public void redirect(@PathVariable("shortCode") String shortCode, HttpServletRequest request, HttpServletResponse response) throws IOException { // 1. 查询长链 String longUrl = shortLinkService.getLongUrl(shortCode); // 2. 找不到则返回404 if (StringUtils.isBlank(longUrl)) { response.setStatus(HttpStatus.NOT_FOUND.value()); response.setContentType("text/plain;charset=UTF-8"); response.getWriter().write("短链不存在或已过期"); return; } // 3. 302重定向 response.setStatus(HttpStatus.FOUND.value()); response.setHeader("Location", longUrl); // 4. 异步记录点击日志,不要阻塞主流程 shortLinkService.recordClickLog(shortCode, request, response); } }

这段代码逻辑很直观,实际落地时核心都在shortLinkService里。查询逻辑大家都会写,重点说一下其中容易被忽略的两个细节。

第一个是重定向状态码的选择。我上面的代码用的是302,原因我放在下一小节单独展开。第二个是点击日志的异步化。有些人喜欢把点击日志写在跳转之前,同步写库,这样会导致每次跳转都多一次数据库写入,并发一上来接口直接被打挂。正确的做法是先把重定向响应发出去,再把日志丢进异步线程池或者消息队列。日志丢失几条不影响核心功能,但接口超时一定影响用户体验。

3.3 301还是302,这是个问题

我见过不少网上教程直接用301永久重定向,说是“对SEO友好”,这其实是个偷懒的做法。301语义是Moved Permanently,浏览器收到后会缓存这个跳转结果,下次访问短链时不再请求短链服务器,直接跳到目标地址。这看起来“省流量”,但对业务而言是灾难——你永远无法收集到后续的点击数据了,想改目标地址,浏览器还在走旧缓存,调试起来极其痛苦。

302语义是Found(历史上也常写成临时重定向),每次访问短链时浏览器都会先请求短链服务器,再由服务端决定跳转目标。这样你就能在跳转前统一做点击统计、渠道标记、安全校验,甚至可以做灰度切量。代价是每次访问多一次网络往返,但对短链场景来说完全可控。

我的建议非常明确:除非是永久性的营销素材,能接受不追踪,否则一律用302。尤其你在项目里引入了风控和防作弊逻辑,想在跳转前判断来源是否可疑,用301就等于自断一臂。

3.4 非法链接与过期链接怎么处理

要么404,要么给一个中间提示页。如果是短链系统中不存在的短码,直接404无可厚非,但更好的体验是返回一个统一风格的提示页,写明“链接不存在或已被删除”。为什么这么设计?因为用户从短信、社交平台点进来,看到浏览器原生的404页面,第一反应是打不开,容易产生投诉,而一个品牌化的提示页能明显降低疑惑。

过期链接同理。我建议加一个expire_time字段,查询时直接带状态判断:

public String getLongUrl(String shortCode) { ShortLink link = shortLinkRepository.findByShortCodeAndStatus(shortCode, 1); if (link == null) { return null; } // 过期直接视为无效 if (link.getExpireTime() != null && link.getExpireTime().before(new Date())) { return null; } return link.getLongUrl(); }

注意这里“存在但不是有效状态”的业务含义和“完全不存在”是不同的。如果后续要出管理后台,这两个状态最好能区分开,方便运营判断是删除还是封禁。

4. 缓存层:把QPS扛上去的关键一步

4.1 Redis缓存结构与击穿防护

跳转链路里最频繁的操作就是“根据短码查长链”,这个操作要不要直接查数据库?不查。短链系统是典型的读多写少场景,热门短链一天的点击量可能是百万级别,全打在MySQL上,再多连接池也扛不住。所以在数据库前面放一层Redis缓存,key用short_link:{code},value直接存长链地址。

查询策略用经典的Cache Aside模式:先查缓存,命中就直接跳转;未命中则查数据库,查到后回填缓存并设置过期时间。看起来没什么问题,但在高并发场景下要特别注意缓存击穿——某个短链的缓存刚好过期,瞬间涌入大量请求,全部穿透到数据库,MySQL压力瞬间飙升。

解决方案是加互斥锁,只让一个请求去查库并回填缓存,其他请求等待。用Redis分布式锁实现互斥:

public String getLongUrl(String shortCode) { // 1. 先查缓存 String longUrl = redisTemplate.opsForValue().get(CACHE_KEY + shortCode); if (StringUtils.isNotBlank(longUrl)) { return longUrl; } // 2. 缓存未命中,加锁查库 String lockKey = LOCK_KEY + shortCode; boolean locked = tryLock(lockKey, 3000); if (!locked) { // 没抢到锁,短暂休眠后递归重试 Thread.sleep(50); return getLongUrl(shortCode); } try { longUrl = shortLinkMapper.findLongUrlByShortCode(shortCode); if (StringUtils.isNotBlank(longUrl)) { redisTemplate.opsForValue().set(CACHE_KEY + shortCode, longUrl, 72, TimeUnit.HOURS); } return longUrl; } finally { releaseLock(lockKey); } }

这个代码牺牲了一点点首访延迟,但避免了缓存失效瞬间被打穿数据库,真实场景下非常实用。

4.2 缓存击穿、穿透、雪崩三兄弟别搞混

这三类问题面试常问,项目实战也躲不开。

缓存穿透查的是一个根本不存在的数据,缓存和数据库中都没有。解决办法是在缓存中短暂存储一个空值,或者在查询前用布隆过滤器快速判断短码是否存在。短链场景下,不存在的短码往往意味着恶意扫描,加一层空值缓存能有效挡掉大部分无效请求。

缓存击穿跟穿透不一样,它针对的是某个热点key在失效瞬间的高并发请求,解决思路就是上面提到的互斥锁或逻辑过期。

缓存雪崩是大量key在同一时间过期,导致请求全部落到数据库。解决思路是在设置过期时间时加一个随机偏移量,比如72小时 + Random(0, 3600),让过期时间散开,避免集体失效。

这三个问题听起来相似,但成因、影响范围、解决手段完全不同,建议在自己项目文档里分别写清楚。

4.3 热点短链的保护:本地缓存兜底

有些短链的使用场景极其集中,比如大促活动页,同一个短链可能承担全站80%的流量。这时候即使Redis扛得住,网络IO也会成为瓶颈。更激进的方案是在应用内存里加一层Caffeine本地缓存,Hot Key直接被本机内存命中,不再需要走网络访问Redis。

但引入本地缓存要非常小心一致性问题:本地缓存的更新无法广播到其他节点,如果某个短链的长链地址在运营后台被修改了,其他机器上的本地缓存可能还在使用旧地址。可行的做法是把本地缓存的TTL设置得很短,比如30-60秒,容忍极短时间的不一致;或者在修改短链时,通过Redis的Pub/Sub主动通知所有节点清空对应本地缓存。

这个设计听起来复杂,但在短链系统的高阶优化里很常见。如果你的并发量还没到单机峰值1万以上,我建议先不要引入本地缓存,多一层组件就多一分维护成本。

5. 常见问题与排查技巧实录

5.1 长链重复会导致短链重复吗

如果不做任何处理,同一个长链被不同用户提交两次,系统会生成两条不同的短链记录。这在业务上未必是错的——可能一个用户希望它永久有效,另一个只想让它存活24小时。但如果你的产品要求“同一长链返回同一短链”,比如是给外部开发者提供的开放接口,就必须在写入前增加“长链幂等校验”。

最简单的方式是在短链接表增加了long_url_hash字段,存储长链的哈希值,并建立索引。写入时先查这个哈希值是否存在:

SELECT short_code FROM short_link WHERE long_url_hash = ? LIMIT 1;

注意这里不要直接比对长链字段,长链可能有2000字符,索引长度根本撑不住,最好用哈希值缩短检索路径。哈希碰撞概率很低,但为了严谨,找到候选记录后仍要精确比对long_url是否一致。

5.2 URL编码乱码:重定向时容易踩的坑

长链里经常带中文参数、空格、#、&这些特殊字符。用户在浏览器里看到的地址和实际传入HTTP请求的地址是有差异的。如果存库时没有统一编码,跳转时就可能出现两种情况:跳转过去后页面404;或者网页能打开,但参数值丢失。

我建议在创建短链时统一做一次URL标准化:先通过URI.create(url)做合法性校验,然后按需解码或编码。跳转时写入Location头的长链必须是标准编码后的URL,不能直接存什么返回什么。一个很经典的坑是#号,它在HTTP响应头里如果不加处理,浏览器会把它当作Location值的终止符,导致参数被截断。

String encodedUrl = URLEncoder.encode(longUrl, StandardCharsets.UTF_8.toString());

当然,这么粗暴地把整个URL编码是不对的,因为http://里的:和/也会被转义。正确做法是用URI或专门处理URL的工具类做部分编码,或者存储时就确保写入的是标准格式。

5.3 写入性能与并发重复的取舍

生成短链时,前端提交一个长链,后端需要完成“取号、生成短码、写入数据库”三个步骤。系统并发高时,可能出现两条一样的短码同时入库,虽然唯一索引会拦截第二条,但抛异常会影响体验。

我习惯在写入时使用INSERT ... ON DUPLICATE KEY UPDATE或者捕获重复键异常后重新取号重试。面试时如果被问到“如何保证短码唯一”,最高分答案是“数据库唯一索引兜底,应用层保证最优路径,双保险缺一不可”。

5.4 恶意跳转与安全风控,短链不能裸奔

“短链跳第三方是真是假”能被问出来,本身就说明短链容易被当成跳转工具。作为系统设计者,必须在跳转链路里加安全防线。最简单的路子是维护一个基础域名白名单:只有长链域名在白名单内才允许生成短链,否则拒绝。更进一步,可以在跳转页面先展示一个中间提示页,标明“即将跳转到外部链接”,由用户确认后再跳转。

有接口对外放量时,还可以加频率控制,限制单个IP的创建请求频率,防止短链被批量生成用于垃圾引流。这块其实可以做得非常深,但作为项目起步,先把白名单拦截和提示页加上,你就能在简历或者面试里讲出“我考虑了安全设计”的亮点。

5.5 面试怎么把这个项目讲出亮点

短链系统在Java后端面试里出现频率特别高,几乎人手一个。但大部分候选人只会说“我用了MyBatis,一个表存映射关系,重定向用302”,这样毫无区分度。真正能加分的点,是你把为什么这么设计讲清楚。

我建议从这几个角度组织叙述:讲发号器时突出“全局唯一ID的生成策略”以及号段模式对数据库压力的下降;讲缓存时把击穿、穿透、雪崩三兄弟的区别和方案逐一说明;讲重定向时把301和302的取舍、点击统计的影响说明白;讲安全时把白名单校验、频率限制这些风控思路带出来。把这些都串起来,一个普通“接口开发”瞬间就成了一个有设计深度、有性能考量、有安全意识的完整项目。

6. 实操心得:这套短链方案还能怎么扩展

做完基础版之后,我实际用的系统里还加了几个模块,这里一并分享。

第一个是短链的持久化归档。短链记录和点击日志不断增长,日志表不能永远在同一张表里膨胀,一般会按天分表,比如click_log_20250215,查询时按日期路由。这块用ShardingSphere或者仅靠SQL手动拼接都能实现,关键在于归档策略,旧数据复盘时再走离线数仓。

第二个是自定义短码的需求。运营同学经常想用有意义的短码,比如s.xxx.com/lianmai表示“连麦活动”,生成时必须先查自定义码是否被占用,占用则重新命名。自定义短码不能跟发号器生成的短码空间冲突,我的做法是自定义短码统一加一个前缀,比如vip_lianmai,从源头上隔离。

第三个是短链的统计报表。既然用了302,每次点击都有日志,那日维度的点击量、独立访客数、来源分布都可以做。最简单的做法是定时任务按小时汇总日志表,把数据刷进统计表,运营后台直接查统计表,不用再做实时聚合。

我个人在写这套系统时最大的体会是:短链看起来简单,但它像一个枢纽,把分布式发号、缓存设计、HTTP协议、安全风控、数据统计全串起来了。把一个“小功能”拆到这种颗粒度,才是做后端项目真正有收获的地方。如果你正在做一个练手项目,别急着写代码,先把发号、存储、缓存、重定向四条主链路画出来,再动手,写起来会顺畅得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询