面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
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 | ★★★★☆ | ★★☆☆☆(非常容易上手) | ★★★★☆(轻量) | ✅ 非常适合 | 简单好用又高效 |