🔖

[ポエム]データベースで消耗している話

2020/09/27に公開1

はじめに

状態を持つWebアプリを作ろうとすると、ほぼほぼ確実にデータベース (DB) が必要になります。ECサイトであれば商品データやアカウントごとに紐付けられた住所や決済のための情報、ゲームであれば手持ちのアイテムや装備品、このzenn.devのような記事投稿型サービスであれば記事そのものの情報か記事を保管した場所を指すメタデータといった具合で、この世にひしめく数多のサービスは背後にデータベースを持っていてそこに何らかの情報を格納しています。

何らかのサービスを作ろうと思い立った時、データベースをどうするかという問題がほぼほぼ確実に立ちはだかることになります。

僕がIT業界に入るよりさらに以前のクラウドがまだ無かった (あるいはまだまだ始まったばかりで全く普及していなかった) オンプレミス時代にはMySQL等の関係データベース (Ralational Database, RDB) しか選択肢が無く、RDBの中からコストや性能等の要件からMySQLにするのか、Oracle Databaseにするのか、はたまたSQL Serverにするのかを決定し、どれを選んだとしても共通の悩みとして出現する「どうやって必要なスループットを確保するか」、「どうやって止まらないようにするか」といったテーマで頭を悩ませ、その上で必要なハードウェアを調達してデータベースを構築していたと会社の大先輩に聞きました。

そしてクラウドが当たり前となった現在に至り、AWS、Azure、GCPといったパブリッククラウドベンダーが提供するDatabase-as-a-Service (DBaaS) によってそもそもハードウェア調達は不要となり、RDBには無い尖った特徴を持ったNoSQL系データベースが多数登場したことで、頭を悩ませるポイントは「どのデータベースが適切か」に切り替わりました。

きっと総じて楽になっているのは間違いないと思うのですが、「銀の弾丸」というのはやはり無いもので、選択肢がどれだけあってもそれで完璧に全てをカバーできるわけではありません。苦労する箇所は残っていますし、複数の尖ったDBが選択肢として上がってきたからこそ生じた苦労もあります。

そんな話をしようと思います。

NoSQL系DBを取り巻く現状

DBで消耗している話の本題に入る前に事前知識としてNoSQL系DBの簡単な概要と何故NoSQL系DBが使われているかという2点に触れたいと思います。

NoSQL

No SQL (SQLにあらず)だとか、いやNot only SQL (SQLに限らない) だとか色々言われていますが、要するにこれまでのRDBとは異なる思想で設計されたデータベースを十把一絡げにまとめてNoSQL系DBなんて呼んだりします。

DynamoDB、CosmosDB、Redis、Cassandra、MongoDB、Neo4j等、それぞれどこかに特徴を持った多数のプロダクトが存在します。

例えばDynamoDBはAWSのサービスで、水平スケール (性能と容量の両方) とスループットに強みを持っていますし、AzureのサービスであるCosmosDBはグローバル分散 (複数リージョンへの展開) についてはDynamoDB以上の機能を持っています。Redisはメモリを使用したキャッシュとして非常に強力ですし、Neo4jはグラフ構造のデータを格納することが可能です。

一方で不得意なこともあり、例えばDynamoDBは複数テーブルにまたがる整合性を確保したクエリの実行 (トランザクション) はできなくはないですがその仕組み上あまり多用するとエラーハンドリングが辛くなりますし、柔軟なクエリを投げたり集計処理をすることはアンチパターンとすら言える程苦手です。

あらゆる全てに対してピタッとハマるDBはあり得ず、それ故に特定のユースケースにハマる数多のデータベースが出現している現状があります。

この辺りの事情はCAP定理という言葉で検索をかけると詳しく出てきます。

FaaSとNoSQL系DB

Function-as-a-Serviceと呼ばれるサービス群があります。AWSならばLambda、AzureならFunctions、GCPならCloud Functionsです。完全なアプリケーション全体ではなく1つの関数をホストし、関数実行にかかった時間や関数の実行回数に対して課金されるサービスです。

