AiVedio:高并发调度与资源隔离
从准入控制、分层队列、WRR/DRR、公平调度、资源池隔离、多级并发限制、Provider Semaphore、Worker Slot、资源预留、原子认领、Fencing Token、大客户容量、成本估算、背压和降级出发,设计 AI 视频平台的高并发调度与多租户资源隔离。
第 18 章:高并发调度与资源隔离
18.1 本章要解决的业务问题
AI 视频平台的高并发,与普通 Web 系统的高 QPS 不完全相同。
一次生成请求可能只需要几十毫秒完成任务创建,但任务会在系统中持续数十秒甚至数分钟。在此期间,它会持续占用一种或多种稀缺资源:
- 第三方供应商并发槽位;
- 模型级 RPM、并发数或账户额度;
- 平台分钟级成本预算;
- GPU、CPU、内存和临时磁盘;
- 回调处理、轮询和媒体回源能力;
- 转码、代理视频和最终渲染 Worker。
因此,系统真正需要控制的是:
在途任务量
排队任务量
单位时间调度成本
不同租户的资源份额
不同资源池的占用率
任务最大等待时间
如果只把所有任务放进一个全局 FIFO 队列,会出现以下问题:
- 某个大客户瞬间提交数万条任务,占满全部队列和供应商并发。
- 普通用户的短任务被企业客户的长任务长期阻塞。
- 4K、长视频等高成本任务与普通任务共用 Worker,导致普通任务延迟激增。
- 高优先级任务持续进入后,低优先级任务永久饥饿。
- 调度器无限消费 RocketMQ 消息,却没有可用供应商槽位,最终造成大量本地堆积和重试风暴。
- 第三方调用已经成功但本地超时,系统提前释放槽位并再次提交,造成并发超限和重复计费。
- Redis 计数丢失后,多个调度器同时认为还有容量,向供应商发送远超配额的请求。
本章要解决的核心问题是:
在大量、长耗时、成本不同且通常不可抢占的任务下,如何同时保证平台稳定、租户公平、付费等级差异、资源隔离和成本可控。
18.2 核心设计原则
原则一:先准入,再排队,最后执行
生成请求通过参数校验,不代表系统必须无条件接收。
系统需要依次判断:
请求是否允许进入系统
→ 是否允许进入某个队列
→ 当前是否允许被调度
→ 是否存在可以执行它的资源
准入控制、排队和执行是三个不同阶段。
原则二:高并发控制必须分层
至少需要以下几层限制:
| 层级 | 典型限制 |
|---|---|
| API 层 | 用户请求速率、租户请求速率 |
| 业务层 | 每日额度、分钟成本、最大排队量 |
| 调度层 | 每租户运行任务数、服务等级并发 |
| 供应商层 | Provider、模型、地区、账户并发 |
| Worker 层 | CPU、GPU、显存、磁盘、进程槽位 |
| 平台层 | 全局成本、数据库连接、MQ 消费速度 |
任何单一限流器都无法覆盖全部风险。
原则三:按资源消耗单位调度,而不是只按任务数调度
下面两个任务不能简单视为同样的“一条任务”:
任务 A:5 秒、720p、普通文生视频
任务 B:60 秒、4K、参考视频驱动、带音频
系统需要为任务估算:
fairness_units 公平调度单位
provider_slot_units 供应商槽位单位
gpu_units GPU 或显存单位
media_units 转码资源单位
estimated_cost 预计真实成本
调度公平可以使用一个归一化单位,但实际资源准入仍应按各自维度分别判断。
原则四:采用分层公平调度
推荐的调度层次是:
第一层:选择服务等级或资源池
第二层:选择租户
第三层:选择租户内部任务
第四层:选择供应商或 Worker
不能把企业客户、付费个人用户、免费用户和高成本任务全部放在一个全局优先级队列中。
原则五:PostgreSQL 管事实,Redis 管速度
- PostgreSQL 保存任务状态、调度尝试、资源预留和策略版本。
- Redis 保存限流计数、活跃租户索引、队列加速索引和容量镜像。
- RocketMQ 负责异步传递、削峰、延迟调度和重试。
- 对象存储负责视频、图片和中间媒体文件。
Redis 中的数据必须能够从 PostgreSQL 重建。
原则六:先完成资源预留和任务认领,再产生外部副作用
调用供应商前,系统必须先确定:
- 当前任务仍处于可调度状态;
- 当前租户仍有运行配额;
- 当前供应商仍有容量;
- 当前成本预算仍允许调用;
- 本次 attempt 已经持久化。
不能先调用供应商,成功后再尝试认领任务。
原则七:外部视频生成任务通常按不可抢占任务处理
很多供应商即使支持取消,也不保证:
- 取消立即生效;
- 已消耗的计算不会收费;
- 已提交任务能够释放并发;
- 取消与完成之间没有竞态。
因此,平台不能依赖强制抢占来保障高级用户,而应通过预留容量、独立资源池和准入控制实现隔离。
18.3 总体架构和组件职责
┌──────────────────────┐
Client / Open API ──────▶│ API Gateway │
│ Auth / Rate Limit │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Admission Controller │
│ 配额、队列、成本准入 │
└──────────┬───────────┘
│
PostgreSQL Transaction
│
┌───────────────┴────────────────┐
▼ ▼
GenerationTask / Ledger OutboxEvent
│ │
│ ▼
│ RocketMQ
│ │
▼ ▼
Queue Index Rebuilder ───────────────▶ Redis Index
│
▼
Scheduler Replicas
┌───────────┴──────────┐
│ 公平调度与资源选择 │
└───────────┬──────────┘
│
PostgreSQL 原子预留
│
▼
Dispatch MQ
┌──────────┴──────────┐
▼ ▼
Provider Dispatcher Media Worker
│ │
▼ ▼
第三方 AI Provider FFmpeg / GPU
│ │
└──────────┬──────────┘
▼
Completion Event
│
▼
Slot Release
18.3.1 Admission Controller
负责判断请求能否进入平台。
主要检查:
- 用户和租户请求速率;
- 最大排队任务数;
- 最大排队资源单位;
- 当前最老任务等待时间;
- 预计排队时间;
- 用户剩余额度;
- 租户分钟成本预算;
- 当前服务等级是否已关闭准入;
- 高成本任务池是否还有空间。
它不负责真正调用供应商。
18.3.2 Queue Indexer
任务事实保存在 PostgreSQL,但每次调度都全表扫描会增加数据库压力,因此可以在 Redis 中维护可重建的排队索引:
活跃服务等级
服务等级中的活跃租户
租户内部的优先级队列
租户的 DRR deficit
Queue Indexer 可以消费 Outbox 事件,将任务加入 Redis 索引。索引更新失败时,由定期扫描器补偿。
18.3.3 Fair Scheduler
负责:
- 选择服务等级;
- 选择租户;
- 选择租户内的具体任务;
- 匹配供应商或 Worker;
- 发起数据库资源预留事务。
Scheduler 不直接把 Redis 中弹出的任务当作已认领任务。
18.3.4 Resource Manager
资源池不应只有一个全局并发数,而应至少按以下维度拆分:
provider
provider_account
region
model
resource_class
worker_pool
GPU 类型
例如:
provider:runway:model-x:region-us
provider:vendor-b:model-y:region-ap
worker:proxy-transcode:cpu
worker:render-4k:gpu-a100
18.3.5 Provider Dispatcher
只处理已经完成资源预留和 attempt 创建的任务。
负责:
- 构造供应商请求;
- 携带供应商幂等键;
- 设置超时;
- 记录原始响应;
- 区分明确失败和结果未知;
- 更新
provider_job_id; - 启动回调或轮询跟踪。
18.3.6 Reconciler
负责修复分布式系统中的不一致:
- 过期但未释放的资源预留;
- 长时间处于
DISPATCHING的任务; - 供应商已经存在但本地没有记录的任务;
- Redis 队列索引与 PostgreSQL 状态不一致;
- 资源计数与有效预留不一致;
- MQ 发送失败的 Outbox;
- 长时间没有调度机会的饥饿任务。
18.4 服务等级与资源池设计
推荐至少划分以下逻辑队列:
| 队列 | 典型用户 | 调度策略 |
|---|---|---|
ENTERPRISE_RESERVED | 有合同 SLA 的企业客户 | 预留容量和硬等待时间目标 |
PAID_SHARED | 普通付费用户 | 权重公平、允许弹性扩容 |
STANDARD | 普通用户 | 标准公平调度 |
BEST_EFFORT | 免费或活动用户 | 仅使用剩余容量 |
EXPENSIVE | 4K、长时长、高成本模型 | 独立并发和成本预算 |
RECOVERY | 对账、补偿、未知任务恢复 | 小容量但不可完全阻塞 |
资源隔离不一定意味着每个队列都部署一整套独立集群。
可以采用三种程度:
逻辑隔离
共用服务实例,但使用独立的:
- 队列;
- 配额;
- semaphore;
- 成本预算;
- 调度权重。
进程隔离
不同队列使用不同的:
- RocketMQ Consumer Group;
- Worker Deployment;
- Go worker pool;
- HTTP 连接池;
- 数据库连接池配额。
计算节点隔离
对于高成本或高风险任务,进一步使用:
- 独立 Kubernetes Namespace;
- 独立 Node Pool;
- GPU 节点标签和 taint;
- CPU、内存和临时磁盘配额;
- 独立网络出口。
Kubernetes 的 ResourceQuota 可以限制 Namespace 聚合资源消耗,PriorityClass 可以表达 Pod 相对重要性及是否允许抢占低优先级 Pod;但视频生成任务是否能被安全抢占,仍需由业务状态机决定,不能直接等同于 Pod 调度优先级。(Kubernetes)
18.5 多级并发限制
18.5.1 推荐限制矩阵
| 限制项 | 示例 |
|---|---|
| 单用户提交速率 | 5 次/分钟 |
| 单租户提交速率 | 100 次/分钟 |
| 单用户排队任务 | 20 |
| 单租户排队任务 | 5,000 |
| 单用户运行任务 | 2 |
| 单租户运行任务 | 100 |
| 模型全局运行任务 | 800 |
| 供应商账户并发 | 200 |
| 高成本任务并发 | 20 |
| 代理转码槽位 | 400 units |
| 4K 渲染槽位 | 32 GPU units |
| 平台分钟成本 | 10,000 cost units |
| 单租户分钟成本 | 1,000 cost units |
具体值必须通过压测、供应商合同和线上指标确定。
18.5.2 任务数限制与资源单位限制应同时存在
仅限制任务数会被高成本任务绕过。
例如:
普通任务:1 unit
1080p 长视频:3 units
4K 视频:8 units
复杂视频到视频任务:12 units
租户可能允许:
max_running_tasks = 100
max_running_units = 240
这样即使任务数未达到 100,高成本任务也无法无限进入。
18.5.3 Provider Semaphore
供应商 semaphore 应按真实配额边界划分:
provider + account + region + model
不能把所有供应商模型共用一个 semaphore,也不能只在单个 Go 进程中使用本地 channel 控制。
本地 semaphore 只能限制当前 Pod,无法限制所有调度器实例的总并发。
18.5.4 Worker Slot
Worker slot 应表达资源消耗,而不是简单表达进程数。
例如:
720p 代理视频:1 CPU unit
1080p 代理视频:2 CPU units
4K 转码:6 CPU units
普通 GPU 渲染:1 GPU unit
高显存渲染:2 GPU units
Worker 启动任务前需要确认:
- 节点 CPU;
- 内存;
- GPU 和显存;
- 临时磁盘;
- 输出存储空间;
- 单任务超时;
- 最大 FFmpeg 进程数。
18.6 WRR、DRR、优先级与 Aging
18.6.1 Weighted Round Robin
Weighted Round Robin 按配置权重轮询租户。
例如:
Tenant A:weight = 4
Tenant B:weight = 2
Tenant C:weight = 1
理想情况下,一个轮次可以近似调度:
A、A、A、A、B、B、C
WRR 适用于任务成本比较接近的场景。
如果 A 的任务平均耗时 30 秒,而 B 的任务平均耗时 5 分钟,仅按任务数轮询并不公平。
18.6.2 Deficit Round Robin
DRR 为每个租户维护一个 deficit。
每轮增加:
quantum_i = base_quantum × tenant_weight_i
deficit_i = min(deficit_i + quantum_i, deficit_cap_i)
只有当任务成本不大于当前 deficit 时才能执行:
task.cost_units <= tenant.deficit
成功调度后:
tenant.deficit -= task.cost_units
DRR 最初用于不同报文大小下的公平排队,其关键价值是把“服务数量”转换为“服务成本”,并让未用完的配额能够跨轮次累积。
18.6.3 AI 视频任务中的 DRR 示例
假设:
base_quantum = 4
Tenant A:
weight = 2
quantum = 8
Tenant B:
weight = 1
quantum = 4
当前任务:
A1.cost = 8
A2.cost = 2
B1.cost = 4
B2.cost = 4
第一轮:
A deficit += 8,调度 A1,剩余 0
B deficit += 4,调度 B1,剩余 0
第二轮:
A deficit += 8,可调度多个小任务或一个大任务
B deficit += 4,调度 B2
长期来看,A 获得约两倍于 B 的资源份额,但不会简单按照任务条数计算。
18.6.4 防止 deficit 积累造成突发占用
当一个租户长期没有任务时,不能让 deficit 无限积累。
推荐:
deficit_cap = quantum × burst_rounds
队列从空变为非空时,也可以将 deficit 重置为:
min(previous_deficit, initial_burst_credit)
否则沉默一天的租户可能在重新提交任务后瞬间抢占大量资源。
18.6.5 优先级不能替代公平调度
推荐先选择租户,再选择租户内部任务。
服务等级选择
→ 租户公平选择
→ 租户内部优先级选择
如果直接使用全局优先级队列,持续进入的企业任务可能让普通用户永久无法运行。
18.6.6 Aging
任务的有效优先级可以表示为:
effective_priority =
base_priority
+ min(wait_seconds / aging_step, aging_cap)
但不建议每秒更新 Redis 中所有任务的 score。
更合理的实现方式是:
- 每个租户维护多个优先级桶;
- 桶内按
enqueue_at排序; - 定时将等待时间过长的任务提升到更高优先级桶;
- 对提升次数设置上限。
例如:
P0:紧急恢复任务
P1:企业高优先级
P2:普通付费任务
P3:标准任务
P4:Best Effort
每等待 5 分钟,可以提升一级,最多提升到 P1。
18.6.7 防止大任务队头阻塞
某租户队头任务需要 12 units,但当前 deficit 只有 8 units。
此时不能无限跳过它、只执行后面的小任务,否则大任务可能永久饥饿。
可以采用:
- 高成本任务进入独立资源队列;
- 允许有限次数的 fit scan;
- 达到最大跳过次数后停止跳过;
- 通过 aging 提高大任务优先级;
- 让 deficit 跨轮次积累到足够大小。
18.7 任务成本估算
18.7.1 公平调度成本
可以定义一个归一化公平单位:
fairness_units = max(
expected_provider_seconds / provider_second_base,
expected_provider_cost / cost_base,
expected_gpu_seconds / gpu_second_base
)
使用 max 而不是简单相加,可以近似表达任务的主要瓶颈资源。
例如:
provider_second_base = 30 秒
cost_base = 0.1 成本单位
gpu_second_base = 30 GPU 秒
18.7.2 实际资源仍应分别限制
fairness_units 只用于租户之间的公平选择。
真正执行时仍需要分别检查:
provider_slot_units
GPU units
CPU units
temporary_disk_units
cost_budget
一个标量不能完全表达多维资源约束。
18.7.3 估算误差修正
任务完成后记录:
estimated_units
actual_units
estimation_error = actual_units - estimated_units
如果长期低估某类任务,应更新模型参数。
还可以将误差转为租户后续调度债务:
tenant.fairness_debt += actual_units - estimated_units
下一轮增加 deficit 时,先抵扣 fairness debt。
为了防止单个异常任务让租户长期无法运行,应对单次修正设置上限。
18.8 文字版正常时序图
1. 用户调用创建生成任务接口,并携带 Idempotency-Key。
2. API Gateway 完成认证、请求速率限制和请求大小限制。
3. Admission Controller 获取租户策略,估算任务的:
- fairness_units
- provider_slot_units
- estimated_cost
- resource_class
4. Admission Controller 检查:
- 用户和租户排队上限
- 最大预计等待时间
- 租户预算
- 服务等级是否开放
- 高成本任务池是否已满
5. PostgreSQL 事务中:
- 创建 GenerationTask,状态为 QUEUED
- 预占用户额度
- 写入 OutboxEvent
- 记录 policy_version 和 estimate_version
6. Outbox Relay 将 TaskQueued 事件发送到 RocketMQ。
7. Queue Indexer 消费事件,将 task_id 加入 Redis 的租户队列索引,
并将 tenant_id 加入相应服务等级的活跃租户集合。
8. Scheduler 选择一个服务等级。
9. Scheduler 使用 DRR 选择一个租户。
10. Scheduler 在租户内部按 priority、aging 和 enqueue_at 选择任务。
11. Provider Router 根据能力矩阵、健康度、成本和可用槽位,
选择 provider、account、region 和 model。
12. PostgreSQL 原子事务中:
- CAS 将任务从 QUEUED 更新为 DISPATCHING
- 增加租户 running_units
- 增加资源池 used_units
- 创建 resource_reservation
- 创建 task_attempt
- 生成 fencing_token
- 写入 TaskDispatchRequested Outbox
13. Dispatch Worker 消费消息,调用第三方供应商。
14. 供应商明确受理后:
- 保存 provider_job_id
- 将任务更新为 RUNNING
- 启动回调或轮询跟踪
15. 供应商完成后,回调或轮询服务使用 task_id、attempt_id、
fencing_token 和 provider_job_id 更新任务。
16. PostgreSQL 事务中:
- 将任务更新为 PROVIDER_SUCCEEDED
- 幂等释放资源预留
- 更新租户和资源池计数
- 写入媒体回源事件
17. 媒体 Worker 完成回源、探测、转码和代理视频生成。
18. 任务进入 SUCCEEDED,并进行最终计费结算。
18.9 关键数据结构
18.9.1 GenerationTask
CREATE TABLE generation_task (
task_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
queue_class TEXT NOT NULL,
priority_band SMALLINT NOT NULL,
resource_class TEXT NOT NULL,
state TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
model_code TEXT NOT NULL,
selected_provider TEXT,
selected_account TEXT,
fairness_units BIGINT NOT NULL,
provider_slot_units BIGINT NOT NULL,
estimated_cost_units BIGINT NOT NULL,
enqueue_at TIMESTAMPTZ NOT NULL,
next_eligible_at TIMESTAMPTZ NOT NULL,
deadline_at TIMESTAMPTZ,
current_attempt_id UUID,
policy_version BIGINT NOT NULL,
estimate_version BIGINT NOT NULL,
idempotency_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (tenant_id, idempotency_key)
);
关键索引:
CREATE INDEX idx_task_schedulable
ON generation_task (
queue_class,
tenant_id,
priority_band,
next_eligible_at,
enqueue_at
)
WHERE state = 'QUEUED';
18.9.2 TenantSchedulingPolicy
CREATE TABLE tenant_scheduling_policy (
tenant_id UUID PRIMARY KEY,
service_class TEXT NOT NULL,
weight INTEGER NOT NULL,
max_queued_tasks INTEGER NOT NULL,
max_queued_units BIGINT NOT NULL,
max_running_tasks INTEGER NOT NULL,
max_running_units BIGINT NOT NULL,
max_cost_per_minute BIGINT NOT NULL,
max_cost_per_day BIGINT NOT NULL,
reserved_units BIGINT NOT NULL,
burst_units BIGINT NOT NULL,
max_wait_seconds INTEGER NOT NULL,
policy_version BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
18.9.3 ResourcePoolShard
资源计数如果集中在一行,可能形成数据库热点,因此可以拆成多个 shard。
CREATE TABLE resource_pool_shard (
resource_key TEXT NOT NULL,
shard_no INTEGER NOT NULL,
capacity_units BIGINT NOT NULL,
used_units BIGINT NOT NULL,
version BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (resource_key, shard_no)
);
资源预留使用条件更新:
UPDATE resource_pool_shard
SET used_units = used_units + $demand,
version = version + 1,
updated_at = now()
WHERE resource_key = $resource_key
AND shard_no = $shard_no
AND used_units + $demand <= capacity_units
RETURNING used_units, version;
没有返回行表示当前 shard 容量不足。
18.9.4 ResourceReservation
CREATE TABLE resource_reservation (
reservation_id UUID PRIMARY KEY,
task_id UUID NOT NULL,
attempt_id UUID NOT NULL,
resource_key TEXT NOT NULL,
shard_no INTEGER NOT NULL,
reserved_units BIGINT NOT NULL,
state TEXT NOT NULL,
lease_until TIMESTAMPTZ NOT NULL,
fencing_token BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
released_at TIMESTAMPTZ
);
可增加部分唯一索引,防止同一 attempt 重复创建同类有效预留。
18.9.5 TaskAttempt
CREATE TABLE task_attempt (
attempt_id UUID PRIMARY KEY,
task_id UUID NOT NULL,
attempt_no INTEGER NOT NULL,
provider TEXT NOT NULL,
provider_account TEXT NOT NULL,
provider_request_key TEXT NOT NULL,
provider_job_id TEXT,
state TEXT NOT NULL,
fencing_token BIGINT NOT NULL,
submit_started_at TIMESTAMPTZ,
submit_finished_at TIMESTAMPTZ,
next_check_at TIMESTAMPTZ,
error_class TEXT,
error_code TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (task_id, attempt_no),
UNIQUE (provider, provider_request_key)
);
18.9.6 Redis Key
| Key | 作用 |
|---|---|
rate:tenant:{id}:submit | 租户提交令牌桶 |
queue:active_tenants:{lane} | 当前有排队任务的租户 |
queue:tenant:{tenant}:{band} | 租户优先级桶中的任务索引 |
sched:deficit:{lane} | 各租户 DRR deficit |
capacity:mirror:{resource} | PostgreSQL 容量的快速镜像 |
queue:oldest:{lane} | 最老任务时间 |
budget:minute:{tenant} | 分钟成本快速计数 |
Redis Lua 可以原子执行多个读取和写入,但脚本执行期间会阻塞其他服务器活动,因此只能用于短小、边界明确的操作,不能在 Lua 中扫描大型队列。Redis Sorted Set 适合维护按 score 排序的可重建索引。(Redis)
18.9.7 Dispatch Message
{
"event_id": "uuid",
"event_type": "TaskDispatchRequested",
"task_id": "uuid",
"attempt_id": "uuid",
"tenant_id": "uuid",
"queue_class": "PAID_SHARED",
"provider": "provider-a",
"provider_account": "account-01",
"model_code": "video-model-x",
"resource_class": "video-standard",
"fencing_token": 18423,
"policy_version": 37,
"aggregate_version": 12,
"trace_id": "trace-id",
"created_at": "2026-06-24T08:00:00Z"
}
消息中可以携带资源预留 ID,但资源预留的事实仍以 PostgreSQL 为准。
18.10 原子认领与资源预留
一次调度应在单个 PostgreSQL 事务中完成:
锁定并校验任务
→ 校验租户运行额度
→ 校验资源池容量
→ 创建资源预留
→ 创建 attempt
→ 更新任务状态
→ 写入 Outbox
建议按照固定顺序锁定资源:
tenant runtime
→ service lane
→ provider pool
→ cost budget
→ task
或者先锁任务,再按排序后的资源键锁资源。整个系统必须采用统一顺序,降低死锁概率。
简化伪代码:
func ReserveAndClaim(ctx context.Context, taskID uuid.UUID) error {
return db.WithTx(ctx, func(tx *sql.Tx) error {
task, err := lockQueuedTask(ctx, tx, taskID)
if err != nil {
return err
}
if err := reserveTenantCapacity(ctx, tx, task); err != nil {
return err
}
if err := reserveProviderCapacity(ctx, tx, task); err != nil {
return err
}
attempt, err := createAttempt(ctx, tx, task)
if err != nil {
return err
}
if err := markDispatching(ctx, tx, task, attempt); err != nil {
return err
}
return insertDispatchOutbox(ctx, tx, task, attempt)
})
}
多个调度器做数据库兜底扫描时,可以使用 FOR UPDATE SKIP LOCKED 跳过已被其他事务锁定的任务。它适合队列式认领,但返回的是有意不完整的视图,不能仅凭它实现租户公平。(PostgreSQL)
18.11 准入控制、队列深度与最大等待时间
18.11.1 准入决策
Admission Controller 可以返回:
ACCEPTED
ACCEPTED_BEST_EFFORT
REJECT_USER_RATE_LIMIT
REJECT_TENANT_QUEUE_FULL
REJECT_EXPECTED_WAIT_TOO_LONG
REJECT_COST_BUDGET
REJECT_RESOURCE_CLASS_DISABLED
REJECT_PLATFORM_OVERLOADED
对允许排队的任务,响应中可以包含:
{
"task_id": "uuid",
"state": "QUEUED",
"queue_class": "PAID_SHARED",
"estimated_start_at": "2026-06-24T08:05:00Z",
"queue_position_level": "MEDIUM"
}
精确队列位置在公平调度系统中通常不稳定,建议返回等级或预计时间范围。
18.11.2 队列上限应以资源单位为主
同时限制:
max_queued_tasks
max_queued_units
原因是 1,000 个普通任务和 1,000 个 4K 长视频任务的资源压力完全不同。
18.11.3 最大等待时间
设某服务等级的安全服务能力为:
μ_units = 每秒可处理的公平资源单位
前方排队量为:
Q_ahead_units
则可粗略估算:
estimated_wait ≈ Q_ahead_units / μ_units
如果:
estimated_wait > tenant.max_wait_seconds
应采取以下一种策略:
- 拒绝新任务;
- 降级到 Best Effort;
- 降低分辨率或模型档位,但必须取得用户同意;
- 提示用户稍后重试;
- 企业合同用户进入独立预留池。
18.11.4 队列最大深度
可用以下方式初步估算:
max_queue_units ≈ safe_service_units_per_second × max_wait_seconds
这只是初始容量值,线上还需要根据以下指标动态修正:
- P95 实际生成时长;
- 供应商失败率;
- 不同任务成本分布;
- 调度权重;
- 资源池利用率;
- 回调和轮询延迟;
- 不可取消任务比例。
18.12 大客户与普通用户的资源隔离
18.12.1 预留容量
例如平台有 2,000 个供应商并发单位,可以划分为:
企业合同预留:500
付费共享池:1,200
Best Effort:200
恢复和运维:100
预留容量不能简单永久闲置。
18.12.2 可借用但不可立即抢占
企业池空闲时,普通付费任务可以借用一部分容量:
borrowable_enterprise_units = 200
借用任务需要标记:
capacity_mode = BORROWED
当企业流量重新上升时:
- 停止新的借用;
- 等待已有借用任务自然结束;
- 不直接取消已经提交到供应商的任务;
- 始终保留一部分未借出的企业 headroom。
这种策略能够兼顾利用率和企业 SLA。
18.12.3 独立大客户
对于有明确合同容量的大客户,可以进一步采用:
- 独立服务等级;
- 独立 Provider Account;
- 独立 Consumer Group;
- 独立 Worker Deployment;
- 独立 Kubernetes Namespace 或 Node Pool;
- 独立成本预算和告警。
但不建议为每个普通租户都创建独立 Topic、独立数据库表或独立 Kubernetes Namespace,否则运维对象数量会快速膨胀。
18.13 高成本任务隔离
高成本任务包括:
- 4K;
- 长时长;
- 视频到视频;
- 多参考人物;
- 高帧率;
- 带音频生成;
- 复杂最终渲染;
- 大量 GPU 显存需求。
这些任务应至少具备:
独立 queue_class
独立 max_running_units
独立 cost budget
独立 worker pool
独立超时
独立最大重试次数
普通任务池资源耗尽时,不应自动借用所有高成本任务资源;反过来也一样。
可以允许有限借用,但必须配置:
borrow_limit
borrow_timeout
minimum_reserved_headroom
高成本任务还应采用更严格的准入:
- 先完成额度预占;
- 检查租户合同权限;
- 检查媒体时长、分辨率和文件大小;
- 检查供应商实际成本;
- 对不支持幂等的供应商减少自动重试。
18.14 异常流程与竞态条件
| 场景 | 风险 | 处理方式 |
|---|---|---|
| Scheduler 认领后崩溃 | 槽位长期泄漏 | lease、过期扫描、资源重建 |
| MQ 重复投递 | 重复调用供应商 | attempt_id、状态 CAS、供应商幂等键 |
| 供应商已受理但本地超时 | 重复生成、重复计费 | 进入 SUBMIT_UNKNOWN,先查询后重试 |
| Provider 返回 429 | 重试风暴 | 释放明确未受理的槽位,延迟重新调度 |
| Redis 队列索引丢失 | 任务不再被调度 | PostgreSQL 扫描重建 |
| Redis 容量镜像偏大 | 预检查通过但真实容量不足 | PostgreSQL 条件更新最终拒绝 |
| 任务取消与调度同时发生 | 已取消任务仍被提交 | state + version CAS |
| 完成回调与取消回调同时到达 | 终态覆盖 | 状态机限定合法迁移 |
| lease 过期但旧 Worker 仍执行 | 旧 Worker 覆盖新 attempt | fencing_token |
| 大任务长期在队首 | 队头阻塞或任务饥饿 | DRR 累积、独立资源类、有限 fit scan |
| 调度策略在线修改 | 同一任务前后规则不一致 | 固化 policy_version |
| 释放消息重复消费 | 计数被重复减小 | reservation 状态 CAS,幂等释放 |
RocketMQ 的消费重试适合处理偶发业务处理失败,不应被当作限速机制;消费者扩缩容和重新平衡也可能导致少量消息重复处理,因此消费逻辑必须具备业务幂等。(RocketMQ)
18.15 “供应商已成功,但本地超时”的处理
这是整章最重要的异常场景之一。
错误做法
调用供应商超时
→ 释放 provider slot
→ 立即重新投递
→ 再次调用供应商
供应商可能已经创建了第一个任务。
结果是:
- 实际并发超过平台记录;
- 两次供应商费用;
- 两个视频结果;
- 用户只支付一次;
- 后续无法准确释放资源。
正确状态
DISPATCHING
→ SUBMIT_UNKNOWN
进入 SUBMIT_UNKNOWN 后:
- 暂时保留供应商资源预留;
- 记录唯一
provider_request_key; - 查询供应商是否支持按幂等键或客户端请求 ID 查询;
- 如果查到任务,保存
provider_job_id并进入RUNNING; - 如果供应商明确表示不存在,再释放旧预留并重新提交;
- 如果供应商既不能查询也不支持幂等,需要等待安全窗口或进入人工补偿;
- 不得把普通 MQ 重试直接等同于再次调用供应商。
如果必须重试供应商调用,应优先使用同一个供应商幂等键。
18.16 幂等、一致性、重试和补偿
18.16.1 幂等键层次
| 层次 | 幂等键 |
|---|---|
| API 创建任务 | tenant_id + idempotency_key |
| 调度 attempt | task_id + attempt_no |
| 资源预留 | attempt_id + resource_key |
| MQ 事件 | event_id |
| 供应商提交 | provider_request_key |
| 输出资产 | task_id + output_version |
| 计费结算 | task_id + settlement_type |
18.16.2 状态 CAS
更新任务时使用:
UPDATE generation_task
SET state = $new_state,
version = version + 1,
updated_at = now()
WHERE task_id = $task_id
AND state = $expected_state
AND version = $expected_version;
受影响行数为 0,说明状态已经被其他流程修改。
18.16.3 Fencing Token
每次 attempt 生成单调递增的 fencing token。
Worker 回写时必须满足:
callback.fencing_token == task.current_fencing_token
旧 Worker 即使仍在运行,也不能覆盖新 attempt 的结果。
但应注意:
fencing token 只能阻止本地旧结果写入,不能撤销旧 Worker 已经对第三方产生的外部副作用。
因此仍需要对供应商任务进行孤儿任务查询和成本对账。
18.16.4 资源释放
资源释放应在 PostgreSQL 事务中执行:
ACTIVE reservation
→ RELEASED reservation
→ resource_pool.used_units 减少
→ tenant running_units 减少
只有成功将 reservation 从 ACTIVE 更新为 RELEASED 的事务,才允许减少计数。
18.16.5 重试分类
| 操作 | 是否可直接重试 |
|---|---|
| PostgreSQL 事务死锁 | 可以,短退避并限制次数 |
| Redis 预检查失败 | 可以回退数据库或稍后重试 |
| RocketMQ 消费失败 | 可以,但消费逻辑必须幂等 |
| Provider 明确未受理 | 可以延迟重试 |
| Provider 返回 429 | 可以延迟重试并降低并发 |
| Provider 请求超时 | 不能直接重新提交 |
| FFmpeg 在写临时文件阶段失败 | 可以清理工作目录后重试 |
| 计费结算超时 | 使用唯一业务键重试 |
| 回调处理失败 | 可以重放,但状态更新必须 CAS |
18.16.6 补偿任务
需要定期执行:
ExpiredReservationScanner
StuckDispatchingScanner
UnknownSubmissionReconciler
QueueIndexRebuilder
ResourceCounterRebuilder
StarvationDetector
ProviderCostReconciler
资源计数重建时,以有效 resource_reservation 聚合结果为事实,而不是以 Redis 当前值为事实。
18.17 性能瓶颈与容量估算
18.17.1 在途任务估算
假设:
稳态到达速率:8 tasks/s
峰值到达速率:12 tasks/s
平均生成时间:150 s
稳态在途任务:
L = λ × W
= 8 × 150
= 1,200 tasks
这意味着即使任务创建接口只有 8 QPS,平台也可能长期维护 1,200 个供应商在途任务。
18.17.2 供应商服务能力
假设多个供应商总共提供:
原始并发槽位:2,000
目标安全利用率:75%
平均任务时间:150 秒
安全运行槽位:
safe_slots = 2,000 × 0.75
= 1,500
安全吞吐量:
μ_safe = 1,500 / 150
= 10 tasks/s
稳态到达 8 tasks/s 时,有约 2 tasks/s 的恢复余量。
18.17.3 峰值积压
峰值持续 5 分钟:
峰值流入:12 tasks/s
安全处理:10 tasks/s
持续时间:300 s
新增积压:
backlog = (12 - 10) × 300
= 600 tasks
峰值结束后回到 8 tasks/s:
净清理速度 = 10 - 8
= 2 tasks/s
清理时间:
600 / 2 = 300 秒
约 5 分钟恢复。
如果稳态到达速率已经等于安全处理能力,则峰值积压不会自动消失,系统必须扩容或拒绝部分请求。
18.17.4 队列上限
若业务允许最大排队 10 分钟,安全服务率为 10 tasks/s:
理论队列上限 ≈ 10 × 600
= 6,000 个平均任务
实际应使用资源单位,并为以下因素保留余量:
- 供应商故障;
- 任务成本长尾;
- 回调丢失;
- 高成本任务;
- 重试和未知提交;
- 企业预留容量。
例如将实际全局准入上限设置为:
4,000 个平均任务单位
同时再设置每租户上限。
18.17.5 媒体 Worker 容量
假设生成成功任务进入媒体处理的速率为:
8 tasks/s
平均媒体处理时间:
45 s
需要的并发单位:
8 × 45 = 360 units
增加 30% 余量:
360 × 1.3 ≈ 468 units
这说明生成供应商容量和媒体 Worker 容量必须一起规划。只扩容供应商而不扩容回源和转码,会把瓶颈从生成阶段转移到媒体阶段。
18.17.6 轮询容量
假设:
- 1,500 个在途任务;
- 70% 通过回调;
- 30% 需要轮询;
- 轮询间隔 15 秒。
平均轮询 QPS:
1,500 × 30% / 15
= 30 QPS
如果所有任务每秒轮询一次,则会变成 1,500 QPS,因此必须使用分阶段轮询和抖动:
刚提交:10 秒后首次查询
运行中:15~30 秒
接近超时:适当加密
连续异常:指数退避
18.17.7 调度系统常见瓶颈
| 瓶颈 | 优化方式 |
|---|---|
| 单个资源池计数行过热 | 资源计数分 shard |
| 单个 Redis 活跃租户 key 过热 | 按 lane 和 hash 分片 |
| 每次调度扫描全部租户 | 维护活跃租户环 |
| 无资源时持续空转 | next_eligible_at、事件唤醒、退避 |
| 一次只调度一条任务 | 小批量 claim,但限制事务大小 |
| MQ Consumer 拉取过快 | 降低消费并发和本地缓存 |
| 数据库连接池耗尽 | 为 API、Scheduler、Reconciler 分配预算 |
| Provider 429 | 自适应降低 semaphore |
| 大任务估算严重偏差 | 使用历史 P95 和实际消耗校准 |
| Worker 临时盘耗尽 | 磁盘 slot、工作目录配额和清理器 |
18.18 高可用与降级方式
| 故障 | 降级行为 |
|---|---|
| 单个 Scheduler 崩溃 | 其他实例继续;过期 lease 由 Reconciler 接管 |
| Redis 不可用 | 切换 PostgreSQL 保守调度,降低并发,暂停 Best Effort |
| RocketMQ 不可用 | Outbox 堆积;超过阈值后停止新任务准入 |
| PostgreSQL 不可用 | 停止新付费任务和新调度,避免无记录外部成本 |
| 单个 Provider 故障 | 熔断并切换兼容供应商,已受理任务不直接迁移 |
| 全部 Provider 故障 | 保留排队任务,停止继续消耗额度 |
| Media Worker 满载 | 暂停媒体消费,生成结果保留待回源 |
| 策略服务不可用 | 使用最近一次已签名策略快照 |
| 容量计数异常 | 进入保守容量模式并触发全量重建 |
| 回调网关故障 | 使用后端轮询补偿 |
| 大客户流量突增 | 使用预留池,限制共享池借用 |
Redis 降级
Redis 不可用时:
- 不再依赖 Redis deficit 和队列索引;
- 从 PostgreSQL 按服务等级扫描候选任务;
- 降低调度批量;
- 使用数据库资源预留作为最终限制;
- 暂停免费或高成本任务;
- 恢复后重建 Redis 索引。
RocketMQ 降级
不能简单绕过 MQ 直接调用供应商,否则会产生两套提交路径。
可以:
- 继续把事件写入 Outbox;
- 让 Outbox Relay 重试;
- Outbox 积压超过阈值后关闭新准入;
- 保留运维控制的紧急直连流程,但必须使用同一 attempt、幂等键和状态机。
Provider 降级
切换供应商前必须确认:
- 模型能力兼容;
- 用户是否允许模型变化;
- 内容审核标准是否兼容;
- 输出分辨率和时长一致;
- 成本是否超出租户预算;
- 原供应商是否明确未受理。
18.19 安全风险
18.19.1 用户不能自行指定调度权重
以下字段必须由服务端根据合同和策略生成:
queue_class
tenant_weight
base_priority
resource_class
reserved_capacity
不能直接信任客户端提交的 priority=9999。
18.19.2 防止低报资源需求
客户端可能伪造:
- 视频时长;
- 分辨率;
- 文件大小;
- 编码格式;
- 模型档位。
平台必须通过服务端媒体探测和模型能力矩阵重新计算资源需求。
18.19.3 队列洪泛
攻击者可能:
- 不断创建不同幂等键;
- 创建后立即取消;
- 提交大量超大参考视频;
- 使用高成本参数制造估算压力。
需要:
- 请求速率限制;
- 排队单位限制;
- 上传大小和时长限制;
- 取消频率限制;
- 创建任务前额度预占;
- 异常租户熔断。
18.19.4 管理面安全
调整以下配置的接口必须具有严格 RBAC 和审计:
租户权重
预留容量
供应商并发
成本预算
资源池容量
优先级
准入开关
配置变更应记录:
operator
old_value
new_value
reason
policy_version
timestamp
18.19.5 多租户隔离
所有调度查询、任务查询和资源统计都必须带 tenant_id 边界。
不能只依赖前端隐藏任务 ID。
18.20 常见错误设计及其后果
| 错误设计 | 后果 |
|---|---|
| 所有任务共用一个 FIFO | 大客户造成 noisy neighbor |
| 只按任务数公平 | 长任务和高成本任务占用过多资源 |
| 全局高优先级队列 | 低优先级任务永久饥饿 |
| Redis 计数作为唯一事实 | 故障后槽位丢失或超发 |
| 每个 Pod 使用本地 semaphore | 多实例总并发失控 |
| 先调用 Provider,再记录 attempt | 超时和崩溃后产生孤儿任务 |
| Provider 超时后立即释放槽位 | 对方已受理时发生超卖 |
| 利用 MQ 消费失败做限流 | 重试风暴和 DLQ 污染 |
| 无界队列 | 高峰期间耗尽内存、数据库和预算 |
| 高成本任务与普通任务共池 | 普通任务延迟被长任务拖垮 |
| 企业预留资源永久禁止借用 | 利用率过低 |
| 企业资源全部允许借用 | 企业流量恢复时无法满足 SLA |
| 每秒重写所有任务 aging score | Redis CPU 和网络开销过高 |
任务完成时直接 used-- | 重复回调导致计数变负 |
| 只监控队列长度 | 无法发现高成本单位积压和饥饿 |
18.21 面试官可能追问的 10 个问题及资深回答
问题一:为什么不用 RocketMQ 的多个队列直接实现租户公平?
回答:
RocketMQ 更适合可靠传递和削峰,不适合作为全部业务调度策略的事实源。
消息队列可以按 Topic、Tag 或 MessageQueue 做物理分流,但租户数量可能非常大,为每个租户创建 Topic 或 Queue 会增加运维复杂度。更重要的是,租户权重、任务成本、Provider 容量和成本预算是动态业务条件,不能只依赖 MQ 的原生消费顺序。
我会使用少量按服务等级或资源类型划分的 Topic,由调度器在业务层做租户 DRR 和资源预留。RocketMQ 负责传递“任务已排队”和“任务可派发”事件,不负责最终公平决策。
问题二:WRR 和 DRR 应该怎样选择?
回答:
如果任务成本高度接近,例如都是相同时长和相同模型,WRR 简单、易实现。
AI 视频任务通常在时长、分辨率、模型和成本上差异明显。只按任务条数做 WRR,会让提交大任务的租户获得更多实际资源。因此更适合使用 DRR,把预计成本转换为 units。
实际系统可以先用 WRR 上线,再根据历史耗时和费用引入 DRR。但从设计上要预留 cost_units 和 deficit,避免后期大改数据模型。
问题三:任务实际成本在完成前不知道,DRR 怎么保证公平?
回答:
调度时使用历史数据估算:
模型
分辨率
时长
输入视频大小
供应商
地区
质量档位
任务完成后记录实际时长和实际成本,计算估算误差。
对于长期系统性误差,更新估算模型;对于单次低估,可以把差额计入租户后续的 fairness debt。公平调度不需要每个任务估算绝对精确,但估算不能长期被某类请求系统性利用。
问题四:多个 Scheduler Pod 怎样保证不会超过 Provider 并发?
回答:
本地 Go semaphore 只能作为 Pod 内保护,不能作为全局限制。
全局流程是:
- Redis 容量镜像做快速预检查;
- PostgreSQL 事务对资源池 shard 做条件更新;
- 更新成功后创建资源预留和 attempt;
- 事务提交后才发送派发事件。
这样即使多个 Scheduler 同时选择到相同 Provider,只有成功完成数据库预留的任务可以调用供应商。
问题五:为什么不直接用 Redis Lua 原子扣减所有槽位?
回答:
Redis Lua 很适合做高性能准入和快速预留,但 Redis 不应成为核心任务和资源事实的唯一来源。
如果 Redis 故障、切换或数据被清理,系统必须知道哪些任务实际占用了 Provider 和 Worker。这个事实需要与 task attempt 一起持久化在 PostgreSQL。
我会让 Redis 负责减少无效数据库尝试,而让 PostgreSQL 的资源预留事务承担最终正确性。Redis 恢复后,可以从有效 reservation 重建。
问题六:Provider 调用超时后为什么不能直接换供应商?
回答:
因为超时只说明本地没有收到响应,不说明对方没有受理。
如果原供应商已经创建任务,再切换到新供应商,会出现双生成和双计费。
正确流程是进入 SUBMIT_UNKNOWN:
- 保留原资源预留;
- 使用客户端请求 ID 查询;
- 优先重试同一个幂等键;
- 只有明确确认原任务不存在,才允许创建新 attempt;
- 无法确认时进入延迟对账或人工补偿。
问题七:Priority 加 Aging 能否替代 DRR?
回答:
不能。
Priority 和 Aging 解决的是任务紧急程度和饥饿问题,DRR 解决的是不同租户和不同任务成本之间的长期资源公平。
合理顺序是:
先通过 DRR 选择租户
再通过 priority 和 aging 选择租户内任务
只有少量真正紧急的恢复任务,才进入独立的高优先级恢复池。
问题八:企业预留容量空闲时怎样避免浪费?
回答:
允许共享池借用一部分企业容量,但借用必须有上限并标记为 borrowed。
企业流量恢复时:
- 停止新借用;
- 保留固定 headroom;
- 等待借用任务自然完成;
- 不对已提交供应商的任务做强制抢占。
如果企业 SLA 很严格,应降低可借比例,而不是假设所有运行任务都可以随时取消。
问题九:Scheduler 崩溃后怎样恢复资源计数?
回答:
每次资源占用都有持久化的 resource_reservation。
Reconciler 定期:
- 找出 lease 过期的 reservation;
- 检查对应 attempt 是否仍可能在 Provider 运行;
- 对明确终止的 reservation 幂等释放;
- 对状态未知的任务进入查询流程;
- 聚合所有有效 reservation,与资源池计数对比;
- 修正 PostgreSQL 计数并重建 Redis 镜像。
不能只依据 lease 到期就释放,因为供应商任务可能已经成功提交。
问题十:队列最大长度应该怎样确定?
回答:
不能只拍一个任务数。
先计算安全服务能力:
safe_service_rate =
安全并发槽位 / 平均服务时间
再根据业务允许的最大等待时间:
max_queue_units ≈
safe_service_units_per_second × max_wait_seconds
然后根据任务成本长尾、供应商故障、重试和预留容量打折。
最终同时设置:
- 全局任务数上限;
- 全局资源单位上限;
- 每服务等级上限;
- 每租户上限;
- 最大预计等待时间;
- 最老任务等待时间告警。
18.22 三分钟口述稿
这个系统的高并发重点不是 HTTP QPS,而是分钟级 AI 任务带来的大量在途任务和稀缺资源占用。假设每秒进入 8 个任务,平均生成 150 秒,根据 Little’s Law,平台长期会有大约 1,200 个在途任务。因此我不会通过无限增加 goroutine 来解决,而是设计准入、排队、公平调度、资源预留和背压。
整个调度分四层。第一层选择企业预留、付费共享、普通或高成本资源池;第二层在资源池内使用 DRR 选择租户;第三层在租户内部根据优先级、等待时间和任务成本选择任务;第四层匹配具体 Provider、模型、账户和 Worker。
因为不同视频任务的时长、分辨率和费用差异很大,我不会只按任务条数公平,而会给任务估算 fairness units。租户每轮获得与权重成比例的 quantum,任务只有在成本不超过 deficit 时才能运行。这样大客户可以获得更高份额,但不能占满所有资源;普通用户等待过久后,也可以通过 aging 提升优先级。
资源控制是多级的,包括用户并发、租户并发、服务等级容量、Provider 并发、模型并发、GPU 槽位和分钟成本预算。Redis 保存令牌桶、活跃租户和容量镜像,用于快速筛选;PostgreSQL 保存任务、attempt 和 resource reservation,是最终事实源。调度器必须先在 PostgreSQL 事务中完成任务认领、租户配额扣减、Provider 槽位预留和 Outbox 写入,提交后才能调用第三方。
最关键的异常是供应商已经受理但本地超时。此时不能直接释放槽位并重试,而要进入 SUBMIT_UNKNOWN,保留资源预留,通过供应商幂等键或客户端请求 ID 查询。只有明确确认原请求不存在,才能重新提交,否则可能发生双生成和双计费。
高成本任务会进入独立资源池,企业预留容量可以有限借用,但已提交的生成任务通常按不可抢占处理。Scheduler、MQ Consumer 和 Worker 都是有界并发;队列达到最大资源单位或预计等待时间后,系统会拒绝或降级新请求。
最终目标不是让所有用户完全相同,而是在合同权重、平台成本和资源容量边界内,实现可解释、可恢复、可观测的公平调度。
18.23 十分钟深入讲解提纲
| 时间 | 内容 |
|---|---|
| 0:00—1:00 | 说明 AI 视频高并发的本质:长任务、在途量、成本和不可抢占 |
| 1:00—2:00 | 展示总体架构:Admission、Queue Index、Scheduler、Resource Manager、Dispatcher |
| 2:00—3:30 | 讲解分层调度:服务等级、租户、任务、Provider |
| 3:30—5:00 | 对比 FIFO、WRR 和 DRR,解释 cost units、deficit 和 aging |
| 5:00—6:00 | 讲解多级并发:租户、Provider、模型、Worker、成本预算 |
| 6:00—7:00 | 讲解 PostgreSQL 原子认领、resource reservation、Outbox 和 fencing token |
| 7:00—8:00 | 重点推演“供应商已受理但本地超时”及重复消费 |
| 8:00—9:00 | 使用 Little’s Law 完成在途量、供应商吞吐和队列深度计算 |
| 9:00—9:40 | 讲解 Redis、MQ、Provider 和 Scheduler 故障时的降级 |
| 9:40—10:00 | 总结:有界准入、分层公平、持久化预留、不可盲目重试 |
18.24 本章总结
本章可以浓缩为五句话:
- AI 视频系统的高并发核心是大量长任务在途,而不是单纯的 API QPS。
- 公平调度应先选服务等级和租户,再选租户内部任务,不能只使用全局 FIFO。
- 任务成本差异较大时,DRR 通常比按任务条数计算的 WRR 更合理。
- Redis 可以加速准入和队列选择,但任务认领与资源预留必须有持久化事实源。
- 供应商调用超时不等于调用失败,任何重试都必须先分析重复生成和重复计费风险。