news 2026/9/2 16:54:54

Qt+SQLite千万级数据游标分页实战与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt+SQLite千万级数据游标分页实战与性能优化

这次我们直接看一个 Qt 项目里非常实际的性能问题:SQLite 单表数据量到了千万级之后,原来的列表查询、CRUD 和界面刷新会越来越卡。很多项目其实不是死在 SQLite 本身,而是死在“拿处理万级数据量的分页方式去处理千万级数据”。本文把 Qt + SQLite 的游标分页实战完整拆开,给出表设计、索引策略、CRUD 代码、UI 模型和异步刷新方案,目标是让千万级数据列表在 QTableView 上保持可交互的流畅度。

先说结论。传统 LIMIT + OFFSET 分页在 OFFSET 到几十万、上百万之后,SQLite 每次都要扫描并丢弃前面的行,耗时随页码上涨;游标分页(Keyset Pagination)用索引直接定位“上一页最后一条记录”,下一页从它后面继续取,查询耗时与总数据量基本解耦。这是 Qt 客户端做本地大数据量列表最值得先尝试的方案之一。

本文面向 Qt 开发者和正在做上位机、数据采集、工业组态、本地历史数据查询的同学。下面包含:核心能力速览、适用场景、环境准备、表设计与索引、游标分页实现、千万级 CRUD 实战、UI 分页模型与异步加载、性能观察方法、问题排查和最佳实践。文中代码基于 Qt 5.15+ / Qt 6 的 QSql 模块,可以直接复制后用 CMake 工程验证。

1. 核心能力速览

能力项说明
技术栈Qt 5.15+ / Qt 6,QSqlDatabase + QSqlQuery + QAbstractTableModel
数据量目标单表千万级
核心方案游标分页(Keyset Pagination),替代 LIMIT + OFFSET
UI 优化分页加载、后台线程查询、只渲染当前页数据
写入优化事务 + 预编译语句批量写入
更新策略UPSERT(ON CONFLICT)
支持平台Windows / Linux / macOS
接口形态本地数据库访问,可封装为 Qt 内部服务接口
批量任务支持批量导入、批处理更新和删除
硬件建议SSD + 8G 内存以上,具体耗时以本机为准

这套方案有一个前提:分页是基于排序键的连续翻页,而不是任意跳页。如果产品要求“直接跳到第 100 万条”,游标分页并不合适,需要另行设计索引、缓存或离线统计方案。先认清这一点,再决定是否采用。

2. 适用场景与使用边界

游标分页最适合的场景是:数据持续增长、用户按顺序翻页查看、列表需要保持稳定响应。典型例子包括设备上报数据、温度采样曲线、日志检索、订单流水、历史告警记录。这些场景的共同特征是数据量大、写入以追加为主、读取是顺序翻页。

使用边界也很明确。第一,SQLite 是单文件单写者数据库,不适合多进程高并发写入场景。第二,游标分页擅长顺序翻页,不支持随机跳页,如果 UI 提供“跳转到第 N 页”的输入框,那还需要结合总数估算或改用其他方案。第三,千万级数据仍然不适合一次性加载到界面上,即使改用了游标分页,UI 模型也必须按页装载数据,不能把整表塞进 QTableView。

数据合规方面需要单独说明:演示和开发时建议使用模拟数据或脱敏数据;如果使用真实采集数据,需要确认数据来源合法、不包含未授权的个人信息或敏感内容。尤其是设备编号、工号、位置信息、鉴权字段,发布示例代码前要清理干净。测试环境用模拟数据验证逻辑,生产库的 Schema 变更和批量操作要先备份,再执行。

3. 环境准备与 Qt 项目搭建

写 Qt 工程前,先确认环境里安装了 Qt 的 SQL 模块。Qt 官方安装包默认包含 QSQLITE 驱动,不需要额外编译 SQLite。如果发现驱动加载失败,查看是否缺少 Qt SQL 相关模块,以及插件目录路径是否正确。Windows 下常见问题是 release 程序加载 debug 插件,或缺少插件 DLL,这个在常见问题章节统一排查。

建议工程用 CMake 管理。确认以下依赖已经通过 find_package 引入:

cmake_minimum_required(VERSION 3.16) project(QtSqliteCursorPaging) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Core Gui Widgets Sql REQUIRED) qt_add_executable(QtSqliteCursorPaging main.cpp MainWindow.cpp MainWindow.h MeasureDataModel.cpp MeasureDataModel.h MeasureQueryWorker.cpp MeasureQueryWorker.h ) target_link_libraries(QtSqliteCursorPaging PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Sql )

