Một migration nhỏ có thể làm database bị treo như thế nào?

Lâu lâu lại có case study chia sẻ với anh em, có một kiểu sự cố database mà chắc anh em gặp dễ bị debug sai hướng.

Nó kiểu API bắt đầu timeout. Connection pool đầy. Request liên quan đến một vài bảng cụ thể bị treo. Nhưng CPU database lại thấp, disk không cao, network cũng không có gì bất thường.

5b39341c-3ec5-4919-9d8c-a076adfdeabc

Nhìn dashboard thì không giống quá tải. Nhưng hệ thống vẫn chậm. Và đôi khi vấn đề chỉ là một migration rất nhỏ:

ALTER TABLE orders ADD COLUMN source text;

hoặc:

CREATE INDEX idx_orders_user_id ON orders(user_id);

Nghe qua thì quá bình thường. Thêm một column, tạo một index, có gì đâu?

Nhưng trong production, nhất là với những tần suất đọc ghi cao như orders, transactions, users, payments, một câu migration nhỏ vẫn có thể làm cả hệ thống đứng hình nếu nó tạo ra lock chain.

Điểm nguy hiểm nhất là database lúc đó không bận xử lý nhiều. Nó chỉ đang khiến rất nhiều query phải đứng chờ.

Sai lầm phổ biến: migration nhỏ thì rủi ro nhỏ

Tôi thấy nhiều anh em đánh giá migration theo cảm giác: Thêm column thôi mà, tạo index thôi mà, staging chạy nhanh mà, không update data thì chắc không sao

Vấn đề là staging không có traffic thật.

  • Staging không có hàng nghìn request đang đọc ghi cùng lúc.
  • Không có report job đang mở transaction lâu.
  • Không có connection pool từ nhiều service cùng đổ vào.
  • Không có bảng 100 triệu dòng với traffic liên tục cả ngày.

Trong database production, migration không chỉ được đánh giá bằng việc nó chạy nhanh hay chậm. Câu hỏi quan trọng hơn là: Câu SQL này cần loại lock nào, và nó sẽ ảnh hưởng gì đến bảng đang có traffic thật?

Một migration có thể chạy trong vài mili giây nếu lấy được lock ngay. Nhưng nếu nó không lấy được lock, nó sẽ đứng chờ. Và khi nó đứng chờ ở sai vị trí, các query bình thường phía sau cũng bị kẹt theo.

Đó là lúc một thay đổi nhỏ biến thành sự cố lớn.

Lock chain là gì?

Lock chain là tình huống một truy vấn đang chờ một lock, nhưng chính truy vấn đang chờ đó lại vô tình làm các truy vấn khác phía sau phải chờ tiếp.

Nói đơn giản, nó là một chuỗi chờ dây chuyền trong database.

Ví dụ bảng orders đang được dùng liên tục.

Ứng dụng chạy các query kiểu:

SELECT * FROM orders WHERE user_id = $1;
UPDATE orders SET status = $1 WHERE id = $2;
INSERT INTO orders (...);

Bây giờ pipeline chạy migration:

ALTER TABLE orders ADD COLUMN source text;

Để thay đổi cấu trúc bảng, PostgreSQL cần lấy lock trên bảng orders.

Nếu ngay lúc đó có một transaction cũ đang giữ lock hoặc đang dùng bảng này, migration phải chờ.

Vấn đề bắt đầu từ đây.

Kịch bản thực tế thường diễn ra như sau:

  1. Một transaction cũ đang giữ liên quan đến bảng orders
  2. Migration muốn lấy lock nhưng chưa lấy được
  3. Request mới vẫn tiếp tục đi vào bảng orders
  4. Các request mới bị kẹt phía sau migration
  5. Connection pool của app bắt đầu đầy
  6. API timeout hàng loạt

Đây chính là lock chain. Một session chờ một session khác, các query mới lại chờ sau session migration. Cuối cùng cả tần suất đọc ghi cao bị lock.

Vì sao CPU database thấp mà hệ thống vẫn sập?

Khi database quá tải thật, ta thường thấy: CPU cao, I/O cao, Query chạy lâu, Throughput giảm

Nhưng khi database bị lock chain, mọi thứ nhìn khác hẳn: CPU thấp, Disk không cao, Connection tăng, Request treo, App timeout

Lý do rất đơn giản query không thật sự chạy. Nó đang chờ lock.

Query chờ lock thường không chiếm nhiều CPU, vì database chưa thực sự bước vào phần xử lý chính của truy vấn. Vì vậy, CPU thấp không đủ để kết luận database không có vấn đề.

