上一篇:04 演示应用与 APM 采集 | 目录:README | 下一篇:06 主机监控与多机 Agent
上一篇结束时,每个请求的指标在响应返回之前同步写进了 SQLite。 演示没问题,但只要上点并发就会现出原形:SQLite 是单写者锁——同一时刻只允许一个 线程写库,所有请求线程排队抢锁、每条 INSERT 各自 fsync。压测数据(16 线程闭环):
| 写库方式 | QPS | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| 逐条同步写(改造前) | 74 | 92 | 958 | 2190 | 5499 |
| 批量缓冲写(改造后) | 91 | 176 | 214 | 251 | 644 |
p99 降了 8.7 倍。这一篇讲清楚这笔账怎么算、代码怎么写、边界怎么定—— 以及把全平台的取数枢纽"指标注册表"立起来。
一、问题解剖:慢在哪
把一次"同步落库的请求"拆开看:
请求线程: [视图 8ms] ──[排队等写锁 ~200ms]──[INSERT + fsync ~3ms]──[返回]
↑ 所有并发请求在这里挤独木桥
三个成本叠加:抢锁排队(单写者,一次一人)、每条 INSERT 一个事务(各自 fsync)、 采集在响应路径上(用户等采集写完才拿到响应)。
思路自然浮出水面:把"写库"从请求线程剥离。请求线程只往内存队列
put_nowait 一次(微秒级),一个后台线程按"攒满 200 条或满 1 秒"一条
bulk_create 批量落库——写库次数从"每请求一次"降到"每批一次",
fsync 被摊薄,请求线程彻底不参与写锁竞争。
二、完整实现:monitor/buffer.py
用下面的完整版整体替换 04 篇的占位版(接口不变,submit() 签名一致):
"""
monitor/buffer.py — 请求指标批量缓冲(APM 热路径与写库解耦)
改造前:中间件在响应返回前同步 INSERT 一行 RequestMetric。单写者数据库
(SQLite)下所有请求线程排队抢同一把写锁、每条 INSERT 各自 fsync,
压测里表现为 p50 5ms 而 p99 800ms 的长尾。
改造后:请求线程只做一次 put_nowait(微秒级、不碰数据库),
后台线程按"攒满 N 条 或 到期 T 秒"用一条 bulk_create 批量落库——
写库次数从"每请求一次"降到"每批一次",事务数(fsync 数)同样被摊薄。
代价与边界(都是有意选择,且可观测):
- 指标可见延迟最多 FLUSH_SEC 秒(大盘/告警读的是库,不是实时总线);
- 队列满时丢新点并计数:观测数据可以采样,采集不允许反压业务;
丢弃数经 /metrics 的 obs_metric_buffer_dropped_total 暴露;
- 后台落库遇瞬时故障(单写者锁、连接抖动)先重试 2 次,仍失败才整批丢弃并计数;
- 进程被 SIGKILL 时缓冲区里未落库的点会丢(atexit 只覆盖正常退出)。
`OBSERVABILITY['METRIC_BUFFER_ENABLED']` 为假时 submit() 直接同步写库,
管理命令与测试因此仍看到"请求结束即落库"的语义。
"""
import atexit
import logging
import os
import queue
import threading
import time
from django.conf import settings
from django.db import connection, transaction
from django.utils import timezone
logger = logging.getLogger(__name__)
DEFAULT_BATCH_SIZE = 200
DEFAULT_FLUSH_SEC = 1.0
DEFAULT_QUEUE_SIZE = 20000
# 后台线程落库遇到瞬时故障(单写者锁、连接抖动)时的重试预算:最多 1+2 次尝试
WRITE_RETRIES = 2
WRITE_RETRY_SEC = 0.05
# 队列里放的是构造 RequestMetric 的字段字典(请求线程不实例化模型,省一次开销)
_q = queue.Queue()
_q_sized = False
_stats = {'written': 0, 'dropped': 0, 'batches': 0, 'errors': 0}
# 可重入:start() 持锁期间会调用 _ensure_queue_size()
_lock = threading.RLock()
_flusher = None
_stop = threading.Event()
_last_drop_log = 0.0
def _cfg(key, default):
return settings.OBSERVABILITY.get(key, default)
def _ensure_queue_size():
"""按配置确定队列容量(Queue 容量建好后不可改,故延迟到首次使用)"""
global _q, _q_sized
if _q_sized:
return
with _lock:
if _q_sized:
return
size = int(_cfg('METRIC_BUFFER_QUEUE_SIZE', DEFAULT_QUEUE_SIZE))
if size > 0:
_q = queue.Queue(maxsize=size)
_q_sized = True
def enabled():
"""缓冲区是否启用(关闭时 submit 退化为同步写库)"""
return bool(_cfg('METRIC_BUFFER_ENABLED', False))
def queued():
return _q.qsize()
def stats():
"""缓冲区自观测:落库/丢弃条数、批次数、失败批次数、当前积压"""
return dict(_stats, pending=queued(), enabled=enabled(),
batch_size=int(_cfg('METRIC_BUFFER_BATCH_SIZE', DEFAULT_BATCH_SIZE)),
flush_sec=float(_cfg('METRIC_BUFFER_FLUSH_SEC', DEFAULT_FLUSH_SEC)))
def write_rows(rows, retries=0):
"""把一批字段字典写入库,返回落库条数。
`retries` > 0 时,失败先重试(间隔逐次翻倍)再放弃——SQLite 的单写者锁
"database table is locked" 属于典型瞬时故障,让整批点因为一次锁竞争蒸发
说不过去。最终仍写不进去才整批丢弃并计数:采集组件不能因为自己写不进去
而把调用方拖垮。失败后关闭本线程连接,下一批自动重连(数据库重启/连接失效)。
"""
from .models import RequestMetric
if not rows:
return 0
objs = [RequestMetric(**r) for r in rows]
batch_size = max(1, int(_cfg('METRIC_BUFFER_BATCH_SIZE', DEFAULT_BATCH_SIZE)))
for attempt in range(retries + 1):
try:
with transaction.atomic():
RequestMetric.objects.bulk_create(objs, batch_size=batch_size)
_stats['written'] += len(objs)
_stats['batches'] += 1
return len(objs)
except Exception as exc:
if attempt < retries:
logger.warning('RequestMetric 批量落库失败(第 %d/%d 次尝试):%s',
attempt + 1, retries + 1, exc)
time.sleep(WRITE_RETRY_SEC * (2 ** attempt))
continue
_stats['errors'] += 1
_stats['dropped'] += len(objs)
logger.exception('RequestMetric 批量落库失败,本批 %d 条已丢弃', len(objs))
try:
connection.close()
except Exception:
pass
return 0
def submit(payload):
"""提交一条请求指标。返回 'buffered' / 'written' / 'dropped',永不抛异常。"""
global _last_drop_log
payload.setdefault('created_at', timezone.now())
if not enabled():
write_rows([payload])
return 'written'
_ensure_queue_size()
try:
_q.put_nowait(payload)
return 'buffered'
except queue.Full:
_stats['dropped'] += 1
now = time.monotonic()
if now - _last_drop_log > 60: # 限流日志:打满时每秒刷一条会淹没真错误
_last_drop_log = now
logger.error('请求指标缓冲区已满,累计丢弃 %d 条(写库跟不上采集)',
_stats['dropped'])
return 'dropped'
def flush():
"""立即排空队列并落库(压测/管理命令/测试同步取数用),返回落库条数"""
written = 0
while True:
rows, drained = [], False
try:
while True:
rows.append(_q.get_nowait())
except queue.Empty:
drained = True
written += write_rows(rows)
if drained:
return written
def _flusher_loop():
batch_size = max(1, int(_cfg('METRIC_BUFFER_BATCH_SIZE', DEFAULT_BATCH_SIZE)))
flush_sec = float(_cfg('METRIC_BUFFER_FLUSH_SEC', DEFAULT_FLUSH_SEC))
batch = []
deadline = time.monotonic() + flush_sec
while not _stop.is_set():
if batch and (len(batch) >= batch_size or time.monotonic() >= deadline):
write_rows(batch, retries=WRITE_RETRIES)
batch = []
deadline = time.monotonic() + flush_sec
try:
batch.append(_q.get(timeout=0.2)) # 短等待,停服时能尽快退出
except queue.Empty:
continue
write_rows(batch, retries=WRITE_RETRIES)
def start():
"""启动 flusher 线程(幂等)。缓冲区未启用时不启动,保持同步写库语义。"""
global _flusher
if not enabled():
return False
with _lock:
if _flusher is not None:
return True
_ensure_queue_size()
_stop.clear()
_flusher = threading.Thread(target=_flusher_loop, name='obs-metric-buffer',
daemon=True)
_flusher.start()
atexit.register(shutdown)
logger.info('请求指标缓冲线程已启动:batch=%s flush=%ss',
_cfg('METRIC_BUFFER_BATCH_SIZE', DEFAULT_BATCH_SIZE),
_cfg('METRIC_BUFFER_FLUSH_SEC', DEFAULT_FLUSH_SEC))
return True
def shutdown():
"""停止 flusher 并把剩余点排空(正常退出路径不丢数据)"""
global _flusher
thread = _flusher
if thread is None:
flush()
return
_flusher = None
_stop.set()
thread.join(timeout=5)
flush()
def _reset_after_fork():
"""gunicorn/uwsgi 预加载(--preload)场景:master 进程建好缓冲区后 fork,
worker 只继承内存、不继承线程——句柄失效,重建。"""
global _lock, _flusher, _q, _q_sized
_lock = threading.RLock()
_flusher = None
_q = queue.Queue()
_q_sized = False
_stop.clear()
try:
start()
except Exception: # pragma: no cover - fork 处理路径尽力而为
logger.exception('fork 后重启请求指标缓冲失败')
if hasattr(os, 'register_at_fork'):
os.register_at_fork(after_in_child=_reset_after_fork)
三、接线:settings、线程启动、以及四个"边界决定"
3.1 settings 补配置
在 OBSERVABILITY 字典末尾(LOCAL_COLLECTOR 之前)加一段;文件顶部补 import sys:
import os
import sys
from pathlib import Path
# ---- 请求指标批量缓冲(monitor/buffer.py)----
# 请求线程只入队,后台线程按"攒满 N 条或到期 T 秒"bulk_create 批量落库。
# 关闭(或管理命令/测试进程)时退化为同步写库,保持"请求结束即落库"的语义。
'METRIC_BUFFER_BATCH_SIZE': max(1, int(os.environ.get('OBS_METRIC_BUFFER_BATCH', '200'))),
'METRIC_BUFFER_FLUSH_SEC': max(0.1, float(os.environ.get('OBS_METRIC_BUFFER_FLUSH_SEC', '1'))),
'METRIC_BUFFER_QUEUE_SIZE': max(1, int(os.environ.get('OBS_METRIC_BUFFER_QUEUE_SIZE', '20000'))),
'METRIC_BUFFER_ENABLED': os.environ.get('OBS_METRIC_BUFFER', '1') != '0',
并在 settings 文件末尾追加(管理命令与测试自动退回同步写库):
# ---------------- 管理命令与测试:缓冲关闭,退回同步写库 ----------------
# 这些场景必须"写完立刻读得到",且 migrate 期间表可能还不存在,缓冲线程反而添乱。
_current_cmd = sys.argv[1] if len(sys.argv) > 1 else ''
if _current_cmd in WORKER_START_EXCLUDE_CMDS:
OBSERVABILITY['METRIC_BUFFER_ENABLED'] = False
(WORKER_START_EXCLUDE_CMDS 集合 03 篇已建好——当年占的位现在兑现。)
3.2 线程启动器与 AppConfig
新建 monitor/workers.py(本篇先只管缓冲线程;06 篇会把四条后台线程加进来):
"""
monitor/workers.py — 后台线程的启动器
runserver 下只在自动重载的子进程里启动一次(RUN_MAIN=1),
migrate / shell / init_data 等管理命令不启动。线程全部为 daemon,随进程退出。
"""
import logging
import os
import sys
import threading
from django.conf import settings
logger = logging.getLogger(__name__)
_started = False
_start_lock = threading.Lock()
def _should_start():
if os.environ.get('OBS_DISABLE_WORKERS') == '1':
return False
if 'runserver' in sys.argv:
# 自动重载模式:仅子进程(RUN_MAIN=1)启动;--noreload 模式:本进程即服务进程
return os.environ.get('RUN_MAIN') == '1' or '--noreload' in sys.argv
command = (sys.argv[1] if len(sys.argv) > 1 else '')
if command in settings.WORKER_START_EXCLUDE_CMDS:
return False
return True
def maybe_start_workers():
# 请求指标缓冲线程在 Web 进程里也必须起:指标由 Web 进程产生
from . import buffer
try:
buffer.start()
except Exception:
logger.exception('请求指标缓冲线程启动失败')
# (06 篇在这里加:if _should_start(): start_workers() —— 采集/告警等四条线程)
改 monitor/apps.py:
from django.apps import AppConfig
from django.db.backends.signals import connection_created
def _set_sqlite_pragma(sender, connection, **kwargs):
"""SQLite 开启 WAL:后台采集线程与请求线程并发读写更稳
(config/__init__.py 已全局注册一份;这里保留 app 级注册,两处幂等)"""
if connection.vendor != 'sqlite':
return
try:
cursor = connection.cursor()
cursor.execute('PRAGMA journal_mode=WAL;')
cursor.execute('PRAGMA synchronous=NORMAL;')
cursor.close()
except Exception:
pass
class MonitorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'monitor'
verbose_name = '全栈可观测运维平台'
def ready(self):
connection_created.connect(_set_sqlite_pragma, dispatch_uid='obs-sqlite-wal')
from .workers import maybe_start_workers
maybe_start_workers()
为什么挂在 AppConfig.ready():Django 启动完成 app 装载后自动调用,
不用改 manage.py;dispatch_uid 防信号重复注册;runserver 的重载机制会起两个进程,
RUN_MAIN 判断保证只有真正服务请求的子进程启动线程。
3.3 四个边界决定(面试追问高发区)
- 为什么丢新点不丢旧点、且要计数?观测数据允许采样(少一个点曲线照样连贯),
业务请求不允许被采集反压(宁可丢点不能堵请求)。丢弃不可怕,丢得悄无声息才可怕——
所以计数并经
/metrics暴露(11 篇接 Prometheus 输出); - 为什么重试 2 次:SQLite "table is locked" 是瞬时锁竞争,让整批 200 个点因一次
锁竞争蒸发说不过去;2 次(间隔 50ms 翻倍)是"等一把"和"别添乱"的平衡。重试用尽才整批
丢弃并计数;失败后
connection.close()让下一批重连(覆盖数据库重启场景); - 为什么 atexit 排空:
Ctrl+C/正常重启时把队列剩余点落库,正常退出路径不丢数据; SIGKILL 无能为力——最多丢一个批次 ≤200 条,这是明码标价的边界; - 为什么管理命令退回同步写:测试和 shell 里"写完必须读得到"(断言语义), migrate 期间表都不存在,后台缓冲线程纯属添乱。
3.4 亲眼看长尾消失(可选压测)
启动服务后,拿你熟悉的压测工具(ab/wrk/hey 均可)打 /forum/problem/:
# 例:16 并发 2000 请求(ab 是 Apache Benchmark)
ab -n 2000 -c 16 -C "sessionid=x" http://127.0.0.1:8014/forum/problem/
(注:登录门禁会拦匿名请求, -C "sessionid=x" 只是让请求通过 cookie 检查进入视图,
或临时用会话 cookie 的真实值。看趋势即可,数字绝对值依赖机器。)
关掉缓冲再压一次对比:OBS_METRIC_BUFFER=0 python manage.py runserver 8014。
你会直观看到 p99 的差别——这就是 2190ms → 251ms 的来源。
四、指标注册表:全平台的取数枢纽
数据落库了,接下来解决"读"。总览、大盘、告警、智能分析都要"按分钟聚合的指标序列",
如果各自写聚合,口径迟早漂移(02 篇决策 1)。建 monitor/registry.py:
"""
monitor/registry.py — 指标注册表(全平台统一取数入口)
总览页、自定义大盘、告警策略下拉、智能分析共用:
每个指标 key 对应 {标签, 单位, series(minutes)->按分钟聚合的点列}。
新增一个可观测指标只需在这里注册一项,大盘/告警/分析立即生效。
"""
from django.db.models import Avg, Count, Sum
from django.db.models.functions import TruncMinute
from django.utils import timezone
# ---------------------------------------------------------------------------
# 通用工具
# ---------------------------------------------------------------------------
def _since(minutes):
return timezone.now() - timezone.timedelta(minutes=minutes)
def _bucket(qs, annotate_map, ts_field='created_at'):
"""按分钟聚合,返回 [(时间字符串, dict(...))] 升序"""
rows = (
qs.annotate(bucket=TruncMinute(ts_field))
.values('bucket')
.annotate(**annotate_map)
.order_by('bucket')
)
return [(r['bucket'], r) for r in rows]
def _fmt(dt):
return timezone.localtime(dt).strftime('%H:%M')
def _p95(values):
if not values:
return 0.0
vs = sorted(values)
k = max(0, min(len(vs) - 1, int(len(vs) * 0.95)))
return float(vs[k])
# ---------------------------------------------------------------------------
# HTTP / APM 指标(RequestMetric)
# ---------------------------------------------------------------------------
def http_request_count(minutes):
from .models import RequestMetric
pts = _bucket(
RequestMetric.objects.filter(created_at__gte=_since(minutes)),
{'v': Count('id')},
)
return [{'t': _fmt(t), 'v': float(r['v'] or 0)} for t, r in pts]
def http_avg_duration(minutes):
from .models import RequestMetric
pts = _bucket(
RequestMetric.objects.filter(created_at__gte=_since(minutes)),
{'v': Avg('duration_ms')},
)
return [{'t': _fmt(t), 'v': round(float(r['v'] or 0), 1)} for t, r in pts]
def http_p95_duration(minutes):
from .models import RequestMetric
qs = RequestMetric.objects.filter(created_at__gte=_since(minutes))
per_min = {}
for ts, dur in qs.values_list('created_at', 'duration_ms'):
key = ts.replace(second=0, microsecond=0)
per_min.setdefault(key, []).append(dur)
out = []
for t in sorted(per_min):
out.append({'t': _fmt(t), 'v': round(_p95(per_min[t]), 1)})
return out
def http_error_rate(minutes):
from django.db.models import Case, When
from .models import RequestMetric
pts = _bucket(
RequestMetric.objects.filter(created_at__gte=_since(minutes)),
{'total': Count('id'),
'errs': Sum(Case(When(is_error=True, then=1), default=0))},
)
return [
{'t': _fmt(t), 'v': round((r['errs'] or 0) * 100.0 / r['total'], 2) if r['total'] else 0.0}
for t, r in pts
]
def http_sql_avg(minutes):
from .models import RequestMetric
pts = _bucket(
RequestMetric.objects.filter(created_at__gte=_since(minutes)),
{'v': Avg('sql_count')},
)
return [{'t': _fmt(t), 'v': round(float(r['v'] or 0), 1)} for t, r in pts]
def http_slow_query_count(minutes):
from .models import RequestMetric
pts = _bucket(
RequestMetric.objects.filter(created_at__gte=_since(minutes)),
{'v': Sum('slow_query_count')},
)
return [{'t': _fmt(t), 'v': float(r['v'] or 0)} for t, r in pts]
# ---------------------------------------------------------------------------
# 注册表本体(本篇先注册 HTTP 域;主机/RUM/日志域在对应篇目加入)
# ---------------------------------------------------------------------------
REGISTRY = {
'http.request_count': ('请求数/分钟', '次/分', http_request_count),
'http.avg_duration': ('接口平均耗时', 'ms', http_avg_duration),
'http.p95_duration': ('接口 P95 耗时', 'ms', http_p95_duration),
'http.error_rate': ('接口错误率', '%', http_error_rate),
'http.sql_avg': ('平均 SQL 次数', '条/请求', http_sql_avg),
'http.slow_query_count': ('慢查询数', '条/分', http_slow_query_count),
}
def series(key, minutes):
"""按 key 取指标序列;custom.<name> 动态分发。
返回 None 表示 key 不存在。"""
if key.startswith('custom.'):
name = key[len('custom.'):]
from .models import CustomMetric
if not CustomMetric.objects.filter(name=name).exists():
return None # 自定义指标不存在:与未知 registry key 一致返回 None
pts = _bucket(
CustomMetric.objects.filter(name=name, created_at__gte=_since(minutes)),
{'v': Avg('value')},
)
return {'key': key, 'label': f'自定义指标 {name}', 'unit': '',
'points': [{'t': _fmt(t), 'v': round(float(r['v'] or 0), 3)} for t, r in pts]}
item = REGISTRY.get(key)
if not item:
return None
label, unit, fn = item
return {'key': key, 'label': label, 'unit': unit, 'points': fn(minutes)}
def metric_value(key, minutes=5):
"""取最近 N 分钟的均值(告警引擎用);无数据返回 None"""
s = series(key, minutes)
if not s or not s['points']:
return None
vals = [p['v'] for p in s['points'] if p['v'] is not None]
return round(sum(vals) / len(vals), 2) if vals else None
def catalog():
"""给下拉框用:[{key,label,unit}],含 custom.* 动态指标"""
items = [
{'key': k, 'label': f'{v[0]} ({v[1]})', 'unit': v[1]}
for k, v in REGISTRY.items()
]
from .models import CustomMetric
names = set(CustomMetric.objects.values_list('name', flat=True).distinct()[:50])
items += [{'key': f'custom.{n}', 'label': f'自定义指标 {n}', 'unit': ''} for n in sorted(names)]
return items
读这段代码的三个要点:
_bucket()是唯一的聚合原语:TruncMinute把时间戳截到分钟 →values分组 →annotate聚合。所有指标序列都长一个样:[{'t': '14:32', 'v': 123.0}, ...];- P95 的实现刻意在 Python 层做(SQLite 没有 percentile 函数;数据按分钟分组后 每组排序取 95 分位)——简单、可解释,量级完全够用;
series()支持custom.<name>动态前缀:自定义指标不需要进 REGISTRY 字典, 名字约定即注册——这就是"新增指标只改一处"的一半(另一半是上报 API,06 篇)。
接上总览页(第一张真数据曲线)
monitor/views.py 里的 overview 换成:
def overview(request):
"""监控总览:KPI 卡片 + 注册表指标曲线"""
from .registry import series
kpi = {
'req_5m': metric_value('http.request_count', 5),
'avg_5m': metric_value('http.avg_duration', 5),
'err_5m': metric_value('http.error_rate', 5),
}
charts = {
'request_count': series('http.request_count', 60),
'avg_duration': series('http.avg_duration', 60),
}
return render(request, 'monitor/overview.html', {'kpi': kpi, 'charts': charts})
(顶部记得 from .registry import metric_value 或统一在函数内 import——
保持与上面代码一致即可。)
templates/monitor/overview.html 换成:
{% extends "base.html" %}
{% block title %}监控总览{% endblock %}
{% block content %}
<h1>监控总览</h1>
<p>
近5分钟请求:<b>{{ kpi.req_5m|default:"-" }}</b> |
平均耗时:<b>{{ kpi.avg_5m|default:"-" }}</b> ms |
错误率:<b>{{ kpi.err_5m|default:"-" }}</b> %
</p>
<div id="c1" style="width:100%;height:320px;background:#fff;"></div>
<h3>接口平均耗时(60 分钟)</h3>
<div id="c2" style="width:100%;height:320px;background:#fff;"></div>
<script src="/static/echarts.min.js"></script>
<script>
const c1 = echarts.init(document.getElementById('c1'));
c1.setOption({
title: { text: '请求数/分钟' },
xAxis: { type: 'category', data: {{ charts.request_count.points|safe }}.map(p => p.t) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: {{ charts.request_count.points|safe }}.map(p => p.v),
smooth: true, areaStyle: {} }],
tooltip: { trigger: 'axis' },
});
const c2 = echarts.init(document.getElementById('c2'));
c2.setOption({
title: { text: '接口平均耗时 (ms)' },
xAxis: { type: 'category', data: {{ charts.avg_duration.points|safe }}.map(p => p.t) },
yAxis: { type: 'value' },
series: [{ type: 'line', data: {{ charts.avg_duration.points|safe }}.map(p => p.v),
smooth: true, areaStyle: {} }],
tooltip: { trigger: 'axis' },
});
</script>
{% endblock %}
ECharts 用本地自托管:把仓库 static/echarts.min.js(约 1MB)拷进你的 static/
目录(或从 echarts 官网下载)。为什么不用 CDN:监控平台要能跑在内网/断网环境,
自托管是"零外部依赖"承诺的一部分。
模板里
|safe+.map()是教学简化写法。仓库完整版经json_script安全嵌入 JSON 并配static/obs/common.js(图表/时间范围/轮询的公共工具)——XSS 防护的 正确姿势,10 篇安全清单里会回头点名。教程版请照抄下面更安全的形式:
把上面两个 {{ ... |safe }} 的出现方式改为 json_script:
{{ charts.request_count.points|json_script:"d1" }}
{{ charts.avg_duration.points|json_script:"d2" }}
<script>
const d1 = JSON.parse(document.getElementById('d1').textContent);
const d2 = JSON.parse(document.getElementById('d2').textContent);
// 之后 c1 用 d1.map(p => p.t) / d1.map(p => p.v),c2 同理
</script>
本篇检查点
- [ ] 重启服务,控制台出现"请求指标缓冲线程已启动:batch=200 flush=1.0s";
- [ ] 访问论坛几次,1 秒内刷新总览页能看到曲线出现新点(可见延迟 ≤1 秒的直接体验);
- [ ]
python manage.py shell里from monitor import buffer; buffer.stats()返回{'written': N, 'dropped': 0, 'pending': 0, ...}; - [ ] 总览页两张 ECharts 曲线正常渲染;
- [ ]
python manage.py test场景虽未建,但 shell 里跑buffer.submit({'path':'/t','method':'GET','status_code':200,'duration_ms':1})然后查 RequestMetric 立即可见(管理命令同步写语义)。
常见报错速查
| 报错 | 原因 | 解法 |
|---|---|---|
| 曲线一直空 | 缓冲线程没启动(看控制台日志)或访问的路径都在 SKIP_PATHS | 访问 /forum/problem/ 产生数据 |
NameError: name 'metric_value' |
views 里漏 import | 对照总览视图代码 |
图表空白且控制台 echarts is not defined |
static/echarts.min.js 没拷 |
从仓库拷入或官网下载 |
页面 500:name 'sys' is not defined |
settings 顶部没补 import sys |
见 3.1 |
| 重启后丢最近 1 秒数据 | SIGKILL 边界(正常退出会排空) | 正常 Ctrl+C 停服,属预期 |
请求指标有了"写"的通路和"读"的枢纽。下一篇把观测范围从 HTTP 扩到机器本身: psutil 采集线程、四条后台线程的启动接线、租约选主,以及接入第一台外部服务器—— 06 · 主机监控与多机 Agent。