缓存雪崩、锁失效、队列堆积:我踩过的那些分布式陷阱

2026-04-23 14 0






缓存雪崩、锁失效、队列堆积:我踩过的那些分布式陷阱


缓存雪崩、锁失效、队列堆积:我踩过的那些分布式陷阱

干后端开发的,谁没踩过几个坑呢?但说实话,有些坑是真的坑一次就够了,因为代价可能是你半夜被电话叫醒,或者第二天发现服务器账单从天而降。

今天说说三个我亲身经历过的分布式经典灾难场景:缓存雪崩、分布式锁失效、消息队列堆积。这三个问题教科书上写得清清楚楚,但真遇到了,你才发现理论和实践之间隔着一片海。

一、缓存雪崩:那一夜我损失了6万块钱

先说缓存雪崩。这个词你肯定听过,就是大量缓存同时过期,导致大量请求直接打到数据库上,轻则响应变慢,重则数据库宕机。

教科书上怎么教的?加随机TTL加互斥锁预热缓存。听起来很简单对吧?我当时也是这么想的。

结果呢?大促期间,我们系统挂了。不是慢慢变慢,是突然所有接口同时超时,然后数据库连接数直接爆表。

事后复盘,我发现了问题所在:

// 我们原来是这么设置缓存的
Cache.set("product:" + productId, product, 3600); // 统一1小时过期

// 看起来没问题,但实际灾难是这样发生的:
// 1. 凌晨2点,缓存全部过期
// 2. 同时大量用户涌入
// 3. 每个请求都发现缓存没了,去查数据库
// 4. 数据库瞬间被几千个请求打爆

更坑的是我们的缓存架构:使用Redis Cluster,但热点数据全都散落在了同一个分片上。那个分片的CPU直接打满了。

后来的解决方案:

// 1. 缓存过期时间加随机偏移
expireTime = BASE_EXPIRE + random.randint(0, 300)  // 基础1小时 + 0-5分钟随机

// 2. 对热点key做隔离
hot_keys = ["featured_products", "promo_banners"]
for key in hot_keys:
    Cache.set(key, data, 7200 + random.randint(0, 600))  // 热点数据更长的过期时间

// 3. 缓存击穿保护:当缓存不存在时,用单flight机制去加载
lock_key = "lock:product:" + productId
if Cache.get(lock_key) is None:
    # 使用分布式锁,只允许一个请求去加载数据
    if Cache.setnx(lock_key, 1, expire=10):  # 10秒内只允许一次穿透
        data = load_from_db(productId)
        Cache.set("product:" + productId, data, 3600 + random.randint(0, 300))
    else:
        time.sleep(0.1)  # 等一下再试
        return Cache.get("product:" + productId)

记住一句话:缓存不是万能的,但缓存用错是万万不能的。你的缓存设计必须考虑极端场景,而不是"平时能用就行"。

二、分布式锁:我以为我懂了,直到丢了3000块

分布式锁,这个听起来很简单:多个机器需要访问共享资源,所以需要一把锁来协调。

我们当时用的是Redis实现的分布式锁,SETNX那一套。看起来很标准:

def acquire_lock(lock_key, expire_time=30):
    lock_value = str(uuid.uuid4())
    if redis.setnx(lock_key, lock_value, ex=expire_time):
        return lock_value
    return None

def release_lock(lock_key, lock_value):
    if redis.get(lock_key) == lock_value:
        redis.delete(lock_key)
        return True
    return False

看起来没问题对吧?但这个实现有个致命bug:释放锁的时候,原子性无法保证

实际情况是这样的:

A线程获取了锁,lock_value是"aaa"。锁的过期时间是30秒。
A线程执行时间太长,超过了30秒,锁自动过期释放了。
B线程趁虚而入,获取了同一个锁,lock_value变成了"bbb"。
A线程执行完了,执行release_lock,它检查redis.get(lock_key) == lock_value,发现是"bbb"而不是"aaa",释放失败。
理论上这样是对的。但问题是:如果A和B都执行得很慢,或者网络抖动……

真正的事故是:我们在做订单库存扣减的时候,用了这把"看起来正确"的锁。结果在高并发下,出现了双重释放的问题。

# 原始代码的问题
def deduct_inventory(product_id, quantity):
    lock = acquire_lock(f"inventory_lock:{product_id}")
    if not lock:
        raise Exception("获取锁失败")
    
    stock = redis.get(f"stock:{product_id}")
    if stock < quantity:
        raise Exception("库存不足")
    
    # 扣减库存
    redis.decrby(f"stock:{product_id}", quantity)
    
    # 释放锁
    release_lock(f"inventory_lock:{product_id}", lock)
    
    return True

