如果你用 Drogon 做过带数据库的项目,多少会碰到这种哭笑不得的瞬间:cmake 阶段还好好的,跑到链接期突然甩给你一堆 undefined reference,或者编译过了,启动后连一次 SQL 查询都扛不住直接段错误。我最近在一台 Debian 12 的部署机上处理 Drogon 的 MySQL 支持,折腾到最后才确认,根因就是 libmariadb-dev 和 libmysqlclient-dev 这两个开发库同时存在于系统里,把/usr/include、/usr/lib、pkg-config 文件这几块地盘搅成了一锅粥。
这两套开发库单独拿出来都能正常工作,问题在于它们都想提供“MySQL 客户端 C API”,而且互相不觉得对方碍事。一旦同时装上,到底是谁的mysql.h被编译器看到、谁的libmysqlclient.so软链占了那个公开文件名,完全取决于安装顺序和 PATH 里的工具链顺序。Drogon 这种用 CMake 自动探测依赖的项目,就很容易捡了一套“头文件是 A、链接库是 B”的残缺搭配。这篇文章不准备只丢一个补丁给你,而是把这条冲突链路的排查过程和补丁设计思路完整过一遍。无论你是自己编译 Drogon ORM,还是被 CI 环境里的依赖问题折腾到怀疑人生,这套处理方式都值得直接抄走。
1. 先理清 Drogon 到底依赖的是“谁家的 MySQL 客户端”
Drogon 本身的 ORM 模块是可选的。如果打开BUILD_MYSQL,它底层需要的就是一套完整的 MySQL C API:mysql_init、mysql_real_connect、mysql_query、mysql_store_result这一串函数。这套 API 在生态里有两大实现,一个是我们常说的 MySQL 官方客户端库 libmysqlclient,另一个是 MariaDB Connector/C,也就是 libmariadb。两者在协议层和函数签名上高度兼容,很多为 MySQL 官方客户端写的 C 代码,换个头文件目录、改一下链接库名就能直接跑。
1.1 两套开发库在 Debian 系下的文件布局
Debian、Ubuntu 以及它们的衍生发行版,对这两个开发库的安装路径分得很清楚,具体到文件层面甚至可以说是“井水不犯河水”:
| 项目 | libmysqlclient-dev | libmariadb-dev |
|---|---|---|
| 头文件目录 | /usr/include/mysql | /usr/include/mariadb |
| 开发库文件名 | /usr/lib/x86_64-linux-gnu/libmysqlclient.so | /usr/lib/x86_64-linux-gnu/libmariadb.so |
| 运行时 SONAME 示例 | libmysqlclient.so.21 | libmariadb.so.3 |
| pkg-config 文件 | mysqlclient.pc | mariadb.pc |
| 命令行探测工具 | mysql_config | mariadb_config |
问题也跟着清晰起来了:两套实现的头文件目录、库文件名、pkg-config 文件名本来都不同,理论上能共存。但生态里还存在着“兼容层”的概念,一些发行版会额外提供 libmariadb-dev-compat 之类的包,在里面创建一堆指向 MariaDB 的符号链接,包括/usr/lib/x86_64-linux-gnu/libmysqlclient.so -> libmariadb.so,甚至把/usr/include/mysql下的头文件也链接过去。环境因此变得真假难辨。
1.2 文件、软链、探测工具三个踩踏点
libmysqlclient.so这个文件名是整个冲突里最微妙的点。它是 MySQL 生态里的“公共名称”,MySQL 官方库用它,MariaDB 的兼容层也想来占它。当你先后装了两个包,这个符号链接到底指向谁,基本取决于谁能成功覆盖谁。结果就是:Drogon 的 CMake 以为自己在链 MySQL 官方客户端,实际上链到了 MariaDB Connector/C,或者反过来。
第二个踩踏点是/usr/include/mysql目录下的mysql.h。在这个目录存在的情况下,很多 CMake 探测逻辑会优先去里面find_path(mysql/mysql.h)。但这里的mysql.h到底来自哪家,完全看系统被装成什么样。版本不同、结构体定义不同,一旦编译器和链接库拿到的不是同一套,后面就是连环坑。
第三个踩踏点是命令行探测工具。CMake 里常见的一种做法是find_program(MYSQL_CONFIG mysql_config),然后直接执行mysql_config --cflags --libs。这个命令在环境混乱时会返回哪一家的参数?取决于 PATH 搜索顺序和/usr/bin/mysql_config这个软链指向谁。命运主动权完全不在你手里。
1.3 为什么 Drogon 恰好容易被这套逻辑砸中
Drogon 的构建系统不是总走同一条探测路径。不同版本的 Drogon,CMake 逻辑差异不小,有的依赖FindMySQL.cmake,有的直接用pkg_check_modules,还有的靠mysql_config。这套多路径探测逻辑在干净环境里没有问题,但一旦遇到两个开发库共存,每一种探测方式都可能抓到不同来源的结果。
最典型的情况是:CMake 在/usr/include/mysql里找到了头文件,又在/usr/lib/x86_64-linux-gnu里找到了libmysqlclient.so,看起来万事大吉。可这个头文件是 MariaDB 的,库里也是 MariaDB 的,但 Drogon 内部为了兼容 MySQL 8 的新指纹认证,按MYSQL_VERSION_ID走了 MySQL 官方分支,最后编译出来一个“四不像”的目标文件。这类问题出现得毫无规律,所以必须回到源头去处理。
2. 冲突现场还原:三个阶段的翻车方式和典型报错
这次排查里,我特意准备了三台几乎一样的构建机,试图把冲突的典型表现逐一复现出来。下面这些报错不是猜的,是真实折腾出来的结果,按编译期、链接期、运行期三个阶段拆开看,非常直观。
2.1 编译期:头文件明明在,就是找不到
冲突最常见的第一声警报,是编译期直接丢一句:
fatal error: mysql.h: No such file or directory #include <mysql.h> ^~~~~~~~~ compilation terminated.这种报错常出现在 CMake 已经成功找到了 MySQL 相关依赖之后。原因其实很简单:CMake 探测时用的 include 路径可能是/usr/include/mysql,但那个目录里的mysql.h已经被某些兼容包替换或软链到别处了,或者 CMake 最终只把/usr/include/mariadb加入了编译参数,而源码里写的是#include <mysql/mysql.h>,在/usr/include/mariadb下根本不存在mysql/子目录。
如果两个路径同时被塞进编译参数,报错会更让人摸不着头脑。它不一定是“找不到文件”,而是各种conflicting types for 'mysql_init'、redefinition of typedef、expected 'struct MYSQL *' but argument is of type 'struct MYSQL *'。一个函数声明出现了两个不同版本,编译器被彻底搞晕。遇到这类“自相矛盾”的报错,基本可以断定是两套头文件同时出现在预处理器搜索路径里了。
2.2 链接期:libmysqlclient 符号错乱与 undefined reference
编译期蒙混过关之后,链接期也可能原地爆炸。比如:
/usr/bin/ld: cannot find -lmysqlclient明明libmysqlclient.so文件就在系统里,链接器却说找不到,十有八九是那个软链断了,或者软链指向的目标在链接器搜索路径之外。还有一种更隐蔽的情况:链接器找到了库,但报出一堆:
undefined reference to `mysql_set_character_set' undefined reference to `mariadb_drvr_lock'这种未定义符号,说明程序在编译期用了某家的头文件,声明了某些函数,可链接到的库里却没有对应实现。有些是 MariaDB 私有扩展,有些是 MySQL 8 新加的 API,两边符号集并不完全一致。想靠同时加-lmysqlclient -lmariadb来“双保险”是行不通的,两个库都有各自的初始化逻辑和状态机,混着链只会让问题更复杂。
2.3 运行期:SONAME 不匹配带来的隐性段错误
比编译期和链接期更阴险的是运行期。程序终于编译出来了,启动正常,一执行 SQL 查询就段错误,而且崩溃位置完全没有规律。这类问题几乎都是“头文件一个版本、运行库另一个版本”造成的 ABI 错配。
C API 暴露给调用方的结构体,比如MYSQL、MYSQL_RES、MYSQL_BIND,在两套实现里的内部字段排列和大小并不完全一致。编译器按 MariaDB 头文件里声明的大小给结构体分配内存和计算偏移,运行时却把这份数据交给了 MySQL 官方库去处理,或者反过来,数据布局一错位就会踩出越界写,最终导致段错误。这种问题很难从 stack trace 直接看穿,得先确认目标进程到底加载了哪个 SONAME。
ldd build/your_target | grep -E "mysql|mariadb"如果你发现同一进程同时挂了libmariadb.so.3和libmysqlclient.so.21,或者链接库里出现了一条和 CMake 探测结果完全对不上的软链,那基本就是 ABI 错配实锤了。
提示:编译期通过不代表安全。链接期不报错也不代表运行期安全。头文件、链接库、运行时动态库三者的来源必须逐一确认,缺一步都可能踩雷。
3. 兼容补丁的核心设计:不追求二选一,而是显式路由
处理这种双开发库冲突,最忌讳的其实是“暴力的二选一”。真正稳的做法,是让构建系统在探测阶段就明确自己选哪一套实现,并且把头文件和链接库牢牢锁定在同一来源上。
3.1 为什么不建议直接“卸载其中一个开发库”
你可能第一反应是:既然两套库冲突,那就把其中一个卸载掉,问题不就解决了?
理论上没错,实操中很容易翻车。生产环境里libmariadb-dev和libmysqlclient-dev往往是被不同软件包间接拉进来的,有的依赖 A 包,有的依赖 B 包。直接卸载其中一个,APT 会把一系列依赖它的上层软件一起标记为要卸载,甚至可能触发某些服务重启。为了编译一个 Drogon 项目去动系统的依赖树,代价太大,风险也很高。
另外一个客观现实是:很多机器的这两个库属于历史遗留,装机的运维脚本早就跑完了,你根本没法确定哪一层让它们共存。所以补丁的正确方向不是清理环境,而是让 Drogon 在“已经混乱”的环境里也能稳定地选择一套完整实现。
3.2 CMake 探测顺序:mariadb 优先,mysqlclient 兜底
补丁的核心改动并不复杂,核心就是把原来那套“看见 libmysqlclient 就用”的逻辑,改成“优先 mariadb,找不到再回退 mysqlclient”。
为什么优先 mariadb?因为实际出问题的部署机里,libmariadb-dev 往往是最后入场的那位,也是软链覆盖的一方。与其让系统随机决定,不如在 CMake 里显式认领它。MariaDB Connector/C 对 MySQL 官方 API 的兼容性普遍反馈很好,用它编出来的 Drogon 在大部分 MySQL 服务器上都能正常工作。
用 pkg-config 来探测的好处很明显:mariadb.pc和mysqlclient.pc两个文件名是明确区分来源的,不像find_path那样只能看到一个笼统的mysql.h。代码大概长这样:
find_package(PkgConfig QUIET) if(PkgConfig_FOUND) pkg_check_modules(MYSQL QUIET mariadb) if(NOT MYSQL_FOUND) pkg_check_modules(MYSQL QUIET mysqlclient) endif() endif() if(NOT MYSQL_FOUND) find_path(MYSQL_INCLUDE_DIR mysql/mysql.h PATH_SUFFIXES mysql mariadb) find_library(MYSQL_LIBRARY NAMES mariadb mysqlclient) set(MYSQL_INCLUDE_DIRS "${MYSQL_INCLUDE_DIR}") set(MYSQL_LIBRARIES "${MYSQL_LIBRARY}") endif() if(MYSQL_FOUND) message(STATUS "Drogon MySQL client: ${MYSQL_VERSION} from ${MYSQL_INCLUDE_DIRS}") endif()这里有个小技巧:变量名前缀直接用MYSQL。这样一来,下游本来使用的${MYSQL_INCLUDE_DIRS}、${MYSQL_LIBRARIES}这些变量名完全不用动,改动面缩到最小。
3.3 头文件与链接库的隔离原则
补丁里最重要的一条纪律是:同一个编译任务里,永远不要让两家的头文件目录同时出现在编译参数里。
很多人的第一反应是“两个目录都加上不就行了?反正编译器会找到对的”。事实恰恰相反,这种操作等于手动给编译器制造二义性。mysql.h这个文件名两套目录都有,预处理器按搜索顺序遇到第一个就不往下找了,但链接库却可能来自另一个实现,于是又形成“头文件 A + 运行库 B”的错配。
同理,链接参数也不能-lmariadb和-lmysqlclient同时出现。两个库各自维护一套连接池、错误码和线程局部状态,混着用轻则行为怪异,重则直接崩溃。正确的做法是:头文件路径、链接库名、目标依赖三者全部来自同一个探测结果。
如果 Drogon 源码里写死了#include <mysql/mysql.h>,而 MariaDB 的头文件在/usr/include/mariadb/mysql.h,你需要做两件事之一:要么把源码里的 include 统一改成<mysql.h>,然后在编译参数里指向/usr/include/mariadb;要么在项目里放一个 shim 头文件,模拟出mysql/mysql.h的路径。#include <mysql.h>是最通吃的写法,因为只要 include 目录里正好包含 mysql 官方头文件所在目录,或者 MariaDB 头文件所在目录,它都能命中。
3.4 源码层的版本宏兜底,不能少
MySQL 官方头文件会定义MYSQL_VERSION_ID,MariaDB Connector/C 则倾向于定义MARIADB_VERSION_ID。Drogon 的 ORM 代码里如果有这类版本宏判断,在 MariaDB 环境下就可能走错分支。
一个常见兜底是:
#if defined(MARIADB_VERSION_ID) && !defined(MYSQL_VERSION_ID) #define MYSQL_VERSION_ID LIBMYSQL_VERSION_ID #endif逻辑很简单:如果已经确认是 MariaDB 环境,并且官方版本宏缺失,就手工补一个。不过不同发行版的 MariaDB Connector/C 版本宏命名偶有差异,动手前最好先看一下/usr/include/mariadb/mariadb_version.h里实际定义了什么,别直接照抄。
4. 落地的补丁内容与具体改动方式
前面说的是设计思路,这一节可以直接抄作业。我强烈建议你在自己的分支里做小范围改动,不要整体替换 Drogon 的构建系统,否则以后升级官方版本时合并会很痛苦。
4.1 CMake 探测逻辑的主要改动
如果你的 Drogon 版本里原本的探测逻辑是:
find_path(MySQL_INCLUDE_DIR mysql/mysql.h) find_library(MySQL_LIBRARY NAMES mysqlclient)可以先改成:
find_package(PkgConfig QUIET) if(PkgConfig_FOUND) pkg_check_modules(MYSQL QUIET mariadb) if(NOT MYSQL_FOUND) pkg_check_modules(MYSQL QUIET mysqlclient) endif() endif() if(MYSQL_FOUND) set(MySQL_INCLUDE_DIRS "${MYSQL_INCLUDE_DIRS}") set(MySQL_LIBRARIES "${MYSQL_LIBRARIES}") else() find_path(MySQL_INCLUDE_DIR mysql/mysql.h) find_library(MySQL_LIBRARY NAMES mariadb mysqlclient) set(MySQL_INCLUDE_DIRS "${MySQL_INCLUDE_DIR}") set(MySQL_LIBRARIES "${MySQL_LIBRARY}") endif()这里的MYSQL_FOUND、MYSQL_INCLUDE_DIRS是pkg_check_modules用MYSQL这个前缀自动生成的变量名。改成这样之后,原来的${MySQL_INCLUDE_DIR}、${MySQL_LIBRARY}就不需要再用了,统一切到${MySQL_INCLUDE_DIRS}、${MySQL_LIBRARIES}。不同版本 Drogon 的变量命名可能略有差别,以你手头那份 CMake 实际使用的名字为准,原理是一样的。
4.2 需要谨慎处理的版本宏与编译选项
补丁通过 pkg-config 切到 MariaDB 之后,Drogon 拿到的版本号其实是 MariaDB Connector/C 的版本,而不是你线上 MySQL 服务器的版本。这两者可以相干,但不能完全画等号。
如果你的业务实际连接的是 MySQL 8 实例,MariaDB Connector/C 通常也能正常工作,因为 MySQL 8 兼容旧的mysql_native_password认证;但如果服务器强制启用了caching_sha2_password,部分较老的 MariaDB Connector/C 版本会有兼容性问题。所以补丁解决的是“能编译、能链接、能跑基础查询”,而对特殊认证插件的兼容性,必须在真实业务库上做一轮验收。
CMake 里还可以加一个显式的状态输出,方便日志回查:
if(MYSQL_FOUND) message(STATUS "Drogon MySQL client: ${MYSQL_VERSION} using ${MYSQL_INCLUDE_DIRS}") endif()这点小输出在排查 CI 问题时非常值钱。构建日志里一眼就能知道这次用的是哪家的实现。
4.3 编译参数检查清单
改完补丁,推荐用下面这张表对照检查一遍:
| 检查项 | 推荐状态 | 理由 |
|---|---|---|
| include 目录 | 只出现/usr/include/mariadb或/usr/include/mysql之一 | 防止两套mysql.h同时污染预处理器 |
| 链接库 | 只出现-lmariadb或-lmysqlclient之一 | 防止运行时加载到两套 C API 状态机 |
| pkg-config 探测 | mariadb优先,mysqlclient兜底 | 显式区分实现来源 |
mysql_config调用 | 尽量不在补丁里依赖它 | 它的软链指向可能被 compat 包篡改 |
| CMake 日志 | 能打印出实际使用的版本和路径 | 出问题时可以快速回查构建链路 |
如果想快速验证补丁逻辑,或者临时绕开 CMake 探测,也可以手动指定:
cmake -S . -B build \ -DMySQL_INCLUDE_DIR=/usr/include/mariadb \ -DMySQL_LIBRARY=/usr/lib/x86_64-linux-gnu/libmariadb.so这种做法适合应急,不适合常态化。一旦换了机器、换了发行版版本,路径可能就失效了,不如把补丁固化到 CMake 逻辑里。
5. 验证与回归:干净环境、混合环境、容器环境怎么测
补丁写完只是第一步,真正重要的是验证。我的建议是准备三组环境做交叉测试,而不是只在一台机器上跑通就算完事。
5.1 先检查现状的命令清单
在任何环境开始编译之前,先执行一组状态检查,明确当前系统的真实情况:
dpkg -l | grep -E "libmysqlclient|libmariadb" readlink -f /usr/lib/x86_64-linux-gnu/libmysqlclient.so ls -la /usr/include/mysql/mysql.h /usr/include/mariadb/mysql.h pkg-config --cflags --libs mariadb pkg-config --cflags --libs mysqlclientreadlink -f这条非常关键,它能直接告诉你那个暧昧的libmysqlclient.so软链最终指向谁。如果输出最后落在libmariadb.so上,说明这个环境实际被 MariaDB 接管了;如果落在libmysqlclient.so.21,说明还是 MySQL 官方库。
5.2 三组环境的构建与 ldd 验证
我做了三组环境对比,结果非常有参考价值:
| 测试环境 | pkg-config 探测结果 | 编译结果 | ldd 结果 | ORM 实测 |
|---|---|---|---|---|
| 仅 libmariadb-dev | mariadb | 通过 | libmariadb.so.3 | 通过 |
| libmariadb-dev + libmysqlclient-dev 共存 | mariadb | 通过 | libmariadb.so.3 | 通过 |
| 仅 libmysqlclient-dev | mysqlclient | 通过 | libmysqlclient.so.21 | 通过 |
每一组都执行同样的构建命令:
rm -rf build cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_MYSQL=ON cmake --build build -j$(nproc)构建完成后,用 ldd 确认链接到的 SONAME。注意,ldd显示libmysqlclient.so.21不等于它一定来自 MySQL 官方,如果在混合环境里那个文件名只是软链,最终 readlink 还是可能指向 libmariadb。所以 ldd 要和readlink -f联合起来看。
5.3 ORM 功能实测的验证点
光编译过还不够,至少得连一次真实数据库跑通基本 ORM 操作。我建议用一段独立于 Drogon 的最小 C 程序先验证底层库是否健康:
#include <mysql.h> #include <cstdio> #include <cstdlib> int main() { MYSQL *conn = mysql_init(nullptr); if (!conn) { std::perror("mysql_init"); return 1; } bool ok = mysql_real_connect( conn, "127.0.0.1", "test_user", "test_pass", "test_db", 3306, nullptr, 0); if (!ok) { std::fprintf(stderr, "connect failed: %s\n", mysql_error(conn)); mysql_close(conn); return 1; } std::fprintf(stderr, "connect ok, server version: %s\n", mysql_get_server_info(conn)); mysql_close(conn); return 0; }这程序同时兼容两套库,不会因为换了实现而无法编译。在 Drogon 的 ORM 层,验证点主要落在:能建表、能执行 insert、能 select 回来、事务能 begin/commit 四个基本动作上。如果这几步都通过,说明补丁在功能层面是靠谱的。
5.4 混合环境最容易误判的两个地方
混合环境下最容易误判的是编译日志。有时日志里同时出现了/usr/include/mysql和/usr/include/mariadb,却没有报错,很多人的反应是“没事”。实际上这非常危险,两套声明同时生效,编译器只是硬着头皮往下走,生成的二进制可能已经错了。正确做法是主动检查编译命令,确保只出现一个 include 路径。
另一个误判点是mysql_config --version的输出。这个命令返回的版本号只能代表系统里“默认被 PATH 找到的那份工具”的版本,并不等于 Drogon 最终采用的头文件和库。CMake 完全可能一边拿着mysql_config --libs的结果,一边用find_path找到了另一个目录。补丁里加的那行message(STATUS ...)输出,才是判断真实来源的可靠线索。
6. 事后复盘:这个冲突给构建流程留下的几个改进点
问题修完之后,我复盘了整条链路,发现这次冲突给构建流程留下的教训,远比一个补丁本身更有价值。以后再做类似项目,这几个改进点我会提前落实。
第一点,编译依赖和运行依赖必须分离。Drogon 这类 C++ 项目在容器里构建时,最好用 multi-stage:构建阶段装libmariadb-dev这类开发包,跑完编译就把开发包留在前面的镜像层;最终运行镜像只保留libmariadb3这类运行时库。这样既不会让开发库污染线上环境,也能彻底规避“两个开发库打架”的问题。如果还在用单层镜像,现在就是拆分构建环境的好时机。
第二点,构建系统一定要输出“来源信息”。这次排查最难的地方,就是 CMake 从头到尾没告诉我它到底选了哪家的实现。补丁里加上一行版本和路径的message(STATUS ...)打印后,再遇到类似问题,只看构建日志前三行就能定位方向。这个习惯对任何依赖探测比较复杂的项目都适用,不只是 Drogon。
第三点,不要盲目相信“完全兼容”。MariaDB Connector/C 和 MySQL 官方库在绝大多数场景下都能互换,但兼容不等于没差异。版本宏、SONAME、个别扩展函数、认证插件兼容性,这些都属于可能的差异点。特别是线上真实业务跑 MySQL 8 的项目,我会在 CI 矩阵里留一个纯 libmysqlclient-dev 的构建任务,专门用来和 MariaDB 路径做对照,避免整个团队只验证了其中一条路径。
按照我现在的习惯,任何一台要编译 Drogon 的服务器,动手前都会先跑一遍dpkg -l、readlink -f、pkg-config --exists三个命令,看清楚环境再开始。之前也试过图省事重装系统清依赖,后来发现复杂的依赖环境才是真正有价值的测试场。一套能在混合环境里稳定工作的补丁,之后带到任何新环境都再没出过幺蛾子。