场景设计:如何统计用户在线时长
1. 先定义“在线”,否则统计一定会吵起来
“统计用户在线时长”不是一个纯技术问题。首先要和产品、运营、数据团队确认口径。
常见口径至少有四种:
| 口径 | 定义 | 典型场景 |
|---|---|---|
| 连接在线 | 与服务端保持长连接或最近心跳正常 | IM、客服、游戏 |
| 前台在线 | App/页面处于前台 | 内容、社交应用 |
| 活跃在线 | 前台且近期有点击、输入、滚动等行为 | 广告、使用时长 |
| 业务在线 | 正在执行某项核心业务 | 直播观看、骑手接单、坐席工作 |
例如:
- 用户打开网页后去吃饭 2 小时,连接还在,算不算在线?
- 手机 App 进入后台,但 WebSocket 没断,算不算?
- 用户同时打开 3 个标签页,是 3 小时还是 1 小时?
- 手机和电脑同时在线 30 分钟,用户时长是 30 分钟还是 60 分钟?
如果不先定义,系统算得再精确也没有业务意义。
本文设计两套指标:
1 | |
2. 需求拆解
2.1 功能需求
- 判断用户当前是否在线;
- 统计每次会话开始、结束和时长;
- 支持 Web、App、桌面端、多设备;
- 支持异常断网、进程被杀、浏览器崩溃;
- 支持跨天切分;
- 支持实时看板和离线报表;
- 支持按用户、设备、渠道、版本、地区聚合;
- 支持数据重算和口径升级。
2.2 非功能需求
- 心跳流量不能压垮服务;
- 不依赖客户端主动 logout 才结算;
- 事件重复和乱序时仍可恢复;
- 服务端时间作为权威时间;
- 实时统计可以最终一致;
- 用户在线状态查询延迟低;
- 原始事件可审计、可重放;
- 注意隐私、留存期限和数据最小化。
3. 事件模型
建议把在线统计建模为事件流,而不是仅在一张用户表上更新 online_seconds。
常见事件:
1 | |
事件结构:
1 | |
最终统计以 serverTime 为主,clientTime 可用于辅助排查和弱网补偿,但不能完全信任。
4. 总体架构
flowchart LR
CLIENT[Web/App/桌面端] --> GW[连接/心跳网关]
GW --> PRESENCE[在线状态服务]
PRESENCE --> REDIS[(Redis 实时在线状态)]
PRESENCE --> LOG[在线事件日志]
LOG --> MQ[Kafka/Pulsar]
MQ --> STREAM[流式计算]
STREAM --> REALTIME[(实时聚合库)]
MQ --> LAKE[(原始事件湖/仓)]
LAKE --> BATCH[离线批处理与重算]
BATCH --> DW[(统计数仓)]
API[业务查询 API] --> REDIS
DASH[实时看板] --> REALTIME
REPORT[离线报表] --> DW
建议分成三层:
- Presence 层:回答“现在是否在线”;
- Session 层:记录每个设备会话区间;
- Analytics 层:按口径聚合日、周、月在线时长。
不要让实时在线状态 Key 同时承担长期统计事实。
5. 客户端协议
5.1 建立会话
登录或连接成功后服务端签发:
1 | |
session_epoch 用于区分同一设备的旧连接和新连接,防止旧连接延迟心跳覆盖新连接状态。
5.2 心跳
客户端每隔一段时间发送心跳,例如 30 秒:
1 | |
服务端返回:
1 | |
具体间隔取决于业务:
- IM 强在线:可更短;
- 普通 App 使用时长:可更长;
- 移动端要兼顾耗电和后台限制;
- 浏览器后台标签页定时器会被节流,不能假设精确 30 秒。
5.3 正常关闭
客户端可发送 SESSION_CLOSE 或 APP_BACKGROUND,但服务端不能把它当唯一结算依据,因为:
- 浏览器可能崩溃;
- 手机直接杀进程;
- 网络突然断开;
- 设备关机;
- 页面卸载请求不保证送达。
所以必须有服务端超时兜底。
6. 在线状态存储
6.1 Redis Key 设计
1 | |
Hash:
1 | |
TTL 设置为心跳间隔的若干倍,例如:
1 | |
不要把示例值机械复制到所有系统。弱网、移动后台和业务实时性会影响阈值。
6.2 用户到会话的索引
1 | |
用于查询用户是否任一设备在线。
由于 session Key 会自动过期,user Set 可能残留失效 sessionId。读取时要校验 session Key,并异步清理。
另一种方案是使用 ZSet:
1 | |
查询时删除超时成员:
1 | |
6.3 在线判断
1 | |
7. 心跳更新必须防乱序
移动网络可能让 sequence=129 先到,sequence=128 后到。
如果直接覆盖:
1 | |
服务端更新条件:
1 | |
Redis Lua 伪代码:
1 | |
8. 在线时长怎么累计
8.1 错误做法:每次心跳固定加 30 秒
如果心跳重试或乱序,可能重复累计;如果心跳延迟 2 分钟,又可能少算或多算。
8.2 增量累计模型
服务端维护:
1 | |
每次有效心跳到达:
1 | |
但要加上限:
1 | |
例如心跳期望 30 秒,允许最大计入 60 秒。若用户断网 1 小时后突然恢复,不能把这 1 小时全部算在线。
1 | |
8.3 区间模型更容易重算
比直接累计总秒数更可靠的方式,是先生成在线区间:
1 | |
或者生成小粒度区间:
1 | |
只在间隔小于阈值时认为连续在线。
flowchart LR
H1[10:00 心跳] --> I1[计入 10:00-10:00:30]
H2[10:00:30 心跳] --> I2[计入 10:00:30-10:01:00]
H3[10:01 心跳] --> G[之后失联]
G --> T[10:02:30 超时关闭]
T --> I3[最多补到定义的超时边界]
原始事件保留后,可以根据新口径重新计算,而不是被早期的累计错误永久锁死。
9. 会话结束时间如何确定
9.1 正常关闭
1 | |
9.2 心跳超时
不能简单使用检测任务运行时间作为结束时间,否则任务晚跑 10 分钟会多算 10 分钟。
推荐:
1 | |
或者更保守:
1 | |
具体取决于口径。
9.3 后台切换
如果统计“前台在线时长”:
1 | |
长连接是否仍在不影响前台时长。
9.4 活跃在线
如果统计“活跃使用时长”:
1 | |
例如 5 分钟没有点击、键盘、滚动等行为,就停止累计活跃时长,直到下一次用户交互。
10. 多标签页和多设备重复统计
10.1 设备会话时长
每个 session 单独累计:
1 | |
设备会话总和为 40 分钟。
10.2 用户去重时长
用户实际在线区间是并集:
1 | |
用户去重时长为 30 分钟。
flowchart TB
A[设备 A: 10:00 -------- 10:30]
B[设备 B: 10:10 -- 10:20]
U[用户并集: 10:00 -------- 10:30]
A --> U
B --> U
10.3 区间合并算法
- 按开始时间排序;
- 初始化当前区间;
- 若下一区间开始时间小于等于当前结束时间,则合并;
- 否则输出当前区间并开始新区间。
伪代码:
1 | |
数据仓库中也可以通过窗口函数合并重叠区间。
11. Web 多标签页优化
如果每个标签页都独立发送心跳,流量会被放大。
可在浏览器端通过以下方式选出一个 Leader Tab:
- BroadcastChannel;
- SharedWorker;
- Service Worker;
- localStorage 锁和过期时间;
- Web Locks API。
Leader Tab 负责心跳,其他 Tab 通过本地通道汇报前台/活跃状态。
但需要处理:
- Leader Tab 被关闭;
- 浏览器冻结后台标签;
- 多窗口和隐私模式;
- 浏览器兼容性;
- Leader 选举脑裂。
服务端仍应能容忍多个 Tab 同时心跳,客户端优化不能成为正确性的前提。
12. 数据库模型
12.1 会话表
1 | |
12.2 原始事件表或事件湖
1 | |
原始事件不一定全部写传统 OLTP 表。高频心跳更适合进入 Kafka 和对象存储/数仓,在线状态只保留在 Redis,最终会话摘要再落关系库或分析库。
13. 为什么不应该每个心跳都直接 UPDATE MySQL
假设:
- 1000 万在线用户;
- 每 30 秒一次心跳;
心跳 QPS:
1 | |
如果每次都更新关系数据库同一会话行,会带来:
- 高频随机写;
- 索引更新;
- redo/binlog 压力;
- 热页和锁竞争;
- 主从复制压力;
- 大量无价值的中间状态。
推荐:
1 | |
14. 实时计算与离线计算
14.1 实时层
用于:
- 当前在线人数;
- 近 5 分钟活跃用户;
- 实时在线时长趋势;
- 运营大盘;
- 在线状态查询。
可使用 Flink/Kafka Streams 等按事件流聚合。
14.2 离线层
用于:
- 日活跃时长;
- 用户多端区间并集;
- 跨天切分;
- 延迟事件修正;
- 口径变更后重算;
- 数据质量校验。
flowchart LR
E[原始在线事件] --> RT[实时流计算]
E --> OL[对象存储/数仓]
RT --> R[实时分钟级结果]
OL --> B[离线区间重建]
B --> D[日级权威结果]
R --> C[次日校准]
D --> C
实时结果追求快,离线结果追求完整和可重算。
15. 事件乱序和延迟
流式计算要使用:
event_id去重;session_id + sequence排序;- 服务端接收时间;
- watermark;
- 允许迟到窗口;
- 状态 TTL;
- 补偿输出。
例如 APP_BACKGROUND 事件比后续心跳更晚到达,不能简单按消费时间计算。
如果业务只要求近似在线时长,可使用服务端到达顺序和封顶策略简化;如果用于计费、考勤或劳动结算,则必须采用更严格的设备可信、签名、审计和人工纠错机制。
16. 跨天切分
一个会话:
1 | |
日统计必须切成:
1 | |
要明确使用哪个时区:
- 用户时区;
- 业务城市时区;
- 统一 UTC 后按报表时区转换;
- 企业考勤所在地时区。
底层事件建议统一存 UTC,统计任务显式传入业务时区。不要依赖数据库服务器默认时区。
17. API 设计
17.1 心跳
1 | |
1 | |
17.2 前后台切换
1 | |
1 | |
17.3 查询在线状态
1 | |
响应:
1 | |
隐私产品不一定允许任何人查看精确在线时间。应经过关系和隐私权限校验,并可只返回模糊状态。
18. last_seen 怎么算
常见定义:
1 | |
不要在每次读取时扫描所有历史会话。可维护用户级摘要:
1 | |
字段:
1 | |
多 session 并发关闭时,需要防止较旧 session 把 lastOfflineAt 覆盖为错误值。只有当有效 session 数降到 0,才认为用户整体离线。
19. 定时扫描还是 Key 过期通知
19.1 依赖 Redis Keyspace Notification
可以监听过期事件,但不应将其作为唯一可靠结算机制:
- 通知需要配置;
- 可能丢失;
- 过期触发时间不保证精确;
- 故障切换和订阅重连会影响处理。
19.2 时间轮/延迟队列
每次心跳更新一个预期超时时间,调度器到期检查:
1 | |
任务携带版本或最后心跳时间,旧任务不能关闭已经续期的新会话。
19.3 周期扫描兜底
扫描:
1 | |
或扫描 Redis ZSet 中 score 小于阈值的 session。
生产系统可以采用:
1 | |
20. 故障场景
20.1 客户端断网后恢复
旧 session 可以:
- 在超时前继续复用,并携带递增 sequence;
- 或创建新 session epoch,让旧连接失效。
业务必须统一,避免两个连接同时认为自己是同一设备的主会话。
20.2 服务重启
实时状态在 Redis,连接网关重启后客户端重连。会话事件通过 Kafka/日志恢复,不依赖单节点内存。
20.3 Redis 故障
- 在线查询可能降级为 UNKNOWN;
- 不要把 UNKNOWN 当 OFFLINE;
- 客户端重建心跳状态;
- 原始事件仍进入日志;
- 恢复后重建用户到 session 索引。
20.4 Kafka 积压
当前在线状态仍由 Redis 提供;实时统计延迟增加;离线数据最终可重放修复。
20.5 重复心跳
按 sessionId + sequence 幂等,不能重复累计时长。
20.6 旧连接延迟心跳
通过 session_epoch 或 fencing token 拒绝旧连接更新。
21. 数据质量校验
建立以下规则:
end_at >= start_at;- 单 session 时长不超过合理上限;
- 同 session sequence 不回退;
- 已 CLOSED 会话不能再被旧心跳打开;
- 用户去重时长不超过一天 24 小时;
- active 时长不应大于 foreground 时长;
- foreground 时长不应大于连接时长;
- 跨天切分之和等于原会话时长;
- 实时与离线结果差异在阈值内。
异常数据进入隔离表,不要悄悄丢弃。
22. 隐私和合规
在线状态与使用时长可能构成敏感行为数据。应考虑:
- 只采集业务需要的事件;
- 设备 ID 做稳定但不可逆的内部标识;
- 日志不记录完整 Token;
- 精确位置不是在线统计的必要字段时不要采集;
- 定义原始事件保留期限;
- 用户注销和数据删除流程;
- 内部报表权限;
- 防止管理端任意追踪单个用户;
- 导出和共享审计。
23. 常见错误回答
错误一:登录时记开始,退出时记结束
用户经常不会正常退出,异常断线会导致时长无限增长。
错误二:每个心跳固定加一段时间
重试、乱序和延迟会重复或错误累计。
错误三:每个心跳都更新 MySQL
高并发下写放大严重,应区分实时状态、事件日志和最终摘要。
错误四:多个设备时长直接相加
如果指标是“用户使用时长”,需要对区间取并集。
错误五:以客户端时间为准
客户端时钟可漂移、可伪造,应使用服务端接收时间和序列。
错误六:使用 Redis TTL 自动过期就算完成
TTL 只能辅助判断当前状态,长期统计还需要可靠事件、结算和补偿。
错误七:在线只有 true/false
实际还要区分 CONNECTED、FOREGROUND、ACTIVE、IDLE、UNKNOWN 等状态。
24. 面试总结话术
我会先和产品定义连接在线、前台在线还是活跃在线,并分别统计设备会话时长和用户多端去重时长。客户端建立 session 后周期发送带 epoch 和 sequence 的心跳,服务端使用 Redis 保存实时 Presence,并通过 Lua 防止旧连接和乱序心跳覆盖新状态。时长不按固定心跳次数累加,而是基于相邻服务端时间差并设置最大可计入间隔;正常关闭立即结算,异常断线按 lastHeartbeatAt 加宽限期关闭。原始事件进入 Kafka 和数据湖,实时流计算提供看板,离线任务重建区间、合并多端并集、跨天切分并校准结果。Redis TTL 只是实时状态机制,不是长期统计事实源。
25. 延伸追问
- 浏览器后台定时器被限制,心跳不准怎么办?
- 多标签页如何避免心跳放大?
- 用户手机和电脑同时在线如何计算?
- 在线时长用于计费时需要增加哪些安全措施?
- 如何实时统计当前在线人数且避免重复?
- Redis 过期事件是否可靠?
- 客户端离线 10 分钟后补传事件,是否修正历史?
- 如何处理用户跨时区旅行?
- “观看直播时长”和“App 活跃时长”应共用一个模型吗?
参考资料
- Redis EXPIRE: https://redis.io/docs/latest/commands/expire/
- Redis Sorted Sets: https://redis.io/docs/latest/develop/data-types/sorted-sets/
- RFC 6455 WebSocket Protocol: https://datatracker.ietf.org/doc/html/rfc6455
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html