🎬 设计一个视频网站系统:B站的秘密!

80 阅读10分钟

📖 开场:电影院的进化

想象电影院的进化史 🎥:

1.0版(传统电影院)

看电影:
    ↓
去电影院(30分钟)🚗
排队买票(10分钟)🎫
等待开场(20分钟)⏰
看完回家(30分钟)🏠
    ↓
总耗时:2小时 ❌

缺点:
- 麻烦 ❌
- 时间成本高 ❌
- 不能快进 ❌

2.0版(视频网站)

看视频:
    ↓
打开B站 📱
搜索视频 🔍
点击播放 ▶️
    ↓
总耗时:10秒 ✅

优点:
- 方便 ✅
- 随时随地 ✅
- 可以快进/倍速 ✅
- 弹幕互动 💬

这就是视频网站:革新了观影体验!


🤔 核心挑战

挑战1:视频存储 💾

问题:
一部1080P电影:5GB
一部4K电影:20GB
    ↓
B站有百万级视频
    ↓
需要PB级存储 💀

解决:
- 对象存储(OSS)✅
- 视频压缩(H.264/H.265)✅
- 多分辨率(360P/720P/1080P)✅

挑战2:视频播放 ▶️

问题:
用户点击播放:
    ↓
下载5GB视频?💀
    ↓
等待10分钟?❌

解决:
- 视频切片(HLS/DASH)✅
- 边下边播 ✅
- CDN加速 ✅

挑战3:视频转码 🔄

问题:
用户上传的视频格式千奇百怪:
- MP4
- AVI
- MOV
- FLV
- ...

如何统一播放?

解决:
- 视频转码 ✅
- 多分辨率转码(360P/720P/1080P)✅
- 异步处理(消息队列)✅

🎯 核心设计

设计1:系统架构 🏗️

        视频网站系统架构

┌────────────────────────────────────┐
│           客户端                    │
│  - Web/App/小程序                  │
│  - 视频播放器                      │
└──────────────┬─────────────────────┘
               │
               ↓
┌────────────────────────────────────┐
│        CDN(内容分发)              │
│  - 视频加速                        │
│  - 就近访问                        │
└──────────────┬─────────────────────┘
               │
               ↓
┌────────────────────────────────────┐
│        应用服务器                   │
│  - 视频上传                        │
│  - 视频管理                        │
│  - 播放记录                        │
└──────────────┬─────────────────────┘
               │
       ┌───────┼───────┐
       ↓       ↓       ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│  OSS     │ │  转码    │ │  数据库  │
│ 对象存储 │ │  服务    │ │  MySQL   │
└──────────┘ └──────────┘ └──────────┘

设计2:视频上传 📤

上传流程

视频上传流程:

1. 客户端:选择视频文件
    ↓
2. 获取上传凭证(服务器)
    ↓
3. 分片上传到OSS
    ↓
4. 上传完成,通知服务器
    ↓
5. 触发视频转码任务
    ↓
6. 转码完成,视频可播放 ✅

代码实现

@RestController
@RequestMapping("/video")
public class VideoUploadController {
    
    @Autowired
    private VideoService videoService;
    
    @Autowired
    private OSSService ossService;
    
    /**
     * ⭐ 获取上传凭证
     */
    @GetMapping("/upload/credential")
    public Result<UploadCredential> getUploadCredential(@RequestParam Long userId) {
        // 生成唯一的视频ID
        String videoId = UUID.randomUUID().toString();
        
        // 生成OSS上传凭证(有效期1小时)
        UploadCredential credential = ossService.generateUploadCredential(
            "video/original/" + videoId,
            3600
        );
        
        credential.setVideoId(videoId);
        
        return Result.success(credential);
    }
    
    /**
     * ⭐ 上传完成通知
     */
    @PostMapping("/upload/complete")
    public Result<Void> uploadComplete(@RequestBody VideoUploadRequest request) {
        // 1. 创建视频记录
        Video video = new Video();
        video.setId(request.getVideoId());
        video.setUserId(request.getUserId());
        video.setTitle(request.getTitle());
        video.setDescription(request.getDescription());
        video.setOriginalUrl(request.getOssUrl());
        video.setStatus(VideoStatus.TRANSCODING);  // 转码中
        video.setCreateTime(new Date());
        videoService.save(video);
        
        // ⭐ 2. 触发转码任务
        TranscodeTask task = new TranscodeTask();
        task.setVideoId(request.getVideoId());
        task.setOriginalUrl(request.getOssUrl());
        transcodeService.submitTask(task);
        
        return Result.success();
    }
}

设计3:视频转码 🔄

转码流程

