src/db/ 是库外的一层薄封装:一个连接 = 一个 uvcpp_db_client,底下挂
SQLite / MySQL / PostgreSQL / ODBC 四个后端,SQL 由调用方写,结果按表取
(列名 → 值)。前三个各对着一种数据库,第四个对着的是一个接口 —— 连什么库由
连接串里的驱动名决定(见 §1 的「ODBC:驱动管理器与具体驱动」)。
它不参与事件循环,也不是 ORM —— 没有模型、没有迁移、没有关系映射。
(建在它上面、替你把 uv_queue_work 那段样板写掉的 uvcpp_db_async 与
uvcpp_db_pool 才碰循环,见 §3;底座本身不认识 uvcpp_loop。)
同步的底座(异步门面与连接池建在它上面)、一个 client 一个连接、 不自动重连、返回码不抛异常, 把 SQLite / MySQL / PostgreSQL 三个后端在"同一条 SQL 该得到同一个结果"这件事上 钉到一套契约里(第四个后端 ODBC 也进了这套套件,但它的方言按数据源分派 —— §6 写了它偏在哪、 §11 写了哪一族还没跑过),并把各家真不一样的地方如实 写在这里,而不是假装它们一样。
它默认关(
UVCPP_ENABLE_DB=OFF):它是全仓唯一一个必需第三方客户端库的 模块。发布配置显式打开它,所以预编译包里带着这个模块 ——1.7.0那个包里 SQLite / MySQL / PostgreSQL 三个都在(SQLite 从源码编,MySQL/PG 编进来但库走 运行时加载,见 §1 与 §3)。ODBC 是第四个后端,1.7.2-dev起六条发布腿也显式 打开它,而v1.7.4是第一个带着它发布的包(RELEASE.md 的「换包之前」一节记着UVCPP_DB_ODBC_ENABLE由 0 变 1);它和 MySQL/PG 走同一条运行时加载(链上的是驱动管理器,也是"客户端库"那一类),所以带着它 的包在没装驱动管理器的机器上照样起得来,只是真去连时拿到NO_DRIVER。
驱动随不随包(哪些后端在仓里、哪些客户端库不在产物里、缺库时是什么现象)
是另一页的事:db-driver-policy.md。本文只讲怎么用。
- 1. 怎么开
- 2. 连接串
- 3. 公开面
- 4. 返回码
- 5. 参数绑定与转义
- 6. 三个后端:钉住的一致,与如实列出的三处不同
- 7. 事务,以及"事务里不重试"
- 8. 类型与精度:
DECIMAL的钱会丢 - 9. 跨后端共用套件抓到过的四处
- 10. 本地怎么跑
- 11. 没做的(如实列出)
cmake -S . -B build-db -DUVCPP_ENABLE_DB=ON
cmake --build build-db -j| 开关 | 默认 | 语义 |
|---|---|---|
UVCPP_ENABLE_DB |
OFF |
主开关。关掉时 src/db/ 整个不进构建,公开头的内容整段落在 #if UVCPP_DB_ENABLE 外面 |
UVCPP_ENABLE_DB_SQLITE |
ON |
编 SQLite 后端(要 sqlite3.h + libsqlite3) |
UVCPP_DB_SQLITE_FROM_SOURCE |
OFF |
上面那个 SQLite 后端改用钉死哈希的源码 amalgamation自己编,不走 find_package。六条发布腿都是这个(见下一节) |
UVCPP_ENABLE_DB_MYSQL |
ON |
编 MySQL 后端(要 mysql.h + libmysqlclient) |
UVCPP_ENABLE_DB_PGSQL |
ON |
编 PostgreSQL 后端(要 libpq-fe.h + libpq) |
UVCPP_ENABLE_DB_ODBC |
ON |
编 ODBC 后端(要 sql.h + 驱动管理器)。连什么库不在这里定 —— 那是连接串里 Driver= 的事(见下面「ODBC:驱动管理器与具体驱动」) |
UVCPP_ENABLE_DB_MONGODB |
ON |
编 MongoDB 后端(要 mongoc/mongoc.h + bson/bson.h,只要头)。它不是 uvcpp_db_client 的后端,是同一个模块里的另一个类,详见下面「MongoDB 是同一个模块里的另一个类」 |
UVCPP_DB_RUNTIME_LOAD |
OFF |
MySQL/PG/ODBC/MongoDB 的客户端库改成运行时加载(dlopen/LoadLibrary),只是不在链接行上出现 —— 头还是要(见下面「运行时加载」一节)。ODBC 那一份加载的是驱动管理器,具体驱动仍由它自己加载 |
SQL 那四个子开关的语义是**"找得到就编、找不到就降级并出声"**,不是"我要不要用它":
想明确裁掉哪一个才写 =OFF(那时连"找不到"的警告都不会有)。MongoDB 那一个守同一条
规矩,但它不属于 uvcpp_db_client 的后端清单。
降级链是两级,两级都是 message(WARNING) + 普通变量 set(... OFF):
- 某一个后端找不到 → 关掉那一个,其余照编;
- 五个后端(SQL 四个 + MongoDB)都关掉了 → 关掉整个模块 —— 那样的库能编出一个
uvcpp_db_client,但它谁也打不开,属于"能配置、跑不起来"。
所以 CMakeCache.txt 里 UVCPP_ENABLE_DB_MYSQL:BOOL=ON 不代表它编进去了
—— 缓存不是判据。判据是配置期的这几行,CI 与用例都 grep 它们:
-- db: SQLite 后端开(3.45.1)
-- db: MySQL 后端开(/usr/lib/x86_64-linux-gnu/libmysqlclient.so)
-- db: PostgreSQL 后端开(libpq 16.15)
-- db: ODBC 后端开(驱动管理器 /usr/lib/x86_64-linux-gnu/libodbc.so)
-- Including db module in build (database integration)
ODBC 那一行报的是驱动管理器(Windows 上是 -- db: ODBC 后端开(Windows 系统 odbc32))—— 它不是"这台机器连得上某个数据源":那件事只有 open() 才知道,
见下面「ODBC:驱动管理器与具体驱动」。找不到 sql.h、或只找到头没找到
libodbc 时,这一行换成一条 warning 并只关掉 ODBC 这一个后端,其余照编。
开了 UVCPP_DB_RUNTIME_LOAD 时这两行会换个说法(后半截是头的出处,
不再是库的出处):
-- db: MySQL 后端开(运行时加载,不链 libmysqlclient;头来自 /usr/include/mysql)
-- db: PostgreSQL 后端开(运行时加载,不链 libpq;头来自 /usr/include/postgresql)
运行期问一句更直接:
#include <cstdio>
#include <db/uvcpp_db.h>
int main() {
// "sqlite,mysql,postgres,odbc" —— 只列**真的编进来**的那几个
std::printf("%s\n", uvcpp::uvcpp_db_drivers().c_str());
return 0;
}自己编的包少哪个后端时(官方预编译包 1.7.0 里三个都在,不会有这一行缺项;
1.7.2-dev 起六条发布腿显式开 ODBC,所以它也会出现在这一行里),
这一行是第一个该看的地方。
注意这一行说的是"编进来没有",不是"这台机器上能不能用"。 开了
UVCPP_DB_RUNTIME_LOAD之后这两件事分了家:MySQL 打印在清单里, 只代表这个库带了这个后端;客户端库在不在是运行时才知道的 (下一节)。要问"现在真能用吗",看open()的返回码。
UVCPP_ENABLE_DB_MONGODB(默认 ON)编的是 <db/uvcpp_mongo.h> 里的
uvcpp_mongo_client。它不在 uvcpp_db_client 的后端清单里:
uvcpp_db_drivers() 不会打印 mongodb,SQL 那一套结果表、绑定、事务也与它无关 ——
它的公开面是 JSON 进、JSON 出(insert_one / find / update_one / delete_one /
command / ping,同步与异步各一份)。
它守同一条"找不到头就只关掉自己并出声"的规矩,但客户端库一律运行时加载:
链接行上没有 libmongoc / libbson,发布包里也没有它们。缺库时 open() 回
NO_DRIVER,进程与别的后端一切照常。完整说明见
mongodb-guide.md。
为什么要有它。 直接链客户端库时,libpq.so.5 / libmysqlclient.so.21 /
libodbc.so.2 会变成产物的一条 DT_NEEDED,而 DT_NEEDED 是加载期解析的:
装了 libuvcpp 的机器上只要缺这一条,进程根本起不来 —— 一行 db 代码都没跑,
甚至这个程序压根不碰数据库。Windows 同理(PE 的导入表),只有 odbc32.dll 是
例外:它属于操作系统,链接它不算代价 —— 所以这一档对 ODBC 的意义集中在
Linux / macOS 的产物上(那上面 ODBC 的"客户端库"是普通包 unixODBC)。
本机实测(同一个 hello,只 printf,一行 db 代码不跑):
$ ldd libuvcpp.so | grep 'not found'
libmysqlclient.so.21 => not found
libpq.so.5 => not found
$ ./hello
./hello: error while loading shared libraries: libmysqlclient.so.21:
cannot open shared object file: No such file or directory # rc=127
开了这个开关之后,改由运行时去找:
cmake -S . -B build-db -DUVCPP_ENABLE_DB=ON \
-DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \
-DUVCPP_ENABLE_DB_ODBC=ON \
-DUVCPP_DB_RUNTIME_LOAD=ON直接链(默认,OFF) |
运行时加载(ON) |
|
|---|---|---|
| 链接期要 | mysql.h/libpq-fe.h/sql.h + 客户端库 |
只要头(libmysqlclient-dev / libpq-dev / unixodbc-dev) |
产物的 DT_NEEDED |
多三条 | 一条都不多 |
| 机器上没装那些库 | 进程起不来 | 进程照常跑;HTTP、SQLite 一切照常 |
| 真去连 MySQL/PG/ODBC | 能连 | 返回 NO_DRIVER + last_error() 里写明试过哪几个库名 |
| 找库的办法 | 链接器(/etc/ld.so.cache、RPATH) |
固定的候选名列表,或 UVCPP_DB_MYSQL_LIBRARY / UVCPP_DB_PGSQL_LIBRARY / UVCPP_DB_ODBC_LIBRARY |
(最后那一行那三个环境变量是可以拿来造条件的:它一设,候选名一个都不再试
—— 所以指到一个不存在的路径就等于"这台机器没装这个客户端库",这是确定性地
复现这一档的唯一办法,也是 test_db_odbc_nodrv_func 用的办法,见 §10。
它是排他的:想指到别处就得把完整路径给对。)
候选名(按顺序试,全试完才报错):
| 平台 | MySQL | libpq | ODBC(驱动管理器) |
|---|---|---|---|
| Linux | libmysqlclient.so.21、libmysqlclient.so.24、libmariadb.so.3、libmysqlclient.so |
libpq.so.5、libpq.so |
libodbc.so.2、libodbc.so |
| Windows | libmysql.dll、libmariadb.dll、mysqlclient.dll |
libpq.dll |
odbc32.dll(系统件,一定在) |
| macOS | libmysqlclient.21.dylib、libmariadb.3.dylib、libmysqlclient.dylib |
libpq.5.dylib、libpq.dylib |
libodbc.2.dylib、libodbc.dylib |
两个环境变量是显式指定,设了它就只试这一个(不再走候选名列表)—— 装在非标准前缀下的人用它,而它同时是**"没装库"那条路的确定性旋钮**: 指向一个不存在的路径,就能在装好了库的机器上复现"没装"的样子。
UVCPP_DB_PGSQL_LIBRARY=/nonexistent/libpq.so.5 ./myapp
# last_error(): 加载 PostgreSQL 客户端库失败,试过这些名字都没打开:
# UVCPP_DB_PGSQL_LIBRARY=/nonexistent/libpq.so.5(...: cannot open shared object file: ...)。
# 装一份 libpq(...),或者把环境变量 UVCPP_DB_PGSQL_LIBRARY 指到 libpq 的完整路径。拿到的句柄永不释放:没有哪个时刻能保证"再也不会有人用它"(活连接、活语句 都可能还在),卸掉比留着危险。
它只改"符号从哪来",不改后端在不在:UVCPP_DB_MYSQL_ENABLE /
UVCPP_DB_PGSQL_ENABLE / UVCPP_DB_ODBC_ENABLE 那三个宏照旧是"头找得到就是 1",
uvcpp_db_drivers() 的输出也不变。换句话说 NO_DRIVER 现在有两条来路
(后端没编进来 / 编进来了但这台机器没库),last_error() 会说清是哪一条 ——
详见 uvcpp_db_status.h 里 NO_DRIVER 那一段的文档注释。
共享库上这一档还多一条链接选项 --no-undefined(ELF):调用点全改成经
api() 那张 dlsym 函数指针表走,而共享库默认允许未定义符号 ——
漏绑/漏改一处,ld 不吭声,产物却带着一个没人提供的 mysql_query(或
SQLExecDirectW),于是每个使用者在加载这个 .so 时就炸。--no-undefined
把这条不变式交还给链接器:漏一处就是链接期红。加 ODBC 那一份时这条兜底直接生效
过 —— nm -u …/uvcpp_db_odbc.cpp.o 里剩下 0 个 SQL* 未定义符号。
前三个后端各自钉死一种客户端库(libsqlite3 / libmysqlclient / libpq),ODBC 对着
的是一个接口:编进来的是 ODBC 驱动管理器(Linux 的 libodbc、Windows 的
系统 odbc32),连到哪张库由连接串里的 Driver=(或 DSN=)决定。那个具体
驱动(比如 SQL Server 的 ODBC Driver 18 for SQL Server,官方 msodbcsql18)
既不归构建管、也不归这个库管:装没装、装的是哪一版,只有 open() 那一刻才
知道。接 SQL Server 走的就是这条路 —— 微软没给它一个可链接的 C 客户端库,它给的
就是 ODBC 驱动,所以在 C 层没有第二个选择;而这个后端本身是按通用写的:能连
SQL Server,也能连任何装得出 ODBC 驱动的数据源(Oracle、DB2、达梦……),全文件
没有一处"是 SQL Server 就怎么着"。
由此有三条与前三个后端不同的、值得先知道的:
-
开关说的是"这份产物有没有这个后端",不是"能不能连上"。 配置期只要
sql.h与驱动管理器两样。缺sql.h时打一条 warning(Debian/Ubuntu 上装unixodbc-dev, RHEL 系unixODBC-devel)并只关掉 ODBC 这一个后端。只装运行时unixodbc的 机器上也会走到这条:那个包给的是libodbc.so.2,而.so这个开发符号链接 是unixodbc-dev才有的 —— 所以要的是unixodbc-dev,不是unixodbc。 -
连接失败时报的是驱动管理器给的原文(驱动名写错时就是它那句"找不到数据源、 也没有默认驱动"),本模块不把它改写成自己的话:这句话是判"我的连接串写错了" 还是"这台机器没装驱动"唯一有用的信息。它不走
NO_DRIVER那条路 ——NO_DRIVER的含义是"这个后端没编进这份包"(见 §2)。 -
它也走
UVCPP_DB_RUNTIME_LOAD:链上的是驱动管理器,那也是"客户端库"。 起先这里写的是"ODBC 不走这条路,因为驱动管理器本来就该在、而且很小" —— 那句话 只在 Windows 上成立(odbc32.dll属于操作系统,和ws2_32同类,链接它没有 代价);Linux / macOS 上驱动管理器是一个普通包(unixODBC),链上它就是在产物里 加一条DT_NEEDED libodbc.so.2,正是上面「运行时加载」那一节要消灭的形状:两棵树只差 `-DUVCPP_DB_RUNTIME_LOAD` 这一个开关(其余逐条相同): $ readelf -d /tmp/uvcpp-off/libuvcpp.so | grep NEEDED # 关(默认) NEEDED libuv.so.1 NEEDED libsqlite3.so.0 NEEDED libodbc.so.2 NEEDED libstdc++.so.6 $ readelf -d /tmp/uvcpp-rt/libuvcpp.so | grep NEEDED # -DUVCPP_DB_RUNTIME_LOAD=ON NEEDED libuv.so.1 NEEDED libsqlite3.so.0 NEEDED libstdc++.so.6于是它和 MySQL/PG 同一条规矩:开 ⇒ 产物里不出现这个库,"这台机器没装 unixODBC"推迟到真去连那一次(拿到的是
NO_DRIVER加一句写清了试过哪些库名 的话);关(默认)⇒ 老实链上。两件事别混:驱动管理器是这一层的依赖, 它下面的具体驱动由它自己dlopen,那是open()那一刻的事(上面第一条)。候选名是各平台的惯例名(Linux
libodbc.so.2/libodbc.so、macOSlibodbc.2.dylib/libodbc.dylib、Windowsodbc32.dll),要指到别处用UVCPP_DB_ODBC_LIBRARY。iODBC 有意不在候选名里:它是另一个驱动管理器实现, 符号名一样但行为没在本仓量过 —— 想用它就显式指路,别让"打开成功"盖住"换了一个 没量过的实现"。
这个问题一定会被再问一遍("SQLite 能全静态编进去,ODBC 为什么不行、为什么还要装 东西"),所以答案写在这里,而不是留给下一个人再想一次:
- 静态链消灭的是"管理器这层可替换",换不来"不用装东西"。 ODBC 后端的契约是
"编进来的是一层接口,连哪张库由
Driver=/DSN=在open()那一刻决定"。 驱动管理器存在的意义就是按那个名字去dlopen具体驱动(msodbcsql18之类)。 把管理器静态链上,那份dlopen一样要发生、那个具体驱动一样得装 —— 也就是说1.7.2的静态链版本到了现场,该装的还是装,只是少了一层的可替换性。 - Windows 上它连"合规"都过不去。
odbc32.dll是操作系统组件,不可再分发 (同一类里还有ws2_32.dll)。把它静态链进自己的产物,既没有上游给的静态库, 也不是允许的做法 —— 而目标平台正是 Windows。 - Linux/macOS 上它有害。 驱动管理器在那是普通包(unixODBC),静态链进去多一条
DT_NEEDED,正是上面「客户端库运行时加载」那一节要消灭的形状。 - 它也不解决这份文档开头那个问题。 真正会缺的是具体驱动(比如
ODBC Driver 18 for SQL Server),那是个几 MB 的安装包,跟本库怎么编没关系。
所以这一档的答案是:静态链不适用,也不需要。产物里编进的是那 1600 多行后端代码
(本来就在 libuvcpp.so/.dll 里),缺的只是"管理器这一层的一次 dlopen";要
ODBC 的机器上装一份驱动管理器 + 具体驱动就能用,没装的机器上其余三个后端一切
照常,只有真去连 odbc:// 时拿到 NO_DRIVER 与一句写清了试过哪些库名的话
(这一条有独立用例钉着:tests/functional/db_odbc_nodrv_func.cpp,见 §10)。
连接串里的默认驱动名(ODBC Driver 18 for SQL Server)只是默认:?Driver= 或
?DSN= 一写就换掉(见 §2)。
六条发布腿各传这几个 -D(1.7.0 起前五行、1.7.2 起第六行;在那之前后两行是 =OFF):
-DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \
-DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \
-DUVCPP_DB_RUNTIME_LOAD=ON -DUVCPP_ENABLE_DB_ODBC=ON \
-DUVCPP_DB_ODBC_INCLUDE_DIR=<那份 sql.h 在哪>
也就是说 —— 1.7.0 那个预编译包里的 db 模块三个后端都在(uvcpp_db_drivers()
会打印 sqlite,mysql,postgres —— 是驱动的稳定短名,pgsql 只是 scheme 的
别名之一,见下面的别名表),但 MySQL/PG 的客户端库是运行时才打开的,链接行上
不出现(见下面「客户端库运行时加载」那一节)。
ODBC 是第四个,1.7.2-dev 起六条腿都显式开、且每一条都显式给出头在哪
(1.7.1 这个号没发过,所以还没有哪个已发布的包带过它)。这件事必须显式,
不能靠默认值:UVCPP_ENABLE_DB_ODBC 默认就是 ON,而它找不到 sql.h 时的后果是
静默降级 —— message(WARNING) + 普通变量 set(... OFF),CMakeCache.txt 里
照样写着 ON、生成头里却是 0、包里少一个后端,一条断言都不会红。所以发布腿上是
两件事一起写:
| 腿 | sql.h 从哪来 |
为什么是它 |
|---|---|---|
mingw-x64 / mingw-arm64 |
MSYS2 包 mingw-w64-{x86_64,clang-aarch64}-unixodbc,头在 $MINGW_PREFIX/include/unixodbc/ |
这是唯一一套给 mingw 编的 ODBC 头;Windows SDK 那份是给 MSVC 的(__declspec/SAL/#pragma),混进 mingw 是拿一个没人试过的组合去赌。运行期反正加载系统 odbc32.dll,两种头在这个后端里等价 |
msvc-x64 / msvc-arm64 |
Windows SDK 的 <SDK>/Include/<ver>/um,由 .github/scripts/win-odbc-headers.sh 按版本号取最大的那一份 |
SDK 自带、随工具链走,不用网络也不用解包。取"版本号最大"而不是字典序:字典序会把 10.0.17763.0 排在最前,于是 um 来自一份老 SDK 而 <windows.h> 来自新的,报出来是 oaidl.h/propidlbase.h/sqltypes.h 里成百条 C4430/C2144,看着像 SDK 坏了 |
linux-x64 / linux-arm64 |
apt-get download unixodbc-dev + dpkg -x(不 install,与 libmysqlclient-dev/libpq-dev 同一个机制) |
与那两份头同一条规矩:只要头。腿里 -DUVCPP_DB_RUNTIME_LOAD=ON,配置期连 find_library(odbc) 都不跑,所以 ODBC 不会往产物里多添一条 DT_NEEDED libodbc.so.2 |
Linux 那份头有个只有把包摊到一个目录里才会遇到的坑:Debian 把
usr/include与usr/include/<multiarch>/分成两层,而sqltypes.h里有一句#include "unixodbc.h"(unixodbc.h在第二层)。真机上两层都在编译器的默认 搜索路径里,所以装了unixodbc-dev的人一辈子看不到这个分层;发布腿只把usr/include这一层交给-D,于是它掉了 —— 症状是编译sqltypes.h时那句#include报fatal error: unixodbc.h: 没有那个文件或目录。腿里把那一层 软链到公共头旁边补上(见release.yml里ln -sfn … unixodbc.h那两行)。
六条腿的产物断言里也因此多了一枚 UVCPP_DB_ODBC_ENABLE —— 它读的是生成头
(<tree>/include/uvcpp/uvcpp_config.h),不是 CMakeCache.txt:上面那条"cache 里
照旧 ON"的降级形状只有读生成头才拦得住。两条 Linux 腿还各多两条负向判据
(ldd 的名单里加 odbc、nm -D -u 的计数加 SQL[A-Z]),两条 mingw 腿的导入表
白名单一个字都没动 —— 里面没有 odbc32.dll,这正是想要的:真链进去了它就会红。
SQLite 那一半必须从源码编,不是漏配, 是两条实测理由把另两条路都堵死了:
| 想链系统的那份 | 实测结果 |
|---|---|
libsqlite3.so |
产物多一条 DT_NEEDED。发布腿有一条 ldd 断言在拦这个形状(原本的名单里还没有 sqlite3 —— 已经补上,否则它会一条条放行) |
libsqlite3.a |
Ubuntu 24.04 上那份不是 PIC:gcc -shared -fPIC … /usr/lib/x86_64-linux-gnu/libsqlite3.a 当场报 relocation R_X86_64_PC32 against symbol 'sqlite3CtypeMap' can not be used when making a shared object; recompile with -fPIC |
MySQL / PostgreSQL 在 1.6.x 的包里是显式关的,不是"找不到":runner 上装着
libpq-dev 时 find_package 会静默成功,发布包就悄悄多一条 libpq.so.5 的
DT_NEEDED。1.7.0 起放着 D 那条路不用了 —— 后端编进去、库运行时取 —— 因为
DT_NEEDED 是加载期解析的:链上就等于"没装客户端库的机器连加载这份包都做不到"。
打开这个开关之后,配置期会做这几件事(都可以在本机复现):
-- db: 下载 SQLite amalgamation 3540000(sqlite.org,钉死哈希)
-- db: SQLite 后端开(3.54.0,源码 amalgamation,静态)
- 从
https://www.sqlite.org/2026/sqlite-amalgamation-3540000.zip下那份 amalgamation(一个sqlite3.c+ 一个sqlite3.h,没有别的依赖),EXPECTED_HASH SHA3_256=7b670a62fdfbd672b75fef004cb703c8a3e87d3a5cc7d675b4a08337004a2d93——这个值就是 sqlite.org 下载页上印在那一行旁边的那个,逐字对上。TLS_VERIFY ON。zip 落在<build>/_deps/uvcpp-sqlite/,下次配置再核一遍哈希 (那个文件就躺在构建目录里、谁都能改;只核一次等于把"上次下对了"当成"这次也对")。 - 解出
sqlite-amalgamation-3540000/sqlite3.c,编成静态目标uvcpp_sqlite3, 显式POSITION_INDEPENDENT_CODE ON—— 不靠全局那个CMAKE_POSITION_INDEPENDENT_CODE:这一份一定会进共享库,而使用者那棵树未必传了 全局开关,那时症状是链接期一句recompile with -fPIC,离原因很远。 - 版本号从解出来的
sqlite3.h里读(SQLITE_VERSION),不在 CMake 里再抄一遍。 所以上面那行 STATUS 里的3.54.0是编进去的那个版本,不是想象的。
这条路不调用 find_package。 与 UVCPP_BUILD_LIBUV_FROM_SOURCE 同一条纪律:
不猜机器上有什么。"优先源码、找不到再看系统"是不够的——runner 上装着
libsqlite3-dev 时它会静默链上系统那份,而那种产物在开发机上照样跑得动。
离线机器:把 sqlite-amalgamation-3540000.zip 自己放到
<build>/_deps/uvcpp-sqlite/ 下(哈希要对得上),或者直接
-DUVCPP_DB_SQLITE_FROM_SOURCE=OFF 回到系统那份(本地开发用完全够)。
这条路只影响 SQLite 后端怎么来,不动公开面:uvcpp_db_client 的方法、
返回码、连接串格式一个字不变。
默认(直接链):不用新开关,走 CMake 的常规搜索路径:
cmake -DUVCPP_ENABLE_DB=ON -DCMAKE_PREFIX_PATH=/opt/mysql ..
# 或者直接点名(find_path/find_library 会尊重预先设好的同名 cache 变量)
cmake -DUVCPP_ENABLE_DB=ON \
-DUVCPP_DB_MYSQL_INCLUDE_DIR=/opt/mysql/include \
-DUVCPP_DB_MYSQL_LIBRARY=/opt/mysql/lib/libmysqlclient.so ..
# PostgreSQL 走 CMake 自带的 FindPostgreSQL
cmake -DUVCPP_ENABLE_DB=ON \
-DPostgreSQL_INCLUDE_DIR=/opt/pgsql/include \
-DPostgreSQL_LIBRARY=/opt/pgsql/lib/libpq.so ..
# ODBC 是本模块自己的两个 find_*
cmake -DUVCPP_ENABLE_DB=ON \
-DUVCPP_DB_ODBC_INCLUDE_DIR=/opt/unixodbc/include \
-DUVCPP_DB_ODBC_LIBRARY=/opt/unixodbc/lib/libodbc.so ..开了 UVCPP_DB_RUNTIME_LOAD:位置的找法分成两截 ——
-
头(编译期)仍旧走上面那套,但 PG 那边不再用
find_package(PostgreSQL)(它一个判据同时含头与库,而"库在不在"这条路上与我们无关;它还会去读PostgreSQL_LIBRARY那个 cache 变量)。改成一个find_path,所以给的是-DUVCPP_DB_PGSQL_INCLUDE_DIR=/opt/pgsql/include(MySQL 那个同名变量照旧)。 -
库(运行期)与构建完全无关,构建期给什么都没用。装库的人在跑的时候 用环境变量指路,而且设了就只试这一个:
UVCPP_DB_MYSQL_LIBRARY=/opt/mysql/lib/libmysqlclient.so.21 \ UVCPP_DB_PGSQL_LIBRARY=/opt/pgsql/lib/libpq.so.5 \ ./myapp
注意这不是 CMake cache 变量 —— 与构建期那两个同名变量是同名不同物, 别指望
cmake -DUVCPP_DB_PGSQL_LIBRARY=…会影响运行时(那时链接行上根本没有它)。
四个后端用到的库(sqlite3 / pq / mysqlclient / ODBC 的驱动管理器 odbc)
挂在 $<BUILD_INTERFACE:> + PRIVATE 下:共享库上 DT_NEEDED 会跟着走,
使用方什么都不用做;静态库(uvcpp_a / uvcpp_a_s)上那一条不传给消费者,
使用方要自己链:
target_link_libraries(myapp PRIVATE uvcpp_a sqlite3 pq mysqlclient odbc)这是故意的取舍,与 zlib / nghttp2 那几处同形:把绝对路径写进导出集会过得了
install(EXPORT) 却搬到别的机器才断,比缺一条 target_link_libraries 更难查
(后者是链接期一句 undefined reference to sqlite3_open_v2,指向明确)。
开了 UVCPP_DB_RUNTIME_LOAD 时这一节不适用:MySQL/PG 的客户端库根本不在
UVCPP_DB_LINK_LIBS 里(那正是那个开关干的事),所以静态库的使用方也不用
再补 pq/mysqlclient —— 只剩 SQLite 那一条要不要补,取决于它是系统那份还是
UVCPP_DB_SQLITE_FROM_SOURCE 编进来的静态目标。ODBC 那一条不受这个开关影响:
驱动管理器照旧在链接行上,所以静态库的使用方照旧要补(Windows 上是 odbc32)。
sqlite:///abs/path/app.db sqlite://rel/path.db sqlite::memory:
mysql://user:pass@host:3306/dbname
postgres://user:pass@host:5432/dbname
odbc://user:pass@host:1433/dbname?Driver={ODBC Driver 18 for SQL Server}
odbc:?DSN=MyDsn
| 项 | 规则 |
|---|---|
| scheme 别名 | postgres / postgresql / pgsql / pg → postgres;mariadb → mysql。别名归一化之后才去做"认不认得"的判断 |
| 缺省端口 | MySQL 3306、PostgreSQL 5432。ODBC 没有缺省端口:不写就不是 tcp:host,port 而是 Server=tcp:host |
| 密码 | 可省(mysql://root@host/db)。URL 里带特殊字符要自己转义,本模块不做 URL 解码 |
| SQLite 的路径 | sqlite:// 后面原样当一个路径用(不解析 user/host/port)。父目录不存在时驱动会建出来 |
| ODBC 的 host / 库名 | host:port → Server=tcp:host,port,/dbname → Database=,user:pass@ → Uid= / Pwd= |
ODBC 的 ? 之后 |
原样追加到 ODBC 连接串末尾(本模块不解析它,; 分隔),所以那里能写任何驱动认得的键 |
| 没带 scheme | BAD_URL。报错原文里会带上传进来的整串 |
ODBC 那两行的默认值只补缺:连接串里已经写了 Driver= / DSN= / FILEDSN=
就一个都不补(?DSN=MyDsn 那行就是完全不碰主机与库名的用法);没写时补的是
Driver={ODBC Driver 18 for SQL Server} 加一条 Encrypt=no(18 版默认强制加密,
而内网库多半没配证书 —— 不显式关掉的话,使用者会先撞上一个"证书不受信任"的握手
错误,然后去怀疑连接串写错了)。同样,没给账号时补 Trusted_Connection=yes
(Windows 集成认证:同域部署下更该用的方式,而且没有口令进连接串,也就不会进日志),
?Uid= / ?User= 里写了一个就不补。
Server=tcp:host,port 这个带逗号的写法是 SQL Server 的。别的驱动的连接串文法
各认各的,认不出 ,port 的那个会忽略它或报错 —— 这是"通用驱动"在连接串这一层的
固有代价,写在这里而不是藏起来:换数据源时 ? 之后通常也要跟着换。
这是本模块唯一一处"两个返回码看着像、含义差很远"的分界,值得单说:
BAD_URL—— 这个 scheme 根本不存在(oracle://…、少写 scheme)。是调用方写错了。NO_DRIVER—— 这个后端存在、但这份包没编进来(mysql://…而包里只有 SQLite)。换一份包就好,SQL 一个字不用改。
判据是「这个 scheme 是不是本模块支持过的后端」(uvcpp_db_driver_known()),
不是「这份构建里有没有」。用后者的话两种情况都是假,分不开 —— 而"打不开连接"
这个笼统说法,恰恰是最没用的一句错误信息。用例里两条都有断言。
ODBC 上还有第三种"打不开",它在返回码上不算前两种:后端编进来了、驱动管理器
也在,缺的是那个数据源的驱动(或者驱动名写错了)。这一种要到 open() 才知道,
返回的是驱动管理器给的原话,last_error() 里就是它 —— 别去 NO_DRIVER 那边找
(那条只会在"这份包没编 ODBC 后端"时出现)。
#include <cstdio>
#include <db/uvcpp_db.h>
int main() {
uvcpp::uvcpp_db_client db;
uvcpp::uvcpp_db_status st = db.open("sqlite:///tmp/app.db");
if (!uvcpp::uvcpp_db_ok(st)) {
std::fprintf(stderr, "%s\n", db.last_error().c_str());
return 1;
}
uvcpp::uvcpp_db_table t;
db.query("SELECT id, name FROM users WHERE age > ?", {18}, &t);
for (const uvcpp::uvcpp_db_row& row : t.rows()) {
std::printf("%s\n", row["name"].to_text().c_str());
}
return 0;
}| 组 | 成员 |
|---|---|
| 连接 | open / close / is_open / reconnect / ping / url / driver_name / last_error |
| 读 | query(sql, &t)、query(sql, params, &t) |
| 写 | execute(sql, [affected])、execute(sql, params, [affected])、insert(sql, params, &new_id) |
| 自增 | last_insert_id() / last_insert_id(&v) |
| 事务 | begin / commit / rollback |
| 元信息 | table_names(&v) / table_schema(table, &t) |
| 转义 | escape(text) / escape_identifier(ident) |
| 配置 | set_timeout_ms / timeout_ms / set_log |
| 自由函数 | uvcpp_db_open(url, &status, &error)、uvcpp_db_drivers() |
四条容易踩的:
last_error()不会因为一次成功就清空 —— 失败现场比成功更值得留着。 所以判"这次行不行"看返回码,别去看last_error()空不空。uvcpp_db_open()的error出口是必需的,不是可选装饰:失败时那个 client 当场就被销毁了,last_error()跟着没 —— 只给返回码的话,"认证失败" 与"库不存在"在调用方眼里是同一个OPEN_FAILED。row["name"]保证不崩:找不到列时给一个静态的 NULL 值。 行/列越界同样返回 NULL 值,不抛也不越界读。set_log()的钩子是在「锁里面」被调的,别在里面阻塞。 它在发起这次 查询的那条线程上被调(异步门面下就是池线程;真异步那条路上就是循环线程), 而调用时 client 的互斥量还在 手上 —— 日志是query()体内记的,锁罩着整个函数体。在钩子里等一件事 = 替整个 client 攥着锁:同一个 client 上别的线程的查询会全部停住。异步门面下这足以 凑出死锁(同一个 client 上投两条活,其中一条的钩子里等另一条 —— 另一条正卡在 锁上等它)。要记日志就往缓冲区里塞,别在钩子里做慢事。
三个客户端库的公开面都是阻塞 API。把它们塞进事件循环有两条路:丢线程池
(uv_queue_work)或者自己实现协议。uvcpp_db_client 是第一条路的底座,而
uvcpp_db_async(#include <db/uvcpp_db_async.h>)就是建在它上面、替你把那段
样板写掉的那一层。
第二条路 1.7.2 起也有了,但只在 PostgreSQL 这条腿上:uvcpp_db_client 的
query_async() 用 libpq 自己的非阻塞协议(PQsendQueryParams + PQflush,然后
uv_poll 盯 PQsocket(),PQconsumeInput / PQisBusy / PQgetResult 收结果)——
不占线程,I/O 由调用方那条循环驱动。它不是另一套公开类型,而是连接池异步接口
下面的一条实现(见 §3.1的两个跑法);uvcpp_db_client::query() 那套
阻塞方法照旧,事务、ODBC、MySQL、SQLite 全走它。
uvcpp_db_client 内部有一把递归锁:同一个 client 可以被多个线程调,但同一时刻
只有一个在真的用连接。要并发就开多个 client(或者 §3.1 的连接池)。
同步底座本身不是缺陷,缺陷是在事件循环线程上直接调它。量具
(bench/bench_db.cpp 第 9 节)把代价量出来了 —— 一条 1 ms 的定时器挂在循环上,
同期跑 10 条 50 ms 的慢查询,记相邻两次到点的最大间隔:
| 这批查询怎么跑 | 循环被卡多久(max gap) | 同批工作总耗时 |
|---|---|---|
| 直接在循环线程上同步跑 | 504 ms(MySQL) | 504 ms |
手写 uvcpp_work 派到线程池 |
2.1 ms | 506 ms |
uvcpp_db_async 派到线程池 |
2.0 ms | 505 ms |
三行里最要紧的是后两行的总耗时和第一行一样:异步买的是"循环不被卡住", 不是"查询变快"。同一个 client、同一条连接、同一把锁,总时间当然一样。
#include <cstdio>
#include <db/uvcpp_db.h>
#include <db/uvcpp_db_async.h>
#include <handle/uvcpp_loop.h>
// ① 回调式:cb 在 loop 的线程上被调(loop 由调用方自己 run)
void submit_on_loop(uvcpp::uvcpp_db_client* db, uvcpp::uvcpp_loop* loop) {
uvcpp::uvcpp_db_async a(db); // 绑连接,不持有
a.query(loop, "SELECT id, name FROM users WHERE city = ?", {"杭州"},
[](uvcpp::uvcpp_db_status st, const uvcpp::uvcpp_db_table& t) {
if (!uvcpp::uvcpp_db_ok(st)) return; // 失败时 t 是空表
for (const uvcpp::uvcpp_db_row& row : t.rows()) {
std::printf("%s\n", row["name"].to_text().c_str());
}
});
a.execute(loop, "UPDATE users SET age = ? WHERE id = ?", {30, 7},
[](uvcpp::uvcpp_db_status, int64_t) {});
}
// ② future 糖:给不在循环上的调用方(门面懒起一条私有循环线程)
uvcpp::uvcpp_db_result fetch_from_worker(uvcpp::uvcpp_db_client* db) {
uvcpp::uvcpp_db_async a(db);
auto f = a.query("SELECT COUNT(*) FROM users");
return f.get(); // 阻塞**调用方这条线程**
}契约有四条,都是承重的:
- 完成回调一定在
loop的线程上被调,且只调一次。 这不是本库拼的,是uv_queue_work的 after-work 由 libuv 从uv__work_done里在循环线程上发出的 结果 —— 所以这里没有uv_async邮箱。 - 投递失败(loop 传空、同一个 work 复用):
query()/execute()返回负值, 回调不会被调。别等回调,看返回值。 - 在回调里
delete门面是合法的。 门面析构第一件事就断掉存活凭证,之后跑完的 活不再调用户闭包,也不会二次回调。 - future 的
get()会阻塞调用方自己那条线程 —— 在事件循环线程上用它就是把 刚躲开的阻塞又请回来。不在循环上的线程(工作线程、C# 的线程池)才用它。
uv_queue_work 丢进的是 libuv 的默认线程池,默认只有 4 条线程
(UV_THREADPOOL_SIZE 可改,1..1024,第一次投递时读进缓存)。同一条池子还被
uv_fs_*、uv_getaddrinfo、uv_getnameinfo、uv_random 用着 —— 所以
一条 30 秒的慢查询会把文件读写和 DNS 一起堵住。要变就在任何投递之前调
uvcpp_set_threadpool_size()。这是这套模型的已知代价,不是门面漏做了什么。
门面不做背压(不限制在途条数)。一条连接本来就是串行的,在途多条只会排队等那把 递归锁,不会更快;真正的并发看下一节的连接池。
别把异步和同步混着用来"省时间":在循环线程上调一次同步 db.query(),会去等
worker 手里的锁 —— 不会死锁,但会把循环卡住,正好抵消掉异步的全部意义。
uvcpp_db_pool(#include <db/uvcpp_db_pool.h>)是一组 uvcpp_db_client,按需借出、
用完归还。它买的是连接复用(不再每次付 open 的 1.9~2.4 ms)与多条连接
(上一段那把递归锁不再互相等)—— 正好补上异步门面补不了的那一半:异步买的是"循环
不被卡住",池子买的是"并发"。单线程串行跑的场景它一分钱都省不下来,还会多一层
借还,那就别用,一个 uvcpp_db_client 加门面就够。
量具 bench/bench_db.cpp 第 10 节,4 条线程 × 500 次点查:
| 连接怎么来 | MySQL | PostgreSQL | SQLite |
|---|---|---|---|
| 4 条线程共享 1 个 client | 211.5 ms | 101.3 ms | 16.4 ms |
| 4 条线程各开 1 个 client | 64.1 ms | 29.6 ms | 7.1 ms |
4 条线程走池子(max=4) |
61.9 ms | 32.8 ms | 6.0 ms |
| 池子 / 各开一条 | 0.97× | 1.11× | 0.85× |
判据就是最后一行的同量级(1× 附近):池子买的正是"各开一条"那份并行度,多出来的
只有一次借还(一把锁 + 一次出队)。同时打印的 created_total() 是复用的见证 ——
它停在 max(4)上而不是 2000,说明连接是借来借去,不是每次新开。
#include <cstdio>
#include <db/uvcpp_db.h>
#include <db/uvcpp_db_pool.h>
// 建池:min 条**当场**开好 —— 连不上在这里就报出来,不留到第一次查询
int open_pool(uvcpp::uvcpp_db_pool* pool) {
if (!uvcpp::uvcpp_db_ok(pool->init("sqlite:///tmp/app.db", 2, 8))) {
std::fprintf(stderr, "%s\n", pool->last_error().c_str());
return 1;
}
return 0;
}
// ① 代借代还:单条语句用这个 —— 一次调用借一条、用完立刻还
void count_users(uvcpp::uvcpp_db_pool* pool) {
uvcpp::uvcpp_db_table t;
if (!uvcpp::uvcpp_db_ok(pool->query("SELECT COUNT(*) AS n FROM users", &t))) {
std::fprintf(stderr, "%s\n", pool->last_error().c_str());
return;
}
for (const uvcpp::uvcpp_db_row& row : t.rows()) {
std::printf("%s\n", row["n"].to_text().c_str());
}
}
// ② 借还:借到还之间这条连接**归你独占** —— 事务只能这么写
void transfer(uvcpp::uvcpp_db_pool* pool, int64_t from, int64_t to, int64_t amount) {
uvcpp::uvcpp_db_client* c = pool->acquire(); // 会阻塞(默认 5 s)
if (c == nullptr) return; // 借不到:原因在 pool->last_error() 里
c->begin();
c->execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", {amount, from});
c->execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", {amount, to});
c->commit();
pool->release(c); // 一定要还
}| 组 | 成员 |
|---|---|
| 起停 | init(url, min=1, max=8) / close() |
| 借还 | acquire() / acquire(timeout_ms) / release(c) / discard(c) |
| 代借代还 | query(...) / execute(...) / insert(...) / ping() |
| 异步 | query(loop, ...) / execute(loop, ...) / insert(loop, ...)(回调形状与门面完全一致) |
| 调参 | set_acquire_timeout_ms(5000) / set_idle_timeout_ms(60000) / set_check_on_acquire(false) / set_timeout_ms |
| 观测 | size / in_use / idle / max_size / min_size / created_total / reused_total / last_error / close_idle |
异步池查询每次调用自己借还一条连接,所以单条语句走它最省事;而事务走不了异步的
—— 它要独占一条连接直到 commit,异步那套的借还在回调之前就结束了。事务只有
acquire()/release() 这一条路,池子也因此不提供 begin()。
回调和线程完全一样(query(loop, ...) 的 cb 在 loop 的线程上被调一次),
区别只在等待发生在哪儿:
| 真异步(默认) | 线程池 | |
|---|---|---|
| 谁来等 | 连接自己的套接字挂在你的循环上(uv_poll),不占线程 |
uv_queue_work,每条在途查询占一条 libuv 池线程 |
| 谁能用 | 后端得自带异步协议 —— 现在只有 PostgreSQL | 所有后端 |
| 线程池要开多大 | 不用开 | 至少等于在途条数(UV_THREADPOOL_SIZE) |
| 每请求 CPU | 低(doc/benchmark.md §4.24:中位 −35%) |
高(每请求两次跨线程交接) |
连接增长(open)在哪跑 |
循环线程上,会卡住循环约 2 ms | 池线程上,不卡循环 |
insert |
仍走线程池 | 同左 |
set_native_async(false) 把池子按回线程池那条路(后端不支持时这个开关没有意义)。
insert 不在这条路上:它要"执行 + 取 lastval()"两次往返,而这条路一次只交一张
结果表。
收尾有一个硬约束:真异步这条路的查询 I/O 由那条循环驱动,所以别带着在飞的
查询 loop.stop()(或在回调都还没到时就析构循环)。那几条查询永远不会落地,
池子析构会一直等 work_in_flight 归零 —— 症状是进程退不出去。线程池那条路没有
这个问题。连线都对上了再停循环,判据是"投出去的每条回调都已经到过"。
谁让循环活着:真异步那一路在循环上留一个 uv_async 唤醒句柄,它是
unreferenced 的(libuv 自己的线程池唤醒句柄也这么处理)—— 排队中的那一段
不保活,与 uv_queue_work 同一个口径;查询一旦发出去,它自带的 uv_poll_t 才
保活,直到落地。所以 loop.run(UV_RUN_DEFAULT) 仍然能正常返回。
四条承重语义:
min条立刻开,失败当场报OPEN_FAILED(open一次 1.9~2.4 ms,留到第一次 查询再开是把延迟藏起来,不是消掉)。min默认 1。- 增长发生在
acquire()的调用线程上(total < max就新开一条)。同步路径下 就是调用方自己;异步线程池路径下是池线程 —— 不卡循环;异步真异步路径下是循环 线程(open约 2 ms,会卡住那一条循环,只发生在增长那一刻,稳态不付)。 - 坏连接不回池子:借出去的那条一旦返回
NOT_CONNECTED,池子把它discard掉 (代借代还那组自己就是这么做的)。不自动重试 —— 写操作重试就是重复写,与 §7同一条原则。默认也不在借出时ping(ping一次约 20 µs,每次借出都付会吃掉池子省下的一部分),要开用set_check_on_acquire(true)。 - 池子不回收自己:不起后台线程、不挂定时器。缩容只在
acquire()/release()顺手做(按idle_timeout_ms),或者你显式close_idle();两者都不会缩到min以下。一个池子挂着min条空闲连接是设计,不是漏回收。
三条禁令,都是这套接口形状的直接后果(详见 src/db/uvcpp_db_pool.h 的类注释):
acquire()是阻塞的,绝不能在事件循环线程上调。 要在循环上取连接就用异步 接口(借还在池子自己的路径里做,不占调用方线程;真异步那条路上它就在循环线程上 完成,只是不阻塞)。- 代借代还的同步方法(
pool.query等)同样是阻塞的,它们是给工作线程用的。 - 别在借出期间去投异步池查询。 异步池查询自己要借一条连接:
max=1时那唯一一条 在你手里,那条活就排在队列里发不出去(真异步那条路上它不占线程,只是排着), 而你手上那次acquire()会一直等到借出超时。它会醒(超时是硬的,默认 5 s), 但你会拿到NO_CONNECTION而不是结果。这条同时也是"libuv 默认池只有 4 条线程、uv_fs_*/ DNS 共用"那条代价的另一面:4 条池线程都去等连接,文件 IO 就一起饿着。 用例tests/functional/db_sqlite_pool_func.cpp钉的就是这个 —— 借走唯一一条、投 异步池查询、断言它在超时后拿到NO_CONNECTION而不是挂住。
C# / Rust / Python 的 ctypes 用数据库模块不必先写一层 C++。这一片是上面几组的
C 面,一格一格对着:
| C 面 | C++ 那侧 |
|---|---|
uvcpp_c_db_client_* |
uvcpp_db_client 的同名方法 |
uvcpp_c_db_params_* |
uvcpp_db_params(八个 add_* 逐个对上它的重载) |
uvcpp_c_db_table_* |
uvcpp_db_table 的读侧 + to_json() / to_csv() |
uvcpp_c_db_value_* |
uvcpp_db_value 的读侧(写侧走参数) |
uvcpp_c_db_pool_* |
uvcpp_db_pool(借还 + 代借代还 + 观测) |
uvcpp_c_db_async_* |
uvcpp_db_async,以及池子那一组异步 |
三个开关与这一片的关系,一句话:UVCPP_ENABLE_DB 是它的开关(关了就没有
uvcpp_c_db.h,一个函数都不导出);后端那一级开关与它无关 —— 关掉 MySQL 只是让
uvcpp_c_db_drivers() 不再报出那个名字、让 mysql://… 报 NO_DRIVER,符号面一个
不多一个不少。C 面的完整清单与所有权规则在
doc/capi-guide.md §4 那张表里。
C 这一侧有四条与 C++ 不同、必须知道的:
- 一个
int上住着两套编码。>= 0是uvcpp_c_db_status(与 §4那张表数值逐条对齐,0 = OK);< 0是 C 层错误 (UVCPP_C_E_STALE句柄失效 /_E_INVALID_ARG/_E_EXCEPTION)或者 libuv 码 原样穿过。两段不会撞:db 状态码全在0..11。 - 结果集是调用方拥有的句柄。
_query/_table_schema交出来的、以及异步回调 收到的那个uvcpp_c_db_table*,用完都要uvcpp_c_db_table_free();而_cell()给出的uvcpp_c_db_value*是借来的视图(属于那张表,没有free)。 池子借出的uvcpp_c_db_client*同理归池子 —— 对它调_client_free()是E_STATE。 - C 的异步没有循环参数。 C++ 的
uvcpp_db_async::query()收一枚uvcpp_loop*, 而 C 面没有合法的东西可填(公开头里造不出uvcpp_loop*;把调用方自己的uv_loop_t*借进来会让~uvcpp_loop()去uv_loop_close()并释放别人的内存)。 所以uvcpp_c_db_async_*的完成回调一律在门面自带的那条懒起的循环线程上被调; 要回到自己的循环,就在回调里投一次uvcpp_c_net.h的*_post()。 - 不提供
set_log()。 C++ 那侧收std::function,跨 FFI 每行日志都要另立一份 字符串生命周期约定;诊断走_client_last_error()与uvcpp_c_db_status_name()。
真跑的判据是 tests/capi/capi_db_func.c(纯 C 编译):把后端从环境变量里拿
(UVCPP_DB_TEST_PGSQL_URL / UVCPP_DB_TEST_MYSQL_URL),有哪个就连哪个,逐后端把
建表 / 插入 / 查询 / 事务 / 池 / 异步各跑一遍;一个后端都没有时退 3(未判定,
tests/functional/ 那几条 db 用例是同一个约定)。本机 build-capi-all 上 296 条断言,
三个后端都给连接串时 792 条。
| 返回码 | 含义 |
|---|---|
OK |
成功 |
BAD_URL |
连接串解析不了 |
NO_DRIVER |
这个后端的驱动没编进这份库 |
OPEN_FAILED |
主机/端口不可达、认证失败、库不存在、超时 |
NOT_CONNECTED |
没开就调,或执行途中连接掉了 |
NO_CONNECTION |
连接池借不出:已到 max 且全在外借,等到借出超时也没等到 |
PREPARE_FAILED |
语句准备失败:SQL 语法错、表/列不存在 |
EXEC_FAILED |
语句执行失败:约束冲突、类型不匹配、死锁重试用尽、权限不足 |
BIND_FAILED |
参数与占位符对不上(个数、或名字找不到) |
UNSUPPORTED |
这个后端不支持该操作(SQLite 没有存储过程、没有 lastval()) |
MISUSE |
用法错误:空 SQL、空结果集指针 |
OUT_OF_MEMORY |
内存不足 |
返回码只区分哪一类失败,不枚举各家数据库自己的错误号 —— 那属于驱动的私有
词汇表。要 SQLSTATE / errno 时看 last_error() 里的原文(它里面就有)。
PREPARE_FAILED 与 EXEC_FAILED 的分界线是「服务端有没有接受这条语句」,
不是「哪一步失败」:语法错、表/列不存在属于前者;唯一键冲突、除零、死锁属于后者。
这条线在三家上都成立,包括裸通道(MySQL 的文本协议、PostgreSQL 的
PQexec)—— 那两条路是服务端一步做完"解析 + 执行",两种错从同一个出口回来,
驱动按错误号/SQLSTATE 分开(判据见 §9)。
为什么值得分:调用方的下一步动作不同 —— PREPARE_FAILED 改 SQL,EXEC_FAILED
可能是重试(死锁、锁等待)或改数据。
NO_CONNECTION 与 NOT_CONNECTED 必须分开:后者说的是"这条连接没打开",拿它顶
前者就是撒谎 —— 池子里每条连接都是好的,只是没有空闲的。两者的处置办法也完全
不同:前者该重连,后者该扩容(或把借出的还回来)。这个码只有 uvcpp_db_pool 会产生
(见 §3.1)。
值一律走参数,SQL 文本里永远不拼接外部输入。 但占位符是方言的一部分 —— 本模块把 SQL 原样交给驱动,不换算:
| 后端 | 占位符 | 第 n 个参数 |
|---|---|---|
| SQLite | ? |
按出现次序 |
| MySQL | ? |
按出现次序 |
| PostgreSQL | $1…$n |
$1 = 参数表第 0 个 |
#include <db/uvcpp_db.h>
uvcpp::uvcpp_db_status find_by_id(uvcpp::uvcpp_db_client& db,
uvcpp::uvcpp_db_table& t) {
// SQLite / MySQL:
return db.query("SELECT * FROM users WHERE id = ? AND name = ?", {1, "abc"}, &t);
// PostgreSQL 上同一条要写成 "... WHERE id = $1 AND name = $2"
}在 PostgreSQL 与 MySQL 上,拿错写法不会静默走偏 —— 它在准备阶段就报出来 (本机对着真服务端量过,六种组合一个不漏):
| 连接 | 写法 | 结果 |
|---|---|---|
| PostgreSQL | ? |
PREPARE_FAILED —— syntax error at end of input |
| PostgreSQL | $1 |
ok |
| MySQL | ? |
ok |
| MySQL | $1 |
PREPARE_FAILED —— Unknown column '$1' in 'where clause' |
| SQLite | ? |
ok |
| SQLite | $1 |
ok —— 这里有个坑,见下 |
SQLite 把 $1 当命名参数收下(能跑通),所以只测 SQLite 永远发现不了写法
错:同一段 SQL 在 PG 上必红。跨后端的代码请走共用套件的 dialect::ph(n),别
自己写死 —— 那是方言必须暴露的差异,也是
tests/functional/db_suite.h 里的
dialect::ph() 与 test_placeholder_dialect() 钉住的。
为什么驱动不做换算:PostgreSQL 里 ? 本身就是合法的操作符(jsonb / hstore
的"键存在"),? 也能出现在字符串字面量和带引号的标识符里 —— 盲扫一遍会把
合法的 SQL 悄悄改成语义不同的另一句。反方向同样不通:SQLite 收下 $1(当命名
参数,语义与位置参数不同),所以没有哪个统一形式能安全地翻译成两家。宁可让写错
的人当场拿到 PREPARE_FAILED,也不替他猜。
uvcpp_db_params从{1, "a", 2.5}这种初始化列表构造,元素类型由uvcpp_db_value的构造函数定;add()/add_null()是追加式写法。- 个数对不上 →
BIND_FAILED(三家一致,且都不会执行语句)。 - 字符串默认是
TEXT,要按字节存(图片、加密后的 payload)用uvcpp_db_value::blob(p, n)—— 它按数据长度走,含0x00也不会被截断。
转义只在拼标识符时才需要,而且它是"加引号",不是"消毒":
#include <string>
#include <db/uvcpp_db.h>
std::string quote_table(uvcpp::uvcpp_db_client& db,
const std::string& user_supplied) {
const std::string tbl = db.escape_identifier(user_supplied);
// 表名来自外部输入时,仍然要对着白名单查一遍再用
return tbl;
}escape() 转义的是字面量内容(不含两侧引号),MySQL 用反斜杠、SQLite/PG
用双写单引号 —— 期望值各家不同,所以用例判的是往返相等:
把一段装着引号、反斜杠、分号与 -- 的文本存进去,再原样读回来。
顺带一条与安全有关的实事:MySQL 连接开着
CLIENT_MULTI_STATEMENTS, 一次execute()里可以放多条以分号隔开的语句。那是便利也是风险 —— 拼接 SQL 就等于把第二条语句的权力交给数据。能参数化就参数化。 另外,含?的语句走的是预处理通道,而预处理协议不接受多语句: 想用多语句就别用占位符。
这一节的标题说的是 SQLite / MySQL / PostgreSQL 三家,ODBC 单独放在紧接着的 「ODBC 换了一列」里。ODBC 已经进了同一个共用套件 (
tests/functional/db_odbc_func.cpp;方言按Driver=认出来的驱动族挑,读数见 下面表格的最后一行),而本仓跑过的那条路是 ODBC 到 SQLite。SQL Server 那一族 本仓量不到(§11 说了为什么,以及下游一个私有项目的 CI 已经把它跑到了哪一步)。读到"三个后端"、"三家"时,ODBC 不在里面。
本机实测的读数(数字各不相同是设计如此,见下面"三处真不一样"):
| 后端 | 通过 | 不适用 | 不适用的那几行 |
|---|---|---|---|
| SQLite | 196 | 1 | 原生时间类型标签(它没有原生时间类型) |
| ODBC(到 SQLite) | 181 | 3 | 原生时间类型标签、last_insert_id、外键约束 |
| MySQL | 159 | 1 | ——(沿用旧读数) |
| PostgreSQL | 159 | 1 | 同上 |
ODBC 那一行的 181/3 是关掉 MySQL 与 PG 时量的。把另外两个后端一起打开会是 180/4:多的那条不适用是
没编进来的后端 -> NO_DRIVER—— 它只有在"这份构建 真的缺了某个后端"时才是个能跑的断言,四个后端都在的树上它按设计记 N/A。 两个读数都是对的,差别在构建配置,不在后端。(1.7.2的发布包是四个都在的那一 种,所以现场看到的是 180/4。)
ODBC 那一行之外还有另一组 ODBC 专属用例(日志里单独一段,-- ODBC 专属 --,
57 通过 / 0 失败):共用套件判"三家一致"的那些契约,ODBC 专属那一组判它自己的坑。
口径写清楚:读数是这台机器、这份构建(SQLite 与 ODBC 编进来,MySQL / PostgreSQL 没编)跑出来的;SQLite 那一行在 ODBC 关掉重编重跑时同样是 196 / 1, 所以它不随"编不编 ODBC"变。MySQL / PostgreSQL 两行是上一轮的读数,本机没有 可跑的 MySQL/PG 服务端,这一轮只重测了 SQLite 与 ODBC。SQLite 那一行同样和更早的 文档对不上(旧数字是 157 / 2,本树上复现不出来)—— 复现不出来的旧读数不留着当 "约等于",直接换成现测数字。
"不适用"是独立的一格计数器,与"通过"分开打印 —— 跳过永远不会在日志里 伪装成通过。
套件钉住的是:
- 同一条 DDL/DML/SELECT 在三个后端上得到同一份结果:列名、列序、行数、 值的文本形态逐字节相等;
- 空串不是 NULL,NULL 不是空串(DDL 里带
NOT NULL DEFAULT '', 把空串折成 NULL 的实现会撞在非空约束上,失败得响亮); - 长值(32 KB 文本、含
0x00/0xFF的 BLOB)往返逐字节相等; - 参数个数对不上 →
BIND_FAILED;语法错/表不存在 →PREPARE_FAILED; - 事务:
begin之后rollback,写进去的行不在;commit之后在; - 外键约束真的生效(SQLite 在
open()里执行了PRAGMA foreign_keys = ON—— 它默认是关的,不显式打开的话外键是画上去的); table_names()/table_schema()能报出刚建的表与列。
共用套件的形状是「一个 dialect 虚基类 + 一个 harness」:不同后端只能提供
方言(占位符写法、自增主键的列声明、定点的列声明),不能分叉断言。
任何 if (backend == ...) 都被视为"差异还没收敛",不接受。
| 项 | SQLite | MySQL | PostgreSQL |
|---|---|---|---|
| 布尔 | 没有布尔类型,INTEGER 的 0/1 |
TINYINT(1),读回来是整数 |
有真 BOOL,读回来是 BOOL |
| 日期时间 | 没有原生类型,存文本 | 原生 DATETIME/DATE/TIME |
原生 timestamp/date/time |
| 定点(钱) | 没有,只能 TEXT 存字符串 |
原生 DECIMAL,读成 double |
原生 NUMERIC,读成 double |
所以套件里判值(to_bool()、to_text())而不是判类型标签 ——
判类型标签的断言在 MySQL/SQLite 上必然红,而那红的不是被测代码。
日期时间一律以文本形态保存("YYYY-MM-DD"、"HH:MM:SS"、
"YYYY-MM-DD HH:MM:SS"):这是三家都能无损往返、且人眼直接可读的表示。
SQLite 本来就没有日期类型,PostgreSQL 的文本格式恰好就是这个形状。
前三家的差别在库里;ODBC 的差别在驱动里。同一个 odbc 后端,Driver= 指的
是 SQLite 的 ODBC 驱动还是 SQL Server 的,占位符写法、自增主键声明、定点列声明就跟着
变 —— 这也是它必须有两个 dialect 实现(odbc_sqlite_dialect / odbc_mssql_dialect)
的原因:断开的是"后端"与"方言"的绑定。共用套件里"不能分叉断言"这条规矩对它同样成立,
多出来的自由度只体现在方言上。
| 项 | ODBC 到 SQLite(sqliteodbc,跑过) | ODBC 到 SQL Server(本仓没量) |
|---|---|---|
$1 占位符 |
不接受(parameter marker count incorrect) |
—— |
| 语法错 / 表不存在 | 报 EXEC_FAILED:SQLPrepare 只登记,不解析 SQL |
—— |
| 自增主键 | INTEGER PRIMARY KEY AUTOINCREMENT |
—— |
| 定点(钱) | 没有,TEXT 存字符串 |
—— |
| 外键 | 连接里不开 PRAGMA foreign_keys → 不强制 |
—— |
last_insert_id() |
没有这条路 → 不适用 | —— |
右列的破折号不是"待填",是"本仓量不到" —— 跑这一列要一台真 SQL Server,而
本仓的 CI 里没有、也不该有(见 §11)。这些列声明写在 odbc_mssql_dialect 里,等着
一台真库来对。
第二行那条是 dialect::prepares_eagerly() 这个钩子存在的理由:SQLPrepare 是当下
解析 SQL、还是只把语句登记下来等 SQLExecute 才报错,是驱动的自由。套件不假定它,
只按声明的能力判返回码 —— 说"预处理阶段失败"的地方在这类驱动上报的是 EXEC_FAILED,
而那同样是真的失败,只是晚一步。
- SQLite:
sqlite3_column_*按运行期类型取,与列的声明类型无关 (SQLite 是动态类型,声明只是"亲和性")。整型超出INT64_MAX时驱动拒绝 绑定(BIND_FAILED),不截断成负数存进去 —— 用例里有这条见证。 - MySQL:两条通道。非空参数走预处理协议(二进制),空参数走文本协议
(
mysql_query)—— 后者不只是"少一次 prepare":旧服务端的预处理协议拒绝 DDL。结果读回来时,TEXT与BLOB是同一个类型标签,分它们的是字符集 (见 §9)。 - PostgreSQL:
libpq全部以文本形态收发,参数也按文本传 (PQexecParams的paramFormats全 0),类型让服务端从上下文推断。BYTEA走文本形态时必须十六进制编码成\x...(open()里设了bytea_output = 'hex'),读回来再解码 —— 两头都不做的话,blob("a\0b")会以原始文本发出去,服务端报invalid input syntax for type bytea, 而读回来的是一个 10 字符长的假 blob,不报任何错。
#include <db/uvcpp_db.h>
uvcpp::uvcpp_db_status transfer(uvcpp::uvcpp_db_client& db) {
uvcpp::uvcpp_db_status st = db.begin();
if (uvcpp::uvcpp_db_ok(st)) {
st = db.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", {100, 1});
}
if (uvcpp::uvcpp_db_ok(st)) {
st = db.execute("UPDATE accounts SET balance = balance + ? WHERE id = 2", {100});
}
if (!uvcpp::uvcpp_db_ok(st)) {
db.rollback(); // 出错就回滚
return st;
}
return db.commit();
}- 三家都是同一个形状,不嵌套:重复
begin()由后端报错(不在这里拦, 因为 MySQL 会静默提交前一个 —— 拦了反而给出一种它做不到的保证)。 reconnect()在事务里调 = 放弃那个事务。- MySQL 的死锁/锁等待超时在事务里不重试。 InnoDB 报
ER_LOCK_DEADLOCK(1213)时会回滚整个事务 —— 那时重放这一条语句只会写进一个"已经不在事务 里"的连接,调用方以为还在交易中,实际每条语句都在自动提交。所以那种情况下 驱动把错误交回调用方,由它决定要不要重做整笔交易。事务外(单条语句)才 重试,退避 50/100/200 ms,共 3 次。这条规矩写在驱动里,也有注释说明为什么。
| 本模块 | MySQL | SQLite | PostgreSQL |
|---|---|---|---|
NIL |
NULL | NULL | NULL |
INT64 / UINT64 |
BIGINT 及以下整型 | INTEGER | int2/int4/int8 |
DOUBLE |
FLOAT / DOUBLE / DECIMAL | REAL | float4 / float8 / numeric |
BOOL |
TINYINT(1) / BOOL | INTEGER(0/1) | bool |
TEXT |
CHAR / VARCHAR / TEXT | TEXT | text / varchar / char |
BLOB |
BLOB | BLOB | bytea |
DATE / TIME / DATETIME |
DATE / TIME / DATETIME | —(文本) | date / time / timestamp |
DECIMAL / NUMERIC 读进来是 double —— 金额在那一步就丢精度了
(0.1 + 0.2 那件事)。本模块没有定点类型,所以:
要精确的钱就用
TEXT存字符串(或存最小货币单位的整数)。 三个后端都支持这条路,而且 SQLite 上它本来就是唯一的路。
这条不是"设计缺陷说明",是取值建议:反过来做(把 DECIMAL 读成 double
再算账)在测试里也不会红,只会在对账时差一分钱。
这一节是"共用套件为什么值这个钱"的账本。四条都是共用套件在真服务端上抓出来 的,不是设计时想到的;抓出来之前,每一条都是"看着没事"。
-
TEXT列读回来是个 blob。 MySQL 里TEXT与BLOB在协议上是同一个 类型标签(服务端两个都报MYSQL_TYPE_BLOB= 252),唯一的区别是charsetnr:TEXT报列自己的排序规则(utf8mb4 上是 255),BLOB报 63。 驱动早先按类型标签一刀切,于是s_val TEXT读出来是 blob、to_json()打出[blob 5 bytes],而不报任何错。本机实测:s_val TEXT -> type=252 charsetnr=255 b_val BLOB -> type=252 charsetnr=63 v_val VARBINARY(32) -> type=253 charsetnr=63顺带一条反面教训:判据也不能写成"
charsetnr == 63就是字节" ——DECIMAL/INT这些数值列的charsetnr同样是 63,那样一刀切的话 金额列会以 blob 抛出来,而to_double()得到 0。两层判据缺一不可。 -
多给参数被静默忽略。
mysql_stmt_bind_param按服务端报的param_count读绑定数组,多给的元素直接不看 ——WHERE id = ?配两个 参数照常执行、照常返回OK。套件里那条"多给参数 →BIND_FAILED"当初拿到 的是ok,于是补上mysql_stmt_param_count的显式校验。 -
少给参数被当成语法错。
WHERE id = ?配 0 个参数时,驱动会走"空参数 = 裸通道"那条路,服务端把?当语法错报回来(1064)—— 于是"参数个数不对" 表现成"SQL 写错了"。现在的判据是:SQL 里出现?就走预处理通道, 让param_count做权威判断(字面量里的?也走那条路,代价只是这一次用 二进制协议,结果一样)。 -
PREPARE_FAILED在裸通道上丢过。 上面两条修完之后还剩一条:语法错与 表不存在在 MySQL/PG 的裸通道上都报EXEC_FAILED,而 SQLite 报PREPARE_FAILED。裸通道是服务端一步做完"解析 + 执行",两种错从同一个出口 回来,所以驱动得按错误号分开:MySQL 看ER_PARSE_ERROR(1064) /ER_NO_SUCH_TABLE(1146) /ER_BAD_FIELD_ERROR(1054) 等,PostgreSQL 看SQLSTATE的 42 类(syntax_error_or_access_rule_violation)—— 两个例外是42P02($1没给参数)与08P01(Bind 参数个数对不上), 它们归BIND_FAILED,不是PREPARE_FAILED。
还有一条不是"抓到的"、但值得记:PostgreSQL 的 BYTEA 两头都要编解码
(见 §6),早先是"写进去报错、
读回来不报错但内容错"——后者更坏。
再一条反向的教训,关于"套件绿了"能推出什么:SQLite 收下 $1(当命名
参数),所以一段把 ? 写死、又在 SQLite 上跑绿了的 SQL,拿到 PostgreSQL 上是
PREPARE_FAILED。共用套件本身用 dialect::ph() 取占位符,永远不会写出错
的那种写法 —— 也就是说这一条不是"套件抓到的",是套件覆盖不到的:想过这一关
得专门写一条"拿错写法必须失败"的用例(db_suite.h 的 test_placeholder_dialect,
三家各跑一遍)。文档里那句"驱动会自己换算"活了很久没被发现,正是这个盲区。
四条用例在 tests/functional/:db_sqlite_func.cpp(不需要服务端,永远真
跑)、db_mysql_func.cpp、db_pgsql_func.cpp、db_odbc_func.cpp。共用套件在
db_suite.h。
ODBC 那一条要一个真的数据源(服务器、或一个本地文件库的驱动),所以它排在第
四:给不出 URL 时退 3(未判定),而不是拿一份默认方言硬跑。它的方言按 Driver=
挑 —— ODBC 的方言属于数据源、不属于这个后端,认不出就退 3:
cmake -S . -B build-db3 -DUVCPP_BUILD_TESTS=ON -DUVCPP_ENABLE_DB=ON
cmake --build build-db3 -j"$(nproc)"
# SQLite:什么都不用给
./build-db3/tests/functional/test_db_sqlite_func
# MySQL / PostgreSQL:给一个**一次性**的库的连接串
export UVCPP_DB_TEST_MYSQL_URL='mysql://root@127.0.0.1:3306/uvcpp_test'
export UVCPP_DB_TEST_PGSQL_URL='postgresql://uvcpp@127.0.0.1:5432/uvcpp_test'
./build-db3/tests/functional/test_db_mysql_func
./build-db3/tests/functional/test_db_pgsql_func
# ODBC:SQL Server(本后端存在的理由)
export UVCPP_DB_TEST_ODBC_URL='odbc://sa:pw@127.0.0.1:1433/uvcpp_test?Driver={ODBC Driver 18 for SQL Server}'
./build-db3/tests/functional/test_db_odbc_func
# ODBC:也可以走 sqliteodbc —— 不用服务端,一条命令就能把这条用例跑起来
# (Ubuntu:apt-get install unixodbc libsqliteodbc,驱动落在
# /usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so)
export UVCPP_DB_TEST_ODBC_URL='odbc:?Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=/tmp/uvcpp_odbc.db'
./build-db3/tests/functional/test_db_odbc_func套件里有 DROP TABLE / CREATE TABLE,URL 必须指向一个一次性的库。
用例自己的表名都是 uvcpp_db_* 前缀,但别拿生产库试。
| 环境变量 | 作用 |
|---|---|
UVCPP_DB_TEST_MYSQL_URL |
MySQL 用例的连接串。没有它 → 退出码 3(未判定) |
UVCPP_DB_TEST_PGSQL_URL |
PostgreSQL 用例的连接串,同上 |
UVCPP_DB_TEST_ODBC_URL |
ODBC 用例的连接串,同上(认不出 Driver= 是哪一族也退 3) |
UVCPP_DB_TEST_REQUIRE |
设了它,缺 URL 就是失败(退出码 1),不是跳过 |
UVCPP_DB_ODBC_LIBRARY |
指到驱动管理器的完整路径。指到一个不存在的路径就等于"这台机器没装驱动管理器"—— test_db_odbc_nodrv_func 就是这么跑的(ctest 的 ENVIRONMENT 属性替它设好,见 §1 那张候选名表) |
test_db_odbc_nodrv_func 是另一条,与上面三条形状不同:它不要数据源,要的是
"这台机器上没有驱动管理器",验的是"没装 unixODBC 时 open("odbc://…") 回
NO_DRIVER + 一句人话、而同一进程里 SQLite 照常可用"。所以它不需要任何环境变量
就跟着 ctest 跑(前提由 tests/functional/CMakeLists.txt 里的 ENVIRONMENT
设好),前提不成立时是红、不是跳过 —— 它不该有"这台机器没配好"这种借口。
手动跑:
UVCPP_DB_ODBC_LIBRARY=/nonexistent/libodbc.so.2 ./build-db3/tests/functional/test_db_odbc_nodrv_func它必须是独立可执行文件(不能并进 test_db_odbc_func):ODBC 的符号加载走
magic static,同一个进程里成功加载过一次之后,再设这个环境变量也不再生效。
ODBC 这一条不在 CI 里:那几台 runner 上没有任何 ODBC 数据源,装了驱动管理器也
只够跑"驱动名不存在时报 OPEN_FAILED"(那是它自己的一条断言),跑不了共用套件。
所以它的读数只能在本地产出 —— 见 §11 里哪些还真没有。
退出码 3 是照本仓 gate 脚本的规矩来的:「未判定」不是「通过」。ctest 那边用
SKIP_RETURN_CODE 3 把它报成 ***Skipped —— 与 Passed 在日志里是两个词。
CI 上服务容器都起了还缺 URL,说明是配置写错了,所以 CI 里额外设
UVCPP_DB_TEST_REQUIRE=1,那时跳过就变成红。
不设 UVCPP_DB_TEST_REQUIRE 时,本地没起 MySQL 也能得到一句诚实的
***Skipped,而不是一条假绿。
CI 上这四条腿摆在哪:Ubuntu 与 macOS 各一条 db 矩阵格、MSVC 一条、MinGW 那条腿
(两次 configure 都开),四者都只跑 SQLite —— 那三个 runner 上没有数据库服务端,而
Windows 上走的是 UVCPP_DB_SQLITE_FROM_SOURCE(MSYS2 若装了
mingw-w64-x86_64-sqlite3,find_library 会挑中 libsqlite3.dll.a,dll 就多一个
sqlite3.dll 依赖)。远程后端另有一个 job:Ubuntu 上单独的 db-servers,用两个
services: 容器跑真 MySQL 8.0 与 PostgreSQL 16 —— services: 是 job 级的,塞进矩阵
会把两个容器拖进全部十一格。那三格把两个远程后端显式关掉(不关也只是两条 ***Skipped,
买不到覆盖),db-servers 反过来把 SQLite 显式关掉:每一边都有一条反向断言,
少了它,"开关被改坏了"是一条全绿的路。逐格的四道门禁与两个 service 容器的配置见
ci-guide.md §5。
- ODBC 到 SQL Server 本身 —— 本仓没量,但下游的 CI 已经跑到"真库上执行查询"
这一步了。 先说本仓这一半:这个后端进了共用套件(
db_odbc_func.cpp,UVCPP_DB_TEST_ODBC_URL),但本仓跑过的那条路是 ODBC 到 SQLite(sqliteodbc, Ubuntu,驱动管理器 unixODBC)。SQL Server 那一族的方言(odbc_mssql_dialect)在 本仓的测试集里一次都没执行过 —— 这里加不进去:跑它要一台真 SQL Server,而 CI 里没有。 再说另一半,因为它比"完全没验过"要强,不写出来会让人误判成熟度:下游那个 私有项目(这个后端存在的理由就是它)的 CI 里有一段"探得到就跑、探不到就跳过" 的步骤,在 Windows runner 上找到 SQL Server LocalDB 与一个能连它的 ODBC 驱动 时,会让它打出来的包真的建一条连接、真的执行查询、真的把结果读回来 (SELECT DB_NAME(), CONVERT(varchar(30), GETDATE(), 120), @@VERSION,走的就是 本库的 ODBC 后端),并且另外用一份夹具库把两条业务口径(会员统计、引车员检测 统计)的数字逐个对过 —— 那是odbc_mssql_dialect第一次落到真 SQL Server 上。 但那是另一个仓库的 CI:本仓既不能复现它、也不该把"某台机器上恰好有 LocalDB"写成自己的一条判据。所以两句话一起成立:方言在真库上跑通过(下游 验的),而本仓这一列读数仍然是空的。下面的表说的就是后者。 - ODBC 到 SQL Server 那些逐条读数(
IDENTITY/DATETIME2/BIT落成 什么类型、$1占位符在 mssql 方言下写成什么、DSN 形式的连接串)。这些是上面 那条"本仓没量"的具体条目:真去跑真库时,红的第一条先怀疑那份方言的写法,而不是 后端本身。在这一列被谁真量出来之前,别把"ODBC 到 SQL Server"当成与另外三家 同等"验证过"的后端。 - ODBC 的
last_insert_id()(除了 SQL Server 那一族)。 它发的就是SCOPE_IDENTITY(),别的数据源不认,如实返回UNSUPPORTED(实测:sqliteodbc 上 报UNSUPPORTED并留下原因,不是编一个 0 出来)。别的数据源要报自增值,得先有 一条按数据源分派的写法。 - 协程(
co_await/ C++20 那套)。 异步门面给的是回调 + future两种形状 (见 §3),本库是 C++11 的,不引入协程。 - 门面的背压。 不限制在途条数 —— 一个连接本来就是串行的,排队等锁不会更快。
池子那边也一样:它在途条数的上界就是
max(借不出来就等),没有额外的闸。 - 池子的后台回收。 不起线程、不挂定时器(见 §3.1):不调
acquire()/release()/close_idle(),池子就不会缩。这是有意的取舍 —— 池子要保持事件循环无关,多一条线程去扫空闲表不值得。 - 独立的数据库线程池。 门面复用的是 libuv 那条默认池(4 条线程,与
uv_fs_*/ DNS 共用)。慢查询会把文件 IO 挤掉,要缓解只能调uvcpp_set_threadpool_size()。 - 真异步只到 PostgreSQL 这条腿。 MySQL / SQLite / ODBC 的客户端库没有可用的
异步协议,异步池查询在那三家上仍走线程池(回调形状、线程、返回码都不变,只是
又占线程了);
insert也不在真异步那条路上(两次往返,完成回调只交一张结果表)。 - ORM 那套东西。 没有模型、没有迁移、没有关系映射、没有查询构造器 ——
它是"能安全地执行 SQL 并拿到表",不是 ActiveRecord。命名上叫
db而不是orm,就是因为后面那半句不打算假装。 - 准备语句缓存 / 语句复用。 每次
query/execute都是新的mysql_stmt_prepare/PQexecParams。省掉它换来的是"状态不跨语句泄漏", 这个取舍是有意的;要批量插入时请用事务包住循环。 DECIMAL的定点类型(见 §8)。- SQLite 上的日期时间语义。 它是文本,
date()/datetime()这些函数能用, 但本模块不替你做时区换算 —— 存进去什么字符串,取出来就是什么字符串。 UINT64超过INT64_MAX的往返:MySQL 有 unsigned 绑定、SQLite 会拒绝、 PostgreSQL 的int8是有符号的。跨后端要存那么大的数请用TEXT。