在线人数实时推送?WebSocket 太重,SSE 正合适 🎯🎯🎯

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777

有些项目要统计在线人数,其实更多是为了"营造热闹气氛"。比如你进个聊天室,看到"有 120 人在看",是不是感觉这个地方挺活跃的?这就是一种"社交证明",让用户觉得:哇,这个地方挺火,值得留下来。而且对产品来说,这也能提高用户的参与感和粘性。

有哪些实现方式?为啥我最后选了 SSE?

在考虑怎么实现"统计在线人数并实时显示"这个功能时,其实我一开始也没直接想到要用 SSE。毕竟实现方式有好几种,咱们不妨一步步分析一下常见的几种做法,看看它们各自的优缺点,这样最后为啥选 SSE,自然就水落石出了。

❌ 第一种想法:轮询(Polling)

最容易想到的方式就是:我定时去问服务器,"现在有多少人在线?"

比如用 setInterval 每隔 3 秒发一次 AJAX 请求,服务器返回一个数字,前端拿到之后更新页面。

听起来没毛病,对吧?确实简单,写几行代码就能跑起来。

但问题也很快暴露了:

  • 就算在线人数 10 分钟都没变,客户端也在一直请求,完全没必要,非常低效

  • 这种方式根本做不到真正的"实时",除非你每秒钟请求一次(但那样服务器压力就爆炸了)

  • 每个用户都发请求,这压力不是乘以用户数么?人一多,服务器直接"变卡"

所以轮询虽然简单,但在"实时在线人数"这种场景下,不管性能、实时性还是用户体验,都不够理想

❌ 第二种方案:WebSocket

再往上一个层级,很多人就会想到 WebSocket,这是一个可以实现双向通信的技术,听起来非常高级。

确实,WebSocket 特别适合聊天室、游戏、协同编辑器这种实时互动场景------客户端和服务端随时可以互相发消息,效率高、延迟低。

但问题也来了:我们真的需要那么重的武器吗?

  • 我只是要服务器把"当前在线人数"这个数字发给页面,不需要客户端发什么消息回去

  • WebSocket 的连接、心跳、断线重连、资源管理......这套机制确实强大,但同时也让开发复杂度和服务器资源占用都提高了不少

  • 而且你要部署 WebSocket 服务的话,很多时候还得考虑反向代理支持、跨域、协议升级等问题

总结一句话:WebSocket 能干的活太多,反而不适合干这么简单的一件事

✅ 最后选择:SSE(Server-Sent Events)

然后我就想到了 SSE。

SSE 是 HTML5 提供的一个非常适合"服务端单向推送消息"的方案,浏览器用 EventSource 这个对象就能轻松建立连接,服务端只需要按照特定格式往客户端写数据就能实时推送,非常简单、非常轻量。

对于"统计在线人数"这种场景来说,它刚好满足所有需求:

  • 客户端不需要发消息,只要能"听消息"就够了 ------ SSE 就是只读的推送流,正合适

  • 我只需要服务端一有变化(比如某个用户断开连接),就通知所有人当前在线人数是多少 ------ SSE 的广播机制就很好实现这一点

  • 而且浏览器断线后会自动重连,你不需要写额外的心跳或者重连逻辑,直接爽用

  • 它用的是普通的 HTTP 协议,部署和 Nginx 配合也没啥问题

当然它也不是没有缺点,比如 IE 不支持(但现在谁还用 IE 啊),以及它是单向通信(不过我们压根也不需要双向)。

所以综合来看,SSE 就是这个功能的"刚刚好"方案:轻量、简单、稳定、足够用。

项目实战

首先我们先贴上后端的代码,后端我们使用的是 NextJs 提供的 API 来实现的后端接口,首先我们先来看看我们的辅助方法:

ts 复制代码
// 单例模式实现的在线用户计数器
// 使用Symbol确保私有属性
const _connections = Symbol("connections");
const _clients = Symbol("clients");
const _lastCleanup = Symbol("lastCleanup");
const _maxInactiveTime = Symbol("maxInactiveTime");
const _connectionTimes = Symbol("connectionTimes");

// 创建一个单例计数器
class ConnectionCounter {
  private static instance: ConnectionCounter;
  private [_connections]: number = 0;
  private [_clients]: Set<(count: number) => void> = new Set();
  private [_lastCleanup]: number = Date.now();
  // 默认10分钟未活动的连接将被清理
  private [_maxInactiveTime]: number = 10 * 60 * 1000;
  // 跟踪连接ID和它们的最后活动时间
  private [_connectionTimes]: Map<string, number> = new Map();

  private constructor() {
    // 防止外部直接实例化
  }

  // 获取单例实例
  public static getInstance(): ConnectionCounter {
    if (!ConnectionCounter.instance) {
      ConnectionCounter.instance = new ConnectionCounter();
    }
    return ConnectionCounter.instance;
  }

