Backfill dữ liệu cho bảng lớn trong Production: tránh lock kéo dài, replica lag và cutover đầy ảo giác

Sơ đồ migration production với dual write, backfill theo chunk, verify drift và cutover sang path mới
Backfill an toàn không chỉ là copy dữ liệu cũ, mà là một chuỗi kiểm soát write path, verify drift, shadow read và cutover có thể rollback.

Nhiều incident migration không bắt đầu ở bước ALTER TABLE, mà bắt đầu ở lúc team nói câu nghe rất vô hại: “chỉ cần backfill dữ liệu cũ sang cột mới thôi”.

Trên giấy, backfill có vẻ chỉ là một job cập nhật hàng loạt. Trong production, nó có thể là thứ âm thầm ăn hết headroom của database: tăng write amplification, kéo replication lag, làm autovacuum tụt nhịp, phá cache locality, tạo row bloat, giữ transaction quá lâu, và tệ hơn là đưa team vào một cutover “trông như đã xong” nhưng dữ liệu thật vẫn đang drift.

Bài toán này xuất hiện khắp nơi:

  • thêm cột mới rồi cần suy diễn giá trị từ dữ liệu cũ;
  • tách một bảng lớn thành schema mới;
  • chuyển format JSON sang cột typed;
  • đổi chiến lược indexing hoặc partitioning;
  • chuyển read path sang bảng/materialized snapshot mới;
  • dual write sang storage mới rồi cần lấp lịch sử.

Bài này đi thẳng vào phần khó nhất của backfill production: khi nào nên backfill online, vì sao backfill không chỉ là “batch update”, cách thiết kế chunking và throttling, dual write/cutover sao cho không tự lừa mình, cách đo drift, xử lý replica lag, và những anti-pattern khiến một migration tưởng như an toàn biến thành incident kéo dài nhiều giờ.

Backfill thực chất là bài toán production engineering, không chỉ là data scripting

Một backfill cho bảng lớn gần như luôn chạm ít nhất 4 lớp cùng lúc:

  1. data correctness

- giá trị mới có đúng logic business không;

- có row nào bị bỏ sót không;

- có race với traffic live không.

  1. database performance

- write load tăng bao nhiêu;

- WAL tăng ra sao;

- index maintenance và vacuum chịu nổi không.

  1. application behavior

- code đang đọc path cũ hay mới;

- có dual write chưa;

- retry có idempotent không.

  1. operational control

- có pause/resume được không;

- có biết lúc nào nên giảm tốc không;

- có guard chống overlap hay chạy lại sai không.

Nếu team chỉ coi backfill là một script SQL chạy ngoài giờ, thường là chưa đánh giá đúng blast radius của nó.

Sai lầm lớn nhất: bắt đầu backfill trước khi khóa chiến lược read/write

Trước khi đụng vào dữ liệu lịch sử, team phải trả lời rất rõ hệ thống đang ở phase nào của migration. Với production-grade migration, tôi thường nhìn nó theo chuỗi này:

  1. expand: thêm schema mới tương thích ngược;
  2. dual write hoặc write-new-compatible: traffic mới bắt đầu ghi dữ liệu theo format/path mới;
  3. historical backfill: lấp dữ liệu cũ;
  4. verification: đo row parity, checksum, drift, mismatch rate;
  5. read switch hoặc shadow read: chuyển dần read path;
  6. cutover: path mới thành source of truth;
  7. contract: dọn path cũ sau khi chắc chắn rollback window đã qua.

Nhiều team làm ngược: backfill trước, dual write sau, verify rất sơ sài, rồi cutover bằng niềm tin. Kết quả là dữ liệu live tiếp tục đổi trong khi backfill đang chạy, và ngay lúc cutover không ai còn chắc bảng mới có thực sự đồng bộ hay không.

Đừng update cả bảng trong một transaction dài

Đây là anti-pattern kinh điển:

update orders
set normalized_status = case ... end
where normalized_status is null;

Nếu bảng đủ lớn, câu lệnh kiểu này có thể gây ra cả chuỗi vấn đề:

  • transaction giữ quá lâu;
  • WAL spike mạnh;
  • replication lag tăng;
  • autovacuum bị cản hoặc tụt lịch;
  • lock contention tăng ở row hot;
  • rollback nếu fail cực đắt;
  • application latency tăng vì cache và I/O bị tranh chấp.

