简介:一套基于Java的SaaS短链接管理系统设计源码,面向Java开发者、企业技术团队和营销运营人员,重点解决长链接难以记忆、分享后无法追踪与优化的问题。压缩包共包含219个文件,整体约563KB,其中188个Java源文件承载核心业务逻辑,14个XML与11个YAML负责系统配置,2个SQL文件管理数据库结构,另有HTML页面、Lua脚本和.gitignore等辅助内容,目录模块划分清晰。已有391人学习/下载,适合具备Java基础并希望接触真实项目源码、理解短链接生成和数据分析流程的读者。系统已实现短链接生成、PV/UV/UUI关键指标统计、安全监控等功能,并借助admin、gateway、aggregation、project等模块实现解耦,便于扩展、维护与二次开发。对于需要快速搭建短链接服务或研究SaaS系统设计的人来说,这套源码提供了完整可参考的工程实践,能直观体现数据统计、用户管理与安全守护等环节的落地方式。
1. 为什么把短链接系统做成 SaaS 值得做:从单机工具到多租户平台的三个关键转变
如果你在营销或代运营公司待过,一定经历过这种场景:运营同事手工把几百条带 UTM 参数的长链接整理成 Excel,发给客户,客户打开时要么拷不全,要么被 IM 工具自动截断。等到想按渠道看数据,Excel 早乱成一锅粥。把短链接系统做成 SaaS,本质上是把“能缩短 URL 的单机工具”升级为“按租户隔离、可计费、可配置域名、可看统计”的服务。基于 Java 落地,Spring Boot 负责接口和应用骨架,MyBatis-Plus 处理数据访问,Redis 扛跳转读路径,这是目前主流且招聘成本可控的技术组合。适合正在做 SaaS 平台或营销中台的后端工程师,也适合准备把短链接方向做成产品的独立开发者。下面会从生成算法、多租户隔离、跳转代码到避坑清单逐层拆开。
2. 短链生成算法选型:自增 ID 转 62 进制与哈希截断的取舍、冲突兜底怎么写
2.1 两类主流生成方案:可逆有序与不可预测各有适用场景
短链生成的起点是决定“短码从哪来”。最常见的做法是按发号器递增,再把数字转成 62 进制——0-9、a-z、A-Z 共 62 个字符来编码,5 位、6 位、7 位根据预估量选择。这种方案生成极快,短码有序,数据库索引友好,出问题时按码反查 ID 也很直接。缺点同样明显:短码可枚举,如果有人写脚本从短码 000001 开始遍历,你的全部链接都会被扫走,适合内部系统或对安全性要求不高的营销场景。
另一种方案是对原始长链接做哈希(MD5 或 MurmurHash),截取一段作为短码。优点是短码随机分布,无法从短码推断生成顺序,外部想批量遍历得到的是大量无效码,成本高得多;缺点是碰撞概率随数据量上升,需要处理冲突。还有用 UUID 或随机字符串的做法,但 UUID 太长,直接当短码浪费字符,随机字符串在并发下要么查库去重,要么冒冲突风险。三种方案放在一起对比会更直观:
| 生成方案 | 短码特点 | 冲突概率 | 数据安全 | 适用场景 |
|---|---|---|---|---|
| 自增 ID 转 62 进制 | 有序、短 | 几乎为零 | 可枚举 | 内部工具或活动页 |
| 雪花 ID 转 62 进制 | 全局唯一、带时间语义 | 基本为零 | 可枚举但生成量可观测 | 中小型 SaaS |
| 长链接哈希截断 | 随机分布 | 随数据量上升 | 无法倒推长链接 | 开放短链平台 |
| 随机字符串 | 随机分布 | 较高 | 无法枚举 | 一次性短码 |
我个人选型习惯是:内部营销工具用雪花 ID 转 62 进制,对外开放的链接平台用哈希截断加布隆过滤器,后者代码复杂度高一截,要权衡团队维护能力。短链方向也是 Java 面试里常见的一个设计题,能讲明白为什么不在代码里先查重、而是靠数据库唯一索引兜底,面试官基本就认账了。
如果一个短链系统只做内部工具,甚至可以省掉反查逻辑,后台列表里展示 short_code 就够了。但如果要做开放平台,往往还需要支持“用户传一个长链接,系统判断是否已生成过”的需求。这时候不要在生成入口做全表扫描,而是先查 Redis 里的映射关系,再查数据库,两条路径都查不到才生成新短码。
2.2 62 进制转换的基准实现与字符集顺序的细节
不少人的第一个版本是 JDK 的 Long.toString(id, 36),出来的是 36 进制,少了大小写字母,位数会比 62 进制多一位。62 进制需要自己写,下面这个工具类可以直接抄:
public class Base62Utils { private static final char[] CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); private static final int BASE = 62; public static String encode(long num) { if (num < 0) { throw new IllegalArgumentException("short link code requires positive number"); } StringBuilder sb = new StringBuilder(); while (num > 0) { sb.append(CHARS[(int) (num % BASE)]); num /= BASE; } if (sb.length() == 0) { return CHARS[0] + ""; } return sb.reverse().toString(); } }逻辑说明:把十进制数反复除以 62,每次取余数作为字符下标,最后反转得到短码。字符集顺序里数字在前、小写字母中间、大写字母在后,这样生成的短码和字典序保持某种一致性,排错时比较好目测。参数上要注意两点:一是 num 小于 0 直接抛异常,否则会出现空字符串;二是while (num > 0)对 0 返回的是空串,所以要补一个返回"0"的兜底。
2.3 冲突兜底:数据库唯一索引比代码里查重更可靠
无论用哪种生成方案,都要考虑两个不同长链接算出同一个短码。哈希截断的碰撞概率随数据量增长会更明显;雪花 ID 方案理论上不会撞,但一旦做过数据迁移、手工插入或序列重置,一样可能冲突。我的做法是不在业务代码里先查一遍再插入——查那一下在并发下没有意义,而是直接在t_short_link表上建short_code唯一索引,插入时捕获 DuplicateKeyException 再重试。
for (int i = 0; i < 3; i++) { try { linkMapper.insert(link); return link; } catch (DuplicateKeyException e) { link.setShortCode(Base62Utils.encode(SnowflakeId.next())); } } throw new BizException("短码生成连续冲突,请稍后重试");逻辑说明:catch 到唯一键冲突后换一个新 ID 重新生成短码,最多重试三次。参数上建议把重试上限设成 3 而不是 5,因为连续三次冲突说明字符集选择或 ID 生成器有问题,再多试只是放大写冲突日志的噪音。另一个值得做的优化是预热一段短码队列:系统启动时先把未来 1000 条短码算好放到内存,创建链接时直接从队列里取,把 62 进制转换从关键路径上挪走。这个优化量级不大,但对创建接口的 P99 延迟改善明显,尤其是并发批量导入场景。
2.4 短码长度与容量:6 位、7 位到底差多少
62 进制的容量增长是指数级的。位数为 6 时总量是 62 的 6 次方,约 568 亿;位数为 7 时约 3.52 万亿。对绝大多数 SaaS 租户规模,6 位已经足够。但要注意“总量”和“可用量”不是一回事,如果短码是纯顺序生成,早期 6 位码前面全是 0,视觉上不如 7 位随机码好看,所以不少开放平台直接用 7 位。节省一位短码让 URL 更精短,体验上有增益;代价是容量减少约 62 倍。我的建议是:域名长度已经够长了(比如带品牌、地区、渠道词),就不要再省这一位;短码长度按未来三年的预测量取整到最近的档位。
另外,固定 6 位短码追求的是“短的极致”,但很多场景里 7 位更实用——带一个字母数字混合码的短链被误读的几率更低,用户口头播报时能按单个字符去区分。如果短码里既有小写 l 又有大写 I、数字 0 和字母 O,视觉上几乎不可辨。我的建议是生成后把l、I、0、O这四个字符替换成固定字符,或直接把这四个字符从字符集里去掉,短码容量从 62 的 7 次方降到 58 的 7 次方,依然够用。
3. 多租户隔离怎么落:共享表加租户字段的 MyBatis-Plus 插件方案与上下文传递
3.1 独立库、独立表、共享表三种方案的取舍
SaaS 系统的第一道设计题是租户隔离。独立数据库把每个租户的数据物理隔开,安全性最高,出问题互不影响,但数据库连接数、备份数量、单项资源成本都陡增,对一个短链服务来说很不划算。独立表结构介于两者之间,隔离性和共享性都不突出,反而很少用。最常用的是共享表加租户 ID,通过应用层约束保证数据不串,成本最低、运维最简单,但对代码纪律要求很高,漏写一个tenant_id条件就是数据事故。短链接系统的单租户数据量往往不大,共享表加租户字段是最合理的选择,只要在 ORM 层把租户条件统一管住。
三种方案对比要点如下:
- 独立数据库:租户数量少、合同对数据隔离有明确要求时选;运维成本随租户数线性上升。
- 独立 Schema 或独立表:适合租户表结构差异大的场景;短链业务表结构高度统一,很少需要。
- 共享表加租户 ID:默认做法;配合 ORM 租户插件和强制校验,能在成本和安全性之间取平衡。
3.2 MyBatis-Plus 租户插件让查询自动带条件
MyBatis-Plus 的TenantLineInnerInterceptor能自动在 SQL 上追加租户条件,不需要在 Mapper 里手写。配置如下:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { @Override public Expression getTenantId() { Long tenantId = TenantContext.get(); if (tenantId == null) { throw new BizException("tenant context is empty"); } return new LongValue(tenantId); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { return "t_tenant".equals(tableName) || "t_user".equals(tableName); } })); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }逻辑说明:getTenantId()从TenantContext取出当前租户 ID,拼到 SELECT、UPDATE、DELETE 的 WHERE 条件里;ignoreTable放行不需要租户隔离的表。参数要注意三点:一是DbType.MYSQL必须与数据库一致,否则分页 SQL 会生成错方言;二是租户上下文中取不到值时不要静默放行,直接抛异常比留一个数据漏洞更安全;三是自定义 SQL 不一定被插件覆盖,后面避坑章会展开。
3.3 租户上下文传递:从 Token 到 ThreadLocal 的完整链路
租户 ID 不能每次从接口参数里取,那样每个方法都要检查传参。常见做法是在 Spring MVC 拦截器里解析 JWT,把租户 ID 放入ThreadLocal,请求结束在 finally 里清理:
@Component public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); JwtPayload payload = JwtUtil.parse(token); TenantContext.set(payload.getTenantId()); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); } }逻辑说明:preHandle里解析并设置租户 ID,afterCompletion清理,避免线程池复用线程时把上一次的租户上下文带给下一个请求。这个清理动作常被漏掉,后果是 A 租户的请求偶尔查出 B 租户的数据,而且只在并发量高时偶发,特别难抓。如果项目里用到了异步线程池,ThreadLocal 默认不会透传,可以把TenantContext里的值手动在提交任务时复制进去。这套租户隔离设计也是 Java 面试里 SaaS 方向的常考题,能讲清楚 ThreadLocal 为什么必须清理的人,基本就跨过了中级门槛。
3.4 管理后台的租户视角:一套 Vue 端同时服务超管与租户管理员
短链系统在管理端通常有两种角色:平台超管可以看到所有租户、所有链接;租户管理员只能看到自己的。前端如果用 Vue3 后台管理系统做,后端接口要同时遵守两个约束:超管接口跳过租户条件,租户接口必须带上租户 ID。我一般会在 Controller 上加自定义注解@IgnoreTenant,让租户插件在特定方法上忽略租户条件,而不是直接放行整张表,这个粒度比 ignoreTable 更安全。管理端的链接列表会按创建时间排序,注意分页查询里不要忘了把tenant_id带上,否则 MyBatis-Plus 插件没有生效时,列表接口会把其他租户的数据吐出去。
提示:租户插件的
ignoreTable和@IgnoreTenant是两种不同粒度。前者放行整张表,连新增和更新都绕过租户条件;后者只影响带注解的方法。超管后台选后者更稳。
4. 核心链路源码拆解:建表、生成短码、302 跳转与异步统计的完整实现
4.1 数据表设计:链接表、租户表、访问日志表
短链接系统最少需要三类表。租户表存 SaaS 客户基础信息;链接表存长短链接映射和状态;访问日志表记录每一次跳转,是统计点击量、UV、来源渠道的数据底座。设计时注意把唯一索引、普通索引分清楚:
CREATE TABLE `t_tenant` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL COMMENT '租户名称', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `expire_time` datetime DEFAULT NULL COMMENT '到期时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB COMMENT='租户表'; CREATE TABLE `t_short_link` ( `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` bigint NOT NULL, `short_code` varchar(16) NOT NULL COMMENT '短码', `long_url` varchar(2048) NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `expire_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_short_code` (`short_code`), KEY `idx_tenant_id` (`tenant_id`) ) ENGINE=InnoDB COMMENT='短链接映射表'; CREATE TABLE `t_visit_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` bigint NOT NULL, `short_code` varchar(16) NOT NULL, `ip` varchar(64) DEFAULT NULL, `ua` varchar(512) DEFAULT NULL, `referer` varchar(512) DEFAULT NULL, `visit_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_code_time` (`short_code`, `visit_time`) ) ENGINE=InnoDB COMMENT='访问日志表';逻辑说明:short_code的唯一索引是冲突兜底的关键;为了支持租户后台按创建时间列表,按tenant_id建普通索引。long_url用 2048 长度是为了容纳长参数串,如果业务里真会出现超过 4096 字符的超长 URL,建议把长链接单独拆成 text 字段放到扩展表,别和主表挤在同一行。另一个容易忽略的参数是expire_time:营销链接经常有活动期限,过期后 302 跳转应该返回 410 或跳到提示页,而不是继续跳到原地址。
如果你拿到的只有 Java 实体类而没有建表 SQL,可以用 MyBatis-Plus 的代码生成器反推建表脚本,但我更建议按上面的表结构直接建,避免代码生成器把超长字段自动映射成别扭的类型。
4.2 生成短码并落库的 Service 实现
@Service @RequiredArgsConstructor public class ShortLinkService { private final ShortLinkMapper shortLinkMapper; private final RedisTemplate<String, String> redisTemplate; private static final Duration CACHE_TTL = Duration.ofDays(7); @Transactional public String createShortLink(Long tenantId, String longUrl, LocalDateTime expireTime) { ShortLink link = new ShortLink(); link.setTenantId(tenantId); link.setLongUrl(longUrl); link.setExpireTime(expireTime); for (int i = 0; i < 3; i++) { link.setShortCode(Base62Utils.encode(SnowflakeId.next())); try { shortLinkMapper.insert(link); redisTemplate.opsForValue().set(shortKey(link.getShortCode()), longUrl, CACHE_TTL); return link.getShortCode(); } catch (DuplicateKeyException ignored) { // 冲突后下次循环重新生成短码 } } throw new BizException("生成短链失败"); } private String shortKey(String code) { return "short:link:" + code; } }逻辑说明:先构造对象,用雪花 ID 生成全局不重复的长整数,转成 62 进制短码。插入时如果和其他并发请求撞了唯一索引,捕获异常换下一个 ID 重试,最多三次。插入成功后立刻把“短码 → 长链接”写入 Redis,这样创建完的链接第一次被访问时就不用回源数据库。参数说明:缓存 TTL 设为 7 天,后续每次访问时用滑动过期策略续期,这样临近过期又仍被高频访问的链接不会突然打回源;Redis 的 Value 只放 longUrl,不放下沉的对象结构,命中链路尽量短。
4.3 跳转控制器:302 与异步统计
跳转接口是整个系统里 QPS 最高的接口,处理逻辑要尽量少。拿到短码后先查 Redis,命中就直接拼 Location 返回 302;未命中再查库,查到就把缓存回填,查不到返回 404。同时把访问信息放到一个内存队列里,异步写入访问日志:
@RestController public class RedirectController { private final ShortLinkService shortLinkService; private final VisitLogProducer visitLogProducer; @GetMapping("/{code}") public ResponseEntity<Void> redirect(@PathVariable String code) { String longUrl = shortLinkService.resolve(code); if (longUrl == null) { return ResponseEntity.notFound().build(); } visitLogProducer.submit(code); return ResponseEntity.status(HttpStatus.FOUND) .location(URI.create(longUrl)) .build(); } }逻辑说明:resolve里走缓存、回源、回填三步,日志提交走异步线程池,主线程只做一次 Redis 读和一次响应构造。选择 302 而不是 301 的原因是要保留每次点击都走我们服务的权利,能统计、能安全管控;301 会让浏览器和 CDN 缓存重定向结果,虽然性能更好,但点击量从此变成黑匣子。visitLogProducer内部用一个有界队列加消费线程,队列满时直接丢弃日志,避免拖垮跳转主链,统计允许延迟但不允许丢跳转。
4.4 管理接口清单:建链、批量导入与状态管理
管理后台一般通过 REST 接口和前端系统通信,核心接口集中在链接管理。下面是接口清单与参数约定:
| 接口 | 请求方式 | 作用 | 关键参数 |
|---|---|---|---|
| /api/v1/short-links | POST | 新建单条短链 | longUrl、expireTime |
| /api/v1/short-links | GET | 分页查询当前租户短链 | page、size、status |
| /api/v1/short-links/{id}/status | PUT | 启停短链 | status(1/0) |
| /api/v1/statistics/{code} | GET | 查看点击趋势 | startDate、endDate |
| /api/v1/short-links/batch | POST | 批量导入长链接 | urls[]、expireTime |
批量导入通常由运营系统一次传几千行长链接,后端分批生成短码返给前端,这里的参数要点是单批 500 条,否则 MyBatis-Plus 的 batch 过长会占用太多 MySQL 的max_allowed_packet配额。如果你在对接 Vue3 后台管理系统模板,前端会按这套接口约定封装请求,后端钉死字段命名风格即可,不用额外适配。
5. 短链接系统避坑记录:点击量虚高、租户串数据、缓存穿透五个翻车现场
5.1 点击量对不上:302 预加载和爬虫造成的“虚高”
现象:链接发出去第二天看数据,点击量是老员工手工统计时的 3 倍。原因:微信、钉钉内置浏览器会预先请求链接做安全检测,很多运营系统的素材爬虫也会自动追一遍短链,这些量都算进了“点击”。解决:在访问日志里记录 User-Agent、Referer、IP,统计口径区分真实用户和 Bot。实践上把 UA 含bot|spider|slurp|curl|python-requests的流量单独存一张表,报表默认不展示;再配合同一 IP 同一短码 5 秒内只计一次的规则,点击量才接近真实水平。这个“虚高”问题一点都不玄学,把日志拉出来按 UA 分组看一眼就知道谁的贡献最大。
5.2 租户串数据:MyBatis-Plus 插件没作用在自定义 SQL 上
现象:租户 A 后台偶尔出现租户 B 的链接,不是每次都出现,无法稳定复现。原因:项目里有通过@Select注解手写的统计 SQL,比如SELECT long_url FROM t_short_link WHERE short_code = #{code},租户插件能解析自动生成的 CRUD SQL,但手写 SQL 如果没有走插件解析同样会被忽略。解决:所有自定义 SQL 都放进 Mapper XML,并在 SQL 里显式带上tenant_id;同时在接口层对返回结果做二次校验,result.getTenantId().equals(TenantContext.get())不相等直接报错。这个血泪经验的价值在于,它告诉我们不能把“插件已经处理租户条件”当成默认事实,凡是涉及租户 ID 的代码 review,都要多核一遍。另一个诱因是@Select注解里用了IN子查询,MyBatis-Plus 的租户插件在复杂 SQL 上的解析能力有限,要么把 SQL 改写放行,要么老老实实在 XML 里手写条件。取舍上我通常建议手写,因为改写逻辑容易在下个版本升级时悄悄失效。
5.3 缓存穿透:一次脚本挨打,数据库差点被击穿
现象:某个短码在活动页被刷成 404,但每秒几千次的访问仍然全部打到 MySQL。原因:Redis 只缓存了存在映射的短码,不存在的短码每次都回源,被脚本刷根本不存在的短码就会穿透。解决:把不存在的短码也用空字符串缓存,TTL 给 60 秒;再在跳转层加一个布隆过滤器,用已有短码集合判断是否存在,判断不存在直接 404,避免回源。两者结合后,数据库基本只在缓存回填的瞬间有零星读写。注意事项:空缓存 TTL 不要让所有 key 同时过期,可以加上随机正负 5 秒的偏移,避免缓存雪崩的副作用。
5.4 自定义域名与 HTTPS:客户端看到“连接不安全”
现象:客户绑定了自己的域名 c.xxx.com,配置完解析后访问却报证书错误。原因:短码域名解析到了平台服务器,但证书只绑定了主站域名,子域名不覆盖。解决:给短链域名单独申请泛域名证书或单域名证书;如果用了 CDN,还要确认回源协议是 HTTPS 还是 HTTP,回源协议不一致会反复握手,首延飙升。这个坑其实不怪代码,但要在系统里给租户留出“域名配置 → 证书状态 → 解析状态”三个状态,不然客户只会找你的售后背锅。域名配置做不好,不少营销团队开会时直接开着手机投屏演示,页面挂锁非常显眼。
5.5 短码重复率偏高:唯一索引救场,但日志要留证据
现象:线上日志出现大量 DuplicateKeyException,看起来像是短码生成算法出了问题。原因:如果短码由随机字符串生成,又没做概率控制,冲突概率随规模上升,在 6 位短码、100 万数据时随机碰撞已经需要认真对待;自增发号器没问题,但如果是手工从旧表导入数据,ID 重新生成后可能和已有短码撞车。解决:保留唯一索引,catch 到冲突后记录冲突短码、生成时使用的种子值、重试次数,方便事后分析;不要直接关掉唯一索引来“避免报错”,那是拿数据完整性换表面上的日志干净。真到了高频冲突,先查是不是不同环境共用了同一套发号器,这是最常见的坑。
6. 上线前的进阶调优:跳转接口从 2000 到 6000 QPS 的具体改法与验证
6.1 读路径三步改造:Redis 直读、本地缓存、空值保护
跳转接口的瓶颈往往不在代码,而在每次请求拆出去的数据访问次数。第一版是每个跳转都读一次 Redis,去掉一次 DB 回源后 QPS 大概翻了 1.5 倍。再往下做,把短码映射放在本机 Caffeine 缓存里,TTL 60 秒,Redis 挂了本地缓存还能撑一段窗口。注意缓存更新时机要统一,否则活动期改了链接跳转目标,本机缓存会把老目标带到 60 秒以后。
6.2 点击统计从同步写改为异步消费,主线程不再等日志
不同步写日志后,主线程少了一次表插入。我的做法是在服务里放一个ArrayBlockingQueue,跳转请求只往队列里放一条消息;后台单线程批量消费,每攒 100 条批量 insert 一次。队列容量设 10000,满了丢弃新消息并计数,监控这个 drop 指标来反推日志写入能力。这样做的代价是统计有几十秒到几分钟延迟,但对点击趋势报表完全可接受。
6.3 验证方法:用压测找出瓶颈是 CPU、GC 还是连接池
不要拍脑袋说调优成功,压测要能复现。先用wrk -t8 -c200 -d30s http://localhost:8080/abc123压跳转接口,观察 CPU 与 GC 日志:如果 CPU 没打满但 QPS 上不去,优先怀疑连接池或 Redis 连接开销;如果 GC 频繁,调堆大小并检查是否有大对象。把 302 响应时间和异步统计日志分开采样,确认掉 QPS 的段落是否落在批量写库的时间点,是就把批量窗口调更小,或改成写入消息队列。
这套改法做完,线上验证一周后我们的跳转腰身从 2000 出头提到接近 6000 QPS,代价是统计延迟从秒级变成分钟级,对营销报表完全不影响。短链这种东西看着极其简单,真正复杂的是把每一分性能都花在刀刃上。我一直记得刚上线两天点击量虚高十倍、租户串号被客户电话骂醒那次,后来所有涉及租户 ID 的 SQL 都要在 code review 里多核一遍。希望帮到你。
本文还有配套的精品资源,点击获取