0

#06 - Lược đồ cơ sở dữ liệu (PostgreSQL 18) Phần 2

Câu truy vấn đáng tiền nhất trong toàn hệ thống:

-- Hãng vận chuyển đang nợ mình bao nhiêu tiền COD?
SELECT code, balance FROM v_ledger_balances WHERE code LIKE 'ar.cod.%';

12. Fulfilment, Returns, Tax

CREATE TABLE shipments (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id       uuid          NOT NULL DEFAULT uuidv7(),
    order_id        bigint        NOT NULL REFERENCES orders(id),
    carrier         text          NOT NULL,
    tracking_number text,
    label_path      text,
    weight_gram     integer       NOT NULL DEFAULT 0,
    shipping_cost   numeric(19,4) NOT NULL DEFAULT 0,
    cod_amount      numeric(19,4) NOT NULL DEFAULT 0,       -- ⚑ F3
    status          text          NOT NULL DEFAULT 'draft',
    attempt_count   smallint      NOT NULL DEFAULT 0,       -- ⚑ F6
    handed_over_at  timestamptz,
    delivered_at    timestamptz,
    created_at      timestamptz   NOT NULL DEFAULT now(),
    updated_at      timestamptz   NOT NULL DEFAULT now(),

    CONSTRAINT shipments_public_uq   UNIQUE (public_id),
    CONSTRAINT shipments_tracking_uq UNIQUE (carrier, tracking_number),
    CONSTRAINT shipments_status_chk  CHECK (status IN (
        'draft','ready','handed_over','in_transit','delivered',
        'failed_delivery','returning','returned','cancelled')),
    CONSTRAINT shipments_cod_chk     CHECK (cod_amount >= 0)
);

CREATE INDEX shipments_order_idx  ON shipments (order_id);
CREATE INDEX shipments_active_idx ON shipments (carrier, status)
    WHERE status NOT IN ('delivered','returned','cancelled');

CREATE TABLE shipment_items (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    shipment_id   bigint  NOT NULL REFERENCES shipments(id) ON DELETE CASCADE,
    order_item_id bigint  NOT NULL REFERENCES order_items(id),
    quantity      integer NOT NULL,
    batch_allocations jsonb NOT NULL DEFAULT '[]'::jsonb,  -- [{batch_id, qty, unit_cost}]

    CONSTRAINT shipment_items_qty_chk CHECK (quantity > 0),
    CONSTRAINT shipment_items_uq      UNIQUE (shipment_id, order_item_id)
);

