正在加载,请稍候…

Database Design: Normalization, Denormalization, and When to Break the Rules

Design effective database schemas — 1NF through 5NF normalization, strategic denormalization, JSONB for flexible attributes, audit trails, multi-tenancy patterns.

Database Design Principles

Good schema design is a balance between normalization (data integrity) and denormalization (query performance).

Normalization Forms

1NF: Atomic Values

-- Bad: repeating groups
CREATE TABLE orders (
  id INT,
  items TEXT  -- 'apple,banana,cherry' — violates 1NF
);

-- Good: atomic values
CREATE TABLE order_items (
  order_id INT,
  item_name VARCHAR(100),
  quantity INT
);

3NF: No Transitive Dependencies

-- Bad: city determines zip, zip determines state (transitive)
CREATE TABLE users (
  id INT,
  name VARCHAR,
  zip_code VARCHAR(10),
  city VARCHAR(100),  -- determined by zip, not id
  state VARCHAR(2)    -- determined by zip, not id
);

-- Good: extract to zip_codes table
CREATE TABLE zip_codes (
  zip VARCHAR(10) PRIMARY KEY,
  city VARCHAR(100),
  state VARCHAR(2)
);

Strategic Denormalization

Cached Counts

-- Instead of COUNT(*) on every page load:
ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0;
ALTER TABLE users ADD COLUMN follower_count INT DEFAULT 0;

-- Keep in sync with triggers
CREATE OR REPLACE FUNCTION update_comment_count()
RETURNS TRIGGER AS $
BEGIN
  IF TG_OP = 'INSERT' THEN
    UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
  ELSIF TG_OP = 'DELETE' THEN
    UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id;
  END IF;
  RETURN NULL;
END;
$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_comment_count
AFTER INSERT OR DELETE ON comments
FOR EACH ROW EXECUTE FUNCTION update_comment_count();

JSONB for Flexible Attributes (EAV Alternative)

-- Bad: Entity-Attribute-Value (terrible performance)
CREATE TABLE product_attributes (
  product_id INT,
  attr_name VARCHAR,
  attr_value TEXT  -- Loses type information!
);

-- Good: JSONB column
ALTER TABLE products ADD COLUMN attributes JSONB DEFAULT '{}';

-- Index specific JSONB paths
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
CREATE INDEX idx_products_attrs ON products USING gin(attributes);

-- Query
SELECT * FROM products
WHERE attributes->>'color' = 'red'
  AND (attributes->>'weight')::float < 5.0;

Multi-Tenancy Patterns

Row-Level (Shared Schema)

-- Add tenant_id to all tables
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(100),
  plan VARCHAR(50)
);

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID REFERENCES tenants(id) NOT NULL,
  email VARCHAR(255),
  UNIQUE (tenant_id, email)
);

-- RLS for automatic isolation
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.tenant_id')::UUID);

-- Set in application
SET app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';

Schema-Per-Tenant

-- Each tenant gets own PostgreSQL schema
CREATE SCHEMA tenant_abc;
CREATE TABLE tenant_abc.users (...);
CREATE TABLE tenant_abc.orders (...);

Audit Trail Pattern

CREATE TABLE audit_log (
  id BIGSERIAL PRIMARY KEY,
  table_name VARCHAR(100) NOT NULL,
  record_id TEXT NOT NULL,
  operation CHAR(1) CHECK (operation IN ('I','U','D')),
  old_values JSONB,
  new_values JSONB,
  changed_by UUID REFERENCES users(id),
  changed_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_audit_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_changed_at ON audit_log (changed_at DESC);

-- Generic audit trigger
CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $
BEGIN
  INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_by)
  VALUES (
    TG_TABLE_NAME,
    COALESCE(NEW.id::TEXT, OLD.id::TEXT),
    LEFT(TG_OP, 1),
    CASE WHEN TG_OP != 'INSERT' THEN row_to_json(OLD)::JSONB END,
    CASE WHEN TG_OP != 'DELETE' THEN row_to_json(NEW)::JSONB END,
    current_setting('app.user_id', true)::UUID
  );
  RETURN NEW;
END;
$ LANGUAGE plpgsql;

Soft Deletes vs Hard Deletes

-- Soft delete: mark as deleted, keep data
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN is_deleted BOOLEAN GENERATED ALWAYS AS (deleted_at IS NOT NULL) STORED;

CREATE INDEX idx_users_active ON users (id) WHERE deleted_at IS NULL;

-- View for non-deleted records
CREATE VIEW active_users AS
  SELECT * FROM users WHERE deleted_at IS NULL;