提示

返回博客列表

上线三个月后的第一次事故:一个 Django 服务的日志、监控与故障复盘

那天下午两点零几分,用户在群里问:"下载是不是挂了?"

我打开站点,页面能开,但一点下载就 502。SSH 上去看,gunicorn 进程还在,CPU 不高,内存正常,磁盘也够——所有"看起来应该有问题的指标"都是好的。

花了 35 分钟才定位到:数据库连接耗尽了。原因是一条慢查询在高峰时段被放大,请求堆积,用户不断刷新让请求翻倍,最后连接池打满,整个站点的写操作全部卡死。

恢复只用了 5 分钟(杀掉长事务 + 重启应用),但事后复盘做了两天。因为我们意识到一件更严重的事:这次是靠用户告诉我们才知道出事了。没有监控、没有告警、日志里也什么都看不出来。这篇写完整的时间线、每一步用什么命令,以及我们后来补上的东西。

TL;DR:定位顺序是 nginx 日志(看状态码和 upstream 时间)→ 应用日志(看报错和慢请求)→ 数据库(看连接数和锁)→ 队列(看堆积)。这次的根因是"慢查询 → 请求堆积 → 用户重试 → 雪崩"的连锁反应。事后补的三层监控:外部可用性拨测(用户视角)、资源指标(CPU/内存/磁盘/连接数/队列长度)、业务指标(成功率、P95 响应时间)。另外:日志必须结构化 + 带 request_id,否则出事了根本没法查。

目录

一、事故时间线:35 分钟里发生了什么

真实的时间线(我现在会要求每次事故都记这个):

时间 事件
13:52 一条统计查询开始变慢(当时没人知道)
13:58 数据库连接数开始爬升(没人看)
14:03 首个 502 出现
14:07 用户在群里反馈(这是我们的"监控系统")
14:12 我登录服务器,看资源指标:全部正常
14:16 看 nginx 日志,发现大量 502 + upstream 超时
14:21 看应用日志,发现大量数据库连接超时
14:23 查 pg_stat_activity:100 个连接全满,一堆 idle in transaction
14:27 定位到根因:一个统计接口的全表扫描
14:31 杀掉长事务 + 重启应用
14:35 恢复
15:20 临时下线那个统计接口

从用户发现到我们开始查,中间隔了 5 分钟;从我们开始查到定位,用了 15 分钟。 如果有监控,第一个数字应该是 0(我们比用户先知道),第二个能压到 5 分钟。

二、第一现场:nginx 日志

出事时我第一个看的是 nginx 日志,因为它是唯一记录了所有请求的地方。

配置一个好的日志格式,是事后能查清问题的基础:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" "$http_x_forwarded_for" '
                'rt=$request_time urt=$upstream_response_time '
                'upstream=$upstream_addr';

access_log /var/log/nginx/access.log main;

$request_time 和 $upstream_response_time 是排障的关键:

  • $request_time:从收到第一个字节到发完响应的总时间(包括客户端慢慢接收的时间);
  • $upstream_response_time:nginx 等上游(Django)响应的时间。

两者的差值能告诉你"慢在 Django 还是慢在客户端网络"。

出事时的统计:

# 最近 1000 行的状态码分布
tail -1000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn
#    612 502
#    288 200
#     97 499        ← 客户端主动断开(用户等不及关了页面)
#      3 504

# 找出最慢的请求
awk '{print $NF, $0}' /var/log/nginx/access.log | sort -rn | head -10

502 是 nginx 连不上上游(Django 挂了或者拒绝连接),504 是上游超时。这次是 502 占多数,说明 Django 侧已经完全无法响应——结合后面的数据库连接耗尽,就对上了:worker 都在等数据库连接,新的连接请求直接被拒绝。

nginx 错误日志里对应的信息:

2026/03/11 14:03:22 [error] 8821#0: *152341 connect() to 127.0.0.1:8000 failed
(111: Connection refused) while connecting to upstream, client: ...

一个实用技巧:把 upstream_response_time 做成 P95 监控指标。这比"平均响应时间"有用得多——平均值会被大量快请求稀释,P95 才能反映真实用户体验。

# 手工算 P95(没有 Prometheus 时的土办法)
awk '{print $NF}' /var/log/nginx/access.log | sort -n | awk '{a[NR]=$1} END{print a[int(NR*0.95)]}'

三、第二现场:应用日志

nginx 告诉我"Django 不行了",但不知道为什么。这时要看应用日志——然后我发现我们的日志基本没用。

当时的日志长这样:

INFO 2026-03-11 14:03:11,221 views 处理下载请求
INFO 2026-03-11 14:03:11,228 views 处理下载请求
INFO 2026-03-11 14:03:41,221 views 请求失败

问题在哪:

  1. 不知道是哪个请求(没有 request_id);
  2. 不知道失败原因(只写了"请求失败");
  3. 不知道耗时(没有耗时记录);
  4. 日志级别用错(失败用了 INFO)。

那次排查我基本上是靠"看时间对不上"猜的。事后第一件事就是重做日志。

结构化日志

现在我们的配置:

# settings.py
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'json': {
            '()': 'pythonjsonlogger.json.JsonFormatter',
            'format': '%(asctime)s %(levelname)s %(name)s %(message)s '
                      '%(request_id)s %(duration_ms)s %(user_id)s',
        },
    },
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': '/var/log/viddown/app.log',
            'maxBytes': 100 * 1024 * 1024,     # 100MB
            'backupCount': 10,                  # 最多 1GB
            'formatter': 'json',
        },
    },
    'loggers': {
        'downloader': {'handlers': ['file'], 'level': 'INFO', 'propagate': False},
    },
}

日志必须轮转。不设 RotatingFileHandler,一个失控的日志循环能在几小时内把磁盘写满(我在磁盘那篇里写过,我们真的遇到过)。

request_id:把一次请求的所有日志串起来

# middleware.py
import uuid
import time
import logging

logger = logging.getLogger('downloader.request')


class RequestLogMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        request.request_id = request.META.get('HTTP_X_REQUEST_ID') or uuid.uuid4().hex[:16]
        start = time.time()

        response = self.get_response(request)

        duration_ms = int((time.time() - start) * 1000)
        logger.info('request finished', extra={
            'request_id': request.request_id,
            'duration_ms': duration_ms,
            'path': request.path,
            'status': response.status_code,
            'user_id': getattr(request.user, 'id', None),
        })
        # 把 id 回给客户端,用户报障时可以直接给我们
        response['X-Request-ID'] = request.request_id
        return response

有了 request_id,用户报障时给我一个 ID,我就能:

grep 'a3f9c2e1b8d04a7f' /var/log/viddown/app.log | jq .

一次请求的所有日志按时间排出来,一目了然。这个改动排查效率提升了不止一个数量级。

记得在 nginx 那边也带上它,这样 nginx 日志和应用日志能对上:

log_format main '... rt=$request_id ...';
proxy_set_header X-Request-ID $request_id;    # 需要 nginx 生成或透传

敏感信息

日志里绝对不要写:密码、token、cookie、完整 URL(可能带签名参数)、身份证/手机号。

我们的做法是在日志前做一次脱敏:

SENSITIVE_KEYS = {'password', 'token', 'sig', 'signature', 'secret', 'cookie', 'authorization'}

def mask(data: dict) -> dict:
    return {k: ('***' if k.lower() in SENSITIVE_KEYS else v) for k, v in data.items()}

这条不是"最好做",是"必须做"。日志的访问权限通常比数据库宽松得多。

四、第三现场:数据库连接与锁

应用日志指向"数据库连接超时",接下来查数据库:

