Vượt xa sức hút của Redis: Khi Memcached là lựa chọn đúng đắn cho Caching quy mô lớn

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Cuộc gọi lúc 2 giờ sáng: Khi Database của bạn “chạm ngưỡng” giới hạn

Vào lúc 2:15 sáng thứ Ba, điện thoại của tôi rung mạnh đến mức suýt rơi khỏi tủ đầu giường. Nền tảng thương mại điện tử của chúng tôi không chỉ chậm; nó đã bị tê liệt hoàn toàn. Dashboard hiển thị CPU của PostgreSQL primary đang bị treo ở mức 98%, trong khi thời gian phản hồi ở phân vị thứ 95 (95th-percentile) đã vượt quá 5 giây. Đây không phải là một trục trặc nhỏ. Đó là một sự cố hệ thống toàn diện.

Các bản log tiết lộ một thực tế dễ đoán nhưng đau đớn. Chúng tôi đang dồn dập gửi 12.000 truy vấn giống hệt nhau mỗi giây vào database chỉ để lấy các cấu hình trang web cơ bản và metadata của session. Những thao tác đọc nặng nề này đang buộc engine phải duyệt qua các index và quản lý lock cho những dữ liệu hiếm khi thay đổi. Chúng tôi cần một lớp cache ngay lập tức để ngăn toàn bộ hệ thống bị sụp đổ.

Chi phí ẩn của các truy vấn quan hệ

Ngay cả một database SQL được tối ưu hóa tốt nhất cũng sẽ gặp khó khăn khi bạn yêu cầu nó truy xuất cùng một hàng chính xác mười nghìn lần mỗi giây. Mỗi request sẽ kích hoạt một chuỗi các chi phí vận hành (overhead): phân tích cú pháp SQL, xác minh quyền hạn và quản lý buffer pool. Đối với các chuỗi JSON tĩnh hoặc các chuỗi session, quy trình này là một sự lãng phí tài nguyên tính toán đắt đỏ.

Ứng dụng của chúng tôi đang truy xuất một đối tượng JSON đã được serialize nặng 150KB chứa các cài đặt toàn trang trên mỗi lượt tải trang. Việc chuyển các “blob” này vào RAM sẽ cho phép database tập trung vào những gì nó thực sự làm tốt—xử lý các transaction phức tạp và đảm bảo tính nhất quán của dữ liệu.

Memcached vs. Redis: Chọn công cụ tối ưu nhất

Giữa lúc xảy ra sự cố, lập trình viên trưởng của tôi đã đặt ra một câu hỏi hiển nhiên: “Tại sao không dùng Redis cho nhanh?” Mặc dù Redis hiện đang là cái tên được ưa chuộng nhất trong ngành, nhưng Memcached thực tế lại là lựa chọn ưu việt hơn cho nút thắt cổ chai cụ thể của chúng tôi. Đây là lý do tại sao chúng tôi chọn công cụ cũ hơn, đơn giản hơn này:

1. Kiến trúc đa luồng (Multithreaded)

Redis chủ yếu hoạt động đơn luồng (single-threaded). Mặc dù nó cực kỳ nhanh, nhưng nó có thể trở thành nút thắt cổ chai trên các máy chủ có nhiều nhân CPU khi xử lý khối lượng lớn các thao tác get/set đơn giản. Memcached được thiết kế đa luồng ngay từ đầu. Nó mở rộng quy mô theo chiều ngang trên các nhân CPU với hiệu suất tăng gần như tuyến tính, biến nó thành một “con quái vật” trong việc tra cứu key-value đơn giản.

2. Quản lý bộ nhớ và Slab Allocation

Memcached sử dụng cơ chế slab allocator để quản lý bộ nhớ. Nó chia RAM thành các khối được cấp phát trước (slabs) với kích thước cụ thể, giúp loại bỏ hầu như hoàn toàn tình trạng phân mảnh bộ nhớ theo thời gian. Redis linh hoạt hơn với các kiểu dữ liệu nhưng có thể gặp phải tình trạng phân mảnh bộ nhớ khi dữ liệu thay đổi liên tục. Khi bạn chỉ cần lưu trữ các chuỗi hoặc đối tượng đã serialize, tính dự đoán được của Memcached là một lợi thế vận hành cực lớn.

3. Sự đơn giản trong vận hành

Memcached tập trung hoàn toàn vào nhiệm vụ của mình. Nó không cung cấp Pub/Sub, Geospatial index hay các sorted set phức tạp. Về cơ bản, nó là một bảng băm (hash table) khổng lồ, phân tán trong RAM. Sự thiếu hụt các tính năng phức tạp này đồng nghĩa với việc có ít thông số cần điều chỉnh hơn và ít khả năng cấu hình sai hệ thống hơn khi trang web của bạn đang chịu tải nặng.

Thiết lập Memcached cho môi trường Production

Để ổn định hệ thống, tôi đã triển khai một node Memcached chuyên dụng. Trên Ubuntu hoặc Debian, việc thiết lập ban đầu mất chưa đầy một phút.

