概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Partition and conquer
- 原文公開日: 2017/11/07
- 著者: Sergey Dolganov、Denis Lifanov
- サイト: https://evilmartians.com/
原文タイトルはおそらくCommand&Conquerのもじりと思われます。
Rails+PostgreSQLのパーティショニングを制覇する(翻訳)
前書き
本記事は実際の出来事をヒントにしたデータベースパーティショニングについて書いたものです。productionアプリ、すなわちRuby on RailsとPostgreSQLの速度を低下させる巨大なテーブルを分割する方法について手順を追って学びます。
これ以上大きくなっては困るとき
データベースは肥大化する傾向があります。データベースのサイズはある時点から負債と化しますが、主キー数が上限に達するような極端な状況をなかなか想定できません(そしてこれは実際に起きます)。本記事は、私達の一顧客であるGettでの経験を元にしています。このときはデータベーステーブルが危険水域に達するほど肥大化し続けていました。
行数が数百万行に達すると、クエリによっては完了に数時間を要することもあります。これによって生じた技術的な困難をデータベースパーティショニングによって解決しました。
1個の巨大なテーブルを多数の小さなテーブルに分割するというのは標準的な技法ですが、特に本番稼働中のデータが危機にひんしている場合は注意深く行う必要があります。本記事では、よくある落とし穴を回避してデータロスなしで移行できるようにする方法を解説します。何らかのハンズオンをやってみるのが学習法として最善なので、最初にフェイクデータで巨大なテーブルを作成して問題を作り出します。続いて、PostgreSQLのマジックを武器としてこの問題を皆さんと一緒に解決します。
実際のフェイクテーブル
まずデータが、それも大量のデータが必要です。改善前のテーブルが含むorders
には、通常のビジネスロジックを模したカラムがあります。
訳注: 上の一文目は、おそらく映画「マトリックス」のセリフ(Guns, Lots of Guns)のもじりです。
CREATE TABLE orders (
id SERIAL,
country VARCHAR(2) NOT NULL, -- 国コード
type VARCHAR NOT NULL DEFAULT 'delivery', -- orderの種類
scheduled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, -- orderの作成時刻
cost NUMERIC(10,2) NOT NULL DEFAULT 0, -- orderのコスト
data JSONB NOT NULL DEFAULT '{}' -- 追加データ
);
注: リファクタリングでは主にPostgreSQLを考慮するため、以後クエリは純粋なSQLで、関数はPL/pgSQLでそれぞれ表記します。作業が終わった後は、ActiveRecord経由でデータをRailsで扱えるようになります。
最初に、実行頻度が最も高いクエリを次のように決めます。
id
でorder_by
してorderを1件取得- ある期間(精度は分単位)の特定の国についてのordersをすべて取得する
- orderの
cost
や関連付けられたデータを変更する
ordersテーブルを検討すれば、最も多いクエリの速度を向上させるにはcountry
とscheduled_at
をインデックス化するのが妥当であることが即座にわかります。
CREATE INDEX index_orders_on_country_and_scheduled_at ON orders (country, scheduled_at);
準備が整ったので、以下のようにランダムな値を用いてgenerate_series
でテーブルの値を埋めます。
INSERT INTO orders (country, type, scheduled_at, cost)
SELECT
('{RU,RU,RU,RU,US,GB,GB,IL}'::text[])[trunc(random() * 8) + 1],
('{delivery,taxi}'::text[])[trunc(random() * 2) + 1],
CURRENT_DATE - (interval '1 day' * (n / 100000)) + (interval '1 second' * (random() * 86400)),
round((100 + random() * 200)::numeric, 2)
FROM
generate_series(1,30 * 1000000) s(n);
分割
目標はストレートに設定しなければなりません。ここではorders
を分割して次のようにしたいと考えています。
- 生成されるテーブルには特定の月の特定の国の
orders
がすべて含まれること - アプリのロジックがほぼ変わらないようにすること
最も達成しやすいのは、子テーブルを作成し、対応するトリガを作成し、テーブル全体にレコードを分散させるトリガ関数を作成する方法です。
しかしこの方法でActiveRecordでデータベースにクエリをかけたい場合、ひとつ面倒な点があります。純粋なSQLでは、同じレコードを2回INSERT
する(マスターテーブルで1回、子テーブルで1回)のを避けるため、トリガプロシージャはNULL
を返す必要があります。しかしこれはActiveRecordとの相性がよくありません。ActiveRecordはINSERT
文でRETURNING
文を使った場合に新規レコードの主キーを1つ返すことを期待するからです。解決方法はいくつか考えられます。
レガシーなスキーマで(データベース)ビューを使う方法の例はRailsガイド(英語)をご覧ください。
- ActiveRecordにおまかせする:
NULL
の代わりに新規レコードを返します。新規レコードをマスターテーブルと子テーブルにそれぞれ配置したら、マスターテーブルから即座に削除します。つまり1つの操作を3つに分けて行うことになります。この方法を選んでもパフォーマンスが必然的に著しく低下するため、ほとんどのRails開発者はパーティショニング自体を諦めざるを得なくなるでしょう。 -
ActiveRecord PostgreSQLアダプタで切り抜ける: Rails 4.0.2以降なら設定ファイルで
insert_returning
をfalse
に設定すればよいので、これは難しくありません。これはうまくいきますが、その代わりアプリの全テーブルの振る舞いが変わってしまいます。また、(主キーの現在の値を取得するため)INSERT
操作ごとにリクエストを1つ余分に受け取ることになります。 -
(データベース)ビューを使う: これならデータベースレベルのリファクタリングだけでできるようになります。しかも、ビューは「普通の」テーブルであるかのように扱えるため、ActiveRecordはビューと自然に協調動作でき、既存アプリのロジック変更は最小限で済みます。
第3の方法を使うことにします。最初に、テーブルを複製する必要があります。テーブルを複製するメリットは次のとおりです。
- 既存データの完全性を保ち、他のモデルからの参照が安全に保たれる
- パーティショニングのデプロイ中(マイグレーション後からリスタートまでの間)に既存アプリを生かしておくことができる
- 作業中に問題が発生しても元のテーブルにフォールバックできる
複製は次の方法で行います。
CREATE TABLE orders_partitioned (LIKE orders INCLUDING ALL);
クローンされたテーブルの主キーは、元のテーブルと同じorders_id_seq
シーケンスを参照します。これにより、古いテーブルから新しいテーブルにデータを移動するときに衝突を回避できます。
データベースビューはマジでいいやつ
今度は新しいテーブルでビューを作成する必要があります。新しいテーブルはまだ空ですが、変更をデプロイするとすべての新規レコードがそこに配置され、対応する複数の子テーブルにも直ちに同じレコードが配置されます。
CREATE OR REPLACE VIEW orders_partitioned_view AS SELECT * FROM orders_partitioned;
まだ何か足りないようです。デフォルト値はどうすればよいでしょうか。明らかに主キーのデフォルト値が必要ですし、デフォルト値がないとActiveRecordでINSERT
が効かなくなってしまいます(orders_id_seq
にご注目ください)。
ALTER VIEW orders_partitioned_view
ALTER COLUMN id
SET DEFAULT nextval('orders_id_seq'::regclass);
理論上は、他のカラムはデフォルト値なしでActiveRecordによって扱われますが、アプリのコードではまだ必要になる可能性があります。
また、先のトリガ関数はデフォルト値がないと動作しません(フィールド値がNULL
のままになるため、INSERT
がNot Null Violation
で失敗します)
ALTER VIEW orders_partitioned_view
ALTER COLUMN cost
SET DEFAULT 0;
ALTER VIEW orders_partitioned_view
ALTER COLUMN type
SET DEFAULT 'delivery';
ALTER VIEW orders_partitioned_view
ALTER COLUMN data
SET DEFAULT '{}';
次は、以下を行うトリガプロシージャが必要です。
- 子テーブルをどのレコードを配置できるかを決定する
- テーブルが存在しない場合は作成する: これがないと、思いつく限りすべてのテーブルを手動で作成しなければならなくなります。もちろんアプリで自動化もできますが、余分なコードは書かずに済ませたいものです。
データベースの(ストアド)プロシージャのコードを書くときが来ました。PL/pgSQLを使います。
CREATE OR REPLACE FUNCTION orders_partitioned_view_insert_trigger_procedure() RETURNS TRIGGER AS $BODY$
DECLARE
partition TEXT;
partition_country TEXT;
partition_date TIMESTAMP;
BEGIN
/* 新しいテーブルの名前を作成する*/
partition_date := date_trunc('month', NEW.scheduled_at);
partition_country := lower(NEW.country);
partition := TG_TABLE_NAME || '_' || partition_country || '_' || to_char(partition_date, 'YYYY_MM');
/*
必要な場合に子テーブルを作成する。関連するすべての部分に通知する。
*/
IF NOT EXISTS(SELECT relname FROM pg_class WHERE relname = partition) THEN
RAISE NOTICE 'ordersの新しいパーティションを作成します: %', partition;
/*
ここでは以下を行います:
* マスターテーブルからテーブルを1つ継承
* 生成されるテーブルにCHECK制約を作成
(条件を満たさないレコードが挿入されないようにするため)
* 必要なインデックスを作成
* 引用符が3つあるのは機能であり、バグではありません
*/
EXECUTE 'CREATE TABLE IF NOT EXISTS ' || partition || ' (CHECK (
country = ''' || NEW.country || ''' AND
date_trunc(''minute'', scheduled_at) >= ''' || partition_date || ''' AND
date_trunc(''minute'', scheduled_at) < ''' || partition_date + interval '1 month' || '''))
INHERITS (orders_partitioned);';
EXECUTE 'CREATE INDEX IF NOT EXISTS ' || partition || '_scheduled_at_idx ON ' || partition || ' (scheduled_at);';
END IF;
/* And, finally, insert. */
EXECUTE 'INSERT INTO ' || partition || ' SELECT(orders ' || quote_literal(NEW) || ').*';
/*
注意: NULLではなく、新規レコードが返されます。
これによりActiveRecordとの相性がよくなります。
*/
RETURN NEW;
END;
$BODY$
LANGUAGE plpgsql;
これで以下ができました。
- テーブルとして使えるビュー
- 新規レコードを配置するトリガ関数
それではここまでの成果をまとめてみましょう。
CREATE TRIGGER orders_partitioned_view_insert_trigger
INSTEAD OF INSERT ON orders_partitioned_view
FOR EACH ROW EXECUTE PROCEDURE orders_partitioned_view_insert_trigger_procedure();
ところで、Rubyらしく行うには、元のテーブルの代わりにorders_partitioned_view
を使うようOrder
モデルに指示するだけでできます。Railsはこうした操作を自然に行なえますので、モデルのtable_name
にビュー名を与えるだけでおしまいです。
この機能はレガシーなスキーマを用いるときによく使われます。
ところで元のデータは?
そうそう、既存のデータを忘れてはいけません。まだ元のテーブルにそのまま残っているので、注意深く新しいテーブルに移動しなければなりません。問題はデータ量が非常に大きい(数百万行でしたよね)ことで、対処方法はいくつも考えられます。ありがたいことに、すべてのデータを移行しなければならないことはめったにありません。通常、直近のいくつかの月に対応する子テーブルにデータを入れる必要があります。先月分のデータでやってみましょう。
INSERT INTO orders_partitioned_view
SELECT * FROM orders
WHERE scheduled_at >= date_trunc('month', now());
後悔しないデプロイ方法
手順はできあがりましたが、本番環境で行うのはまったく別の話です。予想もつかないような問題がいくつも起きるでしょう。
私たちが本番でこの手法を使ったとき、まさにそれが起きたのです。パーティショニングが必要なサービスは「High Availability(高可用性)」を謳っていました。つまりダウンタイム15分以上は許されないということです。
本番サービスへの全リクエストはMessage Queueを経由しますので、少し待てば作業完了後にリクエストを実行できます。しかし実際には長時間待つしかありませんでした。
アプリはActiveRecordに全面的に依存していたのではなく、生のデータベースクエリも使われていたため、ダウンタイムは避けられませんでした(生SQLを避けることができれば、多くのトラブルに遭わずに済みます)。そういうわけで、Railsでテーブル名を単に切り替えるという選択肢は使えませんでした。また、テーブルの複製も避けたかったため、いくつかの手順を組み合わせました。
ALTER TABLE orders RENAME TO orders_partitioned;
CREATE OR REPLACE VIEW orders AS SELECT * FROM orders_partitioned;
/* ...省略 */
常に操作が繰り返されている稼働中のビジネスデータを扱うので、変更のリリースは「リスキー」とマーキングすべきです。理想的なデプロイチェックリストを以下に示します。
- 運用エンジニアは今度のリリース内容を把握し、かつ立ち会うこと
- データベース管理者も同様にリリース内容を把握し、かつ立ち会うこと
- 監視可能なものはすべて監視対象に含めること: ディスクアクセス操作、RAMやCPUの使用量、ネットワークリクエスト、データベースクエリ、アプリリクエスト、バックグラウンドタスク、ログ
- 別の開発者かチームリーダーに精査してもらった「リリースプラン」を用意すること
- マイグレーションコードを書いた開発者に連絡を取れること
- チームリーダーはこれらを把握し、立ち会うこと
- あらゆる手順を手動で再現できるようにしておくこと
- ロールバックプランを用意すること
大げさに見えるかもしれませんが、自信過剰は禁物です。十分時間をかけて正しいデプロイ手順を策定しましょう。
パーティショニングすべきかどうかの決定
テーブルのサイズを検査するのはもちろんですが、最も重要なのは時間の経過に伴うデータ増加を見積もることです。どんなクエリが最も多いかを検討します。主キーや外部キーによるSELECT
文しか実行しないのであれば、おそらくパーティショニングは不要です。データを何らかの形でグループ化し(上の例のような期間ごとのグループ化など)、グループに関連するクエリを常用するのであれば、パーティショニングすることでかなり楽になるでしょう。
「これ使っちゃダメですか?」
- …pg_pathmanを使う
より複雑で粒度の高いパーティショニングが必要になることがあります。しかし一度に1つのカラムしか作成しないのであれば使ってもよいでしょう。
- PostgreSQL 10(とお遊びでpg_partyも)
宣言的パーティショニング(declarative partitioning)は本当に便利です。使える状況であれば遠慮なくどうぞ。ただし、PostgreSQLにかぎらず、最新バージョンを無条件に使える幸せな状況はめったにありません。(PostgreSQL 10へ)アップグレードできるのであれば、上述のデプロイプランを用意しておきましょう。
パーティショニングを試す準備は整いましたか?マイグレーションコードの作成は仕事の半分でしかありません。残り半分はチームが正しく作業を実行できるよう準備することです。
もっと詳しく知りたい方へ
スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。