Qt与SQLite千万级数据流畅展示:游标分页方案详解

发布时间:2026/9/1 6:06:20
Qt与SQLite千万级数据流畅展示:游标分页方案详解 很多人第一次把 Qt 和 SQLite 放在一起都会在某个瞬间经历一种奇特的挫败单机程序数据量也不算大到离谱就三四百万行界面上一个 QTableView 要展示结果程序却像吞了秤砣一样拖一下滚动条CPU 飙满窗口直接进入“未响应”。你以为是 SQLite 不靠谱转头去查数据库版本和编译参数你以为是 Qt 的 Model/View 架构有问题把 QSqlTableModel 换成手写 Model折腾一圈可能问题根本不是某一个组件而是整个取数策略错了——你仍然想一次性把千万级结果集交给 UI。这篇文章要说的就是一套在 Qt SQLite 下处理千万级数据的思路游标分页。它不只是把 LIMIT/OFFSET 换一种写法而是让查询和界面都只关心当前需要的一小片数据。我的主判断很直接千万级数据要流畅 CRUD真正难点从来不是“SQLite 能不能存下”而是“一次取多少、取到哪里停、界面按什么节奏拿新数据”。游标分页恰好把这三点同时解决了。1. 先认清卡顿根源不是数据慢是取数策略错了1.1 一次加载 60 万行数据的三个成本我做过的项目里有这样一个很典型的功能一个设备历史数据窗口需要把某张表的数据全部列出来。最初实现很简单SELECT * FROM record WHERE device_id ?然后把 QSqlQuery 遍历一次塞进 QVector再一次性beginInsertRows交给 QTableView。数据量一开始只有几万行体感没问题。后来设备运行了几个月单设备数据到了六十万行窗口打开需要 4、5 秒而且打开之后滚动依然卡。更夸张的是在数据量接近千万级的时候程序会在查询完成前先弹出“未响应”。我们通常会下意识归因为“SQLite 慢”。但拆开来看一次查询从点击到界面出现其实经历了三笔成本数据库执行成本SQL 是否能走索引、是否全表扫描、排序是否用了临时表。结果集构造成本SQLite 返回的数据要转成 Qt 的 QVariant、QString、QDateTime几十万行就是几十万个对象构造这一步在 UI 线程里做极其昂贵。界面呈现成本QTableView 收到几十万行插入后要计算布局、创建 QModelIndex、调用 item delegate 绘制。即使 Model 层已经把数据存好一次性插入几十万行也足够让界面卡死。很多人的项目卡在第二步和第三步却一直去优化第一步。结果就是把 SQL 从全表扫描改成索引扫描后卡顿只是从“看不了”变成“勉强能开”只要数据继续增长迟早还是崩。1.2 为什么 QSqlTableModel 直接拿来扛千万级会失控QSqlTableModel 是 Qt 提供的最省事的数据库表格模型绑定一个表名调select()一个可编辑的表格就出来了。但它更适合小数据量的管理页面比如配置项、用户列表、订单录入这种几千行的场景。一旦数据量到几十万、上千万问题就出现了QSqlTableModel 的定位是“把一张表映射成可编辑模型”它不会自动帮你做游标分页。如果你用SELECT *之后让它加载所有行模型层会先把整个结果集承担下来QTableView 又会默认这些行全部可访问于是滚动条的范围直接变成一千万行。Model 要维护的数据缓存、View 要处理的绘制请求、滚动时要触发的数据获取全部按一千万行的规模放大。不是说 QSqlTableModel 不能重载而是它的设计假设和千万级列表不匹配。我们更需要的是“滚动到什么地方才去数据库拿什么范围的数据”而不是“开屏就是全部数据”。2. 普通分页在千万级数据上的真实短板2.1 LIMIT/OFFSET 总是在为“跳过”付出代价先看最传统的分页SELECT id, name, created_at FROM record ORDER BY id LIMIT 100 OFFSET 900000;这套写法在数据量小的时候很好用因为数据库不需要跳过多少行。但当用户翻到第 9000 页时OFFSET已经变成 900000SQLite 必须扫描并丢弃前 90 万行才返回后面的 100 行。这里很容易产生一个误解“既然有主键索引数据库直接从索引里跳到第 900000 行不就行了”实际上LIMIT/OFFSET 的语义是“先找到所有符合条件的行按 ORDER BY 排序然后丢掉前 OFFSET 行返回后续 LIMIT 行”。如果你的排序键是主键SQLite 可以顺着 B 树往前扫但依然要遍历并计数跳过那么多行如果排序键还不是索引列数据库更可能先生成完整的排序结果再做 OFFSET代价会进一步放大。所以你会发现一个规律OFFSET越大查询越慢。这不是参数没调好而是这个方案本身的复杂度就会随着页数增长。千万级数据下用户翻到中后段单页查询花几百毫秒甚至几秒都很正常。2.2 为什么“把 OFFSET 改小”解决不了问题有人会把分页改成每页 20 条这样在相同数据量下翻到同样位置需要的 OFFSET 会稍微小一点。但这只是把后面的卡顿延后没有消除问题。还有人会加一个“只允许查看前 1000 页”的限制这确实是一种产品层的缓解手段但不是数据层方案。核心问题在于OFFSET 分页需要知道“目标页之前有多少行”而游标分页不需要知道。当数据量到千万级让数据库为了找一个页码反复扫描中间行本身就是一种巨大的浪费。真正的做法是改变查询条件不告诉数据库“跳过多少行”而是告诉它“从哪一行开始往后取”。这一改变就是游标分页。3. 游标分页沿着有序键向前取数3.1 keyset pagination 的核心思想游标分页也叫 keyset pagination核心思路只有一句记住上一批最后一条记录的位置下一批从那个位置之后继续取。它很像看书夹书签。第一次读到第 100 页把书签夹在第 100 页下次继续读时直接翻到书签位置再往后读 100 页。中间那些页早就翻过去了不需要重新数。对应到 SQL 里上一页的最后一条记录的id就是书签。下一页查询带上这个id用WHERE id :last_id作为过滤条件数据库就能借助主键索引直接定位跳过之前的所有数据。这种做法的最大优势是翻页成本近似恒定和数据总量无关。第一页需要 5 毫秒翻到第 10000 页可能也是 5 到 10 毫秒。代价也很明显它不支持随机跳页因为数据库不知道某个页码对应哪个 id。这一点我后面会专门讲。3.2 单字段游标和复合字段的 SQL 写法最简单的游标分页用主键排序SELECT id, name, created_at FROM record WHERE id :last_id ORDER BY id ASC LIMIT :page_size;第一页时last_id可以传 0假设 id 从正数开始。查询结果取完后把最后一条记录的id保存下来作为下一页的last_id。这里只需要保证id是唯一、递增、创建后不变就行SQLite 的 INTEGER PRIMARY KEY 天然满足。如果你想按业务字段排序比如按created_at排序而同一秒内可能有多条记录就需要复合游标。SQLite 里最稳妥的写法是SELECT id, name, created_at FROM record WHERE created_at :last_created_at OR (created_at :last_created_at AND id :last_id) ORDER BY created_at ASC, id ASC LIMIT :page_size;这个方案的逻辑是把created_at作为主排序键把id作为“打破平局”的次要键。只有当created_at相等时才用id比较确保数据顺序严格唯一。执行前最好用EXPLAIN QUERY PLAN确认索引被充分利用。如果排序方向反过来游标条件对应对称调整WHERE created_at :last_created_at OR (created_at :last_created_at AND id :last_id) ORDER BY created_at DESC, id DESC LIMIT :page_size;需要注意一点游标键不能是 NULL。如果created_at允许 NULL游标边界会变得很麻烦。实际工程里要么建表时就设置NOT NULL DEFAULT CURRENT_TIMESTAMP要么在查询条件里排除 NULL。3.3 游标分页对索引的硬性要求没有索引的游标分页是把排序工作重复一遍甚至更慢。因为它不仅要按字段排序还要再套一层 WHERE 过滤。所以在建表期就要把索引设计好-- 单字段游标用主键就不需要额外建索引 -- 复合游标必须要有对应顺序的复合索引 CREATE INDEX idx_record_created_id ON record(created_at, id);这个索引的顺序很关键created_at在前id在后。如果只建(id, created_at)对ORDER BY created_at, id是没用的。查询条件里出现的过滤字段、排序字段要尽量和索引列的顺序保持一致。判断索引是否生效用 SQLite 自带的执行计划EXPLAIN QUERY PLAN SELECT id, name, created_at FROM record WHERE created_at ? OR (created_at ? AND id ?) ORDER BY created_at ASC, id ASC LIMIT 200;如果输出里是SEARCH record USING INDEX idx_record_created_id说明走对了如果看到SCAN、TEMP B-TREE FOR ORDER BY这类字样说明索引没有命中或者查询写法让优化器没法利用索引要重新审视条件结构。4. Qt 侧落地从 QSqlQuery 到按需加载 Model4.1 setForwardOnly 让结果集“轻装前进”QSqlQuery 在 Qt 里默认是支持随机访问的也就是说你可以在结果集里前后来回seek()。但支持随机访问的代价是驱动可能要缓存更多数据。对于游标分页这种只向前读的场景没必要保留回退能力所以一定要在exec()之前调用setForwardOnly(true)。QSqlQuery query(db); query.setForwardOnly(true); query.prepare(SELECT id, name, created_at FROM record WHERE id ? ORDER BY id ASC LIMIT ?); query.addBindValue(lastId); query.addBindValue(pageSize); if (query.exec()) { while (query.next()) { // 这里逐行读取 } }这个 API 的意思是告诉驱动“我只从头到尾按顺序读一遍不会回头。”在这套逻辑下SQLite 驱动可以更轻量地管理语句句柄也避免了一部分结果集全量缓存。但要注意setForwardOnly(true)之后seek()和at()这类随机访问操作会失去意义强行调用可能拿到错误结果。这正好和单向游标分页匹配。4.2 不要一上来就维护一个长期 Query 游标实现游标分页时有两种常见路线在 Model 里长期持有一个 QSqlQuery 实例每滚动一次调next()从原结果集继续读下一批。每次需要加载下一页时用保存的last_id重新执行查询。我更推荐第二种。原因很简单如果一个 QSqlQuery 长期持有未结束的结果集它就会一直占着 SQLite 的 statement 句柄也占着数据库连接的资源。一旦用户中途切换排序、刷新数据、重建 Model还要想着怎么清理旧查询。更麻烦的是如果同一个连接上还要执行其他操作未结束的结果集可能影响事务状态。而保存last_id每次查询都只取 200 条这个查询本身非常快重建成本几乎可以忽略。代码也更清楚上一次的最后一条记录就是“书签”查询就是“用书签接着翻”。除非你是在做一次性导出需要从头到尾流式读完结果集否则不建议长期持有游标。4.3 用 canFetchMore / fetchMore 配合 QTableView 滚动加载Qt 的 QAbstractItemModel 里有两个专门为按需加载设计的接口canFetchMore()和fetchMore()。QTableView 在滚动到接近底部时会调用canFetchMore()判断是否还有数据如果有就调用fetchMore()请求追加数据。这两个接口和游标分页简直是天生一对。一个基础的分页 Model 可以这样写class PaginatedTableModel : public QAbstractTableModel { public: int rowCount(const QModelIndex parent QModelIndex()) const override { return parent.isValid() ? 0 : m_rows.size(); } bool canFetchMore(const QModelIndex parent) const override { if (parent.isValid()) { return false; } return m_hasMore; } void fetchMore(const QModelIndex parent) override { if (parent.isValid() || !m_hasMore) { return; } QListItemRow newRows loadNextPage(); if (newRows.isEmpty()) { m_hasMore false; return; } beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size() newRows.size() - 1); m_rows newRows; endInsertRows(); // 如果取到的行数小于 pageSize说明已经到末尾 m_hasMore (newRows.size() m_pageSize); } private: QListItemRow m_rows; qint64 m_lastId 0; int m_pageSize 200; bool m_hasMore true; };这里有一个关键细节初始时rowCount()返回的是已加载行数而不是总行数。如果你在rowCount()里返回一千万QTableView 会认为所有行都已经可访问从而丧失按需加载的触发机制甚至为不存在的行请求数据。那用户想看到总行数怎么办单独用一条SELECT COUNT(*)查询放到状态栏或者标题区展示。不要把它塞进 Model 的行数里。一个可以进一步优化的点查询时取pageSize 1条如果实际返回了pageSize 1条说明还有下一页但只展示前pageSize条。这样最后一页的判断会更准确避免因为“最后一页恰好是 200 条”而多触发一次空 fetch。4.4 数据库连接不要跨线程共享游标分页本身很轻量每页查询几十毫秒如果没做复杂 JOIN放主线程问题不大。但有些人会在 UI 卡死后直接把查询扔到后台线程这时候最容易踩 Qt 的经典坑QSqlDatabase 连接默认只能在创建它的线程中使用。你在主线程创建了一个连接然后在子线程里拿同一个 QSqlDatabase 对象去执行查询轻则警告重则崩溃。正确做法是在子线程内部重新创建连接QThread::create([this]() { QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE, worker_connection); db.setDatabaseName(m_dbPath); if (!db.open()) { return; } // 执行查询 QSqlQuery query(db); // 结束后 query.finish(); db.close(); QSqlDatabase::removeDatabase(worker_connection); })-start();注意连接名不能和主线程重复用完要close()并removeDatabase()。这个规则的优先级比任何 SQL 优化都高因为一旦跨线程使用连接程序会变得极不稳定。5. 千万级 CRUD不只是读写也要讲策略5.1 批量事务、参数绑定是写入提速的基本功“千万级数据”不只是要读得快插入、更新、删除同样要控制节奏。最常见的写入慢问题是一条一条执行 INSERT每条都自动提交一次事务。SQLite 每次提交都要写日志、刷磁盘即使都写在内存页里开销也很可观。更合理的写法是一次事务里批量执行db.transaction(); QSqlQuery query(db); query.prepare(INSERT INTO record(name, created_at) VALUES(?, ?)); for (int i 0; i 5000; i) { query.addBindValue(name_ QString::number(i)); query.addBindValue(QDateTime::currentDateTime().toString(Qt::ISODate)); query.exec(); } db.commit();这里有两个点很重要必须用prepare()加参数绑定不要拼 SQL 字符串。SQLite 每解析一条新 SQL 都有成本绑定参数可以复用执行计划。批量大小要适中。5000 条是一个常见起点但取决于单行数据的大小。如果每行都带很长的文本或 BLOB一批 5000 条可能导致内存和锁持有时间过长。落地时先从 500 或 1000 条开始测再往上加。5.2 WAL、缓存大小等 PRAGMA 参数的选择与边界对于 Qt SQLite 的桌面应用几个 PRAGMA 参数会比想象中更影响体感PRAGMA作用适合场景边界说明journal_modeWAL读写并发更好读不会阻塞写桌面应用读多写少或需要边写入边读取会生成-wal和-shm文件备份时要一起考虑synchronousNORMAL减少磁盘同步次数提升写入速度可接受极端断电丢失最近少量已提交事务不能完全关闭 synchronous否则更容易损坏cache_size-20000设置缓存约 20MB大数据量查询时减少磁盘 I/O不是越大越好要权衡内存占用temp_storeMEMORY临时表/排序尽量放内存查询中包含较大排序超大临时数据可能占满内存需要结合真实数据量评估比如打开 WALPRAGMA journal_modeWAL; PRAGMA synchronousNORMAL; PRAGMA cache_size-20000;但要注意journal_modeWAL不是所有 SQLite 版本或运行环境都默认支持。生产环境里应该在程序启动后做一次版本检查再执行这些 PRAGMA并捕获失败情况QSqlQuery pragmaQuery(db); pragmaQuery.exec(PRAGMA journal_modeWAL); if (pragmaQuery.next()) { QString mode pragmaQuery.value(0).toString(); qDebug() journal_mode: mode; }不要把这些参数当成“万能钥匙”。synchronousNORMAL在 WAL 模式下是常见折中但如果你做的是银行对账、离线数据回传这种绝不能丢数据的场景就要重新评估。5.3 更新、删除时怎么保持游标稳定游标分页对“读”很友好但更新和删除一旦让排序键变化就会出现重复或漏数据。最稳的做法是游标键只用创建后不会变的字段。这也是为什么id和created_at是天然好用的游标键而 update_time