Skip to content

11.9 - Presence - Online/Offline ​

Tóm tắt: Presence trông đơn giản — "ai đang online" — nhưng nó là trạng thái chia sẻ giữa các instance, nên cài sai là sai ở nhiều tầng cùng lúc. Trong bộ nhớ thì hỏng ngay khi có instance thứ hai: mỗi pod thấy một phần sự thật. Chỉ dựa vào OnDisconnectedAsync cũng hỏng: nó không chạy khi tiến trình bị kill, và người dùng đó "online mãi mãi". Lời giải là Redis với TTL — mỗi kết nối là một khoá tự hết hạn, được gia hạn định kỳ; nếu server chết thì khoá tự biến mất. Và nhớ: một người có nhiều kết nối, nên "offline" chỉ đúng khi kết nối cuối cùng đóng.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Giải thích vì sao presence trong bộ nhớ sai khi scale ngang.
  • Cài presence bằng Redis với TTL.
  • Xử lý đúng việc một người có nhiều kết nối.
  • Tránh bão sự kiện presence khi deploy.
  • Quyết định độ chính xác presence mà hệ thống thật sự cần.

Nội dung bài học ​

11.9.1 — Vì sao trong bộ nhớ không đủ ​

csharp
private static readonly ConcurrentDictionary<string, HashSet<string>> _connections = new();

Hai vấn đề, và cả hai đều nghiêm trọng:

Sai khi có nhiều instance. Người dùng A kết nối vào pod 1, người dùng B vào pod 2. Pod 1 hỏi "B có online không" và nhận false — dù B đang online.

Rò rỉ khi server chết. OnDisconnectedAsync không chạy nếu tiến trình bị kill (OOM, kubectl delete pod, crash). Những mục trong dictionary đó không bao giờ được dọn. Với dictionary static, chúng sống tới khi tiến trình khởi động lại — và người dùng đó hiện "online" mãi mãi.

Lỗi thứ hai xảy ra cả khi bạn chỉ có một instance, nên đây không chỉ là vấn đề của scale.

Ngoài ra, HashSet<string> trong ConcurrentDictionary không thread-safe — ConcurrentDictionary bảo vệ chính nó, không bảo vệ giá trị bên trong. Hai kết nối cùng lúc của một người có thể làm hỏng HashSet (bài 6.7).

11.9.2 — Redis với TTL ​

Ý tưởng: mỗi kết nối là một khoá tự hết hạn, được gia hạn định kỳ. Server chết thì khoá tự biến mất — không cần ai dọn.

csharp
public interface IPresenceTracker
{
    Task ConnectAsync(string userId, string connectionId, CancellationToken ct);
    Task DisconnectAsync(string userId, string connectionId, CancellationToken ct);
    Task<bool> IsOnlineAsync(string userId, CancellationToken ct);
    Task<IReadOnlyList<string>> GetOnlineAsync(string tenantId, CancellationToken ct);
    Task HeartbeatAsync(string userId, string connectionId, CancellationToken ct);
}

public sealed class RedisPresenceTracker(IConnectionMultiplexer redis) : IPresenceTracker
{
    private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(90);

    private static string ConnKey(string userId, string connectionId)
        => $"presence:{userId}:{connectionId}";

    private static string UserSet(string userId) => $"presence:user:{userId}";

    public async Task ConnectAsync(string userId, string connectionId, CancellationToken ct)
    {
        var db = redis.GetDatabase();

        // Khoá một kết nối — TỰ HẾT HẠN
        await db.StringSetAsync(ConnKey(userId, connectionId), "1", Ttl);

        // Tập kết nối của người dùng — cũng có TTL để không rò rỉ
        await db.SetAddAsync(UserSet(userId), connectionId);
        await db.KeyExpireAsync(UserSet(userId), Ttl);
    }

    public async Task DisconnectAsync(string userId, string connectionId, CancellationToken ct)
    {
        var db = redis.GetDatabase();
        await db.KeyDeleteAsync(ConnKey(userId, connectionId));
        await db.SetRemoveAsync(UserSet(userId), connectionId);
    }

