App Engine 中的事务隔离

Max Ross

根据维基百科,数据库管理系统的隔离级别“定义一个操作所做的更改对其他并发操作可见的方式/时间”。本文旨在介绍 App Engine 所使用的 Cloud Datastore 中的查询事务隔离。阅读本文之后,您应该会更好地理解并发读写在事务内和事务外的行为方式。

在事务内:可序列化

按照从高到低的顺序,隔离总共分为四个级别:可序列化、可重复读取、读取已提交、读取未提交。数据存储区事务属于“可序列化”隔离级别。每个事务均完全与所有其他数据存储区事务和操作完全隔离。指定实体组中的事务按顺序依次执行。

请参阅事务文档的隔离和一致性部分以了解详情,另请参阅有关快照隔离的维基百科文章。

在事务外:读取已提交

数据存储区在事务外的操作最接近“读取已提交”隔离级别。通过查询或获取操作从数据存储区中检索的实体只会看到已提交的数据。检索的实体从不包含部分提交数据(即有些数据来自提交前,有些则来自提交后)。查询与事务之间的交互稍微有点微妙,为了理解这种交互,我们需要更深入地了解提交过程。

提交过程

提交成功返回时,系统一定会应用事务,但这并不意味着读取者可以立即看到您的写入结果。应用由两个里程碑组成的事务:

  • 里程碑 A - 系统已更改实体的时间点
  • 里程碑 B - 系统已更改该实体索引的时间点

显示从提交事务到可见实体更改到可见实体和索引更改的进度箭头。

在 Cloud Datastore 中,系统通常会在提交返回后的几百毫秒内完全应用事务。不过,即使系统没有完全应用事务,后续的读取、写入和祖先查询仍将始终反映提交的结果,因为这些操作会在执行之前应用所有尚未应用的修改。但是,跨多个实体组的查询在执行之前无法确定是否有任何尚未应用的修改,并且可能返回过时的结果或部分应用的结果。

到达里程碑 A 后,如果发出按键查找已更新实体的请求,一定会发现该实体的最新版本。不过,如果并发请求所执行的查询的谓词(对于你们这些 SQL/GQL 爱好者来说就是 WHERE 子句)不是由更新前的实体满足的,而是由更新后的实体满足的,则只有在应用操作到达里程碑 B 后执行查询时,实体才会出现在结果集中。

换句话说,在短暂的时段内,结果集可能不包含属性(根据按键查找的结果)满足查询谓词的实体。结果集中也有可能包含其属性不符合查询谓词要求的实体(同样根据按键查找的结果)。在确定要返回的实体时,查询不能考虑里程碑 A 和里程碑 B 之间的事务。系统将针对过时的数据执行查询,但对返回的键执行 get() 操作将始终获取该实体的最新版本。这意味着,在获取相应的实体后,您可能缺少与查询匹配的结果或者得到不匹配的结果。

在某些情况下,系统在执行查询(例如 Cloud Datastore 中的任何祖先查询)之前一定会完全应用所有待处理的修改。在这些情况下,查询结果将始终是最新的、一致的。

示例

我们已概述了并发更新和查询交互的工作方式,但是如果您像我一样,一般会发现通过具体的示例更容易理解这些概念。让我们来看一些示例。我们首先来看一些简单的示例,最后再看一些更有趣的示例。

假设我们有个存储 Person 实体的应用。Person 具有以下属性:

  • 名称
  • 身高

此应用支持以下操作:

  • updatePerson()
  • getTallPeople(),用于返回身高超过 72 英寸(约 183 厘米)的所有人。

我们在数据存储区中有 2 个 Person 实体:

  • Adam,身高 68 英寸(约 173 厘米)。
  • Bob,身高 73 英寸(约 185 厘米)。

示例 1 - 增加 Adam 身高

假设应用在同一时间收到了两个请求。第一个请求将 Adam 的身高从 68 英寸(约 173 厘米)更新至 74 英寸(约 188 厘米)。突然长高!第二个请求调用 getTallPeople()。getTallPeople() 会返回什么呢?

答案取决于由请求 1 触发和由请求 2 执行的 getTallPeople() 查询触发的两个提交里程碑之间的关系。假设关系如下:

  • 请求 1,put()
  • 请求 2,getTallPeople()
  • 请求 1,put()-->commit()
  • 请求 1,put()-->commit()-->里程碑 A
  • 请求 1,put()-->commit()-->里程碑 B

在这种情况下,getTallPeople() 将只返回 Bob。为什么?因为对 Adam 的更新(即增加身高)尚未提交,因此该更改对我们在请求 2 中发起的查询尚不可见。

现在,假设关系如下:

  • 请求 1,put()
  • 请求 1,put()-->commit()
  • 请求 1,put()-->commit()-->里程碑 A
  • 请求 2,getTallPeople()
  • 请求 1,put()-->commit()-->里程碑 B

在这种情况下,查询是在请求 1 到达里程碑 B 之前执行的,因此对 Person 索引的更新尚未应用。因此,getTallPeople() 只会返回 Bob。此示例所给出的结果集将属性满足查询谓词的实体排除在外。

示例 2 - 让 Bob 变矮一点(对不起,Bob)

在此示例中,我们将让请求 1 做另外的事情。它不是将 Adam 的身高从 68 英寸(约 173 厘米)增加至 74 英寸(约 188 厘米),而是将 Bob 的身高从 73 英寸(约 185 厘米)减少至 65 英寸(约 165 厘米)。再来一次,getTallPeople() 会返回什么呢?

回车键?
  • 请求 1,put()
  • 请求 2,getTallPeople()
  • 请求 1,put()-->commit()
  • 请求 1,put()-->commit()-->里程碑 A
  • 请求 1,put()-->commit()-->里程碑 B

在这种情况下,getTallPeople() 将只返回 Bob。为什么?因为对 Bob 的更新(即减少身高)尚未提交,因此该更改对我们在请求 2 中发起的查询尚不可见。

现在,假设关系如下:

  • 请求 1,put()
  • 请求 1,put()-->commit()
  • 请求 1,put()-->commit()-->里程碑 A
  • 请求 1,put()-->commit()-->里程碑 B
  • 请求 2,getTallPeople()

在这种情况下,getTallPeople() 不会返回任何结果。为什么?因为在我们通过请求 2 发起查询时对 Bob 的更新(即减少身高)已提交。

现在,假设关系如下:

  • 请求 1,put()
  • 请求 1,put()-->commit()
  • 请求 1,put()-->commit()-->里程碑 A
  • 请求 2,getTallPeople()
  • 请求 1,put()-->commit()-->里程碑 B

在这种情况下,查询是在到达里程碑 B 之前执行的,因此对 Person 索引的更新尚未应用。因此,getTallPeople() 仍旧会返回 Bob,但是该 Person 实体的身高属性在返回时会是更新后的数值,即 65。此示例所给出的结果集包含属性不满足查询谓词的实体。

总结

从上面的示例中可以看出,Cloud Datastore 的事务隔离级别非常接近于“读取已提交”。当然,两者还是有一些显著的差异,但现在您已充分认识了这些差异及其背后的原因,您在设计应用中的数据存储区时就可以做出更明智的决策。