Spanner에서 시퀀스 생성

이 문서에서는 데이터베이스 관리자 및 애플리케이션 개발자가 Spanner를 사용하는 애플리케이션에서 고유한 숫자 시퀀스를 생성하는 방법을 설명합니다.

소개

회사에서는 사원 번호나 인보이스 번호와 같이 고유한 숫자 ID가 필요한 경우가 많습니다. 기존의 관계형 데이터베이스에는 종종 단조 증가하는 일련의 숫자를 생성하는 기능이 포함되어 있습니다. 이러한 시퀀스는 데이터베이스에 저장된 객체의 고유 식별자(행 키)를 생성하는 데 사용됩니다.

하지만 행 키로 단조 증가하는(또는 감소하는) 값을 사용할 때는 데이터베이스에 핫스팟을 생성하여 성능 감소를 일으키기 때문에 Spanner의 권장사항을 따르지 않을 수 있습니다. 이 문서에서는 Spanner 데이터베이스 테이블과 애플리케이션 레이어 논리를 사용하여 시퀀스 생성기를 구현하는 메커니즘을 제안합니다.

또한 Spanner는 기본 제공되는 비트 반전 시퀀스 생성기를 지원합니다. Spanner 시퀀스 생성기에 대한 자세한 내용은 시퀀스 만들기 및 관리를 참조하세요.

시퀀스 생성기 요구사항

모든 시퀀스 생성기는 각 트랜잭션에 대해 고유한 값을 생성해야 합니다.

사용 사례에 따라 시퀀스 생성기는 다음과 같은 특성을 가진 시퀀스를 생성해야 할 수도 있습니다.

  • 순차적(Ordered): 시퀀스에서는 낮은 값이 높은 값보다 먼저 발급되어야 합니다.
  • 간격 없음(Gapless): 시퀀스에 간격이 없어야 합니다.

또한 시퀀스 생성기는 애플리케이션에서 요구하는 빈도로 값을 생성해야 합니다.

특히 분산형 시스템에서는 이러한 요구사항을 모두 충족하기 어려울 수 있습니다. 성능 목표를 달성하는 데 필요한 경우 시퀀스의 정렬 및 간격 관련 요구사항을 절충할 수 있습니다.

다른 데이터베이스 엔진에서 이러한 요구사항을 처리할 수 있습니다. 예를 들어 MySQL의 PostgreSQL 및 AUTO_INCREMENT 열 시퀀스는 개별 트랜잭션에 대해 고유한 값을 생성할 수 있지만 트랜잭션이 롤백되는 경우 간격 없는 값을 생성할 수 없습니다. 자세한 내용은 PostgreSQL의 참고사항 문서MySQL의 AUTO_INCREMENT 영향을 참조하세요.

데이터베이스 테이블 행을 사용한 시퀀스 생성기

애플리케이션은 데이터베이스 테이블을 사용해 시퀀스 생성기를 구현하여 시퀀스 이름과 시퀀스의 다음 값을 저장할 수 있습니다.

데이터베이스 트랜잭션 내에서 시퀀스의 next_value 셀을 읽고 증분하면 애플리케이션 프로세스 간에 추가로 동기화할 필요 없이 고유한 값이 생성됩니다.

먼저 다음과 같이 테이블을 정의합니다.

CREATE TABLE sequences (
    name STRING(64) NOT NULL,
    next_value INT64 NOT NULL,
) PRIMARY KEY (name)

테이블에 새 시퀀스 이름과 시작 값(예: ("invoice_id", 1))을 삽입하여 시퀀스를 생성할 수 있습니다. 하지만 생성된 모든 시퀀스 값에 대해 next_value 셀이 증분되므로 행을 업데이트할 수 있는 빈도에 따라 성능이 제한됩니다.

