🧰 Redis nhập môn: GET, SET, TTL và key naming

By Better Wise

Redis: cái tủ lạnh đặt cạnh ứng dụng

Ở Module 1 bạn đã dựng luồng cache-aside: đọc cache trước, miss thì đọc DB rồi ghi lại vào cache. Module 2 bạn gắn TTL và header HTTP cho phần cache phía client và CDN. Còn cái "tủ lạnh" thật sự nằm cạnh backend thì chưa ai mở nắp. Bài này mở nắp: Redis, với đúng ba thứ cần cho người mới là SET, GET, TTL và cách đặt tên key.

Sau bài này bạn sẽ:

  • Ghi một value vào Redis kèm TTL trong một lệnh duy nhất
  • Đọc lại value và phân biệt "cache miss" với "giá trị rỗng"
  • Kiểm tra TTL còn lại và phát hiện key không bao giờ hết hạn
  • Đặt tên key có namespace, tránh key quá lớn

Redis giữ gì trong bụng

Redis là key-value store chạy trong RAM. Nhanh, đơn giản, nhưng có một chi tiết người mới hay quên: Redis chỉ lưu string/binary. Không có "object" tự động. Muốn cache một product, bạn tự serialize nó (thường là JSON) rồi tự parse lại khi đọc.

Note: Vì value là string, GET luôn trả về string hoặc nil. Đừng giả định nó trả về object hay number như driver DB quen thuộc.

SET và GET: hai lệnh làm nên phần lớn công việc

Lệnh Việc nó làm Ví dụ
SET key value EX 300 Ghi value, tự hết hạn sau 300 giây SET shop:product:42 '{"name":"Bàn phím cơ"}' EX 300
GET key Đọc value, trả nil nếu không có GET shop:product:42
TTL key Số giây còn lại TTL shop:product:42
EXPIRE key 600 Gắn TTL cho key đã tồn tại EXPIRE shop:product:42 600
DEL key Xóa ngay DEL shop:product:42
PERSIST key Bỏ TTL, cho key sống mãi PERSIST shop:product:42

Ví dụ chạy thật, đọc từ trên xuống:

> SET shop:product:42 '{"id":42,"name":"Bàn phím cơ","price":1290000}' EX 300
OK
> TTL shop:product:42
(integer) 300
> GET shop:product:42
"{\"id\":42,\"name\":\"Bàn phím cơ\",\"price\":1290000}"
> GET shop:product:99
(nil)

Chú ý dòng cuối: key không tồn tại trả nil, không phải lỗi. Đây chính là tín hiệu "cache miss" để bạn đi đọc DB.

Giờ ghép vào cache-aside bằng code:

async function getProduct(id) {
  const key = `shop:product:${id}`;

  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);   // hit

  const product = await db.getProduct(id);          // miss
  if (!product) return null;

  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}

Câu hỏi quen thuộc của tôi: "dữ liệu này cũ bao lâu thì chấp nhận được?" Product đổi giá mỗi ngày một lần, 300 giây là quá an toàn. Nhưng nếu đó là số dư ví thì 300 giây là thảm họa, và bạn cần một chiến lược invalidation khác, không chỉ TTL.

TTL: dán hạn sử dụng lên từng hộp sữa

TTL là thời gian sống của key, tính bằng giây. Có hai con số đặc biệt bạn phải nhớ:

  • TTL key trả -1: key tồn tại nhưng không có TTL. Nó sẽ sống mãi.
  • TTL key trả -2: key không tồn tại (chưa tạo hoặc đã hết hạn).
> SET auth:session:9f3a "user=17" EX 1800
OK
> TTL auth:session:9f3a
(integer) 1800
> PERSIST auth:session:9f3a
(integer) 1
> TTL auth:session:9f3a
(integer) -1

Ví dụ trên cho thấy -1 đến từ đâu: ai đó gọi PERSIST, hoặc gọi SET mà quên EX. Một session không có TTL nghĩa là user đăng nhập một lần rồi... không bao giờ bị đăng xuất. Đó là lý do một key không có TTL là một key sẽ nói dối bạn mãi mãi.

Mẹo nhỏ nhưng quan trọng: luôn dùng SET key value EX n, đừng tách thành SET rồi EXPIRE. Hai lệnh rời nhau, nếu process chết ở giữa thì key tồn tại vĩnh viễn mà không ai biết. SET ... EX là một lệnh nguyên tử.

Key naming: namespace là ngăn kéo trong tủ

Tủ lạnh mà nhét đồ không nhãn thì hôm sau bạn phải mở từng hộp ra ngửi. Redis cũng vậy. Quy ước phổ biến là phân cấp bằng dấu hai chấm:

shop:product:42
shop:product:42:reviews:page1
shop:category:7:top_products
auth:session:9f3a

