☰
Java SaaS短链接系统源码拆解:从短码生成到Redis Stream统计
2026/9/28 8:25:22 网站建设 项目流程

简介:一套基于Java的SaaS短链接管理系统设计源码,面向需要快速构建短链接服务的企业开发者与Java学习者。系统将长网址转换为短链接,提供PV、UV等关键访问数据统计,并具备安全监控与隐私保障能力,适用于营销推广、链接管理和数据分析等场景。压缩包共219个文件,以188个Java源文件为主,承担业务逻辑与核心算法;XML与YAML文件负责系统配置管理,SQL文件用于数据库交互,另有Lua脚本和HTML页面分别支撑功能扩展与前端展示,整体大小563KB。源码模块划分清晰,涵盖短链接生成、访问统计监听与消费、回收站管理、用户与分组管理等完整业务链路,便于深入研读SaaS平台的分层设计与扩展方式。目前已有389人学习参考,适合希望掌握短链接分析机制、模块化架构及企业级开发实践的读者。

1. Java SaaS 短链接管理系统:一套源码里藏着完整业务闭环

做营销投放的人应该都有这种体会:长链接带一堆跟踪参数,贴进朋友圈、短信、抖音评论区,又长又丑还容易被截断。短链接系统看着就是把长 URL 变短,但真正落到代码层面,它背后是一套完整的 Java 技术栈——多租户隔离、网关过滤、Redis Stream 异步消息、PV/UV/UUI 统计、回收站机制。这套源码一共 219 个文件,188 个 Java 源文件把核心业务全部覆盖,我把模块一个个拆开看过之后,确认它不是一个 Demo,而是一个能跑通完整业务闭环的 SaaS 短链接平台。

如果你是 Java 后端开发者,或者正在准备 SaaS 方向的项目经验,这套源码的价值在于:短链接表面上是算法题里的 62 进制转换,实际工程里要解决的问题是短码冲突、统计消息不丢、租户数据串号、回收站恢复冲突这些脏活累活。本篇我不讲虚的,直接按源码里的核心类一条条拆,把生成链路、统计链路、SaaS 隔离和部署验证全部落到可操作的代码和参数上。

2. 模块与数据模型:gateway、aggregation、project 怎么分工

2.1 从文件结构反推系统的模块边界

拿到源码第一件事不是看代码,而是先看目录。这套项目的包结构按照功能拆成了 admin、gateway、aggregation、project 四个大方向。初次打开可能有点懵,这四个词不像 MVC 那样直观,但它们的职责边界其实很清楚:

  • gateway:网关卡口,处理两件事——外部请求的登录态校验,以及短链接跳转的长转短入口。它负责把非法请求挡在业务逻辑之外。
  • project:核心业务主体,承载短链接实体、分组实体、用户实体和对应的 Service 实现。ShortLinkServiceImpl、GroupServiceImpl、UserServiceImpl 都在这个模块里。
  • aggregation:聚合层,解决跨模块数据查询的问题。比如后台首页要展示 PV、UV、UUI 趋势,如果直接去查 project 的业务表,不仅耦合重,而且统计 SQL 会把核心表拖慢。aggregation 专门干这件事。
  • admin:后台管理能力,通常与网关配合,给运营人员提供管理端接口。

这个拆分方式在 SaaS 项目里很常见,叫"纵向按业务域切分、横向按访问层级切分"。我一般会建议团队这样理解:project 是底盘,gateway 是门卫,aggregation 是分析室,admin 是控制台。只有理解了这层设计,后面看代码才不会迷路。

2.2 数据模型:短链接、统计、回收站三张核心表

接着看数据库脚本,两个 SQL 文件里定义了完整的数据模型。我梳理了核心表关系,重点关注短链接主表、点击统计表、回收站表,它们构成了整个系统的数据骨架。

表名核心字段作用
短链接主表短码、长链接、分组ID、用户ID、过期时间、删除标记存储短码与原始链接映射
点击统计表短码、PV数、UV数、UUI数、统计日期按天或按小时维度的访问聚合
回收站表短码、删除时间、过期清理时间实现软删除与恢复

