第三章 打造高性能的视频弹幕系统

第三章 打造高性能的视频弹幕系统

  • 场景分析:客户端针对某一视频创建了弹幕,发送给后端进行处理,后端需要对所有正在观看该视频的用户推送该弹幕
  • 两种实现方式:使用短连接进行通信或使用长连接进行通信

短连接实现方案:

  • 所有观看视频的客户端不断轮询后端,若有新的弹幕则拉取后进行显示
  • 缺点:轮询的效率低,非常浪费资源(因为HTTP协议只能由客户端向服务端发起,故必须不停连接后端)

长连接实现方案:

  • 采用 WebSocket 进行前后端通信
  • 为什么要用 WebSocket:HTTP 协议的通信只能由客户端发起,做不到服务器主动向客户端推送信息。

WebSocket 协议

  • WebSocket简介:WebSocket 协议是基于TCP的一种新的网络协议。它实现了浏览器与服务器全双工(Full-Duplex)通信。
  • 全双工(Full-Duplex)通信:客户端可以主动发送信息给服务端,服务端也可以主动发送信息给客户端。
  • WebSocket协议优点:报文体积小、支持长连接。

弹幕系统架构设计

Untitled

优化方向一(后端接收前端发来的弹幕,并将弹幕推送给前端展示)

  • 假设前端传过来2万条请求(弹幕),后端需要推送这2万条请求到前端,那么就相当于后端总共需要处理4万条请求,后端将这4万条请求分成10批次,每一批就是4000个请求,
  • 但是这10批次里面的第一批我们首先进行处理,第2~10批我们先不进行处理,把它们先放到 MQ 里面进行排队,这个就是削峰;
  • 将第一批的4000条请求,好好利用服务器的并行处理能力,给它进行并发处理,同一时间段内进行并发处理4000条请求的耗时可能也就几百毫秒,
  • 这样在 2~4 秒的时间段内,服务器就能完成这4万条请求的处理;
  • 在前端的用户感知来看,实际就是用户发送了一条弹幕,2~4秒后就可以在页面上看到自己所发送的弹幕了,体验感较好。

优化方向二(后端接收弹幕后,将弹幕持久化到数据库)

  • 后端接收前端传过来的弹幕后,将弹幕通过 MQ 进行异步持久化到数据库,并且采用 MQ 的目的是为了限流削峰,减轻数据库的压力;
  • 并且由于是异步操作,主线程是另外开了一条线程在进行持久化数据库的操作,这样子不会影响主线程的其他操作(例如同步保存弹幕到 Redis 里面)
  • 假设有 2 万条弹幕同时过来数据库,先将弹幕数据保存到 MQ 里面,这样子 MQ 可以每秒处理 2000 个请求,这样的速度保存到数据库中,不至于会使数据库崩溃,能够有效降低数据库的压力。

优化方向三(将弹幕数据写到redis,再次查询可以快速读取)

  • 在将弹幕数据保存到数据库中时,也要将弹幕数据同步保存到 redis(缓存)中,
  • 为了我们在下一次加载到视频详情页的时候,能够把我们当前或者当天的弹幕数据给快速查询出来;
  • 如果某个视频在今天保存了很大的弹幕数据量,如果每次都从数据库中进行查询的话,一是速度慢,二是可能会对数据库造成读取压力(如果有多个视频进行查询,其他视频可能需要排队查询);
  • 如果将今天生成的弹幕数据都保存到 redis 中,在下次进行页面刷新的时候,会调用一个弹幕数据查询的操作,就可以直接从 redis 里面进行读取,这样的速度是非常快的,因为它是从内存里面查询数据。
  • redis 单机最大处理量可以达到 10 ~ 50 万左右。

SpringBoot 整合 WebSocket

导入依赖

<!-- WebSocket依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

工具类

@Configuration
public class WebSocketConfig {

    /**
     * 用来发现WebSocket服务的
     *
     * @return
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

业务类

@Component
@ServerEndpoint("/imserver")
public class WebSocketService {

    private final Logger logger = LoggerFactory.getLogger(WebSocketService.class);

    /**
     * 当前长连接的数量(在线人数的统计)
     * 也就是当前有多少客户端通过WebSocket连接到服务端
     */
    private static final AtomicInteger ONLINE_COUNT = new AtomicInteger(0);