Vấn đề không chỉ là “query nặng”. Vấn đề là production không thích những thao tác monolithic kéo dài và khó điều khiển.

Backfill production tốt thường phải chia nhỏ, pause được, đo được, resume được.

Chunking: chia nhỏ theo cách nào mới đúng?

Minh họa backfill runner xử lý dữ liệu theo chunk và lưu checkpoint để resume an toàn
Chunking an toàn bắt đầu từ boundary ổn định, checkpoint rõ ràng và khả năng resume mà không xử lý trùng hoặc bỏ sót row.

Chunking là nền tảng của hầu hết backfill an toàn. Nhưng chia nhỏ không chỉ là thêm LIMIT 1000 cho có.

Chunk theo khóa ổn định

Tốt nhất là chunk theo một key có thứ tự và ổn định, ví dụ:

  • primary key tăng dần;
  • created_at + id;
  • partition key;
  • shard key;
  • tenant boundary nếu workload đa tenant.

Ví dụ tư duy an toàn hơn:

update orders
set normalized_status = ...
where id > :last_id
  and id <= :next_id
  and normalized_status is null;

Hoặc nếu cần tính tuần tự theo thời gian:

where created_at >= :from_ts
  and created_at < :to_ts

Điểm quan trọng là mỗi chunk phải:

  • xác định được rõ phạm vi;
  • không overlap mơ hồ;
  • chạy lại được mà không làm hỏng dữ liệu;
  • có thể resume từ checkpoint rõ ràng.

Đừng chunk bằng OFFSET trên bảng lớn

OFFSET rất dễ viết nhưng là lựa chọn tệ cho backfill production:

  • càng về sau càng chậm;
  • không ổn định nếu dữ liệu thay đổi giữa chừng;
  • dễ bỏ sót hoặc xử lý trùng row.

Keyset/chunk theo boundary ổn định gần như luôn tốt hơn.

Idempotency là yêu cầu bắt buộc, không phải bonus

Một backfill runner tốt phải assume rằng:

  • job có thể crash giữa chừng;
  • chunk có thể retry;
  • worker có thể timeout nhưng thực tế DB đã commit;
  • operator có thể pause rồi resume;
  • cùng một chunk có thể bị chạy lại sau failover.

Vì vậy logic backfill nên idempotent càng nhiều càng tốt.

Ví dụ thay vì viết kiểu:

set counter = counter + 1

hãy ưu tiên kiểu:

set normalized_status = computed_value
where normalized_status is distinct from computed_value

Hoặc dùng marker/checkpoint rõ ràng như:

  • backfilled_at is null;
  • version < 2;
  • migration_state = 'pending'.

Một backfill không idempotent rất dễ biến retry thành corruption.

Dual write không cứu được bạn nếu không verify drift

Ma trận verify drift giữa nguồn và đích sau dual write và historical backfill
Dual write chỉ là điều kiện cần; drift verification mới là thứ giúp team biết dữ liệu mới có thật sự theo kịp dữ liệu nguồn hay không.

Nhiều team yên tâm quá sớm khi đã bật dual write. Họ nghĩ rằng từ giờ dữ liệu mới sẽ tự đồng bộ, nên chỉ cần backfill dữ liệu cũ rồi cutover.

Vấn đề là dual write hiếm khi hoàn hảo ngay lập tức. Có thể tồn tại:

  • một path hiếm quên ghi trường mới;
  • job retry ghi path cũ thành công nhưng path mới fail;
  • event consumer replay tạo lệch trạng thái;
  • conditional update ở hai path không tương đương;
  • dữ liệu mới được transform khác nhau giữa app version cũ và mới.

Vì vậy sau khi dual write bật, team vẫn cần drift detection.

Cần đo gì?

Tùy migration, nhưng ít nhất nên có:

  • row count parity giữa nguồn và đích theo chunk/tenant/ngày;
  • checksum hoặc hash trên tập field quan trọng;
  • mismatch rate;
  • số row source exists / target missing;
  • số row target exists / source missing;
  • số row có timestamp mới hơn checkpoint nhưng giá trị không khớp.

Nếu không có lớp verify này, cutover rất dễ chỉ là một niềm tin được trình bày bằng dashboard xanh đẹp.

Backfill online luôn phải nghĩ tới replica lag

