Ngày 18/11/2025, một “Core Entity service” nằm sâu hơn năm tầng trong call graph của Uber bắt đầu lỗi vì một vấn đề hạ tầng. Ban đầu chẳng có gì kịch tính — chỉ là error rate tăng ở một node, bị chôn dưới hàng loạt lớp caller chẳng ai biết đó mới là nguồn gốc thật sự. Với chính sách retry mà Uber từng chạy trước đó, chuyện này lẽ ra đã gây ra một đợt tăng traffic 46%-135% dội thẳng vào cái service đã suy yếu, vì mọi tầng phía trên đều retry song song mà không biết những tầng khác cũng đang retry y hệt. Nhưng theo báo cáo của team engineering Uber, họ đã chặn được 9.5 triệu request thừa trong toàn bộ mesh, nhờ một pattern họ gọi là “error ownership” đã triển khai từ trước đó vài tháng. Những caller trực tiếp của service bị lỗi bị chặn không cho gửi thêm tới 200.000 request mỗi bên.

Mình từng debug đúng kiểu lỗi này mà không biết gọi tên nó là gì. Một service nằm bốn lớp phía trên vấn đề thật sự, retry một request vốn đã “chết” ngay từ lúc rời khỏi service đó, khiến outage tệ hơn thay vì giúp hệ thống gượng qua. Bài viết của Uber là lần đầu tiên mình thấy ai đó đưa ra toán học rõ ràng cho việc tại sao chuyện này xảy ra, và một cách sửa thật sự chứ không chỉ là “chỉnh backoff cho chặt hơn”.

Lỗi gốc: mọi hop đều coi lỗi là của chính mình

Retry middleware thông thường không phân biệt được “chính tôi lỗi” với “ai đó tôi gọi bị lỗi”. Nó chỉ thấy lỗi và retry, ở mọi tầng, một cách đồng đều. Trong chuỗi A → B → C → D, nếu D lỗi, C retry, B retry, A retry — ba lần retry chồng lên một lỗi thật, và đó còn là một chuỗi ngắn. Công thức khuếch đại retry của chính Uber cho thấy rõ hình dạng của vấn đề:

Số request ở độ sâu d, không có retry budget:  R^d × Ƞ
Số request ở độ sâu d, có retry budget B:       (1+B)^d × Ƞ

Trong đó R là số lần retry mỗi hop, Ƞ là lượng request nền. Chạy thử ví dụ sáu hop của chính họ: một lần retry mỗi hop, node D lỗi ở độ sâu 3. Không có budget, các node D đến G mỗi node nhận 8Ƞ request chỉ từ một lỗi upstream duy nhất. Với retry budget 10% thay vì retry mù, con số giảm còn 1.33Ƞ. Với error ownership giới hạn retry chỉ ở đúng cạnh thật sự lỗi — C gọi D — các node A, B, C không nhận thêm traffic nào cả, còn D đến G chỉ nhận 1.1Ƞ.

Đó là toàn bộ câu chuyện gói gọn trong một bảng số: retry budget một mình đã giúp ích, nhưng biết chính xác cạnh nào “sở hữu” lỗi và chỉ retry ở đó thì tốt hơn cả một bậc, và đó là khác biệt giữa một hệ thống tự phục hồi và một hệ thống dồn dập tự hại chính mình.

Cách sửa: một service tự “nhận” lỗi của chính nó

Quy tắc của Uber, lấy thẳng từ bài viết: nếu một service gọi ra N downstream trong lúc xử lý request, và một trong các downstream đó lỗi, và đó chính là lý do service trả về lỗi — thì service này không phải chủ sở hữu lỗi. Nó chỉ là triệu chứng, và lỗi lan lên trên ở trạng thái “unclaimed” (chưa ai nhận). Nhưng nếu không có outbound call nào lỗi mà service vẫn trả về lỗi, thì chính service đó là nguyên nhân, và nó “nhận” lỗi trước khi trả về.

