你的备份正在悄悄复活你删掉的用户
每一个增量备份都默默做着同一个假设:要备份变化了的东西,就去找变化了的行。在 Azure Table Storage 或 DynamoDB 这类键值存储上,“变化了的”意味着“最后修改时间戳比我上一个水位线更新的行”。扫一遍表,抓出所有更新的行,写出去。快、便宜、正确。
对写入来说是正确的。现在,删一行试试。
这一行不会得到“已删除”的标记,不会获得更新的时间戳,也不会进入回收站。它只是不复存在。这些存储没有删除标记,也没有针对删除的变更流,所以被删的行留下的恰恰是一无所有。你的增量备份靠扫描更新的时间戳来发现变化,它径直扫过那行数据曾经所在的空位,什么也没找到。本来就没有东西可找。这次删除是隐形的。
这意味着你的备份里仍然存着那一行。而正是在这里,一个数据丢失的故事变成了一个安全故事。
想想在认证系统里,删除通常意味着什么。你删掉那个用户不是闹着玩的。你办理了一名员工的离职。你移除了一个密码出现在泄露数据包里的账号。你在令牌泄露后吊销了一个 OAuth 授权。你在一位管理员离开的当天收回了他的权限。这里每一件都是安全决策,而在一个看不见删除的备份面前,它们一个都活不下来。
于是你执行恢复。也许是真遇上了灾难,也许只是要克隆一套预发环境。备份不折不扣地照办:把它知道的每一行都放了回去。办完离职的员工又成了用户。泄露过的密码又能用了。吊销过的授权又生效了。被移除的管理员又有了管理员权限。你的恢复没有丢数据,它悄无声息地撤销了你的安全决策,交还给你一个看起来健康、实则已被暗中攻陷的系统。一个会忘记删除的备份比没有备份更糟,因为你信任它。
解决办法是:不再把删除当成“一行数据的缺席”,而是把它当成一个事件。删除也是数据。
所以我们给删除单独建了一张表。系统里的每一次删除都要经过注入的 ITombstoneWriter,由它记下一块墓碑:键,以及它死去的时间。不存在删了行却不留墓碑的代码路径,因为删除和墓碑是同一个操作。这样一来,增量备份就是在同一个水位线下捕获的两部分:upsert(时间戳更新的行)和墓碑(上一个水位线以来的删除)。恢复时按时间顺序重放两者,删除就像写入一样被应用,先删后建的键会解析为最后发生的那个状态。那块空位,终于有了一条属于它的记录。
窍门就这么多,而且很小。但它重要的原因,一点也不小。
这个模式的适用范围远不止 Table Storage 和 DynamoDB。任何把删除建模为“这行没了”的系统,都无法备份、复制或同步删除,因为没有东西可以传递。它出现在只重放创建和更新的天真事件重放里,出现在只会过期、从不主动失效的缓存里,出现在因为删除从未传播而逐渐漂移的只读副本里。而它最危险的地方,恰恰是那些靠删除来落实安全的场景,在身份系统里这意味着无处不在:吊销、离职、轮换、锁定。如果你的持久化方案只追踪存在的东西,它就会忠实地保存下那些你费尽力气让其不存在的东西。
所以,用一个问题来审计你自己的备份:如果我现在删除一个用户,做一次备份,再恢复它,那个用户还在吗?如果诚实的答案是“我不确定”,那你的备份就是一台开错了方向的时间机器。它不是在保护你免受错误的伤害,而是在复活那些错误:你轮换掉的密码、你办完离职的员工、你吊销掉的令牌。
删除也是数据。请照此备份。
如果你希望备份天生就尊重删除,而不用你自己去证明这一点,那正是平台存在的意义。Authagonal 为每一次删除记录墓碑,所以恢复永远不会带回一个已吊销的凭证。