Mục lục
Mục Tiêu Bài Học
- Hiểu vì sao round-trip time (RTT) là nút thắt thật sự, không phải tốc độ xử lý của Redis.
- Dùng pipelining để gộp nhiều command gửi một lượt, giảm số lần đi-về mạng.
- Phân biệt batching bằng lệnh đa-key (
MGET,MSET,HMGET) với pipeline. - Phân biệt rõ
MULTI/EXEC(atomic) với pipeline (chỉ gộp network). - Nắm mô hình connection pooling và multiplexing; biết khi nào cần connection riêng.
- Đo khác biệt bằng
redis-benchmarkvới cờ-P, và tránh các anti-pattern phổ biến.
Bài Toán Round-Trip Time
Redis chạy trên RAM nên một lệnh đơn giản như GET hay SET thường được xử lý trong dưới 1 microsecond. Vậy điều gì làm chậm ứng dụng? Câu trả lời là round-trip time (RTT): thời gian một gói tin đi từ client tới server và quay về.
Mô hình request/response của Redis là tuần tự: client gửi 1 command, chờ reply, rồi mới gửi command tiếp theo. Mỗi cặp gửi-chờ tốn đúng 1 RTT. Tổng thời gian cho N command tuần tự xấp xỉ:
tong_thoi_gian ≈ N × RTT (+ thời gian xử lý của Redis, thường không đáng kể)
Ví dụ cụ thể: cần ghi 1000 key, RTT trong cùng datacenter khoảng 0.5ms. Nếu gửi tuần tự:
1000 × 0.5ms = 500ms
Nửa giây trôi qua chỉ để chờ network, trong khi tổng thời gian Redis thật sự bận xử lý có thể chỉ vài mili-giây. Nếu Redis nằm ở region khác (RTT 50ms) thì 1000 lệnh tuần tự ngốn tới 50 giây — hoàn toàn không chấp nhận được.
Điểm mấu chốt: nút thắt là số lần đi-về mạng, không phải sức mạnh CPU của Redis. Mọi kỹ thuật trong bài này đều hướng tới một mục tiêu — giảm số RTT. Có hai hướng chính: gửi nhiều command trong một lần đi-về (pipelining), và thay nhiều command bằng một command thao tác nhiều key (batching đa-key).
Pipelining — Gộp Command Một Lượt
Pipelining là kỹ thuật client gửi liên tiếp nhiều command mà không chờ reply của từng cái, rồi đọc tất cả reply theo lô ở cuối. Thay vì N lần đi-về, ta gom thành (lý tưởng) 1 lần đi-về cho cả lô.
So sánh trực quan:
# Không pipeline (tuần tự) — 3 RTT
client → SET a 1 server
client ← OK server
client → SET b 2 server
client ← OK server
client → SET c 3 server
client ← OK server
# Pipeline — gộp gửi, gộp đọc → ~1 RTT
client → SET a 1 | SET b 2 | SET c 3 server
client ← OK | OK | OK server
Code với ioredis (TypeScript):
import Redis from "ioredis";
const redis = new Redis();
// Tạo pipeline, chain command, rồi exec() một lần
const pipeline = redis.pipeline();
for (let i = 0; i < 1000; i++) {
pipeline.set(`key:${i}`, i);
}
// exec() gửi cả lô và trả mảng kết quả theo đúng thứ tự đã thêm
const results = await pipeline.exec();
// results: [[null, "OK"], [null, "OK"], ...]
// mỗi phần tử là [error, value]; error = null nếu thành công
for (const [err, value] of results!) {
if (err) console.error("Lệnh lỗi:", err);
}
Code với redis-py (Python):
import redis
r = redis.Redis()
# transaction=False để chỉ pipeline thuần (không bọc MULTI/EXEC)
pipe = r.pipeline(transaction=False)
for i in range(1000):
pipe.set(f"key:{i}", i)
# execute() gửi cả lô, trả list kết quả theo thứ tự đã thêm
results = pipe.execute()
# results: [True, True, True, ...]
Với 1000 lệnh, pipeline biến 1000 × RTT thành gần 1 × RTT (cộng thời gian truyền dữ liệu của lô). Trên cùng datacenter, 500ms có thể rút còn vài mili-giây.
Lưu ý quan trọng: pipeline không đảm bảo tính atomic. Redis vẫn nhận và xử lý từng lệnh trong lô theo thứ tự, nhưng lệnh của các client khác có thể xen kẽ vào giữa lô của bạn. Pipeline chỉ là tối ưu tầng network — nó gom việc gửi/nhận, không tạo ra "khối lệnh không thể tách rời". Nếu cần atomic, dùng MULTI/EXEC hoặc Lua script (Lua sẽ đào sâu ở module sau).
Batching Bằng Lệnh Đa-key
Một số lệnh Redis được thiết kế để thao tác nhiều key trong một command duy nhất. Phổ biến nhất là MGET / MSET cho string và HMGET cho hash field.
# Thay vì 3 lần GET (3 command), dùng 1 lần MGET
MGET user:1 user:2 user:3 # trả về [v1, v2, v3]
# Thay vì 3 lần SET, dùng 1 lần MSET
MSET user:1 alice user:2 bob user:3 carol
# Đọc nhiều field của cùng một hash
HMGET user:1 name email age # trả về [name, email, age]
Trong code:
// ioredis
const values = await redis.mget("user:1", "user:2", "user:3");
await redis.mset("user:1", "alice", "user:2", "bob");
# redis-py
values = r.mget(["user:1", "user:2", "user:3"])
r.mset({"user:1": "alice", "user:2": "bob"})
Phân biệt với pipeline — đây là điểm nhiều người nhầm:
- Batching đa-key: một command thao tác nhiều key. Redis xử lý nguyên command đó một mạch, nên nó có tính atomic ở mức command. Hạn chế: chỉ áp dụng cho các lệnh có biến thể đa-key (
MGET,MSET,HMGET,SADDnhiều member...), và mọi key phải cùng loại thao tác. - Pipeline: nhiều command bất kỳ (có thể trộn
GET,INCR,EXPIRE...) gửi trong một lượt network. Linh hoạt hơn nhưng không atomic.
Quy tắc thực dụng: nếu bài toán đúng dạng "đọc/ghi nhiều key cùng kiểu", ưu tiên lệnh đa-key vì gọn và có atomic mức command. Nếu cần trộn nhiều loại lệnh, dùng pipeline. Hai kỹ thuật cũng kết hợp được — pipeline một loạt MGET chẳng hạn.
Cảnh báo nhỏ trong cluster mode: lệnh đa-key yêu cầu các key nằm cùng một hash slot (dùng hash tag {...}), nếu không sẽ bị từ chối với lỗi CROSSSLOT. Chủ đề cluster sẽ bàn ở module riêng.
MULTI/EXEC vs Pipeline
MULTI/EXEC là cơ chế transaction của Redis. Các lệnh giữa MULTI và EXEC được Redis xếp hàng (queue), rồi khi EXEC chạy, cả khối được thực thi liền mạch, không lệnh nào của client khác xen vào giữa. Đây chính là sự khác biệt cốt lõi so với pipeline.
MULTI # bắt đầu transaction
INCR counter # được QUEUED, chưa chạy
INCR counter # QUEUED
EXEC # chạy cả khối một mạch, trả [1, 2]
| Tiêu chí | Pipeline | MULTI/EXEC |
|---|---|---|
| Mục đích | Gộp network, giảm RTT | Đảm bảo atomic, không bị xen kẽ |
| Atomic? | Không — client khác có thể chen lệnh vào giữa | Có — cả khối chạy liền mạch |
| Giảm RTT? | Có (mục tiêu chính) | Có một phần (cũng gửi gộp), nhưng không phải mục tiêu |
| Rollback? | Không liên quan | Không có rollback kiểu SQL; lệnh sai cú pháp khi queue thì cả EXEC bị huỷ |
Code MULTI/EXEC:
// ioredis: multi() trả object để chain, exec() chạy atomic
const results = await redis
.multi()
.incr("counter")
.incr("counter")
.exec();
// results: [[null, 1], [null, 2]]
# redis-py: pipeline mặc định transaction=True → bọc MULTI/EXEC
pipe = r.pipeline() # transaction=True (mặc định)
pipe.incr("counter")
pipe.incr("counter")
results = pipe.execute() # chạy atomic, trả [1, 2]
Chú ý ở redis-py: r.pipeline() mặc định transaction=True, tức là tự bọc MULTI/EXEC — vừa gộp network vừa atomic. Muốn pipeline thuần (chỉ gộp network, không atomic) phải truyền transaction=False như ở Section 3. Đây là điểm dễ gây hiểu nhầm khi đọc code redis-py.
Khi cần logic điều kiện (đọc giá trị rồi quyết định ghi gì) một cách atomic, có thêm WATCH (optimistic locking) hoặc Lua script. Lua cho phép chạy cả đoạn logic atomic phía server trong một command — sẽ trình bày kỹ ở module sau.
Connection Pooling & Multiplexing
Tạo một connection Redis mới không hề rẻ: phải làm TCP handshake (3 bước), có thể thêm TLS handshake, rồi AUTH (gửi mật khẩu), đôi khi SELECT database. Nếu mỗi HTTP request lại mở connection mới, ta gánh toàn bộ chi phí đó cho từng request — đồng thời dễ rơi vào connection exhaustion: số connection mở vượt giới hạn maxclients của server, các connection mới bị từ chối.
Giải pháp là tái sử dụng connection. Có hai mô hình chính:
1. Multiplexing (ioredis). Một instance new Redis() giữ một connection duy nhất và ghép (multiplex) mọi command của ứng dụng qua nó. Vì Redis xử lý lệnh tuần tự và ioredis biết ghép request/response đúng thứ tự, một connection là đủ cho phần lớn workload. Bạn tạo client một lần lúc khởi động và dùng lại suốt vòng đời ứng dụng:
// Tạo MỘT LẦN ở module-level, tái sử dụng cho mọi request
import Redis from "ioredis";
export const redis = new Redis({
host: "127.0.0.1",
port: 6379,
maxRetriesPerRequest: 3, // số lần retry trước khi command fail
});
// Trong handler chỉ việc dùng lại — KHÔNG tạo new Redis() mỗi request
async function handler(userId: string) {
return redis.get(`user:${userId}`);
}
2. Connection pool (node-redis, redis-py). Duy trì một tập connection sẵn sàng; mỗi lần cần, mượn một connection từ pool, dùng xong trả lại. Mô hình này phù hợp khi có nhiều thread/worker chạy command song song.
import redis
# Tạo pool MỘT LẦN, chia sẻ cho toàn ứng dụng
pool = redis.ConnectionPool(
host="127.0.0.1",
port=6379,
max_connections=50, # giới hạn số connection trong pool
)
# Mỗi client lấy connection từ pool khi cần, trả lại khi xong
r = redis.Redis(connection_pool=pool)
def get_user(user_id: str):
return r.get(f"user:{user_id}")
redis-py cũng tự tạo pool ngầm nếu bạn chỉ gọi redis.Redis(host=...) — nhưng khai báo pool tường minh giúp kiểm soát max_connections.
Tuning pool size. Không có con số vàng, nhưng nguyên tắc: pool đủ lớn để phục vụ số request đồng thời (concurrency) thực tế, nhưng không vượt maxclients của Redis khi nhân với số process/instance ứng dụng. Pool quá nhỏ → request phải xếp hàng chờ connection rảnh (nghẽn). Pool quá lớn → tốn file descriptor, RAM phía client và ngốn slot connection của Redis.
Cảnh báo về lệnh blocking. Các lệnh như BLPOP, BRPOP, BLMOVE, hay SUBSCRIBE sẽ giữ connection lại cho đến khi có dữ liệu hoặc hết timeout. Nếu chạy chúng trên connection multiplexed dùng chung, mọi command khác trên connection đó bị chặn theo. Vì vậy lệnh blocking và pub/sub cần một connection riêng. Trong ioredis, dùng redis.duplicate() để tạo connection độc lập cho mục đích này.
Đo Lường Với redis-benchmark
redis-benchmark là công cụ đi kèm Redis để đo throughput. Cờ -P (pipeline) cho phép mô phỏng pipeline với số lệnh mỗi lô, giúp thấy rõ tác động.
# Không pipeline (mặc định -P 1): mỗi command 1 RTT
redis-benchmark -t set,get -n 100000 -P 1 -q
# Pipeline 16 lệnh mỗi lô
redis-benchmark -t set,get -n 100000 -P 16 -q
# Pipeline 100 lệnh mỗi lô
redis-benchmark -t set,get -n 100000 -P 100 -q
Giải thích các cờ:
-t set,get: chỉ chạy các test SET và GET.-n 100000: tổng số request.-P 16: gộp 16 command mỗi pipeline.-q: chế độ quiet, chỉ in dòng kết quả "requests per second".- (có thể thêm
-c 50để mô phỏng 50 client đồng thời.)
Kết quả điển hình (con số tuyệt đối tuỳ máy, nhưng xu hướng luôn rõ): với -P 1 bạn có thể thấy vài chục nghìn request/giây; tăng lên -P 16 hay -P 100, throughput thường nhảy lên hàng trăm nghìn tới hàng triệu request/giây. Đó chính là RTT bị "phân bổ" cho cả lô thay vì gánh từng lệnh.
Lưu ý đọc số liệu: throughput tăng mạnh không có nghĩa Redis "nhanh hơn", mà là ta đã loại bỏ thời gian chờ network. Sau một ngưỡng (ví dụ -P 100 trở lên) lợi ích giảm dần vì CPU và băng thông trở thành giới hạn mới.
Trade-off Cần Cân Nhắc
Không kỹ thuật nào miễn phí. Hiểu trade-off giúp chọn đúng độ lớn.
Pipeline quá lớn. Mỗi lô pipeline phải được buffer ở cả client (chờ gửi/đọc) lẫn server (Redis tích reply vào output buffer trước khi flush). Gộp hàng triệu lệnh trong một lô:
- Ngốn memory buffer lớn ở cả hai phía, nguy cơ phình RAM đột biến.
- Tăng latency cho lệnh đầu tiên trong lô: nó chỉ nhận được reply sau khi cả lô được gửi và xử lý xong.
- Giữ event loop / connection bận lâu, ảnh hưởng các tác vụ khác.
Thực hành tốt: chia lô vừa phải, thường vài trăm đến vài nghìn lệnh mỗi lô (chunking), thay vì một lô khổng lồ.
// Chunk 1 triệu lệnh thành nhiều lô 1000 thay vì 1 lô khổng lồ
const CHUNK = 1000;
for (let i = 0; i < 1_000_000; i += CHUNK) {
const pipeline = redis.pipeline();
for (let j = i; j < Math.min(i + CHUNK, 1_000_000); j++) {
pipeline.set(`key:${j}`, j);
}
await pipeline.exec();
}
Pool size. Như đã nói ở Section 6: quá nhỏ thì request xếp hàng chờ → tăng latency và nghẽn; quá lớn thì tốn tài nguyên client và đụng trần maxclients của Redis (mặc định 10000). Nhớ rằng tổng connection = pool_size × số process ứng dụng, nên với nhiều instance phải tính tổng.
Atomic vs throughput. Khi vừa cần gộp network vừa cần atomic, MULTI/EXEC đáp ứng cả hai nhưng đánh đổi: trong lúc EXEC chạy, Redis (single-thread) bận với khối đó nên các client khác chờ. Khối transaction càng dài thì độ trễ "tail" của client khác càng tăng. Giữ transaction ngắn gọn.
Pitfalls & Anti-patterns
1. Tạo client/connection mới mỗi request. Đây là lỗi phổ biến và tốn kém nhất.
// SAI — mỗi request mở connection mới: TCP + AUTH lặp lại, dễ exhaustion
async function handler(id: string) {
const redis = new Redis(); // ANTI-PATTERN
const v = await redis.get(`u:${id}`);
redis.quit();
return v;
}
// ĐÚNG — tạo một lần ở module-level, tái sử dụng
import { redis } from "./redis-client";
async function handler(id: string) {
return redis.get(`u:${id}`);
}
# SAI — tạo client (và pool ngầm) mới mỗi lần gọi
def get_user(uid):
r = redis.Redis() # ANTI-PATTERN
return r.get(f"u:{uid}")
# ĐÚNG — client/pool tạo một lần, dùng chung
def get_user(uid):
return r.get(f"u:{uid}") # r đã tạo sẵn ở module-level
2. Pipeline hàng triệu lệnh trong một lô. Phình memory buffer client/server và đẩy latency lệnh đầu lên cao. Luôn chunk thành lô vừa phải (Section 8).
3. Nhầm pipeline là transaction. Pipeline KHÔNG atomic — lệnh client khác có thể xen vào giữa lô của bạn. Nếu một chuỗi lệnh phải chạy không bị tách rời (ví dụ đọc rồi ghi có điều kiện), dùng MULTI/EXEC (hoặc WATCH, Lua), không phải pipeline thuần.
4. Quên connection riêng cho lệnh blocking. Chạy BLPOP/SUBSCRIBE trên connection dùng chung sẽ chặn mọi command khác trên connection đó. Dùng connection độc lập (redis.duplicate() với ioredis, hoặc client riêng với redis-py).
// ĐÚNG — connection riêng cho blocking/pub-sub
const blockingConn = redis.duplicate();
const job = await blockingConn.blpop("jobs", 0); // không chặn redis chính
5. Bỏ kiểm tra lỗi từng lệnh trong pipeline. Mỗi phần tử kết quả ioredis là [error, value]; một lệnh fail không làm cả lô fail. Luôn duyệt kiểm tra phần tử error.
Tổng Kết & Quiz
Tổng kết
- Nút thắt thực sự là RTT: N command tuần tự ≈ N × RTT. Redis nhanh, network mới chậm.
- Pipelining gộp nhiều command gửi một lượt, đọc reply theo lô → giảm RTT mạnh; nhưng KHÔNG atomic.
- Batching đa-key (
MGET,MSET,HMGET) = một command nhiều key, atomic mức command; pipeline = nhiều command một lượt gửi. - MULTI/EXEC đảm bảo khối lệnh chạy liền mạch (atomic), khác hẳn pipeline vốn chỉ gộp network. redis-py mặc định
transaction=True. - Connection pooling / multiplexing: tạo client một lần, tái sử dụng; tránh chi phí TCP + AUTH và connection exhaustion. Tune pool theo concurrency và
maxclients. - Đo bằng
redis-benchmark -P; chunk pipeline vừa phải; dành connection riêng cho lệnh blocking.
Quiz 5 câu
- Vì sao gửi 1000 command tuần tự lại chậm dù Redis xử lý mỗi lệnh dưới 1 microsecond? Công thức ước lượng tổng thời gian là gì?
- Pipeline và MULTI/EXEC khác nhau ở điểm cốt lõi nào? Cái nào đảm bảo atomic?
- Phân biệt batching bằng
MGETvới pipeline gồm nhiều lệnhGET. Khi nào nên dùng cái nào? - Tại sao không nên tạo
new Redis()/redis.Redis()trong mỗi HTTP request? Hai mô hình tái sử dụng connection là gì? - Vì sao lệnh
BLPOPcần connection riêng, và điều gì xảy ra nếu chạy nó trên connection multiplexed dùng chung?
Đáp án gợi ý
- Vì mỗi command tốn một round-trip mạng (RTT) để chờ reply trước khi gửi lệnh kế tiếp; thời gian chờ network áp đảo thời gian xử lý. Ước lượng: tổng ≈ N × RTT (ví dụ 1000 × 0.5ms = 500ms).
- Pipeline chỉ gộp việc gửi/nhận trên network, KHÔNG atomic — lệnh client khác có thể xen vào giữa. MULTI/EXEC xếp hàng rồi chạy cả khối liền mạch, đảm bảo atomic. MULTI/EXEC mới là cái atomic.
MGETlà một command thao tác nhiều key, atomic mức command, nhưng chỉ áp dụng cho thao tác cùng kiểu. Pipeline nhiềuGETlinh hoạt hơn (trộn được nhiều loại lệnh) nhưng không atomic. Cùng kiểu thao tác nhiều key → ưu tiên lệnh đa-key; cần trộn lệnh khác nhau → pipeline.- Vì mỗi connection mới gánh chi phí TCP handshake + (TLS) + AUTH, và dễ gây connection exhaustion vượt
maxclients. Hai mô hình: multiplexing (ioredis — một connection ghép mọi command) và connection pool (node-redis, redis-py — tập connection mượn/trả). BLPOPlà lệnh blocking, giữ connection cho đến khi có dữ liệu hoặc hết timeout. Trên connection multiplexed dùng chung, nó sẽ chặn mọi command khác đi qua connection đó. Vì vậy cần connection riêng (ví dụredis.duplicate()).
Bài tiếp theo
Bài 4 chuyển sang tư duy thiết kế dữ liệu trong Redis: vì sao nên đặt TTL ngay từ đầu cho mọi key, cách EXPIRE hoạt động và những bẫy thường gặp khi quản lý vòng đời key.
