

本文属于机器翻译版本。若本译文内容与英语原文存在差异，则一律以英文原文为准。

# Neptune 中的事务语义
<a name="transactions"></a>

Amazon Neptune 旨在支持针对数据图形的高度并发的在线事务处理 (OLTP) 工作负载。[适用于 RDF 的 W3C SPARQL 查询语言](https://www.w3.org/TR/rdf-sparql-query/)规范和 [Apache TinkerPop Gremlin Graph Traversal Language 文档并未定义并发查询处理的事务语](http://tinkerpop.apache.org/gremlin.html)义。由于 ACID 支持和良好定义的事务保证非常重要，因此，我们实施了严格的语义来帮助避免数据异常。

本部分定义这些语义，并介绍它们如何应用于 Neptune 中的一些常用案例。

**Topics**
+ [隔离级别定义](transactions-isolation-levels.md)
+ [Neptune 中的事务隔离级别](transactions-neptune.md)
+ [Neptune 事务语义示例](transactions-examples.md)

# 隔离级别定义
<a name="transactions-isolation-levels"></a>

`ACID` 中的“I”代表*隔离*。事务的隔离程度决定了其他并发事务对其所操作的数据的影响程度。

[SQL:1992 标准](http://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt)制定了用于描述隔离级别的词汇表。它定义了两个并发事务 `Tx1` 和 `Tx2` 之间可能发生的三种类型的交互（称为*现象*）：
+ `Dirty read` – 当 `Tx1` 修改某个项目，然后 `Tx2` 在 `Tx1` 提交更改前读取该项目时，将发生这种情况。之后，如果 `Tx1` 始终未成功提交该更改或将其回滚，则 `Tx2` 读取的值从未进入数据库。
+ `Non-repeatable read` – 当 `Tx1` 读取某个项目，然后 `Tx2` 修改或删除该项目并提交更改，随后 `Tx1` 尝试重新读取该项目时，将发生这种情况。此时，`Tx1` 将读取到与以前不同的值，或发现该项目已不存在。
+ `Phantom read` – 当 `Tx1` 读取一组满足搜索条件的项目，然后 `Tx2` 添加一个满足搜索条件的新项目，随后 `Tx1` 重复该搜索时，将发生这种情况。此时，`Tx1` 将获得与之前不同的项目集。

这三种交互类型中的任何一种都可能导致数据库中的最终数据不一致。

SQL:1992 标准定义了四个隔离级别，这些隔离级别在三种交互类型及其可能产生的不一致方面具有不同的保证。在所有四个级别上，都可以保证事务完整执行或根本不执行：
+ `READ UNCOMMITTED` – 允许所有三种交互类型（即脏读、不可重复读以及幻读）。
+ `READ COMMITTED` – 不可能出现脏读，但可能出现不可重复读和幻读。
+ `REPEATABLE READ` – 不可能出现脏读和不可重复读，但仍可能出现幻读。
+ `SERIALIZABLE` – 三种交互现象均不会发生。

多版本并发控制 (MVCC) 允许另一种隔离，即*快照*隔离。这保证事务对事务开始时就存在的数据快照进行操作，并且没有任何其他事务可以更改该快照。

# Neptune 中的事务隔离级别
<a name="transactions-neptune"></a>

Amazon Neptune 为只读查询和突变查询实现了不同的事务隔离级别。基于以下标准，SPARQL 和 Gremlin 查询被划分为只读查询或更改查询：
+ 在 SPARQL 中，读取查询（`SELECT`、`ASK`、`CONSTRUCT` 和 `DESCRIBE`，如 [SPARQL 1.1 Query Language](https://www.w3.org/TR/sparql11-query/) 规范中所定义）和突变查询（`INSERT` 和 `DELETE`，如 [SPARQL 1.1 Update](https://www.w3.org/TR/sparql11-update/) 规范中所定义）之间有明显的区别。

  请注意，Neptune 将一起提交的多个突变查询（例如，在 `POST` 消息中，以分号分隔）视为单个事务。它们作为原子单位保证成功或失败，在失败的情况下，会回滚部分更改。
+ 但是，在 Gremlin 中，Neptune 根据查询是否包含操纵数据的任何查询路径步骤（例如 `addE()`、`addV()`、`property()` 或 `drop()`）将查询分类为只读查询或突变查询。如果查询包含任何此类路径步骤，则将其分类为更改查询并执行。

还可以在 Gremlin 中使用长期会话。有关更多信息，请参阅 [基于 Gremlin 脚本的会话](access-graph-gremlin-sessions.md)。在这些会话中，所有查询（包括只读查询）都是在与写入器端点上的突变查询相同的隔离条件下执行的。

在 openCypher 中使用 bolt 读写会话，所有查询（包括只读查询）都是在与突变查询相同的隔离条件下在写入器端点上执行的。

**Topics**
+ [Neptune 中的只读查询隔离](#transactions-neptune-read-only)
+ [Neptune 中的突变查询隔离](#transactions-neptune-mutation)
+ [使用锁定等待超时解决冲突](#transactions-neptune-conflicts)
+ [范围锁定和虚假冲突](#transactions-neptune-false-conflicts)

## Neptune 中的只读查询隔离
<a name="transactions-neptune-read-only"></a>

Neptune 根据快照隔离语义计算只读查询。也就是说，只读查询以逻辑方式对在查询评估开始时拍摄的数据库一致性快照进行操作。然后，Neptune 可以保证不会发生以下任何现象：
+ `Dirty reads` – Neptune 中的只读查询绝不会看到并发事务中未提交的数据。
+ `Non-repeatable reads` – 多次读取相同数据的只读事务将始终返回相同的值。
+ `Phantom reads` – 只读事务绝不会读取到在该事务开始后添加的数据。

由于快照隔离是使用多版本并发控制 (MVCC) 实现的，只读查询不需要锁定数据，因此不会阻止更改查询。

只读副本仅接受只读查询，因此所有针对只读副本的查询均按照 `SNAPSHOT` 隔离语义执行。

查询只读副本时，唯一需要考虑的其他问题是，写入副本和只读副本之间可能会有较小的复制滞后。这意味着对写入器进行的更新可能需要一个较短的时间才能传播到您正在读取的只读副本。实际复制时间取决于针对主实例的写入负载。Neptune 架构支持低延迟复制，复制延迟是在 Amazon 指标中进行衡量的。 CloudWatch 

但是，由于隔离级别为 `SNAPSHOT`，读取查询看到的始终是数据库的一致性状态（即使不是最新的状态）。

如果需要强力保证查询看到的是之前更新的结果，请将查询发送到写入器终端节点本身而不是只读副本。

## Neptune 中的突变查询隔离
<a name="transactions-neptune-mutation"></a>

在更改查询中进行的读取按照 `READ COMMITTED` 事务隔离执行，这排除了脏读的可能性。除了为 `READ COMMITTED` 事务隔离提供通常的保证之外，Neptune 还提供不会发生 `NON-REPEATABLE` 或 `PHANTOM` 读取的强力保证。

这些强力保证是通过在读取数据时锁定记录和记录范围实现的。这可防止并发事务在被读取后的索引范围内进行插入或删除，从而保证可重复读取。

**注意**  
但是，并发更改事务 `Tx2` 可在更改事务 `Tx1` 开始后开始，并且可在 `Tx1` 锁定并读取数据前提交更改。在这种情况下，`Tx1` 将看到 `Tx2` 的更改，就像 `Tx2` 在 `Tx1` 开始之前已经完成一样。由于这仅适用于已提交的更改，因此绝不会发生 `dirty read`。

要了解 Neptune 用于突变查询的锁定机制，首先了解 Neptune [图形数据模型](feature-overview-data-model.md)和[索引策略](feature-overview-storage-indexing.md)的细节会有所帮助。Neptune 使用三个索引来管理数据，即 `SPOG`、`POGS` 和 `GPSO`。

为了实现 `READ COMMITTED` 事务级别的可重复读取，Neptune 将对正在使用的索引进行范围锁定。例如，如果更改查询读取名为 `person1` 的顶点的所有属性和出边，则该节点将在读取数据前锁定由 `SPOG` 索引中的 `S=person1` 前缀定义的整个范围。

使用其他索引时，将应用相同的机制。例如，当更改事务使用 `POGS` 索引在所有源-目标顶点对中查找给定边缘标签时，将锁定 `P` 位置中该边缘标签的范围。任何并发事务，不管是只读查询还是更改查询，都仍可在锁定范围内执行读取。但是，涉及在锁定的前缀范围内插入或删除新记录的任何更改都需要排他锁，并且将被阻止。

换句话说，当更改事务已读取索引范围时，可以强力保证在该读取事务结束之前，任何并发事务都不会修改该范围。这可保证不会出现 `non-repeatable reads`。

## 使用锁定等待超时解决冲突
<a name="transactions-neptune-conflicts"></a>

如果第二个事务试图在第一个事务已锁定的范围内修改记录，Neptune 会立即检测到该冲突并阻止第二个事务。

如果未检测到依赖性死锁，Neptune 将自动应用锁定等待超时机制，被阻止的事务等待最多 60 秒，以便持有该锁的事务完成并释放锁。
+ 如果锁定等待超时在释放锁前到期，则回滚被阻止的事务。
+ 如果在锁定等待超时之内释放了锁，则第二个事务将被解除阻止，并且可以成功完成而无需重试。

但是，如果 Neptune 检测到两个事务之间存在依赖性死锁，则无法自动协调冲突。在这种情况下，Neptune 将立即取消并回滚这两个事务之一，而不会启动锁定等待超时。Neptune 会尽最大努力回滚插入或删除的记录数最少的事务。

### 测量锁定等待时间（发动机 ≥ 1.4.5.0）
<a name="transactions-neptune-lock-wait-metrics"></a>

从引擎版本 1.4.5.0 开始，你可以使用两个计数器准确观察突变查询被屏蔽了多长时间： slow-query-log


| 计数器 | 说明 | 
| --- | --- | 
| `sharedLocksWaitTimeMillis` | 等待获取共享 (S) 锁所花费的时间，共享 (S) 锁允许多个读取器但会屏蔽写入器。 | 
| `exclusiveLocksWaitTimeMillis` | 等待获得专用 (X) 锁所花费的时间，这些锁会阻止所有其他访问。 | 

只有在`debug`模式 () `neptune_enable_slow_query_log=debug` 中启用慢查询日志记录时，这两个字段才会出现在`storageCounters`对象中。

**提示**  
如果`sharedLocksWaitTimeMillis + exclusiveLocksWaitTimeMillis`接近查询`overallRunTimeMs`，则查询会受到锁争用（而不是 CPU、网络或 I/O）的瓶颈。

减少争夺的实用技巧：
+ **错开相互冲突的作业** — 在用户活动较少的时期进行大量批量突变。
+ 将较@@ **大的突变分成较小的区块** ——较小的交易锁定时间更短，从而减少超时的几率。

## 范围锁定和虚假冲突
<a name="transactions-neptune-false-conflicts"></a>

Neptune 使用间隙锁定来进行范围锁定。间隙锁定是对索引记录之间间隙的锁定，或者是对第一条索引记录之前或最后一条索引记录之后间隙的锁定。

Neptune 使用所谓的字典表将数字 ID 值与特定的字符串文本关联起来。以下是此类 Neptune 字典的示例状态：表：


| 字符串 | ID | 
| --- | --- | 
| 类型 | 1 | 
| default\$1graph | 2 | 
| person\$13 | 3 | 
| person\$11 | 5 | 
| knows | 6 | 
| person\$12 | 7 | 
| age | 8 | 
| edge\$11 | 9 | 
| lives\$1in | 10 | 
| New York | 11 | 
| 人员 | 12 | 
| Place | 13 | 
| edge\$12 | 14 | 

上面的字符串属于属性图模型，但这些概念同样适用于所有 RDF 图形模型。

SPOG (Subject-Predicate-Object\$1Graph) 索引的相应状态如下图左侧所示。右侧显示了相应的字符串，以帮助理解索引数据的含义。


| S (ID) | P (ID) | O (ID) | G (ID) |  | S（字符串） | P（字符串） | O（字符串） | G（字符串） | 
| --- | --- | --- | --- | --- | --- | --- | --- | --- | 
| 3 | 1 | 12 | 2 |  | person\$13 | 类型 | 人员 | default\$1graph | 
| 5 | 1 | 12 | 2 |  | person\$11 | 类型 | 人员 | default\$1graph | 
| 5 | 6 | 3 | 9 |  | person\$11 | knows | person\$13 | edge\$11 | 
| 5 | 8 | 40 | 2 |  | person\$11 | age | 40 | default\$1graph | 
| 5 | 10 | 11 | 14 |  | person\$11 | lives\$1in | New York | edge\$12 | 
| 7 | 1 | 12 | 2 |  | person\$12 | 类型 | 人员 | default\$1graph | 
| 11 | 1 | 13 | 2 |  | New York | 类型 | Place | default\$1graph | 

现在，如果突变查询读取名为 `person_1` 的顶点的所有属性和外出边缘，则该节点将在读取数据前锁定由 SPOG 索引中的 `S=person_1` 前缀定义的整个范围。范围锁定将在所有匹配的记录和第一条不匹配的记录上设置间隙锁定。匹配的记录将被锁定，不匹配的记录不会被锁定。Neptune 会按如下方式放置间隙锁定：
+ ` 5 1 12 2 `*（间隙 1）*
+ ` 5 6 3 9 `*（间隙 2）*
+ ` 5 8 40 2 `*（间隙 3）*
+ ` 5 10 11 14 `*（间隙 4）*
+ ` 7 1 12 2 `*（间隙 5）*

这将锁定以下记录：
+ ` 5 1 12 2`
+ ` 5 6 3 9`
+ ` 5 8 40 2`
+ ` 5 10 11 14`

在此状态下，以下操作被合理阻止：
+ 为 `S=person_1` 插入新的属性或边缘。不同于 `type` 或新边缘的新属性必须进入间隙 2、间隙 3、间隙 4 或间隙 5，所有这些都被锁定。
+ 删除任何现有记录。

同时，一些并发操作会被错误地阻止（生成虚假冲突）：
+ `S=person_3` 的任何属性或边缘插入都会被阻止，因为它们必须进入间隙 1。
+ 任何分配了一个 3 到 5 之间的 ID 的新顶点插入都将被阻止，因为它必须进入间隙 1。
+ 任何分配了一个 5 到 7 之间的 ID 的新顶点插入都将被阻止，因为它必须进入间隙 5。

间隙锁定不够精确，无法锁定一个特定谓词的间隙（例如，锁定谓词 `S=5` 的 gap5）。

范围锁定只放在读取发生的索引中。在上述情况下，记录仅锁定在 SPOG 索引中，而不锁定在 POGS 或 GPSO 中。可以对所有索引执行查询读取，具体取决于访问模式，可以使用 `explain` APIs （对于 [Sparql 和 Gre](sparql-explain-examples.md) [m](gremlin-explain.md) lin）列出访问模式。

**注意**  
也可以使用间隙锁定来安全地并发更新底层索引，这也可能导致虚假冲突。这些间隙锁定的放置与隔离级别或事务执行的读取操作无关。

虚假冲突不仅发生在*并发*事务由于间隙锁定而发生冲突时，而且在某些情况下，当事务在任何类型的失败后重试时，也会发生。如果失败所触发的回滚仍在进行中，并且之前为该事务采取的锁定尚未完全释放，则重试将遇到虚假冲突并失败。

在高负载下，您通常会发现 3-4% 的写入查询由于虚假冲突而失败。对于外部客户端，此类虚假冲突很难预测，应使用[重试](transactions-exceptions.md)来处理。

# Neptune 事务语义示例
<a name="transactions-examples"></a>

以下示例说明了 Amazon Neptune 中事务语义的不同用例。

**Topics**
+ [有条件地插入属性](#transactions-examples-conditional-insertion)
+ [属性值的唯一性](#transactions-examples-unique-property)
+ [有条件地更改属性](#transactions-examples-conditional-edit)
+ [替换属性](#transactions-examples-replace)
+ [避免悬垂元素](#transactions-examples-dangling)

## 示例 1 – 仅在不存在属性时插入属性
<a name="transactions-examples-conditional-insertion"></a>

假设您要确保某个属性仅设置一次。例如，假设有多个查询同时尝试为某人指定信用评分。您只希望插入该属性的一个实例，而其他查询因该属性已设置而失败。

```
# GREMLIN:
g.V('person1').hasLabel('Person').coalesce(has('creditScore'), property('creditScore', 'AAA+'))

# SPARQL:
INSERT { :person1 :creditScore "AAA+" .}
WHERE  { :person1 rdf:type :Person .
         FILTER NOT EXISTS { :person1 :creditScore ?o .} }
```

Gremlin `property()` 步骤插入具有给定键和值的属性。`coalesce()` 步骤在第一步中执行第一个参数，如果失败，则执行第二步：

在为给定 `person1` 顶点的 `creditScore` 属性插入值之前，事务必须尝试读取 `person1` 可能不存在的 `creditScore` 值。此尝试的读取将锁定 `SPOG` 索引中 `S=person1` 和 `P=creditScore` 的 `SP` 范围，其中 `creditScore` 值要么存在，要么会被写入。

进行该范围锁定可防止任何并发事务同时插入 `creditScore` 值。当存在多个并行事务时，某一时刻它们中只有一个可以更新该值。这可排除创建多个 `creditScore` 属性的异常。

## 示例 2 – 断言某个属性值是全局唯一的
<a name="transactions-examples-unique-property"></a>

假设您要插入一个使用社会保险号作为主键的人员。您希望更改查询，以确保在全局范围内，数据库中没有其他人具有相同的社会保险号：

```
# GREMLIN:
g.V().has('ssn', 123456789).fold()
  .coalesce(__.unfold(),
            __.addV('Person').property('name', 'John Doe').property('ssn', 123456789'))

# SPARQL:
INSERT { :person1 rdf:type :Person .
         :person1 :name "John Doe" .
         :person1 :ssn 123456789 .}
WHERE  { FILTER NOT EXISTS { ?person :ssn 123456789 } }
```

该示例与上一个示例相似。主要区别在于范围锁定是在 `POGS` 索引而不是 `SPOG` 索引上进行的。

执行查询的事务必须读取模式 `?person :ssn 123456789`，其中绑定了 `P` 和 `O` 位置。范围锁定是在 `P=ssn` 和 `O=123456789` 的 `POGS` 索引上进行的。
+ 如果存在该模式，则不采取任何操作。
+ 如果不存在该模式，锁将阻止任何并发事务也插入该社会保险号

## 示例 3 – 如果其它属性具有指定值，则更改属性
<a name="transactions-examples-conditional-edit"></a>

假设游戏中的各种事件将一个人从第一关移动到第二关，并为他们分配一个设置为零的新 `level2Score` 属性。您需要确保此类事务的多个并发实例无法创建第二关得分属性的多个实例。Gremlin 和 SPARQL 中的查询可能如下所示。

```
# GREMLIN:
g.V('person1').hasLabel('Person').has('level', 1)
 .property('level2Score', 0)
 .property(Cardinality.single, 'level', 2)

# SPARQL:
DELETE { :person1 :level 1 .}
INSERT { :person1 :level2Score 0 .
         :person1 :level 2 .}
WHERE  { :person1 rdf:type :Person .
         :person1 :level 1 .}
```

在 Gremlin 中，指定了 `Cardinality.single` 时，`property()` 步骤将添加新属性，或将现有属性值替换为指定的新值。

对属性值的任何更新（例如将 `level` 从 1 增加到 2）都实现为删除当前记录并插入具有新属性值的新记录。在这种情况下，将删除第 1 关的记录，并重新插入第 2 关的记录。

在增加 `level2Score` 并将 `level` 从 1 更新为 2 之前，事务必须先验证 `level` 值当前等于 1。为此，它需要对 `SPOG` 索引中的 `S=person1`、`P=level` 和 `O=1` 的 `SPO` 前缀进行范围锁定。该锁可防止并发事务删除版本 1 三元组，因此，不会发生冲突性的并发更新。

## 示例 4 – 替换现有属性
<a name="transactions-examples-replace"></a>

某些事件可能会将某人的信用评分更新为新值（此处为 `BBB`）。但是，您想要确保这种类型的并发事件无法为某人创建多个信用评分属性。

```
# GREMLIN:
g.V('person1').hasLabel('Person')
 .sideEffect(properties('creditScore').drop())
 .property('creditScore', 'BBB')

# SPARQL:
DELETE { :person1 :creditScore ?o .}
INSERT { :person1 :creditScore "BBB" .}
WHERE  { :person1 rdf:type :Person .
         :person1 :creditScore ?o .}
```

这种情况与示例 3 相似，不同之处在于，Neptune 不是锁定 `SPO` 前缀，而是仅使用 `S=person1` 和 `P=creditScore` 来锁定 `SP` 前缀。这可防止并发事务插入或删除 `person1` 对象具有 `creditScore` 属性的任何三元组。

## 示例 5 – 避免悬垂属性或边缘
<a name="transactions-examples-dangling"></a>

对实体的更新不应造成悬垂元素，即与无类型的实体相关联的属性或边缘。仅 SPARQL 存在该问题；Gremlin 具有内置约束，可避免造成悬垂元素。

```
# SPARQL:
tx1: INSERT { :person1 :age 23 } WHERE { :person1 rdf:type :Person }
tx2: DELETE { :person1 ?p ?o }
```

`INSERT` 查询必须读取并使用 `SPOG` 索引中的 `S=person1`、`P=rdf:type` 和 `O=Person` 锁定 `SPO` 前缀。该锁可防止 `DELETE` 查询并行成功。

在 `DELETE` 查询尝试删除 `:person1 rdf:type :Person` 记录和 `INSERT` 查询读取该记录并在 `SPOG` 索引中该记录的 `SPO` 上创建范围锁的争用过程中，可能产生以下结果：
+ 如果 `INSERT` 查询在 `DELETE` 查询读取并删除 `:person1` 的所有记录之前提交，则将从数据库中完全删除 `:person1`，包括新插入的记录。
+ 如果 `DELETE` 查询在 `INSERT` 查询尝试读取 `:person1 rdf:type :Person` 记录之前提交，则读取内容将包括已提交的更改。也就是说，它找不到任何 `:person1 rdf:type :Person` 记录，因此成为一个空操作。
+ 如果 `INSERT` 查询在 `DELETE` 查询之前读取，则 `:person1 rdf:type :Person` 三元组将被锁定，并且 `DELETE` 查询将被阻止，直到 INSERT 查询提交为止，就像前面的第一种情况一样。
+ 如果 `DELETE` 在 `INSERT` 查询之前读取，并且 `INSERT` 查询尝试读取并锁定该记录的 `SPO` 前缀，则会检测到冲突。这是因为三元组已标记为等待删除，因此 `INSERT` 将失败。

在上述所有不同的可能事件序列中，均未创建任何悬垂边缘。