Qt+SQLite千万级数据游标分页实战:告别UI卡顿

发布时间:2026/9/2 1:50:17
Qt+SQLite千万级数据游标分页实战:告别UI卡顿 做 Qt 客户端开发的基本都遇到过这种场景业务数据量一旦涨到百万甚至千万级QSqlTableModel直接绑定数据库表界面打开要卡好几秒用户滚动表格鼠标滚轮转了半天屏幕上还是白花花一片如果用LIMIT/OFFSET做了常规分页点击“下一页”时越往后翻响应越慢最后查询耗时从几十毫秒涨到几百毫秒甚至秒级。先给一个结论问题不在 SQLite而在加载策略。SQLite 本身在千万级数据上的查询能力并不差真正拖垮界面的是“一次性把全量数据读进内存”的写法以及对深翻页场景的错误预估。这篇文章会讲清楚一种更符合数据库原理、也更适合客户端 UI 的加载方案——游标分页。你会看到从表设计、索引建立、Qt 代码实现到运行验证的完整过程。1. 这篇文章要解决的问题与适用场景先说清楚这篇实战文章对应哪些痛点、哪些场景适用以及哪些场景没有必要使用游标分页。1.1 直接全量加载为什么会卡很多 Qt 项目在处理数据库时代码是这样的QSqlTableModel *tableModel new QSqlTableModel(this, db); tableModel-setTable(records); tableModel-select(); ui-tableView-setModel(tableModel);这套写法在数据量只有几百、几千条时完全没问题代码简单还能自动映射字段名。可当数据量到达千万级时问题会同时出现在三个地方内存占用select()会把整张表的数据全部加载到内存。每条记录如果包含多个字段千万条记录很容易吃掉几 GB 内存。界面响应慢只是表象内存压力才是更隐蔽的风险。查询耗时SELECT * FROM records会扫描主表全部数据页这一步在千万级数据上可能需要几百毫秒甚至更久。UI 线程阻塞Qt 中所有 GUI 操作都在主线程执行。一次耗时查询发生在主线程里整个窗口就进入“未响应”状态。用户看到的效果就是拖动窗口、点击按钮都没反应。所以说这个场景优化的核心并不是“把 SQL 写得更好看”而是彻底改变数据加载链路。1.2 本文方案的取舍我会在下面演示一套基于QAbstractTableModel QSqlQuery 游标分页的实现。它的核心思路是数据库只返回当前页需要的数据。利用上一页最后一条记录的唯一排序字段作为下一页查询的“游标”。通过QAbstractTableModel让界面“按需追加”数据。滚动到底部时自动加载下一页不在主线程做全量查询。使用游标分页有三个前提排序字段需要稳定且唯一。需要为排序字段建立合适的索引。用户需求是“持续浏览大量记录”而不是“精确跳转到任意页”。如果你的业务只是每页固定显示 20 条、且必须支持用户跳到第 10000 页那游标分页不适合你你应该考虑服务端搜索或二次过滤。2. SQLite 大数据量下“慢”的本质要听懂游标分页得先理解 SQLite 在千万级数据下运行时到底慢在哪里。2.1 SQLite 的架构特点SQLite 是一个嵌入式文件型数据库没有独立的服务进程数据库就是一个文件。应用程序通过 SQLite 引擎直接读写这个文件。每次查询SQLite 都要打开 B 树页面、读取磁盘数据、执行条件匹配。这带来一个直观结果SQLite 的查询耗时绝大多数由磁盘 IO 和 B 树遍历决定。和 MySQL 这种服务型数据库相比SQLite 少了网络开销但也少了服务端缓存和独立的查询优化器调优空间。2.2 查询慢的三个层次如果你把“慢”当作一个现象去拆通常出自三个层次全表扫描查询条件没有命中索引SQLite 只能逐行读取主表。深翻页扫描使用了LIMIT/OFFSET分页每次查询都需要扫描并跳过前面所有行。冗余列读取SELECT *把很多用不到的列也读了出来尤其是BLOB、超长字符串这类字段会显著拉高 IO 成本。前面说的QSqlTableModel全量加载本质是“全表扫描 冗余列读取”同时发生。这里真正容易踩坑的地方是很多人以为表太大所以慢换一个数据库就能解决但同一个 SQLite 文件使用正确的索引和分页策略查询耗时会大幅下降。3. LIMIT/OFFSET 与游标分页的对比分页方案有很多种最常被新手使用的是LIMIT/OFFSET。它在数据量小的时候看不出毛病数据量大了以后才会暴露问题。3.1 OFFSET 分页的深翻页问题OFFSET分页工作过程可以这样理解SELECT * FROM records ORDER BY record_time, id LIMIT 100 OFFSET 9000000;数据库做的事情是从 B 树根部开始按排序顺序扫描前 9000100 行然后把其中前 100 行返回给你。也就是说你已经看过的 900 万条记录每次翻页时都会被数据库重新扫描一遍。这是数据库分页中最典型的深翻页问题。页码越靠后扫描的行数越多耗时越长。千万级数据下甚至可能出现翻到后面一次查询超过 1 秒的情况。3.2 游标分页的核心思想游标分页换了个角度既然用户只能从第 N 页翻到第 N1 页那我们只需要记住“当前页最后一条记录的位置”下一次直接从这个位置往后取就行了。它的典型 SQL 长这样SELECT id, sn, device_id, value, record_time FROM records WHERE (record_time, id) (上一页最后一条记录的record_time, 上一页最后一条记录的id) ORDER BY record_time ASC, id ASC LIMIT 100;因为record_time和id组成了排序位置(record_time, id)大于上一页最后一条记录的行就是下一页的开头。整个查询利用索引直接定位到目标位置不需要扫描前面的任何行。3.3 两种方案对比表对比项LIMIT/OFFSET 分页游标分页深翻页耗时随页码增大而增长明显与页码无关只与当前页位置相关SQL 复杂度简单易于理解需要维护游标条件稍复杂排序字段要求常规排序即可需要稳定且唯一的排序字段组合是否支持跳页支持任意页码不支持直接跳到任意页适用场景后台管理表格、页码明确的数据客户端持续滚动浏览、流式加载索引依赖不强依赖但依赖会更快强依赖组合索引否则性能退化结论很清晰如果你的 Qt 项目需要“持续向下浏览百万级数据”游标分页是更合理的选型如果需要“跳转到任意页”再考虑 OFFTSET 或者其他方案。4. 环境准备与表结构设计这一章进入实操。先用最小可运行的项目演示核心流程。4.1 开发环境本文代码以 Qt 5.15 / Qt 6.x 为基础使用 Qt 自带 SQLite 驱动插件不需要额外安装 SQLite。工程文件中需要添加sql模块。CMakeLists.txt示例cmake_minimum_required(VERSION 3.16) project(CursorPageDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 COMPONENTS Core Gui Widgets Sql REQUIRED) qt6_add_executable(CursorPageDemo main.cpp recordtablemodel.cpp recordtablemodel.h ) target_link_libraries(CursorPageDemo PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Sql )如果使用 Qt 5把Qt6改为Qt5并把qt6_add_executable改为qt5_add_executable或者用传统的find_package(Qt5 COMPONENTS Core Gui Widgets Sql REQUIRED) target_link_libraries(CursorPageDemo PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets Qt5::Sql)4.2 建表语句与索引设计建表是这一步的关键。为了演示游标分页我们需要一个排序字段和唯一键字段。CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, sn TEXT NOT NULL, device_id INTEGER NOT NULL, value REAL NOT NULL, record_time INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_records_time_id ON records(record_time, id);注意这里的核心是idx_records_time_id组合索引它必须包含排序字段record_time和唯一字段id。游标分页查起来快不快基本由这个索引决定。4.3 为什么索引要按 (record_time, id) 建立如果只看单字段record_time本身就可能重复。如果排序字段出现重复游标判断就会失灵比如上一页最后一条数据的record_time 1000下一页条件record_time 1000会把所有等于 1000 的记录全部漏掉。所以游标分页一定要把“排序字段”和“唯一字段”组合成一个候选键。(record_time, id)满足两个条件记录在逻辑上有固定的浏览顺序。每条记录在排序顺序中的位置是唯一的。建立(record_time, id)组合索引SQLite 可以直接通过索引定位到游标位置然后顺序读取后续记录不需要回表扫描大量无关数据。5. 生成千万级测试数据没有数据验证就无从谈起。我提供一种批量生成大量测试数据的方法同时也展示 SQLite 写入时的基本性能优化思路。5.1 批量写入思路直接逐条执行INSERT写入千万条数据速度会非常慢。原因在于每次INSERT都需要独立提交事务SQLite 每提交一次事务都要执行文件同步、写日志等操作。提高写入速度最有效的方法把大量 INSERT 放在同一个事务里分批提交。下面我用 C 示例演示这个思路。5.2 C 批量插入实现#include QCoreApplication #include QSqlDatabase #include QSqlQuery #include QSqlError #include QDateTime #include QVariant #include QDebug static constexpr int kTotalCount 5000000; // 生成 500 万条可根据需要调整 static constexpr int kBatchSize 10000; // 每批提交 10000 条 int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(records.db); if (!db.open()) { qWarning() 打开数据库失败: db.lastError().text(); return 1; } QSqlQuery init(db); init.exec(CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, sn TEXT NOT NULL, device_id INTEGER NOT NULL, value REAL NOT NULL, record_time INTEGER NOT NULL)); init.exec(CREATE INDEX IF NOT EXISTS idx_records_time_id ON records(record_time, id)); QSqlQuery insert(db); insert.prepare(INSERT INTO records(sn, device_id, value, record_time) VALUES (?, ?, ?, ?)); db.transaction(); for (int i 0; i kTotalCount; i) { insert.addBindValue(QString(SN%1).arg(i, 10, 10, QChar(0))); insert.addBindValue(i % 100); insert.addBindValue(i * 0.5); insert.addBindValue(1000000000 i % 1000000); insert.exec(); if (i % kBatchSize 0 i ! 0) { db.commit(); db.transaction(); qInfo() 已写入: i; } } db.commit(); qInfo() 生成完成; return 0; }这段代码的关键点是每 10000 条执行一次commit()。一次事务里连续执行多条INSERT。record_time的值使用了1000000000 i % 1000000这样记录时间分布在一个范围内方便测试索引查找。生成的数据量可以根据实际机器性能调整。如果只需要验证功能先建 50 万条也够用如果要做千万级性能验证建议在 WAL 模式下写入速度更快。PRAGMA journal_modeWAL;可以在打开数据库后执行db.exec(PRAGMA journal_modeWAL;);6. Qt 端游标分页核心实现这是文章中最核心的一节。要拆成三部分游标分页查询类、表格模型、滚动加载事件。6.1 游标分页查询类先定义一个游标结构体用来保存上一页最后一条记录的位置。struct Cursor { QVariant lastRecordTime; // 上一页最后一条的 record_time qint64 lastId -1; // 上一页最后一条的 id };然后写查询函数#include QSqlQuery #include QSqlRecord #include QSqlError #include QVariant #include QDateTime QListQSqlRecord loadPage(QSqlQuery query, const Cursor cursor, int pageSize) { QListQSqlRecord records; if (cursor.lastId 0) { // 第一页 query.prepare( SELECT id, sn, device_id, value, record_time FROM records ORDER BY record_time ASC, id ASC LIMIT ?); query.addBindValue(pageSize); } else { // 后续页利用游标位置 query.prepare( SELECT id, sn, device_id, value, record_time FROM records WHERE (record_time, id) (?, ?) ORDER BY record_time ASC, id ASC LIMIT ?); query.addBindValue(cursor.lastRecordTime); query.addBindValue(cursor.lastId); query.addBindValue(pageSize); } if (!query.exec()) { qWarning() 分页查询失败: query.lastError().text(); return records; } while (query.next()) { records.append(query.record()); } return records; }注意(record_time, id) (?, ?)这种行值写法是 SQLite 3.15 开始支持的。Qt 5.15 内置 SQLite 版本一般都能支持。如果你的 SQLite 版本比较旧可以改成等价写法WHERE record_time ? OR (record_time ? AND id ?)两种写法语义一致行值写法更简洁OR 写法兼容性更好。6.2 表格模型接入QSqlQueryModel和QSqlTableModel不适合这种增量追加模式所以需要基于QAbstractTableModel实现一个自己的模型。// recordtablemodel.h #ifndef RECORDTABLEMODEL_H #define RECORDTABLEMODEL_H #include QAbstractTableModel #include QSqlQuery #include QList #include QVariant struct Cursor { QVariant lastRecordTime; qint64 lastId -1; }; class RecordTableModel : public QAbstractTableModel { Q_OBJECT public: explicit RecordTableModel(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; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; void openDatabase(const QString dbPath); void loadInitialPage(int pageSize); void loadNextPage(); bool hasMore() const { return m_hasMore; } private: void appendRecords(const QListQVectorQVariant rows); QSqlDatabase m_db; QSqlQuery m_query; Cursor m_cursor; bool m_hasMore true; int m_pageSize 500; QListQVectorQVariant m_rows; // 行号 - 每列的值 }; #endif // RECORDTABLEMODEL_H实现文件// recordtablemodel.cpp #include recordtablemodel.h #include QSqlError #include QDebug RecordTableModel::RecordTableModel(QObject *parent) : QAbstractTableModel(parent) { } void RecordTableModel::openDatabase(const QString dbPath) { m_db QSqlDatabase::addDatabase(QSQLITE, cursor_page_conn); m_db.setDatabaseName(dbPath); if (!m_db.open()) { qWarning() 打开数据库失败: m_db.lastError().text(); return; } m_query QSqlQuery(m_db); } int RecordTableModel::rowCount(const QModelIndex parent) const { if (parent.isValid()) return 0; return m_rows.size(); } int RecordTableModel::columnCount(const QModelIndex parent) const { if (parent.isValid()) return 0; return 5; } QVariant RecordTableModel::data(const QModelIndex index, int role) const { if (!index.isValid() || index.row() m_rows.size()) return QVariant(); if (role Qt::DisplayRole || role Qt::EditRole) return m_rows.at(index.row()).at(index.column()); return QVariant(); } QVariant RecordTableModel::headerData(int section, Qt::Orientation orientation, int role) const { if (orientation Qt::Horizontal role Qt::DisplayRole) { switch (section) { case 0: return QStringLiteral(id); case 1: return QStringLiteral(sn); case 2: return QStringLiteral(device_id); case 3: return QStringLiteral(value); case 4: return QStringLiteral(record_time); default: break; } } return QAbstractTableModel::headerData(section, orientation, role); } void RecordTableModel::loadInitialPage(int pageSize) { m_pageSize pageSize; m_hasMore true; m_cursor.lastId -1; m_rows.clear(); loadNextPage(); } void RecordTableModel::loadNextPage() { if (!m_hasMore) return; QListQVectorQVariant batch; QListQVariant row; if (m_cursor.lastId 0) { m_query.prepare( SELECT id, sn, device_id, value, record_time FROM records ORDER BY record_time ASC, id ASC LIMIT ?); m_query.addBindValue(m_pageSize); } else { m_query.prepare( SELECT id, sn, device_id, value, record_time FROM records WHERE (record_time, id) (?, ?) ORDER BY record_time ASC, id ASC LIMIT ?); m_query.addBindValue(m_cursor.lastRecordTime); m_query.addBindValue(m_cursor.lastId); m_query.addBindValue(m_pageSize); } if (!m_query.exec()) { qWarning() 查询失败: m_query.lastError().text(); m_hasMore false; return; } int count 0; while (m_query.next()) { row.clear(); for (int col 0; col columnCount(); col) row.append(m_query.value(col)); batch.append(row); count; } if (count m_pageSize) m_hasMore false; if (!batch.isEmpty()) { const QVectorQVariant lastRow batch.last(); beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size() batch.size() - 1); for (const auto r : batch) m_rows.append(r); endInsertRows(); m_cursor.lastRecordTime lastRow.at(4); m_cursor.lastId lastRow.at(0).toLongLong(); } }这段代码里有两个值得注意的细节加载完成后用beginInsertRows和endInsertRows通知视图插入数据。这比reset()更高效也能避免视图视角跳动。游标更新放在数据追加之后记录的是追加批次中最后一条记录的排序字段和 id。6.3 滚动到底部自动加载在主窗口里创建QTableView然后把垂直滚动条的valueChanged信号连接到加载逻辑。// mainwindow.h 关键部分 #include QMainWindow #include recordtablemodel.h class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent nullptr); private slots: void onScrollValueChanged(int value); private: void setupTableView(); QTableView *m_tableView nullptr; RecordTableModel *m_model nullptr; };实现// mainwindow.cpp #include mainwindow.h #include QTableView #include QScrollBar #include QVBoxLayout MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *central new QWidget(this); auto *layout new QVBoxLayout(central); m_tableView new QTableView(central); m_model new RecordTableModel(this); m_model-openDatabase(records.db); m_tableView-setModel(m_model); // 关闭自动排序保持与查询顺序一致 m_tableView-setSortingEnabled(false); layout-addWidget(m_tableView); setCentralWidget(central); resize(900, 600); connect(m_tableView-verticalScrollBar(), QScrollBar::valueChanged, this, MainWindow::onScrollValueChanged); m_model-loadInitialPage(500); } void MainWindow::onScrollValueChanged(int value) { QScrollBar *bar m_tableView-verticalScrollBar(); // 接近底部时触发加载下一页 if (bar-maximum() - value 50) { if (m_model-hasMore()) { m_model-loadNextPage(); } } }这里的触发策略是滚动条距离最大值不足 50 时自动加载下一页。这是一种常见的“滚动到底部自动加载下一页”的实现方式适合大量数据的持续浏览场景。需要说明的是为了保证演示完整性数据库查询仍然在主线程执行。当单页查询耗时较短时比如索引命中、单页 500 条影响不大但如果你的单页查询耗时超过几十毫秒建议把查询放到工作线程通过信号将查询结果交给主线程更新模型。这部分在第 9 章展开。7. 运行效果与验证方法代码写完后需要验证两件事查询是否真正命中了组合索引。UI 在持续滚动时是否依然流畅。7.1 验证查询是否命中索引在 SQLite 中可以使用EXPLAIN QUERY PLAN检查查询计划EXPLAIN QUERY PLAN SELECT id, sn, device_id, value, record_time FROM records WHERE (record_time, id) (1000000000, 100) ORDER BY record_time ASC, id ASC LIMIT 500;在 DB Browser for SQLite 中执行后应该能看到查询计划里包含USING INDEX idx_records_time_id。这说明 SQLite 确实通过组合索引定位游标位置而不是做全表扫描。如果你的查询计划里出现SCAN TABLE records说明索引没有生效需要检查表结构是否真的创建了idx_records_time_id。查询条件里的字段类型是否和表字段类型一致。是否使用了会导致索引失效的函数包裹排序字段。7.2 在界面侧观察卡顿是否消除运行程序后初始只加载 500 条窗口能快速打开。持续向下滚动数据会一段一段追加。判断是否流畅可以观察窗口拖动是否顺滑。滚动过程是否出现明显的白屏或停顿。任务管理器里程序占用内存是否在持续高速增长。在没有游标分页、直接全量加载的版本中千万级数据很容易把内存撑到几 GB界面也会卡死。使用游标分页后内存占用稳定在“已加载行数”对应的规模滚动过程中只会追加小批数据。7.3 单页耗时的判断标准SQLite 本身没有内置慢查询日志但可以在 Qt 侧统计一页查询耗时QElapsedTimer timer; timer.start(); bool ok m_query.exec(); if (ok) { qInfo() 本页查询耗时: timer.elapsed() ms; }判断标准没有绝对数值取决于机器和数据集分布。更重要的判断原则是耗时是否随着页码增大而增长。游标分页的理想状态是不管翻到第 100 页还是第 10000 页单页查询耗时都稳定在同一个量级。如果发现越翻越慢优先检查索引是否失效或者排序字段是否被表达式包住。8. 常见问题与排查思路问题现象可能原因排查方式解决方案滚动加载后界面仍然卡顿单页加载过多或查询没有命中索引查看本页打印耗时执行 EXPLAIN QUERY PLAN减小 pageSize检查索引是否建立将查询放入工作线程下一页数据缺失排序字段不唯一游标判断出错检查排序字段是否有重复值使用 (排序字段, id) 组合游标确保唯一性页面加载数据重复游标更新时机错误或查询语句绑定了错误参数输出游标的 lastRecordTime 和 lastId确保游标取自上一页最后一条记录数据一直在加载永远到不了底部每页返回行数刚好等于 pageSize但实际数据量非常大观察模型行数是否持续增长正常现象如不想滚动加载可限制最大加载行数查询计划出现 SCAN TABLE索引未建立或条件写法导致索引失效EXPLAIN QUERY PLAN 查看计划建立组合索引避免对排序字段使用函数启动时连接数据库失败数据库文件路径错误或驱动未加载检查插件目录输出 lastError确认QSQLITE插件存在路径改为绝对路径批量插入速度很慢每一条 insert 都独立提交事务检查是否在循环里调用 commit使用事务分批提交每批 5000 ~ 20000 条9. 工程最佳实践与进阶方向到这里最小演示已经跑通了。但如果要放到生产环境还需要考虑更完整的工程方案。9.1 把数据库操作移到工作线程第 6 章的演示里查询在主线程执行。单页 500 条、命中索引的前提下主线程压力不大但生产环境为了稳妥建议用QThread或QtConcurrent把查询放到后台线程查询结果通过信号槽传给主线程主线程只调用模型追加数据。大致结构工作线程负责执行loadPage查询、封装为QVectorQVariant列表。查询完成后发出pageReady(const QListQVectorQVariant, Cursor)信号。主线程收到信号后调用beginInsertRows、追加数据、更新游标。这样即使遇到 BLOB 字段较多、单页查询耗时较长的情况窗口拖动也不会卡顿。9.2 组合索引与查询字段的取舍游标分页的性能好坏很大程度取决于索引命中情况。建议只读取界面需要的字段不要写SELECT *。如果业务上只需要sn、value、record_time那么可以建立一个覆盖索引把查询字段都包含进去进一步减少回表操作。例如CREATE INDEX idx_records_covering ON records(record_time, id, sn, value);这样查询可以直接从索引中获取所有需要的字段不需要回到主表读取数据页。覆盖索引会占用更多磁盘空间是否使用取决于你的查询字段。9.3 配合 WAL 模式和预编译语句在 SQLite 中WAL模式Write-Ahead Logging可以提升并发场景下的读写性能。它允许一个写事务与多个读事务并发执行对 Qt 客户端这个场景很友好。打开数据库后执行m_db.exec(PRAGMA journal_modeWAL;);另外尽量复用同一个QSqlQuery这相当于 SQLite 的预编译语句能减少重复解析 SQL 的开销。9.4 游标分页的边界与不足游标分页并不是万能的。它最大的限制是不能直接跳转到任意页。对排序字段要求高必须在插入和更新时保证排序字段稳定。如果排序字段在浏览过程中发生变化游标可能失效。实际项目中更常见的做法是第一页用游标分页加载最近数据用户需要精确查找时输入查询条件走索引搜索只有持续浏览场景才使用游标分页。三种模式结合用户体验会更完整。10. 总结回到最开始的问题Qt SQLite 处理千万级数据 CRUDUI 卡顿的关键往往不是 SQLite 慢而是加载策略和查询方式出了问题。QSqlTableModel全量加载会一次性读入所有数据LIMIT/OFFSET深翻页会反复扫描前序数据这两个方案在千万级数据下都不合适。游标分页用“记住上一页最后一条记录的排序位置”代替“从第 0 行开始数 offset 行”把查询开销稳定控制在一页范围。配合(record_time, id)组合索引、QAbstractTableModel增量追加和滚动加载Qt 端可以做到持续浏览大量数据时界面依然流畅。接下来建议你动手做两件事第一用 DB Browser for SQLite 生成测试数据并验证查询计划是否命中索引第二把文中的RecordTableModel接入自己的项目先做一个滚动加载的最小 DEMO再继续加入线程、过滤和缓存等工程能力。只有亲手跑一遍才能真正理解游标分页为什么在深翻页场景下比 OFFSET 更可靠。