Danh sách bài viết

Bài 98: Bitmap DAU/MAU/WAU — Active User Tracking

Bài này đi sâu vào việc dùng Bitmap Redis để track DAU (Daily Active Users), WAU (Weekly Active Users), MAU (Monthly Active Users) ở mức production. Từ cấu trúc key-per-day, BITOP OR cho WAU/MAU, BITOP AND cho Day-N retention, tính stickiness ratio, xử lý sparse user_id bằng dense mapping, đến per-segment bitmap theo country/plan. Phần cuối phân tích memory, performance BITCOUNT/BITOP, Lua script cleanup, cluster hash tag, và tổng hợp anti-patterns. Bài 25 đã cover foundation command — bài này tập trung vào production pattern và edge case.

01/06/2026
0 lượt xem
1

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

  • Hiểu ý nghĩa của DAU, WAU, MAU và stickiness ratio trong ngữ cảnh SaaS/social platform.
  • Biết cách dùng SETBIT, BITCOUNT, BITOP để track active user từng ngày.
  • Tính WAU và MAU on-demand bằng BITOP OR — không cần storage thêm.
  • Tính Day-N retention bằng BITOP AND trên cohort bitmap.
  • Xử lý sparse user_id bằng dense mapping để tránh lãng phí memory.
  • Thiết kế per-segment bitmap (country, plan) cho DAU breakdown.
  • Dùng Lua script để BITOP + BITCOUNT + cleanup trong 1 atomic call.
  • Nhận diện anti-patterns và áp dụng best practices trong production.
2

DAU / WAU / MAU Là Gì

Ba metric này là số lượng unique user có hoạt động trong một khoảng thời gian:

  • DAU (Daily Active Users): unique user active trong 1 ngày.
  • WAU (Weekly Active Users): unique user active trong 7 ngày liên tiếp (không phải tổng 7 DAU — user dùng 3 ngày trong tuần vẫn chỉ đếm 1 lần).
  • MAU (Monthly Active Users): unique user active trong 30 ngày.

"Active" được định nghĩa theo sản phẩm: đăng nhập, mở app, gửi message, thực hiện transaction… Điều quan trọng là phải nhất quán định nghĩa đó để số liệu có thể so sánh theo thời gian.

Stickiness

Stickiness = DAU / MAU — tỉ lệ cho biết bao nhiêu phần trăm user trong tháng quay lại dùng mỗi ngày. Benchmark phổ biến: trên 20% là mức engagement tốt cho phần lớn sản phẩm. Messenger/WhatsApp có stickiness > 50%. Sản phẩm B2B thường thấp hơn do người dùng chỉ active vào ngày làm việc.

WAU / MAU đo tương tự nhưng theo tuần — phù hợp hơn với sản phẩm có weekly workflow (công cụ làm việc nhóm, project management).

Tại sao không dùng SQL?

SELECT COUNT(DISTINCT user_id) FROM events WHERE day = '2026-06-01' hoạt động tốt ở quy mô nhỏ, nhưng khi event table có hàng tỉ bản ghi thì truy vấn COUNT DISTINCT không thể dưới giây cho dashboard realtime. Redis Bitmap giải quyết vấn đề này bằng bitwise operation trên RAM.

3

Bitmap Intuition: 1 Bit Per User

Ý tưởng cốt lõi: mỗi user_id ánh xạ đến 1 bit trong một chuỗi bit. Bit 1 = user đó active trong ngày, bit 0 = không. Key cho ngày 2026-06-01:

active:day:2026-06-01

Offset (user_id):  0  1  2  3  4  5  6  7  ...
Bit value:         0  1  0  1  0  0  1  0  ...

Các operation cần thiết:

  • SETBIT key offset 1 — đánh dấu user offset là active.
  • GETBIT key offset — kiểm tra user offset có active không.
  • BITCOUNT key — đếm tổng số bit 1 = DAU.
  • BITOP OR dest src1 src2 ... — OR nhiều ngày lại = unique users active trong bất kỳ ngày nào (WAU/MAU).
  • BITOP AND dest src1 src2 — AND hai key = user có mặt trong cả hai = retention.

