简介:这份资源面向具备一定C++基础、希望快速打通MySQL数据库访问链路的开发者,围绕MySQL ODBC接口展开,演示如何在Windows环境下通过ODBC API完成数据库连接与增删改查。压缩包共26个文件,约38KB,以h头文件与cpp源文件为主,辅以dsp、dsw工程文件、rc资源脚本、ico图标及ReadMe说明,构成一个可直接用Visual Studio打开的完整示例工程。代码覆盖ODBC环境与连接句柄初始化、SQLConnect建立连接、SQLExecDirect执行语句、SQLFetch遍历结果集以及断开连接释放资源等关键环节,并包含自定义列表控件展示查询结果、添加与编辑记录等功能模块,便于对照理解各步骤的调用顺序与参数含义。目前已有1024人学习下载,适合作为C++操作MySQL的入门练手项目,也可作为课程设计或小型管理系统的数据库访问层参考,帮助读者在较短时间内掌握ODBC编程的基本套路与常见排错思路。
1. 用 C++ 连 MySQL,为什么我最后选了 ODBC 而不是手写协议
前阵子帮一个做工业数据采集的朋友改一套老系统,需求很朴素:把产线上的检测数据实时写进 MySQL,再定时读回来做报表。听起来简单,但现场环境是 Windows 工控机,代码是十几年前的 C++,编译链固定,不允许引入重型框架。我一开始想用 MySQL 官方 C API,结果发现现场那台机器上装的是另一套数据库客户端,版本对不上,头文件都找不到。折腾半天,最后用 ODBC 把这事干成了——一套接口,换驱动就能连不同的库,现场那台机器上现成的驱动直接能用。
这就是 ODBC 的价值:它不绑定某一家数据库,C++ 通过统一的 API 调用,底层由驱动去翻译成具体数据库的协议。你写一次代码,MySQL、SQL Server、Oracle 都能连,只要换驱动和连接串。对于需要在多种数据库之间切换、或者部署环境不可控的项目,这个特性非常实用。这篇文章就围绕「MySQL ODBC + C++」这条链路,把环境搭建、连接封装、增删改查、批量写入和踩坑排查讲清楚,代码可以直接抄去改。
2. 环境准备:驱动、头文件、连接串三件套
2.1 装对驱动,别装成 32 位的
ODBC 连接 MySQL 靠的是 MySQL 官方提供的 Connector/ODBC 驱动。这里第一个坑就是位数问题:你的 C++ 程序编译成 64 位,就必须装 64 位的驱动;编译成 32 位,就装 32 位。两者不匹配时,程序运行会报「找不到数据源名称,且未指定默认驱动程序」,而且这个报错不会告诉你位数不对,只会让你怀疑人生。
Windows 上装完驱动后,去「ODBC 数据源管理器」里能看到两个标签页:用户 DSN 和系统 DSN。用户 DSN 只对当前登录用户可见,系统 DSN 对所有用户可见。如果是做成服务跑在后台,建议配系统 DSN,否则服务账户登录后找不到你配的用户 DSN。
Linux 上则是装 unixODBC 加 MySQL 的 ODBC 驱动,配置文件在/etc/odbcinst.ini和/etc/odbc.ini。用odbcinst -j可以查看当前配置路径,用isql -v 数据源名可以测试连接是否通。这一步先测通,再写代码,能省掉大量排查时间。
2.2 头文件与链接库
Windows 下 ODBC 的头文件是<windows.h>加<sql.h>、<sqlext.h>,链接odbc32.lib。Linux 下头文件在/usr/include下,链接-lodbc。一个最小可编译的骨架长这样:
// odbc_basic.cpp #include <windows.h> // Linux 下不需要 #include <sql.h> #include <sqlext.h> #include <iostream> int main() { SQLHENV env; SQLHDBC dbc; // 分配环境句柄 SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env); // 声明使用 ODBC 3.x 版本 SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, (void*)SQL_OV_ODBC3, 0); // 分配连接句柄 SQLAllocHandle(SQL_HANDLE_DBC, env, &dbc); // 连接串:DSN 方式或 DSN-less 方式 SQLCHAR connStr[] = "DRIVER={MySQL ODBC 8.0 Driver};SERVER=127.0.0.1;PORT=3306;DATABASE=testdb;UID=root;PWD=123456;"; SQLCHAR outStr[1024]; SQLSMALLINT outLen; SQLRETURN ret = SQLDriverConnect(dbc, NULL, connStr, SQL_NTS, outStr, sizeof(outStr), &outLen, SQL_DRIVER_COMPLETE); if (ret != SQL_SUCCESS && ret != SQL_SUCCESS_WITH_INFO) { std::cerr << "连接失败" << std::endl; return -1; } std::cout << "连接成功" << std::endl; SQLDisconnect(dbc); SQLFreeHandle(SQL_HANDLE_DBC, dbc); SQLFreeHandle(SQL_HANDLE_ENV, env); return 0; }这段代码里几个关键点:SQLSetEnvAttr必须在使用 ODBC 3.x 特性前调用,否则默认按 2.x 行为走,某些函数会返回未定义结果。SQLDriverConnect的最后一个参数用SQL_DRIVER_COMPLETE,表示如果连接串信息不全,驱动可以弹窗补全——但在服务里跑要改成SQL_DRIVER_NOPROMPT,否则会卡住等输入。连接串里DRIVER={...}的名字必须和系统里注册的驱动名完全一致,大小写和空格都不能错,这个后面避坑章节还会细说。
2.3 连接串的两种写法
DSN 方式需要先在系统里配好数据源,连接串写DSN=myTest;UID=root;PWD=xxx;。好处是配置和代码分离,换环境只改 DSN。DSN-less 方式把驱动和地址全写在连接串里,部署时不用配数据源,适合容器化或临时环境。我一般生产用 DSN,开发用 DSN-less,省得每台机器都去配。
3. 核心模块拆解:句柄、执行、取结果的三层结构
3.1 句柄体系:环境、连接、语句
ODBC 的 API 是围绕句柄设计的,分三层:环境句柄(SQLHENV)管理全局状态,连接句柄(SQLHDBC)代表一个数据库连接,语句句柄(SQLHSTMT)代表一条 SQL 的执行上下文。所有操作都是「分配句柄 → 设置属性 → 执行 → 取结果 → 释放句柄」这个流程。
理解这个层级很重要,因为资源释放必须自下而上:先释放语句句柄,再断开连接,最后释放环境句柄。顺序错了会内存泄漏,而且泄漏得很隐蔽,跑几天才崩。我习惯把这三层封成一个 RAII 类,构造时分配,析构时释放,避免手动管理出错。
3.2 执行 SQL 与参数绑定
直接拼接 SQL 字符串是新手最容易犯的错,既有注入风险,又有转义问题。正确做法是用参数绑定:
// 参数化插入,避免拼接和注入 SQLHSTMT stmt; SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt); SQLCHAR* sql = (SQLCHAR*)"INSERT INTO sensor_data (device_id, value, ts) VALUES (?, ?, ?)"; SQLPrepare(stmt, sql, SQL_NTS); int deviceId = 1001; double value = 23.5; SQLCHAR ts[] = "2024-01-15 10:30:00"; // 按位置绑定参数,注意类型要匹配 SQLLEN cbDeviceId = 0, cbValue = 0, cbTs = SQL_NTS; SQLBindParameter(stmt, 1, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER, 0, 0, &deviceId, 0, &cbDeviceId); SQLBindParameter(stmt, 2, SQL_PARAM_INPUT, SQL_C_DOUBLE, SQL_DOUBLE, 0, 0, &value, 0, &cbValue); SQLBindParameter(stmt, 3, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, 0, 0, ts, 0, &cbTs); SQLRETURN ret = SQLExecute(stmt); if (ret != SQL_SUCCESS && ret != SQL_SUCCESS_WITH_INFO) { // 取错误信息 SQLCHAR state[6], msg[256]; SQLINTEGER nativeErr; SQLSMALLINT msgLen; SQLGetDiagRec(SQL_HANDLE_STMT, stmt, 1, state, &nativeErr, msg, sizeof(msg), &msgLen); std::cerr << "执行失败: " << msg << std::endl; } SQLFreeHandle(SQL_HANDLE_STMT, stmt);SQLBindParameter的参数含义:第一个是语句句柄,第二个是参数序号(从 1 开始),第三个是输入输出方向,第四个是 C 语言类型,第五个是 SQL 类型,第六个是列大小,第七个是小数位数,第八个是变量地址,第九个是缓冲区长度,第十个是指示器(用于标记 NULL 或字符串长度)。类型不匹配是常见错误,比如把double绑成SQL_C_LONG,数据会静默截断,不报错但值不对。
3.3 取结果集:逐行读取与列绑定
查询结果的读取有两种方式:SQLBindCol预先绑定列到缓冲区,然后循环SQLFetch;或者用SQLGetData逐列取。前者效率高,适合列数固定的场景;后者灵活,适合动态列。
// 查询并逐行读取 SQLHSTMT stmt; SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt); SQLExecDirect(stmt, (SQLCHAR*)"SELECT id, device_id, value FROM sensor_data WHERE device_id = ?", SQL_NTS); int id; int deviceId; double value; SQLLEN cbId, cbDeviceId, cbValue; SQLBindCol(stmt, 1, SQL_C_LONG, &id, 0, &cbId); SQLBindCol(stmt, 2, SQL_C_LONG, &deviceId, 0, &cbDeviceId); SQLBindCol(stmt, 3, SQL_C_DOUBLE, &value, 0, &cbValue); while (SQLFetch(stmt) == SQL_SUCCESS) { // 检查 NULL:指示器为 SQL_NULL_DATA if (cbValue != SQL_NULL_DATA) { std::cout << id << " " << deviceId << " " << value << std::endl; } } SQLFreeHandle(SQL_HANDLE_STMT, stmt);SQLFetch返回SQL_NO_DATA表示结果集读完,返回SQL_SUCCESS_WITH_INFO表示有警告但数据有效。指示器变量在取到 NULL 时会被设为SQL_NULL_DATA,不检查这个直接读值会拿到脏数据。这个细节在数据质量要求高的场景里必须处理。
4. 批量写入与事务:把每秒几千条数据压进去
4.1 批量绑定:一次提交多行
工业采集场景数据量大,逐条SQLExecute效率很低,因为每次都是一次网络往返。ODBC 支持参数数组绑定,一次提交多行:
// 批量插入:参数数组方式 #define BATCH_SIZE 500 int deviceIds[BATCH_SIZE]; double values[BATCH_SIZE]; SQLLEN cbDeviceIds[BATCH_SIZE], cbValues[BATCH_SIZE]; SQLHSTMT stmt; SQLAllocHandle(SQL_HANDLE_STMT, dbc, &stmt); SQLPrepare(stmt, (SQLCHAR*)"INSERT INTO sensor_data (device_id, value) VALUES (?, ?)", SQL_NTS); // 绑定数组,最后一个参数指定数组元素个数 SQLBindParameter(stmt, 1, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER, 0, 0, deviceIds, 0, cbDeviceIds); SQLBindParameter(stmt, 2, SQL_PARAM_INPUT, SQL_C_DOUBLE, SQL_DOUBLE, 0, 0, values, 0, cbValues); SQLSetStmtAttr(stmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)BATCH_SIZE, 0); // 填充数据后一次执行 SQLExecute(stmt);SQL_ATTR_PARAMSET_SIZE告诉驱动这次要处理多少行。注意 MySQL 的 ODBC 驱动对批量插入的支持依赖rewriteBatchedStatements类似的优化,实际是否合并成一条多值 INSERT 取决于驱动版本。如果发现批量没提速,可以抓包看实际发出的 SQL 是不是还是逐条。
4.2 事务控制:手动提交的时机
ODBC 默认是自动提交模式,每条语句执行完立即生效。批量写入时应该关掉自动提交,手动控制事务边界:
// 关闭自动提交 SQLSetConnectAttr(dbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_OFF, 0); // 执行一批插入 for (int i = 0; i < batchCount; i++) { // ... 执行插入 } // 全部成功则提交,失败则回滚 if (allSuccess) { SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_COMMIT); } else { SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_ROLLBACK); } // 恢复自动提交 SQLSetConnectAttr(dbc, SQL_ATTR_AUTOCOMMIT, (SQLPOINTER)SQL_AUTOCOMMIT_ON, 0);事务粒度要权衡:太小则提交频繁,磁盘 IO 压力大;太大则回滚代价高,锁持有时间长。我一般按 500 到 1000 条一批,具体看单条大小和数据库负载。另外注意,事务未提交时如果连接断开,数据库会自动回滚,但应用层要能感知到并重试。
5. 避坑与排查:那些让我加班到凌晨的报错
5.1 报「Data source name not found」但驱动明明装了
现象:程序运行报找不到数据源,但 ODBC 管理器里能看到驱动。原因九成是位数不匹配——程序 64 位,驱动 32 位,或者反过来。Windows 上 64 位 ODBC 管理器在System32下,32 位在SysWOW64下,两个管理器看到的数据源列表是独立的。解决:确认程序编译位数,装对应位数的驱动,用对应位数的管理器配 DSN。
5.2 中文乱码:连接串少了一个参数
现象:写入的中文变成问号或乱码。原因:ODBC 默认字符集和 MySQL 表的字符集不一致。解决:连接串里加charset=utf8mb4,同时确认数据库、表、列的字符集都是 utf8mb4。如果用的是宽字符 API(SQLConnectW),还要注意 C 层字符串的编码转换。
5.3 长连接断开:MySQL 的 wait_timeout
现象:程序跑一晚上,第二天第一条 SQL 报连接已断开。原因:MySQL 默认wait_timeout是 8 小时,空闲连接会被服务端切断,但客户端不知道。解决:要么在连接串里设autoReconnect=true(不推荐,状态可能不一致),要么自己实现心跳,定时执行SELECT 1保活,要么在每次操作前检查连接状态,断了就重连。我一般用心跳加连接池,重连逻辑封装在获取连接的函数里。
5.4 SQLFetch 返回 SQL_ERROR 但没报错信息
现象:取结果时返回错误,但SQLGetDiagRec拿不到有用信息。原因:诊断信息可能挂在语句句柄上,也可能挂在连接句柄上,要按句柄类型去取。另外,如果前一个函数调用失败后没有清理诊断记录,后续取到的可能是旧信息。解决:封装一个统一的错误打印函数,按SQL_HANDLE_STMT和SQL_HANDLE_DBC分别取,并且每次操作前先清空诊断。
5.5 批量插入变慢:驱动没合并语句
现象:用了参数数组绑定,但速度没提升。原因:某些版本的 MySQL ODBC 驱动不会自动把批量参数合并成多值 INSERT,实际还是逐条发送。解决:确认驱动版本,或者改用INSERT INTO ... VALUES (...), (...), (...)手动拼多值语句(注意用参数绑定防注入),或者用LOAD DATA LOCAL INFILE做大批量导入。
6. 二次开发:把 ODBC 封装成好用的 C++ 连接层
6.1 封装思路:RAII + 连接池
裸调 ODBC API 的代码又长又容易漏释放,二次开发第一步就是封装。我一般写三个类:OdbcEnv管理环境句柄,全局一个;OdbcConnection管理连接,支持重连;OdbcStatement管理语句和参数绑定。用 RAII 保证析构时释放,用移动语义避免拷贝句柄。
连接池不是必须的,但如果你的应用是多线程的,每个线程一个连接比共享连接更安全,因为 ODBC 连接句柄不是线程安全的。连接池的实现可以用一个线程安全的队列,取连接时如果队列空就新建,归还时放回队列,同时记录创建时间,超过一定空闲时间就销毁。
6.2 一个可复用的查询函数
// 通用查询:返回 vector<map<string, string>> std::vector<std::map<std::string, std::string>> query(OdbcConnection& conn, const std::string& sql) { std::vector<std::map<std::string, std::string>> rows; SQLHSTMT stmt; SQLAllocHandle(SQL_HANDLE_STMT, conn.handle(), &stmt); if (SQLExecDirect(stmt, (SQLCHAR*)sql.c_str(), SQL_NTS) != SQL_SUCCESS) { SQLFreeHandle(SQL_HANDLE_STMT, stmt); throw std::runtime_error("query failed: " + sql); } SQLSMALLINT colCount; SQLNumResultCols(stmt, &colCount); // 取列名 std::vector<std::string> colNames(colCount); for (int i = 1; i <= colCount; i++) { SQLCHAR name[256]; SQLSMALLINT nameLen; SQLDescribeCol(stmt, i, name, sizeof(name), &nameLen, NULL, NULL, NULL, NULL); colNames[i-1] = (char*)name; } // 逐行取数据,统一转成字符串 while (SQLFetch(stmt) == SQL_SUCCESS) { std::map<std::string, std::string> row; for (int i = 1; i <= colCount; i++) { SQLCHAR buf[1024]; SQLLEN indicator; SQLGetData(stmt, i, SQL_C_CHAR, buf, sizeof(buf), &indicator); row[colNames[i-1]] = (indicator == SQL_NULL_DATA) ? "" : (char*)buf; } rows.push_back(row); } SQLFreeHandle(SQL_HANDLE_STMT, stmt); return rows; }这个函数把所有列都当字符串取,牺牲一点类型精度换通用性,适合配置表、报表这类场景。如果对性能敏感,还是用SQLBindCol绑定具体类型。注意SQLGetData取长文本时可能需要多次调用,缓冲区不够会返回SQL_SUCCESS_WITH_INFO并指示剩余长度,要循环取完。
6.3 验证方法:先 isql 再代码
改完连接层,别急着跑业务代码。先用isql命令行工具测连接串,确认驱动、地址、账号密码、字符集都对。然后写一个最小 main 函数,只做连接和SELECT 1,跑通再加查询,再加写入。每加一层就验证一次,出问题能快速定位是哪一层的错。这个习惯帮我省了无数次通宵。
6.4 一个具体技巧:用 SQLGetDiagRec 做统一错误日志
ODBC 的错误信息分散在环境、连接、语句三个句柄上,我封装了一个函数,按句柄类型循环取所有诊断记录,拼成一条完整日志。这样出问题时日志里能看到错误码、SQLSTATE、驱动返回的原始消息,不用再去猜。从那以后我每次封装新的数据库访问层,都强制先把错误日志走一遍,确认能打出有用信息再写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取