系统设计笔试题记录:如何设计一个用户在线时长统计系统?
前段时间做了一道系统设计类笔试题,考察点主要集中在实时在线状态、在线时长统计、多端会话处理、异常断开、历史数据查询以及大规模数据下的扩展性。
因为当时处理得比较仓促,后面又重新整理了一下自己的回答。题目本身比较适合作为系统设计训练,所以这里将思路做一些脱敏处理,作为一篇技术笔记记录下来。顺便一提,当时因为没有及时看到邮件,等发现时已经有些来不及了,所以最后结果也就不重要了。这里主要还是把它当作一次系统设计训练来整理。
宇宙安全说明:本文不会贴出原始题目内容,文中的业务背景、需求描述、表结构和字段命名均已做抽象和脱敏处理,仅作为通用系统设计讨论,不对应任何具体公司、平台或真实业务系统。文中 employee、department、position 等命名仅用于表达用户主体、组织维度和角色维度。
背景
在后台系统、协作平台、客服系统或运营系统中,平台方经常需要了解用户的在线情况,用于系统运营观察、资源调度和使用情况分析。
一个完整的在线时长统计系统通常需要支持:
- 展示当前在线用户列表;
- 用户在线状态上报;
- 统计用户每日在线时长;
- 查询一段时间内用户的总在线时长和平均每日在线时长;
- 支持按组织、角色、日期范围筛选;
- 浏览器异常关闭时,也能合理结束在线状态;
- 支持历史数据查询;
- 允许分钟级统计误差。
1. 技术选型:WebSocket、Polling 还是其他方案?
我推荐通过 WebSocket + 类似于会话保持时长HTTP Heartbeat 的方式实现数据同步,用 WebSocket 的原因很简单,需求来看要做员工在线状态的上报处理,相比于 纯Polling 的方案,WebSocket明显可以通过长TCP连接方案减少没HTTP请求(HTTP3.0之前都是 TCP)、实时查询效果也会更好。
另外即使是针对后续潜在的在线员工广播一类系统广播机制等,场景设计的实现,主基于 WebSocket 的方案也更好实现目标效果,当然 Polling 也不能说完全不用,我们可以引入 HTTP Heartbeat 作为心跳兜底。
2. 整体系统方案设计
主要架构图

