并发控制

Spanner 事务提供两种并发控制模式:悲观乐观。并发控制模式的选择会影响事务处理并发读取和写入的方式,从而影响性能、延迟时间和事务中止率。选择最符合应用性能和一致性要求的模式。

默认行为取决于事务使用的隔离级别

悲观并发控制

默认情况下,Spanner 使用悲观并发控制机制和串行化隔离。您还可以将悲观并发与可重复读隔离结合使用。

可序列化隔离中的悲观并发

此模式假定并发事务可能会争用相同的数据。在事务内读取或写入数据时,它会主动获取数据锁定。它还会验证在事务中较早获取的锁是否在后续语句中仍处于持有状态。当 Spanner 检测到锁定冲突时,会使用“受伤-等待”算法来解决冲突。

在悲观并发中,事务会在事务的执行阶段和提交阶段获取数据锁。

  • 对于读取操作:当事务读取数据时,会在执行阶段获取共享读取 (ReaderShared) 锁。这些锁会一直保持,直到事务提交。
  • 对于 DML 和写入操作
    • 在执行期间,对于通过 DML 或写入操作修改的数据,事务可能会获取行存在性读取锁。
    • 在提交时,事务会尝试获取所写入数据的写入锁定或独占锁定。写入锁会阻止并发读取,但可能不会阻止并发写入,尤其是在两者都使用写入锁的情况下。这意味着多个事务可以继续提交,并且在提交时使用 wound-wait 算法解决写-写冲突。所有锁都会一直被持有,直到事务提交。

可重复读隔离中的悲观并发

在可重复读隔离中使用悲观并发来序列化写入。在此模式下,读取操作使用快照,但排他锁适用于通过 FOR UPDATE 查询或 lock_scanned_ranges=exclusive 提示读取的数据,以及通过 DML 查询写入的数据。

采用串行化隔离的悲观并发的优势

在可序列化隔离级别下使用悲观并发的主要好处是,在竞争激烈的工作负载中,它有助于事务取得进展。在发生冲突时,Spanner 会优先处理较早的事务,而不是较新的事务,从而确保事务最终完成,同时减少反复中止的事务数量。

采用可重复读隔离的悲观并发的优势

在可重复读隔离级别下,如果事务在提交之前,通过 FOR UPDATE 或 DML 查询读取的数据被并发事务修改,那么即使事务获取了锁定,仍可能会在提交时中止。不过,在获取锁之后,它会阻止进一步的并发更新,直到事务提交,从而序列化写入操作。

悲观并发的风险

采用串行化隔离的悲观并发存在以下风险:

  • 长时间运行的读取操作可能会阻塞对延迟时间比较敏感的写入操作。
  • 在完成之前需要用户互动的交易可能会导致锁定状态持续很长时间,从而可能会阻塞其他操作。

采用可序列化隔离的悲观并发使用场景

悲观并发适合读写和写写争用严重的工作负载。当交易中止和重试成本较高时,这种方式也适用。除非您的工作负载存在过长的锁定延迟,或者受到锁定冲突的严重影响,否则请使用此默认模式。

使用悲观并发和可重复读隔离的用例

对于需要使用 FOR UPDATE 子句或 DML 查询来获取锁的工作负载,请使用悲观并发和可重复读。对于从其他数据库迁移到 Spanner 且会针对这些语句获取锁的工作负载,此方法尤其有用。

乐观并发控制

Spanner 还提供乐观并发控制。使用可重复读隔离时,默认模式为乐观并发控制。您还可以配置可序列化隔离,以使用乐观并发控制。

乐观并发控制假设冲突很少发生。即使在读写事务中,读取和查询也会在不获取锁的情况下继续进行。在 Spanner 的默认可序列化隔离级别下,读取会在提交时进行验证。这样可确保没有其他并发提交的事务修改该事务之前读取的数据。如果您使用可重复读隔离,则在提交时会验证带有 FOR UPDATElock_scanned_ranges=exclusive 提示的读取操作。如果 Spanner 检测到冲突,则会中止事务。

乐观并发的工作原理

乐观并发会改变 Spanner 执行读取、查询和提交事务的方式。在读取阶段执行无锁操作,并在提交时验证一致性。

对于读取和查询

读取和查询是无锁的。乐观事务中的所有读取和查询都以单个快照时间戳执行。Spanner 会在首次执行读取或查询时选择此时间戳。这样可确保事务中的所有后续读取和查询都能看到在第一次读取或查询之前提交的写入。