    /**
     * 一个客户端 关联 一个WebSocketService
     * 是一个多例模式的,这里需要注意下
     */
    private static final ConcurrentHashMap<String, WebSocketService> WEBSOCKET_MAP = new ConcurrentHashMap<>();

    /**
     * 服务端 和 客户端 进行通信的一个会话
     * 当我们有一个客户端进来了,然后保持连接成功了,那么我们就会保存一个跟这个客户端关联的session
     */
    private Session session;

    /**
     * 唯一标识
     */
    private String sessionId;

    /**
     * 打开连接
     *
     * @param session
     * @OnOpen 连接成功后会自动调用该方法
     */
    @OnOpen
    public void openConnection(Session session) {
        // 保存session相关信息到本地
        this.sessionId = session.getId();
        this.session = session;

        // 判断WEBSOCKET_MAP是否含有sessionId,有的话先删除再重新添加
        if (WEBSOCKET_MAP.containsKey(sessionId)) {
            WEBSOCKET_MAP.remove(sessionId);
            WEBSOCKET_MAP.put(sessionId, this);
        } else { // 没有的话就直接新增
            WEBSOCKET_MAP.put(sessionId, this);
            // 在线人数加一
            ONLINE_COUNT.getAndIncrement();
        }
        logger.info("用户连接成功:" + sessionId + ",当前在线人数为:" + ONLINE_COUNT.get());

        // 连接成功之后需要通知客户端,方便客户端进行后续操作
        try {
            this.sendMessage("0");
        } catch (Exception e) {
            logger.error("连接异常!");
        }

    }

    /**
     * 客户端刷新页面,或者关闭页面,服务端断开连接等等操作,都需要关闭连接
     */
    @OnClose
    public void closeConnection() {
        if (WEBSOCKET_MAP.containsKey(sessionId)) {
            WEBSOCKET_MAP.remove(sessionId);
            // 在线人数减一
            ONLINE_COUNT.getAndDecrement();
            logger.info("用户退出:" + sessionId + ",当前在线人数为:" + ONLINE_COUNT.get());
        }
    }

    /**
     * 客户端发送消息给后端
     *
     * @param message
     */
    @OnMessage
    public void onMessage(String message) {

    }

    /**
     * 发生错误之后的处理
     *
     * @param error
     */
    @OnError
    public void onError(Throwable error) {

    }

    /**
     * 后端发送消息给客户端
     *
     * @param message
     * @throws IOException
     */
    private void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
    }

}

多例模式下引发的Bean注入为null的问题

在启动类中将ApplicationContext传给WebSocketService中的*APPLICATION_CONTEXT*

@SpringBootApplication
@EnableTransactionManagement
public class ImoocBilibiliApplication {

    public static void main(String[] args) {
        ApplicationContext app = SpringApplication.run(ImoocBilibiliApplication.class, args);
        WebSocketService.setApplicationContext(app);
    }

}

WebSocketService

  • @Autowired 在多例模式下是不会自动进行加载的,所以这里我们不能使用@Autowired进行注入;
  • 而我们的启动类生成的ApplicationContext,是可以通过getBean( )方法获取到Spring容器中所有Bean的;
		/**
     * 全局的上下文变量
     */
    private static ApplicationContext APPLICATION_CONTEXT;

    /**
     * 通用的上下文环境变量的方法,每个WebSocketService都会共用同一个ApplicationContext
     *
     * @param applicationContext
     */
    public static void setApplicationContext(ApplicationContext applicationContext) {
        WebSocketService.APPLICATION_CONTEXT = applicationContext;
    }

弹幕系统实现

数据库表设计及相关实体类设计

弹幕记录表

Untitled

业务层

WebSocketService.java

@Component
@ServerEndpoint("/imserver/{token}")
public class WebSocketService {

    private final Logger logger = LoggerFactory.getLogger(WebSocketService.class);

    /**
     * 当前长连接的数量(在线人数的统计)
     * 也就是当前有多少客户端通过WebSocket连接到服务端
     */
    private static final AtomicInteger ONLINE_COUNT = new AtomicInteger(0);

