Skip to content

数据脱敏方案 ​

加密与权限解决的是「让不该看的人拿不到数据」,但现实中还有一大类需求是:数据必须给出去,但敏感内容不能真给——测试环境需要像样的数据集,BI 报表要展示用户行为分析,客服系统要核对订单,新同事要在预发环境复现问题。这一类「数据可用、内容不可见」的需求,就是脱敏(data masking)的主场。

PostgreSQL 上可用的脱敏手段覆盖四个层次:视图脱敏(最朴素)、PostgreSQL Anonymizer 扩展(动态脱敏与匿名导出的专用工具)、应用层脱敏(PHP 序列化层)、RLS + 角色组合(策略驱动)。四个层次不是互斥选项,成熟的环境往往按场景组合使用:生产查询出口用动态脱敏,测试数据集用静态匿名导出,报表接口用应用层兜底。

本页先建立脱敏的概念坐标系(假名化 / 匿名化 / 泛化,可逆 / 不可逆,动态 / 静态),再逐个展开四种方案,重点讲解 Anonymizer 扩展的用法与一致性保持(同一手机号永远得到同一个假值),最后以「给测试环境生成脱敏数据集」的完整实战收尾。

前置知识

建议先阅读:列级加密(pgcrypto)

基础概念 ​

脱敏领域的术语先把边界划清,选型时才不会鸡同鸭讲:

术语英文 / 记法含义可逆性
假名化pseudonymization用假值替换真实值,但配合额外信息(密钥/映射表)可以还原可逆(受控)
匿名化anonymization彻底去除可识别性,还原在技术与成本上不可行不可逆
泛化generalization降低精度(精确年龄 → 年龄段、完整地址 → 城市)不可逆
动态脱敏dynamic masking查询时按角色实时替换返回值,底表不变取决于实现
静态脱敏static masking对数据副本(导出、还原库)整体加工后再使用不可逆

三大使用场景 ​

  • 开发 / 测试数据:测试库需要结构与数据分布都接近真实的数据集,但不能含真实 PII。典型形态是静态脱敏——从生产匿名导出一份,导入测试环境。
  • BI / 报表 / 数据分析:分析师与报表账号能连生产库做统计,但不应看到个体级敏感值。典型形态是动态脱敏(视图或扩展按角色替换)。
  • 最小化暴露:应用的不同模块、不同岗位对同一张表的需要不同(客服要手机号后四位,风控要全号),按主体最小化给出。典型形态是视图/RLS/应用层组合。

动态脱敏与静态脱敏 ​

两者的工程差异决定了选型:

  • 动态脱敏不改数据,改的是「谁能看到什么」:生产库上按角色实时替换,适合查询出口多、需要与权限联动的环境;代价是每条查询都要过一层替换逻辑,且策略错误会直接在生产暴露真数据。
  • 静态脱敏改的是数据副本:导出、还原后再加工,适合「给测试环境一份完整数据集」这类一次性交付;加工在非生产环境进行,即使出错也不影响生产,但要注意中间副本的管理(未脱敏副本落在哪里、谁清理)。

一个容易忽视的合规细节:假名化数据在多数法规(如 GDPR)的表述里仍属于个人数据——因为它可逆。向第三方提供假名化数据时的合同与评估要求,与匿名化数据不同,这直接影响方案选「可逆」还是「不可逆」。

语法与用法 ​

方案速览 ​

方案手段动态/静态适用
一CREATE VIEW + 权限动态报表账号、简单场景
二PostgreSQL Anonymizer(anon 扩展)两者皆可生产动态脱敏 + 匿名导出
三应用层(PHP 序列化层)动态API 出口、与业务权限天然结合
四RLS + 角色组合动态行级隔离 + 列级脱敏联动

方案一:视图脱敏 ​

sql
-- 底表只有特权角色可见
REVOKE ALL ON public.customers FROM PUBLIC;

-- 脱敏视图按岗位给权限
CREATE VIEW v_customers_masked AS
SELECT id,
       name,
       substring(phone FROM 1 FOR 3) || '****' || substring(phone FROM 8) AS phone,
       '***' AS id_card,
       created_at
FROM public.customers;

GRANT SELECT ON v_customers_masked TO bi_reader;
GRANT SELECT ON public.customers TO pii_reader;   -- 仅极少数角色
sql
-- bi_reader 会话
SELECT * FROM v_customers_masked LIMIT 2;
text
 id | name |    phone    | id_card |     created_at
----+------+-------------+---------+---------------------
  1 | 张三 | 138****5678 | ***     | 2026-09-30 10:12:00
  2 | 李四 | 139****2233 | ***     | 2026-09-30 10:15:41

