Danh sách bài viết

Bài 69: Presence Tracking — Ai Đang Online

Presence tracking là bài toán biết ai đang online, ai vừa offline, last seen bao lâu trước — tính năng cốt lõi của chat (Messenger, Slack, WhatsApp). Bài này phân tích ba pattern Redis: Sorted Set với last seen timestamp, Set per device cho multi-device, và Hash per user cho rich state. Bao gồm cơ chế heartbeat + TTL để detect disconnect khi client crash, Pub/Sub fan-out tới friends để update UI real-time, cùng optimization cho hàng triệu user online, anti-patterns thường gặp và best practices.

01/06/2026
15 phút đọc
0 lượt xem
1

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

  • Hiểu bài toán presence tracking: "đang online", "last seen", multi-device, real-time update.
  • Nắm ba pattern Redis cho presence: Sorted Set + timestamp, Set per device, Hash per user.
  • Thiết kế heartbeat + TTL để detect disconnect khi client crash (không gửi explicit logout).
  • Dùng Pub/Sub fan-out presence change chỉ tới interested users (friends), tránh broadcast global.
  • Implement last seen với format "N phút trước".
  • Nhận diện anti-patterns: in-memory multi-instance, quên TTL, heartbeat quá thường, fan-out global.
2

Bài Toán Presence

Presence tracking xuất hiện trong mọi ứng dụng chat và collaboration. Những gì cần biểu diễn:

  • Đang online: chấm xanh bên cạnh avatar — user đang mở app và tương tác.
  • Last seen: "5 phút trước", "2 giờ trước" — hiển thị khi user đã offline.
  • Multi-device: user mở Messenger trên cả điện thoại lẫn laptop — cả hai đều "online"; chỉ offline thật khi đóng hết.
  • Real-time update: khi bạn mở conversation với ai đó, avatar họ chuyển từ xám sang xanh ngay lập tức.

Vì sao không dùng database quan hệ? Presence cần write rất thường xuyên (heartbeat mỗi 30s per user), query theo thời gian thực (ai online trong 60s gần nhất), và TTL-based expiry tự động — đây là thế mạnh của Redis, không phải PostgreSQL.

3

Challenges: Detect Disconnect & Multi-device

Ba thách thức chính cần giải quyết:

1. Detect disconnect khi client không gửi logout: Trong thực tế, nhiều trường hợp client biến mất mà không gửi bất kỳ signal nào — điện thoại hết pin, app crash, mất mạng đột ngột. Không thể chờ explicit "logout" event để đánh dấu offline.

2. Multi-device: User A đang online cả mobile lẫn desktop. Nếu đóng tab desktop thì vẫn còn online qua mobile. Chỉ offline khi mọi device đều ngừng kết nối. Cần track từng device riêng, không chỉ track user-level.

3. Scale broadcast: Khi user A online, không phải mọi người dùng đều cần biết — chỉ bạn bè của A mới quan tâm. Với 1 triệu user online cùng lúc, broadcast toàn bộ presence change sẽ overwhelm hệ thống.

Trạng thái kết nối thực tế:
─────────────────────────────────────────────────────
User A  │ mobile (connected)  │ desktop (connected)  │  → ONLINE
        │ mobile (connected)  │ desktop (closed)     │  → ONLINE
        │ mobile (crash)      │ desktop (closed)     │  → cần TTL để detect
─────────────────────────────────────────────────────
4

Pattern 1 — Sorted Set + Last Seen Timestamp

Pattern đơn giản và hiệu quả nhất cho single-device presence: dùng một Sorted Set duy nhất presence:online, trong đó score là Unix timestamp lần hoạt động cuối của user.

import time
import redis

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

def heartbeat(user_id: str):
    """Gọi mỗi 30s khi user active — cập nhật last seen."""
    now = int(time.time())
    r.zadd("presence:online", {user_id: now})

def is_online(user_id: str, threshold: int = 60) -> bool:
    """
    Online = đã heartbeat trong threshold giây gần nhất.
    threshold=60: nếu không heartbeat 60s → coi là offline.
    """
    last = r.zscore("presence:online", user_id)
    if last is None:
        return False
    return (time.time() - last) < threshold

def online_users(threshold: int = 60) -> list:
    """Danh sách user có last seen trong threshold giây gần nhất."""
    now = int(time.time())
    return r.zrangebyscore("presence:online", now - threshold, now)