Vì Bitmap trong Redis thực chất là String, mọi lệnh STRING như GET, SET, STRLEN đều hoạt động. Key sẽ tự động mở rộng khi offset lớn hơn độ dài hiện tại — các bit mới được khởi tạo bằng 0.

4

DAU Cơ Bản — mark_active & bitcount

import redis
from datetime import date

r = redis.Redis(host="localhost", port=6379, decode_responses=False)

def mark_active(user_id: int) -> None:
    """Đánh dấu user_id là active hôm nay."""
    today = date.today().isoformat()          # "2026-06-01"
    r.setbit(f"active:day:{today}", user_id, 1)

def dau(day: str) -> int:
    """Đếm DAU cho ngày cụ thể (format ISO: '2026-06-01')."""
    return r.bitcount(f"active:day:{day}")

def is_active(user_id: int, day: str) -> bool:
    """Kiểm tra user có active vào ngày đó không."""
    return bool(r.getbit(f"active:day:{day}", user_id))

Gọi mark_active tại bất kỳ điểm nào trong request lifecycle — sau login, sau API call đầu tiên trong ngày, hoặc trong middleware. SETBIT là O(1) và không blocking.

Lưu ý về TTL: nên đặt TTL cho key DAU ngay sau khi tạo để tránh tích luỹ vô hạn:

def mark_active(user_id: int, ttl_days: int = 90) -> None:
    today = date.today().isoformat()
    key = f"active:day:{today}"
    pipe = r.pipeline()
    pipe.setbit(key, user_id, 1)
    # Chỉ set TTL nếu key mới tạo (EXPIRE trả về 0 nếu key đã có TTL)
    pipe.expire(key, ttl_days * 86400, xx=False)
    pipe.execute()

Tham số xx=False (hay không dùng XX flag) đảm bảo chỉ set TTL khi key chưa có TTL. Từ Redis 7.0, EXPIRE hỗ trợ options NX, XX, GT, LT.

5

WAU & MAU Bằng BITOP OR

WAU = số unique user active ít nhất 1 ngày trong 7 ngày. BITOP OR gộp các ngày: bit i trong result bằng 1 nếu user i active ít nhất 1 trong các ngày nguồn.

from datetime import date, timedelta

def get_week_days(anchor: date) -> list[str]:
    """Trả về 7 ngày kết thúc tại anchor (inclusive)."""
    return [(anchor - timedelta(days=i)).isoformat() for i in range(6, -1, -1)]

def get_month_days(anchor: date) -> list[str]:
    """Trả về 30 ngày kết thúc tại anchor (inclusive)."""
    return [(anchor - timedelta(days=i)).isoformat() for i in range(29, -1, -1)]

def wau(week_days: list[str]) -> int:
    """Tính WAU cho 7 ngày cho trước."""
    src_keys = [f"active:day:{d}" for d in week_days]
    tmp = "tmp:wau"
    r.bitop("OR", tmp, *src_keys)
    count = r.bitcount(tmp)
    r.delete(tmp)
    return count

def mau(month_days: list[str]) -> int:
    """Tính MAU cho 30 ngày cho trước."""
    src_keys = [f"active:day:{d}" for d in month_days]
    tmp = "tmp:mau"
    r.bitop("OR", tmp, *src_keys)
    count = r.bitcount(tmp)
    r.delete(tmp)
    return count

# Ví dụ dùng
today = date.today()
print("WAU:", wau(get_week_days(today)))
print("MAU:", mau(get_month_days(today)))

Lưu ý: key tmp:wautmp:mau là ephemeral — tạo ra, đọc xong, xóa ngay. Nếu có nhiều process chạy đồng thời, cần dùng key unique theo request ID hoặc dùng Lua (xem mục 13) để tránh race condition trên temp key.

BITOP OR là O(N × M) trong đó N = số key nguồn, M = số byte của key dài nhất. Với 30 key mỗi key 12.5MB, tổng data cần xử lý là 375MB — tốn ~100–300ms trên máy đơn. Đây là lý do cần pre-compute (mục 9).

6

Stickiness Ratio — DAU / MAU