方案二:PostgreSQL Anonymizer(anon 扩展) ​

bash
# 安装(PGDG 仓库或源码编译,随版本选择对应包)
sudo apt install postgresql-anonymizer
conf
# 动态脱敏必须预加载
shared_preload_libraries = 'anon'
sql
-- 重启后启用扩展并初始化动态脱敏
CREATE EXTENSION anon CASCADE;
SELECT anon.start_dynamic_masking();

-- 声明脱敏规则(SECURITY LABEL 挂在列上)
SECURITY LABEL FOR anon ON COLUMN customers.first_name IS 'MASKED WITH FUNCTION anon.fake_first_name()';
SECURITY LABEL FOR anon ON COLUMN customers.last_name  IS 'MASKED WITH FUNCTION anon.fake_last_name()';
SECURITY LABEL FOR anon ON COLUMN customers.email      IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN customers.birthday   IS 'MASKED WITH FUNCTION anon.random_date_between(''1970-01-01''::date, ''2005-12-31''::date)';
SECURITY LABEL FOR anon ON COLUMN customers.phone      IS 'MASKED WITH FUNCTION anon.partial(customers.phone, 3, 2, ''*'')';

-- 把需要被脱敏的角色打上 MASKED 标记
SECURITY LABEL FOR anon ON ROLE app_dev IS 'MASKED';
sql
-- app_dev 会话看到的是假数据
SELECT first_name, last_name, email, phone FROM customers LIMIT 1;
text
 first_name | last_name |      email       |     phone
------------+-----------+------------------+----------------
 Sarah      | Nguyen    | sarah.nguyen@…   | 138******78

同一账号(非 MASKED 角色)看到的是真实数据——动态脱敏的语义就是「按角色分流」。

方案三:应用层脱敏(PHP) ​

php
// 序列化出口统一脱敏:任何 API 响应都经过它
function mask_pii(array $row, array $rules): array
{
    foreach ($rules as $field => $rule) {
        if (!isset($row[$field])) {
            continue;
        }
        $row[$field] = match ($rule['type']) {
            'phone'  => preg_replace('/(\d{3})\d{4}(\d{4})/', '$1****$2', $row[$field]),
            'idcard' => str_repeat('*', strlen($row[$field]) - 4) . substr($row[$field], -4),
            'email'  => preg_replace('/^(.).*(@.*)$/', '$1***$2', $row[$field]),
            default  => $row[$field],
        };
    }
    return $row;
}

// 在 JSON 序列化器 / 模型 toArray 的出口处按当前用户权限决定是否应用
$data = mask_pii($customer->toArray(), $maskRulesFor($currentUser));

方案四:RLS + 角色组合 ​

sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 租户只能看到本租户订单(行级)
CREATE POLICY orders_tenant_isolation ON orders
    USING (tenant_id = current_setting('app.tenant_id')::int);

-- 列级敏感字段通过视图补充脱敏(列级 + 行级叠加)
CREATE VIEW v_orders_agent AS
SELECT id, tenant_id, status, amount,
       substring(customer_phone FROM 1 FOR 3) || '****' AS customer_phone
FROM orders;

GRANT SELECT ON v_orders_agent TO cs_agent;

RLS 的完整用法见行级安全应用场景一页;这里只强调组合思路:RLS 管「哪些行」,脱敏视图管「哪些列以什么形态」,两者叠加才是完整的最小化暴露。

详细说明 ​

先看泄漏形态:脱敏要堵住哪些口子 ​

方案选型前,先把「数据是怎么漏出去的」排一遍队。工程实践中反复出现的泄漏形态:

泄漏形态典型路径对应手段
测试库直连生产备份未脱敏的备份直接还原到测试环境静态脱敏(导出即脱敏)
BI 下钻到明细报表账号有底表权限,下钻看到个体行动态脱敏(视图 / anon / RLS)
日志与错误堆栈带 PII异常信息把整行数据打进日志应用层出口组件 + 日志脱敏
导出文件二次流转BI 导出 Excel 后随手转发出口审计 + 导出权限收敛
演示 / 截图客服系统、管理后台直接截图应用层脱敏 + 前端遮蔽
新列未纳管新增敏感字段没进任何脱敏规则分级登记 + 覆盖巡检(见示例四)

每个形态对应一类方案,这正是「按场景选形态」的落地含义——没有哪个单一方案能同时堵住这六个口子。

方案一:视图脱敏的能力与边界 ​

