本文基于 Connector/C++ 26.7.0(2026-07-29 GA),全部 file:line 指向该版本源码树。
配套仓库:
yongchaowu/mysql-connector-cpp本文提到的
demo/(两个可编译运行的 CRUD 程序 + 两份真实抓包)、docs/(12 个校验脚本)、docker/(麒麟容器一条命令出镜像)、offline-debs/(离线包)都在那个仓库里。 下文所有路径都是仓库相对路径,克隆后即可复现本文的每一条实测结论。
关于证据强度,先说清楚三件事——本文的结论不是一个可信度:
- 线上字节的论断 = 实测。所有关于帧格式、握手顺序、消息类型的说法都来自
demo/wire/里两份真实抓包(tcptee.py录的,未经改写), 可复现。文中用 【见】/【推】 区分”抓包看到的”和”由明文那次推断的”。- API 用法 = 编译 + 连库跑过。两个完整 demo 都在真实 MySQL 8.0.46 上跑到
EXIT=0。凡是”照着文档想当然写”的 API(as<T>()、rows()、sql().bind()…) 都在编译或运行时被推翻,已在「实测踩到的 22 个坑」列出。- 其余部分 = 读源码,可能滞后于实现。特别是那些”这个类不存在”“这个宏没定义过” 之类的断言,来自 grep,不排除我看漏了。
另外:本文在修订过程中推翻过自己几次(”全树零命中”“20 轮 challenge” “服务端回
Ok“都是错的)。这些更正我保留在正文里没删,并标了出处—— 读到和自己经验冲突的地方,以抓包和编译器为准。
mysql-connector-c++-26.7.0-src.tar.gz 解开是一个自举项目(bootstrap CMake,源码带 vendored 依赖)。大多数人用它只写 5 行:
#include <mysqlx/xdevapi.h>
using namespace mysqlx;
Session s("root", "pwd", "127.0.0.1", 33060);
auto r = s.sql("SELECT 1").execute();
然后就以为”连上 MySQL 了”。但真正决定成败的问题,全藏在这 5 行背后:
#include哪个头,决定了你走的是完全不同的两套栈。 这个 tarball 里有两个互不相干的连接器,协议、端口、依赖、错误模型全不一样——而你只是换了个头文件。Session构造函数其实没有连接。 它只把参数翻译成一个”数据源列表”,真正的 socket 在下一层、另一个文件里创建。- X 协议没有 capability 位图。 没有
CLIENT_SSL/CLIENT_COMPRESS/CLIENT_PLUGIN_AUTH;连”服务端支不支持 row locking”都是靠发一条会失败的探针消息试出来的。 ssl-mode的默认值是REQUIRED,不是PREFERRED。 这一个默认值会让大量”本地测试能过、上生产连不上”的案例归零。- 这个客户端不支持
caching_sha2_password。 MySQL 8 的默认认证插件,它没有实现——而测试代码里明明白白写着SKIP_TEST("No caching_sha2_password support")。 - CRUD 操作不翻译成 SQL。
collection.find().execute()发出去的是 protobufMysqlx.Crud.Find,服务端根本没看到 SQL 文本。 - JDBC 风格那条路径是个空壳适配器。 它一行协议代码都没有,全部转手给外部的
libmysqlclient——而且默认根本不编译(WITH_JDBC=OFF)。
这 7 件事是本文的引子。展开后是 11 个机制:机制一到机制九顺着 X DevAPI 那条链往下走(选项 → 数据源 → 能力协商 → 认证 → TLS → 帧头 → CRUD),机制十和机制十一切到 classic 那条链。合起来解释了”为什么 C++ 连 MySQL”这件事有两个完全不同的答案。
目录
- 先看两个 demo:写法风格不同,底下是两套产品
- 机制一:include 哪个头,决定了你走哪套栈
- 机制二:Session 构造时并没有连接
- 机制三:选项的归一化与数据源排序
- 机制四:能力协商靠”故意发一条会失败的消息”
- 机制五:认证只有四种,且没有 caching_sha2_password
- 机制六:ssl-mode 默认 REQUIRED,且 TLS 是”先明文后升级”
- 机制七:从 protobuf 消息到 ::send() 的最后一米
- 机制八:结果回来时,帧头是 5 字节不是 4 字节
- 机制九:CRUD 不翻译成 SQL
- 机制十:classic 路径是 mysql_real_query 的三层桥
- 机制十一:classic 路径默认不编译,还有一堆死代码
- 两套栈的完整对照表
- 动手:构建与最小可运行例子
- 版本台账(防腐剂)
- 总结
先看两个 demo:写法风格不同,底下是两套产品
这两段都实测编译通过。真正的差别不是”能不能编”,而是 demo_b 根本不在 默认构建产物里——要
-DWITH_JDBC=ON才会编出来。文末「动手」 一节给出两个完整可运行的 CRUD demo(增删改查全流程)。
// demo_a.cpp —— X DevAPI,默认构建产物就有
#include <mysqlx/xdevapi.h>
using namespace mysqlx;
int main() {
Session s("root", "pwd", "127.0.0.1", 33060);
SqlResult r = s.sql("SELECT 1").execute();
for (auto row : r) { /* ... */ }
s.close();
}
// demo_b.cpp —— JDBC 风格 API,默认构建产物里没有
#include <mysql/jdbc.h>
int main() {
sql::Driver* d = sql::mysql::get_mysql_driver_instance();
std::unique_ptr<sql::Connection> c(
d->connect("tcp://127.0.0.1:3306", "root", "pwd"));
std::unique_ptr<sql::Statement> st(c->createStatement());
std::unique_ptr<sql::ResultSet> rs(st->executeQuery("SELECT 1"));
}
两段代码看起来只是风格不同——一个是”面向对象 + 文档模型”,一个是”JDBC + 游标”。实际上它们分岔在编译期:
┌──────────────────────────────────────────┐
│ mysql-connector-c++-26.7.0-src.tar.gz │
│ 一个 tarball,build 出两个 .so │
└───────────────────┬──────────────────────┘
│
┌─────────────────────────────┴─────────────────────────────┐
│ WITH_JDBC=OFF (默认) WITH_JDBC=ON(要显式打开) │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ libmysqlcppconnx.so.2 │ │ libmysqlcppconn.so.10 │
│ X DevAPI │ │ classic / JDBC │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
┌────────────▼─────────────┐ ┌────────────▼─────────────┐
│ cdk/ 自己实现 X Protocol │ │ jdbc/ 零行协议代码 │
│ · protobuf 序列化 │ │ 三层桥: │
│ · 3 种压缩算法 │ │ MySQL_Statement │
│ · TLS 状态机 │ │ → NativeConnectionWrap│
│ · 4 种认证 │ │ → IMySQLCAPI │
│ · 解析器(表达式/URI) │ │ → ::mysql_real_query │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
│ ldd 实测:只有 libssl/libcrypto/resolv/pthread/dl │
│ 没有 libprotobuf-lite / libzstd / liblz4 │
│ 也没有 libmysqlclient │
│ │ 依赖外部 libmysqlclient
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ 127.0.0.1 : 33060 │ │ 127.0.0.1 : 3306 │
│ ┌──┬──┬──┬──┬──┐ │ │ ┌──┬──┬──┐ │
│ │ 4 │ 3 │ 2 │ 1 │ 5B帧 │ │ │ │ │ │ 文本协议 │
│ └──┴──┴──┴──┴──┘ │ │ └──┴──┴──┘ │
│ len(4LE)+type(1) │ │ 手写字节,无帧头概念 │
│ + protobuf payload │ │ │
└───────────────────────┘ └───────────────────────┘
压缩算法是 3 种(不是 5 种):
data_source.h:201-206的枚举是NONE / DEFLATE_STREAM / LZ4_MESSAGE / ZSTD_STREAM,去掉NONE哨兵只剩 3 个。 抓包也证实了——建连时客户端依次发deflate_stream、lz4_message、zstd_stream三个CapabilitiesSet试过去(见「抓包实证」)。
两段代码的差异对照:
| demo_a | demo_b | |
|---|---|---|
| 头文件 | <mysqlx/xdevapi.h> |
<mysql/jdbc.h> |
| 产物 | libmysqlcppconnx.so.2.26.7.0 |
libmysqlcppconn.so.10.26.7.0 |
| 端口 | 33060 | 3306 |
| 线上字节 | protobuf,5 字节帧头 | classic MySQL 协议文本 |
| 协议实现者 | 连接器自己(vendored protobuf 3.19.6) | 外部 libmysqlclient |
| 默认构建 | ✅ 默认就有 | ❌ 要 -DWITH_JDBC=ON 才编 |
| 错误模型 | C++ 异常 cdk::Error |
C++ 异常 sql::SQLException + SQLSTATE |
| 连接池 | ✅ 内置 Session_pool |
❌ 无 |
端口不同这一点最容易踩:demo_a 连的是 33060(X 协议端口,DEFAULT_MYSQLX_PORT,include/mysqlx/common_constants.h:37),你把 3306 填进去会直接连不上,或者更糟——连上一个不认 X 协议的服务端。
后文所有
file:line都相对mysql-connector-c++-26.7.0-src/根目录,版本取自version.cmake:37-39(CONCPP_VERSION 26.7.0)与version.cmake:85-90(ABI_VERSION 2.1,与 9.2.0 相同)。
机制一:include 哪个头,决定了你走哪套栈
先说版本,因为它决定了后面所有事。
本文基于 26.7.0(2026-07-29 GA)。注意 Oracle 在 9.7.x 之后把版本号改成了日历版本——
9.7.0(2026-04-22)之后直接跳到26.7.0,中间没有10.x,也没有25.x。所以”最新版本”不是”9.x 的下一个”,而是26.x。如果你的 tarball 是 9.2.0,本文 95% 的结论照样成立(两个 tarball 之间的结构性变化很小),但所有
file:line都要重新校准。方法见「版本台账」——本文附带了一个自动校准脚本,30 秒能出结果。升级动机的补充:9.7.0–9.7.1 受 CVE-2026-60180(DoS)影响,涉及 2026-07 的 Oracle 关键补丁更新。如果你在 9.7.x 上,
26.7.0才是修好的版本。ABI 版本号没变,但”能换库”这件事是单向的。
version.cmake:85-90的ABI_VERSION仍是2.1(JDBC 侧10.0),两版.so的 soname 也相同(libmysqlcppconnx.so.2)。 我把 9.2.0 也编出来做了四种组合实测,结论如下——升级安全,降级会崩:
配 9.2.0的.so配 26.7.0的.so用 9.2.0 头编的程序 ✅ 正常(46 行输出) ✅ 正常(46 行输出) 用 26.7.0 头编的程序 ❌ 段错误,一行输出都没有 ✅ 正常 所以:升级时你只需要换
.so,不用重编自己的程序(这是最省事的一条路); 但回滚时必须连程序一起换成旧版本。顺带说明为什么”ABI 2.1 没变”这个信号在这里不足以保证安全——下面几项全都一致, 却照样崩:
检查项 结果 soname 两版都是 libmysqlcppconnx.so.2✅ 相同ABI 命名空间 都是 MYSQLX_ABI_BEGIN(2,0)/(2,1)✅ 相同导出符号集 各 1760 个, comm对比双向零差异 ✅ 相同公开类 sizeofColumn16 /Row16 /SqlResult120 /Value136 /Table128 /SessionSettings96 … 全部逐个相同 ✅cdk/devapi/detail/result.h逐行 diff 只差一个宏名( INTERNAL→MYSQLX_INTERNAL)✅崩的地方是私有类型:gdb 栈顶是
std::_Destroy<mysqlx::abi2::r0::internal::Column_storage<Column>>里 通过空 vtable 指针调虚析构(0x0)。Column_storage持有一个const typename COL::Impl*,而Column::Impl是私有的——它的布局变了, 但外部观察不到(公开头文件里sizeof一模一样,导出符号也没变)。教训:
ABI_VERSION相同 + soname 相同 + 符号表相同,三者齐平也保证不了二进制兼容。 公开 API 尺寸不变而私有Impl布局变了,是 C++ 里最典型的静默 ABI 破坏。 唯一可靠的判断办法就是像本文这样把两个版本都编出来、跑一遍交叉矩阵。
一个 tarball、两个连接器,这在构建系统里是显式的:
# CMakeLists.txt:389-395
add_config_option(WITH_JDBC BOOL DEFAULT OFF
"Whether to build a variant of connector library which implements legacy JDBC API")
if(WITH_JDBC)
add_subdirectory(jdbc)
endif()
WITH_JDBC 默认 OFF。也就是说 classic 路径不是”另一种用法”,是”另一种构建”。默认 cmake .. 出来的只有 X DevAPI。
X 侧的产物是这么合出来的:
# CMakeLists.txt:456
merge_libraries(connector xapi devapi)
# install_layout.cmake:218-219
set(LIB_NAME_BASE "mysqlcppconnx")
set(LIB_NAME_STATIC "${LIB_NAME_BASE}-static")
# CMakeLists.txt:576-579
VERSION "${ABI_VERSION_MAJOR}.${CONCPP_VERSION}" # 2.26.7.0
SOVERSION "${ABI_VERSION_MAJOR}" # 2
merge_libraries(cmake/libutils.cmake:145-339)把一堆 STATIC 中间库折叠成一个 SHARED:
xapi (STATIC) ──┐
├── merge_libraries ──> libmysqlcppconnx.so.2.26.7.0
devapi (STATIC) ─┘ (soname libmysqlcppconnx.so.2)
│
└──> common (STATIC)
└──> cdk (STATIC)
└──> cdk_mysqlx (STATIC) cdk/mysqlx/CMakeLists.txt:36
└──> cdk_proto_mysqlx (STATIC) cdk/protocol/mysqlx/CMakeLists.txt:119
└──> ext::protobuf-lite, ext::z, ext::lz4, ext::zstd
注意这条链里没有 libmysqlclient。X DevAPI 自己实现了整个 X 协议:protobuf 序列化、3 种压缩算法、TLS 状态机、4 种认证方式,全在 cdk/ 里。这就是为什么它能独立于 libmysqlclient 构建。
一个容易误导的细节:xapi/CMakeLists.txt:47 里有个变量叫 _libmysqlx_cc_src——
# xapi/CMakeLists.txt:47-52
set(_libmysqlx_cc_src crud.cc result.cc mysqlx.cc session.cc)
add_library(xapi STATIC ${_libmysqlx_cc_src} ${HEADERS})
target_link_libraries(xapi PUBLIC common)
全树 grep 一下,”libmysqlx” 这个字符串只在这一行出现过。它不指向任何被安装的产物,是个残留的变量名。xapi/ 是 C 语言 API(mysqlx_get_session 那些 mysqlx_* 函数,xapi/mysqlx.cc:206-265),C++ 那层在 devapi/。
机制二:Session 构造时并没有连接
Session s("root", "pwd", "127.0.0.1", 33060); 走的是变参构造:
// include/mysqlx/xdevapi.h:1720-1722
template<typename...T> Session(T...options) : Session(SessionSettings(options...)) {}
落到真正干活的地方,只有 7 行:
// devapi/session.cc:238-246
Session_detail::Session_detail(common::Settings_impl &settings)
{
try {
cdk::ds::Multi_source source;
settings.get_data_source(source); // ← 只是把选项翻译成一个数据源列表
m_impl = std::make_shared<Impl>(source); // ← 真正建对象,但还没连
}
Settings_impl::get_data_source()(common/session.cc:471)不碰网络,它只做翻译:把 host/port/user/password/schema/ssl-mode/auth 这些散装选项,塞进一个 cdk::ds::Multi_source——一个带优先级权重的候选端点列表。
真正的连接发生在 Impl 的构造函数里(common/session.h:320),而且它第一件事是去要连接池:
// common/session.cc:711
Pooled_session::Pooled_session(cdk::ds::Multi_source &ds) { reset(new cdk::Session(ds)); }
// common/session.cc:980-990
std::shared_ptr<cdk::Session> Session_pool::get_session(Session_cleanup *cleanup)
{
if (!m_pool_enable)
return std::shared_ptr<cdk::Session>(new cdk::Session(m_ds)); // 未开池:直接建
...
所以 Session 构造函数的语义其实是”从池子里取一个会话“,而不是”建立一个会话”。这一点对写服务端程序影响很大:
- 池开启时(
SessionSettings默认开),new Session(...)可能复用一条已存在的物理连接; - 池的默认参数在
common/session.h:220-222:m_max = 25、m_timeout = 10 min、m_time_to_live = 10 min; - 池还有一层 60 秒的端点黑名单(
common/session.h:224-239),某个端点认证失败后 60 秒内不再尝试(common/session.cc:1019-1040)。这解释了一个经典困惑:改完密码立刻重连还是失败,等一分钟就好了。
把这段流程画出来(每一步都标了出处):
Session s("root","pwd","127.0.0.1",33060)
│ xdevapi.h:1720 变参构造
▼
SessionSettings(options...) ← 构造期**不碰网络**
│ devapi/settings.h:538
▼
Session_detail::Session_detail(Settings_impl&) devapi/session.cc:238
│
├─► settings.get_data_source(Multi_source&) common/session.cc:471
│ │
│ ├─ prepare_options() ─────────────► common/session.cc:296
│ │ · USER 必填,否则 throw_error
│ │ · ssl-mode 默认 = REQUIRED ★ 很多人在这里被坑
│ │ · TLS 开了就把 socket=true → 认证走 PLAIN
│ │ · unix socket + REQUIRED = 非法组合,直接抛
│ │ · PRIORITY 取反(100 - prio),cdk 是"权重大者优先"
│ │
│ └─ add_prio(TCPIP(host,port), opts, prio) :590
│ 产出:一个**带权重的候选端点列表**,仍然没有 socket
│
└─► m_impl = make_shared<Impl>(source) devapi/session.cc:244
│
▼
Session_impl(Multi_source&) common/session.h:320
│
▼
Session_pool::get_session() common/session.cc:980
│
┌───────┴────────────────────────────────┐
│ │
m_pool_enable == false m_pool_enable == true(默认)
(显式关掉池) │
│ ▼
│ ┌───────── 池里有空闲的? ─────────┐
│ │ 是 │ 否
▼ ▼ ▼
new cdk::Session(m_ds) 直接复用物理连接 池未满?──是──► new cdk::Session()
立刻建连 (省掉握手) │ │
│ │ 否 │
│ │ ▼ │
│ │ 等一个旧的 │
│ │ 或超时腾位 │
│ │ │ │
│ └────────────┬────────────────┘ │
│ ▼ ▼
│ ★ 失败端点进 60s 黑名单 │
│ common/session.cc:1019 │
└────────────────────────────┬───────────────────────────────────┘
▼
cdk::Session(Multi_source&) cdk/core/session.cc:439
│
ds::Multi_source::visit(Session_builder) ← 故障转移循环
│
┌──────────────┴──────────────┐
▼ ▼
Session_builder::operator() (下一个端点,权重低的先试)
(TCPIP) cdk/core/session.cc:191
│
┌────────────┼─────────────────────┐
▼ ▼ ▼
new TCPIP() tls_connect() (跳过 TLS)
connect() CapabilitiesSet{tls}
│ ←── 明文 ──→ ★ 抓包证实,见机制六
│ SSL_connect()
│ (之后全程加密)
└────────────┬─────────────────────┘
▼
new cdk::mysqlx::Session(conn, options)
│ cdk/core/session.cc:239 / :251
▼
┌────────────────────────────────────────┐
│ 握手三步(cdk/mysqlx/session.h:261) │
│ ① negotiate_compression() │
│ ② send_connection_attr() │
│ ③ authenticate() │
└────────────────────────────────────────┘
读这张图只需记一件事:构造函数跑到 Session_pool::get_session() 就分叉了。
往上(get_data_source)是纯翻译,往下(cdk::Session)才是网络。
所以”new 一个 Session 慢”卡在池这一层,而不是参数解析。
机制三:选项的归一化与数据源排序
get_data_source() 之前有一步 prepare_options()(common/session.cc:296-456),它做三件容易被忽略的事:
1. 强制要求 USER
// common/session.cc:303-304
if (!settings.has_option(Option::USER))
throw_error("USER option not defined");
2. TLS 一开就把 socket 标记为 true——这是为了让认证走明文通道
// common/session.cc:352-360(节选)
#ifdef WITH_SSL
... 构造 TLS_options ...
socket = true; // 注释原文:so that PLAIN auth method is used below
这个 socket = true 有点 hack:它的唯一作用是让 common/session.cc:417-425 那段选到 PLAIN 认证(明文密码只在加密通道上传送)。理解了这一点,后面机制五才讲得通。
3. Unix socket + REQUIRED 是非法组合
// common/session.cc:346-350
if (socket && mode_set && mode >= unsigned(SSL_mode::REQUIRED))
throw_error("SSL connection over Unix domain socket requested.");
然后才是数据源排序。add_host lambda 里有个值得注意的处理:
// common/session.cc:582-600(节选)
if (settings.get(Option::VERIFY_IDENTITY).get_bool())
tls.set_host_name(host); // ← 只有 VERIFY_IDENTITY 才做主机名校验
...
src.add_prio(cdk::ds::TCPIP(host, port), opts, prio);
VERIFY_CA 只验证书链,不验主机名;只有 VERIFY_IDENTITY 才填 set_host_name()。用自签 CA 做 VERIFY_CA 时不会因为 hostname 不匹配而失败——这跟 libmysqlclient 的 --ssl-verify-server-cert 语义不同,是迁移时的一个真实差异点。
另外 PRIORITY 是反的:
// common/session.cc:523-540(节选)
check_prio = [&uri](const SQLString &p) { ... prio = 100 - prio; ... };
因为 cdk 内部按”权重越大越优先”排序,而连接串里习惯写”优先级数字小的优先”。
机制四:能力协商靠”故意发一条会失败的消息”
这是 X 协议跟 classic 协议最本质的区别。
classic MySQL 协议在握手包里带一个 32 位 capability 位图,客户端 SET、服务端 AND,一条报文搞定所有特性协商(CLIENT_SSL、CLIENT_COMPRESS、CLIENT_DEPRECATE_EOF、CLIENT_PLUGIN_AUTH……)。
X 协议代码里这些常量一个都不存在。 在 X 侧那四个目录(cdk/ common/ devapi/ xapi/)里 grep CLIENT_SSL|CLIENT_COMPRESS|CLIENT_DEPRECATE_EOF|CLIENT_PLUGIN_AUTH 是零命中。
⚠️ 但注意别把范围说成”全树”。 classic 那一侧是有这些位图的——
jdbc/driver/mysql_connection.cpp:212-222有一张flagsOptions[]表,把 8 个CLIENT_*位(CLIENT_COMPRESS、CLIENT_FOUND_ROWS、CLIENT_LOCAL_FILES、CLIENT_MULTI_STATEMENTS……)映射成连接选项;jdbc/cppconn/connection.h:121甚至把OPT_CLIENT_COMPRESS "CLIENT_COMPRESS"做成了公开选项名。准确的说法是:classic 侧仍然用 capability 位图,X 侧完全不用。 下面这段只讲 X 侧。
取而代之的是 Mysqlx.Expect.Open——客户端声明”我期望服务端支持 X”,然后看服务端回不回复错误码:
// cdk/mysqlx/session.cc:151-176(节选)
template<> Expectation_processor<Protocol_fields::ROW_LOCKING>::Expectation_processor()
// Find=17, locking=12
: m_data("17.12")
{}
template<> Expectation_processor<Protocol_fields::UPSERT>::Expectation_processor()
// Insert=18, upsert=6
: m_data("18.6")
{}
template<> Expectation_processor<Protocol_fields::PREPARED_STATEMENTS>::Expectation_processor()
: m_data("40") // Prepare.Prepare
{}
template<> Expectation_processor<Protocol_fields::KEEP_OPEN>::Expectation_processor()
: m_data("6.1") // SessionReset.keep_open=1
{}
template<> Expectation_processor<Protocol_fields::COMPRESSION>::Expectation_processor()
: m_data("46") // Connection.Compression
{}
那个 "17.12" 就是 X 协议里 protobuf 消息的编号.字段编号寻址。协议是按 protobuf message id 编址的:
Mysqlx.Connection.CapabilitiesSet = 2 cdk/include/mysql/cdk/protocol/mysqlx.h:142
Mysqlx.Session.AuthenticateStart = 4 :146
Mysqlx.Sql.StmtExecute = 12 :154
Mysqlx.Crud.Find = 17 :157
Mysqlx.Crud.Insert = 18
Mysqlx.Prepare.Prepare = 40
Mysqlx.Prepare.Execute = 41
Expect.Open 的语义是”从现在起,如果服务端不支持这些特性,就明确报错而不是静默忽略”。于是降级逻辑是运行时捕获异常再重试:
// common/op_impl.h:303-336(节选)
try { check_errors(); }
catch (cdk::mysqlx::Server_prepare_error&) { retry = true; } // 服务端不支持 PS
catch (cdk::mysqlx::Server_expectation_error&) { retry = true; }
if (retry) { reset_state(); wait(); } // reset_state() 把 stmt_id 归 0 → 改成直接执行
所以“连接器以为在用预处理语句、实际上没在用”是可能发生且静默的——服务端不支持时它自动退回明文执行。你只能从 has_prepared_statements() 的返回值看出来(common/session.h:393)。
同样的静默降级也发生在 collection.find(...).lock("SHARED"):服务端不支持行锁就退化成无锁读。
机制五:认证只有四种,且没有 caching_sha2_password
先说结论:这个客户端没有实现 caching_sha2_password,也没有 mysql_native_password 和 sha256_password。 这些字符串全树只出现在测试代码里,而且是用来在服务端建账号的:
// devapi/tests/session-t.cc:1573-1576
SKIP_TEST("No caching_sha2_password support")
// testing/test.h:299
xplugin, std::move(name), std::move(pwd), "caching_sha2_password"
X 协议用的是自己那套四种机制(cdk/include/mysql/cdk/data_source.h:187-193):
| DevAPI 名 | 实现类 | 线上 mech_name |
干什么 |
|---|---|---|---|
MYSQL41 |
AuthMysql41 cdk/mysqlx/session.cc:495 |
"MYSQL41" |
challenge-response,SHA1 |
SHA256_MEMORY |
AuthSha256Memory :513 |
"SHA256_MEMORY" |
challenge-response,SHA256 |
PLAIN |
AuthPlain :381 |
"PLAIN" |
一次把密码明文发过去 |
EXTERNAL |
AuthExternal :420 |
"EXTERNAL" |
不发认证数据,交给外部回调 |
轮数由服务端决定,客户端没有硬编码。 本文早期版本在这里写过”20 轮 challenge”,
那是把 MySQL 服务端 caching_sha2_password 的行为错安到了客户端头上。实际代码里
根本找不到 20 这个常量(grep -rn '\b20\b' cdk/mysqlx/session.cc cdk/mysqlx/auth_hash.cc
无结果),逻辑只有两行:
// cdk/mysqlx/session.cc:350-356 服务端每发一次 AuthenticateContinue,客户端就回一次
void SessionAuth::auth_continue(bytes data) {
m_state = CONT;
m_op = &m_sess.m_protocol.snd_AuthenticateContinue(auth_response(++m_round, data));
}
// cdk/mysqlx/session.cc:482-491 round 0 不带数据,其余每轮算一次摘要
virtual bytes auth_response(unsigned round, bytes data) override {
if (0 == round) return {};
m_cont_data = build_hash(data);
return bytes((byte*)m_cont_data.c_str(), m_cont_data.size());
}
抓包也印证了:明文那次会话的认证段是 type=4 → 3 → 5 → 11/4 四个帧,
轮数完全跟服务端节奏走。所以”服务端可以任意决定要几轮”——理论上它可以要 100 轮,
客户端就会老实回 100 次。
为什么 PLAIN 可以明文发密码? 因为它只在”安全通道”上被选中:
// cdk/mysqlx/session.cc:610-612
auto am = original_am;
if (Protocol_options::DEFAULT == am)
am = secure_conn ? Protocol_options::PLAIN : Protocol_options::MYSQL41;
secure_conn 来自 conn.is_secure()。这个虚函数有两个实现,注意别看错:
| 类 | 位置 | 返回 |
|---|---|---|
TCPIP::is_secure() |
cdk/include/mysql/cdk/foundation/connection_tcpip.h:260-263 |
false(裸 TCP) |
Unix_socket::is_secure() |
cdk/include/mysql/cdk/foundation/connection_tcpip.h:280-283 |
true |
TLS::is_secure() |
cdk/include/mysql/cdk/foundation/connection_openssl.h:59-62 |
true |
所以 is_secure() 为真只有两种情况:TLS 通道,或 Unix socket。裸 TCP 永远不会走 PLAIN。回看机制三里那个 socket = true:TLS 开了之后 is_secure() 为真,默认认证就顺理成章变成 PLAIN——因为 TLS 里面已经是密文了,没必要再做一次 challenge。
默认值下的完整决策过程:
用户没指定 auth= → original_am = DEFAULT
├─ 连接是 TLS / Unix socket → PLAIN (一次搞定)
└─ 裸 TCP → MYSQL41 (先试 SHA1 challenge)
└─ 失败且用户没钉死 auth= → 再试 SHA256_MEMORY
(cdk/mysqlx/session.cc:639-651)
PLAIN 的报文构造(cdk/mysqlx/session.cc:388-402)值得看一眼——它是 authz\0authc\0password 三段 NUL 分隔,第一段是可选的 schema:
std::string user(options.user());
if (options.database()) m_data.append(*options.database());
m_data.push_back('\0'); // authz
m_data.append(user).push_back('\0'); // authc
if (options.password()) m_data.append(*options.password()); // 明文
抓包实证:一次完整的明文会话
把 ssl-mode 设成 DISABLED,整条会话就全部明文了。下面是
demo/wire/capture-plaintext.log 的完整解码(len 已按机制七换算成 payload 长度):
段 方向 字节 帧内容
─── ──── ────── ──────────────────────────────────────────────────────────
0 --> 324B 3 帧 CapabilitiesSet,压缩算法**按优先级递增逐个试**:
len=106 type=2 payload 可见 "…compression…deflate_stream"
len=103 type=2 payload 可见 "…compression…lz4_message"
len=103 type=2 payload 可见 "…compression…zstd_stream"
↑ 顺序不是随意的:默认向量是 {ZSTD, LZ4, DEFLATE}
(data_source.h:272-273),协商时用 rbegin()→rend() 反向遍历
(cdk/mysqlx/session.cc:91-92),所以发出去是 deflate→lz4→zstd。
源码注释原话(:88-89):"must be attempted with increasing
priority. The last successful type will be applied."
→ 三个都成功 = **zstd 生效**(这也解释了段 11/13 的 type=19)
1 <-- 9B len=5 type=11 Notice
2 <-- 10B len=1 type=0 Ok ┐ 三个算法各回一个 Ok
3 <-- 5B len=1 type=0 Ok ┘
4 --> 322B len=318 type=2 payload 可见 "…session_connect_attrs…
_client_license…" ← 连接属性
5 <-- 5B len=1 type=0 Ok
─────── 至此 TLS/压缩/连接属性都谈完了,开始认证 ───────────────────────────
6 --> 18B len=14 type=4 payload 明文含 "MYSQL41" ★ 机制名是字符串
7 <-- 27B len=23 type=3 AuthenticateContinue ← 服务端发 challenge
8 --> 59B len=55 type=5 客户端回应(SHA1 摘要)
9 <-- 26B len=15 type=11 Notice
len=3 type=4 AuthenticateOk ★ 认证通过
─────── 业务阶段 ────────────────────────────────────────────────────────
10 --> 20B len=16 type=12 payload 明文含 "SELECT 1" 和 ns="sql"
11 <-- 176B len=51 type=19 Compression ┐
len=32 type=19 Compression │ 结果帧被压缩裹了一层
len=29 type=19 Compression │ type=19 就是
len=43 type=19 Compression │ Mysqlx.Connection.Compression
len=1 type=17 StmtExecuteOk ┘
12 --> 20B len=16 type=12 payload 明文含 "ROLLBACK" 和 ns="sql" ★
13 <-- 52B len=43 type=19 Compression
len=1 type=17 StmtExecuteOk
14 --> 5B len=1 type=3 AuthenticateContinue (关闭会话)
15 <-- 11B len=7 type=0 Ok
一次抓包同时证实了文章里 5 条论断:
| 抓包里的证据 | 证伪/证实了哪条 |
|---|---|
段 0 三个 CapabilitiesSet:deflate_stream / lz4_message / zstd_stream |
压缩算法是 3 种不是 5 种;且按优先级逐个试(机制四的”试错式协商”在这里也能看到) |
段 6 payload 明文含 MYSQL41 |
认证机制名是客户端选的一段字符串,不是位图(机制四/五) |
| 段 6→7→8→9 四帧一轮回 | challenge 轮数由服务端驱动,客户端没有硬编码 20(机制五) |
段 10 payload 明文含 SELECT 1 + sql |
raw SQL 走 Mysqlx.Sql.StmtExecute(msg id 12),namespace 字段是 sql |
段 12 payload 明文含 ROLLBACK + sql |
事务确实是普通 SQL——CRUD 是 Mysqlx.Crud.* 结构化消息,事务却是文本,这个不对称是真的(机制九) |
| 段 11/13 结果帧 type=19 | 压缩生效后,结果帧被 Mysqlx.Connection.Compression(msg 19)再包一层,所以你在抓包里看不到 ColumnMetaData(14) / Row(13) |
段 12 那条尤其值得记:如果你在抓包里看到
ROLLBACK,就知道 DevAPI 把事务当普通 SQL 发了——它没有专门的Mysqlx.Session.Rollback消息。
那个致命缺口
如果你在 MySQL 8 上建了个默认 caching_sha2_password 账号,然后用裸 TCP 连 X DevAPI,认证会失败,而且报错信息会是:
Authentication failed using MYSQL41 and SHA256_MEMORY, check username and password or try a secure connection
(cdk/mysqlx/session.cc:641-643)
这个信息有很强的误导性——它会让人以为是密码错了。真实原因是客户端根本没实现服务端要求的机制,重输密码一万次也没用。三条出路:
- 开 TLS(
ssl-mode=REQUIRED,而这本来就是默认值)。这样走PLAIN,绕开 challenge 机制。 - 改账号的认证插件为
mysql_native_password。 - 用 classic 路径(
libmysqlclient自己支持caching_sha2_password的 full auth / RSA 流程)。
这是”用 C++ 连 MySQL 有两个答案”最实际的落点:X DevAPI 在 MySQL 8 + 裸 TCP + caching_sha2_password 这个最常见的组合上是有缺口的。
机制六:ssl-mode 默认 REQUIRED,且 TLS 是”先明文后升级”
先看默认值:
// common/session.cc:337
unsigned mode = unsigned(SSL_mode::REQUIRED);
REQUIRED 是默认值,不是 PREFERRED。 这意味着你只写 Session("root","pwd","127.0.0.1",33060),就已经在要求 TLS 了。对一个连本地测试库都嫌麻烦的默认配置来说,这个选择很激进,但它是对的——因为机制五里那个 caching_sha2_password 缺口,只有 TLS 能绕开。
cdk 层其实有五档(cdk/include/mysql/cdk/foundation/connection_openssl.h:88-95):
enum class SSL_MODE { DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY };
但 DevAPI 暴露出来的只有四档(include/mysqlx/common_constants.h:196-210),没有 PREFERRED。所以 cdk 里那套”服务端不支持 TLS 就降级”的逻辑(cdk/core/session.cc:369)在 C++ API 层永远走不到。
TLS 的握手顺序:先明文,再升级
这是最反直觉的一段。TLS 不是 connect() 之后立刻 SSL_connect(),而是:
// cdk/core/session.cc:305-399(节选)
Session_builder::TLS* Session_builder::tls_connect(Socket_base *connection, const TLS::Options &options)
{
...
cdk::protocol::mysqlx::Protocol proto(*connection); // ← 临时 Protocol,跑在裸 TCP 上
api::Any::Document tls_caps;
tls_caps.add()->scalar()->key_val("tls")->yesno(true); // CapabilitiesSet { tls: true }
proto.snd_CapabilitiesSet(tls_caps).wait(); // ← 明文发出去
Reply_processor prc;
proto.rcv_Reply(prc).wait();
// 错误 5001 = "TLS not supported" → PREFERRED 模式下 m_tls = false
unique_ptr<TLS> tls_conn(new TLS(conn_ptr.release(), options));
tls_conn->connect(); // ← 这时才 SSL_connect()
return tls_conn.release();
}
顺序是——下面是实测抓包。图里 【见】= 抓包直接看到的, 【推】= 由明文那次会话推断的(TLS 之后全是密文,看不见内容):
客户端 mysqld X plugin (:33060)
│ │
│ ① TCP connect() ──明文──▶ │ 此刻还是裸 TCP
│ │
│ ② CapabilitiesSet{ tls:true } ──明文──▶ │ ★ 协议层先握手
│ 【见】24B 帧头 05 00 00 00 14 02 … │ len=20 type=2
│ 【见】payload 内含明文字段 "tls" │ (0a 03 74 6c 73)
│ │
│ ③ Notice(type=11) + Ok(type=0) ◀──明文── │ ★ 不是裸 Ok,见下
│ 【见】05 00 00 00 0b 08 05 1a 00 … │
│ │
├──────────── TLS 从这里才开始 ──────────────────────────┤
│ ④ ClientHello ──────────────────────────▶ │ ╔══════════════════╗
│ 【见】253B 0x16 03 01 type=1 │ ║ 之后全是密文, ║
│ │ ║ wireshark 只看 ║
│ ⑤ ServerHello ◀───────────────────────── │ ║ 得到长度和方向 ║
│ 【见】99B 0x16 03 03 type=2 │ ╚══════════════════╝
│ │
│ ⑥ (Certificate / ServerHelloDone 等,合并在 2196B 段) │
│ 【见】2196B 0x16 … │
│ │
│ ⑦ ChangeCipherSpec + ClientKeyExchange + Finished │
│ 【见】292B 14 03 03 00 01 01 16 03 03 01 19 … │
│ │
│ ⑧ AuthenticateStart / AuthenticateContinue / Ok │ ★ 认证数据在密文里
│ 【推】机制名不可见 │
│ │
│ ⑨ CapabilitiesSet{session_connect_attrs} │ (压缩协商也在密文里)
│ 【推】不可见 │
│ │
│ ⑩ StmtExecute{namespace:"sql", stmt:"SELECT 1"} │
│ 【推】不可见 │
│ ⑪ Resultset 帧流 ◀────────────────────── │
│ 【见】后续 16 段全是 0x17 ApplicationData │
抓包原文(demo/wire/capture-tls-default.log,共 22 段,前 7 段):
段 0 C->S 24B frame len=20 type=2 payload=0a110a0f0a03746c7312080801120408074001
段 1 S->C 14B frame len=5 type=11 payload=08051a0001000000 00
段 2 C->S 253B *** TLS 0x16(Handshake) ver=0301 type=1 ClientHello
段 3 S->C 99B *** TLS 0x16(Handshake) ver=0303 type=2 ServerHello
段 4 C->S 292B 14 03 03 00 01 01 | 16 03 03 01 19 … ← ChangeCipherSpec + 后续握手
段 5 S->C 2196B *** TLS 0x16(Handshake) … type=11 Certificate
段 6 C->S 88B *** TLS 0x17 ApplicationData ← 之后全程加密
段 7..21 … 全部 0x17 ApplicationData
段 1 不是 Ok,而是 Notice——这点我一开始也写错了
服务端回的第一帧 type = 0x0b = 11,按 ServerMessages_Type(protocol/mysqlx.h:98-114)是
Mysqlx::Notice::Frame,不是 Ok(type 0)。它的 payload 是 08 05 1a 00,按
mysqlx_notice.proto 解出来是:
08 05 → field 1 (required uint32 type) = 5
1a 00 → field 3 (optional bytes payload) = 空
但 Notice.Type 枚举只定义了 1~4(WARNING / SESSION_VARIABLE_CHANGED /
SESSION_STATE_CHANGED / GROUP_REPLICATION_STATE_CHANGED)。type=5 不在客户端的枚举里。
服务端是 MySQL 8.0.46、客户端是 Connector/C++ 26.7.0——这个 type=5 是什么意思,
本文没有结论,也不打算编一个。 在明文那次抓包里(capture-plaintext.log 段 1)
第一帧同样是 type=11,随后 4 个 CapabilitiesSet 各自收到一个 type=0 的 Ok。
所以规律是:Notice 先来一条,然后才是每个请求对应的 Ok。
三个可以直接从抓包读出来的结论
- 段 0/1 是明文。
CapabilitiesSet{tls:true}裸奔在第一条 TCP 段里,payload 里能直接 grep 出tls(0a 03 74 6c 73)。中间人能看到你连哪台服务器、发的是什么客户端。 - 段 2 才第一次出现
0x16(TLS Handshake 记录层)。 也就是SSL_connect()发生在 协议层握手之后,而不是connect()之后立刻做。 - 段 6 之后 16 段全是
0x17(ApplicationData)。 认证数据、SQL、结果全部不可见。
这跟 classic 协议的 CLIENT_SSL 旗标语义一致(先用裸连接换加密,再加密跑认证),
但实现方式完全不同——一个是位图协商,一个是 protobuf 消息。X 协议的副作用是
第 ② 步明文:攻击者能看到服务端地址、客户端版本和连接属性
(_client_name / _client_version / _client_license / _os / _pid / _platform),
看不到认证数据和 SQL。
真实 TLS 握手在 cdk/foundation/connection_openssl.cc:551-652:
// :593-628(节选)
if (ssl_mode >= VERIFY_CA) {
SSL_CTX_set_verify(SSL_CTX, SSL_VERIFY_PEER, nullptr);
SSL_CTX_load_verify_locations(m_tls_ctx, ca, ca_path);
X509_STORE_load_locations(store, crl); // CRL 校验
X509_VERIFY_PARAM_set_flags(param, X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL);
} else {
SSL_CTX_set_verify(m_tls_ctx, SSL_VERIFY_NONE, nullptr); // ← 不验证书!
}
...
m_tls = SSL_new(m_tls_ctx);
SSL_set_fd(m_tls, fd);
verify_server_cert(); // :649
if (SSL_connect(m_tls) != 1) throw_openssl_error(); // :652
注意 VERIFY_CA 走 SSL_VERIFY_PEER + verify_server_cert()(connection_openssl.cc:833,含 matches_common_name() :740 和 matches_alt_name() :788),但结合机制三里说的——VERIFY_CA 时 DevAPI 不调 tls.set_host_name(),主机名校验实际是关的。要真正防中间人,用 VERIFY_IDENTITY。
机制七:从 protobuf 消息到 ::send() 的最后一米
这是整条链最值得逐帧看的部分。从 .sql("SELECT 1").execute() 到 ::send():
mysqlx::SqlStatement::execute() include/mysqlx/devapi/executable.h:148
└─ common::Op_base::execute() common/op_impl.h:346
└─ Op_base::wait() common/op_impl.h:303
└─ Op_sql::send_command() common/op_impl.h:1352
└─ cdk::Session::sql(0, q, args) cdk/include/mysql/cdk/session.h:222
└─ new Cmd_StmtExecute(...) cdk/mysqlx/stmt.h:609
└─ Protocol::snd_StmtExecute() cdk/protocol/mysqlx/stmt.cc:112
└─ Msg_builder<cli_StmtExecute>::send() protocol.h:1013
└─ Protocol_impl::snd_start() protocol.cc:213
└─ Protocol_impl::write_msg() protocol.cc:371
└─ Protocol_impl::write() protocol.cc:461
└─ Stream::Impl<C>::write() protocol/mysqlx.h:835
└─ Socket_base::Write_op::do_wait() connection_tcpip.cc:395
└─ detail::send() socket_detail.cc:950
└─ send_some() + poll_one() socket_detail.cc:1021
└─ ::send() socket_detail.cc:1039
11 层,从一个 execute() 到 ::send()。 中间没有任何一步做 I/O 之外的”聪明事”——它们全在拼 protobuf。
write_msg() 干的三件事:
// cdk/protocol/mysqlx/protocol.cc:371-390, 444-457
void Protocol_impl::write_msg(msg_type_t msg_type, Message &msg)
{
msg_size_t net_size = static_cast<msg_size_t>(msg.ByteSizeLong()) + 1; // 376
resize_buf(CLIENT, header_length + net_size); // 378
msg.SerializeToArray(wr_buffer() + header_length, ...); // 385
...
// 396-441: 如果开了压缩且超过阈值,整个包再套一层 Mysqlx.Connection.Compression
HTONSIZE(net_size); // 444
memcpy(wr_buf, &net_size, sizeof(net_size)); // 445 ← 4 字节长度
wr_buf[header_length - 1] = (byte)msg_type; // 446 ← 1 字节消息类型
NTOHSIZE(net_size);
total_write_size = net_size + header_length - 1;
m_pipeline_size += total_write_size;
if (!m_pipeline) { write(wr_buf); } // 454-457
}
header_length 是 5(cdk/protocol/mysqlx/protocol.h:108)。抓包实测画出来是这样:
真实的一帧(StmtExecute,来自 capture-plaintext.log 段 10)
偏移 0 1 2 3 4 5 ...
┌───────────────┬──────┬────────────────────────────────────────┐
│ len (4B LE) │ type │ protobuf payload(len-1 字节) │
└───────────────┴──────┴────────────────────────────────────────┘
└─ 含 type 自己 ┘ 1B └─────── len - 1 ────────┘
实际字节:10 00 00 00 0c 0a 04 73 71 6c 12 07 53 45 4c 45 43 54 20 31
└─16─┘ └0c┘ └─ ns="sql" ─┘ └────── "SELECT 1" ──────┘
len=16 type=12
(小端) Sql.StmtExecute
┌─ 整帧在线上的字节数 = 4 + len = 4 + 16 = 20 ─────────────────────┐
│ 抓包该 TCP 段恰好 20 字节 ✓ │
└───────────────────────────────────────────────────────────────────┘
最容易搞错的一点:len 包含 type 那 1 个字节。所以:
| 关系 | 公式 |
|---|---|
| payload 长度 | len - 1 |
| 整帧长度 | 4 + len |
header_length 常量 |
5 = 4(len) + 1(type) |
对照 write_msg() 的代码(protocol.cc:444-448)就完全对上了:
HTONSIZE(net_size); // 444
memcpy(wr_buf, &net_size, sizeof(net_size)); // 445 ← 写 4 字节长度
wr_buf[header_length - 1] = (byte)msg_type; // 446 ← 索引 4,即第 5 字节
NTOHSIZE(net_size);
total_write_size = net_size + header_length - 1; // = len + 4 ✓
抓包里另一帧可以交叉验证 len-1:
段 6 C->S 18B len=14 type=4 AuthenticateStart payload=13B
payload 可见文本: "..MYSQL41...."
18 = 4 + 14 ✓ 14 - 1 = 13 ✓ 且明文里能直接读出机制名 MYSQL41
write() 本身只有一行有效代码:
// cdk/protocol/mysqlx/protocol.cc:461-464
void Protocol_impl::write(byte *buf)
{ m_wr_op.reset(m_str->write(buffers(buf, m_pipeline_size))); clear_Pipeline(); }
m_pipeline_size 这个变量揭示了一个优化:pipeline 模式下多条消息会攒在一起一次 write() 出去。看 send() 就明白了:
// cdk/protocol/mysqlx/protocol.h:1013-1024
Protocol::Op& send() {
if (m_stmt_id != 0) { // 预处理语句
m_protocol.start_Pipeline();
m_protocol.snd_start(m_prepare, msg_type::cli_PreparePrepare).wait(); // 40
m_protocol.snd_start(m_prepare_execute, msg_type::cli_PrepareExecute).wait(); // 41
return m_protocol.snd_Pipeline(); // 一次 write() 发出两帧
}
return m_protocol.snd_start(m_msg, T); // 单消息快路径
}
预处理语句的执行是两条消息一次写:Prepare.Prepare(40) + Prepare.Execute(41)。这就是 DevAPI 的 PS 状态机 PS_EXECUTE → PS_PREPARE_EXECUTE → PS_EXECUTE_PREPARED(common/op_impl.h:97-102、:517-559)的线上形态。
传输层是模板注入的——这是整个设计里最优雅的一处:
// cdk/include/mysql/cdk/protocol/mysqlx.h:822-836
template <class C> class Protocol::Stream::Impl : public Stream {
typedef typename C::Read_op Rd_op;
typedef typename C::Write_op Wr_op;
Op* read (const buffers &buf) { return new Rd_op (m_conn, buf); }
Op* write(const buffers &buf) { return new Wr_op(m_conn, buf); }
};
C 是 TCPIP(connection_tcpip.h:251)还是 TLS(connection_openssl.h:48),编译器在实例化时就定好了。协议层代码完全不知道自己跑在明文 TCP 还是 TLS 上。 两条路的叶子分别是:
TCP: Socket_base::Write_op → detail::send → ::send() socket_detail.cc:1039
TLS: TLS::Write_op → common_write → SSL_write() connection_openssl.cc:1045
机制八:结果回来时,帧头是 5 字节不是 4 字节
SELECT 的回复不是一条消息,是服务端连续发的一串消息,客户端按语法状态机逐条吃:
Stmt_op::do_cont cdk/mysqlx/result.cc:58
└─ Protocol::rcv_MetaData / rcv_Rows / rcv_StmtReply cdk/protocol/mysqlx/rset.cc:507/513/519
└─ Op_rcv::do_read_msg cdk/protocol/mysqlx/protocol.cc:782
├─ Protocol_impl::read_header() ← 读 5 字节帧头 protocol.cc:496
│ └─ m_str->read(buffers(m_rd_buf, 5)) protocol.cc:530
├─ Protocol_impl::read_payload() ← 读 len 字节 protocol.cc:534
├─ MessageLite::ParseFromArray protocol.cc:998
└─ Rcv_result_base::do_next_msg() ← 语法状态机 rset.cc:304
└─ process_msg_with(Row | ColumnMetaData | FetchDone | StmtExecuteOk)
└─ Cursor::row_begin / col_* / row_end cdk/mysqlx/result.cc:784
读取侧和写入侧是严格对称的:先 read_header() 读满 5 字节(protocol.cc:530),再 read_payload() 按头里的长度读 payload(protocol.cc:567)。顺序不能反,也不能少读——TCP 是字节流,没有消息边界,一切靠这个 5 字节头。
状态机在 Rcv_result_base::do_next_msg()(rset.cc:304-479),状态有 START / MDATA / ROWS / CLOSE / DONE(rset.cc:119-120)。行数据的分发是逐字段回调:
// cdk/protocol/mysqlx/rset.cc:569-606
template<>
void Rcv_result_base::process_msg_with(
Mysqlx::Resultset::Row &row, Row_processor &rp
)
{
row_count_t rcount = m_rcount++;
if (!rp.row_begin(rcount))
return; // 576 ← 处理器不要这行就整行跳过
col_count_t ccount = 0;
for (auto it = row.field().begin(); it != row.field().end(); ++it, ++ccount)
{
if (it->length() == 0)
{
rp.col_null(ccount); // 587 ← 零长度字段 == NULL
continue; // 588 ← 注意:直接跳过,col_begin 根本不会被调用
}
size_t read_window = rp.col_begin(ccount, it->length()); // 591
size_t pos = 0;
while (it->length() > pos && read_window) // 594 ← 读窗口循环
{
size_t bytes_to_feed = it->length() - pos > read_window ? read_window
: it->length() - pos;
size_t read_window_new = rp.col_data(ccount,
bytes((byte*)(it->c_str() + pos), bytes_to_feed)); // 597
pos += read_window;
read_window = read_window_new;
}
rp.col_end(ccount, it->length()); // 602
}
rp.row_end(rcount); // 605
}
这里有三个容易看漏的细节:
:587——X 协议用”零长度”表示 NULL。 这是 protobuf 字段的默认行为,客户端必须显式处理,否则空字符串和 NULL 会混淆。- NULL 字段根本不进
col_begin()。 那个continue意味着处理器的col_begin/col_end不会被调用——如果你自己写Row_processor,col_begin和col_end的调用次数不保证成对,只有非 NULL 字段才成对。 :594的读窗口循环:col_begin返回一个read_window(消费者一次能吃多少字节),col_data也返回新的窗口,于是大字段被分块投喂。窗口为 0 就停止——这就是”消费者主动限流”的实现。
这个”逐消息驱动”的模型还有个后果:Op_rcv 每读一条消息就回调一次处理器,所以结果集是流式消费的,不是攒齐了才给你。Result_impl 靠 prefetch_size 控制预取窗口(common/result.cc:317-319):
m_cursor->get_rows(*this, prefetch_size); // 带预取
m_cursor->get_rows(*this); // 不带
对大结果集来说,这是”能处理超过内存的结果”和”不能”的区别。
机制九:CRUD 不翻译成 SQL
这一条最容易让人意外。当你写:
coll.find("name = 'x'").limit(10).execute();
线上没有任何 SQL 文本。发出去的是 protobuf:
Mysqlx.Crud.Find (msg id 17)
├─ data_model = DOCUMENT
├─ collection = "库.表"
├─ limit = 10
└─ projection = Mysqlx.Expr.Expr (表达式树,不是字符串)
完整的 CRUD 消息映射(cdk/protocol/mysqlx/crud.cc):
| DevAPI 调用 | protobuf 消息 | msg id | 发送函数 |
|---|---|---|---|
Session::sql() |
Mysqlx.Sql.StmtExecute |
12 | stmt.cc:112 |
Collection::add() |
Mysqlx.Crud.Insert(doc 模式,带 upsert flag) |
18 | cdk/protocol/mysqlx/crud.cc:669 |
Collection::find() |
Mysqlx.Crud.Find |
17 | cdk/protocol/mysqlx/crud.cc:568 |
Collection::modify() |
Mysqlx.Crud.Update |
19 | cdk/protocol/mysqlx/crud.cc:776 |
Collection::remove() |
Mysqlx.Crud.Delete |
20 | cdk/protocol/mysqlx/crud.cc:812 |
Table::insert() |
Mysqlx.Crud.Insert(table 模式) |
18 | cdk/protocol/mysqlx/crud.cc:669 |
Table::update() |
Mysqlx.Crud.Update(table 模式) |
19 | cdk/protocol/mysqlx/crud.cc:776 |
这意味着写错字段名不会在客户端报 SQL 语法错,而是由服务端做 schema 校验后返回一个 protobuf error。如果你把 CRUD 路径当”SQL 的语法糖”来用,会失去 SQL 语法检查这一层保护。
那条件表达式 name = 'x' 是怎么变成 protobuf 的?靠 cdk 自带的一个表达式解析器:
// cdk/parser/expr_parser.h:1530
Expr_parser_base::parse(...)
它把字符串解析成 Mysqlx.Expr.Expr 树。.proto 定义全在 cdk/protocol/mysqlx/pb/mysqlx_expr.proto。所以 DevAPI 那套”点链式”语法(.find(expr).where(...).order_by(...))最终是客户端本地解析成表达式树,不是拼字符串。
同样地,事务是唯一的例外——它确实是普通 SQL:
// cdk/mysqlx/session.cc:883-927
// BEGIN / COMMIT / ROLLBACK / SAVEPOINT 走 SQL 字符串
这个不对称有点反直觉:CRUD 是结构化的,事务是文本的。
机制十:classic 路径是 mysql_real_query 的三层桥
现在切到另一条路。demo_b 的调用链:
sql::Connection::createStatement() jdbc/driver/mysql_connection.cpp:1862
└─ new MySQL_Statement(...) jdbc/driver/mysql_statement.h:70
└─ MySQL_Statement::executeQuery(sql) jdbc/driver/mysql_statement.cpp:252
└─ do_query(sql) jdbc/driver/mysql_statement.cpp:98
└─ proxy_p->query(q) :118
└─ MySQL_NativeConnectionWrapper::query() nativeapi/mysql_native_connection_wrapper.cpp:654
└─ api->real_query(mysql, str, len) :657
└─ LibmysqlStaticProxy::real_query() libmysql_static_proxy.cpp:454
└─ ::mysql_real_query() :456 ◄── 交给 libmysqlclient
整个 jdbc/driver/ 目录里没有一行协议代码。 sql::Statement::executeQuery 到 ::mysql_real_query 之间全是适配层。
三层桥
① sql::mysql::MySQL_Statement jdbc/driver/*
│ 持有 std::weak_ptr<NativeConnectionWrapper>
▼
② sql::mysql::NativeAPI::NativeConnectionWrapper nativeapi/native_connection_wrapper.h:73
│ virtual connect() / query() / store_result() / stmt_*
▼
③ sql::mysql::NativeAPI::IMySQLCAPI nativeapi/mysql_client_api.h:190
│ ~90 个纯虚函数 + 上一层的函数指针 typedef 表(:48-183)
▼
┌──────────────────────┬──────────────────────────────┐
│ LibmysqlStaticProxy │ LibmysqlDynamicProxy │
│ 直接 ::mysql_xxx() │ dlopen + dlsym("mysql_xxx") │
└──────────────────────┴──────────────────────────────┘
第 ③ 层的接口注释写得很直白(mysql_client_api.h:185-189):
“Interface MySQL C-API wrapper class should implement. At the moment we must have at least 2 implementation - for static and dynamic mysql client library linking”
编译期二选一(nativeapi/mysql_client_api.cpp:39-45):
#ifdef MYSQLCLIENT_STATIC_BINDING
#include "libmysql_static_proxy.h"
#else
#include "libmysql_dynamic_proxy.h"
#endif
MYSQLCLIENT_STATIC_BINDING 决定”调用时怎么拿到函数”(直接符号 vs dlsym),MYSQLCLIENT_STATIC_LINKING 决定”链接时怎么挂上去”(.a vs .so)——两个不同的开关,名字很像,很容易混。默认在 jdbc/CMakeLists.txt:89-90:
add_config_option(MYSQLCLIENT_STATIC_LINKING BOOL ADVANCED DEFAULT OFF "enable libmysqlclient static linking")
add_config_option(MYSQLCLIENT_STATIC_BINDING BOOL ADVANCED DEFAULT ON "enable libmysqlclient static binding")
默认是”链接期动态 + 调用期静态”——也就是链接 libmysqlclient.so,但代码里直接写 ::mysql_real_query()。若要 dlopen:
# jdbc/driver/CMakeLists.txt:215-222
if(MYSQLCLIENT_STATIC_BINDING)
target_link_libraries(jdbc PRIVATE MySQL::client) # 硬链接
else()
target_include_directories(jdbc PRIVATE ${MYSQL_INCLUDE_DIR})
if(NOT WIN32) target_link_libraries(jdbc PRIVATE dl) # 运行时 dlopen
endif()
动态路径的 SONAME 默认是 "libmysqlclient_r.so"(libmysql_dynamic_proxy.cpp:52),符号懒解析(library_loader.cpp:48-50):
#define LoadLibrary(p) ::dlopen(p, RTLD_LAZY)
#define FreeLibrary(p) ::dlclose(p)
#define GetProcAddress(p1,p2) ::dlsym(p1,p2)
dlopen 模式的价值是可替换 libmysqlclient 而不用重编译连接器。但 dlsym 找不到符号时抛的是 "Couldn't find symbol "(library_loader.cpp:113-135)——这个报错信息本身不告诉你是哪个符号。
URL 解析:没有 jdbc: 前缀
这是个容易踩的细节。MySQL_Driver::connect() 的第一个参数名叫 hostName(jdbc/driver/mysql_driver.cpp:126-131),但它实际是整个 URL 字符串。解析器 parseUri()(jdbc/driver/mysql_uri.cpp:124-260)接受:
| 前缀 | 协议 |
|---|---|
tcp:// |
TCP |
unix:// |
Unix socket |
pipe:// |
Windows 命名管道 |
mysql:// |
TCP |
| 无前缀 | host[:port][/schema] |
"jdbc:mysql://" 不被支持——"jdbc:" 在 jdbc/**/*.cpp 里零命中。写 jdbc:mysql://host:3306/db 会被当成主机名 jdbc 后面跟一堆垃圾,解析失败抛:
sql::InvalidArgumentException("Invalid hostname URI") mysql_connection.cpp:828
另外逗号分隔的主机列表是支持的(mysql_uri.cpp:231-243),配合 OPT_MULTI_HOST 做故障转移;IPv6 要用方括号 [::1]:3306(mysql_uri.cpp:174-186)。
Statement vs PreparedStatement:文本 vs 二进制
Statement 路径的结果是字符串:
// jdbc/driver/mysql_resultset.cpp:742-772(节选)
sql::SQLString MySQL_ResultSet::getString(uint32_t columnIndex) {
size_t len = result->fetch_lengths()[columnIndex - 1]; // 769
return sql::SQLString(row[columnIndex-1], len); // 772
}
row 是 MYSQL_ROW(char**),libmysqlclient 已经把线上字节转成了文本。getInt() 是先 atol() 那个字符串。
PreparedStatement 路径完全不一样——服务端下发二进制行,客户端用预分配的 MYSQL_BIND 缓冲区直接接:
// jdbc/driver/mysql_resultbind.cpp:354-369(节选)
for (每个 field) {
rbind[i].buffer_type = p.type; // 358 按列的真实类型
rbind[i].buffer = p.buffer; // 359
rbind[i].length = &len[i]; // 361
rbind[i].is_null = &is_null[i]; // 362
rbind[i].is_unsigned = field->flags & UNSIGNED_FLAG; // 364
}
if (proxy->bind_result(rbind.get())) throwSQLException(...); // 366-369
读取时 getInt64_intern() 直接从 rbind[i].buffer 取 8 字节(mysql_ps_resultset.h:92-93),没有字符串中间层。这是 PS 相对 Statement 的实质性能差异,不只是”能防注入”。
参数侧同理,setInt() 是在填 MYSQL_BIND(jdbc/driver/mysql_prepared_statement.cpp:1115-1149):
--parameterIndex; // 1125 DBC 从 1 开始
enum_field_types t = MYSQL_TYPE_LONG; // 1133
BufferSizePair p = allocate_buffer_for_type(t); // 1135
param->buffer_type = t; // 1140
param->buffer = p.first; // 1142
memcpy(param->buffer, &value, p.second); // 1148
MySQL_Bind : public MYSQL_BIND(jdbc/driver/mysql_resultbind.h:55)——连接器直接继承 C API 的结构体,靠 setBigInt/setString 这些薄封装往里填。
机制十一:classic 路径默认不编译,还有一堆死代码
如果你决定用 classic 路径,构建时要显式打开,并告诉它 libmysqlclient 在哪:
# ❌ 网上(包括本文早期版本)常见的写法,在 Debian/Ubuntu/麒麟 上必失败
cmake .. -DWITH_JDBC=ON -DWITH_MYSQL=/usr
# ✅✅ 首选:一个 mysql_config 搞定全部(实测)
cmake .. -DWITH_JDBC=ON \
-DMYSQL_CONFIG_EXECUTABLE=/usr/bin/mysql_config
优先用 mysql_config,因为 use_mysql_config()(DepFindMySQL.cmake:483-511)会一次性
把四个变量都填对——实测输出:
MYSQL_INCLUDE_DIR:PATH=/usr/include/mysql ← 指向 mysql.h 真正所在
MYSQL_LIB_DIR:PATH=/usr/lib/x86_64-linux-gnu
MYSQL_VERSION:INTERNAL=8.0.42
MYSQL_VERSION_ID:INTERNAL=80042 ← 各个 #if 守卫就靠它
注意 mysql_config 报的是它自己被安装到的位置,所以离线环境下把 libmysqlclient
解到别的目录也能用——实测把它指向一个 relocatable 目录后,四个变量全部正确指向新位置。
没有 mysql_config 时才手工给四个:
cmake .. -DWITH_JDBC=ON -DCMAKE_BUILD_TYPE=Release \
-DMYSQL_VERSION=8.0.42 -DMYSQL_VERSION_ID=80042 \
-DMYSQL_INCLUDE_DIR=/usr/include/mysql \
-DMYSQL_LIB_DIR=/usr/lib/x86_64-linux-gnu
MYSQL_VERSION_ID 必须和 MYSQL_VERSION 对应(8.0.42 → 80042),否则那些
#if MYSQL_VERSION_ID >= 80300 守卫会走错分支。
⚠️
MYSQL_INCLUDE_DIR只能是单目录。DepFindMySQL.cmake:135硬查EXISTS ${MYSQL_INCLUDE_DIR}/mysql.h,给分号列表会直接报Could not find MySQL headers。但只给/usr/include/mysql又不够——mysql_com.h内部写的是#include "mysql/udf_registration_types.h"(带前缀), 还需要/usr/include也在搜索路径上。解法是做一个同时满足两种写法的目录 (把mysql/整个链进去,再把每个.h软链到顶层),实测这样两边都能过。
为什么 -DWITH_MYSQL=/usr 不行:它会推出 MYSQL_INCLUDE_DIR=/usr/include,然后做这个检查(jdbc/cmake/DepFindMySQL.cmake:134):
if(NOT MYSQL_INCLUDE_DIR OR NOT EXISTS "${MYSQL_INCLUDE_DIR}/mysql.h")
message(FATAL_ERROR "Could not find MySQL headers at: ${MYSQL_INCLUDE_DIR}\n" ...)
它要求 ${MYSQL_INCLUDE_DIR}/mysql.h 直接存在。但发行版的 libmysqlclient-dev 装在 /usr/include/mysql/mysql.h,/usr/include/mysql.h 并不存在 → 直接 FATAL_ERROR: Could not find MySQL headers at: /usr/include。
WITH_MYSQL / MYSQL_DIR 只适合自编译安装的 libmysqlclient(那种布局是 <prefix>/include/mysql.h)。三个变量都可以用,WITH_MYSQL 会覆盖 MYSQL_DIR(DepFindMySQL.cmake:87-89),再推 MYSQL_LIB_DIR / MYSQL_PLUGIN_DIR(:117-127)。
⚠️ classic 路径对 libmysqlclient 版本有硬要求(实测)
这是本文最重要的一条实践结论,编译验证过:libmysql_static_proxy.cpp 无版本保护地调用三个符号——
libmysql_static_proxy.cpp:376 ::mysql_plugin_get_option(...)
libmysql_static_proxy.cpp:427 ::mysql_real_connect_dns_srv(...)
libmysql_static_proxy.cpp:438 ::mysql_bind_param(...)
(9.2.0 的 :376 / :427 / :438 一模一样,没有 #if 保护。)
实测各版本:
| libmysqlclient | 三个符号 | glibc 要求 | OpenSSL | 能否编过 |
|---|---|---|---|---|
| 8.0.19(focal 初始发行版) | ✗ ✗ ✗ | >= 2.28 ✅ |
libssl1.1 ✅ |
不能 |
| 8.0.42(focal-updates) | ✓ ✓ ✓ | >= 2.28 ✅ |
libssl1.1 ✅ |
能(实测通过) |
| 8.0.46(jammy-updates) | ✓ ✓ ✓ | >= 2.34 ❌ |
libssl3 ❌ |
能,但装不上 |
用 8.0.19 编的实际报错:
jdbc/driver/nativeapi/libmysql_static_proxy.cpp:376:9: error:
'::mysql_plugin_get_option' has not been declared; did you mean 'ptr2mysql_plugin_get_option'?
⚠️ 勘误。 本文早期版本在这里写的是”对麒麟 V10 SP1 是个死结,只能走 X DevAPI”。 那个结论是错的,错因是版本选错了,不是平台不行。 我当时手上只有
8.0.19(focal 初始发行版)和8.0.46(jammy),前者太老、后者太新, 就误判成”没有可用版本”。后来在真的麒麟 V10 SP1 容器(
liyaosong/kylin:v10.1-sp1-amd64, glibc2.31-0kylin9.1k20.3)里逐个试了一遍,结论是:
| libmysqlclient | 来源 | 三个符号 | 麒麟上编 classic |
|---|---|---|---|
| 8.0.19 | focal 初始发行版 | ✗ ✗ ✗ | ❌ 符号缺失 |
| 8.0.26 | 麒麟自带源 | ✗ ✅ ✅ | ❌ 实测 make exit=2 |
| 8.0.42 | focal-updates | ✓ ✓ ✓ | ✅ 实测 make exit=0 + demo exit=0 |
| 8.0.46 | jammy-updates | ✓ ✓ ✓ | 装不上(libc6 >= 2.34 + libssl3) |
麒麟自己的源里只有 8.0.26,差在
mysql_plugin_get_option——它声明在client_plugin.h(不是mysql.h),8.0.26 没有:jdbc/driver/nativeapi/libmysql_static_proxy.cpp:376:9: error: '::mysql_plugin_get_option' has not been declared; did you mean 'ptr2mysql_plugin_get_option'?换上 focal-updates 的 8.0.42 后,只装 2 个包(
libmysqlclient21+libmysqlclient-dev),dpkg -i退出码 0、dpkg --audit干净、麒麟自带的libssl1.1/zlib1g/mysql-common全部未动;随后在容器内从源码编出libmysqlcppconn.so.10.26.7.0(最高只要求GLIBC_2.14),CRUD demo 跑通, 连caching_sha2_password账号全绿。教训一:手上只有两个候选版本时,容易把”我没试过的那个”当成”不存在的”。 教训二:别只 grep
mysql.h。我第一轮查 8.0.26 时只 grep 了mysql.h, 得出”三个全无”,实际有两个是有的。判据只能是编译。
| 方案 | 可行性 |
|---|---|
| 源码编 X DevAPI(只要目标机自带的 libssl-dev) | ✅ 实测通过,完全不碰 libmysqlclient |
| 源码编 classic + libmysqlclient 8.0.42 | ✅ 实测通过(麒麟容器内编 + 跑) |
| 源码编 classic + 麒麟自带 8.0.26 | ❌ libmysql_static_proxy.cpp:376 |
| 源码编 classic + libmysqlclient 8.0.19 | ❌ 符号缺失 |
| 源码编 classic + libmysqlclient 8.0.46 | ❌ glibc / OpenSSL 不够 |
用预编译的 libmysqlcppconn.so |
✅ 若它是在 glibc ≤ 2.31 上构建的 |
最后一行仍然成立:这些符号只在编译期需要。如果你的环境里已有别人编好的 libmysqlcppconn.so,运行期不再需要它们,直接用即可。
符号检查时注意 nm 输出的版本后缀,否则会误判:
$ nm -D --defined-only libmysqlclient.so.21.2.42 | grep mysql_bind_param
0000000000031500 T mysql_bind_param@@libmysqlclient_21.0
# ^^^^^^^^^^^^^^^^^^^ 带 @@libmysqlclient_21.0 后缀
按裸符号名 mysql_bind_param 精确匹配会判成”缺失”——我第一次就是这么错的。
选版本判据:拿 nm -D 的输出比 grep 裸名字可靠;再对着
MYSQL_VERSION_ID 确认 >= 80300 / >= 80400 这类分界,最后才动手编。
Linux 上的最终链接行(jdbc/driver/CMakeLists.txt:203-222):
libmysqlcppconn.so ← libjdbc.a + pthread + resolv + dl [+ MySQL::client]
三个需要知道的坑
1. jdbc/thread/ 是死代码。
目录里有完整的 my_pthread.c、thr_mutex.c、my_wincond.c……还配了个 CMakeLists.txt 要生成 mysqlcppconn_thread 库。但:
# jdbc/CMakeLists.txt:244-245 —— 只有这两个
ADD_SUBDIRECTORY(cppconn)
ADD_SUBDIRECTORY(driver)
全树 grep add_subdirectory(thread) 零命中,jdbc/driver/CMakeLists.txt:97-121 的源文件列表里也没有 ../thread/*.c。唯一的另一个引用是 jdbc.cmake:188——而 jdbc.cmake 从来没有被 include() 过,而且它在 jdbc.cmake:143 就 return() 了,后面 270 行代码不可达。
结论:这个目录里的东西一个都没被编译、没被打包、没被安装。 连接器直接链系统 pthread(jdbc/driver/CMakeLists.txt:208),用 C++17 的 <mutex>/<shared_mutex>。这是 MySQL 早期 vendored my_pthread shim 的历史遗留。
顺带一提,jdbc/FindMySQL.cmake(864 行)和 jdbc.cmake(413 行)也都是孤儿,实际生效的是 jdbc/cmake/DepFindMySQL.cmake。
2. get_driver_instance() 不是线程安全的。
// jdbc/driver/mysql_driver.cpp:79-98(节选)
sql::mysql::MySQL_Driver * _get_driver_instance_by_name(const char * const clientlib)
{
static std::map<SQLString, std::shared_ptr<MySQL_Driver>> driver; // 84 无锁
...
}
函数内 static 局部变量的初始化是线程安全的(C++11 起保证),但之后的 find / operator[] 插入不是。getCApiHandle() 里的 static std::map 也一样(nativeapi/mysql_client_api.cpp:55)。官方文档确认了这点(doc/jdbc_ref.txt:47-50):
“The
get_driver_instance()function is not thread-safe. Either avoid invoking it from within multiple threads at once, or surround the calls with a mutex to prevent simultaneous execution in multiple threads.” (doc/jdbc_ref.txt:48-50,逐字)
正确姿势是:driver 在 main 里取一次共享给所有线程,connection 每个线程自己建一个。 官方示例 jdbc/examples/pthreads.cpp 前半段正是这么做的——jdbc/examples/pthreads.cpp:125 取一次 driver,jdbc/examples/pthreads.cpp:129 建连接。
但要注意这个示例后半段把同一个 con 也传给了线程(jdbc/examples/pthreads.cpp:147 param->con = con.get(),线程里 jdbc/examples/pthreads.cpp:216 直接用它 createStatement())。那是为了演示”最小可编译”而写的,不是推荐做法:连接器在语句/结果集路径上没有任何互斥(MySQL_Statement / MySQL_ResultSet 只持 std::weak_ptr,不持锁),一个 MYSQL* 被两个线程同时用属于未定义行为。
| 对象 | 是否可跨线程共享 |
|---|---|
sql::Driver |
✅ 可以(在 main 取一次) |
sql::Connection |
❌ 不行,一个线程一个 |
sql::Statement / ResultSet |
❌ 不行,且生命周期不得长于其 Connection |
3. 异常层次比 JDBC 少很多。
std::runtime_error
└── sql::SQLException jdbc/cppconn/exception.h:54
├── MethodNotImplementedException :97
├── InvalidArgumentException :103
├── InvalidInstanceException :109
├── NonScrollableException :116
└── SQLUnsupportedOptionException :122
没有 SQLTimeoutException、BatchUpdateException、NoDataFoundException、DataTruncation——从 JDBC 迁过来的代码会找不到这些类型。
而 sql::SQLWarning 根本不是异常,是独立的抽象接口(jdbc/cppconn/warning.h:50),不继承 SQLException。想按 JDBC 的习惯 catch (SQLException&) 一把抓Warning 是抓不到的。
错误码统一从一个地方转换(jdbc/driver/mysql_util.cpp:55-58):
void throwSQLException(NativeConnectionWrapper & proxy) {
throw sql::SQLException(proxy.error(), proxy.sqlstate(), proxy.errNo()); }
三个值分别来自 mysql_error() / mysql_sqlstate() / mysql_errno()。
有意思的是 Warning 的获取方式——它不是 C API 给的,而是再发一条 SQL(jdbc/driver/mysql_warning.cpp:229):
stmt->executeQuery("SHOW WARNINGS");
SQLSTATE 还得在客户端反推(mysql_warning.cpp:100-218 的 errCode2SqlState())。这比 libmysqlclient 直接给结构化的 MYSQL_WARNING 差一层。
4. 还有一批 throw MethodNotImplementedException 的坑。Connection::setHoldability、setCatalog(no-op)、Statement::cancel、setMaxRows、setFetchSize、getFetchSize、ResultSet::getRowId……都直接抛。
另外 MySQL_Savepoint::getSavepointId() 无条件抛(mysql_connection.cpp:122-128):
int MySQL_Savepoint::getSavepointId()
{
throw sql::InvalidArgumentException("Only named savepoints are supported.");
return 0; // fool compilers
}
即只支持具名 savepoint。注意那句 return 0; // fool compilers——throw 之后代码不可达,
但标准不保证编译器知道,所以还得写一句骗它,否则 -Wreturn-type 会报。这是”故意不可达”
的常见写法,全树还有几处同款。
还有一个隐蔽的:WE_SUPPORT_USE_RESULT_WITH_PS 这个宏从来没被定义过(全树 0 处 #define),
所以三个 #if 分支全被编译掉(mysql_connection.cpp:1145、:2256,
mysql_prepared_statement.cpp:1722——第四处 jdbc/test/unit/classes/connection.cpp:152
只是一句注释 "compiled without -DWE_SUPPORT_USE_RESULT_WITH_PS")。
后果是 defaultPreparedStatementResultType 作为连接选项会抛
"parameter still not implemented"。
5. 五个类的析构函数是 protected。sql::Driver、DatabaseMetaData、ResultSetMetaData、ParameterMetaData、SQLWarning 的析构都是 protected(分别在 jdbc/cppconn/driver.h:46、jdbc/cppconn/metadata.h:49、jdbc/cppconn/resultset_metadata.h:101、jdbc/cppconn/parameter_metadata.h:78、jdbc/cppconn/warning.h:68),所以不能放进 std::unique_ptr——那要求 public 析构。得用裸指针。而且这些 metadata 的 getter 全是非 const,指针连 const 都不能加:
⚠️
sql::Driver那个 protected 析构不是”编译器较真”,是官方明确要求你别 delete 它。 文档原文(doc/jdbc_ref.txt:44-45,逐字,9.2.0 与 26.7.0 一致):“However, the driver instance should not be explicitly deleted, the connector takes care of freeing it.”
也就是说
get_driver_instance()返回的对象归连接器所有,生命周期是进程级。 正确写法是拿裸指针用着、永远不 delete(也别交给unique_ptr):sql::Driver* driver = sql::mysql::get_mysql_driver_instance(); // 不 delete std::unique_ptr<sql::Connection> con(driver->connect(url, user, pass));顺带说明为什么这五个类的析构都是 protected 而不是 public:它们要么归库管 (
Driver、SQLWarning),要么是随Connection一起死的视图对象 (三个*Metadata)——都不该由调用方单独 delete。
// ❌ 编译不过:protected destructor
std::unique_ptr<sql::DatabaseMetaData> meta(con->getMetaData());
// ✅ 裸指针,非 const
sql::DatabaseMetaData* meta = con->getMetaData();
std::cout << meta->getDatabaseProductVersion();
6. 构建树的 include/mysql/jdbc.h 不能直接用来编译。它内部全是 #include "../jdbc/xxx.h"(jdbc.h:30-49),而那个扁平布局只有 make install 之后才存在(源码树里这些头在 jdbc/driver/ 和 jdbc/cppconn/)。所以编译 classic 程序必须对着安装前缀:
make install # 库装到 <prefix>/lib64(不是 lib)
g++ -std=c++17 app.cpp -I<prefix>/include -L<prefix>/lib64 -lmysqlcppconn -lmysqlclient
顺带印证机制一说的 ABI:安装产物是
libmysqlcppconn.so.10.26.7.0——JDBC 侧 ABI 主版本 10(version.cmake的JDBC_ABI_VERSION),X 侧是 2。两个库 soname 不同,可以并存。
两套栈的完整对照表
| 维度 | X DevAPI | JDBC 风格 |
|---|---|---|
| 头文件 | <mysqlx/xdevapi.h> |
<mysql/jdbc.h> |
| 库 | libmysqlcppconnx.so.2.26.7.0 |
libmysqlcppconn.so |
| 构建开关 | 默认开 | -DWITH_JDBC=ON |
| 默认端口 | 33060 | 3306 |
| 协议 | X Protocol(protobuf,5 字节帧头) | classic MySQL 协议 |
| 协议实现 | 连接器自己(cdk/,vendored protobuf 3.19.6) |
外部 libmysqlclient |
| 特性协商 | Mysqlx.Expect.Open + 错误码试错 |
32 位 capability 位图 |
| 认证 | MYSQL41 / SHA256_MEMORY / PLAIN / EXTERNAL | libmysqlclient 的全部插件,含 caching_sha2_password |
caching_sha2_password |
不支持 | 支持 |
ssl-mode 默认 |
REQUIRED |
PREFERRED(libmysqlclient 默认) |
| 连接字符串 | URI + JSON | tcp:// / unix:// / mysql:// / 裸 host:port;不支持 jdbc: |
| 对象模型 | Session → Schema → Collection → Document |
Connection → Statement → ResultSet |
| CRUD | protobuf Mysqlx.Crud.* |
普通 SQL 文本 |
| 事务 | 普通 SQL 文本 | 普通 SQL 文本 |
| 结果类型 | 流式 + 预取窗口 | MYSQL_RES* 惰性游标 / MYSQL_BIND 二进制 |
| 取值 API | Row::operator[] → Value::get<T>() |
ResultSet::getString/getInt/...(都是字符串) |
| 结果遍历 | fetchAll() / fetchOne() |
ResultSet::next() |
| 参数绑定 | 仅 CRUD 语句(sql().bind() 抛 Too many arguments) |
PreparedStatement::setXxx() 全支持 |
| ABI soname | libmysqlcppconnx.so.2 |
libmysqlcppconn.so.10 |
| 连接池 | ✅ Session_pool(默认 25,TTL 10 min) |
❌ 无 |
| 多主机故障转移 | ✅ Multi_source + 优先级 + DNS SRV |
✅ 逗号分隔主机列表 + OPT_MULTI_HOST |
| 错误模型 | cdk::Error / Mysqlx_exception |
sql::SQLException + 5 子类 + SQLSTATE |
| 线程安全 | 池内部有锁 | get_driver_instance() 无锁;语句/结果集无锁 |
| 最低服务端 | 8.0+(doc/usage.txt:43-45:”MySQL Server 8 or later”) |
pre-8.0(:46-47:”earlier than version 8”) |
动手:构建与最小可运行例子
只构建 X DevAPI
⚠️
make -j4本身不够,必须改两个 CMake 文件。 这一节是本文实测踩得最疼的地方。先看现象:8 核机上
make -j4,loadavg依然冲到 39.96 ~ 50.44,cc1plus进程 82 个。满核-j8时 43.78。此时systemctl会排队甚至超时。根因不是”子构建忽略
-j“,是参数在 CMake 里被拆坏了。cdk/cmake/dependency.cmake:334-339取满核拼成列表:ProcessorCount(prc_cnt) list(APPEND build_opt --parallel ${prc_cnt}) # 列表: --parallel;8然后
cdk/cmake/dependency.cmake:357传参时没加引号:-DOPTS=${build_opt}生成的 Makefile 里实际是
-DOPTS=--parallel 8——一个 argv。于是cdk/cmake/ext/ext-build.cmake:50收到的OPTS只有--parallel,那个8变成游离参数被丢弃。cmake --build拿到不带数值的--parallel,按其文档 就是”用 native build tool 的默认并发”,对 Make 即不限并发:gmake[3]: warning: -j0 forced in submake: resetting jobserver mode.光加引号也不行——
"-DOPTS=--parallel;8"里那个;会被 Make 当命令分隔符, 报/bin/sh: 1: 8: not found。而且这四个${NAME}-build是无依赖顺序的add_custom_target,protobuf / zstd / zlib / lz4 会同时起。改法——把并发数当独立标量传,在脚本里自己拼:
# cdk/cmake/dependency.cmake:删掉拼 build_opt 那两行 ProcessorCount(prc_cnt) if(DEFINED WITH_EXT_BUILD_PARALLEL) set(prc_cnt ${WITH_EXT_BUILD_PARALLEL}) elseif(prc_cnt AND prc_cnt GREATER 4) set(prc_cnt 4) endif() add_custom_target(${NAME}-build COMMAND ${CMAKE_COMMAND} -DBIN_DIR=${bin_dir} -DCONFIG=$<CONFIG> -DPARALLEL=${prc_cnt} # ← 独立标量 -DOPTS=${OPTS_EXTRA} # ← 不再塞 --parallel -P ${EXT_DIR}/ext-build.cmake# cdk/cmake/ext/ext-build.cmake:自己拼 --parallel set(build_args --build ${BIN_DIR} --config ${CONFIG}) if(PARALLEL AND NOT CMAKE_VERSION VERSION_LESS 3.12) list(APPEND build_args --parallel ${PARALLEL}) endif() execute_process(COMMAND ${CMAKE_COMMAND} ${build_args} ${OPTS} RESULT_VARIABLE res)然后
cmake .. -DWITH_EXT_BUILD_PARALLEL=2+make -j4。实测对比(同一台 8 核机、同一份源码):
顶层 外部子构建 峰值 loadavg 改之前 make -j4不限并发 × 4 个并行 39.96 ~ 50.44 改之后 make -j4--parallel 27.33 打了补丁后
grep -c 'forced in submake'仍可能非 0(protobuf 自己内部还有子构建)。 判据看 loadavg,不要看警告计数。
tar -xzf mysql-connector-c++-26.7.0-src.tar.gz
cd mysql-connector-c++-26.7.0-src
mkdir build && cd build
# WITH_SSL 决定能不能开 TLS;关掉它,ssl-mode 就只能 DISABLED
# WITH_EXT_BUILD_PARALLEL 需要先打上面那两个补丁,否则不生效
cmake .. -DCMAKE_BUILD_TYPE=Release -DWITH_SSL=ON -DBUILD_STATIC=OFF \
-DWITH_EXT_BUILD_PARALLEL=2
make -j4
产物:libmysqlcppconnx.so.2.26.7.0(soname libmysqlcppconnx.so.2)。
验证一下”X 侧不依赖 libmysqlclient”:
ldd libmysqlcppconnx.so.2.26.7.0 | grep -i mysql
# 只有 libmysqlcppconnx 自己 —— 没有 libmysqlclient
加上 classic 路径
先装 C API 头和库(classic 路径唯一的硬依赖):
sudo apt install libmysqlclient-dev # Debian/Ubuntu;或 default-libmysqlclient-dev
再配置。注意这里不是 -DWITH_MYSQL=/usr——那个写法在 Debian 系上必失败,
原因见机制十一:
cmake .. -DWITH_JDBC=ON -DMYSQL_CONFIG_EXECUTABLE=/usr/bin/mysql_config
make -j4 && make install # 同样需要上面那两个补丁
⚠️ 在银河麒麟 V10 SP1 上这一步会因 libmysqlclient 版本不够而编译失败 (缺
mysql_real_connect_dns_srv等三个符号)。详见机制十一那节, 以及demo/README.md的实测记录。X DevAPI 路径不受影响。
编译你的程序
g++ -std=c++17 demo_a.cpp -o demo_a \
-I<prefix>/include/mysqlx \
-L<prefix>/lib -lmysqlcppconnx -lssl -lcrypto
注意这里不需要 -lzstd -llz4 -lz -lprotobuf-lite。 机制一说过它们是 vendored 的——cdk/protocol/mysqlx/CMakeLists.txt:135-139 把 ext::protobuf-lite / ext::z / ext::lz4 / ext::zstd 以 PRIVATE 链进静态库 cdk_proto_mysqlx,再由 merge_libraries 在 POST_BUILD 阶段把这些 .a 全部折叠进最终的 .so。所以它们是静态吸收进去的,链接你的程序时根本不用管。
唯一的外部运行时依赖是 OpenSSL(libssl + libcrypto)。这一点实测可验:
ldd libmysqlcppconnx.so.2.26.7.0
# 期望:libssl / libcrypto / libstdc++ / libm / libc ...
# 不期望:libprotobuf-lite / libzstd / liblz4 / libmysqlclient
classic 那边(注意 lib64,且必须先 make install——见机制十一第 6 点):
g++ -std=c++17 demo_b.cpp -o demo_b \
-I<prefix>/include \
-L<prefix>/lib64 -lmysqlcppconn -lmysqlclient
本文配套两个可直接运行的 CRUD demo(增删改查全流程,含实测输出与踩坑记录):
demo/demo_xdevapi_crud.cpp和demo/demo_jdbc_crud.cpp, 说明见同目录README.md。两者都在真实 MySQL 8.0.46 上验证过EXIT=0。
如果你的项目用 CMake
别手写 -I/-L,包文件已经把 target 备好了(mysql-concpp-config.cmake.in:38-44):
find_package(mysql-concpp REQUIRED)
target_link_libraries(my-xdevapi-app mysql::concpp) # X DevAPI
target_link_libraries(my-classic-app mysql::concpp-jdbc) # classic API
别名关系:mysql::concpp = mysql::concpp-xdevapi,mysql::concpp-static = mysql::concpp-xdevapi-static(mysql-concpp-config.cmake.in:43-44)。
注意 concpp-jdbc-static 会让 libmysqlclient 变成传递依赖(:123)——这正是机制十里”classic 路径把协议全部外包”的必然结果。
官方文档还提醒:
<mysql/jdbc.h>这个头是 8.0.16 才有的(doc/usage.txt:60)。更早的版本要 include 一堆分散的头。这个坑只影响从很老的版本升上来的人。
离线环境(银河麒麟 V10 SP1 / 内网):JDBC 路径要准备什么
如果你在内网、且用 classic(JDBC)路径,那么”导入依赖包”这件事有个明确的最小集。关键认知:JDBC 路径唯一真正外部的依赖是 libmysqlclient,其余全是系统库。
JDBC 路径的运行时依赖(从 jdbc/driver/CMakeLists.txt:203-222 反推):
| 库 | 来自 | 是否必需 |
|---|---|---|
libmysqlclient.so |
libmysqlclient-dev / libmysqlclient21 |
必需(唯一硬依赖) |
libssl.so / libcrypto.so |
libssl-dev / libssl1.1 |
必需(resolv/dl/pthread 属 libc) |
libz.so |
zlib1g-dev |
必需 |
mysql.h、errmsg.h、mysqld_error.h |
libmysqlclient-dev |
编译必需 |
而 X DevAPI 路径不需要 libmysqlclient(机制一已证),它需要的是 OpenSSL + 一个 C++17 编译器;protobuf / zstd / lz4 / rapidjson 全部 vendored 在 cdk/extra/ 里,会被静态合进产物。
所以两条路径的离线准备清单是不一样的,别混:
X DevAPI 离线清单 classic 离线清单
───────────────── ─────────────────
gcc/g++ ≥ 9 (C++17) gcc/g++ ≥ 9
cmake ≥ 3.16 cmake ≥ 3.16
libssl-dev + libssl1.1 libssl-dev + libssl1.1
(无 libmysqlclient) zlib1g-dev
libmysqlclient-dev ← 关键,就这一个
libmysqlclient21
mysql-common
最容易踩的坑:别去凑 Connector/C++ 的 deb,用源码编。 官方 deb 是针对 Ubuntu jammy/noble 之类构建的,glibc 需求会高于国产发行版(Kylin V10 SP1 是 glibc 2.31,jammy 是 2.35)。硬装会在 ldd 阶段报 GLIBC_2.34 not found,而且这种错很难一眼看出根因。用 mysql-connector-c++-26.7.0-src.tar.gz 在目标机上现编,产物天然匹配它的 glibc。
第二个坑:libssl-dev 无法被关掉。 你可能以为 -DWITH_SSL=OFF 就不需要 OpenSSL 了——不是:
# CMakeLists.txt:348-351
# Note: Find OpenSSL early because it is needed by both CDK and JDBC (in case
# of static linking with the client library)
find_dependency(SSL) # ← 无条件,不受 WITH_SSL 保护
cdk/CMakeLists.txt:96 还有一次。所以即使 WITH_SSL=OFF,缺 libssl-dev 也会在 configure 阶段直接失败:
CMake Error at cdk/cmake/DepFindSSL.cmake:76 (message):
Cannot find appropriate system libraries for SSL. ...
第三个坑:configure 会拿 SHA512_DIGEST_LENGTH 探测 OpenSSL 版本(cdk/cmake/DepFindSSL.cmake:96-101):
CHECK_SYMBOL_EXISTS(SHA512_DIGEST_LENGTH "openssl/sha.h" HAVE_SHA512_DIGEST_LENGTH)
if(NOT HAVE_SHA512_DIGEST_LENGTH)
message(SEND_ERROR "Could not find SHA512_DIGEST_LENGTH symbol in sha.h header of OpenSSL library")
endif()
这个宏确实存在于 OpenSSL 1.1.1(OpenSSL 自带头文件 openssl/sha.h:71),所以麒麟 V10 SP1 自带的 OpenSSL 1.1.1 能过——但只有当头文件装对位置时。CHECK_SYMBOL_EXISTS 只加一个 include 目录(CMAKE_REQUIRED_INCLUDES),而 Debian 系把 opensslconf.h 放在 multiarch 路径下(/usr/include/x86_64-linux-gnu/openssl/opensslconf.h),所以手工指定 OPENSSL_INCLUDE_DIR 时必须把两个目录都带上,否则会误报成”OpenSSL 版本不支持”:
# ❌ 少了 multiarch 那一段,会误报 SHA512_DIGEST_LENGTH 找不到
-DOPENSSL_INCLUDE_DIR=/usr/include
# ✅ 正常情况直接 dpkg -i 装 libssl-dev 即可,不需要手工指定
-DOPENSSL_INCLUDE_DIR="/usr/include;/usr/include/x86_64-linux-gnu"
正常路径下 dpkg -i libssl-dev*.deb 就够了——FindOpenSSL 自己会处理 multiarch 目录。上面那行只是在你必须手工指路时的写法。
本文的构建步骤已实测:
cmake -DWITH_SSL=ON在 OpenSSL 1.1.1 下 configure 通过(-- Configuring done),make编出 174 个目标文件与全部四个 vendored 静态库(libprotobuf-lite.a/liblz4.a/libzlib.a/libzstd.a)。这四条印证了机制一那句”X 侧不依赖 libmysqlclient”——protobuf 是源码树里编出来的。
libmysqlclient 的版本怎么选? 26.7.0 的 jdbc/driver/mysql_connection_options.h:44 用 MYCPPCONN_STATIC_MYSQL_VERSION_ID >= 80000 分叉出两套枚举。libmysqlclient 8.0.x 走 >= 8.0 分支,是当前唯一稳妥的选择。
还有一个很贴心的细节——绑定函数按 libmysqlclient 版本在编译期二选一(jdbc/driver/nativeapi/libmysql_static_proxy.cpp:525-535):
my_bool LibmysqlStaticProxy::stmt_bind_named_param(MYSQL_STMT* stmt, MYSQL_BIND* bind,
unsigned n_params, const char** names)
{
#if MYSQL_VERSION_ID >= 80300
return ::mysql_stmt_bind_named_param(stmt, bind, n_params, names);
#else
throw ::sql::MethodNotImplementedException("::mysql_stmt_bind_named_param()");
#endif
}
也就是说:对这一类符号,用老 libmysqlclient 编不会失败——mysql_stmt_bind_named_param(8.3 才有的符号)被 #if 挡住了,运行时抛 MethodNotImplementedException,上层 mysql_prepared_statement.cpp 的 try/catch 自动回退到 mysql_stmt_bind_param()。反过来 8.4+ 删掉了 mysql_stmt_bind_param,同一个 #if 的另一侧(libmysql_static_proxy.cpp:537-545)也在处理。
这意味着对这一类符号,你在离线机上把 libmysqlclient 从 8.0 换到 8.4/9.x,Connector/C++ 侧不需要重新编译——两个方向的退路都写好了。前提是你用源码编 Connector/C++(因为 MYSQL_VERSION_ID 是编译期宏)。
⚠️ 但别把它推广到所有符号。 另一类调用没有这种守卫(libmysql_static_proxy.cpp:376/427/438),老版本直接编不过——所以 8.0.19 编 26.7.0 整体仍然是失败的,见上一节。判据是”这个调用点外面有没有 #if MYSQL_VERSION_ID“,不是”这个函数名里有没有 bind”。
实测踩到的 22 个坑
下面两个 demo 是编译 + 实际连库跑通的(MySQL 8.0.46,X 协议 13360 / classic 13306,
EXIT=0)。过程中撞到的坑一并列在这里——大部分是”照着文档想当然写就会错”的。
X DevAPI
| # | 坑 | 正解 |
|---|---|---|
| 1 | Value 没有 as<T>() |
用 row[0].get<T>()。get_string() 在不可继承的基类 common::Value 上(include/mysqlx/devapi/document.h:227-229 是 protected 继承),外部根本调不到 |
| 2 | SqlResult 没有 rows() |
用 fetchAll() / fetchOne();它本身也有 begin()/end()(include/mysqlx/devapi/result.h:684/692),可直接 range-for |
| 3 | SessionSettings 没有默认构造 |
用 SessionSettings(host, port, user, pwd, db)(include/mysqlx/devapi/settings.h:540) |
| 4 | 枚举名不是 SSL_mode |
顶层 enum class SSLMode(include/mysqlx/devapi/settings.h:226),SessionOption::SSLMode 只是别名;选项名要写全 SessionOption::SSL_MODE |
| 5 | Table::insert().values(Row{...}) → Column count doesn't match value count |
Table API 此时不知道列定义,必须具名列:insert("name","age","city").values("dave",28,"chengdu") |
| 6 | Table::modify() 不存在 |
叫 Table::update() |
| 7 | Table::update() / remove() 不收条件 |
参数一律 .where(expr);且 set() 返回 TableUpdate&、where() 返回终结的 Operation&,必须先 set 再 where |
| 8 | sql().bind(...) 抛 Too many arguments |
raw SQL 不支持参数绑定(:name 和 :1 都不行)。绑定只在 CRUD 语句上 |
| 9 | ⚠️ execute() 后再链 fetchAll() → 段错误 |
for (Row r : stmt.execute()) ✅(range-for 延长临时对象)for (Row r : stmt.execute().fetchAll()) ❌ 崩——fetchAll() 的 holder 引用临时 Result 内部,而 range-for 只延长最外层auto res = stmt.execute(); …res.fetchAll() ✅ |
| 10 | ⚠️ Table 链式调用一条写完 → 段错误 | TableSelect 要存具名变量;orderBy() 返回新 wrapper 对象,必须按值持有 |
坑 9/10 是 gdb 复现的段错误,栈顶都是
common::Result_impl::get_row()。
classic / JDBC
| # | 坑 | 正解 |
|---|---|---|
| 11 | ResultSet::getColumnLabel() 不存在 |
在 ResultSetMetaData 上:rs->getMetaData()->getColumnLabel(i) |
| 12 | sql::Driver / DatabaseMetaData / ResultSetMetaData / ParameterMetaData / SQLWarning 析构是 protected |
不能放进 std::unique_ptr(那要求 public 析构),用裸指针 |
| 13 | 这些 metadata 的 getter 全非 const | 指针连 const 都不能加 |
| 14 | driver 忘了赋值 → 段错误 |
driver = sql::mysql::get_mysql_driver_instance(); |
构建 / 离线
| # | 坑 | 正解 |
|---|---|---|
| 15 | -DWITH_MYSQL=/usr 必失败 |
DepFindMySQL.cmake:134 要求 ${MYSQL_INCLUDE_DIR}/mysql.h 直接存在;发行版装在 /usr/include/mysql/mysql.h。用 -DMYSQL_INCLUDE_DIR=/usr/include/mysql |
| 16 | 不能用构建树的 include/mysql/jdbc.h |
它内部是 #include "../jdbc/xxx.h",那个扁平布局只有 make install 后才存在。库装在 lib64 不是 lib |
| 17 | mysql.h 需要两个 include 目录 |
<mysql.h> 要 include/mysql;mysql_com.h 里 #include <mysql/udf_registration_types.h> 要 include。真实装机 /usr/include 隐式在路径里,只有自定义前缀才撞上 |
| 18 | configure 会编译并运行探针取版本 | DepFindMySQL.cmake:396-453 的 try_run。.so 不在 LD_LIBRARY_PATH 就失败(Could not determine the MySQL client library version.)。用 -DMYSQL_CONFIG_EXECUTABLE=<mysql_config> 一步绕过(它直接跑 mysql_config --version,DepFindMySQL.cmake:509),或 -DMYSQL_VERSION=<ver> 手动指定 |
| 19 | X DevAPI 编译要加构建树的头路径 | version_info.h 是生成的,-I<build>/include/mysqlx 必需,源码树里没有 |
| 20 | 手工给 MYSQL_INCLUDE_DIR 容易给错 |
优先 -DMYSQL_CONFIG_EXECUTABLE=<mysql_config>:一次填好 MYSQL_INCLUDE_DIR/MYSQL_LIB_DIR/MYSQL_VERSION/MYSQL_VERSION_ID,且路径跟着安装位置走(relocatable 目录也实测可用) |
| 21 | make -j4 挡不住 vendored 子构建 |
cdk/cmake/dependency.cmake:357 的 -DOPTS=${build_opt} 没加引号,列表被空格并成一个 argv,ext-build.cmake:50 只收到 --parallel,数值变游离 argv → 裸 --parallel = 不限并发。加引号也不行(; 被 Make 当命令分隔符,报 /bin/sh: 1: 8: not found)。改法:改成传 -DPARALLEL=<n> 标量,在 ext-build.cmake 里 list(APPEND build_args --parallel ${PARALLEL})。实测 loadavg 50 → 7.3 |
| 22 | classic 要 libmysqlclient ≥ 8.0.42(真麒麟实测) | libmysql_static_proxy.cpp:376/427/438 无版本保护地调 mysql_plugin_get_option(在 client_plugin.h)/ mysql_real_connect_dns_srv / mysql_bind_param。麒麟自带源只有 8.0.26,缺 mysql_plugin_get_option → make exit=2;focal-updates 的 8.0.42 三个齐全且只要 libc6>=2.28+libssl1.1 → 麒麟容器内 exit=0+demo exit=0。8.0.46 不行(libc6>=2.34+libssl3)。判据是编译不是 grep(mysql_plugin_get_option 不在 mysql.h 里);nm -D 输出带 @@libmysqlclient_21.0 后缀,按裸名精确匹配会误判成”缺失” |
完整 demo:X DevAPI
下面的代码就是 demo/demo_xdevapi_crud.cpp 的全文
(编译运行均验证过)。它同时演示两种写法:普通 SQL,以及走 Mysqlx.Crud.*
protobuf 的 Table API——后者不经过 SQL 文本,和机制九讲的是一回事。
// demo_xdevapi_crud.cpp —— X DevAPI 的完整 CRUD
//
// 演示:连接 -> 建表 -> 新增(add) -> 查询(search) -> 修改(modify) -> 删除(delete)
//
// 编译(注意只需要 2 个 -l,protobuf/zstd/lz4 是静态合进 .so 的):
// g++ -std=c++17 demo_xdevapi_crud.cpp -o demo_x \
// -I<build>/include/mysqlx -I<src>/include/mysqlx -I<src>/include \
// -L<build> -lmysqlcppconnx -lssl -lcrypto
//
// 第一个 -I 是必需的:version_info.h 是构建时生成的,只在构建树里。
//
// 运行:
// ./demo_x 127.0.0.1 33060 xuser 'Xuser#Pass1' demo
//
// 两个容易踩的点:
// 1. 端口是 33060(X 协议),不是 3306。
// 2. X DevAPI **不支持 caching_sha2_password**。裸 TCP 下账号必须用
// mysql_native_password,否则报 "Authentication failed using
// MYSQL41 and SHA256_MEMORY"。开 TLS 可以绕开(走 PLAIN 机制)。
//
// API 备注(都是实际编译验证过的):
// - SessionSettings 没有默认构造函数,必须用带参构造
// - 取值用 Row::operator[](pos) -> Value,再 .get<T>(),**没有 as<T>()**
// - 结果集遍历用 fetchAll() / fetchOne(),**没有 rows()**
// - Table::remove() 不收条件,要链 .where(expr)
// - Table 的改方法叫 update()(不是 modify()),且**不收条件**,要链 .where(expr)
// - fetchAll() 的返回类型是 internal 里的 RowList,别在签名里写它,用 auto
// - ⚠️ for (Row r : stmt.execute()) ✅ 安全(range-for 会延长临时对象)
// for (Row r : stmt.execute().fetchAll()) ❌ 段错误(fetchAll 返回的 holder
// 引用的是临时 Result 的内部对象,
// 而 range-for 只延长最外层临时)
// auto res = stmt.execute(); for (Row r : res.fetchAll()) ✅ 安全
// - sql().bind(...) 不支持(抛 "Too many arguments"),绑定只在 CRUD 语句上
// - Table::insert() 走 .values(Row{...}) 会报 "Column count doesn't match",
// 要用 insert("col1","col2",...).values(v1, v2, ...) 的具名列形式
#include <mysqlx/xdevapi.h>
#include <iostream>
#include <string>
using namespace mysqlx;
namespace {
template <typename Rows>
void print_rows(Rows rows) { // 按值收:fetchAll() 返回的 List_initializer 不能绑定到 const&
for (const Row& row : rows) {
std::cout << " " << row[0].get<uint64_t>() << " | "
<< row[1].get<std::string>() << " | " << row[2].get<int64_t>()
<< " | " << row[3].get<std::string>() << "\n";
}
}
void print_header() {
std::cout << " id | name | age | city\n";
}
} // namespace
int main(int argc, char** argv) {
const std::string host = (argc > 1) ? argv[1] : "127.0.0.1";
const unsigned port = (argc > 2) ? std::stoul(argv[2]) : 33060;
const std::string user = (argc > 3) ? argv[3] : "root";
const std::string pass = (argc > 4) ? argv[4] : "";
const std::string db = (argc > 5) ? argv[5] : "demo";
try {
// ============ 1. 连接 ============
// ssl-mode 默认是 REQUIRED(不是 PREFERRED)。这里显式设 DISABLED
// 以便本地明文测试;生产请用 SSLMode::VERIFY_CA + SessionOption::SSL_CA。
SessionSettings opt(host, port, user, pass, db);
opt.set(SessionOption::SSL_MODE, SSLMode::DISABLED);
Session session(opt);
std::cout << "== 连接成功 ==\n";
std::cout << " server: "
<< session.sql("SELECT VERSION()").execute().fetchOne()[0].get<std::string>()
<< "\n\n";
// ============ 2. 建表 ============
session.sql("DROP TABLE IF EXISTS person").execute();
session.sql(
"CREATE TABLE person ("
" id INT PRIMARY KEY AUTO_INCREMENT,"
" name VARCHAR(64) NOT NULL,"
" age INT,"
" city VARCHAR(64)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
.execute();
std::cout << "== 建表 person ==\n\n";
// ============ 3. 新增 (add) ============
std::cout << "== 新增 (add) ==\n";
// 3a. 普通 SQL 批量插入
session.sql(
"INSERT INTO person(name, age, city) VALUES"
"('alice', 30, 'beijing'),"
"('bob', 25, 'shanghai'),"
"('carol', 35, 'guangzhou')")
.execute();
std::cout << " +3 rows via SQL\n";
// 3b. Table API —— 发的是 Mysqlx.Crud.Insert(msg id 18),不是 SQL
Table table = session.getSchema(db).getTable("person");
// 注意:必须显式给列名。直接 .values(Row{...}) 走的是"按位置"形式,
// 而 Table API 此时并不知道表的列定义,会报
// "Column count doesn't match value count"。
table.insert("name", "age", "city")
.values("dave", 28, "chengdu")
.execute();
std::cout << " +1 row via Mysqlx.Crud.Insert\n\n";
// ============ 4. 查询 (search) ============
// 4a. 全表
{
std::cout << "== 查询 (search) 全表 ==\n";
print_header();
// for (auto row : stmt.execute()) 是安全的 —— range-for 会延长 execute()
// 返回的临时 SqlResult。但**不能**再链 .fetchAll(),那个 holder 引用的是
// 临时对象内部,临时一析构就悬垂(实测段错误)。
print_rows(session.sql("SELECT id, name, age, city FROM person ORDER BY id")
.execute());
std::cout << "\n";
}
// 4b. 命名参数绑定(占位符 :min_age),不拼字符串
//
// ⚠️ 重要:sql().bind(...) 在 X DevAPI 里**不可用**,会抛
// "CDK Error: Too many arguments"(无论 :name 还是 :1 形式)。
// 参数绑定只在 CRUD 语句(Table/Collection 的 select/find/update/
// remove)上支持。所以下面用 Table::select 演示绑定。
{
std::cout << "== 查询 age > 26(Table::select + :min_age 绑定)==\n";
// ⚠️ 必须把 TableSelect 存进具名变量再链式调用。
// 写成一条链 table.select(...).where(...).orderBy(...).bind(...)
// 在 26.7.0 上会 **段错误**(where() 返回的是指向临时对象的
// Operation&,orderBy() 又包一层临时 wrapper,临时对象析构后悬垂)。
// 这是实测复现的,不是理论问题。
// 安全写法:**每一步都存成具名变量**,尤其 orderBy() 要按值持有
// (它返回的是一个新的 wrapper 类型,不是引用)。
TableSelect sel = table.select("id", "name", "age", "city");
auto& filtered = sel.where("age > :min_age");
auto ordered = filtered.orderBy("age"); // 必须按值存
auto& bound = ordered.bind("min_age", 26);
// execute() 的返回值也必须存成具名变量:fetchAll() 返回的是
// **持有 Result 引用的 List_initializer**,直接写进 range-for
// 会让 Result 临时对象提前析构 → get_row() 段错误。
RowResult res = bound.execute();
for (const Row& row : res.fetchAll()) {
std::cout << " " << row[0].get<uint64_t>() << " | "
<< row[1].get<std::string>() << " | "
<< row[2].get<int64_t>() << " | " << row[3].get<std::string>()
<< "\n";
}
std::cout << "\n";
}
// 4c. 单行 + 聚合
{
SqlResult agg =
session.sql("SELECT COUNT(*) AS n, AVG(age) AS a FROM person").execute();
Row r = agg.fetchOne();
std::cout << "== 统计 ==\n";
std::cout << " count : " << r[0].get<uint64_t>() << "\n";
std::cout << " avg_age : " << r[1].get<double>() << "\n\n";
}
// ============ 5. 修改 (modify) ============
std::cout << "== 修改 (modify) ==\n";
// 5a. SQL UPDATE
session.sql("UPDATE person SET age = 31 WHERE name = 'alice'").execute();
std::cout << " alice.age -> 31 (SQL UPDATE)\n";
session.sql("UPDATE person SET city = 'shenzhen' WHERE age < 30").execute();
std::cout << " city(age<30) -> shenzhen (SQL UPDATE)\n";
// 5b. Table API —— Mysqlx.Crud.Update(msg id 19)
// 注意顺序:set() 返回 TableUpdate&,where() 返回终结的 Operation&,
// 所以必须先 set 再 where,反过来就编译不过。
table.update().set("city", "hangzhou").where("name = 'bob'").execute();
std::cout << " bob.city -> hangzhou (Mysqlx.Crud.Update)\n\n";
// 5c. 验证
{
std::cout << "== 修改后 ==\n";
print_header();
// for (auto row : stmt.execute()) 是安全的 —— range-for 会延长 execute()
// 返回的临时 SqlResult。但**不能**再链 .fetchAll(),那个 holder 引用的是
// 临时对象内部,临时一析构就悬垂(实测段错误)。
print_rows(session.sql("SELECT id, name, age, city FROM person ORDER BY id")
.execute());
std::cout << "\n";
}
// ============ 6. 删除 (delete) ============
std::cout << "== 删除 (delete) ==\n";
session.sql("DELETE FROM person WHERE name = 'carol'").execute();
std::cout << " - carol (SQL DELETE)\n";
// Table API —— Mysqlx.Crud.Delete(msg id 20)
table.remove().where("name = 'dave'").execute();
std::cout << " - dave (Mysqlx.Crud.Delete)\n\n";
{
std::cout << "== 删除后 ==\n";
SqlResult left =
session.sql("SELECT name FROM person ORDER BY id").execute();
for (const Row& row : left.fetchAll()) {
std::cout << " " << row[0].get<std::string>() << "\n";
}
std::cout << "\n";
}
// ============ 7. 清理 ============
session.sql("DROP TABLE person").execute();
session.close();
std::cout << "== 全部完成 ==\n";
return 0;
} catch (const Error& e) {
std::cerr << "X DevAPI 错误: " << e << "\n";
return 1;
} catch (const std::exception& e) {
std::cerr << "标准异常: " << e.what() << "\n";
return 1;
}
}
完整 demo:classic / JDBC
对应 demo/demo_jdbc_crud.cpp,端口是 3306 而非 33060。
// demo_jdbc_crud.cpp —— classic (JDBC 风格) API 的完整 CRUD
//
// 演示:连接 -> 建表 -> 新增(add) -> 查询(search) -> 修改(modify) -> 删除(delete)
// 依赖:libmysqlcppconn.so + libmysqlclient.so
//
// 编译(注意:库在 lib64 不是 lib;且必须先 make install —— 原因见 README 坑 16):
// g++ -std=c++17 demo_jdbc_crud.cpp -o demo_jdbc \
// -I<prefix>/include -I<prefix>/include/mysql \
// -L<prefix>/lib64 -lmysqlcppconn -lmysqlclient
//
// 运行:
// ./demo_jdbc 127.0.0.1 13306 sha2user 'Sha2#Pass1' demo
//
// 注意端口:classic 协议走 3306(X 协议才是 33060)。
#include <mysql/jdbc.h>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
namespace {
// ---- 小工具:把一行按 | 分隔打印出来 --------------------------------------
void print_row(sql::ResultSet* rs) {
const auto cols = rs->getMetaData()->getColumnCount();
std::cout << " ";
for (uint32_t i = 1; i <= cols; ++i) {
std::cout << (i > 1 ? " | " : "") << rs->getString(i);
}
std::cout << "\n";
}
// 注意:列名/标签在 **ResultSetMetaData** 上,不在 ResultSet 上。
// sql::ResultSet 没有 getColumnLabel()。
void print_header(sql::ResultSet* rs) {
// 同样不能 unique_ptr:sql::ResultSetMetaData 析构也是 protected,
// 且它的 getter 都非 const,所以这里也不能声明成 const 指针。
sql::ResultSetMetaData* md = rs->getMetaData();
const auto cols = md->getColumnCount();
std::cout << " ";
for (uint32_t i = 1; i <= cols; ++i) {
std::cout << (i > 1 ? " | " : "") << md->getColumnLabel(i);
}
std::cout << "\n";
}
} // namespace
int main(int argc, char** argv) {
const std::string host = (argc > 1) ? argv[1] : "127.0.0.1";
const std::string port = (argc > 2) ? argv[2] : "3306";
const std::string user = (argc > 3) ? argv[3] : "root";
const std::string pass = (argc > 4) ? argv[4] : "";
const std::string db = (argc > 5) ? argv[5] : "demo";
// classic 路径的 URL 用 tcp://,**不要写 jdbc: 前缀**(不支持)
const std::string url = "tcp://" + host + ":" + port;
// sql::Driver 和 sql::DatabaseMetaData 的析构函数是 **protected**,
// 所以不能放进 std::unique_ptr(那会要求 public 析构)。
// Driver 由 get_driver_instance() 返回、生命周期归库管,不用管;
// DatabaseMetaData 等也用裸指针。
sql::Driver* driver = nullptr;
std::unique_ptr<sql::Connection> con;
std::unique_ptr<sql::Statement> stmt;
try {
// ============ 1. 连接 ============
// get_driver_instance() 不是线程安全的,在 main 里取一次即可
driver = sql::mysql::get_mysql_driver_instance();
con.reset(driver->connect(url, user, pass));
con->setSchema(db);
std::cout << "== 连接成功 ==\n";
{
// 非 const 指针:getDatabaseProductVersion() 等 getter 都非 const
sql::DatabaseMetaData* meta = con->getMetaData();
std::cout << " server : " << meta->getDatabaseProductVersion() << "\n";
std::cout << " driver : " << meta->getDriverVersion() << "\n";
}
std::cout << " schema : " << con->getSchema() << "\n\n";
stmt.reset(con->createStatement());
// ============ 2. 建表 ============
stmt->execute("DROP TABLE IF EXISTS person");
stmt->execute(
"CREATE TABLE person ("
" id INT PRIMARY KEY AUTO_INCREMENT,"
" name VARCHAR(64) NOT NULL,"
" age INT,"
" city VARCHAR(64)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
std::cout << "== 建表 person ==\n\n";
// ============ 3. 新增 (add) ============
// 用 PreparedStatement,避免拼字符串
{
std::unique_ptr<sql::PreparedStatement> ps(
con->prepareStatement(
"INSERT INTO person(name, age, city) VALUES(?, ?, ?)"));
ps->setString(1, "alice");
ps->setInt(2, 30);
ps->setString(3, "beijing");
std::cout << "== 新增 (add) ==\n";
std::cout << " inserted rows: " << ps->executeUpdate() << "\n";
ps->setString(1, "bob");
ps->setInt(2, 25);
ps->setString(3, "shanghai");
std::cout << " inserted rows: " << ps->executeUpdate() << "\n";
ps->setString(1, "carol");
ps->setInt(2, 35);
ps->setString(3, "guangzhou");
std::cout << " inserted rows: " << ps->executeUpdate() << "\n\n";
}
// ============ 4. 查询 (search) ============
// 4a. 全表
{
std::unique_ptr<sql::ResultSet> rs(stmt->executeQuery(
"SELECT id, name, age, city FROM person ORDER BY id"));
std::cout << "== 查询 (search) 全表 ==\n";
print_header(rs.get());
while (rs->next()) print_row(rs.get());
std::cout << "\n";
}
// 4b. 条件查询
{
std::unique_ptr<sql::PreparedStatement> ps(
con->prepareStatement(
"SELECT name, age FROM person WHERE age > ? ORDER BY age"));
ps->setInt(1, 26);
std::unique_ptr<sql::ResultSet> rs(ps->executeQuery());
std::cout << "== 查询 age > 26 ==\n";
print_header(rs.get());
while (rs->next()) print_row(rs.get());
std::cout << "\n";
}
// 4c. 统计
{
std::unique_ptr<sql::ResultSet> rs(
stmt->executeQuery("SELECT COUNT(*) AS n, AVG(age) AS avg_age FROM person"));
rs->next();
std::cout << "== 统计 ==\n";
std::cout << " count : " << rs->getInt("n") << "\n";
std::cout << " avg_age : " << rs->getDouble("avg_age") << "\n\n";
}
// ============ 5. 修改 (modify) ============
// 5a. PreparedStatement UPDATE(可重复执行)
{
std::unique_ptr<sql::PreparedStatement> ps(
con->prepareStatement("UPDATE person SET age = ? WHERE name = ?"));
ps->setInt(1, 31);
ps->setString(2, "alice");
const int n = ps->executeUpdate();
std::cout << "== 修改 (modify) alice.age -> 31 ==\n";
std::cout << " updated rows: " << n << "\n\n";
}
// 5b. Statement UPDATE
{
const int n = stmt->executeUpdate(
"UPDATE person SET city = 'shenzhen' WHERE age < 30");
std::cout << "== 修改 city (age<30) -> shenzhen ==\n";
std::cout << " updated rows: " << n << "\n\n";
}
// 5c. 验证修改结果
{
std::unique_ptr<sql::ResultSet> rs(
stmt->executeQuery("SELECT name, age, city FROM person ORDER BY id"));
std::cout << "== 修改后 ==\n";
print_header(rs.get());
while (rs->next()) print_row(rs.get());
std::cout << "\n";
}
// ============ 6. 删除 (delete) ============
{
std::unique_ptr<sql::PreparedStatement> ps(
con->prepareStatement("DELETE FROM person WHERE name = ?"));
ps->setString(1, "carol");
const int n = ps->executeUpdate();
std::cout << "== 删除 (delete) carol ==\n";
std::cout << " deleted rows: " << n << "\n\n";
}
{
std::unique_ptr<sql::ResultSet> rs(
stmt->executeQuery("SELECT name FROM person ORDER BY id"));
std::cout << "== 删除后 ==\n";
while (rs->next()) std::cout << " " << rs->getString(1) << "\n";
std::cout << "\n";
}
// ============ 7. 清理 ============
stmt->execute("DROP TABLE person");
con->close();
std::cout << "== 全部完成 ==\n";
return 0;
} catch (const sql::SQLException& e) {
// SQLException 带 SQLSTATE 与厂商错误码,来自 mysql_error/sqlstate/errno
std::cerr << "SQL 错误: " << e.what() << "\n"
<< " SQLSTATE: " << e.getSQLState() << "\n"
<< " errno : " << e.getErrorCode() << "\n";
return 1;
} catch (const std::exception& e) {
std::cerr << "标准异常: " << e.what() << "\n";
return 1;
}
}
选型决策
| 你的情况 | 选 | 理由 |
|---|---|---|
| 新项目、MySQL 8+、能开 TLS | X DevAPI | 自带连接池、多主机故障转移、文档模型 |
需要 caching_sha2_password over 裸 TCP |
classic | X DevAPI 没实现(机制五) |
| 要兼容 JDBC 风格的老代码 | classic | 唯一选择 |
已有大量 sql::ResultSet 字符串处理代码 |
classic | X DevAPI 是流式 + 类型化结果,迁移成本高 |
要 SELECT 以外的任意 SQL |
两边都行 | X 的 sql() 走 StmtExecute,和 CRUD 独立 |
| 只要读写关系表、不用文档模型 | classic 或 X 的 Table API |
X 的 Table 模式(data_model=TABLE)不为 SQL 让路 |
一句话判断:如果你在 MySQL 8 上遇到一个奇怪的认证失败,先问自己是不是选了 X DevAPI。
版本台账(防腐剂)
这篇文章的结论全部锚定在具体版本号上。换版本时按下表重新校准:
| 事实 | 依据 | 怎么复核 |
|---|---|---|
| Connector 版本 26.7.0 | version.cmake:37-39 |
cat version.cmake \| grep CONCPP_VERSION_MAJOR |
| ABI 版本 2.1,soname 主版本 2 | version.cmake:85-90;CMakeLists.txt:576-579 |
ls -l libmysqlcppconnx.so* |
WITH_JDBC 默认 OFF |
CMakeLists.txt:389-391 |
grep -rn "WITH_JDBC" CMakeLists.txt |
库名 mysqlcppconnx |
install_layout.cmake:218-219 |
grep -n LIB_NAME_BASE install_layout.cmake |
| X 协议端口 33060 | include/mysqlx/common_constants.h:37 |
grep -rn DEFAULT_MYSQLX_PORT |
| 帧头 5 字节 | cdk/protocol/mysqlx/protocol.h:108 |
grep -n header_length cdk/protocol/mysqlx/protocol.h |
ssl-mode 默认 REQUIRED |
common/session.cc:337 |
sed -n '325,335p' common/session.cc |
不支持 caching_sha2_password |
devapi/tests/session-t.cc:1573-1576 |
grep -rn caching_sha2 --include=*.cc . \| grep -v tests/ |
| X 侧不依赖 libmysqlclient | CMakeLists.txt:456 的 merge 链 |
ldd libmysqlcppconnx.so* \| grep mysqlclient |
jdbc/thread/ 是死代码 |
jdbc/CMakeLists.txt:244-245 |
grep -rn add_subdirectory.*thread |
9.2.0 → 26.7.0 的实际漂移量(实测): 首轮迁移时 139 处引用里 27 处行号失效,但0 处文件消失、0 处结论翻转。全部 27 处都是纯粹的行号平移,没有一个机制发生变化。(本文定稿时引用数已涨到 187 处,全部校验通过。)最典型的一批:
下表左列是 9.2.0 的行号,在 26.7.0 源码树里已经指不到东西了——这是故意留着的失效样本,用来演示漂移长什么样。右列才是 26.7.0 的正确位置。
| 事实 | 9.2.0(已失效) | 26.7.0(本文采用) |
|---|---|---|
WITH_JDBC 开关 |
CMakeLists.txt:350 |
CMakeLists.txt:389-391 |
merge_libraries(connector …) |
CMakeLists.txt:416 |
CMakeLists.txt:456 |
ssl-mode 默认 REQUIRED |
common/session.cc:329 |
common/session.cc:337 |
get_data_source() |
common/session.cc:463 |
common/session.cc:471 |
::send() 叶子 |
socket_detail.cc:1020 |
socket_detail.cc:1039 |
ParseFromArray |
protocol.cc:961 |
protocol.cc:998 |
Msg_builder::send() |
protocol.h:1004 |
protocol.h:1013-1024 |
Session_pool::get_session() |
common/session.cc:971 |
common/session.cc:980 |
cdk/mysqlx/session.cc 的 Expectation_processor 甚至只是从”结构体内联 set()“重构成了”构造函数初始化 m_data“,"17.12" 这些路径字符串一个没变。这说明 X DevAPI 这条链在 9.x → 26.x 之间是稳定的——版本号大跳不等于架构大改。
高频失效点:
- 行号会漂,结论不会。 上表 27 处全是平移。所以看到一篇带
file:line的文章,先假设行号过期、结论有效,然后抽查两三条——这比整篇重新调研便宜一个数量级。 -
自动校准脚本。 本文配套的
docs/verify_citations.py会把文章里所有file:line解析出来,逐条打印该行实际内容;加-d <另一个源码树>参数还能做跨版本漂移检测,只报内容不一致的引用;docs/relocate.py则按内容锚点给出新行号;docs/spotcheck.py做最终的逐条语义核对(236 处引用、0 问题)。30 秒出结果:# 1. 文章引用的文件是否都还存在 python3 docs/verify_citations.py src/mysql-connector-c++-26.7.0-src mysql-connector-cpp-26.7-call-to-bytes.md # 2. 相对 9.2.0,哪些引用的行号漂了 python3 docs/verify_citations.py src/mysql-connector-c++-26.7.0-src mysql-connector-cpp-26.7-call-to-bytes.md -d src/mysql-connector-c++-9.2.0-src # 3. 漂了的行,新行号在哪 python3 docs/relocate.py src/mysql-connector-c++-26.7.0-src src/mysql-connector-c++-9.2.0-src mysql-connector-cpp-26.7-call-to-bytes.md # 4. 最终语义核对(应为 0 problem) python3 docs/spotcheck.py src/mysql-connector-c++-26.7.0-src mysql-connector-cpp-26.7-call-to-bytes.md全部脚本在
docs/(12 个)。这些是本文唯一真正可靠的防腐剂 ——比任何手工维护的版本对照表都强,因为它把”校准”从体力活变成了 grep:脚本 作用 verify_citations.py被引文件是否存在;加 -d <另一棵源码树>做跨版本漂移检测relocate.py按内容锚点给出漂移后的新行号 spotcheck.py逐条语义核对——打印每个 file:line的实际内容,应为0 problemabi_matrix.sh跨版本 ABI 交叉矩阵,回答”能不能只换 .so不重编程序”resolve_debs.py从 apt Packages算依赖闭包(离线打包用)inline_demos.py/inline_demos2.py把 demo 源码同步进本文(幂等;坑表在 inline_demos2.py里,是唯一来源)tcptee.py录线上字节用的 TCP tee(副本在 demo/wire/)check_markdown.py校验本文的栅栏配对/嵌套、表格列数、内部锚点(转义感知) prep_src.sh校验 src/树与「tarball + patches」一致——跑上面几条之前先跑它test_offline_install.sh在纯净 Ubuntu rootfs 里真跑 dpkg -itest_kylin_docker.sh在真麒麟 V10 SP1 容器里验整条链(自带源失败 → 换 8.0.42 → 编 → 跑 demo) -
想自己抓一遍线上字节? 用
demo/wire/里的现成件:# 1. 起一个 TCP tee(把 23390 转发到 13360,同时落盘双向字节) python3 demo/wire/tcptee.py 23390 13360 /tmp/wire.log & # 2. 让程序连 tee 的端口 g++ -std=c++17 demo/wire/probe_sslmode_disabled.cc -o probe \ -I<build>/include/mysqlx -I<src>/include/mysqlx -I<src>/include \ -L<build> -lmysqlcppconnx -lssl -lcrypto ./probe # 里面是 ssl-mode=DISABLED,整条会话明文 # 3. 按 4 字节长度 + 1 字节类型切帧,就能看到机制六/七/九说的每一步现成的两份抓包也在同目录:
capture-tls-default.log(默认ssl-mode,前 6 段明文、 之后0x17加密)与capture-plaintext.log(全程明文,15 段全解出来了)。 文章里所有关于”线上字节”的论断都出自这两份文件,不是读代码猜的。 ngs命名空间已经不存在了。 网上大量 8.0.x 时代的文章讲ngs::Client/ngs::Session/ngs::Auth,9.x 起全部换成了cdk::mysqlx::*;26.7.0 全树 grep\bngs\b零命中。任何引用ngs::的教程对 9.2.0 之后的版本都是错的。- 类名和文件名对不上。 C++ 的
Session构造函数在devapi/session.cc(不是xapi/session.cc——那是 C API 层);Settings_impl在common/settings.h;Session_impl在common/session.h。按名字找文件会全部找错地方。 SessionConfig这个类在 26.7.0 里不存在。 对应物是SessionSettings(公开)+common::Settings_impl(内部)。ds::TCPIP_old变体也在 visitor 里直接抛异常(cdk/core/session.cc:291-300),是纯历史包袱。- X DevAPI 没有 session search path / session-state-tracker。 全树 grep
search_path零命中。session 状态只在SessionReset时刷新。
总结
mysql-connector-c++-26.7.0-src.tar.gz 里有两个连接器,而”用 C++ 连 MySQL”这个问题因此有两个答案。核心结论:
#include决定命运。<mysqlx/xdevapi.h>→libmysqlcppconnx.so/ 33060 / protobuf / 自实现协议;<mysql/jdbc.h>→libmysqlcppconn.so/ 3306 / classic 协议 / 转手libmysqlclient。而且后者默认不编译(WITH_JDBC=OFF)。Session构造不等于连接。 它把选项翻译成一个带优先级的Multi_source,然后向连接池要一条连接——所以它可能复用物理连接,池默认 25 条、TTL 10 分钟、失败端点 60 秒黑名单。- X 协议没有 capability 位图。 一切特性(含预处理语句、行锁、upsert、压缩)靠
Mysqlx.Expect.Open声明 + 捕获服务端错误码来协商,降级是静默的。 - 认证只有四种机制,且没有
caching_sha2_password。 裸 TCP 走 MYSQL41→SHA256_MEMORY 两轮尝试;TLS/Unix socket 走PLAIN明文(在加密通道内)。ssl-mode默认REQUIRED不是巧合——它是绕开这个缺口的唯一办法。 - TLS 是”先明文协商再升级”:裸连接上先发
CapabilitiesSet{tls:true},拿到Ok才SSL_connect()。第 2 步是明文的。 - 叶子是
::send()。 11 层,从execute()到::send()(socket_detail.cc:1039)或SSL_write()(connection_openssl.cc:1045);传输层靠模板注入,协议层不知道自己跑在明文还是密文上。 - 帧头是 5 字节:4 字节小端长度 + 1 字节消息号,后跟 protobuf。读侧必须先读满 5 字节再读 payload——TCP 无边界,全靠这个头。
- CRUD 不翻译成 SQL。 发的是
Mysqlx.Crud.Find/Insert/Update/Delete,条件字符串在客户端被解析成Mysqlx.Expr.Expr树。但事务是普通 SQL——这个不对称很反直觉。 - classic 路径是三层桥(
MySQL_Statement→NativeConnectionWrapper→IMySQLCAPI→::mysql_real_query),且MYSQLCLIENT_STATIC_LINKING(链接期)和MYSQLCLIENT_STATIC_BINDING(调用期)是两个不同开关、默认一开一关。别把jdbc:前缀写进 URL,它不被支持。 - classic 路径对 libmysqlclient 版本有硬要求(实测):无保护地调用
mysql_plugin_get_option/mysql_real_connect_dns_srv/mysql_bind_param,8.0.19 三个全没有、编不过。8.0.42 三个齐全且只要 glibc ≥ 2.28 + libssl1.1,麒麟 V10 SP1 上实测编过并跑通;8.0.46 虽然也有,但要 glibc ≥ 2.34 + OpenSSL 3,装不上。另外-DWITH_MYSQL=/usr在 Debian 系上必失败,要用-DMYSQL_CONFIG_EXECUTABLE=<mysql_config>。
还有四条不算机制但会咬人的常识:jdbc/thread/ 是死代码(add_subdirectory(thread) 零命中);get_driver_instance() 不线程安全(无锁 static map,要在 main 取一次);异常层次比 JDBC 少(没有 SQLTimeoutException/BatchUpdateException,SQLWarning 压根不是异常);sql::Driver 等 5 个类析构是 protected,别塞进 unique_ptr。
本文所有 API 结论都经过编译 + 实际连库运行验证,配套 demo 在
demo/。写作过程中靠”读代码猜”出的 API(as<T>()、rows()、sql().bind()、Table::modify()等)在编译或运行时就暴露了—— 没有一条是照着文档想当然写上去的。
最后一句实用建议:在 MySQL 8 上遇到一个语义不明的认证失败、或者一个”连上了但行为不对”的怪问题,先确认你用的是哪一套栈。 X DevAPI 和 libmysqlclient 的默认值不一样(ssl-mode 一个 REQUIRED 一个 PREFERRED),能力不一样(caching_sha2_password 一个没有一个有),错误信息也不一样。把这条排掉,剩下的问题通常就自己浮出来了。