视频点播场景Redis-SDK封装实战:从序列化到分布式锁

📅 发布时间:2026/9/8 7:00:03
视频点播场景Redis-SDK封装实战:从序列化到分布式锁
做视频点播的同学应该都有体会用户刷视频时最忙的往往不是数据库而是 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 的命名更是五花八门videoplaycount、play_count_123、video: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-SDK3.1 基础依赖与配置项假设你的项目是 Spring Boot第一步是引入依赖。我在 pom.xml 里常见的一组依赖是这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.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, ClassT 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, ClassString clazz, long expire, TimeUnit unit, SupplierString 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 是 videoIdvalue 是播放进度秒数。这样做的好处是可以一次查询一个用户的多条观看记录性能非常好。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 MapString, String getUserPlayHistory(long userId) { String key user:play:progress: userId; return redisTemplate.opsForHash().entries(key); }但这里要提一个实际的问题直接把播放进度长期放在 Redis 里内存消耗会非常大。我的做法是 Redis 只保留最近 30 天的播放进度并且通过异步任务定期把 Hash 里的数据回写到 MySQLRedis 相当于一个高速缓存层。过期之后用户再查看历史记录如果 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。排查时我一般按三步走先看 Redis 服务端连接数用redis-cli info clients查看connected_clients判断是服务端连接数被打满还是客户端连接池被打满。再看客户端连接池配置如果 max-active 设置太小比如默认 8但服务 QPS 几百连接池肯定不够用。建议压测时关注最大并发连接数然后把 max-active 设置为峰值的 1.5~2 倍。检查代码里是否存在连接泄漏比如每次操作都手动创建连接但没有 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:123、dev:video:playcount:123。这样即使开发环境误操作连了测试环境也不会污染线上数据。可观测性方面我在 SDK 里统一埋了指标操作耗时、操作类型、key 的 Redis 节点信息上报到 Prometheus 和 Grafana。线上出现慢查询时能直接定位到具体业务方法而不是盯着 Redis monitor 一条一条翻。这一点在事故复盘时特别有用能够快速回答哪个接口导致 Redis 响应变慢。6. 最后分享两个经验这套 Redis-SDK 在团队落地已经一年多了回头看我最大的收获不是写了多少行封装代码而是规范让事故概率大幅下降。有统一前缀、统一序列化、统一连接池参数之后排查 Redis 问题的效率比之前翻了好几倍。如果你也要在团队里推 SDK我的经验是别一上来就追求大而全先把配置、序列化、基础操作层做好再逐步加分布式锁、防击穿、布隆过滤器这些高级能力。功能太多反而容易让团队不敢用。还有一个小技巧SDK 的文档和示例代码一定要跟着代码仓库同步维护。我见过太多项目代码写得很优雅但 README 还是半年前的老版本新同学照着文档接入直接踩坑。好的 SDK 不是代码写完就结束配套的坑位说明、参数解释和业务示例才是它真正能被团队用起来的关键。