ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java实现SaaS短链接管理系统:从发号器到多租户隔离的完整源码解析

Java实现SaaS短链接管理系统:从发号器到多租户隔离的完整源码解析 简介一套面向Java中高级开发者的SaaS短链接管理系统源码聚焦长链接缩短、安全监控与营销数据分析帮助企业或个人用户提升链接传播效率与业务转化。压缩包共219个文件以188个Java源文件为主辅以14个XML、11个YAML完成配置管理2个SQL初始化数据库还有Lua脚本、HTML页面等整体仅563KB结构紧凑。系统按admin、gateway、aggregation、project等模块拆解覆盖后台管理、网关控制、数据聚合与核心业务源码中包含RedisStreamConfiguration及短链接统计消费者等实现可学习基于Java的流式消息处理与PV、UV、UUId指标统计。已有389人浏览学习适合想掌握短链接系统设计、SaaS后台架构或数据追踪方案的技术人员参考复用。1. 短链接离我们不远为什么 Java 团队要自己写一套 SaaS 短链接管理系统做增长、做投放、做短信营销的团队早晚会碰到同一个诉求长链接又长又丑放进短信按字计费放进海报连二维码都撑满——短链接不是工具是基础设施。而这个标题里的关键词是「基于 Java」「SaaS」「源码」翻译成业务语言就是不是做个单机版缩链工具而是要做一套能卖、能租、能隔离租户数据、能统计点击的短链接管理系统。我在给几家客户公司做内部基建时发现大多数团队的第一步不是选型而是先问「能不能直接用第三方短链服务」。答案是能用但你把点击数据、用户行为、渠道归因全交出去了SaaS 客户可不愿意。所以这套源码的价值在于它把「发号器、跳转链路、访问统计、租户隔离」这四件事用 Java 技术栈串了起来既能让新手通过源码理解短链接的完整闭环也能让熟手直接拿去改造成生产级服务。适合的人群很明确准备自建短链接中台的 Java 工程师、做 To B 业务需要给客户提供短链能力的团队以及想拿一个既有业务深度又有设计亮点的源码来拆解学习的人。接下来我按自己的实现思路把这套系统的设计逻辑、核心代码、多租户细节和常见坑拆开讲。2. 短链接系统的核心链路从发号器到 302 跳转SaaS 版多了哪三层短链接系统的原理一句话能讲完把一串长 URL 映射到一个短码访问短域名加短码时服务端查映射关系并 302 跳回长 URL。但把它做成 SaaS 服务复杂度会往上叠三层第一层是短码不能重复且不能按顺序被猜到否则别人可以遍历你的短链第二层是跳转要做缓存和容灾否则热点短链一上来数据库就扛不住第三层是每个租户的数据要隔离A 租户的短链不能出现在 B 租户的统计报表里。这三层分别对应发号器、跳转链路和租户模型下面一个一个拆。2.1 发号器选型雪花 ID 还是自增 ID多租户下不能随便选短码本质是一个唯一 ID 的 Base62 编码所以发号器的选择直接决定短码的生成效率和外泄风险。常见方案有两种一是数据库自增 ID 加步长比如部署三台实例每台分别以 1、2、3 为起始值、步长 3 递增二是用雪花算法生成 64 位趋势递增 ID再截取后段做 Base62 编码。自增 ID 的优点是完全有序、短码最短缺点是 ID 可被顺序遍历竞对可以每天调用你的接口枚举你的短链数量这在 SaaS 场景里属于不可接受的隐私漏洞。雪花 ID 在单机短链场景里表现尚可但放到 SaaS 多租户下有一个隐藏问题短码会变长。雪花 ID 是 64 位长整型转成 Base62 后大约 11 位字符而使用数据库自增在低并发下能做到 6 位以内。短码长度直接影响短信计费和二维码容错率所以我在做这套设计时更倾向混合方案使用 Redis 的 INCR 命令为每个租户维护独立的短码段然后用租户 ID 参与混淆不让短码呈现明显的连续递增规律。下面这段代码是核心发号逻辑的骨架public String generateShortCode(Long tenantId, String longUrl) { // 每个租户单独一个计数器key 里带上租户 ID避免全局共用一个序列号 String counterKey shortlink:counter: tenantId; long sequence redisTemplate.opsForValue().increment(counterKey); // 把租户 ID 与序列号做一次可逆混淆防止短码被顺序遍历 long mixed mix(tenantId, sequence); return base62Encode(mixed); }这里有两个参数需要留意一是 Redis 计数器的增量步长多实例部署时默认步长要大于等于实例数否则会撞码二是 mixed 的位数租户 ID 占的位越多短码可用位越少需要预留足够的位宽给序列号。生产环境我会把 Redis 计数器持久化开启配合 AOF 每秒刷盘避免 Redis 重启后计数器回退导致短码重复。实际项目中我见过因为 Redis 没开持久化重启后短码重复生成、部分历史链接被覆盖的案例属于典型的「开发环境没事、上线即翻车」类问题。2.2 跳转逻辑与缓存穿透一次点击背后的 Redis 和 MySQL 配合短链接跳转接口是整个系统压力最大的入口因为用户每次点击都会请求这里。直接查 MySQL 是最简单也最容易被打爆的做法所以我一般会加两层缓存本地 caffeine 缓存和 Redis 缓存。查询顺序是本地缓存 → Redis → MySQL查到 MySQL 后回填 Redis 和本地缓存。跳转接口的关键点在于缓存过期瞬间如果碰到热点短链大量请求会同时穿透到数据库这就是典型的缓存击穿问题。解决思路是加互斥锁让同一短码在缓存失效时只有一个请求去查数据库。public String resolveUrl(String shortCode) { String cacheKey shortlink:url: shortCode; String url redisTemplate.opsForValue().get(cacheKey); if (url ! null) { return url; } // 加分布式锁防止缓存击穿打爆数据库 String lockKey shortlink:lock: shortCode; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofMillis(300)); if (locked) { try { url shortLinkMapper.selectUrlByShortCode(shortCode); if (url ! null) { redisTemplate.opsForValue().set(cacheKey, url, Duration.ofHours(24)); } } finally { redisTemplate.delete(lockKey); } } else { // 其他线程让出 CPU短暂自旋后重试读取缓存 Thread.sleep(50); return resolveUrl(shortCode); } return url; }这段代码有几个值得注意的参数锁的过期时间设 300 毫秒是为了防止持有锁的线程异常退出时锁不被释放如果一次数据库查询超过 300 毫秒锁就会提前失效所以这里要和 MySQL 慢查询阈值对齐排查缓存 TTL 我设 24 小时是针对通用链接的默认值VIP 租户的短链可以单独配置更长的缓存时间。另外重试逻辑用了简单递归生产环境建议改成固定次数的循环否则锁竞争激烈时递归过深会浪费栈空间。跳转状态码我推荐用 302 而不是 301301 会被浏览器缓存重定向结果后续想改目标地址时用户的浏览器不一定会发新请求统计也会失真。2.3 SaaS 维度租户隔离、子域名与套餐限流的落地设计SaaS 短链接系统的第一个设计决策是所有租户共用一套短码池还是每个租户独立短码池。共用池的好处是短码利用率高、短码更短坏处是统计和风控维度需要额外挂租户标识独立池的好处是隔离彻底、排查方便坏处是短码位宽被切碎短码整体变长。做 To B 产品时我会选择共用短码池用一个 tenant_id 字段做隔离因为大部分租户的短链量级在几十万到几百万条共用池的编码长度优势非常明显。子域名是 SaaS 短链最容易忽略的设计点。客户用自己的品牌域名做短链是很常见的需求所以系统要支持配置每个租户的独立域名比如 a.com 和 b.com 的短链解析到同一套服务但根据 Host 头区分租户。这个功能在做网关或拦截器时就要预留。套餐限流则是对应 API 的流量控制每个租户的 QPS、每日生成条数、短链过期策略都要可配置技术实现可以用令牌桶也可以在网关层做简单的计数器限流。我在设计数据库时会把套餐配置单独建表而不是把限流参数硬编码在每个租户表里否则后续调套餐要改全表数据。3. Java 实现与源码目录设计Spring Boot MyBatis 的分层结构怎么搭如果你拿到一套短链接系统源码第一件事应该是看包目录。好的目录结构能直接告诉你系统的扩展方向而不是让你在几百个类里找入口。这套系统我推荐的包结构遵循 Spring Boot MyBatis 的标准分层但会在业务模块上做垂直切分把短链生成、跳转、统计、租户管理拆成独立的包这样后续做模块隔离或服务拆分时不会伤筋动骨。3.1 后端分层controller、service、mapper 与事件驱动的统计链路controller 层只做参数接收和结果返回不写业务逻辑。短链系统的 controller 数量很少核心就是生成接口、解析接口、统计查询接口和租户管理接口。service 层承担业务编排比如生成短链时先校验租户配额、再检查长 URL 合法性、最后调用发号器生成短码。mapper 层对应 MyBatis 的数据库操作这里要特别注意统计相关查询不要在主库执行否则报表类的慢查询会拖垮核心跳转链路。统计链路我会用 Spring 的事件机制解耦。跳转接口只负责更新 Redis 计数器把点击明细丢进事件里监听到事件后异步写访问日志表。这样做的好处是跳转接口的响应时间不依赖数据库写入压测时能稳定保持在 10 毫秒以内。异步写日志最怕丢数据所以我会把事件封装成包含租户 ID、短码、IP、UA、时间戳的消息体并用有界队列承接队列满时降级为直接丢弃并告警——在短链统计场景里丢万分之一的点击量是可以接受的但主链路延迟飙升是不可接受的。3.2 核心表结构设计短链接表、访问日志表与租户表短链接表是系统的核心字段设计要兼顾查询效率和业务扩展。我见过太多人把短码、长 URL、租户 ID、创建时间这几个字段建完就收工结果后续加状态、加过期时间、加平台分类的时候只能重建表。合理的表结构应该预留状态字段和扩展字段并用联合索引覆盖高频查询。访问日志表是另一个重点它和短链表是典型的一对多关系但日志表不能和业务表放在同一个库的同一张表里按天分表是短链统计的常规做法。CREATE TABLE t_short_link ( id bigint NOT NULL AUTO_INCREMENT, short_code varchar(16) NOT NULL COMMENT 短码, long_url varchar(2048) NOT NULL COMMENT 原始长链接, tenant_id bigint NOT NULL COMMENT 租户ID, domain varchar(128) DEFAULT NULL COMMENT 独立域名, status tinyint NOT NULL DEFAULT 1 COMMENT 状态: 1启用 0禁用, expire_time datetime DEFAULT NULL COMMENT 过期时间, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_tenant_short_code (tenant_id, short_code), KEY idx_short_code_domain (short_code, domain) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短链接表;这个表结构里最关键的三个设计一是联合唯一索引uk_tenant_short_code保证同一个租户下短码不重复同时给租户级查询提供索引二是domain字段让子域名绑定成为可能三是expire_time做定时任务扫描过期短链时这个字段必须有索引。long_url用 varchar(2048) 而不是 text是为了避免 MyBatis 对 text 类型的额外映射开销同时 2048 足够覆盖绝大多数合法 URL。访问日志表我会按visit_date做分表主键用雪花 ID并单独存短码和租户 ID 两个查询维度。3.3 关键代码跳转接口、缓存策略与异步落库跳转接口是整套系统里最不应该出问题的代码它短小精悍但对并发和性能的要求最高。跳转前要检查短链状态、是否过期命中后用 302 返回长 URL同时把点击事件发布出去。异步落库的代码我放在事件监听器里用线程池执行写入线程池的核心线程数、最大线程数和队列容量需要根据预估 QPS 调整。Component public class VisitLogListener { // 线程池参数核心8最大16队列5000丢弃策略为 CallerRunsPolicy private final ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(5000), new ThreadPoolExecutor.CallerRunsPolicy() ); Async(visitLogExecutor) EventListener public void onVisitEvent(VisitEvent event) { try { visitLogMapper.insert(event.toVisitLog()); } catch (Exception e) { log.error(visit log insert failed, event: {}, event, e); } } }这段代码有三个参数值得展开核心线程数 8 意味着系统空闲时也常驻 8 个线程处理日志写入最大线程数 16 是处理突发流量的上限队列容量 5000 表示积压超过 5000 条任务时触发拒绝策略。我选了 CallerRunsPolicy 而不是 AbortPolicy因为前者在队列满时让调用方线程自己执行写入任务天然实现了背压不会因为拒绝而丢日志但代价是跳转线程会被拖慢。如果你的系统对跳转延迟极其敏感可以换成 DiscardPolicy 并搭配监控告警优先保主链路。线程池的名称visitLogExecutor需要在配置类里显式声明否则 Spring 的Async注解会使用默认的 SimpleAsyncTaskExecutor那个执行器每次都会新建线程高并发下会直接把内存打满。4. 多租户隔离与关键参数从数据库到 Redis 的配置清单多租户隔离是这套系统的灵魂也是「SaaS」三个字母和普通短链工具最本质的区别。隔离做不好租户间的数据互相泄露技术债会严重到推倒重来。我在设计时会把隔离分成三层来做数据层隔离保证查询不出界缓存层隔离保证 Redis 的 key 不冲突业务层隔离保证套餐能力不越权。三层各管一摊缺一不可。4.1 租户字段还是独立库SaaS 短链系统的两种隔离方式独立数据库库隔离的方案安全性最高每个租户一套库表但运维成本跟着租户数量线性增长而且你要为每个租户单独执行建表脚本、单独配置备份策略。共享表加租户 ID 字段的方案维护成本低但所有租户的数据混在物理表里查询时漏加租户条件就是事故。短链接系统的表结构相对统一、查询模式固定我倾向用共享表加租户 ID 的方案配合 MyBatis 拦截器做数据权限控制而不是靠每个开发手写where tenant_id ?手写漏一处就是一处数据泄露的隐患。实现层面我会做一个自定义的 MyBatis 拦截器拦截所有查询语句自动在 SQL 尾部追加and tenant_id #{当前租户ID}。租户 ID 从登录态或 API 密钥解析出来存放在 ThreadLocal 中。这个方案的边界在于统计类的聚合查询和定时任务定时任务扫描过期短链时是全局视角不能被租户拦截器误伤所以拦截器要支持白名单机制。这个细节在实际项目中很容易踩坑后面避坑章节会展开。4.2 缓存 TTL、连接池与线程池一组可以被直接抄走的参数多租户场景下的缓存设计跟单机版有一个显著区别Redis 的 key 必须带上租户 ID 或域名否则两个租户如果碰巧生成了相同的短码缓存就会串数据。我在生成短码时已经保证了全局唯一但缓存 key 仍然建议使用shortlink:url:{tenantId}:{shortCode}的格式多一层隔离防御。缓存 TTL 的设置要考虑两个极端太短热点短链反复穿透到 DB太长短链被禁用后缓存里还能跳转。通用 TTL 我设 24 小时但短链被禁用时主动删除对应缓存。连接池参数直接决定系统的并发上限。Druid 或 HikariCP 我常用 HikariCPSpring Boot 默认就是它。核心配置是maximum-pool-size和minimum-idle对于短链这种读多写少的系统我会把 maximum-pool-size 设为 CPU 核数乘以 2 再加 1而不是无限调大。连接池过大的后果是数据库端连接数被打满反而拖垮数据库。Redis 连接池同理Lettuce 默认的共享连接在高并发下够用但如果你用 Jedismax-total不要超过 100否则 Redis 实例的连接数会成为新的瓶颈。4.3 安全与风控短链被滥用时 Java 侧怎么兜底短链接天然是网络钓鱼和恶意跳转的温床SaaS 服务如果不做风控很快会被黑产盯上。我在生产环境会做三道防线生成侧拦截、跳转侧检测、管理侧封禁。生成侧拦截是指创建短链时对长 URL 做域名白名单校验至少校验域名是否在禁止列表中跳转侧检测是指对访问频率异常的短链或者 IP 段做实时限流管理侧封禁则是提供运营后台的封禁接口一旦发现恶意短链可以立即把短码状态置为禁用。状态禁用后跳转接口的查询要把状态字段纳入缓存 key或者走缓存删除否则封禁不生效。跳转侧的高频访问检测我一般用 Redis 的计数器对同一个短码统计每秒请求数超过阈值就返回安全提示页而不是直接跳转。这个阈值要设置得足够高避免误伤正常的营销活动流量比如大促期间一个短链在一分钟内被点击几万次是正常的。所以在做套餐设计时限流阈值应该按租户套餐级别配置而不是全局共享同一套阈值。安全兜底这段逻辑不需要多复杂但有没有这段代码决定了这套系统敢不敢拿去面对真实公网流量。5. 避坑与排查短链接系统上线后最常见的 5 个翻车现场任何系统上线后都会暴露设计时看不到的问题短链接系统尤其如此。因为它的访问链路短任何一个环节的异常都会直接反馈到跳转成功率上。下面这五个问题是我自己在实际项目中踩过或帮别人排查过的每条都按「现象 → 原因 → 解决」的顺序写希望能给你省点排查时间。5.1 短链跳转偶发 404缓存与数据库不一致的治理现象是短链刚生成时必现可跳转但运行一段时间后偶发 404。最典型的案例是短链过期后仍留在 Redis 里用户点击时缓存命中返回了 URL但数据库里的记录已经被定时任务标记为过期反过来也出现过缓存里没有、数据库有但缓存回填失败了的情况。这个问题的根源是缓存和数据库两套存储的生命周期没有被统一管理。解决思路分三步短链的新增、禁用、过期都必须主动删除 Redis 缓存不能依赖 TTL 自然过期定时任务扫描过期短链时处理完数据库后要再删一次缓存跳转接口遇到数据库记录不存在但缓存命中时要把缓存删掉再返回 404避免脏缓存一直残留。我在代码里加了一个小技巧跳转接口返回 404 前把短码写入一个标记为「已失效」的 Redis 集合后续同样的请求直接拦截不回源数据库减少无效查询。5.2 点击量对不上账异步统计丢数据的排查思路现象是报表里某一天的点击量明显少于网关日志里的请求数或者对不上渠道统计的数据。原因基本出在异步落库环节线程池拒绝策略把任务丢弃了或者事件监听器抛异常时没有兜底又或者 Redis 计数器本身有丢失。公网用户访问短链时可能不会等完整链路走完比如手机端弱网环境下请求中断服务端线程已经完成了跳转但后续的异步写入任务还没执行就随着线程池的关闭被丢弃。排查方法是分两步验证先对比 Redis 计数器和 MySQL 日志表的总数如果 Redis 多而 MySQL 少说明是异步写入环节丢了再查看应用日志里有没有拒绝执行的堆栈CallerRunsPolicy 策略下一般不会抛拒绝异常但如果用的是 AbortPolicy会看到TaskRejectedException。解决时我在生产环境做了双写补偿每次跳转先在 Redis 用INCR更新总点击量异步任务只负责写明细日志报表查询时以 Redis 总数为基线明细缺失部分允许一定误差。这样对于绝大多数商业分析场景误差已经可以接受。5.3 上报接口被刷导致大量无效日志频率限制与签名校验现象是短链点击量突然暴涨数据库日志表每分钟新增几万条且大量点击的 IP 和 UA 高度相似。原因通常是短链被刷量工具盯上了或者竞对在恶意攻击你的跳转接口。这种事在短链接服务里太常见了黑产刷量、羊毛党刷收益都靠这个。短链系统不能像内部系统那样假设所有请求都来自自己的 App它天然是公网开放接口必须默认请求不可信。我一般会在跳转接口加两层防护第一层是单 IP 频率限制用 Redis 记录同一个 IP 在单位时间内的请求次数超过阈值直接返回 429第二层是短码维度的限流同一个短码的 QPS 超过套餐阈值时返回安全提示页。被刷出来的大量无效日志虽然不影响主链路但会占满磁盘和数据库写入带宽所以日志表要设置写入配额超过配额后丢弃日志并触发告警。签名校验对这个场景帮助不大因为浏览器点击跳转时没法带签名但如果你提供 API 供第三方调用生成短链API 密钥的频率限制是必须的。5.4 长链接里带特殊字符导致生成失败URL 编码问题现象是用户在后台粘贴了一个带中文参数或特殊符号的长链接生成短链时报参数错误或者生成后跳转过去变成乱码。原因基本是长 URL 没有做编码处理就存库了URL 里常见的、?、、%在传递过程中会被解析成不同含义甚至被某些框架的安全过滤器拦截。短链接系统的输入天然包含这些字符所以这类问题几乎是新手上线必踩。解决方法是分两步前端提交前先对长 URL 做encodeURIComponent编码后端收到后再decode一次存入数据库存的是标准 URI 编码后的明文跳转时直接返回数据库里的原始字符串让浏览器自行处理。要注意的是别在跳转时再编码一次否则浏览器会看到双重编码后的乱码。我在代码里写了校验存入前先用java.net.URI解析一次解析失败直接拒绝生成这样能在入口拦截掉大量非法格式的链接。5.5 多租户数据串号拦截器漏配置导致的的资损级 Bug现象是某租户反馈在统计报表里看到了不属于自己域名下的短链记录或者自己在后台创建的短链在另一个租户的列表里出现。这个问题的严重程度是资损级的——SaaS 系统一旦数据串号客户信任基本归零。原因几乎都是同一个查询 SQL 漏了tenant_id条件。特别是分页查询和统计查询写 SQL 时容易只关注业务条件而漏加租户维度。解决方式是不要把租户条件的控制权交给 SQL 编写者而是交给框架。我在系统里实现了 MyBatis 拦截器自动追加租户条件同时在 mapper XML 里禁用不带租户条件的全表查询。这里有一个关键配置拦截器要对「查询」和「更新」都生效更新语句漏了租户条件会直接导致跨租户数据覆盖。为了避免拦截器误伤运维类的全局操作我建了一张白名单表维护不需要拦截的 Mapper 方法名白名单的变更要走审批流程。上线前我还会写一个自动化测试用例模拟两个租户的 Token 分别查询数据断言结果集没有任何交叉这个测试用例放在 CI 里每次发版都会跑一遍。6. 从能跑到能上线短链系统的压测、监控与上线前检查清单短链接系统上线前最值得做的一次压测是跳转接口的压测因为这是所有流量的必经之路。用 JMeter 或 wrk 模拟并发请求重点观察两个指标P99 响应时间和数据库连接池的活跃连接数。我见过很多系统在压测时单接口 QPS 很好看但一接真实流量就出问题原因是没有把 Redis 缓存命中率纳入监控。上线后的第一周我会盯三个面板Redis 命中率、跳转 302 的状态码分布、异步日志写入的积压数。命中率低于 95% 就要检查是不是缓存 key 设计不合理302 占比骤降说明跳转逻辑可能有异常日志积压数持续上涨说明线程池配置跟不上流量。还有一个容易被忽略的验证步骤是短链的生命周期测试。新生成的短链要能跳转禁用后要立即失效过期后要返回友好提示页删除后再次访问不能出现回光返照式的缓存命中。这些操作在开发环境都能通过但生产环境的缓存层级更多——CDN、浏览器缓存、Redis、本地缓存——每一层都可能让失效变得不彻底。所以我会在预发环境用 curl 带不同的 Cache-Control 头反复验证确认服务端返回的响应头没有让浏览器长期缓存跳转结果。HTTP 响应头里加Cache-Control: no-store是禁止浏览器缓存 302 的一种有效手段对统计准确性和封禁及时性都有帮助。这套系统做下来我最深的体感是短链接看起来简单但「简单」只停留在原理层面业务设计才是真正的分水岭。单机版缩链工具一小时能写完SaaS 化之后要面对的是租户隔离、套餐限流、数据隔离、防刷安全这些持续性问题。如果你手里的项目已经走到需要自建短链这一步我建议先从这套 Java 源码的目录结构开始拆解把发号器、缓存策略、租户字段这三个设计点吃透再按自己的业务场景替换掉统计维度和风控策略。希望这些从实际项目里攒下来的参数和踩坑经验能帮你在自建短链接系统时少走几趟弯路。本文还有配套的精品资源点击获取
返回列表