def stickiness(today: str, month_days: list[str]) -> float:
    """
    Tính DAU / MAU stickiness ratio.
    today: string ISO date cho DAU.
    month_days: list 30 ngày cho MAU.
    """
    d = dau(today)
    m = mau(month_days)
    return d / m if m > 0 else 0.0

# Ví dụ output
ratio = stickiness("2026-06-01", get_month_days(date.today()))
print(f"Stickiness: {ratio:.1%}")  # e.g. "Stickiness: 23.4%"

Benchmark: > 20% là mức tốt cho phần lớn sản phẩm tiêu dùng. Công cụ sinh sản xuất (B2B SaaS) thường thấp hơn vì user chỉ hoạt động vào ngày làm việc — stickiness 10–15% vẫn bình thường nếu MAU lớn. Stickiness thấp đột ngột mà không do dữ liệu sai thường chỉ ra vấn đề về engagement hoặc UX, không phải vấn đề Redis.

WAU / MAU là biến thể hữu ích khi sản phẩm có weekly cadence:

def weekly_stickiness(week_days: list[str], month_days: list[str]) -> float:
    w = wau(week_days)
    m = mau(month_days)
    return w / m if m > 0 else 0.0
7

Day-N Retention Bằng BITOP AND

Day-N retention đo tỉ lệ user đăng ký vào ngày X còn active vào ngày X+N. Cần 2 bitmap:

  • cohort:signup:{signup_day} — bitmap đánh dấu toàn bộ user đăng ký vào signup_day.
  • active:day:{day_n} — bitmap active của ngày X+N.

BITOP AND của 2 bitmap cho ra tập user vừa thuộc cohort vừa active vào ngày N.

from datetime import date, timedelta

def mark_signup(user_id: int, signup_day: str) -> None:
    """Đánh dấu user đăng ký vào cohort ngày đó."""
    r.setbit(f"cohort:signup:{signup_day}", user_id, 1)

def day_n_retention(signup_day: str, n: int) -> float:
    """
    Tính Day-N retention của cohort đăng ký vào signup_day.
    n: số ngày sau khi đăng ký (n=1 là Day-1, n=7 là Day-7...).
    """
    day_n_date = (date.fromisoformat(signup_day) + timedelta(days=n)).isoformat()
    cohort_key = f"cohort:signup:{signup_day}"
    active_key = f"active:day:{day_n_date}"
    tmp = f"tmp:dn:{signup_day}:{n}"

    r.bitop("AND", tmp, cohort_key, active_key)
    retained = r.bitcount(tmp)
    cohort_size = r.bitcount(cohort_key)
    r.delete(tmp)

    return retained / cohort_size if cohort_size > 0 else 0.0

# Ví dụ: Day-7 retention của cohort đăng ký 25/05/2026
rate = day_n_retention("2026-05-25", 7)
print(f"Day-7 retention: {rate:.1%}")

Lưu ý tên temp key unique theo signup_dayn để tránh conflict giữa các call đồng thời. Với traffic cao, vẫn nên dùng Lua để đảm bảo atomic.

8

Memory Analysis

Kích thước bitmap phụ thuộc vào max user_id, không phải số user active:

Max user_idBitmap size / ngày365 ngày
1 triệu125 KB~45 MB
10 triệu1.25 MB~450 MB
100 triệu12.5 MB~4.6 GB
1 tỷ125 MB~46 GB

WAU và MAU tính on-demand qua BITOP không tốn storage thêm, chỉ tốn RAM tạm trong thời gian tính toán. Nếu pre-compute (mục 9), cần thêm 1 key per tuần/tháng — không đáng kể.

So sánh với Set: lưu 1 triệu user_id (int 64-bit) trong Set tốn ~64MB. Bitmap cùng max user_id chỉ tốn 125KB — tiết kiệm 512 lần. Lợi thế này càng lớn khi active user chiếm tỉ lệ nhỏ trong tổng user base.

Khi max user_id rất lớn mà số lượng active user nhỏ (ví dụ: user_id đến 1 tỉ nhưng chỉ 100k active/ngày), bitmap trở nên kém hiệu quả vì phải dự trữ 125MB bit nhưng chỉ 100k bit được set. Đây là sparse user_id problem — xem mục 10.

9

Pre-compute WAU / MAU — Daily Job

