Hello! Welcome back to your course on designing high-load distributed systems.
In our last lesson, we implemented a zero-downtime column modification using the expand-contract pattern. We saw how to use triggers, batched updates, and concurrent operations in PostgreSQL to change a column's data type on a live table without causing significant locks or downtime.
That approach works well for isolated column changes. However, what happens when the required schema change is more complex? Imagine needing to refactor multiple columns, change a table's partitioning key, or split one large table into several smaller, normalized ones. A column-by-column migration would be incredibly complex and error-prone.
Today, we will scale up our thinking from a single column to the entire table. This lesson addresses exactly that challenge.
Our learning outcome is to design a shadow table migration strategy for a complex schema change, outlining the trigger logic, backfill process, and cutover plan. This powerful technique, also known as "ghost table migration," is a cornerstone of database evolution in high-throughput environments like the payment and trading systems you've built.
1. The Strategy: Expand-Contract at the Table Level
The shadow table migration is a direct application of the expand-contract pattern, but at the table level. The core idea is simple:
- Expand: Create a new "shadow" table with the desired target schema.
- Migrate: Copy all existing data from the original table to the shadow table (backfill) while simultaneously capturing all new changes (dual-writing).
- Contract: Atomically swap the original and shadow tables, then clean up the old one.
This approach allows you to build and validate the new structure in the background without affecting the live application, which continues to use the original table until the final, near-instantaneous cutover.
To understand the formal steps and coordination involved, let's review a general framework for this pattern.
Using the expand and contract pattern | Prisma's Data Guide
The article 'Using the expand and contract pattern' from Prisma's Data Guide provides an excellent, high-level overview of the phased approach required for such a migration. It frames the process in clear, distinct steps that we will adapt for our shadow table strategy.
Please read the section 'How to use the expand and contract pattern', which details a seven-step process. Focus on understanding the purpose of each step and the sequence of operations involving the schema, application clients, and data.
As the article outlines, a successful migration is a carefully choreographed sequence. Let's map those steps directly to our shadow table strategy:
| Step (from resource) | Shadow Table Implementation |
|---|---|
| 1. Build & Deploy New Schema | CREATE TABLE your_table_shadow (...) with the new structure. |
| 2. Expand the Interface | Implement dual-writing, typically via database triggers (INSERT, UPDATE, DELETE). |
| 3. Migrate Existing Data | Run a batched backfill script to copy and transform data from the original table to the shadow table. |
| 4. Test the New Interface | Run verification queries and performance tests against the shadow table to ensure correctness and efficiency. |
| 5. Cut Reads Over | Perform the atomic table swap. This is the "cutover". |
| 6. Discontinue Writing | Drop the dual-writing triggers. |
| 7. Remove Original Structure | DROP TABLE your_table_old; after a safe waiting period. |
2. Designing the Core Components
With the high-level strategy defined, let's design the three most critical components: the synchronization logic, the backfill process, and the cutover plan.
The following resource provides a concise, practical example of these components.
Zero-Downtime Schema Migrations on Large Production Tables
The article 'Zero-Downtime Schema Migrations on Large Production Tables' gives a concrete example of the shadow table approach. It will help us visualize the SQL needed for each phase.
Please read the section '1. Shadow Table + Backfill'. It covers the creation of the shadow table, a batched backfill, the use of a trigger for synchronization, and the final table rename for the cutover.
Drawing from this and our previous lesson, we can now outline the design for each component.
Component 1: Trigger Logic (The Synchronization Engine)
To keep the shadow table perfectly in sync with the original after the backfill begins, we must capture every data modification. A set of triggers on the original table is the most common database-centric approach.
You need to handle all three DML operations:
ON INSERT: For every new row in the original table, an equivalent row must be inserted into the shadow table. The trigger function will execute anINSERT INTO your_table_shadow ... VALUES (NEW.col1, NEW.col2, ...);.ON UPDATE: When a row is updated in the original table, the corresponding row must be updated in the shadow table. A robust way to handle this in PostgreSQL is with anUPSERToperation:INSERT INTO your_table_shadow ... ON CONFLICT (id) DO UPDATE SET ...;. This handles cases where the backfill process hasn't copied the row yet.ON DELETE: When a row is deleted from the original table, it must also be deleted from the shadow table. The trigger function will executeDELETE FROM your_table_shadow WHERE id = OLD.id;.
These three triggers ensure that by the time the backfill is complete, the shadow table is a perfect, up-to-the-millisecond copy of the original, but with the new schema.
Component 2: The Backfill Process (The Data Mover)
This is the most time-consuming part of the migration. As we learned previously, it must be done in small, non-locking batches.
The core of the backfill is a script that repeatedly executes a statement like this:
INSERT INTO your_table_shadow (new_col1, new_col2, ...)
SELECT
-- This is where data transformation happens
old_col1,
some_function(old_col2),
'some_static_value'
FROM
your_table
WHERE
-- Batching logic
id > :last_processed_id
ORDER BY
id
LIMIT 1000;
Key Design Considerations:
- Transformation: The
SELECTclause is your transformation layer. This is where you can split columns, cast data types, join with other tables to enrich data, or apply any business logic required to fit the old data into the new schema. - Batching: The
WHERE id > :last_processed_idandLIMITclauses are crucial for processing the table in manageable chunks, preventing long-running transactions and locks. - Idempotency: The script must be resumable. If it fails, it should be able to pick up where it left off without duplicating data. Using a primary key or a timestamp for batching helps achieve this.
Component 3: The Cutover Plan (The Atomic Swap)
This is the climax of the migration. It must be executed within a single, short transaction to be atomic. The goal is to hold the required ACCESS EXCLUSIVE lock for the shortest possible duration (milliseconds).
Here is a robust cutover plan:
- Pre-computation: Before the cutover transaction, create all necessary indexes, constraints, and foreign keys on the shadow table. Use
CONCURRENTLYfor indexes and theNOT VALID/VALIDATEtwo-step process for constraints to avoid locking. Give them temporary names (e.g.,my_index_shadow). - The Transaction:
BEGIN; -- Lock both tables to prevent any activity. This is the start of the downtime. LOCK TABLE your_table IN ACCESS EXCLUSIVE MODE; LOCK TABLE your_table_shadow IN ACCESS EXCLUSIVE MODE; -- Atomically swap the tables. These are fast metadata operations. ALTER TABLE your_table RENAME TO your_table_old; ALTER TABLE your_table_shadow RENAME TO your_table; -- Rename the indexes and constraints on the newly promoted table. ALTER INDEX my_index_shadow RENAME TO my_index; ALTER TABLE your_table RENAME CONSTRAINT pk_shadow TO pk_your_table; COMMIT; -- Downtime ends here. - Post-Cutover: The application is now using the new table. You can drop the triggers from
your_table_old. - Cleanup: After a safe period (e.g., 24 hours) to ensure a rollback is not needed, you can
DROP TABLE your_table_old;.
Rollback Plan: The your_table_old provides an immediate rollback path. If a critical issue is found post-cutover, you can run a similar transaction to swap the names back.
3. Activity: Design a Migration Strategy
Let's apply this. Imagine you manage a high-load orders table in a fintech system. The table has grown to 500 million rows. It contains a metadata column of type JSONB that stores payment provider details, fraud scores, and shipping information. Queries filtering on nested keys within this JSONB are becoming slow.
The Goal: Improve performance by refactoring the orders table. You need to extract provider_id (string) and fraud_score (integer) from the metadata JSONB into their own dedicated, indexed columns.
Your Task: Outline a shadow table migration strategy to achieve this with zero downtime. You don't need to write the full, final SQL. Instead, describe the plan for each key component:
- Shadow Table Schema: What would the
CREATE TABLE orders_shadow (...)statement look like conceptually? - Trigger Logic: Briefly describe what the
ON UPDATEtrigger function would need to do. - Backfill Process: Write a pseudo-SQL
INSERT ... SELECT ...statement showing how you would transform the data fromorderstoorders_shadow. - Cutover Plan: List the 3 most critical commands that would go inside your final
BEGIN/COMMITblock. - Key Risk: Identify one major risk you would need to monitor during the backfill phase.
Take a few minutes to think through your design.
(Self-reflection and solution will follow once you've considered your approach.)
Solution Outline
Here is a well-structured approach to the design activity:
-
Shadow Table Schema:
CREATE TABLE orders_shadow ( -- Copy all columns from the original 'orders' table id BIGINT PRIMARY KEY, amount DECIMAL(10, 2), ... metadata JSONB, -- Keep the original for rollback and data integrity checks -- Add the new, extracted columns provider_id VARCHAR(255), fraud_score INT ); -- Also plan for CREATE INDEX on provider_id and fraud_score -
Trigger Logic (
ON UPDATE):
The trigger function would fire onUPDATEoforders. It would receive theNEWrow record. Inside the function, it would run anINSERT INTO orders_shadow ... ON CONFLICT (id) DO UPDATEstatement. TheUPDATEpart would set all columns, extracting the new values for the new columns:provider_id = NEW.metadata ->> 'providerId',fraud_score = (NEW.metadata ->> 'fraudScore')::int. -
Backfill Process:
INSERT INTO orders_shadow (id, amount, ..., metadata, provider_id, fraud_score) SELECT id, amount, ..., metadata, metadata ->> 'providerId', -- JSONB operator to extract text (metadata ->> 'fraudScore')::integer -- Extract and cast FROM orders WHERE id > :last_processed_id ORDER BY id LIMIT 5000; -
Cutover Plan (Critical Commands):
-- Inside a BEGIN/COMMIT block LOCK TABLE orders IN ACCESS EXCLUSIVE MODE; ALTER TABLE orders RENAME TO orders_old; ALTER TABLE orders_shadow RENAME TO orders; -
Key Risk during Backfill:
Increased Database Load. The continuousINSERTstatements of the backfill, combined with the dual-writes from the triggers, add significant write load to the database. This could impact the performance of the live application. It's critical to monitor CPU, I/O, and replication lag, and to tune the backfill's batch size and speed accordingly.
Conclusion
Today, we've designed a complete, robust strategy for performing complex schema changes on massive, live tables. The shadow table migration, while intricate, is a proven method for evolving database architecture without sacrificing availability.
Key Takeaways:
- The shadow table migration is the expand-contract pattern applied to an entire table, enabling complex refactoring.
- A successful strategy depends on three well-designed components: comprehensive trigger logic for synchronization, a batched and idempotent backfill process for data migration, and an atomic cutover plan for the final swap.
- Thorough planning is essential, including creating new indexes and constraints concurrently before the cutover to minimize the duration of the final exclusive lock.
- The
_oldtable serves as an immediate, powerful rollback mechanism, providing a critical safety net.
Preview of the Next Lesson
We've designed the strategy and outlined the trigger logic conceptually. In the next lesson, we will get our hands dirty with the implementation. We will implement triggers to dually write to an original table and a shadow table during a migration, writing the actual PostgreSQL plpgsql functions and CREATE TRIGGER statements needed to make the synchronization engine a reality.