整个系统其实主要需要解决的问题有两个部分:
- 实时在线状态 (题意要求低延迟、异常退出情况下仍可正常该统计)
- 历史在线时长统计 (题意要求每日、时间段、平均等时间范围内数据查询,并提供部门、职位、日期筛选)
为了尽可能降低时延,我决定将实时状态的实现依托于 WebSocket 状态同步 + HTTP Heartbeat 辅助兜底 和 Redis 存储实时在线状态 ,而历史数据聚合、统计查询上,考虑到整体数据长期累计潜在大规模数据统计问题,则采用 MQ解耦,将事件异步统计时长,并持久化到 RDBMS 和 OLAP 的整体架构。
设计简述
整个架构分为 客户端、接入层、在线状态层、实时状态存储层、事件消息层、统计计算层、持久化层、查询层。
客户端层
员工在登录后台系统后,通过建立 WebSocket 连接,并建立 Heartbeat 心跳机制,周期回报状态。客户端状态主要依靠 WebSocket 上传,当 WebSocket 不可用时,交由 HTTP Heartbeat 上报状态。页面关闭时,通过 sendBeacon 尽可能通知 Presence 进入离线状态。
除此以外,还可以通过 WebSocket 的 close 事件、心跳超时 和 定时扫描补偿机制,实现离线兜底处理。
接入层
接入层包括典型的 WAF、Nginx、Api Gateway 等网关组件,对报文提供防护、负载均衡、Path/协议路由、微服务流量治理的工作,为了方便针对 Presence Servers 和 查询 流量的统一治理 ,设计时统一接入了 API Gateway。
Presence 在线状态层
Presence 层包括有 WebSocket Servers 和 Presence API Servers ,分别负责 WebSocket 和 Heartbeat 两大通道的请求处理,并通过 Redis 记录员工上线、下线和连接状态。
由于浏览器等异常关闭时不一定能正常触发JS的 sendBeacon,为了避免 session 长期留存导致长期数据不一致的潜在问题,引入了 Timeout Scanner Servers 定时扫描 Redis 中记录的 最后心跳时间戳,只要超过阈值那么就认为 session 超时离线,推送下线事件。
实时状态存储层
基于 Redis 通过 Set 记录当前在线员工列表、员工活跃 session 集合,通过HSET记录 session,内部记录包含 lastHeartbeatTime 和 lastActiveTime,通过 ZSet 记录不同 session 的最后心跳时间戳,Timeout Scanner Servers 可通过 ZSet 排查超过五分钟的失效 Session 并推送下线事件。
这里涉及用户规模决定采用 单节点 还是基于 Hash分区 的方式 存储 ZSet 还是 Set 等统计用数据结构,实际上根据数据规模可以分为三种不同的规模级别情况进行介绍:
- 小规模场景,在员工规模不大,几百到几千,假设心跳间隔30s左右,那么实际估算下来心跳 QPS 只在几十到几百左右,那么实际上对于单节点的压力指标其实非常低,完全可以将
在线员工列表、员工活跃 session 级别、session 最终心跳时间戳集合不做拆分存储在同一个节点上。 - 中度规模场景,如果员工规模达到几万 到 十万 数量级,假设心跳间隔仍然是 30s ,那么心跳 QPS 则可达 几千 QPS,如果仍然将
在线员工列表、员工活跃 session 级别、session 最终心跳时间戳集合不做拆分,极容易导致 Redis 单点过热、ZSet 统计带来 CPU 压力等问题,推荐做轻度拆分,主要是针对ZSet 存储的 Session最终心跳时间做分片拆分。分片策略可按照hash(sessionId) % 分片数进行拆分。 - 大规模场景,如果员工规模数量级超过十万,那么心跳 QPS 会达到 万级,达到该级别后,我们必须针对
在线员工列表、员工活跃 session 级别、session 最终心跳时间戳级别都做拆分处理。类似于中等规模场景,都可以通过hash(usersId / sessionId)%分片数进行拆分, 具体分片数需要根据 用户规模 和 Redis Node 数量决定。
在未涉及 Hash 拆分时,Timeout Scanner Servers 可以单实例,并通过
ZRANGEBYSCORE key -inf timeoutBefore LIMIT startIdx endIdx,完成查询;
在设计 Hash 拆分后,Timeout Scanner Servers 进行多实例扫描时,可让不同实例负责不同分片进行统计,每个 Scanner 节点,只负责一部分的分片统计,并通过 ZREM 删除节点,从而占用处理权后,通过 HGET session 二次确认会话信息,更新 Redis 中状态去除超时 session 会话信息,并推送 超时下线 事件消息到消息队列层。另外值得补充的一点是,可以考虑采用 Faster 等支持无锁算法结构的新型 K-V 内存数据库代替 Redis 处理实时状态存储层,在性能上有更好的表现
消息队列层
解耦 Presence 和 Statistics 行为,考虑到 Presence 层涉及相关用户规模量级及特殊场景下,潜在的突峰流量引入消息队列层,可提高系统可用性,达到削峰填谷的效果。
MQ时间应当包含有:
- session_online 会话上线
- session_offline 会话离线
- user_online 用户上线
- user_offline 用户离线
- timeout_offline 超时离线
- active_event 活跃状态
需要注意的是,WebSocket 和 Heartbeat 正常心跳更新最后心跳事件不做任何数据库持久化处理,消息队列层接收并做持久化处理的应当只包含用户状态实际发生变化时,才需要推送MQ事件消息,这样可以尽可能减少不必要的事件数量
统计计算层
Statistics 层通过消费 MQ 事件消息,创建 session 记录明细、关闭 Session 统计、合并多 session 时间范围、针对跨天时间进行切分等,中的来说统计层不做任何数据上的改动,只根据 Presence 事件转为统计结果并做持久化存储处理。
持久化层
持久化数据分别存储两类数据库中,分别是:RDBMS、OLAP。
RDBMS:部门、职位、员工、session 明细、period 员工在线时间区间、每日汇总
OLAP存储:员工在线时间明细 (需要冗余员工、部门、职位信息) 、每日员工汇总、部门每日汇总、职位每日汇总
他们分别负责不同的职能工作,RDBMS本身其实作为主存储,他需要记录事实信息,这部分既包含了员工、部门、职位、session 明细、员工在线时间区间 等;OLAP 本质上其实是一个主分析的数据库,数据本身通过 CDC 工具同步数据,并针对大规模历史数据查询时通过分布式多节点合并统计的方式保证低延迟效果。
RDBMS
employee - 员工表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | BIGINT | Snowflake ID |
| name | VARCHAR(64) | 员工名 |
| dept_id | BIGINT | 员工从属部门 |
| position_id | BIGINT | 员工职称 |
| status | TINYINT | 员工状态(-1离职、1在职) |
| created_at | DATETIME | 记录创建时间 |
| updated_at | DATETIME | 记录更新时间 |
可对 dept_id, position_id 构建索引 INDEX idx_dept_position (dept_id, position_id)
department - 部门表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | BIGINT | Snowflake ID |
| name | VARCHAR(64) | 部门名 |
| parent_id | BIGINT | 父级部门ID |
| created_at | DATETIME | 记录创建时间 |
| updated_at | DATETIME | 记录更新时间 |
position - 职位表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | BIGINT | UUID / ULID |
| name | VARCHAR(64) | 职位名 |
| created_at | DATETIME | 记录创建时间 |
| updated_at | DATETIME | 记录更新时间 |
employee_online_session - session明细表
| 字段 | 类型 | 注释 |
|---|---|---|
| id | BIGINT | Snowflake ID |
| session_id | VARCHAR(64) | 会话ID |
| user_id | BIGINT | 关联用户ID |
| dept_id | BIGINT | 关联部门ID |
| position_id | BIGINT | 关联职位ID |
| device_type | VARCHAR(32) | 设备类型(Web / Mobile App) |
| ip | VARCHAR(64) | IP 地址 方便溯源 |
| user_agent | VARCHAR(512) | Agent 信息 |
| start_time | DATETIME | 会话开始时间 |
| end_time | DATETIME | 会话结束时间 |
| last_heartbeat_time | DATETIME | 最后心跳时间 |
| last_active_time | DATETIME | 最后活跃时间 |
| online_seconds | BIGINT | 在线时长 |
| active_seconds | BIGINT | 活跃时长 |
| status | TINYINT | 状态(1在线 2正常离线 3超时离线 4异常离线) |
| close_reason | VARCHAR(200) | 关闭原因 (Statistics 统计层备注) |
| created_at | DATETIME | 记录创建时间 |
| updated_at | DATETIME | 记录更新时间 |
可创建对应多条索引记录:
UNIQUE KEY uk_session_id (session_id)
INDEX idx_user_start_time (user_id, start_time)
INDEX idx_dept_position_start (dept_id, position_id, start_time)
INDEX idx_start_time (start_time)
INDEX idx_end_time (end_time)
employee_online_period - 员工级去重在线区间
| 字段 | 类型 | 注释 |
|---|---|---|
| id | BIGINT | Snowflake ID |
| user_id | BIGINT | 关联用户ID |
| dept_id | BIGINT | 关联部门ID |
| position_id | BIGINT | 关联职位ID |
| start_time | DATETIME | 会话开始时间 |
| end_time | DATETIME | 会话结束时间 |
| online_seconds | BIGINT | 在线时长 |
| status | TINYINT | 状态 (1在线、2正常结果、3超时结果) |
| close_reason | VARCHAR(200) | 关闭原因 |
| created_at | DATETIME | 记录创建时间 |
| updated_at | DATETIME | 记录更新时间 |
UNIQUE KEY uk_period_id (period_id)
INDEX idx_user_start (user_id, start_time)
INDEX idx_start_time (start_time)
INDEX idx_dept_position_start (dept_id, position_id, start_time)
employee_online_daily - 员工每日在线汇总
| 字段 | 类型 | 注释 |
|---|---|---|
| id | BIGINT | Snowflake ID |
| period_id | VARCHAR(36) | 业务 UUID |
| user_id | BIGINT | 关联用户ID |
| dept_id | BIGINT | 关联部门ID |
| position_id | BIGINT | 关联职位ID |
| stat_date | DATETIME | 统计日期 |
| online_seconds | BIGINT | 总在线时长/天 |
| active_seconds | BIGINT | 活跃时长/天 |
| session_count | INT | session 总数 |
| first_online_time | DATETIME | 首次上线时间 |
| last_offline_time | DATETIME | 最终离线时间 |
| created_at | DATETIME | 记录创建时间 |
| updated_at | DATETIME | 记录更新时间 |
UNIQUE KEY uk_user_date (user_id, stat_date)
INDEX idx_date_dept_position (stat_date, dept_id, position_id)
INDEX idx_dept_position_date (dept_id, position_id, stat_date)
INDEX idx_user_date (user_id, stat_date)
OLAP
online_session_wide - 在线明细表
可从 RDBMS 的 employee_online_session + employee + department + position 同步得来
| 字段 | 注释 |
|---|---|
| session_id | 会话ID |
| user_id | 用户ID |
| user_name | 用户名 |
| dept_id | 部门ID |
| dept_name | 部门名 |
| position_id | 职位ID |
| position_name | 职位名 |
| device_type | 设备类型 |
| client_type | 协议类型 |
| start_time | 会话开始时间 |
| end_time | 会话结束时间 |
| online_seconds | 在线时长 |
| active_seconds | 活跃时长 |
| status | 状态(1在线 2正常离线 3超时离线 4异常离线) |
| close_reason | 关闭原因 |
| stat_date | 统计日期 |
olap_employee_online_daily - 每日员工汇总表
| 字段 | 注释 |
|---|---|
| stat_date | 统计日期 |
| user_id | 用户ID |
| user_name | 用户名 |
| dept_id | 部门ID |
| dept_name | 部门名 |
| position_id | 职位ID |
| position_name | 职位名 |
| online_seconds | 在线时长 |
| active_seconds | 活跃时长 |
| session_count | 会话总数 |
| first_online_time | 首次在线时间 |
| last_offline_time | 最终离线时间 |
其他表
olap_dept_online_daily、olap_position_online_daily 以员工每日汇总表类推,相差不大,主要目的是为了做冗余,减少后续针对这一类别做统计潜在的 group by 步骤所需的资源占用。
3. 如何保证在线状态与在线时长统计的准确性?
Q3.1:针对多浏览器、多设备、多标签页场景的处理,我们可以简单的基于 User 级别做去重即可。
**员工在线状态判断:**在用户级别上,只要存在至少一个有效 Session ,那么就判断员工在线
**员工在线时长统计:**当出现一个用户同时存在多个 Session 时,根据时序对 Session 在线时间做合并,即可获得在线时长的准确时间。
多 Session 去重逻辑可按此图逻辑实现,最终session有效时间可覆盖 9:00 ~ 15:30 ,总计是 6.5h。

