先说说短链接这回事
你肯定见过这种东西:https://b23.tv/aB3xY9,点进去跳到的是一个很长的 B 站视频链接。日常聊天、发微博、印海报的时候,长链接又丑又占地方,需要有一个短的东西来替它。
乍一想,这不就是个映射吗——存一个对应关系,有人访问短链接的时候,查到原始链接,302 跳过去,完事。
我一开始也是这么想的。但真的动手写的时候,问题一个接一个冒出来:怎么保证短码不重复?两个人同时用同一个自定义短码怎么办?有人拿脚本刷创建接口怎么防?短码被人遍历怎么办?缓存穿透了怎么办?大量请求同时访问一个不存在的短链,会不会直接把数据库打垮?
这篇文章就从一次创建请求的角度,从头到尾把它走过的路拆开来看。项目用的是 Spring Boot 3 + MyBatis-Plus + Redis + Redisson + MySQL 这套技术栈。
整个流程长什么样
在具体讲每个环节之前,先给一个全景图。当一个客户端发送 POST /api/short-link/create,带上 {"originalUrl": "https://某个很长的网址.com"},请求会依次穿过这些层:
客户端
│
▼
限流拦截器(Redis Lua 滑动窗口,IP 维度限流)
│
▼
Controller 层(接收请求 + 触发参数校验)
│
▼
Service 层(核心业务编排,11 个步骤)
├─ SHA-256 哈希原始 URL
├─ Redisson 分布式锁
├─ 同 URL 去重查询(自动模式)
├─ 布隆过滤器 + DB 双重确认短码唯一性
├─ 短码生成器生成 6 位 Base62 短码
├─ DB INSERT
├─ 布隆过滤器注册新短码
├─ Redis 缓存回填(TTL 随机偏移)
└─ 释放锁 + 返回结果
│
▼
统一响应:{ code: 200, data: { shortUrl: "http://localhost:8080/sl/aB3xY9" } }
看起来组件很多,但每个都有它存在的理由。下面一个一个聊。
第一关:限流——不让恶意请求打进来
请求到达服务器的第一件事,不是进 Controller,而是被一个拦截器拦住。
这个拦截器叫 RateLimitInterceptor,注册在 WebConfig 里,拦截路径是 /api/**。注意它不拦截 /sl/**——短链跳转是给用户用的,不能因为限流把正常访问也卡了。
它是怎么知道限多少的
在 Controller 的方法上有一个自定义注解:
@RateLimit(window = 60, maxCount = 10, message = "创建过于频繁,请1分钟后再试")
意思是同一个 IP,60 秒内最多调 10 次创建接口。
具体怎么限的
它用的是 Redis 的 ZSET(有序集合)做滑动窗口。
我第一次看到这个方案的时候,脑子里冒出来的问题是:不能直接用固定窗口吗?记一个计数器,到 60 秒就重置,超了就拒绝,逻辑简单得多。
后来仔细一想才发现了固定窗口的边界漏洞:假设限制是 60 秒 10 次,一个人在 59 秒时刷了 10 个请求,下一秒(下一分钟的 0 秒)又刷了 10 个。从固定窗口看,每一分钟的请求都没超限,但实际上他在两秒内刷了 20 个请求。滑动窗口不存在这个问题,它看的始终是「过去 60 秒内」的请求数,不按自然分钟切分。
具体用 ZSET 实现滑动窗口,大概的思路是:
但这四步如果分开执行,会有并发问题。想象两个请求几乎同时到:线程 A 删完旧数据、数了是 9 条、准备放行;线程 B 也删完旧数据、也数了是 9 条。两个都觉得自己没超限,结果实际上是 10+1,限流失效了。
所以这里用了一个很经典的做法——把整个逻辑写进一段 Lua 脚本,一次性发给 Redis 执行。Redis 是单线程的,脚本执行期间不会有其他命令插进来,天然保证了原子性。
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) — 删掉窗口外的
local count = redis.call('ZCARD', KEYS[1]) — 统计当前窗口内请求数
if count >= tonumber(ARGV[3]) then
return 1 — 超限,拒绝
end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[2]) — 记录本次请求
redis.call('EXPIRE', KEYS[1], ARGV[4]) — 设过期,防止内存泄漏
return 0 — 放行
我第一次看到这个的时候,觉得"就四行 Redis 命令而已,至于写 Lua 吗"。后来自己模拟了两个并发请求,才明白分开执行的竞态窗口有多致命。这是新手很容易忽略的细节——分布式环境下,多个操作合在一起不等于原子操作。
IP 获取也有讲究
限流是按 IP 来的,那 IP 怎么拿?不是简单地 request.getRemoteAddr() 就行。如果服务前面有 Nginx 或者 CDN,直接拿 RemoteAddr 拿到的可能是代理服务器的 IP,所有人的请求看起来都来自同一个 IP,限流就废了。
实际代码里是按优先级去取的:先看 X-Forwarded-For 头(Nginx 代理时会设),没有再试 X-Real-IP,最后才用 getRemoteAddr()。而且 X-Forwarded-For 可能是 客户端IP, 代理1, 代理2 这样的逗号分隔列表,取第一个才是真实客户端。
第二关:参数校验——不让脏数据进来
过了限流,请求终于到了 Controller。但 Controller 几乎没写什么逻辑:
@PostMapping("/api/short-link/create")
@RateLimit(window = 60, maxCount = 10, message = "创建过于频繁,请1分钟后再试")
public Result<ShortLinkVO> create(@Valid @RequestBody ShortLinkCreateDTO dto) {
ShortLinkVO vo = shortLinkService.create(dto);
return Result.success(vo);
}
就三行。关键的 @Valid 注解触发了 Jakarta Bean Validation,框架会自动去检查 DTO 里的约束:
public class ShortLinkCreateDTO {
@NotBlank(message = "原始链接不能为空")
@URL(message = "URL格式不正确")
private String originalUrl;
private String description;
private Integer expireDays;
@Pattern(regexp = "^[A-Za-z0-9]([A-Za-z0-9-]{0,6}[A-Za-z0-9])?$",
message = "自定义短码格式不正确")
private String customCode;
}
@NotBlank 保证了 originalUrl 不能是空字符串,@URL 保证它得是个合法的 URL 格式。customCode 的正则限制比较细:必须是 2 到 8 位,只能包含字母、数字和中间的连字符,首尾不能是连字符。
校验失败的时候,Spring 会自动抛出 MethodArgumentNotValidException,然后被全局异常处理器接管,返回一个统一的错误格式给前端。
这里有个小设计值得提一下:虽然校验失败了,HTTP 状态码依然是 200,真正的错误信息在响应体里的业务码(401)里。这样做的好处是前端不需要区分 HTTP 层错误和业务层错误,统一解析 JSON 里的 code 字段就行。
第三关:核心业务逻辑——11 步的编排
终于到了最核心的 Service 层。ShortLinkServiceImpl.create() 这个方法大概 80 多行,是整个创建功能的指挥官。
第 1 步:给 URL 算个哈希
String urlHash = DigestUtils.sha256Hex(dto.getOriginalUrl());
为什么要对原始 URL 做 SHA-256?因为 URL 最长可能有 2048 个字符,拿它直接在数据库里建索引效率很低,而且有些数据库对索引长度有限制。算成一个固定 64 位的十六进制字符串,建索引就快多了。
配合数据库里的 idx_url_hash 索引,后续的"同一 URL 去重"查询就能走索引,不会全表扫描。
第 2-3 步:加锁
接下来是分布式锁。先判断是不是自定义短码模式:
- 如果用户传了 customCode:锁的 key 是 shortlink:lock:code:{自定义短码},保护的是"这个短码不被别人同时占用"
- 如果没传(自动生成模式):锁的 key 是 shortlink:lock:{urlHash},保护的是"同一个 URL 不被并发创建出两个不同的短码"
为什么自动模式用 urlHash 而不用 shortCode?因为自动模式这个时候还没生成短码,锁的目的是保护 URL 到短码这个映射的唯一性。
锁用的是 Redisson 的 RLock:
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
tryLock(5, 10) 的意思是:最多等 5 秒去抢锁,抢到之后最多持有 10 秒(防止死锁)。如果 5 秒还没抢到,说明当前并发太高,直接返回"系统繁忙,请稍后重试"。
我一开始不理解为什么自动生成也要加锁——不是有布隆过滤器加数据库唯一约束吗?后来自己画了一遍并发时序图才想明白:没有锁的话,两个同时创建同一 URL 的请求,都会走到"查 DB→没查到→生成短码→插入"这四步。第一个请求插入成功后,第二个请求因为短码不同(自动生成是随机的),也会插入成功。结果就是同一个 URL 有了两个不同的短码。
锁把这四步包成一个临界区,第二个请求在"查 DB"那一步就会发现这个 URL 已经有短码了,直接返回已有的结果。这就是幂等性的保证。
第 4 步:自动模式下的同 URL 去重
if (!isCustom) {
ShortLink existing = shortLinkMapper.selectByOriginalUrl(urlHash, originalUrl);
if (existing != null) {
return convertToVO(existing); // 已存在,直接返回
}
}
拿 urlHash 和 originalUrl 一起去数据库查(两个条件都是为了保证准确,因为 SHA-256 理论上存在碰撞的可能,虽然概率极低)。如果查到了,说明这个 URL 之前已经被人创建过短链接了,直接返回现成的,不用再生成一个。
这个逻辑让自动模式变成了幂等的:同一个 URL 不管你调用多少次创建接口,都只会有唯一的一个短码。
第 5 步:确定短码
这是最关键的一步,分两种情况。
自定义模式:
if (bloomFilterUtil.mightContain(customCode)) {
ShortLink existByCode = shortLinkMapper.selectByShortCode(customCode);
if (existByCode != null) {
throw new BusinessException(ResultCode.SHORT_CODE_EXISTS);
}
}
这里的判断分两层。第一层是布隆过滤器,一个概率型数据结构,它能告诉你"这个短码一定不存在"或者"可能存在"。
如果布隆过滤器说一定不存在,那就可以放心用。如果它说"可能存在",那就不一定了——它有 0.1% 的误判率,也就是每 1000 次里大约有一次"实际不存在却被说可能存在"。所以这时候还得去数据库里查一下确认。
这种两层确认的做法在性能敏感的场合很常见:大部分情况布隆过滤器直接挡掉了(不需要查数据库),少数误判才需要查库兜底。
自动生成模式:
shortCode = shortCodeGenerator.generate();
while (bloomFilterUtil.mightContain(shortCode)) {
shortCode = shortCodeGenerator.generate();
}
生成一个 6 位短码,然后问布隆过滤器:这个码存在吗?如果存在就再生成一个,循环到不冲突为止。6 位 Base62 有大约 568 亿种组合,碰撞概率极低,一般循环一两次就出来了。
第 6-7 步:写数据库
构建实体对象,调用 shortLinkMapper.insert(shortLink) 插入数据库。这里有一个兜底保护:数据库的 short_code 字段有 UNIQUE 约束。万一在极端并发下布隆过滤器漏了、锁也没完全防住,数据库的唯一索引是最后一道防线,重复插入会直接报错。
第 8 步:注册布隆过滤器
bloomFilterUtil.add(shortCode);
插入成功后,告诉布隆过滤器"这个短码已存在",后续的创建请求就不会再生成跟它冲突的短码了。
第 9 步:缓存回填
int randomExpire = expireHours + random.nextInt(randomHours);
stringRedisTemplate.opsForValue().set(
"shortlink:url:" + shortCode, originalUrl, randomExpire, TimeUnit.HOURS
);
把短码和原始 URL 的映射存进 Redis,这样后续的跳转请求就不用每次都查数据库了。
TTL 不是固定的 24 小时,而是 24 + random(0,6) = 24 到 30 小时的随机值。这一个小偏移,是为了防止所有缓存在同一时刻集体过期,瞬间所有请求都打到数据库——也就是缓存雪崩。我第一次看到这个设计的时候觉得"有必要吗,不就差几个小时吗",后来读了一些生产事故复盘,发现这确实是一个低成本但有效的防御手段。
第 10-11 步:释放锁,返回结果
在 finally 块里释放锁,然后组装 VO 返回。VO 里最重要的是 shortUrl,由配置里的域名 + “/sl/” + 短码拼接而成,比如 http://localhost:8080/sl/aB3xY9。
两个关键组件展开聊
布隆过滤器
布隆过滤器是我在这个项目里新认识的一个工具。它的原理不复杂:一个大的 bit 数组,加几个哈希函数。
加一个元素的时候,用 K 个哈希函数算出 K 个位置,把那些位置的 bit 都设成 1。查的时候,用同样的 K 个哈希函数算出位置,如果有一个 bit 是 0,那这个元素一定没被加过;如果全是 1,那它可能存在,因为有可能是别的元素恰好把这些位置都置为 1 了。
这个"可能存在"就是布隆过滤器的误判。误判率取决于 bit 数组的大小和哈希函数的数量。本项目的配置是 100 万容量 + 0.1% 的误判率,大概占用 1.7 MB 的 Redis 内存。
为什么不用 Redis 的 Set 直接存所有短码?因为 100 万个短码用 Set 存要几十 MB,布隆过滤器只要 1.7 MB。我第一次看到 1.7 MB 这个数字的时候还以为自己算错了,反复确认了两遍——这么小的代价换来一个"快速判断不存在"的能力,性价比太高了。对于"仅用于快速判断不存在"这个场景来说,完全够用。
短码生成器
这是我觉得最有意思的组件。它是怎么生成唯一短码的?
核心思路借鉴了雪花算法(Snowflake)的 ID 生成方式:一个 64 位的数字,由时间戳(41 位)+ 机器 ID(10 位)+ 序列号(12 位)拼成。这样保证每个 ID 全局唯一且趋势递增。
但直接用这个 ID 有问题:它是递增的,别人可以遍历。所以代码里做了一步异或混淆:
uniqueId = Math.abs(uniqueId ^ random.nextLong());
把自增 ID 和一个随机数异或,结果的规律性就完全被打乱了,无法从短码反推出 ID。
然后把混淆后的 64 位数字用 Base62 编码转成 6 位字符串。Base62 用了 0-9、A-Z、a-z 这 62 个字符,不像 Base64 那样带 + 和 /,在 URL 里完全不需要转义。
有个细节值得一提:代码里对时钟回拨做了兼容。System.currentTimeMillis() 可能因为 NTP 时间同步回退几毫秒,如果直接取当前时间,生成的 ID 可能会跟之前的重复。第一次读到这的时候我心想「几毫秒而已,至于吗?」。后来自己模拟了一下分布式高并发的场景才发现,几毫秒足够生成几十上百个 ID 了,一个都不能重复。这个细节让我记住了:分布式系统里没有"小问题"。代码用了一个 AtomicLong 记录上次的时间戳,每次生成时取 max(当前时间, 上次时间),保证了时间戳的单调递增。
异常处理:每种错都有归宿
整个流程里可能出问题的地方不少,但每个异常都有明确的处理路径:
- 限流超了 → RateLimitException → HTTP 429,告诉用户"别刷了,一会儿再试"
- 参数不对 → MethodArgumentNotValidException → HTTP 200 + 业务码 401,告诉用户具体哪个字段不对
- 自定义短码已被占用 → BusinessException(SHORT_CODE_EXISTS) → HTTP 200 + 业务码 505
- 锁抢不到 → BusinessException("系统繁忙") → HTTP 200 + 业务码 500
- 其他未知异常 → Exception 兜底 → HTTP 200 + 业务码 900,同时记日志
所有的异常映射集中在 GlobalExceptionHandler 一个类里。好处是 Controller 和 Service 不用到处写 try-catch,代码很干净。坏处也有——如果你忘记了一种异常类型,它就会掉进兜底的 900,排查起来需要翻日志。
我当时在这个地方踩了个坑:自定义短码冲突抛出的 BusinessException,它的 HTTP 状态码是 200 而不是 409。我跟前端对接的时候习惯性以为"冲突就是 409",结果前端同学按 HTTP 状态码写了判断,一直走不到正确的分支。后来看了 GlobalExceptionHandler 的代码才搞明白——这个项目统一用 HTTP 200,真正的状态放在 body 的业务码里。所以看一个项目的接口规范,最好先翻它的异常处理器。
串联起来:一个完整请求的时间线
把上面所有东西串起来,一个创建请求从头到尾大概是这样的:
整个过程的瓶颈在哪?大概率是第 6 步的锁和第 7 步的数据库查询。Redis 操作(限流、锁、缓存)通常在毫秒级,布隆过滤器也是 Redis 操作,几乎无感。数据库写入因为量小也很快。真正可能慢的是高并发下的锁竞争。
我学到的东西
写完这篇文章,我把整个项目又过了一遍,有几个感受:
第一,简单功能不等于简单实现。 看起来只是"把长链接变短",但要做到高并发下的正确性、防刷、防遍历、缓存优化,需要十几个组件协同工作。
第二,读代码的时候,从入口开始跟。 我一开始直接跳到 Service 层看核心逻辑,发现很多细节看不懂(比如为什么锁的 key 是 urlHash 而不是 shortCode,缓存 TTL 为什么随机),后来从 Controller 一层层往下跟,每个环节在当时的情境下都变得合理了。
第三,分布式环境下的问题跟单机完全不一样。 限流的 Lua 脚本、Redisson 锁、缓存雪崩的随机 TTL,这些在单机下都不是问题,但在多实例部署时就是必须考虑的。新手容易用单机思维写分布式代码,我犯过不少这种错。
第四,防御式编程的收益。 布隆过滤器 + 数据库唯一索引的两层防冲突、锁 + 同 URL 去重的两层防重复,这些看似"过度设计"的东西,是线上系统跟练手项目的区别所在。多一层保护,就少一条半夜告警。
第五,画图真的有用。 文章开头我说自己画了一张调用链路图。不是客气话。在最开始读这个项目的时候,十几个文件堆在脑子里就是一团乱麻,读了这个忘了那个。画完图之后,虽然丑得不好意思给别人看,但每根箭头是一个调用关系,每个框是一个组件,摆在那儿就不抽象了。如果你也在硬啃一个看不懂的项目,我推荐试一试。
网硕互联帮助中心




评论前必须登录!
注册