搞短链接服务,day-04了。前三天把骨架搭好,域名解析、证书、数据库表和最基本的生成接口都跑通了,今天重点不是“能跳转”,而是“跳得稳、看得清”这个阶段。我今天的计划很明确:短码生成从随机碰运气改成更可控的方案,补上访问统计和分布式环境下的计数一致性,再把跳转性能拉一截。一天折腾下来,踩了几个坑,也验证了几个关键设计,这篇把当天做的事和值不值得做都摊开聊。
1. 第4天的整体设计思路:为什么不是“能用了就行”
短链接这个项目做到第四天,最危险的信号就是“反正已经能跳转了,剩下的以后再说”。如果只是自己用或者内部测试,确实怎么搞都行。但短链接一旦放开给真实用户,有两个问题会立刻冒出来:一个是短码能不能扛住并发生成,另一个是链接被人刷访问量时,数据能不能准确记下来。
我先把自己定义的“day-04验收标准”列在了笔记本上,一共五条:
- 短码生成不能依赖随机碰运气,必须在高并发下也可控、可预测
- 短码查询走缓存,缓存没命中再回源数据库,不能每次跳转都查一次表
- 访问统计要有独立的计数存储,不能因为统计拖垮主流程
- 重定向的状态码能区分临时跳转和永久跳转,满足不同业务场景
- 所有核心表要有索引和清理策略,不能等数据量大了再头疼
这五条看着不多,但每一条都能展开成一堆细节。短码生成涉及算法选型,缓存涉及一致性,统计涉及并发写,重定向涉及状态码语义,索引涉及慢查询排查。我给自己定的原则是:今天只做能落地的事,不做花架子优化。
1.1 第4天在整体项目里的定位
短链接服务如果从零开始做,通常会走这么几个阶段:
| 阶段 | 核心目标 | 对应内容 |
|---|---|---|
| day-01 | 跑通最小闭环 | 域名、HTTPS、创建短链、302跳转 |
| day-02 | 数据结构合理化 | 数据库表设计、参数校验、错误处理 |
| day-03 | 可用性补全 | 短码生成、防冲突、基本日志 |
| day-04 | 性能与数据可信度 | 缓存、统计、一致性、监控埋点 |
| day-05+ | 运营与扩展 | 管理后台、用户体系、批量生成、限流 |
第四天处于一个微妙的位置。它前面有能用的基础,后面还有一堆产品化的事情等着做。如果第四天只顾着加功能,性能问题和数据问题会在后面集中爆炸;如果第四天只做性能,那功能进展又太慢。所以我选择的组合是“缓存 + 计数 + 短码可控”,既照顾了性能,又没丢掉对数据的掌控。
2. 短码生成方案:从“随机够用”到“可控可查”
前三天的短码我图省事,直接用的随机字符串,碰撞了就重试。单机低并发下问题不大,可一旦生成频率上来,重试次数会变得不可控。而且随机短码没法反向推算生成时间,出了问题排查起来很痛苦。今天我把短码生成拆成三个可选方案,逐个对比后确定主方案。
2.1 三个候选方案对比
第一是哈希截取。把原始长链接做MD5或SHA-256,取其中一段转成短码。优点是同一长链接生成的短码固定,方便去重;缺点是哈希分布不均匀时,可能出现局部碰撞,而且哈希本身没有业务含义,出了问题没法快速定位。
第二是随机短码加唯一索引。生成随机字符串,直接插入数据库,靠数据库唯一索引把冲突挡在门外。实现最简单,但在写入量大的时候,冲突概率上升,每次冲突都是一次无效写。
第三是发号器模式。用一个全局自增ID或者雪花ID,再通过Base62编码变成短码。特点是短码体积小、不冲突、可单调递增。缺点是ID会有规律,别人能通过遍历短码来抓取你的链接。所以发号器模式一般要配合随机混淆。
我最后选的是“发号器 + 随机后缀混淆”的组合。具体做法是:用提前生成好的ID,把十进制转成62进制作为主体,再在末尾插入两个随机字符作为校验和干扰位。这样既保证了唯一性,又不会让人一眼看出规律。
2.2 为什么我不直接用雪花ID当短码
雪花ID本身是64位的长整数,转成字符串之后依然很长。拿雪花ID直接跳转,短链会变成“x.cn/123456789012345678”,这已经失去了短链接的意义。而且雪花ID依赖机器ID和时钟,如果时钟回拨,可能出现重复ID。把雪花ID当短码用,是把杀鸡用牛刀和一个错误用在了同一个地方。
更合理的方式是让发号器只负责生成一个“序号”,这个序号专门用来编码成短码。我用了一张独立的短码序列表,每次批量取一批号段放内存,用完再取。这样数据库写入频率低,内存里也有余量,短码生成速度非常快。
核心代码大概长这样:
public String generateShortCode(String originalUrl) { // 从号段池里取下一个ID long id = idSegmentHolder.nextId(); // 编码成62进制 String encoded = encodeBase62(id); // 加两个随机字符做混淆,随机字符从固定字符表里取 String salt = RandomStringUtils.random(2, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); // 把混淆字符插在中间,避免末尾连续数字太明显 return encoded.substring(0, 4) + salt + encoded.substring(4); }Base62编码的原理不复杂,就是用62个字符表示一个数字,数字越大,位数越长。62位字符的好处是,4位能表示约1470万个组合,7位能表示约3.5万亿个组合。短链码控制在6到8位,完全够用。
2.3 批量生成与冲突兜底
批量生成号段的时候要注意一个细节:号段一定要持久化,不能只存在内存里。我建了一张short_code_segment表,记录每个发号器的当前值和步长。应用启动时读取这一行,然后在内存里加上步长,再把新的值写回去。这样即使某一台机器挂掉重启,也不会把已经发出去的号段重新发给别人。
当然,纸上设计的再完美,落地还是有意外。今天测试时我就遇到过一次短码冲突,排查了半天,发现是有两条测试数据是前一天手动插进去的,短码格式恰好和当天生成的重了。后来我在短码列上加了唯一索引,同时在生成接口里捕获DuplicateKeyException,一旦冲突就用下一个ID重新生成。这就是典型的“兜底设计要比理想设计多一层”。
3. 核心实现:短链接的创建与跳转链路
短链接服务的核心链路其实就两个接口:一个是创建短链,一个是访问跳转。今天我把这两个接口完整串了一遍,把每个环节的细节都理清,顺便把之前缺失的参数校验和异常处理补齐了。
3.1 创建短链接口
创建短链时,用户传过来一个长链接,服务端要做的事情按顺序拆开是这样的:
- 校验长链接格式,拒绝明显的非法输入
- 判断是否已经存在相同的长链接,如果存在,直接返回已有短码
- 如果不存在,生成新短码,落库保存
- 把“短码到长链接”的映射写入缓存,方便后续跳转直接读缓存
- 返回完整短链
这里有人会问:要不要做“相同长链接直接返回旧短码”的去重?我的看法是要做,但只在当前用户维度做。如果全局去重,一个用户删除短链,另一个用户手里的链接也会失效,这个体验很糟糕。所以我给数据表加了一个user_id字段,去重时带上用户条件。
参数校验也是今天补齐的。长链接的协议头不能省,用户传“www.example.com”这种不带协议的,我统一在前面补http://。如果传javascript:这种白名单之外的协议,直接拒绝,防止有人拿短链做钓鱼跳转。
3.2 跳转接口与302/301的选择
访问短链时,服务端拿到短码,先查缓存,缓存没有就查数据库,查不到返回404,查到了就发一个重定向响应。这里有个细节值得单独说:重定向状态码到底用301还是302。
301是永久重定向,浏览器会缓存这个结果。同一短链接第二次访问时,浏览器不会请求服务端,直接跳到长链接。好处是服务端压力小,缺点是短链后续如果改了目标地址,用户端可能还是旧的缓存结果,改链不生效。
302是临时重定向,每次访问都会经过服务端。短链接最常见的场景是运营系统里随时可能修改目标地址,所以我默认用302。只有针对那些明确永久不变的链接,才会改成301。
具体实现:
@GetMapping("/{shortCode}") public ResponseEntity<Void> redirect(@PathVariable String shortCode, HttpServletRequest request) { String originalUrl = shortUrlCache.get(shortCode); if (originalUrl == null) { ShortUrlEntity entity = shortUrlMapper.selectByShortCode(shortCode); if (entity == null) { return ResponseEntity.notFound().build(); } originalUrl = entity.getOriginalUrl(); shortUrlCache.put(shortCode, originalUrl); } // 异步记录访问日志,不能阻塞主流程 visitLogProducer.send(new VisitLogEvent(shortCode, request.getRemoteAddr(), getUa(request))); return ResponseEntity.status(HttpStatus.FOUND) .location(URI.create(originalUrl)) .build(); }注意我在这里把访问日志的写入做成了异步,日志和跳转不能互相阻塞。很多第一次做短链接的人会把统计写到主流程里,结果访问量一大,跳转延迟跟着上去了,这属于把两个不同生命周期的事绑在了一起。
3.3 数据库表结构调整
今天对数据表做了一个小改动:把原来单独的short_code和original_url索引,改成了short_code唯一索引 +user_id + original_url联合索引。原因是线上慢查询日志里经常出现“按用户查他的短链列表”以及“按短码查目标链接”,这两个查询方向都要覆盖到。
表结构大致是这个样子:
CREATE TABLE t_short_url ( id BIGINT PRIMARY KEY AUTO_INCREMENT, short_code VARCHAR(16) NOT NULL, original_url VARCHAR(2048) NOT NULL, user_id BIGINT NOT NULL DEFAULT 0, visit_count BIGINT NOT NULL DEFAULT 0, expire_at DATETIME NULL, 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_user_original (user_id, original_url(255)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;original_url最长2048是因为有些长链接带很多参数,实际用下来超过2048的很少。联合索引里只对前255个字符做索引,是因为MySQL对索引长度有限制,太长反而影响写入性能,而且一般同用户下前255个字符相同的链接基本就是同一个来源。
4. 访问统计:用异步来保证“统计不影响主链路”
短链接和普通跳转链接最大的区别,就是短链接天生自带“被点了几次”的数据价值。运营通过短链接发活动,最关心的就是链接曝光和点击数据。今天我把统计这块正式接进来了。
4.1 统计的两种实现思路
访问统计的标准做法是在跳转逻辑里埋点。埋点有两个实现方向:
拦截器方案是写一个HandlerInterceptor,在请求进入Controller之前统一处理,统计逻辑和业务逻辑解耦。优点是代码侵入小,后续要扩展限流、黑名单也方便。缺点是异步线程和事务边界要自己管理。
AOP注解方案是在跳转方法上打一个@VisitStatistic注解,通过切面在方法执行后做统计。优点是灵活,哪个接口要统计就给哪个加注解;缺点是对新人不友好,出了问题要同时看切面代码和业务代码。
我选了拦截器方案,因为短链接服务不太可能有太多需要统计的接口,一个全局拦截器覆盖所有短链接访问就够了,没必要搞得太复杂。
4.2 计数一致性方案
访问计数的难点在于并发写。同一短链接同一秒可能被点几百上千次,如果每次都去更新数据库里的visit_count字段,行锁会非常严重。我采用的做法是:先写Redis计数,Redis按短码维度做累加,然后每隔一段时间批量同步到MySQL。
public void recordVisit(String shortCode) { String key = "shortlink:visit:" + shortCode; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { // 第一次写入时设置过期时间,避免冷数据长期占用内存 redisTemplate.expire(key, Duration.ofHours(24)); } }这里有几个细节。第一,INCR命令是原子的,多线程下不会丢计数。第二,我给冷数据设置了过期时间,如果一个链接24小时没有新访问,Redis里的计数会消失,下次再有访问时重新从MySQL加载。第三,批量同步需要记录上一次同步的位置,否则会把同一批数据重复累加到MySQL。
同步我用的是一个定时任务,每分钟跑一次,把Redis里计数大于0的短码取出来,把差值更新到MySQL。这个设计在数据量不大时完全够用,真要到了每秒几万次点击的级别,再换消息队列慢慢写也不迟。
4.3 访问日志的数据洞察点
除了计数,访问日志还记录了IP、User-Agent、Referer这几个字段。这些字段能告诉你一个短链接是从哪里被打开的:是微信内打开的,还是浏览器直接输入的,还是某个App内嵌WebView打开的。运营可以根据这些信息调整投放渠道。
我会在次日凌晨对访问日志做一次离线统计,按小时维度聚合,生成访问趋势图。这个功能今天只做了数据落库,统计报表的部分留到后面几天做。毕竟“记录原始数据”比“出漂亮报表”更基础,先把数据存下来,后面愿意怎么分析都行。
5. 缓存策略:让短链接跳转跑得更轻
短链接跳转这个动作,本质上是一次非常轻量的查找操作。为了保证轻,缓存是必须的。今天我把缓存这块认真做了一遍,覆盖了缓存穿透、缓存击穿和缓存过期三个问题。
5.1 缓存的读取与更新
简单说,跳转时先读Redis,Redis拿到就直接跳转,拿不到就查数据库,再回填Redis。这个流程99%的场景都够用。
缓存字段建议存JSON对象,不要只存目标URL。因为后续可能需要返回“是否过期、是否启用”这些元信息,只存URL还得再查一次库。
public String getOriginalUrl(String shortCode) { String cacheKey = "shortlink:url:" + shortCode; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } ShortUrlEntity entity = shortUrlMapper.selectByShortCode(shortCode); if (entity == null) { // 写入空值缓存,防止穿透 redisTemplate.opsForValue().set(cacheKey, "", Duration.ofMinutes(2)); return null; } String originalUrl = entity.getOriginalUrl(); redisTemplate.opsForValue().set(cacheKey, originalUrl, Duration.ofDays(7)); return originalUrl; }短链接的缓存时间我给的目标链接设置7天,空值设置2分钟。7天是因为短链接的目标地址理论上会有修改,缓存太久容易旧;空值缓存2分钟是为了防止大量不存在的短码直接把数据库打垮。就算真有攻击者拿一堆随机短码来刷,数据库最多被穿透2分钟的量级,风险可控。
5.2 缓存过期瞬间的击穿问题
缓存击穿是指某个短链突然特别火爆,缓存刚好过期,一瞬间所有请求都去查数据库。这种场景典型的应对方法有两种:互斥锁和逻辑过期。
互斥锁是在缓存失效后,用SET NX抢锁,抢到锁的线程查数据库回填缓存,其他线程先等待或者直接返回默认值。逻辑过期是把缓存设成永不过期,但数据里存一个过期时间字段,发现过期后由后台线程异步更新。
我采用互斥锁方案,因为实现简单,对短链接这种数据变化不频繁的场景已经足够。代码大概这样:
String lockKey = "shortlink:lock:" + shortCode; String requestId = UUID.randomUUID().toString(); boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, Duration.ofSeconds(5)); if (locked) { try { // 二次查缓存,可能其他线程已经回填了 String cachedAgain = redisTemplate.opsForValue().get(cacheKey); if (cachedAgain != null) { return cachedAgain; } // 查数据库并回填 ShortUrlEntity entity = shortUrlMapper.selectByShortCode(shortCode); if (entity == null) { redisTemplate.opsForValue().set(cacheKey, "", Duration.ofMinutes(2)); return null; } redisTemplate.opsForValue().set(cacheKey, entity.getOriginalUrl(), Duration.ofDays(7)); return entity.getOriginalUrl(); } finally { // 释放锁,使用lua脚本保证比较和删除是原子的 releaseLock(lockKey, requestId); } } return null;释放锁的时候不能用简单的DEL,因为有可能锁已经被重新获取。我用的Lua脚本先比较当前值和请求ID,一致才删除。这里面最容易翻车的点是忘了在finally里释放锁,一旦锁没释放,后面所有该短码的查询都会被堵上。
6. 常见问题与排查技巧实录
第六部分放几个我今天实际踩到的问题。这些问题每一个单看都不复杂,但组合在一起,基本就是短链接从“能跑”到“能扛”的分水岭。
6.1 问题一:短码突然生成失败,报唯一索引冲突
排查过程:我先看了日志,发现有大量DuplicateKeyException,但生成逻辑里明明加了冲突重试。后来发现是我上午改成了“先取号段再编码”的方式,但是测试环境里还有另外一台旧服务在跑,用的还是随机字符串,两个服务代码不一致,生成规则完全不同,新代码根本没法判断旧数据。
解决方案:把测试环境所有旧服务停掉,统一用新代码。同时我还在代码里加了一个逻辑:捕获到冲突之后,把短码前四位相同的记录查出来看一眼,如果发现是规则变更引起的历史数据,再做一次短码重新生成。
这个问题的本质是“线上多版本共存”。做短链接这种有唯一约束的系统,最怕新旧逻辑并行,规则一变就会互相踩踏。
6.2 问题二:点击量翻倍,跳转延迟明显上升
排查过程:压测时发现跳转接口P99从30ms涨到了200ms。先怀疑数据库慢查询,看慢查询日志没有可疑记录。再怀疑Redis连接池,发现连接数确实打满了。原因是我在访问日志里直接同步写MySQL,每跳转一次,就在事务里插入一条访问日志,数据库写入成了瓶颈。
解决方案:把访问日志改成先写到内存队列,再由一个后台线程批量刷库。跳转接口只做Redis计数和日志异步投递,主链路不再碰MySQL写操作。
这里有一个经验:统计类数据和业务数据从一开始就要分开。业务数据要强一致,统计类数据允许一定的延迟。一旦把它们混在一起,业务高峰期统计就会拖垮跳转性能。
6.3 问题三:同一个短链接在微信和浏览器里打开效果不同
排查过程:运营反馈同一个短链接在微信里打开正常,在PC浏览器里却被拦截。看访问日志发现,浏览器请求的Referer和User-Agent都正常,但目标长链接是一个带有推广参数的电商链接,被浏览器插件识别成可疑链接拦截了。
解决方案:这不是服务端能解决的问题。我能做的是给运营提供一个自检工具,在生成短链时,把长链接放到一个预检页面里,实时看目标站点是否返回正常状态码。如果目标站点本身有内容安全策略,只能让运营更换目标链接。
做短链接服务的人一定要理解:短链接只是中间层,目标网站是否被信任、是否被封杀,不是短链接服务能控制的。很多对接方不理解这一点,出问题就来找你,你要学会用日志和数据说话。
6.4 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 创建短链提示短码已存在 | 新旧代码生成规则冲突 | 检查是否有多个服务实例在用不同规则 |
| 跳转很慢 | 缓存未生效或数据库连接池打满 | 先看Redis命中率,再看连接池监控 |
| 点击量统计偏高或偏低 | 异步日志丢失或重复计数 | 检查内存队列是否积压,配合日志核对去重逻辑 |
| 短链在部分App内打不开 | 目标链接被App内置安全策略拦截 | 换成备案域名或调整目标链接 |
| 数据库表数据量增长过快 | 访问日志表和短链表共用库 | 把日志表拆分到独立库或加TTL策略 |
| 修改短链目标地址后不生效 | 浏览器缓存了301结果 | 改链操作同时删除缓存,并把场景改成302 |
7. 一点心得与下一步计划
第四天做下来,我最大的体会是:短链接系统的复杂度不在于“怎么生成短码”,而在于“生成之后怎么保证可用、可信、可查”。今天做的缓存、统计、短码可控设计,没有一个是新奇的发明,但每一个都在真实场景里解决过具体问题。尤其是异步计数和缓存穿透防护,等访问量上来了再补,付出的代价会大得多。
下一步我准备继续处理几个方向:第一是短链的批量导出和管理后台,方便运营自助操作;第二是给接口加上限流和风控,防止有人拿短链接口做批量滥用;第三是把访问日志接入更完整的数据分析流程,做出渠道转化报告。做短链接不是做完跳转就结束了,后面有一整个数据运营的链条等着铺开。