场景设计:如何统计用户在线时长

1. 先定义“在线”,否则统计一定会吵起来

“统计用户在线时长”不是一个纯技术问题。首先要和产品、运营、数据团队确认口径。

常见口径至少有四种:

口径 定义 典型场景
连接在线 与服务端保持长连接或最近心跳正常 IM、客服、游戏
前台在线 App/页面处于前台 内容、社交应用
活跃在线 前台且近期有点击、输入、滚动等行为 广告、使用时长
业务在线 正在执行某项核心业务 直播观看、骑手接单、坐席工作

例如:

  • 用户打开网页后去吃饭 2 小时,连接还在,算不算在线?
  • 手机 App 进入后台,但 WebSocket 没断,算不算?
  • 用户同时打开 3 个标签页,是 3 小时还是 1 小时?
  • 手机和电脑同时在线 30 分钟,用户时长是 30 分钟还是 60 分钟?

如果不先定义,系统算得再精确也没有业务意义。

本文设计两套指标:

1
2
设备会话在线时长:每个 device/session 独立累计
用户去重在线时长:同一用户多端在线区间做并集

2. 需求拆解

2.1 功能需求

  • 判断用户当前是否在线;
  • 统计每次会话开始、结束和时长;
  • 支持 Web、App、桌面端、多设备;
  • 支持异常断网、进程被杀、浏览器崩溃;
  • 支持跨天切分;
  • 支持实时看板和离线报表;
  • 支持按用户、设备、渠道、版本、地区聚合;
  • 支持数据重算和口径升级。

2.2 非功能需求

  • 心跳流量不能压垮服务;
  • 不依赖客户端主动 logout 才结算;
  • 事件重复和乱序时仍可恢复;
  • 服务端时间作为权威时间;
  • 实时统计可以最终一致;
  • 用户在线状态查询延迟低;
  • 原始事件可审计、可重放;
  • 注意隐私、留存期限和数据最小化。

3. 事件模型

建议把在线统计建模为事件流,而不是仅在一张用户表上更新 online_seconds

常见事件:

1
2
3
4
5
6
7
8
9
SESSION_START
HEARTBEAT
APP_FOREGROUND
APP_BACKGROUND
USER_ACTIVE
USER_IDLE
SESSION_CLOSE
SERVER_TIMEOUT
LOGOUT

事件结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"eventId": "evt_01K...",
"userId": 10001,
"sessionId": "sess_01K...",
"deviceId": "device-abc",
"clientType": "WEB",
"eventType": "HEARTBEAT",
"clientTime": 1785303000000,
"serverTime": 1785303000231,
"sequence": 128,
"foreground": true,
"active": true
}

最终统计以 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

建议分成三层:

  1. Presence 层:回答“现在是否在线”;
  2. Session 层:记录每个设备会话区间;
  3. Analytics 层:按口径聚合日、周、月在线时长。

不要让实时在线状态 Key 同时承担长期统计事实。

5. 客户端协议

5.1 建立会话

登录或连接成功后服务端签发:

1
2
3
4
session_id
session_epoch
heartbeat_interval
idle_threshold

session_epoch 用于区分同一设备的旧连接和新连接,防止旧连接延迟心跳覆盖新连接状态。

5.2 心跳

客户端每隔一段时间发送心跳,例如 30 秒:

1
2
3
4
5
6
{
"sessionId": "sess_01K...",
"sequence": 128,
"foreground": true,
"lastActiveAt": 1785302995000
}

服务端返回:

1
2
3
4
{
"serverTime": 1785303000231,
"nextHeartbeatInMs": 30000
}

具体间隔取决于业务:

  • IM 强在线:可更短;
  • 普通 App 使用时长:可更长;
  • 移动端要兼顾耗电和后台限制;
  • 浏览器后台标签页定时器会被节流,不能假设精确 30 秒。

5.3 正常关闭

客户端可发送 SESSION_CLOSEAPP_BACKGROUND,但服务端不能把它当唯一结算依据,因为:

  • 浏览器可能崩溃;
  • 手机直接杀进程;
  • 网络突然断开;
  • 设备关机;
  • 页面卸载请求不保证送达。

所以必须有服务端超时兜底。

6. 在线状态存储

6.1 Redis Key 设计

1
presence:session:{sessionId}

Hash:

1
2
3
4
5
6
7
8
9
10
11
{
"userId": "10001",
"deviceId": "device-abc",
"epoch": "7",
"startAt": "1785300000000",
"lastHeartbeatAt": "1785303000231",
"lastActiveAt": "1785302995000",
"foreground": "1",
"active": "1",
"lastSequence": "128"
}

TTL 设置为心跳间隔的若干倍,例如:

1
2
3
heartbeat = 30s
timeout = 90s
TTL = 120s

不要把示例值机械复制到所有系统。弱网、移动后台和业务实时性会影响阈值。

6.2 用户到会话的索引

1
presence:user:{userId} -> Set(sessionId...)

用于查询用户是否任一设备在线。

由于 session Key 会自动过期,user Set 可能残留失效 sessionId。读取时要校验 session Key,并异步清理。