  // 生成唯一连接ID
  generateConnectionId(): string {
    return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
  }

  // 添加新连接
  addConnection(connectionId: string): number {
    this[_connectionTimes].set(connectionId, Date.now());
    this[_connections]++;
    this.broadcastCount();

    // 如果活跃连接超过100或上次清理已经超过5分钟,执行清理
    if (
      this[_connectionTimes].size > 100 ||
      Date.now() - this[_lastCleanup] > 5 * 60 * 1000
    ) {
      this.cleanupStaleConnections();
    }

    return this[_connections];
  }

  // 移除连接
  removeConnection(connectionId: string): number {
    // 如果连接ID存在则移除
    if (this[_connectionTimes].has(connectionId)) {
      this[_connectionTimes].delete(connectionId);
      this[_connections] = Math.max(0, this[_connections] - 1);
      this.broadcastCount();
    }
    return this[_connections];
  }

  // 更新连接的活动时间
  updateConnectionActivity(connectionId: string): void {
    if (this[_connectionTimes].has(connectionId)) {
      this[_connectionTimes].set(connectionId, Date.now());
    }
  }

  // 清理长时间不活跃的连接
  cleanupStaleConnections(): void {
    const now = Date.now();
    this[_lastCleanup] = now;

    let removedCount = 0;
    this[_connectionTimes].forEach((lastActive, connectionId) => {
      if (now - lastActive > this[_maxInactiveTime]) {
        this[_connectionTimes].delete(connectionId);
        removedCount++;
      }
    });

    if (removedCount > 0) {
      this[_connections] = Math.max(0, this[_connections] - removedCount);
      this.broadcastCount();
      console.log(`Cleaned up ${removedCount} stale connections`);
    }
  }

  // 获取当前连接数
  getConnectionCount(): number {
    return this[_connections];
  }

  // 订阅计数更新
  subscribeToUpdates(callback: (count: number) => void): () => void {
    this[_clients].add(callback);
    // 立即返回当前计数
    callback(this[_connections]);

    // 返回取消订阅函数
    return () => {
      this[_clients].delete(callback);
    };
  }

  // 广播计数更新到所有客户端
  private broadcastCount(): void {
    this[_clients].forEach((callback) => {
      try {
        callback(this[_connections]);
      } catch (e) {
        // 如果回调失败,从集合中移除
        this[_clients].delete(callback);
      }
    });
  }
}

// 导出便捷函数
const counter = ConnectionCounter.getInstance();

export function createConnection(): string {
  const connectionId = counter.generateConnectionId();
  counter.addConnection(connectionId);
  return connectionId;
}

export function closeConnection(connectionId: string): number {
  return counter.removeConnection(connectionId);
}

export function pingConnection(connectionId: string): void {
  counter.updateConnectionActivity(connectionId);
}

export function getConnectionCount(): number {
  return counter.getConnectionCount();
}

export function subscribeToCountUpdates(
  callback: (count: number) => void
): () => void {
  return counter.subscribeToUpdates(callback);
}

// 导出实例供直接使用
export const connectionCounter = counter;

这段代码其实就是做了一件事:统计当前有多少个用户在线 ,而且可以实时推送到前端 。我们用了一个"单例"模式,也就是整个服务里只有一个 ConnectionCounter 实例,避免多人连接时出现数据错乱。每当有新用户连上 SSE 的时候,就会生成一个唯一的连接 ID,然后调用 createConnection() 把它加进来,在线人数就 +1。

这些连接 ID 都会被记录下来,还会记住"最后活跃时间"。如果用户一直在线,我们就可以通过前端发个心跳(pingConnection())来告诉后端"我还在哦"。断开连接的时候(比如用户关闭了页面),我们就通过 closeConnection() 把它移除,人数就 -1。

为了防止有些用户没正常断开(比如突然关机了),代码里还有一个"自动清理机制",默认 10 分钟没动静的连接就会被清理掉。每次人数变化的时候,这个计数器会"广播"一下,通知所有订阅它的人说:"嘿,在线人数变啦!"

而这个订阅机制(subscribeToCountUpdates())特别关键------它可以让我们在 SSE 里实时推送人数更新,前端只要监听着,就能第一时间看到最新的在线人数。我们还把常用的操作都封装好了,比如 createConnection()getConnectionCount() 等,让整个流程特别容易集成。

总结一下:这段逻辑就是 自动统计在线人数 + 自动清理无效连接 + 实时推送更新

接下来我们编写后端 SSE 接口,如下代码所示:

ts 复制代码
import {
  createConnection,
  closeConnection,
  pingConnection,
  subscribeToCountUpdates,
} from "./counter";

