Hình dung một quán cà phê đông khách có tấm bảng ghi "giá hôm nay" treo ngoài cửa. Bình thường ai tới cũng chỉ liếc bảng rồi gọi món — nhanh, gọn. Nhưng đúng lúc nhân viên nhấc bảng xuống để cập nhật, hai trăm khách bước vào cùng thấy chỗ treo bảng trống trơn, và cả hai trăm người cùng xúm lại hỏi thẳng người quản lý. Người quản lý — vốn chỉ cần trả lời một lần rồi ghi lại lên bảng cho tất cả cùng đọc — giờ phải trả lời hai trăm lần, và hàng người dồn ứ. Đây chính là "bão cache", và bài này đo nó xảy ra tệ đến đâu cùng ba cách dập nó. Phần trước đo được cache-aside chạy tốt khi mọi thứ bình thường; giờ ta đo đúng khoảnh khắc nó không bình thường.

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 — đúng như quán vẫn treo tấm bảng giá cũ bên cạnh để dùng tạm.

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.

Mẫu số chung: nhiều người cùng cần một giá trị chưa sẵn sàng

Cái ta vừa đo có một cái tên chung: thundering herd (đàn thú giẫm đạp) hay cache stampede. Nó xuất hiện ở mọi tầng, mỗi khi một tài nguyên chung tốn công tạo hết hiệu lực cùng lúc và nhiều bên đồng thời lao vào tái tạo nó — thay vì để đúng một bên tạo còn những bên kia chờ trên kết quả đó. Lời giải cũng chung một cái tên: single-flight (một chuyến bay), hay request coalescing (gộp yêu cầu trùng).

Trong thế giới Go, đây đúng là gói chuẩn golang.org/x/sync/singleflight: group.Do(key, fn) đảm bảo với nhiều goroutine gọi cùng một key thì fn chỉ chạy một lần, và tất cả nhận chung một kết quả — chính xác cái khoá nx ở trên làm, nhưng dựng sẵn thành thư viện vì mẫu này phổ biến đến vậy.

Trong Java, Caffeine và Guava LoadingCache khoá theo từng khoá khi nạp: nhiều luồng cùng get(k) lúc trượt thì chỉ một luồng chạy hàm nạp, số còn lại chờ nó — ConcurrentHashMap.computeIfAbsent cho cùng đảm bảo nguyên tử ở mức nhỏ hơn. Đây là lý do dùng một cache có sẵn cơ chế nạp thường an toàn hơn tự ghép get/put bằng tay.

Còn ở cơ sở dữ liệu và tầng hạ tầng, đây là hiệu ứng "dogpile" kinh điển của memcached, và là lý do nginx có proxy_cache_lock on (chỉ một request được đi tới gốc khi cache miss, phần còn lại chờ) — cùng chuyện Redis restart làm trống toàn bộ ở trên: một sự kiện làm sạch đồng loạt biến mọi lượt đọc thành lượt trượt.

Mẫu số chung: khi một giá trị chung tốn công tạo hết hiệu lực đồng thời, chi phí không phải một lần tính mà là một lần tính nhân số người cùng lao vào — nên dập bão nghĩa là gộp về single-flight (một người tính, còn lại chờ hoặc dùng bản cũ), và làm nhoè thời điểm hết hạn để chúng đừng cùng biến mất một lúc. Câu hỏi đúng không phải "tính cái này nhanh chưa" mà là "khi nó chưa sẵn sàng và có N người cùng cần, N người đó cùng tính hay một người tính" — vì cái đàn giẫm đạp, không phải bản thân phép tính, mới là thứ làm sập.

Muốn tự xem hệ thống 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_keys tă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_misses tăng cùng nhịp với expired_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_keys tă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.