0. 上一章思考题参考答案
思考题 1:revoke 靠 pidbox 广播「撤销集合」,集合保存在各 Worker 内存里。Worker 重启后内存集合清空——如果被撤销的任务消息还躺在队列里(尚未消费),新 Worker 会把它当成正常任务再次拾取执行,于是状态从 REVOKED「倒回」PENDING。Mingle 机制(Worker 启动时向其他 Worker 同步撤销集合,第 33 章讲)就是在缩小这个窗口,但广播本身是尽力而为,仍有漏网可能。
思考题 2:update_state(state='ABORTED')把自定义状态写进 Backend,任务中心能看到明确的「已取消」节点,与正常完成(SUCCESS)可区分、可审计;直接return走默认成功路径,状态是 SUCCESS,调用方无法判断是「跑完了」还是「被取消了」。前者明显更利于任务中心展示,代价是自定义状态要登记进状态字典(第 10 章注意事项)。
1. 项目背景
大促前夜,压测打出了三个要命的问题:第一,短信网关偶发超时(每 200 条里约 3 条 5xx),短信任务当场失败,失败就没了——用户付了款收不到短信,第二天客诉 200 单。第二,库存扣减任务偶发「扣了两遍」:Worker 执行到一半被 kill,消息重投,第二次执行又扣一遍库存,导致超卖。第三,一个爬虫任务卡死 40 分钟没退出,把 Worker 的并发槽位占死,整个队列跟着堵。
这三个问题恰好是异步系统的三座大山,而且彼此制衡:
重试 ↑ 提高送达率 ────但────► 重复执行风险 ↑ ────要求────► 幂等 └────但────► 任务卡死占槽 ────要求────► 超时兜底没有重试,通知必丢;有了重试,扣库存就可能双扣;重试又放大了卡死任务的危害。三者必须一起设计,单独优化任何一环都会把压力转移到另一环。本章的目标:短信任务自动退避重试 5 次(不吵不闹),库存任务用「订单号去重表」保证重试一万遍也只扣一次,卡死任务由超时机制强制收场。
2. 项目设计
场景:大促压测复盘会,三个问题摆上台面。
小胖:重试还不简单?任务里try...except包一层,失败就再调一遍send_order_sms.delay()呗。我打游戏网络卡了都是疯狂点重连的,有啥难的?
小白:小胖你那个「重连」是在任务里再发一个新任务,那新任务失败谁重试?无限套娃?而且原任务返回 SUCCESS 了,状态机全乱了。我看 Celery 有专门的retry机制,它和「再发一个任务」本质区别是什么?还有autoretry_for那些参数,retry_backoff、retry_jitter都管什么?
大师:问到根上了。Celery 的self.retry()(celery/app/task.py:767)不是「再发一个任务」,而是抛出一个Retry异常——这个异常被 trace 捕获后,把当前任务重新投递(同一个任务 ID、同一个上下文、retries+1),任务状态变成 RETRY。重试是同一个任务生命周期的延续,不是新任务。而autoretry_for(celery/app/autoretry.py)是把这段逻辑自动化:任务抛出指定异常类型时自动 retry。配套参数:retry_backoff是退避系数(countdown = factor × 2^retries),retry_backoff_max封顶,retry_jitter加随机抖动——为什么抖?因为如果 100 个任务同时失败,同时退避会导致同一秒同时重试,把刚恢复的网关又打挂,抖动把重试打散。
技术映射:手动 retry = 排队排到一半「先去喝口水再回来排同一个号」;autoretry_for = 系统自动按「排队规则」重新叫你号;退避+抖动 = 失败的人别一起涌回来,分批再排。
小白:那我再问超时。我看有soft_time_limit和time_limit两个,什么区别?「软」在哪「硬」在哪?
大师:软超时soft_time_limit到点后向任务进程发SIGUSR1 信号,触发SoftTimeLimitExceeded异常——任务有机会捕获它、写个进度、优雅收尾;硬超时time_limit到点直接杀进程(SIGKILL),没有辩解机会。所以实践是:软超时 = 任务自己把握的止损线(如清理临时文件、标记失败),硬超时 = 系统兜底的暴力线。配的时候soft_time_limit < time_limit,中间差就是「留给任务自救的时间」。你那个爬虫任务卡死,就是软硬都没配,Worker 槽位被无限占用。
小胖:那库存扣两遍呢?是不是重试的锅?那干脆库存不重试了呗。
大师:不行——不重试就可能「该扣的没扣」。正解是幂等:让「重复执行」的结果和「执行一次」完全一样。库存扣减的经典做法是去重表:以订单号为唯一键建一张order_deduct_log表,扣库存前先「抢插」记录,插不进去说明这单已经扣过了,直接跳过。这样任务重试一百遍,库存也只扣一次。记住一句话:在异步系统里,任务默认「至少执行一次」,幂等是业务代码的责任,不是框架的承诺。哪些异常不该重试也很重要:业务校验失败(订单不存在)重试一万遍也没用,纯属浪费;网络抖动、锁冲突、超时才值得重试——用dont_autoretry_for把前者排除掉。
技术映射:幂等 = 食堂「凭券领餐」——券上有唯一编号,重复排队领到的还是同一份;去重表 = 券根(撕过的券根不重复发)。
3. 项目实战
3.1 环境准备
沿用环境(Redis Broker + Backend)。本章新增任务:短信任务升级重试策略、库存任务实现幂等扣减、爬虫任务配软硬超时。
3.2 分步实现
步骤 1:短信任务——自动退避重试 5 次
目标:网关抖动类异常自动重试,退避打散,最多 5 次。
# order_tasks.pyfromceleryimportCelery app=Celery('order_tasks')app.config_from_object('celeryconfig')@app.task(name='orders.send_order_sms',bind=True,autoretry_for=(ConnectionError,TimeoutError),# 网络类异常才重试dont_autoretry_for=(ValueError,),# 参数/业务错误不重试retry_backoff=2,# 指数退避:2^retries 秒retry_backoff_max=60,# 封顶 60 秒retry_jitter=True,# 随机抖动,防止同时重试风暴max_retries=5)# 最多 5 次defsend_order_sms(self,order_id:int)->bool:print(f"[SMS] 第{self.request.retries+1}次尝试: 订单{order_id}")iforder_id%13==0:# 模拟 1/13 的网关超时raiseConnectionError("短信网关超时")returnTrue关键点:
retry_backoff=2的退避序列是 2s、4s、8s、16s、32s(封顶 60),配合 jitter 实际是「附近随机」——观察 Worker 日志的重试间隔即可验证。
步骤 2:库存任务——订单号去重表保幂等
目标:任务重试/重复投递一万遍,库存只扣一次。
# inventory_tasks.pyimportsqlite3 DEDUP_DB="order_deduct_log.db"def_deduct_once(order_id:int,qty:int)->bool:"""幂等扣减:以订单号为唯一键抢插去重记录。"""conn=sqlite3.connect(DEDUP_DB)conn.execute("CREATE TABLE IF NOT EXISTS deduct_log ""(order_id INTEGER PRIMARY KEY, qty INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP)")try:conn.execute("INSERT INTO deduct_log(order_id, qty) VALUES (?, ?)",(order_id,qty))conn.commit()# 抢插成功 = 首次扣减returnTrueexceptsqlite3.IntegrityError:returnFalse# 已扣过 → 跳过finally:conn.close()@app.task(name='orders.deduct_stock',bind=True,max_retries=3)defdeduct_stock(self,order_id:int,qty:int)->str:if_deduct_once(order_id,qty):print(f"[stock] 订单{order_id}首次扣减{qty}件")return"deducted"print(f"[stock] 订单{order_id}已扣减过,幂等跳过")return"skipped"关键点:去重表用
PRIMARY KEY唯一约束做原子抢插,数据库约束就是锁,不用自己写分布式锁。生产用 MySQL 同理(INSERT ... ON DUPLICATE KEY或唯一索引 + 捕获冲突)。
步骤 3:爬虫任务——软硬超时双保险
目标:卡死任务先自救、再被杀,Worker 槽位永不沦陷。
# crawler_tasks.pyfromcelery.exceptionsimportSoftTimeLimitExceeded@app.task(name='orders.crawl_page',bind=True,soft_time_limit=8,# 8 秒软超时:抛异常可捕获time_limit=10)# 10 秒硬超时:直接杀进程defcrawl_page(self,url:str)->str:importtimetry:foriinrange(100):time.sleep(0.2)# 模拟爬取exceptSoftTimeLimitExceeded:print(f"[crawl]{url}触发软超时,写入止损状态")self.update_state(state='TIMEOUT',meta={'url':url})raise# 重新抛出,任务按失败处理return"done"步骤 4:运行验证
celery-Aorder_tasks worker--loglevel=info--pool=solo# 终端 B:触发三种场景celery-Aorder_tasks call orders.send_order_sms--args='[13]'# 触发 5 次重试celery-Aorder_tasks call orders.deduct_stock--args='[100, 2]'# 首次扣减celery-Aorder_tasks call orders.deduct_stock--args='[100, 2]'# 幂等跳过celery-Aorder_tasks call orders.crawl_page--args='["http://x"]'# 软超时止损运行结果(文字描述):
短信任务日志出现 6 行「第 N 次尝试」(1 首次 + 5 重试),间隔约 2/4/8/16/32 秒, 最终状态 FAILURE(重试耗尽); 库存任务第一次打印「首次扣减」,第二次打印「幂等跳过」,扣减表里只有一条记录; 爬虫任务 8 秒打印「触发软超时」,状态为 TIMEOUT,10 秒前进程已被框架接管。验证重试状态机:重试期间用
celery -A order_tasks result <task_id>查询,可以看到状态在RETRY与PENDING之间流转,retries计数递增——重试是同一个任务的延续,不是新任务(对比第 2 节对话里小胖的「再发一个任务」方案,新任务会有新的 task_id)。
3.3 可能遇到的坑及解决方法
| 坑 | 现象 | 解决 |
|---|---|---|
| autoretry_for 不生效 | 异常被任务内 try/except 吞掉 | 网络类异常别全捕获,让 Retry 异常冒泡给框架 |
| 重试间隔像「连发」 | retry_backoff 没设或 jitter 关闭 | 显式配retry_backoff+retry_jitter=True |
| 幂等表插入死锁 | 高并发抢插同一订单 | 唯一键抢插 + 事务短小;失败分支立即提交 |
| 软超时不触发 | --pool=solo或 Windows 下信号受限 | Windows 用--pool=solo时软超时不可用;Linux 生产用 prefork 正常 |
| 硬超时后数据写一半 | time_limit 到点杀进程 | 关键写操作放幂等边界内;软超时先行收尾 |
| 重试与幂等脱节 | 重试策略改了,幂等键没跟着评审 | 契约表里「幂等键」与「重试策略」同一行评审,缺一不可 |
3.4 完整代码清单与测试验证
清单:order_tasks.py(短信重试)、inventory_tasks.py(幂等扣减)、crawler_tasks.py(软硬超时)。生产建议:重试参数与幂等键写入任务契约表(第 6 章),评审必查「这任务幂等吗」。
测试验证:
# tests/test_retry_idempotent.pyimportpytestfromorder_tasksimportapp,send_order_smsfrominventory_tasksimportdeduct_stock,_deduct_once app.conf.task_always_eager=Truedeftest_sms_task_has_autoretry_config():assertsend_order_sms.autoretry_for==(ConnectionError,TimeoutError)assertsend_order_sms.max_retries==5deftest_deduct_once_then_skip():assert_deduct_once(1001,1)isTrue# 首次assert_deduct_once(1001,1)isFalse# 重复 → 幂等assert_deduct_once(1002,3)isTrue# 不同订单正常deftest_deduct_task_idempotent_path():r1=deduct_stock.apply(args=[2001,2])r2=deduct_stock.apply(args=[2001,2])assertr1.result=='deducted'assertr2.result=='skipped'deftest_soft_time_limit_configured():fromcrawler_tasksimportcrawl_pageassertcrawl_page.soft_time_limit==8assertcrawl_page.time_limit==10python-mpytest tests/test_retry_idempotent.py-v# 4 passed4. 项目总结
4.1 优点 & 缺点
| 维度 | autoretry_for 自动重试 | 手动 try/except 再 delay |
|---|---|---|
| 状态机 | RETRY 状态与 retries 计数完整 | 新任务新 ID,状态割裂 |
| 退避控制 | backoff/jitter 内建 | 手写 sleep,易遗漏 |
| 幂等协同 | 同一任务重入,幂等键连续 | 新任务要重新算幂等 |
| 缺点 1 | 配置集中在装饰器,长行难读 | 逻辑直观 |
| 缺点 2 | 异常类型配错会「疯狂重试」 | —— |
4.2 适用场景
- 适用:① 网络类/第三方依赖类任务(短信、支付回调);② 需要退避防风暴的批量任务;③ 库存/金额等必须幂等的写操作;④ 执行时长不可控的外部调用(配软硬超时);⑤ 大促流量洪峰下的「可靠性兜底」组合(重试+幂等+超时三件套一起上)。
- 不适用:① 业务校验失败类错误(重试无意义);② 强实时性任务(重试延迟不可接受时直接告警人工介入);③ 无法幂等的资源型操作(如「发送一次性的物理信号」)。
4.3 注意事项
- 重试默认以 2 的倍数退避,
retry_backoff_max记得封顶,否则重试间隔会指数爆炸。 dont_autoretry_for与autoretry_for同时配:先查前者,业务错误直接失败。- 幂等键选「业务天然唯一键」(订单号、交易号),不要用任务 ID(重试不换 ID 但重复投递会换)。
- 软超时在 Windows/
--pool=solo下不可用;生产 Linux + prefork 是标配。 - 手动
self.retry()与autoretry_for二选一即可,混用会让重试策略变成「谁在最后一刻覆盖谁」的谜题;统一用声明式(装饰器参数)便于评审。
4.4 常见踩坑经验(3 个生产故障)
- 故障:短信轰炸投诉,同一用户收到 6 条相同短信。根因:autoretry_for 配了全量 Exception,业务校验错误(手机号格式错)也重试,且短信任务没幂等。对策:
dont_autoretry_for排除业务错误 + 短信以 (order_id, template) 幂等。教训:重试的敌人不是失败,是不该重试的失败。 - 故障:大促库存超卖 300 单。根因:扣库存任务 acks_late + 无幂等,Worker 重启重投双扣。对策:订单号去重表(本章落地)。教训:晚确认与重试都是「至少一次」,幂等是底线。
- 故障:Worker 全部卡死,队列雪崩。根因:第三方接口无限挂起,任务无超时,并发槽位全部占死。对策:全部外部调用任务配 soft < time 双超时。教训:没有超时的异步任务就是一枚定时炸弹。
4.5 思考题
self.retry()抛出Retry异常和「再调一次self.delay()」的本质区别是什么?为什么说 Retry 异常改变了控制流?- 幂等键用
order_id,但同一个订单在「用户改单」后会重新扣减——这种业务语义下幂等键要如何设计?(提示:引入版本号/操作流水号)
答案见第 12 章开头的「上一章思考题参考答案」。至此基础篇「可靠性三件套」闭环:第 10 章状态机、本章重试幂等超时、第 12 章序列化时区日志,三者将共同支撑第 16 章综合实战。
延伸阅读与资源
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析