猜测、实现 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>
相关推荐
IT大白鼠16 小时前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz16 小时前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙17 小时前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除18 小时前
redis--018
redis
两锭子19 小时前
Redis基础
redis
烟沙九洲20 小时前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索20 小时前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞20 小时前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路1 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog1 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis