Consistency issue trong distributed cache: đọc nhanh hơn thật, nhưng đọc sai luôn

Cuối tuần ngồi cafe thời tiết Hà Nội thật đẹp, đem laptop ra viết chia sẻ kinh nghiệm một lần đáng nhớ của tôi đến từ cái suy nghĩ “muốn mọi thứ tốt hơn” :)).

2c83ec8f-1e45-438c-93f0-6f2f2bcf61dc

Công ty tôi có một Redis instance single-node đã sống yên ổn hơn 1 năm. 16GB RAM, CPU lúc nào cũng loanh quanh 80%, cache hit ~92%. Đợt đó traffic tăng nhanh, CTO bảo “Redis bắt đầu choke rồi đấy, scale lên cluster đi cho an toàn”. Nghe quá hợp lý.

Công ty tôi triển khai Redis Cluster 3 node, mỗi node 8GB RAM, replication đầy đủ.

App backend (Spring Boot, dùng lettuce client) chỉ cần đổi host list, test xong latency giảm hẳn từ 3ms xuống còn hơn 1ms.

Mọi người high-five nhau: “Cuối cùng cũng production-grade rồi”. Rồi sáng hôm sau mọi thứ bắt đầu sai sai.

Những tín hiệu đầu tiên

Slack support channel đầy những tin kiểu:

  • “Sao em bị logout giữa chừng?”
  • “Vừa đổi pass xong mà token cũ vẫn chạy?”
  • “Shopping cart tự reset à?”

Trong khi dashboard Redis thì xanh lè: latency ổn, CPU ổn, memory ổn. Không crash, không failover. Công ty tôi mở CLI lên check thử key:

get session:102934

Kết quả:

  • Lúc thì trả về "token_abc", lúc "token_xyz".
  • Cùng một user. Hai giá trị khác nhau.

Lúc đầu công ty tôi tưởng là replication lag

Redis Cluster có replication async giữa primary và replica nên nghĩ chắc “delay vài giây”. Nhưng check metric thì replication offset gần như bằng nhau, không có lag đáng kể. Tụi tôi bắt đầu dump key ra để so sánh giữa các node. Kết quả khiến ai cũng cười gượng:

  • Trên node 1 có session:1001
  • Trên node 2 cũng có session:1001
  • Hai value khác nhau.

Redis không báo lỗi. Không warning. Không “key conflict”. Chỉ đơn giản là cả hai đều hợp lệ theo slot logic.

Hóa ra lỗi nằm ở cách công ty tôi đặt key

Code cũ sinh key kiểu này:

String redisKey = String.format("session:%s:%s", appName, userId);

Trước đây chạy single-node thì vô hại. Nhưng Redis Cluster chia slot bằng CRC16(key) % 16384. Tức là chỉ cần khác 1 ký tự là nó bay sang node khác.

Khi migrate, công ty tôi restore nguyên file dump.rdb cũ mà không rehash lại key. Redis Cluster đọc vẫn được (vì có hash slot fallback), nhưng mapping slot bị lệch. Thế là mỗi node giữ một phần “sự thật” khác nhau về cùng user_id. Session revoke ở node A, validate ở node B không ai nói chuyện với ai.

Cái giá của “chạy được là được”

Khi người dùng logout, token cũ không bị xóa trên node kia. Khi người dùng login lại, app đọc nhầm session cũ. Và cache invalidation thì thành random.

  • Có lúc request vào node có session mới => hợp lệ.
  • Có lúc vào node khác => token mismatch.

Kết quả: user bị logout giữa chừng hoặc vẫn truy cập được sau khi revoke. Tỷ lệ login fail tăng 4%, nhìn thì nhỏ nhưng ảnh hưởng user experience rõ rệt.

Lúc này công ty tôi mới nhận ra: Redis Cluster không phải Redis cũ

Redis Cluster chỉ đảm bảo slot consistency, không đảm bảo global consistency. Công ty tôi giả định cache vẫn “một nguồn dữ liệu thống nhất” như trước và sai hoàn toàn.

Cái cay là Redis không cảnh báo gì. Không lỗi, không log, không metric nào nói bạn đang đọc sai data. Tất cả “chạy mượt mà” chỉ là kết quả sai.

Khắc phục

Bước 1: Chuẩn hóa lại key. Redis Cluster cho phép dùng hash-tag {} để ép các key cùng entity vào cùng slot:

session:{user_id}:metadata
session:{user_id}:cart

App sửa key format lại thành:

String redisKey = String.format("session:{%s}", userId);

Toàn bộ session cùng user sẽ nằm chung node.

Bước 2: Rehash dữ liệu cũ. Viết script quét toàn bộ key kiểu cũ:

SCAN 0 MATCH session:* COUNT 1000

và chuyển sang key mới theo {user_id}. Cứ chạy batch 1000 key/lần để tránh nghẽn.

Bước 3: Dừng local cache. App nào còn giữ local cache TTL 60s đều tắt hết. Không ai muốn giữ thêm stale session thêm 1 phút nào nữa.

Bước 4: Đưa session thành service riêng. Không để từng app tự cache nữa. Tách riêng session-api, quản lý consistency, TTL, và revoke logic tập trung.

Hậu quả thật sự không phải downtime

Không có downtime, không có lỗi nghiêm trọng nào trong log. Nhưng có data drift cái thứ âm thầm hơn cả crash. Bạn mất niềm tin vào dữ liệu và đó là thứ tệ hơn gấp nhiều lần downtime.

Tối hôm đó, khi mọi thứ chạy lại ổn định, có người buông một câu:

  • “Cache đúng là con dao hai lưỡi. Dùng cho nhanh, nhưng lệch một nhát là chảy máu dữ liệu.”

Sau cùng, Công ty tôi rút ra mấy điều

  • Redis Cluster không phải Redis Single. Nó chỉ consistent theo slot. Key schema phải thiết kế ngay từ đầu.

  • Cache không được coi là source of truth. Nếu cache sai mà hệ thống hỏng, nghĩa là thiết kế sai.

  • Observability cần đo cả consistency drift. Công ty tôi viết thêm script so sánh random key giữa node để đo độ lệch data, metric này về sau cứu mạng vài lần.

  • Luôn rehearsal migration. Đừng restore dump trực tiếp từ single sang cluster mà không tính hash slot.

Kết

Ba ngày sau khi fix xong, tôi ghi một dòng vào doc của team:

  • “Mỗi lần chạm vào cache, hãy tự hỏi: nếu nó sai, hệ thống có chịu được không?”

Và từ đó, mỗi lần ai trong team nói “cache cho nhanh đi”, mọi người đều bật cười:

  • “Nhanh thôi, nhưng nhớ là nhanh theo cả nghĩa… chết nhanh.”

Thông tin nổi bật

Sự kiện phát trực tiếp​

Event Thumbnail

Báo cáo quan trọng

Article Thumbnail
Article Thumbnail
Chia sẻ bài viết:
Theo dõi
Thông báo của
0 Góp ý
Được bỏ phiếu nhiều nhất
Mới nhất Cũ nhất
Phản hồi nội tuyến
Xem tất cả bình luận

Tiêu điểm chuyên gia