Stateful và Stateless
Xem thửGiỏ hàng biến mất sau khi bấm làm mới
gdev-shop vừa nâng từ một lên hai server ứng dụng sau Load Balancer để chịu tải khuyến mãi. Ngay sau đó, bộ phận chăm sóc khách hàng nhận khiếu nại lạ: "Tôi vừa thêm hai sản phẩm vào giỏ, bấm làm mới trang thì giỏ trống trơn, bấm lại thì lại có." Không có lỗi trong log. Từng server chạy hoàn toàn đúng theo code.
Hãy dự đoán trước khi đọc tiếp: điều gì trong kiến trúc mới có thể khiến cùng một người dùng lúc thấy giỏ đầy, lúc thấy giỏ trống?
Giỏ hàng nằm trong bộ nhớ của từng server. Request đầu tiên của khách rơi vào server 1, nơi lưu giỏ. Request làm mới trang được Load Balancer đưa đến server 2, nơi không biết gì về giỏ này. Đây là hệ quả trực tiếp của việc ứng dụng giữ trạng thái ngay trong tiến trình.
State là gì, stateless và stateful khác nhau ra sao?
State (trạng thái) là bất cứ thông tin nào mà hệ thống cần nhớ giữa hai request: giỏ hàng, người dùng đã đăng nhập chưa, tiến độ tải file lên, kết nối WebSocket đang mở.
- Stateless service: server không giữ trạng thái quan trọng của client trong bộ nhớ riêng giữa các request. Mọi thông tin cần thiết hoặc đi kèm trong request, hoặc được lấy từ nơi lưu chung (database, Redis). Server nào xử lý request cũng cho kết quả như nhau.
- Stateful service: trạng thái gắn với một instance cụ thể. Request sau phải đến đúng instance đó mới có kết quả đúng.
Một điểm dễ nhầm: stateless không có nghĩa là hệ thống không có state. Hệ thống nào có người dùng cũng có state. Stateless nghĩa là state được đẩy ra nơi chia sẻ để các server ứng dụng trở nên thay thế được cho nhau.
Cách đọc hình
Nửa trên của hình cho thấy cái sai: giỏ hàng nằm trong RAM của Server 1, nên request sau vào Server 2 thấy giỏ trống và người dùng thấy dữ liệu nhảy. Nửa dưới là cách sửa: giỏ hàng nằm trong Redis thay vì bộ nhớ ứng dụng. App 1 và App 2 đều đọc ghi cùng một nơi, nên server nào nhận request cũng thấy cùng một giỏ. Các server ứng dụng giờ hoàn toàn thay thế được cho nhau, từ đó:
- Thêm server để chịu tải rất đơn giản: bật thêm instance, Load Balancer bắt đầu chia request cho nó.
- Một server chết không làm mất giỏ của khách, vì dữ liệu không nằm ở đó.
- Triển khai bản mới theo từng đợt (rolling deploy) không làm khách mất phiên.
Cái giá phải trả:
- Mỗi lần cần giỏ hàng là thêm một lần gọi mạng đến Redis. Chậm hơn một chút so với đọc bộ nhớ cục bộ.
- Redis trở thành bottleneck và điểm lỗi tiềm tàng: Redis chết thì khách mất giỏ hoặc đăng nhập, nên cần nhân bản và giám sát nó như một thành phần quan trọng.
- Phải thiết kế thời hạn sống (TTL) cho giỏ và phiên để bộ nhớ không phình mãi.
Đang tải sơ đồ…
Đang tải Animation…
Cách đọc sơ đồ
Đây là tình huống sai, và nó cho thấy vì sao state cục bộ là nguy hiểm khi có nhiều instance:
- Request thêm sản phẩm đến Load Balancer, được chuyển đến App 1, nơi lưu giỏ vào bộ nhớ của chính nó rồi trả lời "giỏ có 1 sản phẩm".
- Request xem giỏ lại đi qua Load Balancer, nhưng lần này được chuyển đến App 2. Load Balancer không biết giỏ nằm ở App 1, và thuật toán phân phối có thể chọn bất kỳ server nào.
- App 2 không có dữ liệu giỏ nên trả lời "giỏ trống".
Lỗi không nằm ở Load Balancer hay ở code của từng server. Lỗi nằm ở giả định rằng hai request liên tiếp của một người luôn gặp cùng một server.
Cách sửa: đẩy state ra ngoài
Đang tải Animation…
Những loại state thường gặp và nên đặt ở đâu
| Loại state | Ví dụ ở gdev-shop | Nơi đặt hợp lý |
|---|---|---|
| Dữ liệu nghiệp vụ lâu dài | Đơn hàng, tài khoản | Database |
| Phiên đăng nhập | Ai đang đăng nhập | Redis hoặc token ký số (bài sau) |
| Dữ liệu tạm theo người dùng | Giỏ hàng chưa thanh toán | Redis hoặc database với TTL |
| Cache dữ liệu đọc nhiều | Thông tin sản phẩm | Redis hoặc cache cục bộ có kiểm soát |
| State gắn kết nối | Kết nối WebSocket của chat | Giữ ở instance giữ kết nối, kèm cơ chế chia kết nối |
Một cache cục bộ nhỏ (ví dụ cấu hình hoặc danh mục ít đổi) trong bộ nhớ ứng dụng không phá tính stateless miễn là mất nó thì chỉ chậm đi chứ không sai kết quả. Nguyên tắc cần nhớ: nếu mất dữ liệu trong bộ nhớ của một instance mà kết quả nghiệp vụ bị sai, đó là stateful.
Khi nào chấp nhận stateful?
Có những hệ thống bản chất là stateful: server game giữ trạng thái phòng chơi, ứng dụng soạn thảo cộng tác giữ phiên chỉnh sửa, WebSocket giữ kết nối. Với chúng, state được giữ có chủ đích ở một lớp riêng với cách chia và phục hồi riêng, tách khỏi lớp xử lý CRUD thông thường. Cách phổ biến: giữ phần stateless nhiều nhất có thể và cô lập phần stateful vào đúng chỗ cần.
Những hiểu lầm phổ biến
- "Stateless nghĩa là không lưu gì cả." Sai. Dữ liệu vẫn được lưu, chỉ là không nằm trong bộ nhớ riêng của instance ứng dụng.
- "Chỉ cần thêm Load Balancer là scale được." Chỉ đúng nếu ứng dụng stateless. Nếu không, thêm server chỉ làm lỗi như giỏ hàng biến mất xuất hiện.
- "Stateless luôn nhanh hơn." Không. Nó thêm lời gọi mạng để lấy state, đổi lại khả năng mở rộng và chịu lỗi.
- "Đưa state ra Redis là xong." Redis giờ là thành phần quan trọng cần nhân bản, giám sát và tính toán dung lượng.
Must Know, Good to Know, Advanced
- Must Know: định nghĩa stateless và stateful; vì sao state cục bộ phá việc scale-out; đẩy state ra nơi chia sẻ; cái giá là lời gọi mạng và thêm một thành phần quan trọng.
- Good to Know: phân loại state; cache cục bộ không phá tính stateless nếu mất chỉ chậm đi.
- Advanced: các kỹ thuật chia state có chủ đích (sharding theo khóa, actor model). Chỉ cần biết rằng chúng tồn tại cho các hệ thống bản chất stateful.
Tự kiểm tra
- Một cache cục bộ lưu danh mục sản phẩm trong bộ nhớ ứng dụng có làm app thành stateful không? Dùng nguyên tắc nào để quyết định?
- Sau khi chuyển giỏ hàng sang Redis, điểm yếu mới của hệ thống là gì và bạn xử lý ra sao?
- Vì sao lỗi giỏ hàng biến mất không xuất hiện khi chỉ có một server?
Đáp án gợi ý
- Không, nếu cache đó chỉ chứa dữ liệu tái tạo được: mất thì ứng dụng chỉ chậm hơn rồi nạp lại từ nguồn, không mất dữ liệu quan trọng. Nguyên tắc quyết định: hỏi "nếu instance này chết, người dùng có mất gì không thể phục hồi, hoặc thấy hành vi khác nhau giữa các instance không?". Nếu không thì vẫn coi là stateless.
- Điểm yếu mới là Redis trở thành thành phần dùng chung quan trọng: nó chết thì mọi giỏ hàng không truy cập được, và mỗi thao tác thêm một lần gọi mạng. Cách xử lý: nhân bản (replica và failover), đặt timeout và retry hợp lý, giám sát, và tùy yêu cầu có thể bật lưu bền hoặc lưu giỏ hàng quan trọng vào database.
- Khi chỉ có một server, mọi yêu cầu của người dùng luôn vào cùng một bộ nhớ, nên giỏ hàng luôn thấy nhau. Với nhiều server, các yêu cầu khác nhau vào các server khác nhau, mỗi server giữ giỏ riêng trong RAM, nên người dùng lúc thấy giỏ đầy lúc thấy giỏ trống tùy server nào xử lý.
Nếu chỉ nhớ 3 điều
- Stateless nghĩa là instance không giữ state quan trọng của client trong bộ nhớ riêng, nên server nào cũng thay thế được server khác.
- Muốn scale-out và chịu lỗi tốt, hãy đẩy state ra nơi chia sẻ như database hoặc Redis.
- Cái giá là thêm một lần gọi mạng và một thành phần quan trọng mới cần nhân bản và giám sát.