5578 字
28 分钟
多租户自适应调度引擎:从零构建支撑十万级 QPS 的加权公平调度系统

一、问题背景:当多个租户共享一个线程池#

想象你在一个微服务网关层工作。每秒有数十万请求涌入,它们来自不同的租户——有核心 API 服务(要求毫秒级响应)、有 CDN 合作伙伴(中等优先级)、有数据分析服务(可容忍延迟)、还有免费试用用户(尽力而为)。

所有这些请求共享同一个线程池。

问题来了:如何保证核心租户在系统过载时依然获得优先服务,同时不让低优先级租户完全饿死?

这不是一个简单的”设置线程优先级”就能解决的问题。Java 的 Thread.setPriority() 在现代操作系统上几乎没有实际效果,而且线程池中的线程是复用的,优先级会被继承和污染。

我们需要的是一套应用层的调度引擎——在线程池之上,自己实现公平性、限流、背压和熔断。

本文将完整拆解这样一个多租户自适应调度引擎的设计与实现。

二、整体架构总览#

先看全局视角,理解各组件之间的关系:

graph TB subgraph 请求入口层 C1[租户 A - PLATINUM<br/>200 RPS] --> SUBMIT[submit 方法<br/>核心入口] C2[租户 B - GOLD<br/>100 RPS] --> SUBMIT C3[租户 C - SILVER<br/>50 RPS 突发 500] --> SUBMIT C4[租户 D - BRONZE<br/>30 RPS] --> SUBMIT end subgraph 四层防御链路 SUBMIT --> CB{① 熔断检查} CB -->|OPEN| REJECT1[快速拒绝] CB -->|CLOSED/HALF_OPEN| TB{② 令牌桶限流} TB -->|无令牌| REJECT2[限流拒绝] TB -->|获取令牌| BP{③ 队列背压} BP -->|队列满| REJECT3[背压拒绝] BP -->|有空间| ENQUEUE[④ 入队<br/>PriorityBlockingQueue] end subgraph 调度执行层 ENQUEUE --> DRR[Deficit Round Robin<br/>加权公平调度] DRR --> W1[Worker 1] DRR --> W2[Worker 2] DRR --> W3[Worker ...] DRR --> WN[Worker N] WS[Work-Stealing<br/>空闲线程偷取] -.-> DRR end subgraph 控制面 AC[自适应控制器<br/>每秒评估负载] -->|调整 adaptiveFactor| TB MC[指标采集器<br/>P50/P99 延迟] --> DASH[仪表盘输出] end style REJECT1 fill:#ff6b6b,color:#fff style REJECT2 fill:#ff6b6b,color:#fff style REJECT3 fill:#ff6b6b,color:#fff style ENQUEUE fill:#51cf66,color:#fff

三、租户 SLA 模型:用枚举定义资源配额#

一切的起点是租户分级。不同等级的租户拥有不同的资源配额:

public enum TierLevel {
PLATINUM(8, 10000, 500), // 权重8,令牌桶容量10000,队列深度500
GOLD(4, 5000, 300),
SILVER(2, 2000, 200),
BRONZE(1, 1000, 100);
final int weight; // 调度权重:决定获得多少 CPU 时间片
final int tokenBucketCapacity; // 令牌桶容量:决定突发流量的承受能力
final int maxQueueDepth; // 最大队列深度:决定背压触发阈值
}

三个维度的配额形成了一个立体的资源控制模型

维度PLATINUMGOLDSILVERBRONZE含义
weight8421每轮调度获得的任务执行配额
tokenBucketCapacity10000500020001000能承受的最大突发请求量
maxQueueDepth500300200100排队等待的最大任务数
令牌补充速率4000/s2000/s1000/s500/sweight × 500

PLATINUM 租户的令牌补充速率是 BRONZE 的 8 倍,队列容量是 5 倍。这意味着在系统过载时,BRONZE 租户会最先被限流和背压拒绝,而 PLATINUM 租户几乎不受影响。

四、submit 方法:四层防御的分层漏斗#

submit 是整个引擎的核心入口,每个请求都要经过四道关卡。设计哲学是:成本最低的检查放最前面,成本最高的操作放最后面