这里有个容易被忽略的设计点:短链接表里同时有 create_time 和 del_flag,但回收站不是简单地 UPDATE 删除标记,而是把记录单独挪到回收站表。为什么这么做?因为 del_flag 只能解决"查询时不可见",解决不了"30 天后自动清理"的定时任务。单独建表后,清理任务只需要扫回收站表,不会误伤正常数据。

再说统计表,它存的是聚合结果而不是原始日志。PV、UV、UUI 这三个指标里,PV 好算,一次点击加一;UV 需要用 Redis 去重;UUI(Unique User Identifier)需要基于用户标识做基数统计。这套源码的做法是:统计消息进来先写 Redis Stream,消费者异步消费后按小时聚合,再落地到统计表。后续章节我细讲这条链路。

3. 核心链路一:短链接生成与 Redis Stream 消息队列

3.1 ShortLinkServiceImpl:短码生成、冲突检测、落库

短链接系统的核心操作就一个:把长链接变成短码。先看 ShortLinkServiceImpl 里最关键的生成方法,我摘出主流程,去掉与业务无关的边角代码:

public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) { // 1. 基于自增ID做62进制压缩,得到一串短码 String shortLinkSuffix = Base62Utils.encodeToBase62Sequence( distributor.generateId() ); String fullShortUrl = requestParam.getDomain() + "/" + shortLinkSuffix; // 2. 防止短码撞车:先查一次,如果已存在则重新生成 ShortLinkDO shortLinkDO = baseMapper.selectOne( Wrappers.lambdaQuery(ShortLinkDO.class) .eq(ShortLinkDO::getShortUrl, fullShortUrl) ); if (shortLinkDO != null) { // 常见做法是换一个ID再生成,或者加随机盐 shortLinkSuffix = Base62Utils.encodeToBase62Sequence( distributor.generateId() + ThreadLocalRandom.current().nextInt(100) ); fullShortUrl = requestParam.getDomain() + "/" + shortLinkSuffix; } // 3. 组装实体落库 ShortLinkDO saveDO = ShortLinkDO.builder() .domain(requestParam.getDomain()) .originUrl(requestParam.getOriginUrl()) .fullShortUrl(fullShortUrl) .gid(requestParam.getGid()) .createTime(new Date()) .delFlag(0) .build(); baseMapper.insert(saveDO); return ShortLinkCreateRespDTO.builder() .fullShortUrl(fullShortUrl) .build(); }

逻辑说明:第一步用分布式 ID 生成器拿一个全局唯一 ID,再转成 62 进制字符串,这就是短码。第二步为什么要再查一次?因为理论上 ID 不会重复,但转换后的字符串在极端情况下可能撞库,所以源码里做了二次校验,这也是我建议你保留的兜底逻辑。第三步落库时注意 delFlag 默认 0,后续回收站逻辑会依赖这个字段。

参数说明里有个细节:短码长度取决于 ID 位数。62 进制下,1 位能表示 62 个值,2 位能表示 3844 个值,一般业务量级下 6 位就够。如果你接手后要改短码长度,改的不是这里,而是 ID 的位数或进制表长度,别在生成方法里写死。

3.2 RedisStreamConfiguration:为什么用 Redis Stream 而不是 RabbitMQ

这套源码的统计链路没有引入额外 MQ 组件,而是用 Redis Stream。先看配置类:

@Configuration public class RedisStreamConfiguration { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(factory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); return redisTemplate; } @Bean public StreamMessageListenerContainer<String, Object> streamMessageListenerContainer( RedisConnectionFactory factory, StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, Object> options) { StreamMessageListenerContainer<String, Object> container = StreamMessageListenerContainer.create(factory, options); container.start(); return container; } }

逻辑说明:这里注册了一个 Stream 消息监听容器,消费者小组基于它接收短链接点击统计消息。Stream 相比 Redis List 的优势是支持消费者组模式,多个消费者实例可以分摊消息,同时保证一条消息只被一个消费者处理一次。在短链接这种高点击场景下,这比 List 的 BRPOPLPUSH 可靠得多。

