流量洪峰:热门短链接带来的流量冲击

场景

想象一下这个场景:

  1. 某明星突然宣布结婚 📱

    • 粉丝疯狂转发相关链接
    • 一小时内点击量从 1000 爆发到 1000万+
  2. 重大新闻发布 📰

    • 权威媒体报道重要新闻
    • 相关短链接在社交媒体病毒式传播
  3. 限时优惠活动 🎁

    • 网络红人发布限时折扣
    • 短链接瞬间被几十万人同时点击
  4. 热门话题讨论 💬

    • 热点话题引发热议
    • 相关讨论和分享激增

真实案例:

  • 某电商平台黑五活动期间,短链接点击量单日突破 1 亿次
  • 某新闻客户端热门新闻的短链接,3 分钟内被访问 500 万次
  • 某娱乐明星绯闻相关链接,2 小时内访问量破千万

问题:系统崩溃的痛苦

当流量突然暴增时,我们的短链接系统会面临什么?

现状问题分析

让我们先看看我们的”完美”系统是如何设计的:

落地思路

  • 这个「完美」系统的跳转链路只有一步:拿短码查 urls 表,靠 idx_short_code 定位到那一条记录,取出目标长 URL 直接返回 302。
  • 每一次点击都对应一次真实的数据库查询,前面没有任何缓存或旁路能挡掉这次查询。
  • 应用实例直接连 MySQL,连接池是链路上唯一的闸门——请求量涨上来时它最先被占满。

这个简单系统在流量洪峰面前的表现:

落地思路

  • 洪峰到来时,连接池里的连接被排队占满,新请求拿不到连接就只能等,超时后报 “too many connections”。
  • 排在后面的请求要等前面的慢查询做完,响应时间被拉长到秒级,用户点击后长时间无反应。
  • 处理速度追不上到达速度时,请求在服务内部持续堆积,内存占用一路上涨,最后被 OOM Killer 直接干掉。
  • 整条链路没有任何限流点:后端已经被压垮了,入口层还在源源不断地把请求送进来。

具体故障表现

故障类型具体表现用户感受
数据库连接池耗尽“too many connections” 错误页面无法打开
响应时间过长每次请求等待 5-10 秒点击后长时间无反应
请求队列堆积请求积压在队列中点击后数分钟才跳转
系统内存溢出OOM Killer 杀死进程服务完全不可用

应对策略:层层递进的防御体系

策略 1:缓存层防护 🛡️

首先,我们需要在最前面加上缓存层:

落地思路

  • 在跳转链路最前面插一层 Redis,存「短码 → 目标长 URL」的映射,命中缓存就直接返回 302,不碰数据库。
  • 缓存是只读旁路:缓存里没有也不影响主流程,查不到就回落到数据库查询再回填。
  • 这一层把绝大多数请求挡在 MySQL 之前,数据库只处理缓存未命中的那部分流量。
  • 兜底是 Redis 不可用时降级成直查数据库,而不是让整个跳转功能直接不可用。

缓存策略配置:

落地思路

  • 键就用短码本身,不加额外前缀,值存目标长 URL,TTL 按热点时长来定。
  • 写入顺序是「先更新数据库,再删缓存」:直接改缓存里的值会和数据库产生不一致。
  • 热点短码的 TTL 要比普通链接长,让官宣、抽奖这类短链接在洪峰期一直留在缓存里。
  • 兜底是给「查不到」的短码也缓存一个短 TTL 的空标记,防止不存在的短码被反复打到数据库上。

策略 2:负载均衡 ⚖️

单个服务器扛不住?那就用多个!

落地思路

  • 在应用前面放一组反向代理(Nginx 或云负载均衡),把请求按连接数或轮询分给多个应用实例。
  • 代理侧限制每个上游的并发连接数,避免某一个实例被慢请求占满把整组拖下水。
  • 代理必须透传 X-Forwarded-For 和 X-Real-IP,否则后端拿到的全是代理 IP,限流和访问日志都会失真。
  • 某个实例挂掉时,代理把它摘掉、流量转给健康实例,用户感知到的是偶发失败而不是整站不可用。

策略 3:流量削峰 📊

防患于未然,在流量达到峰值前就采取措施:

