alitrack

在 DuckDB 里写 Python,WASM 让它成真了

上篇讲了用 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~30msJavaScript 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 还有几步:

  1. 01MicroPython WASM 编译为 duckdb:extension 组件(工作量约 1 天)
  2. 02类型系统完善(现在只支持 Int64/Float64/Text/Boolean,Python 的 list/dict 需要额外的序列化)
  3. 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