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.request logger 发送,若该 logger 的 handler 配置不当或级别设置过高,同样可能丢失)。

排查线索

  • 容器日志中只有 Gunicorn 的启动信息和访问日志,没有 Worker 崩溃的 traceback。
  • kubectl logsdocker 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.pyready() 方法中(但需确认调用时机在主线程),或直接放在 wsgi.py 中,因为 wsgi.py 在 Worker 初始化时由 Gunicorn 主线程加载。

坑 3:grequests 顶层导入触发全局 monkey-patch

问题
gateway/views.py 顶层写了 import grequests
grequests 依赖 gevent,在导入时会自动执行 gevent.monkey.patch_all(),将 Python 全局的 socketsslthreadingselect 等模块替换为 gevent 的协程版本。
gthread 使用的是原生系统线程。多线程模型下,原生线程遇到被猴子补丁替换的非协程安全对象,会发生严重的内存竞态、死锁或 C 扩展崩溃。典型现象包括:

  • RecursionError(SSL 上下文递归)
  • 数据库连接 OperationalError: (2013, 'Lost connection to MySQL server during query')
  • 随机 SystemError: NULL object passed to Py_BuildValue

详细机制

  • patch_all() 会替换 threading 模块,使得标准库的 LockEvent 等变成 gevent 的 greenlet 版本。但 Gunicorn 的 gthread Worker 使用真正的内核线程,依赖标准库的线程同步原语。二者混用导致内部状态不一致。
  • MySQL 的 mysqlclient 是基于 C 扩展的,其 socket 操作在 monkey-patch 后可能绕过 gevent 的 hub 调度,造成连接状态错乱。

排查方法

  • 全局搜索 grequestsgeventmonkey 关键字。
  • 检查是否有模块顶层 import grequestsfrom 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)后出现全站不可用时:

  1. 立刻恢复日志可见性

    • 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
      
  2. 检查线程安全性

    • 全局搜索 signal.signal(,确保只在主线程注册。
    • 搜索 threading.current_thread() 使用,检查是否有主线程假设。
    • 确认所有模块顶层代码无副作用。
  3. 检查协程库冲突

    • 搜索 geventeventletmonkey.patch_all 等关键字。
    • 确认没有任何库在顶层执行猴子补丁。如有,改为惰性导入或移除。
  4. 重新计算资源边界

    • 最大数据库连接数 = workers × threads 至少留有 20% 余量。
    • 同样检查 Redis 连接池、外部 API 连接池等。
  5. 建立回归测试清单

    • 新 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 XXXnext run time
  • 检查项目目录下的 celerybeat-schedule 文件(或 celerybeat-schedule.db),删除后重启。

修复方案

  • 修改任何与时间相关的配置后,必须删除调度持久化文件

    rm -f celerybeat-schedule celerybeat-schedule.db
    
  • 在启动脚本中加入清理逻辑,确保每次部署或配置变更时都能重新计算。

三、本类问题的排查 SOP

当定时任务未按预期时间执行时:

  1. 快速确认时区

    • 检查 CELERY_TIMEZONE 是否显式设置,是否与 TIME_ZONE 一致。
    • 计算实际执行时间与预期的差值。若为整数小时(如 8 小时),100% 是时区问题。
  2. 重启 Beat 并清理缓存

    • 删除 celerybeat-schedule 文件(具体文件名可在配置中指定,默认为 celerybeat-schedule)。
    • 重启 Celery Beat,观察日志中显示的 last_run_atnext run 是否正确。
  3. 增加任务执行的可观测性

    • 在任务内打印 datetime.datetime.now(),并输出到日志或发送通知。
    • 为关键任务添加心跳监控(如 Prometheus 指标或企业微信提醒),及时发现执行延迟。

通用防御原则总结

以上两个案例虽然表现不同,但都揭示了同样的架构缺陷:

  1. 环境变更前必须评估副作用
    切换 Gunicorn Worker 类型属于运行环境重大变更,必须检查:线程安全性、第三方库兼容性、全局 monkey-patch 冲突、资源上限。

  2. 日志和可观测性是急救的第一现场
    “无堆栈的 500”等于蒙眼排查。所有中间件、任务都必须确保异常能被完整记录。调低日志级别、增加强制捕获是最快定位问题的手段。

  3. 显式配置优于隐式依赖
    Celery 不会自动继承 Django 时区;Gunicorn gthread 不会自动处理 signal 线程限制。任何独立组件的配置都应显式声明,避免依赖“框架默认会处理好”。

  4. 资源上限要同步变更
    线程数增大后,必须重新评估数据库、缓存、外部 API 的连接池限制,防止资源耗尽型阻塞。

  5. 状态持久化是定时任务的隐形炸弹
    任何调度系统的本地持久化文件(如 Celery Beat 的 schedule 文件),在修改时间配置后必须清理,避免旧状态污染新规则。

posted @ 2026-05-30 16:35  小郑[努力版]  阅读(16)  评论(0)    收藏  举报