-- 1. 连接数按状态统计
SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count(*) DESC;
/*
 state              | count
--------------------+-------
 idle in transaction|    58     ← 危险信号
 active             |    31
 idle               |     9
*/

idle in transaction 是红色警报:这些连接开启了一个事务但什么都没干(通常是因为应用在事务里做了别的事——比如发了个 HTTP 请求、跑了个 ffmpeg),它们持有锁、阻塞 vacuum、占着连接不放。

-- 2. 看当前上限
SHOW max_connections;                      -- 100
SELECT count(*) FROM pg_stat_activity;     -- 98

-- 3. 找出最老的事务
SELECT pid,
       now() - xact_start AS duration,
       state,
       wait_event_type,
       left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY duration DESC
LIMIT 10;

结果里有一条跑了 18 分钟的事务,查询是那个统计接口的全表扫描。

-- 4. 看谁在等锁
SELECT pid, wait_event_type, wait_event, left(query, 60)
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

止血

-- 温柔:取消这个连接的当前查询
SELECT pg_cancel_backend(<pid>);

-- 强硬:直接终止连接
SELECT pg_terminate_backend(<pid>);

-- 批量清理:终止所有 idle in transaction 超过 5 分钟的连接
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > interval '5 minutes';

我当时是先 pg_cancel_backend 试了一次(没用,因为连接池会立刻重发),然后批量 terminate 了所有 idle in transaction 的连接,再重启应用让连接池重建。

注意:重启应用是必要的,因为 Django 的连接池里可能还持有那些被 terminate 的死连接,不重启的话会一直报错。

五、第四现场:Celery 队列

应用恢复了,但还要确认异步任务没被拖垮:

redis-cli llen parse
redis-cli llen download
celery -A video_downloader inspect active

这次事故里队列也积压了 3000 多个任务,因为任务里也要查数据库。好消息是队列会自己消化(不像 HTTP 请求会超时失败),坏消息是积压期间用户看到的是"排队中"。

这也是我后来坚持"队列长度要告警"的原因:HTTP 请求失败你能立刻看到(502),队列积压是静默的——用户只是觉得慢,不会报错,等你发现可能已经积压几小时了。

六、根因:一条慢查询引发的雪崩

把线索串起来:

13:52  统计接口的全表扫描开始变慢(数据量增长到某个临界点)
   ↓
13:55  这个查询占住数据库连接(每个请求 8 秒)
   ↓
13:58  并发上来,连接被占满
   ↓
14:00  新请求拿不到连接 → Django 报 OperationalError
   ↓
14:01  用户看到错误 → 刷新页面 → 请求量翻倍
   ↓
14:03  连接彻底耗尽,gunicorn worker 全部卡在等连接
   ↓
14:03  nginx 连不上 → 502

典型的雪崩(cascade failure):一个小小的慢查询,通过"用户重试"这个放大器,变成了全站不可用。

三个关键放大环节:

  1. 慢查询:根本原因是索引缺失(那篇文章里讲过);
  2. 连接耗尽:没有连接池上限保护,请求无限排队;
  3. 重试风暴:用户 + 前端自动重试,让请求量翻倍。

对应的三个修复:

环节 修复
慢查询 加索引、加游标分页、总数用近似值(前面那篇讲过)
连接耗尽 加 pgbouncer,设置连接获取超时(statement_timeout、CONN_MAX_AGE)
重试风暴 前端重试加指数退避 + 抖动,服务端加熔断

statement_timeout 是个特别值得加的保护:

DATABASES = {
    'default': {
        ...
        'OPTIONS': {
            'statement_timeout': 5000,      # 单条 SQL 超过 5 秒就取消(毫秒)
            'connect_timeout': 5,
        },
    }
}

这样任何一条失控的 SQL 最多占用 5 秒就会被 PostgreSQL 杀掉,不会把整个库拖死。这个参数是我这次事故之后加的第一条配置,它把"一个慢查询拖垮整站"的可能性直接掐断了。

前端重试的退避:

async function fetchWithRetry(url, maxRetry = 3) {
    for (let i = 0; i < maxRetry; i++) {
        try {
            return await fetch(url);
        } catch (e) {
            if (i === maxRetry - 1) throw e;
            // 指数退避 + 随机抖动(避免所有客户端同时重试)
            const delay = Math.min(1000 * 2 ** i, 8000) + Math.random() * 1000;
            await new Promise(r => setTimeout(r, delay));
        }
    }
}

抖动(jitter)很重要:不加大批客户端会在同一时刻重试,形成"重试尖峰",反而更糟。

七、恢复:先止血,再治病

事故处理的原则:先恢复服务,再找根因。不要在生产上"边查边改"。

我们的止血顺序(写在运维手册里了):

  1. 确认影响面:哪些功能挂了?全站还是部分?
  2. 降级:关掉非核心功能(我们临时下线了统计接口);
  3. 限流:如果请求量异常,先在 nginx 层限;
  4. 重启应用:重建连接池(最快见效);
  5. 清数据库:terminate 异常连接;
  6. 确认恢复:看 nginx 日志的 502 是否消失;
  7. 再查根因:这时候才有时间慢慢查。

第 2 步"降级"是最容易被忽略的。人在慌的时候容易想着"赶紧修好那个 bug",但正确的做法是先把出问题的功能摘掉,让其他功能恢复,再从容修 bug。我们有个开关:

# settings.py 或数据库配置表
FEATURE_FLAGS = {
    'stats_page': False,        # 出事时改成 False,页面直接返回维护中
}

feature flag 是运维的救命稻草,成本极低,收益极大。现在任何非核心的新功能我都会加开关。

八、事后补的三层监控

这次之后我们建了三层监控,从外到内:

第一层:外部可用性(用户视角)

最重要的告警,因为它测的是"用户能不能用"。内部的 CPU、内存再正常,用户访问不了就是事故。

最简单的方式是定时拨测:

#!/bin/bash
# /opt/scripts/probe.sh —— 每分钟跑一次,失败就告警
URLS=("https://example.com/" "https://example.com/api/health")

for url in "${URLS[@]}"; do
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url")
    if [ "$code" != "200" ]; then
        echo "ALERT: $url returned $code"
        # 发到告警渠道
    fi
done

有条件的用 Prometheus 的 blackbox_exporter,能看趋势和响应时间。

健康检查接口要真检查,别写个永远返回 200 的 /health:

def health(request):
    from django.db import connection
    from django.core.cache import cache
    checks = {}
    try:
        with connection.cursor() as c:
            c.execute('SELECT 1')
        checks['db'] = 'ok'
    except Exception as e:
        checks['db'] = f'fail: {e}'

    try:
        cache.set('health_check', '1', 5)
        checks['cache'] = 'ok' if cache.get('health_check') == '1' else 'fail'
    except Exception as e:
        checks['cache'] = f'fail: {e}'

    status = 200 if all(v == 'ok' for v in checks.values()) else 503
    return JsonResponse(checks, status=status)

要检查依赖(数据库、缓存、队列),而不是只返回 200。一个"数据库挂了但健康检查还是 200"的接口,等于没有。

第二层:资源指标

指标 阈值 为什么
CPU 使用率 > 80% 持续 5 分钟
内存使用率 > 85%
磁盘使用率 > 85% 加"预计 7 天写满"的趋势告警
inode 使用率 > 80% 容易忘
数据库连接数 > 80% of max 这次事故的关键指标
队列长度 > 1000 静默故障的唯一信号
gunicorn worker 存活数 < 预期
nginx 5xx 比例 > 1% 持续 2 分钟 最直接的用户影响指标

最小实现(没上 Prometheus 之前的土办法):

#!/bin/bash
# /opt/scripts/metrics.sh —— 每 5 分钟跑,异常就告警
HOST=$(hostname)

