alitrack

duckdb-luajit: 一条 SQL 脱敏,还能自证脱够了

金融行业有一条硬规定:敏感级数据未经脱敏,不得进测试环境。

规定本身没问题。问题是执行。多数团队的「脱敏」是三段手工 SQL:身份证截中间几位、手机号遮掉中间、姓名换成「张*」。做完谁也不知道脱干净没有,出事才发现某个字段漏了。

DuckDB 在这件事上是空白:官方 roadmap 里既没有 masking 也没有行级权限,想在管道里做脱敏,只能自己写 SQL。我给 duckdb-luajit 的 privacy 库补上了这一层——现在它是五个 op,从证件规则一直管到「脱到什么程度算够」。

privacy 库的完整链路:脱敏、日期平移、ε 门禁,最后交给 k/l/t 效果评估,不达标就拒批

privacy 库的完整链路:脱敏、日期平移、ε 门禁,最后交给 k/l/t 效果评估,不达标就拒批

● ● ●

中文证件不是「截中间几位」那么简单

先看结果。同一张表,一条 SQL 出去就是合规副本:

SELECT
  luajit_s('privacy', {'op':'mask_cn','kind':'idcard','v':id_no})   AS id_no_masked,
  luajit_s('privacy', {'op':'mask_cn','v':mobile})                 AS mobile_masked,
  luajit_s('privacy', {'op':'mask_cn','kind':'name','v':name})     AS name_masked
FROM patients;

实际输出(这几个用例都在回归套件里):

110101199003071234  → 110101********1234
13800138000         → 138****8000
6228480402564890018 → 622848*********0018
欧阳锋               → 欧阳*

几处细节是踩过才知道的。复姓要认:欧阳锋 该出 欧阳* 而不是 欧,所以规则库里带了一张复姓词表。格式要保持:身份证还是 18 位、手机号还是 11 位,否则下游的类型校验和长度断言全崩。银行卡保留 BIN 前 6 位**,对账和风控还认得出是哪家卡。

还有一条是给工程用的:hash 模式对同一输入给同一输出,所以脱敏后的外键还能连接。生产库和测试库要做行数比对、要复现某个用户的订单链路,这个性质是刚需——不然脱敏完就没法对账了。

识别不出来怎么办?fail-closed。长度不对、格式不认,一律退成通用星号,绝不原样透出。宁可多遮,不可漏遮。

● ● ●

日期也得脱,而且住院时长不能变

临床数据里日期是最要命的准标识符。生日加邮编能定位到个人,入院日期加科室也能。

dateshift 做的是 subject 级平移:偏移量只由受试者 ID 决定,和日期本身无关。

SELECT luajit_s('privacy', {'op':'dateshift','v':admittime,'key':subject_id,'days':180})
FROM admissions;
2150-03-04  key=10001  ±180  →  2150-06-27 (实际偏移 115 天)

关键在于同一个人的偏移恒定。住院 7 天,平移之后还是 7 天;两次入院相隔 90 天,平移之后还是 90 天。绝对日期不可反推,但时间结构逐位保持。做时序分析、做再入院研究,数据依然能用。

日期算术用的是 Howard Hinnant 的 civil-days 算法,纯整数、闰年精确。这块我做了 2,055 例跨引擎交叉验证,覆盖 1900 到 2200 全区间、含全部闰日,跟 Python 的 datetime 逐例比对,零不匹配——日期算法出错的代价太高,自己实现自己断言没有意义。

● ● ●

脱到什么程度算是脱够了

这是最难交代的一环,也是我觉得这个库跟生态里其他方案最不一样的地方。

注册表上的隐私类扩展,大多做的是「自动加噪」或「造假数据」——工具替你决定了脱敏强度,但你拿不出一个数字证明它够。合规检查要的偏偏是那个数字。

所以 privacy 库里有一个 kanon_report,按 GB/T 42460 的思路把效果评估做进 SQL:

SELECT luajit_s('privacy', {'op':'kanon_report',
  'age':[25,26,27,28,60,61,62,63],
  'zip':['310','310','310','310','110','110','110','110'],
  'disease':['A','B','A','C','A','B','B','C'],
  'k':2, 'l':2, 't':0.2, 'sensitive_field':'disease'});

它按准标识符分组,每组算三个数:

  • k
    :每个等价类至少几条记录。分组后返回每组的泛化区间和去重后的敏感值个数
  • l
    :敏感属性的多样性。我不只查不同取值个数,还查信息熵——取值数够但极度倾斜的组,一样能被推出来
  • t
    :这一组的敏感分布,和高危全局分布差多远。分类属性用总变差,有序数值用归一化的一维 EMD

输出直接给结论:

{"verdict":"fail","violations":["t"],"k_ok":true,"l_ok":true,"t_ok":false,
 "min_class_size":2,"min_distinct_l":2,"max_t":0.375,"n":8,"groups":4,
 "suppression_rate":0,"generalization_loss":0.1754}

