Mình đọc khá nhiều postmortem, phần lớn chỉ lướt qua. Bài viết của Inngest về sự cố ngày 18/9 là một trong số ít mình đọc lại lần hai, vì nó ghi timestamp chính xác cho từng giai đoạn của sự cố, và đây là dạng lỗi mình từng suýt gây ra: xóa một account có ON DELETE CASCADE trải khắp vài chục bảng, trong một transaction duy nhất, và tin rằng “chỉ là một lệnh xóa” nghĩa là rẻ.
Nó không rẻ. Nó làm sập service hai lần chỉ trong một buổi chiều.
15:41 UTC — transaction giữ quá nhiều
Một lệnh xóa account trên Vercel Marketplace kích hoạt cascade trải khắp vài chục bảng liên quan, tất cả nằm trong một transaction Postgres duy nhất. Cascading delete ở quy mô đó không phải một thao tác nhanh gọn — đó là một transaction chạy dài, giữ lock ở cấp row và table suốt thời gian nó đi qua từng foreign key relationship để xóa từng dòng phụ thuộc. Account càng lớn, transaction càng chạy lâu, và mọi thứ khác càng phải chờ đợi phía sau các lock đó.
Từ 15:41 đến 15:56 UTC, các lần retry của lệnh xóa xếp hàng phía sau lệnh gốc — không phải thay thế nó, mà xếp sau nó. Không có gì dedupe các lần retry. Không có gì serialize chúng với transaction đang chạy. Mỗi lần retry chỉ đơn giản chồng thêm vào hàng đợi lock, chờ tới lượt để cũng thử xóa những dòng mà, ngay lúc đó, vẫn đang trong quá trình bị xóa dở.
Phần thực sự làm sập trang
Đây là cơ chế mình chưa thực sự thấm cho tới khi đọc bài postmortem này: query bị block không chỉ nằm im lặng chờ. Nó giữ luôn slot kết nối client trên PgBouncer suốt toàn bộ thời gian chờ lock. Một query bị block chín mươi giây thì chiếm slot kết nối chín mươi giây, bất kể có đang làm việc gì thật sự hay không.
Số kết nối client leo lên tới khoảng 14 lần mức bình thường khi các query bị xếp hàng, retry, và block bởi lock cứ tích tụ dần. Đến 16:18–16:19 UTC, cả hai instance PgBouncer chạm max_client_conn và bắt đầu trả về FATAL: no more connections allowed. Tới lúc đó thì database bản thân nó khỏe mạnh cỡ nào cũng không còn quan trọng — không có gì mới nói chuyện được với nó nữa. Sự cố này về bản chất không phải một lỗi database. Nó là một lỗi connection pool gây ra bởi một vấn đề database, và đó là khác biệt quyết định bạn thực sự nên sửa cái gì.
Executor, queue-proxy, và pipeline CDC đều crash-loop khi hết kết nối, và đó chính là thứ biến một vấn đề query chậm thành một sự cố toàn diện. Hai khoảng thời gian riêng biệt — 24 phút, rồi thêm 22 phút nữa trong lúc rollout bản fix — trước khi mọi thứ thực sự ổn định trở lại.
Ba thứ riêng biệt phải cùng sai một lúc
Điều mình thích ở bài postmortem này là không có quyết định đơn lẻ nào là điên rồ cả. Cascading FK delete là một pattern schema hoàn toàn bình thường. Retry một thao tác fail hoặc chậm là hành vi client hoàn toàn bình thường. Một connection pool có giới hạn kích thước là hạ tầng hoàn toàn bình thường. Không cái nào trong ba thứ đó là sai lầm nếu đứng riêng.
Chồng lên nhau, chúng cộng dồn thành một failure mode nơi logic retry khuếch đại chính vấn đề nó đang cố tránh. Các lần retry không phải do bug trong logic retry — chúng đang làm đúng những gì logic retry được thiết kế để làm. Bug nằm ở chỗ không có gì phía trên tầng retry biết thao tác gốc vẫn đang chạy và giữ lock, nên “thử lại” thực chất có nghĩa là “thêm một query bị block nữa vào một pool đang cạn dần.”
Bản fix nhàm chán hơn sự cố, và đó mới là điều đúng
Bản fix của Inngest không phải kiểu thông minh gì cả — một kill switch cho việc xóa account Marketplace để chặn đứng ngay lập tức, chuyển hard delete thành soft delete để phần dọn dẹp cascade tốn kém chạy bất đồng bộ thay vì nằm trong một transaction đối diện người dùng, batch/rate-limit/dedupe cho những hard delete còn lại, và cô lập pool PgBouncer để một workload cạn kiệt kết nối không thể làm đói traffic không liên quan.
Không có gì trong đó là kỹ thuật mới lạ. Đó là playbook tiêu chuẩn cho “một thao tác tốn kém bị gắn chặt vào một đường đi đồng bộ đối diện người dùng.” Điều khiến nó trở nên cần thiết không phải vì thiếu tinh vi — mà vì “xóa account” đọc lên nghe như một thao tác tần suất thấp, rủi ro thấp, cho tới khi bạn thực sự đo xem nó chạm bao nhiêu bảng và transaction chạy lâu bao nhiêu dưới tải.
Chỗ mình kiểm tra lại hệ thống của mình sau khi đọc bài này
Hai câu hỏi đáng chạy thử trên bất cứ thứ gì bạn đang sở hữu có cascading delete: lệnh xóa có chạy trong một transaction giữ lock suốt toàn bộ thời gian không, và logic retry của bạn có biết thao tác gốc còn đang chạy trước khi bắn thêm một lần retry nữa không? Nếu câu trả lời cho câu thứ hai là “không, nó chỉ retry khi timeout,” thì bạn đang có đúng hình dạng của bug này nằm im chờ, chỉ đợi một account đủ lớn để làm transaction đủ chậm mà gây chuyện.
Cô lập pool là lớp bảo hiểm nhàm chán nằm dưới tất cả những thứ này — failure mode của một workload không nên có khả năng làm đói kết nối của mọi workload khác, và điều đó đúng bất kể bạn có bao giờ gặp đúng kịch bản cascading-delete này hay không.