def cleanup_stale(max_age: int = 300):
    """Xóa entry quá cũ — nên chạy định kỳ (cron hoặc background task)."""
    now = int(time.time())
    removed = r.zremrangebyscore("presence:online", 0, now - max_age)
    return removed  # số entry đã xóa

Lý do chọn Sorted Set thay vì Set thường:

  • Score là timestamp → có thể query "ai online trong N giây gần nhất" bằng ZRANGEBYSCORE với O(log N + M).
  • ZADD là upsert — gọi nhiều lần trên cùng user_id chỉ update score, không tạo duplicate.
  • Có thể lấy last seen timestamp của bất kỳ user nào bằng ZSCORE — O(log N).
  • Cleanup theo khoảng thời gian bằng ZREMRANGEBYSCORE — O(log N + M) thay vì scan toàn bộ.

Với 1 triệu entry trong Sorted Set, ZADD mất khoảng O(log 1.000.000) ≈ 20 bước — hoàn toàn chấp nhận được.

5

Heartbeat Từ Client

Heartbeat là cơ chế client định kỳ báo hiệu "tôi vẫn đang đây". Không có heartbeat = không thể phân biệt user đang đọc yên lặng với user đã crash.

Cách 1 — WebSocket ping/pong: Với kết nối WebSocket dài hạn, server gửi ping frame mỗi 30s, client trả pong. Server nhận được pong thì gọi hàm heartbeat(user_id).

# Server-side WebSocket handler (ví dụ với websockets lib)
import asyncio
import websockets

async def handler(websocket, path):
    user_id = await authenticate(websocket)
    heartbeat(user_id)  # đánh dấu online ngay khi connect

    try:
        async for message in websocket:
            # Bất kỳ message nào từ client cũng update presence
            heartbeat(user_id)
            await process_message(message)
    except websockets.ConnectionClosed:
        pass
    finally:
        # Explicit disconnect — có thể xóa khỏi online set ngay
        r.zrem("presence:online", user_id)
        # Hoặc để TTL tự xử lý nếu muốn delay

Cách 2 — HTTP polling: Client gọi POST /presence/heartbeat mỗi 30s. Đơn giản hơn nhưng tốn thêm HTTP overhead.

Chọn frequency 30s là sweet spot thực tế:

  • Quá ngắn (5s): 1 triệu user = 200.000 write/s vào Redis — không cần thiết.
  • Quá dài (120s): presence stale — user offline 2 phút nhưng vẫn hiện xanh.
  • 30s heartbeat + 90s TTL: detect offline trong vòng 30–90s sau khi mất kết nối.
6

Pattern 2 — Set Per Device (Multi-device)

Pattern Sorted Set ở trên phù hợp cho single-device. Với multi-device (user mở app trên nhiều thiết bị), cần track từng device riêng lẻ.

import uuid

def device_connect(user_id: str, device_id: str):
    """Gọi khi device kết nối (WebSocket opened, app foreground)."""
    key = f"presence:devices:{user_id}"
    r.sadd(key, device_id)
    r.expire(key, 120)  # TTL 120s — device phải heartbeat để duy trì

def device_heartbeat(user_id: str, device_id: str):
    """Gọi mỗi 30s từ device — refresh TTL của cả Set."""
    key = f"presence:devices:{user_id}"
    # Thêm lại để chắc chắn còn trong Set (phòng TTL expire)
    r.sadd(key, device_id)
    r.expire(key, 120)

def device_disconnect(user_id: str, device_id: str):
    """Gọi khi device explicit disconnect."""
    key = f"presence:devices:{user_id}"
    r.srem(key, device_id)
    # Không cần xóa key — expire tự dọn khi Set rỗng không đúng,
    # cần check và xóa nếu Set rỗng:
    if r.scard(key) == 0:
        r.delete(key)

def is_online(user_id: str) -> bool:
    """Online nếu có ít nhất 1 device active."""
    return r.scard(f"presence:devices:{user_id}") > 0

def active_device_count(user_id: str) -> int:
    """Số device đang active của user."""
    return r.scard(f"presence:devices:{user_id}")

Lưu ý về TTL với Set per device: TTL được đặt trên key Set, không phải trên từng member. Điều đó có nghĩa là nếu user có 2 device — device A vẫn active, device B đã crash — Set sẽ expire theo lần heartbeat gần nhất của bất kỳ device nào. Vì vậy cần kết hợp: mỗi device heartbeat riêng, và dùng TTL per key như safety net.

