Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

Pairy : チャットデータを Redis から Amazon DynamoDB に全移行した話(2) 〜DynamoDB導入事例〜

CTOの椎名アマド ( @ima_amataro) です。


前回の記事:「Pairy : チャットデータを Redis から Amazon DynamoDB に全移行した話(1)

前回はRedisをチャットのプライマリのストレージとして使う上での問題点と、
Amazon DynamoDB の特徴などを紹介しました。

今回はDynamoDBの詳細説明と、実際の移行作業と、その際にハマった点をお話していきます。



DynamoDBのテーブル構成

まずは DynamoDB 上のテーブル構成を考えるところから。
Redisにおいてはシンプルな list にチャットを保存していて、

chat.room.{room_id}
{timestamp}:{user_id}:{urlencode(message)}
{timestamp}:{user_id}:{urlencode(message)}
{timestamp}:{user_id}:{urlencode(message)}

こんな感じでした。
これが Amazon DynamoDB になると、

Table definition:

Table Name Primary Key Type Hash Attribute Name(Type) Range Attribute Name(Type)
chat Hash and Range room_id (Number) timestamp (Number)

Other attributes:

Attribute Name(Type) Attribute Name(Type)
user_id (Number) message (String)

こんな感じですね。

一つ一つ説明すると、

  • Table Name

テーブル名です。

  • Attribute

RDBでいう「カラム」のような概念です。nameが名前で、valueが値。

  • Primary Key Type

テーブルのインデックスが何を基準に貼られるかの設定です。"Hash"のみか、"Hash and Range"という2つのキーの組み合わせか、の二択です。
 - "Hash" のみの場合は、Hash Attributeの値のハッシュがインデックスされるのみなので、順番はソートされずに保存されます。
 - "Hash and Range" の場合は、Hash Attributeの値のハッシュに加えてRange Attributeの値をソートしたものがインデックスとして構成されます。
今回のようなチャットデータは、もちろんメッセージ送信順に並んで欲しいのでHash and Rangeをチョイス。
過去のチャットデータをページングで辿っていく際にもRange Attributeを用いてクエリできるので、カンペキです。

※Redisの頃は、特定の日付のメッセージをとってくるためには別途でindex用のハッシュを用意しなきゃいけなかったので非常に助かります

  • Hash Attribute Name

 上記で書いた、インデックスに用いられるハッシュ用のAttributeです。
 基本的には room_id ごとにクエリするので、 room_id をHash Attributeにします。

  • Range Attribute Name

 同じく上記で書いた、インデックスに用いられるソート用のAttributeです。
 時系列なので timestamp を使用します。
 ※ちなみにNumberはIntegerもDoubleも許してます。我々はチャットが高速でやりとりされても良いようにタイムスタンプに小数点4桁ついたマイクロ秒を使用してます

  • Other Attributes

 テーブルの必須の定義に含まれないAttributesです。今回の例ではユーザーのID と メッセージですね。
 本当は DynamoDB テーブルには普通のAttributeに "Secondary Index" というものを貼って(最大5個)、そのAttributeでクエリできるようにもなるんですが、我々は別にuser_idやメッセージ内容からクエリする目的などないので、今回は貼ってないです。



移行戦略

さてテーブルの準備ができたのでいよいよ移行です。
まずは戦略立て。
サービスを1秒たりとも停止させたくないので、下記の流れで進めました。

1. チャットデータをRedisとDynamoDB 両方にwriteするようにしてリリース
2. 過去データ(大量)を RedisからDynamoDBへ移行
3. チャットデータをDynamoDBからreadするにしてリリース
4. Redisへのwriteを廃止

一つずつ解説していきます。



1. チャットデータをDynamoDBにwriteする

データ書き込み処理を書きましょう。
うちは AWS SDK for PHP を使ってるんですが、これをそのまま使うとAttributeの書き方がちょっと面倒くさいです。
書き込むAttributes一つ一つに対してデータ型をちゃんと指定しないといけない。

  $client->putItem(array(
    'TableName'  => 'chat',
    'Item'       => array(
      'room_id'   => array (AmazonDynamoDB::TYPE_NUMBER =>  $room_id),
      'user_id'   => array (AmazonDynamoDB::TYPE_STRING => $user_id),
      'message'   => array (AmazonDynamoDB::TYPE_STRING => $message),
      'timestamp' => array (AmazonDynamoDB::TYPE_NUMBER =>  $timestamp)
    )
  ));

