数据库基础体系 · 第 136/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据库隐私与数据生命周期:分类、最小化、脱敏、留存和删除

数据库隐私治理不是在字段上加一层“隐藏显示”的功能,而是要回答一条数据在整个生命周期中的问题:

  • 它是什么,是否包含个人信息或业务敏感信息?
  • 为什么要收集它,哪些处理步骤真正需要它?
  • 哪些角色可以看到原值、部分值或统计值?
  • 数据经过复制、缓存、日志、备份和导出后,是否仍受同样的控制?
  • 到期后如何删除,删除是否会被软删除标记、备份或下游副本抵消?
  • 删除与查询、更新、同步、恢复并发发生时,最终状态是什么?

因此,隐私治理至少包含五个相互约束的主题:

  1. 分类:识别数据的敏感程度、用途、主体和传播范围。
  2. 最小化:只收集、存储、处理和暴露完成业务目的所必需的数据。
  3. 脱敏:在不需要原值的场景中降低可识别性,但不能把可逆的假名化误称为匿名化。
  4. 留存:在明确的业务或合规目的下保存数据,并定义可验证的到期条件。
  5. 删除:从在线库、派生数据、同步链路和备份恢复流程中实现可证明的删除或不可用化。

这些机制相互关联,但不能互相替代。加密不能替代最小化,脱敏不能替代权限控制,软删除不能自动满足删除要求,设置了留存期限也不代表备份中的数据已经消失。


一、先建立数据生命周期模型

1. 数据生命周期不只是“插入到删除”

一条业务数据通常会经历以下状态:

采集
  ↓
校验与分类
  ↓
在线存储
  ↓
查询、修改、派生
  ↓
复制、缓存、导出、备份
  ↓
到期、撤回目的或删除请求
  ↓
删除、匿名化或不可用化
  ↓
删除验证与审计

例如,用户手机号可能同时存在于:

  • 主业务表;
  • 唯一索引;
  • 搜索索引;
  • 订单快照;
  • 消息队列;
  • CDC(Change Data Capture,变更数据捕获)下游;
  • 应用日志;
  • 数据仓库;
  • 备份文件;
  • 监控 SQL 文本或慢查询日志。

如果只执行:

DELETE FROM users WHERE user_id = 42;

那么最多证明主表中的这一行在当前事务提交后被删除。它不能证明其他副本也已经删除。

2. 建议把“数据对象”与“数据副本”分开登记

数据目录中至少应记录以下实体:

项目 含义
数据对象 例如 users.phone、订单地址
数据主体 数据属于谁,例如客户、员工、设备
业务目的 注册、履约、风控、客服等
数据分类 公开、内部、机密、敏感个人数据等
原始来源 用户输入、设备采集、第三方接口
派生对象 哈希、统计指标、推荐特征
传播路径 主库、只读副本、仓库、导出文件
责任人 能解释用途和删除规则的业务或数据负责人
留存规则 起算事件、期限、例外和删除动作
控制方式 权限、加密、视图、脱敏、审计
删除状态 待删除、已删除、受法律保留、待备份过期

这里的“责任人”不等于拥有数据库管理员权限的人。数据库管理员负责执行技术动作,而数据责任人需要确认“为什么保留”和“什么情况下可以删除”。

3. 删除事件必须有明确的起算点

“保留 90 天”并不完整,必须说明 90 天从何时开始计算。例如:

  • 从账号创建时间开始;
  • 从最后一次业务活动开始;
  • 从合同终止时间开始;
  • 从订单完成时间开始;
  • 从最后一次访问日志产生时间开始。

设一条记录的起算时间为 t0t_0,留存期限为 RR,当前时间为 tt,则最基本的到期条件是:

tt0+Rt \geq t_0 + R

但真实系统还需要加入例外状态 HH,表示存在法律保留、争议处理或审计冻结:

eligible_for_deletion=(tt0+R)(¬H)\text{eligible\_for\_deletion} = (t \geq t_0 + R) \land (\neg H)

如果没有明确 t0t_0HH 的来源,定时任务只能“猜测”哪些数据可以删除。


二、数据分类:先区分“值是什么”和“如何使用”

1. 分类不是给表打一个标签就结束

同一个字段在不同场景可能有不同风险。

例如:

  • user_id 在单独系统中可能只是内部标识;
  • user_id + 行为时间 + IP 可能可以关联到具体个人;
  • 经过加密的身份证号仍然是高敏感数据,因为有密钥时可恢复;
  • 仅保存年龄段可能比保存出生日期更符合统计用途,但“小群体中的年龄段”仍可能重新识别个体。