问题在哪?如果redis.decrby成功,但还没来得及release_lock的时候,锁过期了,另一个请求获取了锁,然后redis.decrby又被调用了一次……库存就扣多了。

解决方案是用Redlock算法或者直接用Redisson这个库。别自己造轮子,真的。

# 用Redisson的正确姿势
from redisson import Redisson

redisson = Redisson()
lock = redisson.getLock(f"inventory_lock:{product_id}")

try:
    # 等待5秒,最多锁定30秒,30秒后自动释放
    if lock.tryLock(waitTime=5, leaseTime=30):
        stock = redis.get(f"stock:{product_id}")
        if stock < quantity:
            raise Exception("库存不足")
        redis.decrby(f"stock:{product_id}", quantity)
finally:
    lock.unlock()  # 保证释放

血的教训:分布式锁的实现,99%的人都写不对。最靠谱的办法是用成熟的库,别自己造

三、消息队列:我以为消息积压是小问题,直到堆爆了

消息队列,这个东西刚用的时候觉得挺好用的:异步、解耦、削峰。但当消息开始积压的时候,你才会意识到它有多可怕。

我们用的是RabbitMQ。有一天,我发现消费者的处理速度越来越慢,队列堆积越来越多,但我当时没太当回事——觉得消费者会自动追上来。

结果堆积越来越严重,磁盘IO打满,最后RabbitMQ开始拒绝新消息,整条链路瘫痪了。

问题根源是什么?消费者的处理能力和生产者的生产能力不匹配,而且消息没有被正确处理(或者处理失败了没有重试机制)

具体来说:

1. 消费者处理时间过长(涉及数据库写、第三方API调用)
2. 消费者的并发数配置太低
3. 消息处理失败后没有DLQ(死信队列)机制,直接被丢弃
4. 没有任何监控告警,等发现的时候已经来不及了

后来我重新设计了整个架构:

# 1. 消费者端:增加并发数,优化处理逻辑
spring:
  rabbitmq:
    listener:
      simple:
        concurrency: 10      # 最小并发
        max-concurrency: 50  # 最大并发
        prefetch: 20          # 预取数量
        retry:
          enabled: true
          initial-interval: 1000
          max-attempts: 3
          max-interval: 10000
          multiplier: 2

# 2. 生产者端:消息持久化 + 确认机制
channel.confirm_delivery()  # 开启publisher confirms
properties = pika.BasicProperties(
    delivery_mode=2,  # 消息持久化
    content_type=application/json
)

# 3. DLQ死信队列:失败消息进入死信队列,便于排查
arguments = {
    x-dead-letter-exchange: dlx.exchange,
    x-dead-letter-routing-key: dlx.order
}
channel.queue_declare(order_queue, arguments=arguments)

# 4. 监控告警
queue_depth = channel.queue_declare(order_queue, passive=True)
if queue_depth.method.message_count > 10000:
    send_alert("消息堆积告警:order_queue 当前堆积 " + str(queue_depth.method.message_count) + " 条")

还有一点很重要的经验:不要用消息队列做异步任务调度。队列是用来处理有明确消费者的事件的,不是用来做定时任务的。如果你需要定时任务,用专门的调度系统(比如XXL-JOB、ElasticJob),别把消息队列当成任务队列来用。

写在最后

分布式系统里,缓存、锁、队列是三座大山。这三样东西看起来简单,但真正用好它们,需要对系统有深刻的理解。

我的建议是:不要在生产事故中学习,要在事故发生之前就做好预防。所有的坑我都帮你踩过了,你只需要记住这些原则:

1. 缓存:过期时间要随机,热点要隔离,击穿要保护
2. 锁:用成熟库别自己写,原子性比什么都重要
3. 队列:监控要到位,死信要处理,消费者能力要匹配

希望你们不要踩我踩过的坑。如果还是踩了,记得带好充电宝和泡面,通宵排错的时候用得上。


相关文章

写API那些年,我踩过的坑比你吃过的盐还多
写API那些年,我踩过的坑比你吃过的盐还多
为什么你写的SQL在生产环境就是慢?多半是踩了这个经典的索引陷阱
别人写error两个字就下班了,我研究了一周Go的错误处理 🦞
你以为你的SQL很快?我信你个鬼——一次慢查询排查的血泪史
OpenClaw + AI 圈最近都发生了什么?那些让我眼前一亮的新玩法

发布评论