AI摘要

短链跳转链路需在日均50万次、流量极度不均的营销场景下把响应稳在100毫秒内。核心判断是短码到长链接映射只读不变,无需考虑一致性。做法:用Redis String缓存映射,不设TTL,靠allkeys-lru自然淘汰让热点常驻、长尾出局;空值缓存加限流防穿透;主从哨兵加降级限流防雪崩;点击记录异步化不阻塞跳转。上线后响应稳定在100毫秒内,命中率多数时间超90%。

上一篇讲了短码怎么生成。这篇讲另一半:跳转。

短链系统里,生成和跳转是两条完全不同的链路。生成是写,批量、集中、可以慢一点;跳转是读,单次、分散、用户能直接感知到延迟。用户点一下链接,转圈超过一秒,他就退出了。

所以缓存这件事在跳转链路上不是优化,是必需品。

一、先看清楚流量长什么样

动手设计缓存之前,我先把跳转的流量特征捋了一遍,因为缓存的策略完全取决于流量形态。

这个系统的流量有两个特点。

第一,读远多于写,但写也不小。 生成侧日均 100 万+,跳转侧日均 50 万+。生成的量大是因为营销场景经常批量出链接——给一批用户每人一条带追踪参数的链接,一次就能生成几十万条。跳转的 50 万是这些链接里真正被点开的部分。

第二,也是最关键的:流量极度不均匀。

这是营销场景的常态。一场活动推送出去,那条主链接可能在半小时里吃掉当天一半以上的点击量;而同一批生成的其他几十万条链接,可能一条都没人点。

也就是说,50 万次日均跳转听起来不多,但它不是均匀分布在 24 小时里的。平均值 6 QPS,峰值可能是几百甚至上千 QPS,而且集中在很短的时间窗口内。

这两个特点决定了缓存的目标不是"让平均响应更快",而是:

  • 扛住突发峰值,别让瞬时流量把数据库打死
  • 把响应时间稳定住,不管这一刻是 1 QPS 还是 500 QPS
  • 降低数据库的常态压力,让数据库只处理长尾和回源

二、缓存存什么:这个问题比想象中简单

跳转链路要做的事只有一件:拿着短码,查出长链接,然后 302 跳转。

所以缓存的内容就是一组映射:

key:   short:link:{code}
value: 长链接

用 Redis 的 String 类型就够了。

这里我考虑过用 Hash,把一批短链放进一个 Hash 里减少 key 的数量。算了一下就放弃了:每条短链只需要存一个值(长链接),用 Hash 只会多一层字段名,结构上没有收益;而且 Hash 的字段级别过期不好做,反而增加复杂度。

用最简单的结构,是这个系统里我做得比较对的一个决定。 短链的缓存不需要考虑复杂的更新逻辑,因为数据本身就是简单的。

三、这个系统的缓存,本质上比一般业务简单

写到这里要先说一个前提,这个前提决定了后面很多取舍。

短码到长链接的映射,是不变的。

一条短链一旦生成,它指向哪个页面就固定了。长链接不会改(改了就是另一条短链),短码也不会改。所以这个缓存里存的是只读数据。

只读意味着什么?意味着一般缓存设计里最麻烦的那几件事,在这里全都不存在:

  • 不用考虑缓存和数据库的一致性
  • 不用考虑双写、不用考虑先删缓存还是先更库
  • 不用考虑更新时的并发覆盖
  • 回源的结果永远和缓存里的一致,所以回源不会写坏数据

这一点在面试里被问到的时候我经常提。很多人一听"缓存"就开始背双写一致性的方案,但要先看数据是不是会变。会变的和不会变的,方案完全不是一回事。不变的数据用变化的方案,是给自己找麻烦。

所以这个系统的缓存,真正需要处理的只有三件事:容量、穿透、峰值。

四、容量:30 亿条数据,能缓存多少

这是第一个要算的账。

累计 30 亿条短链,当然不可能全塞进 Redis。所以要先估算单条记录的开销,再倒推能缓存多少。

一条缓存记录大概是:

key:    short:link: + 6 位短码    约 20 字节
value:  长链接,实测平均         约 80 字节
Redis 对象开销(robj、dictEntry 等)  约 50 字节
--------------------------------------------
合计                              约 150 字节

按 150 字节算,一台 16GB 内存的 Redis(实际可用按 12GB 算):

12 GB ÷ 150 B ≈ 8000 万条

也就是说,理论上最多缓存 8000 万条左右,占累计量的百分之几。

那剩下的怎么办?答案是:不处理。

因为流量的分布是不均匀的——绝大部分点击集中在少数热点短链上,长尾那几十亿条可能几个月都没人访问。我们要缓存的就是那批有人访问的,剩下的访问时回源就好。

这就引出了下一个问题:怎么让热点自然留在缓存里,长尾自然出去。

