需求清单有了,这一篇把它翻译成三样东西:一张数据流架构图、9 个 app 的职责边界、 一组关键设计决策(每个都带"为什么选它、放弃了什么")。设计做到这个颗粒度就够了—— 剩下的细节交给开发时按需决定,过度设计和没有设计一样危险。
一、数据流架构:四路采集,一个枢纽
先看全景。平台的本质是一条单向数据管道:采集 → 存储 → 聚合 → 展示/告警。
┌─ 浏览器 ─────────────┐ ┌─ Django 进程 ─────────────────────────────┐
│ rum.js(自研 SDK) │ │ RequestTimingMiddleware │
│ PV/SPA路由 │ │ 耗时/状态码/TraceID/SQL span/错误标记 │
│ TTFB·FCP·LCP │──▶│ → RequestMetric(含 spans JSON 瀑布数据) │
│ JS异常 │ │ hosts.collector 线程(15s) │
│ fetch/XHR 包装 │ │ psutil → HostMetric │
│ 慢资源 │ │ loghub.SQLiteLogHandler(WARNING+) │
└───────────┬──────────┘ │ → LogEntry │
│ POST /rum/beacon/ │ alerts.engine 线程(30s) │
┌───────────▼──────────┐ │ registry 均值 vs 阈值 → 事件+通知 │
│ 外部程序 / 脚本 │──▶│ OpenAPI: │
│ POST /logs/api/ingest/ │ /api/ingest/metrics/ 自定义指标 │
│ POST /api/ingest/metrics/│ /logs/api/ingest/ 日志 │
└──────────────────────┘ └────────────────────────────────────────────┘
(另外:外部服务器跑 obs_agent.py 推送 /api/ingest/host/ → HostMetric)
读这张图记住三件事:
- 四路采集互不干扰:请求中间件(服务端 APM)、主机线程(系统指标)、 日志 Handler(logging 体系)、浏览器 SDK(RUM)各自独立, 坏一路不影响其余三路;
- 所有展示与告警都从库里读,不从内存总线读——组件可以随时重启, 大盘刷新一下就是权威数据;
- 枢纽是指标注册表(
monitor/registry.py):它把库里的原始数据聚合成 "按分钟聚合的指标序列",总览、大盘、告警、智能分析全部来这一个地方取数。
二、Django app 划分:9 个应用各管一段
Django 的 app 是"高内聚的功能单元"。我们按数据域划 app,而不是按"页面"划—— 同一个域的模型、采集、查询、页面放一起,改一个功能只动一个目录:
| App | 职责 | 关键文件 | 依赖 |
|---|---|---|---|
forum |
演示目标应用:故意埋性能陷阱的论坛 + 故障演练端点 | models / views | 无(被观测对象) |
monitor |
平台核心:采集中间件、批量缓冲、指标注册表、总览/APM/大盘/Prometheus、追踪、安全门禁 | middleware / buffer / registry / security / tracing / leadership | 被所有模块依赖 |
hosts |
主机监控:psutil 采集线程、主机页、Agent 上报接口 | collector / models | monitor(缓冲/租约/registry 注册) |
loghub |
日志服务:入库 Handler、接入 API、查询页 | handler / models | monitor(tracing) |
rum |
前端 RUM:beacon 上报接口 + 六个分析页 | views / models | monitor(security) |
alerts |
告警中心:策略/评估引擎/事件/通知 | engine / models | monitor(registry 取数)、ops(故障单/自愈) |
analytics |
智能分析:算法库 + 工作台 + 报表 + IP 地图 | algorithms / reporting / views | monitor(registry)、loghub/rum/hosts(数据) |
ops |
运维中心:拨测/故障单/巡检/SLO/资产/自愈/通知渠道/审计 | probing / incidents / inspection / slo / heal / notify / audit / urlsafe / crypto | monitor、alerts |
cleaner |
清理加速:磁盘分析/垃圾清理/内存整理 | services / views | 无(系统级工具) |
依赖方向要记牢:monitor 是底座,不 import 任何业务 app;
alerts 依赖 ops(告警要聚合成故障单、触发自愈),用函数内延迟 import 避免循环依赖
(你会在 08 篇的 evaluate_once() 里看到 from ops.incidents import ... 写在函数体内)。
目录结构(从 03 篇开始逐步建出来):
beacon-tower-lab/
├── manage.py
├── requirements.txt
├── config/ # settings / urls(全站路由)
├── static/ # echarts.min.js / rum.js / obs/common.js
├── templates/ # base(侧边栏布局)+ 各模块页面
├── forum/ monitor/ hosts/ rum/ loghub/
├── alerts/ analytics/ ops/ cleaner/
└── agent/ # obs_agent.py(部署到被监控机器上)
三、核心数据模型:一张脑图
设计数据库先想清楚"谁存什么、谁查什么"。九张核心表(字段见对应篇目):
| 表 | App | 一行的含义 | 谁写 | 谁读 |
|---|---|---|---|---|
RequestMetric |
monitor | 一次 HTTP 请求(含 spans JSON 调用链) | 请求中间件(经缓冲) | APM/总览/告警/分析 |
HostMetric |
hosts | 一次主机采样 | 本机线程 / Agent 上报 | 主机页/总览/告警 |
LogEntry |
loghub | 一条日志 | logging Handler / 接入 API | 日志页/告警/分析 |
RumEvent |
rum | 一个前端事件(pv/perf/api/error/…) | rum.js beacon | RUM 页/总览/告警 |
CustomMetric |
monitor | 一个业务自定义数值点 | OpenAPI | 大盘/告警/分析 |
AlertPolicy |
alerts | 一条告警规则 | 页面 CRUD | 评估引擎 |
AlertEvent |
alerts | 一次触发→恢复的事件 | 评估引擎 | 事件页/故障单 |
DashCard |
monitor | 一张大盘卡片配置 | 页面 CRUD | 大盘页 |
TaskLease |
monitor | 一把后台任务租约 | 租约模块 | 四条后台线程 |
三条建模纪律:
- 时间戳字段一律
db_index=True:所有查询都是"最近 N 分钟"窗口过滤,没有索引就是全表扫; - JSON 明细用 TextField 存(spans / slow_queries / payload):SQLite 的 JSON 支持有限, 文本 + 应用层解析最稳,也方便直接在详情页渲染;
- 模型之间用字符串引用防循环:
AlertEvent.incident = ForeignKey('ops.Incident', ...)。
四、五个关键设计决策(含被否掉的方案)
决策 1:指标注册表统一取数(而不是每个页面自己写聚合)
问题:总览页要"请求量曲线",大盘要"请求量卡片",告警要"请求量均值", 智能分析要"请求量序列"——同一个指标四处取数,写四遍聚合逻辑,口径迟早漂移。
方案:monitor/registry.py 维护 key → (标签, 单位, series 函数) 的注册表:
REGISTRY = {
'http.request_count': ('请求数/分钟', '次/分', http_request_count),
'http.avg_duration': ('接口平均耗时', 'ms', http_avg_duration),
# ...18 个内置指标
}
统一出口 series(key, minutes) 返回 {'key','label','unit','points'},
还动态分发 custom.<name>(自定义指标)和 probe.<task_id>.<field>(拨测指标)。
被否的方案:每个视图自己 ORM 聚合。省了 30 行注册代码,换来口径分裂—— 否掉。收益在 09 篇兑现:新增一个自定义指标只改一处,大盘/告警/分析立即全部生效。
决策 2:采集与写库解耦——批量缓冲(性能的生死线)
问题:中间件在响应返回前同步 INSERT 一行 RequestMetric,SQLite 单写者锁下 所有请求线程排队抢锁,压测 p99 高达 2190ms——采集本身成了性能杀手。
方案:monitor/buffer.py。请求线程只做一次 put_nowait(微秒级、不碰数据库),
后台 flusher 线程按"攒满 200 条或满 1 秒"一条 bulk_create 批量落库。
代价与边界都是明码标价的:
- 指标可见延迟 ≤ 1 秒(大盘/告警读库,不是实时总线);
- 队列满丢新点并计数(
obs_metric_buffer_dropped_total经 /metrics 暴露)—— 观测数据可以采样,采集不允许反压业务; - 落库撞瞬时锁重试 2 次,重试用尽才整批丢弃;
- 管理命令与测试自动退回同步写("写完即可读"语义);
- 进程正常退出前排空缓冲区(atexit)。
被否的方案 A:换 PostgreSQL 就没有单写者锁——对,但开发环境就跑不起来了,否; 方案 B:内存聚合、定时快照——丢明细就没有调用链了,否。
实测效果(16 线程闭环压测,详见 docs/BENCHMARK.md):QPS 74→91,p95 958→214ms, p99 2190→251ms,零错误。这笔账 05 篇还会细算。
决策 3:后台任务租约选主(而不是部署纪律)
问题:主机采集/告警评估/拨测/巡检四个周期任务,必须"全集群同时只有一个进程在跑", 否则同一台机器采两次、同一条策略告警两次。靠 compose 注释里写"切勿 --scale"?那是人治。
方案:monitor/leadership.py + TaskLease 表。每个任务一行租约,进程身份是
主机名:pid:随机后缀;抢约/续约都是单条 compare-and-swap 条件 UPDATE——
数据库行锁天然保证竞争者中只有一个命中,SQLite 和 PostgreSQL 共用同一套代码。
持有者宕机后租约到期(默认 TTL 60 秒),其它副本下一次抢约自动接管,term(任期)+1。
配套的 LeaseLoop 骨架把"续约节拍"和"任务节拍"分开:tick 间隔取 min(interval, ttl/3)
保证远快于租约到期,而任务实际执行由 next_run 控制——5 秒的拨测和 12 小时的巡检
共用同一套代码。诚实边界:租约不是围栏(fencing),旧持有者正跑的一轮长任务仍可能
做完,最坏"一个周期内重复执行一次"——所以四个任务都写成幂等(告警按策略去重、
采集点多一条只是曲线略密)。实测硬杀持有者后 28 秒内待命副本接管。
被否的方案:Redis 分布式锁——为一把锁引入一个新组件,单人项目运维成本不对称,否。
决策 4:整站登录门禁 + 令牌最小作用域(安全的第一层)
问题:监控平台握着你全系统的命脉数据(SQL 明文、路径、IP),一旦被扫到就是裸奔。
方案:两层。第一层 AuthRequiredMiddleware:未登录访问任何页面一律跳登录页,
API 路径返回 401 JSON,唯一公开端点是 /api/health/(只返回 ok + 时间,供拨测探活)。
第二层接入令牌:Agent/日志/指标/RUM 上报端点接受 X-OBS-Token 头(或 Bearer),
比较用 secrets.compare_digest 防时序侧信道;令牌只放行"写数据"的上报端点,
读类 API 与所有页面只认登录会话——令牌分发在各 Agent 机器上,泄露半径必须
限制为"只能写监控数据"(写脏数据可清理,读走数据是事故)。
外加轻量限速:上报 120 次/分钟/IP(429),登录按"用户名 10 次 + IP 30 次 / 10 分钟"—— 且用户名超限后错密码 429、正确密码始终放行,防止攻击者拿别人的用户名把受害者锁在门外。
被否的方案:公开读、只保护写——监控数据本身敏感,否。
决策 5:SQLite(WAL) 起步,环境变量切 PostgreSQL
问题:教学项目要"clone 下来 5 分钟能跑",但生产不能拿 SQLite 硬扛。
方案:默认 SQLite + 三个针对性优化(config/__init__.py 注册连接级 PRAGMA):
cursor.execute('PRAGMA journal_mode=WAL;') # 读写不互斥
cursor.execute('PRAGMA synchronous=NORMAL;') # 掉电最多丢最后一个事务
cursor.execute('PRAGMA busy_timeout=20000;') # 写锁等待余量
设置 OBS_DATABASE_URL=postgres://... 即切 PostgreSQL(settings 里一段解析代码,
Django ORM 保证两边的查询代码零差异)。这是"约束驱动的教学":SQLite 的单写者锁
逼出了批量缓冲、分批删除、WAL 三件真本事,换 PostgreSQL 反而学不到。
五、横切关注点:三件事贯穿所有模块
- TraceID 贯穿(07 篇展开):中间件生成/采纳 W3C trace_id → 绑定 contextvars → 日志 Handler 自动带上 → 日志页按 trace_id 过滤 ↔ APM 调用链互查;
- 自观测(11 篇展开):平台自己也是被监控对象——缓冲丢弃数、租约状态、
采集管道指标全部经
/metrics暴露,"监控工具自己瞎没瞎"是可回答的; - 审计留痕(10 篇展开):策略变更/大盘编辑/自愈执行/登录成败全部写审计日志—— 运维动作必须可回溯,这在多人使用时是信任的基础。
六、开发顺序:先铺"采集→存储→展示"主轴,再长闭环
按依赖关系排的施工顺序(也就是后面各篇的顺序):
03 骨架(settings/9 app/模型)
04 演示应用 + APM 中间件 ← 有数据可看
05 批量缓冲 + 指标注册表 ← 数据流立起来
06 主机采集线程 + 租约 + Agent ← 第二路数据 + 后台任务基建
07 日志 Handler + rum.js ← 三四路数据 + TraceID 贯穿
08 告警引擎 ← 数据被"消费"
09 智能分析 + 大盘 ← 数据被"深挖"
10 运维中心 + 清理加速 ← 闭环补全
11 测试 + CI ← 质量闸门
12 部署 ← 上线
先主轴后分支的好处:每篇结束时平台都能跑、都能看到新东西, 任何一篇卡住都不影响你已经拥有的部分。
本篇检查点
- [ ] 能白板画出四路采集 → 存储 → 注册表 → 展示/告警的数据流;
- [ ] 能说出 9 个 app 各管什么、monitor 为什么不能 import 业务 app;
- [ ] 能讲出五个设计决策各自的"问题→方案→被否的方案";
- [ ] 能解释为什么租约"不是围栏",以及为什么幂等让这个缺口可接受;
- [ ] 手里有开发顺序图,知道下一篇要建什么。
设计定了,下一篇开始动工:虚拟环境、startproject、settings 逐段精讲、9 个 app、 核心模型、跑通第一个页面——03 · 创建项目骨架。