Database Design — ERP/MES PostgreSQL 17 · Redis
Hoàn thành

Database Design
Event Store · Read Models · Time-series

Thiết kế schema đầy đủ cho toàn bộ hệ thống ERP/MES — từ Event Store (Write side), Read Model Projections, đến sensor time-series và Redis cache layer.

Cập nhật: 22/07/2026
Engines: PostgreSQL 17, Redis
Sơ đồ: 7 diagrams
01 — Chiến lược

Chiến lược Database — Write vs Read

Theo kiến trúc CQRS, hệ thống tách biệt hoàn toàn Write side (Event Store) và Read side (Projections). Mỗi loại dữ liệu dùng engine phù hợp nhất với đặc tính của nó.

PostgreSQL 17
Event Store + Write DB
PostgreSQL 17
Read Models / Projections
Redis 7
Hot Cache · Sessions
PostgreSQL 17
Sensor OEE · Range-partitioned
Sơ đồ 1 — Database Topology
flowchart TB App["Application Core\nModular Monolith"] subgraph Write["Write Side"] ES[("Event Store\nPostgreSQL 17\nevents · snapshots · outbox")] end subgraph Read["Read Side"] RM[("Read Models\nPostgreSQL 17\nprojection tables")] RC[("Redis 7\nhot cache · sessions")] end subgraph Sensor["Sensor OEE"] SR[("sensor_readings\nPostgreSQL — range-partitioned\nmonthly partitions")] SN[("oee_daily_snapshots\nA×P×Q per machine/shift/day")] end Kafka[["Apache Kafka\nEvent Backbone"]] App -->|"Commands → Domain Events"| ES ES -->|"Outbox Pattern"| Kafka Kafka -->|"Projection consumers"| RM App -->|"POST /sensors/readings"| SR SR -->|"RecalculateSnapshot"| SN RM <-->|"Read-through"| RC style Write fill:#1E1B4B,stroke:#6366F1,color:#E2E8F0 style Read fill:#052E16,stroke:#10B981,color:#E2E8F0 style Sensor fill:#0C2340,stroke:#06B6D4,color:#E2E8F0
🖱️ Kéo để di chuyển · Cuộn chuột để zoom
Tại sao dùng PostgreSQL cho cả Write và Read?

Với quy mô Phase 1–2, một PostgreSQL cluster (primary + read replica) xử lý được cả Event Store, Read Models và Sensor OEE (range-partitioned). Chỉ cần tách ra database riêng khi đo được bottleneck thực tế — tránh phụ thuộc vào extension bên ngoài (không dùng TimescaleDB).

02 — Event Store

Event Store Schema — PostgreSQL

Event Store là trung tâm của Write side. Ba bảng chính: events (nguồn sự thật), snapshots (tối ưu replay), outbox (đảm bảo publish to Kafka không mất).

