PostgreSQLのtable partitioning

例えば日付でテーブルを分けたいという時に、制約を使った方法があります。 http://www.postgresql.jp/document/9.3/html/ddl-partitioning.html

ただ、例えば年月でパーティションされたテーブルを作り忘れたり、triggerを更新しなかったりすると悲劇なので、そこら辺を忘れても大丈夫なようにしましょうという話です。

partitioningの例

説明のために以下のようなテーブルを作ります。salesテーブルを分割することを想定します。

CREATE TABLE items (id SERIAL PRIMARY KEY, name TEXT, price numeric CHECK (price > 0));
INSERT INTO items(name, price) VALUES ('hoge', 100);
INSERT INTO items(name, price) VALUES ('abc', 200);

CREATE TABLE sales (id SERIAL PRIMARY KEY, item_id INT REFERENCES items(id), count INT, ctime TIMESTAMP NOT NULL DEFAULT current_timestamp);
CREATE INDEX ctime_idx ON sales (ctime);

子テーブル(例として2013/12, 2014/1)は以下の様なSQLで作成するものとします。LIKE使った場合にインデックスや制約は引き継いでくれますが、外部キー制約は引き継いでくれないので、ちゃんと指定する必要があります。CHECKでこのテーブルに入るデータの制限をかけます。

CREATE TABLE sales_201312 (
    LIKE sales INCLUDING ALL,
    FOREIGN KEY (item_id) REFERENCES items(id),
    CHECK (ctime >= '2013-12-01 00:00:00' and ctime < '2014-01-01 00:00:00')
) INHERITS (sales);


CREATE TABLE sales_201401 (
    LIKE sales INCLUDING ALL,
    FOREIGN KEY (item_id) REFERENCES items(id),
    CHECK (ctime >= '2014-01-01 00:00:00' and ctime < '2014-02-01 00:00:00')
) INHERITS (sales);

実際に2014/1のデータを入れてEXPLAINすると、ctimeがWHERE句に入っていればうまく枝刈りしてくれます。下の例だと201312のテーブルが枝刈りされています。

hoge=# insert into sales_201401(item_id, count, ctime)  values (1, 2, '2014-01-10 10:00:00');
INSERT 0 1
hoge=# analyze;
ANALYZE
hoge=# explain select * from sales where ctime >= '2014-01-10 00:00:00' and ctime < '2014-01-11 00:00:00';
                                                                    QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------
 Append  (cost=0.00..1.01 rows=2 width=20)
   ->  Seq Scan on sales  (cost=0.00..0.00 rows=1 width=20)
         Filter: ((ctime >= '2014-01-10 00:00:00'::timestamp without time zone) AND (ctime < '2014-01-11 00:00:00'::timestamp without time zone))
   ->  Seq Scan on sales_201401  (cost=0.00..1.01 rows=1 width=20)
         Filter: ((ctime >= '2014-01-10 00:00:00'::timestamp without time zone) AND (ctime < '2014-01-11 00:00:00'::timestamp without time zone))
(5 rows)
TRIGGER

やっと本題です。どのテーブルにINSERTするかは、アプリでもちろん決めることができますが、めんどくさい場合やCURRENT_TIMESTAMP使っている場合は、親テーブルにトリガー仕込んでおきます。親テーブルにINSERTするとトリガーが勝手に子テーブルのどこかに入れるようにします。

トリガー使う場合に注意したいのは以下の点です。

  • 振り分けルールがない
    • たとえば、201401ならsales_201401に入れるみたいなルールをそのまま列挙していると、いつかルールを書き忘れて想定しない挙動になってしまうかもしれません
  • 振り分けルールはあるが、実際に振り分け先のテーブルがない
    • 悲劇ですね

ということで上の2つの問題を解決するために、トリガーの中で動的にテーブル格納先を決めつつ、テーブルなければテーブル作るようなトリガーを作ってみます。

