1. 先说清楚QSqlQuery在整个Qt数据库体系里的位置1.1 一条SQL从Qt到数据库的完整旅程先把责任划分说清楚这是最容易犯迷糊的地方。QSqlDatabase负责的是连接——建立和维持应用与数据库之间的通道它本身不会帮你执行任何SQL真正把SQL语句送过去、把结果集搬回来的是QSqlQuery。用一个不严谨但好记的比喻QSqlDatabase就像铺好的一条路QSqlQuery是这条路上来回跑的货车你往车上装什么SQL决定它能带回来什么。最朴素的一段代码如下#include QSqlDatabase #include QSqlQuery #include QSqlError #include QDebug int main(int argc, char *argv[]) { QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(demo.db); if (!db.open()) { qDebug() 连接失败: db.lastError().text(); return -1; } QSqlQuery query; bool ok query.exec(SELECT 1); qDebug() 查询执行结果: ok; return 0; }注意addDatabase第一参数是驱动名第二个参数如果不传它会使用默认连接名。对于单连接应用这是最省事的写法但后面讲到多线程时你会发现命名连接其实在项目初期就该养成习惯。这个类设计的核心思路是一条QSqlQuery实例在任意时刻承载一条查询你可以复用同一个query对象连续执行多条SQL但执行新SQL时前一条查询的结果集会失效一些驱动会释放资源所以不要企图在两次exec之间还去读上一次的结果。1.2 两种最常见的连库方式SQLite和MySQL不同数据库的接入方式差异主要在驱动和连接参数上我给一张常用的对照表驱动名说明典型场景QSQLITEQt自带无需额外部署本地缓存、单机工具软件QMYSQLMySQL/MariaDB需客户端库客户端-服务器架构QODBC通用ODBCSQL Server等老牌商业库QPSQLPostgreSQL数据关系复杂的业务系统如果你对接的是MySQL连接代码会多一些参数QSqlDatabase db QSqlDatabase::addDatabase(QMYSQL); db.setHostName(127.0.0.1); db.setPort(3306); db.setDatabaseName(app_db); db.setUserName(root); db.setPassword(your_password); if (!db.open()) { qDebug() MySQL连接失败: db.lastError().text(); }这里有个非常值得提前确认的点先执行qDebug() QSqlDatabase::drivers();看看你的Qt环境里实际有哪些驱动。很多人的QMYSQL连不上根本不是密码错了而是发布版的Qt根本没带MySQL驱动插件。2. 动手前先把这些前置条件排干净2.1 工程文件里的QT sql和驱动检查工程文件里漏掉SQL模块是最低级的编译错误但真有不少人栽过。qmake工程在.pro文件里QT core gui sql如果你用CMake则对应find_package(Qt5 COMPONENTS Core Gui Sql REQUIRED) target_link_libraries(app PRIVATE Qt5::Sql)漏掉这一行最常见的报错是无法打开包含文件QSqlDatabase或者no such file or directory。这种错误一般在第一步就能暴露但还有一种更隐蔽的情况工程里某个子模块用了sql主模块没加链接期才报一堆undefined reference那时候排查起来就更费劲。所以加依赖模块时顺手把该模块的源文件头文件引用检查一遍。判断一个驱动是否可用除了看drivers()列表还要专门跑一次if (!QSqlDatabase::isDriverAvailable(QMYSQL)) { qDebug() QMYSQL驱动不可用; }2.2 编译期报错那串长长的dependent ... does not exist很多用Qt 5.15.2 msvc2019_64环境的人会撞见这种报错:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets... does not exist第一次见到这一长串路径你可能会以为是自己代码写错了或者某个头文件丢了。实际上这个报错的意思是Qt Creator在构建时发现一个依赖文件的路径无效这个路径通常来自构建缓存里的旧include路径。它和你的源码基本没关系常见诱因是这三种构建目录shadow build目录里残留了旧版本的.qmake.stash和Makefile里面记录的Qt路径已经失效。Kit配置不对最常见的是装了MSVC编译器却把编译器选成了MinGW或者反过来。Qt SDK本体的目录被移动过而Kit里的Qt Version路径还是老位置。我的处理顺序是固定的先看Kit里的编译器和Qt版本是否匹配Qt 5.15.2 msvc2019_64必须配MSVC2019的64位编译器然后删除构建目录让Qt Creator重新跑一次qmake再不行去工具-选项-Kits里检查Qt Version的路径现在是否真实存在。三步做完这类报错基本都能解决。这条经验本身和QSqlQuery无关但它卡在写数据库代码之前不清理掉后面什么都做不了。2.3 MySQL驱动的DLL依赖问题如果你用QMYSQL还要面对一个运行时的问题Qt的sqldrivers目录里确实有qsqlmysql.dll但打开连接时依然可能报Driver not loaded。原因是这个插件本身动态依赖MySQL客户端库Qt官方包构建时选择的是libmysql.dll还是libmariadb.dll不同版本并不一致。我遇到过5.15.2的包用libmysql.dll就正常换了一台机器同样的库路径却起不来最后把libmariadb.dll放过去才通的。所以排查顺序应该是先isDriverAvailable看驱动插件在不在再确认客户端库是不是和插件匹配。部署时把对应的dll放到exe同目录或者放到能被动态加载的路径下。这个坑在开发机上不明显打包到没有MySQL客户端的机器上才会爆发。3. 执行SQL的三种姿势以及各自适合什么场景3.1 直接exec()拼字符串演示和学习够用最直观的执行方式就是把SQL拼成QString再传给exec()QSqlQuery query; QString name 张三; bool ok query.exec(QString(SELECT * FROM user WHERE name %1).arg(name));这种写法在演示DEMO、执行建表语句、或者SQL内容完全是内部常量时没有问题。但一旦参数来自用户输入、配置文件、网络数据立即暴露两个问题一是字符串转义name里只要出现单引号SQL就碎了二是SQL注入用户输入一段精心构造的内容可能直接改变你要执行的语句。在涉及钱、账号、删除这类操作时这是绝对不能碰的写法。我的建议是exec()只用来执行没有外部参数的SQL比如CREATE TABLE、PRAGMA设置。3.2 prepare() bindValue()生产环境的第一选择正确做法是预处理语句配合占位符。Qt支持两种占位符命名占位符:name和位置占位符?。QSqlQuery query; query.prepare(INSERT INTO user (name, age, created_at) VALUES (:name, :age, :createdAt)); query.bindValue(:name, 张三); query.bindValue(:age, 25); query.bindValue(:createdAt, QDateTime::currentDateTime()); if (!query.exec()) { qDebug() 插入失败: query.lastError().databaseText(); }这里要理解一个关键点绑定的参数值由驱动单独传递不会拼进SQL字符串里因此任何引号、注释符、分号都只是数据不会再被当成SQL指令执行。这从根上解决了注入问题也同时解决了转义问题——你再也不用自己给字符串加一层引号了。循环执行同一条SQL时prepared的好处更大SQL语句在数据库端只需要编译一次后面反复传参执行。位置占位符适合SQL本身很短、参数又多的情况QSqlQuery query; query.prepare(SELECT * FROM user WHERE age ? AND city ?); query.addBindValue(18); query.addBindValue(北京); if (query.exec()) { // 处理结果 }注意bindValue和addBindValue的区别bindValue必须指定占位符名字addBindValue按照占位符出现顺序绑定。混用容易搞乱一个查询里选定一种风格就好。还有一个实用点是lastInsertId()。执行INSERT之后想拿到自增主键的值直接if (query.exec()) { QVariant id query.lastInsertId(); qDebug() 新记录ID: id.toLongLong(); }SQLite和MySQL都支持省掉你重新SELECT一次的麻烦。3.3 execBatch()批量插入别急着写循环往本地表中批量灌数据的人最容易犯的错就是写一个for循环每条INSERT调用一次exec。数据量几百条还好上到几千上万就开始肉眼可见地卡。QSqlQuery提供了execBatch()专门处理批量操作QVariantList names; names 张三 李四 王五; QVariantList ages; ages 20 25 30; QSqlQuery query; query.prepare(INSERT INTO user (name, age) VALUES (?, ?)); query.addBindValue(names); query.addBindValue(ages); bool ok query.execBatch();它的执行模式是批量的也就是说驱动有机会把多条语句合并传输而不是每传一次等一次往返。但要注意不是所有驱动对execBatch都能做到一次提交SQLite驱动实际上还是会逐条执行只是省去了应用层一次次调exec的开销。真正的性能提升往往还要配合第6节讲的事务。如果你想拿到bind到某一行时的逐行控制execBatch的参数模式也可以调整但日常批量灌数据用默认的ValuesAsRows就够了。4. 结果集的读取细节游标、类型转换与NULL4.1 游标遍历到底能不能回头SELECT执行成功后结果集以二维表的形式存在查询对象里。但QSqlQuery不是一次性把结果全倒进内存的容器它更像一个只能按顺序移动的游标。刚执行完时游标位于第一行之前你必须调用next()让它向下移动一行每调用一次移动一行返回false表示已经走完while (query.next()) { int id query.value(0).toInt(); QString name query.value(name).toString(); qDebug() id name; }很多人第一次写循环时直接query.value()然后编译通过、运行也不报错但结果全是错的——就是因为忘了先next()。记住next()的返回值就是是否还存在下一行这也是最标准的遍历终止条件。关于能不能回头这里有个容易踩的认知差。QSqlQuery提供了first()、last()、previous()、seek()这几个游标操作看起来可以随便前后跳。但底层驱动不一定支持双向游标SQLite驱动支持得比较好而某些ODBC驱动或网络数据库驱动可能只允许向前遍历。稳妥的做法是业务代码一律按单次向前遍历来写非要倒序访问就先last()再previous()但前提是你确认过当前驱动支持。另外很多驱动尤其是SQLite在SELECT结果上调用size()会返回-1这是设计如此因为结果是一行一行流式读取的。千万别拿size()做循环次数的预算老老实实用next()判断。4.2 value()取值时的类型转换陷阱value()返回的是一个QVariant具体类型取决于列类型和驱动实现。取数时你通常要显式转换int id query.value(id).toInt(); QString name query.value(name).toString(); double price query.value(price).toDouble(); QDateTime createdAt query.value(created_at).toDateTime();最大的坑在NULL值。数据库里某个字段是NULLQt这边对应的QVariant是无效值此时调用toInt()会返回0、toString()返回空字符串完全没有报错。如果你没意识到这是NULL很可能把0当成真实数据存进业务逻辑。安全写法是QVariant v query.value(remark); if (!v.isNull()) { QString remark v.toString(); } else { // 单独处理NULL情况 }还有个细节MySQL的tinyint(1)字段Qt的驱动经常以整数形式返回你需要用toBool()或者直接toInt()再和0/1比较日期时间字段在不同驱动下返回类型也不同我在MySQL驱动上就遇到过QDateTime绑定和读取不一致的情况。碰到这类问题先qDebug() query.value(i).typeName();看看拿到手的到底是什么类型再决定转换方式。4.3 record()动态获取列的信息有些场景下你并不提前知道SQL返回了哪些列比如一个通用的导出工具。这时用record()动态解析QSqlRecord rec query.record(); qDebug() 列数: rec.count(); for (int i 0; i rec.count(); i) { QSqlField field rec.field(i); qDebug() 列名: field.name() 类型: field.typeID(); }QSqlRecord里装着每条QSqlField字段名、类型、是否有效都能查到。日常还有一个更实用的用法先拿到列的索引循环里用索引取值避免每行都按字符串查找列名QSqlRecord rec query.record(); int nameIdx rec.indexOf(name); int ageIdx rec.indexOf(age); while (query.next()) { QString name query.value(nameIdx).toString(); int age query.value(ageIdx).toInt(); }数据量小时这个优化不明显但几十万行时还是有意义的。如果你只是在界面上展示只读查询结果QSqlQueryModel会是更合适的类它直接和QTableView配合QSqlQuery更适合需要自己控制逐行处理逻辑的场景两者定位不同别混着用。5. 查询失败时怎么快速定位错误对象与调试习惯5.1 lastError()能告诉你什么exec()返回false只是告诉你出错了具体错在哪要用lastError()去问QSqlError err query.lastError(); qDebug() 错误类型: err.type(); qDebug() 错误文本: err.text(); qDebug() 数据库原文: err.databaseText();QSqlError里text()是驱动提供的可读信息databaseText()是数据库本身返回的原始信息。很多时候text()是空的关键内容全在databaseText()里。我调试时习惯两条一起打出来不然会漏掉真正的原因。error.type()返回的枚举大致分几类NoError、ConnectionError、StatementError、TransactionError、UnknownError。连接类错误通常要去检查db.open()而不是query本身语句类错误则聚焦SQL文本和参数绑定。一个被很多人忽略的调试接口是lastQuery()if (!query.exec()) { qDebug() 失败的SQL: query.lastQuery(); qDebug() 错误详情: query.lastError().databaseText(); }尤其用占位符绑定时你实际提交给驱动的SQL可能和你想象的不一样把lastQuery()打出来立刻能对账。5.2 几个高频运行期错误及排查思路我整理了实际项目中遇得比较多的几类供对照错误表现常见原因排查方向no such table表名大小写不一致或MySQL里没选对数据库检查setDatabaseName和建表语句database is lockedSQLite多线程/多进程同时写缩短事务时间启用WAL模式column not found字段名拼写错误或表结构已变更打印record()列名核对Parameter count mismatch占位符数量和绑定值数量不一致检查prepare里的?或:name个数Driver not loaded驱动插件缺失或依赖dll缺失先isDriverAvailable再查客户端库sqlite出现database is locked时第一反应不该是加大超时时间而是审视自己的事务是不是开得太久。我曾经在一个导入功能里事务里做了一堆到数据库外的耗时操作结果其他连接全被锁死。正确做法是事务里只放纯粹的数据库操作。SQLite的并发写问题还可以通过执行PRAGMA journal_modeWAL;缓解让读和写不再互相阻塞。排查这些错误时我建议遵循一个固定链路先确认连接本身还活着db.isOpen()再确认SQL文本lastQuery()再看绑定参数的类型和数量最后才怀疑驱动和数据库版本。走完这条链路绝大多数运行期查询错误都能定位。6. 事务、并发与性能让QSqlQuery从能用走向好用6.1 事务三件套transaction、commit、rollback默认情况下QSqlQuery每执行一条SQL都会自动提交这在单条写入时没什么问题但几条SQL需要作为一个整体成功或失败时就必须显式开启事务。典型场景是转账从A账户扣钱、往B账户加钱两步必须同时成立。QSqlDatabase db QSqlDatabase::database(); if (!db.transaction()) { qDebug() 开启事务失败: db.lastError().text(); return; } QSqlQuery query; bool ok1 query.exec(UPDATE account SET balance balance - 100 WHERE id 1); bool ok2 query.exec(UPDATE account SET balance balance 100 WHERE id 2); if (ok1 ok2 db.commit()) { qDebug() 事务提交成功; } else { db.rollback(); qDebug() 事务回滚; }注意commit()和rollback()本身也有返回值我在代码里只判断了commit()实际严谨的做法是rollback()结果也看一眼。另外事务状态是和连接绑定的这意味着你在这条连接上执行的所有QSqlQuery都会参与当前事务如果你同时又新建了另一个QSqlQuery对象只要用的是同一条连接它也在事务内。想确认某条连接当前是否在事务里可以用QSqlDatabase::transaction()的返回值或者通过驱动查询。事务嵌套在多数驱动下不支持别指望能像某些数据库客户端那样随便套。6.2 多线程环境下连接不能共用QSqlDatabase的文档明确说连接不是线程安全的同一个连接实例不能在多个线程里并发使用。这不只是理论问题实际跑起来会出现随机崩溃、查询卡死、结果错乱。正确的做法是每个需要访问数据库的线程自己建一条连接并且用唯一的名字// 在子线程内部创建 QSqlDatabase threadDb QSqlDatabase::addDatabase(QSQLITE, thread_conn); threadDb.setDatabaseName(data.db); if (!threadDb.open()) { qDebug() 线程连接打开失败; } QSqlQuery query(threadDb); query.exec(SELECT * FROM user WHERE age ?); // ... 处理结果 threadDb.close(); QSqlDatabase::removeDatabase(thread_conn);这里有一个极其隐蔽的坑removeDatabase()必须在所有使用该连接的QSqlQuery对象析构之后调用。如果你在removeDatabase时还有存活的QSqlQueryQt会在运行期输出警告甚至直接行为未定义。所以线程结束时先销毁query对象再close连接最后removeDatabase顺序不能反。那主线程的默认连接能拿到子线程用吗不能。子线程里直接用QSqlDatabase::database()取默认连接本质上是拿主线程的连接跨线程使用属于上面说的风险行为。项目中我见过不少偶尔崩溃的诡异问题最后查下来都是这个原因。6.3 大批量写入的性能实测结论性能这块我给你几组我实测过的经验数据环境是SQLite本地库Qt 5.15.2 msvc2019_64写入1万条记录。逐条execINSERT不开事务耗时大约4到6秒同样逐条execINSERT但整体包在一个事务里耗时能降到0.3秒左右用prepare批次绑定事务的组合进一步降到0.2秒上下。也就是说最大的收益来自事务而不是批处理本身。个中道理不难理解不开事务时每条INSERT都要触发一次磁盘同步1万次fsync的开销是巨大的事务把同步点拖到commit一次完成代价自然小一个量级。所以批量导入场景的黄金组合是prepare()一次循环里bindValue()exec()外层包事务。execBatch()在代码上更简洁但性能收益并不会比prepare循环exec事务更明显我的习惯是数据源本身是QVector或QList时用execBatch是流式读取时用循环exec。还有一个查询侧的优化同一条SELECT反复执行时把prepare()提出循环只在循环里改绑定值。有的驱动对预处理语句有缓存重复prepare同名SQL可能命中缓存也可能不会但把prepare提出循环至少能避免无谓的SQL文本解析。另外SQLite外键约束默认是关闭的如果你用了外键记得每次连接建立后执行PRAGMA foreign_keys ON;不然你会发现删除父表记录时子表数据被悄悄保留查半天查不出原因。最后分享一个使用习惯上的心得。QSqlQuery虽然是Qt SQL模块里最底层的执行类但它的定位恰恰是够用且可控——CRUD、批量、事务、动态列解析它都能胜任完全没有必要为了用上ORM框架而引入几十万行的依赖。如果你只是要在界面上展示一个查询结果列表把数据集交给QSqlQueryModel更省事如果你需要逐行加工、复杂参数绑定、精确控制事务边界那么老老实实拿QSqlQuery写清楚每一条SQL反而比套一层对象关系映射更容易排查问题。我做了几年Qt数据库相关的活最深的体会是SQL本身才是业务里最有价值的部分QSqlQuery只是把它安全、高效地送到数据库执行的那双手。把这双手用熟了再往上层去看QSqlTableModel、QSqlRelationalTableModel都会顺畅很多。