五、过期策略:不给热点设过期时间

常规做法是给每个 key 设一个过期时间,到点自动淘汰。但这个系统里我做了个不太一样的处理:不主动设过期时间,靠 Redis 的内存淘汰策略来管。

理由是这样的:

先看设过期时间会带来什么。 短链是长期有效的——它印在物料上、发在短信里,三个月后还有人点是完全正常的。所以过期时间不能设短,设短了会导致大量回源。设长了(比如 7 天)又会发现:真正的热点链接,7 天里被访问了几十万次,但在第 7 天零点,它还是会被过期掉,然后下一个访问者要回源。

这不是浪费,这是在给热点制造不必要的回源。

所以我用的是:key 不设 TTL,Redis 配置 maxmemory-policy allkeys-lru。

这样得到的效果是:

  • 经常被访问的短链,LRU 链上一直活跃,不会被淘汰 → 热点常驻
  • 偶尔被访问一次的短链,内存不够时优先被淘汰 → 长尾自然出去
  • 缓存的容量自动被访问频率填满,不需要人工规划"该缓存哪些"

这个设计还顺手解决了一个问题:常规缓存里最头疼的"热点 key 过期瞬间大量请求同时回源"(也就是缓存击穿),在"不设过期时间"的前提下就不存在了——热点 key 永远不会因为 TTL 到期而失效,它只会在内存压力下被淘汰,而一个真正的热点在内存压力下几乎不会被选中淘汰。

用 LRU 自然淘汰替代 TTL 过期,是这套缓存里我觉得最省事的一个决定。

六、穿透:不存在的短码怎么处理

上面那套设计对付"存在的、被访问的"短链没问题。但还有一种情况:短码根本不存在。

跳转接口是这样的:GET /{code}。如果 code 不存在,按理说要返回 404。但麻烦在于,"不存在的 code"也会打到缓存,缓存不命中就打数据库——如果数据库查不到,什么都不会被缓存下来,那么同一个不存在的 code 每次请求都会穿透到数据库。

如果只是用户输错了链接,这没什么。但如果有人拿工具扫短码空间,几万个不存在的 code 一起打过来,数据库就危险了。

而且我算过一笔账,这个风险不算小。 假设系统里有 1 亿条活跃短链,5 位空间是 9.16 亿,那么随机猜一个 5 位短码,命中率大约 11%。也就是说扫 100 个随机短码,大概有 11 个是真实存在的。反过来说,89 个是不存在的——这 89 个请求全部会穿透到数据库。

所以我们做了两件事。

第一,空值缓存。

查数据库发现不存在时,往 Redis 写一个特殊值(比如空字符串),TTL 设得比较短,60 秒:

String longUrl = redis.get(key);
if (longUrl != null) {
    // 命中,直接跳转;空值也在这里被拦下
    return longUrl.isEmpty() ? notFound() : redirect(longUrl);
}

// 未命中,回源
LongLink link = mapper.selectByCode(code);
if (link == null) {
    // 空值缓存,防止同一个不存在的 code 反复穿透
    redis.setex(key, 60, "");
    return notFound();
}

redis.set(key, link.getLongUrl());
return redirect(link.getLongUrl());

TTL 设 60 秒是个权衡:太长的话,如果短链是刚生成的(缓存里还没有),会误判成不存在;太短的话防穿透效果打折。60 秒对短链这个场景够用——短链创建后极少会立即被大量访问。

第二,限流。

空值缓存能挡住"同一个 code 被反复打",但挡不住"换着 code 扫"。所以跳转接口上挂了限流,按 IP 和整体 QPS 两个维度做,异常流量直接拒掉。

关于限流我想说一句:它不是一个可选项。空值缓存、布隆过滤器这类手段,防的都是"重复请求同一个不存在的 key";而扫描行为的特征恰恰是"每次都是不同的 key"。只靠缓存层的防护,是挡不住扫描的。

顺便说一个我评估后没做的方案:布隆过滤器。

用布隆过滤器先把不存在的 code 拦掉,是所有讲缓存穿透的文章都会提的方案。我算了一下内存开销:

n = 30 亿条,误判率 p = 1%
需要的位数 m = -n × ln(p) / (ln2)²  ≈ 3.6 GB

3.6GB 只为了拦穿透,占了缓存服务器四分之一的内存。而且布隆过滤器还有个麻烦:数据是动态增长的,新生成的短链要实时加进去,30 亿条数据的布隆过滤器做更新,维护成本不低。

如果只对活跃数据(比如近三个月)做,规模能降到 1 亿条左右,内存开销约 120MB,这个是可以接受的。但当时跳转量还没到需要它的时候,加了反而是过度设计,所以先放着。这是我留给"如果重来"的一条。

七、雪崩:Redis 整体不可用的时候

雪崩说的是缓存大面积失效,所有请求瞬间压到数据库上。