Tính MAU on-demand = BITOP OR 30 key + BITCOUNT mỗi khi có request. Nếu dashboard gọi 100 lần/phút, đây là 100 BITOP OR trên 30 key × 12.5MB mỗi lần — không chấp nhận được.

Giải pháp: chạy một scheduled job hàng ngày (cron, Celery beat, APScheduler...) để pre-compute và lưu lại:

from datetime import date, timedelta

def precompute_wau(anchor: date) -> None:
    """
    Tính WAU 7 ngày kết thúc tại anchor.
    Lưu vào active:wau:{iso_week} với TTL 90 ngày.
    """
    week_days = get_week_days(anchor)
    src_keys = [f"active:day:{d}" for d in week_days]
    # ISO week format: "2026-W22"
    week_key = f"active:wau:{anchor.isocalendar().year}-W{anchor.isocalendar().week:02d}"
    r.bitop("OR", week_key, *src_keys)
    r.expire(week_key, 90 * 86400)

def precompute_mau(anchor: date) -> None:
    """
    Tính MAU 30 ngày kết thúc tại anchor.
    Lưu vào active:mau:{year}-{month:02d} với TTL 90 ngày.
    """
    month_days = get_month_days(anchor)
    src_keys = [f"active:day:{d}" for d in month_days]
    month_key = f"active:mau:{anchor.year}-{anchor.month:02d}"
    r.bitop("OR", month_key, *src_keys)
    r.expire(month_key, 90 * 86400)

def wau_fast(year: int, week: int) -> int:
    """Đọc WAU từ pre-computed key."""
    key = f"active:wau:{year}-W{week:02d}"
    return r.bitcount(key)

def mau_fast(year: int, month: int) -> int:
    """Đọc MAU từ pre-computed key."""
    key = f"active:mau:{year}-{month:02d}"
    return r.bitcount(key)

Job chạy lúc 00:05 hàng ngày (sau midnight một chút để đảm bảo key của ngày vừa qua đã đầy đủ). BITCOUNT trên pre-computed key là O(N bytes) — với 12.5MB thì ~5ms, đủ nhanh cho dashboard realtime.

10

Sparse User_ID Problem & Dense Mapping

Vấn đề: user_id trong database thường không liên tục. UUID là 128-bit nên không thể dùng làm offset. Ngay cả với auto-increment integer, nếu user_id chạy từ 1M đến 100M (có gaps) thì bitmap phải dài 100M bit = 12.5MB dù chỉ có 100k user active. Khi user_id có thể lên đến 1 tỉ, mỗi day key sẽ là 125MB.

Giải pháp: ánh xạ user_id gốc sang dense integer index bắt đầu từ 0:

INDEX_MAP = "user:index"         # Hash lưu user_id → index
INDEX_COUNTER = "user:index:counter"  # Atomic counter

def get_user_index(user_id: str) -> int:
    """
    Trả về dense index của user_id.
    Nếu chưa có, tạo mới tự động.
    """
    idx = r.hget(INDEX_MAP, user_id)
    if idx is not None:
        return int(idx)
    # Chưa có → cấp index mới
    new_idx = r.incr(INDEX_COUNTER)
    # HSETNX đảm bảo không override nếu key đã được set đồng thời
    was_set = r.hsetnx(INDEX_MAP, user_id, new_idx)
    if was_set:
        return new_idx
    # Race condition: process khác đã set trước → đọc lại
    return int(r.hget(INDEX_MAP, user_id))

def mark_active_sparse(user_id: str) -> None:
    """Mark active với sparse user_id mapping."""
    idx = get_user_index(user_id)
    today = date.today().isoformat()
    r.setbit(f"active:day:{today}", idx, 1)

Tradeoff:

  • Mỗi lần mark_active cần 1 HGET (và INCR + HSETNX nếu user mới).
  • INDEX_MAP hash có thể lớn nếu user base lớn (100M user × ~20 bytes/entry ≈ 2GB).
  • Cần backup mapping — mất INDEX_MAP thì không thể recover ý nghĩa bitmap.

Thay thế đơn giản hơn: nếu user_id là integer và khoảng cách không quá lớn (ví dụ: 1M đến 2M), dùng trực tiếp làm offset vì 2M bit = 250KB — không đáng kể. Chỉ cần dense mapping khi max user_id vượt ngưỡng memory chấp nhận được.