另一种方案是使用 ZSet:

1
2
3
Key: presence:user:{userId}
Score: lastHeartbeatAt
Member: sessionId

查询时删除超时成员:

1
ZREMRANGEBYSCORE presence:user:10001 -inf <timeoutTimestamp>

6.3 在线判断

1
2
任一有效 session 的 lastHeartbeatAt >= now - timeout
且满足所选口径(foreground/active)

7. 心跳更新必须防乱序

移动网络可能让 sequence=129 先到,sequence=128 后到。

如果直接覆盖:

1
2
lastActiveAt 可能回退
foreground 可能被旧事件覆盖

服务端更新条件:

1
只有 event.sequence > stored.lastSequence 才更新状态

Redis Lua 伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local currentEpoch = redis.call('HGET', KEYS[1], 'epoch')
local currentSeq = tonumber(redis.call('HGET', KEYS[1], 'lastSequence') or '-1')

if currentEpoch ~= ARGV[1] then
return {0, 'STALE_EPOCH'}
end

local newSeq = tonumber(ARGV[2])
if newSeq <= currentSeq then
return {0, 'DUPLICATE_OR_OUT_OF_ORDER'}
end

redis.call('HSET', KEYS[1],
'lastSequence', newSeq,
'lastHeartbeatAt', ARGV[3],
'lastActiveAt', ARGV[4],
'foreground', ARGV[5],
'active', ARGV[6])
redis.call('PEXPIRE', KEYS[1], ARGV[7])
return {1, 'UPDATED'}

8. 在线时长怎么累计

8.1 错误做法:每次心跳固定加 30 秒

如果心跳重试或乱序,可能重复累计;如果心跳延迟 2 分钟,又可能少算或多算。

8.2 增量累计模型

服务端维护:

1
2
3
last_accounted_at
last_heartbeat_at
state

每次有效心跳到达:

1
delta = serverNow - last_accounted_at

但要加上限:

1
countedDelta = min(delta, maxHeartbeatGap)

例如心跳期望 30 秒,允许最大计入 60 秒。若用户断网 1 小时后突然恢复,不能把这 1 小时全部算在线。

1
2
sessionDuration += countedDelta
last_accounted_at = serverNow

8.3 区间模型更容易重算

比直接累计总秒数更可靠的方式,是先生成在线区间:

1
[sessionStart, sessionEnd)

或者生成小粒度区间:

1
[heartbeat1, heartbeat2)

只在间隔小于阈值时认为连续在线。

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
endAt = serverReceivedCloseAt

9.2 心跳超时

不能简单使用检测任务运行时间作为结束时间,否则任务晚跑 10 分钟会多算 10 分钟。

推荐:

1
endAt = lastHeartbeatAt + allowedGracePeriod

或者更保守:

1
endAt = lastHeartbeatAt + heartbeatInterval

具体取决于口径。

9.3 后台切换

如果统计“前台在线时长”:

1
2
APP_BACKGROUND 到达时立即关闭前台区间
APP_FOREGROUND 时开启新区间

长连接是否仍在不影响前台时长。

9.4 活跃在线

如果统计“活跃使用时长”:

1
lastActiveAt + idleThreshold < now -> 进入 IDLE

例如 5 分钟没有点击、键盘、滚动等行为,就停止累计活跃时长,直到下一次用户交互。

10. 多标签页和多设备重复统计

10.1 设备会话时长

每个 session 单独累计:

1
2
Web Tab A: 10:00-10:30 = 30 分钟
Web Tab B: 10:10-10:20 = 10 分钟

设备会话总和为 40 分钟。

10.2 用户去重时长

用户实际在线区间是并集:

1
[10:00, 10:30] ∪ [10:10, 10:20] = [10:00, 10:30]

用户去重时长为 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. 按开始时间排序;
  2. 初始化当前区间;
  3. 若下一区间开始时间小于等于当前结束时间,则合并;
  4. 否则输出当前区间并开始新区间。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
List<Interval> merge(List<Interval> intervals) {
intervals.sort(Comparator.comparing(Interval::start));
List<Interval> result = new ArrayList<>();

for (Interval next : intervals) {
if (result.isEmpty() || next.start().isAfter(result.getLast().end())) {
result.add(next);
} else {
Interval current = result.removeLast();
result.add(new Interval(
current.start(),
current.end().isAfter(next.end()) ? current.end() : next.end()
));
}
}
return result;
}

数据仓库中也可以通过窗口函数合并重叠区间。

11. Web 多标签页优化

如果每个标签页都独立发送心跳,流量会被放大。

可在浏览器端通过以下方式选出一个 Leader Tab:

  • BroadcastChannel;
  • SharedWorker;
  • Service Worker;
  • localStorage 锁和过期时间;
  • Web Locks API。

Leader Tab 负责心跳,其他 Tab 通过本地通道汇报前台/活跃状态。

但需要处理:

  • Leader Tab 被关闭;
  • 浏览器冻结后台标签;
  • 多窗口和隐私模式;
  • 浏览器兼容性;
  • Leader 选举脑裂。

