Thundering Herds:可扩展性杀手
Source: Dev.to
请提供您想要翻译的文章正文内容,我将把它翻译成简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!
什么是 Thundering Herd?
从本质上讲,Thundering Herd 发生在大量进程在等待某个事件,而当事件发生时,它们全部同时被唤醒——但实际上只有一个进程能够处理该事件。
该术语最初来源于操作系统内核调度,但现代 Web 工程师最常将其遇到的情形称为 Cache Stampede(缓存抢夺)。
金州
- 你有一个在 Redis 中缓存的高流量接口。 一切都很快。
- 过期: 缓存的 TTL(存活时间)归零。
- 抢夺: 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 中,这通常由 Promises 或 Futures 原生支持。其他语言可以使用 条件变量(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,或专用锁服务)。模式保持不变:
- 尝试获取键对应的分布式锁。
- 如果获取到锁 → 你就是 leader;从数据库获取数据,写入缓存,然后 释放锁 并 发布通知(例如通过 Pub/Sub)。
- 如果未能获取锁 → 订阅通知频道并 等待结果。
下面是使用 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)
- 指数退避 可防止对目标造成过大压力。
- 抖动(随机性)确保来自多个客户端的重试不会再次对齐。
要点
- 提前识别群体效应特征(缓存未命中、连接池激增、CPU 上下文切换、突发错误)。
- 通过进程内或分布式请求合并来折叠重复工作。
- 使用事件驱动的通知(Futures、Promises、Condition Variables、Pub/Sub)而不是忙等。
- 在任何重试逻辑中加入抖动,以避免同步风暴。
- 进行大规模测试——模拟成千上万的并发请求,以验证你的缓解措施在负载下是否有效。
通过将 请求合并 与 抖动重试 相结合,你可以将潜在的“惊群效应”转化为行为良好、具备弹性的系统。 🚀
分布式锁、抖动和请求合并
当多个节点尝试为同一个键成为 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:概率性缓存刷新
不要等缓存过期后再刷新,而是使用抖动在 即将 过期时触发刷新。
- 当 TTL 接近零时,每个请求进行一次 “掷骰子”。
- 如果掷出的数值较低,该请求成为 leader,重新获取数据并重置缓存。
- 其他所有请求继续收到 仍然安全的 旧数据。
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 了解更多。