flowchart TD START([请求到达]) --> LOOKUP[查找租户上下文<br/>tenants.get tenantId] LOOKUP -->|未注册| FAIL1[❌ IllegalArgumentException] LOOKUP -->|已注册| STEP1 STEP1{① 熔断检查<br/>ctx.circuitState == OPEN?} STEP1 -->|OPEN 且未冷却| REJECT1[❌ 熔断拒绝<br/>rejectedCount++] STEP1 -->|OPEN 且已冷却 5s| HALFOPEN[状态 → HALF_OPEN<br/>放行试探] STEP1 -->|CLOSED / HALF_OPEN| STEP2 HALFOPEN --> STEP2 STEP2{② 令牌桶限流<br/>tokenBucket.tryAcquire} STEP2 -->|false 无令牌| REJECT2[❌ 限流拒绝<br/>rejectedCount++] STEP2 -->|true 获取成功| STEP3 STEP3{③ 队列背压<br/>taskQueue.size >= maxQueueDepth?} STEP3 -->|队列已满| REJECT3[❌ 背压拒绝<br/>rejectedCount++] STEP3 -->|有空间| STEP4 STEP4[④ 入队成功<br/>new ScheduledTask → taskQueue.offer<br/>submittedCount++] STEP4 --> RETURN([返回 CompletableFuture]) style REJECT1 fill:#ff6b6b,color:#fff style REJECT2 fill:#ff6b6b,color:#fff style REJECT3 fill:#ff6b6b,color:#fff style STEP4 fill:#51cf66,color:#fff style RETURN fill:#339af0,color:#fff

4.1 为什么这个顺序很重要?#

每一步的计算成本递增:

步骤操作成本拒绝场景
① 熔断检查一次 volatile 读~1ns下游服务连续故障
② 令牌桶CAS 自旋 + 时间计算~10-50ns请求速率超限
③ 队列背压size() 调用~10ns消费速度跟不上生产
④ 入队对象创建 + 堆插入~100-500ns正常路径

在系统过载时,绝大多数请求在前两步就被弹回去了,根本不会走到创建对象、操作队列那一步。系统越忙,拒绝越快,资源消耗反而越少——这就是分层漏斗的精髓。

4.2 每一步的深度解析#

Step 1:熔断检查——最后一道防线#

if (ctx.circuitState == CircuitState.OPEN) {
if (System.currentTimeMillis() - ctx.lastFailureTimestamp > 5000) {
ctx.circuitState = CircuitState.HALF_OPEN; // 冷却后试探
} else {
ctx.rejectedCount.incrementAndGet();
return CompletableFuture.failedFuture(...); // 秒拒
}
}

熔断器是三态状态机:

stateDiagram-v2 [*] --> CLOSED: 初始状态 CLOSED --> OPEN: 连续失败 ≥ 5 次 OPEN --> HALF_OPEN: 冷却 5 秒后 HALF_OPEN --> CLOSED: 试探请求成功 HALF_OPEN --> OPEN: 试探请求失败 CLOSED: 正常放行所有请求 OPEN: 拒绝所有请求(快速失败) HALF_OPEN: 放行一个请求试探

关键设计点:

  • OPEN 状态下是零成本拒绝:一次 volatile 读 + 一次时间比较,不涉及任何锁或 CAS
  • HALF_OPEN 是自愈机制:不需要人工干预,冷却后自动试探恢复
  • 连续失败计数器用 AtomicInteger:在 executeTask 中递增,成功时归零

Step 2:自适应令牌桶——CAS 无锁限流#

这是整个引擎中并发设计最精妙的部分。

boolean tryAcquire() {
refill(); // 先补充令牌
while (true) {
long current = tokens.get();
if (current <= 0) return false; // 无令牌,拒绝
if (tokens.compareAndSet(current, current - 1))
return true; // CAS 成功,获取令牌
// CAS 失败 → 自旋重试(比 synchronized 高效)
}
}

为什么不用 synchronizedReentrantLock

在每秒数十万请求的场景下,锁竞争会成为严重瓶颈。CAS 自旋的优势在于:

  • 无上下文切换:失败后立即重试,不会被挂起
  • 无内核态切换:纯用户态操作
  • 在低竞争时几乎零开销:一次 CAS 就成功

令牌补充的 refill() 方法同样精巧:

private void refill() {
long now = System.nanoTime();
long elapsed = now - lastRefillTime;
if (elapsed < 1_000_000) return; // 至少 1ms 才补充,减少无效计算
long tokensToAdd = (long)(elapsed / 1_000_000_000.0 * refillRate * adaptiveFactor);
if (tokensToAdd > 0) {
lastRefillTime = now;
tokens.getAndUpdate(current -> Math.min(capacity, current + tokensToAdd));
}
}

