ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java SaaS短链接系统设计:多租户隔离与Base62短码生成实战

Java SaaS短链接系统设计:多租户隔离与Base62短码生成实战 简介这是一套基于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 ResponseEntityVoid 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 ThreadLocalString 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 接口。数据库连接池用 HikariCPSpring 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明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 短链接管理系统设计源码最大的门槛不在算法而在多租户边界和跳转状态码这些细节上。希望帮到你。本文还有配套的精品资源点击获取
返回列表