Danh sách bài viết

Bài 20: String — Cache, Counter, Token, Bitfield

Redis String là data type binary-safe, tối đa 512MB, có thể chứa text, JSON, số nguyên hoặc raw bytes. Bài tổng quan ở Module 0 đã giới thiệu String ở mức bề mặt; bài này đi sâu vào 3 use case thường gặp sau cache (đã cover Module 1): atomic counter với INCR/INCRBY, token storage (session, OTP, API key revocation) với SETEX, và bitfield compact để lưu nhiều trường số nguyên nhỏ trong vài byte. Bao gồm 3 encoding nội bộ mà Redis tự chọn (int, embstr, raw), cách đo với MEMORY USAGE và OBJECT ENCODING, code Python hoàn chỉnh, anti-patterns và best practices Redis 7.x.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

  • Giải thích được "binary-safe" nghĩa là gì và tại sao Redis String có thể lưu integer, JSON, bytes.
  • Phân biệt 3 encoding nội bộ của String: int, embstr, raw — Redis chọn cái nào dựa trên tiêu chí nào.
  • Viết được counter pattern với INCR/INCRBY/INCRBYFLOAT, hiểu tại sao nó atomic và không cần lock phía application.
  • Triển khai token storage (session, OTP, revocation list) đúng cách bằng SETEX, xử lý TTL, one-time delete.
  • Dùng BITFIELD để pack nhiều trường số nguyên nhỏ vào 1 key, biết khi nào Bitfield có lợi và khi nào không.
  • Dùng MEMORY USAGEOBJECT ENCODING để kiểm tra thực tế.
2

String Trong Redis: Binary-safe & 3 Encoding Nội Bộ

Binary-safe là gì

String Redis không phải là "chuỗi text" theo nghĩa ngôn ngữ lập trình — nó là một sequence of bytes, tối đa 512MB. "Binary-safe" có nghĩa là Redis không xử lý nội dung theo bất kỳ encoding cụ thể nào (không null-terminate như C string), nên có thể lưu:

  • Text UTF-8: "Xin chào"
  • JSON serialized: {"id":1,"name":"Alice"}
  • Số nguyên: "12345" hay thẳng integer khi dùng INCR
  • Raw bytes: ảnh JPEG, Protobuf message, binary data bất kỳ

Điều đó không có nghĩa là bạn nên nhét ảnh JPEG vào Redis — đây là điểm kỹ thuật để hiểu giới hạn, không phải gợi ý thiết kế. Với hot data nhỏ (vài KB), String Redis hoàn toàn phù hợp; với binary lớn nên dùng object storage.

Implementation: SDS

Bên dưới, Redis dùng SDS (Simple Dynamic String) thay vì C string thuần. SDS lưu độ dài tường minh (không quét tới null byte), hỗ trợ binary-safe và cấp phát lại buffer theo chiến lược riêng. Đây là lý do STRLEN là O(1) — độ dài được lưu sẵn trong header của SDS.

3 Encoding Nội Bộ

Redis tự chọn encoding dựa vào nội dung value, bạn không set tường minh:

Encoding Điều kiện áp dụng Đặc điểm
int Value là integer 64-bit (long long) Không có SDS buffer riêng, giá trị nhúng trực tiếp vào object. INCR/DECR cực nhanh vì chỉ tăng số nguyên, không serialize/deserialize chuỗi.
embstr Value ≤ 44 bytes (string) robj và SDS nằm chung 1 allocation, cache-friendly hơn. Read-only sau khi tạo — nếu modify (append, setrange) Redis convert sang raw ngay.
raw Value > 44 bytes (string) Hai allocation riêng: robj và SDS. Cho phép modify in-place. Phổ biến nhất với JSON, text dài.
redis> SET counter 100
OK
redis> OBJECT ENCODING counter
"int"

redis> SET name "Alice"
OK
redis> OBJECT ENCODING name
"embstr"

redis> SET json '{"id":1,"name":"Alice Chen","email":"[email protected]","role":"admin"}'
OK
redis> OBJECT ENCODING json
"raw"

Hiểu encoding có giá trị thực tế khi bạn debug memory hoặc benchmark. Ví dụ, counter dạng int tiêu tốn ít RAM hơn đáng kể so với lưu cùng số dưới dạng chuỗi text.

3

Use Case 1 — Atomic Counter

Tại sao INCR là atomic

Redis là single-threaded với event loop — mỗi lệnh được thực thi hoàn toàn trước khi lệnh tiếp theo bắt đầu. Không có context switch giữa chừng. Vì vậy INCR (đọc giá trị hiện tại, tăng 1, ghi lại) là một thao tác không thể bị ngắt từ góc độ client. Application không cần lock, không cần transaction, không lo race condition khi có nhiều instance cùng INCR một key.

Đây là sự khác biệt cốt lõi so với pattern GET → tăng ở application → SET: khoảng trống giữa GET và SET là race condition.

Lệnh counter cơ bản

redis> SET page:views 0
OK
redis> INCR page:views
(integer) 1
redis> INCRBY page:views 100
(integer) 101
redis> DECR page:views
(integer) 100
redis> DECRBY page:views 10
(integer) 90
redis> INCRBYFLOAT order:total 9.99
"9.99"
redis> INCRBYFLOAT order:total 4.01
"14"

Lưu ý: INCRBYFLOAT trả về chuỗi (vì float không có encoding riêng như integer), value lưu dưới dạng embstr/raw chứ không phải int. Nếu cần đếm số tiền chính xác, ưu tiên dùng integer (đơn vị xu/cent) thay vì float.

Khởi tạo không cần SET trước

Nếu key chưa tồn tại, INCR tự khởi tạo về 0 rồi tăng lên 1. Không cần pattern GET → check nil → SET 0 → INCR. Đây cũng tránh được race condition khi hai request cùng kiểm tra "key có tồn tại chưa":

# Sai — 3 round-trip, có khoảng hở race condition
redis> GET new_counter         # nil
redis> SET new_counter 0       # ghi 0
redis> INCR new_counter        # 1

# Đúng — 1 round-trip, atomic, không có race
redis> INCR new_counter        # tự init 0 → trả về 1

Counter với TTL (window-based)

Một use case phổ biến là counter theo khoảng thời gian — ví dụ đếm số request trong 1 phút. Lưu ý quan trọng: INCR không reset TTL. TTL bắt đầu đếm từ lúc key được tạo (lúc SET hoặc lúc INCR đầu tiên tự tạo key) và không bị ảnh hưởng bởi các INCR sau đó.

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

def increment_with_window(key: str, window_seconds: int) -> int:
    """
    Tăng counter, tự đặt TTL nếu key mới tạo.
    Trả về giá trị sau khi tăng.
    """
    count = r.incr(key)
    if count == 1:
        # Key vừa được tạo lần đầu → đặt TTL
        r.expire(key, window_seconds)
    return count

Tại sao chỉ set expire khi count == 1? Vì nếu set expire mỗi lần INCR, cửa sổ thời gian bị reset liên tục — counter không bao giờ hết hạn miễn là có request liên tục đến. Đây là anti-pattern phổ biến dẫn đến rate limiter bị bypass.

Tuy nhiên có một race condition nhỏ: nếu server crash giữa INCR và EXPIRE, key tồn tại mãi không có TTL. Cách an toàn hơn dùng Lua script (sẽ được đề cập ở Module 3 — Rate Limiting):

