ビューとは「SELECT文に名前を付けて保存したもの」で、参照するたびに元のテーブルから計算します。マテリアライズドビューは「SELECTの結果そのものを保存したもの」で、速く読める代わりに REFRESH するまで中身が更新されません。 この「毎回計算するか、結果を保存するか」の違いが、速度・鮮度・更新可否のすべてを決めます。

この記事では、PostgreSQL 16 と MySQL 8.0 で実際に動かし、50万件の注文の集計で通常ビューとマテリアライズドビューの速度差を EXPLAIN ANALYZE で実測しました。REFRESH ... CONCURRENTLY のエラー、更新可能なビューと WITH CHECK OPTION、ビューの列を変えるときのエラー、そして マテリアライズドビューがない MySQL で、集計テーブル+イベントで代わりを作るコードまで、実行結果つきで紹介します。

MySQL 8.0 は2026年4月にサポートが終了しています。MySQLの例は MySQL 8.4 LTS/9.7 でも同じ構文で使えます(ビュー、WITH CHECK OPTION、イベントスケジューラの仕様は共通です)。検証は手元の 8.0 で行いました。

ビュー・マテリアライズドビュー・集計テーブルの早見表

比較項目 ビュー(VIEW) マテリアライズドビュー 集計テーブル+定期更新
保存しているもの SELECT文(定義)だけ SELECTの結果 結果(普通のテーブル)
読む速さ 元のSELECTと同じ 速い(保存済みを読む) 速い
データの鮮度 常に最新 REFRESH した時点 更新処理を動かした時点
ディスク使用量 ほぼなし 結果の分だけ使う 結果の分だけ使う
インデックス 作れない(元テーブルのものを使う) 作れる 作れる
INSERT/UPDATE 単純なビューなら可 不可 可(更新処理で書き換える)
差分だけ更新 ― 不可(毎回全件を再計算) 可(直近N日だけなど)
PostgreSQL CREATE VIEW CREATE MATERIALIZED VIEW 自作
MySQL(8.0/8.4/9.7) CREATE VIEW なし 自作(イベントで定期実行)
Oracle CREATE VIEW CREATE MATERIALIZED VIEW(高速リフレッシュあり) 自作
SQL Server CREATE VIEW インデックス付きビューで近いことができる 自作
主な用途 複雑なSQLの再利用、列を隠す 重い集計・ダッシュボード MySQLでの集計、差分更新したい集計

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLビューとマテリアライズドビュー|違い・REFRESH・使い分け」

検証環境とサンプルデータ

  • PostgreSQL 16.15、MySQL 8.0.46(いずれも Ubuntu 24.04 パッケージ)
  • 注文テーブル orders に50万件(会員1万人・商品200種・2026年1〜9月)
-- PostgreSQL 16
CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  member_id   int NOT NULL,
  item_id     int NOT NULL,
  qty         int NOT NULL,
  price       int NOT NULL,
  ordered_at  timestamp NOT NULL
);
INSERT INTO orders (member_id, item_id, qty, price, ordered_at)
SELECT (random()*9999)::int + 1, (random()*199)::int + 1, (random()*4)::int + 1,
       ((random()*90)::int + 10) * 10,
       timestamp '2026-01-01' + (random() * 270) * interval '1 day'
FROM generate_series(1, 500000);
ANALYZE orders;

ビューとマテリアライズドビューを作る(PostgreSQL)

同じ「日別・商品別の売上」を、ビューとマテリアライズドビューの両方で作ります。

CREATE VIEW v_daily_sales AS
SELECT ordered_at::date AS day, item_id, count(*) AS orders, sum(qty*price) AS amount
FROM orders GROUP BY 1, 2;

CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT ordered_at::date AS day, item_id, count(*) AS orders, sum(qty*price) AS amount
FROM orders GROUP BY 1, 2;
CREATE VIEW
SELECT 53986

CREATE MATERIALIZED VIEW は作成時にSELECTを実行し、結果の53,986行を保存しました。 \dm+ で見ると、ディスクを 3072 kB 使っています。ビューのほうは定義だけなので、サイズはほぼありません。

 Schema |      Name      |       Type        |  Owner   | Persistence | Access method |  Size
--------+----------------+-------------------+----------+-------------+---------------+---------
 public | mv_daily_sales | materialized view | postgres | permanent   | heap          | 3072 kB

速度差をEXPLAIN ANALYZEで実測する

「9月1日〜27日の売上上位5商品」を、ビューとマテリアライズドビューから取ります。

EXPLAIN (ANALYZE, BUFFERS OFF)
SELECT item_id, sum(amount) AS amount FROM v_daily_sales   -- mv_daily_sales に変えて比較
WHERE day BETWEEN '2026-09-01' AND '2026-09-27'
GROUP BY item_id ORDER BY amount DESC LIMIT 5;

ビューの実行計画(抜粋)です。50万件の orders を毎回スキャンして集計しています。

 Limit  (actual time=53.851..55.385 rows=5 loops=1)
   ->  Sort
         ->  HashAggregate  (actual time=53.770..55.330 rows=200 loops=1)
               ->  Finalize GroupAggregate  (actual time=40.946..54.480 rows=5399 loops=1)
                     ->  Gather Merge
                           Workers Launched: 2
                           ->  Partial GroupAggregate
                                 ->  Sort
                                       ->  Parallel Seq Scan on orders  (actual time=0.014..24.556 rows=16568 loops=3)
                                             Filter: (((ordered_at)::date >= '2026-09-01'::date) AND ((ordered_at)::date <= '2026-09-27'::date))
                                             Rows Removed by Filter: 150098
 Planning Time: 0.441 ms
 Execution Time: 55.567 ms

マテリアライズドビューでは、保存済みの5万4千行を読むだけです。

 Limit  (actual time=4.008..4.011 rows=5 loops=1)
   ->  Sort
         ->  HashAggregate  (actual time=3.919..3.971 rows=200 loops=1)
               ->  Seq Scan on mv_daily_sales  (actual time=0.009..3.042 rows=5399 loops=1)
                     Filter: ((day >= '2026-09-01'::date) AND (day <= '2026-09-27'::date))
                     Rows Removed by Filter: 48587
 Execution Time: 4.039 ms

さらに、マテリアライズドビューにはインデックスを作れます。 (day, item_id) にユニークインデックスを作ると、Bitmap Index Scan に変わりました。

CREATE UNIQUE INDEX mv_daily_sales_uk ON mv_daily_sales (day, item_id);
ANALYZE mv_daily_sales;
               ->  Bitmap Heap Scan on mv_daily_sales  (actual time=0.454..1.427 rows=5399 loops=1)
                     ->  Bitmap Index Scan on mv_daily_sales_uk  (actual time=0.410..0.410 rows=5399 loops=1)
                           Index Cond: ((day >= '2026-09-01'::date) AND (day <= '2026-09-27'::date))
 Execution Time: 2.401 ms
読み取り元 Execution Time 通常ビュー比
通常ビュー v_daily_sales 55.567 ms 1倍
マテリアライズドビュー(インデックスなし) 4.039 ms 約14倍速い
マテリアライズドビュー+ユニークインデックス 2.401 ms 約23倍速い

結果の上位5件(54番の商品が509,900円で1位)はどれも同じでした。 集計が重いほど、また読む回数が多いほど、マテリアライズドビューの効果は大きくなります。実行計画の詳しい読み方は「EXPLAIN ANALYZE」の記事で扱っています。

REFRESH MATERIALIZED VIEW:鮮度と更新方法

REFRESHするまで古いまま

注文を1件追加して、ビューとマテリアライズドビューを比べます。

INSERT INTO orders (member_id, item_id, qty, price, ordered_at)
VALUES (1, 54, 5, 1000, '2026-09-27 10:00');

SELECT 'view' AS src, orders, amount FROM v_daily_sales WHERE day='2026-09-27' AND item_id=54
UNION ALL
SELECT 'mview', orders, amount FROM mv_daily_sales WHERE day='2026-09-27' AND item_id=54;
  src  | orders | amount
-------+--------+--------
 view  |     17 |  35190
 mview |     16 |  30190

ビューは追加した1件(5,000円)を反映していますが、マテリアライズドビューは作成時点のままです。 REFRESH で作り直すと一致します。

REFRESH MATERIALIZED VIEW mv_daily_sales;
REFRESH MATERIALIZED VIEW
Time: 177.510 ms
         src          | orders | amount
----------------------+--------+--------
 mview(after refresh) |     17 |  35190

REFRESH中は読み取りも待たされる

通常の REFRESH は、マテリアライズドビューに最も強いロック(ACCESS EXCLUSIVE)をかけます。 別セッションで REFRESH を含むトランザクションを3秒続け、その間に lock_timeout = '1s' で読み取ってみました。

