Design Data-Intensive Applications 读书笔记十九 第七章:事务的单对象和多对象操作

单对象和多对象操作

总的来说,ACID中,原子性和隔离性描述了如果一个客户端在一个事务内做了多个写入,数据库该怎么做?

原子性:如果在执行一系列写入过程中发生了错误,那么事务应该被中止,事务开始至出错时间点的写入都应该被丢弃。换句话说,客户端不需要担心中途出错,保证只有完成或者未完成。

隔离性:并发执行事务不应该与其他交互。例如:如果一个事务做了多个写入,另一个事务应该看到所有写入或者看不到任何写入,不应该是中间值。

这些定义都认为你要同时修改多个对象(行,文档,记录)。如果要操作多个数据库就需要类似的多对象事务。图7-2显示了邮件应用的示例。为了显示用户未读的邮件,你可以做如下查询:

Design Data-Intensive Applications 读书笔记十九 第七章:事务的单对象和多对象操作

但是如果邮件数量很多,那么这个查询会变得很慢,然后决定将未读邮件存储为一个独立的属性。现在,当来了一封新邮件,你需要增加未读邮件计数,当邮件标注已读,你需要减少计数。

图7-2,用户2的体验不寻常:邮件列表展示了一个未读的消息,但是计数器显示显示未读为0,因为计数还未增加。隔离性能防止这类情况:隔离性可以保证用户2要么看见新插入的邮件和更新的计数,要么都看不见,而不是中间状态。

Design Data-Intensive Applications 读书笔记十九 第七章:事务的单对象和多对象操作

图7-3展示了原子性的必要:如果一个错误发生在事务进行的过程中,邮箱内容和未读计数可能不同步。在原子性事务中,如果更新账户失败,丢弃事务,回滚插入的邮件。

Design Data-Intensive Applications 读书笔记十九 第七章:事务的单对象和多对象操作

多对象事务需要一些方法决定哪些读写属于同一事务。在关系型数据库中,一般是使用客户端的TCP连接来确定:在一个连接中,处于 BEGIN TRANSACTION 和COMMIT之间的语句被认为是属于相同事务。

另一方面,很多非关系型数据库没有类似的打包操作。即便是有多对象API(例如,一个键值对存储可能有一个multi-put多存储操作能在一个操作中更新多个key),也不意味着有事务的语义:命令可能在一些key上成功,在其他key上失败,数据库部分更新。

 

单对象写入

在改变单一对象时原子性和隔离性同样适用。例如:如果你有20KB的Json数据要写入数据库:

1、如果在已经发送了10KB的时候,网络中断了,数据库要存储已经接收到的10KB片段吗?

2、如果在覆写旧值的时候,断电了,你会得到部分更新的值吗?

3、如果其他客户端在写入途中读取文档,那么它是否会看到部分更新的值。

要处理这些问题很麻烦,所以存储引擎几乎都在单对象单节点这个层级上提供了原子性。原子性可以使用故障恢复日志实现,隔离性可以给对象加锁来实现。

一些数据库提供更加复杂的原子操作,例如增加操作,移除了图7-1的读取-修改-写入循环。类似的有CAS(compare-and-set)操作,只有在值没有被其他并发操作修改的情况下才能写入。

这些单对象操作很有用,因为它们能防止在多个客户端并发写入相同对象时的更新丢失。但是它们不属于一般的"事务"的范畴内。cas操作和其他的单对象操作一般是因为商业目的被称为“轻量级事务”甚至“ACID”,这属于误导。事务一般被理解为将多对象的多个操作打包成一个执行单元的机制。

 

对多对象事务的需求

很多分布式数据库抛弃了多对象事务,因为很难实现跨分区的事务,并且事务在某些需要高可用或者性能的场景下会造成妨害。但是没有规定分布式数据库不能使用事务,我们在第九章讨论分布式事务。

但是我们需要多对象事务吗?有没有可能只使用一个键值数据模型和单对象操作来实现一个应用?

很多场景使用单对象的增删改是足够的。但是很多场景下,需要结合使用多对象写入。

1、在关系型数据模型中,表格中一行经常有外键指向其他表。(类似的,在图数据结构中,一个顶点也有向量指向其他顶点。)多对象事务允许你确保那些引用仍然有效:当插入多个指向其他记录的记录时,外键需要被更新,否则数据无意义。

2、在文档数据模型中,如果是在一个文档中更新多个字段,会认为是单对象事务而不是多对象事务。但是文档数据库缺少join操作,经常鼓励反向规范化。当如图7-2中的例子,反向规范化的信息需要更新时,你需要一次更新多个文档。这种情况下,事务能防止反规范化的数据失去同步。

3、在有第二索引的数据库中,索引需要在每次更新值的时候更新。这些索引从事务的角度看都是不同对象:例如,没有事务隔离,一条记录可能出现在某个索引中,而消失在其他索引中,因为更新第二索引的操作还没发生。

类似的应用也可以不使用事务。但是因为没有原子性,缺失隔离性,错误处理会变得很复杂。

 

处理错误和丢弃操作

事务的关键特性就是如果发生了错误可以丢弃然后安全地重试。ACID数据库都遵从这样的理念:如果可能违背原子性,隔离性或者持久性的保证,那么宁愿废弃整个事务也不会允许在其在中途结束。

不是所有的系统都遵从这种理念。特别是无主节点数据库遵从的是“最大努力”(best effort),数据库会尽可能做到最多,如果发生错误,不会撤销掉已经做过的事情。所以从错误中恢复是应用要做的事情。

错误不可避免,但是很多开发者倾向于至考虑理想情况“ happy path”,而不是繁杂的错误处理。例如,一个ORM框架( ActiveRecord或者 Django),在废弃事务后不会重试,错误经常描述为一个异常然后抛出,因此用户的输入都会被丢弃然后返回一个错误消息。这让人难堪,因为丢弃的目的就是为了安全重试。

尽管重试废弃的事务是个简单有效的错误处理机制,但是它不完美:

1、如果事务实际上成功了,但是因为服务端返回成功信息时,网络故障,因而客户端判定事务失败,那么会再次重试事务——除非在应用端有去重的机制。

2、如果是因为工作负载超载而失败,那么重试会让事情更糟糕。为了避免反馈循环,你可以限制重试次数,使用指数补偿 exponential backoff和使用区别于其他错误的不同的方法处理过负载。

3、只有事务失败值得重试,其他永久错误(如违反约束),不值得重试。

4、如果事务在数据库外有副作用,这些副作用在事务废弃时也可能发生。例如,如果你要发送一封邮件,你不会想在每次重试事务时发送邮件。如果你想确保多种不同的系统能够一起提交和丢弃,可以使用两种格式的提交。

5、如果客户端在重试时处理错误,任何试图写入数据库的数据都会丢失。