猜测、实现 B 站在看人数

猜测、实现 B 站在看人数

猜测

找到接口

浏览器打开一个 B 站视频,比如 《黑神话:悟空》最终预告 | 8月20日,重走西游_黑神话悟空 (bilibili.com) ,打开 F12 开发者工具,经过观察,发现每 30 秒就会有一个如下的请求:

json 复制代码
https://api.bilibili.com/x/player/online/total?aid=1056417986&cid=1641689875&bvid=BV1oH4y1c7Kk&ts=57523354

{
    "code": 0,
    "message": "0",
    "ttl": 1,
    "data": {
        "total": "239",
        "count": "182",
        "show_switch": {
            "total": true,
            "count": true
        },
        "abtest": {
            "group": "b"
        }
    }
}

返回值中的 data.total 就是在看人数,如下:

参数

请求有 4 个参数:

复制代码
aid=1056417986
cid=1641689875
bvid=BV1oH4y1c7Kk
ts=57523354

aid、bvid 是稿件的编号,cid 是视频的编号,一个稿件可能有多个视频。通过三者可定位到唯一的视频。

ts 从命名上来看应该是时间戳,比如 57523353、57523354 ,但显然太短了,应该是经过处理的,最后发现是时间戳(秒)除以 30 向上取整的结果:

js 复制代码
calcTs = function(date) {
    // 时间戳(秒)
    const timestamp_second = date.getTime() / 1000;
    // 除以 30 向上取整
    const ts = Math.ceil(timestamp_second / 30);
    console.log(ts)
    return ts;
}

下图是两个请求的参数以及请求的时间:

在浏览器控制台验证猜想,通过 calcTs 函数可计算出 ts,与请求参数完全吻合:

总结

B 站的实现思路应该是:aid、bvid、cid 作为唯一编号,以 30 秒为一个时间窗口进行统计,在这 30s 中的请求都会使窗口值加 1,每次累加完后返回最新值即可。

但同时还发现在多个标签页中打开同一个视频时,比如 5 个标签页,一开始在看人数都是 1,等一会在看人数才会陆续变成 5。也就是说返回的不是最新值,因为如果返回最新值的话,5 个标签页的在看人数应该分别是 1 2 3 4 5

猜测应该是同时存在两个 30 秒时间窗口,这里称为当前窗口( currentWindow ,也就是 ts 对应的 30s 窗口) 和上一个窗口(previousWindowts - 1 对应的 30s 窗口),每次都累加到 currentWindow,但返回 previousWindow

这样就能解释为什么一开始在看人数都是 1,等一会在看人数才会陆续变成 5 了。打开视频时,previousWindow 不存在,所以返回了 1;同时创建 currentWindow 并从 1 累加到 5。这样等 30s 后下一个定时任务时,currentWindow 就变成了 previousWindow,5 个标签页都会返回 5,在看人数就都陆续变成 5 了。

实现

后端可以使用 Redis 实现,最简单的办法是使用 string 结构,以 aid、bvid、cid、ts 作为 key,给 key 设置大于 60s 的过期时间,每次请求时使用 incr 自增即可。但这样会导致 Redis 找那个有大量的 key,不好维护。

可以使用 hash 结构,以 ts 为 key,以 aid、bvid、cid 为 field,窗口值为 value。这样 Redis 中只会有 ts、ts - 1 两个 key。如果必要的话,也可以根据 field 的值将其 hash 分区到 2 * N 个 key 中。

TotalService

java 复制代码
package com.example.demo3;

import lombok.SneakyThrows;
import org.redisson.api.*;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;

import java.time.Duration;
import java.util.concurrent.ExecutionException;

@Service
public class TotalService {

    private final RedissonClient redisson;

    public TotalService(RedissonClient redisson) {
        this.redisson = redisson;
    }