注意 adaptiveFactor 这个自适应因子——它不是静态的,而是由自适应控制器根据全局负载动态调整:

graph LR LOAD[系统负载] --> DECISION{负载 > 80%?} DECISION -->|是| SLOW[adaptiveFactor 线性衰减<br/>max 0.1, 1.0 - load-0.8 × 5] DECISION -->|否| NORMAL[adaptiveFactor = 1.0<br/>全速补充] SLOW --> EFFECT1[令牌补充变慢<br/>入口流量自动收紧] NORMAL --> EFFECT2[令牌正常补充<br/>全速放行]
系统负载adaptiveFactor效果
0% - 80%1.0全速补充,不限流
85%0.75补充速率降至 75%
90%0.50补充速率降至 50%
95%0.25补充速率降至 25%
100%0.10补充速率降至 10%(保底)

这意味着系统在”喘不过气”的时候自动收紧入口,恢复后又自动放开,比写死一个阈值优雅得多。

Step 3:队列深度背压——防止内存溢出#

if (ctx.isQueueFull()) { // taskQueue.size() >= tier.maxQueueDepth
ctx.rejectedCount.incrementAndGet();
return CompletableFuture.failedFuture(
new RejectedExecutionException("[背压] 队列已满"));
}

背压(Backpressure)的核心思想:如果消费者处理不过来,就主动告诉生产者”别发了”

这里的实现很直接——队列满了就拒绝。但配合前面的令牌桶,形成了双重保护:

  • 令牌桶控制速率(每秒多少个)
  • 队列深度控制总量(最多积压多少个)

两者缺一不可:只有令牌桶,突发流量可能瞬间填满内存;只有队列深度,稳定的高速率流量会持续堆积。

Step 4:入队——终于到了正常路径#

ScheduledTask<T> task = new ScheduledTask<>(tenantId, priority, work);
ctx.taskQueue.offer(task); // PriorityBlockingQueue,按优先级排序
ctx.submittedCount.incrementAndGet();
return task.resultFuture; // 返回 CompletableFuture

任务被封装为 ScheduledTask,它实现了 Comparable 接口:

public int compareTo(ScheduledTask other) {
int cmp = Integer.compare(this.priority, other.priority);
return cmp != 0 ? cmp : Long.compare(this.enqueueTimeNano, other.enqueueTimeNano);
}

先按优先级排序(数值越小越优先),同优先级按入队时间 FIFO。这保证了同一租户内部的请求也是公平的。

返回的 CompletableFuture 让调用方可以:

  • thenApply() 链式处理结果
  • exceptionally() 处理异常
  • orTimeout() 设置超时
  • join() 同步等待

五、调度核心:Deficit Round Robin + Work-Stealing#

任务入队后,由 Worker 线程负责消费。这里用了两个经典算法的组合。

5.1 Deficit Round Robin(DRR)——加权公平调度#

DRR 是网络领域的经典调度算法,被广泛用于路由器的 QoS 实现。核心思想是:每轮给每个队列累加一个”配额”(deficit),配额越高的队列每轮能发送越多的包

sequenceDiagram participant W as Worker 线程 participant P as PLATINUM 队列<br/>权重=8 participant G as GOLD 队列<br/>权重=4 participant S as SILVER 队列<br/>权重=2 participant B as BRONZE 队列<br/>权重=1 Note over W: 第 1 轮 W->>P: deficit += 8 → 执行 8 个任务 W->>G: deficit += 4 → 执行 4 个任务 W->>S: deficit += 2 → 执行 2 个任务 W->>B: deficit += 1 → 执行 1 个任务 Note over W: 第 2 轮 W->>P: deficit += 8 → 再执行 8 个 W->>G: deficit += 4 → 再执行 4 个 W->>S: deficit += 2 → 再执行 2 个 W->>B: deficit += 1 → 再执行 1 个 Note over W: 如果某队列为空<br/>deficit 会累积到下一轮

代码实现:

private void workerLoop() {
Map<String, Integer> deficitCounters = new HashMap<>(); // 每个 Worker 独立维护
while (running) {
boolean didWork = false;
// Phase 1: DRR 调度
for (Map.Entry<String, TenantContext> entry : tenants.entrySet()) {
String tenantId = entry.getKey();
TenantContext ctx = entry.getValue();
// 累加 deficit
deficitCounters.merge(tenantId, ctx.tier.weight, Integer::sum);
int deficit = deficitCounters.getOrDefault(tenantId, 0);
while (deficit > 0) {
ScheduledTask<?> task = ctx.taskQueue.poll();
if (task == null) break; // 队列空了,deficit 保留到下一轮
executeTask(ctx, task);
deficit--;
didWork = true;
}
deficitCounters.put(tenantId, Math.max(0, deficit));
}
// Phase 2: Work-Stealing
if (!didWork) {
didWork = tryWorkStealing();
}
// Phase 3: 无任务时休眠 1ms
if (!didWork) {
Thread.sleep(1);
}
}
}

关键设计点:deficitCounters 是每个 Worker 线程的局部变量,不是共享状态。 这意味着 8 个 Worker 线程各自独立做 DRR 调度,完全无锁。虽然不如全局共享 deficit 那么”精确公平”,但在高并发下性能好得多——这是一个典型的”精确性 vs 性能”的工程权衡。

5.2 Work-Stealing——不让任何线程闲着#

当一个 Worker 在 DRR 轮次中没有拿到任何任务时,它不会傻等,而是去”偷”:

private boolean tryWorkStealing() {
TenantContext busiest = null;
int maxDepth = 0;
// 找到队列最深的租户
for (TenantContext ctx : tenants.values()) {
int depth = ctx.currentQueueDepth();
if (depth > maxDepth) {
maxDepth = depth;
busiest = ctx;
}
}
// 从最繁忙的队列偷一个任务
if (busiest != null) {
ScheduledTask<?> stolen = busiest.taskQueue.poll();
if (stolen != null) {
executeTask(busiest, stolen);
return true;
}
}
return false;
}
flowchart TD START[Worker 空闲] --> SCAN[扫描所有租户队列深度] SCAN --> FIND{找到最深的队列?} FIND -->|否| SLEEP[Thread.sleep 1ms<br/>避免空转] FIND -->|是| STEAL[从最深队列 poll 一个任务] STEAL --> EXEC[执行被偷取的任务] EXEC --> LOOP[回到 DRR 主循环] SLEEP --> LOOP

Work-Stealing 的好处是自动负载均衡:如果某个租户突然来了一波流量,它的队列会迅速变深,空闲的 Worker 会自动过来帮忙消化。这比静态分配”每个 Worker 负责哪些租户”灵活得多。

5.3 三阶段调度的完整流程#

flowchart TD LOOP([Worker 主循环开始]) --> DRR subgraph Phase1 [Phase 1: Deficit Round Robin] DRR[遍历所有租户] --> ADD[deficit += weight] ADD --> CHECK{deficit > 0<br/>且队列非空?} CHECK -->|是| EXEC1[执行任务<br/>deficit--] EXEC1 --> CHECK CHECK -->|否| NEXT[下一个租户] NEXT --> DRR end DRR -->|所有租户遍历完| DIDWORK1{本轮有执行任务?} DIDWORK1 -->|是| LOOP DIDWORK1 -->|否| PHASE2 subgraph Phase2 [Phase 2: Work-Stealing] PHASE2[扫描最深队列] --> STEAL{偷到任务?} STEAL -->|是| EXEC2[执行偷来的任务] STEAL -->|否| PHASE3 end EXEC2 --> LOOP subgraph Phase3 [Phase 3: 休眠] PHASE3[Thread.sleep 1ms<br/>避免 CPU 空转] end PHASE3 --> LOOP style Phase1 fill:#e3f2fd,stroke:#1976d2 style Phase2 fill:#fff3e0,stroke:#f57c00 style Phase3 fill:#f3e5f5,stroke:#7b1fa2

六、熔断与自愈:executeTask 中的状态机驱动#

任务执行不仅仅是 task.run(),还承担着熔断器状态转换和指标采集的职责:

private void executeTask(TenantContext ctx, ScheduledTask<?> task) {
// 采集调度延迟
long scheduleLatencyMicros = (System.nanoTime() - task.enqueueTimeNano) / 1000;
ctx.scheduleLatency.record(scheduleLatencyMicros);
try {
task.run();
ctx.completedCount.incrementAndGet();
// 熔断恢复:HALF_OPEN 状态下成功 → 回到 CLOSED
if (ctx.circuitState == CircuitState.HALF_OPEN) {
ctx.circuitState = CircuitState.CLOSED;
ctx.consecutiveFailures.set(0);
}
} catch (Exception e) {
int failures = ctx.consecutiveFailures.incrementAndGet();
ctx.lastFailureTimestamp = System.currentTimeMillis();
// 连续失败 ≥ 5 次 → 触发熔断
if (failures >= 5) {
ctx.circuitState = CircuitState.OPEN;
}
}
}

