Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails+PostgreSQLのパーティショニングを制覇する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

原文タイトルはおそらく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で扱えるようになります。

最初に、実行頻度が最も高いクエリを次のように決めます。

  • idorder_byしてorderを1件取得
  • ある期間(精度は分単位)の特定の国についてのordersをすべて取得する
  • orderのcostや関連付けられたデータを変更する

ordersテーブルを検討すれば、最も多いクエリの速度を向上させるにはcountryscheduled_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ガイド(英語)をご覧ください。

  1. ActiveRecordにおまかせする: NULLの代わりに新規レコードを返します。新規レコードをマスターテーブル子テーブルにそれぞれ配置したら、マスターテーブルから即座に削除します。つまり1つの操作を3つに分けて行うことになります。この方法を選んでもパフォーマンスが必然的に著しく低下するため、ほとんどのRails開発者はパーティショニング自体を諦めざるを得なくなるでしょう。

  2. ActiveRecord PostgreSQLアダプタで切り抜ける: Rails 4.0.2以降なら設定ファイルでinsert_returningfalseに設定すればよいので、これは難しくありません。これはうまくいきますが、その代わりアプリの全テーブルの振る舞いが変わってしまいます。また、(主キーの現在の値を取得するため)INSERT操作ごとにリクエストを1つ余分に受け取ることになります。

  3. (データベース)ビューを使う: これならデータベースレベルのリファクタリングだけでできるようになります。しかも、ビューは「普通の」テーブルであるかのように扱えるため、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のままになるため、INSERTNot 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文しか実行しないのであれば、おそらくパーティショニングは不要です。データを何らかの形でグループ化し(上の例のような期間ごとのグループ化など)、グループに関連するクエリを常用するのであれば、パーティショニングすることでかなり楽になるでしょう。

「これ使っちゃダメですか?」

より複雑で粒度の高いパーティショニングが必要になることがあります。しかし一度に1つのカラムしか作成しないのであれば使ってもよいでしょう。

  • PostgreSQL 10(とお遊びでpg_partyも)

宣言的パーティショニング(declarative partitioning)は本当に便利です。使える状況であれば遠慮なくどうぞ。ただし、PostgreSQLにかぎらず、最新バージョンを無条件に使える幸せな状況はめったにありません。(PostgreSQL 10へ)アップグレードできるのであれば、上述のデプロイプランを用意しておきましょう。


パーティショニングを試す準備は整いましたか?マイグレーションコードの作成は仕事の半分でしかありません。残り半分はチームが正しく作業を実行できるよう準備することです。

もっと詳しく知りたい方へ


スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。

関連記事

PostgreSQL 10の使って嬉しい5つの機能(翻訳)

Rails: PostgreSQLのマイグレーション速度を改善する(翻訳)

[Rails] RubyistのためのPostgreSQL EXPLAINガイド(翻訳)

Rails開発者のためのPostgreSQLの便利技(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。