AiVedio:Redis 限流、缓存与并发控制
从 Redis 的速度层与控制层定位出发,拆解 AI 视频平台中的限流、配额、并发槽位、租约、Fencing Token、状态缓存、Pub/Sub、Streams、Cluster、热 Key 和故障降级。
第 08 章:Redis 限流、缓存与并发控制
8.1 本章定位:Redis 不是事实源,而是“速度层”和“控制层”
在 AI 生成视频平台里,Redis 的定位一定要讲清楚:
PostgreSQL 管事实,RocketMQ 管异步传递,Redis 管速度、准入和短期状态。
AI 视频生成任务有几个典型特征:
-
请求入口高并发 用户可能频繁点击生成、重新生成、扩写 prompt、上传参考图。
-
任务本身长耗时 一个 text-to-video 或 image-to-video 任务可能持续几十秒到数分钟。
-
第三方模型有严格配额 供应商通常会限制 QPS、并发数、RPM、每日额度、单组织额度。
-
任务成本高 不能让用户无限提交,也不能因为并发竞争导致重复扣费、重复调用模型。
-
状态刷新频繁 前端要展示排队中、生成中、进度、失败原因、输出视频地址。
所以 Redis 在这个系统里主要承担四类职责:
| 类型 | 典型用途 | 是否是最终事实源 |
|---|---|---|
| 准入控制 | 用户限流、租户限流、接口限流 | 否 |
| 并发控制 | provider/model 并发槽位、worker 租约 | 否 |
| 短期缓存 | 任务进度、项目元数据、用户短期额度快照 | 否 |
| 实时通知加速 | SSE/WebSocket 节点间广播、短期事件流 | 否 |
面试时不要说:
我们用 Redis 保证任务不会重复执行。
更好的说法是:
Redis 负责减少竞争、加速查询、做短期准入控制;真正的任务事实、计费事实和终态一致性仍然由 PostgreSQL 的事务、唯一约束、状态机版本号和幂等表保证。
8.2 Redis 在 AI 视频平台中的具体位置
可以把 Redis 放在下面这条链路里理解:
Browser
│
▼
API Gateway
├── IP 限流
├── 用户限流
└── 短期幂等缓存
│
▼
Generation Service
├── 租户额度快照缓存
├── 任务状态缓存
└── 写 PostgreSQL 事实源
│
▼
RocketMQ
│
▼
Scheduler
├── 用户队列权重
├── Provider 并发槽位
├── Model 并发槽位
├── Worker 租约
└── 熔断状态
│
▼
Provider Adapter
│
▼
第三方 AI 视频模型
│
▼
Task State Service
├── 更新 PostgreSQL
├── 刷新 Redis 状态缓存
└── Pub/Sub 通知 WebSocket/SSE 节点
Redis 不应该承担这些职责:
| 不应该放 Redis 的内容 | 原因 |
|---|---|
| 用户余额唯一事实 | Redis 可能丢数据、过期、被驱逐,计费必须可审计 |
| 任务终态唯一事实 | 任务完成、失败、取消必须落 PostgreSQL |
| 项目时间轴唯一版本 | 时间轴是核心资产,必须版本化、可恢复 |
| 视频资产元数据唯一事实 | 素材、输出、checksum、对象存储 key 必须持久化 |
| 不可丢失事件 | 应该放 PostgreSQL Outbox、RocketMQ 或事件表 |
一句话:
Redis 可以让系统更快,但不能让系统“正确”。正确性要靠数据库事务、状态机和幂等设计。
8.3 限流、配额、并发控制不是一回事
很多候选人会把这三件事混在一起讲:
我们用 Redis 做限流,所以不会打爆供应商。
这个说法太粗糙。
在 AI 视频项目里,至少要区分三类控制:
| 控制类型 | 控制什么 | 例子 |
|---|---|---|
| Rate Limit | 单位时间内允许多少次请求 | 每个用户每分钟最多提交 5 次生成 |
| Quota | 一段周期内允许消耗多少资源 | 免费用户每天最多生成 10 条视频 |
| Concurrency Slot | 同时运行中的任务数量 | Veo 模型同时最多 50 个任务 |
这三类控制要同时存在。
例如:
用户 A:
- 每分钟最多提交 3 次生成请求
- 每天最多生成 20 次
- 同时最多运行 2 个任务
租户 T:
- 每分钟最多提交 100 次
- 同时最多运行 30 个任务
Provider P:
- 全局同时最多 200 个任务
Model M:
- 同时最多 50 个任务
请求提交时主要检查:
用户是否被限流
租户是否被限流
用户额度是否足够
余额是否可预占
是否存在幂等重复请求
调度执行时主要检查:
用户 running 数是否达到上限
租户 running 数是否达到上限
provider running 数是否达到上限
model running 数是否达到上限
worker 是否能拿到租约
所以正确架构是:
入口限流:保护 API 和系统入口
业务额度:保护成本和商业规则
并发槽位:保护第三方模型和调度系统
数据库事务:保护扣费、任务创建和终态一致性
8.4 Token Bucket:适合生成请求入口限流
8.4.1 为什么适合 AI 视频生成
Token Bucket 的核心思想是:
桶里按照固定速率补充 token,请求进来时消耗 token;桶容量允许短时间突发,但长期平均速率受控。
它适合 AI 视频平台的原因是:
- 用户可能短时间连续点击生成。
- 系统可以允许小范围突发,避免体验太硬。
- 长期要限制平均请求速率。
- Redis + Lua 可以把读取、补充、扣减合并成一次原子操作。
Redis 官方也提供了基于 Redis 和 Lua 实现 token bucket rate limiter 的文档示例,INCR 文档中也把计数器限流作为 Redis 的典型模式之一。(Redis)
8.4.2 Token Bucket 逻辑
假设:
capacity = 10 # 桶最大容量
refill_rate = 1 token/s # 每秒补充 1 个 token
cost = 1 # 每次生成请求消耗 1 个 token
每次请求时:
now = 当前时间
elapsed = now - last_refill_time
new_tokens = min(capacity, old_tokens + elapsed * refill_rate)
if new_tokens >= cost:
new_tokens -= cost
allow
else:
reject
Redis key 可以这样设计:
rl:gen:user:{user_id}
rl:gen:tenant:{tenant_id}
rl:prompt:user:{user_id}
rl:upload:tenant:{tenant_id}
value 可以用 Hash:
tokens
last_refill_ms
8.4.3 Lua 原子限流伪代码
-- KEYS[1] = rate limit key
-- ARGV[1] = now_ms
-- ARGV[2] = capacity
-- ARGV[3] = refill_rate_per_ms
-- ARGV[4] = cost
-- ARGV[5] = ttl_ms
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill_rate = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl = tonumber(ARGV[5])
local data = redis.call("HMGET", key, "tokens", "last_refill_ms")
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local elapsed = math.max(0, now - last_refill)
local refill = elapsed * refill_rate
tokens = math.min(capacity, tokens + refill)
local allowed = 0
local retry_after_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_after_ms = math.ceil((cost - tokens) / refill_rate)
end
redis.call("HMSET", key, "tokens", tokens, "last_refill_ms", now)
redis.call("PEXPIRE", key, ttl)
return {allowed, tokens, retry_after_ms}
返回给 API 层后:
allowed = 1
继续进入业务校验
allowed = 0
返回 429 Too Many Requests
响应体带 retry_after_ms
前端可以展示:
请求过于频繁,请稍后再试
而不是直接报系统错误。
8.5 Sliding Window:适合更精确的风控和防刷
Token Bucket 允许突发,所以不适合所有场景。
例如:
免费用户每 10 分钟最多提交 3 次高成本模型任务
这种规则更适合 Sliding Window。
8.5.1 Sliding Window Log
实现方式:
ZSET key = rl:expensive:user:{user_id}
score = 请求时间戳
member = request_id
每次请求:
1. 删除窗口外记录
2. 统计窗口内数量
3. 如果数量 >= limit,拒绝
4. 否则写入当前请求
5. 设置 TTL
伪代码:
-- KEYS[1] = zset key
-- ARGV[1] = now_ms
-- ARGV[2] = window_ms
-- ARGV[3] = limit
-- ARGV[4] = request_id
-- ARGV[5] = ttl_ms
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local request_id = ARGV[4]
local ttl = tonumber(ARGV[5])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count >= limit then
return {0, count}
end
redis.call("ZADD", key, now, request_id)
redis.call("PEXPIRE", key, ttl)
return {1, count + 1}
优点:
精确
适合风控
适合低频高价值操作
缺点:
每个请求都要写一条记录
内存成本更高
高 QPS 场景下 ZSET 操作成本更高
8.5.2 Sliding Window Counter
如果不需要那么精确,可以把窗口分成多个小桶:
1 分钟窗口
拆成 6 个 10 秒小桶
统计最近 6 个桶的总和。
优点:
比固定窗口准确
比 Sliding Window Log 节省内存
缺点:
有近似误差
实现比固定窗口复杂
AI 视频平台可以这样选型:
| 场景 | 推荐算法 |
|---|---|
| 普通 API 请求 | Token Bucket |
| 生成任务提交 | Token Bucket + 日额度检查 |
| 高成本模型调用 | Sliding Window Log |
| IP 防刷 | Sliding Window Counter |
| 登录、验证码 | Sliding Window Log |
| SSE/WebSocket 重连 | Token Bucket |
8.6 多级限流:用户、租户、模型、供应商
AI 视频平台不能只做单个用户限流,还要做多级限流。
一次生成请求至少要经过这些检查:
user_limit
tenant_limit
endpoint_limit
model_submit_limit
provider_submit_limit
可以设计成:
rl:user:{user_id}:generate
rl:tenant:{tenant_id}:generate
rl:model:{model}:submit
rl:provider:{provider}:submit
rl:ip:{ip}:api
提交请求时:
1. 检查 IP 限流
2. 检查用户限流
3. 检查租户限流
4. 检查模型提交限流
5. 检查供应商提交限流
6. 检查 PostgreSQL 中的额度和余额
7. 创建任务
注意这里有一个重要原则:
Redis 限流通过,不代表一定能创建任务;Redis 限流只是第一层准入。
真正创建任务时还要进入 PostgreSQL 事务:
begin transaction
检查用户状态
检查项目权限
检查余额
插入 generation_tasks
插入 credit_ledger 预占记录
插入 outbox_events
commit
不能因为 Redis 里看到用户还有额度,就直接认为可以扣费。
Redis 可以缓存额度快照,例如:
quota:snapshot:user:{user_id}
但它只能作为快速拒绝或快速展示:
estimated_remaining_credits
daily_free_remaining
vip_level
rate_limit_profile
最终是否能扣费,必须以 PostgreSQL 的 credit_ledger 为准。
8.7 并发槽位:控制正在运行的任务数
限流控制的是“单位时间请求数”,并发槽位控制的是“同时运行数”。
AI 视频平台最容易出问题的是:
请求入口没被打爆
但是调度器把任务全部提交给第三方
导致 provider 429、超时、封禁、成本失控
所以调度器在真正调用供应商前,要先拿 Redis 并发槽位。
8.7.1 并发维度
建议至少有这些维度:
tenant_inflight:{tenant_id}
user_inflight:{user_id}
provider_inflight:{provider}
model_inflight:{provider}:{model}
worker_inflight:{worker_id}
例如:
用户同时最多 2 个任务
租户同时最多 30 个任务
Runway 同时最多 100 个任务
Veo 同时最多 50 个任务
单个 Worker 同时最多 10 个任务
8.7.2 获取槽位流程
Scheduler 拉取候选任务
│
▼
调用 Redis Lua acquire_slots
│
├── 检查 user_inflight < user_limit
├── 检查 tenant_inflight < tenant_limit
├── 检查 provider_inflight < provider_limit
├── 检查 model_inflight < model_limit
│
├── 全部满足:
│ - 所有计数 +1
│ - 写入 task lease
│ - 返回 acquired
│
└── 任一不满足:
- 不修改计数
- 返回 rejected
槽位记录可以长这样:
lease:task:{task_id}
{
"tenant_id": "t1",
"user_id": "u1",
"provider": "veo",
"model": "veo-xxx",
"worker_id": "w1",
"fencing_token": 1024,
"expire_at": 1710000000000
}
8.7.3 为什么需要租约
如果只做计数:
provider_inflight = provider_inflight + 1
然后 Worker 崩溃,就会出现计数泄漏。
所以必须有租约:
拿槽位时写入 lease
lease 有 TTL
Worker 定期 heartbeat 续租
任务结束时 release
后台 reconciliation 定期对账
完整生命周期:
ACQUIRE
│
├── user_inflight +1
├── tenant_inflight +1
├── provider_inflight +1
├── model_inflight +1
└── lease:task:{task_id} 写入 TTL
HEARTBEAT
│
└── 刷新 lease TTL
RELEASE
│
├── user_inflight -1
├── tenant_inflight -1
├── provider_inflight -1
├── model_inflight -1
└── 删除 lease
RECONCILE
│
├── 扫描 PostgreSQL 中真实 RUNNING / PROVIDER_SUBMITTED 任务
├── 对比 Redis 计数
├── 修复泄漏
└── 输出告警
8.7.4 并发槽位不是正确性边界
即使 Redis 槽位泄漏或丢失,系统也不能产生错误终态。
所以数据库表里要有:
task_id
status
attempt_no
fencing_token
worker_id
provider_task_id
version
updated_at
Worker 更新任务时必须带条件:
UPDATE generation_tasks
SET status = 'PROVIDER_SUBMITTED',
provider_task_id = $providerTaskID,
version = version + 1
WHERE id = $taskID
AND attempt_no = $attemptNo
AND fencing_token = $fencingToken
AND status = 'SCHEDULING';
这样即使旧 Worker 失去租约后又恢复,也不能覆盖新 Worker 的状态。
8.8 Fencing Token:解决锁过期后的旧持有者写入
Redis 分布式锁常见问题是:
Worker A 拿到锁
Worker A 卡顿超过 TTL
锁过期
Worker B 拿到锁
Worker A 恢复后继续写数据库
如果没有 fencing token,A 和 B 都可能写成功。
更安全的做法是:
1. 每次获取租约时,从 Redis INCR 得到递增 token
2. Worker 后续写数据库、写对象存储元数据时都携带 token
3. PostgreSQL 只接受当前 token 或更高 token 的写入
4. 旧 token 的写入直接失败
示意:
Worker A acquire lease -> fencing_token = 100
Worker A 卡顿
Worker B acquire lease -> fencing_token = 101
DB 当前 token = 101
Worker A 恢复,尝试用 token=100 写完成
DB 拒绝
数据库层可以这样设计:
UPDATE generation_tasks
SET status = $newStatus,
version = version + 1
WHERE id = $taskID
AND fencing_token = $fencingToken
AND version = $expectedVersion;
或者:
UPDATE generation_tasks
SET status = $newStatus,
fencing_token = $newToken
WHERE id = $taskID
AND fencing_token <= $newToken;
面试时要强调:
Redis 锁只能降低重复执行概率,不能单独保证业务正确性。真正防止旧 Worker 覆盖新状态,要靠 fencing token、数据库条件更新和任务状态机。
Redis 官方分布式锁文档也强调了锁需要满足互斥、死锁释放和容错等性质,并说明简单基于异步复制的故障切换方案并不足以保证严格互斥。(Redis)
8.9 短期任务状态缓存
前端生成视频时,用户最关心的是:
排队第几位?
是否开始生成?
当前进度多少?
是否失败?
输出视频是否可播放?
如果前端每 1 秒轮询一次 PostgreSQL,在高并发下会给数据库造成巨大压力。
所以可以用 Redis 缓存任务快照。
8.9.1 缓存结构
task:snapshot:{task_id}
value:
{
"task_id": "task_123",
"status": "RUNNING",
"progress": 45,
"stage": "provider_generating",
"provider": "veo",
"model": "veo-xxx",
"queue_position": 12,
"preview_asset_id": null,
"output_asset_id": null,
"error_code": null,
"version": 18,
"updated_at": 1710000000000
}
TTL 可以按状态区分:
| 状态 | TTL |
|---|---|
| QUEUED | 30 秒 |
| RUNNING | 10 秒 |
| PROVIDER_SUBMITTED | 10 秒 |
| POST_PROCESSING | 10 秒 |
| SUCCEEDED | 1 小时 |
| FAILED | 30 分钟 |
| CANCELED | 30 分钟 |
注意:
Redis 里的 progress 只是展示用,不是最终事实。
用户刷新页面时,后端可以:
优先读 Redis
Redis miss 再读 PostgreSQL
重新写入 Redis
8.9.2 防止旧状态覆盖新状态
状态缓存也要考虑乱序。
例如:
事件 A:progress = 80,version = 10
事件 B:progress = 60,version = 9
如果 B 后到,不能覆盖 A。
可以用 Lua 做版本比较:
-- KEYS[1] = task snapshot key
-- ARGV[1] = new_version
-- ARGV[2] = new_payload
-- ARGV[3] = ttl_sec
local current = redis.call("GET", KEYS[1])
if current then
local current_version = tonumber(cjson.decode(current)["version"])
local new_version = tonumber(ARGV[1])
if current_version ~= nil and current_version > new_version then
return 0
end
end
redis.call("SETEX", KEYS[1], tonumber(ARGV[3]), ARGV[2])
return 1
更简单的方式是:
状态缓存允许短暂不准
客户端定期重新拉 PostgreSQL 快照
服务端以数据库版本为准
面试时可以说:
Redis 状态缓存是弱一致的,前端展示可以接受几秒级延迟;但是任务终态、扣费、资产绑定全部以 PostgreSQL 为准。
8.10 Pub/Sub 与 Streams:不要混用语义
8.10.1 Pub/Sub 适合实时刷新,不适合可靠事件
Redis Pub/Sub 适合做:
任务状态变化后通知 WebSocket 节点
SSE 节点之间广播在线用户刷新
后台管理页面实时刷新
示例:
TaskStateService 更新任务状态
│
├── 写 PostgreSQL
├── 更新 Redis task snapshot
└── PUBLISH task.changed.{tenant_id}
│
▼
WebSocket / SSE Node
│
└── 推送给在线用户
但是 Pub/Sub 不适合做可靠业务事件。
原因是 Redis Pub/Sub 是 at-most-once 语义。订阅者断线、网络异常或处理失败时,消息不会自动重发。Redis 官方文档明确说明 Pub/Sub 消息如果未能被订阅者处理,就会永久丢失。(Redis)
所以正确设计是:
Pub/Sub 只负责“在线刷新”
断线重连后,客户端必须重新查询当前任务快照
前端逻辑:
WebSocket 收到 task_changed
│
└── 调用 GET /tasks/{task_id} 拉取最新快照
WebSocket 断线重连
│
└── 调用 GET /projects/{project_id}/tasks 拉取当前状态
不要让前端只依赖推送事件增量更新。
8.10.2 Streams 适合可重放短期事件
Redis Streams 是 append-only log,并支持 consumer group 这类复杂消费模式。Redis 官方文档也把 Streams 描述为可以记录并实时分发事件的数据结构。(Redis)
可以用于:
短期通知事件
轻量级内部事件流
需要 pending / ack / replay 的场景
例如:
stream:task-events:{tenant_id}
消息:
{
"task_id": "task_123",
"event_type": "TASK_PROGRESS_CHANGED",
"version": 18,
"occurred_at": 1710000000000
}
但是在本项目里,如果已经使用 RocketMQ,那么建议边界是:
| 场景 | 推荐 |
|---|---|
| 生成任务调度 | RocketMQ |
| 回源转码任务 | RocketMQ |
| 计费结算 | RocketMQ + PostgreSQL |
| 可靠补偿 | RocketMQ / PostgreSQL Outbox |
| 在线状态刷新 | Redis Pub/Sub |
| 短期可重放通知 | Redis Streams |
一句话:
RocketMQ 管可靠异步,Redis Pub/Sub 管实时刷新,Redis Streams 可做短期可重放事件,但不要替代核心消息队列。
8.11 Redis Cluster 下的 Hash Slot 问题
如果用 Redis Cluster,要特别注意:
Lua 脚本里操作多个 key 时,这些 key 必须落在同一个 hash slot。
Redis Cluster 官方文档说明,hash tags 可以让多个 key 映射到同一个 hash slot,从而支持多 key 操作。(Redis)
8.11.1 错误设计
tenant_inflight:t1
provider_inflight:veo
model_inflight:veo:xxx
这几个 key 很可能不在同一个 slot。
Lua 同时操作时可能报:
CROSSSLOT Keys in request don't hash to the same slot
8.11.2 使用 hash tag
可以这样设计:
slot:{veo}:provider
slot:{veo}:model:veo-xxx
slot:{veo}:tenant:t1
{veo} 里面的内容会作为 hash tag,使这些 key 落到同一个 slot。
但是这也有问题:
所有 veo 相关并发控制都集中到一个 slot
可能形成热点
所以要权衡:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 全部 key 同 slot | Lua 原子性强 | 容易形成热点 |
| 按 tenant 分 slot | 分散压力 | 难做 provider 全局原子限制 |
| 按 provider 分 slot | 适合 provider 并发限制 | 热门 provider 可能热 key |
| 两阶段获取 | 分散压力 | 需要补偿释放 |
| 独立 Quota Service | 逻辑清晰 | 多一个服务组件 |
8.11.3 生产建议
对于 AI 视频平台,可以这样分层:
用户 / 租户限流:
按 tenant hash tag 分散
provider / model 并发槽位:
按 provider hash tag 聚合
全局成本控制:
PostgreSQL / 配额服务兜底
高价值模型:
使用更保守的 fail-closed 策略
如果需要同时检查:
tenant slot
provider slot
model slot
但这些 key 不在一个 slot,可以采用:
1. 先获取 provider/model 槽位
2. 再获取 tenant/user 槽位
3. 如果第二步失败,释放第一步
4. 所有状态更新必须有 task_id 和 fencing_token
5. 后台 reconciliation 修正泄漏
这不是严格数学意义上的全局原子,但在工程上可控。
如果业务要求非常严格,就应该把并发控制抽象成一个独立的调度配额服务,而不是把复杂一致性全部塞进 Redis Lua。
8.12 缓存穿透、击穿、雪崩和热 Key
8.12.1 缓存穿透
缓存穿透是指:
大量请求访问不存在的数据
Redis miss
PostgreSQL 也 miss
每次都打到数据库
例如攻击者不断请求:
GET /tasks/random_id
解决方案:
-
鉴权先行 用户只能访问自己项目下的 task。
-
负缓存 对不存在的 task 缓存短 TTL:
task:notfound:{task_id} = 1 TTL = 30s -
请求格式校验 非法 UUID、非法 ID 格式直接拒绝。
-
布隆过滤器 超大规模场景可以用 Bloom Filter 判断资产 ID 是否可能存在。
8.12.2 缓存击穿
缓存击穿是指:
某个热点 key 过期
大量请求同时打到 PostgreSQL
例如:
热门模板
热门项目
首页推荐模型配置
解决方案:
-
singleflight 同一进程内相同 key 只允许一个请求回源。
-
分布式短锁 只有一个节点回源,其他节点短暂等待或返回旧值。
-
逻辑过期 Redis 中保留旧值,后台异步刷新。
-
stale-while-revalidate 允许短时间返回旧数据,同时触发刷新。
8.12.3 缓存雪崩
缓存雪崩是指:
大量 key 同时过期
瞬间打爆数据库
解决方案:
TTL 加随机抖动
热点 key 预热
分批刷新
核心配置永不过期但带版本号
缓存服务降级
例如:
SETEX model:capability:veo 3600 + random(0, 300)
8.12.4 热 Key
AI 视频平台常见热 key:
provider:health:veo
model:capability:veo
template:popular
task:snapshot:hot_task
tenant:quota:large_customer
解决方案:
- 本地进程缓存。
- Redis TTL 加抖动。
- 拆分 key。
- 热点配置走配置中心。
- 读多写少数据可以多级缓存。
- 大租户单独隔离限流维度。
不要把所有用户的进度都写到一个大 Hash 里,例如:
task_snapshots_all
这会变成大 key,迁移、删除、持久化和网络传输都会出问题。
8.13 分布式锁:只能用于短期互斥,不能承担业务正确性
Redis 锁适合这些场景:
防止多个节点同时刷新同一个热点缓存
防止多个节点同时跑同一个低频补偿任务
防止多个节点同时生成同一份缩略图
防止重复执行轻量级定时任务
不适合这些场景:
唯一扣费
唯一创建任务
唯一确认任务完成
唯一绑定最终资产
唯一结算账单
正确加锁方式:
SET lock:asset:{asset_id}:thumbnail random_token NX PX 30000
释放锁不能直接 DEL,必须校验 token:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
否则可能误删别人的锁:
A 锁过期
B 拿到新锁
A 执行 DEL
B 的锁被误删
面试时可以这样说:
Redis 锁用于效率优化和减少重复工作;凡是涉及钱、任务终态、资产归属的地方,必须用 PostgreSQL 唯一约束、状态机版本和 fencing token 做最终保护。
8.14 Redis 故障时如何降级
这是面试高频追问:
Redis 挂了怎么办?
不能只说“Redis 高可用、主从、哨兵、Cluster”。
你要讲清楚不同 Redis 用途的降级策略。
| Redis 用途 | Redis 故障时策略 |
|---|---|
| 普通缓存 | 回源 PostgreSQL,降低 TTL,必要时限流 |
| 任务状态缓存 | 直接查 PostgreSQL,前端降低刷新频率 |
| Pub/Sub 通知 | WebSocket 降级为轮询 |
| 用户入口限流 | 本地内存限流临时兜底 |
| 供应商并发槽位 | fail-closed,不再提交新任务 |
| 短期幂等缓存 | 回退 PostgreSQL 幂等表 |
| 分布式锁 | 跳过非关键任务,关键任务走数据库条件更新 |
| 熔断器状态 | 使用本地 breaker 快照 |
最关键的是 provider/model 并发槽位。
如果 Redis 不可用,不能继续无限提交第三方任务。正确策略是:
已有运行任务继续等待结果
新任务仍可进入 QUEUED
调度器暂停向 provider 提交新任务
用户看到“排队中 / 系统繁忙”
Redis 恢复后继续调度
也就是:
入口可以有限 fail-open,供应商提交必须 fail-closed。
原因很简单:
入口多放几个请求,最多排队;
供应商并发失控,可能导致封禁、成本爆炸、全局雪崩。
8.15 Redis 部署隔离:不要所有用途混一个实例
生产环境建议至少逻辑隔离:
redis-cache
redis-control
redis-realtime
8.15.1 redis-cache
用于:
任务快照
项目元数据
模型能力矩阵
模板缓存
特点:
允许丢
允许过期
允许 LRU 淘汰
8.15.2 redis-control
用于:
限流
并发槽位
租约
fencing token
熔断状态
特点:
不应该被随便淘汰
需要更严格监控
建议 noeviction
内存要预留
8.15.3 redis-realtime
用于:
Pub/Sub
Streams
在线连接路由
SSE/WebSocket 节点广播
特点:
关注连接数
关注消息吞吐
关注 pending 和消费延迟
不要把大缓存、Pub/Sub、高频限流、租约全部混在一个 Redis 里。
否则一个热点模板缓存或大 key 问题,就可能影响任务调度。
8.16 和 Go 服务的结合方式
Go 服务里建议封装 Redis 为领域组件,而不是到处直接写 Redis 命令。
错误方式:
redisClient.Get(ctx, key)
redisClient.Set(ctx, key, value, ttl)
redisClient.Incr(ctx, key)
这些命令散落在业务代码中,后期很难统一处理:
超时
降级
指标
重试
序列化
key 规范
Lua 脚本版本
更好的方式:
type RateLimiter interface {
Allow(ctx context.Context, subject string, rule RateLimitRule) (RateLimitResult, error)
}
type SlotManager interface {
Acquire(ctx context.Context, req AcquireSlotRequest) (Lease, error)
Heartbeat(ctx context.Context, lease Lease) error
Release(ctx context.Context, lease Lease) error
}
type TaskSnapshotCache interface {
Get(ctx context.Context, taskID string) (*TaskSnapshot, error)
SetIfNewer(ctx context.Context, snapshot TaskSnapshot) error
}
type RealtimeBus interface {
PublishTaskChanged(ctx context.Context, event TaskChangedEvent) error
}
业务层只关心语义:
限流是否通过
槽位是否获取成功
任务快照是否更新
事件是否发布
而不是关心 Redis 的 key 和命令细节。
8.17 可观测性指标
Redis 相关指标不能只看 CPU 和内存。
AI 视频平台要重点看这些:
限流指标
rate_limit_allowed_total
rate_limit_rejected_total
rate_limit_retry_after_ms
rate_limit_lua_latency_ms
按维度打标签:
tenant_id
endpoint
model
provider
rule_name
并发槽位指标
slot_acquire_success_total
slot_acquire_rejected_total
slot_release_total
slot_heartbeat_failed_total
slot_lease_expired_total
slot_reconcile_diff
关键看:
Redis inflight count
PostgreSQL running count
两者差值
缓存指标
cache_hit_rate
cache_miss_rate
cache_set_total
cache_evicted_keys
hot_key_detected_total
Redis 实例指标
used_memory
mem_fragmentation_ratio
connected_clients
blocked_clients
instantaneous_ops_per_sec
cmdstat_evalsha
expired_keys
evicted_keys
keyspace_hits
keyspace_misses
rejected_connections
实时通知指标
pubsub_publish_total
websocket_push_total
sse_push_total
client_reconnect_total
task_snapshot_refetch_total
如果使用 Streams,还要看:
stream_length
consumer_group_lag
pending_entries
oldest_pending_age
面试时可以说:
我们不会只监控 Redis 是否存活,还会监控 Redis 计数和 PostgreSQL 事实之间的偏差,因为这能直接发现槽位泄漏、重复释放和调度异常。
8.18 典型故障场景推演
场景一:Worker 拿到槽位后崩溃
问题:
Redis 计数 +1
Worker 崩溃
任务没有释放槽位
解决:
lease TTL 自动过期
后台 reconciliation 扫描 PostgreSQL
发现任务长时间无 heartbeat
任务重新进入可调度状态
修正 Redis inflight 计数
数据库保护:
attempt_no
fencing_token
version
防止旧 Worker 恢复后乱写。
场景二:Redis Pub/Sub 消息丢失
问题:
任务已经成功
但是用户前端没有收到 WebSocket 推送
解决:
任务成功状态已经写入 PostgreSQL
Redis task snapshot 已更新
前端定期兜底轮询
WebSocket 重连后主动拉取当前快照
结论:
Pub/Sub 丢失不会影响任务正确性
只影响实时刷新体验
场景三:Redis Cluster CROSSSLOT
问题:
Lua 同时操作 tenant key 和 provider key
但是 key 不在同一个 hash slot
线上报 CROSSSLOT
解决:
重新设计 key hash tag
或者拆成两阶段获取
或者把并发控制收敛到独立 quota service
面试加分点:
Redis Cluster 下不是所有 Lua 多 key 脚本都能直接跑,多 key 原子操作必须提前设计 hash slot。
场景四:热点任务状态 key 被打爆
问题:
用户打开项目页面
前端每秒请求多个任务状态
同一个项目下大量协作者同时查看
解决:
WebSocket/SSE 推送替代高频轮询
任务列表接口批量查询
本地进程缓存
Redis pipeline / MGET
终态任务降低刷新频率
场景五:Redis 完全不可用
处理策略:
API 层:
本地限流兜底
降低高频接口刷新频率
Generation Service:
仍可写 PostgreSQL 创建 QUEUED 任务
但高风险场景可限制新建任务
Scheduler:
暂停向 provider 提交新任务
已提交任务继续轮询或等待回调
Frontend:
显示排队中
降低轮询频率
关键原则:
不能因为 Redis 不可用就丢任务
不能因为 Redis 不可用就无限打供应商
不能因为 Redis 不可用就错误扣费
8.19 面试话术总结
面试官问:
你们 Redis 主要用来做什么?
可以回答:
在这个 AI 视频平台里,Redis 主要作为速度层和控制层。我们不会把任务终态、余额、资产元数据这类事实源放到 Redis,而是放在 PostgreSQL。Redis 负责用户和租户限流、provider/model 并发槽位、worker 租约、任务短期状态缓存、SSE/WebSocket 的实时通知、熔断状态和短期幂等缓存。这样可以减轻数据库压力,也能在调度层保护第三方模型配额。
面试官问:
Redis 怎么做限流?
可以回答:
普通生成入口使用 Token Bucket,因为它允许合理突发,同时控制长期平均速率;高成本模型和风控场景使用 Sliding Window Log 或 Sliding Window Counter。实现上使用 Redis Lua,把读取、补充 token、扣减、设置 TTL 合并成原子操作。限流只是准入层,真正的余额预占和任务创建仍然在 PostgreSQL 事务里完成。
面试官问:
如何控制第三方模型并发?
可以回答:
调度器在提交 provider 前必须先从 Redis 获取并发槽位。槽位按 user、tenant、provider、model 多维度控制。获取槽位时用 Lua 原子检查多个计数,并写入带 TTL 的 lease。Worker 定期 heartbeat,任务终态释放槽位。为了防止 Worker 崩溃导致计数泄漏,我们有 reconciliation 任务定期用 PostgreSQL 中真实运行任务重建或修正 Redis 计数。
面试官问:
Redis 锁能保证任务不会重复执行吗?
可以回答:
不能这么说。Redis 锁只能减少重复执行概率,不能作为业务正确性的唯一保证。锁过期、网络分区、主从切换都可能导致旧持有者继续执行。我们的做法是 Redis lease + fencing token + PostgreSQL 条件更新。每次获取租约都会生成递增 token,Worker 后续更新任务状态必须携带 token、attempt_no 和 version,数据库只接受当前合法尝试的写入。
面试官问:
Redis Pub/Sub 消息丢了怎么办?
可以回答:
Pub/Sub 只用于在线用户的实时刷新,不承载可靠业务事件。任务状态先写 PostgreSQL,再更新 Redis snapshot,最后 publish 通知。客户端收到通知后会重新拉取任务快照;如果 WebSocket 断线或 Pub/Sub 丢消息,重连后也会查询 PostgreSQL/Redis 当前状态。所以消息丢失只影响刷新实时性,不影响任务正确性。
面试官问:
Redis 挂了怎么办?
可以回答:
要按用途降级。普通缓存挂了就回源 PostgreSQL;Pub/Sub 挂了就降级轮询;短期幂等缓存挂了就回退 PostgreSQL 幂等表;入口限流可以用本地限流临时兜底。但 provider/model 并发槽位不可用时,调度器必须 fail-closed,暂停提交新任务,避免第三方配额失控和成本爆炸。已有任务继续等待回调或轮询,Redis 恢复后再继续调度。
8.20 本章核心结论
本章最重要的结论是:
Redis 是 AI 视频平台的高性能控制面组件,但不是核心事实源。
具体来说:
-
限流控制请求频率 Token Bucket 适合普通入口,Sliding Window 适合精确风控。
-
配额控制商业成本 Redis 可以缓存额度快照,但最终扣费必须走 PostgreSQL ledger。
-
并发槽位控制运行中任务数 provider/model/user/tenant 都要有 inflight 限制。
-
租约解决 Worker 崩溃问题 lease TTL、heartbeat、release、reconcile 必须配套出现。
-
Fencing Token 防止旧 Worker 写入 Redis 锁不是正确性边界,数据库条件更新才是。
-
任务状态缓存只能弱一致 Redis snapshot 用于展示,PostgreSQL 终态才可信。
-
Pub/Sub 只做实时刷新 它不是可靠消息队列,断线后必须重新查询快照。
-
Redis Cluster 要提前设计 hash slot 多 key Lua 脚本必须考虑 hash tag 和热点问题。
-
Redis 故障要分场景降级 缓存可 fail-open,供应商提交要 fail-closed。
-
资深表述不是“我们用了 Redis” 而是能讲清楚 Redis 的边界、失效模式、降级策略和一致性兜底。