视频点播场景Redis-SDK封装实战:从序列化到分布式锁
2026/9/8 7:00:03 网站建设 项目流程

做视频点播的同学应该都有体会:用户刷视频时,最忙的往往不是数据库,而是 Redis。播放量计数、热门榜、断点续播、转码任务去重、临时播放凭证……这些高并发场景全都压在 Redis 上。正因为 Redis 在链路里这么关键,我在项目里推动封装了一套统一的 Redis-SDK,把客户端选型、序列化方案、连接池参数、缓存兜底逻辑全部收敛到一层,业务方不需要再关心 Redis 底层细节,只用面向业务方法调用。这篇文章就把这套方案从设计到落地的过程拆开讲清楚,适合正在做视频点播、直播回放或短视频类后端,以及想给团队封装缓存中间件的同学参考。

1. 项目概述与核心需求拆解

1.1 视频点播系统里 Redis 到底在扛什么活

很多没做过视频业务的同学,对 Redis 的理解停留在"缓存热数据"这个层面。但真实视频点播场景里,Redis 承担的角色远不止缓存,我随手列一下当时系统里依赖 Redis 的核心链路:

  • 计数器:视频的播放量、点赞数、收藏数。用 String 的 INCR 指令,秒级累加,再异步刷回 MySQL。

  • 排行榜:热门视频榜、飙升榜。用 ZSet 存储视频 ID 和热度分值,按分钟、小时、天粒度聚合。

  • 用户观看记录:断点续播进度。用 Hash 记录 userId -> videoId -> 播放进度秒数,用户在历史记录里点开一个视频,几毫秒就能拿到上次的播放位置。

  • 首页Feed与搜索结果缓存:视频基础信息、封面图、播放 URL 组装后的 JSON 结果,缓存到 Redis,避免每次请求都打 MySQL 和对象存储。

  • 分布式锁:转码、审核、抽帧这些异步任务,在多实例部署时用分布式锁保证同一个视频同一时刻只有一个任务在执行。

  • 临时播放凭证:点播播放地址通常会拼接签名过期参数,这些凭证和视频基本信息一起临时存放在 Redis,设置短 TTL,到期自动失效。

可以说,视频点播系统里的 Redis 是实打实扛流量的一线组件。它一旦抖动,播放列表加载、历史记录、热门榜这些接口大概率会跟着超时,用户体感就是"进不去"或"一直转圈"。所以我们在最初做系统设计时,就把 Redis 定位成核心基础设施,而不是随手可换的工具。

1.2 为什么需要单独封装一套 Redis-SDK 而不是直接用客户端

团队早期图省事,业务模块直接注入 Spring Data Redis 的 RedisTemplate,或者直接用 Jedis 连接池。结果项目跑到几百万日活的时候,问题就暴露了:

首先,序列化方式完全失控。有的服务用 JDK 默认序列化,有的用 Fastjson,还有的直接往 Redis 里塞 JSON 字符串。key 的命名更是五花八门:videoplaycountplay_count_123video:playCount……同一个 key 在不同服务里叫法都不一样。Redis Desktop Manager 打开一看,满屏二进制乱码,排查数据全靠猜。

其次,连接管理混乱。每个服务各建各的连接池,参数随手填,没有统一压测标准。有些服务 maxTotal 设置 8,流量一来立刻连接拒绝;有些服务连接泄漏,几百个连接挂在那不回收,Redis 端连接数被打满。

最后,缓存异常没有兜底。当 Redis 抖动或者热 key 过期瞬间,请求直接穿透到 MySQL,数据库压力陡增。业务方来不及做互斥锁和降级逻辑,只能事后补救。

所以封装一套统一的 Redis-SDK 就成了刚需。它不是要把 Redis 封装得多么复杂,相反,它的核心目标是统一规范、收敛风险、提供开箱即用的高级能力。这套 SDK 在团队里推广后,新同学接入缓存只需要调用一个方法,不需要理解底层客户端和连接池细节,出问题的概率直线下降。