因此,分类至少要考虑四个维度:

  1. 内容:字段本身存储什么。
  2. 可识别性:能否单独或与其他字段结合识别主体。
  3. 用途:展示、履约、统计、风控还是调试。
  4. 传播范围:只在数据库内部,还是会进入日志、报表和第三方系统。

2. 常见分类层次

一种可执行的分类方式如下:

公开数据

泄露后通常不会影响个人或核心业务,例如公开的产品名称、公共帮助文档。

内部数据

不应公开,但泄露影响有限,例如内部状态码、非敏感运营配置。

机密业务数据

泄露可能造成竞争、财务或运营风险,例如成本、合同价格、未发布策略。

个人数据

能够直接或间接关联到自然人的数据,例如:

  • 姓名;
  • 邮箱;
  • 手机号;
  • 身份证件号码;
  • 精确地址;
  • 设备标识;
  • IP 地址;
  • 账户和订单关联关系。

“个人数据”的具体法律定义取决于适用法域,工程上不应只依赖字段名称判断。

高敏感或特殊风险数据

例如认证凭据、支付信息、健康信息、精确位置、未成年人信息等。它们通常需要更严格的访问控制、加密、审计和更短的留存期限。

3. 直接标识符、准标识符和敏感属性

为了理解重识别风险,可以把字段分成三类:

  • 直接标识符:单独即可识别主体,例如身份证件号码、手机号。
  • 准标识符:单独通常不能唯一识别,但组合后可能识别主体,例如出生日期、邮编、职业。
  • 敏感属性:一旦关联主体,可能造成严重影响,例如健康诊断、收入、风险评级。

假设统计表只删除了姓名和手机号,但保留:

出生日期、精确邮编、性别、就诊日期、诊断结果

攻击者可能通过公开资料或另一张表关联这些准标识符,恢复个体身份。删除直接标识符不等于匿名化。

4. 分类应落实为可查询的元数据

可以建立一个数据目录表:

CREATE TABLE data_catalog (
    object_name       text PRIMARY KEY,
    classification    text NOT NULL CHECK (
        classification IN ('public', 'internal', 'confidential', 'personal', 'sensitive')
    ),
    purpose            text NOT NULL,
    retention_days     integer CHECK (retention_days IS NULL OR retention_days > 0),
    owner_name         text NOT NULL,
    deletion_basis     text NOT NULL,
    created_at         timestamptz NOT NULL DEFAULT now()
);

这里的表只是治理元数据,不会自动改变数据库权限,也不会自动删除业务数据。要让分类产生实际效果,还需要把它连接到:

  • DDL 审核;
  • 权限申请;
  • SQL 审计;
  • 脱敏视图;
  • 数据导出审批;
  • 留存任务;
  • 删除验证。

三、数据最小化:不仅是“少存几个字段”

1. 最小化的四个层次

数据最小化至少有四种不同含义:

  1. 收集最小化:业务不需要的字段不采集。
  2. 存储最小化:不重复保存同一事实,不保存不再需要的精度。
  3. 处理最小化:某个处理步骤只获得完成任务所需的字段。
  4. 暴露最小化:调用者只能看到完成任务所需的结果。

例如,配送系统需要判断地址是否在配送范围内,可能只需要:

经纬度网格、城市、区域

而不一定需要永久保存完整门牌地址。另一方面,实际派送又可能确实需要完整地址。因此“最小化”通常是按处理目的分别定义的,而不是要求整个系统永远只保存最粗粒度的数据。

2. 用函数依赖判断是否存在重复存储

假设订单表为:

orders(order_id, user_id, user_phone, amount)

如果业务规则保证:

user_iduser_phoneuser\_id \rightarrow user\_phone

user_id 能确定 user_phone,那么把手机号复制到每一条订单中会产生冗余。它带来两个隐私问题:

  • 用户手机号变更时,会有旧快照继续保留;
  • 删除用户信息时,订单表仍然持有手机号副本。

可以改为:

users(user_id, phone)
orders(order_id, user_id, amount)

订单履约若需要联系信息,在事务内通过关联读取:

SELECT o.order_id, o.amount, u.phone
FROM orders AS o
JOIN users AS u ON u.user_id = o.user_id
WHERE o.order_id = $1;

但是,规范化并不总是意味着必须消灭所有快照。发票、合同、物流凭证可能需要保留“交易当时的收货地址”。这时应明确该字段是:

  • 当前用户资料;
  • 还是交易事实的历史快照。

两者的目的和留存规则不同,不能只因为字段内容相同就混用。

3. 用“必要性约束”形式化最小化