export async function GET() {
  // 标记连接是否仍然有效
  let connectionClosed = false;

  // 为此连接生成唯一ID
  const connectionId = createConnection();

  // 当前连接的计数更新回调
  let countUpdateUnsubscribe: (() => void) | null = null;

  // 使用Next.js的流式响应处理
  return new Response(
    new ReadableStream({
      start(controller) {
        const encoder = new TextEncoder();

        // 安全发送数据函数
        const safeEnqueue = (data: string) => {
          if (connectionClosed) return;
          try {
            controller.enqueue(encoder.encode(data));
          } catch (error) {
            console.error("SSE发送错误:", error);
            connectionClosed = true;
            cleanup();
          }
        };

        // 定义interval引用
        let heartbeatInterval: NodeJS.Timeout | null = null;
        let activityPingInterval: NodeJS.Timeout | null = null;

        // 订阅在线用户计数更新
        countUpdateUnsubscribe = subscribeToCountUpdates((count) => {
          if (!connectionClosed) {
            try {
              safeEnqueue(
                `data: ${JSON.stringify({ onlineUsers: count })}\n\n`
              );
            } catch (error) {
              console.error("发送在线用户数据错误:", error);
            }
          }
        });

        // 清理所有资源
        const cleanup = () => {
          if (heartbeatInterval) clearInterval(heartbeatInterval);
          if (activityPingInterval) clearInterval(activityPingInterval);

          // 取消订阅计数更新
          if (countUpdateUnsubscribe) {
            countUpdateUnsubscribe();
            countUpdateUnsubscribe = null;
          }

          // 如果连接尚未计数为关闭,则减少连接计数
          if (!connectionClosed) {
            closeConnection(connectionId);
            connectionClosed = true;
          }

          // 尝试安全关闭控制器
          try {
            controller.close();
          } catch (e) {
            // 忽略关闭时的错误
          }
        };

        // 设置15秒的心跳间隔,避免连接超时
        heartbeatInterval = setInterval(() => {
          if (connectionClosed) {
            cleanup();
            return;
          }
          safeEnqueue(": heartbeat\n\n");
        }, 15000);

        // 每分钟更新一次连接活动时间
        activityPingInterval = setInterval(() => {
          if (connectionClosed) {
            cleanup();
            return;
          }
          pingConnection(connectionId);
        }, 60000);
      },
      cancel() {
        // 当流被取消时调用(客户端断开连接)
        if (!connectionClosed) {
          closeConnection(connectionId);
          connectionClosed = true;

          if (countUpdateUnsubscribe) {
            countUpdateUnsubscribe();
          }
        }
      },
    }),
    {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
        "Access-Control-Allow-Origin": "*",
        "X-Accel-Buffering": "no", // 适用于某些代理服务器如Nginx
      },
    }
  );
}

这段代码是一个 Next.js 的 API 路由,用来建立一个 SSE 长连接 ,并把"当前在线人数"实时推送给客户端

第一步就是建立连接并注册计数当客户端发起请求时,后端会:

  • 调用 createConnection() 生成一个唯一的连接 ID;
  • 把这次连接计入在线用户总数里;
  • 并返回一个 ReadableStream,让服务端能不断往客户端"推送消息"。

第二步就是订阅在线人数变化,一旦连接建立,服务端就调用 subscribeToCountUpdates(),订阅在线人数的变化。一旦总人数发生变化,它就会通过 SSE 推送这样的数据给前端:

ts 复制代码
data: {
  onlineUsers: 23;
}

也就是说,每次有人连上或断开,所有前端都会收到更新,非常适合"在线人数展示"。

第三步就是定期心跳和活跃检测:

  • 每 15 秒服务端会发一个 : heartbeat,保持连接不断开;

  • 每 60 秒调用 pingConnection(),告诉后台"我还活着",防止被误判为不活跃连接而清理。

第四步是清理逻辑,当连接被取消(比如用户关闭页面)或出错时,后台会:

  • 调用 closeConnection() 把这条连接从统计中移除;

  • 取消掉在线人数的订阅;

  • 停掉心跳和活跃检测定时器;

  • 安全关闭这个数据流。

这个清理逻辑保证了数据准确、资源不浪费,不会出现"人数不减"或"内存泄露"。

最后总结一下,这段代码实现了一个完整的"谁连接我就+1,谁断开我就-1,然后实时广播当前在线人数 "的机制。你只要在前端用 EventSource 接收这条 SSE 流,就能看到用户数量实时跳动,非常适合用在聊天室、控制台、直播页面等场景。

目前后端代码我们是编写完成了,我们来实现一个前端页面来实现这个功能来对接这个接口:

ts 复制代码
"use client";

import React, { useState, useEffect, useRef } from "react";