落地思路

  • 点击事件不直接同步写 MySQL:入口只做「短码 → 长 URL」的解析,把点击记录投进消息队列,由消费者批量落库。
  • 队列把「瞬时到达的点击」和「数据库能消化的写入速度」解耦,突发流量先在队列里排队。
  • 队列积压超过阈值时先告警、再降速消费,宁可让统计数据延迟,也不能把数据库写垮。
  • 跳转本身不依赖这条链路,所以队列积压只影响统计口径,不影响用户能不能点开链接。

策略 4:降级机制 🔧

系统扛不住时,优雅降级:

落地思路

  • 数据库不可用时降级成「只读缓存」:缓存里有就直接 302,没有就返回明确的服务异常页,而不是让请求长时间挂起。
  • 给数据库调用设置超时上限,超过就快速失败,把慢查询占住的连接尽快还给连接池。
  • 熔断器统计下游失败比例,超过阈值就打开、直接跳过真实调用,快速失败比排队等超时更友好。
  • 降级期间只保留核心的「短码 → 长 URL」跳转,统计上报这类非核心能力先关掉。

性能对比测试

让我们对比不同策略的效果:

落地思路

  • 对比方式是同一台压测机上逐级叠加策略:裸查数据库 → 加缓存 → 加负载均衡 → 加队列削峰,每一级记录吞吐和响应时间。
  • 关注三组数据:缓存命中率、平均响应时间和错误率,跟本章小结里的目标值(命中率 > 80%、响应 < 100ms、错误率 < 1%)对齐。
  • 洪峰用突增流量模拟,验证的是系统从峰值回落后能不能自动恢复,而不只是峰值那一瞬间的极限吞吐。
  • 结论看趋势:缓存层对数据库负载的削减最直接,削峰和降级决定的是系统会不会崩。

实战策略拆解

Redis 缓存配置

验证要点

  • 命中率用 INFO stats 里的 keyspace_hits 和 keyspace_misses 相除得到,这是判断缓存层有没有生效的第一手数据。
  • 再用 TTL <短码> 确认热点短码的剩余有效期没有比预期更早归零——TTL 设短了,命中率会莫名其妙往下掉。
  • 抽查一个短码的缓存值(GET <短码>),确认里面存的是目标长 URL 而不是整个 302 响应,否则回源时拿到的不是想要的东西。
  • 期望看到的命中率达到本章小结里的 80% 目标;低于这个数,先查 TTL 和键的过期策略。

入口层限流策略

入口配置要点

  • 入口层负责接住流量,并把请求转发到后端服务。
  • 限流和连接控制放在入口层,可以更早保护后端。
  • 代理配置的重点是保留必要请求信息,方便后端识别来源和生成日志。

本章小结

面对流量洪峰,我们的防御体系包括:

防御层作用效果
缓存层减少数据库查询80%+ 命中率,降低 80% 数据库负载
负载均衡分散请求压力线性扩展,支持更多并发
流量削峰防止系统过载优雅降级,避免崩溃
熔断机制快速故障转移快速恢复,减少影响范围

关键指标

指标目标值监控方法
缓存命中率> 80%Redis INFO
平均响应时间< 100ms应用监控
错误率< 1%日志分析
服务器负载< 70%系统监控

设计原则

  1. 缓存优先:尽量使用缓存,减少数据库访问
  2. 降级处理:宁可降级也不崩溃
  3. 监控预警:及时发现异常,主动处理
  4. 弹性伸缩:根据流量动态扩缩容

下一步

我们解决了流量洪峰问题,但还有一个关键挑战:如何在分布式环境中生成全局唯一的短链接 ID?

答案:分布式 ID 生成器。我们将在下一章深入探讨。


练习题

练习 1

如果缓存命中率只有 60%,流量从 1000 QPS 增加到 10000 QPS,数据库负载增加了多少?

流量测算

解题思路:

这题问的是”负载增加了多少”,所以不能用总量算,必须算增量:两次流量下的数据库查询数相减。关键是缓存命中率只对未命中的那 40% 请求起作用,命中那 60% 根本不落到数据库。

详细步骤:

计算过程:

  1. 无缓存时的数据库负载

    • 1000 QPS:1000 次数据库查询/秒
    • 10000 QPS:10000 次数据库查询/秒
    • 增长:10000 - 1000 = 9000 次/秒
  2. 60% 缓存命中率时的数据库负载

    • 1000 QPS:1000 × (1 - 0.6) = 400 次数据库查询/秒
    • 10000 QPS:10000 × (1 - 0.6) = 4000 次数据库查询/秒
    • 增长:4000 - 400 = 3600 次/秒
  3. 负载对比

    • 无缓存:数据库负载增长 9000 次/秒
    • 有缓存:数据库负载增长 3600 次/秒
    • 减少:9000 - 3600 = 5400 次/秒(减少 60%)