设业务功能需要满足的输出为 YY,输入字段集合为 SS,系统使用字段子集 XSX \subseteq S。如果存在处理函数:

Y=f(X)Y = f(X)

而去掉任意一个字段 xXx \in X 后都无法稳定完成该功能:

xX,Yfx(X{x})\forall x \in X,\quad Y \neq f_x(X-\{x\})

XX 是该功能的一组必要输入。

工程上不需要真的求解数学上的最小集合,但可以逐字段做反事实检查:

如果删除这个字段,功能会失败、结果会降低准确性,还是只是让实现不方便?

“实现不方便”通常不是保留个人数据的充分理由。

4. 例子:登录功能不应读取完整用户行

假设登录只需要验证邮箱、密码哈希和账户状态。应用不应无条件执行:

SELECT *
FROM users
WHERE email = $1;

因为这可能把手机号、地址、备注等不必要字段带入应用内存、调试日志或追踪系统。

更小的查询是:

SELECT user_id, password_hash, account_status
FROM users
WHERE email = $1;

如果邮箱不应作为应用内部长期标识,还可以在登录成功后只返回内部用户 ID 和会话标识,不继续在各服务间传播邮箱。

5. 精度最小化与聚合

分析系统常见的风险不是字段太多,而是精度太高。例如保留:

精确时间、精确经纬度、精确年龄

可能比保留:

小时、城市网格、年龄段

更容易识别个体。

但聚合存在“小群体”风险。设查询结果包含 nn 个主体,若 n=1n=1,即使只返回平均值,也可能直接暴露个人值。常见控制包括:

  • 设置最小群体阈值 kk,仅在 nkn \geq k 时返回;
  • 降低时间和空间精度;
  • 限制连续查询,防止通过差分推导单条记录;
  • 对统计结果加入噪声。

这些方法会影响准确性,必须根据分析目的选择,而不能把任意 GROUP BY 都称为匿名化。


四、脱敏:降低暴露,不等于删除或匿名化

1. 四个容易混淆的概念

加密

加密通常是可逆的。拥有密钥即可恢复原值。它主要解决存储或传输过程中的机密性,不解决“被授权应用是否不应看到原值”。

哈希

哈希通常是单向函数,但低熵值容易被字典枚举。例如手机号、邮箱、短身份证号都可能被预计算或批量猜测。裸 SHA-256 不能自动把这些数据变成匿名数据。

假名化

把原始标识替换为内部 ID、随机标识或令牌。只要映射表、密钥或其他关联信息还存在,就仍可能恢复主体关系。

匿名化

目标是使数据在合理可行的攻击条件下无法再关联到特定主体。它不是一个 SQL 函数,而是要结合数据集、外部信息、攻击模型和发布方式评估。工程上不应仅凭“删掉姓名”宣布完成匿名化。

2. 静态脱敏与动态脱敏

静态脱敏

把生产数据复制到测试或分析环境时,生成一个新的脱敏数据集:

生产手机号:13812345678
测试手机号:13800001234

优点是测试环境不再依赖生产原值。缺点是脱敏任务本身需要读取原值,生成过程和中间文件也必须受到保护。

动态脱敏

原表保留原值,但不同角色通过视图看到不同结果。例如 PostgreSQL:

CREATE TABLE customer (
    customer_id bigint PRIMARY KEY,
    full_name   text NOT NULL,
    phone       text NOT NULL,
    email       text NOT NULL
);

CREATE VIEW customer_support_view AS
SELECT
    customer_id,
    full_name,
    CASE
        WHEN length(phone) >= 7
        THEN repeat('*', length(phone) - 4) || right(phone, 4)
        ELSE '****'
    END AS phone,
    CASE
        WHEN position('@' IN email) > 1
        THEN left(email, 1) || '***' ||
             substring(email FROM position('@' IN email))
        ELSE '***'
    END AS email
FROM customer;

创建只读角色并授权:

CREATE ROLE support_readonly LOGIN PASSWORD 'use-a-secret-manager';
GRANT USAGE ON SCHEMA public TO support_readonly;
GRANT SELECT ON customer_support_view TO support_readonly;
REVOKE ALL ON customer FROM support_readonly;

预期结果类似:

customer_id | full_name | phone        | email
------------+-----------+--------------+----------------
1           | 张三      | ******5678   | z***@example.com

这里有几个重要边界:

  1. 应用使用的连接角色必须确实是 support_readonly,否则视图没有保护作用。
  2. 不应同时授予该角色对原表的 SELECT
  3. 视图中的 full_name 仍然可能是个人数据,脱敏一个字段不代表整行安全。
  4. 邮箱和手机号格式不同时,固定字符串截取可能泄露长度或导致错误;规则应按业务风险设计。
  5. 视图只是数据库层控制,导出的结果、日志和缓存仍需单独治理。

