《互联网产品的 AI 架构·平台化与治理》8.2 留存、删除与审计

删除请求发出后,主库删掉不等于数据消失:缓存、日志、检索索引、评测集和备份都可能是副本。本节定义各存储的留存上限与删除传播时限,用确定性回执检查每条路径,生成可复核的删除证明,并给出审计记录的最小字段与校验规则,避免删了主库却留下副本而无人察觉。

主库删了,数据没有消失

一位用户要求删除自己的私信记录。客服系统执行了 DELETE FROM messages,返回成功。三天后做抽查,同一个用户的原文仍然能在四个地方找到:对话缓存里有一份 7 天的热数据,网关日志里有一份脱敏不彻底的请求体,检索索引里有一条分片副本,评测集里有一段被抽作回归样本的会话。

备份里还有一份,只是没人去查。审计同学问了一句「怎么证明已经删干净」,团队拿不出任何记录,只有一条数据库操作日志。

AI 链路的删除比传统业务更难,因为同一份内容会被复制成很多形态:原文、摘要、向量、评测样本、回放包。删除请求如果只覆盖主库,剩下的副本会继续存在,而且每一份都足以重新识别用户。本节把删除从一次数据库操作变成一条可检查的传播链。

所有天数与时限都是示例口径,不是法规结论;生产值要由合规评审确认后写入配置。

先定留存,再谈删除

删除的上游是留存。如果一份数据本来就不该留 90 天,讨论「90 天后怎么删」没有意义。示例给每个存储一个上限,同时受数据级别上限约束,取两者更严的一条。

存储留存上限删除时限能否延期
主库 primary30 天24 小时否
缓存 cache7 天24 小时否
日志 log30 天72 小时否
检索索引 search-index30 天72 小时否
评测集 eval-set180 天168 小时否
备份 backup90 天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 供应商合规与模型变更通知 。

常见错误与边界

  • 把主库删除当成删除完成。缓存、日志、索引、评测集与备份都可能是完整副本。
  • 删除请求不带目标清单。执行时靠记忆,漏掉哪个存储无人知道。
  • 回执没有完成时间。无法与时限比较,「已删除」变成不可验证的声明。
  • 用「稍后处理」代替延期。没有到期日的延期不是删除证明。
  • 备份延期超过留存上限。到期日只是把违规推迟,不是消除。
  • 审计记录保存原始内容。审计库会变成第二个泄露源。
  • 审计记录只写成功。没有未完成项与问题码,无法复核失败路径。
  • 只检查是否完成,不检查是否超时。删得太慢同样是治理缺陷。
  • 删除证明用随机数或时间戳。同一请求每次得到不同证明,无法比对。
  • 把示例天数当成法规。留存与时限必须由合规评审确认,并按数据级别配置。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查