# 数据库连接数
DB_CONN=$(psql -U viddown -h 127.0.0.1 -tAc "SELECT count(*) FROM pg_stat_activity")
DB_MAX=$(psql -U viddown -h 127.0.0.1 -tAc "SHOW max_connections")
if [ "$DB_CONN" -gt $((DB_MAX * 80 / 100)) ]; then
    echo "WARN: $HOST db connections $DB_CONN/$DB_MAX"
fi

# 队列长度
QUEUE=$(redis-cli llen download)
if [ "$QUEUE" -gt 1000 ]; then
    echo "WARN: $HOST queue length $QUEUE"
fi

# 磁盘
df -h | awk 'NR>1 && $5+0 > 85 {print "WARN: '"$HOST"' disk " $6 " " $5}'

# 5xx 比例(最近 1000 行)
ERR=$(tail -1000 /var/log/nginx/access.log | grep -c ' 5[0-9][0-9] ')
if [ "$ERR" -gt 10 ]; then
    echo "WARN: $HOST 5xx count in last 1000 req: $ERR"
fi

第三层:业务指标

资源正常不代表业务正常。我们加了:

  • 任务成功率(成功数 / 总数),低于 90% 告警;
  • P95 响应时间(按接口分);
  • 每日活跃用户数(突然掉一半说明出问题了);
  • 关键接口的调用量(突然归零说明上游挂了)。

"突然归零"这类告警最容易被忽略但很有用——某个接口的调用量从每小时 2000 掉到 0,通常意味着链路断了。

告警降噪

监控做不好会变成"狼来了",最后所有告警都被忽略。三条原则:

  1. 持续时间:瞬时抖动不告警,要求持续 N 分钟(for: 5m);
  2. 分级:P1(电话/短信,影响用户)、P2(群消息,需要处理)、P3(日报,可延后);
  3. 聚合:同一类告警合并,别刷屏。

我们现在的规则:只有"外部拨测失败"和"5xx 比例超标"是 P1,其他全是 P2 以下。这样 P1 响起的时候一定是真的事故。

九、日志规范:出事了能不能查,全看这个

这次事故让我意识到:日志不是"打出来就行",它决定了事故排查的速度。我们定的规范:

该记什么

场景 级别 内容
请求完成 INFO request_id、路径、状态码、耗时、用户 ID
业务状态变更 INFO 谁、什么时候、从什么改成什么
外部调用(API/ffmpeg) INFO 目标、耗时、结果
可恢复的错误(重试成功) WARNING 错误类型、重试次数
不可恢复的错误 ERROR 完整堆栈 + 上下文(但脱敏)
降级/熔断触发 WARNING 触发条件、影响范围

不该记什么

  • 敏感信息(密码、token、cookie、身份证);
  • 大文件的完整内容(只记路径和大小);
  • 高频循环里的每一条(要采样或者汇总)。

三条实践

1. 错误日志必须带上下文

# 不好
except Exception:
    logger.error('下载失败')

# 好
except Exception:
    logger.error('下载失败', exc_info=True, extra={
        'task_id': task.id,
        'url': mask_url(task.url),      # 脱敏!
        'retry': retry_count,
        'file_size': task.size,
    })

exc_info=True 会把完整堆栈打出来。没有堆栈的错误日志等于没有日志。

2. 慢操作要记耗时

start = time.time()
result = call_external_api()
duration = time.time() - start
if duration > 1.0:
    logger.warning('外部 API 慢', extra={'duration_ms': int(duration*1000), 'api': name})
logger.info('外部 API 完成', extra={'duration_ms': int(duration*1000)})

3. 日志要能被机器读

JSON 格式 + 统一字段命名。这样出事的时候可以:

# 找出所有超过 5 秒的请求
jq 'select(.duration_ms > 5000)' /var/log/viddown/app.log

