☰
B站缓存合并工具:m4s与微服务架构实战
2026/10/14 16:35:01 网站建设 项目流程

如果你也习惯把B站缓存到手机里通勤看,一定见过这种画面:换到电脑上想接着看,打开缓存目录,里面躺着一堆 video.m4s、audio.m4s、entry.json,有的还带 danmaku.xml,拖进播放器要么黑屏,要么只有声音,要么干脆识别不了。我之前为这事折腾了好几个晚上,后来干脆写了一套全平台B站缓存合并服务:用户只需要上传缓存文件夹,后端自动识别每个分P的音视频轨,调用 ffmpeg 抽取并合并,最后吐出一个能直接播放的 mp4。服务最开始是个 Java 单体,为了同时服务 Windows 客户端、macOS 脚本、Web 端和手机端,又把后端拆成了网关加四个微服务。

这篇文章不打算讲太多花哨的概念,而是围绕这个工具,把 m4s 合并的原理、Java 侧调 ffmpeg 的方式、异步任务设计,以及真正在项目里用得上的微服务知识点挨个捋一遍。想学 Spring Cloud 但一直被网上的“全家桶教程”劝退的人,写过单体后端正准备拆服务的人,以及纯粹想做个缓存合并小工具的人,都能在里面找到能直接用的东西。

1. 项目全貌与拆分思路

1.1 缓存合并到底在解决什么问题

B站客户端的缓存目录在不同版本里长得不太一样,但核心结构非常相似。以常见的安卓缓存为例,路径通常是 Android/data/tv.danmaku.bili/download/ 下面的某个视频编号目录,里面按清晰度分目录,再往里就是音视频文件和描述信息:

download/{视频编号}/ ├── entry.json // 视频信息:标题、分P、清晰度描述 ├── danmaku.xml // 弹幕文件 └── 1/ // 清晰度目录编号 ├── video.m4s // 视频轨(DASH 分片格式) └── audio.m4s // 音频轨

有些老版本缓存还会出现一串 .blv 分段文件,这时候就不是简单的“音视频分离”,而是要把 N 个分段先按顺序拼起来才能进入下一步。

用户的需求说简单也简单:把这一堆碎片变成能双击播放的单个文件。但真做起来,坑全在细节里:文件名不一定带后缀、m4s 文件头有偏移、不同清晰度目录命名规则不一样、分P多还会嵌套子目录。正因为这些细节太啰嗦,做成后端服务才有价值——客户端只负责上传缓存目录,解析判断全部集中在服务端,修一个版本所有人跟着受益。

所谓“全平台”,本质不是写一套跨平台桌面程序,而是把合并能力收敛成一套 HTTP API。Windows 客户端可以上传,macOS 脚本可以调用,Web 页面可以预览,手机端可以提交任务,所有端共用同一份逻辑。

1.2 为什么从单体走到微服务

这个项目最早确实是一个单体应用:一个 Spring Boot 工程,controller、service、mapper 全堆在里面。数据量小的时候完全够用,问题出在合并任务开始多起来之后。

第一个矛盾是负载模型完全不一样。合并任务要拉起 ffmpeg 进程,处理几分钟的视频时 CPU、磁盘 IO、内存都被占满;而 API 查询(查任务状态、查文件列表)是典型的低负载短请求。两者塞在同一个进程里,一旦有人并发提交几个合并任务,整个服务的接口延迟都会变得不可接受,登录请求跟着遭殃。

第二个矛盾是扩容维度不同。文件服务和任务服务面对的压力来源不同:文件服务要处理上传下载,瓶颈在带宽和 IO;任务服务要跑 ffmpeg,瓶颈在 CPU。如果捆在一起,想单独加任务服务的实例,就得连文件服务一起多部署一份,成本直接翻倍。

第三个原因是多端接入需要统一的出口。Windows、macOS、Web、手机端如果直接连接各个服务,客户端要维护多套地址;加一个网关之后,所有端只认 /api/xxx 这一套路径,内部怎么拆跟调用方无关。

