仅用Redis+定时任务实现视频在线人数统计的缓存方案
有些需求乍一看平平无奇真正动手做的时候才发现水很深。比如“统计一个视频的在线观看人数”很多人第一反应就是上WebSocket、上Netty、上长连接网关再配一套实时计数服务。但有一次我接到的需求很明确只允许用Redis不引入其他中间件不监听Redis的key失效事件统计结果允许10分钟左右的延迟。第一反应是“就这”但真落地的时候踩了不少坑也把方案的细节磨了一遍。这篇文章就把这套“仅用Redis实现视频在线人数统计10分钟缓存定时任务兜底”的方案完整拆开讲清楚。先说结论这个方案的核心思路是“用Redis的Hash结构存观看痕迹 定时任务做离线聚合 缓存结果10分钟过期”。不依赖Redis的过期监听所以不需要开启notify-keyspace-events也不需要额外的消息队列和消费者纯靠一套定时任务就能跑。听起来简单但里面有几个关键细节比如Hash的field怎么设计、过期时间怎么算、去重怎么做、缓存击穿怎么防每一处都能看出一个开发者是“会Redis”还是“只会用Redis”。1. 项目背景与需求拆解为什么不用实时方案先还原一下当时的需求场景。某公司内部要做一个视频学习平台需要统计“正在观看某个视频的人数”用于后台运营和课程热度展示。技术约束有三条只准用Redis不监听Redis失效时间统计结果允许10分钟缓存。这三条约束其实已经把方案死死按在一个方向上不追求秒级精确而是用“近期活跃”代替“当前在线”。10分钟缓存的含义本质上就是把“在线人数”重新定义成“最近10分钟内有多少用户看过这个视频”。这跟“实时在线”是两回事但对大多数内容型平台来说够用了。想想看视频网站显示的“XX人在看”一般也不是一秒不差的实时数字而是有一定时间窗口的近似值。用户不会盯着数字等它跳变运营只看量级和趋势。为什么不监听Redis失效时间有几个现实原因。监听key过期需要开启notify-keyspace-events而生产环境改Redis配置需要走审批流程如果用的是云Redis实例有些参数甚至不允许修改就算能改消息可靠性也是个问题Redis的过期通知是“尽力而为”的如果消费者挂了、消息积压了或者key在通知发出前就被删了都会导致漏统计。最要命的场景是一个大视频10分钟内来了几万人key一过期全部观看记录瞬间消失而通知消息只发一条消费者还没来得及处理Redis那边已经删完了。所以在这套方案里定时任务 Hash结构 10分钟过期缓存成了最优解。它不需要额外组件不需要改Redis配置不依赖消息通知的可靠性唯一付出的代价就是“统计结果最多有10分钟的滞后”完全在可接受范围内。2. 方案选型背后的逻辑为什么是Hash而不是Set先直接给出存储结构的设计。对每个视频使用一个Hash类型的key来存储观看记录。Hash的field存放用户IDvalue存放最近一次观看的时间戳。键的设计强烈建议带上业务前缀和视频ID比如video:online:10001其中10001是视频ID。field是用户IDvalue是Unix时间戳秒级。为什么用Hash不用Set这是整个方案里最值得说的一个选型点也是很多人容易想当然的地方。最早的直觉方案可能是用一个Set每来一个用户就SADD video:online:10001 userId定时任务统计时用SCARD查一下size。但Set有一个硬伤存进去的用户不会自动消失。假设8点有100个人看了视频9点这100个人都走了Set里还是100个。怎么清理要么定时删除要么每次统计时把所有用户捞出来逐个对比时间戳这又回到了“先查数据再判断”的老路既费内存又费网络。Hash的好处在于value天然可以存一个“时间戳”。每次统计的时候只需要把field的时间和当前时间做差超过阈值比如10分钟的field删掉剩下的就是有效在线用户。更妙的是Hash可以配合HSCAN做增量清理不需要一次性把整个Hash拉出来。有人可能会说Set里也可以存userId:timestamp这样的字符串统计时遍历解析。理论上可以但问题很多。字符串拼接后长度更长、内存更大解析还要写split逻辑更关键的是Set没法在Redis服务端做任何原子操作所有“遍历判断删除”都得靠客户端循环网络开销呈指数上升。Hash虽然没有办法一条命令完成“统计并清理”但它配合HSCAN和HDEL可以做到分页遍历、批量删除整个过程的客户端内存占用和网络开销都可控。再补充一点Redis内存效率上Hash在field数量少的时候会使用ziplist编码比Set的hashtable编码更省内存。一个视频几万用户的情况下Hash依然能保持不错的内存表现。这个优势在小规模起步、突然爆量的时候尤其明显。所以选型结论Hash结构是为了配合时间戳做自动淘汰而Set只能做“永久在线”的出勤表。这正是Hash在这个场景下无法被替代的原因。3. 核心架构与工作流程定时任务如何完成聚合整个方案的运行流程可以拆成三层写入层、清理层、读取层。写入层用户观看视频时在业务代码里调用Redis写入观看记录。不需要实时统计也不需要在写入时做一些复杂的计算只做简单写入。采用HSET命令同样一个用户10分钟内重复观看时间戳覆盖即可天然去重。HSET video:online:10001 user_123 1700000000然后设置整个Hash的过期时间为10分钟这里有个门道后面细说。EXPIRE video:online:10001 600清理层定时任务每1分钟或每30秒跑一次对每个Hash执行一次“清洗统计”操作。清洗的过程就是遍历所有field把时间戳超过10分钟的field删掉。统计的结果写入一个缓存key缓存key的过期时间也是10分钟。video:online:count:10001 - 23读取层业务端查询在线人数时直接读video:online:count:10001这个缓存如果缓存不存在则返回0或者触发一次实时计算。这里需要解释一个容易被忽略的设计为什么统计结果要单独建一个缓存key而不是直接读取Hash的size原因在于Hash里有可能残留着少量过期字段没有清理干净。比如定时任务跑的时候正好碰到Hash被删了、或者某次HSCAN没扫完就超时了都会导致Hash里的实际field数不等于真实在线人数。如果直接读取HLEN结果会有偏差。而统计任务每次算完都把结果固化到独立缓存中读到的数字永远是上一次定时任务“清洗干净之后”的数字语义更准确。更重要的是独立缓存key可以设置自己的过期时间统计结果天然具备“10分钟缓存”的特性。查询端永远不碰Hash只碰缓存key压力极小简直是教科书级的“读写分离”。整个流程用文字大概描述就是用户看视频 - 写入Hash记录刷新Hash过期时间定时任务扫Hash - 清洗过期field - 统计剩余field个数 - 写入统计缓存查询端读统计缓存 - 返回在线人数4. 定时任务的具体实现从Redis命令到Java代码接下来进入实操环节。我用Java Spring Boot RedisTemplate来写这套定时任务如果你用的是其他语言或框架核心思路完全一致照搬命令就行。先定义几个常量private static final String ONLINE_KEY_PREFIX video:online:; private static final String COUNT_KEY_PREFIX video:online:count:; private static final long ONLINE_EXPIRE_SECONDS 600L; private static final long ONLINE_THRESHOLD_SECONDS 600L;ONLINE_EXPIRE_SECONDS是整个Hash的过期时间ONLINE_THRESHOLD_SECONDS是判定“在线有效”的时间窗口。这两个值可以不同比如Hash做成15分钟过期但统计只算10分钟内的活跃用户这样在定时任务漏跑几次的情况下Hash里还保留着数据不会丢记录。这种设计就是所谓的“容错冗余”我个人强烈建议这两个值不要设成一样要给定时任务留出一定的偶发故障空间。定时任务采用Scheduled固定间隔30秒执行一次Component public class OnlineCountTask { Autowired private StringRedisTemplate redisTemplate; Scheduled(fixedDelay 30000) public void calcOnlineCount() { SetString keys scanOnlineKeys(); for (String key : keys) { long videoId extractVideoId(key); cleanExpiredFields(key); long count countValidUsers(key); writeCountCache(videoId, count); } } }scanOnlineKeys使用ScanOptions完成模糊匹配不要用KEYS video:online:*这个命令在线上是灾难会阻塞Redis单线程。private SetString scanOnlineKeys() { SetString keys new HashSet(); ScanOptions options ScanOptions.scanOptions().match(video:online:*).count(1000).build(); try (CursorString cursor redisTemplate.scan(options)) { while (cursor.hasNext()) { keys.add(cursor.next()); } } catch (IOException e) { // 记录日志下次任务再补偿 } return keys; }cleanExpiredFields是核心方法使用HSCAN避免一次性拉取全部fieldprivate void cleanExpiredFields(String key) { long now System.currentTimeMillis() / 1000; long threshold now - ONLINE_THRESHOLD_SECONDS; ScanOptions options ScanOptions.scanOptions().count(500).build(); ListString expiredFields new ArrayList(); try (CursorMap.EntryObject, Object cursor redisTemplate.opsForHash().scan(key, options)) { while (cursor.hasNext()) { Map.EntryObject, Object entry cursor.next(); long timestamp Long.parseLong(entry.getValue().toString()); if (timestamp threshold) { expiredFields.add(entry.getKey().toString()); } } } // 批量删除 for (String field : expiredFields) { redisTemplate.opsForHash().delete(key, field); } }countValidUsers统计剩余field数private long countValidUsers(String key) { Long size redisTemplate.opsForHash().size(key); return size null ? 0L : size; }写入统计缓存private void writeCountCache(long videoId, long count) { String countKey COUNT_KEY_PREFIX videoId; redisTemplate.opsForValue().set(countKey, String.valueOf(count), ONLINE_EXPIRE_SECONDS, TimeUnit.SECONDS); }到这里整个定时任务的核心逻辑就完整了。注意几个细节fixedDelay而不是fixedRate。fixedDelay表示上次任务执行完之后再过30秒执行下一次避免上一次还没跑完下一次又叠加启动导致任务堆积。HSCAN每次返回的数量count不是精确限制Redis内部有自己的算法这里设置500或1000都行但要理解它只是“提示”。删除field应该是批量操作。一个Hash可能有几万个field逐个删除网络开销太大建议攒一批用HDEL key field1 field2 ...删除代码里是循环删除实际生产建议批量。5. 业务侧写入的细节什么时候写怎么写有问题吗写入侧的代码同样值得细抠。最理想的写入时机是“用户开始播放视频”时而不是“用户每次发心跳包”时。这两个差别很大。如果在播放期间每5秒发一次心跳每次都执行HSET那么Redis的写入压力会成倍增加同时时间戳不断刷新用户的在线时长会被无限拉长直到他离开页面很久后才被判定离线。这种设计表面上看“在线人数更实时”实际上会让在线人数虚高10分钟窗口永远清不掉活跃用户。推荐做法在播放器初始化、视频真正开始播放的时候写入一次如果前端能检测到用户切后台、关页面也可以主动调一次接口“离线”但不是必须的。因为就算不主动离线10分钟后定时任务也会把他清掉。写入时的EXPIRE刷新也是有讲究的。很多人只写了HSET忘了刷新过期时间就会导致一个Hash在10分钟后被整体删除但Hash里可能还有大量“在线”用户没被统计过。这个问题在低流量时段尤其明显上午10点有人看了视频Hash的TTL设10分钟10点10分被删但定时任务可能10点15分才跑统计窗口直接错过。所以写入时必须带上EXPIRE而且这个过期时间建议是统计窗口的1.5倍到2倍给定时任务留出足够的缓冲。考虑到代码健壮性推荐用Pipeline批量提交这两个命令public void recordView(long videoId, String userId) { String key ONLINE_KEY_PREFIX videoId; long now System.currentTimeMillis() / 1000; ListString commands new ArrayList(); commands.add(HSET key userId now); commands.add(EXPIRE key ONLINE_EXPIRE_SECONDS); redisTemplate.executePipelined((RedisCallbackObject) connection - { connection.stringCommands().set(...); // 根据实际封装调整 return null; }); }Pipeline的好处是减少一次RTT往返延迟写入性能提升明显。更重要的是HSET和EXPIRE放在同一个Pipeline里两个操作在逻辑上是“要么都成功、要么都失败”的相邻操作虽然Pipeline不保证原子性但两个命令连续发送异常概率极低比分开两次发送可靠得多。更极致一点可以用Lua脚本把两步操作合并成一个原子操作if redis.call(exists, KEYS[1]) 1 then redis.call(hset, KEYS[1], ARGV[1], ARGV[2]) redis.call(expire, KEYS[1], ARGV[3]) else redis.call(hset, KEYS[1], ARGV[1], ARGV[2]) redis.call(expire, KEYS[1], ARGV[3]) end return 1这段脚本其实可以简化为无条件HSETEXPIRE因为HSET对不存在key会自动创建。用Lua脚本的意义在于不管key存不存在两步操作在Redis服务端是原子的中途不会被其他命令插入最大限度保证了数据和过期时间的写入一致性。生产环境建议直接用Lua这是“熟悉Redis的人”和“会用Redis的人”的一个重要分水岭。6. 缓存击穿与并发查询你说别人读什么查询端的逻辑看起来最简单但坑也不少。先看最直接的实现public long getOnlineCount(long videoId) { String countKey COUNT_KEY_PREFIX videoId; String value redisTemplate.opsForValue().get(countKey); if (value null) { return 0L; } return Long.parseLong(value); }如果缓存不存在直接返回0表面上看没问题但会引入一个体验问题视频刚刚上传还没有人看过此时前端查询会显示“0人在看”。等第一个用户开始看视频至少要等定时任务跑完人数才会变成1。如果定时任务间隔是30秒那这30秒内显示0用户会觉得“这视频没人看”相对影响点击意愿。更严重的场景是大流量视频突然爆火成千上万的请求同时查询一个还没有缓存的视频ID直接穿透到0用户体验不好。但更早碰到的问题可能是定时任务还在跑而统计缓存key刚刚过期所有请求都会走回源逻辑。解决思路有两个层面。第一层确保几乎不会出现缓存不存在的情况。定时任务启动时全量扫描所有Hash并写入统计缓存每次写入Hash时也可以顺手初始化一个统计缓存比如设置初始值0过期时间和Hash一致。这个“顺手”很关键它保证了“视频有观看记录”和“统计缓存存在”在绝大多数情况下是绑定的。第二层如果统计缓存真的不存在了说明这个视频当前没有活跃用户或者定时任务还没跑到它返回0没有问题。不要为了“精确”去实时扫描Hash那样会把压力全部引到Redis上开头说的10分钟缓存就失去了意义。高并发场景下还有个细节如果查询量极大统计缓存key过期瞬间查询端会同时打到Redis并同时发现缓存不存在。虽然我们选择返回0但如果有降级逻辑比如触发重新统计就会出现缓存击穿。解决办法是加一层本地锁或者互斥锁保证同一时间只有一个线程去触发重新统计。public long getOnlineCount(long videoId) { String countKey COUNT_KEY_PREFIX videoId; String value redisTemplate.opsForValue().get(countKey); if (value ! null) { return Long.parseLong(value); } // 双重检测加锁重建 synchronized (this) { value redisTemplate.opsForValue().get(countKey); if (value ! null) { return Long.parseLong(value); } long count calcOnlineCountDirectly(videoId); redisTemplate.opsForValue().set(countKey, String.valueOf(count), ONLINE_EXPIRE_SECONDS, TimeUnit.SECONDS); return count; } }我建议把“实时计算一次”做成兜底方法它直接扫描对应Hash、清洗过期field、统计size、写回缓存。这样查询端的最大延迟就是一次Redis扫描的时间通常在几十毫秒内不会给用户造成明显卡顿。但如果视频热度极高建议把这个兜底也关掉直接返回0宁可数字平滑不准也不要给Redis增加瞬时压力。7. 定时任务的空跑问题在线人数为何变0实际生产中最容易踩的坑就是某个视频明明一直有人看统计结果却突然变0。排查思路基本就两种Hash被整体删了或者统计缓存写入了错误值。先看第一种。Hash被整体删除最常见的原因就是写入侧没有刷新EXPIRE。用户A在10:00看视频写入HSET并设置10分钟过期10:10过期用户B在10:12看视频如果写入侧只执行了HSET没有执行EXPIRE此时key已经不存在HSET会新建一个Hash并不带过期时间——这个Hash就变成了永久key。如果定时任务每次扫描时只清field不更新过期时间这个Hash将永远存在field越积越多内存涨到怀疑人生。第二种情况更隐蔽。定时任务扫描时因为是全量SCAN单次扫描所有video:online:*需要一定时间。如果视频数量特别多比如几万个一轮扫描可能要跑几分钟。而统计缓存设置了10分钟过期那么还没被扫到的视频其统计缓存已经过期了查询端可能返回0也可能触发兜底逻辑看起来就是“人数跳变”。针对第二种情况我建议给统计缓存设置一个比Hash过期时间更长的TTL。比如Hash过期时间600秒统计缓存过期时间720秒这样即使定时任务因为量大延迟统计缓存也不会提前消失。不会有用户看到“人数突然变0”的尴尬。还有一种情况定时任务执行时某个视频的Hash刚好被EXPIRE删除了导致这一轮统计结果为0并写入缓存。下一轮如果又有用户观看Hash重建统计结果变成1。这就是“在线人数”从0到1的跳变过程。要解决的话可以在删除Hash时不直接依赖Redis过期而是定时任务里做“软删除”——把过期Hash先扫描一遍、统计完、最后再删除。但考虑到我们本来就是为了简化方案这种极低概率的跳变完全可接受。我把这类问题整理成了一张排查速查表现象可能原因排查方式在线人数突然变成0Hash过期被整体删除检查是否写入时刷新了EXPIRE在线人数一直不下降过期field没有被清理确认定时任务是否执行了cleanExpiredFields在线人数虚高用户心跳包不断刷新时间戳改为仅播放开始时写入一次统计缓存频繁消失缓存TTL设太短设成Hash过期时间的1.2倍内存持续上涨Hash变成永久keyfield不断累积全局检查是否有带过期时间的HSET逻辑8. 项目落地中的一些注意事项除了上面的核心流程还有几个边角细节虽然小但影响很大。关于键的回收时机定时任务扫描时如果发现某个Hash的HLEN等于0也就是一个活跃字段都没有了建议顺手把它删掉。否则大量的空Hash会占据Redis内存日积月累也是不小的开销。同时也可以把这个视频的统计缓存一并删除或者设为0避免查询端直接穿透到既有Hash。if (redisTemplate.opsForHash().size(key) 0) { redisTemplate.delete(key); redisTemplate.delete(countKey); }关于统计时间窗口的粒度10分钟窗口定时任务30秒跑一次那“在线人数”在每个时刻的语义都是“过去10分钟内观看过的去重用户数”。如果产品希望更像“实时”可以缩短窗口到5分钟定时任务间隔降到15秒。核心代码完全不用改只调整两个常量即可。这正体现出这套设计的高扩展性。关于Redis内存估算一个Hash的field包含用户ID和时间戳。假设用户ID是8个字节的Long时间戳是10个字节的字符串加上field本身的key一个field大概占用40~60字节。10万用户同时在线的话一个视频的Hash内存大约是5MB左右对于一台内存几GB到几十GB的Redis来说完全能扛住。但如果平台有上万个热门视频每个都有几万field总数据量就要认真估算了。此时可以引入时间分片每10分钟一个Hash或者只保留热门视频的key冷门视频不写入统计数据从源头上控制内存。关于多实例部署的定时任务公司可能有好几个应用实例如果每台机器都部署定时任务会发生同一个Hash被多台机器同时扫描、统计虽然最终结果一样但会造成重复的Redis扫描开销还可能在清理field时互相干扰。解决办法是引入分布式锁或者用一个独立的Job调度平台来触发。鉴于本方案目标是“简化”如果只有一台机器跑任务可以用Scheduled如果多实例部署强烈建议加一个Redis分布式锁保证同一时间只有一个实例在跑统计。锁可以用Redisson实现30秒超时代码量很小。关于监控与告警定时任务最怕静默失败。建议在任务执行完后记录扫描了多少个key、清理了多少field、统计耗时多久输出到日志或监控平台。如果某次扫描的key数量为0而Redis里明明有数据就要立刻排查。另外一个很实用的监控指标是“统计缓存命中率”如果大量查询都是返回0说明定时任务很可能没有覆盖全量视频需要留意一下。9. 这套方案还能怎么扩展最后聊点个人经验层面的东西。这个方案虽然是为了一个具体需求设计的但它具备很强的通用性稍微换个壳就是另一套方案。比如“最近10分钟活跃用户数”、“直播间最近几分钟的互动人数”、“某个活动的参与热度”都能套用同一套Hash 定时任务 缓存的结构。区别只在于field的语义从“用户ID”换成“设备ID”、“会话ID”等等统计逻辑完全不变。如果未来需求升级为“需要秒级精确在线人数”这套方案也不需要推倒重来。可以在Hash基础上补充一个WebSocket连接管理模块维护一份“真实在线连接”的列表然后让统计任务同时融合Hash和连接列表的数据最终输出一个更准确的结果。也可以接入消息队列把观看行为丢到MQ由消费者做更细粒度的统计与清洗。如果Redis内存压力变大还可以把统计逻辑从“应用层定时任务”下沉到Redis函数Redis Function或Lua脚本让Redis内部完成遍历和统计把结果直接写到统计缓存key中避免大数据量从Redis传到应用层再传回来。这个方法具体实现时需要注意脚本执行耗时超过Redis的lua-time-limit可能引发阻塞但配合HSCAN分页执行可以大幅降低单次脚本的执行时间。我个人在实际项目里更倾向于给这一套方案加一个“风控开关”流量小时用30秒跑一次的定时任务足够如果某天全站大促、流量暴增就把定时任务间隔调整到10秒并把Hash的过期时间从10分钟缩短到5分钟。代价是Redis的写入和扫描压力变大但在线人数会显得更“活”。这种在“精确性”和“资源消耗”之间动态调节的能力是固定方案给不了的也是这套设计最大的价值之一。最后再分享一个在自己项目中踩过一次的坑。最初我把统计缓存设计成“过期时间为0”也就是永久不过期自以为省事结果视频下架后统计缓存还在接口一直返回一个过气的数字。后来改成跟着Hash的过期时间走视频没了、Hash没了、统计缓存自然也没了才真正干净。这套“仅用Redis 定时任务”的方案不是最花哨的但胜在简单、可靠、好维护。如果你接到的需求也恰好是“不追求秒级精确、不想上一堆中间件”完全可以照着这套思路落地至少能少踩一半的坑。