去年做一个行情采集服务时我被性能问题狠狠教育了一顿Python脚本连MySQL入库单线程QPS上不去改成生产者消费者模型也不达标最后被逼着用C写了个直连MySQL的入库模块同样一台机器吞吐直接翻了三倍。从那以后我系统把C/C访问数据库这条技术栈摸了一遍从环境搭建、API调用链到事务、预处理、踩坑排查都有了不少体会。这篇文章就是这些经验汇总核心就讲一件事用C/C语言访问MySQL怎么从能连上走到写得稳、跑得快。适合已经有C/C基础语法、想绕过脚本层直接操作数据库的开发者也适合做嵌入式、网关这类资源受限设备的同学参考。1. 用C/C访问MySQL这个选择是在什么场景下成立的1.1 先泼盆冷水不是所有项目都该这么干网上有很多C/C连MySQL的教程但很少有人说清楚到底该不该用。我个人的结论是绝大多数的业务开发不应该用C/C直接访问数据库。原因很简单——开发效率太低了。你用Python写一个INSERT带参数校验可能三行代码用C API你要初始化结构体、拼SQL、处理错误码、注意内存释放同样功能至少三十行。而且C/C没有垃圾回收结果集处理不好就是内存泄漏排查起来比业务逻辑本身还费时间。那为什么还要写这篇文章因为确实存在一些场景C/C是绕不开甚至更优选的选择。比如我做的行情采集单条数据量小、频率极高、字段固定这种场景下脚本语言的解释开销和数据库驱动层的数据拷贝会明显放大C/C直连的收益非常直观。再比如嵌入式设备和网关内存只有几十上百MB装一个Python解释器或JVM根本不现实但MySQL的C客户端库本身是很轻量的。还有游戏服务器、交易系统这类对延迟敏感的基础组件C/C访问数据库至今仍是常用方案。1.2 适合用C/C访问数据库的典型场景我把这些场景整理了一下你对照自己的项目判断场景为什么适合C/C典型例子高频数据采集入库降低单条写入开销提升吞吐行情数据、物联网传感器上报嵌入式/边缘设备运行环境资源受限C库足够轻量工业网关、路由器上的数据上报延迟敏感的基础组件省去脚本层解释与类型转换开销交易系统、游戏服务器GM工具需要复用存量C/C代码已有的通信、解析模块直接用协议网关、文件处理后再落库反过来如果你只是做个Web后端或者管理后台老老实实用脚本语言加ORM别跟自己过不去。2. 环境准备从MySQL安装到VS Code链接库配置2.1 先有一个能正常跑的MySQL服务端不管你是Windows还是Linux先把MySQL服务端装好、跑起来。Windows下推荐用官方安装包8.0以上版本注意选择Server onlyDevelopment Components也一起勾上后面要用到按向导走流程即可。Linux下分发行版操作略有差异Ubuntu/Debian用sudo apt install mysql-serverCentOS/RHEL用sudo yum install mysql-server或sudo dnf install mysql-server。装完之后启动服务用命令行确认一下# Windows net start mysql mysql -uroot -p # Linux sudo systemctl start mysql mysql -uroot -p如果遇到服务无法启动之类的问题先去看错误日志不要急着重装。我碰到过一次MySQL 8在Windows下启动失败日志里报的是数据目录权限问题清理掉旧的data目录重新初始化就好mysqld --initialize-insecure --console提示--initialize-insecure会生成一个密码为空的root账号仅限本地开发环境这么干生产环境务必用安全初始化方式并设置强密码。2.2 获取开发库头文件和链接库才是关键连接MySQL服务端有两种编程接口一种是官方C API也就是libmysqlclient另一种是MySQL Connector/C。本文讲的是C API因为它稳定、轻量、C和C都能直接用也是很多高级封装的基础。Windows下MySQL安装目录里通常自带include和lib两个文件夹里面有mysql.h、libmysql.lib、libmysql.dll这些关键文件。如果你的安装包没有Development Components那你只有客户端没有头文件后面编译过不了。Linux下需要单独装开发包# Ubuntu/Debian sudo apt install libmysqlclient-dev # CentOS/RHEL sudo yum install mysql-devel装完后可以用mysql_config这个命令确认环境mysql_config --cflags mysql_config --libs它输出的编译参数可以直接复用。比如我的机器输出类似-I/usr/include/mysql -L/usr/lib/x86_64-linux-gnu -lmysqlclient2.3 VS Code MinGW-w64 下配置C/C连接环境很多朋友在VS Code里写C/C配环境时容易卡住。这里给一套我验证过的最小配置。首先VS Code装好C/C扩展插件编译器用MinGW-w64。MinGW-w64安装完记得把bin目录加到系统PATH命令行里执行gcc --version能输出版本即可。然后在你的项目目录下建.vscode/tasks.json核心是编译参数里要带上MySQL头文件路径和库路径{ version: 2.0.0, tasks: [ { label: build mysql demo, type: cppbuild, command: gcc, args: [ -g, demo.c, -o, demo.exe, -I, C:/Program Files/MySQL/MySQL Server 8.0/include, -L, C:/Program Files/MySQL/MySQL Server 8.0/lib, -lmysql ], group: build } ] }再配.vscode/c_cpp_properties.json让编辑器能识别头文件、不报红{ configurations: [ { name: Win64, includePath: [ ${workspaceFolder}, C:/Program Files/MySQL/MySQL Server 8.0/include ], defines: [], compilerPath: C:/mingw64/bin/gcc.exe } ], version: 4 }配置完成后编译通过只是第一步运行时还有一个大坑Windows下程序需要libmysql.dll在PATH路径里或者直接拷贝到exe所在目录。我建议直接拷贝省得每次开环境变量。Linux下编译就简单多了直接gcc demo.c -o demo $(mysql_config --cflags --libs)2.4 链接阶段常见的坑MinGW-w64链接libmysql.lib时如果你下载的是32位版编译器而MySQL库是64位会报难以理解的链接错误建议统一采用64位版。另外Visual Studio系的.lib与MinGW的链接格式兼容性时好时坏如果遇到诡异的undefined reference可以换用官方发布包里的libmysql.dll配合动态加载方式或者改用ODBC驱动中转。3. 连接与查询API调用链从mysql_init到结果集3.1 初始化连接mysql_init 与 mysql_real_connect先看最简单的连接代码#include stdio.h #include stdlib.h #include mysql.h int main() { MYSQL *conn mysql_init(NULL); if (conn NULL) { fprintf(stderr, mysql_init failed\n); return EXIT_FAILURE; } if (mysql_real_connect(conn, 127.0.0.1, root, your_password, demo_db, 3306, NULL, 0) NULL) { fprintf(stderr, mysql_real_connect failed: %s\n, mysql_error(conn)); mysql_close(conn); return EXIT_FAILURE; } printf(connect ok\n); mysql_close(conn); return 0; }mysql_init用于分配或初始化一个MYSQL对象传NULL会帮你分配。mysql_real_connect的参数分别是主机地址、用户名、密码、数据库名、端口、socket路径Windows传NULL、客户端标志。其中端口如果填0会自动用默认的3306。客户端标志位client_flag平时传0就够用但如果你要执行多语句或做特殊处理可以组合CLIENT_MULTI_STATEMENTS等选项。这里注意一个细节mysql_real_connect返回的不是BOOL而是MYSQL*指针判断是否成功要看返回值是否为NULL。3.2 执行SQLmysql_query 与 mysql_real_query连接建立后执行SQL常用两个函数mysql_query和mysql_real_query。mysql_query(conn, sql)接受以\0结尾的字符串。mysql_real_query(conn, sql, length)多一个长度参数它能处理包含二进制数据的查询语句。日常写CRUD用mysql_query即可但是插入含\0的二进制字段或者特殊字符时必须用mysql_real_query或者做好转义。返回值都是0表示成功非0表示出错。出错后立刻用mysql_error(conn)取错误信息用mysql_errno(conn)取错误码这是排查问题的第一手线索。3.3 结果集读取mysql_store_result 与 mysql_use_result执行SELECT之后要用mysql_store_result或mysql_use_result取结果集。这两个函数差别很大特性mysql_store_resultmysql_use_result读取方式一次性把结果拉到客户端内存逐行从服务端读取内存占用高整个结果集都在内存中低每次只缓存一行使用限制无特殊限制必须读完所有行才能执行新查询适用场景绝大多数业务查询超大结果集遍历mysql_store_result最简单拿回MYSQL_RES*后用mysql_num_fields获取列数用mysql_fetch_row逐行取出字符串数组MYSQL_RES *res mysql_store_result(conn); if (res NULL) { fprintf(stderr, store result failed: %s\n, mysql_error(conn)); } else { int num_fields mysql_num_fields(res); MYSQL_ROW row; while ((row mysql_fetch_row(res)) ! NULL) { for (int i 0; i num_fields; i) { printf(%s\t, row[i] ? row[i] : NULL); } printf(\n); } mysql_free_result(res); }注意mysql_fetch_row返回的每个字段都是char*如果字段值为SQL NULL那这个指针为NULL不能直接printf要先判断再处理。用mysql_use_result时有个硬约束读取不完整就执行新查询会导致连接状态错乱。所以用完一定要把剩余的行读完或者干脆mysql_free_result终止。顺便说一句SELECT结果集默认按存储顺序返回需要稳定排序时在SQL里写ORDER BY字段不要在C代码里自己排序。这也是很多新手会踩的坑——把几千行结果拉回内存做排序白白浪费内存和CPU。3.4 字段信息与元数据处理除了数据本身有时候你还需要知道列名、列类型这就用mysql_fetch_fieldsMYSQL_FIELD *fields mysql_fetch_fields(res); for (int i 0; i num_fields; i) { printf(column %d: %s, type: %d\n, i, fields[i].name, fields[i].type); }这在做通用查询工具、动态生成结构体映射时很实用。我知道有人想写通用的C ORM但说实话C API的字段类型映射到C类型是个不小的工程建议先从小范围工具做起。4. CRUD实战与中文乱码排查4.1 建表与会话准备假设我们要管理一个简单的用户表CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, age INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个关键点DEFAULT CHARSETutf8mb4要建表时就定好否则后续中文内容容易出现乱码或字符集转换问题。C代码里连接建立后立刻指定客户端字符集if (mysql_set_character_set(conn, utf8mb4) ! 0) { fprintf(stderr, set charset failed: %s\n, mysql_error(conn)); }4.2 INSERT、UPDATE、DELETE 实操插入代码带转义字符串的完整写法char sql[1024]; char escaped_name[128]; mysql_real_escape_string(conn, escaped_name, 张三, strlen(张三)); snprintf(sql, sizeof(sql), INSERT INTO users(name, age) VALUES(%s, %d), escaped_name, 28); if (mysql_query(conn, sql) ! 0) { fprintf(stderr, insert failed: %s\n, mysql_error(conn)); } else { my_ulonglong affected mysql_affected_rows(conn); unsigned long long new_id mysql_insert_id(conn); printf(affected rows: %llu, new id: %llu\n, affected, new_id); }mysql_real_escape_string是你手工拼接SQL时必须要过的坎别偷懒用snprintf直接拼用户输入SQL注入会找上门。插入成功后mysql_insert_id可以拿到自增ID这在关联业务里非常常用。mysql_affected_rows返回影响行数更新和删除后靠它判断操作是否命中数据。UPDATE和DELETE流程一样只是SQL语句不同mysql_query(conn, UPDATE users SET age 29 WHERE name 张三); printf(updated rows: %llu\n, mysql_affected_rows(conn)); mysql_query(conn, DELETE FROM users WHERE id 10086); printf(deleted rows: %llu\n, mysql_affected_rows(conn));4.3 中文乱码问题不止SET NAMES一条中文乱码是C/C连MySQL被问得最多的问题之一。很多人以为执行一句SET NAMES utf8mb4就完事了但实测下来会发现乱码的根子往往在三个地方缺一不可第一数据库、表的字符集。上面建表的DEFAULT CHARSETutf8mb4就是解决这层。第二连接的字符集。mysql_set_character_set可以解决等价于执行SET NAMES utf8mb4。第三C/C源码文件本身的编码。如果你的源码文件是GBK保存的而数据库是utf8mb4字符串字面量被直接写进SQL时依然是GBK字节流这时候设置连接字符集也没用。我遇到过最隐蔽的情况同事在Windows上用VS Code默认GBK保存的源码Linux编译运行后插入的名字乱码了折腾很久才发现是源码文件编码不一致。这种情况下统一把源文件转成UTF-8或者用u8张三这种显式UTF-8字面量C11起支持来规避。4.4 字段为NULL的判断C API读取空值不是返回字符串NULL而是返回NULL指针。直接strcmp(row[i], NULL)是错的会把数据里的空字段误判。正确判断if (row[i] NULL) { printf(NULL); } else { printf(%s, row[i]); }如果你需要区分SQL NULL和字符串NULL只能靠这个方式没有捷径。5. 实际开发中踩过的坑与完整排查链路5.1 链接库找不到编译过了运行却报错典型的异常是VS Code里编译成功一运行提示找不到libmysql.dll或者进程退出状态 0xc0000135。排查思路很简单分三步走看系统PATH里有没有MySQL的bin目录里面通常有libmysql.dll。没加就加上加完重启终端。最省事的办法把libmysql.dll拷贝到exe旁边程序启动时会优先加载同目录DLL。确认拷贝的DLL是64位版本如果你的exe是64位而DLL是32位加载会直接失败。Linux下对应的问题是error while loading shared libraries: libmysqlclient.so.XX可以用ldd 可执行文件查看依赖缺失sudo ldconfig更新缓存或检查/usr/lib下的软链接是否存在。5.2 MySQL 8认证插件导致连接失败MySQL 8.0开始默认用户认证插件是caching_sha2_password如果你用的是旧版客户端库尤其5.7时期遗留的连接时会报类似Authentication plugin caching_sha2_password cannot be loaded的错误。我的处理建议优先升级你的C API开发库到与MySQL 8匹配的版本。因为caching_sha2_password在完整版TLS下连接时要求客户端支持RSA公钥交换旧库往往不具备。如果你不想升级客户端库可以临时把用户改成mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY your_password;但这只是临时方案生产环境不建议长期使用旧的认证插件。5.3 SSL连接错误ERROR 2026 之类的处理如果你在mysql_real_connect里附加了CLIENT_SSL标志但服务端SSL配置没就绪会报SSL连接错误。排查链路先登录MySQL命令行执执行SHOW VARIABLES LIKE have_ssl;确认服务端SSL是否开启再检查客户端是否指定了CA证书路径。如果你的数据链路本身在受信内网可以不启用SSL直接去掉CLIENT_SSL标志如果必须加密那要在mysql_ssl_set里正确配置ca、cert、key的路径。5.4 存储过程返回多结果集一个隐蔽的大坑C API调用存储过程时如果存储过程内部有多个SELECT则客户端会收到多个结果集。很多人只处理了第一个然后便拿到Commands out of sync的错误。完整处理要循环取完所有结果集mysql_query(conn, CALL my_procedure(100)); do { MYSQL_RES *res mysql_store_result(conn); if (res ! NULL) { // 处理结果集 mysql_free_result(res); } else { // 没有结果集但可能有影响行数 if (mysql_field_count(conn) 0) { printf(affected rows: %llu\n, mysql_affected_rows(conn)); } } } while (mysql_next_result(conn) 0);这个坑在我做库存统计报表时遇到过存储过程里先用SELECT查汇总再查明细如果只取第一段结果看不出问题加第二段就翻车了。5.5 连接泄漏与内存泄漏C API连接对象是MYSQL*不少人在分支里提前返回忘了mysql_close导致连接数缓慢爬升最后数据库Too many connections。排查连接泄漏其实有现成方法在MySQL里执行SHOW PROCESSLIST;看有多少个来自你程序地址的连接处于Sleep状态数量只增不减基本就是泄漏了。内存方面mysql_store_result拿到的MYSQL_RES*必须mysql_free_result释放。我见过线上程序长期跑下来内存翻倍最后定位到就是某条SQL的查询结果一直没释放。在C里可以写RAII包装类构造函数里初始化析构函数里统一释放能省很多心。6. 从能用走向可靠事务、预处理与线程安全6.1 手动事务与autocommit如果你的业务存在多步写入比如扣库存 加订单必须用事务保证原子性。C API控制事务最简单的做法是关闭自动提交然后手动提交或回滚mysql_autocommit(conn, 0); // 关闭自动提交 if (mysql_query(conn, UPDATE stock SET count count - 1 WHERE id 1) ! 0 || mysql_query(conn, INSERT INTO orders(user_id, goods_id) VALUES(100, 1)) ! 0) { mysql_rollback(conn); } else { mysql_commit(conn); }我习惯用mysql_autocommit(conn, 0)而不是手动执行BEGIN因为mysql_commit和mysql_rollback会直观映射到提交与回滚少一层语义混淆。注意事务提交后不要忘了视情况恢复mysql_autocommit(conn, 1)否则连接复用时老的自动提交状态会影响后续逻辑。6.2 预处理语句防注入也提性能手工snprintf拼SQL始终有转义不彻底的风险更稳妥的是预处理语句。C API的预处理流程有点繁琐但值得掌握MYSQL_STMT *stmt mysql_stmt_init(conn); const char *sql INSERT INTO users(name, age) VALUES(?, ?); if (mysql_stmt_prepare(stmt, sql, (unsigned long)strlen(sql)) ! 0) { fprintf(stderr, prepare failed: %s\n, mysql_stmt_error(stmt)); return EXIT_FAILURE; } MYSQL_BIND param[2]; memset(param, 0, sizeof(param)); char name[64] 李四; int age 30; param[0].buffer_type MYSQL_TYPE_STRING; param[0].buffer name; param[0].buffer_length strlen(name); param[1].buffer_type MYSQL_TYPE_LONG; param[1].buffer age; mysql_stmt_bind_param(stmt, param); if (mysql_stmt_execute(stmt) ! 0) { fprintf(stderr, execute failed: %s\n, mysql_stmt_error(stmt)); } mysql_stmt_close(stmt);预处理的好处有两条一是参数和SQL语句分离你不需要手动转义注入风险大幅下降二是对于重复执行的同构SQL服务端可以缓存执行计划性能优于每次都重新解析。批量插入时复用同一个stmt反复绑定参数执行比每行重新拼SQL快不少。6.3 线程安全与连接复用MySQL Connector/C的线程安全模型是同一MYSQL*连接在同一时刻只能被一个线程使用。你有没有见过多线程程序偶尔报Commands out of sync或段错误十有八九就是多个线程共享了同一个连接。解决办法是每个线程持有独立连接或者用一个线程安全的连接池管理连接。最简陋的池子就是互斥锁 vectorMYSQL*取连接时分一个出来用完放回去超过最大连接数就阻塞等待。C API本身还提供了mysql_thread_init和mysql_thread_end在子线程里使用连接前需要调用前者初始化线程相关资源这一点在新版库中可能自动处理但老版本库必须要手动调。void *worker(void *arg) { mysql_thread_init(); MYSQL *conn mysql_init(NULL); // 连接并使用 mysql_close(conn); mysql_thread_end(); return NULL; }6.4 锁与隔离级别一次说清数据库事务会涉及锁的概念。锁从粒度分有全局锁、表级锁、行级锁从模式分有共享锁、排他锁从思想分就是乐观锁和悲观锁。C API本身不直接操作锁但你要理解事务隔离级别下锁的代价。MySQL默认的REPEATABLE READ隔离级别在SELECT时会用MVCC不阻塞普通读但UPDATE、DELETE时会加行锁可能产生锁等待。排查锁等待的办法是执行SHOW ENGINE INNODB STATUS;或者查performance_schema里的等待事件。如果你的程序在低并发下偶尔出现事务超时优先查锁等待而不是盲目加索引。7. 性能优化让数据库访问再快一步7.1 索引先于代码优化的第一板斧C/C连接MySQL的性能问题很多时候不是C代码的锅而是SQL本身没走索引。我见过有人辛辛苦苦优化半天代码最后EXPLAIN SELECT ...一看全是typeALL全表扫描。建索引的原则不复杂查询的WHERE条件字段、ORDER BY字段、JOIN关联字段都是索引候选。但别往极端建索引不是越多越好写入频繁的表索引过多会拖慢INSERT和UPDATE。比如我们的用户表经常按age查可以建一个CREATE INDEX idx_users_age ON users(age);然后配合EXPLAIN验证确保优化器用上了索引。7.2 连接池高并发下必须考虑的方案纯C直连MySQL每次新建连接都要走TCP握手认证高并发下开销不小。我压测过简单查询时连接建立时间可能占整个请求时延的30%以上。所以高并发场景建议实现连接池。起步阶段可以写一个极简的池子初始化N条连接放到队列取用加锁归还解锁连接空闲超时则断开重建。复杂度不高但能把单次请求的时延降一个台阶。7.3 store_result 与 use_result 的选择再谈很多教程只讲mysql_store_result因为好用。但如果你的SELECT结果集有几百万行一次store_result会把几GB数据全拉进客户端内存直接OOM。这时候要改用mysql_use_result配合逐行处理同时注意必须读完所有行的约束。一句话总结小结果集用store大结果集用use中间档位依据内存余量判断。7.4 批量插入常见的高吞吐玩法高频写入场景下逐条INSERT永远不是最优解。我实测下来把多条记录合并成一条批量INSERT或者用预处理语句循环绑定参数再批量执行吞吐差距非常明显。批量INSERT的SQL大概是这个套路INSERT INTO users(name, age) VALUES (a, 1), (b, 2), (c, 3);拼这个SQL时用mysql_real_escape_string转义每个值然后一次mysql_query提交。批量大小建议控制在100~500条之间太大容易超出max_allowed_packet反而报错。我个人在实际项目里的体会是C/C访问MySQL这套东西最难的不是API本身而是把环境、编码、内存、事务这些基础功课做扎实。只要在开发初期解决掉字符集和内存释放这两个问题后续基本就是按部就班的活。最后再分享一个小技巧调试阶段可以打开MySQL通用日志在my.cnf或my.ini里临时开启general_logON你就能看到C程序真实发给数据库的SQL语句排查乱码和拼接问题会直观很多。
阅读完成 · 觉得有帮助?