    /**
     * 一个客户端 关联 一个WebSocketService
     * 是一个多例模式的,这里需要注意下
     */
    private static final ConcurrentHashMap<String, WebSocketService> WEBSOCKET_MAP = new ConcurrentHashMap<>();

    /**
     * 服务端 和 客户端 进行通信的一个会话
     * 当我们有一个客户端进来了,然后保持连接成功了,那么我们就会保存一个跟这个客户端关联的session
     */
    private Session session;

    /**
     * 唯一标识
     */
    private String sessionId;

    private Long userId;

    /**
     * 全局的上下文变量
     */
    private static ApplicationContext APPLICATION_CONTEXT;

    /**
     * 打开连接
     *
     * @param session
     * @param token
     * @OnOpen 连接成功后会自动调用该方法
     * @PathParam("token") 获取 @ServerEndpoint("/imserver/{token}") 后面的参数
     */
    @OnOpen
    public void openConnection(Session session, @PathParam("token") String token) {
        // 如果是游客观看视频,虽然有弹幕,但是没有用户信息,所以需要用try
        try {
            this.userId = TokenUtil.verifyToken(token);
        } catch (Exception ignored) {
        }
        // 保存session相关信息到本地
        this.sessionId = session.getId();
        this.session = session;

        // 判断WEBSOCKET_MAP是否含有sessionId,有的话先删除再重新添加
        if (WEBSOCKET_MAP.containsKey(sessionId)) {
            WEBSOCKET_MAP.remove(sessionId);
            WEBSOCKET_MAP.put(sessionId, this);
        } else { // 没有的话就直接新增
            WEBSOCKET_MAP.put(sessionId, this);
            // 在线人数加一
            ONLINE_COUNT.getAndIncrement();
        }
        logger.info("用户连接成功:" + sessionId + ",当前在线人数为:" + ONLINE_COUNT.get());

        // 连接成功之后需要通知客户端,方便客户端进行后续操作
        try {
            this.sendMessage("0");
        } catch (Exception e) {
            logger.error("连接异常!");
        }
    }

    /**
     * 客户端发送消息给服务端
     *
     * @param message
     */
    @OnMessage
    public void onMessage(String message) {
        logger.info("用户信息:" + sessionId + ",报文:" + message);
        if (!StringUtils.isNullOrEmpty(message)) {
            try {
                // 群发消息(服务端拿到某一个客户端发来的消息,然后群发到所有与它连接的客户端)
                for (Map.Entry<String, WebSocketService> entry : WEBSOCKET_MAP.entrySet()) {
                    // 获取每一个和服务端连接的客户端
                    WebSocketService webSocketService = entry.getValue();
                    // 判断会话是否还处于打开状态
                    if (webSocketService.session.isOpen()) {
                        webSocketService.sendMessage(message);
                    }
                }
                if (this.userId != null) {
                    // --------- 保存弹幕到数据库 ----------
                    // 将message转换成Danmu实体类的数据
                    Danmu danmu = JSONObject.parseObject(message, Danmu.class);
                    danmu.setUserId(userId);
                    danmu.setCreateTime(new Date());
                    DanmuService danmuService = (DanmuService) APPLICATION_CONTEXT.getBean("danmuService");
                    danmuService.addDanmu(danmu);

                    // ----------- 保存弹幕到redis -----------
                    danmuService.addDanmusToRedis(danmu);
                }
            } catch (Exception e) {
                logger.error("弹幕接收出现问题!");
                e.printStackTrace();
            }
        }
    }

}

DanmuService.java

@Service
public class DanmuService {

    private static final String DANMU_KEY = "dm-video-";

    @Autowired
    private DanmuDao danmuDao;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 添加弹幕
     *
     * @param danmu
     */
    public void addDanmu(Danmu danmu) {
        danmuDao.addDanmu(danmu);
    }

    /**
     * 查询弹幕
     *
     * @param danmu
     */
    @Async
    public void asyncAddDanmu(Danmu danmu) {
        danmuDao.addDanmu(danmu);
    }

