Phần trước đo được cache-aside hoạt động tốt — khi mọi thứ bình thường. Bài này đo đúng khoảnh khắc nó không bình thường: một khoá nóng hết hạn.
200 khách, một khoá vừa hết hạn
Truy vấn phía sau tốn 19,9 ms — chính truy vấn đo ở phần trước.
| Cách làm | Truy vấn CSDL | p50 độ trễ | max độ trễ | Tổng |
|---|---|---|---|---|
| Không bảo vệ | 200 | 360 ms | 670 ms | 0,70 s |
| Một khoá độc quyền | 1 | 56 ms | 71 ms | 0,11 s |
| Trả giá trị cũ | 2 | 29 ms | 59 ms | 0,11 s |
Không bảo vệ: mọi khách đều thấy trượt, đều tự chạy truy vấn. 200 truy vấn 19,9 ms cùng lúc vào một cơ sở dữ liệu.
Và độ trễ mà khách cảm nhận không phải 19,9 ms mà là 360 ms — vì 200 truy vấn tranh nhau làm mọi truy vấn chậm đi.
Một dòng khoá đưa 200 về 1
if r.get(key) is None:
if r.set(f"lock:{key}", "1", nx=True, px=5000):
v = db_query()
r.set(key, v, ex=60)
r.delete(f"lock:{key}")
else:
# cho nguoi khac tinh xong
for _ in range(200):
time.sleep(0.02)
v = r.get(key)
if v is not None: break
Kết quả: 1 truy vấn thay vì 200.
Và điều đáng chú ý: chính khách cũng nhanh hơn — p50 từ 360 ms xuống 56 ms, nhanh hơn 6,4 lần. Không phải vì họ chờ ít hơn về mặt logic, mà vì cơ sở dữ liệu không bị 200 truy vấn đâm vào cùng lúc nên truy vấn duy nhất chạy nhanh.
Đây là điều dễ bỏ qua khi nghĩ về bão cache: nó không chỉ hại cơ sở dữ liệu, nó hại chính người dùng đang chờ.
Khoá này chịu đúng những giới hạn đo ở phần 24 — nó có thể hết hạn giữa chừng. Nhưng ở đây hậu quả nhẹ: hai truy vấn thay vì một. Đây là loại khoá "để tối ưu", không phải "để đúng đắn".
Trả giá trị cũ: không ai phải chờ
v = r.get(key)
if v is None:
cu = r.get(f"{key}:cu") # TTL dai hon nhieu
if r.set(f"lock:{key}", "1", nx=True, px=5000):
v = db_query()
r.set(key, v, ex=60)
r.set(f"{key}:cu", v, ex=3600)
r.delete(f"lock:{key}")
else:
v = cu # dung ngay, khong cho
Kết quả: 2 truy vấn, và p50 29 ms — thấp nhất trong ba cách.
Ý tưởng: giữ hai bản của cùng giá trị. Bản chính TTL ngắn; bản dự phòng TTL dài hơn nhiều. Khi bản chính hết hạn, một người tính lại còn tất cả những người khác dùng ngay bản dự phòng.
Không ai phải chờ. Đổi lại, trong vài chục mili giây đó họ nhận dữ liệu cũ hơn TTL — phải chắc điều đó chấp nhận được.
Khoá độc quyền an toàn hơn về tính đúng đắn; trả giá trị cũ tốt hơn về độ trễ. Với bảng giá thì chọn cái đầu; với số lượt xem thì chọn cái sau.
Hết hạn sớm theo xác suất
Một cách thứ tư không cần khoá: mỗi lần đọc, tính xác suất tự làm mới trước khi khoá hết hạn, xác suất tăng dần khi gần hết hạn.
if random.random() < math.exp(-beta * ttl_con_lai / thoi_gian_tinh):
lam_moi()
Ưu điểm: không có khoảnh khắc nào khoá thật sự trống, nên không có bão. Nhược điểm: cần biết thời gian tính toán, và nó vẫn có thể để hai người cùng làm mới.
Cách này hợp khi bạn có một khoá cực nóng. Với hàng nghìn khoá vừa vừa, khoá độc quyền đơn giản hơn và đủ tốt.
Ba cách khác bão hình thành
Hết hạn đồng loạt. Nạp toàn bộ bộ đệm lúc khởi động với cùng TTL nghĩa là chúng cùng hết hạn. Chữa bằng cách rải: ex = 300 + random(0, 60).
Redis khởi động lại. Bộ đệm trống hoàn toàn và mọi khoá cùng trượt. Đây là bão lớn nhất có thể có, và nó không phòng bằng khoá được — cần làm nóng lại bộ đệm trước khi cho lưu lượng vào, hoặc bật lưu trữ để Redis nạp lại được.
Đuổi khoá vì đầy bộ nhớ. Như đo ở phần 9, allkeys-lru đuổi liên tục khi bộ nhớ đầy — và mỗi khoá bị đuổi là một lần trượt. evicted_keys tăng đều và keyspace_misses tăng theo chính là dấu hiệu này.
Thử ba mươi giây
Xem hệ thống của bạn có đang chịu bão không:
redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys|expired_keys'
Ghi lại, đợi 60 giây, ghi lại lần nữa, rồi so:
expired_keystăng thành từng cụm lớn thay vì đều đặn — bạn đang có hết hạn đồng loạt.keyspace_missestăng cùng nhịp vớiexpired_keys— mỗi lần hết hạn là một lần trượt, tức là không có lớp giá trị cũ nào đỡ.evicted_keystăng — bộ nhớ đầy, và bão đến từ việc đuổi khoá chứ không từ TTL.
Ba chỉ số này rẻ và có sẵn. Bão cache là loại sự cố mà bạn gần như luôn có thể thấy đến trước, nếu có nhìn.
Phần sau: cache lệch nhịp với cơ sở dữ liệu — đo bốn chiến lược cập nhật.