如果你的 Qt 程序里 QTableView 加载几十万行数据时,界面已经卡得拖不动,滚动一下要等两三秒,那这篇文章就是给你准备的。
这次我们来看一个非常务实的组合:Qt + SQLite。SQLite 常被误认为只能做小工具、小配置存储,但实际上只要正确使用索引、事务和分页策略,它完全可以支撑千万级数据量的桌面应用。核心问题只有一个:数据量上来后,直接把全部结果集塞给 UI 控件的写法,会让 UI 线程被查询、内存分配和控件刷新拖垮。
这篇文章会先给出一套完整的 Qt + SQLite 千万级数据 CRUD 方案,重点讲游标分页(Keyset Paging)怎么在 UI 上落地,并附上基础 CRUD、批量写入、事务优化、UI 模型刷新、性能观察和排查方法。读完你可以直接套用,不需要改动现有项目结构。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | Qt 桌面应用 + SQLite 数据库的工程实践 |
| 核心功能 | 千万级数据量的增删改查、批量写入、游标分页查询 |
| 分页方案 | Keyset 分页(基于排序字段游标),替代 OFFSET 大偏移分页 |
| UI 刷新方式 | QAbstractTableModel 模型增量刷新 + 滚动加载,避免一次性加载全量数据 |
| 数据库文件 | 单文件 SQLite,可随应用分发,支持 WAL 模式提升并发读写 |
| 适合场景 | 本地数据管理工具、工业组态历史数据查询、桌面报表、日志分析工具 |
| 不适合场景 | 高并发服务端写入、多进程大规模同时写入、超复杂 SQL 分析 |
先说明一点:本文讲的是工程方案,不是某个开源整合包。你不需要额外安装大型运行时,只需要 Qt 开发环境和一个能跑 SQLite 的编译器,就能把这个架构应用到自己项目里。
2. 适用场景与使用边界
2.1 适合谁
- Qt 桌面应用开发者,尤其是 QTableView、QListView、QTreeView 遇到大数据量卡顿的人。
- 需要做本地数据管理系统、历史数据查询工具、组态软件数据记录模块的人。
- 希望不引入 MySQL、PostgreSQL 等独立数据库服务,直接用 SQLite 扛住大数据量单机场景的人。
2.2 能解决什么问题
用游标分页后,UI 不再需要一次拿到几十万行数据。每次只查询当前屏幕附近的一小段数据,比如 50 行或 100 行,滚动到接近底部时再取下一批。这样内存占用、查询耗时、UI 刷新成本都控制在很小范围内,用户感知就是“滑动流畅、不卡顿”。
2.3 不适合什么场景
- 需要随机跳到第 800000 行,并且要求瞬时完成,这种场景不适合游标分页,更适合数据库端聚合统计或数据仓库方案。
- 多个进程同时高频写同一个 SQLite 文件,锁竞争会非常明显。SQLite 适合单进程多线程或少量进程低频写入。
- 数据本身在服务端,不需要本地缓存,就没必要往客户端塞 SQLite。
2.4 使用边界与合规提醒
SQLite 本身是开源且可靠的嵌入式数据库,但如果你把用户数据、客户资料、隐私信息放入本地数据库,仍然要注意:
- 对敏感字段做加密或采用系统级加密方案。
- 导出、分享数据前确认授权。
- 删除数据时要考虑“物理删除”和“逻辑删除”的差别,避免隐私残留。
- 涉及版权素材、人脸、声音、个人隐私数据时,必须获得明确授权。
3. 为什么 SQLite 在 Qt 里会卡 UI
很多人把卡顿归咎于 SQLite 性能差,实际更常见的原因是使用方式不对。
3.1 主线程同步执行长查询
如果直接在 UI 线程执行SELECT * FROM table,当数据量到几十万行时,SQLite 解析和返回数据的时间会阻塞事件循环。对于 Qt 应用,QTableWidget 大量 setItem 也会让界面长时间无响应。
3.2 一次加载全量数据到 Item 控件
QTableWidget 是 Qt 里最“重”的表格控件,因为每一个单元格都是一个 QTableWidgetItem 对象。50 万行乘 10 列就是 500 万个对象,光构造和释放就把内存吃满了。
3.3 分页用了 OFFSET
传统分页写法:
SELECT * FROM record ORDER BY id LIMIT 50 OFFSET 800000;这个 SQL 在 SQLite 中的开销很大,因为数据库必须先扫描并丢弃前 800000 行,才能返回目标 50 行。数据量越大,越往后翻越慢。
3.4 缺少索引或排序字段不稳定
ORDER BY的字段如果没有索引,每次分页查询都要做一次全表排序,耗时呈指数级上升。
4. 环境准备与项目基线
4.1 开发环境
本文代码基于 Qt 6 + Qt SQL 模块编写,但思路完全适用于 Qt 5.15 及更高版本。
| 依赖项 | 说明 |
|---|---|
| 操作系统 | Windows / Linux / macOS 均可 |
| Qt 版本 | Qt 5.15 或 Qt 6.x |
| Qt 模块 | Qt Core、Qt GUI、Qt Widgets、Qt SQL |
| 编译器 | MSVC / MinGW / GCC / Clang |
| 数据库文件 | SQLite 3.x,Qt 自带驱动 |
| 可视化调试工具 | 可选:DB Browser for SQLite |
新建数据库文件时,建议用QFileDialog::getSaveFileName让用户选择保存路径,避免把数据库文件藏在程序安装目录导致权限问题。
4.2 准备一个测试数据表
先建立表结构,并给排序字段加索引。这个表模拟“设备上报记录”场景,包含自增主键、设备编号、时间戳、数值和备注。
CREATE TABLE IF NOT EXISTS device_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, record_time DATETIME NOT NULL, value REAL NOT NULL, remark TEXT ); CREATE INDEX IF NOT EXISTS idx_device_record_time ON device_record(record_time); CREATE INDEX IF NOT EXISTS idx_device_record_device_id ON device_record(device_id);从 CRUD 和分页的角度看,主键id天然带索引,record_time是高频率排序字段,必须手动建索引。
5. 基础 CRUD 实现
5.1 数据库连接管理
建议封装一个数据库管理类,每个线程使用独立连接名,避免跨线程共享 QSqlDatabase 实例。
// DatabaseManager.h #pragma once #include <QSqlDatabase> #include <QString> class DatabaseManager { public: static DatabaseManager &instance(); bool open(const QString &filePath); QSqlDatabase connection(const QString &connectionName = QStringLiteral("qt_sqlite_connection")); private: DatabaseManager() = default; QString m_filePath; };// DatabaseManager.cpp #include "DatabaseManager.h" #include <QSqlQuery> #include <QVariant> #include <QDebug> DatabaseManager &DatabaseManager::instance() { static DatabaseManager manager; return manager; } bool DatabaseManager::open(const QString &filePath) { m_filePath = filePath; auto db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("qt_sqlite_connection")); db.setDatabaseName(filePath); if (!db.open()) { qWarning() << "Failed to open database:" << db.lastError().text(); return false; } QSqlQuery query(db); query.exec(QStringLiteral("PRAGMA journal_mode=WAL;")); query.exec(QStringLiteral("PRAGMA synchronous=NORMAL;")); query.exec(QStringLiteral("PRAGMA cache_size=-16000;")); return true; } QSqlDatabase DatabaseManager::connection(const QString &connectionName) { return QSqlDatabase::database(connectionName); }连接初始化时打开 WAL 模式,可以让读操作不阻塞写操作,对 UI 快速查询和后台写入同时进行很有帮助。
5.2 插入记录
用QSqlQuery::prepare绑定参数,避免字符串拼接带来的注入风险和 SQL 解析开销。
bool insertRecord(const QString &deviceId, const QDateTime &time, double value, const QString &remark) { auto db = DatabaseManager::instance().connection(); QSqlQuery query(db); query.prepare(QStringLiteral( "INSERT INTO device_record (device_id, record_time, value, remark) " "VALUES (?, ?, ?, ?)")); query.addBindValue(deviceId); query.addBindValue(time.toString(Qt::ISODate)); query.addBindValue(value); query.addBindValue(remark); return query.exec(); }5.3 更新记录
bool updateRecord(qint64 id, double newValue) { auto db = DatabaseManager::instance().connection(); QSqlQuery query(db); query.prepare(QStringLiteral( "UPDATE device_record SET value = ? WHERE id = ?")); query.addBindValue(newValue); query.addBindValue(id); return query.exec(); }5.4 删除记录
bool deleteRecord(qint64 id) { auto db = DatabaseManager::instance().connection(); QSqlQuery query(db); query.prepare(QStringLiteral("DELETE FROM device_record WHERE id = ?")); query.addBindValue(id); return query.exec(); }5.5 查询判断标准
每次 CRUD 操作后,都应检查query.lastError()和影响行数:
if (!query.exec()) { qWarning() << "SQL error:" << query.lastError().text(); return false; }在测试阶段,你可以用 DB Browser for SQLite 直接打开数据库文件,实时查看插入、更新和删除是否生效。
6. 游标分页实战(核心章节)
6.1 什么是游标分页
游标分页的关键思想是:不再告诉数据库“跳过多少行”,而是告诉数据库“上一次拿到哪一行”以及“往下取多少行”。因为查询条件里直接带上了排序字段的边界值,SQLite 可以通过索引快速定位起点,不需要扫描大量中间行。
常见的术语叫 Keyset Pagination,也叫 Seek Pagination,中文叫“游标分页”或“基于键集的分页”。
6.2 方案 A:基于主键的正向游标
适合按主键id升序往下翻的场景。
SELECT id, device_id, record_time, value, remark FROM device_record WHERE id > :lastId ORDER BY id ASC LIMIT :pageSize;对应 Qt 代码:
QVector<DeviceRecord> fetchNextPage(qint64 lastId, int pageSize) { QVector<DeviceRecord> records; auto db = DatabaseManager::instance().connection(); QSqlQuery query(db); query.prepare(QStringLiteral( "SELECT id, device_id, record_time, value, remark " "FROM device_record " "WHERE id > ? " "ORDER BY id ASC " "LIMIT ?")); query.addBindValue(lastId); query.addBindValue(pageSize); if (!query.exec()) { qWarning() << "fetch next page failed:" << query.lastError().text(); return records; } while (query.next()) { DeviceRecord rec; rec.id = query.value(0).toLongLong(); rec.deviceId = query.value(1).toString(); rec.recordTime = QDateTime::fromString(query.value(2).toString(), Qt::ISODate); rec.value = query.value(3).toDouble(); rec.remark = query.value(4).toString(); records.append(rec); } return records; }首页查询时lastId = 0,之后每次取records.last().id作为下一轮的lastId。这个方案在千万级数据下表现很好,因为id是主键,索引定位非常快。
6.3 方案 B:支持双向翻页的时间游标
按时间排序更符合业务习惯。用record_time作为排序键时,查询条件里同时带上record_time和id,确保排序稳定。因为record_time可能重复,光靠它无法唯一确定上一行。
SELECT id, device_id, record_time, value, remark FROM device_record WHERE (record_time < :lastTime) OR (record_time = :lastTime AND id < :lastId) ORDER BY record_time DESC, id DESC LIMIT :pageSize;这段 SQL 表示:取晚于当前页面最后一条记录的数据,同一时间点则按主键id继续往回取。这种方式也天然规避了 OFFSET 大偏移问题。
对应的 Qt 封装:
QVector<DeviceRecord> fetchPrevPage(const QDateTime &lastTime, qint64 lastId, int pageSize) { QVector<DeviceRecord> records; auto db = DatabaseManager::instance().connection(); QSqlQuery query(db); query.prepare(QStringLiteral( "SELECT id, device_id, record_time, value, remark " "FROM device_record " "WHERE (record_time < ?) " " OR (record_time = ? AND id < ?) " "ORDER BY record_time DESC, id DESC " "LIMIT ?")); query.addBindValue(lastTime.toString(Qt::ISODate)); query.addBindValue(lastTime.toString(Qt::ISODate)); query.addBindValue(lastId); query.addBindValue(pageSize); if (!query.exec()) { qWarning() << "fetch prev page failed:" << query.lastError().text(); return records; } while (query.next()) { DeviceRecord rec; rec.id = query.value(0).toLongLong(); rec.deviceId = query.value(1).toString(); rec.recordTime = QDateTime::fromString(query.value(2).toString(), Qt::ISODate); rec.value = query.value(3).toDouble(); rec.remark = query.value(4).toString(); records.append(rec); } return records; }注意,这里往上一页翻时,返回的结果顺序是“倒序”的,实际展示到 UI 后需要反转,保持用户视觉上的顺序一致。判断是否还有上一页,可以多取一条记录,比如LIMIT pageSize + 1,如果返回条数大于 pageSize,说明还有更多数据。
6.4 方案 C:滚动游标类
如果多张表、多个排序字段都要复用,可以封装一个轻量的 Cursor 类。
class Cursor { public: explicit Cursor(QString table, QString orderColumn, QString idColumn = QStringLiteral("id")); void setPageSize(int size) { m_pageSize = size; } QVector<QSqlRecord> nextPage(); QVector<QSqlRecord> prevPage(); bool hasMore() const { return m_hasMore; } private: QString m_table; QString m_orderColumn; QString m_idColumn; int m_pageSize = 100; QVariant m_lastValue; QVariant m_lastId; bool m_hasMore = true; };这个类内部记录m_lastValue和m_lastId,nextPage()和prevPage()生成不同的 SQL 语句,并把查询结果转换成QSqlRecord列表。这样业务层只需要关心数据模型,不需要关心 SQL 边界条件。
6.5 UI 层如何配合
分页方案确定后,UI 层建议使用 QAbstractTableModel 而不是 QTableWidget。QAbstractTableModel 可以做到“只加载可见区域附近的数据”,内存占用和刷新效率都远高于 QTableWidget。
以一个简单实现为例,模型内部维护当前已加载的记录列表:
class DeviceRecordModel : public QAbstractTableModel { Q_OBJECT public: explicit DeviceRecordModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; void appendRecords(const QVector<DeviceRecord> &records); void clear(); bool canFetchMore(const QModelIndex &parent) const override; void fetchMore(const QModelIndex &parent) override; private: QVector<DeviceRecord> m_records; Cursor m_cursor; bool m_fetching = false; };canFetchMore返回!m_fetching && m_cursor.hasMore(),当用户滚动到表格底部时,Qt 的视图会自动调用fetchMore。fetchMore内部开启异步查询或者直接同步查询,拿到结果后调用beginInsertRows和endInsertRows刷新模型。
这里有一个关键点:如果你的查询很快,比如几十毫秒,可以直接在fetchMore里同步执行;如果查询比较慢,建议丢到子线程,完成后通过信号槽回到主线程更新模型。
6.6 随机跳转页与游标分页的取舍
游标分页的核心优势是“向后翻页快”,但无法快速跳到第 800000 行。如果你的业务逻辑必须支持任意跳转页,可以做一个折中:
- 使用游标分页处理用户连续向下翻页。
- 在界面单独提供一个“跳转到指定行”的入口,跳转时先用
COUNT(*)或快速定位方式找到该行对应的排序值,再转换为游标参数。
这个方案比直接OFFSET 800000高效得多,又能兼容随机跳转需求。
7. 千万级数据写入与更新优化
7.1 用事务批量写入
逐行插入时,SQLite 每执行一次 INSERT 就是一次事务提交,磁盘同步开销非常大。千万级数据写入时,应手动开启事务。
bool batchInsertRecords(const QVector<DeviceRecord> &records) { auto db = DatabaseManager::instance().connection(); if (!db.transaction()) { qWarning() << "begin transaction failed"; return false; } QSqlQuery query(db); query.prepare(QStringLiteral( "INSERT INTO device_record (device_id, record_time, value, remark) " "VALUES (?, ?, ?, ?)")); for (const auto &rec : records) { query.addBindValue(rec.deviceId); query.addBindValue(rec.recordTime.toString(Qt::ISODate)); query.addBindValue(rec.value); query.addBindValue(rec.remark); if (!query.exec()) { qWarning() << "insert failed:" << query.lastError().text(); db.rollback(); return false; } } return db.commit(); }实际测试时建议每次事务控制在 5000 到 10000 条,事务太大反而会导致回滚耗时过长和内存压力增加。
7.2 复用同一条 QSqlQuery
循环插入时,同一条QSqlQuery反复调用prepare和addBindValue即可,不需要每次循环都新建对象。SQLite 的语句准备(prepare)成本虽低,但在千万级循环中也要尽量避免浪费。
7.3 更新大量数据时只用必要的列
UPDATE语句尽量只更新变化字段,不要每次把一个对象的所有字段都写回数据库。写入字段越多,日志和页复制成本越高。
7.4 定时清理或归档
千万级数据量的应用,通常会遇到“数据会持续增长”的问题。合理的做法是:
- 保留热数据在业务表。
- 历史数据按月或按年归档到独立表。
- 删除数据使用
DELETE FROM device_record WHERE id BETWEEN ? AND ?,并放在事务中执行。
8. 数据访问层接口与后续扩展
8.1 为什么建议封装接口层
在 Qt 项目中,把 SQL 直接写在窗口类里会让后续维护非常痛苦。建议按“界面 -> 控制器 -> 数据访问层”的方式拆分。数据访问层对外提供干净的接口:
class DeviceRecordRepository { public: QVector<DeviceRecord> fetchPage(qint64 lastId, int pageSize); bool insert(const DeviceRecord &record); bool batchInsert(const QVector<DeviceRecord> &records); bool updateValue(qint64 id, double value); bool remove(qint64 id); qint64 count(); };这样做的收益是:
- UI 代码只关心数据和信号,不关心 SQL。
- 后续如果换成 MySQL、PostgreSQL,只需要替换 Repository 实现。
- 数据层可以单独做单元测试。
8.2 后续扩展为本地服务接口
如果桌面应用需要给其他端提供数据查询能力,可以在数据访问层之上再封装一个服务层。你可以使用 Qt 自带的QTcpServer或QHttpServer提供本地 HTTP 接口。
此时要注意:
- 服务默认只绑定
127.0.0.1,不要直接暴露到公网。 - 接口需要做鉴权和访问频率限制,防止其他本机进程非法读取数据。
- 所有 SQL 参数必须绑定,不要拼接字符串。
典型调用示例(伪代码):
httpServer.route("/api/records", [](const QHttpServerRequest &request) { QString lastId = request.query().queryItemValue("lastId"); QString pageSize = request.query().queryItemValue("pageSize"); // 调用 Repository 查询 // 返回 JSON });这个模式可以让桌面应用的数据能力被脚本、小程序或另一台电脑上的程序复用,但边界是必须确认使用方有合法权限访问这些数据。
9. 资源占用与性能观察
9.1 如何观察 SQLite 查询耗时
最简单的办法是使用QElapsedTimer:
QElapsedTimer timer; timer.start(); auto records = repository.fetchPage(m_lastId, 100); qInfo() << "fetch page cost:" << timer.elapsed() << "ms";分页查询应控制在使用户无感知的范围内,建议单页耗时小于 100ms。如果发现明显变慢,优先检查排序字段是否命中索引。
9.2 如何观察内存占用
Qt Creator 自带内存分析工具,也可以在任务管理器或系统资源监视器观察进程内存。QTableWidget 一旦加载大量 Item,内存会明显上升,所以建议运行项目后用测试数据点击“加载全部”和“游标分页”两种模式对比,这样能直观看到差距。
9.3 影响性能的关键因素
| 因素 | 影响程度 | 优化方向 |
|---|---|---|
| 排序字段是否有索引 | 高 | 对所有 ORDER BY 字段建索引 |
| 一次返回行数 | 中 | 页面大小控制在 50 到 200 行之间 |
| WAL 模式 | 中 | 开启PRAGMA journal_mode=WAL提升并发读 |
| 同步频率 | 中 | 批量写入时使用PRAGMA synchronous=NORMAL |
| UI 控件类型 | 高 | 用 QAbstractTableModel 代替 QTableWidget |
| 是否在主线程执行大查询 | 高 | 耗时查询放到子线程 |
9.4 降低资源占用的策略
- 单页数据量控制在 100 行以内。
- 模型只缓存已加载页的数据,窗口销毁或切换表时调用
clear()释放。 - 禁止
SELECT *,只查询需要的列。 - 图片或 BLOB 字段不要存在业务主表里,单独建关联表存储文件路径。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 滚动表格卡顿 | 主线程执行了全量查询 | 用 QElapsedTimer 统计查询耗时 | 改为游标分页,查询放入子线程或模型 fetchMore |
| 翻页越来越慢 | 使用了 OFFSET 大偏移分页 | 查看 SQL 日志 | 改为 Keyset 分页 |
| 插入 10 万条数据耗时过长 | 每一条都自动提交事务 | 检查事务开启情况 | 手动开启事务批量提交 |
| 查询结果排序不稳定 | ORDER BY 字段有重复值 | 观察连续翻页是否出现重复或遗漏 | 排序字段加上主键 id 作为第二排序条件 |
| 数据库文件被锁定 | 多线程共用同一个连接 | 查看错误日志 “database is locked” | 每个线程使用独立连接名 |
| 界面显示数据后修改不生效 | 模型没有触发 dataChanged 信号 | 检查 model 是否调用 emit dataChanged | 更新后手动调用 dataChanged |
| 删除大量数据后文件体积不变 | SQLite 默认不回收空闲页 | 查看文件大小 | 执行VACUUM或定期重构数据库 |
| 程序退出后数据丢失 | 未执行事务提交或未关闭连接 | 查看代码中是否调用 commit | 确保事务提交后再退出 |
10.1 多线程访问 SQLite 的注意事项
Qt 中每个线程创建数据库连接时,连接名必须不同。
void workerThread() { QString connectionName = QStringLiteral("worker_%1").arg(reinterpret_cast<quintptr>(QThread::currentThreadId())); QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); db.setDatabaseName(filePath); db.open(); // 查询逻辑 db.close(); QSqlDatabase::removeDatabase(connectionName); }只建议一个线程负责写操作,其他线程读。写线程和读线程之间的锁冲突由 SQLite 自己处理,但在高并发写入时仍可能出现SQLITE_BUSY。可以设置 busy timeout:
query.exec("PRAGMA busy_timeout = 5000;");10.2 游标分页边界值处理
当lastId为 0 时,WHERE id > 0相当于取第一页。当数据库为空或已没有更多数据时,返回结果小于 pageSize,此时应把hasMore置为 false。
可以在查询时多取一条记录来判断是否还有下一页:
int queryLimit = pageSize + 1; // 执行查询 bool hasMore = records.size() > pageSize; if (hasMore) { records.removeLast(); }这种方式简单可靠,避免每次额外执行一次 COUNT 查询。
11. 最佳实践与使用建议
11.1 第一次先小参数测试
把一个 10 行数据的模型接入游标分页,确认翻页方向、顺序、边界正常后,再灌入百万级测试数据。不要在百万级数据上直接调试分页逻辑,会浪费大量时间。
11.2 保留一套最小可运行配置
项目里保留一个独立的TestDatabase测试类,数据库文件可以放在临时目录。每次改动数据层代码后,先跑测试类,确认 CRUD 和分页正常,再做界面联调。
11.3 合理划分目录
database/:数据库连接和脚本。repository/:数据访问接口。model/:Qt Model 层。ui/:界面层。data/test.sqlite:测试数据库。output/:导出结果目录。
模型文件、输入素材、输出结果分目录管理,可以避免后续维护时到处找文件。
11.4 批量任务要加日志和失败重试
如果你的应用支持批量导入、批量导出,建议:
- 每处理一条记录记录日志。
- 失败记录单独保存到
failed_records表。 - 支持断点续跑,即批量导入中间失败后,下次从失败记录继续。
11.5 涉及数据库文件分发时的安全边界
- 数据库文件不要放在程序安装目录,推荐放在用户数据目录,例如
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)。 - 如果包含敏感数据,使用 SQLCipher 或对关键字段做加密。
- 对用户数据做备份机制,建议在应用启动时自动备份最近一个版本的数据库。
12. 总结与下一步
这篇文章的核心结论是:Qt + SQLite 完全可以承载千万级数据的 CRUD,前提是别再用“一次查询全量数据 + QTableWidget 全量加载”的老路子。游标分页可以让翻页操作稳定在极短耗时的范围内,配合 QAbstractTableModel 的增行机制,UI 流畅度会有非常明显的改善。
可以先从下面几步验证:
- 建一张百万行测试表,确认索引和 WAL 模式开启。
- 在项目里实现
fetchNextPage,用日志打印每页查询耗时。 - 把表头控件换为 QAbstractTableModel 子类,接入
canFetchMore和fetchMore。 - 测试往下翻 100 页、200 页,观察内存是否稳定。
最容易踩的坑有三个:排序字段忘加索引、分页时只用单字段排序、把 QSqlDatabase 连接跨线程共享。这些在代码评审时可以重点检查。
后续可以继续扩展的方向:把 Repository 层升级为可插拔实现,支持 MySQL 或 PostgreSQL;在数据访问层之上开放本地 HTTP 服务接口;为数据库文件增加加密和自动备份;把游标分页封装成通用 Qt 控件,供多个项目复用。
建议收藏备用,下次遇到 Qt 大数据量卡顿可以直接翻到“游标分页实战”这一节对照实现。