ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

第11章:Celery 失败重试、超时与幂等

第11章:Celery 失败重试、超时与幂等 0. 上一章思考题参考答案思考题 1revoke 靠 pidbox 广播「撤销集合」集合保存在各 Worker 内存里。Worker 重启后内存集合清空——如果被撤销的任务消息还躺在队列里尚未消费新 Worker 会把它当成正常任务再次拾取执行于是状态从 REVOKED「倒回」PENDING。Mingle 机制Worker 启动时向其他 Worker 同步撤销集合第 33 章讲就是在缩小这个窗口但广播本身是尽力而为仍有漏网可能。思考题 2update_state(stateABORTED)把自定义状态写进 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、同一个上下文、retries1任务状态变成 RETRY。重试是同一个任务生命周期的延续不是新任务。而autoretry_forcelery/app/autoretry.py是把这段逻辑自动化任务抛出指定异常类型时自动 retry。配套参数retry_backoff是退避系数countdown factor × 2^retriesretry_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 appCelery(order_tasks)app.config_from_object(celeryconfig)app.task(nameorders.send_order_sms,bindTrue,autoretry_for(ConnectionError,TimeoutError),# 网络类异常才重试dont_autoretry_for(ValueError,),# 参数/业务错误不重试retry_backoff2,# 指数退避2^retries 秒retry_backoff_max60,# 封顶 60 秒retry_jitterTrue,# 随机抖动防止同时重试风暴max_retries5)# 最多 5 次defsend_order_sms(self,order_id:int)-bool:print(f[SMS] 第{self.request.retries1}次尝试: 订单{order_id})iforder_id%130:# 模拟 1/13 的网关超时raiseConnectionError(短信网关超时)returnTrue关键点retry_backoff2的退避序列是 2s、4s、8s、16s、32s封顶 60配合 jitter 实际是「附近随机」——观察 Worker 日志的重试间隔即可验证。步骤 2库存任务——订单号去重表保幂等目标任务重试/重复投递一万遍库存只扣一次。# inventory_tasks.pyimportsqlite3 DEDUP_DBorder_deduct_log.dbdef_deduct_once(order_id:int,qty:int)-bool:幂等扣减以订单号为唯一键抢插去重记录。connsqlite3.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(nameorders.deduct_stock,bindTrue,max_retries3)defdeduct_stock(self,order_id:int,qty:int)-str:if_deduct_once(order_id,qty):print(f[stock] 订单{order_id}首次扣减{qty}件)returndeductedprint(f[stock] 订单{order_id}已扣减过幂等跳过)returnskipped关键点去重表用PRIMARY KEY唯一约束做原子抢插数据库约束就是锁不用自己写分布式锁。生产用 MySQL 同理INSERT ... ON DUPLICATE KEY或唯一索引 捕获冲突。步骤 3爬虫任务——软硬超时双保险目标卡死任务先自救、再被杀Worker 槽位永不沦陷。# crawler_tasks.pyfromcelery.exceptionsimportSoftTimeLimitExceededapp.task(nameorders.crawl_page,bindTrue,soft_time_limit8,# 8 秒软超时抛异常可捕获time_limit10)# 10 秒硬超时直接杀进程defcrawl_page(self,url:str)-str:importtimetry:foriinrange(100):time.sleep(0.2)# 模拟爬取exceptSoftTimeLimitExceeded:print(f[crawl]{url}触发软超时写入止损状态)self.update_state(stateTIMEOUT,meta{url:url})raise# 重新抛出任务按失败处理returndone步骤 4运行验证celery-Aorder_tasks worker--loglevelinfo--poolsolo# 终端 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 秒打印「触发软超时」状态为 TIMEOUT10 秒前进程已被框架接管。验证重试状态机重试期间用celery -A order_tasks result task_id查询可以看到状态在RETRY与PENDING之间流转retries计数递增——重试是同一个任务的延续不是新任务对比第 2 节对话里小胖的「再发一个任务」方案新任务会有新的 task_id。3.3 可能遇到的坑及解决方法坑现象解决autoretry_for 不生效异常被任务内 try/except 吞掉网络类异常别全捕获让 Retry 异常冒泡给框架重试间隔像「连发」retry_backoff 没设或 jitter 关闭显式配retry_backoffretry_jitterTrue幂等表插入死锁高并发抢插同一订单唯一键抢插 事务短小失败分支立即提交软超时不触发--poolsolo或 Windows 下信号受限Windows 用--poolsolo时软超时不可用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_eagerTruedeftest_sms_task_has_autoretry_config():assertsend_order_sms.autoretry_for(ConnectionError,TimeoutError)assertsend_order_sms.max_retries5deftest_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():r1deduct_stock.apply(args[2001,2])r2deduct_stock.apply(args[2001,2])assertr1.resultdeductedassertr2.resultskippeddeftest_soft_time_limit_configured():fromcrawler_tasksimportcrawl_pageassertcrawl_page.soft_time_limit8assertcrawl_page.time_limit10python-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/--poolsolo下不可用生产 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 实战修炼与源码剖析
返回列表