Spark SQL 1.3の登場を機にバッチ処理基盤の刷新を考えています。この流れの中でJobSchedulerやSpark SQLをDockerで動かす試み(Docker ComposeでMySQLを使う,DockerでSpark SQL)などを行ってきました。
バッチをSpark SQLで記述し、データや計算量の規模に応じてDocker Cluster(e.g. ECS)またはSpark Cluster(e.g. EMR)を選択してバッチ処理を実行するという枠組みが見えてきました。
次に考えておきたいのはバッチ処理で使用する要素技術の選択です。今回はJSONライブラリについて性能の観点から味見してみました。
なお、あくまでも味見レベルの測定なので、条件を変えると違った結果になる可能性も高いです。また、ありがちですが性能測定プログラムにバグがあって結果が逆にでるようなことがあるかもしれません。このようなリスクがあることを前提に参考にして頂ければと思います。
オンライン処理とバッチ処理
クラウド・アプリケーションはおおまかにいうとWebページやREST APIとして実現するフロント系(オンライン系)と、バックエンドで動作するバッチ系で構成されます。(今後はこれに加えてストリーム系が入ってきます。またバッチ系とはニュアンスの異なる分析系も別立てで考えておくとよさそうです。)
極簡単なアプリケーションではフロント系だけで成立する場合もありますが、ある程度本格的な機能を提供する場合はバッチ系は必須になってきます。また、クラウド・アプリケーション・アーキテクチャの定番であるCQRS+Event Sourcingはバッチ系が主役ともいえるアーキテクチャであり、今後バッチ系の重要度はますます高まってくることは間違いないでしょう。
オンライン系のロジックとバッチ系のロジックでは以下のような特性の違いがあります。
オンライン処理 | バッチ処理 | |
---|---|---|
性能特性 | レスポンス重視 | スループット重視 |
データ規模 | 小 | 大 |
計算量 | 小 | 大 |
実行時間 | 小 | 大 |
エラー処理 | 即時通知 | ジョブ管理 |
こうやって整理すると様々な面で求められる特性が大きく異なることが分かります。
これらの特性を念頭にプログラミングしていくことは当然ですが、使用する要素技術やライブラリもこれらの特性に適合したものを選択することが望ましいです。
オンライン処理はデータ規模や計算量が小さいので使いやすさ重視でよいですが、バッチ処理はデータ規模や計算量が大きいので処理性能やメモリ消費量がクリティカルな要因になります。多少使い難くても、高速、メモリ消費量が少ない、ものを選ぶ方がよいでしょう。
バッチ処理の中のJSON
バッチ処理の中でのJSONの利用シーンですが、主に意識しなければいけないのは大規模JSONファイルの入力ということになるかと思います。ログファイルや移入データがJSON形式で用意されているようなケースですね。
通常この用途のデータはCSVやLTSVで用意されるケースが多いと思いますが、データが木構造になっている場合(いわゆる文書型)にはJSONやXMLデータが適しており、JSONで用意されるケースも多いでしょう。
バッチをSpark SQLで組む場合、データがCSVで用意されていたり、JSONであってもフラットな構造の場合はSpark SQLで簡単に取り込めるのでJSONライブラリの出番はありません。
ということで、ある程度複雑な構造を持ったJSONファイルがメインの操作対象となります。
ただ、バッチをSpark SQLではなく通常のScalaプログラムとして組みたいケースも出てくるはずなので、フラットな構造のJSONファイルに対してもペナルティなしで扱える必要はあります。
ユースケース1: 複雑な構造を持ったJSON→CSV
代表的なユースケースとしては、複雑な構造を持った大規模JSONファイルから必要な項目を抽出してCSVに書き出す、といった用途が考えられます。
SparkやHadoopの前処理として頻出しそうなユースケースです。
このケースでは(1)JSONの基本的なパース性能と、(2)JSONからデータを抽出したりデータのフィルタリングをしたりする処理の記述性が論点となります。
ユースケース2: 複雑な構造を持ったJSON→アプリケーションロジック適用
少し複雑なバッチ処理を行う場合は、アプリケーションロジックを適用するためcase classなどで定義したドメイン・オブジェクトにJSONを変換する必要がでてきます。
case classに変換後は、アプリケーションが管理しているドメインロジックを型安全に適用することができるので、プログラムの品質と開発効率を高めることができます。
ただ、JSONをcase classにマッピングする処理はそれなりに重たいので、JSON段階である程度フィルタリングした上で、必要なデータのみcase classにマッピングする形を取りたいところです。
テストプログラム
検証対象のJSONライブラリは以下の4つにしました。
- Json4s native
- Json4s jackson
- Play-json
- Argonaut
各ライブラリの性能測定対象のプログラムは以下になります。
どのライブラリもほとんど使い方は同じです。ただし、ArgonautのみJSONとオブジェクトのマッピング設定がかなり煩雑になっています。
Json4s native
- package sample
- import org.json4s._
- import org.json4s.native.JsonMethods._
- object Json4sNativeSample {
- implicit val formats = DefaultFormats
- def jsonSimple() = {
- val s = Company.example
- parse(s)
- }
- def jsonComplex() = {
- val s = Person.example
- parse(s)
- }
- def simple() = {
- val s = Company.example
- val j = parse(s)
- j.extract[Company]
- }
- def complex() = {
- val s = Person.example
- val j = parse(s)
- j.extract[Person]
- }
- }
Json4s jackson
- package sample
- import org.json4s._
- import org.json4s.jackson.JsonMethods._
- object Json4sSample {
- implicit val formats = DefaultFormats
- def jsonSimple() = {
- val s = Company.example
- parse(s)
- }
- def jsonComplex() = {
- val s = Person.example
- parse(s)
- }
- def simple() = {
- val s = Company.example
- val j = parse(s)
- j.extract[Company]
- }
- def complex() = {
- val s = Person.example
- val j = parse(s)
- j.extract[Person]
- }
- }
Play-json
- package sample
- import play.api.libs.json._
- object PlaySample {
- implicit val companyReads = Json.reads[Company]
- implicit val addressReads = Json.reads[Address]
- implicit val personReads = Json.reads[Person]
- def jsonSimple() = {
- val s = Company.example
- Json.parse(s)
- }
- def jsonComplex() = {
- val s = Person.example
- Json.parse(s)
- }
- def simple() = {
- val s = Company.example
- val j = Json.parse(s)
- Json.fromJson[Company](j)
- }
- def complex() = {
- val s = Person.example
- val j = Json.parse(s)
- Json.fromJson[Person](j)
- }
- }
Argonaut
- package sample
- import scalaz._, Scalaz._
- import argonaut._, Argonaut._
- object ArgonautSample {
- implicit def decodeCompanyJson: DecodeJson[Company] =
- casecodec1(Company.apply, Company.unapply)("name")
- implicit def encodeCompanyJson: EncodeJson[Company] =
- jencode1L((a: Company) => (a.name))("name")
- implicit def decodeAddressJson: DecodeJson[Address] =
- casecodec2(Address.apply, Address.unapply)("zip", "city")
- implicit def encodeAddressJson: EncodeJson[Address] =
- jencode2L((a: Address) => (a.zip, a.city))("zip", "city")
- implicit def decodePersonJson: DecodeJson[Person] =
- casecodec4(Person.apply, Person.unapply)("name", "age", "address", "company")
- def jsonSimple() = {
- val s = Company.example
- Parse.parse(s)
- }
- def jsonComplex() = {
- val s = Person.example
- Parse.parse(s)
- }
- def simple() = {
- val s = Company.example
- Parse.decodeOption[Company](s)
- }
- def complex() = {
- val s = Person.example
- Parse.decodeOption[Person](s)
- }
- }
テストデータ
Company
- package sample
- case class Company(name: String)
- object Company {
- val example = """
- {"name": "Yamada Corp"}
- """
- }
Person
- package sample
- case class Person(name: String, age: Int, address: Address, company: Option[Company])
- case class Address(zip: String, city: String)
- object Person {
- val example = """
- {"name": "Yamada Taro", "age": 30, "address": {"zip": "045", "city": "Yokohama", "company": "Yamada Corp"}}
- """
- }
測定結果
性能測定は以下のプログラムで行いました。JSONのパース処理を100万回繰り返した時の累積ミリ秒を表示しています。
- package sample
- object All {
- def go(msg: String)(body: => Unit): Unit = {
- System.gc
- val ts = System.currentTimeMillis
- for (i <- 0 until 1000000) yield {
- body
- }
- println(s"msg:{System.currentTimeMillis - ts}")
- }
- def main(args: Array[String]) {
- go(s"Json4s native json simple")(Json4sNativeSample.jsonSimple)
- go(s"Json4s jackson json simple")(Json4sSample.jsonSimple)
- go(s"Play json simple")(PlaySample.jsonSimple)
- go(s"Argonaut json simple")(ArgonautSample.jsonSimple)
- go(s"Json4s native json complex")(Json4sNativeSample.jsonComplex)
- go(s"Json4s jackson json complex")(Json4sSample.jsonComplex)
- go(s"Play json complex")(PlaySample.jsonComplex)
- go(s"Argonaut json complex")(ArgonautSample.jsonComplex)
- go(s"Json4s native simple")(Json4sNativeSample.simple)
- go(s"Json4s jackson simple")(Json4sSample.simple)
- go(s"Play simple")(PlaySample.simple)
- go(s"Argonaut simple")(ArgonautSample.simple)
- go(s"Json4s native complex")(Json4sNativeSample.complex)
- go(s"Json4s jackson complex")(Json4sSample.complex)
- go(s"Play complex")(PlaySample.complex)
- go(s"Argonaut complex")(ArgonautSample.complex)
- }
- }
性能測定結果は以下になります。
ネストなし/JSON
ライブラリ | 性能(ms) |
---|---|
Json4s native | 747 |
Json4s jackson | 788 |
Play-json | 640 |
Argonaut | 872 |
ほとんど同じですがPlay-jsonが速く、Argonautが遅いという結果になっています。
ネストあり/JSON
ライブラリ | 性能(ms) |
---|---|
Json4s native | 1495 |
Json4s jackson | 1310 |
Play-json | 1535 |
Argonaut | 1724 |
ネストありのJSONの場合は、Json4s jacksonが速く、Argonautが遅いという結果になりました。
ネストなし/case class
ライブラリ | 性能(ms) |
---|---|
Json4s native | 2080 |
Json4s jackson | 1601 |
Play-json | 1149 |
Argonaut | 1151 |
JSONをcase classにマッピングする場合は、単にJSONをパースするだけの性能とは全く異なる性能特性になりました。
Json4s系はいずれもかなり遅くなっています。
Play-jsonとArgonautはほぼ同じで高速です。
ネストあり/case class
ライブラリ | 性能(ms) |
---|---|
Json4s native | 5162 |
Json4s jackson | 4786 |
Play-json | 4471 |
Argonaut | 3840 |
ネストありのJSONをcase classにマッピングする場合は、Argonautが高速でした。
Json4sはこのケースでもかなり遅くなっています。
評価
性能測定結果を評価の観点で表にまとめました。
ライブラリ | ネストありJSON性能 | case class性能 | case class設定 |
---|---|---|---|
Json4s native | ◯ | △ | ◯ |
Json4s jackson | ◎ | △ | ◯ |
Play-json | ◯ | ◯ | ◯ |
Argonaut | △ | ◎ | △ |
ネストありJSONのパース性能はJson4sが最速ですが、逆にcase classへのマッピングはかなり遅くなっています。このため、Json4s一択という感じではなさそうです。
ArgonautはJSONパースは遅く、case classへのマッピング性能はよいもののマッピング設定が煩雑なので、使い場所が難しい感触です。Scalazと組合せて関数型的に面白いことが色々できそうなので、その点をどう評価するかということだと思います。
一番バランスがよいのがPlay-jsonで、一択するならPlay-jsonがよさそうです。
ただPlay-jsonはPlayのバージョンと連動しているためdependency hellの可能性があるのが難点です。たとえば、最新版のPlay-jsonを使って共通ライブラリを作っても、サービスで運用しているPlayのバージョンが古いと適用できない、といった問題です。
まとめ
全体的にPlay-jsonが最もバランスがよいという結果が得られました。
個別の性能的には「ユースケース1: 複雑な構造を持ったJSON→CSV」はJson4s jackson、「ユースケース2: 複雑な構造を持ったJSON→アプリケーションロジック適用」はArgonautが適しているという結果が得られました。
ただArgonautはcase classの設定が煩雑なので、広範囲に使える共通処理を作る場合はよいのですが、用途ごとの小さな要件には適用しづらい感じです。そういう意味では、Play-jsonがこの用途にも適していそうです。
以上の点から、次のような方針で考えていこうと思います。
- Play-jsonを基本にする
- 必要に応じて「大規模JSON→CSV」の用途向けにJson4s jacksonを併用
- 必要に応じて「大規模JSON→アプリケーションロジック適用」の用途向けにArgonautを併用
Play-jsonを基本ライブラリとして採用するメリットが大きいので、Play-jsonのdependency hell問題は、運用で回避する作戦を取りたいと思います。
諸元
- Mac OS 10.7.5 (2.6 GHz Intel Core i7)
- Java 1.7.0_75
- Scala 2.11.6
- Json4s native 2.3.11
- Json4s jackson 2.3.11
- Play-json 2.3.9
- Argonaut 6.1