☰
Spring Boot 3 构建工业级 AI 生图管道:异步、限流与防刷实战
2026/10/5 14:22:10 网站建设 项目流程

1. 为什么要在 Spring Boot 3 里做 AI 生图管道

1.1 从一次线上事故说起

去年年底我接手了一个内部创意工具平台,核心功能是让运营同学输入一段中文描述,后台调用 AI 生图模型返回图片,用于活动海报、商品主图、社媒配图这些场景。最初版本是运营同学自己在某个网页端手动生成,再下载上传到素材库,效率低不说,风格还完全不统一。后来我们把它做成了内部系统,前端一个输入框,后端直接对接生图接口。

上线第一周就出事了。某个运营同学写了个脚本,循环调用我们的接口批量生成图片,一晚上跑了三万多张,账单直接爆掉,同时因为同步阻塞调用,整个服务的线程池被打满,其他业务接口全部超时。那次事故之后我才意识到,AI 生图这件事,技术难点根本不在"怎么调通接口",而在于"怎么把它做成一条可控、可限流、可观测的工业级管道"。

这也是我写这篇东西的原因。市面上讲 AI 生图的文章,绝大多数停留在"申请 key、发个请求、拿到图片 URL"这个层面,但真正把它放到生产环境里,你会发现一堆问题:同步调用把 Tomcat 线程占满、用户疯狂刷单、生成失败没有重试、图片存哪、怎么计费、怎么防止 prompt 注入……这些才是真正吃经验的地方。

1.2 技术选型的几个关键判断

先说为什么是 Spring Boot 3 而不是 Python 那套。很多人第一反应是"AI 相关的东西不是应该用 Python 吗",这个认知其实有偏差。Python 在模型训练、推理脚本、算法实验上确实无可替代,但生图接口的调用本质上是一次 HTTP 请求编排,它考验的是你的 Web 框架在高并发下的稳定性、事务管理、连接池、限流熔断这些工程能力。这块 Java 生态,尤其是 Spring Boot 3 配合虚拟线程,优势非常明显。

Spring Boot 3 有几个点是我特别看重的:

  • 虚拟线程(Virtual Threads):JDK 21 正式落地,Spring Boot 3.2 之后一行配置就能开启。生图接口是典型的 IO 密集型任务,一次调用动辄十几秒,用虚拟线程可以把线程占用成本降到极低,这是传统平台线程做不到的。
  • 原生支持可观测性:Micrometer + Actuator 开箱即用,生图这种长耗时任务,没有指标监控基本等于裸奔。
  • 声明式限流与重试生态成熟:Resilience4j、Bucket4j 这些库和 Spring Boot 3 集成度很高,防刷架构不用自己造轮子。

至于异步管道,我选的是Spring 的@Async+ 线程池 + 数据库任务表这套组合,而不是一上来就上 MQ。原因很简单:初期量级没到那个份上,引入 Kafka 或 RocketMQ 会增加运维成本和排查难度。用数据库任务表做状态机,配合定时补偿,足够撑到日均十万张的量级。等真的到了瓶颈再换 MQ,迁移成本也不高。

1.3 这条管道到底要解决什么问题

把需求拆开看,一条工业级 AI 生图管道至少要解决下面这几件事:

问题朴素做法工业级做法
调用耗时同步等待返回异步任务 + 轮询/回调
用户刷单无限制多维度限流 + 配额
生成失败直接报错分级重试 + 降级
图片存储存本地磁盘对象存储 + CDN
成本控制事后看账单实时计量 + 预算熔断
内容安全不处理prompt 审核 + 结果审核
可观测性打日志指标 + 链路追踪 + 告警

这张表基本就是整篇文章的骨架。下面我会一层一层拆开讲,每个环节都会给出可落地的代码和参数,以及我在实际踩坑之后总结出来的经验。

2. 核心架构设计与模块拆解

2.1 整体分层:接入层、编排层、执行层、存储层

我最终落地的架构分成四层,这个分层不是拍脑袋定的,而是根据"职责边界"和"故障隔离"两个原则划出来的。

接入层负责接收用户请求、鉴权、参数校验、限流。这一层必须极快,不能有任何阻塞操作,所有耗时逻辑全部往后丢。它的核心职责是"快速拒绝",把不合法的、超额的请求挡在门外。