    public async Task<bool> IsOnlineAsync(string userId, CancellationToken ct)
    {
        var db = redis.GetDatabase();
        var connectionIds = await db.SetMembersAsync(UserSet(userId));

        // Kiểm tra từng khoá — cái nào hết hạn thì coi như không còn
        foreach (var id in connectionIds)
        {
            if (await db.KeyExistsAsync(ConnKey(userId, id!)))
                return true;

            await db.SetRemoveAsync(UserSet(userId), id);    // don rac lazy
        }

        return false;
    }

    public Task HeartbeatAsync(string userId, string connectionId, CancellationToken ct)
        => redis.GetDatabase().KeyExpireAsync(ConnKey(userId, connectionId), Ttl);
}

Cấu trúc hai tầng — một khoá cho mỗi kết nối, một Set cho mỗi người dùng — cho phép TTL riêng cho từng kết nối trong khi vẫn liệt kê được các kết nối của một người.

TTL là lưới an toàn, không phải cơ chế chính. OnDisconnectedAsync vẫn dọn ngay khi đóng bình thường; TTL chỉ bắt những trường hợp nó không chạy.

Chọn TTL: phải dài hơn chu kỳ heartbeat đủ để chịu được một vài nhịp bị mất. Heartbeat 30 giây với TTL 90 giây cho phép mất 2 nhịp — cùng nguyên tắc với KeepAliveInterval và ClientTimeoutInterval (bài 11.2).

11.9.3 — Heartbeat ​

csharp
public sealed class PresenceHeartbeatWorker(
    IHubContext<NotificationHub, INotificationClient> hub,
    IPresenceTracker presence,
    IConnectionRegistry registry) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));

        while (await timer.WaitForNextTickAsync(ct))
        {
            // Gia hạn TTL cho mọi kết nối mà INSTANCE NÀY đang giữ
            foreach (var (userId, connectionId) in registry.GetLocalConnections())
                await presence.HeartbeatAsync(userId, connectionId, ct);
        }
    }
}

Instance tự gia hạn cho những kết nối nó đang giữ. Instance chết là không ai gia hạn, và sau tối đa 90 giây những kết nối đó tự biến mất khỏi Redis.

Đây là điểm mấu chốt: không có bước dọn dẹp nào cần chạy, không có danh sách nào cần đối chiếu. Hệ thống tự sửa trong vòng một TTL.

Sơ đồ presence bằng Redis. Instance A ghi hai loại khoá vào Redis khi có kết nối: thêm connectionId vào Set presence:user:userId và tạo khoá presence:userId:connId, cả hai có TTL 90 giây. PresenceHeartbeatWorker của instance gia hạn TTL cho các kết nối nó đang giữ mỗi 30 giây. Instance B bị kill thì OnDisconnected không chạy và không ai gia hạn, nên khoá của nó tự biến mất sau tối đa 90 giây. IsOnlineAsync đọc Set rồi kiểm tra từng khoá còn sống, khoá đã hết hạn thì gỡ khỏi Set; người dùng online khi còn ít nhất một khoá. UserPresenceChanged chỉ phát tới nhóm tenant khi trạng thái đổi giữa offline và online, nên ba tab vẫn chỉ một sự kiện online.Sơ đồ presence bằng Redis. Instance A ghi hai loại khoá vào Redis khi có kết nối: thêm connectionId vào Set presence:user:userId và tạo khoá presence:userId:connId, cả hai có TTL 90 giây. PresenceHeartbeatWorker của instance gia hạn TTL cho các kết nối nó đang giữ mỗi 30 giây. Instance B bị kill thì OnDisconnected không chạy và không ai gia hạn, nên khoá của nó tự biến mất sau tối đa 90 giây. IsOnlineAsync đọc Set rồi kiểm tra từng khoá còn sống, khoá đã hết hạn thì gỡ khỏi Set; người dùng online khi còn ít nhất một khoá. UserPresenceChanged chỉ phát tới nhóm tenant khi trạng thái đổi giữa offline và online, nên ba tab vẫn chỉ một sự kiện online.

File gốc: nền sáng · nền tối

11.9.4 — Một người, nhiều kết nối ​

