Database Design Guide: Theory & Practice
Version: 1.0 Last Updated: 2026-01-07 Purpose: Comprehensive guide to database design theory, normalization, and practical patterns for PostgreSQL, Redis, and MongoDB
Table of Contents
- Normalization Theory
- PostgreSQL Design Patterns
- Redis Design Patterns
- MongoDB Design Patterns
- Streaming Patterns
- Polyglot Persistence Strategy
1. Normalization Theory
1.1 Overview
Purpose: Eliminate redundancy, ensure data integrity, optimize for consistency.
Key Concepts:
- Functional Dependency: Attribute B is functionally dependent on A if each value of A determines exactly one value of B (A → B)
- Prime Attribute: Attribute that is part of any candidate key
- Non-Prime Attribute: Attribute that is not part of any candidate key
- Candidate Key: Minimal set of attributes that uniquely identifies a tuple
- Superkey: Set of attributes that uniquely identifies a tuple (may contain extra attributes)
1.2 First Normal Form (1NF)
Definition: Each attribute contains only atomic (indivisible) values. No repeating groups or arrays.
Violations:
-- ❌ VIOLATES 1NF: Multiple phone numbers in single columnCREATE TABLE contacts_bad ( id SERIAL PRIMARY KEY, name TEXT, phone_numbers TEXT -- "555-1234, 555-5678, 555-9012");
-- ❌ VIOLATES 1NF: Array/composite valuesCREATE TABLE contacts_bad2 ( id SERIAL PRIMARY KEY, name TEXT, phones TEXT[] -- PostgreSQL array);Correct 1NF:
-- ✅ 1NF: Atomic values onlyCREATE TABLE contacts ( id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE contact_phones ( id SERIAL PRIMARY KEY, contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, phone_number TEXT NOT NULL, phone_type TEXT CHECK (phone_type IN ('mobile', 'home', 'work')), UNIQUE(contact_id, phone_number));Benefits:
- Queryable individual values
- Enforceable constraints
- Indexable columns
- No parsing required
1.3 Second Normal Form (2NF)
Definition: Must be in 1NF AND every non-prime attribute must be fully functionally dependent on the entire candidate key (no partial dependencies).
Rule: Only applies to tables with composite keys. If key is single attribute, 2NF is automatically satisfied.
Violations:
-- ❌ VIOLATES 2NF: Partial dependency on composite keyCREATE TABLE order_items_bad ( order_id INTEGER, product_id INTEGER, quantity INTEGER NOT NULL, product_name TEXT, -- Depends only on product_id (partial dependency) product_price NUMERIC, -- Depends only on product_id (partial dependency) PRIMARY KEY (order_id, product_id));-- Problem: product_name and product_price depend on product_id only,-- not on the full key (order_id, product_id)Correct 2NF:
-- ✅ 2NF: Eliminate partial dependenciesCREATE TABLE products ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, price NUMERIC(10, 2) NOT NULL CHECK (price >= 0));
CREATE TABLE orders ( id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id), order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'delivered', 'cancelled')));
CREATE TABLE order_items ( order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE, product_id INTEGER REFERENCES products(id), quantity INTEGER NOT NULL CHECK (quantity > 0), unit_price NUMERIC(10, 2) NOT NULL, -- Snapshot price at order time PRIMARY KEY (order_id, product_id));Benefits:
- Update product name/price in one place
- No update anomalies
- Reduced redundancy
1.4 Third Normal Form (3NF)
Definition: Must be in 2NF AND no transitive dependencies (non-prime attributes must not depend on other non-prime attributes).
Rule: A → B → C means C transitively depends on A through B.
Violations:
-- ❌ VIOLATES 3NF: Transitive dependencyCREATE TABLE employees_bad ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, department_id INTEGER NOT NULL, department_name TEXT, -- Transitively depends on id through department_id department_budget NUMERIC -- Transitively depends on id through department_id);-- Problem: department_name depends on department_id, which depends on id-- Transitive: id → department_id → department_nameCorrect 3NF:
-- ✅ 3NF: Eliminate transitive dependenciesCREATE TABLE departments ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, budget NUMERIC(12, 2) NOT NULL CHECK (budget >= 0));
CREATE TABLE employees ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, department_id INTEGER NOT NULL REFERENCES departments(id), hire_date DATE NOT NULL, salary NUMERIC(10, 2) NOT NULL CHECK (salary >= 0));Benefits:
- Update department info in one place
- No anomalies when department changes
- Clear separation of concerns
1.5 Boyce-Codd Normal Form (BCNF)
Definition: Must be in 3NF AND for every functional dependency X → Y, X must be a superkey.
Stricter than 3NF: Eliminates anomalies when multiple overlapping candidate keys exist.
Violations:
-- ❌ VIOLATES BCNF: Professor determines course, but not a superkeyCREATE TABLE course_assignments_bad ( student_id INTEGER, course_id INTEGER, professor_id INTEGER, PRIMARY KEY (student_id, course_id), -- Functional dependency: course_id → professor_id -- But course_id is not a superkey -- Problem: Same course must have same professor for all students);Correct BCNF:
-- ✅ BCNF: Decompose into proper relationshipsCREATE TABLE courses ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, credits INTEGER NOT NULL CHECK (credits > 0));
CREATE TABLE course_sections ( id SERIAL PRIMARY KEY, course_id INTEGER NOT NULL REFERENCES courses(id), professor_id INTEGER NOT NULL REFERENCES professors(id), semester TEXT NOT NULL, UNIQUE(course_id, professor_id, semester));
CREATE TABLE enrollments ( student_id INTEGER REFERENCES students(id), section_id INTEGER REFERENCES course_sections(id), grade TEXT CHECK (grade IN ('A', 'B', 'C', 'D', 'F', 'W', 'I')), PRIMARY KEY (student_id, section_id));Benefits:
- No update anomalies with overlapping keys
- Clear relationship modeling
- Proper constraint enforcement
1.6 Fourth Normal Form (4NF)
Definition: Must be in BCNF AND no multi-valued dependencies (MVDs) exist.
Multi-Valued Dependency: A →→ B means for each value of A, there exists a set of values for B that is independent of other attributes.
Violations:
-- ❌ VIOLATES 4NF: Multi-valued dependenciesCREATE TABLE employee_skills_languages_bad ( employee_id INTEGER, skill TEXT, -- MVD: employee_id →→ skill language TEXT, -- MVD: employee_id →→ language PRIMARY KEY (employee_id, skill, language));-- Problem: Skills and languages are independent facts about employee-- Cartesian product: If employee knows 3 skills and 2 languages = 6 rows-- Inserting new skill requires updating all language combinationsCorrect 4NF:
-- ✅ 4NF: Separate independent multi-valued factsCREATE TABLE employee_skills ( employee_id INTEGER REFERENCES employees(id), skill TEXT NOT NULL, proficiency_level TEXT CHECK (proficiency_level IN ('beginner', 'intermediate', 'advanced', 'expert')), PRIMARY KEY (employee_id, skill));
CREATE TABLE employee_languages ( employee_id INTEGER REFERENCES employees(id), language TEXT NOT NULL, fluency_level TEXT CHECK (fluency_level IN ('basic', 'conversational', 'fluent', 'native')), PRIMARY KEY (employee_id, language));Benefits:
- No redundant combinations
- Independent updates
- Clearer semantics
- Reduced storage
1.7 Fifth Normal Form (5NF) / Project-Join Normal Form (PJNF)
Definition: Must be in 4NF AND no join dependencies exist (cannot be decomposed into smaller tables without loss of information).
Join Dependency: A table has a join dependency if it can only be reconstructed by joining multiple projections.
Violations:
-- ❌ VIOLATES 5NF: Hidden join dependencyCREATE TABLE supplier_part_project_bad ( supplier_id INTEGER, part_id INTEGER, project_id INTEGER, PRIMARY KEY (supplier_id, part_id, project_id));-- Problem: If the rules are:-- 1. Supplier S supplies Part P-- 2. Part P is used in Project J-- 3. Supplier S supplies to Project J-- Then (S, P, J) is implied by joins, storing it creates redundancyCorrect 5NF:
-- ✅ 5NF: Decompose to eliminate join dependenciesCREATE TABLE supplier_parts ( supplier_id INTEGER REFERENCES suppliers(id), part_id INTEGER REFERENCES parts(id), PRIMARY KEY (supplier_id, part_id));
CREATE TABLE part_projects ( part_id INTEGER REFERENCES parts(id), project_id INTEGER REFERENCES projects(id), PRIMARY KEY (part_id, project_id));
CREATE TABLE supplier_projects ( supplier_id INTEGER REFERENCES suppliers(id), project_id INTEGER REFERENCES projects(id), PRIMARY KEY (supplier_id, project_id));
-- Valid combinations can be reconstructed via:-- SELECT sp.supplier_id, sp.part_id, pp.project_id-- FROM supplier_parts sp-- JOIN part_projects pp ON sp.part_id = pp.part_id-- JOIN supplier_projects sproj ON sp.supplier_id = sproj.supplier_id-- AND pp.project_id = sproj.project_id;Benefits:
- No redundancy from implied relationships
- Each fact stored once
- Maximum flexibility for changes
When to Use: Rare in practice. Most systems stop at 3NF/BCNF.
1.8 Sixth Normal Form (6NF) / Domain-Key Normal Form (DKNF)
Definition: Decompose to eliminate all data redundancy. Every table has exactly one non-key attribute (or is all-key).
Purpose: Temporal databases, audit systems, bi-temporal data.
Example:
-- ✅ 6NF: Maximal decomposition for temporal dataCREATE TABLE employee_names ( employee_id INTEGER REFERENCES employees(id), name TEXT NOT NULL, valid_from TIMESTAMPTZ NOT NULL, valid_to TIMESTAMPTZ, PRIMARY KEY (employee_id, valid_from), CHECK (valid_to IS NULL OR valid_to > valid_from));
CREATE TABLE employee_salaries ( employee_id INTEGER REFERENCES employees(id), salary NUMERIC(10, 2) NOT NULL, valid_from TIMESTAMPTZ NOT NULL, valid_to TIMESTAMPTZ, PRIMARY KEY (employee_id, valid_from), CHECK (valid_to IS NULL OR valid_to > valid_from));
CREATE TABLE employee_departments ( employee_id INTEGER REFERENCES employees(id), department_id INTEGER REFERENCES departments(id), valid_from TIMESTAMPTZ NOT NULL, valid_to TIMESTAMPTZ, PRIMARY KEY (employee_id, valid_from), CHECK (valid_to IS NULL OR valid_to > valid_from));Benefits:
- Independent attribute history
- Update one attribute without affecting others
- Perfect for audit trails
Drawbacks:
- Many tables
- Complex queries
- Performance overhead
When to Use: Temporal databases, regulatory compliance, complete audit trails.
1.9 Denormalization: When to Break the Rules
Strategic Denormalization for performance when:
- Read-Heavy Workloads:
-- Denormalized for reportingCREATE MATERIALIZED VIEW order_summary ASSELECT o.id, o.order_date, c.name AS customer_name, c.email AS customer_email, COUNT(oi.product_id) AS item_count, SUM(oi.quantity * oi.unit_price) AS total_amountFROM orders oJOIN customers c ON o.customer_id = c.idJOIN order_items oi ON o.id = oi.order_idGROUP BY o.id, o.order_date, c.name, c.email;
CREATE UNIQUE INDEX ON order_summary(id);REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary;- Computed Aggregates:
-- Maintain aggregate for performanceCREATE TABLE orders ( id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id), order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), total_amount NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Denormalized sum item_count INTEGER NOT NULL DEFAULT 0 -- Denormalized count);
-- Keep denormalized data in sync with triggersCREATE OR REPLACE FUNCTION update_order_totals()RETURNS TRIGGER AS $$BEGIN UPDATE orders SET total_amount = ( SELECT COALESCE(SUM(quantity * unit_price), 0) FROM order_items WHERE order_id = NEW.order_id ), item_count = ( SELECT COUNT(*) FROM order_items WHERE order_id = NEW.order_id ) WHERE id = NEW.order_id; RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER maintain_order_totalsAFTER INSERT OR UPDATE OR DELETE ON order_itemsFOR EACH ROW EXECUTE FUNCTION update_order_totals();- Common Joins:
-- Cache frequently joined dataCREATE TABLE order_items ( order_id INTEGER REFERENCES orders(id), product_id INTEGER REFERENCES products(id), quantity INTEGER NOT NULL, unit_price NUMERIC(10, 2) NOT NULL, product_name TEXT NOT NULL, -- Denormalized from products PRIMARY KEY (order_id, product_id));Rules for Safe Denormalization:
- Document the denormalization and rationale
- Use triggers/application logic to maintain consistency
- Monitor for staleness
- Measure performance impact (before/after)
- Consider materialized views instead of manual denormalization
- Never denormalize without profiling first
2. PostgreSQL Design Patterns
2.1 Schema Design Principles
Core Guidelines:
- Start with 3NF, denormalize only with measurement
- Use appropriate data types (don’t use TEXT for everything)
- Add constraints at database level, not just application
- Use DOMAIN types for reusable constraints
- Leverage PostgreSQL-specific features (arrays, JSON, ranges, etc.)
Type Selection:
-- ✅ Use appropriate typesCREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email CITEXT NOT NULL UNIQUE, -- Case-insensitive email username VARCHAR(50) NOT NULL UNIQUE CHECK (username ~* '^[a-z0-9_]{3,50}$'), age SMALLINT CHECK (age BETWEEN 0 AND 150), balance NUMERIC(12, 2) NOT NULL DEFAULT 0 CHECK (balance >= 0), metadata JSONB, -- Flexible schema data ip_address INET, -- IP addresses active_period TSTZRANGE, -- Time ranges tags TEXT[], -- Arrays created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- ❌ Avoid generic typesCREATE TABLE users_bad ( id TEXT PRIMARY KEY, -- Should be UUID or BIGSERIAL email TEXT, -- Should be CITEXT age TEXT, -- Should be SMALLINT balance TEXT, -- Should be NUMERIC created_at TEXT -- Should be TIMESTAMPTZ);DOMAIN Types for Reusability:
-- Define reusable constrained typesCREATE DOMAIN email AS CITEXT CHECK (VALUE ~ '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
CREATE DOMAIN phone AS TEXT CHECK (VALUE ~ '^\+?[1-9]\d{1,14}$');
CREATE DOMAIN url AS TEXT CHECK (VALUE ~ '^https?://[^\s/$.?#].[^\s]*$');
CREATE DOMAIN slug AS VARCHAR(100) CHECK (VALUE ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$');
CREATE DOMAIN money AS NUMERIC(12, 2) CHECK (VALUE >= 0);
-- Use domainsCREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug slug UNIQUE NOT NULL, price money NOT NULL, website url);2.2 Indexing Strategies
Index Types:
B-Tree (Default) - Equality, range queries, sorting:
CREATE INDEX idx_users_email ON users(email);CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date DESC);CREATE INDEX idx_products_price ON products(price) WHERE active = true; -- Partial indexHash - Exact equality only (faster than B-tree for equality):
CREATE INDEX idx_users_uuid_hash ON users USING HASH(id);GIN (Generalized Inverted Index) - Full-text search, JSONB, arrays:
-- Full-text searchCREATE INDEX idx_posts_search ON posts USING GIN(to_tsvector('english', title || ' ' || body));
-- JSONBCREATE INDEX idx_users_metadata ON users USING GIN(metadata);CREATE INDEX idx_users_metadata_path ON users USING GIN(metadata jsonb_path_ops);
-- ArraysCREATE INDEX idx_posts_tags ON posts USING GIN(tags);GiST (Generalized Search Tree) - Geometric types, ranges, full-text:
-- RangesCREATE INDEX idx_bookings_period ON bookings USING GIST(period);
-- GeometricCREATE INDEX idx_locations_point ON locations USING GIST(coordinates);BRIN (Block Range Index) - Very large tables with natural ordering:
-- Time-series dataCREATE INDEX idx_events_created_brin ON events USING BRIN(created_at);Covering Indexes (INCLUDE):
-- Index includes extra columns for index-only scansCREATE INDEX idx_orders_customer_coveringON orders(customer_id) INCLUDE (order_date, total_amount);Multi-Column Index Order:
-- ✅ High cardinality first, query order matches indexCREATE INDEX idx_orders_customer_date_statusON orders(customer_id, order_date DESC, status);
-- Supports queries:-- WHERE customer_id = X-- WHERE customer_id = X AND order_date > Y-- WHERE customer_id = X AND order_date > Y AND status = Z
-- ❌ Low cardinality firstCREATE INDEX idx_orders_bad ON orders(status, customer_id);-- Less efficient for filteringPartial Indexes:
-- Index only active recordsCREATE INDEX idx_active_users ON users(email) WHERE active = true;
-- Index only recent dataCREATE INDEX idx_recent_orders ON orders(created_at)WHERE created_at > NOW() - INTERVAL '90 days';
-- Index only non-null valuesCREATE INDEX idx_users_phone ON users(phone) WHERE phone IS NOT NULL;Expression Indexes:
-- Index on computed expressionCREATE INDEX idx_users_lower_email ON users(LOWER(email));
-- Index on JSON fieldCREATE INDEX idx_users_subscription_levelON users((metadata->>'subscription_level'));
-- Trigram index for fuzzy searchCREATE EXTENSION pg_trgm;CREATE INDEX idx_products_name_trgm ON products USING GIN(name gin_trgm_ops);2.3 Constraints & Data Integrity
NOT NULL:
-- ✅ Be explicit about required fieldsCREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id), order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), status TEXT NOT NULL DEFAULT 'pending');CHECK Constraints:
CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL CHECK (LENGTH(name) > 0), price NUMERIC(10, 2) NOT NULL CHECK (price > 0), discount_pct NUMERIC(5, 2) CHECK (discount_pct BETWEEN 0 AND 100), stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), weight_kg NUMERIC(8, 3) CHECK (weight_kg > 0), published_at TIMESTAMPTZ, archived_at TIMESTAMPTZ, CHECK (archived_at IS NULL OR archived_at > published_at));UNIQUE Constraints:
-- Single columnCREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email CITEXT NOT NULL UNIQUE, username VARCHAR(50) NOT NULL UNIQUE);
-- Multi-column (composite unique)CREATE TABLE product_reviews ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), product_id UUID NOT NULL REFERENCES products(id), user_id UUID NOT NULL REFERENCES users(id), rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), UNIQUE(product_id, user_id) -- One review per product per user);
-- Partial unique constraintCREATE UNIQUE INDEX idx_users_username_activeON users(username) WHERE active = true;EXCLUSION Constraints:
-- Prevent overlapping time rangesCREATE EXTENSION btree_gist;
CREATE TABLE room_bookings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), room_id UUID NOT NULL REFERENCES rooms(id), guest_id UUID NOT NULL REFERENCES guests(id), period TSTZRANGE NOT NULL, EXCLUDE USING GIST (room_id WITH =, period WITH &&));
-- Prevent overlapping IP rangesCREATE TABLE ip_allocations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), network INET NOT NULL, allocated_to TEXT NOT NULL, EXCLUDE USING GIST (network inet_ops WITH &&));Foreign Keys with Actions:
CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, -- Cannot delete customer with orders);
CREATE TABLE order_items ( order_id UUID REFERENCES orders(id) ON DELETE CASCADE, -- Delete items when order is deleted product_id UUID REFERENCES products(id) ON DELETE RESTRICT, -- Cannot delete product that's been ordered PRIMARY KEY (order_id, product_id));
CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- Keep audit log but clear user reference action TEXT NOT NULL);2.4 Partitioning
Range Partitioning (time-series, numeric ranges):
-- Parent tableCREATE TABLE events ( id UUID DEFAULT gen_random_uuid(), user_id UUID NOT NULL, event_type TEXT NOT NULL, payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, created_at)) PARTITION BY RANGE (created_at);
-- Create partitionsCREATE TABLE events_2024_01 PARTITION OF events FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE events_2024_02 PARTITION OF events FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE events_2024_03 PARTITION OF events FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- Indexes on partitionsCREATE INDEX ON events_2024_01(user_id);CREATE INDEX ON events_2024_02(user_id);CREATE INDEX ON events_2024_03(user_id);
-- Automatic partition creation (requires extension)CREATE EXTENSION pg_partman;SELECT partman.create_parent( 'public.events', 'created_at', 'native', 'monthly');List Partitioning (discrete values):
CREATE TABLE orders ( id UUID DEFAULT gen_random_uuid(), customer_id UUID NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, status)) PARTITION BY LIST (status);
CREATE TABLE orders_pending PARTITION OF orders FOR VALUES IN ('pending', 'processing');
CREATE TABLE orders_completed PARTITION OF orders FOR VALUES IN ('completed', 'delivered');
CREATE TABLE orders_cancelled PARTITION OF orders FOR VALUES IN ('cancelled', 'refunded');Hash Partitioning (uniform distribution):
CREATE TABLE user_events ( id UUID DEFAULT gen_random_uuid(), user_id UUID NOT NULL, event_data JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, user_id)) PARTITION BY HASH (user_id);
CREATE TABLE user_events_0 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_events_1 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_events_2 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_events_3 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 3);Partition Maintenance:
-- Detach old partitionALTER TABLE events DETACH PARTITION events_2023_01;
-- Archive to separate tablespaceALTER TABLE events_2023_01 SET TABLESPACE archive;
-- Drop old partitionsDROP TABLE events_2022_01;
-- Attach pre-created partitionCREATE TABLE events_2024_12 (LIKE events INCLUDING ALL);ALTER TABLE events ATTACH PARTITION events_2024_12 FOR VALUES FROM ('2024-12-01') TO ('2025-01-01');2.5 Materialized Views
Basic Materialized View:
CREATE MATERIALIZED VIEW daily_sales_summary ASSELECT DATE_TRUNC('day', o.created_at) AS sale_date, COUNT(DISTINCT o.id) AS order_count, COUNT(DISTINCT o.customer_id) AS unique_customers, SUM(oi.quantity * oi.unit_price) AS total_revenue, AVG(oi.quantity * oi.unit_price) AS avg_order_valueFROM orders oJOIN order_items oi ON o.id = oi.order_idWHERE o.status = 'completed'GROUP BY DATE_TRUNC('day', o.created_at);
CREATE UNIQUE INDEX ON daily_sales_summary(sale_date);
-- Refresh (blocking)REFRESH MATERIALIZED VIEW daily_sales_summary;
-- Refresh concurrently (requires unique index, non-blocking)REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_summary;Incremental Refresh Pattern:
-- Track last refreshCREATE TABLE mv_refresh_log ( view_name TEXT PRIMARY KEY, last_refresh TIMESTAMPTZ NOT NULL);
-- Materialized view with incremental dataCREATE MATERIALIZED VIEW user_activity_summary ASSELECT user_id, COUNT(*) AS event_count, MAX(created_at) AS last_event_at, MIN(created_at) AS first_event_atFROM eventsWHERE created_at > COALESCE( (SELECT last_refresh FROM mv_refresh_log WHERE view_name = 'user_activity_summary'), '1970-01-01'::TIMESTAMPTZ)GROUP BY user_id;
-- Refresh procedureCREATE OR REPLACE FUNCTION refresh_user_activity()RETURNS VOID AS $$BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY user_activity_summary;
INSERT INTO mv_refresh_log (view_name, last_refresh) VALUES ('user_activity_summary', NOW()) ON CONFLICT (view_name) DO UPDATE SET last_refresh = NOW();END;$$ LANGUAGE plpgsql;
-- Schedule with pg_cronCREATE EXTENSION pg_cron;SELECT cron.schedule('refresh-user-activity', '*/15 * * * *', 'SELECT refresh_user_activity()');2.6 Triggers and Stored Procedures
Audit Trail Trigger:
-- Audit tableCREATE TABLE audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), table_name TEXT NOT NULL, record_id UUID NOT NULL, action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')), old_data JSONB, new_data JSONB, changed_by UUID REFERENCES users(id), changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Generic audit trigger functionCREATE OR REPLACE FUNCTION audit_trigger_func()RETURNS TRIGGER AS $$BEGIN IF (TG_OP = 'DELETE') THEN INSERT INTO audit_log (table_name, record_id, action, old_data) VALUES (TG_TABLE_NAME, OLD.id, TG_OP, row_to_json(OLD)); RETURN OLD; ELSIF (TG_OP = 'UPDATE') THEN INSERT INTO audit_log (table_name, record_id, action, old_data, new_data) VALUES (TG_TABLE_NAME, NEW.id, TG_OP, row_to_json(OLD), row_to_json(NEW)); RETURN NEW; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO audit_log (table_name, record_id, action, new_data) VALUES (TG_TABLE_NAME, NEW.id, TG_OP, row_to_json(NEW)); RETURN NEW; END IF;END;$$ LANGUAGE plpgsql;
-- Apply to tablesCREATE TRIGGER audit_usersAFTER INSERT OR UPDATE OR DELETE ON usersFOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
CREATE TRIGGER audit_ordersAFTER INSERT OR UPDATE OR DELETE ON ordersFOR EACH ROW EXECUTE FUNCTION audit_trigger_func();Updated At Trigger:
CREATE OR REPLACE FUNCTION update_updated_at()RETURNS TRIGGER AS $$BEGIN NEW.updated_at = NOW(); RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_atBEFORE UPDATE ON usersFOR EACH ROW EXECUTE FUNCTION update_updated_at();Soft Delete Pattern:
CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, price NUMERIC(10, 2) NOT NULL, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Prevent hard deletesCREATE OR REPLACE FUNCTION prevent_delete()RETURNS TRIGGER AS $$BEGIN RAISE EXCEPTION 'Hard deletes are not allowed. Use soft delete.';END;$$ LANGUAGE plpgsql;
CREATE TRIGGER prevent_product_deleteBEFORE DELETE ON productsFOR EACH ROW EXECUTE FUNCTION prevent_delete();
-- Soft delete functionCREATE OR REPLACE FUNCTION soft_delete_product(product_id UUID)RETURNS VOID AS $$BEGIN UPDATE products SET deleted_at = NOW() WHERE id = product_id AND deleted_at IS NULL;END;$$ LANGUAGE plpgsql;
-- View for active productsCREATE VIEW active_products ASSELECT * FROM products WHERE deleted_at IS NULL;Cascading Status Updates:
-- Update order status based on all items shippedCREATE OR REPLACE FUNCTION check_order_completion()RETURNS TRIGGER AS $$DECLARE all_shipped BOOLEAN;BEGIN SELECT NOT EXISTS( SELECT 1 FROM order_items WHERE order_id = NEW.order_id AND status != 'shipped' ) INTO all_shipped;
IF all_shipped THEN UPDATE orders SET status = 'completed', completed_at = NOW() WHERE id = NEW.order_id; END IF;
RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER order_item_shippedAFTER UPDATE OF status ON order_itemsFOR EACH ROWWHEN (NEW.status = 'shipped')EXECUTE FUNCTION check_order_completion();2.7 Advanced Querying Patterns
CTEs (Common Table Expressions):
-- Recursive CTE for hierarchical dataWITH RECURSIVE category_tree AS ( -- Base case: root categories SELECT id, name, parent_id, name AS path, 0 AS depth FROM categories WHERE parent_id IS NULL
UNION ALL
-- Recursive case: child categories SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name AS path, ct.depth + 1 AS depth FROM categories c JOIN category_tree ct ON c.parent_id = ct.id)SELECT * FROM category_treeORDER BY path;
-- Multiple CTEs for complex queriesWITHmonthly_sales AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status = 'completed' GROUP BY DATE_TRUNC('month', order_date)),monthly_growth AS ( SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue, (revenue - LAG(revenue) OVER (ORDER BY month)) / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100 AS growth_pct FROM monthly_sales)SELECT month, revenue, prev_month_revenue, ROUND(growth_pct, 2) AS growth_percentageFROM monthly_growthWHERE prev_month_revenue IS NOT NULLORDER BY month DESC;Window Functions:
-- Running totals and rankingsSELECT o.order_date, o.total_amount, SUM(o.total_amount) OVER (ORDER BY o.order_date) AS running_total, AVG(o.total_amount) OVER ( ORDER BY o.order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS moving_avg_7day, RANK() OVER (PARTITION BY DATE_TRUNC('month', o.order_date) ORDER BY o.total_amount DESC) AS monthly_rank, NTILE(4) OVER (ORDER BY o.total_amount) AS quartileFROM orders oWHERE o.status = 'completed'ORDER BY o.order_date;
-- Top N per groupSELECT *FROM ( SELECT p.*, ROW_NUMBER() OVER (PARTITION BY p.category_id ORDER BY p.sales DESC) AS rn FROM products p) rankedWHERE rn <= 5;LATERAL Joins:
-- Get latest order for each customerSELECT c.id, c.name, latest.order_id, latest.order_date, latest.total_amountFROM customers cLEFT JOIN LATERAL ( SELECT id AS order_id, order_date, total_amount FROM orders WHERE customer_id = c.id ORDER BY order_date DESC LIMIT 1) latest ON true;
-- Top 3 products per category with detailsSELECT cat.name AS category_name, top_products.*FROM categories catLEFT JOIN LATERAL ( SELECT p.id, p.name, p.price, p.sales FROM products p WHERE p.category_id = cat.id ORDER BY p.sales DESC LIMIT 3) top_products ON true;JSONB Queries:
-- Query JSONB fieldsSELECT id, metadata->>'subscription_level' AS subscription, metadata->'preferences'->>'theme' AS theme, jsonb_array_elements_text(metadata->'tags') AS tagFROM usersWHERE metadata @> '{"subscription_level": "premium"}' AND metadata ? 'preferences' AND jsonb_typeof(metadata->'tags') = 'array';
-- Update JSONB fieldsUPDATE usersSET metadata = jsonb_set( metadata, '{preferences, notifications}', 'true'::jsonb, true)WHERE id = 'some-uuid';
-- Remove JSONB keyUPDATE usersSET metadata = metadata - 'old_key'WHERE id = 'some-uuid';2.8 Performance Optimization
EXPLAIN ANALYZE:
-- Always profile before optimizingEXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)SELECT c.name, COUNT(o.id) AS order_countFROM customers cLEFT JOIN orders o ON c.id = o.customer_idWHERE c.created_at > NOW() - INTERVAL '1 year'GROUP BY c.id, c.nameHAVING COUNT(o.id) > 10ORDER BY order_count DESC;
-- Look for:-- 1. Sequential Scans on large tables (add indexes)-- 2. High actual time vs planned time (statistics out of date)-- 3. High buffer reads (add indexes, optimize queries)-- 4. Nested loops with high row counts (rewrite join)Query Optimization Checklist:
-- ✅ Use covering indexesCREATE INDEX idx_orders_customer_summaryON orders(customer_id, status) INCLUDE (order_date, total_amount);
-- ✅ Avoid SELECT *SELECT id, name, email FROM users; -- GoodSELECT * FROM users; -- Bad (retrieves unnecessary columns)
-- ✅ Use LIMIT for paginationSELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- ✅ Filter early in CTEsWITH recent_orders AS ( SELECT * FROM orders WHERE order_date > NOW() - INTERVAL '30 days' -- Filter early)SELECT * FROM recent_orders WHERE status = 'completed';
-- ❌ Don't filter lateWITH all_orders AS ( SELECT * FROM orders -- Loads everything)SELECT * FROM all_ordersWHERE order_date > NOW() - INTERVAL '30 days' -- Too late AND status = 'completed';Connection Pooling (application-side with pgbouncer):
[databases]mydb = host=localhost dbname=mydb
[pgbouncer]listen_port = 6432listen_addr = *auth_type = md5auth_file = /etc/pgbouncer/userlist.txtpool_mode = transactionmax_client_conn = 1000default_pool_size = 25reserve_pool_size = 5reserve_pool_timeout = 3Vacuuming and Maintenance:
-- Autovacuum configuration (postgresql.conf)autovacuum = onautovacuum_max_workers = 4autovacuum_vacuum_threshold = 50autovacuum_vacuum_scale_factor = 0.1autovacuum_analyze_threshold = 50autovacuum_analyze_scale_factor = 0.05
-- Manual vacuum for maintenance windowsVACUUM ANALYZE VERBOSE;
-- Aggressive vacuum to reclaim spaceVACUUM FULL ANALYZE users; -- Locks table, use sparingly
-- Update statisticsANALYZE users;ANALYZE orders;
-- Check bloatSELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size, n_tup_upd + n_tup_del AS modifications, last_vacuum, last_autovacuumFROM pg_stat_user_tablesORDER BY modifications DESC;3. Redis Design Patterns
3.1 Overview
Redis Use Cases:
- Cache Layer: Session storage, query results, computed data
- Real-Time Analytics: Counters, rate limiting, leaderboards
- Message Broker: Pub/Sub, task queues, event streaming
- Distributed Lock: Coordination across services
- Geospatial: Location-based queries
Key Characteristics:
- In-memory (fast reads/writes)
- Single-threaded (atomic operations)
- Data structures (not just key-value)
- Persistence optional (RDB snapshots, AOF log)
3.2 Data Structure Patterns
Strings - Simple values, counters, bitmaps:
# Basic key-valueSET user:1000:name "Alice"GET user:1000:name
# With expiration (session storage)SETEX session:abc123 3600 '{"user_id": 1000, "role": "admin"}'
# Atomic counterINCR page:views:homepageINCRBY product:1000:stock -5
# Bit operations for flagsSETBIT user:1000:features 0 1 # Enable feature flag 0GETBIT user:1000:features 0 # Check feature flag 0BITCOUNT user:1000:features # Count enabled featuresHashes - Objects, structured data:
# Store user objectHSET user:1000 name "Alice" email "alice@example.com" age 30HGETALL user:1000HGET user:1000 emailHINCRBY user:1000 age 1
# Partial updatesHMSET product:2000 name "Laptop" price 999.99 stock 50HGET product:2000 priceHINCRBY product:2000 stock -1Lists - Queues, activity feeds, recent items:
# Task queue (FIFO)LPUSH queue:emails '{"to": "user@example.com", "subject": "Welcome"}'BRPOP queue:emails 0 # Blocking pop (worker pattern)
# Recent activity (capped list)LPUSH user:1000:recent_views product:5000LTRIM user:1000:recent_views 0 49 # Keep only 50 itemsLRANGE user:1000:recent_views 0 9 # Get 10 most recent
# Stack (LIFO)LPUSH stack:undo "action1"LPOP stack:undoSets - Unique collections, tags, relationships:
# TagsSADD post:100:tags "redis" "database" "nosql"SMEMBERS post:100:tagsSISMEMBER post:100:tags "redis" # Check membership
# Set operationsSADD user:1000:following user:2000 user:3000 user:4000SADD user:2000:following user:1000 user:3000 user:5000SINTER user:1000:following user:2000:following # Mutual followsSDIFF user:1000:following user:2000:following # Follows 1000 but not 2000SUNION user:1000:following user:2000:following # All follows
# Random elementsSRANDMEMBER recommendations:products 5 # Get 5 random recommendationsSorted Sets - Leaderboards, priority queues, time-based data:
# LeaderboardZADD leaderboard:global 1500 "player1" 1200 "player2" 1800 "player3"ZREVRANGE leaderboard:global 0 9 WITHSCORES # Top 10ZRANK leaderboard:global "player1" # Player rankZINCRBY leaderboard:global 100 "player1" # Add score
# Time-series (score = timestamp)ZADD events:user:1000 1704067200 "login" 1704070800 "purchase"ZRANGEBYSCORE events:user:1000 1704067200 1704153600 # Events in time rangeZREMRANGEBYSCORE events:user:1000 0 1672531200 # Delete old events
# Priority queueZADD tasks:priority 1 "urgent-task" 5 "normal-task" 10 "low-priority-task"ZPOPMIN tasks:priority # Get highest priority taskStreams - Event log, message queue, time-series:
# Add to streamXADD events:orders * user_id 1000 product_id 5000 amount 99.99
# Read from streamXREAD COUNT 10 STREAMS events:orders 0-0 # Read from beginningXREAD COUNT 10 BLOCK 5000 STREAMS events:orders $ # Block for new messages
# Consumer groups (multiple consumers)XGROUP CREATE events:orders order-processor $ MKSTREAMXREADGROUP GROUP order-processor consumer1 COUNT 1 STREAMS events:orders >XACK events:orders order-processor <message-id>
# TrimmingXTRIM events:orders MAXLEN ~ 10000 # Keep ~10k messages3.3 Caching Patterns
Cache-Aside (Lazy Loading):
def get_user(user_id): # Try cache first cache_key = f"user:{user_id}" cached = redis.get(cache_key)
if cached: return json.loads(cached)
# Cache miss - load from database user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
# Store in cache with TTL redis.setex(cache_key, 3600, json.dumps(user))
return user
def update_user(user_id, data): # Update database db.execute("UPDATE users SET ... WHERE id = %s", (user_id,))
# Invalidate cache redis.delete(f"user:{user_id}")Write-Through:
def update_user(user_id, data): # Update database db.execute("UPDATE users SET ... WHERE id = %s", (user_id,))
# Update cache immediately cache_key = f"user:{user_id}" user = db.query("SELECT * FROM users WHERE id = %s", (user_id,)) redis.setex(cache_key, 3600, json.dumps(user))Write-Behind (Write-Back):
def update_user(user_id, data): # Update cache immediately cache_key = f"user:{user_id}" redis.hset(cache_key, mapping=data)
# Queue for async database write redis.lpush("write_queue:users", json.dumps({"id": user_id, "data": data}))
# Background workerdef write_worker(): while True: _, payload = redis.brpop("write_queue:users", timeout=5) if payload: data = json.loads(payload) db.execute("UPDATE users SET ... WHERE id = %s", (data["id"],))Cache Stampede Prevention (Locking):
import time
def get_expensive_data(key): cache_key = f"data:{key}" lock_key = f"lock:{key}"
# Try cache cached = redis.get(cache_key) if cached: return json.loads(cached)
# Acquire lock lock_acquired = redis.set(lock_key, "1", nx=True, ex=10)
if lock_acquired: try: # Compute expensive data data = compute_expensive_data(key) redis.setex(cache_key, 3600, json.dumps(data)) return data finally: redis.delete(lock_key) else: # Another process is computing, wait and retry time.sleep(0.1) return get_expensive_data(key)3.4 Rate Limiting
Fixed Window:
def rate_limit_fixed_window(user_id, limit=100, window=60): key = f"rate_limit:{user_id}:{int(time.time() // window)}" current = redis.incr(key)
if current == 1: redis.expire(key, window)
return current <= limitSliding Window Log:
def rate_limit_sliding_log(user_id, limit=100, window=60): key = f"rate_limit:log:{user_id}" now = time.time() window_start = now - window
# Remove old entries redis.zremrangebyscore(key, 0, window_start)
# Count requests in window count = redis.zcard(key)
if count < limit: # Add current request redis.zadd(key, {str(now): now}) redis.expire(key, window) return True
return FalseToken Bucket:
-- token_bucket.lualocal key = KEYS[1]local capacity = tonumber(ARGV[1])local refill_rate = tonumber(ARGV[2])local now = tonumber(ARGV[3])
local tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity)local last_refill = tonumber(redis.call('HGET', key, 'last_refill') or now)
-- Refill tokenslocal elapsed = now - last_refilllocal refilled = math.floor(elapsed * refill_rate)tokens = math.min(capacity, tokens + refilled)
if tokens >= 1 then tokens = tokens - 1 redis.call('HSET', key, 'tokens', tokens) redis.call('HSET', key, 'last_refill', now) redis.call('EXPIRE', key, math.ceil(capacity / refill_rate)) return 1else return 0end# Usagescript_sha = redis.script_load(token_bucket_lua)allowed = redis.evalsha(script_sha, 1, f"bucket:{user_id}", 100, 10, time.time())3.5 Pub/Sub Patterns
Basic Pub/Sub:
# Publisherredis.publish("notifications", json.dumps({ "type": "new_message", "user_id": 1000, "message": "Hello"}))
# Subscriberpubsub = redis.pubsub()pubsub.subscribe("notifications")
for message in pubsub.listen(): if message['type'] == 'message': data = json.loads(message['data']) process_notification(data)Pattern Subscriptions:
# Subscribe to multiple channels with patternpubsub = redis.pubsub()pubsub.psubscribe("user:*:notifications")
for message in pubsub.listen(): if message['type'] == 'pmessage': channel = message['channel'] # e.g., "user:1000:notifications" data = json.loads(message['data']) process_notification(channel, data)Fan-Out Pattern:
# Notify all connected clientsdef notify_all(event_type, payload): redis.publish("broadcast", json.dumps({ "event": event_type, "data": payload, "timestamp": time.time() }))3.6 Distributed Locking
Redlock Algorithm:
import uuidimport time
def acquire_lock(lock_name, timeout=10): identifier = str(uuid.uuid4()) lock_key = f"lock:{lock_name}"
# SET with NX (only if not exists) and EX (expiration) acquired = redis.set(lock_key, identifier, nx=True, ex=timeout)
return identifier if acquired else None
def release_lock(lock_name, identifier): lock_key = f"lock:{lock_name}"
# Lua script for atomic check-and-delete lua_script = """ if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end """
return redis.eval(lua_script, 1, lock_key, identifier)
# Usagelock_id = acquire_lock("process:critical_section")if lock_id: try: # Critical section perform_critical_operation() finally: release_lock("process:critical_section", lock_id)else: # Lock not acquired, handle accordingly passLock with Auto-Renewal:
import threading
class AutoRenewLock: def __init__(self, redis_client, lock_name, timeout=10): self.redis = redis_client self.lock_name = lock_name self.timeout = timeout self.identifier = str(uuid.uuid4()) self.renew_thread = None self.stop_renew = threading.Event()
def acquire(self): lock_key = f"lock:{self.lock_name}" acquired = self.redis.set(lock_key, self.identifier, nx=True, ex=self.timeout)
if acquired: # Start renewal thread self.renew_thread = threading.Thread(target=self._renew_lock) self.renew_thread.daemon = True self.renew_thread.start() return True
return False
def _renew_lock(self): while not self.stop_renew.is_set(): time.sleep(self.timeout / 2) # Renew at half the timeout lock_key = f"lock:{self.lock_name}"
lua_script = """ if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("EXPIRE", KEYS[1], ARGV[2]) else return 0 end """
renewed = self.redis.eval(lua_script, 1, lock_key, self.identifier, self.timeout) if not renewed: break
def release(self): self.stop_renew.set() if self.renew_thread: self.renew_thread.join(timeout=1)
lock_key = f"lock:{self.lock_name}" lua_script = """ if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end """ self.redis.eval(lua_script, 1, lock_key, self.identifier)3.7 Persistence Configuration
RDB (Snapshotting):
save 900 1 # Save if 1 key changed in 900ssave 300 10 # Save if 10 keys changed in 300ssave 60 10000 # Save if 10000 keys changed in 60s
dbfilename dump.rdbdir /var/lib/redis
# Compressionrdbcompression yesrdbchecksum yesAOF (Append-Only File):
appendonly yesappendfilename "appendonly.aof"
# Fsync policyappendfsync everysec # Good balance (default)# appendfsync always # Slowest, safest# appendfsync no # Fastest, least safe
# AOF rewriteauto-aof-rewrite-percentage 100auto-aof-rewrite-min-size 64mbHybrid Persistence (RDB + AOF):
# Use both for maximum durabilitysave 900 1appendonly yesappendfsync everysec
# Load AOF on startup if available (more complete)aof-use-rdb-preamble yes4. MongoDB Design Patterns
4.1 Overview
MongoDB Use Cases:
- Document Storage: Content management, catalogs, user profiles
- Real-Time Analytics: Event tracking, metrics aggregation
- Flexible Schema: Rapid prototyping, evolving data models
- Hierarchical Data: Nested structures, trees, graphs
Key Characteristics:
- Document-oriented (BSON format)
- Schema-less (flexible structure)
- Horizontal scaling (sharding)
- Rich query language
- Secondary indexes
4.2 Document Modeling
Embedding vs. Referencing:
Embed When (One-to-Few, One-to-Many with bounded growth):
// ✅ Embed comments (bounded, accessed together){ "_id": ObjectId("..."), "title": "MongoDB Design Patterns", "author": "Alice", "content": "...", "comments": [ { "author": "Bob", "text": "Great article!", "date": ISODate("2024-01-07T10:00:00Z") }, { "author": "Charlie", "text": "Very helpful", "date": ISODate("2024-01-07T11:00:00Z") } ], "created_at": ISODate("2024-01-07T09:00:00Z")}Reference When (One-to-Many unbounded, Many-to-Many):
// ✅ Reference users (unbounded, independent access)// Users collection{ "_id": ObjectId("user_123"), "name": "Alice", "email": "alice@example.com"}
// Posts collection{ "_id": ObjectId("post_456"), "title": "MongoDB Design", "author_id": ObjectId("user_123"), // Reference "content": "..."}
// Comments collection (unbounded growth){ "_id": ObjectId("comment_789"), "post_id": ObjectId("post_456"), // Reference "author_id": ObjectId("user_123"), // Reference "text": "Great post!", "created_at": ISODate("2024-01-07T10:00:00Z")}Hybrid Approach (Embed summary, reference full):
// ✅ Embed author summary, reference for full profile{ "_id": ObjectId("post_456"), "title": "MongoDB Design", "author": { "id": ObjectId("user_123"), "name": "Alice", "avatar_url": "https://..." }, "content": "...", "stats": { "views": 1500, "likes": 42, "comment_count": 10 }}4.3 Schema Patterns
Polymorphic Pattern (Multiple types in one collection):
// Products with different types{ "_id": ObjectId("..."), "type": "book", "name": "MongoDB Patterns", "price": 29.99, // Book-specific fields "isbn": "978-1234567890", "author": "Alice Smith", "pages": 350}
{ "_id": ObjectId("..."), "type": "electronics", "name": "Laptop", "price": 999.99, // Electronics-specific fields "brand": "TechCo", "warranty_months": 24, "specs": { "cpu": "Intel i7", "ram_gb": 16 }}
// Query by typedb.products.find({ type: "book" })db.products.createIndex({ type: 1, name: 1 })Attribute Pattern (Sparse, varying attributes):
// Instead of flat fields that are mostly null// ❌ Bad (sparse){ "_id": ObjectId("..."), "name": "Product", "color": "red", "size": null, "weight": null, "material": "plastic", "voltage": null}
// ✅ Good (attribute array){ "_id": ObjectId("..."), "name": "Product", "attributes": [ { "k": "color", "v": "red" }, { "k": "material", "v": "plastic" } ]}
// Index on attributesdb.products.createIndex({ "attributes.k": 1, "attributes.v": 1 })
// Querydb.products.find({ attributes: { $elemMatch: { k: "color", v: "red" } } })Bucket Pattern (Time-series, IoT data):
// Instead of one document per reading// ❌ Bad (millions of documents){ "_id": ObjectId("..."), "sensor_id": "sensor_001", "temperature": 22.5, "timestamp": ISODate("2024-01-07T10:00:00Z")}
// ✅ Good (bucketed by hour){ "_id": ObjectId("..."), "sensor_id": "sensor_001", "date": ISODate("2024-01-07T10:00:00Z"), // Start of hour "measurements": [ { "ts": ISODate("2024-01-07T10:00:15Z"), "temp": 22.5 }, { "ts": ISODate("2024-01-07T10:01:15Z"), "temp": 22.7 }, { "ts": ISODate("2024-01-07T10:02:15Z"), "temp": 22.6 } // ... up to 3600 readings per hour ], "summary": { "count": 3, "avg_temp": 22.6, "min_temp": 22.5, "max_temp": 22.7 }}
// Indexdb.sensor_data.createIndex({ sensor_id: 1, date: 1 })Computed Pattern (Pre-computed aggregations):
// Real-time updates with pre-computed stats{ "_id": ObjectId("..."), "product_id": ObjectId("prod_123"), "reviews": [ { "user_id": ObjectId("..."), "rating": 5, "text": "Excellent!" }, { "user_id": ObjectId("..."), "rating": 4, "text": "Good" } ], "stats": { "total_reviews": 2, "avg_rating": 4.5, "rating_distribution": { "5": 1, "4": 1, "3": 0, "2": 0, "1": 0 } }, "last_updated": ISODate("2024-01-07T10:00:00Z")}
// Update with computed statsdb.products.updateOne( { _id: ObjectId("...") }, { $push: { reviews: newReview }, $inc: { "stats.total_reviews": 1, [`stats.rating_distribution.${newReview.rating}`]: 1 }, $set: { last_updated: new Date() } })
// Recalculate avg_rating periodically or with change streamsOutlier Pattern (Handle rare large documents separately):
// Most orders have few items{ "_id": ObjectId("order_123"), "customer_id": ObjectId("..."), "items": [ { "product_id": ObjectId("..."), "qty": 2, "price": 29.99 }, { "product_id": ObjectId("..."), "qty": 1, "price": 49.99 } ], "total": 109.97}
// Large orders (outliers) split into separate documents{ "_id": ObjectId("order_456"), "customer_id": ObjectId("..."), "has_overflow": true, "items": [/* first 100 items */], "total": 5499.99}
// Overflow documents{ "_id": ObjectId("order_456_overflow_1"), "parent_order_id": ObjectId("order_456"), "items": [/* next 100 items */]}4.4 Indexing Strategies
Single Field Index:
db.users.createIndex({ email: 1 }) // Ascendingdb.products.createIndex({ price: -1 }) // DescendingCompound Index:
// Order matters for query supportdb.orders.createIndex({ customer_id: 1, order_date: -1, status: 1 })
// Supports:// { customer_id: X }// { customer_id: X, order_date: Y }// { customer_id: X, order_date: Y, status: Z }
// Does NOT support:// { order_date: Y } // Missing prefix (customer_id)// { status: Z } // Missing prefixMultikey Index (Arrays):
// Index array elementsdb.posts.createIndex({ tags: 1 })
// Querydb.posts.find({ tags: "mongodb" }) // Matches if "mongodb" in tags arrayText Index (Full-text search):
// Create text indexdb.articles.createIndex({ title: "text", content: "text"})
// Searchdb.articles.find({ $text: { $search: "mongodb design patterns" } })
// With scoredb.articles.find( { $text: { $search: "mongodb design patterns" } }, { score: { $meta: "textScore" } }).sort({ score: { $meta: "textScore" } })Geospatial Index:
// 2dsphere for Earth-like geometrydb.locations.createIndex({ coordinates: "2dsphere" })
// Store locationdb.locations.insertOne({ name: "Coffee Shop", coordinates: { type: "Point", coordinates: [-73.97, 40.77] // [longitude, latitude] }})
// Query nearbydb.locations.find({ coordinates: { $near: { $geometry: { type: "Point", coordinates: [-73.98, 40.76] }, $maxDistance: 1000 // meters } }})Partial Index (Filtered):
// Index only active usersdb.users.createIndex( { email: 1 }, { partialFilterExpression: { active: true } })
// Index only premium subscriptionsdb.users.createIndex( { subscription_end_date: 1 }, { partialFilterExpression: { subscription_level: "premium" } })TTL Index (Auto-expiration):
// Expire documents after 30 daysdb.sessions.createIndex( { created_at: 1 }, { expireAfterSeconds: 2592000 } // 30 days)
// Document will be deleted when created_at + 30 days < now4.5 Aggregation Pipeline
Basic Aggregation:
// Group and aggregatedb.orders.aggregate([ { $match: { status: "completed" } }, { $group: { _id: "$customer_id", total_orders: { $sum: 1 }, total_spent: { $sum: "$total_amount" }, avg_order_value: { $avg: "$total_amount" } }}, { $sort: { total_spent: -1 } }, { $limit: 10 }])Lookup (Join):
// Left join orders with customersdb.orders.aggregate([ { $lookup: { from: "customers", localField: "customer_id", foreignField: "_id", as: "customer" } }, { $unwind: "$customer" }, { $project: { order_id: "$_id", order_date: 1, total_amount: 1, customer_name: "$customer.name", customer_email: "$customer.email" } }])Faceted Search:
// Multiple aggregations in one querydb.products.aggregate([ { $facet: { // Price ranges "price_ranges": [ { $bucket: { groupBy: "$price", boundaries: [0, 50, 100, 500, 1000, 5000], default: "Other", output: { count: { $sum: 1 }, avg_price: { $avg: "$price" } } } } ], // Top categories "categories": [ { $sortByCount: "$category" }, { $limit: 5 } ], // Rating distribution "ratings": [ { $group: { _id: { $floor: "$rating" }, count: { $sum: 1 } } }, { $sort: { _id: -1 } } ] } }])Time-Series Aggregation:
// Daily sales summarydb.orders.aggregate([ { $match: { order_date: { $gte: ISODate("2024-01-01"), $lt: ISODate("2024-02-01") } }}, { $group: { _id: { year: { $year: "$order_date" }, month: { $month: "$order_date" }, day: { $dayOfMonth: "$order_date" } }, daily_revenue: { $sum: "$total_amount" }, order_count: { $sum: 1 }, unique_customers: { $addToSet: "$customer_id" } } }, { $project: { date: { $dateFromParts: { year: "$_id.year", month: "$_id.month", day: "$_id.day" } }, daily_revenue: 1, order_count: 1, customer_count: { $size: "$unique_customers" } } }, { $sort: { date: 1 } }])5. Streaming Patterns
5.1 PostgreSQL Logical Replication & Change Data Capture
Logical Replication Setup:
-- On primary server (publisher)ALTER SYSTEM SET wal_level = 'logical';ALTER SYSTEM SET max_replication_slots = 10;-- Restart PostgreSQL
-- Create publicationCREATE PUBLICATION my_publication FOR TABLE users, orders, products;
-- Or publish all tablesCREATE PUBLICATION all_tables FOR ALL TABLES;
-- On replica server (subscriber)CREATE SUBSCRIPTION my_subscriptionCONNECTION 'host=primary.example.com port=5432 dbname=mydb user=repl_user password=...'PUBLICATION my_publication;
-- Check replication statusSELECT * FROM pg_stat_subscription;SELECT * FROM pg_replication_slots;CDC with Debezium (Kafka Connect):
{ "name": "postgres-connector", "config": { "connector.class": "io.debezium.connector.postgresql.PostgresConnector", "database.hostname": "localhost", "database.port": "5432", "database.user": "debezium", "database.password": "secret", "database.dbname": "mydb", "database.server.name": "mydb", "table.include.list": "public.users,public.orders", "plugin.name": "pgoutput", "publication.name": "dbz_publication", "slot.name": "dbz_slot" }}Change Stream Consumer (Python):
from kafka import KafkaConsumerimport json
consumer = KafkaConsumer( 'mydb.public.users', 'mydb.public.orders', bootstrap_servers=['localhost:9092'], value_deserializer=lambda m: json.loads(m.decode('utf-8')), group_id='cdc-processor')
for message in consumer: event = message.value
if event['op'] == 'c': # Create handle_insert(event['after']) elif event['op'] == 'u': # Update handle_update(event['before'], event['after']) elif event['op'] == 'd': # Delete handle_delete(event['before'])Logical Decoding with pg_recvlogical:
# Create replication slotpsql -c "SELECT * FROM pg_create_logical_replication_slot('my_slot', 'test_decoding');"
# Stream changespg_recvlogical -d mydb --slot my_slot --start -f - | while read line; do echo "Change: $line" # Process change eventdonePostgreSQL LISTEN/NOTIFY:
-- Trigger for notificationsCREATE OR REPLACE FUNCTION notify_order_changes()RETURNS TRIGGER AS $$BEGIN IF TG_OP = 'INSERT' THEN PERFORM pg_notify('order_channel', json_build_object( 'operation', 'INSERT', 'order_id', NEW.id, 'customer_id', NEW.customer_id, 'amount', NEW.total_amount )::text); ELSIF TG_OP = 'UPDATE' THEN PERFORM pg_notify('order_channel', json_build_object( 'operation', 'UPDATE', 'order_id', NEW.id, 'old_status', OLD.status, 'new_status', NEW.status )::text); END IF; RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER order_changesAFTER INSERT OR UPDATE ON ordersFOR EACH ROW EXECUTE FUNCTION notify_order_changes();# Python consumerimport psycopg2import selectimport json
conn = psycopg2.connect("dbname=mydb")conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()cursor.execute("LISTEN order_channel;")
print("Listening for order changes...")
while True: if select.select([conn], [], [], 5) == ([], [], []): continue # Timeout
conn.poll() while conn.notifies: notify = conn.notifies.pop(0) payload = json.loads(notify.payload) print(f"Received: {payload}") process_order_change(payload)5.2 Redis Streams
Producer:
import redisimport jsonimport time
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Add events to streamdef publish_event(event_type, data): event_id = r.xadd( 'events:orders', { 'type': event_type, 'data': json.dumps(data), 'timestamp': time.time() }, maxlen=100000 # Trim to 100k messages ) return event_id
# Usageevent_id = publish_event('order_created', { 'order_id': '12345', 'customer_id': '67890', 'amount': 99.99})Consumer (Single):
# Read from stream (blocking)def consume_events(): last_id = '0-0' # Start from beginning
while True: # Block for 5 seconds waiting for new messages messages = r.xread( {'events:orders': last_id}, count=10, block=5000 )
if not messages: continue
for stream_name, stream_messages in messages: for message_id, data in stream_messages: event_type = data['type'] payload = json.loads(data['data'])
print(f"Processing {event_type}: {payload}") process_event(event_type, payload)
last_id = message_idConsumer Groups (Multiple consumers, load balanced):
# Create consumer grouptry: r.xgroup_create('events:orders', 'order-processor', id='0', mkstream=True)except redis.exceptions.ResponseError: pass # Group already exists
def consume_with_group(consumer_name): while True: # Read new messages for this consumer messages = r.xreadgroup( 'order-processor', consumer_name, {'events:orders': '>'}, count=1, block=5000 )
if not messages: # Check for pending messages pending = r.xpending_range( 'events:orders', 'order-processor', '-', '+', count=10, consumername=consumer_name )
if pending: # Reclaim abandoned messages for p in pending: messages = r.xclaim( 'events:orders', 'order-processor', consumer_name, min_idle_time=60000, # 60 seconds message_ids=[p['message_id']] ) # Process reclaimed messages continue
for stream_name, stream_messages in messages: for message_id, data in stream_messages: try: event_type = data['type'] payload = json.loads(data['data'])
process_event(event_type, payload)
# Acknowledge successful processing r.xack('events:orders', 'order-processor', message_id) except Exception as e: print(f"Error processing {message_id}: {e}") # Message remains pending for retry
# Run multiple consumersimport threading
for i in range(3): t = threading.Thread(target=consume_with_group, args=(f'consumer-{i}',)) t.daemon = True t.start()Stream Monitoring:
# Get stream infoinfo = r.xinfo_stream('events:orders')print(f"Length: {info['length']}")print(f"First entry: {info['first-entry']}")print(f"Last entry: {info['last-entry']}")
# Get consumer group infogroups = r.xinfo_groups('events:orders')for group in groups: print(f"Group: {group['name']}, Pending: {group['pending']}")
# Get consumers in group consumers = r.xinfo_consumers('events:orders', group['name']) for consumer in consumers: print(f" Consumer: {consumer['name']}, Pending: {consumer['pending']}")5.3 MongoDB Change Streams
Watch Collection:
const { MongoClient } = require('mongodb');
async function watchOrders() { const client = new MongoClient('mongodb://localhost:27017'); await client.connect();
const db = client.db('mydb'); const collection = db.collection('orders');
// Open change stream const changeStream = collection.watch([ { $match: { 'operationType': { $in: ['insert', 'update', 'delete'] } } } ]);
console.log('Watching for changes...');
changeStream.on('change', (change) => { console.log('Change detected:', change);
switch (change.operationType) { case 'insert': handleInsert(change.fullDocument); break; case 'update': handleUpdate(change.documentKey, change.updateDescription); break; case 'delete': handleDelete(change.documentKey); break; } });
changeStream.on('error', (error) => { console.error('Change stream error:', error); });}
watchOrders();Resume After Interruption:
const fs = require('fs');
async function watchWithResume() { const client = new MongoClient('mongodb://localhost:27017'); await client.connect();
const collection = client.db('mydb').collection('orders');
// Load resume token from file let resumeToken; try { resumeToken = JSON.parse(fs.readFileSync('resume-token.json', 'utf8')); } catch (e) { // No resume token, start from now }
const options = resumeToken ? { resumeAfter: resumeToken } : {}; const changeStream = collection.watch([], options);
changeStream.on('change', (change) => { // Process change processChange(change);
// Save resume token fs.writeFileSync('resume-token.json', JSON.stringify(change._id)); });}Aggregation Pipeline Filtering:
// Watch with aggregation pipelineconst pipeline = [ // Only watch updates to specific fields { $match: { 'operationType': 'update', 'updateDescription.updatedFields.status': { $exists: true } } }, // Project only needed fields { $project: { '_id': 1, 'fullDocument._id': 1, 'fullDocument.status': 1, 'fullDocument.customer_id': 1, 'updateDescription': 1 } }];
const changeStream = collection.watch(pipeline);
changeStream.on('change', (change) => { console.log(`Order ${change.fullDocument._id} status changed to ${change.fullDocument.status}`); notifyCustomer(change.fullDocument.customer_id, change.fullDocument.status);});Watch Entire Database:
// Watch all collections in databaseconst db = client.db('mydb');const changeStream = db.watch();
changeStream.on('change', (change) => { console.log(`Change in ${change.ns.coll}: ${change.operationType}`);
// Route to appropriate handler based on collection switch (change.ns.coll) { case 'users': handleUserChange(change); break; case 'orders': handleOrderChange(change); break; case 'products': handleProductChange(change); break; }});Python Change Streams:
from pymongo import MongoClientfrom pymongo.errors import PyMongoError
client = MongoClient('mongodb://localhost:27017/')db = client['mydb']collection = db['orders']
# Watch with pipelinepipeline = [ {'$match': {'operationType': {'$in': ['insert', 'update']}}}]
try: with collection.watch(pipeline) as stream: for change in stream: operation = change['operationType']
if operation == 'insert': doc = change['fullDocument'] print(f"New order: {doc['_id']}") process_new_order(doc) elif operation == 'update': doc_id = change['documentKey']['_id'] updated_fields = change['updateDescription']['updatedFields'] print(f"Order {doc_id} updated: {updated_fields}") process_order_update(doc_id, updated_fields)except PyMongoError as e: print(f"Change stream error: {e}")5.4 Cross-Database Event Streaming
Event-Driven Architecture Pattern:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ PostgreSQL │─────>│ Kafka │<─────│ MongoDB ││ (OLTP) │ │ (Events) │ │ (Analytics)│└─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ v │ │ ┌─────────────┐ │ └────────────>│ Redis │<────────────┘ │ (Cache) │ └─────────────┘Unified Event Producer (Python):
from kafka import KafkaProducerimport json
producer = KafkaProducer( bootstrap_servers=['localhost:9092'], value_serializer=lambda v: json.dumps(v).encode('utf-8'))
class EventBus: def publish(self, topic, event): producer.send(topic, event) producer.flush()
def publish_order_created(self, order): self.publish('orders.created', { 'order_id': str(order['id']), 'customer_id': str(order['customer_id']), 'amount': float(order['total_amount']), 'timestamp': order['created_at'].isoformat() })
def publish_user_updated(self, user_id, changes): self.publish('users.updated', { 'user_id': str(user_id), 'changes': changes, 'timestamp': datetime.utcnow().isoformat() })Unified Event Consumer:
from kafka import KafkaConsumerimport redisimport psycopg2from pymongo import MongoClient
# Initialize connectionsredis_client = redis.Redis(host='localhost', decode_responses=True)pg_conn = psycopg2.connect("dbname=mydb")mongo_client = MongoClient('mongodb://localhost:27017/')
consumer = KafkaConsumer( 'orders.created', 'users.updated', bootstrap_servers=['localhost:9092'], group_id='event-sync-processor', value_deserializer=lambda m: json.loads(m.decode('utf-8')))
for message in consumer: topic = message.topic event = message.value
if topic == 'orders.created': # Invalidate cache redis_client.delete(f"order:{event['order_id']}") redis_client.delete(f"customer:{event['customer_id']}:orders")
# Update analytics in MongoDB mongo_client.mydb.order_events.insert_one(event)
# Trigger notifications notify_order_created(event)
elif topic == 'users.updated': # Invalidate user cache redis_client.delete(f"user:{event['user_id']}")
# Sync to MongoDB for analytics mongo_client.mydb.user_events.insert_one(event)6. Polyglot Persistence Strategy
6.1 Database Selection Matrix
| Use Case | PostgreSQL | Redis | MongoDB |
|---|---|---|---|
| Transactional Data | ✅ Primary | ❌ | ⚠️ Secondary |
| User Sessions | ❌ | ✅ Primary | ❌ |
| Real-time Analytics | ⚠️ Heavy | ⚠️ Limited | ✅ Primary |
| Full-text Search | ⚠️ Basic | ❌ | ✅ Good |
| Geospatial Queries | ✅ PostGIS | ⚠️ Basic | ✅ Native |
| Time-series Data | ⚠️ TimescaleDB | ❌ | ✅ Primary |
| Graph Relationships | ⚠️ Recursive | ❌ | ⚠️ References |
| Cache Layer | ❌ | ✅ Primary | ❌ |
| Message Queue | ⚠️ LISTEN/NOTIFY | ✅ Streams | ⚠️ Capped |
| Document Storage | ⚠️ JSONB | ❌ | ✅ Primary |
6.2 Consistency Models
PostgreSQL - ACID, Strong Consistency:
- Immediate consistency
- SERIALIZABLE isolation
- Use for: Financial transactions, inventory, user accounts
Redis - AP (Availability + Partition Tolerance):
- Eventual consistency (with replication)
- Single-threaded atomicity
- Use for: Caching, rate limiting, leaderboards
MongoDB - Tunable Consistency:
- Write Concern:
majority,1,0 - Read Concern:
majority,local,available - Use for: Content management, analytics, flexible schemas
6.3 Integration Patterns
Pattern 1: PostgreSQL (Source of Truth) + Redis (Cache):
class UserService: def get_user(self, user_id): # Try cache cached = redis.get(f"user:{user_id}") if cached: return json.loads(cached)
# Load from PostgreSQL user = pg.query_one("SELECT * FROM users WHERE id = %s", (user_id,))
# Cache for 1 hour redis.setex(f"user:{user_id}", 3600, json.dumps(user))
return user
def update_user(self, user_id, data): # Update PostgreSQL (source of truth) pg.execute("UPDATE users SET ... WHERE id = %s", (user_id,))
# Invalidate cache redis.delete(f"user:{user_id}")Pattern 2: PostgreSQL (OLTP) + MongoDB (Analytics):
# CDC from PostgreSQL to MongoDBdef sync_order_to_analytics(order): # Denormalized document for analytics mongo.db.orders_analytics.insert_one({ '_id': order['id'], 'customer': { 'id': order['customer_id'], 'name': order['customer_name'], 'email': order['customer_email'] }, 'items': order['items'], 'total': order['total_amount'], 'date': order['created_at'], 'year': order['created_at'].year, 'month': order['created_at'].month, 'day': order['created_at'].day })Pattern 3: Three-Tier (PostgreSQL + Redis + MongoDB):
class OrderService: def create_order(self, order_data): # 1. Write to PostgreSQL (source of truth) with pg.transaction(): order_id = pg.insert("INSERT INTO orders (...) VALUES (...) RETURNING id") pg.insert("INSERT INTO order_items (...) VALUES (...)")
# 2. Invalidate related caches redis.delete(f"customer:{order_data['customer_id']}:orders")
# 3. Publish event for MongoDB analytics event_bus.publish('orders.created', { 'order_id': order_id, 'customer_id': order_data['customer_id'], 'amount': order_data['total_amount'] })
return order_id6.4 Migration Strategies
Zero-Downtime Migration:
Phase 1: Dual Writes┌─────────┐│ App │─────> PostgreSQL (old)└─────────┘ └─> MongoDB (new)
Phase 2: Verify & Compare┌─────────┐│ App │─────> PostgreSQL ──┐└─────────┘ └─> MongoDB │ └─> Validator
Phase 3: Read Migration┌─────────┐ ┌──> PostgreSQL (fallback)│ App │─────┤└─────────┘ └──> MongoDB (primary)
Phase 4: Deprecate Old┌─────────┐│ App │─────> MongoDB (only)└─────────┘6.5 Monitoring & Observability
Unified Metrics Collection:
from prometheus_client import Counter, Histogram, Gauge
# Database operation metricsdb_queries = Counter('db_queries_total', 'Total database queries', ['database', 'operation'])db_latency = Histogram('db_query_duration_seconds', 'Query latency', ['database', 'operation'])cache_hits = Counter('cache_hits_total', 'Cache hits', ['cache_type'])cache_misses = Counter('cache_misses_total', 'Cache misses', ['cache_type'])
# Stream processing metricsstream_events_processed = Counter('stream_events_processed_total', 'Events processed', ['stream', 'event_type'])stream_lag = Gauge('stream_consumer_lag', 'Consumer lag', ['stream', 'consumer_group'])
# Usagewith db_latency.labels(database='postgresql', operation='SELECT').time(): result = pg.query("SELECT ...")db_queries.labels(database='postgresql', operation='SELECT').inc()Health Checks:
def health_check(): health = { 'postgres': check_postgres(), 'redis': check_redis(), 'mongodb': check_mongodb(), 'kafka': check_kafka() }
overall_healthy = all(health.values())
return { 'status': 'healthy' if overall_healthy else 'degraded', 'details': health }
def check_postgres(): try: pg.query_one("SELECT 1") return True except: return FalseDocument Complete: This guide covers normalization theory (1NF-6NF), PostgreSQL patterns (indexing, partitioning, triggers, CTEs), Redis patterns (caching, rate limiting, pub/sub, streams), MongoDB patterns (document modeling, aggregation, change streams), streaming architectures (CDC, event-driven), and polyglot persistence strategies.