编排层是整条管道的大脑,负责把一次生图请求拆解成任务、写入任务表、调度执行、管理状态流转。它不直接调用生图接口,而是通过任务状态机来驱动。

执行层是真正干活的地方,由一组异步 worker 组成,从任务表里捞取待执行任务,调用 AI 生图接口,处理返回结果,上传图片,回写状态。这一层是 IO 密集型的,用虚拟线程池最合适。

存储层包括任务表(MySQL)、图片对象存储、以及缓存(Redis,用于限流计数和幂等)。

分层的好处是故障隔离。比如生图接口挂了,接入层依然能正常接收请求并返回"任务已排队",用户体验不会崩;执行层可以独立扩容,不影响其他层。

2.2 任务状态机:整条管道的心脏

任务状态机是我认为整个设计里最值得花心思的部分。一个生图任务从创建到完成,会经历这些状态:

CREATED -> QUEUED -> RUNNING -> SUCCEEDED | +-> FAILED -> RETRYING -> RUNNING | +-> FAILED_FINAL

每个状态流转都要落库,并且带上时间戳和操作人(系统或用户)。这样做的好处是:

  • 可追溯:任何一张图什么时候生成的、重试了几次、失败原因是什么,一查便知。
  • 可补偿:定时任务扫描长时间停留在 RUNNING 的任务,判定为超时并重新入队。
  • 可计费:只有 SUCCEEDED 的任务才计入配额消耗,避免用户为失败任务买单。

这里有个坑我要提前说:状态流转一定要用乐观锁或者UPDATE ... WHERE status = ?这种带条件的更新,否则并发场景下会出现同一个任务被两个 worker 同时执行的情况。我一开始没注意,结果同一张图生成了两次,白白浪费了两次调用额度。

2.3 为什么用数据库任务表而不是直接上 MQ

这个问题我被问过很多次,我的回答是:看你的量级和团队规模。

数据库任务表的优势在于:实现简单、事务一致性强、排查方便(直接查表就能看到所有任务状态)、不需要额外运维中间件。缺点是轮询有延迟、高并发下数据库压力大。

MQ 的优势是吞吐高、解耦彻底、天然支持削峰。缺点是引入运维复杂度、消息丢失和重复消费需要额外处理、排查链路变长。

我的判断标准是:日均任务量低于 50 万,数据库任务表完全够用。超过这个量级,或者对延迟有极高要求(比如要求 1 秒内开始执行),再考虑上 MQ。我现在的系统日均 8 万张左右,数据库任务表跑得很稳,单表数据量控制在 500 万以内,配合归档策略没有任何性能问题。

2.4 防刷架构的三个维度

防刷这件事,单靠一个限流器是不够的。我把它拆成三个维度:

第一维度是频率限制。同一个用户、同一个 IP、同一个设备指纹,在单位时间内的请求次数要有上限。这里用 Redis 的滑动窗口或者令牌桶都行,我选的是 Bucket4j 配合 Redis 做分布式限流。

第二维度是配额管理。每个用户每天/每月能生成多少张图,这是业务层面的限制。配额和频率是两回事,频率管的是"瞬时压力",配额管的是"总量成本"。

第三维度是行为风控。这个最容易被忽略。比如同一个账号在凌晨三点突然高频调用、prompt 内容高度雷同、请求间隔极其规律,这些都是机器行为的特征。我加了一个简单的规则引擎,命中规则就降级处理或者直接拒绝。

这三个维度叠加起来,基本能挡住 95% 以上的刷单行为。剩下的靠人工审核和事后追责。

3. 核心细节解析与实操要点

3.1 接入层:参数校验与幂等设计

接入层的接口设计我遵循一个原则:请求进来先做幂等判断,再做限流,最后才落任务。

幂等这块,我要求客户端必须带一个requestId,服务端用 Redis 做SETNX,key 是idempotent:{userId}:{requestId},过期时间设 10 分钟。如果 key 已存在,直接返回上一次的任务 ID,不重复创建任务。这个设计能有效防止用户手抖连点或者网络重试导致的重复生成。

