仅用Redis实现视频在线人数统计:心跳Hash+定时清理方案 视频详情页上那个XX人正在观看的数字看着不起眼真要做得稳、做得准比想象中更容易翻车。我之前在一个播放类项目里被这个需求折腾过第一版方案用的是Redis Key过期事件用户进播放页就写一个带10分钟TTL的Key再监听Redis的expired事件去维护计数。结果上线没两天计数就开始漂移高峰时一个冷门视频都能显示几千人在看最后全部推倒换成了心跳写Hash 定时任务清理这套极简方案也就是标题里说的仅用Redis不监听失效时间用一个10分钟缓存窗口加一个定时任务全部搞定。这篇文章不写虚的直接分享完整方案在线人数的口径怎么定、为什么放弃Redis过期事件监听、Hash在线表的结构与分片、定时任务的清理逻辑和并发安全、高并发下的读优化以及生产环境踩过的坑。适合正在做视频网站、直播平台、在线课堂这类需要展示观看人数的开发者参考。1. 先回答一个产品问题在线人数到底该怎么定义1.1 详情页那个数字真实业务语义是热度在动手设计之前我建议先去找产品经理确认一件事这个X人正在观看到底要给用户传递什么信息。大多数视频平台的答案都是热度——告诉观众这个视频有点人气、值得点进去而不是真的承诺此时此刻有X个连接挂在播放器上。这个定位差异很关键。如果是热度展示就不需要、也不应该追求秒级实时。用户看到一个1.2万人正在观看过了几秒变成1.1万没有任何问题但如果这个数字从1万突然跌到100那才是产品事故。所以口径上应该追求平滑、稳定、能反映最近一段时间内有多少人在看。1.2 为什么实时在线是伪需求很多第一次接这个需求的人会下意识想用户播放时通知我上线离开时通知我下线差值不就是在线人数吗听起来很干净实操却全是坑。客户端能靠得住的信号只有两个进入播放页、定期上报播放进度。至于离开有太多种情况根本不给你通知的机会用户直接杀进程、App切后台被系统回收、断网、电梯里没信号、播放器崩溃。只要有一次下线事件没送达服务端就会永久多算一个人。A同学之前就在这个坑里爬了很久最后靠每隔30秒上报心跳的用户如果在3分钟内没再上报就算离线这个兜底逻辑才把数字拉回来。这不就反向印证了在线状态本质上只能靠最近活跃时间来推断指望客户端主动告知是不现实的。1.3 10分钟窗口的拆解所以方案的第一步是把在线重新定义为最近10分钟内有有效播放心跳的用户。这个10分钟是怎么来的我会反推播放器的心跳间隔通常是30秒或60秒。如果窗口小于两三个心跳周期一个用户稍微卡顿一下就会被踢出在线名单数字抖动会非常明显。产品上观众对在线人数的心理预期是这个视频最近挺火10分钟足够覆盖随手点开看了两眼再划走的绝大多数用户。技术上也合理10分钟窗口允许清理任务用60秒的频率去跑清理间隔占窗口的10%即使有误差也完全在可接受范围。口径定了之后技术指标就非常好拆了写路径是每个在线用户每30-60秒一次心跳写入读路径是每次详情页加载显示人数数据量级是同时有几个百万级播放的热门视频。剩下的就是怎么用Redis把这两条路径高效串起来。2. 三条路线对比Redis失效事件监听为什么被淘汰2.1 路线A每个用户一个TTL Key 监听expired事件这是很多人第一反应会想到的方案用户进播放页执行SET online:{videoId}:{userId} 1 EX 600同时开启Redis的keyspace notificationnotify-keyspace-events Ex订阅__keyevent0__:expired当Key自然过期时Redis推送事件收到事件后把对应视频的计数器减一。我第一版就是照着这个思路做的上线后发现了几个非常现实的问题第一notify-keyspace-events是一个全局配置开了之后所有Key的过期事件都会往订阅端推。在一个Key量很大的实例上这本身就会带来额外的网络和CPU开销。而且生产环境很多基础组件是共享Redis的改全局配置需要走一堆变更流程不是你想开就能开。第二过期事件不保证实时更不保证不丢。Redis的过期机制是惰性删除加定期抽样删除一个Key到了TTL时间并不一定立刻被物理删除事件自然也延迟。特别是在大Key、高写入的场景下过期事件延迟几十秒甚至几分钟都不奇怪。订阅客户端一旦重启、断连期间所有过期事件全部丢失计数就再也回不去了。第三也是最隐蔽的如果用SET online:{videoId}:{userId} 1 EX 600来刷新用户心跳这个操作实际上会覆盖旧Key并重新计算过期时间逻辑上没问题。但要统计数据得用SCAN匹配online:{videoId}:*去数Key个数。热门视频同时几万个KeySCAN一轮下来对Redis是实打实的压力统计接口根本不敢做得太频繁。2.2 路线B实时上线/下线事件第二个方案是播放器主动上报上下线。这条路前面已经说过最大的问题是下线事件永远不可靠。为了兜底最后还是要加心跳超时离线逻辑那既然都要靠心跳兜底了为何不直接让心跳成为唯一数据源这种方案还有另一个麻烦上线、下线消息量翻倍高峰期播放器一个网络波动瞬间一大堆下线重连消息打过来服务端还要做去重和防抖。2.3 路线C心跳Hash 定时任务清理本文方案最终方案可以概括成三句话用户心跳时把userId - 最后活跃时间戳写进对应视频的Hash里字段天然去重重复心跳只是更新值。一个定时任务每60秒扫描这些Hash把最后活跃时间超过10分钟的字段删掉。在线人数就是Hash的HLEN读路径O(1)不需要数Key不需要算差值。整个方案只用到了HSET、HSCAN、HDEL、HLEN这几个最基础的Redis命令不依赖任何Redis高级特性更不需要Keyspace事件通知。下面这张表是三条路线的对比对比项路线ATTL Key过期事件路线B上下线事件路线C心跳Hash定时清理可靠性过期事件可能丢失客户端异常退出无法通知纯服务端推断可靠实时性依赖Redis删除时机不可控好但有洪峰分钟级满足热度展示统计成本SCAN数Key贵实时维护计数简单HLENO(1)对Redis配置要求需开启keyspace notification无无实现复杂度中要处理事件丢失低但不可靠中定时任务逻辑所以最终选择C不是因为A和B不可行而是它们在可控性上都输给了C。作为一个接线上需求的工程师你要的不是大部分时候准而是出问题时能定位、能兜底、不会越漂越远。3. 核心存储设计Hash在线表 时间戳窗口的具体结构3.1 Key结构与字段说明我用的是这样的结构Keyvideo:online:{videoId}:{shard}类型HashField{userId}Value{lastActiveUnixTimestamp}秒级时间戳举个例子视频ID是10086userId是9527分片是5HSET video:online:10086:5 9527 1735000000这个命令的意思是用户9527在1735000000这个时刻活跃于视频10086。如果同样field再执行一次value就被更新成最新时间戳这就是心跳的幂等写入。为什么要用Hash而不是Set核心原因是Set的成员只有值没有额外空间去存最后活跃时间。如果只用Set你就不知道谁该被清理除非把10分钟不活跃变成每10分钟重建整个Set——那样数据闪断在线人数会周期性归零再恢复体验很差。Hash的value天然就是为这个场景设计的。3.2 分片规则避免大Key拖垮清理任务单个Hash理论上可以存几十万field但HSCAN完整扫一遍有几十万条目在清理任务里跑完一轮很耗时。所以我把每个视频的在线表按userId分片shard hash(userId) % SHARD_COUNTSHARD_COUNT取16还是32取决于单视频峰值在线人数的预算。我一般按单分片不超过5万field来估如果峰值10万人在线16片足够如果预计百万级直接32片。userId.hashCode()的结果是稳定的同一个用户会一直落在同一个分片不会出现一个用户同时出现在两片里导致去重失效的问题。查询在线人数就是把这16个分片的HLEN加起来HLEN video:online:10086:0 HLEN video:online:10086:1 ... HLEN video:online:10086:1516次O(1)命令耗时在毫秒级完全没有压力。3.3 内存占用到底有多大很多人担心所有在线用户都存在Redis内存会不会爆。我实际估算过假设userId是9位纯数字在Hash里存储时field大约10字节value用秒级整数Redis内部对这个Hash如果启用紧凑编码一个field的综合开销能压到40字节左右。10万在线用户大约4-6MB100个同时在播的热门视频也才几百MB。相比每个用户一个Key的方案——那种方式光是Key对象和TTL元数据就比Hash贵不少——Hash方案在内存上是有优势的。当然如果你的userId是UUID那种36字节字符串内存会明显上涨所以我建议线上尽量用数字ID或短ID做field。3.4 一个额外的元信息活跃视频注册表还有一个细节定时任务每60秒要扫哪些视频如果每次把所有历史视频全扫一遍冷门视频的空Hash会白白消耗时间。我维护了一个活跃视频注册表用ZSet实现当某个视频的在线Hash第一次有写入时执行ZADD video:active {timestamp} video:10086定时任务用ZRANGEBYSCORE video:active (now-2h inf)拿到最近有活跃的视频列表只清理这些如果一个视频清理后Hash为空就DEL掉对应的分片Key并在ZSet里移除该视频或等到它被新心跳重新加回来这个ZSet本身很小内存可忽略但能让定时任务避免无意义的空转。4. 定时任务清理10分钟过期窗口的落地与并发安全4.1 清理规则一句话两个关键点清理规则一句话把value小于当前时间减10分钟的field删掉。因为value就是用户最后一次心跳的时间戳这个判断逻辑在数学上完全等价于10分钟没有心跳的用户按离线处理。为什么清理频率是60秒而不是10分钟如果10分钟才清理一次Hash里会一直留着大量已经超过窗口的fieldHLEN返回的数字会明显偏大。用60秒的清理间隔最多只有60秒的过期残留相对10分钟窗口误差在10%以内而且这些残留会在下一轮被清掉。想要更干净可以把清理频率调到30秒但对大部分视频站点来说没必要。4.2 清理任务的代码实现下面是核心清理逻辑我按Spring Scheduled Jedis的写法贴出来换成其他语言和客户端思路完全一致Scheduled(fixedDelay 60_000) public void cleanExpiredOnlineUsers() { if (!tryLock(task:lock:online-cleaner, 60)) { return; } long now System.currentTimeMillis() / 1000; long deadline now - ONLINE_WINDOW_SECONDS; // 比如600 for (String videoKey : getActiveVideoKeys()) { for (int shard 0; shard SHARD_COUNT; shard) { String hashKey buildOnlineKey(videoKey, shard); cleanShard(hashKey, deadline); } } } private void cleanShard(String hashKey, long deadline) { String cursor 0; ListString toDelete new ArrayList(); do { ScanResultMap.EntryString, String scanResult jedis.hscan( hashKey, cursor, new ScanParams().count(500)); cursor scanResult.getCursor(); for (Map.EntryString, String entry : scanResult.getResult()) { long lastActive Long.parseLong(entry.getValue()); if (lastActive deadline) { toDelete.add(entry.getKey()); if (toDelete.size() 200) { jedis.hdel(hashKey, toDelete.toArray(new String[0])); toDelete.clear(); } } } } while (!0.equals(cursor)); if (!toDelete.isEmpty()) { jedis.hdel(hashKey, toDelete.toArray(new String[0])); } }有几个细节必须交代清楚HSCAN的COUNT 500只是一个提示值Redis不保证每次只返回500条实际可能多也可能少。代码里通过游标循环到0来保证完整遍历不能只扫一次就认为扫完了。删除用HDEL批量操作一次删200个field避免超大Hash场景下一次性构造太大命令。为什么不用相对简单的先收集全部再删因为一个分片可能有几万field全收集到内存再删会把这一次任务搞得很重分批删更平滑。HSCAN是游标式迭代不阻塞Redis、不保证快照一致性这对我们这个场景完全够用。它不会像KEYS命令那样把Redis卡住这是用它而不是用HKEYS/KEYS的根本原因。4.3 多实例部署时的分布式锁应用通常不止一个实例如果每个实例都跑同样的定时任务会出现重复清理虽然并发HDEL不会造成数据错乱但会白白消耗Redis资源和扫描带宽。我用一个很简单的Redis分布式锁SET task:lock:online-cleaner 1 NX EX 60拿不到锁的实例直接跳过这轮任务。锁的过期时间设成60秒是为了防止执行任务的实例挂掉导致锁永远不释放。正常任务执行时间远小于60秒锁的边界很安全。如果你的清理任务执行时间可能超过60秒把EX调大一些或者做续期。4.4 心跳写入和清理同时发生会不会出问题这是我自己当时最担心的问题实测下来结论是不会。如果用户心跳发生在清理扫描之前value是新的大于deadline不会被删。如果心跳发生在清理扫描之后但正好这个field刚被删掉下一次心跳会用HSET重新创建field数据照样恢复。如果心跳发生在HSCAN看到旧值、还没执行HDEL的间隙HDEL会把旧field删掉而HSET可能已经成功写入了新值。这里有一点竞态HSET写入新值后HDEL再删会把新值也删掉但这个用户的下一次心跳会在最多60秒内再次写入。也就是说极端情况下这个用户会暂时掉出在线列表但最多丢失一个心跳周期的展示不会造成永久性错误。要彻底消除这个竞态也可以比如清理前用WATCH/MULTI做事务或者用Lua脚本把检查时间戳并删除原子化。但对10分钟窗口展示人数这个业务来说一个用户最多在下一轮心跳时回来统计误差几乎不可感知。我建议先把简单的方案跑起来别过度设计。5. 查询与缓存HLEN统计和高并发下的读优化5.1 在线人数的查询链路基础版查询就是分片HLEN求和。代码很简单public long getOnlineCount(String videoId) { long total 0; for (int shard 0; shard SHARD_COUNT; shard) { total jedis.hlen(buildOnlineKey(videoId, shard)); } return total; }这个逻辑返回的值背后有定时任务保证Hash里剩下的都是最近10分钟内活跃的用户所以HLEN的结果就是在线人数。不需要再额外减去什么、过滤什么非常直觉。5.2 热点视频的读放大问题但如果你做过详情页就知道热门视频的详情页QPS可以到几千上万。每个请求都执行16次HLEN虽然只有毫秒级但乘以几千QPS也是实打实的Redis压力。而且业务上没有人需要秒级精确的数字。这一步才是标题里10分钟缓存的第二层含义把统计结果缓存起来10分钟内最多重算一次。具体做法定时任务每分钟清理完在线Hash之后顺手统计一遍每个活跃视频的HLEN总和写入缓存KeySET video:online:count:{videoId} {count} EX 600查询接口优先读这个缓存Key命中直接返回。没命中比如冷门视频从没被写入过缓存再实时做分片HLEN求和并回填缓存。这样热点视频的读请求全部命中缓存Redis的压力瞬间降了一个数量级。10分钟窗口本身是数据语义上的缓存这个EX 600是查询结果上的缓存两层一起才构成了完整的方案。为什么EX 600而不是EX 60如果查询缓存每60秒刷新一次详情页数字就会跟着清理任务节奏跳变。设成10分钟一次页面访问周期内数字相对稳定用户不会看到它频繁闪动。真的要细究定时任务每分钟刷新一次缓存更新鲜但把TTL设短会失去缓存兜底的作用Redis一旦短暂抖动短TTL的缓存会提前失效。我实测下来10分钟TTL 每分钟刷新日常表现最舒服。5.3 前端和后端的完整协作链路把整条链路串起来看播放器播放视频进入播放页时上报一次心跳之后每30秒上报一次POST /api/video/{videoId}/heartbeat参数带userId。服务端收到心跳先做基本校验视频是否存在、用户是否登录然后HSET video:online:{videoId}:{shard} {userId} {now}如果是新写入且该Hash之前为空需要ZADD video:active now video:{videoId}定时任务每分钟执行拿分布式锁从video:active拿到最近活跃的视频列表逐个分片HSCAN清理过期field清理完对一个视频的所有分片做HLEN求和SET video:online:count:{videoId} {sum} EX 600详情页加载GET /api/video/{videoId}/online-count服务端读GET video:online:count:{videoId}命中就直接返回数字没命中就实时HLEN求和并回填缓存。前端拿到数字展示当前X人正在观看每30秒轮询一次接口刷新。这六步全部围绕Redis展开全程没有引入任何额外中间件Redis挂了还有降级方案兜底这正是仅用Redis这四个字的价值。6. 生产落地踩过的坑、监控指标与降级方案6.1 多端登录的去重决策第一个要提前问清楚的是同一个用户同时在手机和电视上看同一个视频算1个人还是2个人如果field直接用userId哈希结构天然就把多端去重成1人如果产品想要设备数field就要改成{deviceId}:{userId}之类的组合Value还是时间戳。这个决策看起来小却会影响后面所有逻辑和估算。我建议默认按userId去重因为在线人数在大多数产品里指的是有多少个用户在观看而不是有多少个播放器在连接。真到需要按设备算的场景字段改一下就行清理逻辑和查询逻辑完全不用动。6.2 冷热视频与空Key释放定时任务只清理活跃视频注册表里的视频冷门视频在窗口过后自然会被移出注册表。但要注意如果一个视频曾经很火、现在没人看了它的在线Hash还留在Redis里占内存。清理任务判断一个视频的所有分片HLEN都归零后要做两件事DEL 掉所有分片Key从video:active移除该视频千万别留着空Hash否则积累久了会出现大量空Key占用内存和SCAN时间。可以给这个操作加一个阈值连续几轮清理都为空再删防止用户刚走又被新心跳拉回来导致的反复重建。6.3 监控与告警我上线后给自己配了三个核心监控指标在线表field总量正常情况应该随业务平稳波动持续上涨说明清理任务可能异常了。清理任务耗时HSCAN一个大分片如果耗时超过几秒说明某个视频的在线Hash已经大到需要扩容分片或者COUNT取值不合理。统计缓存命中率如果命中率低说明大量请求都在实时计算要考虑增加EX时间或提前预热。可能还要一个告警某个视频分片的HLEN突然超过预估上限。这个信号意味着该视频热度远超预期需要及时扩容分片。6.4 降级方案Redis抖动时最怕的是查询接口也跟着抖。我的做法是查询在线人数的接口如果读Redis失败直接返回后端预热好的缓存值或者返回上一次成功值前端可以显示-不显示错误数字。心跳上报接口如果写Redis失败返回一个可重试的标识播放器用指数退避重试而不是高频重试把Redis打得更死。定时任务拿不到锁或者Redis异常时只记录告警不阻塞业务。在线Hash在Redis里稍微多膨胀几分钟没有大碍Redis恢复后清理任务会继续收敛。6.5 Redis Cluster模式下的一个隐藏坑如果你们的Redis是Cluster模式会按Key的hash slot路由到不同节点。我上面用的Key格式video:online:{videoId}:{shard}在不同shard下可能落在不同节点这本身没毛病因为查询本来就要跨16个分片求和。但如果某个业务要求一个视频的所有分片落在同一个节点比如要做Lua脚本原子操作就必须用hash tagvideo:online:{videoId}:{shard}改成video:online:{{videoId}}:{shard}加上hash tag之后Redis Cluster只对花括号内的内容计算hash slot同一个videoId的所有分片会固定在一个节点上。要不要加取决于你的客户端、命令组合和对数据局部性的要求。单机Redis不需要考虑这个问题。6.6 最后一个实用小技巧清理任务里给在线Hash的Value存的时间戳是秒级整数。如果想要更省内存可以把userId从字符串改成整数编码Redis内部在字段能转成整数时采用的编码更紧凑。具体可以看一下Hash相关的listpack/ziplist参数配置不同Redis版本配置项不同。实测中10万在线用户大约6MB内存的方案调优后能压到3-4MB。对大多数业务不做这步也完全没有问题但性能答辩或者汇报时提一嘴会显得你确实抠过细节。我在实际部署这套方案时还有一个习惯清理任务跑完后把本轮删除了多少field、每个视频还剩多少分片非空打到日志里。这个日志平时看不出什么用一旦有人反馈在线人数异常翻出日志就能立刻判断到底是心跳写入出了问题、还是清理任务扫得太狠定位效率会高很多。