Một cách chính xác hơn là dùng Hash thay vì Set để lưu timestamp per device:

def device_heartbeat_v2(user_id: str, device_id: str):
    """Hash lưu {device_id: last_seen_timestamp} — track từng device."""
    key = f"presence:devices:{user_id}"
    r.hset(key, device_id, int(time.time()))
    r.expire(key, 300)  # cleanup nếu không có device nào active lâu

def is_online_v2(user_id: str, threshold: int = 90) -> bool:
    """Online nếu ít nhất 1 device heartbeat trong threshold giây."""
    key = f"presence:devices:{user_id}"
    device_timestamps = r.hgetall(key)
    now = time.time()
    return any(
        (now - int(ts)) < threshold
        for ts in device_timestamps.values()
    )
7

Pattern 3 — Hash Per User (Rich State)

Khi cần lưu nhiều thông tin hơn về trạng thái người dùng — không chỉ online/offline mà còn thiết bị đang dùng, app instance, trạng thái tùy chỉnh — dùng Hash per user.

import json

def set_user_presence(user_id: str, device: str, instance_id: str, status: str = "online"):
    """
    Lưu rich state per user.
    status: "online" | "away" | "dnd" | "offline"
    """
    key = f"presence:user:{user_id}"
    r.hset(key, mapping={
        "status": status,
        "last_seen": int(time.time()),
        "device": device,          # "mobile" | "desktop" | "tablet"
        "instance": instance_id,   # server instance đang serve user này
    })
    r.expire(key, 120)

def get_user_presence(user_id: str) -> dict | None:
    """Lấy toàn bộ presence state của user."""
    data = r.hgetall(f"presence:user:{user_id}")
    if not data:
        return None
    return {
        "status": data.get("status", "offline"),
        "last_seen": int(data.get("last_seen", 0)),
        "device": data.get("device"),
        "instance": data.get("instance"),
    }

def set_status(user_id: str, status: str):
    """User tự đặt trạng thái (ví dụ DND)."""
    key = f"presence:user:{user_id}"
    if r.exists(key):  # chỉ update nếu key còn tồn tại
        r.hset(key, "status", status)
        r.expire(key, 120)  # reset TTL

Pattern này phù hợp khi UI cần hiển thị nhiều thông tin hơn (thiết bị đang dùng, biểu tượng DND), hoặc khi cần biết user đang trên server instance nào để routing WebSocket message.

8

Detect Disconnect Bằng TTL Fallback

Trong môi trường thực tế, client không phải lúc nào cũng gửi được signal "tôi đã đi". Giải pháp: kết hợp explicit disconnect với TTL fallback.

Cơ chế kết hợp:

  Client                    Server                 Redis
    │                          │                     │
    │──── heartbeat ──────────►│─── ZADD score=now ─►│
    │                          │                     │  TTL reset
    │  [30s later]             │                     │
    │──── heartbeat ──────────►│─── ZADD score=now ─►│
    │                          │                     │  TTL reset
    │  [CLIENT CRASH]          │                     │
    │  [không gửi được gì]     │                     │
    │                          │                     │  ...90s trôi qua...
    │                          │                     │  entry vẫn còn
    │                          │                     │  nhưng score cũ
    │                          │                     │
    │           Query: is_online(user_id, threshold=60)
    │                          │─── ZSCORE ─────────►│
    │                          │◄── timestamp cũ ────│
    │                          │  time.time() - old_ts > 60  → OFFLINE

Hai lớp bảo vệ:

  • Explicit logout: Gọi ZREM hoặc DEL ngay lập tức → user offline instant. Áp dụng cho trường hợp normal disconnect.
  • TTL / threshold fallback: Heartbeat dừng → score không được update → is_online() trả False sau khi vượt ngưỡng (60–90s). Bắt được trường hợp crash, mất mạng.

Latency detect offline với cơ chế này: 30–90s. Đây là trade-off thực tế — không thể instant detect crash không gửi signal. Messenger và WhatsApp cũng có độ trễ tương tự.

9

Pub/Sub — Notify Presence Change Real-time

