alitrack

只写列名,让 DuckDB 自己学会造假数据

给 DuckDB 灌测试数据这事,我最近翻车过一次,翻得很彻底:屏幕上 18 行数据,每一行都是 18|New York|James Smith。

一模一样。18 遍。

我盯着看了大概十秒钟,才反应过来——不是数据错了,是随机数生成器坏了。整列的"随机",其实是一个常数。

这次翻车之后,我在 DuckDB 的 Lua 扩展里写了个 fake 库:列名本身就是规格——name 列给你人名,email 列给你邮箱,lat 列给你经纬度,你只管写列名,造数的活儿它自己判断。 55 种占位符、seed 可复现、单文件零依赖,SELECT 一条 SQL 装完就能用。

● ● ●

为什么是 fake 数据

新表一建好,第一个需求往往不是查询,而是"给我点数据"。没有数据,连 SELECT 长什么样都不知道。

手写 VALUES 十行就烦了;拿真实数据洗一洗,又要先过一遍脱敏,还得担心哪一列的格式和线上对不上。造数工具就是为此存在的,但常见的两条路都有毛病:

一是装个 Rust/Go 编译出来的 fakeit 扩展——功能确实全,120 多个函数,但你的每一列都得手写一遍 fakeit_name_full()、fakeit_contact_email()。30 列的表,30 次函数调用。

二是 Python 侧的 Faker 包——好用,但你的 DuckDB 进程得挨着一整个 Python 环境。

我想要的形态更朴素:列名即规格。我写表结构的时候,本来就在给列起名字,起名的时候脑子里其实已经知道这列该填什么了。那就让库替我把这件事做完。

● ● ●

fake 库长在 duckdb-luajit-libs 仓库里,形态是一个自包含的 Lua 文件,零 FFI、零编译、零依赖。装它是一条 SQL:

SELECT * FROM luajit_module(mode := 'install', sql_name := 'fake');

装完有两个入口:标量函数 luajit_s('fake', {op:'gen', kind:'person.last'}) 出单值;表函数 luajit_table('fake', list := '<spec>') 出整表。后者才是主力:

SELECT * FROM luajit_table('fake',
  list := '{"cols":{"name":"person.full","email":"","city":"address.city_us","state":"address.state","zip":"address.zip","age":"person.age","dob":"person.dob"},
            "rows":100,"seed":9}');

看那个 cols——有的列写死了 kind(name 指定 person.full),有的列留空(email、zip)。55 种占位符覆盖了常见的造假场景:人名(中英)、邮箱、电话、公司、地址(含经纬度、邮编)、IP、银行卡号、日期、金额、颜色、uuid,还有中文句子。带参数的写法也支持:int:18,65 是区间整数,date.iso:2024-01-01,2024-12-31 是限定年份的日期,模板语法 {person.full} <{contact.email}> 能把占位符嵌进任意字符串。

但主角是那个"留空"。

● ● ●

列名,就是规格

规则很短:cols 里的值留空(或者干脆写列名自己),库就按列名推断该用哪种生成器。 推断走三层:先查精确表——name、email、phone、lat、lon、zip、created_at、amount、cvv 这些最常见的几十个名字,一个个查表命中;查不到再做有序模糊匹配,_at 结尾的列当时间戳、带 email 的列当邮箱,按从具体到宽泛的顺序匹配;都落空了,兜底给一个通用词。你显式写了 kind 的列永远优先,自动推断只是兜底。

跑出来的东西长这样(seed:9,可复现):

Anthony Young | [email protected] | Philadelphia | Pennsylvania | 19412 | 65 | 1961-10-04

列名推断只是第一层。真正让数据"看起来像数据"的,是下一层。

● ● ●

假数据最容易"一眼假"的地方,是列与列之间

单独看每一列,假数据都挺像样的:名字是名字,邮箱是邮箱,邮编是五位数。可一旦把几列放在一起看,假就露出来了——邮箱的本地部分和姓名对不上,Chicago 的行配了 Texas 的州名,30 岁的人生日写成了 1970 年。

这是因为大多数造数工具(包括 fakeit 的 120 个函数)都是每列独立随机:name 抽一次,email 再抽一次,zip 又抽一次。每一列各自为政,列与列之间没有约定。

fake 库的做法是把"一行"当成一个人:生成一行的时候,先在心里立一个人物档案,姓名、性别、出生年份先定下来;之后这一行里所有"关于这个人"的列,都从这份档案派生,而不是各自再掷一次骰子。