Đọc là hiểu: service shop, entity product, id 42. Vài quy tắc thực dụng:

  • Có namespace ở đầu: shop:, auth:, report:. Khi debug, bạn nhìn key là biết nó thuộc team nào.
  • Giữ key nhỏ: mục tiêu vài chục đến vài trăm byte. Key dài làm tốn RAM và khó đọc log.
  • Đừng nhồi collection lớn vào một key: một key chứa list 50.000 item thì mỗi lần đọc là kéo cả 50.000 item qua mạng, và mỗi lần dữ liệu đổi là phải xóa cả cục. Chia nhỏ theo trang hoặc theo id.
  • Đừng đặt dữ liệu nhạy cảm trong tên key: email, token thô, số điện thoại. Key hay lọt vào log, công cụ monitoring, thông báo lỗi.

Sơ đồ luồng cho dễ hình dung:

flowchart TD
    Client["🛒<br/>Client xin product 42"] --> Get["GET shop:product:42"]
    Get --> Hit{"Có giá trị?"}
    Hit -->|Có| Return["Trả JSON cho client"]
    Hit -->|Không| DB["Đọc DB"]
    DB --> Set["SET key value EX 300"]
    Set --> Return

Những vấp ngã đầu tiên khi cầm Redis

Vài lỗi tôi gặp nhiều nhất ở người mới, kèm cách tránh:

  • Tách SET và EXPIRE: process chết giữa hai lệnh là key không TTL. Dùng SET ... EX.
  • Không kiểm tra TTL: khi debug dữ liệu cũ, gõ TTL key trước tiên. -1 là thủ phạm thường gặp.
  • Đặt TTL quá dài "cho an toàn": TTL dài không làm dữ liệu đúng hơn, nó chỉ làm dữ liệu cũ sống lâu hơn.
  • Dùng KEYS * trên production: lệnh này quét toàn bộ keyspace và chặn Redis. Dùng SCAN khi cần duyệt key.
  • Cache cả object khổng lồ: nếu value vài MB, bạn đang dùng Redis như một cái DB tệ. Cache phần nhỏ, đọc thường xuyên.

Mang gì sang bài sau

Bốn điều cần nhớ: SET key value EX n là một lệnh nguyên tử và luôn nên dùng; GET trả nil khi miss; TTL trả -1 nghĩa là key sống mãi, -2 nghĩa là không tồn tại; và tên key nên có namespace, nhỏ, không chứa dữ liệu nhạy cảm.

Bài tiếp theo đi thẳng vào hệ quả của TTL: chuyện gì xảy ra khi dữ liệu trong Redis cũ hơn DB, và chuyện gì xảy ra khi một key hết hạn đúng lúc hàng nghìn request cùng miss, hay còn gọi là cache stampede.

Front: Redis lưu dữ liệu ở đâu và dưới dạng gì? Back: Trong RAM, dạng key-value; value là string/binary nên object phải serialize, thường bằng JSON.

Front: Lệnh nào ghi một value kèm TTL 300 giây trong một lần gọi? Back: SET key value EX 300.

Front: Vì sao không nên tách SET rồi EXPIRE thành hai lệnh? Back: Nếu process chết ở giữa, key tồn tại vĩnh viễn mà không có TTL.

Front: GET trả về gì khi key không tồn tại hoặc đã hết hạn? Back: nil, đây là tín hiệu cache miss chứ không phải lỗi.

Front: TTL key trả về -1 nghĩa là gì? Back: Key tồn tại nhưng không có TTL, tức là sẽ sống mãi.

Front: TTL key trả về -2 nghĩa là gì? Back: Key không tồn tại, chưa được tạo hoặc đã hết hạn.

Front: Quy ước phân cấp namespace cho key Redis dùng ký tự gì? Back: Dấu hai chấm, ví dụ shop:product:42.

Front: Một key Redis nên có kích thước thế nào? Back: Nhỏ, vài chục đến vài trăm byte; không nhồi cả collection lớn vào một key.

Front: Vì sao không đặt email hay token thô trong tên key? Back: Tên key có thể lộ qua log, monitoring và thông báo lỗi.

Front: Vì sao không dùng KEYS * trên production? Back: Nó quét toàn bộ keyspace và chặn Redis; dùng SCAN thay thế.

Front: DEL khác gì với việc để key hết hạn theo TTL? Back: DEL xóa ngay lập tức; hết hạn là Redis tự xóa sau khoảng TTL.

Front: PERSIST key làm gì? Back: Bỏ TTL của key, biến nó thành key sống mãi, thường là điều không mong muốn.

Front: Trong cache-aside, sau khi SET giá trị vừa đọc từ DB cần kèm gì? Back: Kèm TTL để dữ liệu cũ tự rời khỏi cache mà không cần ai xóa tay.

Ôn nhanh bằng cách điền vào chỗ trống:

  • Ghi value kèm hạn 300 giây trong một lệnh: _____.
  • Lệnh _____ trả về -1 khi key tồn tại nhưng _____.
  • Tên key phân cấp namespace bằng dấu _____.
  • GET trả về _____ khi key không tồn tại hoặc đã hết hạn.
  • Trên production nên dùng _____ thay cho KEYS *.