なので、連想配列をそのまま上記形式に変換できるように自分でラッパーのクラスをぱぱっと書いてみたんですが、本当はAWS SDK for PHPに連想配列をAttributes形式に変える素敵なラッパーが用意されていた事に気づきました。
是非AWS SDK for PHPに搭載されているメソッドを使う事をおすすめします。

ちなみに使ってみると下記のように書けます:
※ちゃんとデータ型には気をつけないといけないので、僕は慣習として毎回castしてます

$result = $client->putItem(array(
    'TableName' => 'chat',
    'Item' => $client->formatAttributes(array(
      'room_id'   => (int)$pair_id,
      'user_id'   => (int)$user_id,
      'message'   => (string)$message,
      'timestamp' => (float)$timestamp
    ))
));

注意点:
DynamoDBはアイテム毎のサイズが最大64KBなので、それ以上の大きさだとwriteは失敗してしまいます。
この場合メッセージ本体を分割する必要がありそうですが、
これは「アイテム毎のサイズ」なので他のAttributeのサイズとの累計じゃないといけないです。
大体1KB程度の余裕を持たせて、63KB毎に分割するのが良さそうです。

我々の提供してるチャットには64KBを優に超えるような巨大なメッセージのやり取りもたまにあるので、
サイズをチェックして 63KB を超える場合はメッセージを分割して、投稿タイムスタンプを0.0001秒あげて追加投稿してます
ユーザーが送った1件のメッセージが分割されてしまうのは残念な気もしますが、わざわざ別のDBを用意して
紐付けさせる等の実装の複雑さに比べたら許容できる仕様だろうという判断です。


良い感じに書けたので、こんなものをRedisのwrite処理の直後に加えてリリース。



2. 過去データ(大量)を RedisからDynamoDBへ移行

次に、過去のデータをRedisからDynamoDBに移行するスクリプトの作成です。
Pairy は当時でも1日で数十万のオーダーのチャット量をさばいていたので、量は結構あります。

一度に大量のデータをDynamoDBに流し込む時には batchWriteItem というものが用意されてるのでそれを使いましょう。

batchWriteItemのざっくりとした仕様は:

  • putItem か deleteItem に対応。updateはできない。
  • batchWriteItemの1コールは putItemのみかdeleteItemのみかで統一しないといけない。
  • データサイズは1コールで累計 1MB まで。
  • 一度に 25件まで。
  • アイテム毎のサイズは64KBまで(putItemの仕様)。

僕が試しで動かしてみた時には25件に制限してても、1MBのデータ上限に引っかかって詰まる事がありました。
親切な事に DynamoDB API は、レスポンス内に失敗したputItemを UnproccesedItems というキーの中でちゃんと返してくれるのです。なので、レスポンス処理のところで「UnprocessedItems が返ってきたらそれらのリクエストをリトライさせる」という処理を書けば上手く全部さばいてくれます。

これまた自分でラッパー書いてから、既にAWS SDK for PHPに WriteRequestBatch というUnprocessedItemsのリトライまで扱ってくれるクラスが用意されてる事に気づきました。
是非AWS SDK for PHPに搭載されているメソッドを使う事をおすすめします。

$batchItems = array(
    array(
      'room_id'   => (int)100,
      'user_id'   => (int)50,
      'message'   => (string)"hello",
      'timestamp' => (float)1376038880.0001
    ),
    array(
      'room_id'   => (int)100,
      'user_id'   => (int)51,
      'message'   => (string)"goodbye",
      'timestamp' => (float)1376038890.3456
    )
);

$tableName = 'chat'; 
$putBatch = WriteRequestBatch::factory($client);
foreach ($batchItems as $item) {
    $formattedItem = Item::fromArray($item);
    $putBatch->add(new PutRequest($formattedItem, $tableName));
}
$putBatch->flush();

AWS SDK for PHP の親切さを見くびってました。。。

注意点:
batchWriteItem で流し込むものも、一つ一つは普通の putItem と同じなので、テーブルの負荷はputItemを大量に行なった時と同じように上がっていきます。
なので、batchWriteItemを行なう前にちゃんと Write Capacity Units をそれなりの数字に引き上げないと、すぐにリクエストが Throttle(制限)され始めて、大量に失敗してしまいます。
DynamoDB のコンソールで使用している Capacity Units の量や Throttled Requests の量が常にモニタリングできるので、それを逐一見ながら or 適切な CloudWatch Alarm をセットして作業を行なう事をおすすめします。



