工作内容

\ResumeItem{哔哩哔哩(B站) \hfill OGV技术部 \hfill 开发实习生 \hfill 2026.05-2026.10}
\textbf{工作概述:}参与B站专业生产长视频内容(OGV)的业务开发建设,覆盖番剧、电影、电视剧等视频播放业务,主要负责OGV的C端(如播放页、播放器)等页面的业务功能的后端开发与维护

\begin{itemize}

\item \textbf{SLO 异常自动化诊断工具}:针对服务请求可用率 SLO 抖动或不达标问题,构建从异常发现到根因定位的自动化诊断系统。打通质量平台与日志平台数据链路,采用 MAD 识别异常日与异常窗口加权采样等方法,并通过线程池并行采集提升效率,经多维度聚合后交由大模型生成根因报告,保障核心服务可用率连续多日达 99.999%

\item \textbf{DRM 投屏提示改造}:针对遗漏单集 DRM 导致购买后投屏失败的问题,扩展剧集分节统计能力,实现兼容新老剧集模型的 DRM_STATUS分桶统计,通过批量统计接口聚合全分节 DRM 桶计数,结合 Redis/Caffeine 分层缓存复用统计结果,实现单集 DRM 场景的收银台不可投屏提示

\item \textbf{大追番列表空数据治理}:定位追番列表分页前全量处理导致的超时问题,短期通过动态配置调高 RPC deadline,覆盖样本内95.6%的尾部慢请求;进一步实现日番/国创按 seasonType 分区异步查询,将分区过滤前移至下游,合并结果后按 mtime 全局排序分页,配套动态开关与深分页回退,减少无关数据处理,大追番用户请求耗时降低约30%

\item \textbf{凡人修仙传复播压测与扩容}:基于 moni 批量采集并分析播放页服务及核心下游 RPC 的 QPS 变化,结合调用链放大比例定位限流与容量瓶颈,推动补齐下游限流配置,并落地 Caffeine 本地缓存、批量 miss 回源及空结果保护等优化,最终保障播放链路稳定承载峰值 140k+ QPS的流量

\end{itemize}

1. SLO 异常自动化诊断工具

\item \textbf{SLO 异常自动化诊断工具}:针对服务请求可用率 SLO 抖动或不达标问题,构建从异常发现到根因定位的自动化诊断系统。打通质量平台与日志平台数据链路,采用 MAD 识别异常日与异常窗口加权采样等方法,并通过线程池并行采集提升效率,经多维度聚合后交由大模型生成根因报告,保障核心服务可用率连续多日达 99.999%

1.1 工作概述

背景与目标:

这个项目主要是解决线上服务 SLO 出问题后(连续多天SLO达不到五个9的指标),排查过程比较依赖人工的问题。以前要先去质量平台找哪一天异常,再看哪个接口错误最多,然后到日志平台按时间和错误码查询大量日志,最后再靠经验判断到底是下游服务、某个实例、可用区,还是业务数据导致的。

方法与过程:

我把这套流程做成了一个 Java 自动化工具。

  1. 输入当前应用服务->找到异常日期:输入服务名后,工具会先拉取最近几天的 SLO 数据,用 MAD 算法识别异常日期;
  2. 找到异常接口和异常时间段:然后对比正常日和异常日,找出错误增长最多的接口和异常时间段;
  3. 接着按时间分段采样日志,并从错误类型、下游服务、实例 IP、可用区、请求大小、业务参数等维度做聚合分析,
  4. 最后把结构化结果交给大模型或人工生成根因报告。
  5. 另外,针对多接口、多日期分析耗时较长的问题,我增加了线程池并行、基线日志缓存和失败重试机制,减少重复查询,提高整体分析效率。

最终效果:

这个工具最终支撑了核心服务问题的快速定位和处理,帮助核心应用服务连续多日保持 99.999% 的请求可用率。

1.2 具体工作内容

1.2.1 需求背景

业务问题

原始需求关注的是服务在某些时段无法达到 99.999% 可用率,同时 Service 层错误数显著上升。可能原因很多,包括下游服务变慢或不可用、数据库和缓存问题、单 Pod 或单 IP 故障、可用区异常、特定剧集数据、批量请求过大,以及 deadline 在调用链传播过程中被耗尽。

真正的难点不是“看到错误数升高”,而是把错误量继续缩小到可行动的范围:哪一天、哪个接口、什么时间段、哪类错误、哪个下游、是否集中在实例或区域、是否由特定业务参数触发。

原人工流程
  1. 在 Watcher 的 SLO 报表中选择服务和日期范围。
  2. 观察 Service 层错误数、请求可用性和告警量,找异常日期。
  3. 进入根因面板,按接口查看错误数时序和错误码。
  4. 手工记录错误最多的接口及时间段。
  5. 到 Billions 选择应用,拼接接口路径和返回码条件。
  6. 调整查询窗口并抽取日志。
  7. 阅读大量日志,判断错误类型、下游、实例、可用区和业务参数分布。
  8. 选择正常基线,再判断哪些变化与异常相关。

这个过程跨平台、依赖个人经验,而且每个服务和日期都要重复执行。日志数量大时,简单取前 N 条还会导致样本集中在最后几分钟。

1.2.2 具体代码实现

1. 项目目标

该工具解决 SLO 异常后需要人工跨质量平台和日志平台排查的问题,将排查流程拆成以下阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Watcher SLO 日指标
↓
MAD 识别异常日期
↓
指定正常基线日和异常日
↓
拉取 30 秒粒度错误面板
↓
筛选错误显著增长的接口
↓
定位 Top 异常时间窗口
↓
并行采集基线日和异常日日志
↓
错误密度加权采样
↓
多维度日志聚合与基线 Diff
↓
生成结构化 JSON 证据
↓
人工或外部大模型生成根因报告

2. 核心模块

模块 作用
SloCrawlerService 从质量平台获取按天统计的 SLO 指标
AnomalyDetector 使用 MAD 检测异常日期
SloErrorCrawlerService 获取 30 秒粒度的接口错误时序
PipelineCompareOrchestrator 对比基线日和异常日,筛选增量接口
ApiTimeWindowDetector 定位异常日内的异常时间窗口
BillionsLogCrawlerService 从日志平台分页、分窗、加权采样日志
LogAggregator 将原始日志聚合为诊断维度
IncreasedApiOrchestrator 对比基线和异常日的聚合结果
BatchDiffOrchestratorV2 串联上述模块,负责缓存、并发和结果落盘
OutputWriter 写入 JSON、Markdown 文件

3. CLI 入口

命令入口位于 BatchDiffV2Command.java。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Command(
name = "batch-diffv2",
description = "V2多天逐天对比:预爬基线日志,再逐天对比异常日"
)
public class BatchDiffV2Command implements Callable<Integer> {

@Parameters(index = "0", description = "应用服务名", arity = "0..1")
private String appPath;

@Option(names = "--baseline")
private String baseline;

@Option(names = "--targets")
private String targets;

@Option(names = "--min-diff", defaultValue = "50")
private double minDiff;

@Option(names = "--min-ratio", defaultValue = "1.5")
private double minRatio;

@Option(names = "--apis")
private String apis;
}

调用示例:

1
2
3
4
5
6
./gradlew bootRun --args="batch-diffv2 \
open.bangumi.view-gateway \
--baseline 2026-05-12 \
--targets 2026-05-14,2026-05-16 \
--min-diff 50 \
--min-ratio 1.5"

参数含义:

  • appPath:待分析的服务。
  • baseline:正常基线日。
  • targets:需要对比的异常日期,可传多天。
  • min-diff:异常日相对基线日最少增加的错误数。
  • min-ratio:异常日相对基线日最小增长倍数。
  • apis:可选,按接口名称模糊匹配;指定后会绕过增量和倍率阈值。

4. 质量平台和日志平台接入

4.1 获取 SLO 日指标

SloCrawlerService.java 通过 WebClient 请求质量平台:

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, Object> result = webClient.get()
.uri(props.getWatcher().getBaseUrl(), uriBuilder -> uriBuilder
.queryParam("node_id", nodeId)
.queryParam("zone", zone)
.queryParam("period", period)
.queryParam("stime", stime)
.queryParam("etime", etime)
.queryParam("api_desc", "")
.queryParam("app_path", appPath)
.build())
.retrieve()
.bodyToMono(Map.class)
.block();

数据包括:

  • service_err
  • service_available
  • slb_available
  • apigw_available
  • alarm
  • service_budget_surplus
4.2 获取错误面板

SloErrorCrawlerService.java 拉取生产环境 GRPC 错误时序:

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, Object> result = webClient.get()
.uri(props.getWatcher().getErrorPanelUrl(), uriBuilder -> uriBuilder
.queryParam("app", appPath)
.queryParam("env", env)
.queryParam("zone", zone)
.queryParam("stime", stime)
.queryParam("etime", etime)
.queryParam("protocol", protocol)
.queryParam("metric", "error")
.build())
.retrieve()
.bodyToMono(Map.class)
.block();

默认参数为:

1
return fetchErrorData(appPath, stime, etime, "all", "GRPC", "prod");

5. MAD 异常日识别

MAD 实现在 AnomalyDetector.java。

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
List<Long> sorted = new ArrayList<>(serviceErrs);
Collections.sort(sorted);

double median = n % 2 == 1
? sorted.get(n / 2)
: (sorted.get(n / 2 - 1) + sorted.get(n / 2)) / 2.0;

List<Double> absDevs = new ArrayList<>();
for (long err : serviceErrs) {
absDevs.add(Math.abs(err - median));
}
Collections.sort(absDevs);
double mad = absDevs.get(absDevs.size() / 2);

double threshold;
if (mad == 0) {
threshold = median > 0 ? median * 3 : 1000;
} else {
threshold = median + madThreshold * mad;
}

异常日判断:

1
2
3
4
5
6
7
8
9
10
11
if (serviceErr <= threshold) {
continue;
}

double deviation = mad > 0
? (serviceErr - median) / mad
: Double.POSITIVE_INFINITY;

reasons.add(String.format(
"SERVICE 错误数 %d 偏离中位数 %.0f (%.1f MAD)",
serviceErr, median, deviation));

除错误数之外,还会给异常日补充解释信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (drop > 0.01) {
reasons.add(String.format(
"%s 可用性 %.3f%% 低于中位数 %.3f%% (下降 %.3f%%)",
layer.toUpperCase(), currentAvail, medianAvail, drop));
}

if (alarm > 3) {
reasons.add(String.format("触发告警 %d 次", alarm));
}

if (budgetSurplus < 0) {
reasons.add(String.format(
"服务层错误预算已耗尽 (%.3fmin)", budgetSurplus));
}

这里的异常判定条件只使用 service_err;可用性、告警和错误预算用于丰富异常原因。

完整流水线中的调用:

1
2
List<AnomalyDay> anomalies =
anomalyDetector.detectAnomalies(sloData, 3.0, topDays);

6. 基线数据预处理与缓存

V2 流水线首先获取基线日错误面板,并按接口累计全天错误数:

1
2
3
4
5
Map<String, Object> baselineData =
errorCrawler.fetchErrorData(appPath, baselineStart, baselineEnd);

Map<String, Object> baselineAgg =
compareOrchestrator.aggregateErrorPanel(baselineData);

接口错误累计逻辑位于 PipelineCompareOrchestrator.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for (String ts : timestamps) {
Map<String, Object> point =
(Map<String, Object>) timeData.get(ts);
Map<String, Object> values =
(Map<String, Object>) point.getOrDefault("values", Map.of());

String minuteKey = ts.substring(0, 16);

for (var entry : values.entrySet()) {
String apiName = entry.getKey().startsWith("error:")
? entry.getKey().substring(6)
: entry.getKey();

double count = toDouble(entry.getValue());
apiTotals.merge(apiName, count, Double::sum);
totalByMinute.merge(minuteKey, count, Double::sum);
}
}

之后选择基线日错误量最高的接口。接口数不超过 5 时全部处理,否则最多处理前 8 个:

1
2
3
4
5
6
7
8
9
10
11
12
13
List<String> baselineApis = apiTotals.entrySet().stream()
.filter(e -> e.getValue() > 0)
.sorted(Comparator.comparingDouble(e -> -e.getValue()))
.map(Map.Entry::getKey)
.toList();

List<String> apisToFetch;
if (baselineApis.size() <= 5) {
apisToFetch = baselineApis;
} else {
apisToFetch = baselineApis.subList(
0, Math.min(8, baselineApis.size()));
}
持久化缓存

每个基线接口的日志和聚合结果保存在:

1
2
3
output/batch_diffv2/{app}/baseline/{api}/
├── logs.json
└── aggregate.json

再次执行时只读取 aggregate.json,避免重复请求日志平台:

1
2
3
4
5
6
7
8
9
Path aggFile = apiDir.resolve("aggregate.json");

if (Files.exists(aggFile)) {
Map<String, Object> cachedAgg = outputWriter.readJson(aggFile);
if (cachedAgg != null) {
baselineAggCache.put(api, cachedAgg);
continue;
}
}

7. 接口增量识别

系统对基线日和异常日所有接口取并集,计算:

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for (String api : allApis) {
double bCount = bTotals.getOrDefault(api, 0.0);
double tCount = tTotals.getOrDefault(api, 0.0);
double diff = tCount - bCount;

double ratio = bCount > 0
? tCount / bCount
: (tCount > 0 ? Double.MAX_VALUE : 1.0);

apiDiff.add(new ApiDiff(
api,
round1(bCount),
round1(tCount),
round1(diff),
round2(ratio)));
}

apiDiff.sort(Comparator.comparingDouble(ApiDiff::diff).reversed());

默认筛选条件:

1
2
3
4
increased = result.apiDiff().stream()
.filter(a -> a.diff() >= minDiff
&& a.ratio() >= minRatio)
.toList();

默认要求:

1
2
3
错误增量 >= 50
并且
异常日错误数 / 基线日错误数 >= 1.5

如果指定 --apis,则改为接口名称模糊匹配:

1
2
3
4
increased = result.apiDiff().stream()
.filter(a -> filterApis.stream()
.anyMatch(f -> a.api().contains(f)))
.toList();

同时还会根据接口名称最后一个冒号后的内容聚合错误码变化,例如 :-504、:-500。

8. 异常时间窗口检测

异常窗口检测位于 ApiTimeWindowDetector.java。

固定参数:

1
2
3
private static final int MIN_WINDOW_POINTS = 10;   // 5分钟
private static final int MAX_WINDOW_POINTS = 240; // 2小时
private static final int MERGE_GAP_POINTS = 10; // 间隔小于5分钟合并

由于错误面板是 30 秒粒度,因此:

  • 10 个点对应 5 分钟;
  • 240 个点对应 2 小时;
  • 相邻窗口间隔小于 10 个点时合并。
8.1 同时刻基线对齐

系统用“时分秒”对齐两个日期,而不是直接比较时间戳:

1
2
3
4
5
6
Map<String, Double> baselineByHMS = new HashMap<>();

for (var entry : baselineByTime.entrySet()) {
String hms = entry.getKey().substring(11);
baselineByHMS.merge(hms, entry.getValue(), Double::sum);
}

这样异常日 15:30:00 会和基线日 15:30:00 比较,可一定程度降低日内流量周期的影响。

8.2 异常点判断
1
2
3
4
5
6
7
8
9
10
11
double baselineVal =
baselineByHMS.getOrDefault(hms, baselineAvg);

double threshold = Math.max(
baselineVal * 3,
baselineAvg * 2);

if (targetVal > threshold
&& targetVal - baselineVal > 2) {
anomalyIndices.add(i);
}

异常点需要同时满足:

1
2
3
异常值 > max(同时刻基线值 × 3, 基线全天均值 × 2)
并且
异常值 - 同时刻基线值 > 2
8.3 窗口合并和裁剪
1
2
3
4
5
List<int[]> rawWindows =
mergeConsecutiveIndices(anomalyIndices);

List<int[]> mergedWindows =
mergeCloseWindows(rawWindows);

短窗口向两侧扩展到 5 分钟;超过 2 小时则使用滑动窗口找到错误增量最大的 2 小时区间。

最终计算:

1
2
3
4
5
6
windows.add(new TimeWindow(
startTs,
endTs,
totalErrors,
peak,
Math.round(ratio * 10.0) / 10.0));

按窗口内总错误数降序,最多保留 3 个:

1
2
3
4
5
windows.sort(
Comparator.comparingDouble(TimeWindow::totalErrors).reversed());

return windows.subList(
0, Math.min(topN, windows.size()));

9. 分窗日志采样

9.1 全天均匀采样

基线日和异常日全天日志均拆成 48 个窗口,每个窗口最多取 50 条:

1
2
3
4
5
6
7
List<LogEntry> logs = logCrawler.searchLogsSampled(
appPath,
query,
baselineStart,
baselineEnd,
48,
50);

单接口理论采样上限为:

1
48 × 50 = 2400 条

均匀分窗避免日志查询接口只返回最新数据,导致样本集中在一天中的某个时间点。

9.2 查询失败二分重试

BillionsLogCrawlerService.java 在窗口查询失败时,将窗口拆成两半重试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {
List<LogEntry> logs =
searchLogs(appId, query, wStart, wEnd, perWindow);
allLogs.addAll(logs);
} catch (Exception e) {
long wMid = (wStart + wEnd) / 2;
int halfLimit = perWindow / 2;

try {
allLogs.addAll(searchLogs(
appId, query, wStart, wMid, halfLimit));
} catch (Exception ignored) {
}

try {
allLogs.addAll(searchLogs(
appId, query, wMid, wEnd, halfLimit));
} catch (Exception ignored) {
}
}

这能够缩小单次查询时间跨度,降低日志平台查询超时对整个任务的影响。

10. 异常窗口加权采样

全天均匀采样用于了解整体分布;异常窗口会根据错误密度重新分配采样预算。

调用参数:

1
2
3
4
5
6
7
8
windowLogs = logCrawler.searchLogsSampledWeighted(
appPath,
query,
w.start(),
w.end(),
10,
500,
windowSeries);

含义:

  • 将异常窗口拆成 10 个子窗口;
  • 总采样预算约 500 条;
  • 错误越集中的子窗口获得越多日志配额。

先统计每个子窗口的错误量:

1
2
3
4
5
6
7
8
9
10
11
12
13
double[] windowErrors = new double[windows];
double totalErrors = 0;

for (double[] point : errorSeries) {
long epoch = (long) point[0];
if (epoch < startTs || epoch >= endTs) continue;

int idx = (int) ((epoch - startTs) / windowSize);
if (idx >= windows) idx = windows - 1;

windowErrors[idx] += point[1];
totalErrors += point[1];
}

然后按错误占比分配采样配额:

1
2
3
4
5
6
7
8
9
10
11
int minPerWindow = 10;
int reservedForMin = activeWindows * minPerWindow;
int distributable = Math.max(0, totalLimit - reservedForMin);

for (int i = 0; i < windows; i++) {
if (windowErrors[i] > 0) {
quotas[i] = minPerWindow
+ (int) Math.round(
distributable * windowErrors[i] / totalErrors);
}
}

对应公式:

[
quota_i = 10 +
round\left((500-10m)\times\frac{errors_i}{\sum errors}\right)
]

其中 m 为存在错误的活跃窗口数量。

这样既保证每个活跃时间段至少有样本,又会把主要预算分配给错误峰值区域。

11. 四线程并行采集

V2 创建固定大小为 4 的线程池:

1
2
ExecutorService executor =
Executors.newFixedThreadPool(4);
11.1 并行预爬基线接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CompletableFuture<?>[] baselineFutures =
apisNeedCrawl.stream()
.map(api -> CompletableFuture.runAsync(() -> {
List<LogEntry> logs =
logCrawler.searchLogsSampled(
appPath, query,
baselineStart, baselineEnd,
48, 50);

Map<String, Object> agg =
logAggregator.aggregateLogs(logs, 60);

baselineAggCache.put(api, agg);
outputWriter.writeJson(
apiDir.resolve("aggregate.json"), agg);
}, executor))
.toArray(CompletableFuture[]::new);

CompletableFuture.allOf(baselineFutures).join();
11.2 并行处理异常接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CompletableFuture<?>[] futures = increased.stream()
.map(apiInfo -> CompletableFuture.runAsync(() ->
processApi(
appPath,
apiInfo,
dayDir,
targetStart,
targetEnd,
baselineAggCache,
baselineData,
targetData),
executor))
.toArray(CompletableFuture[]::new);

CompletableFuture.allOf(futures).join();

并发粒度是接口级:

  • 最多同时处理 4 个接口;
  • 每个接口内部的 48 个或 10 个采样窗口仍按顺序请求;
  • 使用 ConcurrentHashMap 保存并发任务产生的基线聚合结果;
  • 单接口失败只记录日志,不中断其他接口;
  • 最终在 finally 中关闭线程池。

12. 日志查询条件构造

接口名称格式示例:

1
/pgc.gateway.view.v1.View/SeasonInfos:-504

ApiNameParser.java 按最后一个冒号拆分路径和错误码:

1
2
3
4
5
6
int idx = apiStr.lastIndexOf(':');

String path =
stripLeadingSlash(apiStr.substring(0, idx));
String retCode =
apiStr.substring(idx + 1);

根据不同服务的日志字段构造查询表达式:

1
2
3
4
5
private static final Map<String, String> APP_QUERY_FIELD = Map.of(
"main.bangumi.season-service", "_server_path",
"open.bangumi.view-gateway", "_server_path",
"ogv.player.authunite-service-ogv", "server.path"
);
1
2
3
4
5
6
7
8
9
if ("server.path".equals(field)) {
return String.format(
"server.path IN ('%s') AND ret IN (%s)",
path, retCode);
}

return String.format(
"_server_path = '%s' AND ret = '%s'",
apiPath, retCode);

13. 多维日志聚合

聚合器位于 LogAggregator.java。

日志平台字段结构不完全固定,因此使用 LogEntry(Map<String,Object>) 保存,并通过类型安全的 getter 做转换。

核心字段提取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String traceId = log.getString("log.trace_id");
String mid = log.getString("mid").isEmpty()
? log.getString("auth-mid")
: log.getString("mid");

String hostName = log.getString("host.name");
String targetIp = log.getString("target.ip");
String targetZone = log.getString("target.zone");
String serverPath = log.getString("server.path");
String downstreamPath = log.getString("path");
String peerService = log.getString("peer.service");

int statusCode = log.getInt("status.code");
int ret = log.getInt("ret");
double ts = log.getDouble("ts");

当前输出包括 16 组结果:

输出 Key 诊断作用
1_全局汇总 日志数、Trace 数、用户数、实例数、下游 IP 数、平均/P95/最大耗时
2_错误类型分布 超时、不可用、内部错误、资源耗尽等
3_错误描述分布 原始错误描述 Top 5
4_入口接口影响 入口 API 与关联下游
5_下游服务失败 下游服务、接口、耗时、IP 数、主要错误
6_实例维度_gateway Gateway Pod 错误集中度
7_实例维度_下游IP 下游 IP、可用区和服务分布
8_可用区维度 错误是否集中在特定 Zone
9_业务数据维度 season_id、ep_id、aid 等业务参数
10_请求体大小维度 请求数组大小与错误、耗时关系
11_超时配置维度 timeout、quota、错误类型和耗时
12_响应体大小维度 响应体大小与耗时关系
13_调用方维度 错误是否集中在某个调用方
14_异常类型维度 Java 异常类型和异常消息
15_客户端区域维度 客户端地域分布
16_时间桶趋势 每分钟错误量、主要 Zone、主要下游

例如下游服务聚合:

1
2
3
4
5
6
7
8
9
10
11
String psKey = peerService + "|" + downstreamPath;

DimAccum ps = getAccum(byPeerService, psKey);
ps.add(traceId, mid, hostName, targetIp, ts);

ps.extras.computeIfAbsent(
"error_descs",
k -> new HashMap<String, Integer>());

((Map<String, Integer>) ps.extras.get("error_descs"))
.merge(statusDesc, 1, Integer::sum);

全局汇总还会计算重试放大系数:

1
2
3
4
5
6
7
double retryRatio = allTraces.isEmpty()
? 1.0
: (double) totalLogs / allTraces.size();

globalSummary.put(
"retry_amplification_ratio",
round4(retryRatio));

如果日志条数显著大于唯一 Trace 数,说明一次请求可能触发多次重试或产生多条错误日志。

14. 基线和异常日聚合对比

异常日日志聚合完成后,从缓存读取同接口的基线聚合:

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, Object> baselineLogAgg =
baselineAggCache.get(apiInfo.api());

if (baselineLogAgg != null) {
Map<String, Object> diffResult =
increasedApiOrchestrator.diffAggregates(
baselineLogAgg,
targetLogAgg);

outputWriter.writeJson(
apiDir.resolve("diff_result.json"),
diffResult);
}

主要对比:

  • 基线和异常日采样日志量;
  • 平均响应时间;
  • Gateway 实例数量;
  • 下游 IP 数量;
  • 下游服务占比变化;
  • 可用区占比变化。

下游服务变化计算:

1
2
3
4
5
6
7
8
9
10
11
12
double bPct = bDs.containsKey(key)
? toDouble(bDs.get(key).get("pct")) : 0;

double tPct = tDs.containsKey(key)
? toDouble(tDs.get(key).get("pct")) : 0;

dsDiff.add(Map.of(
"service", key,
"baseline_pct", bPct,
"target_pct", tPct,
"pct_change",
Math.round((tPct - bPct) * 10.0) / 10.0));

15. 输出目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
output/batch_diffv2/{app}/
├── baseline/
│ └── {api}/
│ ├── logs.json
│ └── aggregate.json
├── {target-date}/
│ ├── increased_apis.json
│ └── {api}/
│ ├── target_logs.json
│ ├── target_aggregate.json
│ ├── diff_result.json
│ ├── time_windows.json
│ ├── window_1/
│ │ ├── logs.json
│ │ └── aggregate.json
│ ├── window_2/
│ └── window_3/
└── summary.json

各文件作用:

  • increased_apis.json:错误显著增长的接口、基线值、异常值、增量和倍率。
  • target_logs.json:异常日全天分窗采样的原始日志。
  • target_aggregate.json:异常日多维聚合结果。
  • diff_result.json:基线和异常日的聚合差异。
  • time_windows.json:Top 异常窗口及错误总数、峰值和倍率。
  • window_N/aggregate.json:异常窗口内的局部特征。
  • summary.json:各异常日期及其增量接口摘要。

16. 大模型报告生成方式

仓库中的 .claude/commands/analyze.md 定义了报告生成规范。外部大模型读取:

1
2
3
4
5
baseline/{api}/aggregate.json
{date}/{api}/target_aggregate.json
{date}/{api}/diff_result.json
{date}/{api}/time_windows.json
{date}/{api}/window_*/aggregate.json

然后从以下方向生成根因候选:

  • 错误增长最大的接口和错误码;
  • 占比显著升高的下游服务;
  • 是否存在固定 deadline 超时;
  • 是否集中于单个 Gateway Pod 或下游 IP;
  • 是否集中于特定可用区;
  • 是否与请求大小、响应体大小、调用方或业务参数相关;
  • 异常是持续性劣化还是短时尖刺。

因此更准确的实现描述是:

Java 流水线自动生成结构化诊断证据,外部大模型基于证据生成根因分析报告。

当前不宜描述成“Java 服务内部已经集成大模型 API”。

1.3 提问

1.3.1 自己能想到的

1.3.2 AI想到的问题

P0

1. 请用两分钟介绍系统完整链路,你具体实现了什么?

参考回答:

这个项目解决的是 SLO 异常后需要人工跨质量平台和日志平台排查的问题。我把排查过程拆成了一条确定性诊断流水线:

1
2
3
4
5
6
7
8
9
10
质量平台 SLO 日指标
→ MAD 识别异常日
→ 获取接口错误时序
→ 基线日与异常日接口增量对比
→ 定位异常时间窗口
→ 日志平台分窗采样
→ 16 维日志聚合
→ 基线 Diff
→ 结构化 JSON 证据
→ 外部大模型或人工生成根因报告

代码分为采集、分析、编排和输出四层:

  • SloCrawlerService、SloErrorCrawlerService 接入质量平台。
  • BillionsLogCrawlerService 接入日志平台。
  • AnomalyDetector、ApiTimeWindowDetector、LogAggregator 负责算法和聚合。
  • BatchDiffOrchestratorV2.java 负责批量编排、缓存和并发。
  • OutputWriter 将中间证据和最终结果落盘。

需要主动说明:MAD 自动发现异常日由 pipeline 命令实现;主要使用的 batch-diffv2 接收指定基线日和目标日,两条路径目前没有完全自动串联。

2. 为什么选择 MAD?算法具体怎么实现?

参考回答:

固定错误数阈值无法适配不同流量规模的服务,均值和标准差又容易被尖峰拉高,所以日期层使用中位数和 MAD:

[
median = median(x_i)
]

[
MAD = median(|x_i-median|)
]

[
threshold = median + 3 \times MAD
]

当前实现从每天的 service_err 计算阈值,超过阈值的日期才进入异常列表,代码位于 AnomalyDetector.java。

MAD 为 0 时使用兜底规则:

1
threshold = median > 0 ? median * 3 : 1000;

异常日期还会补充 SLB、Service、API Gateway 可用率下降、告警数量和错误预算耗尽等解释。

它的局限也要说明:

  • 当前没有乘 1.4826 标准化系数,本质是业务启发式规则。
  • 只有七天数据时样本偏少。
  • 连续多日异常可能污染中位数。
  • 周期性服务更适合“历史同星期、同时段”的多日基线。
3. 找到异常日后,如何确定是哪个接口导致的?

参考回答:

先将质量平台返回的 30 秒时序按接口累计成全天错误量,然后比较基线日和异常日:

[
diff = target-baseline
]

[
ratio = target/baseline
]

默认同时要求:

1
2
diff >= 50
ratio >= 1.5

对应实现位于 PipelineCompareOrchestrator.java 和 BatchDiffOrchestratorV2.java。

同时使用绝对量和倍率是为了避免两类问题:

  • 只看倍率会放大 1 → 3 这样的小样本噪声。
  • 只看绝对量会忽略小体量接口的显著恶化。

基线为 0、异常日大于 0 时,代码用 Double.MAX_VALUE 表示新增接口。

4. 异常时间窗口是怎么定位的?

参考回答:

系统不会直接对异常日使用固定阈值,而是把异常日与基线日相同 HH:mm:ss 的点对齐,降低日常流量高峰的影响。

一个点需要满足:

1
2
target > max(3 × baselineAtSameTime, 2 × baselineAverage)
target - baselineAtSameTime > 2

然后:

  1. 合并连续异常点。
  2. 合并间隔小于 5 分钟的窗口。
  3. 不足 5 分钟的窗口向两侧扩展。
  4. 超过 2 小时的窗口通过滑动窗口保留增量最密集的 2 小时。
  5. 按窗口总错误数排序,取 Top 3。

实现位于 ApiTimeWindowDetector.java。

5. 为什么不拉取全量日志?加权采样具体怎么做?

参考回答:

全量日志存在三个问题:日志平台查询限制、故障日数据量过大、直接取前 N 条会产生严重时间偏置。

因此采样分为两层:

  • 全天采样:拆成 48 个半小时窗口,每个窗口最多取 50 条,单接口理论上最多 2400 条。
  • 异常窗口采样:拆成 10 个子窗口,总预算约 500 条。

异常窗口内先给每个有错误的子窗口至少 10 条,再按错误量占比分配剩余预算:

[
quota_i=10+(500-10m)\times\frac{errors_i}{\sum errors}
]

实现位于 BillionsLogCrawlerService.java。

采样不能保证不遗漏根因,所以结论只应表达为“根因候选”。证据不足时可以通过 query 命令扩大样本或缩小查询范围。

6. 线程池是怎么设计的?为什么是四个线程?

参考回答:

V2 使用固定四线程池:

1
ExecutorService executor = Executors.newFixedThreadPool(4);

并发粒度是接口级:

  • 基线阶段并行预取多个接口日志。
  • 异常日阶段并行处理多个增量接口。
  • 每个接口内部的时间窗口仍然顺序查询。
  • 使用 ConcurrentHashMap 保存基线聚合缓存。
  • 使用 CompletableFuture.allOf(...).join() 等待当批任务完成。
  • 单接口捕获异常,不影响其他接口。
  • 最终在 finally 中关闭线程池。

日志查询是 I/O 密集型任务,四线程能利用等待时间,同时避免对内部日志平台产生过高并发。

但当前“四线程”是经验值,没有压测或动态限流数据,因此不能声称具体提升了多少百分比。更成熟的方案应增加可配置线程数、QPS 限制、指数退避和平台侧限流感知。

7. 16 个聚合维度如何帮助定位根因?

参考回答:

聚合层的目标不是单纯统计日志,而是把根因假设转化为可验证的数据分布。实现位于 LogAggregator.java。

主要维度包括:

维度 可以回答的问题
错误类型和描述 是超时、不可用、内部错误还是资源耗尽
Gateway 实例 是否集中在单个 Pod
下游服务和接口 哪个依赖最可疑
下游 IP 单实例故障还是服务整体故障
可用区 是否存在地域或机房集中
Trace 数和日志数 是否存在重试或扇出放大
业务参数 是否由特定内容或热点数据触发
请求/响应大小 是否存在大请求、大响应导致的延迟
超时配置 实际耗时是否触碰 deadline
调用方 是否由某个上游请求模式变化引起
时间桶 故障是持续劣化还是局部尖刺

例如错误集中在一个下游 IP,更像单实例问题;同一服务多个 IP 均匀失败,更像服务级故障;多个下游在极短耗时内同时 DEADLINE_EXCEEDED,可能是调用链剩余 deadline 已经耗尽。

这些都是根因候选,不应把分布相关性直接说成确定因果。

8. 大模型具体做了什么?Java 代码里如何调用?

参考回答:

这是这条简历最容易被追问穿的地方。

当前 Java 代码没有内置大模型客户端,也没有自主 Agent 循环。Java 流水线负责生成:

1
2
3
4
5
increased_apis.json
time_windows.json
target_aggregate.json
diff_result.json
window_N/aggregate.json

然后由 .claude/commands/analyze.md 定义外部分析指令,让大模型读取这些结构化证据并生成报告。

因此准确回答应该是:

我完成的是诊断数据和证据流水线,大模型作为外部解释层读取聚合结果生成根因报告;当前 Java 工程尚未内置模型 API,也不是模型自主选择工具的 Agent。

如果回答成“Java 中调用 Claude API 并形成自主诊断闭环”,与当前代码不符。

9. “保障核心服务连续多日达 99.999%”如何证明?

参考回答:

需要提供至少以下证据:

  • 服务和 SLO 指标的具体定义。
  • 99.999% 的统计周期,是天、周还是滚动窗口。
  • 工具上线前后的可用率和错误预算数据。
  • 对应异常的修复动作。
  • 排除发布、流量变化和其他治理措施影响。
  • 最好有 MTTR、异常发现耗时或 Top K 根因命中率变化。

当前仓库只能证明链路在真实数据上运行过:项目文档记录了 3 个服务、33 个日期目录和 1600 余份 JSON 产物。但代码不能单独证明 99.999%,也不能证明这个结果完全由该工具带来。

10. 能不能讲一个实际案例?

参考回答:

项目产物中有一个内容服务案例:某列表接口的错误量从基线日 1986 增长到 9106,绝对增量 7120,约为基线的 4.59 倍。

排查路径是:

  1. 用绝对增量和倍率筛出该接口。
  2. 通过 time_windows.json 判断是全天劣化还是局部尖刺。
  3. 对比错误类型和错误描述。
  4. 检查下游服务、Gateway Pod、下游 IP 和可用区分布。
  5. 检查业务参数和请求大小是否集中。
  6. 输出最可能原因和下一步验证项。

另一个鉴权服务窗口只有 8 条样本,但涉及 8 个 Trace、7 个 Gateway 实例和7个下游 IP,全部指向同一个下游接口。这个证据削弱了单 Pod 或单 IP 故障假设,更接近下游服务级问题,但因为样本很少,仍需要结合发布记录、下游监控和完整 Trace 验证。

P1:工程深度问题

1. 正常基线日如何选择?单日基线可靠吗?

参考回答:

当前由使用者指定一个正常日,优点是简单、口径明确;缺点是容易受到星期、活动、流量结构和发布差异影响。

更成熟的方案是:

  • 使用过去多个同星期、同时段的数据。
  • 排除历史故障日、发布日和活动日。
  • 对多个基线点取中位数或分位数带。
  • 将基线版本和统计范围写入结果元数据。

当前单日基线是工程验证阶段的取舍,不是最终最优方案。

2. 缓存如何保证一致性?

参考回答:

当前缓存以 aggregate.json 是否存在且可读作为命中条件,但缓存目录只有:

1
batch_diffv2/{app}/baseline/{api}/aggregate.json

缓存键没有包含基线日期、查询条件、采样参数和聚合版本。更换基线日期后可能错误复用旧数据。

改进方案是增加 manifest,并让缓存键包含:

1
2
服务 + 基线日期 + 接口 + 返回码
+ 采样参数 + 查询模板版本 + 聚合版本

也可以给缓存增加 TTL、schema version 和数据校验摘要。

3. 系统如何处理部分失败和幂等?

参考回答:

当前处理策略是:

  • 错误面板失败:跳过整个目标日。
  • 日志窗口失败:拆成两个子窗口重试。
  • 子窗口仍失败:跳过该窗口。
  • 单接口失败:记录日志,不影响其他接口。
  • 已存在 increased_apis.json:整个日期直接跳过。

这里存在一个幂等性问题:increased_apis.json 在日志处理完成前就写入。如果后续任务部分失败,下次运行仍会认为日期已经完成。

更合理的方式是使用任务状态:

1
RUNNING → PARTIAL/FAILED → SUCCESS

只有所有必要产物写完后再原子写入 _SUCCESS,并记录成功窗口、失败窗口和采样覆盖率。

4. WebClient 是不是意味着系统是响应式的?

参考回答:

不是。虽然使用了 Spring WebClient,但代码最终调用了 .block(),编排层又使用固定线程池,因此当前整体是同步阻塞模型。

WebClient 在这里主要用于 HTTP 客户端和配置能力,不应描述成端到端响应式系统。

如果进一步改造,可以让采集接口返回 Mono/Flux,结合并发上限、超时、重试和背压完成非阻塞编排。但内部平台的限流和稳定性仍然比“是否响应式”更重要。

5. 如何控制采样偏差?

参考回答:

当前通过三种方式降低偏差:

  • 全天等分为 48 段,避免样本只集中在最后几分钟。
  • 异常窗口根据错误密度加权,提升高价值时间段分辨率。
  • 输出 unique_trace_count、样本数量和重试放大倍率,防止把日志条数等同于失败请求数。

仍然存在的偏差包括:

  • 日志平台按倒序返回,窗口内部仍可能偏向窗口末尾。
  • 权重由错误面板计数决定,两边口径可能不完全一致。
  • 低频根因可能被高频错误覆盖。
  • 配额四舍五入后总量可能略偏离 500。

后续可以使用随机采样、蓄水池采样、按错误类型分层采样,并输出置信区间和覆盖率。

6. 如何避免误报和漏报?

参考回答:

当前是多层过滤:

1
2
3
4
日期层:MAD
接口层:绝对增量 + 增长倍率
时间层:基线同时刻比较
日志层:多维分布交叉验证

这比单一阈值可靠,但仍可能:

  • 漏掉缓慢持续劣化,因为毛刺算法偏向尖峰。
  • 在节假日、活动日或流量结构变化时误报。
  • 连续多日异常污染历史中位数。
  • 对低流量接口过度敏感。

改进方向包括 EWMA、滑动窗口斜率、分位数带、CUSUM、季节分解和服务级动态阈值。

7. 如何接入更多服务?字段不一致怎么办?

参考回答:

当前 ApiNameParser.java 只显式映射了三个服务使用 server.path 还是 _server_path,其他服务默认使用 _server_path。

这不适合大规模接入。应把以下元数据配置化:

  • 服务对应的日志应用 ID。
  • HTTP 或 GRPC 协议。
  • 接口路径字段。
  • 返回码字段。
  • 环境和可用区。
  • 查询模板。
  • 日志是否可用。
  • 特殊字段映射。

配置可以来自配置中心或 CMDB,任务启动前还应执行日志字段探测和 schema 校验。

8. 项目如何测试和评估?

参考回答:

当前 src/test 没有实际测试代码,这是明显短板。

至少应该补充:

  • MAD 正常值、尖峰和 MAD 为 0 的测试。
  • 新增接口和双阈值筛选测试。
  • 异常点合并、5 分钟扩展和 2 小时裁剪测试。
  • 加权配额总量和零错误回退测试。
  • 日志字段缺失、类型变化和空值测试。
  • 16 维聚合快照测试。
  • HTTP 超时、分页和二分重试测试。
  • 缓存命中、缓存失效和部分失败恢复测试。

评估层面应建立历史故障数据集,统计:

1
2
3
4
5
6
异常日检测召回率
误报率
根因候选 Top 1 / Top 3 命中率
平均诊断耗时
人工复核通过率
采样覆盖率
9. 如果让你现场做 Code Review,能发现什么问题?

参考回答:

最明显的问题是聚合字段名称不一致。

LogAggregator 输出:

1
2
5_下游服务失败
8_可用区维度

但 IncreasedApiOrchestrator.java 仍读取:

1
2
4_下游服务失败
7_可用区维度

这会导致部分 diff_result.json 的下游服务变化和可用区变化为空。

此外还有:

  • 跳过历史日期后可能覆盖旧 summary.json。
  • 基线缓存没有日期和版本维度。
  • increased_apis.json 被过早用作完成标记。
  • 缺少正式测试。
  • README 中“Java 内置 AI”和聚合维度描述已过时。

能主动说明这些问题,通常比声称项目没有缺陷更可信。

10. 如果把它产品化,你会怎么设计?

参考回答:

我会从 CLI 演进为异步诊断任务服务:

1
2
3
4
5
6
7
API/控制台
→ 任务服务
→ 消息队列
→ 限流的数据采集 Worker
→ 分析与聚合 Worker
→ 对象存储/数据库
→ 报告生成服务

重点增加:

  • 任务状态机和断点续跑。
  • 服务级、平台级限流。
  • 指数退避和熔断。
  • 数据与算法版本管理。
  • 缓存 manifest 和失效策略。
  • 多租户权限与审计。
  • 敏感字段脱敏。
  • 报告页面和证据下钻。
  • 离线评测集和准确率看板。

Agent 化应放在数据契约和评测成熟以后,让模型根据证据缺口决定是否追加日志、查询 Trace 或发布记录,并设置最大轮次和成本上限。

补充

1. 这里的 SLO、SLI、SLA 分别是什么?

简要回答:

SLI 是实际观测指标,例如请求成功率;SLO 是内部设定的目标,例如成功率达到 99.999%;SLA 是对外承诺及违约责任。当前代码直接读取质量平台计算好的 SLO 指标,没有自行计算请求成功率的分子和分母。

2. 99.999% 请求可用率意味着什么?

简要回答:

如果 SLI 定义为成功请求数除以总请求数,那么 99.999% 意味着错误率不能超过 0.001%,约每 10 万次请求最多失败 1 次。面试中需要先明确按请求计算还是按时间计算,不能直接把它换算成停机时间。

3. 为什么使用 service_err 检测异常,而不是直接使用可用率?

简要回答:

错误数更适合发现绝对故障规模,但会受流量影响;可用率能归一化流量,但高可用服务的数值变化很小。当前代码以 service_err 作为异常门槛,可用率下降作为辅助原因。更完善的方案应同时分析错误率、请求量和错误预算消耗速度。

4. pipeline 和 batch-diffv2 有什么区别?

简要回答:

pipeline 从最近 N 天的 SLO 数据开始,通过 MAD 自动发现异常日;batch-diffv2 主要处理“一个指定基线日对多个目标日”,重点是缓存复用、接口并行和异常窗口加权采样。当前两条路径没有自动串联。

5. V2 相比 V1 主要解决了什么问题?

简要回答:

V1 在处理多个目标日时可能重复查询同一份基线日志。V2 先并行预取基线日主要接口,将聚合结果持久化,后续异常日直接复用;同时增加已处理日期跳过和窗口加权采样。

6. 为什么基线只预取错误量最高的 8 个接口?

简要回答:

主要是控制日志查询成本,优先覆盖最常见的错误接口。但异常日可能出现基线 Top 8 之外的接口,这些接口没有基线日志聚合,只能输出异常日结果。更合理的方式是发现增量接口后按需补抓对应基线日志。

7. 新增接口基线为 0 时如何处理?

简要回答:

接口层会将倍率设为 Double.MAX_VALUE,因此只要绝对增量达到阈值就会入选。日志层如果没有基线缓存,只输出异常日聚合,不生成完整基线 Diff。

8. 日志查询条件是怎么从接口名称生成的?

简要回答:

接口名格式类似 /path:-504,代码按最后一个冒号拆成接口路径和返回码。然后根据服务配置选择 server.path 或 _server_path 字段构造日志平台查询条件。

9. 如何判断同一个请求产生了多条错误日志?

简要回答:

聚合器统计日志总数和去重后的 trace_id 数,并计算:

[
retryAmplificationRatio = totalLogs / uniqueTraceCount
]

比例明显大于 1 时,说明一个请求可能因为重试、扇出或多层记录产生了多条错误日志。

10. 错误类型如何分类?

简要回答:

当前主要根据 gRPC status.code、业务 ret 和异常文本分类:

  • 4 或 -504:超时。
  • 14 或 -503:不可用。
  • 13 或 -500:内部错误。
  • 8:资源耗尽。
  • 包含 UNKNOWN:未知错误。

这是启发式映射,新业务错误码需要继续配置化。

11. 为什么既保留原始日志又保留聚合结果?

简要回答:

原始日志用于证据复核和重新聚合,聚合结果用于快速比较和提供给大模型。只保留最终报告会无法判断错误来自数据采集、采样、聚合还是模型解释。

12. 如何保证大模型不凭空给出根因?

简要回答:

报告要求引用聚合字段、具体时间窗口、样本量和分布变化,并将输出定位为“根因候选”。当前还缺少自动事实校验,后续可以要求结论携带证据路径,并在生成后执行规则校验。

1. 当前异常检测阈值是怎么管理的?

简要回答:

日期层 MAD 倍数、窗口三倍阈值、最短窗口和最大窗口主要是代码常量;接口增量和倍率可以通过 CLI 调整。后续应配置化并记录阈值版本,保证结果可复现。

2. 30 秒时间序列如果缺点怎么办?

简要回答:

当前代码按已有时间点处理,基线缺少同一时刻时使用基线平均值兜底,没有显式补零或插值。这样实现简单,但可能把采集缺失误认为零流量,需要在数据进入算法前增加完整性检查。

3. 为什么按 HH:mm:ss 对齐基线,而不是直接对齐时间戳?

简要回答:

两个日期的 epoch 时间戳不相同,按时分秒对齐可以比较日内相同时刻,抵消部分流量周期。但它不能处理工作日和周末差异,也需要关注时区和夏令时问题。

4. 系统时区会不会影响结果?

简要回答:

会。代码使用 ZoneId.systemDefault() 解析时间,如果部署机器时区不同,窗口时间可能偏移。生产版本应显式固定业务时区,例如 Asia/Shanghai,并在产物中记录时区。

5. 日志分页会不会产生重复数据?

简要回答:

当前按页拉取,没有根据日志 ID 或 Trace 做采集阶段去重。如果日志平台分页期间数据变化,可能产生重复或漏读。可以使用稳定排序字段、游标分页,并按日志唯一标识去重。

6. P95 是如何计算的?小样本可靠吗?

简要回答:

聚合器会对耗时列表排序后取 P95 位置。样本量很小时,P95 接近最大值,统计意义有限,因此报告必须同时展示样本数,不能只凭 8 条日志的 P95 下结论。

7. 聚合器如何处理字段缺失?

简要回答:

LogEntry 的 getter 对缺失字段返回空字符串或 0,避免单条日志导致任务失败。但空值有时仍会进入聚合,例如空下游 IP 或空可用区,因此应增加 unknown 分类、字段完整率和按服务定制的必填字段校验。

8. 多维聚合会不会有高基数和内存问题?

简要回答:

当前一次先把采样日志全部加载到内存,再为多个维度维护 Map、Set 和耗时列表。由于单接口采样量有限,当前规模可控;如果处理全量日志,应改为流式聚合、Top K、近似去重和分位数摘要。

9. 为什么使用 Map<String,Object>,不用强类型对象?

简要回答:

内部平台和日志字段变化较多,Map 能快速兼容不同服务。代价是类型转换错误只能在运行时发现。更成熟的方案是外层响应强类型化,动态日志字段保留 Map,并通过 schema adapter 做服务级转换。

10. 线程池中的任务是否会把异常吞掉?

简要回答:

当前每个接口任务内部会捕获异常并打印,因此 CompletableFuture.allOf() 通常不会感知单任务失败。优点是局部失败不终止批次,缺点是最终没有统一失败清单。应返回结构化任务结果,汇总成功、失败和重试次数。

11. 有没有 HTTP 超时、连接池和熔断配置?

简要回答:

当前 WebClientConfig 主要配置请求头和 16 MB 内存上限,没有看到明确的连接超时、响应超时、连接池或熔断策略。生产化时应配置 Reactor Netty 超时、连接池、Retry Backoff 和 Circuit Breaker。

12. 如何防止四线程把日志平台打挂?

简要回答:

固定小线程池只限制接口级并发,但没有严格限制每秒请求数。应增加全局信号量或令牌桶,根据 429、超时率和平台配额动态降速,并对多个任务共享统一的限流器。

13. 加权采样预算一定不会超过 500 吗?

简要回答:

不一定。配额按比例四舍五入,每个活跃窗口还有最小配额,因此最终总和可能略高或略低于 500。可以使用“最大余数法”分配整数配额,保证总配额严格等于预算。

14. 如果一个窗口没有错误时序,但日志平台有错误怎么办?

简要回答:

当前错误时序为空时会退回均匀采样。如果两个平台统计口径不一致,可能出现错误面板没有计数但日志存在,需要记录两个数据源的口径差异并设置数据质量告警。

15. --apis 参数与默认阈值是什么关系?

简要回答:

指定 --apis 后,代码只按接口名称模糊匹配,不再检查 minDiff 和 minRatio。这适合定向排查,但可能把没有增长甚至下降的接口也纳入分析,使用时应明确这一语义。

16. 如何检测慢性劣化而不是尖峰?

简要回答:

当前毛刺检测更擅长识别明显尖峰。慢性劣化可以通过全天基线 Diff 补充发现,进一步可增加 EWMA、窗口斜率、CUSUM、错误预算燃烧率和 P95 延迟趋势。

17. 错误预算应该如何接入诊断优先级?

简要回答:

可以根据错误预算燃烧率决定任务优先级。短窗口和长窗口都快速燃烧时应立即诊断;只有短时尖峰但长期预算健康时可以降低优先级。当前代码只把预算是否耗尽写入异常原因,没有参与调度。

18. 如果目标日流量是基线日的两倍,错误数增加是否一定异常?

简要回答:

不一定。当前接口比较主要使用错误数,没有按请求量归一化,因此流量增长可能造成误判。更严谨的方式是同时比较请求量、错误率和错误数:错误数用于评估影响规模,错误率用于判断质量变化。

19. 如何判断根因是本服务还是下游服务?

简要回答:

需要组合入口接口、Gateway 实例、下游服务、下游 IP、耗时和错误类型。如果错误集中在单 Gateway Pod,更偏向本服务实例;多个本服务实例都指向同一批下游失败,更偏向下游。但最终仍需 Trace、发布记录和下游监控验证。

20. 如何处理多个根因同时发生?

简要回答:

不能只输出一个最高占比维度。可以先按错误类型、下游和窗口进行聚类,再分别生成根因候选及证据占比,例如“70% 下游超时、20% 单实例错误、10% 业务数据异常”。

21. 如何评价根因报告的准确率?

简要回答:

需要建立历史故障评测集,将人工复盘结论作为标签,评估 Top 1、Top 3 根因命中率、证据引用正确率和错误归因率。仅评价报告语言是否通顺没有意义。

22. 如果大模型不可用,系统还能工作吗?

简要回答:

可以。异常日、接口、窗口、日志聚合和基线 Diff 都是确定性 Java 逻辑,大模型只是解释层。模型不可用时仍能输出 JSON 证据,由工程师人工分析。

23. 为什么先做 CLI,而不是直接做在线平台?

简要回答:

第一阶段重点是验证数据链路、算法口径和诊断维度,CLI 开发和部署成本更低,也便于保存中间产物。规模扩大后再演进为带任务队列、状态机、权限和可视化的异步服务。

简要回答:

Cookie 通过本地配置或环境变量注入,不应提交仓库。日志产物包含内部服务名、IP、Trace、用户和业务 ID,应限制目录权限、设置保留周期并脱敏;如果发送给外部模型,还需要字段白名单和合规审批。

25. 如果只允许你优先改三个问题,会改什么?

简要回答:

第一,修复聚合字段名称不一致导致 Diff 为空的问题;第二,补充任务状态、缓存版本和失败覆盖率,保证结果可信;第三,建立单元测试和历史故障评测集。前两项保证工程正确性,第三项才能量化诊断效果。

2. DRM不可投屏

\item \textbf{DRM 投屏提示改造}:针对遗漏单集 DRM 导致投屏失败的问题,扩展剧集分节统计能力,实现兼容新老剧集模型的 DRM_STATUS分桶统计,通过批量统计接口聚合全分节 DRM 桶计数,结合 Redis + Caffeine 分层缓存复用统计结果,实现单集 DRM 场景的收银台不可投屏提示

2.1 工作概述

需求的根因不是投屏拦截失效,而是“告知链路”和“执行链路”读取 DRM 的粒度不同。DRM 可以配置在 Season 和 Episode 两个层级:Season 级属性进入 Season 的 configAttrs,EP 级属性进入 Episode 的动态规则属性。Player-Gateway 判断的是 Season DRM 或 EP DRM,而 Pay-Service 的 buildBuyDesc 历史上只执行 checkDrm(season),所以遗漏了单集配置。

我先梳理了数据生产、统计和消费链路,再比较两种方案。方案一是 Pay 拉回全量 EP Proto 并遍历 configAttrs,优点是直观,但数据量大、重复组装多,而且会把剧集模型兼容逻辑带到 Pay。方案二是复用 Season-Service 的 countEpisodesBySection:它按 (sectionId, dimension) 缓存分桶结果,内部通过 SPI Counter 计算不同维度,并已处理新老剧集模型。最终选择方案二,让领域数据仍由 Season-Service 负责。

在 Pay 中,buildBuyDesc 先判断 Season DRM;由于 Java || 短路,Season 已开启 DRM 时不会再访问 EP 统计。未命中才通过 profile 拿所有 Section ID,然后批量请求 DRM_STATUS。bucket=1 是开启 DRM,bucket=0 是未开启;任一 bucket=1 的计数大于 0 就给所有 SKU 追加“本片由于版权方限制暂不支持投屏”。

可用性方面,两次下游 RPC 都限制为 200ms,并在 RPC 封装和业务方法两层兜底;异常时选择 fail-open,即不阻塞收银台,但可能暂时漏提示。Pay 对 seasonId -> sectionIds 和 sectionIds -> DRM buckets 分别做 3 秒 Caffeine 缓存,只缓存非空结果;Season-Service 还按统计维度使用 Redis 缓存。测试覆盖有 DRM、无 DRM、空 Section、空统计和 RPC 异常,并用 UAT 正反例验证 buyDesc。

2.2 具体工作内容

2.2.1 需求背景

DRM 是版权方对内容设置的一种播放限制。这个限制既可以配置在整部剧上,也可以只配置在某一集上。原来的播放系统会同时检查这两个地方,所以只要整部剧或者其中某一集开启了 DRM,用户投屏时就会被限制。

但收银台原来只检查整部剧有没有开启 DRM,没有检查单集。这样就会出现一种情况:整部剧没有配置 DRM,但其中某一集配置了 DRM。用户购买时,收银台不会提示“暂不支持投屏”;等用户购买后真正投屏时,却被播放系统拦截,容易引起投诉和退款。

为了解决这个问题,我们让收银台除了检查整部剧,还会检查这部剧下面的所有分节中是否存在开启 DRM 的单集。具体做法是先拿到整部剧的所有分节,再批量查询每个分节中有多少集开启了 DRM。只要发现任意一集开启 DRM,就在购买说明中增加“本片由于版权方限制暂不支持投屏”的提示。

2.2.2 方案选型(两种方案)

方案 A:拉取所有 EP 明细后遍历

1
2
3
4
5
Pay
→ listBySeasonId / listBySection
→ 拉回所有 EP Proto
→ 遍历 configAttrs["drm_supported"]
→ 任一为 true 则提示

优点:

  • 逻辑直观,与 Player 读取 EP configAttrs 的方式接近。
  • 理论上可以进一步定位具体 EP。

缺点:

  • 为一个布尔结论传输全部 EP 明细,响应体和组装成本偏大。
  • Pay 需要理解 EP 数据和新老模型差异,领域边界变差。
  • 全量遍历的计算和序列化成本会重复发生。
  • 统计口径容易与 Season-Service 的可见性过滤不一致。

方案 B:复用 Season-Service 分桶统计,最终采用

1
2
3
4
Pay
→ profile(seasonId) 获取全部 sectionId
→ countEpisodesBySection(sectionIds, DRM_STATUS)
→ 判断任一 Section 的 bucket 1 是否大于 0

优点:

  • 只传 Section ID 和桶计数,网络数据轻量。
  • 新老剧集模型分流封装在 Season-Service 内。
  • 复用 (sectionId, dimension) Redis 缓存,避免每次重新扫描 EP。
  • 新增统计维度可通过 Counter SPI 扩展,Pay 不接触底层数据模型。
  • 可见性口径集中在领域服务,减少多服务各写一套判断。

代价:

  • 首次查询需要 profile 和统计两个 RPC。
  • 结果受 Redis TTL 和缓存失效及时性影响。
  • 只有聚合结果,无法直接知道具体哪个 EP 命中。
  • 当前 Pay 没有对超过 50 个 Section 的请求分批,存在边界风险。

为什么最终选统计方案

需求只需要回答“是否存在至少一个 DRM EP”,而不需要 EP 列表。用聚合接口返回聚合结论更符合最小数据原则,也把新老模型兼容、可见性过滤、缓存和统计规则留在 Season 领域内。Pay 只消费稳定的统计契约。

2.2.3 具体代码实现

1. 需求背景

DRM 可以配置在两个层级:

  • Season 级:整部剧都开启 DRM。
  • Episode 级:只有部分单集开启 DRM。

播放器投屏时同时检查 Season DRM 和 Episode DRM:

1
2
3
4
5
public boolean checkDrm(EpisodeInfoProto episode, ProfileInfoProto season) {
boolean checkSeasonDrm = checkSeasonDrm(season);
boolean checkEpDrm = checkEpDrm(episode);
return checkSeasonDrm || checkEpDrm;
}

代码位置:PlayurlDrmService.java

但收银台原来只检查 Season DRM。于是存在这种情况:

  1. Season 没有开启 DRM。
  2. 某个 Episode 单独开启 DRM。
  3. 用户购买时看不到不可投屏提示。
  4. 购买完成后,播放器按 Episode DRM 禁止投屏。

因此,本次改造的本质不是修改投屏拦截,而是让收银台的“购买前告知口径”与播放器的“实际执行口径”保持一致。

2. 实现功能

本次需求主要实现了以下能力:

功能 实现内容
通用分节统计 支持按 Section + Dimension + Bucket 统计 Episode 数量
Counter SPI 不同统计维度通过独立 Counter 实现
DRM 分桶 bucket=1 表示开启 DRM,bucket=0 表示未开启
模型兼容 分别从新、老剧集模型读取 drm_supported
可见性过滤 新模型只统计 BILIBILI 有版权且已上架的 Episode
Redis 缓存 按 (sectionId, dimensionCode) 缓存统计结果
批量查询 一次 RPC 查询一个 Season 下多个 Section 的 DRM 桶计数
Caffeine 缓存 缓存 Section ID 列表和 DRM 统计结果
收银台提示 Season 或任一 Episode 开启 DRM 时追加不可投屏提示
异常降级 RPC 超时或异常时不阻塞收银台,按不展示提示降级

3. 整体调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
PayService.seasonPayConfig
|
v
PayService.buildBuyDesc
|
+-- checkDrm(season) == true
| |
| +-- 直接追加不可投屏提示
|
+-- Season 未开启 DRM
|
v
SeasonGrpc.profile(seasonId)
|
v
获取全部 Section ID
|
v
EpisodeGrpc.countEpisodesBySection
dimension = DRM_STATUS
|
v
EpisodeStatService
|
+-------+-------+
| |
Redis 命中 Redis 未命中
|
v
新老剧集模型分流
|
v
DrmStatusCounter
|
v
sectionId -> {0: 无DRM数, 1: 有DRM数}
|
v
任一 Section 的 bucket 1 > 0
|
v
buyDesc 追加“本片由于版权方限制暂不支持投屏”

4. 分节统计框架

4.1 Counter SPI

每种统计维度只负责把一个 Episode 映射到某个桶:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface EpisodeStatCounter {

// 当前 Counter 对应的统计维度
int dimensionCode();

// 老剧集模型分桶
Integer bucketOf(EpisodeInfoBO bo);

// 新剧集模型分桶
default Integer bucketOfNew(
pleiades.business.service.season.service.bo.episode.EpisodeInfoBO bo) {
throw new UnsupportedOperationException(
getClass().getSimpleName() + " 未实现 bucketOfNew");
}
}

代码位置:EpisodeStatCounter.java

SPI 的作用是把“如何读取数据、如何缓存、如何聚合”和“某个业务维度如何分桶”分离。新增维度时不需要修改主统计流程,只需要新增一个 EpisodeStatCounter 实现。

4.2 Counter 自动注册

Spring 启动时收集所有 Counter,并以维度码注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostConstruct
public void init() {
Map<Integer, EpisodeStatCounter> map = new HashMap<>();
for (EpisodeStatCounter counter : counters) {
EpisodeStatCounter previous =
map.put(counter.dimensionCode(), counter);

if (previous != null) {
throw new IllegalStateException(
"duplicate EpisodeStatCounter: " + counter.dimensionCode());
}
}
this.byDimension = Collections.unmodifiableMap(map);
}

代码位置:EpisodeStatRegistry.java

重复注册相同维度会在启动阶段直接报错,避免 Counter 被静默覆盖。

5. DRM_STATUS 分桶实现

DRM_STATUS 的协议维度值为 2,分桶定义如下:

Bucket 含义
1 Episode 开启 DRM
0 Episode 未开启 DRM、属性缺失或属性值不是 true

核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Component
public class DrmStatusCounter implements EpisodeStatCounter {

private static final String ATTR_DRM_SUPPORTED = "drm_supported";

public static final int BUCKET_DRM_ENABLED = 1;
public static final int BUCKET_DRM_DISABLED = 0;

@Override
public int dimensionCode() {
return StatDimension.DRM_STATUS.getNumber();
}

// 老模型:从 EpisodeInfoBO.configAttrs 读取
@Override
public Integer bucketOf(EpisodeInfoBO bo) {
if (bo == null) {
return BUCKET_DRM_DISABLED;
}
return isDrmSupported(bo.getConfigAttrs())
? BUCKET_DRM_ENABLED
: BUCKET_DRM_DISABLED;
}

// 新模型:从 episodeOperationalRules.ruleAttrs 读取
@Override
public Integer bucketOfNew(NewEpisodeInfoBO bo) {
if (bo == null) {
return BUCKET_DRM_DISABLED;
}

EpisodeOperationalRulesBO rules = bo.getEpisodeOperationalRules();
List<EntityRuleAttributeBO> attrs =
rules == null ? null : rules.getRuleAttrs();

return hasDrmAttr(attrs,
EntityRuleAttributeBO::getAttrName,
EntityRuleAttributeBO::getAttrValue)
? BUCKET_DRM_ENABLED
: BUCKET_DRM_DISABLED;
}

private <T> boolean hasDrmAttr(
List<T> attrs,
Function<T, String> nameGetter,
Function<T, String> valueGetter) {

if (attrs == null || attrs.isEmpty()) {
return false;
}

return attrs.stream()
.filter(Objects::nonNull)
.filter(attr ->
ATTR_DRM_SUPPORTED.equals(nameGetter.apply(attr)))
.findFirst()
.map(attr ->
"true".equalsIgnoreCase(valueGetter.apply(attr)))
.orElse(false);
}
}

完整代码:DrmStatusCounter.java

这里的关键点是:新老模型虽然属性对象不同,但最终都统一按照 drm_supported=true 判断,输出相同的 Bucket 语义。

6. 新老剧集模型兼容

统计服务先查询 Section 的模型路由,再拆分成新模型和老模型两组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Map<Long, Boolean> routeMap =
canaryingService.batchQueryModelRoute(
EntityTypeEnum.SECTION, sectionIds);

List<Integer> oldModelIds = new ArrayList<>();
List<Long> newModelIds = new ArrayList<>();

for (Integer sectionId : sectionIds) {
if (Boolean.TRUE.equals(routeMap.get(sectionId.longValue()))) {
newModelIds.add(sectionId.longValue());
} else {
oldModelIds.add(sectionId);
}
}

result.putAll(backSourceOldModel(oldModelIds, counter, seasonType));
result.putAll(backSourceNewModel(newModelIds, counter, seasonType));

代码位置:EpisodeStatService.java

老模型

老模型复用 sectionEpisodesBO 获取分节 Episode,然后逐个调用 counter.bucketOf:

1
2
3
4
5
6
for (EpisodeInfoBO episode : infos) {
Integer bucket = counter.bucketOf(episode);
if (bucket != null) {
buckets.merge(bucket, 1L, Long::sum);
}
}

代码位置:EpisodeStatService.java

新模型

新模型走轻量 DAO 链路,并先过滤 BILIBILI 渠道的可见 Episode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
List<Long> visibleEpIds = briefs.stream()
.filter(this::isBilibiliPublished)
.map(EpisodePageUseBriefBO::getEpisodeId)
.filter(Objects::nonNull)
.collect(Collectors.toList());

for (Long epId : visibleEpIds) {
EpisodeInfoBO episode = epInfoMap.get(epId);
if (episode == null) {
continue;
}

Integer bucket = counter.bucketOfNew(episode);
if (bucket != null) {
buckets.merge(bucket, 1L, Long::sum);
}
}

代码位置:EpisodeStatService.java

过滤条件为:

1
2
3
BILIBILI 渠道存在
AND hasCopyright != 0
AND publishStatus == 1

这样可以避免把已经下架但尚未物理删除的 Episode 统计为 DRM Episode。

7. Redis 分维度缓存

统计缓存不是按 Season 缓存,而是按以下组合缓存:

1
(sectionId, dimensionCode) -> bucket/count Map

核心代码:

1
2
3
4
5
6
7
8
9
10
adapter.gets(
sectionIds,
sectionId ->
SeasonCacheKeys.Episode.getEpisodesStatBySection(
sectionId, dimensionCode),
missingSectionIds ->
backSource(missingSectionIds, dimensionCode, seasonType),
ttl,
opTimeout
);

代码位置:EpisodeStatService.java

缓存 Key:

1
2
3
4
5
6
public static String getEpisodesStatBySection(
Long sectionId, Integer dimensionCode) {
return String.format(
"season:grpc:ep:section:stat:v1:%d:%d",
sectionId, dimensionCode);
}

代码位置:SeasonCacheKeys.java

当前缓存配置为:

  • TTL:300 秒。
  • Redis 操作超时:100 毫秒。
  • 配置缺失时,代码使用 600 秒兜底 TTL。

配置位置:cache_canary.yml

这种设计使 DRM_STATUS 和其他统计维度使用不同的 Redis Key,不会互相覆盖,也可以独立回源和失效。

8. 批量统计 RPC

countEpisodesBySection 接收多个 Section 和多个统计维度,返回三层结果:

1
2
3
4
sectionId
-> dimensionCode
-> bucket
-> count

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
List<Integer> dimensionCodes = request.getDimensionsList()
.stream()
.map(StatDimension::getNumber)
.collect(Collectors.toList());

Map<Integer, Map<Integer, Map<Integer, Long>>> stats =
episodeStatService.countBySection(
request.getSectionIdsList(),
dimensionCodes,
request.getSeasonType());

stats.forEach((sectionId, byDimension) -> {
SectionStatProto.Builder sectionBuilder =
SectionStatProto.newBuilder();

byDimension.forEach((dimension, buckets) ->
sectionBuilder.putByDimension(
dimension,
StatBuckets.newBuilder()
.putAllBuckets(buckets)
.build()));

replyBuilder.putSections(sectionId, sectionBuilder.build());
});

代码位置:EpisodeRPCController.java

接口最多接受 50 个 Section。当前 Pay 调用没有进一步分片,因此这也是该实现需要注意的边界。

9. Pay-Service 批量查询 DRM 统计

Pay-Service 构造只包含 DRM_STATUS 的批量请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CountEpisodesBySectionReq request =
CountEpisodesBySectionReq.newBuilder()
.addAllSectionIds(sectionIds)
.addDimensions(StatDimension.DRM_STATUS)
.build();

CountEpisodesBySectionReply reply = blockingStub
.withDeadlineAfter(200, TimeUnit.MILLISECONDS)
.withWaitForReady()
.countEpisodesBySection(request);

Map<Integer, Map<Integer, Long>> result = new HashMap<>();
reply.getSectionsMap().forEach((sectionId, stat) -> {
StatBuckets buckets = stat.getByDimensionMap()
.get(StatDimension.DRM_STATUS_VALUE);

if (buckets != null) {
result.put(sectionId, buckets.getBucketsMap());
}
});

代码位置:SeasonService.java

这里返回的已经是聚合结果,例如:

1
2
section 11 -> {0: 5, 1: 0}
section 22 -> {0: 3, 1: 2}

Pay 不需要拉取并遍历完整 Episode Proto,也不需要感知新老模型的数据差异。

10. Caffeine 本地缓存

Pay-Service 增加了两份 3 秒本地缓存:

1
2
3
4
5
6
7
8
9
10
11
12
private static final Cache<Integer, List<Integer>> sectionIdsCache =
Caffeine.<Integer, List<Integer>>newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();

private static final Cache<List<Integer>,
Map<Integer, Map<Integer, Long>>> drmStatCache =
Caffeine.<List<Integer>, Map<Integer, Map<Integer, Long>>>newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();

代码位置:SeasonService.java

两份缓存分别保存:

缓存 Key Value
sectionIdsCache seasonId Season 下的全部 Section ID
drmStatCache Section ID 列表 每个 Section 的 DRM Bucket

只缓存非空结果,避免 RPC 异常产生的空结果在短时间内持续影响收银台。

11. 收银台提示逻辑

11.1 DRM 判断优先级
1
2
boolean isDrm =
checkDrm(season) || hasDrmEpisodeByStat(seasonId);

由于 Java || 短路:

  1. Season 已开启 DRM,直接返回 true,不再请求 Episode 统计。
  2. Season 未开启 DRM,才查询全分节的 DRM_STATUS。
  3. 任一 Section 的 bucket=1 计数大于 0,即认为存在 DRM Episode。

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
boolean hasDrmEpisodeByStat(int seasonId) {
try {
List<Integer> sectionIds =
seasonService.getAllSectionIdsBySeasonId(seasonId);

if (CollectionUtils.isEmpty(sectionIds)) {
return false;
}

Map<Integer, Map<Integer, Long>> drmBySection =
seasonService.countDrmBySections(sectionIds);

return drmBySection.values().stream()
.anyMatch(buckets ->
buckets.getOrDefault(
DRM_STATUS_BUCKET_ENABLED, 0L) > 0);
} catch (Exception e) {
log.warn(
"[PayService]-[hasDrmEpisodeByStat]-[seasonId={}]",
seasonId, e);
return false;
}
}

代码位置:PayService.java

11.2 写入 buyDesc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private String buildBuyDesc(
PayConfigElement payElement,
PayType payType,
int seasonId) {

CardInfoProto season =
seasonService.getSeasonInfo(
seasonId, PayConstant.Type.PGC_TYPE);

boolean isDrm =
checkDrm(season) || hasDrmEpisodeByStat(seasonId);

String form = "购买后可在%s内观看,虚拟商品购买后不支持退换";

if (isDrm) {
form += "\n本片由于版权方限制暂不支持投屏";
}

return String.format(
form,
TimeLimitUtil.convert(payElement.getTimeLimit()));
}

代码位置:PayService.java

实际判断结果如下:

Season DRM Episode bucket=1 结果
开启 任意 展示不可投屏提示,不查询 EP 统计
未开启 大于 0 展示不可投屏提示
未开启 等于 0 不展示
未开启 RPC 异常或空结果 不展示,收银台继续返回

需要特别说明:代码通过 profile.sectionList 获取的是全部 Section,没有按“正片、预告、花絮”类型过滤。

12. 超时与异常降级

两个新增 RPC 都设置了 200ms Deadline:

  • SeasonGrpc.profile:查询 Season 下全部 Section。
  • EpisodeGrpc.countEpisodesBySection:查询 DRM 桶统计。

代码位置:SeasonService.java

异常处理采用 fail-open:

1
2
3
4
RPC 超时/异常
-> 返回空 Section 或空统计 Map
-> hasDrmEpisodeByStat 返回 false
-> 收银台正常展示,但暂时可能漏掉不可投屏提示

这是可用性优先的选择:DRM 提示属于购买说明增强,不应因为下游统计服务异常导致整个收银台不可用。

13. 测试覆盖

业务判断测试覆盖:

  • 某个 Section 存在 DRM Episode,返回 true。
  • 所有 Section 都没有 DRM,返回 false。
  • Section 为空,返回 false。
  • 统计结果为空,返回 false。
  • RPC 抛异常,返回 false。

代码位置:PayServiceDrmTest.java

RPC 和缓存测试覆盖:

  • 正确提取 DRM_STATUS 的 bucket -> count。
  • 第二次请求命中 Caffeine,不重复发起 RPC。
  • 空 Section 不调用 RPC。
  • 响应缺少 DRM_STATUS 时跳过。
  • Profile RPC 和统计 RPC 异常时返回空结果。
  • 空结果不写入本地缓存。

代码位置:SeasonServiceTest.java

14. 提交时间线

MR Commit 实现内容
!8325 8f36e7cffc7 建立 countEpisodesBySection、Counter SPI、Registry 和分维度缓存框架
!8325 2b2973497b8 新增 DRM_STATUS 和 DrmStatusCounter,支持新老模型
!8325 28aab21628d 新模型增加 BILIBILI 版权、上架过滤,统一 DRM 属性提取
!8325 771623c2027d MR !8325 合入 master
!8377 cd83e9c55fc Pay 接入 DRM 统计并修改 buyDesc 判断
!8377 e97e7643a69 增加 Section 和 DRM 统计的 Caffeine 缓存
!8377 7507179937b 补充业务、RPC、缓存及异常场景测试
!8377 274afe7b53e MR !8377 合入 master

因此:

  • 通用分节统计框架由 8f36e7cffc7 引入。
  • DRM_STATUS 由 2b2973497b8 实现。
  • 新模型可见性过滤由 28aab21628d 完善。
  • 收银台不可投屏提示链路由 cd83e9c55fc 接入。

15. 面试时可概括为

这项工作分为统计生产端和业务消费端两部分。生产端通过 Counter SPI 扩展分节统计框架,实现了兼容新老剧集模型的 DRM_STATUS 分桶,并按 Section 和统计维度缓存结果;消费端先检查 Season DRM,未命中时批量查询全分节 DRM 桶计数,任一 bucket=1 即在收银台追加不可投屏提示。同时通过 Redis、短时 Caffeine 缓存、200ms RPC 超时和 fail-open 降级保证收银台可用性。

2.3 提问

2.3.1 自己能想到的

2.3.2 AI想到的问题

P0:核心业务与实现

  1. 问:这个需求解决了什么问题?
    答: DRM 可以配置在 Season 和 Episode 两层。播放器会检查两层,但收银台原来只检查 Season,导致单集开启 DRM 时,用户购买前看不到不可投屏提示。

  2. 问:“导致投屏失败”是投屏功能出现故障吗?
    答: 不是。播放器正确执行了版权限制,真正的问题是购买前告知链路遗漏了 Episode DRM,用户购买后才发现不能投屏。

  3. 问:为什么 Season 没有 DRM,Episode 还能有 DRM?
    答: DRM 是动态规则属性,可以分别配置在 Season 和 Episode 层级,两层配置相互独立。

  4. 问:改造前后的判断条件有什么变化?
    答: 改造前只判断 Season DRM;改造后判断 Season DRM || 任一可见 Episode DRM。

  5. 问:完整调用链是什么?
    答: 收银台构建 buyDesc,先判断 Season DRM;未命中时获取 Season 下全部 Section,再批量查询 DRM_STATUS,任一 Section 的 DRM 桶计数大于 0 就追加提示。

  6. 问:DRM_STATUS 是什么?
    答: 它是分节统计框架中的一个统计维度,用于统计每个 Section 内开启和未开启 DRM 的 Episode 数量。

  7. 问:DRM_STATUS 如何分桶?
    答: bucket=1 表示开启 DRM,bucket=0 表示未开启、属性不存在或属性值不是 true。

  8. 问:DRM 属性存在哪里?
    答: 老模型从 EpisodeInfoBO.configAttrs 读取,新模型从 episodeOperationalRules.ruleAttrs 读取,属性名都是 drm_supported。

  9. 问:如何兼容新老剧集模型?
    答: 先根据 Section 查询模型路由,再分别走新模型和老模型的数据获取链路,最后统一交给 DrmStatusCounter 分桶。

  10. 问:什么是 Counter SPI?
    答: EpisodeStatCounter 是统计维度扩展接口,每个 Counter 负责把一个 Episode 映射到某个桶,主统计流程只负责路由、缓存和聚合。

  11. 问:Counter SPI 对 DRM 统计有什么作用?
    答: DrmStatusCounter 是它的实现,负责从新老模型读取 drm_supported 并映射到 0/1 桶。

  12. 问:为什么不在 Pay-Service 拉取全量 Episode 后遍历?
    答: 全量 Episode Proto 数据较大、组装逻辑重,还会把新老模型兼容逻辑带到 Pay。统计接口只返回桶计数,数据量和职责都更合适。

  13. 问:为什么按 Section 统计,而不是直接按 Season 统计?
    答: 现有 Episode 数据、模型路由和缓存能力都以 Section 为稳定边界,Pay 获取全部 Section 后再完成 Season 级聚合。

  14. 问:批量统计接口批量了什么?
    答: 一次请求传入多个 Section ID 和统计维度,返回每个 Section 下各维度的 bucket -> count,避免逐 Section、逐 Episode 调用。

  15. 问:Pay 如何判断 Season 下存在 DRM Episode?
    答: 遍历所有 Section 的统计结果,只要任一 Section 的 bucket=1 计数大于 0,就认为存在 DRM Episode。

  16. 问:Season 已开启 DRM 时还会查询 Episode 吗?
    答: 不会。代码使用 Java || 短路,Season DRM 为 true 时直接展示提示。

  17. 问:提示最终展示在哪里?
    答: 在收银台返回的 buyDesc 中追加“本片由于版权方限制暂不支持投屏”。

  18. 问:这次改造修改了播放器的投屏拦截吗?
    答: 没有。实际投屏拦截仍由 Player-Gateway 执行,本次只补齐购买前提示。

  19. 问:MR !8325 做了什么?
    答: 建立 countEpisodesBySection 分节统计框架,实现 Counter SPI、注册中心、分维度缓存和 DRM_STATUS。

  20. 问:MR !8377 做了什么?
    答: 在 Pay 中消费 DRM 统计结果,接入 buyDesc,增加 Caffeine 缓存、RPC 超时降级和测试。

P1:技术设计与稳定性

  1. 问:统计接口返回什么数据结构?
    答: 返回 sectionId -> dimensionCode -> bucket -> count,一次请求可以统计多个 Section 和多个维度。

  2. 问:EpisodeStatRegistry 有什么作用?
    答: Spring 启动时收集全部 Counter,建立 dimensionCode -> Counter 映射,主流程不直接依赖具体业务实现。

  3. 问:两个 Counter 注册相同维度会怎样?
    答: 启动阶段直接抛出异常,避免后注册的实现静默覆盖前一个实现。

  4. 问:请求了未注册维度怎么办?
    答: 记录告警并跳过;如果没有任何有效维度,就返回空结果。

  5. 问:老模型如何统计?
    答: 复用 sectionEpisodesBO 获取 Episode 列表,逐个调用 counter.bucketOf,再通过 Map.merge 累加桶计数。

  6. 问:新模型为什么走轻量 DAO 链路?
    答: 完整 Episode 接口包含大量与统计无关的字段组装,轻量 DAO 只获取分桶需要的数据,链路更短。

  7. 问:新模型统计前做了哪些过滤?
    答: 只保留 BILIBILI 渠道存在、具有版权且已经上架的 Episode。

  8. 问:为什么需要过滤下架 Episode?
    答: 下架数据可能仍未物理删除,如果直接统计会把用户不可见的 DRM Episode 算进去,造成误提示。

  9. 问:为什么收集 Episode ID 时使用 distinct?
    答: 避免重复 Episode ID 产生重复查询,也降低因异常关联数据导致的重复统计风险。

  10. 问:Redis 缓存的粒度是什么?
    答: 按 (sectionId, dimensionCode) 缓存,值是该维度的 bucket -> count。

  11. 问:为什么把 dimensionCode 放进缓存 Key?
    答: 不同统计维度可以独立缓存、回源和失效,避免 DRM、付费状态等统计结果互相覆盖。

  12. 问:Redis 缓存多久?
    答: 当前配置 TTL 为 300 秒;配置缺失时,代码使用 600 秒作为兜底。

  13. 问:Caffeine 缓存了什么?
    答: 一份缓存 seasonId -> sectionIds,另一份缓存 sectionIds -> DRM桶统计结果。

  14. 问:Caffeine 为什么只缓存 3 秒?
    答: 用于吸收收银台短时间内的重复请求,同时控制 DRM 配置变更后的数据陈旧时间。

  15. 问:为什么 Redis 和 Caffeine 同时存在?
    答: Redis 跨实例复用领域统计结果;Caffeine 在 Pay 实例内减少短时间重复 RPC,两者解决不同层次的问题。

  16. 问:为什么不缓存空结果?
    答: 空结果可能来自 RPC 超时或临时异常,缓存后会延长漏提示时间;不缓存可以让后续请求重新尝试。

  17. 问:两个新增 RPC 的超时时间是多少?
    答: 获取 Section 和查询 DRM 统计都设置为 200ms Deadline。

  18. 问:RPC 异常时怎么处理?
    答: RPC 封装返回空集合,业务方法再兜底捕获异常,最终按无 Episode DRM 处理。

  19. 问:为什么选择 fail-open?
    答: DRM 提示是购买说明增强,不能因为统计服务异常导致整个收银台不可用。

  20. 问:fail-open 有什么代价?
    答: 下游异常期间可能暂时漏提示,因此需要监控超时率、空结果率和提示命中情况。

P2:边界与风险追问

  1. 问:代码查询的是正片 Section 还是全部 Section?
    答: 实际从 profile.sectionList 获取全部 Section,没有按正片、预告或花絮类型过滤。

  2. 问:查询全部 Section 有什么风险?
    答: 花絮或预告开启 DRM 也可能触发整部剧提示。如果产品口径只要求正片,需要增加 Section 类型过滤。

  3. 问:批量接口有数量限制吗?
    答: 有,单次最多接收 50 个 Section。

  4. 问:Season 超过 50 个 Section 怎么办?
    答: 当前 Pay 路径没有分片,会触发参数校验失败并降级为不提示;完善方案是每 50 个一组查询后合并。

  5. 问:Episode DRM 修改后,缓存如何更新?
    答: 当前主要依赖 Redis TTL 和 Pay 的 3 秒本地 TTL,因此存在数分钟的最终一致性窗口。

  6. 问:如何提高 DRM 变更后的及时性?
    答: 在 Episode DRM 属性变更事件中,根据 Episode 所属 Section 精确删除对应的 DRM_STATUS 缓存。

  7. 问:空结果不缓存会不会产生缓存穿透?
    答: 会有这个风险。可以区分业务空结果和异常空结果,业务空结果使用短 TTL 占位,异常空结果不缓存。

  8. 问:如何避免热门 Section 缓存击穿?
    答: 可以增加 TTL 抖动、请求合并、单飞加载或者互斥回源。

  9. 问:如何避免缓存雪崩?
    答: 设置随机过期时间、限制批量回源并发、增加下游限流,并保留超时和降级逻辑。

  10. 问:模型路由查询失败时怎么办?
    答: 当前没有明确命中新模型的 Section 会进入老模型路径,这是兼容性降级,但需要对路由异常进行监控。

  11. 问:新老模型会重复统计同一个 Episode 吗?
    答: 正常情况下一个 Section 只进入一条模型链路;新模型还对 Episode ID 做了去重。

  12. 问:为什么使用字符串 "true" 判断 DRM?
    答: 动态规则属性以字符串形式存储,因此统一使用大小写不敏感的 "true" 判断,缺失或非法值按关闭处理。

  13. 问:如果存在多个 drm_supported 属性怎么办?
    答: 当前实现取第一个匹配属性。正常数据应保证属性唯一,也可以增加重复属性告警。

  14. 问:Season DRM 解析异常后还会查询 Episode 吗?
    答: 会。checkDrm 异常时返回 false,由于 || 左侧未命中,代码会继续查询 Episode 统计。

  15. 问:为什么不直接调用 Player-Gateway 判断 DRM?
    答: Player 面向单次播放和单 Episode,Pay 需要 Season 级聚合;直接依赖 Player 会产生跨域耦合和多次查询。

  16. 问:为什么统计结果返回计数,而不是布尔值?
    答: 计数模型更通用,既能支持是否存在 DRM 的判断,也能支持对账和其他统计消费者。

  17. 问:使用 List<Integer> 作为 Caffeine Key 有什么问题?
    答: 列表内容和顺序都会影响命中;写入时还必须使用不可变副本,防止调用方修改 Key。

  18. 问:Caffeine 多实例之间是否一致?
    答: 不一致,但它只有 3 秒 TTL,权威数据仍然在统计服务和 Redis,本地缓存只是短时优化。

  19. 问:如何评估这次改造的性能影响?
    答: 关注收银台 P95/P99、RPC 调用量和耗时、Redis/Caffeine 命中率、统计回源次数及超时率。

  20. 问:如何监控业务正确性?
    答: 分别统计 Season DRM 命中、Episode DRM 命中、提示展示、空结果和异常降级,并与播放器 DRM 拦截数据抽样对账。

P3:架构与开放题

  1. 问:如果重新设计,会不会直接提供 Season 级 DRM 聚合接口?
    答: 可以对 Pay 提供更直接的 Season 级接口,但服务内部仍可复用 Section 统计,避免重复实现模型兼容逻辑。

  2. 问:如何扩展新的统计维度?
    答: 在协议中新增维度枚举,实现新的 EpisodeStatCounter,注册后即可复用模型路由、缓存和聚合流程。

  3. 问:Counter SPI 有什么缺点?
    答: 随着维度增加,不同 Counter 可能需要不同字段和回源方式,通用统计服务容易积累条件分支。

  4. 问:如何灰度发布?
    答: 先上线统计生产端并进行新旧结果对比,再灰度 Pay 消费端,同时保留 Episode DRM 查询开关。

  5. 问:如何回滚?
    答: 关闭或回滚 Pay 的 Episode 统计调用即可恢复 Season-only 判断,统计接口可以独立保留。

  6. 问:如何证明分桶统计口径正确?
    答: 对同一批 Section 同时执行全量 Episode 遍历和新统计接口,对比 0/1 桶计数,并覆盖新老模型和上下架状态。

  7. 问:如果要求强一致,应该怎么做?
    答: DRM 属性写入成功后同步失效 Section 统计缓存,或者通过事件驱动更新聚合结果,必要时支持绕过缓存查询。

  8. 问:如果流量扩大十倍,优先优化哪里?
    答: 先提高缓存命中率和批量效率,再引入请求合并、回源限流,并检查热门 Section 的 Redis 热 Key。

  9. 问:这个需求最大的技术亮点是什么?
    答: 不只是增加一个判断,而是把跨模型的 Episode DRM 归一为可扩展的分桶统计,再通过批量接口和分层缓存接入交易链路。

  10. 问:这个方案最大的遗留问题是什么?
    答: 50 个 Section 的接口上限、全 Section 统计口径,以及依赖 TTL 保证缓存最终一致性。面试中应主动说明这些边界及改进方案。

3. 大追番用户列表空数据治理

\item \textbf{大追番列表空数据治理}:定位追番列表分页前全量处理导致的超时问题,短期通过动态配置调高 RPC deadline,覆盖样本内95.6%的尾部慢请求;进一步实现日番/国创按 seasonType 分区异步查询,将分区过滤前移至下游,合并结果后按 mtime 全局排序分页,配套动态开关与深分页回退,减少无关数据处理,大追番用户请求耗时降低约30%

3.1 工作概述

手机空间页的 AppCard/MyFollows 在大追番用户上偶发返回空列表,而 Web 端通常正常。沿移动端调用链排查后确认:card-service 调用 follow-service 的 Follow/MyFollows 原来只有 100ms deadline;follow-service 对大用户会分页拉取全部收藏关系,再对全量追番补充 miniSeason、过滤和排序,最后才分页。当下游耗时越过 deadline 时,card-service 捕获 RPC 异常并返回 MyFollowsReply.getDefaultInstance(),真实的超时被折叠成了业务空列表。

治理分三层:

  1. 短期通过 Paladin 动态配置 deadline,保留 100ms 默认值并支持灰度调整;历史一小时样本中,超过 100ms 的 160 个请求有 153 个落在 100~180ms 内,因此调至 180ms 在该样本内理论覆盖 95.6% 的尾部慢请求,同时没有吃满约 250ms 的上游预算。
  2. 中期实现日番(seasonType=1)和国创(seasonType=4)分区异步查询:follow-service 将 seasonType 过滤前移到 getMiniSeason 和排序之前,card-service 各取两分区前 pn * ps 条,合并后按 mtime 全局排序并分页;配套动态开关、pn * ps > 100 回退和单元测试。
  3. 长期设计 Redis ZSet 排序索引,先取当前页 ID、再补当前页信息,使主链路复杂度由追番总量相关转为主要与页大小相关。

3.2 具体工作内容

3.2.1 需求背景

在手机端个人空间的追番列表中,部分长期使用追番功能的用户累计收藏了数千部番剧。当用户打开列表时,客户端通常只请求当前页的二十条左右数据,但原有服务链路并不会直接查询这一页,而是先拉取用户的全部追番关系,再为每条记录补充番剧信息和观看进度,完成过滤、排序后才进行分页。

对于普通用户,这种处理方式耗时尚可;但随着追番数量增长,全量数据补充和排序的开销会明显增加。手机端上游调用设置了较短的 RPC 等待时间,当下游没有在规定时间内返回时,请求就会超时。由于调用方在捕获超时异常后返回了默认空对象,客户端无法区分“用户确实没有追番”和“服务处理超时”,最终表现为用户明明有大量追番记录,页面却显示空列表。相比之下,Web 端的等待时间更宽松,并且会按照番剧类型分别查询,因此同一用户在 Web 端通常可以正常看到数据。

本次需求需要解决的核心问题,是在不修改客户端协议,并保持原有列表内容、排序和分页规则的前提下,减少大追番用户查询过程中不必要的全量处理,降低接口超时和错误降级为空列表的风险,提升追番列表的稳定性和可用性。

3.2.2 具体代码实现

1. 需求背景

手机客户端查询追番列表时,通常只需要返回当前页约 20 条数据,但 follow-service 原有逻辑会先拉取用户全部追番关系,再对全量数据补充番剧信息、过滤、计算排序时间,最后才分页。

普通用户的追番数量较少,整体耗时通常能控制在 RPC deadline 内;大追番用户可能拥有数千条记录,全量处理容易超过 card-service 原有的 100ms deadline。RPC 超时后,card-service 捕获异常并返回默认空对象,最终表现为用户明明存在追番记录,页面却显示空列表。

2. 整体方案

本次治理分为两个阶段:

  1. 短期止血:将固定的 100ms deadline 改为 Paladin 动态配置,支持灰度调整至 180ms。
  2. 性能优化:将追番查询拆分为日番和国创两个分区,并发查询后重新完成全局排序分页。
1
2
3
4
5
6
7
8
9
10
11
12
AppCard/MyFollows
-> card-service
-> 并发查询 seasonType=1(日番)
-> 并发查询 seasonType=4(国创)
-> follow-service
-> 提前按 seasonType 过滤
-> 仅补充当前分区的 season 信息
-> 分区内排序
-> card-service
-> 合并两个分区
-> 按 mtime 全局排序
-> 截取目标页

3. 功能一:RPC deadline 动态配置

原代码将 deadline 固定为 100ms,无法根据线上耗时分布进行灰度调整。本次在 AppProperties 中新增配置,并由 RPC 调用读取。

1
2
3
4
/**
* card-service 调 follow-service MyFollows 的 deadline,单位 ms
*/
private int followMyFollowsDeadlineMs = 100;

调用 follow-service 时使用动态配置:

1
2
3
4
5
6
7
return FollowGrpc.newBlockingStub(channel)
.withWaitForReady()
.withDeadlineAfter(
appProperties.getFollowMyFollowsDeadlineMs(),
TimeUnit.MILLISECONDS
)
.myFollows(request);

默认值仍为 100ms,因此发布代码本身不会改变线上行为,可以通过 Paladin 逐步灰度到 150ms、180ms,出现异常时直接回调配置。

对应代码:

4. 功能二:分区查询开关与深分页保护

方案二增加两个配置:

1
2
private boolean enableSplitAnimeFollowsBySeasonType = false;
private int splitAnimeFollowsMaxQuerySize = 100;

分区查询只有在以下条件全部满足时才会启用:

1
2
3
4
5
6
7
8
9
10
private boolean shouldSplitAnimeFollows(int followType, int pn, int ps) {
long querySize = (long) pn * ps;
int maxQuerySize =
Math.min(app.getSplitAnimeFollowsMaxQuerySize(), 100);

return app.isEnableSplitAnimeFollowsBySeasonType()
&& followType == FollowTypeEnum.ANIME.code
&& querySize > 0
&& querySize <= maxQuerySize;
}
条件 处理方式
开关关闭 回退原单次查询
followType != 1 回退原单次查询
0 < pn*ps <= 100 执行分区异步查询
pn*ps > 100 回退原单次查询

使用 long 计算 pn*ps 可以避免整数溢出;同时将配置值与硬上限 100 取最小值,避免错误配置突破下游页大小限制。

对应实现:提交 cfa41569ea4 中 card FollowService.java:163。

5. 功能三:下游分区过滤前移

原 follow-service 的执行顺序是:

1
2
3
4
5
6
全量追番关系
-> 补充全量 miniSeason
-> 过滤和修正 mtime
-> 全量排序
-> seasonType 过滤
-> 分页

本次将 seasonType 过滤移动到 getMiniSeason 之前:

1
2
3
4
5
if (seasonType > 0) {
followList = followList.stream()
.filter(r -> seasonType == r.getSeason_type())
.collect(Collectors.toList());
}

过滤后再按当前分区加载 season 信息:

1
2
3
4
5
6
7
8
9
for (FollowSeason follow : followList) {
seasonTypeSeasonIds
.computeIfAbsent(follow.getSeason_type(), key -> Lists.newArrayList())
.add(follow.getSeason_id());
}

seasonTypeSeasonIds.forEach((type, ids) ->
recentUpdate.putAll(seasonDao.getMiniSeason(ids, type))
);

这样,日番请求不会再为国创数据补充信息,国创请求也不会处理日番数据,减少了缓存查询、season-service 回源、过滤和排序的数据量。

过滤已提前完成,因此原来按 seasonType 分支处理的分页逻辑可以统一为:

1
2
3
4
5
int total = followList.size();
followList = followList.stream()
.skip((pn - 1L) * ps)
.limit(ps)
.collect(Collectors.toList());

对应实现:提交 cfa41569ea4 中 follow FollowService.java:228。

6. 功能四:seasonType 参数透传与异步 RPC

为兼容旧调用点,保留原方法签名,默认传入 seasonType=0:

1
2
3
4
5
6
7
8
public MyFollowsReply getFollows(
long mid, int type, int followStatus,
int pn, int ps, int sortType, boolean withSeries
) {
return getFollows(
mid, type, 0, followStatus, pn, ps, sortType, withSeries
);
}

新增异步调用方法,使用 gRPC Future Stub,并通过项目已有的 ContextFutures 转换为 CompletableFuture:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public CompletableFuture<MyFollowsReply> getFollowsAsync(
long mid, int type, int seasonType, int followStatus,
int pn, int ps, int sortType, boolean withSeries
) {
MyFollowsReq request = buildMyFollowsReq(
mid, type, seasonType, followStatus,
pn, ps, sortType, withSeries
);

return ContextFutures.asCompletableFuture(
FollowGrpc.newFutureStub(channel)
.withDeadlineAfter(
appProperties.getFollowMyFollowsDeadlineMs(),
TimeUnit.MILLISECONDS
)
.myFollows(request)
).exceptionally(e -> MyFollowsReply.getDefaultInstance());
}

同步和异步方法复用 buildMyFollowsReq,确保 mid、followType、seasonType、分页和排序参数保持一致。

对应实现:提交 cfa41569ea4 中 FollowRPCService.java:74。

7. 功能五:日番、国创异步查询

card-service 将一次追番查询拆为两个异步请求:

1
2
3
4
5
6
7
8
9
10
11
int querySize = pn * ps;

var jpFuture = followRPCService.getFollowsAsync(
mid, FollowTypeEnum.ANIME.code, SeasonTypeEnum.JP.type,
FollowStatusEnum.ALL.code, 1, querySize, sortType, false
);

var chnFuture = followRPCService.getFollowsAsync(
mid, FollowTypeEnum.ANIME.code, SeasonTypeEnum.CHN.type,
FollowStatusEnum.ALL.code, 1, querySize, sortType, false
);
分区 seasonType pn ps
日番 1 1 pn*ps
国创 4 1 pn*ps

两个请求同时发起,整体等待时间更接近两个分区中较慢的一次,而不是两次请求耗时相加。

8. 功能六:合并、全局排序与分页

两个分区各自分页后不能直接拼接,否则会破坏原列表的全局顺序。因此 card-service 合并候选数据后,重新按 mtime 排序并截取目标页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MyFollowsReply jpFollows = jpFuture.join();
MyFollowsReply chnFollows = chnFuture.join();

List<FollowSeasonProto> follows = Stream.concat(
jpFollows.getFollowsList().stream(),
chnFollows.getFollowsList().stream())
.sorted((a, b) -> Long.compare(b.getMtime(), a.getMtime()))
.skip((pn - 1L) * ps)
.limit(ps)
.collect(Collectors.toList());

return MyFollowsReply.newBuilder()
.addAllFollows(follows)
.setTotal(jpFollows.getTotal() + chnFollows.getTotal())
.build();

例如请求 pn=2, ps=20:

1
2
3
4
日番取前 40 条
国创取前 40 条
合并后全局排序
截取第 21~40 条

这是因为全局前 k 条一定包含在两个分区各自的前 k 条之中,因此每个分区取 pn*ps 个候选即可恢复目标页。

对应实现:提交 cfa41569ea4 中 card FollowService.java:172。

9. 效果与边界

短期 deadline 调整方面,一小时样本中 ts>100ms 有 160 个请求,ts>180ms 有 7 个,因此调整到 180ms 理论覆盖:

1
(160 - 7) / 160 = 95.6%

分区优化方面,根据处理量变化,预估大追番用户请求耗时可降低约 20%~30%。但该比例不是生产实测数据,原因是方案二仍存在以下边界:

  • 两个分区都会读取一次用户全量收藏关系;
  • 用户数据高度集中在单一分区时,拆分收益会下降;
  • 两次异步请求会增加 follow-service QPS;
  • 单分区异常目前会降级为空 reply,可能返回另一个分区的部分结果;
  • mtime 相同时尚未定义明确的第二排序键;
  • pn*ps>100 的深分页仍走旧链路。

3.3 提问

3.3.1 自己能想到的

3.3.2 AI想到的问题

P0:项目真实性与核心原理

1. 这个问题的用户现象是什么?

大追番用户在手机空间页看到空列表,但用户实际存在数千条追番记录,Web 端通常可以正常展示。

2. 为什么超时最终表现为空列表,而不是接口报错?

card-service 捕获下游 RPC 异常后返回 MyFollowsReply.getDefaultInstance(),上层将其中的空 followsList 当成正常空数据返回,导致技术异常被转换成业务空结果。

3. 完整调用链是什么?
1
2
3
4
5
6
AppCard/MyFollows
-> card-service FollowService
-> card-service FollowRPCService
-> follow-service FollowRPCController
-> follow-service FollowService
-> favorite-service / season-service / Redis
4. 根因到底是 deadline 太短,还是下游性能问题?

两者共同导致。100ms deadline 是直接触发条件;分页前全量读取、补充和排序是性能根因。只调 deadline 能止血,但不能减少实际计算量。

5. 为什么大追番用户更容易触发?

接口只返回当前页,但下游会读取用户全部追番关系,对全量数据补充 season 信息、过滤并排序,耗时随追番数量增长。

6. 原链路的复杂度是多少?

关系读取和信息补充约为 O(N),全量排序约为 O(N log N),其中 N 是用户追番总数,而不是页大小。

7. 95.6% 是怎么计算出来的?

一小时样本中,ts>100ms 有 160 个请求,ts>180ms 有 7 个,因此 100~180ms 区间有 153 个:

1
153 / 160 = 95.6%
8. 95.6% 是否代表线上超时率下降了 95.6%?

不是。它只是基于 follow-service 服务端耗时的理论覆盖率。客户端 deadline 还包括网络、排队、序列化和线程调度开销。

9. 为什么选择 180ms,而不是直接调到 250ms?

上游总预算约为 250ms,card-service 在 follow-service 返回后还要补卡片、进度和追番状态。设置 180ms 后理论保留约 70ms;设置 250ms 会吃满整个上游预算。

10. “请求耗时降低约 30%”是怎么测出来的?

目前不是生产实测,而是根据信息补充和排序工作集减少所做的模型估算。严谨回答应说明:最终还需要通过同批大用户的 p95、p99 和阶段耗时验证。

11. 为什么按日番和国创拆分?

当前追番口径由 seasonType=1 的日番和 seasonType=4 的国创组成,两者互斥且能覆盖追番集合,因此可以分区查询后重新合并。

12. 方案二是否已经上线?

deadline 配置化已经合入 master;分区异步查询位于独立分支,已经实现并补充测试,但当前没有证据表明已经合入或生产全量。

13. 你个人具体做了什么?

梳理跨服务调用链和日志分布,完成 deadline 配置化,并实现 seasonType 过滤前移、异步分区查询、全局排序分页、动态开关、深分页回退和对应单元测试。

14. 为什么不能只把 deadline 调大?

调大 deadline 只允许慢请求等待更久,并不会减少 CPU、缓存查询、RPC 回源和排序开销。当用户数据继续增长或下游抖动时,问题仍会复现。

P1:代码实现与正确性

15. deadline 如何实现动态配置?

在 AppProperties 中新增 followMyFollowsDeadlineMs,默认值保持 100,RPC 调用通过 getter 获取并传给 withDeadlineAfter,由 Paladin 动态调整。

16. 为什么默认值仍然是 100ms?

为了让代码发布和配置生效解耦。只发布代码不会改变线上行为,可以再逐步灰度配置。

17. 分区查询在什么条件下启用?
1
2
3
4
动态开关开启
&& followType == ANIME
&& pn*ps > 0
&& pn*ps <= min(配置上限, 100)

否则回退原单次查询。

18. 为什么要将 seasonType 过滤前移?

旧逻辑先为日番和国创全量补充 season 信息并排序,最后才过滤。前移后,每个请求只处理目标分区,减少 getMiniSeason、缓存回源和排序的数据量。

19. 过滤前移会改变业务结果吗?

如果过滤只依赖追番关系中的 season_type,且后续逻辑不会修改该字段,前移不会改变目标集合。但仍需通过 total、seasonId 集合、顺序和分页结果进行对比验证。

20. 异步查询如何实现?

使用 gRPC newFutureStub 发起日番和国创请求,再用项目已有的 ContextFutures.asCompletableFuture 转成 CompletableFuture,避免自行创建线程池并保留上下文传播。

21. 为什么不是用 CompletableFuture.supplyAsync?

RPC Future Stub 本身已经是异步模型,直接使用可以避免占用额外阻塞线程,也能更好地继承 tracing 和请求上下文。

22. 为什么每个分区都查询 pn=1, ps=pn*ps?

要得到全局第 pn 页,必须先取得每个分区的前 pn*ps 个候选,再合并排序。分别查询各分区的第 pn 页会遗漏正确候选。

23. 如何证明取两个分区的 TopK 足够?

某元素如果在自己的分区中排在第 k+1 位以后,仅该分区就已有至少 k 个元素排在它前面,因此它不可能进入全局 TopK。

24. 合并后的分页逻辑是什么?
1
2
3
4
Stream.concat(jp.stream(), chn.stream())
.sorted(mtime倒序)
.skip((pn - 1L) * ps)
.limit(ps);
25. 为什么 total 可以直接相加?

日番和国创分区互斥,并且当前追番口径只包含这两个类型,因此全集 total 等于两个分区 total 之和。

26. 如果以后增加新的番剧 seasonType 怎么办?

当前相加逻辑会漏数据。应将追番分区集合收敛为统一配置或枚举,并让拆分逻辑和 isAnime 口径使用同一数据源。

27. 为什么保留原来的 getFollows 方法签名?

为了兼容动态页等旧调用点。旧方法通过重载默认传入 seasonType=0,减少改造范围。

28. 深分页为什么回退?

follow-service 单次 ps 上限是 100。当 pn*ps>100 时,需要对每个分区继续翻页,会增加请求数和一致性复杂度,因此暂时回退旧路径。

29. 为什么计算 pn*ps 时先转成 long?

避免两个较大的 int 相乘时发生整数溢出,导致边界判断被绕过。

30. 为什么还要把配置上限和 100 取最小值?

防止配置误设为大于下游协议允许值,代码中的硬上限是最后一道保护。

31. mtime 是什么?

它是最终排序时间。对于空间页,会综合追番时间、最近观看时间和剧集更新时间,取能够反映最新行为的时间。

32. 两条数据的 mtime 相同怎么办?

当前 comparator 只比较 mtime,相同值时没有明确第二排序键,页边界可能与旧链路存在顺序差异。可以增加 seasonId 等稳定的第二排序键。

33. 一个分区超时会发生什么?

当前异步方法通过 exceptionally 返回默认空 reply,因此可能只返回另一个分区的部分数据,而不是自动回退旧链路。

34. 更合理的单分区失败策略是什么?

如果业务要求完整结果,任一分区失败时应整体回退旧查询;如果接受部分可用,需要明确标记降级并增加独立指标。

35. 发布顺序是什么?

先发布 follow-service 的过滤前移,再发布 card-service 的分区逻辑且默认关闭开关,最后逐步灰度开启。

36. 为什么必须先发布 follow-service?

如果先拆成两次请求,但下游还没有过滤前移,两次请求都可能执行全量补充和排序,性能可能比原来更差。

P2:性能、稳定性与工程权衡

37. 分区后下游 QPS 会发生什么变化?

一次请求变为两个并发请求,follow-service QPS 理论上接近翻倍,需要观察 CPU、缓存命中率、favorite 和 season 下游压力。

38. 分区查询一定会更快吗?

不一定。只有当 season 信息补充和排序占较大比例,并且两个分区相对均衡时收益明显;如果全量关系读取占主导,收益可能很低。

39. 当前方案有一个容易被忽略的性能问题是什么?

两个分区请求都会调用 getMyFollows,因此可能各自读取一次用户全量收藏关系。也就是说,收藏关系读取可能从一次变成两次。

40. 为什么仍然预估能降低 20%~30%?

假设 getMiniSeason、缓存回源和全量排序是主要耗时,分区后关键路径接近最大分区的处理时间。但这个假设必须通过分阶段埋点验证。

41. 如果用户 90% 的追番都是日番,收益如何?

最大分区仍接近原始数据规模,国创分支虽然很轻,但整体耗时由日番分支决定,可能只能降低少量耗时。

42. 如果两个分区数量接近 50:50 呢?

理论收益更明显,因为两个分区并行处理,每个分区的信息补充和排序规模约减半。

43. 新方案的复杂度是多少?

仍然存在两次全量关系读取;分区处理约为 O(N1 log N1)+O(N2 log N2),card 合并候选集的排序约为 O(K log K),其中 K<=2*pn*ps。

44. 为什么 card-service 合并时还要再排序?

follow-service 只能保证各分区内部有序,两个有序分区直接拼接不能保证全局有序。

45. 是否可以不用全量 sort,而使用归并?

可以。两个分区已经有序,可以使用双指针做 O(K) 归并,只保留目标页需要的前 pn*ps 条,避免再次 O(K log K) 排序。

46. 为什么当前没有使用归并?

候选集最大只有 200 条,直接排序代码简单、可读性高,实际开销很小。若未来扩大分页上限,可以改为归并。

47. 如何验证 30% 的性能收益?

选取同一批普通用户和大用户,对比改造前后的端到端 p50/p95/p99、follow-service RT、getMiniSeason 输入数量、缓存 miss、season RPC 次数和排序耗时。

48. 压测应该如何设计?

按追番规模分桶,例如 100、1000、3000、6000 条;分别测试日番集中、国创集中和 50:50 分布,同时控制缓存冷、热状态。

49. 需要增加哪些监控?

分区命中率、旧链路回退率、单分区失败率、部分结果数、两分区耗时、merge 耗时、deadline exceeded、空列表率和大用户 RT。

50. 动态开关如何回滚?

关闭 enableSplitAnimeFollowsBySeasonType 即可恢复原始单次查询,不需要重新发布代码。

51. deadline 提高有什么副作用?

blocking 调用等待时间变长,会增加线程和连接占用;下游故障时可能积累更多慢请求,并推高上游尾延迟。

52. withWaitForReady 有什么风险?

连接暂时不可用时会等待恢复而不是快速失败,这段等待也会消耗 deadline。在下游故障时可能增加请求驻留时间。

53. 上游取消后,下游任务会停止吗?

需要确认 gRPC cancellation 是否完整向下传播,以及 follow-service 内部已经发起的异步 favorite/season 请求是否能取消,否则可能出现调用方超时但下游继续消耗资源。

54. 如何区分真实空列表与超时降级空?

增加独立指标或结果状态:真实无数据、下游超时、分区部分失败、卡片补充后为空不能统计到同一个“空列表”指标中。

P3:架构演进与深入追问

55. 如何从根本上解决全量处理问题?

建立按用户和排序维度维护的 Redis ZSet 索引,先分页取当前页 seasonId,再只补充当前页信息,使读路径主要与 ps 相关。

56. ZSet 的 key 和 score 怎么设计?
1
2
3
key = follow:list:{mid}:{followType}:{seasonType}:{followStatus}:{sortType}
member = seasonId
score = max(followTime, recentWatchTime, seasonUpdateTime)
57. ZSet 方案最大的难点是什么?

不是分页读取,而是 score 的一致性维护。新增追番、取消追番、状态变化、剧集更新和观看行为都会影响索引。

58. 如何保证索引和原链路一致?

先旁路构建索引,线上仍返回旧结果;异步对比 seasonId、total 和排序,达到一致性门槛后再小流量切读。

59. 索引中有下架内容导致当前页不足怎么办?

过滤后继续向后补取,直到补满、没有更多数据或达到最大补偿次数。

60. 为什么不直接使用数据库分页?

当前排序依赖追番时间、最近观看时间和剧集更新时间等多个动态来源,普通数据库分页难以同时满足实时排序和高性能。

61. 是否可以改成游标分页?

可以。使用 (mtime, seasonId) 作为复合游标能避免深分页 offset 成本,也能提供稳定顺序,但需要客户端或上游协议配合。

62. 数据在翻页期间发生更新怎么办?

offset 分页可能出现重复或遗漏。可以使用固定快照、版本号或基于复合排序键的 cursor pagination 改善一致性。

63. 如果让你重新设计异常语义,你会怎么做?

不会把所有异常都转成正常空列表。至少应在内部区分真实空、超时降级和部分失败,并通过错误码、标志位或指标保留异常语义。

64. 这个项目最值得总结的工程经验是什么?

分页接口需要重点检查是否在分页前进行了全量 enrich 和 sort;动态配置必须同时具备安全默认值、硬上限、灰度指标和快速回退;性能结果必须区分实测数据与理论估算。

4. 凡人修仙传复播压测与扩容

\item \textbf{凡人修仙传复播压测与扩容}:基于 moni 批量采集并分析播放页服务及核心下游 RPC 的 QPS 变化,结合调用链放大比例定位限流与容量瓶颈,推动补齐下游限流配置,并落地 Caffeine 本地缓存、批量 miss 回源及空结果保护等优化,最终保障播放链路稳定承载峰值 140k+ QPS的流量

4.1 工作概述

凡人修仙传复播前,我们对播放页和播放地址两条核心链路做了专项压测。我的主要工作是通过 moni 批量拉取 view-gateway、player-gateway 及其下游 RPC 的 QPS 和错误变化,以低错误率窗口建立调用放大基线,再在逐级升压过程中判断下游流量是否符合预期,并区分限流、超时和熔断问题。针对发现的热点回源和配置瓶颈,我们补齐了限流配置,在 view、player、season、pay 等模块增加短 TTL 本地缓存、批量 miss 回源和空结果保护。最终两条核心入口按双机房各 70k QPS 的目标完成保障,合计口径为 140k+ QPS。

4.2 具体工作内容

4.2.1 需求背景

凡人修仙传第五季计划于 2026-06-13 开播。历史上该 IP 更新时曾出现突发流量打满播放链路、核心应用实例批量 unhealthy、播放页白屏的问题,因此本次开播前需要对播放链路进行专项扩容和压测。

播放链路不是一个单体服务:

  • view-gateway 负责聚合播放详情页,需要请求 season、episode、crowd、operation、pay、review、DM、recall 等多个下游。
  • player-gateway 负责播放鉴权和流地址下发,需要请求视频物料、播放鉴权、进度、付费、广告、视频调度等下游。
  • 一个入口请求可能触发多个下游 RPC,同一接口也可能因模块组合、用户态或设备态分支被调用多次。

所以风险不只是“网关能否扛住入口 QPS”,更重要的是入口流量经过聚合后是否会在某个下游被放大并形成级联故障。

4.2.2 具体代码实现

1. 项目背景

凡人复播时,播放详情页和播放地址会形成单一内容热点。view-gateway 和 player-gateway 都是聚合网关,一个入口请求会继续调用 season、episode、media、pay、crowd、operation、video material 等多个下游,因此下游 QPS 并不等于入口 QPS。

健康压测窗口中观察到的典型放大关系包括:

调用链 下游/入口放大比
view -> Crowd/VerifyCrowd 3.004x
view -> Relation/Relations 2.001x
player -> Video/GetVideoMaterialInfo 2.000x
player -> Ads/SuitAds 1.991x

整体容量目标是双机房 view-gateway 70k + player-gateway 70k,即两条核心入口链路合计 140k+ QPS,不是单个服务独立承载 140k。

2. 整体治理链路

flowchart LR
    A[入口压测流量] --> B[view/player gateway]
    B --> C{入口保护}
    C -->|超过阈值| D[限流或兜底]
    C -->|正常请求| E[查询本地缓存]
    E -->|全部命中| F[直接聚合返回]
    E -->|部分 miss| G[只汇总 miss ID]
    G --> H[批量调用下游 RPC]
    H --> I{回源结果}
    I -->|正常数据| J[写入短 TTL 缓存]
    I -->|正常空结果| K[写入空结果哨兵]
    I -->|RPC 异常| L[降级但不写负缓存]

实现目标不是完全消除下游调用,而是把“每个入口请求都重复回源”改成“相同热点数据在几秒内只由少量请求回源”。

3. 功能一:根据调用放大比补齐限流配置

3.1 实现逻辑

压测中先通过 moni 计算:

1
2
调用放大比 = 下游接口 QPS / 网关入口 QPS
下游容量目标 = 入口目标 QPS × 健康窗口放大比

出现错误时按错误语义处理:

现象 处理方式
509、Navigator usage 接近 100% 核对 caller 维度的 threshold
504 检查 P99、线程池、连接池、deadline 和实例容量
breaker 检查熔断原因及恢复情况
Client QPS 高、Server QPS 低 判断是否已经被限流或熔断截断

下游 Navigator 限流属于平台配置,不在 OGV Git 仓库中,因此没有可以直接粘贴的 Java 实现。代码仓库里能看到的是 view-gateway 已有的入口集群限流能力。本次工作的重点是依据压测放大比推动调整平台阈值,而不是为凡人重新开发 GlobalLimiter。

3.2 网关集群限流核心代码

HTTP 播放页在进入聚合逻辑前执行限流,见 ViewV2Controller.java:

1
2
3
4
5
@GetMapping("/pgc/view/v2/app/season")
public JSONData<ViewSeason> appSeason(...) {
globalLimiter.limit(seasonId, epId);
// 通过限流后再进入播放页聚合
}

GlobalLimiter 使用 Redisson 的 RRateLimiter,多个实例共享限流状态,因此控制的是集群总量,见 GlobalLimiter.java:

1
2
3
4
5
6
7
8
9
RRateLimiter limiter = appRedis.getRateLimiter(key);
limiter.setRate(RateType.OVERALL, rate, 1, RateIntervalUnit.SECONDS);

if (seasonId > 0
&& seasonLimit.getIds().contains(seasonId)
&& seasonLimit.getRate() > 0
&& !acquire(RedisKeys.buildSeasonRatelimitKey(seasonId), seasonLimit.getRate())) {
throw ServerException.code(ServerCode.TOO_MANY_REQUESTS);
}

只有全局开关开启、ID 命中名单且速率大于 0 时才限流。Redis 限流器异常时当前实现默认放行,优先避免限流组件故障导致全量播放失败,但代价是过载保护暂时失效。

4. 功能二:对高频下游 RPC 增加短 TTL Caffeine 缓存

4.1 为什么使用本地缓存

热点剧集的 season、episode、视频物料等数据会在短时间内被大量重复查询。使用 Caffeine 可以:

  • 避免 Redis 网络开销和热点 key 压力。
  • 在网关实例内吸收秒级重复请求。
  • 通过短 TTL 控制数据陈旧窗口。
  • 设置最大容量,避免缓存无限增长。

主要缓存策略如下:

模块 数据 TTL
player-gateway 通用 RPC 返回 2s
player-gateway 视频物料 10s
view-gateway season/episode 等热点数据 主要为 2s
season-gateway Media Card 2s
season-gateway Rank Info 10s
pay-service season/episode 3s
4.2 player-gateway 分层缓存

player 默认缓存保持 2 秒,对变化较慢的视频物料单独配置 10 秒 CacheManager,见 CacheConfig.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
@Primary
public CacheManager guavaCacheManager() {
return new CaffeineCacheManagerBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.maximumSize(50000)
.build();
}

@Bean
public CacheManager videoMaterialCacheManager() {
return new CaffeineCacheManagerBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.maximumSize(50000)
.build();
}

实际项目使用的是 CaffeineCacheManager + Caffeine.newBuilder();这里省略了重复的构造代码。视频物料接口显式选择 10 秒缓存,见 VideoMaterialRpcService.java:

1
2
3
4
5
6
7
8
@Cacheable(
value = "VideoMaterialRpcService.opEdPoints",
cacheManager = "videoMaterialCacheManager"
)
public CompletableFuture<GetVideoMaterialInfoReply> getOpEdPoints(Collection<Long> cids) {
return ContextFutures.asCompletableFuture(
futureStub.getVideoMaterialInfo(buildRequest(cids)));
}

同类缓存还覆盖 getOgvBrandReportFuture 和 getVideoMetaDetail。它们直接针对压测中约 2 倍放大的 GetVideoMaterialInfo 调用削峰。

5. 功能三:批量请求只回源 miss 数据

5.1 关键逻辑

不能直接把整个 ID 列表作为一个缓存 key。例如 [1,2,3] 和 [1,2,4] 虽然共享两个 ID,整批缓存仍会互相 miss。

项目中的处理方式是:

  1. 对每个 ID 单独构造缓存 key。
  2. 拆出 hitMap 和 missIds。
  3. 只将 missIds 批量发送给下游。
  4. 将回源结果逐 ID 写回缓存。
  5. 合并缓存命中和回源结果。

通用实现位于 CaffeineCacheSupportV2.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var missAndHit = MissHitUtils.getMissIdsAndHitMap(
ids, cache::getIfPresent, cacheKeyConverter);

var missIds = missAndHit.a;
var hitMap = missAndHit.b;

if (missIds.isEmpty()) {
return CompletableFuture.completedFuture(hitMap);
}

var futures = Lists.partition(
Lists.newArrayList(missIds), config.getPartBackSourceSize())
.stream()
.map(asyncBackSourceFunc)
.map(future -> future.whenComplete((result, error) -> {
if (error == null && !CollectionUtils.isEmpty(result)) {
write(cache, cacheKeyConverter, result);
}
}))
.collect(Collectors.toList());

futures.add(CompletableFuture.completedFuture(hitMap));
return FutureUtils.mergeFutures(futures, config.isThrowEx());
5.2 pay-service 的业务应用

pay-service 对 season card 和 episode info 建立 3 秒缓存,并只把 miss ID 发给 season-service,见 SeasonService.java:

1
2
3
4
5
6
7
8
9
RequestVariantSnapshot snapshot = RequestVariantSnapshot.capture();

return BackSourceUtil.getsAsyncFromLocalCache(
seasonCardCache,
seasonId -> buildSeasonCardCacheKey(snapshot, seasonId, type),
seasonIds,
missSeasonIds ->
cardsFutureWithoutCache(missSeasonIds, type, deadlineMs)
);

当前代码的 key 还包含 RequestVariantSnapshot、seasonId 和 type,用于隔离不同请求上下文。需要注意:request variant 是 2026 年 8 月的后续安全增强,不能全部归功于 6 月 4 日的初始缓存改造。

6. 功能四:正常空结果缓存,RPC 异常不缓存

6.1 问题背景

如果某个 sectionId 确实没有剧集,下游会正常返回空结果。如果不缓存,这个不存在的 ID 会在高并发下持续穿透缓存。

但是 RPC 超时返回的空结果不能缓存,否则一次临时故障会被转换成数秒内稳定的“数据不存在”。

6.2 核心实现

view-gateway 使用一个仅在 RPC 层内部可见的空对象作为哨兵,见 EpisodeRpcService.java:

1
2
private static final SectionEpisodeProto EMPTY_CACHE_SECTION_EPISODE =
SectionEpisodeProto.newBuilder().build();

关键顺序是先处理正常结果,再处理异常,见同文件 listBySectionFutureV2:

1
2
3
4
5
6
7
8
9
10
11
12
13
return CaffeineCacheSupportV2.getsAsync(
sectionIds,
missIds -> listBySectionFutureInnerV2(missIds, channel)
// 只有 RPC 正常完成,才为缺失 ID 补空哨兵
.thenApply(result -> fillSectionEpisodeEmptyCache(missIds, result))
// 超时或异常只降级,不补哨兵
.exceptionally(e -> Collections.emptyMap()),
sectionEpisodeProtoCache,
cacheKeyBuilder,
CaffeineCacheSupportConfig.default4Rpc(),
CacheKeyPolicy.CONTENT_DELIVERY_V2,
snapshot
).thenApply(this::filterSectionEpisodeEmptyCache);

对正常回源未返回的 ID 补入哨兵,返回业务层前再过滤,见 EpisodeRpcService.java:

1
2
3
4
5
6
7
8
9
missIds.forEach(sectionId -> {
if (!filled.containsKey(sectionId)) {
filled.put(sectionId, EMPTY_CACHE_SECTION_EPISODE);
}
});

return result.entrySet().stream()
.filter(entry -> entry.getValue() != EMPTY_CACHE_SECTION_EPISODE)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

其语义优先级是:

回源状态 是否写缓存 返回业务层
正常返回数据 写真实数据 返回数据
正常返回但 ID 不存在 写空哨兵 过滤为无数据
RPC 超时或异常 不写负缓存 本次降级为空
已命中空哨兵 不再回源 过滤为无数据

7. 功能五:降低扩容预热对下游的瞬时冲击

扩容新实例启动时会主动预热代码、连接和缓存。如果每个实例同时发起大量预热请求,扩容动作本身会形成额外流量尖峰。

提交 1ba8445f9e6 将单实例预热任务数从 2000 调整为 100,当前代码见 AbstractHealthCheckAdaptor.java:

1
2
3
4
5
6
7
8
private static final int warmupTaskCount = 100;

private static final ThreadPoolExecutor threadPoolExecutor =
ThreadPools.newFixedThreadPool("healthcheck-warmup", 50, true);

for (int i = 0; i < warmupTaskCount; i++) {
futures.add(threadPoolExecutor.submit(this::warmupRequest));
}

这项优化本质上是启动阶段的流量整形:让新实例逐步建立热缓存,避免扩容时反向打满下游。

8. 凡人专项预刷新兜底:功能分支方案

提交 60d50201116 在 origin/feat/fanren-season-fallback 中实现了按凡人 season 限流、超限返回预刷新响应的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
public SeasonInfosReply degradeIfNeeded(int seasonId) {
if (Boolean.TRUE.equals(refreshing.get())) {
return null; // 刷新线程必须走真实链路
}
if (!isFanrenSeason(seasonId)) {
return null;
}
int qps = config().getSingleNodeQps();
if (qps > 0 && getLimiter(seasonId).tryAcquire()) {
return null; // 阈值内继续完整聚合
}
return fallbackCache.get(seasonId); // 超限返回预刷新响应
}

调度器默认每 30 秒以匿名用户上下文刷新一次响应;如果兜底尚未生成,则返回 null 继续正常链路,避免直接返回空播放页。

这套方案有代码和单测证据,但没有合入当前 master。面试时应表述为“完成了专项方案设计与验证”,不能直接说成“已生产全量上线”。

9. Git 实现时间线

时间 提交 主要内容
06-02 60d50201116 凡人 season 单机限流与预刷新兜底,功能分支
06-04 1ba8445f9e6 player/view/pay 缓存、season 缓存接入、预热 2000→100
06-05 98a63adfb4f 继续补充 view/player 缓存
06-08 321b69ebbf2 为 section 正常空结果增加负缓存
06-08 a03a0423ea3 修复 RPC 失败被误写负缓存的问题

10. 面试复述版本

我先通过 moni 获取 view-gateway、player-gateway 入口以及核心下游 RPC 的 QPS,选择无明显报错的健康窗口计算调用放大比,再结合 509、504、breaker 和 Navigator usage 判断是限流配置问题还是实际容量问题。代码侧主要在 player、view、season 和 pay 链路增加短 TTL Caffeine 缓存;批量请求按单个 ID 拆分命中和 miss,只把 miss ID 批量回源。对于正常不存在的 section 写入内部空结果哨兵,避免反复穿透,但 RPC 超时或异常不写负缓存。同时把实例启动预热任务从 2000 降到 100,避免扩容过程形成新的流量尖峰。最终保障的是 view 和 player 两条核心入口双机房各约 70k、合计 140k+ QPS。

4.3 提问

4.3.1 自己能想到的

4.3.2 AI想到的问题

下面按面试重要性分为:

  • P0:几乎必问,回答不好会直接质疑项目真实性。
  • P1:技术深挖,判断是否真正参与过设计和落地。
  • P2:高级追问,考察性能治理和系统设计能力。
  • P3:开放题,考察复盘、协作和技术判断力。

回答均基于目前已有代码和压测资料。最终 P99、缓存命中率、错误率等没有明确数据的地方,不建议现场编造。

P0:项目真实性与核心链路

1. 先用两分钟介绍这个项目

答:凡人复播前,播放详情页和播放地址预计会出现单一内容热点。我的主要工作是通过 moni 批量获取 view-gateway、player-gateway 入口和核心下游 RPC 的 QPS,建立入口到下游的调用放大关系,定位限流和容量瓶颈。代码侧在 view、player、season、pay 等模块增加短 TTL Caffeine 缓存、批量 miss 回源、空结果保护,并降低实例启动预热流量。最终两条核心入口双机房各约 70k,合计保障 140k+ QPS。

2. 140k+ QPS 的统计口径是什么?

答:是 view-gateway 和 player-gateway 两条核心入口的合计。view 双机房目标约 70k,player 双机房目标约 70k,不是单个服务独立承载 140k,也不是所有下游 RPC QPS 的简单相加。

3. 为什么不能只看网关入口 QPS?

答:网关是聚合层,一个入口请求会并发调用多个下游。不同依赖的调用次数不同,有些接近 1x,有些是 2x 或 3x。入口 QPS 只能表示用户请求规模,无法直接表示下游真实压力。

4. 调用链放大比例怎么计算?

答:

1
2
放大比 = 下游接口 QPS / 对应入口 QPS
下游容量目标 = 目标入口 QPS × 健康窗口放大比

例如健康窗口里 view -> Crowd/VerifyCrowd 约为 3.004x,如果单机房入口目标是 35k,理论下游容量参考值约为 105k QPS。

5. 为什么要用健康窗口计算放大比?

答:高压阶段可能已经发生限流、熔断、超时或重试。此时下游 QPS 可能被截断,也可能因重试异常放大。健康窗口基本没有错误,更能反映正常业务调用模型。

6. 实际观察到了哪些典型放大关系?

答:健康窗口中,view -> Crowd/VerifyCrowd 约 3x,view -> Relation/Relations 约 2x,player -> GetVideoMaterialInfo 约 2x,player -> SuitAds 也接近 2x。

7. 如何判断 QPS 变化“不符合预期”?

答:先与健康窗口放大比比较。如果比例突然升高,排查重复调用、重试和缓存失效;如果比例降低但错误率上升,排查限流、熔断或超时是否截断请求;还要同时比较 RPC Client QPS 和 Server QPS。

8. 你们发现了哪些主要瓶颈?

答:主要分为两类:一类是 Navigator threshold 配置不足,表现为 usage 接近 100% 和 509;另一类是真实容量或耗时问题,表现为 504、breaker、P99 上升,涉及 Resource、Ads、DM、Recall、Delivery 等依赖。

9. 509、504 和 breaker 分别说明什么?

答:

  • 509:通常先检查调用方限流阈值和 usage。
  • 504:通常表示 deadline 内没有完成,需要查耗时、线程池、连接池或实例容量。
  • breaker:表示熔断器已经触发,需要查原始失败原因和恢复速度。

不能把三者都归结为“限流配置太低”。

10. “推动补齐下游限流配置”具体做了什么?

答:先根据健康窗口的放大比估算目标下游 QPS,再检查对应 caller、method、机房的 threshold 和 usage。确认提供方容量后,由对应服务负责人调整限流配置并复压验证。Navigator 配置属于平台操作,不是 OGV 仓库中的 Java 代码。

11. 为什么不能看到 509 就直接调高限流?

答:限流是在保护真实容量。如果提供方 CPU、连接池或依赖没有余量,直接提高阈值只会把 509 转换成 504、线程池堆积或熔断。必须先确认服务容量,再调阈值。

12. 这次主要实现了哪些代码功能?

答:

  1. player 视频物料等高频 RPC 的短 TTL 缓存。
  2. view 的 season、episode 等热点数据缓存。
  3. pay-service 的 season card 和 episode 缓存。
  4. 批量请求按 ID 拆分,只回源 miss 数据。
  5. section 正常空结果的负缓存保护。
  6. RPC 异常不写负缓存。
  7. 启动预热任务从 2000 降到 100。
13. 为什么选择 Caffeine?

答:目标是吸收单实例内几秒钟的重复热点请求。Caffeine 没有网络访问开销,也不会把热点转移到 Redis。数据允许秒级陈旧、读多写少时,本地短 TTL 缓存比较合适。

14. 为什么 TTL 只有 2 秒、3 秒或 10 秒?

答:重点不是长期缓存,而是吸收瞬时重复请求。season、episode、权益等数据对实时性比较敏感,所以使用 2 至 3 秒;视频物料、榜单等变化较慢的数据可以使用 10 秒。TTL 是削峰收益和数据陈旧风险之间的平衡。

15. 最终如何证明优化有效?

答:复压时保持相同入口模型和统计窗口,对比优化前后的入口 QPS、下游 Client/Server QPS、放大比、错误率、509/504/breaker 和资源水位。不能仅凭“代码上线成功”判断优化有效。

P1:缓存实现与代码深挖

16. @Cacheable 的执行流程是什么?

答:Spring AOP 在方法调用前根据参数生成 key,命中则直接返回缓存值;未命中才执行方法,并将正常结果写入 CacheManager 管理的 Caffeine 缓存。

17. player 为什么配置两个 CacheManager?

答:默认 CacheManager 使用 2 秒 TTL;视频物料使用独立的 videoMaterialCacheManager,TTL 为 10 秒。这样不需要让所有缓存统一使用较长 TTL。

18. 哪些 player 接口使用了 10 秒缓存?

答:包括 getOpEdPoints、getOgvBrandReportFuture、getVideoMetaDetail 等 GetVideoMaterialInfo 相关查询。

19. 什么是批量 miss 回源?

答:先逐个 ID 查询本地缓存,将结果拆成 hitMap 和 missIds。缓存命中的直接复用,只有 missIds 被批量发送给下游,最后再合并两部分结果。

20. 为什么不能缓存整个 ID 列表?

答:[1,2,3] 和 [1,2,4] 会形成不同的整批 key,虽然它们共享 1、2,也无法复用。逐 ID 缓存可以跨不同批次复用热点数据。

21. 批量 miss 会不会退化成 N 次单查?

答:不会。miss ID 会按固定批次聚合后调用下游,仍然保留批量 RPC,只是请求内容从全部 ID 缩小为未命中的 ID。

22. 缓存命中与回源结果如何合并?

答:缓存框架先保存 hitMap,对 miss 分批创建 Future;回源成功后按 ID 写缓存,最后使用 mergeFutures 合并回源结果和 hitMap。

23. 如果缓存写入失败怎么办?

答:当前通用实现会记录缓存写入异常,但不把一次成功的下游响应转换成业务失败。缓存是性能优化,不应该成为主流程正确性的强依赖。

24. 什么是缓存穿透?

答:查询一个确定不存在的 ID 时,如果空结果不缓存,每次请求都会 miss 并回源。高并发下,不存在的数据也会持续冲击下游。

25. 空结果保护是怎么实现的?

答:对“RPC 正常完成但没有返回该 sectionId”的情况,写入内部 EMPTY_CACHE_SECTION_EPISODE 哨兵。后续请求命中哨兵后不再回源,但在返回业务层前会过滤掉哨兵。

26. 为什么不能直接缓存一个普通空 Proto?

答:业务下游可能真的返回结构为空的合法 Proto。使用同一个静态哨兵对象并按引用比较,可以区分“内部负缓存占位符”和“下游真实返回的空结构”。

27. 为什么 RPC 异常不能写空缓存?

答:超时或连接失败表示暂时无法获取数据,不代表数据不存在。如果写入空缓存,就会把一次临时故障固化为 TTL 内稳定的数据缺失。

28. 代码如何区分正常空结果和异常?

答:正常 Future 完成后先执行 thenApply(fillEmptyCache);异常直接进入 exceptionally 返回空 Map,不执行填充哨兵的逻辑,因此不会写负缓存。

29. 缓存 key 应该包含哪些维度?

答:除 seasonId、epId 外,还需要评估 type、channel、地区、客户端版本、平台、用户身份、会员状态和实验分组。任何会改变返回内容的维度都可能需要进入 key。

30. 缓存 key 少了维度会发生什么?

答:可能把不同端、地区或用户的结果互相复用,造成数据串读。缓存命中率可能很高,但业务结果是错误的。

31. 为什么当前代码里有 RequestVariantSnapshot?

答:它把请求上下文中影响内容分发的维度固化到缓存 key,避免异步执行时上下文变化或不同请求复用同一个 key。这属于后续的缓存安全增强,不能全部归因于最初的 6 月改造。

32. 本地缓存会导致多实例不一致吗?

答:会。每个实例维护自己的缓存,同一时间可能保存不同版本。因此只适用于允许秒级陈旧的数据,不能替代支付状态等强一致数据源。

33. 为什么不用 Redis?

答:Redis 可以跨实例共享,但增加网络开销,也可能形成新的热点 key。这里主要解决实例内几秒钟的重复 RPC,本地缓存成本更低。需要共享和一致性时才考虑 Redis。

34. 如何设置 maximumSize?

答:根据 key 数量、单 value 大小和实例内存预算估算,并观察 Caffeine eviction、堆使用和 GC。不能只根据 QPS 设置容量。

35. 缓存满了会怎样?

答:Caffeine 会按照淘汰策略移除部分条目,不会无限增长。但如果容量过小会频繁淘汰、命中率降低;过大则增加堆占用和 GC 压力。

36. 缓存过期时会不会发生击穿?

答:可能。多个请求同时发现同一热点 key 过期时,会并发回源。短 TTL 降低了长期不一致风险,但没有天然解决 single-flight 问题。

37. 如何进一步解决缓存击穿?

答:可以按 key 合并并发请求、缓存正在执行的 Future、增加随机过期抖动、预刷新热点 key,或者对热点对象设置单独的回源限流。

38. 缓存 CompletableFuture 有什么风险?

答:需要确认异常 Future 是否会留在缓存中、默认降级结果是否会被当成成功结果缓存,以及异步阶段是否携带请求上下文。Future 返回对象还应避免被后续修改。

39. 发生 RPC 异常后返回默认 Proto,会不会被缓存?

答:取决于异常是在缓存切面之外抛出,还是已经被方法内部 exceptionally 转换成正常完成的默认 Proto。后者可能被当作成功结果缓存,因此需要结合具体方法和 unless 条件检查。

40. 如何验证缓存没有串数据?

答:构造相同 ID、不同地区、端、版本、用户和实验参数的请求,比较首次回源和缓存命中的结果;同时检查 key 生成逻辑和缓存命中日志。

P1:限流与容量设计

41. GlobalLimiter 是单机还是集群限流?

答:是基于 Redisson RRateLimiter 的集群级限流,使用 RateType.OVERALL,多个网关实例共享同一个 Redis key。

42. 为什么热点 season 适合集群限流?

答:如果使用单机限流,扩容后总阈值会随实例数变化。集群限流能保证某个 season 或 episode 的整体流量不超过配置上限。

43. GlobalLimiter 是本次凡人项目新开发的吗?

答:不是。Git 记录显示它是已有通用能力。本次项目主要是结合压测结果使用现有保护机制并推动调整下游 Navigator 配置。

44. Redisson 限流器异常时为什么默认放行?

答:这是可用性优先的选择,避免 Redis 故障导致播放页全部被拒绝。代价是保护能力暂时丢失,因此需要下游限流、熔断和告警补偿。

45. 默认放行有什么风险?

答:限流组件故障时流量会直接进入后续聚合链路,可能打穿下游。需要监控限流器异常,并准备网关降级或下游保护。

46. 单机限流和集群限流分别适合什么场景?

答:单机限流适合保护当前实例的线程池或局部接口;集群限流适合严格控制热点内容的总量。单机限流的总额度会随实例数变化。

47. 如何给下游设置合理阈值?

答:以健康窗口放大比乘以目标入口 QPS,再考虑机房分布、容量冗余和突发系数。阈值必须低于经过验证的提供方容量,不能只按调用方需求配置。

48. 如果两个机房流量不均怎么办?

答:分别观察各机房入口、实例数、下游 Client/Server QPS 和限流配置。容量规划不能简单按总量除以二,需要考虑流量倾斜和单机房故障场景。

49. 为什么限流后下游 QPS 反而可能低于预期?

答:请求在到达下游前已经被入口限流、Navigator 限流或熔断截断。此时低 QPS 不是容量充足,而可能是请求没有真正到达。

50. 为什么 Client QPS 和 Server QPS 可能不一致?

答:可能存在网络失败、客户端限流、熔断、超时取消、统计口径差异或多次重试。需要结合 caller、method、zone 和时间窗口分析。

P1:压测方法与验证

51. moni 批量取数的关键参数有哪些?

答:至少固定 app、app-group、env、zone、caller、method、数据源、绝对起止时间和 step。否则不同查询之间的统计口径可能不一致。

52. 为什么使用绝对时间而不是 now-15m?

答:相对时间会随着查询时刻变化,不同接口取到的窗口无法严格对齐。压测复盘和比例计算应固定绝对时间。

53. 如何验证批量采集脚本的数据正确?

答:随机抽取几个核心接口,与 moni 面板同一时间窗口的 Last、Max 或平均值核对;同时检查查询条件和返回序列是否为空。

54. Last、Max 和平均 QPS 应该用哪个?

答:取决于目标。调用比例分析通常用同一稳定窗口的平均值;瞬时容量风险看 Max;实时观测可以看 Last。不能把不同聚合方式的数据直接相除。

55. 压测流量模型应该如何设计?

答:尽量还原真实入口比例、登录/匿名用户比例、端和版本分布、热点 season/episode 分布,以及播放页和播放地址的调用时序。

56. 为什么不能只压一个接口?

答:真实用户行为可能同时触发详情页、播放鉴权、物料、广告、进度等链路。只压单接口无法暴露共享下游和连接池之间的竞争。

57. 升压应该一次到目标值吗?

答:不应该。应分阶段升压,每一阶段观察入口、下游、错误率、P99、CPU、线程池、连接池和限流 usage,确认稳定后再继续。

58. 如何定义压测成功?

答:目标 QPS 下核心接口错误率、P99、资源水位、下游限流和熔断均在可接受范围,并能稳定维持规定时间;停止压测后服务能快速恢复。

59. 为什么只看 CPU 不够?

答:瓶颈可能在 RPC 线程池、连接池、Redis、网络、GC、队列或下游服务。CPU 不高不代表服务没有发生排队或超时。

60. 如何判断优化后 QPS 降低是缓存生效,而不是请求失败?

答:必须同时确认入口 QPS稳定、成功率正常、Server QPS下降、缓存命中上升、509/504/breaker 没有增加。只看下游 QPS 下降无法得出结论。

P2:高级技术追问

61. 为什么短 TTL 仍然可能产生周期性流量尖峰?

答:如果大量 key 同时写入,也会在接近同一时间过期,形成周期性回源。可以增加 TTL 抖动、主动刷新或 single-flight。

62. Caffeine 是强一致缓存吗?

答:不是。它是进程内性能缓存,不保证跨实例一致,也不能保证与下游数据实时同步。

63. 如何估算本地缓存节省的下游 QPS?

答:

1
2
节省 QPS ≈ 原始回源 QPS × 缓存命中率
实际下游 QPS ≈ 原始回源 QPS × (1 - 命中率)

批量接口还需要按 miss ID 比例计算,不能只按请求次数计算。

64. 如何估算缓存命中率?

答:记录 hit、miss、load success、load failure 和 eviction 指标;批量接口最好同时记录请求 ID 数、hit ID 数和 miss ID 数。

65. 为什么“请求命中率”可能具有误导性?

答:一个批量请求可能包含 100 个 ID,其中只有 1 个 miss。如果按请求统计它是 miss,但实际已经减少了 99% 的 ID 回源,因此还要统计 ID 级命中率。

66. 如何防止热点 key 回源拖垮线程池?

答:对热点 key 使用 single-flight、隔离线程池、回源限流、短超时和预刷新,并为下游设置合理的并发上限。

67. 负缓存 TTL 应该怎么确定?

答:结合不存在状态的变化概率和下游压力确定。TTL 太短无法防穿透,太长会延迟新数据生效。这里复用短 TTL,优先控制错误放大范围。

68. 空结果哨兵为什么用引用比较而不是 equals?

答:默认 Proto 可能与下游合法返回的空 Proto 结构相等。引用比较只识别代码内部写入的那个静态哨兵对象。

69. 本地缓存如何应对发布和扩容?

答:新实例启动时缓存为空,会出现冷启动回源。可以降低预热并发、灰度扩容、逐步接流,必要时对少量热点 key 主动预热。

70. 为什么把预热任务从 2000 降到 100?

答:2000 个预热任务在扩容阶段可能制造明显下游尖峰。降低到 100 可以减少启动冲击,让实例逐步建立连接和缓存。

71. 降低预热任务会不会导致实例更慢进入稳定状态?

答:可能会延长完全预热时间,但避免新实例因瞬时冲击导致自身或下游异常。目标是安全接流,不是最快填满所有缓存。

72. 如何验证预热参数 100 是合理的?

答:观察实例启动耗时、预热成功率、预热期间下游 QPS、连接池和 CPU。100 是工程参数,不应该脱离监控被认为是永久最优值。

73. 如果重新设计,会如何做动态限流?

答:可以基于实例数量、下游 P99、错误率和线程池水位动态调整,但要设置上下界和抖动控制,避免反馈回路导致阈值频繁变化。

74. 如何实现热点 season 的降级?

答:一种方案是让阈值内请求走完整聚合,超限请求返回定时预刷新的通用响应,将流量截断在网关。但用户态、地区和实验字段必须剥离或重新补充。

75. 为什么预刷新响应不能包含用户态数据?

答:同一份响应会被多个用户复用。如果包含关注状态、购买状态、VIP、地区或实验信息,会发生跨用户数据串读。

76. 预刷新失败怎么办?

答:保留上一份成功响应并告警;如果从未刷新成功,则回退正常链路,不能直接返回空页面。还应监控缓存年龄。

77. 如何避免定时刷新被自身降级逻辑拦截?

答:刷新线程设置独立上下文标记,例如 ThreadLocal。降级判断发现当前是刷新线程时直接放行,让它走真实聚合链路。

78. 为什么凡人专项兜底不能直接说已上线?

答:现有 Git 证据显示该提交位于功能分支,未进入当前 master。准确说法是完成了专项方案设计和测试,上线范围需要发布记录确认。

79. 有没有加缓存后出现业务问题?

答:相关推荐曾因缓存语义或 key 维度问题出现加载异常,后续先移除缓存恢复正确性。这个案例说明命中率必须服从业务正确性。

80. 缓存方案如何支持快速回滚?

答:优先配置开关或独立 CacheManager;发布时灰度观察;出现异常可以关闭缓存、缩短 TTL 或回滚提交,而不影响原始 RPC 回源路径。

P2:稳定性与故障演练

81. 如果 Caffeine 出现大量 eviction,怎么处理?

答:检查 key 基数、maximumSize、value 大小和访问分布。先判断是否 key 维度过多或存在无界参数,再考虑增加容量。

82. 如何监控缓存带来的 GC 风险?

答:观察堆使用、对象分配速率、Old GC、停顿时间、缓存 size 和 eviction。Proto value 较大时尤其要关注单实例内存。

83. 如果 Redis 集群限流不可用,你会怎么降级?

答:当前代码默认放行。更完整的设计可以增加本地应急限流、限流器异常告警,以及对关键下游的并发隔离。

84. 下游超时后是否应该立即重试?

答:不一定。高压场景无节制重试会继续放大流量。应结合幂等性、剩余 deadline、错误类型和重试预算决定。

85. 如何避免多个下游故障相互扩散?

答:为不同依赖设置独立超时、线程池或并发隔离;非核心模块允许降级;核心链路使用熔断和容量保护,避免一个慢依赖拖住整个聚合请求。

86. 哪些数据可以降级为空,哪些不能?

答:装饰性榜单、推荐、广告等通常可以降级;播放鉴权、权益判断、基础 season/episode 信息需要更谨慎。具体以业务正确性和安全要求为准。

87. 如果缓存命中率突然下降,排查什么?

答:检查发布、实例扩容、key 维度变化、TTL、maximumSize、请求参数分布、缓存对象是否被频繁淘汰,以及是否发生机房流量切换。

88. 如果入口 QPS不变但下游 QPS翻倍,可能是什么原因?

答:重复调用、重试增加、缓存失效、代码分支变化、实验开启,或者同一个下游方法在不同聚合模块被重复调用。

89. 如果入口 QPS上涨但下游 QPS不涨,可能是什么原因?

答:缓存命中提升,也可能是入口限流、下游限流、熔断或调用分支未命中。必须结合成功率和错误指标判断。

90. 如何做故障演练?

答:分别模拟下游超时、509、连接失败、缓存冷启动、Redis 限流器故障和单机房流量切换,验证降级、熔断、告警及恢复行为。

P3:项目复盘与个人贡献

91. 你在项目中的个人贡献是什么?

答:重点参与 moni 数据批量采集、入口与下游放大关系分析、限流问题判断,以及本地缓存、批量 miss 回源和空结果保护等代码落地。扩容和部分下游配置由对应服务负责人协同完成。

92. 这个项目最难的地方是什么?

答:最难的不是加缓存,而是把复杂聚合链路中的正常调用放大、重试放大、限流截断和真实容量不足区分开,避免根据错误指标做出错误调整。

93. 你做过最关键的技术判断是什么?

答:没有把所有问题都归因于限流阈值,而是先建立健康调用比例,再区分 509、504 和 breaker。代码侧也没有单纯缓存所有空结果,而是区分正常空结果和 RPC 异常。

94. 如果再做一次,会提前补什么?

答:会提前建立自动化容量模型、ID 级缓存命中指标、统一的下游容量清单,以及压测期间的自动化对比报表,减少人工切换面板和计算。

95. 如何证明不是单纯堆机器解决问题?

答:扩容解决实例计算能力,但无法解决下游调用放大和重复回源。本次同时通过缓存、批量 miss、负缓存、限流配置和预热流量整形减少实际下游压力。

96. 这个项目有什么失败或回滚经验?

答:相关推荐缓存曾出现加载问题,最终选择先移除缓存恢复正确性。说明性能优化必须建立在缓存 key 和业务语义正确的基础上。

97. 如何与下游团队协作?

答:提供明确的 caller、method、机房、时间窗口、入口 QPS、下游 QPS、放大比和错误类型,而不是只反馈“服务扛不住”。下游据此核对阈值和容量,再共同复压。

98. 如何避免把团队成果说成个人成果?

答:明确个人负责监控分析、问题定位和部分代码优化;扩容执行、下游配置调整及其他模块改造由对应负责人共同完成。

99. 这个项目给你的最大收获是什么?

答:容量治理不能只看单服务资源,也不能只靠扩容。要从入口流量、调用放大、限流、缓存、超时、熔断和冷启动完整分析系统行为。

100. 用一句话概括这个项目的价值

答:把一次依赖人工观察的热点保障,转化成了基于调用放大比进行容量规划、通过短 TTL 缓存和分层保护削减下游压力的系统化治理过程。

SLO 异常自动化诊断项目面试复盘手册

使用目的

这份手册用于面试前恢复项目上下文,并帮助我在面试中完整回答四类问题:为什么做、怎么设计、难点在哪里、结果与不足是什么。内容根据原始需求说明、日志指标设计稿、当前 Java 代码、Git 提交记录和本地分析产物整理。

文档中的信息分为三类:

  • 已实现:当前代码可以验证的能力,可以作为项目事实陈述。
  • 原始设想:需求或设计稿中提出、但当前代码未完整落地的能力,面试时不能表述为已完成。
  • 复盘建议:基于现状总结的改进方向,用于回答“如果再做一次会怎么优化”。

个人复习时可以保留内部服务名和字段;对外面试时,应将服务名替换为“内容网关”“播放鉴权服务”“内容服务”,不要展示内部域名、IP、Cookie、日志全文和组织路径。

一页速记

简历表述

负责 SLO 异常自动化诊断工具设计与落地,基于 Java 和 Spring Boot 打通 Watcher 与 Billions 数据链路,采用 MAD 异常检测、接口增量对比、异常窗口加权采样等方案,构建覆盖异常发现、日志采集、16 维聚合、基线对比和批量分析的 CLI 流水线,并通过并行抓取与基线缓存提升多服务故障定位效率。

三十秒回答

这个项目解决的是 SLO 抖动后人工跨 Watcher 和日志平台排查成本高的问题。以前需要先找异常日期,再看错误接口和时间段,最后到日志平台逐条判断下游、实例、可用区或业务参数是否异常。我把这条链路做成了 Java CLI:先用 MAD 识别异常天,再做接口和基线增量对比,定位异常时间窗口,然后分时采样 Billions 日志,聚合成 16 个诊断维度。批量版本还增加了四线程并发、基线缓存和失败窗口拆分重试,最终沉淀可复用的 JSON 证据供人工或大模型生成根因报告。

两分钟回答

项目起因是部分核心服务在某些时段达不到 SLO,可用率下降时错误日志可能达到数千甚至更多。原排查方式依赖人工在质量平台确认异常日期和错误接口,再到日志平台切时间范围、拼查询条件、抽样日志,最后靠经验判断是下游服务、单实例、可用区、请求复杂度还是业务数据导致,过程重复且容易受采样偏差影响。

我先把人工步骤拆成确定性流水线:SLO 日报采集、异常日期检测、错误面板采集、错误接口与时间窗口检测、日志查询、指标聚合和基线对比。异常天使用中位数和 MAD,避免固定阈值无法适配不同流量级别的服务;接口层同时看绝对增量和增长倍数;时间窗口将异常日与基线日相同时刻对齐,过滤常态高峰。日志侧不一次性拉全天数据,而是等分窗口采样;异常窗口内再按错误密度分配配额,查询超时时拆分窗口重试。

最终聚合结果覆盖错误类型、下游服务、Gateway 实例、下游 IP、可用区、业务参数、请求和响应大小、超时配置、调用方及分钟趋势等维度。V2 将基线结果持久化并复用,支持多日期批量分析。当前实现属于确定性诊断流水线,而不是自主推理 Agent;Java 程序负责把证据做准,最终解释由外部分析指令或人工完成。

必须记住的数字

项目 数字 说明
CLI 命令 7 pipeline、day、compare、diff、batch-diff、batch-diffv2、query
日志聚合维度 最多 16 部分维度仅在对应字段存在时输出
V2 工作线程 4 用于并行预取基线日志和处理增量接口
全天日志采样 48 × 50 将一天拆为 48 个半小时窗口,每窗最多 50 条
异常窗口采样 10 窗口、总量 500 按错误密度加权分配配额
异常窗口范围 5 分钟至 2 小时 过短扩展,过长取增量最密集子窗口
默认增量门槛 50 且 1.5 倍 同时满足绝对增量和增长倍率
本地 V2 验证范围 3 个服务、33 个日期目录 当前仓库产物事实,不等同于线上覆盖率

需求背景

业务问题

原始需求关注的是服务在某些时段无法达到 99.999% 可用率,同时 Service 层错误数显著上升。可能原因很多,包括下游服务变慢或不可用、数据库和缓存问题、单 Pod 或单 IP 故障、可用区异常、特定剧集数据、批量请求过大,以及 deadline 在调用链传播过程中被耗尽。

真正的难点不是“看到错误数升高”,而是把错误量继续缩小到可行动的范围:哪一天、哪个接口、什么时间段、哪类错误、哪个下游、是否集中在实例或区域、是否由特定业务参数触发。

原人工流程

  1. 在 Watcher 的 SLO 报表中选择服务和日期范围。
  2. 观察 Service 层错误数、请求可用性和告警量,找异常日期。
  3. 进入根因面板,按接口查看错误数时序和错误码。
  4. 手工记录错误最多的接口及时间段。
  5. 到 Billions 选择应用,拼接接口路径和返回码条件。
  6. 调整查询窗口并抽取日志。
  7. 阅读大量日志,判断错误类型、下游、实例、可用区和业务参数分布。
  8. 选择正常基线,再判断哪些变化与异常相关。

这个过程跨平台、依赖个人经验,而且每个服务和日期都要重复执行。日志数量大时,简单取前 N 条还会导致样本集中在最后几分钟。

目标拆解

层次 要回答的问题 系统输出
服务层 哪几天异常 anomaly_days.json
接口层 哪些接口和错误码增长 increased_apis.json
时间层 异常集中在哪些时间段 time_windows.json
日志层 错误呈现哪些分布特征 aggregate.json
对比层 相对基线变化在哪里 diff_result.json
解释层 最可能的根因和排查方向 外部分析报告或人工结论

需求演进

阶段一 从人工步骤抽象出流水线

最初设想是串联已有 Python 文件,形成“输入服务名,输出最近一周 SLO 抖动原因文档”的八步流程:抓取 SLO、识别异常日、抓取错误面板、识别接口和时段、查询日志、聚合指标、调用 AI、输出原因。

这一阶段的关键贡献是把模糊的“自动分析 SLO”拆成可独立验证的步骤,并明确每一步的输入和输出。没有这一层拆解,模型只能面对大量原始日志,难以稳定判断。

阶段二 建立可解释的日志指标体系

对真实日志样本分析后,设计从简单计数扩展为诊断维度。设计稿特别关注以下模式:

  • 同一 trace 产生多条错误日志,错误数可能被请求扇出放大。
  • 多个下游同时以极低耗时报 DEADLINE_EXCEEDED,可能不是下游都慢,而是剩余 deadline 已不足。
  • 错误均匀分布在多个 IP 时,不应误判为单实例故障。
  • 大批量参数可能使请求处理时间超过 deadline。

这一步把“让 AI 看日志”改造成“先把日志压缩成有诊断含义的结构化特征,再进行解释”。这是项目最重要的方法论。

阶段三 完成 Java CLI 和多场景命令

当前项目使用 Java 17、Spring Boot 3.4、WebClient、Jackson 和 Picocli,形成分层架构,并提供自动发现、指定日期、两日对比、增量接口、批量对比和精确查询等七个命令。

阶段四 从单次运行演进到可复用批处理

V2 的重点不再是增加一个命令,而是解决重复抓取和大批量分析的成本:

  • 按服务建立稳定目录,持久化基线日志和聚合结果。
  • 已存在基线聚合时跳过重复请求。
  • 已存在日期结果时跳过重复处理。
  • 使用四线程并发处理多个接口。
  • 在异常窗口内按错误密度加权采样。
  • 单窗口超时时拆成两个子窗口继续查询。

Git 历史显示,初始版本和 2.0 版本集中在 2026 年 5 月完成;2.0 主要新增 V2 批处理、query 命令、聚合维度、采样和分析说明。

设计与落地的边界

能力 状态 面试表达
自动抓取 Watcher 和 Billions 已实现 可以明确说明
异常天、接口、时间窗口检测 已实现 可以讲算法和阈值
最多 16 维日志聚合 已实现 要说明空字段时不输出对应维度
基线缓存、并行和失败拆分重试 已实现 可以讲工程优化
Java 内部直接调用大模型 未实现 不要说已接入 Claude API
自主规划和循环调用工具的 Agent 未实现 定位为确定性诊断流水线
完整 Trace 级联表和耗时直方图 设计稿提出,未完整落地 可作为后续演进
48 个应用全量接入 只有应用清单和部分字段配置 不要说已覆盖 48 个应用

系统架构

技术结构

1
2
3
4
5
6
7
8
9
10
11
12
13
Picocli CLI
↓
Pipeline Orchestrator
↓
Watcher SLO / Watcher Error Panel / Billions Log
↓
AnomalyDetector / ErrorSpikeAnalyzer / ApiTimeWindowDetector
↓
LogAggregator / Baseline Diff
↓
JSON and Markdown artifacts
↓
人工或外部大模型生成根因报告

分层职责

分层 主要类 职责
CLI SloAgentCommand 及各 Command 参数解析、命令路由、基本校验
编排 PipelineOrchestrator 等 串联抓取、检测、采样、聚合、输出
数据采集 SloCrawlerService、SloErrorCrawlerService、BillionsLogCrawlerService 封装内部 HTTP 接口和日志分页
分析 AnomalyDetector、ErrorSpikeAnalyzer、ApiTimeWindowDetector、LogAggregator 纯计算和统计聚合
配置 SloProperties、WebClientConfig URL、Cookie、输出目录和旧配置兼容
输出 OutputWriter JSON、Markdown、运行目录和缓存读取

为什么采用 CLI

这个阶段的核心诉求是快速复用内部平台能力、支持离线批量排查,而不是建设多人在线系统。CLI 的优点是部署成本低、参数明确、容易保存中间产物,也便于和外部分析命令组合。代价是缺少任务调度、权限治理、可视化状态和多人协作能力;如果要产品化,再演进为任务服务更合适。

端到端 SOP

步骤一 接收分析目标

输入:应用服务名、时间范围或基线日、目标日、增量门槛、可选接口过滤条件。

动作:CLI 校验必填参数,并使用 DateParser 兼容 5月18、05-18、2026-05-18 和完整时间格式。

输出:统一的 epoch 秒时间范围。

检查点:结束时间必须晚于开始时间;未指定目标日的批量命令默认取最近七天。

步骤二 拉取 SLO 日指标

输入:服务名和近 N 天范围。

动作:调用 Watcher 应用详情接口,获取 SLB、Service、API Gateway 的错误数、可用性、告警和错误预算信息。

输出:slo_report.json。

检查点:主流程只使用今天零点之前的完整天,避免当天未结束导致错误数不可比。

步骤三 识别异常日期

输入:按天组织的 SLO 数据。

动作:以 Service 错误数为主指标,计算中位数与 MAD。阈值为 median + 3 × MAD;MAD 为 0 时,使用中位数三倍或 1000 兜底。超过阈值的日期按错误数降序取 Top N。

辅助解释:检查 SLB、Service、API Gateway 可用性相对中位数是否下降,告警是否超过三次,Service 错误预算是否耗尽。

输出:anomaly_days.json。

为什么不用固定 3000:不同服务流量级别不同,固定阈值对低流量服务过高、对高流量服务过低;中位数和 MAD 对尖峰更稳健。

步骤四 获取错误面板并识别接口毛刺

输入:异常日期的起止时间。

动作:拉取 30 秒粒度的 GRPC 错误面板。基础流程先计算所有接口每个时间点的错误总量,以全天平均值五倍为毛刺阈值,合并连续超阈值点;再统计毛刺窗口内错误最多的接口,并以接口峰值的 20% 收缩核心区间。

输出:error_data.json 和 error_spikes.json。

检查点:这一算法适合自动发现明显尖刺,但持续性缓慢劣化可能不容易被均值五倍阈值捕获。

步骤五 基线对比和增量接口筛选

输入:基线日和目标日的错误面板。

动作:按接口累计全天错误数,计算 diff = target - baseline 和 ratio = target / baseline;默认要求增量不低于 50 且倍率不低于 1.5。基线为 0、目标大于 0 时视为新增接口。

输出:increased_apis.json,同时统计错误码变化和热点分钟。

为什么同时使用绝对量和倍率:只看倍率会放大小样本噪声,例如 1 增长到 3;只看绝对量又可能忽略小体量接口的显著恶化。

步骤六 识别相对基线的异常窗口

输入:某接口在基线日和目标日的 30 秒时序。

动作:按相同 HH:mm:ss 对齐两天数据,目标点需超过基线同时刻约三倍,且绝对增量大于 2。连续异常点先合并,间隔不足五分钟的窗口再次合并。窗口不足五分钟则向两侧扩展,超过两小时则选择错误增量最密集的子窗口,最后按窗口总错误数取 Top 3。

输出:time_windows.json,包含开始、结束、总错误数、峰值和相对倍率。

价值:用基线同时刻抵消日常流量高峰,比单纯使用目标日全局均值更能识别真正的异常增量。

步骤七 查询和采样日志

输入:服务名、接口路径、返回码和时间窗口。

动作:ApiNameParser 将 /path:-504 拆成路径和返回码,并根据服务选择 server.path 或 _server_path 字段生成查询条件。Billions 单页最多查询 200 条。

全天采样:将一天拆为 48 个窗口,每窗最多 50 条,使样本覆盖全天,而不是集中在最后发生的日志。

异常窗口采样:将窗口再拆为 10 段,总配额 500。活跃段先获得最低配额,剩余配额按错误数分配。

容错:单窗口查询失败时拆成前后两个子窗口重试;子窗口仍失败则跳过,不中断整次分析。

输出:logs.json、baseline_logs.json 或 target_logs.json。

步骤八 多维聚合和基线差异

输入:采样日志集合。

动作:逐条解析日志字段,按错误、入口、下游、实例、区域、业务参数、大小、配置、调用方和时间维度累加。对耗时计算平均值、P95 和最大值,对 trace、用户、实例和下游 IP 去重。

输出:aggregate.json、baseline_aggregate.json、target_aggregate.json 和 diff_result.json。

检查点:聚合结果代表样本分布,不等于完整日志总体;结论应结合样本量、采样策略和基线共同判断。

步骤九 批量复用和结果沉淀

输入:一个基线日和多个目标日。

动作:V2 先选择基线日主要错误接口并预取日志,聚合结果保存到服务固定目录。后续目标日直接复用基线聚合;存在 increased_apis.json 的日期跳过。多个接口通过固定四线程池并行处理。

输出目录:

1
2
3
4
5
6
7
8
output/batch_diffv2/{service}/
├── baseline/{api}/logs.json and aggregate.json
├── {date}/increased_apis.json
├── {date}/{api}/target_logs.json
├── {date}/{api}/target_aggregate.json
├── {date}/{api}/diff_result.json
├── {date}/{api}/time_windows.json
└── {date}/{api}/window_N/logs.json and aggregate.json

核心算法

MAD 异常检测

设每天 Service 错误数为 x1...xn:

1
2
3
median = 错误数中位数
MAD = median(|xi - median|)
threshold = median + 3 × MAD

MAD 比均值和标准差更不容易被少量尖峰拉高,适合历史窗口较短、异常点比例低的情况。当前实现没有乘常见的 1.4826 标准化系数,因此它是业务阈值规则,不应包装成严格的正态统计检验。

边界:只有七天数据时样本较少;周期性强、连续多天异常或整体水平缓慢抬升时,中位数本身也可能被污染。更成熟的版本可引入同星期基线、季节分解或分位数模型。

两阶段毛刺检测

第一阶段对每个 30 秒点汇总所有接口错误量,以全量平均值乘五作为阈值,找连续超阈值窗口。第二阶段在窗口内按接口累计错误数,取 Top N,再以该接口峰值的 20% 找核心区间。

优点:实现简单、解释直接,可以先缩小时间再缩小接口。

不足:平均值会受尖峰影响;固定五倍和 20% 依赖经验;对平台型高基线服务和持续小幅劣化不够敏感。

同时刻基线窗口检测

对于目标日点 t,取基线日相同 HH:mm:ss 的值 b。目标值需满足:

1
2
target > max(3 × b, 2 × baselineAvg)
target - b > 2

这一规则把相对增长和绝对增长结合起来,并用基线平均值避免同时刻基线为零时过度敏感。窗口合并和长度约束则保证后续日志查询既不过碎,也不会覆盖整个全天。

加权采样

先将异常窗口等分,统计每段错误量。每个活跃窗口获得最小配额,剩余配额按错误占比分配:

1
quota_i = minQuota + distributable × errors_i / totalErrors

这样既保留时间覆盖,又把更多查询额度投入错误密集区。需要注意四舍五入可能使最终日志数略偏离目标总量,日志平台实际返回数也可能低于配额。

十六维日志聚合

编号 维度 关键字段 主要判断
1 全局汇总 trace、mid、host、target IP、ts 影响请求、用户、实例范围及重试放大
2 错误类型 status.code、ret、error 超时、不可用、内部错误、资源耗尽
3 错误描述 status.description 新增或集中的具体错误文本
4 入口接口 server.path、_server_path 单接口问题还是公共链路问题
5 下游服务 peer.service、path、ts 哪个下游最可疑,是真慢还是快速失败
6 Gateway 实例 host.name 是否集中在单 Pod
7 下游 IP target.ip、target.zone 单实例还是服务整体问题
8 可用区 target.zone 是否存在区域性集中
9 业务参数 parameters、args 特定 season、ep 或 aid 热点
10 请求体大小 args 中数组长度 大批量请求是否更易失败
11 超时配置 timeout、timeout_quota 配置与实际耗时是否匹配
12 响应体大小 resp.size 大响应是否关联高耗时
13 调用方 caller、caller-path 是否由特定上游请求模式触发
14 异常类型 exception.type、exception.message 代码异常类别及消息
15 客户端区域 ip-region 是否存在地域性问题
16 时间桶趋势 timestamp、zone、peer 分钟级趋势及主导区域和下游

诊断逻辑

  • 错误集中于一个 Gateway Pod:优先排查该实例的 CPU、内存、GC、线程池和连接池。
  • 错误集中于一个下游 IP:优先排查该实例过载、卡死或发布差异。
  • 同一服务多个下游 IP 均匀异常:更可能是下游服务整体容量或公共依赖问题。
  • 多个下游同时以很短耗时失败:考虑上游 deadline 已耗尽、网络或注册发现等公共问题。
  • 某业务 ID 占比显著:排查热点内容、缓存击穿或异常数据。
  • 大请求桶平均耗时明显更高:考虑限制批量大小、拆分调用或调整超时策略。
  • 错误数明显高于独立 trace 数:说明单请求产生多条错误日志,不能把日志条数直接当受影响请求数。

设计稿与实现差异

日志设计稿曾提出单独的 Trace 级联表、耗时分布直方图和客户端版本维度。当前代码实现了全局 trace 去重和重试放大倍率,但没有完整输出“每个 trace 涉及哪些下游、最大最小耗时和级联模式”的表;也没有独立的 ts 直方图和客户端版本表。面试时可以说这些分析启发了现有指标,但不能说全部落地。

工程设计与取舍

为什么先做结构化证据而不是直接把原始日志交给模型

原始日志数量大、字段噪声多,直接送入模型存在上下文成本、隐私风险、采样不稳定和结论不可复现的问题。聚合层把大量日志压缩成稳定指标,模型或人工只需比较分布变化,并且每个结论都能回到 JSON 证据复核。

为什么保留原始数据和中间产物

流水线每一步都落盘,便于断点复查、算法重跑和问题定位。如果只输出最终文本,一旦结论有疑问就无法判断是数据抓取、窗口识别、采样还是解释环节出了问题。

为什么基线使用指定正常日

指定正常日实现简单、口径清楚,适合快速验证。但它可能受到星期、活动和流量结构差异影响。原始需求提出“上周同时段或近七天平均”,当前代码主要使用单日基线。后续可建设同星期、同时间段的多日稳健基线。

为什么线程池固定为四

日志查询属于 I/O 密集任务,并发可以缩短多个接口的处理时间;固定小线程池可以避免对内部日志平台造成过高并发。当前数字是经验配置,没有自适应限流和性能压测数据,面试时不要声称有具体百分比提升。

为什么不是 Web 服务

项目先验证诊断链路和数据口径,CLI 更容易快速迭代,也避免提前引入任务队列、数据库、权限和前端。若覆盖更多团队,应把抓取和分析封装成异步任务,增加状态机、审计、配额和可视化报告。

为什么不把它称为完整 Agent

当前执行路径由代码预先定义,没有“观察、推理、选择工具、再观察”的动态循环,也没有内置大模型服务。它更准确的定位是确定性 SLO 诊断流水线,或 Agent 的工具与数据准备层。外部分析指令可以读取产物生成报告,但决策循环不在 Java 项目内部。

命令能力

命令 使用场景 核心输出
pipeline 不知道异常日期,从近 N 天自动发现 SLO、异常日、毛刺、日志聚合、summary
day 已知异常日期 指定日的错误接口和聚合结果
compare 只需比较两个日期的错误面板 总量、接口、错误码和热点分钟差异
diff 对增量接口深入比较两日日志 基线与目标日志、聚合和差异
batch-diff 一个基线对多个目标日 多日期增量接口和窗口数据
batch-diffv2 需要缓存复用和增量运行 固定服务目录、持久化基线、并行分析
query 已知接口和时间段,做精确验证 指定日志样本及聚合结果

案例复盘

案例一 内容服务接口错误激增

本地产物显示,某内容服务的一个列表接口在目标日错误量从 1986 增长到 9106,绝对增量 7120,约为基线的 4.59 倍。系统首先通过错误面板累计识别该接口,再输出增量列表,后续按接口采集目标日日志并生成聚合结果。

面试讲法应关注方法,不必暴露真实接口名:

  1. 先用绝对增量和倍率共同筛选,排除小样本噪声。
  2. 查看时间窗口,确认是全天持续劣化还是少量尖刺。
  3. 对比错误类型,判断是内部错误、超时还是不可用。
  4. 查看下游服务与 IP 分布,区分单实例与服务级问题。
  5. 查看业务参数和请求大小,判断是否由特定内容或大批量请求触发。
  6. 将结论限定为“最可能原因和下一步验证”,不把样本相关性说成确定因果。

案例二 鉴权服务异常窗口

一个实际窗口聚合包含 8 条样本、8 个独立 trace、7 个 Gateway 实例和 7 个下游 IP,错误全部为 INTERNAL,且全部指向同一个下游服务与接口。平均耗时约 0.2015 秒,P95 约 0.2100 秒;下游 IP 和 Gateway 实例较分散。

基于这些证据,合理判断是:问题集中在特定下游调用,但不像单 Gateway Pod 或单下游 IP 故障,更接近下游服务级行为或统一超时边界。由于样本只有 8 条,不能直接断言根因,需要继续结合基线日相同接口分布、发布记录、下游监控和完整 trace 验证。

这个案例适合展示“如何排除错误假设”:看到某个下游占 100% 不代表某台机器有问题;IP 和调用实例分散恰好削弱了单点故障假设。

证据规模的正确表达

当前仓库 batch_diffv2 目录包含 3 个服务、33 个日期目录和 1600 余份 JSON 产物,说明流程在多个服务和日期上运行过。这个数字可以证明工具经过实际数据验证,但不能等价为线上全量覆盖,也不能替代准确率、召回率或 MTTR 改善数据。

结果与价值

可以确认的结果

  • 将跨 Watcher 和 Billions 的手工排查过程固化为可重复执行的 CLI 流水线。
  • 支持从服务级异常逐层收敛到日期、接口、错误码、时间窗口和日志维度。
  • 通过分时采样降低时间偏置,通过异常密度加权提高关键窗口的样本利用率。
  • 通过基线缓存、并行处理和失败窗口拆分提高批量运行的可用性。
  • 保存原始数据、中间结果和聚合证据,支持复查和二次分析。

不能夸大的结果

  • 没有可靠数据证明 MTTR 降低了多少百分比。
  • 没有建立根因识别准确率、召回率或人工标注评测集。
  • 没有完成 Java 内部的大模型调用和自主 Agent 循环。
  • 当前应用查询字段只对少量服务做了显式映射,不能声称应用清单中的 48 个服务全部接入。

更稳妥的影响表达

我把依赖人工经验的跨平台排查步骤变成了可复用的数据流水线,并把大量原始日志压缩成可解释、可对比的结构化证据。项目已经在多个真实服务和日期上生成分析产物,验证了从异常发现到日志归因的链路可行性;下一阶段需要补齐评测、服务配置化和产品化能力。

已知问题与复盘

当前代码问题

  1. 聚合器已将下游服务和可用区编号调整为 5_下游服务失败 和 8_可用区维度,但差异计算仍读取旧的 4_下游服务失败 和 7_可用区维度,导致部分 diff_result.json 的对应数组为空。
  2. batch-diffv2 跳过已处理日期时,不会先读取旧 summary.json,最后可能用本次列表覆盖历史汇总。
  3. pipeline 命令定义了 --log-limit,但没有传入编排器,内部仍使用固定采样数量。
  4. README 仍描述旧的四个命令、10 维聚合和 Java 内部 AI 分析,与当前代码不一致。
  5. src/test 没有实际测试代码,算法阈值、字段缺失和 HTTP 异常缺少回归保障。
  6. 应用查询字段映射只硬编码了三个服务,原始 CMDB 清单中存在 _server_path、server.path、HTTP 以及无日志等多种情况,尚未配置化。

如果重新做一次

第一步先补测试和数据契约。使用 MockWebServer 固定 Watcher 与 Billions 响应,对日期解析、MAD、窗口合并、采样配额和 16 维聚合做单元测试,并为字段缺失准备样例。

第二步把服务元数据配置化。将应用级别、基线日期、查询字段、协议和日志可用性从硬编码迁移到配置中心或 CMDB,同步解决多服务接入问题。

第三步建设基线与评测。用同星期、同时间段的多日中位数代替单日基线;收集历史故障与人工根因,评估异常检测召回率和根因候选准确率。

第四步产品化。把 CLI 改造成异步任务服务,增加任务状态、限流、重试策略、产物索引、权限控制和报告页面。

第五步再引入 Agent。将 SLO 查询、错误面板、日志查询、聚合和 Trace 检索封装为工具,让模型根据证据缺口决定是否扩大时间范围、切换接口、追加采样或查询发布记录,并设置成本和最大轮次。

面试高频问答

这个项目最难的地方是什么

最难的不是 HTTP 接口串联,而是把模糊的人工判断转换成稳定、可解释的数据口径。比如错误日志多不等于失败请求多,因为一个 trace 可能扇出多条下游错误;看到多个下游超时也不一定是多个服务同时变慢,可能是 deadline 在前置环节耗尽。为此我先设计聚合维度和基线对比,再让上层解释结论,而不是直接让模型阅读原始日志。

为什么选择 MAD

不同服务错误量级差异很大,固定阈值难以通用;均值和标准差又容易被少量尖峰拉高。MAD 基于中位数,对离群点更稳健,计算和解释也简单。当前只有短时间窗口,所以它是实用启发式规则,不是完整的时序预测模型。

如何降低误报

日期层使用相对历史分布;接口层同时要求绝对增量和增长倍率;时间层与基线日相同时刻对齐;日志层再验证错误类型、下游、实例和区域是否呈现一致变化。多层证据交叉验证,比单一阈值更可靠。

如何选择基线

当前工具使用人工指定的正常日,优点是口径明确、实现简单。缺点是可能存在星期和业务活动差异。更成熟的方案是取过去若干个同星期、同时段的中位数,并排除发布、活动和历史故障日。

为什么需要采样而不是全量日志

日志平台有查询和返回限制,全量拉取成本高,而且故障日可能有大量重复日志。分时采样保证全天覆盖,异常窗口加权采样提高关键时间段分辨率。结论中保留样本量和 trace 去重指标,避免把样本分布误认为总体精确比例。

采样会不会遗漏根因

会,因此采样不是一次性定论。系统先通过错误面板确定接口和时间窗口,再在窗口内加权采样;如果证据不足,可以用 query 命令扩大样本或缩小到特定接口和时间段。后续 Agent 化也应围绕“证据不足时追加查询”建立循环。

如何区分下游慢和 deadline 耗尽

主要看实际耗时、错误类型、同 trace 的多下游行为和基线变化。较长耗时集中于单一下游更像下游真慢;多个下游在很短时间内同时 DEADLINE_EXCEEDED,更像剩余 deadline 不足。当前代码能提供下游耗时和 trace 去重,但完整的 Trace 级联表仍是后续改进项。

为什么用 Java 和 Spring Boot

Java 适合团队现有工程体系,Spring Boot 提供配置绑定、依赖注入和 WebClient,Picocli 负责 CLI。选择 WebFlux 主要是为了 WebClient,而当前编排代码仍以阻塞式调用为主;如果追求端到端响应式,需要进一步改造,不能仅凭用了 WebClient 就说系统是全响应式。

并发如何控制

V2 使用固定四线程池并行处理接口。这样可以利用 I/O 等待时间,又限制对内部平台的并发压力。每个任务内部捕获异常,单接口失败不会终止所有接口;最终在 finally 中关闭线程池。后续可以增加信号量、速率限制、指数退避和可配置线程数。

缓存如何保证一致性

当前缓存是文件级复用:只要基线接口的 aggregate.json 存在且可读,就视为命中。它没有把基线日期、聚合版本和查询条件写入缓存键,因此基线变化或指标升级时可能复用旧数据。改进方案是增加 manifest,缓存键包含服务、日期、接口、返回码、采样参数和聚合版本。

为什么输出 JSON 而不是直接生成报告

JSON 便于机器处理、差异比较和重复解释,也保留每一步证据。报告是解释层,可以由不同模板或模型生成;数据层不应和一次性的自然语言结论绑定。

这是 Agent 项目吗

严格来说不是完整 Agent。它有自动选择异常日期、接口和窗口的能力,也能调用多个数据源,但执行流程是固定的,没有大模型驱动的动态工具选择和反思循环。更准确的说法是 SLO 诊断流水线,以及未来 Agent 的工具和证据层。

大模型在项目里做了什么

原始目标包括用 AI 根据聚合表生成根因文档,仓库也保留了分析指令;但当前 Java 代码没有内置大模型客户端。我的表述会区分清楚:已完成结构化证据生成,最终根因解释由外部 Claude 或人工完成。

如何证明项目有效

当前能证明的是链路可运行,并在多个服务和日期生成了接口、窗口、日志与聚合产物。还不能证明根因识别准确率或 MTTR 改善。要做严格验证,需要收集历史故障、人工确认根因和处理结果,形成离线评测集,统计异常检测召回率、候选根因 Top K 命中率和分析耗时。

遇到新服务字段不一致怎么办

当前通过 ApiNameParser 为少量服务映射 server.path 或 _server_path。CMDB 材料表明还有 HTTP、无日志和其他情况。正确演进方式是把查询字段、协议和模板变成服务元数据配置,并在任务启动前做日志可用性探测。

系统如何处理部分失败

错误面板抓取失败会跳过该日期;日志窗口失败会拆半重试,仍失败则跳过该子窗口;单接口处理异常只记录错误,不影响其他接口。代价是最终报告可能使用不完整样本,所以产物中还应增加成功窗口数、失败窗口数和覆盖率,目前这部分还不完整。

为什么保留固定阈值

项目处于工程验证阶段,固定阈值便于解释和调试。阈值集中在命令参数或算法常量中,已经支持部分调整。后续应按服务历史分布自动校准,并记录阈值版本,避免同一结果无法复现。

如果错误是缓慢增长而不是尖刺怎么办

均值五倍的毛刺算法可能漏掉慢性劣化。可以增加滑动窗口斜率、EWMA、分位数带或累计偏差检测;同时 compare 和 diff 命令仍能通过全天基线增量发现持续增长,因此不同命令是互补的。

如何保护敏感信息

Cookie 通过本地配置或环境变量提供,不提交仓库。原始日志和聚合产物包含服务名、IP、业务 ID 等内部信息,只应保存在受控目录;对外报告需要脱敏 IP、用户标识、trace 和内部组织路径。若引入外部模型,还必须限制发送字段并经过合规评估。

你个人承担了什么

基于现有材料可以稳妥表述为:我参与或负责了需求流程抽象、Java 流水线实现、异常和窗口算法、日志指标聚合、批量缓存与采样优化,并基于真实产物迭代分析维度。若实际协作中存在他人负责的模块,面试时应按真实分工进一步收窄,不要把团队成果全部归为个人。

追问时的白板讲法

先画主链路

1
2
3
4
5
6
7
8
服务名
→ SLO 日报
→ MAD 找异常天
→ 错误面板找增量接口
→ 同时刻基线找异常窗口
→ 分时和加权采样日志
→ 16 维聚合
→ 基线差异与根因候选

再讲两个关键取舍

第一个取舍是先用确定性算法缩小范围,再让模型解释,而不是直接把海量日志交给模型。第二个取舍是使用采样和缓存控制日志平台压力,同时保留原始和中间产物保证可复查。

最后主动说明边界

当前没有内置大模型决策循环,也没有准确率和 MTTR 量化;单日基线、固定阈值和服务字段硬编码仍需演进。主动讲清边界,反而能说明我理解工程验证和生产系统之间的差距。

面试前复习清单

十分钟速看

  • 背熟简历一条和三十秒回答。
  • 记住 7 个命令、16 个维度、4 个线程、48 × 50 全天采样和 10 窗口 500 条加权采样。
  • 能解释 MAD、绝对增量与倍率双门槛、同时刻基线窗口。
  • 能讲清一个内容服务增量案例和一个“多 IP 分散,排除单点”的案例。
  • 明确项目不是完整 Agent,Java 内部没有大模型调用。

三十分钟复习

  • 按九步 SOP 从输入讲到产物。
  • 复述日志采样为什么能减少时间偏置。
  • 复述 16 个聚合维度中最关键的五个:错误类型、下游服务、Gateway 实例、下游 IP、可用区。
  • 准备回答基线选择、误报控制、部分失败和缓存一致性。
  • 记住至少三个已知问题及修复方案。

面试表达纪律

  • 不虚构性能提升比例和根因准确率。
  • 不说覆盖了全部 48 个应用。
  • 不说已经实现自主 Agent 或 Java 内置 Claude 调用。
  • 不展示内部 URL、IP、Cookie、日志原文和组织路径。
  • 用“证据支持最可能原因”代替“系统百分百确认根因”。

代码索引

主题 文件
CLI 路由 src/main/java/co/bilibili/slo/cli/SloAgentCommand.java
主流水线 src/main/java/co/bilibili/slo/pipeline/PipelineOrchestrator.java
V2 批量流程 src/main/java/co/bilibili/slo/pipeline/BatchDiffOrchestratorV2.java
异常日期 src/main/java/co/bilibili/slo/analysis/AnomalyDetector.java
毛刺检测 src/main/java/co/bilibili/slo/analysis/ErrorSpikeAnalyzer.java
异常窗口 src/main/java/co/bilibili/slo/analysis/ApiTimeWindowDetector.java
日志采样 src/main/java/co/bilibili/slo/crawler/BillionsLogCrawlerService.java
16 维聚合 src/main/java/co/bilibili/slo/analysis/LogAggregator.java
基线比较 src/main/java/co/bilibili/slo/pipeline/PipelineCompareOrchestrator.java
聚合差异 src/main/java/co/bilibili/slo/pipeline/IncreasedApiOrchestrator.java
查询字段适配 src/main/java/co/bilibili/slo/util/ApiNameParser.java
日期兼容 src/main/java/co/bilibili/slo/util/DateParser.java
配置加载 src/main/java/co/bilibili/slo/config/SloProperties.java

资料依据

  • /Users/bilibili/Desktop/work/slo/自动排查工具.md
  • /Users/bilibili/Desktop/work/slo/自动排查服务抖动工具.md
  • /Users/bilibili/Desktop/work/slo/日志指标聚合表设计.md
  • /Users/bilibili/Desktop/work/slo/cmdb_l0_l1_apps.csv
  • 当前 Java 工程源码、Git 历史和 output 目录中的本地分析产物

本手册依据 2026 年 9 月 18 日可见的本地文件整理。后续代码发生变化时,应优先以代码和最新运行产物更新“已实现”部分。

DRM 收银台不可投屏提示:面试复盘与 SOP

用途:面试前快速回顾 feature/cashier-drm-projection-tip 的需求背景、排查过程、技术方案、工程权衡、验证方法和后续改进。

结论口径基于:项目资料、MR !8325、MR !8377、相关 Git 提交,以及 2026-09-18 的 OGV 当前代码。

重要说明:MR !8325 先建设了分节统计框架和 DRM_STATUS;MR !8377 的核心贡献是在 Pay-Service 中复用该能力,补齐收银台 EP 级 DRM 提示链路。面试时应准确说明贡献边界。


1. 五分钟速记

1.1 一句话背景

历史收银台只读取 Season 级 drm_supported,当 Season 未配置 DRM、但某个可见 EP 单独配置 DRM 时,购买页没有提示不可投屏,而用户购买后会被播放链路实际拦截,造成体验预期不一致和退款进线。

1.2 一句话方案

保留 Season 级 DRM 快速判断,并在未命中时获取 Season 下全部 Section ID,调用 Season-Service 的 countEpisodesBySection(DRM_STATUS) 分桶统计;只要任一 Section 的 bucket=1 数量大于 0,就在收银台 buyDesc 追加版权限制不可投屏提示。

1.3 一句话工程设计

方案复用 Season-Service 已有的新老剧集模型兼容、Redis 分维度缓存和分桶统计能力;Pay-Service 增加两层 3 秒 Caffeine 缓存、两个 200ms RPC deadline、异常降级和空结果不缓存,控制收银台链路的延迟与故障放大。

1.4 一句话边界

该需求只负责“购买前告知”,真正的 DRM 投屏禁止仍由 Player-Gateway 执行;本次采用 Season 粒度的一刀切提示,不做 SKU 到 EP 的精确映射。

1.5 推荐简历表述

1
\item 负责收银台 DRM 不可投屏提示改造,针对仅校验 Season 级 DRM、遗漏单集 DRM 导致购买后投屏失败的问题,复用兼容新老剧集模型的 \texttt{DRM\_STATUS} 分桶统计;打通 Season 全分节查询、DRM 统计与 \texttt{buyDesc} 展示链路,通过 Redis/Caffeine 缓存、RPC 超时及异常降级减少重复计算与跨服务调用,保障收银台可用性。

1.6 面试时不要说错

  • 如果自己的工作范围是 MR !8377,应说“复用并接入 DRM_STATUS”,不要说“从零实现了 DRM_STATUS 统计框架”。
  • DRM_STATUS 由提交 2b2973497b8 引入,属于 MR !8325 的演进。
  • 收银台接入由提交 cd83e9c55fc 完成,缓存由 e97e7643a69 补充,测试由 7507179937b 扩充,最终通过 MR !8377 合入。
  • 旧资料《两种方法链路.md》记录的是中间讨论状态,其中“遍历 EP 已接入”已经过时;最终代码实际走统计方案 hasDrmEpisodeByStat。

2. 面试叙述版本

2.1 30 秒版本

这个需求解决的是收银台 DRM 提示与真实播放限制不一致的问题。原逻辑只判断 Season 级 DRM,但 DRM 也可以单独配置在 EP 上,所以会出现购买时没提示、购买后又不能投屏。我在 Pay-Service 中复用 Season-Service 的 DRM_STATUS 分桶统计,先拿全量 Section ID,再批量判断是否存在 DRM EP,命中后补充 buyDesc。同时用短 TTL 本地缓存、200ms 超时、异常降级和空结果不缓存来保护收银台可用性。

2.2 1 分钟版本

线上问题发生在一个 Season 本身没有配置 DRM、但正片 EP 单独开启 DRM 的场景。Player-Gateway 会按 Season 或 EP 维度执行投屏限制,但收银台只读取 Season 的 drm_supported,因此用户在购买前看不到不可投屏提示。

排查后我没有让 Pay-Service 拉回所有 EP 明细逐条判断,而是复用了 Season-Service 已有的分节统计能力。Pay 先通过 profile 获取 Season 下全部 Section ID,再调用 countEpisodesBySection,只请求 DRM_STATUS 维度;统计结果中 bucket=1 表示有 DRM 的 EP,任一 Section 的该桶计数大于 0 就追加提示。为避免给核心收银台链路增加明显负担,我给两个 RPC 设置了 200ms deadline,并增加 3 秒 Caffeine 缓存;异常或空数据按无 DRM 降级,保证支付信息正常返回。

2.3 3 分钟版本

需求的根因不是投屏拦截失效,而是“告知链路”和“执行链路”读取 DRM 的粒度不同。DRM 可以配置在 Season 和 Episode 两个层级:Season 级属性进入 Season 的 configAttrs,EP 级属性进入 Episode 的动态规则属性。Player-Gateway 判断的是 Season DRM 或 EP DRM,而 Pay-Service 的 buildBuyDesc 历史上只执行 checkDrm(season),所以遗漏了单集配置。

我先梳理了数据生产、统计和消费链路,再比较两种方案。方案一是 Pay 拉回全量 EP Proto 并遍历 configAttrs,优点是直观,但数据量大、重复组装多,而且会把剧集模型兼容逻辑带到 Pay。方案二是复用 Season-Service 的 countEpisodesBySection:它按 (sectionId, dimension) 缓存分桶结果,内部通过 SPI Counter 计算不同维度,并已处理新老剧集模型。最终选择方案二,让领域数据仍由 Season-Service 负责。

在 Pay 中,buildBuyDesc 先判断 Season DRM;由于 Java || 短路,Season 已开启 DRM 时不会再访问 EP 统计。未命中才通过 profile 拿所有 Section ID,然后批量请求 DRM_STATUS。bucket=1 是开启 DRM,bucket=0 是未开启;任一 bucket=1 的计数大于 0 就给所有 SKU 追加“本片由于版权方限制暂不支持投屏”。

可用性方面,两次下游 RPC 都限制为 200ms,并在 RPC 封装和业务方法两层兜底;异常时选择 fail-open,即不阻塞收银台,但可能暂时漏提示。Pay 对 seasonId -> sectionIds 和 sectionIds -> DRM buckets 分别做 3 秒 Caffeine 缓存,只缓存非空结果;Season-Service 还按统计维度使用 Redis 缓存。测试覆盖有 DRM、无 DRM、空 Section、空统计和 RPC 异常,并用 UAT 正反例验证 buyDesc。

这次工作的关键不是一个布尔判断,而是识别跨服务口径不一致,复用领域侧统计能力,在正确性、性能和核心交易链路可用性之间做取舍。


3. STAR 复盘

Situation:背景

  • 某影片只在正片 EP 上开启 DRM,Season 没有开启 DRM。
  • 收银台没有展示不可投屏提示,用户购买后实际投屏失败。
  • 播放侧限制是正确的,问题在购买前信息披露不足。
  • 结果是用户预期落差、客服进线和退款风险。

Task:目标

  • Season 开启 DRM 时保持原提示能力。
  • Season 未开启,但任一“当前统计口径内的可见 EP”开启 DRM 时,也展示不可投屏提示。
  • 不显著增加收银台耗时,不因 Season-Service 抖动阻断收银台。
  • 兼容新老剧集数据模型,并控制跨服务数据传输量。

Action:行动

  1. 全项目检索 drm_supported、checkDrm、configAttrs、DRM_STATUS,确认生产侧和消费侧的口径。
  2. 定位根因为 Pay 只调用 checkDrm(season),Player 则同时判断 Season 与 EP。
  3. 对比“拉 EP 明细遍历”和“复用分节统计”两种实现。
  4. 选择 profile + countEpisodesBySection(DRM_STATUS),复用 Season-Service 的统计与缓存框架。
  5. 在 buildBuyDesc 中用 checkDrm(season) || hasDrmEpisodeByStat(seasonId) 汇总判断。
  6. 给两个 RPC 设置 200ms deadline,并在异常时返回空结果。
  7. 增加两类 3 秒 Caffeine 缓存,仅缓存非空结果。
  8. 补充单测并通过 UAT 的 Season DRM、EP DRM、完全无 DRM 三类数据验证。

Result:结果

  • 补齐了 EP 级 DRM 的购买前提示,收银台展示与真实投屏限制对齐。
  • 保留原 Season DRM 逻辑,且利用短路避免已知 DRM 场景的额外调用。
  • 通过轻量分桶响应、Redis 缓存和 Pay 本地缓存降低重复计算与 RPC 压力。
  • 通过超时与 fail-open 设计避免下游故障阻断收银台。
  • UAT 记录中正反例均符合预期;短时间重复调用命中本地缓存,不再重复请求统计 RPC。

4. 业务概念和数据来源

4.1 实体关系

1
2
3
Season(剧集)
└─ Section(分节:正片、预告、花絮等)
└─ Episode / EP(单集)

4.2 DRM 可以配置在两个层级

层级 规则名 规则 ID 主要存储 消费形态
Season drm_supported 12 t_season_rule_attr Season Proto configAttrs
Episode drm_supported 14 t_episode_rule_attr Episode BO/Proto 动态规则属性

配置映射可在 app/season/servant/src/main/resources/common_hot_param.yml 中看到:Season 的 drm_supported 对应 12,Episode 对应 14。

4.3 DRM_STATUS 的枚举和桶值

值 名称 含义
dimension 2 DRM_STATUS 按 EP 是否开启 DRM 统计
bucket 1 BUCKET_DRM_ENABLED EP 的 drm_supported=true
bucket 0 BUCKET_DRM_DISABLED EP 未开启 DRM、属性缺失或 BO 为空

示例:

1
2
3
4
5
6
7
8
{
"12345": {
"2": {
"1": 3,
"0": 7
}
}
}

含义是 Section 12345 在 DRM_STATUS 维度下有 3 个 DRM EP、7 个非 DRM EP。收银台只关心 bucket 1 > 0,不需要 EP 明细。


5. 根因定位 SOP

Step 1:先区分“提示失败”还是“限制失败”

用户现象是“购买时没有提示,购买后不能投屏”。这说明要分别检查:

  • 收银台是否正确披露限制;
  • 播放链路是否正确执行限制。

Player-Gateway 中 PlayurlDrmService.checkDrm 同时判断 Season 和 EP,投屏请求会被 isForbiddenDrm 拦截。因此不是实际 DRM 限制失效,而是 Pay 的提示条件少了一维。

Step 2:从展示字段反查入口

链路入口是 PayService.seasonPayConfig,最终调用 buildBuyDesc 设置 SeasonPayConfigReply.buyDesc:

1
2
3
4
5
seasonPayConfig
└─ buildBuyDesc(payElement, payType, seasonId)
├─ getSeasonInfo
├─ checkDrm(season)
└─ 命中时追加“本片由于版权方限制暂不支持投屏”

改造前只有 checkDrm(season),它只读取 Season Proto 的 configAttrs["drm_supported"]。

Step 3:确认属性生产链路

1
2
3
4
5
6
7
8
9
运营后台配置 DRM
├─ Season 规则:t_season_rule_attr,规则 ID 12
└─ Episode 规则:t_episode_rule_attr,规则 ID 14
↓
Season-Servant / Season-Service 恢复动态属性
↓
Season / Episode BO 与 Proto 中的 drm_supported
↓
Pay、Player 等消费方分别读取

Step 4:对比不同消费方口径

消费方 Season DRM EP DRM 业务作用
改造前 Pay-Service 是 否 生成购买说明
Player-Gateway 是 是 真正限制投屏/播放场景
改造后 Pay-Service 是 是,通过统计 购买前提示

Step 5:形成根因结论

根因是跨服务消费口径不一致:Player 按 Season OR EP 判断,Pay 只按 Season 判断。EP 级配置不是脏数据,而是合法配置方式,因此修复点应是扩展 Pay 的判断范围,而不是修改 Player 或强制运营只配 Season。


6. 方案选型

6.1 方案 A:拉取所有 EP 明细后遍历

1
2
3
4
5
Pay
→ listBySeasonId / listBySection
→ 拉回所有 EP Proto
→ 遍历 configAttrs["drm_supported"]
→ 任一为 true 则提示

优点:

  • 逻辑直观,与 Player 读取 EP configAttrs 的方式接近。
  • 理论上可以进一步定位具体 EP。

缺点:

  • 为一个布尔结论传输全部 EP 明细,响应体和组装成本偏大。
  • Pay 需要理解 EP 数据和新老模型差异,领域边界变差。
  • 全量遍历的计算和序列化成本会重复发生。
  • 统计口径容易与 Season-Service 的可见性过滤不一致。

6.2 方案 B:复用 Season-Service 分桶统计,最终采用

1
2
3
4
Pay
→ profile(seasonId) 获取全部 sectionId
→ countEpisodesBySection(sectionIds, DRM_STATUS)
→ 判断任一 Section 的 bucket 1 是否大于 0

优点:

  • 只传 Section ID 和桶计数,网络数据轻量。
  • 新老剧集模型分流封装在 Season-Service 内。
  • 复用 (sectionId, dimension) Redis 缓存,避免每次重新扫描 EP。
  • 新增统计维度可通过 Counter SPI 扩展,Pay 不接触底层数据模型。
  • 可见性口径集中在领域服务,减少多服务各写一套判断。

代价:

  • 首次查询需要 profile 和统计两个 RPC。
  • 结果受 Redis TTL 和缓存失效及时性影响。
  • 只有聚合结果,无法直接知道具体哪个 EP 命中。
  • 当前 Pay 没有对超过 50 个 Section 的请求分批,存在边界风险。

6.3 为什么最终选统计方案

需求只需要回答“是否存在至少一个 DRM EP”,而不需要 EP 列表。用聚合接口返回聚合结论更符合最小数据原则,也把新老模型兼容、可见性过滤、缓存和统计规则留在 Season 领域内。Pay 只消费稳定的统计契约。

6.4 需求做了什么业务简化

最终采用的是 Season 粒度提示:Season 或任一可见 EP 开启 DRM,就给该 Season 的所有 SKU 展示不可投屏提示。

它没有处理:

  • SKU 只购买某个 EP 时的精确判断;
  • 同一分节部分 EP 可投、部分 EP 不可投时的差异文案;
  • 只针对正片而排除预告、花絮等 Section 类型。

这是偏保守的用户告知策略,优先避免“买后才发现不能投屏”,代价是少数 SKU 可能被扩大提示。


7. 整体架构

flowchart LR
    U[用户打开收银台] --> P[Pay-Service seasonPayConfig]
    P --> B[buildBuyDesc]
    B --> S{Season drm_supported?}
    S -->|是| T[追加不可投屏提示]
    S -->|否| C1{sectionIdsCache 命中?}
    C1 -->|否| PR[SeasonGrpc.profile]
    PR --> IDS[全部 sectionId]
    C1 -->|是| IDS
    IDS --> C2{drmStatCache 命中?}
    C2 -->|否| RPC[EpisodeGrpc.countEpisodesBySection]
    RPC --> ES[EpisodeStatService]
    ES --> RC{Redis 按 section+dimension 命中?}
    RC -->|是| BK[返回 bucket 计数]
    RC -->|否| BS[新老模型回源并分桶]
    BS --> RSET[写 Redis]
    RSET --> BK
    C2 -->|是| BK
    BK --> D{任一 bucket 1 > 0?}
    D -->|是| T
    D -->|否| N[保持原 buyDesc]
    PR -.超时/异常.-> N
    RPC -.超时/异常.-> N

分层职责:

层 输入 处理 输出
Pay 业务层 seasonId、支付配置 汇总 Season/EP DRM,组装文案 buyDesc
Pay RPC 封装 seasonId 或 Section ID 列表 本地缓存、deadline、响应转换 Section ID / DRM 桶
Season RPC Controller Section ID、统计维度 参数校验、调用统计服务、组装 Proto Section 统计
EpisodeStatService Section ID、dimension Redis 读取、新老模型回源、分桶 嵌套 Map
DrmStatusCounter 单个 EP BO 读取 drm_supported bucket 0/1
Player-Gateway 播放/投屏请求 判断实际 DRM 和场景限制 允许或拒绝投屏

8. DRM_STATUS 是如何统计的

8.1 通用统计框架

MR !8325 在 Season-Service 中建立了分节维度统计框架。核心不是为 DRM 写一个专用 RPC,而是提供通用结构:

1
2
3
4
5
6
7
8
9
CountEpisodesBySectionReq
- sectionIds
- dimensions
- seasonType

CountEpisodesBySectionReply
sectionId
└─ dimensionCode
└─ bucket -> count

扩展点是 EpisodeStatCounter:

  • dimensionCode():声明维度码;
  • bucketOf(oldBO):老模型 EP 映射到哪个桶;
  • bucketOfNew(newBO):新模型 EP 映射到哪个桶;
  • 返回 null 表示该 EP 不参与统计。

EpisodeStatRegistry 在 Spring 启动时收集全部 Counter;同一个维度注册两次会直接启动失败,防止静默覆盖。

8.2 RPC 入口

EpisodeRPCController.countEpisodesBySection 完成:

  1. 校验 Section ID 非空且最多 50 个;
  2. 校验 seasonType;
  3. 校验维度不为 UNSPECIFIED/UNRECOGNIZED;
  4. 把枚举转成维度码;
  5. 调用 EpisodeStatService.countBySection;
  6. 组装 section -> dimension -> buckets 的回复。

8.3 Redis 缓存设计

缓存不是“一个 Section 一份所有统计”,而是每个 (sectionId, dimensionCode) 一条独立 key:

1
season:grpc:ep:section:stat:v1:{sectionId}:{dimensionCode}

这样做的意义:

  • 调用方只请求需要的维度;
  • 某个维度新增或失效,不必重算其他维度;
  • 避免大对象缓存和无关字段耦合;
  • Counter 可以独立演进。

当前配置 cache_canary.yml 的 expireTime 是 300 秒;代码兜底 TTL 是 600 秒。因此不能笼统地说“Redis 固定缓存 10 分钟”,准确说法是“热配置当前 5 分钟,未配置时兜底 10 分钟”。

8.4 缓存未命中后的回源

flowchart TD
    A[countBySection] --> B[按 dimension 去重并校验已注册]
    B --> C[按 section 选择缓存适配器]
    C --> D{Redis 命中?}
    D -->|是| R[返回 bucket map]
    D -->|否| E[CanaryingService 批量判断新老模型]
    E --> O[老模型 SectionEpisodeBO]
    E --> N[新模型轻量 DAO 链路]
    O --> OC[counter.bucketOf]
    N --> NF[过滤 BILIBILI 有版权且已上架 EP]
    NF --> NC[counter.bucketOfNew]
    OC --> M[合并 bucket count]
    NC --> M
    M --> W[写回 Redis]
    W --> R

老模型路径通过 EpisodeService.sectionEpisodesBO 获取分节 EP,并使用 type=0 的 BILIBILI 主站口径。新模型路径不走重型的全量组装,而是:

  1. selectEpisodePageUseBriefBySectionIds 获取 Section 下的轻量 EP 信息;
  2. 保留 BILIBILI 渠道有版权且已上架的 EP;
  3. queryEpisodeInfoByEpisodeIdList 批量取 Episode BO;
  4. 补充需要的规则配置;
  5. 调用 Counter 分桶。

8.5 DrmStatusCounter 的判断

老模型:

1
2
3
4
EpisodeInfoBO.configAttrs
→ 找 attrName == "drm_supported"
→ attrValue equalsIgnoreCase "true"
→ bucket 1,否则 bucket 0

新模型:

1
2
3
4
EpisodeInfoBO.episodeOperationalRules.ruleAttrs
→ 找 attrName == "drm_supported"
→ attrValue equalsIgnoreCase "true"
→ bucket 1,否则 bucket 0

关键点:

  • 属性缺失默认归入 bucket 0;
  • 值用大小写不敏感的字符串 true 判断;
  • 新老模型最终输出同一套桶语义;
  • 新模型只统计 BILIBILI 有版权且上架的 EP,更贴近收银台当前可购买/观看内容。

9. 收银台展示链路

9.1 入口

1
2
3
PayService.seasonPayConfig
→ 根据支付配置构造 SeasonPayConfigReply
→ setBuyDesc(buildBuyDesc(...))

OGV Java 仓库内确认的是 buyDesc 的生成;没有在当前仓库中找到明确的客户端 UI 消费实现,客户端如何排版展示属于仓库外边界。

9.2 核心条件

1
boolean isDrm = checkDrm(season) || hasDrmEpisodeByStat(seasonId);

这里有一个重要性能细节:Java || 短路。当 Season DRM 已经为 true 时,不会调用 EP 统计链路,因此原有明确命中场景没有新增两个 RPC。

9.3 EP 统计消费逻辑

1
2
3
4
5
6
7
hasDrmEpisodeByStat(seasonId)
1. getAllSectionIdsBySeasonId
└─ SeasonGrpc.profile,取 ProfileInfoProto.sectionList.sectionId
2. countDrmBySections(sectionIds)
└─ EpisodeGrpc.countEpisodesBySection,只请求 DRM_STATUS
3. 遍历各 Section 的 bucket map
└─ 任一 buckets.getOrDefault(1, 0) > 0,返回 true

9.4 文案组装

命中后在原购买说明后追加:

1
本片由于版权方限制暂不支持投屏

9.5 条件矩阵

Season DRM 存在可见 DRM EP RPC 正常 最终是否提示
是 任意 任意 是,Season 判断短路
否 是 是 是
否 否 是 否
否 未知 否 否,fail-open
解析 Season DRM 异常 是 是 是,继续走 EP 统计
解析 Season DRM 异常 未知 否 否

9.6 为什么覆盖全部 Section

profile 返回全部 Section,代码没有按正片、预告、花絮类型过滤。这样可以避免某个合法 DRM EP 因分类差异被漏掉,但也意味着非正片 Section 的 DRM 可能让整季收银台提示不可投屏。

代码注释中曾出现“正片分节”,但实际实现是“全部分节”。面试时以代码行为为准。


10. 两级缓存和性能设计

10.1 缓存结构

所在服务 Key Value TTL 最大条数 空结果缓存
Pay Caffeine seasonId 全部 sectionIds 3 秒 10,000 否
Pay Caffeine 有序 sectionIds 列表 Section 到 DRM 桶映射 3 秒 10,000 否
Season Redis sectionId + dimensionCode bucket -> count 当前 300 秒,兜底 600 秒 由 Redis 管理 由缓存适配器策略决定

10.2 “Redis 分维度缓存”是什么意思

DRM_STATUS 不是在 Pay 中把 Redis 按业务维度分库,而是 Season-Service 为每个 Section 的每个统计维度生成独立 key。例如同一个 Section 的支付状态和 DRM 状态分别缓存,Pay 只请求 DRM 维度。

10.3 “Pay Caffeine 本地缓存”是什么意思

每个 Pay-Service 实例在进程内保存最近 3 秒的:

  • Season 到全部 Section ID 的映射;
  • 一组 Section ID 到 DRM 分桶结果的映射。

同一个实例短时间内重复打开同一收银台时,可以跳过 profile 和统计 RPC。它不是分布式缓存,不同 Pod 之间不共享。

10.4 为什么 TTL 只有 3 秒

  • 收银台访问可能在短时间内形成重复请求,3 秒足以削减瞬时重复 RPC。
  • Section 和 DRM 配置可能被运营修改,短 TTL 降低本地陈旧窗口。
  • Season-Service 已有更长的 Redis 缓存,Pay 本地层不应再次叠加长时间陈旧。

10.5 为什么不缓存空结果

空结果既可能代表“业务上确实没有”,也可能来自超时、短暂故障或下游空回复。若缓存空结果,会把一次瞬时故障放大为 3 秒内持续漏提示。不缓存使下一次请求可以立即重试回源。

10.6 缓存 Key 的细节

第二层 Caffeine 使用 List<Integer> 作为 Key,按列表内容和顺序匹配。写入时使用 List.copyOf(sectionIds) 防止调用方后续修改 Key。相同集合但顺序不同会形成不同 Key;当前 profile 顺序通常稳定,因此可接受,但更稳健的做法是去重并排序后再构造规范化 Key。


11. 超时、降级和可用性权衡

11.1 超时设计

  • SeasonGrpc.profile:200ms deadline;
  • EpisodeGrpc.countEpisodesBySection:200ms deadline;
  • Season-Service 统计内部还存在缓存操作超时控制;
  • 当前 RedisAdapter 的 pipeline 实现可见硬编码 300ms 超时,而热配置 opTimeout 当前是 100ms,说明内部不同适配层的超时边界并不完全统一;
  • 对 Pay 而言,外层 200ms gRPC deadline 是最终保护边界之一。

两个 RPC 是串行的,最坏不能简单理解为“只增加 200ms”;若第一个接近 deadline 后第二个继续调用,理论上新增等待可接近两段 deadline,再加本地处理。缓存和 Season DRM 短路因此很重要。

11.2 降级路径

1
2
3
4
5
6
7
8
9
profile 异常/超时
→ 返回空 sectionIds
→ hasDrmEpisodeByStat 返回 false
→ 正常返回收银台,但不追加提示

countEpisodesBySection 异常/超时
→ 返回空 Map
→ hasDrmEpisodeByStat 返回 false
→ 正常返回收银台,但不追加提示

业务方法外层还有一次 try/catch,即使 RPC 封装未来抛出未预期异常,也会兜底为 false。

11.3 为什么选择 fail-open

这是一个提示增强,不是支付资格或版权鉴权本身。若选择 fail-close,下游统计故障会让用户无法正常打开收银台或产生误报,影响核心交易链路。最终选择:

  • 优先保证收银台可用;
  • 接受下游故障期间可能漏提示;
  • 真正投屏请求仍由 Player-Gateway 阻断,不会绕过版权限制。

11.4 如何向面试官解释权衡

这是“合规执行”和“用户告知”的分层。Player 是强一致的执行点,Pay 是尽力而为的提前提示点。Pay 的 fail-open 会降低异常期间的提示完整性,但不会降低实际 DRM 保护;同时避免提示依赖反向拖垮交易入口。


12. 真正的投屏限制在哪里

本需求没有修改实际投屏鉴权。Player-Gateway 的核心行为是:

  1. checkDrm(episode, season) 返回 Season DRM OR EP DRM;
  2. 投屏请求 urlType == PROJ_TYPE 时,DRM 场景被禁止;
  3. 使用新错误码时返回版权限制不可投屏错误;
  4. 播放页设置项也会根据 isDrm 禁用投屏入口。

因此两个链路的职责是:

链路 时机 作用 故障策略
Pay buyDesc 购买前 告知用户限制 fail-open,优先收银台可用
Player DRM 播放/投屏时 实际执行版权限制 强制禁止投屏

面试时可以强调:需求价值是让“购买前承诺”与“购买后能力”一致,而不是新建 DRM 鉴权。


13. 测试与验证 SOP

13.1 单元测试范围

PayServiceDrmTest 覆盖:

  • 某个 Section 的 bucket 1 大于 0,返回 true;
  • 所有 Section 都没有 DRM,返回 false;
  • Section ID 为空,返回 false;
  • 统计结果为空,返回 false;
  • RPC 封装抛异常,返回 false。

SeasonServiceTest 覆盖:

  • profile 正确提取全部 Section ID;
  • 第二次调用命中本地缓存;
  • 空 Section 不缓存;
  • 正确提取 DRM_STATUS 桶;
  • 统计第二次调用命中本地缓存;
  • 空入参不发 RPC;
  • 回复缺少 DRM 维度时跳过;
  • RPC 错误返回空集合。

13.2 UAT 场景矩阵

场景 测试数据记录 预期
Season 有 DRM Season 34192 buyDesc 有不可投屏提示
Season 无 DRM,某 EP 有 DRM Season 38603,EP 147904 buyDesc 有不可投屏提示
Season 和 EP 都无 DRM Season 266/267 buyDesc 无不可投屏提示

13.3 联调步骤

  1. 准备有有效收银台配置的 Season。
  2. 确认 Season drm_supported=false/缺失。
  3. 给一个可见、已上架、有版权的 EP 配置 drm_supported=true。
  4. 确认该 EP 所属 Section 出现在 profile.sectionList。
  5. 直接调 countEpisodesBySection,确认该 Section 的 DRM_STATUS.bucket[1] > 0。
  6. 调 Pay seasonPayConfig,检查 buyDesc。
  7. 删除 EP DRM 或换无 DRM 反例,确认不误报。
  8. 连续调用两次,观察第二次是否命中本地缓存、下游 RPC 是否减少。
  9. 模拟 Season RPC 超时,确认收银台仍正常返回且没有空结果缓存放大。

13.4 当 buyDesc 没出现提示时的排查顺序

1
2
3
4
5
6
7
8
9
10
11
12
1. Pay 是否拿到有效 payInfo / payElement?
2. buildBuyDesc 是否执行?
3. Season 级 checkDrm 结果是什么?
4. profile 是否返回全部 Section ID?
5. Section 数量是否超过 50?
6. countEpisodesBySection 是否返回 DRM_STATUS?
7. bucket 1 是否大于 0?
8. EP 是否满足 BILIBILI 有版权且上架口径?
9. Redis 是否存在旧统计?
10. t_episode_rule_attr 变更后统计缓存是否已失效?
11. Pay 是否命中 3 秒本地缓存?
12. 客户端是否实际展示了服务端 buyDesc?

13.5 本地联调踩坑记录

现象 原因 处理
IDE 启动时数据源连接失败 直接跑 Main 缺少 Gradle/local 配置注入 补齐 profile、APP_ID 和必要 VM 参数
Kryo 反射 java.util 失败 JDK 17 模块封装 增加对应 --add-opens 启动参数
偶发 -503 / GOAWAY 机器休眠后 gRPC 长连接失效 重启服务重建连接,并确认不是业务空数据
很难找到完整测试 Season 需要同时满足付费、Season 无 DRM、EP 有 DRM、Profile 正常 在 UAT 构造最小测试数据,验证后清理

14. Git 与 MR 时间线

14.1 MR !8325:统计能力建设

链接:https://git.bilibili.co/ogv/ogv/-/merge_requests/8325

时间 提交 作用
2026-05-14 8f36e7cffc7 新增 countEpisodesBySection 通用轻量统计接口、Counter SPI、Registry、统计服务、Redis key,并切换 Coupon 消费
2026-05-14 451b021ec41 根据 Review 完善新老模型分流、DTS、seasonType 校验和文档
2026-05-19 2b2973497b8 新增 DrmStatusCounter 和 DRM_STATUS 维度,完善新模型轻量回源
2026-05-19 28aab21628d 新模型路径补 BILIBILI 渠道上架/版权过滤,并修正 DRM 统计重复问题
2026-05-20 c4c8d85ef22 在统计校验工具中加入 DRM 维度新老路径对比

关键回答:

  • “DRM 分桶统计是哪次分支提交达到的?”

    • 通用分桶框架从 8f36e7cffc7 开始具备。
    • DRM 专属统计由 2b2973497b8 引入。
    • 28aab21628d 补齐了新模型 BILIBILI 可见性口径并修正细节。
  • “DRM_STATUS 是哪次代码变更实现的?”

    • 明确是 2b2973497b8。

14.2 MR !8377:收银台消费统计

链接:https://git.bilibili.co/ogv/ogv/-/merge_requests/8377

评论/过程记录:https://git.bilibili.co/ogv/ogv/-/merge_requests/8377#note_19512761

时间 提交 作用
2026-06-10 cd83e9c55fc Pay 接入 profile + DRM_STATUS,扩展 buildBuyDesc,增加业务测试
2026-06-11 e97e7643a69 增加 Section ID 与 DRM 统计两类 3 秒 Caffeine 缓存,只缓存非空结果
2026-06-29 7507179937b 补充 SeasonServiceTest 等单测,提高覆盖率
2026-06-29 274afe7b53e feature/cashier-drm-projection-tip 通过 MR !8377 合入 master

15. 交付时设计与当前 master 的差异

15.1 不变的核心链路

  • seasonPayConfig -> buildBuyDesc;
  • Season DRM 短路;
  • profile 获取全部 Section ID;
  • countEpisodesBySection(DRM_STATUS);
  • 任一 bucket 1 大于 0 即提示;
  • 两个 200ms deadline;
  • 两类 3 秒 Caffeine 缓存;
  • 异常返回空结果并 fail-open。

15.2 当前 master 的统计缓存版本

统计 Redis key 当前是:

1
season:grpc:ep:section:stat:v1:{sectionId}:{dimensionCode}

资料中的 v0 和“约 600 秒”属于旧版本描述。当前热配置 TTL 为 300 秒,只有配置缺失时才回退到 600 秒。

15.3 当前 master 的缓存失效能力

当前代码已有 EpisodeStatCacheService,并在 EP 基础信息、可见性变更等 DTS 链路中按 Section 精确清理全部已注册统计维度。但 EP 规则属性变更链路仍有一个值得关注的缺口:

  • EpisodeRuleAttrDTSHandler 收到 t_episode_rule_attr 变化后,调用 episodeDao.clearEpCache(epId, null, null);
  • EpisodeDao.clearEpCache 只有拿到 sectionId 才会删除统计 key;
  • 该 Handler 会清 EP 信息和运营规则缓存,但当前没有调用 EpisodeStatCacheService.evictByEpisodeIds;
  • 因此单独修改 EP 的 drm_supported 后,DRM_STATUS Redis 统计可能等待 TTL 才更新。

这是当前代码复查出的风险,不代表当时 MR 的需求验收失败。面试中可以把它作为“如果继续优化,我会完善基于 EP ID 反查 Section 并精确失效 DRM 统计缓存”的回答。


16. 已知边界和改进方向

16.1 超过 50 个 Section

Season-Service 的 checkBatchParam 限制一次最多 50 个 Section。Pay 当前把 profile 返回的全部 Section 一次性交给 countEpisodesBySection,没有分片。

结果:若 Season 超过 50 个 Section,Season-Service 返回参数错误,Pay 捕获后降级为空统计;如果 Season 自身未开 DRM,就可能漏提示。

改进:

  • 在 Pay 将 Section ID 去重后按 50 分片;
  • 可串行短路,也可受控并发;
  • 任一分片出现 bucket 1 即停止后续查询;
  • 明确“部分分片失败时”选择保守提示还是继续 fail-open。

16.2 DRM 规则属性变更后的缓存一致性

改进:在 EpisodeRuleAttrDTSHandler 中注入 EpisodeStatCacheService,根据 EP ID 反查 Section ID,并删除该 Section 的统计 key;或者只清理 DRM_STATUS 维度,进一步缩小影响。

16.3 SKU 到 EP 的精确映射

当前任一 EP 有 DRM 就给整季所有 SKU 提示。精确版可以:

  • 单 EP SKU:只判断目标 EP;
  • Section SKU:全部 DRM 时提示“不可投屏”,部分 DRM 时提示“部分内容不可投屏”;
  • Season SKU:沿用任一 DRM 即提示;
  • 文案和缓存 Key 中纳入 SKU/权益范围。

但精确版需要支付商品模型提供稳定的内容范围映射,不能只在 Pay 临时推断。

16.4 可观测性

当前日志能看到 RPC 异常,但可以进一步增加:

  • Season DRM 命中率;
  • EP DRM 统计命中率;
  • profile、统计 RPC、本地缓存命中率和 P99;
  • 降级次数及 Season ID;
  • Section 超 50 的计数;
  • 统计结果空与真实无 DRM 的区分指标。

16.5 降级策略可配置

当前固定 fail-open。若业务认为漏提示带来的退款风险高于误提示,可以考虑:

  • 热配置选择 fail-open/fail-conservative;
  • 只在统计超时且存在付费 SKU 时使用保守提示;
  • 使用最近一次成功值作为 stale cache;
  • 配合熔断和后台校验任务,而不是简单把异常当 false。

16.6 规范化本地缓存 Key

对 Section ID 做过滤、去重和排序,再作为 Caffeine Key,避免同一集合因顺序不同重复缓存,也能提前过滤无效 ID。


17. 面试高频问题与参考回答

Q1:这个需求最核心的技术问题是什么?

不是追加一行文案,而是 Pay 和 Player 对 DRM 的判断粒度不一致。Player 同时读取 Season 和 EP,Pay 只读 Season。要补齐 EP 维度,同时不能让聚合查询拖慢核心收银台链路。

Q2:为什么不直接复用 Player 的接口?

Player 的职责是针对具体播放请求和设备场景做鉴权,需要具体 EP、CID、请求场景等上下文。收银台要回答的是 Season 下是否存在 DRM 内容,属于内容聚合查询。直接调用 Player 会造成职责倒置和额外场景参数依赖。

Q3:为什么不用数据库直接查 t_episode_rule_attr?

Pay 不应跨领域直连 Season 数据表。直接查表会绕过新老模型、可见性、版权和缓存口径,也增加数据库压力。通过 Season-Service 的领域接口能保持数据所有权和统计语义一致。

Q4:为什么需要先调 profile?

统计接口的输入是 Section ID,不是 Season ID。profile 的 Section Proto 含 sectionId,可以一次拿到 Season 下全部分节。现有 Episode 列表 Proto 在当时依赖中不适合稳定提取完整 Section ID。

Q5:为什么不让统计接口直接支持 Season ID?

已有接口按 Section 统计并被其他业务复用,直接扩协议会扩大改动和缓存语义。当前需求可以通过已有 profile 组合完成,符合最小改造。若 Season 聚合成为高频通用需求,再考虑增加领域侧聚合接口。

Q6:DRM_STATUS 是你实现的吗?

如果按 MR !8377 的贡献边界回答:不是从零实现。它在 MR !8325 的提交 2b2973497b8 中加入,我负责在收银台链路中复用该统计能力,并补充 RPC 封装、本地缓存、降级、测试和展示判断。

Q7:为什么选择分桶而不是返回布尔值?

统计框架是通用的,bucket -> count 不只回答“有没有”,还保留了数量分布,可被其他业务复用。Pay 只取 bucket 1 是否大于 0。若专门返回布尔值,会降低接口通用性并增加专用 RPC。

Q8:新老剧集模型怎么兼容?

Season-Service 先通过 Canary 路由按 Section 分组。老模型读取 EpisodeInfoBO.configAttrs,新模型读取 episodeOperationalRules.ruleAttrs,最终都经 DrmStatusCounter 映射为 bucket 0/1。Pay 不感知底层模型。

Q9:统计哪些 EP?

当前口径关注 BILIBILI 主站有版权且已上架的 EP。新模型代码明确做版权和发布状态过滤;老模型通过 type=0 的分节查询口径获取主站可见 EP。这比无差别扫描所有历史 EP 更贴近收银台场景。

Q10:为什么 Season 已开 DRM 时不再查 EP?

判断使用 Java ||,Season DRM 为真会短路。业务结论已经确定,不需要再增加两个下游 RPC,这是正确性和性能都受益的快速路径。

Q11:两个 RPC 会不会拖慢收银台?

有风险,所以做了三层控制:Season DRM 先短路;两个 RPC 各有 200ms deadline;Pay 用 3 秒 Caffeine 缓存重复请求,Season-Service 还有 Redis 统计缓存。异常时 fail-open,不阻塞收银台。

Q12:为什么本地缓存只设 3 秒?

它解决的是瞬时重复访问,而不是承担长期数据缓存。更长的缓存已经在 Season-Service Redis 层,Pay 再设长 TTL 会叠加陈旧时间。3 秒能削峰,同时保持运营配置变更相对敏感。

Q13:为什么空结果不缓存?

因为空结果可能是 RPC 抖动或超时,不一定是真实无数据。缓存空结果会把一次故障扩散成一段时间的持续漏提示。不缓存允许下一请求重新回源。

Q14:为什么异常时按无 DRM,而不是直接提示不可投屏?

该链路是购买前提示,不是版权执行点。按有 DRM 降级会在下游故障时大面积误报,影响交易;按无 DRM 能保持收银台可用,实际投屏仍由 Player 强制限制。这是可用性优先的 fail-open 权衡。

Q15:这种 fail-open 有什么问题?

异常期间可能漏提示,用户仍可能购买后才发现不能投屏。因此需要监控降级率,并可考虑最近成功值、可配置保守降级或异步校验,而不是把 false 当作强事实。

Q16:为什么不只查正片 Section?

实现通过 profile 获取全部 Section,没有类型过滤,目的是避免分类差异造成漏报。但这可能被非正片 DRM 扩大提示。代码注释和实际行为有轻微不一致;后续应由产品明确口径,再决定是否过滤。

Q17:当前实现最大的边界问题是什么?

两个明确问题:统计 RPC 一次最多 50 个 Section,而 Pay 没有分片;EP 规则属性变更后,统计缓存没有在对应 DTS Handler 中直接失效,可能等待 Redis TTL。

Q18:缓存一致性如何保证?

当前主要依靠 DTS 精确失效和 TTL 兜底。EP 基础信息、可见性等变更已接入统计缓存清理,但 t_episode_rule_attr 的 DRM 变化链路仍需补齐按 EP 反查 Section 后的失效。因此准确说法是“最终一致,部分变更依赖 TTL”,不是强一致。

Q19:如何验证新老统计口径一致?

MR !8325 后续增加了 StatVerifyController,可以将旧的 EP 列表计算结果与新 countEpisodesBySection 的 DRM_STATUS 桶做对比。联调时还应挑选老模型、新模型、上下架和版权边界数据。

Q20:为什么统计结果按维度拆 Redis Key?

降低缓存对象大小和维度耦合,让调用方按需加载;新增维度只新增 Counter 和独立 key,不需要让所有调用方承担额外回源和序列化成本。

Q21:如果让你重新设计,会怎么减少两个 RPC?

如果该需求已成为高频通用能力,我会在 Season-Service 增加按 Season 聚合的 hasDrmEpisode 或轻量摘要字段,由领域侧一次完成 Section 展开和统计,并复用内部缓存。但在当时需求里,组合现有接口改动更小、上线风险更低。

Q22:这个需求的业务价值如何量化?

应关注因“不可投屏”产生的购买后退款率、客服进线量、提示覆盖率、EP DRM 漏提示数,以及收银台 P95/P99 和 RPC 降级率。已有资料证明功能正反例和缓存效果,但没有完整线上业务指标时不要虚构数字。

Q23:如何说明你的个人贡献?

可以说:负责问题定位、方案对比、Pay 侧链路接入、超时降级和缓存设计、单元测试及 UAT 验证;复用了 Season 团队先前建设的统计框架和 DRM_STATUS,没有把这部分基础能力据为自己的产出。

Q24:这个项目体现了什么工程能力?

  • 跨 Pay、Season、Player 的调用链追踪;
  • 对领域边界和复用能力的判断;
  • 核心交易链路的超时、缓存与降级设计;
  • 新老数据模型兼容意识;
  • 用正例、反例、异常场景验证,而不是只跑 Happy Path;
  • 能识别已有实现的缓存一致性和批量上限边界。

18. 代码索引

Pay-Service

位置 作用
app/pay/service/src/main/java/pleiades/business/service/pay/service/PayService.java:802 seasonPayConfig 入口
app/pay/service/src/main/java/pleiades/business/service/pay/service/PayService.java:913 buildBuyDesc 汇总 Season/EP DRM
app/pay/service/src/main/java/pleiades/business/service/pay/service/PayService.java:941 hasDrmEpisodeByStat
app/pay/service/src/main/java/pleiades/business/service/pay/service/PayService.java:962 Season 级 checkDrm
app/pay/service/src/main/java/pleiades/business/service/pay/service/rpc/SeasonService.java:575 profile 获取全部 Section ID
app/pay/service/src/main/java/pleiades/business/service/pay/service/rpc/SeasonService.java:615 请求并解析 DRM 分桶
app/pay/service/src/test/java/pleiades/business/service/pay/service/PayServiceDrmTest.java:76 DRM 业务判断测试
app/pay/service/src/test/java/pleiades/business/service/pay/service/rpc/SeasonServiceTest.java:137 RPC、缓存和异常测试

Season-Service

位置 作用
app/season/service/src/main/java/pleiades/business/service/season/controller/EpisodeRPCController.java:735 统计 RPC 入口
app/season/service/src/main/java/pleiades/business/service/season/controller/EpisodeRPCController.java:336 最多 50 个批量参数限制
app/season/service/src/main/java/pleiades/business/service/season/module/episode/service/stat/EpisodeStatService.java:82 统计总入口
app/season/service/src/main/java/pleiades/business/service/season/module/episode/service/stat/EpisodeStatService.java:246 按维度查缓存和回源
app/season/service/src/main/java/pleiades/business/service/season/module/episode/service/stat/DrmStatusCounter.java:26 DRM 0/1 分桶
app/season/service/src/main/java/pleiades/business/service/season/module/episode/service/stat/EpisodeStatRegistry.java:22 Counter 注册表
app/season/service/src/main/java/pleiades/business/service/season/common/util/SeasonCacheKeys.java:233 统计 Redis key
app/season/service/src/main/java/pleiades/business/service/season/module/episode/service/stat/EpisodeStatCacheService.java:24 统计缓存精确失效服务
app/season/service/src/main/java/pleiades/business/service/season/controller/handler/dts/EpisodeRuleAttrDTSHandler.java:39 EP 规则属性 DTS 处理

Player-Gateway

位置 作用
app/player/gateway/src/main/java/pleiades/business/gateway/player/module/rpc/video/PlayurlDrmService.java:1218 是否进入 DRM 逻辑
app/player/gateway/src/main/java/pleiades/business/gateway/player/module/rpc/video/PlayurlDrmService.java:1304 Season OR EP DRM 判断
app/player/gateway/src/main/java/pleiades/business/gateway/player/module/rpc/video/PlayurlDrmService.java:1402 投屏场景禁止 DRM
app/player/gateway/src/main/java/pleiades/business/gateway/player/module/play/service/PlayGrpcV3Service.java:262 播放页投屏按钮禁用判断

19. 面试前自检清单

  • 能在 30 秒内说清事故、根因、方案和结果。
  • 能画出 Pay → Season → Redis/回源 → buyDesc 链路。
  • 能解释 Season DRM 与 EP DRM 两个配置层级。
  • 能解释 DRM_STATUS 的 dimension 和 bucket 0/1。
  • 能明确 DRM_STATUS 来自 MR !8325,而收银台接入来自 MR !8377。
  • 能说明为何选分桶统计而不是拉全量 EP。
  • 能说明 Java || 短路带来的快速路径。
  • 能解释 Redis 分维度缓存与 Pay Caffeine 缓存分别解决什么问题。
  • 能解释 200ms deadline、空结果不缓存和 fail-open。
  • 能说明实际投屏限制仍在 Player-Gateway。
  • 能说出全部 Section、50 个 Section 上限、规则属性缓存失效三个边界。
  • 不虚构线上收益数据;没有数据时说验证结果和应关注指标。
  • 准备一个“如果重做”的答案:领域侧 Season 聚合接口、分片、精确缓存失效、可观测性。

20. 最终总结

这次需求的完整框架可以概括为:

1
2
3
4
5
6
7
8
9
发现购买前提示与购买后能力不一致
→ 追踪 DRM 在 Season/EP 两级的配置与消费
→ 确认 Pay 漏掉 EP 维度
→ 对比全量 EP 遍历与领域统计复用
→ 选择 profile + DRM_STATUS 分桶统计
→ 在 buildBuyDesc 中做 Season OR EP 判断
→ 用 Redis/Caffeine、deadline、异常降级保护收银台
→ 通过单测和 UAT 正反例验证
→ 识别批量上限、缓存一致性和 SKU 精确度的后续空间

最值得强调的能力是:没有在 Pay 中重复实现一套 EP 数据处理,而是识别并复用 Season-Service 的通用统计框架;同时把提示链路与真正的版权执行链路分开,通过明确的超时、缓存和降级策略控制对核心收银台的影响。

MyFollows 大追番用户空数据治理:面试复盘 SOP

文档用途:面试前快速恢复项目上下文,并为调用链、根因、数据依据、方案取舍、工程实现和风险控制类追问准备可复述答案。

代码基线:master(记录时 HEAD:05bd03664f4)

方案一提交:14b25e6442c feat(card): 支持动态配置MyFollows下游调用的deadline,已合入 master

方案二提交:cfa41569ea4 分区并发查询与过滤前移,位于本地分支 codex/myfollows-season-partition,尚未合入当前 master

方案三状态:完成设计,尚未实现

整理日期:2026-09-18


0. 阅读方式与口径

面试前建议按三个层次阅读:

  1. 时间不足时,只看“1 分钟项目摘要”“三种方案对比”“30 秒/2 分钟讲述”。
  2. 技术面前,重点看“完整调用链”“根因模型”“方案二实现与分页证明”“高频追问”。
  3. 复盘或主管面前,重点看“方案决策”“灰度与回滚”“事实、推断与待验证项”“经验总结”。

本文严格区分以下三种口径:

标记 含义 面试表达要求
已确认 能由当前代码、Git 提交或历史日志直接证明 可以明确陈述
推算 根据日志样本或链路预算计算得出 必须使用“理论上”“样本内”等限定词
待验证 设计合理但缺少生产灰度数据 不能表达成已上线效果

特别注意:方案二虽然已经完成代码和测试,但当前只存在于本地分支,不能说成“已经合入 master”或“已经在线上全量生效”。


1. 一分钟项目摘要

手机空间页的 AppCard/MyFollows 在大追番用户上偶发返回空列表,而 Web 端通常正常。沿移动端调用链排查后确认:card-service 调用 follow-service 的 Follow/MyFollows 原来只有 100ms deadline;follow-service 对大用户会分页拉取全部收藏关系,再对全量追番补充 miniSeason、过滤和排序,最后才分页。当下游耗时越过 deadline 时,card-service 捕获 RPC 异常并返回 MyFollowsReply.getDefaultInstance(),真实的超时被折叠成了业务空列表。

治理分三层:

  1. 短期通过 Paladin 动态配置 deadline,保留 100ms 默认值并支持灰度调整;历史一小时样本中,超过 100ms 的 160 个请求有 153 个落在 100~180ms 内,因此调至 180ms 在该样本内理论覆盖 95.6% 的尾部慢请求,同时没有吃满约 250ms 的上游预算。
  2. 中期实现日番(seasonType=1)和国创(seasonType=4)分区异步查询:follow-service 将 seasonType 过滤前移到 getMiniSeason 和排序之前,card-service 各取两分区前 pn * ps 条,合并后按 mtime 全局排序并分页;配套动态开关、pn * ps > 100 回退和单元测试。
  3. 长期设计 Redis ZSet 排序索引,先取当前页 ID、再补当前页信息,使主链路复杂度由追番总量相关转为主要与页大小相关。

项目价值不只是“把超时时间调大”,而是完成了从故障止血、计算量优化到长期数据结构治理的分层方案设计。


2. 背景、现象与问题定义

2.1 用户现象

  • 大追番用户在手机客户端空间页看不到追番列表,表现为整个模块为空。
  • 同一用户 Web 端追番列表通常可以正常展示。
  • 用户实际存在追番关系,因此不能把结果简单解释为真实空数据。

历史自测请求示例:

1
2
3
4
5
6
7
{
"from": 2,
"tid": 11608899,
"ps": 20,
"follow_type": 1,
"pn": 1
}

2.2 把模糊问题转成可验证问题

原始问题是“为什么大追番用户列表为空”。排查时将其拆成四个问题:

  1. 空数据在哪一层首次出现:客户端、card-service 还是 follow-service?
  2. 是真实业务空数据,还是异常后的默认值?
  3. 为什么只在大追番用户上明显,耗时与追番总量有什么关系?
  4. 为什么移动端有问题,而 Web 端相对正常?

2.3 排查边界

主问题链路限定为:

1
2
pgc.service.card.app.AppCard/MyFollows
-> pgc.service.follow.Follow/MyFollows

核心场景限定为:

1
2
3
from = SPACE(空间页)
followType = 1(追番)
seasonType = 0(日番 + 国创)

动态页、车载、追剧、Web、OTT 等调用点需要搜索以判断影响面,但不是本次方案二灰度的默认扩大范围。


3. SOP 1:复现并确认“空列表”的性质

3.1 收集最小复现条件

排查前至少记录:

  • 用户 mid/tid
  • from、follow_type、pn、ps
  • 用户大致追番数量
  • 手机端与 Web 端是否同时异常
  • card-service 和 follow-service 的同一请求日志
  • RPC 错误码、服务端 ts、调用方剩余 timeout_quota

历史慢请求样本显示,超过 150ms 的用户中存在约 1000、2300、3700、4000、4800、5000 条收藏关系的案例;6000 多条追番用户的耗时主要落在 100~150ms。这说明数据规模与尾延迟有明显相关性,但也有少量追番数量不大的慢请求,因此不能把“追番数量大”当作唯一变量,还要考虑缓存 miss、下游抖动和调度开销。

3.2 判断是真空还是降级空

当前 master 的 card-service 调用代码:

1
2
3
4
5
6
7
8
9
try {
return FollowGrpc.newBlockingStub(channel)
.withWaitForReady()
.withDeadlineAfter(appProperties.getFollowMyFollowsDeadlineMs(), TimeUnit.MILLISECONDS)
.myFollows(...);
} catch (Exception e) {
log.warn("getFollows ...", e);
}
return MyFollowsReply.getDefaultInstance();

由此可以确认:

1
2
3
4
5
6
下游超时/异常
-> catch Exception
-> 返回 default instance
-> followsList 为空
-> 上层返回空 FollowReply
-> 客户端看到空模块

因此这是“技术失败被降级成业务空数据”,而不是 follow-service 明确返回“用户没有追番”。

3.3 面试表达

不要只说“下游超时导致空列表”,应说清楚空列表的形成机制:

card-service 把 RPC 异常转换成 protobuf 默认对象,上层又把默认对象中的空 followsList 当作正常空列表处理,因此技术错误没有透传到客户端,最终表现成业务空数据。


4. SOP 2:梳理完整调用链

4.1 手机端主链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
AppCard/MyFollows
-> AppCardRPCController.myFollows()
- 校验 mid/tid、followType
- pn 默认 1,ps 默认 100,ps 上限 100
-> card FollowService.myFollows()
-> from=SPACE/CAR:spaceFollows()
-> FollowRPCService.getFollows()
-> pgc.service.follow.Follow/MyFollows
-> FollowRPCController.myFollows()
- 读取 followType/seasonType/followStatus/sortType/pn/ps
-> follow FollowService.getMyFollows()
-> FollowMergeFavService.getMyFollows()
-> favorite/UserSubList
-> 分页拉取用户全部收藏关系
-> followStatus 过滤
-> FollowDao.getRecentWatch(mid)
-> 按 season_type 分组
-> SeasonDao.getMiniSeason(...)
-> Caffeine
-> Redis mini:season:v2:{seasonId}
-> miss 后 season-service Cards,50 个一批
-> filterSeason 过滤删除/下架内容
-> 根据观看时间、更新时刻、追番时刻修正 mtime
-> 全量排序
-> seasonType 过滤
-> pn/ps 分页
-> 返回 MyFollowsReply
-> card-service 再补卡片和观看进度
-> 返回 FollowReply

4.2 关键代码位置(当前 master)

层次 文件 关键位置/作用
card RPC 入口 app/card/service/.../controller/AppCardRPCController.java myFollows,约第 94 行;参数默认值和 ps <= 100 校验
card 业务层 app/card/service/.../module/follow/service/FollowService.java myFollows/spaceFollows,约第 63、121 行
card 下游调用 app/card/service/.../module/rpc/FollowRPCService.java getFollows,约第 72 行;deadline 和异常降级
follow RPC 入口 app/follow/service/.../controller/FollowRPCController.java myFollows,约第 164 行;读取 seasonType 等参数
follow 核心处理 app/follow/service/.../module/follow/service/FollowService.java getMyFollows,约第 207 行;过滤、补充、排序、分页
全量收藏读取 app/follow/service/.../module/follow/service/FollowMergeFavService.java getMyFollowsFav,约第 98 行;分页读取全部收藏
season 补充 app/follow/service/.../module/season/dao/SeasonDao.java getMiniSeason,约第 112 行;本地缓存、Redis、RPC 回源

4.3 移动端与 Web 端差异

Web 调用链中的 app/web/gateway/.../rpc/follow/FollowService.java#getFollows:

  • 使用 200ms deadline;
  • 显式透传 seasonType;
  • 请求通常按日番/国创分区。

历史移动端链路则是:

  • card-service 原固定 100ms deadline;
  • 请求 followType=1, seasonType=0,一次查询日番和国创;
  • 超时后降级为空默认对象。

这解释了“同一用户 Web 正常、手机端空列表”的差异,但不能仅凭 Web 正常就断言下游一定健康;两个入口的预算和请求形态本来就不同。

4.4 参数和枚举表

参数 值 含义
followType 1 追番
followType 2 追剧
seasonType 0 不按分区过滤
seasonType 1 日番/番剧,JP
seasonType 4 国创,CHN
followStatus 0 全部状态
sortType 0 WATCH_RELEASE_UPDATE,空间页使用

当前 follow-service 的追番口径明确为 JP(1) || CHN(4),所以方案二将 followType=1 拆成 seasonType=1 和 4 是穷尽且互斥的分区。

4.5 入口分支条件

场景 card-service 行为 是否是方案二目标
from=DYNAMIC 分别查询追番、追剧,各取 20,合并后取 5 否
from=SPACE 查询空间追番/追剧,使用 WATCH_RELEASE_UPDATE 排序 是
from=CAR 查询车载列表,使用默认 UPDATE 排序 代码可经过相同方法,但应单独评估
非法 from 返回默认空对象 否
tid > 0 查他人列表后再补当前用户追番状态 需要保持原语义

5. SOP 3:用数据验证瓶颈与预算

5.1 日志查询原则

统计必须同时限定服务端接口和调用来源:

1
2
3
_server_path IN ('pgc.service.follow.v1.Follow/MyFollows')
AND log.category IN ('grpc-access-log')
AND caller.path IN ('pgc.service.card.app.AppCard/MyFollows')

否则会混入 Web、OTT 或其他调用方,使 deadline 结论失真。

5.2 15 分钟样本:用于最初的 150ms 评估

阈值 命中数 总请求数 占比
ts > 100ms 55 563,835 0.0098%
ts > 150ms 10 563,835 0.0018%
ts > 200ms 0 563,835 0%

这个窗口支持的结论是:大多数超过 100ms 的服务端慢请求落在 100~150ms,所以 150ms 是合理的初始止血值。

5.3 一小时样本:用于后续 180ms 评估

历史一小时样本:总请求 1,972,439。

服务端耗时阈值 命中数
ts > 100ms 160
ts > 150ms 20
ts > 160ms 14
ts > 170ms 10
ts > 180ms 7
ts > 190ms 7
ts > 200ms 6

从 100ms 调到 180ms 的样本内理论覆盖量:

1
2
160 - 7 = 153
153 / 160 = 95.625% ≈ 95.6%

准确表述应为:

在该一小时样本中,180ms 理论覆盖了 160 个 ts > 100ms 请求中的 153 个,即 95.6%。这只是基于 follow-service 服务端耗时的估算,不等于真实客户端超时率下降 95.6%。

原因是客户端 deadline 还包含网络、排队、序列化和线程调度开销。

5.4 不同窗口不能混算

另一个滚动一小时窗口共有 2,791,964 次请求,其中 ts > 180ms 为 33 次。这说明尾部慢请求数量会随流量、缓存命中和下游状态波动。

必须把下面三组数据分别表述:

  • 15 分钟:563,835 总请求,评估 150ms;
  • 一小时:1,972,439 总请求,计算 180ms 的 95.6% 理论覆盖率;
  • 另一滚动一小时:2,791,964 总请求,>180ms 为 33,用于说明尾部波动。

不能把不同窗口的分子和分母拼在一起计算比例。

5.5 上游预算

从 card-service access log 的 timeout_quota + ts 反推,上游 /x/v2/space 给 AppCard/MyFollows 的总预算约为 250ms,历史样例如下:

1
2
3
4
timeout_quota=0.210, ts=0.039 -> 约 249ms
timeout_quota=0.212, ts=0.038 -> 约 250ms
timeout_quota=0.221, ts=0.027 -> 约 248ms
timeout_quota=0.244, ts=0.005 -> 约 249ms

预算推演:

1
2
3
deadline=150ms -> 理论保留约 100ms 给 card-service 后续组装
deadline=180ms -> 理论保留约 70ms
deadline=200ms -> 理论只剩约 50ms

card-service 在 follow-service 返回后仍需补卡片、观看进度、剧集信息和追番状态,所以 deadline 不能无上限增加。


6. SOP 4:建立根因模型

6.1 直接原因

1
2
3
4
Follow/MyFollows 实际耗时 > card-service deadline
-> DEADLINE_EXCEEDED / RPC 异常
-> card-service 返回 default instance
-> 空列表

6.2 性能根因

当前 follow-service 的主路径复杂度随用户追番总量增长:

1
2
3
4
5
拉取全部收藏关系 O(N)
+ 对全量追番补 miniSeason O(N)
+ 全量过滤 O(N)
+ 全量排序 O(N log N)
+ 最后只返回 ps 条

例如用户有 6800 条追番、客户端只需要 20 条,服务端仍然处理约 6800 条关系并参与补充和排序。

6.3 为什么数据量会放大耗时

  1. FollowMergeFavService.getMyFollowsFav 首先读第一页,再并发读取收藏服务的所有后续页。
  2. getMiniSeason 会按 seasonType 分组,对所有 seasonId 查询 Caffeine 和 Redis;缓存 miss 时再按 50 条一批调用 season-service。
  3. 排序前会根据最近观看时间和 season 更新时间修正每条记录的 mtime。
  4. seasonType 在旧逻辑中位于补充和全量排序之后,分区请求也没有尽早减少工作集。

6.4 根因树

1
2
3
4
5
6
7
8
9
10
11
用户看到空列表
├─ 表层:card-service 返回默认空对象
│ └─ 原因:Follow/MyFollows 超过 RPC deadline
├─ 时延放大:100ms 预算偏紧
│ ├─ 网络/排队/序列化也消耗预算
│ └─ Web 侧有 200ms,移动端原来只有 100ms
└─ 计算根因:分页前处理全量数据
├─ 全量拉收藏关系
├─ 全量 getMiniSeason
├─ 全量过滤和 mtime 修正
└─ 全量排序后才分页

6.5 一句话根因

一个只需要当前页的查询,在下游执行了与用户全部追番量相关的补充和排序;移动端又设置了偏紧的 100ms deadline,并把超时降级成空对象,因此大用户的尾延迟最终表现为空列表。


7. SOP 5:三种方案与决策逻辑

方案 核心动作 成本 生效速度 是否降低计算量 是否根治 当前状态
方案一 deadline 动态配置,灰度调至 180ms 低 快 否 否 已合入 master
方案二 seasonType 过滤前移 + 日番/国创异步分区 + 全局排序分页 中 中 是,减少补充和排序工作集 部分 已实现并测试,未合入 master
方案三 Redis ZSet 索引,先分页后补信息 高 慢 是,主路径与总量解耦 是 设计完成,未实现

选择三层方案而不是只选一个,是因为故障治理同时存在三个目标:

  • 立即恢复大用户可用性;
  • 在不改协议的前提下降低真实耗时;
  • 从数据结构上消除全量处理。

推荐顺序:

1
2
3
方案一快速止血
-> 方案二降低主链路实际工作量
-> 方案三作为长期索引化治理

8. 方案一:动态 deadline 止血

8.1 实现

提交:

1
14b25e6442c feat(card): 支持动态配置MyFollows下游调用的deadline

改动只有两个核心点:

  1. AppProperties 新增 followMyFollowsDeadlineMs,默认 100。
  2. FollowRPCService.getFollows 用配置值替换硬编码 100ms。
1
2
3
4
5
6
private int followMyFollowsDeadlineMs = 100;

.withDeadlineAfter(
appProperties.getFollowMyFollowsDeadlineMs(),
TimeUnit.MILLISECONDS
)

默认仍为 100ms 的原因是代码发布本身不改变线上行为,可以通过 Paladin 小流量调整并随时回退。

8.2 为什么最终选择 180ms

  • 150ms 来自早期 15 分钟窗口,是保守起点。
  • 后续一小时样本表明 180ms 在该窗口内可理论覆盖 95.6% 的 100ms 以上慢请求。
  • 上游约 250ms,设置 180ms 后仍理论保留约 70ms 给 card-service 后处理。
  • 相比 200ms,180ms 对上游预算和线程占用更保守。

8.3 风险

  • 没有减少任何下游计算量,只是延长等待。
  • card-service 的 TP99/TP999 可能上升。
  • blocking stub 的线程占用时间变长。
  • follow-service 慢请求会执行更久,异常期可能放大资源占用。
  • 当耗时继续增长到 180ms 以上时,问题仍会复现。

8.4 灰度 SOP

1
2
3
4
5
1. 代码以默认 100ms 发布
2. 小流量把配置调到 150ms
3. 观察超时数、空列表率和上下游 RT
4. 根据更长窗口分布逐步调到 180ms
5. 若上游超时或线程占用异常,立即回调配置

8.5 应观察的指标

  • card-service AppCard/MyFollows p95/p99/p999
  • card -> follow DEADLINE_EXCEEDED 数量
  • 默认空对象/空列表比例
  • follow-service MyFollows p95/p99/p999
  • ts > 100/150/180/200ms 数量
  • card-service 线程池、连接池和 CPU
  • /x/v2/space RT 与超时率

8.6 面试中的正确定位

方案一是低风险止血,不是性能优化。它把“下游其实能在 100~180ms 返回,但调用方提前放弃”的请求接住,为方案二和方案三争取治理时间。


9. 方案二:分区异步查询、过滤前移和全局分页

9.1 实现状态与范围

分支:

1
codex/myfollows-season-partition

提交:

1
cfa41569ea4 分区并发查询与过滤前移

相对 master:

1
7 files changed, 369 insertions(+), 22 deletions(-)

涉及:

  • card-service 配置
  • card-service 追番业务层
  • card-service follow RPC 封装
  • follow-service 核心查询
  • 三组对应单元测试

9.2 改造前后

改造前:

1
2
3
4
5
6
7
card-service
-> MyFollows(followType=1, seasonType=0)
follow-service
-> 全量拉日番 + 国创
-> 全量补 miniSeason
-> 全量修正 mtime 和排序
-> 最后分页

改造后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
card-service
├─ async MyFollows(followType=1, seasonType=1, pn=1, ps=pn*ps)
└─ async MyFollows(followType=1, seasonType=4, pn=1, ps=pn*ps)

follow-service(每个分区)
-> 读取追番关系
-> followStatus 过滤
-> seasonType 过滤
-> 只为当前分区补 miniSeason
-> 当前分区修正 mtime、排序、分页

card-service
-> 合并两个分区结果
-> 按 mtime 全局倒序
-> skip((pn-1)*ps).limit(ps)
-> total = jp.total + chn.total

9.3 第一步:follow-service 过滤前移

旧顺序:

1
2
3
4
5
6
followStatus 过滤
-> getRecentWatch
-> 全量 getMiniSeason
-> 全量排序
-> seasonType 过滤
-> 分页

新顺序:

1
2
3
4
5
6
followStatus 过滤
-> seasonType 过滤
-> getRecentWatch
-> 当前分区 getMiniSeason
-> 当前分区排序
-> 分页

核心逻辑:

1
2
3
4
5
if (seasonType > 0) {
followList = followList.stream()
.filter(r -> seasonType == r.getSeason_type())
.collect(Collectors.toList());
}

收益发生在两个重环节之前:

  • getMiniSeason 的 seasonId 数量减少;
  • 参与 mtime 修正和排序的记录数减少。

边界:这一步没有消除 FollowMergeFavService 对全部收藏关系的读取,因此它是“减少后半段工作集”,不是“先分页后读取关系”的根治方案。

9.4 第二步:RPC 支持 seasonType 和异步调用

保留旧签名,默认 seasonType=0:

1
2
getFollows(mid, type, followStatus, pn, ps, sortType, withSeries)
-> getFollows(mid, type, 0, followStatus, pn, ps, sortType, withSeries)

新方法统一通过 buildMyFollowsReq 构造请求并透传:

1
.setSeasonType(seasonType)

异步路径使用:

1
2
FollowGrpc.newFutureStub(channel)
ContextFutures.asCompletableFuture(...)

使用项目现有 ContextFutures 的意义是保留请求上下文传播能力,避免直接创建公共线程池任务导致 tracing/context 丢失。

9.5 第三步:收窄启用条件

方案二不是对所有 MyFollows 请求全量开启,判断条件为:

1
2
3
4
app.isEnableSplitAnimeFollowsBySeasonType()
&& followType == FollowTypeEnum.ANIME.code
&& (long) pn * ps > 0
&& (long) pn * ps <= min(configuredMaxQuerySize, 100)

配置项:

1
2
enableSplitAnimeFollowsBySeasonType = false
splitAnimeFollowsMaxQuerySize = 100

设计点:

  • 默认关闭,代码上线不改变行为;
  • 只优化 followType=1;
  • 使用 long 计算 pn * ps 做边界判断,避免整数溢出;
  • 配置值再与硬上限 100 取最小值,防止配置误设突破下游 ps 上限;
  • 深分页和异常参数自动回退旧单次查询。

9.6 第四步:并发查日番和国创

设:

1
k = pn * ps

两次请求都固定:

1
2
pn = 1
ps = k

分别查询:

1
2
JP:seasonType=1
CHN:seasonType=4

并发的整体等待时间近似:

1
max(T_jp, T_chn) + merge/sort overhead

而不是:

1
T_jp + T_chn

但 QPS 会从一次变成两次,必须用灰度监控 follow-service 的 QPS、CPU、缓存和下游 RPC。

9.7 第五步:全局排序与分页

1
2
3
4
Stream.concat(jp.follows, chn.follows)
.sorted((a, b) -> Long.compare(b.getMtime(), a.getMtime()))
.skip((pn - 1L) * ps)
.limit(ps)

总数:

1
total = jp.total + chn.total

成立前提:追番集合正好由互斥的 JP(1) 和 CHN(4) 构成。当前 follow-service 的 Constants.SeasonType.isAnime 正是这个口径。

9.8 为什么每个分区要取前 pn * ps 条

以 pn=2, ps=20 为例,目标是全局第 21~40 条。

错误做法:

1
2
3
JP 直接取第 2 页 20 条
CHN 直接取第 2 页 20 条
然后合并

这会遗漏某个分区第一页中本应进入全局第 21~40 位的元素。

正确做法:

1
2
3
4
5
6
k = 2 * 20 = 40
JP 取前 40 条
CHN 取前 40 条
合并最多 80 条
全局排序
截取第 21~40 条

9.9 分页正确性证明

设两个分区分别为有序集合 A、B,全局目标是 A ∪ B 的前 k 条。

如果 A 中某元素排在该分区第 k+1 位或更后,则仅 A 内就至少有 k 个元素不小于它,因此它不可能进入全局前 k。B 同理。

所以:

1
TopK(A ∪ B) ⊆ TopK(A) ∪ TopK(B)

取每个分区前 k=pn*ps 条,合并后按同一个排序键重排,就足以得到全局前 k,再截取目标页。

成立条件:

  1. 两个分区使用相同的排序语义;
  2. 下游返回的是各自分区的正确 TopK;
  3. 分区互斥且覆盖完整集合;
  4. 相同排序值有一致或可接受的 tie-break 规则。

9.10 降级与回退

条件 行为
开关关闭 走原始 seasonType=0 单次查询
followType != 1 走原始查询
pn * ps <= 0 走原始查询
pn * ps > 配置上限 走原始查询
pn * ps > 100 硬性回退原始查询
一个分区异常 当前实现将该 future 转成默认空对象,可能返回另一分区的部分结果
两个分区都为空 上层返回默认空结果

最后两项是需要主动说明的语义:异步 RPC 使用 exceptionally 将异常转换为空 reply,因此单分区失败时不会自动回退旧链路,而可能返回部分列表和部分 total。生产化前可以进一步考虑:

  • 让返回值携带成功/失败状态;
  • 任一分区失败则整体回退旧查询;
  • 或明确接受“部分可用优于全部为空”,并增加降级指标。

选择哪种策略需要产品语义和流量成本共同确认,不能由技术侧默默假定。

9.11 发布顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 先发布 follow-service 的 seasonType 过滤前移
- 对 seasonType=0 主链路无行为变化
- 先让分区查询具备真实减负能力

2. 再发布 card-service 的 seasonType 透传、future stub 和合并逻辑
- 动态开关默认关闭

3. 小流量开启 enableSplitAnimeFollowsBySeasonType
- 普通用户和大用户分别做结果 diff

4. 逐步扩大灰度
- 监控结果一致性、超时率、QPS、CPU 和缓存

5. 异常时关闭开关
- 无需回滚代码即可恢复旧路径

不能反过来先开启 card-service 分区查询,因为旧 follow-service 仍会对两次请求分别做全量补充和排序,可能从“一次全量处理”变成“两次全量处理”。

9.12 单元测试覆盖

方案二分支新增或补充了以下测试:

测试 证明内容
follow-service testGetMyFollows_WhenSeasonTypeSpecified_FiltersBeforeLoadingSeason getMiniSeason 只收到目标分区 ID,不会加载另一个分区
card RPC getFollowsAsyncPassesSeasonTypeAndReturnsReply async 请求正确透传 mid/followType/seasonType/pn/ps
card myFollowsWhenSplitEnabledMergesSortsAndPagesPartitions 两分区合并、全局 mtime 排序、第二页截取、total 相加
card myFollowsWhenSplitDisabledUsesOriginalQuery 开关关闭时只调用旧同步查询
card myFollowsWhenQuerySizeExceedsLimitUsesOriginalQuery pn*ps 超过上限时回退旧路径

历史验证记录表明 card-service、follow-service 对应测试和两个模块的 compileJava 均通过。由于当前文档只新增说明文件,本次整理没有重新切换分支执行编译。

9.13 方案二的真实收益边界

能优化:

  • 目标分区 getMiniSeason 的输入规模;
  • season 缓存查询和 miss 回源数量;
  • 参与 filterSeason、mtime 修正、排序的记录数;
  • 两个分区可以并行执行。

不能优化:

  • favorite-service 全量收藏关系读取;
  • 最近观看 Map 的整体读取;
  • 极端单一分区用户,例如几乎全部是日番时,拆分后的最大分区仍接近原规模;
  • pn*ps > 100 的深分页,因为当前会回退旧路径;
  • 缓存大面积 miss 或下游整体抖动。

所以面试中应说“方案二显著缩小重计算工作集、缓解根因”,而不是说“彻底把复杂度降为 O(ps)”。

9.14 仍需关注的正确性风险

  1. 同 mtime 的稳定顺序:当前 comparator 只比较 mtime。不同分区存在相同 mtime 时,合并路径的稳定顺序可能与原全量列表不同,页边界可能出现顺序差异。若产品要求完全一致,应增加明确的第二排序键,例如 seasonId,并确保旧、新链路一致。
  2. 部分失败语义:一个分区失败时当前会返回另一个分区,total 也会偏小。需要产品确认是“部分展示”还是“整体回退”。
  3. 字段合并语义:当前目标调用 withSeries=false、withUnread=false,所以只合并 follows 和 total。若未来扩大场景,需要定义 series/unread/moveTs/wantWatchTs 的合并规则。
  4. 双倍 QPS:单次数据量下降,但调用次数翻倍。应以 CPU、缓存 miss、下游 RPC 和实际 RT 验证总体收益。

10. 方案三:Redis ZSet 索引化分页

10.1 目标

把主查询从:

1
全量关系 -> 全量补充 -> 全量排序 -> 分页

改为:

1
排序索引取当前页 seasonId -> 只补当前页 -> 返回

使核心读路径由用户追番总量 N 相关,变成主要与页大小 ps 相关。

10.2 索引设计

建议使用 Redis ZSet:

1
2
3
key = follow:list:{mid}:{followType}:{seasonType}:{followStatus}:{sortType}
member = seasonId
score = 排序分数

分页:

1
2
3
start = (pn - 1) * ps
end = pn * ps - 1
ZREVRANGE key start end

10.3 排序分数

当前 WATCH_RELEASE_UPDATE 可以抽象为:

1
score = max(followMtime, recentWatchTime, seasonUpdateTime)

不同 sortType 的语义不同,不应一开始建所有维度。第一阶段只覆盖手机客户端核心场景:

1
2
3
4
followType=1
seasonType=0/1/4
followStatus=0
sortType=WATCH_RELEASE_UPDATE

10.4 索引维护事件

事件 需要的索引动作
新增追番 向具体分区和全集索引写入 member/score
取消追番 从相关索引删除 seasonId
追番状态变化 从旧状态索引删除,写入新状态索引
剧集更新 更新关注该 season 用户的 release 相关 score
观看进度变化 更新该用户 WATCH_RELEASE_UPDATE score

难点不在读 ZSet,而在写链路的一致性、事件覆盖和重建能力。

10.5 下架内容与补偿拉取

索引页中的 season 可能已被删除或下架,补充信息后会被过滤,导致不足 ps 条。

推荐策略:

1
2
3
4
先取 ps 条
-> 过滤后不足
-> 向后补取一段
-> 直到补满、无更多数据或达到最大补偿次数

必须限制补偿次数,避免脏数据造成无界循环。

10.6 一致性和灰度

上线顺序:

1
2
3
4
5
1. 旁路构建索引,线上仍返回旧结果
2. 异步读取新索引,与旧链路对比 seasonId/total/排序
3. 提供单用户和批量重建
4. 一致性达标后按 1% -> 5% -> 10% -> 50% -> 100% 切读
5. 索引 miss、结果异常或 Redis 失败时回退旧链路

关键指标:

  • 索引命中率、miss 和 fallback 数
  • 新旧结果的集合、total、排序 diff
  • 补偿拉取次数
  • ZSet 写失败和重建耗时
  • MyFollows p95/p99/p999
  • 大追番用户 RT、超时数和空列表比例

10.7 为什么没有直接做方案三

  • 涉及 follow 新增/取消、状态变化、season 更新、progress 更新等多条写链路;
  • 需要历史数据构建、版本管理、重建任务和一致性监控;
  • Redis 多维索引会增加容量和写放大;
  • 排序依赖观看行为和剧集更新时间,实时维护复杂;
  • 在缺少旁路 diff 前直接切读风险过高。

因此它适合作为长期专项,而不适合承担当时的快速止血目标。


11. 测试、灰度、监控与回滚 SOP

11.1 离线功能验证

用户集应覆盖:

  • 普通用户、大追番用户
  • 只有日番、只有国创、两者混合
  • pn=1/2/5,ps=20/50
  • pn*ps=100 边界和 >100 回退
  • 他人空间 tid > 0
  • 开关开启/关闭
  • 单分区为空、两个分区都为空
  • 同 mtime 和页边界数据

对比字段:

1
2
3
4
5
6
7
total
seasonId 集合
顺序
每页条数
mtime
follow 状态
卡片与观看进度

11.2 性能验证

follow-service:

  • getMiniSeason 输入 ID 数
  • Caffeine/Redis 命中率和 miss 回源量
  • season-service Cards RPC 次数
  • 分区请求耗时分布
  • MyFollows p95/p99/p999
  • CPU、GC、线程池和 QPS

card-service:

  • 两 future 的单独耗时和整体 join 耗时
  • merge/sort 耗时
  • DEADLINE_EXCEEDED
  • 空结果、部分结果和回退计数
  • AppCard/MyFollows p95/p99/p999

上游:

  • /x/v2/space RT 和超时率
  • 追番模块实际展示率
  • 用户投诉或失败 case

11.3 灰度门槛

进入下一阶段前至少满足:

  • 新旧 seasonId 集合和 total diff 在可解释范围;
  • 排序差异已定位到明确规则;
  • 大用户 getMiniSeason 输入规模有下降;
  • card-service 超时率下降或不恶化;
  • follow-service CPU/QPS/下游 RPC 无不可接受增长;
  • 上游 /x/v2/space 没有被拖慢。

11.4 回滚方式

异常 回滚动作
deadline 调整导致上游 RT 恶化 Paladin 将 deadline 回调至原值
分区结果不一致 关闭 enableSplitAnimeFollowsBySeasonType
深分页问题 保持 pn*ps > 100 回退
follow-service 压力上升 关闭 card 分区开关,恢复单次请求
过滤前移本身异常 回滚 follow-service 版本

动态开关的价值是把“回滚逻辑”从重新发版缩短为配置操作。


12. Git 证据与项目时间线

阶段 证据 状态
方案一代码提交 14b25e6442c 已完成
方案一合入 deb1aed5b18,MR !8455 的 merge commit 已合入 master
方案二分支 codex/myfollows-season-partition 本地存在
方案二代码提交 cfa41569ea4 已完成,未合入 master
方案三 历史方案文档 仅设计

查看方案二完整差异:

1
git diff master..codex/myfollows-season-partition

查看方案二指定文件:

1
2
3
git show codex/myfollows-season-partition:app/card/service/src/main/java/pleiades/app/season/card/module/follow/service/FollowService.java
git show codex/myfollows-season-partition:app/card/service/src/main/java/pleiades/app/season/card/module/rpc/FollowRPCService.java
git show codex/myfollows-season-partition:app/follow/service/src/main/java/pleiades/business/service/follow/module/follow/service/FollowService.java

13. 已确认事实、合理推断与待验证项

13.1 已确认事实

  • card-service 曾使用固定 100ms deadline,现已改为动态配置,默认 100ms。
  • RPC 异常会被捕获并返回 MyFollowsReply.getDefaultInstance()。
  • follow-service 旧逻辑在 seasonType 过滤和分页前读取、补充并排序全量追番。
  • Web 调用使用 200ms deadline 并透传 seasonType。
  • 追番的 seasonType 集合是 JP(1) 和 CHN(4)。
  • 方案一提交已经合入 master。
  • 方案二代码和测试位于 cfa41569ea4,未合入当前 master。

13.2 有数据支持的推断

  • 大用户空列表主要由 100ms deadline 与全量处理共同触发。
  • 在 1,972,439 请求的一小时样本内,180ms 理论覆盖 95.6% 的 100~180ms 尾部请求。
  • 分区过滤前移会减少 getMiniSeason 和排序工作集,但收益取决于日番/国创分布、缓存命中和下游状况。

13.3 不能过度宣称的内容

  • 没有生产指标证明“空列表下降了 X%”时,不能把理论覆盖率当成实际治理结果。
  • 不能说方案二已经上线或全量生效。
  • 不能说方案二把整个接口复杂度降成 O(ps),因为收藏关系仍是全量读取。
  • 不能说分区后结果在所有情况下绝对一致,同 mtime tie-break 和部分失败语义仍需验证。
  • 不能说方案三已落地,它只是长期设计。

14. 面试讲述模板

14.1 30 秒版本

我治理过一个大追番用户手机空间页返回空列表的问题。根因是 card-service 调 follow-service 只有 100ms deadline,而下游会在分页前全量拉取、补充和排序用户追番,超时后又被降级成默认空对象。我先把 deadline 做成 Paladin 动态配置,结合近 200 万请求样本灰度到 180ms;再实现日番和国创分区异步查询,把 seasonType 过滤前移到 miniSeason 补充之前,合并后做全局排序分页,并用开关和深分页回退控制风险。长期方案则是 Redis 排序索引,实现先分页后补信息。

14.2 2 分钟版本

现象是大追番用户手机端空间页看不到追番,但 Web 正常。我先沿 AppCard/MyFollows -> Follow/MyFollows 排查。card-service 原来给下游 100ms deadline,任何异常都会 catch 后返回 protobuf 默认对象,所以下游超时被伪装成业务空列表。

follow-service 的查询虽然只返回 20 条,但会分页拉用户全部收藏关系,对全量追番查 recentWatch、补 miniSeason、过滤、修正 mtime、排序,最后才按 seasonType 和 pn/ps 分页。大用户因此更容易超过 100ms。Web 端有 200ms deadline,而且请求会带 seasonType,所以更不容易触发。

我设计了三层方案。第一层是把 deadline 改成 Paladin 动态配置,默认 100ms,按样本灰度到 180ms。一小时近 197 万请求中有 160 个超过 100ms,只有 7 个超过 180ms,所以样本内理论覆盖 95.6%,同时上游总预算约 250ms,仍留有后处理空间。

第二层是实际减少计算量:follow-service 将 seasonType 过滤前移;card-service 并发查日番 1 和国创 4,每个分区取前 pn*ps 条,合并后按 mtime 全局重排再分页。加了默认关闭的动态开关,pn*ps>100 回退旧路径,并覆盖参数透传、过滤前移、排序分页和回退测试。第三层设计 Redis ZSet 索引,长期做到先分页再补信息。目前方案一已合入,方案二在独立分支实现并验证,方案三是设计方案。

14.3 5 分钟展开顺序

  1. 先讲用户现象和移动端/Web 差异。
  2. 画出 card -> follow -> favorite/season 的调用链。
  3. 解释“超时为什么会变成空列表”。
  4. 用 O(N) + O(N log N) 说明大用户为什么慢。
  5. 给出 15 分钟和一小时日志样本,强调窗口隔离。
  6. 讲三层方案及为什么不能只调 deadline。
  7. 重点讲方案二分页证明、开关、回退和发布顺序。
  8. 主动说明方案二边界和未上线状态。
  9. 用方案三收尾,体现长期系统设计能力。

15. 高频面试问题与参考回答

Q1:为什么返回空列表,而不是错误?

card-service 捕获了下游 RPC 异常并返回 protobuf 默认对象,上层看到的 followsList 为空,又继续按正常空结果返回,所以错误语义丢失。这是一种可用性降级,但也降低了可观测性。更完善的做法是至少增加超时、降级空和真实空的独立指标。

Q2:怎么证明不是数据库或收藏数据丢失?

同一用户 Web 正常,且 follow-service 在部分 case 中能生成有效响应,只是超过移动端 100ms deadline;代码也显示 RPC 异常会被转为空对象。这些证据共同说明主要问题是超时降级,而非追番关系真实为空。

Q3:为什么不直接把 deadline 调成 250ms?

250ms 是整个上游链路预算,不是 follow-service 独占预算。card-service 返回前还要补卡片、进度、episode 和追番状态。把下游 deadline 吃满会把超时从 card -> follow 转移到 /x/v2/space,并增加 blocking 线程占用。

Q4:95.6% 是如何计算的?

一小时样本中 ts>100ms 有 160 个,ts>180ms 有 7 个,所以 100~180ms 有 153 个,153/160=95.6%。这是服务端耗时分布的理论覆盖率,不是实际成功率提升。

Q5:为什么 15 分钟数据推荐 150ms,后来又是 180ms?

15 分钟样本用于快速给出保守止血值,长窗口用于观察更完整的尾部分布。deadline 是动态配置,正确方式就是先保守灰度,再根据更长窗口和上游指标调整,而不是一次硬编码到目标值。

Q6:为什么方案二必须同时改 card-service 和 follow-service?

只改 card-service 拆成两次请求,而 follow-service 仍在最后才过滤,会导致两次请求都执行全量补充和排序,工作量可能翻倍。过滤前移后,两次查询才各自只处理日番或国创的重计算部分。

Q7:为什么不是两个分区各查原来的 pn/ps?

分区内第二页不等于全局第二页。要得到全局第 pn 页,必须让每个分区提供前 pn*ps 个候选,合并后再按同一键全局排序、截取目标区间。

Q8:怎样证明取每个分区 TopK 足够?

任何在某个分区排名 k+1 之后的元素,单在该分区就有至少 k 个元素排在它之前,所以它不可能进入两个分区并集的全局 TopK。因此全局 TopK 一定包含在两个分区 TopK 的并集中。

Q9:为什么设置 pn*ps<=100?

follow-service 的单次 ps 上限是 100。方案二要让每个分区取前 pn*ps 条,超过 100 就需要分区内继续翻页,会显著增加请求数和一致性复杂度。中期方案选择回退旧路径,把风险限制在浅分页主流场景。

Q10:异步查询用什么线程池?

实现没有手写 CompletableFuture.supplyAsync,而是使用 gRPC future stub,再通过项目现有 ContextFutures.asCompletableFuture 转换。这样复用 RPC 异步模型并保留请求上下文传播。

Q11:并发后 QPS 翻倍,会不会更慢?

有这个风险。方案的假设是过滤前移后单请求重计算量明显下降,且两分区并行,总 RT 接近较慢分区。但是否净收益必须通过 CPU、缓存 miss、season RPC、QPS 和 RT 灰度验证,不能只做理论判断。因此设计了默认关闭的动态开关。

Q12:如果用户 90% 都是日番,方案二还有收益吗?

收益会下降,因为最大分区仍接近原规模;国创分支很轻,但整体 RT 由日番分支决定。这也是方案二只是中期缓解、方案三才是根治的原因。

Q13:一个分区超时怎么办?

当前实现把异常 future 转成默认空 reply,因此会返回另一个分区的部分结果。生产化前需要明确业务语义:如果接受部分可用,要加部分降级指标;如果要求强一致,应在任一分区失败时回退旧单次查询。这个选择要结合额外请求成本和产品体验确认。

Q14:如何保证排序完全一致?

主排序键都是 follow-service 修正后的 mtime,分区合并后重新排序。需要注意同 mtime 时当前没有显式第二排序键,稳定顺序可能与旧链路不同。严格一致时应统一增加 deterministic tie-break,例如 mtime desc, seasonId desc,并通过旁路 diff 验证。

Q15:为什么 total 可以相加?

当前追番口径只包含互斥的 seasonType=1 和 4,两个分区并集等于追番全集,所以 total_jp + total_chn 成立。如果未来追番口径增加新 seasonType,这个假设必须同步修改,最好把分区集合收敛为统一枚举配置。

Q16:过滤前移会不会改变业务语义?

在过滤条件只依赖 FollowSeason.season_type,且后续操作不会改变 seasonType 的前提下,把相同过滤从排序后移动到补充前不会改变目标分区集合,只减少无关分区的补充和排序。但必须通过 total、集合、排序和分页 diff 验证。

Q17:方案二为什么不算根治?

它仍然通过 favorite-service 拉取用户全部收藏关系,也仍需要对目标分区做排序。它只缩小了补 season 和排序的工作集,没有让查询复杂度完全与总追番量解耦。

Q18:方案三最大的难点是什么?

不是 ZSet 分页,而是排序 score 的持续正确维护。追番新增/取消、状态变化、剧集更新、观看进度都会影响索引,需要事件驱动更新、补偿、重建、旁路 diff 和 fallback,属于跨写链路的一致性工程。

Q19:如果重新做一次,你会补什么?

我会优先补三类能力:区分真实空与降级空的指标;对方案二做线上 shadow diff 和单分区失败指标;统一新旧路径的第二排序键。这样能更快量化效果并降低分页一致性争议。

Q20:你个人在项目中的核心贡献是什么?

可以按真实参与程度选择表述:

我负责/参与了移动端到 follow-service 的跨服务调用链排查、日志分布和预算分析,提出短中长期三层治理方案;落地了 deadline 动态配置,并实现了方案二中的 seasonType 过滤前移、日番/国创异步分区查询、全局排序分页、动态开关、深分页回退及单元测试。

不要把团队工作全部表述成个人独立完成,也不要把设计中的方案三说成已经交付。


16. 项目难点与亮点提炼

16.1 难点一:错误表现和真实原因不一致

用户看到的是空数据,实际是下游超时。需要同时看异常处理和返回组装,不能只从业务数据查起。

16.2 难点二:跨服务预算不是单接口预算

下游 deadline 必须放在约 250ms 上游预算中评估,还要为 card-service 后处理留空间。

16.3 难点三:分区查询仍要保持全局分页语义

并发拆分容易,正确地取候选、重排和分页才是关键。pn*ps TopK 证明是方案二最适合展开讲的技术点。

16.4 难点四:收益与风险同时增加

分区减小单次工作集,但会把下游 QPS 翻倍;deadline 提高成功概率,但增加线程占用和尾延迟。方案通过灰度、开关、上限和回退控制这些权衡。

16.5 可迁移的工程经验

  • 技术异常降级为业务空数据时,要补充可观测性,避免“失败看起来像成功”。
  • 优化分页接口前,先检查是否存在“分页前全量 enrich/sort”。
  • 拆分查询必须同时证明集合覆盖、排序一致和分页正确。
  • 配置化不是简单加开关,还要有安全默认值、硬上限、发布顺序和回滚路径。
  • 日志统计要固定窗口和调用来源,不能跨窗口混算。

17. 面试前速记卡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
现象:大追番用户手机端空列表,Web 正常

直接原因:
100ms deadline -> DEADLINE_EXCEEDED -> default instance -> 空列表

性能根因:
全量收藏 -> 全量 miniSeason -> 全量修正/排序 -> 最后分页

关键数据:
15min:563,835,总 >100ms 55,>150ms 10,>200ms 0
1h:1,972,439,总 >100ms 160,>180ms 7
180ms 样本内理论覆盖:(160-7)/160 = 95.6%
上游预算:约 250ms

方案一:
Paladin 动态 deadline,默认 100ms,灰度到 180ms
状态:已合入 master

方案二:
follow 过滤前移
card 并发 JP=1 / CHN=4
每区取 Top(pn*ps)
合并 -> mtime 全局排序 -> skip/limit
开关默认关,pn*ps>100 回退
状态:分支已实现测试,未合 master

方案三:
Redis ZSet 索引,先分页 ID 后补当前页
状态:设计,未实现

主动说边界:
方案二仍全量拉收藏关系
QPS 翻倍
同 mtime tie-break
单分区失败可能部分返回
没有生产数据就不宣称实际下降百分比

18. 简历表述

约 180 字版本:

大追番用户空列表治理:定位 100ms RPC deadline 与分页前全量补充、排序导致的超时降级;基于近 200 万请求样本及 250ms 上游预算,将 deadline 改为 Paladin 动态配置并灰度至 180ms,理论覆盖 95.6% 的 100~180ms 尾部请求。进一步实现日番/国创分区异步查询,将 seasonType 过滤前移至信息补充前,按 pn*ps 拉取候选并全局排序分页,配套动态开关、深分页回退和单元测试保障灰度兼容。

更偏技术实现版本:

治理大追番用户列表空数据问题,梳理 AppCard、card-service、follow-service 与 favorite/season 下游链路,定位超时被降级为默认空对象;落地动态 deadline 灰度,并实现 seasonType 过滤前移、日番/国创 gRPC future 并发查询及 TopK 合并分页,以开关、硬上限、旧链路回退和跨模块测试控制一致性及发布风险。


19. 复盘结论

这次工作的完整价值链是:

1
2
3
4
5
6
7
从用户空数据现象出发
-> 识别“技术异常伪装成业务空”的降级机制
-> 用调用链定位分页前全量处理
-> 用日志分布和预算约束止血参数
-> 用分区、过滤前移和 TopK 合并降低工作集
-> 用开关、回退、测试和发布顺序控制风险
-> 用索引化方案规划长期根治

面试时最重要的不是背代码,而是清楚表达四点:

  1. 你如何证明根因;
  2. 为什么设计三层方案;
  3. 方案二如何保持分页语义;
  4. 哪些结论已经证实,哪些仍需要生产灰度验证。

凡人修仙传复播压测与容量保障:面试复盘 SOP

用途:面试前快速恢复项目背景、排查方法、技术方案和个人贡献边界。

复盘范围:2026-06-03 至 2026-06-13 前后的凡人修仙传复播压测、扩容、限流与本地缓存优化。

代码基线:本文代码引用以当前 master 为主;历史方案通过具体 Git 提交说明。后续代码已经继续演进,不能把当前实现的所有细节都归因于本次压测。


1. 一页速览

1.1 简历表述

凡人修仙传复播压测与扩容:基于 moni 批量采集并分析 view-gateway、player-gateway 及核心下游 RPC 的 QPS 变化,结合调用链放大比例定位限流与容量瓶颈,推动补齐下游限流配置,并落地 Caffeine 本地缓存、批量 miss 回源及空结果保护等优化,最终保障两条核心播放入口及其下游链路稳定承载合计 140k+ QPS。

1.2 30 秒口述版

凡人修仙传复播前,我们对播放页和播放地址两条核心链路做了专项压测。我的主要工作是通过 moni 批量拉取 view-gateway、player-gateway 及其下游 RPC 的 QPS 和错误变化,以低错误率窗口建立调用放大基线,再在逐级升压过程中判断下游流量是否符合预期,并区分限流、超时和熔断问题。针对发现的热点回源和配置瓶颈,我们补齐了限流配置,在 view、player、season、pay 等模块增加短 TTL 本地缓存、批量 miss 回源和空结果保护。最终两条核心入口按双机房各 70k QPS 的目标完成保障,合计口径为 140k+ QPS。

1.3 一句话技术主线

1
2
先建立健康流量基线 -> 计算上下游放大比 -> 分阶段升压 ->
用错误码、限流 usage 和资源水位归因 -> 限流/扩容/缓存治理 -> 复压验收

1.4 我在这个项目中的核心价值

不是单纯“看监控”或“加缓存”,而是把入口流量、调用链、下游放大、限流配置和代码回源行为串成一条可验证的证据链:

  1. 将大量分散的 moni 面板数据批量化、结构化,减少人工逐图核对的成本。
  2. 用健康窗口的真实调用比例判断压测时的 QPS 是否合理,而不是只看绝对值。
  3. 把故障分成限流、容量、超时、熔断和业务预期错误,避免错误扩容或盲目放开限流。
  4. 将压测发现转化为具体工程动作,包括限流配置、本地缓存、批量 miss 回源、负缓存和预热保护。
  5. 在复压阶段重新检查入口目标、下游 QPS、错误率和限流 usage,形成闭环。

2. 背景、目标与指标口径

2.1 业务背景

凡人修仙传第五季计划于 2026-06-13 开播。历史上该 IP 更新时曾出现突发流量打满播放链路、核心应用实例批量 unhealthy、播放页白屏的问题,因此本次开播前需要对播放链路进行专项扩容和压测。

播放链路不是一个单体服务:

  • view-gateway 负责聚合播放详情页,需要请求 season、episode、crowd、operation、pay、review、DM、recall 等多个下游。
  • player-gateway 负责播放鉴权和流地址下发,需要请求视频物料、播放鉴权、进度、付费、广告、视频调度等下游。
  • 一个入口请求可能触发多个下游 RPC,同一接口也可能因模块组合、用户态或设备态分支被调用多次。

所以风险不只是“网关能否扛住入口 QPS”,更重要的是入口流量经过聚合后是否会在某个下游被放大并形成级联故障。

2.2 容量目标

指标 目标口径
单机房核心入口目标 35,000 QPS
双机房 view-gateway 入口目标 70,000 QPS
双机房 player-gateway 入口目标 70,000 QPS
两条核心入口合计 140,000+ QPS

目标由历史上可承载单机房 20,000 QPS 的容量基线按 1.75 倍放大得到:

1
2
3
20,000 x 1.75 = 35,000 QPS / 单机房
35,000 x 2 = 70,000 QPS / 双机房 / 核心入口
70,000 x 2 条核心入口 = 140,000 QPS

2.3 140k+ 的正确解释

面试时必须明确:

  • 140k+ 是 view-gateway 和 player-gateway 两条核心入口的合计目标口径。
  • 不能表述成“单个 view-gateway 承载了 140k”。
  • 不能把所有下游接口 QPS 的数学求和当成一个服务的实际承载 QPS。
  • 下游理论 QPS 是按入口 QPS 和接口放大比推导的容量规划值,不等于最终线上每个服务的实测峰值。

2.4 核心观测指标

指标 含义 用途
入口 QPS SeasonInfos 或 PlayURL/PlayView 的请求速率 压测水位基准
RPC Client QPS 网关向某个下游接口发出的请求速率 计算调用放大比
RPC Server QPS 下游实际收到的请求速率 检查调用方和提供方口径是否一致
错误 QPS 单位时间错误请求量 衡量故障影响面
错误率 错误 QPS / 总 QPS 区分低比例噪声和系统性故障
P99/999 延迟 尾部响应耗时 判断容量和超时风险
Navigator threshold/usage 下游针对调用方的限流阈值与使用率 判断是否撞限流
CPU/实例/core 服务资源与部署规模 判断真实容量是否足够
breaker 熔断发生量 判断故障是否已从慢请求扩散为链路保护

核心公式:

1
2
3
接口放大比 = 下游接口 QPS / 核心入口 QPS
下游目标 QPS = 接口放大比 x 核心入口目标 QPS
限流安全余量 = 下游限流阈值 - 下游目标 QPS

3. 播放链路与风险模型

3.1 核心调用链

flowchart LR
    Client[客户端进入播放页] --> View[view-gateway<br/>SeasonInfos]
    Client --> Player[player-gateway<br/>PlayURL / PlayView]

    View --> Season[season / episode]
    View --> Crowd[crowd]
    View --> Operation[operation / resource]
    View --> User[account / follow / vip]
    View --> Pay[pay / coupon]
    View --> Content[review / DM / recall]

    Player --> Auth[playauth / authunite]
    Player --> Video[video material / playurl]
    Player --> Progress[progress / history]
    Player --> PlayerPay[pay / coupon / vip]
    Player --> Ads[ads / delivery]

    Crowd --> Taishan[Taishan]

3.2 为什么入口流量会放大

放大来源包括:

  1. 一个播放页由多个并行模块聚合,每个模块可能调用一个或多个下游。
  2. 同一业务可能按多个对象查询,例如 season、section、episode、aid、cid。
  3. 用户态和非用户态分支不同,登录用户可能额外查询关注、付费、进度等信息。
  4. 某些模块可能对同一数据重复回源,或者批量查询没有只请求 miss 部分。
  5. 下游自身还可能继续访问二级下游,例如 view -> crowd -> Taishan。
  6. 缓存命中率、压测账号特征、客户端版本和设备参数都会改变调用比例。

因此“下游 QPS 约等于入口 QPS”不是默认事实,必须用健康窗口的数据建立基线。

3.3 代码入口证据

  • view-gateway gRPC 入口:app/view/gateway/src/main/java/pleiades/business/gateway/view/controller/ViewV2RpcController.java:46
  • view-gateway HTTP v2 入口:app/view/gateway/src/main/java/pleiades/business/gateway/view/controller/ViewV2Controller.java:64
  • 播放页聚合服务:app/view/gateway/src/main/java/pleiades/business/gateway/view/module/view/v2/service/ViewSeasonV2Service.java:319
  • player-gateway 控制层:app/player/gateway/src/main/java/pleiades/business/gateway/player/controller/
  • player-gateway 核心播放逻辑:app/player/gateway/src/main/java/pleiades/business/gateway/player/module/play/service/

4. 压测保障 SOP

4.1 阶段一:确认范围和成功标准

输入

  • 开播时间、预估峰值和历史故障。
  • 核心入口:SeasonInfos、PlayURL/PlayView。
  • 环境:prod。
  • 机房:sh001、sh004,同时确认是否有逻辑机房合并口径。
  • 目标:单机房 35k,双机房每个核心入口 70k。

操作

  1. 明确压测请求的端、版本、登录态、season/episode 和 caller。
  2. 固定入口接口和统计维度,防止同一轮压测中途更换口径。
  3. 确认压测开始、升压、降压和结束时间,所有监控使用同一绝对时间窗口。
  4. 列出核心网关的一层下游和关键二层下游。
  5. 定义验收标准:入口 QPS、错误率、P99、限流 usage、CPU 和实例健康状态。

输出

  • 压测范围表。
  • 入口与下游依赖图。
  • 目标 QPS 和验收指标。
  • 监控链接及固定时间窗口。

容易遗漏

  • 不同机房的限流配置和部署规模可能不对称。
  • view-gateway 和 player-gateway 可能存在多个 app id 或调用方,需要分别纳入。
  • HPA 是兜底,不应把“压测时能扩起来”当成常态容量已经满足。

4.2 阶段二:盘点容量和限流配置

压测前先做静态盘点,避免压测一开始就被旧限流配置挡住。

容量盘点

检查:

  • 实例数、CPU request/limit、机房分布。
  • 历史承载水位和峰值 CPU。
  • 核心线程池、连接池和 RPC deadline。
  • 服务是否依赖 HPA 才能达到目标容量。

扩容方案文档采用“历史抗住 2 万时的配额乘以 1.75”作为 3.5 万目标的初步估算。文档中的核心资源估算为:

项目 core 数
历史抗 2 万的总配额 15,544c
3.5 万目标估算 27,202c
当时现状 5,930c
初步缺口 21,272c

这只是线性容量规划的起点,网关聚合性能、缓存命中率和下游瓶颈不能仅靠线性加机器解决。

限流盘点

针对每条下游调用记录:

字段 示例
caller open.bangumi.view-gateway
provider open.bangumi.operation-service
method Operation/DictContains
sh001 threshold 12,000
sh004 threshold 12,000
目标 QPS 放大比乘以入口目标
动作 调高、对齐机房、保持或先扩容

判断原则:

  1. 已启用且阈值低于目标:需要评估调高,但必须先确认提供方容量。
  2. 未配置或未启用:当前不会挡量,但不代表没有风险,仍需确认容量和保护策略。
  3. 两机房阈值不一致:先解释原因,再决定是否对齐。
  4. 接口没有独立限流但受服务整体限流影响:按整体限流核算。

4.3 阶段三:建立健康窗口放大基线

选择一段入口稳定、错误率低、没有明显限流和熔断的窗口,记录入口与所有核心下游的 QPS。

本次复盘使用 2026-06-04 16:00 至 16:05 作为主要健康基线窗口。在约 7.7k 入口 QPS 下,关键放大关系包括:

调用链 入口 QPS 下游 QPS 放大比
view -> Crowd/VerifyCrowd 7.7k 23.0k 3.004x
view -> Relation/Relations 7.7k 15.3k 2.001x
player -> Video/GetVideoMaterialInfo 7.7k 15.4k 2.000x
player -> Ads/SuitAds 7.7k 15.4k 1.991x
view -> Operation/DictContains 7.7k 8.1k 1.064x
season-gateway -> Media/Cards 7.7k 8.0k 1.043x
view -> Season/Profile 7.7k 7.9k 1.033x
view -> Episode/ListBySection 7.7k 7.8k 1.022x

如何解释放大比

  • 约 1x:通常表示大部分入口都会调用一次。
  • 约 2x 或 3x:需要结合代码确认是合理的多对象/多模块查询,还是重复调用。
  • 远低于 1x:可能只有部分用户或业务分支调用,也可能有缓存。
  • 升压后比例明显下降:可能是缓存开始生效,也可能是下游已经限流或失败。
  • 升压后比例明显上升:需要检查重试、循环调用、缓存失效和异常回源。

不能仅凭“比例不是整数”判断异常,因为压测用户、设备、版本和业务分支都会影响比例。

4.4 阶段四:从 moni 批量获取上下游 QPS

为什么需要批量取数

view-gateway 和 player-gateway 分别涉及几十个下游接口。人工逐个切换 Grafana 变量和时间窗口存在三个问题:

  1. 取数慢,压测期间无法快速反馈。
  2. 容易出现时间窗口、机房或环境不一致。
  3. 不利于把多轮压测结果做横向比较。

因此采用“读取仪表盘查询定义,替换变量,批量请求数据源,再结构化输出”的思路。

moni 取数参数

至少固定以下变量:

变量 说明
datasource 监控数据源
app_group 应用组,例如 open.bangumi.view-gateway
app 具体应用
env prod
zone sh001、sh004 或全量
grpc_caller RPC 调用方筛选
grpc_method RPC Server 方法筛选
grpc_client_method RPC Client 下游方法筛选
from/to 绝对时间窗口
step 查询采样间隔

批量取数流程

flowchart TD
    A[获取 Grafana dashboard 定义] --> B[枚举入口与 RPC Client 面板]
    B --> C[替换 app/env/zone/时间等变量]
    C --> D[批量查询监控数据源]
    D --> E[按 service/method/code 归一化序列]
    E --> F[计算 avg/max/error rate/放大比]
    F --> G[输出本轮压测对比表]

建议输出格式

round window gateway entry_qps downstream downstream_qps ratio error_qps error_rate limit_usage
baseline 16:00-16:05 view 7,700 Crowd/VerifyCrowd 23,000 3.004 … … …

取数校验

批量脚本不是权威本身,至少要做以下交叉检查:

  1. 随机抽取 2 至 3 个接口,与 Grafana 面板肉眼读数核对。
  2. 确认入口与下游使用完全相同的起止时间。
  3. 检查 QPS 使用平均值、最大值还是末值,报告中必须统一。
  4. 同时检查调用方 Client QPS 和提供方 Server QPS,避免只看单边指标。
  5. 对无数据面板区分“接口没有调用”和“查询条件错误”。

4.5 阶段五:逐级升压并记录故障扩散

不要直接打到目标峰值。逐级升压可以判断瓶颈首先出现在哪一层,以及问题是否随压力线性增长。

2026-06-05 的部分巡检记录:

时间 view QPS player QPS 主要现象
15:07 5.0k 5.1k Resource 开始出现高错误;Ads 已有明显错误
15:15 9.8k 9.8k Resource、SuitAds、HighDefinitionTrial 错误继续放大
15:28 17.5k 17.5k Ads、Resource、Recall 等大面积升高
16:30 14.6k 14.6k SuitAds、Resource、DM 等仍处于高错误区
16:38 18.2k 19.3k Ads、Resource、DM 等链路进一步扩散

每一轮至少记录:

  • 入口 QPS 和持续时间。
  • 核心下游 QPS 与放大比。
  • 错误 QPS、错误率和主要错误码。
  • Navigator threshold/usage。
  • 网关及下游 CPU、实例健康、P99。
  • 降压后错误和 breaker 是否同步恢复。

4.6 阶段六:按证据归因,而不是按现象猜测

故障分类决策表

现象 优先判断 需要核对
-509 增长,limit usage 接近 100% 撞下游限流 threshold、caller、机房配置
-504 增长但没有明显 509 下游慢或容量不足 P99、CPU、连接池、deadline
breaker 大量出现 慢请求或错误已经触发熔断 熔断窗口、下游恢复速度
-429 入口或业务侧主动限流 网关限流、业务限流配置
错误随入口近 1:1 增长且业务确认可预期 压测账号或业务预期错误 业务码语义、是否影响主流程
下游 QPS 超过健康放大比 重复调用、重试或缓存失效 调用点、重试策略、缓存命中
下游 QPS 低于预期且错误率升高 请求可能已被限流/熔断 Client/Server QPS、错误码

重点故障链路

链路 峰值错误 QPS 峰值错误率 主要问题 排查方向
view -> Resource/Receive 17.3k 94.7% 限流 + breaker 核对 1,400 阈值及 resource 容量
player -> Ads/SuitAds 27.9k 72.5% breaker + 509 + 504 约 2x 放大,确认 Ads 全局承载
player -> Ads/HighDefinitionTrial 17.3k 89.5% breaker + 509 评估独立扩容或拆分限流
view -> DM/SubjectInfos 13.5k 63.3% breaker + 509 + 504 检查 view 并发与 DM 容量
view -> Recall/GetUgcSection 11.4k 65.3% 504 + breaker 检查耗时、连接池和超时
player -> Delivery/GetPageDeliveryInfo 9.4k 48.8% 504 + breaker 检查 delivery 容量
crowd -> TaishanProxy/batch_get 11.0k 15.9% breaker + 504 检查二级下游放大与承载
view -> Operation/DictContains 7.0k 37.0% 509 + breaker Navigator usage 100%,先核对规则
view -> ReviewUser/MediaStatus 4.5k 24.9% 509 + breaker + 504 限流接近打满并存在容量问题

典型限流证据

调用方 -> 下游接口 threshold usage 结论
view -> Operation/DictContains 12,000 100.0% 已打满
view -> Resource/Receive 1,400 100.0% 已打满
view -> ReviewUser/MediaStatus 14,000 99.5% 接近打满
player -> Progress/ByMid 35,000 60.4% 观察,尚未打满
player -> Playurl/ProtobufPlayurl 35,000 60.4% 观察,尚未打满
view -> PayInfoByUser 38,500 42.9% 观察

结论:限流确实存在,但不是全部问题。只调高阈值无法解决 504、连接池拥塞和 breaker;放开限流前必须确认提供方容量。

4.7 阶段七:按问题类型制定动作

优先级矩阵

优先级 条件 动作
P0 核心播放入口不可用、错误率快速上升 立即降压,启用限流/降级,保护主链路
P1 下游限流已打满但服务有容量余量 调整阈值并复压
P1 下游 504/breaker 且资源不足 扩容、优化超时/连接池或削减调用
P1 热点相同数据重复回源 增加短 TTL 本地缓存和批量 miss 回源
P2 空结果持续回源 增加负缓存,但异常结果不得写负缓存
P2 扩容启动阶段形成突发预热流量 限制预热任务和并发
P3 业务预期错误影响监控判断 保留原始数据,在核心瓶颈排序中降噪

动作选择原则

1
2
3
4
5
如果是限流配置低:确认下游容量后调高阈值
如果是服务本身容量不足:扩容或优化服务
如果是重复回源:本地缓存、请求合并、批量 miss 回源
如果是热点穿透:限流、负缓存或预刷新兜底
如果是非核心模块故障:评估降级,优先保障播放主链路

4.8 阶段八:复压和验收

改造完成后不能只看代码上线成功,需要按相同压测模型重新验证。

验收清单

  • view-gateway 双机房达到 70k QPS 目标。
  • player-gateway 双机房达到 70k QPS 目标。
  • 两条核心入口合计达到 140k+ QPS。
  • 核心下游 QPS 与健康放大模型基本一致。
  • 已治理接口不再持续出现 509、504 或 breaker 扩散。
  • Navigator usage 保留安全余量,不贴近 100%。
  • 网关和核心下游 CPU、线程池、连接池、P99 可接受。
  • 降压后错误和 breaker 能快速恢复。
  • 本地缓存命中后,下游 QPS 有符合预期的削峰。
  • sh001、sh004 的容量和限流均经过验证,不能只用一个机房的结论外推。

最终结果口径

根据项目结论,两条核心播放入口及其下游链路最终完成 140k+ QPS 保障。当前资料没有提供最终轮所有接口的 P99、错误率和缓存命中率精确值,面试时不要临时编造;如被追问,应说明验收维度和已确认的总入口结果。


5. 代码改造复盘

5.1 通用限流能力

Redis 集群级限流

view-gateway 已有 GlobalLimiter,使用 Redisson RRateLimiter 按 seasonId 或 epId 做集群级限流:

  • 入口调用:app/view/gateway/src/main/java/pleiades/business/gateway/view/controller/ViewV2Controller.java:80
  • 核心实现:app/view/gateway/src/main/java/pleiades/business/gateway/view/module/ratelimit/GlobalLimiter.java:25
  • season 判断:GlobalLimiter.java:82
  • episode 判断:GlobalLimiter.java:95
  • 限流器异常时默认放行:GlobalLimiter.java:57

当前仓库默认配置:

1
2
3
4
5
6
limit:
globalOn: false
seasonLimit:
rate: 0
epLimit:
rate: 0

配置位置:app/view/gateway/src/main/resources/ratelimit.yml:1。

注意:仓库默认值不等于生产配置中心的实时值,面试时不能根据本地 yml 断言生产一定关闭。

单机接口限流

InternalLimiter 使用 Guava RateLimiter,根据功能名读取规则并执行 tryAcquire():

  • 实现:app/view/gateway/src/main/java/pleiades/business/gateway/view/module/ratelimit/InternalLimiter.java:19
  • 规则匹配:InternalLimiter.java:23
  • 令牌获取失败返回限流:InternalLimiter.java:36
  • 分页 RPC 调用点:app/view/gateway/src/main/java/pleiades/business/gateway/view/controller/ViewMossRpcController.java:58

集群限流与单机限流的区别

维度 Redis/Redisson 集群限流 Guava 单机限流
统计范围 整个集群共享 每个实例独立
扩容影响 总阈值不随实例数自然增加 总允许量约为单机阈值乘实例数
外部依赖 Redis 无远程依赖
适用场景 season/ep 全局保护 单功能快速保护、局部降级
风险 Redis 异常、中心热点 实例数变化导致总阈值变化

5.2 凡人 season 专用预刷新兜底

提交 60d50201116(2026-06-02,凡人降级策略测试)设计了凡人 season 专用兜底:

flowchart TD
    A[SeasonInfos 请求] --> B{开关开启且 season 在白名单?}
    B -- 否 --> N[执行正常聚合]
    B -- 是 --> C{是否为兜底刷新线程?}
    C -- 是 --> N
    C -- 否 --> D{singleNodeQps > 0?}
    D -- 否 --> F[读取内存兜底]
    D -- 是 --> E{RateLimiter.tryAcquire?}
    E -- 成功 --> N
    E -- 失败 --> F
    F --> G{兜底是否存在?}
    G -- 是 --> H[直接返回预刷新响应]
    G -- 否 --> N

配置项

配置 默认值 含义
enable false 总开关
seasonIds 空集合 需要保护的凡人 season
singleNodeQps 50 单实例允许进入完整聚合链路的 QPS
refreshIntervalMs 30000 兜底刷新周期
mobiApp iphone 刷新使用的端
platform ios 刷新使用的平台
build 89600000 刷新使用的版本

关键设计

  1. 按 seasonId 保存预刷新的 SeasonInfosReply。
  2. 按 seasonId 保存单机 RateLimiter。
  3. 启动时先刷新,降低首次触发兜底为空的概率。
  4. 默认每 30 秒刷新一次。
  5. 使用匿名用户、无 buvid、固定 iOS 版本生成通用响应,避免缓存用户私有数据。
  6. 用 ThreadLocal 标记刷新线程,使刷新请求绕过兜底,避免把旧兜底再次写回。
  7. 如果兜底为空,则回到正常链路,避免直接返回空响应。

历史代码证据:

  • 60d50201116:.../module/fallback/FanrenSeasonFallback.java:20
  • 60d50201116:.../module/fallback/FanrenSeasonFallback.java:91
  • 60d50201116:.../scheduler/FanrenSeasonFallbackScheduler.java:46
  • 60d50201116:.../scheduler/FanrenSeasonFallbackScheduler.java:52
  • 60d50201116:.../module/view/v2/service/ViewSeasonV2Service.java:331

必须说明的上线边界

该提交只存在于 origin/feat/fanren-season-fallback,不在当前 master。因此面试时应该说:

我参与设计和验证了凡人 season 的单机限流与预刷新内存兜底方案。

不要在缺少其他上线证据时说:

这套凡人专用兜底已经在生产全量上线并承担了全部流量。

5.3 player-gateway 本地缓存

提交 1ba8445f9e6(2026-06-04,凡人本地缓存优化)为 player 链路补充短 TTL 缓存。

缓存配置

缓存 TTL 最大条目 适用数据
默认 Caffeine CacheManager 2s 50,000 高频、允许极短暂延迟的数据
videoMaterialCacheManager 10s 50,000 变化较慢的视频物料

代码:app/player/gateway/src/main/java/pleiades/business/gateway/player/config/CacheConfig.java:27。

主要覆盖接口

  • episode 按 season 查询:EpisodeService.java:239、EpisodeService.java:260
  • episode 稿件信息:EpisodeService.java:409
  • 视频 OP/ED 点位:VideoMaterialRpcService.java:97
  • 品牌报备信息:VideoMaterialRpcService.java:123
  • 视频物料详情:VideoMaterialRpcService.java:146
  • 相关推荐:SeasonServantRpcService.java:41
  • 活动配置、应用配置、Freya 配置等高频查询。

为什么物料缓存使用 10 秒

视频物料相对 season/episode 用户可见状态变化更慢,允许更长 TTL。更长 TTL 可以明显降低约 2x 放大的 GetVideoMaterialInfo 回源压力,同时把陈旧窗口控制在秒级。

5.4 view-gateway 本地缓存

主要缓存对象包括:

数据 策略 当前代码证据
Season Profile @Cacheable,空值不缓存 SeasonRpcService.java:181
Season Profile Future @Cacheable,空值不缓存 SeasonRpcService.java:227
Series Season Caffeine,2s SeasonRpcService.java:48
Season Type Caffeine,10s SeasonRpcService.java:53
Season Card Caffeine,10s,批量 miss 回源 SeasonRpcService.java:77、:513
Episode 单查/批量/按 aid、cid @Cacheable EpisodeRpcService.java:222 起
Section Episode Caffeine + 空结果哨兵 EpisodeRpcService.java:406
PGC Rank @Cacheable CardRankRPCService.java:40

当前代码已经进一步使用 requestVariantKeyGenerator、RequestVariantSnapshot 和 CacheKeyPolicy 隔离不同请求上下文。这是后续演进后的状态;面试时可以把它作为“缓存键必须覆盖业务维度”的设计认识,但不要把所有后续演进都说成本次压测当天完成。

5.5 season-gateway 本地缓存

MediaService 对媒体卡片和榜单信息做短 TTL 缓存:

数据 TTL 最大条目
Media Card 2s 50,000
Rank Info 10s 500

批量查询先检查本地缓存,只把 miss 的 seasonId 回源:

  • 缓存定义:app/season/gateway/src/main/java/pleiades/business/gateway/season/module/rpc/media/MediaService.java:41
  • 批量 miss 回源:MediaService.java:98

5.6 pay-service 本地缓存

提交 1ba8445f9e6 对 pay-service 的 season 和 episode 查询增加本地缓存,核心 TTL 为 3s:

  • LOCAL_CACHE_SECONDS = 3:app/pay/service/src/main/java/pleiades/business/service/pay/service/rpc/SeasonService.java:70
  • season card 缓存:SeasonService.java:89
  • episode info 缓存:SeasonService.java:94
  • season 批量 miss 回源:SeasonService.java:179
  • episode 批量 miss 回源:SeasonService.java:272

当前代码还包含 aid、section 和新模型路由等更多缓存,它们存在后续演进,复盘时应以具体 Git diff 区分本次改造与后来新增能力。

5.7 批量 miss 回源

普通的整批缓存键有一个问题:请求 [1, 2, 3] 和 [1, 2, 4] 会成为两个完全不同的 key,即使大部分对象相同也无法复用。

批量 miss 回源的逻辑是:

flowchart LR
    A[批量 IDs] --> B[逐 ID 查本地缓存]
    B --> C[命中结果]
    B --> D[miss IDs]
    D --> E[只对 miss IDs 发批量 RPC]
    E --> F[结果逐 ID 写回缓存]
    C --> G[合并]
    F --> G
    G --> H[返回完整 Map]

收益:

  1. 热点 ID 可以跨不同批次复用。
  2. 下游收到的请求只包含真正 miss 的对象。
  3. 减少整批重复回源和网络传输。
  4. 更适合 season/episode/media 这种天然按 ID 查询的数据。

5.8 空结果与异常不缓存

2026-06-08 至 06-09,Episode/ListBySection 连续补充了空结果缓存和异常保护:

  • 321b69ebbf2:缓存空的 listBySection 结果。
  • 61504db523a:简化负缓存实现。
  • 2a5c1da3608、a03a0423ea3:修正异常场景,避免把 RPC 失败写成“不存在”。
  • 8954476c96d、b5ff2f0996c:补充说明并重命名。

当前逻辑:

  1. 回源正常完成,但某些 sectionId 没有结果:写入空结果哨兵。
  2. RPC 超时或异常:本次降级为空 Map,但不为 miss ID 写入空结果哨兵。
  3. 从缓存读取后过滤哨兵,不把内部占位对象返回给业务层。

代码:app/view/gateway/src/main/java/pleiades/business/gateway/view/module/rpc/EpisodeRpcService.java:406、:464。

这是面试中的重要设计点:

负缓存用于防止确定不存在的数据反复穿透,但瞬时 RPC 故障不能被缓存成业务不存在,否则会把短暂故障扩大为整个 TTL 窗口内的稳定错误。

5.9 缓存回滚说明

1ba8445f9e6 曾对 season-gateway 的 Freya 相关推荐实验查询增加缓存。提交 a35abc9791306(2026-06-05,修复相关推荐无法加载的问题)移除了该缓存。

这说明缓存不能只看 QPS:

  • 必须确认返回值是否受用户、设备、实验和请求顺序影响。
  • 列表参数应规范化,但规范化本身也可能改变业务语义。
  • 命中率高不代表业务正确。
  • 发现相关推荐异常后应优先回滚,再重新设计缓存键和适用范围。

面试时可以把它描述为“压测优化过程中同步关注业务正确性,发现缓存语义不安全后及时回滚”,而不是回避这个迭代。

5.10 启动预热保护

middleware/warmup 的单次预热任务数从 2000 降至 100:

  • 当前值:middleware/warmup/src/main/java/ogv/middleware/warmup/AbstractHealthCheckAdaptor.java:21
  • 批量预热逻辑:AbstractHealthCheckAdaptor.java:45

目的不是提升单次预热速度,而是减少大规模扩容和实例重启时的瞬时回源,避免“为了扩容而启动的新实例反过来打垮下游”。

5.11 压测专用空响应

提交 f1cba7ea3c2(2026-06-03)对 caller test.ep.melloi01 增加压测特殊响应,后续提交补充了 null-safe 字符串比较。

该能力只能描述为“压测流量隔离/特殊响应入口”,不能描述为生产业务限流或凡人兜底。当前代码在特殊响应完成后仍会继续执行普通响应,若后续复用还需要检查双响应风险,因此本文不把它列为核心生产保障成果。

5.12 反泄漏告警限流不是业务限流

2026-06-12 的 release/leak-monitor-ep177-178 分支为未上架剧集增加 playurl 反泄漏告警:

  • 53180b6873f:增加临时监控。
  • 055d984039f:将告警频率控制改为令牌桶,约 10 秒一个令牌。
  • 5a8e6cbeb44:card 侧改为有界队列和丢弃策略。

这里限制的是企业微信告警发送频率,不是播放请求 QPS。它可作为开播前风险保障的补充,但不能用来解释播放链路为何能承载 140k+ QPS。


6. 为什么选择短 TTL 本地缓存

6.1 选择 Caffeine 的原因

  1. 进程内访问,无网络开销,适合极高频热点读。
  2. 不增加 Redis 的额外压力和单点热点。
  3. 即使外部缓存异常,本地仍可吸收一部分重复请求。
  4. 数据量有限、TTL 很短,内存成本可控。
  5. 对 season、episode、物料等读多写少的数据收益明显。

6.2 为什么不是永久缓存

播放页数据可能受上下架、地区、客户端版本、实验、用户态和运营配置影响。永久缓存会放大数据一致性问题,因此采用 2 秒、3 秒、10 秒等短 TTL,在削峰和新鲜度之间折中。

6.3 TTL 选择逻辑

TTL 适用数据 权衡
2s 高频 season/episode/media 基础数据 新鲜度优先,吸收瞬时热点
3s pay 聚合依赖的 season/episode 数据 降低回源,同时控制权益相关陈旧窗口
10s 视频物料、排行榜、类型等低频变化数据 更强削峰,业务变化较慢

TTL 不应该凭感觉设置,应结合:

  • 数据实际更新频率。
  • 业务可接受陈旧时间。
  • 下游压力和目标削峰比例。
  • 本地实例数与缓存容量。
  • 是否有主动失效或版本隔离手段。

6.4 本地缓存风险

风险 应对方式
多实例数据短暂不一致 使用短 TTL,避免缓存强一致数据
缓存键缺少地区/版本/用户维度 使用规范化 key generator 和 request variant
空结果穿透 对确定空结果写哨兵
把 RPC 异常缓存为空 只在正常回源完成后写负缓存
同时过期导致瞬时回源 TTL 抖动、请求合并、预刷新或限流
内存无限增长 设置 maximumSize
Future/异常对象被缓存 明确 unless、异常恢复和缓存写入时机

7. Git 时间线与事实边界

日期 提交 内容 当前 master 状态 面试表述
06-02 60d50201116 凡人 season 限流 + 预刷新兜底 + 测试 未合入 方案设计与验证
06-03 f1cba7ea3c2 压测 caller 特殊空响应 已合入 压测辅助能力
06-04 1ba8445f9e6 多模块本地缓存与预热优化 已合入 核心落地改造
06-05 98a63adfb4f 继续补充本地缓存和测试 已合入 缓存迭代
06-05 a35abc9791306 修复相关推荐无法加载,回滚不安全缓存 已合入 正确性保护
06-08~09 多个提交 ListBySection 空结果和异常不缓存 已合入 防穿透完善
06-12 53180b6873f 等 上线前反泄漏告警保护 未合入 master 补充保障,非业务限流

已确认事实

  • 播放链路容量目标是单机房 3.5 万、双机房每条核心入口 7 万。
  • 健康窗口存在约 3x、2x 等真实下游放大。
  • 逐级升压过程中同时出现了 509、504 和 breaker,不能全部归因于限流。
  • 多个模块的本地缓存优化已经合入 master。
  • 凡人专用预刷新兜底只在功能分支中可见。
  • 最终 140k+ 是两条核心入口合计口径。

不应虚构的事实

  • 最终轮每个下游的精确峰值 QPS、P99 和错误率。
  • 所有缓存的最终命中率。
  • 某个单服务独立承载了 140k QPS。
  • 功能分支方案已经生产全量上线。
  • 所有相关提交都由个人独立完成。

8. 面试表达:STAR 版本

8.1 Situation

凡人修仙传复播会在开播时形成明显的热点流量。历史上同类更新曾把播放页链路打到实例 unhealthy,导致播放页白屏。播放页和播放地址网关又是聚合服务,入口请求会进一步放大到几十个下游,因此需要在开播前完成扩容、限流检查和全链路压测。

8.2 Task

我的任务主要有两部分:

  1. 在压测过程中快速拿到 view-gateway、player-gateway 及核心下游的 QPS、错误率和限流状态,判断流量变化是否符合依赖关系。
  2. 根据发现的问题推动限流、缓存和预热治理,减少热点回源和级联故障风险,并通过复压验证。

8.3 Action

我先选取低错误率压测窗口,批量拉取网关入口和 RPC Client 指标,计算下游放大比,例如 view 到 crowd 约 3 倍、player 到视频物料约 2 倍。之后在逐级升压过程中,把每一轮入口 QPS、下游 QPS、错误码和 Navigator usage 放到同一张表里对比。

对于 509 且 usage 接近 100% 的链路,我优先检查并推动调整限流配置;对于 504 和 breaker,则继续检查下游容量、连接池和超时,避免简单放开限流。代码侧针对 season、episode、media、视频物料等热点数据增加了 2 至 10 秒的 Caffeine 本地缓存,批量接口只回源 miss ID;对确定空结果增加哨兵缓存,但 RPC 异常不写空缓存。同时降低启动预热任务量,避免扩容阶段额外冲击下游。

8.4 Result

通过监控分析、限流配置治理、本地缓存和预热优化,播放页和播放地址两条核心入口最终按双机房各 7 万 QPS 的目标完成保障,合计承载 140k+ QPS,凡人修仙传复播期间播放链路保持稳定。

8.5 两分钟口述版

凡人复播前的主要风险是热点流量经过 view-gateway 和 player-gateway 聚合后向几十个下游放大。我的工作重点是把压测监控变成一条可验证的证据链。

首先,我固定每轮压测的绝对时间窗口,从 moni 批量获取两个网关的入口 QPS、下游 RPC Client QPS、错误 QPS 和 Navigator 限流 usage。然后选择 6 月 4 日基本无错误的窗口作为基线,算出不同接口的正常放大比,例如 view 到 crowd 约 3 倍、view 到 relation 约 2 倍、player 到视频物料约 2 倍。这样升压后就能判断下游增长是正常调用模型,还是重复回源或异常放大。

在 6 月 5 日从 5k 逐步升到接近 20k 的过程中,我们发现 Resource、Operation、Review 等接口存在限流打满,同时 Ads、DM、Recall、Delivery 还出现 504 和 breaker。这说明不能只调限流,必须同时做容量和调用削减。我们据此补齐或调整下游限流配置,并在 player、view、season、pay 等模块对高频 season、episode、media、视频物料数据增加 2 至 10 秒本地缓存;批量接口只查询 miss 数据,空结果使用负缓存,但 RPC 异常不写负缓存。此外还把扩容时的预热请求数从 2000 降到 100,减少新实例启动对下游的冲击。

最终两条核心入口按双机房各 70k 的目标完成保障,合计是 140k+ QPS。这个项目让我比较系统地实践了如何从入口 QPS、调用放大、限流和缓存几个层面做全链路容量治理,而不是只关注单个服务的 CPU。


9. 高频面试问题与回答要点

9.1 你是怎么判断下游 QPS 是否符合预期的?

先找一个错误率低、没有明显限流的健康窗口,计算每个下游接口的 下游 QPS / 入口 QPS。后续每轮升压都用同一统计口径比较实际值和基线值。如果入口翻倍、下游也按相同比例增长,通常符合调用模型;如果比例明显升高,检查重试、重复调用和缓存失效;如果比例下降但错误上升,检查是否已经被限流或熔断。

9.2 为什么不直接根据代码调用次数算放大比?

代码调用次数只能给出理论上限,实际调用还受登录态、设备、版本、实验、业务分支、缓存命中和空数据影响。健康窗口的真实监控数据更接近实际流量模型,代码用于解释监控结果,两者要互相验证。

9.3 怎么区分限流问题和容量问题?

  • 509 增长且 Navigator usage 接近 100%,优先判断为限流。
  • 504、P99 和 CPU 同时上升,但没有明显 509,优先判断容量或耗时问题。
  • breaker 增长说明慢请求或错误已经触发熔断,需要看下游恢复速度和熔断参数。
  • 即使有限流,也要确认放开后下游是否有真实容量,不能把限流器当成唯一瓶颈。

9.4 为什么选本地缓存而不是 Redis?

目标是吸收几秒内同一热点对象的重复请求。本地 Caffeine 没有网络开销,也不会把压力转移到 Redis;配合短 TTL 和容量上限,可以用较小的一致性代价快速削峰。强一致或跨实例共享的数据仍不能只靠本地缓存。

9.5 为什么 TTL 是 2 秒、3 秒或 10 秒?

TTL 根据数据变化频率和业务可接受陈旧时间分层:season/episode 等页面基础数据使用 2 至 3 秒,视频物料、排行榜等变化更慢的数据使用 10 秒。目标不是长期缓存,而是合并热点窗口内的重复请求。

9.6 批量 miss 回源解决了什么问题?

它把缓存粒度从“整批参数”降到“单个业务 ID”。不同批次只要包含相同热点 ID 就可以复用缓存,并且只把未命中的 ID 批量发给下游,避免部分 miss 导致整批回源。

9.7 为什么要缓存空结果?

确定不存在的数据如果不缓存,每个请求都会穿透到下游。但只有 RPC 正常完成且明确没有该 ID 时才能写空结果;超时和异常不能写负缓存,否则会把临时故障固化成业务不存在。

9.8 本地缓存最大的风险是什么?

缓存键缺少业务维度和数据短暂不一致。播放页结果可能受地区、客户端版本、实验、用户态影响,所以要用 request variant 隔离,并避免缓存强用户态数据。短 TTL、最大容量、异常不缓存和可快速回滚同样重要。

9.9 为什么不能只扩容网关?

网关入口每增加 1 QPS,某些下游可能增加 2 至 3 QPS。只扩网关会让它更快地把压力传给下游,甚至扩大故障。容量规划必须按调用放大比覆盖核心下游。

9.10 为什么不能只调高限流阈值?

限流是保护手段,不是容量本身。压测中除了 509,还存在 504 和 breaker。如果下游实例、线程池或连接池没有容量,调高阈值只会把明确的限流错误变成更严重的超时和级联故障。

9.11 凡人专用兜底为什么使用预刷新响应?

播放页完整聚合依赖很多下游,热点 season 超限后继续实时聚合仍会传递压力。预刷新响应可以在内存中快速返回,绕开大部分下游。用匿名、无设备标识的固定端请求生成,是为了减少用户私有信息和设备差异进入共享兜底。

9.12 如果兜底刷新失败怎么办?

现有方案保留上一份成功缓存;如果启动阶段还没有兜底,则回退正常逻辑而不是返回空结果。工程上还应增加刷新成功率、缓存年龄和兜底触发量监控,并准备关闭开关。

9.13 140k 是怎么计算的?

单机房目标 35k,双机房每条核心入口 70k。view-gateway 和 player-gateway 两条入口合计 140k+。它不是单服务 QPS,也不是所有下游接口 QPS 的求和。

9.14 你个人具体做了什么?

建议按真实参与程度回答:

我主要负责/参与压测期间的监控取数和依赖分析,把两个核心网关及下游的 QPS、错误和限流状态批量化对比;根据健康基线判断放大是否合理,并参与推动限流配置和本地缓存优化。代码层面我重点关注热点 RPC 缓存、批量 miss 回源、空结果保护和凡人 season 兜底方案。扩容执行和部分下游改造由各服务负责人协同完成。

不要把团队所有扩容、所有服务改造和全部提交都表述成个人独立完成。

9.15 如果再做一次,你会改进什么?

  1. 压测前自动生成“目标 QPS、当前限流、当前容量、理论放大”的差异报告。
  2. 为每轮压测自动保存绝对时间窗口和 dashboard 快照,避免复盘时依赖相对时间。
  3. 为缓存增加命中率、回源 QPS、空缓存命中和缓存年龄指标。
  4. 预先定义错误码降噪规则,区分业务预期错误与容量错误。
  5. sh001、sh004 使用同一套脚本独立验证,不从单机房直接外推。
  6. 对高风险降级方案做故障注入,包括刷新失败、缓存为空和配置热更新。

10. 面试时容易被追问的技术细节

10.1 限流优先级

凡人专用方案中的判断优先级:

优先级 条件 结果
1 刷新线程 绕过兜底,执行正常聚合
2 开关关闭或非目标 season 正常聚合
3 singleNodeQps > 0 且拿到令牌 正常聚合
4 超限或 singleNodeQps <= 0,且有缓存 返回兜底
5 需要兜底但缓存为空 回退正常聚合

10.2 为什么限流器异常默认放行

GlobalLimiter 当前实现中 Redis 限流器异常会默认放行,优点是避免 Redis 故障直接阻断所有播放页请求,缺点是失去保护后可能把压力传递给下游。这是可用性与保护性的取舍,应通过限流器异常告警和其他下游保护补足。

10.3 为什么缓存 Future 需要谨慎

Spring Cache 如果直接缓存 CompletableFuture,需要确认:

  • 异常 Future 是否会被缓存。
  • Context 是否能跨请求安全复用。
  • 返回对象是否可变。
  • key 是否覆盖请求上下文。
  • 超时降级的默认值是否应该进入缓存。

本项目后续引入 request variant 和更明确的缓存支持工具,也是为了降低这些风险。

10.4 如何防止缓存击穿

短 TTL 缓存仍可能在同一时刻过期。可选方案包括:

  • 单 key 请求合并或 single-flight。
  • TTL 增加随机抖动。
  • 热点数据预刷新。
  • 过期后短时间返回旧值并异步刷新。
  • 对回源链路增加限流。

本次凡人 season 的预刷新兜底属于热点数据预计算思路。

10.5 如何验证缓存真的有效

不能只看代码存在 @Cacheable,要比较改造前后:

  1. 相同入口水位下的下游 QPS。
  2. 缓存 hit/miss 和回源 QPS。
  3. 下游错误率和 P99。
  4. 网关内存、GC 和 CPU。
  5. 数据正确性和陈旧窗口。

11. 复盘检查表

面试前 5 分钟

  • 能解释为什么是 35k、70k、140k 三个数字。
  • 能画出 view/player 到核心下游的调用图。
  • 能说出至少三个放大比:3.004x、2.001x、2.000x。
  • 能区分 509、504 和 breaker。
  • 能解释为什么限流和扩容不能互相替代。
  • 能说明 2s、3s、10s 缓存的选择逻辑。
  • 能讲清批量 miss 回源和异常不写负缓存。
  • 能说明凡人专用兜底未进入当前 master。
  • 能说明告警令牌桶不是播放请求限流。
  • 能用 STAR 在两分钟内讲完项目。

面试表达边界

  • 使用“参与、负责其中一部分、推动协同”,不把团队成果全部归为个人。
  • 只说有证据的精确数字。
  • 不把理论目标 QPS 当成所有服务的实测 QPS。
  • 不把当前 master 的后续演进全部算作 6 月压测改造。
  • 被问到未知最终指标时,说明验收方法和现有证据,不猜测。

12. 资料与代码索引

内部资料

当前代码

  • app/view/gateway/src/main/java/pleiades/business/gateway/view/controller/ViewV2RpcController.java
  • app/view/gateway/src/main/java/pleiades/business/gateway/view/controller/ViewV2Controller.java
  • app/view/gateway/src/main/java/pleiades/business/gateway/view/module/view/v2/service/ViewSeasonV2Service.java
  • app/view/gateway/src/main/java/pleiades/business/gateway/view/module/ratelimit/GlobalLimiter.java
  • app/view/gateway/src/main/java/pleiades/business/gateway/view/module/ratelimit/InternalLimiter.java
  • app/view/gateway/src/main/java/pleiades/business/gateway/view/module/rpc/EpisodeRpcService.java
  • app/view/gateway/src/main/java/pleiades/business/gateway/view/module/rpc/SeasonRpcService.java
  • app/player/gateway/src/main/java/pleiades/business/gateway/player/config/CacheConfig.java
  • app/player/gateway/src/main/java/pleiades/business/gateway/player/module/rpc/season/EpisodeService.java
  • app/player/gateway/src/main/java/pleiades/business/gateway/player/module/rpc/servant/VideoMaterialRpcService.java
  • app/season/gateway/src/main/java/pleiades/business/gateway/season/module/rpc/media/MediaService.java
  • app/pay/service/src/main/java/pleiades/business/service/pay/service/rpc/SeasonService.java
  • middleware/warmup/src/main/java/ogv/middleware/warmup/AbstractHealthCheckAdaptor.java

关键提交

1
2
3
4
5
6
7
8
9
10
60d50201116  凡人降级策略测试
f1cba7ea3c2 seasonInfos在压测时返回空响应
1ba8445f9e6 凡人本地缓存优化
98a63adfb4f 新增本地缓存
a35abc97913 修复相关推荐无法加载的问题
321b69ebbf2 cache empty listBySection results
a03a0423ea3 avoid negative cache on listBySection failures
53180b6873f 新增未上架剧集 playurl 反泄漏监控
055d984039f 反泄漏监控限流改令牌桶
5a8e6cbeb44 反泄漏监控异步池改有界队列

13. 最终记忆锚点

1
2
3
4
5
6
7
8
9
10
11
目标:35k/机房,70k/核心入口,view + player 合计 140k+

方法:健康基线 -> 批量取数 -> 放大比 -> 逐级升压 ->
509/504/breaker 归因 -> 限流/扩容/缓存 -> 复压

关键放大:view-crowd 3x,view-relation 2x,player-material 2x

关键改造:2/3/10 秒本地缓存、批量 miss 回源、异常不写负缓存、
限流配置治理、预热 2000 -> 100、凡人预刷新兜底方案

边界:140k 是两条入口合计;凡人兜底未进 master;告警限流不是业务限流