Thử lại là thứ ai cũng thêm vào và gần như không ai gỡ ra. Nó rẻ, nó dễ, và nó biến một lỗi thoáng qua thành một request thành công. Vấn đề là mỗi tầng trong hệ thống đều nghĩ y hệt như vậy, và tác dụng của chúng nhân với nhau chứ không cộng.

Tôi dựng ba tầng Vert.x — khách → A → B → C — trong đó C luôn trả 503, rồi đếm xem C thật sự nhận bao nhiêu lượt.

Một request, chín lượt

Cơ chế thử lại viết một lần, dùng cho cả hai tầng:

static void thu(WebClient wc, int cong, String duong, int soLan, long nghiMs, Vertx vertx,
                Promise<HttpResponse<Buffer>> p, int lan, Long hanChotNs, boolean rung) {
    if (hanChotNs != null && System.nanoTime() > hanChotNs) { p.fail("het ngan sach thoi gian"); return; }
    wc.get(cong, "localhost", duong).send().onComplete(ar -> {
        boolean thanhCong = ar.succeeded() && ar.result().statusCode() < 500;
        if (thanhCong)            { p.complete(ar.result()); return; }
        if (lan + 1 >= soLan)     { p.fail("het luot thu lai"); return; }
        vertx.setTimer(nghi, t -> thu(wc, cong, duong, soLan, nghiMs, vertx, p, lan+1, hanChotNs, rung));
    });
}

A thử 3 lần, B thử 3 lần. Kết quả đo:

A thu 3 x B thu 3          : 1 request cua khach -> 9 luot toi tang C
chi tang ngoai cung thu 3  : 1 request cua khach -> 3 luot toi tang C

Rồi tôi cho chính khách cũng thử lại 3 lần — đúng như trình duyệt, thư viện client, hay một cron vẫn hay làm:

khach thu 3 x A thu 3 x B thu 3 -> 27 luot toi tang C

Đúng 27, không lệch một lượt. Công thức là tích, không phải tổng: ba tầng, mỗi tầng ba lượt, là 3³.

Con số này quan trọng vì nó lật ngược ý nghĩa của việc thử lại. Ở điều kiện bình thường retry là mạng lưới an toàn. Ở đúng lúc hạ nguồn quá tải — tức là đúng lúc nó bắt đầu trả lỗi — retry biến tải bình thường thành tải gấp 27 lần. Hạ nguồn đang chệch choạc thì bị chôn hẳn, và nó không thể hồi phục vì lượng công việc đổ vào tăng đúng khi năng lực xử lý giảm.

Đây là cơ chế của phần lớn các sự cố dây chuyền: không phải cái lỗi đầu tiên gây ra thảm hoạ, mà là phản ứng của hệ thống trước cái lỗi đó.

Luật thứ nhất: chỉ một tầng được thử lại

Số đo cho câu trả lời trực tiếp — chỉ tầng ngoài cùng thử lại thì C nhận 3 lượt thay vì 27, gấp chín lần ít hơn mà vẫn giữ nguyên khả năng vượt qua lỗi thoáng qua.

Chọn tầng nào thì tuỳ, và mỗi lựa chọn có cái giá của nó:

  • Tầng trong cùng (ngay sát dịch vụ hỏng) biết rõ nhất lỗi này có đáng thử lại không — nó phân biệt được "kết nối bị từ chối" với "dữ liệu sai". Nhưng nó không biết tầng trên còn bao nhiêu thời gian.
  • Tầng ngoài cùng biết ngân sách thời gian nhưng đã mất hết thông tin về loại lỗi, và mỗi lượt thử của nó tốn công của toàn bộ chuỗi.

Trong thực tế, cái đắt nhất không phải chọn sai tầng mà là không ai biết mình đang ở tầng thứ mấy. Thư viện client thường bật retry mặc định — vài SDK của nhà cung cấp đám mây thử lại 3 lần mà không nói gì — nên tầng của bạn có thể đang là tầng thứ hai hoặc thứ ba mà bạn không hề hay. Việc đầu tiên đáng làm là đếm, y như phép đo ở trên.

Luật thứ hai: truyền hạn chót xuống dưới

Kể cả khi mỗi tầng chỉ thử lại "một cách hợp lý", chúng vẫn tiêu thời gian chồng lên nhau. Tôi cho mỗi lượt tới C mất 60 ms rồi đo lại:

Lượt tới C Thời gian
Không hạn chót 9 567 ms
Ngân sách 300 ms cho cả chuỗi 5 312 ms

Cách làm là tầng ngoài tính một mốc hết hạn tuyệt đối, gửi nó xuống theo header, và mỗi tầng kiểm trước khi thử lại:

// tang A: cap ngan sach
long hanChot = System.nanoTime() + 300_000_000L;
goiCoThuLai(wc, B, "/co-han-chot", 3, 0, vertx, hanChot, false);

// moi tang, truoc moi luot thu:
if (hanChotNs != null && System.nanoTime() > hanChotNs) { p.fail("het ngan sach thoi gian"); return; }
req.putHeader("x-han-chot-ns", String.valueOf(hanChotNs));

Điểm mấu chốt là gửi mốc hết hạn tuyệt đối, không phải "còn bao nhiêu mili giây". Gửi thời lượng thì mỗi chặng lại khởi động lại đồng hồ của nó và ngân sách phình ra theo số chặng — đúng cái bệnh mà ta đang chữa.