Việc “nhận” này được mang theo trên chính response lỗi — sơ đồ của Uber thể hiện nó như một header mà retry middleware đọc trước khi quyết định có retry hay không. Lỗi đã được “nhận” (claimed) từ một downstream trực tiếp thì đủ điều kiện retry ở hop đó, vì bạn biết retry ở đây có khả năng giúp ích thật. Lỗi “chưa ai nhận” (unclaimed) nghĩa là lỗi thật nằm sâu hơn trong chuỗi, retry ở đây chỉ dồn thêm tải lên một service vốn đã khó khăn, nên middleware sẽ không retry.

Đây là dạng rút gọn của logic đó thành middleware — không phải code thật của Uber, chỉ là pattern áp dụng vào một service mesh Node/Express thông thường:

async function callDownstream(req, res, next) {
  const outboundResults = await Promise.allSettled(
    req.dependencies.map(dep => dep.call(req))
  );

  const anyDownstreamFailed = outboundResults.some(r => r.status === 'rejected');

  try {
    const result = await handleRequest(req, outboundResults);
    return res.json(result);
  } catch (err) {
    // Mình lỗi, nhưng không phải vì downstream lỗi — mình sở hữu lỗi này.
    if (!anyDownstreamFailed) {
      res.set('x-error-claim', 'claimed');
    } else {
      // Downstream lỗi và đó là lý do mình lỗi — chuyển tiếp ở trạng thái unclaimed.
      res.set('x-error-claim', 'unclaimed');
    }
    return res.status(err.status || 500).json({ error: err.message });
  }
}

function shouldRetry(response) {
  return response.headers['x-error-claim'] === 'claimed';
}

Kết quả thực tế trên production, tổng hợp trên toàn bộ API hướng người dùng của Uber, không chỉ riêng sự cố kể trên: “max retry-storm radius” — độ sâu tối đa mà một retry storm có thể lan tới trước khi error ownership chặn lại — giảm từ 25 hop xuống còn 3. Trung bình giảm từ 20 xuống còn 2.

Chỗ nào đáng áp dụng, chỗ nào chưa cần

Nếu service graph của bạn chỉ sâu hai ba tầng, bỏ qua bài này cũng được. Bài toán chỉ thật sự đau khi bạn có đủ tầng để một lỗi bị retry, rồi retry lại, rồi retry lại nữa bởi các caller không hề biết về nhau. Nếu hệ thống của bạn có từ bốn năm tầng gọi nội bộ trở lên — điều mà hầu hết các hệ microservice mình từng làm việc cùng đều gặp phải sau một quy mô nhất định — thì đây là thứ đáng dành một buổi chiều để nghiên cứu.

Một điểm Uber tự thừa nhận thẳng thắn: việc gán “chủ sở hữu” lỗi không hoàn hảo. Ước tính trường hợp xấu nhất của họ là khoảng 2% số lần, một caller thật sự cũng tự lỗi lại bị gán nhầm thành “unclaimed”, vì một downstream call tình cờ lỗi cùng lúc. Họ xử lý việc này bằng cách đưa lịch sử pattern lỗi vào quyết định, chứ không chỉ kiểm tra tại một thời điểm. Nếu bạn tự xây pattern này, hãy dự trù cho đúng trường hợp false-negative đó thay vì giả định tín hiệu nhị phân hoàn toàn sạch — một sự cố downstream trùng hợp không nên âm thầm che mất một lỗi thật trong chính service của bạn.

Phần mình sẽ “lấy trộm” áp dụng trước tiên, còn trước cả toàn bộ cơ chế ownership: công thức retry budget ở trên. (1+B)^d so với R^d chỉ là một dòng config thay đổi trong hầu hết các thư viện retry, và riêng nó thôi đã mang lại phần lớn biên độ an toàn trước khi bạn viết một dòng logic ownership nào.

Xuất nội dung

Bình luận