FDE PulseViệc làm FDE đang mở 316Mới đăng 7 ngày qua 10Chủ đề nổi bật: Đào tạo kỹ năng FDE tại Đông Nam Á

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

Thực hành Redis: ba cách che độ chậm của hệ thống khách hàng

Khi API của khách trả lời chậm mà bạn không được sửa nó, chọn đúng chiến lược cache quyết định người dùng nhận dữ liệu nhanh hay nhận dữ liệu sai.

Đồ hoạBa chiến lược cache trên Redis
Điểm mạnhCái giá
Cache-asideĐa dụng, chỉ nạp dữ liệu khi được đọc, DEL khi ghiMỗi lần miss tốn ba lượt đi về, có cửa sổ dữ liệu cũ
Write-throughCache luôn có dữ liệu vừa ghi qua ứng dụngGhi chậm hơn, cache chứa dữ liệu có thể không ai đọc
Refresh-aheadLàm mới key vừa được đọc trước khi nó hết hạnĐoán sai key cần dùng sẽ làm giảm hiệu năng
Chung cho cả baTTL giới hạn thời gian dữ liệu cũ được phục vụKey hot hết hạn dưới tải cao dễ gây stampede

Mỗi chiến lược đổi một thứ lấy một thứ: cache-aside đơn giản nhưng có thể cũ, write-through luôn mới, refresh-ahead hợp với key hot.

Đồ hoạ: FDE Times

Tóm tắt nhanh

  • Cache-aside là lựa chọn mặc định tốt, nhưng mỗi lần miss tốn ba lượt đi về và dữ liệu có thể cũ.
  • Write-through giữ cache luôn mới, đổi lại thao tác ghi chậm hơn và cache có thể chứa dữ liệu không ai đọc.
  • Refresh-ahead và chống stampede quan trọng nhất với key hot đứng trước một hệ thống chậm.
Chia sẻLinkedInFacebookX

Mỗi lần cache miss trong mô hình cache-aside tốn ba lượt đi về: hỏi cache, hỏi nguồn dữ liệu, rồi ghi lại vào cache. Với một database nhanh, con số ba ấy gần như vô hại. Đặt trước một hệ thống ERP cũ mất vài giây cho mỗi truy vấn, nó là thứ người dùng cảm thấy ngay.

Đây là tình huống quen thuộc của FDE. Bạn đến site khách hàng, hệ thống lõi thì chậm, không ai cho bạn sửa nó, còn demo tuần sau cần chạy mượt. AWS nhận xét điểm hay nhất của caching là nó ít xâm lấn khi triển khai, và đó chính là lý do cache thường là công cụ đầu tiên bạn rút ra.

Nhưng cache sai chiến lược thì bạn chỉ đổi “chậm” lấy “sai”. Bài này đi qua ba chiến lược trên Redis theo đúng thứ tự bạn sẽ dùng chúng ngoài đời: cache-aside trước, write-through khi cần dữ liệu mới, refresh-ahead cho key hot.

Bạn sẽ dựng gì, cần gì?

Bạn sẽ dựng một lớp cache bằng Python đứng trước các hàm giả lập “hệ thống chậm của khách”, chẳng hạn API lấy thông tin khách hàng và bảng giá từ ERP. Bạn cần một Redis chạy local, Python 3 và thư viện redis-py. Code trong bài được rút gọn để dạy ý tưởng, chưa có xử lý lỗi hay kết nối pool như bản production.

Bước 1: cache-aside, chiến lược mặc định

Trong cache-aside, ứng dụng tự chịu trách nhiệm đọc và ghi nguồn dữ liệu; cache không bao giờ tự chạm vào storage. Tài liệu của Redis gợi ý lưu mỗi thực thể dưới key dạng cache:{entity}:{id}, kèm TTL để giới hạn thời gian dữ liệu cũ có thể được phục vụ.

Đoạn đầu tiên định nghĩa luôn cả ba hàm giả lập hệ thống chậm mà các bước sau dùng tới, để bạn chạy được toàn bộ bài trong một file.

import json, time
import redis

r = redis.Redis(decode_responses=True)
TTL_SECONDS = 300

# --- Giả lập hệ thống chậm của khách (mỗi lần gọi mất 2 giây) ---
def slow_erp_get_customer(customer_id):
    time.sleep(2)
    return {"id": customer_id, "name": "Cong ty A"}

def slow_erp_update_customer(customer_id, fields):
    time.sleep(2)
    return {"id": customer_id, "name": "Cong ty A", **fields}  # bản ghi sau khi sửa

