流与数据库

​ 我们已经在消息代理和数据库之间进行了一些比较。尽管传统上它们被视为单独的工具类别,但是我们看到基于日志的消息代理已经成功地从数据库中获取灵感并将其应用于消息传递。我们也可以反过来:从消息传递和流中获取灵感,并将它们应用于数据库。

​ 我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是写入数据库。某些东西被写入数据库的事实是可以被捕获,存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。

​ 事实上,复制日志(参阅“复制日志的实现”)是数据库写入事件的流,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。

​ 我们还在“全序广播”中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将达到相同的最终状态 (假设处理一个事件是一个确定性的操作)。这是事件流的又一种场景!

​ 在本节中,我们将首先看看异构数据系统中出现的一个问题,然后探讨如何通过将事件流的想法带入数据库来解决这个问题。

保持系统同步

​ 正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储,查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用OLTP数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引搜索处理搜索查询,使用数据仓库用于分析。每一个组件都有自己的数据副本,以自己的表示存储,并根据自己的目的进行优化。

​ 由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存,搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由ETL进程执行(参见“数据仓库”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在“批量工作流的输出”中同样看到了如何使用批处理创建搜索索引,推荐系统和其他衍生数据系统。

​ 如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是双写(dual write),其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。

​ 但是,双写有一些严重的问题,其中一个是竞争条件,如图11-4所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。

流与数据库 - 图1

图11-4 在数据库中X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达

​ 除非有一些额外的并发检测机制,例如我们在“检测并发写入”中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。

​ 双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(参阅“原子提交和两阶段提交(2PC)”)。

​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在图11-4中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“多领导者复制“)。

​ 如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?

变更数据捕获

​ 大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的API。客户端应该通过其数据模型和查询语言来查询数据库,而不是解析复制日志并尝试从中提取数据。

​ 数十年来,许多数据库根本没有记录在档的,获取变更日志的方式。由于这个原因,捕获数据库中所有的变更,然后将其复制到其他存储技术(搜索索引,缓存,数据仓库)中是相当困难的。

​ 最近,人们对变更数据捕获(change data capture, CDC) 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。

​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如图11-5所示。

流与数据库 - 图2

图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统

变更数据捕获的实现

​ 我们可以将日志消费者叫做衍生数据系统,正如在第三部分的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是记录系统数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。

​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了图11-2的重新排序问题)。