その特性上APIの実装と極めて相性がよく、マイクロサービスアーキテクチャを採用したアプリケーションのバックエンドを作ろうと思うとほぼほぼ確実に登場してくるサービスです。 サーバレスコンピューティングの骨格をなす技術でもあります。

FaaSにおいて関数実行を行う際、コンテナやインスタンスと呼ばれる何らかの実行環境が立ち上がります。1つの実行環境が捌ける関数の実行リクエスト数は限られていて、負荷が大きくなってくるとさらに同じ中身を持った別の実行環境が立ち上がり、水平スケールして実行リクエストを捌きます。負荷が落ち着いてくれば実行環境を落として対応します。

最初にRDBとFaaSを組み合わせることを考えてみます。

RDBに対してアクセスを行う際、まずコネクションを確立する必要があります。実行環境が1つ立ち上がり、その度にコネクションが張られます。FaaSでは実行環境が使い回される保証はなく、当然コネクションプーリングによるコネクションの使い回しが難しいため、毎度負荷が揺れる度コネクションを張っては切ってを繰り返す挙動となります。

コネクション確立はそれなりのリソースを必要とするため、どのRDBのエンジンも同時接続数というものが決まっていますが、RDBとFaaSの組み合わせではこれを食い尽くすことが十分に考えられます。障害が発生することになり、エンジニアの心に平穏は訪れません。そんなわけで、FaaSとRDBの組み合わせは長らくアンチパターンでした。 (過去形にしているのは、現在はAWSでは新たに登場したRDS Proxy、AzureではFanctionsの性質と機能により、多少気をつければRDBと組み合わせることが十分に可能なためです)

そこで登場してくるのが分散型のNoSQL系DBです。AWSであればDynamoDB、AzureであればCosmosDBがFaaSとの組み合わせでよく登場するDBです。これらのデータベースは設計レベルで水平スケールによって性能を拡張しやすい構造となっていて、FaaSが発生させる同時接続のリクエストを分散されたその構造によって難なく捌くことができます。

そういうわけで、マイクロサービスアーキテクチャを採用する場合はDynamoDBやCosmosDBのような分散型NoSQL系DBと組み合わせることが王道とされています。

もともとDynamoDBやCosmosDBが生まれた経緯を紐解くと必ずしもマイクロサービスアーキテクチャのために作られたというわけではない(これはこれで面白いので是非調べてみてください) のですが、その性質からマイクロサービスアーキテクチャを採用する場合はまず始めに採用を検討することになるDBと言えます。

DBの特性で苦労する話

本題です。

分散型NoSQL系DBの一種、DynamoDBとLambdaでバックエンドを実装している場合で例を上げて、DynamoDBの特性に反することをする場合にどうするかという話をします。

前提として、要件の大半に対してDynamoDBが適切と判断しての選定ですが、要件といっても大量にあるわけで、その全てについてDynamoDBが得意とする処理だけで実装できるわけではありません。時にはに苦手な処理であっても組み込まなければならないときがある、という状況です。そうした機能はバッサリ切ることもありますが、今回はそういう方向では考えません。

クエリとDB

インデックス

SELECT * FROM customer WHERE age=20 AND group=34;

RDBであれば、こんなSQL句を書けば、customerテーブルからageが20、groupが34となっているようなレコードを取り出すことができます。

ではこれをDynamoDBで行おうとするとどうすれば良いでしょうか。

const queryParams = {
    TableName: customer,
    IndexName: 'age-group',
    ExpressionAttributeNames: {
      '#age': 'age',
      '#group': 'group',
    },
    ExpressionAttributeValues: {
      ':age': 20,
      ':group': 34,
    },
    KeyConditionExpression: '#age = :age and #group = :group',
};
docClient.query(queryParams);

ageやgroupがプライマリキーとなることはあり得ないので、十中八九groupをパーティションキー、ageをソートキーとしたGlobal Secondory Indexを作っておき、上記のようなクエリをかけることとなります。

ここでもし検索条件が増えてさらにsex=femaleを追加しろと言われたお手上げです。RDBは苦も無くやってのけますが、DynamoDBでは全件探索することになります。当然遅いですし、コストもかかります。

