Amazon RDSにおけるFallback-Queueingパターン

よく訓練されたアップル信者、都元です。先日、DynamoDBにおけるスループット超過対策というエントリにて、DynamoDBへの書き込み失敗をSQSでフォローするFallback-Queueingパターン(以下、FQパターン)をご紹介しました。

今回はこのFQパターンを応用して、Amazon RDSのフォローにも一部適用ができることをご紹介します。

RDBとKVS

本エントリでは、リレーショナルデータベース(RDB)やキーバリューストア(KVS)等、データを永続化するシステムを「データストア」と呼ぶことにしましょう。データストアには、次に示すような様々な特徴があったり(なかったり)します。

原子性 (Atomicity)
トランザクションに含まれるタスクが全て実行されるか、あるいは全く実行されないことを保証する性質。
整合性 (Consistency)
トランザクション開始と終了時にあらかじめ与えられた整合性を満たすことを保証する性質。
独立性 (Isolation)
トランザクション中に行われる操作の過程が他の操作から隠蔽される性質。
耐久性 (Durability)
トランザクション操作の完了通知をユーザが受けた時点で、その操作は永続的となり、結果が失われないことを保証する性質。
可用性 (Availability)
ノード障害により生存ノードの機能性は損なわれない(つまり、ダウンしていないノードが常に応答を返す)という性質。単一障害点が存在しないことを表す。
分散化 (Partition-tolerance)
任意の通信障害などによるメッセージ損失に対し、継続して動作できること。つまり、通信可能なサーバーが複数のグループに分断されるケース(ネットワーク分断)に対処できること。

RDBは前半の4つ、Atomicity, Consistency, Isolation, Durability(ACID特性)を確保できる一方で、通常の状態ではAvailabilityやPartition-torelanceを保証できません。AWSのMulti-AZを利用すれば、Availabilityを確保することもできますが、やはりPartition-torelanceを保証することはできません。

実は「Consistency, Availability, Partition-toleranceの3つは同時に満たす事ができない」ということが証明されており、これをCAP定理と呼びます。

この状況に対して、データストアに対する拡張性の要求は高まっており、CAPのうち1つを犠牲にし、BASE特性 *1というACIDよりも緩い制約のもとに、スケーラビリティを確保しようとしたのがKVSです。

KVSにおけるFQパターン

DynamoDBはKVS型のデータストアです。つまり、元々が「整合性はいつか取れる」という哲学のもとにあるため、先日ご紹介したFQとの相性が非常に良いものでした。

詳細はDynamoDBにおけるスループット超過対策を参照してください。

RDSにおけるFQパターン

これに対し、RDBはACIDという強い制約を保証するがために、FQとの相性は、正直言って悪いと言わざるを得ません。

しかし、要求というのは果てしないもので、「スケールアップをしたいけど、その間DBは1秒たりとも止めたくない」という要求が平気で口を衝いて出るのは世の常でございます。CAP定理は証明済みの特徴であって、越える事のできない壁です。しかし、何らかの別の我慢の仕方をすれば、最低限の要求はクリアできるかもしれない…。

そんな時にRDBに対するFQパターンを考えてみるのも良いかもしれません。

実証実験

はーい、前置きが長くなりました。要するにRDBに対してFQを適用します。実例としては、前述の「スケールアップ」のシーンを考えたいと思います。DBには常日頃から止めどなく情報がINSERTされ続けています。この五月雨INSERTを失わないように、DBをスケールアップしてみましょう。

FQパターンを適用しなかった場合

ひとまずアプリを普通に実装した場合、DBのスケールアップを行うと何が起こるか確認してみましょう。

RDSをdb.t1.microのMulti-AZで配備します。アプリケーションにおいては、2秒につき1件ずつレコードをINSERTし、抜けがないかどうかSELECTを試みています。