2. 方案选型与整体设计思路

2.1 客户端选型:Jedis、Lettuce 还是 Redisson

在选客户端之前,我先梳理了市面主流的三个 Java 客户端:Jedis、Lettuce、Redisson。它们的侧重点完全不同,选错后面会很被动。

客户端线程模型与连接方式优势需要注意的点
Jedis阻塞式,每个实例对应一个物理连接,必须靠连接池复用简单直观,起步快在高并发下连接数多,性能天花板较低
Lettuce基于 Netty,单连接多线程复用,Spring Boot 默认使用连接数少、响应快、支持集群和哨兵排障时连接模型相对抽象
Redisson封装了大量分布式对象和服务,分布式锁、信号量、延迟队列等开箱即用的高级能力,社区活跃依赖较厚,需要控制引入范围

我的选择策略是:底层用 Lettuce,分布式锁和高阶场景引入 Redisson,业务代码不直接依赖底层客户端。原因很简单,视频点播系统的 Redis 操作以读多写少、短命令为主,Lettuce 的共享连接模型能显著降低连接数,配合连接池也能防止单个慢命令阻塞整个链路。而 Redisson 提供的是成熟稳定的分布式锁、RateLimiter 等高级服务,没有必要自己造轮子。

2.2 SDK 的分层设计与模块划分

SDK 刚启动时,要是把所有代码堆在一个包里面,后面维护会非常痛苦。我按职责拆成四层,依赖方向自上而下:

  • 配置层:负责读取 application.yml 中的 Redis 配置,创建 LettuceConnectionFactory、连接池参数、RedisTemplate 或者自定义的缓存模板,同时支持单机、哨兵、Cluster 三种模式。

  • 核心操作层:封装最基础的缓存操作,包括 get、set、del、expire、HSet、HGet、ZAdd、ZIncrBy 等方法,并且统一序列化和 key 命名规则。

  • 高级能力层:提供分布式锁、限流器、布隆过滤器、防击穿/穿透逻辑等能力,业务方不需要理解具体实现原理,直接调用lock.tryLock()这类方法。

  • 业务场景层:针对视频点播业务,封装 VideoCacheService、PlayRecordService、HotRankService 等,让接口面向业务语义而不是 Redis 指令。

分层之后有个很直观的好处:底层客户端想换掉时,只影响配置层和核心操作层,业务代码完全无感知。我甚至见过有的团队从 Jedis 切换到 Lettuce,业务层零改动,这就是分层设计的价值。

2.3 序列化策略:避免乱码和跨语言问题

序列化是整个 SDK 里最容易被低估、后期最头疼的环节。

当时我打开 Redis 看到满屏\xAC\xED\x00\x05这类字节,第一反应就是某个服务用了 JDK 默认序列化。这个做法在 Java 服务内部自娱自乐还行,一旦遇到下面两种情况就会踩坑:

  • 客户端这边用 Java 写的,但日志系统或者其他语言写的数据清理脚本读取不到 Java 序列化的二进制。
  • Redis 里的数据需要人工排查时,可读性差,很难快速定位问题。

我们的方案很明确:key 和 Hash 的 field 一律用 String 序列化,Value 统一使用 JSON 序列化。StringRedisTemplate 本身就是这个套路,默认 String 序列化,配合 Jackson 或者 Fastjson2 手动把对象转成 JSON 字符串存储。这样 Redis 里的数据肉眼可读,出问题可以直接用 redis-cli 查看和调试,还方便后续让 Go、Python 的脚本读取同一份数据做分析。

关于 JSON 工具选型,我当时的经验是:如果团队对性能敏感,可以用 Fastjson2;如果更看重稳定和生态,Jackson 更稳。但不管选哪个,都要在 SDK 内部做统一封装,不要在业务代码里各写各的 ObjectMapper。

3. 实操:从零搭建一套视频点播场景的 Redis-SDK

3.1 基础依赖与配置项