服务端仍应能容忍多个 Tab 同时心跳,客户端优化不能成为正确性的前提。

12. 数据库模型

12.1 会话表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE TABLE user_online_session (
session_id VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
client_type VARCHAR(32) NOT NULL,
session_epoch BIGINT NOT NULL,
start_at TIMESTAMP NOT NULL,
last_heartbeat_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NULL,
close_reason VARCHAR(32) NULL,
foreground_seconds BIGINT NOT NULL DEFAULT 0,
active_seconds BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_online_user_start
ON user_online_session(user_id, start_at);

CREATE INDEX idx_online_status_heartbeat
ON user_online_session(status, last_heartbeat_at);

12.2 原始事件表或事件湖

1
2
partition by event_date/hour
cluster by user_id/session_id

原始事件不一定全部写传统 OLTP 表。高频心跳更适合进入 Kafka 和对象存储/数仓,在线状态只保留在 Redis,最终会话摘要再落关系库或分析库。

13. 为什么不应该每个心跳都直接 UPDATE MySQL

假设:

  • 1000 万在线用户;
  • 每 30 秒一次心跳;

心跳 QPS:

1
10,000,000 / 30 ≈ 333,333 QPS

如果每次都更新关系数据库同一会话行,会带来:

  • 高频随机写;
  • 索引更新;
  • redo/binlog 压力;
  • 热页和锁竞争;
  • 主从复制压力;
  • 大量无价值的中间状态。

推荐:

1
2
3
4
Redis 更新实时状态
+ Kafka 记录事件或采样事件
+ 周期性/结束时写会话摘要
+ 离线重算纠偏

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
2026-07-29 23:50 -> 2026-07-30 00:20

日统计必须切成:

1
2
7 月 29 日:10 分钟
7 月 30 日:20 分钟

要明确使用哪个时区:

  • 用户时区;
  • 业务城市时区;
  • 统一 UTC 后按报表时区转换;
  • 企业考勤所在地时区。

底层事件建议统一存 UTC,统计任务显式传入业务时区。不要依赖数据库服务器默认时区。

17. API 设计

17.1 心跳

1
2
POST /api/v1/presence/heartbeat
Authorization: Bearer <token>
1
2
3
4
5
6
7
{
"sessionId": "sess_01K...",
"epoch": 7,
"sequence": 128,
"foreground": true,
"lastActiveAt": 1785302995000
}

17.2 前后台切换

1
POST /api/v1/presence/state
1
2
3
4
5
6
{
"sessionId": "sess_01K...",
"epoch": 7,
"sequence": 129,
"state": "BACKGROUND"
}

17.3 查询在线状态

1
GET /api/v1/users/{userId}/presence

响应:

1
2
3
4
5
6
{
"online": true,
"lastSeenAt": 1785303000231,
"deviceCount": 2,
"status": "ACTIVE"
}

隐私产品不一定允许任何人查看精确在线时间。应经过关系和隐私权限校验,并可只返回模糊状态。

18. last_seen 怎么算

常见定义:

1
用户最后一个有效 session 结束或最后活动时间

不要在每次读取时扫描所有历史会话。可维护用户级摘要:

1
presence:user-summary:{userId}

字段:

1
2
3
4
5
activeSessionCount
lastHeartbeatAt
lastActiveAt
lastOfflineAt
version

多 session 并发关闭时,需要防止较旧 session 把 lastOfflineAt 覆盖为错误值。只有当有效 session 数降到 0,才认为用户整体离线。

19. 定时扫描还是 Key 过期通知

19.1 依赖 Redis Keyspace Notification

可以监听过期事件,但不应将其作为唯一可靠结算机制:

  • 通知需要配置;
  • 可能丢失;
  • 过期触发时间不保证精确;
  • 故障切换和订阅重连会影响处理。

19.2 时间轮/延迟队列

每次心跳更新一个预期超时时间,调度器到期检查:

1
2
3
如果 currentLastHeartbeatAt == scheduledHeartbeatAt
且 now >= timeoutAt
则关闭会话

任务携带版本或最后心跳时间,旧任务不能关闭已经续期的新会话。

19.3 周期扫描兜底

扫描:

1
2
WHERE status = 'ONLINE'
AND last_heartbeat_at < :timeoutThreshold

或扫描 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. 延伸追问

  1. 浏览器后台定时器被限制,心跳不准怎么办?
  2. 多标签页如何避免心跳放大?
  3. 用户手机和电脑同时在线如何计算?
  4. 在线时长用于计费时需要增加哪些安全措施?
  5. 如何实时统计当前在线人数且避免重复?
  6. Redis 过期事件是否可靠?
  7. 客户端离线 10 分钟后补传事件,是否修正历史?
  8. 如何处理用户跨时区旅行?
  9. “观看直播时长”和“App 活跃时长”应共用一个模型吗?

参考资料


场景设计:如何统计用户在线时长
https://allendericdalexander.github.io/2026/07/29/archtect/examples/07-statistics-user-online-duration/
作者
AtLuoFu
发布于
2026年7月29日
许可协议