sudo apt update
sudo apt install memcached libmemcached-tools -y

Công việc thực sự nằm ở file /etc/memcached.conf. Theo mặc định, dịch vụ sẽ bind vào 127.0.0.1 để bảo mật. Nếu các application server của bạn nằm trên các instance khác nhau, bạn phải cập nhật địa chỉ này thành IP mạng nội bộ của mình.

Các tham số chính cần điều chỉnh cho lưu lượng truy cập cao:

  • -m 2048: Thiết lập giới hạn RAM tính bằng MB. Đối với node production của chúng tôi, tôi đã tăng con số này lên 2GB để đảm bảo tỷ lệ hit rate cao.
  • -c 2048: Tăng số lượng kết nối đồng thời tối đa từ mặc định 1024.
  • -t 8: Số lượng luồng (threads) sử dụng. Tôi đã thiết lập con số này khớp với số nhân CPU trên instance của mình.

Áp dụng các thay đổi bằng cách khởi động lại nhanh chóng:

sudo systemctl restart memcached
sudo systemctl enable memcached

Triển khai thực tế với Python

Chúng tôi cần kết nối ứng dụng với lớp cache mới ngay lập tức. Vì backend của chúng tôi dựa trên Python, tôi đã sử dụng thư viện pymemcache nhờ tốc độ và tính an toàn trong môi trường đa luồng (thread-safety).

Trong quá trình di chuyển, tôi phải chuyển đổi một số file cấu hình CSV cũ sang định dạng JSON cho cache. Tôi đã sử dụng toolcraft.app/vi/tools/data/csv-to-json để xử lý việc chuyển đổi ngay trên trình duyệt. Nó giúp dữ liệu không bị đẩy lên các server bên ngoài và giúp tôi không phải viết một script tạm bợ trong lúc dầu sôi lửa bỏng.

Dưới đây là logic chúng tôi đã sử dụng để triển khai mô hình caching “Look-Aside”:

from pymemcache.client import base
import json

# Kết nối tới Memcached cluster
client = base.Client(('10.0.0.5', 11211))

def get_site_settings(settings_id):
    cache_key = f"settings_v2_{settings_id}"
    cached_data = client.get(cache_key)

    if cached_data:
        # Cache Hit: Trả về dữ liệu ngay lập tức
        return json.loads(cached_data)

    # Cache Miss: Lấy dữ liệu từ PostgreSQL
    # Trong một ứng dụng thực tế, đây sẽ là một lệnh gọi SQLAlchemy hoặc Psycopg2
    db_data = {"theme": "dark", "version": "2.4.1", "api_limit": 5000}
    
    # Lưu vào cache trong 1 giờ (3600 giây)
    client.set(cache_key, json.dumps(db_data), expire=3600)
    
    return db_data

Những bài học từ thực tế “xương máu”

Cài đặt package là phần dễ dàng. Quản lý nó ở quy mô lớn đòi hỏi một vài biện pháp phòng ngừa bổ sung mà tôi đã học được qua nhiều năm kinh nghiệm.

Tôn trọng giới hạn 1MB

Memcached có giới hạn mặc định cứng là 1MB cho mỗi item. Nếu bạn cố gắng lưu trữ một đối tượng JSON dung lượng 2MB, client thường sẽ thất bại trong im lặng, dẫn đến tỷ lệ cache hit là 0%. Nếu các đối tượng của bạn lớn hơn 1MB, bạn nên nén chúng bằng zlib hoặc cân nhắc sử dụng Redis, vốn hỗ trợ lên đến 512MB cho mỗi key.

Giải quyết vấn đề Thundering Herd

Khi một key có lưu lượng truy cập cao hết hạn, hàng chục luồng ứng dụng có thể đồng thời thấy cache miss. Tất cả chúng sẽ truy cập vào database cùng một lúc để làm mới giá trị. Để ngăn chặn điều này, chúng tôi đã triển khai “tính toán lại sớm theo xác suất” (probabilistic early recomputation). Chúng tôi làm mới item trong cache khi nó còn 10% thời gian là hết hạn, thay vì đợi cho đến khi nó biến mất hoàn toàn.

Kết luận cuối cùng

Đến 3:30 sáng, hệ thống cache đã hoạt động. Kết quả đến ngay lập tức. Mức sử dụng CPU của database giảm mạnh từ 98% xuống còn 12% ổn định, và thời gian phản hồi trung bình của chúng tôi giảm từ 5 giây xuống chỉ còn 45ms.

Memcached không phải là sự thay thế cho Redis nếu bạn cần lưu trữ dữ liệu bền vững (persistence) hoặc các cấu trúc dữ liệu phức tạp như hash và list. Tuy nhiên, nếu bạn cần một bộ đệm đa luồng, tốc độ cao để bảo vệ database khỏi khối lượng đọc khổng lồ, Memcached vẫn là công cụ hiệu quả nhất trong kho vũ khí của bạn.

Share: