Circuit Breakers và Cách các kĩ sư Grab thiết kế hệ thống phản ứng lỗi (Phần 2)

Bài viết này là phần thứ hai của loạt bài về Thiết kế Hệ thống Khả năng phản hồi lỗi. Ở Phần 1, chúng ta đã xem xét các trường hợp sử dụng để triển khai circuit breaker. Trong phần thứ hai này, chúng ta sẽ đi chi tiết về Retry và các trường hợp sử dụng của nó, tiếp theo là một so sánh về mặt kỹ thuật giữa cả hai phương pháp.
Link bài viết phần 1: Circuit Breakers và Cách các kĩ sư Grab thiết kế hệ thống phản ứng lỗi (Phần 1)
Giới thiệu về Retry
Retry là một cơ chế phần mềm theo dõi request và nếu phát hiện lỗi, nó sẽ tự độngthực hiện lại request. Hãy lấy ví dụ sau:

Giả sử load balancer của chúng ta được cấu hình để thực hiện load balance theo phương pháp round-robin, điều này có nghĩa là với hai máy chủ trong dịch vụ upstream của chúng ta, request đầu tiên sẽ được chuyển đến một máy chủ và request thứ hai sẽ được chuyển đến máy chủ khác.
Nếu requestu của chúng ta không may mắn và chúng ta được định tuyến đến máy chủ gặp sự cố, thì quá trình tương tác của chúng ta sẽ trông như sau:

Tuy nhiên, khi Retry tại đây, tương tác của chúng ta sẽ như thế này:

Bạn sẽ nhận thấy một số điều ở đây. Thứ nhất, do Retry, chúng ta đã hoàn thành quá trình xử lý một cách thành công; có nghĩa là chúng ta đang trả về ít lỗi hơn cho người dùng của chúng ta.
Thứ hai, trong khi request của chúng ta thành công, nhưng nó đòi hỏi nhiều tài nguyên hơn (CPU và thời gian) để hoàn thành. Chúng ta phải retry yêu cầu đầu tiên, đợi và phát hiện sự cố, trước khi lặp lại và thành công ở lần retry thứ hai.
Cuối cùng, khác với circuit breaker (đã thảo luận ở Phần 1), chúng ta không tracking kết quả của các request của mình. Do đó, chúng ta không làm gì để ngăn chặn bản thân khỏi việc gửi yêu cầu đến máy chủ gặp sự cố trong tương lai. Ngoài ra, trong ví dụ của chúng ta, request thứ hai đã được định tuyến đến máy chủ hoạt động. Điều này sẽ không luôn luôn như vậy, vì sẽ có nhiều request đồng thời từ service của chúng ta và có thể là yêu cầu từ cácservice khác. Do đó, chúng ta không đảm bảo sẽ có một máy chủ hoạt động trong lần retry thứ hai. Thực tế, cơ hội để chúng ta có một máy chủ hoạt động bằng với tỷ lệ số máy chủ hoạt động chia cho tổng số máy chủ, trong trường hợp này là 50%.
Đi sâu vào một chút, chúng ta có 50% cơ hội để nhận một máy chủ xấu trong request đầu tiên, và 50% cơ hội trong lần thử lại. Mở rộng ra, chúng ta do đó có một cơ hội là 50% x 50% = 25% để thất bại ngay cả sau 1 lần thử lại. Nếu chúng ta thử lại hai lần, điều này trở thành 12.5%.
Hiểu khái niệm này sẽ giúp bạn xác định cài đặt số lần thử lại tối đa của mình.
Chúng ta có nên Retry cho tất cả các lỗi không?
Câu trả lời ngắn gọn là không. Chúng ta nên xem xét việc retry lại request nếu nó có bất kỳ cơ hội nào để thành công (ví dụ: mã lỗi 503 - Service Temporarily Unavailable và 500 - Internal Server Error). Ví dụ, đối với mã lỗi 503, một lần thử lại có thể hoạt động nếu kết quả của lần thử lại dẫn đến 1 request đến một máy chủ không bị quá tải. Ngược lại, đối với các lỗi như 401 - Not Authorization hoặc 400 - Bad Request, việc thử lại này là lãng phí tài nguyên vì chúng sẽ không bao giờ hoạt động nếu người dùng không thay đổi request của họ.
Có hai điểm chính cần xem xét: Thứ nhất, upstream service phải trả về lỗi hợp lý và có thông tin, và thứ hai, cơ chế retry của chúng ta phải được cấu hình để phản ứng khác nhau đối với các loại lỗi khác nhau.
Idempotency
Một process (function hoặc request) được coi là idempotent nếu nó được retry bất kỳ số lần nào (tức là một hoặc nhiều lần) và có kết quả giống nhau.
Hãy tưởng tượng bạn có một REST endpoint load thông tin về một thành phố. Mỗi khi bạn gọi phương thức này, bạn nên nhận được cùng một kết quả. Bây giờ, giả sử chúng ta có mộtendpoint khác, nhưng lần này là để đặt vé. Nếu chúng ta gọi endpoint này hai lần, chúng ta sẽ đặt được 2 vé. Điều này liên quan như thế nào đến việc retry?
Hãy xem xét lại tương tác retry của chúng ta ở trên:

Điều gì s4 xảy ra nếu request đầu tiên của chúng ta đến máy chủ gặp sự cố thực sự đặt vé nhưng không thể phản hồi chính xác cho chúng ta, thì lần thử lại đến máy chủ thứ hai có thể dẫn đến việc đặt vé lần thứ hai, và chúng ta sẽ không biết rằng chúng ta đã mắc phải một lỗi.
Điều này xảy ra vì endpoint đặt vé của chúng ta không phải là idempotent.
Vui lòng không hiểu theo cách rằng chỉ có các hoạt động đọc mới có thể được retry và tất cả các thao tác ghi/mutate data thì không thể
Hoạt động đặt vé vé hầu như luôn liên quan đến một lượng vé hữu hạn, và trong tình huống như vậy, một request chỉ dẫn tới 1 vé nhất định. Các tình huống tương tự khác có thể bao gồm việc thu tiền từ thẻ tín dụng hoặc tăng giá trị của một bộ đếm.
Một số hoạt động ghi, như lưu thông tin đăng kí hoặc cập nhật một record bằng 1 một giá trị cụ thể (mà không có tính toán), có thể được lặp lại. Việc lưu nhiều thông tin đăng ký có thể làm cho dữ liệu lộn xộn, nhưng điều này có thể được clean up bằng một quy trình khác không liên quan đến người sử dụng. Trong trường hợp này, nên đảm bảo chúng ta đáp ứng yêu cầu của người dùng với nhiều chi phí hơn là để cho nó thất bại và để lại cho hệ thống một trạng thái không xác định và đưa ra vấn đề cho người dùng. Ví dụ, giả sử chúng ta đang cập nhật mật khẩu người dùng thành abc123, trạng thái cuối cùng này được người dùng cung cấp là cố định và, do đó, lặp lại quy trình chỉ làm lãng phí tài nguyên của kho dữ liệu.
Trong những trường hợp nơi việc retry là có thể, nhưng bạn muốn có khả năng phát hiện và ngăn chặn các transaction trùng lặp (như trong ví dụ đặt vé của chúng ta), có thể đưa vào sử dụng một cryptographic nonce. Chủ đề này sẽ đòi hỏi một bài viết riêng biệt, nhưng giải thích ngắn là: một cryptographic nonce là một số ngẫu nhiên được thêm vào một yêu cầu giúp chúng ta phát hiện rằng hai yêu cầu thực sự là một.
Nếu đó không rõ ràng, đây là một ví dụ:
Giả sử chúng ta nhận được một yêu cầu đăng ký vé từ người dùng, và chúng ta thêm vào đó một số ngẫu nhiên. Bây giờ, khi chúng ta gọi upstream service của mình, chúng ta có thể truyền dữ liệu yêu cầu cùng với nonce. Yêu cầu này được xử lý một phần nhưng sau đó gặp sự cố và trả về mã lỗi HTTP 500 - Internal Server. Chúng ta thử lại yêu cầu này với một máy chủ upstream service khác và lại cung cấp dữ liệu yêu cầu và cùng một nonce chính xác. Máy chủ upstream service giờ có thể sử dụng nonce này và các thông tin nhận dạng khác trong yêu cầu (ví dụ: id người tiêu dùng, số lượng, loại vé, v.v.) để xác định rằng cả hai yêu cầu đều xuất phát từ cùng một yêu cầu của người dùng và do đó nên được xử lý như một. Trong trường hợp của chúng ta, điều này có thể có nghĩa là chúng ta trả lại các vé đã đặt từ request đầu tiên và hoàn tất quá trình xử lý.
Backoff
Trong ví dụ trước đó của chúng ta, khi chúng ta gặp thất bại, chúng ta ngay lập tức retry và vì load balancer chuyển hướng cho chúng ta đến một máy chủ khác, nên request thứ hai đã thành công. Tuy nhiên, điều này thực sự không hoạt động như vậy. Triển khai thực tế bao gồm delay/wait in-between giữa các request như sau:

Thời gian chờ giữa các yêu cầu này được gọi là backoff.
Hãy xem xét xem điều gì sẽ xảy ra khi tất cả các máy chủ của upstream service đều bị tắt. Hãy nhớ rằng, upstream service có thể chỉ là một máy chủ (như một cơ sở dữ liệu). Nếu chúng ta thử lại ngay lập tức, chúng ta có khả năng cao sẽ gặp thất bại liên tục, cho đến khi chúng ta vượt quá số lần thử lại tối đa.
Nhìn qua, backoff process là một quy trình thay đổi thời gian chờ giữa các lần retry dựa trên số lần thất bại trước đó.
Quay lại ví dụ của chúng ta, hãy giả sử rằng thời gian chờ lại của chúng ta là 100 mili giây.
| Retry Attempt | Delay |
|---|---|
| 1 | 1 x 100ms = 100ms |
| 2 | 2 x 100ms = 200ms |
| 5 | 5 x 100ms = 500ms |
| 10 | 10 x 100mx = 1,000ms |
Lý thuyết cơ bản ở đây là nếu một yêu cầu đã thất bại một số lần, thì có khả năng cao nó sẽ thất bại một lần nữa. Do đó, chúng ta muốn tăng khả năng cho upstream service có thể phục hồi và có khả năng thực hiện yêu cầu của chúng ta.
Bằng cách tăng thời gian chờ, chúng ta không chỉ đang cung cấp thêm thời gian để phục hồi, mà còn đang phân tán tải của các request và lần retry của chúng ta. Trong những trường hợp yêu cầu thất bại do upstream service quá tải, việc phân tán tải này cũng mang lại khả năng thành công lớn hơn.
Jitter
Với việc áp dụng backoff, chúng ta có một cách để phân tán tải mà chúng ta đang gửi đến upstream service. Tuy nhiên, tải vẫn sẽ có những đợt cao điểm.
Giả sử chúng ta thực hiện 10,000 yêu cầu và tất cả đều thất bại vì upstream service không thể xử lý số lượng lớn request đồng thời đó. Theo triển khai backoff đơn giản từ trước đó, sau thời gian chờ 100ms, chúng ta sẽ thử lại tất cả 10,000 yêu cầu, cũng sẽ thất bại vì cùng một lý do. Để tránh điều này, chúng ta triển khai retry bao gồm jitter. Jilter là quy trình tăng hoặc giảm thời gian chờ so với thời gian chờ tiêu chuẩn để phân tán tải . Trong ví dụ của chúng ta, điều này có thể đồng nghĩa với việc các yêu cầu 10,000 của chúng ta bị trì hoãn trong khoảng 70-150ms (đối với lần thử lại đầu tiên) theo một lượng ngẫu nhiên làm cho một lượng lớn request sẽ không tập trung retry cùng 1 lúc
Mục tiêu ở đây tương tự như trước đó, đó là làm giảm tải các request.
Settings
Ở Grab, chúng tôi đã triển khai thư viện Retryi của riêng mình lấy cảm hứng từ bài viết trên blog AWS này. Trong thư viện này, chúng tôi có các cài đặt sau:
Maximum Retries
Giá trị này cho biết số lần request có thể được thử lại trước khi từ bỏ (không thành công).
Retry Filter
Đây là hàm xử lý lỗi trả về và quyết định xem có nên thử lại yêu cầu hay không.
Base và Max Delay
Khi kết hợp các khái niệm về backoff và jilter, chúng ta thu được hai thiết lập này.
Thời gian chờ cơ bản (base delay) là thời gian chờ tối thiểu giữa các lần thử lại, trong khi thời gian chờ tối đa (max delay) là thời gian chờ tối đa giữa các lần thử lại.
Lưu ý: Thời gian chờ thực tế sẽ luôn nằm giữa các giá trị cho Thời gian chờ Cơ bản và Tối đa, và cũng sẽ dựa trên số lần thử lại (số lần thất bại trước đó).
Time-boxing Requests
Trong khi mục tiêu cơ bản của cơ chế retry là làm mọi thứ có thể để đáp ứng yêu cầu của người dùng bằng cách thử lại cho đến khi chúng ta hoàn thành request thành công, chúng ta không thể thử mãi mãi.
Ở một điểm nào đó, chúng ta cần từ bỏ và chấp nhận thất bại.
Khi cấu hình cơ chế retry, điều quan trọng là điều chỉnh cùng nhau các giá trị Maximum Retries, Request Timeout và Maximum Delay. Mục tiêu cần nhớ khi điều chỉnh các giá trị này là thời gian phản hồi tệ nhất cho người tiêu dùng của chúng ta.
Thời gian phản hồi tệ nhất có thể được tính như sau: (maximum retries x request timeout) + (maximum retries x maximum delay)
Ví dụ:
| Max Retries | Request Timeout (ms) | Maximum Delay (ms) | Total Time for first attempt and retries | Total time for delays | Total Time Overall (ms) |
|---|---|---|---|---|---|
| 2 | 100 | 200 | 3 x 100 | 2 x 200 | 800 |
| 5 | 100 | 200 | 6 x 100 | 5 x 200 | 1,600 |
| 3 | 500 | 200 | 4 x 500 | 3 x 200 | 2,600 |
Bạn có thể thấy từ bảng này tổng lượng thời gian thực hiện tăng lên rất nhanh.
Circuit Breakers vs Retries
Một số cuộc thảo luận ban đầu bắt đầu loạt bài này tập trung vào một câu hỏi “tại sao lại sử dụng circuit breaker khi bạn có thể retry?” Hãy tìm hiểu sâu hơn một chút.
Chỉ giao tiếp với Retry
Giả sử chúng ta dành đủ thời gian để lập kế hoạch, theo dõi và điều chỉnh cài đặt retry, thì một hệ thống chỉ có Retry sẽ có cơ hội tuyệt vời để đạt được thành công trong các mục tiêu của chúng ta chỉ bằng cách thử lại. Hãy xem xét ví dụ trước đây của chúng tôi:

