先说明一下这篇笔记是我在复习自己之前写的短链服务项目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(); }对应地解析的时候把短码转回数字再用这个数字去数据库查原始URLpublic 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) ) ENGINEInnoDB;每次需要发号时先预取一段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 zoneshortlink: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左右热缓存情况下命中RedisQPS能到5000以上。这个数据说明短链服务在缓存加持下性能表现强劲真正的瓶颈几乎必然在数据库端。所以性能优化的核心思路就是提高缓存命中率。命中率能做到99%以上数据库压力就完全可以被忽略这也是为什么短链服务可以支撑千万级日活在单机部署架构下的原因。6. 复习中发现的新问题和接下来的安排6.1 三个隐患需要修经过Day02的复习我发现自己这个项目里至少有三个地方需要改进第一个是缓存TTL设置问题前面提到了从1小时调整为7天并且加随机偏移消除雪崩风险。第二个是创建接口的响应报文格式不统一导致调用方解析困难。有的地方返回{code:0, data:{...}}有的地方又返回{success:true, shortUrl:...}这种不一致的API设计在提供给公司内部使用时问题不大但一旦对外开放会让对接方非常痛苦。这次准备统一成一套标准响应格式。第三个是数据库连接池参数从来没有调过。默认连接池对于短链服务这种“读多写少”的业务需要调整最大连接数和超时时间否则高并发时会出现获取连接超时的故障。6.2 Day03的准备今天的内容主线已经完成发号策略、重定向链路、缓存设计。下一轮Day03我打算把统计分析那一块重新整理把短链的点击趋势、来源分析、地域分布、设备分布做一次整体梳理同时把数据从MySQL导到ClickHouse的方案一并想清楚。如果继续深挖我可能会把短链服务和对象存储结合做一个支持文件链接的服务算是这个项目的自然延伸。不过那要等我把数据统计做扎实之后再说。复习Day02结束收获比预期大。希望这篇笔记对你自己的短链项目复习也有参考价值。