ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

TypeScriptのデコレータを使って、SQL ServerエラーでのExponential Backoffを含む再試行処理を実装する

ソリューション技術部の近藤(id:kazumeat)です。

みなさんクラウド(AWS, Azure, GCP...)のサービスは使われていますか?クラウド上でサービスを構築される場合のベストプラクティスは実装されていますでしょうか?今回はベストプラクティスの一つである、リトライ、再試行の処理について記載します。

クラウドの各サービスは、一過性の障害が必ず起こりうる、という前提で実装することが求められます。

リモートのサービスやリモートのリソースとやり取りするすべてのアプリケーションは、一過性の障害に特別な注意を払う必要があります。 とりわけクラウドは、環境の特性やインターネットでの接続性から、一過性の障害を起こしやすく、そこで動作するアプリケーションでは、一過性の障害への対応が特に重要となります。 一過性の障害とはたとえば、コンポーネントやサービスとのネットワーク接続が一瞬失われたり、サービスを一時的に利用できなくなったり、サービスがビジー状態となってタイムアウトしたりすることが該当します。 多くの場合、これらの障害は自動修正され、少し時間をおいてから操作を再試行すれば、高い確率で正常に実行されます。

再試行の一般的なガイダンス - Best practices for cloud applications | Microsoft Docs

それぞれのクラウドサービス公式ページにはそれら一過性の障害に対してサービスを止めることなく、自動的に復旧できるよう、リトライの処理を自分で実装するよう案内されています。

また、Exponential Backoffとは、直訳すると「指数関数的後退」つまり、指数関数的に処理のリトライ間隔を後退させるアルゴリズムのことになります。リトライするにしても、1秒おきにするのではなく、最初は3秒後、次は10秒後、などとリトライ間隔を後退していくことで、クラウドサービスへの負荷を減らします。

今回弊社では、Azure上にて、Node.jsのフルスタックフレームワーク(NestJS)を使いつつ、TypeScriptのデコレータでこれらの処理を実装しましたので実際のコードを紹介します。 (NestJSや、TypeScriptのデコレータについての説明は省略させていただきます)

使用DBは、SQL Serverになります。例として、ユーザテーブルにアクセスするメソッドに、デコレータを付けるだけでリトライ処理を実現します。

user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { Retry } from './Retry.decorator';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  @Retry()
  async findOneById(id: number): Promise<User> {
    return await this.userRepository.findOne(id);
  }
}

デコレータ(@Retry)の実装は下記となります。

Retry.decorator.ts

import { Logger } from '@nestjs/common';
import isObject = require('isobject');

export const sleep = (ms: number) =>
  new Promise(resolve => setTimeout(resolve, ms));

// 待機時間及び、最大試行回数(2)
export const DEFAULT_RETRY_INTERVAL_SECONDS = [5, 7];

/**
 * リトライ対象のSQLServerエラー番号
 */
const RETRY_MSSQL_ERROR_NUMBERS: number[] = [
  // SqlServerRetryingExecutionStrategy
  41301, // A transaction dependency failure occurred, and the current transaction can no longer commit. Please retry the transaction.
  41302, // The current transaction attempted to update a record that has been updated since this transaction started. The transaction was aborted.
  41305, // The current transaction failed to commit due to a repeatable read validation failure.
  41325, // The current transaction failed to commit due to a serializable validation failure.
  41839, // Transaction exceeded the maximum number of commit dependencies and the last statement was aborted. Retry the statement.
  // System Error
  1203, // 所有していないリソースの解除
  10060, // 接続確立中のエラー
  10061, // 接続確立中のエラー
  11001, // 接続確立中のエラー
];

/**
 * リトライ対象のエラーメッセージ
 */
const RETRY_ERROR_NAMES: string[] = ['ConnectionError', 'TimeoutError'];

/**
 * 再試行処理
 * @param retryIntervalSeconds 待機時間及び、最大試行回数
 */
export function Retry(
  retryIntervalSeconds: number[] = DEFAULT_RETRY_INTERVAL_SECONDS,
) {
  return (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor,
  ) => {
    const original = descriptor.value;
    if (typeof original === 'function') {
      descriptor.value = async function(...args: any[]) {
        let i = 0;
        while (true) {
          try {
            // this -> @Retryが付与されたオブジェクト
            // args -> function呼び出し時の引数
            return await original.apply(this, args);  // 実際のfunctionを実行
          } catch (err) {
            let retry = false;
            if (isObject(err)) {
              const { name, message } = err;
              if (typeof name === 'string') {
                Logger.log(`err.name = ${name}`);
                Logger.log(`err.message = ${message}`);
                // エラーメッセージから、特定エラーのみリトライ
                if (RETRY_ERROR_NAMES.indexOf(name) > -1) {
                  retry = true;
                } else if (name === 'QueryFailedError') {
                  // QueryFailedError でラップされている場合は massage のはじめに元のエラーの型が入る
                  if (
                    typeof message === 'string' &&
                    RETRY_ERROR_NAMES.some(errName =>
                      message.startsWith(errName),
                    )
                  ) {
                    retry = true;
                  }
                }
              }

              if (!retry) {
                // 特定のmssqlのエラー番号によるリトライ
                const { errNumber } = err;
                if (typeof errNumber === 'number') {
                  Logger.log(`err.number = ${errNumber}`);
                  if (RETRY_MSSQL_ERROR_NUMBERS.indexOf(errNumber) > -1) {
                    retry = true;
                  }
                }
              }
            }

            if (retry) {
              const sec = retryIntervalSeconds[i];
              if (sec) {
                // Exponential Backoff
                Logger.warn(err.message);
                Logger.log(`retry after ${sec} seconds.`);
                await sleep(sec * 1000);
                i++;
                continue;
              }
            }
            throw err;
          }
        }
      };
    }
  };
}

参考リポジトリは下記です。

github.com

如何でしたか。Exponential Backoffを含む再試行処理は、クラウドアプリケーションを使用する上では必須の考え方ですので、まだ実装していない方は積極的に実装していきましょう。