转码流程:

1. 从OSS下载原视频
    ↓
2. FFmpeg转码:
   - 360P(流畅)
   - 720P(高清)
   - 1080P(超清)
    ↓
3. 切片(HLS):
   - 每10秒一个分片(.ts文件)
   - 生成索引文件(.m3u8)
    ↓
4. 上传到OSS
    ↓
5. 更新视频状态:可播放 ✅

代码实现

@Service
public class TranscodeService {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    @Autowired
    private VideoMapper videoMapper;
    
    /**
     * ⭐ 提交转码任务
     */
    public void submitTask(TranscodeTask task) {
        // 发送到MQ,异步处理
        rocketMQTemplate.syncSend("transcode-topic", task);
    }
}

/**
 * ⭐ 转码消费者
 */
@Component
@RocketMQMessageListener(
    topic = "transcode-topic",
    consumerGroup = "transcode-consumer"
)
public class TranscodeConsumer implements RocketMQListener<TranscodeTask> {
    
    @Autowired
    private VideoMapper videoMapper;
    
    @Autowired
    private OSSService ossService;
    
    @Override
    public void onMessage(TranscodeTask task) {
        String videoId = task.getVideoId();
        String originalUrl = task.getOriginalUrl();
        
        try {
            // 1. 从OSS下载原视频到本地
            String localPath = "/tmp/" + videoId + ".mp4";
            ossService.download(originalUrl, localPath);
            
            // ⭐ 2. 转码多个分辨率
            transcode360P(videoId, localPath);
            transcode720P(videoId, localPath);
            transcode1080P(videoId, localPath);
            
            // 3. 删除本地临时文件
            new File(localPath).delete();
            
            // ⭐ 4. 更新视频状态
            Video video = videoMapper.selectById(videoId);
            video.setStatus(VideoStatus.PUBLISHED);
            videoMapper.updateById(video);
            
            System.out.println("⭐ 转码完成:" + videoId);
            
        } catch (Exception e) {
            e.printStackTrace();
            
            // 转码失败
            Video video = videoMapper.selectById(videoId);
            video.setStatus(VideoStatus.TRANSCODE_FAILED);
            videoMapper.updateById(video);
        }
    }
    
    /**
     * ⭐ 转码360P
     */
    private void transcode360P(String videoId, String inputPath) throws Exception {
        String outputDir = "/tmp/" + videoId + "/360p/";
        new File(outputDir).mkdirs();
        
        // FFmpeg命令
        String command = String.format(
            "ffmpeg -i %s " +
            "-c:v libx264 " +           // 视频编码器
            "-s 640x360 " +             // 分辨率
            "-b:v 800k " +              // 视频比特率
            "-c:a aac " +               // 音频编码器
            "-b:a 128k " +              // 音频比特率
            "-hls_time 10 " +           // 每个分片10秒
            "-hls_list_size 0 " +       // m3u8包含所有分片
            "-f hls " +                 // 输出格式HLS
            "%s/index.m3u8",
            inputPath,
            outputDir
        );
        
        // 执行FFmpeg命令
        Process process = Runtime.getRuntime().exec(command);
        process.waitFor();
        
        // 上传到OSS
        uploadToOSS(videoId, outputDir, "360p");
        
        // 删除本地文件
        FileUtils.deleteDirectory(new File(outputDir));
    }
    
    /**
     * 转码720P
     */
    private void transcode720P(String videoId, String inputPath) throws Exception {
        // 类似360P,分辨率改为1280x720,比特率改为2000k
        // ...
    }
    
    /**
     * 转码1080P
     */
    private void transcode1080P(String videoId, String inputPath) throws Exception {
        // 类似360P,分辨率改为1920x1080,比特率改为4000k
        // ...
    }
    
    /**
     * 上传到OSS
     */
    private void uploadToOSS(String videoId, String dir, String quality) {
        File folder = new File(dir);
        File[] files = folder.listFiles();
        
        if (files != null) {
            for (File file : files) {
                String ossKey = "video/" + videoId + "/" + quality + "/" + file.getName();
                ossService.upload(file.getAbsolutePath(), ossKey);
            }
        }
    }
}

设计4:视频播放 ▶️

播放流程

播放流程:

1. 客户端:请求视频播放地址
    ↓
2. 服务器:返回m3u8地址(CDN)
    ↓
3. 播放器:解析m3u8,获取分片列表
    ↓
4. 播放器:依次下载分片(.ts文件)
    ↓
5. 播放器:边下边播 ✅

代码实现

@RestController
@RequestMapping("/video")
public class VideoPlayController {
    
    @Autowired
    private VideoService videoService;
    
