有些需求,乍一看平平无奇,真正动手做的时候才发现水很深。比如“统计一个视频的在线观看人数”,很多人第一反应就是上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存放用户ID,value存放最近一次观看的时间戳。键的设计强烈建议带上业务前缀和视频ID,比如:
video:online:10001其中10001是视频ID。field是用户ID,value是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() { Set<String> 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 Set<String> scanOnlineKeys() { Set<String> keys = new HashSet<>(); ScanOptions options = ScanOptions.scanOptions().match("video:online:*").count(1000).build(); try (Cursor<String> cursor = redisTemplate.scan(options)) { while (cursor.hasNext()) { keys.add(cursor.next()); } } catch (IOException e) { // 记录日志,下次任务再补偿 } return keys; }cleanExpiredFields是核心方法,使用HSCAN避免一次性拉取全部field:
private void cleanExpiredFields(String key) { long now = System.currentTimeMillis() / 1000; long threshold = now - ONLINE_THRESHOLD_SECONDS; ScanOptions options = ScanOptions.scanOptions().count(500).build(); List<String> expiredFields = new ArrayList<>(); try (Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.opsForHash().scan(key, options)) { while (cursor.hasNext()) { Map.Entry<Object, 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; List<String> commands = new ArrayList<>(); commands.add("HSET " + key + " " + userId + " " + now); commands.add("EXPIRE " + key + " " + ONLINE_EXPIRE_SECONDS); redisTemplate.executePipelined((RedisCallback<Object>) 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这段脚本其实可以简化为无条件HSET+EXPIRE,因为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先扫描一遍、统计完、最后再删除。但考虑到我们本来就是为了简化方案,这种极低概率的跳变完全可接受。
我把这类问题整理成了一张排查速查表:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 在线人数突然变成0 | Hash过期被整体删除 | 检查是否写入时刷新了EXPIRE |
| 在线人数一直不下降 | 过期field没有被清理 | 确认定时任务是否执行了cleanExpiredFields |
| 在线人数虚高 | 用户心跳包不断刷新时间戳 | 改为仅播放开始时写入一次 |
| 统计缓存频繁消失 | 缓存TTL设太短 | 设成Hash过期时间的1.2倍 |
| 内存持续上涨 | Hash变成永久key,field不断累积 | 全局检查是否有带过期时间的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 + 定时任务”的方案,不是最花哨的,但胜在简单、可靠、好维护。如果你接到的需求也恰好是“不追求秒级精确、不想上一堆中间件”,完全可以照着这套思路落地,至少能少踩一半的坑。