    @SneakyThrows({ExecutionException.class, InterruptedException.class})
    @GetMapping
    public Integer total(String aid, String bvid, String cid, Long ts) {
        RBatch batch = redisson.createBatch(BatchOptions.defaults());
        // currentWindow
        // 以时间戳作为 key
        RMapAsync<String, Integer> currentWindow = batch.getMap(ts.toString());
        // 以 aid, bvid, cid 作为 currentWindow 的 key
        String field = field(aid, bvid, cid);
        // 自增 + 1
        currentWindow.addAndGetAsync(field, 1);
        // 过期时间必须大于 60s
        currentWindow.expireIfNotSetAsync(Duration.ofSeconds(70));

        // previousWindow
        RMapAsync<String, Integer> previousWindow = batch.getMap(String.valueOf(ts - 1));
        RFuture<Integer> totalFuture = previousWindow.getAsync(field);
        batch.execute();

        Integer total = totalFuture.get();
        // 如果 previousWindow 不存在,则返回 1
        if (total == null || total == 0) {
            return 1;
        }
        return total;
    }

    private String field(String aid, String bvid, String cid) {
        return aid + ":" + bvid + ":" + cid;
    }
}

TotalController

java 复制代码
@RestController
@RequestMapping("/x/player/online/total")
public class TotalController {

    private final TotalService totalService;

    public TotalController(TotalService totalService) {
        this.totalService = totalService;
    }

    @CrossOrigin(originPatterns = "*")
    @GetMapping
    public Integer total(@RequestParam("aid") String aid, @RequestParam("bvid") String bvid,
                         @RequestParam("cid") String cid, @RequestParam("ts") Long ts) {
        return totalService.total(aid, bvid, cid, ts);
    }
}

test.html

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
    <div>
        aid <input id="aid" type="text" value="113071355923972">
        bvid <input id="bvid" type="text" value="BV1giHnexEiD">
        cid <input id="cid" type="text" value="25714427593">
    </div>
    <div>
        在看:<span id="total">0</span>
    </div>
</div>
</body>
<script type="text/javascript">
    const elem_aid = document.getElementById("aid");
    const elem_bvid_elem = document.getElementById("bvid");
    const elem_cid_elem = document.getElementById("cid");
    const elem_total = document.getElementById("total");

    refreshTotal().then(() => {
        // 30 秒执行一次
        setInterval(function () {
            refreshTotal();
        }, 30000)
    });

    async function refreshTotal() {
        const aid = elem_aid.value;
        const bvid = elem_bvid_elem.value;
        const cid = elem_cid_elem.value;
        const ts = calcTs(new Date());
        const url = `http://localhost:8080/x/player/online/total?aid=${aid}&cid=${cid}&bvid=${bvid}&ts=${ts}`;
        const response = await fetch(url);
        const total = await response.json();
        console.log(total);
        elem_total.innerHTML = total;
    }

    function calcTs(date) {
        // 时间戳(秒)
        const timestamp_second = date.getTime() / 1000;
        // 除以 30 向上取整
        const ts = Math.ceil(timestamp_second / 30);
        console.log(ts)
        return ts;
    }
</script>
</html>
相关推荐
二宝哥13 小时前
CentOS 7.9 系统下 Redis 5.0.4 单机安装与源码编译安装指南
redis·centos
ShiXZ21319 小时前
Redis docker-compose 部署指南
redis·docker·eureka
青山木1 天前
Redis 缓存一致性全解:从 Cache Aside 到延迟双删、Canal+MQ 的进阶之路
java·数据库·redis·后端·缓存
YDS8291 天前
大营销平台 —— 活动SKU库存扣减业务及其一致性处理
java·spring boot·redis·rabbitmq·ddd
爬也要爬着前进2 天前
redis主从搭建
数据库·redis·缓存
用户095367515832 天前
在系统的学习 redis 前的疑惑
redis·go
吴声子夜歌2 天前
Redis 3.x——集群故障转移
java·数据库·redis·集群
明豆2 天前
Redis 分布式缓存生产实战
redis·分布式·缓存
海兰2 天前
【高速缓存】RedisVL 指南:从向量搜索到 AI 应用落地
人工智能·redis
weixin_727535622 天前
双Token认证体系深度拆解:Spring Security + JWT + Redis
redis·spring·wpf