数据安全不等于“全部保存”,也不等于“全部删除”。更可行的设计,是让临时数据在 72 小时后自动消失,让确有必要的核心证据进入不可篡改的存证层,同时把高敏感信息留在用户本地。
这套分级存储思路可以概括为三个目标:隐私数据最小留存、关键证据最大可信、在线系统保持轻量。真正的难点不在于配置一个 TTL,而在于证明数据按时删除、存证内容没有被替换,并且每一次读取和变更都有审计记录。
先分类,再决定数据去向
建议把数据分成四类,而不是让所有业务表共用一套保留策略。
| 数据层级 | 典型内容 | 建议策略 | 关键控制 |
|---|---|---|---|
| 临时数据 | 上传中间件、解析结果、一次性会话 | 最长保留 72 小时 | TTL、异步清理、删除审计 |
| 业务数据 | 订单状态、任务记录、账号配置 | 按业务和法规期限保存 | 权限隔离、加密、备份 |
| 核心证据 | 签署结果、关键操作凭证、内容摘要 | 进入不可篡改存证层 | 哈希、时间戳、对象锁、审计日志 |
| 本地敏感数据 | 私钥、原始身份材料、用户私密文件 | 尽量只保存在用户设备 | 本地加密、明确授权、可导出删除 |
分类时需要设置几条绝对边界:私钥、口令明文和完整支付凭证不得进入普通日志;原始敏感材料不能因为“方便排查”被复制到分析系统;存证层只保存证明事实所需的最小内容,不能成为绕过删除机制的长期数据仓库。
“永久存证”也不应简单理解为无限期保存全部原文。更稳妥的实现通常保存事件摘要、签名、发生时间、主体标识和必要的证据对象,并为不同对象配置经过法务确认的保留期限。删除权、法定留存义务和诉讼保全发生冲突时,应由明确的策略和审批流程处理。
72 小时清理不能只依赖定时任务
TTL 是临时数据的第一道保障,但不能是唯一保障。一个完整的清理链路至少需要以下机制:
- 写入时生成服务器端
expiresAt,禁止直接信任客户端提交的过期时间。 - 数据库通过 TTL 索引执行最终删除。
- 定时巡检任务发现超过期限但尚未删除的数据。
- 对对象存储、缓存和搜索索引执行同步清理。
- 审计系统记录删除批次、数量和失败原因,但不复制已删除的敏感正文。
下面是一个可以本地运行的 MongoDB TTL 示例。运行前需要安装 Docker;示例使用 30 秒过期时间便于观察,生产环境将其改为 72 小时。
# 启动临时 MongoDB
docker run --rm -d \
--name ttl-demo \
-p 27017:27017 \
mongo:7
# 等待数据库启动
sleep 5
# 创建 TTL 索引并写入测试记录
docker exec ttl-demo mongosh --quiet --eval '
const db = db.getSiblingDB("security_demo");
db.temporary_payloads.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0, name: "delete_at_expiry" }
);
db.temporary_payloads.insertOne({
requestId: "req-demo-001",
payload: { status: "temporary" },
createdAt: new Date(),
expiresAt: new Date(Date.now() + 30 * 1000)
});
printjson(db.temporary_payloads.findOne({ requestId: "req-demo-001" }));
'
# TTL 扫描不是实时触发,等待约 90 秒后检查
sleep 90
docker exec ttl-demo mongosh --quiet --eval '
const db = db.getSiblingDB("security_demo");
printjson({ remaining: db.temporary_payloads.countDocuments({}) });
'
生产环境中的 72 小时应由服务端计算:
const TTL_MS = 72 * 60 * 60 * 1000;
function buildTemporaryRecord(requestId, payload) {
const createdAt = new Date();
return {
requestId,
payload,
createdAt,
expiresAt: new Date(createdAt.getTime() + TTL_MS)
};
}
需要注意,MongoDB TTL 后台任务并不保证记录在到期瞬间删除。因此系统的读取接口还应增加逻辑过期判断:只返回 expiresAt > now 的记录。这样即使物理删除稍有延迟,过期数据也不会继续暴露给业务调用方。
备份同样属于留存边界。主库删除一条记录,不代表快照、日志归档和灾备副本已经消失。备份周期、备份加密、恢复权限和介质销毁时间必须纳入同一份数据保留策略。
永久存证要证明“内容未变”
存证记录至少应包含业务对象标识、事件类型、发生时间、内容哈希、签名算法版本和证据对象位置。敏感原文是否进入存证层,需要单独评估;许多场景只需要保存规范化内容的哈希。
可以这样生成一个可重复验证的 SHA-256 摘要:
#!/usr/bin/env python3
import hashlib
import json
from datetime import datetime, timezone
def canonical_json(value: dict) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
event = {
"event_id": "evt-20250308-001",
"event_type": "agreement.confirmed",
"subject_id": "user-123",
"occurred_at": datetime.now(timezone.utc).isoformat(),
"document_version": 3,
}
payload = canonical_json(event)
record = {
"event": event,
"hash_algorithm": "SHA-256",
"content_hash": hashlib.sha256(payload).hexdigest(),
}
print(json.dumps(record, ensure_ascii=False, indent=2))
哈希只能发现内容变化,不能单独证明是谁提交、何时提交,也无法阻止攻击者同时替换正文和哈希。生产方案还需要数字签名、可信时间源、密钥轮换,以及支持 WORM 或 Object Lock 的存储介质。审计日志最好进入独立安全域,业务管理员不应同时拥有修改业务数据和清除审计记录的权限。
审计事件可以采用以下最小结构:
{
"eventId": "audit-01J...",
"occurredAt": "2025-03-08T10:30:00Z",
"actorId": "service-cleaner",
"action": "temporary_data.deleted",
"resourceType": "temporary_payload",
"resourceIdHash": "sha256:...",
"policyId": "retention-temporary-v1",
"result": "success",
"traceId": "trace-..."
}
这里使用资源标识的哈希,是为了避免审计系统再次收集一份可直接识别用户的数据。是否允许哈希化标识、是否需要加盐,应结合标识符的可枚举性和实际合规要求评估。
法规依据必须落到可执行策略
“符合法规”不能只写在架构图旁边。每条留存策略都应关联适用范围、处理目的、法律依据、保留期限、责任人、删除方式和例外审批。法规会因地区、行业和数据类型不同而变化,摘要没有提供具体条文,因此实施时应由法务或数据保护负责人确认逐条映射,避免把示例期限当成普遍法律结论。
上线前可以按下面的清单验收:
- 数据目录已经标出临时、业务、证据和本地数据。
- 72 小时从哪个事件开始计算有明确定义。
- 读取接口实施逻辑过期,数据库实施物理过期。
- 缓存、搜索索引、对象存储和备份都有对应清理策略。
- 存证对象具备哈希、签名、可信时间和不可篡改控制。
- 审计管理员与业务管理员完成职责分离。
- 每条策略能够追溯到审批记录和适用法规。
- 定期执行删除演练、恢复演练和存证完整性校验。
分级存储的价值不在于多建几个数据库,而在于让每类数据都有清楚的生命周期。72 小时清理解决的是最小留存,永久存证解决的是证据可信,本地存储解决的是数据边界;只有三者与审计、备份和法规映射一起实施,系统才能真正做到可验证、可追责。