def slow_erp_get_price(sku):
    time.sleep(2)
    return {"sku": sku, "price": 100000}

# --- Cache-aside ---
def get_customer(customer_id):
    key = f"cache:customer:{customer_id}"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)          # hit
    data = slow_erp_get_customer(customer_id)  # miss
    r.set(key, json.dumps(data), ex=TTL_SECONDS)
    return data

Kiểm tra: gọi get_customer(42) hai lần và đo thời gian. Lần đầu mất khoảng 2 giây, lần hai gần như tức thì. Mở redis-cli và chạy GET cache:customer:42 để thấy bản ghi đã nằm trong Redis.

Tùy chọn ex=TTL_SECONDS tương ứng với SET ... EX của Redis. AWS khuyến nghị luôn đặt TTL phù hợp cho các key, và lý do sẽ rõ ở bước tiếp theo.

Bước 2: dữ liệu cũ đến từ đâu?

Prisma gọi cache-aside là chiến lược đa dụng tốt, nhưng nó có một cửa sổ mà cache và database không khớp nhau. Dữ liệu trong cache sẽ cũ nếu bản ghi được cập nhật trong database. Cách xử lý mà Redis mô tả gồm hai lớp: TTL theo từng key để mỗi entry có giới hạn thời gian cũ, và DEL khi ghi để chủ động vô hiệu hóa.

def update_customer(customer_id, fields):
    slow_erp_update_customer(customer_id, fields)
    r.delete(f"cache:customer:{customer_id}")  # lần đọc sau sẽ miss và lấy bản mới

Kiểm tra: gọi update_customer(42, {"name": "Cong ty B"}), rồi chạy GET cache:customer:42 trong redis-cli. Kết quả phải rỗng.

Chi tiết dễ bị bỏ qua là DEL chỉ bảo vệ bạn khỏi những lần ghi đi qua code của bạn. Thử hình dung kế toán của khách sửa tên công ty trực tiếp trong màn hình ERP.

Ứng dụng không hề biết, và TTL là lưới an toàn duy nhất còn lại. Vì thế TTL không phải con số đặt cho có; nó là câu trả lời cho câu hỏi “khách chấp nhận thấy dữ liệu cũ tối đa bao lâu”.

Bước 3: write-through khi dữ liệu mới là bắt buộc

Nếu nghiệp vụ không chịu được cửa sổ dữ liệu cũ, ví dụ trạng thái đơn hàng vừa được duyệt, write-through phù hợp hơn. Mỗi lần ghi đều cập nhật cache, nên cache luôn có dữ liệu vừa ghi và không bị cũ.

# Bản rút gọn: ứng dụng ghi cả hai nơi.
# Trong mô hình kinh điển, chính lớp cache đảm nhận việc ghi xuống storage.
def update_customer_write_through(customer_id, fields):
    data = slow_erp_update_customer(customer_id, fields)  # trả về bản ghi mới
    r.set(f"cache:customer:{customer_id}", json.dumps(data), ex=TTL_SECONDS)
    return data

Kiểm tra: sau khi cập nhật, GET cache:customer:42 phải trả về giá trị mới chứ không rỗng như ở bước 2.

Cái giá có hai phần. Thao tác ghi chậm hơn, vì phải đi qua cả hai nơi. Và phần lớn dữ liệu được ghi có thể không bao giờ được đọc, làm phình bộ nhớ cache; system-design-primer gợi ý giảm thiệt hại này bằng TTL.

Cũng cần nhớ write-through vẫn không cứu được những lần ghi từ bên ngoài ứng dụng như ví dụ kế toán ở trên.

Bước 4: refresh-ahead và cơn bão trên key hot

Hãy quay lại con số ba lượt đi về. Với một key hot, chẳng hạn bảng giá mà mọi trang đều đọc, khoảnh khắc nó hết hạn mới là nguy hiểm.

Redis mô tả hiện tượng cache stampede: khi một key phổ biến hết hạn dưới tải cao, hàng chục process cùng lúc truy vấn database cho cùng một bản ghi. Với hệ thống chậm của khách, cơn bão đó có thể làm nó sập hẳn.

Refresh-ahead xử lý chuyện này bằng cách làm mới entry vừa được truy cập trước khi nó hết hạn. Bản rút gọn dưới đây lưu một mốc “hết hạn mềm” ngay trong giá trị, sớm hơn TTL thật. Khi một request chạm mốc đó, nó vẫn nhận ngay bản đang có, còn việc gọi hệ thống chậm được đẩy sang một thread chạy nền.

import threading

REFRESH_BEFORE = 60  # làm mới trước khi TTL thật hết 60 giây

def _load_price(key, sku):
    data = slow_erp_get_price(sku)
    entry = {"data": data,
             "soft_expire": time.time() + TTL_SECONDS - REFRESH_BEFORE}
    r.set(key, json.dumps(entry), ex=TTL_SECONDS)
    return data

def get_price_refresh_ahead(sku):
    key = f"cache:price:{sku}"
    cached = r.get(key)
    if cached is None:
        return _load_price(key, sku)  # miss thật: vẫn phải chờ
    entry = json.loads(cached)
    if time.time() >= entry["soft_expire"]:
        # qua mốc mềm: làm mới chạy nền, request hiện tại không phải chờ
        threading.Thread(target=_load_price, args=(key, sku), daemon=True).start()
    return entry["data"]

Kiểm tra: đặt TTL_SECONDS và REFRESH_BEFORE nhỏ để thử, gọi liên tục và in log mỗi lần hàm chậm được gọi. Bạn sẽ thấy việc làm mới xảy ra trước khi key thật sự biến mất khỏi Redis, và các request sau lần miss đầu không phải chờ 2 giây.

Cần nói rõ đây chỉ là bản phác hoạ. Trong mô hình kinh điển, chính cache được cấu hình để tự làm mới, còn ở đây ứng dụng làm thay bằng thread. Nếu bạn bỏ phần thread và gọi _load_price ngay trong request, thứ bạn có chỉ là một “soft TTL” đồng bộ: request chạm mốc mềm vẫn phải đứng chờ hệ thống chậm.

Bản này vẫn hở một chỗ trước khi lên được production. Mỗi request chạm mốc mềm đều khởi động một thread riêng, nên nhiều request hay nhiều process chạm mốc cùng lúc vẫn cùng gọi xuống hệ thống của khách, tức là stampede chỉ dời sớm hơn chứ chưa biến mất.

Redis gợi ý dùng script Lua để chạy mutex lock hoặc làm mới sớm theo xác suất trong một bước atomic duy nhất, không cần cơ chế khóa bên ngoài; đó là bước nâng cấp tiếp theo bạn nên đọc trong tài liệu cache-aside của Redis.

Những lỗi hay gặp nhất là gì?

Lỗi phổ biến nhất là quên TTL vì đã có DEL khi ghi, rồi bị dữ liệu sửa từ bên ngoài làm cache sai vô thời hạn.

Lỗi thứ hai là bật refresh-ahead cho mọi key: system-design-primer cảnh báo rằng đoán sai item nào sẽ được dùng tiếp có thể làm giảm hiệu năng, vì bạn tốn công gọi hệ thống chậm cho dữ liệu không ai cần.

Lỗi thứ ba là chỉ thử với một người dùng, nên stampede không bao giờ lộ ra cho đến ngày demo.

Kỹ năng này xuất hiện ở site khách hàng thế nào?

Ở site, phần code chỉ chiếm một nửa công việc. Nửa còn lại là hỏi đúng câu: loại dữ liệu nào được sửa ngoài ứng dụng, mỗi loại chịu được cũ bao nhiêu giây, key nào bị đọc nhiều nhất.

Câu trả lời quyết định chiến lược, chẳng hạn hồ sơ khách hàng dùng cache-aside với TTL vài phút, trạng thái đơn dùng write-through, bảng giá dùng refresh-ahead có chống stampede.

Khi đọc mô tả công việc FDE, hãy để ý những cụm như “integrate with legacy systems” hay “performance under real customer load”; đó là nơi kỹ năng này được kiểm tra. Trong CV, đừng chỉ ghi “dùng Redis”. Hãy viết bạn chọn chiến lược nào, vì sao, đặt TTL dựa trên yêu cầu nghiệp vụ nào, và đã xử lý dữ liệu cũ ra sao.

Hệ thống chậm của khách sẽ vẫn chậm sau khi bạn rời đi. Thứ bạn để lại là một lớp cache mà ai đọc code cũng hiểu vì sao từng key sống đúng bấy nhiêu giây.

4 nguồn
Đọc tiếp trên lộ trình · Chặng 5: Triển khaiTrước khi ký SLA 99,9%: tính ngân sách downtime, chuỗi phụ thuộc và cái giá của failoverCon số 99,9% nghe như một lời hứa dễ dàng, cho đến khi bạn nhân nó với database, API bên thứ ba và hai phút chờ failover.