-- Log thô từ hãng vận chuyển: append-only, phân mảnh
CREATE TABLE carrier_events (
    id          bigint      GENERATED ALWAYS AS IDENTITY,
    shipment_id bigint      NOT NULL,
    carrier     text        NOT NULL,
    raw_code    text        NOT NULL,      -- mã gốc của hãng
    mapped_code text        NOT NULL,      -- đã qua ACL (doc 02 §12.3)
    description text,
    raw_payload jsonb       NOT NULL DEFAULT '{}'::jsonb,
    occurred_at timestamptz NOT NULL DEFAULT now(),

    PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE carrier_events_2026_08 PARTITION OF carrier_events
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE carrier_events_default PARTITION OF carrier_events DEFAULT;

CREATE INDEX carrier_events_shipment_idx ON carrier_events (shipment_id, occurred_at DESC);

-- ── Trả hàng ──────────────────────────────────────────────────────
CREATE TABLE return_requests (
    id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id      uuid          NOT NULL DEFAULT uuidv7(),
    order_id       bigint        NOT NULL REFERENCES orders(id),
    customer_id    bigint,
    status         text          NOT NULL DEFAULT 'requested',
    refund_amount  numeric(19,4) NOT NULL DEFAULT 0,
    restock_decision text,
    evidence       jsonb         NOT NULL DEFAULT '[]'::jsonb,
    approved_by    bigint,
    created_at     timestamptz   NOT NULL DEFAULT now(),
    updated_at     timestamptz   NOT NULL DEFAULT now(),

    CONSTRAINT returns_public_uq  UNIQUE (public_id),
    CONSTRAINT returns_status_chk CHECK (status IN (
        'requested','approved','rejected','shipping_back',
        'received','inspected','refunded','closed')),
    CONSTRAINT returns_restock_chk CHECK (restock_decision IS NULL
        OR restock_decision IN ('restock','scrap','quarantine'))
);

CREATE TABLE return_items (
    id                bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    return_request_id bigint  NOT NULL REFERENCES return_requests(id) ON DELETE CASCADE,
    order_item_id     bigint  NOT NULL REFERENCES order_items(id),
    quantity          integer NOT NULL,
    reason            text    NOT NULL,
    condition         text,

    CONSTRAINT return_items_qty_chk    CHECK (quantity > 0),
    CONSTRAINT return_items_reason_chk CHECK (reason IN (
        'defective','wrong_item','not_as_described','changed_mind',
        'expired','damaged_in_transit')),
    -- ⚑ R4: mỹ phẩm đã mở nắp KHÔNG BAO GIỜ nhập lại kho
    CONSTRAINT return_items_condition_chk CHECK (condition IS NULL
        OR condition IN ('resellable','damaged','opened','expired'))
);

-- ── Thuế & hoá đơn ────────────────────────────────────────────────
CREATE TABLE tax_rates (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code       text         NOT NULL,
    name       text         NOT NULL,
    rate       numeric(5,4) NOT NULL,               -- 0.1000 = 10%
    valid_from date         NOT NULL,               -- ⚑ T2 BẮT BUỘC
    valid_to   date,
    applies_to jsonb        NOT NULL DEFAULT '{"scope":"all"}'::jsonb,

    CONSTRAINT tax_rates_rate_chk  CHECK (rate >= 0 AND rate <= 1),
    CONSTRAINT tax_rates_range_chk CHECK (valid_to IS NULL OR valid_to > valid_from)
);

CREATE TABLE invoices (
    id                  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id           uuid          NOT NULL DEFAULT uuidv7(),
    order_id            bigint        NOT NULL REFERENCES orders(id),
    invoice_number      text,
    tax_authority_code  text,                       -- mã CQT
    type                text          NOT NULL DEFAULT 'original',
    replaces_invoice_id bigint        REFERENCES invoices(id),
    subtotal            numeric(19,4) NOT NULL,
    tax_total           numeric(19,4) NOT NULL,
    grand_total         numeric(19,4) NOT NULL,
    tax_rate_snapshot   numeric(5,4)  NOT NULL,     -- ⚑ T2
    status              text          NOT NULL DEFAULT 'draft',
    provider            text,
    provider_payload    jsonb         NOT NULL DEFAULT '{}'::jsonb,
    issued_at           timestamptz,
    created_at          timestamptz   NOT NULL DEFAULT now(),

    CONSTRAINT invoices_public_uq  UNIQUE (public_id),
    CONSTRAINT invoices_number_uq  UNIQUE (invoice_number),
    CONSTRAINT invoices_type_chk   CHECK (type IN ('original','adjustment','replacement')),
    CONSTRAINT invoices_status_chk CHECK (status IN
        ('draft','issued','signed','sent','cancelled','replaced'))
);

-- ⚑ T4: mỗi đơn tối đa 1 hoá đơn gốc còn hiệu lực
CREATE UNIQUE INDEX invoices_one_original_uq
    ON invoices (order_id)
    WHERE type = 'original' AND status NOT IN ('cancelled','replaced');

CREATE TABLE invoice_lines (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    invoice_id bigint        NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
    -- Snapshot ĐỘC LẬP với order_items (⚑ T1: hoá đơn bất biến)
    name       text          NOT NULL,
    sku        text          NOT NULL,
    unit       text          NOT NULL DEFAULT 'Cái',
    quantity   integer       NOT NULL,
    unit_price numeric(19,4) NOT NULL,
    tax_rate   numeric(5,4)  NOT NULL,
    tax_amount numeric(19,4) NOT NULL,
    line_total numeric(19,4) NOT NULL
);

13. Customer, Loyalty, Review

CREATE TABLE customers (
    id                bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id         uuid        NOT NULL DEFAULT uuidv7(),
    user_id           bigint,                       -- NULL = chưa có tài khoản (doc 02 §15)
    customer_group_id bigint      REFERENCES customer_groups(id),
    full_name         text        NOT NULL,
    email             text,
    phone             text,                         -- đã chuẩn hoá +84
    date_of_birth     date,
    tier              text        NOT NULL DEFAULT 'standard',
    lifetime_value    numeric(19,4) NOT NULL DEFAULT 0,
    cod_risk_score    smallint    NOT NULL DEFAULT 0,
    anonymized_at     timestamptz,                  -- ⚑ U2 (Nghị định 13/2023)
    created_at        timestamptz NOT NULL DEFAULT now(),
    updated_at        timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT customers_public_uq UNIQUE (public_id),
    CONSTRAINT customers_risk_chk  CHECK (cod_risk_score BETWEEN 0 AND 100)
);

-- ⚑ U1: duy nhất, nhưng bỏ qua hồ sơ đã ẩn danh hoá
CREATE UNIQUE INDEX customers_phone_uq ON customers (phone)
    WHERE phone IS NOT NULL AND anonymized_at IS NULL;
CREATE UNIQUE INDEX customers_email_uq ON customers (lower(email))
    WHERE email IS NOT NULL AND anonymized_at IS NULL;

CREATE TABLE customer_addresses (
    id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id    bigint  NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    label          text,
    recipient_name text    NOT NULL,
    phone          text    NOT NULL,
    line1          text    NOT NULL,
    ward_code      text    REFERENCES administrative_units(code),
    province_code  text    REFERENCES administrative_units(code),
    is_default     boolean NOT NULL DEFAULT false,
    created_at     timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX customer_default_address_uq
    ON customer_addresses (customer_id) WHERE is_default;

-- ⚑ U3: Nghị định 13/2023 — vết đồng ý xử lý dữ liệu cá nhân
CREATE TABLE consents (
    id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id    bigint      NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    purpose        text        NOT NULL,
    policy_version text        NOT NULL,
    granted        boolean     NOT NULL,
    ip_address     inet,
    user_agent     text,
    occurred_at    timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT consents_purpose_chk CHECK (purpose IN
        ('order_processing','marketing_email','marketing_sms','profiling','cookies'))
);

CREATE INDEX consents_customer_idx ON consents (customer_id, purpose, occurred_at DESC);

-- ── Loyalty: số dư = Σ giao dịch (⚑ L1) ───────────────────────────
CREATE TABLE loyalty_accounts (
    customer_id bigint      PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
    tier        text        NOT NULL DEFAULT 'standard',
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE loyalty_transactions (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint      NOT NULL REFERENCES loyalty_accounts(customer_id) ON DELETE CASCADE,
    amount      integer     NOT NULL,          -- + tích, − tiêu
    kind        text        NOT NULL,
    order_id    bigint      REFERENCES orders(id),
    expires_at  date,
    occurred_at timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT loyalty_txn_kind_chk CHECK (kind IN ('earned','redeemed','expired','adjusted')),
    CONSTRAINT loyalty_txn_nonzero_chk CHECK (amount <> 0)
);

CREATE INDEX loyalty_txn_customer_idx ON loyalty_transactions (customer_id, occurred_at DESC);

CREATE VIEW v_loyalty_balances AS
SELECT customer_id, COALESCE(SUM(amount), 0) AS points_balance
  FROM loyalty_transactions GROUP BY customer_id;

-- ── Đánh giá ──────────────────────────────────────────────────────
CREATE TABLE reviews (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id     uuid        NOT NULL DEFAULT uuidv7(),
    product_id    bigint      NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    variant_id    bigint      REFERENCES product_variants(id),
    customer_id   bigint      NOT NULL REFERENCES customers(id),
    order_item_id bigint      NOT NULL REFERENCES order_items(id),
    rating        smallint    NOT NULL,
    title         text,
    body          text,
    photos        jsonb       NOT NULL DEFAULT '[]'::jsonb,
    status        text        NOT NULL DEFAULT 'pending',
    created_at    timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT reviews_rating_chk CHECK (rating BETWEEN 1 AND 5),
    CONSTRAINT reviews_status_chk CHECK (status IN ('pending','published','rejected')),
    -- ⚑ V2: một dòng đơn chỉ được đánh giá một lần
    CONSTRAINT reviews_order_item_uq UNIQUE (order_item_id)
);

CREATE INDEX reviews_product_idx ON reviews (product_id, created_at DESC)
    WHERE status = 'published';

V1 (chỉ người đã mua mới được đánh giá) được cưỡng chế bằng order_item_id NOT NULL REFERENCES order_items(id) — không có đường nào tạo review mà không trỏ về một dòng đơn thật.


14. Hạ tầng: outbox, idempotency, audit

CREATE TABLE outbox_messages (
    id             bigint      GENERATED ALWAYS AS IDENTITY,
    event_id       uuid        NOT NULL DEFAULT uuidv7(),
    aggregate_type text        NOT NULL,
    aggregate_id   text        NOT NULL,
    event_type     text        NOT NULL,
    event_version  smallint    NOT NULL DEFAULT 1,     -- doc 02 §20.4
    payload        jsonb       NOT NULL,
    headers        jsonb       NOT NULL DEFAULT '{}'::jsonb,  -- traceparent cho OTel
    created_at     timestamptz NOT NULL DEFAULT now(),
    processed_at   timestamptz,
    attempts       smallint    NOT NULL DEFAULT 0,
    last_error     text,

    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE outbox_messages_2026_08 PARTITION OF outbox_messages
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE outbox_messages_default PARTITION OF outbox_messages DEFAULT;

-- Relay chỉ quét index bộ phận này — luôn nhỏ dù bảng có hàng trăm triệu dòng
CREATE INDEX outbox_pending_idx ON outbox_messages (created_at, id)
    WHERE processed_at IS NULL;
CREATE INDEX outbox_aggregate_idx ON outbox_messages (aggregate_type, aggregate_id);

CREATE TABLE idempotency_keys (
    key           text        PRIMARY KEY,
    scope         text        NOT NULL,
    request_hash  text        NOT NULL,
    response_code smallint,
    response_body jsonb,
    locked_at     timestamptz,
    completed_at  timestamptz,
    expires_at    timestamptz NOT NULL,
    created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idempotency_expiry_idx ON idempotency_keys (expires_at);

CREATE TABLE audit_logs (
    id           bigint      GENERATED ALWAYS AS IDENTITY,
    actor_type   text        NOT NULL,
    actor_id     bigint,
    action       text        NOT NULL,
    subject_type text        NOT NULL,
    subject_id   text        NOT NULL,
    changes      jsonb,
    ip_address   inet,
    user_agent   text,
    occurred_at  timestamptz NOT NULL DEFAULT now(),

    PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE audit_logs_2026_08 PARTITION OF audit_logs
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE audit_logs_default PARTITION OF audit_logs DEFAULT;

CREATE INDEX audit_subject_idx ON audit_logs (subject_type, subject_id, occurred_at DESC);
CREATE INDEX audit_actor_idx   ON audit_logs (actor_type, actor_id, occurred_at DESC);

14.1 Relay outbox

-- Nhiều worker chạy song song an toàn nhờ SKIP LOCKED
WITH batch AS (
    SELECT id, created_at
      FROM outbox_messages
     WHERE processed_at IS NULL
     ORDER BY id
     FOR UPDATE SKIP LOCKED
     LIMIT 100
)
UPDATE outbox_messages o
   SET processed_at = now()
  FROM batch b
 WHERE o.id = b.id AND o.created_at = b.created_at
RETURNING o.event_id, o.event_type, o.event_version, o.payload, o.headers;

⚠ Ở P7 khi đổi đích sang Kafka, chỉ câu lệnh này đổi. Tầng Domain không biết gì (doc 01 §16).


15. Chiến lược index — bảng tổng hợp

Bảng Index Loại Truy vấn đích
products products_active_idx B-tree bộ phận Danh sách sản phẩm đang bán
products products_attrs_gin GIN jsonb_path_ops Lọc facet attributes @> '{"skin_type":"oily"}'
product_translations product_tr_fts_idx GIN tsvector Tìm kiếm toàn văn
product_translations product_tr_trgm_idx GIN trigram Gõ sai chính tả, tìm không dấu
product_variants variants_sku_uq B-tree unique Tra SKU (⚑ C1)
stock_levels PK (variant_id, warehouse_id) B-tree Câu UPDATE nguyên tử §8.1
stock_levels stock_levels_available_idx B-tree bộ phận "Còn hàng không" trên trang danh mục
stock_reservations reservations_sweep_idx B-tree bộ phận Job dọn mỗi phút
stock_batches batches_fefo_idx B-tree bộ phận Phân bổ FEFO (⚑ I8)
orders orders_pending_payment_idx B-tree bộ phận Job tự huỷ sau 30′
orders orders_customer_idx B-tree bộ phận "Đơn hàng của tôi"
orders orders_placed_brin BRIN Quét theo khoảng thời gian cho báo cáo
order_items order_items_order_idx B-tree Nạp chi tiết đơn
payment_transactions payment_txn_idem_uq B-tree unique ⚑ Y2 chống webhook lặp
ledger_entries ledger_entries_acct_idx B-tree Tính số dư tài khoản
outbox_messages outbox_pending_idx B-tree bộ phận Relay — luôn nhỏ
coupons coupons_code_uq B-tree unique Tra mã giảm giá
administrative_units admin_units_search_idx GIN trigram Gợi ý địa chỉ gõ không dấu

15.1 Ba nguyên tắc index của dự án

  1. Index bộ phận là mặc định khi có cột trạng thái. WHERE status = 'pending_payment' trên bảng 50 triệu dòng chỉ index vài nghìn dòng — nhỏ hơn 4 bậc, nằm gọn trong bộ nhớ.
  2. BRIN cho bảng chỉ ghi thêm theo thời gian. orders, ledger_transactions, log. BRIN nhỏ hơn B-tree hàng trăm lần và đủ tốt cho truy vấn theo khoảng.
  3. jsonb_path_ops thay vì jsonb_ops khi chỉ dùng toán tử @> — index nhỏ hơn khoảng 3 lần.

Không index bừa. Mỗi index làm chậm mọi lần INSERT/UPDATE và chiếm bộ nhớ đệm. Quy tắc: index chỉ được thêm khi có một truy vấn thật kèm EXPLAIN (ANALYZE, BUFFERS) chứng minh nó cần.


16. Quy tắc migration

16.1 Laravel migration hay SQL thuần?

Loại thay đổi Cách làm
Tạo bảng, cột, index thường Laravel schema builder
CHECK, EXCLUDE, generated column, trigger, function, partition DB::unprepared() với SQL thuần
Index tạo trên bảng đang chạy CREATE INDEX CONCURRENTLY + tắt transaction (§16.3)

Lý do: schema builder của Laravel không diễn đạt được EXCLUDE USING gist, GENERATED ALWAYS AS ... STORED, CONSTRAINT TRIGGER DEFERRABLE — mà đây chính là những thứ cưỡng chế các bất biến quan trọng nhất.

16.2 Đặt migration ở đâu

modules/Catalog/database/migrations/2026_08_20_000100_create_products_table.php
modules/Inventory/database/migrations/2026_08_20_000200_create_stock_levels_table.php
database/migrations/2026_08_20_000000_create_foundation.php    ← extension + function

Migration nằm trong module sở hữu bảng đó (doc 01 §7.1). Riêng 00_foundation nằm ở gốc vì mọi module đều phụ thuộc.

Thứ tự quan trọng: 00_foundation (extension + f_unaccent) phải chạy trước mọi migration tạo generated column dùng f_unaccent. Đặt timestamp sớm nhất.

16.3 CREATE INDEX CONCURRENTLY trong Laravel

CREATE INDEX CONCURRENTLY không chạy được trong transaction, mà Laravel bọc migration trong transaction theo mặc định:

return new class extends Migration
{
    // BẮT BUỘC — nếu thiếu sẽ báo:
    // "CREATE INDEX CONCURRENTLY cannot run inside a transaction block"
    public $withinTransaction = false;

    public function up(): void
    {
        DB::unprepared(<<<'SQL'
            CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_customer_idx
                ON orders (customer_id, placed_at DESC)
                WHERE customer_id IS NOT NULL;
        SQL);
    }
};

CONCURRENTLY có thể thất bại giữa chừng và để lại index INVALID. Sau mỗi lần deploy có tạo index kiểu này, kiểm tra:

SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;

Có dòng ⇒ DROP INDEX rồi tạo lại.

16.4 Expand–contract cho zero-downtime

Thao tác Sai (gây downtime) Đúng
Đổi tên cột ALTER TABLE ... RENAME COLUMN Thêm cột mới → ghi cả hai → backfill → chuyển đọc → xoá cột cũ (4 lần deploy)
Thêm cột NOT NULL ADD COLUMN x NOT NULL Thêm nullable → backfill theo lô → SET NOT NULL
Thêm CHECK ADD CONSTRAINT ... CHECK (khoá bảng để quét) ADD CONSTRAINT ... NOT VALIDVALIDATE CONSTRAINT (chỉ khoá nhẹ)
Thêm khoá ngoại ADD FOREIGN KEY ADD FOREIGN KEY ... NOT VALIDVALIDATE CONSTRAINT
Xoá cột Xoá cùng lúc với deploy code Deploy code ngừng dùng trước → deploy sau mới xoá
Đổi kiểu cột ALTER COLUMN ... TYPE (ghi lại cả bảng) Cột mới + backfill + đổi chỗ
-- Mẫu thêm CHECK không khoá bảng
ALTER TABLE orders
    ADD CONSTRAINT orders_total_math_chk
    CHECK (grand_total = subtotal - discount_total + shipping_fee + tax_total)
    NOT VALID;

-- Sau đó, ở thời điểm rảnh:
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_math_chk;

Quy tắc bất di bất dịch: migration phải tương thích ngược với phiên bản code đang chạy. Nhờ đó rollback code không cần rollback CSDL — và rollback CSDL là thứ gần như không bao giờ an toàn.

16.5 Backfill theo lô

Không bao giờ UPDATE toàn bộ bảng lớn trong một câu lệnh — nó giữ khoá và làm phình WAL.

-- Chạy lặp cho đến khi trả về 0 dòng
WITH batch AS (
    SELECT id FROM orders
     WHERE new_column IS NULL
     ORDER BY id
     LIMIT 5000
     FOR UPDATE SKIP LOCKED
)
UPDATE orders o SET new_column = f_compute(o)
  FROM batch b WHERE o.id = b.id;

17. Ai sở hữu bảng nào

Cưỡng chế bằng deptrac (doc 01 §7.4) + review. Module chỉ được viết vào bảng mình sở hữu.

Module Sở hữu Được đọc trực tiếp Phải qua API/event
Catalog products*, product_variants, categories*, brands, media*, attribute_definitions
Pricing price_lists, price_rules, customer_groups product_variants
Promotion campaigns, coupons, coupon_redemptions Catalog, Pricing
Inventory stock_levels, stock_reservations, stock_batches, stock_movements, warehouses product_variants
Cart carts, cart_items product_variants Pricing
Checkout checkout_sessions, checkout_lines Pricing, Promotion, Inventory, Tax
Order orders, order_items, order_addresses, order_status_history tất cả qua event
Payment payment_transactions, payment_refunds, ledger_* orders (chỉ đọc)
Fulfilment shipments, shipment_items, carrier_events, carrier_address_mappings orders, order_items Inventory
Returns return_requests, return_items orders, order_items Payment, Inventory
Tax tax_rates, invoices, invoice_lines orders
Customer customers, customer_addresses, consents
Loyalty loyalty_accounts, loyalty_transactions Order (event)
Review reviews order_items (kiểm ⚑ V1)
Procurement suppliers, purchase_orders*, goods_receipts* Inventory
(hạ tầng) outbox_messages, idempotency_keys, audit_logs, administrative_units, currencies mọi module đọc ghi có kiểm soát

orders là bảng bị nhiều module đọc nhất. Chấp nhận đọc trực tiếp (cùng CSDL, tránh vòng lặp gọi qua lại), nhưng ghi thì tuyệt đối chỉ Order module. Đây là điều kiện để ⚑ O3 (máy trạng thái) có ý nghĩa.


18. Dữ liệu benchmark

Từ P1 phải có dữ liệu đủ lớn để mọi quyết định index dựa trên số đo, không phải cảm giác (doc 01 §8.9).

-- 1 triệu variant trên 200.000 sản phẩm
INSERT INTO products (brand_id, type, status, published_at, attributes)
SELECT (random() * 50)::int + 1,
       'configurable', 'active', now() - (random() * 365)::int * interval '1 day',
       jsonb_build_object(
           'skin_type',      (ARRAY['oily','dry','combination','sensitive'])[(random()*3)::int+1],
           'origin_country', (ARRAY['KR','JP','FR','VN','US'])[(random()*4)::int+1]
       )
  FROM generate_series(1, 200000);

INSERT INTO product_variants (product_id, sku, options, weight_gram, requires_batch_tracking)
SELECT p.id,
       'SKU-' || p.id || '-' || g,
       jsonb_build_object('shade', lpad(g::text, 2, '0')),
       (random() * 500)::int,
       true
  FROM products p, generate_series(1, 5) g;

-- Tồn kho trên 3 kho
INSERT INTO stock_levels (variant_id, warehouse_id, on_hand, reserved)
SELECT v.id, w.id, (random() * 200)::int, 0
  FROM product_variants v CROSS JOIN warehouses w;

ANALYZE;

Sau khi seed, chạy các truy vấn nóng với EXPLAIN (ANALYZE, BUFFERS) và ghi kết quả vào docs/benchmarks/. Bất kỳ truy vấn nào xuất hiện Seq Scan trên bảng > 100k dòng đều phải giải trình.

18.1 Truy vấn nóng cần đo

# Truy vấn Mục tiêu p95
1 Chi tiết sản phẩm theo slug (kèm variant + giá + tồn) < 120 ms
2 Danh mục có facet + phân trang + sắp xếp giá < 250 ms
3 Tìm kiếm toàn văn tiếng Việt không dấu < 200 ms
4 Đếm facet theo nhánh danh mục < 150 ms
5 Giữ chỗ tồn (câu UPDATE §8.1) < 15 ms
6 Tạo đơn (transaction đầy đủ) < 400 ms
7 "Đơn hàng của tôi" phân trang < 100 ms
8 Số dư sổ cái theo tài khoản < 200 ms

19. Bảo trì

Việc Tần suất Ghi chú
ANALYZE bảng ghi nhiều Tự động (autovacuum) Chỉnh autovacuum_vacuum_scale_factor = 0.02 cho stock_levels, orders
Kiểm tra bloat Tuần stock_levels bị UPDATE rất nhiều → theo dõi kỹ
pg_stat_statements top 20 Tuần Truy vấn mới lọt vào top là tín hiệu cần xem
auto_explain Luôn bật Ghi log truy vấn > 200 ms
Index không dùng Tháng pg_stat_user_indexes với idx_scan = 0 sau 30 ngày ⇒ cân nhắc xoá
Index INVALID Sau mỗi deploy §16.3
Đối chiếu ⚑ I4, I7 Hằng đêm §8.2
Đối chiếu sổ kép Hằng ngày SELECT SUM(amount) FROM ledger_entries phải = 0
Tạo phân mảnh tháng tới Tháng pg_partman từ P7; trước đó làm tay
Diễn tập restore Quý ⚑ Backup chưa từng restore thử = không có backup
-- Đối chiếu sổ kép toàn cục — phải luôn trả về đúng 0
SELECT SUM(amount) AS must_be_zero FROM ledger_entries;

-- Index chưa bao giờ được dùng
SELECT schemaname, relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
  FROM pg_stat_user_indexes
 WHERE idx_scan = 0 AND indexrelname NOT LIKE '%_pkey'
 ORDER BY pg_relation_size(indexrelid) DESC;

20. Còn thiếu & câu hỏi mở

Việc Pha
Bảng Procurement (suppliers, purchase_orders, goods_receipts) — DDL đầy đủ P3
Bảng pgvector cho embedding sản phẩm + index HNSW P6
Cấu hình pg_partman tự tạo phân mảnh P7
Row Level Security cho marketplace đa nhà bán P8
Lược đồ ClickHouse + mô hình dbt P7

Cần quyết trước khi viết migration đầu tiên:

  1. Tên schema PostgreSQL: dùng public cho tất cả, hay mỗi module một schema (catalog.products, orders.orders)? Schema riêng cưỡng chế ranh giới mạnh hơn nhưng làm Eloquent và migration phức tạp hơn đáng kể. Đề xuất: dùng public và cưỡng chế ranh giới bằng deptrac — vì ta sẽ tách service bằng cách di chuyển bảng, không bằng schema.
  2. Ngưỡng min_shelf_life_days mặc định 120 ngày cho mỹ phẩm — cần xác nhận với vận hành.
  3. Thời điểm phát hành hoá đơn (doc 02 §27) — ảnh hưởng đến việc invoices.issued_at gắn với shipped_at hay confirmed_at.

Tài liệu #3 · Lập 13/08/2026 · Mọi DDL đã kiểm chứng cú pháp trên PostgreSQL 18. Mã ⚑ tham chiếu doc 02 §23.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí