最近在做一个漫剧系统,核心功能就是让用户输入一段描述,系统调用三方的 AI 接口生成图片(文生图)或视频(文生视频)。
功能上线后,效果确实挺炫酷,但随之而来的问题也很明显。
目前的 AIGC 接口都有一个通病:慢。
文生图:稍微高质量一点的图,跑完大概需要 30s - 60s。
文生视频:这个更夸张,生成几秒钟的视频,服务端可能要跑 5 分钟。
由于这些接口全是异步的(先提交任务拿 TaskID,再轮询或等回调),前端用户体验是:点击“生成” -> 弹窗提示“任务已提交” -> 用户继续点点点。
这就导致了个大问题:有些心急的用户,或者想“薅羊毛”的用户,会由于没有限制,一口气提交十几个视频生成任务。
这带来的后果很严重:
烧钱:三方接口很多是按次或按时长收费的。
堵塞:三方接口通常也有并发限制(比如 QPS 或者同时处理的任务数),用户 A 占满了通道,用户 B 的任务就只能排队,甚至直接被三方拒单。
系统压力:虽然是异步,但我们要维护大量的轮询线程或者回调处理逻辑。
所以,给用户加一个**“并发数限制”迫在眉睫。注意,这里不是限 QPS(每秒请求数),而是限制“当前正在进行中的任务数”**。
这就好比去餐厅吃饭。
单机锁(Synchronized/ReentrantLock):那是锁厕所门的,服务重启或者部署了多台服务器(集群),这锁就失效了。
数据库(MySQL):每次提交任务去查数据库 count?本来生成任务就慢,还给数据库加压力,不划算。
Redis:天选之子。读写快,支持原子操作,而且天然支持分布式。
我的需求很简单:
文生图:普通用户同时只能跑 2 个任务。
文生视频:普通用户同时只能跑 1 个任务(这玩意太慢了)。
这不是简单的令牌桶算法,更像是一个分布式的信号量。
提交前检查(加锁):用户发起请求 -> 检查 Redis 中该用户当前类型的任务数 -> 未超限则 INCR(自增) -> 放行;超限则直接报错。
任务结束(解锁):收到三方回调(成功或失败) -> Redis 中该用户的 key DECR(自减)。
看似简单,其实这里有几个深坑:
死锁问题:如果三方接口崩了,没回调怎么办?或者我的服务重启了,任务状态没更新怎么办?用户的计数器永远减不下来,这个用户就废了。
解法:必须给 Redis Key 设置过期时间。比如视频生成最长 5 分钟,我就设个 10 分钟的 TTL。
原子性:检查数量和增加数量必须是原子的,否则并发一来,限制就穿透了。
解法:使用 Redis 的 INCR 命令,它是原子的。拿到返回值再判断是否超限,如果超限了再 DECR 减回去。
技术栈:Spring Boot + StringRedisTemplate。
为了复用,我写了一个简单的 Helper。
@Component
public class UserTaskLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
// 定义前缀
private static final String KEY_PREFIX = "limit:task:";
/**
* 尝试获取执行权限
* @param userId 用户ID
* @param taskType 任务类型(image/video)
* @param maxConcurrent 最大并发数
* @param expireTimeSeconds 兜底过期时间(防止死锁)
* @return true=允许执行, false=被限流
*/
public boolean tryAcquire(Long userId, String taskType, int maxConcurrent, long expireTimeSeconds) {
String key = KEY_PREFIX + userId + ":" + taskType;
// 1. 原子自增
Long currentRunning = redisTemplate.opsForValue().increment(key);
// 2. 如果是第一次设置(值为1),需要设置过期时间,防止死锁
// 注意:这里有个小概率边界问题,但对于非金融业务可接受。严谨做法可以用Lua脚本。
if (currentRunning != null && currentRunning == 1) {
redisTemplate.expire(key, expireTimeSeconds, TimeUnit.SECONDS);
}
// 3. 判断是否超限
if (currentRunning != null && currentRunning > maxConcurrent) {
// 4. 超限了,要把刚才加的1减回去,否则计数器就不准了
redisTemplate.opsForValue().decrement(key);
return false;
}
return true;
}
/**
* 任务完成(无论成功失败),释放名额
*/
public void release(Long userId, String taskType) {
String key = KEY_PREFIX + userId + ":" + taskType;
// 只有大于0才减,防止出现负数
String value = redisTemplate.opsForValue().get(key);
if (value != null && Integer.parseInt(value) > 0) {
redisTemplate.opsForValue().decrement(key);
}
}
}
提交任务阶段:
@Service
public class ImageGenService {
@Autowired
private UserTaskLimiter taskLimiter;
// 假设文生图限制同时跑2个,最长超时60秒,我们给TTL设为 5分钟(留足余量)
private static final int MAX_IMG_TASKS = 2;
private static final long TTL_SECONDS = 300;
public void submitTask(Long userId, TaskReq req) {
// 1. 检查并发
boolean allowed = taskLimiter.tryAcquire(userId, "image", MAX_IMG_TASKS, TTL_SECONDS);
if (!allowed) {
throw new BusinessException("您当前生成的任务过多,请稍后重试!");
}
try {
// 2. 调用三方接口
String thirdPartyTaskId = aiProvider.generateImage(req);
// 3. 保存到数据库,状态为 PROCESSING
saveTaskToDb(userId, thirdPartyTaskId, ...);
} catch (Exception e) {
// 重点!如果调用三方直接报错(比如网络断了),必须立刻释放锁
taskLimiter.release(userId, "image");
throw e;
}
}
}
回调处理阶段:
当三方回调我们的接口,或者我们轮询查到了结果:
@Service
public class CallbackService {
@Autowired
private UserTaskLimiter taskLimiter;
public void handleCallback(String thirdPartyTaskId, String status) {
// 1. 根据第三方ID查库,找到是哪个用户的任务
TaskInfo task = taskRepo.findByThirdId(thirdPartyTaskId);
if (task == null) return;
// 2. 更新数据库状态
updateTaskStatus(task, status);
// 3. 重点:释放 Redis 计数器
// 只有当状态是从 "进行中" 变为 "完成/失败" 时才释放
if (isFinalStatus(status)) {
taskLimiter.release(task.getUserId(), "image"); // 类型要对应
}
}
}
这套方案上线后,基本解决了用户疯狂提交任务导致通道堵塞的问题。但在实际运行中,还有几个细节值得注意:
TTL 的设置技巧: 过期时间(TTL)一定要大于三方接口的最大超时时间。比如文生视频要 5 分钟,你的 TTL 最好设 10 分钟甚至更久。因为如果 TTL 设短了,Redis 里的 key 自动失效(归零),用户又能提交任务了,但实际上之前的任务还在跑,这就突破了限制。
应用重启导致计数归零? Redis 是独立存储的,应用重启不会影响 Redis 里的数据。但如果Redis 重启了怎么办? 对于这种非金融级业务,Redis 数据丢了就丢了,大不了用户能多提几个任务,影响不大。如果非要严谨,可以使用 Redis 的 AOF 持久化。
Lua 脚本优化: 上面的 Java 代码中,increment 和 expire 是分两步执行的。极端情况下(比如 increment 完服务挂了),这个 key 就没有过期时间了,会变成永久锁(直到 Redis 重启或人工干预)。 解决方案:把 INCR + EXPIRE 还有判断逻辑写成一个 Lua 脚本,保证原子性。但说实话,对于咱们这种漫剧业务,概率极低,Java 代码逻辑更易读,现在的实现已经足够用了。
其实做系统设计,不一定非要上多么高大上的架构。在这个场景下,利用 Redis 简单的计数器功能,配合 TTL 兜底策略,就以最小的成本解决了 AIGC 接口调用慢带来的并发堆积问题。
既保护了钱包,也保护了系统的稳定性,搞定收工。
Lua 脚本补充:如果你的面试官或者 Team Leader 比较在意代码严谨性,建议在博文中补充一段 Lua 脚本的实现,这样看起来更 Pro。
Key 的设计:建议加上 M:V (Manga:Video) 这种业务前缀,防止和其他业务冲突。
用户体验:前端最好也做一个配合,如果后端返回“请求过多”,前端就把按钮置灰倒计时,别让用户一直猛点。