Azure SQL Database のリトライ処理を Doma で書いてみる

Microsoft Azure SQL Database*1 では、リトライ処理が必須と言われています。
[SQL Database] アプリケーション作成における推奨事項について (Microsoft Azure SQL Database) - Microsoft SQL Server Japan Support Team Blog - Site Home - MSDN Blogs

.NET の場合は、
Retry Logic for Transient Failures in Windows Azure SQL Database - TechNet Articles - United States (English) - TechNet Wiki
The Design of the Transient Fault Handling Application Block
Entity Framework 6 と SQL Azure をうまく使う、たったひとつの冴えたやりかた - しばやん雑記
等の情報がありますが、Java の場合はあまり見かけません。

JDBC to SQL Azure Best Practices?

今回は Doma を使って試してみました。
リトライの方法として2個試してみました。

  • DataSource#getConnection をリトライする
  • 各 Dao のメソッド呼び出しでリトライする
DataSource#getConnection をリトライする

このパターンはお手軽な感じ。
DataSource を wrap して getConnection だけカスタムする感じ。

package sqldatabase_doma_retry.retryconnection;

import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;

import javax.sql.DataSource;

public class RetryConnectionDataSource implements DataSource {
  private final DataSource datasource;
  public static final int DEFAULT_MAX = 3;
  public static final int DEFAULT_DELAY = 10;
  private final int max;
  private final int delay;
  private int count;

  public int getRetryCount() {
    return count;
  }

  public RetryConnectionDataSource(DataSource dataSource) {
    this(dataSource, DEFAULT_MAX, DEFAULT_DELAY);
  }

  public RetryConnectionDataSource(DataSource datasource, int max, int delay) {
    this.datasource = datasource;
    this.max = max;
    this.delay = delay;
  }

  /**
   * @return
   * @throws SQLException
   * @see javax.sql.DataSource#getConnection()
   */
  public Connection getConnection() throws SQLException {
    count = 0;
    while (true) {
      try {
        System.out.println(count + "回目-");
        return datasource.getConnection();
      } catch (SQLException e) {
        count++;
        if (count > max) {
          System.out.println("リトライ回数超えた-");
          throw e;
        }
        try {
          TimeUnit.SECONDS.sleep(delay);
        } catch (InterruptedException ex) {
          ex.printStackTrace();
        }
      }
    }
  }
  // 他のメソッドはそのまま datasource に委譲...
}

これを Config クラスで利用するだけです。

package sqldatabase_doma_retry.retryconnection;

import javax.sql.DataSource;

import org.seasar.doma.jdbc.Config;
import org.seasar.doma.jdbc.dialect.Dialect;
import org.seasar.doma.jdbc.dialect.MssqlDialect;
import org.seasar.doma.jdbc.tx.LocalTransactionDataSource;
import org.seasar.doma.jdbc.tx.LocalTransactionManager;
import org.seasar.doma.jdbc.tx.TransactionManager;

import com.microsoft.sqlserver.jdbc.SQLServerDataSource;

public class SqlConfig implements Config {
  private final RetryConnectionDataSource retryDS;
  private final LocalTransactionDataSource datasource;
  private final TransactionManager manager;

  private static SQLServerDataSource DEFAULT_DATASOURCE;
  static {
    SQLServerDataSource tmp = new SQLServerDataSource();
    tmp.setUser("user");
    tmp.setPassword("pass");
    tmp.setURL("jdbc:sqlserver://<your server>.database.windows.net:1433;database=<your database>;ssl=require");
    tmp.setLoginTimeout(30);
    DEFAULT_DATASOURCE = tmp;
  }

  public SqlConfig() {
    this(DEFAULT_DATASOURCE);
  }

  public SqlConfig(SQLServerDataSource ds) {
    retryDS = new RetryConnectionDataSource(ds);
    datasource = new LocalTransactionDataSource(retryDS);
    manager = new LocalTransactionManager(
        datasource.getLocalTransaction(getJdbcLogger()));
  }

  @Override
  public DataSource getDataSource() {
    return datasource;
  }

  @Override
  public TransactionManager getTransactionManager() {
    return manager;
  }

  @Override
  public Dialect getDialect() {
    return new MssqlDialect();
  }

  public int getRetryCount() {
    return retryDS.getRetryCount();
  }
}

これだけで他は何も変わりません。

各 Dao のメソッド呼び出しで リトライする

こっちのパターンは、それぞれの呼び出し元でリトライ処理を経由しないといけません。
こんなリトライ用のヘルパーを用意して

package sqldatabase_doma_retry.retrydao;

import java.sql.SQLException;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

import org.seasar.doma.jdbc.JdbcException;
import org.seasar.doma.message.Message;

public class RetryHelper {
  private static final int DEFAULT_MAX = 3;
  private static final int DEFAULT_DELAY = 10;
  // SQL Server デッドロックのエラーコード
  private static final int ERROR_CODE_DEADLOCK = 1205;

  public static <T> T retry(Supplier<T> supplier) {
    return retry(supplier, DEFAULT_MAX, DEFAULT_DELAY);
  }

  public static <T> T retry(Supplier<T> supplier, int maxCount, int delay) {
    int count = 0;
    JdbcException lastEx = null;
    while (count <= maxCount) {
      try {
        System.out.println(count + "回目ー");
        return supplier.get();
      } catch (JdbcException ex) {
        if (isRetryable(ex)) {
          try {
            TimeUnit.SECONDS.sleep(delay);
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
          count++;
          lastEx = ex;
          continue;
        }

        throw ex;
      }
    }

    throw new RuntimeException(String.valueOf(count), lastEx);
  }

  private static boolean isRetryable(JdbcException ex) {
    if (ex.getMessageResource() != null
        && Message.DOMA2015.getCode().equals(ex.getMessageResource().getCode())) {
      return true;
    }

    if (ex.getCause() instanceof SQLException
        && ((SQLException) ex.getCause()).getErrorCode() == ERROR_CODE_DEADLOCK) {
      return true;
    }
    return false;
  }
}

各 Dao のメソッド呼び出しではこんな感じになります。

SqlConfig config = new SqlConfig();
Test1Dao dao = new Test1DaoImpl(config);
config.getTransactionManager().required(() -> {
  RetryHelper.retry(() -> dao.insert(new Test1(1, "aa")));
});

ちょっと面倒な感じですが、リトライするエラーの種類をカスタム出来ます。
また Interceptor 等が使える環境なら、そっちでリトライ処理を書いてしまえば、呼び出し側のコードは特に何も変わらずで良さそうです。

SQL Database + JDBC Driver でのリトライサンプルがあれば教えて下さい~。

コード全部はこちら
sample/sqldatabase_doma_retry at master · OdaShinsuke/sample · GitHub