用 DuckDB mysql 扩展直连线上 MySQL:ATTACH 写法、secret 管理与 pushdown 边界
项目信息:
- 扩展文档:
- 仓库:
- 性能相关 issue:
- DuckDB 多库支持背景:
适合的场景是:不想把线上数据整表同步过来,直接 ATTACH 上去做一次性分析,结果落成本地 Parquet。前提是接受它不走 MySQL 执行器,pushdown 有限。
安装与连接
INSTALL mysql; -- 一次性
LOAD mysql; -- 首次使用会自动加载
ATTACH 'host=localhost user=root port=0 database=mysql' AS mysqldb (TYPE mysql);
USE mysqldb;
连接串是 PostgreSQL 风格的 key=value 列表,不是 MySQL 的 DSN 格式(不要写成 mysql://user:pass@host/db)。未给出的项回落到默认值或环境变量。
对线上库一律加只读:
ATTACH 'host=... user=... database=...' AS mysqldb (TYPE mysql, READ_ONLY);
密码不要写进连接串
官方明确警告:连接出错时,完整连接串(含密码)会出现在错误信息里。改用 secret:
CREATE SECRET (
TYPE mysql,
HOST '127.0.0.1',
PORT 0,
DATABASE mysql,
USER 'mysql',
PASSWORD ''
);
ATTACH '' AS mysql_db (TYPE mysql);
CREATE PERSISTENT SECRET ... 可跨会话保留;临时 secret 默认只在当前会话有效。
连接串仍可覆盖 secret 里的单项,例如临时换库:
ATTACH 'database=my_other_db' AS mysql_db (TYPE mysql);
环境变量与 SSL
| 变量 | 对应项 |
|---|---|
MYSQL_DATABASE | 库名 |
MYSQL_HOST | 主机 |
MYSQL_PWD | 密码 |
MYSQL_TCP_PORT | TCP 端口 |
MYSQL_UNIX_PORT | Unix socket 端口 |
MYSQL_USER | 用户名 |
SSL 由 ssl_mode 控制,取值 disabled / required / verify_ca / verify_identity / preferred,默认 preferred。
读表、物化与导出
ATTACH 之后按普通 DuckDB 表查,数据在查询时直接从 MySQL 读,不在本地留副本:
SELECT * FROM mysqldb.orders WHERE created_at >= '2026-10-01';
同一张大表反复读不划算时,先物化到本地:
CREATE TABLE local_orders AS FROM mysqldb.orders;
导出:
COPY (SELECT ...) TO 'orders.parquet' (FORMAT parquet);
关键 settings
mysql_pool_size:每个 catalog 的最大连接数,默认按 CPU 数自动推导。mysql_pool_acquire_mode:force(忽略上限)/wait/try。mysql_enable_filter_pushdown:默认true。mysql_debug_show_queries:打印发给 MySQL 的每条 SQL。排查「到底推了什么」时打开。mysql_incomplete_dates_as_nulls:默认false,MySQL 的零日期会原样返回,不会自动变成 NULL。mysql_session_time_zone:新建连接的会话时区,默认空。mysql_tinyint1_as_boolean/mysql_bit1_as_boolean:默认true。mysql_time_as_time:默认false。mysql_pool_connection_idle_timeout_millis:默认60000。
schema 缓存
DuckDB 会缓存表结构。别人(另一条连接或其它工具)加了列之后,本地缓存会过期,直接清:
CALL mysql_clear_cache();
执行任意 SQL
SELECT * FROM mysql_query('mysqldb', 'SELECT * FROM cars LIMIT 3'); -- 只读查询
CALL mysql_execute('mysqldb', 'CREATE TABLE my_table (i INTEGER)'); -- 可含 DDL
mysql_query 支持 stream_results=TRUE,流式不可用时会直接报错,而不是悄悄全量载入内存。
性能边界
以下结论来自 duckdb-mysql issue #55,维护者 staticlibs 在 2025-12 的说明:
- 不对聚合做 pushdown,带聚合的查询会把整张表数据读进 DuckDB 再计算。
- 只有当一个 attached catalog 上只有一个 scan 时才流式返回结果集,多数情况下结果集先完整载入内存。
- 一个连接同一时刻只跑一条查询,没有并行。
- pushdown 支持有限,可能读到比实际需要更多的数据。
- 通过 scanner 读取比
mysqlCLI 慢约 2 倍,可以拿这个当基线。 - 复杂类型(尤其 DECIMAL)在大结果集上有明显额外开销,必要时在
mysql_query里 CAST 成基本类型。
对应的用法:聚合留在 DuckDB 侧,用 ATTACH 外表扫描;过滤尽量用 mysql_query 子查询把条件下压到 MySQL;大结果集可以用多个连接(同一个库 ATTACH 多次)拿到流式;复杂类型先 CAST。
可复现的工作流
过滤推到 MySQL、聚合留 DuckDB、结果落 Parquet。DuckDB 支持 $$ 包裹字符串字面量,写内嵌单引号的 SQL 时比转义省事。
-- 1. 过滤和列裁剪下压到 MySQL,只回传需要的行
CREATE OR REPLACE TABLE filtered AS
FROM mysql_query('mysqldb', $$
SELECT id, user_id, amount, created_at
FROM orders
WHERE created_at >= '2026-10-01'
AND status = 'paid'
$$);
-- 2. 聚合留在 DuckDB 侧做
CREATE OR REPLACE TABLE daily AS
SELECT created_at::DATE AS d,
count(*) AS n,
sum(amount) AS amt
FROM filtered
GROUP BY 1
ORDER BY 1;
-- 3. 结果落 Parquet
COPY (SELECT * FROM daily) TO 'daily.parquet' (FORMAT parquet);
金额类字段如果是 DECIMAL,在第 1 步里就 CAST 成基本类型,避免第 2 步在大结果集上付第 6 条那笔开销。想确认过滤有没有真的推下去,会话里 SET mysql_debug_show_queries=true; 看一遍发出的 SQL。
事务限制
DuckDB 单条语句默认各自一个事务;跨多个 attached 数据库的写在同一个事务里不支持,会直接报错。另外 MySQL 侧的 DDL 本身不是事务性的,mysql_execute 里执行 DDL 时不要指望能回滚。