CREATE OR REPLACE FUNCTION sales_insert_trigger_func() RETURNS TRIGGER AS
  $$
    DECLARE
      partition text;
      createtable_sql text;
      startdate text;
      enddate text;
      parent_table text;
    BEGIN
      parent_table := 'sales';
      partition := parent_table || '_' || to_char(new.ctime, 'YYYYMM');
      EXECUTE 'INSERT INTO ' || partition || ' VALUES(($1).*)' USING new;
      RETURN NULL;
    EXCEPTION
        WHEN undefined_table THEN
            startdate := to_char(NEW.ctime, 'YYYY-MM-01');
            enddate := to_char(NEW.ctime + interval '1 month', 'YYYY-MM-01');
            createtable_sql := 'CREATE TABLE IF NOT EXISTS ' || partition || ' (LIKE '|| parent_table || ' INCLUDING ALL,
                                FOREIGN KEY (item_id) REFERENCES items(id),
                                CHECK (ctime >= ' || quote_literal(startdate) || ' AND ctime < ' || quote_literal(enddate) || ')) INHERITS ( ' || parent_table || ')';
            EXECUTE createtable_sql;
            EXECUTE 'INSERT INTO ' || partition || ' VALUES(($1).*)' USING new;
            RETURN NULL;
    END;
  $$
  LANGUAGE plpgsql;

CREATE TRIGGER insert_sales_trigger BEFORE INSERT ON sales FOR EACH ROW EXECUTE PROCEDURE sales_insert_trigger_func();

new.ctime(INSERTしようとしている行のctime)からテーブル名を決定し、EXECUTEでSQLを実行します。もしテーブルがない場合は、 undefined_tableというエラーが返ってくるので、テーブル作って再実行するということをやります。

実際にテーブルない状態で2014/2のデータをINSERTしてみた結果です。

hoge=# insert into sales(item_id, count, ctime)  values (1, 2, '2014-02-10 10:00:00');
NOTICE:  merging column "id" with inherited definition
CONTEXT:  SQL statement "CREATE TABLE IF NOT EXISTS sales_201402 (LIKE sales INCLUDING ALL,
                                  FOREIGN KEY (item_id) REFERENCES items(id),
                                  CHECK (ctime >= '2014-02-01' AND ctime < '2014-03-01')) INHERITS ( sales)"
PL/pgSQL function sales_insert_trigger_func() line 20 at EXECUTE statement
NOTICE:  merging column "item_id" with inherited definition
CONTEXT:  SQL statement "CREATE TABLE IF NOT EXISTS sales_201402 (LIKE sales INCLUDING ALL,
                                  FOREIGN KEY (item_id) REFERENCES items(id),
                                  CHECK (ctime >= '2014-02-01' AND ctime < '2014-03-01')) INHERITS ( sales)"
PL/pgSQL function sales_insert_trigger_func() line 20 at EXECUTE statement
NOTICE:  merging column "count" with inherited definition
CONTEXT:  SQL statement "CREATE TABLE IF NOT EXISTS sales_201402 (LIKE sales INCLUDING ALL,
                                  FOREIGN KEY (item_id) REFERENCES items(id),
                                  CHECK (ctime >= '2014-02-01' AND ctime < '2014-03-01')) INHERITS ( sales)"
PL/pgSQL function sales_insert_trigger_func() line 20 at EXECUTE statement
NOTICE:  merging column "ctime" with inherited definition
CONTEXT:  SQL statement "CREATE TABLE IF NOT EXISTS sales_201402 (LIKE sales INCLUDING ALL,
                                  FOREIGN KEY (item_id) REFERENCES items(id),
                                  CHECK (ctime >= '2014-02-01' AND ctime < '2014-03-01')) INHERITS ( sales)"
PL/pgSQL function sales_insert_trigger_func() line 20 at EXECUTE statement
INSERT 0 0

hoge=# select * from sales_201402;
 id | item_id | count |        ctime
----+---------+-------+---------------------
  4 |       1 |     2 | 2014-02-10 10:00:00
(1 row)

なお注意点としては、

  • トリガー使った場合はINSERTの戻り値が常に0件になるのと、RETURNINGも動かないので、or mapperが勝手にreturningしている場合はうまく回避する必要があります
  • あまりまじめに検証していないので、同時にテーブル作成しようとした場合にどうなるかはちょっと怪しいので、基本的にはcrontabか何かにtable作るような定期ジョブを設定しておいたほうが良いです。といってもテーブル忘れた場合にエラーが出続けるよりはいいとは思います。

といったところでしょうか。