export default function OnlineCounter() {
  const [onlineUsers, setOnlineUsers] = useState(0);
  const [connected, setConnected] = useState(false);
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    // 创建SSE连接
    const connectSSE = () => {
      if (eventSourceRef.current) {
        eventSourceRef.current.close();
      }

      try {
        const eventSource = new EventSource(`/api/sse?t=${Date.now()}`);
        eventSourceRef.current = eventSource;

        eventSource.onopen = () => {
          setConnected(true);
        };

        eventSource.onmessage = (event) => {
          try {
            const data = JSON.parse(event.data);
            // 只处理在线用户数
            if (data.onlineUsers !== undefined) {
              setOnlineUsers(data.onlineUsers);
            }
          } catch (error) {
            console.error("解析数据失败:", error);
          }
        };

        eventSource.onerror = (error) => {
          console.error("SSE连接错误:", error);
          setConnected(false);
          eventSource.close();

          // 5秒后尝试重新连接
          setTimeout(connectSSE, 5000);
        };
      } catch (error) {
        console.error("创建SSE连接失败:", error);
        setTimeout(connectSSE, 5000);
      }
    };

    connectSSE();

    // 组件卸载时清理
    return () => {
      if (eventSourceRef.current) {
        eventSourceRef.current.close();
      }
    };
  }, []);

  return (
    <div className="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 text-white flex flex-col items-center justify-center p-4">
      <div className="bg-slate-800 rounded-xl shadow-2xl overflow-hidden max-w-sm w-full">
        <div className="p-6">
          <h1 className="text-3xl font-bold text-center text-blue-400 mb-6">
            在线用户统计
          </h1>

          <div className="flex items-center justify-center mb-4">
            <div
              className={`h-3 w-3 rounded-full mr-2 ${
                connected ? "bg-green-500" : "bg-red-500"
              }`}
            ></div>
            <p className="text-sm text-slate-300">
              {connected ? "已连接" : "连接断开"}
            </p>
          </div>

          <div className="flex items-center justify-center bg-slate-700 rounded-lg p-8 mt-4">
            <div className="flex flex-col items-center">
              <div className="text-6xl font-bold text-green-400 mb-2">
                {onlineUsers}
              </div>
              <div className="flex items-center">
                <svg
                  className="w-5 h-5 text-green-400 mr-2"
                  fill="currentColor"
                  viewBox="0 0 20 20"
                >
                  <path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" />
                </svg>
                <span className="text-lg font-medium text-green-300">
                  在线用户
                </span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

最终输出结果如下图所示:

总结

SSE 实现在线人数统计可以说是简单高效又刚刚好 的选择:它支持服务端单向推送,客户端只用监听就能实时获取在线人数更新,不用自己轮询。相比 WebSocket 来说,SSE 更轻量,部署起来也更方便。我们还通过心跳机制和活跃时间管理,保证了数据准确、连接稳定。整体来说,功能对得上,性能扛得住,代码写起来也不费劲,是非常适合这个场景的一种实现方式。

技术方式 实时性 实现难度 性能消耗 适不适合这个功能 备注
轮询 ★★☆☆☆ ★☆☆☆☆(最简单) ★☆☆☆☆(浪费) ❌ 不推荐 太低效了
WebSocket ★★★★★ ★★★★☆(较复杂) ★★★☆☆(重型) ❌ 不合适 太强大、太复杂
SSE ★★★★☆ ★★☆☆☆(非常容易上手) ★★★★☆(轻量) ✅ 非常适合 简单好用又高效
相关推荐
喜樂的CC36 分钟前
[react]Next.js之自适应布局和高清屏幕适配解决方案
javascript·react.js·postcss
天天扭码1 小时前
零基础 | 入门前端必备技巧——使用 DOM 操作插入 HTML 元素
前端·javascript·dom
软件测试曦曦1 小时前
16:00开始面试,16:08就出来了,问的问题有点变态。。。
自动化测试·软件测试·功能测试·程序人生·面试·职场和发展
咖啡虫1 小时前
css中的3d使用:深入理解 CSS Perspective 与 Transform-Style
前端·css·3d
烛阴1 小时前
手把手教你搭建 Express 日志系统,告别线上事故!
javascript·后端·express
拉不动的猪2 小时前
设计模式之------策略模式
前端·javascript·面试
旭久2 小时前
react+Tesseract.js实现前端拍照获取/选择文件等文字识别OCR
前端·javascript·react.js
独行soc2 小时前
2025年常见渗透测试面试题-红队面试宝典下(题目+回答)
linux·运维·服务器·前端·面试·职场和发展·csrf
uhakadotcom2 小时前
Google Earth Engine 机器学习入门:基础知识与实用示例详解
前端·javascript·面试
麓殇⊙2 小时前
Vue--组件练习案例
前端·javascript·vue.js