Hãy hình dung một dãy ngăn kéo có dán nhãn. Cần lấy đúng cái trong ngăn "A-42"? Bạn kéo ra một phát, tức thì. Nhưng nếu sếp hỏi "tìm cho tôi mọi món màu đỏ trong toàn bộ dãy tủ", bạn phải mở từng ngăn, nhấc từng món ra soi — dãy tủ không được thiết kế cho câu hỏi đó. Muốn hỏi kiểu ấy nhanh, bạn cần một cuốn mục lục tra chéo, mà lập và giữ cuốn mục lục ấy đúng lại là việc khác. Redis là dãy ngăn kéo dán nhãn đó, và bài này đo cái giá của việc bắt nó trả lời câu hỏi mà nó không được sinh ra để trả lời.

Phần 1 của sê-ri đã nói Redis không phải cơ sở dữ liệu để truy vấn. Ở đây tôi dựng thử một ứng dụng thật trên Redis rồi đo xem điều đó nghĩa là gì cụ thể.

Cùng 100.000 đơn hàng, hai cách lưu

Trong Redis, mỗi đơn là một Hash, cộng hai tập chỉ mục ngược:

HSET dh:12345 khach 42 trang_thai xong tong 123.45 ngay 2026-03-15
SADD idx:khach:42 12345
SADD idx:tt:xong 12345
Redis        101.004 khoá   19,71 MB RAM
PostgreSQL   bảng + 3 chỉ mục    8,81 MB đĩa

Redis tốn gấp 2,2 lần chỗ — và đó là RAM chứ không phải đĩa.

Bốn truy vấn, bốn kết cục

Truy vấn Redis PostgreSQL
Đếm đơn của một khách 0,145 ms 0,334 ms
Tổng tiền một trạng thái 48,3 ms 8,9 ms
Gom nhóm cả bốn trạng thái 203,8 ms 8,9 ms
Lọc trạng thái + khoảng ngày, gom theo khách, sắp xếp, top 5 không làm được 6,5 ms

Dòng đầu Redis thắng: 0,145 so với 0,334 ms, nhanh hơn 2,3 lần. Đó là điều nó được dựng để làm — tra một khoá đã biết, kéo đúng ngăn kéo. Từ dòng hai trở xuống nó thua 5 đến 23 lần, hoặc không làm được.

Vì sao khoảng cách lớn đến vậy

PostgreSQL đọc một chỉ mục và quét tuần tự vài nghìn dòng nằm liền nhau trên đĩa, tất cả trong bộ đệm. Redis phải: lấy danh sách id từ tập chỉ mục, rồi với mỗi id gọi HGET để lấy giá trị. 24.844 đơn cho một trạng thái là 24.844 lần tra bảng băm rải rác trong bộ nhớ — mở 24.844 ngăn kéo.

Và chú ý dòng ba so với dòng hai: bốn trạng thái tốn 203,8 ms, đúng bằng bốn lần một trạng thái. Không có tối ưu hoá nào — không quét chung, không tái sử dụng, không song song. Mỗi lần thêm một chiều là nhân lên. PostgreSQL làm cả bốn nhóm trong cùng một lần quét: 8,9 ms cho một nhóm và 8,9 ms cho bốn nhóm.

Và 203,8 ms đó chặn cả máy chủ

Con số 203,8 ms là thời gian một script Lua chạy. Như đo ở phần 14, đó cũng chính xác là thời gian mọi khách hàng khác phải chờ. Nghĩa là mỗi lần ai đó mở trang báo cáo, toàn bộ Redis đứng yên một phần năm giây. Với ứng dụng có mười người dùng xem báo cáo, đó là hai giây mỗi phút mà không ai được phục vụ.

Cách tránh duy nhất là làm việc gom nhóm ở phía ứng dụng — nhưng khi đó bạn phải kéo 24.844 bản ghi qua mạng, và như đo ở phần 30, đó là bộ đệm đầu ra của máy chủ.

Chỉ mục phải tự duy trì

Ba lệnh cho mỗi đơn thêm mới. Và khi đơn đổi trạng thái:

SREM idx:tt:moi 12345
SADD idx:tt:xong 12345
HSET dh:12345 trang_thai xong

Ba lệnh nữa, và chúng không nằm trong một giao dịch với việc ghi dữ liệu — trừ khi bạn gói vào Lua, và khi đó bạn quay lại vấn đề chặn. Một lỗi giữa chừng để lại chỉ mục sai: đơn nằm trong hai tập trạng thái, hoặc không nằm trong tập nào. Không có gì phát hiện điều đó ngoài việc bạn tự viết một job đối soát. PostgreSQL cập nhật chỉ mục trong cùng giao dịch với dòng dữ liệu. Bạn không thể làm sai điều đó.

Vậy khi nào Redis làm kho chính được

Ba điều kiện, và cần cả ba:

  • Mọi truy vấn đều theo khoá đã biết. Không lọc, không gom nhóm, không sắp xếp theo trường tuỳ ý.
  • Dữ liệu vừa trong RAM, kể cả khi tăng gấp đôi, và bạn chấp nhận trả tiền cho RAM đó.
  • Bạn đã đo mô hình bền và chấp nhận nó. Từ phần 19: everysec mất tối đa một giây khi mất điện; từ phần 21: bản sao chậm thì mất đúng phần chậm đó.

Những hệ thống hợp: bảng phiên đăng nhập, hàng đợi công việc, bảng xếp hạng thời gian thực, bộ đếm. Những hệ thống không hợp: bất cứ thứ gì có báo cáo, bất cứ thứ gì có màn hình quản trị lọc theo nhiều trường, bất cứ thứ gì kế toán.

