はじめに
同じようなSQLを投げているのに、片方は一瞬で返ってくるのに、もう片方は何分も待たされる。そんな経験はないでしょうか。
自分も最初は「同じDBに同じSELECTを投げているのに、なんでこんなに違うんだろう」とピンと来ていませんでした。
でも、この差はSQLの書き方ではなく、データの並べ方に理由があります。
この記事では「行で並べる世界」と「列で並べる世界」が、どう別物なのかを見ていきます。
TL;DR
- 分析処理(OLAP)はトランザクション処理(OLTP)の延長ではなく、データの並べ方から別物です。
- 列で並べると、必要な列だけ読めて、圧縮も効いて、ビット演算でまとめて絞り込めます。
- この「まとめて絞る」という発想は、データキューブ・全文検索・ベクトル検索にも形を変えて現れます。
1. 同じSQLでも、裏側の並べ方が違う
トランザクション処理と分析処理は、投げるSQLは似ていても、必要としているものがまるで違います。同じ「テーブルから読む」でも、求めるものが正反対なんです。
1-1. トランザクション処理と分析処理は、求めるものが違う
身近な例で考えてみます。
ユーザー管理のサービスで、「1人のユーザーのプロフィールを開いて、メールアドレスを更新する」という操作と、「全ユーザーの登録日を曜日別に数える」という操作を比べてみます。
前者は特定の1行を主キーでパッと取ってきて、少しだけ書き換える操作です。
後者は何百万行も読んで、特定の列だけを集計する操作になります。
この2つは、データベースの世界ではそれぞれOLTP(トランザクション処理)とOLAP(分析処理)と呼ばれます。
後者の分析用に特化したデータベースは、データウェアハウス(分析専用のDB)と呼ばれます。
| 観点 | OLTP(トランザクション処理) | OLAP(分析処理) |
|---|---|---|
| 読み書きの単位 | 少数の行 | 大量の行 |
| アクセス方法 | 主キーや索引で1行を特定 | 数列をまとめてスキャン |
| 典型的な操作 | 1人分を読む・書き換える | 全件を集計する |
| 求める速さ | 1件あたりの応答が速いこと | 大量集計を捌けること |
両方を1つの製品で扱おうとするHTAP(OLTPとOLAPを1製品で扱うDB)もあります。
ただし実装としては、行指向に向いた処理と列指向に向いた処理を内部で分けたり、分析用の別経路を持ったりすることが多いです。
同じSQLの窓口に見えても、裏側ではワークロードごとに違う工夫が入っています。
1-2. 分析クエリは、テーブルの数列しか見ていない
ここで分析クエリの中身をもう少し見てみます。
分析用のテーブルは100列を超える幅広いものになることがよくありますが、1本のクエリが実際に触る列は4〜5列だけ、ということがほとんどです。
さっきの「曜日別に数える」クエリも、必要なのは登録日と、せいぜいカテゴリくらいです。
残りの90数列は使いません。
ところが行で並べているデータベースでは、基本的に1行ぶんのデータがまとまって置かれているため、使わない列まで読み込みやすくなります。
この「必要ない列まで一緒に読んでしまう」が、分析では大きな無駄になります。
2. 行で並べるか、列で並べるか
分析クエリは数列しか見ていません。それなのに行指向のデータベースでは、使わない列まで一緒に読み込みやすくなります。ここを並べ替えるだけで、大きな無駄を減らせます。
2-1. 行指向は、1行ぶんをまとめて置く
OLTPのリレーショナルDBは、ほとんどが行指向(行ごとにデータを並べる方式)でデータを置いています。
1行ぶんの全列の値が、ディスク上で隣り合って並んでいる形です。
1人分のユーザー情報をまるごと読んだり、1行を更新したりするには、これがとても合理的です。
必要な行の場所さえ分かれば、そこを連続して読むだけで全部そろいます。
問題は分析クエリのときです。
4〜5列しか要らなくても、隣にくっついている残りの90数列まで一緒に読み込みやすくなります。
2-2. 列指向は、同じ列の値をまとめて置く
そこで発想を変えて、行ではなく列ごとに値を縦に並べるのが列指向(カラムナ)です。
登録日の列は登録日だけ、カテゴリの列はカテゴリだけ、というふうに、同じ列の値を連続して置きます。
こうすると、クエリが使う列だけを読めばよくなります。
まず原理を一言で言うと、「必要な列だけ読めばいい」というだけのことです。この並べ替えが、構造としてどう違うのかを図で見てみます。
元のテーブル
+--------+----------+-----------+
| 登録日 | カテゴリ | ステータス|
+--------+----------+-----------+
| 月 | A | active |
| 月 | B | active |
| 火 | A | inactive |
+--------+----------+-----------+
行指向(1行ぶんをまとめて置く)
[月, A, active] [月, B, active] [火, A, inactive]
↑ 1行が連続。1人分を読むのは速いが、分析では使わない列も一緒に読む
列指向(同じ列の値をまとめて置く)
登録日 : [月, 月, 火]
カテゴリ : [A, B, A]
ステータス: [active, active, inactive]
↑ 列が連続。登録日だけ集計したいなら、登録日の列だけ読めばいい
ここで1つ仕組みのポイントがあります。
k番目の値は、どの列でも同じ行に対応している、というルールです。
登録日の列の3番目と、カテゴリの列の3番目を取ってくれば、それで元の3行目が復元できます。
だから列をバラバラに置いても、行を組み立て直せます。
実際には、何兆行もある列を一気に1つにして置くわけではありません。数千〜数百万行ごとのブロックに区切って、そのブロックの中で列ごとに分けて置きます。
3. 列にすると圧縮が一気に効く
列を分けて、読む量を減らせました。さらにうれしいのが、列ごとにまとめると圧縮が驚くほど効くことです。
3-1. 同じ列には同じ値が並ぶ
列を縦に並べると、同じような値ばかりが連続します。
たとえば商品の列は、取引が何十億件あっても、商品の種類自体は10万くらいしかない、ということがよくあります。
曜日なら7種類、ステータスなら数種類です。
値の種類が少なくて繰り返しが多いデータは、圧縮がとてもよく効きます。
行指向だと「登録日・カテゴリ・ステータス・…」と性質の違う値が交互に並ぶので、こうはいきません。
列にまとめた時点で、圧縮の前提が整います。
3-2. ビットマップで「ある/ない」を持つ
列指向では辞書圧縮やランレングスなどいろいろな圧縮が使われますが、ここでは後の話につながるビットマップエンコーディングを見てみます。これは「値の種類ごとに、その行が該当するかどうかを1ビットで持つ」やり方です。
たとえばステータスの列なら、active用のビット列とinactive用のビット列を作ります。各行について、その値なら1、違うなら0を立てます。
ステータス列: [active, active, inactive, active, inactive, inactive]
active のビットマップ : 1 1 0 1 0 0
inactive のビットマップ : 0 0 1 0 1 1
このビット列は0が長く続くことが多く、さらにランレングス(連長圧縮)でぐっと畳めます。
「0が3個」のように同じ値の連続を個数で持つ方式です。
このビットマップが効くのは圧縮だけではありません。
複数値の条件はビットマップのOR、複数列にまたがる条件はANDで、一発で解けます。
1件ずつ突き合わせず、ビット演算でまとめて絞り込めます。
これが分析側の核になる発想で、後のセクションで何度も出てきます。
名前が似ていますが、ここで言う列指向データベースと、ワイドカラム(カラムファミリー)型は別物です。
ワイドカラム型は1行に大量の列を持てるモデルですが、データは行ごとにまとめて置くので、実は行指向です。
3-3. 並べ替えると、もっと圧縮が効く
列指向では、行をどの順番で置くかを選べます。ここでソート順(並べ替えの基準)をうまく選ぶと、圧縮がさらに効きます。
たとえば登録日を先頭のソートキー(並べ替えの第1基準)にすると、同じ日付がずらっと連続します。
すると、さっきのランレングスで「月が10万個」のように畳めて、値の変化が少ない列では、何億行あっても驚くほど小さく表現できることがあります。
圧縮効果が一番強く出るのは、この先頭のソートキーです。
2番目・3番目のキーは値が混ざってくるので、効きは弱まります。
ここで1つ注意があります。
列を個別にソートしてはいけません。
列ごとにバラバラに並べ替えると、k番目どうしが同じ行に対応するというルールが壊れて、行を組み立て直せなくなります。
並べ替えるときは、列ごとではなく行まるごとの単位で順番を決めます。
4. 並べ方が決まると、実行のしかたも変わる
列に並べて、圧縮も効かせました。次はCPUの使い方です。何百万行も処理するとき、1行ずつ丁寧に解釈していると、それだけで遅くなります。
4-1. 1行ずつ解釈するのは遅い
一番素朴な実行エンジンは、プログラミング言語のインタプリタのように動きます。
1行を処理するたびに「このクエリでは何と何を比べるんだっけ」とクエリの構造を見にいって、その通りに計算する。
これを行の数だけ繰り返します。
1件や2件ならいいのですが、何百万行もこれをやると、データを読む時間よりも、この解釈のオーバーヘッドのほうが効いてきます。分析には遅すぎる、というわけです。
4-2. コンパイルする / まとめて処理する(ベクトル化)
この遅さを解消する方法が2つあります。
1つはクエリコンパイルです。
構造を毎回解釈せず、SQLから実行用コードを生成して機械語まで落とします(JavaのJITに近い発想です)。
もう1つがベクトル化処理です。
列の値をバッチ(ひとかたまり)でまとめて演算子に通します。
ここで§3のビットマップが、今度は「実行を速くする道具」として再登場します。
上の図のように、§3で圧縮の利点だったビット演算が、ここでは実行を速くする手段に変わります。
この2つが速いのは、現代のCPUの得意なことに乗っているからです。
メモリを連続して読む、短いループを回す、SIMD(1命令で複数の値をまとめて演算するCPUの仕組み)を使う。
どれも、列でまとめて並んでいるからこそ効く特性です。
5. よく使う集計は、先に畳んでおける
読む量もCPUの使い方も削りました。それでも、同じ集計を毎回ゼロから回すのはもったいない。なら、結果を先に持っておけばいい、という話です。
5-1. マテリアライズドビューは、結果を実体として持つ
ビューには2種類あります。
普通の仮想ビューは、読むたびに裏のクエリをその場で展開して実行するので、ただのSQLの短縮形です。
一方マテリアライズドビューは、クエリの結果そのものをディスクに実体として書いておきます。
実体を持っているぶん、読むのは速くなります。
代わりに、元データが変わったら結果も更新しないといけないので、書き込み側の手間は増えます。
同じ重い集計を何度も繰り返すワークロードでは、この先払いが効いてきます。
5-2. データキューブは、次元ごとの集計を格子に
マテリアライズドビューに近い考え方で、分析でよく出てくるのがデータキューブ(OLAPキューブ)です。よく使う集計を、いくつかの次元でグループ化して、格子の形に並べておくものです。
言葉だけだと分かりにくいので、2次元の例を見てみます。Excelのピボットテーブルを思い浮かべると近いです。
| 売上集計 | カテゴリA | カテゴリB | カテゴリC | 行合計 |
|---|---|---|---|---|
| 月曜 | 120 | 80 | 40 | 240 |
| 火曜 | 90 | 110 | 50 | 250 |
| 水曜 | 70 | 60 | 30 | 160 |
| 列合計 | 280 | 250 | 120 | 650 |
縦に曜日、横にカテゴリを取り、各セルにその組み合わせの集計値を入れておきます。
行方向に畳めば曜日ごと、列方向に畳めばカテゴリごとの合計が、計算済みで取れます。
利点は、こうした集計が一瞬で返ること。格子の合計を見るだけで済みます。
欠点は、次元に入れていない切り口では集計できないこと。
「100円より高い商品の割合」は、価格を次元にしていなければ出せず、生データほどの柔軟さはありません。
なので最近は、昔ほどキューブの事前計算には頼りません。
§3・§4で見たように、列指向とベクトル化で生データのスキャン自体が速くなったからです。
今は「生データは残し、繰り返す重い集計だけ事前計算する」という使い分けが主流です。
6. 「まとめて絞る」発想は検索まで貫いている
ここまでは集計の話でした。実は、§3で出てきた「ビットマップでまとめて絞る」という発想は、検索のためのインデックスにもそのまま出てきます。
6-1. 単一の列だけでは足りない(多次元インデックス)
B木(バランスの取れた木構造の索引)は、1つの属性の範囲検索が得意です。
名前で「Lから始まる人」を探す、みたいな使い方です。
でも、2つの属性を同時に絞りたいときに困ります。
たとえば地図上のレストラン検索で、「緯度がこの範囲、かつ、経度がこの範囲」を一度に絞りたい。
緯度用・経度用にB木の索引を作っても、2次元の範囲をまとめて扱うのは得意ではありません。
そこで使うのが多次元インデックスです。
R木のような索引が、空間を区切って近いものを同じグループにまとめてくれます。
これは地理空間が代表例ですが、考え方は一般化できます。
たとえば(日付・気温)の2次元で索引を作れば、「ある年の、気温が25〜30度だった観測」を両方の条件で一度に絞れます。
6-2. 全文検索は「単語=次元」の多次元クエリ
全文検索(文章の中のキーワードで探す検索)も、かなりざっくり見ると多次元クエリのように考えられます。
出てくる単語の1つ1つを次元と考えます。
「red と apple を両方含む文書」は、red次元もapple次元も1の文書、になります。
これを支えるのが転置インデックスです。
単語をキーに、その単語を含む文書IDのリスト(ポスティングリスト)を値として持ちます。
文書IDが連番なら、このリストは§3のビットマップそのもので表せます。
そうなると「両方含む」は、2つのビットマップのANDを取るだけ。
§3・§4でやってきたビット演算とまったく同じです。集計が検索でそのまま再利用されています。
ちなみに、タイポや表記ゆれを許して近い単語を探すあいまい検索もできます。
仕組みは実装によって違いますが、「完全一致だけでなく、近い候補も拾う工夫がある」と思っておけば十分です。
6-3. ベクトル検索は「意味の近さ」で絞る
最後がベクトル検索です。単語の一致ではなく、意味の近さで探します。
意味検索とも呼ばれ、AIアプリのRAG(検索結果をLLMへの入力に渡して回答を生成させる手法)でよく使われます。
たとえば「サブスクリプションの解約」の記事に、「アカウントを閉じたい」で検索してもたどり着いてほしい。
使う単語は違っても、意味は近いからです。
そこで文章を多次元のベクトル(埋め込み)に変換し、意味が近い文章どうしを空間内で近くに置きます。
名前が似ていますが、§4の「ベクトル化処理」と、ここの「ベクトル(埋め込み)」は別物です。
ベクトル化処理は列の値をバッチでまとめて処理する話で、埋め込みは意味を表す浮動小数点数の並びです。
ここで問題になるのが、ベクトルの次元が非常に多い(1000を超えることもある)ことです。
§6-1のR木は次元が多すぎると効かないので、近いものを近似で速く探す専用のベクトルインデックスが使われます。
IVFやHNSWといった種類がありますが、「次元が多すぎるから専用の索引が要る」という点だけ押さえておけば十分です。
7. 分析側のデータは、1か所に固まっていない
ここまでは1つのストレージの中の話でした。でも現実の分析では、データはDBやデータレイク、クラウドにバラバラに散っているのが普通です。
7-1. クラウドDWHと、分離していく構成要素
最近のクラウドDWH(クラウド上のデータウェアハウス)は、計算とストレージを切り離す作りになっています。
データはローカルディスクではなくオブジェクトストレージに置いて、クエリの計算リソースとは独立に増減できます。
だから「保存容量はそのままで計算だけ増やす」といった調整がやりやすくなっています。
さらに、昔は1つのシステムに統合されていた構成要素が、それぞれ別の部品に分かれてきています。
| 構成要素 | 役割 |
|---|---|
| クエリエンジン | SQLを解析して実行計画を立て、実行する |
| ストレージ形式 | データをどうバイト列にしてファイルに並べるか(Parquetなど。列指向のファイル形式) |
| テーブル形式 | どのファイル群で1つのテーブルを構成するか、スキーマや履歴を管理 |
| データカタログ | どのテーブルがそのDBに含まれるかを管理 |
7-2. SQLが抽象レイヤーになる
データが散らばると、それを束ねる仕組みが要ります。
リレーショナルDB・NoSQL・ファイルといった種類の違うソースをまたいで、1本のSQLで問い合わせる横断クエリツールが出てきています。
それぞれのソースに別々のクエリを投げて結果を手で突き合わせるのではなく、SQLという1つの言語でまとめて聞けます。
こうなると、SQLはもう1つのDBだけの言語ではありません。散らばったデータを束ねる、分析の共通語になりつつあります。
おわりに
分析処理は、トランザクション処理の延長というより、データの並べ方から別の前提で組み立てられた世界でした。
列で並べるという一点を起点に、圧縮も、実行のしかたも、インデックスまで、かなり違う考え方で組み立てられています。
行指向の常識を一度外してみると、ばらばらに見えていた話が同じ発想でつながってきます。
列でまとめると圧縮が効く。ビットマップでまとめて絞れる。その同じ発想は全文検索のANDにも現れます。さらに、ベクトル検索では「意味の近さ」で候補を絞るために、また別の専用インデックスが使われます。
「同じSQLなのに速度が全然違う」と感じたときは、裏でデータがどう並んでいるかを思い浮かべると、差の見え方が変わります。