    @Autowired
    private CDNService cdnService;
    
    /**
     * ⭐ 获取视频播放地址
     */
    @GetMapping("/{videoId}/play")
    public Result<VideoPlayInfo> getPlayInfo(
            @PathVariable String videoId,
            @RequestParam(defaultValue = "720p") String quality) {
        
        // 1. 查询视频信息
        Video video = videoService.getById(videoId);
        
        if (video == null) {
            return Result.fail("视频不存在");
        }
        
        if (video.getStatus() != VideoStatus.PUBLISHED) {
            return Result.fail("视频未就绪");
        }
        
        // ⭐ 2. 生成m3u8播放地址(CDN加速)
        String m3u8Url = cdnService.getVideoUrl(
            "video/" + videoId + "/" + quality + "/index.m3u8"
        );
        
        // 3. 构造播放信息
        VideoPlayInfo playInfo = new VideoPlayInfo();
        playInfo.setVideoId(videoId);
        playInfo.setTitle(video.getTitle());
        playInfo.setM3u8Url(m3u8Url);
        playInfo.setDuration(video.getDuration());
        
        // 支持的清晰度列表
        List<QualityOption> qualities = Arrays.asList(
            new QualityOption("360p", "流畅"),
            new QualityOption("720p", "高清"),
            new QualityOption("1080p", "超清")
        );
        playInfo.setQualities(qualities);
        
        return Result.success(playInfo);
    }
    
    /**
     * ⭐ 上报播放进度
     */
    @PostMapping("/{videoId}/progress")
    public Result<Void> reportProgress(
            @PathVariable String videoId,
            @RequestParam Long userId,
            @RequestParam Integer progress) {
        
        // 保存播放进度到Redis
        String key = "video:progress:" + userId + ":" + videoId;
        redisTemplate.opsForValue().set(key, String.valueOf(progress), 7, TimeUnit.DAYS);
        
        return Result.success();
    }
    
    /**
     * ⭐ 获取播放进度
     */
    @GetMapping("/{videoId}/progress")
    public Result<Integer> getProgress(
            @PathVariable String videoId,
            @RequestParam Long userId) {
        
        String key = "video:progress:" + userId + ":" + videoId;
        String progress = redisTemplate.opsForValue().get(key);
        
        return Result.success(progress != null ? Integer.parseInt(progress) : 0);
    }
}

设计5:CDN加速 🚀

CDN原理:

用户(北京) → 请求视频
    ↓
CDN:选择最近的边缘节点(北京节点)
    ↓
北京节点:
- 有缓存 → 直接返回 ✅(超快)
- 无缓存 → 回源到OSS → 缓存 → 返回

优点:
- 就近访问 ✅
- 减轻源站压力 ✅
- 提高播放速度 ✅

配置CDN

@Service
public class CDNService {
    
    private String cdnDomain = "video.cdn.example.com";
    
    /**
     * ⭐ 获取CDN视频地址
     */
    public String getVideoUrl(String ossKey) {
        // CDN域名 + OSS Key
        return "https://" + cdnDomain + "/" + ossKey;
    }
    
    /**
     * ⭐ 刷新CDN缓存(视频更新时)
     */
    public void refreshCache(String ossKey) {
        // 调用CDN API刷新缓存
        // ...
    }
}

🎓 面试题速答

Q1: 视频如何存储?

A: 对象存储OSS

视频存储路径:
video/{videoId}/{quality}/index.m3u8
video/{videoId}/{quality}/segment0.ts
video/{videoId}/{quality}/segment1.ts
...

例子:
video/abc123/720p/index.m3u8
video/abc123/720p/segment0.ts
video/abc123/720p/segment1.ts

优点

  • 海量存储(PB级)
  • 高可靠(多副本)
  • CDN加速

Q2: 视频如何播放?

A: HLS切片 + 边下边播

1. 视频切片(每10秒一个.ts文件)
2. 生成索引文件(.m3u8)
3. 播放器解析m3u8,获取分片列表
4. 依次下载分片,边下边播 ✅

优点

  • 不需要等待整个视频下载
  • 支持多清晰度切换

Q3: 视频如何转码?

A: FFmpeg + MQ异步

// FFmpeg转码命令
ffmpeg -i input.mp4 \
  -c:v libx264 \          // 视频编码H.264
  -s 1280x720 \           // 分辨率720P
  -b:v 2000k \            // 比特率2000k
  -hls_time 10 \          // 每个分片10秒
  -f hls \                // 输出HLS格式
  output.m3u8

异步处理

  • 上传完成 → 发送MQ消息
  • 转码Consumer消费 → FFmpeg转码
  • 转码完成 → 更新状态