PostgreSQL 的列级权限可以进一步限制可读列,但列级权限不等同于值级脱敏。需要按行或按当前用户动态判断时,可以使用行级安全策略(Row-Level Security,RLS),但策略设计、角色绕过能力和表所有者权限必须单独验证。

3. MySQL 中的视图示例

MySQL 8.4 支持视图和授权机制,也可以构造类似的脱敏层:

CREATE TABLE customer (
    customer_id BIGINT PRIMARY KEY,
    full_name   VARCHAR(100) NOT NULL,
    phone       VARCHAR(32) NOT NULL,
    email       VARCHAR(255) NOT NULL
);

CREATE VIEW customer_support_view AS
SELECT
    customer_id,
    full_name,
    CASE
        WHEN CHAR_LENGTH(phone) >= 7
        THEN CONCAT(
            REPEAT('*', CHAR_LENGTH(phone) - 4),
            RIGHT(phone, 4)
        )
        ELSE '****'
    END AS phone,
    CASE
        WHEN LOCATE('@', email) > 1
        THEN CONCAT(
            LEFT(email, 1),
            '***',
            SUBSTRING(email FROM LOCATE('@', email))
        )
        ELSE '***'
    END AS email
FROM customer;

授权时必须注意视图的安全上下文、定义者和调用者权限配置,以及应用用户是否还能够直接读取基表。不同于 PostgreSQL,不能把一个数据库的角色、RLS 或权限语义直接套到 MySQL。

4. 脱敏函数不应破坏业务约束

脱敏后数据可能不再满足:

  • 唯一性;
  • 格式校验;
  • 外键关联;
  • 时间顺序;
  • 数值分布;
  • 可重复测试。

例如测试系统需要验证“同一个用户的订单能否关联”,随机生成每一行手机号会破坏一致性。更合理的静态脱敏应使用稳定映射:

masked(x)=Fk(x)masked(x) = F_k(x)

其中 FkF_k 使用受保护的密钥或映射表,使同一个输入在同一脱敏批次中得到同一个替代值,但不能从替代值反推出原值。

然而,稳定映射也有风险:攻击者可以观察不同数据集中的相同替代值,从而进行关联分析。因此,是否需要跨表一致性、跨批次一致性,应由测试目的决定,而不是默认开启。


五、最小权限与脱敏必须同时存在

脱敏视图解决的是“拿到查询结果后看到什么”,权限控制解决的是“谁可以发起什么查询”。

一个常见失败配置是:

给客服账号授予原表 SELECT
再额外创建一个脱敏视图
要求客服使用视图

这并没有限制客服账号读取原表。正确关系应当是:

原表:仅数据服务账号可读
脱敏视图:授权客服账号读取

还要检查以下旁路:

  • 是否可以读取系统元数据中的默认值、注释或表达式;
  • 是否可以执行函数间接返回原值;
  • 是否可以访问物化视图、临时导出表;
  • 是否能通过错误信息、排序、过滤和计数推断隐藏值;
  • 是否能读取应用日志、备份或只读副本。

“不能 SELECT phone”不代表“无法推断 phone”。例如一个接口允许按完整手机号查询并返回“是否存在”,攻击者可以批量枚举手机号。值级保密和查询行为都需要考虑。


六、留存:规则必须能被机器执行和人工解释

1. 留存不是“永远保留”或“定时清理”二选一

留存规则至少包含:

数据对象
业务目的
起算事件
留存期限
允许的例外
到期动作
执行频率
验证方式
责任人

例如:

登录审计事件:
起算事件 = 事件发生时间
期限 = 180 天
例外 = 安全事件调查冻结
到期动作 = 删除原始事件,保留按月聚合统计

这里的聚合统计是否可以长期保留,要重新评估识别风险,不能因为“已经聚合”就自动排除隐私影响。

2. 软删除不是物理删除

常见设计:

ALTER TABLE customer
ADD COLUMN deleted_at timestamptz;

查询时加条件:

SELECT *
FROM customer
WHERE deleted_at IS NULL;

这只是业务层不可见。原值仍然存在于:

  • 表数据页;
  • 索引;
  • 只读副本;
  • CDC 消息;
  • 备份;
  • 导出文件;
  • 可能的审计记录。

软删除适合表达“暂时停用”或“保留引用关系”,但如果要求数据不可再使用,还必须定义后续物理删除、匿名化或密钥销毁流程。

3. 到期删除的 SQL 示例

