那天下午两点零几分,用户在群里问:"下载是不是挂了?"
我打开站点,页面能开,但一点下载就 502。SSH 上去看,gunicorn 进程还在,CPU 不高,内存正常,磁盘也够——所有"看起来应该有问题的指标"都是好的。
花了 35 分钟才定位到:数据库连接耗尽了。原因是一条慢查询在高峰时段被放大,请求堆积,用户不断刷新让请求翻倍,最后连接池打满,整个站点的写操作全部卡死。
恢复只用了 5 分钟(杀掉长事务 + 重启应用),但事后复盘做了两天。因为我们意识到一件更严重的事:这次是靠用户告诉我们才知道出事了。没有监控、没有告警、日志里也什么都看不出来。这篇写完整的时间线、每一步用什么命令,以及我们后来补上的东西。
TL;DR:定位顺序是 nginx 日志(看状态码和 upstream 时间)→ 应用日志(看报错和慢请求)→ 数据库(看连接数和锁)→ 队列(看堆积)。这次的根因是"慢查询 → 请求堆积 → 用户重试 → 雪崩"的连锁反应。事后补的三层监控:外部可用性拨测(用户视角)、资源指标(CPU/内存/磁盘/连接数/队列长度)、业务指标(成功率、P95 响应时间)。另外:日志必须结构化 + 带 request_id,否则出事了根本没法查。
目录
- 一、事故时间线:35 分钟里发生了什么
- 二、第一现场:nginx 日志
- 三、第二现场:应用日志
- 四、第三现场:数据库连接与锁
- 五、第四现场:Celery 队列
- 六、根因:一条慢查询引发的雪崩
- 七、恢复:先止血,再治病
- 八、事后补的三层监控
- 九、日志规范:出事了能不能查,全看这个
- 十、复盘文档怎么写
- 十一、坑清单
一、事故时间线: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 请求失败
问题在哪:
- 不知道是哪个请求(没有 request_id);
- 不知道失败原因(只写了"请求失败");
- 不知道耗时(没有耗时记录);
- 日志级别用错(失败用了 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):一个小小的慢查询,通过"用户重试"这个放大器,变成了全站不可用。
三个关键放大环节:
- 慢查询:根本原因是索引缺失(那篇文章里讲过);
- 连接耗尽:没有连接池上限保护,请求无限排队;
- 重试风暴:用户 + 前端自动重试,让请求量翻倍。
对应的三个修复:
| 环节 | 修复 |
|---|---|
| 慢查询 | 加索引、加游标分页、总数用近似值(前面那篇讲过) |
| 连接耗尽 | 加 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)很重要:不加大批客户端会在同一时刻重试,形成"重试尖峰",反而更糟。
七、恢复:先止血,再治病
事故处理的原则:先恢复服务,再找根因。不要在生产上"边查边改"。
我们的止血顺序(写在运维手册里了):
- 确认影响面:哪些功能挂了?全站还是部分?
- 降级:关掉非核心功能(我们临时下线了统计接口);
- 限流:如果请求量异常,先在 nginx 层限;
- 重启应用:重建连接池(最快见效);
- 清数据库:terminate 异常连接;
- 确认恢复:看 nginx 日志的 502 是否消失;
- 再查根因:这时候才有时间慢慢查。
第 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,通常意味着链路断了。
告警降噪
监控做不好会变成"狼来了",最后所有告警都被忽略。三条原则:
- 持续时间:瞬时抖动不告警,要求持续 N 分钟(
for: 5m); - 分级:P1(电话/短信,影响用户)、P2(群消息,需要处理)、P3(日报,可延后);
- 聚合:同一类告警合并,别刷屏。
我们现在的规则:只有"外部拨测失败"和"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 分钟""用户沟通及时",这些要固化下来。
十一、坑清单
- 没有外部拨测 → 靠用户告诉我们出事了,晚 5 分钟(好的情况是用户愿意说,不然更晚)。
- 健康检查接口永远返回 200 → 数据库挂了它还是绿的。要真检查依赖。
- 日志没有 request_id → 一次请求的日志散落各处,拼不起来。
- 错误日志没有堆栈 → 知道失败了不知道为什么。用
exc_info=True。 - 日志不轮转 → 磁盘写满,二次事故。
- 日志里带敏感信息 → 令牌泄露。
- 没设
statement_timeout→ 一条慢 SQL 拖垮整库。 - 监控只看 CPU/内存/磁盘 → 这次事故这三个指标全正常。要监控连接数和队列长度。
- 没有队列长度告警 → 队列积压是静默的。
- 前端重试没退避 → 重试风暴,雪崩放大器。
- 没有 feature flag → 不能快速摘掉问题功能。
- 没有 pgbouncer → 连接数无上限保护。
- 告警不分级 → 所有告警都是 P1,最后全是噪音。
- 复盘没 owner 和 deadline → 改进项永远做不完。
- 复盘变成追责会 → 下次没人敢上报问题,事故发现得更晚。
这次事故之后,我最大的改变不是技术上的,是心态上的:
以前我觉得"监控是锦上添花"——服务跑得好好的,加什么监控。现在我明白,没有监控的服务等于是在裸奔,你只是还不知道自己已经出问题了。这次运气好,用户在群里反馈了;如果用户只是默默关掉页面走了呢?我们可能几天都不知道。
还有一点体会:故障复盘这件事本身比修复那个 bug 更有价值。那个慢查询加个索引就修好了,半小时的事;但复盘让我们补上了监控、日志规范、超时保护、熔断降级——这些是"让系统不容易再出同类事故"的东西。
现在我们有个规矩:任何 P1 事故,48 小时内必须出复盘文档。这个规矩执行下来一年,事故数量降了三分之二,而且平均恢复时间从 35 分钟降到了 8 分钟。不是因为我们变聪明了,是因为我们能更早知道并且更快定位。