同一份数据,只把阈值 t 从 0.2 放宽到 0.4(这组数据里最差的那个等价类 t 是 0.375,刚好过关),输出就变成 "verdict":"pass"、"violations":[]。阈值是显式参数,摆在那里让人调——这是「可审计」和「不可控」的区别。

顺带说一个我踩到的坑,值得写进任何讲 t-closeness 的文档:度量口径会改变结论。举个具体例子,一组取值为 1、1、2、3 的敏感字段,用总变差算出来的 t 是 0.5,用一维 EMD 算是 0.375——在 t=0.4 这个阈值上,一个判死一个放行。所以报告里必须标明用了哪个度量,含糊过去等于报告本身在骗人。

● ● ●

ε 预算:差分隐私最容易被忽略的一笔账

dp_count / dp_sum / dp_mean 这些 Laplace 机制早就在库里了。机制本身够用,容易出事的是记账。

差分隐私的预算是花掉的。一条 ε=0.1 的查询跑十次,隐私损失不是 0.1,而是 1.0。多数人写 DP 代码时只关心「这次加多少噪」,没人记账,最后预算早就超了。

现在有三个 op 专门管这笔账:

-- 单步门禁:手里有一本账,这次申请 0.3 批不批?
SELECT luajit_s('privacy', {'op':'dp_budget','budget':1.0,'request':0.3,
  'ledger':[{'epsilon':0.25},{'epsilon':0.25}]});
-- → {"allow":true,"spent_after":0.8,"remaining_after":0.2,"reason":"ok"}

-- 超了直接拒批
-- → {"allow":false,"reason":"budget_exceeded"}

ledger 是历史消耗数组。库是无状态的,账本由调用方拿着——这样它才是个纯函数,可以塞进任何管道。

另外两个 op 解决「预算怎么切」和「怎么算更省」:

-- 总预算 1.0,要跑 100 个查询,每个分多少?
SELECT luajit_s('privacy', {'op':'dp_alloc','budget':1.0,'n':100,'delta':1e-5});
-- per_query 0.01(朴素均分) / recommended 0.019998(强组合界下)

同样的总预算,改用 Dwork-Roth 强组合界,单查询的 ε 从 0.01 提到约 0.02——翻了一倍,100 条查询的隐私总损失从 1.0 降到 0.49。

这里有个反直觉的结论,我在跑回归时才确认:强组合在小 n 时反而更松。10 条 ε=0.1 的查询,朴素相加是 1.0,强组合界算出来 1.62。所以 dp_alloc 默认给保守的均分口径,同时把两个界都报出来,标出哪个更紧——不替用户默认选激进档。这种取舍不该藏在默认值里。

● ● ●

装上只要一条 SQL

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

就这一句。装完 luajit_s('privacy', {...}) 直接可用,不需要编译、不需要节点、不需要多一个服务。

全靠纯 Lua 实现,零新依赖——日期算法、FNV 指纹、Laplace 噪声、熵和 EMD,都是几十行的事。这也是 luajit 扩展的定位:DuckDB 的管道里缺一块能力时,用一条 SQL 装上,而不是为它起一个服务。

● ● ●

边界,说在前面

不吹的部分:

  • l-diversity 只做了 distinct-l 和 entropy-l
    ,recursive-(c,l) 没有做
  • t-closeness 用的是总变差和一维 EMD
    ,不是完整的 EMD
  • 泛化损失是简化版 ILA
    :没有分类层级树时,用组内离散度近似,不是 Mondrian 意义上的信息损失最小化
  • k-匿名分组是简化 Mondrian
    ,等权范围分裂,不是最优解

这些边界写在源码注释和 README 里,不藏在文档角落。合规场景里,「不知道它没做什么」比「它做得不够好」危险得多。

验证也是按这个标准来的:Lua 层断言全过,跨引擎交叉验证用 Python 从零复现每一个公式再逐例比对,DuckDB 端跑端到端断言。真正让我踏实的其实是那两处我自己算错的期望值:公式类的东西手算必错,基准必须由独立实现产出。

● ● ●

一句实在话

数据脱敏真正难的地方,是回答「这样够了吗」——把身份证中间几位换成星号,只是其中最小的一步。

规则库解决「脱得对」,效果评估解决「脱得够」,预算台账解决「加噪加得心里有数」。这三件事凑齐了,一条从生产库到测试库的合规副本才真正说得清。

DuckDB 官方没做这块,生态里做这块的方案又普遍只做前一半。我把它做成了 SQL 原语,希望它能省掉一些团队的三段手工 SQL,和那句说不出口的「应该脱干净了吧」。

如果你也在用 DuckDB 处理敏感数据,你会先要哪一块:中文证件规则、临床日期平移,还是那个能出报告的 k-匿名评估?评论区说一声,我按需求排下一版。