Sự cố có thể xảy ra

Mỗi khi một dịch vụ hoặc hệ thống gửi lệnh gọi đến dịch vụ/hệ thống khác, thì sự cố có thể xảy ra. Có nhiều yếu tố gây ra những sự cố này. Đó là máy chủ, mạng, cân bằng tải, phần mềm, hệ điều hành hoặc thậm chí là sai sót từ phía người vận hành hệ thống. Chúng tôi thiết kế các hệ thống của mình để giảm thiểu xác suất xảy ra sự cố, nhưng không thể nào xây dựng các hệ thống không hề gặp trục trặc được. Bởi vậy tại Amazon, chúng tôi thiết kế hệ thống để tiếp nhận và giảm bớt xác suất xảy ra sự cố, đồng thời tránh việc một sự cố nhỏ khiến cả hệ thống ngừng hoạt động. Để xây dựng các hệ thống mạnh mẽ, chúng tôi sử dụng 3 công cụ thiết yếu: thời gian chờ (timeout), thử lại (retry) và rút lại (backoff).

Nhiều loại sự cố sẽ trở nên rõ ràng do yêu cầu mất nhiều thời gian hơn bình thường và có thể là không hoàn thành được. Khi máy khách chờ yêu cầu hoàn thành trong thời gian lâu hơn bình thường, thì nó cũng giữ lại các tài nguyên dùng cho yêu cầu đó lâu hơn. Khi có nhiều yêu cầu giữ lại tài nguyên trong thời gian dài, tài nguyên của máy chủ có thể bị cạn kiệt. Những tài nguyên này có thể là bộ nhớ, luồng công việc, kết nối, cổng tạm thời hoặc bất cứ thứ gì khác có giới hạn. Để tránh tình trạng này, máy khách đặt thời gian chờ. Thời gian chờ là khoảng thời gian tối đa mà máy khách đợi yêu cầu hoàn tất.

Thông thường, việc thử lại sẽ giúp yêu cầu thực hiện thành công. Điều này xảy ra vì các loại hệ thống mà chúng tôi xây dựng không thường xuyên gặp sự cố như một đơn vị thống nhất. Thay vào đó, hệ thống thường gặp sự cố từng phần hoặc tạm thời. Sự cố từng phần là khi chỉ có một phần của yêu cầu được thực hiện thành công. Sự cố tạm thời là khi yêu cầu không thực hiện được trong khoảng thời gian ngắn. Thử lại giúp máy khách vượt qua các sự cố từng phần và tạm thời này bằng cách gửi lại yêu cầu.

Việc thử lại không phải lúc nào cũng an toàn. Việc thử lại có thể tăng tải lên hệ thống nhận yêu cầu, nếu hệ thống đó đang gặp sự cố do sắp bị quá tải. Để tránh tình huống này, chúng tôi triển khai các máy khách để sử dụng thuật toán rút lại. Đây là thuật toán tăng thời gian giữa các lần thử lại nối tiếp nhau, từ đó cân bằng tải ở phía máy chủ. Một vấn đề khác đối với thử lại là một số lệnh gọi từ xa có tác dụng phụ. Thời gian chờ hoặc sự cố không nhất thiết có nghĩa là các tác dụng phụ không xảy ra. Nếu khó tạo được hiệu ứng phụ nhiều lần, thì cách hay nhất là thiết kế để các API cho kết quả bất biến (idempotent), tức là chúng có thể được thử lại một cách an toàn.

Cuối cùng, lưu lượng truy cập vào các dịch vụ của Amazon không thực sự ổn định. Thay vào đó, tốc độ của yêu cầu gửi đến thường tăng vượt mức. Nguyên nhân có thể do hành vi của máy khách, khôi phục sau sự cố và thậm chí chỉ là một tác vụ thực thi định kỳ. Nếu lỗi là do tải gây ra, thao tác thử lại có thể không hiệu quả nếu tất cả máy khách đều thử lại cùng một lúc. Để tránh vấn đề này, chúng tôi căn cứ vào phương sai độ trễ (jitter). Đây là khoảng thời gian ngẫu nhiên trước khi thực hiện hoặc thử lại yêu cầu, góp phần ngăn tốc độ tăng vượt mức qua việc dàn trải tốc độ gửi gói tin.

Mỗi giải pháp nêu trên sẽ được thảo luận trong các phần bên dưới.

Thời gian chờ