Cập nhật Sorted Set là đủ để query presence, nhưng chưa đủ để push presence change ra UI real-time. Khi user A online, những client đang mở conversation với A cần nhận update ngay — không phải đợi đến lần poll tiếp theo.

Giải pháp: kết hợp ZADD với PUBLISH trong cùng một thao tác. Bài 65 đã giới thiệu cơ chế Pub/Sub cơ bản; bài này áp dụng trực tiếp vào presence.

import json

def heartbeat_with_notify(user_id: str, prev_status: str = "offline"):
    """
    Cập nhật presence + publish event nếu trạng thái thay đổi.
    prev_status: trạng thái trước đó (lưu tại application layer).
    """
    now = int(time.time())
    was_online = is_online(user_id, threshold=60)

    r.zadd("presence:online", {user_id: now})

    # Chỉ publish khi trạng thái thực sự thay đổi
    if not was_online:
        event = json.dumps({"user": user_id, "status": "online", "ts": now})
        r.publish("presence:events", event)

def user_disconnect(user_id: str):
    """Explicit logout — publish offline event."""
    # Lưu last seen trước khi xóa
    last_seen = int(time.time())
    r.hset(f"user:{user_id}", "last_seen", last_seen)

    # Xóa khỏi online set
    r.zrem("presence:online", user_id)

    event = json.dumps({"user": user_id, "status": "offline", "ts": last_seen})
    r.publish("presence:events", event)
# Consumer: server nhận presence event và push tới frontend qua WebSocket
import threading

def presence_event_listener():
    pubsub = r.pubsub()
    pubsub.subscribe("presence:events")

    for message in pubsub.listen():
        if message["type"] != "message":
            continue
        event = json.loads(message["data"])
        user_id = event["user"]
        status = event["status"]
        # Push tới tất cả WebSocket connection đang chờ presence của user này
        notify_watchers(user_id, status)

# Chạy listener trong background thread
listener_thread = threading.Thread(target=presence_event_listener, daemon=True)
listener_thread.start()

Lưu ý: Pub/Sub là fire-and-forget (đã phân tích ở bài 65). Subscriber offline tại thời điểm publish sẽ mất event. Với presence, đây là chấp nhận được vì: khi client reconnect, nó sẽ query lại presence state từ Redis — không cần replay history.

10

Fan-out Tới Friends Only

Publish vào một channel global presence:events rồi để mọi consumer lọc chỉ hoạt động ở scale nhỏ. Với hàng triệu user, mỗi presence change sẽ tạo ra message mà hầu hết consumer không cần — lãng phí bandwidth và CPU.

Pattern thực tế: publish tới channel per user (presence:user:{friend_id}) thay vì global channel. Mỗi frontend chỉ subscribe channel của những người họ thực sự cần biết presence.

def heartbeat_fan_out(user_id: str):
    """
    Publish presence event tới channel của từng friend.
    Chỉ những ai đang watch user này mới nhận được event.
    """
    now = int(time.time())
    was_online = is_online(user_id, threshold=60)
    r.zadd("presence:online", {user_id: now})

    if not was_online:
        # Lấy danh sách bạn bè (đã lưu trong Set)
        friends = r.smembers(f"friends:{user_id}")
        event = json.dumps({"friend": user_id, "status": "online", "ts": now})
        # Mỗi friend có channel riêng — frontend subscribe channel của mình
        for friend_id in friends:
            r.publish(f"presence:user:{friend_id}", event)
# Frontend (qua WebSocket gateway) subscribe channel của chính user
# Khi friend A online → nhận event trên channel "presence:user:{my_id}"

def subscribe_my_presence_channel(my_user_id: str):
    pubsub = r.pubsub()
    pubsub.subscribe(f"presence:user:{my_user_id}")

    for message in pubsub.listen():
        if message["type"] != "message":
            continue
        event = json.loads(message["data"])
        # event = {"friend": "alice", "status": "online", "ts": ...}
        update_friend_avatar(event["friend"], event["status"])

Trade-off của pattern này: nếu user có 1000 friends, mỗi lần online tạo ra 1000 PUBLISH. Với user thông thường (100–300 friends) không thành vấn đề. Với mạng xã hội có "influencer" — cần rate limit hoặc batch publish.

11

Last Seen: "5 Phút Trước"

Khi user offline, cần lưu lại timestamp chính xác để hiển thị "last seen". Thông tin này cần persist lâu hơn TTL của presence (có thể hiển thị "3 ngày trước"), vì vậy lưu riêng vào Hash user, không dùng TTL.