这个系统里有两个可能触发雪崩的场景,处理方式不一样。

场景一:同一时间大批 key 失效。

因为我们不设 TTL,所以这个场景天然不存在。如果哪天改回 TTL 策略,那也要给过期时间加随机扰动(比如 7 天 ± 随机 1 小时),避免同一秒集体过期。

场景二:Redis 实例故障。

这个没法靠策略避免,只能靠架构和预案。

架构上,Redis 做了主从 + 哨兵,主节点挂了自动切换,切换期间有几秒的不可用。

预案上,跳转接口做了降级:Redis 不可用时直接回源数据库,但同时在数据库前面挂一层限流,把并发压到数据库能承受的量级。

这里要坦白一个取舍:降级之后响应会变慢,而且如果流量正好在峰值,就算限流也可能有一部分请求失败。 这是没办法的事——缓存的容量和数据库的容量之间有数量级的差距,Redis 挂了就是扛不住全部流量,只能保证"不整体崩掉"。做过这一层的系统,至少不会因为缓存故障把数据库一起带走。

八、完整的跳转链路

把上面这些拼起来:

用户访问 /{code}
  ↓
限流检查(IP + 全站 QPS)
  ↓
查 Redis:short:link:{code}
  ├─ 命中且非空 → 302 跳转,异步记录点击
  ├─ 命中且为空值 → 404(防穿透)
  └─ 未命中 ↓
       ↓
     查数据库(按 code 唯一索引)
       ├─ 不存在 → 写空值缓存(TTL 60s)→ 404
       └─ 存在 → 写回 Redis(不设 TTL)→ 302 跳转,异步记录点击

有个细节值得单独说:点击数据的记录是异步的,不阻塞跳转。

如果记录下来这一步放在主链路上(哪怕是写 Redis),都会给跳转增加一次网络往返。而点击数据本身是可以容忍少量丢失的——差几十条点击,不影响营销效果的判断。所以我把记录动作丢进队列,主链路只管跳转。

这个取舍很清楚:用户感知得到的延迟,和统计数据的完整性,前者优先。

九、这套设计的效果和代价

上线之后,跳转响应基本稳定在 100 毫秒以内,Redis 的命中率在大部分时间是 90% 以上(活动期间更高,因为流量更集中)。

代价也有,说清楚:

一是响应时间有下限。 就算缓存命中,一次 Redis 查询加一次 302 响应,也是有耗时的。要做到 10 毫秒级,得再往上一层本地缓存。

二是 LRU 淘汰不可控。 不设 TTL 的好处是热点常驻,坏处是我们失去了对"哪些数据会被淘汰"的控制权。如果某个链接的访问模式突然变化(比如一个老活动被重新推广),它要经历一段回源才能重新热起来。这种情况目前靠监控发现,没有更好的办法。

三是空值缓存会占内存。 如果有人持续扫描,空值 key 会不断写入,挤占真正有用的缓存。TTL 设 60 秒是对这个问题的缓解,但不是根治。真正挡住扫描的还是限流。

十、如果重来

会加一层本地缓存。 现在每次跳转都要访问一次 Redis,就算在本机房里,也要一两毫秒。如果在应用进程内用 Caffeine 加一层本地缓存(比如缓存最近访问的 1 万条),热点的响应能压到毫秒级。代价是多实例之间的数据不同步——但对只读且不变的数据来说,这个问题几乎不存在,最多就是某个实例的本地缓存稍旧一点,而值根本不会变。这个方案在这个场景下性价比很高,我当时的判断偏保守了。

会早点把布隆过滤器加上。 现在靠空值缓存 + 限流顶着,能用,但不够优雅。等活跃数据规模明确了,用布隆过滤器把不存在的 code 在缓存层之前就拦掉,可以省掉空值缓存这部分内存和它的副作用。

会给热点 key 加主动探测。 现在热点的识别完全靠 LRU 的自然行为。如果能在监控里加上 key 级别的访问频率统计,就能提前发现"某条链接正在变成热点",做一些主动的预热或保护。这次活动流量都还算平稳,没遇到极端情况,但这个能力迟早要有。

最后

这篇里我写得最多的不是 Redis 怎么用,而是先判断数据会不会变。

只读数据、可变数据,是两套完全不同的缓存方案。前者不用管一致性,重点在容量和防护;后者的一致性方案能写一本书。判断错了方向,后面怎么优化都是白费。

下一篇我想写点击数据的存储结构。那部分跟这篇的缓存逻辑完全不是一回事——短链主表是典型的"小而热",而点击数据是"大而冷",数据量差了几个数量级,索引设计、归档策略都得重新想。那才是这个系统里真正麻烦的地方。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:短链系统的缓存设计:日均 50 万次跳转,怎么把响应稳在 100 毫秒以内
▶ 本文链接:https://www.huangleicole.com/backend-related/126.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