资讯动态

Qt + SQLite 千万级数据 CRUD:游标分页与后台线程实战

发布时间:2026/9/2 1:50:03 来源:尧图企业网站定制
Qt 配 SQLite 做千万级数据的 CRUD真正需要解决的从来不是“SQLite 能不能撑住”而是查询方式、索引设计、线程边界和界面刷新策略。很多人一开始习惯用SELECT *把整张表装进 QList再丢给 QTableView 渲染数据到几十万行时界面已经拖不动到千万行时连打开窗口都要等好几秒。换成游标分页之后每次只取当前需要的那一小批数据配合后台线程和事务写入SQLite 在本地千万行场景下完全可以做到界面流畅、操作不卡顿。下面按实际落地顺序拆一遍先解决认知问题再给出查询和索引方案然后讲 Qt 层怎么把查询丢到后台、怎么增量刷新表格最后补上写入、UPSERT、锁库和排查路径。代码以 Qt Widgets C 为例思路同样适用于 QML 项目。1. SQLite 千万行数据的真实瓶颈到底在哪1.1 慢的不是 SQLite是“一次全取”和“UI 线程执行”先给结论SQLite 本身处理千万行级别数据是没问题的。它把整个数据库放在本地文件里查询走 B 树索引单机访问延迟很低。真正让项目卡死的通常是下面三种写法一次性SELECT *把全表读进内存。查询直接写在 UI 线程里界面等 SQL 返回。分页用LIMIT ? OFFSET ?翻页越深扫描越多。这三种问题叠加在一起体感就是数据量上来之后窗口打开慢、滚动卡、按按钮没反应甚至直接白屏几秒钟。SQLite 不是为高并发服务设计的但它的定位就是“单机本地数据库”。千万行的表只要索引正确、每次只取少量行、写入走事务表现完全可以接受。问题从来不是“SQLite 扛不住”而是“我们一直用不适合大数据量的方式去访问它”。1.2 数据量大和界面卡顿要分开看“千万级数据流畅 CRUD”这句话其实包含两个独立指标查询性能单次查询返回结果需要多久翻页时是几十毫秒还是几秒。界面响应用户在界面上点击、滚动、输入时界面有没有冻结。查询慢可以通过索引、分页、减少返回字段来优化。界面卡是因为耗时操作执行在 UI 线程上阻塞了 Qt 的事件循环。前者是 SQL 问题后者是线程问题。所以落地时我建议先分开排查先确定 SQL 本身快不快再把耗时的 SQL 挪到后台线程。很多新手一上来就引入 QThread结果查询还是在 UI 线程跑的当然还是卡。正确顺序是先把单条查询优化到快再把这条查询放到后台最后再考虑并发和 Model 刷新。另外一个常见的误解是QSqlTableModel 能直接装大表。实际上 QSqlTableModel 在数据量大时非常吃力因为它默认会拉取很多行刷新策略也不适合千万级。更稳的做法是写一个自定义 QAbstractTableModel内存里只保留当前已经加载的行缺什么再按游标补什么。2. 游标分页的核心不数行直接定位2.1 OFFSET 分页为什么越翻越慢传统分页写法是SELECT id, name, amount FROM t_record ORDER BY id LIMIT 200 OFFSET 100000;这条 SQL 的意思是先按 id 排序再从头数出前 100200 行最后丢掉前 100000 行只返回最后 200 行。问题就出在“先数出来”这一步。OFFSET 越大SQLite 需要扫描和丢弃的行就越多翻到第 500000 行时它可能要扫描几十万行才能拿到你要的那一批。对千万级数据来说OFFSET 分页是典型的“前期没问题后期越来越慢”。前几页可能毫秒级返回翻到一半就开始卡越往后越离谱。2.2 游标分页的 SQL 写法游标分页不关心“当前是第几页”它只记住“上一次拿到哪一行”然后从那一行之后继续取。核心 SQL 是-- 第一页没有游标从最小位置开始 SELECT id, name, amount FROM t_record WHERE id 0 ORDER BY id ASC LIMIT 200; -- 下一页把上一页最后一条记录的 id 传进来 SELECT id, name, amount FROM t_record WHERE id 137890 ORDER BY id ASC LIMIT 200;因为id是主键SQLite 可以直接用主键索引定位到137890然后向后扫 200 行结束。这个查询的耗时和当前已经翻了多少页基本无关只和数据总量、索引命中率、返回字段数量有关。游标分页也叫 keyset pagination适合“加载更多”或“滚动到底部继续加载”的场景。它不适合传统的那种“第 1 页、第 2 页、跳页”交互。如果你的产品必须支持点击页码跳转那你需要保留 OFFSET或者另外记录页码到主键的映射表。如果是桌面工具、后台管理列表、日志查看器这类“往下刷”的场景游标分页是更合适的选择。游标分页还有一个额外好处数据边看边插入时它不容易重复或漏掉。OFFSET 分页如果有人在翻页过程中新增了记录页面里的数据会整体后移容易看到重复行。游标分页只按 id 位置向后推进新插入的更大 id 会出现在后续加载里已加载区域不受影响。对比项OFFSET 分页游标分页翻到很深的页OFFSET 越大越慢耗时基本稳定支持跳页支持不支持只能顺序加载翻页期间新增数据可能重复/偏移相对稳定前端交互页码、跳页加载更多、滚动加载适合场景数据量小、交互传统千万级、单向浏览2.3 索引怎么建才算对游标分页快不快很大程度上取决于索引。主键本身就是索引所以单纯的WHERE id ? ORDER BY id LIMIT ?不需要额外建索引。但如果你的查询带了过滤条件例如“只看某个分类下的记录”就必须建复合索引CREATE INDEX idx_t_record_cat_id ON t_record(category, id);查询写成SELECT id, name, amount FROM t_record WHERE category :cat AND id :lastId ORDER BY id ASC LIMIT :limit;复合索引(category, id)可以让 SQLite 在同一个索引里完成“按分类过滤”和“按 id 定位”两步不需要回表排序。如果只给category建单列索引虽然能过滤分类但排序还是要额外处理翻页到深处仍可能变慢。判断索引是否生效不要靠猜直接看查询计划EXPLAIN QUERY PLAN SELECT id, name, amount FROM t_record WHERE category A AND id 100000 ORDER BY id ASC LIMIT 200;理想结果是出现SEARCH t_record USING INDEX idx_t_record_cat_id或USING INTEGER PRIMARY KEY。如果看到SCAN t_record或USE TEMP B-TREE FOR ORDER BY说明索引没有覆盖到查询条件或者排序字段没进索引需要重新设计。这里有个细节复合索引的字段顺序不能乱。(category, id)适用于“先按分类过滤再按 id 排序”的查询如果你经常单独按 id 翻页不按分类过滤那复合索引反而帮不上WHERE id ?这种独立条件。要根据最常用的查询场景来建索引不是索引越多越好。3. Qt 实战后台查询 Model 增量填充3.1 每个线程独立连接并打开 WALQt 的 QSqlDatabase 对象不是线程安全的同一个连接不能在多个线程里同时使用。后台线程里跑 SQL 时必须给那个线程创建独立的连接。我一般用一个工具函数按线程 id 生成连接名保证每条线程只创建一次连接QSqlDatabase getWorkerDb() { const QString name QStringLiteral(conn_%1) .arg((quintptr)QThread::currentThreadId()); if (QSqlDatabase::contains(name)) { return QSqlDatabase::database(name); } QSqlDatabase db QSqlDatabase::addDatabase(QStringLiteral(QSQLITE), name); db.setDatabaseName(QStringLiteral(records.db)); if (!db.open()) { qWarning() open db failed: db.lastError().text(); return QSqlDatabase(); } db.exec(QStringLiteral(PRAGMA journal_modeWAL;)); db.exec(QStringLiteral(PRAGMA synchronousNORMAL;)); db.exec(QStringLiteral(PRAGMA busy_timeout3000;)); return db; }WAL 模式的好处是读写不互相阻塞读查询进行时写事务可以同时进行写事务进行时读查询也不会锁整个库。对“后台翻页加载 主线程界面刷新”的场景非常合适。busy_timeout设置成 3000 毫秒意思是遇到锁竞争时等待最多 3 秒而不是立刻报database is locked。本地单用户程序这个值通常够用。3.2 QtConcurrent 跑查询信号回来再更新 Model查询放在后台线程代码可以这样写#include QtConcurrent struct Record { qint64 id 0; QString name; double amount 0.0; }; QListRecord loadPage(qint64 lastId, int pageSize) { QSqlDatabase db getWorkerDb(); if (!db.isValid()) { return {}; } QSqlQuery q(db); q.prepare(QStringLiteral( SELECT id, name, amount FROM t_record WHERE id :lastId ORDER BY id ASC LIMIT :limit)); q.bindValue(QStringLiteral(:lastId), lastId); q.bindValue(QStringLiteral(:limit), pageSize); if (!q.exec()) { qWarning() q.lastError().text(); return {}; } QListRecord rows; while (q.next()) { Record r; r.id q.value(0).toLongLong(); r.name q.value(1).toString(); r.amount q.value(2).toDouble(); rows.append(r); } return rows; }调用侧用 QFutureWatcher 接收结果// 假设这是某个按钮“加载更多”的槽函数 void MainWindow::onLoadMore() { if (m_watcher.isRunning()) { return; // 上一次查询还没结束先忽略 } qint64 lastId m_model-lastId(); const int pageSize 200; QFutureQListRecord future QtConcurrent::run([this, lastId, pageSize]() { return loadPage(lastId, pageSize); }); m_watcher.setFuture(future); } // QFutureWatcher::finished 信号里更新 Model void MainWindow::onPageLoaded() { QListRecord rows m_watcher.result(); m_model-appendRows(rows); }在 .pro 文件里要记得加QT core gui sql concurrent另外建议在界面放一个“正在加载”状态位避免用户连续点击造成重复请求。更稳妥的做法是维护一个m_loading标志进入查询时置 true收到结果后再置 false。3.3 自定义 Model 用 appendRows 代替 resetQTableView 默认对beginResetModel()/endResetModel()的响应是整体重建视图滚动条会跳回顶部选中态会丢失。加载新数据时如果每次都 reset用户翻页会非常别扭。正确做法是写一个 QAbstractTableModel 子类加载新页时用beginInsertRows()/endInsertRows()增量插入class RecordModel : public QAbstractTableModel { Q_OBJECT public: int rowCount(const QModelIndex parent QModelIndex()) const override { return parent.isValid() ? 0 : m_rows.size(); } int columnCount(const QModelIndex parent QModelIndex()) const override { return parent.isValid() ? 0 : 3; } QVariant data(const QModelIndex idx, int role) const override { if (!idx.isValid() || role ! Qt::DisplayRole) { return {}; } const Record r m_rows.at(idx.row()); switch (idx.column()) { case 0: return r.id; case 1: return r.name; case 2: return r.amount; default: return {}; } } void appendRows(const QListRecord rows) { if (rows.isEmpty()) { m_hasMore false; return; } const int first m_rows.size(); beginInsertRows(QModelIndex(), first, first rows.size() - 1); m_rows rows; m_lastId rows.last().id; endInsertRows(); } qint64 lastId() const { return m_lastId; } bool hasMore() const { return m_hasMore; } private: QListRecord m_rows; qint64 m_lastId 0; bool m_hasMore true; };内存里始终只保留已加载的行。比如每页 200 行用户看到第 5000 行时Model 里也只有 5000 行而不是把千万行全部塞进内存。表格只渲染可视区域QTableView 本身有视图级复用机制数据量不大时滚动很顺畅。注意lastId不能取当前页最后一行在界面上的显示值而要取 SQL 返回顺序中的最后一行 id。如果用户手动排序了列表游标值会乱。游标分页要求查询结果始终按同一个字段排序不要在 Model 层再自由排序。4. 千万级写入事务、预编译和 UPSERT4.1 批量插入必须进事务SQLite 默认每条 SQL 自动提交一次事务。逐条插入 10 万行相当于要写 10 万次磁盘事务效率非常低。批量插入正确姿势是先开启事务循环执行预编译插入语句最后一次性提交void batchInsert(QSqlDatabase db, const QListRecord records) { if (!records.isEmpty() !db.transaction()) { qWarning() begin transaction failed; return; } QSqlQuery q(db); q.prepare(QStringLiteral( INSERT INTO t_record(name, amount) VALUES(:name, :amount))); for (const Record r : records) { q.bindValue(QStringLiteral(:name), r.name); q.bindValue(QStringLiteral(:amount), r.amount); if (!q.exec()) { qWarning() insert failed: q.lastError().text(); db.rollback(); return; } } if (!db.commit()) { qWarning() commit failed: db.lastError().text(); db.rollback(); } }事务建议分批提交不要一个事务塞 100 万行。一方面单条失败时要回滚的数据太多另一方面长事务会占用锁影响其他查询。我一般建议每批 500 到 2000 行提交一次具体根据数据字段大小和机器磁盘性能调整。可以先拿 1 万行测观察耗时和锁表现。还有一点总量很大时插入执行速度会随着表膨胀略有下降这是 B 树结构本身的特性。不要因此在代码里乱加索引索引越多插入越慢。普通业务查询需要的索引保持在必要数量即可数据导入完成后可以再执行一次ANALYZE让查询计划更稳定。4.2 UPSERT存在更新不存在新增热词里常看到“sqlite 存在就更新不存在就新增”这其实就是 UPSERT。SQLite 从 3.24.0 版本开始支持标准写法INSERT INTO t_record(id, name, amount) VALUES(:id, :name, :amount) ON CONFLICT(id) DO UPDATE SET name excluded.name, amount excluded.amount;-- id 冲突时更新内容没有冲突时插入 INSERT INTO t_record(id, name, amount) VALUES(1, 新名称, 99.5) ON CONFLICT(id) DO UPDATE SET name excluded.name, amount excluded.amount;在 Qt 里把这条 SQL 放进 QSqlQuery 的 prepared statement 里绑定参数即可。注意它和INSERT OR REPLACE不同INSERT OR REPLACE本质是“删除旧行再插入新行”会触发行 id 变化并且没有给默认值的字段可能被重置ON CONFLICT DO UPDATE是真正的更新语义更适合业务数据。使用前提是 SQLite 版本支持。可以在程序里执行SELECT sqlite_version();确认Qt 自带的 SQLite 版本比较新一般没问题如果是系统旧库先验证再上线。4.3 SQLite 并发写边界单写多读SQLite 的写入是串行的同一时刻只允许一个写事务。WAL 模式下读和写可以并行但“两个写”仍然不能同时进行。对本地桌面程序来说这通常不是问题因为大多数场景只有一个线程负责写。但要注意一个常见的坑多条线程都往同一个数据库文件写即使加了 busy_timeout也容易因为互相等待导致性能下降甚至偶发database is locked。更稳的设计是读操作可以多线程每个线程用独立连接。写操作收敛到同一个线程或者用一个写队列串行执行。在 Qt 里可以简单做一个单线程写队列使用QtConcurrent::run配合QThreadPool的串行执行器或者直接用一个 QThread 加槽函数接收写请求。这样读并发高写不打架数据库文件也安全。如果程序要长时间运行写入后可以定期执行PRAGMA wal_checkpoint(TRUNCATE);或者VACUUM;来回收 WAL 文件大小。注意VACUUM会重建整个数据库文件数据量大时耗时较久不要在用户操作高峰期自动执行。5. 最容易踩的坑和排查顺序5.1 查询计划先检查任何“翻页慢”的问题第一件事不是调并发而是看 SQL 的查询计划。先打开命令行或DB Browser for SQLite把目标 SQL 用EXPLAIN QUERY PLAN跑一遍。EXPLAIN QUERY PLAN SELECT id, name, amount FROM t_record WHERE category A AND id 100000 ORDER BY id ASC LIMIT 200;看到SCAN说明全表扫描需要补索引。看到USE TEMP B-TREE FOR ORDER BY说明索引没有覆盖排序字段。这两类问题在数据量小时很难察觉到千万级就原形毕露。还有一种很隐蔽的情况查询里对字段做了函数处理索引就失效了。例如WHERE upper(name) ABC或者WHERE strftime(%Y, created_at) 2024这类写法会让 SQLite 无法使用普通索引。要么改成范围查询要么存一个冗余字段。5.2 常见卡顿、锁库、内存问题排查表下面这张表是我在排查 Qt SQLite 项目时最常用的检查顺序现象优先检查常见原因打开界面卡几秒查询是否在 UI 线程SQL 直接写在窗口构造函数里内存持续上涨是否一次SELECT *全表加载Model 里塞了整张表翻页越来越慢是否用LIMIT ? OFFSET ?没有游标越翻越深查询计划异常索引是否覆盖 WHERE 和 ORDER BY复合索引字段顺序不对写入大量数据很慢是否逐条自动提交没走事务出现database is locked并发写是不是超过一个线程缺少 busy_timeout写未串行界面刷新后滚动跳位Model 是否用了 reset应改用 beginInsertRows同一连接跨线程使用是否有全局 QSqlDatabase 在后台跑连接未按线程隔离排查顺序永远是从现象到输入再到环境和参数。先看报什么错、卡在哪一步再看 SQL、表结构、索引然后看连接、事务、并发最后才考虑是不是工具版本的问题。不要一上来就改一堆参数容易把问题搞复杂。5.3 删除历史数据时怎么控制事务大小千万级数据免不了要清理历史记录。直接DELETE FROM t_record WHERE created_at :cutoff;可能在一个超大事务里锁住整个库运行时间也很长。可以按 id 分批删除每批 500 条DELETE FROM t_record WHERE id IN ( SELECT id FROM t_record WHERE created_at :cutoff ORDER BY id LIMIT 500 );或者更直接地维护一个“删除游标”DELETE FROM t_record WHERE id :maxId AND created_at :cutoff ORDER BY id LIMIT 500;每批执行后主动提交再循环执行下一批直到删除条数为 0。这样每次事务很小不会长时间占锁也不容易把 WAL 文件撑大。删除场景下如果created_at和id经常组合过滤可以考虑(created_at, id)的复合索引。如果删除条件只用created_at那就单独建created_at索引。索引怎么建还是以实际删除 SQL 的查询计划为准。6. 落地建议先单页再批量最后盯验收指标6.1 最小可用版本的验证顺序不要一上来就直接写完整项目。我建议按这个顺序推进建表手动插入 10 万行测试数据。用一条游标分页 SQL 加载第一页确认结果正确。循环传入 lastId验证能把所有数据完整翻完不重复、不遗漏。把这条查询放到后台线程确认界面不卡。接入自定义 Model用 appendRows 刷新表格。测试批量插入确认事务和 prepared statement 正常。逐步把数据量提升到 100 万、500 万、1000 万记录每页耗时和整体稳定性。每一步都确认之后再进入业务功能开发。千万级数据项目一旦表结构、查询方式错了后期改起来成本很高。6.2 流畅度的验收标准怎么定“流畅”不能只靠感觉要定指标。可以量化成几条打开窗口后首屏数据在 1 秒内显示出来。点击“加载更多”界面立刻有反馈查询结果到达后表格增量追加。拖动滚动条时界面不出现长时阻塞。批量插入 100 万条数据能够在可接受时间内完成且期间界面仍可操作。连续翻 100 页单页查询耗时基本稳定没有明显递增。具体数字要根据机器配置、字段数量、数据库文件所在磁盘类型调整。机械硬盘和固态硬盘差异很大千万行全表扫描在两种环境下可以差出好几倍。不要拿别人的测试数字当绝对标准自己在本机跑一遍记录基线最重要。如果首屏加载都要 1 秒以上先别急着加并发。优先检查是否返回了不必要的字段、是否缺索引、是否在 UI 线程执行。多数首屏慢不是量大的问题是查询设计和索引问题。6.3 什么时候才需要考虑换更重的数据库SQLite 适合单机、单用户、以读为主或单写多读的桌面应用。如果你的业务出现下面这些信号再考虑切换到 PostgreSQL、MySQL 之类的数据库多个进程、多台机器同时写同一个库。数据量超过千万甚至上亿且持续高频写入。需要复杂的 JOIN、窗口函数、全文检索等高级特性。需要网络访问、权限管理、备份恢复等服务端能力。但在这个临界点之前SQLite 加游标分页明显更省事。它不带独立服务不需要安装数据库软件文件拷贝就能迁移配合 Qt 直接内嵌使用部署成本极低。对大多数桌面工具、本地管理系统、数据分析客户端来说这个组合已经足够。我个人更建议先把单任务跑稳再考虑批量和并发。先用 10 万行数据把游标分页、索引、后台查询、Model 增量刷新这四件事跑通再逐步加量。千万级数据真正落地时最该盯住的不是功能列表而是查询计划、事务边界、连接隔离和失败重试。这些基础打好了UI 卡顿告别起来并不难。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价