视图方案的优势是零依赖、人人会写,把脱敏规则与权限模型解耦(底表权限收紧,视图按岗开放)。它的边界同样清楚:

  • 函数式脱敏是写死的:substring 拼接、regexp_replace 这类规则散落在视图定义里,字段多了以后维护成本上升,需要用统一的脱敏函数库收口;
  • 视图绕过问题:底表有 SELECT 权限的角色可以直接查底表,脱敏视图只对「看不到底表」的角色有意义——它本质上是权限模型的一部分;
  • 聚合与导出泄漏:攻击面不只在行值,count(distinct phone)、按脱敏列 group by 的统计也可能泄漏信息,高敏环境的脱敏视图要评估聚合路径;
  • 可逆性:substring 式脱敏不可逆;如果视图里返回的是「加密值 + 受控解密」(见下文可逆脱敏),则视图同时承担了取钥与授权的职责。

适合作为起步方案与小规模环境的长期方案;规则复杂、角色多的环境,交给专用扩展(anon)或应用层。

方案二:anon 的动态脱敏机制 ​

PostgreSQL Anonymizer(扩展名 anon,由 Dalibo 发起的开源项目)把动态脱敏做成了声明式体系,核心概念三个:

  • SECURITY LABEL 声明规则:脱敏规则以安全标签的形式挂在列上(MASKED WITH FUNCTION ... / MASKED WITH VALUE ... / MASKED WITH NULL 等),元数据随库走,不需要额外的规则表;不同大版本的标签语法细节略有差异,以安装版本的官方文档为准。
  • 角色标记 MASKED:被打上 SECURITY LABEL FOR anon ... ON ROLE xxx IS 'MASKED' 的角色,查询时自动经过规则替换;未标记角色照常读到真数据。规则与「角色」而不是「连接」绑定,与现有权限体系天然融合。
  • 伪造数据函数族:anon.fake_first_name() / anon.fake_last_name() / anon.fake_email() 等生成可信的假值,anon.partial() 做部分遮蔽,anon.random_date_between() 做日期泛化,anon.pseudo_* 系列做保一致性的伪名化。假值来自内置词典,分布合理,测试用例不会出现「姓名全叫 aa」的尴尬。

工程要点:动态脱敏要求 shared_preload_libraries = 'anon' 并重启;CREATE EXTENSION anon CASCADE; 建扩展;SELECT anon.start_dynamic_masking(); 初始化(不同大版本的初始化方式略有差异,升级前核对文档);规则变更即时生效,无需重启。

anon 常用标签与函数速查 ​

动态脱敏的规则写作就是「给列贴标签」。常用形态(语法与函数名随版本演进,以安装版本的官方文档为准):

标签 / 函数作用示例输出
MASKED WITH FUNCTION ...用函数输出替换原值anon.fake_email()
MASKED WITH VALUE ...固定值替换***
MASKED WITH NULL直接置空NULL
MASKED(角色级标签)该角色的会话整体进入脱敏视图模式见方案二开头的角色标签
anon.fake_first_name() / fake_last_name()伪造可信假名Sarah / Nguyen
anon.fake_email() / fake_free_email()伪造邮箱sarah.nguyen@example.com
anon.partial(col, 前保留, 后保留, 字符)部分遮蔽(保格式)138******78
anon.random_date_between(d1, d2)日期泛化1988-04-12
anon.pseudo_email(col) 等 pseudo_* 系列一致性伪名化(同一输入同一假值)跨表关联不散架
anon.digest(col, 盐, 算法)哈希假名化(不可逆)7fad...

选择顺序的经验值:能泛化就不伪造(成本低、统计保真);需要格式与关联就 pseudo_* / partial;真正要隐藏存在性(不能让人知道「这个字段有值」)才用 NULL。

anon 的静态脱敏与匿名导出 ​

静态路径不依赖预加载与角色标记,目标是「产出一份可直接给测试/第三方使用的匿名数据集」:

sql
-- 静态脱敏的准备工作:初始化随机数发生器(生成假数据前执行一次)
SELECT anon.random_init();

-- 匿名导出:anon.dump() 直接产出脱敏后的转储
SELECT anon.dump();

anon.dump() 的输出是脱敏后的库转储(假名化/伪造值已落定),可直接交给测试环境导入。相比「生产导出 → 测试环境跑脱敏脚本」的传统流程,它在导出环节就完成加工,避免明文副本落地。老版本或定制流程也可以用「还原副本 + 批量 UPDATE 假值」自建静态脱敏,anon 的规则标签可以配合读取,形成统一的规则来源。

方案三:应用层脱敏的取舍 ​

应用层方案把脱敏放在 PHP 序列化/出口层,本质上是「按会话身份决定输出形态」:

  • 优势:与业务权限体系(登录态、RBAC)同源,策略表达力最强(可以按订单金额、按岗位组合决定);不增加数据库负担;API 出口单一的环境实施成本极低。
  • 风险:防线依赖出口纪律——任何绕过序列化层的路径(运维直连、内部脚本、导出工具、GraphQL 的新字段)都会漏出明文;数据库侧工具(psql、BI 直连)完全不受保护。
  • 适用:作为纵深的一层存在很合理;作为唯一防线只在「数据出口严格收敛到应用 API」的环境成立。

工程上把它做成统一的出口组件(框架的序列化器/响应中间件),配以「新字段默认脱敏、显式声明才暴露」的白名单策略,比散落在各接口的手写替换可靠得多。

方案四:RLS + 角色组合 ​

RLS 本身不脱敏,但它提供了「同一张表、不同会话看到不同行」的机制,与脱敏组合后形成两个常见模式:

  • 行级隔离 + 列级脱敏叠加:RLS 过滤行(多租户),脱敏视图替换列(手机号部分遮蔽),业务账号两层数据最小化同时生效;
  • 策略驱动的动态脱敏:RLS 策略里引用会话变量(current_setting('app.pii_level')),配合视图或应用层按该变量分级给值——把「脱敏等级」变成会话属性,由认证/授权层下发。

注意 RLS 与脱敏视图的组合顺序:视图属于定义者权限体系(security_invoker 视图行为随版本不同),组合时明确视图内外 RLS 是否生效,避免出现「视图绕过了行级策略」的经典事故。

假名化、匿名化与泛化 ​

回到概念层,把三种加工方式映射到工程选择:

  • 假名化(可逆):pseudo_* 类函数(固定盐下同一输入得到同一假值)、加密 + 受控解密视图。适用于「测试环境要保持业务一致性」与「第三方数据合作要保留关联分析能力」的场景;配合密钥/盐的治理,还原能力始终受控。
  • 匿名化(不可逆):假值替换 + 破坏关联(每个手机号独立随机假名)、直接置空。适用于数据真正外发的场景;要认识到单纯「删名字」不是匿名化——组合特征(如「邮编 + 生日 + 性别」)仍可能重识别。
  • 泛化(不可逆):年龄段、城市级地址、金额区间。统计与报表场景的默认选择;泛化粒度是「数据效用」与「重识别风险」的调节旋钮。

脱敏评审时对每个字段问三个问题:要保什么分析能力(决定假名化还是泛化)、给谁用(决定可逆性要求)、泄露后果多严重(决定粒度)。

数据分布与业务规则的保真 ​

「看起来像真数据」不是审美问题,它决定脱敏数据集有没有用:

  • 值分布保真:手机号段、性别比例、注册时间分布若与真实业务差太远,依赖分布的测试(风控策略、报表对数)全部失真。伪造函数族与「按真实分布随机抽假值」(映射表法)都能保分布;MASKED WITH VALUE 这类「全体一个值」的规则只适合演示,不适合测试。
  • 引用完整性:订单的 customer_id 必须还指向存在的客户,外键关系不能被独立随机打断——这是「逐表独立脱敏」与「按实体脱敏」的区别,跨表实体要用同一套伪名化键串起来。
  • 业务规则保真:金额与状态字段的组合要满足业务约束(已支付订单金额不能为 NULL)。全字段独立随机化会制造大量「业务上不可能」的行,测试结果反而误导。
  • 时间与空值形态:时间间隔(下单到支付间隔)要保序;空值率要接近真实(全量补齐与全量置空都是失真)。

统一脱敏函数库的收口 ​

无论规则落在视图、anon 标签还是应用层,底层遮蔽动作都应该收口到一组自定义函数——这是三套方案能长期并存的粘合剂:

sql
-- 脱敏函数库:统一格式、统一维护入口
CREATE OR REPLACE FUNCTION public.mask_phone(p text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
  SELECT CASE WHEN p IS NULL THEN NULL
              ELSE substring(p FROM 1 FOR 3) || '****' || substring(p FROM 8) END
$$;

CREATE OR REPLACE FUNCTION public.mask_email(p text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
  SELECT CASE WHEN p IS NULL THEN NULL
              ELSE substring(p FROM 1 FOR 1) || '***@' || split_part(p, '@', 2) END
$$;

CREATE OR REPLACE FUNCTION public.mask_name(p text) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
  SELECT CASE WHEN p IS NULL THEN NULL
              ELSE substring(p FROM 1 FOR 1) || repeat('*', greatest(length(p) - 1, 1)) END
$$;

收益体现在三个层面:视图脱敏直接引用 public.mask_phone(phone),规则升级(如「留 3 位」改「留 2 位」)只改函数体一处;anon 标签可以写成 MASKED WITH FUNCTION public.mask_phone(phone),与应用层共享同一套格式约定;应用层(PHP 的 MaskService)以函数库的输出为验收基准,保证「数据库里看到什么、接口返回什么」形态一致。

函数库本身要纳入版本管理与 DDL 审计——它决定所有敏感列的出口形态,是高价值篡改目标,列级加密一页的攻击面自查在这里同样适用。

不可逆脱敏(哈希)与可逆脱敏(加密 + 视图控制) ​

两个方向的实现模板(加密函数细节见上一页):

sql
-- 不可逆:hmac 假名化(同一输入同一摘要,可用于 join 与防重,但无法还原)
SELECT encode(hmac(phone, convert_to('mask-mac-key', 'UTF8'), 'sha256'), 'hex')
  FROM customers;

-- 可逆:加密落库 + 视图按角色解密
CREATE VIEW v_customers_pii AS
SELECT id, name,
       pgp_sym_decrypt(phone_enc, current_setting('app.pii_key')) AS phone
FROM public.customers;

GRANT SELECT ON v_customers_pii TO pii_reader;

选择依据:需要「还原成真值」的场景(客服回拨、风控核身)用可逆方案,密钥与授权即还原边界;只需要「稳定关联」不需要还原的场景(去重、跨表 join、用户行为串联)用不可逆 HMAC——它没有密钥保管的负担,泄露后果也小得多。

一致性保持:同一输入同一假值 ​

脱敏最大的实用陷阱是「一致性丢失」:同一个客户在订单表里叫 Sarah、在地址表里叫 Tom、手机号各不相同——数据集失去了业务关联性,测试与统计全部失效。保持一致性的三种手段:

  • Anonymizer 的伪名化函数:anon.pseudo_email() 等 pseudo_* 系列在固定盐下对同一输入输出同一假值,天然跨表一致;盐值本身纳入密钥管理(换盐 = 全部假值变化)。
  • 自建映射表:CREATE TABLE mask_map (real_value bytea, fake_value text),脱敏时先查映射再生成;直观可控,但映射表本身成为敏感资产(真值—假值对照),要与底表同等级保护。
  • HMAC 摘要:哈希天然确定,用作「稳定关联键」;配合截断/格式化展示。

一致性还有一个方向是格式保持:手机号脱敏后仍是 11 位数字、邮箱仍是合法格式、身份证校验位仍正确——多数伪造函数与 anon.partial() 都支持按格式输出,格式破坏的假值会在下游 ETL 与校验逻辑里炸出一片报错。

脱敏与查询性能 ​

动态脱敏的成本发生在每条查询的输出路径上:

  • 视图方案:substring / regexp_replace 的单行开销可忽略,但「视图 + 复杂查询」可能影响优化器的谓词下推与统计信息,复杂报表场景要 EXPLAIN 验证;
  • anon 动态脱敏:规则替换在查询返回前执行,简单规则开销很小;伪造函数(词典查找、随机数)比字符串截断贵,热点接口上避免对高频列使用重伪造规则;
  • 静态脱敏的成本一次性发生在加工时,运行期零开销——这是「测试数据集」一律走静态路径的性能理由。

监控方式与列级加密一致:对脱敏视图与底表的同类查询做 EXPLAIN ANALYZE 对比,把脱敏开销量化后再决定在哪些角色上启用。

与 pg_dump 的配合 ​

脱敏与导出工具链的关系分三种形态:

  • 导出后脱敏(传统):pg_dump 出明文 → 恢复到加工库 → 脱敏脚本处理 → 再导出交付。链路里存在明文副本,要按「敏感副本」管理(加密存储、限期清理);
  • 导出即脱敏:anon 的 anon.dump()(或等价的定制导出函数)在导出环节完成加工,明文副本不出生产;
  • 掩码视图导出:对只需部分数据的场景,从脱敏视图做 COPY / 逻辑导出,天然只含脱敏值。

交付给测试环境前,用真实 PII 抽样清单反查导出产物(见实战),确认没有明文泄漏——「导出产物里不应出现任何真实手机号」应该是一条可机检的规则,而不是人工保证。

数仓与 ETL 链路中的脱敏落点 ​

数据一旦进了数仓,脱敏的「位置」比「手段」更重要。三个标准落点:

  • 抽取层伪名化:ETL 从生产抽数时,PII 字段在管道里就地转换为 HMAC / 伪名(示例三的模式),明文不过数仓大门——这是首选位置,成本最低、覆盖最全。
  • 分层权限收敛:即便抽取层已处理,raw 层的账号仍要收敛(只允许管道账号写入,只读账号只到明细层);「raw 层随便查」是数仓侧最常见的事故源。
  • 明细层脱敏视图:面向分析师的明细层以脱敏视图暴露(泛化 + 部分遮蔽),底表权限只留给数据平台自身;BI 工具的直连账号一律接视图而不是底表。
  • 回流路径:分析结论回写生产(如用户分群标签回流)时,回流数据不含 PII,且写回路径走白名单账号与审计。

与生产侧的分工:生产的动态脱敏保护「查询出口」,数仓的伪名化保护「复制出口」——两个出口都要有人管,只加密生产而放任数仓,等于把家门锁好、后墙拆掉。

脱敏方案的验收口径 ​

「脱敏做好了」的验收不是看演示,而是四条可机检的结论:

  1. 明文反查通过:真实 PII 抽样清单在交付产物(导出文件、测试库)中命中数为 0;
  2. 格式与关联通过:假值通过下游格式校验,跨表关联键的基数(distinct 数)与生产同量级;
  3. 角色分流通过:MASKED 角色与非 MASKED 角色的同一条查询结果形态符合预期,两者对底表的权限差异经 has_table_privilege 核对;
  4. 覆盖巡检通过:分级清单与规则清单的双向比对无未纳管项。

把四条写进交付模板,验收记录随交付物归档——与审计章节的演练思路一致:可验证的结论才值得写进文档。

实战示例 ​

示例一:给测试环境生成脱敏数据集 ​

目标:测试组每周需要一份与生产同构的数据集,含真实的数据分布与业务关联,但不含任何真实 PII。

第一步:生产库部署 anon 并声明规则

sql
CREATE EXTENSION anon CASCADE;
SELECT anon.start_dynamic_masking();
SELECT anon.random_init();

-- 声明规则(按列)
SECURITY LABEL FOR anon ON COLUMN customers.first_name IS 'MASKED WITH FUNCTION anon.fake_first_name()';
SECURITY LABEL FOR anon ON COLUMN customers.last_name  IS 'MASKED WITH FUNCTION anon.fake_last_name()';
SECURITY LABEL FOR anon ON COLUMN customers.email      IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN customers.phone      IS 'MASKED WITH FUNCTION anon.partial(customers.phone, 3, 2, ''*'')';
SECURITY LABEL FOR anon ON COLUMN customers.birthday   IS 'MASKED WITH FUNCTION anon.random_date_between(''1970-01-01''::date, ''2005-12-31''::date)';
SECURITY LABEL FOR anon ON COLUMN orders.shipping_addr IS 'MASKED WITH NULL';

第二步:匿名导出

bash
# 生产库执行匿名导出,产物直接落加密对象存储
sudo -u postgres psql -c "SELECT anon.dump();" \
  | gpg --encrypt -r testops@example.com \
> /tmp/testdata-$(date +%F).sql.gpg

# 上传后立即清理本地临时产物
rm -f /tmp/testdata-$(date +%F).sql.gpg

第三步:测试库导入与验证

bash
gpg --decrypt /tmp/testdata-*.sql.gpg | psql -h test-db -U test -d appdb
sql
-- 导入后自检:抽样字段不应出现真实值模式
SELECT phone, email FROM customers LIMIT 5;
text
    phone     |         email
--------------+------------------------
 138******45  | sarah.nguyen@example
 139******20  | tom.becker@example
bash
# 反查自检:导出产物中不应存在真实手机号(用真实号码抽样清单)
for p in $(cat prod-phone-samples.txt); do
  grep -q "$p" testdata-export.sql && echo "LEAK: $p" && exit 1
done; echo "OK: no real PII in export"

第四步:纳入周任务:导出、上传、通知测试组,全部脚本化;规则变更走评审。

示例二:报表账号的动态脱敏视图 ​

sql
-- BI 账号只能看脱敏视图
CREATE ROLE bi_reader NOLOGIN;

CREATE VIEW v_orders_bi AS
SELECT o.id,
       o.created_at,
       o.status,
       o.amount,
       substring(c.phone FROM 1 FOR 3) || '****' AS customer_phone,
       c.city,                                   -- 泛化:只到城市
       date_trunc('month', c.birthday)           -- 泛化:生日只到月
       AS birth_month
FROM orders o
JOIN customers c ON c.id = o.customer_id;

GRANT SELECT ON v_orders_bi TO bi_reader;
REVOKE ALL ON public.orders, public.customers FROM bi_reader;
sql
-- bi_reader 会话:统计能力完整,个体不可识别
SELECT city, count(*) AS orders, avg(amount) AS avg_amount
FROM v_orders_bi
WHERE created_at >= date_trunc('month', now())
GROUP BY city
ORDER BY orders DESC
LIMIT 5;
text
   city   | orders |    avg_amount
----------+--------+-------------------
 Shanghai |   4211 | 289.4427
 Beijing  |   3802 | 301.1108

示例三:HMAC 假名化做跨表一致关联 ​

数据平台需要把订单、工单、物流三张表的「同一客户」串起来,但平台侧不允许持有真实手机号:

sql
-- ETL 写入平台时:真实号 → HMAC 假名(三张表用同一盐与算法)
INSERT INTO dw.fact_orders (order_id, customer_pseudo, amount)
SELECT o.id,
       encode(hmac(c.phone, convert_to('dw-mac-key', 'UTF8'), 'sha256'), 'hex'),
       o.amount
FROM orders o JOIN customers c ON c.id = o.customer_id;

-- 工单表同样用 hmac(phone, 'dw-mac-key', 'sha256') 生成关联键
-- 平台侧 join 用的就是这个伪名键
SELECT count(DISTINCT customer_pseudo) AS active_customers
FROM dw.fact_orders
WHERE order_date >= now() - interval '30 days';

平台全程只见密文摘要;若某天确需还原,持盐方可以按清单反查——这是假名化「可逆但受控」的典型落地。

示例四:脱敏覆盖度巡检 ​

动态脱敏最大的风险是「新敏感列忘了贴标签」。把「数据分级登记表 × 脱敏规则登记表」的比对做成周期巡检(两张登记表由分级制度与变更流程维护,是巡检的基准):

sql
-- sensitive_columns:数据分级登记(table_schema, table_name, column_name, level, owner)
-- mask_rules:脱敏规则登记(table_schema, table_name, column_name, rule_type, updated_at)

-- 1. 已登记为敏感、却没有脱敏规则在册的列(高风险缺口)
SELECT s.table_schema, s.table_name, s.column_name, s.level
FROM sensitive_columns s
LEFT JOIN mask_rules r
       ON r.table_schema = s.table_schema
      AND r.table_name   = s.table_name
      AND r.column_name  = s.column_name
WHERE r.column_name IS NULL
ORDER BY s.level, s.table_name;
text
 table_schema | table_name | column_name | level
--------------+------------+-------------+-------
 public       | customers  | wechat_id   | P2
sql
-- 2. 反向核对:规则在册的列是否都登记为敏感(防止规则老化)
SELECT r.table_schema, r.table_name, r.column_name
FROM mask_rules r
LEFT JOIN sensitive_columns s
       ON s.table_schema = r.table_schema
      AND s.table_name   = r.table_name
      AND s.column_name  = r.column_name
WHERE s.column_name IS NULL;
sql
-- 3. 规则时效:超过 180 天未复核的规则进入人工确认队列
SELECT table_schema, table_name, column_name, rule_type, updated_at
FROM mask_rules
WHERE updated_at < now() - interval '180 days'
ORDER BY updated_at;
sql
-- 4. 运行时对账:以 MASKED 测试角色直连目标表,核对其看到的确实是规则形态
--    (「纸面规则」与「运行时行为」的对账,巡检脚本以测试角色跑通为准)

巡检结果进问题清单闭环:第 1 项的非空行按「新增敏感列流程」补规则,第 2 项的非空行清理失效规则,第 3 项逐条复核。两张登记表要与 anon 的标签目录(如 pg_seclabels 视图,字段以版本文档为准)做一次初始同步,避免登记表与实际标签漂移。

注意事项 ​

脱敏后的假名化数据仍属敏感数据。 多数法规框架下,可逆的假名化数据仍然算个人数据;向第三方提供时要走同样的评估与合同流程,不能因为「已经假名化」就降级管理。

视图脱敏不是权限的替代。 底表权限没收紧的视图脱敏等于门没锁只拉了窗帘;先 REVOKE 底表、再 GRANT 视图,顺序不能反。

anon 的标签语法随大版本有差异。 不同版本的 SECURITY LABEL 写法、初始化函数与导出函数都有演进,实施与升级前以安装版本的官方文档为准,不要跨版本照抄示例。

动态脱敏要防「规则遗漏」型泄漏。 新增敏感列时若忘记加标签,MASKED 角色立即可见明文。把「敏感列清单 vs 标签清单」的比对做成巡检(pg_seclabel 与数据分级清单 join)。

导出链路的中间副本要按敏感副本管理。 「导出后脱敏」模式里的明文 dump、加工库快照都是泄露高危点:加密存储、限期清理、传输走 TLS,一样都不能少。

应用层脱敏只保护它覆盖的出口。 psql、BI 直连、内部脚本、新加的导出工具都在防线之外;应用层方案必须配合数据库侧的出口收敛(底表权限、连接账号清单)。

泛化粒度不够会留下重识别风险。 「城市 + 出生日期 + 性别」的组合在统计意义上足以重识别大量个体;高敏数据集的泛化粒度要过一次专门的评估,而不是按感觉定。

一致性盐是密钥资产。 pseudo_* 的盐、HMAC 的 MAC 密钥一旦泄漏,攻击者可用候选明文反推对应关系;换盐会导致全部假值变化,跨库同步的测试集要同步换。

静态脱敏要在非生产环境执行加工,或在生产用「导出即脱敏」。直接在生产底表上 UPDATE 成假值做「测试数据」会永久污染生产数据——这听起来荒谬,但真实事故里并不罕见。

测试集的 schema 同步与数据同步是两件事。 脱敏数据集的表结构会随生产 schema 演进而漂移,漂移的测试集让测试结论失真;导出流程要把结构同步与数据脱敏一起做。

演示环境是「随手复制生产库」的高发地。 「给销售一个能点的 demo」式的生产库拷贝是最常见的泄漏起点;演示环境的数据来源要写进交付清单,与测试集同等走脱敏流程。

脱敏规则的多处引用要防「时间差」。 视图与 anon 标签各自引用函数时,改函数体对两者同时生效;但应用层若另写了一份格式逻辑,三处漂移要在巡检里核对——同一字段在不同出口形态不一致,本身就是一次小型泄漏。

函数收口挡不住绕过它的导出。 从脱敏视图 COPY 的产物天然带遮蔽形态,但绕过视图直连底表的导出脚本会让函数库形同虚设——导出账号的权限核对(见最佳实践)与函数收口是同一件事的两面。

脱敏与加密/RLS 的组合顺序要评审。 视图与 RLS 的叠加涉及 security_invoker / security_definer 语义与视图属主权限,组合不当会出现「视图绕过行级策略」的越权通道。

最佳实践 ​

  1. 按场景选形态:生产查询出口用动态脱敏,测试/第三方数据集用静态匿名导出,API 出口用应用层兜底。 不要指望一个方案覆盖所有场景。
  2. 规则集中声明,不散落实现。 优先用 anon 的 SECURITY LABEL 或统一的脱敏函数库;视图里手写的 substring/regexp_replace 超过十条就该收敛。
  3. 先收权限,再给脱敏视图。 底表 REVOKE 到 PUBLIC,按岗位最小化 GRANT;脱敏是权限模型的延伸,不是权限的替代。
  4. 需要关联分析的字段用伪名化(pseudo_ / HMAC)保持一致性*,需要还原的字段用加密 + 受控解密视图;两者都要求把盐/密钥纳入密钥管理。
  5. 格式保持是硬要求。 假值必须满足下游的格式与校验逻辑(手机号位数、邮箱合法性),否则测试集与 ETL 会大面积报错。
  6. 「导出即脱敏」优先于「导出后脱敏」。 anon.dump() 或定制导出函数让明文副本不出生产;确需后置加工时,中间副本加密存储、限期清理。
  7. 导出产物做机检反查。 用真实 PII 抽样清单对导出文件做匹配检查,让「没带出真数据」成为可验证结论而非口头保证。
  8. 把「敏感列清单 vs 脱敏标签清单」做成巡检。 新列上线必须同步登记分级与脱敏规则,巡检发现未登记敏感列即告警。
  9. 泛化粒度过数据分级评审。 报表与外发数据集的泛化粒度(城市/年龄段/金额区间)由数据 owner 与安全共同确定,写进交付规范。
  10. 动态脱敏的规则变更走变更流程。 标签与视图定义的每次修改都要评审与留痕——脱敏规则本身就是高危配置。
  11. 复杂查询场景先 EXPLAIN 再上线。 视图与动态脱敏对谓词下推、统计信息的影响要实测,避免报表高峰期才发现性能塌方。
  12. 应用层方案白名单化。 新字段默认脱敏、显式声明才明文输出;把脱敏组件做成框架级出口,而不是各接口各写一套。

下一节

继续学习:pg_stat_activity

参考链接 ​