Biểu đồ backfill production với cơ chế giảm tốc khi replica lag vượt ngưỡng
Tốc độ backfill nên co giãn theo headroom thật của hệ thống, đặc biệt khi replica lag và I/O bắt đầu xấu đi.

Đây là chỗ nhiều backend team đánh giá thiếu vì ứng dụng chính vẫn “chạy ổn”.

Backfill lớn làm tăng:

  • WAL generation;
  • ghi index;
  • dirty pages;
  • contention I/O.

Replica sẽ là nơi đau đầu đầu tiên nếu hệ thống phụ thuộc vào:

  • read replicas cho API read-heavy;
  • analytics query đọc từ replica;
  • failover target cần bắt kịp;
  • CDC hoặc downstream consumers đọc WAL.

Nếu lag tăng mạnh, hậu quả có thể là:

  • stale read vượt ngưỡng SLA;
  • cutover verify đọc từ replica cho kết quả sai thời điểm;
  • failover trở nên rủi ro vì replica tụt quá xa;
  • logical consumers backlog lớn.

Guardrail thực tế cho replica lag

Một backfill runner tử tế nên có throttle hoặc pause theo tín hiệu như:

  • replica lag vượt X giây;
  • WAL generation vượt baseline quá xa;
  • CPU/IOPS/lock wait tăng quá ngưỡng;
  • p95 latency của request path chính bắt đầu xấu đi.

Nói cách khác, tốc độ backfill không nên là hằng số. Nó nên là một hàm của headroom production.

Throttling: chạy nhanh nhất có thể thường là chiến lược tệ

Rất nhiều incident bắt đầu từ tâm lý “đã làm thì làm nhanh cho xong”. Với backfill bảng lớn, mục tiêu đúng hơn thường là:

> hoàn thành đủ nhanh nhưng không ăn hết headroom của hệ thống.

Throttling có thể theo nhiều lớp:

  • giới hạn số chunk concurrent;
  • sleep ngắn giữa các chunk;
  • adaptive rate theo replica lag hoặc p95 query latency;
  • giới hạn tenant hot chạy trước/sau;
  • tạm dừng trong peak traffic.

Một pattern thực tế là runner luôn kiểm tra health signal trước khi nhận chunk mới. Nếu hệ thống đang đỏ hơn bình thường, nó tự giảm tốc hoặc pause.

Điều này nghe “chậm”, nhưng thực tế thường giúp migration hoàn thành chắc chắn hơn nhiều so với một đợt spike dữ dội rồi phải dừng khẩn cấp.

Chọn boundary chunk theo tenant đôi khi tốt hơn theo id

Trong hệ multi-tenant, chunk theo id không phải lúc nào cũng đủ. Có những bài toán mà một số tenant rất nóng hoặc có rule business riêng. Khi đó chunk theo tenant mang vài lợi ích:

  • dễ isolate issue cho tenant lớn;
  • dễ rollback logic ở phạm vi hẹp hơn;
  • dễ so sánh parity theo domain business;
  • tránh một tenant cực lớn làm méo metric của toàn job.

Ví dụ chiến lược có thể là:

  1. backfill tenant nhỏ trước để validate logic;
  2. chạy pilot trên 1-2 tenant lớn;
  3. mới mở rộng dần;
  4. tenant VIP hoặc compliance-sensitive có lịch riêng.

Cách này thường tốt hơn kiểu đẩy toàn bảng đi một mạch rồi hy vọng logic tổng quát đủ đúng cho mọi tenant.

Shadow read và cutover: đừng chuyển read path chỉ vì row count đã đủ

Một lỗi rất hay gặp là team nhìn thấy:

  • 100% row đã backfill;
  • dual write đã bật;
  • vài query so sánh sample trông ổn.

Rồi họ chuyển read path sang storage mới.

Vấn đề là cutover tốt cần nhiều hơn row count.

Shadow read cho bạn điều gì?

Trước khi switch đọc thật, ứng dụng có thể:

  • vẫn trả response từ path cũ;
  • đồng thời đọc path mới ở background;
  • so sánh các field quan trọng;
  • ghi metric mismatch;
  • chỉ promote khi mismatch ở mức chấp nhận được.

Shadow read giúp team phát hiện những lỗi rất đời thường như:

  • sort order khác nhau;
  • missing default value;
  • timezone transform lệch;
  • row mới tạo đúng lúc checkpoint bị mất ở target;
  • path mới chậm hơn kỳ vọng với tenant nóng.