Spanner 클라이언트 라이브러리재시도 가능 트랜잭션을 사용하여 충돌을 해결합니다. 읽기-쓰기 트랜잭션 중에 읽은 셀(열 값)이 다른 곳에서 수정되면 다른 트랜잭션이 완료될 때까지 해당 트랜잭션이 차단 및 취소된 후 다시 시도되어 업데이트된 값을 읽습니다. 이렇게 하면 쓰기 잠금 시간이 최소화되지만 트랜잭션이 성공적으로 커밋되기 전까지 여러 번 시도될 수 있습니다.

한 번에 트랜잭션 1개만 발생할 수 있으므로 시퀀스 값을 실행하는 최대 빈도는 트랜잭션의 총 지연 시간에 반비례합니다.

총 트랜잭션 지연 시간은 클라이언트 애플리케이션과 Spanner 노드 간의 지연 시간, Spanner 노드 간의 지연 시간, TrueTime 불확실성과 같은 여러 요소에 따라 달라집니다. 예를 들어 멀티 리전 구성은 트랜잭션 지연 시간이 더 긴데, 이는 완료하기 전에 다른 리전의 노드에서 쓰기 확인 쿼럼을 기다려야 하기 때문입니다.

예를 들어 단일 셀 (단일 행의 열 1 개)의 읽기-업데이트 트랜잭션의 지연 시간이 10밀리초(ms)인 경우 시퀀스 값 실행의 이론상 최대 빈도는 초당 100회입니다. 이 최댓값은 클라이언트 애플리케이션 인스턴스 수 또는 데이터베이스의 노드 수에 관계없이 전체 데이터베이스에 적용됩니다. 이는 단일 행이 항상 단일 노드에 의해 관리되기 때문입니다.

다음 섹션에서는 이러한 제한을 해결하는 방법을 설명합니다.

애플리케이션 측 구현

이 애플리케이션 코드는 데이터베이스에서 next_value 셀을 읽고 업데이트해야 합니다. 이는 여러 가지 방법으로 수행할 수 있으며, 각각의 방법에는 나름의 성능 특성과 단점이 있습니다.

간단한 트랜잭션 내 시퀀스 생성기

시퀀스 생성을 처리하는 가장 간단한 방법은 애플리케이션에서 새로운 순차 값이 필요할 때마다 트랜잭션 내의 열 값을 증분하는 것입니다.

애플리케이션은 단일 트랜잭션으로 다음을 수행합니다.

  • 애플리케이션에서 사용할 시퀀스 이름의 next_value 셀을 읽습니다.
  • 시퀀스 이름의 next_value 셀을 증분하고 업데이트합니다.
  • 애플리케이션에 필요한 열 값이 무엇이든지 검색된 값을 사용합니다.
  • 애플리케이션의 나머지 트랜잭션을 완료합니다.

이 프로세스는 순차적이고 간격이 없는 시퀀스를 생성합니다. 그 어떤 것도 데이터베이스의 next_value 셀을 더 낮은 값으로 업데이트하지 않으므로 시퀀스도 고유하게 됩니다.

시퀀스 값은 광범위한 애플리케이션 트랜잭션의 일부로 검색되므로 시퀀스 생성의 최대 빈도는 전체 애플리케이션 트랜잭션의 복잡도에 따라 달라집니다. 복잡한 트랜잭션은 지연 시간이 길어지고 최대 가능 빈도가 낮아집니다.

분산형 시스템에서는 많은 트랜잭션이 동시에 시도될 수 있으므로 시퀀스 값에 대해 많은 경합이 발생할 수 있습니다. next_value 셀이 애플리케이션의 트랜잭션 내에서 업데이트되므로, 다른 모든 트랜잭션이 동시에 next_value 셀 증분을 시도하면 첫 번째 트랜잭션이 차단되고 다시 시도됩니다. 이로 인해 애플리케이션이 트랜잭션을 성공적으로 완료하는 데 필요한 시간이 대폭 증가하여 성능 문제가 발생할 수 있습니다.

다음 코드는 트랜잭션당 단일 시퀀스 값만 반환하는 간단한 트랜잭션 내 시퀀스 생성기의 예를 제공합니다. 이러한 제한은 변형 API를 사용하는 트랜잭션 내에서 쓰기가(동일한 트랜잭션의 읽기 포함) 트랜잭션 커밋이 끝날 때까지 표시되지 않기 때문에 발생합니다. 따라서 동일한 트랜잭션에서 이 함수를 여러 번 호출하면 항상 동일한 시퀀스 값이 반환됩니다.