2013/04/01 20:32:46.490 [xecutor-158] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:32:46.491 [xecutor-158] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
2013/04/01 20:32:46.529 [xecutor-158] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:32:46
2013/04/01 20:32:46.531 [xecutor-158] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:32:46.582 [xecutor-158] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:32:46.599 [xecutor-158] INFO  j.classmethod.aws.sample.fqrds.Tasks:59 - no problems found
2013/04/01 20:32:46.599 [xecutor-158] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end
2013/04/01 20:32:48.490 [xecutor-159] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:32:48.490 [xecutor-159] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
2013/04/01 20:32:48.525 [xecutor-159] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:32:48
2013/04/01 20:32:48.527 [xecutor-159] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:32:48.581 [xecutor-159] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:32:48.602 [xecutor-159] INFO  j.classmethod.aws.sample.fqrds.Tasks:59 - no problems found
2013/04/01 20:32:48.602 [xecutor-159] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end
2013/04/01 20:32:50.490 [xecutor-160] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:32:50.491 [xecutor-160] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
2013/04/01 20:32:50.517 [xecutor-160] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:32:50
2013/04/01 20:32:50.520 [xecutor-160] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:32:50.585 [xecutor-160] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:32:50.594 [xecutor-160] INFO  j.classmethod.aws.sample.fqrds.Tasks:59 - no problems found
2013/04/01 20:32:50.595 [xecutor-160] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end

ここでRDSをdb.m1.smallにスケールアップしてみましょう。

# ここでスケールアップ操作
2013/04/01 20:35:50.494 [xecutor-250] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:35:50.494 [xecutor-250] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
2013/04/01 20:35:50.540 [xecutor-250] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:35:50
2013/04/01 20:35:50.543 [xecutor-250] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:35:50.593 [xecutor-250] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:35:50.605 [xecutor-250] INFO  j.classmethod.aws.sample.fqrds.Tasks:59 - no problems found
2013/04/01 20:35:50.605 [xecutor-250] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end
# ...略...
2013/04/01 20:42:32.490 [executor-51] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:42:32.490 [executor-51] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
2013/04/01 20:42:32.518 [executor-51] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:42:32
2013/04/01 20:42:32.520 [executor-51] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:42:32.567 [executor-51] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:42:32.584 [executor-51] INFO  j.classmethod.aws.sample.fqrds.Tasks:59 - no problems found
2013/04/01 20:42:32.584 [executor-51] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end
2013/04/01 20:42:34.490 [executor-52] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:42:34.490 [executor-52] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
# ここで接続断
2013/04/01 20:42:36.490 [executor-53] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:42:36.491 [executor-53] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
# ...略...
2013/04/01 20:46:50.490 [xecutor-180] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:46:50.490 [xecutor-180] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.999.170.118
2013/04/01 20:46:52.490 [xecutor-181] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:46:52.498 [xecutor-181] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.998.15.13
# ここで復活
2013/04/01 20:46:52.933 [xecutor-181] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:46:52
2013/04/01 20:46:52.941 [xecutor-181] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:46:52.971 [xecutor-181] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:46:52.975 [xecutor-181] WARN  j.c.a.sample.fqrds.ScheduledEntryDao:95 - some problems found:
    id=452, current=2013/04/01 11:46:52, previous=2013/04/01 11:42:32
2013/04/01 20:46:52.977 [xecutor-181] WARN  j.classmethod.aws.sample.fqrds.Tasks:61 -     id=452, current=2013/04/01 11:46:52, previous=2013/04/01 11:42:32
2013/04/01 20:46:52.977 [xecutor-181] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end
2013/04/01 20:46:54.490 [xecutor-182] INFO  j.classmethod.aws.sample.fqrds.Tasks:47 - start
2013/04/01 20:46:54.490 [xecutor-182] INFO  j.classmethod.aws.sample.fqrds.Tasks:51 - addr = foo.bar.us-east-1.rds.amazonaws.com/10.998.15.13
2013/04/01 20:46:54.506 [xecutor-182] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:58 - inserting 2013/04/01 11:46:54
2013/04/01 20:46:54.508 [xecutor-182] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:62 - insert successfully
2013/04/01 20:46:54.538 [xecutor-182] INFO  j.c.a.sample.fqrds.ScheduledEntryDao:72 - checking
2013/04/01 20:46:54.541 [xecutor-182] WARN  j.c.a.sample.fqrds.ScheduledEntryDao:95 - some problems found:
    id=452, current=2013/04/01 11:46:52, previous=2013/04/01 11:42:32
2013/04/01 20:46:54.543 [xecutor-182] WARN  j.classmethod.aws.sample.fqrds.Tasks:61 -     id=452, current=2013/04/01 11:46:52, previous=2013/04/01 11:42:32
2013/04/01 20:46:54.543 [xecutor-182] INFO  j.classmethod.aws.sample.fqrds.Tasks:63 - end

