Django 服务阻塞深度排查手册
本手册基于真实生产事故整理,详细记录了 Gunicorn 多线程切换 与 Celery 定时任务延迟 两大阻塞场景的根因、排查步骤及修复方案。可作为后续同类问题的排查 SOP。
案例一:切换 Gunicorn gthread 后全站 500
一、事故背景与现象
操作:为支持 SSE 长连接,将 Gunicorn 配置从 sync 改为 gthread,并设定 workers=3, threads=50。
现象:
- 服务部署后所有 API 返回 HTTP 500,且无正常响应。
- 容器未 Crash,Gunicorn 进程存活,看起来“服务正常”。
- 业务日志(Django Log)中无 Error 级别堆栈,只能看到中间件记录的请求入口。
- 健康检查 / 普通请求均失败,服务完全阻塞。
二、根因详细分析(四个坑叠加)
坑 1:日志级别设置导致“盲飞”
问题:
Gunicorn 配置文件中设置了 loglevel = 'warning',导致 Worker 启动或处理请求时发生的异常被过滤。同时,Django 自定义中间件只记录了请求进入,未对 get_response() 包裹 try/except 并打印堆栈,导致 500 错误在框架底层被静默吞掉。
具体机制:
- Gunicorn 的日志级别分为
debug, info, warning, error, critical。当设为warning时,error以下的异常信息(如 Worker 子线程崩溃的 traceback)不会被输出到 stderr 或日志文件。 - Django 中间件若只做了类似
print(request.path)的记录而未捕获异常,则请求一旦进入视图层,视图抛出异常后会被 Django 的handle_uncaught_exception转为 500 响应,但不会在应用日志中留下堆栈(因为 Django 默认将 500 的 traceback 通过django.requestlogger 发送,若该 logger 的 handler 配置不当或级别设置过高,同样可能丢失)。
排查线索:
- 容器日志中只有 Gunicorn 的启动信息和访问日志,没有 Worker 崩溃的 traceback。
kubectl logs或docker logs看不到任何 Python 异常。
为何在 sync 模式下没问题:
sync 模式下,请求处理发生在主进程,异常更容易被 Gunicorn 的 master 捕捉并输出;gthread 模式下,异常发生在子线程,线程崩溃的 traceback 默认情况下 Gunicorn 可能只输出到自己的错误日志,且受日志级别限制。
坑 2:信号注册在子线程触发 ValueError
问题:
application/views.py 模块顶层有 import signal; signal.signal(signal.SIGTERM, handler)。
gthread 模式下,Django 的 URL 路由和视图是懒加载的——第一个 HTTP 请求进来时,Gunicorn 从线程池分配子线程去处理请求,此时才触发 views.py 的导入。Python 规定 signal.signal() 只能在主线程调用,子线程调用抛出 ValueError: signal only works in main thread,导致路由初始化失败,视图无法加载,所有后续请求均 500。
代码示例:
# application/views.py
import signal
def _handle_sigterm(signum, frame):
# 优雅退出逻辑
pass
signal.signal(signal.SIGTERM, _handle_sigterm) # 若在子线程 import 则炸
错误输出(如果日志能打印):
ValueError: signal only works in main thread
排查方法:
- 临时将 Gunicorn
loglevel调为debug,重启后立刻发一个请求,观察 stderr 是否有上述错误。 - 在 Django 的
wsgi.py或中间件中添加一个线程检查:
import threading, logging
logger = logging.getLogger(__name__)
class DebugThreadMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
logger.info(f"Serving request in thread: {threading.current_thread().name}")
return self.get_response(request)
若打印出的线程名是 ThreadPoolExecutor-0_0 而非 MainThread,则证明确实在子线程中处理。
修复方案:
- 将信号注册代码加上主线程判断:
import threading, signal
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGTERM, _handle_sigterm)
- 或者将信号注册移到
apps.py的ready()方法中(但需确认调用时机在主线程),或直接放在wsgi.py中,因为wsgi.py在 Worker 初始化时由 Gunicorn 主线程加载。
坑 3:grequests 顶层导入触发全局 monkey-patch
问题:
gateway/views.py 顶层写了 import grequests。
grequests 依赖 gevent,在导入时会自动执行 gevent.monkey.patch_all(),将 Python 全局的 socket、ssl、threading、select 等模块替换为 gevent 的协程版本。
而 gthread 使用的是原生系统线程。多线程模型下,原生线程遇到被猴子补丁替换的非协程安全对象,会发生严重的内存竞态、死锁或 C 扩展崩溃。典型现象包括:
RecursionError(SSL 上下文递归)- 数据库连接
OperationalError: (2013, 'Lost connection to MySQL server during query') - 随机
SystemError: NULL object passed to Py_BuildValue
详细机制:
patch_all()会替换threading模块,使得标准库的Lock、Event等变成 gevent 的 greenlet 版本。但 Gunicorn 的gthreadWorker 使用真正的内核线程,依赖标准库的线程同步原语。二者混用导致内部状态不一致。- MySQL 的
mysqlclient是基于 C 扩展的,其 socket 操作在 monkey-patch 后可能绕过 gevent 的 hub 调度,造成连接状态错乱。
排查方法:
- 全局搜索
grequests、gevent、monkey关键字。 - 检查是否有模块顶层
import grequests或from gevent import monkey; monkey.patch_all()。 - 临时注释掉这些导入,重启服务看是否恢复。
修复方案:
- 原则:多线程模式(gthread)下禁止使用协程库的全局 monkey-patch。
- 使用局部导入(惰性导入):
# 不要顶层 import grequests
def call_external_api():
import grequests
# ...
- 但更推荐:在
gthread模式下直接用requests+concurrent.futures实现并发,或彻底迁移到 ASGI + 异步网络库(如httpx.AsyncClient)。
坑 4:线程数过高耗尽 MySQL 连接池
问题:
配置 workers=3, threads=50 意味着最大并发线程数 = 3 × 50 = 150。Django 默认每个线程会持有独立的数据库连接(CONN_MAX_AGE 未设置时,每个请求结束后连接放回池中,但池的最大连接数受数据库端限制)。MySQL 的默认 max_connections 通常是 151(包括系统用户)。当 150 个线程同时发起 DB 查询时,瞬间打满连接数,导致 OperationalError: Too many connections,后续请求全部阻塞在获取数据库连接上。
补充:
- 即使没到 150 个并发,高线程数也会频繁创建/销毁连接,增加数据库压力。
- 在
sync模式下,workers=3只有 3 个并发连接,远未触及上限。
排查方法:
- 查看 MySQL 错误日志或 Gunicorn 日志中的
OperationalError。 - 进入 MySQL 执行
SHOW PROCESSLIST;观察连接数。 - 计算
workers * threads是否接近max_connections。
修复方案:
- 降低线程数:对于 I/O 密集型应用,单 Worker 4~8 个线程足够,例如
workers=3, threads=8。 - 在数据库端适当增大
max_connections(需评估内存和性能)。 - 配置 Django 的连接池(使用
django-db-connection-pool等库)复用连接。 - 设置
CONN_MAX_AGE控制连接存活时间,减少重复握手。
三、本类问题的排查 SOP
当改变 Gunicorn Worker 类型(sync → gthread/gevent)后出现全站不可用时:
-
立刻恢复日志可见性
-
在
gunicorn.conf.py中设置loglevel = 'debug'或'info'。 -
检查
logging配置,确保django.request的 handler 启用了ERROR级别并输出到 stderr。 -
添加强制异常捕获中间件:
class ExceptionLoggingMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): try: response = self.get_response(request) except Exception: import logging, traceback logging.error(f"Unhandled exception: {traceback.format_exc()}") raise return response
-
-
检查线程安全性
- 全局搜索
signal.signal(,确保只在主线程注册。 - 搜索
threading.current_thread()使用,检查是否有主线程假设。 - 确认所有模块顶层代码无副作用。
- 全局搜索
-
检查协程库冲突
- 搜索
gevent、eventlet、monkey.patch_all等关键字。 - 确认没有任何库在顶层执行猴子补丁。如有,改为惰性导入或移除。
- 搜索
-
重新计算资源边界
最大数据库连接数=workers × threads至少留有 20% 余量。- 同样检查 Redis 连接池、外部 API 连接池等。
-
建立回归测试清单
- 新 Worker 类型上线前,在 staging 环境进行并发压测(如 wrk、locust),验证 500 率和资源使用。
案例二:Celery 定时任务延迟 8 小时执行
一、事故背景与现象
操作:在 CELERY_BEAT_SCHEDULE 中配置每天 07:20 执行的任务。
现象:任务精准地在 15:20 执行,恰好推迟 8 小时。
修正时区后:当天任务被跳过,没有执行。
二、根因详细分析
坑 1:Celery 时区独立于 Django
问题:
Django settings.py 中设置了 TIME_ZONE = 'Asia/Shanghai',但 Celery 有自己的时区配置项 CELERY_TIMEZONE,默认值为 'UTC'。当使用 crontab(hour=7, minute=20) 时,Celery Beat 将“7:20”解释为 UTC 时间。UTC 7:20 = 北京时间 15:20,因此任务晚执行 8 小时。
完整配置示例:
# settings.py
TIME_ZONE = 'Asia/Shanghai'
USE_TZ = True
# Celery 未配置时区,使用默认 UTC
CELERY_BEAT_SCHEDULE = {
'task1': {
'task': 'app.tasks.my_task',
'schedule': crontab(hour=7, minute=20),
},
}
预期与实际的对比:
- 开发者期望:北京时间 07:20。
- Celery 理解:UTC 07:20 → 北京时间 15:20。
为什么 Django 的 TIME_ZONE 不影响 Celery:
Celery 是独立的调度系统,它依赖自己的配置项 timezone(小写)或 CELERY_TIMEZONE。Django 的 TIME_ZONE 只在 Django ORM、模板等内部生效,Celery 不会自动继承。
排查方法:
-
查看 Celery Beat 启动日志,若有打印调度信息,会显示当前使用的时区。
-
在任务中打印当前时间:
import datetime, logging @app.task def my_task(): logging.info(f"Executed at: {datetime.datetime.now()}")对比北京时间和日志时间,差值 8 小时即确认。
修复方案:
显式配置 Celery 时区,并与 Django 一致:
# 推荐使用 Django 的 TIME_ZONE
from django.conf import settings
CELERY_TIMEZONE = settings.TIME_ZONE
# 或者直接硬编码
CELERY_TIMEZONE = 'Asia/Shanghai'
# 同时关闭 UTC 选项(即使 USE_TZ=True,时区也已指定)
CELERY_ENABLE_UTC = False # 让 Celery 内部使用该时区
注意:如果 CELERY_ENABLE_UTC=True(默认),Celery 会将 CELERY_TIMEZONE 转换为 UTC 存储调度,但实际触发时仍会考虑时区,只要 CELERY_TIMEZONE 设置正确即可。但为了减少混乱,建议国内业务直接设 CELERY_ENABLE_UTC = False。
坑 2:修改时区后旧调度缓存导致任务跳过
问题:
修改时区配置并重启 Celery Beat 后,发现当天的任务未执行。
根因:Celery Beat 使用一个持久化文件(celerybeat-schedule,通常是 db 文件或 shelve)记录每个任务的 last_run_at。
假如任务之前一直在 UTC 15:20 执行(北京时间 23:20),文件里记录的最后运行时间是昨天 UTC 15:20。
当我们将时区改为 Asia/Shanghai 并重启 Beat 后,Beat 会读取该文件,看到上次运行是“昨天 15:20”(现在这个时间被解释为北京时间)。然后 Beat 计算下次运行:last_run_at + 24h = 今天 15:20,而早上 7:20 已经过去,所以直接跳过。此外,时间回退也可能导致 Beat 认为“当前时间早于上次运行”,从而不触发。
排查方法:
- 查看 Beat 的详细日志(
celery beat -l debug),会打印Scheduler: Scheduler created due to task XXX和next run time。 - 检查项目目录下的
celerybeat-schedule文件(或celerybeat-schedule.db),删除后重启。
修复方案:
-
修改任何与时间相关的配置后,必须删除调度持久化文件:
rm -f celerybeat-schedule celerybeat-schedule.db -
在启动脚本中加入清理逻辑,确保每次部署或配置变更时都能重新计算。
三、本类问题的排查 SOP
当定时任务未按预期时间执行时:
-
快速确认时区
- 检查
CELERY_TIMEZONE是否显式设置,是否与TIME_ZONE一致。 - 计算实际执行时间与预期的差值。若为整数小时(如 8 小时),100% 是时区问题。
- 检查
-
重启 Beat 并清理缓存
- 删除
celerybeat-schedule文件(具体文件名可在配置中指定,默认为celerybeat-schedule)。 - 重启 Celery Beat,观察日志中显示的
last_run_at和next run是否正确。
- 删除
-
增加任务执行的可观测性
- 在任务内打印
datetime.datetime.now(),并输出到日志或发送通知。 - 为关键任务添加心跳监控(如 Prometheus 指标或企业微信提醒),及时发现执行延迟。
- 在任务内打印
通用防御原则总结
以上两个案例虽然表现不同,但都揭示了同样的架构缺陷:
-
环境变更前必须评估副作用
切换 Gunicorn Worker 类型属于运行环境重大变更,必须检查:线程安全性、第三方库兼容性、全局 monkey-patch 冲突、资源上限。 -
日志和可观测性是急救的第一现场
“无堆栈的 500”等于蒙眼排查。所有中间件、任务都必须确保异常能被完整记录。调低日志级别、增加强制捕获是最快定位问题的手段。 -
显式配置优于隐式依赖
Celery 不会自动继承 Django 时区;Gunicorngthread不会自动处理 signal 线程限制。任何独立组件的配置都应显式声明,避免依赖“框架默认会处理好”。 -
资源上限要同步变更
线程数增大后,必须重新评估数据库、缓存、外部 API 的连接池限制,防止资源耗尽型阻塞。 -
状态持久化是定时任务的隐形炸弹
任何调度系统的本地持久化文件(如 Celery Beat 的 schedule 文件),在修改时间配置后必须清理,避免旧状态污染新规则。

浙公网安备 33010602011771号