上线我的 2.0

上线我的 2.0

马图图

岁月变迁何必不悔,尘世喧嚣怎能无愧。

20 文章数
1 评论数

实战:漫剧系统对接AI绘画接口,我是如何用Redis给用户"限流"的?

Matuto
2026-01-07 / 0 评论 / 131 阅读 / 0 点赞

最近在做一个漫剧系统,核心功能就是让用户输入一段描述,系统调用三方的 AI 接口生成图片(文生图)或视频(文生视频)。

功能上线后,效果确实挺炫酷,但随之而来的问题也很明显。

遇到的问题(起因)

目前的 AIGC 接口都有一个通病:

  • 文生图:稍微高质量一点的图,跑完大概需要 30s - 60s。

  • 文生视频:这个更夸张,生成几秒钟的视频,服务端可能要跑 5 分钟。

由于这些接口全是异步的(先提交任务拿 TaskID,再轮询或等回调),前端用户体验是:点击“生成” -> 弹窗提示“任务已提交” -> 用户继续点点点。

这就导致了个大问题:有些心急的用户,或者想“薅羊毛”的用户,会由于没有限制,一口气提交十几个视频生成任务。

这带来的后果很严重:

  1. 烧钱:三方接口很多是按次或按时长收费的。

  2. 堵塞:三方接口通常也有并发限制(比如 QPS 或者同时处理的任务数),用户 A 占满了通道,用户 B 的任务就只能排队,甚至直接被三方拒单。

  3. 系统压力:虽然是异步,但我们要维护大量的轮询线程或者回调处理逻辑。

所以,给用户加一个**“并发数限制”迫在眉睫。注意,这里不是限 QPS(每秒请求数),而是限制“当前正在进行中的任务数”**。

为什么选 Redis?

这就好比去餐厅吃饭。

  • 单机锁(Synchronized/ReentrantLock):那是锁厕所门的,服务重启或者部署了多台服务器(集群),这锁就失效了。

  • 数据库(MySQL):每次提交任务去查数据库 count?本来生成任务就慢,还给数据库加压力,不划算。

  • Redis:天选之子。读写快,支持原子操作,而且天然支持分布式。

方案设计

我的需求很简单:

  • 文生图:普通用户同时只能跑 2 个任务。

  • 文生视频:普通用户同时只能跑 1 个任务(这玩意太慢了)。

核心逻辑:

这不是简单的令牌桶算法,更像是一个分布式的信号量

  1. 提交前检查(加锁):用户发起请求 -> 检查 Redis 中该用户当前类型的任务数 -> 未超限则 INCR(自增) -> 放行;超限则直接报错。

  2. 任务结束(解锁):收到三方回调(成功或失败) -> Redis 中该用户的 key DECR(自减)。

看似简单,其实这里有几个深坑:

  1. 死锁问题:如果三方接口崩了,没回调怎么办?或者我的服务重启了,任务状态没更新怎么办?用户的计数器永远减不下来,这个用户就废了。

    • 解法:必须给 Redis Key 设置过期时间。比如视频生成最长 5 分钟,我就设个 10 分钟的 TTL。

  2. 原子性:检查数量和增加数量必须是原子的,否则并发一来,限制就穿透了。

    • 解法:使用 Redis 的 INCR 命令,它是原子的。拿到返回值再判断是否超限,如果超限了再 DECR 减回去。

代码实战

技术栈:Spring Boot + StringRedisTemplate。

1. 定义一个工具类或者 Service

为了复用,我写了一个简单的 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);
        }
    }
}

2. 在业务层使用(Controller/Service)

提交任务阶段:

@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"); // 类型要对应
        }
    }
}

踩坑小结与优化

这套方案上线后,基本解决了用户疯狂提交任务导致通道堵塞的问题。但在实际运行中,还有几个细节值得注意:

  1. TTL 的设置技巧: 过期时间(TTL)一定要大于三方接口的最大超时时间。比如文生视频要 5 分钟,你的 TTL 最好设 10 分钟甚至更久。因为如果 TTL 设短了,Redis 里的 key 自动失效(归零),用户又能提交任务了,但实际上之前的任务还在跑,这就突破了限制。

  2. 应用重启导致计数归零? Redis 是独立存储的,应用重启不会影响 Redis 里的数据。但如果Redis 重启了怎么办? 对于这种非金融级业务,Redis 数据丢了就丢了,大不了用户能多提几个任务,影响不大。如果非要严谨,可以使用 Redis 的 AOF 持久化。

  3. Lua 脚本优化: 上面的 Java 代码中,incrementexpire 是分两步执行的。极端情况下(比如 increment 完服务挂了),这个 key 就没有过期时间了,会变成永久锁(直到 Redis 重启或人工干预)。 解决方案:把 INCR + EXPIRE 还有判断逻辑写成一个 Lua 脚本,保证原子性。但说实话,对于咱们这种漫剧业务,概率极低,Java 代码逻辑更易读,现在的实现已经足够用了。

总结

其实做系统设计,不一定非要上多么高大上的架构。在这个场景下,利用 Redis 简单的计数器功能,配合 TTL 兜底策略,就以最小的成本解决了 AIGC 接口调用慢带来的并发堆积问题。

既保护了钱包,也保护了系统的稳定性,搞定收工。


给你的建议(Technical Note):

  • Lua 脚本补充:如果你的面试官或者 Team Leader 比较在意代码严谨性,建议在博文中补充一段 Lua 脚本的实现,这样看起来更 Pro。

  • Key 的设计:建议加上 M:V (Manga:Video) 这种业务前缀,防止和其他业务冲突。

  • 用户体验:前端最好也做一个配合,如果后端返回“请求过多”,前端就把按钮置灰倒计时,别让用户一直猛点。

上一篇 下一篇
评论
来首音乐
光阴似箭
今日已经过去小时
这周已经过去
本月已经过去
今年已经过去个月
文章目录
每日一句