# セッション1:REFRESH を含むトランザクションを3秒続ける
psql -d t108 -c "BEGIN; REFRESH MATERIALIZED VIEW mv_daily_sales; SELECT pg_sleep(3); COMMIT;" &
sleep 1.5
# セッション2:読み取り
psql -d t108 -c "SET lock_timeout = '1s';" -c "SELECT count(*) FROM mv_daily_sales;"
ERROR:  canceling statement due to lock timeout
LINE 1: SELECT count(*) FROM mv_daily_sales;

同じことを REFRESH ... CONCURRENTLY で行うと、読み取りは待たされずに結果が返りました(count = 53986)。

CONCURRENTLY にはユニークインデックスが必要

ユニークインデックスがない状態で CONCURRENTLY を使うとエラーになります。

=# REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;
ERROR:  cannot refresh materialized view "public.mv_daily_sales" concurrently
HINT:  Create a unique index with no WHERE clause on one or more columns of the materialized view.

HINT のとおり、WHERE句なしのユニークインデックスを作ると実行できます。 CONCURRENTLY は新しい結果を別に作り、旧結果との差分だけを書き換える方式なので、行を一意に特定するキーが必要です。

CREATE UNIQUE INDEX mv_daily_sales_uk ON mv_daily_sales (day, item_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;
REFRESH MATERIALIZED VIEW
Time: 916.678 ms       ← CONCURRENTLY
REFRESH MATERIALIZED VIEW
Time: 238.655 ms       ← 通常(インデックスの作り直しを含む)
方式 時間(実測) 実行中の読み取り 条件
REFRESH MATERIALIZED VIEW 177〜239 ms 待たされる なし
REFRESH MATERIALIZED VIEW CONCURRENTLY 917 ms 読める ユニークインデックス必須、作成済み(populated)であること

CONCURRENTLY は約4倍時間がかかりましたが、読み取りを止めません。 画面から常に参照されるなら CONCURRENTLY、夜間バッチなど誰も読まない時間なら通常の REFRESH が向いています。

WITH NO DATA と自動更新

WITH NO DATA を付けると、定義だけ作って中身は空のままになります。この状態で読むとエラーです。

=# CREATE MATERIALIZED VIEW mv_empty AS
     SELECT item_id, count(*) FROM orders GROUP BY item_id WITH NO DATA;
=# SELECT * FROM mv_empty LIMIT 1;
ERROR:  materialized view "mv_empty" has not been populated
HINT:  Use the REFRESH MATERIALIZED VIEW command.

作成済みかどうかは pg_matviews.ispopulated で確認できます。

SELECT matviewname, ispopulated FROM pg_matviews ORDER BY 1;
  matviewname   | ispopulated
----------------+-------------
 mv_daily_sales | t
 mv_empty       | f

PostgreSQL のマテリアライズドビューは、元テーブルが変わっても自動では更新されません。 定期的に更新するには、OSの cron から psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;" を実行するか、拡張機能 pg_cron を使います(この記事では cron 方式を想定し、pg_cron は未実行です)。

更新可能なビューと WITH CHECK OPTION

PostgreSQL:単純なビューはそのまま更新できる

1つのテーブルから列を選び、WHEREで絞っただけのビューは、INSERT・UPDATE・DELETE ができます(自動更新可能ビュー)。

CREATE TABLE members (id int PRIMARY KEY, name text NOT NULL, email text NOT NULL,
                      status text NOT NULL DEFAULT 'active', password_hash text);
INSERT INTO members VALUES (1,'佐藤','sato@example.com','active','$2b$12$aaa'),
                           (2,'鈴木','suzuki@example.com','inactive','$2b$12$bbb');

CREATE VIEW v_active_members AS
  SELECT id, name, email, status FROM members WHERE status = 'active';

UPDATE v_active_members SET name = '佐藤花子' WHERE id = 1;
INSERT INTO v_active_members (id, name, email, status)
  VALUES (3, '田中', 'tanaka@example.com', 'inactive');
UPDATE 1
INSERT 0 1
=# SELECT * FROM v_active_members;
 id |   name   |      email       | status
----+----------+------------------+--------
  1 | 佐藤花子 | sato@example.com | active

=# SELECT id, name, status FROM members ORDER BY id;
 id |   name   |  status
----+----------+----------
  1 | 佐藤花子 | active
  2 | 鈴木     | inactive
  3 | 田中     | inactive

status='inactive' の行もビュー経由で入ってしまい、入れた直後からビューでは見えません。 これを防ぐのが WITH CHECK OPTION です。

CREATE VIEW v_active_members_chk AS
  SELECT id, name, email, status FROM members WHERE status = 'active'
  WITH CHECK OPTION;

INSERT INTO v_active_members_chk (id, name, email, status)
  VALUES (4, '高橋', 'takahashi@example.com', 'inactive');
UPDATE v_active_members_chk SET status = 'inactive' WHERE id = 1;
ERROR:  new row violates check option for view "v_active_members_chk"
DETAIL:  Failing row contains (4, 高橋, takahashi@example.com, inactive, null).
ERROR:  new row violates check option for view "v_active_members_chk"
DETAIL:  Failing row contains (1, 佐藤花子, sato@example.com, inactive, $2b$12$aaa).

ビューの条件から外れる INSERT も、条件から外す UPDATE も止まりました。 集計を含むビューは更新できません。

=# UPDATE v_daily_sales SET orders = 0 WHERE item_id = 1;
ERROR:  cannot update view "v_daily_sales"
DETAIL:  Views containing GROUP BY are not automatically updatable.
HINT:  To enable updating the view, provide an INSTEAD OF UPDATE trigger or an unconditional ON UPDATE DO INSTEAD rule.

更新可能なビューの条件

条件 PostgreSQL MySQL
FROM がテーブル(または更新可能ビュー)1つ 必要 JOINも一部可(更新は片方のテーブルのみ)
GROUP BY・HAVING・集計関数・DISTINCT 不可 不可
UNION・LIMIT/OFFSET 不可 不可
計算した列 その列だけ更新不可 その列だけ更新不可
条件を満たさないとき INSTEAD OF トリガーで更新可能にできる 更新不可
確認方法 information_schema.views.is_updatable information_schema.VIEWS.IS_UPDATABLE

MySQL:WITH CHECK OPTION と LOCAL/CASCADED

MySQLでも同じ書き方で動きます。エラーは ERROR 1369 です。

CREATE VIEW v_active_members_chk AS
  SELECT id, name, email, status FROM members WHERE status = 'active'
  WITH CHECK OPTION;
UPDATE v_active_members_chk SET status = 'inactive' WHERE id = 1;
ERROR 1369 (HY000): CHECK OPTION failed 'viewdemo.v_active_members_chk'

集計ビューへの UPDATE は ERROR 1288 (HY000): The target table v_cnt of the UPDATE is not updatable でした。information_schema.VIEWS で確認すると、CHECK OPTION だけを書いた場合は CASCADED になっていました。

TABLE_NAME            IS_UPDATABLE  CHECK_OPTION
v_active_members_chk  YES           CASCADED
v_cnt                 NO            NONE

ビューを重ねたときの違いも確認しました。v_base(status='active')の上に id < 100 のビューを作り、status='inactive' の行を入れます。

CREATE VIEW v_base  AS SELECT * FROM members WHERE status = 'active';
CREATE VIEW v_local AS SELECT * FROM v_base WHERE id < 100 WITH LOCAL CHECK OPTION;
CREATE VIEW v_casc  AS SELECT * FROM v_base WHERE id < 100 WITH CASCADED CHECK OPTION;
INSERT INTO v_local (id,name,email,status) VALUES (10,'L','l@example.com','inactive');  -- 成功
INSERT INTO v_casc  (id,name,email,status) VALUES (11,'C','c@example.com','inactive');  -- 失敗
ERROR 1369 (HY000): CHECK OPTION failed 'viewdemo.v_casc'
指定 チェックする条件 結果
WITH LOCAL CHECK OPTION 自分のビューの条件(id < 100)だけ 入った
WITH CASCADED CHECK OPTION(既定) 元のビューの条件(status='active')も 止まった

迷ったら既定の CASCADED にしておくと、重ねたビュー全体の条件が守られます。

ビューの列を変えるときのエラーと CREATE OR REPLACE の制約

PostgreSQL の CREATE OR REPLACE VIEW は「末尾に列を足す」ことしかできません。 列を減らす・並べ替える・名前を変えるとエラーになります。

=# CREATE OR REPLACE VIEW v_active_members AS
     SELECT id, name, status FROM members WHERE status = 'active';
ERROR:  cannot drop columns from view

=# CREATE OR REPLACE VIEW v_active_members AS
     SELECT id, email, name, status FROM members WHERE status = 'active';
ERROR:  cannot change name of view column "name" to "email"
HINT:  Use ALTER VIEW ... RENAME COLUMN ... to change name of view column instead.

=# CREATE OR REPLACE VIEW v_active_members AS
     SELECT id, name, email, status, password_hash IS NOT NULL AS has_password
     FROM members WHERE status = 'active';
CREATE VIEW

ビューに依存するものがあると、削除や元テーブルの型変更もできません。

=# CREATE VIEW v_member_names AS SELECT id, name FROM v_active_members;
=# DROP VIEW v_active_members;
ERROR:  cannot drop view v_active_members because other objects depend on it
DETAIL:  view v_member_names depends on view v_active_members
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

=# ALTER TABLE members ALTER COLUMN name TYPE varchar(50);
ERROR:  cannot alter type of a column used by a view or rule
DETAIL:  rule _RETURN on view v_active_members_chk depends on column "name"
やりたいこと PostgreSQL MySQL
末尾に列を追加 CREATE OR REPLACE VIEW CREATE OR REPLACE VIEW
列を削除・並べ替え DROP VIEW してから作り直す(依存ビューも) CREATE OR REPLACE VIEW でできる
列名だけ変える ALTER VIEW v RENAME COLUMN a TO b CREATE OR REPLACE VIEW
元テーブルの列の型を変える ビューを削除 → 変更 → 作り直し そのまま変更できる(ビューは参照時に解決)
マテリアライズドビューの定義変更 DROP MATERIALIZED VIEW → 作り直し(OR REPLACE はない) ―

MySQL では、列を減らす CREATE OR REPLACE VIEW がそのまま通りました。

mysql> CREATE OR REPLACE VIEW v_active_members_chk AS
    ->   SELECT id, name FROM members WHERE status='active' WITH CHECK OPTION;
mysql> SHOW CREATE VIEW v_active_members_chk\G
Create View: CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW `v_active_members_chk` AS select `members`.`id` AS `id`,`members`.`name` AS `name` from `members` where (`members`.`status` = 'active') WITH CASCADED CHECK OPTION

PostgreSQL では、ビューの定義を変える作業は「依存関係を確認 → まとめて DROP → 作り直し」を1つのトランザクションで行うと安全です(PostgreSQL は DDL もトランザクションで取り消せます)。

MySQLでマテリアライズドビューの代わりを作る(集計テーブル+イベント)

MySQL(8.0/8.4/9.7 の Community 版)にはマテリアライズドビューがありません。 同じ効果は「集計結果を入れるテーブル」と「定期的に作り直す処理(イベント)」の組み合わせで作れます。

flowchart LR
    A[orders 50万件] -->|EVENT 5分ごと<br>直近2日を再集計| B[daily_sales 集計テーブル]
    A -->|夜間に全件作り直し<br>RENAME TABLEで入れ替え| B
    B --> C[画面・レポートは集計テーブルを読む]

集計テーブルと更新プロシージャ

-- MySQL 8.0(8.4/9.7 も同じ)
CREATE TABLE daily_sales (
  day          DATE   NOT NULL,
  item_id      INT    NOT NULL,
  orders       INT    NOT NULL,
  amount       BIGINT NOT NULL,
  refreshed_at DATETIME NOT NULL,
  PRIMARY KEY (day, item_id)
);

DELIMITER //
-- 直近 p_days 日だけ作り直す(差分更新)
CREATE PROCEDURE refresh_daily_sales(IN p_days INT)
BEGIN
  DECLARE v_from DATE DEFAULT CURRENT_DATE - INTERVAL p_days DAY;
  START TRANSACTION;
    DELETE FROM daily_sales WHERE day >= v_from;
    INSERT INTO daily_sales (day, item_id, orders, amount, refreshed_at)
    SELECT DATE(ordered_at), item_id, COUNT(*), SUM(qty * price), NOW()
    FROM orders
    WHERE ordered_at >= v_from
    GROUP BY DATE(ordered_at), item_id;
  COMMIT;
END//

-- 全件を別テーブルで作り直してから入れ替える(REFRESH 相当)
CREATE PROCEDURE rebuild_daily_sales()
BEGIN
  DROP TABLE IF EXISTS daily_sales_new, daily_sales_old;
  CREATE TABLE daily_sales_new LIKE daily_sales;
  INSERT INTO daily_sales_new (day, item_id, orders, amount, refreshed_at)
  SELECT DATE(ordered_at), item_id, COUNT(*), SUM(qty * price), NOW()
  FROM orders GROUP BY DATE(ordered_at), item_id;
  RENAME TABLE daily_sales TO daily_sales_old, daily_sales_new TO daily_sales;
  DROP TABLE daily_sales_old;
END//
DELIMITER ;

RENAME TABLE は複数のテーブル名を一度に入れ替えるので、読み手から見ると「古い結果」から「新しい結果」へ一瞬で切り替わります。 作り直しの間も古い集計を読めるため、PostgreSQL の CONCURRENTLY に近い使い方ができます。

mysql> CALL rebuild_daily_sales();      -- 実測 2.28 秒(50万件)
mysql> SELECT COUNT(*), MAX(refreshed_at) FROM daily_sales;
COUNT(*)  MAX(refreshed_at)
53993     2026-10-02 00:34:40

元テーブルからの集計と、集計テーブルからの読み取りを SHOW PROFILES で比べました(3回実行)。上位5件の結果は同じです。

読み取り元 1回目 2回目 3回目
orders を毎回集計 0.280 秒 0.121 秒 0.114 秒
daily_sales 集計テーブル 0.0046 秒 0.0060 秒 0.0057 秒

集計テーブルからの読み取りは約20倍速くなりました。

イベントスケジューラで定期実行する

SHOW VARIABLES LIKE 'event_scheduler';   -- 8.0 以降は既定で ON

CREATE EVENT ev_refresh_daily_sales
  ON SCHEDULE EVERY 10 SECOND            -- 検証用。運用では 5 MINUTE など
  DO CALL refresh_daily_sales(2);

INSERT INTO orders (member_id, item_id, qty, price, ordered_at) VALUES (1, 179, 3, 1000, NOW());
event_scheduler  ON
NOW()                today_rows
2026-10-02 00:34:51  0

12秒待ってから確認すると、イベントが動いて今日の行が集計テーブルに入っていました。

mysql> SELECT day, item_id, orders, amount, refreshed_at FROM daily_sales WHERE day = CURRENT_DATE;
day         item_id  orders  amount  refreshed_at
2026-10-02  179      1       3000    2026-10-02 00:35:01

mysql> SELECT EVENT_NAME, STATUS, INTERVAL_VALUE, INTERVAL_FIELD, LAST_EXECUTED
    ->   FROM information_schema.EVENTS WHERE EVENT_SCHEMA='viewdemo';
EVENT_NAME              STATUS   INTERVAL_VALUE  INTERVAL_FIELD  LAST_EXECUTED
ev_refresh_daily_sales  ENABLED  10              SECOND          2026-10-02 00:35:01

運用向けに間隔を変えるのは ALTER EVENT です。

ALTER EVENT ev_refresh_daily_sales ON SCHEDULE EVERY 5 MINUTE;
-- 夜間の全件作り直し
CREATE EVENT ev_rebuild_daily_sales
  ON SCHEDULE EVERY 1 DAY STARTS '2026-10-02 03:00:00'
  DO CALL rebuild_daily_sales();
設計のポイント 内容
差分と全件を組み合わせる 頻繁な差分更新(直近N日)+夜間の全件作り直しで、遅れて届いた注文も拾う
主キーを集計キーにする (day, item_id) で重複を防ぎ、範囲検索も速くする
更新時刻を持たせる refreshed_at で「いつ時点の数字か」を画面に出せる
イベントの確認 information_schema.EVENTS.LAST_EXECUTED、エラーはエラーログに出る
レプリカ イベントはソースで動き、レプリカには無効の状態で複製される(集計テーブルの変更はレプリケーションで届く)

ビューで列を隠し、権限を絞る

ビューは「見せたい列・行だけを公開する窓」として使えます。 パスワードハッシュを持つ members テーブルに対し、ビューだけに SELECT 権限を付けました(PostgreSQL 16)。

CREATE ROLE t108_reader LOGIN;
GRANT SELECT ON v_active_members TO t108_reader;
SET ROLE t108_reader;
SELECT * FROM v_active_members;
SELECT * FROM members;
 id |   name   |      email       | status | has_password
----+----------+------------------+--------+--------------
  1 | 佐藤花子 | sato@example.com | active | t

ERROR:  permission denied for table members

ビュー経由では読めて、元テーブルは読めません。 パスワードハッシュは「設定済みかどうか(has_password)」だけに変換して公開しています。

方法 隠せるもの 向いている場面
ビュー+ビューへの GRANT 列・行・値の加工(マスクなど) 条件付きで行も絞りたい、加工して見せたい
列単位の GRANT(GRANT SELECT (id, name) ON members) 列だけ 単純に列を隠したい(SELECT * はエラーになる)
行レベルセキュリティ(RLS) 行(ユーザーごと) 利用者ごとに見える行を変えたい

PostgreSQL のビューは既定で「ビューの所有者の権限」で元テーブルを読みます。利用者自身の権限で読ませたい(RLSを効かせたい)ときは、PostgreSQL 15以降の WITH (security_invoker = true) を指定します。 MySQL では SQL SECURITY DEFINER(既定)と SQL SECURITY INVOKER で同じ切り替えができます。ロールと権限の設計は「PostgreSQLのロールと権限」の記事で詳しく扱っています。

使い分けの判断フロー

flowchart TD
    A[SELECTを再利用したい] --> B{常に最新の値が必要?}
    B -->|はい| C{元のSELECTは十分速い?}
    C -->|はい| V[通常のビュー]
    C -->|いいえ| I[インデックス・SQLの見直し<br>それでも遅ければ集計テーブル]
    B -->|数分〜1日の遅れは許容| D{DBは?}
    D -->|PostgreSQL| E{更新中も読ませたい?}
    E -->|はい| MC[マテビュー+ユニークインデックス<br>REFRESH CONCURRENTLY]
    E -->|いいえ| M[マテビュー+REFRESH]
    D -->|MySQL| T[集計テーブル+EVENT]
    D -->|差分だけ更新したい| T
状況 おすすめ
複雑なJOINを毎回書きたくない、結果は常に最新で ビュー
一部の列・行だけを見せたい ビュー+GRANT
ダッシュボードの重い集計、数分遅れてよい(PostgreSQL) マテリアライズドビュー+定期REFRESH
画面から常に参照される集計(PostgreSQL) マテリアライズドビュー+CONCURRENTLY
MySQLで重い集計を速く読みたい 集計テーブル+EVENT
全件の再計算が重い、直近だけ更新したい 集計テーブル(差分更新)

よくある質問

Q. ビューを使うと遅くなりますか? A. 通常のビューは、参照時に元のSELECTへ展開されて実行されるため、同じSQLを直接書いた場合とほぼ同じ速さです。多くの場合、ビューの外側の WHERE も中へ押し込まれて最適化されます(EXPLAIN で確認できます)。速くするには、マテリアライズドビューか集計テーブルを使います。

Q. マテリアライズドビューは元テーブルの更新で自動的に変わりますか? A. PostgreSQL では変わりません。REFRESH MATERIALIZED VIEW を cron や pg_cron で定期実行します。Oracle には差分で更新する高速リフレッシュがありますが、PostgreSQL の REFRESH は毎回全件を再計算します。

Q. MySQL 8.4 や 9.7 でマテリアライズドビューは使えますか? A. MySQL Community Server にはありません。この記事の「集計テーブル+イベント」の方法が 8.0/8.4/9.7 で共通して使えます。

Q. マテリアライズドビューの定義を変えるには? A. CREATE OR REPLACE MATERIALIZED VIEW はないため、DROP MATERIALIZED VIEW してから作り直し、インデックスも作り直します。依存するビューがあれば DROP ... CASCADE の対象を確認してから行います。

まとめ

  • ビューはSELECTを保存、マテリアライズドビューは結果を保存。速度・鮮度・更新可否はこの違いから決まる
  • 50万件の集計で、ビュー55.6 ms → マテビュー4.0 ms → インデックス付き2.4 ms
  • 通常のREFRESHは読み取りも待たせ、CONCURRENTLYは読める代わりにユニークインデックス必須で時間は約4倍
  • 単純なビューは更新でき、WITH CHECK OPTION で条件外の書き込みを止められる(既定は CASCADED)
  • PostgreSQL の CREATE OR REPLACE VIEW は末尾への列追加のみ、減らすなら作り直し
  • MySQLは集計テーブル+EVENT+RENAME TABLE の入れ替えでマテビューの代わりを作れる
  • ビュー+GRANT で列を隠し、見せたいものだけを公開できる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLビューとマテリアライズドビュー|違い・REFRESH・使い分け」

株式会社RJCでは、ビューやマテリアライズドビューの使い分けを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?