定时任务为什么执行了两次
RedEarth 每天早上会自动生成一份资讯摘要。有一天我发现同一份摘要收到了两遍,时间完全一样。
第一反应:是不是开了两个进程
最常见的原因是 Web 服务开了多个 worker,每个 worker 都启动了一个调度器。但我检查以后发现服务只有一个进程,这个猜测排除了。
在调度器启动处打日志
我在创建调度器的地方加了一行日志,打印进程号和对象的 id:
logger.info("scheduler start pid=%s id=%s", os.getpid(), id(scheduler))
重启后日志里出现了两行,进程号相同,对象 id 不同。也就是说,同一个进程里创建了两个调度器。
原因:同一个文件被当成两个模块导入
启动入口是 app.py,它在模块顶层直接调用了 create_app()。服务以脚本方式运行时,这个文件的模块名是 __main__;而项目里另有一处代码写了:
from app import app
Python 发现 sys.modules 里没有名为 app 的模块,于是把 app.py 又执行了一遍,create_app() 被调用了第二次,调度器也就启动了第二个。
这是 Python 里一个经典的坑:一个文件既作为脚本运行,又被其他代码按模块名导入,就会被执行两次。
修复
根治的方法是在入口文件里,把 __main__ 模块登记为 app 的别名,这样后面的导入会直接复用已加载的模块:
import sys
if __name__ == "__main__":
sys.modules.setdefault("app", sys.modules["__main__"])
同时我又加了一层兜底:定时任务执行前先在 Redis 里抢一把短期锁,key 里带上任务名和计划执行时间,抢不到就跳过。这样即使将来因为其他原因又出现了两个调度器,任务也只会执行一次。
收获
- 排查“执行两次”类问题时,同时打印进程号和对象 id,可以很快区分是“多进程”还是“同进程多实例”;
- 应用入口文件里不要有导入即执行的副作用,或者确保它只会被执行一次;
- 对外有副作用的定时任务(发消息、写数据),最好在执行层再做一次幂等保护。
