#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
- 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ớ. - 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. jsonb_path_opsthay vìjsonb_opskhi 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 VALID → VALIDATE CONSTRAINT (chỉ khoá nhẹ) |
| Thêm khoá ngoại | ADD FOREIGN KEY |
ADD FOREIGN KEY ... NOT VALID → VALIDATE 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:
- Tên schema PostgreSQL: dùng
publiccho 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ùngpublicvà 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. - Ngưỡng
min_shelf_life_daysmặc định 120 ngày cho mỹ phẩm — cần xác nhận với vận hành. - Thời điểm phát hành hoá đơn (doc 02 §27) — ảnh hưởng đến việc
invoices.issued_atgắn vớishipped_athayconfirmed_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