データ分析の最近の記事

目次


結局、何が違うのか?

こんにちは、アクセルユニバース株式会社、データサイエンティストの世古大樹です。

クラウド環境にデータウェアハウス(DWH)を構築しようとする場合、まず候補に上がるのはAWSの Redshift と、Google Cloud(旧GCP)の BigQuery でしょう。Redshiftが選ばれるのは、主に組織がすでにAWS上にシステムを構築しており、DWHも同じAWS上に作りたいからだと思います。AWSのシェアは国内トップであり(出典1)、「クラウドサービスならAWS」と考える企業も多いです。

一方、データ分析基盤に詳しい人は、Google Cloud のBigQueryを好みがちかもしれません。AWSがクラウドサービス全体をリードしているのに対し、Google Cloud はデータ分析や機械学習の領域に特別な強みがあると知られています。2019年に「DWHにBigQueryを採用しないことは大罪である」と主張するブログ記事(出典2)が公開され、このような意見は今も根強く残っています。

とはいえ、この記事の時からは月日が流れ、データウェアハウスを取り巻く環境は大きく変わりました。上のブログでRedshiftがBigQueryより劣っているとされた理由は「自前のクラスタ管理が必要など、フルマネージドでない」「スケールしない」という運用性の面でしたが、2022年に Redshift Serverless がリリースされ、BigQuery同様のフルマネージド・サーバーレスサービスとして使えるようになりました(今でもBigQueryの長所に「サーバーレス」を挙げる記事は多いですが、Redshiftもとっくにサーバーレスなのです)。

今は、単純に「RedshiftよりBigQueryの方が良い」と言えない状況です。両者の差は縮まっており、ほとんど同じ物のように見えます。RedshiftとBigQueryで共通している主な機能を、筆者が調査できた限りで表にしてみます。1つ1つ読む必要はありませんが、「これほど多くの機能が共通しているならどちらでもいいのでは?」と感じられるのではないでしょうか。

機能ジャンル 機能 備考
処理最適化 自動スケーリング
処理最適化 ストアドプロシージャ
処理最適化 クエリ結果のキャッシュ
処理最適化 マテリアライズドビュー
運用効率化 クエリのスケジューリング
運用効率化 ウェブベースのクエリエディタ Redshift:Redshift クエリエディタ v2
BigQuery:Google Cloud コンソール、BigQuery data canvas
運用効率化 クエリプランの取得 実行中のクエリを細分化したステージや進捗状況を表示する機能
SQLのEXPLAINステートメント
運用効率化 クエリの待ち行列(キュー) Redshift:WLM
BigQuery:クエリキュー
Redshiftはショートクエリを自動で判断して
待ち行列をスキップさせる機能もある。(後述)
運用効率化 手動のテーブル設計・
自動のテーブル最適化
拡張性 フェデレーテッドクエリ・
外部テーブル
DWH外部のストレージや関係データベースにクエリを
かける機能と、そのクエリに必要な便宜上のテーブル
拡張性 半構造化データへのクエリ Redshift:SUPERデータ型
BigQuery:JSONデータ型
拡張性 ユーザー定義関数(UDF) Lambda/Cloud Function を呼び出せるのも同じ。
拡張性 ほぼ全てのBIツールとの円滑な統合
拡張性 SQLでの機械学習モデル構築 Redshift:Redshift ML
BigQuery:BigQuery ML
セキュリティ 保存時暗号化 Redshift:KMSキーでの暗号化(オプション)
BigQuery:Google管理のAESキーでの暗号化(デフォルト)
レジリエンス デフォルトの地理的分散 Redshift:複数AZへの分散
BigQuery:複数ゾーンへの分散
レジリエンス 自動バックアップ Redshift:30分毎の復旧ポイントを0〜35日の設定期間にわたり保持
BigQuery:全時点のタイムトラベルを7日間保持
レジリエンス リージョンレベルの災害復旧 Redshift:スナップショット(手動バックアップ)と
復旧ポイント(自動バックアップ)のクロスリージョンレプリケーション
BigQuery:クロスリージョン・データセットレプリケーション
(プレビュー中の機能)

実際のところ、「どちらを使ってもあまり変わらない」というのも一つの見識だと思います。自社や担当者が詳しい方のクラウドを選択するのも手、です。

しかし、より詳しく見ていくと、両者には明確な違いもあることがわかります。このブログでは、それを 「運用性ならRedshift、コスパならBigQuery」 という視点に整理し、2回に分けて説明します。1回目の今回はRedshiftの運用性を解説します。読者の方が適切なクラウドサービスを選べる助けになれば幸いです。


Redshiftの運用性

Redshiftの運用性が高いと言える根拠は、「1. AWS上の対向システムとの統合容易性」「2. 機械学習ベースの管理タスク自動化」「3. Glue/Lake Formation による一元的なメタデータ・アクセス権限管理」の3つです。


1. AWS上の対向システムとの統合容易性

前述した通り、AWSは国内シェアが高く、クラウドサービス業界を牽引する存在です。そのため多くの組織のITシステムはすでにAWS上に構築されており、オンプレミスでホストされていても、AWSへの移行が検討される場合が多いです。DWHには、これらのITシステムからデータを受け取るための連携が必要なので、DWHも同じAWS上に立てると簡単になります。構成としては、データソース・データレイク・データウェアハウス・データマートをはっきり分けて構築する、以下のようなものが考えられます(あくまで一例です)。

スライド1.jpeg (筆者作成)

Glueが複数種類のデータソースを横断して、簡単にデータ収集・データレイク構築をしてくれるのが見て取れると思います。一方、AWSから Google Cloud へのデータ連携は複雑です。一般的なマルチクラウド構成の複雑さ(クラウド相互の認証認可、学習コスト、運用体制等)はもちろんのこと、RDSから Google Cloud へのデータ転送など、構築自体が大きく複雑になります。

AWS上のシステムと容易に統合できるRedshiftには、 運用簡素性・開発工数・自由度 の面でメリットがあるのです。


2. 機械学習ベースの管理タスク自動化

先ほどRedshiftとBigQueryで共通している機能の表を載せましたが、どちらかにしかない機能もあります。それらの機能の一つ一つは細々としたものですが、「機械学習ベースの管理タスク自動化」とまとめると、塵も積もれば山となる、Redshiftの強みと言って差し支えないものになります。

このことをより具体的に言うと、Redshiftは、 利用しているだけでデータやクエリの状況を自動的に判定し、DWHを最適化してくれる のです。他方のBigQueryは自動最適化の機能が限定的で、BIツールによるクエリを最適化する BI Engine がある程度です。基本的には、モニタリングを行って、その結果を判定し、手動でメンテナンスを行わなければなりません(出典3参照)。Redshiftであれば、機械学習などによって監視・判定・メンテナンスを自動化し、放っておくだけで処理を効率的にしてくれる様々な機能が提供されています。詳しく見ていきましょう。

バラしてしまうと、この節の元ネタは Black Belt(出典4) の、主に pp.20-33 です。この Black Belt 資料自体、Redshiftを理解するうえで必読のものなのでぜひ読んでいただきたいのですが、それはそれとして、この記事では Black Belt をもとにRedshift独自の(=BigQueryにはない)管理機能をまとめ、筆者の観点から補足を行います。


Redshift独自の自動管理機能のまとめ

  • カラム(列データ)の最適化
    • ゾーンマップとソートキー:カラムをあらかじめソートし、ディスクの物理ブロックごとに最小値と最大値を記憶しておくことで、不要なブロックを読み飛ばして処理する。
    • 圧縮エンコードの自動管理:カラムごとに最適なエンコード方式を自動で判定し、圧縮する。(手動選択も可能)
  • テーブルの最適化
    • 自動テーブル最適化:利用状況を判定し、ソートキーや分散キーを必要に応じて自動設定する。(手動設定も可能)
    • Vacuum Delete 自動化:Delete・Update 後の削除領域の開放を、適切なタイミングで自動実行する。(手動実行も可能)
    • ソート自動化:テーブル全体のソートは負荷が高くなるが、機械学習によってソートが必要な部分だけをピンポイントに、適切なタイミングでソートすることで、パフォーマンスやアクセスに影響なくソートできる。(手動ソートも可能)
  • ネットワークの最適化
    • データを均等分散して並列処理に最適化するだけでなく、JOINするレコード同士を同じ場所に置くことで、I/Oを削減できる。
  • クエリ実行の最適化
    • Analyze 自動化:テーブルの統計情報を適切なタイミングで自動更新することで、最適な実行計画のもとにクエリを行える。
    • Auto WLM:ワークロードごとに、物理リソース(メモリ・CPU等)の割当を自動で最適化する。
    • ショートクエリアクセラレーション:クエリの実行時間を予測し、短時間と判断されたクエリは、特別な領域でスムーズに処理される。(オフにもできる)


「ゾーンマップとソートキー」「自動テーブル最適化」について

たとえばテーブルの列 date: DATE がちゃんと日付順に並んでいないと、普通は WHERE date >= '2024-01-01' のようなWHERE句を含むクエリに対してフルスキャンを行う必要があり、応答時間や課金額が無駄に大きくなってしまいます。そこで列をいくつかの塊(1MBのブロックごと)にまとめ、塊の中の最大値と最小値をメモリに保存しておけば、最大値が 2024-01-01 未満である部分を読み飛ばして処理することができ、効率が上がります。これがゾーンマップです。

