猜测
找到接口
浏览器打开一个 B 站视频,比如 《黑神话:悟空》最终预告 | 8月20日,重走西游_黑神话悟空 (bilibili.com) ,打开 F12 开发者工具,经过观察,发现每 30 秒就会有一个如下的请求:
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 向上取整的结果:
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 窗口) 和上一个窗口(previousWindow
即 ts - 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
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
RMapAsyncString, 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
RMapAsyncString, Integer> previousWindow = batch.getMap(String.valueOf(ts - 1));
RFutureInteger> 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
@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
DOCTYPE html>
html lang="en">
head>
meta charset="UTF-8">
title>Titletitle>
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">0span>
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>
评论(0)