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. Bài này đo xem nó đáng giá bao nhiêu — và khi nào nó không đáng gì cả.

Ba phép đo: tra khoá chính, truy vấn đắt, và tác dụng của một chỉ mục

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.

Hệ quả thực hành: đừ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.

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 thứ nhất: đá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ỗ hỏng thứ hai: 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ỡ.

Thử ba mươi giây

Đ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. Một dòng CREATE INDEX thường thắng mọi thứ bạn làm với bộ đệm.

Phần sau: các mẫu bộ đệm khác — write-through, write-behind, và chỗ mỗi mẫu hỏng.