
1. 为什么要在 Spring Boot 3 里自建 AI 生图管道1.1 从“调个接口”到“工业级管道”的认知转变很多人第一次接触 AI 生图脑子里想的都是“不就是发个 HTTP 请求把 prompt 丢过去等图片回来存一下”。我一开始也这么想直到真正把它放到生产环境里跑才发现事情远没有这么简单。一个能用的生图功能和一条能扛住真实流量的生图管道中间隔着的不是几行代码而是一整套关于异步、限流、重试、存储、成本控制的工程体系。gpt-image-2.5这类图像生成模型的特点是单次调用耗时波动极大快的时候几秒慢的时候几十秒甚至超时单次成本不低被恶意刷接口就是真金白银的损失返回的是二进制图片数据直接塞进数据库或者同步返回给前端都是灾难。所以标题里说的“工业级”和“防刷架构”不是噱头而是被现实逼出来的必需品。这篇文章面向的是已经会用 Spring Boot 写 CRUD、但对 AI 生图工程化还没有完整认知的后端开发者。我会把整条管道拆开从请求入口、任务排队、异步执行、结果存储到防刷限流一层一层讲清楚每个环节为什么这么设计以及我在实际落地时踩过的坑。你不需要事先了解任何图像模型的内部原理只要会写 Spring Boot就能跟着把这条管道搭起来。1.2 工业级生图管道到底要解决哪些问题先把需求摊开来看一条合格的生图管道至少要同时满足下面几件事缺一个都会在上线后出问题。响应时间不可控模型推理时间不稳定同步阻塞会拖垮 Tomcat 线程池必须异步化。并发压力集中营销活动或者被爬虫盯上时请求量会瞬间暴涨必须有排队和限流。成本必须可控每次生图都是钱要能识别并拦截异常高频的调用来源。结果需要持久化图片不能只存在内存里要有可靠的存储和访问方式。失败要能自愈网络抖动、模型侧限流都会导致失败要有重试和降级策略。状态要可追踪用户提交任务后要能查询进度不能提交完就石沉大海。这六点对应到技术选型上就是异步任务队列、令牌桶限流、Redis 计数、对象存储、重试机制和任务状态表。下面我会逐个展开把每个选择的理由讲透。1.3 整体架构分层与数据流转在动手写代码之前先把整条链路在脑子里过一遍。我采用的是经典的分层结构从上到下依次是接入层、任务层、执行层和存储层。接入层负责接收 HTTP 请求做参数校验和初步的防刷判断然后把任务丢进队列就立刻返回一个任务 ID绝不在这里等模型结果。任务层用 Redis 维护任务状态和排队信息同时承担限流计数的职责。执行层是真正调用gpt-image-2.5的地方用独立的线程池消费任务控制并发度。存储层把生成的图片落到对象存储元数据落到数据库。数据流转是这样的用户 POST 一个生图请求接入层校验通过后生成全局唯一 taskId写入 Redis 状态为PENDING同时把任务推入队列立即返回 taskId。执行层的消费者从队列取出任务状态改为PROCESSING调用模型接口拿到图片后上传对象存储把 URL 和元数据写库状态改为SUCCESS。用户拿着 taskId 轮询查询接口就能拿到最终结果。这个设计里最关键的一点是接入与执行彻底解耦。接入层永远快执行层永远稳两者通过队列这个缓冲带隔开任何一方的抖动都不会直接传导给另一方。2. 核心组件选型与防刷架构设计思路2.1 为什么用 Redis 而不是数据库做任务队列任务队列的选型上我一开始考虑过直接用数据库表轮询也考虑过引入专业的消息中间件。最后选了 Redis理由很实在。数据库轮询的问题在于高频轮询会给数据库带来持续压力而且任务状态更新和队列消费耦合在一起锁竞争严重。专业消息中间件功能强大但对于一个中小规模的生图服务来说运维成本偏高有点杀鸡用牛刀。Redis 的 List 结构天然适合做简单的先进先出队列配合 Hash 存任务状态一套 Redis 就把队列和状态管理都解决了部署简单性能足够。具体来说我用LPUSH把任务推入队列执行层用BRPOP阻塞式取出这样消费者在没有任务时会阻塞等待不会空转浪费 CPU。任务状态则单独用一个 Hash 存储key 是task:{taskId}field 包括状态、创建时间、重试次数、结果 URL 等。这样查询任务状态和消费队列互不干扰。注意Redis 做队列在极端情况下有丢消息的风险如果你的业务对任务可靠性要求极高建议开启 AOF 持久化或者用 Redis Stream 替代 List后者支持消费确认机制。2.2 令牌桶限流与滑动窗口计数的组合拳防刷是这条管道的重头戏。我采用的是两层防护第一层是令牌桶限流控制整体请求速率第二层是滑动窗口计数识别单个用户的异常行为。令牌桶用 Redis 实现核心思路是维护一个令牌桶按固定速率往里面放令牌每个请求消耗一个令牌桶空了就拒绝。这个方案的好处是既能限制平均速率又能容忍一定程度的突发流量。比如我设置桶容量为 100每秒补充 20 个令牌那么平时每秒 20 个请求能稳定通过偶尔来一波 100 个的突发也能扛住但持续超速就会被拦。滑动窗口计数则是针对单个用户维度的。我用 Redis 的 ZSet 记录每个用户最近的请求时间戳每次请求前先清理掉窗口外的记录然后统计窗口内的请求数超过阈值就判定为异常。相比固定窗口计数滑动窗口不会出现窗口边界处请求量翻倍的问题判断更准确。两层防护的分工很明确令牌桶保护的是系统整体不被压垮滑动窗口保护的是成本不被单个恶意用户刷爆。两者缺一不可。2.3 任务状态机的设计与幂等保证任务状态不能随便乱改必须有一个清晰的状态机来约束流转。我定义的状态有五个PENDING已入队、PROCESSING执行中、SUCCESS成功、FAILED失败、REJECTED被限流拒绝。状态流转规则是单向的PENDING只能到PROCESSING或REJECTEDPROCESSING只能到SUCCESS或FAILED终态不允许再变更。每次更新状态前都要先检查当前状态是否允许流转防止并发场景下状态被覆盖。幂等性方面taskId 在生成时就保证了全局唯一执行层消费任务时会先检查状态如果发现任务已经是SUCCESS或PROCESSING就直接跳过避免重复执行。这个检查用 Redis 的SETNX配合状态判断来实现确保同一个任务不会被消费两次。2.4 对象存储与元数据分离的存储策略图片数据绝对不能直接存数据库。一张 1024x1024 的 PNG 动辄一两兆存进 MySQL 会让数据库迅速膨胀备份和查询都会变慢。我的做法是图片走对象存储数据库只存元数据。对象存储我用的是兼容 S3 协议的服务上传后拿到一个 URL把这个 URL 连同 prompt、尺寸、生成耗时、用户标识等元数据一起写进数据库表。这样数据库表很轻查询快图片的访问则交给对象存储的 CDN 能力去扛。元数据表的设计上我建了必要的索引taskId 唯一索引用于查询用户标识加创建时间的联合索引用于统计和风控分析。表字段尽量精简避免大字段保证单表查询性能。3. 从零搭建核心代码与配置实操3.1 项目依赖与基础配置先看依赖。Spring Boot 3 要求 Java 17 起步这一点要注意别用 Java 8 硬上。核心依赖包括 Web、Redis、以及对象存储的 SDK。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency dependency groupIdsoftware.amazon.awssdk/groupId artifactIds3/artifactId version2.25.0/version /dependency /dependencies配置文件里把 Redis 和对象存储的连接信息配好。这里有个经验Redis 的连接池参数一定要调默认配置在高并发下很容易出现连接等待。spring: data: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 2000ms storage: endpoint: https://your-object-storage-endpoint bucket: ai-image-bucket access-key: your-access-key secret-key: your-secret-keymax-active设成 50 是根据我的并发量估算的你可以根据自己的 QPS 调整。经验公式是连接数 ≈ 平均 QPS × 平均单次操作耗时秒× 安全系数 1.5。比如 QPS 是 100单次 Redis 操作 5 毫秒那 100 × 0.005 × 1.5 ≈ 0.75理论上 1 个连接就够但考虑到突发和阻塞实际给到 20 到 50 比较稳妥。3.2 请求接入层与参数校验接入层的 Controller 要做得足够轻。它只做三件事校验参数、防刷判断、入队返回。RestController RequestMapping(/api/image) public class ImageController { private final ImageTaskService taskService; public ImageController(ImageTaskService taskService) { this.taskService taskService; } PostMapping(/generate) public ResponseEntityTaskResponse generate(RequestBody Valid GenerateRequest request, HttpServletRequest httpRequest) { String clientId resolveClientId(httpRequest); String taskId taskService.submitTask(request, clientId); return ResponseEntity.ok(new TaskResponse(taskId, PENDING)); } }参数校验用 Jakarta Validation 注解prompt 长度、尺寸枚举、数量范围都要卡死。这里有个坑prompt 一定要限制最大长度我见过有人传几万字的 prompt 进来不仅浪费 token还可能触发模型侧的异常。我一般限制在 1000 字符以内。resolveClientId这个方法负责识别调用方身份可以基于登录用户 ID也可以基于 IP 加设备指纹。生产环境建议用登录用户 ID匿名场景再用 IP 兜底因为 IP 容易被代理池绕过。3.3 防刷拦截器的实现细节防刷逻辑我封装成一个独立的组件在提交任务前调用。Component public class RateLimitGuard { private final StringRedisTemplate redisTemplate; private static final int BUCKET_CAPACITY 100; private static final int REFILL_RATE 20; private static final int WINDOW_SECONDS 60; private static final int USER_LIMIT 10; public RateLimitGuard(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public boolean allowRequest(String clientId) { return checkTokenBucket() checkSlidingWindow(clientId); } }令牌桶的实现用 Lua 脚本保证原子性这是关键。如果用 Java 代码分多步操作 Redis在高并发下会出现竞态条件导致限流失效。private static final String TOKEN_BUCKET_SCRIPT local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local tokens redis.call(hget, key, tokens) local lastTime redis.call(hget, key, lastTime) if tokens false then tokens capacity lastTime now end local delta math.max(0, now - lastTime) tokens math.min(capacity, tokens delta * rate) if tokens 1 then tokens tokens - 1 redis.call(hset, key, tokens, tokens, lastTime, now) return 1 else redis.call(hset, key, tokens, tokens, lastTime, now) return 0 end;滑动窗口用 ZSet 实现每次请求前先移除窗口外的成员再统计当前成员数。public boolean checkSlidingWindow(String clientId) { String key ratelimit:window: clientId; long now System.currentTimeMillis(); long windowStart now - WINDOW_SECONDS * 1000L; redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); Long count redisTemplate.opsForZSet().zCard(key); if (count ! null count USER_LIMIT) { return false; } redisTemplate.opsForZSet().add(key, String.valueOf(now), now); redisTemplate.expire(key, Duration.ofSeconds(WINDOW_SECONDS 10)); return true; }注意滑动窗口的 ZSet 一定要设置过期时间否则冷用户的数据会一直堆积在 Redis 里时间长了内存会被撑爆。我设置的是窗口时间加 10 秒留一点缓冲。3.4 异步任务队列与线程池配置任务入队很简单用LPUSH推入即可。执行层的线程池配置才是重点配不好要么浪费资源要么把模型接口打爆。Configuration public class ExecutorConfig { Bean(imageTaskExecutor) public ThreadPoolTaskExecutor imageTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(200); executor.setThreadNamePrefix(image-task-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; } }核心线程数设成 5是因为模型接口本身有并发限制开太多线程只会导致大量请求被模型侧拒绝。这个数字要根据你实际拿到的配额来定我建议从 5 开始观察模型侧的响应情况再调整。队列容量 200 是缓冲带超过就触发拒绝策略。拒绝策略我选的是CallerRunsPolicy意思是队列满了之后由提交任务的线程自己执行。这样做的目的是形成背压让上游的接入层慢下来而不是直接丢弃任务。对于生图这种用户有明确预期的场景宁可让用户多等一会也不要静默丢任务。3.5 调用 gpt-image-2.5 的执行器封装执行器是真正干活的地方。它从队列取出任务调用模型接口处理结果。Component public class ImageTaskExecutor { private final StringRedisTemplate redisTemplate; private final ImageModelClient modelClient; private final StorageService storageService; private final ImageTaskRepository taskRepository; Async(imageTaskExecutor) public void execute(String taskId) { if (!markProcessing(taskId)) { return; } try { ImageTask task loadTask(taskId); byte[] imageBytes modelClient.generate(task.getPrompt(), task.getSize()); String url storageService.upload(imageBytes, taskId); markSuccess(taskId, url); } catch (Exception e) { handleFailure(taskId, e); } } }markProcessing方法用SETNX保证同一个任务只被处理一次这是幂等性的关键。如果返回 false说明任务已经被其他线程处理了直接返回。模型调用的客户端要设置合理的超时时间。我设置的是连接超时 5 秒读取超时 60 秒。读取超时给到 60 秒是因为生图确实慢给太短会误杀正常请求。同时要配置重试但重试次数不能多我一般设 2 次且只对网络类异常重试对参数错误这类业务异常不重试。3.6 结果存储与状态回写图片上传到对象存储后拿到 URL然后回写状态。这里要注意顺序先上传成功再改状态。如果先改状态再上传上传失败就会导致状态和实际不符。private void markSuccess(String taskId, String url) { String key task: taskId; redisTemplate.opsForHash().put(key, status, SUCCESS); redisTemplate.opsForHash().put(key, resultUrl, url); redisTemplate.expire(key, Duration.ofHours(24)); ImageTaskRecord record new ImageTaskRecord(); record.setTaskId(taskId); record.setResultUrl(url); record.setStatus(SUCCESS); taskRepository.save(record); }Redis 里的任务状态设置 24 小时过期因为用户查询通常集中在提交后的几分钟内24 小时足够覆盖。数据库里的记录则长期保留用于统计和审计。4. 上线后踩过的坑与排查实录4.1 任务状态卡在 PROCESSING 的排查上线第一周就遇到一个问题部分任务状态一直卡在PROCESSING既不成功也不失败。排查下来发现是执行线程在调用模型接口时抛了非受检异常而我的 catch 块只捕获了 Exception 的子类漏掉了某些 Error 级别的异常导致状态回写逻辑没执行。解决办法是在execute方法外层加一个finally块兜底无论发生什么都检查一次状态如果还是PROCESSING就强制标记为FAILED。同时给任务加一个超时机制超过 5 分钟还是PROCESSING的任务由定时任务扫描出来标记为失败。Scheduled(fixedDelay 60000) public void scanStuckTasks() { SetString keys redisTemplate.keys(task:*); long now System.currentTimeMillis(); for (String key : keys) { Object status redisTemplate.opsForHash().get(key, status); Object createTime redisTemplate.opsForHash().get(key, createTime); if (PROCESSING.equals(status) createTime ! null) { long elapsed now - Long.parseLong(createTime.toString()); if (elapsed 5 * 60 * 1000L) { redisTemplate.opsForHash().put(key, status, FAILED); } } } }注意redisTemplate.keys()在生产环境要慎用数据量大时会阻塞 Redis。更好的做法是维护一个单独的 ZSet按时间戳存储进行中的任务扫描时只查这个 ZSet。4.2 限流误伤正常用户的调整过程防刷上线后有用户反馈正常使用也被拦了。查日志发现是滑动窗口的阈值设得太低而且窗口是按 IP 算的同一个办公室的用户共用一个出口 IP很容易触发限制。调整方案有两个一是把阈值从 10 提高到 30给正常用户留足空间二是优先用登录用户 ID 做维度只有在拿不到用户 ID 时才降级用 IP。改完之后误伤率明显下降。这里的心得是限流阈值不能拍脑袋定要基于真实的用户行为数据来调上线初期宁可宽松一点观察一段时间再收紧。4.3 模型接口超时与重试策略优化模型接口偶尔会超时一开始我的重试策略是无脑重试 3 次结果发现有些请求重试后反而加重了模型侧的负担形成恶性循环。优化后的策略是只对连接超时和 5xx 错误重试重试间隔采用指数退避第一次等 1 秒第二次等 2 秒第三次等 4 秒。同时引入熔断机制如果连续 10 次调用失败率超过 50%就暂时熔断 30 秒期间所有请求直接返回失败给模型侧喘息时间。异常类型是否重试重试次数退避策略连接超时是2指数退避读取超时是1固定 2 秒4xx 参数错误否0无5xx 服务错误是2指数退避限流 429是3指数退避4.4 常见问题速查表把上线以来遇到的问题整理成一张表方便快速定位。现象可能原因排查方向解决方案任务一直 PENDING消费者未启动或队列积压检查线程池状态和队列长度扩容消费者或清理积压任务卡 PROCESSING执行异常未回写状态查执行日志和超时扫描加 finally 兜底和超时扫描正常用户被限流阈值过低或维度不合理查限流日志和用户标识调高阈值改用用户 ID图片上传失败对象存储配置错误检查密钥和桶权限修正配置加重试Redis 内存暴涨任务 key 未设过期查 key 数量和内存占用统一设置过期时间模型调用频繁超时并发过高或网络抖动查模型侧响应时间和并发数降并发加熔断5. 性能压测与容量规划经验5.1 压测方案与关键指标管道搭好之后一定要压测不然你不知道它的极限在哪里。我用 JMeter 模拟了 500 并发用户持续提交任务观察几个关键指标接入层的响应时间、队列积压长度、执行成功率、以及 Redis 的内存占用。压测结果很有意思。接入层因为只做入队响应时间稳定在 20 毫秒以内完全不受后端影响。队列在并发上来后迅速积压最高到了 180 左右接近我设置的 200 容量。执行成功率在并发 300 以下时保持在 99% 以上超过 300 后开始下降主要是模型侧开始返回限流错误。这个结果告诉我瓶颈不在我的管道本身而在模型侧的配额。所以容量规划的核心不是加机器而是根据模型配额来反推我能承载的并发。5.2 容量估算的实际计算方法容量估算我总结了一个简单的公式可持续并发数 模型侧每秒允许的调用数 × 平均单次生成耗时。假设模型侧允许每秒 10 次调用平均每次生成耗时 15 秒那么理论上同时可以有 10 × 15 150 个任务在执行。但这是理想值实际要打个七折因为耗时会有波动留出缓冲。所以我的执行线程池最大线程数设在 100 左右比较合适队列容量则根据用户能接受的等待时间来定。如果用户能接受最长等待 2 分钟每秒进来 10 个任务那队列容量至少要能缓冲 10 × 120 1200 个任务。但队列太长也不是好事用户等太久体验差所以更好的做法是在队列超过一定长度时直接拒绝新请求返回“当前繁忙请稍后再试”。5.3 成本控制与异常用量监控生图是花钱的买卖成本监控必须做。我在数据库里记录了每个任务的用户标识和生成时间然后写了一个定时统计任务每小时统计一次各用户的调用量超过阈值的自动加入观察名单。同时设置了一个全局的日调用量上限达到上限后自动降级只允许白名单用户继续调用。这个兜底机制救过我一次有天晚上被爬虫刷了日调用量在凌晨就逼近上限降级机制触发后及时止损。监控指标上我重点关注三个单位时间调用量、调用失败率、以及平均生成耗时。这三个指标任何一个异常波动都可能是被刷或者模型侧出问题的信号。6. 后续可扩展的方向与个人体会6.1 从单模型到多模型路由现在管道里只接了gpt-image-2.5一个模型后续可以扩展成多模型路由。比如根据 prompt 的类型自动选择最合适的模型或者在一个模型不可用时自动切换到备用模型。实现上就是在执行层加一个路由组件根据配置和模型健康状态决定调用哪个。这个扩展的价值在于提高可用性和灵活性。单一模型一旦出问题整个服务就挂了多模型路由能显著提升鲁棒性。不过要注意不同模型的接口协议和返回格式可能不一样需要做一层适配。6.2 任务优先级与VIP通道设计目前所有任务都是先进先出没有优先级区分。后续可以引入优先级队列给付费用户或者重要业务开VIP通道。实现上可以用多个 Redis List 分别对应不同优先级消费者按优先级顺序取任务。这个设计要小心低优先级任务不能被饿死。我的想法是给每个优先级设置一个权重消费者按权重比例取任务比如高优先级取 3 个低优先级取 1 个这样既保证了VIP体验又不会让普通用户等太久。6.3 我在实际落地中的几点体会最后说几句掏心窝的话。这条管道我从零搭到稳定运行前后迭代了三个版本最大的体会是不要试图一步到位。第一版能跑通就行哪怕同步阻塞、没有防刷先让功能可用。然后根据真实流量暴露出来的问题一个一个补。异步化是被慢请求逼出来的防刷是被刷出来的重试是被超时逼出来的。每个设计都有它对应的现实痛点脱离实际场景空谈架构没有意义。另外日志和监控一定要在早期就做好。我第一版没做任务状态追踪出了问题只能靠猜排查效率极低。后来加了完整的日志和状态查询接口定位问题的时间从小时级降到了分钟级。这个投入绝对值得。还有一点限流和防刷的阈值不要一次定死要留出调整空间。我现在的做法是把阈值放在配置中心可以随时调整不用重启。上线初期宽松一点观察真实数据后再逐步收紧这样既能防住恶意流量又不会误伤正常用户。