参数说明:options 里可以配置拉取批次大小,我一般设batchSize(10),pollTimeout 设 2000ms。批次太大会导致单次处理时间过长,消费者组 Rebalance 时容易重复消费;批次太小则消费吞吐上不去。这个参数要按你的单条消息处理耗时来调——处理 1ms 以内的消息,批次 10~30 都行;处理耗时超过 10ms,建议压到 5 左右。

选择 Redis Stream 而不是 RabbitMQ,核心原因是这套系统里 Redis 本来就在链路中——短链接跳转要查缓存,UV 去重要用 Redis Set,统计预算也用 Redis。再加一个 MQ 组件等于多维护一套集群,不划算。如果你的团队已经有成熟的 MQ 基础设施,把 Stream 换成 MQ 也可以,但消息体的数据结构不用动,这是 Stream 方案留给你的迁移余地。

4. 统计链路与租户隔离:PV/UV/UUI 和 Group/RecycleBin

4.1 ShortLinkStatsMsgConsumer:统计消息的消费与聚合

点击短链接后,请求会触发一个统计消息写入 Redis Stream,ShortLinkStatsMsgConsumer 负责消费。看核心流程:

@Component @RequiredArgsConstructor public class ShortLinkStatsMsgConsumer implements StreamListener<String, Object> { private final ShortLinkStatsService shortLinkStatsService; @Override public void onMessage(Record<String, Object> message) { RecordId recordId = message.getId(); Object value = message.getValue(); if (value instanceof Map) { Map<String, Object> statsRecord = (Map<String, Object>) value; // 1. 从消息体里拆出短码、IP、UA、用户标识 String fullShortUrl = statsRecord.get("fullShortUrl").toString(); String ip = statsRecord.get("ip").toString(); String userAgent = statsRecord.get("ua").toString(); String uvId = statsRecord.get("uvId").toString(); // 2. 按天维度做PV/UV/UUI统计 Integer pv = 1; String uvKey = "short_link:uv:" + fullShortUrl + ":" + LocalDate.now(); Boolean isNewUv = redisTemplate.opsForSet().add(uvKey, uvId) > 0; Integer uv = isNewUv ? 1 : 0; // 3. 更新或插入统计表 shortLinkStatsService.recordStats(fullShortUrl, pv, uv); } // 4. 手动ACK,防止消息丢失 streamOperations.acknowledge("short-link-stats", "stats-consumer-group", recordId); } }

逻辑说明:这段代码就是 PV/UV 统计的最小实现。PV 是事件计数,每条消息加 1;UV 依赖 Redis Set 的去重能力,第一次出现的 uvId 返回 1,后续重复访问返回 0,累加到当天统计里。UUI 的统计逻辑与 UV 类似,只是标识不同——UV 偏 IP + UA,UUI 更偏业务侧用户标识。

参数说明:uvId 这个字段的取值决定 UV 准确率。如果 ua 和 ip 拼接作为 uvId,同一个手机用 WiFi 和 4G 切换 IP 时会被误判为两个访客。我一般建议优先用设备指纹,拿不到就用ip + 前几段 UA + 屏幕分辨率做拼接,能显著降低误判率。isNewUv 判断用的 opsForSet().add() 返回值:添加成功返回大于 0,说明这个 uvId 第一次来。

4.2 GroupServiceImpl 与 RecycleBinServiceImpl:SaaS 租户隔离与回收站机制

SaaS 系统的核心不是功能多,而是租户之间不串数据。看 GroupServiceImpl 里查询分组的方法:

public List<GroupRespDTO> listGroup() { // 从登录上下文获取当前用户名,作为租户隔离维度 String username = UserContext.getUsername(); List<GroupDO> groupDOList = groupMapper.selectList( Wrappers.lambdaQuery(GroupDO.class) .eq(GroupDO::getUsername, username) .eq(GroupDO::getDelFlag, 0) .orderByDesc(GroupDO::getSortOrder) ); return BeanUtil.convertToList(groupDOList, GroupRespDTO.class); }

逻辑说明:SaaS 租户隔离最常见的实现方式是"逻辑隔离",即每条业务数据带租户标识,查询时强制带条件。这里用的是 username 作为租户维度,所有分组查询都带上用户名条件,防止 A 用户看到 B 用户的分组。如果你在改造这套源码,可以考虑把 username 替换成 tenantId,结构不变,但语义更通用。

再看 RecycleBinServiceImpl 的回收与恢复逻辑:

public void moveToRecycleBin(String gid, String fullShortUrl) { // 1. 查出短链接记录 ShortLinkDO shortLinkDO = shortLinkMapper.selectOne( Wrappers.lambdaQuery(ShortLinkDO.class) .eq(ShortLinkDO::getGid, gid) .eq(ShortLinkDO::getFullShortUrl, fullShortUrl) .eq(ShortLinkDO::getDelFlag, 0) ); if (shortLinkDO == null) { throw new ServiceException("短链接记录不存在"); } // 2. 写入回收站表 RecycleBinDO recycleBinDO = RecycleBinDO.builder() .gid(shortLinkDO.getGid()) .fullShortUrl(shortLinkDO.getFullShortUrl()) .delTime(new Date()) .build(); recycleBinMapper.insert(recycleBinDO); // 3. 原表标记删除 shortLinkMapper.update(null, Wrappers.lambdaUpdate(ShortLinkDO.class) .eq(ShortLinkDO::getGid, gid) .eq(ShortLinkDO::getFullShortUrl, fullShortUrl) .set(ShortLinkDO::getDelFlag, 1) ); }

逻辑说明:回收站不是 DELETE,而是"挪窝"——先往回收站表插入一条带删除时间的记录,再把原记录的 delFlag 置为 1。这样原表查询自动过滤掉删除数据,同时回收站保留了恢复所需的完整信息。

参数说明:delTime 字段用来做 30 天自动清理,定时任务只需要扫回收站表delTime < now - 30d的记录,物理删除即可,不影响正常数据。恢复时有个关键检查:要确认短码没有被别人重新使用。短码资源在被删除后可能被新链接复用,如果恢复时不做冲突检测,会出现两个长链接共用同一个短码的脏数据。这是我在实际项目里踩过的坑,后面避坑章节细说。

5. 常见问题与避坑:五个最容易翻车的点

5.1 短码冲突导致旧链接失效

现象:生成的短链接刚上线时正常,但某个短码访问量突然归零,排查发现是另一个长链接占了同一个短码。

原因:62 进制转换的 ID 空间相对有限,在高并发生成场景下,如果 ID 生成器回退或时钟回拨,可能出现短码重复。源码里虽然有二次查重,但查重后与新记录之间没有唯一索引兜底,并发窗口下依然可能插入重复短码。

解决:在短链接表的 full_short_url 字段上加唯一索引,这是最靠得住的兜底。同时把短码生成逻辑里的查重改为主键冲突捕获重试,也就是先 insert,捕获 DuplicateKeyException 后重新生成短码,不要先查后插——先查后插在高并发下形同虚设。

5.2 Redis Stream 消费者组消息重复消费

现象:统计数据显示 PV 明显高于实际访问量,某些短链接一天内的 PV 几乎是 UV 的 20 倍。

原因:消费者处理完消息、写库成功之后,还没来得及 ACK,进程就宕机了。消息会重新进入 Pending 列表,恢复后同一批消息会被再次投递,导致 PV 重复计数。

解决:把统计操作设计成幂等。我的做法是在统计消息体里带上一个全局唯一递增的 eventId,消费时先查这个 eventId 是否处理过,处理过则直接 ACK。如果你不想加这个查询开销,至少要在 ACK 前完成全部业务操作,不要先 ACK 再异步写库。

5.3 UV 统计被 Set 过期时间坑掉

现象:昨天 UV 还是 1.8 万,今天回看变成 1.2 万,数据"缩水"了。

原因:Redis Set 里存 UV 去重的 key 设置了过期时间,但过期时间是从键第一次写入开始算的。如果某个短链接的 Set 键在凌晨过期,而当天又有大量新访问,夜里 23 点过后的访问全都算成新 UV,数据就飘了。

解决:给 UV Set 的 key 设置过期时间时,用"当天剩余时间 + 1 天"作为 ttl,比如现在是下午 3 点,过期时间设为 9 小时,确保 key 在当天 24 点之后才过期。另外,统计任务要做好对账:每天凌晨比对前一天的 UV 趋势,出现跳水要能告警。

5.4 回收站恢复时短码已被占用

现象:用户把短链接移入回收站,第二天恢复时提示失败,但原链接确实没有被物理删除。

原因:短码从回收站清掉后,又被新链接使用。恢复逻辑只检查了回收站表里有没有这条记录,没检查短链接主表里有没有同名短码。

解决:恢复前先查主表,如果 full_short_url 已存在且 del_flag = 0,说明短码被占,此时要么生成新短码,要么提示用户换一个恢复时间。我改这个逻辑时会复用 createShortLink 里的冲突检测方法,而不是重新写一遍。

5.5 多租户查询漏带租户条件导致串数据

现象:线上反馈用户 A 看到了用户 B 的短链接访问记录,但用户体系是独立的,账号密码都查不到问题。

原因:部分统计查询接口没有经过 service 层统一封装,直接在 mapper 层用selectByShortUrl查询,漏掉了租户隔离条件。Java 这种动态拼接 SQL 的项目里,多写一个条件容易漏,少写一个条件就是安全事故。

解决:把所有租户相关查询收敛到统一的 Service 方法,严禁 Controller 直接注入 Mapper。同时在 SQL 层面加拦截器,自动为所有带 tenant_id 或 username 字段的表追加隔离条件。上线前用两个测试租户做交叉验证,A 租户登录后确认访问不了 B 租户的任何数据,这是 SaaS 系统的底线测试。

6. 压测与验证:回放脚本确认统计链路没白写

改动完上述逻辑,最怕的是功能看着正常,但压力一上来就翻车。我的习惯是写一个回放脚本,模拟并发点击短链接,然后验证 PV/UV 是否与预期一致。下面是我常用的一段压测模拟脚本,本质上是往 Redis Stream 里灌消息:

import redis import json import uuid import time import random r = redis.Redis(host='127.0.0.1', port=6379, db=0) SHORT_URL = "https://demo.com/abc123" STREAM_KEY = "short-link-stats" GROUP_NAME = "stats-consumer-group" # 模拟200个独立的UV标识 uv_ids = [uuid.uuid4().hex for _ in range(200)] # 每个UV标识访问1到3次,模拟PV>UV的效果 for uv_id in uv_ids: times = random.randint(1, 3) for _ in range(times): msg = { "fullShortUrl": SHORT_URL, "ip": f"10.0.{random.randint(0, 255)}.{random.randint(1, 254)}", "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "uvId": uv_id, "eventId": uuid.uuid4().hex } r.xadd(STREAM_KEY, msg, maxlen=100000) time.sleep(0.001)

逻辑说明:脚本的核心是制造一个已知的"正确答案"——200 个 UV,PV 总量在 200 到 600 之间。跑完脚本后,去统计表里查这个短链接当天的记录:如果 UV 等于 200,说明 Redis Set 去重逻辑正确;如果 PV 等于脚本发送的总消息数,说明消费者一条没丢。用已知数据校验消费链路,比凭感觉看数字靠谱得多。

参数说明:maxlen=100000 设置了 Stream 的最大长度,防止压测数据长期占用内存。实际生产环境这个值建议调到 50 万到 100 万之间,短链接量大但消息体很小,占不了多少内存。send 端的 sleep(0.001) 是限速用,否则单机瞬间塞几十万条消息,消费者的消费速度跟不上,pending 列表会暴涨。

压测时重点盯三个指标:Stream 的 pending 数量、消费者组的 lag、统计表写入速率。pending 持续上涨说明消费者消费不过来,优先看消费逻辑里有没有耗时操作;lag 归零说明消费追平了,链路健康。我那套系统上线前就是这么压的,压完后透传到测试环境跑了一周,确认没有消息积压后才敢上生产。

从那以后,每次改统计链路我都强制走一遍回放脚本,确认当前版本和上一版本的 PV/UV 数字对得上再合代码——否则统计数字是错的全靠运气发现,这比功能 bug 可怕多了。这套源码值得下载的场景就是:想搞懂短链接统计链路怎么用 Redis Stream 落地,或者想参考 SaaS 多租户隔离的分寸感。希望帮到你。

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

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

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

立即咨询