다음 예시 코드는 동기식 getNext() 함수를 구현하는 방법을 보여줍니다.

/**
 * Returns the next value from this sequence.
 *
 * <p>Should only be called once per transaction.
 */
long getNext(TransactionContext txn) {
  Struct result =
      txn.readRow(
          SEQUENCES_TABLE, Key.of(sequenceName), Collections.singletonList(NEXT_VALUE_COLUMN));
  if (result == null) {
    throw new NoSuchElementException(
        "Sequence " + sequenceName + " not found in table " + SEQUENCES_TABLE);
  }
  long value = result.getLong(0);
  txn.buffer(
      Mutation.newUpdateBuilder(SEQUENCES_TABLE)
          .set(SEQUENCE_NAME_COLUMN)
          .to(sequenceName)
          .set(NEXT_VALUE_COLUMN)
          .to(value + 1)
          .build());
  return value;
}

다음 예시 코드는 동기 getNext() 함수가 트랜잭션에 사용되는 방식을 보여줍니다.

// Simple Sequence generator created outside transaction, eg as field.
private SimpleSequenceGenerator simpleSequence = new SimpleSequenceGenerator("my Sequence");

public void usingSimpleSequenceGenerator() {
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Get a sequence value
              long nextValue = simpleSequence.getNext(txn);
              // Use nextValue in the transaction
              // ...
              return null;
            }
          });
}

향상된 트랜잭션 내 동기식 시퀀스 생성기

앞의 추상화를 수정하여 트랜잭션 내에서 발급된 시퀀스 값을 추적함으로써 단일 트랜잭션 내에서 여러 개의 값을 생성할 수 있습니다.

애플리케이션은 단일 트랜잭션으로 다음을 수행합니다.

  • 애플리케이션에서 사용할 시퀀스 이름의 next_value 셀을 읽습니다.
  • 이 값을 내부적으로 변수로 저장합니다.
  • 새 시퀀스 값이 요청될 때마다 저장된 next_value 변수를 증분하고 데이터베이스에서 업데이트된 셀 값을 설정하는 쓰기를 버퍼링합니다.
  • 애플리케이션의 나머지 트랜잭션을 완료합니다.

추상화를 사용하는 경우 트랜잭션 내에서 이 추상화의 객체를 만들어야 합니다. 객체는 첫 번째 값이 요청될 때 단일 읽기를 수행합니다. 객체는 내부적으로 next_value 셀을 추적하여 둘 이상의 값을 생성할 수 있습니다.

이전 버전에 적용된 지연 시간 및 경합에 대한 동일한 주의사항이 이 버전에도 적용됩니다.

다음 예시 코드는 동기식 getNext() 함수를 구현하는 방법을 보여줍니다.

private final TransactionContext txn;
@Nullable private Long nextValue;

/** Creates a sequence generator for this transaction. */
public SynchronousSequenceGenerator(String sequenceName, TransactionContext txn) {
  super(sequenceName);
  this.txn = txn;
}

/**
 * Returns the next value from this sequence.
 *
 * <p>Can be called multiple times in a transaction.
 */
public long getNext() {
  if (nextValue == null) {
    // nextValue is unknown - read it.
    Struct result =
        txn.readRow(
            SEQUENCES_TABLE, Key.of(sequenceName), Collections.singletonList(NEXT_VALUE_COLUMN));
    if (result == null) {
      throw new NoSuchElementException(
          "Sequence " + sequenceName + " not found in table " + SEQUENCES_TABLE);
    }
    nextValue = result.getLong(0);
  }
  long value = nextValue;
  // increment and write nextValue to the database.
  nextValue++;
  txn.buffer(
      Mutation.newUpdateBuilder(SEQUENCES_TABLE)
          .set(SEQUENCE_NAME_COLUMN)
          .to(sequenceName)
          .set(NEXT_VALUE_COLUMN)
          .to(nextValue)
          .build());
  return value;
}

