Thundering Herds:可扩展性杀手

发布: (2026年1月1日 GMT+8 16:00)
13 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您想要翻译的文章正文内容,我将把它翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

什么是 Thundering Herd?

从本质上讲,Thundering Herd 发生在大量进程在等待某个事件,而当事件发生时,它们全部同时被唤醒——但实际上只有一个进程能够处理该事件。

该术语最初来源于操作系统内核调度,但现代 Web 工程师最常将其遇到的情形称为 Cache Stampede(缓存抢夺)。

金州

  1. 你有一个在 Redis 中缓存的高流量接口。 一切都很快。
  2. 过期: 缓存的 TTL(存活时间)归零。
  3. 抢夺: 5 000 个并发用户刷新页面。它们全部命中缓存未命中。

崩溃

所有 5 000 个请求同时击中你的数据库,以重新生成相同的数据。数据库负载激增,延迟飙升,服务宕机。

注意: 5 000 只是一个任意数字。实际数字取决于你的系统容量。

群体的其他表现

场景会发生什么
认证服务宕机主认证服务宕机 5 分钟。所有其他服务不断重试。认证服务恢复后,立即受到大量重试请求的冲击 → 重试风暴
共享访问令牌50 个微服务共享一个机器对机器的 JWT。令牌在同一秒钟同时过期,导致所有服务向身份提供者“冲刺”请求新令牌。
午夜定时任务00 * * * * 上安排了一个大型清理任务,分布在 100 个服务器节点。凌晨 12:00,所有节点同时访问数据库或共享存储。
移动应用发布你部署了一个新的 500 MB 移动二进制文件。通知 100 万用户立即下载。首批数千个请求未命中 CDN 边缘,而是一次性击中源服务器,可能导致存储层崩溃。

识别群体

  • 缓存未命中与延迟的关联 – 缓存未命中率的突然飙升恰好与 p99 数据库延迟的激增同步。
  • 连接池耗尽 – 数据库连接池在毫秒内达到最大限制。
  • CPU 上下文切换 – “系统 CPU”或上下文切换出现大幅激增,表明成千上万的线程争夺同一锁。
  • 错误日志 – 成千上万的 “lock wait timeout” 或 “connection refused” 错误在短时间内集中出现。

请求折叠(Promise 记忆化)

请求折叠确保对于任意给定资源 一次只能有一个上游请求处于活跃状态

  • 如果 请求 A 已经在从数据库获取 user_data_123,则 请求 B、C、D 应该 订阅 A 的结果,而不是自行发起获取。

忙等陷阱

一个天真的锁可能会产生二次羊群效应:如果有 4 999 个请求在等待同一次数据库调用,它们可能每 10 ms 轮询一次 “是否已经准备好?”,从而消耗 CPU 并在应用内存中形成新的羊群。

转向基于事件的通知

推/轮询 模型切换为 拉/通知 模型:

  • 与其不断询问 “是否完成?”,等待的请求应当 休眠,并在数据准备好时 被唤醒

在 Python 或 Node.js 中,这通常由 PromisesFutures 原生支持。其他语言可以使用 条件变量(Condition Variables)通道(Channels) 实现。

使用 asyncio 的 Python 示例

下面是一个最小示例,演示了在单台服务器上进行请求合并。“跟随者” 只会 await 一个 Event,在等待 领袖 完成工作时 不消耗 CPU

import asyncio

class RequestCollapser:
    def __init__(self):
        # Stores the events for keys currently being fetched
        self.inflight_events = {}
        self.cache = {}

    async def get_data(self, key):
        # 1️⃣ Check if data is already in cache
        if key in self.cache:
            return self.cache[key]

        # 2️⃣ Check if someone else is already fetching it
        if key in self.inflight_events:
            print(f"Request for {key} joining the herd (waiting)...")
            event = self.inflight_events[key]
            await event.wait()               # Zero CPU usage while waiting
            return self.cache.get(key)

        # 3️⃣ Be the "Leader"
        print(f"Request for {key} is the LEADER. Fetching from DB...")
        event = asyncio.Event()
        self.inflight_events[key] = event

        try:
            # Simulate DB fetch
            await asyncio.sleep(1)
            data = "Fresh Data"
            self.cache[key] = data
            return data
        finally:
            # 4️⃣ Notify the herd
            event.set()                       # Wakes up all waiters instantly
            del self.inflight_events[key]

该示例在 单服务器 环境下运行完美。但如果你有 100 台应用服务器 呢?仍然会有 100 个“领袖”可能会猛烈冲击数据库。

扩展跨多个实例的请求折叠

要在整个集群中实现折叠,需要一个 分布式协调层(例如 Redis、etcd、Zookeeper,或专用锁服务)。模式保持不变:

  1. 尝试获取键对应的分布式锁
  2. 如果获取到锁 → 你就是 leader;从数据库获取数据,写入缓存,然后 释放锁发布通知(例如通过 Pub/Sub)。
  3. 如果未能获取锁 → 订阅通知频道并 等待结果

下面是使用 Redis 及其 Pub/Sub 功能的高级示例(为说明清晰采用伪代码):

import aioredis
import asyncio

REDIS_LOCK_TTL = 30          # seconds
CHANNEL_PREFIX = "herd:"

