
搞短链接服务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 ResponseEntityVoid 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)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 缓存的读取与更新简单说跳转时先读RedisRedis拿到就直接跳转拿不到就查数据库再回填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结果改链操作同时删除缓存并把场景改成3027. 一点心得与下一步计划第四天做下来我最大的体会是短链接系统的复杂度不在于“怎么生成短码”而在于“生成之后怎么保证可用、可信、可查”。今天做的缓存、统计、短码可控设计没有一个是新奇的发明但每一个都在真实场景里解决过具体问题。尤其是异步计数和缓存穿透防护等访问量上来了再补付出的代价会大得多。下一步我准备继续处理几个方向第一是短链的批量导出和管理后台方便运营自助操作第二是给接口加上限流和风控防止有人拿短链接口做批量滥用第三是把访问日志接入更完整的数据分析流程做出渠道转化报告。做短链接不是做完跳转就结束了后面有一整个数据运营的链条等着铺开。