Cách làm hay nhất ở Amazon là đặt thời gian chờ cho mọi lệnh gọi từ xa và cho mọi lệnh gọi trên các quy trình trong cùng một nhóm. Điều này bao gồm cả thời gian chờ kết nối và thời gian chờ yêu cầu. Nhiều máy khách tiêu chuẩn cung cấp sẵn các chức năng về thời gian chờ rất mạnh mẽ.
Thông thường, trở ngại lớn nhất là chọn giá trị thời gian chờ. Nếu giá trị thời gian chờ quá cao thì sẽ làm giảm tính hữu ích, vì các tài nguyên vẫn được sử dụng khi máy khách đang trong thời gian chờ. Thời gian chờ quá thấp có thể dẫn đến hai rủi ro:
 
• Lưu lượng tăng cao ở phía backend và độ trễ tăng lên do thử lại quá nhiều yêu cầu.
• Độ trễ tăng ít ở phía backend gây ra tình trạng ngừng hoạt động hoàn toàn, vì tất cả yêu cầu đều bắt đầu được thử lại.
 
Một cách hữu hiệu để chọn thời gian chờ cho các lệnh gọi trong Khu vực AWS là bắt đầu với các số liệu về độ trễ của dịch vụ xuôi chiều. Do đó, ở Amazon, khi thực hiện một lệnh gọi dịch vụ đến dịch vụ khác, chúng tôi chọn một tỷ lệ thời gian chờ sai lệch ở mức chấp nhận được (0,1% chẳng hạn). Sau đó, chúng tôi xem xét phân vị độ trễ tương ứng trên dịch vụ xuôi chiều (p99.9 trong ví dụ này). Cách này áp dụng được cho hầu hết các trường hợp, nhưng cũng có một vài rủi ro như sau:
 
• Cách này không hoạt động trong các trường hợp mà máy khách có độ trễ mạng lớn, chẳng hạn như trên Internet. Trong các trường hợp như vậy, chúng tôi tính toán độ trễ mạng hợp lý trong trường hợp xấu nhất và không quên rằng máy khách có thể duy trì được một khoảng thời gian.
• Cách này cũng không áp dụng cho các dịch vụ phụ thuộc chặt chẽ vào độ trễ, trong đó p99.9 gần bằng p50. Trong các trường hợp như vậy, chúng tôi bổ sung một vài khoảng đệm (padding) để tránh việc độ trễ tăng ít khiến số lượng thời gian chờ tăng cao.
• Chúng tôi đã gặp phải một trở ngại quen thuộc khi triển khai thời gian chờ. SO_RCVTIMEO của Linux rất mạnh mẽ nhưng cũng có một số nhược điểm khiến nó không phù hợp để làm thời gian chờ giữa hai điểm cuối. Một số ngôn ngữ lập trình, chẳng hạn như Java, thể hiện trực tiếp cách kiểm soát này. Các ngôn ngữ khác (như Go) cung cấp cơ chế thời gian chờ mạnh mẽ hơn.
• Ngoài ra còn có các lần triển khai mà thời gian chờ không đáp ứng được tất cả lệnh gọi từ xa, như giao tiếp lần đầu qua DNS hoặc TLS. Nhìn chung, chúng tôi ưu tiên sử dụng thời gian chờ được tích hợp sẵn vào các máy khách đã được kiểm thử đầy đủ. Nếu tự triển khai thời gian chờ thì chúng tôi sẽ chú ý nhiều đến ý nghĩa chính xác của các tùy chọn thời gian chờ giữa hai đầu cuối, cũng như công việc đang được thực hiện.
 
Trong một hệ thống mà tôi vận hành ở Amazon, từng có trường hợp một số lượng nhỏ thời gian chờ giao tiếp với đối tượng phụ thuộc ngay sau khi triển khai. Thời gian chờ được đặt ở mức rất thấp, tối đa khoảng 20 mili giây. Ngoài việc triển khai, ngay cả với giá trị thời gian chờ thấp như vậy, chúng tôi cũng không nhận thấy thời gian chờ xảy ra thường xuyên. Tìm hiểu kỹ hơn nữa, tôi nhận thấy bộ định thời đã thiết lập một kết nối bảo mật mới mà được tái sử dụng cho các yêu cầu tiếp theo. Vì quá trình thiết lập kết nối diễn ra trong hơn 20 mili giây, chúng tôi nhận thấy một lượng nhỏ yêu cầu bị hết thời gian chờ khi máy chủ mới đi vào hoạt động sau khi triển khai. Trong một số trường hợp, yêu cầu được thử lại và thành công. Ban đầu, chúng tôi khắc phục vấn đề này bằng cách tăng giá trị thời gian chờ phòng khi kết nối được thiết lập. Về sau, chúng tôi cải thiện hệ thống bằng cách thiết lập các kết nối này khi khởi động quy trình và trước khi nhận lưu lượng. Đây là giải pháp cho các vấn đề về thời gian chờ.

Thử lại và rút lại

Thử lại có tính chất đòi hỏi cao. Nói cách khác, khi một máy khách thử lại thì nó sẽ tốn nhiều thời gian của máy chủ hơn để có xác suất thành công cao hơn. Việc sự cố hiếm gặp hoặc mang tính tạm thời không thực sự là một vấn đề. Đó là vì tổng số yêu cầu được thử lại là rất nhỏ, và hiệu quả thu được khi độ sẵn sàng tăng lên rõ rệt là khá nhiều. Khi sự cố xảy ra do quá tải, các lần thử lại làm tăng tải có thể khiến vấn đề trở nên tệ hơn nhiều. Thậm chí, chúng còn có thể trì hoãn quá trình khôi phục bằng cách duy trì tải cao trong thời gian dài sau khi vấn đề ban đầu đã được khắc phục. Thử lại cũng giống như một loại thuốc mạnh: có ích khi dùng đúng liều và gây tác hại lớn khi dùng quá liều. Tiếc rằng trong hệ thống phân tán, gần như không có cách nào để điều phối toàn bộ máy khách nhằm đạt được số lần thử lại phù hợp.

Giải pháp mà chúng tôi tin dùng ở Amazon là rút lại. Thay vì thử lại ngay lập tức với tần suất cao, máy khách sẽ đợi một thời gian nhất định giữa các lần thử lại. Quy luật thường gặp nhất là rút lại theo hàm lũy, trong đó thời gian chờ tăng lên theo hàm lũy thừa sau mỗi lần thử lại. Rút lại theo hàm lũy có thể kéo dài các khoảng thời gian rút lại, bởi hàm lũy sẽ tăng lên nhanh chóng. Để tránh thử lại trong thời gian quá dài, thao tác triển khai thường giới hạn rút lại đến giá trị tối đa. Trường hợp này được gọi là rút lại theo hàm lũy có giới hạn. Tuy nhiên, việc này lại đặt ra một vấn đề khác. Hiện tại, tất cả các máy khách đều liên tục thử lại ở tốc độ có giới hạn. Trong hầu hết mọi trường hợp, giải pháp của chúng tôi là giới hạn số lần máy khách thử lại, đồng thời xử lý sự cố đã xảy ra trước đó trong kiến trúc hướng dịch vụ. Trong hầu hết các trường hợp, máy khách sẽ bỏ qua lệnh gọi vì nó có thời gian chờ riêng.

Sau đây là các vấn đề khác với về thử lại:

• Hệ thống phân tán thường có nhiều lớp. Giả sử, có một hệ thống mà lệnh gọi của khách hàng tạo ra các lệnh gọi dịch vụ cao đến 5 ngăn xếp. Cuối cùng, nó gửi truy vấn đến cơ sở dữ liệu và thử lại ba lần ở mỗi lớp. Điều gì xảy ra khi cơ sở dữ liệu bắt đầu các truy vấn lỗi khi đang có tải? Nếu mỗi lớp thử lại theo cách riêng biệt, thì tải trên cơ sở dữ liệu sẽ tăng gấp 243 lần và khiến hệ thống không thể khôi phục được. Đó là vì các lần thử lại ở mỗi lớp sẽ tăng theo cấp số nhân - đầu tiên là 3 lần, rồi đến 9 lần, v.v. Ngược lại, việc thử lại ở lớp cao nhất của ngăn xếp có thể làm lãng phí các lệnh gọi trước đó và giảm hiệu quả. Nhìn chung, để thực hiện các thao tác của cơ chế điều khiển và cơ chế dữ liệu sao cho ít tốn kém nhất, biện pháp chúng tôi tin dùng là thử lại ở một điểm duy nhất trong ngăn xếp.
• Tải. Kể cả khi thử lại ở một lớp duy nhất thì lưu lượng vẫn có thể tăng cao khi lỗi bắt đầu. Cơ chế chặn yêu cầu, trong đó các lệnh gọi gửi đến dịch vụ xuôi chiều bị dừng hoàn toàn khi vượt quá ngưỡng lỗi, được áp dụng rộng rãi để khắc phục vấn đề này. Tiếc rằng cơ chế này lại đưa hành vi mô thức vào các hệ thống, khiến việc kiểm thử trở nên khó khăn và kéo dài thời gian khôi phục. Chúng tôi nhận thấy rằng mình có thể giảm thiểu rủi ro này bằng cách giới hạn số lần thử lại ở mạng cục bộ bằng bộ chứa token. Thuật toán này cho phép các lệnh gọi thử lại khi có token, sau đó thử lại với tốc độ cố định khi dùng hết token. Năm 2016, AWS đã bổ sung hành vi này vào AWS SDK. Vì vậy, khách hàng sử dụng SDK có thể dùng ngay hành vi điều tiết này.
• Quyết định thời điểm thử lại. Nhìn chung, quan điểm của chúng tôi là các API có hiệu ứng phụ thường không an toàn để thử lại, trừ khi chúng cho kết quả bất biến. Điều này bảo đảm rằng các hiệu ứng phụ chỉ xảy ra một lần, bất kể ta thử lại thường xuyên đến mức nào. Các API chỉ đọc thường cho kết quả bất biến, trong khi các API tạo tài nguyên có thể không cho kết quả như vậy. Một số API, chẳng hạn như Amazon Elastic Compute Cloud (Amazon EC2) RunInstances API, cung cấp cơ chế rõ ràng dựa trên token để cho kết quả bất biến và cho phép thử lại một cách an toàn. Để ngăn chặn các hiệu ứng phụ trùng lặp, ta cần có thiết kế API tốt và lưu ý khi triển khai máy khách.
• Biết sự cố nào đáng để thử lại. HTTP phân biệt rõ ràng giữa lỗi máy kháchmáy chủ. Nó cho biết là đối với các lỗi máy khách, không nên thử lại cùng một yêu cầu do xác suất thành công là không cao, trong khi các lỗi máy chủ có thể được khắc phục trong các lần thử lại tiếp theo. Tiếc rằng, tính nhất quán cuối trong hệ thống xóa nhòa ranh giới này. Lỗi máy khách tại thời điểm này có thể thành công tại thời điểm tiếp theo khi trạng thái lan truyền.

Bất kể các rủi ro và thách thức nêu trên, thử lại vẫn là một cơ chế mạnh mẽ để mang lại độ sẵn sàng cao khi gặp lỗi ngẫu nhiên và tạm thời. Để xác định yếu tố đánh đổi phù hợp cho từng dịch vụ, ta cần có óc phán đoán. Theo kinh nghiệm của chúng tôi, hãy bắt đầu bằng cách nhớ rằng thử lại có tính chất đòi hỏi cao. Thử lại là một cách để máy khách khẳng định tầm quan trọng của yêu cầu và đòi hỏi dịch vụ phải dành nhiều tài nguyên để xử lý hơn. Nếu máy khách đòi hỏi quá cao thì có thể gây ra các vấn đề có ảnh hưởng sâu rộng.

Phương sai độ trễ (jitter)

Khi sự cố xảy ra do quá tải hoặc xung đột, thuật toán back off sẽ không hữu ích như ta tưởng. Nguyên nhân là do tính tương quan. Nếu tất cả các lệnh gọi bị lỗi được rút lại về cùng một thời điểm, chúng sẽ gây ra tình trạng xung đột hoặc quá tải khi thử lại. Giải pháp chính là phương sai độ trễ. Giải pháp này bổ sung tính ngẫu nhiên nhất định cho thao tác rút lại, qua đó giãn cách thời gian giữa các lần thử lại. Để biết thêm thông tin về số lượng phương sai độ trễ cần bổ sung và cách bổ sung tốt nhất, hãy xem phần Rút lại theo hàm lũy và phương sai độ trễ.

Phương sai độ trễ không chỉ dành cho thử lại. Kinh nghiệm về vận hành đã chỉ cho chúng tôi rằng lưu lượng chuyển đến dịch vụ, bao gồm cả cơ chế điều khiển lẫn cơ chế dữ liệu, đều có xu hướng tăng cao đột biến. Hiện tượng tăng đột biến về lưu lượng có thể diễn ra trong thời gian rất ngắn và thường ẩn sau các số liệu tổng hợp. Khi xây dựng hệ thống, chúng tôi cân nhắc bổ sung một vài phương sai độ trễ cho tất cả bộ định thời, tác vụ định kỳ và các công việc trì hoãn khác. Điều này góp phần làm tăng đột biến khối lượng công việc và giúp các dịch vụ xuôi chiều thay đổi quy mô để phù hợp với khối lượng công việc.

Khi bổ sung phương sai độ trễ vào công việc theo lịch, chúng tôi không chọn ngẫu nhiên phương sai độ trễ trên từng máy chủ lưu trữ. Thay vào đó, chúng tôi sử dụng một phương thức nhất quán để tạo ra cùng một giá trị phương sai trên cùng một máy chủ lưu trữ. Như vậy, nếu dịch vụ bị quá tải hoặc có tình trạng tranh chấp, nó sẽ xảy ra theo một quy luật. Con người chúng ta rất giỏi xác định quy luật và thường dễ tìm ra căn nguyên hơn. Việc sử dụng một phương thức ngẫu nhiên sẽ đảm bảo rằng khi một tài nguyên bị quá tải thì việc đó chỉ xảy ra ngẫu nhiên. Điều này khiến quá trình khắc phục sự cố trở nên khó khăn hơn nhiều.

Trên các hệ thống mà tôi từng vận hành, như Amazon Elastic Block Store (Amazon EBS) và AWS Lambda, chúng tôi nhận thấy rằng máy khách thường gửi yêu cầu sau các quãng thời gian cố định, như mỗi phút một lần. Tuy nhiên, khi máy khách có nhiều máy chủ hoạt động theo cùng một cách, các máy chủ đó có thể dồn lại và gửi yêu cầu cùng lúc. Đây có thể là vài giây đầu tiên trong một phút hoặc vài giây đầu tiên sau nửa đêm đối với các tác vụ hàng ngày. Bằng cách lưu ý đến lượng tải mỗi giây và làm việc với máy khách để điều chỉnh phương sai độ trễ của khối lượng công việc định kỳ, chúng tôi thực hiện được khối lượng công việc như cũ và tốn ít dung lượng máy chủ hơn.

Chúng tôi có ít quyền kiểm soát hơn đối với hiện tượng tăng đột biến lưu lượng truy cập của khách hàng. Tuy nhiên, kể cả với các tác vụ do khách hàng kích hoạt, việc bổ sung phương sai độ trễ là một ý hay bởi nó không ảnh hưởng đến trải nghiệm của khách hàng.

Kết luận

Trong các hệ thống phân tán, sự cố tạm thời hoặc độ trễ trong các tương tác từ xa là khó tránh khỏi. Thời gian chờ giúp hệ thống không dừng lại quá lâu, thử lại giúp khắc phục sự cố, còn rút lại và phương sai độ trễ có thể cải thiện tính khả dụng và giảm tình trạng tắc nghẽn hệ thống.

Tại Amazon, chúng tôi nhận thấy rằng cần phải thận trọng khi thử lại. Thử lại có thể tăng khối lượng tải trên một hệ thống phụ thuộc. Nếu lệnh gọi gửi đến một hệ thống bị hết thời gian chờ và hệ thống đó bị quá tải, thì việc thử lại không những không khắc phục mà còn khiến tình trạng quá tải trở nên tệ hơn. Để tránh làm vấn đề trầm trọng thêm, chúng tôi chỉ thử lại khi quan sát được rằng yếu tố phụ thuộc đang ở tình trạng tốt. Chúng tôi dừng thử lại khi cách này không góp phần cải thiện tính sẵn sàng.


Về tác giả

Marc Brooker là Kỹ sư chính cấp cao tại Amazon Web Services. Ông đã làm việc ở AWS từ năm 2008 và chuyên về nhiều loại dịch vụ như EC2, EBS và IoT. Hiện ông tập trung vào AWS Lambda, bao gồm cả lĩnh vực mở rộng và ảo hóa. Marc rất thích đọc COE và tài liệu quản lý hậu dự án. Ông có bằng Tiến sĩ về kỹ thuật điện.

Thách thức về hệ thống phân tán Sử dụng biện pháp giảm tải để tránh quá tải Tránh dự phòng trong hệ thống phân tán