参数校验用 Jakarta Validation(Spring Boot 3 已经内置),重点校验 prompt 长度、图片尺寸、生成数量这些。prompt 长度我限制在 500 字符以内,太长的 prompt 不仅成本高,还容易被用来做注入攻击。

public record ImageGenRequest( @NotBlank @Size(max = 500) String prompt, @NotNull @Min(256) @Max(2048) Integer width, @NotNull @Min(256) @Max(2048) Integer height, @Min(1) @Max(4) Integer count, @NotBlank String requestId ) {}

注意:count这个字段一定要限制上限。我见过有人不限制,用户一次请求 100 张,直接把配额打爆。单次最多 4 张是个比较合理的值。

3.2 限流器:Bucket4j + Redis 的分布式实现

单机限流用 Guava RateLimiter 就够了,但我们是多实例部署,必须用分布式限流。Bucket4j 提供了 Redis 后端的支持,配置起来不复杂。

核心思路是:每个用户一个桶,桶的容量和补充速率根据用户等级动态调整。普通用户每分钟 5 次,VIP 用户每分钟 20 次。桶的 key 是ratelimit:{userId}。

@Component public class RateLimitService { private final RedissonClient redissonClient; public boolean tryAcquire(Long userId, int capacity, int refillPerMinute) { RRateLimiter limiter = redissonClient.getRateLimiter("ratelimit:" + userId); limiter.trySetRate(RateType.OVERALL, capacity, Duration.ofMinutes(1), RateIntervalUnit.MINUTES); return limiter.tryAcquire(1); } }

这里有个细节:trySetRate只在 key 不存在时生效,所以不用担心每次调用都重置。但要注意,如果用户等级变了,需要主动删除 key 让它重新初始化。

实操心得:限流的粒度不要只按 userId。我加了 IP 维度和设备指纹维度作为补充,因为有些刷单是注册大量小号来绕过的。三个维度任意一个超限就拒绝,效果比单维度好很多。

3.3 任务表设计:字段与索引的取舍

任务表是整个系统的核心,字段设计要兼顾查询效率和存储成本。我的表结构大致是这样:

字段类型说明
idbigint主键,雪花 ID
user_idbigint用户 ID
request_idvarchar(64)幂等 ID
promptvarchar(500)提示词
paramsjson尺寸、数量等参数
statustinyint状态枚举
retry_counttinyint重试次数
result_urlsjson结果图片地址
error_msgvarchar(500)失败原因
cost_creditsint消耗配额
created_atdatetime创建时间
updated_atdatetime更新时间

索引方面,我建了三个:idx_user_created(用户查自己的任务)、idx_status_updated(worker 捞任务和超时补偿)、idx_request_id(幂等查询)。这三个索引覆盖了 99% 的查询场景。

注意:prompt字段不要建索引,也不要全文检索。生图 prompt 的查询需求极低,建索引纯属浪费。如果真要做内容分析,走离线数仓。

3.4 异步执行:虚拟线程池的正确打开方式

Spring Boot 3.2 之后开启虚拟线程非常简单,一行配置:

spring: threads: virtual: enabled: true

但这里有个坑:开启虚拟线程后,@Async默认用的还是平台线程池,你需要显式配置一个虚拟线程执行器。

@Configuration public class AsyncConfig { @Bean("imageGenExecutor") public AsyncTaskExecutor imageGenExecutor() { return new TaskExecutorAdapter( Executors.newVirtualThreadPerTaskExecutor() ); } }

然后在@Async("imageGenExecutor")上指定这个执行器。

虚拟线程的好处是,你可以放心地创建大量并发任务,不用担心线程栈内存。但要注意,虚拟线程不适合 CPU 密集型任务,而生图接口调用是纯 IO 等待,正好是它的主场。

实操心得:虚拟线程配合Semaphore做并发控制是个好组合。虽然虚拟线程很轻,但下游生图接口有并发上限,用信号量限制同时在跑的任务数,避免把下游打挂。

4. 完整实操流程与关键环节实现

4.1 从请求到任务落库的完整链路