Đây là điểm dễ làm anh em điều tra sai hướng. Khi thấy CPU thấp, nhiều người loại database khỏi danh sách nghi vấn và chuyển sang kiểm tra ingress, autoscale, ứng dụng hoặc network. Nhưng nếu sự cố đến từ lock wait, nguyên nhân vẫn nằm trong database: các truy vấn đang xếp hàng chờ được cấp lock để tiếp tục thực thi.

Một ví dụ khác cho anh em dễ hình dung

Giả sử hệ thống có bảng orders khoảng 100 triệu dòng. Đây là tần suất đọc ghi cao, được đọc ghi liên tục.

Một migration được chạy:

ALTER TABLE orders ADD COLUMN note text;

Trên staging, câu này chạy rất nhanh.

Nhưng trên production, đúng lúc migration chạy, có một report job đang mở transaction đọc dữ liệu từ orders.

Migration cần lock nên phải chờ report job.

Trong lúc migration chờ, request mới vẫn tiếp tục đổ vào bảng orders. Các query này không được xử lý ngay vì bị kẹt phía sau migration.

Sau vài phút, anh em thấy:

  • API /orders timeout
  • Connection pool full
  • P99 latency tăng mạnh
  • CPU database chỉ 15%
  • Slow query log không chỉ ra query nào quá nặng

Nếu chỉ nhìn slow query, rất dễ bỏ sót nguyên nhân. Vì query không chậm do nó chạy lâu. Nó chậm vì chưa được chạy.

Cách nhận biết lock chain trong PostgreSQL

Khi nghi database bị kẹt lock, câu đầu tiên tôi thường chạy là:

SELECT
  pid,
  state,
  wait_event_type,
  wait_event,
  now() - query_start AS query_age,
  left(query, 120) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start;

Nếu thấy nhiều dòng kiểu này:

pid    state   wait_event_type  wait_event   query_age   query
8121   active  Lock             relation     00:04:12    ALTER TABLE orders ADD COLUMN note text
8129   active  Lock             relation     00:03:58    SELECT * FROM orders WHERE user_id = $1
8130   active  Lock             relation     00:03:57    UPDATE orders SET status = $1 WHERE id = $2

thì câu chuyện khá rõ rồi: nhiều session đang chờ lock.

Tiếp theo, cần xem ai đang block ai:

SELECT
  pid,
  pg_blocking_pids(pid) AS blocking_pids,
  wait_event_type,
  wait_event,
  left(query, 120) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

Ví dụ output:

pid   blocking_pids  wait_event_type  query
8121  {7710}         Lock             ALTER TABLE orders ADD COLUMN note text
8129  {8121}         Lock             SELECT * FROM orders WHERE user_id = $1
8130  {8121}         Lock             UPDATE orders SET status = $1 WHERE id = $2

Cách đọc rất đơn giản:

PID 7710 đang block migration
Migration 8121 lại block các query ứng dụng phía sau
Các request của user bị treo dây chuyền

Đây là một lock chain điển hình.

Những migration dễ gây sự cố

Không phải migration nào cũng nguy hiểm như nhau. Nhưng với tần suất đọc ghi cao, các nhóm sau cần được review rất kỹ.

1. ALTER TABLE

Ví dụ:

ALTER TABLE orders ADD COLUMN source text;
ALTER TABLE orders ALTER COLUMN status TYPE varchar(50);
ALTER TABLE orders DROP COLUMN old_field;

Tùy loại thay đổi, PostgreSQL có thể cần lock mạnh. Điều đáng nói là lock này đôi khi chỉ cần giữ rất ngắn, nhưng nếu không lấy được lock ngay, nó có thể đứng chờ và gây nghẽn dây chuyền.

2. ADD COLUMN DEFAULT

Ví dụ:

ALTER TABLE orders ADD COLUMN source text DEFAULT 'web';

PostgreSQL các phiên bản mới đã tối ưu nhiều trường hợp thêm column với constant default, nên không phải lúc nào cũng rewrite toàn bảng. Nhưng anh em đừng hiểu nhầm thành “không có lock”.

Nó vẫn là thay đổi schema, vẫn cần lock metadata, và vẫn có thể bị kẹt nếu bảng đang bận.

3. CREATE INDEX thường

Ví dụ:

CREATE INDEX idx_orders_created_at ON orders(created_at);

Trên bảng lớn, tần suất đọc ghi cao, câu này không nên chạy tùy tiện.

Thường cần cân nhắc:

CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at);

