先说明一下,这篇笔记是我在复习自己之前写的短链服务项目,Day02的整理记录。昨天把整体需求、数据库表结构过了一遍,今天主要钻进了两个最核心的模块:发号策略和重定向链路,外加把缓存设计重新推导了一遍。复习过程中发现了好几个当年没想清楚的细节,今天全记下来了。
1. 复习开始前的定位:Day02到底要看什么
1.1 为什么把发号策略放在第二天
短链服务的核心流程其实不复杂:客户端提交一个长链接,服务端生成一个短字符串,然后存起来,等用户访问短字符串的时候,重定向到原来的长链接。但越简单的东西,越容易在细节上翻车。
Day01已经确认了整体数据模型,大概就是一张映射表,字段无非是主键ID、短码、原始URL、创建时间、过期时间、点击次数这类。那第二天自然要往更深一层走——短码这个字符串怎么来?重定向用301还是302?缓存怎么扛住大流量?这三个问题才是短链项目的灵魂。
我给自己定的Day02复习路线很明确:
- 第一,把短码生成方案重新推演一遍,搞清楚每种方案的适用边界
- 第二,把完整请求链路的每一步都画在纸上,确认没有遗漏异常场景
- 第三,把缓存设计从零推导一次,不看旧代码,看看能不能得出相同的结论
这样复习的好处是,你会发现哪些东西当年是靠背方案“做”出来的,哪些是真正理解后“长”出来的。
1.2 技术栈快照与架构回顾
为了后面的讨论不悬空,先把我项目的技术选型摆出来:服务端用的是Java + Spring Boot,数据库是MySQL,缓存用的Redis,发号器用数据库号段模式实现(这个后面细说)。整个服务形态就是最简单的单体应用,部署后用Nginx做入口。
为什么用单体而不是微服务,理由很简单——短链服务的核心功能就两个:生成短码、解析跳转。单体应用能把这套逻辑讲得清清楚楚,微服务在这个规模下纯粹是给自己加负担。如果你现在正在选型,我强烈建议别一上来就搞微服务,先把单体做扎实,后面真有需要再拆也不迟。
架构上只有三条链路需要关心:
链路一:创建短链 Client -> Nginx -> Spring Boot -> MySQL/Redis 链路二:访问短链 Client -> Nginx -> Spring Boot -> Redis/MySQL -> 重定向 链路三:后台统计 Admin -> Spring Boot -> MySQL其中前两条是高频路径,也是我今天复习的重点。链路三对性能要求不高,数据库直接查就行。
2. 短码生成方案:唯一性、长度、并发之间的三角博弈
2.1 哈希截断方案为什么被我否掉了
最直觉的短码生成方式,是把原始长URL做哈希(MD5或者SHA),然后取其中一段字符作为短码。比如取MD5结果的前8位,这不是很多人第一反应就能想到的做法吗?
当年我也试过这个方案,但它有几个硬伤。
第一是碰撞问题。哈希是压缩映射,不同长URL哈希后截取同一位的概率虽然不高,但在千万级数据量下,这个概率会被放大到一个不可忽略的程度。你不能拿用户的正常访问去赌那百万分之一的概率,一旦碰撞,就会出现A用户的短码跳到了B用户的链接上,这个事故属于严重的生产故障级别。
第二是不可控性。哈希结果是分散的,你没办法通过它判断自己的发码进度,也没办法进行后续的分库分表扩展。比如说你想把数据按短码范围分片,哈希方案就做不到。
第三是长度不友好。MD5是16字节,转成十六进制字符串就是32位,Base64编码后也是22位左右的字符串。当然你可以截取,但截取进一步推高了碰撞概率。
哈希方案不是不能用,但它更适合那种“不需要精确控制生成顺序、数据量小、碰撞可以接受”的场景。做正经短链服务,我不会选它。
2.2 自增发号 + 进制转换:经典方案的正确打开方式
后来我换成了经典的“发号器 + 进制转换”方案,这也是目前绝大多数短链服务的底层逻辑。
思路很朴素:维护一个全局自增ID,每次生成短码时取一个ID,然后把这个十进制的ID转换成62进制(大小写字母加数字,共62个字符)。
转换算法其实就一个循环,极简单:
private static final char[] BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray(); private static final int BASE = 62; public static String encode(long num) { StringBuilder sb = new StringBuilder(); while (num > 0) { sb.append(BASE62_CHARS[(int) (num % BASE)]); num /= BASE; } return sb.reverse().toString(); }对应地,解析的时候把短码转回数字,再用这个数字去数据库查原始URL:
public static long decode(String shortCode) { long result = 0; for (char c : shortCode.toCharArray()) { result = result * BASE + base62IndexOf(c); } return result; }这套方案的好处非常直观:
- 唯一性由发号器保证,只要ID不重复,短码一定不重复
- 短码长度可控,62的6次方约880亿,也就是说6位短码可以覆盖880亿条记录,7位就是5.4万亿
- 短码字典序和生成时间正相关,便于后续数据归档和分库分表
在实际项目里,我用的是7位短码。6位留一点余量,7位短期内绝对够用,而且7位短码在视觉上不会显得太长。
2.3 数据库号段模式:给发号器做高可用
发号器听起来简单——一个自增ID嘛,但生产环境里你不能让它成为单点。如果你直接在数据库表里用自增主键当ID,那每次生成短码都要落一次库,性能会被限制在单库单表的写入能力上,而且数据库一重启,自增ID还存在跳号问题。
更稳的做法是号段模式。我维护一张发号表:
CREATE TABLE id_allocator ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,区分不同发号器', max_id BIGINT NOT NULL COMMENT '当前已分配的最大ID', step INT NOT NULL COMMENT '步长', version INT NOT NULL COMMENT '乐观锁版本号', update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_type (biz_type) ) ENGINE=InnoDB;每次需要发号时,先预取一段ID到内存中。比如一次申请1000个ID,把max_id从1000更新到2000,然后接下来的1000次发号都从内存里分配,直到用完再去数据库申请下一批。
这个用乐观锁来实现很清爽:
@Transactional public long allocateSegment(String bizType, int step) { // 先查当前记录 // 用 version 做乐观锁更新 int updated = idAllocatorMapper.updateMaxId(bizType, step, version); if (updated == 0) { // 冲突则重试 } return newMaxId; }号段模式的本质,是把“每次发号落库”变成“批量落库”,把数据库的压力从每秒成千上百次降低到每千次才一次。而且即使某个实例宕机,最多损失一个号段,对业务没有影响。
这里踩过的坑在Day02复习时又浮现出来:千万不要在短链生成接口里去同步调发号器接口,一定要用本地缓存号段的方式。我当时犯过的错误是把号段存在Redis里,每次生成短码之前先查Redis拿当前ID,结果把缓存的一致性维护搞复杂了,完全没有必要。号段数据就应该存在发号服务的本地内存里,简单直接,不涉及跨进程一致性。
2.4 关于“不可猜测”与可逆性的深度思考
复习到这里,我停下来问了自己一个问题:短码是可逆的,用户会不会根据短码推测新生成的短码,遍历我的服务?
答案是会。
这让“不可猜测”变成了一个安全需求。可逆自增ID有一个原生的弱点——你拿到一个短码,往前减个1、2,就能遍历到其他用户的短码,这属于典型的越权访问漏洞。
针对这个问题,我复习时总结了三个级别的防护手段:
第一级,短码加盐打乱。在把十进制ID转成62进制之前,先把ID和一个随机盐值做一次位运算变换,比如异或、移位、取反等。这样即使ID是有序的,生成的短码在外部看起来也是杂乱无章的。
第二级,访问鉴权。如果短链服务需要登录后才能创建,那么访问时也要校验用户身份和权限,防止未授权访问。这个要看产品定位,如果是完全公开的跳转服务,就不能靠这一层。
第三级,监控与风控。对短码的访问频率做监控,发现连续递增探测的行为,直接拉黑IP或增加验证码。
我的项目目前做了第一级和第三级。异或变换和进制转换组合起来,安全性和发号效率都不损失。这个平衡点很微妙——你不能引入随机UUID作为短码本身,那会让短码方案退回哈希方案的老路,又长又无法保证长度。
3. 重定向链路:301和302之间藏着大学问
3.1 一次完整的短链访问,到底经历了什么
短链服务的重定向链路,是理解整个系统的关键。我梳理了一下,从用户点击短链到最终打开网页,需要经过这么几步:
1. 浏览器输入或点击短链(比如 https://s.example.com/x7K9mQ) 2. DNS解析 s.example.com 到服务器IP 3. Nginx接收请求,转发到Spring Boot 4. Spring Boot解析短码 x7K9mQ,生成key 5. 查Redis缓存,命中则直接返回长链接 6. 缓存未命中则查MySQL 7. 数据库查不到则返回404 8. 查到则写入Redis缓存 9. Spring Boot返回HTTP 301/302响应,Location头指向长链接 10. 浏览器收到响应,跳转到长链接页面这里面最关键的一步在第9步——返回什么状态码。我在复习时把这个细节重新拉出来分析,因为301和302的选择直接影响了系统的流量分布。
3.2 HTTP状态码选择的深层决策依据
301是永久重定向,302是临时重定向。这个区别大家应该都知道,但落到短链场景里,影响远不止“永久”和“临时”这两个词的差异。
如果我用301,浏览器会缓存这个重定向关系。用户第一次访问短链,服务端返回301和长链接地址,浏览器之后再来访问同一个短链时,直接使用缓存,不再请求短链服务。
如果我用302,浏览器就不会缓存重定向关系,每次访问短链都会重新请求一次短链服务,拿到302响应后再跳转。
从服务器压力角度看,301明显更省事。但这里有个极其重要的产品决策——你需要统计点击量吗?
如果短链服务要做点击统计(大多数产品都要做),而用了301,那么用户对于同一个短链的第二次、第三次点击,根本不会到达你的服务器,你的计数器统计到的大部分是第一次点击量。这对数据分析来说是一个致命的失真。
所以我最终选择的是302。每次点击都会经过服务端,统计才能反映真实的用户行为。代价是服务器压力更大,但这个压力通过缓存就能解决,而统计数据的价值远远大于这点服务器开销。
一个更进阶的玩法是:根据合作伙伴或者链接类型区分策略。比如短期活动链接用302,因为活动结束后链接立即失效,不允许缓存;而对于一些不要求统计的固定链接,可以用301降低服务压力。这个粒度控制要做的话也不复杂,在数据库里加一个redirect_type字段就行。
3.3 缓存中的查询key怎么设计
重定向链路里的关键操作是根据短码查询长链接。缓存的key设计很重要,我采用的是短码本身作为key的一部分。
public String getLongUrl(String shortCode) { String cacheKey = "shortlink:code:" + shortCode; String longUrl = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(longUrl)) { return longUrl; } // 查数据库 // 写缓存,设置过期时间 }短码在这个场景里就是天然的唯一标识,用它做缓存的key简洁明了。有些同学会把key设计成shortlink:url:xxxx或者直接存原始URL,其实没必要——短码本身就是对原始URL的压缩,用它做key是最高效的。
缓存过期时间我设置的是7天,和短链的默认有效期保持一致。但这里有一个容易被坑的细节:缓存过期时间和短链过期时间不要搞混。短链过期是指数据库里的记录不可用了,而缓存过期只是把数据提前从Redis淘汰,两者不应该绑定。我复习时发现自己的旧代码居然把缓存TTL设置成了1小时,这意味着如果短链还没过期但缓存先过期了,用户的一次请求就会落到数据库上,白白增加了数据库压力。这部分后面调整了,改成7天。
3.4 防攻击:短码暴力枚举怎么挡
既然短码落在URL的Path里,就一定会有人尝试遍历访问。常见攻击姿势有三种:顺序猜、字典猜、高频刷。
我的第一层防护是上面提到的ID打乱,让短码看起来完全没有规律。第二层是Nginx层面对单IP的QPS做限制,这个在Nginx配置里几行就能搞定:
limit_req_zone $binary_remote_addr zone=shortlink:10r/s;第三层是Redis里做一个访问频率计数的滑动窗口,单位时间超过阈值就返回429。这个逻辑我是在网关里实现的,成本不高,但对暴力枚举的拦截效果很明显。
有人可能会问,直接在后端逻辑里做频率限制是不是更精确?理论上是,但Nginx层做前置挡板可以过滤掉大部分无效流量,让后端服务更轻松。两层都用最稳妥。
4. 缓存设计:把性能压到极致
4.1 冷热数据分离:把好钢用在刀刃上
短链服务的流量往往高度集中,少部分热门链接占据了绝大部分访问量。比如一个活动链接可能每秒上千次请求,而其他所有链接加起来也没多少流量。缓存系统的设计首先要意识到这一点:缓存不是把所有数据都塞进去,而是把热数据放进去。
我的做法是两级缓存:本地缓存 + Redis。本地缓存放的是当前实例自己最热的数据,访问速度是纳秒级,Redis是毫秒级,数据库是几十毫秒级,三者之间差异巨大。
本地缓存我用的是Caffeine,配置了最大缓存条数10万条,过期时间120秒。Redis则是全局共享,配置了7天过期。
这样设计的原因在于,单机本地缓存虽然最快,但它有一个致命问题——无法跨实例共享。当你部署多个实例的时候,同一个短链在不同实例上的访问命中情况各不相同,本地缓存反而可能会引入数据不一致。所以我的策略是:本地缓存只放最热的、变化不频繁的数据,Redis放全量热数据,数据库兜底。
4.2 缓存穿透:不存在的短码是最容易忽视的风险
空值穿透是我在实际里踩过最深的一个坑。如果用户访问一个不存在的短码(比如被污染的前端页面生成了无效链接),那么我们的查询链路会一路打到数据库,然后查不到,直接就返回404。这个流程在流量小的时候没有什么感觉,但如果被脚本大量扫描,每次都不存在的短码都能消耗一次数据库查询,很快数据库连接就会被耗尽。
解决穿透的标准做法是把空值也缓存起来。查不到数据库时,在Redis里写入一个特殊值,比如空字符串或者一个特殊的前缀,TTL设置短一些,30秒。下一次同样的短码再来查询时,先命中缓存的空值,直接返回404,不再查数据库。
public String getLongUrl(String shortCode) { String cacheKey = "shortlink:code:" + shortCode; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { if (cached.length() == 0) { // 命中空值缓存,说明之前已经确认不存在 return null; } return cached; } String longUrl = shortUrlMapper.getByCode(shortCode); if (longUrl == null) { redisTemplate.opsForValue().set(cacheKey, "", 30, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(cacheKey, longUrl, 7, TimeUnit.DAYS); return longUrl; }除了空值缓存,还有一种更彻底的方案——布隆过滤器。在服务启动时把所有存在的短码加载到布隆过滤器里,请求来了先查布隆,如果不存在直接返回404,连缓存都不用碰。这个方案的内存占用极小,适合短码量级很大的场景。我做了一个简化版的实现,用Google Guava的BloomFilter,效果不错。
4.3 热点key的缓存击穿与雪崩应对
热点key的击穿问题也很经典。假设缓存中某个短码过期了,偏偏在这个时刻有上千个并发请求同时打到服务上,那么它们全会穿透缓存去查数据库。这就是击穿。
解决的方案有两个方向。一个是互斥锁。在缓存未命中时,先尝试获取一个分布式锁(或者本地锁),拿到锁的线程才允许查数据库,其他线程自旋等待锁释放后直接从缓存取值。另一个方向是为热数据设置较短的过期时间,分散过期时刻,让击穿窗口变小。
我采用的是互斥锁方案,实现很简洁:
public String getLongUrlWithLock(String shortCode) { String cacheKey = "shortlink:code:" + shortCode; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) return cached; String lockKey = "shortlink:lock:" + shortCode; boolean locked = tryLock(lockKey, 500); if (!locked) { // 没有抢到锁,可能是其他线程在重建缓存,短暂等待后重试 Thread.sleep(50); return getLongUrlWithLock(shortCode); } try { String longUrl = shortUrlMapper.getByCode(shortCode); if (longUrl != null) { redisTemplate.opsForValue().set(cacheKey, longUrl, 7, TimeUnit.DAYS); } else { redisTemplate.opsForValue().set(cacheKey, "", 30, TimeUnit.SECONDS); } return longUrl; } finally { unlock(lockKey); } }雪崩则是大量key同时过期导致缓存集体失效,数据库瞬间被压垮。解决办法是在TTL上做随机化,不要所有key都在同一秒过期。比如设置基础过期时间7天,加上随机0到600秒的偏移量。这个技巧在集群都配了缓存时尤其重要。
5. 接口实现复盘:创建短链与跳转接口的细节
5.1 创建短链接口的参数校验到底校验什么
创建接口是短链服务的入口,看似简单,实际上要注意几个细节。
第一是URL的合法性校验。不能只校验字符串格式,还要确认URL的主机名存在、协议是http或https、没有明显的问题。我用的是正则加URL解析双重校验,防止有人把本地路径或者不完整URL提交上来。
第二是URL的规范化处理。比如同一个长URL有多种写法:带不带www、带不带尾部斜杠、是http还是https,这些如果不去重,会生成大量重复短码。我在创建时做了简单的规范化处理:强制小写域名、去掉默认端口、去掉多余的斜杠。当然,要做到彻底去重,更可靠的做法是在数据库里针对原始URL的唯一索引,创建前先查一次是否已存在,存在就直接复用已有短码。
第三是对账逻辑。创建接口必须返回生成的短码、原始URL、过期时间、有效期单位这些信息,方便调用方确认。我遇到过测试人员反馈“创建成功但跳转失败”的问题,后来查明是测试环境数据库和Redis中数据不一致导致的。所以创建成功后一定要同步清理可能存在的旧缓存。
5.2 跳转接口的完整实现与报警
跳转接口的核心代码在上面已经展示了。除此之外,还有几个容易漏掉的点值得记一下:
跳转接口里必须打日志。每来一次请求,记录短码、来源IP、UserAgent、目标URL。这些日志最终会进入消息队列,被下游消费统计。
跳转接口必须有失败处理。数据库异常、Redis故障、超时,都需要有兜底方案。我的做法是:短链能查缓存就查缓存,缓存挂了直接降级查数据库,数据库也挂了就返回一个静态的默认错误页面,而不是让用户看到白屏。这个兜底虽然简单,但在故障演练时能顶上很大用场。
跳转接口还要注意性能监控。每个请求的平均耗时要打点上报,超时阈值设置为100毫秒,超过就要报警。短链服务绝大多数情况对响应时间是极度敏感的,如果发现P99耗时开始上升,就说明缓存命中率在下降或者数据库出现了慢查询,需要赶紧排查。
5.3 压测结果与分析视角
复习完逻辑之后,我对单机版本做了简化的压测,数据不追求精确,主要是观察逻辑链路有没有明显的瓶颈。用wrk简单打了一下,冷缓存情况下(全量走数据库)QPS大约在800到1200左右,热缓存情况下(命中Redis)QPS能到5000以上。这个数据说明,短链服务在缓存加持下性能表现强劲,真正的瓶颈几乎必然在数据库端。
所以性能优化的核心思路就是提高缓存命中率。命中率能做到99%以上,数据库压力就完全可以被忽略,这也是为什么短链服务可以支撑千万级日活在单机部署架构下的原因。
6. 复习中发现的新问题和接下来的安排
6.1 三个隐患需要修
经过Day02的复习,我发现自己这个项目里至少有三个地方需要改进:
第一个是缓存TTL设置问题,前面提到了,从1小时调整为7天,并且加随机偏移消除雪崩风险。
第二个是创建接口的响应报文格式不统一,导致调用方解析困难。有的地方返回{code:0, data:{...}},有的地方又返回{success:true, shortUrl:...},这种不一致的API设计在提供给公司内部使用时问题不大,但一旦对外开放,会让对接方非常痛苦。这次准备统一成一套标准响应格式。
第三个是数据库连接池参数从来没有调过。默认连接池对于短链服务这种“读多写少”的业务,需要调整最大连接数和超时时间,否则高并发时会出现获取连接超时的故障。
6.2 Day03的准备
今天的内容主线已经完成:发号策略、重定向链路、缓存设计。下一轮Day03我打算把统计分析那一块重新整理,把短链的点击趋势、来源分析、地域分布、设备分布做一次整体梳理,同时把数据从MySQL导到ClickHouse的方案一并想清楚。
如果继续深挖,我可能会把短链服务和对象存储结合,做一个支持文件链接的服务,算是这个项目的自然延伸。不过那要等我把数据统计做扎实之后再说。
复习Day02结束,收获比预期大。希望这篇笔记对你自己的短链项目复习也有参考价值。