11

Per-Segment Bitmap — Multi-Dimensional

Thay vì chỉ 1 bitmap per ngày, có thể duy trì nhiều bitmap per segment để tính DAU theo chiều ngang:

active:day:{date}                    # tổng DAU
active:day:{date}:country:VN         # DAU tại Việt Nam
active:day:{date}:country:SG         # DAU tại Singapore
active:day:{date}:plan:free          # DAU user free
active:day:{date}:plan:premium       # DAU user premium
def mark_active_segmented(user_id: str,
                          country: str,
                          plan: str) -> None:
    idx = get_user_index(user_id)
    today = date.today().isoformat()
    pipe = r.pipeline()
    # Tổng
    pipe.setbit(f"active:day:{today}", idx, 1)
    # Theo country
    pipe.setbit(f"active:day:{today}:country:{country}", idx, 1)
    # Theo plan
    pipe.setbit(f"active:day:{today}:plan:{plan}", idx, 1)
    pipe.execute()

def dau_by_country(day: str, country: str) -> int:
    return r.bitcount(f"active:day:{day}:country:{country}")

def dau_by_plan(day: str, plan: str) -> int:
    return r.bitcount(f"active:day:{day}:plan:{plan}")

Giao giữa các segment (user ở VN dùng premium):

def dau_intersection(day: str, country: str, plan: str) -> int:
    """DAU của user thuộc country VÀ plan nhất định."""
    tmp = f"tmp:intersect:{day}:{country}:{plan}"
    r.bitop("AND", tmp,
            f"active:day:{day}:country:{country}",
            f"active:day:{day}:plan:{plan}")
    count = r.bitcount(tmp)
    r.delete(tmp)
    return count

Mỗi segment tốn thêm 1 bitmap = thêm memory. Với 100M user, 5 country + 2 plan = 7 bitmap phụ/ngày = 7 × 12.5MB = 87.5MB/ngày thêm. Cân nhắc giới hạn số segment và cleanup data cũ.

12

Stickiness Theo Segment

Sau khi có per-segment bitmap, tính stickiness từng segment tương tự như stickiness tổng:

def mau_by_country(month_days: list[str], country: str) -> int:
    src_keys = [f"active:day:{d}:country:{country}" for d in month_days]
    tmp = f"tmp:mau:country:{country}"
    r.bitop("OR", tmp, *src_keys)
    count = r.bitcount(tmp)
    r.delete(tmp)
    return count

def stickiness_by_country(today: str,
                           month_days: list[str],
                           country: str) -> float:
    d = dau_by_country(today, country)
    m = mau_by_country(month_days, country)
    return d / m if m > 0 else 0.0

def stickiness_by_plan(today: str,
                        month_days: list[str],
                        plan: str) -> float:
    d = dau_by_plan(today, plan)
    m_src = [f"active:day:{day}:plan:{plan}" for day in month_days]
    tmp = f"tmp:mau:plan:{plan}"
    r.bitop("OR", tmp, *m_src)
    m = r.bitcount(tmp)
    r.delete(tmp)
    return d / m if m > 0 else 0.0

Kết quả điển hình: premium user thường có stickiness cao hơn free user — họ đã cam kết trả phí. Segment theo country giúp nhận biết thị trường nào đang tăng/giảm engagement trước khi số tổng phản ánh.

13

BITOP Performance & Lua Cleanup

BITOP tạo key mới trong Redis. Nếu process crash sau BITOP nhưng trước DEL, temp key sẽ tồn tại vĩnh viễn. Giải pháp: dùng Lua script để đảm bảo 3 bước (BITOP, BITCOUNT, DEL) là atomic:

-- Script: bitop_or_count.lua
-- KEYS: danh sách bitmap nguồn
-- ARGV[1]: tên key tạm (unique per call)
local tmp = ARGV[1]
redis.call("BITOP", "OR", tmp, unpack(KEYS))
local count = redis.call("BITCOUNT", tmp)
redis.call("DEL", tmp)
return count
BITOP_OR_COUNT = r.register_script("""
local tmp = ARGV[1]
redis.call('BITOP', 'OR', tmp, unpack(KEYS))
local count = redis.call('BITCOUNT', tmp)
redis.call('DEL', tmp)
return count
""")