效果是:

  • 邮箱的本地部分就是人名——Anthony Young 配 [email protected],不是随机撞的;
  • city
     定了,state 和 zip 跟着走——Philadelphia 必然配 Pennsylvania、19 开头的邮编,内置了一张城市→州/邮编前缀的映射表;
  • age
     和 dob 永远吻合——65 岁,生日就在 1961 年,不会差十年;
  • 金额、工资改走对数正态分布——一串订单里大部分是小额、偶尔冒出几个大额,而不是均匀撒在 0 到 5000 之间。

用 seed:9 复跑那行:Anthony Young | [email protected] | Philadelphia | Pennsylvania | 19412 | 65 | 1961-10-04。人名、邮箱、城市、州、邮编前缀、年龄、生日,七个字段互相咬合。这种一致性,是逐列独立随机永远做不出来的。

想要旧的逐列独立行为?spec 里加一个 "entity": false 就关掉了。

● ● ●

实测:5.7 微秒一行

17 项断言全过,duckdb -unsigned 跑完 exit 0。速度实测:10000 行 × 3 列,0.057 秒,一行 5.7 微秒;5 万行 uuid 单列,0.253 秒。造测试数据这个量级,足够了。

翻车那一下,也顺便交代清楚。我第一版手搓了个 mulberry32,结果在 LuaJIT 的位运算下序列退化——每行都是 18|New York|James Smith,int:18,65 恒等于 18,seed 换个值输出居然都一样。排查半天,换成 Park-Miller LCG(double 精确、无位运算)才活过来。第二个坑更微妙:json_extract 取出来的值自带引号,前锚的正则匹配全挂,换成 json_extract_string 才通。一个在数据层,一个在测试层,都栽在"看起来对,其实不对"的地方。

fake 库:列名 → 推断 → 55 种 kind + 实体关联的分发结构

fake 库:列名 → 推断 → 55 种 kind + 实体关联的分发结构

● ● ●

比 fakeit 快吗?不。但够用

不装腔,直接把对比摆出来。社区里有个 Rust 写的 fakeit 扩展,120 多个离散函数,纯 Rust 实现,单值速度大概是我的 3 到 10 倍,功能数量也几乎是两倍。

那我为什么还在维护一个纯 Lua 版?

省事和速度是两件事。 Lua 版的代价是:一个文件,push 上去就能装,没有 cdylib、没有跨平台编译、没有按平台发 release 的二进制。Rust 版每加一个功能,都是编译链路 + 多平台分发的成本。造测试数据是灌库前的准备动作,不是性能热点——我的 5.7 微秒一行,在"够不够用"和"够不够快"的分界线上,站在够用那一侧。

真到了千万行级别,该迁 Rust 就迁,列名推断这套逻辑是语言无关的,直接搬。现在不着急。

● ● ●

边界,说清楚

词表是均匀抽样的,不是真实人口分布——lat 列不会真按经纬度密度加权,中文名也不会按人口频率排布。实体关联是行内的:同一行里这个人自洽,但不会跨表维护"同一个客户在 orders 表和 users 表里是同一个 ID"那种跨行、跨表关联——那需要外键模型。行级生成,不支持嵌套的 object/array YAML 模型。那是 npm-fakeit 那种模型生成器的领地。

我的定位就是最薄的一层:表结构变了,写列名,改行数,换 seed,跑一条 SQL,灌完。 造测试数据,本来就该是这个重量级。


再回头看那个屏幕:18 行,18 个 18|New York|James Smith。当时我以为是随机数写坏了——现在看,随机数确实写坏了,只是坏的方式比我想象的诚实。Park-Miller 填平了那个坑,json_extract_string 救了那个测试,而"列名即规格"这条规则,把 120 个函数调用的活儿,压缩成了给列起一个合适的名字。

下一次表结构改了,我不打算再对着 30 列手写 30 遍函数了。写列名,定行数,换 seed,跑一条 SQL——灌完。


参考来源

  1. 01
    fake 库源码与测试:github.com/alitrack/duckdb-luajit-libs(libs/fake/fake.lua、test_fake.sql)
  2. 02
    对比对象:DuckDB 社区扩展 fakeit(github.com/tobilg/duckdb-fakeit,Rust 实现,120+ 函数)
  3. 03
    DuckDB 官方:Create Synthetic Data 指南(duckdb.org/docs/current/guides/snippets/create_synthetic_data)