Stack Overflow: How We Do App Caching - 2019 Edition
Bài tóm tắt Stack Overflow 2019 3. Quy mô lớn Độ khó: Trung bình
Stack Overflow · 2019 · 3. Quy mô lớn · Độ khó: Trung bình — bài gốc
Bài 5 trong series của Nick Craver (bài cuối đã viết). Bài trước: Stack Overflow 2018. Chi tiết hoá phần "cache 2 tầng" trong Stack Overflow 2016.
Bối cảnh
- Thang độ trễ (2019): RAM khoảng 100 ns; một lần gọi Redis qua LAN của họ 0,2–0,5 ms (chậm hơn RAM 2.000–5.000 lần); trang câu hỏi render trung bình 18–20 ms, nên vài lần gọi Redis đã chiếm một phần đáng kể.
- Nguyên tắc chung theo tác giả: chỉ cache khi cần. Cache tốn bộ nhớ, tốn GC, phải invalidate và khó debug hơn.
- Redis ngày 30/7/2019: máy 256 GB nhưng dùng dưới 96 GB; khoảng 1,6 tỷ lệnh/ngày (đỉnh 87K/giây); CPU trung bình 2%; 124 triệu key; cho 308 triệu HTTP hit. Họ chia ra nhiều instance để tổ chức, không phải vì hiệu năng.
Thay đổi chính
- Ba loại cache trong một app multi-tenant:
- Global Cache: trong RAM của từng web server, miss thì đọc Redis; dùng chung cho cả mạng (ví dụ số đếm trên thanh trên cùng của user);
- Site Cache: như trên nhưng theo từng site (danh sách câu hỏi, danh sách user); key có tiền tố theo ID site (Stack Overflow là 1), ở Redis mỗi site một database;
- Local Cache: chỉ trong RAM, không có Redis phía sau, cho dữ liệu rẻ để lấy nhưng lớn, không đáng một hop mạng.
- Ví dụ key:
prod:1-related-questions:1234.
- Đọc: RAM → Redis (lấy giá trị và TTL trong một round-trip nhờ pipelining) → nguồn. Lấy được từ Redis thì ghi vào RAM với cùng TTL. Lớp bọc giá trị cho phép cache cả giá trị null, phân biệt "chưa cache" với "đã tra và là null". Redis là tuỳ chọn: nếu Redis không đọc được thì đi thẳng tới nguồn.
- Invalidate: xoá hoặc ghi giá trị mới trong Redis trước, rồi
PUBLISHtên key lên một kênh pub/sub; mọi web server xoá bản trong RAM. Đảo thứ tự hai bước thì có race khiến server đọc lại giá trị cũ. Không đẩy giá trị mới đi, vì có thể các server kia sẽ không cần tới nó. GetSet(key, lookup, durationSecs, serveStaleDataSecs):- trong
durationSecstrả giá trị cache; sau đó tới hếtserveStaleDataSecsvẫn trả giá trị cũ nhưng chạy refresh ở luồng nền; - bảng khoá theo key trên mỗi server (
ConcurrentDictionary) để 400 request cùng miss chỉ tạo một lần gọi DB; - hàm lookup nhận giá trị cũ, nên lỗi thì log rồi trả lại giá trị cũ. Theo tác giả, dữ liệu hơi cũ luôn tốt hơn trang lỗi (trừ khi tính năng không cho phép).
- trong
- Không dùng DTO riêng cho cache: cache thẳng kiểu mà Dapper trả về, serialize bằng protobuf-net. Ít kiểu dữ liệu thì ít allocation hơn.
- Kiểu dữ liệu Redis khác: List làm hàng đợi (gộp sự kiện, thao tác tài khoản), Set, Hash (ngày hoạt động mới nhất của từng site để quyết định có chạy badge không), Sorted Set (100 trace chậm nhất mỗi route). Pub/sub cũng dùng cho websocket.
- Bỏ output cache: nhiều năm họ cache nguyên HTML của trang chủ, trang câu hỏi, RSS. Nhưng phải tách theo nhiều biến thể (ẩn danh, mobile, kiểu nén), và khoảng 80% câu hỏi được xem trong mỗi hai tuần, nên tỉ lệ hit thấp. Còn chi phí bộ nhớ và GC (chuỗi lớn vào large object heap) thì cao. Kết quả là cache "hại nhiều hơn lợi"; nay không output cache gì, trừ vài route RSS có tỉ lệ hit cao.
Sự cố được kể lại
- Nhân bản Redis sang server mới khi cache đã 90 GB trên máy 96 GB:
BGSAVEfork bộ nhớ, Redis hết RAM rồi sập, mọi site offline vì web server lâu năm không chạy khi thiếu Redis. Phải dựng lại với cache rỗng. - Một refactor đổi giá trị TTL đánh dấu "không có TTL" từ
-1sangnullmà viết sai điều kiện, nên khoảng 95% key không còn vào cache RAM. Redis chịu được nên mấy giờ sau mới có người phát hiện. - 2011:
new TimeSpan(60)là 60 tick chứ không phải 60 giây, tức cache trang 0,000006 giây. Sửa xong thì không nhanh hơn, chỉ tốn RAM và CPU hơn; đó cũng là dấu hiệu đầu tiên rằng output cache không có ích. - Redis bị timeout ngẫu nhiên vì tiến trình monitoring gọi
vmstatđẩy Redis khỏi nhân CPU; sửa bằng cách ghim Redis vào nhân CPU cố định.
Vì sao không dùng Redis Cluster hay Sentinel (theo tác giả, 2019)
- Cluster không hỗ trợ nhiều database; replication khi đó là node tới node nên bảo trì cùng cụm ở DR khó hơn; và cần ít nhất 3 node, trong khi họ chỉ có 2 máy mỗi data center và một máy đã dư sức.
- Sentinel ghi topology vào chính file cấu hình, nên xung đột với Puppet.
- Payload lớn đi qua một connection riêng tên "bulky" có timeout dài hơn, để không chặn đường ống chung.
Bài học cho SWE
- Cache 2 tầng (RAM + Redis) cùng pub/sub để xoá bản trong RAM là mẫu đơn giản cho một web tier vài chục máy.
- Phục vụ dữ liệu cũ trong khi refresh, cộng với khoá theo key để gộp các lần miss, chống được thundering herd khi cache hết hạn.
- Trước khi cache, ước lượng tỉ lệ hit theo số biến thể; cache có hit thấp có thể làm hệ chậm đi.
- Đặt cache sao cho không có nó hệ vẫn chạy được (Redis không phải nguồn sự thật của thứ gì), nhưng vẫn phải tập chạy khi không có nó.
Liên quan
#Two-tier cache#In-memory cache#Redis#Pub/sub invalidation#Stale-while-revalidate#Request coalescing#Protobuf#Multi-tenant key prefix#Output cache#Pipelining
Được nhắc tới trong
Công ty (1)
Bài tóm tắt (2)