在线判断需结合last_activity字段与心跳机制:后端在中间件中按最小间隔更新时间戳,前端定时发送认证心跳,数据库统一utc存储并加索引,高并发时缓存结果,强实时场景需websocket探活,且须明确定义“在线”业务含义。

用 last_activity 字段 + 心跳更新判断是否在线
Laravel 自带的 users 表里有 last_activity(或类似字段),但默认不存、也不自动更新。得自己加逻辑:用户每次有效请求(比如访问需登录的页面、调接口)时,更新这个时间戳。
常见错误是只在登录时写一次,之后就不管了——结果用户开着页面半小时没操作,系统还显示“在线”。
- 推荐在中间件里统一更新,比如新建
UpdateLastActivity中间件,注册到web组 - 别用
touch()直接更新整个模型,避免触发不必要的事件或监听器 - 更新语句建议用
DB::table('users')->where('id', auth()->id())->update(['last_activity' => now()]),轻量且可控 - 注意并发:如果用户高频刷新或多个标签页同时发请求,不用锁表,但要避免频繁写磁盘,可考虑加个 30 秒最小间隔(缓存上次更新时间做判断)
前端定时发心跳,避免假离线
光靠后端请求更新不够——用户可能停留在某个页面不动,但浏览器还在运行。这时候得靠前端主动“报活”。
典型场景:后台管理页、聊天面板、协作编辑工具。不发心跳,5 分钟后用户就被标为离线,其实人还在盯着屏幕。
- 用
setInterval(() => axios.post('/api/heartbeat'), 60000),每分钟打一次,比 session 过期时间短即可 - 心跳接口必须走
auth:sanctum或对应 guard,否则未登录用户也能刷last_activity - 别在
beforeunload里发异步请求——浏览器可能直接关掉,发不出去;真要清理,用navigator.sendBeacon发个同步标记 - 移动端要注意:WebView 或 PWA 可能被系统休眠,心跳会断,得配合后台长连接兜底(见下一条)
数据库查“最近 X 分钟活跃”要小心时区和索引
判断是否在线,本质就是查 last_activity > now() - X minutes。但 Laravel 的 now() 是 PHP 时区,MySQL 默认用系统时区,两边不一致会导致误判。
比如 PHP 设了 Asia/Shanghai,MySQL 用 UTC,那查出来的“5 分钟内”实际是错的。
- 统一用 UTC 存储
last_activity,PHP 写入前转成 UTC:now()->utc(),查询也用 UTC 时间比较 -
last_activity字段必须加索引,否则用户一多,WHERE last_activity > ?就变全表扫描 - 别用
User::where('last_activity', '>', now()->subMinutes(5))->get()直接查全部——改成select id, name, last_activity只取必要字段,减少内存和网络开销 - 如果并发查在线人数高(比如万人级实时看板),考虑把结果缓存 10 秒,用
Cache::remember('online_count', 10, fn() => ...)
WebSocket 不是必须,但复杂场景绕不开
纯 HTTP 心跳撑不住强实时要求:比如 IM 消息送达回执、协作文档光标同步、在线人数秒级刷新。这时候 HTTP 延迟+重试机制会让状态滞后 2~5 秒。
容易被忽略的是:WebSocket 连接建立后,服务端并不知道客户端是否真的“活着”——网络闪断、手机锁屏、浏览器挂起都可能导致连接假连。
- 用 Laravel WebSockets 或 Soketi 时,务必开启
ping_interval和pong_timeout,让服务端主动探活 - 客户端收到 ping 后必须立刻回 pong,不能等 JS 主线程空闲——要用
websocket.onping = () => websocket.pong()这类底层钩子 - 别把 WebSocket 连接状态和数据库
last_activity混为一谈:前者反映“此刻 TCP 是否通”,后者反映“最近一次业务动作”,两者要分开维护、按需合并 - 上线前压测真实弱网环境(比如用 Chrome DevTools 的 “Slow 3G” + 随机断连),光本地跑通没用
实际最难的不是写代码,是定义“在线”本身——是 TCP 连着就算?还是得有鼠标移动?不同业务答案不同。先想清楚这个,再选技术方案。