다음 예시 코드는 두 시퀀스 값에 대한 요청에서 동기 getNext() 함수를 사용하는 방법을 보여줍니다.

public void usingSynchronousSequenceGenerator() {
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Create the sequence generator object within the transaction
              SynchronousSequenceGenerator syncSequence =
                  new SynchronousSequenceGenerator("my_sequence", txn);
              // Get two sequence values
              long key1 = syncSequence.getNext();
              long key2 = syncSequence.getNext();
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

트랜잭션 외(비동기식) 시퀀스 생성기

이전 두 구현에서 생성기의 성능은 애플리케이션 트랜잭션의 지연 시간에 따라 달라집니다. 별도의 트랜잭션에서 시퀀스를 증분하여 최대 빈도를 늘릴 수 있습니다. 이때 간격 요구사항이 절충될 수 있습니다. (이것은 PostgreSQL에서 사용하는 접근 방식입니다.) 애플리케이션에서 트랜잭션을 시작하기 전에 먼저 사용할 시퀀스 값을 검색해야 합니다.

애플리케이션은 다음을 수행합니다.

  • 시퀀스 값을 가져와 업데이트하려는 첫 번째 트랜잭션을 만듭니다.
    • 애플리케이션에서 사용할 시퀀스 이름의 next_value 셀을 읽습니다.
    • 이 값을 변수로 저장합니다.
    • 데이터베이스에서 시퀀스 이름의 next_value 셀을 증분하고 업데이트합니다.
    • 트랜잭션을 완료합니다.
  • 반환된 값을 별도의 트랜잭션에서 사용합니다.

이 개별 트랜잭션의 지연 시간은 최소 지연 시간에 근접하며, 이때 성능은 이론적으로 초당 최대 값 100개의 빈도에 해당합니다(트랜잭션 지연 시간을 10밀리초로 가정). 시퀀스 값은 개별적으로 검색되므로 애플리케이션 트랜잭션 자체의 지연 시간은 변경되지 않으며 경합은 최소화됩니다.

하지만 시퀀스 값이 요청되고 사용되지 않으면 요청된 시퀀스 값을 롤백할 수 없으므로 시퀀스에 공백이 남습니다. 이 문제는 시퀀스 값을 요청한 후 트랜잭션 중에 애플리케이션이 취소되거나 실패하는 경우에 발생할 수 있습니다.

다음 예시 코드는 데이터베이스에서 next_value 셀을 검색 및 증분하는 함수를 어떻게 구현하는지 보여줍니다.

/**
 * Gets the next sequence value from the database, and increments the database value by the amount
 * specified in a single transaction.
 */
protected Long getAndIncrementNextValueInDB(long increment) {
  return dbClient
      .readWriteTransaction()
      .run(
          txn -> {
            Struct result =
                txn.readRow(
                    SEQUENCES_TABLE,
                    Key.of(sequenceName),
                    Collections.singletonList(NEXT_VALUE_COLUMN));
            if (result == null) {
              throw new NoSuchElementException(
                  "Sequence " + sequenceName + " not found in table " + SEQUENCES_TABLE);
            }
            long value = result.getLong(0);
            txn.buffer(
                Mutation.newUpdateBuilder(SEQUENCES_TABLE)
                    .set(SEQUENCE_NAME_COLUMN)
                    .to(sequenceName)
                    .set(NEXT_VALUE_COLUMN)
                    .to(value + increment)
                    .build());
            return value;
          });
}

다음 비동기 getNext() 함수 구현에 표시된 대로, 이 함수를 사용하여 새 시퀀스 값을 손쉽게 검색할 수 있습니다.

/**
 * Returns the next value from this sequence.
 *
 * Uses a separate transaction so must be used <strong>outside</strong>any other transactions.
 * See {@link #getNextInBackground()} for an alternative version that uses a background thread
 */
public long getNext() throws SpannerException {
  return getAndIncrementNextValueInDB(1);
}

다음 예시 코드는 두 시퀀스 값에 대한 요청에서 비동기 getNext() 함수를 사용하는 방법을 보여줍니다.

// Async Sequence generator created outside transaction as a long-lived object.
private AsynchronousSequenceGenerator myAsyncSequence =
    new AsynchronousSequenceGenerator("my Sequence", dbClient);

public void usingAsynchronousSequenceGenerator() {
  // Get two sequence values
  final long key1 = myAsyncSequence.getNext();
  final long key2 = myAsyncSequence.getNext();
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

앞의 코드 예시에서는 시퀀스 값이 애플리케이션 트랜잭션 외부에서 요청된 것을 확인할 수 있습니다. 이는 Cloud Spanner가 동일한 스레드(중첩된 트랜잭션이라고도 함)의 다른 트랜잭션 내에서 트랜잭션 실행을 지원하지 않기 때문입니다.

백그라운드 스레드를 사용하고 결과를 기다리는 방식으로 시퀀스 값을 요청하여 이러한 제한을 해결할 수 있습니다.

protected static final ExecutorService executor = Executors.newCachedThreadPool();

/**
 * Gets the next value using a background thread - to be used when inside a transaction to avoid
 * Nested Transaction errors.
 */
public long getNextInBackground() throws Exception {
  return executor.submit(this::getNext).get();
}

배치 시퀀스 생성기

시퀀스 값이 순차적이어야 한다는 요구사항을 삭제하면 성능이 대폭 향상될 수 있습니다. 이 경우 애플리케이션에서 시퀀스 값의 배치를 예약하고 내부적으로 발급할 수 있습니다. 개별 애플리케이션 인스턴스에는 별도의 값 배치가 있으므로 발급되는 값이 순차적으로 정렬되지 않습니다. 또한 애플리케이션 인스턴스가 종료되는 경우와 같이 전체 값 배치를 사용하지 않는 애플리케이션 인스턴스는 시퀀스에서 사용되지 않은 값의 공백을 남깁니다.

애플리케이션은 다음을 수행합니다.

  • 시작 값과 배치 크기, 사용 가능한 다음 값을 포함하는 각 시퀀스의 내부 상태를 유지합니다.
  • 배치에서 시퀀스 값을 요청합니다.
  • 배치에 남아있는 값이 없는 경우 다음을 수행합니다.
    • 시퀀스 값을 읽고 업데이트할 트랜잭션을 만듭니다.
    • 시퀀스의 next_value 셀을 읽습니다.
    • 이 값을 내부적으로 새 배치의 시작 값으로 저장합니다.
    • 데이터베이스의 next_value 셀을 배치 크기와 동일하게 증분합니다.
    • 트랜잭션을 완료합니다.
  • 다음 번 사용 가능한 값을 반환하고 내부 상태를 증분합니다.
  • 트랜잭션에서 반환된 값을 사용합니다.

이 메서드를 사용하면 시퀀스 값을 사용하는 트랜잭션에서 새 시퀀스 값 배치를 예약해야 하는 경우에만 지연 시간이 늘어납니다.

이 경우 제한 요소가 초당 발급되는 배치 수가 되므로 배치 크기를 늘려 성능을 원하는 수준으로 높일 수 있다는 장점이 있습니다.

예를 들어 배치 크기가 100이고 새 배치를 가져오는 데 10밀리초의 지연 시간이 발생한다고 가정하면 초당 최대 100개의 배치 ~ 초당 최대 10,000개의 시퀀스 값이 발급될 수 있습니다.

다음 예시 코드는 배치를 사용하여 getNext() 함수를 구현하는 방법을 보여줍니다. 이 코드는 이전에 정의된 getAndIncrementNextValueInDB() 함수를 재사용하여 데이터베이스에서 새로운 시퀀스 값 배치를 검색합니다.

/**
 * Gets a new batch of sequence values from the database.
 *
 * <p>Reads next_value, increments it by batch size, then writes the updated next_value back.
 */
private synchronized void getBatch() throws SpannerException {
  if (next_value <= last_value_in_batch) {
    // already have some values left in the batch - maybe this has been refreshed by another
    // thread.
    return;
  }
  next_value = getAndIncrementNextValueInDB(batchSize);
  last_value_in_batch = next_value + batchSize - 1;
}

/**
 * Returns the next value from this sequence, getting a new batch of values if necessary.
 *
 * When getting a new batch, it creates a separate transaction, so this must be called
 * <strong>outside</strong> any other transactions. See {@link #getNextInBackground()} for an
 * alternative version that uses a background thread
 */

public synchronized long getNext() throws SpannerException {
  if (next_value > last_value_in_batch) {
    getBatch();
  }
  long value = next_value;
  next_value++;
  return value;
}

다음 예시 코드는 두 시퀀스 값에 대한 요청에서 비동기 getNext() 함수를 사용하는 방법을 보여줍니다.

// Batch Sequence generator created outside transaction, as a long-lived object.
private BatchSequenceGenerator myBatchSequence =
    new BatchSequenceGenerator("my Sequence", /* batchSize= */ 100, dbClient);

public void usingBatchSequenceGenerator() {
  // Get two sequence values
  final long key1 = myBatchSequence.getNext();
  final long key2 = myBatchSequence.getNext();
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

Spanner는 중첩된 트랜잭션을 지원하지 않으므로 다시 트랜잭션 외부에 또는 백그라운드 스레드를 사용하여 값을 요청해야 합니다.

비동기식 배치 시퀀스 생성기

지연 시간 증가가 허용되지 않는 고성능 애플리케이션의 경우 현재 값 배치가 소진될 때 새 배치 값을 준비하여 이전 배치 생성기의 성능을 향상시킬 수 있습니다.

배치에 남아있는 시퀀스 값 수가 너무 낮은 경우를 나타내는 기준점을 설정하여 이를 달성할 수 있습니다. 기준점에 도달하면 시퀀스 생성기가 백그라운드 스레드에서 값의 새 배치 요청을 시작합니다.

이전 버전과 마찬가지로 값은 순차적으로 발급되지 않으며 트랜잭션이 실패하거나 애플리케이션 인스턴스가 종료되는 경우 시퀀스에 사용되지 않은 값의 공백이 있습니다.

애플리케이션은 다음을 수행합니다.

  • 배치의 시작 값과 사용 가능한 다음 값을 포함하는 각 시퀀스의 내부 상태를 유지합니다.
  • 배치에서 시퀀스 값을 요청합니다.
  • 배치의 나머지 값이 기준점보다 작으면 백그라운드 스레드에서 다음을 수행합니다.
    • 시퀀스 값을 읽고 업데이트할 트랜잭션을 만듭니다.
    • 애플리케이션에서 사용할 시퀀스 이름의 next_value 셀을 읽습니다.
    • 이 값을 내부적으로 다음 배치의 시작 값으로 저장합니다.
    • 데이터베이스의 next_value 셀을 배치 크기와 동일하게 증분합니다.
    • 트랜잭션을 완료합니다.
  • 배치에 남아있는 값이 없으면 백그라운드 스레드에서 다음 배치의 시작 값을 검색하고(필요한 경우 완료될 때까지 대기) 검색된 시작 값을 다음 값으로 사용하여 새 배치를 만듭니다.
  • 다음 값을 반환하고 내부 상태를 증분합니다.
  • 트랜잭션에서 반환된 값을 사용합니다.

최적의 성능을 위해 현재 배치의 시퀀스 값이 소진되기 전에 백그라운드 스레드가 시작되고 완료되어야 합니다. 그렇지 않으면 애플리케이션이 다음 배치를 기다려야 하고 지연 시간이 늘어납니다. 따라서 발급된 시퀀스 값의 빈도에 따라 배치 크기와 하위 기준점을 조정해야 합니다.

예를 들어 새 값 배치, 1000의 배치 크기, 초당 500개의 값 빈도(2밀리초마다 하나의 값)를 검색하는 데 20밀리초의 트랜잭션 시간이 걸린다고 가정합니다. 새 값 배치가 발급되는 20밀리초 동안 10개의 시퀀스 값이 발급됩니다. 따라서 남은 시퀀스 값 수의 기준점이 10보다 커야 하므로 필요한 경우 다음 배치를 사용할 수 있습니다.

다음 예시 코드는 배치를 사용하여 getNext() 함수를 구현하는 방법을 보여줍니다. 이 코드는 이전에 정의된 getAndIncrementNextValueInDB() 함수를 사용하며 백그라운드 스레드를 사용하여 시퀀스 값 배치를 검색합니다.

/**
 * Gets a new batch of sequence values from the database.
 *
 * <p>Reads nextValue, increments it by batch size, then writes the updated nextValue back.
 * Stores the resulting value in  nextBatchStartValue, ready for when the existing pool of values
 * is exhausted.
 */
private Long readNextBatchFromDB() {
  return getAndIncrementNextValueInDB(batchSize);
}

/**
 * Returns the next value from this sequence.
 *
 * If the number of remaining values is below the low watermark, this triggers a background
 * request for new batch of values if necessary. Once the current batch is exhausted, then a the
 * new batch is used.
 */
public synchronized long getNext() throws SpannerException {
  // Check if a batch refresh is required and is not already running.
  if (nextValue >= (lastValueInBatch - lowWaterMarkForRefresh) && pendingNextBatchStart == null) {
    // Request a new batch in the background.
    pendingNextBatchStart = executor.submit(this::readNextBatchFromDB);
  }

  if (nextValue > lastValueInBatch) {
    // batch is exhausted, we should have received a new batch by now.
    try {
      // This will block if the transaction to get the next value has not completed.
      long nextBatchStart = pendingNextBatchStart.get();
      lastValueInBatch = nextBatchStart + batchSize - 1;
      nextValue = nextBatchStart;
    } catch (InterruptedException | ExecutionException e) {
      if (e.getCause() instanceof SpannerException) {
        throw (SpannerException) e.getCause();
      }
      throw new RuntimeException("Failed to retrieve new batch in background", e);
    } finally {
      pendingNextBatchStart = null;
    }
  }
  // return next value.
  long value = nextValue;
  nextValue++;
  return value;
}

다음 예시 코드는 트랜잭션에 사용할 두 값의 요청에서 비동기 배치 getNext() 함수가 사용되는 방식을 보여줍니다.

// Async Batch Sequence generator created outside transaction, as a long-lived object.
private AsyncBatchSequenceGenerator myAsyncBatchSequence =
    new AsyncBatchSequenceGenerator("my Sequence", /* batchSize= */ 1000, 200, dbClient);

public void usingAsyncBatchSequenceGenerator() {
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Get two sequence values
              final long key1 = myBatchSequence.getNext();
              final long key2 = myBatchSequence.getNext();
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

이 경우 새 값 배치 검색이 백그라운드 스레드에서 발생하므로 트랜잭션 내에서 값을 요청할 수 있습니다.

요약

다음 표에서는 네 가지 유형의 시퀀스 생성기 특성을 비교합니다.

동기 비동기 배치 비동기 배치
고유한 값
전역적으로 정렬된 값 아니요
하지만 로드가 충분히 높고 배치 크기가 작으면 값이 서로 근접하게 됩니다.
아니요
하지만 로드가 충분히 높고 배치 크기가 작으면 값이 서로 근접하게 됩니다.
간격 없음 아니요 아니요 아니요
성능 1/트랜잭션 지연 시간,
(초당 최대 25개의 값)
초당 50~100개 값 초당 50~100개의 값 배치 초당 50~100개의 값 배치
지연 시간 증가 10밀리초 초과
높은 경합으로 지연 시간이 상당히 김(트랜잭션에 많은 시간이 걸리는 경우)
트랜잭션당 10밀리초
높은 경합으로 지연 시간이 상당히 김
10밀리초(새 값 배치를 검색하는 경우에만) 0(배치 크기 및 낮은 기준점이 적절한 값으로 설정된 경우)

앞의 표는 전반적인 성능 요구사항을 충족하면서 고유한 값을 생성하기 위해 전역적으로 정렬된 값 및 일련의 간격 없는 값 요구사항이 절충될 수 있다는 사실을 보여줍니다.

성능 테스트

이전 시퀀스 생성기 클래스와 동일한 GitHub 저장소에 있는 성능 테스트/분석 도구를 사용하여 이러한 각 시퀀스 생성기를 테스트하고 성능 및 지연 시간 특성을 보여줄 수 있습니다. 이 도구는 10밀리초의 애플리케이션 트랜잭션 지연 시간을 시뮬레이션하고 시퀀스 값을 요청하는 여러 스레드를 동시에 실행합니다.

성능 테스트에서는 단일 행만 수정되므로 테스트할 단일 노드 Spanner 인스턴스만 필요합니다.

예를 들어 다음 출력은 10개의 스레드가 있는 동기 모드의 성능과 지연 시간을 비교한 것입니다.

$ ITERATIONS=2000
$ MODE=SYNC
$ NUMTHREADS=10
$ java -jar sequence-generator.jar \
   $INSTANCE_ID $DATABASE_ID $MODE $ITERATIONS $NUMTHREADS
2000 iterations (10 parallel threads) in 58739 milliseconds: 34.048928 values/s
Latency: 50%ile 27 ms
Latency: 75%ile 31 ms
Latency: 90%ile 1189 ms
Latency: 99%ile 2703 ms

다음 표에서는 초당 발급될 수 있는 값 수와 50번째, 90번째, 99번째 백분위수의 지연 시간을 포함한 다양한 모드와 병렬 스레드의 결과를 비교합니다.

모드 및 매개변수 Num 스레드 값/초 50번째 백분위수 지연 시간(밀리초) 90번째 백분위수 지연 시간(밀리초) 99번째 백분위수
지연 시간(밀리초)
SYNC 10 34 27 1189 2703
SYNC 50 30.6 1191 3513 5982
ASYNC 10 66.5 28 611 1460
ASYNC 50 78.1 29 1695 3442
BATCH
(크기 200)
10 494 18 20 38
BATCH(배치 크기 200) 50 1195 27 55 168
ASYNC BATCH
(배치 크기 200, LT 50)
10 512 18 20 30
ASYNC BATCH
(배치 크기 200, LT 50)
50 1622 24 28 30

동기(SYNC) 모드에서는 스레드 수가 증가함에 따라 경합이 증가했음을 알 수 있습니다. 따라서 트랜잭션 지연 시간이 훨씬 더 길어집니다.

비동기(ASYNC) 모드에서는 시퀀스를 가져오는 트랜잭션이 적고 애플리케이션 트랜잭션과 별개이므로 경합이 줄고 빈도도 높아집니다. 하지만 여전히 경합이 발생할 수 있으므로 90번째 백분위수 지연 시간이 더 길어집니다.

배치(BATCH) 모드에서는 99번째 백분위수를 제외하면 지연 시간이 대폭 줄어듭니다. 이는 생성기가 데이터베이스에서 다른 시퀀스 값 배치를 동기로 요청해야 하는 경우에 해당합니다. ASYNC 모드보다 BATCH 모드에서 성능이 여러 배 더 높습니다.

50개의 스레드 배치 모드는 대기 시간이 길며, 시퀀스가 빠르게 발급되므로 가상 머신(VM) 인스턴스의 성능(이 경우 4개의 vCPU 머신이 테스트 중 CPU에서 350%로 실행)이 제한 요소가 됩니다. 다중 머신과 프로세스를 사용하면 전반적인 결과가 10개의 스레드 배치 모드와 유사하게 표시됩니다.

ASYNC BATCH 모드에서는 데이터베이스에서 새 배치를 요청하는 지연 시간이 애플리케이션 트랜잭션과 완전히 독립적이므로 지연 시간의 변화가 최소화되고 성능이 더 높아집니다.

다음 단계