熔断器的完整生命周期:

sequenceDiagram participant R as 请求 participant S as submit participant E as executeTask participant CB as 熔断器 Note over CB: 状态: CLOSED(正常) R->>S: 请求 1 S->>E: 通过四层检查,执行 E->>CB: 执行失败,consecutiveFailures = 1 R->>S: 请求 2 S->>E: 执行失败,consecutiveFailures = 2 R->>S: 请求 3-5 S->>E: 连续失败...consecutiveFailures = 5 Note over CB: 状态: CLOSED → OPEN(熔断) R->>S: 请求 6 S-->>R: ❌ 秒拒(熔断状态) R->>S: 请求 7-100 S-->>R: ❌ 全部秒拒(零成本) Note over CB: 5 秒冷却期过后... R->>S: 请求 101 Note over CB: 状态: OPEN → HALF_OPEN(试探) S->>E: 放行一个请求试探 alt 试探成功 E->>CB: consecutiveFailures = 0 Note over CB: 状态: HALF_OPEN → CLOSED(恢复) else 试探失败 E->>CB: consecutiveFailures++ Note over CB: 状态: HALF_OPEN → OPEN(继续熔断) end

为什么用 volatile 而不是 AtomicReference?#

circuitState 字段用的是 volatile

volatile CircuitState circuitState = CircuitState.CLOSED;

这是一个有意的选择:

  • 熔断器状态转换是单向的、低频的(不是每个请求都会改变状态)
  • volatile 保证可见性就够了,不需要 CAS 的原子性
  • 即使两个线程同时把状态从 HALF_OPEN 改为 CLOSED,结果也是正确的(幂等操作)
  • AtomicReference 少一层间接引用,读取更快

这是一个典型的”够用就好”的工程判断——不是所有并发场景都需要最强的同步原语。

七、自适应控制器:系统的”自主神经”#

每秒执行一次的自适应控制器,是整个引擎的”大脑”:

private void adaptiveControl() {
// 计算系统负载 = 所有队列深度之和 / 总容量
long totalDepth = tenants.values().stream()
.mapToLong(TenantContext::currentQueueDepth).sum();
long totalCapacity = tenants.values().stream()
.mapToLong(ctx -> ctx.tier.maxQueueDepth).sum();
currentSystemLoad = totalCapacity > 0 ? (double) totalDepth / totalCapacity : 0;
// 通知所有令牌桶调整速率
tenants.values().forEach(ctx -> ctx.tokenBucket.adjustFactor(currentSystemLoad));
}
graph TD subgraph 每秒执行一次 CALC[计算全局负载<br/>totalDepth / totalCapacity] --> LOAD{负载水平} LOAD -->|0-80%| GREEN[adaptiveFactor = 1.0<br/>全速放行] LOAD -->|80-100%| YELLOW[adaptiveFactor 线性衰减<br/>0.1 ~ 1.0] GREEN --> APPLY[应用到所有租户的令牌桶] YELLOW --> APPLY end subgraph 效果 APPLY --> E1[PLATINUM: refillRate = 4000 × factor] APPLY --> E2[GOLD: refillRate = 2000 × factor] APPLY --> E3[SILVER: refillRate = 1000 × factor] APPLY --> E4[BRONZE: refillRate = 500 × factor] end style GREEN fill:#51cf66,color:#fff style YELLOW fill:#ffd43b,color:#333

这个设计形成了一个负反馈回路

graph LR A[队列堆积增加] --> B[系统负载上升] B --> C[adaptiveFactor 下降] C --> D[令牌补充变慢] D --> E[入口流量减少] E --> F[队列堆积减少] F --> G[系统负载下降] G --> H[adaptiveFactor 回升] H --> I[令牌补充恢复] I --> A style A fill:#ff6b6b,color:#fff style F fill:#51cf66,color:#fff

这就是控制论中的比例控制器(P-Controller)——偏差越大,修正力度越大。系统会自动趋向一个动态平衡点。

