PostgreSQL: 総合プロジェクト: ECデータベースシステムをゼロから構築

最終更新:2026-08-26

1. 学習目標


2. ストーリー

BobとAliceは中東向けのクロスボーダーECプラットフォームを立ち上げようとしています。要件は複雑です。アラビア語の全文検索、JSONBによる動的商品属性、月次パーティショニングされた注文、テナント分離のための行レベルセキュリティ、AI商品レコメンデーション。彼らはPostgreSQL 1つですべてを解決します。tsvectorでアラビア語検索、jsonbで柔軟なSKU、宣言的パーティショニ��グで数億件の注文、pgvectorで意味的レコメンデーション、postgres_fdwでレガシーPGからのデータ移行。1つのPGデータベースがMySQL + Elasticsearch + Redis + レコメンデーションエンジンを置き換えます。


3. 概念: 要件分析とアーキテクチャ設計

(1) ビジネスモジュールの分解

モジュール コアテーブル 主要機能
ユーザーシス▶ム users, user_addresses RLS行レベルセキュリティ、bcryptパスワード
カテゴリ・商品 categories, products JSONB動的属性、全文検索
注文・注文明細 orders, order_items 月次RANGEパーティショニング
ショッピングカート cart_items 高同時実行UPSERT
支払い payments 列挙型、監査証跡
配送追跡 shipments, shipment_events 時系列データ、JSONBイベント
レビュー・評価 reviews 星集計、GINインデックス
データレポート mv_daily_sales など マテリアライズドビュー、スケジュールリフレッシュ

(2) 完全なER図

100%
erDiagram
    USERS ||--o{ USER_ADDRESSES : "持つ"
    USERS ||--o{ ORDERS : "発注する"
    USERS ||--o{ CART_ITEMS : "持つ"
    USERS ||--o{ REVIEWS : "書く"
    CATEGORIES ||--o{ CATEGORIES : "親"
    CATEGORIES ||--o{ PRODUCTS : "含む"
    PRODUCTS ||--o{ ORDER_ITEMS : "含まれる"
    PRODUCTS ||--o{ CART_ITEMS : "追加される"
    PRODUCTS ||--o{ REVIEWS : "レビューされる"
    ORDERS ||--o{ ORDER_ITEMS : "含む"
    ORDERS ||--o{ PAYMENTS : "支払われる"
    ORDERS ||--o{ SHIPMENTS : "配送される"
    SHIPMENTS ||--o{ SHIPMENT_EVENTS : "追跡される"

    USERS {
        bigint id PK
        text email UK
        text password_hash
        text role
        timestamptz created_at
    }
    USER_ADDRESSES {
        bigint id PK
        bigint user_id FK
        text address_line
        text city
        text country
    }
    CATEGORIES {
        int id PK
        text name
        int parent_id FK
        int sort_order
    }
    PRODUCTS {
        bigint id PK
        text name
        text name_ar
        int category_id FK
        numeric price
        jsonb attributes
        tsvector search_vector
        vector embedding
    }
    ORDERS {
        bigint id PK
        bigint user_id FK
        date order_date
        numeric total_amount
        text status
    }
    ORDER_ITEMS {
        bigint id PK
        bigint order_id FK
        bigint product_id FK
        int quantity
        numeric unit_price
    }
    CART_ITEMS {
        bigint id PK
        bigint user_id FK
        bigint product_id FK
        int quantity
    }
    PAYMENTS {
        bigint id PK
        bigint order_id FK
        text method
        numeric amount
        text status
        timestamptz paid_at
    }
    SHIPMENTS {
        bigint id PK
        bigint order_id FK
        text carrier
        text tracking_code
        text status
    }
    SHIPMENT_EVENTS {
        bigint id PK
        bigint shipment_id FK
        text event_type
        jsonb metadata
        timestamptz event_time
    }
    REVIEWS {
        bigint id PK
        bigint user_id FK
        bigint product_id FK
        int rating
        text comment
    }

(3) PostgreSQL vs MySQL選定レポート

観点 PostgreSQL MySQL
JSONB動的属性 ネイティブjsonb + GINインデックス + 演算子 JSON型だがインデックスが弱い
全文検索 ビルトインtsvector/tsquery、多言語 ネイティブサポートなし、Elasticsearchが必要
ベクトル検索 pgvectorネイティブ拡張 外部サービスが必要
パーティショニング 宣言的RANGE/LIST/HASH 8.0+でサポートされるが弱い
行レベルセキュリティ RLSポリシー 非サポート
マテリアライズドビュー ネイティブサポート+スケジュールリフレッシュ 非サポート
拡張エコシステム 豊富(PostGIS/pgcrypto/FDW) プラグインが少ない
複雑なクエリ ウィンドウ関数/CTE/LATERAL 8.0+で徐々にサポート
運用成熟度 高い、autovacuum/PITR 高い、成熟したマスタースレーブレプリケーション
コミュニティ活発度 最も急成長中 最大のユーザーベース

結論: このプロジェクトにはJSONB動的属性、全文検索、ベクトル検索、RLS、パーティショニング、マテリアライズドビューが必要です。PostgreSQLはこれらすべてをネイティブにサポートしますが、MySQLでは4つ以上の外部ミドルウェアが必要になるため、PostgreSQLを選択します。


4. 操作: モジュール1 — ユーザーシステム

(1) ユーザーと住所のテーブル

SQL
CREATE TABLE users (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    role          TEXT NOT NULL DEFAULT 'customer'
                    CHECK (role IN ('customer','vendor','admin')),
    tenant_id     BIGINT DEFAULT 1,
    created_at    TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE user_addresses (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id      BIGINT NOT NULL REFERENCES users(id),
    address_line TEXT NOT NULL,
    city         TEXT NOT NULL,
    country      TEXT NOT NULL,
    is_default   BOOLEAN DEFAULT false
);

▶ サンプル: テナント分離のためのRLS行レベルセキュリティ

SQL
-- usersテーブルでRLSを有効化
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- テナント分離: ユーザーは同じテナントのみ表示可能
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.tenant_id')::bigint);

-- 管理者は全件表示可能
CREATE POLICY admin_all_access ON users
    USING (role = 'admin');

-- セッションごとにテナントコンテキストを設定
SET app.tenant_id = '1';
SELECT * FROM users;

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル: パスワードハッシュ登録

SQL
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- bcryptハッシュで登録
INSERT INTO users (email, password_hash, role, tenant_id)
VALUES (
    'alice@example.com',
    crypt('SecurePass123', gen_salt('bf')),
    'customer',
    1
);

-- ログイン検証
SELECT id, role FROM users
WHERE email = 'alice@example.com'
  AND password_hash = crypt('SecurePass123', password_hash);

Output:

TEXT 📖 参照専用
INSERT 0 1

5. 操作: モジュール2 — カテゴリと商品

▶ サンプル: 自己参照カテゴリツリー

SQL
CREATE TABLE categories (
    id          SERIAL PRIMARY KEY,
    name        TEXT NOT NULL,
    name_ar     TEXT,
    parent_id   INT REFERENCES categories(id),
    sort_order  INT DEFAULT 0
);

INSERT INTO categories (name, name_ar, parent_id, sort_order) VALUES
    ('Electronics', 'إلكترونيات', NULL, 1),
    ('Phones', 'هواتف', 1, 1),
    ('Laptops', 'حاسبات', 1, 2),
    ('Clothing', 'ملابس', NULL, 2);

-- 再帰クエリ: カテゴリツリー
WITH RECURSIVE cat_tree AS (
    SELECT id, name, name_ar, parent_id, 0 AS level
    FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.name, c.name_ar, c.parent_id, ct.level + 1
    FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id
)
SELECT repeat('  ', level) || name AS tree, name_ar
FROM cat_tree ORDER BY level, sort_order;

Output:

TEXT 📖 参照専用
INSERT 0 1

▶ サンプル: JSONB動的属性付き商品テーブル

SQL
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE products (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name          TEXT NOT NULL,
    name_ar       TEXT,
    category_id   INT NOT NULL REFERENCES categories(id),
    price         NUMERIC(12,2) NOT NULL,
    attributes    JSONB DEFAULT '{}',
    search_vector TSVECTOR GENERATED ALWAYS AS (
        setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
        setweight(to_tsvector('simple', coalesce(name_ar, '')), 'B')
    ) STORED,
    embedding     vector(1536)
);

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル: JSONB属性クエリ

SQL
-- 動的属性付きで挿入
INSERT INTO products (name, name_ar, category_id, price, attributes) VALUES
    ('iPhone 15 Pro', 'آيفون 15 برو', 2, 1199.00,
     '{"color": "titanium", "storage": "256GB", "5g": true}'::jsonb),
    ('MacBook Air M3', 'ماك بوك إير', 3, 1299.00,
     '{"color": "midnight", "ram": "16GB", "screen": "15 inch"}'::jsonb);

-- 5G対応で1200 USD未満のスマートフォンを検索
SELECT name, price, attributes->>'storage' AS storage
FROM products
WHERE attributes @> '{"5g": true}'::jsonb
  AND price < 1200;

-- JSONB包含クエリ用GINインデックス
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);

Output:

TEXT 📖 参照専用
INSERT 0 1

▶ サンプル: 全文検索(アラビア語対応)

SQL
-- 全文検索用GINインデックス
CREATE INDEX idx_products_search ON products USING GIN (search_vector);

-- 英語またはアラビア語で検索
SELECT name, name_ar, ts_rank(search_vector, q) AS rank
FROM products, plainto_tsquery('simple', 'iphone') q
WHERE search_vector @@ q
ORDER BY rank DESC;

-- アラビア語検索
SELECT name, name_ar
FROM products, plainto_tsquery('simple', 'آيفون') q
WHERE search_vector @@ q;

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル: pgvector類似商品レコメンデーション

SQL
-- ベクトル検索用HNSWインデックス
CREATE INDEX idx_products_embedding ON products
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- 類似商品を検索
SELECT p2.name, p2.price,
       p2.embedding <=> p1.embedding AS distance
FROM products p1
CROSS JOIN LATERAL (
    SELECT * FROM products
    WHERE id != p1.id
    ORDER BY embedding <=> p1.embedding
    LIMIT 3
) p2
WHERE p1.name = 'iPhone 15 Pro';

Output:

TEXT 📖 参照専用
CREATE TABLE

6. 操作: モジュール3 — 注文と注文明細

▶ サンプル: 月次RANGEパーティション注文テーブル

SQL
CREATE TABLE orders (
    id           BIGINT GENERATED ALWAYS AS IDENTITY,
    user_id      BIGINT NOT NULL REFERENCES users(id),
    order_date   DATE NOT NULL DEFAULT current_date,
    total_amount NUMERIC(12,2) DEFAULT 0,
    status       TEXT DEFAULT 'pending'
                  CHECK (status IN ('pending','paid','shipped','completed','cancelled')),
    created_at   TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (id, order_date)
) PARTITION BY RANGE (order_date);

CREATE TABLE orders_2024_01 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE orders_2024_03 PARTITION OF orders
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

CREATE TABLE order_items (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    BIGINT NOT NULL,
    order_date  DATE NOT NULL,
    product_id  BIGINT NOT NULL REFERENCES products(id),
    quantity    INT NOT NULL CHECK (quantity > 0),
    unit_price  NUMERIC(12,2) NOT NULL,
    FOREIGN KEY (order_id, order_date) REFERENCES orders(id, order_date)
);

CREATE INDEX idx_orders_user ON orders (user_id, order_date);
CREATE INDEX idx_order_items_order ON order_items (order_id, order_date);

Output:

TEXT 📖 参照専用
CREATE TABLE

▶ サンプル: 注文作成と金額計算

SQL
-- 注文作成
WITH new_order AS (
    INSERT INTO orders (user_id, order_date, status)
    VALUES (1, '2024-01-15', 'pending')
    RETURNING id, order_date
)
INSERT INTO order_items (order_id, order_date, product_id, quantity, unit_price)
SELECT new_order.id, new_order.order_date, p.id, 2, p.price
FROM new_order, products p
WHERE p.name = 'iPhone 15 Pro';

-- 注文合計金額を更新
UPDATE orders o SET total_amount = (
    SELECT SUM(quantity * unit_price)
    FROM order_items oi
    WHERE oi.order_id = o.id AND oi.order_date = o.order_date
)
WHERE o.id = 1 AND o.order_date = '2024-01-15';

Output:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

7. 操作: モジュール4 — ショッピングカート

▶ サンプル: UPSERTショッピングカート

SQL
CREATE TABLE cart_items (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id),
    product_id BIGINT NOT NULL REFERENCES products(id),
    quantity   INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
    added_at   TIMESTAMPTZ DEFAULT now(),
    UNIQUE (user_id, product_id)
);

-- カート商品の追加または更新(UPSERT)
INSERT INTO cart_items (user_id, product_id, quantity)
VALUES (1, 1, 1)
ON CONFLICT (user_id, product_id)
DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity;

-- 商品詳細付きでカートを表示
SELECT p.name, p.price, ci.quantity,
       p.price * ci.quantity AS line_total
FROM cart_items ci
JOIN products p ON ci.product_id = p.id
WHERE ci.user_id = 1;

Output:

TEXT 📖 参照専用
INSERT 0 1

8. 操作: モジュール5 — 支払い

▶ サンプル: 支払いテーブルと列挙型

SQL
CREATE TYPE payment_method AS ENUM ('credit_card', 'paypal', 'bank_transfer', 'cod');
CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded');

CREATE TABLE payments (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id   BIGINT NOT NULL,
    order_date DATE NOT NULL,
    method     payment_method NOT NULL,
    amount     NUMERIC(12,2) NOT NULL,
    status     payment_status DEFAULT 'pending',
    paid_at    TIMESTAMPTZ,
    FOREIGN KEY (order_id, order_date) REFERENCES orders(id, order_date)
);

-- 成功した支払いを記録
INSERT INTO payments (order_id, order_date, method, amount, status, paid_at)
VALUES (1, '2024-01-15', 'credit_card', 2398.00, 'completed', now());

-- 支払い後に注文ステータスを更新
UPDATE orders SET status = 'paid'
WHERE id = 1 AND order_date = '2024-01-15';

Output:

TEXT 📖 参照専用
INSERT 0 1

9. 操作: モジュール6 — 配送追跡

▶ サンプル: 配送テーブルとJSONBイベント

SQL
CREATE TABLE shipments (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id      BIGINT NOT NULL,
    order_date    DATE NOT NULL,
    carrier       TEXT NOT NULL,
    tracking_code TEXT NOT NULL UNIQUE,
    status        TEXT DEFAULT 'created'
                   CHECK (status IN ('created','in_transit','delivered','failed')),
    FOREIGN KEY (order_id, order_date) REFERENCES orders(id, order_date)
);

CREATE TABLE shipment_events (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    shipment_id  BIGINT NOT NULL REFERENCES shipments(id),
    event_type   TEXT NOT NULL,
    metadata     JSONB DEFAULT '{}',
    event_time   TIMESTAMPTZ DEFAULT now()
);

-- イベント付きで配送を追跡
INSERT INTO shipments (order_id, order_date, carrier, tracking_code)
VALUES (1, '2024-01-15', 'DHL Express', 'DHL123456789');

INSERT INTO shipment_events (shipment_id, event_type, metadata) VALUES
    (1, 'picked_up',     '{"location": "ドバイ倉庫"}'::jsonb),
    (1, 'in_transit',    '{"location": "バーレーンハブ", "eta": "2024-01-18"}'::jsonb),
    (1, 'out_for_delivery', '{"location": "リヤド"}'::jsonb);

-- 配送タイムライン
SELECT se.event_time, se.event_type, se.metadata->>'location' AS location
FROM shipment_events se
WHERE se.shipment_id = 1
ORDER BY se.event_time;

Output:

TEXT 📖 参照専用
INSERT 0 1

10. 操作: モジュール7 — レビューと評価

▶ サンプル: レビューテーブルと星集計

SQL
CREATE TABLE reviews (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id),
    product_id BIGINT NOT NULL REFERENCES products(id),
    rating     INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
    comment    TEXT,
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (user_id, product_id)
);

INSERT INTO reviews (user_id, product_id, rating, comment) VALUES
    (1, 1, 5, '素晴らしいスマートフォン、配送も早い'),
    (2, 1, 4, '良いが高価'),
    (3, 2, 5, '最高のノートパソコン');

-- ウィンドウ関数で商品評価サマリー
SELECT p.name,
       COUNT(r.id) AS review_count,
       AVG(r.rating)::numeric(3,2) AS avg_rating,
       COUNT(r.id) FILTER (WHERE r.rating = 5) AS five_star,
       COUNT(r.id) FILTER (WHERE r.rating = 4) AS four_star
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
GROUP BY p.id, p.name;

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

11. 操作: モジュール8 — データレポート

▶ サンプル: 日次売上マテリアライズドビュー

SQL
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT order_date,
       COUNT(DISTINCT user_id) AS unique_buyers,
       COUNT(*) AS order_count,
       SUM(total_amount) AS daily_revenue,
       AVG(total_amount)::numeric(12,2) AS avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY order_date
ORDER BY order_date;

CREATE UNIQUE INDEX idx_mv_daily_sales_date ON mv_daily_sales (order_date);

-- 日次リフレッシュ(pg_cronでスケジュール可能)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

-- 収益上位日
SELECT order_date, daily_revenue, order_count
FROM mv_daily_sales
ORDER BY daily_revenue DESC
LIMIT 10;

Output:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

▶ サンプル: カテゴリ別売上マテリアライズドビュー

SQL
CREATE MATERIALIZED VIEW mv_category_sales AS
SELECT c.name AS category,
       p.name AS product_name,
       SUM(oi.quantity) AS total_sold,
       SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
JOIN orders o ON oi.order_id = o.id AND oi.order_date = o.order_date
WHERE o.status = 'completed'
GROUP BY c.name, p.name
ORDER BY revenue DESC;

REFRESH MATERIALIZED VIEW mv_category_sales;

Output:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

12. 操作: ストアドプロシージャと自動化

▶ サンプル: 注文作成ストアドプロシージャ

SQL
CREATE OR REPLACE PROCEDURE create_order(
    p_user_id    BIGINT,
    p_items      JSONB
)
LANGUAGE plpgsql AS 
DECLARE
    v_order_id   BIGINT;
    v_order_date DATE := current_date;
    v_item       JSONB;
BEGIN
    INSERT INTO orders (user_id, order_date, status)
    VALUES (p_user_id, v_order_date, 'pending')
    RETURNING id INTO v_order_id;

    FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
    LOOP
        INSERT INTO order_items (order_id, order_date, product_id, quantity, unit_price)
        VALUES (v_order_id, v_order_date,
                (v_item->>'product_id')::bigint,
                (v_item->>'quantity')::int,
                (SELECT price FROM products WHERE id = (v_item->>'product_id')::bigint));
    END LOOP;

    UPDATE orders SET total_amount = (
        SELECT SUM(quantity * unit_price) FROM order_items
        WHERE order_id = v_order_id AND order_date = v_order_date
    ) WHERE id = v_order_id AND order_date = v_order_date;

    COMMIT;
END;
;

-- プロシージャ呼び出し
CALL create_order(1, '[
    {"product_id": 1, "quantity": 1},
    {"product_id": 2, "quantity": 2}
]'::jsonb);

Output:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

▶ サンプル: 自動パーティションメンテナンス用ストアドプロシージャ

SQL
CREATE OR REPLACE FUNCTION maintain_order_partitions()
RETURNS VOID AS 
DECLARE
    v_next_month DATE;
    v_part_name  TEXT;
BEGIN
    v_next_month := date_trunc('month', current_date + interval '1 month')::date;
    v_part_name  := 'orders_' || to_char(v_next_month, 'YYYY_MM');

    IF NOT EXISTS (
        SELECT 1 FROM pg_class WHERE relname = v_part_name
    ) THEN
        EXECUTE format(
            'CREATE TABLE %I PARTITION OF orders
             FOR VALUES FROM (%L) TO (%L)',
            v_part_name,
            v_next_month,
            (v_next_month + interval '1 month')::date
        );
    END IF;

    -- 2年以上前のパーティションをデタッチ
    FOR v_part_name IN
        SELECT relname FROM pg_class
        WHERE relname LIKE 'orders_20__%'
          AND relkind = 'r'
          AND relname < 'orders_' || to_char(current_date - interval '2 years', 'YYYY_MM')
    LOOP
        EXECUTE format('ALTER TABLE orders DETACH PARTITION %I', v_part_name);
    END LOOP;
END;
 LANGUAGE plpgsql;

Output:

TEXT 📖 参照専用
CREATE TABLE

13. 操作: FDWデータ移行とPITRバックアップ

▶ サンプル: postgres_fdwでレガシーPGから移行

SQL
CREATE EXTENSION IF NOT EXISTS postgres_fdw;

CREATE SERVER legacy_pg FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host '10.0.1.50', port '5432', dbname 'legacy_shop');

CREATE USER MAPPING FOR current_user SERVER legacy_pg
    OPTIONS (user 'migrate_user', password 'secure_pass');

IMPORT FOREIGN SCHEMA public LIMIT TO (old_users, old_products)
    FROM SERVER legacy_pg INTO legacy;

-- ユーザーをパスワード再ハッシュ化して移行
INSERT INTO users (email, password_hash, role, tenant_id, created_at)
SELECT email, crypt(raw_password, gen_salt('bf')), 'customer', 1, created_at
FROM legacy.old_users
ON CONFLICT (email) DO NOTHING;

Output:

TEXT 📖 参照専用
INSERT 0 1

▶ サンプル: PITRバックアップ戦略

BASH
# ベースバックアップ
pg_basebackup -D /backup/base -Ft -z -P

# WALアーカイブ(postgresql.conf)
wal_level = replica
archive_mode = on
archive_command = 'cp %p /backup/wal/%f'

# 特定時点への復元
pg_restore --target-time='2024-03-15 14:30:00' -d shop_db /backup/base

Output:

TEXT 📖 参照専用
# command executed successfully
戦略 頻度 保持期間 復旧時間
pg_basebackupフル 毎日 7日間 30〜60分
WALアーカイブ 継続的 7日間 任意の時点
論理バックアップpg_dump 毎週 4週間 1〜4時間

14. 総合サンプル

SQL
-- 完全なECデータベース初期化スクリプト
-- モジュール1: ユーザーシステム
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    role TEXT NOT NULL DEFAULT 'customer'
         CHECK (role IN ('customer','vendor','admin')),
    tenant_id BIGINT DEFAULT 1,
    created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.tenant_id')::bigint);

CREATE TABLE user_addresses (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    address_line TEXT NOT NULL, city TEXT NOT NULL, country TEXT NOT NULL,
    is_default BOOLEAN DEFAULT false
);

-- モジュール2: カテゴリ・商品
CREATE TABLE categories (
    id SERIAL PRIMARY KEY, name TEXT NOT NULL,
    name_ar TEXT, parent_id INT REFERENCES categories(id),
    sort_order INT DEFAULT 0
);
CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL, name_ar TEXT,
    category_id INT NOT NULL REFERENCES categories(id),
    price NUMERIC(12,2) NOT NULL,
    attributes JSONB DEFAULT '{}',
    search_vector TSVECTOR GENERATED ALWAYS AS (
        setweight(to_tsvector('simple', coalesce(name,'')), 'A') ||
        setweight(to_tsvector('simple', coalesce(name_ar,'')), 'B')
    ) STORED,
    embedding vector(1536)
);
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
CREATE INDEX idx_products_search ON products USING GIN (search_vector);
CREATE INDEX idx_products_embedding ON products
    USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64);

-- モジュール3: 注文(月次パーティション)
CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    order_date DATE NOT NULL DEFAULT current_date,
    total_amount NUMERIC(12,2) DEFAULT 0,
    status TEXT DEFAULT 'pending'
           CHECK (status IN ('pending','paid','shipped','completed','cancelled')),
    created_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (id, order_date)
) PARTITION BY RANGE (order_date);
CREATE TABLE orders_2024_q1 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

CREATE TABLE order_items (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id BIGINT NOT NULL, order_date DATE NOT NULL,
    product_id BIGINT NOT NULL REFERENCES products(id),
    quantity INT NOT NULL CHECK (quantity > 0),
    unit_price NUMERIC(12,2) NOT NULL,
    FOREIGN KEY (order_id, order_date) REFERENCES orders(id, order_date)
);

-- モジュール4: カート
CREATE TABLE cart_items (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    product_id BIGINT NOT NULL REFERENCES products(id),
    quantity INT NOT NULL DEFAULT 1, added_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (user_id, product_id)
);

-- モジュール5: 支払い
CREATE TYPE payment_method AS ENUM ('credit_card','paypal','bank_transfer','cod');
CREATE TYPE payment_status AS ENUM ('pending','completed','failed','refunded');
CREATE TABLE payments (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id BIGINT NOT NULL, order_date DATE NOT NULL,
    method payment_method NOT NULL, amount NUMERIC(12,2) NOT NULL,
    status payment_status DEFAULT 'pending', paid_at TIMESTAMPTZ,
    FOREIGN KEY (order_id, order_date) REFERENCES orders(id, order_date)
);

-- モジュール6: 配送
CREATE TABLE shipments (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id BIGINT NOT NULL, order_date DATE NOT NULL,
    carrier TEXT NOT NULL, tracking_code TEXT NOT NULL UNIQUE,
    status TEXT DEFAULT 'created'
           CHECK (status IN ('created','in_transit','delivered','failed')),
    FOREIGN KEY (order_id, order_date) REFERENCES orders(id, order_date)
);
CREATE TABLE shipment_events (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    shipment_id BIGINT NOT NULL REFERENCES shipments(id),
    event_type TEXT NOT NULL, metadata JSONB DEFAULT '{}',
    event_time TIMESTAMPTZ DEFAULT now()
);

-- モジュール7: レビュー
CREATE TABLE reviews (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    product_id BIGINT NOT NULL REFERENCES products(id),
    rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
    comment TEXT, created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (user_id, product_id)
);

-- モジュール8: レポート用マテリアライズドビュー
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT order_date, COUNT(DISTINCT user_id) AS unique_buyers,
       COUNT(*) AS order_count, SUM(total_amount) AS daily_revenue
FROM orders WHERE status = 'completed'
GROUP BY order_date;
CREATE UNIQUE INDEX idx_mv_daily ON mv_daily_sales (order_date);

❓ よくある質問

Q 総合プロジェクトでテーブル構築はどのモジュールから始めるべきですか?
A 外部キー依存のないベーステーブル(users, categories)から始め、それらを参照するテーブル(products, orders)を構築し、最後にordersに依存するテーブル(payments, shipments)を構築します。順序が間違うと外部キー制約でエラーになります。
Q パーティションテーブルで外部キー参照をどう処理すればよいですか?
A パーティションテーブルの外部キーにはパーティションキーを含める必要があります。order_itemsがordersを参照する場合、order_idだけでなく複合外部キー(order_id, order_date)を使用してください。
Q RLSと通常のWHERE句の違いは何ですか?
A RLSはデータベースレベルの強制ポリシーであり、アプリケーション層が絞り込みを忘れても効果を発揮します。通常のWHERE句はアプリケーションコードに依存し、抜け漏れが発生しやすくなります。マルチテナントシナリオではRLSが必須です。
Q JSONB属性が多すぎるとクエリパフォーマンスに影響しますか?
A JSONB自体は行サイズ制限に影響しませんが、属性が増えると行が大きくなりI/Oが増加します。頻繁にクエリされる属性にGINインデックスを構築するか、式インデックスでホットフィールドを抽出してください。
Q マテリアライズドビューはどのくらいの頻度でリフレッシュすべきですか?
A データの鮮度要件によって異なります。日次レポートは1日1回のリフレッシュで十分です。リアルタイムダッシュボードはテーブルロックを避けるため5〜15分ごとにREFRESH CONCURRENTLYを使用できます。
Q pgvectorの埋め込みカラムにどのようにデータを投入しますか?
A PostgreSQL自体は埋め込みを生成しません。アプリケーション層がAIモデル(例: OpenAI埋め込みAPI)を呼び出してベクトルを取得し、それをPGのvectorカラムに書き込みます。トリガーやアプ��ケーションコードで自動同期できます。
Q このプロジェクトはElasticsearchを置き換えられますか?
A 中規模(数百万ドキュメント)であれば可能です。PG全文検索にpg_trgmを加えれば約80%のシナリオをカバーします。ただし超大規模(数億件)や集計分析が必要な場合は、Elasticsearchが依然として専用検索エンジンとして適しています。

📖 まとめ


📝 練習問題

  1. ⭐ 総合サンプルスクリプトに従って、ローカルのPGインスタンスに完全なECデータベースを作成し、10行のテストデータを挿入し、各モジュールの基本クエリ(ユーザーログイン、商品検索、注文作成、カートUPSERT)を検証してください。

  2. ⭐⭐ 総合プロジェクトを拡張: (1) クーポンモジュールを追加(couponsテーブル + JSONBルール + 検証用ストアドプロシージャ)。(2) 全文検索 + JSONB絞り込み + 価格範囲を組み合わせたsearch_products(keyword TEXT, min_price NUMERIC, max_price NUMERIC, category_id INT)関数を作成。(3) 月次自動パーティショニング用のpg_cronスケジュールジョブを作成。

  3. ⭐⭐⭐ 本番グレードの最適化レポートを作成: (1) pg_stat_statementsでTop 10スロークエリを収集し最適化計画を提案。(2) 全パーティションテーブルのautovacuum戦略を設計。(3) PITRバックアップを設定し特定時点復旧をテスト。(4) 1つのPGインスタンスから別のPGインスタンスへのデータ移行用の完全なpostgres_fdwスクリプトを作成。(5) EXPLAIN ANALYZEで全キークエリが最適なプランを取ることを検証。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%