Q4: 如何保证播放流畅?

A: CDN + 多分辨率

  1. CDN加速

    • 就近访问边缘节点
    • 减少延迟
  2. 多分辨率

    • 360P(流畅)
    • 720P(高清)
    • 1080P(超清)
    • 根据网速自动切换
  3. 预加载

    • 提前下载下一个分片

Q5: 播放进度如何记录?

A: Redis存储

// 上报播放进度(每5秒)
String key = "video:progress:" + userId + ":" + videoId;
redisTemplate.opsForValue().set(key, progress, 7, TimeUnit.DAYS);

// 下次播放时获取进度
String progress = redisTemplate.opsForValue().get(key);
player.seek(progress);  // 跳转到上次播放位置

Q6: 如何防止视频盗链?

A: URL签名

// 生成带签名的URL
public String generateSignedUrl(String videoId, Long userId) {
    // 过期时间(1小时)
    long expireTime = System.currentTimeMillis() + 3600000;
    
    // 生成签名
    String sign = MD5.encode(videoId + userId + expireTime + SECRET_KEY);
    
    // 拼接URL
    return String.format("%s?videoId=%s&userId=%s&expire=%d&sign=%s",
                        CDN_DOMAIN, videoId, userId, expireTime, sign);
}

// 验证签名
public boolean verifySign(String videoId, Long userId, 
                         long expireTime, String sign) {
    // 检查是否过期
    if (System.currentTimeMillis() > expireTime) {
        return false;
    }
    
    // 验证签名
    String expectedSign = MD5.encode(videoId + userId + expireTime + SECRET_KEY);
    return expectedSign.equals(sign);
}

🎬 总结

       视频网站系统核心

┌────────────────────────────────────┐
│ 1. 视频上传                        │
│    - 分片上传到OSS                 │
│    - 获取上传凭证                  │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 视频转码(FFmpeg)⭐             │
│    - 多分辨率(360P/720P/1080P)   │
│    - HLS切片(10秒/片)            │
│    - MQ异步处理                    │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. 视频播放                        │
│    - m3u8索引文件                  │
│    - 边下边播                      │
│    - 多清晰度切换                  │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. CDN加速 🚀                       │
│    - 就近访问                      │
│    - 边缘缓存                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 播放进度(Redis)               │
│    - 记录播放位置                  │
│    - 断点续播                      │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了视频网站系统的设计!🎊

核心要点

  1. 对象存储OSS:海量存储,高可靠
  2. FFmpeg转码:多分辨率,HLS切片
  3. MQ异步处理:上传完成触发转码
  4. CDN加速:就近访问,边缘缓存
  5. 播放进度:Redis存储,断点续播

下次面试,这样回答

"视频网站系统的核心是视频存储、转码和播放。视频存储使用对象存储OSS,支持PB级海量存储和多副本高可靠。用户上传视频时,先获取OSS上传凭证,直接上传到OSS,上传完成后通知服务器,服务器发送MQ消息触发转码任务。

视频转码使用FFmpeg实现。Consumer从MQ消费转码任务,下载原视频到本地,使用FFmpeg命令转码成360P、720P、1080P三种分辨率。转码同时进行HLS切片,每10秒生成一个.ts分片文件,并生成index.m3u8索引文件。转码完成后上传到OSS,更新视频状态为可播放。

视频播放采用HLS协议。客户端请求播放地址,服务器返回CDN加速后的m3u8地址。播放器解析m3u8获取分片列表,依次下载.ts分片文件边下边播。支持多清晰度切换,用户可根据网速选择不同分辨率。

CDN加速是性能关键。视频文件通过CDN分发到全国各地的边缘节点,用户就近访问最近的边缘节点获取视频。边缘节点有缓存直接返回,无缓存则回源到OSS并缓存。这样大大减少了用户到源站的距离,提高播放速度。

播放进度使用Redis存储。客户端每5秒上报一次播放进度,key为'video:progress:用户ID:视频ID',7天过期。下次播放时先查询Redis获取上次播放位置,实现断点续播。

防盗链通过URL签名实现。生成播放地址时加入用户ID、过期时间和签名,签名由这些参数加密钥MD5生成。CDN节点验证签名和过期时间,防止视频被盗链。"

面试官:👍 "很好!你对视频网站的设计理解很深刻!"


本文完 🎬

上一篇: 217-设计一个分布式爬虫系统.md
下一篇: 219-设计一个推荐系统架构.md

作者注:写完这篇,我都想去B站当UP主了!🎬
如果这篇文章对你有帮助,请给我一个Star⭐!