以下 PostgreSQL 示例假定:

  • created_at 是留存起算时间;
  • 记录没有法律保留;
  • 按批次删除以降低长事务和锁影响;
  • 运行任务的角色确实有删除权限。
CREATE TABLE login_event (
    event_id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id        bigint NOT NULL,
    created_at     timestamptz NOT NULL,
    event_type     text NOT NULL,
    legal_hold     boolean NOT NULL DEFAULT false
);

一次删除一批:

WITH candidates AS (
    SELECT event_id
    FROM login_event
    WHERE created_at < now() - interval '180 days'
      AND legal_hold = false
    ORDER BY event_id
    LIMIT 1000
)
DELETE FROM login_event AS e
USING candidates AS c
WHERE e.event_id = c.event_id
RETURNING e.event_id, e.created_at;

执行过程是:

  1. candidates 选出当前到期且未冻结的最多 1000 条记录。
  2. DELETE ... USING 只删除本批候选记录。
  3. RETURNING 返回实际删除的行,便于记录审计结果。
  4. 事务提交后,其他事务才会观察到本批删除结果。

如果还有更多记录,任务应循环执行,并在每批之间提交事务。一次删除数千万行可能造成:

  • 长时间事务;
  • 表和索引膨胀;
  • WAL 或 binlog 迅速增长;
  • 复制延迟;
  • 锁等待;
  • 恢复时间增加。

这不是说分批删除一定没有锁,而是把锁和日志压力限制在可观察的批次内。

MySQL 可以使用相同的候选主键分批思想,例如:

DELETE FROM login_event
WHERE event_id IN (
    SELECT event_id
    FROM (
        SELECT event_id
        FROM login_event
        WHERE created_at < CURRENT_TIMESTAMP - INTERVAL 180 DAY
          AND legal_hold = 0
        ORDER BY event_id
        LIMIT 1000
    ) AS candidates
);

这里增加一层派生表是为了避免同一语句中直接读写目标表时遇到 MySQL 的目标表限制。实际执行还需要结合索引、事务隔离级别和执行计划验证。

4. 索引决定删除任务是否可控

如果删除条件是:

WHERE created_at < ...
  AND legal_hold = false
ORDER BY event_id
LIMIT 1000

应检查是否存在适合的索引。没有索引时,数据库可能反复扫描大量历史数据。索引不是隐私控制,但它决定了删除控制能否稳定运行。

PostgreSQL 可以检查:

EXPLAIN (ANALYZE, BUFFERS)
SELECT event_id
FROM login_event
WHERE created_at < now() - interval '180 days'
  AND legal_hold = false
ORDER BY event_id
LIMIT 1000;

生产环境要关注:

  • 是否使用了预期索引;
  • 是否扫描了大量不符合条件的行;
  • 是否产生排序和临时空间;
  • 是否导致复制延迟;
  • 是否因并发写入反复处理相同范围。

EXPLAIN ANALYZE 会真正执行语句。对删除语句进行计划分析时,应先使用 EXPLAIN,或在明确的测试事务中运行并回滚;不要在生产上无意执行破坏性操作。


七、分区、归档和备份会改变删除路径

1. 按时间分区适合“整段时间到期”的数据

如果日志按月分区,并且整个月份的数据都已到期,那么删除整个分区通常比逐行删除更适合:

DROP TABLE login_event_2024_01;

或在支持分区表的设计中执行相应的分区移除操作。这样可以避免逐行产生大量删除记录。

但它有严格前提:

  • 分区内所有记录都已到期;
  • 没有法律保留记录;
  • 没有外部对象仍把该分区当作在线数据;
  • 下游同步、仓库和备份流程知道该分区已删除;
  • 分区边界与留存起算时间一致。

如果同一分区中既有到期记录又有未到期记录,不能为了效率直接删除整个分区。

2. 归档不是删除

把在线表移动到归档库仍然是保留。归档库同样需要:

  • 分类;
  • 权限;
  • 加密;
  • 审计;
  • 留存;
  • 删除;
  • 恢复验证。

“从生产库删掉,但在归档库永久保留”只是改变了存储位置。

3. 备份中的删除具有延迟语义

假设今天删除了在线库记录,但昨天的全量备份仍然保留 30 天,那么从备份恢复到隔离环境后,旧记录可能重新出现。

这并不意味着备份机制不可用,而是删除策略必须写清楚:

  • 删除是否要求立即清除所有备份;
  • 备份是否采用固定过期周期;
  • 备份是否不可变;
  • 恢复环境是否允许对外提供服务;
  • 恢复后是否必须重新执行删除清单;
  • 加密密钥销毁是否是允许的不可用化措施。