csharp
public override async Task OnConnectedAsync()
{
    var userId = Context.UserIdentifier!;

    var wasOffline = !await _presence.IsOnlineAsync(userId, default);
    await _presence.ConnectAsync(userId, Context.ConnectionId, default);

    // CHỈ báo khi chuyển từ offline sang online
    if (wasOffline)
        await Clients.Group(GroupNames.Tenant(tenantId)).UserPresenceChanged(userId, true);

    await base.OnConnectedAsync();
}

public override async Task OnDisconnectedAsync(Exception? exception)
{
    var userId = Context.UserIdentifier!;

    await _presence.DisconnectAsync(userId, Context.ConnectionId, default);

    // CHỈ báo khi KHÔNG CÒN kết nối nào
    if (!await _presence.IsOnlineAsync(userId, default))
        await Clients.Group(GroupNames.Tenant(tenantId)).UserPresenceChanged(userId, false);

    await base.OnDisconnectedAsync(exception);
}

Không có hai lần kiểm tra này, người dùng mở 3 tab sẽ tạo 3 sự kiện "online", và đóng một tab tạo một sự kiện "offline" sai trong khi họ vẫn còn 2 tab mở.

Vẫn còn một race condition: hai tab đóng gần như đồng thời, cả hai cùng thấy "không còn kết nối nào" và cùng phát sự kiện offline. Với presence thì hai sự kiện trùng nhau vô hại, nhưng nếu nó kích hoạt một tác dụng phụ (gửi email "người dùng đã offline") thì cần khử trùng.

11.9.5 — Bão sự kiện khi deploy ​

Mỗi lần triển khai, mọi kết nối đứt và kết nối lại. Với 5.000 người dùng:

5.000 su kien offline trong vai giay
5.000 su kien online ngay sau do
=> 10.000 lan broadcast toi nhom tenant
=> Moi lan broadcast lai qua backplane

Nó có thể làm quá tải Redis đúng lúc hệ thống đang khởi động lại — thời điểm tệ nhất.

Hai cách giảm:

1. Hoãn sự kiện offline. Người dùng kết nối lại trong 10 giây thì không phát gì cả:

csharp
// Thay vi bao offline ngay
await _presence.ScheduleOfflineCheckAsync(userId, TimeSpan.FromSeconds(10), ct);

// Sau 10 giây, kiểm tra lại; còn online thì bỏ qua

Cách này còn cải thiện trải nghiệm: người dùng mạng chập chờn không nhấp nháy online/offline liên tục.

2. Gom sự kiện. Thay vì broadcast từng người, gửi danh sách thay đổi mỗi 5 giây:

csharp
await Clients.Group(tenantGroup).PresenceBatchChanged(changes);

Với nhóm lớn, cách 2 giảm số lần qua backplane từ hàng nghìn xuống vài chục.

11.9.6 — Độ chính xác bạn thật sự cần ​

Yêu cầuCách làm
"Ai đang xem hồ sơ này"Presence theo nhóm, chính xác vừa phải, TTL 60–90 giây
Chấm ngắt kết nối chính xácKhông dùng presence — dùng sự kiện có ghi log
"Hoạt động lần cuối lúc nào"Cột LastSeenAt trong database, cập nhật theo lô
Chỉ báo online trong chatPresence với TTL ngắn hơn, 30–45 giây

Hàng thứ hai quan trọng: presence không đủ tin cậy cho bất cứ thứ gì có hậu quả nghiệp vụ. Mạng hỏng, TTL hết hạn, deploy — tất cả đều tạo "offline" giả. Đừng dùng nó để tính giờ làm việc hay tính tiền.

Với "hoạt động lần cuối", cập nhật một cột trong database theo lô (mỗi 5 phút) rẻ hơn nhiều so với giữ presence thời gian thực, và đủ chính xác cho mục đích hiển thị.

11.9.7 — Rà lại code của bạn ​