# 按接口统计错误数
jq -r 'select(.levelname=="ERROR") | .path' app.log | sort | uniq -c | sort -rn

比 grep 强太多了。

十、复盘文档怎么写

我们现在的复盘模板(真的在用):

# 事故复盘:<一句话描述>

## 影响
- 时间:2026-03-11 14:03 ~ 14:35(32 分钟)
- 影响范围:全部下载功能不可用,约 XXX 次请求失败
- 影响用户:约 XXX 人

## 时间线
| 时间 | 事件 |
|---|---|
| 13:52 | 根因触发 |
| 14:07 | 用户反馈(我们此时才知道) |
| ... | ... |

## 根因
<技术层面的根因,要具体到"哪行代码/哪个配置">

## 为什么没早发现
<监控/告警的缺失>

## 改进行动
| 行动 | 负责人 | 截止 | 状态 |
|---|---|---|---|
| 加 statement_timeout | 张三 | 03-12 | 已完成 |
| 建外部拨测 | 李四 | 03-15 | 已完成 |
| 慢查询治理 | 王五 | 03-20 | 进行中 |

## 做得好的地方
<也要写,比如恢复速度快、沟通及时>

三条原则:

1. 不追责(blameless)。复盘的目的是让系统更可靠,不是找人背锅。如果复盘让大家不敢上报问题,那下次事故会更晚被发现。写"为什么系统允许这件事发生",不写"谁搞的"。

2. 行动项要有 owner 和 deadline。没有这两个的"改进措施"最后都会不了了之。我们每周会过一次未完成的行动项。

3. 也要写"做得好的"。比如这次"恢复只用了 5 分钟""用户沟通及时",这些要固化下来。

十一、坑清单

  1. 没有外部拨测 → 靠用户告诉我们出事了,晚 5 分钟(好的情况是用户愿意说,不然更晚)。
  2. 健康检查接口永远返回 200 → 数据库挂了它还是绿的。要真检查依赖。
  3. 日志没有 request_id → 一次请求的日志散落各处,拼不起来。
  4. 错误日志没有堆栈 → 知道失败了不知道为什么。用 exc_info=True。
  5. 日志不轮转 → 磁盘写满,二次事故。
  6. 日志里带敏感信息 → 令牌泄露。
  7. 没设 statement_timeout → 一条慢 SQL 拖垮整库。
  8. 监控只看 CPU/内存/磁盘 → 这次事故这三个指标全正常。要监控连接数和队列长度。
  9. 没有队列长度告警 → 队列积压是静默的。
  10. 前端重试没退避 → 重试风暴,雪崩放大器。
  11. 没有 feature flag → 不能快速摘掉问题功能。
  12. 没有 pgbouncer → 连接数无上限保护。
  13. 告警不分级 → 所有告警都是 P1,最后全是噪音。
  14. 复盘没 owner 和 deadline → 改进项永远做不完。
  15. 复盘变成追责会 → 下次没人敢上报问题,事故发现得更晚。

这次事故之后,我最大的改变不是技术上的,是心态上的:

以前我觉得"监控是锦上添花"——服务跑得好好的,加什么监控。现在我明白,没有监控的服务等于是在裸奔,你只是还不知道自己已经出问题了。这次运气好,用户在群里反馈了;如果用户只是默默关掉页面走了呢?我们可能几天都不知道。

还有一点体会:故障复盘这件事本身比修复那个 bug 更有价值。那个慢查询加个索引就修好了,半小时的事;但复盘让我们补上了监控、日志规范、超时保护、熔断降级——这些是"让系统不容易再出同类事故"的东西。

现在我们有个规矩:任何 P1 事故,48 小时内必须出复盘文档。这个规矩执行下来一年,事故数量降了三分之二,而且平均恢复时间从 35 分钟降到了 8 分钟。不是因为我们变聪明了,是因为我们能更早知道并且更快定位。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部