ここ一ヶ月ほど、API Gateway + Lambda + DynamoDBを使って、APIでDynamoDBを叩くようなものを作った。DynamoDBもLambdaも初心者。で、色々とわかったところあり、わからないということがわかったこともあり、わからないことさえわからないこともありつつ、考えるようにしていること、ハマったところ、気をつけていることなどを書く。
やりたい
小さなWebサービスのために、AWSのLambda関数で作ったAPIでDynamo DBを読んだり書いたりして、調子よくサービスを動かしたい。そのためには
- DynamoDBでテーブルの設計
- DyanmoDBを叩くLambda関数の作成
が必要で、まぁそれぞれ色々とハマったりなんだりしたので、そこらへんを書いていく。
DBのテーブル設計
どのようにDBのテーブルを設計するか。基本的に「DynamoDB のベストプラクティス - Amazon DynamoDB」を頼りにして考えていくのだが、最初読んでも書いてあることの意味がまったくわからなかった。本当にわからなかった。悲しい。自分の阿呆ぶりにウンザリしつつ、まぁでもきっと最初は皆そんな感じじゃなかろうか、と自分を励まし、アレコレいじっていると、読むたびに少しずつおぼろげながらわかるような気がしなくもないところが少しずつ増えてきた。
まずDynamoDBにおけるテーブル設計とはなにかだが、これは畢竟キー設計。パーティションキーとソートキー、グローバルセカンダリインデックスをどのようにするか。
で、キーをどうやって決めるかだが、まず以下の制約について考える。
- 検索に使える列は限られている
- 基本はパーティションキーとソートキーのみ
- パーティションを物理的にイメージすると、検索の制約や課金の事情などが多少わかりよくなる…気がする
- パーティションキーのように振る舞う列を作ることもできるが、コストが増大する(グローバルセカンダリインデックスという)
- ソートキーだけ変えたものはローカルセカンダリインデックス(パーティションが同じ、という意味で「ローカル」らしい)
- それぞれ5つまで
- ソートキーだけ変えたものはローカルセカンダリインデックス(パーティションが同じ、という意味で「ローカル」らしい)
つまり、RDBのように柔軟な検索はできない。だから、パーティションキーとソートキーだけで、アプリケーションで必要とされうるあらゆる検索パターンに対応できるように考えたい。で、どうしても無理だと思ったらグローバルセカンダリインデックス。全部拾ってくるスキャンも可能ではあるのだが、もちろんパフォーマンスは落ちるので、基本的に使わないようにする。
そのうえで、以下の指針を持つ。
- テーブルは少なく、可能であれば1つ
- グローバルセカンダリインデックスは最小に
- パーティションキーはランダムに近く
ベストプラクティスには
優れた設計のアプリケーションで必要なテーブルは 1 つのみです
という記述があり、これが特に衝撃的だった。RDBでは正規化してテーブルを分割していくが、DynamoDBではその逆で、関連するデータはなるべく一箇所に集めるようにする、ということだ。RDBとNoSQLは違う、ということを一番感じたところだった。
なので、基本的にテーブル一つだけでやれるようにキーを考えたい。検索パターン毎にパーティションキーとソートキーを別にしたテーブルを作る、なんてのは当然よろしくないわけだ。
ならばといって、なんでもかんでもグローバルセカンダリインデックスにしてしまってもいけない。5つという制約もあるし、内部的には、別にパーティションを持つようなものなので、書き込みにかかるコストが増える。
さらに、パーティションキーはなるべくランダムになるようにする。たとえば日付をパーティションキーにしてしまうと、今日のパーティションのアクセスは増大し、一方古い日のパーティションのアクセスはほとんどないだろうことが予想される。そのような偏りがあっては具合が悪い、というわけだ。
そう考えていくと、キーとして利用できるものは絞られてくる。たとえばユーザIDをパーティションキー、時刻をソートキーにする、というような設計が考えられる。
他、キーを考えるときには以下の点を考慮するとよい。
- パーティションキーは=で一致させる必要がある
- ソートキーは>や<、betweenやstarts_withが使える
- グローバルセカンダリインデックスでは、パーティションキーとソートキーが一意でなくてもよい
以上の制約と、やりたいことを突きあわせて考えれば、まぁなんとかそれらしいものはできるんじゃなかろうか。
Lambda関数で叩く時に気をつけること
さて、なんとかテーブル設計をしたら、いよいよLambda関数でDynamoDBを叩いていくわけだが、気をつけるべきことは
- すべてのクエリは失敗する可能性がある
- トランザクション機能はない
つまり、常に処理の途中で失敗する可能性があり、かつ自動でロールバックなんて素敵なことはしてくれない。まぁ読み出しで失敗する分には、エラーコードを返すだけなので、アプリケーション側で対応すればよいのだが、これが書き込みだと、データベースに影響があるためそうはいかない。
たとえばユーザをINSERT INTO的な処理で増やし、ユーザ数を管理するアイテムのUPDATEでユーザ数を更新、なんてことを一つの関数でやるとする。で、INSERT INTO的処理後のUPDATE的な処理で失敗したとき、実際には人数が増えているにもかかわらず、ユーザ数は変わらない、という不整合が生じることになる。
なので、書き込みに失敗した時の対応はたいへんなのだが、自分は「Lambda関数内で書き込みは(読み出しを含めた)最後の1回」という方針を取って誤魔化している。これならば、少なくともデータベース内の不整合は避けられるので。
そのほか、DynamoDBの数値型のDecimalはそのままjsonにしてレスポンスのbodyに入れられないとか、空文字は送れないとか(スキーマが決まっているわけでもないのに、あるのかないのかどっちなんだということか)、汎用的な英語はだいたい予約語(「DynamoDB の予約語 - Amazon DynamoDB」)とか、ちょこちょことハマりどころがあるので気をつける。
これから
兎にも角にもなんとか動いている、という状態なのだが、いざ使ってみると考えることがたくさんあるものだなぁと、まぁ当たり前といえばそうなのだが、ずいぶんと悩まされた。データベースの変更がログとして残していないとか、集計作業で毎回クエリとか、あかんなぁと思いつつ…。
それでもなんとかして使いたかった……。。実践と勉強しかない。ベストプラクティスにかかれていることがわかるよう頑張ろう。。。
コメント