Danh sách rà soát presence

  • Presence lưu ở kho dùng chung, không phải dictionary static trong tiến trình.
  • Mỗi kết nối có TTL tự hết hạn làm lưới an toàn.
  • TTL dài hơn chu kỳ heartbeat đủ để chịu vài nhịp bị mất.
  • Có worker gia hạn TTL cho các kết nối của instance hiện tại.
  • Sự kiện online chỉ phát khi chuyển từ offline sang online.
  • Sự kiện offline chỉ phát khi kết nối cuối cùng đóng.
  • Có cơ chế hoãn sự kiện offline để tránh nhấp nháy khi mạng chập chờn.
  • Sự kiện presence được gom lô cho nhóm lớn.
  • Presence không được dùng cho bất kỳ tính toán nghiệp vụ nào.
  • Hoạt động lần cuối lưu ở database theo lô, không giữ thời gian thực.

Bài tập áp dụng ​

Bài 1 — Presence lưu trong bộ nhớ tiến trình ​

Dùng dictionary trong bộ nhớ, kết nối vài client rồi kill tiến trình bằng SIGKILL. Khởi động lại và quan sát. Sau đó giải thích vì sao trên production vấn đề lại khác.

Tiêu chí hoàn thành: bạn nêu được vì sao restart không phải tình huống tệ nhất của cách lưu này.

Gợi ý và lời giải — Bài 1

Gợi ý. Mất sạch dữ liệu nghe có vẻ tệ, nhưng ít nhất nó nhất quán. Hãy nghĩ tới trường hợp dữ liệu còn lại nhưng sai.

Lời giải — bản trong bộ nhớ:

csharp
public sealed class PresenceTracker
{
    private static readonly ConcurrentDictionary<string, HashSet<string>> _online = new();

    public void Connect(string userId, string connectionId) =>
        _online.AddOrUpdate(userId, [connectionId], (_, set) => { set.Add(connectionId); return set; });

    public void Disconnect(string userId, string connectionId)
    {
        if (_online.TryGetValue(userId, out var set))
        {
            set.Remove(connectionId);
            if (set.Count == 0) _online.TryRemove(userId, out _);
        }
    }
}
bash
# Kết nối 3 client rồi kill
kill -9 $(pgrep -f Crm.Api)
dotnet run
bash
curl -s .../api/v1/presence/online | jq length
# 0

Mất sạch — nhưng đây chưa phải vấn đề thật. Client tự kết nối lại sau vài giây, OnConnectedAsync chạy lại, và danh sách được dựng lại đúng. Trạng thái sai chỉ tồn tại trong khoảng thời gian rất ngắn, và nó sai theo hướng an toàn: báo offline nhầm rồi tự đúng lại.

Hai tình huống tệ hơn hẳn:

1. Nhiều instance — mỗi cái thấy một phần sự thật.

text
Instance A: giữ kết nối của An, Bình      -> _online = { An, Bình }
Instance B: giữ kết nối của Cường          -> _online = { Cường }

An hỏi "ai đang online?" -> request rơi vào A -> trả về [An, Bình]
An hỏi lại               -> request rơi vào B -> trả về [Cường]

Danh sách đổi mỗi lần tải lại trang, tuỳ vào pod nào nhận request. Đây không phải mất dữ liệu — nó là dữ liệu sai một cách không ổn định, thứ rất khó gỡ rối vì không tái hiện được.

2. OnDisconnectedAsync không chạy khi mạng đứt đột ngột. Người dùng rút mạng, đóng nắp laptop, hoặc bị mất sóng: kết nối chết nhưng server không nhận được gói đóng nào. Bản ghi nằm lại trong dictionary cho tới khi timeout phát hiện — và nếu tiến trình có lỗi ở nhánh dọn dẹp thì nằm lại vĩnh viễn.

Kết quả là danh sách online tích luỹ dần những người đã rời đi từ lâu. Nó không tự sửa, vì không có gì ép nó sửa.

Cách đúng — Redis với TTL, để dữ liệu tự hết hạn:

csharp
public async Task ConnectAsync(string userId, string connectionId)
{
    var db = _redis.GetDatabase();

    // Khoá một kết nối — TỰ HẾT HẠN
    await db.StringSetAsync($"conn:{connectionId}", userId, TimeSpan.FromSeconds(90));

    // Tập kết nối của người dùng — cũng có TTL để không rò rỉ
    await db.SetAddAsync($"user:{userId}:conns", connectionId);
    await db.KeyExpireAsync($"user:{userId}:conns", TimeSpan.FromHours(24));
}
csharp
// Gia hạn TTL cho mọi kết nối mà INSTANCE NÀY đang giữ
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(ct))
    foreach (var id in _localConnections)
        await db.KeyExpireAsync($"conn:{id}", TimeSpan.FromSeconds(90));