def wau_atomic(week_days: list[str]) -> int:
    import uuid
    src_keys = [f"active:day:{d}" for d in week_days]
    tmp = f"tmp:wau:{uuid.uuid4().hex}"
    return BITOP_OR_COUNT(keys=src_keys, args=[tmp])

Lua script trong Redis luôn chạy đơn luồng và không bị interrupt — không có process nào đọc được temp key ở trạng thái trung gian. UUID trong tên temp key ngăn conflict giữa các lời gọi đồng thời.

Performance BITOP OR:

  • O(N × M): N = số key nguồn, M = bytes của key dài nhất.
  • 7 key × 12.5MB = 87.5MB cần OR → ~30–60ms trên máy single-thread.
  • 30 key × 12.5MB = 375MB → ~150–300ms.
  • Pre-compute daily job (mục 9) là cần thiết nếu dashboard call thường xuyên.
14

BITCOUNT Performance

BITCOUNT là O(N) với N = số byte của key (không phải số bit 1). Redis dùng thuật toán popcount tối ưu (SWAR/Hamming weight), xử lý 64-bit word một lần:

  • 12.5MB bitmap ≈ ~5ms trên Redis 7.x.
  • 125MB bitmap ≈ ~50ms.

Nếu chỉ cần DAU của một tháng cụ thể trong ngày, BITCOUNT key không cần range. Từ Redis 7.0, BITCOUNT hỗ trợ index theo bit (không chỉ byte) qua tham số BYTE|BIT:

# Đếm bit 1 từ offset 0 đến offset 999 (user_id 0-999)
BITCOUNT active:day:2026-06-01 0 124 BYTE   # byte 0-124 = bit 0-999
BITCOUNT active:day:2026-06-01 0 999 BIT    # trực tiếp theo bit (Redis 7.0+)

Tính năng này hữu ích khi muốn đếm user trong một range user_id nhất định (ví dụ: user đăng ký trước 2025 so với sau 2025, nếu dùng dense mapping tuần tự theo thời gian).

15

BITPOS & BITFIELD

BITPOS

BITPOS key bit [start [end [BYTE|BIT]]] — tìm vị trí đầu tiên của bit có giá trị bit (0 hoặc 1) trong key. Use case:

  • Tìm user_id nhỏ nhất active vào ngày nào đó: BITPOS active:day:2026-06-01 1.
  • Tìm vùng user_id nào chưa active (để ưu tiên re-engagement campaign): BITPOS key 0.

BITPOS trả về -1 nếu không tìm thấy bit thoả điều kiện.

BITFIELD cho per-user compact data

BITFIELD cho phép đọc/ghi integer nhiều bit tại một offset cụ thể trong string. Khác với SETBIT/GETBIT (1 bit), BITFIELD xử lý unsigned/signed integer 8–64 bit:

# Lưu login count (u8, max 255) và streak (u8) của user tại index 0
BITFIELD user:compact:flags SET u8 #0 5   # set login_count=5 tại slot 0
BITFIELD user:compact:flags SET u8 #1 12  # set streak=12 tại slot 1
BITFIELD user:compact:flags GET u8 #0 GET u8 #1  # đọc cả hai

Use case điển hình: lưu login_count, streak, hoặc permission bits per user trong 1 key compact thay vì nhiều key riêng. Bài 25 đã cover BITFIELD cơ bản — bài này không lặp lại; chỉ nhắc context khi combine với DAU tracking.

16

Bitmap + HLL — Khi Nào Dùng Cái Nào

Tiêu chíBitmapHyperLogLog
Count chính xácXấp xỉ (sai số ~0.81%)
Intersect / ANDKhông
Kiểm tra 1 user có active khôngCó (GETBIT)Không
Memory (100M user)12.5MB/ngày~12KB cố định
Thêm phần tửSETBIT O(1)PFADD O(1)
Merge nhiều ngàyBITOP ORPFMERGE

