☰
Java SaaS短链接系统设计:多租户隔离与Base62短码生成实战
2026/10/7 1:07:51 网站建设 项目流程

简介:这是一套基于Java技术构建的SaaS短链接管理系统设计源码,面向需要短链接生成、访问统计与营销效果分析的企业或个人开发者。系统涵盖短链接管理、数据监控与分析等核心功能,支持PV、UV、UUI等关键指标统计,并采用模块化设计(含admin、gateway、aggregation、project等模块),便于扩展与维护。压缩包共219个文件,以188个Java源文件为主,辅以14个XML与11个YAML配置、2个SQL脚本、1个HTML页面、1个Lua脚本及Markdown说明等,整体打包体积约563KB,目录结构清晰。目前已有389人学习浏览,适合正在学习Java企业级应用或需要搭建短链接分析系统的开发者参考,可作为系统设计、代码组织与统计功能的实现范例。

1. 基于Java的SaaS短链接管理系统设计源码:先看清楚它解决的不只是“缩短 URL”

很多人看到“基于Java的SaaS短链接管理系统设计源码”这个标题,第一反应是:短链接不就是把一个长 URL 转成短的吗?现成的缩短服务一大把,为什么还要自己写一套 Java 工程。但把“SaaS”三个字加进来以后,问题完全变了:你要面对的是一批租户,每个租户要有独立的短码空间、独立的统计报表、独立的管理后台,还要保证数据互相看不见。

这个设计源码真正的价值,是把“URL 缩短”和“多租户隔离”两套逻辑揉进同一个工程里。URL 变短只是最外层的结果,里面还牵扯短码生成策略、跳转状态码选择、租户上下文传递、配额控制和数据隔离。适合谁看?想在公司内部搭一套短链接中台、准备在 SaaS 产品里内置营销工具、或者想找一个典型 Java 业务工程练手的开发者,都可以照着这套设计思路落地。短链接本身不是黑匣子,但多租户一进来,水深很多。

2. 短链接核心链路拆解:短码生成、302 跳转和过期处理怎么配合

一个短链接系统,光看“能跳转”远远不够。源码设计里真正需要你拍板的是三个点:短码怎么生成、跳转用什么状态码、过期链接怎么处理。把这三个点单独拆开看,每个都可以做成独立模块,也各自有坑。

2.1 短码生成方案对比:为什么 Base62 在 SaaS 场景里最顺手

常见的短码生成思路有四种:UUID 截断、哈希取余、自增 ID 加 Base62、随机字符串。UUID 截断最大的问题是长度太长,即使截到 8 位,碰撞概率也不低,而且字符里可能带横线,体验差。哈希取余的问题在于长度不可控,本质上还是要解决碰撞。随机字符串看着最灵活,但生成时必须查重,并发一上来就变成写瓶颈。

生成方案典型码长冲突处理并发表现适用场景
UUID 截断8-16 位查重/重试一般内部小工具
MD5 取余6-8 位碰撞后加盐一般不推荐
自增 ID + Base625-8 位天然唯一好生产环境常用
随机字符串5-8 位必须查重较差不推荐

生产环境里最常见的做法是“全局 ID + Base62 编码”。全局 ID 用雪花算法生成,然后转成 62 进制字符串。10 亿级别的 ID 编码后大概 6 位,完全满足短链接的体感要求。Base62 的字符集是数字、小写字母、大写字母,不会生成“http://xxx/ab-1”这种带横线或下划线的码。

public class Base62 { private static final String CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; private static final int BASE = CHARS.length(); public static String encode(long num) { if (num == 0) return "0"; StringBuilder sb = new StringBuilder(); while (num > 0) { sb.append(CHARS.charAt((int)(num % BASE))); num /= BASE; } return sb.reverse().toString(); } public static long decode(String str) { long num = 0L; for (int i = 0; i < str.length(); i++) { int idx = CHARS.indexOf(str.charAt(i)); if (idx < 0) { throw new IllegalArgumentException("invalid base62 char"); } num = num * BASE + idx; } return num; } }

逻辑说明:encode 把十进制 ID 映射成 62 进制字符串,低位的 ID 会落在码串靠后的位置。这里做了一次 reverse,让码串看起来更像随机值,虽然本质还是渐进递增。decode 一般用在你需要把短码还原成 ID 的场景,但跳转链路里没必要多这一步——直接拿短码去查表反而更直接,因为短码在表里就是唯一索引。

参数说明:CHARS 的顺序一旦发布就不要改,否则历史短码全部失效。这个码没有加密效果,62 进制的规律是可以通过枚举猜出来的,所以对外暴露的短码不要直接用连续自增 ID 编码,最好用雪花 ID,或者自增 ID 加上随机盐后再编码。

2.2 用 302 而不是 301:跳转策略决定了统计准不准

很多同学看到 301 会想:浏览器缓存一次,后续请求都不打服务器,压力小很多。但在 SaaS 场景里,这个“省”换来的是三个损失。第一,点击量统计严重失真,第二次以后根本不会到服务器。第二,活动链接过期后浏览器还缓存着旧跳转,没办法自动失效。第三,无法在跳转过程中做渠道标记和设备识别。

所以生产环境的短链接服务,几乎都用 302 Found,而不是 301 Moved Permanently。跳转接口的写法很直接:

@Controller public class RedirectController { @GetMapping("/{shortCode}") public ResponseEntity<Void> redirect(@PathVariable String shortCode) { // 先查缓存,再查库,缓存见后面章节 ShortLink link = shortLinkService.getActive(shortCode); if (link == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } return ResponseEntity.status(HttpStatus.FOUND) .location(URI.create(link.getOriginUrl())) .build(); } }

逻辑说明:HttpStatus.FOUND 对应的就是 302。跳转目标放在 location 头里,浏览器收到这个响应后会直接发起二次请求。这里返回 ResponseEntity 而不是直接用 response.sendRedirect,是为了让后续统计逻辑可以挂在同一层拦截器里,代码也更方便写单元测试。

参数说明:如果短链接失效,这里统一返回 404。产品上如果要求“跳到活动结束页”,可以把 404 改成 302 到固定页面,但要注意不要让失效链接变成 SEO 死循环。

2.3 过期短链接:懒删除和定时删除怎么配合

SaaS 场景里,短链接一般都有有效期,常见做法是双轨制。第一轨是查询时判断 expire_at:如果当前时间大于过期时间,就按失效处理,不跳转。第二轨是后台定时任务扫描 expire_at 小于当前时间的记录,分批删除或标记软删。

为什么不干脆在跳转时把过期的记录直接 DELETE?因为“判断过期”和“删除数据”是两件事。跳转链路是热点路径,每分钟可能被访问几千次,如果每次都要执行 DELETE,行锁和磁盘 IO 会拖垮接口。更合理的做法是跳转时只判断,后台用低频率、小批量的任务去清理。这个双轨设计是源码里最容易被忽略的部分,也是后面第 5 章要说的清扫任务翻车点。

3. SaaS 多租户设计:从数据隔离到租户上下文传递

短链接只是表象,SaaS 才是这个标题的重心。多租户设计要回答三个问题:数据放一桌还是分房间、怎么让每个请求都带上自己的租户身份、以及租户隔离会不会把日常开发逼疯。

3.1 隔离方案选型:共享表加租户列是短链接系统的常选

做 SaaS 的人都知道,租户隔离有三条路:独立数据库、共享库独立 Schema、共享库共享表加租户列。独立数据库隔离性最好,但一个租户一套库,数据库连接数、备份、成本跟着涨。共享库独立 Schema 在 MySQL 里运维麻烦,查询要小心跨 schema。短链接这种业务,单条记录几十字节,单表千万行完全扛得住,所以绝大多数场景用共享表加租户列就够了。

隔离方案隔离度成本运维复杂度适用规模
独立数据库高高低大客户、强合规
共享库 + 独立 Schema中中高有一定隔离需求
共享库 + 共享表 + 租户列中低中SaaS 短链接默认推荐

隔离方案选定之后,真正的难点不是“加一列 tenant_id”,而是“别漏掉这个字段”。INSERT 的时候漏了 tenant_id,租户 A 的数据就长到了租户 B 头上。SELECT 的时候漏了 where 条件,管理后台直接把别的租户的链接列表暴露出来。所以租户字段不能靠自觉,要靠框架约束。

3.2 租户上下文传递:ThreadLocal 与拦截器的经典组合

常见做法是定义一个 TenantContext,用 ThreadLocal 保存当前请求的 tenantId,再用一个全局拦截器在请求进来时解析、请求结束时清理。最怕的写法是在 Controller 和 Service 之间一层层传 tenantId 参数,传三五个方法以后,少传一个就是事故。

public class TenantContext { private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>(); public static void setTenantId(String tenantId) { TENANT_ID.set(tenantId); } public static String getTenantId() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } }

逻辑说明:ThreadLocal 让每个线程都有自己独立的租户副本,Service 层不需要依赖 Servlet 对象,也能拿到当前租户。难点不是存,而是清。Tomcat 的线程池会复用线程,如果请求结束不清理,下一个请求拿到的还是上一个租户的 ID,这是典型的串号来源。

@Component public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId = request.getHeader("X-Tenant-Id"); if (StringUtils.hasText(tenantId)) { TenantContext.setTenantId(tenantId); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); } }

