Qt/C++ 保存设备采集数据:SQLite 批量写入、历史查询与 CSV 导出

217 阅读4分钟

设备数据在界面上显示出来,只完成了采集流程的一部分。

实际项目通常还需要查询某个时间段的记录、导出测试结果,或者在设备异常后回看数据变化。对于单机上位机,SQLite 不需要额外部署数据库服务,比较适合保存结构化采集记录。

下面以时间戳、设备编号、通道编号和测量值为例,介绍基本的数据保存流程。

一、创建数据库和数据表

项目需要引入 SQL 模块:

find_package(Qt6 REQUIRED COMPONENTS Sql)
target_link_libraries(your_app PRIVATE Qt6::Sql)

创建命名连接:

#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDebug>

QSqlDatabase db =
        QSqlDatabase::addDatabase("QSQLITE", "acquisition");

db.setDatabaseName("/your/data/directory/device_data.db");

if (!db.open()) {
    qWarning() << db.lastError().text();
    return;
}

数据库目录需要事先存在。部署程序时,也要确保包含 Qt 的 SQLite 驱动插件。

创建表和索引:

QSqlQuery query(db);

if (!query.exec(R"(
    CREATE TABLE IF NOT EXISTS samples (
        id        INTEGER PRIMARY KEY,
        timestamp INTEGER NOT NULL,
        device    TEXT NOT NULL,
        channel   INTEGER NOT NULL,
        value     REAL NOT NULL
    )
)")) {
    qWarning() << query.lastError().text();
    return;
}

if (!query.exec(R"(
    CREATE INDEX IF NOT EXISTS idx_samples_device_time
    ON samples(device, timestamp)
)")) {
    qWarning() << query.lastError().text();
    return;
}

timestamp 使用 Unix 毫秒时间戳。设备编号和时间组成索引,便于按设备查询某个时间段的数据。

时间戳应在采集时确定,不要等到批量写库时才统一生成,否则无法还原真实采样时间。

二、使用事务批量写入

频繁写入时,每条记录单独提交会增加事务开销。可以先积累一批数据,再在一个事务中写入。

struct Sample {
    qint64 timestamp;
    QString device;
    int channel;
    double value;
};

批量保存函数:

#include <QVector>

bool insertBatch(QSqlDatabase& db,
                 const QVector<Sample>& samples,
                 QString* error)
{
    if (samples.isEmpty())
        return true;

    if (!db.transaction()) {
        if (error)
            *error = db.lastError().text();
        return false;
    }

    QSqlQuery query(db);

    if (!query.prepare(R"(
        INSERT INTO samples(timestamp, device, channel, value)
        VALUES(?, ?, ?, ?)
    )")) {
        if (error)
            *error = query.lastError().text();

        db.rollback();
        return false;
    }

    for (const auto& sample : samples) {
        query.bindValue(0, sample.timestamp);
        query.bindValue(1, sample.device);
        query.bindValue(2, sample.channel);
        query.bindValue(3, sample.value);

        if (!query.exec()) {
            if (error)
                *error = query.lastError().text();

            db.rollback();
            return false;
        }
    }

    if (!db.commit()) {
        if (error)
            *error = db.lastError().text();

        db.rollback();
        return false;
    }

    return true;
}

这里使用参数绑定,不把设备名称等内容直接拼进 SQL。

批量大小可以按条数或时间控制,例如累计 500 条,或者每隔 500 ms 提交一次。具体数值应结合设备速率和可接受的数据丢失窗口调整。

内存里尚未提交的数据,在程序异常退出时可能丢失,因此不能只追求大批量,也要考虑及时落盘。

三、按设备和时间范围查询

下面查询某台设备在指定时间段内的前 1000 条数据:

QSqlQuery query(db);

query.prepare(R"(
    SELECT timestamp, channel, value
    FROM samples
    WHERE device = ?
      AND timestamp >= ?
      AND timestamp < ?
    ORDER BY timestamp, id
    LIMIT 1000
)");

query.addBindValue(deviceId);
query.addBindValue(beginMs);
query.addBindValue(endMs);

if (!query.exec()) {
    qWarning() << query.lastError().text();
    return;
}

while (query.next()) {
    const qint64 timestamp = query.value(0).toLongLong();
    const int channel = query.value(1).toInt();
    const double value = query.value(2).toDouble();

    // 添加到历史数据模型或曲线缓存。
}

时间范围使用左闭右开区间:

beginMs <= timestamp < endMs

这样连续查询相邻时间段时,不会重复包含边界时刻的数据。

这里的 LIMIT 1000 用于限制界面一次加载的数据量,不代表这个时间段只有 1000 条记录。数据较多时应实现分页,导出时则需要读取完整范围。

四、导出 CSV 文件

导出可以边查询边写文件,不必先把所有结果装进内存。

下面用 QSaveFile 写出文件,只有全部成功后才提交,避免把半成品当成完整结果:

#include <QSaveFile>
#include <QTextStream>
#include <QStringConverter>

bool exportCsv(QSqlDatabase& db,
               const QString& path,
               const QString& deviceId,
               qint64 beginMs,
               qint64 endMs)
{
    QSqlQuery query(db);
    query.setForwardOnly(true);

    query.prepare(R"(
        SELECT timestamp, channel, value
        FROM samples
        WHERE device = ?
          AND timestamp >= ?
          AND timestamp < ?
        ORDER BY timestamp, id
    )");

    query.addBindValue(deviceId);
    query.addBindValue(beginMs);
    query.addBindValue(endMs);

    if (!query.exec())
        return false;

    QSaveFile file(path);

    if (!file.open(QIODevice::WriteOnly))
        return false;

    QTextStream out(&file);
    out.setEncoding(QStringConverter::Utf8);
    out.setGenerateByteOrderMark(true);

    out << "时间戳毫秒,通道,测量值\n";

    while (query.next()) {
        out << QString::number(query.value(0).toLongLong()) << ','
            << QString::number(query.value(1).toInt()) << ','
            << QString::number(query.value(2).toDouble(), 'g', 17)
            << '\n';
    }

    if (query.lastError().isValid())
        return false;

    out.flush();

    if (out.status() != QTextStream::Ok)
        return false;

    return file.commit();
}

这个示例只导出数值字段。如果增加设备名称、备注等文本列,应正确处理逗号、双引号和换行:通常把字段包在双引号中,并把字段内的 " 替换为 ""。

导出记录很多时,应放到工作线程执行,并增加进度和取消操作。

五、数据库连接的线程与生命周期

Qt 数据库连接需要在所属线程中使用。不要在界面线程创建连接,再把同一连接拿到保存线程中直接写入。

比较清晰的做法是:

保存线程:自己的数据库连接,负责批量写入
查询线程:自己的数据库连接,负责历史查询和导出
界面线程:接收查询结果,更新控件

每个连接使用不同名称。SQLite 同一时刻仍只有一个写入者,多建几个写线程并不能直接提高写入性能。

释放命名连接时,需要先销毁相关 QSqlQuery,再释放所有引用该连接的 QSqlDatabase 对象,最后调用 removeDatabase()。

此外,设备长期运行时,数据库文件会持续增长。可以按日期拆分文件,或者设置数据保留周期,避免直到磁盘写满才发现问题。