返回文章列表

AiVedio:Redis 限流、缓存与并发控制

从 Redis 的速度层与控制层定位出发,拆解 AI 视频平台中的限流、配额、并发槽位、租约、Fencing Token、状态缓存、Pub/Sub、Streams、Cluster、热 Key 和故障降级。

第 08 章:Redis 限流、缓存与并发控制

8.1 本章定位:Redis 不是事实源,而是“速度层”和“控制层”

在 AI 生成视频平台里,Redis 的定位一定要讲清楚:

PostgreSQL 管事实,RocketMQ 管异步传递,Redis 管速度、准入和短期状态。

AI 视频生成任务有几个典型特征:

  1. 请求入口高并发 用户可能频繁点击生成、重新生成、扩写 prompt、上传参考图。

  2. 任务本身长耗时 一个 text-to-video 或 image-to-video 任务可能持续几十秒到数分钟。

  3. 第三方模型有严格配额 供应商通常会限制 QPS、并发数、RPM、每日额度、单组织额度。

  4. 任务成本高 不能让用户无限提交,也不能因为并发竞争导致重复扣费、重复调用模型。

  5. 状态刷新频繁 前端要展示排队中、生成中、进度、失败原因、输出视频地址。

所以 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 视频平台的原因是:

  1. 用户可能短时间连续点击生成。
  2. 系统可以允许小范围突发,避免体验太硬。
  3. 长期要限制平均请求速率。
  4. 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
QUEUED30 秒
RUNNING10 秒
PROVIDER_SUBMITTED10 秒
POST_PROCESSING10 秒
SUCCEEDED1 小时
FAILED30 分钟
CANCELED30 分钟

注意:

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 同 slotLua 原子性强容易形成热点
按 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

解决方案:

  1. 鉴权先行 用户只能访问自己项目下的 task。

  2. 负缓存 对不存在的 task 缓存短 TTL:

    task:notfound:{task_id} = 1
    TTL = 30s
  3. 请求格式校验 非法 UUID、非法 ID 格式直接拒绝。

  4. 布隆过滤器 超大规模场景可以用 Bloom Filter 判断资产 ID 是否可能存在。

8.12.2 缓存击穿

缓存击穿是指:

某个热点 key 过期
大量请求同时打到 PostgreSQL

例如:

热门模板
热门项目
首页推荐模型配置

解决方案:

  1. singleflight 同一进程内相同 key 只允许一个请求回源。

  2. 分布式短锁 只有一个节点回源,其他节点短暂等待或返回旧值。

  3. 逻辑过期 Redis 中保留旧值,后台异步刷新。

  4. 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

解决方案:

  1. 本地进程缓存。
  2. Redis TTL 加抖动。
  3. 拆分 key。
  4. 热点配置走配置中心。
  5. 读多写少数据可以多级缓存。
  6. 大租户单独隔离限流维度。

不要把所有用户的进度都写到一个大 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 视频平台的高性能控制面组件,但不是核心事实源。

具体来说:

  1. 限流控制请求频率 Token Bucket 适合普通入口,Sliding Window 适合精确风控。

  2. 配额控制商业成本 Redis 可以缓存额度快照,但最终扣费必须走 PostgreSQL ledger。

  3. 并发槽位控制运行中任务数 provider/model/user/tenant 都要有 inflight 限制。

  4. 租约解决 Worker 崩溃问题 lease TTL、heartbeat、release、reconcile 必须配套出现。

  5. Fencing Token 防止旧 Worker 写入 Redis 锁不是正确性边界,数据库条件更新才是。

  6. 任务状态缓存只能弱一致 Redis snapshot 用于展示,PostgreSQL 终态才可信。

  7. Pub/Sub 只做实时刷新 它不是可靠消息队列,断线后必须重新查询快照。

  8. Redis Cluster 要提前设计 hash slot 多 key Lua 脚本必须考虑 hash tag 和热点问题。

  9. Redis 故障要分场景降级 缓存可 fail-open,供应商提交要 fail-closed。

  10. 资深表述不是“我们用了 Redis” 而是能讲清楚 Redis 的边界、失效模式、降级策略和一致性兜底。