Mẫu thực dụng: cả hai

Cách gần như luôn đúng là dùng cả hai, mỗi thứ cho việc nó giỏi:

  • PostgreSQL giữ dữ liệu gốc, chỉ mục, ràng buộc, giao dịch, báo cáo.
  • Redis giữ những gì cần đọc nhanh theo khoá: phiên, bộ đệm kết quả, bộ đếm, hàng đợi.

Điều quan trọng: Redis giữ thứ dựng lại được từ PostgreSQL. Khi Redis mất sạch, hệ thống chậm đi một lúc rồi tự hồi phục. Đây chính là câu hỏi kiểm tra ở phần 1: "Nếu toàn bộ Redis biến mất ngay bây giờ, cái gì không dựng lại được?" Với kiến trúc này, câu trả lời là "không có gì".

Còn RedisJSON và RediSearch

Redis Stack thêm mô-đun cho phép lập chỉ mục thứ cấp và truy vấn thật — nó giải quyết phần lớn những gì đo ở trên. Nhưng nó là một sản phẩm khác với giấy phép khác, và nó không có trong ảnh redis chính thức. Nếu bạn cần truy vấn, cân nhắc nó thay vì tự dựng chỉ mục bằng tập hợp — và cân nhắc luôn cả việc dùng một cơ sở dữ liệu vốn đã làm việc đó.

Mẫu số chung: một cấu trúc chỉ tối ưu một kiểu truy cập, kiểu khác phải tự dựng và tự giữ đồng bộ

Cái lõi ở đây không riêng gì Redis: một cấu trúc dữ liệu được tối ưu cho một kiểu truy cập (tra theo khoá) thì mọi kiểu truy cập khác — lọc, gom, sắp xếp theo thuộc tính — phải được mô phỏng bằng chỉ mục tự dựng, và cái chỉ mục ấy vừa chậm hơn vừa phải tự tay giữ đồng bộ, mà "tự tay giữ đồng bộ" chính là chỗ nứt.

Trong thế giới Java, đây đúng là HashMap: get(key) là O(1) tuyệt vời, nhưng "tìm mọi value thoả một điều kiện" bắt buộc quét toàn bộ; muốn nhanh phải nuôi thêm một map phụ khoá theo thuộc tính đó, và giờ mỗi lần sửa một object bạn phải nhớ cập nhật cả hai map — quên một chỗ là hai chỉ mục lệch nhau, đúng lỗi chỉ-mục-sai ở trên. Đây cũng là lý do người ta dùng một CSDL thay vì cố nuôi chục Map phụ trong bộ nhớ.

Trong Go, cùng chuyện: một map[ID]Order giỏi tra theo ID, nhưng truy vấn "theo trạng thái" cần một map[Status][]ID tự dựng, và không có gì trong ngôn ngữ ràng buộc hai map ấy khớp nhau — bạn tự viết mọi lối cập nhật và tự chịu khi bỏ sót một lối.

Còn ở cơ sở dữ liệu, đây là chuyện chọn khoá phân mảnh / khoá chính: một bảng phân mảnh theo user_id tra theo user thì tức thì, nhưng truy vấn theo created_at thành scatter-gather toàn cụm; và denormalize hay materialized view để phục vụ một kiểu truy vấn khác thì phải được làm mới, không tự đồng bộ — đúng cái job đối soát ở trên. Khác biệt cốt lõi giữa "tự dựng chỉ mục trong Redis" và "để Postgres làm" chính là: Postgres cập nhật chỉ mục trong cùng giao dịch, còn bạn thì không.

Mẫu số chung: mỗi cấu trúc/kho dữ liệu tối ưu cho một mô hình truy cập cụ thể; mọi mô hình truy cập khác phải được phục vụ bằng một cấu trúc phụ tự dựng, và cấu trúc phụ đó luôn (a) chậm hơn cái gốc và (b) phải được giữ đồng bộ bằng tay — mà đồng bộ bằng tay không có giao dịch bảo vệ là nguồn của dữ liệu lệch âm thầm. Nên trước khi ép một công cụ phục vụ mô hình truy cập nó không được thiết kế cho, câu hỏi đúng là "mọi truy vấn của tôi có đi qua đúng cái khoá nó tối ưu không — nếu không, ai sẽ giữ chỉ mục phụ đồng bộ, và điều đó có nằm trong một giao dịch không"; nếu câu trả lời là "tôi, bằng tay, ngoài giao dịch" thì bạn đang dựng lại một cơ sở dữ liệu, chỉ là bản không có ai bảo hành tính đúng đắn.

Muốn tự kiểm xem bạn có đang dựng chỉ mục trong Redis không:

redis-cli --scan --count 500 | head -3000 | \
  sed 's/[0-9][0-9]*/N/g' | sort | uniq -c | sort -rn | head -15

Thấy mẫu như idx:*, by-*, *:set, *:list với số lượng lớn là dấu hiệu bạn đang mô phỏng chỉ mục của cơ sở dữ liệu. Câu hỏi tiếp theo: ai giữ chúng đồng bộ, và bạn đã bao giờ đối soát chưa? Với gần như mọi hệ thống tôi từng nhìn, câu trả lời cho vế sau là chưa — và khi đối soát lần đầu, luôn có sai lệch.

Phần sau: Redis so với Memcached — đo cùng một bài toán bộ đệm.