简介:基于Java的SaaS短链接管理系统设计源码,面向需要搭建或研究短链接服务的企业开发者、Java学习者及毕业设计人员,旨在解决长网址分享不便、点击数据分散和管理安全性不足等痛点。整套资源压缩后约563KB,共219个文件,包含188个Java源文件、14个XML配置、11个YAML配置、2个SQL脚本以及HTML、Lua等辅助文件,Java源码覆盖短链接生成、分组管理、用户与回收站、Redis流式消息监听等业务模块,配置与脚本则支撑系统启动、数据库初始化和语言扩展,目录按admin、gateway、aggregation等分层,便于按功能快速定位。系统支持PV、UV、UUId等关键指标统计,并内置安全监控能力,可直接用于SaaS化短链接平台的设计参考或二次开发。已有391人学习下载,对想掌握短链接系统从数据模型到消息处理完整链路的中高级Java开发者很有帮助。
1. 短链接系统为什么绕不开Java与SaaS这套组合拳
一个看起来只是“把长链变短再跳转”的功能,一旦按SaaS去设计,反而会同时撞上高并发读、缓存一致性、多租户数据隔离、防恶意刷量四座山。我这两年用Spring Boot 3、MyBatis-Plus和Redis连续做过两版短链接管理系统,体会很直接:Java生态里能很顺地接到成熟的限流、熔断、分布式缓存组件,而SaaS化带来的租户和账号体系,恰恰是这个方向做产品的基本盘。这篇文章适合有单体开发经验、想自己把短链接服务做成交付产品的人,也适合接手这类源码做二次开发的团队。后面内容不依赖某份现成源码包,我会按一次从零搭建的路径讲清楚模型、代码和坑。
2. 先定边界:短链接系统的核心链路与数据模型设计
2.1 一条短链接的完整生命周期:从生成到点击再到回收
做设计不能一上来就写代码,先把生命周期画清楚。一次完整的短链流程分成五步:创建时,管理端把原始长链接和所属租户ID交给服务端,服务端产出全局唯一的短码并落库;第二步,服务端把短码和长链接的映射写进Redis,供后续跳转命中缓存;第三步,用户访问短链时,跳转接口按短码查出对应的长链接,返回302临时重定向;第四步,跳转的同时异步写一条访问日志,记录租户、IP、UA、来源页面和时间;第五步,当短链到期或被主动删除后,Redis缓存失效,DB里的记录进入回收或冷数据区。
这五步里最容易漏的是第五步。很多只做“生成+跳转”的管理系统源码跑起来很正常,一到生产就出问题:过期短链一直在被访问,数据库里堆了几千万条僵尸记录,点击统计也错得离谱。原因不是代码能力,而是没有把回收放进核心链路里设计。所以我在建表之前,一定会先把这张流程画给自己看,明确每一步归属于哪个租户、在哪张表落数据。
2.2 数据模型设计:租户、短链、访问日志三张表怎么建
短链接系统的表结构不复杂,但字段坑很多。我以MySQL 8.0为例,给出我目前沿用的一套简化DDL,重点是字段类型、唯一索引和冗余字段,别急着往里头加一堆无关业务字段。
-- 租户表:管理端登录、配额、状态都挂在租户上 CREATE TABLE t_tenant ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID', tenant_name VARCHAR(64) NOT NULL COMMENT '租户名称', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', daily_generate_limit INT NOT NULL DEFAULT 10000 COMMENT '每日生成短链上限', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='租户表'; -- 短链表:短码全局唯一,即使租户不同也不能共用一个短码 CREATE TABLE t_short_link ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', tenant_id BIGINT NOT NULL COMMENT '租户ID', short_code VARCHAR(8) NOT NULL COMMENT '短码,全局唯一', long_url VARCHAR(2048) NOT NULL COMMENT '原始长链接', expire_time DATETIME NULL COMMENT '过期时间,NULL表示永不过期', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', create_by BIGINT NOT NULL COMMENT '创建人ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_short_code (short_code), KEY idx_tenant_create (tenant_id, create_time DESC) ) ENGINE=InnoDB COMMENT='短链接映射表'; -- 访问日志表:按短码加时间聚合点击数据 CREATE TABLE t_link_visit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', tenant_id BIGINT NOT NULL COMMENT '租户ID', short_code VARCHAR(8) NOT NULL COMMENT '短码', visitor_ip VARCHAR(64) NOT NULL COMMENT '访问者IP,IPv6存完整串', user_agent VARCHAR(512) NOT NULL COMMENT 'UA原始串', referer VARCHAR(1024) NULL COMMENT '来源页', country VARCHAR(32) NULL, city VARCHAR(64) NULL, visit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_short_time (short_code, visit_time DESC) ) ENGINE=InnoDB COMMENT='短链访问日志表';几个字段值得说清楚。short_code长度限制在8个字符以内,因为常见短链接都在这个量级,太长用户体验差,太短碰撞概率翻倍;这里不留tenant_id做联合唯一,是因为短码本身就应该全局唯一,否则两个租户生成同一条短码,跳转时服务端根本不知道该给谁,这是SaaS场景和内部工具最关键的差异。long_url用VARCHAR(2048)而不是TEXT,是因为跳转接口要频繁读这一列,行内存储比溢出页更快;极长链接很少见,真有就做二次压缩或参数裁剪。日志表里的country、city是冗余字段,配合IP解析库在写入时落值,避免统计时再去查第三方库。管理端界面通常用Vue3后台管理系统来做,后端只出JSON、不渲染页面,前后端可以完全分开交付。
2.3 给表加索引和分区:别等上线后再想起来
日志表的查询模式非常固定:按短码和时间区间聚合,不会有单行主键查询。所以我把主键保留为自增BIGINT,但真正提供查询能力的是idx_short_time这个二级索引。按这个索引做聚合没问题,但数据量上来后,索引和行数据都会成为IO瓶颈。
我给客户交付时一般按两条规则预扩容。第一,短链表单表控制在5000万行以内,超出后按tenant_id做哈希分表或转存OSS归档。第二,日志表按月分区,每个月对应一个RANGE分区,或者直接用按月的分表任务。统计接口按天然的时间范围去查询,直接落在月份分片上,避免扫全表。有个容易被忽略的点:日志表分区键建议用visit_time而不是tenant_id,因为绝大多数统计查询都带时间范围,不带时间的跨租户查询几乎只在后台运维时才出现,单独走旁路任务处理就行。
容量估算同样要在设计阶段做。假设一个租户每天生成2000条短链,50个租户就是10万条,一个月300万条短链;日志按每条短链平均被点击10次算,一个月就是3000万条日志。看到这个数字,你就明白为什么日志表必须按月分区、为什么统计查询不能直接落在短链主表上。
3. 用Spring Boot落地短链接生成:算法选型与接口实现
3.1 短码生成算法怎么选:随机码、62进制、哈希截取做对比
选算法之前先明确短码的三条硬约束:长度短、全局唯一、不可预测性不要太强。三条同时满足很难,工程上都在做取舍。我对比过三种主流做法:
| 算法 | 生成方式 | 短码长度 | 碰撞风险 | 主要问题 |
|---|---|---|---|---|
| 随机字符串 | 从字符集随机抽6-8位 | 6-8位 | 随数据量上升 | 必须先查重,写入压力大 |
| 62进制自增 | 全局ID转62进制 | 6-8位 | 无碰撞 | 短码可被顺序遍历,依赖发号器 |
| 哈希截取 | MD5/SHA-256取前6-8位 | 6-8位 | 有理论碰撞 | 需要唯一索引与重试兜底 |
我一般选哈希截取,理由是它在三者中兼顾了不可预测性和无中心依赖。随机字符串每次生成都要去查一次“这个码用过没”,数据量到千万级后查询代价很高;62进制自增虽然没碰撞,但短码连号,竞品可以直接遍历你的短链列表,SaaS场景里很容易被恶意脚本拉取全部链接。哈希截取需要解决碰撞,解决方式也很简单:数据库唯一索引加上生成时重试。
这里补充一个参数:哈希取几位。取6位对应约568亿空间,一千万条数据跑下来会有几百次碰撞,生成接口会频繁触发重试,但能压住;取8位几乎没有碰撞,代价是短码每多一位,短信和投放场景里的字符成本都会增加。实践上我倾向取8位,在“短”和“稳”之间更稳。
3.2 生成接口实现:布隆过滤器、唯一索引与重试
直接看代码。这个Service方法是整个系统的核心入口,我按“先挡无效请求、再落库、最后写缓存”的顺序写,每一层都有明确用途。
@Service @RequiredArgsConstructor public class ShortLinkService { private final ShortLinkMapper shortLinkMapper; private final StringRedisTemplate redisTemplate; // 位图预估5000万条短码,误判率1% private final BloomFilter<String> codeFilter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 50_000_000, 0.01); public String createShortCode(String longUrl, Long tenantId, int ttlSeconds) { String shortCode = null; for (int i = 0; i < 3; i++) { // 只重试3次,避免无限循环拖垮接口 shortCode = generateShortCode(); // MD5取前8位 // 1. 布隆过滤器快速判断:不存在就大概率可以直接用 if (codeFilter.mightContain(shortCode)) { continue; } // 2. 插入数据库,唯一索引冲突时进入重试 try { ShortLink link = new ShortLink(); link.setTenantId(tenantId); link.setShortCode(shortCode); link.setLongUrl(longUrl); link.setExpireTime(ttlSeconds > 0 ? LocalDateTime.now().plusSeconds(ttlSeconds) : null); shortLinkMapper.insert(link); codeFilter.put(shortCode); // 3. 缓存统一用短码做key,value里拼上租户ID,供跳转时反解 String cacheValue = tenantId + "|" + longUrl; redisTemplate.opsForValue().set( cacheKey(shortCode), cacheValue, ttlSeconds > 0 ? ttlSeconds : 2592000, TimeUnit.SECONDS); return shortCode; } catch (DuplicateKeyException e) { // 唯一索引冲突,说明生成重复,进入下一次重试 } } throw new BizException("短码生成失败,请重试"); } private String generateShortCode() { String md5 = DigestUtils.md5DigestAsHex( UUID.randomUUID().toString().getBytes()); return md5.substring(0, 8); // 取前8位 } private String cacheKey(String shortCode) { return "sl:" + shortCode; } }代码里有三个兜底,缺一不可。布隆过滤器只是“一定不存在”的快速判断,它挡掉了大量重复生成时无意义的写库尝试;数据库唯一索引是最终裁决者,就算布隆误判说“可能存在”,insert一样会因为唯一键冲突失败;重试循环把这层冲突消化掉。重试次数不要设太大,3次足够,因为MD5取8位在千万级数据下碰撞概率本来就不高,连续撞3次的概率可以忽略。
几个参数按业务调。短码长度8位是默认值,如果对短信字数敏感想压到6位,先把布隆过滤器的预期容量调高,同时准备接受更多重试;ttlSeconds是短链过期时间,0表示永不过期,Redis侧默认给30天TTL,是为了让永不过期的短链也尽量别一直占内存,DB里过期时间还是空,等下次被访问时决定是否续期。缓存value里拼接了tenantId,这是SaaS多租户的基础,跳转时能从缓存直接拿到租户信息。
3.3 跳转接口实现:301还是302,为什么SaaS场景必须用302
跳转接口是短链系统的读热点。一个大原则:SaaS产品一律用302,不要用301。301是永久重定向,浏览器第一次访问后会把结果缓存住,后续请求根本不会再到服务端,这样你就拿不到点击数据,也做不了租户级封禁和分流。302每次都会请求服务端,哪怕多一次跳转开销,长期看是值得的。
@GetMapping("/{shortCode}") public void redirect(HttpServletRequest request, HttpServletResponse response, @PathVariable String shortCode) throws IOException { // 1. 先从Redis查,缓存命中直接跳转 String cached = redisTemplate.opsForValue().get(cacheKey(shortCode)); Long tenantId = null; String longUrl = null; if (cached != null) { int sep = cached.indexOf('|'); tenantId = Long.parseLong(cached.substring(0, sep)); longUrl = cached.substring(sep + 1); } else { // 2. 缓存没有,查数据库,并回填缓存 ShortLink link = shortLinkMapper.selectOne(new LambdaQueryWrapper<ShortLink>() .eq(ShortLink::getShortCode, shortCode) .last("LIMIT 1")); if (link == null) { response.setStatus(HttpStatus.SC_NOT_FOUND); return; } if (link.getExpireTime() != null && link.getExpireTime().isBefore(LocalDateTime.now())) { response.setStatus(HttpStatus.SC_GONE); // 410,告诉客户端链接已失效 return; } tenantId = link.getTenantId(); longUrl = link.getLongUrl(); // 回填20分钟,避免热点短链打穿DB redisTemplate.opsForValue().set(cacheKey(shortCode), tenantId + "|" + longUrl, 20, TimeUnit.MINUTES); } // 3. 异步记录访问日志,线程池或MQ都行 saveVisitLog(tenantId, shortCode, request); response.setStatus(HttpStatus.SC_MOVED_TEMPORARILY); response.setHeader("Location", longUrl); response.setHeader("Cache-Control", "no-store"); }先想清楚顺序:Redis查不到再查库,查库时直接用短码查,不带租户条件,因为短码全局唯一,这个查询本身就要跳过租户隔离。查到后把“租户ID|长链接”回填20分钟,这个TTL比生成时的默认缓存短,是为了避免短链被改绑后缓存还留着旧值。跳转前先做过期校验,过期给410而不是404,语义上更准确,客户端能明确区分“不存在”和“已过期”。最后日志写入放进异步线程池或MQ,不要让跳转接口等日志落库,并发一上来就废了。
这里有个容易踩的坑:response.setHeader("Cache-Control", "no-store")这行不能省。虽然用了302,但某些浏览器和CDN会对302结果做启发式缓存,加这行能强行阻止。异步日志的线程池要独立配置,不要用JDK默认的无界队列,否则高峰写日志会把内存撑爆;异步任务里没有登录态,执行前要临时把tenantId塞进TenantContext再clear,否则租户插件会用空值拼接SQL。
4. SaaS多租户改造:三种隔离模式的取舍与落地
4.1 三种多租户隔离方案对比与选择
SaaS系统的隔离选择直接决定后续运维成本和数据安全边界。短链接这种读多写少、单租户数据规模不算大的业务,我不建议一上来就做独立数据库。
| 方案 | 隔离强度 | 成本 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最强 | 高 | 高 | 金融、私有化、强合规 |
| 共享库独立Schema | 中 | 中 | 中 | 租户量少、表结构稳定 |
| 共享表+租户ID | 逻辑隔离 | 最低 | 低 | 中小规模SaaS,短链系统典型选择 |
共享表+租户ID的风险是“一旦where条件漏了tenant_id,就是跨租户数据泄露”。这个风险靠两条防线兜住:第一是数据库层不采用任何魔法,只靠应用代码和ORM自动拼接;第二是用MyBatis-Plus的租户插件做强制条件注入,让所有SQL都自动带上tenant_id。我见过不少团队用共享表方案裸奔,查询写在Service里忘带租户条件,结果用户改一下请求参数就看到了别的租户的短链数据,这属于事故级问题,不是小bug。
不过话说回来,当单个租户的数据量大到几亿行,或者甲方明确写了“数据需要物理隔离”的合规要求时,共享表就撑不住了。我的判断标准是:如果这个SaaS短链服务要卖给银行、政务或大型国企,直接按独立数据库或独立Schema报价,不要为了省一台数据库把合同风险扛下来。技术选型没有银弹,先搞清楚客户会为“隔离度”付多少钱,再决定方案。
4.2 基于MyBatis-Plus租户插件做共享表隔离:拦截器与上下文
MyBatis-Plus从3.5.x开始提供了TenantLineInnerInterceptor,用法简单,但有几个前置条件:每张涉及租户数据的表都要有tenant_id字段,实体类要有对应属性。核心配置如下。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 租户插件要放在分页插件之前,顺序别搞反 TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor( new TenantLineHandler() { @Override public String getTenantId() { // 从自定义上下文里取当前租户ID return String.valueOf(TenantContext.getTenantId()); } @Override public boolean ignoreTable(String tableName) { // 租户表是全局表,不带租户条件 if ("t_tenant".equalsIgnoreCase(tableName)) return true; // 跳转接口没有租户上下文,短码全局唯一,放行单表全局查询 if (TenantContext.getTenantId() == null && "t_short_link".equalsIgnoreCase(tableName)) return true; return false; } }); interceptor.addInnerInterceptor(tenantInterceptor); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配套的TenantContext用ThreadLocal实现,底层很简单,但要注意线程池传递问题。
public class TenantContext { private static final ThreadLocal<Long> HOLDER = new ThreadLocal<>(); public static void setTenantId(Long tenantId) { HOLDER.set(tenantId); } public static Long getTenantId() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }TenantLineInnerInterceptor的原理是在应用层改写SQL:执行前给SELECT的where条件追加tenant_id = 当前值,给INSERT语句补充tenant_id列,给UPDATE和DELETE追加同样的过滤条件。只要你配置了拦截器,手写SQL里忘没忘租户条件都会被补齐。前提是当前上下文里有值,所以每个请求进入Controller前,由过滤器或拦截器从Token里解析租户ID并塞进TenantContext,请求结束再clear,否则线程池复用时会出现租户串号。
重点是ignoreTable方法。租户表、系统配置表这类全局表一定要排除;跳转接口是公开入口,没有租户上下文,但短码本身全局唯一,允许它全局查询反而更正确。拦截器顺序也有讲究,租户插件必须放在分页插件之前,因为分页插件要基于改写后的SQL做COUNT,顺序反了会导致分页总数错误。另外在线程池里写日志或做异步任务时,ThreadLocal里的租户ID不会自动带过去,要么手动传参,要么用TransmittableThreadLocal,否则异步线程里拿到的租户ID是空的。
4.3 租户维度缓存与配额控制的常见做法
数据隔离解决后,缓存也要跟着隔离。第三章里缓存key只用了短码,value里拼了tenantId,是因为跳转入口没有登录态,带租户的key在跳转时无法定位。这个设计带来的麻烦是“按租户清缓存”不能直接扫前缀了;我的处理是写一个后台任务,先查SELECT short_code FROM t_short_link WHERE tenant_id = ?,再逐条DEL。几万条短码删起来也就几十毫秒,比在Redis里单独维护租户索引更省钱。另一个隐蔽场景:某个租户违规被停用,先用SQL把它的短码都查出来,再批量删缓存,跳转接口自然就返回404了。
配额控制在SaaS里是收入相关的硬功能。常见实现是租户表存daily_generate_limit,生成短链前用Redis的INCR对"quota:{tenantId}:{yyyy-MM-dd}"做自增,第一次INCR后设置一天过期。INCR返回1说明是当天第一条,同时设置TTL;INCR返回超过上限就拒绝生成并返回明确错误码。这里有个坑:INCR和EXPIRE不是原子的,极端情况下会没设上TTL,导致key永不过期、配额永久失效。解决方式是写一个Lua脚本把两步合成原子操作,代码量不大但能避免这个隐患。
5. 避坑清单:缓存穿透、重复码、过期回收的5个高频排查记录
短链接系统代码量不大,生产事故却不少。下面五条是这类系统里最常见的坑,按“现象→原因→解决”写,每一条都值得收进团队的排查手册。这些坑都属于一看就会、一跑就翻车的类型。
5.1 缓存穿透:不存在的短码刷爆数据库
现象:线上突然出现大量404请求,数据库连接数飙升,Redis还有余量,DB的SELECT却一直在慢查询日志里。
原因:攻击者遍历不存在的短码,每次请求先打Redis,查不到就落库,数据库被无效查询打穿。我当时第一反应是加长Redis缓存,结果根本不解决问题,因为key本身不存在,缓存里没有东西可命中。
解决:两条路都做。一是在Redis里缓存空值,value用空字符串,TTL设60秒,让不存在的短码在一分钟内不再打库;二是在布隆过滤器上也做一次查询拦截,虽然布隆对“已存在”没有100%保证,但对“不存在”的判断是准确的,直接用mightContain快速拒绝。空值缓存和布隆过滤不冲突,空值兜底的是布隆更新延迟期间的那几十毫秒,实际效果很好。
5.2 短码碰撞:布隆过滤器误判与唯一索引兜底
现象:接口偶发报DuplicateKeyException,业务日志里能看到重试,但用户感知是创建失败,重试后的短码和之前一样。
原因:布隆过滤器和唯一索引都做了,但漏了一个关键点:布隆过滤器在应用启动时没有预热,刚启动的一段时间内,已存在的旧短码在布隆里是空的,导致新生成的短码撞上旧短码时只能靠唯一索引发现。另一个情况是重试函数写得不严谨,连续两次generateShortCode都没有把旧值从布隆里清掉,同一个随机串被重复使用。
解决:启动时做一个预热任务,把最近90天内有效的短码扫描一遍并逐条put进布隆过滤器;同时生成函数里用UUID作为MD5输入,确保每次结果都不一样。更稳妥的做法是即使重试到第3次,也只把异常上抛,让前端提示用户重新提交,不要无限循环把CPU烧掉。现在的代码里还加了校验位:短码前6位来自hash,最后两位来自校验算法,进一步降低碰撞后跳转错误链接的概率,代价是短码长度多两位。
5.3 短链过期回收:懒删除与定时清扫
现象:t_short_link表里过期数据占比超过60%,每次查询都慢,统计报表的数字也和业务对不上。
原因:只设置了expire_time字段,但没有做回收。短链到期后,Redis的TTL会自然把缓存淘汰,但DB里的行还在;过期短码被重新生成时,唯一索引又挡住不让插入,于是用户只能不断换长链接。这个问题的本质是“过期”和“删除”没有联动。
解决:设计两段式回收。第一段是懒删除,跳转接口发现短码超时后,先返回410,再异步把这条记录的状态置为禁用,这样下次直接走空值缓存逻辑,不再查库;第二段是定时任务,每天凌晨扫描过期超过24小时且状态仍为启用的记录,批量软删除。短链系统的过期时间一般只精确到天,定时任务放在低峰期跑即可。这个方案能让“看起来过期”和“真正可复用”之间的窗口控制在1天以内。
5.4 跳转被缓存:301带来的统计黑洞与CDN误缓存
现象:点击量在某个时间点后骤降为原来的五分之一,业务方确认有正常投放;排查时发现很多请求返回的是200而不是302。
原因:跳转接口曾经被改成301以“提升跳转效率”,浏览器把永久重定向缓存了;更隐蔽的是某个CDN厂商对302也做了缓存,回源协议配置里没有关闭“缓存重定向”。
解决:一律切回302,并显式输出Cache-Control: no-store和Expires: 0。如果必须经过CDN,在CDN控制台关闭“重定向缓存”行为。排查时不要只看状态码,要看响应头里有没有Cache-Control,因为有些中间件会偷偷给302加上缓存头。这个问题我后来写进了发布检查清单:每次改跳转逻辑,都要用浏览器隐身窗口和curl -I各看一次响应头。
5.5 租户数据越权:接口里忘了带租户条件
现象:某个租户反馈能打开别人的短链;复现时把请求里的Token换掉,依然能跳到同一组长链接。
原因:共享表+租户ID的模式下有两个漏洞。一是跳转接口只查short_code、没查tenant_id,本身不算越权,因为短码全局唯一,但管理端改名接口用主键更新,where条件漏了tenant_id,A租户就能把B租户的短链改掉;二是某些原生SQL没有走MyBatis-Plus的租户插件,绕过自动拼接。
解决:MyBatis-Plus租户插件能自动处理大部分SQL,但它拦不住你自己写的原生SQL和特殊sql注入。我的做法是双保险:插件开着,同时在Service层的关键更新方法里手动加一次tenant_id条件;跳转接口不校验租户,因为它本来就是公有的;管理接口一律从Token取租户ID,不给前端传租户ID的机会。这个问题在SaaS短链系统里是最严重的,定级应当是P0。
遇到这类问题,我习惯先看Redis命中率,再看DB慢查询,最后查响应头。命中率低于90%先查穿透和倾斜;慢查询日志里全是同一条SQL就去看索引和分页;响应头出现cache-control相关异常,按5.4的方向查。这个顺序能省掉大半无头绪的抓包时间。
6. 进阶:点击埋点与统计分析,让短链接系统具备数据价值
短链接的真实价值不在跳转本身,而在点击数据。把日志表里的原始数据变成“哪个渠道、哪个时间、哪个地域带来了多少点击”,才是让管理层愿意为这个系统买单的理由。这一节讲三个我认为最值得做的分析能力。
第一是异步链路。跳转接口同步返回302,日志写入必须异步。常见做法是用阻塞队列加固定线程池,核心线程数设为CPU核数,队列容量控制在一万左右,满了就丢弃并打WARN;条件允许的话上RocketMQ或Kafka。不要用redisTemplate直接Rpush,因为Redis本身也有写瓶颈。
第二是聚合查询。日志表按月分区后,统计SQL基本长这样:
SELECT short_code, COUNT(*) AS pv, COUNT(DISTINCT visitor_ip) AS uv, MAX(visit_time) AS last_visit FROM t_link_visit_log WHERE tenant_id = 1 AND short_code IN ('abc123', 'def456') AND visit_time >= '2026-06-01 00:00:00' AND visit_time < '2026-07-01 00:00:00' GROUP BY short_code ORDER BY pv DESC LIMIT 50;这个SQL必须命中(tenant_id, short_code, visit_time)的联合索引,否则按月分区也扛不住。同时要控制short_code的范围,别把整表拉出来。
第三是按设备、地域、来源的维度拆解。这里不用联表,日志写入时就把UA解析、IP转地域的结果落进冗余字段,查询时直接GROUP BY country/city/device_type。解析可以用内置的ua-parser和IP库,写入时多花几毫秒,查询时省掉无数麻烦。
我第一版统计功能犯过一个错:所有查询直接打到短链主库,报表一跑,连接池就满了,最后把跳转接口拖垮。后来按“写库只做跳转数据,读库只做统计报表”切了读写分离,定时把日志同步到统计库,问题才彻底解决。这个教训让我后来接手任何带报表的系统,第一件事都是先确认报表查询会不会污染主链路。希望今天的这些细节,能帮你在做短链接系统时少熬夜排查几次。
本文还有配套的精品资源,点击获取