主库删了,数据没有消失
一位用户要求删除自己的私信记录。客服系统执行了 DELETE FROM messages,返回成功。三天后做抽查,同一个用户的原文仍然能在四个地方找到:对话缓存里有一份 7 天的热数据,网关日志里有一份脱敏不彻底的请求体,检索索引里有一条分片副本,评测集里有一段被抽作回归样本的会话。
备份里还有一份,只是没人去查。审计同学问了一句「怎么证明已经删干净」,团队拿不出任何记录,只有一条数据库操作日志。
AI 链路的删除比传统业务更难,因为同一份内容会被复制成很多形态:原文、摘要、向量、评测样本、回放包。删除请求如果只覆盖主库,剩下的副本会继续存在,而且每一份都足以重新识别用户。本节把删除从一次数据库操作变成一条可检查的传播链。
所有天数与时限都是示例口径,不是法规结论;生产值要由合规评审确认后写入配置。
先定留存,再谈删除
删除的上游是留存。如果一份数据本来就不该留 90 天,讨论「90 天后怎么删」没有意义。示例给每个存储一个上限,同时受数据级别上限约束,取两者更严的一条。
| 存储 | 留存上限 | 删除时限 | 能否延期 |
|---|---|---|---|
| 主库 primary | 30 天 | 24 小时 | 否 |
| 缓存 cache | 7 天 | 24 小时 | 否 |
| 日志 log | 30 天 | 72 小时 | 否 |
| 检索索引 search-index | 30 天 | 72 小时 | 否 |
| 评测集 eval-set | 180 天 | 168 小时 | 否 |
| 备份 backup | 90 天 | 720 小时 | 是,必须给到期日 |
评测集的 180 天看起来很长,但它只保存经过筛选的样本,不承载线上流量。即便如此,一旦样本里含用户原文,它仍然受数据级别上限约束:敏感数据的上限是 30 天,评测集不能因为「它是评测集」就豁免。checkRetention 会同时检查存储上限与级别上限,两个都通过才算合规。
备份的例外需要说清楚。不可变备份通常无法按单条记录删除,只能等整个备份过期。工程上的诚实做法不是假装已删,而是登记一条「延期删除」,写明到期时间,并保证到期时间不超过备份留存上限。任何没有到期日的「稍后处理」都不算删除证明。
删除要沿五条路径传播
删除请求的输入不是一张表,而是一组「资产 × 存储」的目标。示例里一条私信会出现在主库、缓存、日志、检索索引与评测集;一次删除要覆盖五个存储,外加备份的延期记录。
| 路径 | 为什么容易漏 | 检查点 |
|---|---|---|
| 缓存 | TTL 未到就仍可读,键可能不含用户 id | 按内容哈希或租户前缀失效 |
| 日志 | 请求体、响应体、错误堆栈各有一份 | 结构化字段与自由文本都要扫 |
| 检索索引 | 分片副本、向量库、倒排表分别存储 | 按文档 id 删除并刷新段 |
| 评测集 | 样本被复制进回归集,来源字段可能已丢 | 按来源回放 id 反查 |
| 备份 | 不可变,无法单条删除 | 登记到期日,到期后验证 |
「主库删除成功」只是一个起点。checkDeletion 按目标逐项查找回执,缺回执就记为未完成;状态为失败或挂起同样记为未完成;只有删除、或备份带确定到期日的延期,才计入完成。
时限按存储分别计算:缓存与主库 24 小时,日志与索引 72 小时,评测集 168 小时,备份 720 小时。超时不一定代表数据还在,但一定代表无法证明它已经不在了。审计要的是证明,不是推测。
回执、证明与审计记录
删除的可复核性来自三样东西:每个存储的回执、一份对回执的规范化哈希、以及一条不可篡改的审计记录。
回执回答「这个存储说它删了没有」。它必须带资产 id、存储类型、状态与完成时间;备份的延期还要带到期时间。没有完成时间的 deleted 不算数,因为它无法与时限比较。
证明回答「这次删除的证据有没有被改过」。deletionProof 把请求 id、主体引用、发起时间与所有相关回执按固定顺序拼接,再做 SHA-256。同一组输入必须得到同一个证明;任何一条回执状态变化,证明随之变化。证明本身不证明数据已删,它证明的是「当时提交的证据是这一组」。
审计记录回答「谁在什么时候因为什么删了什么」。它保存主体引用而不是原始账号,保存证明而不是原始内容,保存覆盖的存储列表而不是每一行数据。validateAuditEntry 会拒绝包含原始主体 id 的记录,也会拒绝没有证明、证明格式错误、记录时间早于请求时间、未完成却没有未完成项、以及没有存储覆盖列表的记录。
可运行的删除检查
下面的模块把留存、传播、证明与审计放在一起。它不访问任何真实存储,只对回执做确定性判定,因此可以直接进单元测试与发布门禁。
// file: src/gov/retention.ts
// tier: L2
// verify: node --test test/gov/compliance.test.ts
import { createHash } from "node:crypto";
import { EGRESS_POLICY } from "./compliance.ts";
import type { DataClass } from "./compliance.ts";
export type StoreKind = "primary" | "cache" | "log" | "search-index" | "eval-set" | "backup";
export const HOUR_MS = 3_600_000;
export const DAY_MS = 24 * HOUR_MS;
/** deferAllowed 表示不可变存储可登记延期删除,但必须给出确定的到期时间。 */
export interface RetentionRule { maxDays: number; deletable: boolean; slaHours: number; deferAllowed: boolean }
export const RETENTION_RULES: Record<StoreKind, RetentionRule> = {
primary: { maxDays: 30, deletable: true, slaHours: 24, deferAllowed: false },
cache: { maxDays: 7, deletable: true, slaHours: 24, deferAllowed: false },
log: { maxDays: 30, deletable: true, slaHours: 72, deferAllowed: false },
"search-index": { maxDays: 30, deletable: true, slaHours: 72, deferAllowed: false },
"eval-set": { maxDays: 180, deletable: true, slaHours: 168, deferAllowed: false },
backup: { maxDays: 90, deletable: false, slaHours: 720, deferAllowed: true },
};
export const STORE_KINDS = Object.keys(RETENTION_RULES) as StoreKind[];
export interface StoreRetention { store: StoreKind; retentionDays: number }
export interface AssetRetention { assetId: string; dataClass: DataClass; createdAt: number; stores: readonly StoreRetention[] }
export type RetentionIssueCode = "unknown-store" | "duplicate-store" | "retention-too-long" | "retention-exceeds-class";
export interface RetentionIssue { assetId: string; store: StoreKind | ""; code: RetentionIssueCode; detail: string }
/** 每条留存声明同时受存储上限与数据级别上限约束,取两者更严的一条。 */
export function checkRetention(assets: readonly AssetRetention[]): RetentionIssue[] {
const issues: RetentionIssue[] = [];
for (const asset of assets) {
const seen = new Set<StoreKind>();
const classCap = EGRESS_POLICY[asset.dataClass].maxRetentionDays;
for (const item of asset.stores) {
const rule = RETENTION_RULES[item.store];
if (rule === undefined) {
issues.push({ assetId: asset.assetId, store: item.store, code: "unknown-store", detail: `${item.store} is not a registered store` });
continue;
}
if (seen.has(item.store)) issues.push({ assetId: asset.assetId, store: item.store, code: "duplicate-store", detail: `${item.store} appears twice` });
seen.add(item.store);
if (item.retentionDays > rule.maxDays) issues.push({ assetId: asset.assetId, store: item.store, code: "retention-too-long", detail: `${item.retentionDays}d exceeds store cap ${rule.maxDays}d` });
if (item.retentionDays > classCap) issues.push({ assetId: asset.assetId, store: item.store, code: "retention-exceeds-class", detail: `${item.retentionDays}d exceeds ${asset.dataClass} cap ${classCap}d` });
}
}
return issues;
}
export interface PurgeDue { assetId: string; store: StoreKind; expiresAt: number }
export function purgeDue(assets: readonly AssetRetention[], now: number): PurgeDue[] {
const due: PurgeDue[] = [];
for (const asset of assets) {
for (const item of asset.stores) {
if (RETENTION_RULES[item.store] === undefined) continue;
const expiresAt = asset.createdAt + item.retentionDays * DAY_MS;
if (expiresAt <= now) due.push({ assetId: asset.assetId, store: item.store, expiresAt });
}
}
return due;
}
export type ReceiptStatus = "deleted" | "pending" | "failed" | "deferred";
export interface DeletionReceipt { assetId: string; store: StoreKind; status: ReceiptStatus; completedAt?: number; expiresAt?: number }
export interface DeletionTarget { assetId: string; stores: readonly StoreKind[] }
export interface DeletionRequest { requestId: string; subjectRef: string; issuedAt: number; targets: readonly DeletionTarget[] }
export type DeletionIssueCode =
| "missing-receipt" | "pending" | "failed" | "deleted-without-time" | "sla-missed"
| "deferred-not-allowed" | "deferred-without-expiry" | "backup-expiry-too-late";
export interface DeletionIssue { assetId: string; store: StoreKind | ""; code: DeletionIssueCode; detail: string }
export interface DeletionReport {
requestId: string; complete: boolean; withinSla: boolean; required: number;
deleted: number; deferred: number; outstanding: number;
issues: DeletionIssue[]; proofId: string; storesCovered: StoreKind[];
}
function relevant(request: DeletionRequest, receipts: readonly DeletionReceipt[]): DeletionReceipt[] {
return receipts.filter((receipt) => request.targets.some((target) => target.assetId === receipt.assetId && target.stores.includes(receipt.store)));
}
/** 删除证明对请求与被覆盖的存储回执做规范化哈希,同一输入必须得到同一证明。 */
export function deletionProof(request: DeletionRequest, receipts: readonly DeletionReceipt[]): string {
const lines = relevant(request, receipts)
.map((receipt) => `${receipt.assetId}/${receipt.store}/${receipt.status}/${receipt.completedAt ?? 0}/${receipt.expiresAt ?? 0}`)
.sort();
const canonical = [request.requestId, request.subjectRef, String(request.issuedAt), ...lines].join("|");
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
}
/** 删除必须覆盖主库、缓存、日志、检索索引、评测集与备份;主库删除不等于传播完成。 */
export function checkDeletion(request: DeletionRequest, receipts: readonly DeletionReceipt[], now: number): DeletionReport {
const issues: DeletionIssue[] = [];
const covered = new Set<StoreKind>();
let required = 0, deleted = 0, deferred = 0, outstanding = 0, withinSla = true;
for (const target of request.targets) {
for (const store of target.stores) {
required += 1;
covered.add(store);
const rule = RETENTION_RULES[store];
if (rule === undefined) {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "missing-receipt", detail: `${store} is not a registered store` });
continue;
}
const deadline = request.issuedAt + rule.slaHours * HOUR_MS;
const receipt = receipts.find((item) => item.assetId === target.assetId && item.store === store);
if (receipt === undefined) {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "missing-receipt", detail: `no deletion receipt for ${store}` });
if (now > deadline) {
withinSla = false;
issues.push({ assetId: target.assetId, store, code: "sla-missed", detail: `nothing recorded by deadline ${deadline}` });
}
continue;
}
if (receipt.status === "deleted") {
if (receipt.completedAt === undefined) {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "deleted-without-time", detail: "deleted without a completion timestamp" });
continue;
}
deleted += 1;
if (receipt.completedAt > deadline) {
withinSla = false;
issues.push({ assetId: target.assetId, store, code: "sla-missed", detail: `completed ${receipt.completedAt} after deadline ${deadline}` });
}
continue;
}
if (receipt.status === "pending") {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "pending", detail: "deletion pending" });
if (now > deadline) {
withinSla = false;
issues.push({ assetId: target.assetId, store, code: "sla-missed", detail: `still pending after deadline ${deadline}` });
}
continue;
}
if (receipt.status === "failed") {
outstanding += 1;
withinSla = false;
issues.push({ assetId: target.assetId, store, code: "failed", detail: "deletion failed" });
continue;
}
if (!rule.deferAllowed) {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "deferred-not-allowed", detail: `${store} cannot defer deletion` });
continue;
}
if (receipt.expiresAt === undefined) {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "deferred-without-expiry", detail: "deferred backup has no expiry" });
continue;
}
const latest = request.issuedAt + rule.maxDays * DAY_MS;
if (receipt.expiresAt > latest) {
outstanding += 1;
issues.push({ assetId: target.assetId, store, code: "backup-expiry-too-late", detail: `expiry ${receipt.expiresAt} beyond ${latest}` });
continue;
}
deferred += 1;
}
}
return {
requestId: request.requestId, complete: outstanding === 0, withinSla, required, deleted, deferred, outstanding,
issues, proofId: deletionProof(request, receipts), storesCovered: STORE_KINDS.filter((store) => covered.has(store)),
};
}
/** 审计主体只保存不可逆的引用,不保存原始账号、手机号或邮箱。 */
export function pseudonymizeSubject(rawId: string): string {
return `subj_${createHash("sha256").update(rawId).digest("hex").slice(0, 16)}`;
}
export interface AuditEntry {
requestId: string; subjectRef: string; issuedAt: number; recordedAt: number;
required: number; deleted: number; deferred: number; outstanding: number;
complete: boolean; proofId: string; storesCovered: readonly StoreKind[];
}
export function buildAuditEntry(request: DeletionRequest, report: DeletionReport, recordedAt: number): AuditEntry {
return {
requestId: request.requestId, subjectRef: request.subjectRef, issuedAt: request.issuedAt, recordedAt,
required: report.required, deleted: report.deleted, deferred: report.deferred,
outstanding: report.outstanding, complete: report.complete, proofId: report.proofId, storesCovered: report.storesCovered,
};
}
export type AuditIssueCode =
| "raw-subject-id" | "missing-proof" | "invalid-proof" | "recorded-before-issued"
| "incomplete-without-outstanding" | "missing-store-coverage";
export interface AuditIssue { requestId: string; code: AuditIssueCode; detail: string }
export function validateAuditEntry(entry: AuditEntry, rawSubjectId: string): AuditIssue[] {
const issues: AuditIssue[] = [];
const push = (code: AuditIssueCode, detail: string): void => { issues.push({ requestId: entry.requestId, code, detail }); };
if (entry.subjectRef === rawSubjectId || entry.subjectRef.includes(rawSubjectId)) push("raw-subject-id", "subjectRef contains the raw subject identifier");
if (entry.proofId.trim() === "") push("missing-proof", "deletion proof is required");
else if (!/^sha256:[0-9a-f]{64}$/.test(entry.proofId)) push("invalid-proof", "proof must be a sha256 digest");
if (entry.recordedAt < entry.issuedAt) push("recorded-before-issued", "recorded before request");
if (!entry.complete && entry.outstanding === 0) push("incomplete-without-outstanding", "incomplete entry must list outstanding items");
if (entry.storesCovered.length === 0) push("missing-store-coverage", "entry must name the stores it covered");
return issues;
}
checkDeletion 的返回值里有两个容易混淆的布尔量。complete 表示是否所有目标都已删除或已登记有界延期;withinSla 表示是否在各自时限内完成。一次删除可以 complete=true 但 withinSla=false:数据最终删掉了,但主库花了 30 小时,超过了 24 小时。审计要同时记录这两个值,否则「删了」会掩盖「删得太慢」。
示例样本上的运行结果
示例请求包含三个资产、五个目标:私信的主库、缓存、日志,评测集的一条样本,以及一份备份。回执全部按时提交,备份登记 60 天后到期。在示例样本上运行检查,结果如下。
| 指标 | 结果 |
|---|---|
| 必需目标数 | 5 |
| 已删除 | 4 |
| 延期删除 | 1 |
| 未完成 | 0 |
| 是否完成 | 是 |
| 是否在时限内 | 是 |
| 覆盖存储 | 主库、缓存、日志、评测集、备份 |
故障样本更能说明判据。删掉日志回执后,未完成变为 1,问题码为「缺回执」;在 101 小时检查时,日志的 72 小时时限已过,withinSla 变为 false。把日志状态改成失败,未完成仍为 1,且 withinSla 立即为 false。把主库完成时间改到 30 小时,数据算删了,但 withinSla 为 false,问题码为「超时」。
延期也有边界。把评测集标成延期,会得到「该存储不允许延期」;备份延期不给到期日,会得到「延期缺到期日」;备份到期日给到 120 天,超过 90 天上限,会得到「备份到期过晚」。三种情况都计入未完成,不会因为写了「延期」就放行。
证明的确定性可以直接断言:同一请求与同一组回执得到同一个 sha256: 值;把日志回执从已删除改成挂起,证明立即不同。审计记录用主体引用 subj_ 加 16 位哈希,原始手机号不会出现在记录里。
审计记录的最小字段
| 字段 | 作用 | 缺失后的表现 |
|---|---|---|
| 请求 id | 关联工单与回执 | 无法回答「这次删除对应哪条请求」 |
| 主体引用 | 定位数据主体,不暴露原始 id | 要么无法关联,要么记录本身成为泄露源 |
| 发起时间 | 计算各存储时限 | 无法判断是否超时 |
| 记录时间 | 证明审计发生在请求之后 | 记录可被倒填 |
| 目标与完成数 | 说明覆盖范围与进度 | 只能看到「成功」两个字 |
| 未完成项 | 解释为什么未完成 | 未完成记录无法复核 |
| 删除证明 | 固定证据集合 | 回执可被替换而无人发现 |
| 覆盖存储 | 防止只删主库 | 缓存、日志、评测集被漏掉 |
这张表里没有「原始内容」。审计记录不应该保存被删除的数据,否则删除本身失去意义。它保存的是「删除发生过」的证据,而不是「删了什么」的副本。
一条可执行的删除流程
第一步,受理与定级。记录请求 id、主体引用、发起时间,按 8.1 的级别确定每个资产适用的留存上限。
第二步,生成目标清单。按资产查主库、缓存、日志、检索索引、评测集与备份,形成「资产 × 存储」的目标列表。清单要落盘,不能只存在于任务内存里。
第三步,逐存储执行。每个存储返回回执,包含状态与完成时间;备份返回延期与到期日。失败要重试并记录重试次数,不能静默跳过。
第四步,判定与补删。checkDeletion 返回未完成项,按存储类型分派补删;超过时限的项升级为事故,而不是继续等。
第五步,生成证明与审计记录。对回执做规范化哈希,写入审计记录,校验主体引用不含原始 id。
第六步,到期复核。备份到期后重新检查一次,把「延期」改成「已删除」,并更新证明。延期不是终点,只是带日期的中间状态。
小结
删除不是一条 SQL,而是一条传播链。示例用六个存储、五个目标说明:主库、缓存、日志、检索索引、评测集与备份各有留存上限与删除时限,级别上限与存储上限取更严者。checkDeletion 按目标查回执,缺回执、失败、挂起都算未完成;只有删除或带确定到期日的备份延期才算完成。
示例样本上,五个目标全部按时完成:已删除 4、延期 1、未完成 0。缺日志回执会留下 1 个未完成项;主库 30 小时完成会得到「完成但超时」。删除证明是对请求与回执的规范化 SHA-256,同一输入稳定复现,回执变化则证明变化。审计记录保存主体引用、时间、覆盖存储与证明,不保存原始内容。
阅读导航:上一节:8.1 数据分级与出域 · 下一节:8.3 供应商合规与模型变更通知 。
常见错误与边界
- 把主库删除当成删除完成。缓存、日志、索引、评测集与备份都可能是完整副本。
- 删除请求不带目标清单。执行时靠记忆,漏掉哪个存储无人知道。
- 回执没有完成时间。无法与时限比较,「已删除」变成不可验证的声明。
- 用「稍后处理」代替延期。没有到期日的延期不是删除证明。
- 备份延期超过留存上限。到期日只是把违规推迟,不是消除。
- 审计记录保存原始内容。审计库会变成第二个泄露源。
- 审计记录只写成功。没有未完成项与问题码,无法复核失败路径。
- 只检查是否完成,不检查是否超时。删得太慢同样是治理缺陷。
- 删除证明用随机数或时间戳。同一请求每次得到不同证明,无法比对。
- 把示例天数当成法规。留存与时限必须由合规评审确认,并按数据级别配置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。