Redis Cluster 在 AI 平台:会话缓存、结果缓存和队列要分开实例
基础设施不需要漂亮话。一个 Redis 实例承载所有缓存和队列的场景,在生产环境中就是一颗定时炸弹。
一、为什么不能用一个 Redis 实例扛一切
刚上线的 AI 平台项目常见一种"朴素架构":启动一个 Redis Cluster,会话状态存这里、推理结果缓存存这里、离线推理任务的排队也走这里。在日请求量 10 万量级时一切正常。当请求量突破 100 万/天,问题就逐一浮现。
这三种用途对 Redis 的资源需求完全不同:
- 会话缓存(Session Cache):数据量小(KB 级),读写延迟要求极高(<1ms),数据存活时间短(5-30 分钟),需要高可用但允许少量丢失。
- 推理结果缓存(Result Cache):数据量中到大(KB-MB 级),读多写少,命中率直接影响 GPU 成本,数据量可能达到几十 GB,需要 LRU/LFU 淘汰策略。
- 任务队列(Task Queue):基于 List/Stream 的 FIFO 队列,写入频繁,消息不能丢失,消费者故障时需要消息可重试。
这三种负载在一个实例上会产生相互干扰。场景一:结果缓存的内存淘汰导致 Redis 响应延迟升高,会话查询的 P99 从 2ms 飙到 50ms,前端页面出现卡顿。场景二:任务队列堆积大量消息,内存被 List 占满,缓存开始大量淘汰,结果缓存命中率从 85% 跌到 30%,同一批请求全部穿透到 GPU 推理,引发雪崩。
graph TB subgraph 推荐架构 Redis1["Redis Cluster A<br/>会话缓存<br/>内存: 2GB<br/>淘汰策略: volatile-ttl"] Redis2["Redis Cluster B<br/>结果缓存<br/>内存: 32GB<br/>淘汰策略: allkeys-lru"] Redis3["Redis Cluster C<br/>任务队列<br/>内存: 8GB<br/>淘汰策略: noeviction"] end App[AI 应用服务] --> Redis1 App --> Redis2 App --> Redis3 Redis1 -.->|故障隔离| S1[会话不丢失] Redis2 -.->|故障隔离| S2[缓存可重建] Redis3 -.->|故障隔离| S3[队列可重放]二、会话缓存实例:低延迟与高可用
会话缓存的核心指标是P99 延迟,而不是吞吐量。对于一个单个会话对象 2-5KB 的场景,Redis 的单线程模型处理 GET/SET 操作的开销微乎其微。真正影响 P99 延迟的是 Redis 内部的淘汰操作(eviction)和持久化策略。
对于会话缓存实例的配置建议:
淘汰策略:使用volatile-ttl而非allkeys-lru。会话数据都有过期时间,让 Redis 按 TTL 自然淘汰即可,避免 LRU 淘汰引发的额外 CPU 开销。设置maxmemory时必须留出 20% 的缓冲空间,因为 Redis 在内存接近上限时的淘汰操作是异步的,可能导致短暂的阻塞。
持久化:使用 RDB + AOF 混合持久化,但 AOF 同步策略设置为everysec而非always。会话数据允许丢失 1 秒的数据,换来延迟的稳定。哨兵模式的切换时间控制在 10 秒以内基本不会影响用户体验。
// 会话缓存的连接池配置 sessionClient := redis.NewClusterClient(&redis.ClusterOptions{ Addrs: []string{"node1:6379", "node2:6379", "node3:6379"}, PoolSize: 20, // 连接池大小,会话场景不需要很大 MinIdleConns: 5, ReadTimeout: 100 * time.Millisecond, // 读超时严格限制 WriteTimeout: 100 * time.Millisecond, MaxRetries: 1, // 重试一次即可,避免级联延迟 })三、推理结果缓存实例:内存与命中率的平衡
推理结果缓存的数据量通常很大。以一个图像生成模型为例,单次推理结果(512x512 图像 + 元数据)占用约 500KB。如果缓存最近 10 万次推理结果,总内存需求约 50GB。加上哈希表开销(约 1.2 倍),实际需要 60GB 内存。
在这种场景下,allkeys-lru 淘汰策略是唯一合理的选择。但默认的 LRU 实现(近似 LRU,采样 5 个 key)在大数据量下有精度问题,可以调高maxmemory-samples到 10 以提升淘汰精度。
Key 的设计直接影响内存效率。推荐使用以下格式:
infer:{model_name}:{input_hash}:{params_hash}对输入内容计算 SHA256 哈希,而非将原始文本作为 Key。一个推理结果缓存的内存分布:
| 组件 | 单条大小 | 100 万条总量 |
|---|---|---|
| Key 字符串 | 80 bytes | 80 MB |
| redisObject 开销 | 16 bytes | 16 MB |
| Value(结果数据) | 500 KB | 500 GB |
| dictEntry 开销 | 24 bytes | 24 MB |
将结果数据压缩后再存储可以显著降低内存。对于文本类结果,使用 Zstandard 压缩比例可达 3-5 倍;对于图像类结果,压缩比例约 1.2-1.5 倍。
四、任务队列实例:消息可靠性与流控
A I 平台中的异步推理任务是队列的主要消费者。一个离线推理任务的生命周期是:用户提交 → 排队 → 分配 Worker → 推理执行 → 结果写入。
使用 Redis Stream 比 List 更适合作为任务队列,原因是:
- 消费者组:支持多个 Worker 组成消费组,自动负载均衡。
- 消息确认:通过
XACK实现消息的显式确认,未确认的消息可以被XPENDING检出并重新投递。 - 消息回溯:Stream 中的消息不会在消费后立即删除,可以按时间范围回溯。
// 任务生产者 func EnqueueTask(ctx context.Context, task *InferTask) error { return queueClient.XAdd(ctx, &redis.XAddArgs{ Stream: "infer:queue:v1", MaxLen: 100000, // 限制队列长度,防止 OOM Values: map[string]interface{}{ "model": task.Model, "input": task.Input, "priority": task.Priority, }, }).Err() } // 任务消费者,带重试机制 func WorkerLoop(ctx context.Context, group, consumer string) { for { msgs, _ := queueClient.XReadGroup(ctx, &redis.XReadGroupArgs{ Group: group, Consumer: consumer, Streams: []string{"infer:queue:v1", ">"}, Count: 1, Block: 5 * time.Second, }).Result() for _, msg := range msgs[0].Messages { processMessage(ctx, msg) queueClient.XAck(ctx, "infer:queue:v1", group, msg.ID) } } }关键配置:队列实例的maxmemory-policy必须设置为noeviction。在任何情况下都不允许队列中的消息被淘汰,消息丢失意味着推理任务的丢失。
五、总结:三个实例是基线,不是过度设计
将会话缓存、结果缓存和任务队列分开部署到不同的 Redis Cluster,在很多人看来是"过度设计"——"我们日均请求量才 100 万,用一个 Redis 够了"。但问题不在于请求量多大,而在于故障爆炸半径。
一个 Redis 实例宕机,三种功能同时不可用。三个独立实例,任何一个故障都只影响单一功能域,并且各自有独立的恢复策略和优先级。这属于运维上的最小爆炸半径原则,和系统规模无关。
还有一个容易被忽视的成本问题:结果缓存需要大内存(512GB+),本地 NVMe 做缓存的方案(如使用 Redis on Flash)可以将内存成本降低 70%。但这种混合存储方案只适用于结果缓存实例,不适合会话缓存(延迟要求高)和队列实例(写入频繁)。分开实例才能按需选择存储方案,实现成本最优。