TTL giải quyết cả ba vấn đề cùng lúc:

Vấn đềTTL xử lý thế nào
Tiến trình chếtKhoá hết hạn sau 90 giây, tự biến mất
Nhiều instanceMọi instance đọc cùng một Redis
OnDisconnectedAsync không chạyKhông gia hạn nữa là tự hết hạn

Nguyên tắc rút ra: với trạng thái phản ánh một kết nối đang sống, hãy chọn cơ chế tự hết hạn thay vì cơ chế phải dọn tay. Dọn tay chỉ đúng khi mọi đường thoát đều chạy — mà mạng thì không bảo đảm điều đó.


Bài 2 — Ba tab, một người, và những sự kiện thừa ​

Mở 3 tab, đóng một tab, và đếm số sự kiện UserPresenceChanged mà các client khác nhận. Thêm hai lần kiểm tra ở mục 11.9.4 và kiểm chứng lại.

Tiêu chí hoàn thành: bạn nêu được hai điều kiện phải kiểm, và vì sao thiếu một trong hai là sai.

Gợi ý và lời giải — Bài 2

Gợi ý. "Online" là trạng thái của người, còn kết nối là của tab. Một người có nhiều tab.

Lời giải — bản phát sự kiện vô điều kiện:

csharp
public override async Task OnConnectedAsync()
{
    await _presence.ConnectAsync(UserId, Context.ConnectionId);
    await Clients.Others.UserPresenceChanged(UserId, true);      // luôn phát
    await base.OnConnectedAsync();
}

public override async Task OnDisconnectedAsync(Exception? ex)
{
    await _presence.DisconnectAsync(UserId, Context.ConnectionId);
    await Clients.Others.UserPresenceChanged(UserId, false);     // luôn phát
    await base.OnDisconnectedAsync(ex);
}
text
Mở tab 1 -> UserPresenceChanged(An, true)
Mở tab 2 -> UserPresenceChanged(An, true)      <- thừa
Mở tab 3 -> UserPresenceChanged(An, true)      <- thừa
Đóng tab 1 -> UserPresenceChanged(An, false)   <- SAI, An vẫn còn 2 tab

Sự kiện cuối là lỗi thật: những người khác thấy An chuyển sang offline trong khi An vẫn đang dùng.

Bản sửa — hai lần kiểm tra:

csharp
public override async Task OnConnectedAsync()
{
    var isFirst = await _presence.ConnectAsync(UserId, Context.ConnectionId);

    // CHỈ báo khi chuyển từ offline sang online
    if (isFirst)
        await Clients.Others.UserPresenceChanged(UserId, true);

    await base.OnConnectedAsync();
}

public override async Task OnDisconnectedAsync(Exception? ex)
{
    var isLast = await _presence.DisconnectAsync(UserId, Context.ConnectionId);

    // CHỈ báo khi KHÔNG CÒN kết nối nào
    if (isLast)
        await Clients.Others.UserPresenceChanged(UserId, false);

    await base.OnDisconnectedAsync(ex);
}
csharp
public async Task<bool> ConnectAsync(string userId, string connectionId)
{
    var db = _redis.GetDatabase();
    var countBefore = await db.SetLengthAsync($"user:{userId}:conns");
    await db.SetAddAsync($"user:{userId}:conns", connectionId);
    return countBefore == 0;                    // đây là kết nối ĐẦU TIÊN
}

public async Task<bool> DisconnectAsync(string userId, string connectionId)
{
    var db = _redis.GetDatabase();
    await db.SetRemoveAsync($"user:{userId}:conns", connectionId);
    return await db.SetLengthAsync($"user:{userId}:conns") == 0;   // kết nối CUỐI
}
text
Mở tab 1 -> UserPresenceChanged(An, true)
Mở tab 2 -> (im lặng)
Mở tab 3 -> (im lặng)
Đóng tab 1 -> (im lặng)
Đóng tab 2 -> (im lặng)
Đóng tab 3 -> UserPresenceChanged(An, false)

