Đau lưng thì có hai cách xử. Cách thứ nhất: uống thuốc giảm đau — cơn đau biến mất ngay, bạn đi lại bình thường, và quên luôn là mình có vấn đề. Cách thứ hai: sửa tư thế ngồi, tập lại cơ lưng — chậm hơn, nhưng chữa đúng cái gốc. Vấn đề của thuốc giảm đau không phải nó vô dụng, mà là nó che triệu chứng giỏi đến mức bạn ngừng tìm nguyên nhân — cho tới ngày thuốc hết tác dụng đúng lúc bạn đang khiêng đồ nặng. Cache-aside trước một cơ sở dữ liệu chậm y hệt viên thuốc ấy, và bài này đo xem lúc nào nó thật sự đáng dùng, lúc nào nó chỉ đang giấu một vấn đề bạn nên sửa.
Cache-aside là mẫu dùng Redis phổ biến nhất: hỏi bộ đệm, trượt thì hỏi cơ sở dữ liệu rồi ghi vào bộ đệm. Ta đo xem nó đáng giá bao nhiêu.
Tra một dòng theo khoá chính: bộ đệm không giúp gì
Cùng qua mạng, cùng một kết nối, 100.000 dòng:
PostgreSQL SELECT theo khoá chính 0,065 ms 15.402 tps
Redis GET 0,062 ms 16.147 tps
Bằng nhau. Chênh 5%, nằm trong nhiễu. Đây là kết quả tôi trông đợi sẽ thấy Redis thắng rõ và không thấy. Với một truy vấn đã có chỉ mục trên bảng nằm gọn trong bộ đệm của PostgreSQL, cơ sở dữ liệu cũng là một bảng băm trong RAM — chỉ có thêm vài lớp phía trên.
Nghĩa là: đặt Redis trước một truy vấn khoá chính đã tối ưu chỉ thêm một hệ thống nữa để hỏng, không thêm tốc độ. Và nó thêm một nguồn dữ liệu cũ.
Nhưng với truy vấn đắt thì khác hẳn
Truy vấn nối 2 triệu dòng đơn hàng, gom nhóm, không có chỉ mục:
PostgreSQL 19,907 ms (50 truy vấn mỗi giây)
Redis GET 0,062 ms
Nhanh hơn 321 lần. Đây mới là chỗ bộ đệm có nghĩa. Và nó cho ra phép tính quen thuộc:
| Tỉ lệ trúng | Độ trễ trung bình |
|---|---|
| 0% | 19,907 ms |
| 50% | 9,985 ms |
| 90% | 2,046 ms |
| 95% | 1,054 ms |
| 99% | 0,260 ms |
| 99,9% | 0,082 ms |
Điều đáng chú ý ở bảng này: đi từ 90% lên 99% không phải "tốt hơn 10%" mà là nhanh hơn 8 lần. Lý do là những lần trượt chi phối hoàn toàn. Ở 90%, một phần mười số yêu cầu tốn 19,9 ms và chúng chiếm 98% tổng thời gian. Những phần trăm cuối cùng mới là phần quyết định.
Nên đừng ăn mừng khi đạt 90% — đó là chỗ vẫn còn gần hết công việc phía trước.
Và rồi: thêm một chỉ mục
trước 19,907 ms
sau 0,110 ms nhanh hơn 181 lần
0,110 ms tốt hơn cả tỉ lệ trúng 99% (0,260 ms). Không cần bộ đệm. Không cần lo dữ liệu cũ. Không cần thêm một hệ thống nữa để vận hành, giám sát, và khôi phục khi hỏng.
Đây là kết luận quan trọng nhất của bài: sửa truy vấn trước khi đệm nó. Bộ đệm che đi một truy vấn chậm; chỉ mục xoá nó đi. Và bộ đệm che đi rất hiệu quả — đủ để không ai nhìn lại truy vấn đó trong hai năm, cho tới ngày bộ đệm nguội và hệ thống sập vì cơ sở dữ liệu không chịu nổi tải thật. Đúng viên thuốc giảm đau hết tác dụng giữa lúc khiêng đồ nặng.
Khi nào cache-aside thật sự đúng
Sau ba phép đo trên, danh sách còn lại khá hẹp:
- Truy vấn không tối ưu được nữa — tổng hợp trên hàng chục triệu dòng, tính toán phức tạp.
- Kết quả đến từ hệ thống ngoài — gọi API bên thứ ba, dịch vụ chậm không kiểm soát được.
- Cần giảm tải cho cơ sở dữ liệu, không phải giảm độ trễ. Đây là lý do chính đáng và hay bị bỏ qua: 15.000 tps của PostgreSQL là trần thật, và bộ đệm dời phần lớn tải ra khỏi nó.
- Dữ liệu được đọc nhiều hơn ghi rất nhiều lần, và cũ vài giây là chấp nhận được.
Mẫu đầy đủ, và hai chỗ nó hỏng
def lay(id):
v = r.get(f"sp:{id}")
if v is not None:
return json.loads(v)
v = db.query("select ... where id = %s", id)
r.set(f"sp:{id}", json.dumps(v), ex=300)
return v
Chỗ hỏng đầu tiên là đám đông cùng trượt: khoá hết hạn, và một nghìn yêu cầu đồng thời cùng thấy trượt, cùng gọi cơ sở dữ liệu. Với truy vấn 19,9 ms, một nghìn lần đồng thời là đủ để hạ cơ sở dữ liệu. Cách chữa là một khoá — đúng cái đo ở phần 24: chỉ một tiến trình được tính lại, số còn lại chờ hoặc trả về giá trị cũ.
Chỗ thứ hai là hết hạn đồng loạt: nạp toàn bộ bộ đệm cùng lúc với cùng TTL nghĩa là chúng cũng hết hạn cùng lúc. Rải ra: ex=300 + random(0, 60).
Cập nhật: xoá hay ghi đè
Khi dữ liệu đổi, có hai lựa chọn: xoá khoá, hoặc ghi giá trị mới vào. Xoá an toàn hơn. Ghi đè có một tình huống đua: hai tiến trình cùng cập nhật, tiến trình chậm hơn ghi giá trị cũ vào bộ đệm và giá trị đó ở lại cho tới khi hết hạn. Xoá cũng có tình huống đua của nó, nhưng cửa sổ hẹp hơn nhiều, và cái giá là một lần trượt chứ không phải dữ liệu sai kéo dài.
Và bất kể chọn gì: luôn đặt TTL. TTL là lưới an toàn cho mọi trường hợp cập nhật bị bỏ lỡ — mà chúng sẽ bị bỏ lỡ.
Mẫu số chung: một tầng đệm dịch tải, không xoá tải
Cái bẫy "cache che một truy vấn chậm" là chung cho mọi tầng đệm ở mọi ngôn ngữ: bộ đệm là một đòn bẩy dịch chi phí — nó dời tải ra khỏi nguồn đắt, nhưng không xoá chi phí đó, và nó giấu vấn đề cực giỏi cho tới đúng khoảnh khắc tầng đệm biến mất (deploy, restart, cold start, hết hạn đồng loạt) thì toàn bộ tải thật dội thẳng xuống nguồn — thường vào lúc tệ nhất. Đối lại, sửa gốc (một chỉ mục, một thuật toán tốt hơn) xoá chi phí, nên không có "khoảnh khắc thuốc hết tác dụng".
Trong thế giới Java, đây đúng là cám dỗ dán @Cacheable lên một phương thức chậm vì một truy vấn thiếu index hay một N+1 query: nó làm p50 đẹp ngay, nhưng cái chậm vẫn nằm đó, và sau một lần deploy làm nguội cache, cơ sở dữ liệu nhận trọn tải nó chưa từng chịu nổi. Lời khuyên chung: cache không phải cách sửa một truy vấn chậm, nó chỉ là cách hoãn việc đối mặt với nó.
Trong Go, memoize một hàm bằng sync.Map để giấu một vòng lặp O(n) đáng lẽ nên là O(1) cũng vậy — bạn đang trả nợ kỹ thuật bằng RAM, và hoá đơn tới vào lần cache miss hàng loạt.
Còn ở cơ sở dữ liệu, đây là ranh giới giữa sửa (một CREATE INDEX, viết lại truy vấn, một materialized view có làm mới đúng) và che (một lớp cache ứng dụng trước truy vấn xấu). Cái đầu hạ chi phí thật; cái sau chỉ dời nó và thêm một nguồn dữ liệu cũ để lo.
Mẫu số chung: bộ đệm dịch chuyển tải chứ không xoá tải, nên nó giấu một vấn đề hiệu năng cho tới đúng lúc tầng đệm biến mất thì vấn đề dội lại nguyên vẹn — hãy sửa cái gốc rẻ (một chỉ mục) trước khi che nó bằng cái đắt để vận hành (một cache). Câu hỏi đúng trước khi thêm cache không phải "cache có làm nhanh hơn không" (gần như luôn có) mà là "truy vấn này còn sửa được để khỏi cần cache không, và nếu ngày mai cache trống thì nguồn có đứng vững không". Một dòng CREATE INDEX thường thắng mọi thứ bạn làm với bộ đệm.
Muốn tự đo tỉ lệ trúng thật của bạn:
redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses'
hits, misses = 812345, 91234
print("ti le trung: %.2f%%" % (hits * 100 / (hits + misses)))
Rồi làm phép tính ở bảng trên với thời gian truy vấn thật của bạn. Nếu truy vấn đó chỉ 0,1 ms thì tỉ lệ trúng 80% hay 99% gần như không khác gì — và bộ đệm đang không mua cho bạn thứ gì. Còn nếu truy vấn 20 ms, hãy xem nó có thêm được chỉ mục không trước khi đi tối ưu tỉ lệ trúng.
Phần sau: các mẫu bộ đệm khác — write-through, write-behind, và chỗ mỗi mẫu hỏng.