如果还在用 Qt 5,把 Qt6 换成 Qt5,CMake 用法一致。工程目录建议按 db / model / worker / ui 分层,避免把 SQL 散落在界面代码里。下面是一套简单分层:

src/ db/ SQLiteHelper.cpp SQLiteHelper.h model/ MeasureDataModel.cpp MeasureDataModel.h worker/ MeasureQueryWorker.cpp MeasureQueryWorker.h ui/ MainWindow.cpp MainWindow.h

打开数据库时,连接名不要用默认的qt_sql_default_connection到处混用。多线程场景下,建议为每个线程显式创建连接,并带上不同 connectionName。下面是 SQLiteHelper 的打开逻辑示例:

#include <QSqlDatabase> #include <QSqlQuery> #include <QSqlError> #include <QSqlRecord> #include <QVariant> #include <QDebug> bool openDatabase(const QString &dbPath, const QString &connectionName, QSqlDatabase &db) { db = QSqlDatabase::addDatabase("QSQLITE", connectionName); db.setDatabaseName(dbPath); if (!db.open()) { qCritical() << "open database failed" << db.lastError().text(); return false; } QSqlQuery query(db); query.exec("PRAGMA journal_mode = WAL"); query.exec("PRAGMA synchronous = NORMAL"); query.exec("PRAGMA busy_timeout = 5000"); query.exec("PRAGMA cache_size = -20000"); // 约 20MB page cache return true; }

打开连接后,建议先执行一遍 WAL、busy_timeout 和 cache_size 设置。WAL 模式能明显改善读写并发时的database is locked问题,busy_timeout 让 SQLite 等待锁而不是立即报错。cache_size 是示例值,具体大小要结合机器内存调整,不是越大越好。

4. 表设计与索引策略

千万级数据能不能跑起来,首先看表结构。下面是一张设备采集表,字段尽量贴近生产场景:

CREATE TABLE IF NOT EXISTS measure_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_no TEXT NOT NULL, channel INTEGER NOT NULL DEFAULT 0, value REAL NOT NULL, ts TEXT NOT NULL, remark TEXT ); CREATE INDEX IF NOT EXISTS idx_measure_data_ts ON measure_data(ts DESC); CREATE INDEX IF NOT EXISTS idx_measure_data_device_ts ON measure_data(device_no, ts DESC);

id 作为 INTEGER PRIMARY KEY,在 SQLite 中会作为 ROWID 别名,天然适合做游标分页的排序键。ts 是业务时间,device_no + ts 的组合索引用来支撑“按设备查询,并按时间倒序翻页”的常见场景。

索引不是越多越好。千万级表上,每次写入都要同步维护索引,索引过多会拖慢插入和更新。先根据实际查询条件设计两三个索引,再通过 EXPLAIN QUERY PLAN 验证是否命中,不要盲目给每一个字段都建索引。

造数阶段建议用脚本或 Qt 程序批量生成模拟数据。千万级 CSV 手工准备很慢,更推荐直接本地生成。生成时保持字段分布接近真实:设备编号几十台、通道几个、时间连续、数值随机。注意模拟数据不要包含真实设备编号、真实地址和真实个人信息。

5. 游标分页原理与 SQL 对照

先看传统分页的痛点。假设每页 100 条,要取第 9000 页的数据:

SELECT id, device_no, channel, value, ts, remark FROM measure_data ORDER BY id ASC LIMIT 100 OFFSET 899900;

这条 SQL 在数据量小的时候没问题。但当表里有 1000 万条记录时,SQLite 为了算出 OFFSET 899900,需要扫描并丢弃前 899900 行,查询耗时会随 OFFSET 越来越大。这是列表越翻越卡的直接原因。

游标分页的思路是:记住当前页最后一条记录的 id,下一页查询用 WHERE id > :last_id,然后 LIMIT 100。SQL 如下:

-- 第一页 SELECT id, device_no, channel, value, ts, remark FROM measure_data ORDER BY id ASC LIMIT 100; -- 下一页,last_id 是上一页最后一条记录的 id SELECT id, device_no, channel, value, ts, remark FROM measure_data WHERE id > :last_id ORDER BY id ASC LIMIT 100;

因为 id 是主键,SQLite 利用索引可以直接跳到最后一条位置,不需要扫描前面的大量数据。数据量从 100 万涨到 1000 万后,只要翻页位置接近最新数据,查询耗时的变化会远小于 OFFSET 分页。

多字段排序也可以做游标分页。比如按 device_no 过滤,然后按 ts DESC, id DESC 排序。SQLite 3.15 及以上支持行值比较:

SELECT id, device_no, channel, value, ts, remark FROM measure_data WHERE device_no = 'DEV-001' AND (ts, id) < (:last_ts, :last_id) ORDER BY ts DESC, id DESC LIMIT 100;

使用 (ts, id) 行值语法时,需要确保排序列和过滤列能被索引覆盖。以上这条 SQL 对应的索引是 idx_measure_data_device_ts。如果只写 ts 条件而索引顺序不匹配,SQLite 可能仍然走全表扫描,性能就回不到预期。

下面是把游标分页封装成 Qt 函数的完整实现:

struct MeasureRecord { qlonglong id = 0; QString deviceNo; int channel = 0; double value = 0.0; QString ts; QString remark; }; QVector<MeasureRecord> queryPage( qlonglong cursorId, int pageSize, QSqlDatabase &db, QString *errorText = nullptr) { QVector<MeasureRecord> records; // pageSize 来自调用方常量或整数参数,拼入前做范围校验 if (pageSize <= 0 || pageSize > 5000) { pageSize = 100; } QString sql = QStringLiteral( "SELECT id, device_no, channel, value, ts, remark " "FROM measure_data " "WHERE id > %1 " "ORDER BY id ASC " "LIMIT %2") .arg(cursorId) .arg(pageSize); QSqlQuery query(db); query.setForwardOnly(true); if (!query.exec(sql)) { if (errorText) { *errorText = query.lastError().text(); } return records; } while (query.next()) { MeasureRecord rec; rec.id = query.value(0).toLongLong(); rec.deviceNo = query.value(1).toString(); rec.channel = query.value(2).toInt(); rec.value = query.value(3).toDouble(); rec.ts = query.value(4).toString(); rec.remark = query.value(5).toString(); records.append(rec); } return records; }

这里 cursorId 是从上一页最后一条记录中取出的整数,pageSize 也做了

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 16:53:27

在Docker上复现一个github项目

接上一篇文章&#xff0c;已经将docker镜像创建好&#xff0c;并和本地目录镜像做了一个统一&#xff1b;这篇记录如何在这个docker容器中进行一个项目复现&#xff1b;复现项目链接&#xff1a;GitHub - HKUDS/LightRAG: [EMNLP2025] LightRAG: Simple and Fast Retrieval-Aug…

作者头像 李华
网站建设 2026/9/2 16:52:41

Excel零基础到进阶:函数、透视表与数据分析实战全攻略

大家好&#xff0c;我是你们的老朋友。 今天这篇内容&#xff0c;我用自己多年的项目实操积累&#xff0c;和你完整拆解一套 Excel 零基础入门到进阶的实战教程。这篇文章的主线很清晰&#xff1a;从 Excel 的基本认知&#xff0c;到高频函数、数据透视表、数据清洗&#xff0…

作者头像 李华
网站建设 2026/9/2 16:52:38

个人知识管理系统:从信息分流到知识复用的实践指南

把“信息”和“知识”区分开&#xff0c;本质上是一个存储与计算问题。信息是流量&#xff0c;是网络上不断刷出来的新闻、帖子、视频、文档&#xff1b;知识是经过提炼、结构化并能够复用的结论。只会获取信息的人&#xff0c;像一台只读缓存&#xff0c;一直在“看过”“收藏…

作者头像 李华
网站建设 2026/9/2 16:47:53

豆包 LeetCode 39. 组合总和 Java实现

题目说明 LeetCode39 组合总和 给定无重复元素候选数组 candidates 和目标 target &#xff0c;可以重复选取数组元素&#xff0c;找出所有和为 target 的组合。 同一个数字可以多次选用组合顺序无关&#xff0c;不能重复输出组合回溯DFS实现 Java完整代码 java import java.ut…

作者头像 李华
网站建设 2026/9/2 16:44:07

ab-testing - sample-size-guide

样本量指南 计算样本量和测试持续时间的参考。 目录 样本量基础&#xff08;所需输入、含义&#xff09;样本量快速参考表持续时间计算器&#xff08;公式、示例、最小持续时间规则、最大持续时间指南&#xff09;在线计算器为多个变体调整常见样本量错误当样本量要求过高时顺序…

作者头像 李华