ログを見ると、操作から約6分30秒後にDBへのアクセスが失敗し始め、そのさらに約4分後に接続が戻りました。この時間には、DBサーバ自体の再作成と、そのDNS名のプロパゲーションに掛かる時間が含まれています。復活した途端に、DNS名の解決結果のIPアドレスが変わっているのが分かります。ちなみに、Multi-AZのフェイルオーバーが発生した場合も同様のダウンタイムが発生します。

このように、スケールアップの結果、約4分のダウンが発生し、その間に発行されてINSERTは取り逃す結果となりました。

FQパターンを適用した場合

FQパターンを適用するにあたって、前述のCAP定理もありますので、普通に攻めても負ける(失敗する)だけです。なので、以下のように犠牲となる制約を設定します。

  • スケールアップ中、INSERTリクエストは許すが、UPDATE/DELETEリクエストは許さない。
  • スケールアップ中のINSERTリクエストに対してはSoft-state, Eventual-consistencyを適用。
  • スケールアップ中のSELECTは、ひとまず許さない。ただし、Read Replicaが対応できるかもしれない *2。(本エントリで未検証)

また、DynamoDBの時とは違い、スケールアップは計画的なものだという考えの下、INPUTの経路を手動でRDSからSQSに変更し、その間にスケールアップを実施、終わったら経路を元に戻す、という手順を踏みました。

結果として、一時的に「連続したINSERTデータが一時的に崩れた状態」になりましたが、workerがQueue上のデータを遅延処理した結果、抜けなく全てのデータがRDB上のデータとして記録されました。

という実証実験は行ったのですが、コードやログを羅列しても眠いだけなってしまいます。DynamoDBの時のようにシリアライズでスマートに解決できる感じでもないので、言葉で説明してしまいました。ホントにやりましたよ!信じて…w

とりあえず、ScheduledEntryDaoのDBへのINSERTはこんな感じの実装でした。

public void put(Date now) {
  DateFormat df = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
  logger.info("inserting {}", df.format(now));
  
  if (fallback) {
    sqs.sendMessage(new SendMessageRequest(QUEUE_URL, df.format(now)));
  } else {
    putNoFallback(now);
  }
}

@Transactional
public void putNoFallback(Date now) {
  try {
    jdbcTemplate.update("INSERT INTO `sheduled_entries` (`scheduled_timestamp`) VALUES (?)", now);
    logger.info("insert successfully");
  } catch (Throwable e) {
    logger.error("insert failed: {}", ExceptionUtils.getRootCause(e).getMessage());
  }
}

workerはこんな感じ。

if (dao.isFallback()) {
  logger.info("worker not started");
  return;
}
logger.info("worker start");
List<Message> messages =
    sqs.receiveMessage(new ReceiveMessageRequest(ScheduledEntryDao.QUEUE_URL).withVisibilityTimeout(20))
      .getMessages();
logger.debug("{} messages recieved", messages.size());
DateFormat df = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
for (Message message : messages) {
  try {
    Date date = df.parse(message.getBody());
    dao.putNoFallback(date);
    sqs.deleteMessage(new DeleteMessageRequest()
      .withQueueUrl(ScheduledEntryDao.QUEUE_URL).withReceiptHandle(message.getReceiptHandle()));
  } catch (ParseException e) {
    e.printStackTrace();
  }
}

ちなみに、周期的なタスクの実行にはcron風タスクスケジューリングをpure Javaで実装してElastic Beanstalkにデプロイするでご紹介したメカニズムを利用しました。

まとめ

というわけで、アップデート中のINSERTも漏らす事なく、RDBで受け取ることができました。(出来たんですよ、本当デスヨw)

ただし、前置きにも書いた通り、このFQパターンは、スケールアップ作業中というほんの短い間に、最低限の要件(INSERTを取り逃さない)を満たすだけの応急処置になります。ぶっちゃけ、読者の皆様も「無茶しやがって」感を感じたと思います。私も感じてますw

実際、制約として設定した通り、UPDATE/DELETEはできませんし、SELECTについてのRead Replicaは検証が必要です。やるにしても、使いどころを充分検討をした上で、事前に綿密に計画を立てる必要があるでしょう。

脚注

  1. Basically-Available, Soft-state, Eventual-consistency … 完璧ではないが基本的な可用性が確保される。常に最新の情報が取得できるわけではない。即時ではないが、いつかは整合性が取れる。
  2. できないかもしれない…。