しかし、これだけではまだ無駄があります。読み込むブロックに含まれる全てのデータが 2024-01-01 以上とは限らないからです。例えば100件中1件のみが 2024-01-01 以上で、99件のスキャンは無駄だった、ということもありえます。そのため、さらに列のデータを降順にソートしておけば、少なくとも最大値が 2024-01-01 以上である最後のブロックの1つ前のブロックまでは全て 2024-01-01 以上ということになるので、効率的な処理が可能です。このようにソートする列を指定するものがソートキーで、適切なソートキーを自動設定してくれるのが自動テーブル最適化です。

スライド2.jpeg (筆者作成)

これらの機能は検索の効率を上げるという点で、インデックスに似ています。BigQueryにはインデックスがあり、一度作成したインデックスを自動管理してくれる機能もあります。しかし、最初にインデックスを作るには手動のDDLコマンドが必要であり、適切なインデックスを作るためにクエリパターンの監視・解析も必要です。管理の簡素さという点で、Redshiftに軍配が上がるところです。


3. Glue/Lake Formation による一元的なメタデータ・アクセス権限管理

これは厳密にはDWHの機能ではありませんが、AWSで多くの場合Redshiftと併用されるサービスのメリットとして挙げます。

「メタデータ」とは何かというと、データレイクやDWHに保存されているデータ(実データ)を説明する情報です。具体的には、テーブル・列・データ型・データソース・データ作成日などの説明です。そしてメタデータは「データカタログ」というデータベースに保管されます。データカタログを調べてメタデータを見れば、どのデータが「使える」データなのか、どの列が何を意味しているのかなどがわかります。DWHに蓄積される膨大なデータを活用するためには、「どこにどんなデータがあるのか」がわかることが重要です(わからないと使えるデータも埋もれてしまいます)。

データに対するアクセス権限管理についても、メタデータが重要な役割を果たします。たとえば、メタデータに「センシティブデータであるかどうか」という列を加え、それを利用してセンシティブデータを閲覧できるユーザーを制限する、というような運用が可能になります。また、(あまり無いことですが)機密情報に関わるメタデータ自体へのアクセスを制限することも考えられます。

AWSではETLとメタデータ・アクセス権限管理が一体化している ため、このあたりの管理が大きく省力化されます。先ほどの構成例をもう一度見てみましょう。

スライド1.jpeg

まず、下の Glue Crawler がデータソースを探索し、メタデータを作って Glue Data Catalog に保管します。保管されたメタデータの使い道は2つあります。1つはGlueのJob(ETLタスク)において、取得対象のデータを特定するために使われます。もう1つは Lake Formation で、テーブルの列単位などのきめ細かい粒度でのアクセス権限を設定するために使われます。Lake Formation のアクセス権限管理はデータレイクだけでなく、データマートにも及びます。Redshiftデータ共有と統合することで、コンシューマーRedshiftのアクセス権限を Lake Formation で管理することができるのです。

要するに、AWSではETLとメタデータがGlueという1つのサービスに合体しており、さらにGlueが統合的なアクセス権限管理を行う Lake Formation と高度に接続されていることで、データの管理負担が抑えられる、ということです。これは大きな特長です。

Google Cloud ではこうは行きません。ETLサービスであるDataflowにはメタデータ管理などの機能がなく、Data Catalog というサービスで別途データカタログを作成する必要があります。また、アクセス権限管理も Data Catalog、Cloud Storage(データレイク)、BigQuery(DWH)で個別に行わなければなりません(かなり大変そうじゃありませんか?)。特に Cloud Storage できめ細かいアクセス権限管理を行うことは、煩雑なため公式に非推奨とされています。Cloud Storage のきめ細かいアクセス権限管理はACL(Amazon S3 のACLと同様のものであり、現在はAWSでも Google Cloud でも非推奨とされている方式)で行う必要があるためです。


きめ細かいアクセスでは、2 つの異なるアクセス制御システム間で調整が必要になります。このため、意図しないデータ漏洩が発生する可能性が高くなり、リソースにアクセスできるユーザーの監査は複雑になります。特に、個人を特定できる情報などの機密データを含むオブジェクトが存在する場合は、均一なバケットレベルのアクセスを有効にしてバケットにデータを保存することをおすすめします。
(出典5)


......と言われても、一部のユーザーから隠さなければならない機密データを含むバケットで均一なアクセスを有効にするとなると、

  1. 機密データを見てはいけないユーザーがそのバケット(=データレイク)全体を見られなくなるか、
  2. 機密データを保管するバケットをデータレイクから分割し、そのバケットにアクセス制限をかけるか


しかありません。1だとそのユーザーのデータ利用が制約されてしまいますし、2だと「すべての生データを1箇所に集約する」というデータレイクのコンセプトそのものに反してしまいます。Google Cloud ではデータレイクのアクセス権限管理に課題があると言わざるを得ないでしょう。


まとめと次回予告

今回の記事では、BigQueryと比較したときのRedshiftの運用性について解説しました。ポイントをまとめておきます。


  1. RedshiftもBigQueryも、多くの機能が共通しているが、詳しく見ると「運用性ならRedshift、コスパならBigQuery」という違いがある。
  2. Redshift中心の構成では、AWS上の対向システムからデータ収集を行うのが簡単。
  3. Redshiftは、自動管理機能によって設定・監視・メンテナンスの負担が小さくなる。
  4. AWSでは、ETL・メタデータ・きめ細かいアクセス権限管理が Glue/Lake Formation に一元化されており、効率的な運用が可能。


次回は、実際にクエリを投げてかかった費用と応答時間を比較しながら、「BigQueryのコスパ」について解説します。「BigQueryはコストパフォーマンスに優れている」とはよく言われますが、必ずしもRedshiftより安くて速い訳ではありません。それでもBigQueryにあるコスパ上の利点とは何でしょうか?(次回記事はこちら


出典(いずれも記事公開時点閲覧)

  1. 総務省『情報通信白書令和6年度版 データ集』「第4章第8節 7. PaaS/IaaS利用者のAWS、Azure、GCP利用率」 https://www.soumu.go.jp/johotsusintokei/whitepaper/ja/r06/html/datashu.html#f00281
  2. データエンジニアの酩酊日記『近年のデータ分析基盤構築における失敗はBigQueryを採用しなかったことに全て起因している』 https://uma66.hateblo.jp/entry/2019/10/17/012049
  3. Google Cloud『BigQuery 管理の概要』 https://cloud.google.com/bigquery/docs/admin-intro?hl=ja
  4. Amazon Web Services『Amazon Redshift 運用管理 AWS Black Belt Online Seminar』 https://pages.awscloud.com/rs/112-TZM-766/images/AWS-Black-Belt2023Amazon-Redshift-Management0331v1.pdf
  5. Google Cloud『アクセス制御の概要』 https://cloud.google.com/storage/docs/access-control?hl=ja




X(旧Twitter)・Facebookで定期的に情報発信しています!

目次


「費用対効果」で選ぶには?

こんにちは。アクセルユニバース株式会社、データサイエンティストの世古大樹です。

今回は、以前のデータウェアハウス比較記事の続編で、BigQuery と Redshift Serverless のコストパフォーマンスを比較する実験を行いました。

詳しくは後述しますが、同じデータセットに対して同じクエリを実行し、かかった料金(コスト)と応答時間(パフォーマンス)を計測しています。

データセットは、80GiB程度の構造化データと360GiB程度の半構造化データの2種類を利用します。それぞれのデータセットに対し、性質の異なる3つのクエリ(合計6つのクエリ)を書きました。クエリ1つにつき5回実行して、特有のオーバーヘッドがある1回目を除外し、2〜5回目の計測結果の平均値を取っています。

「BigQueryの方が安価で性能が良い」とよく言われているようですが、実際に比較した結果を公開しているものは見当たりません。本記事を読めば、コストとパフォーマンスに根拠を持ってデータウェアハウスの選定ができるようになります。


結果:BigQueryは「安定」している

実験の詳細は後回しにして、いきなり結果から発表したいと思います。詳細は .xlsx ファイル(こちらからダウンロード)でご確認いただけますが、ここでは概要の解説を行います。

まず、「コスト」にあたる平均課金額は以下のようになりました。(データセットやクエリの内容は後述します)

スクリーンショット 2024-10-01 15.49.13のコピー3.png

「差」「比」はともに「BigQuery 容量コンピューティング」と「Redshift Serverless」を比較したもので、BigQueryが有利な場合は青色、Redshiftが有利な場合は赤色で表現しています(以降の表も同様)。

「比」を見ると、思いのほか傾向が一定していないのが驚きです。BigQueryの方が安いクエリも、Redshiftの方が安いクエリもあります。クエリ1-1はBigQueryの方が約13倍高い一方、2-2はRedshiftの方が15倍高くなり、ばらつきが大きいようです。

次に、「パフォーマンス」にあたる平均応答時間は以下のようになりました。

スクリーンショット 2024-10-01 15.49.13のコピー2.png

BigQueryの応答時間は比較的安定している(425〜27,212ms、約64倍)のに対し、Redshiftはかなり開きがある(68〜158,411ms、約2300倍)のが目を惹きます。RedshiftのRPU(仮想的な演算リソース)は、BigQueryと近い応答時間になるように設定したのですが、実際に比を1付近にできたのは1-2と2-3だけです。1-1と2-2ではRPUを最低にしてもBigQueryより大幅に速くなり、逆に1-3と2-1ではRPUを非常に大きくしてもあまり速度が上がらず、BigQueryには遠く及びませんでした。

「BigQueryの方がパフォーマンスが高い」とよく言われていますが、むしろ正確には、「BigQueryの方がパフォーマンスが安定している(Redshift Serverless ほど極端に長い応答時間を必要としない)」傾向にありそうだ、ということが読み取れます。

また、平均課金額の表で垣間見えていた、各DWHにとって「どのクエリが得意/不得意か」の傾向は、応答時間にも共通しており、課金額の比が大きい順に平均応答時間の比も大きくなりました。つまり、どちらのDWHにも、「得意なクエリは安いし速い」「苦手なクエリは高いし遅い」という相対的な傾向があるようです。

そこで、各クエリがそれぞれのDWHにとってどの程度「得意」かを表す指標を作ります。平均応答時間の逆数を、クエリに対するDWHの「パフォーマンス」、平均課金額を「コスト」と捉え、パフォーマンスをコストで割ります。この「コストパフォーマンス指数」が高いほど、そのDWHがそのクエリを「得意」としていることを示します。

スクリーンショット 2024-10-01 15.49.13のコピー.png

まず行ごとに「比」を見ると、BigQueryがRedshiftの何倍のコストパフォーマンスを示したかがわかります。全体的にはBigQueryが上回っており、特に2-1では210倍もの差になっていますが、6個中2つのクエリではRedshiftが優勢で、1-1では83倍の差です。必ずBigQueryの方が優れているとは言い切れず、「どちらが良いかは場合による」ことになります。

実験した限りでは、Redshiftが上回った場合の共通点は、「RPUが最低の8でも非常に速いこと(数秒以内)」でした。「CTE(サブクエリ)があるとRedshiftが不利になる傾向にある」ということも言えそうですが、サブクエリはメモリサイズをボトルネックとする処理なので、「メモリサイズと比例するRPUの数を増やしても、サブクエリを含むクエリがあまり速くならないこと」の説明がつきません。事前には、BigQueryではインデックスを張れるのに対しRedshiftではソートキーの指定しか行えないため、JOINが含まれるとBigQueryが大幅に速くなると予想していたのですが、そういった傾向もありませんでした。

困ったことに、BigQueryの測定から得られる情報(スロット数・応答時間・処理データ容量)からは、Redshiftの方が良いのかどうか予想できません。言えるのは、「Redshift Serverless を使っている場合、RPU数が小さくなければBigQueryに移行した方が良くなる見込みが大きい」ということです。

次に列ごとに(縦に)表を見ると、こちらははっきりした傾向が読み取れます。値が大きいほどそのDWHにとって簡単な処理で、逆に小さいほど難しい処理だということになり、最大値と最小値の幅が狭いほど、常に安定したコストパフォーマンスを発揮できることになります。BigQueryの方が、平均課金額・平均応答時間ともに幅の開きが小さかったため、コストパフォーマンス指数も安定しました。BigQueryは 0.00012〜2.4 の20,000倍、Redshiftは 0.0000023〜200 の約90,000,000倍です。「BigQueryのコストパフォーマンスは、Redshift Serverless に比べて、(優れているというより)安定している」というのが、この実験における最も重要な結論です。

平均前の測定値、RPU数、課金額の計算方法などの詳細、および注意事項などは、 .xlsx ファイル(こちらからダウンロード)をご覧ください。


実験条件

以下では、どのような条件の下に実験を行ったのかを、「データセット」「データウェアハウス設定・計測条件」「クエリ」に分けて述べます。ただし、実際に利用したクエリ文等は非常に長いので、末尾の「コード集」にまとめて掲載しました。ここでは日本語ベースで、概要や意図だけを説明します。


データセット

利用したデータセットの1つ目は、Chicago Data Portal の Taxi Trips (2013-2023) です。シカゴ市が公開している、市内のタクシーの賃走についてのデータセットです。11年分の運行について、タクシーID、開始・終了時刻、走行距離、運賃などが記録されています。CSV形式で23列×約2億行、解凍時サイズは約80GiBの、単一テーブルのデータです。表では「小規模構造化データ」と呼んでいます。

2つ目は、Hugging Face にある McAuley-Lab の Amazon-Reviews-2023 です。1996年から2023年までに amazon.com へ投稿された商品レビューが収集されています。レビューデータの集まりである User Reviews、レビューされる商品の情報である Item Metadata の2種類のデータがあり、さらにそれぞれ商品カテゴリでデータセットを分割されています。実験では、商品カテゴリによって分割されたデータを1つにまとめ、種類に応じて2つのテーブルとしました。いずれもJSON形式で、User Reviews は約260GiB、Item Metadata は約90GiB、計360GiBほどです。表では「中規模半構造化データ」と呼んでいます。

いずれも GCS/S3 バケットに保存し、そこから BigQuery/Redshift へロードしました。


データウェアハウス設定・計測条件

今回の実験では、「実際にDWHの利用を開始した時のパフォーマンスを再現すること」を目指し、現実の利用開始時に近い設定としました。

パフォーマンス最適化の基本としては、BigQueryではインデックスの作成、Redshiftではソートキーの指定ができます。DWHの利用が進んでいけば、メトリクスに基づいて最適なインデックスを構築したり、ソートキーが自動的に最適化されたりするところですが、普通、利用開始時にはそこまで行わないと思います。とはいえ、最適化を何も行わないのもリアリティがありません。そこで今回は、「インデックスは主キーにのみ張り、ソートキーにはタイムスタンプ列を指定する」というシンプルな設定にしました。また、RedshiftのVACUUMは、各データベースでデータロード完了後の1度のみ実施しました。

計測に際しては、信頼度の高いデータとするため、リザルトキャッシュの取得を無効化した上で5回連続で同じクエリを行い、2〜5回目の計測結果の平均値を取りました。1回目の結果を除くのは、Redshiftで初回のみコードコンパイルが行われるなど、例外的なオーバーヘッドが発生しうるからです。

リージョン(ロケーション)については、AWS・Google Cloud ともに全て東京単一リージョンで実行し、料金も東京の金額で計算しました。

BigQueryの課金モデルは、予約したスロット(仮想的な演算リソース)の利用量に基づく「容量コンピューティング」と、スキャンしたデータ量に基づく「オンデマンド」の2種類があり、さらに「容量コンピューティング」には3つのエディションがあります。BigQueryの利用を開始する場合、まず「オンデマンド」で自社の利用量等を把握し、「容量コンピューティング」の予約に移行することが想定されていますが、初めから「容量コンピューティング」を使うことも十分あり得ます。そこで今回は、「オンデマンド」を利用してその課金額と処理データ量を記載しつつ、「容量コンピューティング・Standardエディション」の場合のスロット数と課金額も計算し、記録しました(こちらの数値を元に、他のエディションの金額も計算可能です)。

Redshift Serverless の課金額は、RPU(仮想的な演算リソース)の利用量に基づきます。このRPUのベース値は8〜512の間で指定することができ、大きくすると応答時間は短くなりますが、ある程度を超えると短縮効果がコストの増加に見合わなくなります。今回は、原則的にBigQueryの応答時間と近くなるように設定しつつも、コストに見合わないほど大きな値にはしないようにしました。例えばクエリ1-3の応答時間は、RPUが128のとき約160秒でしたが、最大の512に設定しても約100秒と、40%の時短に対して2.5倍のコストになります(コストパフォーマンス指数は0.64倍)。BigQueryは約20秒でしたが、それにできるだけ近づけるのではなく、現実的なラインと考えられた128RPUに設定しました。

なお、最適なRPU数を探索するためのクエリは、測定に影響を及ぼさないように、別のデータベースで行いました。また、Redshift Serverless には1分間の最低課金時間があり、1分未満のクエリであっても1分間分の料金が発生します。しかし実際の利用においては、1分未満のクエリも、他のクエリと同時に実行することで合計1分以上にできると考えられるため、測定されたままのミリ秒数に基づいて料金を計算しました。

以上の料金は、演算(クエリ)に対する費用であり、実際にはストレージ(データ保存)に対する費用も発生しますが、そちらは(通常の利用では)演算に対する費用に比べてかなり安価になることと、デモを行わなくても簡単に見積が取れることから、計測を行いませんでした。また、本記事に掲載されている料金についての情報は公開時点のものであることにご注意ください。料金の詳細について、BigQueryはこちら、Redshiftはこちらをご覧ください。

計測結果の取得には、BigQueryでは後に掲載するクエリを用いました。Redshiftでは、クエリエディタv2のリザルトに表示される Elapsed time で応答時間を計測し、応答時間にRPU数とRPU時間あたりの料金を乗じて課金額を計算しました。RPU数が指定したベースRPU数の通りであることはコンソール上で確認しました。


クエリ

DWHパフォーマンスのボトルネックとして代表的な処理は、ディスクI/OとCPUに負荷のかかるソート(結合・ORDER BY句・ウィンドウ関数)と、メインメモリを消費するサブクエリです。そのため、DWHの主なワークロードである集計(集約関数)を全クエリに含めながら、これらの句や関数の様々な組み合わせを試しました。

できるだけ標準SQLに準拠するようにしました。方言の違いにより、BigQueryとRedshiftで全く同じクエリ文にはなりませんでしたが、同じ処理を実現するものになっています。

また、クエリは集計・分析として意味を持つように作成し、コード集にはその説明も付けておきました。


コード集

前処理

Amazon-Reviews-2023 の Item Metadata は、そのままだと BigQuery の仕様でエラーが出るので、以下のPythonスクリプトで前処理しました。Redshiftの場合は前処理なしで読み込めますが、同一条件で比較するために、同じく前処理したデータをロードしています。



import json
import re

def clean_columns(obj):
    if isinstance(obj, dict):
        keys = list(obj.keys())
        for key in keys:

            # テーブル定義に合わないデータ型を修正する。
            if key == 'rating_number' and not isinstance(obj[key], int):
                del obj[key]
            elif key in ('average_rating', 'price') and not isinstance(obj[key], float):
                del obj[key]
            elif key == 'details' and not isinstance(obj[key], dict):
                del obj[key]
            elif key in ('videos', 'images', 'features', 'description', 'categories', 'bought_together') and not isinstance(obj[key], list):
                obj[key] = []
            elif key == 'images':
                images = obj[key]
                for image in images:
                    for image_size in list(image):
                        if not image_size in ('thumb', 'large', 'variant', 'hi_res'):
                            del image[image_size]
                            print(str(image_size) + 'を削除しました。')

            # BigQueryの仕様に合わせたキーの前処理:使用できない特殊文字を消去し、大文字・小文字の違いを無視する。衝突が発生するか空文字列になる場合は削除する。
            cleaned_key = re.sub(r'[!"$()*,./;?@[\]^`{}~]', '', key.lower())
            if key != cleaned_key or cleaned_key == '':
                if cleaned_key in obj or cleaned_key == '':
                    del obj[key]
                else:
                    obj[cleaned_key] = obj.pop(key)

        for value in obj.values():  # ネストされたオブジェクトに対して再帰的に処理を行う。
            clean_columns(value)
    return obj

def main():
    with open('item_metadata_before_cleaned.jsonl', 'r') as input, open('item_metadata.jsonl', 'w') as output:
        for line in input:
            obj = json.loads(line)
            obj = clean_columns(obj)
            cleaned_line = json.dumps(obj)
            output.write(cleaned_line + '\n')

if __name__ == '__main__':
    main()


データロード等

Taxi Trips (2013-2023), BigQuery

このデータセットのタイムスタンプ形式はBigQueryにサポートされていないので、一度文字列型で読み込んでから変換しています。 また、各データセット共通ですが、BigQueryでは4GB以上のファイルを圧縮形式でロードできないので、解凍したデータを読み込んでいます(Redshiftでは圧縮したまま利用しています)。



CREATE SCHEMA taxi_dataset 
  OPTIONS(
      location='asia-northeast1',
      storage_billing_model='LOGICAL'
  );

CREATE TABLE taxi_dataset.TaxiTrip (
    trip_id STRING,
    taxi_id STRING,
    trip_start_timestamp STRING,
    trip_end_timestamp STRING,
    trip_seconds INT,
    trip_miles FLOAT64,
    pickup_census_tract STRING,
    dropoff_census_tract STRING,
    pickup_community_area INT,
    dropoff_community_area INT,
    fare FLOAT64,
    tips FLOAT64,
    tolls FLOAT64,
    extras FLOAT64,
    trip_total FLOAT64,
    payment_type STRING,
    company STRING,
    pickup_centroid_latitude FLOAT64,
    pickup_centroid_longitude FLOAT64,
    pickup_centroid_location GEOGRAPHY,
    dropoff_centroid_latitude FLOAT64,
    dropoff_centroid_longitude FLOAT64,
    dropoff_centroid_location GEOGRAPHY,
    PRIMARY KEY(trip_id) NOT ENFORCED
);

LOAD DATA INTO taxi_dataset.TaxiTrip
FROM FILES (
    allow_jagged_rows=true,
    field_delimiter=',',
    format='CSV',
    null_marker='',
    skip_leading_rows=1,
    uris=['gs://my-bucket-name/TaxiTrip.csv']
);

ALTER TABLE taxi_dataset.TaxiTrip
    ADD COLUMN new_trip_start_timestamp TIMESTAMP,
    ADD COLUMN new_trip_end_timestamp TIMESTAMP;

UPDATE taxi_dataset.TaxiTrip
    SET
        new_trip_start_timestamp = PARSE_TIMESTAMP('%m/%d/%Y %I:%M:%S %p', trip_start_timestamp),
        new_trip_end_timestamp = PARSE_TIMESTAMP('%m/%d/%Y %I:%M:%S %p', trip_end_timestamp)
    WHERE true;

ALTER TABLE taxi_dataset.TaxiTrip
    DROP COLUMN trip_start_timestamp,
    DROP COLUMN trip_end_timestamp;

ALTER TABLE taxi_dataset.TaxiTrip
    RENAME COLUMN new_trip_start_timestamp TO trip_start_timestamp,
    RENAME COLUMN new_trip_end_timestamp TO trip_end_timestamp;

CREATE SEARCH INDEX tt_pk_index    -- 主キーにインデックスを張る。
    ON taxi_dataset.TaxiTrip(trip_id);


Taxi Trips (2013-2023), Redshift Serverless



CREATE DATABASE taxi_database;

CREATE TABLE IF NOT EXISTS
    taxi_database.public.TaxiTrip (
        trip_id CHAR(40),
        taxi_id CHAR(128),
        trip_start_timestamp TIMESTAMP,
        trip_end_timestamp TIMESTAMP,
        trip_seconds INT,
        trip_miles FLOAT,
        pickup_census_tract VARCHAR(255),
        dropoff_census_tract VARCHAR(255),
        pickup_community_area INT,
        dropoff_community_area INT,
        fare FLOAT,
        tips FLOAT,
        tolls FLOAT,
        extras FLOAT,
        trip_total FLOAT,
        payment_type VARCHAR(64),
        company VARCHAR(255),
        pickup_centroid_latitude FLOAT,
        pickup_centroid_longitude FLOAT,
        pickup_centroid_location GEOMETRY,
        dropoff_centroid_latitude FLOAT,
        dropoff_centroid_longitude FLOAT,
        dropoff_centroid_location GEOMETRY,
        PRIMARY KEY(trip_id)
    )
    BACKUP NO
    COMPOUND SORTKEY(trip_start_timestamp, trip_end_timestamp);    -- タイムスタンプ列をソートキーにする。

COPY taxi_database.public.TaxiTrip (
        trip_id, taxi_id, trip_start_timestamp, trip_end_timestamp, trip_seconds, trip_miles, 
        pickup_census_tract, dropoff_census_tract, pickup_community_area, dropoff_community_area, 
        fare, tips, tolls, extras, trip_total, payment_type, company, 
        pickup_centroid_latitude, pickup_centroid_longitude, pickup_centroid_location, 
        dropoff_centroid_latitude, dropoff_centroid_longitude, dropoff_centroid_location
    )
FROM 's3://my-bucket-name/TaxiTrip.csv.gz'
IAM_ROLE 'my-role-arn'
REGION 'ap-northeast-1'
CSV
DELIMITER ','
NULL AS ''
IGNOREHEADER 1
TIMEFORMAT 'MM/DD/YYYY HH:MI:SS AM'
GZIP;

VACUUM;


Amazon-Reviews-2023, BigQuery



CREATE SCHEMA amazon_reviews_dataset 
    OPTIONS(
        location='asia-northeast1',
        storage_billing_model='LOGICAL'
    );

CREATE TABLE amazon_reviews_dataset.ItemMetadata (
    main_category STRING
    , title STRING
    , average_rating FLOAT64
    , rating_number INT
    , features ARRAY
    , description ARRAY
    , price FLOAT64
    , images ARRAY>
    , videos ARRAY> 
    , store STRING
    , categories ARRAY
    , details JSON
    , parent_asin STRING 
    , bought_together ARRAY
    , PRIMARY KEY(parent_asin) NOT ENFORCED
);

/*
ItemMetadataのみ、スキーマ自動検出でエラーが出るので、スキーマ自動検出をオフにするため、SQLではなく以下のbqコマンドでロードする。

$ bq --location=asia-northeast1 load \
--noautodetect --source_format=NEWLINE_DELIMITED_JSON \
amazon_reviews_dataset.ItemMetadata \
gs://my-bucket-name/item_metadata.jsonl
*/

CREATE SEARCH INDEX im_pk_index    -- 主キーにインデックスを張る。
    ON amazon_reviews_dataset.ItemMetadata(parent_asin);

CREATE TABLE amazon_reviews_dataset.UserReviews (
    rating FLOAT64
    , title STRING
    , text STRING 
    , images ARRAY>
    , asin STRING
    , parent_asin STRING 
    , user_id STRING
    , timestamp INT -- UNIX時刻。
    , verified_purchase BOOLEAN
    , helpful_vote INT
    , PRIMARY KEY(parent_asin, user_id) NOT ENFORCED
    , FOREIGN KEY(parent_asin) REFERENCES amazon_reviews_dataset.ItemMetadata(parent_asin) NOT ENFORCED
);

LOAD DATA INTO amazon_reviews_dataset.UserReviews
FROM FILES (
    format='NEWLINE_DELIMITED_JSON',
    ignore_unknown_values=true,
    uris=['gs://my-bucket-name/user_reviews.jsonl']
);

CREATE SEARCH INDEX ur_pk_index    -- 主キーにインデックスを張る。
    ON amazon_reviews_dataset.UserReviews(parent_asin, user_id);


Amazon-Reviews-2023, Redshift Serverless

Item Metadata の description は本来SUPER型ですが、サイズが大き過ぎてCOPY時にエラーが出るデータがいくつもあります。サイズの限度で打ち切るため、COPY時に TRUNCATECOLUMNS オプションを付け、SUPER型は TRUNCATECOLUMNS の対象にならないため VARCHAR(65535) 型で定義しました。この処理を施してもデータサイズはほぼ変化せず、クエリに description を使うこともないため、測定に問題はありません。



CREATE DATABASE amazon_reviews_database;

CREATE TABLE IF NOT EXISTS
    amazon_reviews_database.public.ItemMetadata (
        main_category VARCHAR(64)
        , title VARCHAR(4096)
        , average_rating FLOAT
        , rating_number INT
        , features SUPER
        , description VARCHAR(65535)
        , price VARCHAR(64)
        , images SUPER
        , videos SUPER
        , store VARCHAR(4096)
        , categories SUPER
        , details SUPER
        , parent_asin VARCHAR(16)
        , bought_together SUPER
        , PRIMARY KEY(parent_asin)
    )
    BACKUP NO;

COPY amazon_reviews_database.public.ItemMetadata (
        main_category, title, average_rating, rating_number, features, description, price,
        images, videos, store, categories, details, parent_asin, bought_together    
    )
FROM 's3://my-bucket-name/item_metadata.jsonl.gz'
IAM_ROLE 'my-role-arn'
REGION 'ap-northeast-1'
JSON 'auto'
NULL AS ''
TRUNCATECOLUMNS
GZIP;

CREATE TABLE IF NOT EXISTS 
    amazon_reviews_database.public.UserReviews (
        rating FLOAT
        , title VARCHAR(4096)
        , text VARCHAR(65535)
        , images SUPER
        , asin VARCHAR(16)
        , parent_asin VARCHAR(16)
        , user_id VARCHAR(128)
        , timestamp BIGINT
        , verified_purchase BOOLEAN
        , helpful_vote INT
        , FOREIGN KEY(parent_asin) REFERENCES ItemMetadata(parent_asin)
    )
    BACKUP NO
    COMPOUND SORTKEY(timestamp);

COPY amazon_reviews_database.public.UserReviews (
        rating, title, text, images, asin,
        parent_asin, user_id, timestamp, verified_purchase, helpful_vote 
    )
FROM 's3://my-bucket-name/user_reviews.jsonl.gz'
IAM_ROLE 'my-role-arn'
REGION 'ap-northeast-1'
JSON 'auto'
NULL AS ''
TRUNCATECOLUMNS
GZIP;

VACUUM;


計測記録の取得(BigQuery)

Redshiftではコンソール上で記録を閲覧するため、コードがありません。



SELECT
    job_id  -- クエリの一意ID。
    , TIMESTAMP_DIFF(end_time, start_time, MILLISECOND) AS elapsed_time_milliseconds  -- 応答時間(ミリ秒)。
    , total_slot_ms / TIMESTAMP_DIFF(end_time, start_time, MILLISECOND) AS num_slot  -- 「容量コンピューティング」の場合のスロット数。
    , 0.051 * total_slot_ms / (1000 * 60 * 60) AS cost_capacity  -- 「容量コンピューティング」の場合の課金額(USD)。
    , total_bytes_billed * POW(2, -40) AS total_terabytes_billed  -- スキャンされたデータ容量(TiB)。
    , 7.5 * total_bytes_billed * POW(2, -40) AS cost_on_demand  -- 「オンデマンド」の場合の課金額(USD)。
    , start_time  -- クエリの開始時刻。
    , state  -- クエリの状態(実行中でないか確認するために出力させる)
    , LEFT(query, 50)  -- クエリ文の冒頭50文字。
FROM `my-project-name`.`region-asia-northeast1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
ORDER BY start_time DESC;


リザルトキャッシュの無効化(Redshift Serverless)

BigQueryではコンソール上で設定を行うため、コードがありません。



SET enable_result_cache_for_session TO off;  -- 無効化。

SHOW enable_result_cache_for_session;  -- 無効化されていることの確認。


クエリ1-1:小規模構造化データ、単純集計

データセットに含まれる運行の総数と、運賃の平均値を求めます。

BigQuery



SELECT 
    COUNT(*) AS num_trips, 
    AVG(fare) AS avg_fare
FROM taxi_dataset.TaxiTrip;


Redshift



SELECT 
    COUNT(*) AS num_trips, 
    AVG(fare) AS avg_fare
FROM TaxiTrip;


クエリ1-2:小規模構造化データ、集計+ウィンドウ関数

1台のタクシーが1日に乗車された回数のTOP10と、その日付を調べます。


BigQuery



SELECT
    RANK() OVER (ORDER BY COUNT(trip_id) DESC) AS ranking,
    LEFT(taxi_id, 10) AS short_taxi_id,
    CAST(trip_start_timestamp AS DATE) AS date,
    COUNT(trip_id) AS trip_count
FROM taxi_dataset.TaxiTrip
GROUP BY
    CAST(trip_start_timestamp AS DATE), 
    taxi_id
ORDER BY ranking ASC
LIMIT 10;


Redshift



SELECT
    RANK() OVER (ORDER BY COUNT(trip_id) DESC) AS ranking,
    LEFT(taxi_id, 10) AS short_taxi_id,
    CAST(trip_start_timestamp AS DATE) AS date,
    COUNT(trip_id) AS trip_count
FROM TaxiTrip
GROUP BY
    CAST(trip_start_timestamp AS DATE), 
    taxi_id
ORDER BY ranking ASC
LIMIT 10;


クエリ1-3:小規模構造化データ、ウィンドウ関数+CTE+集計

「タクシーは、どの程度スムーズに乗客を見つけられるのか?」を知りたいとします。一つの指標として、「前の乗客を降ろした場所の近くで、すぐに次の乗客を拾うという事象」について調べます。1人目の客を降ろした直後にその近くで2人目の客を拾い、2人目の客を降ろした直後にその近くで3人目の客を拾い......と、この事象は連続することがあります。n回連続した事象を series n trip、n回以上連続した事象を series n or more trip と呼び、n = 1, 2, 3 について相対度数を算出します。


BigQuery



-- タクシーごとに、各運行とその直前の運行の、運行ID、開始/終了座標、開始/終了時刻を取得するCTE。
WITH CurrAndPrevTrips AS (
    SELECT
        trip_id AS curr_trip_id,
        LAG(trip_id, 1) OVER W AS prev_trip_id,
        pickup_centroid_location AS curr_start_point,
        ST_GeogFromText(
            LAG(ST_AsText(dropoff_centroid_location), 1) OVER W    -- 地理データに直接ウィンドウ関数を適用できないRedshiftに合わせた処理(本来BigQueryでは不要)。
        ) AS prev_end_point,
        trip_start_timestamp AS curr_start_time, 
        LAG(trip_end_timestamp, 1) OVER W AS prev_end_time
    FROM taxi_dataset.TaxiTrip
    WHERE
        ST_Distance(pickup_centroid_location, dropoff_centroid_location) 
            < trip_miles * 1609 -- 誤った位置情報を持っている可能性が高いレコードが15%程度あるので、除外する。
    WINDOW W AS (
        PARTITION BY taxi_id
        ORDER BY trip_start_timestamp, trip_end_timestamp, trip_id
    )
),
-- 客を降ろしてから、一定の時間・距離内で、新しい客を拾った運行(series 1 trip)の組を取得するCTE。
Series1Trips AS (
    SELECT
        curr_trip_id,
        prev_trip_id
    FROM CurrAndPrevTrips
    WHERE
        TIMESTAMP_DIFF(curr_start_time, prev_end_time, MINUTE) <= 15
        AND ST_Distance(curr_start_point, prev_end_point) < 500
),
Series2Trips AS (
    SELECT
        Tbl1.curr_trip_id AS curr_trip_id,
        Tbl1.prev_trip_id AS prev1_trip_id,
        Tbl2.prev_trip_id AS prev2_trip_id
    FROM 
        Series1Trips AS Tbl1
        INNER JOIN Series1Trips AS Tbl2
            ON Tbl1.prev_trip_id = Tbl2.curr_trip_id -- 連続している2つのうち前者が、別の連続の後者として存在している場合を捉えて結合する。以下同様。
),
Series3Trips AS (
    SELECT
        S2.curr_trip_id AS curr_trip_id,
        S2.prev1_trip_id AS prev1_trip_id,
        S2.prev2_trip_id AS prev2_trip_id,
        S1.prev_trip_id AS prev3_trip_id
    FROM 
        Series2Trips AS S2
        INNER JOIN Series1Trips AS S1
            ON S2.prev2_trip_id = S1.curr_trip_id
),
Series4Trips AS (
    SELECT S3.curr_trip_id
    FROM 
        Series3Trips AS S3
        INNER JOIN Series1Trips AS S1
            ON S3.prev3_trip_id = S1.curr_trip_id
),
-- 総運行数とn連続運行数を集計するCTE。
DuplicatedCounts AS (
    SELECT 
        (SELECT COUNT(*) FROM CurrAndPrevTrips) AS num_all_trips,
        (SELECT COUNT(*) FROM Series1Trips) AS s1, -- この数は、series 2 trip を重複して2回数えている(一般に series n trip を重複してn回数えている)。
        (SELECT COUNT(*) FROM Series2Trips) AS s2, -- この数は、series 3 trip を重複して2回数えている(一般に series n trip を重複してn-1回数えている)。以下同様。
        (SELECT COUNT(*) FROM Series3Trips) AS s3,
        (SELECT COUNT(*) FROM Series4Trips) AS s4
)
SELECT
    num_all_trips,
    s1 - s2 AS num_series_1_or_more_trips, -- この数は、series n trip を重複なく1回として数えている。以下同様。
    s2 - s3 AS num_series_2_or_more_trips,
    s3 - s4 AS num_series_3_or_more_trips,
    FORMAT('%.2f %%', (s1 - s2) / (num_all_trips * 1.0) * 100) AS rate_series_1_or_more_trips, -- 1.0 を掛けるのは、実数除算を行うための措置。以下同様。
    FORMAT('%.2f %%', (s2 - s3) / (num_all_trips * 1.0) * 100) AS rate_series_2_or_more_trips,
    FORMAT('%.2f %%', (s3 - s4) / (num_all_trips * 1.0) * 100) AS rate_series_3_or_more_trips
FROM DuplicatedCounts;


Redshift



-- タクシーごとに、各運行とその直前の運行の、運行ID、開始/終了座標、開始/終了時刻を取得するCTE。
WITH CurrAndPrevTrips AS (
    SELECT
        trip_id AS curr_trip_id,
        LAG(trip_id, 1) OVER (
            PARTITION BY taxi_id
            ORDER BY trip_start_timestamp, trip_end_timestamp, trip_id
        ) AS prev_trip_id,
        pickup_centroid_location AS curr_start_point,
        ST_GeomFromText(LAG(ST_AsText(dropoff_centroid_location), 1) OVER (  -- Redshift では、地理データに直接ウィンドウ関数を適用できない。
            PARTITION BY taxi_id
            ORDER BY trip_start_timestamp, trip_end_timestamp, trip_id
        )) AS prev_end_point,
        trip_start_timestamp AS curr_start_time, 
        LAG(trip_end_timestamp, 1) OVER (
            PARTITION BY taxi_id
            ORDER BY trip_start_timestamp, trip_end_timestamp, trip_id
        ) AS prev_end_time
    FROM TaxiTrip
    WHERE
        ST_Distance(pickup_centroid_location, dropoff_centroid_location) 
            < trip_miles * 1609 -- 誤った位置情報を持っている可能性が高いレコードが15%程度あるので、除外する。
),
-- 客を降ろしてから、一定の時間・距離内で、新しい客を拾った運行(series 1 trip)の組を取得するCTE。
Series1Trips AS (
    SELECT
        curr_trip_id,
        prev_trip_id
    FROM CurrAndPrevTrips
    WHERE
        (EXTRACT(EPOCH FROM curr_start_time) - EXTRACT(EPOCH FROM prev_end_time)) / 60 <= 15
        AND ST_Distance(curr_start_point, prev_end_point) < 500
),
Series2Trips AS (
    SELECT
        Tbl1.curr_trip_id AS curr_trip_id,
        Tbl1.prev_trip_id AS prev1_trip_id,
        Tbl2.prev_trip_id AS prev2_trip_id
    FROM 
        Series1Trips AS Tbl1
        INNER JOIN Series1Trips AS Tbl2
            ON Tbl1.prev_trip_id = Tbl2.curr_trip_id -- 連続している2つのうち前者が、別の連続の後者として存在している場合を捉えて結合する。以下同様。
),
Series3Trips AS (
    SELECT
        S2.curr_trip_id AS curr_trip_id,
        S2.prev1_trip_id AS prev1_trip_id,
        S2.prev2_trip_id AS prev2_trip_id,
        S1.prev_trip_id AS prev3_trip_id
    FROM 
        Series2Trips AS S2
        INNER JOIN Series1Trips AS S1
            ON S2.prev2_trip_id = S1.curr_trip_id
),
Series4Trips AS (
    SELECT S3.curr_trip_id
    FROM 
        Series3Trips AS S3
        INNER JOIN Series1Trips AS S1
            ON S3.prev3_trip_id = S1.curr_trip_id
),
-- 総運行数とn連続運行数を集計するCTE。
DuplicatedCounts AS (
    SELECT 
        (SELECT COUNT(*) FROM CurrAndPrevTrips) AS num_all_trips,
        (SELECT COUNT(*) FROM Series1Trips) AS s1, -- この数は、series 2 trip を重複して2回数えている(一般に series n trip を重複してn回数えている)。
        (SELECT COUNT(*) FROM Series2Trips) AS s2, -- この数は、series 3 trip を重複して2回数えている(一般に series n trip を重複してn-1回数えている)。以下同様。
        (SELECT COUNT(*) FROM Series3Trips) AS s3,
        (SELECT COUNT(*) FROM Series4Trips) AS s4
)
SELECT
    num_all_trips,
    s1 - s2 AS num_series_1_or_more_trips, -- この数は、series n trip を重複なく1回として数えている。以下同様。
    s2 - s3 AS num_series_2_or_more_trips,
    s3 - s4 AS num_series_3_or_more_trips,
    TO_CHAR((s1 - s2) / (num_all_trips * 1.0) * 100, 'FM999.000') || ' %' AS rate_series_1_or_more_trips, -- 1.0 を掛けるのは、実数除算を行うための措置。以下同様。
    TO_CHAR((s2 - s3) / (num_all_trips * 1.0) * 100, 'FM999.000') || ' %' AS rate_series_2_or_more_trips,
    TO_CHAR((s3 - s4) / (num_all_trips * 1.0) * 100, 'FM999.000') || ' %' AS rate_series_3_or_more_trips
FROM DuplicatedCounts;


クエリ2-1:中規模半構造化データ、CTE+集計

Amazonレビューが「参考になった」を獲得する数に関して分析したいとします。一般に、投稿された直後のレビューは「参考になった」を獲得しておらず、時間の経過とともに獲得数を増やし、十分時間が経過することでそのレビューに相応しい獲得数に漸近すると考えられます。そこで分析の際、「十分時間」が経過していないレビューをWHERE句で排除することで、獲得数が安定したデータのみを対象にできます。そのためにここでは、「十分時間」が何年であるか特定するため、レビューを経過年数の階級に分類し、階級ごとに獲得数の平均値を求めます。そして階級の平均値と全体の平均値の比を取り、その比が一定の閾値に達しているかどうかのフラグを出力させます。

このデータセットのタイムスタンプはミリ秒単位のUNIX時刻です。このデータが収集されたのは2023年9月23日で、UNIX時刻にすると1695427200000になります。


BigQuery



WITH VoteNumWithClass AS (
    SELECT
        helpful_vote
        , CASE
            WHEN timestamp > 1679529600000 THEN '0.0-0.5y'  -- 投稿から経過した時間が0年以上半年未満である階級。以下同様。
            WHEN timestamp > 1663891200000 THEN '0.5-1.0y'
            WHEN timestamp > 1647993600000 THEN '1.0-1.5y'
            WHEN timestamp > 1632355200000 THEN '1.5-2.0y'
            WHEN timestamp > 1616457600000 THEN '2.0-2.5y'
            WHEN timestamp > 1600819200000 THEN '2.5-3.0y'
            WHEN timestamp > 1584921600000 THEN '3.0-3.5y'
            WHEN timestamp > 1569196800000 THEN '3.5-4.0y'
            WHEN timestamp > 1553299200000 THEN '4.0-4.5y'
            WHEN timestamp > 1537660800000 THEN '4.5-5.0y'
            WHEN timestamp <= 1537660800000 THEN '5.0y-'
            ELSE NULL
        END AS class
        , AVG(helpful_vote) OVER () AS overall_avg
    FROM amazon_reviews_dataset.UserReviews
)
SELECT
    class
    , AVG(helpful_vote) / MAX(overall_avg) AS votes_ratio_to_overall
    , CASE
        WHEN AVG(helpful_vote) / MAX(overall_avg) < 0.7
        THEN 'Too recent to rely on.'
        ELSE 'Old enough.'
    END AS eval_usable_or_not
FROM VoteNumWithClass
GROUP BY class
ORDER BY class;


Redshift



WITH VoteNumWithClass AS (
    SELECT
        helpful_vote
        , CASE
            WHEN timestamp > 1679529600000 THEN '0.0-0.5y'  -- 投稿から経過した時間が0年以上半年未満である階級。以下同様。
            WHEN timestamp > 1663891200000 THEN '0.5-1.0y'
            WHEN timestamp > 1647993600000 THEN '1.0-1.5y'
            WHEN timestamp > 1632355200000 THEN '1.5-2.0y'
            WHEN timestamp > 1616457600000 THEN '2.0-2.5y'
            WHEN timestamp > 1600819200000 THEN '2.5-3.0y'
            WHEN timestamp > 1584921600000 THEN '3.0-3.5y'
            WHEN timestamp > 1569196800000 THEN '3.5-4.0y'
            WHEN timestamp > 1553299200000 THEN '4.0-4.5y'
            WHEN timestamp > 1537660800000 THEN '4.5-5.0y'
            WHEN timestamp <= 1537660800000 THEN '5.0y-'
            ELSE NULL
        END AS class
        , AVG(helpful_vote) OVER () AS overall_avg
    FROM UserReviews
)
SELECT
    class
    , AVG(helpful_vote) / MAX(overall_avg) AS votes_ratio_to_overall
    , CASE
        WHEN AVG(helpful_vote) / MAX(overall_avg) < 0.7
        THEN 'Too recent to rely on.'
        ELSE 'Old enough.'
    END AS eval_usable_or_not
FROM VoteNumWithClass
GROUP BY class
ORDER BY class;


クエリ2-2:中規模半構造化データ、JOIN+集計

商品のレビュー数が多くなると、「自分がレビューを書く意味がない」と感じる人が増えて、レビュー数の増加が鈍化する傾向がある、という推測を立てました。これについて調査したいと思います。しかし、単にレビュー数の時間変化率だけを見ると、その商品が人気を失ってレビューされなくなった場合と区別できません。また、サクラを利用していたり、クーポンなどの報酬を用意して購入者にレビューを書くよう促したりしている商品であれば、通常のレビュー数の鈍化パターンに当てはまらないだろうと思われます。

そこでこのクエリでは、「商品ID、商品のレビュー数、商品のレビューについた『参考になった』の総数」の組を取得します。十分なレビュー数がある商品のうち、レビュー数に対して「参考になった」総数が多いものは、鈍化傾向にある可能性が高く、逆に少ないものはサクラなどを利用している可能性が高いと考えられます。

結果セットのサイズが大きいので、Redshiftでは一時テーブルに出力して表示させることができません。UNLOADコマンドによってS3バケットに結果を出力します。


BigQuery



SELECT
    UR.parent_asin
    , MAX(IM.rating_number) AS rating_number -- 1つの parent_asin に対応する rating_number は一意。このMAX関数は形式的なもので、実際は唯一の rating_number が返される。
    , SUM(UR.helpful_vote) AS sum_vote
FROM
    amazon_reviews_dataset.UserReviews UR 
    INNER JOIN amazon_reviews_dataset.ItemMetadata IM
        ON UR.parent_asin = IM.parent_asin
GROUP BY UR.parent_asin;


Redshift



UNLOAD('
    SELECT
        UR.parent_asin
        , MAX(IM.rating_number) AS rating_number -- 1つの parent_asin に対応する rating_number は一意。このMAX関数は形式的なもので、実際は唯一の rating_number が返される。
        , SUM(UR.helpful_vote) AS sum_vote
    FROM
        UserReviews UR 
        INNER JOIN ItemMetadata IM
            ON UR.parent_asin = IM.parent_asin
    GROUP BY UR.parent_asin;
')
TO 's3://my-bucket-name/unload2-2/'
IAM_ROLE 'my-role-arn'


クエリ2-3:中規模半構造化データ、ウィンドウ関数集計+CTE+JOIN

「参考になった」を獲得しやすい、優れたレビューの特徴を分析したいとします。そのためのデータを、2-1で求めた期間の条件でフィルタリングし、適切に加工して取り出す必要があります。しかし、単純に「参考になった」の獲得数を基準とするのは不適切だと考えられます。100件以上の「参考になった」を獲得しているレビューが複数ある商品で10件の「参考になった」を獲得しているレビューと、他に「参考になった」を獲得しているレビューが存在しない商品で10件獲得しているレビューでは、後者をより「優れている」と判定すべきだからです。

そこでこの分析では、パレート分析のような方法を用いることにしました。具体的には、商品ごとに、「参考になった」獲得数の順にレビューを並べ、累積相対度数に応じてグループA、B、Cに分けます(総度数が0の場合のグループはNULLとします)。そしてレビューごとに、そのレビューがどのグループに属するかと、以下の情報をセットにして出力します(後工程で、レビューがどのグループになるかを説明できる変数の組み合わせを探るという想定):「商品ID」、「評価値(星の数)」、「商品全体の平均評価値と評価値の差」、「添付画像の有無」、「レビュータイトルの原文」、「レビュータイトルの単語数」、「レビュー本文の原文」、「レビュー本文の単語数」。

結果セットのサイズが大きいので、一時テーブルに出力して表示させることができません。BigQueryでは別のテーブルを作成した上でそこに結果を保存する設定を行い、RedshiftではUNLOADコマンドによってS3バケットに結果を出力します。


BigQuery



WITH UserReviewWithWindow AS (
    SELECT
        *
        , 1.0 * SUM(UR.helpful_vote) OVER (
            PARTITION BY UR.parent_asin
            ORDER BY UR.helpful_vote DESC
        ) -- 商品ごとに、「参考になった」数の多い順にレビューを並べたときの、「参考になった」の累積度数。1.0を掛けるのは商を浮動小数点値にするため。
            / NULLIF(SUM(UR.helpful_vote) OVER (PARTITION BY UR.parent_asin), 0) -- 商品ごとの「参考になった」合計数。これで割ることで、「参考になった」の累積相対度数になる。
            AS crf
        , RANK() OVER (
            PARTITION BY UR.parent_asin
            ORDER BY UR.helpful_vote DESC
        ) AS rank -- 商品ごとに、「参考になった」数の多い順にレビューを並べたときの順位。
    FROM amazon_reviews_dataset.UserReviews UR
    WHERE UR.timestamp < 1584921600000 -- 2−1で決まったUNIX時刻(3年以上経過)。
)
SELECT
    UR.parent_asin -- 商品ID。
    , CASE -- パレート分析のグループを割り当てる。70%までをAグループ、90%までをBグループ、以降をCグループとする。
        WHEN UR.rank = 1 AND UR.crf IS NOT NULL THEN 'A' -- 相対度数が0.7以上のレビューをAグループに割り振るための処理。
        WHEN UR.crf < 0.7 THEN 'A'
        WHEN UR.crf < 0.9 THEN 'B'
        WHEN UR.crf <= 1.0 THEN 'C'
        ELSE NULL
    END AS pareto_group
    , UR.rating
    , IM.average_rating - UR.rating AS diff_avg_rating
    , CASE
        WHEN ARRAY_LENGTH(UR.images) = 0
        THEN false
        ELSE true
    END AS has_images
    , UR.title AS raw_title
    , LENGTH(UR.title) - LENGTH(REPLACE(UR.title, ' ', '')) + 1 AS num_words_title
    , UR.text AS raw_text
    , LENGTH(UR.text) - LENGTH(REPLACE(UR.text, ' ', '')) + 1 AS num_words_text
FROM
    UserReviewWithWindow UR 
    INNER JOIN amazon_reviews_dataset.ItemMetadata IM 
        ON UR.parent_asin = IM.parent_asin;


Redshift



UNLOAD('
    WITH UserReviewWithWindow AS (
        SELECT
            *
            , 1.0 * SUM(UR.helpful_vote) OVER (
                PARTITION BY UR.parent_asin
                ORDER BY UR.helpful_vote DESC
                ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING  -- Redshift では集約ウィンドウ関数に ORDER BY 句を含める場合、フレーム句が必須。
            ) -- 商品ごとに、「参考になった」数の多い順にレビューを並べたときの、「参考になった」の累積度数。1.0を掛けるのは商を浮動小数点値にするため。
                / NULLIF(SUM(UR.helpful_vote) OVER (PARTITION BY UR.parent_asin), 0) -- 商品ごとの「参考になった」合計数。これで割ることで、「参考になった」の累積相対度数になる。
                AS crf
            , RANK() OVER (
                PARTITION BY UR.parent_asin
                ORDER BY UR.helpful_vote desc
            ) AS rank -- 商品ごとに、「参考になった」数の多い順にレビューを並べたときの順位。
        FROM UserReviews UR
        WHERE timestamp < 1584921600000 -- 2-1で決まったUNIX時刻(3年以上経過)。
    )
    SELECT
        UR.parent_asin -- 商品ID。
        , CASE -- パレート分析のグループを割り当てる。70%までをAグループ、90%までをBグループ、以降をCグループとする。
            WHEN UR.rank = 1 AND UR.crf IS NOT NULL THEN ''A'' -- 相対度数が0.7以上のレビューをAグループに割り振るための処理。
            WHEN UR.crf < 0.7 THEN ''A''
            WHEN UR.crf < 0.9 THEN ''B''
            ELSE ''C''
        END AS pareto_group
        , UR.rating
        , IM.average_rating - UR.rating AS diff_avg_rating
        , CASE
            WHEN GET_ARRAY_LENGTH(UR.images) = 0
            THEN false
            ELSE true
        END AS has_images
        , UR.title AS raw_title
        , LENGTH(UR.title) - LENGTH(REPLACE(UR.title, '' '', '''')) + 1 AS num_words_title
        , UR.text AS raw_text
        , LENGTH(UR.text) - LENGTH(REPLACE(UR.text, '' '', '''')) + 1 AS num_words_text
    FROM
        UserReviewWithWindow UR
        INNER JOIN ItemMetadata IM 
            ON UR.parent_asin = IM.parent_asin;
')
TO 's3://my-bucket-name/unload2-3/'
IAM_ROLE 'my-role-arn'




X(旧Twitter)・Facebookで定期的に情報発信しています!

データ分析基盤構築とは、大量のデータを蓄積・変換・分析するためのインフラを開発することです。主軸となるデータレイク・データウェアハウス・BIツールの他、NoSQLデータベース、データパイプラインツール、ETLツールなど様々な要素があり、それぞれ様々なベンダーから多種多様な製品が出ています。

比較項目は膨大で、複雑です。性能・機能・セキュリティ・コスト......一体何を基準に選べばよいのでしょうか。当社には一つの戦略があります。それは、

" 分析ツールはコスト最適性で、データ基盤は運用性で選ぶ。 "


これがデータ利活用を成功させるための鍵です。以下のように、2つのポイントに分けられます。

データ分析基盤の構成.jpg

詳しく解説していきます。

秘訣①BIツールの二極化と、「コスト最適」な配分

まずは実際に、分析ツールの費用をご紹介しましょう。データ利活用の最初に導入するデータ分析ツールとしては、BIツールが一般的です。TableauとQuickSightという2つのBIツールを比較します。1,000人の従業員が利用する場合、支払額は次のようになります。

[ケース1]

  • Tableau:約3,000万円/年
  • QuickSight:約800万円/年


3〜4倍の差です。

極端な例ではありません。BIツールの価格は二極化しており、このくらい高いか、安いか、どちらかです。最初に適切な製品を導入できないと、その後毎年不必要なコストがかかり続けてしまいます。

とはいえ高価なツールには、独自の機能や、高度な利用をする際の使いやすさがあります。例えばTableauはドラッグ&ドロップのような直感的な操作によって、簡単・迅速にデータ分析ができ、分析を主務とするユーザーに向いています。ただ一律に安価なものを導入すると、結局機能が不足してデータ利活用が進まないという本末転倒の事態になるでしょう。

「安ければいい」でも「高機能であればいい」でもなく、「必要な利用者にだけ必要な機能を」。それが「コスト最適化」の観点です。具体的には、

  • 自社のニーズを洗い出し、
  • 「誰が・どんなタスクを・どの程度」できる必要があるかを見極め、
  • それぞれに必要十分なツールを用意すること。


これによってコスト最適な利活用推進を図ることが重要なのです。

ツール配分の例を考えましょう。先ほどの従業員1,000人のうち、データからインサイトを得るための分析業務を行うのは経営戦略部と店舗開発部の計100人で、その他の900人はあらかじめ作成された可視化ダッシュボードを日々の業務で参照するだけだとします。分析を行う100人(とツールを管理するエンジニアチーム)がTableauを利用し、残りの900人はQuickSightを利用するのが適切です。費用は以下の通りです。

[ケース2]

  • Tableau:約700万円/年
  • QuickSight:約800万円/年
  • 合計:約1,500万円/年


さらに、従業員に対する細かいアクセス権限の設定などが不必要である場合、QuickSightの代わりに無料の Looker Studio を利用できます。この場合、費用はさらに安くなります。

[ケース3]

  • Tableau:約700万円/年
  • Looker Studio:0円/年
  • 合計:約700万円/年


これはあくまで簡単な例ですが、「分析か/可視化か」「細かいアクセス権限管理が必要か/不要か」という観点は一般的なものです。自社のニーズをこのような形で整理し、それに合った適切なツールを選んでいくというイメージをつけましょう。

では具体的に、どのようなツールがあるのでしょうか。高価・高機能なものでは、Board、Domo、Qlikといった有名製品にそれぞれ特長がありますが、AIによる自然言語処理で直感的に使えるThoughtSpotのような製品も生産性を向上し、使い始めのハードルを下げるでしょう。安価なものはGoogleの Looker Studio が代表的ですが、OSSのMetabaseも、自動でダッシュボードを生成してくれるX-rayという機能に魅力があります。

秘訣②利活用に必要な「運用性の高いデータ基盤」

「データ基盤」とは、データを利用可能な形で蓄積するためのインフラストラクチャのことです。と一口に言っても、実際にはデータパイプライン・データレイク・ETL・データウェアハウスと、多くの構成要素があります。それぞれの構成要素を動かすのに必要なプログラムや設定の項目は非常に多く、開発や運用の工数が大きくなります。分析ツールが費用の勘所であるのに対して、データ基盤はいわば工数の勘所です。

しかし本当に工数を割くべきなのは、開発や運用ではありません。要件定義とそれに基づく継続的デリバリーに力を入れてこそ、将来の成功があります。データ基盤の構築はDXやクラウド移行と似ており、現行のシステムや業務自体の変化を伴うものです。様々なステークホルダーへの説明・ヒアリング・調整が不足していれば、プロジェクトは失敗へ向かいます。一方で、プロジェクトのエンジニアチームが主体となって調整を行い、使う人たちにとって使いやすいデータ基盤を構築することができれば、利活用は順調な滑り出しを迎えられます。データ基盤は一度出来上がった後でも現場から様々な要望が舞い込んできますが、それに応じられるコミュニケーション体制も確立できるはずです。

そのため、エンジニアチームがコミュニケーションに時間を割けるように、データ基盤では保守運用性の高い構成を選択し、開発工数も抑えることが重要になります。

その視点で一般的に優れているのは、基盤をAWSサービスだけで構築することです。AWSでは、データウェアハウスのメンテナンス自動化、アクセス権限やメタデータの管理一本化などの恩恵を得られます。また、AWS上に構築されることの多い対向業務システムとの連携もスムーズです。

もちろん、どのような場合でもAWSがベストな訳ではありません。Google Cloudは先進的な性能によって、データ基盤市場に確固たる地位を築いています。データウェアハウスについては、独自の機能と料金システムで支持を伸ばしているSnowflakeも検討の余地があります。

データ利活用のスタートダッシュ

"分析ツールはコスト最適性で、データ基盤は運用性で選ぶ。"というテーマについて解説してきました。それでは、なぜこれが望ましい戦略なのでしょうか。それは、「データ利活用/データドリブン経営を、最低限のコストで、スムーズに始められる」からです。このうち「最低限のコスト」については、分析ツールを適切に選ぶことで高すぎる負担を避けられるということが、まずあります。運用性の高いデータ基盤も、保守・改修を含めたTCOを抑えることで、コストカットに繋がります。

では、「スムーズに始められる」とはどういうことでしょうか。データ利活用/データドリブン経営の「スムーズな開始」を阻む要因は、主に2つ考えられます。1つ目は「利用者のリテラシー不足」で、「ツールの操作がわからない」「データが何に使えるのかわからない」といったものです。2つ目は「エンジニアの設計ミス」で、「ツールのスペックが足りない」「使い物にならないデータを生成している」などです。この2種類の失敗は、「基盤を使う人たちとエンジニアチームのコミュニケーション不足」という1つの失敗から出てくる結果に過ぎません。

基盤構築の段階から、エンジニアチームが積極的に利用者に関わっていくことが重要なのです。コミュニケーションを重ねることで、利用者がデータを理解していくことができます。エンジニアチームの側も、フィードバックから要件定義、製品選定やスキーマ設計のヒントを与えられます。

「コスト最適に・スムーズに始める」ことにゴールを置いて基盤構築をすれば、その後のデータカルチャーの浸透、データによる収益拡大といった経営目標へと繋がっていくでしょう。そのためには、分析ツールはコスト最適性で、データ基盤は運用性で選ぶことになります。

最適な製品選定と要件調整のご提供

アクセルユニバース株式会社では、当社の掲げる「顧客中心主義」に基づいて、お客様に合わせた最適な分析ツール・データ基盤を立案・開発いたします。お客様の状況をヒアリングし、適切なプランをご提案することを得意とする企業です。

また、必要に応じて、お客様部署間のファシリテーションなどの総合的なITコンサルティングを提供しております。

まずは追加でお聞きになりたい情報から、お気軽にご相談ください。

(お問い合わせはこちら)

BIツールの費用計算

いずれも記事公開時点での情報です。1ドル=150円、有効数字1桁としました。 Tableau公式の価格情報AWS公式の料金情報 をもとに計算しています。

[ケース1] Tableau:「Tableau Creator」10人、「Tableau Explorer」100人、「Tableau Viewer」890人。 QuickSight:「管理者」10人、「作成者」100人、「閲覧者」890人。

[ケース2] Tableau:「Tableau Creator」10人、「Tableau Explorer」100人。 QuickSight:「管理者」10人、「作成者」100人、「閲覧者」790人。

[ケース3] Tableau:「Tableau Creator」10人、「Tableau Explorer」100人。 Looker Studio:無料ユーザーのみ。

このアーカイブについて

このページには、過去に書かれた記事のうちデータ分析カテゴリに属しているものが含まれています。

前のカテゴリは生成AIです。

次のカテゴリはAWSです。

最近のコンテンツはインデックスページで見られます。過去に書かれたものはアーカイブのページで見られます。