“删除成功”必须定义边界。例如:

在线主库和只读副本:删除任务完成后生效
CDC 与数据仓库:下一个同步窗口内完成
普通备份:按备份过期策略自然淘汰
恢复环境:恢复后执行删除清单,再允许访问

如果业务或适用规则要求备份也必须快速删除,就不能仅依赖自然过期,应设计备份重写、备份分层或密钥管理策略。密钥销毁只能在明确理解加密体系和恢复需求的前提下使用,因为它可能使整批数据都不可恢复,而不是只删除某个主体的数据。


八、删除不是单条 SQL,而是一条传播链

1. 用删除事件传播到派生系统

主库删除后,下游可能通过 CDC 收到删除事件。一个完整事件至少要能表达:

主体或业务对象标识
操作类型 = DELETE
发生时间
来源事务或事件版本
数据分类
幂等键

下游处理必须满足幂等性。若删除事件重复投递,重复执行不应导致错误;若更新事件和删除事件乱序,还需要通过版本号或源事务位置判断哪个状态更新。

可以把对象状态抽象为:

ACTIVE
  ├── 到期 → DELETION_PENDING
  ├── 删除请求 → DELETION_PENDING
  └── 法律保留 → LEGAL_HOLD

DELETION_PENDING
  ├── 所有目标系统完成 → DELETED
  └── 任一系统失败 → RETRY / ESCALATED

只有“主库删除成功”而下游状态仍为 ACTIVE 时,整体删除流程不能标记为完成。

2. 并发条件决定删除是否稳定

假设删除任务在 t1t_1 查询到一条已到期记录,但另一个事务在 t2t_2 更新了它:

  • 如果更新改变了 last_activity_at,记录可能已经不该删除;
  • 如果更新与删除竞争,最终结果取决于锁等待和提交顺序;
  • 如果删除后更新事务基于旧快照继续写入,可能产生重新出现的数据或失败。

因此,删除条件必须在实际 DELETE 时再次判断,而不是先导出 ID,过很久后无条件删除:

DELETE FROM login_event
WHERE event_id = $1
  AND created_at < $2
  AND legal_hold = false;

更稳妥的批量删除会把资格条件放在删除语句本身,而不是只放在预选阶段。

如果业务允许记录在到期后继续更新,应定义“最后活动时间”是否会延长留存;如果不允许,则应在到期状态上加约束,禁止新业务继续使用。

3. 事务边界必须明确

下面两种操作语义不同:

事务 A:删除主库记录,提交
事务 B:发送删除消息,提交

如果事务 A 提交后事务 B 失败,就出现“主库已删、下游未删”。

常见解决方案包括:

  • 事务内写 outbox:删除业务记录的同一事务中写入删除事件;独立投递器可靠发送。
  • CDC:从数据库事务日志读取变更。
  • 重试与对账:下游失败时重试,并定期比较主库删除清单和下游状态。

不能简单假设“执行了数据库删除”就自动通知所有系统。是否能可靠传播,取决于部署架构和事件链路。


九、删除验证:验证结果,不只验证命令成功

1. 主库验证

删除批次至少应记录:

  • 删除任务 ID;
  • 执行时间;
  • 条件版本;
  • 删除行数;
  • 起止主键或时间范围;
  • 是否存在法律保留;
  • 失败原因;
  • 操作者或调度身份。

删除后可以验证:

SELECT count(*)
FROM login_event
WHERE created_at < now() - interval '180 days'
  AND legal_hold = false;

预期结果为 0,但这个查询只覆盖当前表和当前条件。它不能覆盖备份、日志和其他系统。

2. 反向验证下游

对每个下游系统维护删除状态,例如:

subject_id | target_system | requested_at | completed_at | status | error

验证目标不是“下游收到事件”,而是:

  • 记录是否不再可查询;
  • 搜索索引是否删除;
  • 缓存是否失效;
  • 统计或特征表是否仍含可识别关联;
  • 失败任务是否进入重试;
  • 恢复演练后是否会重新暴露原值。

3. 用计数时要认识到聚合泄露

删除前后对比总行数可以发现明显问题,但不能作为唯一证据。因为:

  • 数据可能被转移到另一张表;
  • 记录可能被匿名化但仍保留关联键;
  • 视图可能缓存旧结果;
  • 备份仍可能含原值;
  • 计数相同不代表主体数据相同。

验证应针对数据对象、传播副本和可识别关联分别设计。


十、常见错误与失败表现

错误一:把加密当作最小化