    /**
     * 添加弹幕到redis
     * 下次加载页面时,可以快速从缓存中获取弹幕
     *
     * @param danmu
     */
    public void addDanmusToRedis(Danmu danmu) {
        String key = DANMU_KEY + danmu.getVideoId();
        String value = redisTemplate.opsForValue().get(key);
        List<Danmu> list = new ArrayList<>();
        if (!StringUtil.isNullOrEmpty(value)) {
            // 将从redis中查询到的数据转换成list集合
            list = JSONArray.parseArray(value, Danmu.class);
        }
        // 将新的弹幕添加到list中
        list.add(danmu);
        redisTemplate.opsForValue().set(key, JSONObject.toJSONString(list));
    }

}

推送弹幕性能优化

WebSocketService.java

		/**
     * 客户端发送消息给服务端
     *
     * @param message
     */
    @OnMessage
    public void onMessage(String message) {
        logger.info("用户信息:" + sessionId + ",报文:" + message);
        if (!StringUtils.isNullOrEmpty(message)) {
            try {
                // 群发消息(服务端拿到某一个客户端发来的消息,然后群发到所有与它连接的客户端)
                for (Map.Entry<String, WebSocketService> entry : WEBSOCKET_MAP.entrySet()) {
                    // 获取每一个和服务端连接的客户端
                    WebSocketService webSocketService = entry.getValue();

                    // 获取到弹幕生产者
                    DefaultMQProducer danmusProducer = (DefaultMQProducer) APPLICATION_CONTEXT.getBean("danmusProducer");
                    JSONObject jsonObject = new JSONObject();
                    jsonObject.put("message", message);
                    jsonObject.put("sessionId", webSocketService.getSessionId());
                    Message msg = new Message(UserMomentsConstant.TOPIC_DANMUS, jsonObject.toJSONString().getBytes(StandardCharsets.UTF_8));
                    // 异步发送消息
                    RocketMQUtil.asyncSendMsg(danmusProducer, msg);
                }

RocketMQConfig.java

@Configuration
public class RocketMQConfig {

    @Value("${rocketmq.name.server.address}")
    private String nameServerAddr;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private UserFollowingService userFollowingService;

    /**
     * 弹幕生产者
     *
     * @return
     * @throws Exception
     */
    @Bean("danmusProducer")
    public DefaultMQProducer danmusProducer() throws Exception {
        // 实例化消息生产者Producer
        DefaultMQProducer producer = new DefaultMQProducer(UserMomentsConstant.GROUP_MOMENTS);
        // 设置NameServer的地址
        producer.setNamesrvAddr(nameServerAddr);
        // 启动Producer实例
        producer.start();
        return producer;
    }

    /**
     * 弹幕消费者
     *
     * @return
     * @throws Exception
     */
    @Bean("danmusConsumer")
    public DefaultMQPushConsumer danmusConsumer() throws Exception {
        // 实例化消费者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(UserMomentsConstant.GROUP_DANMUS);
        // 设置NameServer的地址
        consumer.setNamesrvAddr(nameServerAddr);
        // 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
        consumer.subscribe(UserMomentsConstant.TOPIC_DANMUS, "*");
        // 注册回调实现类来处理从broker拉取回来的消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                MessageExt msg = msgs.get(0);
                if (msg == null) {
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                }
                String bodyStr = new String(msg.getBody());
                JSONObject jsonObject = JSONObject.parseObject(bodyStr);
                String sessionId = jsonObject.getString("sessionId");
                String message = jsonObject.getString("message");
                // 根据sessionId获取对应的webSocketService
                WebSocketService webSocketService = WebSocketService.WEBSOCKET_MAP.get(sessionId);

                // 判断会话是否还处于打开状态
                if (webSocketService.getSession().isOpen()) {
                    try {
                        // 服务器发送消息给客户端
                        webSocketService.sendMessage(message);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }

                // 标记该消息已经被成功消费
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者实例
        consumer.start();
        return consumer;
    }
}

弹幕消息异步存储优化

优化一

使用SpringBoot的 @Async 注解进行异步保存弹幕

  • DanmuService.java
		/**
     * 异步保存弹幕
     *
     * @param danmu
     * @Async 标识该方法调用的时候是使用异步的方式
     */
    @Async
    public void asyncAddDanmu(Danmu danmu) {
        danmuDao.addDanmu(danmu);
    }
  • WebSocketService.java

Untitled

优化二

使用 MQ 进行削峰操作

  • RocketMQConfig.java
		/**
     * 异步保存弹幕生产者
     *
     * @return
     * @throws Exception
     */
    @Bean("asyncadddanmusProducer")
    public DefaultMQProducer asyncAddDanmusProducer() throws Exception {
        // 实例化消息生产者Producer
        DefaultMQProducer producer = new DefaultMQProducer(UserMomentsConstant.GROUP_ASYNCADDDANMUS);
        // 设置NameServer的地址
        producer.setNamesrvAddr(nameServerAddr);
        // 启动Producer实例
        producer.start();
        return producer;
    }

    /**
     * 异步保存弹幕消费者
     *
     * @return
     * @throws Exception
     */
    @Bean("asyncadddanmusConsumer")
    public DefaultMQPushConsumer asyncAddDanmusConsumer() throws Exception {
        // 实例化消费者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(UserMomentsConstant.GROUP_ASYNCADDDANMUS);
        // 设置NameServer的地址
        consumer.setNamesrvAddr(nameServerAddr);
        // 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
        consumer.subscribe(UserMomentsConstant.TOPIC_ASYNCADDDANMUS, "*");
        // 注册回调实现类来处理从broker拉取回来的消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                MessageExt msg = msgs.get(0);
                if (msg == null) {
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                }
                String bodyStr = new String(msg.getBody());
                // 将接收到消息转换成DanMu实体类
                Danmu danmu = JSONObject.toJavaObject(JSONObject.parseObject(bodyStr), Danmu.class);
                // 异步保存弹幕
                danmuService.asyncAddDanmu(danmu);

                // 标记该消息已经被成功消费
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者实例
        consumer.start();
        return consumer;
    }
  • WebSocketService.java

Untitled

在线人数统计

设置一个定时任务,每5秒群发一个消息告诉客户端,关于该视频的当前在线人数

		/**
     * 定时任务,每5秒群发一次消息到与服务器相连的所有客户端
     *
     * @throws IOException
     * @Scheduled(fixedRate = 5000) 标识该方法是一个定时任务,并且每隔5秒执行该方法
     */
    @Scheduled(fixedRate = 5000)
    private void noticeOnlineCount() throws IOException {
        for (Map.Entry<String, WebSocketService> entry : WebSocketService.WEBSOCKET_MAP.entrySet()) {
            WebSocketService webSocketService = entry.getValue();
            if (webSocketService.session.isOpen()) {
                JSONObject jsonObject = new JSONObject();
                jsonObject.put("onlineCount", ONLINE_COUNT.get());
                jsonObject.put("msg", "当前在线人数为" + ONLINE_COUNT.get());
                // 服务端发送消息给客户端
                webSocketService.sendMessage(jsonObject.toJSONString());
            }
        }
    }

弹幕查询功能实现

DanmuApi.java

@RestController
public class DanmuApi {

    @Autowired
    private DanmuService danmuService;

    @Autowired
    private UserSupport userSupport;

    /**
     * 查询弹幕
     * 在游客模式下,是没有办法进行弹幕时间段筛选的
     * 用户进行登录之后,就可以指定时间段进行弹幕查询
     *
     * @param videoId   视频id
     * @param startTime 开始时间
     * @param endTime   结束时间
     * @return
     * @throws Exception
     */
    @GetMapping("/danmus")
    public JsonResponse<List<Danmu>> getDanmus(@RequestParam Long videoId, String startTime, String endTime) throws Exception {
        List<Danmu> list;
        try {
            // 判断当前是游客模式还是用户登录模式
            userSupport.getCurrentUserId();
            // 若是用户登录模式,则允许用户进行时间段筛选
            list = danmuService.getDanmus(videoId, startTime, endTime);
        } catch (Exception ignored) {
            // 若为游客模式,则不允许用户进行时间段筛选
            list = danmuService.getDanmus(videoId, null, null);
        }
        return new JsonResponse<>(list);
    }

}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猿小羽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值