用户发起一次生图请求,到任务落库,中间经历了这些步骤:

  1. 网关鉴权:校验 token,解析出 userId。
  2. 幂等检查:Redis SETNX,命中则返回已有任务。
  3. 参数校验:Jakarta Validation 校验字段合法性。
  4. 内容审核:调用文本审核接口,检查 prompt 是否违规。
  5. 频率限流:Bucket4j 三维度检查。
  6. 配额检查:查询用户剩余配额,不足则拒绝。
  7. 任务落库:写入任务表,状态为 QUEUED。
  8. 返回任务 ID:立即返回,不等待生成。

整个链路除了内容审核那一步(通常 100ms 以内),其他都是毫秒级操作。用户拿到任务 ID 后,前端轮询查询任务状态。

这里我要强调内容审核必须在落库之前做。我见过有系统把审核放在生成之后,结果违规图片已经生成出来了才拦截,成本已经花掉了。前置审核虽然会误杀一些正常 prompt,但成本控制上划算得多。

4.2 Worker 捞取任务的两种策略

Worker 从任务表捞取待执行任务,有两种常见策略:

策略一:轮询拉取。Worker 每隔固定时间(比如 1 秒)查询一次status = QUEUED的任务,用LIMIT限制数量,配合FOR UPDATE SKIP LOCKED避免多 worker 抢同一批任务。

策略二:事件驱动。任务落库后发一个事件(本地事件或 Redis 消息),Worker 监听事件立即执行。

我选的是轮询拉取为主,事件驱动为辅的混合策略。轮询保证可靠性(即使事件丢了也能捞到),事件驱动降低延迟(新任务秒级开始执行)。

SELECT * FROM image_task WHERE status = 'QUEUED' ORDER BY id ASC LIMIT 20 FOR UPDATE SKIP LOCKED;

SKIP LOCKED是 MySQL 8.0 的特性,能让多个 worker 并行捞取而不互相阻塞,这个特性对任务队列场景简直是量身定做。

注意:捞取任务后要立即把状态更新为 RUNNING,并且记录 worker 标识。这样超时补偿任务才能判断哪些任务卡住了。

4.3 调用生图接口的重试与降级

生图接口调用失败是常态,网络抖动、下游限流、模型排队都会导致失败。我的重试策略是指数退避 + 最大次数限制。

第一次失败后等 2 秒重试,第二次等 4 秒,第三次等 8 秒,最多重试 3 次。超过 3 次标记为 FAILED_FINAL,退还用户配额。