def on_user_disconnect(user_id: str):
    """Lưu last seen khi user offline — persist lâu dài."""
    last_seen = int(time.time())
    # Lưu vào Hash user profile — không có TTL, persist dài hạn
    r.hset(f"user:{user_id}", "last_seen", last_seen)
    r.zrem("presence:online", user_id)

def format_last_seen(user_id: str) -> str:
    """
    Trả về chuỗi thân thiện:
      - user đang online → "Đang hoạt động"
      - offline < 60s   → "vừa xong"
      - offline < 1h    → "N phút trước"
      - offline < 24h   → "N giờ trước"
      - offline >= 24h  → "N ngày trước" hoặc ngày cụ thể
    """
    if is_online(user_id, threshold=60):
        return "Đang hoạt động"

    last_raw = r.hget(f"user:{user_id}", "last_seen")
    if last_raw is None:
        return "Không xác định"

    delta = time.time() - int(last_raw)

    if delta < 60:
        return "vừa xong"
    if delta < 3600:
        minutes = int(delta / 60)
        return f"{minutes} phút trước"
    if delta < 86400:
        hours = int(delta / 3600)
        return f"{hours} giờ trước"

    days = int(delta / 86400)
    return f"{days} ngày trước"

Không thể lấy last seen từ ZSCORE sau khi user offline — entry đã bị xóa (hoặc TTL expire). Cần lưu riêng vào key persist trước khi xóa khỏi presence set. Thứ tự: lưu last seen → rồi mới xóa presence.

12

Status States & State Machine

Không phải chỉ có online/offline. Các ứng dụng như Slack phân biệt nhiều trạng thái hơn:

  • Online: active heartbeat, tương tác gần đây.
  • Away: kết nối nhưng idle (không có mouse/keyboard event trong N phút). Client tự detect và gửi signal status=away.
  • DND (Do Not Disturb): user tự bật — không muốn nhận notification. Server tôn trọng setting này khi route notification.
  • Offline: không có heartbeat / TTL expire.
VALID_STATUSES = {"online", "away", "dnd", "offline"}

def set_user_status(user_id: str, status: str):
    """
    User hoặc client set trạng thái.
    Offline không được set trực tiếp qua đây — xử lý bởi TTL / disconnect.
    """
    if status not in VALID_STATUSES:
        raise ValueError(f"Invalid status: {status}")

    key = f"presence:user:{user_id}"
    r.hset(key, mapping={
        "status": status,
        "last_seen": int(time.time()),
    })
    if status != "offline":
        r.expire(key, 120)  # TTL fallback
    else:
        r.delete(key)

def get_effective_status(user_id: str) -> str:
    """
    Trả về trạng thái hiển thị ra ngoài.
    DND: vẫn hiện là online với bạn bè (tùy app).
    Away: hiện là "vắng mặt" sau N phút idle.
    """
    data = r.hgetall(f"presence:user:{user_id}")
    if not data:
        return "offline"
    return data.get("status", "offline")

State transition phổ biến: offline → online khi connect; online → away khi idle > 10 phút (client-side idle detection); away → online khi có input; * → dnd khi user toggle; * → offline khi disconnect + TTL.

13

Multi-instance — Global Presence

Khi chạy nhiều server instance (horizontal scaling), presence phải nhất quán trên toàn cluster. Redis là tầng shared state duy nhất.

Không dùng Redis (in-memory per instance):
─────────────────────────────────────────────
  Instance 1: User A connected → local map["A"] = online
  Instance 2: User B queries A → local map["A"] = None → "A offline"?
  → SAI: B không biết A đang online trên instance khác

Dùng Redis làm shared state:
─────────────────────────────────────────────
  Instance 1: User A connected → ZADD presence:online A now
  Instance 2: User B queries A → ZSCORE presence:online A → có timestamp → ONLINE
  → ĐÚNG: mọi instance cùng nhìn vào Redis
# Kịch bản: User A connect instance 1, User B query từ instance 2
# Instance 1
def on_connect_instance1(user_id: str):
    heartbeat(user_id)  # ghi vào Redis — shared

# Instance 2
def query_presence_instance2(user_id: str) -> bool:
    return is_online(user_id)  # đọc từ Redis — nhất quán