逻辑说明:preHandle 里从请求头解析租户,存入上下文字段。注意这里是“有则存,没有不拦”,因为公开短链接跳转时,用户的浏览器根本不会带租户头。如果是管理后台接口,解析不到租户应该直接返回 401。

参数说明:X-Tenant-Id 这个请求头名字是内部约定,也可以从 JWT 或 Session 里解析,但原理一致。关键是注册进 WebMvcConfigurer,并且明确哪些路径走租户拦截,哪些公开路径要排除。比如 /{shortCode} 这个跳转接口就要单独放行。

3.3 统计隔离与配额控制:多租户下容易漏掉的两个点

多租户系统里,统计和配额是管理端两个核心能力。统计要按 tenant_id 聚合,否则 A 租户的链接点击量会被全站数据稀释,越看越糊涂。配额控制则涉及每个租户允许创建的短链接数量、每日可跳转的流量。这两块如果不在表结构设计阶段做好,后面再加就是动表结构的大改动。

@Service public class QuotaService { private final ShortLinkMapper shortLinkMapper; private final TenantConfig tenantConfig; @Transactional public void createLink(String tenantId, String originUrl, LocalDateTime expireAt) { long count = shortLinkMapper.countByTenant(tenantId); if (count >= tenantConfig.getMaxLinks()) { throw new BusinessException("tenant link quota exceeded"); } // 插入 short_link } }

逻辑说明:创建链接和计数检查放在同一个事务里,防止并发下两个请求同时通过配额检查,最终把租户的链接数量顶破上限。countByTenant 要命中 tenant_id + created_at 的联合索引,否则租户列表页也会一起变慢。

参数说明:最大链接数不是写死常量,最好放在 tenant 表里作为套餐字段,这样不同租户可以卖不同档位。这个设计对商业化很重要,不然每人一个包年量,运营完全没法调。

4. 基于 Java 的设计源码落地骨架:Spring Boot 下可复现的最小工程

前面把原理拆完了,接下来把工程骨架补上。这里用的技术栈是 Spring Boot + MyBatis-Plus + MySQL,这也是 Java 后端做业务系统的主力组合。短链接查询逻辑简单,用 MyBatis 可以直接控制 SQL 的租户拼接,比 JPA 更适合做多租户自动改写。

4.1 工程结构与技术栈选择

工程结构按标准分包:controller 放跳转接口和后台管理接口,service 放短码生成、过期判断、配额控制,mapper 放 MyBatis 映射接口,model 放实体类,interceptor 放租户上下文拦截,config 放注册和缓存配置。这套结构不需要额外引入复杂框架,一个 Spring Boot 工程加两张表就能跑起来。

跳转接口用 MVC 的 @GetMapping,管理后台用 RESTful 接口。数据库连接池用 HikariCP,Spring Boot 默认就是它。缓存可以后加,先用 Redis 做热点短码的映射加速,后面第 6 章会讲。

4.2 核心表结构:short_link 和 tenant 表怎么设计

短链接系统的表结构不复杂,但索引要一次设计对。tenant 表管租户与配额,short_link 表管短码与来源。这里有一个重要决定:短码必须全局唯一,而不是租户内唯一。因为对外跳转 URL 是域名/短码,用户浏览器里只有短码没有租户标识,同一个短码两个租户都能用的话,跳转就乱了。

CREATE TABLE tenant ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_code VARCHAR(32) NOT NULL UNIQUE, tenant_name VARCHAR(64) NOT NULL, max_links INT NOT NULL DEFAULT 1000, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE short_link ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id VARCHAR(32) NOT NULL, short_code VARCHAR(8) NOT NULL, origin_url VARCHAR(2048) NOT NULL, expire_at DATETIME NULL, click_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_short_code (short_code), KEY idx_tenant_create (tenant_id, created_at), KEY idx_expire (expire_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:uk_short_code 是短码的全局唯一索引,保证跳转查询一定能命中唯一一条数据。idx_tenant_create 服务管理后台的租户列表查询和配额统计。idx_expire 服务过期链接清扫任务,没有这个索引,定时任务全表扫描会把库拖垮。

参数说明:short_code 定 8 位是因为 62 的 8 次方超过两万亿,足够长期使用;如果业务量小,6 位也够。origin_url 用 VARCHAR(2048) 而不是 TEXT,是为了避免大字段拖慢主键索引,正常业务的长 URL 很少超过 2048 字符。expire_at 允许为空表示永久链接。

4.3 生成短码的 Service 实现:从“插入冲突重试”到“预生成码段”

短码生成的核心不是编码算法,而是怎么保证并发创建时不会撞车。正确做法是先拿全局唯一 ID,再 Base62 编码,插入时如果遇到唯一索引冲突再重试一次。不要先查表再插入,那个“检查-插入”不是原子操作,并发下照样撞。

@Service public class ShortLinkService { private final ShortLinkMapper shortLinkMapper; private final IdGenerator idGenerator; @Transactional public ShortLink create(String tenantId, String originUrl, LocalDateTime expireAt) { String shortCode = generateShortCode(); ShortLink link = new ShortLink(); link.setTenantId(tenantId); link.setShortCode(shortCode); link.setOriginUrl(originUrl); link.setExpireAt(expireAt); try { shortLinkMapper.insert(link); } catch (DuplicateKeyException e) { // 极小概率碰撞,重试一次仍失败就抛异常 shortCode = generateShortCode(); link.setShortCode(shortCode); shortLinkMapper.insert(link); } return link; } private String generateShortCode() { long id = idGenerator.nextId(); // 雪花算法或数据库号段 return Base62.encode(id); } }

逻辑说明:idGenerator 建议用雪花算法,保证全局趋势递增且不重复。短码全局唯一后,插入冲突只可能发生在极端情况,比如生成器回拨或者两个请求拿到同一个 ID。捕获 DuplicateKeyException 再换一个新码重试,比在编码时加各种判断简单得多。

参数说明:重试次数建议最多 2 到 3 次,不要无限循环。如果重试几次还是冲突,直接抛异常,让监控告警去查 ID 生成器,问题大概率不在短码表本身。

4.4 查询与跳转接口:过期判断放在哪里最合适

跳转查询的核心方法是“查询短码 + 判断状态 + 判断过期”。这里的关键是:过期判断不要只依赖 SQL,因为后续用 Redis 缓存跳转映射时,缓存里通常不存过期时间,拿出来的数据还要在 Service 层二次校验。

public ShortLink getActive(String shortCode) { ShortLink link = shortLinkMapper.selectByCode(shortCode); if (link == null) { return null; } if (link.getStatus() != 1) { return null; } if (link.getExpireAt() != null && link.getExpireAt().isBefore(LocalDateTime.now())) { return null; } return link; }

逻辑说明:status 是软删除标记,运营下架链接时把它置 0,而不是物理删除。查询时三个条件都满足才返回。这样即使后边加了 Redis 缓存,缓存命中后还可以用这份逻辑做兜底判断,避免过期链接还在跳。

参数说明:expire_at 字段为空代表永久链接,非空则按当前时间判断。这里用 LocalDateTime 比较,注意数据库和 JVM 要统一时区,否则凌晨 8 点前后的链接过期判断会差 8 个小时,这种问题排查起来非常玄学。

5. SaaS 短链接里的五个大坑:从短码冲突到租户数据串号

这套系统看起来简单,但上线跑起来以后,坑基本都在并发、缓存和多租户边界这三个方向上。下面五条都是实际会翻车的点,按“现象 → 原因 → 解决”写清楚。

5.1 并发短码冲突导致接口 500

现象:压测或者运营同事并发创建短链接时,偶发报错 Duplicate entry,接口直接 500。

原因:短码生成后没有用唯一索引兜底;或者代码里写了“先查询是否存在,再插入”,检查与插入之间不是原子操作,并发请求照样拿到同一个短码。

解决:short_link 表必须有 short_code 唯一索引。Service 层插入时捕获 DuplicateKeyException,重试 1 到 2 次。重试还不能解决,就去查 ID 生成器是否发生回拨,而不是反复调 Base62。

5.2 301 跳转让点击量统计严重失真

现象:跳转功能上线后,管理后台点击量只有真实访问量的十分之一,运营拿着数据来质问。

原因:跳转用了 301 状态码,浏览器会把跳转结果缓存住。同一个用户第二次点击同一个短码,浏览器直接使用缓存,请求根本没打到服务器。

解决:全部改成 302 Found。如果要做点击日志,跳转链路只做查询和跳转,把点击计数异步写入消息队列或者 Redis 自增,再异步落库,不要让点击统计阻塞跳转响应。

5.3 租户数据串号:A 租户看到 B 租户的链接

现象:切换租户账号后,管理后台列表里出现别的租户的短链接。

原因:管理端 SQL 漏了 tenant_id 条件;或者 ThreadLocal 里的租户 ID 在请求结束没有清理,Tomcat 线程池复用线程后,下一个请求拿到了上一个租户的身份。

解决:第一,TenantContext.clear() 放在 afterCompletion 里,务必确保所有情况都执行。第二,管理端查询通过 MyBatis 拦截器自动拼接 tenant_id,不需要每个 Mapper 手写。第三,公开跳转接口要从拦截器里排除,否则匿名请求没有租户 ID,短码永远查不到数据。这第三点是很多人忽略的:租户隔离不是“每个 SQL 都拼 tenant_id”,而是“后台 SQL 拼,公开接口不拼”。

5.4 过期链接清扫任务拖垮数据库

现象:定时任务每 5 分钟执行一次,把 CPU 和磁盘 IO 打满,跳转接口 RT 升高。

原因:清扫任务扫全表,或者直接用 DELETE FROM short_link WHERE expire_at < NOW() 一次性删几万行。大范围 DELETE 会拿大量行锁,并且 InnoDB 的 undo log 膨胀很快。

解决:expire_at 建索引,清扫时先查出 1000 个主键,再按主键批量删除。一次删 1000 行,循环执行,每轮之间 sleep 一下。量级再大就用软删除加分区表,按 created_at 做分区,过期直接 drop 分区。

5.5 点击统计写入阻塞跳转接口

现象:活动上线后出现短链接访问慢,数据库连接池被打满。

原因:跳转接口里同步插入 click_log,每跳一次写一条日志。瞬时流量一起来,数据库连接全被写日志占住,读短码的查询也拿不到连接。

解决:跳转接口只做“查短码 + 302”。点击量用 Redis INCR 计数,异步任务每隔几秒把计数批量刷入数据库。如果不想引消息队列,可以用 Spring 的 @Async 或者本地阻塞队列,但一定要注意队列积压和丢失场景。

6. 进阶优化与验证:预生成短码、Redis 缓存和接口压测

6.1 预生成短码池:把冲突从写时解决变成事先解决

日常量不大时,第 4 章的插入重试方案够用。日创建量上万以后,可以预生成一批短码放进池表,创建接口直接从池里取一个未使用的码,标记为已用。这样创建时不需要 Base62 计算,也没有唯一索引冲突,接口耗时更稳定。

CREATE TABLE short_code_pool ( id BIGINT PRIMARY KEY AUTO_INCREMENT, short_code VARCHAR(8) NOT NULL UNIQUE, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:status 0 表示未使用,1 表示已领取。预生成任务可以在低峰期跑,批量插入一批码。创建链接时用 UPDATE ... WHERE status = 0 抢一个码,抢不到再补充。

这个方案的代价是短码空间会有少量浪费,但对 SaaS 短链接来说,几个废弃码完全可接受。

6.2 用 Redis 缓存跳转映射:注意穿透和空值缓存

跳转链路是读多写少的场景,Redis 缓存 short_code 到 origin_url 的映射能扛住大部分流量。缓存 key 用sl:{shortCode}就够了,因为短码全局唯一,不需要带租户维度。访问量大的短码,Redis 命中率能到 95% 以上。

public ShortLink getCached(String shortCode) { String key = "sl:" + shortCode; String url = redisTemplate.opsForValue().get(key); if (url != null) { return new ShortLink(shortCode, url); } ShortLink link = shortLinkMapper.selectByCode(shortCode); if (link == null) { // 空值缓存,防止恶意遍历打爆数据库 redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, link.getOriginUrl(), 300, TimeUnit.SECONDS); return link; }

逻辑说明:不存在的短码也要缓存 60 秒的空值,否则有人恶意遍历短码时,每个请求都穿透到数据库。正常短码缓存 300 秒,编辑长链接或下架短码时,要主动删掉对应 key。

参数说明:空值缓存时间必须比正常缓存短,这里 60 秒足够。缓存的是 URL 字符串而不是整个对象,省内存也省序列化开销。过期判断仍然放到 Service 层,因为正常缓存的 300 秒内,如果链接在后台被下架,Redis 里可能还是旧值。

6.3 压测与验证:用 curl 确认 302,用 ab 看 QPS

功能上线前,最直接的验证是看跳转响应头:

curl -s -o /dev/null -D - "http://localhost:8080/abc123"

如果看到 HTTP/1.1 302 Found 和 Location 头指向正确目标,说明跳转链路通了。再多测一个不存在的短码,确认返回 404,防止把用户导到诡异页面。

压测可以直接用 ab,但注意压测维度要分开:先压公开跳转接口,再压管理后台创建接口,两个接口的瓶颈完全不一样。

ab -n 10000 -c 100 "http://localhost:8080/abc123"

关注两个指标:Requests per second 和 Time per request。如果 QPS 上不去,先看 Redis 命中率,再看后端有没有同步写 click_count。我做的项目里,最典型的一次翻车就是上线前把 301 改成 302,却忘了把点击统计改成异步写入,结果跳转接口被 click_log 拖到超时。

后面我养成的习惯是:任何状态码调整、缓存策略调整,先压测再放量,把写流量解耦之后再谈性能。这套基于 Java 的 SaaS 短链接管理系统设计源码,最大的门槛不在算法,而在多租户边界和跳转状态码这些细节上。希望帮到你。

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

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

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

立即咨询