Mục lục
- Mục Tiêu Bài Học
- Cohort Analysis Là Gì
- Retention Matrix — Đọc Đúng Đường Chéo
- Pattern Set Per Cohort
- SINTERSTORE — Intersect Phía Server
- Pattern Bitmap — Memory Hiệu Quả
- HyperLogLog Cho Scale Rất Lớn
- Build Retention Matrix — Background Script
- Cohort Theo Dimension Khác
- Day-1, Day-7, Day-30 Retention
- Funnel Kết Hợp Cohort
- A/B Test Per Cohort
- Realtime Dashboard & Pre-aggregate
- Memory Budget Production
- Khi Nào Dùng SQL OLAP Thay Redis
- Anti-patterns & Best Practices
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Hiểu cohort analysis là gì, retention matrix đọc như thế nào và tại sao Redis phù hợp.
- Triển khai Set per cohort và Bitmap per cohort với trade-off rõ ràng.
- Dùng SINTERSTORE và BITOP AND để tính retained user phía server.
- Xây script build retention matrix chạy background và cache kết quả.
- Tính Day-1 / Day-7 / Day-30 retention, cohort theo country/plan và A/B test.
- Nắm memory budget thực tế và biết khi nào chuyển sang SQL Data Warehouse.
Cohort Analysis Là Gì
Cohort (nhóm đồng hành) là tập user chia sẻ một attribute chung tại một thời điểm xác định. Attribute phổ biến nhất là tuần hoặc ngày đăng ký. Khi nhóm user theo thời gian đăng ký, bạn có thể so sánh hành vi giữa các thế hệ user khác nhau mà không bị nhiễu bởi user cũ đã quen sản phẩm.
Ngoài signup date, cohort còn có thể được định nghĩa theo:
- Quốc gia (country): user đăng ký từ VN, SG, US...
- Plan / tier: free, premium, enterprise.
- Acquisition source: organic, campaign A, affiliate B.
- A/B test variant: nhóm control vs nhóm treatment.
Cohort retention đặt câu hỏi: trong số user của cohort X, bao nhiêu phần trăm còn active sau N tuần (hoặc N ngày)? "Active" cần định nghĩa cụ thể theo sản phẩm — ví dụ: đăng nhập ít nhất 1 lần trong tuần đó, hoặc thực hiện ít nhất 1 action quan trọng.
Redis phù hợp cho cohort retention vì:
- Set và Bitmap hỗ trợ intersect giữa cohort và active set.
- Kết quả tính được trong millisecond cho dashboard realtime.
- Pre-compute nhanh với BITOP trên server-side thay vì kéo dữ liệu về client.
Retention Matrix — Đọc Đúng Đường Chéo
Retention matrix có dạng:
| Cohort | Week 0 | Week 1 | Week 2 | Week 4 |
|---|---|---|---|---|
| 2026-W18 | 1000 (100%) | 400 (40%) | 250 (25%) | 180 (18%) |
| 2026-W19 | 1200 (100%) | 500 (42%) | 300 (25%) | — |
| 2026-W20 | 1100 (100%) | 480 (44%) | — | — |
Các điểm quan trọng khi đọc matrix:
- Week 0 = cohort size: số user đăng ký trong tuần đó, luôn là 100%.
- Đường chéo = thời gian thực: ô Week 2 của W18, ô Week 1 của W19, và ô Week 0 của W20 đều xảy ra cùng một tuần thực tế (W20). Điều này giải thích tại sao các ô chưa đến thời điểm hiện tại có giá trị
—. - So sánh dọc (cùng Week N): cho thấy cohort mới có retention tốt hơn hay tệ hơn cohort cũ — có thể do thay đổi onboarding, tính năng mới.
- So sánh ngang (cùng cohort): đường cong retention của một thế hệ user — thường giảm nhanh ở Week 1 rồi ổn định.
Dấu — (None) xuất hiện khi active_week < cohort_week — không có dữ liệu tương lai, không phải missing data.
Pattern Set Per Cohort
Cách đơn giản nhất: mỗi cohort tuần là một Set ghi nhận user_id khi signup. Mỗi tuần active cũng là một Set ghi nhận user_id có hoạt động. Retention = kích thước phần giao.
from datetime import date
def current_week() -> str:
"""Trả về ISO week label, ví dụ '2026-W22'."""
d = date.today()
year, week, _ = d.isocalendar()
return f"{year}-W{week:02d}"
def signup(redis_client, user_id: str):
cohort_week = current_week()
redis_client.sadd(f"cohort:signup:{cohort_week}", user_id)
# Ghi cohort vào profile user để lookup nhanh
redis_client.hset(f"user:{user_id}", "cohort", cohort_week)
def track_active(redis_client, user_id: str):
active_week = current_week()
redis_client.sadd(f"active:week:{active_week}", user_id)
def retention_client_side(redis_client, cohort_week: str, active_week: str) -> float:
"""
Tính retention bằng cách lấy hai Set về client rồi intersect.
Chỉ phù hợp khi Set nhỏ (< vài chục nghìn phần tử).
"""
cohort = redis_client.smembers(f"cohort:signup:{cohort_week}")
active = redis_client.smembers(f"active:week:{active_week}")
if not cohort:
return 0.0
retained = cohort & active # Python set intersection
return len(retained) / len(cohort)
Nhược điểm client-side intersect: phải truyền toàn bộ nội dung hai Set qua mạng về client. Với cohort 100k user, mỗi user_id UUID 36 byte → ~3.6MB mỗi Set, tổng 7.2MB mỗi lần tính retention một ô matrix. Nếu dashboard build matrix 10×8 = 80 ô → 576MB transfer. Giải pháp: chuyển sang intersect phía server.
SINTERSTORE — Intersect Phía Server
Redis cung cấp SINTER (trả về phần giao) và SINTERSTORE (lưu phần giao vào key mới) — toàn bộ xử lý phía server, không cần truyền dữ liệu về client.
def retention_server_side(redis_client, cohort_week: str, active_week: str) -> float:
"""
Server-side intersect bằng SINTER.
Complexity: O(N × M) với N = |set nhỏ nhất|, M = số set tham gia.
Thực tế với 2 set: O(min(|cohort|, |active|)).
"""
cohort_key = f"cohort:signup:{cohort_week}"
active_key = f"active:week:{active_week}"
# SINTER trả về set kết quả trực tiếp, không lưu
retained = redis_client.sinter(cohort_key, active_key)
cohort_size = redis_client.scard(cohort_key)
if not cohort_size:
return 0.0
return len(retained) / cohort_size
def retention_sinterstore(redis_client, cohort_week: str, active_week: str,
result_ttl: int = 3600) -> float:
"""
Dùng SINTERSTORE khi muốn lưu kết quả để reuse.
Ví dụ: cùng retained set dùng cho nhiều metric khác nhau.
"""
cohort_key = f"cohort:signup:{cohort_week}"
active_key = f"active:week:{active_week}"
dest_key = f"retained:{cohort_week}:{active_week}"
# Lưu kết quả vào dest_key
redis_client.sinterstore(dest_key, cohort_key, active_key)
redis_client.expire(dest_key, result_ttl)
retained_count = redis_client.scard(dest_key)
cohort_size = redis_client.scard(cohort_key)
return retained_count / cohort_size if cohort_size else 0.0
Khi nào dùng SINTER vs SINTERSTORE:
SINTER: cần kết quả ngay, không reuse. Trả về set members — vẫn cần đọc qua network nhưng chỉ đọc kết quả (nhỏ hơn input set).SINTERSTORE: kết quả được lưu và dùng lại cho nhiều tính toán khác trong cùng pipeline (ví dụ tính LTV, funnel stage, dimension breakdown trên cùng tập retained).
Scale limit: với user_id là string UUID, Set per cohort tốn ~60–80 byte mỗi member (key overhead + encoding). 1 cohort × 100k user = 6–8MB. Nếu có 50 cohort đang active → 300–400MB chỉ cho Set. Với user_id là integer, Bitmap hiệu quả hơn đáng kể.
Pattern Bitmap — Memory Hiệu Quả
Khi user_id là integer dense (sequential từ 0 đến N), Bitmap thay thế Set với memory chỉ bằng 1/640 so với Set (1 bit vs ~64 byte per user).
def signup_bitmap(redis_client, user_id: int):
cohort_week = current_week()
redis_client.setbit(f"cohort:signup:{cohort_week}", user_id, 1)
# Lưu cohort vào profile: vẫn cần để lookup cohort của user
redis_client.hset(f"user:{user_id}", "cohort", cohort_week)
def track_active_bitmap(redis_client, user_id: int):
active_week = current_week()
redis_client.setbit(f"active:week:{active_week}", user_id, 1)
def retention_bitmap(redis_client, cohort_week: str, active_week: str) -> float:
"""
BITOP AND để intersect hai Bitmap phía server.
Kết quả lưu vào key tạm, BITCOUNT đếm số bit 1.
"""
cohort_key = f"cohort:signup:{cohort_week}"
active_key = f"active:week:{active_week}"
tmp_key = "tmp:retention:bitop"
# BITOP AND: O(N bytes) với N = length của bitmap dài nhất
redis_client.bitop("AND", tmp_key, cohort_key, active_key)
retained = redis_client.bitcount(tmp_key)
redis_client.delete(tmp_key)
cohort_size = redis_client.bitcount(cohort_key)
return retained / cohort_size if cohort_size else 0.0
Memory so sánh với 100 triệu user (user_id 0..99.999.999):
| Structure | Per cohort/active set | 50 cohort + 52 active week |
|---|---|---|
| Set (UUID string) | ~6–8MB (100k user active) | ~600–800MB |
| Bitmap | 100M / 8 = 12.5MB cố định | (50+52) × 12.5MB = 1.275GB |
Bitmap có kích thước cố định theo max user_id, không theo số user active. Nếu cohort chỉ có 1000 user trong khoảng user_id 0..99.999.999, bitmap vẫn chiếm 12.5MB. Set sẽ chỉ tốn ~80KB cho 1000 user. Vì vậy Bitmap chỉ thắng khi cohort đủ dense — thường trên 10–20% của không gian user_id.
Key tạm và concurrency: tmp:retention:bitop được dùng chung là không an toàn khi nhiều process chạy song song. Dùng key tạm duy nhất mỗi request:
import uuid
def retention_bitmap_safe(redis_client, cohort_week: str, active_week: str) -> float:
cohort_key = f"cohort:signup:{cohort_week}"
active_key = f"active:week:{active_week}"
tmp_key = f"tmp:retention:{uuid.uuid4().hex}"
try:
redis_client.bitop("AND", tmp_key, cohort_key, active_key)
retained = redis_client.bitcount(tmp_key)
cohort_size = redis_client.bitcount(cohort_key)
return retained / cohort_size if cohort_size else 0.0
finally:
redis_client.delete(tmp_key)
HyperLogLog Cho Scale Rất Lớn
Với 1 tỷ user, Bitmap cohort cần 1B / 8 = 125MB mỗi key. 50 cohort × 125MB = 6.25GB — bắt đầu nặng. HLL chỉ dùng 12KB cố định.
Tuy nhiên HLL có giới hạn quan trọng với cohort analysis: không hỗ trợ exact intersection. HLL chỉ cho biết cardinality (số lượng unique) của một set, không lưu identity của từng phần tử. Không thể dùng PFCOUNT trực tiếp để tính |cohort ∩ active|.
Có một phương pháp xấp xỉ bằng inclusion-exclusion:
|A ∩ B| ≈ |A| + |B| - |A ∪ B|
def retention_hll_approximate(redis_client, cohort_week: str, active_week: str) -> float:
"""
CẢNH BÁO: phương pháp này có sai số lớn và không đáng tin.
Chỉ dùng nếu chấp nhận error 5–20% trên retention %.
"""
cohort_key = f"cohort:hll:{cohort_week}"
active_key = f"active:hll:{active_week}"
union_key = f"tmp:union:{uuid.uuid4().hex}"
try:
cohort_count = redis_client.pfcount(cohort_key)
active_count = redis_client.pfcount(active_key)
# PFMERGE để tính union
redis_client.pfmerge(union_key, cohort_key, active_key)
union_count = redis_client.pfcount(union_key)
# Inclusion-exclusion
intersection_approx = cohort_count + active_count - union_count
# intersection_approx có thể âm do lỗi HLL cộng dồn
intersection_approx = max(0, intersection_approx)
return intersection_approx / cohort_count if cohort_count else 0.0
finally:
redis_client.delete(union_key)
Vấn đề với inclusion-exclusion qua HLL: mỗi phép PFCOUNT có standard error 0.81%. Khi trừ hai số xấp xỉ có cùng magnitude, sai số tương đối của kết quả tăng vọt. Ví dụ: cohort 10.000, active 80.000, union 85.000 → intersection ≈ 5.000. Nếu union sai 1% = 850 → intersection sai 17%.
Khuyến nghị:
- Bitmap nếu user_id integer dense và max user_id hợp lý (< 100M → <12.5MB per set).
- Set nếu user_id UUID và dataset vừa (< 50k user per cohort, < 100 cohort active).
- HLL chỉ dùng cho trường hợp chỉ cần count cohort size (Week 0), không cần retention rate chính xác.
- Với 1B+ user, cân nhắc shard cohort hoặc chuyển về Snowflake/BigQuery cho historical retention.
Build Retention Matrix — Background Script
Tính retention matrix theo request sẽ block dashboard với nhiều BITOP. Pattern đúng: background job chạy daily, pre-compute toàn bộ matrix, cache vào Hash để dashboard đọc.
def build_retention_matrix(redis_client, cohort_weeks: list[str],
active_weeks: list[str]) -> dict:
"""
Tính retention matrix toàn phần.
cohort_weeks: ["2026-W18", "2026-W19", "2026-W20"]
active_weeks: ["2026-W18", "2026-W19", "2026-W20", "2026-W21", "2026-W22"]
Trả về dict: {cohort_week: [retention_per_active_week, ...]}
"""
matrix = {}
tmp_key = f"tmp:matrix:{uuid.uuid4().hex}"
try:
for c in cohort_weeks:
row = []
cohort_key = f"cohort:signup:{c}"
cohort_size = redis_client.bitcount(cohort_key)
for a in active_weeks:
if a < c:
# Active week trước cohort week — không có nghĩa
row.append(None)
continue
active_key = f"active:week:{a}"
redis_client.bitop("AND", tmp_key, cohort_key, active_key)
retained = redis_client.bitcount(tmp_key)
redis_client.delete(tmp_key)
if cohort_size:
row.append(round(retained / cohort_size, 4))
else:
row.append(0.0)
matrix[c] = row
finally:
redis_client.delete(tmp_key) # đảm bảo cleanup
return matrix
def cache_retention_matrix(redis_client, matrix: dict,
cache_ttl: int = 86400 * 2):
"""
Lưu kết quả matrix vào Hash để dashboard đọc nhanh.
Key: cohort:retention:cache:{cohort_week}
Field: {active_week} → retention value
"""
import json
pipe = redis_client.pipeline()
for cohort_week, row_data in matrix.items():
cache_key = f"cohort:retention:cache:{cohort_week}"
# Lưu dạng JSON string cho đơn giản
pipe.set(cache_key, json.dumps(row_data), ex=cache_ttl)
pipe.execute()
def get_cached_retention_row(redis_client, cohort_week: str) -> list | None:
import json
val = redis_client.get(f"cohort:retention:cache:{cohort_week}")
return json.loads(val) if val else None
Lịch trình: chạy build_retention_matrix mỗi ngày lúc 02:00 UTC. Background job có thể là Celery beat task, cron + Python script, hoặc Cloud Scheduler. TTL cache 2 ngày đảm bảo dashboard không trả về stale data quá lâu nếu job bị lỗi một ngày.
Lưu ý active weeks: cần truyền đúng danh sách active weeks đang có data. Nếu active_weeks bao gồm tuần tương lai (chưa có data), BITCOUNT trả về 0 — retention sẽ là 0.0 thay vì None. Phân biệt "chưa đến" (None) với "đến rồi nhưng không ai active" (0.0).
Cohort Theo Dimension Khác
Cohort không bắt buộc chỉ theo tuần đăng ký. Có thể segment thêm theo country hoặc plan để so sánh retention giữa các nhóm.
def signup_with_dimensions(redis_client, user_id: int,
country: str, plan: str):
"""
Ghi user vào cohort tuần đăng ký, cohort theo country và plan.
"""
cohort_week = current_week()
pipe = redis_client.pipeline()
# Cohort gốc theo tuần
pipe.setbit(f"cohort:signup:{cohort_week}", user_id, 1)
# Cohort theo country
pipe.setbit(f"cohort:country:{country}:week:{cohort_week}", user_id, 1)
# Cohort theo plan
pipe.setbit(f"cohort:plan:{plan}:week:{cohort_week}", user_id, 1)
# User profile
pipe.hset(f"user:{user_id}", mapping={
"cohort": cohort_week,
"country": country,
"plan": plan,
})
pipe.execute()
def retention_by_country(redis_client, country: str,
cohort_week: str, active_week: str) -> float:
cohort_key = f"cohort:country:{country}:week:{cohort_week}"
active_key = f"active:week:{active_week}"
tmp_key = f"tmp:country_retention:{uuid.uuid4().hex}"
try:
redis_client.bitop("AND", tmp_key, cohort_key, active_key)
retained = redis_client.bitcount(tmp_key)
cohort_size = redis_client.bitcount(cohort_key)
return retained / cohort_size if cohort_size else 0.0
finally:
redis_client.delete(tmp_key)
Intersect ba chiều (country + plan + week) cũng có thể bằng BITOP AND nhiều key:
def retention_vn_premium(redis_client, cohort_week: str, active_week: str) -> float:
"""Retention của user VN premium, cohort tuần X, active tuần Y."""
keys = [
f"cohort:country:VN:week:{cohort_week}",
f"cohort:plan:premium:week:{cohort_week}",
f"active:week:{active_week}",
]
tmp_key = f"tmp:vn_premium:{uuid.uuid4().hex}"
try:
redis_client.bitop("AND", tmp_key, *keys)
retained = redis_client.bitcount(tmp_key)
# Cohort base: VN premium user signup tuần đó
base_key = f"tmp:vn_premium_base:{uuid.uuid4().hex}"
redis_client.bitop("AND", base_key,
f"cohort:country:VN:week:{cohort_week}",
f"cohort:plan:premium:week:{cohort_week}")
cohort_size = redis_client.bitcount(base_key)
redis_client.delete(base_key)
return retained / cohort_size if cohort_size else 0.0
finally:
redis_client.delete(tmp_key)
Cảnh báo key explosion: với 200 country × 5 plan × 52 tuần = 52.000 cohort key, mỗi 12.5MB = 650GB. Chỉ dữ trữ dimension breakdown cho những segment đủ lớn và thực sự cần theo dõi.
Day-1, Day-7, Day-30 Retention
Trong mobile và SaaS, Day-1/Day-7/Day-30 là metric retention tiêu chuẩn — nhóm user theo ngày đăng ký (thay vì tuần) và track ngày cụ thể sau đó.
from datetime import date, timedelta
def signup_daily(redis_client, user_id: int):
today = date.today().isoformat() # "2026-06-01"
redis_client.setbit(f"cohort:day:{today}", user_id, 1)
redis_client.hset(f"user:{user_id}", "signup_date", today)
# TTL cohort day: giữ 60 ngày để tính D30 + buffer
redis_client.expire(f"cohort:day:{today}", 86400 * 60)
def track_active_daily(redis_client, user_id: int):
today = date.today().isoformat()
redis_client.setbit(f"active:day:{today}", user_id, 1)
# TTL active day: giữ 35 ngày
redis_client.expire(f"active:day:{today}", 86400 * 35)
def day_n_retention(redis_client, signup_date: str, n: int) -> float:
"""
Tính Day-N retention cho cohort signup_date.
signup_date format: "2026-06-01"
n: 1, 7, 30
"""
active_date = (
date.fromisoformat(signup_date) + timedelta(days=n)
).isoformat()
cohort_key = f"cohort:day:{signup_date}"
active_key = f"active:day:{active_date}"
tmp_key = f"tmp:day{n}:{uuid.uuid4().hex}"
try:
redis_client.bitop("AND", tmp_key, cohort_key, active_key)
retained = redis_client.bitcount(tmp_key)
cohort_size = redis_client.bitcount(cohort_key)
return retained / cohort_size if cohort_size else 0.0
finally:
redis_client.delete(tmp_key)
def get_dau_retention_report(redis_client, signup_date: str) -> dict:
"""Lấy D1, D7, D30 cho một ngày signup cụ thể."""
return {
"D1": day_n_retention(redis_client, signup_date, 1),
"D7": day_n_retention(redis_client, signup_date, 7),
"D30": day_n_retention(redis_client, signup_date, 30),
}
Chú ý TTL: cohort day cần sống ít nhất 30 ngày + buffer để tính D30. Active day chỉ cần sống 35 ngày. Nếu TTL quá ngắn, D30 sẽ luôn trả về 0 vì cohort key đã hết hạn.
Day-1 retention thường là indicator mạnh nhất: nếu <20% user quay lại ngày hôm sau, có vấn đề với onboarding hoặc first-run experience.
Funnel Kết Hợp Cohort
Cohort analysis theo tuần đăng ký cho biết retention chung — user còn login hay không. Kết hợp với funnel, có thể biết retention ở từng stage: user còn active và đã kích hoạt tính năng, hoặc đã convert sang paid.
class FunnelStage:
SIGNUP = "signup"
ACTIVATED = "activated" # hoàn thành onboarding
ENGAGED = "engaged" # dùng tính năng cốt lõi
PAID = "paid"
def track_stage(redis_client, user_id: int, stage: str):
"""Ghi user vào set stage theo tuần."""
current = current_week()
redis_client.setbit(f"funnel:{stage}:week:{current}", user_id, 1)
def stage_retention(redis_client, cohort_week: str,
active_week: str, stage: str) -> float:
"""
Trong số user của cohort_week, bao nhiêu % đạt stage
trong active_week?
"""
cohort_key = f"cohort:signup:{cohort_week}"
stage_key = f"funnel:{stage}:week:{active_week}"
tmp_key = f"tmp:stage_ret:{uuid.uuid4().hex}"
try:
redis_client.bitop("AND", tmp_key, cohort_key, stage_key)
count = redis_client.bitcount(tmp_key)
cohort_size = redis_client.bitcount(cohort_key)
return count / cohort_size if cohort_size else 0.0
finally:
redis_client.delete(tmp_key)
def full_funnel_for_cohort(redis_client, cohort_week: str,
active_week: str) -> dict:
"""Trả về retention tại mỗi stage của funnel."""
stages = [FunnelStage.ACTIVATED, FunnelStage.ENGAGED, FunnelStage.PAID]
return {
stage: stage_retention(redis_client, cohort_week, active_week, stage)
for stage in stages
}
Kết quả điển hình của funnel per cohort: Activated 60% → Engaged 30% → Paid 8%. Nếu cohort W19 có Paid retention thấp hơn W18 dù Activated tương đương, vấn đề nằm ở chuyển đổi từ Engaged sang Paid, không phải onboarding.
A/B Test Per Cohort
A/B test cần biết user thuộc variant nào và track retention riêng cho từng variant.
def assign_ab_variant(redis_client, user_id: int) -> str:
"""Phân công user vào variant A hoặc B (50/50)."""
variant = "A" if user_id % 2 == 0 else "B"
cohort_week = current_week()
redis_client.setbit(f"ab:variant:{variant}:week:{cohort_week}", user_id, 1)
redis_client.hset(f"user:{user_id}", "ab_variant", variant)
return variant
def ab_retention(redis_client, variant: str,
cohort_week: str, active_week: str) -> float:
"""Retention của variant A hoặc B."""
cohort_key = f"ab:variant:{variant}:week:{cohort_week}"
active_key = f"active:week:{active_week}"
tmp_key = f"tmp:ab_ret:{uuid.uuid4().hex}"
try:
redis_client.bitop("AND", tmp_key, cohort_key, active_key)
retained = redis_client.bitcount(tmp_key)
cohort_size = redis_client.bitcount(cohort_key)
return retained / cohort_size if cohort_size else 0.0
finally:
redis_client.delete(tmp_key)
def compare_ab(redis_client, cohort_week: str, active_week: str) -> dict:
return {
"A": ab_retention(redis_client, "A", cohort_week, active_week),
"B": ab_retention(redis_client, "B", cohort_week, active_week),
}
Lưu ý thống kê: Redis cho bạn con số retention thô (ví dụ A=42%, B=45%). Để kết luận B tốt hơn A một cách có ý nghĩa thống kê, cần chạy chi-squared test hoặc z-test trên số liệu tuyệt đối (retained count, cohort size) — không phải Redis làm, mà là bước post-processing trong Python hoặc notebook.
Realtime Dashboard & Pre-aggregate
Dashboard retention cần phản hồi <100ms. Build matrix theo request với nhiều BITOP sẽ vi phạm latency target trên matrix lớn.
Luồng khuyến nghị:
- Event time: khi user signup hoặc active, SETBIT cập nhật ngay vào cohort/active key.
- Aggregate job: cron mỗi giờ (hoặc mỗi ngày lúc 02:00 UTC) chạy
build_retention_matrix, lưu kết quả vào cache Hash/JSON. - Dashboard: đọc từ cache, không bao giờ gọi BITOP trực tiếp trên request path.
import json
def get_dashboard_matrix(redis_client, cohort_weeks: list[str],
active_weeks: list[str]) -> dict:
"""
Đọc matrix từ cache. Nếu cache miss, tính on-the-fly
(chỉ nên xảy ra khi khởi động hoặc cache hết hạn).
"""
matrix = {}
for c in cohort_weeks:
cached = redis_client.get(f"cohort:retention:cache:{c}")
if cached:
matrix[c] = json.loads(cached)
else:
# Cache miss: tính trực tiếp cho cohort này (blocking)
row = []
for a in active_weeks:
if a < c:
row.append(None)
else:
row.append(
retention_bitmap_safe(redis_client, c, a)
)
matrix[c] = row
# Cache kết quả vừa tính
redis_client.set(
f"cohort:retention:cache:{c}",
json.dumps(row),
ex=3600
)
return matrix
Cron job nên chạy ngoài giờ cao điểm vì BITOP trên nhiều key lớn sẽ tốn CPU. Trên Redis 7.x, một lệnh BITOP AND trên hai key 12.5MB mất khoảng 2–5ms — với 10 cohort × 8 active week = 80 lần BITOP → 160–400ms tổng, chấp nhận được cho background job.
Memory Budget Production
Ước tính memory cho hệ thống dùng Bitmap với 100 triệu user (user_id 0..99.999.999):
| Loại key | Số lượng | Size mỗi key | Tổng |
|---|---|---|---|
| Cohort signup (tuần) | 52 tuần | 12.5MB | 650MB |
| Active week | 52 tuần | 12.5MB | 650MB |
| Active day | 365 ngày | 12.5MB | 4.56GB |
| Cohort day (D1/D7/D30) | 60 ngày | 12.5MB | 750MB |
| Cache retention JSON | 52 cohort × ~200 byte | nhỏ | ~10KB |
Tổng ước tính: ~6.6GB cho toàn bộ cohort + active. Chạy được trên instance 8–16GB nếu không có workload khác cạnh tranh memory.
Giảm memory:
- Active day: chỉ giữ 35 ngày (đủ cho D30) → 437MB thay vì 4.56GB.
- Cohort day: xóa sau 62 ngày.
- Cohort week: xóa sau 53 tuần (1 năm) — archive sang S3 nếu cần historical.
- Nếu vượt 8GB, cluster shard theo hash slot hoặc tách key space sang node riêng.
def set_cohort_ttls(redis_client, cohort_week: str):
"""
Đặt TTL cho cohort key sau khi tạo.
Cohort week tồn tại 1 năm + 4 tuần buffer.
"""
ttl = 86400 * (365 + 28) # ~393 ngày
redis_client.expire(f"cohort:signup:{cohort_week}", ttl)
def set_active_ttls(redis_client):
"""
Active day key: 35 ngày.
Active week key: 53 tuần.
Gọi sau mỗi lần tạo key mới.
"""
today = date.today().isoformat()
redis_client.expire(f"active:day:{today}", 86400 * 35)
current = current_week()
redis_client.expire(f"active:week:{current}", 86400 * 7 * 53)
Khi Nào Dùng SQL OLAP Thay Redis
Redis không thay thế SQL Data Warehouse cho mọi use case:
| Tiêu chí | Redis Bitmap/Set | SQL OLAP (BigQuery, Snowflake) |
|---|---|---|
| Latency dashboard | <100ms (pre-compute) | 1–30s (tùy query complexity) |
| Historical depth | 1–2 năm (memory limit) | Không giới hạn, petabyte scale |
| Ad-hoc query | Phải viết code mỗi query mới | SQL linh hoạt, window function |
| Multi-dimension drill-down | Cần pre-define dimension | GROUP BY tùy ý |
| Realtime | Tốt (update per event) | Phụ thuộc pipeline (thường giờ/ngày) |
Pattern hybrid thực tế:
- Redis lưu 90 ngày cohort gần nhất, phục vụ realtime dashboard <100ms.
- Cron job flush Bitmap/Set sang Snowflake dạng event stream hàng ngày.
- Snowflake dùng cho: historical retention quá 90 ngày, ad-hoc query, cross-product analysis.
- Dashboard gọi Redis cho current data, Snowflake cho historical (chấp nhận latency cao hơn).
Anti-patterns & Best Practices
Anti-patterns
- Set lưu user_id forever, không TTL: cohort cũ không bao giờ xóa → memory tăng vô hạn theo tuổi sản phẩm.
- Active set không TTL: active:week:2024-W01 tồn tại mãi dù không còn ai cần dữ liệu đó.
- Inclusion-exclusion HLL cho retention: sai số cộng dồn của hai phép PFCOUNT làm kết quả retention không tin được.
- Tính matrix trực tiếp trên request path: 80 BITOP per dashboard request với traffic cao → latency spike, CPU spike Redis.
- Bitmap với user_id sparse (UUID hash): max user_id 2^63 → bitmap 1 exabyte — Redis không cấp phát được, lệnh SETBIT lỗi hoặc crash.
- Key tạm dùng tên cố định với nhiều process:
tmp:retentionbị nhiều worker ghi đè nhau → kết quả sai. - Track mọi dimension (country × plan × acquisition × variant × ...): key explosion, phần lớn không bao giờ được query.
Best Practices
- Bitmap khi user_id integer dense và <100M max user_id (12.5MB/key).
- Set khi user_id UUID và cohort nhỏ (< 50k user/cohort, < 100 cohort active cùng lúc).
- HLL chỉ để đếm cohort size (Week 0), không dùng cho intersect retention.
- Key tạm: dùng UUID suffix để tránh collision giữa concurrent workers.
- Pre-compute matrix bằng background job hàng ngày, cache kết quả vào Hash/JSON.
- TTL: cohort week 1 năm + buffer, active day 35 ngày, active week 53 tuần.
- Kết hợp Redis (realtime, 90 ngày) với SQL DW (historical, ad-hoc).
- Chỉ track dimension mà dashboard thực sự dùng — đừng track "phòng khi cần sau".
Tổng Kết & Quiz
Cohort analysis trong Redis kết hợp SETBIT khi signup, SETBIT khi active, và BITOP AND để intersect — toàn bộ phía server. Set phù hợp với UUID và dataset vừa; Bitmap phù hợp với integer dense và scale lớn; HLL chỉ dùng để đếm cohort size. Pre-compute matrix background và cache kết quả là bắt buộc cho dashboard latency thấp. Với historical data quá 1–2 năm, SQL DW là lựa chọn phù hợp hơn.
Quiz
- Tại sao "đường chéo" trong retention matrix tương ứng với thời gian thực? Giải thích bằng ví dụ với ba cohort W18, W19, W20.
- Client-side intersect bằng Python
set &khác SINTER/SINTERSTORE ở điểm nào? Khi nào client-side tệ hơn server-side về performance? - BITOP AND hai Bitmap 12.5MB mỗi cái. Nếu có 20 BITOP chạy đồng thời trên cùng Redis instance, tác động gì lên latency của các lệnh khác? Giải pháp?
- Với user_id là UUID (string 36 ký tự), tại sao không dùng Bitmap? Và nếu muốn dùng Bitmap, cần làm gì với UUID đó?
- Tại sao không dùng
SET NX(như dedup window trong page view) để track "user này đã active tuần này chưa" thay vì SETBIT? Hai trường hợp khác nhau ở điểm gì?
Đáp án gợi ý
- Cohort W18 Week 2 = data của tuần W20. Cohort W19 Week 1 = data của tuần W20. Cohort W20 Week 0 = data của tuần W20. Cả ba ô ứng với cùng một khoảng thời gian thực (tuần W20) — đó là "đường chéo". Vì active_week = cohort_week + offset, các cohort ra đời vào các tuần khác nhau đều "gặp nhau" trên cùng đường chéo khi offset bằng nhau.
- Client-side: cần đọc toàn bộ nội dung hai Set qua network về Python rồi mới intersect. Với Set 100k UUID, đó là ~7.2MB transfer. SINTER làm intersect trong Redis process, chỉ trả kết quả nhỏ hơn. SINTER kém client-side chỉ khi kết quả gần bằng input (dense intersection) và cần thêm xử lý phức tạp phía client mà Redis không hỗ trợ.
- BITOP là lệnh O(N) blocking — 12.5MB = 3.1M byte operations. 20 BITOP đồng thời chiếm CPU và làm tăng latency lệnh khác (Redis single-threaded command processing trên Redis 6.x). Giải pháp: chỉ chạy BITOP trong background job lúc thấp tải, không cho request HTTP gọi trực tiếp; hoặc dùng Redis 7.x multi-threaded I/O kết hợp dedicated replica cho analytics queries.
- Bitmap cần integer offset. UUID "550e8400-e29b-41d4-a716..." không phải integer. Nếu muốn dùng Bitmap với UUID: cần ánh xạ UUID → integer dense (ví dụ incremental ID trong DB); nếu dùng hash(UUID) % N thì user_id không còn dense và collision xuất hiện. Giải pháp thực tế: lưu cả user_id integer (database ID) song song với UUID để dùng cho Bitmap.
- SET NX tạo key mới mỗi cặp (user, tuần) → N keys per tuần (N = số user active). SETBIT lưu toàn bộ N user vào một key với offset = user_id → 1 key per tuần, kích thước cố định theo max user_id. SET NX phù hợp cho dedup window vì cần TTL riêng mỗi cặp và không cần aggregate count sau. SETBIT phù hợp khi cần bitcount, bitop, và giữ data dài hạn mà không muốn có hàng triệu key nhỏ.
Bài tiếp theo
Bài 98 đi sâu vào Bitmap DAU/MAU/WAU — Daily/Monthly/Weekly Active User tracking với Redis Bitmap, bao gồm period overlap, rolling window, và multi-period comparison.