Q3.2:在线 / 离线状态判断
在员工登录后台系统后,通过 WebSocket + HTTP Heartbeat 周期上报会话状态,并在 Redis 中构建有效 session 即可作为在线判断依据。
在线判断:员工只需有至少一个有效 session ,并且该 session 的心跳机制并未超时,那么他就是有效的,判断公式:now - lastHeartbeatTime <= 5min
离线判断:员工所有的 session 都已经离线或者超时了,那么员工就是离线状态。
Q3.3:异常断开、网络抖动、页面关闭
**针对页面关闭:**上文实现方案中,已介绍如果是正常关闭,那么通过 JS sendBeacon 正常通知会话关闭,当然这只是一个基础的断开机制,在面对异常断开、网络抖动等场景时,是不可用的。
**针对异常断开:**可以依赖于 WebSocket 的 close 事件、心跳超时 + Timeout Scanner Server ,定时补偿以维护这部分会话的下线处理。
**针对网络抖动:**这部分的会话考虑到公平问题,不能立即判定为离线状态,应当允许合适的误差事件,以避免用户被误判为下线。比如,心跳间隔可以设置为 30s,离线超时设置为 3~5 分钟。
Q3.4:挂机、空闲在线、异常常在线识别
针对这部分的异常数据,我们可以通过在前端项目中采集活跃事件,对外设I/O事件做监听 (鼠标移动、键盘输入等行为做监听),在 WebSocket 和 HTTP Hearbeat 中额外记录最后活跃时间戳,与会话保持时长一并上报到信息到 Presence Servers 纳入统计。
只要最后活跃时间超过 一定时长,那么就认为会话进入到挂机、空闲在线的状态,针对空闲时间较长、会话保持时间极长的会话,可以在 status 中记录标记,并在 close_reason 中记录相关信息。
在最终统计员工数据时,针对这部分的数据不能纳入统计,即可满足题意需求
4. 还需要考虑哪些边界情况?
浏览器刷新 / WebSocket 服务重启
员工可能在使用期间出于业务或习惯等原因刷新浏览器,这会导致 WebSocket 和 HTTP Heartbeat 重连,导致实际会话保持分为两段,针对这部分的数据,需要考虑在 Statistics 引入重连宽限计算时间,可考虑将时间设置为会话超时时长,在未超过这个时间内,出现新的 session 记录,那么重新调整持久化记录为 ONLINE 状态。
Redis 节点故障转移
首先结论先放在回答前面,Redis节点宕机带来的数据一致性不可能完全避免或解决,从整个架构来看,Redis 承担的 Realtime Presence Store 不是事实可信的数据源,而应该认为是一个当前在线的视图。事实的信息应当仅包括 MQ 和 RDBMS 中的存储数据。针对 Redis 宕机的问题,我们可以做的其实是缩短不一致窗口、尽可能保证统计数据一致的效果。
作为 Presence 层用户在线状态的存储数据库,在 Redis 节点出现节点故障时,容易出现数据不一致问题。从架构层面上,可以考虑采用 Redis Cluster / 主从 + 哨兵 ,在自动故障转移的基础上尽可能缩短引发数据不一致的窗口时长。
当然,仅依靠 Redis 自动的故障转移其实无法解决最终一致性问题,因为 master 仍然有可能有一部分命令未同步到 slave ,这部分的数据我们可以依靠 WebSocket服务遍历本地保存的长连接信息检查 Redis 存储数据,并重新注册 的方式,重新写入到 Redis 节点,而 HTTP Heartbeat 虽然没有默认保存会话开始,但是可以依靠携带的 sessionId 从 RDBMS 中有限的恢复会话信息。(要注意的是 firstHeartbeatTime 或者是 lastHeartbeatTime 必须从后端服务恢复,而不应该信任 客户端 所提供的信息)
时区和服务器时间
如果潜在跨时区业务,需要在业务表和实际统计服务中引入 时区记录字段,在统计时长时,可考虑统一换算 UTC 时间进行统计。
另外针对潜在的客户端时间篡改 和 时钟偏移 的可能,在统计服务中应当针对事件时间做检查,不能完全相信客户端上报的时间戳。
5. 大规模数据下的低延迟查询与系统扩展性
针对低延迟查询工作
一句话总结:实时状态走 Redis,历史查询走汇总表,明细表分区归档,复杂分析走 OLAP当前在线员工列表、session 状态、超时 session 扫描都不查数据库,而是查 Redis。
针对 当前在线状态 查询:小规模可以使用单 Redis 主从;大规模下使用 Redis Cluster,并对 heartbeat ZSet、online users Set 做 hash 分片,Timeout Scanner 多实例按 shard 扫描,避免单 key 热点。
针对 历史查询:在查询业务是针对具体员工的在线时长统计时,可以依赖于 employee_online_daily 表,利用 userId+ date 索引快速筛选,返回相关结果。如果查询需要频繁针对 部门、职位 做查询,可以考虑维护:部门每日、职位每日 在线时长统计表,通过数据冗余换取低延迟查询
当明细表、数据量增大,在涉及复杂查询时查询性能可能潜在一定风险,可以考虑基于 时间尺度,对明细表进行拆解,尤其是 employee_online_session 和 employee_online_period,一方面可以减少实际统计时设计的物理统计行数,另外一面也方便后续的数据归档处理,而实际拆分技术可以考虑 分区表 和 物理拆表 两类技术。(当然考虑到 Mysql 的分区表支持存在诸多限制,如果采用Mysql 不建议使用分区表进行处理)
当然如果数据量已达到千万/亿级,在涉及到长时间周期、多因素聚合的复杂、大数据量查询工作的时候,我们可以依赖于 OLAP ,比较推荐的是采用 ClickHouse 或 Doris。利用分布式节点做分散统计,再经由聚合层统计的方式,保证低延迟查询效果。
针对系统扩展工作
在架构设计中,将 WebSocket Server、HTTP Heartbeat、Timeout Scanner Server、Statistics 等服务设计为支持集群部署的方案,在必要时可通过 K8S 水平扩展等类似机制增加服务高可用效果。
针对心跳事件的持久化,引入了消息队列提供异步和削峰的支持,上线/下线事件写 MQ,并交给统计服务异步消费,并写入数据库。可通过水平扩展 MQ 节点和统计服务 提供消息的消费速率。
总结
本文设计了一个基于 WebSocket、Heartbeat、Redis、MQ、RDBMS 和 OLAP 的在线状态与在线时长统计系统。
核心思路是:实时在线状态走 Redis,状态变更事件通过 MQ 异步解耦,统计服务负责 Session 明细、用户级在线区间和每日汇总,历史复杂查询交给汇总表或 OLAP 系统处理。
在实际落地时,还需要重点考虑多端登录、多标签页、异常断开、网络抖动、空闲识别、幂等消费、数据归档和水平扩展等问题。