数据库磁盘加密或字段加密可以降低未授权读取风险,但没有解决:

  • 应用是否真的需要该字段;
  • 查询结果是否进入日志;
  • 数据是否复制到更多系统;
  • 到期后是否删除。

如果应用拥有解密密钥,应用被攻破后原值仍可能暴露。

错误二:把哈希当作匿名化

对邮箱执行:

SHA256(email)

并不能保证安全。攻击者可以对常见邮箱地址批量计算哈希并匹配。加盐可以提高攻击成本,密钥哈希或 HMAC 可以减少公开预计算风险,但稳定哈希仍可能被用于跨表关联,也不必然消除个人数据属性。

错误三:只脱敏展示层,不控制导出和接口

前端显示 ******5678,但后端接口仍返回完整手机号,或者导出任务绕过视图读取原表,结果仍然是暴露。

应沿着完整数据流检查:

数据库查询 → ORM 对象 → API 响应 → 日志 → 消息 → 缓存 → 导出

错误四:只做软删除

软删除适合业务恢复和引用保留,但不等于物理删除。需要删除时,必须继续处理软删除行、索引、派生表和备份。

错误五:先导出 ID,稍后无条件删除

示例:

10:00 查询出到期 ID
12:00 逐个 DELETE BY id

两小时内数据可能被更新、冻结或改变留存状态。删除语句应重新检查资格条件,并尽量缩短预选与删除之间的时间。

错误六:用整表扫描执行每日清理

失败表现通常包括:

  • 删除任务运行时间越来越长;
  • 复制延迟增加;
  • WAL 或 binlog 暴增;
  • 锁等待和连接堆积;
  • 表空间没有立即下降。

应结合索引、批量提交、时间分区和数据库负载窗口设计,而不是只增加调度频率。

错误七:忘记错误数据和调试数据

无效手机号、注册失败请求、异常堆栈、SQL 参数、消息死信队列同样可能含个人数据。数据分类和留存策略必须覆盖“失败路径”,否则攻击者或运维人员可能从日志中获得比主表更多的信息。


十一、数据库层面的控制边界

数据库是重要控制点,但不是唯一控制点。

数据库通常能直接控制的内容

  • 表、列、视图的访问权限;
  • 行级访问策略(具体能力依赖引擎和配置);
  • 事务内的一致性;
  • 外键和约束;
  • 审计扩展或日志能力;
  • 数据库内的删除、归档和分区操作;
  • 备份接口与恢复流程的一部分。

数据库通常不能单独保证的内容

  • 应用是否收集了不必要字段;
  • 应用是否把查询参数写入日志;
  • 消息队列和搜索系统是否删除;
  • 操作系统快照是否过期;
  • 第三方 SaaS 是否保留导出文件;
  • 恢复环境是否重新暴露旧数据;
  • 人员是否通过管理员权限绕过业务流程。

因此,应把隐私控制映射到系统边界:

采集端:字段和目的控制
应用层:接口输出和业务授权
数据库:权限、约束、事务和删除
消息系统:事件幂等与重放策略
分析系统:粒度、聚合和查询审计
备份系统:保留、恢复和密钥生命周期
运维系统:日志、工单和操作审计

十二、一个可落地的端到端设计

下面用“客户账号”说明一条完整路径。

1. 在线模型

CREATE TABLE app_user (
    user_id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email            text NOT NULL,
    phone            text,
    account_status   text NOT NULL CHECK (
        account_status IN ('active', 'disabled', 'pending_delete')
    ),
    created_at       timestamptz NOT NULL DEFAULT now(),
    last_activity_at timestamptz NOT NULL DEFAULT now(),
    deletion_requested_at timestamptz,
    legal_hold       boolean NOT NULL DEFAULT false
);

CREATE UNIQUE INDEX app_user_email_uq
ON app_user (lower(email));

这里需要注意:

  • email 的唯一性是业务约束,不代表所有角色都可以读取它;
  • last_activity_at 的含义必须固定,否则留存计算会失真;
  • legal_hold 不应由普通业务用户随意修改;
  • pending_delete 是业务状态,不等于已完成删除。

2. 删除资格

假设账号删除请求后保留 30 天用于撤销,且没有法律保留:

SELECT user_id
FROM app_user
WHERE account_status = 'pending_delete'
  AND deletion_requested_at <
      now() - interval '30 days'
  AND legal_hold = false;

如果业务要求“最后活动后 2 年删除”,则条件可能还要包含:

AND last_activity_at < now() - interval '2 years'

两个期限的关系必须由业务规则明确:是满足任一条件即可删除,还是必须同时满足,不能由实现人员自行推断。

3. 删除与关联数据

