はじめに

アプリケーション開発において、複雑なSQLクエリを何度も書くことに疲れた経験はありませんか。あるいは、重たい集計クエリの結果を毎回計算するのではなく、キャッシュしておきたいと思ったことはないでしょうか。

PostgreSQLのビュー(View)とマテリアライズドビュー(Materialized View)は、これらの課題を解決する強力な機能です。ビューを使えば複雑なクエリを名前付きで再利用でき、マテリアライズドビューを使えば計算結果を物理的に保存してパフォーマンスを大幅に向上できます。

本記事では、ビューの基本概念と作成方法、更新可能ビューの条件と活用法、マテリアライズドビューの仕組みとリフレッシュ戦略、そしてパフォーマンス面でのトレードオフについて、実践的なサンプルコードとともに解説します。

この記事を読むことで、以下のことができるようになります。

  • ビューを使って複雑なクエリを抽象化し、再利用可能な形で管理できる
  • 更新可能ビューの条件を理解し、適切に設計できる
  • マテリアライズドビューで集計結果をキャッシュし、パフォーマンスを最適化できる
  • リフレッシュ戦略を設計し、データの鮮度とパフォーマンスのバランスを取れる
  • ビューとマテリアライズドビューの使い分けを判断できる

前提条件

  • PostgreSQL 14以降がインストールされていること
  • psqlまたはGUIツール(pgAdmin、DBeaver等)でデータベースに接続できること
  • SELECT文、JOINの基本的な構文を理解していること

基本的なSELECT文やJOINについては、以下の関連記事を参照してください。

サンプルデータの準備

本記事で使用するサンプルテーブルとデータを準備します。ECサイトの売上分析を想定したデータ構成です。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
-- カテゴリテーブル
CREATE TABLE categories (
    category_id   SERIAL PRIMARY KEY,
    name          VARCHAR(100) NOT NULL,
    parent_id     INTEGER REFERENCES categories(category_id)
);