分散型NoSQL系DBの大半は複雑なクエリや集計を苦手としています。データの分散化に使用したパーティションキーやソートキーを使用してのクエリがせいぜいです。集計は分散している全データを見に行く必要があるため、アンチパターンの典型例です。

全くクエリをかけられないというのも困るので、DynamoDBであればLSIやGSI、CosmosDBであればデフォルトでオンになってる全データに対するインデックスで一応ある程度のクエリには対応しますが、コストがかかるのでやり過ぎは禁物です。

データ設計

データ設計と銘打っていますが、技術的と言うよりはかなりお気持ちに寄った話をします。

プライマリキー以外で検索をかける場合、DynamoDBであればGSIを張ることを念頭に置いて実装することとなります。ですがGSIを増やしていくとコストが嵩んでいきます。GSIに設定するAttributeは事前定義を求められるので、DynamoDBの良さである自由なデータ構造という部分もある程度制限されてきてしまい、あまり無節操に増やしたくはありません。しかしGSIを張らないよう意地になっていると他の不利益が襲いかかってくることがあります。

例えばUserというテーブルに含まれる「ユーザーA」と「ユーザーB」のデータと、Reservationというテーブルに含まれる「ユーザーAがホスト、ユーザーBがゲストとしてチャットをする予約」のデータがあり、それぞれ一意なIDを持っているとします。このとき、ユーザーはログイン時点で特定できているので、アプリ側ではユーザーのIDは持っているということになります。従ってユーザーの情報を取ることは簡単です。また、予約は1ユーザーあたり複数件持つことでできるとします。

こういうデータ構造のときにあり得そうなデータ取得の流れは以下の2パターンかなと思います。

  1. ユーザーが自身が含まれている予約の情報を取得する
  2. ユーザーが自身が含まれている予約に含まれる他の参加ユーザーの情報を取得する

この2つのデータ取得の流れは具体的には (AとBは可換として) 以下のような手順で表現できます。

  1. ユーザーAのIDを特定 (ログイン)
  2. 予約を特定
  3. 予約のデータ取得
  4. ユーザーBのIDを特定
  5. ユーザーBのデータ取得

ここで最初の問題はユーザーAの情報だけでどうやって予約を特定するかです。考えられるシンプルなやり方は以下の2パターンかと思います。

  1. ユーザーAのデータの中にreservationIdsのような項目を設け、予約のIDをリストとして持っておく。このIDを使ってReservationテーブルから予約の情報を取得する。
  2. 予約のデータの中にhostIdのような項目を設け、ユーザーAのIDを持っておく。hostIdでGSIを張り、ユーザーAのIDでReservationにクエリをかけて取得する。

この場合、僕は恐らく2を選択すると思います。理由は以下3点です。

  • 予約というデータに参加者の情報は必須であり、ユーザーが予約IDを持つより予約がユーザーIDを持つ方が予約データとして完全 (そのデータ1つで予約の全情報を表現できる)
  • 予約IDのリストは人によって長さが異なりユーザーのデータサイズが不均一になってしまうが、不均一なデータサイズは水平スケールするDynamoDBの特性と相性が良くないため避けたい
  • 予約の削除があったときにユーザーAとBの2人に対して同時に整合性を確保した更新処理 (トランザクション) が必要になるが、エラーハンドリングが面倒なので避けたい。

上記のような不利益を避ける代わりに、GSIを作る分のコストを支払うことになります。

そしてさらに予約情報から別の参加者であるユーザーBを特定する場合に、上記2を選択して予約に完全な情報を持たせた利点が活きてきます。つまり、取得した予約にはユーザーBのIDが含まれるため、このIDでGetItem一発でユーザーBの情報を取得できます。もし1を選んでいたら、リストをキーとしたインデックスを張るという良く分からないことをするか、全件探索のいずれかでした。

GSIを受け入れてデータを完全にしたことの利点として、他所でデータを利用する場合の効率という部分も見逃せません。例えば予約情報をデータレイク等に書き出して解析するという要件が発生した場合、1だと複雑なクエリで処理しないと使えない一方2ではほぼそのまま集計に回すことができます。

重要なことは何を優先するかです。DynamoDBが苦手とすることを敢えて行う以上、何かしらの不利益が生じることは避けられません。その不利益が、例えばトランザクションを行う上で生じるエラーハンドリングの手間と書き込みコストの増大を受け入れるということなのか、GSIによってストレージコストと書き込みコストが増大することなのか、後のデータ再利用で頑張って加工する手間をかけることなのかは、状況によって変わってきます。

その「状況」というのは案外多岐に渡っていて、将来の機能アップデートの方針だったり、データ分析だったり、組織の技術レベルや人員の厚みだったり、コストだったりと色々です。必ずしも技術的な領域だけとは限りません。

正直なところ、自分以外の他の誰かが全部そういうことを決めてくれていてコードを書くだけなら楽だと何度も思いましたが、楽をした結果技術的負債を背負うことになるのは自分かもしれないと思うとなかなか辛いものがあります。

DBの組み合わせ

アプリケーションを作っていると検索機能や集計機能を実装したくなることは多々あります。簡単なクエリであれば上に書いたような「ちょっと苦手だけどコスト負担とか運用負担と引き換えに何とかする技術」で何とかしますが、集計や複雑な条件による検索等、それではどうにもならないときがあります。だからといってその部分のためにだけに、DynamoDBを放棄したくはありません。

今回は例示としてDynamoDBがメインでDynamoDBが苦手なことをする場合ということで考えていますが、これはメインが別のRDBであってもNoSQL系DBであっても同じことです。要するに要件の大半をカバーできるDBがあってそれを使いたいのに、一部の要件だけどうしても実現できないときにどうしようかという話です。

90%の要件がDynamoDBに適していて10%だけどうしてもDynamoDBでは実装できないなら、10%のためだけに別のDBを持ってくるという選択肢を考えます。

一例として、DynamoDBのUserテーブルから様々な属性 (地域、性別、得意なこと等複数項目) を持つユーザーを検索して、特定の条件を満たすものだけを選ぶ検索機能を考えます。

これをGSIで実現するのはほぼ不可能です。かといって全件スキャンをしていては規模が非常に小さいうちはよくても大きくなると厳しくなってきます。そもそも何で検索したいかと言えば、膨大な数の検索対象の中から必要なものを絞り込みたいからです。目的を考慮すれば全件スキャンをすべきではありません。

ではどうするか。検索を得意とする別種のデータベースを併用するという方法があります。

組み合わせるデータベースの候補はいくつかありますが、今回の検索という要件を考えると、Elasticsearchが候補の筆頭格となります。DynamoDB StreamでDynamoDBのUserテーブルに対するデータ追加・変更を検知し、Lambda関数に渡してElasticsearchに挿入させるという流れが良さそうです。

ユーザーを検索するクエリはElasticsearchに任せることで極めて柔軟な検索が可能となります。ただし注意点としてDynamoDBのコストに加えてElasticsearchのコストとLambdaのコストがかかりますし、ElasticsearchのフルマネージドサービスはDynamoDB程には柔軟なスケーリングに対応できないですから負荷の監視と十分な性能を確保するための管理が必要となります。それなりに手間がかかります。

さらに、これはユーザーの絞り込みというベストエフォートで問題ない検索要件である場合の話です。どういう事情で検索が必要かという要件次第では整合性確保等の問題も生じてくるので、必ずしも別DBへのコピーが最適解とは限らなくなります。複製元DBと複製先DBの整合性が必要な場合やデータの抜け漏れへの対応を考慮しなければならない場合はあまり向きません。異種DB間で整合性を取るなんて話、死んでもやりたくないですよね。僕はやりたくないです。

トランザクション

トランザクションの定義をちゃんと知らないのですが、ここでいうトランザクションは「複数テーブルをまたぐ変更をきちんとやること」という意味で使っています。テーブルAとテーブルBに同時に変更をかけたいとき、それぞれ独立して変更をかけていると、たまに片方だけ失敗するという事態が発生します。「両方変更されているか、両方とも変更されていないかとどちらかの状態しか取らない」ことを保証するような処理をトランザクションと言います。ACID特性と呼ばれるものです。応用情報とかによく出てきた気がします。 (忘れました)

例えばあるユーザーAがグループGに所属しているとします。ユーザーAがグループGのIDを持つか、グループGがユーザーAのIDを持つか、どちらが良いでしょうか。

この要件の場合は「クエリとDB」で例示したようにはいきません。データが完全かどうかという観点で考えるなら、ユーザーはグループの所属情報を持っている方が完全でしょうし、グループは所属ユーザーの情報を持っている方が完全でしょう。どちらの数が多いか考えて数が少ない方に対してGSIという考え方は十分検討に値するものですが、もしユーザーの追加やグループの変更等がそれほど頻繁でないなら、両方に互いのIDをもたせてトランザクション処理をするという選択肢も僕はアリだと思います。

すなわち、UserテーブルとGroupテーブル (非正規化して両者同じテーブルに収めるのもありとは思いますが簡単のため別テーブルに分けます) の対応するデータに対してトランザクションで同時に変更をかけます。DynamoDBであればTransactWriteで問題なく処理可能です。

ただし注意点として、DynamoDBでトランザクションを使いまくることは公式からすら推奨されていません。処理の競合などによってエラーが発生する確率が高まります。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/transaction-apis.html#transaction-conflict-handling

ここぞというところだけで使うと良さそうです。

ちなみに、RDBであればこの要件を実現することはさほど難しくありません。というかそのまま何も考えずに実装すればほとんどこの要件を勝手に実現します。正規化の度合いにもよりますが、グループの情報を記載したテーブルとユーザー情報 (所属グループを含む) を記載したテーブルがあれば十分でしょう。

何が言いたいの?

メインとして採用しているDBにあまり向かない要件があった場合に

  1. データ構造の設計で何とか回避する
  2. 苦手なことでも多少はできるはずなのでその範囲で頑張ってみる
  3. その要件が得意な別DBを持ってくる
  4. (要件ごと諦める)

ということを考えなければならなくて大変だし結構体力と時間を消耗しているという話をしたかったのです。

「何を贅沢な、昔はRDBしか無かったから全部RDBで何とかしてたんだぞ」という意見はご尤もなんですが、1つのデータベースを使うだけでも大変なのに、それに加えて複数種類のデータベースをちゃんと知っていないといけなくなっていることも十分大変なことと思います。

ちなみに僕は業務で本気でSQL Database及びSQL Serverに向き合っているのですが、あまりにも奥が深すぎて、先輩方のサポートとしてお役に立てそうな水準に到達するまでにどんなに短くても5年はかかりそうな気配を感じています。1つのDBでこれなのに、RDBよりもうちょっと歴史が浅くて発展途上とはいえ、思想的に尖っていてそれぞれ得意・不得意を持つNoSQL系DBをさらに複数種類把握するなど狂気の沙汰と言ったって怒られないのではないかと思っています。

ただそういう選択肢の広さと発展していく技術について本気で考えて最適なものを選び出していく過程がシステム構築の楽しいところでもあるわけで、僕はこの記事で愚痴を言って何かを変えたいわけでも架空のペルソナとバトルしたいわけでもありません。ただ作る中でこういう苦労があるよねという僕の中にあった色々を整理したかっただけです。

例示した対処法以外にも僕が知らない対処法がゴマンとあるでしょうし、有識者の方に指摘して貰えたら嬉しいという下心が多少あります。特に誤りについてはご指摘いただければ大変うれしく思います。

……ところで特定製品に詳しい仲間と分業するという選択肢が無く全部自分でやる前提なのは、僕が実際に手を動かしてシステムを作っている会社においてはエンジニアが2人しか居ない上に金も無いので誰も雇えず自分で何とかするしかないからです。モノが無いとお金稼げないけどお金がないとそもそもモノを作ることがめちゃくちゃ大変という鶏卵問題に僕も全力で苦しんでいます。一番消耗しているのはそこな気がしてきました。

Discussion

rocchoroccho

とても参考になると思い,フォローさせていただきます.
また書いてください.