Nhưng CONCURRENTLY cũng không phải thuốc tiên. Nó chạy lâu hơn, có nhiều phase hơn, có thể fail và để lại invalid index.

Sau khi chạy nên kiểm tra:

SELECT
  indexrelid::regclass AS index_name,
  indisvalid,
  indisready
FROM pg_index
WHERE indexrelid::regclass::text = 'idx_orders_created_at';

4. Backfill trong một transaction lớn

Ví dụ:

UPDATE orders SET source = 'web' WHERE source IS NULL;

Nếu bảng lớn, câu này dễ tạo làm tăng lock wait, WAL, replication và autovacuum.

Cách an toàn hơn là backfill theo batch:

UPDATE orders
SET source = 'web'
WHERE id >= 100000
  AND id  Không lấy được lock nhanh thì fail, đừng đứng chờ và kéo production die theo.

Ví dụ:

```sql
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN source text;
COMMIT;

Nếu sau 3 giây không lấy được lock, migration fail.

Một migration fail trong pipeline còn dễ xử lý hơn rất nhiều so với một migration treo trong production và làm cả hệ thống timeout.

2. Đặt statement_timeout

Ví dụ:

SET statement_timeout = '5min';
  • lock_timeout giúp tránh chờ lock quá lâu.
  • statement_timeout giúp tránh câu SQL chạy quá lâu ngoài dự kiến.

Con số cụ thể tùy hệ thống, nhưng không nên để migration chạy vô hạn trong production.

3. Tách schema change và data backfill

Đừng gộp mọi thứ vào một migration lớn.

Không nên làm:

BEGIN;

ALTER TABLE orders ADD COLUMN source text;
UPDATE orders SET source = 'web' WHERE source IS NULL;

COMMIT;

Cách thực hiện đúng:

  1. Add column nullable
  2. Deploy app ghi dữ liệu mới vào column mới
  3. Backfill dữ liệu cũ theo batch nhỏ
  4. Sau khi ổn định mới thêm constraint nếu cần

Cách này có thể dài hơn, nhưng an toàn hơn rất nhiều. Production không cần migration phức tạp. Production cần migration không làm hệ thống die.

4. Đừng chạy migration trong giờ cao điểm

Nghe đơn giản nhưng rất nhiều sự cố vẫn đến từ đây.

Nếu migration chạm tần suất đọc ghi cao, hãy chạy lúc traffic thấp, có người trực, có query kiểm tra lock, có kế hoạch dừng nhanh nếu thấy bất thường.

Migration không nên là thứ merge xong pipeline tự chạy lúc nào cũng được với mọi loại thay đổi.

Khi sự cố xảy ra, đừng restart app đầu tiên

Một phản xạ khá phổ biến hay gặp khi thấy app timeout là restart pod hoặc restart service.

Nhưng nếu nguyên nhân là database lock, restart app có thể làm tình hình tệ hơn. Vì app reconnect hàng loạt, tạo thêm áp lực connection lên database.

Việc nên làm trước là anh em nên xác nhận lock:

SELECT
  pid,
  wait_event_type,
  wait_event,
  now() - query_start AS age,
  left(query, 120) AS query
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL
ORDER BY age DESC;

Sau đó tìm blocking PID:

SELECT
  pid,
  pg_blocking_pids(pid) AS blocking_pids,
  wait_event_type,
  left(query, 120) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

Nếu migration đang chặn production, thường sẽ cancel trước:

SELECT pg_cancel_backend();

Nếu không dừng được và đã xác định rõ ràng:

SELECT pg_terminate_backend();

Nhưng tuyệt đối không kill bừa. Trước khi terminate một session, cần biết nó là gì, đang chạy query nào, có phải migration, report job, autovacuum hay replication process không.

Kết lại

Một migration nhỏ vẫn có thể làm database đóng băng nếu nó cần lock trên tần suất đọc ghi cao và bị kẹt sau một transaction khác.

Điểm dễ đánh lừa nhất là CPU database có thể rất thấp. Anh em nhìn vào tưởng database không phải nguyên nhân, nhưng thực tế các query đang xếp hàng chờ lock và chưa được chạy.

Câu cần nhớ là: Database không chỉ die khi quá tải. Nó cũng có thể die khi một migration nhỏ làm cả hệ thống phải đứng chờ.

Thông tin nổi bật

Event Thumbnail

Sự kiện phát trực tiếp​

Chia sẻ bài viết:
Theo dõi
Thông báo của
0 Góp ý
Được bỏ phiếu nhiều nhất
Mới nhất Cũ nhất

Tiêu điểm chuyên gia