-- 商品テーブル
CREATE TABLE products (
    product_id    SERIAL PRIMARY KEY,
    name          VARCHAR(200) NOT NULL,
    category_id   INTEGER REFERENCES categories(category_id),
    price         INTEGER NOT NULL,
    cost          INTEGER NOT NULL,
    is_active     BOOLEAN DEFAULT TRUE,
    created_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 顧客テーブル
CREATE TABLE customers (
    customer_id   SERIAL PRIMARY KEY,
    name          VARCHAR(100) NOT NULL,
    email         VARCHAR(255) UNIQUE NOT NULL,
    prefecture    VARCHAR(20) NOT NULL,
    created_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 注文テーブル
CREATE TABLE orders (
    order_id      SERIAL PRIMARY KEY,
    customer_id   INTEGER REFERENCES customers(customer_id),
    order_date    DATE NOT NULL,
    status        VARCHAR(20) NOT NULL,
    total_amount  INTEGER NOT NULL
);

-- 注文明細テーブル
CREATE TABLE order_items (
    item_id       SERIAL PRIMARY KEY,
    order_id      INTEGER REFERENCES orders(order_id),
    product_id    INTEGER REFERENCES products(product_id),
    quantity      INTEGER NOT NULL,
    unit_price    INTEGER NOT NULL
);

-- サンプルデータの挿入
INSERT INTO categories (name, parent_id) VALUES
('電子機器', NULL),
('家具', NULL),
('文具', NULL),
('PC周辺機器', 1),
('スマートフォン関連', 1),
('オフィス家具', 2);

INSERT INTO products (name, category_id, price, cost, is_active) VALUES
('ノートPC', 4, 120000, 80000, TRUE),
('ワイヤレスマウス', 4, 3500, 1500, TRUE),
('メカニカルキーボード', 4, 15000, 8000, TRUE),
('USB-Cハブ', 4, 5000, 2000, TRUE),
('スマホスタンド', 5, 2000, 800, TRUE),
('ワイヤレス充電器', 5, 4000, 1800, TRUE),
('オフィスデスク', 6, 45000, 25000, TRUE),
('オフィスチェア', 6, 35000, 18000, TRUE),
('モニターアーム', 6, 8000, 3500, TRUE),
('ボールペンセット', 3, 1500, 500, TRUE),
('ノート(5冊組)', 3, 800, 300, TRUE),
('廃盤商品', 4, 10000, 5000, FALSE);

INSERT INTO customers (name, email, prefecture) VALUES
('田中太郎', 'tanaka@example.com', '東京都'),
('鈴木花子', 'suzuki@example.com', '大阪府'),
('佐藤次郎', 'sato@example.com', '愛知県'),
('高橋美咲', 'takahashi@example.com', '福岡県'),
('伊藤健一', 'ito@example.com', '北海道');

INSERT INTO orders (customer_id, order_date, status, total_amount) VALUES
(1, '2025-10-15', 'completed', 123500),
(2, '2025-10-20', 'completed', 50000),
(1, '2025-11-05', 'completed', 45000),
(3, '2025-11-10', 'completed', 165000),
(4, '2025-11-15', 'completed', 8000),
(5, '2025-11-20', 'completed', 35000),
(2, '2025-12-01', 'completed', 120000),
(1, '2025-12-10', 'completed', 18500),
(3, '2025-12-15', 'pending', 80000),
(4, '2025-12-20', 'cancelled', 10000);

INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(1, 1, 1, 120000), (1, 2, 1, 3500),
(2, 7, 1, 45000), (2, 9, 1, 5000),
(3, 7, 1, 45000),
(4, 1, 1, 120000), (4, 7, 1, 45000),
(5, 9, 1, 8000),
(6, 8, 1, 35000),
(7, 1, 1, 120000),
(8, 2, 1, 3500), (8, 3, 1, 15000),
(9, 7, 1, 45000), (9, 8, 1, 35000),
(10, 10, 5, 1500), (10, 11, 5, 800);

-- 統計情報の更新
ANALYZE categories, products, customers, orders, order_items;

ビューの基本

ビューとは

ビュー(View)とは、SELECT文に名前を付けて保存したものです。ビュー自体はデータを持たず、参照されるたびに定義されたクエリが実行されます。これにより、複雑なクエリを抽象化し、シンプルなインターフェースとして提供できます。

ビューの主な特徴は以下の通りです。

特徴 説明
クエリの抽象化 複雑なJOINや計算を隠蔽し、シンプルなSELECTで利用可能
再利用性 同じクエリロジックを複数箇所で使い回せる
セキュリティ 特定のカラムや行だけを公開できる
保守性 クエリの変更がビュー定義の修正だけで済む
リアルタイム性 参照時に常に最新のデータを取得

ビューの内部動作を図で表すと以下のようになります。

flowchart LR
    A[アプリケーション] -->|SELECT * FROM view| B[ビュー]
    B -->|クエリ展開| C[元のSELECT文]
    C --> D[(テーブル1)]
    C --> E[(テーブル2)]
    D --> F[結果セット]
    E --> F
    F --> A

CREATE VIEWの基本構文

ビューはCREATE VIEW文で作成します。基本的な構文は以下の通りです。

1
2
CREATE VIEW ビュー名 AS
    SELECT文;

商品と売上に関する情報を結合したビューを作成してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- アクティブ商品のみを表示するビュー
CREATE VIEW active_products AS
    SELECT 
        p.product_id,
        p.name AS product_name,
        c.name AS category_name,
        p.price,
        p.cost,
        p.price - p.cost AS profit_margin
    FROM products p
    JOIN categories c ON p.category_id = c.category_id
    WHERE p.is_active = TRUE;

作成したビューは通常のテーブルと同様にSELECTで参照できます。

1
SELECT * FROM active_products;

実行結果は以下の通りです。

 product_id |    product_name      | category_name  | price  |  cost  | profit_margin 
------------+----------------------+----------------+--------+--------+---------------
          1 | ノートPC             | PC周辺機器     | 120000 |  80000 |         40000
          2 | ワイヤレスマウス     | PC周辺機器     |   3500 |   1500 |          2000
          3 | メカニカルキーボード | PC周辺機器     |  15000 |   8000 |          7000
...

ビューの活用シーン

ビューは様々なシーンで活用できます。代表的なユースケースを見ていきましょう。

複雑なクエリの簡略化

複数のテーブルをJOINする複雑なクエリを、シンプルなビューとして提供できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- 注文詳細ビュー(顧客・注文・商品情報を結合)
CREATE VIEW order_details AS
    SELECT 
        o.order_id,
        o.order_date,
        o.status,
        c.name AS customer_name,
        c.prefecture,
        p.name AS product_name,
        cat.name AS category_name,
        oi.quantity,
        oi.unit_price,
        oi.quantity * oi.unit_price AS subtotal
    FROM orders o
    JOIN customers c ON o.customer_id = c.customer_id
    JOIN order_items oi ON o.order_id = oi.order_id
    JOIN products p ON oi.product_id = p.product_id
    JOIN categories cat ON p.category_id = cat.category_id;

これにより、アプリケーション側では以下のようなシンプルなクエリで必要な情報を取得できます。

1
2
3
4
-- 東京都の顧客の注文履歴
SELECT * FROM order_details
WHERE prefecture = '東京都'
ORDER BY order_date DESC;

セキュリティ目的での行・列の制限

機密情報を含むテーブルから、必要な情報だけを公開するビューを作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- 顧客の公開情報のみを表示するビュー(メールアドレスを除外)
CREATE VIEW customers_public AS
    SELECT 
        customer_id,
        name,
        prefecture,
        created_at
    FROM customers;

-- 完了済み注文のみを表示するビュー
CREATE VIEW completed_orders AS
    SELECT *
    FROM orders
    WHERE status = 'completed';

計算フィールドの事前定義

計算ロジックをビューに含めることで、アプリケーション側の実装を簡略化できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- 商品別売上サマリビュー
CREATE VIEW product_sales_summary AS
    SELECT 
        p.product_id,
        p.name AS product_name,
        COUNT(oi.item_id) AS order_count,
        SUM(oi.quantity) AS total_quantity,
        SUM(oi.quantity * oi.unit_price) AS total_revenue,
        SUM(oi.quantity * (oi.unit_price - p.cost)) AS total_profit
    FROM products p
    LEFT JOIN order_items oi ON p.product_id = oi.product_id
    LEFT JOIN orders o ON oi.order_id = o.order_id 
        AND o.status = 'completed'
    GROUP BY p.product_id, p.name;

ビューの管理

ビューの定義変更や削除の方法を確認しましょう。

ビューの置き換え

CREATE OR REPLACE VIEWを使うと、既存のビューを削除せずに定義を変更できます。ただし、新しい定義は同じカラム名・型・順序を持つ必要があります(末尾にカラムを追加することは可能)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- ビューの定義を変更(末尾にカラム追加)
CREATE OR REPLACE VIEW active_products AS
    SELECT 
        p.product_id,
        p.name AS product_name,
        c.name AS category_name,
        p.price,
        p.cost,
        p.price - p.cost AS profit_margin,
        p.created_at  -- 新しいカラムを追加
    FROM products p
    JOIN categories c ON p.category_id = c.category_id
    WHERE p.is_active = TRUE;

ビューの削除

ビューはDROP VIEWで削除します。

1
2
3
4
5
6
7
8
-- ビューの削除
DROP VIEW active_products;

-- 存在しない場合もエラーにしない
DROP VIEW IF EXISTS active_products;

-- 依存するビューも含めて削除
DROP VIEW active_products CASCADE;

ビュー定義の確認

ビューの定義を確認するには、\dコマンドやpg_viewsカタログを使用します。

1
2
3
4
5
-- psqlでビューの構造を確認
\d active_products

-- ビューの定義(SQLクエリ)を確認
SELECT definition FROM pg_views WHERE viewname = 'active_products';

更新可能ビュー

更新可能ビューとは

PostgreSQLでは、一定の条件を満たすビューに対してINSERT、UPDATE、DELETE操作を実行できます。これを「更新可能ビュー(Updatable View)」と呼びます。更新可能ビューに対する操作は、自動的に元のテーブルに反映されます。

更新可能ビューの条件は以下の通りです。

条件 説明
単一テーブル FROM句に含まれるテーブルが1つのみ
集計なし GROUP BY、HAVING、集計関数を使用していない
DISTINCT なし DISTINCT句を使用していない
UNION なし UNION、INTERSECT、EXCEPTを使用していない
LIMIT/OFFSET なし トップレベルでLIMIT、OFFSET を使用していない
WITH なし WITH句(CTE)を使用していない

更新可能ビューの作成例

条件を満たすシンプルなビューを作成し、更新操作を試してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 更新可能な顧客ビュー(特定地域のみ)
CREATE VIEW tokyo_customers AS
    SELECT 
        customer_id,
        name,
        email,
        prefecture,
        created_at
    FROM customers
    WHERE prefecture = '東京都';

このビューは単一テーブルから作成されており、更新可能ビューの条件を満たしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- ビュー経由でのINSERT
INSERT INTO tokyo_customers (name, email, prefecture)
VALUES ('山田一郎', 'yamada@example.com', '東京都');

-- ビュー経由でのUPDATE
UPDATE tokyo_customers
SET name = '田中太郎(更新)'
WHERE customer_id = 1;

-- ビュー経由でのDELETE
DELETE FROM tokyo_customers
WHERE name = '山田一郎';

重要な点として、ビューの条件(WHERE prefecture = '東京都')を満たさないデータもINSERTできてしまいます。これを防ぐにはCHECK OPTIONを使用します。

WITH CHECK OPTIONによる制約

WITH CHECK OPTIONを指定すると、ビューの条件を満たさないデータの挿入・更新を防止できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- CHECK OPTION付きビュー
CREATE VIEW tokyo_customers_checked AS
    SELECT 
        customer_id,
        name,
        email,
        prefecture,
        created_at
    FROM customers
    WHERE prefecture = '東京都'
    WITH CHECK OPTION;

このビューに対して条件を満たさないデータを挿入しようとするとエラーになります。

1
2
3
4
-- エラー:東京都以外は挿入できない
INSERT INTO tokyo_customers_checked (name, email, prefecture)
VALUES ('大阪太郎', 'osaka@example.com', '大阪府');
-- ERROR: new row violates check option for view "tokyo_customers_checked"

CHECK OPTIONには2つのモードがあります。

モード 説明
LOCAL そのビューの条件のみチェック
CASCADED 依存するすべてのビューの条件もチェック(デフォルト)

INSTEAD OF トリガーによる複雑なビューの更新

更新可能ビューの条件を満たさない複雑なビューでも、INSTEAD OFトリガーを使えば更新操作を実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- 複雑なビュー(JOINを含むため自動更新不可)
CREATE VIEW product_with_category AS
    SELECT 
        p.product_id,
        p.name AS product_name,
        p.price,
        p.is_active,
        c.category_id,
        c.name AS category_name
    FROM products p
    JOIN categories c ON p.category_id = c.category_id;

-- INSTEAD OF UPDATEトリガー用の関数
CREATE OR REPLACE FUNCTION update_product_with_category()
RETURNS TRIGGER AS $$
BEGIN
    -- productsテーブルのみ更新
    UPDATE products
    SET 
        name = NEW.product_name,
        price = NEW.price,
        is_active = NEW.is_active,
        category_id = NEW.category_id
    WHERE product_id = OLD.product_id;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- トリガーの作成
CREATE TRIGGER trg_update_product_with_category
    INSTEAD OF UPDATE ON product_with_category
    FOR EACH ROW
    EXECUTE FUNCTION update_product_with_category();

これにより、JOINを含むビューに対してもUPDATE操作が可能になります。

1
2
3
4
-- ビュー経由での更新(実際にはproductsテーブルが更新される)
UPDATE product_with_category
SET price = 130000
WHERE product_id = 1;

マテリアライズドビューの基本

マテリアライズドビューとは

マテリアライズドビュー(Materialized View)は、ビューのクエリ結果を物理的に保存するオブジェクトです。通常のビューと異なり、クエリの実行結果がテーブルのように永続化されるため、参照時に再計算が不要になります。

ビューとマテリアライズドビューの違いを比較してみましょう。

特性 ビュー マテリアライズドビュー
データの保存 保存しない(参照時に計算) 保存する(事前に計算)
参照時の速度 クエリ実行時間に依存 高速(保存済みデータを返す)
データの鮮度 常に最新 リフレッシュまで古いまま
ストレージ使用量 なし 結果セット分必要
インデックス 作成不可 作成可能
更新操作 条件付きで可能 不可(リフレッシュのみ)
flowchart LR
    subgraph ビュー
        A1[クエリ実行] --> B1[テーブル参照] --> C1[結果生成]
    end
    
    subgraph マテリアライズドビュー
        A2[リフレッシュ] --> B2[テーブル参照] --> C2[結果保存]
        D2[クエリ実行] --> C2 --> E2[保存済み結果を返す]
    end

CREATE MATERIALIZED VIEWの構文

マテリアライズドビューはCREATE MATERIALIZED VIEWで作成します。

1
2
3
CREATE MATERIALIZED VIEW ビュー名 AS
    SELECT文
    [WITH [NO] DATA];

月別売上サマリのマテリアライズドビューを作成してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- 月別売上サマリのマテリアライズドビュー
CREATE MATERIALIZED VIEW monthly_sales_summary AS
    SELECT 
        DATE_TRUNC('month', o.order_date)::DATE AS sales_month,
        COUNT(DISTINCT o.order_id) AS order_count,
        COUNT(DISTINCT o.customer_id) AS customer_count,
        SUM(oi.quantity) AS total_quantity,
        SUM(oi.quantity * oi.unit_price) AS total_revenue,
        SUM(oi.quantity * (oi.unit_price - p.cost)) AS total_profit,
        ROUND(AVG(o.total_amount), 0) AS avg_order_amount
    FROM orders o
    JOIN order_items oi ON o.order_id = oi.order_id
    JOIN products p ON oi.product_id = p.product_id
    WHERE o.status = 'completed'
    GROUP BY DATE_TRUNC('month', o.order_date)
    ORDER BY sales_month;

作成直後から参照可能です。

1
SELECT * FROM monthly_sales_summary;

実行結果は以下の通りです。

 sales_month | order_count | customer_count | total_quantity | total_revenue | total_profit | avg_order_amount 
-------------+-------------+----------------+----------------+---------------+--------------+------------------
 2025-10-01  |           2 |              2 |              4 |        173500 |        60500 |            86750
 2025-11-01  |           3 |              3 |              4 |        218000 |        77000 |            72667
 2025-12-01  |           2 |              2 |              3 |        138500 |        49000 |            69250

WITH NO DATAオプション

WITH NO DATAを指定すると、マテリアライズドビューの定義のみを作成し、データは後からリフレッシュで投入できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 定義のみ作成(データなし)
CREATE MATERIALIZED VIEW product_ranking AS
    SELECT 
        p.product_id,
        p.name,
        COALESCE(SUM(oi.quantity), 0) AS total_sold,
        RANK() OVER (ORDER BY COALESCE(SUM(oi.quantity), 0) DESC) AS sales_rank
    FROM products p
    LEFT JOIN order_items oi ON p.product_id = oi.product_id
    LEFT JOIN orders o ON oi.order_id = o.order_id AND o.status = 'completed'
    GROUP BY p.product_id, p.name
WITH NO DATA;

-- この時点ではクエリ不可
SELECT * FROM product_ranking;
-- ERROR: materialized view "product_ranking" has not been populated

-- データを投入
REFRESH MATERIALIZED VIEW product_ranking;

-- これで参照可能
SELECT * FROM product_ranking;

マテリアライズドビューへのインデックス作成

マテリアライズドビューはテーブルと同様にインデックスを作成できます。これにより、参照時のパフォーマンスをさらに向上させることが可能です。

1
2
3
4
5
-- 月別サマリへのインデックス
CREATE INDEX idx_monthly_sales_month ON monthly_sales_summary(sales_month);

-- 商品ランキングへのユニークインデックス(CONCURRENTLY用に必要)
CREATE UNIQUE INDEX idx_product_ranking_id ON product_ranking(product_id);

リフレッシュ戦略

REFRESH MATERIALIZED VIEWの基本

マテリアライズドビューのデータは自動更新されません。データを最新化するにはREFRESH MATERIALIZED VIEWを実行します。

1
2
-- 基本的なリフレッシュ
REFRESH MATERIALIZED VIEW monthly_sales_summary;

リフレッシュ中は、デフォルトでマテリアライズドビューに対するSELECTがブロックされます。

CONCURRENTLYオプション

CONCURRENTLYオプションを使用すると、リフレッシュ中もSELECTが可能になります。ただし、使用には条件があります。

1
2
-- 並行リフレッシュ(SELECTをブロックしない)
REFRESH MATERIALIZED VIEW CONCURRENTLY product_ranking;

CONCURRENTLYを使用するための条件は以下の通りです。

条件 説明
ユニークインデックス必須 すべての行を一意に識別できるインデックスが必要
データが存在 WITH NO DATAで作成した直後は使用不可
単一リフレッシュ 同時に複数のCONCURRENTLYは実行不可

CONCURRENTLYの動作を図で表すと以下のようになります。

sequenceDiagram
    participant App as アプリケーション
    participant MV as マテリアライズドビュー
    participant New as 新データ(一時)
    
    Note over MV: 通常リフレッシュ
    App->>MV: SELECT(ブロック)
    MV-->>App: 待機...
    
    Note over MV,New: CONCURRENTLYリフレッシュ
    App->>MV: SELECT
    MV-->>App: 古いデータを返す
    New->>New: 新データ計算
    New->>MV: 差分マージ
    App->>MV: SELECT
    MV-->>App: 新しいデータを返す

自動リフレッシュの実装パターン

PostgreSQLにはマテリアライズドビューの自動リフレッシュ機能が組み込まれていません。以下のパターンで実装できます。

pg_cronによる定期実行

pg_cron拡張を使用して、定期的にリフレッシュを実行できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- pg_cron拡張のインストール(スーパーユーザーで実行)
CREATE EXTENSION pg_cron;

-- 毎日午前2時にリフレッシュ
SELECT cron.schedule(
    'refresh_monthly_sales',
    '0 2 * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary'
);

-- 1時間ごとにリフレッシュ
SELECT cron.schedule(
    'refresh_product_ranking',
    '0 * * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY product_ranking'
);

トリガーによるイベント駆動リフレッシュ

元テーブルの更新時にリフレッシュを実行するトリガーを設定することも可能です。ただし、頻繁な更新がある場合はパフォーマンスに影響するため注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- リフレッシュを実行する関数
CREATE OR REPLACE FUNCTION refresh_sales_summary()
RETURNS TRIGGER AS $$
BEGIN
    -- 非同期でリフレッシュを実行(pg_background拡張が必要)
    -- または単純にリフレッシュを実行
    REFRESH MATERIALIZED VIEW monthly_sales_summary;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- 注文完了時にリフレッシュ(実際の運用では慎重に検討)
CREATE TRIGGER trg_refresh_on_order_complete
    AFTER UPDATE OF status ON orders
    FOR EACH STATEMENT
    WHEN (NEW.status = 'completed')
    EXECUTE FUNCTION refresh_sales_summary();

リフレッシュ管理テーブルによる制御

リフレッシュ履歴を管理し、一定時間経過後のみリフレッシュを実行するパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
-- リフレッシュ履歴テーブル
CREATE TABLE mv_refresh_log (
    view_name       VARCHAR(100) PRIMARY KEY,
    last_refresh    TIMESTAMP NOT NULL,
    refresh_count   INTEGER DEFAULT 0
);

-- 条件付きリフレッシュ関数
CREATE OR REPLACE FUNCTION refresh_if_stale(
    p_view_name VARCHAR,
    p_interval INTERVAL
) RETURNS BOOLEAN AS $$
DECLARE
    v_last_refresh TIMESTAMP;
BEGIN
    -- 最終リフレッシュ時刻を取得
    SELECT last_refresh INTO v_last_refresh
    FROM mv_refresh_log
    WHERE view_name = p_view_name;
    
    -- 初回または指定時間経過後ならリフレッシュ
    IF v_last_refresh IS NULL OR 
       v_last_refresh + p_interval < CURRENT_TIMESTAMP THEN
        
        EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I', p_view_name);
        
        INSERT INTO mv_refresh_log (view_name, last_refresh, refresh_count)
        VALUES (p_view_name, CURRENT_TIMESTAMP, 1)
        ON CONFLICT (view_name) DO UPDATE
        SET last_refresh = CURRENT_TIMESTAMP,
            refresh_count = mv_refresh_log.refresh_count + 1;
        
        RETURN TRUE;
    END IF;
    
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

-- 1時間以上経過していればリフレッシュ
SELECT refresh_if_stale('product_ranking', INTERVAL '1 hour');

パフォーマンスとトレードオフ

ビューのパフォーマンス特性

ビューは参照時に毎回クエリが実行されるため、元のクエリと同じパフォーマンス特性を持ちます。複雑なJOINや集計を含むビューは、参照のたびにその処理が実行されます。

EXPLAIN ANALYZEでビューのパフォーマンスを確認してみましょう。

1
EXPLAIN ANALYZE SELECT * FROM product_sales_summary;

実行計画を見ると、ビューの定義クエリがそのまま展開されて実行されていることがわかります。

ビューのパフォーマンスを改善するためのポイントは以下の通りです。

  • 元テーブルに適切なインデックスを作成する
  • 不要なカラムの取得を避ける
  • WHERE句で絞り込みを行う

マテリアライズドビューのパフォーマンス特性

マテリアライズドビューは事前計算された結果を返すため、参照時は非常に高速です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- 通常のビュー(毎回計算)
CREATE VIEW monthly_sales_view AS
    SELECT 
        DATE_TRUNC('month', o.order_date)::DATE AS sales_month,
        COUNT(DISTINCT o.order_id) AS order_count,
        SUM(oi.quantity * oi.unit_price) AS total_revenue
    FROM orders o
    JOIN order_items oi ON o.order_id = oi.order_id
    WHERE o.status = 'completed'
    GROUP BY DATE_TRUNC('month', o.order_date);

-- パフォーマンス比較
EXPLAIN ANALYZE SELECT * FROM monthly_sales_view;
EXPLAIN ANALYZE SELECT * FROM monthly_sales_summary;

マテリアライズドビューは単純なテーブルスキャンで済むため、複雑な集計クエリよりも大幅に高速です。

ストレージとリフレッシュコストのトレードオフ

マテリアライズドビューを使用する際は、以下のトレードオフを考慮する必要があります。

要素 ビュー マテリアライズドビュー
参照速度 遅い(クエリ実行) 速い(保存データ返却)
データ鮮度 常に最新 リフレッシュ依存
ストレージ 不要 結果セット分必要
リフレッシュコスト なし 完全再計算が必要
インデックス 不可 可能
graph TD
    A[データ更新頻度は?] -->|高い| B[データの鮮度は重要?]
    A -->|低い| C[マテリアライズドビュー推奨]
    
    B -->|リアルタイム必須| D[ビュー推奨]
    B -->|多少の遅延OK| E[参照頻度は?]
    
    E -->|高い| F[マテリアライズドビュー + 頻繁なリフレッシュ]
    E -->|低い| D
    
    C --> G[CONCURRENTLY + 定期リフレッシュ]

使い分けの指針

ビューとマテリアライズドビューの選択指針をまとめます。

ビューを選択するケース

  • データの鮮度が最優先
  • 元テーブルが頻繁に更新される
  • クエリが十分に高速(インデックスが効いている)
  • 更新可能ビューが必要

マテリアライズドビューを選択するケース

  • 集計やJOINが複雑で実行に時間がかかる
  • 同じクエリが頻繁に実行される
  • データの鮮度に多少の遅延が許容される
  • ダッシュボードやレポート用途

併用パターン

実際のシステムでは、両者を組み合わせて使用することが効果的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- リアルタイムデータ用のビュー
CREATE VIEW realtime_orders AS
    SELECT * FROM orders WHERE order_date = CURRENT_DATE;

-- 履歴分析用のマテリアライズドビュー
CREATE MATERIALIZED VIEW historical_analysis AS
    SELECT 
        DATE_TRUNC('month', order_date)::DATE AS month,
        COUNT(*) AS order_count,
        SUM(total_amount) AS revenue
    FROM orders
    WHERE order_date < CURRENT_DATE
    GROUP BY DATE_TRUNC('month', order_date);

実践的な活用例

ダッシュボード向けサマリビュー

経営ダッシュボード向けに、複数の指標を1つのマテリアライズドビューにまとめます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- ダッシュボード用KPIサマリ
CREATE MATERIALIZED VIEW dashboard_kpi AS
    WITH order_stats AS (
        SELECT 
            COUNT(*) AS total_orders,
            COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
            COUNT(*) FILTER (WHERE status = 'pending') AS pending_orders,
            SUM(total_amount) FILTER (WHERE status = 'completed') AS total_revenue
        FROM orders
        WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
    ),
    customer_stats AS (
        SELECT 
            COUNT(*) AS total_customers,
            COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - INTERVAL '30 days') 
                AS new_customers
        FROM customers
    ),
    product_stats AS (
        SELECT 
            COUNT(*) FILTER (WHERE is_active) AS active_products,
            COUNT(*) FILTER (WHERE NOT is_active) AS inactive_products
        FROM products
    )
    SELECT 
        os.total_orders,
        os.completed_orders,
        os.pending_orders,
        os.total_revenue,
        cs.total_customers,
        cs.new_customers,
        ps.active_products,
        ps.inactive_products,
        CURRENT_TIMESTAMP AS refreshed_at
    FROM order_stats os, customer_stats cs, product_stats ps;

CREATE UNIQUE INDEX idx_dashboard_kpi ON dashboard_kpi(refreshed_at);

階層データの展開ビュー

再帰CTEを使用して、カテゴリの階層構造を展開するビューを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- カテゴリ階層ビュー
CREATE VIEW category_hierarchy AS
    WITH RECURSIVE category_tree AS (
        -- ルートカテゴリ
        SELECT 
            category_id,
            name,
            parent_id,
            name AS full_path,
            1 AS level,
            ARRAY[category_id] AS path_ids
        FROM categories
        WHERE parent_id IS NULL
        
        UNION ALL
        
        -- 子カテゴリ
        SELECT 
            c.category_id,
            c.name,
            c.parent_id,
            ct.full_path || ' > ' || c.name,
            ct.level + 1,
            ct.path_ids || c.category_id
        FROM categories c
        JOIN category_tree ct ON c.parent_id = ct.category_id
    )
    SELECT 
        category_id,
        name,
        parent_id,
        full_path,
        level,
        path_ids
    FROM category_tree
    ORDER BY path_ids;

このビューを使用することで、フラットなカテゴリテーブルから階層構造を簡単に取得できます。

1
SELECT * FROM category_hierarchy;

実行結果は以下の通りです。

 category_id |        name        | parent_id |          full_path           | level |  path_ids  
-------------+--------------------+-----------+------------------------------+-------+------------
           1 | 電子機器           |           | 電子機器                     |     1 | {1}
           4 | PC周辺機器         |         1 | 電子機器 > PC周辺機器        |     2 | {1,4}
           5 | スマートフォン関連 |         1 | 電子機器 > スマートフォン関連|     2 | {1,5}
           2 | 家具               |           | 家具                         |     1 | {2}
           6 | オフィス家具       |         2 | 家具 > オフィス家具          |     2 | {2,6}
           3 | 文具               |           | 文具                         |     1 | {3}

セキュリティ目的のビュー

行レベルセキュリティと組み合わせて、ユーザーごとに異なるデータを表示するビューを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- 地域別顧客データビュー(営業担当用)
CREATE VIEW regional_customers AS
    SELECT 
        c.customer_id,
        c.name,
        c.email,
        c.prefecture,
        COUNT(o.order_id) AS order_count,
        COALESCE(SUM(o.total_amount), 0) AS total_spent
    FROM customers c
    LEFT JOIN orders o ON c.customer_id = o.customer_id 
        AND o.status = 'completed'
    GROUP BY c.customer_id, c.name, c.email, c.prefecture;

-- security_invokerを有効にして、参照元の権限で実行
CREATE VIEW secure_customer_view 
    WITH (security_invoker = true) AS
    SELECT * FROM customers;

まとめ

本記事では、PostgreSQLのビューとマテリアライズドビューについて、基本概念から実践的な活用方法まで解説しました。

ビューとマテリアライズドビューのポイントを整理すると以下の通りです。

機能 ビュー マテリアライズドビュー
主な用途 クエリの抽象化、セキュリティ パフォーマンス最適化、キャッシュ
データ保存 なし あり
更新操作 条件付きで可能 不可(リフレッシュのみ)
適用場面 リアルタイムデータ参照 集計・分析クエリの高速化

ビューを使いこなすことで、複雑なSQLを再利用可能な形で管理でき、アプリケーションコードの保守性が向上します。マテリアライズドビューを活用することで、重たい集計クエリのパフォーマンスを大幅に改善できます。

実際のシステム設計では、データの鮮度要件、参照頻度、更新頻度を考慮して、適切な方を選択してください。多くの場合、両者を組み合わせることで最適なソリューションを実現できます。

参考リンク