LUA_INCR_WITH_TTL = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
"""

def safe_increment_with_window(key: str, window_seconds: int) -> int:
    script = r.register_script(LUA_INCR_WITH_TTL)
    return script(keys=[key], args=[window_seconds])

Use cases thực tế

  • Page view, like counter: buffer tạm trong Redis, write-back DB định kỳ (batch).
  • Distributed sequence: thay thế auto-increment của DB cho trường hợp cần ID trước khi INSERT.
  • Quota tracking: đếm số lần gọi API trong tháng theo user/API key.
  • Rate limiting sơ bộ: counter với TTL (logic hoàn chỉnh hơn dùng Lua, xem Module 3).
4

Use Case 2 — Token Storage

Token (session token, OTP, API key) là một trong những use case tự nhiên nhất của String Redis. Đặc điểm chung: cần TTL cứng, truy cập theo key ngẫu nhiên (lookup O(1)), và thường là one-time-use (xóa sau khi dùng).

Pattern 1 — Session Token

import secrets
import json
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

SESSION_TTL = 86400  # 24 giờ


def create_session(user_id: int, metadata: dict) -> str:
    """Tạo session mới, trả về token."""
    token = secrets.token_urlsafe(32)  # 43 ký tự URL-safe, crypto-random
    payload = json.dumps({"user_id": user_id, **metadata})
    r.setex(f"session:{token}", SESSION_TTL, payload)
    return token


def get_session(token: str) -> dict | None:
    """Xác thực token, trả về payload nếu hợp lệ."""
    raw = r.get(f"session:{token}")
    if raw is None:
        return None  # Token không tồn tại hoặc đã hết hạn
    return json.loads(raw)


def delete_session(token: str) -> None:
    """Logout: xóa session khỏi Redis."""
    r.delete(f"session:{token}")

Lưu ý: dùng setex (SET + EXPIRE atomic) thay vì set rồi expire riêng. Nếu process crash sau set nhưng trước expire, token tồn tại mãi không TTL — security risk và memory leak.

Pattern 2 — OTP (One-Time Password)

import random

OTP_TTL = 300      # 5 phút
OTP_FAIL_MAX = 5   # Tối đa 5 lần nhập sai
OTP_FAIL_TTL = 600 # Lockout 10 phút sau khi sai nhiều


def send_otp(phone: str) -> str:
    otp = f"{random.randint(100000, 999999)}"
    r.setex(f"otp:{phone}", OTP_TTL, otp)
    # Gửi SMS thực tế ở đây
    return otp  # chỉ trả về để test


def verify_otp(phone: str, provided: str) -> bool:
    # Kiểm tra lockout trước
    fail_key = f"otp:fail:{phone}"
    fails = r.get(fail_key)
    if fails and int(fails) >= OTP_FAIL_MAX:
        raise PermissionError("Quá nhiều lần thử. Thử lại sau.")

    stored = r.get(f"otp:{phone}")
    if stored is None:
        raise ValueError("OTP đã hết hạn hoặc chưa được gửi.")

    if stored != provided:
        # Đếm lần thất bại, đặt TTL nếu lần đầu
        fails_after = r.incr(fail_key)
        if fails_after == 1:
            r.expire(fail_key, OTP_FAIL_TTL)
        return False

    # Đúng OTP → xóa ngay (one-time use)
    r.delete(f"otp:{phone}")
    r.delete(fail_key)
    return True

Điểm quan trọng: xóa key OTP ngay sau khi verify thành công (delete) để đảm bảo one-time use. Nếu không xóa, cùng OTP có thể dùng lại trong khoảng TTL còn lại.

Pattern 3 — API Key Revocation List

Thay vì lưu toàn bộ API key trong Redis, chỉ lưu những key đã bị thu hồi (revocation list). JWT/API key có thể có TTL dài (30 ngày), nhưng khi cần thu hồi ngay thì thêm vào blacklist với TTL bằng thời gian còn lại của token:

from datetime import datetime, timezone


def revoke_api_key(api_key: str, expires_at: datetime) -> None:
    """Thêm API key vào blacklist, TTL = thời gian còn lại."""
    now = datetime.now(timezone.utc)
    remaining = int((expires_at - now).total_seconds())
    if remaining <= 0:
        return  # Đã hết hạn tự nhiên, không cần revoke
    r.setex(f"revoked:{api_key}", remaining, "1")


def is_revoked(api_key: str) -> bool:
    return r.exists(f"revoked:{api_key}") == 1

Pattern này hiệu quả hơn lưu toàn bộ API key trong Redis vì blacklist chỉ chứa các key đang bị revoke, không phải tất cả key đang hoạt động. Key trong blacklist tự xóa sau khi hết TTL.

Best practice token chung

  • Luôn dùng SETEX (hoặc SET key value EX ttl) — không tách SET và EXPIRE.
  • Token random từ nguồn crypto-secure: secrets.token_urlsafe(32) trong Python, crypto.randomBytes(32) trong Node.
  • One-time token (OTP, reset password link): xóa ngay sau khi verify thành công.
  • TTL phải phản ánh thời gian hợp lệ thực tế của token, không để quá dài.
5

Use Case 3 — Bitfield Compact

BITFIELD là gì

BITFIELD (thêm từ Redis 3.2, stable trong 7.x) cho phép bạn coi String như một mảng bit có thể địa chỉ, rồi GET/SET/INCRBY trên các vùng bit cụ thể theo kiểu số nguyên xác định. Kết quả là bạn pack nhiều trường dữ liệu nhỏ vào 1 key với kích thước tính chính xác bằng bit, không có overhead của Hash hay nhiều key riêng lẻ.

Syntax

BITFIELD <key> SET <type> <offset> <value>
BITFIELD <key> GET <type> <offset>
BITFIELD <key> INCRBY <type> <offset> <increment>
  • Type: u8, u16, u32, u64 (unsigned), i8, i16, i32, i64 (signed).
  • Offset dạng số: vị trí bit tuyệt đối trong chuỗi. Ví dụ 0 là bit đầu tiên.
  • Offset dạng #N: vị trí theo "index của field có type đó". #0 là field 0, #1 là field 1, v.v. — Redis tự tính offset bit. Đây là cách dùng phổ biến hơn khi bạn pack nhiều field cùng type.

Ví dụ: Game state compact

Lưu 3 trường cho mỗi user game: level (u8, 0-255), xp (u32), coin (u32). Tổng = 1 + 4 + 4 = 9 bytes per user. Nhưng 3 trường này có type khác nhau, nên dùng offset bit tường minh thay vì #N:

# level: u8 tại bit 0  (chiếm 8 bit = 1 byte)
# xp:    u32 tại bit 8  (chiếm 32 bit = 4 bytes)
# coin:  u32 tại bit 40 (chiếm 32 bit = 4 bytes)
# Total = 8 + 32 + 32 = 72 bits = 9 bytes

redis> BITFIELD user:123 SET u8 0 50        # level = 50
1) (integer) 0
redis> BITFIELD user:123 SET u32 8 12500    # xp = 12500
1) (integer) 0
redis> BITFIELD user:123 SET u32 40 999     # coin = 999
1) (integer) 0

redis> BITFIELD user:123 GET u8 0 GET u32 8 GET u32 40
1) (integer) 50
2) (integer) 12500
3) (integer) 999

redis> MEMORY USAGE user:123
(integer) 56      # 56 bytes total (overhead struct + 9 bytes data)

Để so sánh, lưu cùng 3 trường bằng Hash:

redis> HSET user:123:hash level 50 xp 12500 coin 999
(integer) 3
redis> MEMORY USAGE user:123:hash
(integer) 116     # ~116 bytes — hơn gấp đôi

Ở quy mô 1 triệu user, chênh lệch này là ~60MB. Với dữ liệu ít thay đổi schema và nhiều trường số nhỏ, Bitfield tiết kiệm đáng kể.

BITFIELD INCRBY và OVERFLOW

BITFIELD INCRBY cho phép tăng atomic từng field. Điểm đặc biệt là có thể kiểm soát hành vi khi tràn số (overflow) qua OVERFLOW:

# OVERFLOW WRAP (mặc định): tràn số quay vòng (wrap-around như unsigned overflow)
# OVERFLOW SAT: bão hòa tại giá trị min/max, không quay vòng
# OVERFLOW FAIL: trả về nil nếu sẽ tràn, không thực hiện thao tác

redis> BITFIELD user:123 OVERFLOW SAT INCRBY u8 0 210   # level hiện tại 50 + 210 = 260 > 255
1) (integer) 255   # bão hòa tại 255, không wrap về 4

SAT hữu ích cho các trường như HP trong game (không được âm) hay stat có giới hạn cứng.

Khi nào dùng Bitfield

  • Nhiều trường số nguyên nhỏ (u8/u16/u32) per entity, schema cố định, số entity lớn.
  • Game state, analytics counters, feature flags (8 flag nhét vào 1 byte u8).
  • Memory là ràng buộc cứng và bạn chấp nhận code phức tạp hơn khi đọc.

Khi nào không dùng Bitfield

  • Schema thay đổi thường xuyên — mỗi lần thêm/đổi trường phải migration toàn bộ key.
  • Cần lưu string hay data variable-length — Bitfield chỉ phù hợp cho integer có kiểu xác định.
  • Ít entity (vài nghìn) — overhead của solution đơn giản hơn (Hash) không đáng lo.
6

Đo Memory Và Encoding

Hai lệnh cần biết để debug và tối ưu String trong production:

# Xem encoding thực tế
redis> SET counter 42
OK
redis> OBJECT ENCODING counter
"int"

redis> SET short_name "Alice"
OK
redis> OBJECT ENCODING short_name
"embstr"

redis> SET long_json '{"id":1,"name":"Alice Chen","email":"[email protected]","created":"2026-01-01"}'
OK
redis> OBJECT ENCODING long_json
"raw"

# Đo memory (bytes, bao gồm overhead của Redis object)
redis> MEMORY USAGE counter
(integer) 52       # int encoding, nhỏ nhất

redis> MEMORY USAGE short_name
(integer) 56       # embstr, 1 allocation

redis> MEMORY USAGE long_json
(integer) 128      # raw, 2 allocation + buffer

Lưu ý: MEMORY USAGE trả về tổng bytes bao gồm struct của Redis object, key string và value. Con số thực tế phụ thuộc vào kiến trúc và jemalloc alignment. Dùng để so sánh tương đối giữa các design, không phải số tuyệt đối.

Một mẹo khi profile: nếu bạn thấy key mà bạn nghĩ là int nhưng OBJECT ENCODING trả về embstr hoặc raw, khả năng cao là bạn đã SET nó dưới dạng string text thay vì để Redis tự nhận integer. Ví dụ:

redis> SET mycount "  100  "    # space ở đầu/cuối → embstr, không phải int
redis> OBJECT ENCODING mycount
"embstr"
redis> INCR mycount              # ERR value is not an integer
(error) ERR value is not an integer or out of range
7

Best Practices

  • Counter: luôn dùng INCR/INCRBY thay vì GET → xử lý → SET. INCR atomic, GET+SET có race condition.
  • Token: luôn dùng SETEX (hoặc SET key value EX ttl). Không tách SET và EXPIRE thành 2 lệnh riêng.
  • TTL là bắt buộc cho token. Token không có TTL là memory leak và security risk.
  • One-time token: DELETE ngay sau khi verify thành công. Không chờ TTL tự hết.
  • Bitfield chỉ khi schema cố định. Đổi schema = migration toàn bộ key, không nhẹ nhàng.
  • Value < 100KB cho hot key. Key lớn hơn tăng network round-trip và thời gian serialize.
  • Số nguyên để nguyên dạng số. SET "100" (text) mất encoding int, không INCR được nếu có ký tự thừa.
  • Float dùng integer nếu có thể. Lưu tiền bằng integer đơn vị xu thay vì float đồng — tránh lỗi floating point và được int encoding.
8

Anti-patterns

  • GET + SET cho counter (race condition):
    # Sai — race condition khi nhiều instance chạy song song
    val = int(r.get("counter") or 0)
    r.set("counter", val + 1)
    
    # Đúng
    r.incr("counter")
  • SET counter = "1" dạng text thay vì số nguyên:
    # Sai — lưu string, không có int encoding, INCR fail nếu có ký tự thừa
    redis> SET visits "total: 0"
    redis> INCR visits    # ERR value is not an integer
    
    # Đúng
    redis> SET visits 0
    redis> INCR visits
  • Token không TTL:
    # Sai — session token tồn tại mãi
    r.set(f"session:{token}", user_id)
    
    # Đúng
    r.setex(f"session:{token}", SESSION_TTL, user_id)
  • SET + EXPIRE riêng cho token (không atomic):
    # Sai — nếu crash sau set(), trước expire(), token không bao giờ hết hạn
    r.set(f"otp:{phone}", otp)
    r.expire(f"otp:{phone}", 300)
    
    # Đúng
    r.setex(f"otp:{phone}", 300, otp)
  • Bitfield với schema hay thay đổi: Bitfield yêu cầu schema cố định (offset của từng field tính theo bit). Nếu bạn cần thêm field, phải đọc và ghi lại toàn bộ key với offset mới — không có ALTER TABLE ở đây.
  • Value quá lớn (> 1MB) trong 1 key: Value 1MB khi GET phải truyền qua network, chiếm event loop. Tách nhỏ hoặc dùng structure khác (List/Hash chunk).
  • Dùng counter làm idempotency key: Counter tăng dần nhưng không reset deterministic — không thể dùng giá trị của counter để đảm bảo idempotency. Dùng token ngẫu nhiên (UUID/nanoid) cho mục đích đó.
9

Tổng Kết & Quiz

Tổng kết

  • String Redis là binary-safe sequence of bytes, tối đa 512MB. Redis tự chọn encoding: int (integer 64-bit), embstr (≤ 44 bytes, single allocation), raw (> 44 bytes).
  • INCR/INCRBY là atomic do single-threaded event loop của Redis — không cần lock từ application. Khởi tạo về 0 tự động nếu key chưa tồn tại.
  • Token (session, OTP, revocation): luôn dùng SETEX, TTL bắt buộc, token crypto-random, one-time token phải DELETE sau verify.
  • BITFIELD pack nhiều integer nhỏ vào ít byte — hữu ích khi schema cố định và số entity lớn. Trade-off: schema migration phức tạp.
  • Dùng OBJECT ENCODINGMEMORY USAGE để kiểm tra encoding thực tế và kích thước key.

Quiz 5 câu

  1. Tại sao INCR counter an toàn để chạy song song từ nhiều instance mà không cần lock?
  2. Vì sao nên dùng SETEX key ttl value thay vì SET key value + EXPIRE key ttl riêng biệt cho token storage?
  3. Một key có encoding embstr. Điều gì xảy ra nếu bạn gọi APPEND key " more"?
  4. BITFIELD lưu 3 trường u8, u16, u32 cho mỗi user. Tổng số byte tối thiểu cần cho data (không tính overhead struct Redis) là bao nhiêu?
  5. Một pattern dùng INCR fail:{phone} mỗi khi OTP sai, nhưng quên EXPIRE sau lần INCR đầu tiên. Hậu quả là gì và cách fix?

Đáp án gợi ý

  1. Redis xử lý lệnh tuần tự trong single event loop — không có context switch giữa đọc giá trị và ghi lại trong INCR. Mỗi INCR là một thao tác không thể bị ngắt, nên nhiều client gọi đồng thời sẽ thấy kết quả increment tuần tự đúng, không bao giờ mất cập nhật.
  2. Nếu process crash sau SET nhưng trước EXPIRE, key tồn tại mãi không TTL — memory leak và token không bao giờ hết hạn (security risk). SETEX là atomic: hoặc cả SET lẫn TTL được thực hiện, hoặc không cái nào.
  3. Redis convert embstr sang raw trước khi thực hiện APPEND. Sau đó key có encoding raw.
  4. u8 = 1 byte, u16 = 2 bytes, u32 = 4 bytes → tổng 7 bytes per user.
  5. Hậu quả: key fail:{phone} không có TTL, tồn tại mãi. User bị lockout vĩnh viễn sau 5 lần sai dù đã nhập đúng OTP sau đó. Fix: sau INCR kiểm tra nếu trả về 1 thì EXPIRE (hoặc dùng Lua script để atomic).

Bài tiếp theo

Bài 21 đi vào Hash: lưu user profile, metadata theo trường, và tính năng Hash field TTL mới trong Redis 7.4.

Tham khảo