先聊一个实际场景:你在 Qt 里做桌面工具,SQLite 作为本地存储,前期两百万条数据跑得很顺。等业务量涨到千万级,问题开始集中爆发——打开列表要等好几秒,滚动表格卡成 PPT,执行一次SELECT COUNT(*)都能让界面假死。网上搜到的方案大多是“用 LIMIT 分页”“加索引”,但你发现 LIMIT 后面 offset 一深,SQL 反而更慢。这篇文章会围绕 Qt + SQLite 的千万级数据 CRUD 性能优化,重点讲清楚游标分页的实现原理与代码落地,附带批量写入、Upsert、删除策略、线程改造等工程经验。
适合的读者有两类:一类是刚接触 Qt 和 SQLite 的新手,需要理解为什么数据量一大界面就卡;另一类是有一定开发经验,希望拿到完整可运行的代码和排查思路的开发者。读完你会掌握一个核心技巧:用WHERE id > lastId ORDER BY id ASC LIMIT pageSize这类“游标式查询”替代深分页,再结合 Qt 的流式查询接口,让千万级数据表的翻页、查询、刷新都保持毫秒级响应。
1. 项目背景:为什么 SQLite 数据量一大,Qt 界面就卡
1.1 SQLite 在 Qt 桌面应用中的角色
SQLite 是桌面软件里最常用的嵌入式数据库。它不需要单独部署服务端,整个数据库就是一个.db文件,Qt 通过内置的QSQLITE驱动可以直接读写,天然适合本地工具、单机管理软件、上位机程序等场景。
在小型应用里,SQLite 通常承担配置存储、日志记录、业务数据落盘等职责。数据量停留在几千到几十万条时,不需要刻意优化,随手写一个QSqlQueryModel绑定到QTableView就能跑。但当数据量进入千万级,问题就不再是“能不能查出来”,而是“查询时间会不会阻塞 UI”。
1.2 千万级数据带来的典型问题
从实际项目反馈来看,千万级数据表在 Qt 应用里通常会遇到三类问题:
第一,全量加载导致内存和界面同时失控。很多人上手会直接SELECT * FROM table,然后把结果塞进QStandardItemModel。假设每行有 10 个字段,一千万行光模型内部的对象创建和 UI 刷新就是灾难级开销,程序可能在几秒内耗尽内存。
第二,单次执行耗时过长,阻塞事件循环。Qt 的 UI 事件循环由主线程维护。如果主线程直接执行耗时 SQL,窗口不会重绘、按钮点了没反应、窗口拖动会出现残影。哪怕查询只需要两秒,用户感知也是“程序死了”。
第三,分页方式选错,越翻越慢。常见做法是LIMIT offset, count。offset 不是直接跳到目标位置,而是让 SQLite 从头扫描、丢弃前面 offset 行,数据量越大越慢。
1.3 卡顿的三大根因
把上面现象拆开看,UI 卡顿本质上有三个原因:
- 查询时间长:没有合适索引,或者使用了深 offset 翻页,SQLite 执行了大量无效扫描;
- 数据装载量大:一次查询几百万行,还要转换成 UI 控件对象;
- 主线程阻塞:SQL 操作和 UI 更新都在主线程,事件循环无法及时响应输入。
所以,解决“千万级数据流畅 CRUD”的关键不是某一条技巧,而是一套组合拳:游标分页控制单次查询数据量,索引设计提升查询速度,必要时把耗时任务放到子线程,最后才是 UI 模型层面的优化。后面的实战案例会按这个思路逐步展开。
2. 环境准备与版本说明
2.1 Qt 与 SQLite 版本选择
本文示例基于 Qt 6 编写,代码同样兼容 Qt 5.15 及更高版本。使用 Qt 5 时,只需要把CMakeLists.txt里的Qt6改成Qt5,其余代码基本不需要改动。
SQLite 版本上,需要注意一个小点:如果要在 Qt 里使用INSERT ... ON CONFLICT DO UPDATE这种 Upsert 语法,需要 SQLite 3.24.0 及以上版本。Qt 官方预编译包自带的 SQLite 通常较新,但如果你的程序打包到旧 Linux 系统或使用系统自带 SQLite,建议在初始化时执行SELECT sqlite_version();做一次版本检查。
2.2 开发工具与验证工具
开发环境建议准备以下工具:
- Qt Creator 或 Visual Studio + Qt 插件;
- CMake 3.16+;
- C++ 编译器,支持 C++17 即可;
- DB Browser for SQLite,用于查看生成的
.db文件、手动执行 SQL、确认索引和数据量。
DB Browser for SQLite 在验证阶段很实用。程序跑完造数逻辑后,可以直接用它打开数据库,检查表结构、索引、总行数,甚至手动执行一条测试 SQL,排除 Qt 代码层面的干扰。
2.3 项目结构
为了保持代码清晰,示例项目按下面结构组织:
QtSqlitePagingDemo/ ├── CMakeLists.txt ├── main.cpp ├── mainwindow.h ├── mainwindow.cpp ├── databasehelper.h └── databasehelper.cppdatabasehelper负责数据库连接、建表、造数、分页查询和增删改操作;mainwindow负责界面布局和用户交互。这样的分层方便你把数据库逻辑迁移到后台线程。
3. 核心原理:游标分页为什么比 OFFSET 分页快
3.1 传统 OFFSET 分页的问题
很多开发者熟悉的分页 SQL 是这样的:
SELECT id, name, age, email FROM user_info ORDER BY id LIMIT 500 OFFSET 500000;这条 SQL 的含义是:先按照id升序排序,然后跳过前面 50 万行,再返回接下来的 500 行。
问题在于 SQLite 没有“直接跳到第 50 万行”的能力。它必须从第一行开始扫描,把前面 50 万行逐行读一遍再丢弃,最后才返回目标数据。页面越靠后,扫描成本越高。当 offset 达到几百万时,单次查询耗时可能是几百毫秒甚至几秒,更别说还要在 UI 线程里等它返回。
另外,LIMIT offset, count这种写法在数据频繁增删时还会出现重复或跳漏的问题。比如用户停在第二页,后台又插入了几条新记录,再翻下一页时可能把上一页的尾部数据又读了一遍。
3.2 游标分页原理:记住上一页最后一条记录
游标分页(也叫 Keyset Pagination、Seek Method)的核心思想是:不使用页码,而是把“上一页最后一条记录的位置”作为下一页的起始条件。
最典型也是最简单的实现,就是利用主键的自增特性:
SELECT id, name, age, email FROM user_info WHERE id > 500000 ORDER BY id ASC LIMIT 500;这里的500000是上一页最后一条记录的id。SQLite 可以利用id主键索引,直接定位到500000之后的位置,只扫描目标范围内的 500 行,因此查询耗时基本稳定。即使你已经翻到第 1000 页,耗时也和第 2 页差不多。
这个方案有几个明显好处:
- 每次查询的数据量固定,响应时间稳定;
- 不受“跳过大量行”的影响,索引命中率高;
- 新增数据不会影响已读分页的位置,因为
WHERE id > lastId是从游标位置继续向后取。
需要注意,游标分页依赖一个稳定有序的排序键。如果只按id排序,那游标就是id;如果业务上要按create_time排序,那么游标通常要变成(create_time, id)组合,并且 SQL 写成:
WHERE (create_time > ?) OR (create_time = ? AND id > ?) ORDER BY create_time ASC, id ASC LIMIT ?;这是因为create_time可能重复,必须再带上唯一字段id作为第二排序键,才能确保游标位置不歧义。
3.3 Qt 中 QSqlQuery 与流式查询
在传统观念里,分页通常是在数据库层完成的,也就是用LIMIT限制返回条数。但在 Qt 里还要理解另一个层次:QSqlQuery本身也是“游标式”读取结果集的。
默认情况下,QSqlQuery的setForwardOnly为false,这意味着查询结果允许随机跳转,比如seek()到指定行。但随机跳转会带来额外缓冲。如果只是顺序读取、不需要回溯,应该调用setForwardOnly(true),告诉驱动我们只需要向前读取,这样某些驱动会使用流式返回,降低内存占用。
在 SQLite 中,流式读取的效果是:每调用一次query.next(),底层才会从数据库中取出一行数据。因此即使一张表有几千万行,只要你不把它全部装进容器,内存占用就不会暴涨。
这里要区分两个层面的“游标分页”:
- 数据库层面:用
WHERE id > lastId ORDER BY id ASC LIMIT pageSize做键集分页,控制单次返回行数; - Qt 接口层面:用
QSqlQuery::setForwardOnly(true)配合next()顺序读取,避免一次性把结果集缓冲到应用内存。
本项目的实战代码会把两者结合:外层用键集分页控制每页数据量,内存层再通过流式查询逐行读取,形成一套完整的“千万级数据翻页不卡”方案。
4. 实战:Qt + SQLite 千万级数据分页 CRUD
下面进入完整实战。示例会用 Qt Widgets 做一个简单窗口,支持初始化数据库、批量造数、下一页、上一页、Upsert 更新,以及打开数据库文件等功能。为了快速演示效果,造数默认是 10 万条,你可以根据机器性能改成 100 万或 1000 万。
4.1 创建项目与 CMake 配置
新建一个 Qt Widgets Application 项目,也可以直接创建纯 C++ 项目后引入 Qt,CMakeLists.txt配置如下:
cmake_minimum_required(VERSION 3.16) project(QtSqlitePagingDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets Sql REQUIRED) add_executable(QtSqlitePagingDemo main.cpp mainwindow.h mainwindow.cpp databasehelper.h databasehelper.cpp ) target_link_libraries(QtSqlitePagingDemo PRIVATE Qt6::Widgets Qt6::Sql)如果你使用的是 Qt 5,把find_package(Qt6 ...)改成find_package(Qt5 COMPONENTS Widgets Sql REQUIRED),同时把Qt6::Widgets、Qt6::Sql改成Qt5::Widgets、Qt5::Sql。
4.2 数据库工具类:初始化与造数
databasehelper.h负责声明数据库操作接口:
// 文件路径:databasehelper.h #pragma once #include <QString> #include <QVector> #include <QVariant> struct PageResult { QVector<QVector<QVariant>> rows; qint64 nextCursor = -1; bool hasMore = false; }; class DatabaseHelper { public: static bool initDatabase(const QString &dbPath); static bool createTable(); static qint64 totalCount(); static bool generateData(qint64 count); static bool upsertRecord(qint64 id, const QString &name, int age, const QString &email); static bool deleteBatch(qint64 beginId, qint64 endId); static PageResult fetchPage(qint64 lastId, int pageSize); };initDatabase里除了打开数据库,还会顺手设置几条 SQLite 编译指令,后续写入和查询会受益:
// 文件路径:databasehelper.cpp 片段 #include "databasehelper.h" #include <QSqlDatabase> #include <QSqlQuery> #include <QSqlError> #include <QFileInfo> #include <QDir> #include <QDebug> #include <QElapsedTimer> bool DatabaseHelper::initDatabase(const QString &dbPath) { QFileInfo info(dbPath); QDir dir(info.absolutePath()); if (!dir.exists()) { dir.mkpath("."); } QString connName = "paging_conn"; if (QSqlDatabase::contains(connName)) { QSqlDatabase::database(connName).close(); QSqlDatabase::removeDatabase(connName); } QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", connName); db.setDatabaseName(dbPath); db.setConnectOptions("QSQLITE_BUSY_TIMEOUT=5000"); 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 cache_size=-65536"); return true; }这里重点说明几个 PRAGMA:
journal_mode=WAL:SQLite 的预写日志模式,允许写入时不阻塞读取,适合桌面应用常见的“读多写少”场景;synchronous=NORMAL:降低同步频率,减少磁盘写入等待,但崩溃恢复时的安全性有所下降。如果存的是强一致业务数据,建议保持FULL;cache_size=-65536:把 SQLite 的页缓存设置为约 64MB,对千万级数据的查询有帮助。
建表和统计总行数:
bool DatabaseHelper::createTable() { QSqlDatabase db = QSqlDatabase::database("paging_conn"); QSqlQuery query(db); bool ok = query.exec(R"sql( CREATE TABLE IF NOT EXISTS user_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL, email TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); )sql"); if (!ok) { qCritical() << "create table failed:" << query.lastError().text(); } return ok; } qint64 DatabaseHelper::totalCount() { QSqlQuery query(QSqlDatabase::database("paging_conn")); if (query.exec("SELECT COUNT(*) FROM user_info")) { if (query.next()) { return query.value(0).toLongLong(); } } return 0; }totalCount用于界面下方显示总数据量。注意千万级数据表执行COUNT(*)也需要扫描索引,如果表变化不频繁,可以在程序启动时缓存一次。
接下来是造数逻辑。为了覆盖千万级测试场景,这里用一个比较稳的批量插入写法。单条插入放在事务里逐条执行,简单且不会让事务过长:
bool DatabaseHelper::generateData(qint64 count) { QSqlDatabase db = QSqlDatabase::database("paging_conn"); QSqlQuery clearQuery(db); if (!clearQuery.exec("DELETE FROM user_info")) { qCritical() << "clear table failed:" << clearQuery.lastError().text(); return false; } const int batchSize = 100000; QElapsedTimer timer; timer.start(); QSqlQuery insertQuery(db); insertQuery.prepare("INSERT INTO user_info (name, age, email) VALUES (?, ?, ?)"); db.transaction(); for (qint64 i = 0; i < count; ++i) { insertQuery.addBindValue(QString("user_%1").arg(i)); insertQuery.addBindValue(int(i % 100)); insertQuery.addBindValue(QString("%1@example.com").arg(i)); insertQuery.exec(); if ((i + 1) % batchSize == 0) { db.commit(); db.transaction(); } } db.commit(); qDebug() << "generateData finished, rows =" << count << ", cost ms =" << timer.elapsed(); return true; }这个版本的优点是逻辑简单、不易出错。但 1000 万条数据逐条执行exec()会比较慢,可能耗时几分钟。如果急着验证效果,建议先用 10 万条测试。想要更快生成大量测试数据,可以用多行INSERT拼接,我在第 7 节最佳实践里会给出优化方向。
4.3 游标分页查询实现
分页查询是本项目的核心。这里实现fetchPage,它接受上一页最后一条记录的id和页面大小,返回当前页数据以及新的游标位置:
PageResult DatabaseHelper::fetchPage(qint64 lastId, int pageSize) { PageResult result; QSqlQuery query(QSqlDatabase::database("paging_conn")); query.setForwardOnly(true); // 多取 1 条,用来判断是否还有下一页 query.prepare("SELECT id, name, age, email " "FROM user_info " "WHERE id > ? " "ORDER BY id ASC " "LIMIT ?"); query.addBindValue(lastId); query.addBindValue(pageSize + 1); if (!query.exec()) { qCritical() << "fetchPage failed:" << query.lastError().text(); return result; } int count = 0; while (query.next()) { if (count == pageSize) { result.hasMore = true; break; } QVector<QVariant> row; row << query.value(0) << query.value(1) << query.value(2) << query.value(3); result.rows.append(row); result.nextCursor = query.value(0).toLongLong(); ++count; } return result; }这段代码有两个关键点。
第一,LIMIT ?绑定的是pageSize + 1。如果查询结果超过pageSize,说明后面还有数据,此时只展示前pageSize行,并通过hasMore = true通知界面。如果恰好只有pageSize行,说明已经到末尾,hasMore保持false。这种“多取一条”的技巧避免了单独再执行一次COUNT(*)来判断是否还有下一页。
第二,result.nextCursor保存的是当前页最后一条记录的id。下一页执行时把它作为WHERE id > ?的绑定参数,就完成了游标推进。
索引在这里是重中之重。因为表的主键就是id,PRIMARY KEY自动生成唯一索引,所以WHERE id > ? ORDER BY id ASC可以直接命中索引,实现接近“直接跳转”的效果。如果你的分页排序字段不是主键,比如要按age排序,就需要额外给age建索引,否则性能立刻退化。
4.4 界面层编排:下一页、上一页、状态展示
界面方面,mainwindow.h设计如下:
// 文件路径:mainwindow.h #pragma once #include <QMainWindow> #include <QVector> #include <QVariant> class QTableView; class QLabel; class QStandardItemModel; class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); private slots: void onInitDatabase(); void onGenerateData(); void onNextPage(); void onPrevPage(); void onUpsert(); void onOpenFile(); private: void refreshTable(const QVector<QVector<QVariant>> &rows); void updateStatus(bool hasMore); QTableView *m_table = nullptr; QLabel *m_statusLabel = nullptr; QStandardItemModel *m_model = nullptr; QVector<qint64> m_cursorHistory; qint64 m_lastId = 0; int m_pageSize = 500; };MainWindow内部维护了一个m_cursorHistory,用于实现“上一页”。因为游标分页天然只向“下一页”推进,想回退,就需要通过历史记录把之前页面的起始位置保存下来。点击下一页前,先把当前m_lastId入栈;点击上一页时,从栈中弹出上一页的起始游标,重新查询。
核心逻辑在mainwindow.cpp中实现:
// 文件路径:mainwindow.cpp 核心片段 #include "mainwindow.h" #include "databasehelper.h" #include <QTableView> #include <QLabel> #include <QPushButton> #include <QVBoxLayout> #include <QHBoxLayout> #include <QStandardItemModel> #include <QHeaderView> #include <QFileDialog> #include <QDir> #include <QDateTime> #include <QMessageBox> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *central = new QWidget(this); m_table = new QTableView(this); m_model = new QStandardItemModel(this); m_table->setModel(m_model); m_table->horizontalHeader()->setStretchLastSection(true); m_table->setAlternatingRowColors(true); m_table->setSelectionBehavior(QAbstractItemView::SelectRows); m_model->setHorizontalHeaderLabels({"id", "name", "age", "email"}); auto *btnInit = new QPushButton("初始化数据库", this); auto *btnGen = new QPushButton("生成测试数据", this); auto *btnPrev = new QPushButton("上一页", this); auto *btnNext = new QPushButton("下一页", this); auto *btnUpsert = new QPushButton("Upsert 更新", this); auto *btnOpen = new QPushButton("打开数据库文件", this); m_statusLabel = new QLabel("游标: 0 | 总数: 0 | 每页: 500", this); auto *btnLayout = new QHBoxLayout(); btnLayout->addWidget(btnInit); btnLayout->addWidget(btnGen); btnLayout->addWidget(btnPrev); btnLayout->addWidget(btnNext); btnLayout->addWidget(btnUpsert); btnLayout->addWidget(btnOpen); auto *layout = new QVBoxLayout(central); layout->addLayout(btnLayout); layout->addWidget(m_table); layout->addWidget(m_statusLabel); setCentralWidget(central); resize(1000, 600); connect(btnInit, &QPushButton::clicked, this, &MainWindow::onInitDatabase); connect(btnGen, &QPushButton::clicked, this, &MainWindow::onGenerateData); connect(btnPrev, &QPushButton::clicked, this, &MainWindow::onPrevPage); connect(btnNext, &QPushButton::clicked, this, &MainWindow::onNextPage); connect(btnUpsert, &QPushButton::clicked, this, &MainWindow::onUpsert); connect(btnOpen, &QPushButton::clicked, this, &MainWindow::onOpenFile); }初始化数据库和造数:
void MainWindow::onInitDatabase() { QString dbPath = QDir::temp().filePath("paging_demo.db"); if (DatabaseHelper::initDatabase(dbPath) && DatabaseHelper::createTable()) { m_statusLabel->setText(QString("数据库已初始化: %1").arg(dbPath)); } else { QMessageBox::critical(this, "错误", "数据库初始化失败"); } } void MainWindow::onGenerateData() { QMessageBox::information(this, "提示", "开始生成 10 万条测试数据,请稍候"); bool ok = DatabaseHelper::generateData(100000); if (ok) { m_lastId = 0; m_cursorHistory.clear(); m_statusLabel->setText(QString("数据生成完成,总数: %1").arg(DatabaseHelper::totalCount())); } }实际测试时,把generateData(100000)改成generateData(10000000)就是千万级数据压测。这里建议先跑 10 万或 50 万,确认界面流畅后再尝试更大规模。
下一页和上一页:
void MainWindow::onNextPage() { PageResult result = DatabaseHelper::fetchPage(m_lastId, m_pageSize); if (result.rows.isEmpty()) { m_statusLabel->setText("没有更多数据了"); return; } m_cursorHistory.push_back(m_lastId); refreshTable(result.rows); m_lastId = result.nextCursor; updateStatus(result.hasMore); } void MainWindow::onPrevPage() { if (m_cursorHistory.isEmpty()) { m_statusLabel->setText("当前已经在第一页"); return; } qint64 prevStartId = m_cursorHistory.back(); m_cursorHistory.pop_back(); PageResult result = DatabaseHelper::fetchPage(prevStartId, m_pageSize); refreshTable(result.rows); m_lastId = result.nextCursor; updateStatus(result.hasMore); }刷新表格时,注意关闭界面的实时更新,减少大量插入时的闪烁:
void MainWindow::refreshTable(const QVector<QVector<QVariant>> &rows) { m_table->setUpdatesEnabled(false); m_model->removeRows(0, m_model->rowCount()); for (const auto &row : rows) { QList<QStandardItem *> items; items.reserve(row.size()); for (const auto &value : row) { items.append(new QStandardItem(value.toString())); } m_model->appendRow(items); } m_table->setUpdatesEnabled(true); m_table->update(); } void MainWindow::updateStatus(bool hasMore) { qint64 total = DatabaseHelper::totalCount(); QString moreText = hasMore ? "还有下一页" : "已到最后一页"; m_statusLabel->setText(QString("游标: %1 | 总数: %2 | 每页: %3 | %4") .arg(m_lastId) .arg(total) .arg(m_pageSize) .arg(moreText)); }由于每页只有 500 行,QStandardItemModel的刷新成本很低,用户几乎感觉不到卡顿。这也正是游标分页带来的直接收益:不是“查询变快了”,而是“每次只查一小部分”,查询时间被控制在了 UI 可接受的范围内。
4.5 Upsert 与批量删除
接下来补齐更新和删除操作。Upsert 是“有则更新,无则插入”的写法,对应热搜词里的“sqlite 存在就更新不存在就新增”。SQLite 3.24 之后支持ON CONFLICT DO UPDATE:
bool Database