async def get_data_distributed(redis, key):
    cache_key = f"cache:{key}"
    lock_key  = f"lock:{key}"
    channel   = f"{CHANNEL_PREFIX}{key}"

    # 1️⃣ 先尝试缓存
    cached = await redis.get(cache_key)
    if cached:
        return cached

    # 2️⃣ 尝试通过获取锁成为 leader
    is_leader = await redis.set(lock_key, "1", nx=True, ex=REDIS_LOCK_TTL)
    if is_leader:
        # Leader:从 DB 获取数据,填充缓存,通知 herd
        try:
            data = await fetch_from_db(key)          # 你的 DB 调用
            await redis.set(cache_key, data, ex=300) # 缓存 5 分钟
            await redis.publish(channel, data)       # 唤醒 follower
            return data
        finally:
            await redis.delete(lock_key)             # 释放锁
    else:
        # Follower:等待通知
        pubsub = redis.pubsub()
        await pubsub.subscribe(channel)

        async for message in pubsub.listen():
            if message["type"] == "message":
                return message["data"]

关键要点

  • 锁保证 每个键在整个集群中只有一个 leader
  • Follower 在 Pub/Sub 上阻塞,等待期间不消耗 CPU。
  • 锁设置了 TTL,以防 leader 崩溃导致死锁。
  • 通知频道可以是简单的字符串;负载可以是新数据本身,也可以是一个 “ready” 标记,提示 follower 从缓存读取。

添加抖动以防止同步重试

即使使用请求合并,当服务暂时不可用时仍可能出现 重试风暴。在退避间隔中加入 随机抖动 可以将重试分散到不同的时间点。

import random
import asyncio

async def retry_with_jitter(coro, max_attempts=5, base_delay=0.5):
    for attempt in range(1, max_attempts + 1):
        try:
            return await coro()
        except Exception as e:
            if attempt == max_attempts:
                raise
            # 指数退避 + 抖动
            jitter = random.uniform(0, base_delay)
            delay = (2 ** (attempt - 1)) * base_delay + jitter
            await asyncio.sleep(delay)
  • 指数退避 可防止对目标造成过大压力。
  • 抖动(随机性)确保来自多个客户端的重试不会再次对齐。

要点

  1. 提前识别群体效应特征(缓存未命中、连接池激增、CPU 上下文切换、突发错误)。
  2. 通过进程内或分布式请求合并来折叠重复工作。
  3. 使用事件驱动的通知(Futures、Promises、Condition Variables、Pub/Sub)而不是忙等。
  4. 在任何重试逻辑中加入抖动,以避免同步风暴。
  5. 进行大规模测试——模拟成千上万的并发请求,以验证你的缓解措施在负载下是否有效。

通过将 请求合并抖动重试 相结合,你可以将潜在的“惊群效应”转化为行为良好、具备弹性的系统。 🚀

分布式锁、抖动和请求合并

当多个节点尝试为同一个键成为 leader 时,可能会 一次性冲击数据库。这在某些数据库上会导致严重问题。为防止出现这种极端情况,请使用 分布式锁,确保在整个集群中只有一个节点成为给定键的 leader。

大规模解决方案

技术工作原理适用场景
Distributed Locks (Redis/Etcd)使用类似 Redlock 的库,在整个集群中保证每个键只有一个 leader。需要在多个实例之间进行严格协同的情况。
Singleflight Pattern (Go)golang.org/x/sync/singleflight 包在本地合并并发调用。将其与分布式锁结合,可同时保护应用内存和数据库。Go 服务中对单一热点键的高并发访问。
Jitter引入有意的随机性,以错开重试和 TTL 失效的时间。防止出现“惊群效应”导致的突发流量峰值。
X‑Fetch (Probabilistic Refresh)在缓存条目即将过期前稍作刷新,使用随机的“掷骰子”决定哪个请求执行刷新操作。对低延迟、关键业务数据的高可靠性需求。

Source:

抖动:错开执行

当一个请求发现某个资源已经在 折叠(即已有另一个请求在获取它)时,不要在固定间隔上重试。

  • 错误做法:50 ms 重试一次。
  • 正确做法:50 ms + random(0, 20 ms) 重试一次。

TTL 示例

不要对大量键设置硬性的 TTL。

  • 问题: 更新 10 000 个商品并将每个商品的过期时间设为恰好 1 小时,会在恰好 60 分钟后引发灾难。
  • 解决方案:
TTL = 3600 + (rand() * 120)   // 将过期时间分散到 2 分钟的窗口内

X‑Fetch:概率性缓存刷新

不要等缓存过期后再刷新,而是使用抖动在 即将 过期时触发刷新。

  1. 当 TTL 接近零时,每个请求进行一次 “掷骰子”。
  2. 如果掷出的数值较低,该请求成为 leader,重新获取数据并重置缓存。
  3. 其他所有请求继续收到 仍然安全的 旧数据。

Python 示例实现

import time
import random

async def get_resilient_data(key):
    cached = await cache.get(key)

    should_refresh = False

    # 1️⃣  缓存未命中
    if cached is None:
        should_refresh = True
    else:
        # 2️⃣  距离过期的剩余时间
        time_remaining = cached.expiry - time.time()

        # 3️⃣  已过期或概率性刷新
        if time_remaining < 0:  # 占位条件
            should_refresh = True

下次设置缓存 TTL 时,问自己:
“如果 10 000 个人同时请求这个,会发生什么?”
如果答案是 “他们都会等数据库”, 那就该加入一些抖动了。

想要更具弹性的分布式系统吗?

如果你喜欢这次深入探讨,请关注以获取更多关于构建稳健、可扩展架构的见解。

Aonnis Valkey Operator – 在 Kubernetes 上部署和管理高性能的兼容 Valkey 的集群,内置可靠性和可扩展性的最佳实践。

🚀 惊喜: 限时免费。

🔗 访问 www.aonnis.com 了解更多。

Back to Blog

相关文章

阅读更多 »

RGB LED 支线任务 💡

markdown !Jennifer Davishttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%...

Mendex:我为何构建

介绍 大家好。今天我想分享一下我是谁、我在构建什么以及为什么。 早期职业生涯与倦怠 我在 17 年前开始我的 developer 生涯……