假设你的项目是 Spring Boot,第一步是引入依赖。我在 pom.xml 里常见的一组依赖是这样:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.5</version> </dependency>

引入commons-pool2是因为 Lettuce 虽然底层共享连接,但还是需要配合连接池来控制连接数量上限,避免极端情况下连接数无限膨胀。Redisson 的 starter 则会帮我们自动创建 RedissonClient,后面做分布式锁时直接用。

然后是 application.yml 里的核心配置,我习惯把参数全部暴露在外,方便不同环境调整:

spring: data: redis: host: ${REDIS_HOST:127.0.0.1} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} timeout: 3s lettuce: pool: max-active: 64 max-idle: 32 min-idle: 8 max-wait: 500ms

这里的timeout我一开始设置的是 1s,后来线上 Redis 抖动时发现大量请求直接被拖死,才改成 3s。这个值不能太长也不能太短,太长会让用户请求堆积,太短会因为客户端感知锁、慢查询等场景直接超时。视频点播这种对首屏加载时间敏感的场景,我的建议是 2s~3s,同时配合熔断降级逻辑。

3.2 核心操作类的封装思路

我强烈建议不要直接在业务代码里注入 RedisTemplate,而是基于 StringRedisTemplate 做一层包装。为什么这么建议?因为 StringRedisTemplate 默认就是 String 序列化,可以规避大量序列化乱码问题,我们只需要在 SDK 内部把对象转成 JSON 字符串即可。

@Component public class RedisCacheService { private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper = new ObjectMapper(); public RedisCacheService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public void set(String key, Object value, long timeout, TimeUnit unit) { String json; try { json = objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new RuntimeException("序列化异常, key=" + key, e); } redisTemplate.opsForValue().set(key, json, timeout, unit); } public <T> T get(String key, Class<T> clazz) { String json = redisTemplate.opsForValue().get(key); if (json == null) { return null; } try { return objectMapper.readValue(json, clazz); } catch (JsonProcessingException e) { throw new RuntimeException("反序列化异常, key=" + key, e); } } public Long increment(String key) { return redisTemplate.opsForValue().increment(key); } }

注意序列化和反序列化异常要抛出统一异常,而不要静默吞掉。因为如果缓存里出现了脏数据,静默失败会让问题特别难排查。我当时就吃过这个亏,线上缓存里混进一条旧版本结构的 JSON,反序列化报错被吞掉后接口返回了 null,排查了半天才发现是异常被吃掉了。

3.3 防击穿与防穿透:应该在 SDK 层内置

视频点播场景里最典型的问题就是热 key 和空对象穿透。一个热门视频突然在播放页被大量用户同时点开,如果缓存刚好过期,一瞬间会有几千个请求同时打到 MySQL。这个场景不能再依赖业务方自己写逻辑,我直接在 SDK 层内置了两道防线。

第一道是互斥锁防击穿:当缓存不存在时,只允许一个线程去数据库查询并回填缓存,其他线程短暂等待后重新从缓存读取。核心代码就是 Redis 的 SETNX 加过期时间:

public String getWithMutex(String key, Class<String> clazz, long expire, TimeUnit unit, Supplier<String> dbLoader) { String value = get(key, clazz); if (value != null) { return value; } String lockKey = "lock:" + key; String requestId = UUID.randomUUID().toString(); // 只有第一个线程能拿到锁 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { value = dbLoader.get(); set(key, value, expire, unit); return value; } finally { // 只释放自己持有的锁 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } } else { // 等待锁释放后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithMutex(key, clazz, expire, unit, dbLoader); } }

这里有两个细节很关键。第一,加锁必须用setIfAbsent(lockKey, requestId, 10, SECONDS)这种带过期时间的原子操作,千万不能先 setnx 再 expire,一旦在两步之间进程崩溃,锁永远不会释放。第二,释放锁之前要校验 value 是不是自己加锁时生成的 requestId,防止出现锁被其他线程续租或覆盖后误删的情况。

第二道是布隆过滤器防穿透:对于视频 ID 这类有限的 key 集合,在系统启动时把全量视频 ID 加载进布隆过滤器,查询缓存之前先判断视频 ID 是否存在,不存在直接返回空结果。这套方案特别适合视频点播场景,因为全量视频 ID 的数量级通常可以预判,布隆过滤器的误判率可控,而且能挡住大多数恶意遍历请求。

3.4 通过自动装配做到开箱即用

SDK 如果还需要业务方手动new配置类,体验会差很多。我做了 Spring Boot 的自动装配,通过spring.factories@AutoConfiguration注册配置类,让引入 SDK 的模块只需要在配置中心修改 Redis 地址即可。

@AutoConfiguration @EnableConfigurationProperties(RedisSdkProperties.class) @ConditionalOnClass(RedisOperations.class) public class RedisSdkAutoConfiguration { @Bean @ConditionalOnMissingBean public RedisCacheService redisCacheService(StringRedisTemplate redisTemplate) { return new RedisCacheService(redisTemplate); } @Bean @ConditionalOnMissingBean public RedisLockService redisLockService(RedissonClient redissonClient) { return new RedisLockService(redissonClient); } @Bean @ConditionalOnMissingBean public HotRankService hotRankService(RedisCacheService redisCacheService) { return new HotRankService(redisCacheService); } }

自动装配的核心价值是"负责兜底,但允许覆盖"。如果某个服务有特殊需求,它可以自己定义一个同名 Bean 把默认实现替换掉,而普通服务完全不需要关心。这样 SDK 既保持了开箱即用,又没有锁死扩展空间。

4. 视频点播核心业务落地示例

4.1 视频热度榜:基于 ZSet 的分钟级聚合方案

视频点播的首页热门榜,背后就是 ZSet 的经典落地。我在设计热度榜时,不是只用一个 key 存所有视频的热度,而是按时间粒度切分:

  • 分钟榜:hot:rank:yyyyMMddHHmm,每个视频被播放一次就执行一次ZINCRBY给对应视频ID加分。
  • 小时榜:hot:rank:yyyyMMddHH,聚合当前小时内所有分钟榜数据生成。
  • 天榜:hot:rank:yyyyMMdd,聚合当天所有小时榜数据生成。

热门榜的写入其实很简单,核心操作就一行:

public void onVideoPlayed(Long videoId) { String minuteKey = "hot:rank:" + LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); redisTemplate.opsForZSet().incrementScore(minuteKey, String.valueOf(videoId), 1); }

这里有个容易忽略的问题:如果只往分钟榜里写,key 会越来越多,Redis 内存迟早被撑爆。我的方案是在生成小时榜和天榜的同时,给分钟榜 key 设置 30 分钟过期时间,这样分钟榜自动消亡,内存只保留小时级和天级的核心数据。读取榜单时直接用ZREVRANGE取 Top N 即可。

4.2 断点续播:使用 Hash 保存用户观看进度

断点续播是视频点播的基础功能之一。我用 Hash 结构保存用户观看记录,key 是user:play:progress:{userId},field 是 videoId,value 是播放进度秒数。这样做的好处是可以一次查询一个用户的多条观看记录,性能非常好。

public void savePlayProgress(long userId, long videoId, int progressSeconds) { String key = "user:play:progress:" + userId; redisTemplate.opsForHash().put(key, String.valueOf(videoId), String.valueOf(progressSeconds)); redisTemplate.expire(key, 30, TimeUnit.DAYS); } public Map<String, String> getUserPlayHistory(long userId) { String key = "user:play:progress:" + userId; return redisTemplate.opsForHash().entries(key); }

但这里要提一个实际的问题:直接把播放进度长期放在 Redis 里,内存消耗会非常大。我的做法是 Redis 只保留最近 30 天的播放进度,并且通过异步任务定期把 Hash 里的数据回写到 MySQL,Redis 相当于一个高速缓存层。过期之后用户再查看历史记录,如果 Redis 查不到就回源 MySQL,然后重新回填缓存。

4.3 转码任务去重:分布式锁的正确姿势

视频上传之后要经过转码、抽帧、审核等一系列异步任务。如果同一个视频被重复提交,可能导致同一时间有多个转码任务在跑,既浪费资源,又可能产生数据错乱。我在 SDK 里封装了基于 Redisson 的分布式锁服务:

@Service public class VideoTranscodeService { @Autowired private RedisLockService lockService; public void transcode(Long videoId) { String lockKey = "lock:transcode:" + videoId; boolean locked = lockService.tryLock(lockKey, 10, TimeUnit.MINUTES); if (!locked) { log.info("视频 {} 已在转码中,跳过", videoId); return; } try { // 实际转码逻辑 doTranscode(videoId); } finally { lockService.unlock(lockKey); } } }

这里有个关键点:锁的过期时间要比转码最长耗时更久。视频转码时间不可控,如果锁 1 分钟就过期,但转码跑了 5 分钟,另一个实例会在锁过期后重复执行任务。我当时在线上就遇到过一次锁过期导致重复转码的问题,后来对锁的超时时间做了动态计算,并在转码开始时把锁的过期时间延长到 30 分钟,才彻底解决。

4.4 临时播放凭证:用短 TTL 管理 CDN 播放地址

点播播放地址不是永久有效的,通常会带签名参数,有效期一般控制在 10~20 分钟。我在播放接口里生成临时凭证,同时把凭证对应的视频信息缓存起来:

public PlayAuth createPlayAuth(Long videoId, Long userId) { String authToken = UUID.randomUUID().toString().replace("-", ""); String key = "play:auth:" + authToken; String videoJson = videoDetailService.getVideoDetailJson(videoId); redisCacheService.set(key, videoJson, 15, TimeUnit.MINUTES); String playUrl = cdnUrlBuilder.buildSignedUrl(videoId, 15, TimeUnit.MINUTES); return new PlayAuth(authToken, playUrl); }

播放器在请求播放视频时,会把 authToken 传给后端,后端先查 Redis 里的凭证信息,确认有效才返回视频流。这项设计的核心是 TTL 管理,临时凭证过期后自动失效,即使用户把播放链接转发出去,也不会长期有效。对视频点播这类内容付费和版权敏感的场景,这个能力非常重要。

5. 常见问题与排查技巧实录

5.1 连接池耗尽:报错信息与排查思路

我见过最多的线上事故就是连接池耗尽,典型报错是RedisConnectionFailureException: Cannot get Jedis connection或者 Lettuce 的RedisConnectionException: Too many open redis connections

排查时我一般按三步走:

  1. 先看 Redis 服务端连接数,用redis-cli info clients查看connected_clients,判断是服务端连接数被打满还是客户端连接池被打满。
  2. 再看客户端连接池配置,如果 max-active 设置太小,比如默认 8,但服务 QPS 几百,连接池肯定不够用。建议压测时关注最大并发连接数,然后把 max-active 设置为峰值的 1.5~2 倍。
  3. 检查代码里是否存在连接泄漏,比如每次操作都手动创建连接但没有 close,或者在 try 块里没有 finally 回收连接。

另外还要注意慢命令对连接池的影响。如果某个命令执行时间花了 1 秒,那么它占用的连接在这 1 秒内都无法服务其他请求,这时候连接池即使设置很大也容易被拖垮。我在视频点播项目里就遇到过HGETALL拉取一个超大 Hash 的场景,整个连接池被几个慢命令占满,最后只能从代码层面把一次拉取改成多次小批量操作。

5.2 序列化导致的乱码与反序列化异常

\xAC\xED\x00\x05这种前缀是 JDK 默认序列化的标志。一旦在 Redis 里看到这类乱码,基本可以确定有服务没有走统一的 Redis-SDK,直接用了原生 RedisTemplate 且没配置序列化器。

解决方式分两步:先把这类 key 找出来,在 SDK 里提供一个扫描和清理工具,把二进制 key 导出并确认影响的业务;然后在配置层强制指定序列化器,StringRedisTemplate 或自定义 RedisTemplate 统一用 String 序列化 key 和 Hash field。做完这两步之后,新产生的数据就不会再有乱码问题。

反序列化异常也值得单独说。最常见的情况是:同一个 key 在不同版本的应用中写入过不同结构的 JSON,旧缓存没有过期时,新版本代码反序列化就直接报错。我的习惯是在 JSON 序列化时带上类型信息,或者在 key 中增加版本号,比如video:info:v2:{videoId},这样即使老缓存还没过期,新代码也不会读旧结构。

5.3 缓存雪崩与热点 key 击穿的处理经验

视频点播首页在某个热点活动上线时,容易出现热点视频 key 同时过期的雪崩效应。我的处理策略有两个:

