Qt+SQLite千万级数据游标分页实战与性能优化 📅 发布时间:2026/9/2 16:55:21 👁 浏览次数: 这次我们直接看一个 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 6QSqlDatabase QSqlQuery QAbstractTableModel数据量目标单表千万级核心方案游标分页Keyset Pagination替代 LIMIT OFFSETUI 优化分页加载、后台线程查询、只渲染当前页数据写入优化事务 预编译语句批量写入更新策略UPSERTON 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 换成 Qt5CMake 用法一致。工程目录建议按 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; }; QVectorMeasureRecord queryPage( qlonglong cursorId, int pageSize, QSqlDatabase db, QString *errorText nullptr) { QVectorMeasureRecord 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 也做了