队列、重试与幂等 · 系统设计
请求来得比你处理得还快时,会发生什么?
课程目录为什么需要它
工作很少是均匀到来的。队列让服务先吸收一波突发,之后再慢慢消化;也让慢任务(发邮件、处理视频)在后台运行。但一个无限增长的队列只会把过载变成漫长的等待,而草率重试的客户端会让故障更严重。这些模式能让一个繁忙的系统稳住。
直觉理解
队列。 生产者放入工作,工作者把它取走。每一秒,积压量的变化是 到达数 − 处理数:
- 短暂超出容量的突发会被吸收,然后慢慢消化
- 如果到达数一直高于容量,积压会永远增长,等待时间也一样(大约是 积压量 ÷ 处理速度)
所以要给队列设上限:满了就快速拒绝新工作。这种「慢一点」的信号叫做背压。
重试。 很多故障是暂时的,所以重试有用,但不能一窝蜂:
- 指数退避:等待
base × 2^尝试次数,并设上限(0.5 秒、1 秒、2 秒、4 秒……) - 抖动(jitter):在这个延迟里随机取一部分来等,这样一千个客户端就不会在同一瞬间重试
幂等性。 一个被重试的「扣款 30 元」不能扣两次。客户端随请求发送一个唯一的幂等键;服务器记住已经处理过的键,不再重复执行。
定义
消息队列
A buffer where producers put work and workers take it, so the two can run at different speeds.生产者放入工作、工作者取出工作的缓冲区,让两边可以按不同的速度运行。
背压
Pushing back on producers (for example by rejecting work) when consumers can't keep up.当消费者跟不上时,反过来对生产者施压(比如拒绝新工作)。
指数退避
Waiting twice as long after each failed attempt, up to a cap.每次失败后等待的时间翻倍,直到一个上限。
幂等键
A unique id sent with a request so the server can recognise a retry and not do the work twice.随请求发送的唯一 id,让服务器能认出重试,不把工作做两遍。
示例
积压的形成与消化
动手试试工作者每秒处理 8 个;来了一波每秒 12 个的突发。
arrivals = [5, 12, 12, 12, 3, 0, 0]
per_second = 8
backlog = 0
for second, n in enumerate(arrivals):
backlog += n
backlog -= min(backlog, per_second)
print(second, backlog)0 0 1 4 2 8 3 12 4 7 5 0 6 0
- L5新的工作加入队列。
- L6工作者最多取走 8 个,但不会超过现有的数量。
- L7忙碌的每一秒多出 4 个,积到 12 个,突发结束后再消化掉。
带上限的退避
动手试试每次尝试延迟翻倍,直到 8 秒为止。
base, cap = 0.5, 8.0
for attempt in range(7):
delay = min(cap, base * 2 ** attempt)
print(attempt + 1, delay)1 0.5 2 1.0 3 2.0 4 4.0 5 8.0 6 8.0 7 8.0
- L30.5 × 2^尝试次数,但绝不超过上限。
- L4真实的客户端接着会睡 random.uniform(0, delay) 秒:这就是抖动。
幂等的支付
动手试试客户端超时后重试了;顾客只被扣一次款。
charged = {}
def pay(key, amount):
if key in charged:
return "already done"
charged[key] = amount
return "charged"
print(pay("order-17", 30))
print(pay("order-17", 30))
print(sum(charged.values()))charged already done 30
- L4以前见过这个键?那是一次重试;不要再扣款。
- L10重试得到了回应,但没有第二次扣款。
already done - L11总共只扣了 30。
30
常见错误
不设上限的队列
队列把过载掩盖起来,直到内存耗尽或者每个请求都要等好几分钟。给它设上限,尽早拒绝。
立刻循环重试
每个客户端都去猛敲一个正在挣扎的服务,只会让它起不来。用指数退避,加上抖动,几次之后就放弃。
重试非幂等的操作
超时并不代表第一次失败了;它可能已经成功了。没有幂等键,重试就可能扣两次款或发两次。
小测验
1. 这段代码会输出什么?
backlog = 0
for n in [10, 10, 10]:
backlog += n
backlog -= min(backlog, 6)
print(backlog)查看答案
答案:A
每秒剩下 4 个:4、8、12。
2. 为什么要给重试的延迟加上随机抖动?
查看答案
答案:A
没有抖动的话,一起失败的客户端会一起重试,再次压垮服务。
3. 一个支付请求超时了。怎样重试才安全?
查看答案
答案:A
第一次可能已经成功了;有了这个键,服务器可以直接回应而不再扣款。
动手练习
3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。
- 预测输出退避时间表
准确写出它打印的内容。
- 改需求给队列设上限
simulate忽略了limit。每一秒加上新到的请求之后,把积压限制在limit以内(多出来的请求被拒绝),然后再让工作者处理。 - 从零写只扣一次
写一个
process(requests)。每个请求是(幂等键, 金额)。为每个请求返回一项,组成列表:某个键第一次出现时是"charged",之后同一个键的请求都是"duplicate"。process([("a", 10), ("b", 5), ("a", 10)]) # ['charged', 'charged', 'duplicate']
本课术语
| 术语 | 中文 | 英文定义 |
|---|---|---|
| Message queue | 消息队列 | A buffer between producers and workers. |
| Back-pressure | 背压 | Slowing producers down when consumers can't keep up. |
| Exponential backoff | 指数退避 | Doubling the wait after each failure, up to a cap. |
| Idempotency | 幂等性 | Doing the same request twice has the same effect as once. |