若订单表保留 user_id 作为业务关联,删除用户行时可能触发外键冲突。此时有几种不同语义:

  • ON DELETE CASCADE:连订单一起删除,适合订单本身也不需保留的场景;
  • ON DELETE SET NULL:保留订单但解除主体关联;
  • 保留不可识别的业务主体占位记录;
  • 将订单中的个人字段匿名化,同时保留必要的交易事实。

不能因为删除方便就默认使用级联删除。级联删除可能破坏财务、审计或履约记录;反过来,保留外键也可能继续保留可识别关联。

4. 删除任务

在事务内再次确认状态:

BEGIN;

WITH candidates AS (
    SELECT user_id
    FROM app_user
    WHERE account_status = 'pending_delete'
      AND deletion_requested_at <
          now() - interval '30 days'
      AND legal_hold = false
    ORDER BY user_id
    LIMIT 500
)
DELETE FROM app_user AS u
USING candidates AS c
WHERE u.user_id = c.user_id
  AND u.account_status = 'pending_delete'
  AND u.legal_hold = false
RETURNING u.user_id;

COMMIT;

这段 SQL 只删除主表。订单、地址、客服工单、消息和仓库中的关联数据仍需要各自处理。

生产任务还应考虑:

  • 并发删除任务是否会重复选取相同记录;
  • 是否需要 FOR UPDATE SKIP LOCKED,以及当前引擎和事务语义是否满足需求;
  • 下游事件是否在同一事务中记录;
  • 删除失败时是否回滚;
  • 部分下游失败时如何重试和告警。

SKIP LOCKED 可用于多个清理工作者减少相互等待,但它可能跳过暂时锁住的记录,因此任务必须可重复运行,不能把“本轮未选中”误认为“无需删除”。


十三、生产取舍:保留、匿名化还是删除

三种动作的语义不同:

删除

让数据不再作为可用记录存在。适合原始数据已无业务必要、且没有保留义务的情况。

匿名化

保留统计或研究价值,但降低主体关联能力。需要评估重识别风险,尤其是准标识符、稀有记录和外部数据关联。

降级保存

删除高风险原值,仅保留低精度或不可逆派生值。例如删除完整生日,只保留年龄段;删除原手机号,只保留国家和运营商统计信息。

降级保存也可能保留个人数据。例如稳定哈希仍可关联同一主体,不能因为“看起来不是原文”就跳过分类和权限评估。

一个合理的决策顺序是:

是否仍有明确目的?
  ├─ 否 → 删除
  └─ 是
       是否必须保留原值?
         ├─ 否 → 聚合、降精度或假名化
         └─ 是 → 严格权限、加密、审计和期限控制

这个顺序体现了最小化优先:安全措施应降低必要数据的暴露风险,而不是为不必要的数据永久保留寻找理由。


十四、如何审计这套机制是否真的有效

一次完整检查应同时问以下问题:

  1. 数据目录是否能指出每个敏感字段的目的和责任人?
  2. 是否有字段实际上没有被业务使用,却一直被收集?
  3. 测试库、数仓、搜索索引和日志是否含有原值?
  4. 应用角色能否直接读取原表?
  5. 脱敏是否会因错误信息、过滤条件或计数接口被绕过?
  6. 留存起算时间是否来自可信字段?
  7. 删除任务是否可重复、可恢复、可观测?
  8. 删除条件是否在实际删除时再次检查?
  9. CDC、消息队列、缓存和仓库是否处理删除事件?
  10. 备份恢复后是否仍会恢复已删除数据?
  11. 法律保留是否有明确创建、解除和审计流程?
  12. 是否能提供某次删除的执行记录和验证证据?

测试不应只做“查询视图看到星号”。还应使用低权限账号尝试:

  • 读取基表;
  • 访问导出接口;
  • 查询异常路径;
  • 读取日志和缓存;
  • 重放删除事件;
  • 在并发更新下验证最终状态;
  • 从备份恢复到隔离环境后检查删除清单。

结语

数据库隐私治理的核心不是某一个函数、某一条 DELETE,而是把数据从产生到消失的每个状态和传播边界都定义清楚:

  • 分类决定风险和控制强度;
  • 最小化决定系统根本不应持有什么;
  • 脱敏决定不需要原值的角色看到什么;
  • 留存决定何时仍有理由保存;
  • 删除决定到期后如何从主库、派生数据和恢复路径中消除或不可用化。

真正可验证的治理方案,应能回答三类问题:

为什么有这条数据?
谁在什么条件下能看到它?
到期后如何证明它已经不再被系统使用?

如果只能回答其中一部分,数据库安全控制仍然是不完整的。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。