Cutover phải có rollback semantics rõ ràng

Câu hỏi quan trọng không phải chỉ là “bật read mới được chưa”, mà là:

  • nếu đọc mới lỗi, tắt flag có quay về path cũ an toàn không;
  • dữ liệu mới ghi trong lúc cutover có còn tương thích với path cũ không;
  • có cần giữ dual write thêm một thời gian sau cutover không.

Đây là lý do backfill thường đi cùng expand/contract và feature-flag discipline, chứ không nên là một cú switch một lần.

PostgreSQL-specific pain points: bloat, autovacuum và index maintenance

Với PostgreSQL, backfill row update lớn còn có vài đau đầu riêng:

  • update tạo tuple version mới theo MVCC;
  • index liên quan có thể phải update theo;
  • bloat tăng nếu workload backfill kéo dài;
  • autovacuum có thể không theo kịp;
  • ANALYZE/stats có thể lệch sau khi phân bố dữ liệu thay đổi mạnh.

Không phải migration nào cũng cần chiến thuật giống nhau, nhưng team nên tự hỏi:

  • backfill đang là UPDATE in-place hay INSERT INTO new_table SELECT ...;
  • có index nào khiến write cost đội lên quá nhiều không;
  • có nên build index sau khi bulk load trong một số mô hình migrate offline/nearline không;
  • có cần recheck planner/statistics sau migration không.

Official PostgreSQL docs về populate database nhấn mạnh những điểm như dùng COPY, cân nhắc index/constraints, và chạy ANALYZE sau bulk load. Không phải mọi lời khuyên đó áp dụng nguyên xi cho online migration, nhưng tinh thần chung rất quan trọng: bulk data movement luôn phải được xem như workload hạng nhất, không phải thao tác phụ.

Đừng để backfill runner thành black box

Một migration runner mà chỉ có log kiểu “processed 120000 rows” là chưa đủ.

Tối thiểu nên có các signal:

  • chunk đang chạy: boundary nào, tenant nào;
  • rows scanned vs rows actually changed;
  • throughput theo phút;
  • retry count;
  • error theo loại;
  • lag lớn nhất của replica trong run;
  • thời gian chunk p50/p95;
  • checksum/mismatch summary;
  • checkpoint cuối cùng an toàn.

Nếu operator không biết runner đang đứng ở đâu và đang gây áp lực gì, quyết định pause/continue sẽ rất cảm tính.

Một playbook an toàn cho backfill bảng lớn

Đây là flow tôi thấy thực dụng nhất trong nhiều hệ production.

Bước 1: khóa migration contract

Xác định rõ:

  • schema mới là gì;
  • write path nào sẽ dual write;
  • source of truth là gì ở từng phase;
  • điều kiện cutover;
  • rollback semantics.

Bước 2: pilot trên phạm vi nhỏ

Chọn:

  • tenant nhỏ;
  • partition cũ ít traffic;
  • hoặc chunk id hẹp.

Mục tiêu không chỉ là logic đúng, mà còn là đo impact thật lên DB/replica.

Bước 3: bật observability và throttling

Trước khi scale job, phải có:

  • metrics chunk throughput;
  • lag guard;
  • error budget;
  • pause/resume control.

Bước 4: historical backfill có checkpoint

Runner xử lý chunk tuần tự hoặc concurrency thấp, lưu checkpoint sau từng chunk commit an toàn.

Bước 5: drift verification liên tục

Không chờ cuối job mới kiểm. Mismatch nên được phát hiện sớm theo chunk/tenant.

Bước 6: shadow read

Ứng dụng âm thầm đọc path mới và so sánh với path cũ trong một khoảng đủ dài.

Bước 7: cutover có flag + guard

Chuyển đọc theo tỷ lệ nhỏ hoặc theo tenant trước, không big bang nếu không cần.

Bước 8: giữ dual write thêm một cửa sổ an toàn

Đừng contract ngay. Hãy giữ thời gian đủ để quan sát drift hậu cutover.

Bước 9: contract và cleanup

Chỉ sau khi metrics và dữ liệu đủ ổn định mới dọn schema/path cũ.

Anti-pattern phổ biến nhất

1. Chạy một câu UPDATE khổng lồ rồi ngồi xem

Đây gần như là cách nhanh nhất để mất quyền kiểm soát.

