桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级

桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级

一篇纯设计复盘。文中的代码是示意性的,不来自任何具体项目;参数是可直接调整的默认值。

很多桌面程序对日志的态度是"能打印就行":qDebug() 走一遍控制台,上线后换成按天滚动的文本文件,出了问题让用户把日志发过来。这套做法在开发期够用,一旦软件要在现场连续跑几周、出问题后还要回答"某条指令到底发没发、对方回没回",就不够了。

下面是我们重新设计运行日志时做的一整套取舍。核心目标只有一个:日志系统不能成为新的故障源,同时要能回答事后的问题。

文章目录

一、先明确它要回答什么

设计之前先列出问题清单,而不是先选技术:

  1. 某次运行从几点开始、连的是谁、跑了多久?
  2. 所有下发的离散命令里,哪些被接受了、哪些被拒绝、哪些超时了?各自耗时多少?
  3. 异常发生前后,程序处于什么状态(深度、姿态、电量、模式)?
  4. 操作员的动作有没有生效?某一段操作持续了多久、推到了多大?
  5. 磁盘写满、权限不足、数据库驱动缺失时,程序还能不能正常用?

前四条要求结构化 ------文本日志能"看",但不能"查"。第五条要求日志系统失败时不影响主程序。

由此得到约束:

约束 含义
不阻塞主线程 记录调用可能发生在 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. 任何一次写失败都进入文本降级

触发点有三处:驱动不可用/建库失败、事务开不起来、批内语句或提交失败。处理方式统一:

  1. 关闭数据库连接;
  2. 往同目录的 fallback.log 写一行 DB_DEGRADED,带上失败原因;
  3. 之后所有批次改走文本,格式一行一条;
  4. 只降级一次(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",而是几个边界条件:

  1. 队列有界,丢弃留痕;
  2. 写失败降级,降级只降一次;
  3. 轮转时搬运在途状态,UPDATE 落空再补一条;
  4. 关停先关入口、再排空、最后 checkpoint;
  5. 时间戳作文件名,字典序必须等于时间序。

如果要给一条建议:先写"日志系统自己出故障时怎么办",再写正常路径。 正常路径怎么写都能跑,异常路径才决定这套日志在事故当天有没有用。

相关推荐
未来之窗软件服务7 小时前
终端优先同步架构终端场景下轻量化、高可用通信模型-东方仙盟
架构·东方仙盟·阿雪技术观·网络震荡·dns污染·数据中继器·边界网关
程序员清风9 小时前
数据处理脚本工程化:日志、配置、命令行与测试
服务器·开发语言·数据库
正在走向自律10 小时前
数据集成产品的技术底座:从日志解析原理到 KFS 同步链路搭建(含建表与增删改查完整代码)
数据库·oracle·信创·数据集成·国产数据库·kingbasees·全量+增量同步
程序员塔塔喵10 小时前
【MySQL篇|第七篇】MySQL事务
数据库·mysql·mysql事务
独隅11 小时前
Claude API 实战教程:从入门到完整项目
android·java·数据库·claude·claude code
_孤傲_11 小时前
快速掌握 MySQL 语法
数据库·后端·mysql
choumin11 小时前
C++中万能引用与std::forward的作用和用法
c++·万能引用·std forward
下一站春风里12 小时前
位运算完整学习笔记:按位与(&)+ 异或(^)
开发语言·c++·算法
数字新视界12 小时前
一体化机柜在数据中心运维中的创新应用与优势分析
大数据·服务器·数据库·动环监控系统·动力与环境监控系统