上篇讲了用 ducklink 给 DuckDB 写 DICOM 文件格式的 reader。有读者问:能不能嵌个脚本语言进去? 比如一行 SQL 里调个 Python 函数。
能。而且不止 Python。
● ● ●
核心思路
ducklink 用 wasmtime 运行 WebAssembly 组件。wasmtime 是通用 WASM 运行时——任何编译到 WASM 的语言都可以跑。Pyodide 把 CPython 3.12 编译到了 WASM,MicroPython 有 WASM 构建,QuickJS 也有。
把它们包成 ducklink 组件,就是 DuckDB 里的脚本引擎。
DuckDB SQL
→ ducklink (wasmtime)
→ python-runner.wasm (WIT: duckdb:extension)
→ Pyodide / MicroPython / QuickJS
→ 用户脚本

架构
● ● ●
sql-eval:一个最小的验证
在上篇写完 DICOM reader 之后,我花了一小时做了个最小验证:sql-eval。它用 Rust 的 evalexpr crate 做表达式引擎,注册为 DuckDB 的标量 UDF。
用法:
FROM ducklink_load('sql_eval');
-- 列值作为变量 $1, $2, $3 传入
SELECT name, sql_eval('upper(trim($1))', name) FROM users;
SELECT a, b, sql_eval('$1 + $2 * $3', a, b, c) AS result FROM numbers;
内部做的事很简单:
// 对每一行:
fn call_scalar(args) -> Duckvalue {
let expr = args[0]; // 表达式字符串
let mut ctx = HashMapContext::new();
ctx.set_value("$1", args[1]); // 绑定列值
ctx.set_value("$2", args[2]);
eval(&expr, &ctx) // 执行,返回结果
}
整个核心逻辑不到 80 行,外加注册和类型桥接。
● ● ●
换成 Python 要多大的改动?
几乎不需要改架构。 把 evalexpr 换成 Pyodide,注册逻辑完全一样:
fn load() {
pyodide::initialize(); // 启动 Python 解释器
register_scalar("py_eval");
}
fn call_scalar(args) -> Duckvalue {
let code = args[0]; // Python 代码
pyodide::bind("$1", args[1]); // 绑定列值
pyodide::bind("$2", args[2]);
pyodide::eval(code) // 执行,返回结果
}
SQL 端就变成了:
SELECT PatientName,
py_eval("name.split('^')[0]", PatientName) AS LastName
FROM read_dicom('/scan.dcm');
SELECT region,
py_eval("sum(values)", price) AS total
FROM sales
GROUP BY region;
● ● ●
不同引擎的取舍
| 引擎 | 体积 | 启动 | 适合 |
|---|
| **evalexpr** | ~200KB | <1ms | 简单数学/字符串表达式 |
| **MicroPython** | ~300KB | ~50ms | 基础 Python 语法、字符串处理 |
| **QuickJS** | ~500KB | ~30ms | JavaScript UDF |
| **Pyodide** | ~8MB(最小) | 2-5s | 完整 CPython + numpy/pandas |
实际建议:如果只需要字符串处理、日期计算、简单逻辑,MicroPython 就够了,启动 50ms,体积 300KB。只有在需要 pandas/numpy 做数据分析时才值得上 Pyodide。
● ● ●
但真的有用吗?
这个问题我认真想过。SQL 已经很全能了,加 Python 是不是多此一举?
三个场景,SQL 确实不好做:
1. 正则和字符串
-- 提取文件名中的日期:scan_20240115_ct.dcm → 2024-01-15
-- 纯 SQL:SUBSTRING + 一堆 POSITION + CASE WHEN
-- Python:一行 import re; re.search(r'(\d{8})', s).group(1)
SELECT py_eval("import re; m = re.search(r'(\d{8})', $1); m.group(1) if m else ''", filename)
FROM files;
2. 业务规则
-- 按患者年龄分级:<18 儿童,18-60 成人,>60 老年
-- SQL 可以写 CASE WHEN,但规则来自数据库里存的 JSON 配置时就抓瞎了
SELECT py_eval(config_rule, age, gender, department)
FROM patients, rules
WHERE rules.id = 'age_group';
规则存在表里,Python 动态解析 JSON 规则 → 执行。这用纯 SQL 要写动态 SQL 生成器。
3. 一次性的数据清洗
-- 这列数据格式乱七八糟:"1,234.56" "1234.56" "1 234,56" "1234.56 EUR"
-- 写个 SQL 处理函数 → 太复杂。写个 Python 正则 → 简单。
SELECT py_eval(
"import re; s = $1.replace(' ', '').replace(',', '.').replace('EUR', '').strip(); float(re.sub(r'[^0-9.]', '', s))",
amount
) AS clean_amount
FROM messy_data;
这不是"Python 比 SQL 好",是"Python 是胶水语言,擅长处理不规则数据"。SQL 强在结构化批量计算,Python 强在灵活处理。各取所长。
● ● ●
现状
sql-eval 验证了架构可行。真正要跑 Pyodide 还有几步:
- 01MicroPython WASM 编译为
duckdb:extension 组件(工作量约 1 天) -
- 02类型系统完善(现在只支持 Int64/Float64/Text/Boolean,Python 的 list/dict 需要额外的序列化)
-
- 03性能测试(wasmtime 调用开销 + Python 解释执行,每行约多少微秒)
代码在 github.com/alitrack/sql-eval,cargo run 即可跑 standalone demo。
dicom-ducklink: github.com/alitrack/dicom-ducklink
sql-eval: github.com/alitrack/sql-eval
ducklink: github.com/tegmentum/ducklink-extension