2. Không có checkpoint rõ ràng

Crash giữa chừng xong không biết resume ở đâu, thế là job chạy lại bằng cảm giác.

3. Tin vào row count nhưng không verify field parity

Số row bằng nhau không có nghĩa dữ liệu tương đương.

4. Không xem replica lag vì primary vẫn xanh

Nhiều hệ chỉ phát hiện vấn đề khi read replica hoặc CDC downstream bắt đầu lệch nặng.

5. Cutover ngay khi backfill xong

Backfill xong chỉ có nghĩa historical snapshot đã chạy xong, không có nghĩa live drift đã được kiểm đủ.

6. Quên dọn index/path cũ sau migration

Nếu dual write và path cũ sống quá lâu, team vừa gánh complexity mới vừa giữ nguyên nợ cũ.

Khi nào không nên backfill online?

Không phải migration nào cũng xứng đáng làm online. Nếu:

  • bảng quá lớn so với headroom hiện tại;
  • logic transform quá đắt;
  • write path đang quá nóng;
  • compliance/business không chấp nhận stale hoặc drift nhỏ;
  • hạ tầng observability chưa đủ;
  • rollback semantics chưa rõ;

thì phương án an toàn hơn có thể là:

  • migrate theo tenant window;
  • dùng maintenance window rõ ràng;
  • tách sang pipeline offline/nearline;
  • hoặc redesign để tránh rewrite toàn khối.

Sự trưởng thành ở đây là dám nói “online migration lúc này không đáng rủi ro”, thay vì cố ép mọi bài toán thành zero-downtime marketing.

Checklist trước khi chạy backfill lớn

Trước khi bấm nút chạy, tôi muốn trả lời được 10 câu hỏi:

  1. Chunk boundary là gì và có ổn định không?
  2. Logic update có idempotent không?
  3. Đã có dual write hay cơ chế giữ dữ liệu mới không bị mất chưa?
  4. Verify drift bằng metric nào?
  5. Replica lag hoặc DB saturation ngưỡng nào thì phải giảm tốc/pause?
  6. Có checkpoint rõ ràng để resume không?
  7. Cutover condition là gì ngoài row count?
  8. Rollback semantics sau cutover là gì?
  9. Sau migration có cần ANALYZE, reindex review hoặc planner review không?
  10. Ai là người có quyền pause/continue và runbook nằm ở đâu?

Nếu chưa trả lời được, backfill chưa sẵn sàng để chạy trên production.

Kết luận

Backfill dữ liệu cho bảng lớn không phải “việc dọn dữ liệu lịch sử”, mà là một thay đổi production có blast radius thật. Làm tốt, nó mở đường cho schema mới, storage mới và read path tốt hơn mà không cần downtime rõ rệt. Làm ẩu, nó tạo lock kéo dài, replica lag, mismatch âm thầm và cutover đầy ảo giác.

Cách tiếp cận đáng tin cậy thường không phải nhanh nhất. Nó là cách:

  • chia nhỏ theo chunk ổn định;
  • idempotent để retry an toàn;
  • throttle theo headroom thật;
  • verify drift liên tục;
  • cutover bằng flag và shadow read;
  • contract chậm hơn một chút nhưng chắc hơn nhiều.

Nếu phải nhớ một điều, thì là: backfill không kết thúc khi script chạy xong; nó chỉ kết thúc khi path mới đã chứng minh được correctness và vận hành ổn định dưới traffic thật.

<section class="internal-links"><h2 id="doc-tiep-trong-cluster-migration-postgresql-va-backend-production">Đọc tiếp trong cluster migration, PostgreSQL và backend production</h2>

<ul>

<li><a href="/postgresql-materialized-view-refresh-production/">PostgreSQL Materialized View Refresh trong Production</a></li>

<li><a href="/postgresql-query-plan-regression-production/">PostgreSQL Query Plan Regression trong Production</a></li>

<li><a href="/read-after-write-consistency-replica-lag-production/">Read-after-write Consistency và Replica Lag trong Production</a></li>

<li><a href="/feature-flags-backend-production/">Feature Flags trong Backend Production</a></li>

<li><a href="/schema-drift-event-driven-systems-production/">Schema Drift trong Event-Driven Systems</a></li>

<li><a href="/idempotent-consumer-event-driven-production/">Idempotent Consumer trong Event-Driven Production</a></li>

</ul></section>