- SQL
SQLビューとマテリアライズドビュー|違い・REFRESH・使い分け
ビューとは「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期連続の黒字決算。