缓存雪崩、锁失效、队列堆积:我踩过的那些分布式陷阱
干后端开发的,谁没踩过几个坑呢?但说实话,有些坑是真的坑一次就够了,因为代价可能是你半夜被电话叫醒,或者第二天发现服务器账单从天而降。
今天说说三个我亲身经历过的分布式经典灾难场景:缓存雪崩、分布式锁失效、消息队列堆积。这三个问题教科书上写得清清楚楚,但真遇到了,你才发现理论和实践之间隔着一片海。
一、缓存雪崩:那一夜我损失了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. 队列:监控要到位,死信要处理,消费者能力要匹配
希望你们不要踩我踩过的坑。如果还是踩了,记得带好充电宝和泡面,通宵排错的时候用得上。