Hai điều kiện, và vì sao cần cả hai:

KiểmNếu thiếu
isFirst khi kết nốiSự kiện thừa — mỗi tab một lần, client khác cập nhật lại giao diện vô ích
isLast khi ngắtSự kiện sai — báo offline trong khi người dùng vẫn đang online

Thiếu cái đầu chỉ gây lãng phí. Thiếu cái sau gây thông tin sai, và người dùng nhìn thấy nó ngay.

Một cái bẫy về tính nguyên tử. Đoạn ConnectAsync ở trên đọc số lượng rồi mới thêm — hai bước riêng. Hai tab mở gần như đồng thời có thể cùng thấy countBefore == 0 và cùng phát sự kiện. Dùng giá trị trả về của chính lệnh thêm sẽ nguyên tử:

csharp
var added = await db.SetAddAsync($"user:{userId}:conns", connectionId);
var count = await db.SetLengthAsync($"user:{userId}:conns");
return added && count == 1;

Hoặc chắc chắn hơn nữa thì gói cả hai bước vào một Lua script — Redis chạy script nguyên tử.

Và nhớ rằng OnDisconnectedAsync không phải lúc nào cũng chạy. Với mạng đứt đột ngột, sự kiện offline chỉ đến khi TTL hết hạn. Nên giao diện nên hiển thị "hoạt động 2 phút trước" thay vì một chấm xanh tuyệt đối — trạng thái gần đúng và thành thật thì tốt hơn trạng thái chính xác giả tạo.


Bài 3 — Bão sự kiện presence khi deploy ​

Tạo 300 kết nối, restart server, và đếm số sự kiện presence phát ra cùng lưu lượng Redis. Thêm hoãn 10 giây và so sánh.

Tiêu chí hoàn thành: bạn tính được số sự kiện theo công thức, và nêu được vì sao hoãn lại giải quyết được.

Gợi ý và lời giải — Bài 3

Gợi ý. Mỗi người rời đi được báo cho mọi người còn lại. Đó là một phép nhân, không phải phép cộng.

Lời giải — đo khi restart:

bash
# 300 kết nối đang mở
kubectl rollout restart deployment/crm-api
text
Sự kiện offline: 300 người × 299 người nhận = 89.700 tin
Sự kiện online:  300 người × 299 người nhận = 89.700 tin
Tổng: ~180.000 tin trong khoảng 20 giây

Lưu lượng Redis backplane: tăng vọt
CPU server: 88% trong lúc rollout

Công thức: với N kết nối, mỗi lần restart sinh ra 2 × N × (N−1) tin — tăng theo bình phương số người dùng.

NSố tin mỗi lần restart
100~19.800
300~179.400
1.000~2.000.000

Với 1.000 người dùng, một lần deploy thường lệ tạo ra hai triệu tin — đủ để làm nghẽn backplane và khiến chính đợt deploy trông như một sự cố.

Và phần lớn số tin đó là vô nghĩa. Người dùng không rời đi thật; họ mất kết nối vài giây rồi quay lại. Giao diện của mọi người nhấp nháy đỏ rồi xanh trở lại.

Bản sửa — hoãn trước khi báo offline:

csharp
public override async Task OnDisconnectedAsync(Exception? ex)
{
    var isLast = await _presence.DisconnectAsync(UserId, Context.ConnectionId);

    if (isLast)
    {
        var userId = UserId;

        _ = Task.Run(async () =>
        {
            await Task.Delay(TimeSpan.FromSeconds(10));

            // Sau 10 giây, kiểm tra lại; còn online thì bỏ qua
            if (!await _presence.IsOnlineAsync(userId))
                await _hub.Clients.All.UserPresenceChanged(userId, false);
        });
    }

    await base.OnDisconnectedAsync(ex);
}
text
Restart với hoãn 10 giây:
  300 kết nối đứt -> 300 tác vụ hoãn được hẹn
  ~5 giây sau, client tự kết nối lại
  10 giây sau, kiểm tra lại: cả 300 đều ONLINE -> không phát tin nào

Tổng sự kiện phát ra: 0
CPU server trong lúc rollout: 24%