对于读取和写入

对于包含读取和写入的乐观事务,Spanner 会在提交时执行验证步骤。仅当未检测到任何冲突且满足以下条件时,交易才会成功提交:

  • 没有并发提交的写入与此事务读取的数据发生冲突;也就是说,在读取时间戳之后但在该事务提交其自己的写入之前,没有提交任何写入。
  • 自读取时间戳以来,架构未发生修改。

隔离级别决定了要验证的读取操作集。在可序列化隔离级别下,所有读取操作都会经过验证。在可重复读隔离级别下,带有 FOR UPDATElock_scanned_ranges=exclusive 提示的读取操作会在提交时进行验证。

在高竞争条件下,乐观事务可能会反复中止。相比之下,悲观事务通过允许较旧的事务提交并重试较新的事务来解决读写冲突。

乐观并发的优势

乐观并发具有以下优势:

  • 读取不会获取锁:乐观事务不会为读取获取锁,因此长时间运行的读取不会阻塞对延迟敏感的写入。
  • 缩短了只读事务的提交延迟时间:由于乐观事务中的所有读取操作都基于同一快照时间戳,因此在执行或提交这些读取操作期间无需验证一致性,从而显著缩短了延迟时间。

乐观并发的风险

乐观并发会带来风险,尤其是在与可序列化隔离搭配使用时,在高读写争用情况下更是如此。在为工作负载使用具有可序列化隔离级别的乐观并发控制之前,请先了解这些风险。

  • 在高读写争用情况下,乐观事务可能会遇到较高的中止率,因为并发写入可能会使乐观事务的读取失效。
  • 如果持续存在高争用,事务可能会因事务饥饿而反复中止,并且永远无法提交。

乐观并发的使用场景

乐观并发适用于读写争用较低的事务性工作负载。对于可序列化事务,它还有利于能够容忍事务中止的工作负载。

对于以下工作负载,请考虑使用乐观并发:

  • 低优先级、容忍延迟且具有长时间运行事务的工作负载:如果长时间运行的读取或查询可能会延迟对延迟敏感的写入,请使用乐观并发。这样可以避免因读取锁定而造成的延迟。例如,移动客户端中连接速度较慢的事务,或低 SLA 事务持有许多行或大范围的读取锁。
  • 对读取延迟敏感且读写争用较低的事务性工作负载:在多区域配置中,使用乐观并发来提供区域性读取服务、缩短读取延迟,并避免因读取流量突然增加而导致热门分块出现生产问题。它还可以在领导者过载或不可用时提高读取可用性。
  • 大多数事务都是只读事务的事务性工作负载:切换到乐观并发可缩短这些工作负载中常见只读事务的提交延迟时间。确保读写争用较低,以避免读写事务中止率过高。

对于对延迟时间敏感且读写冲突频繁的事务性工作负载,请避免使用乐观并发。

配置并发控制

您可以使用 Spanner 客户端库、REST 和 RPC API 来指定读写事务的并发模式。

客户端库

Java

static void readLockModeSetting(DatabaseId db) {
  // The read lock mode specified at the client-level will be applied to all
  // RW transactions.
  DefaultReadWriteTransactionOptions transactionOptions =
      DefaultReadWriteTransactionOptions.newBuilder()
          .setReadLockMode(ReadLockMode.OPTIMISTIC)
          .build();
  SpannerOptions options =
      SpannerOptions.newBuilder()
          .setDefaultTransactionOptions(transactionOptions)
          .build();
  Spanner spanner = options.getService();
  DatabaseClient dbClient = spanner.getDatabaseClient(db);
  dbClient
      // The read lock mode specified at the transaction-level takes precedence
      // over the read lock mode configured at the client-level.
      .readWriteTransaction(Options.readLockMode(ReadLockMode.PESSIMISTIC))
      .run(transaction -> {
        // Read an AlbumTitle.
        String selectSql =
            "SELECT AlbumTitle from Albums WHERE SingerId = 1 and AlbumId = 1";
        String title = null;
        try (ResultSet resultSet = transaction.executeQuery(Statement.of(selectSql))) {
          if (resultSet.next()) {
            title = resultSet.getString("AlbumTitle");
          }
        }
        System.out.printf("Current album title: %s\n", title