Trong ví dụ đơn giản này, việc đặt số lần thử lại của chúng ta thành 1 sẽ đảm bảo rằng chúng tôi sẽ đạt được mục tiêu của mình. Nếu lần thử đầu tiên đến máy chủ bị hỏng, chúng tôi chỉ cần thử lại và được phân phối tải đến working service khác.
Nghe có vẻ tốt phải không? Vậy nhược điểm ở đâu? Hãy xem xét một kịch bản thất bại nơi broken service của chúng ta không gây ra lỗi ngay lập tức mà thay vào đó nó không bao giờ phản hồi. Điều này có nghĩa là:
- Khi được định tuyến đến working service trước thì thời gian phản hồi sẽ nhanh, bất kể thời gian xử lý của working service là bao lâu.
- Khi được định tuyến đến broken service trước thì thời gian phản hồi sẽ bằng với thiết lập Request Timeout của chúng ta cộng với thời gian xử lý của working servicec.
Như bạn có thể tưởng tượng, nếu chúng ta có nhiều máy chủ hơn và đặc biệt là nhiều máy chủ bị hỏng hơn, thì chúng ta sẽ cần đặt mức cao hơn cho Maximum Retries, và điều này sẽ dẫn đến thời gian phản hồi tiềm tàng cao hơn (tức là bội số của thiết lập Request Timeout).
Bây giờ hãy xem xét kịch bản xấu nhất - khi tất cả các máy chủ ở phía trên đều đang tắt. Tất cả các yêu cầu của chúng ta sẽ mất ít nhất Maximum Retries x Request Timeout để hoàn thành. Tình huống này được gọi là sự cố cascading failure.
Một hình thức khác của sự cố cascading failure xảy ra khi tải mà nên được xử lý bởi một máy chủ hỏng được thêm vào một máy chủ làm việc, làm cho máy chủ làm việc trở nên quá tải.
Ví dụ, nếu trong ví dụ trên của chúng ta, chúng ta có 2 máy chủ có khả năng xử lý 10k yêu cầu/giây trên mỗi máy chủ. Nếu hiện tại chúng ta có 15k yêu cầu/giây, thì bộ cân bằng tải của chúng ta đã phân phối tải, và chúng ta có 7.5k yêu cầu/giây trên mỗi máy chủ.
Tuy nhiên, vì tất cả các yêu cầu đến máy chủ bị hỏng đều được thử lại trên máy chủ làm việc, máy chủ làm việc của chúng ta đột ngột phải xử lý số lượng yêu cầu ban đầu của mình cộng với số lần thử lại, cho nên có tổng cộng 15k yêu cầu/giây để xử lý, mà nó không thể làm được.
Chỉ giao tiếp với bộ Circuit Breaker
Nhưng nếu bạn chỉ triển khai một bộ circuit breaker và không có Retry? Có hai yếu tố cần lưu ý trong kịch bản này. Thứ nhất, tỷ lệ lỗi của hệ thống của chúng ta là tỷ lệ lỗi được nhìn thấy bởi người dùng. Ví dụ, nếu hệ thống của chúng ta có tỷ lệ lỗi là 10% thì 10% người dùng của chúng ta sẽ nhận được lỗi.
Thứ hai, nếu tỷ lệ lỗi của chúng ta vượt quá Error Percent Threshold thì mạch sẽ mở, và sau đó 100% người dùng của chúng ta sẽ nhận được lỗi mặc dù có các máy chủ có thể xử lý yêu cầu thành công.
Circuit Breaker và Retries
Lựa chọn thứ ba đương nhiên là áp dụng cả hai cơ chế Circuit Breaker và Retry.
Lấy ví dụ tương tự như chúng ta đã sử dụng trong phần trước, nếu chúng ta retry 10% các yêu cầu đã thất bại một lần, 90% trong số đó sẽ thành công ở lần thử lại thứ hai. Tỷ lệ thành công của chúng ta sẽ tăng từ 90% ban đầu lên thành 90% + (90% x 10%) = 99%.
Có thể một side-effect thú vị khác của việc retry và hoàn thành yêu cầu thành công là ảnh hưởng của nó đối với mạch chính nó. Trong ví dụ của chúng ta, tỷ lệ lỗi của chúng ta đã chuyển từ 10% xuống còn 1%. Sự giảm đáng kể này trong tỷ lệ lỗi của chúng ta có nghĩa là mạch của chúng ta ít có khả năng mở và ngăn chặn tất cả các yêu cầu.
Circuit Breaker bên trong Retries / Retries bên trong Circuit Breaker
Có vẻ lạ nhưng rất quan trọng là bạn dành một chút thời gian để xem xét thứ tự bạn đặt các cơ chế.
Ví dụ, khi bạn có cơ chế Retry bên trong Circuit , khi Circuit gặp sự cố, điều này có nghĩa là chúng ta đã cố gắng thử lại nhiều lần và vẫn thất bại. Một lỗi trong tình huống này có thể là khá không thường xuyên. Theo quan điểm mở rộng, chúng ta nên xem xét sử dụng một Error Percent Threshold rất thấp làm tín hiệu mở Circuit .
Mặt khác, khi chúng ta có một Circuit bên trong một cơ chế Retry, khi cơ chế Retry gặp sự cố, điều này có nghĩa là hoặc mạch bị mở, hoặc chúng ta đã thất bại trong một yêu cầu cụ thể. Trong cấu hình này, Circuit đang giám sát tất cả các yêu cầu cụ thể thay vì batch trong trường hợp trước đó. Do đó, lỗi sẽ xảy ra thường xuyên hơn nhiều. Do đó, chúng ta nên xem xét một Error Percent Threshold cao trước khi mở Circuit. Cấu hình này cũng là cách duy nhất để đạt được circuit breaker per host.
Cấu hình thứ hai là lựa chọn ưa thích của tôi một cách rõ ràng. Tôi ưa thích nó vì:
- Circuit đang giám sát tất cả các yêu cầu.
- Mạch không bị ảnh hưởng không cần thiết bởi một bad request. Ví dụ, một yêu cầu có tải dữ liệu lớn có thể thất bại khi gửi đến tất cả các máy chủ, nhưng tất cả các yêu cầu khác đều ổn. Nếu chúng ta có cài đặt Error Percent Threshold thấp, điều này có thể ảnh hưởng không cần thiết đến mạch.
- Tôi muốn đảm bảo rằng bulwark bên trong cài đặt Circuit của chúng tôi cũng bảo vệ upstream service khỏi các yêu cầu quá mức, điều này thực hiện hiệu quả hơn khi theo dõi các yêu cầu cá nhân.
- Nếu tôi đặt Cài đặt Timeout trên mạch của mình thành một số lớn (ví dụ, 1 giờ), thì tôi có thể hiệu quả bỏ qua nó và việc tính toán thời gian tối đa có thể dành cho việc gọi upstream service được đơn giản hóa thành (maximum retries x request timeout) + (maximum retries x maximum delay). Vâng, điều này không đơn giản nhưng đó là một cài đặt ít phải lo lắng hơn.
Final Thoughts
Trong loạt hai phần này, chúng ta đã giới thiệu hai cơ chế phần mềm hữu ích có thể tăng tính tin cậy của việc giao tiếp của chúng ta với các upstream service bên ngoài.
Chúng ta đã thảo luận về cách chúng hoạt động, cách cấu hình chúng và một số vấn đề ít rõ ràng mà chúng ta phải xem xét khi sử dụng chúng.
Mặc dù có thể sử dụng chúng một cách riêng lẻ, đối với tôi, không bao giờ nên có một câu hỏi liệu bạn nên có một Circuit hay một cơ chế Retry. Nơi có thể, bạn nên luôn có cả hai. Với bulwark được thêm vào miễn phí trong cài đặt cầu chì của chúng tôi, nó còn trở nên tốt hơn.
Điều duy nhất có thể làm cho việc làm việc với một dịch vụ upstream trở nên tốt hơn đối với tôi (ví dụ, đáng tin cậy hơn và có thể nhanh hơn) là thêm một bộ nhớ cache trước tất cả. Nhưng chúng ta sẽ để điều đó cho một bài viết khác.
Hy vọng bạn đã thích loạt bài này và thấy nó hữu ích. Ý kiến, sửa đổi và thậm chí là sự không đồng ý được cân nhắc luôn được hoan nghênh.
Chúc bạn mãi mãi vui vẻ khi lập trình!