最终答案:

即使只有 60% 的缓存命中率,数据库负载仍然减少了 60%。从 9000 次增长减少到 3600 次增长。

练习 2

熔断器机制的原理是什么?为什么要在系统中实现熔断?

高并发

解题思路:

熔断器要解决的不是”下游会不会挂”,而是”下游挂了之后我这边也被拖死”这个问题。所以要理解它其实是个状态机,围绕关闭、打开、半开三态的切换条件去记,而不是只记它的存在。

详细步骤:

熔断器原理:

熔断器模式类似于家庭电路中的保险丝,当电路过载时自动切断电路,保护设备和安全。

工作机制:

  1. 关闭状态(Closed):正常调用,统计失败次数
  2. 打开状态(Open):直接返回降级结果,不执行真实调用
  3. 半开状态(Half-Open):允许少量请求尝试恢复

为什么要实现熔断:

  1. 防止级联故障🔥

    • 当下游服务故障时,防止大量请求堆积
    • 避免故障向整个系统扩散
  2. 快速恢复⚡

    • 避免等待超时(可能 30-60 秒)
    • 立即降级,用户体验更好
  3. 资源保护💪

    • 避免浪费资源在已故障的服务上
    • 让健康的服务能正常处理请求
  4. 故障隔离🚧

    • 将故障服务隔离,不影响其他功能
    • 便于运维人员快速定位问题

实际案例:

  • 微服务调用中,A 服务调用 B 服务
  • B 服务故障时,大量请求超时
  • 使用熔断器后,直接返回缓存数据
  • A 服务仍然可以正常工作,用户体验不受影响

最终答案:

熔断器类似家庭电路的保险丝,下游过载时自动切断调用以保护自身。它有三个状态:关闭状态正常调用并统计失败次数;打开状态直接返回降级结果、不再发起真实调用;半开状态放少量请求试探下游是否恢复。之所以要实现熔断,是因为它能防止级联故障(下游故障时不让请求堆积扩散)、实现快速恢复(不等 30 到 60 秒超时而是立即降级)、保护资源(不把资源浪费在已故障的服务上)、隔离故障(便于运维定位)。典型场景是 A 服务调用 B 服务,B 故障时大量请求超时,加了熔断后 A 直接返回缓存数据,用户体验不受影响。

练习 3

设计一个滑动窗口限流算法,如何实现?

限流

解题思路:

限流算法要对比着固定窗口来理解:固定窗口的毛病是边界处会放行双倍流量,而滑动窗口用”最近 N 秒”这个连续区间来计数,天然没有这个漏洞。实现上要抓住”窗口状态存在哪里、怎么过期”两个点。

详细步骤:

滑动窗口限流设计:

落地思路

  • 滑动窗口把时间切成一格一格的小窗口,每个窗口维护一个计数,限流判断时把当前窗口和前面若干个窗口的计数相加。
  • 相加覆盖的范围就是「最近 N 秒」,所以计数既能反映瞬时峰值,也不会像固定窗口那样在边界处放行双倍流量。
  • 在 Redis 里可以用有序集合实现:每个请求记一个带时间戳的成员,统计前先把超出窗口范围的成员删掉再计数。
  • 兜底是给窗口状态设置过期时间:窗口内没有任何请求就自动清掉,不留下永久增长的键。

滑动窗口 vs 固定窗口:

特性滑动窗口固定窗口
精度精确(1秒内)粗略(60秒内)
实现较复杂(需要维护时间戳)简单(计数器)
性能稍低(时间戳操作)较高(简单计数)
公平性更公平不公平(边界问题)

最终答案:

滑动窗口把时间切成一格一格的小窗口,每个窗口维护一个计数,限流判断时把当前窗口和前面若干个窗口的计数相加,覆盖范围正好是最近 N 秒,既能反映瞬时峰值,又不会像固定窗口那样在边界放行双倍流量。在 Redis 里可用有序集合实现:每个请求记一个带时间戳的成员,统计前先删掉超出窗口范围的成员再计数,并给窗口状态设置过期时间,空窗口自动清理。相比固定窗口,滑动窗口精度更高(1 秒内而不是 60 秒内)、更公平,但实现较复杂、性能稍低,需要额外维护时间戳。

发现内容有问题?

告诉我们哪里需要改进。