Không có hạn chót thì tầng ngoài bỏ cuộc ở 300 ms trong khi các tầng dưới vẫn đang chạy tiếp — không ai bảo chúng dừng, và kết quả chúng cực nhọc làm ra sẽ bị vứt đi. Chuỗi request–reply ở phần 23 đã cho thấy đúng hành vi này trên event bus: hết hạn chờ không hề huỷ công việc đang chạy.

Rung ngẫu nhiên: nó làm gì, và cái nó không làm được

Lời khuyên chuẩn là thêm rung ngẫu nhiên vào khoảng nghỉ để 200 khách cùng gặp lỗi không cùng quay lại một lúc. Tôi cho 200 khách bắn đồng thời, nghỉ 100 ms giữa các lượt, rồi đếm số lượt tới C theo từng ô 50 ms.

Lần đo đầu tiên của tôi nói rằng rung ngẫu nhiên tệ hơn: đỉnh cao nhất trong một cửa sổ 100 ms là 265 lượt so với 200 lượt của bản không rung. Nhưng nhìn vào đường thời gian thì thấy con số đó không đo cái tôi tưởng — nó bị chi phối bởi việc 200 luồng khách của tôi khởi động rải rác khác nhau giữa hai lần chạy, chứ không phải bởi thuật toán. Đó là lỗi của phép đo, không phải phát hiện.

Cho 200 luồng chờ nhau ở một Barrier rồi cùng bắn, chạy ba lần:

Đợt đầu Các sóng thử lại Đỉnh sóng thử lại
Nghỉ đều 200 [200, 200] 200
Rung ngẫu nhiên 200 [56, 102, 106, 76, 56, 4] 106
Rung, lần 2 200 [46, 95, 115, 81, 50, 13] 115
Rung, lần 3 200 [63, 95, 112, 80, 43, 7] 112

Nghỉ đều tạo ra hai cột dựng đứng, mỗi cột đúng 200 — cả đàn di chuyển thành một khối. Rung ngẫu nhiên trải chúng ra sáu ô, đỉnh còn khoảng 110, tức thấp hơn 45%, và ba lần chạy đều cho cùng một hình dạng.

Hai điều cần nói rõ về con số đó:

Rung không làm giảm tổng tải. Cả hai đều là 600 lượt tới C. Nó đổi hình dạng chứ không đổi lượng — hữu ích vì hạ nguồn hỏng vì đỉnh chứ không hỏng vì tổng, nhưng nếu bạn cần giảm tải thì phải giảm số lượt thử, không phải rải chúng ra.

Rung không làm gì được với đợt đầu. Ô đầu tiên là 200 ở cả hai bảng, và không thể khác được: 200 khách đó chưa hề thử lại lần nào. Rung ngẫu nhiên chỉ can thiệp vào khoảng cách giữa các lượt, nên nếu tai hoạ của bạn đến từ việc mọi khách cùng đến một lúc — hết hạn cache đồng loạt, cron chạy đúng phút tròn, đám kết nối cùng nối lại sau một sự cố mạng — thì phải rung ở chính chỗ sinh ra đợt đầu, chứ rung ở chỗ thử lại thì đã muộn.

Danh sách kiểm cho một cơ chế thử lại

  • Đếm trước, sửa sau. Đặt một biến đếm ở tầng cuối rồi gửi đúng một request. Con số nhận được là thứ duy nhất đáng tin về việc bạn có bao nhiêu tầng thử lại.
  • Chỉ thử lại thứ đáng thử. 503, 502, timeout, kết nối bị từ chối — có. 400, 401, 422 — thử lại bao nhiêu lần cũng vẫn hỏng, chỉ tốn công. Nhiều thư viện mặc định thử lại mọi lỗi.
  • Chỉ thử lại thao tác lặp lại được an toàn. GET thì được; POST tạo đơn hàng thì cần khoá chống trùng, nếu không "một lỗi thoáng qua" sẽ thành ba đơn hàng.
  • Nghỉ tăng dần kèm rung, không nghỉ đều.
  • Ngân sách thời gian tuyệt đối truyền xuống dưới, không phải thời lượng.
  • Nối với circuit breaker. Thử lại giả định lỗi là thoáng qua; breaker là thứ nhận ra khi giả định đó sai và cắt hẳn — phần 25 đo được nó cắt 30 lượt xuống 5. Có breaker mà không giới hạn thử lại thì retry vẫn kịp bồi thêm mấy lượt trước khi breaker mở.

Thử ba mươi giây

Đếm số tầng thử lại trong hệ thống của bạn, bằng đúng một request:

// tang trong cung nhat cua ban
static final AtomicLong dem = new AtomicLong();
r.get("/viec").handler(c -> { dem.incrementAndGet(); c.response().setStatusCode(503).end(); });
r.get("/dem").handler(c -> c.response().end(String.valueOf(dem.get())));
curl -s localhost:8080/dat-lai
curl -s https://api-cua-ban/duong-dan-di-qua-het-cac-tang   # dung MOT request
curl -s localhost:8080/dem

Ra 1 là bạn không có tầng thử lại nào. Ra 3 là có một tầng. Ra 9 hay 27 thì bạn vừa tìm ra thứ sẽ đánh sập hệ thống vào ngày hạ nguồn có sự cố đầu tiên.

Phần sau bàn về idempotency và khoá chống trùng — thứ mà mọi cơ chế thử lại đều ngầm giả định là đã có sẵn.