八、P50/P99 延迟追踪:无锁直方图#

监控是生产系统的眼睛。LatencyTrackerConcurrentSkipListMap 实现了一个无锁的直方图:

public static class LatencyTracker {
private final ConcurrentSkipListMap<Long, AtomicInteger> histogram = new ConcurrentSkipListMap<>();
private final AtomicLong totalCount = new AtomicLong();
void record(long latencyMicros) {
long bucket = (latencyMicros / 100) * 100; // 100μs 粒度桶化
histogram.computeIfAbsent(bucket, k -> new AtomicInteger()).incrementAndGet();
totalCount.incrementAndGet();
}
long getPercentile(double percentile) {
long target = (long) (totalCount.get() * percentile);
long cumulative = 0;
for (Map.Entry<Long, AtomicInteger> entry : histogram.entrySet()) {
cumulative += entry.getValue().get();
if (cumulative >= target) return entry.getKey();
}
return 0;
}
}

为什么用 ConcurrentSkipListMap 而不是 ConcurrentHashMap

因为计算百分位数需要有序遍历ConcurrentSkipListMap 的 key 天然有序,遍历时从小到大累加,到达目标百分位就返回。ConcurrentHashMap 无序,需要额外排序。

100μs 的桶粒度是怎么选的?

  • 太细(1μs):桶太多,内存爆炸
  • 太粗(10ms):精度不够,P99 没有参考价值
  • 100μs:对于微服务场景,足够区分”快”和”慢”,同时内存可控(即使延迟分布在 0-1s,也只有 10000 个桶)

九、流量模拟:真实场景的压力测试#

main 方法模拟了四种真实的流量模式:

gantt title 10 秒流量模拟时间线 dateFormat X axisFormat %s秒 section netflix-api (PLATINUM) 稳定 200 RPS :active, 0, 10 section partner-cdn (GOLD) 稳定 100 RPS :active, 0, 10 section analytics (SILVER) 正常 50 RPS :0, 3 突发 500 RPS :crit, 3, 6 正常 50 RPS :6, 9 突发 500 RPS :crit, 9, 10 section free-trial (BRONZE) 稳定 30 RPS :active, 0, 10

其中 analytics 租户的突发流量(50 → 500 RPS,10 倍突增)是最有意思的测试场景。它会触发:

  1. 令牌桶快速耗尽:SILVER 的令牌桶容量只有 2000,补充速率 1000/s,500 RPS 的突发会在约 4 秒内耗尽
  2. 队列快速填满:maxQueueDepth = 200,如果消费速度跟不上,很快触发背压
  3. 自适应控制器介入:全局负载上升,所有租户的令牌补充速率下降
  4. Work-Stealing 启动:analytics 队列变深,空闲 Worker 自动过来帮忙

这个场景完美展示了各个组件如何协同工作。

流量生成的精度控制#

private static void busyWaitMicros(long micros) {
long waitUntil = System.nanoTime() + micros * 1000;
while (System.nanoTime() < waitUntil) {
Thread.onSpinWait(); // JDK 9+ CPU hint
}
}

为什么用忙等待而不是 Thread.sleep?因为 Thread.sleep 的最小粒度是 ~1ms(受操作系统调度器影响),而 200 RPS 需要 5ms 间隔、500 RPS 需要 2ms 间隔。忙等待虽然消耗 CPU,但能提供微秒级精度,保证流量模拟的准确性。

Thread.onSpinWait() 是 JDK 9 引入的 CPU hint,告诉处理器”我在自旋等待”,处理器可以降低功耗或让出超线程资源。

十、数据结构选型总结#

组件数据结构选型理由
租户注册表ConcurrentHashMapO(1) 查找,高并发读写
任务队列PriorityBlockingQueue自动按优先级排序,线程安全
令牌计数AtomicLongCAS 无锁,纳秒级操作
延迟直方图ConcurrentSkipListMap有序遍历计算百分位数
失败计数AtomicInteger无锁递增/归零
熔断状态volatile enum低频写、高频读,可见性足够
deficit 计数器HashMap(线程局部)每个 Worker 独立,无需并发
系统负载volatile double单写多读,可见性足够

十一、并发安全性分析#

整个引擎的并发模型可以归纳为:

graph TD subgraph 写入方-多个流量生成线程 TG1[Traffic Generator 1] --> SUBMIT TG2[Traffic Generator 2] --> SUBMIT TG3[Traffic Generator 3] --> SUBMIT TG4[Traffic Generator 4] --> SUBMIT end subgraph 共享状态 SUBMIT[submit 方法] --> TB[令牌桶<br/>AtomicLong CAS] SUBMIT --> TQ[任务队列<br/>PriorityBlockingQueue] SUBMIT --> RC[拒绝计数<br/>AtomicLong] end subgraph 消费方-多个Worker线程 TQ --> W1[Worker 1<br/>独立 deficit 计数器] TQ --> W2[Worker 2<br/>独立 deficit 计数器] TQ --> WN[Worker N<br/>独立 deficit 计数器] end subgraph 控制面-单线程 AC[自适应控制器] -->|写 adaptiveFactor| TB MC[指标采集器] -->|读 所有计数器| DASH[仪表盘] end style TB fill:#fff3e0,stroke:#f57c00 style TQ fill:#e3f2fd,stroke:#1976d2

关键的并发安全保证:

  1. 令牌桶AtomicLong + CAS 保证原子性,volatile lastRefillTime 保证可见性
  2. 任务队列PriorityBlockingQueue 本身线程安全
  3. 计数器:全部使用 AtomicLong/AtomicInteger
  4. 熔断状态volatile 保证可见性,状态转换是幂等的
  5. deficit 计数器:线程局部变量,无需同步
  6. adaptiveFactor:单线程写(自适应控制器)、多线程读(令牌桶),volatile 足够

没有一把 synchronized 锁,没有一个 ReentrantLock 整个热路径(submit → 入队 → 出队 → 执行)全部是无锁或 lock-free 的。

十二、设计模式与架构思想总结#

模式/思想在引擎中的体现
分层防御(Defense in Depth)submit 的四层检查链路
快速失败(Fail Fast)成本最低的检查放最前面
背压传播(Backpressure)队列深度超限时主动拒绝
熔断器模式(Circuit Breaker)三态状态机 + 自动恢复
令牌桶算法(Token Bucket)CAS 无锁实现 + 自适应因子
Deficit Round Robin加权公平调度,网络 QoS 经典算法
Work-Stealing空闲线程主动偷取,自动负载均衡
负反馈控制(Feedback Control)自适应控制器形成闭环
关注点分离数据面(Worker)与控制面(Controller)独立
异步编程CompletableFuture 非阻塞返回

十三、可以继续优化的方向#

这个引擎已经是一个相当完整的实现,但在生产环境中还可以考虑:

  1. 令牌桶的 refill 竞态:多线程同时调用 refill() 时,lastRefillTime 的更新不是原子的,可能导致少量令牌多补或少补。可以用 CAS 保护 lastRefillTime 的更新。

  2. 熔断器的并发状态转换:多个线程可能同时将 OPEN 转为 HALF_OPEN,导致多个”试探请求”同时放行。可以用 compareAndSet 保证只有一个线程成功转换。

  3. 队列深度检查与入队的非原子性isQueueFull()taskQueue.offer() 之间有时间窗口,可能导致队列实际深度略超 maxQueueDepth。在实践中这个误差可以接受,但如果需要严格控制,可以用 Semaphore 做精确的容量控制。

  4. 指标采集的内存增长LatencyTracker 的直方图会持续增长,长时间运行后需要考虑滑动窗口或定期重置。

  5. 更精细的 Work-Stealing 策略:当前是从最深的队列偷取,可以考虑加入租户权重因素——优先偷取高权重租户的任务。

十四、总结#

这个多租户调度引擎的核心设计哲学可以用一句话概括:

用最小代价、在最早的时机,把不该进来的请求挡在门外,同时保证高等级租户永远比低等级租户更难被拒绝。

它不是某一个算法的胜利,而是多个经典算法和设计模式的精心编排:

  • 入口层用分层漏斗做快速失败
  • 调度层用 DRR + Work-Stealing 做加权公平
  • 控制层用负反馈回路做自适应调节
  • 保护层用熔断器做优雅降级

每一层都在做自己最擅长的事,组合在一起就形成了一个能扛住十万级 QPS、在过载时优雅降级而不是雪崩的调度系统。

多租户自适应调度引擎:从零构建支撑十万级 QPS 的加权公平调度系统
https://mizuki.mysqil.com/posts/javanote/juc/多租户自适应调度引擎/
作者
Laoli
发布于
2026-03-25
许可协议
CC BY-NC-SA 4.0
封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00