  • 过期时间加随机值:SDK 里的set方法默认会在业务传入的过期时间基础上加一个随机偏移量,比如 0~60 秒,避免大量 key 在同一时刻集体过期。

  • 热点 key 逻辑过期:对于已知的热点视频,不设置固定过期时间,而是在 Value 里额外保存一个逻辑过期时间。读取时先判断逻辑过期时间,如果过期了先返回旧值,然后异步去刷新缓存。这种做法的好处是用户请求不会因为没有缓存而阻塞,牺牲的只是一点实时性。

5.4 大 key 与慢查询:如何快速定位

视频点播场景里最容易出现的大 key 是存视频详情 JSON 的 String 类型和存用户观看记录的 Hash 类型。一个热门视频的播放页详情可能包含大量推荐位、榜单、标签信息,拼成 JSON 后动辄几十 KB,一次 GET 命令在客户端看着没多大,但在 Redis 里传输耗时明显增加。

定位大 key 我推荐两个方法:一是用redis-cli --bigkeys快速扫描,它会把当前实例里最大的 key 识别出来;二是通过慢查询日志,执行SLOWLOG GET 10查看耗时高的命令,如果命中 GET 或者 HGETALL,基本能判断是在访问大 key。

解决大 key 的思路是拆。视频详情 JSON 可以拆成基础信息、播放信息、推荐信息三个 key,分别设置过期时间,哪个部分更新就只改哪个部分。Hash 类型的大 key 则可以做分片,比如按照 userId 的位数拆成多个 key,把单个 Hash 的 field 数量控制在几千以内。

5.5 多环境隔离与可观测性建设

如果多个环境的服务连同一个 Redis 实例,还共用 key,很容易互相污染。我在 SDK 里强制加了 key 前缀机制:{env}:{biz}:{key},环境名前缀通过配置注入,比如prod:video:playcount:123dev:video:playcount:123。这样即使开发环境误操作连了测试环境,也不会污染线上数据。

可观测性方面,我在 SDK 里统一埋了指标:操作耗时、操作类型、key 的 Redis 节点信息,上报到 Prometheus 和 Grafana。线上出现慢查询时,能直接定位到具体业务方法,而不是盯着 Redis monitor 一条一条翻。这一点在事故复盘时特别有用,能够快速回答"哪个接口导致 Redis 响应变慢"。

6. 最后分享两个经验

这套 Redis-SDK 在团队落地已经一年多了,回头看我最大的收获不是写了多少行封装代码,而是"规范让事故概率大幅下降"。有统一前缀、统一序列化、统一连接池参数之后,排查 Redis 问题的效率比之前翻了好几倍。如果你也要在团队里推 SDK,我的经验是:别一上来就追求大而全,先把配置、序列化、基础操作层做好,再逐步加分布式锁、防击穿、布隆过滤器这些高级能力。功能太多,反而容易让团队不敢用。

还有一个小技巧,SDK 的文档和示例代码一定要跟着代码仓库同步维护。我见过太多项目,代码写得很优雅,但 README 还是半年前的老版本,新同学照着文档接入直接踩坑。好的 SDK 不是代码写完就结束,配套的坑位说明、参数解释和业务示例,才是它真正能被团队用起来的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询