Pattern kết hợp thực tế:

  • Bitmap: DAU chính xác, retention calculation, per-user check, segment analysis.
  • HLL: monthly trend, long-term unique visitor count khi không cần intersect và memory là ưu tiên. 30 tháng × 12KB = 360KB thay vì 30 × 12.5MB = 375MB.
def track_active_dual(user_id: str) -> None:
    """
    Ghi cả Bitmap (exact DAU/retention) và HLL (long-term trend).
    """
    today = date.today().isoformat()
    month = today[:7]  # "2026-06"
    idx = get_user_index(user_id)
    pipe = r.pipeline()
    pipe.setbit(f"active:day:{today}", idx, 1)   # exact, 90-day TTL
    pipe.pfadd(f"hll:active:month:{month}", user_id)  # approximate, long-term
    pipe.execute()
17

Persistence & Cluster

Persistence

Bitmap là String — RDB và AOF đều lưu đầy đủ. 100M-bit = 12.5MB per key. AOF rewrite với 365 key × 12.5MB = 4.6GB AOF data — nặng nhưng manageable. Nên:

  • Bật appendfsync everysec thay vì always để giảm I/O.
  • Cấu hình auto-aof-rewrite-percentage hợp lý (mặc định 100%).
  • Dùng RDB snapshot hàng giờ cho analytics data — mất vài phút active tracking là chấp nhận được.

Cluster

Trong Redis Cluster, mỗi key nằm trên 1 node theo hash slot. BITOP yêu cầu tất cả key nguồn và key đích phải cùng 1 slot — nếu không Redis trả lỗi CROSSSLOT. Giải pháp: dùng hash tag.

# Không dùng cluster hash tag — có thể nằm khác slot
active:day:2026-06-01
active:day:2026-06-02
active:wau:2026-W22

# Dùng hash tag {active} — force cùng slot
{active}:day:2026-06-01
{active}:day:2026-06-02
{active}:wau:2026-W22

Hash tag trong Redis Cluster: chỉ phần trong {...} được hash để xác định slot. Với {active}:day:2026-06-01, hash slot của key là hash của active. Tất cả key cùng tag {active} nằm cùng slot — BITOP hoạt động bình thường, nhưng toàn bộ data dồn vào 1 node.

Tradeoff: 1 node chịu toàn bộ write của analytics bitmap. Với hệ thống quy mô lớn, cân nhắc chia theo segment (country, plan) sang tag khác nhau để phân tải:

{active:VN}:day:2026-06-01
{active:SG}:day:2026-06-01
18

Anti-patterns

  1. Sparse user_id không map: user_id UUID hoặc integer có gaps lớn dùng trực tiếp làm SETBIT offset. Redis sẽ tự động cấp phát bitmap dài đủ để chứa offset lớn nhất — key trở thành GB trong khi actual data chỉ vài KB.
  2. Quên cleanup temp key sau BITOP: BITOP OR tmp:mau ... tạo key tồn tại vĩnh viễn nếu không có DEL. Sau vài ngày, hàng trăm MB temp key tích luỹ trong memory. Dùng Lua script hoặc pipeline DEL ngay sau BITCOUNT.
  3. Tính MAU mỗi request: BITOP OR 30 key × 12.5MB/key = 375MB xử lý mỗi request → server lag. Pre-compute daily job là bắt buộc cho production dashboard.
  4. Temp key không unique: nhiều process dùng cùng tmp:mau. Process A đang dùng thì process B ghi đè → count sai. Dùng UUID hoặc request ID trong tên temp key.
  5. Race condition trong dense mapping: dùng HSET thay vì HSETNX cho user_id → index. Hai process đồng thời tạo index khác nhau cho cùng 1 user_id → cùng user được đếm 2 lần. HSETNX (Set if Not eXists) là atomic.
  6. Không set TTL cho day key: 365 day × 12.5MB = 4.6GB chỉ cho 1 năm. Sau vài năm, data analytics cũ chiếm phần lớn Redis memory. Set TTL 90–180 ngày cho day key, giữ pre-computed WAU/MAU key lâu hơn.
  7. BITOP cross-slot trong cluster: BITOP trên key nằm khác slot trả lỗi CROSSSLOT. Không test trên standalone Redis mà deploy lên cluster mà không có hash tag.