Vì sao hoãn giải quyết được. Nó tách hai tình huống mà OnDisconnectedAsync không phân biệt nổi:

text
Mất kết nối tạm thời (deploy, wifi chập chờn) -> quay lại trong vài giây
Rời đi thật (đóng tab, tắt máy)                -> không quay lại

Chờ 10 giây rồi kiểm tra lại là cách rẻ nhất để phân biệt. Cái giá là người rời đi thật bị hiển thị online thêm 10 giây — hoàn toàn chấp nhận được với một chỉ báo presence.

Ba biện pháp bổ sung đáng cân nhắc:

  1. Đừng gửi cho Clients.All. Chỉ gửi cho người thật sự quan tâm — cùng phòng, cùng deal đang mở. Với nhóm 10 người thay vì 300, phép nhân nhỏ đi rất nhiều.

  2. Gom thay đổi thành một tin. Thay vì 300 tin riêng, gửi một tin mang danh sách:

    csharp
    await _hub.Clients.All.PresenceBatchChanged(changedUsers);
  3. Để client tự hỏi thay vì đẩy. Với presence, một endpoint GET /presence/online mà client gọi mỗi 30 giây thường rẻ hơn hẳn việc đẩy — vì chi phí không tăng theo bình phương.

Một cảnh báo nên đặt: số sự kiện presence mỗi phút. Nếu nó tăng vọt ngoài giờ deploy, gần như chắc chắn có kết nối đang chập chờn — thứ đáng điều tra trước khi người dùng phàn nàn.

Tự kiểm tra ​

Vì sao presence trong bộ nhớ không đủ?

Hai lý do. Với nhiều instance thì mỗi pod chỉ thấy một phần sự thật, nên hỏi ai đó có online không sẽ nhận câu trả lời sai. Và OnDisconnectedAsync không chạy khi tiến trình bị kill, nên những mục trong dictionary không bao giờ được dọn và người dùng đó hiện online mãi mãi; lỗi này xảy ra cả khi chỉ có một instance.

TTL trong Redis giải quyết gì?

Mỗi kết nối là một khoá tự hết hạn được gia hạn định kỳ bởi chính instance đang giữ nó. Instance chết là không ai gia hạn, và sau một TTL những kết nối đó tự biến mất. Hệ thống tự sửa mà không cần bước dọn dẹp hay danh sách đối chiếu nào.

Chọn TTL và chu kỳ heartbeat thế nào?

TTL phải dài hơn chu kỳ heartbeat đủ để chịu được vài nhịp bị mất, ví dụ heartbeat 30 giây với TTL 90 giây cho phép mất hai nhịp. Đây là cùng nguyên tắc với KeepAliveInterval và ClientTimeoutInterval của SignalR.

Vì sao phải kiểm tra hai lần khi phát sự kiện presence?

Vì một người có nhiều kết nối. Không kiểm tra thì mở 3 tab tạo 3 sự kiện online, và đóng một tab tạo một sự kiện offline sai trong khi họ vẫn còn 2 tab. Chỉ phát online khi chuyển từ offline sang online, và chỉ phát offline khi kết nối cuối cùng đóng.

Bão sự kiện presence khi deploy là gì?

Mỗi lần triển khai thì mọi kết nối đứt và kết nối lại, nên với 5.000 người dùng là 10.000 lần broadcast qua backplane trong vài giây, đúng lúc hệ thống đang khởi động lại. Giảm bằng cách hoãn sự kiện offline khoảng 10 giây, và gom sự kiện thành lô cho nhóm lớn.

Presence có dùng được cho tính toán nghiệp vụ không?

Không. Mạng hỏng, TTL hết hạn và deploy đều tạo ra trạng thái offline giả, nên nó không đủ tin cậy để tính giờ làm việc hay tính tiền. Với hoạt động lần cuối thì cập nhật một cột trong database theo lô rẻ hơn nhiều và đủ chính xác để hiển thị.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. TTL là lưới an toàn cho presence. OnDisconnectedAsync không chạy khi tiến trình bị kill.
  2. Offline chỉ đúng khi kết nối cuối cùng đóng.
  3. Presence không đủ tin cậy cho nghiệp vụ. Đừng dùng nó để tính giờ hay tính tiền.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.