​ 数据库触发器可用来实现变更数据捕获(参阅“基于触发器的复制”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如应对模式变更。

​ LinkedIn的Databus 【25】,Facebook的Wormhole 【26】和Yahoo!的Sherpa【27】大规模地应用这个思路。 Bottled Water使用解码WAL的API实现了PostgreSQL的CDC 【28】,Maxwell和Debezium通过解析binlog对MySQL做了类似的事情【29,30,31】,Mongoriver读取MongoDB oplog 【32,33】 ,而GoldenGate为Oracle提供类似的功能【34,35】。

​ 像消息代理一样,变更数据捕获通常是异步的:记录数据库系统不会等待消费者应用变更再进行提交。这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现(参见“复制延迟问题”)。

初始快照

​ 如果你拥有所有对数据库进行变更的日志,则可以通过重放该日志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重放过于费时,因此日志需要被截断。

​ 例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前上的“设置新的从库”中所述。

​ 数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些CDC工具集成了这种快照功能,而其他工具则把它留给你手动执行。

日志压缩

​ 如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但日志压缩(log compaction) 提供了一个很好的备选方案。

​ 我们之前在日志结构存储引擎的上下文中讨论了“Hash索引”中的日志压缩(参见图3-2的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。

​ 在日志结构存储引擎中,具有特殊值NULL(墓碑(tombstone))的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。

​ 在基于日志的消息代理与变更数据捕获的上下文中也适用相同的想法。如果CDC系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。

​ 现在,无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题0偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从CDC源数据库取一个快照。

​ Apache Kafka支持这种日志压缩功能。正如我们将在本章后面看到的,它允许消息代理被当成持久性存储使用,而不仅仅是用于临时消息。

变更流的API支持

​ 越来越多的数据库开始将变更流作为第一类的接口,而不像传统上要去做加装改造,费工夫逆向工程一个CDC。例如,RethinkDB允许查询订阅通知,当查询结果变更时获得通知【36】,Firebase 【37】和CouchDB 【38】基于变更流进行同步,该变更流同样可用于应用。而Meteor使用MongoDB oplog订阅数据变更,并改变了用户接口【39】。

​ VoltDB允许事务以流的形式连续地从数据库中导出数据【40】。数据库将关系数据模型中的输出流表示为一个表,事务可以向其中插入元组,但不能查询。已提交事务按照提交顺序写入这个特殊表,而流则由该表中的元组日志构成。外部消费者可以异步消费该日志,并使用它来更新衍生数据系统。

​ Kafka Connect 【41】致力于将广泛的数据库系统的变更数据捕获工具与Kafka集成。一旦变更事件进入Kafka中,它就可以用于更新衍生数据系统,比如搜索索引,也可以用于本章稍后讨论的流处理系统。

事件溯源

​ 我们在这里讨论的想法和事件溯源( Event Sourcing) 之间有一些相似之处,这是一个在 领域驱动设计(domain-driven design, DDD) 社区中折腾出来的技术。我们将简要讨论事件溯源,因为它包含了一些关于流处理系统的有用想法。

​ 与变更数据捕获类似,事件溯源涉及到将所有对应用状态的变更 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了几个不同的抽象层次上:

  • 在变更数据捕获中,应用以可变方式(mutable way) 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免图11-4中的竞态条件。写入数据库的应用不需要知道CDC的存在。
  • 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。

事件源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防止应用Bug(请参阅“不可变事件的优点”)。

​ 例如,存储“学生取消选课”事件以中性的方式清楚地表达了单个行为的意图,而副作用“从注册表中删除了一个条目,而一条取消原因被添加到学生反馈表“则嵌入了很多有关稍后数据使用方式的假设。如果引入一个新的应用功能,例如“将位置留给等待列表中的下一个人” —— 事件溯源方法允许将新的副作用轻松地链接至现有事件之后。

​ 事件溯源类似于编年史(chronicle) 数据模型【45】,事件日志与星型模式中的事实表之间也存在相似之处(参阅“星型和雪花型:分析的模式”) 。

​ 诸如Event Store【46】这样的专业数据库已经被开发出来,供使用事件溯源的应用使用,但总的来说,这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用。

从事件日志中派生出当前状态

​ 事件日志本身并不是很有用,因为用户通常期望看到的是系统的当前状态,而不是变更历史。例如,在购物网站上,用户期望能看到他们购物车里的当前内容,而不是他们购物车所有变更的一个仅追加列表。

​ 因此,使用事件溯源的应用需要拉取事件日志(表示写入系统的数据),并将其转换为适合向用户显示的应用状态(从系统读取数据的方式【47】)。这种转换可以使用任意逻辑,但它应当是确定性的,以便能再次运行,并从事件日志中衍生出相同的应用状态。

​ 与变更数据捕获一样,重放事件日志允许让你重新构建系统的当前状态。不过,日志压缩需要采用不同的方式处理:

  • 用于记录更新的CDC事件通常包含记录的完整新版本,因此主键的当前值完全由该主键的最近事件确定,而日志压缩可以丢弃相同主键的先前事件。
  • 另一方面,事件溯源在更高层次进行建模:事件通常表示用户操作的意图,而不是因为操作而发生的状态更新机制。在这种情况下,后面的事件通常不会覆盖先前的事件,所以你需要完整的历史事件来重新构建最终状态。这里进行同样的日志压缩是不可能的。

使用事件溯源的应用通常有一些机制,用于存储从事件日志中导出的当前状态快照,因此它们不需要重复处理完整的日志。然而这只是一种性能优化,用来加速读取,提高从崩溃中恢复的速度;真正的目的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。我们将在“不变性的限制”中讨论这个假设。

命令和事件

​ 事件溯源的哲学是仔细区分事件(event)命令(command)【48】。当来自用户的请求刚到达时,它一开始是一个命令:在这个时间点上它仍然可能可能失败,比如,因为违反了一些完整性条件。应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,则它变为一个持久化且不可变的事件。

​ 例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。 (先前在“容错概念”中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户ID注册的,座位已经预留给特定的顾客。

​ 在事件生成的时刻,它就成为了事实(fact)。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成立,而更改或取消是之后添加的单独的事件。

​ 事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经成为日志中不可变的一部分,并且可能已经被其他消费者看到了。因此任何对命令的验证,都需要在它成为事件之前同步完成。例如,通过使用一个可自动验证命令的可序列化事务来发布事件。

​ 或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如“使用全序广播实现线性一致存储”中所述) 。这种分割方式允许验证发生在一个异步的过程中。

状态,流和不变性

​ 我们在第10章中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。

​ 我们通常将数据库视为应用程序当前状态的存储 —— 这种表示针对读取进行了优化,而且通常对于服务查询而言是最为方便的表示。状态的本质是,它会变化,所以数据库才会支持数据的增删改。这又是如何符合不变性的呢?

​ 只要你的状态发生了变化,那么这个状态就是这段时间中事件修改的结果。例如,当前可用的座位列表是已处理预订产生的结果,当前帐户余额是帐户中的借与贷的结果,而Web服务器的响应时间图,是所有已发生Web请求的独立响应时间的聚合结果。

​ 无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— 变化日志(change log),表示了随时间演变的状态。

​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如图11-6所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。state(now) = \int_{t=0}^{now}{stream(t) \ dt} \stream(t) = \frac{d\ state(t)}{dt}流与数据库 - 图3

图11-6 应用当前状态与事件流之间的关系

​ 如果你持久存储了变更日志,那么重现状态就非常简单。如果你认为事件日志是你的记录系统,而所有的衍生状态都从它派生而来,那么系统中的数据流动就容易理解的多。正如帕特·赫兰(Pat Helland)所说的【52】:

事务日志记录了数据库的所有变更。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。

​ 日志压缩(如“日志压缩”中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。

不可变事件的优点

​ 数据库中的不变性是一个古老的概念。例如,会计在几个世纪以来一直在财务记账中应用不变性。一笔交易发生时,它被记录在一个仅追加写入的分类帐中,实质上是描述货币,商品或服务转手的事件日志。账目,比如利润、亏损、资产负债表,是从分类账中的交易求和衍生而来【53】。

​ 如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误,例如退还一比不正确的费用。不正确的交易将永远保留在分类帐中,对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布,那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。

​ 尽管这种可审计性在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如“批处理输出的哲学”中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。

​ 不可变的事件也包含了比当前状态更多的信息。例如在购物网站上,顾客可以将物品添加到他们的购物车,然后再将其移除。虽然从履行订单的角度,第二个事件取消了第一个事件,但对分析目的而言,知道客户考虑过某个特定项而之后又反悔,可能是很有用的。也许他们会选择在未来购买,或者他们已经找到了替代品。这个信息被记录在事件日志中,但对于移出购物车就删除记录的数据库而言,这个信息在移出购物车时可能就丢失【42】。

从同一事件日志中派生多个视图

​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样(图11-5):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“保持系统同步”)。

​ 添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的,针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。

​ 如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计,索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(参见第3章)。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为命令查询责任分离(command query responsibility segregation, CQRS)【42,58,59】。

​ 数据库和模式设计的传统方法是基于这样一种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写入优化的事件日志转换为针对读取优化的应用状态,那么有关规范化和非规范化的争论就变得无关紧要了(参阅“多对一和多对多的关系”):在针对读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程提供了使其与事件日志保持一致的机制。

​ 在“描述负载”中,我们讨论了推特主页时间线,它是特定用户关注人群所发推特的缓存(类似邮箱)。这是针对读取优化的状态的又一个例子:主页时间线是高度非规范化的,因为你的推文与所有粉丝的时间线都构成了重复。然而,扇出服务保持了这种重复状态与新推特以及新关注关系的同步,从而保证了重复的可管理性。

并发控制

​ 事件溯源和变更数据捕获的最大缺点是,事件日志的消费者通常是异步的,所以可能会出现这样的情况:用户会写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。我们之前在在“读己之写”中讨论了这个问题以及可能的解决方案。

​ 一种解决方案是将事件附加到日志时同步执行读取视图的更新。而将这些写入操作合并为一个原子单元需要事务,所以要么将事件日志和读取视图保存在同一个存储系统中,要么就需要跨不同系统进行分布式事务。或者,你也可以使用在“使用全序广播实现线性化存储”中讨论的方法。

​ 另一方面,从事件日志导出当前状态也简化了并发控制的某些部分。许多对于多对象事务的需求(参阅“单对象和多对象操作”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方进行单次写入操作 —— 即将事件附加到日志中 —— 这个还是很容易使原子化的。

​ 如果事件日志与应用状态以相同的方式分区(例如,处理分区3中的客户事件只需要更新分区3中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(参阅“真的的串行执行”)。日志通过在分区中定义事件的序列顺序,消除了并发性的不确定性【24】。如果一个事件触及多个状态分区,那么需要做更多的工作,我们将在第12章讨论。

不变性的限制

​ 许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(参见“索引和快照隔离” )。 Git,Mercurial和Fossil等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。

​ 永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。一些工作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较高的更新/删除率;在这些情况下,不可变的历史可能增至难以接受的巨大,碎片化可能成为一个问题,压缩与垃圾收集的表现对于运维的稳健性变得至关重要【60,61】。

​ 除了性能方面的原因外,也可能有出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在用户关闭帐户后删除他们的个人信息,数据保护立法可能要求删除错误的信息,或者可能需要阻止敏感信息的意外泄露。

​ 在这种情况下,仅仅在日志中添加另一个事件来指明先前的数据应该被视为删除是不够的 —— 你实际上是想改写历史,并假装数据从一开始就没有写入。例如,Datomic管这个特性叫切除(excision) 【62】,而Fossil版本控制系统有一个类似的概念叫避免(shunning) 【63】。

​ 真正删除数据是非常非常困难的【64】,因为副本可能存在于很多地方:例如,存储引擎,文件系统和SSD通常会向一个新位置写入,而不是原地覆盖旧数据【52】,而备份通常是特意做成不可变的,防止意外删除或损坏。删除更多的是“使取回数据更困难”,而不是“使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在“立法与自律”中所看到的。