Pub/Sub presence events cũng hoạt động across instances: event publish lên Redis, mọi subscriber trên mọi instance đều nhận — không cần thêm message broker riêng.

14

Scale: Triệu User Online

Một số con số để đánh giá khả năng scale:

  • Memory: Sorted Set với 10 triệu entry (user_id string ~10 bytes + score 8 bytes): ước tính ~600MB–1GB RAM. Nằm trong giới hạn của một Redis instance trung bình.
  • ZADD throughput: Redis đạt ~100.000–500.000 ZADD/s tùy hardware. Với 1 triệu user heartbeat mỗi 30s: 1.000.000 / 30 ≈ 33.000 ZADD/s — thoải mái.
  • ZRANGEBYSCORE (lấy danh sách online): O(log N + M) với N = tổng entry, M = số kết quả. Query "ai online trong 60s" với 10 triệu user nhưng chỉ 100.000 người đang online: nhanh.
# Cleanup định kỳ — quan trọng để giữ Sorted Set không tăng vô hạn
import schedule

def periodic_cleanup():
    removed = cleanup_stale(max_age=300)  # xóa entry > 5 phút
    print(f"Cleaned up {removed} stale presence entries")

# Chạy cleanup mỗi 5 phút
schedule.every(5).minutes.do(periodic_cleanup)

Không cleanup → Sorted Set tích lũy tất cả user từng online → memory tăng liên tục. Cleanup 5 phút một lần là đủ — không cần real-time.

Fan-out với user nhiều friends: User có 5.000 friends mỗi lần online tạo ra 5.000 PUBLISH. Nếu 10.000 user như vậy online cùng lúc: 50 triệu PUBLISH — có thể gây vấn đề. Giải pháp: giới hạn fan-out (chỉ push cho friends đang online), hoặc chuyển sang Streams cho fan-out durable hơn.

15

Privacy & User Setting

Người dùng cần quyền kiểm soát ai nhìn thấy trạng thái online của mình. Setting phổ biến:

def set_privacy(user_id: str, show_online: bool):
    """User tắt hiển thị online — lưu vào Hash settings."""
    r.hset(f"user:settings:{user_id}", "show_online", "1" if show_online else "0")

def get_visible_status(viewer_id: str, target_id: str) -> str:
    """
    Trả về status được phép hiển thị cho viewer.
    Nếu target tắt show_online → hiện "offline" với tất cả,
    trừ khi viewer là chính target.
    """
    if viewer_id == target_id:
        return get_effective_status(target_id)  # luôn thấy thật

    show_online = r.hget(f"user:settings:{target_id}", "show_online")
    if show_online == "0":
        return "offline"  # ẩn, không phải thật sự offline

    return get_effective_status(target_id)

Lưu ý: privacy setting cần được filter trước khi gửi presence event qua Pub/Sub — không gửi event tới những người không được phép thấy. Thực tế thường filter ở tầng application khi xây dựng fan-out list, không filter ở Redis.

16

Anti-patterns

  • In-memory presence trên mỗi server instance: user A connect instance 1, user B query trên instance 2 → B thấy A offline (sai). Phải dùng Redis làm shared state.
  • Quên TTL hoặc không cleanup: user crash không gửi logout → entry tồn tại mãi trong Sorted Set → ghost user "always online". Luôn kết hợp threshold check với cleanup định kỳ.
  • Heartbeat quá thường (mỗi 1–5s): 1 triệu user × 1 write/s = 1 triệu ZADD/s — không cần thiết và tốn tài nguyên. 30s là đủ cho trải nghiệm người dùng chấp nhận được.
  • Fan-out Pub/Sub toàn cầu cho hàng triệu user: một channel global với mọi consumer lọc client-side → bandwidth explosion. Dùng channel per user hoặc topic-based routing.
  • Không lưu last seen trước khi xóa presence: xóa entry khỏi Sorted Set trước rồi mới lưu last seen → có race condition. Thứ tự đúng: lưu last seen → xóa presence.
  • Không xử lý multi-device: user có 2 device, đóng 1 device → mark offline ngay → device kia vẫn còn nhưng UI hiện offline. Cần track device-level.
17

Best Practices

  • Heartbeat 30s + threshold 60–90s: balance giữa freshness và Redis write load. TTL trên key (nếu dùng Hash/Set) đặt ở 120s để có buffer.
  • Sorted Set với last seen score: một key duy nhất cho toàn bộ online users — efficient query, O(log N) write, O(log N + M) range query, dễ cleanup với ZREMRANGEBYSCORE.
  • Lưu last seen riêng, persist dài hạn: khi user disconnect, ghi HSET user:{id} last_seen timestamp trước khi xóa presence. Key này không có TTL hoặc TTL dài (7–30 ngày).
  • Fan-out tới friends only qua channel per user: publish vào presence:user:{friend_id} thay vì global channel. Mỗi frontend chỉ subscribe channel của chính nó.
  • Track device với Hash per user: lưu presence:devices:{user_id} Hash với {device_id: timestamp} — user online khi có ít nhất 1 device active.
  • Cleanup stale entries định kỳ: ZREMRANGEBYSCORE presence:online 0 {now-300} mỗi 5 phút — giữ Sorted Set ở kích thước hợp lý.
  • Kết hợp explicit logout + TTL fallback: explicit cho normal disconnect (instant), TTL cho crash/network drop (30–90s delay). Không chỉ dùng một trong hai.
  • Tôn trọng privacy setting trước khi fan-out: filter danh sách recipients theo setting show_online trước khi build fan-out list.
18

Tổng Kết & Quiz

Presence tracking cần kết hợp nhiều pattern Redis:

  • Sorted Set presence:online với score = timestamp → query online users theo khoảng thời gian, O(log N) write.
  • Heartbeat 30s từ client → server gọi ZADD → threshold check thay vì TTL trực tiếp.
  • TTL fallback (Hash/Set key expire) → bắt crash/network drop → detect trong 30–90s.
  • Set hoặc Hash per device → multi-device tracking → user offline chỉ khi mọi device ngừng.
  • Pub/Sub fan-out tới friends → real-time UI update → channel per user thay vì global.
  • Hash persist user:{id} last_seen → hiển thị "N phút trước" dài hạn.

Quiz

  1. Vì sao dùng Sorted Set thay vì Set thường cho presence:online? Lợi thế gì khi score là timestamp?
  2. User có 3 device (mobile, desktop, tablet). Desktop đóng tab. Làm sao biết user vẫn online qua 2 device còn lại?
  3. Client bị crash, không gửi logout. Hệ thống phát hiện offline sau bao lâu? Điều gì quyết định con số đó?
  4. Khi publish presence event vào channel global vs channel per user, trade-off là gì?
  5. Tại sao cần lưu last seen trước khi xóa entry presence, và thứ tự quan trọng như thế nào?

Đáp án gợi ý

  1. Set thường chỉ biết member có hay không (online/offline nhị phân). Sorted Set cho phép lưu timestamp làm score → query ZRANGEBYSCORE để lấy ai online trong N giây gần nhất, ZSCORE để biết last seen của cụ thể user, ZREMRANGEBYSCORE để cleanup hiệu quả.
  2. Dùng Hash per device presence:devices:{user_id} với {device_id: timestamp}. Khi desktop đóng: gọi HDEL presence:devices:{user_id} desktop_device_id hoặc để TTL + threshold check. is_online() kiểm tra có ít nhất 1 device có timestamp < 90s.
  3. Heartbeat gần nhất + threshold. Nếu heartbeat mỗi 30s và threshold là 60s: tối đa phát hiện sau 60s (một heartbeat lỡ). Với TTL 90s trên key: phát hiện sau 30–90s. Không thể instant vì không có signal từ client.
  4. Global channel: mọi consumer nhận mọi event, phải lọc client-side → tốn bandwidth và CPU khi scale. Channel per user (presence:user:{id}): chỉ consumer quan tâm user đó mới nhận → hiệu quả hơn, nhưng tốn nhiều PUBLISH call khi fan-out tới danh sách bạn bè dài.
  5. Nếu xóa presence trước: entry đã gone khỏi Sorted Set, không lấy được timestamp từ ZSCORE nữa. Cần lưu vào Hash persist trước để đảm bảo last seen không bị mất trong trường hợp server crash giữa chừng (xóa presence xong nhưng chưa kịp lưu last seen).

Bài tiếp theo

Bài 70 đi vào Typing Indicator — hiệu ứng "..." khi đối phương đang gõ trong chat. Pattern đơn giản hơn presence nhưng có yêu cầu latency và cleanup khắt khe hơn.

Tham khảo