3. チャットデータをDynamoDBからreadする

最後にチャットデータからreadする処理を書き上げます。
DynamoDBでreadする時、大きくは Query か Scan のどちらかを使います。

  • Query

 Hash Key や Range Key などインデックスされたAttributesの条件を指定してデータ取得

  • Scan

 データの全件取得を行い、"Scan Filter"という条件が指定されている場合はそれで絞り込み

我々のチャットの場合は Query を使います。
room_id と Limit だけ指定して、ページングして2ページ目以降はtimestampも指定すれば上手く取って来れますね。

この Query も AWS SDK for PHP の Low-Level API をそのまま使って書くとだるいのですが、こればかりは公式ドキュメントを調べても残念ながら良い感じのラッパーはなかったです。
なので、ここでは自作のラッパーを利用します。

要点を書くと、

$limit = 30;
$query = array(
  'room_id' => (int)$room_id
);
if ($timestamp) {
  $query["timestamp <"] = $timestamp;
}

$chatList = $this->dynamodb_model->query('chat', $query, $limit, array(
  'ScanIndexForward'> => false
));

こんな感じの使い方が出来ます。
'timestamp <' の部分は、配列キーに比較演算子を入れれば、ラッパー内でそれをparseして上手くDynamoDBの形式に変換してくれます。
'ScanIndexForward' というのはDynamoDB Query APIで昇順/降順を指定するためのパラメータです。

このラッパーモデルは、現在 CodeIgniter 専用なんですが一応githubに公開してます。
需要があれば汎用的に使えるクラスとして書き直すかもしれないです。
https://github.com/ashiina/codeigniter-dynamodb-model



4. Redisへのwriteを廃止

上記 (1)〜(3) まで出来たら、Redisへのwriteを廃止して移行完了です。
ただ、Redisへのwriteを止めてしまうともう後戻りできなくなるので、ここは慎重に。

我々の場合は (3) を

  • テストユーザー
  • 全ユーザーの10%
  • 全ユーザー

の順番で本番に開放していきました。
10%ユーザーに出してから全ユーザーに出すまでの間は、問い合わせやエラーログをウォッチしながら数日間は様子を見ました。
結果的に大丈夫そうだったので、Redisへのwriteを止めました。



移行完了して、その後

移行完了してからは、ちゃんとDynamoDBの Read/Write Capacity Units を監視した方がいいですね。
最初は Capacity Units を多めに置いておいて、1週間程度トラフィック量がログとして溜まった段階で、適切な Capacity Units の設定をしてあげるとちゃんとコストも削減できます。

もちろんその時にちゃんとアラームも設定しておくようにして、Capacity Units が枯渇しそうになったらいじってあげましょう。

EC2のauto-scalingのように、Capacity Units を負荷状況によって自動的に上げ下げしてくれるツールで
Dynamic DynamoDB
というものもあるらしいです。
僕も使ってないので実際の性能はわかりませんが、ドキュメントを読む感じでは結構簡単に設定できそうですね。
使ってみた感想など聞いてみたいです。



結局「RedisからDynamoDBへの移行」というよりも「DynamoDBの使い方」的な記事になってしまいました。
DynamoDBは、余計な事を考えなくて済むという意味で本当に素敵なデータストレージのソリューションなので、AWSを使ってる人には強くおすすめしたいです。
我々も今後、投稿系DBのスケーリング課題に向き合うためにDynamoDBに移行、というのをまた違う機能で行なっていくかもしれません。

頑張れば、DynamoDBだけでアプリケーション構築する事も不可能では無いです。
しかし、DynamoDBのデータ挿入/取得をCLIでがつがつ行なえるshell等は(僕が知る限りは)存在しないので、ちょっと開発/運用効率的には厳しいかなと思います。
APIを上手くくるんだ DynamoDB shell をどこかサードパーティでもファーストパーティでも出してくれたら嬉しいです。


今後ともカップル専用アプリ Pairy をよろしくお願いします!


================

子育て家族アプリFamm、カップル専用アプリPairyを運営する Timers inc. では、現在エンジニアを積極採用中!急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP : http://timers-inc.com

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる