データ指向アプリケーションデザイン ―信頼性、拡張性、保守性の高い分散システム設計の原理 を読む。
注意
これは私のための要約であり、情報が欠落しています。この記事を情報源として使うには不適切です。
この章の主題
- データのエンコーディングはどうやってアプリケーションの変更と向き合っているか書いてある章
- 特にバイナリーエンコーディングの後方互換性と前方互換性が主題になっている
- 「エンコーディングの進化」だと勘違いしていたけど歴史についてはあまり触れていない
要約
4章
- 後方互換性
- 古いデータを、新しいコードが読めること
- 前方互換性
- 新しいデータを、古いコードが読めること
4.1 データエンコードのフォーマット
データ表現は、2通り。
- インメモリ表現(メモリ内でデータを扱うため)
- 自己完結したバイト列表現(ファイルへ書き出したり、ネットワーク経由で送るため。JSON ドキュメントなど)
エンコーディングとデコーディングという用語を、次のように定義する。
- エンコーディング
- インメモリ表現からバイト列表現への変換
- デコーディング
- バイト列表現からインメモリ表現への変換
4.1.1 言語固有のフォーマット
プログラミング言語には、インメモリ表現からバイト列表現へ変換する、手軽な機能が用意されている。
言語 | 機能 | |
---|---|---|
Java | java.io.Serializable | --- |
Ruby | Marshal | --- |
Python | pickle | --- |
これを長期的に利用するには問題点があり、通常使われない。
- プログラミング言語のロック。別のプログラミング言語に乗り換えづらい
- 任意コードが実行される可能性がある
- バージョニングが後付け
- パフォーマンスが悪いことが多い
4.1.2 JSON、XML、様々なバイナリフォーマット
JSONやXML、CSVは広くサポートされており、人間が読めるフォーマットである。しかし、データの表現が曖昧という欠点がある。
XMLやCSVは、数値なのか、数字列なのか区別できない。JSONは、整数値と浮動小数点数を区別しない上、精度指定はできない。
- XMLやJSONは、バイナリをサポートしていないのでBase64でバイナリデータをエンコードする(このとき平均でデータサイズが33%増える)
- XMLとJSONは、スキーマが定義できる。正規表現や値域を指定できる場合もある
- CSVはスキーマがないので、意味がアプリケーションに依存
欠点はあるが、目的の多くを満たせる。データ交換フォーマットとして合意形成ができていれば、利用は問題ない。
4.1.2.1 バイナリエンコーディング
巨大なデータを扱うなら、コンパクトでパースが高速なバイナリフォーマットが効果的。簡単な例であれば、JSONのバイナリエンコーディング形式のMessagePackがある。しかし、MessagePackはあまりコンパクトにはならない。
4.1.3 ThriftとProtocol Buffers
ThriftとProtocol Buffersは、それぞれFacebookとGoogleによって開発されたバイナリエンコーディングライブラリ。どちらもスキーマ定義を書く。スキーマ定義から、コード生成できる(さまざまなプログラミング言語がサポートされている)。
スキーマ定義が決められているため、フィールド名をデータに含む必要はない(例えば JSON ドキュメントのように)。代わりに、フィールドタグ(フィールド名に対応した整数値)がデータの中に含まれる。スキーマ定義には、フィールドごとにOptionalやRequiredが指定でき、バグ発見に役立つ(実行時にチェックされる)。
4.1.3.1 フィールドタグとスキーマの進化
- フィールドタグを変更できない(フィールドの対応が取れなくなり、不正なデータになる)
- 新しいフィールドタグを使う限り、前方互換は保たれる
- 後方互換を保つには、新しく追加するフィールドは必須にできない。必須にする場合は初期値を指定しないといけない。
- 後方互換を保つには、削除するフィールドはオプショナルに限る
4.1.3.2 データ型とスキーマの進化
フィールドのデータ型を変更できる場合はあるが、値の精度が低くなったり、切り捨てられるリスクがある。
Protocol Buffersでは、リストやデータを扱う専用のデータ型はないが、オプショナルなデータをのちのちリストにしたいときにデータ型を変換できるrepeatedという指定ができる。
Thriftにそういう仕組はないが、ネストしたリストをサポートしている。
4.1.4 Avro
Apache Avroはバイナリエンコーディングのフォーマット。ThriftがHadoopのユースケースに適合しなかったため開発された。Avroもスキーマ定義を使う。Protocol BuffersやThriftと違い、フィールドタグが存在しない。そのため最もコンパクトにデータを表現する。データ型を示すバイトもない。
4.1.4.1 ライターのスキーマとリーダーのスキーマ
Avroのスキーマは、エンコードする側とデコードする側で異なっていてもいい。互換性さえあればいいという考え方が鍵になっている。
Avroのスキーマは、ライターのスキーマとリーダーのスキーマに分けて考える
- ライターのスキーマ
- エンコードするときのスキーマのこと
- リーダーのスキーマ
- デコードするときのスキーマのこと
フィールドの順序が変わっていても、フィールド同士を名前でマッチさせるため問題がない。
4.1.4.2 スキーマの進化の規則
Avroにおける
- 前方互換性とは、新しいスキーマをライターとして持ち、古いスキーマをリーダーとして持てること
- 後方互換性とは、古いスキーマをライターとして持ち、新しいスキーマをリーダーとして持てること
といえる。
互換性を持つために、追加、削除できるフィールドはデフォルト値を持っているフィールドのみ。
Avroは、nullを許容しない。フィールドをnullにしたいときはunion型を使う。また、Protocol BuffersやThriftのようにOptionalやRequiredはない。代わりにunion型とデフォルト値がある。
データ型の変更は、Avroが型変換できる限り可能。
フィールド名を変更するときには、エイリアスを使う。
4.1.4.3 そもそもライターのスキーマとは何なのか?
リーダーは、いくつかの方法でライターのスキーマを知ることができる。
- 同一スキーマの大量なレコードを持つファイルの場合: データの先頭にスキーマを含んでおき、それを参照してデコードする(Hadoopのユースケース)
- 異なるスキーマの個別レコードが書かれているデータベース: バージョン別にスキーマをデータベースに保存しておき、レコードの先頭にバージョンを明記しておき、取り出してデコードする
- ネットワーク経由でレコードを送信する場合: スキーマのバージョンのネゴシエーションを行って接続を確立する。そのスキーマでデコードする
4.1.4.4 動的に生成されたスキーマ
Avroはフィールドタグがなく、スキーマの順序が変わったとしてもフィールド名によって同定される。そのため、動的に生成されたスキーマと相性がいい。例えば、バイナリを含むようなリレーショナルデータベースのダンプに便利。列追加や列削除も簡単。
Protocol BuffersやThriftで実現しようと思うと、フィールドタグの対応付けが別途必要。
4.1.4.5 コード生成と動的型付き言語
ThriftとProtocol Buffersはコード生成に依存している。静的型付き言語では、型チェックやIDEの自動補完を利用できて、有益。動的型付きプログラミング言語では、コード生成するプロセスが増えるし、コンパイル時型チェックがないので、あまり旨みはない。
Avroはコード生成することもできるが、コード生成しなくてもうまく動作する。Avroのオブジェクトコンテナファイルがあるなら、ライブラリをつかってJSONのように中身を見ることができる。必要なメタデータがすべて含まれている自己記述型。Apache Pigような動的型付き処理言語とも相性がいい。
4.1.5 スキーマのメリット
XML SchemaやJSON Schemaは、正規表現や値域のバリデーションをサポートしている。それにくらべて、バイナリエンコーディングはシンプル。シンプルだからこそ広まった。複雑なスキーマ定義言語ASN.1はあまり広まっていない。
スキーマがあることのメリットは、次のとおり。
- フィールド名を含まなくていいので、コンパクトになる
- ドキュメントの代わりになる
- スキーマをデータベース管理しておけば、互換性の確認ができる
- 静的型付き言語ならコンパイル時型チェックできる
4.2 データフローの形態
4.2.1 データベース経由でのデータフロー
データベースでは後方互換性が求められる。後方互換がないと、書き込んだけど、読み出せなくなる。
データベースは、複数のプロセスがアクセスしてくるため
- プロセスAが新しいコードで、DBへレコードXを書き込んだ(フィールドαを追加)
- プロセスBが古いコードでDBからレコードXを読み出して、加工して書き込んだ(フィールドαを無視)
- レコードXは、フィールドαが存在しない状態へ巻き戻る
という現象が起こり得るので、アプリケーション側で注意が必要。
4.2.1.1 異なる値が異なる時刻に書き込まれるケース
データベースには、5年前のデータと5ミリ秒前のデータが共存している。データを新しいスキーマに合わせて書き直す(マイグレーションする)ことはできるが、大規模なデータセットの場合大きな負担になり避けられている。
LinkedInのドキュメントデータベースEspressoはストレージにAvroを使い、Avroのスキーマ進化を利用している。
4.2.1.2 アーカイブストレージ
データベースからバックアップしたり、データウェアハウスのスナップショットを取ったりすることがある。当然、そのときどきでスキーマのバージョンが異なる。
通常、過去のいつのバージョンでも最新のバージョンのスキーマでデコードする。このようなユースケースでは、Avroがおすすめ。あるいは、分析に適した列指向フォーマットのParquetがおすすめ。
4.2.2 サービス経由でのデータフロー:RESTとRPC
4.2.2.1 Webサービス
Webサービスで広く使われるアプローチには、RESTと、SOAPがある。
RESTはHTTPの原理の上に成立させている設計。SOAPはXMLベースのプロトコルでHTTPからできるかぎり独立しており、様々な機能を追加する関連標準が多くある。SOAPは大企業で使われているが、小さな企業ではRESTのほうが好まれる。
4.2.2.2 リモートプロシージャコール(RPC)の問題
RPCは、リモートにあるネットワークサービスへのリクエストの発行を同一プロセス内での関数呼び出しやメソッド呼び出しと同じように見せようとする(場所の透過性と呼ばれる)。しかし、ネットワーク越しのリクエストは、ローカル関数呼び出しとは大きく異なる。
- ネットワークやリモートマシンの状況に大きく左右される。レスポンスが遅いこともあるし、レスポンスが返ってこないこともある。リトライ制御が必要なケースも
- 呼び出しに失敗したときに原因がわからない。リクエストがサーバーに届いているのか、ネットワークの問題なのかも不明
- リクエストが届いていてレスポンスだけ返ってこない場合リトライすることになるが、リクエストに冪等性がない場合、不必要に実行されてしまう
- レスポンス時間が安定しない
- クライアントとサーバーで型の相互変換が行えないケースがある(プログラミング言語が違った場合)
透過的に扱おうとしても、実態は異なる扱いをしなければならない
4.2.2.3 現在のRPCの方向性
RPCは問題点が多いが、なくなっていない。新しい世代のRPCフレームワークの中には、RPCがローカルの関数実行と異なることを明確にしているものがある。FinagleやRest.liは非同期動作をカプセル化したり、gRPCはストリームをサポートしていたりする。
RPCはRESTfulなAPIよりもパフォーマンスが優れている一方、RESTfulなAPIは実験とデバッグがしやすいメリットがある。公開APIはRESTで使われて、同一組織内のサービス間リクエストはRPCが使われている。
4.2.2.4 RPCのデータエンコーディングと進化
- RPCはそれぞれのエンコーディングフォーマットの互換性のルールで進化させられる(Thrift, gRPC, Avro RPC)
- SOAPでは、リクエストとレスポンスはXMLスキーマとあわせて指定されて進化できる
- RESTful APIでは、レスポンスをJSON、リクエストにはJSONまたはURIエンコード/formエンコードされたリクエストパラメーター。オプションのリクエストパラメーター追加やレスポンスへのフィールド追加は互換性が保たれるといえる
APIのバージョン管理をどのようにするべきか、まだ合意がない。RESTful APIならURL内か、Acceptヘッダ内にバージョンを使うのが一般的。クライアント識別にAPIキーがあるなら、APIキーごとにAPIバージョンを保存しておくこともある(管理画面で変えられるようにしておく)。
4.2.3 メッセージパッシングによるデータフロー
メッセージブローカーの利点は次のとおり。
- メッセージブローカーがバッファになるため、受信側の状況はあまり気にしなくていい
- 受信中のプロセスがクラッシュしたとき、メッセージの再配信を行うことでメッセージの欠損を防げる
- 送信側は受信側のIPアドレスやポートを知らなくてもいい
- 一つのメッセージを複数に送信できる
- 送信側と受信側が論理的に分離できる
通常のRPCとの違いは、送信側がメッセージに対するリプライを受信しないのが一般的。
4.2.3.1 メッセージブローカー
ActiveMQ、HornetQ、NATS、Apache Kafkaなどが例として挙げられる。
メッセージブローカーの動作は次のとおり。
- あるプロセスが、ブローカーのトピックへメッセージを送信する
- ブローカーは、1つ以上のコンシューマーにそのメッセージが届いたことを保証する
メッセージブローカーは、データモデルの利用を強制しない。
ブローカーは再送処理をサポートするが、古いデータによる新しいデータの上書きやスキーマバージョンの巻き戻しには注意が必要。
4.2.3.2 分散アクターシステム
分散アクターフレームワークは、Akkaや、Orleans、Erlang OTPなどがある。
分散アクターフレームワークは、メッセージブローカーとアクタープログラミングモデルを結合したもの。アクターモデルはメッセージが失われうることを前提としており、RPCよりも場所の透過性がうまく働く。
AkkaとOrleansはエンコーディングフォーマットを指定できるため、Protocol Buffersのような互換性をもたせてローリングアップデートが可能。Erlang OTPは、Erlang R17で導入されたmapsによってローリングアップデートできる可能性があるが、基本的にスキーマに変更を加えることが難しい。