基于Java的短链接生成工具设计与实现:从发号器到缓存、布隆过滤器与压测调优
2026/9/24 23:22:20 网站建设 项目流程

简介:一套基于Java的短链接生成工具完整源码,面向Java开发者、Web全栈初学者及需要进行网络链接管理与数据分析的项目人员,既可用于毕业设计/课程设计,也可作为学习后端接口与前端交互的练习项目。工具整体由Java与Vue前后端结合实现,包含生成短链接、访问量统计、访问详情记录、地区与设备分布图表、修改原始链接、批量创建、AB测试等八大基础功能,覆盖链接管理常见业务场景。短链接生成与跳转涉及唯一标识生成、URL映射、访问日志落库等关键模块,便于理解完整业务链路。资源共280个文件,压缩包约1.44MB;其中187个Java文件承担后端逻辑与数据统计,19个Vue组件配合HTML、CSS、JavaScript构建前端页面,另有YAML、XML、JSON等配置类文件,目录将源码与配置集中归类,结构清晰易于排查和二次开发。当前已有328人学习。借助该源码可完整掌握短链接服务从接口设计到前后端联调的实战方法,对提升项目落地能力有直接帮助。

1. 短链接工具不是“缩短”那么简单:一个 Java 后端要解决的四个问题

短链接工具看起来只是个重定向壳子:给一个长 URL,换一个短码返回。真正动手做起来,你要同时处理四件事:短码生成算法不能冲突、数据库要扛住读多写少的流量、缓存过期要防穿透,还要留一套访问统计。很多 java 课程设计案例源码把表结构和 service 写好就交差,上线跑两三天就会发现生成重复、空链接打爆缓存这些毛病。这篇笔记就顺着“基于 Java 的短链接生成工具设计与实现源码”这个方向,从算法选型、工程分层、压测调优讲到避坑清单。适合想把短链接当毕业设计、课程设计或者开源项目来做的同学,也适合在 java 面试八股文里被问到短链系统设计时,手里有真东西可以讲。

2. 生成算法与数据模型先行:62 进制编码、发号器与分表容量

2.1 两种短码流派:哈希截断 vs 发号器加 62 进制

短码怎么来,是整个系统第一个要拍板的设计决策。常见做法有两种:对长 URL 做 MD5/SHA-256 截断,或者用一个全局自增 ID 再转 62 进制。前者短码不可预测,看起来更安全,但碰撞概率随数据量上升,截断后的码还要回库查重,重试逻辑绕不开;后者码号顺序可推断,有心人遍历短码能看到你所有长链,但实现简单、零碰撞、码长可控。

我一般做中小型工具默认选发号器加 62 进制,主要原因是 ID 本身唯一,不用查重,生成性能极高。若你的场景特别在意短码不能被枚举,可以在发号器拿到 ID 后做一个位混淆(比如乘以一个大质数再取模),或者干脆走哈希分支,但代价是砸钱买“碰撞率低”的心理安慰。两者对比见表。

方案冲突处理短码可预测码长控制适合场景
MD5 截断需要查重重试截几位是几位对遍历敏感、量级大的平台型系统
自增 ID + 62 进制不需要通过 ID 规模反推课程设计、团队内部工具、访问量可控的场景

2.2 62 进制转换:正反向实现的 Java 代码

62 进制用 10 个数字、26 个大写字母、26 个小写字母作为字符表。订单 ID 从 1 开始编码,100 万大概是 4 位码,10 亿是 6 位码,几十亿以内都稳定输出 6 位短码,这正是短链接最常见的长度。正反向转换代码如下。

public class Base62Util { private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int BASE = 62; public static String encode(long num) { StringBuilder sb = new StringBuilder(); while (num > 0) { int remainder = (int) (num % BASE); sb.append(ALPHABET.charAt(remainder)); num = num / BASE; } return sb.reverse().toString(); } public static long decode(String code) { long result = 0; for (char c : code.toCharArray()) { result = result * BASE + ALPHABET.indexOf(c); } return result; } }

逻辑说明:encode 不断取余数并倒序拼接,得到的就是纯字符短码;decode 反向累乘还原。参数上只有 ALPHABET 一个常量,字母顺序可以调整,但一旦线上跑起来就不能再改字符表顺序,否则旧短码按新表解码会得到另一个 ID。如果需要支持最短码长(比如不满 6 位左边补 0),别用数字 0 补,直接拼接一个不属于 62 字符表的占位符,否则解码时会丢掉前导位。

2.3 表结构设计与分表容量估算

短链接表字段不多,but 索引设计直接决定高并发下的读性能。核心表结构如下。

CREATE TABLE t_short_link ( id bigint NOT NULL AUTO_INCREMENT COMMENT '内部自增主键', short_code varchar(16) NOT NULL COMMENT '对外短码,唯一索引', long_url varchar(2048) NOT NULL COMMENT '原始长链', domain varchar(64) DEFAULT '' COMMENT '短链域名', expire_at datetime DEFAULT NULL COMMENT '过期时间,空为永久', created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, hit_count bigint NOT NULL DEFAULT 0 COMMENT '点击次数', PRIMARY KEY (id), UNIQUE KEY uk_short_code (short_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

short_code 用唯一索引而不是普通索引,这是并发兜底的关键,后面避坑章会展开。short_code 字段长度给 16 位足够,62 的 16 次方已经超过任何业务数据规模;long_url 给 2048 是因为正则化后的长链可能带一堆追踪参数,给短了会被截断导致重定向 404。

分表策略单机阶段可以先不做,但设计时要留位。常见做法是拿 short_code 首字符或者 id 对 8/16 取模路由,取模好处是容量扩展顺序可控,首字符分布则天然接近均匀。6 位短码空间是 62 的 6 次方约 568 亿,全部用完不现实,真到接近这个量级时直接升 7 位码就够了,你的库表结构一条不用改。

2.4 布隆过滤器:容量与误判率参数

很多短链项目死在“不存在的短码”把缓存打穿这一关上。攻击者遍历短码,先打本地缓存,缓存没有就打数据库,一轮扫下来数据库连接直接耗尽。布隆过滤器就是干这个的:它回答“某个短码一定不存在”,虽然有可能误判为存在,但绝不会把存在的判成不存在。

import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; // 预估 100 万条短码,误判率 0.1% BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.unencodedCharsFunnel(), 1_000_000, 0.001);

生成短码后执行bloomFilter.put(shortCode),解析时先执行bloomFilter.mightContain(shortCode),返回 false 直接返回 404,不再访问数据库。参数上 n 按短码总量决定,不是按当前数量;fpp 0.001 表示 1000 次请求里有 1 次会把不存在的码放进来多查一次库,这个误伤率可以接受。注意布隆过滤器不支持删除,如果短链有下线能力,不能直接删元素,要定期用全量数据重建。

提示:布隆过滤器初始化容量是“计划总量”,不是当前数据量。按当前量初始化,三个月后数据翻倍,误判率会肉眼可见上升。

3. 源码工程落地:Spring Boot 分层、核心接口与缓存组合拳

3.1 工程结构与核心类清单:接口层、服务层、组件层

拿到标题里“设计与实现源码”,最该看的就是工程分层是否干净。我习惯拆成四个包:controller 只做参数接收和响应包装,service 只做业务编排,component 里放发号器、布隆过滤器、缓存和短码工具,mapper 放数据库访问。分层一乱,后面加统计、加过期任务时就会到处塞代码。

包名核心类职责
controllerShortLinkController短链生成、重定向、状态查询
serviceShortLinkService生成、解析、幂等控制、统计编排
componentBase62Util / IdSegmentFetcher / BloomCache编码工具、号段发号、缓存与布隆
mapperShortLinkMapper短链写入、查询、命中数累加

component 里 BloomCache 单独抽出来而不是直接散在 service 里,是因为布隆过滤器重建、缓存回填这些动作都是跨接口复用的,抽成组件后生成和解析两个方向各只调一行。发号器也必须是独立组件,后面讲压测时你会看到它是单机 QPS 的第一道闸门。

3.2 生成接口:幂等控制、号段出码与唯一索引兜底

生成接口的常规流程是:先查长链是否存在,存在就直接复用短码,不存在才取号、编码、写库。如果你想按用户统计点击,同一长链不同用户要生成不同短码,那这一层就要加 uid 条件,不能全局复用。核心业务代码片段如下。

public String shorten(String longUrl) { // 1. 幂等复用:同一个长链不生成两条码 ShortLinkVO exist = linkMapper.selectByLongUrl(longUrl); if (exist != null) { return exist.getShortCode(); } // 2. 从号段拿一个自增 ID,再转 62 进制 long nextId = idSegmentFetcher.nextId(); String shortCode = Base62Util.encode(nextId); // 3. 唯一索引兜底,冲突就重试一次 for (int i = 0; i < 3; i++) { try { linkMapper.insert(shortCode, longUrl, 0L); bloomManager.put(shortCode); cache.put(shortCode, longUrl); return shortCode; } catch (DuplicateKeyException e) { shortCode = Base62Util.encode(idSegmentFetcher.nextId()); } } throw new BizException("短码生成冲突,稍后重试"); }

逻辑说明:三步分别解决幂等、出码、防冲突。幂等查询用唯一索引兜底的原因是“先查后写”在高并发下不保证安全,两个请求同时查不到,同时插入同一个短码时,最坏情况由唯一索引暴露冲突。参数上重试次数给 3 次就够,连续 3 次撞码说明发号器出了重复段,多试无益,直接报错让监控介入更合理。

3.3 重定向接口:302 与 301 的选择,缓存读取顺序

重定向接口是短链系统的读主链,要求快、要能统计、要支持短链随时改目标长链。基于这三个要求,我会用 302 而不是 301:302 是临时重定向,浏览器每次都重新请求短链服务,你可以记录点击数、实时改目标地址;301 会被浏览器缓存,第二次开始根本不经过你的服务,统计和改链接全部失效。只有长期不变的落地页场景才建议 301。

@GetMapping("/s/{code}") public void redirect(@PathVariable String code, HttpServletResponse response) throws IOException { // 1. 布隆过滤器挡住不存在的码 if (!bloomManager.mightContain(code)) { response.sendError(HttpStatus.NOT_FOUND.value()); return; } // 2. 本地缓存优先,未命中再回源数据库 String longUrl = cache.getIfPresent(code); if (longUrl == null) { ShortLinkVO link = linkMapper.selectByShortCode(code); if (link == null) { response.sendError(HttpStatus.NOT_FOUND.value()); return; } longUrl = link.getLongUrl(); cache.put(code, longUrl); } // 3. 302 跳转,并把点击量异步累加 response.setStatus(HttpStatus.FOUND.value()); response.setHeader("Location", longUrl); statsService.record(code); }

缓存读取顺序是布隆过滤器、本地缓存、数据库三层,顺序不能颠倒。布隆过滤器回答“有没有”,缓存回答“短码对应的长链是什么”,数据库是最终一致性来源。参数上这里用 Caffeine,初始化时设置 expireAfterWrite 为 30 天,最大容量 10 万条,热点短链的缓存有效期远大于冷门链接;回源成功不回填,下一次请求又打库,所以必须在查询后立刻 put。

3.4 点击统计:@Async 异步线程池与失败补偿

点击统计不能写在重定向请求的同步链路里,否则每一次点击都多一次数据库 update,p99 延迟会明显抬高。常见做法是扔进异步线程池,由独立线程批量累加。注意线程池的拒绝策略,我见过很多项目用无界队列把内存打爆。

@Component public class StatsService { private final StatsMapper statsMapper; @Async("statsExecutor") public void record(String shortCode) { try { statsMapper.incrementHitCount(shortCode); } catch (Exception e) { // 失败先记本地日志,定时任务统一补 log.error("stats record failed: {}", shortCode, e); retryQueue.offer(shortCode); } } }
@Bean("statsExecutor") public Executor statsExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(5000); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }

参数说明:核心 4 线程、最大 8 线程、队列 5000,针对单机 QPS 3000 以内的统计场景足够。拒绝策略用 CallerRunsPolicy,线程池满了就让请求线程自己累加,用削峰的方式保数据不丢。这里有一个隐藏坑:@Async 不生效往往是因为调用发生在同类内部,比如在同一个 Service 里调 record(),代理不拦截内部调用,必须把 StatsService 单独拆出来注入使用。

4. 压测与参数调优:单机从 800 到 3000+ QPS 的三个必调项

4.1 第一轮压测结果:瓶颈几乎都在数据库连接池

很多同学本地用 Postman 手动点几个请求觉得项目很流畅,直接用 JMeter 开 200 线程跑,结果全部超时。短链读接口第一轮压测的典型表现是:数据库 CPU 才 20%,连接池已经被占满,平均响应时间从 30ms 飙到 500ms。原因是每个请求都走了一遍“缓存未命中、回源数据库、连接释放”的串行链路,HikariCP 默认池大小 10,200 个并发请求直接排队。

第一轮调优前我会先看三张监控图:数据库连接池活跃数、Tomcat 活跃线程数、接口 P99 延迟。如果活跃连接数顶到上限而数据库 CPU 不高,优先调连接池;如果 Tomcat 线程数跑满但数据库连接还有余量,调 maxThreads;如果两者都低但响应慢,再看 JVM GC。

4.2 号段批量取号:把数据库写操作减少 1000 倍

发号器使用号段模式是短链高并发的第一个关键调优点。第一次通过 SQL 向数据库取 1000 个 ID 到内存,后续 999 次生成短码都不碰数据库。实现时要用“UPDATE 后读取”的原子操作,避免多个实例同时取到同一段。

@Transactional public long fetchIdSegment() { // 行锁:事务结束前其他线程的 UPDATE 会阻塞 jdbcTemplate.update( "UPDATE id_sequence SET last_id = last_id + ? WHERE id = 1", step); Long newMax = jdbcTemplate.queryForObject( "SELECT last_id FROM id_sequence WHERE id = 1", Long.class); return newMax; }

这段代码的关键是 UPDATE 语句会对 id_sequence 表这一行加锁,并发时第二个实例阻塞到第一个事务提交,才能继续取下一段。事务要短,不要在取号事务里做任何业务写操作。单机部署时每次取 1000 个 ID 够用;多实例部署时建议步长按实例数扩大,比如两台机器各自步长 2000,避免频繁争抢行锁。

4.3 Caffeine 热点续约与过期策略

短链流量分布极不均匀,少数爆款短链扛起绝大多数点击。Caffeine 默认 expireAfterWrite 过期策略下,热点短链每 30 天过期一次,过期瞬间回源数据库,如果这个时刻恰好有几十万并发打进来,数据库会瞬间被打穿。解决思路是热点续约:每次命中缓存时检查数据的 lastAccessTime,超过阈值就重建缓存项而不是等它自然过期。

参数上我会把热点判定阈值设为 10 分钟内有超过 1000 次命中,然后给这个 key 单独延长有效期,比如再续 7 天。这个业务逻辑可以写在 service 层,放进 CacheLoader 里写容易把缓存组件搞复杂。经验值是:普通链接 expireAfterWrite 30 天,热点链接有效期上限 180 天,超过这个时间没人点,基本可以接受回源成本。

4.4 连接池、线程数、JVM 参数一张表

以下是我在单机 8C16G 环境下跑短链服务常用的参数组合,压测结论是从 800 QPS 提升到 3000+ QPS 的关键调整记录。

参数项调整前调整后说明
Hikari maximumPoolSize1030连接等待从排队变成少量空闲
Tomcat max-threads200400配合连接池避免线程饥饿
JVM 堆默认-Xms4g -Xmx4g -Xmn2g减少 GC 停顿,新生代放缓存和对象
Caffeine maximumSize1 万10 万压测曲线到 5 万时命中率出现拐点
发号器步长11000数据库写次数下降,锁竞争消失

压测要分段加压,不要一上来就 1000 并发。先 100 线程跑 5 分钟,观察数据库连接池曲线稳定后再加到 200、400。判断瓶颈的标准是:增加并发后 QPS 不涨、RT 涨,说明资源已耗尽;QPS 还在涨而 RT 可控,说明还有余量。不要把压测跑出来的绝对数字当宣传口径,不同机器和 JDK 版本差异很大,数字背后的“从排队到不排队”的变化过程才是调优经验。

5. 短链接避坑手册:并发重复码、缓存穿透与平台拦截的 5 个现场

5.1 线上生成了完全相同的短码

现象:压测时发现两个不同长链拿到同一个短码,用户打开短链跳到了别人的页面。

原因:先查重再插入的非原子操作,在高并发下两个请求同时查不到记录,又同时插入同一个短码,因为代码里只做了查重没有唯一索引兜底,两条记录都写进去了。

解决:表结构必须加 unique key 到 short_code,代码里插入捕获 DuplicateKeyException 重新取号生成。这是数据库兜底,代码层的并发控制从来不可靠,加了唯一索引后最坏情况只是冲突重试,不会数据错乱。

5.2 空码请求把数据库打挂

现象:监控里数据库 select 量激增,响应变慢,日志大量打印连接获取超时。

原因:布隆过滤器没有在解析链路里生效,或者初始化容量过小导致误判率飙升,不存在的短码每个都穿透到数据库。攻击者拿到合法短码后逐位枚举,几万个不存在的码瞬间打满连接池。

解决:解析接口第一个动作就是bloomFilter.mightContain(code),返回 false 直接 return null。同时把布隆过滤器容量按峰值预估的 3 倍初始化,并用一个定时任务每周重建,确保误判率稳定在 0.1% 以下。

5.3 社交 App 内打不开短链

现象:浏览器里打开短链正常,在微信、抖音这类应用的内置 webview 里点击,报“已停止访问该网页”或直接白屏。

原因:短链 302 跳转的目标地址是普通网页倒还好,如果目标地址本身是另一个 App 的 scheme 协议(比如weixin://zhihu://),webview 不识别这个协议会直接拦截。还有一种情况是目标域名被平台安全策略标记,302 的重定向行为会被当成跳转外链拦截。

解决:解析短链时读取请求头的 User-Agent,如果是特定 App 的 webview,跳转到一个本地 H5 中转页,在中转页里引导用户点击按钮再跳,而不是服务端直接 302。这个中转页面还可以承载安全提示、来源统计和广告位,是产品上更稳妥的做法。

5.4 短码撞上平台敏感词被拦截

现象:短码本身是合法 62 进制字符,但生成的短码组合成了某些平台黑名单里的敏感词,链接一发布就被删或被屏蔽。

原因:短码是发号器连续编码出来的,62 进制组合里必定会出现少量特定字符组合,平时很难预判。平台风控按“域名 + 短码”整体拦截,裸链接直接进黑名单。

解决:生成短码后做一次敏感词过滤,命中则跳过这个 ID 继续取下一个号。注意不能只过滤少量常见词,要维护一个长度 2~5 位的词库,用 Trie 树匹配,匹配速度不影响接口性能。上线前还要留一个人工审核入口,运营可以给特定链接指定自定义短码,但自定义短码要加白名单校验,不能有二次注入风险。

5.5 异步统计线程池满了,点击量悄悄丢失

现象:业务侧发现某些爆款短链的点击量和访问日志对不上,少了几万次。

原因:异步线程池队列用的是无界 LinkedBlockingQueue,平时没问题,遇到活动流量洪峰时队列积压几十万条记录,等线程消费到的时候应用已经重启,队列里未消费的任务全部丢失。

解决:队列改用有界 ArrayBlockingQueue,拒绝策略用 CallerRunsPolicy,线程池满时让请求线程同步执行统计,用响应时间换数据不丢。再配合一个定时任务,扫描 short_code 表里 hit_count 与访问日志表计数差异超过阈值的记录,手动补齐。点击量是短链接产品对用户和广告主的核心交付物,宁可慢一点也不能少。

6. 最后一公里:短链健康巡检、容量预警与我的运维习惯

6.1 批量巡检:自动发现失效长链并下架

短链接上线后的日常维护,核心是保证用户点击的每一个码都有可达的落地页。目标长链经常会出现 404、域名过期、被目标站点删除等问题,用户看到的就是一个打不开的短链。我习惯用一个定时任务做批量巡检,检测到异常长链直接标记下线。

public void healthCheck() { // 只扫描最近 7 天创建的短链,避免全量拖垮数据库 List<ShortLinkVO> links = linkMapper.selectRecent(7, 5000); for (ShortLinkVO link : links) { int status = probe(link.getLongUrl()); // HEAD 请求探测状态码 if (status >= 400) { linkMapper.markOffline(link.getShortCode()); cache.invalidate(link.getShortCode()); log.warn("short link offline: {} -> {}", link.getShortCode(), link.getLongUrl()); } } }

probe() 用 HttpURLConnection 发 HEAD 请求,设置超时 3 秒,抓重定向跟随后的最终状态码。对返回 403 或 503 的链接要谨慎,有的网站会拦截 HEAD 请求,可以退化成 GET 且不下载 body。全量扫描太贵,我按创建时间窗口扫描,老数据按月抽 1% 再单独跑一次,这个比例足够发现绝大多数异常。

6.2 短码耗尽预警与容量迁移

短码是发号器按 ID 顺序分配的,所以容量可以精确预测。6 位短码总量 568 亿,看着用不完,但很多团队会限制短码只能包含数字和小写字母,可用的字符集缩到 36,6 位只有 21 亿,再排除敏感词组合后会更少。定期用发号器表里的 last_id 除以当前短码位数的总空间,超过 80% 就要告警。

迁移升级到 7 位短码时有个细节:新旧短码同时在线上服务,不能直接把编码算法改了重启。要保证旧短码继续走旧解码逻辑,新短码走新逻辑,常见做法是把短码长度写进数据库字段,或者用短码首位字符做版本标记,比如首位为 0 的走旧逻辑,非 0 走新逻辑。这个兼容层要在升级前至少一个版本上线。

6.3 监控、日志与回滚习惯

我给短链服务立的监控指标有三个:短链生成 QPS、短链解析 QPS、缓存命中率。缓存命中率低于 95% 就要查布隆过滤器容量和 Caffeine 过期配置,正常热数据场景下命中率在 97% 以上才是健康的。日志方面,重定向接口每次访问打印一条短日志,含短码、来源 IP、UA、目标长链域名,方便后续做访问来源分析;短链生成则要打全量日志,用于对账。

最后说一个我自己的习惯:任何短链系统的配置改动都要保留上一次的 Caffeine 参数、布隆过滤器容量和连接池配置。短链这玩意儿看起来简单,线上翻车往往不是算法错了,而是某个缓存参数被拍脑袋改小了。我每次上线前都会检查一遍布隆过滤器重建任务有没有挂、异步统计队列有没有堆积、连接池曲线是不是平的,这三项全绿才敢把版本切到生产。这套流程帮我躲过好几次半夜告警,也希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询