当然,拆服务也带来了实打实的代价:调试链路变长、部署步骤变多、出问题要跨服务查。所以我在项目里把所有中间件和业务服务都用 Docker Compose 编排好,本地和生产保持一致,这才把复杂度压住。

1.3 服务边界怎么划

我最终划分成四个业务服务加一个网关,每个服务的定位非常明确:

服务职责代表接口
gateway-service统一入口、路由转发、JWT 鉴权/api/v1/**
auth-service登录、注册、Token 签发/api/auth/login
file-service上传下载、缓存包解析、对象存储对接/api/file/upload
task-service任务状态机、ffmpeg 合并、进度推送/api/task/submit

文件服务和任务服务是核心,auth 和 gateway 是标准配套。没有拆独立的消息通知服务,因为合并进度本来就在 task-service 里产生,直接通过 WebSocket 推送最简单,没必要为了“微服务数量多”而额外拆一层。

另外要说明的是,这个项目没有引入分布式事务。合并任务涉及的操作都在 task-service 自己的数据库里:任务表、文件记录表、结果表,属于本地事务能解决的问题。跨服务最多是 file-service 返回一个文件元数据,就算后续失败,补偿成本也很低。很多人一上微服务就想 Seata,其实大多数业务场景根本用不到。

2. 缓存识别与合并的核心流程

2.1 解析缓存目录,文件名别太相信

解析缓存目录的第一原则是:文件名只是参考,不能作为唯一依据。我遇到过缓存包里 video.m4s 被某些系统改名成 video.m4s.mp4 的情况,也见过完全不带扩展名的文件,所以判断文件类型必须靠二进制头。

m4s 属于 ISO BMFF 格式家族,通常文件开头会有 ftyp box。判断逻辑可以写成:读取文件前 16 字节,如果包含 ftyp 且后面跟着合法 box 长度,就按 m4s 处理;否则继续尝试当成无头裸流。这套逻辑虽然简单,但能挡住大部分异常文件。

还有一个非常容易踩的坑:多P视频的排序。entry.json 里通常能拿到分P数据和序号,一定要按这个序号排序,而不是按文件名排序。因为文件可能叫 01.m4s、10.m4s,也可能叫 1.m4s、2.m4s,字符串排序会把 10 排到 2 前面,最后合并出来的视频顺序完全错乱。

解析描述信息时也别硬编字段名。B站客户端的 entry.json 和 videoInfo.json 在不同版本里字段命名有差异,正确做法是做一个容错解析层:先用完整路径优先取标题,取不到再层层回退,所有拿到的字符串都要做空值处理。这个工具面向真实用户,用户手里的缓存包来自各种版本的客户端,兼容性直接决定工具能不能用。

2.2 解决 m4s 偏移这个头号坑

缓存下来的 m4s 文件,很多不是一个标准 MP4,而是一段“描述信息 + 裸流”的复合结构。常见的情况是文件开头有一段 ASCII 数字字符串,表示真正的音视频数据从第几个字节开始。

我处理过的文件里,这个偏移标记通常是 9 个字节,比如 000000245,含义是整个文件前 245 字节是给容器识别的描述性信息,真正的视频数据从第 245 字节开始。也有的文件偏移为 0,说明它本身就是标准的分片 MP4,可以直接交给 ffmpeg。

如果把带偏移的文件直接扔给 ffmpeg,会遇到各种奇怪错误:有的报 Invalid data,有的读到一半掐掉,有的能出画面但花屏。工程上最稳的做法就是统一探测偏移,按偏移切出真正的流数据,再交 ffmpeg 处理:

public static long detectM4sOffset(Path path) throws IOException { byte[] head = new byte[16]; try (InputStream is = Files.newInputStream(path)) { int read = is.read(head); if (read < 9) { return 0L; } } String prefix = new String(head, 0, 9, StandardCharsets.US_ASCII); if (prefix.chars().allMatch(c -> c >= '0' && c <= '9')) { return Long.parseLong(prefix); } return 0L; }

拿到偏移量之后,用 RandomAccessFile 或 NIO 从偏移位置开始读取,写入临时文件,作为后续 ffmpeg 的输入。这个探测逻辑不是官方标准,是我在大量样本上试出来的通用方案,建议在代码里留着兜底分支——如果探测不到合规偏移,就按照标准 MP4 去处理,而不是直接报错。

2.3 ffmpeg 转封装与最终合并命令选型

核心合并流程分三步。第一步把视频轨 m4s 转封装成临时 mp4,第二步把音频轨 m4s 转封装成临时 mp4,第三步把两个临时文件合并成最终文件。

第一、二步的命令形式:

ffmpeg -y -i video_input.m4s -c copy -fflags +genpts -avoid_negative_ts make_zero video_tmp.mp4 ffmpeg -y -i audio_input.m4s -c copy -avoid_negative_ts make_zero audio_tmp.mp4

第三步合并:

ffmpeg -y -i video_tmp.mp4 -i audio_tmp.mp4 -map 0:v:0 -map 1:a:0 -c copy -movflags +faststart output.mp4

全程用 -c copy,也就是只转封装不重新编码。这样速度极快,画质无损,CPU 占用只发生在 mux/demux 阶段。如果中途遇到时间戳异常,可以再叠加 -fflags +genpts 重新生成时间戳,或者用 -avoid_negative_ts make_zero 把负时间戳归零。

为什么输出要加 -movflags +faststart:这个参数会把 moov 元数据挪到文件头部,否则播放器需要把整个文件下载完才能开始播放。对于 Web 端在线预览特别重要,本地播放虽然没有硬性要求,但加上没有任何副作用,还能提高快进快退的响应速度。

如果遇到个别视频转封装失败,我的兜底方案是转为重新编码:视频走 libx264 加 veryfast preset,音频走 aac。代价是慢一点、画质有轻微损耗,但至少能出结果。这个方案优先级放最后,因为绝大多数正常缓存包走 copy 路线就足够了。

2.4 异步任务与进程管理

合并一个视频动辄几十秒到几分钟,这个过程绝对不能放在 HTTP 请求里同步等待。流程应该是:客户端提交缓存包 → 服务端创建任务并返回 taskId → 工作线程异步处理 → 客户端通过轮询或 WebSocket 拿进度。

异步化之后紧接着就是并发控制。一台机器同时跑太多 ffmpeg 子进程,内存和 CPU 会直接被打爆。我的做法是用 Java 的信号量 Semaphore 限制本机同时执行的合并任务数,比如单实例最大并发 2,任务服务部署了 3 个实例,全局就是 6 个并发,完全可控。

ffmpeg 进程本身也需要保护。ProcessBuilder 启动子进程后,waitFor 必须加超时,比如 5 分钟;超时之后 destroyForcibly 强制杀掉,防止异常文件把进程挂死在那里。日志方面,把 ffmpeg 的错误输出流重定向到统一日志文件,合并失败时能直接看到是编码问题、文件损坏问题还是磁盘空间不足,排查效率能提升一个量级。

3. 微服务骨架与关键组件选型

3.1 Nacos 注册配置中心

微服务之间互相发现依赖注册中心,我选 Nacos 而不是 Eureka,主要因为它除了注册发现还带配置中心,一个组件解决两个问题。

注册发现解决的是“实例地址漂移”。task-service 扩容到两个实例之后,网关调用方不需要感知实例 IP,Nacos 会自动把请求分发到健康的实例上,实例下线也会自动摘除。对于文件服务、任务服务这种随时可能扩缩容的服务,这个能力是刚需。

配置中心解决的是“改配置不重启”。ffmpeg 的安装路径、对象存储的 bucket 名称、合并队列名、单机并发上限,这些值全部收进配置中心。上线之后发现并发设大了,直接在控制台改配置,服务通过 @RefreshScope 自动刷新,不用重新打包重新部署。这个能力做上之后就被频繁使用,强烈建议所有微服务方案都配上。

需要注意一个坑:Nacos 2.x 版本客户端和服务端之间有 gRPC 长连接,除了 8848 端口,还会用到 9848 端口。Docker 部署时如果网络没放通这个端口,服务会反复注册失败但日志里不报错,非常隐蔽。我第一次部署时就卡在这里,花了大半天排查网络。

3.2 网关路由与统一鉴权

网关是微服务的门面,所有外部请求先进网关再转发到下游服务。路由配置基于路径匹配:

spring: cloud: gateway: routes: - id: auth uri: lb://auth-service predicates: - Path=/api/auth/** - id: file uri: lb://file-service predicates: - Path=/api/file/** - id: task uri: lb://task-service predicates: - Path=/api/task/**

lb:// 前缀表示按服务名从 Nacos 拉取实例列表做负载均衡。

鉴权逻辑放在网关的全局过滤器里:取 Header 里的 Authorization,解析 JWT 得到用户信息,然后把 userId 放进转发请求的 Header 传给下游。这样做的好处是下游服务完全感知不到用户体系,接口只需要关心业务逻辑。登录接口、注册接口这些不需要登录态的接口通过白名单放行。

网关里的过滤器不要做重业务操作,比如查数据库、调远程接口,否则会拖慢所有请求。JWT 解析是纯本地计算,适合放在网关;如果要查用户状态,应该只在必要时由 auth-service 提供单独接口。

3.3 OpenFeign 与超时控制

task-service 在处理任务时需要知道文件服务那边上传的文件信息,这个跨服务调用用 OpenFeign 最直接。定义一个 FeignClient 接口,声明方法、路径、参数类型,Spring 会自动生成实现类。

Feign 默认的超时配置偏短,连接超时通常没问题,但读取超时要特别小心。文件服务返回的可能是大文件元数据,甚至涉及对象存储的预签名地址生成,耗时可能到秒级。如果读到默认的 5 秒超时,任务服务就会频繁报 SocketTimeout。我把读取超时调到了 30 秒,并加了 Feign 拦截器打印每个接口的耗时和参数,排查慢接口时非常好用。

跨服务调用最怕问题出现在半路,日志却各自记各自的。我的方案是在网关生成一个 traceId,通过 MDC 传到所有线程,再透传给下游服务;每个服务的日志配置都带上 %X{traceId},最终用这个 ID 把一条完整调用链串起来。后面排查问题基本不用猜,直接按 traceId 在日志系统里搜一遍就知道请求走到哪一步断了。

3.4 MQ 异步削峰与任务状态机

合并任务的提交入口并发量可能很高,尤其是周末晚高峰,几百个用户同时提交缓存包。如果请求直接打到 task-service 同步处理,服务瞬间被拖垮。RabbitMQ 在这里的作用就是削峰:任务提交后先写入数据库,状态设为 PENDING,然后发送一条消息到 merge.task.queue,消费者按自己的处理能力一个一个拿任务。

消费者处理前必须先做幂等检查。消息队列虽然能做到基本不丢消息,但无法保证不重复,消费端要有对应的保护机制。我的做法是处理前先查任务状态,只有 PENDING 状态才允许执行;执行前再通过 CAS 把状态改成 RUNNING,改不成功说明任务已经被别的消费者处理了,直接丢弃这条消息。

任务状态机定义成 PENDING → RUNNING → SUCCESS / FAILED,外加一个 CANCELLED。所有状态变更走条件更新:

public boolean casToRunning(Long taskId) { return taskMapper.casStatus(taskId, TaskStatus.PENDING, TaskStatus.RUNNING) == 1; }

SQL 里带上当前状态条件,这样并发和重试都不会把状态打乱。

消费失败要有重试机制。我用的是手动 ACK 加重试计数:业务处理抛异常时,不确认消息,让队列重新投递;重试超过 3 次,消息转到死信队列,由人工或者定时任务介入。简单的失败场景,比如网络抖动造成临时文件上传失败,第二次重试往往就成功了;复杂场景再走死信兜底。

3.5 Redis 的两个角色:缓存与锁

Redis 在这个项目里有两个完全不同的角色。

第一个角色是缓存任务进度。任务执行过程中的进度数据不适合高频写数据库,因为每秒可能更新好几次。我的做法是任务开始后把 step、progress、message 实时写入 Redis 的 hash 结构,客户端轮询进度接口时优先读缓存;任务结束时才把最终状态和进度一次性落库,数据库的写压力瞬间降下来。

第二个角色是分布式锁。防止同一个缓存包被重复提交合并,典型的场景是用户手抖点了两次“提交”,或者客户端弱网时重试导致请求发了多遍。锁的 key 用文件包的唯一标识生成,比如 MD5:

RLock lock = redissonClient.getLock("merge:submit:" + fileMd5); if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) { throw new BizException("提交过于频繁,请稍后重试"); }

Redisson 的锁默认带看门狗续期逻辑,持有锁的线程没执行完会自动续期,不用担心任务超过锁的过期时间导致锁提前释放。我在提交场景里故意把锁的持有范围控制在“创建任务 + 发消息”这个瞬间,而不是锁住整个合并任务,这样锁的占用时间极短,不会因为业务变慢而拖垮后续请求。

4. 核心代码片段:可直接抄作业

4.1 m4s 偏移探测方法

前面的代码已经给了核心实现。这里再补充一个细节:探测偏移时最好只读前 16 字节,不要读整个文件,因为缓存目录里可能躺着几百 MB 的大文件,全部读入内存没有意义。用 try-with-resources 确保流关闭,方法返回 long 类型,因为偏移量可能超过 int 范围。

探测结果要做缓存。同一个缓存包里的 m4s 文件偏移量通常是固定的,可以在解析目录时把每个文件的偏移量算好,放进内存 Map,避免后面合并阶段重复读取文件头。这个优化对多P视频效果明显,几十个文件循环读取文件头也是有开销的。

4.2 ffmpeg 进程调用封装

调用 ffmpeg 一定要用 ProcessBuilder 传 List 参数,不要用 String 拼接命令:

List<String> command = new ArrayList<>(); command.add("ffmpeg"); command.add("-y"); command.add("-i"); command.add(videoInput.toString()); command.add("-i"); command.add(audioInput.toString()); command.add("-map"); command.add("0:v:0"); command.add("-map"); command.add("1:a:0"); command.add("-c"); command.add("copy"); command.add("-movflags"); command.add("+faststart"); command.add(output.toString()); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); Process process = pb.start(); if (!process.waitFor(5, TimeUnit.MINUTES)) { process.destroyForcibly(); throw new MergeTimeoutException("ffmpeg 执行超过 5 分钟"); }

进程启动后要立刻把标准错误合并到标准输出,否则错误信息积压在缓冲区,进程可能因为缓冲区写满而阻塞。waitFor 带超时更是必备,防止 ffmpeg 被异常文件挂到天荒地老。

还有一个细节:ffmpeg 的 -y 参数必须加上,否则输出文件已存在时会交互式询问是否覆盖,而 Process 没有交互输入,进程就卡死了。这个坑我踩过,合并任务第二次运行时莫名其妙停住,查了很久才发现是覆盖确认。

4.3 任务状态机与条件更新

状态机字段定义成枚举还是整型都可以,我偏好枚举加数据库整型映射。关键是所有状态变更必须走条件更新,而不是先查再改。先查再改在高并发下是典型的竞态条件,两个线程同时读到 PENDING,然后同时改成 RUNNING,业务逻辑就会重复执行。

@Update("UPDATE merge_task SET status = #{target}, update_time = NOW() " + "WHERE id = #{taskId} AND status = #{expect}") int casStatus(@Param("taskId") Long taskId, @Param("expect") Integer expect, @Param("target") Integer target);

执行任务前用 casToRunning,任务完成时用 casToSuccess,任一步失败就抛出异常走重试逻辑。

4.4 分布式锁与提交幂等

提交接口的幂等不能只靠数据库唯一索引,因为任务还没创建出来,数据库根本挡不住并发。用 Redisson 分布式锁保护提交瞬间是最直接的方案,锁 key 设计成合并前文件包的 MD5 值,确保同一份文件包只能创建一个任务。

锁的粒度要合适:太小挡不住重试,太大把不相关的合并请求也串起来了。我的 key 只包含文件包 MD5,不包含用户 ID,同一文件包无论谁提交都只合并一次,这符合业务预期。如果想把锁粒度和用户隔离,可以拼上 userId,但那样做会允许同一个包被多个用户各合并一次,需要根据实际产品逻辑权衡。

5. 部署落地与排查实录

5.1 Docker Compose 快速拉起整套环境

微服务最大的部署痛点就是依赖多。MySQL、Redis、RabbitMQ、Nacos、MinIO,再加上四个业务服务,手工部署一次能折腾小半天。Docker Compose 在这里的价值不是炫技,而是把整套环境定义成代码,本地、测试、生产三个环境用同一套编排文件,彻底消灭“在我电脑上是好的”这类问题。

编排时注意依赖顺序。业务服务不能光靠 depends_on 硬等中间件启动,因为容器启动不等于服务就绪。我用 healthcheck 配合 condition: service_healthy,确保 RabbitMQ 真正能接受连接之后再启动 task-service:

services: task-service: image: registry/example/task-service:latest depends_on: rabbitmq: condition: service_healthy nacos: condition: service_healthy environment: SPRING_PROFILES_ACTIVE: prod NACOS_ADDR: nacos:8848

MinIO 默认账号密码要改掉,并且备份策略提前考虑。合并产物是用户数据,万一 bucket 被误删,整个服务就失去意义了。

5.2 常见问题:合并后音画不同步

这是整个项目里被反馈最多的一个问题,现象是画面和声音错位,有的快有的慢。

大部分情况是因为音视频轨的时间基准不一致。DASH 分片本来就是为流媒体设计的,音频轨和视频轨的时间戳起始点可能不同,直接合并时如果不做处理就会错位。我的解决方法是:在第一步转封装时给两个轨都加 -avoid_negative_ts make_zero,把负时间戳归零,再进入合并阶段。

如果个别文件仍然不同步,额外试 -itsoffset 微调音频延迟。这个参数是手动的,需要根据实际偏差反复尝试,我一般建议用户先重新下载缓存,不要只靠命令硬调。因为遇到这种情况多半是原本缓存的文件就有问题,工具能覆盖大部分正常场景,损坏的文件没有义务硬修。

5.3 常见问题:大文件上传超时

用户传一个几 GB 的缓存包,小文件测试全都正常,大文件总是传一半断掉。排查下来有三层问题:网关超时、Web 容器请求体限制、中间代理的连接超时。

Nginx 的 client_max_body_size 默认只有 1MB,不调大必挂。网关层的 Spring Cloud Gateway 也要调整 httpclient 的 response-timeout。但最根本的解法是分片上传:客户端把文件切成 5MB 一片,逐片上传,服务端接收完成后做合并校验。这样单请求的数据量很小,任何一层超时都不容易触发,还能实现断点续传。

服务端合并分片时要严格校验分片序号和大小,防止客户端漏传或者乱序。我用的是分片上传的标准模式:先初始化上传会话,拿到 uploadId,再逐片上传,最后调用合并接口。上传中和合并中都用 Redis 缓存已收到的分片序号,支持断点续传,弱网环境下比整包上传可靠得多。

5.4 常见问题:微服务调用超时与链路排查

task-service 调 file-service 获取文件元数据时,偶发 SocketTimeout。这种问题在单体应用里几乎不存在,拆成微服务后就变成了家常便饭。

排查第一步永远不是看代码,而是先确认服务实例是否健康。去 Nacos 控制台看 file-service 实例是否在线,再直接 curl 实例的健康检查接口,判断是网络问题还是服务本身慢。第二步看 GC 日志,如果老年代满导致频繁 Full GC,响应时间会突然飙升,表现就是偶发超时。第三步看 Docker 网络,容器间通信走的是自定义 bridge 网络,DNS 解析配置不对也会造成超时。

最关键的还是日志链路。我在网关里用 Filter 生成 traceId,写入 MDC,再通过 OpenFeign 的 RequestInterceptor 把 traceId 放进 Header,下游服务从 Header 里取出来继续传。这样一条合并请求从提交到完成,所有日志都带着同一个 ID,随便去日志系统搜一下就能看到每一步耗时,排查效率提升非常明显。

5.5 常见问题:任务积压与子进程内存控制

合并任务积压的根因,大概率不是队列容量不够,而是消费者处理能力不行或者子进程失控。

一个非常隐蔽的问题是 Java 服务内存看着不高,但整机内存被吃光了。原因在于 ffmpeg 是外部进程,它占的内存不属于 JVM。如果并发控制没做好,几十个 ffmpeg 同时跑起来,16G 内存的机器也能瞬间被打爆。我加 Semaphore 限制本机并发之后,这个问题彻底消失。

观察手段:ps aux | grep ffmpeg 看子进程数量,top 看整机负载。如果同时存在几十个 ffmpeg 进程,说明 Semaphore 没生效或者代码路径漏了并发控制。这时候优先检查是不是所有调用 ffmpeg 的地方都走了同一个并发控制入口,而不是又加一个信号量。

6. 复盘清单

6.1 真正用上的知识清单

这个项目过了一遍微服务的方方面面,我给出一份“真正落过地”的知识点对照,和网上那些面经里的微服务八股有很大区别:

知识点在这个项目里的实际落点
服务注册与发现Nacos,实例上下线自动摘除
配置中心ffmpeg 路径、队列名、并发上限动态调整
网关路由与统一鉴权Spring Cloud Gateway + JWT,下游无感
服务间调用OpenFeign,超时配置和日志透传
异步削峰RabbitMQ 合并任务队列
消息幂等与重试状态机 CAS + 手动 ACK + 死信队列
Redis 缓存与分布式锁进度缓存、提交幂等防重
对象存储MinIO 保存合并产物
日志链路traceId 跨服务透传
容器化Docker Compose 本地与生产一致

没用到的东西也明确一下:分布式事务用不上,因为业务模型不需要跨库强一致;分布式调度平台用不上,因为没有定时任务需求;服务网格更是纯负担。如果你正在规划微服务,先问自己的业务有没有“不同服务扩容维度不同”“负载模型冲突明显”这类真实拆分的理由,再动手拆。

6.2 如果重新做一次,我会改掉哪些设计

第一个改变:一开始就把缓存版本解析做成策略接口。不同版本的 B站客户端缓存结构差异极大,旧 blv 分段和新 m4s 是两套解析逻辑,清晰度目录编号的含义也不同。我最初为了赶进度把解析逻辑写成了一个庞大的 switch-case,后面每遇到一个新样本就加一个分支,代码越来越难维护。重做的话,我会把解析器做成接口,每类版本一个实现类,通过工厂方法加载,新增版本只需新增类不动老逻辑。

第二个改变:合并任务改成 worker 池模式。现在每个任务临时创建一个线程去执行,虽然用信号量控制住了并发,但线程创建销毁的开销在高峰期还是有一定浪费。改成固定数量的 worker 常驻线程池,任务从队列进来由 worker 统一调度,对机器资源的利用率会更高。

第三个改变:接口文档从第一天就自动生成。服务拆分成四个之后,Web 端、Windows 端、macOS 端分别对接,文档不同步会消耗掉大量沟通时间。用 OpenAPI 规范管理接口定义,自动生成客户端 SDK,新接入端可以直接拿现成的代码用,而不是对着文档手写请求体。

6.3 经验收尾

这类工具叫“神器”,其实背后没有多少黑科技,真正值钱的是把版本差异、文件偏移、任务状态这些脏活处理干净。微服务也差不多,拆不是目的,让每个服务都能被独立扩展、独立维护才是目的。

回到合并这件事,我现在手机里缓存了几十集视频,想导出时直接打包上传到服务端,几分钟后收到一条下载链接,整个过程不需要装额外软件,不用手动拖动文件,更不用处理那些格式问题。作为一个后端工具,能做到这一步,我觉得就是它存在的意义了。

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

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

立即咨询