短链这个东西听起来不复杂但真要在项目里落地还是有不少门道。很多刚接触后端的同学会觉得“不就是把一个长网址变短嘛”但实际做起来会牵扯到发号策略、存储设计、缓存穿透、重定向状态码选择、甚至还有安全风控。尤其“短链跳第三方是不是真的”这类问题现在经常被拿出来讨论背后其实就是一个完整的重定向链路设计。这篇文章我打算用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 ) ENGINEInnoDB;获取号段的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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意几点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;charsetUTF-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协议、安全风控、数据统计全串起来了。把一个“小功能”拆到这种颗粒度才是做后端项目真正有收获的地方。如果你正在做一个练手项目别急着写代码先把发号、存储、缓存、重定向四条主链路画出来再动手写起来会顺畅得多。
