← 返回 Day 184 正文

Day 184 · 图解

Day 184 图解 —— 日志管道与采集状态机

全部用 Mermaid / ASCII,不生成图片文件:这样它们能跟代码一起 review, 也能在 GitHub、终端(配合 mermaid-cli)里直接看。


图 1:日志管道全景(谁把日志搬到哪去了)

flowchart TB subgraph 产生端["① 产生:应用"] A1["api 服务<br/>JsonLineFormatter"] --> A2["/var/log/api/app.log<br/>JSON Lines"] end subgraph 采集["② 采集:采集器(每台机器一个)"] B1["FollowReader<br/>(inode, offset, buffer)"] B2{"攒够 50 条<br/>或 1 秒?"} B3["Sender<br/>POST NDJSON + 3 次退避"] B4["spill 文件<br/>(投递失败降级)"] B1 --> B2 --> B3 B3 -.失败.-> B4 end subgraph 存储["③ 存储:接收端 + SQLite"] C1["ThreadingHTTPServer<br/>POST /ingest"] C2["LogStore<br/>WAL + busy_timeout=5000"] C3[("logs.db<br/>+ 3 个索引")] C1 --> C2 --> C3 end subgraph 查询["④ 查询与可视化"] D1["GROUP BY 分桶"] D2["ASCII 看板(值班)"] D3["单文件 HTML(存档)"] D4["告警规则<br/>ERROR 占比"] D1 --> D2 D1 --> D3 D1 --> D4 end A2 -->|"rename 轮转后<br/>必须重开文件"| B1 B3 --> C1 C3 --> D1
flowchart TB
    subgraph 产生端["① 产生:应用"]
        A1["api 服务<br/>JsonLineFormatter"] --> A2["/var/log/api/app.log<br/>JSON Lines"]
    end

    subgraph 采集["② 采集:采集器(每台机器一个)"]
        B1["FollowReader<br/>(inode, offset, buffer)"]
        B2{"攒够 50 条<br/>或 1 秒?"}
        B3["Sender<br/>POST NDJSON + 3 次退避"]
        B4["spill 文件<br/>(投递失败降级)"]
        B1 --> B2 --> B3
        B3 -.失败.-> B4
    end

    subgraph 存储["③ 存储:接收端 + SQLite"]
        C1["ThreadingHTTPServer<br/>POST /ingest"]
        C2["LogStore<br/>WAL + busy_timeout=5000"]
        C3[("logs.db<br/>+ 3 个索引")]
        C1 --> C2 --> C3
    end

    subgraph 查询["④ 查询与可视化"]
        D1["GROUP BY 分桶"]
        D2["ASCII 看板(值班)"]
        D3["单文件 HTML(存档)"]
        D4["告警规则<br/>ERROR 占比"]
        D1 --> D2
        D1 --> D3
        D1 --> D4
    end

    A2 -->|"rename 轮转后<br/>必须重开文件"| B1
    B3 --> C1
    C3 --> D1

读图要点:箭头上的小字才是真正的知识点。管道的每一跳都有自己的失败模式, "日志丢了"从来不是单一原因。


图 2:一次请求的日志生命周期(时序)

sequenceDiagram participant App as api 服务 participant File as app.log participant Col as 采集器 participant Srv as 接收端 participant DB as SQLite(WAL) App->>File: write(JSON + "\n") ← 原子性只保证到 buffer File-->>Col: 增量读(offset → 新 offset) Col->>Col: buffer 按 "\n" 切;尾巴留着 Col->>Srv: POST /ingest(一批 50 条或 1 秒) Srv->>DB: BEGIN IMMEDIATE + executemany(批提交) DB-->>Srv: COMMIT Srv-->>Col: {"accepted": 50} Note over Col,DB: 任一步失败 → 重试 3 次 → 落 spill 文件 Col->>Srv: GET /stats?window=3600 Srv-->>Col: {"by_level":{"INFO":49,"ERROR":1}}
sequenceDiagram
    participant App as api 服务
    participant File as app.log
    participant Col as 采集器
    participant Srv as 接收端
    participant DB as SQLite(WAL)

    App->>File: write(JSON + "\n")  ← 原子性只保证到 buffer
    File-->>Col: 增量读(offset → 新 offset)
    Col->>Col: buffer 按 "\n" 切;尾巴留着
    Col->>Srv: POST /ingest(一批 50 条或 1 秒)
    Srv->>DB: BEGIN IMMEDIATE + executemany(批提交)
    DB-->>Srv: COMMIT
    Srv-->>Col: {"accepted": 50}
    Note over Col,DB: 任一步失败 → 重试 3 次 → 落 spill 文件
    Col->>Srv: GET /stats?window=3600
    Srv-->>Col: {"by_level":{"INFO":49,"ERROR":1}}

图 3:采集器状态机(为什么必须记 inode)

                  ┌──────────────────────────────────────────┐
                  │  state = (inode, offset, buffer, mtime)  │
                  └──────────────────────────────────────────┘
                                     │ 每 0.2s poll 一次
                                     ▼
                      ┌──────────────────────────────┐
                      │ os.stat(path) 成功?           │
                      └──────────────────────────────┘
                        │ 否                        │ 是
                        ▼                           ▼
                  等待文件出现            ┌───────────────────────────┐
                  (不报错)              │ st_ino != state.inode ?    │
                                          └───────────────────────────┘
                                             │ 是(发生了 rename 轮转)
                                             ▼
                                   把旧 fd 读到 EOF → 关闭
                                   → open(path) 重新取 inode
                                   → rotations += 1
                                             │ 否
                                             ▼
                             ┌──────────────────────────────────────┐
                             │ size < offset 或 (size<=offset 且       │
                             │ mtime 变了) ?  → 截断                  │
                             └──────────────────────────────────────┘
                                             │ 是
                                             ▼
                                    seek(0); offset = 0
                                    truncations += 1
                                             │ 否
                                             ▼
                                  chunk = fd.read()
                                  buffer += chunk
                                  *完整行, buffer = buffer.split("\n")
                                  → 完整行交给 Sender

反例(naive 模式):删掉图中的两个判断框,只保留 fd.read()。 文件被 rename 后,fd 仍指向旧 inode,读不到任何新内容, 而且不报错 —— 这是日志系统里最危险的一种失败(静默丢数据)。


图 4:SQLite 两种 journal 模式的锁时间线

默认 rollback journal(DELETE)—— 写会挡住读
时间轴 →
写者 A:  [BEGIN IMMEDIATE]──写──[RESERVED]──────────[EXCLUSIVE 落盘]──[COMMIT]
读者 R:  ──────────读 OK──────────┐                    ↑ 读被挡
写者 B:                            └─[尝试写]→ SQLITE_BUSY(立刻失败!)

WAL —— 一写多读并行,写者之间排队
写者 A:  [BEGIN IMMEDIATE]──写──[追加 -wal]──────[COMMIT]
读者 R:  ──────────始终读旧快照,不阻塞─────────→ 看见新数据
写者 B:                            └─[尝试写]→ busy_timeout 内轮询等待 → 成功后写

结论:WAL 解决"读写互斥",但不解决"多写者互斥"。 多写者场景必须再加上 busy_timeout(让 B 等而 A 而不是立刻报错)。 04 号示例实测:timeout=0 时 B 在 0.000s 就失败;加 WAL + busy_timeout=5000 后 B 等待 0.738s 拿到锁并成功。


图 5:轮转(rename)—— 正确采集器 vs naive 采集器

时刻 T0  app.log ──┐
                   └─ inode 8823645  ← 两个采集器的 fd 都指向它
        正确采集器: state.inode = 8823645
        naive 采集器: fd → inode 8823645

时刻 T1  mv app.log app.log.1        (inode 8823645 还在,只是换了名字)
        应用 open("app.log", O_CREAT) → 新 inode 8823700,开始往里写

        正确采集器: os.stat("app.log").st_ino = 8823700 ≠ 8823645
                    → 重开 → 收到新行 ✅   rotations_detected: 1
        naive 采集器: fd 仍是 8823645(旧文件,无人再写)
                    → 永远 EOF,静默什么都不发 ❌  rotations_detected: 0

这就是 --naive 开关存在的意义:它把"看起来能跑"和"真的不丢"的差别 变成一次可复现的对照实验,而不是一句口头警告。