@Retryable( retryFor = {ImageGenException.class}, maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 2) ) public ImageResult callImageApi(ImageGenTask task) { // 调用生图接口 }

降级策略方面,如果生图接口整体不可用(比如连续 10 次调用全部失败),触发熔断,后续任务直接标记为"系统繁忙,请稍后重试",避免无效调用继续消耗资源。Resilience4j 的 CircuitBreaker 可以很好地实现这个。

实操心得:重试一定要区分错误类型。网络超时、5xx 错误可以重试;4xx 错误(比如 prompt 违规、参数错误)重试没有意义,直接标记失败。我一开始没区分,结果违规 prompt 被重试了三次,白白浪费了三次调用。

4.4 图片存储与 CDN 加速

生图接口返回的通常是临时 URL,有效期可能只有几十分钟。必须第一时间把图片转存到自己的对象存储,否则 URL 过期后用户就看不到图了。

我的做法是:worker 拿到临时 URL 后,立即下载图片流,上传到对象存储(我用的是兼容 S3 协议的服务),然后把永久 URL 回写到任务表。整个过程在 worker 内完成,不经过应用服务器磁盘。

存储路径我按{userId}/{yyyyMM}/{taskId}.png组织,方便按用户和时间归档。CDN 加速这块,对象存储一般自带,配置好回源即可。

注意:下载临时 URL 时一定要设置超时时间,我设的是 30 秒。有次下游返回的 URL 指向一个响应极慢的地址,worker 卡在那里不动,导致整个队列积压。加了超时之后就没这个问题了。

4.5 配额计量与成本熔断

配额计量要和任务状态绑定。任务 SUCCEEDED 时才扣减配额,FAILED_FINAL 时退还预扣的配额。我采用的是预扣 + 结算的模式:任务创建时先预扣配额,成功则确认扣减,失败则退还。

成本熔断是最后一道防线。我设置了一个全局的日成本上限,当天的累计消耗达到上限的 80% 时告警,达到 100% 时自动停止接收新任务,只处理已排队的任务。这个机制救过我好几次,尤其是在被刷单的时候。

public boolean checkBudget() { Long todayCost = redisTemplate.opsForValue().get("cost:" + today); return todayCost == null || todayCost < DAILY_BUDGET_LIMIT; }

5. 常见问题与排查技巧实录

5.1 任务卡在 RUNNING 状态怎么办

这是最常见的问题。原因通常是 worker 执行过程中崩溃了,或者下游接口长时间不返回。我的解决方案是超时补偿任务:定时扫描status = RUNNING AND updated_at < now() - 5min的任务,把它们重新置为 QUEUED,让其他 worker 重新执行。

但这里要注意,重新执行前要检查retry_count,超过上限的直接标记 FAILED_FINAL。否则一个永远失败的任务会无限循环。

5.2 用户反馈"生成了但看不到图"

这个问题排查下来通常是两个原因:一是图片转存失败但任务状态被错误地标记为 SUCCEEDED;二是 CDN 缓存了旧的 404 响应。

第一个问题的修复是:转存成功后才更新任务状态,转存失败要抛异常触发重试。第二个问题需要在 CDN 配置里对 404 响应设置较短的缓存时间。

5.3 限流误伤正常用户

限流阈值设得太严会误伤。我的经验是:先观察一周的真实流量分布,取 P99 作为阈值参考。比如 99% 的用户每分钟请求不超过 3 次,那阈值设 5 次就比较安全。同时要给 VIP 用户留出更高的配额,避免影响核心业务。

5.4 常见问题速查表

现象可能原因排查方向解决方案
任务一直 QUEUEDworker 挂了检查 worker 日志和心跳重启 worker,检查线程池
任务卡 RUNNINGworker 崩溃查 updated_at 时间超时补偿任务重新入队
图片 404转存失败查对象存储日志转存成功后再更新状态
配额不扣减状态流转异常查任务状态机日志修复状态流转逻辑
限流误伤阈值过低分析流量分布调整阈值,分级限流
成本超支刷单或预算失控查用户调用分布加强风控,设置熔断

5.5 几个我踩过的坑

坑一:忘记处理下游返回的临时 URL 过期。早期版本直接把临时 URL 存库返回给前端,结果用户过半小时再看就 404 了。后来改成必须转存,问题解决。

坑二:重试没有幂等。有次下游接口超时但实际执行成功了,我们重试又生成了一张,用户拿到两张图。后来在调用下游时带上我们自己的 taskId 作为幂等键,下游去重。

坑三:日志打太多导致磁盘爆满。生图任务的 prompt 和返回结果都很大,全量打日志很快就撑爆磁盘。后来改成只打关键字段,完整内容存到对象存储,日志里只放引用。

坑四:虚拟线程和 synchronized 一起用导致 pinning。虚拟线程遇到 synchronized 块会被"钉"在载体线程上,失去虚拟线程的优势。后来把关键路径上的 synchronized 换成了 ReentrantLock。

6. 后续可以继续扩展的方向

这套管道跑了大半年,整体很稳。如果后面要继续演进,我大概会往这几个方向走:

一是引入 MQ 替换数据库任务表。等日均任务量突破 50 万,数据库轮询会成为瓶颈,那时候上 Kafka 做削峰和解耦是顺理成章的。

二是做多模型路由。现在只对接了一个生图模型,未来可以接入多个模型,根据 prompt 类型、成本预算、质量要求动态路由到最合适的模型。

三是加一层 prompt 优化。用户输入的 prompt 往往很粗糙,可以在调用生图接口前用一个小模型做 prompt 改写和增强,提升出图质量。这块我还在实验阶段,效果好的话再单独写一篇。

四是完善可观测性。现在有基础的指标和告警,但链路追踪还不够细。计划接入 OpenTelemetry,把从请求到出图的完整链路串起来,排查问题会更快。

这套东西说到底,核心不是某个技术点有多难,而是把每个环节的边界想清楚,把异常情况都考虑到。生图接口调用本身很简单,难的是让它在一个真实的生产环境里稳定、可控、可计量地跑下去。我踩过的这些坑,希望你能绕过去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询