桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级
一篇纯设计复盘。文中的代码是示意性的,不来自任何具体项目;参数是可直接调整的默认值。
很多桌面程序对日志的态度是"能打印就行":qDebug() 走一遍控制台,上线后换成按天滚动的文本文件,出了问题让用户把日志发过来。这套做法在开发期够用,一旦软件要在现场连续跑几周、出问题后还要回答"某条指令到底发没发、对方回没回",就不够了。
下面是我们重新设计运行日志时做的一整套取舍。核心目标只有一个:日志系统不能成为新的故障源,同时要能回答事后的问题。
文章目录
- [桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级](#桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级)
-
- 一、先明确它要回答什么
- 二、总体结构
- 三、六个关键决策
-
- [1. 采集侧绝不碰数据库](#1. 采集侧绝不碰数据库)
- [2. 队列必须有界,满了丢最旧](#2. 队列必须有界,满了丢最旧)
- [3. 批量写,四个触发条件](#3. 批量写,四个触发条件)
- [4. 分块轮转,保留固定块数](#4. 分块轮转,保留固定块数)
- [5. 轮转时把在途数据搬过去(copy-forward)](#5. 轮转时把在途数据搬过去(copy-forward))
- [6. 任何一次写失败都进入文本降级](#6. 任何一次写失败都进入文本降级)
- 四、数据模型
- 五、采集策略:只记值得记的
- 六、关停顺序
- 七、踩过的坑
- 八、测试策略
- 九、什么时候不该用这套
- 十、小结
一、先明确它要回答什么
设计之前先列出问题清单,而不是先选技术:
- 某次运行从几点开始、连的是谁、跑了多久?
- 所有下发的离散命令里,哪些被接受了、哪些被拒绝、哪些超时了?各自耗时多少?
- 异常发生前后,程序处于什么状态(深度、姿态、电量、模式)?
- 操作员的动作有没有生效?某一段操作持续了多久、推到了多大?
- 磁盘写满、权限不足、数据库驱动缺失时,程序还能不能正常用?
前四条要求结构化 ------文本日志能"看",但不能"查"。第五条要求日志系统失败时不影响主程序。
由此得到约束:
| 约束 | 含义 |
|---|---|
| 不阻塞主线程 | 记录调用可能发生在 UI 线程和高频回调里 |
| 体积可控 | 长期运行不能让磁盘无限增长 |
| 崩溃可诊断 | 出错前最后几条必须已落盘 |
| 可降级 | 任何写盘失败都不能让程序崩或卡住 |
| 可关联 | 命令与应答要能按请求号对上 |
二、总体结构
采集方(UI 线程 / 回调)
│ 级别过滤
▼
有界队列(上限 N 条)──满──▶ 丢弃最旧 + 记一条溢出事件
│ 攒批(50 条 / 1 秒 / 出现 ERROR / 退出)
▼
写线程 ──▶ 一个事务写一批 ──▶ SQLite(WAL)
│
├─ 块写满 → 轮转:搬运在途数据 → 新块
└─ 超出保留数 → 删最旧的块
写线程任一步失败 ──▶ 文本降级日志(程序继续运行)
一句话概括:采集侧只做过滤和入队,写线程独占数据库,异常路径全部通向一个还能写的文本文件。
三、六个关键决策
1. 采集侧绝不碰数据库
Qt 的 QSqlDatabase 连接不能跨线程使用,多线程各自开连接又会撞上 SQLite 的写锁。于是把边界划死:数据库句柄只存在于写线程,采集侧只往队列里放一个扁平结构。
代价是入队要加一次锁,但只是 mutex + push_back,微秒级。级别过滤放在最前面,低于阈值的记录连字符串都不构造。
2. 队列必须有界,满了丢最旧
无界队列只是把内存泄漏换了个名字。现场程序可能因为链路异常在几秒内刷出几十万条重试日志,无界队列会直接把进程撑死。
有界队列的行为:
cpp
if (queue.size() >= kQueueMax) {
queue.pop_front(); // 丢最旧的
++dropped_;
}
关键不在丢弃本身,而在丢弃要留下证据 :写线程取下一批时,如果 dropped_ > 0,会在批首插入一条 QUEUE_OVERFLOW 事件,写明丢了多少条。否则事后看日志只会以为是"那段时间没发生事情",而不是"那段时间被压垮了"。
丢最旧而不是丢最新,是因为新的记录更能反映当前状态,也更容易和现场现象对应。
3. 批量写,四个触发条件
cpp
// 攒批:谁先到用谁
while (!stopping && !has_error && queue.size() < kBatchMax) {
cv.wait_until(deadline); // deadline = 上次写入 + 1s
}
- 攒满 50 条:高流量时尽快落盘。
- 距上次写入 1 秒:低流量时的兜底,否则一条孤零零的日志可能几分钟都不落盘。
- 出现 ERROR:崩溃前最后几条最值钱,不能等。
- 程序退出:排空队列。
一批一个事务。SQLite 的开销大头是提交时的 fsync 次数,不是插入行数;50 条插在一个事务里,通常只付一次磁盘同步的代价。配合 PRAGMA journal_mode=WAL 和 synchronous=NORMAL,写入性能和断电安全性取一个可接受的折中------NORMAL 下掉电可能丢最后一个事务,但不会损坏数据库,对运行日志是可以接受的。
4. 分块轮转,保留固定块数
单块 50 MB、保留 4 块,超出就删最旧的。这里有几个容易写错的地方:
块大小要算上 WAL。 主库文件在 checkpoint 之前并不会把 WAL 的内容算进去,只看 .db 大小会严重低估,表现为"一直不轮转"。
文件名必须按字典序等于时间序。 用 yyyyMMdd-HHmmss-zzz,同一毫秒内建块就顺延 1 ms。不要用 -2、-copy 这类后缀,它们会排到 .db 前面,导致"最新块"判断错误。
启动时追加到最新一块。 如果每次启动都新建文件,频繁重启的现场会留下一堆几 KB 的碎文件,查询时还得跨文件拼。只有最新块真的写满了才新建。
永远不删活动块。 淘汰循环里一旦轮到当前写入的块就立即停止,哪怕它超过了保留数(例如用户把保留数改成 1)。
5. 轮转时把在途数据搬过去(copy-forward)
这是整个设计里最容易被忽略、也最容易出 bug 的一点。
问题:命令发送时插入一行 result IS NULL;应答回来时 UPDATE ... WHERE request_id = ? AND result IS NULL。如果这期间发生了轮转,原行在旧块里,新块里什么都没有,UPDATE 影响 0 行------应答就这样静默消失了。
解决:关掉旧块之前,把"还没有结果"的数据读出来,复制进新块。
sql
SELECT ... FROM commands WHERE result IS NULL;
SELECT ... FROM sessions WHERE ended_at IS NULL;
然后才 close(旧块) → open(新块) → 在一个事务里插入这些行。未结束的会话同理:会话开始时写一行,结束时 UPDATE ended_at,跨块就会丢。
还需要一层兜底:即使搬过,也可能因为异常顺序导致 UPDATE 落空。此时补一条 kind='UNKNOWN' 的结果行,至少留下"这个请求号收到过应答"的证据:
cpp
if (update.numRowsAffected() == 0) {
insertFallbackRow(request_id, result); // 结果行, kind = UNKNOWN
}
6. 任何一次写失败都进入文本降级
触发点有三处:驱动不可用/建库失败、事务开不起来、批内语句或提交失败。处理方式统一:
- 关闭数据库连接;
- 往同目录的
fallback.log写一行DB_DEGRADED,带上失败原因; - 之后所有批次改走文本,格式一行一条;
- 只降级一次(
exchange(true)),避免每批都重试建库、反复失败。
文本也写不了怎么办?丢掉这一批,不阻塞、不抛异常。日志系统的最高优先级是不拖垮宿主程序。
四、数据模型
五张表,各管一类事实:
| 表 | 一行代表 | 关键列 |
|---|---|---|
sessions |
一次运行/连接 | id(会话号)、started_at、ended_at、mode、peer |
events |
一条分级事件 | ts、level、category、code、message、session_id |
commands |
一次离散命令 | request_id、kind、params、result、timed_out、sent_at、result_at |
samples |
一次 1 Hz 快照 | 深度、姿态、速度、电压、模式等 |
motions |
一段连续操作 | ts_start、ts_end、各轴带符号峰值 |
两个设计细节:
命令是一行,不是两行。 发送时 INSERT,应答时 UPDATE。一次下发是一个完整的事实,拆成"发送行 + 结果行"会让每次查询都要 join 或分组。只有应答先于写入到达、或者原行确实丢了,才补一条 UNKNOWN 行。
给视图加中文列名和本地时间。 表里存 Unix 毫秒和英文枚举,查询时用视图翻译:
sql
CREATE VIEW v_commands AS
SELECT datetime(sent_at/1000,'unixepoch','localtime') AS "时间",
kind AS "命令",
CASE WHEN result IS NULL THEN '待应答'
WHEN timed_out = 1 THEN '超时'
WHEN result = 0 THEN '已接受'
...
END AS "结果",
result_at - sent_at AS "应答耗时ms"
FROM commands;
这样用任意 SQLite 工具打开就能看懂,不需要额外的导出脚本------现场排查时这一点比任何文档都实用。
五、采集策略:只记值得记的
日志设计的一半是"记什么",另一半是"不记什么"。
- 高频数据用节流:遥测可能有 50 Hz,快照只留 1 Hz。节流放在采集侧(记录时间戳,间隔不足直接返回),调用方可以直接接满帧率信号,不需要知道节流规则。
- 连续操作按段记 :摇杆推住 30 秒,记 1500 帧没有意义,记一行"从 A 到 B、持续 30 秒、前向峰值 800、偏航峰值 -400"就够了。段的切分点是:起段、操作方向翻转、回中。峰值取绝对值最大的那个采样并保留符号------符号代表方向,丢了就没法复盘。
- 原始帧不进日志库:图像、声呐、DVL 原始数据走独立的录制文件,日志库只留索引和事件。把大块二进制塞进 SQLite 会让库文件膨胀、轮转变频繁、查询变慢,三头不讨好。
六、关停顺序
active = false // 先关入口,join 期间不再有新条目
stopping = true; notify_all();
thread.join(); // 写线程排空队列后退出
closeBlock(); // wal_checkpoint(TRUNCATE) → close
两个要点:
- 先关入口再 join,否则排空过程中还会有新条目挤进来,永远排不空。
- 关闭前做
wal_checkpoint(TRUNCATE),把 WAL 合并回主库并截断。否则每次非正常关闭都会在目录里留一个-wal文件,下次打开时还要恢复,用户看目录也会困惑。
stop() 必须幂等------析构函数会再调一次,退出路径(关闭窗口、崩溃处理、测试)也各调一次。
七、踩过的坑
1. 会话号用时间戳,同一毫秒重连会互相覆盖。
断开后立刻重连,endSession() 和 beginSession() 在同一个调用栈里,两次取到的毫秒数可能相同。会话表用 INSERT OR REPLACE 时,新会话会覆盖 刚结束那一行------历史里只剩下一次会话,而且结束时间还是错的。
修法:id = max(now(), last_id + 1),强制严格递增,并写一条用例断言两次会话各占一行。
2. 重试必须复用请求号。
命令超时重发时,如果重新分配请求号,对方回的应答就无法关联到原来那一行,命令永远停在"待应答"。
修法:第一次分配后缓存住,重试沿用;只有真正的新命令才递增。
3. 主动复位时,在途命令要补一个失败结果。
连接被主动关闭时,在途的请求号会被清零。如果直接清,数据库里那行永远是 result IS NULL。
修法:清空之前补发一条 timed_out=true 的失败结果,标记为站端超时。
4. synchronous=NORMAL 是取舍,不是免费午餐。
它把 fsync 次数降到很低,代价是掉电可能丢最后一批。运行日志可以接受;如果换成记账类数据,应该用 FULL。
5. 轮转测试要真的把块写满。
把块上限设为 1 MB、用长消息灌 6000 条,几秒就能触发轮转。不要用"等几分钟"的方式测试------既慢又不稳定。
八、测试策略
每个行为一条独立用例,重点是异步路径用轮询而不是固定 sleep:
级别过滤 warn 阈值下只写 WARN/ERROR
关闭开关 off 时不建库
非法配置 未知级别回落 info,而不是关闭
会话+采样 一次会话一行;同一毫秒内两次采样只留一条
命令生命周期 发送+接受 / 发送+超时 / 无对应发送行的孤儿结果
定时刷盘 一条日志在 1 秒内可见
ERROR 刷盘 错误 800 ms 内可见(给慢机器留余量)
轮转+搬运 1 MB 强制轮转,旧块的在途命令行出现在新块并回填结果
文本降级 强制降级后不建库,文本里有事件码和降级原因
干净关闭 退出后 -wal 被截断或删除
"轮转 + 搬运"和"孤儿结果"这两条最值得写:它们覆盖的是跨块的状态一致性,也是这套设计里唯一不能靠读代码看出来的部分。
九、什么时候不该用这套
- 多进程同时写:单写线程的假设不成立,需要集中式日志服务。
- 需要实时订阅/告警:SQLite 不是消息队列,应该接 OpenTelemetry 之类的管道。
- 日志量到 GB/天:分块轮转和本地查询都不划算,直接上专门的日志系统。
- 要求审计级不可篡改:需要追加写 + 哈希链,本地库不满足。
它适合的场景很明确:单机、桌面、数据量中等、需要事后按条件查询、且不能因为日志而崩溃。
十、小结
这套设计里没有特别新的技术,全部是朴素的工程取舍。真正决定质量的不是"用了 SQLite",而是几个边界条件:
- 队列有界,丢弃留痕;
- 写失败降级,降级只降一次;
- 轮转时搬运在途状态,UPDATE 落空再补一条;
- 关停先关入口、再排空、最后 checkpoint;
- 时间戳作文件名,字典序必须等于时间序。
如果要给一条建议:先写"日志系统自己出故障时怎么办",再写正常路径。 正常路径怎么写都能跑,异常路径才决定这套日志在事故当天有没有用。