Sơ đồ 2 — Event Store Entity Relationship
erDiagram EVENTS { uuid id PK varchar aggregate_type uuid aggregate_id bigint sequence_number varchar event_type jsonb payload jsonb metadata varchar correlation_id varchar causation_id varchar tenant_id timestamptz occurred_at timestamptz recorded_at } SNAPSHOTS { uuid id PK varchar aggregate_type uuid aggregate_id bigint at_sequence jsonb state varchar tenant_id timestamptz created_at } OUTBOX { uuid id PK varchar aggregate_type uuid aggregate_id varchar event_type jsonb payload varchar kafka_topic varchar kafka_key varchar status int retry_count timestamptz created_at timestamptz published_at } EVENTS ||--o{ SNAPSHOTS : "snapshots at sequence" EVENTS ||--o{ OUTBOX : "queued for publish"
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

DDL — Bảng events

SQL — PostgreSQL 17
CREATE TABLE events (
    id              UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(100) NOT NULL,
    aggregate_id    UUID         NOT NULL,
    sequence_number BIGINT       NOT NULL,
    event_type      VARCHAR(200) NOT NULL,
    payload         JSONB        NOT NULL,
    metadata        JSONB        NOT NULL DEFAULT '{}',
    correlation_id  VARCHAR(100),
    causation_id    VARCHAR(100),
    tenant_id       VARCHAR(50)  NOT NULL DEFAULT 'default',
    occurred_at     TIMESTAMPTZ  NOT NULL,
    recorded_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),

    -- Optimistic locking: no two events same aggregate + sequence
    CONSTRAINT uq_events_aggregate_seq
        UNIQUE (aggregate_type, aggregate_id, sequence_number)
);

-- Indexes
CREATE INDEX idx_events_aggregate
    ON events (aggregate_type, aggregate_id, sequence_number);

CREATE INDEX idx_events_type
    ON events (event_type, occurred_at DESC);

CREATE INDEX idx_events_correlation
    ON events (correlation_id)
    WHERE correlation_id IS NOT NULL;

CREATE INDEX idx_events_tenant
    ON events (tenant_id, recorded_at DESC);

-- Partition by recorded_at for large-scale (optional Phase 3)
-- PARTITION BY RANGE (recorded_at)

DDL — Bảng snapshots

SQL — PostgreSQL 17
CREATE TABLE snapshots (
    id             UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id   UUID        NOT NULL,
    at_sequence    BIGINT      NOT NULL,
    state          JSONB       NOT NULL,
    tenant_id      VARCHAR(50) NOT NULL DEFAULT 'default',
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Chỉ giữ snapshot mới nhất mỗi aggregate
    CONSTRAINT uq_snapshots_aggregate
        UNIQUE (aggregate_type, aggregate_id)
);

CREATE INDEX idx_snapshots_aggregate
    ON snapshots (aggregate_type, aggregate_id);

-- Trigger snapshot sau mỗi 500 events (thực hiện ở Application layer)

DDL — Bảng outbox (Transactional Outbox Pattern)

SQL — PostgreSQL 17
CREATE TABLE outbox (
    id             UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id   UUID        NOT NULL,
    event_type     VARCHAR(200) NOT NULL,
    payload        JSONB       NOT NULL,
    kafka_topic    VARCHAR(200) NOT NULL,
    kafka_key      VARCHAR(200) NOT NULL,
    -- PENDING | PUBLISHED | FAILED
    status         VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    retry_count    INT         NOT NULL DEFAULT 0,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at   TIMESTAMPTZ
);

CREATE INDEX idx_outbox_pending
    ON outbox (created_at)
    WHERE status = 'PENDING';

-- Polling interval: 100ms. Debezium CDC là alternative tốt hơn.
Optimistic Locking — tránh concurrent write conflict

Khi 2 requests cùng lúc ghi vào aggregate A, UNIQUE (aggregate_type, aggregate_id, sequence_number) đảm bảo chỉ 1 thành công. Request thứ 2 nhận UniqueViolationException → retry từ đầu (load aggregate + replay + reapply command).

03 — Production

Read Model — Production Module

Projection tables được tạo và cập nhật bởi Kafka consumers khi nhận Domain Events từ Event Store. Đây là Read-only — không bao giờ ghi trực tiếp vào đây từ Command side.

Sơ đồ 3 — Production Module ER
erDiagram PRODUCTION_ORDERS { uuid id PK varchar order_number UK uuid product_id varchar product_code int planned_qty int actual_qty int scrap_qty varchar status uuid machine_id varchar machine_code uuid operator_id varchar operator_name varchar shift varchar tenant_id timestamptz planned_start timestamptz actual_start timestamptz completed_at timestamptz updated_at } PRODUCTION_STEPS { uuid id PK uuid order_id FK varchar step_code varchar step_name int sequence_no varchar status int qty_done timestamptz started_at timestamptz completed_at } MATERIAL_CONSUMPTIONS { uuid id PK uuid order_id FK uuid material_id varchar material_code decimal planned_qty decimal actual_qty varchar unit } MACHINE_UTILIZATION { uuid id PK uuid machine_id date work_date varchar shift int runtime_min int downtime_min decimal oee_percent varchar tenant_id } PRODUCTION_ORDERS ||--o{ PRODUCTION_STEPS : "has steps" PRODUCTION_ORDERS ||--o{ MATERIAL_CONSUMPTIONS : "consumes"
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

DDL — production_orders (Projection)

SQL — PostgreSQL 17
CREATE TABLE production_orders (
    id             UUID         PRIMARY KEY,
    order_number   VARCHAR(50)  NOT NULL UNIQUE,
    product_id     UUID         NOT NULL,
    product_code   VARCHAR(100) NOT NULL,
    planned_qty    INT          NOT NULL,
    actual_qty     INT          NOT NULL DEFAULT 0,
    scrap_qty      INT          NOT NULL DEFAULT 0,
    -- PLANNED | IN_PROGRESS | ON_HOLD | COMPLETED | CANCELLED
    status         VARCHAR(30)  NOT NULL DEFAULT 'PLANNED',
    machine_id     UUID,
    machine_code   VARCHAR(50),
    operator_id    UUID,
    operator_name  VARCHAR(100),
    shift          VARCHAR(10),   -- MORNING | AFTERNOON | NIGHT
    tenant_id      VARCHAR(50)  NOT NULL DEFAULT 'default',
    planned_start  TIMESTAMPTZ,
    actual_start   TIMESTAMPTZ,
    completed_at   TIMESTAMPTZ,
    updated_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_prod_orders_status  ON production_orders (status, planned_start DESC);
CREATE INDEX idx_prod_orders_machine ON production_orders (machine_id, work_date);
CREATE INDEX idx_prod_orders_tenant  ON production_orders (tenant_id, updated_at DESC);
04 — Quality & Maintenance

Read Model — Quality & Maintenance Modules

Sơ đồ 4 — Quality & Maintenance ER
erDiagram QUALITY_INSPECTIONS { uuid id PK uuid order_id FK varchar inspection_no UK varchar inspection_type varchar status uuid inspector_id varchar inspector_name int total_items int passed_items int failed_items varchar verdict varchar tenant_id timestamptz inspected_at } INSPECTION_RESULTS { uuid id PK uuid inspection_id FK varchar parameter_code varchar parameter_name decimal measured_value decimal spec_min decimal spec_max varchar unit boolean is_passed } NON_CONFORMANCES { uuid id PK uuid inspection_id FK varchar ncr_number UK varchar defect_code varchar defect_desc varchar severity varchar disposition varchar status uuid raised_by uuid closed_by timestamptz raised_at timestamptz closed_at } ASSETS { uuid id PK varchar asset_code UK varchar asset_name varchar asset_type varchar location varchar status date next_maintenance uuid responsible_id varchar tenant_id } MAINTENANCE_WORK_ORDERS { uuid id PK varchar wo_number UK uuid asset_id FK varchar wo_type varchar priority varchar status varchar description uuid assigned_to timestamptz scheduled_at timestamptz started_at timestamptz completed_at int actual_duration_min varchar tenant_id } QUALITY_INSPECTIONS ||--o{ INSPECTION_RESULTS : "contains" QUALITY_INSPECTIONS ||--o{ NON_CONFORMANCES : "raises" ASSETS ||--o{ MAINTENANCE_WORK_ORDERS : "has"
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

Bảng schemas quan trọng

BảngMục đíchTrigger eventIndex chính
quality_inspectionsHeader phiếu kiểm traQualityInspectionCreatedstatus, order_id, inspected_at
inspection_resultsChi tiết từng thông số đoInspectionResultRecordedinspection_id, is_passed
non_conformancesPhiếu NCR — sản phẩm không đạtNonConformanceRaisedstatus, severity, raised_at
assetsDanh mục thiết bị / máy mócAssetRegisteredasset_type, status, next_maintenance
maintenance_work_ordersLệnh bảo trì (PM/CM/PdM)WorkOrderCreatedasset_id, status, scheduled_at
05 — Inventory & Finance

Read Model — Inventory & Finance Modules

Sơ đồ 5 — Inventory & Finance ER
erDiagram INVENTORY_LOCATIONS { uuid id PK varchar code UK varchar name varchar type varchar parent_id varchar tenant_id } STOCK_LEVELS { uuid id PK uuid material_id varchar material_code UK varchar material_name uuid location_id FK decimal on_hand_qty decimal reserved_qty decimal available_qty decimal reorder_point varchar unit varchar tenant_id timestamptz updated_at } STOCK_MOVEMENTS { uuid id PK uuid material_id varchar material_code uuid from_location FK uuid to_location FK decimal qty varchar unit varchar movement_type varchar reference_type uuid reference_id varchar tenant_id timestamptz moved_at } COST_LEDGER { uuid id PK varchar ledger_no UK varchar account_code varchar cost_center decimal amount varchar currency varchar transaction_type varchar reference_type uuid reference_id varchar tenant_id timestamptz posted_at } INVENTORY_LOCATIONS ||--o{ STOCK_LEVELS : "holds" INVENTORY_LOCATIONS ||--o{ STOCK_MOVEMENTS : "from/to"
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

DDL — stock_levels (điểm quan trọng)

SQL — PostgreSQL 17
CREATE TABLE stock_levels (
    id             UUID         PRIMARY KEY,
    material_id    UUID         NOT NULL,
    material_code  VARCHAR(100) NOT NULL,
    material_name  VARCHAR(200) NOT NULL,
    location_id    UUID         NOT NULL REFERENCES inventory_locations(id),
    on_hand_qty    DECIMAL(15,4) NOT NULL DEFAULT 0,
    reserved_qty   DECIMAL(15,4) NOT NULL DEFAULT 0,
    -- available = on_hand - reserved
    available_qty  DECIMAL(15,4) GENERATED ALWAYS AS
                   (on_hand_qty - reserved_qty) STORED,
    reorder_point  DECIMAL(15,4) NOT NULL DEFAULT 0,
    unit           VARCHAR(20)  NOT NULL,
    tenant_id      VARCHAR(50)  NOT NULL DEFAULT 'default',
    updated_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_stock_material_location
        UNIQUE (material_id, location_id, tenant_id)
);

-- Cảnh báo reorder: query materialized view hàng giờ
CREATE MATERIALIZED VIEW mv_reorder_alerts AS
SELECT * FROM stock_levels
WHERE available_qty <= reorder_point
  AND reorder_point > 0;

CREATE UNIQUE INDEX ON mv_reorder_alerts (id);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_reorder_alerts;
06 — Sensor OEE

Sensor OEE — PostgreSQL Range-Partitioned Tables

Dữ liệu telemetry từ PLC/edge được ingest vào PostgreSQL plain tables với RANGE partitioning theo tháng — không cần TimescaleDB extension. OEE A×P×Q được tính thuần C# (OeeFormulas.cs) và upsert vào snapshot mỗi khi trigger.

Sơ đồ 6 — Sensor OEE Data Flow
flowchart TB PLC["PLC / Edge Device\nPOST /api/v1/sensors/readings\n(anonymous + optional API key)"] subgraph Ingest["PostgreSQL — Range Partitioned"] SR["sensor_readings\nreading_type: cycle_count · good_count\nreject_count · cycle_time_ms · power_kw\npartitioned by month"] UE["machine_uptime_events\nevent_type: start · stop · fault · resume\npartitioned by month"] end Snap["oee_daily_snapshots\nUNIQUE(machine_code, date, shift, tenant)\nA · P · Q · Total\ncalculated_at"] Calc["OeeFormulas.cs\npure static — fully tested\nA=uptime/planned×100\nP=min(100, cycles×idealSec/uptimeSec×100)\nQ=good/cycles×100\nTotal=A×P×Q/10000"] PLC -->|"BulkInsertAsync"| SR PLC -->|"BulkInsertAsync"| UE SR & UE -->|"UpsertDailySnapshotAsync\n(trigger on demand)"| Calc Calc -->|"UPSERT"| Snap style Ingest fill:#0C2340,stroke:#06B6D4,color:#E2E8F0
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

DDL — sensor_readings (migration 022)

SQL — PostgreSQL 17 Range Partitioning
CREATE TABLE sensor_readings (
    id           BIGSERIAL,
    machine_code VARCHAR(50)   NOT NULL,
    reading_type VARCHAR(50)   NOT NULL,  -- cycle_count | good_count | reject_count | cycle_time_ms | power_kw | fault_code
    value        NUMERIC(18,4) NOT NULL,
    unit         VARCHAR(20),
    source       VARCHAR(100),
    tenant_id    VARCHAR(50)   NOT NULL DEFAULT 'default',
    recorded_at  TIMESTAMPTZ   NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (recorded_at);

CREATE TABLE machine_uptime_events (
    id           BIGSERIAL,
    machine_code VARCHAR(50)   NOT NULL,
    event_type   VARCHAR(30)   NOT NULL,  -- start | stop | fault | resume | planned_stop
    reason       TEXT,
    source       VARCHAR(100),
    tenant_id    VARCHAR(50)   NOT NULL DEFAULT 'default',
    recorded_at  TIMESTAMPTZ   NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (recorded_at);

-- Monthly partitions created by DO $$ block in migration (13 months)
-- Example:
CREATE TABLE sensor_readings_2026_07
    PARTITION OF sensor_readings
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

DDL — oee_daily_snapshots

SQL
CREATE TABLE oee_daily_snapshots (
    id               BIGSERIAL PRIMARY KEY,
    machine_code     VARCHAR(50)    NOT NULL,
    snapshot_date    DATE           NOT NULL,
    shift_code       VARCHAR(20)    NOT NULL DEFAULT 'Morning',
    planned_minutes  NUMERIC(10,2)  NOT NULL DEFAULT 480,
    uptime_minutes   NUMERIC(10,2)  NOT NULL DEFAULT 0,
    downtime_minutes NUMERIC(10,2)  NOT NULL DEFAULT 0,
    cycle_count      BIGINT         NOT NULL DEFAULT 0,
    good_count       BIGINT         NOT NULL DEFAULT 0,
    reject_count     BIGINT         NOT NULL DEFAULT 0,
    ideal_cycle_sec  NUMERIC(10,4),
    -- Generated columns (auto-computed by DB)
    oee_availability NUMERIC(6,2) GENERATED ALWAYS AS
        (CASE WHEN planned_minutes > 0 THEN ROUND(uptime_minutes / planned_minutes * 100, 2) END) STORED,
    oee_quality      NUMERIC(6,2) GENERATED ALWAYS AS
        (CASE WHEN cycle_count > 0 THEN ROUND(good_count::NUMERIC / cycle_count * 100, 2) END) STORED,
    -- Stored columns (computed by OeeFormulas.cs + upserted)
    oee_performance  NUMERIC(6,2),
    oee_total        NUMERIC(6,2),
    tenant_id        VARCHAR(50)    NOT NULL DEFAULT 'default',
    calculated_at    TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_oee_snapshot UNIQUE (machine_code, snapshot_date, shift_code, tenant_id)
);
07 — Redis

Redis 7 — Cache & Hot Data

Redis cache những gì được đọc nhiều nhất và không cần tính nhất quán tức thì. Mọi cache đều có TTL và chiến lược invalidation rõ ràng khi Projection cập nhật.

Sơ đồ 7 — Redis Key Structure
flowchart LR subgraph Session["Session & Auth"] S1["session:{userId}\nTTL: 8h\nHash: roles, permissions"] S2["token:blacklist:{jti}\nTTL: token expiry\nString: revoked"] end subgraph Dashboard["Dashboard Cache"] D1["dashboard:production:{tenant}:{date}\nTTL: 60s\nHash: orders_count, oee, ..."] D2["machine:status:{machineId}\nTTL: 10s\nHash: status, current_order, speed"] end subgraph Lookup["Master Data Lookup"] L1["product:{productId}\nTTL: 1h\nHash: code, name, bom"] L2["machine:{machineId}\nTTL: 1h\nHash: code, name, type"] end subgraph Queue["Rate Limit & Lock"] Q1["ratelimit:{userId}:{endpoint}\nTTL: 60s\nCounter"] Q2["lock:order:{orderId}\nTTL: 30s\nString: processing"] end style Session fill:#1E1B4B,stroke:#6366F1,color:#E2E8F0 style Dashboard fill:#052E16,stroke:#10B981,color:#E2E8F0 style Lookup fill:#1A1200,stroke:#F59E0B,color:#E2E8F0 style Queue fill:#1A0A10,stroke:#EF4444,color:#E2E8F0
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

Key Naming Convention

PatternData typeTTLInvalidation
session:{userId}Hash8 giờLogout / password change
token:blacklist:{jti}StringToken expiryTự hết hạn
dashboard:production:{tenant}:{date}Hash60 giâyProjection update event
machine:status:{machineId}Hash10 giâyMachineStatusChanged event
product:{productId}Hash1 giờProductUpdated event
machine:{machineId}Hash1 giờMachineUpdated event
ratelimit:{userId}:{endpoint}Counter (INCR)60 giâyTự hết hạn
lock:order:{orderId}String (SET NX)30 giâyRelease sau xử lý
Cache invalidation strategy — Event-driven

Khi Kafka consumer cập nhật Projection (PostgreSQL Read Model), nó đồng thời xóa/cập nhật Redis key tương ứng. Pattern: DEL dashboard:production:{tenant}:* sau khi Production Order thay đổi trạng thái. Tránh dùng time-based invalidation thuần túy cho dữ liệu quan trọng.

Tài liệu tiếp theo

Domain Events Catalog — Commands, Events & Payloads

Xem tài liệu → ← Về trang chủ