19

Best Practices

  • Dense mapping cho sparse user_id: dùng Hash + INCR + HSETNX để map user_id → dense index. Document convention mapping — không có nó, bitmap không có nghĩa.
  • TTL ngay khi tạo key: set TTL 90–180 ngày cho day bitmap. Dùng EXPIRE key ttl NX để không override TTL của key đã tồn tại.
  • Pre-compute WAU/MAU: chạy job hàng ngày sau midnight để tính và lưu WAU/MAU bitmap. Dashboard chỉ cần BITCOUNT key pre-computed.
  • Lua script cho BITOP + cleanup: đảm bảo atomic — không có orphan temp key, không có race condition trên temp key.
  • Unique tên temp key: dùng UUID hoặc request_id làm suffix để tránh conflict giữa concurrent request.
  • Per-segment bitmap có chọn lọc: chỉ tạo segment bitmap cho chiều phân tích thực sự cần thiết, không tạo cho tất cả attribute của user. Mỗi segment bitmap = thêm 12.5MB/ngày.
  • Cluster hash tag: nhóm tất cả bitmap analytics vào cùng hash tag để BITOP hoạt động. Cân nhắc chia tag theo dimension để phân tải node.
  • Kết hợp Bitmap + HLL: Bitmap cho exact count và intersect; HLL cho long-term trend khi memory là ưu tiên.
  • Document index mapping: lưu schema của INDEX_MAP (user_id namespace, counter reset policy, backup strategy). Mất mapping = mất khả năng đọc bitmap.
20

Quiz

Câu hỏi

  1. Stickiness = DAU / MAU là bao nhiêu nếu DAU = 50.000 và MAU = 300.000? Benchmark này ở mức nào?
  2. Vì sao WAU không bằng tổng 7 DAU? Cho ví dụ cụ thể.
  3. Một hệ thống có user_id chạy từ 10.000.000 đến 10.001.000 (1.000 user). Nếu dùng user_id trực tiếp làm SETBIT offset, bitmap sẽ có kích thước bao nhiêu? So sánh với dense mapping.
  4. Tại sao cần Lua script cho BITOP + BITCOUNT + DEL thay vì dùng pipeline thông thường?
  5. Trong Redis Cluster, nếu BITOP OR trả lỗi CROSSSLOT, nguyên nhân là gì và cách fix như thế nào?

Đáp án gợi ý

  1. Stickiness = 50.000 / 300.000 ≈ 16.7%. Đây là dưới benchmark 20% — chấp nhận được với B2B SaaS (user chỉ active ngày làm việc) nhưng đáng quan tâm với sản phẩm consumer.
  2. WAU là unique user — user active cả 7 ngày chỉ đếm 1 lần trong WAU, nhưng đóng góp vào cả 7 DAU. Ví dụ: 100 user mỗi ngày, trong đó 80 user active cả tuần và 20 user mới mỗi ngày. Tổng 7 DAU = 700, nhưng WAU = 80 + 7×20 = 220 (unique).
  3. User_id tối đa là 10.001.000 → bitmap cần 10.001.001 bit = ~1.22MB. Nhưng chỉ có 1.000 user nên 99.99% bitmap là 0 — lãng phí. Dense mapping ánh xạ 1.000 user sang index 0–999 → bitmap chỉ 125 byte.
  4. Pipeline gửi nhiều command nhưng không atomic — giữa BITOP và DEL, một process khác có thể đọc temp key hoặc ghi đè. Lua script chạy đơn luồng trên Redis server, không bị interrupt, đảm bảo BITOP + BITCOUNT + DEL hoàn thành liên tục không có state trung gian.
  5. Nguyên nhân: các key nguồn nằm trên khác slot (khác node trong cluster). BITOP yêu cầu tất cả key cùng 1 slot. Fix: dùng hash tag — thêm {active} vào tên key để force cùng slot, ví dụ {active}:day:2026-06-01.

Bài tiếp theo

Bài 99 phân tích các anti-patterns analytics phổ biến và incident thực tế: counter overflow khi dùng INCR không giới hạn, race condition trong leaderboard, và các lỗi thiết kế thường gặp khi scale analytics pipeline.

Tham khảo